mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
refactor(cron): rebrand Cron Recipes -> Automation Blueprints
Product rename across every surface: module/file names (blueprint_catalog, tools/blueprints, blueprint_cmd), slash command /cron-recipe -> /blueprint (alias /bp), dashboard API /api/cron/blueprints, desktop deep-link hermes://blueprint/<key>, docs catalog page + extract script, and the skill frontmatter block metadata.hermes.blueprint. No behavior change.
This commit is contained in:
parent
3c489fda81
commit
cb29e8a82e
29 changed files with 627 additions and 627 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/<key>?slot=val -> host="cron-recipe", path="/<key>"
|
||||
// hermes://blueprint/<key>?slot=val -> host="blueprint", path="/<key>"
|
||||
const kind = parsed.hostname || ''
|
||||
const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, ''))
|
||||
const params = {}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
8
cli.py
8
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 <name>)
|
||||
# One-shot agent seed set by a slash handler (e.g. /blueprint <name>)
|
||||
# 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 <name>) to be run as the next agent turn.
|
||||
# /blueprint <name>) 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)
|
||||
|
|
|
|||
|
|
@ -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 <key> slot=val …`` command string.
|
||||
def blueprint_slash_command(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Build the flattened ``/blueprint <key> 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/<key>?slot=val`` deep-link URL."""
|
||||
def blueprint_deeplink(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Build the ``hermes://blueprint/<key>?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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
"""Shared ``/cron-recipe`` command logic for CLI, TUI, and gateway.
|
||||
"""Shared ``/blueprint`` command logic for CLI, TUI, and gateway.
|
||||
|
||||
The conversational counterpart to the dashboard's Cron Recipes form. Where a
|
||||
The conversational counterpart to the dashboard's Automation Blueprints form. Where a
|
||||
surface has a screen, the user fills a form (dashboard / GUI app) and the API
|
||||
calls ``fill_recipe`` -> ``create_job`` directly. Where a surface is just a
|
||||
chat line, the user picks a recipe by name and the agent asks for what it
|
||||
needs — pick a recipe by name and the agent asks you for what it needs, one
|
||||
question at a time (the messaging-assistant model: pick a recipe → it asks you
|
||||
calls ``fill_blueprint`` -> ``create_job`` directly. Where a surface is just a
|
||||
chat line, the user picks a blueprint by name and the agent asks for what it
|
||||
needs — pick a blueprint by name and the agent asks you for what it needs, one
|
||||
question at a time (the messaging-assistant model: pick a blueprint → it asks you
|
||||
a couple things → done).
|
||||
|
||||
Subcommand shapes:
|
||||
/cron-recipe list the catalog
|
||||
/cron-recipe <name> name-match a recipe, then SEED THE AGENT to
|
||||
/blueprint list the catalog
|
||||
/blueprint <name> name-match a blueprint, then SEED THE AGENT to
|
||||
ask the user for each value conversationally
|
||||
/cron-recipe <name> slot=val … fill + create the cron job directly
|
||||
/blueprint <name> slot=val … fill + create the cron job directly
|
||||
(the deterministic dashboard / docs / power-
|
||||
user shortcut — no agent turn)
|
||||
|
||||
The ``<name>`` form is forgiving: exact key, unique prefix, or fuzzy match all
|
||||
resolve; an ambiguous query lists the candidates; an unknown one suggests the
|
||||
closest. When it resolves, the handler returns an ``agent_seed`` — a natural-
|
||||
language instruction built from the recipe's typed slots + schedule/prompt
|
||||
language instruction built from the blueprint's typed slots + schedule/prompt
|
||||
templates — that the calling surface feeds to the agent as a normal user turn
|
||||
(gateway: rewrite ``event.text`` and fall through, the ``/steer`` pattern; CLI:
|
||||
a one-shot pending seed the main loop runs). The agent then asks for each slot
|
||||
|
|
@ -41,12 +41,12 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
@dataclass
|
||||
class RecipeCommandResult:
|
||||
"""Outcome of a ``/cron-recipe`` invocation.
|
||||
class BlueprintCommandResult:
|
||||
"""Outcome of a ``/blueprint`` invocation.
|
||||
|
||||
``text`` is always shown to the user. When ``agent_seed`` is set, the
|
||||
calling surface should ALSO hand that seed to the agent as the user's next
|
||||
turn (the recipe was matched and now the agent gathers the slot values
|
||||
turn (the blueprint was matched and now the agent gathers the slot values
|
||||
conversationally). When ``agent_seed`` is None the command is fully handled
|
||||
(catalog listing, direct create, or an error) and nothing is sent to the
|
||||
agent.
|
||||
|
|
@ -91,11 +91,11 @@ def _parse_kv(tokens) -> Tuple[Dict[str, str], list]:
|
|||
return values, leftovers
|
||||
|
||||
|
||||
def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]:
|
||||
"""Resolve a free-typed recipe name to a recipe.
|
||||
def match_blueprint(query: str) -> Tuple[Optional[Any], List[Any]]:
|
||||
"""Resolve a free-typed blueprint name to a blueprint.
|
||||
|
||||
Returns ``(recipe, candidates)``:
|
||||
* exact key or unique prefix / fuzzy match -> ``(recipe, [])``
|
||||
Returns ``(blueprint, candidates)``:
|
||||
* exact key or unique prefix / fuzzy match -> ``(blueprint, [])``
|
||||
* ambiguous (2+ plausible) -> ``(None, [candidates…])``
|
||||
* no plausible match -> ``(None, [])``
|
||||
|
||||
|
|
@ -103,13 +103,13 @@ def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]:
|
|||
dashboard/Discord where it's picked): exact key first, then case-insensitive
|
||||
prefix on key or title, then a difflib fuzzy pass.
|
||||
"""
|
||||
from cron.recipe_catalog import CATALOG, get_recipe
|
||||
from cron.blueprint_catalog import CATALOG, get_blueprint
|
||||
|
||||
q = (query or "").strip().lower()
|
||||
if not q:
|
||||
return None, []
|
||||
|
||||
exact = get_recipe(q)
|
||||
exact = get_blueprint(q)
|
||||
if exact is not None:
|
||||
return exact, []
|
||||
|
||||
|
|
@ -138,43 +138,43 @@ def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]:
|
|||
keys = [r.key for r in CATALOG]
|
||||
close = difflib.get_close_matches(q, keys, n=3, cutoff=0.6)
|
||||
if len(close) == 1:
|
||||
return get_recipe(close[0]), []
|
||||
return get_blueprint(close[0]), []
|
||||
if len(close) > 1:
|
||||
return None, [get_recipe(k) for k in close]
|
||||
return None, [get_blueprint(k) for k in close]
|
||||
|
||||
return None, []
|
||||
|
||||
|
||||
def _humanize_schedule(recipe) -> str:
|
||||
from cron.recipe_catalog import _humanize_schedule as _h
|
||||
def _humanize_schedule(blueprint) -> str:
|
||||
from cron.blueprint_catalog import _humanize_schedule as _h
|
||||
|
||||
try:
|
||||
return _h(recipe)
|
||||
return _h(blueprint)
|
||||
except Exception:
|
||||
return "on a schedule"
|
||||
|
||||
|
||||
def build_recipe_seed(recipe) -> str:
|
||||
def build_blueprint_seed(blueprint) -> str:
|
||||
"""Build the natural-language fill-request the agent will act on.
|
||||
|
||||
The agent reads this as a normal user turn, asks the user for each unfilled
|
||||
slot one at a time, then calls the ``cronjob`` tool with the
|
||||
cron expression it builds from the recipe's ``schedule_template`` and the
|
||||
cron expression it builds from the blueprint's ``schedule_template`` and the
|
||||
rendered prompt. Defaults are stated so the agent can offer them.
|
||||
"""
|
||||
from cron.recipe_catalog import WEEKDAY_PRESETS
|
||||
from cron.blueprint_catalog import WEEKDAY_PRESETS
|
||||
|
||||
lines: List[str] = []
|
||||
lines.append(
|
||||
f"Set up the '{recipe.title}' automation for me (cron recipe "
|
||||
f"'{recipe.key}'). {recipe.description}"
|
||||
f"Set up the '{blueprint.title}' automation for me (automation blueprint "
|
||||
f"'{blueprint.key}'). {blueprint.description}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Ask me for each of these, one at a time, offering the default in "
|
||||
"brackets if I don't have a preference:"
|
||||
)
|
||||
for s in recipe.slots:
|
||||
for s in blueprint.slots:
|
||||
bits = [f"- {s.label} ({s.name})"]
|
||||
if s.options:
|
||||
bits.append(f" — one of: {', '.join(map(str, s.options))}")
|
||||
|
|
@ -190,47 +190,47 @@ def build_recipe_seed(recipe) -> str:
|
|||
lines.append(
|
||||
"Once you have my answers, create the job by calling the cronjob tool "
|
||||
"with action='create'. Build the schedule as a cron expression from "
|
||||
f"this template: `{recipe.schedule_template}` "
|
||||
f"this template: `{blueprint.schedule_template}` "
|
||||
"(fill {minute}/{hour} from the chosen time, {dow} from the weekday "
|
||||
f"choice using {dict(WEEKDAY_PRESETS)}, {{interval_min}} from any "
|
||||
"interval). Use this exact prompt for the job (substituting my "
|
||||
f"answers into any {{slot}} placeholders): \"{recipe.prompt_template}\". "
|
||||
f"answers into any {{slot}} placeholders): \"{blueprint.prompt_template}\". "
|
||||
"Confirm the schedule and what it will do before you create it."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fmt_catalog() -> str:
|
||||
from cron.recipe_catalog import CATALOG
|
||||
from cron.blueprint_catalog import CATALOG
|
||||
|
||||
lines = ["Cron Recipes — `/cron-recipe <name>` and I'll ask you what I need:\n"]
|
||||
lines = ["Automation Blueprints — `/blueprint <name>` and I'll ask you what I need:\n"]
|
||||
for r in CATALOG:
|
||||
lines.append(f" • {r.key} — {r.title}")
|
||||
lines.append(f" {r.description}")
|
||||
lines.append(
|
||||
"\nTip: `/cron-recipe <name>` walks you through it. Power users can "
|
||||
"pass values inline, e.g. `/cron-recipe morning-brief time=08:00`."
|
||||
"\nTip: `/blueprint <name>` walks you through it. Power users can "
|
||||
"pass values inline, e.g. `/blueprint morning-brief time=08:00`."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fmt_candidates(query: str, candidates: List[Any]) -> str:
|
||||
lines = [f"'{query}' matches several recipes — which one?\n"]
|
||||
lines = [f"'{query}' matches several blueprints — which one?\n"]
|
||||
for r in candidates:
|
||||
lines.append(f" • {r.key} — {r.title}")
|
||||
lines.append("\nRun `/cron-recipe <name>` with one of the names above.")
|
||||
lines.append("\nRun `/blueprint <name>` with one of the names above.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fmt_no_match(query: str) -> str:
|
||||
from cron.recipe_catalog import CATALOG
|
||||
from cron.blueprint_catalog import CATALOG
|
||||
|
||||
keys = [r.key for r in CATALOG]
|
||||
close = difflib.get_close_matches((query or "").lower(), keys, n=3, cutoff=0.4)
|
||||
msg = f"No cron recipe matches '{query}'."
|
||||
msg = f"No automation blueprint matches '{query}'."
|
||||
if close:
|
||||
msg += " Did you mean: " + ", ".join(close) + "?"
|
||||
msg += " Run /cron-recipe to see the catalog."
|
||||
msg += " Run /blueprint to see the catalog."
|
||||
return msg
|
||||
|
||||
|
||||
|
|
@ -243,28 +243,28 @@ def _manage_hint(surface: str) -> str:
|
|||
return "Ask me to list, pause, or remove it any time."
|
||||
|
||||
|
||||
def handle_cron_recipe_command(
|
||||
def handle_blueprint_command(
|
||||
args: str,
|
||||
*,
|
||||
origin: Optional[Dict[str, Any]] = None,
|
||||
surface: str = "cli",
|
||||
) -> RecipeCommandResult:
|
||||
"""Dispatch a ``/cron-recipe`` invocation.
|
||||
) -> BlueprintCommandResult:
|
||||
"""Dispatch a ``/blueprint`` invocation.
|
||||
|
||||
Returns a :class:`RecipeCommandResult`. When ``agent_seed`` is set the
|
||||
Returns a :class:`BlueprintCommandResult`. When ``agent_seed`` is set the
|
||||
caller must feed it to the agent as the next user turn; otherwise the
|
||||
command is fully handled and only ``text`` is shown.
|
||||
|
||||
``args`` is everything after ``/cron-recipe``. ``origin`` lets a directly
|
||||
``args`` is everything after ``/blueprint``. ``origin`` lets a directly
|
||||
created job deliver back to the chat it was set up from. ``surface``
|
||||
(``"cli"`` | ``"gateway"``) picks the right wording for follow-up hints —
|
||||
``/cron`` only exists on the CLI.
|
||||
"""
|
||||
try:
|
||||
from cron.recipe_catalog import fill_recipe, RecipeFillError
|
||||
from cron.blueprint_catalog import fill_blueprint, BlueprintFillError
|
||||
except Exception as e: # pragma: no cover - import guard
|
||||
logger.debug("recipe catalog import failed: %s", e)
|
||||
return RecipeCommandResult("Cron Recipes are unavailable in this build.")
|
||||
logger.debug("blueprint catalog import failed: %s", e)
|
||||
return BlueprintCommandResult("Automation Blueprints are unavailable in this build.")
|
||||
|
||||
try:
|
||||
tokens = shlex.split(args or "")
|
||||
|
|
@ -273,33 +273,33 @@ def handle_cron_recipe_command(
|
|||
|
||||
# Bare -> list catalog.
|
||||
if not tokens:
|
||||
return RecipeCommandResult(_fmt_catalog())
|
||||
return BlueprintCommandResult(_fmt_catalog())
|
||||
|
||||
query = tokens[0]
|
||||
values, _leftover = _parse_kv(tokens[1:])
|
||||
|
||||
recipe, candidates = match_recipe(query)
|
||||
if recipe is None:
|
||||
blueprint, candidates = match_blueprint(query)
|
||||
if blueprint is None:
|
||||
if candidates:
|
||||
return RecipeCommandResult(_fmt_candidates(query, candidates))
|
||||
return RecipeCommandResult(_fmt_no_match(query))
|
||||
return BlueprintCommandResult(_fmt_candidates(query, candidates))
|
||||
return BlueprintCommandResult(_fmt_no_match(query))
|
||||
|
||||
# `<name>` with no inline slot values -> seed the agent to ask for them.
|
||||
if not values:
|
||||
seed = build_recipe_seed(recipe)
|
||||
seed = build_blueprint_seed(blueprint)
|
||||
text = (
|
||||
f"Setting up '{recipe.title}' ({_humanize_schedule(recipe)}). "
|
||||
f"Setting up '{blueprint.title}' ({_humanize_schedule(blueprint)}). "
|
||||
"I'll ask you a couple of things…"
|
||||
)
|
||||
return RecipeCommandResult(text, agent_seed=seed)
|
||||
return BlueprintCommandResult(text, agent_seed=seed)
|
||||
|
||||
# `<name> slot=val …` -> fill + create directly (deterministic shortcut).
|
||||
try:
|
||||
spec = fill_recipe(recipe, values, origin=_resolve_origin(origin))
|
||||
except RecipeFillError as e:
|
||||
return RecipeCommandResult(
|
||||
f"Can't set up '{recipe.title}': {e}\n"
|
||||
f"Or just run /cron-recipe {recipe.key} and I'll ask you for the values."
|
||||
spec = fill_blueprint(blueprint, values, origin=_resolve_origin(origin))
|
||||
except BlueprintFillError as e:
|
||||
return BlueprintCommandResult(
|
||||
f"Can't set up '{blueprint.title}': {e}\n"
|
||||
f"Or just run /blueprint {blueprint.key} and I'll ask you for the values."
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -307,12 +307,12 @@ def handle_cron_recipe_command(
|
|||
|
||||
job = create_job(**spec)
|
||||
except Exception as e:
|
||||
logger.debug("cron-recipe create_job failed: %s", e)
|
||||
return RecipeCommandResult(f"Failed to create the job: {e}")
|
||||
logger.debug("blueprint create_job failed: %s", e)
|
||||
return BlueprintCommandResult(f"Failed to create the job: {e}")
|
||||
|
||||
sched = job.get("schedule_display") or spec.get("schedule", "")
|
||||
return RecipeCommandResult(
|
||||
f"Scheduled '{recipe.title}'"
|
||||
return BlueprintCommandResult(
|
||||
f"Scheduled '{blueprint.title}'"
|
||||
+ (f" ({sched})" if sched else "")
|
||||
+ f", delivering to {spec.get('deliver', 'origin')}. {_manage_hint(surface)}"
|
||||
)
|
||||
|
|
@ -1276,13 +1276,13 @@ class CLICommandsMixin:
|
|||
output = f"Suggestions command failed: {e}"
|
||||
self._console_print(output)
|
||||
|
||||
def _handle_cron_recipe_command(self, cmd: str):
|
||||
"""Handle /cron-recipe — set up an automation from a recipe template.
|
||||
def _handle_blueprint_command(self, cmd: str):
|
||||
"""Handle /blueprint — set up an automation from a blueprint template.
|
||||
|
||||
Delegates to the shared handler. A bare ``/cron-recipe`` lists the
|
||||
catalog; ``/cron-recipe <name>`` name-matches a recipe and seeds the
|
||||
Delegates to the shared handler. A bare ``/blueprint`` lists the
|
||||
catalog; ``/blueprint <name>`` name-matches a blueprint and seeds the
|
||||
agent to ask the user for each value conversationally (the result's
|
||||
``agent_seed``); ``/cron-recipe <name> slot=val …`` creates the job
|
||||
``agent_seed``); ``/blueprint <name> slot=val …`` creates the job
|
||||
directly. When a seed is returned it is stashed as a one-shot pending
|
||||
message the interactive loop runs as the next agent turn.
|
||||
"""
|
||||
|
|
@ -1294,10 +1294,10 @@ class CLICommandsMixin:
|
|||
tokens = (cmd or "").split()[1:]
|
||||
args = " ".join(shlex.quote(t) for t in tokens)
|
||||
try:
|
||||
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command
|
||||
result = handle_cron_recipe_command(args)
|
||||
from hermes_cli.blueprint_cmd import handle_blueprint_command
|
||||
result = handle_blueprint_command(args)
|
||||
except Exception as e:
|
||||
self._console_print(f"Cron recipe command failed: {e}")
|
||||
self._console_print(f"Cron blueprint command failed: {e}")
|
||||
return
|
||||
self._console_print(result.text)
|
||||
seed = getattr(result, "agent_seed", None)
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <name>` (no inline slots) now seeds the agent to ask
|
||||
# `/blueprint <name>` (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)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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_name>/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"
|
||||
|
||||
|
||||
|
|
@ -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<string, string> {
|
||||
/** Initial form values for a blueprint = each field's default (or ""). */
|
||||
function initialValues(blueprint: AutomationBlueprint): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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<Record<string, string>>(() => initialValues(recipe));
|
||||
const [values, setValues] = useState<Record<string, string>>(() => initialValues(blueprint));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Card className={cn("overflow-hidden", themedBody)}>
|
||||
|
|
@ -108,11 +108,11 @@ function RecipeCard({
|
|||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wand2 className="h-4 w-4 shrink-0 opacity-70" />
|
||||
<span className="font-medium">{recipe.title}</span>
|
||||
<span className="font-medium">{blueprint.title}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm opacity-70">{recipe.description}</p>
|
||||
<p className="mt-1 text-sm opacity-70">{blueprint.description}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{recipe.tags.map((t) => (
|
||||
{blueprint.tags.map((t) => (
|
||||
<Badge key={t} tone="secondary">
|
||||
{t}
|
||||
</Badge>
|
||||
|
|
@ -130,9 +130,9 @@ function RecipeCard({
|
|||
|
||||
{open && (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
{recipe.fields.map((f) => (
|
||||
{blueprint.fields.map((f) => (
|
||||
<div key={f.name} className="space-y-1">
|
||||
<Label htmlFor={`${recipe.key}-${f.name}`}>{f.label}</Label>
|
||||
<Label htmlFor={`${blueprint.key}-${f.name}`}>{f.label}</Label>
|
||||
<FieldInput
|
||||
field={f}
|
||||
value={values[f.name] ?? ""}
|
||||
|
|
@ -162,22 +162,22 @@ function RecipeCard({
|
|||
}
|
||||
|
||||
/**
|
||||
* Cron Recipes gallery — the form-where-there's-a-screen surface. Each recipe
|
||||
* Automation Blueprints gallery — the form-where-there's-a-screen surface. Each blueprint
|
||||
* card expands into an inline form (one field per typed slot); submitting POSTs
|
||||
* to /api/cron/recipes/instantiate which fills the recipe and creates the job
|
||||
* to /api/cron/blueprints/instantiate which fills the blueprint and creates the job
|
||||
* via the same create_job path as everything else.
|
||||
*/
|
||||
export function CronRecipes({ profile, onCreated }: CronRecipesProps) {
|
||||
export function AutomationBlueprints({ profile, onCreated }: AutomationBlueprintsProps) {
|
||||
const { toast, showToast } = useToast();
|
||||
const [recipes, setRecipes] = useState<CronRecipe[] | null>(null);
|
||||
const [blueprints, setBlueprints] = useState<AutomationBlueprint[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(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 <p className="text-sm text-red-500">Couldn't load recipes: {loadError}</p>;
|
||||
return <p className="text-sm text-red-500">Couldn't load blueprints: {loadError}</p>;
|
||||
}
|
||||
if (recipes === null) {
|
||||
if (blueprints === null) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 opacity-70">
|
||||
<Spinner className="h-4 w-4" /> Loading recipes…
|
||||
<Spinner className="h-4 w-4" /> Loading blueprints…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (recipes.length === 0) {
|
||||
return <p className="opacity-70">No cron recipes available.</p>;
|
||||
if (blueprints.length === 0) {
|
||||
return <p className="opacity-70">No automation blueprints available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast toast={toast} />
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{recipes.map((r) => (
|
||||
<RecipeCard
|
||||
{blueprints.map((r) => (
|
||||
<BlueprintCard
|
||||
key={r.key}
|
||||
recipe={r}
|
||||
blueprint={r}
|
||||
profile={profile}
|
||||
showToast={showToast}
|
||||
onCreated={onCreated}
|
||||
|
|
@ -219,4 +219,4 @@ export function CronRecipes({ profile, onCreated }: CronRecipesProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export default CronRecipes;
|
||||
export default AutomationBlueprints;
|
||||
|
|
@ -497,14 +497,14 @@ export const api = {
|
|||
deleteCronJob: (id: string, profile = "default") =>
|
||||
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<string, string> },
|
||||
// Automation Blueprints — parameterized automation templates
|
||||
getAutomationBlueprints: () =>
|
||||
fetchJSON<{ blueprints: AutomationBlueprint[] }>("/api/cron/blueprints"),
|
||||
instantiateAutomationBlueprint: (
|
||||
body: { blueprint: string; values: Record<string, string> },
|
||||
profile = "default",
|
||||
) =>
|
||||
fetchJSON<CronJob>(`/api/cron/recipes/instantiate?profile=${encodeURIComponent(profile)}`, {
|
||||
fetchJSON<CronJob>(`/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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CronJob[]>([]);
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
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() {
|
|||
|
||||
<Segmented
|
||||
value={view}
|
||||
onChange={(v) => 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" && (
|
||||
<CronRecipes
|
||||
{view === "blueprints" && (
|
||||
<AutomationBlueprints
|
||||
profile={selectedProfile === "all" ? "default" : selectedProfile}
|
||||
onCreated={loadJobs}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 <name> ...`) 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 <name> ...`) 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 |
|
||||
|
||||
|
|
|
|||
36
website/docs/reference/automation-blueprints-catalog.mdx
Normal file
36
website/docs/reference/automation-blueprints-catalog.mdx
Normal file
|
|
@ -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 <name>` (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`.
|
||||
|
||||
<AutomationBlueprintsCatalog />
|
||||
|
||||
## 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.
|
||||
|
|
@ -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 <name>` (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`.
|
||||
|
||||
<CronRecipesCatalog />
|
||||
|
||||
## 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.title}>{recipe.title}</h3>
|
||||
<span className={styles.schedule}>{recipe.scheduleHuman}</span>
|
||||
<h3 className={styles.title}>{blueprint.title}</h3>
|
||||
<span className={styles.schedule}>{blueprint.scheduleHuman}</span>
|
||||
</div>
|
||||
<p className={styles.desc}>{recipe.description}</p>
|
||||
<p className={styles.desc}>{blueprint.description}</p>
|
||||
|
||||
<div className={styles.tags}>
|
||||
{recipe.tags.map((t) => (
|
||||
{blueprint.tags.map((t) => (
|
||||
<span key={t} className={styles.tag}>
|
||||
{t}
|
||||
</span>
|
||||
|
|
@ -62,12 +62,12 @@ function RecipeCard({ recipe }: { recipe: Recipe }): JSX.Element {
|
|||
</div>
|
||||
|
||||
<div className={styles.cmdRow}>
|
||||
<code className={styles.cmd}>{recipe.command}</code>
|
||||
<CopyButton text={recipe.command} />
|
||||
<code className={styles.cmd}>{blueprint.command}</code>
|
||||
<CopyButton text={blueprint.command} />
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<a className={styles.appBtn} href={recipe.appUrl}>
|
||||
<a className={styles.appBtn} href={blueprint.appUrl}>
|
||||
Send to App ↗
|
||||
</a>
|
||||
<span className={styles.hint}>
|
||||
|
|
@ -78,16 +78,16 @@ function RecipeCard({ recipe }: { recipe: Recipe }): JSX.Element {
|
|||
);
|
||||
}
|
||||
|
||||
export default function CronRecipesCatalog(): JSX.Element {
|
||||
const [recipes, setRecipes] = useState<Recipe[] | null>(null);
|
||||
export default function AutomationBlueprintsCatalog(): JSX.Element {
|
||||
const [blueprints, setBlueprints] = useState<Blueprint[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 <p>Couldn't load the recipe catalog: {error}</p>;
|
||||
return <p>Couldn't load the blueprint catalog: {error}</p>;
|
||||
}
|
||||
if (recipes === null) {
|
||||
return <p>Loading recipes…</p>;
|
||||
if (blueprints === null) {
|
||||
return <p>Loading blueprints…</p>;
|
||||
}
|
||||
if (recipes.length === 0) {
|
||||
return <p>No cron recipes are available.</p>;
|
||||
if (blueprints.length === 0) {
|
||||
return <p>No automation blueprints are available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
{recipes.map((r) => (
|
||||
<RecipeCard key={r.key} recipe={r} />
|
||||
{blueprints.map((r) => (
|
||||
<BlueprintCard key={r.key} blueprint={r} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue