forked from Zakaria/hermes-agent
Hermes-agent
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Cron job scheduling system for Hermes Agent.
|
||||
|
||||
This module provides scheduled task execution, allowing the agent to:
|
||||
- Run automated tasks on schedules (cron expressions, intervals, one-shot)
|
||||
- Self-schedule reminders and follow-up tasks
|
||||
- Execute tasks in isolated sessions (no prior context)
|
||||
|
||||
Cron jobs are executed automatically by the gateway daemon:
|
||||
hermes gateway install # Install as a user service
|
||||
sudo hermes gateway install --system # Linux servers: boot-time system service
|
||||
hermes gateway # Or run in foreground
|
||||
|
||||
The gateway ticks the scheduler every 60 seconds. A file lock prevents
|
||||
duplicate execution if multiple processes overlap.
|
||||
"""
|
||||
|
||||
from cron.jobs import (
|
||||
create_job,
|
||||
get_job,
|
||||
list_jobs,
|
||||
remove_job,
|
||||
update_job,
|
||||
pause_job,
|
||||
resume_job,
|
||||
trigger_job,
|
||||
JOBS_FILE,
|
||||
)
|
||||
from cron.scheduler import tick
|
||||
|
||||
__all__ = [
|
||||
"create_job",
|
||||
"get_job",
|
||||
"list_jobs",
|
||||
"remove_job",
|
||||
"update_job",
|
||||
"pause_job",
|
||||
"resume_job",
|
||||
"trigger_job",
|
||||
"tick",
|
||||
"JOBS_FILE",
|
||||
]
|
||||
@@ -0,0 +1,713 @@
|
||||
"""Automation Blueprints — parameterized automation blueprints with typed slots.
|
||||
|
||||
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 ``/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. ``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 blueprint carries a fixed recurrence
|
||||
in ``schedule_template`` and parameterizes only the human-friendly parts
|
||||
(time-of-day, weekday set). Blueprints 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__ = [
|
||||
"BlueprintSlot",
|
||||
"AutomationBlueprint",
|
||||
"CATALOG",
|
||||
"get_blueprint",
|
||||
"blueprint_form_schema",
|
||||
"blueprint_slash_command",
|
||||
"blueprint_deeplink",
|
||||
"blueprint_catalog_entry",
|
||||
"fill_blueprint",
|
||||
"BlueprintFillError",
|
||||
"WEEKDAY_PRESETS",
|
||||
]
|
||||
|
||||
|
||||
class BlueprintFillError(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 BlueprintSlot:
|
||||
"""A single fillable field on a blueprint."""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
label: str
|
||||
default: Any = None
|
||||
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:
|
||||
raise ValueError(f"unknown slot type {self.type!r} (slot {self.name})")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AutomationBlueprint:
|
||||
"""A parameterized automation blueprint."""
|
||||
|
||||
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[BlueprintSlot] = 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": BlueprintSlot( # noqa: E731 - concise factory
|
||||
name="time", type="time", label="What time?", default=default,
|
||||
help="24h local time, e.g. 08:00",
|
||||
)
|
||||
_DELIVER = BlueprintSlot(
|
||||
name="deliver", type="enum", label="Where to deliver?",
|
||||
default="origin", options=("origin", "local", "telegram", "discord", "email"),
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
CATALOG: List[AutomationBlueprint] = [
|
||||
AutomationBlueprint(
|
||||
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"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
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=[
|
||||
BlueprintSlot(
|
||||
name="interval_min", type="enum", label="How often?",
|
||||
default="30", options=("15", "30", "60"),
|
||||
help="minutes between checks",
|
||||
),
|
||||
BlueprintSlot(
|
||||
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"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
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"),
|
||||
BlueprintSlot(
|
||||
name="day", type="enum", label="Which day?",
|
||||
default="sunday",
|
||||
options=("sunday", "monday", "friday", "saturday"),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("weekly", "review"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
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"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
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=[
|
||||
BlueprintSlot(name="what", type="text", label="Remind me to…",
|
||||
default="take a break and stretch"),
|
||||
_TIME("14:00"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="everyday",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("reminder",),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
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"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
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=[
|
||||
BlueprintSlot(
|
||||
name="topic", type="text", label="What topic?",
|
||||
default="AI and technology",
|
||||
help="a subject, product, person, or search phrase",
|
||||
),
|
||||
_TIME("18:00"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="weekdays",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="count", type="enum", label="How many bullets?",
|
||||
default="5", options=("3", "5", "8"),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("digest", "research"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
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=[
|
||||
BlueprintSlot(
|
||||
name="what", type="text", label="What's due?",
|
||||
default="my streaming subscription renews soon",
|
||||
),
|
||||
_TIME("10:00"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="everyday",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("reminder", "finance"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
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=[
|
||||
BlueprintSlot(
|
||||
name="habit", type="text", label="Which habit?",
|
||||
default="20 minutes of reading",
|
||||
),
|
||||
_TIME("20:00"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="everyday",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("habit", "wellbeing"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="hydration-move",
|
||||
title="Hydration & movement nudge",
|
||||
description="A periodic nudge during the day to drink water, stand up, "
|
||||
"and stretch.",
|
||||
category="general",
|
||||
# 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 "
|
||||
"doesn't feel robotic. One short line."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="interval_hours", type="enum", label="How often?",
|
||||
default="1", options=("1", "2", "3"),
|
||||
help="hours between nudges",
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="start_hour", type="enum", label="Start hour",
|
||||
default="9", options=("7", "8", "9", "10"),
|
||||
help="first hour of the active window (24h)",
|
||||
),
|
||||
BlueprintSlot(
|
||||
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"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
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 blueprints "
|
||||
"simple and skimmable."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="diet", type="enum", label="Diet?",
|
||||
default="no restrictions",
|
||||
options=("no restrictions", "vegetarian", "vegan",
|
||||
"high-protein", "low-carb"),
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="meals", type="enum", label="Meals per day?",
|
||||
default="dinner only",
|
||||
options=("dinner only", "lunch and dinner", "all three"),
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="effort", type="enum", label="Cooking effort?",
|
||||
default="quick", options=("quick", "medium", "ambitious"),
|
||||
),
|
||||
_TIME("17:00"),
|
||||
BlueprintSlot(
|
||||
name="day", type="enum", label="Which day?",
|
||||
default="sunday",
|
||||
options=("sunday", "monday", "friday", "saturday"),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("weekly", "food"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
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=[
|
||||
BlueprintSlot(
|
||||
name="topic", type="text", label="Learn about…",
|
||||
default="Spanish vocabulary",
|
||||
),
|
||||
_TIME("08:30"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="weekdays",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("learning", "daily"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
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"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="everyday",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("wellbeing", "reflection"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
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=[
|
||||
BlueprintSlot(
|
||||
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_blueprint(key: str) -> Optional[AutomationBlueprint]:
|
||||
return _CATALOG_BY_KEY.get(key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Renderers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def blueprint_form_schema(blueprint: AutomationBlueprint) -> Dict[str, Any]:
|
||||
"""Emit the JSON a form renderer (dashboard / GUI) needs for this blueprint."""
|
||||
return {
|
||||
"key": blueprint.key,
|
||||
"title": blueprint.title,
|
||||
"description": blueprint.description,
|
||||
"category": blueprint.category,
|
||||
"tags": list(blueprint.tags),
|
||||
"fields": [
|
||||
{
|
||||
"name": s.name,
|
||||
"type": s.type,
|
||||
"label": s.label,
|
||||
"default": s.default,
|
||||
"options": list(s.options),
|
||||
"optional": s.optional,
|
||||
"strict": s.strict,
|
||||
"help": s.help,
|
||||
}
|
||||
for s in blueprint.slots
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def blueprint_slash_command(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Build the flattened ``/blueprint <key> 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"/blueprint {blueprint.key}"]
|
||||
for s in blueprint.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 blueprint_deeplink(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Build the ``hermes://blueprint/<key>?slot=val`` deep-link URL."""
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
values = values or {}
|
||||
query = {}
|
||||
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://blueprint/{quote(blueprint.key)}{qs}"
|
||||
|
||||
|
||||
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 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 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 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 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}"
|
||||
return f"at {when}" if when else "on a schedule"
|
||||
if when:
|
||||
return f"daily at {when}"
|
||||
return "on a schedule"
|
||||
|
||||
|
||||
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 {
|
||||
**blueprint_form_schema(blueprint),
|
||||
"schedule": blueprint.schedule_template,
|
||||
"scheduleHuman": _humanize_schedule(blueprint),
|
||||
"command": blueprint_slash_command(blueprint),
|
||||
"appUrl": blueprint_deeplink(blueprint),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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(blueprint: AutomationBlueprint, values: Dict[str, Any]) -> str:
|
||||
"""Fill the schedule_template placeholders from resolved slot values."""
|
||||
sched = blueprint.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 BlueprintFillError("a time is required")
|
||||
m = _TIME_RE.match(str(time_val).strip())
|
||||
if not m:
|
||||
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)))
|
||||
|
||||
# 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 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 BlueprintFillError(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 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_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])
|
||||
|
||||
try:
|
||||
return sched.format(**repl)
|
||||
except KeyError as e: # pragma: no cover - template/slot mismatch is a dev error
|
||||
raise BlueprintFillError(f"schedule template missing value for {e}") from e
|
||||
|
||||
|
||||
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 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 blueprint.slots}
|
||||
unknown = sorted(set(values) - known)
|
||||
if unknown:
|
||||
raise BlueprintFillError(
|
||||
f"unknown slot{'s' if len(unknown) > 1 else ''}: "
|
||||
f"{', '.join(unknown)} — valid: {', '.join(s.name for s in blueprint.slots)}"
|
||||
)
|
||||
resolved: Dict[str, Any] = {}
|
||||
for s in blueprint.slots:
|
||||
raw = values.get(s.name, s.default)
|
||||
if raw in (None, ""):
|
||||
if s.optional:
|
||||
continue
|
||||
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 BlueprintFillError(
|
||||
f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}"
|
||||
)
|
||||
resolved[s.name] = raw
|
||||
|
||||
schedule = _resolve_schedule(blueprint, resolved)
|
||||
|
||||
# Render the prompt with whatever slots it references.
|
||||
try:
|
||||
prompt = blueprint.prompt_template.format(**resolved)
|
||||
except KeyError as e:
|
||||
raise BlueprintFillError(f"blueprint prompt missing value for {e}") from e
|
||||
|
||||
spec: Dict[str, Any] = {
|
||||
"prompt": prompt,
|
||||
"schedule": schedule,
|
||||
"name": blueprint.title,
|
||||
"deliver": resolved.get("deliver", blueprint.deliver_default),
|
||||
}
|
||||
if blueprint.skills:
|
||||
spec["skills"] = list(blueprint.skills)
|
||||
if origin is not None:
|
||||
spec["origin"] = origin
|
||||
return spec
|
||||
+1212
File diff suppressed because it is too large
Load Diff
+2213
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
"""Scripts shipped with the cron subsystem (runnable via ``python3 -m cron.scripts.<name>``)."""
|
||||
@@ -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 -- 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
|
||||
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": <int>, "score": <int 0-10>, "reason": "<short>"}]. '
|
||||
"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())
|
||||
@@ -0,0 +1,154 @@
|
||||
"""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 "
|
||||
"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."
|
||||
),
|
||||
"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
|
||||
@@ -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, ...).
|
||||
* ``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.
|
||||
* ``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", "blueprint", "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
|
||||
Reference in New Issue
Block a user