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.
153 lines
6.1 KiB
Python
153 lines
6.1 KiB
Python
"""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
|