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:
teknium1
2026-06-11 10:49:47 -07:00
committed by Teknium
parent 4d6a133a9f
commit 9a09ea69fb
12 changed files with 1599 additions and 0 deletions
+3
View File
@@ -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")),
+14
View File
@@ -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": {
+27
View File
@@ -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:
+145
View File
@@ -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"
)