feat(cron): Cron Recipes — parameterized automation templates across every surface
A 'recipe' is a one-place definition of an automation that every surface renders natively. The slot schema (cron/recipe_catalog.py) is the single source of truth; four renderers consume it, and all paths end at the same cron.jobs.create_job — no second job engine. Form where there's a screen, conversation where there's a chat line: - Dashboard / GUI app: a Recipes sub-tab on the Cron page renders each recipe's typed slots as a form (time-picker, enum dropdown, free-text); submit POSTs /api/cron/recipes/instantiate which fills + creates the job. - CLI / TUI / messengers: /cron-recipe lists the catalog, shows a recipe's fields, or fills + creates from a pasted 'key slot=val' command. The shared handler (hermes_cli/cron_recipe_cmd.py) names any missing/invalid slot so the agent can ask a targeted follow-up. - Docs: a generated Cron Recipes catalog page (website, .mdx + React cards) shows each recipe with a copy-paste command and a 'Send to App' button. - Desktop: a hermes:// URL scheme (Electron single-instance lock + setAsDefaultProtocolClient + open-url/second-instance) routes hermes://cron-recipe/<key>?slot=val into the chat composer pre-filled. Typed slots (time/enum/text/weekdays) with defaults: users never type raw cron — recipes parameterize time-of-day and weekday sets and translate to cron expressions; a free-text 'schedule' slot is the full-flexibility escape hatch. Consent-first throughout: nothing schedules without an explicit submit or send. Core: - cron/recipe_catalog.py — CronRecipe + RecipeSlot, 5 curated recipes, recipe_form_schema / recipe_slash_command / recipe_deeplink / recipe_catalog_entry renderers, fill_recipe (validate + translate to create_job kwargs). - hermes_cli/cron_recipe_cmd.py — shared /cron-recipe handler (CLI + TUI + gateway never drift). CommandDef + dispatch in commands.py / cli.py / gateway/run.py. Dashboard: GET /api/cron/recipes + POST /api/cron/recipes/instantiate (web_server.py), CronRecipes.tsx gallery+form, Segmented sub-tab on CronPage, api.ts methods + types. Desktop: hermes:// scheme end to end (main.cjs deep-link router + ready-queue, preload onDeepLink/signalDeepLinkReady, global.d.ts types, desktop-controller composer prefill, electron-builder protocols key). Docs: extract-cron-recipes.py generator wired into prebuild.mjs, cron-recipes-catalog.mdx + CronRecipesCatalog React component, sidebar entry. Generated index json gitignored like skills.json. Tests: 23 core (catalog/slots/schedule-resolution/validation/renderers/command handler/generator) + 5 web_server endpoint tests. E2E verified end to end: slot fill -> create_job -> persisted job with correct schedule/deliver/origin.
This commit is contained in:
parent
9a09ea69fb
commit
1593ca5406
25 changed files with 1975 additions and 0 deletions
|
|
@ -1255,6 +1255,49 @@ class CLICommandsMixin:
|
|||
print(f"(._.) Unknown cron command: {subcommand}")
|
||||
print(" Available: list, add, edit, pause, resume, run, remove")
|
||||
|
||||
def _handle_suggestions_command(self, cmd: str):
|
||||
"""Handle /suggestions — review/accept/dismiss suggested automations.
|
||||
|
||||
Delegates to the shared handler so CLI and gateway never drift. CLI
|
||||
origin is the local platform so an accepted job's "origin" delivery
|
||||
resolves to a configured home channel.
|
||||
"""
|
||||
import shlex
|
||||
|
||||
try:
|
||||
tokens = shlex.split(cmd)[1:] if cmd else []
|
||||
except ValueError:
|
||||
tokens = (cmd or "").split()[1:]
|
||||
args = " ".join(tokens)
|
||||
try:
|
||||
from hermes_cli.suggestions_cmd import handle_suggestions_command
|
||||
output = handle_suggestions_command(args)
|
||||
except Exception as e:
|
||||
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.
|
||||
|
||||
Delegates to the shared handler so CLI, TUI, and gateway never drift.
|
||||
The user pastes a pre-filled command (from the docs/dashboard or a bare
|
||||
``/cron-recipe`` listing), edits the slot values, and sends; the handler
|
||||
validates and creates the cron job, or names the slot that's missing.
|
||||
"""
|
||||
import shlex
|
||||
|
||||
try:
|
||||
tokens = shlex.split(cmd)[1:] if cmd else []
|
||||
except ValueError:
|
||||
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
|
||||
output = handle_cron_recipe_command(args)
|
||||
except Exception as e:
|
||||
output = f"Cron recipe command failed: {e}"
|
||||
self._console_print(output)
|
||||
|
||||
def _handle_curator_command(self, cmd: str):
|
||||
"""Handle /curator slash command.
|
||||
|
||||
|
|
|
|||
|
|
@ -182,6 +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("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")),
|
||||
|
|
@ -1028,6 +1030,15 @@ _SLACK_RESERVED_COMMANDS = frozenset({
|
|||
"topic", "mute", "pro", "shortcuts",
|
||||
})
|
||||
|
||||
# High-value aliases that must survive Slack's 50-slash cap even when the
|
||||
# registry fills up. Without this, adding a new canonical command silently
|
||||
# clamps off low-priority aliases (they're added in the second pass), so a
|
||||
# long-standing native slash like /btw could disappear just because an
|
||||
# unrelated command landed. These claim their slots right after /hermes,
|
||||
# ahead of both canonical names and the rest of the aliases. Anything not
|
||||
# listed here still degrades gracefully (reachable via /hermes <command>).
|
||||
_SLACK_PRIORITY_ALIASES = ("btw", "bg", "reset")
|
||||
|
||||
|
||||
def _sanitize_slack_name(raw: str) -> str:
|
||||
"""Convert a command name to a valid Slack slash command name.
|
||||
|
|
@ -1082,6 +1093,21 @@ def slack_native_slashes() -> list[tuple[str, str, str]]:
|
|||
entries.append((slack_name, desc[:140], hint[:100]))
|
||||
seen.add(slack_name)
|
||||
|
||||
# Priority pass: pin high-value aliases (e.g. /btw, /bg, /reset) ahead of
|
||||
# everything except /hermes, so a new canonical command can never silently
|
||||
# clamp them off the 50-slash cap. Each alias borrows its parent command's
|
||||
# description and hint.
|
||||
_alias_to_cmd = {
|
||||
alias: cmd
|
||||
for cmd in COMMAND_REGISTRY
|
||||
if _is_gateway_available(cmd, overrides)
|
||||
for alias in cmd.aliases
|
||||
}
|
||||
for alias in _SLACK_PRIORITY_ALIASES:
|
||||
cmd = _alias_to_cmd.get(alias)
|
||||
if cmd is not None:
|
||||
_add(alias, f"Alias for /{cmd.name} — {cmd.description}", cmd.args_hint or "")
|
||||
|
||||
# First pass: canonical names (so they win slots if we hit the cap).
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if not _is_gateway_available(cmd, overrides):
|
||||
|
|
|
|||
147
hermes_cli/cron_recipe_cmd.py
Normal file
147
hermes_cli/cron_recipe_cmd.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""Shared ``/cron-recipe`` command logic for CLI, TUI, and gateway.
|
||||
|
||||
The conversational counterpart to the dashboard's Cron Recipes 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 pastes a pre-filled slash command and this handler
|
||||
parses it; any missing or invalid slot is reported so the agent can ask.
|
||||
|
||||
Subcommand shapes:
|
||||
/cron-recipe list the catalog (numbered + copy commands)
|
||||
/cron-recipe <key> show that recipe's slots + a ready command
|
||||
/cron-recipe <key> slot=val … fill + create the cron job
|
||||
|
||||
Parsing is shlex-based so quoted free-text values (``criteria="from my boss"``)
|
||||
survive. On a fill error the message names the slot, which is exactly what the
|
||||
agent needs to ask a targeted follow-up rather than re-prompting everything.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shlex
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_origin(explicit: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if explicit is not None:
|
||||
return explicit
|
||||
try:
|
||||
from gateway.session_context import get_session_env
|
||||
|
||||
platform = get_session_env("HERMES_SESSION_PLATFORM")
|
||||
chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
|
||||
if platform and chat_id:
|
||||
return {
|
||||
"platform": platform,
|
||||
"chat_id": chat_id,
|
||||
"chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None,
|
||||
"thread_id": get_session_env("HERMES_SESSION_THREAD_ID") or None,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _parse_kv(tokens) -> Tuple[Dict[str, str], list]:
|
||||
"""Split ``slot=value`` tokens from bare tokens. Returns (values, leftovers)."""
|
||||
values: Dict[str, str] = {}
|
||||
leftovers = []
|
||||
for tok in tokens:
|
||||
if "=" in tok:
|
||||
k, _, v = tok.partition("=")
|
||||
k = k.strip()
|
||||
if k:
|
||||
values[k] = v.strip()
|
||||
continue
|
||||
leftovers.append(tok)
|
||||
return values, leftovers
|
||||
|
||||
|
||||
def _fmt_catalog() -> str:
|
||||
from cron.recipe_catalog import CATALOG, recipe_slash_command
|
||||
|
||||
lines = ["Cron Recipes — `/cron-recipe <name>` to set one up:\n"]
|
||||
for r in CATALOG:
|
||||
lines.append(f" • {r.key} — {r.title}")
|
||||
lines.append(f" {r.description}")
|
||||
lines.append(f" ↳ {recipe_slash_command(r)}")
|
||||
lines.append("\nEdit the values then send, or just send to use the defaults.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fmt_recipe(recipe) -> str:
|
||||
from cron.recipe_catalog import recipe_slash_command
|
||||
|
||||
lines = [f"{recipe.title} — {recipe.description}\n", "Fields:"]
|
||||
for s in recipe.slots:
|
||||
opts = f" (one of: {', '.join(map(str, s.options))})" if s.options else ""
|
||||
dflt = f" [default: {s.default}]" if s.default not in (None, "") else ""
|
||||
opt = " (optional)" if s.optional else ""
|
||||
lines.append(f" • {s.name}: {s.label}{opts}{dflt}{opt}")
|
||||
lines.append("\nReady-to-edit command:")
|
||||
lines.append(f" {recipe_slash_command(recipe)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def handle_cron_recipe_command(
|
||||
args: str,
|
||||
*,
|
||||
origin: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Dispatch a ``/cron-recipe`` invocation. Returns text to show the user.
|
||||
|
||||
``args`` is everything after ``/cron-recipe``. ``origin`` lets an accepted
|
||||
recipe's job deliver back to the chat it was created from; resolved from
|
||||
session env when omitted.
|
||||
"""
|
||||
try:
|
||||
from cron.recipe_catalog import fill_recipe, get_recipe, RecipeFillError
|
||||
except Exception as e: # pragma: no cover - import guard
|
||||
logger.debug("recipe catalog import failed: %s", e)
|
||||
return "Cron Recipes are unavailable in this build."
|
||||
|
||||
try:
|
||||
tokens = shlex.split(args or "")
|
||||
except ValueError:
|
||||
tokens = (args or "").split()
|
||||
|
||||
# Bare -> list catalog.
|
||||
if not tokens:
|
||||
return _fmt_catalog()
|
||||
|
||||
key = tokens[0]
|
||||
recipe = get_recipe(key)
|
||||
if recipe is None:
|
||||
return (
|
||||
f"No cron recipe named '{key}'. Run /cron-recipe to see the catalog."
|
||||
)
|
||||
|
||||
values, _leftover = _parse_kv(tokens[1:])
|
||||
|
||||
# `<key>` with no slot args -> show the recipe's fields + a ready command.
|
||||
if not values:
|
||||
return _fmt_recipe(recipe)
|
||||
|
||||
# `<key> slot=val …` -> fill + create.
|
||||
try:
|
||||
spec = fill_recipe(recipe, values, origin=_resolve_origin(origin))
|
||||
except RecipeFillError as e:
|
||||
return f"Can't set up '{recipe.title}': {e}\nRun /cron-recipe {key} to see its fields."
|
||||
|
||||
try:
|
||||
from cron.jobs import create_job
|
||||
|
||||
job = create_job(**spec)
|
||||
except Exception as e:
|
||||
logger.debug("cron-recipe create_job failed: %s", e)
|
||||
return f"Failed to create the job: {e}"
|
||||
|
||||
sched = job.get("schedule_display") or spec.get("schedule", "")
|
||||
return (
|
||||
f"Scheduled '{recipe.title}'"
|
||||
+ (f" ({sched})" if sched else "")
|
||||
+ f", delivering to {spec.get('deliver', 'origin')}. Manage it with /cron."
|
||||
)
|
||||
|
|
@ -6778,6 +6778,53 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None):
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cron Recipes — 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.
|
||||
# ---------------------------------------------------------------------------
|
||||
class CronRecipeInstantiate(BaseModel):
|
||||
recipe: str # recipe 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."""
|
||||
try:
|
||||
from cron.recipe_catalog import CATALOG, recipe_catalog_entry
|
||||
|
||||
return {"recipes": [recipe_catalog_entry(r) for r in CATALOG]}
|
||||
except Exception as e:
|
||||
_log.exception("GET /api/cron/recipes 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)."""
|
||||
try:
|
||||
from cron.recipe_catalog import fill_recipe, get_recipe, RecipeFillError
|
||||
|
||||
recipe = get_recipe(body.recipe)
|
||||
if recipe is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown recipe: {body.recipe}")
|
||||
try:
|
||||
spec = fill_recipe(recipe, body.values)
|
||||
except RecipeFillError 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
|
||||
# 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")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP server endpoints — list / add / remove / test.
|
||||
#
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue