Compare commits

...
Author SHA1 Message Date
Jeff 72290f0809 fix(desktop): use served dashboard token for websocket auth
(cherry picked from commit f8209f91d3)
2026-06-11 17:52:13 -05:00
brooklyn!andAJ b1fe2107d6 fix(desktop): keep named-profile desktop backends per-profile (#44510)
Desktop spawns its dashboard backend with `--profile <name>` and
`HERMES_DESKTOP=1`. cmd_dashboard's unified-launch routing treats any
named profile as a request for the shared machine dashboard: it re-execs
as the default profile (dropping HERMES_HOME) or, when one is already
listening, prints "Machine dashboard already running ... Managing profile
'<name>'" and exits 0. Either way the desktop-spawned child exits before
the app sees a ready backend, so Desktop retries forever — the Windows
named-profile boot loop in the post-mortem.

Skip the machine-dashboard reroute when HERMES_DESKTOP=1 so desktop pool
backends stay per-profile (which is what the pool expects). Carved out of
#44478.

Co-authored-by: AJ <yspdev@gmail.com>
2026-06-11 22:47:28 +00:00
brooklyn!andAJ 73969771a5 fix(desktop): discover MCP tools for dashboard /api/ws backends (#44512)
The desktop chat surface talks to the dashboard's in-process /api/ws
gateway, which builds agents through tui_gateway.server._make_agent. That
path only snapshots the existing tool registry — MCP discovery is started
by tui_gateway/entry.py (the stdio TUI), which the dashboard process never
runs. So a profile's configured MCP servers never connect under the
desktop app and sessions show no MCP tools.

Start a shared background MCP discovery thread at dashboard startup (via
hermes_cli.mcp_startup, bounded so a slow/dead server can't block boot),
and have _make_agent briefly join that thread in addition to the existing
entry-owned TUI thread before snapshotting tools.

Carved out of #44478.

Co-authored-by: AJ <yspdev@gmail.com>
2026-06-11 22:45:45 +00:00
Austin PickettandCursor 2ee69d0579 fix(skills): let ClawHub index build walk past the 12s browse budget (#44500)
The deploy-site skills index crawl was capped at ~3k ClawHub entries
because CATALOG_WALK_BUDGET_SECONDS applied to max_items=0 walks too.
Only enforce the wall-clock budget for bounded browse requests and pass
limit=0 from build_skills_index so CI walks the full catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 18:03:11 -04:00
Austin PickettandCursor 021ed69141 docs: finish Automation Blueprints terminology rebrand (#44470)
* docs: finish Automation Blueprints terminology rebrand

Replace leftover "Automation Templates" wording from the Cron Recipes
rebrand, rename the copy-paste cookbook guide to Automation Recipes, and
point the marketing gallery link at the blueprints catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: use Automation Blueprints instead of Recipes in guide

Rename the cookbook guide from automation-recipes to
automation-blueprints so sidebar and copy match the product term.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: rename automation-blueprints-catalog to automation-blueprints

Drop the -catalog suffix from the reference page slug and title, and
move the copy-paste cookbook to automation-blueprint-examples so the
main Automation Blueprints doc is unambiguous.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Revert "docs: rename automation-blueprints-catalog to automation-blueprints"

This reverts commit 605f1eeab5.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 17:22:22 -04:00
Teknium 6c752ca3a5 refactor(agent): tighten SUMMARY_PREFIX wording and fix stale doc references
Legibility pass on the consolidated prefix: collapse the topic-overlap rule
from three overlapping sentences into one WINS sentence + one discard/no-wrap-up
sentence (same constraints, less dilution), fix the module docstring to
describe the headings that actually shipped, and correct the #10896 comment's
heading name (Historical Pending User Asks).
2026-06-11 13:57:13 -07:00
Teknium acb2954d82 fix(agent): freeze carveout-era SUMMARY_PREFIX for renormalization
The prompt consolidation above retires the carveout-era prefix. Without a
frozen copy in _HISTORICAL_SUMMARY_PREFIXES, summaries persisted by
pre-upgrade builds would lose detection (_is_context_summary_content) and
renormalization (_strip_summary_prefix) — the exact regression class the
tuple exists to prevent. Adds contract tests covering every frozen prefix.

Refs #41607 #38364 #42812
2026-06-11 13:57:13 -07:00
kyssta-exe 8f8cad7ec5 fix(agent): strengthen compression preamble against stale task execution (#41607) 2026-06-11 13:57:13 -07:00
konsisumer d5e2fbf244 fix(agent): frame compaction handoff sections as historical context 2026-06-11 13:57:13 -07:00
brooklyn! 484f484c25 fix(desktop): carve sidebar nav rows out of the titlebar drag region (#44453)
A WSL2 user reported the top two left-sidebar items being unclickable
while the rest of the UI works. That symptom shape matches an
-webkit-app-region:drag hit-test band eating clicks, not GPU/compositing:
the shell's titlebar drag strips (app-shell.tsx) span the top 34px and
the nav group clears them by only 6px, and drag regions win hit-testing
over DOM regardless of pointer-events. Linux WCO (Electron >=32) is the
newest implementation and has known region quirks (electron#43030).

Apply the same no-drag carve-out the codebase already uses for sticky
user bubbles (USER_BUBBLE_BASE_CLASS in thread.tsx) to the sidebar nav
buttons. Harmless on every platform: the rows were never meant to be
draggable surface.
2026-06-11 15:10:09 -05:00
teknium1 114e265737 fix(plugins): don't cache a failed discovery sweep as discovered
Root-cause hardening for the stranded-empty-registry failure behind
'No web search/extract provider configured': discover_and_load() set
_discovered=True before scanning, so a sweep that raised partway was
swallowed by callers as a warning and every later call early-returned
against an empty registry for the process lifetime. The flag now acts
only as a re-entrancy guard and is reset when the sweep raises, so the
next call retries discovery.
2026-06-11 12:56:44 -07:00
xxxigm 32a73010bb test(web): cover keyless default surviving a failed plugin sweep
Pins the invariant that _ensure_web_plugins_loaded registers the keyless
Parallel default (and the wider bundled set) even when the general plugin
discovery raises, that the direct-registration fallback honors plugins.disabled,
and that it stays a no-op on the healthy path.
2026-06-11 12:56:44 -07:00
xxxigm 93764b9303 fix(web): guarantee the keyless web default registers even if discovery doesn't
web_search/web_extract are documented to work with zero setup via the bundled
keyless Parallel free-MCP backend, but that only holds when the bundled
plugins/web/* providers are registered. The dispatch relied entirely on the
general plugin sweep to do that; when the sweep finishes without registering
them (its exception swallowed as a warning, a packaged layout where it ran
before the bundled tree was importable, or a stale empty-discovery cache), the
registry is empty and BOTH tools dead-end on "No web {search,extract} provider
configured" — despite needing no setup at all.

_ensure_web_plugins_loaded now verifies the keyless default landed after the
sweep and, if not, registers the bundled web providers directly against the
registry. Idempotent, a no-op on the healthy path (one dict lookup), and honors
an explicit plugins.disabled entry.
2026-06-11 12:56:44 -07:00
c3464ecf45 fix(discord): recover from runtime gateway task exits (#44383)
* fix(discord): recover from runtime gateway task exits

Salvaged from #39416 (AMEOBIUS) — cherry-picked only the task-exit
recovery; the original PR was 1081 commits behind with 28 unrelated
commits.

A post-ready discord.py WebSocket crash left the gateway split-brained:
producers stayed active while Discord stopped responding. After this fix
the adapter calls _set_fatal_error(retryable=True) + _notify_fatal_error()
so the existing GatewayRunner reconnect watcher replaces the dead adapter.

Also adds _wait_for_ready_or_bot_exit() so startup failures (SOCKS/proxy
errors, invalid tokens) surface fast instead of burning the full ready
timeout. Because connect() no longer waits via asyncio.wait_for on that
path, test_connect_releases_token_lock_on_timeout is updated to trigger
the timeout through the new helper (same lock-release contract).

3 tests pass (2 new runtime-failure tests + the updated timeout test);
test_discord_connect.py and test_discord_slash_commands.py green.

Co-Authored-By: ameobius <ameobius@local.host>

* fix(test): patch _wait_for_ready_or_bot_exit in timeout cancel test

connect() no longer uses asyncio.wait_for for the ready handshake, so
test_connect_timeout_cancels_bot_task was hanging for 30s in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: ameobius <ameobius@local.host>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:39:01 -04:00
ethernet e080365a7a fix(tui): new weird typeerror 2026-06-11 15:36:39 -04:00
ethernet 5e5308d34d fix(node): fix @types/node version
TODO lock to a specific node/npm version.
this is a fix for a diff between 10 and 11.
2026-06-11 15:36:39 -04:00
teknium1 08b1c44a53 fix(discord): extend bot-task cancellation to connect()'s generic exception branch
Follow-up to #44389: the generic 'except Exception' branch in connect()
had the same orphaned-task hazard as the timeout branch. Extract the
cancel-and-await logic into _cancel_bot_task() and call it from all
three sites (timeout branch, exception branch, disconnect()).

Also adds deaneeth to AUTHOR_MAP.
2026-06-11 12:09:18 -07:00
Dineth Hettiarachchi 020ef76cf1 fix(discord): cancel _bot_task on connect() timeout to prevent zombie client
When connect() times out waiting for the Discord ready event, the background
asyncio.Task running client.start() was not cancelled. discord.py's internal
reconnect loop can ignore client.close() while a WebSocket handshake is in
flight, so the orphaned task eventually completes and fires on_ready.

A later successful reconnect then leaves two live Discord clients in the same
process — each with its own on_message handler and MessageDeduplicator instance
— so every @mention creates two threads because the per-adapter dedup caches
cannot catch cross-client duplicates.

Fix: explicitly cancel and await _bot_task in two places:
1. The asyncio.TimeoutError handler inside connect() — catches the case where
   the adapter's own inner wait_for fires before the gateway's outer timeout.
2. The start of disconnect() — the load-bearing path, always reached via
   _dispose_unused_adapter regardless of which timeout fired first.

Root cause confirmed from production logs: a Jun 8 network outage caused three
consecutive connect() timeouts. The first attempt's bot_task completed its
handshake 4 minutes later ("Connected as") with no preceding watcher line,
then the watcher's real reconnect also connected 90 seconds after that. The two
clients ran continuously for 41+ hours, confirmed by the same user message
appearing as two separate inbound events in two different thread IDs 357ms apart.

Regression tests added to tests/gateway/test_discord_connect.py:
- test_connect_timeout_cancels_bot_task: simulates a connect() timeout with a
  NeverReadyBot and asserts _bot_task is None afterward
- test_disconnect_cancels_running_bot_task: injects a live zombie task, calls
  disconnect(), and asserts the task is cancelled and the attribute cleared
2026-06-11 12:09:18 -07:00
Teknium 13650ab7f8 fix(gateway): audio attachment note no longer steers the agent into punting
Sibling site of the PDF/DOCX note fixed in PR #44175: the audio file
attachment context note led with "Ask the user what they'd like you to
do with it", steering the model into asking instead of transcribing.
Rewritten to instruct the agent to transcribe/process the file itself
when the request involves its content, only asking when intent is
genuinely unclear. Contract assertion added to the existing audio
attachment note test.
2026-06-11 11:58:19 -07:00
xxxigm 4e9be3ee32 test(gateway): cover document context note for PDF/DOCX vs text
Pin the contract for _build_document_context_note: text documents confirm the
inlined content and record the path; binary documents (PDF/DOCX/XLSX/octet-
stream) tell the agent to extract the text itself and never instruct it to ask
the user to paste the contents.
2026-06-11 11:58:19 -07:00
xxxigm e7ae145ac4 fix(gateway): guide the agent to read attached PDF/DOCX instead of punting
When a user attached a binary document (PDF, DOCX, XLSX, …) in chat, the
context note prepended to the turn said "Ask the user what they'd like you to
do with it." That steered the model into asking the user to paste the
contents rather than extracting the text it is fully capable of reading — so
attached PDFs/DOCX appeared "unreadable" to the agent.

Rewrite the binary-document note to tell the agent the file is a non-text
format saved at the given path and to extract its text itself (e.g. via the
terminal tool or the ocr-and-documents skill) before answering. Text
documents (whose content is already inlined by the platform adapter) keep
their existing note. The note construction is pulled into a small
`_build_document_context_note` helper so it is unit-testable.
2026-06-11 11:58:19 -07:00
Austin PickettandCursor ce99a81123 fix(dashboard): suppress unicode-animations postinstall during npm ci
Set CI=1 in _run_npm_install_deterministic so the package's /dev/tty
postinstall demo is skipped during hermes dashboard web UI builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 11:49:08 -07:00
xxxigmandBrooklyn Nicholson 743c55efa3 fix(desktop): stop file tree throwing "Cannot have two HTML5 backends" on remount (#43541)
* fix(desktop): stop file tree throwing "two HTML5 backends" on remount

The Agent Workspace file tree (react-arborist) shows a permanent "TREE ERROR"
with `[error-boundary:file-tree] Cannot have two HTML5 backends at the same
time.` react-arborist mounts its own react-dnd DndProvider + HTML5Backend per
<Tree>. react-dnd v14 keeps that manager on a global, ref-counted singleton
context and nulls it when the count reaches 0. The tree is keyed on
`${cwd}:${collapseNonce}`, so changing folder / collapsing forces a fresh
<Tree>; during the remount the singleton can be torn down and recreated while
the previous HTML5Backend still owns `window.__isReactDndHtml5Backend`, so the
new backend's setup() throws. The error boundary then sticks, because "Try
again" just remounts into the same race.

Pass arborist a stable, app-lifetime `dndManager` (new getFileTreeDndManager
singleton) so it reuses one backend for the life of the app and never
double-claims the window flag. Drag/drop is already disabled on this tree;
this only changes how the (unused) dnd backend is provisioned.

Promotes dnd-core and react-dnd-html5-backend to explicit deps (already present
transitively via react-arborist's react-dnd 14.x line, so they dedupe to one
instance).

* fix(nix): bump npmDepsHash for desktop dnd deps

Adding dnd-core / react-dnd-html5-backend changed the workspace
package-lock.json, so the single workspace-root npmDepsHash in
nix/lib.nix was stale and the nix build failed. Regenerate it
(hash from the failing nix CI job's 'got:' value).

* fix(nix): update npmDepsHash for merged lockfile

After merging main, the workspace lockfile combined main's dep
changes with the desktop dnd additions, so the npmDepsHash needed
recomputing again. Hash from the nix lockfile-check job.

* fix(nix): use fetchNpmDeps hash for desktop dnd lockfile

prefetch-npm-deps reported sha256-lVnybH9RE/... but fetchNpmDeps
wants sha256-mYgKXE/FL4hnkrEvpVv+ULM/oeyIfO2AM9Ol8OrfWm0= for the
merged workspace lockfile. Use the nix build 'got:' hash so CI passes.

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-06-11 11:47:34 -07:00
liuhao1024 93a2f680fd fix(desktop): preserve explicit hide-all choice in model visibility dialog (#43496)
When a user toggles off the last visible model for a provider group, the
effectiveVisibleKeys() function treated the missing provider prefix as
'never customized' and re-added the default models on the next render,
causing all models to snap back to enabled.

Fix: store a sentinel key (e.g. 'provider::') when the last model for a
provider is toggled off. The sentinel distinguishes 'user hid everything'
from 'user never customized', preventing the default-fallback path from
re-adding models the user explicitly chose to hide.

Fixes #43485
2026-06-11 13:27:38 -05:00
brooklyn! 8505e9d669 fix(desktop): disable spellcheck on composer inputs (#44415)
Turn off browser spellcheck, autocorrect, and autocomplete on the main chat composer and message-edit composer so code, paths, and slash commands are not flagged or altered.
2026-06-11 18:03:23 +00:00
brooklyn! a4f179c509 fix(agent): steer GPT/Codex family to V4A for single-file edits too (#44411)
The coding-posture brief told GPT/Codex models to use patch mode='patch'
(V4A) for structured/multi-file changes but mode='replace' "for a single
small swap". That second nudge points those models at a format their
first-party harness never taught them.

Verified against openai/codex (current main): apply_patch is the ONLY file
editor in codex-rs — zero occurrences of str_replace/old_string anywhere in
the repo; the grammar (core/src/tools/handlers/apply_patch.lark) is exactly
the V4A dialect our patch_parser implements; the shipped model prompts
(gpt_5_codex, gpt-5.2-codex, gpt-5.1-codex-max + instruction templates)
explicitly say to use apply_patch "for single file edits"; and the tool is
gated per model via ModelInfo.apply_patch_tool_type, i.e. OpenAI ships
V4A-for-everything as model metadata.

The GPT-family line now steers to mode='patch' for all edits, single-file
included. The replace-family line (Claude + open-weight) is unchanged —
Claude Code's FileEdit is old_string/new_string/replace_all exact string
replacement (confirmed from Anthropic's shipped sdk-tools.d.ts, the only
file editor in its tool union), matching our mode='replace'.
2026-06-11 17:52:52 +00:00
Teknium cb29e8a82e refactor(cron): rebrand Cron Recipes -> Automation Blueprints
Product rename across every surface: module/file names (blueprint_catalog,
tools/blueprints, blueprint_cmd), slash command /cron-recipe -> /blueprint
(alias /bp), dashboard API /api/cron/blueprints, desktop deep-link
hermes://blueprint/<key>, docs catalog page + extract script, and the
skill frontmatter block metadata.hermes.blueprint. No behavior change.
2026-06-11 10:49:47 -07:00
Teknium 3c489fda81 fix(commands): unpin /reset from Slack priority aliases — registry hit the 50-cap
CI tests the PR merged with current main, where the new /memory canonical
command filled Slack's 50-slash cap: with btw/bg/reset all pinned ahead of
canonicals, the last canonical (/debug) got clamped and the Telegram-parity
test failed. Canonical commands must win slots over alias spellings — /new
keeps its native slot and 'reset' stays reachable via /hermes reset.

Also updates test_includes_aliases_as_first_class_slashes to assert the
pinned-alias contract (_SLACK_PRIORITY_ALIASES survive) instead of a
specific unpinned alias's survival, which was the same change-detector
pattern the docstring already warned about.
2026-06-11 10:49:47 -07:00
Teknium e8b757845d fix(cron-recipes): pre-release hardening — honest cadences, strict slot names, surface-aware UX
Review fixes for the Cron Recipes stack before release:

- hydration-move: */90 in the cron minute field silently wraps to hourly
  (croniter-verified) — 90/120-minute options never fired at their stated
  cadence. Replaced with an hour-field step (0 9-17/2 * * 1-5) and an
  interval_hours slot whose options (1/2/3h) all fire as labeled.
- fill_recipe: reject unknown slot names. A typo'd 'tiem=07:15' used to
  silently create the job at the 08:00 default; now it 422s on the dashboard
  form and errors on the slash/deep-link paths with the valid slot list.
- deliver slot: non-strict enum (options are suggestions, scheduler
  validates downstream) so slack/whatsapp/etc. users aren't locked out;
  GET /api/cron/recipes rewrites its options from cron_delivery_targets()
  so the dashboard form only offers configured platforms; help text no
  longer claims dashboard-created jobs deliver to 'the chat you set this
  up from' (the endpoint strips origin — they go to the home channel).
- gateway: success/accept messages no longer point at /cron (cli_only);
  surface-aware hint instead. Conversational fill now sends the
  'Setting up X — I'll ask you a couple of things…' ack before the agent
  turn, matching the CLI experience.
- important-mail catalog entry: reference the urgency classifier by module
  path (python3 -m cron.scripts.classify_items) instead of baking an
  absolute host path into the job prompt — stale after relocation and
  nonexistent on remote terminal backends. cron/scripts is now a real
  package and ships in the wheel (pyproject packages.find).
- export_recipe: interval schedules round-trip again — parse_schedule
  stores 'minutes' but the renderer only read 'seconds', so every interval
  job exported as the silent '0 9 * * *' fallback.
- skills_hub install: say so when a recipe suggestion is dropped
  (latched dedup or pending cap) instead of printing nothing.

Targeted tests: 58 cron/recipe + 261 web_server pass; E2E-validated all
14 recipes fill+parse, hydration cadences via croniter, typo rejection on
slash + endpoint paths, surface-aware hints, and interval export round-trip.
2026-06-11 10:49:47 -07:00
teknium1 e976faac7a feat(cron-recipes): /cron-recipe <name> seeds a conversational fill
Reworks the chat-line UX: pick a recipe by name and the agent asks you for
what it needs, one question at a time, instead of forcing you to hand-type a
slot=val command line.

- /cron-recipe                  -> lists the catalog
- /cron-recipe <name>           -> forgiving name match (exact/prefix/substring/
                                   fuzzy; ambiguous lists candidates), then seeds
                                   the agent with a natural-language fill request
                                   built from the recipe's typed slots + schedule
                                   and prompt templates. The agent asks for each
                                   value one at a time and calls the EXISTING
                                   cronjob tool. No new tool.
- /cron-recipe <name> slot=val  -> unchanged deterministic path (fill_recipe ->
                                   create_job) for the dashboard/docs/power user.

Mechanism (no new plumbing, invariant-safe — the seed enters as a normal user
turn, never a synthetic injection):
- shared handler returns RecipeCommandResult{text, agent_seed}; match_recipe()
  and build_recipe_seed() are the new shared pieces.
- gateway: dispatch rewrites event.text to the seed and falls through to the
  agent (the same pattern /steer uses).
- CLI: handler sets a one-shot self._pending_agent_seed; the interactive loop
  consumes it right after process_command() and runs it as the next turn.

The typed-slot schema stays the single source of truth (still validates the
form/inline path via fill_recipe); the agent path just renders those slots into
the questions to ask. Docs updated to lead with the name-then-ask flow.
2026-06-11 10:49:47 -07:00
teknium1 1593ca5406 feat(cron): Cron Recipes — parameterized automation templates across every surface
A 'recipe' is a one-place definition of an automation that every surface
renders natively. The slot schema (cron/recipe_catalog.py) is the single
source of truth; four renderers consume it, and all paths end at the same
cron.jobs.create_job — no second job engine.

Form where there's a screen, conversation where there's a chat line:
- Dashboard / GUI app: a Recipes sub-tab on the Cron page renders each
  recipe's typed slots as a form (time-picker, enum dropdown, free-text);
  submit POSTs /api/cron/recipes/instantiate which fills + creates the job.
- CLI / TUI / messengers: /cron-recipe lists the catalog, shows a recipe's
  fields, or fills + creates from a pasted 'key slot=val' command. The shared
  handler (hermes_cli/cron_recipe_cmd.py) names any missing/invalid slot so
  the agent can ask a targeted follow-up.
- Docs: a generated Cron Recipes catalog page (website, .mdx + React cards)
  shows each recipe with a copy-paste command and a 'Send to App' button.
- Desktop: a hermes:// URL scheme (Electron single-instance lock +
  setAsDefaultProtocolClient + open-url/second-instance) routes
  hermes://cron-recipe/<key>?slot=val into the chat composer pre-filled.

Typed slots (time/enum/text/weekdays) with defaults: users never type raw
cron — recipes parameterize time-of-day and weekday sets and translate to
cron expressions; a free-text 'schedule' slot is the full-flexibility escape
hatch. Consent-first throughout: nothing schedules without an explicit submit
or send.

Core:
- cron/recipe_catalog.py — CronRecipe + RecipeSlot, 5 curated recipes,
  recipe_form_schema / recipe_slash_command / recipe_deeplink /
  recipe_catalog_entry renderers, fill_recipe (validate + translate to
  create_job kwargs).
- hermes_cli/cron_recipe_cmd.py — shared /cron-recipe handler (CLI + TUI +
  gateway never drift). CommandDef + dispatch in commands.py / cli.py /
  gateway/run.py.

Dashboard: GET /api/cron/recipes + POST /api/cron/recipes/instantiate
(web_server.py), CronRecipes.tsx gallery+form, Segmented sub-tab on CronPage,
api.ts methods + types.

Desktop: hermes:// scheme end to end (main.cjs deep-link router + ready-queue,
preload onDeepLink/signalDeepLinkReady, global.d.ts types, desktop-controller
composer prefill, electron-builder protocols key).

Docs: extract-cron-recipes.py generator wired into prebuild.mjs,
cron-recipes-catalog.mdx + CronRecipesCatalog React component, sidebar entry.
Generated index json gitignored like skills.json.

Tests: 23 core (catalog/slots/schedule-resolution/validation/renderers/command
handler/generator) + 5 web_server endpoint tests. E2E verified end to end:
slot fill -> create_job -> persisted job with correct schedule/deliver/origin.
2026-06-11 10:49:47 -07:00
teknium1 9a09ea69fb feat(cron): Suggested Cron Jobs — one surface for proposed automations
Hermes can propose automations and let the user accept them with one tap
via /suggestions, instead of making them assemble cron jobs by hand. Every
proposal — wherever it originates — flows through one surface.

Sources (the 'where suggestions come from'):
- catalog: curated starter automations (daily briefing, important-mail
  monitor, weekly review, workday-start reminder) via /suggestions catalog
- recipe: installing a skill that carries a metadata.hermes.recipe block
  registers a suggestion instead of auto-scheduling
- usage / integration: reserved for the background-review detector and
  account-connect triggers (sources defined; emitters land next)

Pieces:
- cron/suggestions.py — the store. add/list/accept/dismiss, dedup+latch by
  key (dismissed proposals never re-offered), pending cap so it can't become
  a nag wall. Accepting calls the existing cron.jobs.create_job — there is
  NO second job engine. Mirrors jobs.py storage (atomic writes, lock, 0600).
- cron/suggestion_catalog.py — the curated set. The important-mail monitor
  entry is where the old proactive-monitor poll->classify->surface engine
  lives now (cron/scripts/classify_items.py + the 'monitor' aux task), as ONE
  catalog automation rather than a standalone feature.
- tools/recipes.py — recipe<->job bridge; register_recipe_suggestion() makes
  a recipe source 'recipe' of this surface. recipe_to_job_spec() is the single
  translation both the direct and suggestion paths share.
- hermes_cli/suggestions_cmd.py — shared /suggestions handler (CLI + gateway
  never drift); /suggestions [accept N|dismiss N|catalog|clear].
- Wired: CommandDef + CLI dispatch (cli.py) + gateway dispatch (gateway/run.py)
  + aux 'monitor' task (config.py) + recipe-install hook (skills_hub.py).

Consent-first throughout: nothing auto-schedules; acceptance is always
explicit; dismissals latch.

Supersedes #41122 (proactive-monitor) and #41127 (recipes): both fold in here
as a catalog entry and a suggestion source respectively.

Tests: store (dedup/cap/accept/dismiss/latch), catalog seeding+idempotency,
recipe->suggestion bridge, command handler, aux config. E2E: recipe SKILL.md
-> parsed -> suggested -> accepted -> real cron job persisted to jobs.json.
2026-06-11 10:49:47 -07:00
Teknium 4d6a133a9f fix(agent): gate skill-index demotion behind the opt-in focus mode (#44387)
The coding posture's names-only demotion of non-coding skill categories
(#44342) applied under the default auto mode, silently changing the skill
index for every user in a git repo. Index changes must be opt-in: demotion
now only fires under agent.coding_context=focus, alongside the toolset
collapse. auto/on leave the skill index untouched; focus semantics are
unchanged (demoted, never hidden; deny-list keeps coding-adjacent and
custom categories at full entries).
2026-06-11 10:00:57 -07:00
Teknium c7bfc938d5 fix(dashboard): Config page header shows the switched profile's config.yaml path (#44374)
The Config page read config_path from /api/status, which is machine-global
and always reports the profile the dashboard process was started under.
After switching profiles with the global switcher, the header kept showing
the old profile's path (e.g. /root/.hermes/profiles/worker_1/config.yaml)
even though reads/writes correctly targeted the new profile.

Fix: /api/config/raw now returns the resolved path alongside the YAML
(resolved inside _profile_scope, so it follows ?profile=). ConfigPage
prefers that scoped path and only falls back to /api/status for old
servers. ProfileKeyedRoutes already remounts the page on switch, so the
header refreshes immediately.
2026-06-11 09:46:15 -07:00
yoniebans 9121834b31 fix(desktop): scope remote workspace defaults 2026-06-11 09:41:35 -07:00
yoniebans 56a0f48ba6 fix(desktop): tighten remote filesystem wiring 2026-06-11 09:41:35 -07:00
yoniebans 8878484f85 feat(desktop): wire remote filesystem browsing 2026-06-11 09:41:35 -07:00
yoniebans db79e90130 feat(desktop): add filesystem routing facade 2026-06-11 09:41:35 -07:00
yoniebans 51f47f9a97 feat(desktop): add read-only remote filesystem API 2026-06-11 09:41:35 -07:00
104 changed files with 6439 additions and 212 deletions
+3
View File
@@ -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)
+38 -22
View File
@@ -38,12 +38,13 @@ session (deferred), the same contract as ``/skills install`` vs ``--now``.
Activation (config ``agent.coding_context``):
* ``auto`` (default) — posture (brief + snapshot + names-only demotion of
non-coding skill categories) on an interactive coding surface sitting in
a code workspace (git repo or recognised project root). Prompt-only;
toolsets untouched, no skill is ever hidden.
* ``auto`` (default) — posture (brief + snapshot) on an interactive coding
surface sitting in a code workspace (git repo or recognised project root).
Prompt-only; toolsets and the skill index untouched.
* ``focus`` — like ``auto``, but additionally collapses the toolset to the
``coding`` set + enabled MCP servers. Explicit opt-in for a lean schema.
``coding`` set + enabled MCP servers and demotes non-coding skill
categories to names-only in the prompt's skill index (no skill is ever
hidden). Explicit opt-in for a lean schema.
* ``on`` — force the posture anywhere (incl. non-workspaces). Prompt-only.
* ``off`` — disable entirely.
"""
@@ -105,13 +106,19 @@ _GIT_TIMEOUT = 2.5
# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
# its native format. Unknown families get nothing (the brief's neutral wording
# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
#
# GPT/Codex get V4A for ALL edits, single-file included: in codex-rs,
# apply_patch (V4A — apply_patch.lark) is the ONLY file editor, no
# str_replace-style tool exists, and the shipped model prompts say to use
# apply_patch even "for single file edits" — so a replace-mode nudge would
# steer those models toward a format their first-party harness never taught
# them.
_EDIT_FORMAT_GUIDANCE: dict[str, tuple[tuple[str, ...], str]] = {
"patch": (
("gpt", "codex"),
"- Edit format: author new files with `write_file`; for edits to "
"existing code prefer `patch` with `mode='patch'` (V4A multi-file diff) "
"for structured or multi-file changes — it's the diff format you handle "
"most reliably. Use `mode='replace'` for a single small swap.",
"existing code use `patch` with `mode='patch'` (V4A diff) — including "
"single-file edits. It's the edit format you handle most reliably.",
),
"replace": (
("claude", "sonnet", "opus", "haiku",
@@ -214,8 +221,8 @@ class ContextProfile:
(extension seam; not yet consumed by the router).
``memory_policy``— memory namespace/weighting hint (extension seam).
``compact_skill_categories`` — skill categories DEMOTED to names-only in
the system-prompt skill index while this posture is
active. Never hidden: every skill name stays visible
the system-prompt skill index under the opt-in ``focus``
mode. Never hidden: every skill name stays visible
(so memory-anchored recall keeps working) — only the
descriptions are dropped to cut index noise. Deny-list
semantics so unknown/custom categories keep full
@@ -231,7 +238,7 @@ class ContextProfile:
# Skill categories that are clearly not part of a coding workflow. Demoted to
# names-only in the prompt's skill index while the coding posture is active
# names-only in the prompt's skill index under the opt-in ``focus`` mode only
# (deny-list — anything not listed here, incl. custom user categories, keeps
# full entries). Coding-adjacent categories (devops, github, mcp,
# data-science, diagramming, research, security, …) are intentionally absent.
@@ -438,15 +445,22 @@ class RuntimeMode:
def compact_skill_categories(self) -> frozenset[str]:
"""Skill categories to demote to names-only in the prompt's skill index.
Demoted — never hidden. An earlier revision fully pruned these
categories from the index, which caused silent capability loss in a
real workflow: agent-created skills are the model's accumulated
project memory (server-ops runbooks, learned pitfalls, …), and models
do not reliably reach for ``skills_list`` to rediscover what the
index stopped showing them. Names-only keeps every skill loadable on
recall while still cutting the description noise from the index.
Gated on the opt-in ``focus`` mode, like the toolset collapse: the
default posture leaves the skill index untouched. Users who didn't ask
for a lean prompt keep full entries for every category — index changes
under ``auto`` proved too surprising in practice, even names-only ones
(a demoted description is information the model no longer weighs when
deciding what to load).
Demoted — never hidden — even under ``focus``. An earlier revision
fully pruned these categories from the index, which caused silent
capability loss in a real workflow: agent-created skills are the
model's accumulated project memory (server-ops runbooks, learned
pitfalls, …), and models do not reliably reach for ``skills_list`` to
rediscover what the index stopped showing them. Names-only keeps every
skill loadable on recall while still cutting the description noise.
"""
if not self.is_coding:
if not self.is_coding or self.config_mode != "focus":
return frozenset()
return frozenset(self.profile.compact_skill_categories)
@@ -534,9 +548,11 @@ def coding_compact_skill_categories(
) -> frozenset[str]:
"""Skill categories the active posture demotes to names-only in the index.
Empty outside the coding posture. Demoted — never hidden: every skill
name stays in the index and remains loadable via ``skill_view`` /
``skills_list``; only descriptions are dropped.
Empty outside the coding posture and outside the opt-in ``focus`` mode —
the default posture never touches the skill index. Under ``focus``,
demoted — never hidden: every skill name stays in the index and remains
loadable via ``skill_view`` / ``skills_list``; only descriptions are
dropped.
"""
return resolve_runtime_mode(
platform=platform, cwd=cwd, config=config
+56 -23
View File
@@ -7,7 +7,7 @@ protecting head and tail context.
Improvements over v2:
- Structured summary template with Resolved/Pending question tracking
- Filter-safe summarizer preamble that treats prior turns as source material
- "Remaining Work" replaces "Next Steps" to avoid reading as active instructions
- Historical (reference-only) section headings replace "Next Steps"/"Remaining Work" to avoid reading as active instructions
- Clear separator when summary merges into tail message
- Iterative summary updates (preserves info across multiple compactions)
- Token-budget tail protection instead of fixed message count
@@ -34,7 +34,50 @@ from agent.redact import redact_sensitive_text
logger = logging.getLogger(__name__)
HISTORICAL_TASK_HEADING = "## Historical Task Snapshot"
HISTORICAL_IN_PROGRESS_HEADING = "## Historical In-Progress State"
HISTORICAL_PENDING_ASKS_HEADING = "## Historical Pending User Asks"
HISTORICAL_REMAINING_WORK_HEADING = "## Historical Remaining Work"
SUMMARY_PREFIX = (
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Respond ONLY to the latest user message that appears AFTER this "
"summary — that message is the single source of truth for what to do "
"right now. "
"Topic overlap with the summary does NOT mean you should resume its "
"task: even on similar topics, the latest user message WINS. Treat ONLY "
"the latest message as the active task and discard stale items from "
f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / "
f"'{HISTORICAL_PENDING_ASKS_HEADING}' / "
f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or "
"'finish' work described there unless the latest message explicitly "
"asks for it. "
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
"topic) must immediately end any in-flight work described in the "
"summary; do not re-surface it in later turns. "
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
# stale directive it carried (e.g. "resume exactly from Active Task") survives
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
_HISTORICAL_SUMMARY_PREFIXES = (
# Carveout era (#41607/#38364/#42812): "consistent → use as background"
# licensed stale-task resumption on topic overlap.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
@@ -57,17 +100,7 @@ SUMMARY_PREFIX = (
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
# stale directive it carried (e.g. "resume exactly from Active Task") survives
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
_HISTORICAL_SUMMARY_PREFIXES = (
"described here — avoid repeating it:",
# Pre-#35344: contained the self-contradicting "resume exactly" directive.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
@@ -1155,7 +1188,7 @@ class ContextCompressor(ContextEngine):
)
reason_text = f" Summary failure reason: {reason}." if reason else ""
body = f"""## Active Task
body = f"""{HISTORICAL_TASK_HEADING}
{active_task}
## Goal
@@ -1172,7 +1205,7 @@ Recovered from a deterministic fallback because the LLM context summarizer was u
## Active State
Unknown from deterministic fallback. Inspect current repository/session state if needed.
## In Progress
{HISTORICAL_IN_PROGRESS_HEADING}
{active_task}
## Blocked
@@ -1184,13 +1217,13 @@ None recoverable from deterministic fallback.
## Resolved Questions
None recoverable from deterministic fallback.
## Pending User Asks
{HISTORICAL_PENDING_ASKS_HEADING}
{active_task}
## Relevant Files
{_bullets(relevant_files, limit=12)}
## Remaining Work
{HISTORICAL_REMAINING_WORK_HEADING}
Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims.
## Last Dropped Turns
@@ -1312,7 +1345,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
_temporal_anchoring_rule = ""
# Shared structured template (used by both paths).
_template_sections = f"""## Active Task
_template_sections = f"""{HISTORICAL_TASK_HEADING}
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
input verbatim — the exact words they used. This includes:
- Explicit task assignments ("refactor the auth module")
@@ -1359,7 +1392,7 @@ Be specific with file paths, commands, line numbers, and results.]
- Any running processes or servers
- Environment details that matter]
## In Progress
{HISTORICAL_IN_PROGRESS_HEADING}
[Work currently underway — what was being done when compaction fired]
## Blocked
@@ -1371,14 +1404,14 @@ Be specific with file paths, commands, line numbers, and results.]
## Resolved Questions
[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
## Pending User Asks
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."]
{HISTORICAL_PENDING_ASKS_HEADING}
[Questions or requests from the user that have NOT yet been answered or fulfilled. These are STALE — they were from the compacted turns. Write them here for reference only. The agent must NOT act on them unless the latest user message explicitly requests it. If none, write "None."]
## Relevant Files
[Files read, modified, or created — with brief note on each]
## Remaining Work
[What remains to be done — framed as context, not instructions]
{HISTORICAL_REMAINING_WORK_HEADING}
[What remains to be done — framed as STALE context for reference only. The agent must NOT resume this work unless the latest user message explicitly asks for it.]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.]
@@ -1753,7 +1786,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
Context compressor bug (#10896): ``_align_boundary_backward`` can pull
``cut_idx`` past a user message when it tries to keep tool_call/result
groups together. If the last user message ends up in the *compressed*
middle region the LLM summariser writes it into "Pending User Asks",
middle region the LLM summariser writes it into "Historical Pending User Asks",
but ``SUMMARY_PREFIX`` tells the next model to respond only to user
messages *after* the summary — so the task effectively disappears from
the active context, causing the agent to stall, repeat completed work,
+4 -3
View File
@@ -191,9 +191,10 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
)
if toolset
}
# Coding posture demotes non-coding skill categories to names-only in
# the index (never hidden — skill_view/skills_list reach everything,
# and every name stays visible for memory-anchored recall).
# Focus mode (opt-in) demotes non-coding skill categories to
# names-only in the index (never hidden — skill_view/skills_list
# reach everything, and every name stays visible for recall). The
# default coding posture leaves the index untouched.
_compact_cats = frozenset()
try:
from agent.coding_context import coding_compact_skill_categories
+87
View File
@@ -0,0 +1,87 @@
/**
* Helpers for local dashboard session-token discovery.
*
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
* spawns the local dashboard, but the dashboard is the source of truth for the
* token it actually serves to the renderer. If those drift, HTTP readiness
* probes still pass while /api/ws rejects the renderer's token.
*/
const http = require('node:http')
const https = require('node:https')
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
function fetchPublicText(url, options = {}) {
return new Promise((resolve, reject) => {
let parsed
try {
parsed = new URL(url)
} catch (error) {
reject(new Error(`Invalid URL: ${error.message}`))
return
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
return
}
const client = parsed.protocol === 'https:' ? https : http
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
const req = client.request(parsed, { method: options.method || 'GET' }, res => {
const chunks = []
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8')
if ((res.statusCode || 500) >= 400) {
reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`))
return
}
resolve(text)
})
})
req.on('error', reject)
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
})
req.end()
})
}
function extractInjectedDashboardToken(html) {
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
if (!match) return null
try {
return JSON.parse(match[1])
} catch {
return null
}
}
function dashboardIndexUrl(baseUrl) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
}
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
const fetchText = options.fetchText || fetchPublicText
const html = await fetchText(dashboardIndexUrl(baseUrl), {
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
})
const servedToken = extractInjectedDashboardToken(html)
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
}
return servedToken || fallbackToken
}
module.exports = {
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
resolveServedDashboardToken
}
@@ -0,0 +1,89 @@
/**
* Tests for electron/dashboard-token.cjs.
*
* Run with: node --test electron/dashboard-token.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
resolveServedDashboardToken
} = require('./dashboard-token.cjs')
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
assert.equal(extractInjectedDashboardToken(html), 'served-token')
})
test('extractInjectedDashboardToken handles escaped token strings', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
})
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
})
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
})
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
const logs = []
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async url => {
assert.equal(url, 'http://127.0.0.1:9120/')
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
},
rememberLog: line => logs.push(line)
})
assert.equal(token, 'served-token')
assert.equal(logs.length, 1)
assert.match(logs[0], /served a different session token/)
})
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async () => '<html></html>',
rememberLog: () => {
throw new Error('should not log when no served token is present')
}
})
assert.equal(token, 'spawn-token')
})
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
rememberLog: () => {
throw new Error('should not log when token already matches')
}
})
assert.equal(token, 'same-token')
})
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
await assert.rejects(
() =>
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async () => {
throw new Error('boom')
}
}),
/boom/
)
})
test('fetchPublicText rejects unsupported protocols', async () => {
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
})
+124 -4
View File
@@ -29,6 +29,7 @@ const { runBootstrap } = require('./bootstrap-runner.cjs')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { resolveServedDashboardToken } = require('./dashboard-token.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
@@ -4594,15 +4595,20 @@ async function spawnPoolBackend(profile, entry) {
const baseUrl = `http://127.0.0.1:${port}`
await Promise.race([waitForHermes(baseUrl, token), startFailed])
ready = true
const authToken = await resolveServedDashboardToken(baseUrl, token, { rememberLog }).catch(error => {
rememberLog(`[boot] could not read served dashboard token for profile "${profile}": ${error.message}`)
return token
})
entry.token = authToken
return {
baseUrl,
mode: 'local',
source: 'local',
authMode: 'token',
token,
token: authToken,
profile,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -4821,6 +4827,10 @@ async function startHermes() {
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
const authToken = await resolveServedDashboardToken(baseUrl, token, { rememberLog }).catch(error => {
rememberLog(`[boot] could not read served dashboard token: ${error.message}`)
return token
})
updateBootProgress({
phase: 'backend.ready',
message: 'Hermes backend is ready. Finalizing desktop startup',
@@ -4834,8 +4844,8 @@ async function startHermes() {
mode: 'local',
source: 'local',
authMode: 'token',
token,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
token: authToken,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -6111,6 +6121,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/<key>?slot=val -> host="blueprint", path="/<key>"
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 +6234,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
+6
View File
@@ -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)
@@ -8,7 +8,7 @@ const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
}
function requireHiddenChildOptions(source, needle) {
+12 -2
View File
@@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -72,6 +72,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dnd-core": "^14.0.1",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
@@ -83,6 +84,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dnd-html5-backend": "^14.0.3",
"react-dom": "^19.2.5",
"react-router-dom": "^7.17.0",
"react-shiki": "^0.9.3",
@@ -103,7 +105,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",
"@types/node": "^24.12.0",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.1",
@@ -132,6 +134,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": {
+10 -2
View File
@@ -1630,7 +1630,7 @@ export function ChatBar({
onPaste={handlePaste}
ref={editorRef}
role="textbox"
spellCheck="true"
spellCheck={false}
suppressContentEditableWarning
/>
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
@@ -1649,7 +1649,15 @@ export function ChatBar({
`asChild` swaps TextareaAutosize for a Radix Slot wrapping our
plain <textarea>, which carries the binding but skips autosize. */}
<ComposerPrimitive.Input asChild submitMode="ctrlEnter" tabIndex={-1} unstable_focusOnScrollToBottom={false}>
<textarea aria-hidden className="sr-only" tabIndex={-1} />
<textarea
aria-hidden
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="sr-only"
spellCheck={false}
tabIndex={-1}
/>
</ComposerPrimitive.Input>
</div>
)
@@ -13,6 +13,7 @@ import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { PageLoader } from '@/components/page-loader'
import { translateNow, useI18n } from '@/i18n'
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
@@ -180,15 +181,13 @@ function looksBinaryBytes(bytes: Uint8Array) {
}
async function readTextPreview(filePath: string) {
if (window.hermesDesktop.readFileText) {
try {
return await window.hermesDesktop.readFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
try {
return await readDesktopFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
}
@@ -448,7 +447,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
if (isImage) {
// Prefer bytes the caller already handed us (a pasted/dropped
// screenshot) over re-reading a path that may be transient/unreadable.
const dataUrl = target.dataUrl || (await window.hermesDesktop.readFileDataUrl(filePath))
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
if (active) {
setState({ dataUrl, loading: false })
@@ -1,11 +1,50 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { PreviewPane } from './preview-pane'
describe('PreviewPane console state', () => {
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
})
afterEach(() => {
cleanup()
$connection.set(null)
vi.unstubAllGlobals()
})
it('does not watch backend-only remote filesystem previews locally', () => {
const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
const onPreviewFileChanged = vi.fn(() => vi.fn())
$connection.set({ mode: 'remote' } as never)
vi.stubGlobal('window', {
...window,
hermesDesktop: {
onPreviewFileChanged,
watchPreviewFile
}
})
render(
<PreviewPane
setTitlebarToolGroup={vi.fn()}
target={{
kind: 'file',
label: 'file.txt',
path: '/remote/file.txt',
previewKind: 'text',
source: '/remote/file.txt',
url: 'file:///remote/file.txt'
}}
/>
)
expect(watchPreviewFile).not.toHaveBeenCalled()
expect(onPreviewFileChanged).not.toHaveBeenCalled()
})
it('does not rebuild the pane titlebar group for streamed console logs', () => {
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { Bug } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -406,6 +407,7 @@ export function PreviewPane({
useEffect(() => {
if (
target.kind !== 'file' ||
isDesktopFsRemoteMode() ||
!window.hermesDesktop?.watchPreviewFile ||
!window.hermesDesktop?.onPreviewFileChanged
) {
+8 -1
View File
@@ -797,7 +797,14 @@ export function ChatSidebar({
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
// no-drag: these rows sit directly under the titlebar's
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
// 6px of clearance. Drag regions win hit-testing over DOM
// (pointer-events can't override), and on Linux/WSLg the
// resolved region has been observed to swallow clicks on the
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
// thread.tsx.
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
@@ -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()) {
@@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { translateNow } from '@/i18n'
import { desktopDefaultCwd } from '@/lib/desktop-fs'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
@@ -25,12 +26,16 @@ import {
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
$activeSessionId,
$attentionSessionIds,
$connection,
$currentCwd,
$sessions,
$workingSessionIds,
ensureDefaultWorkspaceCwd,
setConnection,
setCurrentBranch,
setCurrentCwd,
setSessionsLoading
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
@@ -353,6 +358,11 @@ export function useGatewayBoot({
progress: 97
})
await ensureDefaultWorkspaceCwd()
const remoteDefault = await desktopDefaultCwd().catch(() => null)
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
setCurrentCwd(remoteDefault.cwd)
setCurrentBranch(remoteDefault.branch || '')
}
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {
@@ -0,0 +1,27 @@
import { createDragDropManager, type DragDropManager } from 'dnd-core'
import { HTML5Backend } from 'react-dnd-html5-backend'
let manager: DragDropManager | null = null
/**
* A single, app-lifetime react-dnd manager for the file tree.
*
* react-arborist mounts its own react-dnd `DndProvider` with `HTML5Backend`
* inside every `<Tree>`. react-dnd v14 stores that provider's manager on a
* global, ref-counted singleton context and nulls it when the count hits 0.
* On a keyed remount (cwd / collapse changes force a fresh `<Tree>`), the
* singleton can be torn down and recreated while the previous `HTML5Backend`
* still owns the `window.__isReactDndHtml5Backend` setup flag — so the new
* backend's `setup()` throws "Cannot have two HTML5 backends at the same
* time." and trips the file-tree error boundary (it never recovers, because
* "Try again" just remounts into the same race).
*
* Passing arborist a stable `dndManager` makes it skip the global-singleton
* path entirely and reuse one backend for the lifetime of the app, so the
* window flag is never double-claimed.
*/
export function getFileTreeDndManager(): DragDropManager {
manager ??= createDragDropManager(HTML5Backend)
return manager
}
+11 -17
View File
@@ -1,5 +1,6 @@
import ignore from 'ignore'
import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
export type ProjectTreeEntry = HermesReadDirEntry
@@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) {
}
async function gitRootFor(start: string) {
if (!window.hermesDesktop?.gitRoot) {
return null
}
const key = clean(start)
const key = `${desktopFsCacheKey()}:${clean(start)}`
let cached = gitRootCache.get(key)
if (!cached) {
cached = window.hermesDesktop.gitRoot(key)
cached = desktopGitRoot(start)
gitRootCache.set(key, cached)
}
@@ -80,18 +77,14 @@ async function gitRootFor(start: string) {
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
return null
}
try {
const listing = await window.hermesDesktop.readDir(dir)
const listing = await readDesktopDir(dir)
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
return null
}
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
const text = decodeDataUrl(await readDesktopFileDataUrl(`${dir}/.gitignore`))
return { base: dir, ig: ignore().add(text) }
} catch {
@@ -100,11 +93,11 @@ async function readGitignore(dir: string): Promise<GitignoreRule | null> {
}
async function gitignoreFor(dir: string) {
const key = clean(dir)
const key = `${desktopFsCacheKey()}:${clean(dir)}`
let cached = gitignoreCache.get(key)
if (!cached) {
cached = readGitignore(key)
cached = readGitignore(clean(dir))
gitignoreCache.set(key, cached)
}
@@ -142,9 +135,10 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi
return { entries: [], error: 'no-bridge' }
}
const result = await window.hermesDesktop.readDir(dirPath)
const result = await readDesktopDir(dirPath)
const entries = result?.entries ?? []
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }
}
export function clearProjectDirCache(rootPath?: string) {
@@ -155,7 +149,7 @@ export function clearProjectDirCache(rootPath?: string) {
return
}
const key = clean(rootPath)
const key = `${desktopFsCacheKey()}:${clean(rootPath)}`
gitRootCache.delete(key)
gitignoreCache.delete(key)
}
@@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import { useI18n } from '@/i18n'
import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
function clean(path: string) {
return path.replace(/\/+$/, '') || '/'
}
function parentDir(path: string) {
const value = clean(path)
if (value === '/') {
return '/'
}
const parent = value.slice(0, value.lastIndexOf('/'))
return parent || '/'
}
function pathName(path: string) {
return path.split('/').filter(Boolean).pop() || path
}
interface PendingSelection {
defaultPath: string
resolve: (paths: string[]) => void
title: string
}
export function RemoteFolderPicker() {
const { t } = useI18n()
const r = t.rightSidebar
const [pending, setPending] = useState<PendingSelection | null>(null)
const [currentPath, setCurrentPath] = useState('/')
const [entries, setEntries] = useState<Array<{ name: string; path: string }>>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
setDesktopFsRemotePicker({
selectPaths: options =>
new Promise(resolve => {
const defaultPath = clean(options?.defaultPath || '/')
setCurrentPath(defaultPath)
setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle })
})
})
return () => setDesktopFsRemotePicker(null)
}, [r.remotePickerTitle])
useEffect(() => {
if (!pending) {
return
}
let active = true
setLoading(true)
setError(null)
void readDesktopDir(currentPath)
.then(result => {
if (!active) {
return
}
if (result.error) {
setError(result.error)
setEntries([])
return
}
setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path })))
})
.catch(err => {
if (active) {
setError(err instanceof Error ? err.message : String(err))
setEntries([])
}
})
.finally(() => {
if (active) {
setLoading(false)
}
})
return () => {
active = false
}
}, [currentPath, pending])
const crumbs = useMemo(() => {
const parts = clean(currentPath).split('/').filter(Boolean)
const out = [{ label: '/', path: '/' }]
let acc = ''
for (const part of parts) {
acc += `/${part}`
out.push({ label: part, path: acc })
}
return out
}, [currentPath])
const close = (paths: string[] = []) => {
pending?.resolve(paths)
setPending(null)
setEntries([])
setError(null)
}
return (
<Dialog onOpenChange={open => !open && close()} open={Boolean(pending)}>
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
<div className="border-b border-border/70 px-4 py-3">
<DialogTitle className="text-sm">{pending?.title || r.remotePickerTitle}</DialogTitle>
<DialogDescription className="mt-1 text-xs">{r.remotePickerDescription}</DialogDescription>
</div>
<div className="flex min-h-[22rem] flex-col">
<div className="flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
{crumbs.map((crumb, index) => (
<button
className={cn('rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground', index === crumbs.length - 1 && 'text-foreground')}
key={crumb.path}
onClick={() => setCurrentPath(crumb.path)}
type="button"
>
{crumb.label}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<FolderRow disabled={currentPath === '/'} name=".." onClick={() => setCurrentPath(parentDir(currentPath))} />
{loading ? (
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
<Codicon name="loading" size="0.8rem" spinning />
{r.loadingFiles}
</div>
) : error ? (
<div className="px-2 py-3 text-xs text-destructive">{r.unreadableBody(error)}</div>
) : entries.length === 0 ? (
<div className="px-2 py-3 text-xs text-muted-foreground">{r.emptyBody}</div>
) : (
entries.map(entry => <FolderRow key={entry.path} name={pathName(entry.path)} onClick={() => setCurrentPath(entry.path)} />)
)}
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3">
<div className="min-w-0 truncate text-xs text-muted-foreground">{currentPath}</div>
<div className="flex shrink-0 items-center gap-2">
<Button onClick={() => close()} size="sm" variant="ghost">
{t.common.cancel}
</Button>
<Button onClick={() => close([currentPath])} size="sm">
{r.remotePickerSelect}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) {
return (
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
disabled={disabled}
onClick={onClick}
type="button"
>
<Codicon name="folder" size="0.875rem" />
<span className="min-w-0 truncate">{name}</span>
</button>
)
}
@@ -7,6 +7,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { getFileTreeDndManager } from './dnd-manager'
import type { TreeNode } from './use-project-tree'
const ROW_HEIGHT = 22
@@ -94,6 +95,7 @@ export function ProjectTree({
disableDrag
disableDrop
disableEdit
dndManager={getFileTreeDndManager()}
height={size.height}
indent={INDENT}
initialOpenState={openState}
@@ -1,19 +1,24 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import type { HermesReadDirResult } from '@/global'
import { clearProjectDirCache, readProjectDir } from './ipc'
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
beforeEach(() => {
$connection.set(null)
resetProjectTreeState()
readDir.mockReset()
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
})
afterEach(() => {
cleanup()
$connection.set(null)
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
@@ -106,6 +111,36 @@ describe('useProjectTree', () => {
expect(readDir).toHaveBeenCalledTimes(1)
})
it('reads gitignore from the real path while caching per connection', async () => {
const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\n')}`)
const gitRoot = vi.fn(async () => '/repo')
readDir.mockImplementation(async path => {
if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])
if (path === '/repo/src') {
return ok([
{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false },
{ name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false }
])
}
throw new Error(`unexpected path ${path}`)
})
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
$connection.set({ baseUrl: 'local-a', mode: 'local' } as never)
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
})
expect(readDir).toHaveBeenCalledWith('/repo')
expect(readDir).not.toHaveBeenCalledWith(expect.stringContaining('local-a'))
$connection.set({ baseUrl: 'local-b', mode: 'local' } as never)
clearProjectDirCache()
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
})
expect(readDir.mock.calls.filter(([path]) => path === '/repo')).toHaveLength(2)
})
it('captures per-folder error code and shows an error placeholder child', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useCallback, useEffect, useMemo } from 'react'
import { $connection } from '@/store/session'
import { clearProjectDirCache, readProjectDir } from './ipc'
export interface TreeNode {
@@ -96,6 +98,7 @@ const initialState: ProjectTreeState = {
const inflight = new Set<string>()
const $projectTree = atom<ProjectTreeState>(initialState)
let nextRootRequestId = 0
let lastConnectionKey = ''
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
$projectTree.set(updater($projectTree.get()))
@@ -157,6 +160,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
}
export function resetProjectTreeState() {
lastConnectionKey = ''
clearProjectTree()
clearProjectDirCache()
}
@@ -170,6 +174,8 @@ export function resetProjectTreeState() {
*/
export function useProjectTree(cwd: string): UseProjectTreeResult {
const state = useStore($projectTree)
const connection = useStore($connection)
const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
@@ -248,8 +254,15 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
)
useEffect(() => {
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
lastConnectionKey = connectionKey
if (connectionChanged) {
clearProjectDirCache()
void loadRoot(cwd, { force: true })
return
}
void loadRoot(cwd)
}, [cwd])
}, [connectionKey, cwd])
return useMemo(
() => ({
+5 -1
View File
@@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { selectDesktopPaths } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
@@ -16,6 +17,7 @@ import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { RemoteFolderPicker } from './files/remote-picker'
import { ProjectTree } from './files/tree'
import { useProjectTree } from './files/use-project-tree'
@@ -54,7 +56,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
const canCollapse = Object.values(openState).some(Boolean)
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
const selected = await selectDesktopPaths({
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,
@@ -90,6 +92,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
)}
>
<RemoteFolderPicker />
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
@@ -1528,6 +1528,8 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
>
<div
aria-label={copy.editMessage}
autoCapitalize="off"
autoCorrect="off"
autoFocus
className={cn(
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
@@ -1549,9 +1551,26 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
onPaste={handlePaste}
ref={editorRef}
role="textbox"
spellCheck={false}
suppressContentEditableWarning
/>
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
<ComposerPrimitive.Input
asChild
className="sr-only"
submitMode="ctrlEnter"
tabIndex={-1}
unstable_focusOnScrollToBottom={false}
>
<textarea
aria-hidden
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="sr-only"
spellCheck={false}
tabIndex={-1}
/>
</ComposerPrimitive.Input>
{staging && (
<span
className="pointer-events-none absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-background/80 px-1.5 py-0.5 text-[0.62rem] text-muted-foreground backdrop-blur-[1px]"
@@ -14,6 +14,8 @@ import {
$visibleModels,
collapseModelFamilies,
effectiveVisibleKeys,
emptyProviderSentinelKey,
isProviderSentinel,
modelVisibilityKey,
setVisibleModels
} from '@/store/model-visibility'
@@ -61,10 +63,21 @@ export function ModelVisibilityDialog({
const toggle = (provider: ModelOptionProvider, model: string) => {
const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers))
const key = modelVisibilityKey(provider.slug, model)
const sentinel = emptyProviderSentinelKey(provider.slug)
if (next.has(key)) {
next.delete(key)
// Check if this was the last real model for this provider.
const remainingForProvider = [...next].some(
k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k)
)
if (!remainingForProvider) {
next.add(sentinel)
}
} else {
next.delete(sentinel)
next.add(key)
}
+4
View File
@@ -75,6 +75,10 @@ declare global {
}
onClosePreviewRequested?: (callback: () => void) => () => void
onOpenUpdatesRequested?: (callback: () => void) => () => void
onDeepLink?: (
callback: (payload: { kind: string; name: string; params: Record<string, string> }) => void,
) => () => void
signalDeepLinkReady?: () => Promise<{ ok: boolean }>
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
+3
View File
@@ -1532,6 +1532,9 @@ export const en: Translations = {
terminal: 'Terminal',
noFolderSelected: 'No folder selected',
changeCwdTitle: 'Change working directory',
remotePickerTitle: 'Choose remote folder',
remotePickerDescription: 'Browse folders on the connected backend.',
remotePickerSelect: 'Select folder',
folderTip: cwd => `${cwd} — click to change folder`,
openFolder: 'Open folder',
refreshTree: 'Refresh tree',
+3
View File
@@ -1665,6 +1665,9 @@ export const ja = defineLocale({
terminal: 'ターミナル',
noFolderSelected: 'フォルダーが選択されていません',
changeCwdTitle: '作業ディレクトリを変更',
remotePickerTitle: 'リモートフォルダーを選択',
remotePickerDescription: '接続中のバックエンド上のフォルダーを参照します。',
remotePickerSelect: 'フォルダーを選択',
folderTip: cwd => `${cwd} — クリックしてフォルダーを変更`,
openFolder: 'フォルダーを開く',
refreshTree: 'ツリーを更新',
+3
View File
@@ -1194,6 +1194,9 @@ export interface Translations {
terminal: string
noFolderSelected: string
changeCwdTitle: string
remotePickerTitle: string
remotePickerDescription: string
remotePickerSelect: string
folderTip: (cwd: string) => string
openFolder: string
refreshTree: string
+3
View File
@@ -1626,6 +1626,9 @@ export const zhHant = defineLocale({
terminal: '終端機',
noFolderSelected: '未選擇資料夾',
changeCwdTitle: '變更工作目錄',
remotePickerTitle: '選擇遠端資料夾',
remotePickerDescription: '瀏覽已連線後端上的資料夾。',
remotePickerSelect: '選擇資料夾',
folderTip: cwd => `${cwd} — 點擊以變更資料夾`,
openFolder: '開啟資料夾',
refreshTree: '重新整理檔案樹',
+3
View File
@@ -1712,6 +1712,9 @@ export const zh: Translations = {
terminal: '终端',
noFolderSelected: '未选择文件夹',
changeCwdTitle: '更改工作目录',
remotePickerTitle: '选择远程文件夹',
remotePickerDescription: '浏览已连接后端上的文件夹。',
remotePickerSelect: '选择文件夹',
folderTip: cwd => `${cwd} — 点击更改文件夹`,
openFolder: '打开文件夹',
refreshTree: '刷新文件树',
+116
View File
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import {
desktopDefaultCwd,
desktopGitRoot,
readDesktopDir,
readDesktopFileDataUrl,
readDesktopFileText,
selectDesktopPaths,
setDesktopFsRemotePicker
} from './desktop-fs'
const readDir = vi.fn(async () => ({ entries: [{ name: 'local', path: '/local', isDirectory: true }] }))
const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local', byteSize: 5 }))
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=')
const gitRoot = vi.fn(async () => '/local')
const selectPaths = vi.fn(async () => ['/local'])
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/list?')) return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] }
if (path.startsWith('/api/fs/read-text?')) return { path: '/remote/file.txt', text: 'remote', byteSize: 6 }
if (path.startsWith('/api/fs/read-data-url?')) return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' }
if (path.startsWith('/api/fs/git-root?')) return { root: '/remote' }
if (path === '/api/fs/default-cwd') return { cwd: '/backend/project', branch: 'main' }
throw new Error(`unexpected path ${path}`)
})
function stubBridge() {
vi.stubGlobal('window', {
hermesDesktop: {
api,
gitRoot,
readDir,
readFileDataUrl,
readFileText,
selectPaths
}
})
}
describe('desktop filesystem facade', () => {
beforeEach(() => {
stubBridge()
$connection.set(null)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
$connection.set(null)
setDesktopFsRemotePicker(null)
})
it('uses local Electron filesystem methods in local mode', async () => {
$connection.set({ mode: 'local' } as never)
await expect(readDesktopDir('/work')).resolves.toEqual({ entries: [{ name: 'local', path: '/local', isDirectory: true }] })
await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' })
await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=')
await expect(desktopGitRoot('/work')).resolves.toBe('/local')
await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/local'])
expect(readDir).toHaveBeenCalledWith('/work')
expect(readFileText).toHaveBeenCalledWith('/work/file.txt')
expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt')
expect(gitRoot).toHaveBeenCalledWith('/work')
expect(selectPaths).toHaveBeenCalledWith({ directories: true })
expect(api).not.toHaveBeenCalled()
})
it('routes filesystem reads through authenticated backend REST in remote mode', async () => {
$connection.set({ mode: 'remote' } as never)
await expect(readDesktopDir('/home/user/project')).resolves.toMatchObject({ entries: [{ name: 'remote' }] })
await expect(readDesktopFileText('/home/user/project/a b.txt')).resolves.toMatchObject({ text: 'remote' })
await expect(readDesktopFileDataUrl('/home/user/project/a b.txt')).resolves.toBe('data:text/plain;base64,cmVtb3Rl')
await expect(desktopGitRoot('/home/user/project')).resolves.toBe('/remote')
await expect(desktopDefaultCwd()).resolves.toEqual({ cwd: '/backend/project', branch: 'main' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fhome%2Fuser%2Fproject' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-text?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/git-root?path=%2Fhome%2Fuser%2Fproject' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd' })
expect(readDir).not.toHaveBeenCalled()
expect(readFileText).not.toHaveBeenCalled()
expect(readFileDataUrl).not.toHaveBeenCalled()
expect(gitRoot).not.toHaveBeenCalled()
})
it('uses the registered in-app directory picker in remote mode', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ defaultPath: '/remote', directories: true, multiple: false })).resolves.toEqual([
'/remote/project'
])
expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false })
expect(selectPaths).not.toHaveBeenCalled()
})
it('does not treat the remote directory picker as a general file picker', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([])
await expect(selectDesktopPaths({ directories: true, multiple: true })).resolves.toEqual([])
expect(remoteSelect).not.toHaveBeenCalled()
expect(selectPaths).not.toHaveBeenCalled()
})
})
+95
View File
@@ -0,0 +1,95 @@
import { $connection } from '@/store/session'
import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
export interface DesktopFsRemotePicker {
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
}
let remotePicker: DesktopFsRemotePicker | null = null
export function setDesktopFsRemotePicker(next: DesktopFsRemotePicker | null) {
remotePicker = next
}
function connectionCacheKey(connection: HermesConnection | null) {
if (!connection) {
return 'local:'
}
return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
}
export function desktopFsCacheKey() {
return connectionCacheKey($connection.get())
}
export function isDesktopFsRemoteMode() {
return $connection.get()?.mode === 'remote'
}
function fsPath(endpoint: string, filePath: string) {
return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}`
}
function bridge() {
const desktop = window.hermesDesktop
if (!desktop) {
throw new Error('Hermes Desktop bridge is unavailable')
}
return desktop
}
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readDir(path)
}
return desktop.api<HermesReadDirResult>({ path: fsPath('list', path) })
}
export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readFileText(path)
}
return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) })
}
export async function readDesktopFileDataUrl(path: string): Promise<string> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readFileDataUrl(path)
}
const result = await desktop.api<string | { dataUrl?: string }>({ path: fsPath('read-data-url', path) })
return typeof result === 'string' ? result : result.dataUrl || ''
}
export async function desktopGitRoot(path: string): Promise<string | null> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.gitRoot ? desktop.gitRoot(path) : null
}
const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) })
return result.root
}
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
if (!isDesktopFsRemoteMode()) {
return null
}
return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' })
}
export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.selectPaths(options)
}
if (!options?.directories || options.multiple !== false) {
return []
}
return remotePicker ? remotePicker.selectPaths(options) : []
}
+23 -2
View File
@@ -1,3 +1,4 @@
import { isDesktopFsRemoteMode, readDesktopFileText } from '@/lib/desktop-fs'
import type { PreviewTarget } from '@/store/preview'
const HTML_EXTENSIONS = new Set(['.htm', '.html'])
@@ -107,6 +108,26 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev
}
}
async function enrichPreviewTarget(target: PreviewTarget | null): Promise<PreviewTarget | null> {
if (!isDesktopFsRemoteMode() || !target || target.kind !== 'file' || target.previewKind === 'image') {
return target
}
try {
const result = await readDesktopFileText(target.path || target.source)
return {
...target,
binary: result.binary,
byteSize: result.byteSize,
language: result.language || target.language,
large: (result.byteSize ?? 0) > 512 * 1024,
mimeType: result.mimeType
}
} catch {
return target
}
}
export async function normalizeOrLocalPreviewTarget(
rawTarget: string,
cwd?: string | null
@@ -115,12 +136,12 @@ export async function normalizeOrLocalPreviewTarget(
const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)
if (normalized) {
return normalized
return enrichPreviewTarget(normalized)
}
} catch {
// Running Electron may still have the old HTML-only preview IPC. Fall
// through to renderer-side local classification so text/images still open.
}
return localPreviewTarget(rawTarget, cwd)
return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd))
}
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest'
import type { ModelOptionProvider } from '@/types/hermes'
import { effectiveVisibleKeys, modelVisibilityKey } from './model-visibility'
import {
effectiveVisibleKeys,
emptyProviderSentinelKey,
isProviderSentinel,
modelVisibilityKey
} from './model-visibility'
const provider = (slug: string, models: string[]): ModelOptionProvider => ({
models,
@@ -34,4 +39,48 @@ describe('model visibility', () => {
expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(false)
})
it('preserves hidden-provider sentinel without re-adding defaults', () => {
// User explicitly hid all models for "nous" — sentinel marks this choice.
const stored = new Set([emptyProviderSentinelKey('nous')])
const visible = effectiveVisibleKeys(stored, [
provider('nous', ['hermes-3-llama-3.1-70b', 'hermes-3-llama-3.1-8b']),
provider('ollama', ['qwen3:latest'])
])
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))).toBe(false)
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
// Sentinel itself is stripped from the result.
expect(visible.has(emptyProviderSentinelKey('nous'))).toBe(false)
// Other providers still get defaults.
expect(visible.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true)
})
it('restores model when toggling on after hiding all', () => {
// Simulates: user hid all "nous" models, then toggles one back on.
const stored = new Set([
emptyProviderSentinelKey('nous'),
modelVisibilityKey('ollama', 'qwen3:latest')
])
// After toggle: sentinel removed, one model added.
const afterToggle = new Set(stored)
afterToggle.delete(emptyProviderSentinelKey('nous'))
afterToggle.add(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))
const visible = effectiveVisibleKeys(afterToggle, [
provider('nous', ['hermes-3-llama-3.1-70b', 'hermes-3-llama-3.1-8b']),
provider('ollama', ['qwen3:latest'])
])
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))).toBe(true)
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
})
it('sentinel key helper produces correct format', () => {
expect(emptyProviderSentinelKey('openai')).toBe('openai::')
expect(isProviderSentinel('openai::')).toBe(true)
expect(isProviderSentinel('openai::gpt-4o')).toBe(false)
})
})
+25 -2
View File
@@ -13,6 +13,19 @@ export const DEFAULT_VISIBLE_PER_PROVIDER = 50
* that contain a single colon, e.g. `model:tag`). */
export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}`
/** Sentinel key suffix stored when the user explicitly hides ALL models for a
* provider. Distinguishes "user hid everything" from "never customized" so
* `effectiveVisibleKeys` does not re-add defaults for that provider. */
export const EMPTY_PROVIDER_SENTINEL = ''
/** Build the sentinel key for a provider whose last model was toggled off. */
export const emptyProviderSentinelKey = (provider: string): string =>
modelVisibilityKey(provider, EMPTY_PROVIDER_SENTINEL)
/** Check whether a stored key is a provider-hidden sentinel. */
export const isProviderSentinel = (key: string): boolean =>
key.endsWith('::')
/** A model and its optional `-fast` sibling, collapsed into one logical row.
* `id` is the canonical (base) model; `fastId` is the fast variant if present. */
export interface ModelFamily {
@@ -116,9 +129,12 @@ export function effectiveVisibleKeys(
for (const provider of providers) {
const providerPrefix = `${provider.slug}::`
const hasStoredProvider = [...stored].some(key => key.startsWith(providerPrefix))
const hasStoredProvider = [...stored].some(
key => key.startsWith(providerPrefix) && !isProviderSentinel(key)
)
const hasSentinel = stored.has(emptyProviderSentinelKey(provider.slug))
if (hasStoredProvider) {
if (hasStoredProvider || hasSentinel) {
continue
}
@@ -129,5 +145,12 @@ export function effectiveVisibleKeys(
}
}
// Strip sentinel keys — they are bookkeeping, not real visibility entries.
for (const key of [...next]) {
if (isProviderSentinel(key)) {
next.delete(key)
}
}
return next
}
+25
View File
@@ -5,12 +5,14 @@ import type { SessionInfo } from '@/types/hermes'
import {
$activeSessionId,
$attentionSessionIds,
$connection,
$currentCwd,
$workingSessionIds,
applyConfiguredDefaultProjectDir,
getRecentlySettledSessionIds,
mergeSessionPage,
sessionPinId,
setCurrentCwd,
setSessionAttention,
setSessionWorking,
workspaceCwdForNewSession
@@ -184,9 +186,12 @@ describe('mergeSessionPage', () => {
describe('workspaceCwdForNewSession', () => {
afterEach(() => {
applyConfiguredDefaultProjectDir(null)
$connection.set(null)
$currentCwd.set('')
$activeSessionId.set(null)
window.localStorage.removeItem('hermes.desktop.workspace-cwd')
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-a.default')
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-b.default')
})
it('prefers the configured default over the sticky remembered workspace', () => {
@@ -216,6 +221,26 @@ describe('workspaceCwdForNewSession', () => {
expect($currentCwd.get()).toBe('/live/session/path')
expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
})
it('keeps remote workspace memory separate from local and other remotes', () => {
window.localStorage.setItem('hermes.desktop.workspace-cwd', '/local/project')
$currentCwd.set('/live/session/path')
$connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never)
expect(workspaceCwdForNewSession()).toBe('')
setCurrentCwd('/backend/project-a')
expect(workspaceCwdForNewSession()).toBe('/backend/project-a')
$connection.set({ baseUrl: 'http://backend-b', mode: 'remote' } as never)
expect(workspaceCwdForNewSession()).toBe('')
setCurrentCwd('/backend/project-b')
expect(workspaceCwdForNewSession()).toBe('/backend/project-b')
$connection.set(null)
expect(workspaceCwdForNewSession()).toBe('/local/project')
})
})
describe('getRecentlySettledSessionIds', () => {
+30 -14
View File
@@ -10,13 +10,19 @@ type Updater<T> = T | ((current: T) => T)
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
// Cached copy of Settings → Sessions → Default project directory. The main
// process persists this in project-dir.json, but the renderer must also honor it
// when seeding $currentCwd — otherwise PR #37586's sticky localStorage home dir
// wins and new sessions ignore the user's explicit picker choice.
let configuredDefaultProjectDir = ''
export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
if (connection?.mode !== 'remote') {
return WORKSPACE_CWD_KEY
}
const base = encodeURIComponent(connection.baseUrl || 'remote')
const profile = encodeURIComponent(connection.profile || 'default')
return `${WORKSPACE_CWD_KEY}.remote.${base}.${profile}`
}
export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || ''
export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir
@@ -54,6 +60,13 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
}
}
const remembered = getRememberedWorkspaceCwd()
if ($connection.get()?.mode === 'remote') {
seedLiveCwd(remembered)
return
}
if (configured) {
const { cwd } = await sanitize(configured)
seedLiveCwd(cwd)
@@ -61,8 +74,10 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
return
}
const { cwd } = await sanitize(getRememberedWorkspaceCwd())
seedLiveCwd(cwd)
if (remembered) {
const { cwd } = await sanitize(remembered)
seedLiveCwd(cwd)
}
}
export function applyConfiguredDefaultProjectDir(dir: null | string | undefined): void {
@@ -238,15 +253,16 @@ export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive,
export const setCurrentCwd = (next: Updater<string>) => {
updateAtom($currentCwd, next)
// Keep localStorage in sync with the atom: a real folder is remembered, an
// empty cwd clears the key (|| null → removeItem).
persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
persistString(workspaceCwdKey(), $currentCwd.get().trim() || null)
}
/** Workspace for a brand-new chat. Explicit Settings override wins; otherwise
* fall back to the sticky last-used folder, then whatever is already live. */
export const workspaceCwdForNewSession = (): string =>
getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
export const workspaceCwdForNewSession = (): string => {
if ($connection.get()?.mode === 'remote') {
return getRememberedWorkspaceCwd()
}
return getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
}
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
+19 -1
View File
@@ -3504,6 +3504,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# 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 <name>)
# 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 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
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 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# 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 <name>) 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 (.+?)\]')
+713
View File
@@ -0,0 +1,713 @@
"""Automation Blueprints — parameterized automation blueprints with typed slots.
A *blueprint* is a one-place definition of an automation that every surface
renders natively:
* Dashboard / GUI app -> a form (one field per slot)
* CLI / TUI / messenger -> a pre-filled ``/blueprint`` slash command
* Agent -> a seed prompt; it asks for any blank/ambiguous slot
* Docs catalog -> a copy-paste command + a ``hermes://`` deep-link
The single source of truth is the slot schema below. ``blueprint_form_schema``
emits what a form renderer needs; ``blueprint_slash_command`` emits the flattened
one-line command; ``fill_blueprint`` validates user-supplied values and turns a
blueprint into a ``cron.jobs.create_job`` kwargs dict (so there is no second job
engine). The form-where-there's-a-screen / agent-fills-where-there's-a-chat
split both consume this same module.
Design choice: users never type raw cron. A blueprint carries a fixed recurrence
in ``schedule_template`` and parameterizes only the human-friendly parts
(time-of-day, weekday set). Blueprints needing full flexibility expose a ``text``
slot named ``schedule`` that passes through verbatim.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
__all__ = [
"BlueprintSlot",
"AutomationBlueprint",
"CATALOG",
"get_blueprint",
"blueprint_form_schema",
"blueprint_slash_command",
"blueprint_deeplink",
"blueprint_catalog_entry",
"fill_blueprint",
"BlueprintFillError",
"WEEKDAY_PRESETS",
]
class BlueprintFillError(ValueError):
"""Raised when supplied slot values fail validation."""
# Slot types the renderers understand.
_SLOT_TYPES = frozenset({"time", "enum", "text", "weekdays"})
# Named weekday recurrences -> cron day-of-week field.
WEEKDAY_PRESETS: Dict[str, str] = {
"everyday": "*",
"weekdays": "1-5",
"weekends": "0,6",
}
@dataclass(frozen=True)
class BlueprintSlot:
"""A single fillable field on a blueprint."""
name: str
type: str
label: str
default: Any = None
options: tuple = () # for type="enum": allowed values
optional: bool = False
help: str = ""
# When False, ``options`` are suggestions rather than a closed set —
# any value is accepted (e.g. the deliver slot, where the real set of
# valid platforms depends on the user's configured gateways and is
# validated downstream by the cron scheduler).
strict: bool = True
def __post_init__(self) -> None:
if self.type not in _SLOT_TYPES:
raise ValueError(f"unknown slot type {self.type!r} (slot {self.name})")
@dataclass(frozen=True)
class AutomationBlueprint:
"""A parameterized automation blueprint."""
key: str
title: str
description: str
category: str
# Cron expression with ``{slot}`` placeholders, e.g. "{minute} {hour} * * {dow}".
# Placeholders are filled from resolved slot values (time -> minute/hour,
# weekdays -> dow). A literal cron string with no placeholders = fixed schedule.
schedule_template: str
# Seed instruction for the agent / the cron job prompt; may contain {slot}s.
prompt_template: str
slots: List[BlueprintSlot] = field(default_factory=list)
deliver_default: str = "origin"
skills: tuple = () # skills the job loads before running
tags: tuple = ()
# ---------------------------------------------------------------------------
# Curated in-repo catalog
# ---------------------------------------------------------------------------
_TIME = lambda default="08:00": BlueprintSlot( # noqa: E731 - concise factory
name="time", type="time", label="What time?", default=default,
help="24h local time, e.g. 08:00",
)
_DELIVER = BlueprintSlot(
name="deliver", type="enum", label="Where to deliver?",
default="origin", options=("origin", "local", "telegram", "discord", "email"),
optional=False, strict=False,
help="origin = the chat you set this up from (or your configured home "
"channel when created from the dashboard); local = save only, no message; "
"or any connected platform name",
)
CATALOG: List[AutomationBlueprint] = [
AutomationBlueprint(
key="morning-brief",
title="Morning briefing",
description="A short daily briefing: today's calendar, weather, and "
"anything urgent waiting on you.",
category="daily",
schedule_template="{minute} {hour} * * *",
prompt_template=(
"Produce a concise morning briefing for the user: today's calendar "
"events, the local weather, and any urgent items. Keep it short and "
"scannable. If no data sources are connected, give a brief "
"good-morning with the date and offer to connect calendar/email."
),
slots=[_TIME("08:00"), _DELIVER],
tags=("daily", "briefing"),
),
AutomationBlueprint(
key="important-mail",
title="Important-mail monitor",
description="Check your inbox periodically and ping you ONLY about mail "
"that actually needs attention.",
category="email",
schedule_template="*/{interval_min} * * * *",
prompt_template=(
"Check the user's inbox for new messages since the last run. Surface "
"ONLY mail matching: {criteria}. Score candidates with the urgency "
"classifier and deliver only what clears the bar; if nothing does, "
"respond with [SILENT]. Requires a connected mail source; if none is "
"configured, explain how to connect one and stop."
),
slots=[
BlueprintSlot(
name="interval_min", type="enum", label="How often?",
default="30", options=("15", "30", "60"),
help="minutes between checks",
),
BlueprintSlot(
name="criteria", type="text",
label="Only notify me if the mail…",
default="needs a reply today, is from my manager or family, "
"or mentions a deadline",
),
_DELIVER,
],
tags=("email", "monitor"),
),
AutomationBlueprint(
key="weekly-review",
title="Weekly review",
description="A weekly recap: what got done, what's still open, and "
"what's coming up.",
category="weekly",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Produce a weekly review for the user: what was accomplished this "
"week, still-open items, and next week's calendar. Pull from "
"connected sources. Keep it tight."
),
slots=[
_TIME("18:00"),
BlueprintSlot(
name="day", type="enum", label="Which day?",
default="sunday",
options=("sunday", "monday", "friday", "saturday"),
),
_DELIVER,
],
tags=("weekly", "review"),
),
AutomationBlueprint(
key="workday-start",
title="Workday start reminder",
description="A weekday nudge with your agenda and top priorities.",
category="daily",
schedule_template="{minute} {hour} * * 1-5",
prompt_template=(
"Give the user a brief weekday start-of-day nudge: today's calendar "
"and the 1-3 highest-priority things to focus on, inferred from "
"recent context and any task tools. Encouraging, short, one message."
),
slots=[_TIME("09:00"), _DELIVER],
tags=("daily", "focus"),
),
AutomationBlueprint(
key="custom-reminder",
title="Custom reminder",
description="A recurring reminder in your own words, on your schedule.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template="Remind the user: {what}",
slots=[
BlueprintSlot(name="what", type="text", label="Remind me to…",
default="take a break and stretch"),
_TIME("14:00"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("reminder",),
),
AutomationBlueprint(
key="evening-winddown",
title="Evening wind-down",
description="An end-of-day check-in: tomorrow's calendar at a glance "
"and anything you should prep tonight.",
category="daily",
schedule_template="{minute} {hour} * * *",
prompt_template=(
"Give the user a short evening wind-down: tomorrow's calendar, any "
"early commitments to prep for, and one gentle nudge to wrap up "
"loose ends from today. Keep it calm and brief — one message. If no "
"calendar is connected, just offer a friendly sign-off and the "
"weather for tomorrow."
),
slots=[_TIME("21:00"), _DELIVER],
tags=("daily", "evening"),
),
AutomationBlueprint(
key="news-digest",
title="Topic news digest",
description="A recurring digest on a topic you care about — deduped "
"against what was already sent, so only genuinely new items land.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Search the web for new and noteworthy items about: {topic}. "
"Dedupe against what you sent in previous runs — only include "
"genuinely new developments. Deliver a tight digest of at most "
"{count} bullets, each one line with a link. If nothing new since "
"last run, respond with [SILENT]."
),
slots=[
BlueprintSlot(
name="topic", type="text", label="What topic?",
default="AI and technology",
help="a subject, product, person, or search phrase",
),
_TIME("18:00"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="weekdays",
options=tuple(WEEKDAY_PRESETS.keys()),
),
BlueprintSlot(
name="count", type="enum", label="How many bullets?",
default="5", options=("3", "5", "8"),
),
_DELIVER,
],
tags=("digest", "research"),
),
AutomationBlueprint(
key="bill-renewal-watch",
title="Bills & renewals reminder",
description="A heads-up before a recurring payment, subscription "
"renewal, or due date — so nothing auto-charges by surprise.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Remind the user about an upcoming payment or renewal: {what}. "
"Phrase it as an actionable heads-up (e.g. 'review or cancel before "
"it renews'), not just a notification. One short message."
),
slots=[
BlueprintSlot(
name="what", type="text", label="What's due?",
default="my streaming subscription renews soon",
),
_TIME("10:00"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("reminder", "finance"),
),
AutomationBlueprint(
key="habit-checkin",
title="Habit check-in",
description="A recurring nudge to keep a habit on track and reflect "
"on whether you did it.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Nudge the user about their habit: {habit}. Ask whether they did it "
"today, keep it warm and non-judgmental, and offer a one-line word "
"of encouragement. One short message."
),
slots=[
BlueprintSlot(
name="habit", type="text", label="Which habit?",
default="20 minutes of reading",
),
_TIME("20:00"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("habit", "wellbeing"),
),
AutomationBlueprint(
key="hydration-move",
title="Hydration & movement nudge",
description="A periodic nudge during the day to drink water, stand up, "
"and stretch.",
category="general",
# NOTE: cron minute-field steps (*/90) wrap per hour — */90 and */120
# both degrade to hourly. Use an hour-field step instead so the chosen
# cadence is what actually fires.
schedule_template="0 {start_hour}-{end_hour}/{interval_hours} * * 1-5",
prompt_template=(
"Send the user a brief, friendly nudge to drink some water, stand "
"up, and stretch for a moment. Vary the wording each time so it "
"doesn't feel robotic. One short line."
),
slots=[
BlueprintSlot(
name="interval_hours", type="enum", label="How often?",
default="1", options=("1", "2", "3"),
help="hours between nudges",
),
BlueprintSlot(
name="start_hour", type="enum", label="Start hour",
default="9", options=("7", "8", "9", "10"),
help="first hour of the active window (24h)",
),
BlueprintSlot(
name="end_hour", type="enum", label="End hour",
default="17", options=("16", "17", "18", "19"),
help="last hour of the active window (24h)",
),
_DELIVER,
],
tags=("wellbeing", "focus"),
),
AutomationBlueprint(
key="meal-plan",
title="Weekly meal plan",
description="A weekly meal plan plus a consolidated grocery list, "
"tuned to your diet and how much time you have to cook.",
category="weekly",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Build the user a meal plan for the coming week: {meals} per day, "
"suited to a {diet} diet and roughly {effort} cooking effort. "
"Include a consolidated grocery list grouped by aisle. Keep blueprints "
"simple and skimmable."
),
slots=[
BlueprintSlot(
name="diet", type="enum", label="Diet?",
default="no restrictions",
options=("no restrictions", "vegetarian", "vegan",
"high-protein", "low-carb"),
),
BlueprintSlot(
name="meals", type="enum", label="Meals per day?",
default="dinner only",
options=("dinner only", "lunch and dinner", "all three"),
),
BlueprintSlot(
name="effort", type="enum", label="Cooking effort?",
default="quick", options=("quick", "medium", "ambitious"),
),
_TIME("17:00"),
BlueprintSlot(
name="day", type="enum", label="Which day?",
default="sunday",
options=("sunday", "monday", "friday", "saturday"),
),
_DELIVER,
],
tags=("weekly", "food"),
),
AutomationBlueprint(
key="learn-daily",
title="Daily learning drip",
description="One bite-sized lesson a day on a topic you want to learn, "
"building progressively over time.",
category="daily",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Teach the user one bite-sized lesson about: {topic}. Build on "
"earlier lessons so it progresses rather than repeating. Keep it to "
"a couple of short paragraphs with one concrete example, and end "
"with a single question to check understanding."
),
slots=[
BlueprintSlot(
name="topic", type="text", label="Learn about…",
default="Spanish vocabulary",
),
_TIME("08:30"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="weekdays",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("learning", "daily"),
),
AutomationBlueprint(
key="gratitude-journal",
title="Gratitude & reflection prompt",
description="A gentle evening prompt to reflect on the day and note "
"what went well.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Send the user a short, warm reflection prompt for the end of the "
"day — invite them to note one thing that went well, one thing they "
"are grateful for, and one small win. If they reply, acknowledge it "
"kindly. One message."
),
slots=[
_TIME("21:30"),
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("wellbeing", "reflection"),
),
AutomationBlueprint(
key="on-this-day",
title="On-this-day discovery",
description="A daily dose of curiosity: a notable historical event, "
"fact, or word for the day.",
category="daily",
schedule_template="{minute} {hour} * * *",
prompt_template=(
"Give the user one interesting '{flavor}' item for today — keep it "
"short, surprising, and genuinely interesting. One or two sentences, "
"no filler."
),
slots=[
BlueprintSlot(
name="flavor", type="enum", label="What kind?",
default="on this day in history",
options=("on this day in history", "word of the day",
"science fact", "quote of the day"),
),
_TIME("07:30"),
_DELIVER,
],
tags=("daily", "curiosity"),
),
]
_CATALOG_BY_KEY = {r.key: r for r in CATALOG}
def get_blueprint(key: str) -> Optional[AutomationBlueprint]:
return _CATALOG_BY_KEY.get(key)
# ---------------------------------------------------------------------------
# Renderers
# ---------------------------------------------------------------------------
def blueprint_form_schema(blueprint: AutomationBlueprint) -> Dict[str, Any]:
"""Emit the JSON a form renderer (dashboard / GUI) needs for this blueprint."""
return {
"key": blueprint.key,
"title": blueprint.title,
"description": blueprint.description,
"category": blueprint.category,
"tags": list(blueprint.tags),
"fields": [
{
"name": s.name,
"type": s.type,
"label": s.label,
"default": s.default,
"options": list(s.options),
"optional": s.optional,
"strict": s.strict,
"help": s.help,
}
for s in blueprint.slots
],
}
def blueprint_slash_command(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
"""Build the flattened ``/blueprint <key> slot=val …`` command string.
Uses each slot's default when ``values`` is omitted, so the docs/dashboard
can show a ready-to-paste command. Free-text slots are quoted.
"""
values = values or {}
parts = [f"/blueprint {blueprint.key}"]
for s in blueprint.slots:
val = values.get(s.name, s.default)
if val is None or val == "":
if s.optional:
continue
val = ""
sval = str(val)
if s.type == "text" or " " in sval:
sval = '"' + sval.replace('"', '\\"') + '"'
parts.append(f"{s.name}={sval}")
return " ".join(parts)
def blueprint_deeplink(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
"""Build the ``hermes://blueprint/<key>?slot=val`` deep-link URL."""
from urllib.parse import quote, urlencode
values = values or {}
query = {}
for s in blueprint.slots:
val = values.get(s.name, s.default)
if val not in (None, ""):
query[s.name] = str(val)
qs = ("?" + urlencode(query)) if query else ""
return f"hermes://blueprint/{quote(blueprint.key)}{qs}"
def _humanize_schedule(blueprint: AutomationBlueprint) -> str:
"""A short human-readable description of when a blueprint runs (defaults)."""
sched = blueprint.schedule_template
if sched.startswith("*/"):
iv = next((s for s in blueprint.slots if s.name == "interval_min"), None)
every = (iv.default if iv else None) or sched.split("/")[1].split()[0]
return f"every {every} minutes"
if "{interval_hours}" in sched:
iv = next((s for s in blueprint.slots if s.name == "interval_hours"), None)
every = str((iv.default if iv else None) or "1")
scope = "weekdays, " if "* * 1-5" in sched else ""
return f"{scope}every hour" if every == "1" else f"{scope}every {every} hours"
time_slot = next((s for s in blueprint.slots if s.type == "time"), None)
when = time_slot.default if time_slot else None
if "* * 1-5" in sched:
return f"weekdays at {when}" if when else "every weekday"
if "{dow}" in sched:
day_slot = next((s for s in blueprint.slots if s.name in ("day", "recurrence")), None)
scope = (day_slot.default if day_slot else "") or ""
if scope and when:
return f"{scope} at {when}"
return f"at {when}" if when else "on a schedule"
if when:
return f"daily at {when}"
return "on a schedule"
def blueprint_catalog_entry(blueprint: AutomationBlueprint) -> Dict[str, Any]:
"""Unified serializable shape for a blueprint — used by the docs generator
and the dashboard API. Combines the form schema, the ready-to-paste slash
command, the deep-link URL, and a human-readable schedule.
"""
return {
**blueprint_form_schema(blueprint),
"schedule": blueprint.schedule_template,
"scheduleHuman": _humanize_schedule(blueprint),
"command": blueprint_slash_command(blueprint),
"appUrl": blueprint_deeplink(blueprint),
}
# ---------------------------------------------------------------------------
# Fill + validate + translate to a create_job spec
# ---------------------------------------------------------------------------
_TIME_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
_DAY_TO_DOW = {
"sunday": "0", "monday": "1", "tuesday": "2", "wednesday": "3",
"thursday": "4", "friday": "5", "saturday": "6",
}
def _resolve_schedule(blueprint: AutomationBlueprint, values: Dict[str, Any]) -> str:
"""Fill the schedule_template placeholders from resolved slot values."""
sched = blueprint.schedule_template
# A free-text `schedule` slot passes through verbatim (full flexibility).
if "schedule" in values and values["schedule"]:
return str(values["schedule"])
repl: Dict[str, str] = {}
# time -> minute/hour
time_val = values.get("time")
if "{minute}" in sched or "{hour}" in sched:
if not time_val:
raise BlueprintFillError("a time is required")
m = _TIME_RE.match(str(time_val).strip())
if not m:
raise BlueprintFillError(f"invalid time {time_val!r} — use HH:MM (24h)")
repl["hour"] = str(int(m.group(1)))
repl["minute"] = str(int(m.group(2)))
# weekday set -> dow
if "{dow}" in sched:
if "recurrence" in values:
preset = str(values.get("recurrence", "everyday")).lower()
if preset not in WEEKDAY_PRESETS:
raise BlueprintFillError(
f"unknown recurrence {preset!r} — one of {', '.join(WEEKDAY_PRESETS)}"
)
repl["dow"] = WEEKDAY_PRESETS[preset]
elif "day" in values:
day = str(values.get("day", "")).lower()
if day not in _DAY_TO_DOW:
raise BlueprintFillError(f"unknown day {day!r}")
repl["dow"] = _DAY_TO_DOW[day]
else:
repl["dow"] = "*"
# interval (minutes) for */N schedules
if "{interval_min}" in sched:
iv = str(values.get("interval_min", "")).strip()
if not iv.isdigit() or int(iv) <= 0:
raise BlueprintFillError(f"invalid interval {iv!r} — minutes as a positive integer")
repl["interval_min"] = iv
# Any remaining {slot} placeholders are filled verbatim from validated
# enum/text slot values (e.g. an hour-range window). Enum options have
# already been checked in fill_blueprint, so these are safe to interpolate.
for name in re.findall(r"\{(\w+)\}", sched):
if name not in repl and name in values:
repl[name] = str(values[name])
try:
return sched.format(**repl)
except KeyError as e: # pragma: no cover - template/slot mismatch is a dev error
raise BlueprintFillError(f"schedule template missing value for {e}") from e
def fill_blueprint(
blueprint: AutomationBlueprint,
values: Dict[str, Any],
*,
origin: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Validate ``values`` and return ``cron.jobs.create_job`` kwargs.
Missing required (non-optional) slots raise BlueprintFillError naming the
slot, so a form can show field errors and the agent knows what to ask.
Unknown slot names are rejected (a typo'd ``tiem=07:15`` must not silently
create a job with the default time). Enum values are checked against their
options. The result is passed straight to ``create_job`` no second schema.
"""
known = {s.name for s in blueprint.slots}
unknown = sorted(set(values) - known)
if unknown:
raise BlueprintFillError(
f"unknown slot{'s' if len(unknown) > 1 else ''}: "
f"{', '.join(unknown)} — valid: {', '.join(s.name for s in blueprint.slots)}"
)
resolved: Dict[str, Any] = {}
for s in blueprint.slots:
raw = values.get(s.name, s.default)
if raw in (None, ""):
if s.optional:
continue
raise BlueprintFillError(f"missing required value: {s.name} ({s.label})")
if s.type == "enum" and s.strict and s.options and str(raw) not in {str(o) for o in s.options}:
raise BlueprintFillError(
f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}"
)
resolved[s.name] = raw
schedule = _resolve_schedule(blueprint, resolved)
# Render the prompt with whatever slots it references.
try:
prompt = blueprint.prompt_template.format(**resolved)
except KeyError as e:
raise BlueprintFillError(f"blueprint prompt missing value for {e}") from e
spec: Dict[str, Any] = {
"prompt": prompt,
"schedule": schedule,
"name": blueprint.title,
"deliver": resolved.get("deliver", blueprint.deliver_default),
}
if blueprint.skills:
spec["skills"] = list(blueprint.skills)
if origin is not None:
spec["origin"] = origin
return spec
+1
View File
@@ -0,0 +1 @@
"""Scripts shipped with the cron subsystem (runnable via ``python3 -m cron.scripts.<name>``)."""
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Classify candidate items by urgency/importance and emit only the urgent ones.
The proactive-monitor pattern: a fetch step (a watcher script, an inbox dump, a
feed) produces a list of candidate items; this script scores each with a cheap
LLM and prints ONLY the items at or above a threshold. Below-threshold runs
print nothing, so a cron job wrapping this stays silent unless something
actually matters -- the classic urgency-monitor pattern (fetch -> classify
urgency -> surface only what's above the bar).
Design choices:
* Uses Hermes' auxiliary client with task="monitor", so the classifier model
is configured once in config.yaml (auxiliary.monitor.{provider,model}) and
can be a cheap fast model independent of the main chat model.
* Reads items as JSON (a list of objects) from stdin or --input-file.
* One LLM call scores the whole batch (cheap, single round-trip) and returns
structured scores; we filter locally.
* Empty result -> empty stdout -> the cron job's [SILENT]/empty-stdout path
suppresses delivery. No spam on quiet intervals.
Usage (standalone):
cat items.json | python classify_items.py --threshold 7 \
--criteria "Urgent if it needs a reply today or is from my manager/family"
Usage (wired to a watcher via cron, agent mode):
Ask the agent: "Every 10 minutes, run watch_http_json.py for my inbox feed,
pipe its JSON into classify_items.py with my urgency criteria, and deliver
whatever it prints. Stay silent if it prints nothing."
Item schema (flexible): each item is an object; the classifier sees the whole
object. A "title"/"subject"/"summary"/"text" field helps it judge. An "id"
field (any of id/guid/message_id/url) is echoed back so duplicates can be
deduped upstream.
"""
from __future__ import annotations
import argparse
import json
import sys
from typing import Any, Dict, List, Optional
def _eprint(*args: Any) -> None:
print(*args, file=sys.stderr)
def _load_items(input_file: Optional[str]) -> List[Dict[str, Any]]:
raw = ""
if input_file:
with open(input_file, encoding="utf-8") as f:
raw = f.read()
else:
raw = sys.stdin.read()
raw = raw.strip()
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
_eprint(f"classify_items: input is not valid JSON: {e}")
sys.exit(2)
if isinstance(data, dict):
# Allow {"items": [...]} or a single object.
if isinstance(data.get("items"), list):
return data["items"]
return [data]
if isinstance(data, list):
return [x for x in data if isinstance(x, dict)]
_eprint("classify_items: expected a JSON list or {items: [...]}")
sys.exit(2)
def _item_id(item: Dict[str, Any], index: int) -> str:
for key in ("id", "guid", "message_id", "url", "link"):
val = item.get(key)
if val:
return str(val)
return f"item-{index}"
_CLASSIFY_INSTRUCTIONS = (
"You are an urgency classifier for a proactive assistant. You will be given "
"a numbered list of items and the user's importance criteria. Score EACH "
"item from 0 (ignore entirely) to 10 (interrupt the user now). Return ONLY a "
"JSON array, one object per item, in the same order: "
'[{"index": <int>, "score": <int 0-10>, "reason": "<short>"}]. '
"No prose, no markdown fences. Be conservative: most items should score low. "
"Only score high when the item clearly meets the user's criteria."
)
def _build_prompt(items: List[Dict[str, Any]], criteria: str) -> str:
lines = [f"USER IMPORTANCE CRITERIA:\n{criteria}\n", "ITEMS:"]
for i, item in enumerate(items):
# Show a compact view; the model sees the salient fields.
view = {
k: item[k]
for k in ("title", "subject", "summary", "text", "body", "from", "sender", "url")
if k in item
}
if not view:
view = item # fall back to the whole object
lines.append(f"[{i}] {json.dumps(view, ensure_ascii=False)[:1200]}")
lines.append(
"\nReturn the JSON array of scores now (one object per item, same order)."
)
return "\n".join(lines)
def _parse_scores(content: str, n_items: int) -> Dict[int, Dict[str, Any]]:
text = (content or "").strip()
# Tolerate accidental markdown fences.
if text.startswith("```"):
text = text.strip("`")
if "\n" in text:
text = text.split("\n", 1)[1]
try:
arr = json.loads(text)
except json.JSONDecodeError:
# Last-ditch: find the first [...] block.
start = text.find("[")
end = text.rfind("]")
if start >= 0 and end > start:
try:
arr = json.loads(text[start : end + 1])
except json.JSONDecodeError:
_eprint("classify_items: could not parse classifier output")
return {}
else:
_eprint("classify_items: classifier returned no JSON array")
return {}
out: Dict[int, Dict[str, Any]] = {}
if isinstance(arr, list):
for obj in arr:
if not isinstance(obj, dict):
continue
idx = obj.get("index")
if isinstance(idx, int) and 0 <= idx < n_items:
out[idx] = obj
return out
def main() -> int:
parser = argparse.ArgumentParser(description="Classify items by urgency; emit only urgent ones.")
parser.add_argument("--criteria", required=True, help="Plain-language importance criteria.")
parser.add_argument("--threshold", type=int, default=7, help="Minimum score (0-10) to surface. Default 7.")
parser.add_argument("--input-file", default=None, help="Read items JSON from this file instead of stdin.")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format for surfaced items.")
args = parser.parse_args()
items = _load_items(args.input_file)
if not items:
# Nothing to classify -> silent. This is the common quiet-interval case.
return 0
# Import here so --help works without the package importable.
try:
from agent.auxiliary_client import call_llm
except Exception as e: # pragma: no cover - import guard
_eprint(f"classify_items: cannot import auxiliary client: {e}")
return 3
prompt = _build_prompt(items, args.criteria)
try:
resp = call_llm(
task="monitor",
messages=[{"role": "user", "content": prompt}],
max_tokens=1024,
temperature=0,
)
content = resp.choices[0].message.content
if not isinstance(content, str):
content = str(content) if content else ""
except Exception as e:
# Classification failure is NOT silent -- surface it so a broken monitor
# doesn't quietly swallow important items. Non-zero exit -> cron alerts.
_eprint(f"classify_items: classifier call failed: {e}")
return 4
scores = _parse_scores(content, len(items))
surfaced = []
for i, item in enumerate(items):
s = scores.get(i)
score = s.get("score") if isinstance(s, dict) else None
if isinstance(score, int) and score >= args.threshold:
surfaced.append((i, item, s))
if not surfaced:
# Below threshold -> silent. Empty stdout; cron suppresses delivery.
return 0
if args.format == "json":
out = [
{
"id": _item_id(item, i),
"score": s.get("score"),
"reason": s.get("reason", ""),
"item": item,
}
for (i, item, s) in surfaced
]
print(json.dumps(out, ensure_ascii=False, indent=2))
else:
blocks = []
for (i, item, s) in surfaced:
title = (
item.get("title")
or item.get("subject")
or item.get("summary")
or _item_id(item, i)
)
url = item.get("url") or item.get("link") or ""
reason = s.get("reason", "")
block = f"## [{s.get('score')}/10] {title}"
if url:
block += f"\n{url}"
if reason:
block += f"\n_{reason}_"
blocks.append(block)
print("\n\n".join(blocks))
return 0
if __name__ == "__main__":
sys.exit(main())
+154
View File
@@ -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
+257
View File
@@ -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
+129 -13
View File
@@ -1425,6 +1425,33 @@ def _build_media_placeholder(event) -> str:
return "\n".join(parts)
def _build_document_context_note(display_name: str, agent_path: str, mtype: str) -> str:
"""Context note prepended to a user turn when they attach a document.
Text documents (``text/*``) have their content inlined upstream by the
platform adapter, so the note just confirms that and records the path.
Binary documents (PDF, DOCX, XLSX, ) cannot be inlined as text. The note
must tell the agent to *extract* the text itself before answering earlier
wording ("Ask the user what they'd like you to do with it") steered the
model into punting back to the user, which is why attached PDFs/DOCX looked
"unreadable" to the agent even though it has the tools to read them.
"""
if mtype.startswith("text/"):
return (
f"[The user sent a text document: '{display_name}'. "
f"Its content has been included below. "
f"The file is also saved at: {agent_path}]"
)
return (
f"[The user sent a document: '{display_name}'. It is saved at: {agent_path}. "
f"Its text is not inlined here (it's a binary format such as PDF or DOCX). "
f"To read it, extract the document's text yourself — for example with the "
f"terminal tool or the ocr-and-documents skill — before answering, instead "
f"of asking the user to paste the contents.]"
)
def _format_duration(seconds: float) -> str:
total = int(round(seconds))
if total < 0:
@@ -7171,6 +7198,37 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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)
@@ -7669,7 +7727,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_note = (
f"[The user sent an audio file attachment: '{_display}'. "
f"It is saved at: {_agent_path}. "
f"Ask the user what they'd like you to do with it, or pass the path to a transcription or media tool.]"
f"Its content is not inlined here. If the user's request involves "
f"what the audio contains, transcribe or process it yourself — for "
f"example by passing the path to a transcription or media tool — "
f"instead of asking the user to describe it. Only ask what to do "
f"with it if their intent is genuinely unclear.]"
)
message_text = f"{_note}\n\n{message_text}"
@@ -7701,18 +7763,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# cache directories are auto-mounted at /root/.hermes/cache/* by get_cache_directory_mounts().
agent_path = to_agent_visible_cache_path(path)
if mtype.startswith("text/"):
context_note = (
f"[The user sent a text document: '{display_name}'. "
f"Its content has been included below. "
f"The file is also saved at: {agent_path}]"
)
else:
context_note = (
f"[The user sent a document: '{display_name}'. "
f"The file is saved at: {agent_path}. "
f"Ask the user what they'd like you to do with it.]"
)
context_note = _build_document_context_note(display_name, agent_path, mtype)
message_text = f"{context_note}\n\n{message_text}"
if getattr(event, "reply_to_text", None) and event.reply_to_message_id:
@@ -9237,6 +9288,71 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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)
# ────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -149,7 +149,7 @@ hermes webhook subscribe pr-review \
--deliver github_comment
```
Full automation templates gallery: [hermes-agent.nousresearch.com/docs/guides/automation-templates](https://hermes-agent.nousresearch.com/docs/guides/automation-templates)
Full automation blueprints gallery: [hermes-agent.nousresearch.com/docs/reference/automation-blueprints-catalog](https://hermes-agent.nousresearch.com/docs/reference/automation-blueprints-catalog)
Documentation: [hermes-agent.nousresearch.com](https://hermes-agent.nousresearch.com)
+318
View File
@@ -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> name-match a blueprint, then SEED THE AGENT to
ask the user for each value conversationally
/blueprint <name> slot=val fill + create the cron job directly
(the deterministic dashboard / docs / power-
user shortcut no agent turn)
The ``<name>`` 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 <name>` 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 <name>` 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 <name>` 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))
# `<name>` 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)
# `<name> 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)}"
)
+51
View File
@@ -1255,6 +1255,57 @@ class CLICommandsMixin:
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>`` name-matches a blueprint and seeds the
agent to ask the user for each value conversationally (the result's
``agent_seed``); ``/blueprint <name> 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.
+33
View File
@@ -179,6 +179,11 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
cli_only=True, args_hint="[subcommand]",
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
CommandDef("suggestions", "Review suggested automations (accept/dismiss)",
"Tools & Skills", aliases=("suggest",), args_hint="[accept|dismiss N | catalog]",
subcommands=("accept", "dismiss", "catalog", "clear")),
CommandDef("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 @@ _SLACK_RESERVED_COMMANDS = frozenset({
"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 <command>).
# 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 slack_native_slashes() -> list[tuple[str, str, str]]:
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):
+17 -1
View File
@@ -877,7 +877,9 @@ DEFAULT_CONFIG = {
# Toolsets are never touched; messaging platforms
# unaffected.
# "focus" — auto + collapse the toolset to the lean coding
# set (+ enabled MCP servers). Explicit opt-in.
# set (+ enabled MCP servers) + demote non-coding
# skill categories to names-only in the prompt's
# skill index. Explicit opt-in.
# "on" — force the prompt posture everywhere.
# "off" — disable entirely.
"coding_context": "auto",
@@ -1367,6 +1369,20 @@ DEFAULT_CONFIG = {
"timeout": 600,
"extra_body": {},
},
# Monitor — urgency/importance classifier used by the important-mail
# monitor catalog automation (cron/scripts/classify_items.py). Scores
# candidate items 0-10 against the user's criteria so only above-
# threshold items get delivered. "auto" = main chat model; override to
# a cheap fast model (e.g. openrouter google/gemini-3-flash-preview,
# haiku) since per-item scoring is high-volume and a small model is fine.
"monitor": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 60,
"extra_body": {},
},
},
"display": {
+29 -2
View File
@@ -4609,13 +4609,18 @@ def _run_npm_install_deterministic(
the working tree dirty and causes the next ``hermes update`` to stash the
lockfile repeatedly.
"""
# unicode-animations' postinstall animates to /dev/tty (bypasses
# --silent/capture_output). It no-ops when CI is set — same as the TUI
# install path and nix/lib.nix npm ci hooks.
run_env = {**os.environ, **(env or {}), "CI": "1"}
lockfile = cwd / "package-lock.json"
if lockfile.exists():
ci_cmd = [npm, "ci", *extra_args]
ci_result = subprocess.run(
ci_cmd,
cwd=cwd,
env=env,
env=run_env,
capture_output=capture_output,
text=True,
encoding="utf-8",
@@ -4630,7 +4635,7 @@ def _run_npm_install_deterministic(
return subprocess.run(
install_cmd,
cwd=cwd,
env=env,
env=run_env,
capture_output=capture_output,
text=True,
encoding="utf-8",
@@ -10327,6 +10332,8 @@ def cmd_dashboard(args):
_launch_profile not in ("default", "custom")
and not getattr(args, "isolated", False)
and not getattr(args, "open_profile", "")
# Desktop pool backends are intentionally per-profile.
and os.environ.get("HERMES_DESKTOP") != "1"
):
url = f"http://{args.host or '127.0.0.1'}:{args.port}/?profile={_launch_profile}"
if _dashboard_listening(args.host, args.port):
@@ -10426,6 +10433,26 @@ def cmd_dashboard(args):
# the missing-provider state if it matters.
print(f"⚠ Plugin discovery failed: {exc}", file=sys.stderr)
# Desktop chat uses the dashboard's in-process /api/ws gateway, which builds
# agents via tui_gateway.server._make_agent. That path only snapshots the
# tool registry — it never starts MCP discovery (the stdio TUI does that in
# tui_gateway/entry.py, which the dashboard process doesn't run). Without
# this, a profile's configured MCP servers never connect, so desktop
# sessions show no MCP tools. Spawn discovery in the background here so a
# slow/dead server can't block dashboard startup.
try:
from hermes_cli.mcp_startup import start_background_mcp_discovery
start_background_mcp_discovery(
logger=logger,
thread_name="dashboard-mcp-discovery",
)
except Exception:
logger.debug(
"Background MCP tool discovery failed at dashboard startup",
exc_info=True,
)
from hermes_cli.web_server import start_server
# The in-browser Chat tab (the embedded TUI over PTY/WebSocket) is always
+13
View File
@@ -1069,8 +1069,21 @@ class PluginManager:
self._plugin_skills.clear()
self._aux_tasks.clear()
self._context_engine = None
# Set the flag up front as a re-entrancy guard (a plugin's register()
# can transitively trigger discovery again), but reset it if the sweep
# raises so a failed scan is NOT cached as "discovered with an empty
# registry" — callers swallow the exception and would otherwise be
# permanently stranded on the early-return above (the "No web provider
# configured" class of failures).
self._discovered = True
try:
self._discover_and_load_inner()
except BaseException:
self._discovered = False
raise
def _discover_and_load_inner(self) -> None:
"""The actual discovery sweep — see :meth:`discover_and_load`."""
manifests: List[PluginManifest] = []
# 1. Bundled plugins (<repo>/plugins/<name>/)
+41
View File
@@ -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:
+153
View File
@@ -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 <N|id> create the cron job for that suggestion
/suggestions dismiss <N|id> dismiss it (latched, never re-offered)
/suggestions catalog seed the curated starter automations as pending
/suggestions clear drop accepted records (housekeeping)
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
def _fmt_pending(pending: list) -> str:
if not pending:
return (
"No suggested automations right now.\n"
"Try `/suggestions catalog` to see the curated starter set, or "
"install a 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 <number|id>"
job = store.accept_suggestion(rest, origin=origin)
if job is None:
return f"No pending suggestion matches '{rest}'. Run /suggestions to list them."
sched = job.get("schedule_display") or (job.get("job_spec", {}) or {}).get("schedule", "")
name = job.get("name", "automation")
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 <number|id>"
ok = store.dismiss_suggestion(rest)
return (
f"Dismissed. Won't suggest that again."
if ok
else f"No pending suggestion matches '{rest}'."
)
if sub == "catalog":
try:
from cron.suggestion_catalog import seed_catalog_suggestions
created = seed_catalog_suggestions()
except Exception as e:
logger.debug("catalog seed failed: %s", e)
return "Couldn't load the catalog."
if not created:
return (
"No new catalog automations to add (already offered, dismissed, "
"or your suggestion list is full). Run /suggestions to see pending."
)
added = ", ".join(c.get("title", "?") for c in created)
return f"Added {len(created)} suggestion(s): {added}.\nRun /suggestions to review."
if sub == "clear":
removed = store.clear_resolved()
return f"Cleared {removed} resolved suggestion record(s)."
return (
"Usage:\n"
" /suggestions list pending\n"
" /suggestions accept N schedule suggestion N\n"
" /suggestions dismiss N dismiss suggestion N\n"
" /suggestions catalog add curated starter automations\n"
" /suggestions clear housekeeping"
)
+331 -2
View File
@@ -920,6 +920,178 @@ class ManagedFilesPolicy:
can_change_path: bool
_FS_READDIR_HIDDEN = {
".git",
".hg",
".svn",
".cache",
".next",
".turbo",
".venv",
"__pycache__",
"build",
"dist",
"node_modules",
"target",
"venv",
}
_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024
_FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024
_FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024
_FS_PREVIEW_LANGUAGE_BY_EXT = {
".c": "c",
".conf": "ini",
".cpp": "cpp",
".css": "css",
".csv": "csv",
".go": "go",
".graphql": "graphql",
".h": "c",
".hpp": "cpp",
".html": "html",
".java": "java",
".js": "javascript",
".json": "json",
".jsx": "jsx",
".kt": "kotlin",
".lua": "lua",
".md": "markdown",
".mjs": "javascript",
".py": "python",
".rb": "ruby",
".rs": "rust",
".sh": "shell",
".sql": "sql",
".svg": "xml",
".toml": "toml",
".ts": "typescript",
".tsx": "tsx",
".txt": "text",
".xml": "xml",
".yaml": "yaml",
".yml": "yaml",
".zsh": "shell",
}
_FS_MIME_TYPES = {
".avi": "video/x-msvideo",
".bmp": "image/bmp",
".flac": "audio/flac",
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".m4a": "audio/mp4",
".mkv": "video/x-matroska",
".mov": "video/quicktime",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".ogg": "audio/ogg",
".opus": "audio/ogg; codecs=opus",
".png": "image/png",
".svg": "image/svg+xml",
".wav": "audio/wav",
".webm": "video/webm",
".webp": "image/webp",
}
def _fs_path(raw_path: str) -> Path:
raw = str(raw_path or "").strip()
if not raw:
raise HTTPException(status_code=400, detail="Path is required")
if "\0" in raw:
raise HTTPException(status_code=400, detail="Invalid path")
try:
if raw.lower().startswith("file:"):
parsed = urllib.parse.urlparse(raw)
if parsed.netloc and parsed.netloc not in {"", "localhost"}:
raise ValueError
raw = urllib.request.url2pathname(parsed.path)
candidate = Path(raw).expanduser()
if not candidate.is_absolute():
candidate = Path.cwd() / candidate
return candidate.resolve(strict=False)
except (OSError, RuntimeError, ValueError):
raise HTTPException(status_code=400, detail="Invalid path")
def _fs_mime_type(path: Path) -> str:
suffix = path.suffix.lower()
if suffix in _FS_MIME_TYPES:
return _FS_MIME_TYPES[suffix]
guessed, _ = mimetypes.guess_type(str(path))
return guessed or "application/octet-stream"
def _fs_looks_binary(data: bytes) -> bool:
if not data:
return False
if b"\0" in data:
return True
suspicious = sum(1 for byte in data if byte < 32 and byte not in {9, 10, 13})
return suspicious / len(data) > 0.12
def _fs_regular_file(path: Path) -> tuple[Path, os.stat_result]:
target = _fs_path(str(path))
try:
st = target.stat()
except FileNotFoundError:
raise HTTPException(status_code=404, detail="File not found")
except NotADirectoryError:
raise HTTPException(status_code=404, detail="File not found")
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "Invalid path")
if stat.S_ISDIR(st.st_mode):
raise HTTPException(status_code=400, detail="Path points to a directory")
if not stat.S_ISREG(st.st_mode):
raise HTTPException(status_code=400, detail="Only regular files can be read")
return target, st
def _fs_find_git_root(start: Path) -> str | None:
directory = start
for _ in range(50):
try:
if (directory / ".git").exists():
return str(directory)
except OSError:
return None
parent = directory.parent
if parent == directory:
return None
directory = parent
return None
def _fs_default_cwd() -> str:
cfg_terminal = load_config().get("terminal") or {}
raw = str(cfg_terminal.get("cwd") or os.environ.get("TERMINAL_CWD") or "").strip()
if raw and raw not in {".", "auto", "cwd"}:
try:
candidate = Path(raw).expanduser().resolve(strict=False)
if candidate.is_dir():
return str(candidate)
except (OSError, RuntimeError):
pass
return str(Path.cwd())
def _fs_git_branch(cwd: str) -> str:
try:
result = subprocess.run(
["git", "-C", cwd, "branch", "--show-current"],
capture_output=True,
text=True,
timeout=2,
check=False,
)
return result.stdout.strip() if result.returncode == 0 else ""
except Exception:
return ""
def _media_serve_roots() -> list[Path]:
"""Directories ``GET /api/media`` is allowed to read from.
@@ -1264,6 +1436,87 @@ async def delete_managed_file(payload: ManagedFileDelete, request: Request):
return {"ok": True, "path": display_path, **_managed_response_meta(policy)}
@app.get("/api/fs/list")
async def fs_list(path: str):
target = _fs_path(path)
try:
entries = []
with os.scandir(target) as scan:
for entry in scan:
if entry.name in _FS_READDIR_HIDDEN:
continue
entries.append({
"name": entry.name,
"path": str(target / entry.name),
"isDirectory": entry.is_dir(follow_symlinks=False),
})
entries.sort(key=lambda item: (not item["isDirectory"], item["name"].lower(), item["name"]))
return {"entries": entries}
except FileNotFoundError:
return {"entries": [], "error": "ENOENT"}
except NotADirectoryError:
return {"entries": [], "error": "ENOTDIR"}
except PermissionError:
return {"entries": [], "error": "EACCES"}
except OSError as exc:
return {"entries": [], "error": getattr(exc, "strerror", None) or "read-error"}
@app.get("/api/fs/read-text")
async def fs_read_text(path: str):
target, st = _fs_regular_file(_fs_path(path))
if st.st_size > _FS_TEXT_SOURCE_MAX_BYTES:
raise HTTPException(status_code=413, detail="File too large")
bytes_to_read = min(st.st_size, _FS_TEXT_PREVIEW_MAX_BYTES)
try:
with target.open("rb") as handle:
data = handle.read(bytes_to_read)
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "File read failed")
return {
"binary": _fs_looks_binary(data[:4096]),
"byteSize": st.st_size,
"language": _FS_PREVIEW_LANGUAGE_BY_EXT.get(target.suffix.lower(), "text"),
"mimeType": _fs_mime_type(target),
"path": str(target),
"text": data.decode("utf-8", errors="replace"),
"truncated": st.st_size > _FS_TEXT_PREVIEW_MAX_BYTES,
}
@app.get("/api/fs/read-data-url")
async def fs_read_data_url(path: str):
target, st = _fs_regular_file(_fs_path(path))
if st.st_size > _FS_DATA_URL_MAX_BYTES:
raise HTTPException(status_code=413, detail="File too large")
try:
encoded = base64.b64encode(target.read_bytes()).decode("ascii")
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "File read failed")
return {"dataUrl": f"data:{_fs_mime_type(target)};base64,{encoded}"}
@app.get("/api/fs/git-root")
async def fs_git_root(path: str):
target = _fs_path(path)
try:
st = target.stat()
start = target if stat.S_ISDIR(st.st_mode) else target.parent
except OSError:
start = target
return {"root": _fs_find_git_root(start)}
@app.get("/api/fs/default-cwd")
async def fs_default_cwd():
cwd = _fs_default_cwd()
return {"cwd": cwd, "branch": _fs_git_branch(cwd)}
@app.get("/api/status")
async def get_status():
current_ver, latest_ver = check_config_version()
@@ -6525,6 +6778,75 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None):
return {"ok": True}
# ---------------------------------------------------------------------------
# Automation Blueprints — parameterized automation blueprints. 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.
#
@@ -9121,11 +9443,18 @@ class RawConfigUpdate(BaseModel):
@app.get("/api/config/raw")
async def get_config_raw(profile: Optional[str] = None):
"""Raw config.yaml text plus its resolved path.
``path`` is resolved inside ``_profile_scope`` so the Config page header
shows the file the switched profile actually reads/writes /api/status's
``config_path`` is machine-global and always reports the dashboard
process's own profile, which is wrong under the global profile switcher.
"""
with _profile_scope(profile):
path = get_config_path()
if not path.exists():
return {"yaml": ""}
return {"yaml": path.read_text(encoding="utf-8")}
return {"yaml": "", "path": str(path)}
return {"yaml": path.read_text(encoding="utf-8"), "path": str(path)}
@app.put("/api/config/raw")
+1 -1
View File
@@ -21,7 +21,7 @@ let
# Single npm deps fetch from the workspace root lockfile.
# All workspace packages share this derivation.
npmDepsHash = "sha256-mVWPJLIYa4EA0iNPiSVLAPzjjnWdky2HbG5mwApy1lo=";
npmDepsHash = "sha256-jN6rD+vVhTCWz3lFZzlmFYXmcMRPTtYWy3XVSiDYbvM=";
npmDeps = pkgs.fetchNpmDeps {
inherit src;
+32 -17
View File
@@ -102,6 +102,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dnd-core": "^14.0.1",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
@@ -113,6 +114,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dnd-html5-backend": "^14.0.3",
"react-dom": "^19.2.5",
"react-router-dom": "^7.17.0",
"react-shiki": "^0.9.3",
@@ -133,7 +135,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",
"@types/node": "^24.12.0",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.1",
@@ -203,6 +205,16 @@
}
}
},
"apps/desktop/node_modules/@types/node": {
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"apps/desktop/node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
@@ -14559,9 +14571,9 @@
}
},
"node_modules/joi": {
"version": "18.1.2",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz",
"integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==",
"version": "18.2.1",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz",
"integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -21418,7 +21430,7 @@
},
"devDependencies": {
"@eslint/js": "^9",
"@types/node": "^24.12.0",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@typescript-eslint/eslint-plugin": "^8",
"@typescript-eslint/parser": "^8",
@@ -21891,13 +21903,13 @@
}
},
"ui-tui/node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
"undici-types": "~7.18.0"
}
},
"ui-tui/node_modules/ansi-regex": {
@@ -22024,13 +22036,6 @@
"node": ">=14.17"
}
},
"ui-tui/node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
"ui-tui/node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
@@ -22151,7 +22156,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/node": "^24.13.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -22210,6 +22215,16 @@
}
}
},
"web/node_modules/@types/node": {
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"web/node_modules/globals": {
"version": "17.6.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
+133 -2
View File
@@ -20,6 +20,7 @@ import tempfile
import threading
import time
from collections import defaultdict
from contextlib import suppress
from typing import Callable, Dict, List, Optional, Any, Tuple
logger = logging.getLogger(__name__)
@@ -68,6 +69,43 @@ from gateway.platforms.base import (
from tools.url_safety import is_safe_url
async def _wait_for_ready_or_bot_exit(
ready_event: asyncio.Event,
bot_task: asyncio.Task,
timeout: float,
) -> None:
"""Wait until Discord is ready, or surface early bot startup failure.
``discord.py`` startup errors (including SOCKS/proxy failures from
aiohttp-socks/python-socks) happen inside ``Bot.start()``. If ``connect()``
only waits on ``ready_event``, a dead background task still burns the full
ready timeout before the gateway supervisor can reconnect. Racing the ready
event against the bot task keeps failures fast and preserves the original
exception for logging/classification.
"""
ready_task = asyncio.create_task(ready_event.wait())
try:
done, _pending = await asyncio.wait(
{ready_task, bot_task},
timeout=timeout,
return_when=asyncio.FIRST_COMPLETED,
)
if not done:
raise asyncio.TimeoutError
if bot_task in done:
exc = bot_task.exception()
if exc is not None:
raise exc
if not ready_task.done():
raise RuntimeError("Discord bot task exited before ready")
await ready_task
finally:
if not ready_task.done():
ready_task.cancel()
with suppress(asyncio.CancelledError):
await ready_task
def _find_discord_windows_bundled_opus(discord_module: Any = None) -> Optional[str]:
"""Return discord.py's bundled Windows opus DLL path when present."""
if sys.platform != "win32":
@@ -622,6 +660,10 @@ class DiscordAdapter(BasePlatformAdapter):
self._typing_tasks: Dict[str, asyncio.Task] = {}
self._bot_task: Optional[asyncio.Task] = None
self._post_connect_task: Optional[asyncio.Task] = None
# True while disconnect() is intentionally closing discord.py. The
# bot task's done callback uses this to distinguish an operator/service
# shutdown from a runtime websocket crash.
self._disconnecting = False
# Dedup cache: prevents duplicate bot responses when Discord
# RESUME replays events after reconnects.
self._dedup = MessageDeduplicator()
@@ -634,6 +676,65 @@ class DiscordAdapter(BasePlatformAdapter):
# scanning channel.history() on cache miss (cold start / restart).
self._last_self_message_id: Dict[str, str] = {}
def _handle_bot_task_done(self, task: asyncio.Task) -> None:
"""Surface post-startup discord.py task exits to the gateway supervisor.
discord.py reconnects normal gateway interruptions internally. When its
top-level ``Bot.start()`` task actually exits after the adapter has been
marked running, the Discord websocket is dead while the Hermes gateway
process can remain alive. Treat that split-brain state as a retryable
fatal adapter error so ``GatewayRunner._handle_adapter_fatal_error`` can
remove this adapter and queue Discord for the existing reconnect watcher.
"""
if getattr(self, "_disconnecting", False):
# Intentional service/operator shutdown. Drain the task result so
# asyncio doesn't emit "exception was never retrieved" warnings.
with suppress(asyncio.CancelledError, Exception):
task.exception()
return
# Ignore stale callbacks from an older client if a reconnect already
# installed a newer Bot.start() task on this adapter instance.
if self._bot_task is not None and task is not self._bot_task:
with suppress(asyncio.CancelledError, Exception):
task.exception()
return
if not self._running:
# Startup failures are handled by _wait_for_ready_or_bot_exit() in
# connect(); this callback is only for post-startup split-brain.
with suppress(asyncio.CancelledError, Exception):
task.exception()
return
try:
exc = task.exception()
except asyncio.CancelledError:
return
except Exception as err: # pragma: no cover - defensive
exc = err
if exc is None:
message = "Discord gateway task exited without an exception"
else:
message = f"Discord gateway task exited: {exc}"
logger.error("[%s] %s", self.name, message, exc_info=exc if exc else False)
self._set_fatal_error("discord_gateway_task_exited", message, retryable=True)
async def _notify() -> None:
try:
await self._notify_fatal_error()
except Exception as notify_exc: # pragma: no cover - defensive logging
logger.warning(
"[%s] Failed to notify gateway supervisor about Discord task exit: %s",
self.name,
notify_exc,
exc_info=True,
)
asyncio.create_task(_notify())
async def connect(self) -> bool:
"""Connect to Discord and start receiving events."""
if not DISCORD_AVAILABLE:
@@ -900,25 +1001,55 @@ class DiscordAdapter(BasePlatformAdapter):
self._register_slash_commands()
# Start the bot in background
self._disconnecting = False
self._bot_task = asyncio.create_task(self._client.start(self.config.token))
self._bot_task.add_done_callback(self._handle_bot_task_done)
# Wait for ready
await asyncio.wait_for(self._ready_event.wait(), timeout=30)
# Wait for ready, but fail fast if discord.py's background startup
# task dies first (for example on SOCKS/proxy connect errors).
await _wait_for_ready_or_bot_exit(self._ready_event, self._bot_task, timeout=30)
self._running = True
return True
except asyncio.TimeoutError:
logger.error("[%s] Timeout waiting for connection to Discord", self.name, exc_info=True)
# Cancel the background bot task so it cannot fire on_message after
# this adapter is discarded. Without this, the task keeps running and
# a later successful reconnect leaves two active Discord clients that
# each process every message, producing duplicate threads/responses.
await self._cancel_bot_task()
self._release_platform_lock()
return False
except Exception as e: # pragma: no cover - defensive logging
logger.error("[%s] Failed to connect to Discord: %s", self.name, e, exc_info=True)
# Same zombie-client hazard as the timeout branch: the background
# client.start() task may already be running when a later setup
# step raises. Cancel it so the discarded adapter cannot connect.
await self._cancel_bot_task()
self._release_platform_lock()
return False
async def _cancel_bot_task(self) -> None:
"""Cancel and await the background client.start() task, if running."""
if self._bot_task and not self._bot_task.done():
self._bot_task.cancel()
try:
await self._bot_task
except (asyncio.CancelledError, Exception):
pass
self._bot_task = None
async def disconnect(self) -> None:
"""Disconnect from Discord."""
self._disconnecting = True
# Cancel the bot task before closing the client. If connect() timed out
# and returned False, the background client.start() task may still be
# running; calling client.close() alone is not enough to stop it because
# discord.py's reconnect loop can ignore the closed flag while a
# WebSocket handshake is in flight. Explicitly cancelling the task here
# ensures the zombie client cannot receive or dispatch any further events.
await self._cancel_bot_task()
# Clean up all active voice connections before closing the client
for guild_id in list(self._voice_clients.keys()):
try:
+1 -1
View File
@@ -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"]
+3 -2
View File
@@ -272,8 +272,9 @@ def main():
# (well above current catalog size) lets the full catalog land in the
# index instead of being truncated at an arbitrary build-time limit.
SOURCE_LIMITS = {
# ClawHub had 49,698+ skills as of May 2026; 200k leaves headroom.
"clawhub": 200_000,
# 0 = unbounded catalog walk (max_items=0 in ClawHubSource). A positive
# limit bounds the walk and also enables the interactive 12s budget.
"clawhub": 0,
"lobehub": 100_000,
"browse-sh": 5_000,
"claude-marketplace": 5_000,
+1
View File
@@ -63,6 +63,7 @@ AUTHOR_MAP = {
"thomas.paquette@gmail.com": "RyTsYdUp",
"techxacm@gmail.com": "ProgramCaiCai",
"266365592+bmoore210@users.noreply.github.com": "bmoore210",
"123150002+deaneeth@users.noreply.github.com": "deaneeth",
"157839748+psionic73@users.noreply.github.com": "psionic73",
"manishbyatroy@gmail.com": "manishbyatroy",
"chilltulpa@gmail.com": "TheGardenGallery",
+20 -10
View File
@@ -312,6 +312,10 @@ class TestEditFormatSteering:
assert "mode='patch'" in brief
assert "V4A" in brief
assert "write_file" in brief # new files authored, not patched
# Codex-family harnesses ship apply_patch (V4A) as the ONLY editor and
# instruct it even for single-file edits — never nudge replace mode.
assert "single-file" in brief
assert "mode='replace'" not in brief
def test_anthropic_family_gets_replace_nudge(self, tmp_path):
_git_init(tmp_path)
@@ -368,21 +372,27 @@ class TestProfiles:
assert cc.GENERAL_PROFILE.toolset is None
assert cc.GENERAL_PROFILE.guidance == ""
def test_skill_demotion_scoped_to_coding_posture(self, tmp_path):
# Coding posture demotes clearly-non-coding categories to names-only
# in the index (never hides them — agent-created skills are the
# model's project memory and must stay recallable by name).
# Coding-adjacent categories keep full entries (deny-list semantics).
def test_skill_demotion_gated_on_focus(self, tmp_path):
# Names-only demotion is opt-in via focus mode — the default (auto)
# and forced (on) postures leave the skill index untouched. Under
# focus, clearly-non-coding categories are demoted (never hidden) and
# coding-adjacent ones keep full entries (deny-list semantics).
_git_init(tmp_path)
for raw in ("auto", "on", "focus"):
for raw in ("auto", "on"):
mode = cc.resolve_runtime_mode(
platform="cli", cwd=tmp_path, config={"agent": {"coding_context": raw}}
)
assert mode.is_coding is True
compact = mode.compact_skill_categories()
assert "social-media" in compact and "smart-home" in compact
for kept in ("github", "devops", "software-development", "data-science"):
assert kept not in compact
assert mode.compact_skill_categories() == frozenset()
focus = cc.resolve_runtime_mode(
platform="cli", cwd=tmp_path,
config={"agent": {"coding_context": "focus"}},
)
assert focus.is_coding is True
compact = focus.compact_skill_categories()
assert "social-media" in compact and "smart-home" in compact
for kept in ("github", "devops", "software-development", "data-science"):
assert kept not in compact
# General posture demotes nothing.
general = cc.resolve_runtime_mode(platform="telegram", cwd=tmp_path, config={})
assert general.compact_skill_categories() == frozenset()
+8 -3
View File
@@ -3,7 +3,11 @@
import pytest
from unittest.mock import patch, MagicMock
from agent.context_compressor import ContextCompressor, SUMMARY_PREFIX
from agent.context_compressor import (
ContextCompressor,
HISTORICAL_TASK_HEADING,
SUMMARY_PREFIX,
)
@pytest.fixture()
@@ -157,7 +161,7 @@ class TestCompress:
result = c.compress(msgs)
combined = "\n".join(str(m.get("content", "")) for m in result)
assert "## Active Task" in combined
assert HISTORICAL_TASK_HEADING in combined
assert "Please fix the compression summary failure" in combined
assert "read_file" in combined
assert "agent/context_compressor.py" in combined
@@ -1213,7 +1217,8 @@ class TestCompressWithClient:
"""When the summary lands as standalone role='user' (e.g. head ends
with assistant/tool), the message body must include the explicit
'--- END OF CONTEXT SUMMARY ---' marker. Without it, weak models
read the verbatim past user request quoted in '## Active Task' as
read the verbatim past user request quoted in the historical task
snapshot as
fresh input (#11475, #14521).
"""
mock_response = MagicMock()
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import hermes_time
from agent.context_compressor import ContextCompressor
from agent.context_compressor import ContextCompressor, HISTORICAL_TASK_HEADING
def _compressor() -> ContextCompressor:
@@ -98,7 +98,7 @@ def test_clock_failure_omits_rule_but_compaction_still_runs():
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
assert "TEMPORAL ANCHORING" not in prompt
# Structured template still intact.
assert "## Active Task" in prompt
assert HISTORICAL_TASK_HEADING in prompt
def test_anchoring_rule_uses_date_from_hermes_time_now():
+18 -11
View File
@@ -1,9 +1,9 @@
"""Regression coverage for #35344: a resumed session must not let a stale
``## Active Task`` from an inherited compaction handoff hijack the reply to a
historical task snapshot from an inherited compaction handoff hijack the reply to a
new, unrelated user message.
The failure mode (real report): a lineage was compacted, producing a handoff
whose ``## Active Task`` described task A. The lineage was resumed later and
whose historical task snapshot described task A. The lineage was resumed later and
the user asked about an unrelated task B. The model answered with A because
the handoff's resume directive outranked the fresh ask.
@@ -16,14 +16,15 @@ named reverse-signal verbs. Two invariants guard the resume path specifically:
pre-fix stale handoff cannot keep its "resume exactly" directive forever.
2. The current handoff prefix contains an unambiguous "latest message wins /
discard stale Active Task" rule, so an unrelated new ask is privileged over
the inherited ``## Active Task``.
discard stale historical task" rule, so an unrelated new ask is privileged over
the inherited task snapshot.
These are content/structural assertions (no live model call) they pin the
mechanism that makes the stale task historical rather than active.
"""
from agent.context_compressor import (
HISTORICAL_TASK_HEADING,
SUMMARY_PREFIX,
LEGACY_SUMMARY_PREFIX,
ContextCompressor,
@@ -48,13 +49,17 @@ _OLD_CONFLICTING_PREFIX = (
def test_latest_message_wins_over_inherited_active_task():
"""The handoff must explicitly privilege the latest user message over a
stale ``## Active Task`` — the core #35344 contract."""
stale historical task snapshot the core #35344 contract."""
lower = SUMMARY_PREFIX.lower()
assert "latest user message" in lower
assert "## active task" in lower
assert HISTORICAL_TASK_HEADING.lower() in lower
# Conflict-resolution must be explicit, not implied.
assert "wins" in lower or "supersede" in lower
assert "discard" in lower
# The "consistent -> use as background" carveout licensed stale-task
# resumption on topic overlap (#41607, #38364) — it must stay gone.
assert "you may use the summary as background" not in lower
assert "topic overlap" in lower
def test_no_resume_exactly_directive_can_hijack():
@@ -69,7 +74,7 @@ def test_resumed_stale_handoff_gets_renormalized_to_current_prefix():
prefix when re-normalized on re-compaction so the "resume exactly"
directive cannot survive into a resumed session."""
stale_body = (
"## Active Task\n"
f"{HISTORICAL_TASK_HEADING}\n"
"User asked: 'Migrate the billing module to Stripe'\n\n"
"## Goal\nMigrate billing.\n"
)
@@ -86,13 +91,15 @@ def test_resumed_stale_handoff_gets_renormalized_to_current_prefix():
# current latest-message-wins framing.
assert "resume exactly" not in renormalized.lower()
assert renormalized.startswith(SUMMARY_PREFIX)
assert "wins" in renormalized.lower()
assert ("wins" in renormalized.lower()
or "priority" in renormalized.lower()
or "supersede" in renormalized.lower())
def test_legacy_prefix_handoff_also_renormalized():
"""The same upgrade applies to the oldest ``[CONTEXT SUMMARY]:`` handoff
format that may sit in a long-lived resumed lineage."""
legacy = f"{LEGACY_SUMMARY_PREFIX} ## Active Task\nUser asked: 'task A'"
legacy = f"{LEGACY_SUMMARY_PREFIX} {HISTORICAL_TASK_HEADING}\nUser asked: 'task A'"
renormalized = ContextCompressor._with_summary_prefix(legacy)
assert renormalized.startswith(SUMMARY_PREFIX)
assert LEGACY_SUMMARY_PREFIX not in renormalized
@@ -107,7 +114,7 @@ def test_inherited_handoff_detected_in_resumed_protected_head():
Task read as live intent)."""
messages = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": f"{SUMMARY_PREFIX}\n## Active Task\nUser asked: 'task A'"},
{"role": "user", "content": f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nUser asked: 'task A'"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "Unrelated task B: what's the capital of France?"},
]
@@ -129,7 +136,7 @@ def test_historical_prefixed_handoff_detected_and_stripped():
stale 'resume exactly' text as a fresh turn."""
messages = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": f"{_OLD_CONFLICTING_PREFIX}\n## Active Task\nUser asked: 'task A'"},
{"role": "user", "content": f"{_OLD_CONFLICTING_PREFIX}\n{HISTORICAL_TASK_HEADING}\nUser asked: 'task A'"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "Unrelated task B"},
]
+56 -2
View File
@@ -18,7 +18,13 @@ the agent repeatedly re-surfacing already-cancelled work across turns.
These tests pin the post-fix invariants so the conflict cannot regress.
"""
from agent.context_compressor import SUMMARY_PREFIX
from agent.context_compressor import (
HISTORICAL_IN_PROGRESS_HEADING,
HISTORICAL_PENDING_ASKS_HEADING,
HISTORICAL_REMAINING_WORK_HEADING,
HISTORICAL_TASK_HEADING,
SUMMARY_PREFIX,
)
def test_no_resume_exactly_directive():
@@ -30,8 +36,22 @@ def test_latest_message_wins_on_conflict():
"""The prefix must explicitly say latest user message wins on conflict."""
lower = SUMMARY_PREFIX.lower()
assert "latest user message" in lower
assert HISTORICAL_TASK_HEADING.lower() in lower
assert HISTORICAL_PENDING_ASKS_HEADING.lower() in lower
assert HISTORICAL_REMAINING_WORK_HEADING.lower() in lower
# Must have an explicit conflict-resolution rule.
assert "wins" in lower or "supersede" in lower or "discard" in lower
assert "wins" in lower or "supersede" in lower or "discard" in lower or "priority" in lower
def test_handoff_sections_are_framed_as_historical():
"""The summary headings referenced in the prefix must sound historical,
not like live instructions for the current turn."""
lower = SUMMARY_PREFIX.lower()
assert "## active task" not in lower
assert "## pending user asks" not in lower
assert "## remaining work" not in lower
assert HISTORICAL_TASK_HEADING.lower() in lower
assert HISTORICAL_IN_PROGRESS_HEADING.lower() in lower
def test_reverse_signals_called_out():
@@ -60,3 +80,37 @@ def test_memory_authority_preserved():
assert "MEMORY.md" in SUMMARY_PREFIX
assert "USER.md" in SUMMARY_PREFIX
assert "authoritative" in SUMMARY_PREFIX
def test_no_background_consistency_carveout():
"""The "consistent → use as background" carveout licensed stale-task
resumption on topic overlap (#41607, #38364, #42812). It must stay gone,
and the prefix must explicitly neutralize topic overlap."""
lower = SUMMARY_PREFIX.lower()
assert "you may use the summary as background" not in lower
assert "topic overlap" in lower
def test_replaced_prefixes_are_frozen_for_renormalization():
"""Every retired SUMMARY_PREFIX must be frozen into
_HISTORICAL_SUMMARY_PREFIXES, otherwise summaries persisted by older
builds lose detection/renormalization after an upgrade. The carveout-era
prefix is the latest retiree."""
from agent.context_compressor import (
_HISTORICAL_SUMMARY_PREFIXES,
ContextCompressor,
)
carveout_era = [
p for p in _HISTORICAL_SUMMARY_PREFIXES
if "you may use the summary as background" in p
]
assert carveout_era, "carveout-era prefix missing from frozen tuple"
# The live prefix must never be one of the frozen ones.
assert SUMMARY_PREFIX not in _HISTORICAL_SUMMARY_PREFIXES
# Detection + strip must work for every frozen prefix.
for old_prefix in _HISTORICAL_SUMMARY_PREFIXES:
content = old_prefix + "\n## Summary body"
assert ContextCompressor._is_context_summary_content(content)
stripped = ContextCompressor._strip_summary_prefix(content)
assert not stripped.startswith(old_prefix)
+245
View File
@@ -0,0 +1,245 @@
"""Tests for Automation Blueprints — the parameterized automation blueprint 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 <name>` (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)
+198
View File
@@ -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"
+87 -3
View File
@@ -266,11 +266,12 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
),
)
async def fake_wait_for(awaitable, timeout):
awaitable.close()
async def fake_wait_for_ready(ready_event, bot_task, timeout):
raise asyncio.TimeoutError()
monkeypatch.setattr(discord_platform.asyncio, "wait_for", fake_wait_for)
monkeypatch.setattr(
discord_platform, "_wait_for_ready_or_bot_exit", fake_wait_for_ready
)
ok = await adapter.connect()
@@ -279,6 +280,89 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
assert adapter._platform_lock_identity is None
@pytest.mark.asyncio
async def test_connect_timeout_cancels_bot_task(monkeypatch):
"""Regression: connect() timeout must cancel _bot_task so the zombie
Discord client cannot fire on_message after the adapter is discarded.
Without this fix, the orphaned task eventually completes its WebSocket
handshake and a subsequent successful reconnect leaves two live clients
that each process every message, producing duplicate threads.
"""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
intents = SimpleNamespace(
message_content=False, dm_messages=False, guild_messages=False,
members=False, voice_states=False,
)
monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents)
class NeverReadyBot(FakeBot):
"""Bot whose start() never fires on_ready — simulates a slow gateway handshake."""
async def start(self, token):
await asyncio.Event().wait() # hang forever
monkeypatch.setattr(
discord_platform.commands,
"Bot",
lambda **kwargs: NeverReadyBot(
intents=kwargs["intents"],
proxy=kwargs.get("proxy"),
allowed_mentions=kwargs.get("allowed_mentions"),
),
)
async def fake_wait_for_ready(ready_event, bot_task, timeout):
raise asyncio.TimeoutError()
monkeypatch.setattr(
discord_platform, "_wait_for_ready_or_bot_exit", fake_wait_for_ready
)
ok = await adapter.connect()
assert ok is False
assert adapter._bot_task is None, (
"_bot_task must be cancelled and cleared on connect() timeout; "
"leaving it alive creates a zombie Discord client that produces duplicate threads"
)
@pytest.mark.asyncio
async def test_disconnect_cancels_running_bot_task(monkeypatch):
"""Regression: disconnect() must cancel _bot_task even when connect() timed out.
_dispose_unused_adapter calls disconnect() on adapters whose connect() returned
False. If _bot_task was still running (zombie), disconnect() must cancel it.
"""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
# Simulate a zombie bot_task that never finishes (as if discord.py is mid-handshake)
async def _forever():
await asyncio.Event().wait() # hang forever
zombie_task = asyncio.create_task(_forever())
adapter._bot_task = zombie_task
adapter._client = AsyncMock()
adapter._post_connect_task = None
adapter._voice_clients = {}
adapter._running = True
adapter._ready_event = asyncio.Event()
await adapter.disconnect()
# The task must have been cancelled (done + cancelled) and cleared from the adapter.
assert adapter._bot_task is None, "disconnect() must clear _bot_task"
assert zombie_task.done(), "disconnect() must have awaited the bot task to completion"
assert zombie_task.cancelled(), "disconnect() must cancel the zombie bot task"
@pytest.mark.asyncio
async def test_connect_does_not_wait_for_slash_sync(monkeypatch):
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
@@ -0,0 +1,57 @@
"""Tests for the document context note prepended to user turns with attachments.
A user who attaches a PDF / DOCX in chat used to see the agent treat it as
"unreadable" because the context note told the model to "Ask the user what
they'd like you to do with it" — steering it away from extracting the text it
is perfectly capable of reading. These tests pin the contract:
- text documents: note confirms the (adapter-)inlined content + records path.
- binary documents (PDF/DOCX/): note tells the agent to extract the text
itself and never tells it to punt back to the user.
"""
import importlib
import pytest
gateway_run = importlib.import_module("gateway.run")
_build_document_context_note = gateway_run._build_document_context_note
class TestTextDocumentNote:
@pytest.mark.parametrize("mtype", ["text/plain", "text/markdown", "text/csv"])
def test_text_note_mentions_included_content_and_path(self, mtype):
note = _build_document_context_note("notes.txt", "/cache/doc_notes.txt", mtype)
assert "text document" in note
assert "notes.txt" in note
assert "/cache/doc_notes.txt" in note
assert "included below" in note
class TestBinaryDocumentNote:
@pytest.mark.parametrize(
"mtype",
[
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/octet-stream",
],
)
def test_binary_note_guides_extraction(self, mtype):
note = _build_document_context_note("contract.pdf", "/cache/doc_contract.pdf", mtype)
# Records the path so the agent can open it.
assert "/cache/doc_contract.pdf" in note
# Tells the agent to read it by extracting the text...
assert "extract" in note.lower()
# ...and does NOT steer it into punting back to the user (the bug).
assert "ask the user" not in note.lower()
assert "paste" in note.lower()
def test_binary_note_distinct_from_text_note(self):
text_note = _build_document_context_note("a.txt", "/c/a.txt", "text/plain")
pdf_note = _build_document_context_note("a.pdf", "/c/a.pdf", "application/pdf")
assert text_note != pdf_note
# The text path claims content is inlined; the binary path must not.
assert "included below" in text_note
assert "included below" not in pdf_note
@@ -134,6 +134,10 @@ async def test_audio_attachment_context_note_format():
assert "audio file attachment" in result.lower()
# Should NOT contain the voice-message transcription wrapper text
assert "voice message" not in result.lower()
# Guides the agent to transcribe/process the file itself rather than
# punting back to the user (same bug class as the PDF/DOCX note).
assert "transcri" in result.lower()
assert "ask the user what they'd like" not in result.lower()
# ---------------------------------------------------------------------------
+7 -5
View File
@@ -336,20 +336,22 @@ class TestSlackNativeSlashes:
)
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 <alias>``.
"""
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)
@@ -82,6 +82,29 @@ class TestUnifiedDashboardRouting:
# Profile HERMES_HOME dropped so the child binds the machine root.
assert "HERMES_HOME" not in env
def test_desktop_profile_backend_skips_machine_dashboard_reroute(self, main_mod, monkeypatch):
"""A desktop-spawned named-profile backend (HERMES_DESKTOP=1) must NOT
reroute into the machine dashboard. The reroute re-execs as the default
profile and exits, so the desktop never sees a ready backend boot
loop. The guard keeps desktop pool backends per-profile."""
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
listening_calls = []
monkeypatch.setattr(
main_mod, "_dashboard_listening",
lambda host, port: listening_calls.append(1) or False,
)
execs = []
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args())
assert listening_calls == []
assert execs == []
def test_isolated_flag_skips_routing(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
@@ -128,3 +151,45 @@ class TestUnifiedDashboardRouting:
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args(open_profile="worker_x"))
assert execs == []
def test_dashboard_starts_mcp_discovery_for_ws_backend(self, main_mod, monkeypatch):
"""The dashboard process serves the /api/ws gateway but never runs
tui_gateway/entry.py, so it must kick off MCP discovery itself or
desktop sessions never see a profile's MCP tools."""
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
)
monkeypatch.delenv("HERMES_WEB_DIST", raising=False)
monkeypatch.setattr(main_mod, "_sync_bundled_skills_quietly", lambda: None)
monkeypatch.setattr(main_mod, "_build_web_ui", lambda *_a, **_k: True)
monkeypatch.setitem(sys.modules, "fastapi", types.SimpleNamespace())
monkeypatch.setitem(sys.modules, "uvicorn", types.SimpleNamespace())
monkeypatch.setitem(
sys.modules,
"hermes_logging",
types.SimpleNamespace(setup_logging=lambda **_k: None),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.plugins",
types.SimpleNamespace(discover_plugins=lambda: None),
)
calls = []
monkeypatch.setattr(
"hermes_cli.mcp_startup.start_background_mcp_discovery",
lambda **kwargs: calls.append(kwargs),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.web_server",
types.SimpleNamespace(start_server=lambda **_kwargs: None),
)
main_mod.cmd_dashboard(_args())
assert calls == [
{
"logger": main_mod.logger,
"thread_name": "dashboard-mcp-discovery",
}
]
+34
View File
@@ -365,6 +365,40 @@ class TestPluginDiscovery:
}
assert len(non_bundled) == 1
def test_failed_discovery_is_not_cached(self, tmp_path, monkeypatch):
"""A sweep that raises must not cache 'discovered' with no plugins.
Regression for the stranded-empty-registry class of failures: callers
(e.g. tools.web_tools._ensure_web_plugins_loaded) swallow discovery
exceptions as warnings, so if a failed sweep flipped ``_discovered``
permanently, every later call would early-return against an empty
registry ("No web provider configured") for the process lifetime.
"""
plugins_dir = tmp_path / "hermes_test" / "plugins"
_make_plugin_dir(plugins_dir, "retry_plugin")
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
mgr = PluginManager()
def _boom(self_inner):
raise RuntimeError("sweep failed")
monkeypatch.setattr(PluginManager, "_discover_and_load_inner", _boom)
with pytest.raises(RuntimeError, match="sweep failed"):
mgr.discover_and_load()
assert mgr._discovered is False, "failed sweep was cached as discovered"
# A later call (with discovery healthy again) must do the real scan.
monkeypatch.undo()
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
mgr.discover_and_load()
assert mgr._discovered is True
non_bundled = {
n: p for n, p in mgr._plugins.items()
if p.manifest.source != "bundled"
}
assert len(non_bundled) == 1
def test_discover_skips_dir_without_manifest(self, tmp_path, monkeypatch):
"""Directories without plugin.yaml are silently skipped."""
plugins_dir = tmp_path / "hermes_test" / "plugins"
+36
View File
@@ -2360,6 +2360,42 @@ class TestNewEndpoints:
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):
+188
View File
@@ -0,0 +1,188 @@
import base64
from pathlib import Path
import pytest
from hermes_cli import web_server
pytest.importorskip("starlette.testclient")
from starlette.testclient import TestClient
@pytest.fixture
def client(monkeypatch):
previous_auth_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = False
test_client = TestClient(web_server.app)
test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
try:
yield test_client
finally:
if previous_auth_required is None:
try:
delattr(web_server.app.state, "auth_required")
except AttributeError:
pass
else:
web_server.app.state.auth_required = previous_auth_required
def test_fs_list_sorts_and_hides_noise(client, tmp_path):
root = tmp_path / "project"
root.mkdir()
(root / "b.txt").write_text("b")
(root / "a_dir").mkdir()
(root / "a.txt").write_text("a")
(root / "node_modules").mkdir()
(root / ".git").mkdir()
response = client.get("/api/fs/list", params={"path": str(root)})
assert response.status_code == 200
entries = response.json()["entries"]
assert [entry["name"] for entry in entries] == ["a_dir", "a.txt", "b.txt"]
assert entries[0] == {"name": "a_dir", "path": str(root / "a_dir"), "isDirectory": True}
assert all(entry["name"] not in {".git", "node_modules"} for entry in entries)
def test_fs_list_accepts_relative_paths(client, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "rel").mkdir()
(tmp_path / "rel" / "file.txt").write_text("ok")
response = client.get("/api/fs/list", params={"path": "rel"})
assert response.status_code == 200
assert response.json()["entries"] == [
{"name": "file.txt", "path": str(tmp_path / "rel" / "file.txt"), "isDirectory": False}
]
def test_fs_list_missing_path_returns_structured_error(client, tmp_path):
response = client.get("/api/fs/list", params={"path": str(tmp_path / "missing")})
assert response.status_code == 200
assert response.json() == {"entries": [], "error": "ENOENT"}
def test_fs_read_text_matches_preview_shape_and_truncates(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "_FS_TEXT_SOURCE_MAX_BYTES", 32)
monkeypatch.setattr(web_server, "_FS_TEXT_PREVIEW_MAX_BYTES", 5)
target = tmp_path / "sample.py"
target.write_text("print('hello')")
response = client.get("/api/fs/read-text", params={"path": str(target)})
assert response.status_code == 200
assert response.json() == {
"binary": False,
"byteSize": 14,
"language": "python",
"mimeType": "text/x-python",
"path": str(target),
"text": "print",
"truncated": True,
}
def test_fs_read_text_rejects_source_over_cap(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "_FS_TEXT_SOURCE_MAX_BYTES", 4)
target = tmp_path / "large.txt"
target.write_text("12345")
response = client.get("/api/fs/read-text", params={"path": str(target)})
assert response.status_code == 413
def test_fs_read_text_flags_binary(client, tmp_path):
target = tmp_path / "blob.bin"
target.write_bytes(b"hello\x00world")
response = client.get("/api/fs/read-text", params={"path": str(target)})
assert response.status_code == 200
body = response.json()
assert body["binary"] is True
assert body["text"].startswith("hello")
def test_fs_read_data_url_returns_capped_data_url(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "_FS_DATA_URL_MAX_BYTES", 16)
target = tmp_path / "image.png"
target.write_bytes(b"pngbytes")
response = client.get("/api/fs/read-data-url", params={"path": str(target)})
assert response.status_code == 200
assert response.json() == {"dataUrl": "data:image/png;base64," + base64.b64encode(b"pngbytes").decode("ascii")}
def test_fs_read_data_url_rejects_over_cap(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "_FS_DATA_URL_MAX_BYTES", 3)
target = tmp_path / "image.png"
target.write_bytes(b"1234")
response = client.get("/api/fs/read-data-url", params={"path": str(target)})
assert response.status_code == 413
def test_fs_git_root_for_nested_file(client, tmp_path):
(tmp_path / ".git").mkdir()
nested = tmp_path / "pkg" / "mod"
nested.mkdir(parents=True)
target = nested / "file.py"
target.write_text("x")
response = client.get("/api/fs/git-root", params={"path": str(target)})
assert response.status_code == 200
assert response.json() == {"root": str(tmp_path)}
def test_fs_git_root_returns_null_outside_repo(client, tmp_path):
response = client.get("/api/fs/git-root", params={"path": str(tmp_path)})
assert response.status_code == 200
assert response.json() == {"root": None}
def test_fs_default_cwd_prefers_existing_terminal_cwd(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "load_config", lambda: {"terminal": {"cwd": str(tmp_path)}})
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "env"))
monkeypatch.setattr(web_server.Path, "cwd", lambda: tmp_path / "process")
monkeypatch.setattr(web_server, "_fs_git_branch", lambda cwd: "main")
response = client.get("/api/fs/default-cwd")
assert response.status_code == 200
assert response.json() == {"cwd": str(tmp_path), "branch": "main"}
def test_fs_default_cwd_falls_back_when_terminal_cwd_is_invalid(client, tmp_path, monkeypatch):
fallback = tmp_path / "backend"
fallback.mkdir()
monkeypatch.setattr(web_server, "load_config", lambda: {"terminal": {"cwd": "/client/missing"}})
monkeypatch.setenv("TERMINAL_CWD", "/client/missing")
monkeypatch.setattr(web_server.Path, "cwd", lambda: fallback)
monkeypatch.setattr(web_server, "_fs_git_branch", lambda cwd: "")
response = client.get("/api/fs/default-cwd")
assert response.status_code == 200
assert response.json() == {"cwd": str(fallback), "branch": ""}
def test_fs_endpoints_require_auth(tmp_path):
client = TestClient(web_server.app)
target = tmp_path / "secret.txt"
target.write_text("secret")
list_response = client.get("/api/fs/list", params={"path": str(tmp_path)})
read_response = client.get("/api/fs/read-text", params={"path": str(target)})
default_response = client.get("/api/fs/default-cwd")
assert list_response.status_code == 401
assert read_response.status_code == 401
assert default_response.status_code == 401
@@ -92,6 +92,16 @@ class TestProfileScopedConfig:
resp = client.get("/api/config/raw")
assert "Io/Volcano" not in resp.json()["yaml"]
def test_config_raw_path_reflects_requested_profile(self, client, isolated_profiles):
"""The Config page header shows /api/config/raw's ``path`` — it must
point at the SWITCHED profile's config.yaml, not the dashboard's own
(the stale-path bug reported after the profile unification launch)."""
resp = client.get("/api/config/raw", params={"profile": "worker_beta"})
assert resp.status_code == 200
assert resp.json()["path"] == str(isolated_profiles["worker_beta"] / "config.yaml")
resp = client.get("/api/config/raw")
assert resp.json()["path"] == str(isolated_profiles["default"] / "config.yaml")
def test_unknown_profile_404(self, client, isolated_profiles):
resp = client.get("/api/config", params={"profile": "ghost"})
assert resp.status_code == 404
+16
View File
@@ -140,6 +140,22 @@ class TestBuildWebUISkipsWhenFresh:
assert kwargs["encoding"] == "utf-8"
assert kwargs["errors"] == "replace"
def test_npm_install_sets_ci_to_suppress_postinstall_tty_output(self, tmp_path):
web_dir, _ = _make_web_dir(tmp_path)
(web_dir / "package-lock.json").write_text("{}", encoding="utf-8")
mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run:
_run_npm_install_deterministic(
"/usr/bin/npm",
web_dir,
env={"PYTHON": "/nix/store/python"},
)
_, kwargs = mock_run.call_args
assert kwargs["env"]["CI"] == "1"
assert kwargs["env"]["PYTHON"] == "/nix/store/python"
def test_npm_install_uses_workspace_web_scope(self, tmp_path):
web_dir, _ = _make_web_dir(tmp_path)
# Real workspace checkout: the single lockfile lives at the root, so
@@ -0,0 +1,59 @@
import asyncio
from unittest.mock import AsyncMock
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.discord.adapter import DiscordAdapter
@pytest.mark.asyncio
async def test_discord_bot_task_runtime_exit_notifies_gateway_for_reconnect(monkeypatch):
"""A post-ready discord.py websocket task crash must not leave the gateway split-brained.
Regression: producers stayed systemd-active while Discord stopped responding after
a runtime ClientOSError/ConnectionResetError. The adapter must mark Discord as a
retryable fatal platform error and notify the gateway supervisor so the existing
reconnect watcher can replace the dead adapter.
"""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="token"))
adapter._running = True
adapter._ready_event.set()
adapter._notify_fatal_error = AsyncMock()
async def crash():
raise ConnectionResetError("Cannot write to closing transport")
task = asyncio.create_task(crash())
await asyncio.sleep(0)
adapter._handle_bot_task_done(task)
await asyncio.sleep(0)
assert adapter.has_fatal_error is True
assert adapter.fatal_error_retryable is True
assert adapter.fatal_error_code == "discord_gateway_task_exited"
assert adapter.fatal_error_message is not None
assert "Cannot write to closing transport" in adapter.fatal_error_message
adapter._notify_fatal_error.assert_awaited_once()
@pytest.mark.asyncio
async def test_discord_bot_task_done_ignored_during_intentional_disconnect():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="token"))
adapter._running = True
adapter._ready_event.set()
adapter._disconnecting = True
adapter._notify_fatal_error = AsyncMock()
async def stop_cleanly():
return None
task = asyncio.create_task(stop_cleanly())
await asyncio.sleep(0)
adapter._handle_bot_task_done(task)
await asyncio.sleep(0)
assert adapter.has_fatal_error is False
adapter._notify_fatal_error.assert_not_awaited()
+18
View File
@@ -6096,6 +6096,24 @@ def test_make_agent_reads_nested_max_turns(monkeypatch):
assert mock_agent.call_args.kwargs["max_iterations"] == 200
def test_make_agent_waits_for_shared_mcp_discovery(monkeypatch):
_setup_make_agent_mocks(monkeypatch, {})
waited = []
from hermes_cli import mcp_startup
monkeypatch.setattr(
mcp_startup,
"wait_for_mcp_discovery",
lambda timeout=0.75: waited.append(timeout),
)
with patch("run_agent.AIAgent"):
server._make_agent("sid1", "key1")
assert waited == [0.75]
def test_make_agent_nested_max_turns_takes_priority(monkeypatch):
_setup_make_agent_mocks(
monkeypatch, {"agent": {"max_turns": 500}, "max_turns": 100}
+188
View File
@@ -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"
+20 -2
View File
@@ -381,9 +381,10 @@ class TestClawHubSource(unittest.TestCase):
mock_get.side_effect = side_effect
# Force the deadline to be in the past immediately.
# Force the deadline to be in the past immediately. Budget only applies
# to bounded browse walks (max_items > 0), not the index builder path.
with patch.object(ClawHubSource, "CATALOG_WALK_BUDGET_SECONDS", -1):
results = self.src._load_catalog_index()
results = self.src._load_catalog_index(max_items=10)
# Walk broke well before the 750-page cap.
self.assertLess(page_calls["n"], 750)
@@ -480,6 +481,23 @@ class TestClawHubCatalogWalkBounded(unittest.TestCase):
# Partial (bounded) walk must not be cached.
mock_write_cache.assert_not_called()
@patch("tools.skills_hub._write_index_cache")
@patch("tools.skills_hub._read_index_cache", return_value=None)
@patch("tools.skills_hub.httpx.get")
def test_max_items_zero_ignores_wall_clock_budget(
self, mock_get, _mock_read_cache, _mock_write_cache
):
"""Index builder path (max_items=0) must not truncate on the browse budget."""
page_calls = {"n": 0}
mock_get.side_effect = self._infinite_pages(page_calls)
with patch.object(ClawHubSource, "CATALOG_WALK_BUDGET_SECONDS", -1):
results = self.src._load_catalog_index(max_items=0)
# No budget -> walks until the 750-page safety cap, not ~14 pages in 12s.
self.assertEqual(page_calls["n"], 750)
self.assertEqual(len(results), 750)
@patch("tools.skills_hub._write_index_cache")
@patch("tools.skills_hub._read_index_cache", return_value=None)
@patch("tools.skills_hub.httpx.get")
@@ -0,0 +1,100 @@
"""Regression: the keyless Parallel web default must survive a failed sweep.
``web_search`` / ``web_extract`` are documented to work out of the box with
zero setup via the bundled keyless Parallel free-MCP backend. That guarantee
only holds if the bundled ``plugins/web/*`` providers are registered in
``agent.web_search_registry``. The dispatch triggers the general plugin sweep
(:func:`hermes_cli.plugins._ensure_plugins_discovered`) to do that but the
sweep can finish without registering them (its exception swallowed as a
warning, a packaged layout where it ran before the bundled tree was
importable, or a stale empty-discovery cache). When that happened, *both*
tools dead-ended on "No web {search,extract} provider configured" even though
no setup should be needed.
These tests pin the invariant that :func:`tools.web_tools._ensure_web_plugins_loaded`
guarantees the keyless default is registered regardless of the sweep's outcome,
and that the direct-registration fallback honors an explicit ``plugins.disabled``
entry. Real imports from the bundled plugin modules no provider mocking.
"""
from __future__ import annotations
import pytest
import agent.web_search_registry as reg
import hermes_cli.plugins as plugins
from tools import web_tools
@pytest.fixture(autouse=True)
def _clean_registry():
reg._reset_for_tests()
yield
reg._reset_for_tests()
def _boom(*_a, **_k):
raise RuntimeError("discovery boom")
def test_keyless_default_registered_when_discovery_raises(monkeypatch):
"""A swallowed discovery failure must not strand the keyless default."""
monkeypatch.setattr(plugins, "_ensure_plugins_discovered", _boom)
assert reg.get_provider("parallel") is None
web_tools._ensure_web_plugins_loaded()
parallel = reg.get_provider("parallel")
assert parallel is not None, "keyless Parallel default not restored"
# It is the universal keyless default precisely because it does both.
assert parallel.supports_search()
assert parallel.supports_extract()
def test_fallback_registers_full_bundled_set(monkeypatch):
"""The fix covers the whole bundled provider class, not just parallel."""
monkeypatch.setattr(plugins, "_ensure_plugins_discovered", _boom)
web_tools._ensure_web_plugins_loaded()
names = {p.name for p in reg.list_providers()}
# Every bundled backend a user might have configured should be reachable
# again, so an explicit ``web.extract_backend: firecrawl`` etc. resolves.
for expected in ("parallel", "firecrawl", "tavily", "exa"):
assert expected in names, f"{expected} missing after fallback"
def test_fallback_honors_explicit_disable(monkeypatch):
"""A backend the user turned off via plugins.disabled stays off."""
monkeypatch.setattr(plugins, "_get_disabled_plugins", lambda: {"web-parallel"})
web_tools._register_bundled_web_providers_directly()
names = {p.name for p in reg.list_providers()}
assert "parallel" not in names, "explicit disable was ignored"
# Other bundled backends are unaffected by the parallel disable.
assert "tavily" in names
def test_fallback_is_noop_when_discovery_already_registered(monkeypatch):
"""Healthy path: don't pay for the direct sweep when parallel is present."""
# Pretend the general sweep already registered the keyless default.
import importlib
class _Ctx:
def register_web_search_provider(self, provider):
reg.register_provider(provider)
importlib.import_module("plugins.web.parallel").register(_Ctx())
monkeypatch.setattr(plugins, "_ensure_plugins_discovered", lambda *a, **k: None)
calls = {"n": 0}
real = web_tools._register_bundled_web_providers_directly
def _spy():
calls["n"] += 1
real()
monkeypatch.setattr(web_tools, "_register_bundled_web_providers_directly", _spy)
web_tools._ensure_web_plugins_loaded()
assert calls["n"] == 0, "direct-registration ran on the healthy path"
+325
View File
@@ -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_name>/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/<category>/<name>/SKILL.md or skills/<name>/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
+10 -2
View File
@@ -2279,12 +2279,20 @@ class ClawHubSource(SkillSource):
# terminates well before this on `nextCursor` going None — the cap is
# a safety rail against an infinite-cursor loop.
max_pages = 750
deadline = time.monotonic() + self.CATALOG_WALK_BUDGET_SECONDS
# Wall-clock budget is for interactive browse (max_items > 0) only.
# The offline index builder passes max_items=0 and must walk the full
# catalog — a 12s cap there ships ~3k skills and trips the deploy
# health floor (20k).
deadline = (
time.monotonic() + self.CATALOG_WALK_BUDGET_SECONDS
if max_items > 0
else None
)
hit_deadline = False
hit_max_items = False
for _ in range(max_pages):
if time.monotonic() > deadline:
if deadline is not None and time.monotonic() > deadline:
hit_deadline = True
break
params: Dict[str, Any] = {"limit": 200}
+92
View File
@@ -810,6 +810,17 @@ def _ensure_web_plugins_loaded() -> None:
Mirrors :func:`tools.browser_tool._ensure_browser_plugins_loaded` exactly:
the underlying discovery call is idempotent and cheap on subsequent
invocations.
Triggering discovery is necessary but not *sufficient*: the sweep can
finish without registering the bundled web providers (its exception
swallowed below as a warning, a packaged layout where discovery ran before
the bundled tree was importable, or a stale empty-discovery cache). When
that happens the registry is empty and *both* web_search and web_extract
dead-end on "No web {search,extract} provider configured" even though the
keyless Parallel default is supposed to work with zero setup. So after
discovery we verify the keyless default landed and, if not, register the
bundled providers directly (see
:func:`_register_bundled_web_providers_directly`).
"""
try:
from hermes_cli.plugins import _ensure_plugins_discovered
@@ -822,6 +833,87 @@ def _ensure_web_plugins_loaded() -> None:
# clue in normal logs about the real cause.
logger.warning("Web plugin discovery failed (non-fatal): %s", exc)
# Belt-and-suspenders: guarantee the keyless Parallel default (the
# documented zero-setup backend for both web_search and web_extract) is
# actually registered. The lookup is a cheap dict hit on the healthy path
# (discovery already registered it → no-op); only an empty registry pays
# for the direct-registration sweep.
try:
from agent.web_search_registry import get_provider
if get_provider("parallel") is None:
_register_bundled_web_providers_directly()
except Exception as exc: # noqa: BLE001
logger.debug("Bundled web provider fallback check failed: %s", exc)
def _register_bundled_web_providers_directly() -> None:
"""Register the repo's bundled web providers without the plugin manager.
The normal path is the general plugin sweep
(:func:`hermes_cli.plugins._ensure_plugins_discovered`), which auto-loads
every ``plugins/web/<name>`` backend (they are ``kind: backend``). This
fallback exists for the runtimes where that sweep does not leave the web
registry populated so the keyless Parallel default (and any bundled
backend the user explicitly configured) keeps working instead of
surfacing a misleading "No web provider configured" error.
Imports each bundled ``plugins/web/<name>`` package and calls its
``register()`` directly against :mod:`agent.web_search_registry`. Idempotent
(re-register overwrites) and honors an explicit ``plugins.disabled`` entry
so a backend the user turned off stays off.
"""
try:
from hermes_cli.plugins import (
_get_disabled_plugins,
get_bundled_plugins_dir,
)
except Exception as exc: # noqa: BLE001
logger.debug("Bundled web provider fallback unavailable: %s", exc)
return
web_dir = get_bundled_plugins_dir() / "web"
if not web_dir.is_dir():
return
disabled = _get_disabled_plugins()
from agent.web_search_provider import WebSearchProvider
from agent.web_search_registry import register_provider
class _DirectRegistrationCtx:
"""Minimal plugin ctx exposing only web-provider registration."""
def register_web_search_provider(self, provider) -> None:
if isinstance(provider, WebSearchProvider):
register_provider(provider)
ctx = _DirectRegistrationCtx()
import importlib
for child in sorted(web_dir.iterdir()):
if not child.is_dir():
continue
if not (child / "plugin.yaml").exists() and not (child / "plugin.yml").exists():
continue
# Respect an explicit disable — match discover_and_load's key/name
# check (key ``web/<dir>``; manifest name ``web-<dir-with-dashes>``).
if (
f"web/{child.name}" in disabled
or f"web-{child.name.replace('_', '-')}" in disabled
):
continue
try:
module = importlib.import_module(f"plugins.web.{child.name}")
register_fn = getattr(module, "register", None)
if callable(register_fn):
register_fn(ctx)
except Exception as exc: # noqa: BLE001
logger.debug(
"Direct registration of bundled web provider '%s' failed: %s",
child.name, exc,
)
def web_search_tool(query: str, limit: int = 5) -> str:
"""
+11 -5
View File
@@ -3072,11 +3072,17 @@ def _make_agent(
from hermes_cli.runtime_provider import resolve_runtime_provider
# MCP tool discovery runs in a background daemon thread at startup so a
# dead server can't freeze the shell (see tui_gateway/entry.py). The agent
# snapshots its tool list once here and never re-reads it, so briefly wait
# for in-flight discovery to land before building — bounded, so a slow/dead
# server still can't block. No-op once discovery has finished (every build
# after the first during a slow startup).
# dead server can't freeze the shell. The agent snapshots its tool list
# once here and never re-reads it, so briefly wait for in-flight discovery
# to land before building — bounded, so a slow/dead server still can't
# block. Dashboard /api/ws uses hermes_cli.mcp_startup; TUI stdio keeps
# its existing tui_gateway.entry-owned thread.
try:
from hermes_cli.mcp_startup import wait_for_mcp_discovery
wait_for_mcp_discovery()
except Exception:
pass
try:
from tui_gateway.entry import wait_for_mcp_discovery
+1 -1
View File
@@ -26,7 +26,7 @@
},
"devDependencies": {
"@eslint/js": "^9",
"@types/node": "^24.12.0",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@typescript-eslint/eslint-plugin": "^8",
"@typescript-eslint/parser": "^8",
+1 -1
View File
@@ -80,7 +80,7 @@ const asWireText = (raw: unknown): string | null => {
}
if (raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)) {
return _wireDecoder.decode(raw as ArrayBufferLike)
return _wireDecoder.decode(raw as any as ArrayBuffer)
}
return null
+1 -1
View File
@@ -36,7 +36,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/node": "^24.13.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+222
View File
@@ -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<string, string> {
const out: Record<string, string> = {};
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 (
<Select value={value} onValueChange={(v) => onChange(v)}>
{field.options.map((opt) => (
<SelectOption key={opt} value={opt}>
{opt}
</SelectOption>
))}
</Select>
);
}
if (field.type === "time") {
return (
<Input
type="time"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
// text
return (
<Input
type="text"
value={value}
placeholder={field.help || field.label}
onChange={(e) => 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<Record<string, string>>(() => initialValues(blueprint));
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Card className={cn("overflow-hidden", themedBody)}>
<CardContent className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Wand2 className="h-4 w-4 shrink-0 opacity-70" />
<span className="font-medium">{blueprint.title}</span>
</div>
<p className="mt-1 text-sm opacity-70">{blueprint.description}</p>
<div className="mt-2 flex flex-wrap gap-1">
{blueprint.tags.map((t) => (
<Badge key={t} tone="secondary">
{t}
</Badge>
))}
</div>
</div>
<Button
ghost={open}
size="sm"
onClick={() => setOpen((o) => !o)}
>
{open ? "Cancel" : "Set up"}
</Button>
</div>
{open && (
<div className="space-y-3 border-t pt-3">
{blueprint.fields.map((f) => (
<div key={f.name} className="space-y-1">
<Label htmlFor={`${blueprint.key}-${f.name}`}>{f.label}</Label>
<FieldInput
field={f}
value={values[f.name] ?? ""}
onChange={(v) => setValues((prev) => ({ ...prev, [f.name]: v }))}
/>
{f.help && f.type !== "text" ? (
<p className="text-xs opacity-60">{f.help}</p>
) : null}
</div>
))}
{error ? (
<p className="text-sm text-red-500" role="alert">
{error}
</p>
) : null}
<div className="flex items-center gap-2">
<Button onClick={() => void submit()} disabled={submitting}>
{submitting ? <Spinner className="h-4 w-4" /> : <Clock className="h-4 w-4" />}
Schedule it
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}
/**
* 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<AutomationBlueprint[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(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 <p className="text-sm text-red-500">Couldn't load blueprints: {loadError}</p>;
}
if (blueprints === null) {
return (
<div className="flex items-center gap-2 opacity-70">
<Spinner className="h-4 w-4" /> Loading blueprints
</div>
);
}
if (blueprints.length === 0) {
return <p className="opacity-70">No automation blueprints available.</p>;
}
return (
<>
<Toast toast={toast} />
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{blueprints.map((r) => (
<BlueprintCard
key={r.key}
blueprint={r}
profile={profile}
showToast={showToast}
onCreated={onCreated}
/>
))}
</div>
</>
);
}
export default AutomationBlueprints;
+37 -1
View File
@@ -432,7 +432,7 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config }),
}),
getConfigRaw: () => fetchJSON<{ yaml: string }>("/api/config/raw"),
getConfigRaw: () => fetchJSON<{ yaml: string; path?: string }>("/api/config/raw"),
saveConfigRaw: (yaml_text: string) =>
fetchJSON<{ ok: boolean }>("/api/config/raw", {
method: "PUT",
@@ -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 blueprints
getAutomationBlueprints: () =>
fetchJSON<{ blueprints: AutomationBlueprint[] }>("/api/cron/blueprints"),
instantiateAutomationBlueprint: (
body: { blueprint: string; values: Record<string, string> },
profile = "default",
) =>
fetchJSON<CronJob>(`/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;
+11 -1
View File
@@ -177,9 +177,19 @@ export default function ConfigPage() {
.getDefaults()
.then(setDefaults)
.catch(() => {});
// getConfigRaw is profile-scoped (fetchJSON appends ?profile=), so its
// `path` reflects the switched profile's config.yaml. /api/status's
// config_path is machine-global (the dashboard's own profile) — wrong
// header under the global profile switcher, so it's only a fallback.
api
.getConfigRaw()
.then((resp) => {
if (resp.path) setConfigPath(resp.path);
})
.catch(() => {});
api
.getStatus()
.then((resp) => setConfigPath(resp.config_path))
.then((resp) => setConfigPath((prev) => prev ?? resp.config_path))
.catch(() => {});
}, []);
+22
View File
@@ -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<CronJob[]>([]);
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
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() {
<PluginSlot name="cron:top" />
<Toast toast={toast} />
<Segmented
value={view}
onChange={(v) => setView(v as "jobs" | "blueprints")}
options={[
{ value: "jobs", label: "Jobs" },
{ value: "blueprints", label: "Blueprints" },
]}
/>
{view === "blueprints" && (
<AutomationBlueprints
profile={selectedProfile === "all" ? "default" : selectedProfile}
onCreated={loadJobs}
/>
)}
<DeleteConfirmDialog
open={jobDelete.isOpen}
onCancel={jobDelete.cancel}
@@ -746,6 +766,7 @@ export default function CronPage() {
</div>
)}
{view === "jobs" && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
@@ -888,6 +909,7 @@ export default function CronPage() {
);
})}
</div>
)}
<PluginSlot name="cron:bottom" />
</div>
@@ -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 <name> ...`) 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
@@ -1,14 +1,16 @@
---
sidebar_position: 15
title: "Automation Templates"
description: "Ready-to-use automation recipes — scheduled tasks, GitHub event triggers, API webhooks, and multi-skill workflows"
title: "Automation Blueprints"
description: "Ready-to-use automation blueprints — scheduled tasks, GitHub event triggers, API webhooks, and multi-skill workflows"
---
# Automation Templates
# Automation Blueprints
Copy-paste recipes for common automation patterns. Each template uses Hermes's built-in [cron scheduler](/user-guide/features/cron) for time-based triggers and [webhook platform](/user-guide/messaging/webhooks) for event-driven triggers.
Copy-paste blueprints for common automation patterns. Each blueprint uses Hermes's built-in [cron scheduler](/user-guide/features/cron) for time-based triggers and [webhook platform](/user-guide/messaging/webhooks) for event-driven triggers.
Every template works with **any model** — not locked to a single provider.
Every blueprint works with **any model** — not locked to a single provider.
For parameterized blueprints with forms instead of cron syntax, see the [Automation Blueprints Catalog](/reference/automation-blueprints-catalog).
:::tip Three Trigger Types
| Trigger | How | Tool |
@@ -0,0 +1,36 @@
---
sidebar_position: 7
title: "Automation Blueprints Catalog"
description: "Ready-to-run automation blueprints — 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 automations. 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 <name>` (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`.
<AutomationBlueprintsCatalog />
## 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.
@@ -1,14 +1,16 @@
---
sidebar_position: 15
title: "自动化模板"
description: "开箱即用的自动化配方——定时任务、GitHub 事件触发、API webhook 及多技能工作流"
title: "自动化蓝图"
description: "开箱即用的自动化蓝图——定时任务、GitHub 事件触发、API webhook 及多技能工作流"
---
# 自动化模板
# 自动化蓝图
常见自动化模式的复制粘贴配方。每个模板使用 Hermes 内置的 [cron 调度器](/user-guide/features/cron) 实现基于时间的触发,使用 [webhook 平台](/user-guide/messaging/webhooks) 实现事件驱动触发。
常见自动化模式的复制粘贴蓝图。每个蓝图使用 Hermes 内置的 [cron 调度器](/user-guide/features/cron) 实现基于时间的触发,使用 [webhook 平台](/user-guide/messaging/webhooks) 实现事件驱动触发。
所有模板适用于**任意模型**——不绑定单一提供商。
所有蓝图适用于**任意模型**——不绑定单一提供商。
如需带表单的参数化蓝图(无需手写 cron 语法),请参阅[自动化蓝图目录](/reference/automation-blueprints-catalog)。
:::tip 三种触发类型
| 触发方式 | 方式 | 工具 |
@@ -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())

Some files were not shown because too many files have changed in this diff Show More