diff --git a/.gitignore b/.gitignore index fa4d64049b7c..2935832db3be 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 +# 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 9abfc216e56b..bfa5e178d2f1 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://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. +// --------------------------------------------------------------------------- +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://blueprint/?slot=val -> host="blueprint", 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..f04ade8f80e0 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 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 !== 'blueprint' || !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 = `/blueprint ${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..4ae4c6f7029c 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. /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 self._secret_state = None self._secret_deadline = 0 self._spinner_text: str = "" # thinking spinner text for TUI @@ -7409,6 +7413,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 == "blueprint": + self._handle_blueprint_command(cmd_original) elif canonical == "curator": self._handle_curator_command(cmd_original) elif canonical == "kanban": @@ -12827,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. + # /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) + 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/blueprint_catalog.py b/cron/blueprint_catalog.py new file mode 100644 index 000000000000..b6cfc54576bb --- /dev/null +++ b/cron/blueprint_catalog.py @@ -0,0 +1,713 @@ +"""Automation Blueprints — parameterized automation templates 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 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[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 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/?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 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/scripts/classify_items.py b/cron/scripts/classify_items.py new file mode 100644 index 000000000000..ba0cd42d4b44 --- /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 -- 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": , "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..deacf1e13493 --- /dev/null +++ b/cron/suggestion_catalog.py @@ -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 diff --git a/cron/suggestions.py b/cron/suggestions.py new file mode 100644 index 000000000000..636a0335cc32 --- /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, ...). + * ``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 diff --git a/gateway/run.py b/gateway/run.py index 897cb85f6523..041a6efcd560 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7171,6 +7171,37 @@ 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 == "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 + # 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(_blueprint_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("blueprint ack send failed", exc_info=True) + try: + event.text = _blueprint_seed + except Exception: + return getattr(_blueprint_result, "text", "") or None + else: + return getattr(_blueprint_result, "text", "") or None + if canonical == "retry": return await self._handle_retry_command(event) @@ -9237,6 +9268,71 @@ 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, surface="gateway") + except Exception as e: + logger.debug("suggestions command failed: %s", e) + return f"Suggestions command failed: {e}" + + 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 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 blueprint job delivers back to this chat. + """ + 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.blueprint_cmd import handle_blueprint_command + + return handle_blueprint_command(args, origin=origin, surface="gateway") + except Exception as e: + logger.debug("blueprint command failed: %s", e) + from hermes_cli.blueprint_cmd import BlueprintCommandResult + + return BlueprintCommandResult(f"Cron blueprint command failed: {e}") + # ──────────────────────────────────────────────────────────────── # /goal — persistent cross-turn goals (Ralph-style loop) # ──────────────────────────────────────────────────────────────── diff --git a/hermes_cli/blueprint_cmd.py b/hermes_cli/blueprint_cmd.py new file mode 100644 index 000000000000..e4a3afbf3db9 --- /dev/null +++ b/hermes_cli/blueprint_cmd.py @@ -0,0 +1,318 @@ +"""Shared ``/blueprint`` command logic for CLI, TUI, and gateway. + +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_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: + /blueprint list the catalog + /blueprint name-match a blueprint, then SEED THE AGENT to + ask the user for each value conversationally + /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 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 +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. +""" + +from __future__ import annotations + +import difflib +import logging +import shlex +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + + +@dataclass +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 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. + """ + + 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 + 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 match_blueprint(query: str) -> Tuple[Optional[Any], List[Any]]: + """Resolve a free-typed blueprint name to a blueprint. + + Returns ``(blueprint, candidates)``: + * exact key or unique prefix / fuzzy match -> ``(blueprint, [])`` + * 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.blueprint_catalog import CATALOG, get_blueprint + + q = (query or "").strip().lower() + if not q: + return None, [] + + exact = get_blueprint(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_blueprint(close[0]), [] + if len(close) > 1: + return None, [get_blueprint(k) for k in close] + + return None, [] + + +def _humanize_schedule(blueprint) -> str: + from cron.blueprint_catalog import _humanize_schedule as _h + + try: + return _h(blueprint) + except Exception: + return "on a schedule" + + +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 blueprint's ``schedule_template`` and the + rendered prompt. Defaults are stated so the agent can offer them. + """ + from cron.blueprint_catalog import WEEKDAY_PRESETS + + lines: List[str] = [] + lines.append( + 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 blueprint.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: `{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): \"{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.blueprint_catalog import CATALOG + + 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: `/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 blueprints — which one?\n"] + for r in candidates: + lines.append(f" • {r.key} — {r.title}") + lines.append("\nRun `/blueprint ` with one of the names above.") + return "\n".join(lines) + + +def _fmt_no_match(query: str) -> str: + 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 automation blueprint matches '{query}'." + if close: + msg += " Did you mean: " + ", ".join(close) + "?" + msg += " Run /blueprint to see the catalog." + 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_blueprint_command( + args: str, + *, + origin: Optional[Dict[str, Any]] = None, + surface: str = "cli", +) -> BlueprintCommandResult: + """Dispatch a ``/blueprint`` invocation. + + 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 ``/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.blueprint_catalog import fill_blueprint, BlueprintFillError + except Exception as e: # pragma: no cover - import guard + logger.debug("blueprint catalog import failed: %s", e) + return BlueprintCommandResult("Automation Blueprints are unavailable in this build.") + + try: + tokens = shlex.split(args or "") + except ValueError: + tokens = (args or "").split() + + # Bare -> list catalog. + if not tokens: + return BlueprintCommandResult(_fmt_catalog()) + + query = tokens[0] + values, _leftover = _parse_kv(tokens[1:]) + + blueprint, candidates = match_blueprint(query) + if blueprint is None: + if candidates: + 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_blueprint_seed(blueprint) + text = ( + f"Setting up '{blueprint.title}' ({_humanize_schedule(blueprint)}). " + "I'll ask you a couple of things…" + ) + return BlueprintCommandResult(text, agent_seed=seed) + + # ` slot=val …` -> fill + create directly (deterministic shortcut). + try: + 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: + from cron.jobs import create_job + + job = create_job(**spec) + except Exception as 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 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 ffb39d9e9569..b52c6de802e9 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1255,6 +1255,57 @@ 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_blueprint_command(self, cmd: str): + """Handle /blueprint — set up an automation from a blueprint template. + + 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``); ``/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. + """ + 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.blueprint_cmd import handle_blueprint_command + result = handle_blueprint_command(args) + except Exception as e: + self._console_print(f"Cron blueprint 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/commands.py b/hermes_cli/commands.py index aded4d41d81c..78461ca138e2 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -179,6 +179,11 @@ 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("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")), @@ -1025,6 +1030,19 @@ 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 ). +# 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: """Convert a command name to a valid Slack slash command name. @@ -1079,6 +1097,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/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..f1e4f83b2c78 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -691,6 +691,47 @@ 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") + # 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.blueprints import BlueprintError, blueprint_spec_for_installed, register_blueprint_suggestion + + try: + 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_blueprint_suggestion(spec) + if registered is not None: + c.print( + f"[bold cyan]Blueprint:[/] '{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" + ) + 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]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)." + ) + 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 - blueprint 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..2dfe6bf55486 --- /dev/null +++ b/hermes_cli/suggestions_cmd.py @@ -0,0 +1,153 @@ +"""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 blueprint 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, + 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. ``surface`` (``"cli"`` | + ``"gateway"``) picks the wording for follow-up hints — ``/cron`` only + exists on the CLI. + """ + 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") + 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 "") + + f". {manage}" + ) + + 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/hermes_cli/web_server.py b/hermes_cli/web_server.py index ad70a46491f0..40a5fc02eecc 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -6778,6 +6778,75 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None): return {"ok": True} +# --------------------------------------------------------------------------- +# 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/blueprint_catalog.py for the single source of truth. +# --------------------------------------------------------------------------- +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/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.blueprint_catalog import CATALOG, blueprint_catalog_entry + + 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 = 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 {"blueprints": entries} + except Exception as e: + _log.exception("GET /api/cron/blueprints failed") + raise HTTPException(status_code=500, detail=str(e)) + + +@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.blueprint_catalog import fill_blueprint, get_blueprint, BlueprintFillError + + blueprint = get_blueprint(body.blueprint) + if blueprint is None: + raise HTTPException(status_code=404, detail=f"Unknown blueprint: {body.blueprint}") + try: + 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 + # 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/blueprints/instantiate failed") + raise HTTPException(status_code=400, detail=str(e)) + + # --------------------------------------------------------------------------- # MCP server endpoints — list / add / remove / test. # 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_blueprint_catalog.py b/tests/cron/test_blueprint_catalog.py new file mode 100644 index 000000000000..a5470c81f8ca --- /dev/null +++ b/tests/cron/test_blueprint_catalog.py @@ -0,0 +1,245 @@ +"""Tests for Automation Blueprints — the parameterized automation template system. + +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. +""" + +import importlib +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from cron.blueprint_catalog import ( + CATALOG, + BlueprintFillError, + BlueprintSlot, + fill_blueprint, + get_blueprint, + blueprint_catalog_entry, + blueprint_deeplink, + blueprint_form_schema, + blueprint_slash_command, +) + + +class TestCatalog: + def test_catalog_nonempty_and_keyed(self): + assert len(CATALOG) >= 1 + for r in CATALOG: + assert get_blueprint(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): + BlueprintSlot(name="x", type="bogus", label="X") + + +class TestScheduleResolution: + def test_time_to_cron(self): + spec = fill_blueprint(get_blueprint("morning-brief"), {"time": "08:30"}) + assert spec["schedule"] == "30 8 * * *" + + def test_interval_schedule(self): + 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_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_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_blueprint(get_blueprint("morning-brief"), {}) + assert spec["schedule"] == "0 8 * * *" + + +class TestValidation: + def test_invalid_time_rejected(self): + 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(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_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(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. + # The hour-field step form must produce the cadence the user picked. + croniter = pytest.importorskip("croniter").croniter + from datetime import datetime + + 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 = { + (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_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_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 = 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 = 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 = blueprint_slash_command( + get_blueprint("custom-reminder"), {"what": "drink water", "time": "10:00"} + ) + assert '"drink water"' in cmd + + def test_deeplink_shape(self): + 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 = blueprint_catalog_entry(get_blueprint("morning-brief")) + assert entry["command"].startswith("/blueprint") + 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.blueprint_cmd import handle_blueprint_command + + 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.blueprint_cmd import handle_blueprint_command + + # `/blueprint ` (no inline slots) now seeds the agent to ask + # the user for each value conversationally instead of dumping fields. + 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 + # 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.blueprint_cmd import handle_blueprint_command, match_blueprint + + # prefix match + r, cands = match_blueprint("morning") + assert r is not None and r.key == "morning-brief" + # fuzzy / typo + r2, _ = match_blueprint("mornning-brief") + assert r2 is not None and r2.key == "morning-brief" + # a forgiving name still seeds the agent + res = handle_blueprint_command("morning") + assert res.agent_seed is not None + + def test_fill_creates_job(self, isolated_home): + from hermes_cli.blueprint_cmd import handle_blueprint_command + + 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() + 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_blueprint(self, isolated_home): + from hermes_cli.blueprint_cmd import handle_blueprint_command + + 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.blueprint_cmd import handle_blueprint_command + + 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 + + +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-automation-blueprints.py" + ) + 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) + 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/cron/test_suggestions.py b/tests/cron/test_suggestions.py new file mode 100644 index 000000000000..75ee7fe7a875 --- /dev/null +++ b/tests/cron/test_suggestions.py @@ -0,0 +1,198 @@ +"""Tests for the Suggested Cron Jobs feature. + +Covers the store (add/dedup/cap/accept/dismiss/latch), catalog seeding, the +blueprint->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") + # 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" + + +class TestBlueprintBridge: + def test_blueprint_registers_suggestion(self, store): + from tools.blueprints import BlueprintSpec, register_blueprint_suggestion + + spec = BlueprintSpec(skill_name="morning-brief", schedule="0 8 * * *", deliver="telegram") + with patch("cron.suggestions.add_suggestion", store.add_suggestion): + rec = register_blueprint_suggestion(spec) + assert rec is not None + assert rec["source"] == "blueprint" + assert rec["job_spec"]["skills"] == ["morning-brief"] + assert rec["job_spec"]["schedule"] == "0 8 * * *" + + def test_blueprint_to_job_spec_matches_create_blueprint_job(self): + from tools.blueprints import BlueprintSpec, blueprint_to_job_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" + + +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/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) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index dc92f7cefb4f..61c4aa466be4 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 + # --- Automation Blueprints --- + + def test_cron_blueprints_list(self): + resp = self.client.get("/api/cron/blueprints") + assert resp.status_code == 200 + blueprints = resp.json()["blueprints"] + assert len(blueprints) >= 1 + first = blueprints[0] + assert "fields" in first + assert first["command"].startswith("/blueprint") + assert first["appUrl"].startswith("hermes://") + + def test_blueprint_instantiate_creates_job(self): + resp = self.client.post( + "/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_blueprint_instantiate_unknown_404(self): + resp = self.client.post( + "/api/cron/blueprints/instantiate", + json={"blueprint": "does-not-exist", "values": {}}, + ) + assert resp.status_code == 404 + + def test_blueprint_instantiate_bad_value_422(self): + resp = self.client.post( + "/api/cron/blueprints/instantiate", + json={"blueprint": "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_blueprints.py b/tests/tools/test_blueprints.py new file mode 100644 index 000000000000..e23cfa69cfc0 --- /dev/null +++ b/tests/tools/test_blueprints.py @@ -0,0 +1,188 @@ +"""Tests for the blueprints layer (skill frontmatter <-> cron automation bridge). + +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. +""" + +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from tools.blueprints import ( + BlueprintError, + BlueprintSpec, + create_blueprint_job, + export_blueprint, + parse_blueprint, + blueprint_spec_for_installed, +) + + +BLUEPRINT_SKILL = """--- +name: morning-brief +description: Summarize unread email and calendar every morning. +version: 1.0.0 +metadata: + hermes: + tags: [blueprint, email] + blueprint: + 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-blueprint +description: Just a regular skill. +metadata: + hermes: + tags: [misc] +--- + +# Not a blueprint +""" + +MALFORMED_BLUEPRINT = """--- +name: broken +description: Blueprint with no schedule. +metadata: + hermes: + blueprint: + deliver: origin +--- + +# Broken +""" + + +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_blueprint(self): + assert parse_blueprint(PLAIN_SKILL) 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(BlueprintError): + parse_blueprint(MALFORMED_BLUEPRINT) + + 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" + ' blueprint:\n schedule: "every 1h"\n---\n\nbody' + ) + spec = parse_blueprint(skill) + assert spec is not None + assert spec.deliver == "origin" + + +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(BLUEPRINT_SKILL, encoding="utf-8") + + with patch("tools.skills_hub.SKILLS_DIR", skills_dir): + spec = blueprint_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 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-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 blueprint_spec_for_installed("not-a-blueprint") is None + + +class TestCreateBlueprintJob: + def test_bridges_to_create_job(self): + spec = parse_blueprint(BLUEPRINT_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_blueprint_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 TestExportBlueprint: + 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_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_blueprint_tag(self): + job = {"name": "x", "schedule_display": "every 2h", "skills": ["x"]} + md = export_blueprint(job, "body") + assert "blueprint" 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_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_blueprint(export_blueprint(job, "body")) + assert spec is not None + assert spec.schedule == "every 2h" diff --git a/tools/blueprints.py b/tools/blueprints.py new file mode 100644 index 000000000000..7e4c5591a088 --- /dev/null +++ b/tools/blueprints.py @@ -0,0 +1,325 @@ +"""Blueprints: shareable plain-language automations layered on skills + cron. + +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: + 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 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_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 blueprint +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__ = [ + "BlueprintSpec", + "parse_blueprint", + "blueprint_spec_for_installed", + "blueprint_to_job_spec", + "create_blueprint_job", + "register_blueprint_suggestion", + "export_blueprint", + "BlueprintError", +] + + +class BlueprintError(ValueError): + """Raised when a blueprint block is present but malformed.""" + + +@dataclass +class BlueprintSpec: + """Parsed ``metadata.hermes.blueprint`` 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("blueprint: frontmatter YAML parse failed: %s", e) + return None + return data if isinstance(data, dict) else None + + +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 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) + 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 + blueprint = hermes.get("blueprint") if isinstance(hermes, dict) else None + if blueprint is None: + return None + if not isinstance(blueprint, dict): + raise BlueprintError("metadata.hermes.blueprint must be a mapping") + + schedule = str(blueprint.get("schedule", "")).strip() + if not schedule: + raise BlueprintError("blueprint.schedule is required and must be non-empty") + + deliver = str(blueprint.get("deliver", "origin")).strip() or "origin" + prompt = blueprint.get("prompt") + if prompt is not None: + prompt = str(prompt) + 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 BlueprintError("blueprint.enabled_toolsets must be a list when present") + + return BlueprintSpec( + 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=blueprint, + ) + + +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 blueprint. + """ + 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_blueprint(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 blueprint_to_job_spec( + spec: BlueprintSpec, + *, + name: Optional[str] = None, +) -> Dict[str, Any]: + """Build the ``cron.jobs.create_job`` kwargs dict for a BlueprintSpec. + + 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"blueprint:{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_blueprint_job( + spec: BlueprintSpec, + *, + origin: Optional[Dict[str, Any]] = None, + name: Optional[str] = None, +) -> Dict[str, Any]: + """Create the cron job described by a BlueprintSpec via the existing cron API. + + 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 = blueprint_to_job_spec(spec, name=name) + if origin is not None: + job_spec["origin"] = origin + return create_job(**job_spec) + + +def register_blueprint_suggestion(spec: BlueprintSpec) -> Optional[Dict[str, Any]]: + """Turn an installed blueprint into a pending Suggested Cron Job. + + 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.). + """ + 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}' blueprint runs on schedule {spec.schedule}" + + (f", delivering to {spec.deliver}" if spec.deliver and spec.deliver != "origin" else "") + + "." + ), + source="blueprint", + job_spec=blueprint_to_job_spec(spec), + dedup_key=f"blueprint:{spec.skill_name}:{spec.schedule}", + ) + + +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_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 = 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-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 []) + + blueprint_block: Dict[str, Any] = {"schedule": schedule} + deliver = job.get("deliver") + if deliver and deliver != "origin": + blueprint_block["deliver"] = deliver + if job.get("prompt"): + blueprint_block["prompt"] = job["prompt"] + if job.get("no_agent"): + blueprint_block["no_agent"] = True + if job.get("model"): + blueprint_block["model"] = job["model"] + if job.get("provider"): + blueprint_block["provider"] = job["provider"] + if job.get("enabled_toolsets"): + blueprint_block["enabled_toolsets"] = job["enabled_toolsets"] + + description = ( + (body.strip().splitlines() or ["Shared automation blueprint."])[0][:200] + if body.strip() + else "Shared automation blueprint." + ) + + frontmatter = { + "name": name, + "description": description, + "version": "1.0.0", + "license": "MIT", + "metadata": { + "hermes": { + "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 blueprint." + 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": + # 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/components/AutomationBlueprints.tsx b/web/src/components/AutomationBlueprints.tsx new file mode 100644 index 000000000000..10d1270fa059 --- /dev/null +++ b/web/src/components/AutomationBlueprints.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 { AutomationBlueprint, AutomationBlueprintField } from "@/lib/api"; +import { cn, themedBody } from "@/lib/utils"; + +interface AutomationBlueprintsProps { + profile: string; + /** Called after a blueprint is instantiated so the parent can refresh its job list. */ + onCreated?: () => void; +} + +/** Initial form values for a blueprint = each field's default (or ""). */ +function initialValues(blueprint: AutomationBlueprint): Record { + const out: Record = {}; + for (const f of blueprint.fields) out[f.name] = f.default ?? ""; + return out; +} + +function FieldInput({ + field, + value, + onChange, +}: { + field: AutomationBlueprintField; + 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 BlueprintCard({ + blueprint, + profile, + showToast, + onCreated, +}: { + blueprint: AutomationBlueprint; + profile: string; + showToast: (message: string, type: "error" | "success") => void; + onCreated?: () => void; +}) { + const [open, setOpen] = useState(false); + const [values, setValues] = useState>(() => initialValues(blueprint)); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const submit = useCallback(async () => { + setSubmitting(true); + setError(null); + try { + const job = await api.instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile); + const when = job.schedule_display ? ` — ${job.schedule_display}` : ""; + showToast(`${blueprint.title} scheduled${when}`, "success"); + setOpen(false); + setValues(initialValues(blueprint)); + 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); + } + }, [blueprint, values, profile, showToast, onCreated]); + + return ( + + +
+
+
+ + {blueprint.title} +
+

{blueprint.description}

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

{f.help}

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

+ {error} +

+ ) : null} +
+ +
+
+ )} +
+
+ ); +} + +/** + * Automation Blueprints gallery — the form-where-there's-a-screen surface. Each blueprint + * card expands into an inline form (one field per typed slot); submitting POSTs + * to /api/cron/blueprints/instantiate which fills the blueprint and creates the job + * via the same create_job path as everything else. + */ +export function AutomationBlueprints({ profile, onCreated }: AutomationBlueprintsProps) { + const { toast, showToast } = useToast(); + const [blueprints, setBlueprints] = useState(null); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + let cancelled = false; + api + .getAutomationBlueprints() + .then((r) => { + if (!cancelled) setBlueprints(r.blueprints); + }) + .catch((e) => { + if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e)); + }); + return () => { + cancelled = true; + }; + }, []); + + if (loadError) { + return

Couldn't load blueprints: {loadError}

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

No automation blueprints available.

; + } + + return ( + <> + +
+ {blueprints.map((r) => ( + + ))} +
+ + ); +} + +export default AutomationBlueprints; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7349e8596d43..2be64723b2be 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" }), + // Automation Blueprints — parameterized automation templates + getAutomationBlueprints: () => + fetchJSON<{ blueprints: AutomationBlueprint[] }>("/api/cron/blueprints"), + instantiateAutomationBlueprint: ( + body: { blueprint: string; values: Record }, + profile = "default", + ) => + fetchJSON(`/api/cron/blueprints/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,29 @@ export interface CronDeliveryTarget { home_env_var: string | null; } +export interface AutomationBlueprintField { + name: string; + type: "time" | "enum" | "text" | "weekdays"; + label: string; + default: string | null; + options: string[]; + optional: boolean; + /** When false, options are suggestions — any value is accepted. */ + strict?: boolean; + help: string; +} + +export interface AutomationBlueprint { + key: string; + title: string; + description: string; + category: string; + tags: string[]; + fields: AutomationBlueprintField[]; + 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..d69af7cbaf88 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 { AutomationBlueprints } from "@/components/AutomationBlueprints"; 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" | "blueprints">("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" | "blueprints")} + options={[ + { value: "jobs", label: "Jobs" }, + { value: "blueprints", label: "Blueprints" }, + ]} + /> + + {view === "blueprints" && ( + + )} + + )} + {view === "jobs" && (

+ )}

diff --git a/website/docs/developer-guide/creating-skills.md b/website/docs/developer-guide/creating-skills.md index 50335901752e..25d023ed57c8 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" + 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 + 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`. +## Blueprints: skills that are also automations + +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: [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 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 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 +# → Blueprint: '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 +``` + +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 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 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 + +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 | +| `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 | + +```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 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/scripts/extract-automation-blueprints.py b/website/scripts/extract-automation-blueprints.py new file mode 100644 index 000000000000..f41ad7ab8552 --- /dev/null +++ b/website/scripts/extract-automation-blueprints.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Generate the Automation Blueprints catalog JSON for the docs site. + +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/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``. +""" + +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" / "automation-blueprints-index.json" + + +def build_index() -> list: + from cron.blueprint_catalog import CATALOG, blueprint_catalog_entry + + return [blueprint_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-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-automation-blueprints: wrote {len(index)} blueprints -> {OUTPUT}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/website/scripts/prebuild.mjs b/website/scripts/prebuild.mjs index 11f5e07521e7..b873a9b20883 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 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 = @@ -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) automation-blueprints-index.json — Automation Blueprints catalog page. Non-fatal; the page +// renders an empty state if the generator can't run. +runPython(cronBlueprintsScript, "extract-automation-blueprints.py"); diff --git a/website/sidebars.ts b/website/sidebars.ts index b6eccc27a41c..3dac24ba59f0 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -78,6 +78,7 @@ const sidebars: SidebarsConfig = { label: 'Automation', items: [ 'user-guide/features/cron', + '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/AutomationBlueprintsCatalog/index.tsx b/website/src/components/AutomationBlueprintsCatalog/index.tsx new file mode 100644 index 000000000000..7edeca2c705f --- /dev/null +++ b/website/src/components/AutomationBlueprintsCatalog/index.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from "react"; +import styles from "./styles.module.css"; + +interface BlueprintField { + name: string; + type: string; + label: string; + default: string | null; + options: string[]; + optional: boolean; + help: string; +} + +interface Blueprint { + key: string; + title: string; + description: string; + category: string; + tags: string[]; + fields: BlueprintField[]; + scheduleHuman: string; + command: string; + appUrl: string; +} + +const INDEX_URL = "/docs/api/automation-blueprints-index.json"; + +function CopyButton({ text }: { text: string }): JSX.Element { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function BlueprintCard({ blueprint }: { blueprint: Blueprint }): JSX.Element { + return ( +
+
+

{blueprint.title}

+ {blueprint.scheduleHuman} +
+

{blueprint.description}

+ +
+ {blueprint.tags.map((t) => ( + + {t} + + ))} +
+ +
+ {blueprint.command} + +
+ +
+ + Send to App ↗ + + + or paste the command into the CLI, TUI, or any messenger + +
+
+ ); +} + +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: Blueprint[]) => { + if (!cancelled) setBlueprints(data); + }) + .catch((e) => { + if (!cancelled) setError(String(e)); + }); + return () => { + cancelled = true; + }; + }, []); + + if (error) { + return

Couldn't load the blueprint catalog: {error}

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

Loading blueprints…

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

No automation blueprints are available.

; + } + + return ( +
+ {blueprints.map((r) => ( + + ))} +
+ ); +} diff --git a/website/src/components/AutomationBlueprintsCatalog/styles.module.css b/website/src/components/AutomationBlueprintsCatalog/styles.module.css new file mode 100644 index 000000000000..1da29d5b06d2 --- /dev/null +++ b/website/src/components/AutomationBlueprintsCatalog/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); +}