feat(cron): Suggested Cron Jobs — one surface for proposed automations
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.
This commit is contained in:
@@ -179,6 +179,9 @@ COMMAND_REGISTRY: list[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")),
|
||||
|
||||
@@ -1369,6 +1369,20 @@ DEFAULT_CONFIG = {
|
||||
"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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 <N|id> create the cron job for that suggestion
|
||||
/suggestions dismiss <N|id> 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 <number|id>"
|
||||
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 <number|id>"
|
||||
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"
|
||||
)
|
||||
Reference in New Issue
Block a user