diff --git a/.gitignore b/.gitignore index cd2e9d097c0..2935832db3b 100644 --- a/.gitignore +++ b/.gitignore @@ -89,9 +89,9 @@ website/static/api/skills-index.json # every build). website/static/api/skills.json website/static/api/skills-meta.json -# cron-recipes-index.json is a build artifact emitted by -# website/scripts/extract-cron-recipes.py during prebuild. -website/static/api/cron-recipes-index.json +# automation-blueprints-index.json is a build artifact emitted by +# website/scripts/extract-automation-blueprints.py during prebuild. +website/static/api/automation-blueprints-index.json models-dev-upstream/ # Local editor / agent tooling (machine-specific; keep in global config, not the repo) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index eb78830da47..bfa5e178d2f 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -6112,7 +6112,7 @@ ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketpla ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20)) // --------------------------------------------------------------------------- -// hermes:// deep links (e.g. hermes://cron-recipe/morning-brief?time=08:00). +// hermes:// deep links (e.g. hermes://blueprint/morning-brief?time=08:00). // A docs/dashboard "Send to App" button opens this URL; we route it into the // running app's chat composer. Three delivery paths: macOS 'open-url', // Win/Linux running-app 'second-instance' (argv), Win/Linux cold-start argv. @@ -6135,7 +6135,7 @@ function handleDeepLink(url) { rememberLog(`[deeplink] ignoring malformed url: ${url}`) return } - // hermes://cron-recipe/?slot=val -> host="cron-recipe", path="/" + // hermes://blueprint/?slot=val -> host="blueprint", path="/" const kind = parsed.hostname || '' const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, '')) const params = {} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 43b8ab4c900..f04ade8f80e 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -267,14 +267,14 @@ export function DesktopController() { } }, []) - // hermes:// deep links (e.g. a docs "Send to App" button for a cron recipe). - // Build the equivalent /cron-recipe slash command from the payload and drop + // hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint). + // Build the equivalent /blueprint slash command from the payload and drop // it into the composer — the user reviews/edits, then sends; the agent (or // the shared command handler) creates the job. Signal readiness so a link // that arrived during boot is flushed exactly once. useEffect(() => { const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => { - if (!payload || payload.kind !== 'cron-recipe' || !payload.name) { + if (!payload || payload.kind !== 'blueprint' || !payload.name) { return } const slots = Object.entries(payload.params || {}) @@ -283,7 +283,7 @@ export function DesktopController() { return `${k}=${sval}` }) .join(' ') - const command = `/cron-recipe ${payload.name}${slots ? ' ' + slots : ''}` + const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}` requestComposerInsert(command, { mode: 'block', target: 'main' }) requestComposerFocus('main') }) diff --git a/cli.py b/cli.py index 40d5d816caf..4ae4c6f7029 100644 --- a/cli.py +++ b/cli.py @@ -3504,7 +3504,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # the next submitted input, whether it's the selection or anything # else). See #34584. self._pending_resume_sessions = None - # One-shot agent seed set by a slash handler (e.g. /cron-recipe ) + # One-shot agent seed set by a slash handler (e.g. /blueprint ) # that wants its output run as the next agent turn. Consumed and cleared # by the interactive loop immediately after process_command() returns. self._pending_agent_seed = None @@ -7415,8 +7415,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._handle_cron_command(cmd_original) elif canonical == "suggestions": self._handle_suggestions_command(cmd_original) - elif canonical == "cron-recipe": - self._handle_cron_recipe_command(cmd_original) + elif canonical == "blueprint": + self._handle_blueprint_command(cmd_original) elif canonical == "curator": self._handle_curator_command(cmd_original) elif canonical == "kanban": @@ -12837,7 +12837,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): _cprint("\n[dim]Command interrupted.[/dim]") continue # A slash handler may set a one-shot pending seed (e.g. - # /cron-recipe ) to be run as the next agent turn. + # /blueprint ) to be run as the next agent turn. # If present, fall through to the chat path with the seed # as the user message instead of looping back to idle. _seed = getattr(self, "_pending_agent_seed", None) diff --git a/cron/recipe_catalog.py b/cron/blueprint_catalog.py similarity index 80% rename from cron/recipe_catalog.py rename to cron/blueprint_catalog.py index c96b05a7d8e..b6cfc54576b 100644 --- a/cron/recipe_catalog.py +++ b/cron/blueprint_catalog.py @@ -1,23 +1,23 @@ -"""Cron Recipes — parameterized automation templates with typed slots. +"""Automation Blueprints — parameterized automation templates with typed slots. -A *recipe* is a one-place definition of an automation that every surface +A *blueprint* is a one-place definition of an automation that every surface renders natively: * Dashboard / GUI app -> a form (one field per slot) - * CLI / TUI / messenger -> a pre-filled ``/cron-recipe`` slash command + * CLI / TUI / messenger -> a pre-filled ``/blueprint`` slash command * Agent -> a seed prompt; it asks for any blank/ambiguous slot * Docs catalog -> a copy-paste command + a ``hermes://`` deep-link -The single source of truth is the slot schema below. ``recipe_form_schema`` -emits what a form renderer needs; ``recipe_slash_command`` emits the flattened -one-line command; ``fill_recipe`` validates user-supplied values and turns a -recipe into a ``cron.jobs.create_job`` kwargs dict (so there is no second job +The single source of truth is the slot schema below. ``blueprint_form_schema`` +emits what a form renderer needs; ``blueprint_slash_command`` emits the flattened +one-line command; ``fill_blueprint`` validates user-supplied values and turns a +blueprint into a ``cron.jobs.create_job`` kwargs dict (so there is no second job engine). The form-where-there's-a-screen / agent-fills-where-there's-a-chat split both consume this same module. -Design choice: users never type raw cron. A recipe carries a fixed recurrence +Design choice: users never type raw cron. A blueprint carries a fixed recurrence in ``schedule_template`` and parameterizes only the human-friendly parts -(time-of-day, weekday set). Recipes needing full flexibility expose a ``text`` +(time-of-day, weekday set). Blueprints needing full flexibility expose a ``text`` slot named ``schedule`` that passes through verbatim. """ @@ -28,21 +28,21 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional __all__ = [ - "RecipeSlot", - "CronRecipe", + "BlueprintSlot", + "AutomationBlueprint", "CATALOG", - "get_recipe", - "recipe_form_schema", - "recipe_slash_command", - "recipe_deeplink", - "recipe_catalog_entry", - "fill_recipe", - "RecipeFillError", + "get_blueprint", + "blueprint_form_schema", + "blueprint_slash_command", + "blueprint_deeplink", + "blueprint_catalog_entry", + "fill_blueprint", + "BlueprintFillError", "WEEKDAY_PRESETS", ] -class RecipeFillError(ValueError): +class BlueprintFillError(ValueError): """Raised when supplied slot values fail validation.""" @@ -58,8 +58,8 @@ WEEKDAY_PRESETS: Dict[str, str] = { @dataclass(frozen=True) -class RecipeSlot: - """A single fillable field on a recipe.""" +class BlueprintSlot: + """A single fillable field on a blueprint.""" name: str type: str @@ -80,7 +80,7 @@ class RecipeSlot: @dataclass(frozen=True) -class CronRecipe: +class AutomationBlueprint: """A parameterized automation template.""" key: str @@ -93,7 +93,7 @@ class CronRecipe: schedule_template: str # Seed instruction for the agent / the cron job prompt; may contain {slot}s. prompt_template: str - slots: List[RecipeSlot] = field(default_factory=list) + slots: List[BlueprintSlot] = field(default_factory=list) deliver_default: str = "origin" skills: tuple = () # skills the job loads before running tags: tuple = () @@ -103,11 +103,11 @@ class CronRecipe: # Curated in-repo catalog # --------------------------------------------------------------------------- -_TIME = lambda default="08:00": RecipeSlot( # noqa: E731 - concise factory +_TIME = lambda default="08:00": BlueprintSlot( # noqa: E731 - concise factory name="time", type="time", label="What time?", default=default, help="24h local time, e.g. 08:00", ) -_DELIVER = RecipeSlot( +_DELIVER = BlueprintSlot( name="deliver", type="enum", label="Where to deliver?", default="origin", options=("origin", "local", "telegram", "discord", "email"), optional=False, strict=False, @@ -117,8 +117,8 @@ _DELIVER = RecipeSlot( ) -CATALOG: List[CronRecipe] = [ - CronRecipe( +CATALOG: List[AutomationBlueprint] = [ + AutomationBlueprint( key="morning-brief", title="Morning briefing", description="A short daily briefing: today's calendar, weather, and " @@ -134,7 +134,7 @@ CATALOG: List[CronRecipe] = [ slots=[_TIME("08:00"), _DELIVER], tags=("daily", "briefing"), ), - CronRecipe( + AutomationBlueprint( key="important-mail", title="Important-mail monitor", description="Check your inbox periodically and ping you ONLY about mail " @@ -149,12 +149,12 @@ CATALOG: List[CronRecipe] = [ "configured, explain how to connect one and stop." ), slots=[ - RecipeSlot( + BlueprintSlot( name="interval_min", type="enum", label="How often?", default="30", options=("15", "30", "60"), help="minutes between checks", ), - RecipeSlot( + BlueprintSlot( name="criteria", type="text", label="Only notify me if the mail…", default="needs a reply today, is from my manager or family, " @@ -164,7 +164,7 @@ CATALOG: List[CronRecipe] = [ ], tags=("email", "monitor"), ), - CronRecipe( + AutomationBlueprint( key="weekly-review", title="Weekly review", description="A weekly recap: what got done, what's still open, and " @@ -178,7 +178,7 @@ CATALOG: List[CronRecipe] = [ ), slots=[ _TIME("18:00"), - RecipeSlot( + BlueprintSlot( name="day", type="enum", label="Which day?", default="sunday", options=("sunday", "monday", "friday", "saturday"), @@ -187,7 +187,7 @@ CATALOG: List[CronRecipe] = [ ], tags=("weekly", "review"), ), - CronRecipe( + AutomationBlueprint( key="workday-start", title="Workday start reminder", description="A weekday nudge with your agenda and top priorities.", @@ -201,7 +201,7 @@ CATALOG: List[CronRecipe] = [ slots=[_TIME("09:00"), _DELIVER], tags=("daily", "focus"), ), - CronRecipe( + AutomationBlueprint( key="custom-reminder", title="Custom reminder", description="A recurring reminder in your own words, on your schedule.", @@ -209,10 +209,10 @@ CATALOG: List[CronRecipe] = [ schedule_template="{minute} {hour} * * {dow}", prompt_template="Remind the user: {what}", slots=[ - RecipeSlot(name="what", type="text", label="Remind me to…", + BlueprintSlot(name="what", type="text", label="Remind me to…", default="take a break and stretch"), _TIME("14:00"), - RecipeSlot( + BlueprintSlot( name="recurrence", type="weekdays", label="Repeat on", default="everyday", options=tuple(WEEKDAY_PRESETS.keys()), @@ -221,7 +221,7 @@ CATALOG: List[CronRecipe] = [ ], tags=("reminder",), ), - CronRecipe( + AutomationBlueprint( key="evening-winddown", title="Evening wind-down", description="An end-of-day check-in: tomorrow's calendar at a glance " @@ -238,7 +238,7 @@ CATALOG: List[CronRecipe] = [ slots=[_TIME("21:00"), _DELIVER], tags=("daily", "evening"), ), - CronRecipe( + AutomationBlueprint( key="news-digest", title="Topic news digest", description="A recurring digest on a topic you care about — deduped " @@ -253,18 +253,18 @@ CATALOG: List[CronRecipe] = [ "last run, respond with [SILENT]." ), slots=[ - RecipeSlot( + BlueprintSlot( name="topic", type="text", label="What topic?", default="AI and technology", help="a subject, product, person, or search phrase", ), _TIME("18:00"), - RecipeSlot( + BlueprintSlot( name="recurrence", type="weekdays", label="Repeat on", default="weekdays", options=tuple(WEEKDAY_PRESETS.keys()), ), - RecipeSlot( + BlueprintSlot( name="count", type="enum", label="How many bullets?", default="5", options=("3", "5", "8"), ), @@ -272,7 +272,7 @@ CATALOG: List[CronRecipe] = [ ], tags=("digest", "research"), ), - CronRecipe( + AutomationBlueprint( key="bill-renewal-watch", title="Bills & renewals reminder", description="A heads-up before a recurring payment, subscription " @@ -285,12 +285,12 @@ CATALOG: List[CronRecipe] = [ "it renews'), not just a notification. One short message." ), slots=[ - RecipeSlot( + BlueprintSlot( name="what", type="text", label="What's due?", default="my streaming subscription renews soon", ), _TIME("10:00"), - RecipeSlot( + BlueprintSlot( name="recurrence", type="weekdays", label="Repeat on", default="everyday", options=tuple(WEEKDAY_PRESETS.keys()), @@ -299,7 +299,7 @@ CATALOG: List[CronRecipe] = [ ], tags=("reminder", "finance"), ), - CronRecipe( + AutomationBlueprint( key="habit-checkin", title="Habit check-in", description="A recurring nudge to keep a habit on track and reflect " @@ -312,12 +312,12 @@ CATALOG: List[CronRecipe] = [ "of encouragement. One short message." ), slots=[ - RecipeSlot( + BlueprintSlot( name="habit", type="text", label="Which habit?", default="20 minutes of reading", ), _TIME("20:00"), - RecipeSlot( + BlueprintSlot( name="recurrence", type="weekdays", label="Repeat on", default="everyday", options=tuple(WEEKDAY_PRESETS.keys()), @@ -326,7 +326,7 @@ CATALOG: List[CronRecipe] = [ ], tags=("habit", "wellbeing"), ), - CronRecipe( + AutomationBlueprint( key="hydration-move", title="Hydration & movement nudge", description="A periodic nudge during the day to drink water, stand up, " @@ -342,17 +342,17 @@ CATALOG: List[CronRecipe] = [ "doesn't feel robotic. One short line." ), slots=[ - RecipeSlot( + BlueprintSlot( name="interval_hours", type="enum", label="How often?", default="1", options=("1", "2", "3"), help="hours between nudges", ), - RecipeSlot( + BlueprintSlot( name="start_hour", type="enum", label="Start hour", default="9", options=("7", "8", "9", "10"), help="first hour of the active window (24h)", ), - RecipeSlot( + BlueprintSlot( name="end_hour", type="enum", label="End hour", default="17", options=("16", "17", "18", "19"), help="last hour of the active window (24h)", @@ -361,7 +361,7 @@ CATALOG: List[CronRecipe] = [ ], tags=("wellbeing", "focus"), ), - CronRecipe( + AutomationBlueprint( key="meal-plan", title="Weekly meal plan", description="A weekly meal plan plus a consolidated grocery list, " @@ -371,27 +371,27 @@ CATALOG: List[CronRecipe] = [ prompt_template=( "Build the user a meal plan for the coming week: {meals} per day, " "suited to a {diet} diet and roughly {effort} cooking effort. " - "Include a consolidated grocery list grouped by aisle. Keep recipes " + "Include a consolidated grocery list grouped by aisle. Keep blueprints " "simple and skimmable." ), slots=[ - RecipeSlot( + BlueprintSlot( name="diet", type="enum", label="Diet?", default="no restrictions", options=("no restrictions", "vegetarian", "vegan", "high-protein", "low-carb"), ), - RecipeSlot( + BlueprintSlot( name="meals", type="enum", label="Meals per day?", default="dinner only", options=("dinner only", "lunch and dinner", "all three"), ), - RecipeSlot( + BlueprintSlot( name="effort", type="enum", label="Cooking effort?", default="quick", options=("quick", "medium", "ambitious"), ), _TIME("17:00"), - RecipeSlot( + BlueprintSlot( name="day", type="enum", label="Which day?", default="sunday", options=("sunday", "monday", "friday", "saturday"), @@ -400,7 +400,7 @@ CATALOG: List[CronRecipe] = [ ], tags=("weekly", "food"), ), - CronRecipe( + AutomationBlueprint( key="learn-daily", title="Daily learning drip", description="One bite-sized lesson a day on a topic you want to learn, " @@ -414,12 +414,12 @@ CATALOG: List[CronRecipe] = [ "with a single question to check understanding." ), slots=[ - RecipeSlot( + BlueprintSlot( name="topic", type="text", label="Learn about…", default="Spanish vocabulary", ), _TIME("08:30"), - RecipeSlot( + BlueprintSlot( name="recurrence", type="weekdays", label="Repeat on", default="weekdays", options=tuple(WEEKDAY_PRESETS.keys()), @@ -428,7 +428,7 @@ CATALOG: List[CronRecipe] = [ ], tags=("learning", "daily"), ), - CronRecipe( + AutomationBlueprint( key="gratitude-journal", title="Gratitude & reflection prompt", description="A gentle evening prompt to reflect on the day and note " @@ -443,7 +443,7 @@ CATALOG: List[CronRecipe] = [ ), slots=[ _TIME("21:30"), - RecipeSlot( + BlueprintSlot( name="recurrence", type="weekdays", label="Repeat on", default="everyday", options=tuple(WEEKDAY_PRESETS.keys()), @@ -452,7 +452,7 @@ CATALOG: List[CronRecipe] = [ ], tags=("wellbeing", "reflection"), ), - CronRecipe( + AutomationBlueprint( key="on-this-day", title="On-this-day discovery", description="A daily dose of curiosity: a notable historical event, " @@ -465,7 +465,7 @@ CATALOG: List[CronRecipe] = [ "no filler." ), slots=[ - RecipeSlot( + BlueprintSlot( name="flavor", type="enum", label="What kind?", default="on this day in history", options=("on this day in history", "word of the day", @@ -481,7 +481,7 @@ CATALOG: List[CronRecipe] = [ _CATALOG_BY_KEY = {r.key: r for r in CATALOG} -def get_recipe(key: str) -> Optional[CronRecipe]: +def get_blueprint(key: str) -> Optional[AutomationBlueprint]: return _CATALOG_BY_KEY.get(key) @@ -489,14 +489,14 @@ def get_recipe(key: str) -> Optional[CronRecipe]: # Renderers # --------------------------------------------------------------------------- -def recipe_form_schema(recipe: CronRecipe) -> Dict[str, Any]: - """Emit the JSON a form renderer (dashboard / GUI) needs for this recipe.""" +def blueprint_form_schema(blueprint: AutomationBlueprint) -> Dict[str, Any]: + """Emit the JSON a form renderer (dashboard / GUI) needs for this blueprint.""" return { - "key": recipe.key, - "title": recipe.title, - "description": recipe.description, - "category": recipe.category, - "tags": list(recipe.tags), + "key": blueprint.key, + "title": blueprint.title, + "description": blueprint.description, + "category": blueprint.category, + "tags": list(blueprint.tags), "fields": [ { "name": s.name, @@ -508,20 +508,20 @@ def recipe_form_schema(recipe: CronRecipe) -> Dict[str, Any]: "strict": s.strict, "help": s.help, } - for s in recipe.slots + for s in blueprint.slots ], } -def recipe_slash_command(recipe: CronRecipe, values: Optional[Dict[str, Any]] = None) -> str: - """Build the flattened ``/cron-recipe slot=val …`` command string. +def blueprint_slash_command(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str: + """Build the flattened ``/blueprint slot=val …`` command string. Uses each slot's default when ``values`` is omitted, so the docs/dashboard can show a ready-to-paste command. Free-text slots are quoted. """ values = values or {} - parts = [f"/cron-recipe {recipe.key}"] - for s in recipe.slots: + parts = [f"/blueprint {blueprint.key}"] + for s in blueprint.slots: val = values.get(s.name, s.default) if val is None or val == "": if s.optional: @@ -534,38 +534,38 @@ def recipe_slash_command(recipe: CronRecipe, values: Optional[Dict[str, Any]] = return " ".join(parts) -def recipe_deeplink(recipe: CronRecipe, values: Optional[Dict[str, Any]] = None) -> str: - """Build the ``hermes://cron-recipe/?slot=val`` deep-link URL.""" +def blueprint_deeplink(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str: + """Build the ``hermes://blueprint/?slot=val`` deep-link URL.""" from urllib.parse import quote, urlencode values = values or {} query = {} - for s in recipe.slots: + for s in blueprint.slots: val = values.get(s.name, s.default) if val not in (None, ""): query[s.name] = str(val) qs = ("?" + urlencode(query)) if query else "" - return f"hermes://cron-recipe/{quote(recipe.key)}{qs}" + return f"hermes://blueprint/{quote(blueprint.key)}{qs}" -def _humanize_schedule(recipe: CronRecipe) -> str: - """A short human-readable description of when a recipe runs (defaults).""" - sched = recipe.schedule_template +def _humanize_schedule(blueprint: AutomationBlueprint) -> str: + """A short human-readable description of when a blueprint runs (defaults).""" + sched = blueprint.schedule_template if sched.startswith("*/"): - iv = next((s for s in recipe.slots if s.name == "interval_min"), None) + iv = next((s for s in blueprint.slots if s.name == "interval_min"), None) every = (iv.default if iv else None) or sched.split("/")[1].split()[0] return f"every {every} minutes" if "{interval_hours}" in sched: - iv = next((s for s in recipe.slots if s.name == "interval_hours"), None) + iv = next((s for s in blueprint.slots if s.name == "interval_hours"), None) every = str((iv.default if iv else None) or "1") scope = "weekdays, " if "* * 1-5" in sched else "" return f"{scope}every hour" if every == "1" else f"{scope}every {every} hours" - time_slot = next((s for s in recipe.slots if s.type == "time"), None) + time_slot = next((s for s in blueprint.slots if s.type == "time"), None) when = time_slot.default if time_slot else None if "* * 1-5" in sched: return f"weekdays at {when}" if when else "every weekday" if "{dow}" in sched: - day_slot = next((s for s in recipe.slots if s.name in ("day", "recurrence")), None) + day_slot = next((s for s in blueprint.slots if s.name in ("day", "recurrence")), None) scope = (day_slot.default if day_slot else "") or "" if scope and when: return f"{scope} at {when}" @@ -575,17 +575,17 @@ def _humanize_schedule(recipe: CronRecipe) -> str: return "on a schedule" -def recipe_catalog_entry(recipe: CronRecipe) -> Dict[str, Any]: - """Unified serializable shape for a recipe — used by the docs generator +def blueprint_catalog_entry(blueprint: AutomationBlueprint) -> Dict[str, Any]: + """Unified serializable shape for a blueprint — used by the docs generator and the dashboard API. Combines the form schema, the ready-to-paste slash command, the deep-link URL, and a human-readable schedule. """ return { - **recipe_form_schema(recipe), - "schedule": recipe.schedule_template, - "scheduleHuman": _humanize_schedule(recipe), - "command": recipe_slash_command(recipe), - "appUrl": recipe_deeplink(recipe), + **blueprint_form_schema(blueprint), + "schedule": blueprint.schedule_template, + "scheduleHuman": _humanize_schedule(blueprint), + "command": blueprint_slash_command(blueprint), + "appUrl": blueprint_deeplink(blueprint), } @@ -600,9 +600,9 @@ _DAY_TO_DOW = { } -def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str: +def _resolve_schedule(blueprint: AutomationBlueprint, values: Dict[str, Any]) -> str: """Fill the schedule_template placeholders from resolved slot values.""" - sched = recipe.schedule_template + sched = blueprint.schedule_template # A free-text `schedule` slot passes through verbatim (full flexibility). if "schedule" in values and values["schedule"]: @@ -614,10 +614,10 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str: time_val = values.get("time") if "{minute}" in sched or "{hour}" in sched: if not time_val: - raise RecipeFillError("a time is required") + raise BlueprintFillError("a time is required") m = _TIME_RE.match(str(time_val).strip()) if not m: - raise RecipeFillError(f"invalid time {time_val!r} — use HH:MM (24h)") + raise BlueprintFillError(f"invalid time {time_val!r} — use HH:MM (24h)") repl["hour"] = str(int(m.group(1))) repl["minute"] = str(int(m.group(2))) @@ -626,14 +626,14 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str: if "recurrence" in values: preset = str(values.get("recurrence", "everyday")).lower() if preset not in WEEKDAY_PRESETS: - raise RecipeFillError( + raise BlueprintFillError( f"unknown recurrence {preset!r} — one of {', '.join(WEEKDAY_PRESETS)}" ) repl["dow"] = WEEKDAY_PRESETS[preset] elif "day" in values: day = str(values.get("day", "")).lower() if day not in _DAY_TO_DOW: - raise RecipeFillError(f"unknown day {day!r}") + raise BlueprintFillError(f"unknown day {day!r}") repl["dow"] = _DAY_TO_DOW[day] else: repl["dow"] = "*" @@ -642,12 +642,12 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str: if "{interval_min}" in sched: iv = str(values.get("interval_min", "")).strip() if not iv.isdigit() or int(iv) <= 0: - raise RecipeFillError(f"invalid interval {iv!r} — minutes as a positive integer") + raise BlueprintFillError(f"invalid interval {iv!r} — minutes as a positive integer") repl["interval_min"] = iv # Any remaining {slot} placeholders are filled verbatim from validated # enum/text slot values (e.g. an hour-range window). Enum options have - # already been checked in fill_recipe, so these are safe to interpolate. + # already been checked in fill_blueprint, so these are safe to interpolate. for name in re.findall(r"\{(\w+)\}", sched): if name not in repl and name in values: repl[name] = str(values[name]) @@ -655,59 +655,59 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str: try: return sched.format(**repl) except KeyError as e: # pragma: no cover - template/slot mismatch is a dev error - raise RecipeFillError(f"schedule template missing value for {e}") from e + raise BlueprintFillError(f"schedule template missing value for {e}") from e -def fill_recipe( - recipe: CronRecipe, +def fill_blueprint( + blueprint: AutomationBlueprint, values: Dict[str, Any], *, origin: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Validate ``values`` and return ``cron.jobs.create_job`` kwargs. - Missing required (non-optional) slots raise RecipeFillError naming the + Missing required (non-optional) slots raise BlueprintFillError naming the slot, so a form can show field errors and the agent knows what to ask. Unknown slot names are rejected (a typo'd ``tiem=07:15`` must not silently create a job with the default time). Enum values are checked against their options. The result is passed straight to ``create_job`` — no second schema. """ - known = {s.name for s in recipe.slots} + known = {s.name for s in blueprint.slots} unknown = sorted(set(values) - known) if unknown: - raise RecipeFillError( + raise BlueprintFillError( f"unknown slot{'s' if len(unknown) > 1 else ''}: " - f"{', '.join(unknown)} — valid: {', '.join(s.name for s in recipe.slots)}" + f"{', '.join(unknown)} — valid: {', '.join(s.name for s in blueprint.slots)}" ) resolved: Dict[str, Any] = {} - for s in recipe.slots: + for s in blueprint.slots: raw = values.get(s.name, s.default) if raw in (None, ""): if s.optional: continue - raise RecipeFillError(f"missing required value: {s.name} ({s.label})") + raise BlueprintFillError(f"missing required value: {s.name} ({s.label})") if s.type == "enum" and s.strict and s.options and str(raw) not in {str(o) for o in s.options}: - raise RecipeFillError( + raise BlueprintFillError( f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}" ) resolved[s.name] = raw - schedule = _resolve_schedule(recipe, resolved) + schedule = _resolve_schedule(blueprint, resolved) # Render the prompt with whatever slots it references. try: - prompt = recipe.prompt_template.format(**resolved) + prompt = blueprint.prompt_template.format(**resolved) except KeyError as e: - raise RecipeFillError(f"recipe prompt missing value for {e}") from e + raise BlueprintFillError(f"blueprint prompt missing value for {e}") from e spec: Dict[str, Any] = { "prompt": prompt, "schedule": schedule, - "name": recipe.title, - "deliver": resolved.get("deliver", recipe.deliver_default), + "name": blueprint.title, + "deliver": resolved.get("deliver", blueprint.deliver_default), } - if recipe.skills: - spec["skills"] = list(recipe.skills) + if blueprint.skills: + spec["skills"] = list(blueprint.skills) if origin is not None: spec["origin"] = origin return spec diff --git a/cron/suggestions.py b/cron/suggestions.py index cd23da05a68..636a0335cc3 100644 --- a/cron/suggestions.py +++ b/cron/suggestions.py @@ -7,8 +7,8 @@ flows through, regardless of where it came from: * ``catalog`` — a curated starter automation (daily briefing, important-mail monitor, weekly digest, ...). - * ``recipe`` — the user installed a skill that carries a ``recipe:`` block - (see ``tools/recipes.py``); installing it registers a + * ``blueprint`` — the user installed a skill that carries a ``blueprint:`` block + (see ``tools/blueprints.py``); installing it registers a suggestion instead of auto-scheduling. * ``usage`` — the background self-improvement review noticed a recurring ask that a scheduled job would serve. @@ -53,7 +53,7 @@ _suggestions_lock = threading.Lock() # new suggestions are dropped (the user should clear the backlog first). MAX_PENDING = 5 -VALID_SOURCES = frozenset({"catalog", "recipe", "usage", "integration"}) +VALID_SOURCES = frozenset({"catalog", "blueprint", "usage", "integration"}) _STATUS_PENDING = "pending" _STATUS_ACCEPTED = "accepted" _STATUS_DISMISSED = "dismissed" diff --git a/gateway/run.py b/gateway/run.py index cd91f3d3730..041a6efcd56 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7174,11 +7174,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "suggestions": return await self._handle_suggestions_command(event) - if canonical == "cron-recipe": - _recipe_result = await self._handle_cron_recipe_command(event) - _recipe_seed = getattr(_recipe_result, "agent_seed", None) - if _recipe_seed: - # Recipe matched — rewrite the turn to the seed and fall + if canonical == "blueprint": + _blueprint_result = await self._handle_blueprint_command(event) + _blueprint_seed = getattr(_blueprint_result, "agent_seed", None) + if _blueprint_seed: + # Blueprint matched — rewrite the turn to the seed and fall # through to _handle_message_with_agent so the agent asks the # user for each slot value conversationally and then calls the # cronjob tool (the /steer fall-through pattern). The seed @@ -7186,7 +7186,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Send the "Setting up X…" ack first so the user gets the same # immediate feedback CLI users see, instead of silence until # the agent's first question. - _ack = getattr(_recipe_result, "text", "") or "" + _ack = getattr(_blueprint_result, "text", "") or "" if _ack: try: adapter = self.adapters.get(source.platform) @@ -7194,13 +7194,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _ack_meta = self._thread_metadata_for_source(source) await adapter.send(str(source.chat_id), _ack, metadata=_ack_meta) except Exception: - logger.debug("cron-recipe ack send failed", exc_info=True) + logger.debug("blueprint ack send failed", exc_info=True) try: - event.text = _recipe_seed + event.text = _blueprint_seed except Exception: - return getattr(_recipe_result, "text", "") or None + return getattr(_blueprint_result, "text", "") or None else: - return getattr(_recipe_result, "text", "") or None + return getattr(_blueprint_result, "text", "") or None if canonical == "retry": return await self._handle_retry_command(event) @@ -9298,15 +9298,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.debug("suggestions command failed: %s", e) return f"Suggestions command failed: {e}" - async def _handle_cron_recipe_command(self, event: MessageEvent): - """Handle /cron-recipe in the gateway. + async def _handle_blueprint_command(self, event: MessageEvent): + """Handle /blueprint in the gateway. Delegates to the shared handler so CLI, TUI, and gateway never drift. - Returns a RecipeCommandResult: ``text`` is shown to the user, and if + Returns a BlueprintCommandResult: ``text`` is shown to the user, and if ``agent_seed`` is set the dispatch site rewrites ``event.text`` to the seed and falls through to the agent (the ``/steer`` pattern) so the agent gathers the slot values conversationally. Origin is built from the - event source so a directly created recipe job delivers back to this chat. + event source so a directly created blueprint job delivers back to this chat. """ args = (event.get_command_args() or "").strip() source = event.source @@ -9324,14 +9324,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception: origin = None try: - from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + from hermes_cli.blueprint_cmd import handle_blueprint_command - return handle_cron_recipe_command(args, origin=origin, surface="gateway") + return handle_blueprint_command(args, origin=origin, surface="gateway") except Exception as e: - logger.debug("cron-recipe command failed: %s", e) - from hermes_cli.cron_recipe_cmd import RecipeCommandResult + logger.debug("blueprint command failed: %s", e) + from hermes_cli.blueprint_cmd import BlueprintCommandResult - return RecipeCommandResult(f"Cron recipe command failed: {e}") + return BlueprintCommandResult(f"Cron blueprint command failed: {e}") # ──────────────────────────────────────────────────────────────── # /goal — persistent cross-turn goals (Ralph-style loop) diff --git a/hermes_cli/cron_recipe_cmd.py b/hermes_cli/blueprint_cmd.py similarity index 66% rename from hermes_cli/cron_recipe_cmd.py rename to hermes_cli/blueprint_cmd.py index 4927c9d8d17..e4a3afbf3db 100644 --- a/hermes_cli/cron_recipe_cmd.py +++ b/hermes_cli/blueprint_cmd.py @@ -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-match a recipe, then SEED THE AGENT to + /blueprint list the catalog + /blueprint name-match a blueprint, then SEED THE AGENT to ask the user for each value conversationally - /cron-recipe slot=val … fill + create the cron job directly + /blueprint slot=val … fill + create the cron job directly (the deterministic dashboard / docs / power- user shortcut — no agent turn) The ```` 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 ` and I'll ask you what I need:\n"] + lines = ["Automation Blueprints — `/blueprint ` 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 ` walks you through it. Power users can " - "pass values inline, e.g. `/cron-recipe morning-brief time=08:00`." + "\nTip: `/blueprint ` 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 ` with one of the names above.") + lines.append("\nRun `/blueprint ` 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)) # `` 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) # ` 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)}" ) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index ec2927675d7..b52c6de802e 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -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-matches a recipe and seeds the + Delegates to the shared handler. A bare ``/blueprint`` lists the + catalog; ``/blueprint `` name-matches a blueprint and seeds the agent to ask the user for each value conversationally (the result's - ``agent_seed``); ``/cron-recipe slot=val …`` creates the job + ``agent_seed``); ``/blueprint 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) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 20a87eb811c..78461ca138e 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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")), diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index f6f70b288d4..f1e4f83b2c7 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -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: diff --git a/hermes_cli/suggestions_cmd.py b/hermes_cli/suggestions_cmd.py index aa336e37a19..2dfe6bf5548 100644 --- a/hermes_cli/suggestions_cmd.py +++ b/hermes_cli/suggestions_cmd.py @@ -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): diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ffafc93d4bc..40a5fc02eec 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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)) diff --git a/tests/cron/test_recipe_catalog.py b/tests/cron/test_blueprint_catalog.py similarity index 61% rename from tests/cron/test_recipe_catalog.py rename to tests/cron/test_blueprint_catalog.py index 017d6bd2ae1..a5470c81f8c 100644 --- a/tests/cron/test_recipe_catalog.py +++ b/tests/cron/test_blueprint_catalog.py @@ -1,7 +1,7 @@ -"""Tests for Cron Recipes — the parameterized automation template system. +"""Tests for Automation Blueprints — the parameterized automation template system. -Covers the core catalog/slot schema/renderers/fill (cron/recipe_catalog.py), -the shared /cron-recipe command handler (hermes_cli/cron_recipe_cmd.py), and +Covers the core catalog/slot schema/renderers/fill (cron/blueprint_catalog.py), +the shared /blueprint command handler (hermes_cli/blueprint_cmd.py), and the docs generator. Uses an isolated HERMES_HOME for anything that touches the cron job store. """ @@ -13,16 +13,16 @@ from unittest.mock import patch import pytest -from cron.recipe_catalog import ( +from cron.blueprint_catalog import ( CATALOG, - RecipeFillError, - RecipeSlot, - fill_recipe, - get_recipe, - recipe_catalog_entry, - recipe_deeplink, - recipe_form_schema, - recipe_slash_command, + BlueprintFillError, + BlueprintSlot, + fill_blueprint, + get_blueprint, + blueprint_catalog_entry, + blueprint_deeplink, + blueprint_form_schema, + blueprint_slash_command, ) @@ -30,7 +30,7 @@ class TestCatalog: def test_catalog_nonempty_and_keyed(self): assert len(CATALOG) >= 1 for r in CATALOG: - assert get_recipe(r.key) is r + assert get_blueprint(r.key) is r def test_every_slot_has_known_type(self): for r in CATALOG: @@ -39,60 +39,60 @@ class TestCatalog: def test_bad_slot_type_rejected(self): with pytest.raises(ValueError): - RecipeSlot(name="x", type="bogus", label="X") + BlueprintSlot(name="x", type="bogus", label="X") class TestScheduleResolution: def test_time_to_cron(self): - spec = fill_recipe(get_recipe("morning-brief"), {"time": "08:30"}) + spec = fill_blueprint(get_blueprint("morning-brief"), {"time": "08:30"}) assert spec["schedule"] == "30 8 * * *" def test_interval_schedule(self): - spec = fill_recipe( - get_recipe("important-mail"), + spec = fill_blueprint( + get_blueprint("important-mail"), {"interval_min": "15", "criteria": "x", "deliver": "origin"}, ) assert spec["schedule"] == "*/15 * * * *" def test_day_to_dow(self): - spec = fill_recipe( - get_recipe("weekly-review"), + spec = fill_blueprint( + get_blueprint("weekly-review"), {"time": "18:00", "day": "sunday", "deliver": "origin"}, ) assert spec["schedule"] == "0 18 * * 0" def test_weekday_preset_to_dow(self): - spec = fill_recipe( - get_recipe("custom-reminder"), + spec = fill_blueprint( + get_blueprint("custom-reminder"), {"what": "stretch", "time": "14:00", "recurrence": "weekdays", "deliver": "origin"}, ) assert spec["schedule"] == "0 14 * * 1-5" def test_defaults_fill_when_omitted(self): - spec = fill_recipe(get_recipe("morning-brief"), {}) + spec = fill_blueprint(get_blueprint("morning-brief"), {}) assert spec["schedule"] == "0 8 * * *" class TestValidation: def test_invalid_time_rejected(self): - with pytest.raises(RecipeFillError, match="invalid time"): - fill_recipe(get_recipe("morning-brief"), {"time": "25:99"}) + with pytest.raises(BlueprintFillError, match="invalid time"): + fill_blueprint(get_blueprint("morning-brief"), {"time": "25:99"}) def test_bad_enum_rejected_and_names_slot(self): - with pytest.raises(RecipeFillError, match="not allowed"): - fill_recipe(get_recipe("news-digest"), {"count": "42"}) + with pytest.raises(BlueprintFillError, match="not allowed"): + fill_blueprint(get_blueprint("news-digest"), {"count": "42"}) def test_deliver_slot_accepts_any_platform(self): # deliver is a non-strict enum: its options are suggestions, the real # set of valid platforms depends on the user's configured gateways and # is validated downstream by the cron scheduler. - spec = fill_recipe(get_recipe("morning-brief"), {"time": "08:00", "deliver": "slack"}) + spec = fill_blueprint(get_blueprint("morning-brief"), {"time": "08:00", "deliver": "slack"}) assert spec["deliver"] == "slack" def test_unknown_slot_name_rejected(self): # A typo'd slot must NOT silently create a job with the default value. - with pytest.raises(RecipeFillError, match="unknown slot"): - fill_recipe(get_recipe("morning-brief"), {"tiem": "07:15"}) + with pytest.raises(BlueprintFillError, match="unknown slot"): + fill_blueprint(get_blueprint("morning-brief"), {"tiem": "07:15"}) def test_hydration_hourly_step_actually_fires_at_chosen_cadence(self): # Regression: a minute-field step (*/90) silently wraps to hourly. @@ -100,7 +100,7 @@ class TestValidation: croniter = pytest.importorskip("croniter").croniter from datetime import datetime - spec = fill_recipe(get_recipe("hydration-move"), {"interval_hours": "2"}) + spec = fill_blueprint(get_blueprint("hydration-move"), {"interval_hours": "2"}) it = croniter(spec["schedule"], datetime(2026, 6, 10, 8, 0)) first_three = [it.get_next(datetime) for _ in range(3)] gaps = { @@ -110,45 +110,45 @@ class TestValidation: assert gaps == {7200.0}, f"expected 2h gaps, got {spec['schedule']} -> {first_three}" def test_text_slot_renders_into_prompt(self): - spec = fill_recipe( - get_recipe("important-mail"), + spec = fill_blueprint( + get_blueprint("important-mail"), {"interval_min": "30", "criteria": "from my CEO", "deliver": "origin"}, ) assert "from my CEO" in spec["prompt"] def test_origin_threads_through(self): - spec = fill_recipe( - get_recipe("morning-brief"), {"time": "08:00"}, origin={"platform": "telegram", "chat_id": "9"} + spec = fill_blueprint( + get_blueprint("morning-brief"), {"time": "08:00"}, origin={"platform": "telegram", "chat_id": "9"} ) assert spec["origin"] == {"platform": "telegram", "chat_id": "9"} class TestRenderers: def test_form_schema_fields(self): - schema = recipe_form_schema(get_recipe("morning-brief")) + schema = blueprint_form_schema(get_blueprint("morning-brief")) names = [f["name"] for f in schema["fields"]] assert names == ["time", "deliver"] assert schema["key"] == "morning-brief" def test_slash_command_defaults(self): - cmd = recipe_slash_command(get_recipe("morning-brief")) - assert cmd.startswith("/cron-recipe morning-brief") + cmd = blueprint_slash_command(get_blueprint("morning-brief")) + assert cmd.startswith("/blueprint morning-brief") assert "time=08:00" in cmd def test_slash_command_quotes_freetext(self): - cmd = recipe_slash_command( - get_recipe("custom-reminder"), {"what": "drink water", "time": "10:00"} + cmd = blueprint_slash_command( + get_blueprint("custom-reminder"), {"what": "drink water", "time": "10:00"} ) assert '"drink water"' in cmd def test_deeplink_shape(self): - url = recipe_deeplink(get_recipe("morning-brief"), {"time": "07:15"}) - assert url.startswith("hermes://cron-recipe/morning-brief?") + url = blueprint_deeplink(get_blueprint("morning-brief"), {"time": "07:15"}) + assert url.startswith("hermes://blueprint/morning-brief?") assert "time=07" in url def test_catalog_entry_has_all_surfaces(self): - entry = recipe_catalog_entry(get_recipe("morning-brief")) - assert entry["command"].startswith("/cron-recipe") + entry = blueprint_catalog_entry(get_blueprint("morning-brief")) + assert entry["command"].startswith("/blueprint") assert entry["appUrl"].startswith("hermes://") assert entry["scheduleHuman"] assert "fields" in entry @@ -168,18 +168,18 @@ def isolated_home(tmp_path, monkeypatch): class TestCommandHandler: def test_bare_lists_catalog(self, isolated_home): - from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + from hermes_cli.blueprint_cmd import handle_blueprint_command - res = handle_cron_recipe_command("") - assert "morning-brief" in res.text and "Cron Recipes" in res.text + res = handle_blueprint_command("") + assert "morning-brief" in res.text and "Automation Blueprints" in res.text assert res.agent_seed is None def test_name_seeds_agent(self, isolated_home): - from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + from hermes_cli.blueprint_cmd import handle_blueprint_command - # `/cron-recipe ` (no inline slots) now seeds the agent to ask + # `/blueprint ` (no inline slots) now seeds the agent to ask # the user for each value conversationally instead of dumping fields. - res = handle_cron_recipe_command("morning-brief") + res = handle_blueprint_command("morning-brief") assert res.agent_seed is not None assert "morning-brief" in res.agent_seed assert "cronjob tool" in res.agent_seed @@ -187,22 +187,22 @@ class TestCommandHandler: assert "* * *" in res.agent_seed def test_name_match_is_forgiving(self, isolated_home): - from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command, match_recipe + from hermes_cli.blueprint_cmd import handle_blueprint_command, match_blueprint # prefix match - r, cands = match_recipe("morning") + r, cands = match_blueprint("morning") assert r is not None and r.key == "morning-brief" # fuzzy / typo - r2, _ = match_recipe("mornning-brief") + r2, _ = match_blueprint("mornning-brief") assert r2 is not None and r2.key == "morning-brief" # a forgiving name still seeds the agent - res = handle_cron_recipe_command("morning") + res = handle_blueprint_command("morning") assert res.agent_seed is not None def test_fill_creates_job(self, isolated_home): - from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + from hermes_cli.blueprint_cmd import handle_blueprint_command - res = handle_cron_recipe_command("morning-brief time=07:30 deliver=telegram") + res = handle_blueprint_command("morning-brief time=07:30 deliver=telegram") assert "Scheduled" in res.text assert res.agent_seed is None jobs = isolated_home.load_jobs() @@ -210,17 +210,17 @@ class TestCommandHandler: assert (jobs[0].get("schedule_display") or jobs[0].get("schedule")) == "30 7 * * *" assert jobs[0].get("deliver") == "telegram" - def test_unknown_recipe(self, isolated_home): - from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + def test_unknown_blueprint(self, isolated_home): + from hermes_cli.blueprint_cmd import handle_blueprint_command - res = handle_cron_recipe_command("zzz-nope-nothing") - assert "No cron recipe" in res.text + res = handle_blueprint_command("zzz-nope-nothing") + assert "No automation blueprint" in res.text assert res.agent_seed is None def test_bad_value_names_slot(self, isolated_home): - from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + from hermes_cli.blueprint_cmd import handle_blueprint_command - res = handle_cron_recipe_command("morning-brief time=99:99") + res = handle_blueprint_command("morning-brief time=99:99") assert "Can't set up" in res.text and "time" in res.text assert res.agent_seed is None @@ -232,9 +232,9 @@ class TestDocsGenerator: script = ( Path(__file__).resolve().parents[2] - / "website" / "scripts" / "extract-cron-recipes.py" + / "website" / "scripts" / "extract-automation-blueprints.py" ) - spec = importlib.util.spec_from_file_location("extract_cron_recipes", script) + spec = importlib.util.spec_from_file_location("extract_cron_blueprints", script) assert spec is not None and spec.loader is not None mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) diff --git a/tests/cron/test_suggestions.py b/tests/cron/test_suggestions.py index 179c2956623..75ee7fe7a87 100644 --- a/tests/cron/test_suggestions.py +++ b/tests/cron/test_suggestions.py @@ -1,7 +1,7 @@ """Tests for the Suggested Cron Jobs feature. Covers the store (add/dedup/cap/accept/dismiss/latch), catalog seeding, the -recipe->suggestion bridge, and the shared command handler. Uses an isolated +blueprint->suggestion bridge, and the shared command handler. Uses an isolated HERMES_HOME so the real suggestions.json is never touched. """ @@ -136,23 +136,23 @@ class TestCatalog: assert Path(classify_items_script_path()).name == "classify_items.py" -class TestRecipeBridge: - def test_recipe_registers_suggestion(self, store): - from tools.recipes import RecipeSpec, register_recipe_suggestion +class TestBlueprintBridge: + def test_blueprint_registers_suggestion(self, store): + from tools.blueprints import BlueprintSpec, register_blueprint_suggestion - spec = RecipeSpec(skill_name="morning-brief", schedule="0 8 * * *", deliver="telegram") + spec = BlueprintSpec(skill_name="morning-brief", schedule="0 8 * * *", deliver="telegram") with patch("cron.suggestions.add_suggestion", store.add_suggestion): - rec = register_recipe_suggestion(spec) + rec = register_blueprint_suggestion(spec) assert rec is not None - assert rec["source"] == "recipe" + assert rec["source"] == "blueprint" assert rec["job_spec"]["skills"] == ["morning-brief"] assert rec["job_spec"]["schedule"] == "0 8 * * *" - def test_recipe_to_job_spec_matches_create_recipe_job(self): - from tools.recipes import RecipeSpec, recipe_to_job_spec + def test_blueprint_to_job_spec_matches_create_blueprint_job(self): + from tools.blueprints import BlueprintSpec, blueprint_to_job_spec - spec = RecipeSpec(skill_name="x", schedule="every 2h", deliver="origin", prompt="p") - js = recipe_to_job_spec(spec) + spec = BlueprintSpec(skill_name="x", schedule="every 2h", deliver="origin", prompt="p") + js = blueprint_to_job_spec(spec) assert js["skills"] == ["x"] assert js["schedule"] == "every 2h" assert js["prompt"] == "p" diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 0a6ba0607e9..61c4aa466be 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2360,39 +2360,39 @@ class TestNewEndpoints: resp = self.client.get("/api/cron/jobs/nonexistent-id") assert resp.status_code == 404 - # --- Cron Recipes --- + # --- Automation Blueprints --- - def test_cron_recipes_list(self): - resp = self.client.get("/api/cron/recipes") + def test_cron_blueprints_list(self): + resp = self.client.get("/api/cron/blueprints") assert resp.status_code == 200 - recipes = resp.json()["recipes"] - assert len(recipes) >= 1 - first = recipes[0] + blueprints = resp.json()["blueprints"] + assert len(blueprints) >= 1 + first = blueprints[0] assert "fields" in first - assert first["command"].startswith("/cron-recipe") + assert first["command"].startswith("/blueprint") assert first["appUrl"].startswith("hermes://") - def test_cron_recipe_instantiate_creates_job(self): + def test_blueprint_instantiate_creates_job(self): resp = self.client.post( - "/api/cron/recipes/instantiate", - json={"recipe": "morning-brief", "values": {"time": "07:30", "deliver": "local"}}, + "/api/cron/blueprints/instantiate", + json={"blueprint": "morning-brief", "values": {"time": "07:30", "deliver": "local"}}, ) assert resp.status_code == 200 job = resp.json() assert (job.get("schedule_display") or "").strip() == "30 7 * * *" or \ (job.get("schedule", {}) or {}).get("expr") == "30 7 * * *" - def test_cron_recipe_instantiate_unknown_404(self): + def test_blueprint_instantiate_unknown_404(self): resp = self.client.post( - "/api/cron/recipes/instantiate", - json={"recipe": "does-not-exist", "values": {}}, + "/api/cron/blueprints/instantiate", + json={"blueprint": "does-not-exist", "values": {}}, ) assert resp.status_code == 404 - def test_cron_recipe_instantiate_bad_value_422(self): + def test_blueprint_instantiate_bad_value_422(self): resp = self.client.post( - "/api/cron/recipes/instantiate", - json={"recipe": "morning-brief", "values": {"time": "99:99"}}, + "/api/cron/blueprints/instantiate", + json={"blueprint": "morning-brief", "values": {"time": "99:99"}}, ) assert resp.status_code == 422 diff --git a/tests/tools/test_cron_recipes.py b/tests/tools/test_blueprints.py similarity index 61% rename from tests/tools/test_cron_recipes.py rename to tests/tools/test_blueprints.py index 439b9604913..e23cfa69cfc 100644 --- a/tests/tools/test_cron_recipes.py +++ b/tests/tools/test_blueprints.py @@ -1,6 +1,6 @@ -"""Tests for the recipes layer (skill frontmatter <-> cron automation bridge). +"""Tests for the blueprints layer (skill frontmatter <-> cron automation bridge). -A recipe is a skill with a metadata.hermes.recipe block. These verify parsing, +A blueprint is a skill with a metadata.hermes.blueprint block. These verify parsing, the create-job bridge, and the export round-trip without touching the real cron store. """ @@ -11,24 +11,24 @@ from unittest.mock import patch import pytest -from tools.recipes import ( - RecipeError, - RecipeSpec, - create_recipe_job, - export_recipe, - parse_recipe, - recipe_spec_for_installed, +from tools.blueprints import ( + BlueprintError, + BlueprintSpec, + create_blueprint_job, + export_blueprint, + parse_blueprint, + blueprint_spec_for_installed, ) -RECIPE_SKILL = """--- +BLUEPRINT_SKILL = """--- name: morning-brief description: Summarize unread email and calendar every morning. version: 1.0.0 metadata: hermes: - tags: [recipe, email] - recipe: + tags: [blueprint, email] + blueprint: schedule: "0 8 * * *" deliver: telegram prompt: "Summarize my unread email and today's calendar." @@ -40,22 +40,22 @@ Every morning, gather unread email and the day's calendar and send a digest. """ PLAIN_SKILL = """--- -name: not-a-recipe +name: not-a-blueprint description: Just a regular skill. metadata: hermes: tags: [misc] --- -# Not a recipe +# Not a blueprint """ -MALFORMED_RECIPE = """--- +MALFORMED_BLUEPRINT = """--- name: broken -description: Recipe with no schedule. +description: Blueprint with no schedule. metadata: hermes: - recipe: + blueprint: deliver: origin --- @@ -63,49 +63,49 @@ metadata: """ -class TestParseRecipe: - def test_parses_full_recipe(self): - spec = parse_recipe(RECIPE_SKILL) +class TestParseBlueprint: + def test_parses_full_blueprint(self): + spec = parse_blueprint(BLUEPRINT_SKILL) assert spec is not None assert spec.skill_name == "morning-brief" assert spec.schedule == "0 8 * * *" assert spec.deliver == "telegram" assert spec.prompt is not None and spec.prompt.startswith("Summarize") - def test_plain_skill_is_not_a_recipe(self): - assert parse_recipe(PLAIN_SKILL) is None + def test_plain_skill_is_not_a_blueprint(self): + assert parse_blueprint(PLAIN_SKILL) is None - def test_no_frontmatter_is_not_a_recipe(self): - assert parse_recipe("just some text, no frontmatter") is None + def test_no_frontmatter_is_not_a_blueprint(self): + assert parse_blueprint("just some text, no frontmatter") is None def test_missing_schedule_raises(self): - with pytest.raises(RecipeError): - parse_recipe(MALFORMED_RECIPE) + with pytest.raises(BlueprintError): + parse_blueprint(MALFORMED_BLUEPRINT) - def test_recipe_not_mapping_raises(self): - bad = "---\nname: x\nmetadata:\n hermes:\n recipe: not-a-dict\n---\n\nbody" - with pytest.raises(RecipeError): - parse_recipe(bad) + def test_blueprint_not_mapping_raises(self): + bad = "---\nname: x\nmetadata:\n hermes:\n blueprint: not-a-dict\n---\n\nbody" + with pytest.raises(BlueprintError): + parse_blueprint(bad) def test_deliver_defaults_to_origin(self): skill = ( "---\nname: r\ndescription: d\nmetadata:\n hermes:\n" - ' recipe:\n schedule: "every 1h"\n---\n\nbody' + ' blueprint:\n schedule: "every 1h"\n---\n\nbody' ) - spec = parse_recipe(skill) + spec = parse_blueprint(skill) assert spec is not None assert spec.deliver == "origin" -class TestRecipeSpecForInstalled: - def test_finds_and_parses_installed_recipe(self, tmp_path): +class TestBlueprintSpecForInstalled: + def test_finds_and_parses_installed_blueprint(self, tmp_path): skills_dir = tmp_path / "skills" rec_dir = skills_dir / "productivity" / "morning-brief" rec_dir.mkdir(parents=True) - (rec_dir / "SKILL.md").write_text(RECIPE_SKILL, encoding="utf-8") + (rec_dir / "SKILL.md").write_text(BLUEPRINT_SKILL, encoding="utf-8") with patch("tools.skills_hub.SKILLS_DIR", skills_dir): - spec = recipe_spec_for_installed("morning-brief") + spec = blueprint_spec_for_installed("morning-brief") assert spec is not None assert spec.schedule == "0 8 * * *" @@ -113,20 +113,20 @@ class TestRecipeSpecForInstalled: skills_dir = tmp_path / "skills" skills_dir.mkdir() with patch("tools.skills_hub.SKILLS_DIR", skills_dir): - assert recipe_spec_for_installed("nope") is None + assert blueprint_spec_for_installed("nope") is None def test_plain_skill_returns_none(self, tmp_path): skills_dir = tmp_path / "skills" - d = skills_dir / "misc" / "not-a-recipe" + d = skills_dir / "misc" / "not-a-blueprint" d.mkdir(parents=True) (d / "SKILL.md").write_text(PLAIN_SKILL, encoding="utf-8") with patch("tools.skills_hub.SKILLS_DIR", skills_dir): - assert recipe_spec_for_installed("not-a-recipe") is None + assert blueprint_spec_for_installed("not-a-blueprint") is None -class TestCreateRecipeJob: +class TestCreateBlueprintJob: def test_bridges_to_create_job(self): - spec = parse_recipe(RECIPE_SKILL) + spec = parse_blueprint(BLUEPRINT_SKILL) assert spec is not None captured = {} @@ -135,7 +135,7 @@ class TestCreateRecipeJob: return {"id": "abc123", **kwargs} with patch("cron.jobs.create_job", fake_create_job): - job = create_recipe_job(spec, origin={"platform": "telegram"}) + job = create_blueprint_job(spec, origin={"platform": "telegram"}) assert captured["schedule"] == "0 8 * * *" assert captured["skills"] == ["morning-brief"] @@ -144,7 +144,7 @@ class TestCreateRecipeJob: assert job["id"] == "abc123" -class TestExportRecipe: +class TestExportBlueprint: def test_round_trips_job_to_skill_md(self): job = { "name": "My Morning Brief", @@ -153,19 +153,19 @@ class TestExportRecipe: "deliver": "telegram", "prompt": "Summarize my unread email.", } - md = export_recipe(job, "# Morning Brief\n\nDoes the morning digest.") - # The exported SKILL.md must itself parse back as a recipe. - spec = parse_recipe(md) + md = export_blueprint(job, "# Morning Brief\n\nDoes the morning digest.") + # The exported SKILL.md must itself parse back as a blueprint. + spec = parse_blueprint(md) assert spec is not None assert spec.schedule == "0 8 * * *" assert spec.deliver == "telegram" # Name is sanitized to a valid skill identifier. assert spec.skill_name == "my-morning-brief" - def test_export_has_recipe_tag(self): + def test_export_has_blueprint_tag(self): job = {"name": "x", "schedule_display": "every 2h", "skills": ["x"]} - md = export_recipe(job, "body") - assert "recipe" in md + md = export_blueprint(job, "body") + assert "blueprint" in md assert "automation" in md def test_export_interval_job_without_display(self): @@ -177,12 +177,12 @@ class TestExportRecipe: "schedule": {"kind": "interval", "minutes": 30}, "skills": ["poller"], } - md = export_recipe(job, "body") - spec = parse_recipe(md) + md = export_blueprint(job, "body") + spec = parse_blueprint(md) assert spec is not None assert spec.schedule == "every 30m" job["schedule"] = {"kind": "interval", "minutes": 120} - spec = parse_recipe(export_recipe(job, "body")) + spec = parse_blueprint(export_blueprint(job, "body")) assert spec is not None assert spec.schedule == "every 2h" diff --git a/tools/recipes.py b/tools/blueprints.py similarity index 59% rename from tools/recipes.py rename to tools/blueprints.py index 1b95f192802..7e4c5591a08 100644 --- a/tools/recipes.py +++ b/tools/blueprints.py @@ -1,30 +1,30 @@ -"""Recipes: shareable plain-language automations layered on skills + cron. +"""Blueprints: shareable plain-language automations layered on skills + cron. -A "recipe" is NOT a new object type. It is an ordinary skill (a SKILL.md the +A "blueprint" is NOT a new object type. It is an ordinary skill (a SKILL.md the agent loads) that additionally declares an automation schedule in its frontmatter: metadata: hermes: - recipe: - schedule: "0 9 * * *" # presence of `recipe:` marks it runnable + blueprint: + schedule: "0 9 * * *" # presence of `blueprint:` marks it runnable deliver: origin # optional (default "origin") prompt: "..." # optional task instruction for the run no_agent: false # optional -Because a recipe is just a skill, it flows through the ENTIRE existing +Because a blueprint is just a skill, it flows through the ENTIRE existing skills-hub pipeline for free — search, inspect, quarantine, security scan, install, lock-file provenance, audit log, taps, the centralized index, and `hermes skills publish` for sharing. No new source type, no new store, no new transport. This module is the thin bridge between that skill metadata and the existing cron `create_job()` API: - * ``parse_recipe(skill_md_text)`` -> RecipeSpec | None - * ``recipe_spec_for_installed(name)`` -> RecipeSpec | None - * ``create_recipe_job(spec, ...)`` -> the created cron job dict - * ``export_recipe(job, body)`` -> a shareable SKILL.md string + * ``parse_blueprint(skill_md_text)`` -> BlueprintSpec | None + * ``blueprint_spec_for_installed(name)`` -> BlueprintSpec | None + * ``create_blueprint_job(spec, ...)`` -> the created cron job dict + * ``export_blueprint(job, body)`` -> a shareable SKILL.md string -The dev guide's "Extend, Don't Duplicate" rule is the whole design: the recipe +The dev guide's "Extend, Don't Duplicate" rule is the whole design: the blueprint is a skill, the schedule is a cron job, sharing is the existing publish/tap/ index path. """ @@ -39,24 +39,24 @@ from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) __all__ = [ - "RecipeSpec", - "parse_recipe", - "recipe_spec_for_installed", - "recipe_to_job_spec", - "create_recipe_job", - "register_recipe_suggestion", - "export_recipe", - "RecipeError", + "BlueprintSpec", + "parse_blueprint", + "blueprint_spec_for_installed", + "blueprint_to_job_spec", + "create_blueprint_job", + "register_blueprint_suggestion", + "export_blueprint", + "BlueprintError", ] -class RecipeError(ValueError): - """Raised when a recipe block is present but malformed.""" +class BlueprintError(ValueError): + """Raised when a blueprint block is present but malformed.""" @dataclass -class RecipeSpec: - """Parsed ``metadata.hermes.recipe`` automation spec for a skill.""" +class BlueprintSpec: + """Parsed ``metadata.hermes.blueprint`` automation spec for a skill.""" skill_name: str schedule: str @@ -87,16 +87,16 @@ def _split_frontmatter(text: str) -> Optional[Dict[str, Any]]: data = yaml.safe_load(fm_text) except Exception as e: # pragma: no cover - malformed YAML - logger.debug("recipe: frontmatter YAML parse failed: %s", e) + logger.debug("blueprint: frontmatter YAML parse failed: %s", e) return None return data if isinstance(data, dict) else None -def parse_recipe(skill_md_text: str) -> Optional[RecipeSpec]: - """Extract a RecipeSpec from a SKILL.md string, or None if not a recipe. +def parse_blueprint(skill_md_text: str) -> Optional[BlueprintSpec]: + """Extract a BlueprintSpec from a SKILL.md string, or None if not a blueprint. - A skill is a recipe iff ``metadata.hermes.recipe`` is a mapping containing - a non-empty ``schedule``. Raises RecipeError if the block exists but is + A skill is a blueprint iff ``metadata.hermes.blueprint`` is a mapping containing + a non-empty ``schedule``. Raises BlueprintError if the block exists but is structurally invalid (so a typo surfaces instead of silently no-op'ing). """ fm = _split_frontmatter(skill_md_text) @@ -107,28 +107,28 @@ def parse_recipe(skill_md_text: str) -> Optional[RecipeSpec]: meta = fm.get("metadata") hermes = meta.get("hermes") if isinstance(meta, dict) else None - recipe = hermes.get("recipe") if isinstance(hermes, dict) else None - if recipe is None: + blueprint = hermes.get("blueprint") if isinstance(hermes, dict) else None + if blueprint is None: return None - if not isinstance(recipe, dict): - raise RecipeError("metadata.hermes.recipe must be a mapping") + if not isinstance(blueprint, dict): + raise BlueprintError("metadata.hermes.blueprint must be a mapping") - schedule = str(recipe.get("schedule", "")).strip() + schedule = str(blueprint.get("schedule", "")).strip() if not schedule: - raise RecipeError("recipe.schedule is required and must be non-empty") + raise BlueprintError("blueprint.schedule is required and must be non-empty") - deliver = str(recipe.get("deliver", "origin")).strip() or "origin" - prompt = recipe.get("prompt") + deliver = str(blueprint.get("deliver", "origin")).strip() or "origin" + prompt = blueprint.get("prompt") if prompt is not None: prompt = str(prompt) - no_agent = bool(recipe.get("no_agent", False)) - model = recipe.get("model") - provider = recipe.get("provider") - toolsets = recipe.get("enabled_toolsets") + no_agent = bool(blueprint.get("no_agent", False)) + model = blueprint.get("model") + provider = blueprint.get("provider") + toolsets = blueprint.get("enabled_toolsets") if toolsets is not None and not isinstance(toolsets, list): - raise RecipeError("recipe.enabled_toolsets must be a list when present") + raise BlueprintError("blueprint.enabled_toolsets must be a list when present") - return RecipeSpec( + return BlueprintSpec( skill_name=name, schedule=schedule, deliver=deliver, @@ -137,15 +137,15 @@ def parse_recipe(skill_md_text: str) -> Optional[RecipeSpec]: model=str(model).strip() if model else None, provider=str(provider).strip() if provider else None, enabled_toolsets=[str(t) for t in toolsets] if toolsets else None, - raw=recipe, + raw=blueprint, ) -def recipe_spec_for_installed(skill_name: str) -> Optional[RecipeSpec]: - """Locate an installed skill's SKILL.md and parse its recipe block. +def blueprint_spec_for_installed(skill_name: str) -> Optional[BlueprintSpec]: + """Locate an installed skill's SKILL.md and parse its blueprint block. Searches the standard skills tree for ``/SKILL.md``. Returns - None if the skill isn't found or isn't a recipe. + None if the skill isn't found or isn't a blueprint. """ try: from tools.skills_hub import SKILLS_DIR @@ -160,7 +160,7 @@ def recipe_spec_for_installed(skill_name: str) -> Optional[RecipeSpec]: text = path.read_text(encoding="utf-8") except OSError: continue - spec = parse_recipe(text) + spec = parse_blueprint(text) if spec is not None: # Prefer the frontmatter name, fall back to the directory name. if not spec.skill_name: @@ -169,22 +169,22 @@ def recipe_spec_for_installed(skill_name: str) -> Optional[RecipeSpec]: return None -def recipe_to_job_spec( - spec: RecipeSpec, +def blueprint_to_job_spec( + spec: BlueprintSpec, *, name: Optional[str] = None, ) -> Dict[str, Any]: - """Build the ``cron.jobs.create_job`` kwargs dict for a RecipeSpec. + """Build the ``cron.jobs.create_job`` kwargs dict for a BlueprintSpec. - This is the single source of truth for translating a recipe into a job. - Both the direct ``create_recipe_job`` path and the suggestion path - (``register_recipe_suggestion``) build on it, so a recipe scheduled now and - a recipe accepted from a suggestion produce an identical job. + This is the single source of truth for translating a blueprint into a job. + Both the direct ``create_blueprint_job`` path and the suggestion path + (``register_blueprint_suggestion``) build on it, so a blueprint scheduled now and + a blueprint accepted from a suggestion produce an identical job. """ return { "prompt": spec.prompt, "schedule": spec.schedule, - "name": name or f"recipe:{spec.skill_name}", + "name": name or f"blueprint:{spec.skill_name}", "deliver": spec.deliver, "skills": [spec.skill_name] if spec.skill_name else None, "model": spec.model, @@ -194,31 +194,31 @@ def recipe_to_job_spec( } -def create_recipe_job( - spec: RecipeSpec, +def create_blueprint_job( + spec: BlueprintSpec, *, origin: Optional[Dict[str, Any]] = None, name: Optional[str] = None, ) -> Dict[str, Any]: - """Create the cron job described by a RecipeSpec via the existing cron API. + """Create the cron job described by a BlueprintSpec via the existing cron API. - The recipe's skill is loaded before the run (cron ``skills=[name]``); the + The blueprint's skill is loaded before the run (cron ``skills=[name]``); the optional ``prompt`` becomes the task instruction. Delivery, model, and toolsets carry through. Returns the created job dict. """ from cron.jobs import create_job - job_spec = recipe_to_job_spec(spec, name=name) + job_spec = blueprint_to_job_spec(spec, name=name) if origin is not None: job_spec["origin"] = origin return create_job(**job_spec) -def register_recipe_suggestion(spec: RecipeSpec) -> Optional[Dict[str, Any]]: - """Turn an installed recipe into a pending Suggested Cron Job. +def register_blueprint_suggestion(spec: BlueprintSpec) -> Optional[Dict[str, Any]]: + """Turn an installed blueprint into a pending Suggested Cron Job. - Recipes are source ``recipe`` of the unified suggestion surface: installing - a skill that carries a ``recipe:`` block does NOT auto-schedule it — it + Blueprints are source ``blueprint`` of the unified suggestion surface: installing + a skill that carries a ``blueprint:`` block does NOT auto-schedule it — it registers a suggestion the user accepts (or dismisses) like any other. Returns the suggestion record, or None if it was skipped (already seen/dismissed, backlog full, etc.). @@ -233,53 +233,53 @@ def register_recipe_suggestion(spec: RecipeSpec) -> Optional[Dict[str, Any]]: return add_suggestion( title=f"Schedule '{spec.skill_name}'", description=( - f"The '{spec.skill_name}' recipe runs on schedule {spec.schedule}" + f"The '{spec.skill_name}' blueprint runs on schedule {spec.schedule}" + (f", delivering to {spec.deliver}" if spec.deliver and spec.deliver != "origin" else "") + "." ), - source="recipe", - job_spec=recipe_to_job_spec(spec), - dedup_key=f"recipe:{spec.skill_name}:{spec.schedule}", + source="blueprint", + job_spec=blueprint_to_job_spec(spec), + dedup_key=f"blueprint:{spec.skill_name}:{spec.schedule}", ) -def export_recipe(job: Dict[str, Any], body: str, *, recipe_name: Optional[str] = None) -> str: - """Render a shareable recipe SKILL.md from an existing cron job dict. +def export_blueprint(job: Dict[str, Any], body: str, *, blueprint_name: Optional[str] = None) -> str: + """Render a shareable blueprint SKILL.md from an existing cron job dict. - The inverse of ``create_recipe_job``: take a cron job a user already built - and emit a SKILL.md (with a ``metadata.hermes.recipe`` block) they can hand + The inverse of ``create_blueprint_job``: take a cron job a user already built + and emit a SKILL.md (with a ``metadata.hermes.blueprint`` block) they can hand to ``hermes skills publish`` to share. ``body`` is the plain-language description / instructions that become the SKILL.md body. """ import yaml - name = recipe_name or job.get("name") or "shared-recipe" + name = blueprint_name or job.get("name") or "shared-blueprint" # Sanitize to a valid skill identifier. name = "".join(c if (c.isalnum() or c in "-_") else "-" for c in str(name).lower()) - name = name.strip("-_") or "shared-recipe" + name = name.strip("-_") or "shared-blueprint" schedule = job.get("schedule_display") or _schedule_to_string(job.get("schedule")) skills = job.get("skills") or ([job["skill"]] if job.get("skill") else []) - recipe_block: Dict[str, Any] = {"schedule": schedule} + blueprint_block: Dict[str, Any] = {"schedule": schedule} deliver = job.get("deliver") if deliver and deliver != "origin": - recipe_block["deliver"] = deliver + blueprint_block["deliver"] = deliver if job.get("prompt"): - recipe_block["prompt"] = job["prompt"] + blueprint_block["prompt"] = job["prompt"] if job.get("no_agent"): - recipe_block["no_agent"] = True + blueprint_block["no_agent"] = True if job.get("model"): - recipe_block["model"] = job["model"] + blueprint_block["model"] = job["model"] if job.get("provider"): - recipe_block["provider"] = job["provider"] + blueprint_block["provider"] = job["provider"] if job.get("enabled_toolsets"): - recipe_block["enabled_toolsets"] = job["enabled_toolsets"] + blueprint_block["enabled_toolsets"] = job["enabled_toolsets"] description = ( - (body.strip().splitlines() or ["Shared automation recipe."])[0][:200] + (body.strip().splitlines() or ["Shared automation blueprint."])[0][:200] if body.strip() - else "Shared automation recipe." + else "Shared automation blueprint." ) frontmatter = { @@ -289,13 +289,13 @@ def export_recipe(job: Dict[str, Any], body: str, *, recipe_name: Optional[str] "license": "MIT", "metadata": { "hermes": { - "tags": ["recipe", "automation"], - "recipe": recipe_block, + "tags": ["blueprint", "automation"], + "blueprint": blueprint_block, } }, } fm_yaml = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True).strip() - body_text = body.strip() or f"# {name}\n\nShared automation recipe." + body_text = body.strip() or f"# {name}\n\nShared automation blueprint." return f"---\n{fm_yaml}\n---\n\n{body_text}\n" diff --git a/web/src/components/CronRecipes.tsx b/web/src/components/AutomationBlueprints.tsx similarity index 72% rename from web/src/components/CronRecipes.tsx rename to web/src/components/AutomationBlueprints.tsx index ce9f08b62dc..10d1270fa05 100644 --- a/web/src/components/CronRecipes.tsx +++ b/web/src/components/AutomationBlueprints.tsx @@ -10,19 +10,19 @@ import { Badge } from "@nous-research/ui/ui/components/badge"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { Toast } from "@nous-research/ui/ui/components/toast"; import { api } from "@/lib/api"; -import type { CronRecipe, CronRecipeField } from "@/lib/api"; +import type { AutomationBlueprint, AutomationBlueprintField } from "@/lib/api"; import { cn, themedBody } from "@/lib/utils"; -interface CronRecipesProps { +interface AutomationBlueprintsProps { profile: string; - /** Called after a recipe is instantiated so the parent can refresh its job list. */ + /** Called after a blueprint is instantiated so the parent can refresh its job list. */ onCreated?: () => void; } -/** Initial form values for a recipe = each field's default (or ""). */ -function initialValues(recipe: CronRecipe): Record { +/** Initial form values for a blueprint = each field's default (or ""). */ +function initialValues(blueprint: AutomationBlueprint): Record { const out: Record = {}; - for (const f of recipe.fields) out[f.name] = f.default ?? ""; + for (const f of blueprint.fields) out[f.name] = f.default ?? ""; return out; } @@ -31,7 +31,7 @@ function FieldInput({ value, onChange, }: { - field: CronRecipeField; + field: AutomationBlueprintField; value: string; onChange: (v: string) => void; }) { @@ -66,19 +66,19 @@ function FieldInput({ ); } -function RecipeCard({ - recipe, +function BlueprintCard({ + blueprint, profile, showToast, onCreated, }: { - recipe: CronRecipe; + blueprint: AutomationBlueprint; profile: string; showToast: (message: string, type: "error" | "success") => void; onCreated?: () => void; }) { const [open, setOpen] = useState(false); - const [values, setValues] = useState>(() => initialValues(recipe)); + const [values, setValues] = useState>(() => initialValues(blueprint)); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -86,11 +86,11 @@ function RecipeCard({ setSubmitting(true); setError(null); try { - const job = await api.instantiateCronRecipe({ recipe: recipe.key, values }, profile); + const job = await api.instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile); const when = job.schedule_display ? ` — ${job.schedule_display}` : ""; - showToast(`${recipe.title} scheduled${when}`, "success"); + showToast(`${blueprint.title} scheduled${when}`, "success"); setOpen(false); - setValues(initialValues(recipe)); + setValues(initialValues(blueprint)); onCreated?.(); } catch (e) { // 422 from the API carries the slot-level validation message. @@ -99,7 +99,7 @@ function RecipeCard({ } finally { setSubmitting(false); } - }, [recipe, values, profile, showToast, onCreated]); + }, [blueprint, values, profile, showToast, onCreated]); return ( @@ -108,11 +108,11 @@ function RecipeCard({
- {recipe.title} + {blueprint.title}
-

{recipe.description}

+

{blueprint.description}

- {recipe.tags.map((t) => ( + {blueprint.tags.map((t) => ( {t} @@ -130,9 +130,9 @@ function RecipeCard({ {open && (
- {recipe.fields.map((f) => ( + {blueprint.fields.map((f) => (
- + (null); + const [blueprints, setBlueprints] = useState(null); const [loadError, setLoadError] = useState(null); useEffect(() => { let cancelled = false; api - .getCronRecipes() + .getAutomationBlueprints() .then((r) => { - if (!cancelled) setRecipes(r.recipes); + if (!cancelled) setBlueprints(r.blueprints); }) .catch((e) => { if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e)); @@ -188,27 +188,27 @@ export function CronRecipes({ profile, onCreated }: CronRecipesProps) { }, []); if (loadError) { - return

Couldn't load recipes: {loadError}

; + return

Couldn't load blueprints: {loadError}

; } - if (recipes === null) { + if (blueprints === null) { return (
- Loading recipes… + Loading blueprints…
); } - if (recipes.length === 0) { - return

No cron recipes available.

; + if (blueprints.length === 0) { + return

No automation blueprints available.

; } return ( <>
- {recipes.map((r) => ( - ( + fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }), - // Cron Recipes — parameterized automation templates - getCronRecipes: () => - fetchJSON<{ recipes: CronRecipe[] }>("/api/cron/recipes"), - instantiateCronRecipe: ( - body: { recipe: string; values: Record }, + // Automation Blueprints — parameterized automation templates + getAutomationBlueprints: () => + fetchJSON<{ blueprints: AutomationBlueprint[] }>("/api/cron/blueprints"), + instantiateAutomationBlueprint: ( + body: { blueprint: string; values: Record }, profile = "default", ) => - fetchJSON(`/api/cron/recipes/instantiate?profile=${encodeURIComponent(profile)}`, { + fetchJSON(`/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -1838,7 +1838,7 @@ export interface CronDeliveryTarget { home_env_var: string | null; } -export interface CronRecipeField { +export interface AutomationBlueprintField { name: string; type: "time" | "enum" | "text" | "weekdays"; label: string; @@ -1850,13 +1850,13 @@ export interface CronRecipeField { help: string; } -export interface CronRecipe { +export interface AutomationBlueprint { key: string; title: string; description: string; category: string; tags: string[]; - fields: CronRecipeField[]; + fields: AutomationBlueprintField[]; command: string; appUrl: string; } diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 23c30f3d109..d69af7cbaf8 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -30,7 +30,7 @@ import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; import { Segmented } from "@nous-research/ui/ui/components/segmented"; -import { CronRecipes } from "@/components/CronRecipes"; +import { AutomationBlueprints } from "@/components/AutomationBlueprints"; import { cn, themedBody } from "@/lib/utils"; function formatTime(iso?: string | null): string { @@ -178,7 +178,7 @@ export default function CronPage() { const [jobs, setJobs] = useState([]); const [profiles, setProfiles] = useState([]); const [selectedProfile, setSelectedProfile] = useState("all"); - const [view, setView] = useState<"jobs" | "recipes">("jobs"); + const [view, setView] = useState<"jobs" | "blueprints">("jobs"); const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); const { t, locale } = useI18n(); @@ -512,15 +512,15 @@ export default function CronPage() { setView(v as "jobs" | "recipes")} + onChange={(v) => setView(v as "jobs" | "blueprints")} options={[ { value: "jobs", label: "Jobs" }, - { value: "recipes", label: "Recipes" }, + { value: "blueprints", label: "Blueprints" }, ]} /> - {view === "recipes" && ( - diff --git a/website/docs/developer-guide/creating-skills.md b/website/docs/developer-guide/creating-skills.md index ad3c2a7b9f7..25d023ed57c 100644 --- a/website/docs/developer-guide/creating-skills.md +++ b/website/docs/developer-guide/creating-skills.md @@ -66,7 +66,7 @@ metadata: description: "What this setting controls" default: "sensible-default" prompt: "Display prompt for setup" - recipe: # Optional — marks this skill a runnable automation + blueprint: # Optional — marks this skill a runnable automation schedule: "0 9 * * *" # cron expr / "every 2h" / ISO timestamp deliver: origin # optional (default origin) prompt: "Task instruction for each run" # optional @@ -339,28 +339,28 @@ If your skill is official and useful but not universally needed (e.g., a paid se If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a registry and share it via `hermes skills install`. -## Recipes: skills that are also automations +## Blueprints: skills that are also automations -A **recipe** is an ordinary skill that additionally declares a schedule in its frontmatter. Add a `metadata.hermes.recipe` block and the skill becomes a shareable, runnable automation: +A **blueprint** is an ordinary skill that additionally declares a schedule in its frontmatter. Add a `metadata.hermes.blueprint` block and the skill becomes a shareable, runnable automation: ```yaml metadata: hermes: - tags: [recipe, email] - recipe: - schedule: "0 8 * * *" # presence of `recipe:` marks it runnable + tags: [blueprint, email] + blueprint: + schedule: "0 8 * * *" # presence of `blueprint:` marks it runnable deliver: telegram # optional (default: origin) prompt: "Summarize my unread email and today's calendar." # optional no_agent: false # optional ``` -Because a recipe **is** a skill, it flows through the entire skills pipeline unchanged — search, inspect, install, security scan, provenance, taps, the centralized index, and `hermes skills publish` for sharing. Nothing new to learn. +Because a blueprint **is** a skill, it flows through the entire skills pipeline unchanged — search, inspect, install, security scan, provenance, taps, the centralized index, and `hermes skills publish` for sharing. Nothing new to learn. -**Installing a recipe.** When you install a skill that carries a `recipe:` block, Hermes registers it as a **suggested cron job** rather than scheduling it. Scheduling is **opt-in** — installing never silently creates a recurring job. You review and accept it via `/suggestions`: +**Installing a blueprint.** When you install a skill that carries a `blueprint:` block, Hermes registers it as a **suggested cron job** rather than scheduling it. Scheduling is **opt-in** — installing never silently creates a recurring job. You review and accept it via `/suggestions`: ```bash hermes skills install owner/morning-brief -# → Recipe: 'morning-brief' is an automation (schedule 0 8 * * *). +# → Blueprint: 'morning-brief' is an automation (schedule 0 8 * * *). # Added to your suggestions — run /suggestions to schedule or dismiss it. # then, in a session: @@ -369,11 +369,11 @@ hermes skills install owner/morning-brief /suggestions dismiss 1 # never offer it again ``` -Recipes are one **source** of the unified Suggested Cron Jobs surface — the same place curated starter automations and (later) usage-pattern and integration suggestions appear. See [Suggested Cron Jobs](#suggested-cron-jobs) below. +Blueprints are one **source** of the unified Suggested Cron Jobs surface — the same place curated starter automations and (later) usage-pattern and integration suggestions appear. See [Suggested Cron Jobs](#suggested-cron-jobs) below. -**Sharing an automation you built.** A recipe loaded by a cron job (`hermes cron create --skill ...`) can be exported back to a SKILL.md and published like any other skill, so an automation you tuned for yourself becomes a one-command install for someone else. +**Sharing an automation you built.** A blueprint loaded by a cron job (`hermes cron create --skill ...`) can be exported back to a SKILL.md and published like any other skill, so an automation you tuned for yourself becomes a one-command install for someone else. -The recipe layer adds no new object type, store, or transport — the recipe is a skill, the schedule is a cron job, and sharing is the existing publish/tap/index path. +The blueprint layer adds no new object type, store, or transport — the blueprint is a skill, the schedule is a cron job, and sharing is the existing publish/tap/index path. ## Suggested Cron Jobs @@ -382,7 +382,7 @@ Hermes can *propose* automations and let you accept them with one tap, instead o | Source | Trigger | |--------|---------| | `catalog` | Curated starter automations (`/suggestions catalog`) — daily briefing, important-mail monitor, weekly review, workday-start reminder | -| `recipe` | You installed a skill carrying a `recipe:` block | +| `blueprint` | You installed a skill carrying a `blueprint:` block | | `usage` | The background review noticed a recurring ask a schedule would serve | | `integration` | You connected an account (Gmail, GitHub, ...) and the obvious automations are offered | diff --git a/website/docs/reference/automation-blueprints-catalog.mdx b/website/docs/reference/automation-blueprints-catalog.mdx new file mode 100644 index 00000000000..51dc92c3226 --- /dev/null +++ b/website/docs/reference/automation-blueprints-catalog.mdx @@ -0,0 +1,36 @@ +--- +sidebar_position: 7 +title: "Automation Blueprints Catalog" +description: "Ready-to-run automation templates — set one up from the dashboard, CLI, TUI, any messenger, or the desktop app." +--- + +import AutomationBlueprintsCatalog from '@site/src/components/AutomationBlueprintsCatalog'; + +# Automation Blueprints + +Automation Blueprints are ready-to-run automation templates. Pick one, fill in a couple +of fields, and Hermes schedules it as a cron job — no cron syntax required. + +Every blueprint works from **every surface**: + +- **Dashboard / desktop app** — open the Cron page, switch to the **Blueprints** + tab, fill the form, and click *Schedule it*. +- **CLI, TUI, and messengers** — type `/blueprint ` (e.g. + `/blueprint morning-brief`) and Hermes asks you for what it needs, one + question at a time, then schedules it. The name match is forgiving — a + prefix or near-spelling resolves. Power users can skip the questions by + passing values inline: `/blueprint morning-brief time=08:00`. +- **Desktop app** — click **Send to App** on any blueprint and it opens with the + command pre-loaded in your composer. + +Blueprints never schedule anything silently — you always confirm before the job +is created. Manage created jobs anytime with `/cron`. + + + +## Writing your own + +A blueprint is just a skill with a `metadata.hermes.blueprint` block in its +`SKILL.md` frontmatter. See +[Creating Skills → Automation Blueprints](../developer-guide/creating-skills.md) for the +slot schema and how to publish one. diff --git a/website/docs/reference/cron-recipes-catalog.mdx b/website/docs/reference/cron-recipes-catalog.mdx deleted file mode 100644 index 6e4ccff4240..00000000000 --- a/website/docs/reference/cron-recipes-catalog.mdx +++ /dev/null @@ -1,36 +0,0 @@ ---- -sidebar_position: 7 -title: "Cron Recipes Catalog" -description: "Ready-to-run automation templates — set one up from the dashboard, CLI, TUI, any messenger, or the desktop app." ---- - -import CronRecipesCatalog from '@site/src/components/CronRecipesCatalog'; - -# Cron Recipes - -Cron Recipes are ready-to-run automation templates. Pick one, fill in a couple -of fields, and Hermes schedules it as a cron job — no cron syntax required. - -Every recipe works from **every surface**: - -- **Dashboard / desktop app** — open the Cron page, switch to the **Recipes** - tab, fill the form, and click *Schedule it*. -- **CLI, TUI, and messengers** — type `/cron-recipe ` (e.g. - `/cron-recipe morning-brief`) and Hermes asks you for what it needs, one - question at a time, then schedules it. The name match is forgiving — a - prefix or near-spelling resolves. Power users can skip the questions by - passing values inline: `/cron-recipe morning-brief time=08:00`. -- **Desktop app** — click **Send to App** on any recipe and it opens with the - command pre-loaded in your composer. - -Recipes never schedule anything silently — you always confirm before the job -is created. Manage created jobs anytime with `/cron`. - - - -## Writing your own - -A recipe is just a skill with a `metadata.hermes.recipe` block in its -`SKILL.md` frontmatter. See -[Creating Skills → Cron Recipes](../developer-guide/creating-skills.md) for the -slot schema and how to publish one. diff --git a/website/scripts/extract-cron-recipes.py b/website/scripts/extract-automation-blueprints.py similarity index 59% rename from website/scripts/extract-cron-recipes.py rename to website/scripts/extract-automation-blueprints.py index 5c833f8b930..f41ad7ab855 100644 --- a/website/scripts/extract-cron-recipes.py +++ b/website/scripts/extract-automation-blueprints.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 -"""Generate the Cron Recipes catalog JSON for the docs site. +"""Generate the Automation Blueprints catalog JSON for the docs site. -Mirrors ``extract-skills.py``: imports the single-source-of-truth recipe -definitions from ``cron/recipe_catalog.py`` and emits a flat JSON array the +Mirrors ``extract-skills.py``: imports the single-source-of-truth blueprint +definitions from ``cron/blueprint_catalog.py`` and emits a flat JSON array the docs page renders into cards (description, schedule, copy-paste slash command, and a ``hermes://`` "Send to App" deep-link). -Output: ``website/static/api/cron-recipes-index.json`` (served at -``/docs/api/cron-recipes-index.json``). Run automatically by +Output: ``website/static/api/automation-blueprints-index.json`` (served at +``/docs/api/automation-blueprints-index.json``). Run automatically by ``website/scripts/prebuild.mjs`` before ``npm start`` / ``npm run build``. """ @@ -21,13 +21,13 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT)) -OUTPUT = REPO_ROOT / "website" / "static" / "api" / "cron-recipes-index.json" +OUTPUT = REPO_ROOT / "website" / "static" / "api" / "automation-blueprints-index.json" def build_index() -> list: - from cron.recipe_catalog import CATALOG, recipe_catalog_entry + from cron.blueprint_catalog import CATALOG, blueprint_catalog_entry - return [recipe_catalog_entry(r) for r in CATALOG] + return [blueprint_catalog_entry(r) for r in CATALOG] def main() -> int: @@ -36,13 +36,13 @@ def main() -> int: except Exception as e: # pragma: no cover - import/build failure # Match extract-skills.py's resilience: write an empty array so the # docs build never hard-fails on a generator hiccup. - sys.stderr.write(f"extract-cron-recipes: {e}; writing empty index\n") + sys.stderr.write(f"extract-automation-blueprints: {e}; writing empty index\n") index = [] OUTPUT.parent.mkdir(parents=True, exist_ok=True) with open(OUTPUT, "w", encoding="utf-8") as f: json.dump(index, f, separators=(",", ":")) - sys.stderr.write(f"extract-cron-recipes: wrote {len(index)} recipes -> {OUTPUT}\n") + sys.stderr.write(f"extract-automation-blueprints: wrote {len(index)} blueprints -> {OUTPUT}\n") return 0 diff --git a/website/scripts/prebuild.mjs b/website/scripts/prebuild.mjs index 5ea9982d2df..b873a9b2088 100644 --- a/website/scripts/prebuild.mjs +++ b/website/scripts/prebuild.mjs @@ -31,7 +31,7 @@ const scriptDir = dirname(fileURLToPath(import.meta.url)); const websiteDir = resolve(scriptDir, ".."); const extractScript = join(scriptDir, "extract-skills.py"); const llmsScript = join(scriptDir, "generate-llms-txt.py"); -const cronRecipesScript = join(scriptDir, "extract-cron-recipes.py"); +const cronBlueprintsScript = join(scriptDir, "extract-automation-blueprints.py"); const outputFile = join(websiteDir, "static", "api", "skills.json"); const unifiedIndexFile = join(websiteDir, "static", "api", "skills-index.json"); const UNIFIED_INDEX_URL = @@ -140,6 +140,6 @@ if (!existsSync(extractScript)) { // 2) llms.txt + llms-full.txt — agent-friendly docs entrypoints. Non-fatal. runPython(llmsScript, "generate-llms-txt.py"); -// 3) cron-recipes-index.json — Cron Recipes catalog page. Non-fatal; the page +// 3) automation-blueprints-index.json — Automation Blueprints catalog page. Non-fatal; the page // renders an empty state if the generator can't run. -runPython(cronRecipesScript, "extract-cron-recipes.py"); +runPython(cronBlueprintsScript, "extract-automation-blueprints.py"); diff --git a/website/sidebars.ts b/website/sidebars.ts index 8e49567291b..3dac24ba59f 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -78,7 +78,7 @@ const sidebars: SidebarsConfig = { label: 'Automation', items: [ 'user-guide/features/cron', - 'reference/cron-recipes-catalog', + 'reference/automation-blueprints-catalog', 'user-guide/features/delegation', 'user-guide/features/kanban', 'user-guide/features/codex-app-server-runtime', diff --git a/website/src/components/CronRecipesCatalog/index.tsx b/website/src/components/AutomationBlueprintsCatalog/index.tsx similarity index 60% rename from website/src/components/CronRecipesCatalog/index.tsx rename to website/src/components/AutomationBlueprintsCatalog/index.tsx index 3482754f1dd..7edeca2c705 100644 --- a/website/src/components/CronRecipesCatalog/index.tsx +++ b/website/src/components/AutomationBlueprintsCatalog/index.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import styles from "./styles.module.css"; -interface RecipeField { +interface BlueprintField { name: string; type: string; label: string; @@ -11,19 +11,19 @@ interface RecipeField { help: string; } -interface Recipe { +interface Blueprint { key: string; title: string; description: string; category: string; tags: string[]; - fields: RecipeField[]; + fields: BlueprintField[]; scheduleHuman: string; command: string; appUrl: string; } -const INDEX_URL = "/docs/api/cron-recipes-index.json"; +const INDEX_URL = "/docs/api/automation-blueprints-index.json"; function CopyButton({ text }: { text: string }): JSX.Element { const [copied, setCopied] = useState(false); @@ -44,17 +44,17 @@ function CopyButton({ text }: { text: string }): JSX.Element { ); } -function RecipeCard({ recipe }: { recipe: Recipe }): JSX.Element { +function BlueprintCard({ blueprint }: { blueprint: Blueprint }): JSX.Element { return (
-

{recipe.title}

- {recipe.scheduleHuman} +

{blueprint.title}

+ {blueprint.scheduleHuman}
-

{recipe.description}

+

{blueprint.description}

- {recipe.tags.map((t) => ( + {blueprint.tags.map((t) => ( {t} @@ -62,12 +62,12 @@ function RecipeCard({ recipe }: { recipe: Recipe }): JSX.Element {
- {recipe.command} - + {blueprint.command} +
- + Send to App ↗ @@ -78,16 +78,16 @@ function RecipeCard({ recipe }: { recipe: Recipe }): JSX.Element { ); } -export default function CronRecipesCatalog(): JSX.Element { - const [recipes, setRecipes] = useState(null); +export default function AutomationBlueprintsCatalog(): JSX.Element { + const [blueprints, setBlueprints] = useState(null); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; fetch(INDEX_URL) .then((r) => r.json()) - .then((data: Recipe[]) => { - if (!cancelled) setRecipes(data); + .then((data: Blueprint[]) => { + if (!cancelled) setBlueprints(data); }) .catch((e) => { if (!cancelled) setError(String(e)); @@ -98,19 +98,19 @@ export default function CronRecipesCatalog(): JSX.Element { }, []); if (error) { - return

Couldn't load the recipe catalog: {error}

; + return

Couldn't load the blueprint catalog: {error}

; } - if (recipes === null) { - return

Loading recipes…

; + if (blueprints === null) { + return

Loading blueprints…

; } - if (recipes.length === 0) { - return

No cron recipes are available.

; + if (blueprints.length === 0) { + return

No automation blueprints are available.

; } return (
- {recipes.map((r) => ( - + {blueprints.map((r) => ( + ))}
); diff --git a/website/src/components/CronRecipesCatalog/styles.module.css b/website/src/components/AutomationBlueprintsCatalog/styles.module.css similarity index 100% rename from website/src/components/CronRecipesCatalog/styles.module.css rename to website/src/components/AutomationBlueprintsCatalog/styles.module.css