From fad0502a2f7478af07174a6c87c49d317a8794fb Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 7 Jun 2026 06:47:24 -0700 Subject: [PATCH 1/7] =?UTF-8?q?feat(cron):=20Suggested=20Cron=20Jobs=20?= =?UTF-8?q?=E2=80=94=20one=20surface=20for=20proposed=20automations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes can propose automations and let the user accept them with one tap via /suggestions, instead of making them assemble cron jobs by hand. Every proposal — wherever it originates — flows through one surface. Sources (the 'where suggestions come from'): - catalog: curated starter automations (daily briefing, important-mail monitor, weekly review, workday-start reminder) via /suggestions catalog - recipe: installing a skill that carries a metadata.hermes.recipe block registers a suggestion instead of auto-scheduling - usage / integration: reserved for the background-review detector and account-connect triggers (sources defined; emitters land next) Pieces: - cron/suggestions.py — the store. add/list/accept/dismiss, dedup+latch by key (dismissed proposals never re-offered), pending cap so it can't become a nag wall. Accepting calls the existing cron.jobs.create_job — there is NO second job engine. Mirrors jobs.py storage (atomic writes, lock, 0600). - cron/suggestion_catalog.py — the curated set. The important-mail monitor entry is where the old proactive-monitor poll->classify->surface engine lives now (cron/scripts/classify_items.py + the 'monitor' aux task), as ONE catalog automation rather than a standalone feature. - tools/recipes.py — recipe<->job bridge; register_recipe_suggestion() makes a recipe source 'recipe' of this surface. recipe_to_job_spec() is the single translation both the direct and suggestion paths share. - hermes_cli/suggestions_cmd.py — shared /suggestions handler (CLI + gateway never drift); /suggestions [accept N|dismiss N|catalog|clear]. - Wired: CommandDef + CLI dispatch (cli.py) + gateway dispatch (gateway/run.py) + aux 'monitor' task (config.py) + recipe-install hook (skills_hub.py). Consent-first throughout: nothing auto-schedules; acceptance is always explicit; dismissals latch. Supersedes #41122 (proactive-monitor) and #41127 (recipes): both fold in here as a catalog entry and a suggestion source respectively. Tests: store (dedup/cap/accept/dismiss/latch), catalog seeding+idempotency, recipe->suggestion bridge, command handler, aux config. E2E: recipe SKILL.md -> parsed -> suggested -> accepted -> real cron job persisted to jobs.json. --- cron/scripts/classify_items.py | 226 +++++++++++++ cron/suggestion_catalog.py | 152 +++++++++ cron/suggestions.py | 257 ++++++++++++++ gateway/run.py | 33 ++ hermes_cli/commands.py | 3 + hermes_cli/config.py | 14 + hermes_cli/skills_hub.py | 27 ++ hermes_cli/suggestions_cmd.py | 145 ++++++++ tests/cron/test_suggestions.py | 193 +++++++++++ tests/tools/test_recipes.py | 169 ++++++++++ tools/recipes.py | 317 ++++++++++++++++++ .../docs/developer-guide/creating-skills.md | 63 ++++ 12 files changed, 1599 insertions(+) create mode 100644 cron/scripts/classify_items.py create mode 100644 cron/suggestion_catalog.py create mode 100644 cron/suggestions.py create mode 100644 hermes_cli/suggestions_cmd.py create mode 100644 tests/cron/test_suggestions.py create mode 100644 tests/tools/test_recipes.py create mode 100644 tools/recipes.py diff --git a/cron/scripts/classify_items.py b/cron/scripts/classify_items.py new file mode 100644 index 000000000000..d31b1f7427c8 --- /dev/null +++ b/cron/scripts/classify_items.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Classify candidate items by urgency/importance and emit only the urgent ones. + +The proactive-monitor pattern: a fetch step (a watcher script, an inbox dump, a +feed) produces a list of candidate items; this script scores each with a cheap +LLM and prints ONLY the items at or above a threshold. Below-threshold runs +print nothing, so a cron job wrapping this stays silent unless something +actually matters -- mirroring Poke's email monitor (fetch -> classify urgency +-> surface only what's above the bar). + +Design choices: + * Uses Hermes' auxiliary client with task="monitor", so the classifier model + is configured once in config.yaml (auxiliary.monitor.{provider,model}) and + can be a cheap fast model independent of the main chat model. + * Reads items as JSON (a list of objects) from stdin or --input-file. + * One LLM call scores the whole batch (cheap, single round-trip) and returns + structured scores; we filter locally. + * Empty result -> empty stdout -> the cron job's [SILENT]/empty-stdout path + suppresses delivery. No spam on quiet intervals. + +Usage (standalone): + cat items.json | python classify_items.py --threshold 7 \ + --criteria "Urgent if it needs a reply today or is from my manager/family" + +Usage (wired to a watcher via cron, agent mode): + Ask the agent: "Every 10 minutes, run watch_http_json.py for my inbox feed, + pipe its JSON into classify_items.py with my urgency criteria, and deliver + whatever it prints. Stay silent if it prints nothing." + +Item schema (flexible): each item is an object; the classifier sees the whole +object. A "title"/"subject"/"summary"/"text" field helps it judge. An "id" +field (any of id/guid/message_id/url) is echoed back so duplicates can be +deduped upstream. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any, Dict, List, Optional + + +def _eprint(*args: Any) -> None: + print(*args, file=sys.stderr) + + +def _load_items(input_file: Optional[str]) -> List[Dict[str, Any]]: + raw = "" + if input_file: + with open(input_file, encoding="utf-8") as f: + raw = f.read() + else: + raw = sys.stdin.read() + raw = raw.strip() + if not raw: + return [] + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + _eprint(f"classify_items: input is not valid JSON: {e}") + sys.exit(2) + if isinstance(data, dict): + # Allow {"items": [...]} or a single object. + if isinstance(data.get("items"), list): + return data["items"] + return [data] + if isinstance(data, list): + return [x for x in data if isinstance(x, dict)] + _eprint("classify_items: expected a JSON list or {items: [...]}") + sys.exit(2) + + +def _item_id(item: Dict[str, Any], index: int) -> str: + for key in ("id", "guid", "message_id", "url", "link"): + val = item.get(key) + if val: + return str(val) + return f"item-{index}" + + +_CLASSIFY_INSTRUCTIONS = ( + "You are an urgency classifier for a proactive assistant. You will be given " + "a numbered list of items and the user's importance criteria. Score EACH " + "item from 0 (ignore entirely) to 10 (interrupt the user now). Return ONLY a " + "JSON array, one object per item, in the same order: " + '[{"index": , "score": , "reason": ""}]. ' + "No prose, no markdown fences. Be conservative: most items should score low. " + "Only score high when the item clearly meets the user's criteria." +) + + +def _build_prompt(items: List[Dict[str, Any]], criteria: str) -> str: + lines = [f"USER IMPORTANCE CRITERIA:\n{criteria}\n", "ITEMS:"] + for i, item in enumerate(items): + # Show a compact view; the model sees the salient fields. + view = { + k: item[k] + for k in ("title", "subject", "summary", "text", "body", "from", "sender", "url") + if k in item + } + if not view: + view = item # fall back to the whole object + lines.append(f"[{i}] {json.dumps(view, ensure_ascii=False)[:1200]}") + lines.append( + "\nReturn the JSON array of scores now (one object per item, same order)." + ) + return "\n".join(lines) + + +def _parse_scores(content: str, n_items: int) -> Dict[int, Dict[str, Any]]: + text = (content or "").strip() + # Tolerate accidental markdown fences. + if text.startswith("```"): + text = text.strip("`") + if "\n" in text: + text = text.split("\n", 1)[1] + try: + arr = json.loads(text) + except json.JSONDecodeError: + # Last-ditch: find the first [...] block. + start = text.find("[") + end = text.rfind("]") + if start >= 0 and end > start: + try: + arr = json.loads(text[start : end + 1]) + except json.JSONDecodeError: + _eprint("classify_items: could not parse classifier output") + return {} + else: + _eprint("classify_items: classifier returned no JSON array") + return {} + out: Dict[int, Dict[str, Any]] = {} + if isinstance(arr, list): + for obj in arr: + if not isinstance(obj, dict): + continue + idx = obj.get("index") + if isinstance(idx, int) and 0 <= idx < n_items: + out[idx] = obj + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description="Classify items by urgency; emit only urgent ones.") + parser.add_argument("--criteria", required=True, help="Plain-language importance criteria.") + parser.add_argument("--threshold", type=int, default=7, help="Minimum score (0-10) to surface. Default 7.") + parser.add_argument("--input-file", default=None, help="Read items JSON from this file instead of stdin.") + parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format for surfaced items.") + args = parser.parse_args() + + items = _load_items(args.input_file) + if not items: + # Nothing to classify -> silent. This is the common quiet-interval case. + return 0 + + # Import here so --help works without the package importable. + try: + from agent.auxiliary_client import call_llm + except Exception as e: # pragma: no cover - import guard + _eprint(f"classify_items: cannot import auxiliary client: {e}") + return 3 + + prompt = _build_prompt(items, args.criteria) + try: + resp = call_llm( + task="monitor", + messages=[{"role": "user", "content": prompt}], + max_tokens=1024, + temperature=0, + ) + content = resp.choices[0].message.content + if not isinstance(content, str): + content = str(content) if content else "" + except Exception as e: + # Classification failure is NOT silent -- surface it so a broken monitor + # doesn't quietly swallow important items. Non-zero exit -> cron alerts. + _eprint(f"classify_items: classifier call failed: {e}") + return 4 + + scores = _parse_scores(content, len(items)) + surfaced = [] + for i, item in enumerate(items): + s = scores.get(i) + score = s.get("score") if isinstance(s, dict) else None + if isinstance(score, int) and score >= args.threshold: + surfaced.append((i, item, s)) + + if not surfaced: + # Below threshold -> silent. Empty stdout; cron suppresses delivery. + return 0 + + if args.format == "json": + out = [ + { + "id": _item_id(item, i), + "score": s.get("score"), + "reason": s.get("reason", ""), + "item": item, + } + for (i, item, s) in surfaced + ] + print(json.dumps(out, ensure_ascii=False, indent=2)) + else: + blocks = [] + for (i, item, s) in surfaced: + title = ( + item.get("title") + or item.get("subject") + or item.get("summary") + or _item_id(item, i) + ) + url = item.get("url") or item.get("link") or "" + reason = s.get("reason", "") + block = f"## [{s.get('score')}/10] {title}" + if url: + block += f"\n{url}" + if reason: + block += f"\n_{reason}_" + blocks.append(block) + print("\n\n".join(blocks)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cron/suggestion_catalog.py b/cron/suggestion_catalog.py new file mode 100644 index 000000000000..e297bc440a04 --- /dev/null +++ b/cron/suggestion_catalog.py @@ -0,0 +1,152 @@ +"""Curated catalog of starter cron-job suggestions. + +These are the built-in automations Hermes can offer a new user out of the box — +the ``catalog`` source of the unified suggestion surface. Each entry is a +ready-to-run ``cron.jobs.create_job`` spec wrapped as a suggestion; the user +accepts via ``/suggestions``. Nothing here auto-schedules. + +The "important-mail monitor" entry is where the old proactive-monitor engine +lives now: its ``classify_items.py`` (poll a source -> LLM-score urgency -> +surface only above-threshold) is ONE catalog automation, not a standalone +feature. + +Adding a catalog entry: append a CatalogEntry. Keep prompts self-contained +(cron jobs run with no chat context) and schedules sensible. The ``job_spec`` +is passed verbatim to ``create_job`` on accept. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +__all__ = ["CatalogEntry", "CATALOG", "seed_catalog_suggestions", "classify_items_script_path"] + + +def classify_items_script_path() -> str: + """Absolute path to the urgency classifier script shipped with cron/.""" + return str((Path(__file__).resolve().parent / "scripts" / "classify_items.py")) + + +@dataclass(frozen=True) +class CatalogEntry: + """A curated starter automation offered as a suggestion.""" + + key: str # stable dedup key (never re-offered once dismissed) + title: str + description: str + job_spec: Dict[str, Any] # kwargs for cron.jobs.create_job + + +# The curated set. Schedules use the cron/interval syntax create_job accepts. +CATALOG: List[CatalogEntry] = [ + CatalogEntry( + key="catalog:daily-briefing", + title="Daily briefing", + description="Every morning at 8am, a short briefing: today's calendar, " + "weather, and anything urgent waiting on you.", + job_spec={ + "prompt": ( + "Produce a concise morning briefing for the user: today's " + "calendar events, the local weather, and any urgent items " + "(unread important email, due tasks). Keep it short and " + "scannable. If you have no connected data sources, give a brief " + "general good-morning with the date and offer to connect " + "calendar/email." + ), + "schedule": "0 8 * * *", + "name": "Daily briefing", + "deliver": "origin", + }, + ), + CatalogEntry( + key="catalog:important-mail-monitor", + title="Important-mail monitor", + description="Check your inbox periodically and ping you ONLY about mail " + "that actually needs attention — never the newsletters.", + job_spec={ + "prompt": ( + "Check the user's inbox for new messages since the last run. " + "For each candidate, judge urgency against this rule: surface " + "only mail that needs a reply today, is from a manager/family " + "member, or mentions a deadline. Pipe candidates through the " + f"urgency classifier at {classify_items_script_path()} " + "(--threshold 7) and deliver ONLY what it returns. If nothing " + "clears the bar, respond with [SILENT] so the user is not " + "pinged. Requires a connected mail source; if none is " + "configured, explain how to connect one and then stop." + ), + "schedule": "every 30m", + "name": "Important-mail monitor", + "deliver": "origin", + }, + ), + CatalogEntry( + key="catalog:weekly-review", + title="Weekly review", + description="Every Sunday evening, a recap of the week: what got done, " + "what's still open, and what's coming up next week.", + job_spec={ + "prompt": ( + "Produce a weekly review for the user: summarize what was " + "accomplished this week, list still-open items, and preview " + "next week's calendar. Pull from whatever sources are connected " + "(calendar, task tools, recent conversations). Keep it tight." + ), + "schedule": "0 18 * * 0", + "name": "Weekly review", + "deliver": "origin", + }, + ), + CatalogEntry( + key="catalog:standup-reminder", + title="Workday start reminder", + description="A weekday nudge at 9am with your day's agenda and top " + "priorities, so you start focused.", + job_spec={ + "prompt": ( + "Give the user a brief weekday start-of-day nudge: their " + "calendar for today and the 1-3 highest-priority things to " + "focus on, inferred from recent context and any task tools. " + "Encouraging, short, one message." + ), + "schedule": "0 9 * * 1-5", + "name": "Workday start reminder", + "deliver": "origin", + }, + ), +] + + +def seed_catalog_suggestions( + *, + add_fn: Optional[Callable[..., Optional[Dict[str, Any]]]] = None, + keys: Optional[List[str]] = None, +) -> List[Dict[str, Any]]: + """Register catalog entries as pending suggestions. + + ``add_fn`` defaults to ``cron.suggestions.add_suggestion`` (injectable for + tests). ``keys`` restricts to specific catalog entries; omit to seed all. + Entries already dismissed/accepted (by dedup key) or beyond the pending cap + are skipped by the store, so re-seeding is safe and idempotent. Returns the + list of suggestion records actually created. + """ + if add_fn is None: + from cron.suggestions import add_suggestion as add_fn # type: ignore[assignment] + + wanted = set(keys) if keys else None + created: List[Dict[str, Any]] = [] + for entry in CATALOG: + if wanted is not None and entry.key not in wanted: + continue + rec = add_fn( + title=entry.title, + description=entry.description, + source="catalog", + job_spec=dict(entry.job_spec), + dedup_key=entry.key, + ) + if rec is not None: + created.append(rec) + return created diff --git a/cron/suggestions.py b/cron/suggestions.py new file mode 100644 index 000000000000..cd23da05a68a --- /dev/null +++ b/cron/suggestions.py @@ -0,0 +1,257 @@ +"""Suggested cron jobs — proposed automations the user accepts with one tap. + +A *suggestion* is a ready-to-run cron job spec that Hermes surfaces to the +user, who accepts it (creates the real cron job) or dismisses it (latched so +it is never re-offered). This is the single surface every automation proposal +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 + suggestion instead of auto-scheduling. + * ``usage`` — the background self-improvement review noticed a recurring + ask that a scheduled job would serve. + * ``integration`` — the user connected an account (Gmail, GitHub, ...) and + the obvious automations for that surface are offered. + +Accepting a suggestion just calls the existing ``cron.jobs.create_job`` with +the stored ``job_spec`` — there is NO second job engine. Suggestions never +auto-create jobs; acceptance is always explicit (consent-first). Dismissed +suggestions latch by a stable ``dedup_key`` so the same proposal is not +re-offered after the user says no. + +Storage mirrors ``cron/jobs.py``: ``~/.hermes/cron/suggestions.json``, atomic +writes, an in-process lock, and 0600 perms. +""" + +from __future__ import annotations + +import json +import logging +import os +import tempfile +import threading +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional + +from hermes_constants import get_hermes_home +from hermes_time import now as _hermes_now +from utils import atomic_replace + +logger = logging.getLogger(__name__) + +CRON_DIR = get_hermes_home().resolve() / "cron" +SUGGESTIONS_FILE = CRON_DIR / "suggestions.json" + +# In-process lock protecting load->modify->save cycles (the background review +# fork and the main agent can both write). +_suggestions_lock = threading.Lock() + +# Cap pending suggestions so the list never becomes a nag wall. When full, +# new suggestions are dropped (the user should clear the backlog first). +MAX_PENDING = 5 + +VALID_SOURCES = frozenset({"catalog", "recipe", "usage", "integration"}) +_STATUS_PENDING = "pending" +_STATUS_ACCEPTED = "accepted" +_STATUS_DISMISSED = "dismissed" + + +def _secure_file(path: Path) -> None: + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def _ensure_dir() -> None: + CRON_DIR.mkdir(parents=True, exist_ok=True) + + +def _load_raw() -> Dict[str, Any]: + if not SUGGESTIONS_FILE.exists(): + return {"suggestions": []} + try: + with open(SUGGESTIONS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + logger.warning("suggestions.json unreadable (%s); starting empty", e) + return {"suggestions": []} + if isinstance(data, dict) and isinstance(data.get("suggestions"), list): + return data + if isinstance(data, list): + return {"suggestions": data} + logger.warning("suggestions.json malformed; starting empty") + return {"suggestions": []} + + +def _save_raw(suggestions: List[Dict[str, Any]]) -> None: + _ensure_dir() + fd, tmp_path = tempfile.mkstemp(dir=str(SUGGESTIONS_FILE.parent), suffix=".tmp", prefix=".sugg_") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump( + {"suggestions": suggestions, "updated_at": _hermes_now().isoformat()}, + f, + indent=2, + ) + f.flush() + os.fsync(f.fileno()) + atomic_replace(tmp_path, SUGGESTIONS_FILE) + _secure_file(SUGGESTIONS_FILE) + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +def load_suggestions() -> List[Dict[str, Any]]: + """Return all suggestion records (any status).""" + return _load_raw().get("suggestions", []) + + +def list_pending() -> List[Dict[str, Any]]: + """Return pending suggestions in creation order (oldest first).""" + return [s for s in load_suggestions() if s.get("status") == _STATUS_PENDING] + + +def add_suggestion( + *, + title: str, + description: str, + source: str, + job_spec: Dict[str, Any], + dedup_key: str, +) -> Optional[Dict[str, Any]]: + """Register a pending suggestion. Returns the record, or None if skipped. + + Skipped when: the source is unknown, the same ``dedup_key`` was already + dismissed or accepted (never re-offer), an identical pending suggestion + exists, or the pending list is full (``MAX_PENDING``). + + ``job_spec`` is a dict of kwargs for ``cron.jobs.create_job`` — accepting + the suggestion passes it straight through, so there is no second schema to + keep in sync. + """ + if source not in VALID_SOURCES: + raise ValueError(f"unknown suggestion source: {source!r}") + if not title.strip() or not dedup_key.strip(): + raise ValueError("title and dedup_key are required") + + with _suggestions_lock: + suggestions = _load_raw().get("suggestions", []) + + # Never re-offer something the user already saw and decided on, and + # never duplicate a still-pending proposal. + for existing in suggestions: + if existing.get("dedup_key") == dedup_key: + if existing.get("status") in (_STATUS_DISMISSED, _STATUS_ACCEPTED): + return None + if existing.get("status") == _STATUS_PENDING: + return None + + pending_count = sum(1 for s in suggestions if s.get("status") == _STATUS_PENDING) + if pending_count >= MAX_PENDING: + logger.info("Suggestion backlog full (%d); dropping %r", MAX_PENDING, title) + return None + + record = { + "id": uuid.uuid4().hex[:12], + "title": title.strip(), + "description": description.strip(), + "source": source, + "job_spec": job_spec, + "dedup_key": dedup_key.strip(), + "status": _STATUS_PENDING, + "created_at": _hermes_now().isoformat(), + } + suggestions.append(record) + _save_raw(suggestions) + return record + + +def get_suggestion(ref: str) -> Optional[Dict[str, Any]]: + """Resolve a suggestion by id, 1-based pending index, or title (exact).""" + suggestions = load_suggestions() + # By id. + for s in suggestions: + if s.get("id") == ref: + return s + # By 1-based pending index. + if ref.isdigit(): + pending = [s for s in suggestions if s.get("status") == _STATUS_PENDING] + idx = int(ref) - 1 + if 0 <= idx < len(pending): + return pending[idx] + # By exact title (case-insensitive). + for s in suggestions: + if s.get("title", "").lower() == ref.lower(): + return s + return None + + +def _set_status(suggestion_id: str, status: str) -> bool: + with _suggestions_lock: + suggestions = _load_raw().get("suggestions", []) + changed = False + for s in suggestions: + if s.get("id") == suggestion_id: + s["status"] = status + s["resolved_at"] = _hermes_now().isoformat() + changed = True + break + if changed: + _save_raw(suggestions) + return changed + + +def dismiss_suggestion(ref: str) -> bool: + """Dismiss a suggestion (latched — never re-offered for its dedup_key).""" + s = get_suggestion(ref) + if not s: + return False + return _set_status(s["id"], _STATUS_DISMISSED) + + +def accept_suggestion(ref: str, *, origin: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: + """Accept a suggestion: create the real cron job from its ``job_spec``. + + Returns the created cron job dict, or None if the suggestion isn't found / + not pending. The job_spec is passed straight to ``cron.jobs.create_job``; + an ``origin`` (platform/chat) is merged so "origin" delivery routes back to + the chat where the user accepted. + """ + s = get_suggestion(ref) + if not s or s.get("status") != _STATUS_PENDING: + return None + + from cron.jobs import create_job + + spec = dict(s.get("job_spec") or {}) + if origin is not None and "origin" not in spec: + spec["origin"] = origin + + job = create_job(**spec) + _set_status(s["id"], _STATUS_ACCEPTED) + return job + + +def clear_resolved() -> int: + """Drop accepted/dismissed records from disk. Returns the count removed. + + Pending suggestions and the dedup memory of dismissed ones are the only + things that matter long-term, but dismissed records must be RETAINED for + their dedup_key (so they aren't re-offered). This only prunes ACCEPTED + records, which have served their purpose once the job exists. + """ + with _suggestions_lock: + suggestions = _load_raw().get("suggestions", []) + kept = [s for s in suggestions if s.get("status") != _STATUS_ACCEPTED] + removed = len(suggestions) - len(kept) + if removed: + _save_raw(kept) + return removed diff --git a/gateway/run.py b/gateway/run.py index 897cb85f6523..faab5714079e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7171,6 +7171,9 @@ async def _do_reset(): if canonical == "kanban": return await self._handle_kanban_command(event) + if canonical == "suggestions": + return await self._handle_suggestions_command(event) + if canonical == "retry": return await self._handle_retry_command(event) @@ -9237,6 +9240,36 @@ def _is_stale_restart_redelivery(self, event: MessageEvent) -> bool: + async def _handle_suggestions_command(self, event: MessageEvent) -> str: + """Handle /suggestions in the gateway. + + Delegates to the shared handler so CLI and gateway never drift. The + origin is built from the event source so an accepted suggestion's job + delivers back to this chat/thread. + """ + args = (event.get_command_args() or "").strip() + source = event.source + origin = None + try: + platform = getattr(source.platform, "value", None) or str(getattr(source, "platform", "") or "") + chat_id = getattr(source, "chat_id", None) + if platform and chat_id: + origin = { + "platform": platform, + "chat_id": str(chat_id), + "chat_name": getattr(source, "chat_name", None), + "thread_id": getattr(source, "thread_id", None), + } + except Exception: + origin = None + try: + from hermes_cli.suggestions_cmd import handle_suggestions_command + + return handle_suggestions_command(args, origin=origin) + except Exception as e: + logger.debug("suggestions command failed: %s", e) + return f"Suggestions command failed: {e}" + # ──────────────────────────────────────────────────────────────── # /goal — persistent cross-turn goals (Ralph-style loop) # ──────────────────────────────────────────────────────────────── diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index aded4d41d81c..cff8db21d896 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -179,6 +179,9 @@ class CommandDef: CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", cli_only=True, args_hint="[subcommand]", subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")), + CommandDef("suggestions", "Review suggested automations (accept/dismiss)", + "Tools & Skills", aliases=("suggest",), args_hint="[accept|dismiss N | catalog]", + subcommands=("accept", "dismiss", "catalog", "clear")), CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)", "Tools & Skills", args_hint="[subcommand]", subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived")), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 65a618e78f8f..fdd3e541f38f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1369,6 +1369,20 @@ def _ensure_hermes_home_managed(home: Path): "timeout": 600, "extra_body": {}, }, + # Monitor — urgency/importance classifier used by the important-mail + # monitor catalog automation (cron/scripts/classify_items.py). Scores + # candidate items 0-10 against the user's criteria so only above- + # threshold items get delivered. "auto" = main chat model; override to + # a cheap fast model (e.g. openrouter google/gemini-3-flash-preview, + # haiku) since per-item scoring is high-volume and a small model is fine. + "monitor": { + "provider": "auto", + "model": "", + "base_url": "", + "api_key": "", + "timeout": 60, + "extra_body": {}, + }, }, "display": { diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index db96e6262c18..2b7546962f6a 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -691,6 +691,33 @@ 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 + # 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 + + 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 = None + if spec is not None: + registered = register_recipe_suggestion(spec) + if registered is not None: + c.print( + f"[bold cyan]Recipe:[/] '{bundle.name}' is an automation " + f"(schedule [bold]{spec.schedule}[/])." + ) + c.print( + "[dim]Added to your suggestions — run[/] [bold]/suggestions[/] " + "[dim]to schedule or dismiss it.[/]\n" + ) + except Exception: # pragma: no cover - recipe detection is best-effort + pass + if invalidate_cache: # Invalidate the skills prompt cache so the new skill appears immediately try: diff --git a/hermes_cli/suggestions_cmd.py b/hermes_cli/suggestions_cmd.py new file mode 100644 index 000000000000..a0f785016a5e --- /dev/null +++ b/hermes_cli/suggestions_cmd.py @@ -0,0 +1,145 @@ +"""Shared ``/suggestions`` command logic for CLI and gateway. + +Both surfaces call ``handle_suggestions_command(args, origin=...)`` and present +the returned text however they present command output. Keeping the logic here +(not in cli.py / gateway/run.py) means the two surfaces can never drift. + +Subcommands: + /suggestions list pending suggestions (numbered) + /suggestions accept create the cron job for that suggestion + /suggestions dismiss dismiss it (latched, never re-offered) + /suggestions catalog seed the curated starter automations as pending + /suggestions clear drop accepted records (housekeeping) +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + + +def _fmt_pending(pending: list) -> str: + if not pending: + return ( + "No suggested automations right now.\n" + "Try `/suggestions catalog` to see the curated starter set, or " + "install a recipe skill to get one." + ) + lines = ["Suggested automations — `/suggestions accept N` or `dismiss N`:\n"] + for i, s in enumerate(pending, 1): + spec = s.get("job_spec", {}) or {} + sched = spec.get("schedule", "?") + src = s.get("source", "?") + lines.append(f" {i}. {s.get('title', '(untitled)')} [{sched}] ({src})") + desc = s.get("description", "").strip() + if desc: + lines.append(f" {desc}") + return "\n".join(lines) + + +def _resolve_origin() -> Optional[Dict[str, Any]]: + """Best-effort current-chat origin from session env (CLI and gateway both set it). + + Mirrors cron's ``_origin_from_env`` so an accepted suggestion's job delivers + back to the chat where it was accepted. Returns None if unavailable, in + which case create_job falls back to a configured home channel. + """ + 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 handle_suggestions_command( + args: str, + *, + origin: Optional[Dict[str, Any]] = None, +) -> str: + """Dispatch a ``/suggestions`` invocation. Returns text to show the user. + + ``args`` is everything after ``/suggestions`` (already stripped of the + command word). ``origin`` is the platform/chat dict so an accepted job's + "origin" delivery routes back to where the user accepted; when omitted it + is resolved from the session environment. + """ + if origin is None: + origin = _resolve_origin() + try: + from cron import suggestions as store + except Exception as e: # pragma: no cover - import guard + logger.debug("suggestions store import failed: %s", e) + return "Suggestions are unavailable in this build." + + parts = (args or "").strip().split() + sub = parts[0].lower() if parts else "" + rest = " ".join(parts[1:]).strip() + + # Bare /suggestions -> list pending. + if not sub: + return _fmt_pending(store.list_pending()) + + if sub in ("accept", "add", "schedule"): + if not rest: + return "Usage: /suggestions accept " + job = store.accept_suggestion(rest, origin=origin) + if job is None: + return f"No pending suggestion matches '{rest}'. Run /suggestions to list them." + sched = job.get("schedule_display") or (job.get("job_spec", {}) or {}).get("schedule", "") + name = job.get("name", "automation") + return ( + f"Scheduled '{name}'" + + (f" ({sched})" if sched else "") + + ". Manage it with /cron." + ) + + if sub in ("dismiss", "no", "reject"): + if not rest: + return "Usage: /suggestions dismiss " + ok = store.dismiss_suggestion(rest) + return ( + f"Dismissed. Won't suggest that again." + if ok + else f"No pending suggestion matches '{rest}'." + ) + + if sub == "catalog": + try: + from cron.suggestion_catalog import seed_catalog_suggestions + + created = seed_catalog_suggestions() + except Exception as e: + logger.debug("catalog seed failed: %s", e) + return "Couldn't load the catalog." + if not created: + return ( + "No new catalog automations to add (already offered, dismissed, " + "or your suggestion list is full). Run /suggestions to see pending." + ) + added = ", ".join(c.get("title", "?") for c in created) + return f"Added {len(created)} suggestion(s): {added}.\nRun /suggestions to review." + + if sub == "clear": + removed = store.clear_resolved() + return f"Cleared {removed} resolved suggestion record(s)." + + return ( + "Usage:\n" + " /suggestions list pending\n" + " /suggestions accept N schedule suggestion N\n" + " /suggestions dismiss N dismiss suggestion N\n" + " /suggestions catalog add curated starter automations\n" + " /suggestions clear housekeeping" + ) diff --git a/tests/cron/test_suggestions.py b/tests/cron/test_suggestions.py new file mode 100644 index 000000000000..b8db8f54dac7 --- /dev/null +++ b/tests/cron/test_suggestions.py @@ -0,0 +1,193 @@ +"""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 +HERMES_HOME so the real suggestions.json is never touched. +""" + +import importlib +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def store(tmp_path, monkeypatch): + """A cron.suggestions module bound to an isolated HERMES_HOME.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + # Reload so module-level CRON_DIR/SUGGESTIONS_FILE pick up the temp home. + import hermes_constants + importlib.reload(hermes_constants) + import cron.suggestions as s + importlib.reload(s) + return s + + +def _add(store, key="k1", title="Test", source="catalog", schedule="0 9 * * *"): + return store.add_suggestion( + title=title, + description="desc", + source=source, + job_spec={"prompt": "do it", "schedule": schedule, "name": title, "deliver": "origin"}, + dedup_key=key, + ) + + +class TestStore: + def test_add_and_list_pending(self, store): + rec = _add(store) + assert rec is not None + pending = store.list_pending() + assert len(pending) == 1 + assert pending[0]["title"] == "Test" + assert pending[0]["status"] == "pending" + + def test_dedup_blocks_duplicate_pending(self, store): + assert _add(store, key="dup") is not None + assert _add(store, key="dup") is None # same key already pending + assert len(store.list_pending()) == 1 + + def test_dismiss_latches_against_redisplay(self, store): + _add(store, key="latch") + assert store.dismiss_suggestion("1") is True + assert store.list_pending() == [] + # Re-adding the same key is refused (never re-offer a dismissed one). + assert _add(store, key="latch") is None + + def test_unknown_source_rejected(self, store): + with pytest.raises(ValueError): + store.add_suggestion(title="x", description="d", source="bogus", job_spec={}, dedup_key="k") + + def test_pending_cap(self, store): + for i in range(store.MAX_PENDING): + assert _add(store, key=f"k{i}") is not None + # One past the cap is dropped. + assert _add(store, key="over") is None + assert len(store.list_pending()) == store.MAX_PENDING + + def test_accept_creates_job_and_marks_accepted(self, store): + _add(store, key="acc", title="My Job") + created = {} + + def fake_create_job(**kwargs): + created.update(kwargs) + return {"id": "job123", "name": kwargs.get("name"), **kwargs} + + with patch("cron.jobs.create_job", fake_create_job): + job = store.accept_suggestion("1", origin={"platform": "telegram", "chat_id": "5"}) + + assert job is not None + assert created["schedule"] == "0 9 * * *" + assert created["origin"] == {"platform": "telegram", "chat_id": "5"} + # No longer pending. + assert store.list_pending() == [] + # And accepting again is a no-op (not pending anymore). + assert store.accept_suggestion("acc") is None + + def test_get_by_id_and_index_and_title(self, store): + rec = _add(store, key="byref", title="Findable") + assert store.get_suggestion(rec["id"])["id"] == rec["id"] + assert store.get_suggestion("1")["id"] == rec["id"] + assert store.get_suggestion("findable")["id"] == rec["id"] + assert store.get_suggestion("nope") is None + + def test_clear_resolved_drops_accepted_only(self, store): + _add(store, key="a") + _add(store, key="b") + store.dismiss_suggestion("2") # b dismissed (retained for latch) + with patch("cron.jobs.create_job", lambda **k: {"id": "j"}): + store.accept_suggestion("1") # a accepted + removed = store.clear_resolved() + assert removed == 1 # only the accepted record pruned + # Dismissed record retained so its dedup_key still latches. + assert _add(store, key="b") is None + + +class TestCatalog: + def test_seed_registers_all_entries(self, store): + from cron.suggestion_catalog import CATALOG, seed_catalog_suggestions + + created = seed_catalog_suggestions(add_fn=store.add_suggestion) + assert len(created) == len(CATALOG) + assert len(store.list_pending()) == min(len(CATALOG), store.MAX_PENDING) + + def test_seed_is_idempotent(self, store): + from cron.suggestion_catalog import seed_catalog_suggestions + + first = seed_catalog_suggestions(add_fn=store.add_suggestion) + second = seed_catalog_suggestions(add_fn=store.add_suggestion) + assert len(first) >= 1 + assert second == [] # already present -> nothing new + + def test_monitor_entry_references_classifier_script(self): + from cron.suggestion_catalog import CATALOG, classify_items_script_path + + monitor = next(e for e in CATALOG if e.key == "catalog:important-mail-monitor") + assert classify_items_script_path() in monitor.job_spec["prompt"] + 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 + + spec = RecipeSpec(skill_name="morning-brief", schedule="0 8 * * *", deliver="telegram") + with patch("cron.suggestions.add_suggestion", store.add_suggestion): + rec = register_recipe_suggestion(spec) + assert rec is not None + assert rec["source"] == "recipe" + 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 + + spec = RecipeSpec(skill_name="x", schedule="every 2h", deliver="origin", prompt="p") + js = recipe_to_job_spec(spec) + assert js["skills"] == ["x"] + assert js["schedule"] == "every 2h" + assert js["prompt"] == "p" + + +class TestCommandHandler: + def test_bare_lists_pending(self, store): + _add(store, key="c1", title="Daily thing") + with patch("cron.suggestions.list_pending", store.list_pending): + from hermes_cli.suggestions_cmd import handle_suggestions_command + # Patch the module the handler imports. + with patch.dict("sys.modules"): + out = handle_suggestions_command("") + assert "Daily thing" in out + + def test_accept_via_handler(self, store): + _add(store, key="ha", title="Acceptable") + from hermes_cli.suggestions_cmd import handle_suggestions_command + + with patch("cron.jobs.create_job", lambda **k: {"id": "j", "name": k.get("name"), "job_spec": k}): + out = handle_suggestions_command("accept 1", origin={"platform": "cli", "chat_id": "1"}) + assert "Scheduled" in out + assert store.list_pending() == [] + + def test_dismiss_via_handler(self, store): + _add(store, key="hd", title="Dismissable") + from hermes_cli.suggestions_cmd import handle_suggestions_command + + out = handle_suggestions_command("dismiss 1") + assert "Dismissed" in out + assert store.list_pending() == [] + + def test_empty_list_message(self, store): + from hermes_cli.suggestions_cmd import handle_suggestions_command + + out = handle_suggestions_command("") + assert "No suggested automations" in out + + def test_aux_monitor_config_default(self): + from hermes_cli.config import DEFAULT_CONFIG + + assert "monitor" in DEFAULT_CONFIG["auxiliary"] + assert DEFAULT_CONFIG["auxiliary"]["monitor"]["provider"] == "auto" diff --git a/tests/tools/test_recipes.py b/tests/tools/test_recipes.py new file mode 100644 index 000000000000..7d9f89442f2f --- /dev/null +++ b/tests/tools/test_recipes.py @@ -0,0 +1,169 @@ +"""Tests for the recipes layer (skill frontmatter <-> cron automation bridge). + +A recipe is a skill with a metadata.hermes.recipe block. These verify parsing, +the create-job bridge, and the export round-trip without touching the real +cron store. +""" + +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from tools.recipes import ( + RecipeError, + RecipeSpec, + create_recipe_job, + export_recipe, + parse_recipe, + recipe_spec_for_installed, +) + + +RECIPE_SKILL = """--- +name: morning-brief +description: Summarize unread email and calendar every morning. +version: 1.0.0 +metadata: + hermes: + tags: [recipe, email] + recipe: + schedule: "0 8 * * *" + deliver: telegram + prompt: "Summarize my unread email and today's calendar." +--- + +# Morning Brief + +Every morning, gather unread email and the day's calendar and send a digest. +""" + +PLAIN_SKILL = """--- +name: not-a-recipe +description: Just a regular skill. +metadata: + hermes: + tags: [misc] +--- + +# Not a recipe +""" + +MALFORMED_RECIPE = """--- +name: broken +description: Recipe with no schedule. +metadata: + hermes: + recipe: + deliver: origin +--- + +# Broken +""" + + +class TestParseRecipe: + def test_parses_full_recipe(self): + spec = parse_recipe(RECIPE_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_no_frontmatter_is_not_a_recipe(self): + assert parse_recipe("just some text, no frontmatter") is None + + def test_missing_schedule_raises(self): + with pytest.raises(RecipeError): + parse_recipe(MALFORMED_RECIPE) + + 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_deliver_defaults_to_origin(self): + skill = ( + "---\nname: r\ndescription: d\nmetadata:\n hermes:\n" + ' recipe:\n schedule: "every 1h"\n---\n\nbody' + ) + spec = parse_recipe(skill) + assert spec is not None + assert spec.deliver == "origin" + + +class TestRecipeSpecForInstalled: + def test_finds_and_parses_installed_recipe(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") + + with patch("tools.skills_hub.SKILLS_DIR", skills_dir): + spec = recipe_spec_for_installed("morning-brief") + assert spec is not None + assert spec.schedule == "0 8 * * *" + + def test_missing_skill_returns_none(self, tmp_path): + 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 + + def test_plain_skill_returns_none(self, tmp_path): + skills_dir = tmp_path / "skills" + d = skills_dir / "misc" / "not-a-recipe" + 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 + + +class TestCreateRecipeJob: + def test_bridges_to_create_job(self): + spec = parse_recipe(RECIPE_SKILL) + assert spec is not None + captured = {} + + def fake_create_job(**kwargs): + captured.update(kwargs) + return {"id": "abc123", **kwargs} + + with patch("cron.jobs.create_job", fake_create_job): + job = create_recipe_job(spec, origin={"platform": "telegram"}) + + assert captured["schedule"] == "0 8 * * *" + assert captured["skills"] == ["morning-brief"] + assert captured["deliver"] == "telegram" + assert captured["prompt"].startswith("Summarize") + assert job["id"] == "abc123" + + +class TestExportRecipe: + def test_round_trips_job_to_skill_md(self): + job = { + "name": "My Morning Brief", + "schedule_display": "0 8 * * *", + "skills": ["morning-brief"], + "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) + 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): + job = {"name": "x", "schedule_display": "every 2h", "skills": ["x"]} + md = export_recipe(job, "body") + assert "recipe" in md + assert "automation" in md diff --git a/tools/recipes.py b/tools/recipes.py new file mode 100644 index 000000000000..014720801e3e --- /dev/null +++ b/tools/recipes.py @@ -0,0 +1,317 @@ +"""Recipes: 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 +agent loads) that additionally declares an automation schedule in its +frontmatter: + + metadata: + hermes: + recipe: + schedule: "0 9 * * *" # presence of `recipe:` 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 +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 + +The dev guide's "Extend, Don't Duplicate" rule is the whole design: the recipe +is a skill, the schedule is a cron job, sharing is the existing publish/tap/ +index path. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path +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", +] + + +class RecipeError(ValueError): + """Raised when a recipe block is present but malformed.""" + + +@dataclass +class RecipeSpec: + """Parsed ``metadata.hermes.recipe`` automation spec for a skill.""" + + skill_name: str + schedule: str + deliver: str = "origin" + prompt: Optional[str] = None + no_agent: bool = False + model: Optional[str] = None + provider: Optional[str] = None + enabled_toolsets: Optional[List[str]] = None + raw: Dict[str, Any] = field(default_factory=dict) + + +def _split_frontmatter(text: str) -> Optional[Dict[str, Any]]: + """Return the parsed YAML frontmatter mapping, or None if absent/invalid.""" + if not isinstance(text, str): + return None + stripped = text.lstrip() + if not stripped.startswith("---"): + return None + # Find the closing fence after the opening one. + after_open = stripped[3:] + end = after_open.find("\n---") + if end == -1: + return None + fm_text = after_open[:end] + try: + import yaml + + data = yaml.safe_load(fm_text) + except Exception as e: # pragma: no cover - malformed YAML + logger.debug("recipe: 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. + + 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 + structurally invalid (so a typo surfaces instead of silently no-op'ing). + """ + fm = _split_frontmatter(skill_md_text) + if not fm: + return None + + name = str(fm.get("name", "")).strip() + + 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: + return None + if not isinstance(recipe, dict): + raise RecipeError("metadata.hermes.recipe must be a mapping") + + schedule = str(recipe.get("schedule", "")).strip() + if not schedule: + raise RecipeError("recipe.schedule is required and must be non-empty") + + deliver = str(recipe.get("deliver", "origin")).strip() or "origin" + prompt = recipe.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") + if toolsets is not None and not isinstance(toolsets, list): + raise RecipeError("recipe.enabled_toolsets must be a list when present") + + return RecipeSpec( + skill_name=name, + schedule=schedule, + deliver=deliver, + prompt=prompt, + no_agent=no_agent, + 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, + ) + + +def recipe_spec_for_installed(skill_name: str) -> Optional[RecipeSpec]: + """Locate an installed skill's SKILL.md and parse its recipe block. + + Searches the standard skills tree for ``/SKILL.md``. Returns + None if the skill isn't found or isn't a recipe. + """ + try: + from tools.skills_hub import SKILLS_DIR + except Exception: # pragma: no cover - import guard + return None + + base = Path(SKILLS_DIR) + # Skills live at skills///SKILL.md or skills//SKILL.md. + candidates = list(base.glob(f"**/{skill_name}/SKILL.md")) + for path in candidates: + try: + text = path.read_text(encoding="utf-8") + except OSError: + continue + spec = parse_recipe(text) + if spec is not None: + # Prefer the frontmatter name, fall back to the directory name. + if not spec.skill_name: + spec.skill_name = skill_name + return spec + return None + + +def recipe_to_job_spec( + spec: RecipeSpec, + *, + name: Optional[str] = None, +) -> Dict[str, Any]: + """Build the ``cron.jobs.create_job`` kwargs dict for a RecipeSpec. + + 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. + """ + return { + "prompt": spec.prompt, + "schedule": spec.schedule, + "name": name or f"recipe:{spec.skill_name}", + "deliver": spec.deliver, + "skills": [spec.skill_name] if spec.skill_name else None, + "model": spec.model, + "provider": spec.provider, + "enabled_toolsets": spec.enabled_toolsets, + "no_agent": spec.no_agent, + } + + +def create_recipe_job( + spec: RecipeSpec, + *, + 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. + + The recipe'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) + 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. + + Recipes are source ``recipe`` of the unified suggestion surface: installing + a skill that carries a ``recipe:`` 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.). + """ + if not spec.skill_name: + return None + try: + from cron.suggestions import add_suggestion + except Exception: # pragma: no cover - import guard + return None + + return add_suggestion( + title=f"Schedule '{spec.skill_name}'", + description=( + f"The '{spec.skill_name}' recipe 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}", + ) + + +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. + + 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 + 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" + # 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" + + 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} + deliver = job.get("deliver") + if deliver and deliver != "origin": + recipe_block["deliver"] = deliver + if job.get("prompt"): + recipe_block["prompt"] = job["prompt"] + if job.get("no_agent"): + recipe_block["no_agent"] = True + if job.get("model"): + recipe_block["model"] = job["model"] + if job.get("provider"): + recipe_block["provider"] = job["provider"] + if job.get("enabled_toolsets"): + recipe_block["enabled_toolsets"] = job["enabled_toolsets"] + + description = ( + (body.strip().splitlines() or ["Shared automation recipe."])[0][:200] + if body.strip() + else "Shared automation recipe." + ) + + frontmatter = { + "name": name, + "description": description, + "version": "1.0.0", + "license": "MIT", + "metadata": { + "hermes": { + "tags": ["recipe", "automation"], + "recipe": recipe_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." + return f"---\n{fm_yaml}\n---\n\n{body_text}\n" + + +def _schedule_to_string(schedule: Any) -> str: + """Best-effort render of a parsed schedule dict back to a string.""" + if isinstance(schedule, str): + return schedule + if isinstance(schedule, dict): + kind = schedule.get("kind") + if kind == "cron" and schedule.get("expr"): + return str(schedule["expr"]) + if kind == "interval" and schedule.get("seconds"): + secs = int(schedule["seconds"]) + if secs % 3600 == 0: + return f"every {secs // 3600}h" + if secs % 60 == 0: + return f"every {secs // 60}m" + return f"every {secs}s" + return "0 9 * * *" # safe daily fallback diff --git a/website/docs/developer-guide/creating-skills.md b/website/docs/developer-guide/creating-skills.md index 50335901752e..ad3c2a7b9f73 100644 --- a/website/docs/developer-guide/creating-skills.md +++ b/website/docs/developer-guide/creating-skills.md @@ -66,6 +66,11 @@ metadata: description: "What this setting controls" default: "sensible-default" prompt: "Display prompt for setup" + recipe: # 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 + no_agent: false # optional required_environment_variables: # Optional — env vars the skill needs - name: MY_API_KEY prompt: "Enter your API key" @@ -334,6 +339,64 @@ 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 + +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: + +```yaml +metadata: + hermes: + tags: [recipe, email] + recipe: + schedule: "0 8 * * *" # presence of `recipe:` 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. + +**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`: + +```bash +hermes skills install owner/morning-brief +# → Recipe: 'morning-brief' is an automation (schedule 0 8 * * *). +# Added to your suggestions — run /suggestions to schedule or dismiss it. + +# then, in a session: +/suggestions # lists pending suggestions, numbered +/suggestions accept 1 # creates the cron job +/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. + +**Sharing an automation you built.** A recipe loaded by a cron job (`hermes cron create --skill ...`) can be exported back to a SKILL.md and published like any other skill, so an automation you tuned for yourself becomes a one-command install for someone else. + +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. + +## Suggested Cron Jobs + +Hermes can *propose* automations and let you accept them with one tap, instead of making you assemble cron jobs by hand. Every proposal flows through one surface — the `/suggestions` command — regardless of where it came from: + +| 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 | +| `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 | + +```bash +/suggestions # list pending +/suggestions accept N # schedule suggestion N (creates the cron job) +/suggestions dismiss N # dismiss it — latched, never re-offered +/suggestions catalog # add the curated starter automations +``` + +Accepting a suggestion calls the same `cron.jobs.create_job` the `cronjob` tool uses — there is no second job engine. Suggestions **never** auto-create jobs; acceptance is always explicit. Dismissed suggestions latch by a stable key so the same proposal is never re-offered. The pending list is capped so it never becomes a nag wall. + +The **important-mail monitor** catalog entry is the poll→classify→surface pattern: it scores inbox items with a cheap classifier model (`auxiliary.monitor` in `config.yaml`) and delivers only the ones above an urgency threshold, staying silent otherwise. + ## Publishing Skills ### To the Skills Hub From 60f0ef4024c94ac00025006835fa227db7ca3e7d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:49:09 -0700 Subject: [PATCH 2/7] =?UTF-8?q?feat(cron):=20Cron=20Recipes=20=E2=80=94=20?= =?UTF-8?q?parameterized=20automation=20templates=20across=20every=20surfa?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/?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. --- .gitignore | 3 + apps/desktop/electron/main.cjs | 110 +++ apps/desktop/electron/preload.cjs | 6 + apps/desktop/package.json | 8 + apps/desktop/src/app/desktop-controller.tsx | 26 + apps/desktop/src/global.d.ts | 4 + cli.py | 4 + cron/recipe_catalog.py | 688 ++++++++++++++++++ gateway/run.py | 33 + hermes_cli/cli_commands_mixin.py | 43 ++ hermes_cli/commands.py | 26 + hermes_cli/cron_recipe_cmd.py | 147 ++++ hermes_cli/web_server.py | 47 ++ tests/cron/test_recipe_catalog.py | 195 +++++ tests/hermes_cli/test_web_server.py | 36 + .../{test_recipes.py => test_cron_recipes.py} | 0 web/src/components/CronRecipes.tsx | 222 ++++++ web/src/lib/api.ts | 34 + web/src/pages/CronPage.tsx | 22 + .../docs/reference/cron-recipes-catalog.mdx | 34 + website/scripts/extract-cron-recipes.py | 50 ++ website/scripts/prebuild.mjs | 5 + website/sidebars.ts | 1 + .../components/CronRecipesCatalog/index.tsx | 117 +++ .../CronRecipesCatalog/styles.module.css | 114 +++ 25 files changed, 1975 insertions(+) create mode 100644 cron/recipe_catalog.py create mode 100644 hermes_cli/cron_recipe_cmd.py create mode 100644 tests/cron/test_recipe_catalog.py rename tests/tools/{test_recipes.py => test_cron_recipes.py} (100%) create mode 100644 web/src/components/CronRecipes.tsx create mode 100644 website/docs/reference/cron-recipes-catalog.mdx create mode 100644 website/scripts/extract-cron-recipes.py create mode 100644 website/src/components/CronRecipesCatalog/index.tsx create mode 100644 website/src/components/CronRecipesCatalog/styles.module.css diff --git a/.gitignore b/.gitignore index fa4d64049b7c..cd2e9d097c02 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +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 models-dev-upstream/ # Local editor / agent tooling (machine-specific; keep in global config, not the repo) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 9abfc216e56b..eb78830da475 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -6111,6 +6111,111 @@ ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketpla // Search the Marketplace for color-theme extensions (empty query = top installs). 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). +// 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. +// --------------------------------------------------------------------------- +const HERMES_PROTOCOL = 'hermes' +let _pendingDeepLink = null +let _rendererReadyForDeepLink = false + +function _extractDeepLink(argv) { + if (!Array.isArray(argv)) return null + return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null +} + +function handleDeepLink(url) { + if (!url || typeof url !== 'string') return + let parsed + try { + parsed = new URL(url) + } catch { + rememberLog(`[deeplink] ignoring malformed url: ${url}`) + return + } + // hermes://cron-recipe/?slot=val -> host="cron-recipe", path="/" + const kind = parsed.hostname || '' + const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, '')) + const params = {} + parsed.searchParams.forEach((v, k) => { + params[k] = v + }) + const payload = { kind, name, params } + + if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) { + _pendingDeepLink = payload + return + } + try { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + mainWindow.webContents.send('hermes:deep-link', payload) + rememberLog(`[deeplink] delivered ${kind}/${name}`) + } catch (err) { + rememberLog(`[deeplink] delivery failed: ${err.message}`) + } +} + +// Renderer calls this (via IPC) once it has mounted its deep-link listener, so +// a link that arrived during boot/install is flushed exactly once. +ipcMain.handle('hermes:deep-link-ready', () => { + _rendererReadyForDeepLink = true + if (_pendingDeepLink) { + const queued = _pendingDeepLink + _pendingDeepLink = null + handleDeepLink( + `${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` + + (Object.keys(queued.params).length + ? '?' + new URLSearchParams(queued.params).toString() + : ''), + ) + } + return { ok: true } +}) + +function registerDeepLinkProtocol() { + try { + if (process.defaultApp && process.argv.length >= 2) { + // Dev: register with the electron exec path + entry script so the OS can + // relaunch us with the URL. + app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [ + path.resolve(process.argv[1]), + ]) + } else { + app.setAsDefaultProtocolClient(HERMES_PROTOCOL) + } + } catch (err) { + rememberLog(`[deeplink] protocol registration failed: ${err.message}`) + } +} + +// Single-instance lock: deep links on a running app (Win/Linux) arrive as a +// second-instance argv. Without the lock a second `hermes://` launch spawns a +// whole new app instead of routing into the running one. +const _gotSingleInstanceLock = app.requestSingleInstanceLock() +if (!_gotSingleInstanceLock) { + app.quit() +} else { + app.on('second-instance', (_event, argv) => { + const url = _extractDeepLink(argv) + if (url) handleDeepLink(url) + else if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + } + }) +} + +// macOS delivers deep links via 'open-url' — register early (can fire before +// whenReady; handleDeepLink queues until the renderer is ready). +app.on('open-url', (event, url) => { + event.preventDefault() + handleDeepLink(url) +}) + + app.whenReady().then(() => { if (IS_MAC) { Menu.setApplicationMenu(buildApplicationMenu()) @@ -6119,11 +6224,16 @@ app.whenReady().then(() => { } installMediaPermissions() registerMediaProtocol() + registerDeepLinkProtocol() ensureWslWindowsFonts() configureSpellChecker() registerPowerResumeListeners() createWindow() + // Win/Linux cold start: the launching hermes:// URL is in our own argv. + const _coldStartLink = _extractDeepLink(process.argv) + if (_coldStartLink) handleDeepLink(_coldStartLink) + app.on('activate', () => { // Recreate the primary window if it's gone. Guard on mainWindow directly // (not just total window count) so a dock click still restores the main diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index d39bc88fb684..9880d4bcf585 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -80,6 +80,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', { ipcRenderer.on('hermes:open-updates', listener) return () => ipcRenderer.removeListener('hermes:open-updates', listener) }, + onDeepLink: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:deep-link', listener) + return () => ipcRenderer.removeListener('hermes:deep-link', listener) + }, + signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'), onWindowStateChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:window-state-changed', listener) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d03bd7cd0ad6..8df63468f546 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -132,6 +132,14 @@ "appId": "com.nousresearch.hermes", "productName": "Hermes", "executableName": "Hermes", + "protocols": [ + { + "name": "Hermes Protocol", + "schemes": [ + "hermes" + ] + } + ], "artifactName": "Hermes-${version}-${os}-${arch}.${ext}", "icon": "assets/icon", "directories": { diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 0da266395449..43b8ab4c9004 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -11,6 +11,7 @@ import { Pane, PaneMain } from '@/components/pane-shell' import { useMediaQuery } from '@/hooks/use-media-query' import { useSkinCommand } from '@/themes/use-skin-command' +import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus' import { formatRefValue } from '../components/assistant-ui/directive-text' import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes' import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' @@ -266,6 +267,31 @@ 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 + // 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) { + return + } + const slots = Object.entries(payload.params || {}) + .map(([k, v]) => { + const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v + return `${k}=${sval}` + }) + .join(' ') + const command = `/cron-recipe ${payload.name}${slots ? ' ' + slots : ''}` + requestComposerInsert(command, { mode: 'block', target: 'main' }) + requestComposerFocus('main') + }) + // Tell the main process the renderer is ready to receive deep links. + void window.hermesDesktop?.signalDeepLinkReady?.() + return () => unsubscribe?.() + }, []) + useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (!$filePreviewTarget.get() && !$previewTarget.get()) { diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 68e104212e96..0246df344c50 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -75,6 +75,10 @@ declare global { } onClosePreviewRequested?: (callback: () => void) => () => void onOpenUpdatesRequested?: (callback: () => void) => () => void + onDeepLink?: ( + callback: (payload: { kind: string; name: string; params: Record }) => void, + ) => () => void + signalDeepLinkReady?: () => Promise<{ ok: boolean }> onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void onBackendExit: (callback: (payload: BackendExit) => void) => () => void diff --git a/cli.py b/cli.py index 86875bcb60cd..8834f1254755 100644 --- a/cli.py +++ b/cli.py @@ -7409,6 +7409,10 @@ def process_command(self, command: str) -> bool: self.save_conversation() elif canonical == "cron": 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 == "curator": self._handle_curator_command(cmd_original) elif canonical == "kanban": diff --git a/cron/recipe_catalog.py b/cron/recipe_catalog.py new file mode 100644 index 000000000000..0e7a3e1280aa --- /dev/null +++ b/cron/recipe_catalog.py @@ -0,0 +1,688 @@ +"""Cron Recipes — parameterized automation templates with typed slots. + +A *recipe* 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 + * 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 +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 +in ``schedule_template`` and parameterizes only the human-friendly parts +(time-of-day, weekday set). Recipes needing full flexibility expose a ``text`` +slot named ``schedule`` that passes through verbatim. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +__all__ = [ + "RecipeSlot", + "CronRecipe", + "CATALOG", + "get_recipe", + "recipe_form_schema", + "recipe_slash_command", + "recipe_deeplink", + "recipe_catalog_entry", + "fill_recipe", + "RecipeFillError", + "WEEKDAY_PRESETS", +] + + +class RecipeFillError(ValueError): + """Raised when supplied slot values fail validation.""" + + +# Slot types the renderers understand. +_SLOT_TYPES = frozenset({"time", "enum", "text", "weekdays"}) + +# Named weekday recurrences -> cron day-of-week field. +WEEKDAY_PRESETS: Dict[str, str] = { + "everyday": "*", + "weekdays": "1-5", + "weekends": "0,6", +} + + +@dataclass(frozen=True) +class RecipeSlot: + """A single fillable field on a recipe.""" + + name: str + type: str + label: str + default: Any = None + options: tuple = () # for type="enum": allowed values + optional: bool = False + help: str = "" + + def __post_init__(self) -> None: + if self.type not in _SLOT_TYPES: + raise ValueError(f"unknown slot type {self.type!r} (slot {self.name})") + + +@dataclass(frozen=True) +class CronRecipe: + """A parameterized automation template.""" + + key: str + title: str + description: str + category: str + # Cron expression with ``{slot}`` placeholders, e.g. "{minute} {hour} * * {dow}". + # Placeholders are filled from resolved slot values (time -> minute/hour, + # weekdays -> dow). A literal cron string with no placeholders = fixed schedule. + 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) + deliver_default: str = "origin" + skills: tuple = () # skills the job loads before running + tags: tuple = () + + +# --------------------------------------------------------------------------- +# Curated in-repo catalog +# --------------------------------------------------------------------------- + +_TIME = lambda default="08:00": RecipeSlot( # noqa: E731 - concise factory + name="time", type="time", label="What time?", default=default, + help="24h local time, e.g. 08:00", +) +_DELIVER = RecipeSlot( + name="deliver", type="enum", label="Where to deliver?", + default="origin", options=("origin", "local", "telegram", "discord", "email"), + help="origin = the chat you set this up from; local = save only, no message", +) + + +CATALOG: List[CronRecipe] = [ + CronRecipe( + key="morning-brief", + title="Morning briefing", + description="A short daily briefing: today's calendar, weather, and " + "anything urgent waiting on you.", + category="daily", + schedule_template="{minute} {hour} * * *", + prompt_template=( + "Produce a concise morning briefing for the user: today's calendar " + "events, the local weather, and any urgent items. Keep it short and " + "scannable. If no data sources are connected, give a brief " + "good-morning with the date and offer to connect calendar/email." + ), + slots=[_TIME("08:00"), _DELIVER], + tags=("daily", "briefing"), + ), + CronRecipe( + key="important-mail", + title="Important-mail monitor", + description="Check your inbox periodically and ping you ONLY about mail " + "that actually needs attention.", + category="email", + schedule_template="*/{interval_min} * * * *", + prompt_template=( + "Check the user's inbox for new messages since the last run. Surface " + "ONLY mail matching: {criteria}. Score candidates with the urgency " + "classifier and deliver only what clears the bar; if nothing does, " + "respond with [SILENT]. Requires a connected mail source; if none is " + "configured, explain how to connect one and stop." + ), + slots=[ + RecipeSlot( + name="interval_min", type="enum", label="How often?", + default="30", options=("15", "30", "60"), + help="minutes between checks", + ), + RecipeSlot( + name="criteria", type="text", + label="Only notify me if the mail…", + default="needs a reply today, is from my manager or family, " + "or mentions a deadline", + ), + _DELIVER, + ], + tags=("email", "monitor"), + ), + CronRecipe( + key="weekly-review", + title="Weekly review", + description="A weekly recap: what got done, what's still open, and " + "what's coming up.", + category="weekly", + schedule_template="{minute} {hour} * * {dow}", + prompt_template=( + "Produce a weekly review for the user: what was accomplished this " + "week, still-open items, and next week's calendar. Pull from " + "connected sources. Keep it tight." + ), + slots=[ + _TIME("18:00"), + RecipeSlot( + name="day", type="enum", label="Which day?", + default="sunday", + options=("sunday", "monday", "friday", "saturday"), + ), + _DELIVER, + ], + tags=("weekly", "review"), + ), + CronRecipe( + key="workday-start", + title="Workday start reminder", + description="A weekday nudge with your agenda and top priorities.", + category="daily", + schedule_template="{minute} {hour} * * 1-5", + prompt_template=( + "Give the user a brief weekday start-of-day nudge: today's calendar " + "and the 1-3 highest-priority things to focus on, inferred from " + "recent context and any task tools. Encouraging, short, one message." + ), + slots=[_TIME("09:00"), _DELIVER], + tags=("daily", "focus"), + ), + CronRecipe( + key="custom-reminder", + title="Custom reminder", + description="A recurring reminder in your own words, on your schedule.", + category="general", + schedule_template="{minute} {hour} * * {dow}", + prompt_template="Remind the user: {what}", + slots=[ + RecipeSlot(name="what", type="text", label="Remind me to…", + default="take a break and stretch"), + _TIME("14:00"), + RecipeSlot( + name="recurrence", type="weekdays", label="Repeat on", + default="everyday", + options=tuple(WEEKDAY_PRESETS.keys()), + ), + _DELIVER, + ], + tags=("reminder",), + ), + CronRecipe( + key="evening-winddown", + title="Evening wind-down", + description="An end-of-day check-in: tomorrow's calendar at a glance " + "and anything you should prep tonight.", + category="daily", + schedule_template="{minute} {hour} * * *", + prompt_template=( + "Give the user a short evening wind-down: tomorrow's calendar, any " + "early commitments to prep for, and one gentle nudge to wrap up " + "loose ends from today. Keep it calm and brief — one message. If no " + "calendar is connected, just offer a friendly sign-off and the " + "weather for tomorrow." + ), + slots=[_TIME("21:00"), _DELIVER], + tags=("daily", "evening"), + ), + CronRecipe( + key="news-digest", + title="Topic news digest", + description="A recurring digest on a topic you care about — deduped " + "against what was already sent, so only genuinely new items land.", + category="general", + schedule_template="{minute} {hour} * * {dow}", + prompt_template=( + "Search the web for new and noteworthy items about: {topic}. " + "Dedupe against what you sent in previous runs — only include " + "genuinely new developments. Deliver a tight digest of at most " + "{count} bullets, each one line with a link. If nothing new since " + "last run, respond with [SILENT]." + ), + slots=[ + RecipeSlot( + name="topic", type="text", label="What topic?", + default="AI and technology", + help="a subject, product, person, or search phrase", + ), + _TIME("18:00"), + RecipeSlot( + name="recurrence", type="weekdays", label="Repeat on", + default="weekdays", + options=tuple(WEEKDAY_PRESETS.keys()), + ), + RecipeSlot( + name="count", type="enum", label="How many bullets?", + default="5", options=("3", "5", "8"), + ), + _DELIVER, + ], + tags=("digest", "research"), + ), + CronRecipe( + key="bill-renewal-watch", + title="Bills & renewals reminder", + description="A heads-up before a recurring payment, subscription " + "renewal, or due date — so nothing auto-charges by surprise.", + category="general", + schedule_template="{minute} {hour} * * {dow}", + prompt_template=( + "Remind the user about an upcoming payment or renewal: {what}. " + "Phrase it as an actionable heads-up (e.g. 'review or cancel before " + "it renews'), not just a notification. One short message." + ), + slots=[ + RecipeSlot( + name="what", type="text", label="What's due?", + default="my streaming subscription renews soon", + ), + _TIME("10:00"), + RecipeSlot( + name="recurrence", type="weekdays", label="Repeat on", + default="everyday", + options=tuple(WEEKDAY_PRESETS.keys()), + ), + _DELIVER, + ], + tags=("reminder", "finance"), + ), + CronRecipe( + key="habit-checkin", + title="Habit check-in", + description="A recurring nudge to keep a habit on track and reflect " + "on whether you did it.", + category="general", + schedule_template="{minute} {hour} * * {dow}", + prompt_template=( + "Nudge the user about their habit: {habit}. Ask whether they did it " + "today, keep it warm and non-judgmental, and offer a one-line word " + "of encouragement. One short message." + ), + slots=[ + RecipeSlot( + name="habit", type="text", label="Which habit?", + default="20 minutes of reading", + ), + _TIME("20:00"), + RecipeSlot( + name="recurrence", type="weekdays", label="Repeat on", + default="everyday", + options=tuple(WEEKDAY_PRESETS.keys()), + ), + _DELIVER, + ], + tags=("habit", "wellbeing"), + ), + CronRecipe( + key="hydration-move", + title="Hydration & movement nudge", + description="A periodic nudge during the day to drink water, stand up, " + "and stretch.", + category="general", + schedule_template="*/{interval_min} {start_hour}-{end_hour} * * 1-5", + prompt_template=( + "Send the user a brief, friendly nudge to drink some water, stand " + "up, and stretch for a moment. Vary the wording each time so it " + "doesn't feel robotic. One short line." + ), + slots=[ + RecipeSlot( + name="interval_min", type="enum", label="How often?", + default="90", options=("60", "90", "120"), + help="minutes between nudges", + ), + RecipeSlot( + name="start_hour", type="enum", label="Start hour", + default="9", options=("7", "8", "9", "10"), + help="first hour of the active window (24h)", + ), + RecipeSlot( + name="end_hour", type="enum", label="End hour", + default="17", options=("16", "17", "18", "19"), + help="last hour of the active window (24h)", + ), + _DELIVER, + ], + tags=("wellbeing", "focus"), + ), + CronRecipe( + key="meal-plan", + title="Weekly meal plan", + description="A weekly meal plan plus a consolidated grocery list, " + "tuned to your diet and how much time you have to cook.", + category="weekly", + schedule_template="{minute} {hour} * * {dow}", + 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 " + "simple and skimmable." + ), + slots=[ + RecipeSlot( + name="diet", type="enum", label="Diet?", + default="no restrictions", + options=("no restrictions", "vegetarian", "vegan", + "high-protein", "low-carb"), + ), + RecipeSlot( + name="meals", type="enum", label="Meals per day?", + default="dinner only", + options=("dinner only", "lunch and dinner", "all three"), + ), + RecipeSlot( + name="effort", type="enum", label="Cooking effort?", + default="quick", options=("quick", "medium", "ambitious"), + ), + _TIME("17:00"), + RecipeSlot( + name="day", type="enum", label="Which day?", + default="sunday", + options=("sunday", "monday", "friday", "saturday"), + ), + _DELIVER, + ], + tags=("weekly", "food"), + ), + CronRecipe( + key="learn-daily", + title="Daily learning drip", + description="One bite-sized lesson a day on a topic you want to learn, " + "building progressively over time.", + category="daily", + schedule_template="{minute} {hour} * * {dow}", + prompt_template=( + "Teach the user one bite-sized lesson about: {topic}. Build on " + "earlier lessons so it progresses rather than repeating. Keep it to " + "a couple of short paragraphs with one concrete example, and end " + "with a single question to check understanding." + ), + slots=[ + RecipeSlot( + name="topic", type="text", label="Learn about…", + default="Spanish vocabulary", + ), + _TIME("08:30"), + RecipeSlot( + name="recurrence", type="weekdays", label="Repeat on", + default="weekdays", + options=tuple(WEEKDAY_PRESETS.keys()), + ), + _DELIVER, + ], + tags=("learning", "daily"), + ), + CronRecipe( + key="gratitude-journal", + title="Gratitude & reflection prompt", + description="A gentle evening prompt to reflect on the day and note " + "what went well.", + category="general", + schedule_template="{minute} {hour} * * {dow}", + prompt_template=( + "Send the user a short, warm reflection prompt for the end of the " + "day — invite them to note one thing that went well, one thing they " + "are grateful for, and one small win. If they reply, acknowledge it " + "kindly. One message." + ), + slots=[ + _TIME("21:30"), + RecipeSlot( + name="recurrence", type="weekdays", label="Repeat on", + default="everyday", + options=tuple(WEEKDAY_PRESETS.keys()), + ), + _DELIVER, + ], + tags=("wellbeing", "reflection"), + ), + CronRecipe( + key="on-this-day", + title="On-this-day discovery", + description="A daily dose of curiosity: a notable historical event, " + "fact, or word for the day.", + category="daily", + schedule_template="{minute} {hour} * * *", + prompt_template=( + "Give the user one interesting '{flavor}' item for today — keep it " + "short, surprising, and genuinely interesting. One or two sentences, " + "no filler." + ), + slots=[ + RecipeSlot( + name="flavor", type="enum", label="What kind?", + default="on this day in history", + options=("on this day in history", "word of the day", + "science fact", "quote of the day"), + ), + _TIME("07:30"), + _DELIVER, + ], + tags=("daily", "curiosity"), + ), +] + +_CATALOG_BY_KEY = {r.key: r for r in CATALOG} + + +def get_recipe(key: str) -> Optional[CronRecipe]: + return _CATALOG_BY_KEY.get(key) + + +# --------------------------------------------------------------------------- +# Renderers +# --------------------------------------------------------------------------- + +def recipe_form_schema(recipe: CronRecipe) -> Dict[str, Any]: + """Emit the JSON a form renderer (dashboard / GUI) needs for this recipe.""" + return { + "key": recipe.key, + "title": recipe.title, + "description": recipe.description, + "category": recipe.category, + "tags": list(recipe.tags), + "fields": [ + { + "name": s.name, + "type": s.type, + "label": s.label, + "default": s.default, + "options": list(s.options), + "optional": s.optional, + "help": s.help, + } + for s in recipe.slots + ], + } + + +def recipe_slash_command(recipe: CronRecipe, values: Optional[Dict[str, Any]] = None) -> str: + """Build the flattened ``/cron-recipe 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: + val = values.get(s.name, s.default) + if val is None or val == "": + if s.optional: + continue + val = "" + sval = str(val) + if s.type == "text" or " " in sval: + sval = '"' + sval.replace('"', '\\"') + '"' + parts.append(f"{s.name}={sval}") + return " ".join(parts) + + +def recipe_deeplink(recipe: CronRecipe, values: Optional[Dict[str, Any]] = None) -> str: + """Build the ``hermes://cron-recipe/?slot=val`` deep-link URL.""" + from urllib.parse import quote, urlencode + + values = values or {} + query = {} + for s in recipe.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}" + + +def _humanize_schedule(recipe: CronRecipe) -> str: + """A short human-readable description of when a recipe runs (defaults).""" + sched = recipe.schedule_template + if sched.startswith("*/"): + iv = next((s for s in recipe.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" + time_slot = next((s for s in recipe.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) + scope = (day_slot.default if day_slot else "") or "" + if scope and when: + return f"{scope} at {when}" + return f"at {when}" if when else "on a schedule" + if when: + return f"daily at {when}" + return "on a schedule" + + +def recipe_catalog_entry(recipe: CronRecipe) -> Dict[str, Any]: + """Unified serializable shape for a recipe — 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), + } + + +# --------------------------------------------------------------------------- +# Fill + validate + translate to a create_job spec +# --------------------------------------------------------------------------- + +_TIME_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$") +_DAY_TO_DOW = { + "sunday": "0", "monday": "1", "tuesday": "2", "wednesday": "3", + "thursday": "4", "friday": "5", "saturday": "6", +} + + +def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str: + """Fill the schedule_template placeholders from resolved slot values.""" + sched = recipe.schedule_template + + # A free-text `schedule` slot passes through verbatim (full flexibility). + if "schedule" in values and values["schedule"]: + return str(values["schedule"]) + + repl: Dict[str, str] = {} + + # time -> minute/hour + time_val = values.get("time") + if "{minute}" in sched or "{hour}" in sched: + if not time_val: + raise RecipeFillError("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)") + repl["hour"] = str(int(m.group(1))) + repl["minute"] = str(int(m.group(2))) + + # weekday set -> dow + if "{dow}" in sched: + if "recurrence" in values: + preset = str(values.get("recurrence", "everyday")).lower() + if preset not in WEEKDAY_PRESETS: + raise RecipeFillError( + 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}") + repl["dow"] = _DAY_TO_DOW[day] + else: + repl["dow"] = "*" + + # interval (minutes) for */N schedules + 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") + 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. + for name in re.findall(r"\{(\w+)\}", sched): + if name not in repl and name in values: + repl[name] = str(values[name]) + + 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 + + +def fill_recipe( + recipe: CronRecipe, + 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 + slot, so a form can show field errors and the agent knows what to ask. + Enum values are checked against their options. The result is passed + straight to ``create_job`` — no second schema. + """ + resolved: Dict[str, Any] = {} + for s in recipe.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})") + if s.type == "enum" and s.options and str(raw) not in {str(o) for o in s.options}: + raise RecipeFillError( + f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}" + ) + resolved[s.name] = raw + + schedule = _resolve_schedule(recipe, resolved) + + # Render the prompt with whatever slots it references. + try: + prompt = recipe.prompt_template.format(**resolved) + except KeyError as e: + raise RecipeFillError(f"recipe 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), + } + if recipe.skills: + spec["skills"] = list(recipe.skills) + if origin is not None: + spec["origin"] = origin + return spec diff --git a/gateway/run.py b/gateway/run.py index faab5714079e..ff6de3289f70 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7174,6 +7174,9 @@ async def _do_reset(): if canonical == "suggestions": return await self._handle_suggestions_command(event) + if canonical == "cron-recipe": + return await self._handle_cron_recipe_command(event) + if canonical == "retry": return await self._handle_retry_command(event) @@ -9270,6 +9273,36 @@ async def _handle_suggestions_command(self, event: MessageEvent) -> str: logger.debug("suggestions command failed: %s", e) return f"Suggestions command failed: {e}" + async def _handle_cron_recipe_command(self, event: MessageEvent) -> str: + """Handle /cron-recipe in the gateway. + + Delegates to the shared handler so CLI, TUI, and gateway never drift. + Origin is built from the event source so a created recipe job delivers + back to this chat/thread. + """ + args = (event.get_command_args() or "").strip() + source = event.source + origin = None + try: + platform = getattr(source.platform, "value", None) or str(getattr(source, "platform", "") or "") + chat_id = getattr(source, "chat_id", None) + if platform and chat_id: + origin = { + "platform": platform, + "chat_id": str(chat_id), + "chat_name": getattr(source, "chat_name", None), + "thread_id": getattr(source, "thread_id", None), + } + except Exception: + origin = None + try: + from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + + return handle_cron_recipe_command(args, origin=origin) + except Exception as e: + logger.debug("cron-recipe command failed: %s", e) + return f"Cron recipe command failed: {e}" + # ──────────────────────────────────────────────────────────────── # /goal — persistent cross-turn goals (Ralph-style loop) # ──────────────────────────────────────────────────────────────── diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index ffb39d9e9569..97e84d86a0d4 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1255,6 +1255,49 @@ def _parse_flags(tokens): 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. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index cff8db21d896..dc931820e6ce 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -182,6 +182,8 @@ class 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 @@ def discord_skill_commands_by_category( "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 ). +_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 _add(name: str, desc: str, hint: str) -> None: 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): diff --git a/hermes_cli/cron_recipe_cmd.py b/hermes_cli/cron_recipe_cmd.py new file mode 100644 index 000000000000..cb542f48622a --- /dev/null +++ b/hermes_cli/cron_recipe_cmd.py @@ -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 show that recipe's slots + a ready command + /cron-recipe 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 ` 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:]) + + # `` with no slot args -> show the recipe's fields + a ready command. + if not values: + return _fmt_recipe(recipe) + + # ` 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." + ) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ad70a46491f0..05586231820f 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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. # diff --git a/tests/cron/test_recipe_catalog.py b/tests/cron/test_recipe_catalog.py new file mode 100644 index 000000000000..afc4dbeeb869 --- /dev/null +++ b/tests/cron/test_recipe_catalog.py @@ -0,0 +1,195 @@ +"""Tests for Cron Recipes — 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 +the docs generator. Uses an isolated HERMES_HOME for anything that touches the +cron job store. +""" + +import importlib +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from cron.recipe_catalog import ( + CATALOG, + RecipeFillError, + RecipeSlot, + fill_recipe, + get_recipe, + recipe_catalog_entry, + recipe_deeplink, + recipe_form_schema, + recipe_slash_command, +) + + +class TestCatalog: + def test_catalog_nonempty_and_keyed(self): + assert len(CATALOG) >= 1 + for r in CATALOG: + assert get_recipe(r.key) is r + + def test_every_slot_has_known_type(self): + for r in CATALOG: + for s in r.slots: + assert s.type in {"time", "enum", "text", "weekdays"} + + def test_bad_slot_type_rejected(self): + with pytest.raises(ValueError): + RecipeSlot(name="x", type="bogus", label="X") + + +class TestScheduleResolution: + def test_time_to_cron(self): + spec = fill_recipe(get_recipe("morning-brief"), {"time": "08:30"}) + assert spec["schedule"] == "30 8 * * *" + + def test_interval_schedule(self): + spec = fill_recipe( + get_recipe("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"), + {"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"), + {"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"), {}) + 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"}) + + def test_bad_enum_rejected_and_names_slot(self): + with pytest.raises(RecipeFillError, match="not allowed"): + fill_recipe(get_recipe("morning-brief"), {"time": "08:00", "deliver": "pigeon"}) + + def test_text_slot_renders_into_prompt(self): + spec = fill_recipe( + get_recipe("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"} + ) + assert spec["origin"] == {"platform": "telegram", "chat_id": "9"} + + +class TestRenderers: + def test_form_schema_fields(self): + schema = recipe_form_schema(get_recipe("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") + 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"} + ) + 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?") + 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") + assert entry["appUrl"].startswith("hermes://") + assert entry["scheduleHuman"] + assert "fields" in entry + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + import hermes_constants + importlib.reload(hermes_constants) + import cron.jobs as jobs + importlib.reload(jobs) + return jobs + + +class TestCommandHandler: + def test_bare_lists_catalog(self, isolated_home): + from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + + out = handle_cron_recipe_command("") + assert "morning-brief" in out and "Cron Recipes" in out + + def test_show_recipe_fields(self, isolated_home): + from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + + out = handle_cron_recipe_command("morning-brief") + assert "Fields:" in out and "time" in out + + def test_fill_creates_job(self, isolated_home): + from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + + out = handle_cron_recipe_command("morning-brief time=07:30 deliver=telegram") + assert "Scheduled" in out + jobs = isolated_home.load_jobs() + assert len(jobs) == 1 + 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 + + out = handle_cron_recipe_command("does-not-exist") + assert "No cron recipe" in out + + def test_bad_value_names_slot(self, isolated_home): + from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + + out = handle_cron_recipe_command("morning-brief time=99:99") + assert "Can't set up" in out and "time" in out + + +class TestDocsGenerator: + def test_generator_emits_valid_index(self, tmp_path): + # The generator imports the catalog and writes a flat JSON array. + import importlib.util + + script = ( + Path(__file__).resolve().parents[2] + / "website" / "scripts" / "extract-cron-recipes.py" + ) + spec = importlib.util.spec_from_file_location("extract_cron_recipes", script) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + index = mod.build_index() + assert isinstance(index, list) and len(index) == len(CATALOG) + # Each entry must round-trip through json and carry the surfaces. + json.dumps(index) + assert all("command" in e and "appUrl" in e for e in index) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index dc92f7cefb4f..0a6ba0607e9a 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2360,6 +2360,42 @@ def test_cron_job_not_found(self): resp = self.client.get("/api/cron/jobs/nonexistent-id") assert resp.status_code == 404 + # --- Cron Recipes --- + + def test_cron_recipes_list(self): + resp = self.client.get("/api/cron/recipes") + assert resp.status_code == 200 + recipes = resp.json()["recipes"] + assert len(recipes) >= 1 + first = recipes[0] + assert "fields" in first + assert first["command"].startswith("/cron-recipe") + assert first["appUrl"].startswith("hermes://") + + def test_cron_recipe_instantiate_creates_job(self): + resp = self.client.post( + "/api/cron/recipes/instantiate", + json={"recipe": "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): + resp = self.client.post( + "/api/cron/recipes/instantiate", + json={"recipe": "does-not-exist", "values": {}}, + ) + assert resp.status_code == 404 + + def test_cron_recipe_instantiate_bad_value_422(self): + resp = self.client.post( + "/api/cron/recipes/instantiate", + json={"recipe": "morning-brief", "values": {"time": "99:99"}}, + ) + assert resp.status_code == 422 + # --- Profiles --- def test_profiles_list_includes_default(self): diff --git a/tests/tools/test_recipes.py b/tests/tools/test_cron_recipes.py similarity index 100% rename from tests/tools/test_recipes.py rename to tests/tools/test_cron_recipes.py diff --git a/web/src/components/CronRecipes.tsx b/web/src/components/CronRecipes.tsx new file mode 100644 index 000000000000..ce9f08b62dce --- /dev/null +++ b/web/src/components/CronRecipes.tsx @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useState } from "react"; +import { Clock, Wand2 } from "lucide-react"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { Card, CardContent } from "@nous-research/ui/ui/components/card"; +import { Input } from "@nous-research/ui/ui/components/input"; +import { Label } from "@nous-research/ui/ui/components/label"; +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 { cn, themedBody } from "@/lib/utils"; + +interface CronRecipesProps { + profile: string; + /** Called after a recipe 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 { + const out: Record = {}; + for (const f of recipe.fields) out[f.name] = f.default ?? ""; + return out; +} + +function FieldInput({ + field, + value, + onChange, +}: { + field: CronRecipeField; + value: string; + onChange: (v: string) => void; +}) { + if (field.type === "enum" || field.type === "weekdays") { + return ( + + ); + } + if (field.type === "time") { + return ( + onChange(e.target.value)} + /> + ); + } + // text + return ( + onChange(e.target.value)} + /> + ); +} + +function RecipeCard({ + recipe, + profile, + showToast, + onCreated, +}: { + recipe: CronRecipe; + profile: string; + showToast: (message: string, type: "error" | "success") => void; + onCreated?: () => void; +}) { + const [open, setOpen] = useState(false); + const [values, setValues] = useState>(() => initialValues(recipe)); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const submit = useCallback(async () => { + setSubmitting(true); + setError(null); + try { + const job = await api.instantiateCronRecipe({ recipe: recipe.key, values }, profile); + const when = job.schedule_display ? ` — ${job.schedule_display}` : ""; + showToast(`${recipe.title} scheduled${when}`, "success"); + setOpen(false); + setValues(initialValues(recipe)); + onCreated?.(); + } catch (e) { + // 422 from the API carries the slot-level validation message. + const msg = e instanceof Error ? e.message : String(e); + setError(msg.replace(/^\d+:\s*/, "")); + } finally { + setSubmitting(false); + } + }, [recipe, values, profile, showToast, onCreated]); + + return ( + + +
+
+
+ + {recipe.title} +
+

{recipe.description}

+
+ {recipe.tags.map((t) => ( + + {t} + + ))} +
+
+ +
+ + {open && ( +
+ {recipe.fields.map((f) => ( +
+ + setValues((prev) => ({ ...prev, [f.name]: v }))} + /> + {f.help && f.type !== "text" ? ( +

{f.help}

+ ) : null} +
+ ))} + {error ? ( +

+ {error} +

+ ) : null} +
+ +
+
+ )} +
+
+ ); +} + +/** + * Cron Recipes gallery — the form-where-there's-a-screen surface. Each recipe + * 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 + * via the same create_job path as everything else. + */ +export function CronRecipes({ profile, onCreated }: CronRecipesProps) { + const { toast, showToast } = useToast(); + const [recipes, setRecipes] = useState(null); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + let cancelled = false; + api + .getCronRecipes() + .then((r) => { + if (!cancelled) setRecipes(r.recipes); + }) + .catch((e) => { + if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e)); + }); + return () => { + cancelled = true; + }; + }, []); + + if (loadError) { + return

Couldn't load recipes: {loadError}

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

No cron recipes available.

; + } + + return ( + <> + +
+ {recipes.map((r) => ( + + ))} +
+ + ); +} + +export default CronRecipes; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7349e8596d43..4f9b7461151b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -497,6 +497,19 @@ 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 }, + profile = "default", + ) => + fetchJSON(`/api/cron/recipes/instantiate?profile=${encodeURIComponent(profile)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + // Profiles getProfiles: () => fetchJSON<{ profiles: ProfileInfo[] }>("/api/profiles"), @@ -1825,6 +1838,27 @@ export interface CronDeliveryTarget { home_env_var: string | null; } +export interface CronRecipeField { + name: string; + type: "time" | "enum" | "text" | "weekdays"; + label: string; + default: string | null; + options: string[]; + optional: boolean; + help: string; +} + +export interface CronRecipe { + key: string; + title: string; + description: string; + category: string; + tags: string[]; + fields: CronRecipeField[]; + command: string; + appUrl: string; +} + export interface SkillInfo { name: string; description: string; diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 32ed45e783d4..23c30f3d109a 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -29,6 +29,8 @@ import { Label } from "@nous-research/ui/ui/components/label"; 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 { cn, themedBody } from "@/lib/utils"; function formatTime(iso?: string | null): string { @@ -176,6 +178,7 @@ export default function CronPage() { const [jobs, setJobs] = useState([]); const [profiles, setProfiles] = useState([]); const [selectedProfile, setSelectedProfile] = useState("all"); + const [view, setView] = useState<"jobs" | "recipes">("jobs"); const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); const { t, locale } = useI18n(); @@ -507,6 +510,23 @@ export default function CronPage() { + setView(v as "jobs" | "recipes")} + options={[ + { value: "jobs", label: "Jobs" }, + { value: "recipes", label: "Recipes" }, + ]} + /> + + {view === "recipes" && ( + + )} + + )} + {view === "jobs" && (

+ )}

diff --git a/website/docs/reference/cron-recipes-catalog.mdx b/website/docs/reference/cron-recipes-catalog.mdx new file mode 100644 index 000000000000..988b976b5794 --- /dev/null +++ b/website/docs/reference/cron-recipes-catalog.mdx @@ -0,0 +1,34 @@ +--- +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** — copy a recipe's `/cron-recipe` command below, + edit the values, and send it. Hermes fills in anything you leave out and + asks if something's ambiguous. +- **Desktop app** — click **Send to App** on any recipe and it opens with the + command pre-loaded in your composer. + +Recipes never schedule anything silently — you always confirm before the job +is created. Manage created jobs anytime with `/cron`. + + + +## Writing your own + +A recipe is just a skill with a `metadata.hermes.recipe` block in its +`SKILL.md` frontmatter. See +[Creating Skills → Cron Recipes](../developer-guide/creating-skills.md) for the +slot schema and how to publish one. diff --git a/website/scripts/extract-cron-recipes.py b/website/scripts/extract-cron-recipes.py new file mode 100644 index 000000000000..5c833f8b9306 --- /dev/null +++ b/website/scripts/extract-cron-recipes.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Generate the Cron Recipes 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 +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 +``website/scripts/prebuild.mjs`` before ``npm start`` / ``npm run build``. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +# Repo root = two levels up from website/scripts/. +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +OUTPUT = REPO_ROOT / "website" / "static" / "api" / "cron-recipes-index.json" + + +def build_index() -> list: + from cron.recipe_catalog import CATALOG, recipe_catalog_entry + + return [recipe_catalog_entry(r) for r in CATALOG] + + +def main() -> int: + try: + index = build_index() + 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") + 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") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/website/scripts/prebuild.mjs b/website/scripts/prebuild.mjs index 11f5e07521e7..5ea9982d2dff 100644 --- a/website/scripts/prebuild.mjs +++ b/website/scripts/prebuild.mjs @@ -31,6 +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 outputFile = join(websiteDir, "static", "api", "skills.json"); const unifiedIndexFile = join(websiteDir, "static", "api", "skills-index.json"); const UNIFIED_INDEX_URL = @@ -138,3 +139,7 @@ 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 +// renders an empty state if the generator can't run. +runPython(cronRecipesScript, "extract-cron-recipes.py"); diff --git a/website/sidebars.ts b/website/sidebars.ts index b6eccc27a41c..8e49567291b3 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -78,6 +78,7 @@ const sidebars: SidebarsConfig = { label: 'Automation', items: [ 'user-guide/features/cron', + 'reference/cron-recipes-catalog', 'user-guide/features/delegation', 'user-guide/features/kanban', 'user-guide/features/codex-app-server-runtime', diff --git a/website/src/components/CronRecipesCatalog/index.tsx b/website/src/components/CronRecipesCatalog/index.tsx new file mode 100644 index 000000000000..3482754f1dde --- /dev/null +++ b/website/src/components/CronRecipesCatalog/index.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from "react"; +import styles from "./styles.module.css"; + +interface RecipeField { + name: string; + type: string; + label: string; + default: string | null; + options: string[]; + optional: boolean; + help: string; +} + +interface Recipe { + key: string; + title: string; + description: string; + category: string; + tags: string[]; + fields: RecipeField[]; + scheduleHuman: string; + command: string; + appUrl: string; +} + +const INDEX_URL = "/docs/api/cron-recipes-index.json"; + +function CopyButton({ text }: { text: string }): JSX.Element { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function RecipeCard({ recipe }: { recipe: Recipe }): JSX.Element { + return ( +
+
+

{recipe.title}

+ {recipe.scheduleHuman} +
+

{recipe.description}

+ +
+ {recipe.tags.map((t) => ( + + {t} + + ))} +
+ +
+ {recipe.command} + +
+ +
+ + Send to App ↗ + + + or paste the command into the CLI, TUI, or any messenger + +
+
+ ); +} + +export default function CronRecipesCatalog(): JSX.Element { + const [recipes, setRecipes] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + fetch(INDEX_URL) + .then((r) => r.json()) + .then((data: Recipe[]) => { + if (!cancelled) setRecipes(data); + }) + .catch((e) => { + if (!cancelled) setError(String(e)); + }); + return () => { + cancelled = true; + }; + }, []); + + if (error) { + return

Couldn't load the recipe catalog: {error}

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

Loading recipes…

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

No cron recipes are available.

; + } + + return ( +
+ {recipes.map((r) => ( + + ))} +
+ ); +} diff --git a/website/src/components/CronRecipesCatalog/styles.module.css b/website/src/components/CronRecipesCatalog/styles.module.css new file mode 100644 index 000000000000..1da29d5b06d2 --- /dev/null +++ b/website/src/components/CronRecipesCatalog/styles.module.css @@ -0,0 +1,114 @@ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} + +.card { + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 10px; + padding: 1.1rem 1.2rem; + background: var(--ifm-card-background-color, var(--ifm-background-surface-color)); + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.cardHead { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; +} + +.title { + margin: 0; + font-size: 1.1rem; +} + +.schedule { + font-size: 0.8rem; + color: var(--ifm-color-emphasis-700); + white-space: nowrap; +} + +.desc { + margin: 0; + color: var(--ifm-color-emphasis-800); + font-size: 0.92rem; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.tag { + font-size: 0.72rem; + padding: 0.1rem 0.5rem; + border-radius: 999px; + background: var(--ifm-color-emphasis-200); + color: var(--ifm-color-emphasis-800); +} + +.cmdRow { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.cmd { + flex: 1; + overflow-x: auto; + white-space: nowrap; + padding: 0.45rem 0.6rem; + font-size: 0.82rem; + border-radius: 6px; + background: var(--ifm-color-emphasis-100); +} + +.copyBtn { + flex-shrink: 0; + border: 1px solid var(--ifm-color-emphasis-300); + background: transparent; + color: var(--ifm-color-emphasis-800); + border-radius: 6px; + padding: 0.35rem 0.7rem; + font-size: 0.8rem; + cursor: pointer; +} + +.copyBtn:hover { + background: var(--ifm-color-emphasis-200); +} + +.actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.appBtn { + display: inline-block; + padding: 0.4rem 0.85rem; + border-radius: 6px; + background: var(--ifm-color-primary); + color: var(--ifm-color-primary-contrast-background, #fff); + font-size: 0.85rem; + font-weight: 600; + text-decoration: none; +} + +.appBtn:hover { + background: var(--ifm-color-primary-dark); + text-decoration: none; + color: var(--ifm-color-primary-contrast-background, #fff); +} + +.hint { + font-size: 0.78rem; + color: var(--ifm-color-emphasis-600); +} From 730aac26da4147cbbaa875334af356c402b12bf4 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 8 Jun 2026 07:32:17 -0700 Subject: [PATCH 3/7] feat(cron-recipes): /cron-recipe seeds a conversational fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the chat-line UX: pick a recipe by name and the agent asks you for what it needs, one question at a time, instead of forcing you to hand-type a slot=val command line. - /cron-recipe -> lists the catalog - /cron-recipe -> forgiving name match (exact/prefix/substring/ fuzzy; ambiguous lists candidates), then seeds the agent with a natural-language fill request built from the recipe's typed slots + schedule and prompt templates. The agent asks for each value one at a time and calls the EXISTING cronjob tool. No new tool. - /cron-recipe slot=val -> unchanged deterministic path (fill_recipe -> create_job) for the dashboard/docs/power user. Mechanism (no new plumbing, invariant-safe — the seed enters as a normal user turn, never a synthetic injection): - shared handler returns RecipeCommandResult{text, agent_seed}; match_recipe() and build_recipe_seed() are the new shared pieces. - gateway: dispatch rewrites event.text to the seed and falls through to the agent (the same pattern /steer uses). - CLI: handler sets a one-shot self._pending_agent_seed; the interactive loop consumes it right after process_command() and runs it as the next turn. The typed-slot schema stays the single source of truth (still validates the form/inline path via fill_recipe); the agent path just renders those slots into the questions to ask. Docs updated to lead with the name-then-ask flow. --- cli.py | 16 +- cron/scripts/classify_items.py | 4 +- gateway/run.py | 28 +- hermes_cli/cli_commands_mixin.py | 22 +- hermes_cli/cron_recipe_cmd.py | 247 ++++++++++++++---- tests/cron/test_recipe_catalog.py | 45 +++- .../docs/reference/cron-recipes-catalog.mdx | 8 +- 7 files changed, 297 insertions(+), 73 deletions(-) diff --git a/cli.py b/cli.py index 8834f1254755..40d5d816caf3 100644 --- a/cli.py +++ b/cli.py @@ -3504,6 +3504,10 @@ def __init__( # 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 ) + # 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 self._secret_state = None self._secret_deadline = 0 self._spinner_text: str = "" # thinking spinner text for TUI @@ -12831,7 +12835,17 @@ def process_loop(): # session. Without this guard a KeyboardInterrupt unwinds # to the outer prompt_toolkit loop and the session dies. _cprint("\n[dim]Command interrupted.[/dim]") - continue + continue + # A slash handler may set a one-shot pending seed (e.g. + # /cron-recipe ) 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) + if _seed: + self._pending_agent_seed = None + user_input = _seed + else: + continue # Expand paste references back to full content _paste_ref_re = re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]') diff --git a/cron/scripts/classify_items.py b/cron/scripts/classify_items.py index d31b1f7427c8..ba0cd42d4b44 100644 --- a/cron/scripts/classify_items.py +++ b/cron/scripts/classify_items.py @@ -5,8 +5,8 @@ feed) produces a list of candidate items; this script scores each with a cheap LLM and prints ONLY the items at or above a threshold. Below-threshold runs print nothing, so a cron job wrapping this stays silent unless something -actually matters -- mirroring Poke's email monitor (fetch -> classify urgency --> surface only what's above the bar). +actually matters -- the classic urgency-monitor pattern (fetch -> classify +urgency -> surface only what's above the bar). Design choices: * Uses Hermes' auxiliary client with task="monitor", so the classifier model diff --git a/gateway/run.py b/gateway/run.py index ff6de3289f70..e161d8ed13b3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7175,7 +7175,20 @@ async def _do_reset(): return await self._handle_suggestions_command(event) if canonical == "cron-recipe": - return await self._handle_cron_recipe_command(event) + _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 + # 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 + # enters as a normal user turn, preserving role alternation. + try: + event.text = _recipe_seed + except Exception: + return getattr(_recipe_result, "text", "") or None + else: + return getattr(_recipe_result, "text", "") or None if canonical == "retry": return await self._handle_retry_command(event) @@ -9273,12 +9286,15 @@ async def _handle_suggestions_command(self, event: MessageEvent) -> str: logger.debug("suggestions command failed: %s", e) return f"Suggestions command failed: {e}" - async def _handle_cron_recipe_command(self, event: MessageEvent) -> str: + async def _handle_cron_recipe_command(self, event: MessageEvent): """Handle /cron-recipe in the gateway. Delegates to the shared handler so CLI, TUI, and gateway never drift. - Origin is built from the event source so a created recipe job delivers - back to this chat/thread. + Returns a RecipeCommandResult: ``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. """ args = (event.get_command_args() or "").strip() source = event.source @@ -9301,7 +9317,9 @@ async def _handle_cron_recipe_command(self, event: MessageEvent) -> str: return handle_cron_recipe_command(args, origin=origin) except Exception as e: logger.debug("cron-recipe command failed: %s", e) - return f"Cron recipe command failed: {e}" + from hermes_cli.cron_recipe_cmd import RecipeCommandResult + + return RecipeCommandResult(f"Cron recipe command failed: {e}") # ──────────────────────────────────────────────────────────────── # /goal — persistent cross-turn goals (Ralph-style loop) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 97e84d86a0d4..ec2927675d75 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1279,10 +1279,12 @@ def _handle_suggestions_command(self, cmd: str): 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. + Delegates to the shared handler. A bare ``/cron-recipe`` lists the + catalog; ``/cron-recipe `` name-matches a recipe and seeds the + agent to ask the user for each value conversationally (the result's + ``agent_seed``); ``/cron-recipe 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. """ import shlex @@ -1293,10 +1295,16 @@ def _handle_cron_recipe_command(self, cmd: str): 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) + result = handle_cron_recipe_command(args) except Exception as e: - output = f"Cron recipe command failed: {e}" - self._console_print(output) + self._console_print(f"Cron recipe command failed: {e}") + return + self._console_print(result.text) + seed = getattr(result, "agent_seed", None) + if seed: + # One-shot: the interactive loop picks this up right after the + # slash command returns and runs it as a normal agent turn. + self._pending_agent_seed = seed def _handle_curator_command(self, cmd: str): """Handle /curator slash command. diff --git a/hermes_cli/cron_recipe_cmd.py b/hermes_cli/cron_recipe_cmd.py index cb542f48622a..0f97c69ff280 100644 --- a/hermes_cli/cron_recipe_cmd.py +++ b/hermes_cli/cron_recipe_cmd.py @@ -3,28 +3,59 @@ 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. +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 +a couple things → done). Subcommand shapes: - /cron-recipe list the catalog (numbered + copy commands) - /cron-recipe show that recipe's slots + a ready command - /cron-recipe slot=val … fill + create the cron job + /cron-recipe list the catalog + /cron-recipe name-match a recipe, then SEED THE AGENT to + ask the user for each value conversationally + /cron-recipe slot=val … fill + create the cron job directly + (the deterministic dashboard / docs / power- + user shortcut — no agent turn) + +The ```` form is forgiving: exact key, unique prefix, or fuzzy match all +resolve; an ambiguous query lists the candidates; an unknown one suggests the +closest. When it resolves, the handler returns an ``agent_seed`` — a natural- +language instruction built from the recipe's typed slots + schedule/prompt +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 +and calls the existing ``cronjob`` tool. No new tool, no second job engine. 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. +survive. """ from __future__ import annotations +import difflib import logging import shlex -from typing import Any, Dict, Optional, Tuple +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) +@dataclass +class RecipeCommandResult: + """Outcome of a ``/cron-recipe`` 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 + 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. + """ + + text: str + agent_seed: Optional[str] = None + + def _resolve_origin(explicit: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: if explicit is not None: return explicit @@ -60,48 +91,168 @@ 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. + + Returns ``(recipe, candidates)``: + * exact key or unique prefix / fuzzy match -> ``(recipe, [])`` + * ambiguous (2+ plausible) -> ``(None, [candidates…])`` + * no plausible match -> ``(None, [])`` + + Matching is forgiving because chat-line users type the name (unlike the + 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 + + q = (query or "").strip().lower() + if not q: + return None, [] + + exact = get_recipe(q) + if exact is not None: + return exact, [] + + # Prefix match on key or title word-start. + prefix = [ + r for r in CATALOG + if r.key.lower().startswith(q) + or any(w.lower().startswith(q) for w in r.title.split()) + ] + if len(prefix) == 1: + return prefix[0], [] + if len(prefix) > 1: + return None, prefix + + # Substring match anywhere in key/title/description. + substr = [ + r for r in CATALOG + if q in r.key.lower() or q in r.title.lower() or q in r.description.lower() + ] + if len(substr) == 1: + return substr[0], [] + if len(substr) > 1: + return None, substr + + # Fuzzy on keys (typo tolerance). + 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]), [] + if len(close) > 1: + return None, [get_recipe(k) for k in close] + + return None, [] + + +def _humanize_schedule(recipe) -> str: + from cron.recipe_catalog import _humanize_schedule as _h + + try: + return _h(recipe) + except Exception: + return "on a schedule" + + +def build_recipe_seed(recipe) -> 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 + rendered prompt. Defaults are stated so the agent can offer them. + """ + from cron.recipe_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}" + ) + 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: + bits = [f"- {s.label} ({s.name})"] + if s.options: + bits.append(f" — one of: {', '.join(map(str, s.options))}") + if s.default not in (None, ""): + bits.append(f" [default: {s.default}]") + if s.optional: + bits.append(" (optional)") + if s.help: + bits.append(f" — {s.help}") + lines.append("".join(bits)) + + lines.append("") + 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}` " + "(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}\". " + "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, recipe_slash_command + from cron.recipe_catalog import CATALOG - lines = ["Cron Recipes — `/cron-recipe ` to set one up:\n"] + lines = ["Cron Recipes — `/cron-recipe ` 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(f" ↳ {recipe_slash_command(r)}") - lines.append("\nEdit the values then send, or just send to use the defaults.") + lines.append( + "\nTip: `/cron-recipe ` walks you through it. Power users can " + "pass values inline, e.g. `/cron-recipe morning-brief time=08:00`." + ) 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)}") +def _fmt_candidates(query: str, candidates: List[Any]) -> str: + lines = [f"'{query}' matches several recipes — which one?\n"] + for r in candidates: + lines.append(f" • {r.key} — {r.title}") + lines.append("\nRun `/cron-recipe ` with one of the names above.") return "\n".join(lines) +def _fmt_no_match(query: str) -> str: + from cron.recipe_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}'." + if close: + msg += " Did you mean: " + ", ".join(close) + "?" + msg += " Run /cron-recipe to see the catalog." + return msg + + 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. +) -> RecipeCommandResult: + """Dispatch a ``/cron-recipe`` invocation. + + Returns a :class:`RecipeCommandResult`. 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 an accepted - recipe's job deliver back to the chat it was created from; resolved from - session env when omitted. + ``args`` is everything after ``/cron-recipe``. ``origin`` lets a directly + created job deliver back to the chat it was set up from. """ try: - from cron.recipe_catalog import fill_recipe, get_recipe, RecipeFillError + from cron.recipe_catalog import fill_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." + return RecipeCommandResult("Cron Recipes are unavailable in this build.") try: tokens = shlex.split(args or "") @@ -110,26 +261,34 @@ def handle_cron_recipe_command( # 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." - ) + return RecipeCommandResult(_fmt_catalog()) + query = tokens[0] values, _leftover = _parse_kv(tokens[1:]) - # `` with no slot args -> show the recipe's fields + a ready command. + recipe, candidates = match_recipe(query) + if recipe is None: + if candidates: + return RecipeCommandResult(_fmt_candidates(query, candidates)) + return RecipeCommandResult(_fmt_no_match(query)) + + # `` with no inline slot values -> seed the agent to ask for them. if not values: - return _fmt_recipe(recipe) + seed = build_recipe_seed(recipe) + text = ( + f"Setting up '{recipe.title}' ({_humanize_schedule(recipe)}). " + "I'll ask you a couple of things…" + ) + return RecipeCommandResult(text, agent_seed=seed) - # ` slot=val …` -> fill + create. + # ` slot=val …` -> fill + create directly (deterministic shortcut). 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." + 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." + ) try: from cron.jobs import create_job @@ -137,10 +296,10 @@ def handle_cron_recipe_command( 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}" + return RecipeCommandResult(f"Failed to create the job: {e}") sched = job.get("schedule_display") or spec.get("schedule", "") - return ( + return RecipeCommandResult( f"Scheduled '{recipe.title}'" + (f" ({sched})" if sched else "") + f", delivering to {spec.get('deliver', 'origin')}. Manage it with /cron." diff --git a/tests/cron/test_recipe_catalog.py b/tests/cron/test_recipe_catalog.py index afc4dbeeb869..7ccc8f79aadb 100644 --- a/tests/cron/test_recipe_catalog.py +++ b/tests/cron/test_recipe_catalog.py @@ -143,20 +143,41 @@ class TestCommandHandler: def test_bare_lists_catalog(self, isolated_home): from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command - out = handle_cron_recipe_command("") - assert "morning-brief" in out and "Cron Recipes" in out + res = handle_cron_recipe_command("") + assert "morning-brief" in res.text and "Cron Recipes" in res.text + assert res.agent_seed is None - def test_show_recipe_fields(self, isolated_home): + def test_name_seeds_agent(self, isolated_home): from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command - out = handle_cron_recipe_command("morning-brief") - assert "Fields:" in out and "time" in out + # `/cron-recipe ` (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") + assert res.agent_seed is not None + assert "morning-brief" in res.agent_seed + assert "cronjob tool" in res.agent_seed + # the schedule template is handed to the agent to build the cron expr + 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 + + # prefix match + r, cands = match_recipe("morning") + assert r is not None and r.key == "morning-brief" + # fuzzy / typo + r2, _ = match_recipe("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") + 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 - out = handle_cron_recipe_command("morning-brief time=07:30 deliver=telegram") - assert "Scheduled" in out + res = handle_cron_recipe_command("morning-brief time=07:30 deliver=telegram") + assert "Scheduled" in res.text + assert res.agent_seed is None jobs = isolated_home.load_jobs() assert len(jobs) == 1 assert (jobs[0].get("schedule_display") or jobs[0].get("schedule")) == "30 7 * * *" @@ -165,14 +186,16 @@ def test_fill_creates_job(self, isolated_home): def test_unknown_recipe(self, isolated_home): from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command - out = handle_cron_recipe_command("does-not-exist") - assert "No cron recipe" in out + res = handle_cron_recipe_command("zzz-nope-nothing") + assert "No cron recipe" 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 - out = handle_cron_recipe_command("morning-brief time=99:99") - assert "Can't set up" in out and "time" in out + res = handle_cron_recipe_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 class TestDocsGenerator: diff --git a/website/docs/reference/cron-recipes-catalog.mdx b/website/docs/reference/cron-recipes-catalog.mdx index 988b976b5794..6e4ccff42409 100644 --- a/website/docs/reference/cron-recipes-catalog.mdx +++ b/website/docs/reference/cron-recipes-catalog.mdx @@ -15,9 +15,11 @@ 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** — copy a recipe's `/cron-recipe` command below, - edit the values, and send it. Hermes fills in anything you leave out and - asks if something's ambiguous. +- **CLI, TUI, and messengers** — type `/cron-recipe ` (e.g. + `/cron-recipe morning-brief`) and Hermes asks you for what it needs, one + question at a time, then schedules it. The name match is forgiving — a + prefix or near-spelling resolves. Power users can skip the questions by + passing values inline: `/cron-recipe morning-brief time=08:00`. - **Desktop app** — click **Send to App** on any recipe and it opens with the command pre-loaded in your composer. From 9e1ac225113fbed7b06624f160800f6b16c53c55 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 05:25:35 -0700 Subject: [PATCH 4/7] =?UTF-8?q?fix(cron-recipes):=20pre-release=20hardenin?= =?UTF-8?q?g=20=E2=80=94=20honest=20cadences,=20strict=20slot=20names,=20s?= =?UTF-8?q?urface-aware=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for the Cron Recipes stack before release: - hydration-move: */90 in the cron minute field silently wraps to hourly (croniter-verified) — 90/120-minute options never fired at their stated cadence. Replaced with an hour-field step (0 9-17/2 * * 1-5) and an interval_hours slot whose options (1/2/3h) all fire as labeled. - fill_recipe: reject unknown slot names. A typo'd 'tiem=07:15' used to silently create the job at the 08:00 default; now it 422s on the dashboard form and errors on the slash/deep-link paths with the valid slot list. - deliver slot: non-strict enum (options are suggestions, scheduler validates downstream) so slack/whatsapp/etc. users aren't locked out; GET /api/cron/recipes rewrites its options from cron_delivery_targets() so the dashboard form only offers configured platforms; help text no longer claims dashboard-created jobs deliver to 'the chat you set this up from' (the endpoint strips origin — they go to the home channel). - gateway: success/accept messages no longer point at /cron (cli_only); surface-aware hint instead. Conversational fill now sends the 'Setting up X — I'll ask you a couple of things…' ack before the agent turn, matching the CLI experience. - important-mail catalog entry: reference the urgency classifier by module path (python3 -m cron.scripts.classify_items) instead of baking an absolute host path into the job prompt — stale after relocation and nonexistent on remote terminal backends. cron/scripts is now a real package and ships in the wheel (pyproject packages.find). - export_recipe: interval schedules round-trip again — parse_schedule stores 'minutes' but the renderer only read 'seconds', so every interval job exported as the silent '0 9 * * *' fallback. - skills_hub install: say so when a recipe suggestion is dropped (latched dedup or pending cap) instead of printing nothing. Targeted tests: 58 cron/recipe + 261 web_server pass; E2E-validated all 14 recipes fill+parse, hydration cadences via croniter, typo rejection on slash + endpoint paths, surface-aware hints, and interval export round-trip. --- cron/recipe_catalog.py | 41 +++++++++++++++++++++++++------ cron/scripts/__init__.py | 1 + cron/suggestion_catalog.py | 6 +++-- gateway/run.py | 16 ++++++++++-- hermes_cli/cron_recipe_cmd.py | 16 ++++++++++-- hermes_cli/skills_hub.py | 14 +++++++++++ hermes_cli/suggestions_cmd.py | 12 +++++++-- hermes_cli/web_server.py | 26 ++++++++++++++++++-- pyproject.toml | 2 +- tests/cron/test_recipe_catalog.py | 29 +++++++++++++++++++++- tests/cron/test_suggestions.py | 7 +++++- tests/tools/test_cron_recipes.py | 19 ++++++++++++++ tools/recipes.py | 22 +++++++++++------ web/src/lib/api.ts | 2 ++ 14 files changed, 185 insertions(+), 28 deletions(-) create mode 100644 cron/scripts/__init__.py diff --git a/cron/recipe_catalog.py b/cron/recipe_catalog.py index 0e7a3e1280aa..c96b05a7d8e0 100644 --- a/cron/recipe_catalog.py +++ b/cron/recipe_catalog.py @@ -68,6 +68,11 @@ class RecipeSlot: options: tuple = () # for type="enum": allowed values optional: bool = False help: str = "" + # When False, ``options`` are suggestions rather than a closed set — + # any value is accepted (e.g. the deliver slot, where the real set of + # valid platforms depends on the user's configured gateways and is + # validated downstream by the cron scheduler). + strict: bool = True def __post_init__(self) -> None: if self.type not in _SLOT_TYPES: @@ -105,7 +110,10 @@ class CronRecipe: _DELIVER = RecipeSlot( name="deliver", type="enum", label="Where to deliver?", default="origin", options=("origin", "local", "telegram", "discord", "email"), - help="origin = the chat you set this up from; local = save only, no message", + optional=False, strict=False, + help="origin = the chat you set this up from (or your configured home " + "channel when created from the dashboard); local = save only, no message; " + "or any connected platform name", ) @@ -324,7 +332,10 @@ class CronRecipe: description="A periodic nudge during the day to drink water, stand up, " "and stretch.", category="general", - schedule_template="*/{interval_min} {start_hour}-{end_hour} * * 1-5", + # NOTE: cron minute-field steps (*/90) wrap per hour — */90 and */120 + # both degrade to hourly. Use an hour-field step instead so the chosen + # cadence is what actually fires. + schedule_template="0 {start_hour}-{end_hour}/{interval_hours} * * 1-5", prompt_template=( "Send the user a brief, friendly nudge to drink some water, stand " "up, and stretch for a moment. Vary the wording each time so it " @@ -332,9 +343,9 @@ class CronRecipe: ), slots=[ RecipeSlot( - name="interval_min", type="enum", label="How often?", - default="90", options=("60", "90", "120"), - help="minutes between nudges", + name="interval_hours", type="enum", label="How often?", + default="1", options=("1", "2", "3"), + help="hours between nudges", ), RecipeSlot( name="start_hour", type="enum", label="Start hour", @@ -494,6 +505,7 @@ def recipe_form_schema(recipe: CronRecipe) -> Dict[str, Any]: "default": s.default, "options": list(s.options), "optional": s.optional, + "strict": s.strict, "help": s.help, } for s in recipe.slots @@ -543,6 +555,11 @@ def _humanize_schedule(recipe: CronRecipe) -> str: iv = next((s for s in recipe.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) + 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) when = time_slot.default if time_slot else None if "* * 1-5" in sched: @@ -651,9 +668,17 @@ def fill_recipe( Missing required (non-optional) slots raise RecipeFillError naming the slot, so a form can show field errors and the agent knows what to ask. - Enum values are checked against their options. The result is passed - straight to ``create_job`` — no second schema. + 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} + unknown = sorted(set(values) - known) + if unknown: + raise RecipeFillError( + f"unknown slot{'s' if len(unknown) > 1 else ''}: " + f"{', '.join(unknown)} — valid: {', '.join(s.name for s in recipe.slots)}" + ) resolved: Dict[str, Any] = {} for s in recipe.slots: raw = values.get(s.name, s.default) @@ -661,7 +686,7 @@ def fill_recipe( if s.optional: continue raise RecipeFillError(f"missing required value: {s.name} ({s.label})") - if s.type == "enum" and s.options and str(raw) not in {str(o) for o in s.options}: + if s.type == "enum" and s.strict and s.options and str(raw) not in {str(o) for o in s.options}: raise RecipeFillError( f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}" ) diff --git a/cron/scripts/__init__.py b/cron/scripts/__init__.py new file mode 100644 index 000000000000..8dc1a5c3c510 --- /dev/null +++ b/cron/scripts/__init__.py @@ -0,0 +1 @@ +"""Scripts shipped with the cron subsystem (runnable via ``python3 -m cron.scripts.``).""" diff --git a/cron/suggestion_catalog.py b/cron/suggestion_catalog.py index e297bc440a04..deacf1e13493 100644 --- a/cron/suggestion_catalog.py +++ b/cron/suggestion_catalog.py @@ -71,8 +71,10 @@ class CatalogEntry: "For each candidate, judge urgency against this rule: surface " "only mail that needs a reply today, is from a manager/family " "member, or mentions a deadline. Pipe candidates through the " - f"urgency classifier at {classify_items_script_path()} " - "(--threshold 7) and deliver ONLY what it returns. If nothing " + "urgency classifier (run `python3 -m cron.scripts.classify_items " + "--threshold 7 --criteria ...` from the hermes-agent install — " + "resolve the script path at run time, do not assume a fixed " + "location) and deliver ONLY what it returns. If nothing " "clears the bar, respond with [SILENT] so the user is not " "pinged. Requires a connected mail source; if none is " "configured, explain how to connect one and then stop." diff --git a/gateway/run.py b/gateway/run.py index e161d8ed13b3..cd91f3d37300 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7183,6 +7183,18 @@ async def _do_reset(): # user for each slot value conversationally and then calls the # cronjob tool (the /steer fall-through pattern). The seed # enters as a normal user turn, preserving role alternation. + # 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 "" + if _ack: + try: + adapter = self.adapters.get(source.platform) + if adapter: + _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) try: event.text = _recipe_seed except Exception: @@ -9281,7 +9293,7 @@ async def _handle_suggestions_command(self, event: MessageEvent) -> str: try: from hermes_cli.suggestions_cmd import handle_suggestions_command - return handle_suggestions_command(args, origin=origin) + return handle_suggestions_command(args, origin=origin, surface="gateway") except Exception as e: logger.debug("suggestions command failed: %s", e) return f"Suggestions command failed: {e}" @@ -9314,7 +9326,7 @@ async def _handle_cron_recipe_command(self, event: MessageEvent): try: from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command - return handle_cron_recipe_command(args, origin=origin) + return handle_cron_recipe_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 diff --git a/hermes_cli/cron_recipe_cmd.py b/hermes_cli/cron_recipe_cmd.py index 0f97c69ff280..4927c9d8d175 100644 --- a/hermes_cli/cron_recipe_cmd.py +++ b/hermes_cli/cron_recipe_cmd.py @@ -234,10 +234,20 @@ def _fmt_no_match(query: str) -> str: return msg +def _manage_hint(surface: str) -> str: + """Post-create management hint. /cron is a CLI-only slash command; on + gateway platforms the user manages jobs by asking the agent (cronjob tool) + or from the dashboard.""" + if surface == "cli": + return "Manage it with /cron." + return "Ask me to list, pause, or remove it any time." + + def handle_cron_recipe_command( args: str, *, origin: Optional[Dict[str, Any]] = None, + surface: str = "cli", ) -> RecipeCommandResult: """Dispatch a ``/cron-recipe`` invocation. @@ -246,7 +256,9 @@ def handle_cron_recipe_command( command is fully handled and only ``text`` is shown. ``args`` is everything after ``/cron-recipe``. ``origin`` lets a directly - created job deliver back to the chat it was set up from. + 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 @@ -302,5 +314,5 @@ def handle_cron_recipe_command( return RecipeCommandResult( f"Scheduled '{recipe.title}'" + (f" ({sched})" if sched else "") - + f", delivering to {spec.get('deliver', 'origin')}. Manage it with /cron." + + f", delivering to {spec.get('deliver', 'origin')}. {_manage_hint(surface)}" ) diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 2b7546962f6a..f6f70b288d40 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -715,6 +715,20 @@ def do_install(identifier: str, category: str = "", force: bool = False, "[dim]Added to your suggestions — run[/] [bold]/suggestions[/] " "[dim]to schedule or dismiss it.[/]\n" ) + else: + # Dropped: already offered/dismissed (latched) or the pending + # 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"(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)." + ) + c.print( + "[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 pass diff --git a/hermes_cli/suggestions_cmd.py b/hermes_cli/suggestions_cmd.py index a0f785016a5e..aa336e37a195 100644 --- a/hermes_cli/suggestions_cmd.py +++ b/hermes_cli/suggestions_cmd.py @@ -67,13 +67,16 @@ def handle_suggestions_command( args: str, *, origin: Optional[Dict[str, Any]] = None, + surface: str = "cli", ) -> str: """Dispatch a ``/suggestions`` invocation. Returns text to show the user. ``args`` is everything after ``/suggestions`` (already stripped of the command word). ``origin`` is the platform/chat dict so an accepted job's "origin" delivery routes back to where the user accepted; when omitted it - is resolved from the session environment. + is resolved from the session environment. ``surface`` (``"cli"`` | + ``"gateway"``) picks the wording for follow-up hints — ``/cron`` only + exists on the CLI. """ if origin is None: origin = _resolve_origin() @@ -99,10 +102,15 @@ def handle_suggestions_command( return f"No pending suggestion matches '{rest}'. Run /suggestions to list them." sched = job.get("schedule_display") or (job.get("job_spec", {}) or {}).get("schedule", "") name = job.get("name", "automation") + manage = ( + "Manage it with /cron." + if surface == "cli" + else "Ask me to list, pause, or remove it any time." + ) return ( f"Scheduled '{name}'" + (f" ({sched})" if sched else "") - + ". Manage it with /cron." + + f". {manage}" ) if sub in ("dismiss", "no", "reject"): diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 05586231820f..ffafc93d4bc0 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -6790,11 +6790,33 @@ class CronRecipeInstantiate(BaseModel): @app.get("/api/cron/recipes") async def list_cron_recipes(): - """Return the recipe catalog as form schemas for the dashboard gallery.""" + """Return the recipe 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 - return {"recipes": [recipe_catalog_entry(r) for r in CATALOG]} + deliver_options = None + try: + from cron.scheduler import cron_delivery_targets + + platforms = [t["id"] for t in cron_delivery_targets() if t.get("id")] + deliver_options = ["origin", "local", *platforms] + except Exception: + _log.debug("cron_delivery_targets unavailable; using static deliver options", exc_info=True) + + entries = [] + for r in CATALOG: + entry = recipe_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} except Exception as e: _log.exception("GET /api/cron/recipes failed") raise HTTPException(status_code=500, detail=str(e)) diff --git a/pyproject.toml b/pyproject.toml index e5bf882d87a4..e191932c2854 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -319,7 +319,7 @@ plugins = [ ] [tool.setuptools.packages.find] -include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"] +include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "cron.*", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/cron/test_recipe_catalog.py b/tests/cron/test_recipe_catalog.py index 7ccc8f79aadb..017d6bd2ae1a 100644 --- a/tests/cron/test_recipe_catalog.py +++ b/tests/cron/test_recipe_catalog.py @@ -80,7 +80,34 @@ def test_invalid_time_rejected(self): def test_bad_enum_rejected_and_names_slot(self): with pytest.raises(RecipeFillError, match="not allowed"): - fill_recipe(get_recipe("morning-brief"), {"time": "08:00", "deliver": "pigeon"}) + fill_recipe(get_recipe("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"}) + 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"}) + + def test_hydration_hourly_step_actually_fires_at_chosen_cadence(self): + # Regression: a minute-field step (*/90) silently wraps to hourly. + # The hour-field step form must produce the cadence the user picked. + croniter = pytest.importorskip("croniter").croniter + from datetime import datetime + + spec = fill_recipe(get_recipe("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 = { + (b - a).total_seconds() + for a, b in zip(first_three, first_three[1:]) + } + assert gaps == {7200.0}, f"expected 2h gaps, got {spec['schedule']} -> {first_three}" def test_text_slot_renders_into_prompt(self): spec = fill_recipe( diff --git a/tests/cron/test_suggestions.py b/tests/cron/test_suggestions.py index b8db8f54dac7..179c2956623a 100644 --- a/tests/cron/test_suggestions.py +++ b/tests/cron/test_suggestions.py @@ -127,7 +127,12 @@ def test_monitor_entry_references_classifier_script(self): from cron.suggestion_catalog import CATALOG, classify_items_script_path monitor = next(e for e in CATALOG if e.key == "catalog:important-mail-monitor") - assert classify_items_script_path() in monitor.job_spec["prompt"] + # The prompt must reference the classifier by module path (resolvable + # at run time on any backend), never by a baked-in absolute path — + # absolute paths go stale after relocation and don't exist on remote + # terminal backends (Docker/Modal). + assert "cron.scripts.classify_items" in monitor.job_spec["prompt"] + assert classify_items_script_path() not in monitor.job_spec["prompt"] assert Path(classify_items_script_path()).name == "classify_items.py" diff --git a/tests/tools/test_cron_recipes.py b/tests/tools/test_cron_recipes.py index 7d9f89442f2f..439b96049136 100644 --- a/tests/tools/test_cron_recipes.py +++ b/tests/tools/test_cron_recipes.py @@ -167,3 +167,22 @@ def test_export_has_recipe_tag(self): md = export_recipe(job, "body") assert "recipe" in md assert "automation" in md + + def test_export_interval_job_without_display(self): + # Regression: parse_schedule stores interval periods as "minutes" — + # exporting a job with only the parsed schedule dict must round-trip + # the real interval, not fall back to the daily default. + job = { + "name": "poller", + "schedule": {"kind": "interval", "minutes": 30}, + "skills": ["poller"], + } + md = export_recipe(job, "body") + spec = parse_recipe(md) + assert spec is not None + assert spec.schedule == "every 30m" + + job["schedule"] = {"kind": "interval", "minutes": 120} + spec = parse_recipe(export_recipe(job, "body")) + assert spec is not None + assert spec.schedule == "every 2h" diff --git a/tools/recipes.py b/tools/recipes.py index 014720801e3e..1b95f192802a 100644 --- a/tools/recipes.py +++ b/tools/recipes.py @@ -307,11 +307,19 @@ def _schedule_to_string(schedule: Any) -> str: kind = schedule.get("kind") if kind == "cron" and schedule.get("expr"): return str(schedule["expr"]) - if kind == "interval" and schedule.get("seconds"): - secs = int(schedule["seconds"]) - if secs % 3600 == 0: - return f"every {secs // 3600}h" - if secs % 60 == 0: - return f"every {secs // 60}m" - return f"every {secs}s" + if kind == "interval": + # parse_schedule stores interval periods as "minutes"; tolerate a + # legacy/foreign "seconds" form too. + if schedule.get("minutes"): + mins = int(schedule["minutes"]) + if mins % 60 == 0: + return f"every {mins // 60}h" + return f"every {mins}m" + if schedule.get("seconds"): + secs = int(schedule["seconds"]) + if secs % 3600 == 0: + return f"every {secs // 3600}h" + if secs % 60 == 0: + return f"every {secs // 60}m" + return f"every {secs}s" return "0 9 * * *" # safe daily fallback diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4f9b7461151b..f306a43a485d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1845,6 +1845,8 @@ export interface CronRecipeField { default: string | null; options: string[]; optional: boolean; + /** When false, options are suggestions — any value is accepted. */ + strict?: boolean; help: string; } From 4c5bd9678aea2d07f05dce6fb4336da8a8b3ab6a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 05:40:35 -0700 Subject: [PATCH 5/7] =?UTF-8?q?fix(commands):=20unpin=20/reset=20from=20Sl?= =?UTF-8?q?ack=20priority=20aliases=20=E2=80=94=20registry=20hit=20the=205?= =?UTF-8?q?0-cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI tests the PR merged with current main, where the new /memory canonical command filled Slack's 50-slash cap: with btw/bg/reset all pinned ahead of canonicals, the last canonical (/debug) got clamped and the Telegram-parity test failed. Canonical commands must win slots over alias spellings — /new keeps its native slot and 'reset' stays reachable via /hermes reset. Also updates test_includes_aliases_as_first_class_slashes to assert the pinned-alias contract (_SLACK_PRIORITY_ALIASES survive) instead of a specific unpinned alias's survival, which was the same change-detector pattern the docstring already warned about. --- hermes_cli/commands.py | 6 +++++- tests/hermes_cli/test_commands.py | 12 +++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index dc931820e6ce..20a87eb811cd 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -1037,7 +1037,11 @@ def discord_skill_commands_by_category( # 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 ). -_SLACK_PRIORITY_ALIASES = ("btw", "bg", "reset") +# Keep this list TIGHT: every pinned alias takes a slot a canonical command +# would otherwise get, and the Telegram-parity test fails when a canonical +# gets clamped ("reset" was unpinned for exactly that — /new keeps its +# native slot, the alias spelling stays reachable via /hermes reset). +_SLACK_PRIORITY_ALIASES = ("btw", "bg") def _sanitize_slack_name(raw: str) -> str: diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 62c2be4ab79b..0954ccf790dd 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -336,20 +336,22 @@ def test_excludes_slack_reserved_commands(self): ) def test_includes_aliases_as_first_class_slashes(self): - """Aliases (/btw, /bg, /reset, …) must be registered as standalone + """Aliases (/btw, /bg, …) must be registered as standalone slashes — this is the whole point of native-slashes parity. Asserts the contract (aliases are surfaced as first-class slashes), not a specific alias's survival of Slack's 50-slash clamp — which alias - lands last shifts whenever a canonical command is added, so pinning one - name (previously ``q``) made this a change-detector. + lands last shifts whenever a canonical command is added. Only the + explicitly pinned ``_SLACK_PRIORITY_ALIASES`` are guaranteed slots; + every other alias (e.g. ``reset``) may be clamped once the registry + fills the cap — canonical commands win the contest, and clamped + aliases stay reachable via ``/hermes ``. """ slashes = slack_native_slashes() names = {n for n, _d, _h in slashes} - # Aliases that sort early in the registry always fit under the cap. + # The pinned priority aliases are guaranteed to survive the clamp. assert "btw" in names assert "bg" in names - assert "reset" in names # And at least one alias is surfaced as an alias entry (description # carries the "Alias for /…" marker), proving the alias pass ran. assert any(d.startswith("Alias for /") for _n, d, _h in slashes) From 9c07e06f72e59c67551ae60ceaa5326fdd976520 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:15:28 -0700 Subject: [PATCH 6/7] chore: retrigger CI (workflows did not fire on fdeae1b30) From f4f7bc8143455a4e6ea85383bb3916f2dd763ca3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:23:27 -0700 Subject: [PATCH 7/7] 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/, docs catalog page + extract script, and the skill frontmatter block metadata.hermes.blueprint. No behavior change. --- .gitignore | 6 +- apps/desktop/electron/main.cjs | 4 +- apps/desktop/src/app/desktop-controller.tsx | 8 +- cli.py | 8 +- ...recipe_catalog.py => blueprint_catalog.py} | 240 +++++++++--------- cron/suggestions.py | 6 +- gateway/run.py | 38 +-- .../{cron_recipe_cmd.py => blueprint_cmd.py} | 132 +++++----- hermes_cli/cli_commands_mixin.py | 16 +- hermes_cli/commands.py | 4 +- hermes_cli/skills_hub.py | 20 +- hermes_cli/suggestions_cmd.py | 2 +- hermes_cli/web_server.py | 44 ++-- ...e_catalog.py => test_blueprint_catalog.py} | 126 ++++----- tests/cron/test_suggestions.py | 22 +- tests/hermes_cli/test_web_server.py | 32 +-- ...est_cron_recipes.py => test_blueprints.py} | 104 ++++---- tools/{recipes.py => blueprints.py} | 170 ++++++------- ...onRecipes.tsx => AutomationBlueprints.tsx} | 70 ++--- web/src/lib/api.ts | 18 +- web/src/pages/CronPage.tsx | 12 +- .../docs/developer-guide/creating-skills.md | 26 +- .../automation-blueprints-catalog.mdx | 36 +++ .../docs/reference/cron-recipes-catalog.mdx | 36 --- ...es.py => extract-automation-blueprints.py} | 20 +- website/scripts/prebuild.mjs | 6 +- website/sidebars.ts | 2 +- .../index.tsx | 46 ++-- .../styles.module.css | 0 29 files changed, 627 insertions(+), 627 deletions(-) rename cron/{recipe_catalog.py => blueprint_catalog.py} (80%) rename hermes_cli/{cron_recipe_cmd.py => blueprint_cmd.py} (66%) rename tests/cron/{test_recipe_catalog.py => test_blueprint_catalog.py} (61%) rename tests/tools/{test_cron_recipes.py => test_blueprints.py} (61%) rename tools/{recipes.py => blueprints.py} (59%) rename web/src/components/{CronRecipes.tsx => AutomationBlueprints.tsx} (72%) create mode 100644 website/docs/reference/automation-blueprints-catalog.mdx delete mode 100644 website/docs/reference/cron-recipes-catalog.mdx rename website/scripts/{extract-cron-recipes.py => extract-automation-blueprints.py} (59%) rename website/src/components/{CronRecipesCatalog => AutomationBlueprintsCatalog}/index.tsx (60%) rename website/src/components/{CronRecipesCatalog => AutomationBlueprintsCatalog}/styles.module.css (100%) diff --git a/.gitignore b/.gitignore index cd2e9d097c02..2935832db3be 100644 --- a/.gitignore +++ b/.gitignore @@ -89,9 +89,9 @@ website/static/api/skills-index.json # every build). website/static/api/skills.json website/static/api/skills-meta.json -# cron-recipes-index.json is a build artifact emitted by -# website/scripts/extract-cron-recipes.py during prebuild. -website/static/api/cron-recipes-index.json +# automation-blueprints-index.json is a build artifact emitted by +# website/scripts/extract-automation-blueprints.py during prebuild. +website/static/api/automation-blueprints-index.json models-dev-upstream/ # Local editor / agent tooling (machine-specific; keep in global config, not the repo) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index eb78830da475..bfa5e178d2f1 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -6112,7 +6112,7 @@ ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketpla ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20)) // --------------------------------------------------------------------------- -// hermes:// deep links (e.g. hermes://cron-recipe/morning-brief?time=08:00). +// hermes:// deep links (e.g. hermes://blueprint/morning-brief?time=08:00). // A docs/dashboard "Send to App" button opens this URL; we route it into the // running app's chat composer. Three delivery paths: macOS 'open-url', // Win/Linux running-app 'second-instance' (argv), Win/Linux cold-start argv. @@ -6135,7 +6135,7 @@ function handleDeepLink(url) { rememberLog(`[deeplink] ignoring malformed url: ${url}`) return } - // hermes://cron-recipe/?slot=val -> host="cron-recipe", path="/" + // hermes://blueprint/?slot=val -> host="blueprint", path="/" const kind = parsed.hostname || '' const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, '')) const params = {} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 43b8ab4c9004..f04ade8f80e0 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -267,14 +267,14 @@ export function DesktopController() { } }, []) - // hermes:// deep links (e.g. a docs "Send to App" button for a cron recipe). - // Build the equivalent /cron-recipe slash command from the payload and drop + // hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint). + // Build the equivalent /blueprint slash command from the payload and drop // it into the composer — the user reviews/edits, then sends; the agent (or // the shared command handler) creates the job. Signal readiness so a link // that arrived during boot is flushed exactly once. useEffect(() => { const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => { - if (!payload || payload.kind !== 'cron-recipe' || !payload.name) { + if (!payload || payload.kind !== 'blueprint' || !payload.name) { return } const slots = Object.entries(payload.params || {}) @@ -283,7 +283,7 @@ export function DesktopController() { return `${k}=${sval}` }) .join(' ') - const command = `/cron-recipe ${payload.name}${slots ? ' ' + slots : ''}` + const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}` requestComposerInsert(command, { mode: 'block', target: 'main' }) requestComposerFocus('main') }) diff --git a/cli.py b/cli.py index 40d5d816caf3..4ae4c6f7029c 100644 --- a/cli.py +++ b/cli.py @@ -3504,7 +3504,7 @@ def __init__( # the next submitted input, whether it's the selection or anything # else). See #34584. self._pending_resume_sessions = None - # One-shot agent seed set by a slash handler (e.g. /cron-recipe ) + # One-shot agent seed set by a slash handler (e.g. /blueprint ) # that wants its output run as the next agent turn. Consumed and cleared # by the interactive loop immediately after process_command() returns. self._pending_agent_seed = None @@ -7415,8 +7415,8 @@ def process_command(self, command: str) -> bool: 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 @@ def process_loop(): _cprint("\n[dim]Command interrupted.[/dim]") continue # A slash handler may set a one-shot pending seed (e.g. - # /cron-recipe ) to be run as the next agent turn. + # /blueprint ) to be run as the next agent turn. # If present, fall through to the chat path with the seed # as the user message instead of looping back to idle. _seed = getattr(self, "_pending_agent_seed", None) diff --git a/cron/recipe_catalog.py b/cron/blueprint_catalog.py similarity index 80% rename from cron/recipe_catalog.py rename to cron/blueprint_catalog.py index c96b05a7d8e0..b6cfc54576bb 100644 --- a/cron/recipe_catalog.py +++ b/cron/blueprint_catalog.py @@ -1,23 +1,23 @@ -"""Cron Recipes — parameterized automation templates with typed slots. +"""Automation Blueprints — parameterized automation templates with typed slots. -A *recipe* is a one-place definition of an automation that every surface +A *blueprint* is a one-place definition of an automation that every surface renders natively: * Dashboard / GUI app -> a form (one field per slot) - * CLI / TUI / messenger -> a pre-filled ``/cron-recipe`` slash command + * CLI / TUI / messenger -> a pre-filled ``/blueprint`` slash command * Agent -> a seed prompt; it asks for any blank/ambiguous slot * Docs catalog -> a copy-paste command + a ``hermes://`` deep-link -The single source of truth is the slot schema below. ``recipe_form_schema`` -emits what a form renderer needs; ``recipe_slash_command`` emits the flattened -one-line command; ``fill_recipe`` validates user-supplied values and turns a -recipe into a ``cron.jobs.create_job`` kwargs dict (so there is no second job +The single source of truth is the slot schema below. ``blueprint_form_schema`` +emits what a form renderer needs; ``blueprint_slash_command`` emits the flattened +one-line command; ``fill_blueprint`` validates user-supplied values and turns a +blueprint into a ``cron.jobs.create_job`` kwargs dict (so there is no second job engine). The form-where-there's-a-screen / agent-fills-where-there's-a-chat split both consume this same module. -Design choice: users never type raw cron. A recipe carries a fixed recurrence +Design choice: users never type raw cron. A blueprint carries a fixed recurrence in ``schedule_template`` and parameterizes only the human-friendly parts -(time-of-day, weekday set). Recipes needing full flexibility expose a ``text`` +(time-of-day, weekday set). Blueprints needing full flexibility expose a ``text`` slot named ``schedule`` that passes through verbatim. """ @@ -28,21 +28,21 @@ from 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 @@ class RecipeFillError(ValueError): @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 @@ def __post_init__(self) -> None: @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 @@ class CronRecipe: ) -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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class CronRecipe: _CATALOG_BY_KEY = {r.key: r for r in CATALOG} -def get_recipe(key: str) -> Optional[CronRecipe]: +def get_blueprint(key: str) -> Optional[AutomationBlueprint]: return _CATALOG_BY_KEY.get(key) @@ -489,14 +489,14 @@ def get_recipe(key: str) -> Optional[CronRecipe]: # Renderers # --------------------------------------------------------------------------- -def recipe_form_schema(recipe: CronRecipe) -> Dict[str, Any]: - """Emit the JSON a form renderer (dashboard / GUI) needs for this recipe.""" +def blueprint_form_schema(blueprint: AutomationBlueprint) -> Dict[str, Any]: + """Emit the JSON a form renderer (dashboard / GUI) needs for this blueprint.""" return { - "key": recipe.key, - "title": recipe.title, - "description": recipe.description, - "category": recipe.category, - "tags": list(recipe.tags), + "key": blueprint.key, + "title": blueprint.title, + "description": blueprint.description, + "category": blueprint.category, + "tags": list(blueprint.tags), "fields": [ { "name": s.name, @@ -508,20 +508,20 @@ def recipe_form_schema(recipe: CronRecipe) -> Dict[str, Any]: "strict": s.strict, "help": s.help, } - for s in recipe.slots + for s in blueprint.slots ], } -def recipe_slash_command(recipe: CronRecipe, values: Optional[Dict[str, Any]] = None) -> str: - """Build the flattened ``/cron-recipe slot=val …`` command string. +def blueprint_slash_command(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str: + """Build the flattened ``/blueprint slot=val …`` command string. Uses each slot's default when ``values`` is omitted, so the docs/dashboard can show a ready-to-paste command. Free-text slots are quoted. """ values = values or {} - parts = [f"/cron-recipe {recipe.key}"] - for s in recipe.slots: + parts = [f"/blueprint {blueprint.key}"] + for s in blueprint.slots: val = values.get(s.name, s.default) if val is None or val == "": if s.optional: @@ -534,38 +534,38 @@ def recipe_slash_command(recipe: CronRecipe, values: Optional[Dict[str, Any]] = return " ".join(parts) -def recipe_deeplink(recipe: CronRecipe, values: Optional[Dict[str, Any]] = None) -> str: - """Build the ``hermes://cron-recipe/?slot=val`` deep-link URL.""" +def blueprint_deeplink(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str: + """Build the ``hermes://blueprint/?slot=val`` deep-link URL.""" from urllib.parse import quote, urlencode values = values or {} query = {} - for s in recipe.slots: + for s in blueprint.slots: val = values.get(s.name, s.default) if val not in (None, ""): query[s.name] = str(val) qs = ("?" + urlencode(query)) if query else "" - return f"hermes://cron-recipe/{quote(recipe.key)}{qs}" + return f"hermes://blueprint/{quote(blueprint.key)}{qs}" -def _humanize_schedule(recipe: CronRecipe) -> str: - """A short human-readable description of when a recipe runs (defaults).""" - sched = recipe.schedule_template +def _humanize_schedule(blueprint: AutomationBlueprint) -> str: + """A short human-readable description of when a blueprint runs (defaults).""" + sched = blueprint.schedule_template if sched.startswith("*/"): - iv = next((s for s in recipe.slots if s.name == "interval_min"), None) + iv = next((s for s in blueprint.slots if s.name == "interval_min"), None) every = (iv.default if iv else None) or sched.split("/")[1].split()[0] return f"every {every} minutes" if "{interval_hours}" in sched: - iv = next((s for s in recipe.slots if s.name == "interval_hours"), None) + iv = next((s for s in blueprint.slots if s.name == "interval_hours"), None) every = str((iv.default if iv else None) or "1") scope = "weekdays, " if "* * 1-5" in sched else "" return f"{scope}every hour" if every == "1" else f"{scope}every {every} hours" - time_slot = next((s for s in recipe.slots if s.type == "time"), None) + time_slot = next((s for s in blueprint.slots if s.type == "time"), None) when = time_slot.default if time_slot else None if "* * 1-5" in sched: return f"weekdays at {when}" if when else "every weekday" if "{dow}" in sched: - day_slot = next((s for s in recipe.slots if s.name in ("day", "recurrence")), None) + day_slot = next((s for s in blueprint.slots if s.name in ("day", "recurrence")), None) scope = (day_slot.default if day_slot else "") or "" if scope and when: return f"{scope} at {when}" @@ -575,17 +575,17 @@ def _humanize_schedule(recipe: CronRecipe) -> str: return "on a schedule" -def recipe_catalog_entry(recipe: CronRecipe) -> Dict[str, Any]: - """Unified serializable shape for a recipe — used by the docs generator +def blueprint_catalog_entry(blueprint: AutomationBlueprint) -> Dict[str, Any]: + """Unified serializable shape for a blueprint — used by the docs generator and the dashboard API. Combines the form schema, the ready-to-paste slash command, the deep-link URL, and a human-readable schedule. """ return { - **recipe_form_schema(recipe), - "schedule": recipe.schedule_template, - "scheduleHuman": _humanize_schedule(recipe), - "command": recipe_slash_command(recipe), - "appUrl": recipe_deeplink(recipe), + **blueprint_form_schema(blueprint), + "schedule": blueprint.schedule_template, + "scheduleHuman": _humanize_schedule(blueprint), + "command": blueprint_slash_command(blueprint), + "appUrl": blueprint_deeplink(blueprint), } @@ -600,9 +600,9 @@ def recipe_catalog_entry(recipe: CronRecipe) -> Dict[str, Any]: } -def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str: +def _resolve_schedule(blueprint: AutomationBlueprint, values: Dict[str, Any]) -> str: """Fill the schedule_template placeholders from resolved slot values.""" - sched = recipe.schedule_template + sched = blueprint.schedule_template # A free-text `schedule` slot passes through verbatim (full flexibility). if "schedule" in values and values["schedule"]: @@ -614,10 +614,10 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str: time_val = values.get("time") if "{minute}" in sched or "{hour}" in sched: if not time_val: - raise RecipeFillError("a time is required") + raise BlueprintFillError("a time is required") m = _TIME_RE.match(str(time_val).strip()) if not m: - raise RecipeFillError(f"invalid time {time_val!r} — use HH:MM (24h)") + raise BlueprintFillError(f"invalid time {time_val!r} — use HH:MM (24h)") repl["hour"] = str(int(m.group(1))) repl["minute"] = str(int(m.group(2))) @@ -626,14 +626,14 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str: if "recurrence" in values: preset = str(values.get("recurrence", "everyday")).lower() if preset not in WEEKDAY_PRESETS: - raise RecipeFillError( + raise BlueprintFillError( f"unknown recurrence {preset!r} — one of {', '.join(WEEKDAY_PRESETS)}" ) repl["dow"] = WEEKDAY_PRESETS[preset] elif "day" in values: day = str(values.get("day", "")).lower() if day not in _DAY_TO_DOW: - raise RecipeFillError(f"unknown day {day!r}") + raise BlueprintFillError(f"unknown day {day!r}") repl["dow"] = _DAY_TO_DOW[day] else: repl["dow"] = "*" @@ -642,12 +642,12 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str: if "{interval_min}" in sched: iv = str(values.get("interval_min", "")).strip() if not iv.isdigit() or int(iv) <= 0: - raise RecipeFillError(f"invalid interval {iv!r} — minutes as a positive integer") + raise BlueprintFillError(f"invalid interval {iv!r} — minutes as a positive integer") repl["interval_min"] = iv # Any remaining {slot} placeholders are filled verbatim from validated # enum/text slot values (e.g. an hour-range window). Enum options have - # already been checked in fill_recipe, so these are safe to interpolate. + # already been checked in fill_blueprint, so these are safe to interpolate. for name in re.findall(r"\{(\w+)\}", sched): if name not in repl and name in values: repl[name] = str(values[name]) @@ -655,59 +655,59 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str: try: return sched.format(**repl) except KeyError as e: # pragma: no cover - template/slot mismatch is a dev error - raise RecipeFillError(f"schedule template missing value for {e}") from e + raise BlueprintFillError(f"schedule template missing value for {e}") from e -def fill_recipe( - recipe: CronRecipe, +def fill_blueprint( + blueprint: AutomationBlueprint, values: Dict[str, Any], *, origin: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Validate ``values`` and return ``cron.jobs.create_job`` kwargs. - Missing required (non-optional) slots raise RecipeFillError naming the + Missing required (non-optional) slots raise BlueprintFillError naming the slot, so a form can show field errors and the agent knows what to ask. Unknown slot names are rejected (a typo'd ``tiem=07:15`` must not silently create a job with the default time). Enum values are checked against their options. The result is passed straight to ``create_job`` — no second schema. """ - known = {s.name for s in recipe.slots} + known = {s.name for s in blueprint.slots} unknown = sorted(set(values) - known) if unknown: - raise RecipeFillError( + raise BlueprintFillError( f"unknown slot{'s' if len(unknown) > 1 else ''}: " - f"{', '.join(unknown)} — valid: {', '.join(s.name for s in recipe.slots)}" + f"{', '.join(unknown)} — valid: {', '.join(s.name for s in blueprint.slots)}" ) resolved: Dict[str, Any] = {} - for s in recipe.slots: + for s in blueprint.slots: raw = values.get(s.name, s.default) if raw in (None, ""): if s.optional: continue - raise RecipeFillError(f"missing required value: {s.name} ({s.label})") + raise BlueprintFillError(f"missing required value: {s.name} ({s.label})") if s.type == "enum" and s.strict and s.options and str(raw) not in {str(o) for o in s.options}: - raise RecipeFillError( + raise BlueprintFillError( f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}" ) resolved[s.name] = raw - schedule = _resolve_schedule(recipe, resolved) + schedule = _resolve_schedule(blueprint, resolved) # Render the prompt with whatever slots it references. try: - prompt = recipe.prompt_template.format(**resolved) + prompt = blueprint.prompt_template.format(**resolved) except KeyError as e: - raise RecipeFillError(f"recipe prompt missing value for {e}") from e + raise BlueprintFillError(f"blueprint prompt missing value for {e}") from e spec: Dict[str, Any] = { "prompt": prompt, "schedule": schedule, - "name": recipe.title, - "deliver": resolved.get("deliver", recipe.deliver_default), + "name": blueprint.title, + "deliver": resolved.get("deliver", blueprint.deliver_default), } - if recipe.skills: - spec["skills"] = list(recipe.skills) + if blueprint.skills: + spec["skills"] = list(blueprint.skills) if origin is not None: spec["origin"] = origin return spec diff --git a/cron/suggestions.py b/cron/suggestions.py index cd23da05a68a..636a0335cc32 100644 --- a/cron/suggestions.py +++ b/cron/suggestions.py @@ -7,8 +7,8 @@ * ``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 @@ # new suggestions are dropped (the user should clear the backlog first). MAX_PENDING = 5 -VALID_SOURCES = frozenset({"catalog", "recipe", "usage", "integration"}) +VALID_SOURCES = frozenset({"catalog", "blueprint", "usage", "integration"}) _STATUS_PENDING = "pending" _STATUS_ACCEPTED = "accepted" _STATUS_DISMISSED = "dismissed" diff --git a/gateway/run.py b/gateway/run.py index cd91f3d37300..041a6efcd560 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7174,11 +7174,11 @@ async def _do_reset(): 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 @@ async def _do_reset(): # 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 @@ async def _do_reset(): _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 @@ async def _handle_suggestions_command(self, event: MessageEvent) -> str: 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 @@ async def _handle_cron_recipe_command(self, event: MessageEvent): except Exception: origin = None try: - from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + from hermes_cli.blueprint_cmd import handle_blueprint_command - return handle_cron_recipe_command(args, origin=origin, surface="gateway") + return handle_blueprint_command(args, origin=origin, surface="gateway") except Exception as e: - logger.debug("cron-recipe command failed: %s", e) - from hermes_cli.cron_recipe_cmd import RecipeCommandResult + logger.debug("blueprint command failed: %s", e) + from hermes_cli.blueprint_cmd import BlueprintCommandResult - return RecipeCommandResult(f"Cron recipe command failed: {e}") + return BlueprintCommandResult(f"Cron blueprint command failed: {e}") # ──────────────────────────────────────────────────────────────── # /goal — persistent cross-turn goals (Ralph-style loop) diff --git a/hermes_cli/cron_recipe_cmd.py b/hermes_cli/blueprint_cmd.py similarity index 66% rename from hermes_cli/cron_recipe_cmd.py rename to hermes_cli/blueprint_cmd.py index 4927c9d8d175..e4a3afbf3db9 100644 --- a/hermes_cli/cron_recipe_cmd.py +++ b/hermes_cli/blueprint_cmd.py @@ -1,25 +1,25 @@ -"""Shared ``/cron-recipe`` command logic for CLI, TUI, and gateway. +"""Shared ``/blueprint`` command logic for CLI, TUI, and gateway. -The conversational counterpart to the dashboard's Cron Recipes form. Where a +The conversational counterpart to the dashboard's Automation Blueprints form. Where a surface has a screen, the user fills a form (dashboard / GUI app) and the API -calls ``fill_recipe`` -> ``create_job`` directly. Where a surface is just a -chat line, the user picks a recipe by name and the agent asks for what it -needs — pick a recipe by name and the agent asks you for what it needs, one -question at a time (the messaging-assistant model: pick a recipe → it asks you +calls ``fill_blueprint`` -> ``create_job`` directly. Where a surface is just a +chat line, the user picks a blueprint by name and the agent asks for what it +needs — pick a blueprint by name and the agent asks you for what it needs, one +question at a time (the messaging-assistant model: pick a blueprint → it asks you a couple things → done). Subcommand shapes: - /cron-recipe list the catalog - /cron-recipe name-match a recipe, then SEED THE AGENT to + /blueprint list the catalog + /blueprint name-match a blueprint, then SEED THE AGENT to ask the user for each value conversationally - /cron-recipe slot=val … fill + create the cron job directly + /blueprint slot=val … fill + create the cron job directly (the deterministic dashboard / docs / power- user shortcut — no agent turn) The ```` form is forgiving: exact key, unique prefix, or fuzzy match all resolve; an ambiguous query lists the candidates; an unknown one suggests the closest. When it resolves, the handler returns an ``agent_seed`` — a natural- -language instruction built from the recipe's typed slots + schedule/prompt +language instruction built from the blueprint's typed slots + schedule/prompt templates — that the calling surface feeds to the agent as a normal user turn (gateway: rewrite ``event.text`` and fall through, the ``/steer`` pattern; CLI: a one-shot pending seed the main loop runs). The agent then asks for each slot @@ -41,12 +41,12 @@ @dataclass -class RecipeCommandResult: - """Outcome of a ``/cron-recipe`` invocation. +class BlueprintCommandResult: + """Outcome of a ``/blueprint`` invocation. ``text`` is always shown to the user. When ``agent_seed`` is set, the calling surface should ALSO hand that seed to the agent as the user's next - turn (the recipe was matched and now the agent gathers the slot values + turn (the blueprint was matched and now the agent gathers the slot values conversationally). When ``agent_seed`` is None the command is fully handled (catalog listing, direct create, or an error) and nothing is sent to the agent. @@ -91,11 +91,11 @@ def _parse_kv(tokens) -> Tuple[Dict[str, str], list]: return values, leftovers -def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]: - """Resolve a free-typed recipe name to a recipe. +def match_blueprint(query: str) -> Tuple[Optional[Any], List[Any]]: + """Resolve a free-typed blueprint name to a blueprint. - Returns ``(recipe, candidates)``: - * exact key or unique prefix / fuzzy match -> ``(recipe, [])`` + Returns ``(blueprint, candidates)``: + * exact key or unique prefix / fuzzy match -> ``(blueprint, [])`` * ambiguous (2+ plausible) -> ``(None, [candidates…])`` * no plausible match -> ``(None, [])`` @@ -103,13 +103,13 @@ def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]: dashboard/Discord where it's picked): exact key first, then case-insensitive prefix on key or title, then a difflib fuzzy pass. """ - from cron.recipe_catalog import CATALOG, get_recipe + from cron.blueprint_catalog import CATALOG, get_blueprint q = (query or "").strip().lower() if not q: return None, [] - exact = get_recipe(q) + exact = get_blueprint(q) if exact is not None: return exact, [] @@ -138,43 +138,43 @@ def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]: keys = [r.key for r in CATALOG] close = difflib.get_close_matches(q, keys, n=3, cutoff=0.6) if len(close) == 1: - return get_recipe(close[0]), [] + return get_blueprint(close[0]), [] if len(close) > 1: - return None, [get_recipe(k) for k in close] + return None, [get_blueprint(k) for k in close] return None, [] -def _humanize_schedule(recipe) -> str: - from cron.recipe_catalog import _humanize_schedule as _h +def _humanize_schedule(blueprint) -> str: + from cron.blueprint_catalog import _humanize_schedule as _h try: - return _h(recipe) + return _h(blueprint) except Exception: return "on a schedule" -def build_recipe_seed(recipe) -> str: +def build_blueprint_seed(blueprint) -> str: """Build the natural-language fill-request the agent will act on. The agent reads this as a normal user turn, asks the user for each unfilled slot one at a time, then calls the ``cronjob`` tool with the - cron expression it builds from the recipe's ``schedule_template`` and the + cron expression it builds from the blueprint's ``schedule_template`` and the rendered prompt. Defaults are stated so the agent can offer them. """ - from cron.recipe_catalog import WEEKDAY_PRESETS + from cron.blueprint_catalog import WEEKDAY_PRESETS lines: List[str] = [] lines.append( - f"Set up the '{recipe.title}' automation for me (cron recipe " - f"'{recipe.key}'). {recipe.description}" + f"Set up the '{blueprint.title}' automation for me (automation blueprint " + f"'{blueprint.key}'). {blueprint.description}" ) lines.append("") lines.append( "Ask me for each of these, one at a time, offering the default in " "brackets if I don't have a preference:" ) - for s in recipe.slots: + for s in blueprint.slots: bits = [f"- {s.label} ({s.name})"] if s.options: bits.append(f" — one of: {', '.join(map(str, s.options))}") @@ -190,47 +190,47 @@ def build_recipe_seed(recipe) -> str: lines.append( "Once you have my answers, create the job by calling the cronjob tool " "with action='create'. Build the schedule as a cron expression from " - f"this template: `{recipe.schedule_template}` " + f"this template: `{blueprint.schedule_template}` " "(fill {minute}/{hour} from the chosen time, {dow} from the weekday " f"choice using {dict(WEEKDAY_PRESETS)}, {{interval_min}} from any " "interval). Use this exact prompt for the job (substituting my " - f"answers into any {{slot}} placeholders): \"{recipe.prompt_template}\". " + f"answers into any {{slot}} placeholders): \"{blueprint.prompt_template}\". " "Confirm the schedule and what it will do before you create it." ) return "\n".join(lines) def _fmt_catalog() -> str: - from cron.recipe_catalog import CATALOG + from cron.blueprint_catalog import CATALOG - lines = ["Cron Recipes — `/cron-recipe ` and I'll ask you what I need:\n"] + lines = ["Automation Blueprints — `/blueprint ` and I'll ask you what I need:\n"] for r in CATALOG: lines.append(f" • {r.key} — {r.title}") lines.append(f" {r.description}") lines.append( - "\nTip: `/cron-recipe ` walks you through it. Power users can " - "pass values inline, e.g. `/cron-recipe morning-brief time=08:00`." + "\nTip: `/blueprint ` walks you through it. Power users can " + "pass values inline, e.g. `/blueprint morning-brief time=08:00`." ) return "\n".join(lines) def _fmt_candidates(query: str, candidates: List[Any]) -> str: - lines = [f"'{query}' matches several recipes — which one?\n"] + lines = [f"'{query}' matches several blueprints — which one?\n"] for r in candidates: lines.append(f" • {r.key} — {r.title}") - lines.append("\nRun `/cron-recipe ` with one of the names above.") + lines.append("\nRun `/blueprint ` with one of the names above.") return "\n".join(lines) def _fmt_no_match(query: str) -> str: - from cron.recipe_catalog import CATALOG + from cron.blueprint_catalog import CATALOG keys = [r.key for r in CATALOG] close = difflib.get_close_matches((query or "").lower(), keys, n=3, cutoff=0.4) - msg = f"No cron recipe matches '{query}'." + msg = f"No automation blueprint matches '{query}'." if close: msg += " Did you mean: " + ", ".join(close) + "?" - msg += " Run /cron-recipe to see the catalog." + msg += " Run /blueprint to see the catalog." return msg @@ -243,28 +243,28 @@ def _manage_hint(surface: str) -> str: return "Ask me to list, pause, or remove it any time." -def handle_cron_recipe_command( +def handle_blueprint_command( args: str, *, origin: Optional[Dict[str, Any]] = None, surface: str = "cli", -) -> RecipeCommandResult: - """Dispatch a ``/cron-recipe`` invocation. +) -> BlueprintCommandResult: + """Dispatch a ``/blueprint`` invocation. - Returns a :class:`RecipeCommandResult`. When ``agent_seed`` is set the + Returns a :class:`BlueprintCommandResult`. When ``agent_seed`` is set the caller must feed it to the agent as the next user turn; otherwise the command is fully handled and only ``text`` is shown. - ``args`` is everything after ``/cron-recipe``. ``origin`` lets a directly + ``args`` is everything after ``/blueprint``. ``origin`` lets a directly created job deliver back to the chat it was set up from. ``surface`` (``"cli"`` | ``"gateway"``) picks the right wording for follow-up hints — ``/cron`` only exists on the CLI. """ try: - from cron.recipe_catalog import fill_recipe, RecipeFillError + from cron.blueprint_catalog import fill_blueprint, BlueprintFillError except Exception as e: # pragma: no cover - import guard - logger.debug("recipe catalog import failed: %s", e) - return RecipeCommandResult("Cron Recipes are unavailable in this build.") + logger.debug("blueprint catalog import failed: %s", e) + return BlueprintCommandResult("Automation Blueprints are unavailable in this build.") try: tokens = shlex.split(args or "") @@ -273,33 +273,33 @@ def handle_cron_recipe_command( # Bare -> list catalog. if not tokens: - return RecipeCommandResult(_fmt_catalog()) + return BlueprintCommandResult(_fmt_catalog()) query = tokens[0] values, _leftover = _parse_kv(tokens[1:]) - recipe, candidates = match_recipe(query) - if recipe is None: + blueprint, candidates = match_blueprint(query) + if blueprint is None: if candidates: - return RecipeCommandResult(_fmt_candidates(query, candidates)) - return RecipeCommandResult(_fmt_no_match(query)) + return BlueprintCommandResult(_fmt_candidates(query, candidates)) + return BlueprintCommandResult(_fmt_no_match(query)) # `` with no inline slot values -> seed the agent to ask for them. if not values: - seed = build_recipe_seed(recipe) + seed = build_blueprint_seed(blueprint) text = ( - f"Setting up '{recipe.title}' ({_humanize_schedule(recipe)}). " + f"Setting up '{blueprint.title}' ({_humanize_schedule(blueprint)}). " "I'll ask you a couple of things…" ) - return RecipeCommandResult(text, agent_seed=seed) + return BlueprintCommandResult(text, agent_seed=seed) # ` slot=val …` -> fill + create directly (deterministic shortcut). try: - spec = fill_recipe(recipe, values, origin=_resolve_origin(origin)) - except RecipeFillError as e: - return RecipeCommandResult( - f"Can't set up '{recipe.title}': {e}\n" - f"Or just run /cron-recipe {recipe.key} and I'll ask you for the values." + spec = fill_blueprint(blueprint, values, origin=_resolve_origin(origin)) + except BlueprintFillError as e: + return BlueprintCommandResult( + f"Can't set up '{blueprint.title}': {e}\n" + f"Or just run /blueprint {blueprint.key} and I'll ask you for the values." ) try: @@ -307,12 +307,12 @@ def handle_cron_recipe_command( job = create_job(**spec) except Exception as e: - logger.debug("cron-recipe create_job failed: %s", e) - return RecipeCommandResult(f"Failed to create the job: {e}") + logger.debug("blueprint create_job failed: %s", e) + return BlueprintCommandResult(f"Failed to create the job: {e}") sched = job.get("schedule_display") or spec.get("schedule", "") - return RecipeCommandResult( - f"Scheduled '{recipe.title}'" + return BlueprintCommandResult( + f"Scheduled '{blueprint.title}'" + (f" ({sched})" if sched else "") + f", delivering to {spec.get('deliver', 'origin')}. {_manage_hint(surface)}" ) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index ec2927675d75..b52c6de802e9 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1276,13 +1276,13 @@ def _handle_suggestions_command(self, cmd: str): output = f"Suggestions command failed: {e}" self._console_print(output) - def _handle_cron_recipe_command(self, cmd: str): - """Handle /cron-recipe — set up an automation from a recipe template. + def _handle_blueprint_command(self, cmd: str): + """Handle /blueprint — set up an automation from a blueprint template. - Delegates to the shared handler. A bare ``/cron-recipe`` lists the - catalog; ``/cron-recipe `` name-matches a recipe and seeds the + Delegates to the shared handler. A bare ``/blueprint`` lists the + catalog; ``/blueprint `` name-matches a blueprint and seeds the agent to ask the user for each value conversationally (the result's - ``agent_seed``); ``/cron-recipe slot=val …`` creates the job + ``agent_seed``); ``/blueprint slot=val …`` creates the job directly. When a seed is returned it is stashed as a one-shot pending message the interactive loop runs as the next agent turn. """ @@ -1294,10 +1294,10 @@ def _handle_cron_recipe_command(self, cmd: str): tokens = (cmd or "").split()[1:] args = " ".join(shlex.quote(t) for t in tokens) try: - from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command - result = handle_cron_recipe_command(args) + from hermes_cli.blueprint_cmd import handle_blueprint_command + result = handle_blueprint_command(args) except Exception as e: - self._console_print(f"Cron recipe command failed: {e}") + self._console_print(f"Cron blueprint command failed: {e}") return self._console_print(result.text) seed = getattr(result, "agent_seed", None) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 20a87eb811cd..78461ca138e2 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -182,8 +182,8 @@ class CommandDef: CommandDef("suggestions", "Review suggested automations (accept/dismiss)", "Tools & Skills", aliases=("suggest",), args_hint="[accept|dismiss N | catalog]", subcommands=("accept", "dismiss", "catalog", "clear")), - CommandDef("cron-recipe", "Set up an automation from a recipe template", - "Tools & Skills", aliases=("recipe",), args_hint="[name] [slot=value ...]"), + CommandDef("blueprint", "Set up an automation from a blueprint template", + "Tools & Skills", aliases=("bp",), args_hint="[name] [slot=value ...]"), CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)", "Tools & Skills", args_hint="[subcommand]", subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived")), diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index f6f70b288d40..f1e4f83b2c78 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -691,24 +691,24 @@ def do_install(identifier: str, category: str = "", force: bool = False, c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}") c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n") - # Recipe detection: if the installed skill declares a - # metadata.hermes.recipe block, it is a runnable automation. Register it as + # Blueprint detection: if the installed skill declares a + # metadata.hermes.blueprint block, it is a runnable automation. Register it as # a Suggested Cron Job rather than auto-scheduling — installing never # silently creates a recurring job; the user accepts it via /suggestions. # This is the single surface every automation proposal flows through. try: - from tools.recipes import RecipeError, recipe_spec_for_installed, register_recipe_suggestion + from tools.blueprints import BlueprintError, blueprint_spec_for_installed, register_blueprint_suggestion try: - spec = recipe_spec_for_installed(bundle.name) - except RecipeError as _rec_err: - c.print(f"[yellow]Recipe block present but invalid:[/] {_rec_err}\n") + spec = blueprint_spec_for_installed(bundle.name) + except BlueprintError as _rec_err: + c.print(f"[yellow]Blueprint block present but invalid:[/] {_rec_err}\n") spec = None if spec is not None: - registered = register_recipe_suggestion(spec) + registered = register_blueprint_suggestion(spec) if registered is not None: c.print( - f"[bold cyan]Recipe:[/] '{bundle.name}' is an automation " + f"[bold cyan]Blueprint:[/] '{bundle.name}' is an automation " f"(schedule [bold]{spec.schedule}[/])." ) c.print( @@ -720,7 +720,7 @@ def do_install(identifier: str, category: str = "", force: bool = False, # list is at its cap. Say so instead of silently doing nothing — # the user can still schedule it by hand. c.print( - f"[bold cyan]Recipe:[/] '{bundle.name}' is an automation " + f"[bold cyan]Blueprint:[/] '{bundle.name}' is an automation " f"(schedule [bold]{spec.schedule}[/]), but it wasn't added to " "your suggestions (already offered/dismissed, or the pending " "list is full — run [bold]/suggestions[/] to review)." @@ -729,7 +729,7 @@ def do_install(identifier: str, category: str = "", force: bool = False, "[dim]You can still schedule it any time by asking the agent " "or via[/] [bold]hermes cron add[/][dim].[/]\n" ) - except Exception: # pragma: no cover - recipe detection is best-effort + except Exception: # pragma: no cover - blueprint detection is best-effort pass if invalidate_cache: diff --git a/hermes_cli/suggestions_cmd.py b/hermes_cli/suggestions_cmd.py index aa336e37a195..2dfe6bf55486 100644 --- a/hermes_cli/suggestions_cmd.py +++ b/hermes_cli/suggestions_cmd.py @@ -25,7 +25,7 @@ def _fmt_pending(pending: list) -> str: return ( "No suggested automations right now.\n" "Try `/suggestions catalog` to see the curated starter set, or " - "install a recipe skill to get one." + "install a blueprint skill to get one." ) lines = ["Suggested automations — `/suggestions accept N` or `dismiss N`:\n"] for i, s in enumerate(pending, 1): diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ffafc93d4bc0..40a5fc02eecc 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -6779,25 +6779,25 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None): # --------------------------------------------------------------------------- -# Cron Recipes — parameterized automation templates. The dashboard renders the +# Automation Blueprints — parameterized automation templates. The dashboard renders the # slot schema as a form; submitting instantiates a real cron job via the same -# create_job path. See cron/recipe_catalog.py for the single source of truth. +# create_job path. See cron/blueprint_catalog.py for the single source of truth. # --------------------------------------------------------------------------- -class CronRecipeInstantiate(BaseModel): - recipe: str # recipe key, e.g. "morning-brief" +class AutomationBlueprintInstantiate(BaseModel): + blueprint: str # blueprint key, e.g. "morning-brief" values: Dict[str, Any] = {} # filled slot values from the form -@app.get("/api/cron/recipes") -async def list_cron_recipes(): - """Return the recipe catalog as form schemas for the dashboard gallery. +@app.get("/api/cron/blueprints") +async def list_cron_blueprints(): + """Return the blueprint catalog as form schemas for the dashboard gallery. The ``deliver`` slot's options are rewritten from the user's actually configured gateway platforms (plus the universal origin/local/all), so the form never offers a platform that isn't connected. """ try: - from cron.recipe_catalog import CATALOG, recipe_catalog_entry + from cron.blueprint_catalog import CATALOG, blueprint_catalog_entry deliver_options = None try: @@ -6810,40 +6810,40 @@ async def list_cron_recipes(): entries = [] for r in CATALOG: - entry = recipe_catalog_entry(r) + entry = blueprint_catalog_entry(r) if deliver_options: for f in entry.get("fields", []): if f.get("name") == "deliver": f["options"] = deliver_options entries.append(entry) - return {"recipes": entries} + return {"blueprints": entries} except Exception as e: - _log.exception("GET /api/cron/recipes failed") + _log.exception("GET /api/cron/blueprints failed") raise HTTPException(status_code=500, detail=str(e)) -@app.post("/api/cron/recipes/instantiate") -async def instantiate_cron_recipe(body: CronRecipeInstantiate, profile: str = "default"): - """Fill a recipe's slots and create the cron job (form-submit path).""" +@app.post("/api/cron/blueprints/instantiate") +async def instantiate_blueprint(body: AutomationBlueprintInstantiate, profile: str = "default"): + """Fill a blueprint's slots and create the cron job (form-submit path).""" try: - from cron.recipe_catalog import fill_recipe, get_recipe, RecipeFillError + from cron.blueprint_catalog import fill_blueprint, get_blueprint, BlueprintFillError - recipe = get_recipe(body.recipe) - if recipe is None: - raise HTTPException(status_code=404, detail=f"Unknown recipe: {body.recipe}") + blueprint = get_blueprint(body.blueprint) + if blueprint is None: + raise HTTPException(status_code=404, detail=f"Unknown blueprint: {body.blueprint}") try: - spec = fill_recipe(recipe, body.values) - except RecipeFillError as exc: + spec = fill_blueprint(blueprint, body.values) + except BlueprintFillError as exc: # Field-level validation error — 422 so the form can show it inline. raise HTTPException(status_code=422, detail=str(exc)) from exc - # Recipe-created jobs deliver to the dashboard's configured target by + # Blueprint-created jobs deliver to the dashboard's configured target by # default; the form's deliver slot overrides via spec["deliver"]. spec.pop("origin", None) return _call_cron_for_profile(profile, "create_job", **spec) except HTTPException: raise except Exception as e: - _log.exception("POST /api/cron/recipes/instantiate failed") + _log.exception("POST /api/cron/blueprints/instantiate failed") raise HTTPException(status_code=400, detail=str(e)) diff --git a/tests/cron/test_recipe_catalog.py b/tests/cron/test_blueprint_catalog.py similarity index 61% rename from tests/cron/test_recipe_catalog.py rename to tests/cron/test_blueprint_catalog.py index 017d6bd2ae1a..a5470c81f8ca 100644 --- a/tests/cron/test_recipe_catalog.py +++ b/tests/cron/test_blueprint_catalog.py @@ -1,7 +1,7 @@ -"""Tests for Cron Recipes — the parameterized automation template system. +"""Tests for Automation Blueprints — the parameterized automation template system. -Covers the core catalog/slot schema/renderers/fill (cron/recipe_catalog.py), -the shared /cron-recipe command handler (hermes_cli/cron_recipe_cmd.py), and +Covers the core catalog/slot schema/renderers/fill (cron/blueprint_catalog.py), +the shared /blueprint command handler (hermes_cli/blueprint_cmd.py), and the docs generator. Uses an isolated HERMES_HOME for anything that touches the cron job store. """ @@ -13,16 +13,16 @@ 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 @@ def test_every_slot_has_known_type(self): 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 @@ def test_hydration_hourly_step_actually_fires_at_chosen_cadence(self): 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 @@ def test_hydration_hourly_step_actually_fires_at_chosen_cadence(self): assert gaps == {7200.0}, f"expected 2h gaps, got {spec['schedule']} -> {first_three}" def test_text_slot_renders_into_prompt(self): - spec = fill_recipe( - get_recipe("important-mail"), + spec = fill_blueprint( + get_blueprint("important-mail"), {"interval_min": "30", "criteria": "from my CEO", "deliver": "origin"}, ) assert "from my CEO" in spec["prompt"] def test_origin_threads_through(self): - spec = fill_recipe( - get_recipe("morning-brief"), {"time": "08:00"}, origin={"platform": "telegram", "chat_id": "9"} + spec = fill_blueprint( + get_blueprint("morning-brief"), {"time": "08:00"}, origin={"platform": "telegram", "chat_id": "9"} ) assert spec["origin"] == {"platform": "telegram", "chat_id": "9"} class TestRenderers: def test_form_schema_fields(self): - schema = recipe_form_schema(get_recipe("morning-brief")) + schema = blueprint_form_schema(get_blueprint("morning-brief")) names = [f["name"] for f in schema["fields"]] assert names == ["time", "deliver"] assert schema["key"] == "morning-brief" def test_slash_command_defaults(self): - cmd = recipe_slash_command(get_recipe("morning-brief")) - assert cmd.startswith("/cron-recipe morning-brief") + cmd = blueprint_slash_command(get_blueprint("morning-brief")) + assert cmd.startswith("/blueprint morning-brief") assert "time=08:00" in cmd def test_slash_command_quotes_freetext(self): - cmd = recipe_slash_command( - get_recipe("custom-reminder"), {"what": "drink water", "time": "10:00"} + cmd = blueprint_slash_command( + get_blueprint("custom-reminder"), {"what": "drink water", "time": "10:00"} ) assert '"drink water"' in cmd def test_deeplink_shape(self): - url = recipe_deeplink(get_recipe("morning-brief"), {"time": "07:15"}) - assert url.startswith("hermes://cron-recipe/morning-brief?") + url = blueprint_deeplink(get_blueprint("morning-brief"), {"time": "07:15"}) + assert url.startswith("hermes://blueprint/morning-brief?") assert "time=07" in url def test_catalog_entry_has_all_surfaces(self): - entry = recipe_catalog_entry(get_recipe("morning-brief")) - assert entry["command"].startswith("/cron-recipe") + entry = blueprint_catalog_entry(get_blueprint("morning-brief")) + assert entry["command"].startswith("/blueprint") assert entry["appUrl"].startswith("hermes://") assert entry["scheduleHuman"] assert "fields" in entry @@ -168,18 +168,18 @@ def isolated_home(tmp_path, monkeypatch): class TestCommandHandler: def test_bare_lists_catalog(self, isolated_home): - from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + from hermes_cli.blueprint_cmd import handle_blueprint_command - res = handle_cron_recipe_command("") - assert "morning-brief" in res.text and "Cron Recipes" in res.text + res = handle_blueprint_command("") + assert "morning-brief" in res.text and "Automation Blueprints" in res.text assert res.agent_seed is None def test_name_seeds_agent(self, isolated_home): - from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command + from hermes_cli.blueprint_cmd import handle_blueprint_command - # `/cron-recipe ` (no inline slots) now seeds the agent to ask + # `/blueprint ` (no inline slots) now seeds the agent to ask # the user for each value conversationally instead of dumping fields. - res = handle_cron_recipe_command("morning-brief") + res = handle_blueprint_command("morning-brief") assert res.agent_seed is not None assert "morning-brief" in res.agent_seed assert "cronjob tool" in res.agent_seed @@ -187,22 +187,22 @@ def test_name_seeds_agent(self, isolated_home): 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 @@ def test_fill_creates_job(self, isolated_home): 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 @@ def test_generator_emits_valid_index(self, tmp_path): script = ( Path(__file__).resolve().parents[2] - / "website" / "scripts" / "extract-cron-recipes.py" + / "website" / "scripts" / "extract-automation-blueprints.py" ) - spec = importlib.util.spec_from_file_location("extract_cron_recipes", script) + spec = importlib.util.spec_from_file_location("extract_cron_blueprints", script) assert spec is not None and spec.loader is not None mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) diff --git a/tests/cron/test_suggestions.py b/tests/cron/test_suggestions.py index 179c2956623a..75ee7fe7a875 100644 --- a/tests/cron/test_suggestions.py +++ b/tests/cron/test_suggestions.py @@ -1,7 +1,7 @@ """Tests for the Suggested Cron Jobs feature. Covers the store (add/dedup/cap/accept/dismiss/latch), catalog seeding, the -recipe->suggestion bridge, and the shared command handler. Uses an isolated +blueprint->suggestion bridge, and the shared command handler. Uses an isolated HERMES_HOME so the real suggestions.json is never touched. """ @@ -136,23 +136,23 @@ def test_monitor_entry_references_classifier_script(self): assert Path(classify_items_script_path()).name == "classify_items.py" -class TestRecipeBridge: - def test_recipe_registers_suggestion(self, store): - from tools.recipes import RecipeSpec, register_recipe_suggestion +class TestBlueprintBridge: + def test_blueprint_registers_suggestion(self, store): + from tools.blueprints import BlueprintSpec, register_blueprint_suggestion - spec = RecipeSpec(skill_name="morning-brief", schedule="0 8 * * *", deliver="telegram") + spec = BlueprintSpec(skill_name="morning-brief", schedule="0 8 * * *", deliver="telegram") with patch("cron.suggestions.add_suggestion", store.add_suggestion): - rec = register_recipe_suggestion(spec) + rec = register_blueprint_suggestion(spec) assert rec is not None - assert rec["source"] == "recipe" + assert rec["source"] == "blueprint" assert rec["job_spec"]["skills"] == ["morning-brief"] assert rec["job_spec"]["schedule"] == "0 8 * * *" - def test_recipe_to_job_spec_matches_create_recipe_job(self): - from tools.recipes import RecipeSpec, recipe_to_job_spec + def test_blueprint_to_job_spec_matches_create_blueprint_job(self): + from tools.blueprints import BlueprintSpec, blueprint_to_job_spec - spec = RecipeSpec(skill_name="x", schedule="every 2h", deliver="origin", prompt="p") - js = recipe_to_job_spec(spec) + spec = BlueprintSpec(skill_name="x", schedule="every 2h", deliver="origin", prompt="p") + js = blueprint_to_job_spec(spec) assert js["skills"] == ["x"] assert js["schedule"] == "every 2h" assert js["prompt"] == "p" diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 0a6ba0607e9a..61c4aa466be4 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2360,39 +2360,39 @@ def test_cron_job_not_found(self): resp = self.client.get("/api/cron/jobs/nonexistent-id") assert resp.status_code == 404 - # --- Cron Recipes --- + # --- Automation Blueprints --- - def test_cron_recipes_list(self): - resp = self.client.get("/api/cron/recipes") + def test_cron_blueprints_list(self): + resp = self.client.get("/api/cron/blueprints") assert resp.status_code == 200 - recipes = resp.json()["recipes"] - assert len(recipes) >= 1 - first = recipes[0] + blueprints = resp.json()["blueprints"] + assert len(blueprints) >= 1 + first = blueprints[0] assert "fields" in first - assert first["command"].startswith("/cron-recipe") + assert first["command"].startswith("/blueprint") assert first["appUrl"].startswith("hermes://") - def test_cron_recipe_instantiate_creates_job(self): + def test_blueprint_instantiate_creates_job(self): resp = self.client.post( - "/api/cron/recipes/instantiate", - json={"recipe": "morning-brief", "values": {"time": "07:30", "deliver": "local"}}, + "/api/cron/blueprints/instantiate", + json={"blueprint": "morning-brief", "values": {"time": "07:30", "deliver": "local"}}, ) assert resp.status_code == 200 job = resp.json() assert (job.get("schedule_display") or "").strip() == "30 7 * * *" or \ (job.get("schedule", {}) or {}).get("expr") == "30 7 * * *" - def test_cron_recipe_instantiate_unknown_404(self): + def test_blueprint_instantiate_unknown_404(self): resp = self.client.post( - "/api/cron/recipes/instantiate", - json={"recipe": "does-not-exist", "values": {}}, + "/api/cron/blueprints/instantiate", + json={"blueprint": "does-not-exist", "values": {}}, ) assert resp.status_code == 404 - def test_cron_recipe_instantiate_bad_value_422(self): + def test_blueprint_instantiate_bad_value_422(self): resp = self.client.post( - "/api/cron/recipes/instantiate", - json={"recipe": "morning-brief", "values": {"time": "99:99"}}, + "/api/cron/blueprints/instantiate", + json={"blueprint": "morning-brief", "values": {"time": "99:99"}}, ) assert resp.status_code == 422 diff --git a/tests/tools/test_cron_recipes.py b/tests/tools/test_blueprints.py similarity index 61% rename from tests/tools/test_cron_recipes.py rename to tests/tools/test_blueprints.py index 439b96049136..e23cfa69cfc0 100644 --- a/tests/tools/test_cron_recipes.py +++ b/tests/tools/test_blueprints.py @@ -1,6 +1,6 @@ -"""Tests for the recipes layer (skill frontmatter <-> cron automation bridge). +"""Tests for the blueprints layer (skill frontmatter <-> cron automation bridge). -A recipe is a skill with a metadata.hermes.recipe block. These verify parsing, +A blueprint is a skill with a metadata.hermes.blueprint block. These verify parsing, the create-job bridge, and the export round-trip without touching the real cron store. """ @@ -11,24 +11,24 @@ 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 @@ """ 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 @@ """ -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 @@ def test_missing_skill_returns_none(self, tmp_path): 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 @@ def fake_create_job(**kwargs): 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 @@ def fake_create_job(**kwargs): 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 @@ def test_round_trips_job_to_skill_md(self): "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 @@ def test_export_interval_job_without_display(self): "schedule": {"kind": "interval", "minutes": 30}, "skills": ["poller"], } - md = export_recipe(job, "body") - spec = parse_recipe(md) + md = export_blueprint(job, "body") + spec = parse_blueprint(md) assert spec is not None assert spec.schedule == "every 30m" job["schedule"] = {"kind": "interval", "minutes": 120} - spec = parse_recipe(export_recipe(job, "body")) + spec = parse_blueprint(export_blueprint(job, "body")) assert spec is not None assert spec.schedule == "every 2h" diff --git a/tools/recipes.py b/tools/blueprints.py similarity index 59% rename from tools/recipes.py rename to tools/blueprints.py index 1b95f192802a..7e4c5591a088 100644 --- a/tools/recipes.py +++ b/tools/blueprints.py @@ -1,30 +1,30 @@ -"""Recipes: shareable plain-language automations layered on skills + cron. +"""Blueprints: shareable plain-language automations layered on skills + cron. -A "recipe" is NOT a new object type. It is an ordinary skill (a SKILL.md the +A "blueprint" is NOT a new object type. It is an ordinary skill (a SKILL.md the agent loads) that additionally declares an automation schedule in its frontmatter: metadata: hermes: - recipe: - schedule: "0 9 * * *" # presence of `recipe:` marks it runnable + blueprint: + schedule: "0 9 * * *" # presence of `blueprint:` marks it runnable deliver: origin # optional (default "origin") prompt: "..." # optional task instruction for the run no_agent: false # optional -Because a recipe is just a skill, it flows through the ENTIRE existing +Because a blueprint is just a skill, it flows through the ENTIRE existing skills-hub pipeline for free — search, inspect, quarantine, security scan, install, lock-file provenance, audit log, taps, the centralized index, and `hermes skills publish` for sharing. No new source type, no new store, no new transport. This module is the thin bridge between that skill metadata and the existing cron `create_job()` API: - * ``parse_recipe(skill_md_text)`` -> RecipeSpec | None - * ``recipe_spec_for_installed(name)`` -> RecipeSpec | None - * ``create_recipe_job(spec, ...)`` -> the created cron job dict - * ``export_recipe(job, body)`` -> a shareable SKILL.md string + * ``parse_blueprint(skill_md_text)`` -> BlueprintSpec | None + * ``blueprint_spec_for_installed(name)`` -> BlueprintSpec | None + * ``create_blueprint_job(spec, ...)`` -> the created cron job dict + * ``export_blueprint(job, body)`` -> a shareable SKILL.md string -The dev guide's "Extend, Don't Duplicate" rule is the whole design: the recipe +The dev guide's "Extend, Don't Duplicate" rule is the whole design: the blueprint is a skill, the schedule is a cron job, sharing is the existing publish/tap/ index path. """ @@ -39,24 +39,24 @@ logger = logging.getLogger(__name__) __all__ = [ - "RecipeSpec", - "parse_recipe", - "recipe_spec_for_installed", - "recipe_to_job_spec", - "create_recipe_job", - "register_recipe_suggestion", - "export_recipe", - "RecipeError", + "BlueprintSpec", + "parse_blueprint", + "blueprint_spec_for_installed", + "blueprint_to_job_spec", + "create_blueprint_job", + "register_blueprint_suggestion", + "export_blueprint", + "BlueprintError", ] -class RecipeError(ValueError): - """Raised when a recipe block is present but malformed.""" +class BlueprintError(ValueError): + """Raised when a blueprint block is present but malformed.""" @dataclass -class RecipeSpec: - """Parsed ``metadata.hermes.recipe`` automation spec for a skill.""" +class BlueprintSpec: + """Parsed ``metadata.hermes.blueprint`` automation spec for a skill.""" skill_name: str schedule: str @@ -87,16 +87,16 @@ def _split_frontmatter(text: str) -> Optional[Dict[str, Any]]: data = yaml.safe_load(fm_text) except Exception as e: # pragma: no cover - malformed YAML - logger.debug("recipe: frontmatter YAML parse failed: %s", e) + logger.debug("blueprint: frontmatter YAML parse failed: %s", e) return None return data if isinstance(data, dict) else None -def parse_recipe(skill_md_text: str) -> Optional[RecipeSpec]: - """Extract a RecipeSpec from a SKILL.md string, or None if not a recipe. +def parse_blueprint(skill_md_text: str) -> Optional[BlueprintSpec]: + """Extract a BlueprintSpec from a SKILL.md string, or None if not a blueprint. - A skill is a recipe iff ``metadata.hermes.recipe`` is a mapping containing - a non-empty ``schedule``. Raises RecipeError if the block exists but is + A skill is a blueprint iff ``metadata.hermes.blueprint`` is a mapping containing + a non-empty ``schedule``. Raises BlueprintError if the block exists but is structurally invalid (so a typo surfaces instead of silently no-op'ing). """ fm = _split_frontmatter(skill_md_text) @@ -107,28 +107,28 @@ def parse_recipe(skill_md_text: str) -> Optional[RecipeSpec]: meta = fm.get("metadata") hermes = meta.get("hermes") if isinstance(meta, dict) else None - recipe = hermes.get("recipe") if isinstance(hermes, dict) else None - if recipe is None: + blueprint = hermes.get("blueprint") if isinstance(hermes, dict) else None + if blueprint is None: return None - if not isinstance(recipe, dict): - raise RecipeError("metadata.hermes.recipe must be a mapping") + if not isinstance(blueprint, dict): + raise BlueprintError("metadata.hermes.blueprint must be a mapping") - schedule = str(recipe.get("schedule", "")).strip() + schedule = str(blueprint.get("schedule", "")).strip() if not schedule: - raise RecipeError("recipe.schedule is required and must be non-empty") + raise BlueprintError("blueprint.schedule is required and must be non-empty") - deliver = str(recipe.get("deliver", "origin")).strip() or "origin" - prompt = recipe.get("prompt") + deliver = str(blueprint.get("deliver", "origin")).strip() or "origin" + prompt = blueprint.get("prompt") if prompt is not None: prompt = str(prompt) - no_agent = bool(recipe.get("no_agent", False)) - model = recipe.get("model") - provider = recipe.get("provider") - toolsets = recipe.get("enabled_toolsets") + no_agent = bool(blueprint.get("no_agent", False)) + model = blueprint.get("model") + provider = blueprint.get("provider") + toolsets = blueprint.get("enabled_toolsets") if toolsets is not None and not isinstance(toolsets, list): - raise RecipeError("recipe.enabled_toolsets must be a list when present") + raise BlueprintError("blueprint.enabled_toolsets must be a list when present") - return RecipeSpec( + return BlueprintSpec( skill_name=name, schedule=schedule, deliver=deliver, @@ -137,15 +137,15 @@ def parse_recipe(skill_md_text: str) -> Optional[RecipeSpec]: model=str(model).strip() if model else None, provider=str(provider).strip() if provider else None, enabled_toolsets=[str(t) for t in toolsets] if toolsets else None, - raw=recipe, + raw=blueprint, ) -def recipe_spec_for_installed(skill_name: str) -> Optional[RecipeSpec]: - """Locate an installed skill's SKILL.md and parse its recipe block. +def blueprint_spec_for_installed(skill_name: str) -> Optional[BlueprintSpec]: + """Locate an installed skill's SKILL.md and parse its blueprint block. Searches the standard skills tree for ``/SKILL.md``. Returns - None if the skill isn't found or isn't a recipe. + None if the skill isn't found or isn't a blueprint. """ try: from tools.skills_hub import SKILLS_DIR @@ -160,7 +160,7 @@ def recipe_spec_for_installed(skill_name: str) -> Optional[RecipeSpec]: text = path.read_text(encoding="utf-8") except OSError: continue - spec = parse_recipe(text) + spec = parse_blueprint(text) if spec is not None: # Prefer the frontmatter name, fall back to the directory name. if not spec.skill_name: @@ -169,22 +169,22 @@ def recipe_spec_for_installed(skill_name: str) -> Optional[RecipeSpec]: return None -def recipe_to_job_spec( - spec: RecipeSpec, +def blueprint_to_job_spec( + spec: BlueprintSpec, *, name: Optional[str] = None, ) -> Dict[str, Any]: - """Build the ``cron.jobs.create_job`` kwargs dict for a RecipeSpec. + """Build the ``cron.jobs.create_job`` kwargs dict for a BlueprintSpec. - This is the single source of truth for translating a recipe into a job. - Both the direct ``create_recipe_job`` path and the suggestion path - (``register_recipe_suggestion``) build on it, so a recipe scheduled now and - a recipe accepted from a suggestion produce an identical job. + This is the single source of truth for translating a blueprint into a job. + Both the direct ``create_blueprint_job`` path and the suggestion path + (``register_blueprint_suggestion``) build on it, so a blueprint scheduled now and + a blueprint accepted from a suggestion produce an identical job. """ return { "prompt": spec.prompt, "schedule": spec.schedule, - "name": name or f"recipe:{spec.skill_name}", + "name": name or f"blueprint:{spec.skill_name}", "deliver": spec.deliver, "skills": [spec.skill_name] if spec.skill_name else None, "model": spec.model, @@ -194,31 +194,31 @@ def recipe_to_job_spec( } -def create_recipe_job( - spec: RecipeSpec, +def create_blueprint_job( + spec: BlueprintSpec, *, origin: Optional[Dict[str, Any]] = None, name: Optional[str] = None, ) -> Dict[str, Any]: - """Create the cron job described by a RecipeSpec via the existing cron API. + """Create the cron job described by a BlueprintSpec via the existing cron API. - The recipe's skill is loaded before the run (cron ``skills=[name]``); the + The blueprint's skill is loaded before the run (cron ``skills=[name]``); the optional ``prompt`` becomes the task instruction. Delivery, model, and toolsets carry through. Returns the created job dict. """ from cron.jobs import create_job - job_spec = recipe_to_job_spec(spec, name=name) + job_spec = blueprint_to_job_spec(spec, name=name) if origin is not None: job_spec["origin"] = origin return create_job(**job_spec) -def register_recipe_suggestion(spec: RecipeSpec) -> Optional[Dict[str, Any]]: - """Turn an installed recipe into a pending Suggested Cron Job. +def register_blueprint_suggestion(spec: BlueprintSpec) -> Optional[Dict[str, Any]]: + """Turn an installed blueprint into a pending Suggested Cron Job. - Recipes are source ``recipe`` of the unified suggestion surface: installing - a skill that carries a ``recipe:`` block does NOT auto-schedule it — it + Blueprints are source ``blueprint`` of the unified suggestion surface: installing + a skill that carries a ``blueprint:`` block does NOT auto-schedule it — it registers a suggestion the user accepts (or dismisses) like any other. Returns the suggestion record, or None if it was skipped (already seen/dismissed, backlog full, etc.). @@ -233,53 +233,53 @@ def register_recipe_suggestion(spec: RecipeSpec) -> Optional[Dict[str, Any]]: return add_suggestion( title=f"Schedule '{spec.skill_name}'", description=( - f"The '{spec.skill_name}' recipe runs on schedule {spec.schedule}" + f"The '{spec.skill_name}' blueprint runs on schedule {spec.schedule}" + (f", delivering to {spec.deliver}" if spec.deliver and spec.deliver != "origin" else "") + "." ), - source="recipe", - job_spec=recipe_to_job_spec(spec), - dedup_key=f"recipe:{spec.skill_name}:{spec.schedule}", + source="blueprint", + job_spec=blueprint_to_job_spec(spec), + dedup_key=f"blueprint:{spec.skill_name}:{spec.schedule}", ) -def export_recipe(job: Dict[str, Any], body: str, *, recipe_name: Optional[str] = None) -> str: - """Render a shareable recipe SKILL.md from an existing cron job dict. +def export_blueprint(job: Dict[str, Any], body: str, *, blueprint_name: Optional[str] = None) -> str: + """Render a shareable blueprint SKILL.md from an existing cron job dict. - The inverse of ``create_recipe_job``: take a cron job a user already built - and emit a SKILL.md (with a ``metadata.hermes.recipe`` block) they can hand + The inverse of ``create_blueprint_job``: take a cron job a user already built + and emit a SKILL.md (with a ``metadata.hermes.blueprint`` block) they can hand to ``hermes skills publish`` to share. ``body`` is the plain-language description / instructions that become the SKILL.md body. """ import yaml - name = recipe_name or job.get("name") or "shared-recipe" + name = blueprint_name or job.get("name") or "shared-blueprint" # Sanitize to a valid skill identifier. name = "".join(c if (c.isalnum() or c in "-_") else "-" for c in str(name).lower()) - name = name.strip("-_") or "shared-recipe" + name = name.strip("-_") or "shared-blueprint" schedule = job.get("schedule_display") or _schedule_to_string(job.get("schedule")) skills = job.get("skills") or ([job["skill"]] if job.get("skill") else []) - recipe_block: Dict[str, Any] = {"schedule": schedule} + blueprint_block: Dict[str, Any] = {"schedule": schedule} deliver = job.get("deliver") if deliver and deliver != "origin": - recipe_block["deliver"] = deliver + blueprint_block["deliver"] = deliver if job.get("prompt"): - recipe_block["prompt"] = job["prompt"] + blueprint_block["prompt"] = job["prompt"] if job.get("no_agent"): - recipe_block["no_agent"] = True + blueprint_block["no_agent"] = True if job.get("model"): - recipe_block["model"] = job["model"] + blueprint_block["model"] = job["model"] if job.get("provider"): - recipe_block["provider"] = job["provider"] + blueprint_block["provider"] = job["provider"] if job.get("enabled_toolsets"): - recipe_block["enabled_toolsets"] = job["enabled_toolsets"] + blueprint_block["enabled_toolsets"] = job["enabled_toolsets"] description = ( - (body.strip().splitlines() or ["Shared automation recipe."])[0][:200] + (body.strip().splitlines() or ["Shared automation blueprint."])[0][:200] if body.strip() - else "Shared automation recipe." + else "Shared automation blueprint." ) frontmatter = { @@ -289,13 +289,13 @@ def export_recipe(job: Dict[str, Any], body: str, *, recipe_name: Optional[str] "license": "MIT", "metadata": { "hermes": { - "tags": ["recipe", "automation"], - "recipe": recipe_block, + "tags": ["blueprint", "automation"], + "blueprint": blueprint_block, } }, } fm_yaml = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True).strip() - body_text = body.strip() or f"# {name}\n\nShared automation recipe." + body_text = body.strip() or f"# {name}\n\nShared automation blueprint." return f"---\n{fm_yaml}\n---\n\n{body_text}\n" diff --git a/web/src/components/CronRecipes.tsx b/web/src/components/AutomationBlueprints.tsx similarity index 72% rename from web/src/components/CronRecipes.tsx rename to web/src/components/AutomationBlueprints.tsx index ce9f08b62dce..10d1270fa059 100644 --- a/web/src/components/CronRecipes.tsx +++ b/web/src/components/AutomationBlueprints.tsx @@ -10,19 +10,19 @@ import { Badge } from "@nous-research/ui/ui/components/badge"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { Toast } from "@nous-research/ui/ui/components/toast"; import { api } from "@/lib/api"; -import type { CronRecipe, CronRecipeField } from "@/lib/api"; +import type { AutomationBlueprint, AutomationBlueprintField } from "@/lib/api"; import { cn, themedBody } from "@/lib/utils"; -interface CronRecipesProps { +interface AutomationBlueprintsProps { profile: string; - /** Called after a recipe is instantiated so the parent can refresh its job list. */ + /** Called after a blueprint is instantiated so the parent can refresh its job list. */ onCreated?: () => void; } -/** Initial form values for a recipe = each field's default (or ""). */ -function initialValues(recipe: CronRecipe): Record { +/** Initial form values for a blueprint = each field's default (or ""). */ +function initialValues(blueprint: AutomationBlueprint): Record { const out: Record = {}; - for (const f of recipe.fields) out[f.name] = f.default ?? ""; + for (const f of blueprint.fields) out[f.name] = f.default ?? ""; return out; } @@ -31,7 +31,7 @@ function FieldInput({ value, onChange, }: { - field: CronRecipeField; + field: AutomationBlueprintField; value: string; onChange: (v: string) => void; }) { @@ -66,19 +66,19 @@ function FieldInput({ ); } -function RecipeCard({ - recipe, +function BlueprintCard({ + blueprint, profile, showToast, onCreated, }: { - recipe: CronRecipe; + blueprint: AutomationBlueprint; profile: string; showToast: (message: string, type: "error" | "success") => void; onCreated?: () => void; }) { const [open, setOpen] = useState(false); - const [values, setValues] = useState>(() => initialValues(recipe)); + const [values, setValues] = useState>(() => initialValues(blueprint)); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -86,11 +86,11 @@ function RecipeCard({ setSubmitting(true); setError(null); try { - const job = await api.instantiateCronRecipe({ recipe: recipe.key, values }, profile); + const job = await api.instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile); const when = job.schedule_display ? ` — ${job.schedule_display}` : ""; - showToast(`${recipe.title} scheduled${when}`, "success"); + showToast(`${blueprint.title} scheduled${when}`, "success"); setOpen(false); - setValues(initialValues(recipe)); + setValues(initialValues(blueprint)); onCreated?.(); } catch (e) { // 422 from the API carries the slot-level validation message. @@ -99,7 +99,7 @@ function RecipeCard({ } finally { setSubmitting(false); } - }, [recipe, values, profile, showToast, onCreated]); + }, [blueprint, values, profile, showToast, onCreated]); return ( @@ -108,11 +108,11 @@ function RecipeCard({
- {recipe.title} + {blueprint.title}
-

{recipe.description}

+

{blueprint.description}

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

Couldn't load recipes: {loadError}

; + return

Couldn't load blueprints: {loadError}

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

No cron recipes available.

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

No automation blueprints available.

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

{recipe.title}

- {recipe.scheduleHuman} +

{blueprint.title}

+ {blueprint.scheduleHuman}
-

{recipe.description}

+

{blueprint.description}

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

Couldn't load the recipe catalog: {error}

; + return

Couldn't load the blueprint catalog: {error}

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

Loading recipes…

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

Loading blueprints…

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

No cron recipes are available.

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

No automation blueprints are available.

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