Compare commits

...

153 Commits

Author SHA1 Message Date
Teknium 9a85c99d58 fix(mcp): validate remote URLs up-front with a clear error
Port from anomalyco/opencode#25019 ("fix: handle invalid mcp urls").

Previously: a typo in `config.yaml` (missing scheme, wrong scheme,
empty string, non-string value) slipped past `_is_http()` and hit
`httpx.URL(url)` or `streamablehttp_client(url, ...)` deep in the
transport layer. That raised a generic exception which went through
the reconnect-backoff loop, so a bad URL caused _MAX_INITIAL_CONNECT_RETRIES
attempts with doubling backoff — about a minute of pointless retries
plus an opaque error — before the server was marked failed.

Now: we validate the URL once, at the top of `run()`, before
entering the retry loop. A malformed URL raises `InvalidMcpUrlError`
(a `ValueError` subclass) with a message that names the offending
server and explains exactly what was wrong. `_ready` is set and
`_error` is populated, so `start()` re-raises and the server shows
up as failed in `hermes mcp list` without any backoff burn.

Validation rules:
- Must be a string (rejects None, dict, int)
- Must be non-empty (rejects '' and whitespace-only)
- Scheme must be http or https (rejects file://, ws://, stdio://)
- Must have a non-empty host (rejects http:///, http://:8080)

Tests (21 new cases in tests/tools/test_mcp_invalid_url.py):
- TestValidUrlsAccepted: http, https, IPv6, ports, paths, query strings
- TestInvalidUrlsRejected: every rejection path above + clear error text
- TestErrorIsValueError: downstream code catching ValueError still works

E2E verified: a misconfigured server with `url: not-a-valid-url`
now fails in <0.001s with the clear error, instead of minutes of retries.

Doesn't touch stdio servers (they use `command`, not `url`) — the
validator only fires when `_is_http()` returns True.
2026-04-30 17:11:02 -07:00
Teknium e5dad4ac57 fix(agent): propagate ContextVars to concurrent tool worker threads (#18123)
Propagates ContextVars (notably `tools.approval._approval_session_key`) into concurrent tool worker threads via `copy_context().run` — mirrors `asyncio.to_thread` semantics.

Fixes approval-card cross-session misrouting in concurrent gateway traffic. Repro'd on Slack: session A's dangerous-command approval was delivered to channel B (@syahidfrd).

Salvages #16660 — core 4-LOC fix preserved, unrelated `tests/eval_018/` scope contamination dropped. Adds 5 regression guards including an AST-level source check on the real call site.

Closes #16660.

Co-authored-by: firefly <promptsiren@gmail.com>
Co-authored-by: banditburai <banditburai@users.noreply.github.com>
2026-04-30 16:26:26 -07:00
Teknium 180a7036bc feat(skills): add Shopify optional skill (Admin + Storefront GraphQL) (#18116)
Adds optional-skills/productivity/shopify — curl-based guide for the
Shopify Admin GraphQL API (products, orders, customers, inventory,
metafields, bulk operations, webhooks) and the Storefront GraphQL API.

- API version 2026-01 (current stable)
- Custom-app access tokens (shpat_...) with X-Shopify-Access-Token header
- Notes the 2026-01-01 deprecation of admin-created custom apps, points
  users at Dev Dashboard for new setups after that date
- Includes a reusable shop_gql() bash helper, cursor pagination,
  rate-limit cost inspection, GID conventions, userErrors check
- Safety section warns on destructive mutations (delete/refund/cancel)

Installs cleanly via: hermes skills install official/productivity/shopify
2026-04-30 15:58:44 -07:00
brooklyn! 8fed969618 Merge pull request #18113 from NousResearch/bb/tui-sgr-mouse-fragments
fix(tui): recover fragmented SGR mouse reports
2026-04-30 15:56:59 -07:00
Brooklyn Nicholson ded011c5a5 fix(tui): tighten SGR fragment matching 2026-04-30 17:50:49 -05:00
Brooklyn Nicholson 71b685aee0 fix(tui): recover fragmented SGR mouse reports 2026-04-30 17:43:21 -05:00
Teknium bbbce92651 feat(tui): render self-improvement review summaries in the transcript
The Ink TUI (\`hermes --tui\` + dashboard \`/chat\`) had no wiring for the
background self-improvement review. When the review fired and patched
a skill or saved a memory entry, the change landed but the user had
no visual indication it happened — only the CLI had a print surface
for the '💾 Self-improvement review: …' line.

Changes:

- tui_gateway/server.py: in _init_session, attach
  agent.background_review_callback to an _emit('review.summary',
  sid, {text}) closure. Wrapped in try/except so agents with locked
  attribute slots don't break session startup.
- ui-tui/src/app/createGatewayEventHandler.ts: handle 'review.summary'
  by routing ev.payload.text through sys(…), matching the existing
  'background.complete' pattern. Empty / whitespace payloads are
  ignored so the transcript never gets a blank system line.
- ui-tui/src/gatewayTypes.ts: extend the GatewayEvent discriminated
  union with { type: 'review.summary', payload?: { text?: string } }.

Gateway platforms (Telegram, Discord, Slack, …) already route the
review summary via background_review_callback → post-delivery queue
in gateway/run.py, so they pick up the new 'Self-improvement review:'
prefix from the companion run_agent change with no platform edits.

Tests:
- tests/tui_gateway/test_review_summary_callback.py (Python, 2 tests):
  _init_session attaches a callback that emits the right event; the
  callback path survives agents that can't accept the attribute.
- ui-tui/src/__tests__/createGatewayEventHandler.test.ts (vitest, 2
  new cases): review.summary events feed sys(...) with the full text;
  empty / missing payloads are no-ops.
- TypeScript type-check passes.
- tui_gateway suite: 64/64 pass.
2026-04-30 14:07:22 -07:00
Teknium 80a676658c fix(cli): surface self-improvement review summaries from bg thread
When the self-improvement background review fires after a turn, it runs
in a bg thread and emits a '  💾 <summary>' line to announce what it
saved to memory or skills. Two problems made this invisible to users
even when the review successfully modified a skill:

1. The print went through `_cprint` (prompt_toolkit's print_formatted_text)
   on a bg thread while the CLI's PromptSession was live. Direct
   print_formatted_text races with the input-area redraw and the line
   can land behind/above the prompt, scrolled off without the user
   seeing it.

2. The message said only '💾 Skill created.' / '💾 Memory updated'
   with no indication that the self-improvement loop was the one doing
   this. Users who did catch the line couldn't tell the background
   review from some other agent action.

Fixes:

- `_cprint` now detects when it's called from a non-app thread with a
  running prompt_toolkit Application, and routes through
  `run_in_terminal` via `loop.call_soon_threadsafe`. That pauses the
  input, prints the line above the prompt, and redraws — the normal
  prompt_toolkit contract for bg-thread output. Direct-print fallback
  preserved for the no-app / same-thread / import-error paths. Affects
  every bg-thread emission, not just the review summary (curator
  summaries and auxiliary failure prints benefit too).

- The summary now reads '  💾 Self-improvement review: <summary>' in
  both the CLI and the gateway `background_review_callback` path, so
  the origin is unambiguous.

Tests:
- New `tests/cli/test_cprint_bg_thread.py` covers all five routing
  branches (no app, app-not-running, cross-thread schedule, same-thread
  direct, app-loop-attribute-error, import-error).
- New case in `tests/run_agent/test_background_review.py` asserts the
  attributed prefix shows up in both `_safe_print` and
  `background_review_callback`.

Live E2E: exercised _cprint from a bg thread inside a real Application
event loop; confirmed get_app_or_none() sees the app, call_soon_threadsafe
schedules run_in_terminal, and the inner _pt_print runs.
2026-04-30 14:07:22 -07:00
Teknium c868425467 feat(kanban): durable multi-profile collaboration board (#17805)
Salvage of PR #16100 onto current main (after emozilla's #17514 fix
that unblocks plugin Pydantic body validation). History preserved on
the standing `feat/kanban-standing` branch; this squashes the 22
iterative commits into one clean landing.

What this lands:
- SQLite kernel (hermes_cli/kanban_db.py) — durable task board with
  tasks, task_links, task_runs, task_comments, task_events,
  kanban_notify_subs tables. WAL mode, atomic claim via CAS,
  tenant-namespaced, skills JSON array per task, max-runtime timeouts,
  worker heartbeats, idempotency keys, circuit breaker on repeated
  spawn failures, crash detection via /proc/<pid>/status, run history
  preserved across attempts.
- Dispatcher — runs inside the gateway by default
  (`kanban.dispatch_in_gateway: true`). Ticks every 60s, reclaims
  stale claims, promotes ready tasks, spawns `hermes -p <assignee>
  chat -q "work kanban task <id>"` with HERMES_KANBAN_TASK +
  HERMES_KANBAN_WORKSPACE env. Auto-loads `--skills kanban-worker`
  plus any per-task skills. Health telemetry warns on stuck ready
  queue.
- Structured tool surface (tools/kanban_tools.py) — 7 tools
  (kanban_show, kanban_complete, kanban_block, kanban_heartbeat,
  kanban_comment, kanban_create, kanban_link). Gated on
  HERMES_KANBAN_TASK via check_fn so zero schema footprint in normal
  sessions.
- System-prompt guidance (agent/prompt_builder.py KANBAN_GUIDANCE)
  injected only when kanban tools are active.
- Dashboard plugin (plugins/kanban/dashboard/) — Linear-style board
  UI: triage/todo/ready/running/blocked/done columns, drag-drop,
  inline create, task drawer with markdown, comments, run history,
  dependency editor, bulk ops, lanes-by-profile grouping, WS-driven
  live refresh. Matches active dashboard theme via CSS variables.
- CLI — `hermes kanban init|create|list|show|assign|link|unlink|
  claim|comment|complete|block|unblock|archive|tail|dispatch|context|
  init|gc|watch|stats|notify|log|heartbeat|runs|assignees` +
  `/kanban` slash in-session.
- Worker + orchestrator skills (skills/devops/kanban-worker +
  kanban-orchestrator) — pattern library for good summary/metadata
  shapes, retry diagnostics, block-reason examples, fan-out patterns.
- Per-task force-loaded skills — `--skill <name>` (repeatable),
  stored as JSON, threaded through to dispatcher argv as one
  `--skills X` pair per skill alongside the built-in kanban-worker.
  Dashboard + CLI + tool parity.
- Deprecation of standalone `hermes kanban daemon` — stub exits 2
  with migration guidance; `--force` escape hatch for headless hosts.
- Docs (website/docs/user-guide/features/kanban.md + kanban-tutorial.md)
  with 11 dashboard screenshots walking through four user stories
  (Solo Dev, Fleet Farming, Role Pipeline, Circuit Breaker).
- Tests (251 passing): kernel schema + migration + CAS atomicity,
  dispatcher logic, circuit breaker, crash detection, max-runtime
  timeouts, claim lifecycle, tenant isolation, idempotency keys, per-
  task skills round-trip + validation + dispatcher argv, tool surface
  (7 tools × round-trip + error paths), dashboard REST (CRUD + bulk
  + links + warnings), gateway-embedded dispatcher (config gate, env
  override, graceful shutdown), CLI deprecation stub, migration from
  legacy schemas.

Gateway integration:
- GatewayRunner._kanban_dispatcher_watcher — new asyncio background
  task, symmetric with _kanban_notifier_watcher. Runs dispatch_once
  via asyncio.to_thread so SQLite WAL never blocks the loop. Sleeps
  in 1s slices for snappy shutdown. Respects HERMES_KANBAN_DISPATCH_IN_GATEWAY=0
  env override for debugging.
- Config: new `kanban` section in DEFAULT_CONFIG with
  `dispatch_in_gateway: true` (default) + `dispatch_interval_seconds: 60`.
  Additive — no \_config_version bump needed.

Forward-compat:
- workflow_template_id / current_step_key columns on tasks (v1 writes
  NULL; v2 will use them for routing).
- task_runs holds claim machinery (claim_lock, claim_expires,
  worker_pid, last_heartbeat_at) so multi-attempt history is first-
  class from day one.

Closes #16102.

Co-authored-by: emozilla <emozilla@nousresearch.com>
2026-04-30 13:36:47 -07:00
ethernet 59c1a13f45 Merge pull request #15680 from NousResearch/fix/nix-package-lock
fix: let fixing nix pkgs command work without an initial build
2026-04-30 16:21:51 -04:00
Teknium 1d8068d71d feat(models): add openrouter/owl-alpha (free) to curated OpenRouter list (#18071) 2026-04-30 12:57:02 -07:00
Ari Lotter 9ac4a2e53e fix: let fixing nix pkgs command work without an initial build 2026-04-30 15:39:45 -04:00
Austin Pickett 6bc5d72271 Merge pull request #16419 from vincez-hms-coder/feat/dashboard-profiles-hms-coder
feat(dashboard): add profiles management page
2026-04-30 12:09:23 -07:00
ethernet b737af8226 Merge pull request #18047 from stephenschoettler/fix/acp-persist-user-message-test-mocks
test(acp): accept prompt persistence kwargs in MCP E2E mocks
2026-04-30 14:43:26 -04:00
Teknium 73bf3ab1b2 chore: release v0.12.0 (2026.4.30) (#18057)
The Curator release — Hermes Agent now maintains itself. Autonomous
background Curator grades, prunes, and consolidates the skill library;
self-improvement loop substantially upgraded; four new inference
providers; Microsoft Teams (via pluggable platforms) + Yuanbao as 18th
and 19th messaging platforms; Spotify + Google Meet native integrations;
ComfyUI + TouchDesigner-MCP bundled by default; Humanizer skill ported;
~57% cut to visible TUI cold start.

Stats since v0.11.0: 1,096 commits, 550 merged PRs, 1,270 files
changed, 217,776 insertions, 213 community contributors.
2026-04-30 11:31:01 -07:00
Teknium 76edc40ab0 fix(agent): extend thinking-mode reasoning_content pad to Kimi/Moonshot
Builds on #16855 (@lsdsjy) which fixed DeepSeek v4 reasoning_content
replay via model_extra fallback + capturing tool_calls at method entry.
Kimi / Moonshot thinking mode enforces the same echo-back contract and
hits the same 400 when a tool-call turn is persisted without
reasoning_content.

- _build_assistant_message: pad branch now uses _needs_thinking_reasoning_pad()
  (DeepSeek OR Kimi) instead of _needs_deepseek_tool_reasoning() alone.
- Extract _needs_thinking_reasoning_pad() and reuse it in
  _copy_reasoning_content_for_api so both sites share one predicate.
- tests/run_agent/test_deepseek_reasoning_content_echo.py: add
  TestBuildAssistantMessagePadsStrictProviders parametrized over DeepSeek
  (attr=None, attr-absent), Kimi (attr=None), Moonshot (via base_url),
  and an OpenRouter negative control that must NOT pad. Proven to fail
  2/5 cases on Kimi/Moonshot without this change.
- scripts/release.py: add AUTHOR_MAP entries for lsdsjy and season179.

Refs #17400.

Co-authored-by: season179 <season.saw@gmail.com>
2026-04-30 11:18:39 -07:00
lsdsjy b9b9ee3e6c fix(deepseek): preserve v4 reasoning_content on replay 2026-04-30 11:18:39 -07:00
ethernet 8fbc9d7d78 Merge pull request #18043 from NousResearch/feat/help-ui
feat(tui): add a mini help menu when u write ? in the input field
2026-04-30 14:02:28 -04:00
Stephen Schoettler 699a9c11a9 test(acp): accept prompt persistence kwargs in mocks 2026-04-30 10:47:23 -07:00
Teknium d60a9917d3 feat(curator): show most-used and least-used skills in hermes curator status (#18033)
Alongside the existing 'least recently used' section, surface two more
rankings so users can see which of their agent-created skills actually
get exercised:

- 'most used (top 5)' — sorted by use_count descending. Hidden when every
  skill has use_count=0 (noise suppression on fresh installs).
- 'least used (top 5)' — sorted by use_count ascending. Always shown
  when the catalog is non-empty.

use_count started tracking real agent skill activation in PR #17932
(bump_use wired into skill_view tool + slash invocation + --skill
preload), so these rankings are now meaningful.

Tests: 3 new in tests/hermes_cli/test_curator_status.py — happy path
with mixed use_counts, zero-use suppression of the most-used section,
and the no-skills clean-empty case.
2026-04-30 10:37:33 -07:00
ethernet 7c07422202 feat(tui): add a mini help menu when u write ? in the input field
it feels so nice :3 just a lil popup ! doesn't get in the way or take
any focus or anything, and directs users to /help for more info :3
2026-04-30 13:37:12 -04:00
y0shualee f4b76fa272 fix: use skill activity in curator status
Treat skill views and edits as activity when curator reports and applies lifecycle transitions, so recently loaded or patched skills are not displayed or transitioned as never used.\n\nAdds regression tests for activity derivation, automatic transitions, and CLI status output.
2026-04-30 10:31:47 -07:00
0xDevNinja 564a649e6a fix(curator): scan nested archive subdirs in restore_skill
restore_skill() in tools/skill_usage.py used archive_root.iterdir(), which
only walked the top level of .archive/. Skills archived under nested layouts
(e.g. .archive/openclaw-imports/<skill>/ from older archive paths or
external imports) were invisible to both the exact-match and prefix-match
candidate scans, surfacing as a misleading "skill '<name>' not found in
archive" error even though the directory existed on disk.

Switch both candidate scans to archive_root.rglob('*') so the lookup
descends into category subdirectories.

Fixes #17942
2026-04-30 10:31:44 -07:00
Teknium 7913d6a90f chore(author-map): add y0shua1ee and 0xDevNinja for curator PRs (#18031) 2026-04-30 10:31:38 -07:00
Teknium 8b290a5908 feat(curator): split archived into consolidated vs pruned with model + heuristic classification (#17941)
* fix(curator): split 'archived' into consolidated vs pruned in run reports

Users who watched a curator run saw skills like 'anthropic-api' listed
under 'Skills archived' and interpreted that as pruning — but the curator
had actually absorbed those skills into a new umbrella (e.g. 'llm-providers')
during the same run. The directory gets archived for safety (all removals
are recoverable), but the content still lives under a different name.
Users then 'restored' what they thought were deleted skills and ended up
with confusingly duplicated skillsets (old-name + absorbed-inside-umbrella).

Classify removed skills using this run's skill_manage tool calls:
- consolidated: content absorbed into a surviving/newly-created skill
  (evidenced by a skill_manage write_file/patch/create/edit whose target
  is a different skill AND whose file_path/content references the
  removed skill's name)
- pruned: archived without consolidation evidence (truly stale)

REPORT.md now shows two distinct sections:
- 'Consolidated into umbrella skills' — with `removed → merged into umbrella`
- 'Pruned — archived for staleness' — pure staleness archives

run.json schema additions (backward compatible):
- counts.consolidated_this_run, counts.pruned_this_run
- consolidated: [{name, into, evidence}, ...]
- pruned: [names]
- archived: retained as the union for backward compat

Also: relabel the auto-transitions 'archived' counter to 'archived (no
LLM, pure time-based staleness)' so it's clearly distinct from LLM-pass
archives.

Tests: 9 new tests in test_curator_classification.py covering consolidation
evidence parsing (write_file/patch/create), hyphen/underscore name variants,
self-reference rejection, destination-must-exist, mixed runs, and
malformed-JSON fallback safety. Existing test_report_md_is_human_readable
updated to cover the new section names.

E2E: isolated HERMES_HOME, realistic 3-skill run, REPORT.md verified
end-to-end.

* feat(curator): hybrid model-declared + heuristic classification

Extend the consolidated-vs-pruned split with LLM-authored intent:

1. Curator prompt now requires a structured YAML block at the end of the
   final response (consolidations / prunings with short rationale).
2. _parse_structured_summary() extracts it tolerantly — missing block,
   malformed YAML, partial lists all fall back to heuristic cleanly.
3. _reconcile_classification() merges model intent with the tool-call
   heuristic:
   - Model wins on rationale when its umbrella exists post-run
   - Model hallucination (umbrella doesn't exist) is downgraded to the
     heuristic's finding, or pruned if there's no evidence either
   - Heuristic catches model omission — consolidations the model
     enumerated tools for but forgot to list get surfaced with a
     '(detected via tool-call audit)' tag
4. REPORT.md now shows per-row rationale alongside 'removed → umbrella'
   and flags audit-only rows so the user knows why no reason is shown.

Backward compat: run.json's 'archived' field (union) is preserved.
'pruned' is now a list of dicts with {name, source, reason};
'pruned_names' is the flat-name list for legacy consumers.

Tests: 15 new covering YAML parse edge cases (malformed, empty lists,
bare-string entries, missing fields), reconciler rules (model wins,
hallucination fallback, heuristic catches omission, prune with reason),
and an end-to-end report-render test with all four paths exercised.
2026-04-30 10:31:23 -07:00
Henkey cdf9793d6d fix(acp): advertise and forward image prompts 2026-04-30 10:31:16 -07:00
brooklyn! 29bcd2f6e9 Merge pull request #18029 from NousResearch/bb/tui-max-iterations-salvage
fix(tui): respect max turns config
2026-04-30 10:28:58 -07:00
Brooklyn Nicholson b9d9fa7df8 fix(tui): respect max turns config
Co-authored-by: YuShu <24110240104@m.fudan.edu.cn>
2026-04-30 12:26:57 -05:00
ethernet d499d17271 Merge pull request #17969 from stephenschoettler/fix/current-main-test-regressions
fix(ci): stabilize current main test regressions
2026-04-30 13:23:38 -04:00
ethernet 2d3c041338 change(nix): dedupe nix lockfile checking scripts in ci (#18000)
* change(nix): dedupe nix lockfile checking scripts in ci

* feat(nix): make .#fix-lockfiles run --apply if no args passed

* fix(nix): use same nodejs version everywhere & small lints

- prevent lockfile thrashing while using nix :3
- use lib.getExe instead of raw /bin/ paths
- use inputs'.self instead of passing system in manually

* fix(nix): update lock files yet again (hopefully for the last time)

* fix(nix): align indentation of collision check echo

---------

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-04-30 22:52:30 +05:30
oak 4e296dcdda fix(auxiliary): pass raw base_url to _maybe_wrap_anthropic for correct transport detection (#17467)
Fixes HTTP 404 errors when using Anthropic-compatible providers (Kimi Coding, MiniMax, MiniMax-CN) for auxiliary tasks.

Root cause: `_to_openai_base_url()` rewrites `/anthropic` → `/v1` so the OpenAI SDK hits the right endpoint. But the rewritten URL was then passed to `_maybe_wrap_anthropic`, whose `_endpoint_speaks_anthropic_messages` detector only fires on `/anthropic` or `api.kimi.com/coding`. Detector saw `/v1` → returned False → no Anthropic wrap → 404 on every aux call.

Fix: preserve the raw base_url before rewriting and pass it to `_maybe_wrap_anthropic` for transport detection, while still giving the rewritten URL to the OpenAI client constructor.

Closes #17705, #17413, #17086, #10469.

Co-authored-by: oak <chengoak@users.noreply.github.com>
2026-04-30 10:18:42 -07:00
brooklyn! d954d6fbcf Merge pull request #18024 from NousResearch/bb/mouse-mode-fast-path
fix(cli): tighten terminal leak fast path
2026-04-30 10:17:59 -07:00
Brooklyn Nicholson e30de51ee9 fix(cli): tighten terminal leak fast path 2026-04-30 12:16:04 -05:00
brooklyn! 285e9efb3f Merge pull request #17701 from NousResearch/bb/mouse-mode-self-heal
fix(cli): recover leaked mouse tracking terminal state
2026-04-30 10:09:39 -07:00
Brooklyn Nicholson cad7944b92 fix(tui): reset extended keyboard modes 2026-04-30 12:05:15 -05:00
Stephen Schoettler 407dfbb021 fix(ci): stabilize current main test regressions 2026-04-30 06:36:50 -07:00
Siddharth Balyan 9a14540603 fix(nix): replace magic-nix-cache with Cachix (#17928)
* fix(nix): replace magic-nix-cache with Cachix

magic-nix-cache caused recurring CI failures (TwirpErrorResponse
ResourceExhausted) by hitting GitHub Actions Cache's 10 GB limit and
200 req/min rate limit. This was flagged as 'unfixable infra flake' in
#17836 but is actually a fixable architecture choice.

Switch to Cachix (dedicated binary cache, no GHA quota dependency):
- Replace DeterminateSystems/magic-nix-cache-action with cachix/cachix-action
- Add cachix-auth-token input to nix-setup composite action
- Pass CACHIX_AUTH_TOKEN secret through all three nix workflows
- continue-on-error: true so cache failures never block CI

Cache 'hermes-agent' is public at hermes-agent.cachix.org.
Devs can pull locally with: cachix use hermes-agent

* fix: correct cachix-action commit SHA pin

---------

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-04-30 17:38:58 +05:30
Teknium ae8930afa5 fix(skills): also bump_use on skill_view tool invocation
Widen #17818 to cover the dominant 'agent actively used this skill' path:
when the model calls the skill_view tool, bump use_count alongside view_count.
The slash-command and --skill preload paths (covered by the cherry-picked
commit) only catch user-initiated invocation; most skill activation happens
via the agent calling skill_view to consume an indexed skill.

Curator's stale-timer keys off last_used_at (agent/curator.py:233), so
without this wire-up agent-created skills would transition to stale
simultaneously regardless of actual use.
2026-04-30 05:07:34 -07:00
Bartok9 4178ab3c07 fix(skills): wire bump_use() into skill invocation and preload paths (#17782)
bump_use() existed and was tested but had zero production call sites —
use_count stayed 0 for all skills, breaking Curator's stale-detection
logic which relies on last_used_at.

Wire bump_use() into:
1. build_skill_invocation_message() — when a user invokes /skill-name
2. build_preloaded_skills_prompt() — when a skill is preloaded at session start

Both are the canonical 'a skill is actively being used' moments, distinct
from 'browsing' (bump_view in skill_view tool call).

Closes #17782
2026-04-30 05:07:34 -07:00
Teknium 4c792865b4 test(gateway): pin cleanup invariants for #17758 in-band drain hand-off
Belt-and-suspenders on top of @briandevans' #17758 fix.  The in-band
drain hand-off (await->create_task + session-guard preservation)
changed cleanup semantics in three places that the original PR
reasoned about but didn't test directly.  Pin each invariant so a
future refactor can't silently regress them:

1. Normal single-message path still releases _active_sessions[sk] and
   _session_tasks[sk] through end-of-finally.  The #17758 follow-up
   moved _release_session_guard under
     if current_task is self._session_tasks.get(session_key)
   For the 99%-common case current_task IS the stored task, so the
   guard must still fire.  Test would fail if the conditional were
   ever tightened in a way that dropped the normal path.

2. Drain-task cancellation releases the session.  If the drain task
   spawned by the in-band hand-off is cancelled mid-handler (e.g.
   /stop fired while draining a follow-up), its own finally must
   fire _release_session_guard.  Without this a cancel would leave
   the session permanently pinned busy.

3. Late-arrival drain still spawns when no in-band drain preceded
   it.  Pre-existing path, but the #17758 follow-up added a
   re-queue branch that only fires when ownership was already
   handed off.  When no handoff happened the else branch must still
   spawn a fresh drain task — otherwise a message arriving during
   stop_typing gets silently dropped.

All three tests pass against current main.  Zero production code
changes.
2026-04-30 05:00:25 -07:00
Teknium a845177ebe fix(skills): also exclude .archive in skills_tool + add author map entry
Widen #17639 to the fourth sibling site (tools/skills_tool.py _EXCLUDED_SKILL_DIRS)
and register leoneparise in scripts/release.py AUTHOR_MAP so CI release script
resolves the contributor.
2026-04-30 04:59:22 -07:00
Leone Parise eda1d516dc fix(skills): exclude .archive from skill index walk
Archived skills (moved to ~/.hermes/skills/.archive/ by the curator)
were still surfaced in the <available_skills> system prompt under a
fake '.archive' category, causing the agent to load and try to use
deprecated skills. The os.walk in iter_skill_index_files() only
excluded .git/.github/.hub.

Add '.archive' to EXCLUDED_SKILL_DIRS, and to the two other places
that hardcode the same exclusion tuple (gateway/run.py and
agent/skill_commands.py).
2026-04-30 04:59:22 -07:00
Teknium e8e5985ce6 fix(curator): seed defaults on update, create logs/curator dir, defer fire import (#17927)
Three fixes bundled for curator reliability on existing installs and
broken/partial installs:

1. run_agent.py: defer `import fire` into the __main__ block. `fire` is
   only used by `fire.Fire(main)` when running run_agent.py directly as
   a CLI — it is NOT needed for library usage. Importing it at module
   top made `from run_agent import AIAgent` from a daemon thread (e.g.
   the curator's forked review agent) crash with ModuleNotFoundError
   on broken/partial installs where `fire` isn't present.

2. hermes_cli/config.py: add version 22 → 23 migration that writes the
   `curator` + `auxiliary.curator` sections to config.yaml with their
   defaults, only filling keys the user hasn't overridden. Existing
   configs from before PR #16049 / the April 2026 `auxiliary.curator`
   unification had neither section on disk, so users couldn't see or
   edit the settings in their config.yaml (runtime deep-merge papered
   over it at read time, but the file never reflected reality).

3. hermes_cli/config.py: `ensure_hermes_home()` now pre-creates
   `~/.hermes/logs/curator/` alongside cron/sessions/logs/memories on
   every CLI launch. Managed-mode (NixOS) variant mkdir's it
   defensively after the activation-script existence checks, since the
   activation script may not know about this subpath.

4. agent/curator.py: `_reports_root()` mkdir's the dir at call time as
   belt-and-suspenders for entry paths that bypass both
   ensure_hermes_home() and the v23 migration (gateway-only installs,
   bare library use).

E2E validated in isolated HERMES_HOME: fresh install gets full defaults
seeded; partial-override config keeps user's `enabled: false` and
custom `interval_hours` while filling the missing keys; re-running the
migration is a no-op.
2026-04-30 04:52:28 -07:00
konsisumer d1d0ef6dbd fix(gateway): persist user message on transient agent failures (#7100)
The #1630 fix introduced a blanket ``agent_failed_early`` transcript skip
to prevent context-overflow sessions from looping.  That guard also
triggers for unrelated transient failures (429 rate limits, read
timeouts, connection resets, provider 5xx) which have nothing to do with
session size — and it silently drops the user's message, so the agent
has no memory of the last turn on retry.

Split the failure classification in ``GatewayRunner._run_agent``:

* Context-overflow (``compression_exhausted`` flag, explicit
  context-length phrases, or generic 400 with a long history) → keep
  the existing skip, preserving the #1630/#9893 fix.
* Anything else that failed → persist just the user message so the
  conversation survives a retry.

Use specific multi-word phrases (``context length``, ``token limit``,
``prompt is too long``, etc.) to match ``run_agent.py``'s own
classifier; bare ``exceed`` false-positively flagged "rate limit
exceeded" as context overflow.

Covered by new tests in ``tests/gateway/test_7100_transient_failure_transcript.py``
and the existing #1630 suite still passes.
2026-04-30 04:32:33 -07:00
Teknium 87f5e1a25a test(ssh): update tar pipe assertion for --no-overwrite-dir
Existing test_tar_pipe_commands asserted the literal substring
'tar xf - -C /' in ssh_str, which is no longer present after the
#17767 fix adds --no-overwrite-dir between 'tar xf -' and '-C /'.

Split the one substring check into three independent assertions for
the tar stdin mode, the new --no-overwrite-dir flag (regression guard
for #17767), and the extract target.
2026-04-30 04:32:28 -07:00
Teknium b50bc13ef9 fix(config): preserve YAML lists in hermes config set (#17876)
_set_nested unconditionally replaced any non-dict value with an empty
dict when walking the dotted path, which silently destroyed list-typed
config nodes the moment someone set a value with a numeric index
(e.g. 'hermes config set custom_providers.0.api_key NEW'). Any sibling
entries and any fields inside the targeted entry that the user didn't
write were lost.

Fix:
- _set_nested now detects list nodes and navigates by numeric index,
  and preserves both dicts AND lists at intermediate positions (scalars
  are still replaced so bare-scalar -> nested overrides keep working).
- set_config_value drops its duplicated navigation logic and calls
  _set_nested instead -- single source of truth for the rules.

Regression tests (tests/hermes_cli/test_set_config_value.py):
- test_indexed_set_preserves_sibling_list_entries -- exact #17876 repro
- test_indexed_set_preserves_non_targeted_fields -- inner-dict fields survive
- test_deeper_nesting_through_list -- dict -> list -> dict -> scalar path

35/35 existing + new tests pass.

E2E-verified with the issue's repro against a real on-disk config.yaml --
list stays a list, entry 0 updated, entry 1 intact.

Closes #17876
2026-04-30 04:32:17 -07:00
Teknium 3fc4c63d38 test(model_switch): update regression to reflect bare-custom guard 2026-04-30 04:32:11 -07:00
Teknium 61fec7689d chore(release): map Andy283 gitee email in AUTHOR_MAP 2026-04-30 04:32:11 -07:00
Andy 201f7caed8 fix: prevent bare 'custom' slug in model.provider (#17478)
When hermes model picker switches to a custom_providers entry, the slug
assignment can write the literal string 'custom' to model.provider if a
prior failed switch already left that value in config.yaml.

Two fixes:
1. model_switch.py: filter out bare 'custom' in slug assignment, always
   resolve to canonical custom:<name> form
2. providers.py: resolve_custom_provider() self-heals bare 'custom' by
   falling back to the first valid custom_providers entry

Closes #17478
2026-04-30 04:32:11 -07:00
Sanjays2402 e0fa2cf972 fix(tools): isolate get_tool_definitions quiet_mode cache + dedup LCM injection (#17335)
Long-lived Gateway processes were sending duplicate tool names to
providers that enforce uniqueness:

  - DeepSeek:        'Tool names must be unique.'
  - Xiaomi MiMo:     'tools contains duplicate names: lcm_expand'
  - Moonshot/Kimi:   'function name lcm_grep is duplicated'

TUI was unaffected because TUI runs with quiet_mode=False and skips the
cache entirely.

Root cause (two layered bugs)
- model_tools.get_tool_definitions(quiet_mode=True) memoizes its result
  in _tool_defs_cache. The cache-hit path returned list(cached) (safe),
  but the FIRST uncached call stored and returned the SAME object.
  run_agent.py mutates self.tools (memory + LCM context-engine schemas)
  in-place, so the very first agent init in a Gateway process
  poisoned the cache, and every subsequent init appended LCM schemas
  again on top of the already-polluted list.
- run_agent.py's context-engine injection (lcm_grep / lcm_describe /
  lcm_expand) had no dedup, unlike the memory-tools injection right
  above it which already skips already-present names.

Fix (defense in depth, per the issue's suggested fix)
- model_tools.get_tool_definitions: on the uncached branch, cache the
  computed list but return list(result) to the caller. Same pattern as
  the cache-hit path.
- run_agent.py: build _existing_tool_names from self.tools and skip
  schemas whose names are already present, mirroring the memory-tools
  block. This also defends against plugin paths that may register the
  same schemas via ctx.register_tool().

Tests (tests/test_get_tool_definitions_cache_isolation.py)
- test_first_uncached_call_returns_fresh_list \u2014 pins the fix; without
  it, first-call alias caused all the symptoms.
- test_cache_hit_returns_fresh_list \u2014 pre-existing behavior stays.
- test_caller_mutation_does_not_poison_cache \u2014 simulates run_agent
  appending lcm_grep / lcm_expand to the returned list and asserts the
  next call doesn't see them.
- test_repeated_caller_mutation_does_not_accumulate \u2014 reproduces the
  long-lived Gateway accumulation pattern across 5 agent inits.
- test_non_quiet_mode_does_not_use_cache \u2014 sanity, explains why TUI
  was fine.

5/5 pass on the new file; 23/23 still pass on tests/test_model_tools.py.
2026-04-30 04:32:06 -07:00
Teknium 70ae678af1 chore(release): map rob@atlas.lan to @rmoen 2026-04-30 04:31:23 -07:00
Rob Moen 0dd373ec43 fix(context): honor model.context_length for Ollama num_ctx and all display paths
When a user sets model.context_length in config.yaml, the value was only
used for Hermes' internal compression decisions (context_compressor) but
NOT for Ollama's num_ctx parameter. Ollama auto-detects context from GGUF
metadata (often 256K+) and allocates that much VRAM regardless of the
user's config — causing OOM on smaller GPUs like the P100 (16GB).

Root cause: two separate context values existed independently:
  - context_compressor.context_length = config value (e.g. 65536) ✓
  - _ollama_num_ctx = GGUF metadata value (e.g. 256000) ✗ ignored config

Changes:

1. Cap Ollama num_ctx to config context_length (run_agent.py)
   When model.context_length is explicitly set and no explicit
   ollama_num_ctx override exists, cap the auto-detected GGUF value
   to the user's context_length. This is the core fix — it prevents
   Ollama from allocating more VRAM than the user budgeted.

2. Pass config_context_length through all secondary call sites
   Several paths called get_model_context_length() without the config
   override, falling through to the 256K default fallback:
   - cli.py: @-reference expansion and /model switch display
   - gateway/run.py: @-reference expansion and /model switch display
   - tui_gateway/server.py: @-reference expansion
   - hermes_cli/model_switch.py: resolve_display_context_length()

3. Normalize root-level context_length in config (hermes_cli/config.py)
   _normalize_root_model_keys() now migrates root-level context_length
   into the model section, matching existing behavior for provider and
   base_url. Users who wrote `context_length: 65536` at the YAML root
   instead of under `model:` had it silently ignored.

4. Fix misleading comments (agent/model_metadata.py)
   DEFAULT_FALLBACK_CONTEXT is 256K (CONTEXT_PROBE_TIERS[0]), not 128K
   as two comments stated.

Tests: 3 new tests for root-level context_length normalization.
All existing context_length tests pass (96 tests).
2026-04-30 04:31:23 -07:00
Bartok9 fbb3775770 fix(gateway): enforce auth check in busy-session path to prevent unauthorized injection (#17775)
The busy-session handler (_handle_active_session_busy_message) bypassed the
authorization gate that the cold path enforces via _is_user_authorized(). In
shared-thread contexts (Slack threads, Telegram forum topics, Discord threads)
where thread_sessions_per_user=False (the default), all participants share one
session_key. An unauthorized user posting in the same thread as an authorized
user would hit the active-session branch, skip the auth check, and have their
text merged into _pending_messages or injected via agent.interrupt().

This commit adds the same _is_user_authorized() check at the top of the busy
handler, before any message queuing, steering, or interrupt logic. Unauthorized
messages are silently dropped (return True) with a warning log — matching the
cold-path behavior.

Affected platforms: Slack, Telegram, Discord, any adapter with shared-session
thread contexts.

Closes #17775
2026-04-30 04:29:15 -07:00
briandevans cc5b9fb581 fix(transport): omit thinking_config for Gemma on the gemini provider (#17426)
The `gemini` provider also serves Gemma (e.g. `gemma-4-31b-it`) and
historically other Google models like PaLM. Those reject
`extra_body.thinking_config` with HTTP 400:

    Unknown name "thinking_config": Cannot find field

`_build_gemini_thinking_config()` was unconditionally producing a
config dict for any model on the `gemini` / `google-gemini-cli`
provider, which `ChatCompletionsTransport.build_kwargs` then dropped
into `extra_body["thinking_config"]`. The result: every chat turn for
Gemma users on the gemini provider blew up at the API edge.

The fix is the same shape Hermes already uses for the Gemini-2.5 vs
Gemini-3 family clamping: normalise the model id, strip an
`OpenRouter`-style `google/` prefix, and short-circuit early when the
result doesn't start with `gemini`. We return `None` rather than
`{"includeThoughts": False}`, because the API rejects the field name
itself — even the polite "off" form trips the same 400.

Three regression tests cover Gemma with reasoning enabled, Gemma with
reasoning disabled, and the `google/gemma-…` OpenRouter-style id; the
existing Gemini-2.5 / Gemini-3 / `google/gemini-…` cases keep passing
because the Gemini guard fires after the prefix strip.

Fixes #17426

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 04:29:04 -07:00
Teknium 3de8e21683 feat(gateway): native send_multiple_images for Telegram, Discord, Slack, Mattermost, Email
Ports PR #17888's send_multiple_images ABC to every gateway platform that
has a native multi-attachment API, so images arrive as a single bundled
message instead of N separate ones.

Native overrides:
- Telegram: send_media_group (10 photos per album, chunks over); animated
  GIFs peeled off and routed through send_animation (albums don't support
  animations)
- Discord: channel.send(files=[...]) (10 attachments per message, chunks
  over); URL images downloaded into BytesIO so they render inline; forum
  channels use create_thread with files=[...]
- Slack: files_upload_v2(file_uploads=[...]) (10 per call, chunks over);
  respects thread_ts; records thread participation
- Mattermost: single post with file_ids list (5 per post — Mattermost cap,
  chunks over)
- Email: single SMTP message with multiple MIME attachments (no chunk cap,
  SMTP size governs); remote URLs remain linked in body (parity with
  existing send_image)

All platforms fall back to the base per-image loop on any failure, so a
single bad image in a batch never loses the rest.

Matrix, WhatsApp, and single-attachment platforms (BlueBubbles, Feishu,
WeCom, WeChat, DingTalk) continue to use the base default loop — their
server APIs only accept one attachment per message anyway.

Tests: adds tests/gateway/test_send_multiple_images.py with 19 targeted
tests covering base default loop, chunking, animation peel-off, fallback
paths, and empty-batch no-ops across all five new overrides.

Co-authored-by: Maxence Groine <maxence@groine.fr>
2026-04-30 04:28:08 -07:00
Maxence Groine 04ea895ffb feat(gateway/signal): add support for multiple images sending
Adds a new `send_multiple_images` method to the ``BasePlatformAdapter``
that implements the default "One image per message" loop and allows for
platform-specific overriding.

Implements such an override for the Signal adapter, batching images
and trying (best-effort) to work around rate-limits for voluminous
batches using a specific scheduler.

Also implements batching + rate-limit handling in the `send_message`
tool.

New tests added for the Signal adapter, its rate-limit scheduler and the
`send_message` tool
2026-04-30 04:28:08 -07:00
VinceZ-Hms-Coder ca7f46beb5 Merge upstream/main and address Copilot review feedback
Merge resolved conflicts in web/src/{i18n/{en,zh,types}.ts,lib/api.ts}
by keeping both this branch's `profiles` additions and upstream's new
`models` page additions.

Copilot review feedback:
- Implement POST /api/profiles/{name}/open-terminal endpoint (already
  present); align Windows branch to `cmd.exe /c start "" <cmd>` so it
  matches the new test and spawns a fresh window instead of /k reusing
  the parent console.
- Move backslash escaping out of the macOS AppleScript f-string
  expression (Python <3.12 disallows backslashes inside f-string
  expression parts).
- Patch `_get_wrapper_dir` via monkeypatch in
  test_profiles_create_creates_wrapper_alias_when_safe so the test no
  longer writes to the real `~/.local/bin`.
- Extend test_dashboard_browser_safe_imports to scan `.ts` files in
  addition to `.tsx`.
- Switch upstream's new ModelsPage.tsx away from the `@nous-research/ui`
  root barrel onto per-component subpaths to satisfy the stricter scan.
- Fix NouiTypography `leading-1.4` -> `leading-[1.4]` so Tailwind
  actually emits the line-height for the `sm` variant.
- Guard ProfilesPage.openSoulEditor against out-of-order responses by
  tracking the latest requested profile via a ref.
- Replace ProfilesPage's hand-rolled setup command with a fetch to
  `/api/profiles/{name}/setup-command` so the copied command always
  matches what the backend would actually run (handles wrapper-alias
  collisions and reserved names correctly).
- Wire SOUL.md textarea label `htmlFor` -> textarea `id` so screen
  readers and clicking the label work as expected.
2026-04-30 06:43:22 -04:00
Teknium 411f586c67 refactor(gateway): extract _float_env helper for env-var float casts
Follow-up to the try/except guards added in the previous commit.
Four sibling call sites all read HERMES_AGENT_TIMEOUT /
HERMES_AGENT_TIMEOUT_WARNING / HERMES_AGENT_NOTIFY_INTERVAL via the
same read-env-or-fallback pattern, so factor it into _float_env(name,
default) alongside the existing _auto_continue_freshness_window()
helper.
2026-04-30 03:32:37 -07:00
vominh1919 ca87c822ed fix(gateway): guard yaml.safe_load and float() env var casts against crash
Two defensive fixes in gateway/run.py:

1. yaml.safe_load returning None on empty config files (line 12706):
   GatewayConfig.from_dict(data) crashes with AttributeError when the YAML
   file is empty because safe_load returns None. All 6 other yaml.safe_load
   call sites already use `or {}` — this one was missed.
   Impact: gateway fails to start with empty --config file.

2. float() on env vars without ValueError guard (lines 3951, 11757, 11805,
   11807): HERMES_AGENT_TIMEOUT, HERMES_AGENT_TIMEOUT_WARNING, and
   HERMES_AGENT_NOTIFY_INTERVAL are cast via float() directly from
   os.getenv(). A typo (e.g. "abc") raises ValueError and crashes the
   agent turn or gateway startup.
   Impact: single misconfigured env var crashes the entire gateway.
2026-04-30 03:32:37 -07:00
Teknium 5af8fa5c8c chore(release): map Heltman email to username for AUTHOR_MAP 2026-04-30 03:31:16 -07:00
Heltman 19f9be1dff fix(tools): serialize concurrent hermes_tools RPC calls from execute_code
The sandbox-side `_call()` in both the UDS and file-based transports was
not thread-safe, so scripts that call tools from multiple threads (e.g.
`ThreadPoolExecutor` over `terminal()`) inside a single `execute_code`
run could silently receive each other's responses.

Root cause:

* UDS transport — a single module-level `_sock` was shared across all
  threads; the newline-framed protocol has no request-id; and the
  server-side RPC loop handles one connection serially. With concurrent
  callers, each thread would `sendall()` then race to `recv()` the next
  newline-terminated response from the shared buffer, so responses got
  delivered to the wrong caller.

* File transport — `_seq += 1` is a non-atomic read-modify-write, so
  two threads could allocate the same sequence number and clobber each
  other's request/response files.

Fix: guard `_call()` with a `threading.Lock` in the UDS case (covering
send+recv), and guard `_seq` allocation with a lock in the file case.
No protocol change.

Regression tests cover both the generated-source level (lock is present
and used) and an end-to-end concurrency test: running a sandboxed
ThreadPoolExecutor of 10 `terminal()` calls against a slow mock
dispatcher, asserting every caller sees its own tagged response. The
test fails without the fix (10/10 mismatched, matching real-world
repro) and passes with it.
2026-04-30 03:31:16 -07:00
Rylen Anil 3858f9419e fix: handle gateway Ctrl+C shutdown cleanly 2026-04-30 03:29:57 -07:00
Teknium 01d7c87ecc chore(release): map zicochaos to GitHub login 2026-04-30 03:29:48 -07:00
Sebastian B 362996e269 fix(runtime_provider): _get_named_custom_provider must honour transport field on v12+ providers dict
The v11→v12 migrate_config step writes the API mode for every entry
under the new transport: field (per the v12+ schema in
_normalize_custom_provider_entry).  _get_named_custom_provider
read the legacy api_mode: spelling only, so for every migrated
config the lookup returned None for the api mode.

Downstream, _resolve_named_custom_runtime then falls back through
custom_provider.get("api_mode") or _detect_api_mode_for_url(base_url)
or "chat_completions".  For loopback URLs (proxies, local servers)
or unknown hostnames, the URL detector returns None and the resolver
silently downgrades the configured codex_responses /
anthropic_messages transport to chat_completions.  Requests
get sent to /v1/chat/completions instead of /v1/responses or
/v1/messages and the provider 404s — or worse, returns a usable
chat_completions response while skipping the model's reasoning /
caching surface.

Fix: read both field names — entry.get("api_mode") or
entry.get("transport") — at the two match-by-key + match-by-name
branches in _get_named_custom_provider.  The runtime normaliser
_normalize_custom_provider_entry already accepts both spellings;
this lifts the same compat into the direct-dict reader so v12+
configs work without going through the shim.

Adds three regression tests under
tests/hermes_cli/test_user_providers_model_switch.py:
- transport field is read on the match-by-key branch
- legacy api_mode spelling still works for hand-edited configs
- transport is read on the match-by-display-name branch
2026-04-30 03:29:48 -07:00
briandevans f54935738c fix(cron): surface agent run_conversation failure flags as job failure
run_job() ignored the result's `failed=True` / `completed=False` flags
that agent.run_conversation populates on API exhaustion, mid-run
interrupts, and model aborts. Because final_response on those paths is
often a non-empty error string ("API call failed after 3 retries:
Request timed out."), the existing empty-response soft-fail in
_process_job did not trip either: the error text was delivered as if it
were the agent's reply and last_status was set to "ok" with no error
notification. Detect those flags right after the dict-shape guard and
raise so the existing except handler builds the proper failure tuple,
preserving the agent's error message via result["error"].

Adds a parametrized regression covering: API-retry-exhausted with error
text in final_response, completed=False with no final_response,
completed=False without an explicit failed flag, and the partial-reply
plus failed=True case. Plus a guard that a normal completed=True success
result is still treated as success.

Fixes #17855

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 03:27:37 -07:00
briandevans f44f1f9615 fix(gateway): preserve session guard across in-band drain handoff
When the in-band pending-message drain spawns a fresh task and
transfers ownership via _session_tasks[session_key] = drain_task,
the original task still unwinds through the finally block.  The
drain task picks up the same interrupt_event in its own
_process_message_background entry, so an unconditional
_release_session_guard(session_key, guard=interrupt_event) at the
end of the finally matches and deletes _active_sessions[session_key]
while the drain task is still pending its first await.

A concurrent inbound message arriving in that handoff window passes
the Level-1 guard (no entry exists) and spawns a second
_process_message_background for the same session — two agents on
one session_key, duplicate responses, duplicate tool calls.

Fix: only call _release_session_guard when the current task still
owns _session_tasks[session_key].  When ownership has been
transferred to a drain task, leave _active_sessions populated; the
drain task's own lifecycle releases it.  This mirrors the
late-arrival drain path in the same finally block, which already
leaves both entries alone after handing off.

Also reorder stdlib imports in the new regression test file to
match the gateway test convention (stdlib before third-party).

Regression test: capture _active_sessions[sk] identity at every
handler entry across a 2-step in-band drain chain and assert the
guard Event identity stays the same.  Pre-fix, the original task's
finally deletes the entry, the drain task falls through to the
`or asyncio.Event()` branch, and a fresh Event is installed —
identity diverges.  Post-fix, the entry is preserved and the drain
task reuses the original Event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 03:27:08 -07:00
briandevans 663ba9a58f fix(gateway): drain pending messages via fresh task, not recursion (#17758)
`_process_message_background` finished a turn, found a queued
follow-up, and drained it via `await
self._process_message_background(pending_event, session_key)`.  Each
chained follow-up added a frame to the call stack instead of starting
fresh.  Under sustained pending-queue activity (e.g. a user sending
follow-ups faster than the agent finishes turns) the C stack would
exhaust at ~2000 nested frames and SIGSEGV the process.

Mirror the late-arrival drain pattern that already exists in the same
function: spawn a new `asyncio.create_task(...)` for the pending event
and return so the current frame can unwind.  The new task takes
ownership via `_session_tasks[session_key]`.

The late-arrival drain in `finally` could now race with the in-band
drain across the `await typing_task` / `await stop_typing` window, so
add a guard: if `_session_tasks[session_key]` is no longer the current
task, an in-band drain already spawned a follow-up task — re-queue the
late-arrival event so that task picks it up after its current event,
instead of spawning a second concurrent task for the same session_key.

Regression test (`test_pending_drain_no_recursion.py`) chains 12
follow-ups and asserts the recorded
`_process_message_background` stack depth stays bounded at handler
entry.  Pre-fix: depths grow linearly `[1,2,3,…,12]`.  Post-fix: all
depths are `1`.

`test_duplicate_reply_suppression::test_stale_response_suppressed_when_interrupted`
called `_process_message_background` directly and implicitly relied on
the old recursive `await` semantic — updated to wait for the spawned
drain task before checking the sent list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 03:27:08 -07:00
vominh1919 cb130bf776 fix(ssh): prevent tar from overwriting remote home dir permissions
tar xf - -C / extracts the staging directory tree to the remote root.
GNU tar default behavior overwrites metadata (including mode) of existing
directories. When the local umask is 002 (Ubuntu default), the staging
dirs are 0775, and tar chmod's /home/<user> to 0775 — breaking sshd
StrictModes which requires 0755 or stricter for home dirs.

Add --no-overwrite-dir to the remote tar command so existing directory
metadata is preserved.

Fixes #17767
2026-04-30 03:26:35 -07:00
Teknium 8d302e37a8 feat(tts): add Piper as a native local TTS provider (closes #8508) (#17885)
Piper (OHF-Voice/piper1-gpl) is a fast, local neural TTS engine from the
Home Assistant project that supports 44 languages with zero API keys.
Adds it as a native built-in provider alongside edge/neutts/kittentts,
installable via 'hermes tools' with one keystroke.

What ships:

- New 'piper' built-in provider in tools/tts_tool.py
  - Lazy import via _import_piper()
  - Module-level voice cache keyed on (model_path, use_cuda) so switching
    voices doesn't invalidate older cached voices
  - _resolve_piper_voice_path() accepts either an absolute .onnx path or a
    voice name (auto-downloaded on first use via 'python -m
    piper.download_voices --download-dir <cache>')
  - Voice cache at ~/.hermes/cache/piper-voices/ (profile-aware via
    get_hermes_dir)
  - Optional SynthesisConfig knobs: length_scale, noise_scale,
    noise_w_scale, volume, normalize_audio, use_cuda — passed through
    only when configured, so older piper-tts versions aren't broken
  - WAV output then ffmpeg conversion path (same as neutts/kittentts) so
    Telegram voice bubbles work when ffmpeg is present
  - Piper added to BUILTIN_TTS_PROVIDERS so a user's
    tts.providers.piper.command cannot shadow the native provider
    (regression test included)

- 'hermes tools' wizard entry
  - Piper appears under Voice and TTS as local free, with
    'pip install piper-tts' auto-install via post_setup handler
  - Prints voice-catalog URL and default-voice info after install

- config.yaml defaults
  - tts.piper.voice defaults to en_US-lessac-medium
  - Commented advanced knobs for discoverability

- Docs
  - New 'Piper (local, 44 languages)' section in features/tts.md
    explaining install path, voice switching, pre-downloaded voices,
    and advanced knobs
  - Piper listed in the ten-provider table and ffmpeg table
  - Custom-command-providers section updated to drop the Piper example
    (now native) and add a piper-custom example for users with their own
    trained .onnx models
  - overview.md bumps provider count to ten

- Tests (tests/tools/test_tts_piper.py, 16 tests)
  - Registration (BUILTIN_TTS_PROVIDERS, PROVIDER_MAX_TEXT_LENGTH)
  - _resolve_piper_voice_path across every branch: direct .onnx path,
    cached voice name, fresh download with correct CLI args, download
    failure, successful-exit-but-missing-files, empty voice to default
  - _generate_piper_tts: loads voice once, reuses cache, voice-name
    download wiring, advanced knobs flow through SynthesisConfig
  - text_to_speech_tool end-to-end dispatch and missing-package error
  - check_tts_requirements: piper availability toggles the return value
  - Regression guard: piper cannot be shadowed by a command provider
    with the same name
  - Pre-existing test_tts_mistral test broadened to mock the new
    piper/kittentts/command-provider checks (otherwise it false-passes
    when piper is installed in the test venv)

E2E verification (live):

Actual pip install piper-tts, config piper + en_US-lessac-low,
text_to_speech_tool call, voice auto-downloaded from HuggingFace,
WAV synthesized, ffmpeg-converted to Ogg/Opus. Second call hits the
cache (~60ms). Cache dir populated with .onnx and .onnx.json.

This caught a real bug during development: the first pass used '-d' as
the download-dir flag; the actual piper.download_voices CLI wants
'--download-dir'. Fixed before PR opened.
2026-04-30 02:53:20 -07:00
Teknium 2662bfb756 fix(tests): make test_update_stale_dashboard immune to hermes_cli.main reload (#17881)
Six tests in this file failed in CI (-n auto) after #17832 landed because
other tests on the same xdist worker reload hermes_cli.main:

  tests/hermes_cli/test_env_loader.py:85-86
    sys.modules.pop('hermes_cli.main', None)
    importlib.import_module('hermes_cli.main')

  tests/hermes_cli/test_skills_subparser.py:24-25
    del sys.modules['hermes_cli.main']

When either ran first on a worker, our top-of-file
'from hermes_cli.main import _kill_stale_dashboard_processes' captured a
stale function object whose __globals__ points at the old module dict.
patch('hermes_cli.main._find_stale_dashboard_pids', ...) then patched the
new module, but the stale function resolved the dependency via its stale
__globals__, so every patch became a no-op: pids=[] → early return → no
signals, no output, assertions failed.

Fix: add an autouse fixture that rebinds the three module-level names to
whatever is currently live in sys.modules['hermes_cli.main'] before each
test runs. The pollutants in the other two files are load-bearing for
their own tests, so fixing it on the consumer side is correct.

Repro: pytest tests/hermes_cli/test_env_loader.py tests/hermes_cli/test_update_stale_dashboard.py
2026-04-30 02:46:56 -07:00
Teknium 0da968e521 fix(curator): unify under auxiliary.curator (hermes model, dashboard) (#17868)
Voscko reported curator.auxiliary.provider/model was advertised in the
docs but ignored — the review fork read only model.provider/default. The
narrow fix would wire the one-off key through, but that leaves curator
as a parallel system: not in `hermes model` → auxiliary picker, not in
the dashboard Models tab, missing per-task base_url/api_key/timeout/
extra_body.

Unify curator with the rest of the aux task system so `hermes model`
and the dashboard configure it like every other aux task.

Four sources of truth updated:
- hermes_cli/config.py — add 'curator' slot to DEFAULT_CONFIG.auxiliary
  (timeout=600 since reviews run long), drop the one-off curator.auxiliary
  block from DEFAULT_CONFIG.curator.
- hermes_cli/main.py — add ('curator', 'Curator', 'skill-usage review pass')
  to _AUX_TASKS so the CLI picker offers it.
- hermes_cli/web_server.py — add 'curator' to _AUX_TASK_SLOTS so the
  dashboard REST endpoint accepts it.
- web/src/pages/ModelsPage.tsx — add Curator entry so the dashboard
  Models tab renders the task.

agent/curator.py _resolve_review_model() now reads auxiliary.curator
first (canonical), falls back to legacy curator.auxiliary (with an info
log asking users to migrate), then falls back to the main chat model.
Pre-unification users keep working.

Docs updated: docs/user-guide/features/curator.md now points at
`hermes model` → auxiliary → Curator and the dashboard Models tab.

Tests: 6 unit tests on _resolve_review_model (auto default, canonical
slot honored, partial override fallback, legacy fallback with
deprecation log assertion, new-wins-over-legacy, empty-config safety)
plus a cross-registry test that curator is wired into all four sources
of truth. test_aux_tasks_keys_all_exist_in_default_config already
covers the DEFAULT_CONFIG ↔ _AUX_TASKS invariant.

Reported by Voscko on Discord.
2026-04-30 02:46:01 -07:00
teknium1 658947480a fix(acp): drop dead message_id kwarg from replay chunks
UserMessageChunk and AgentMessageChunk do not have a message_id field
in the ACP schema. Passing it silently dropped the kwarg (pydantic
does not raise on unknown init kwargs here) and the subsequent test
assertions on .message_id raised AttributeError. Strip the dead
plumbing (uuid import, message_id= kwarg on both chunk types, unused
session_id/index parameters) and remove the matching .message_id
asserts from the test.
2026-04-30 02:45:54 -07:00
Henkey d2536a72bf fix(acp): replay session history on load 2026-04-30 02:45:54 -07:00
teknium1 5d253e65b7 fix(openviking): pre-check fs/stat to route file URIs before hitting directory-only endpoints
Adds a deterministic pre-check on top of htsh's exception-based fallback:
before calling /content/abstract or /content/overview on a non-pseudo URI,
probe /api/v1/fs/stat. If the server says the URI is a file, route straight
to /content/read instead of eating a failing 500 round-trip.

This is the same idea pty819 and chennest independently landed in PRs
#12757 and #12937 — merged here on top of htsh's broader fix so we keep
pseudo-URI normalization and v0.3.3 browse-shape handling while avoiding
the slow exception path on servers that return a raised 500 every time.

The exception fallback from #5886 stays in place for environments where
fs/stat is unavailable or returns an unfamiliar shape.

Also credits pty819, chennest, and htsh in AUTHOR_MAP so future release
notes attribute them correctly.
2026-04-30 02:35:29 -07:00
hitesh 10e43edc09 fix(openviking): fallback summary reads to content/read for file URIs
OpenViking returns 500 for /content/abstract and /content/overview when URI points to mem_*.md files.
Add resilient fallback to /content/read for non-pseudo summary file URIs while preserving pseudo summary normalization.
Also add regression tests for fallback behavior.
2026-04-30 02:35:29 -07:00
hitesh bff8ab0311 test(openviking): add helper regression coverage 2026-04-30 02:35:29 -07:00
Hitesh Aidasani 97a851bf97 fix(openviking): normalize summary pseudo-URIs to prevent v0.3.3 500s
OpenViking v0.3.3 expects directory URIs for abstract/overview reads.
Passing pseudo-files like /.overview.md and /.abstract.md to
/api/v1/content/overview|abstract triggers HTTP 500.

This change normalizes those pseudo-URIs to their parent directory for
abstract/overview requests, preserves full reads, and hardens parsing for
wrapped/unwrapped result payloads and fs list response shapes.
2026-04-30 02:35:29 -07:00
Teknium 25caaa4a70 feat(tips): add cost-saving tips from April 30 tip-of-the-day (#17841)
Seed the tips corpus with the knobs users can turn to reduce token
spend: hermes tools / hermes skills config to trim surface area,
/reasoning low|minimal to dial thinking depth down from the medium
default, and hermes models to route auxiliary tasks (vision, compression,
title gen, session_search) to cheaper backends while the main chat model
stays intact.

Requested by @micheltamanda under Teknium's tip-of-the-day tweet.
2026-04-30 02:30:36 -07:00
Teknium 0ad4f55aa8 feat(dashboard): add --stop and --status flags (#17840)
`hermes dashboard` is a long-lived foreground server that users often
start and forget about, sometimes in a shell they've since closed.  We
didn't have a way to stop it — users had to find the PID manually.

Adds two lifecycle flags that reuse the same detection + termination
path the post-`hermes update` cleanup (PR #17832) uses:

  hermes dashboard --status
    List running hermes dashboard processes with PID + cmdline.
    Exit 0, informational.

  hermes dashboard --stop
    Terminate all running dashboards (3s grace then force-kill survivors).
    Exit 0 if none remain, 1 if any couldn't be stopped.
    Windows uses `taskkill /F` as before.

Both flags short-circuit before any fastapi/uvicorn import so they work
even on installations where the dashboard extras aren't installed —
useful when you're cleaning up after uninstalling.

The kill helper gained an optional `reason=...` param so the output
reads "(requested via --stop)" instead of the post-update-specific
"running backend no longer matches the updated frontend" wording.

E2E: `hermes dashboard --status` with nothing running prints the
empty message; with a fake `hermes dashboard ...` cmdline spawned via
`exec -a`, `--status` lists it, `--stop` terminates it (exit -15),
and a follow-up `--status` returns empty.
2026-04-30 02:30:20 -07:00
Teknium 2facea7f71 feat(tts): add command-type provider registry under tts.providers.<name> (#17843)
Reshape of PR #17211 (@versun). Lets users wire any local or external
TTS CLI into Hermes without adding engine-specific Python code. Users
declare any number of named providers in config.yaml and switch between
them with tts.provider: <name>, alongside the built-ins (edge, openai,
elevenlabs, …).

Config shape:

  tts:
    provider: piper-en
    providers:
      piper-en:
        type: command
        command: 'piper -m ~/model.onnx -f {output_path} < {input_path}'
        output_format: wav

Placeholders: {input_path}, {text_path}, {output_path}, {format},
{voice}, {model}, {speed}. Use {{ / }} for literal braces.

Key behavior:
- Built-in provider names always win — a tts.providers.openai entry
  cannot shadow the native OpenAI provider.
- type: command is the default when command: is set.
- Placeholder values are shell-quote-aware (bare / single / double
  context), so paths with spaces and shell metacharacters are safe.
- Default delivery is a regular audio attachment. voice_compatible: true
  opts in to Telegram voice-bubble delivery via ffmpeg Opus conversion.
- Command failures (non-zero exit, timeout, empty output) surface to
  the agent with stderr/stdout included so you can debug from chat.
- Process-tree kill on timeout (Unix killpg, Windows taskkill /T).
- max_text_length defaults to 5000 for command providers; override
  under tts.providers.<name>.max_text_length.

Tests: tests/tools/test_tts_command_providers.py — 42 new tests cover
provider resolution, shell-quote context, placeholder rendering with
injection payloads, timeout, non-zero exit, empty output, voice_compatible
opt-in, and end-to-end dispatch through text_to_speech_tool. All 88
pre-existing TTS tests still pass.

Docs: new "Custom command providers" section in
website/docs/user-guide/features/tts.md with three worked examples
(Piper, VoxCPM, MLX-Kokoro), placeholder reference, optional keys,
behavior notes, and security caveat.

E2E-verified live: isolated HERMES_HOME, command provider declared in
config.yaml, text_to_speech_tool dispatches through the registered
shell command and the output file is produced as expected.

Co-authored-by: Versun <me+github7604@versun.org>
2026-04-30 02:29:08 -07:00
Teknium 5b85a7d351 fix(update): kill stale dashboard processes instead of warning (#17832)
`hermes update` previously just printed a warning when it detected a
running `hermes dashboard` process from the previous version, telling
the user to kill and restart it themselves.  In practice dashboards get
started and forgotten, so the warning was routinely ignored and users
ended up with a silent frontend/backend mismatch (new JS bundle served
against the old in-memory Python backend, e.g. new auth headers the old
code doesn't recognise → every API call 401s).

The dashboard has no service manager, no PID file, and we don't record
the original launch args (--host, --port, --insecure, --tui, --no-open)
so we can't auto-restart it.  But we CAN stop it, which is what the
user wants — the failure mode when the stale process is left alive is
worse than the dashboard just being down.

- POSIX: SIGTERM, poll for ~3s, SIGKILL any survivors.
- Windows: `taskkill /PID <pid> /F`.
- Print each PID's outcome plus a one-line restart hint.
- Detection logic is unchanged (same ps / wmic scan, same guards
  against the `pgrep -f` greedy-match trap from #16872 and the
  #17049 wmic UnicodeDecodeError fix).

Also split the old monolithic `_warn_stale_dashboard_processes` into
`_find_stale_dashboard_pids` (scan) + `_kill_stale_dashboard_processes`
(kill), keeping the old name as an alias so any external callers still
work.

E2E verified: spawned a fake `hermes dashboard` cmdline via
`exec -a 'hermes dashboard …' sleep 300`, ran
`_kill_stale_dashboard_processes()`, confirmed SIGTERM exit (-15)
and that a post-scan returns an empty PID list.
2026-04-30 01:34:34 -07:00
Teknium fd0796947f fix: stabilize CI — TS widen, sys.modules restore, WS subscriber race (#17836)
Three narrow fixes targeting the remaining red checks after #17828:

1. ui-tui/src/app/slash/commands/ops.ts (Docker Build):
   /reload-mcp's local params type annotated session_id: string
   while ctx.sid is string | null. Widen to string | null —
   matches every other rpc call site and the test harness which passes
   { session_id: null }. Fixes TS2322 on line 86. The rpc signature
   itself is Record<string, unknown>, so this is purely a local
   typing fix, no behavioral change.

2. tests/plugins/test_achievements_plugin.py (13 cascading test failures):
   _install_fake_session_db did a raw sys.modules['hermes_state'] =
   fake_module without restoration, leaking the fake across xdist
   worker boundaries. Downstream tests doing from hermes_state import
   SessionDB got a module whose SessionDB was lambda: fake_db
   — 6 test_hermes_state.py tests failed with AttributeError: 'function'
   object has no attribute '_sanitize_fts5_query' / _contains_cjk,
   and 7 test_860_dedup.py tests failed with TypeError: got unexpected
   keyword argument 'db_path' (real code calls SessionDB(db_path=...)).

   Fix: stash monkeypatch on the plugin_api module object in the
   fixture, and have the helper do monkeypatch.setitem(sys.modules,
   'hermes_state', fake_module) for auto-restoration at test teardown.

3. tests/hermes_cli/test_web_server.py (WS race):
   TestPtyWebSocket::test_pub_broadcasts_to_events_subscribers hit the
   30s test timeout on CI. websocket_connect returns after
   ws.accept() — but /api/events registers the subscriber in
   _event_channels on the NEXT await (inside _event_lock). A
   publish immediately after connect could race ahead of registration
   and be dropped, and the subsequent receive_text() blocked until
   SIGALRM killed the test. Fix: poll _event_channels after the
   subscriber connects, before publishing.

Validation:
scripts/run_tests.sh tests/plugins/test_achievements_plugin.py
                     tests/run_agent/test_860_dedup.py
                     tests/test_hermes_state.py
                     tests/hermes_cli/test_web_server.py    338 passed
cd ui-tui && npm run type-check                             clean
cd ui-tui && npm run build                                  clean

Remaining red checks are pure infra (Nix ubuntu hits
TwirpErrorResponse ResourceExhausted on the GH Actions cache API; Nix
macos bounces between npm build openssl-legacy and cache rate-limits)
and cannot be fixed in the codebase.
2026-04-30 01:34:08 -07:00
Teknium aa7bf329bc feat(gateway): centralize audio routing + FLAC support + Telegram doc fallback (#17833)
Extracted from PR #17211 (@versun) so it can land independently of the
local_command TTS provider redesign.

- Add should_send_media_as_audio(platform, ext, is_voice) in
  gateway/platforms/base.py; single source of truth for audio routing.
- Add .flac to recognized audio extensions (MEDIA regex, weixin audio
  set, send_message audio set).
- Telegram send_voice() now falls back to send_document for formats
  Telegram's Bot API can't play natively (.wav, .flac, ...) instead of
  raising; MP3/M4A still go to sendAudio, Opus/OGG still go to sendVoice.
- Route _send_telegram() in send_message_tool through a narrower
  _TELEGRAM_SEND_AUDIO_EXTS = {.mp3, .m4a} set.
- cron.scheduler._send_media_via_adapter now delegates the audio
  decision to should_send_media_as_audio so it matches the gateway.
- Update the cron live-adapter ogg test to flag [[audio_as_voice]] so
  it still routes to sendVoice under the new Telegram-specific policy.
- Tests: unit coverage for should_send_media_as_audio across platforms,
  end-to-end MEDIA routing via _process_message_background and
  GatewayRunner._deliver_media_from_response, TelegramAdapter.send_voice
  fallback for FLAC/WAV.

Co-authored-by: Versun <me+github7604@versun.org>
2026-04-30 01:32:31 -07:00
Teknium 26787ce638 test(gateway): isolate plugin adapter imports and guard the anti-pattern
Fixes the xdist collision that broke CI on PR #17764, and structurally
prevents future plugin-adapter tests from reintroducing it.

Problem
-------
tests/gateway/test_teams.py (new in this PR) and tests/gateway/test_irc_adapter.py
(already on main) both followed the same anti-pattern:

  sys.path.insert(0, str(_REPO_ROOT / 'plugins' / 'platforms' / '<name>'))
  from adapter import <Adapter>

Every platform plugin ships its own adapter.py, so the bare
'from adapter import ...' races for sys.modules['adapter']. Whichever test
collected first in a given xdist worker won; the other crashed at
collection with ImportError, and the polluted sys.path cascaded into 19
unrelated test failures across tools/, hermes_cli/, and run_agent/ in the
same worker.

Fix
---
1. tests/gateway/_plugin_adapter_loader.py (new): shared helper
   load_plugin_adapter('<name>') that imports plugins/platforms/<name>/adapter.py
   via importlib.util under the unique module name plugin_adapter_<name>.
   Zero sys.path mutation, no possibility of collision.

2. tests/gateway/test_irc_adapter.py and tests/gateway/test_teams.py:
   migrated to the helper. All 'from adapter import ...' statements
   (including the ones inside test methods) are replaced with module-level
   attribute access on the loaded module.

3. tests/gateway/conftest.py: new pytest_configure guard that AST-scans
   every test_*.py under tests/gateway/ at session start and fails the
   run with a pointer to the helper if any test uses sys.path.insert into
   plugins/platforms/ OR a bare 'import adapter' / 'from adapter import'.
   Runs on the xdist controller only (skipped in workers). The next plugin
   adapter test that tries to reintroduce this pattern gets rejected at
   collection time with a clear remediation message.

4. scripts/release.py: add aamirjawaid@microsoft.com -> heyitsaamir to
   AUTHOR_MAP so the check-attribution workflow passes.

Validation
----------
scripts/run_tests.sh tests/gateway/                    4194 passed
scripts/run_tests.sh tests/gateway/test_{teams,irc}*   72 passed (both orderings)
scripts/run_tests.sh <11 prev-failing test files>      398 passed
Guard triggers correctly on both Path-operator and string-literal forms
of the anti-pattern.
2026-04-30 01:19:34 -07:00
Aamir Jawaid e23bb18dac fix(teams): rewrite interactive_setup to use teams CLI flow
Replace the Azure portal credential prompts with the teams CLI
workflow: install @microsoft/teams.cli, run teams app create,
paste the output credentials. Matches the setup docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 01:19:34 -07:00
Aamir Jawaid 45780edbbf feat(teams): keep card body visible after approval button click
Pass cmd/desc in button action data so the card response can
reconstruct the original body. Clicking a button now replaces
only the actions with a status line, keeping the command and
reason text visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 01:19:34 -07:00
Aamir Jawaid 39b0bc377c fix(teams): override send_image_file for local image attachments
The gateway calls send_image_file() for locally cached images
(e.g. from image_gen tools). Without this override the base class
falls back to sending the file path as plain text. Delegate to
send_image() which already handles base64 encoding local paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 01:19:34 -07:00
Aamir Jawaid ca5bebef00 fix(teams): send images as attachments instead of markdown links
Teams doesn't render markdown image syntax. Send images using the SDK's
Attachment API instead — base64 data URI for local files, direct URL
for remote images.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 01:19:34 -07:00
Aamir Jawaid a696bceafa fix(tools_config): handle plugin platforms in platform_tool_universe
_get_platform_tools() correctly fell back to f"hermes-{platform}" for
unknown (plugin) platforms when building toolset_names, but then
unconditionally used PLATFORMS[platform] again for platform_tool_universe,
causing KeyError for any plugin-registered platform like Teams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 01:19:34 -07:00
Aamir Jawaid b3137d758c feat(teams): add Microsoft Teams platform adapter as a plugin
Hello! I am the maintainer of the microsoft-teams-apps Python SDK and
I built this Teams adapter to integrate Microsoft Teams into Hermes.

Adds a `plugins/platforms/teams` platform plugin using the new
PlatformRegistry system from #17751. The adapter self-registers via
`register(ctx)` — no hardcoding in run.py, toolsets.py, or any
other core file.

Key features:
- Supports personal DMs, group chats, and channel posts
- Adaptive Card approval prompts with in-place button replacement
  (Allow Once / Allow Session / Always Allow / Deny)
- aiohttp webhook server bridged from the Teams SDK to avoid
  the fastapi/uvicorn dependency
- ConversationReference caching for correct proactive sends in
  non-DM chats
- `interactive_setup()` for `hermes gateway setup` integration
- `platform_hint` for LLM context (Teams markdown subset)
- 34 tests covering adapter init, send, message handling, and
  plugin registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 01:19:34 -07:00
Teknium 21e695fcb6 fix: clean up defensive shims and finish CI stabilization from #17660 (#17801)
PR #17660 landed a sweep of CI fixes but left three loose ends:

1. tests/cli/test_cli_loading_indicator.py::test_reload_mcp_sets_busy_state_
   and_prints_status — /reload-mcp gained a prompt-cache-invalidation
   confirmation (commit 4d7fc0f37) that was never wired into this test.
   The test exercises the loading-indicator path, so pre-approve via
   config and go straight into _reload_mcp().

2. tools/mcp_tool.py _make_tool_handler — the added
   getattr(server, '_rpc_lock', None) + 'skip the lock if missing'
   branch is inconsistent with four sibling call sites that still
   direct-access server._rpc_lock. The lock is guaranteed by
   MCPServerTask.__init__; falling through to an unlocked
   session.call_tool would silently serialize-strip RPCs if the guard
   ever triggered. Restore direct access.

3. tui_gateway/server.py _messages_as_conversation — the helper
   existed only to catch 'TypeError: include_ancestors unexpected'
   from mocked SessionDBs that don't actually exist. The real
   SessionDB.get_messages_as_conversation has accepted
   include_ancestors since introduction, and every test FakeDB in
   the repo already declares the kwarg. Remove the shim, inline the
   two call sites.
2026-04-29 23:53:17 -07:00
Teknium 3c27efbb91 feat(dashboard): configure main + auxiliary models from Models page (#17802)
Dashboard Models page was analytics-only — no way to pick a model as main
for new sessions or override an auxiliary task slot without hand-editing
config.yaml or running a /model slash command inside a chat.

Changes:
- hermes_cli/web_server.py: three REST endpoints (GET /api/model/options,
  GET /api/model/auxiliary, POST /api/model/set). Reuses
  list_authenticated_providers() from model_switch.py so the REST path
  surfaces the same curated model lists as the TUI-gateway model.options
  JSON-RPC. POST /api/model/set writes model.provider + model.default for
  scope=main, and auxiliary.<task>.{provider,model} for scope=auxiliary
  (with task="" meaning 'all 8 slots' and task="__reset__" resetting them
  to auto).
- web/src/components/ModelPickerDialog.tsx: accepts an optional loader +
  onApply pair so it works without an open chat PTY. ChatSidebar's
  gw-WebSocket path still works unchanged (back-compat).
- web/src/pages/ModelsPage.tsx: Model Settings panel at the top showing
  main model + collapsible list of 8 auxiliary tasks with per-row Change
  buttons and Reset all to auto. Every existing model card gets a
  'Use as' dropdown for one-click assignment to main or any aux slot.
  Cards badged 'main' or 'aux · <task>' when currently assigned.
- website/docs/user-guide/configuring-models.md: new docs page walking
  through both UI paths, aux task override patterns, troubleshooting,
  plus REST/CLI alternatives.
- Screenshots under website/static/img/docs/dashboard-models/.

Applies to new sessions only — running sessions keep their model (use
/model slash command to hot-swap a live session). No prompt-cache
invalidation on existing sessions.
2026-04-29 23:53:12 -07:00
emozilla 718e4e2e7e fix(plugins): register dynamically-loaded modules in sys.modules before exec
Dashboard plugin API routes (web_server._mount_plugin_api_routes) and
gateway event hooks (gateway.hooks.HookRegistry.discover_and_load) both
loaded Python files via importlib.util.spec_from_file_location +
exec_module without registering the resulting module in sys.modules.

That breaks any plugin or hook handler that uses `from __future__ import
annotations` together with a Pydantic BaseModel / dataclass / anything
that introspects `__module__`: at first request Pydantic tries to
resolve string-form type hints against the defining module's namespace,
can't find it by name, and raises:

  PydanticUserError: TypeAdapter[...] is not fully defined;
  you should define ... and all referenced types,
  then call `.rebuild()` on the instance.

This is what broke the kanban dashboard's 'triage' button — POST
/api/plugins/kanban/tasks validated against CreateTaskBody (a Pydantic
model in a file using `from __future__ import annotations`) and
returned 500 on every click.

The fix, applied symmetrically to both loaders:

  1. Compute module_name once.
  2. Register the module in sys.modules BEFORE exec_module.
  3. On exec_module failure, pop the half-initialized stub so subsequent
     reloads don't pick up broken state.

GETs were unaffected because they don't build a body TypeAdapter, which
is why this only surfaced when users started POSTing.
2026-04-29 23:34:35 -07:00
Teknium 62a5d7207d feat(plugins): bundle hermes-achievements + scan full session history (#17754)
* feat(plugins): bundle hermes-achievements, scan full session history

Ships @PCinkusz's hermes-achievements dashboard plugin (https://github.com/PCinkusz/hermes-achievements) as a bundled plugin at plugins/hermes-achievements/ and fixes a bug in the scan path that made the plugin only see the first 200 sessions — making lifetime badges (50k tool calls, 75k errors, etc.) unreachable on long-running installs.

Changes:

- plugins/hermes-achievements/: vendor v0.3.1 verbatim (manifest, dist/, plugin_api.py, tests, docs, README).
- plugins/hermes-achievements/dashboard/plugin_api.py:
  * scan_sessions(): limit=None now scans ALL sessions via SQLite LIMIT -1. Previously capped at 200, so users with 8000+ sessions saw ~2% of their history.
  * evaluate_all(): first-ever scans run in a background thread so the dashboard request path never blocks. Stale snapshots serve immediately while a background refresh runs. force=True still blocks synchronously for manual /rescan.
  * _build_pending_snapshot(), _start_background_scan(), _run_scan_and_update_cache(): supporting plumbing + idempotent thread spawn.
- tests/plugins/test_achievements_plugin.py: new tests covering the 200-cap regression, the background-scan first-run flow, stale-serve-plus-background-refresh, forced sync rescan, and scan-thread idempotency.
- website/docs/user-guide/features/built-in-plugins.md: lists hermes-achievements in the bundled-plugins table and documents API endpoints, state files, and performance characteristics.

E2E validated against a real 8564-session ~6.4GB state.db:
  * Cold scan: 13m 19s (one-time, backgrounded — UI never blocks)
  * Warm rescan: 1.47s (8563/8564 sessions reused from checkpoint cache)
  * 57/60 achievements unlocked, 3 discovered — aggregates like total_tool_calls=259958, total_errors=164213, skill_events=368243 correctly surface lifetime badges that the 200-cap made unreachable.

Original credit: @PCinkusz (MIT-licensed). Upstream repo remains the staging ground for new badges; this bundle keeps the dashboard feature parity with Hermes core changes.

* feat(achievements): publish partial snapshots during cold scan

Previously a cold scan on a large session DB (13min on 8564 sessions)
showed zero badges for the entire duration, then every badge at once
when the scan completed. A dashboard refresh mid-scan was indistinguishable
from a fresh install with no history.

Now the scanner publishes a partial snapshot to _SNAPSHOT_CACHE every
250 sessions, so each refresh during a cold scan surfaces more badges
incrementally.

Mechanism:
- scan_sessions() takes an optional progress_callback fired every
  progress_every sessions with (sessions_so_far, scanned, total).
- _compute_from_scan() is extracted from compute_all() and gains an
  is_partial flag that skips writing to state.json — we don't want
  to record unlocked_at based on a half-complete aggregate that a
  later session might rebalance.
- _run_scan_and_update_cache() installs a publisher callback that
  builds a partial snapshot, marks it mode='in_progress', and writes
  it to the cache with age=0 so the UI keeps polling /scan-status
  and picks up the final snapshot when the scan completes.
- Manual /rescan (force=True) disables partial publishing — the
  caller is blocking on the final result anyway.

E2E against real 8564-session state.db (polled cache every 10s):
  t=10s: cache empty
  t=20s: 250/8564 scanned, 35 unlocked, 25 discovered
  t=40s: 500/8564 scanned, 42 unlocked, 18 discovered
  t=60s: 1000/8564 scanned, 49 unlocked, 11 discovered
  ...

Tests: 9/9 pass (2 new — partial snapshot publication + no-persist-on-partial).
Upstream unittest suite: 10/10 pass.

* feat(achievements): in-progress scan banner with live % progress

Previously the dashboard showed zero badges silently during long cold
scans (13min on 8564 sessions). The backend was publishing partial
snapshots every 250 sessions, but the bundled UI didn't surface any
indicator that a scan was running — it just rendered the main page
with whatever counts were currently published and no way for the user
to know more progress was coming.

UI changes (dist/index.js, dist/style.css):

- Added a scan-in-progress banner rendered between the hero and stats
  when scan_meta.mode is 'pending' or 'in_progress'. Shows:
    BUILDING ACHIEVEMENT PROFILE…
    Scanned 1,750 of 8,564 sessions · 20%. Badges unlock as more history streams in.
  with a pulsing teal indicator and a filling teal/cyan progress bar.
  Disappears the moment the backend flips to 'full' or 'incremental'.

- Added an auto-poller via useEffect — while scanInFlight is true the
  page re-fetches /achievements every 4s WITHOUT toggling the loading
  skeleton, so unlock counts tick up visibly without the user refreshing.
  The effect cleans itself up when the scan finishes.

- Added refresh() (re-fetch, no loading flip) alongside the existing
  load() (full reload, used by the Rescan button).

Attribution preserved:

- Added a header comment to index.js crediting @PCinkusz
  (https://github.com/PCinkusz/hermes-achievements, MIT) as the
  original author, noting the banner is a layered addition on top
  of the original dist bundle.
- Matching header comment in style.css, flagging the new
  .ha-scan-banner* rules as the local addition.

Live-verified end to end:

- Spun up `hermes dashboard --port 9229 --no-open` against a fresh
  HERMES_HOME symlinked to the real 8564-session state.db.
- Opened /achievements in a browser, confirmed the banner renders with
  live progress: 'Scanned 1,000 of 8,564 sessions · 11%' → updates to
  '1,250 ... · 14%' → '1,750 ... · 20%' without user interaction,
  matching the backend's partial publications.
- Stats row simultaneously climbed from 35 → 49 → 53 unlocked as
  more history streamed in.
- Vision analysis of the rendered page confirms the banner styling
  matches the rest of the dashboard (dark card bg, teal accent, same
  small-caps typography, pulsing indicator reusing ha-pulse keyframes).
2026-04-29 23:23:57 -07:00
Teknium ce0c3ae493 fix(aux): remove hardcoded Codex fallback model, drop Codex from auto chain (#17765)
The _CODEX_AUX_MODEL constant had already rotated twice in 6 weeks
(gpt-5.3-codex -> gpt-5.2-codex -> now broken again at gpt-5.2-codex)
because ChatGPT-account Codex gates which models it accepts via an
undocumented, shifting allow-list that OpenAI publishes no changelog
for.  Any pinned default will keep going stale.  Issue #17533 reports
the current breakage: every ChatGPT-account auxiliary fallback fails
with HTTP 400 "model is not supported" and the 60s pause loop degrades
long sessions.

Rather than reset the clock with another stale pin (PR #17544 proposes
gpt-5.2-codex -> gpt-5.4), remove the hardcoded second-order Codex
fallback entirely:

- Delete `_CODEX_AUX_MODEL`.
- Drop `_try_codex` from `_get_provider_chain()` (the auto chain now
  ends at api-key providers; 4 rungs instead of 5).
- Rename `_try_codex() -> _build_codex_client(model)` and require an
  explicit model from the caller.  No more guessing.
- `resolve_provider_client("openai-codex", model=None)` now warns and
  returns (None, None) instead of silently guessing a stale model ID.
- Remove `_try_codex` from the `provider="custom"` fallback ladder
  (same stale-constant trap).
- `_resolve_strict_vision_backend("openai-codex")` routes through
  `resolve_provider_client` so the caller's explicit model is honored.

Codex-main users are unaffected: Step 1 of `_resolve_auto` already
uses `main_provider` + `main_model` directly and passes the user's
configured Codex model through `resolve_provider_client`, which never
touched `_CODEX_AUX_MODEL`.  Per-task overrides (`auxiliary.<task>.provider/model`)
continue to work and are the supported way to route specific aux tasks
through Codex.

Users whose main provider fails with a payment/connection error and
who have ONLY ChatGPT-account Codex auth will now see the 60s pause
without a stale-model-rejection noise line in between -- same outcome,
cleaner failure.

Closes #17533.  Supersedes #17544 (which resets the clock on the
same stale-constant problem).
2026-04-29 23:23:50 -07:00
Stephen Schoettler f73364b1c4 fix(ci): stabilize main test suite regressions (#17660)
* fix: stabilize main test suite regressions

* test(agent): update MiniMax normalization expectation

* test: stabilize remaining CI assertions

* test: harden config helper monkeypatching

* test: harden CI-only assertions

* fix(agent): propagate fast streaming interrupts
2026-04-29 23:18:55 -07:00
Ben Barclay e7beaaf184 Merge pull request #17694 from NousResearch/fix/docker-add-curl
fix(docker): add curl to apt dependencies
2026-04-30 15:45:37 +10:00
Ben Barclay b06a06e608 fix(docker): restore trailing newline on Dockerfile
Drop the unrelated final-newline deletion; keep only the curl addition.
2026-04-30 15:44:57 +10:00
Teknium 828d3a320b fix(anthropic): reactive recovery for OAuth 1M-context beta rejection (#17752)
Keep context-1m-2025-08-07 in OAuth requests by default so 1M-capable
subscriptions retain full context. When Anthropic rejects a request with
400 'long context beta is not yet available for this subscription',
disable the beta for the rest of the session, rebuild the client, and
retry once.

Addresses #17680 (thanks @JayGwod for the clean reproduction) without
forcing every OAuth user off the 1M context window.

Changes:
- agent/error_classifier.py: new FailoverReason.oauth_long_context_beta_forbidden;
  pattern matches 400 + 'long context beta' + 'not yet available'. Narrow
  enough that the existing 429 tier-gate pattern keeps its own reason.
- agent/anthropic_adapter.py: _common_betas_for_base_url,
  build_anthropic_client, build_anthropic_kwargs gain drop_context_1m_beta
  kwarg. Default=False (1M stays). OAuth OAUTH_ONLY_BETAS unchanged.
- agent/transports/anthropic.py: build_kwargs forwards the flag.
- run_agent.py: self._oauth_1m_beta_disabled flag, retry-once guard,
  recovery branch next to the image-shrink path. _rebuild_anthropic_client
  honors the flag. The main build_kwargs call site threads it through for
  fast-mode extra_headers.
- hermes_cli/doctor.py, hermes_cli/models.py: sibling OAuth /v1/models
  probes get the same reactive retry — previously they'd falsely report
  the Anthropic API as unreachable for affected subscriptions.

Tests: 2190 tests/agent/ + 94 adjacent integration tests pass. New unit
tests cover the classifier pattern (including the collision guard against
the 429 tier-gate) and the drop_context_1m_beta adapter behavior (default
keeps 1M, flag strips only 1M while preserving every other beta).
2026-04-29 21:56:54 -07:00
Teknium 4d363499db feat(plugins): bundled platform plugins auto-load by default
Platform plugins shipped in-repo under plugins/platforms/ should be
available out of the box — users shouldn't have to add 'irc-platform'
to plugins.enabled before they can pick IRC from the gateway setup menu.

Adds a new ``kind: platform`` plugin type that mirrors the existing
``kind: backend`` auto-load semantics:

- Bundled (shipped in the hermes-agent repo): auto-load unconditionally.
- User-installed (~/.hermes/plugins/): still opt-in via plugins.enabled
  so untrusted code doesn't silently run.

Changes:

* hermes_cli/plugins.py: add 'platform' to _VALID_PLUGIN_KINDS, document
  the new kind in the PluginManifest docstring, extend the bundled auto-
  load rule from 'backend only' to 'backend or platform'.

* plugins/platforms/irc/plugin.yaml: declare kind: platform.

* hermes_cli/gateway.py: remove the now-redundant
  _load_bundled_platform_plugins_for_enumeration() helper and the
  _enable_plugin_for_platform() helper. The setup menu's _all_platforms()
  just calls discover_plugins() and reads the registry — bundled
  platforms are already loaded at that point. Drops the 'needs_enable'
  flag and the 'plugin disabled — select to enable' status string.

* hermes_cli/setup.py: relax the "gateway is configured" detector used
  during OpenClaw migration. Switching to _platform_status() in an
  earlier commit tightened the check to require an exact "configured"
  match, dropping platforms whose status is "enabled, not paired",
  "partially configured", "configured + E2EE", etc. Now any non-"not
  configured" status counts — the user has already started setup there
  and we shouldn't force the section to rerun.

* tests/hermes_cli/test_setup_irc.py: drop the TestIRCPluginDisabledFlow
  class and test_configure_platform_enables_disabled_plugin_first — the
  no-longer-existent flow they were testing.

* tests/hermes_cli/test_setup_openclaw_migration.py: patch both
  setup.get_env_value and gateway.get_env_value in the 4 gateway-section
  tests that reach _platform_status() through the unified setup flow;
  switch WHATSAPP_ENABLED to the literal "true" in the registry-parity
  test so WhatsApp's value-shape validator matches.

Verified via fresh-install smoke (empty plugins.enabled, no env vars):
IRC plugin loads, Platform('irc') resolves, _all_platforms() lists IRC
with status 'not configured'. 160 targeted tests pass.
2026-04-29 21:56:51 -07:00
Teknium 71c8ca17dc chore(salvage): strip duplicated/merge-corrupted blocks from PR #17664
Removes drive-by duplication that accumulated during the contributor
branch's multiple rebases. All runtime-benign (dict last-wins,
redefinition last-wins) but left dead source that would confuse
reviewers and maintainers.

Surgical in-place de-duplication (kept PR's intentional additions,
removed only the doubled copy):

* hermes_cli/auth.py: duplicate "gmi" + "azure-foundry" ProviderConfig
* hermes_cli/models.py: duplicate "gmi" entry in _PROVIDER_MODELS
* hermes_cli/config.py: duplicate NOTION/LINEAR/AIRTABLE/TENOR skill env
  block + duplicate get_custom_provider_context_length definition
* hermes_cli/gateway.py: duplicate _setup_yuanbao
* gateway/platforms/base.py: duplicate is_host_excluded_by_no_proxy
* gateway/platforms/telegram.py: duplicate delete_message
* gateway/stream_consumer.py: duplicate _should_send_fresh_final and
  _try_fresh_final
* gateway/run.py: duplicate _parse_reasoning_command_args /
  _resolve_session_reasoning_config / _set_session_reasoning_override,
  duplicate "Drain silently when interrupted" interrupt check
* run_agent.py: duplicate HERMES_AGENT_HELP_GUIDANCE append, duplicate
  codex_message_items capture, duplicate custom_providers resolution
* tools/approval.py: duplicate HARDLINE_PATTERNS section and duplicate
  hardline call in check_dangerous_command
* tools/mcp_tool.py: duplicate _orphan_stdio_pids module-level decl
* cron/scheduler.py: duplicate "not configured/enabled" check — kept
  the new early-rejection, removed the stale late-path copy

Full-file resets to origin/main (all PR additions were duplicates of
content already on main):

* ui-tui/packages/hermes-ink/index.d.ts
* ui-tui/packages/hermes-ink/src/entry-exports.ts
* ui-tui/packages/hermes-ink/src/ink/selection.ts
* ui-tui/src/app/interfaces.ts
* ui-tui/src/app/slash/commands/core.ts
* ui-tui/src/components/thinking.tsx
* ui-tui/src/lib/memoryMonitor.ts
* ui-tui/src/types.ts
* ui-tui/src/types/hermes-ink.d.ts
* tests/hermes_cli/test_doctor.py
* tests/hermes_cli/test_api_key_providers.py
* tests/hermes_cli/test_model_validation.py
* tests/plugins/memory/test_hindsight_provider.py
* tests/run_agent/test_run_agent.py
* tests/gateway/test_email.py
* tests/tools/test_dockerfile_pid1_reaping.py
* hermes_cli/commands.py (slack_native_slashes block — full duplicate)
2026-04-29 21:56:51 -07:00
Ari Lotter 868bc1c242 feat(irc): add interactive setup
feat(gateway): refine Platform._missing_ and platform-connected dispatch

Restricts plugin-name acceptance to bundled plugin scan + registry
(no arbitrary string -> enum-pollution), pulls per-platform connectivity
checks into a _PLATFORM_CONNECTED_CHECKERS lambda map with a clean
_is_platform_connected method, and adds tests covering the checker map,
plugin platform interface, and IRC setup wizard.
2026-04-29 21:56:51 -07:00
Ari Lotter 6e42daf7dd fix(nix): bundle plugins/ and expose it via HERMES_BUNDLED_PLUGINS
Nix-built hermes only copied skills/ into the output, so bundled platform
plugins weren't discoverable when running `nix run` (IRC invisible, no
plugin.yaml files present). Mirror the bundled-skills pattern:

- packages.nix: cleanSourceWith plugins/, copy to
  $out/share/hermes-agent/plugins, set HERMES_BUNDLED_PLUGINS on every
  wrapper.
- checks.nix: new bundled-plugins check verifying the directory, a
  sample manifest, and the wrapper env var.
- hermes_cli.plugins.get_bundled_plugins_dir(): central helper that
  honors HERMES_BUNDLED_PLUGINS with a dev-checkout fallback. Used by
  plugins.py, plugins_cmd.py, gateway.py, and web_server.py so every
  call site resolves the same path.
2026-04-29 21:56:51 -07:00
Ari Lotter 1f1608067c feat(gateway): unify setup flows, load platforms dynamically from registry
Merge the two gateway setup paths (hermes setup gateway + hermes gateway
setup) to use a single _unified_platforms() list that merges built-in
_PLATFORMS with dynamically registered plugin entries from
platform_registry.

- Add setup_fn field to PlatformEntry for plugin setup flows
- _unified_platforms() merges built-ins with registry entries by key
- setup_gateway() now uses unified list instead of hardcoded
  _GATEWAY_PLATFORMS tuple list
- gateway_setup() uses same unified list, plugin entries appear
  alongside built-ins with no [plugin] suffix
- _platform_status() handles plugin platforms via registry check_fn
- Plugin platforms with setup_fn get called directly; plugins without
  get a generic env-var display fallback

IRC and other plugin platforms now appear automatically in the setup
menu when registered via platform_registry.register().

feat(gateway): surface disabled platform plugins in setup and auto-enable on select

Platform plugins under plugins/platforms/* (IRC, etc.) were gated behind
plugins.enabled, so `hermes gateway setup` wouldn't list them until the
user ran `hermes plugins enable <name>` first. Now the setup menu always
surfaces them as "plugin disabled — select to enable", and picking one
adds it to plugins.enabled before running its setup flow.

Along the way, unify the two gateway setup flows so `hermes setup gateway`
and `hermes gateway setup` both read from the same platform list (built-in
_PLATFORMS + platform_registry entries), dispatch through a single
_configure_platform() helper, and share _platform_status(). Deletes the
dead bespoke wrappers in setup.py (_setup_whatsapp, _setup_weixin,
_setup_email, etc.) that duplicated logic now covered by the registry
path or _setup_standard_platform.

Also:
- PlatformEntry gains a plugin_name field so the registry knows which
  plugin owns each entry (required for auto-enable).
- PluginContext.register_platform auto-stamps plugin_name from the
  manifest so plugins don't have to pass it explicitly.
- PluginManager now scans plugins/platforms/* as its own category root,
  one level below the bundled plugin scan.
- Fix IRC plugin discovery: rename PLUGIN.yaml → plugin.yaml (the
  scanner is case-sensitive) and add the missing __init__.py that
  _load_directory_module requires.
2026-04-29 21:56:51 -07:00
Teknium 52d9e57825 feat: dynamic toolset generation for plugin platforms
Plugin platforms now get full toolset support without any entries in
toolsets.py.

tools_config._get_platform_tools(): Falls back to 'hermes-<name>'
  when the platform isn't in the static PLATFORMS dict. No more
  KeyError for plugin platforms.

toolsets.resolve_toolset(): Auto-generates a toolset for plugin
  platforms (hermes-<name>) containing _HERMES_CORE_TOOLS plus any
  tools the plugin registered into a matching toolset name. This means
  a plugin can call ctx.register_tool(toolset='irc', ...) and those
  tools will be included in the hermes-irc toolset automatically.

webhook.py: Registry-aware cross-platform delivery.
run_agent.py: Platform hints from plugin registry.
IRC adapter: Token lock + platform hint.
Removed dead token-empty-warning extension.
Updated docs.
2026-04-29 21:56:51 -07:00
Teknium e464cde58f feat: final platform plugin parity — webhook delivery, platform hints, docs
Closes remaining functional gaps and adds documentation.

webhook.py: Cross-platform delivery now checks the plugin registry
  for unknown platform names instead of hardcoding 15 names in a tuple.
  Plugin platforms can receive webhook-routed deliveries.

prompt_builder: Platform hints (system prompt LLM guidance) now fall
  back to the plugin registry's platform_hint field. Plugin platforms
  can tell the LLM 'you're on IRC, no markdown.'

PlatformEntry: Added platform_hint field for LLM guidance injection.

IRC adapter: Added acquire_scoped_lock/release_scoped_lock in
  connect/disconnect to prevent two profiles from using the same IRC
  identity. Added platform_hint for IRC-specific LLM guidance.

Removed dead token-empty-warning extension for plugin platforms
  (plugin adapters handle their own env vars via check_fn).

website/docs/developer-guide/adding-platform-adapters.md:
  - Added 'Plugin Path (Recommended)' section with full code examples,
    PLUGIN.yaml template, config.yaml examples, and a table showing all
    18 integration points the plugin system handles automatically
  - Renamed built-in checklist to clarify it's for core contributors

gateway/platforms/ADDING_A_PLATFORM.md:
  - Added Plugin Path section pointing to the reference implementation
    and full docs guide
  - Clarified built-in path is for core contributors only
2026-04-29 21:56:51 -07:00
Teknium 457128d4e8 fix: wire PII redaction + token empty warnings for plugin platforms
PII redaction: build_session_context_prompt() now checks the plugin
registry's pii_safe flag in addition to the hardcoded _PII_SAFE_PLATFORMS
frozenset. Plugin platforms that set pii_safe=True (e.g. phone-based
messaging bridges) get their user IDs redacted before LLM context.

Token empty warnings: the empty-token diagnostic at config load now
checks the plugin registry's required_env when a platform isn't in the
hardcoded _token_env_names dict. Catches 'enabled but empty' for
plugin platforms too.
2026-04-29 21:56:51 -07:00
Teknium 2e20f6ae2d feat: complete plugin platform parity — all 12 integration points
Extends the platform plugin interface from Phase 1 to cover every
touchpoint where built-in platforms have hardcoded behavior.

- allowed_users_env / allow_all_env: per-platform auth env vars
- max_message_length: smart-chunking for send_message tool
- pii_safe: session PII redaction flag
- emoji: CLI/gateway display
- allow_update_command: /update access control

send_message tool (tools/send_message_tool.py):
- Replaced hardcoded platform_map dict with Platform() call
- Added _send_via_adapter() for plugin platforms — routes through
  live gateway adapter when available
- Registry-aware max message length for smart chunking

Cron delivery (cron/scheduler.py):
- Replaced hardcoded 15-entry platform_map with Platform() call
- Plugin platforms now work as cron delivery targets

User authorization (gateway/run.py _is_user_authorized):
- Registry fallback: checks PlatformEntry.allowed_users_env and
  allow_all_env when platform not in hardcoded maps
- Plugin platforms get per-platform auth support

_UPDATE_ALLOWED_PLATFORMS: checks registry allow_update_command flag
Channel directory: includes plugin platforms in session enumeration
Orphaned config warning: descriptive message when plugin platform is
  in config but no plugin registered it
Gateway weakref: _gateway_runner_ref for cross-module adapter access

hermes status: shows plugin platforms with (plugin) tag
hermes gateway setup: plugin platforms appear in menu with setup hints
hermes_cli/platforms.py: get_all_platforms() merges with registry,
  platform_label() falls back to registry for plugin names

- 8 new tests (extended fields, cron resolution, platforms merge)
- Updated 3 tests for new Platform() based resolution
- 2829 passed, 24 pre-existing failures, zero new failures
2026-04-29 21:56:51 -07:00
Teknium 8f144fe36b feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.

- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
  validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
  through to existing if/elif chain for built-in platforms

- Platform._missing_() accepts unknown string values, creating cached
  pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
  config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms

- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern

- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
  512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars

- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)

No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-29 21:56:51 -07:00
Teknium 4d7fc0f37c feat(gateway,cli): confirm /reload-mcp to warn about prompt cache invalidation
Reloading MCP servers rebuilds the tool set for the active session, which
invalidates the provider prompt cache (tool schemas are baked into the
system prompt). The next message re-sends full input tokens — can be
expensive on long-context or high-reasoning models.

To surface that cost, /reload-mcp now routes through a new slash-confirm
primitive with three options: Approve Once / Always Approve / Cancel.
'Always Approve' persists approvals.mcp_reload_confirm: false so future
reloads run silently.

Coverage:

* Classic CLI (cli.py) — interactive numbered prompt.
* TUI (tui_gateway + Ink ops.ts) — text warning on first call; `now` /
  `always` args skip the gate; `always` also persists the opt-out.
* Messenger gateway — button UI on Telegram (inline keyboard), Discord
  (discord.ui.View), Slack (Block Kit actions); text fallback on every
  other platform via /approve /always /cancel replies intercepted in
  gateway/run.py _handle_message.
* Config key: approvals.mcp_reload_confirm (default true).
* Auto-reload paths (CLI file watcher, TUI config-sync mtime poll) pass
  confirm=true so they do NOT prompt.

Implementation:

* tools/slash_confirm.py — module-level pending-state store used by all
  adapters and by the CLI prompt. Thread-safe register/resolve/clear.
* gateway/platforms/base.py — send_slash_confirm hook (default 'Not
  supported' → text fallback).
* gateway/run.py — _request_slash_confirm helper + text intercept in
  _handle_message (yields to in-progress tool-exec approvals so
  dangerous-command /approve still unblocks the tool thread first).

Tests:

* tests/tools/test_slash_confirm.py — primitive lifecycle + async
  resolution + double-click atomicity (16 tests).
* tests/hermes_cli/test_mcp_reload_confirm_gate.py — default-config
  shape + deep-merge preserves user opt-out (5 tests).

Targeted runs (hermetic): 89 passed (slash-confirm, config gate,
existing agent cache, existing telegram approval buttons).
2026-04-29 21:56:47 -07:00
helix4u 7fae87bc00 fix(gateway): refresh cached agents after MCP tool changes 2026-04-29 21:56:47 -07:00
Vlad Ra a7fb79efb2 fix(agent): spawn OpenRouter pre-warm thread only once per process
Each AIAgent.__init__() was unconditionally starting a daemon thread to
pre-warm the OpenRouter model metadata cache.  In gateway mode a new
AIAgent is created for every incoming message, so one OS thread leaked
per request.  After ~1 000 messages the process hit the Linux thread
limit and raised RuntimeError: can't start new thread for all subsequent
requests.

Add a module-level threading.Event (_openrouter_prewarm_done) that is
set before the thread is started.  Subsequent AIAgent instantiations
skip the spawn entirely; fetch_model_metadata() is cached for 1 hour so
the single background call is sufficient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 21:09:08 -07:00
teknium1 502debed91 chore: map vlad19@gmail.com -> dandaka for CI author check 2026-04-29 21:09:08 -07:00
simbam99 ffa65291d1 fix(cron): clear auto-delivery thread context between jobs 2026-04-29 21:08:59 -07:00
teknium1 16233711d9 chore(release): map memosr commit email for release notes 2026-04-29 21:08:28 -07:00
memosr d69a0b2c29 fix(security): apply ACL checks to QQBot guild messages and guild DMs to prevent allowlist bypass 2026-04-29 21:08:28 -07:00
teknium1 763aadd6bf fix(telegram): preserve pre-#17686 chat-ID-in-_USERS configs + doc split
PR #15027 (5 days ago) shipped TELEGRAM_GROUP_ALLOWED_USERS as a chat-ID
allowlist. #17686 correctly renames that to sender user IDs and moves
chat IDs to TELEGRAM_GROUP_ALLOWED_CHATS. Without a shim, any user on
PR #15027's guidance would silently start rejecting group traffic on
upgrade.

- gateway/run.py: in _is_user_authorized, if TELEGRAM_GROUP_ALLOWED_USERS
  contains values starting with '-' (chat-ID-shaped), honor them as chat
  IDs and log a one-shot deprecation warning pointing users at the new
  TELEGRAM_GROUP_ALLOWED_CHATS var.
- tests/gateway/test_unauthorized_dm_behavior.py: three new tests cover
  legacy chat-ID values authorizing the listed chat, not crossing to
  other chats, and mixed sender/chat values in the same var.
- website/docs/user-guide/messaging/telegram.md: rewrite the Group
  Allowlisting section to document the new user/chat split + migration
  note. Remove stale '/thread_id' suffix claim (code never parsed it).
- website/docs/reference/environment-variables.md: document all three
  Telegram allowlist env vars.
2026-04-29 21:07:55 -07:00
Anders Bell 1f712173b2 fix(telegram): support group user allowlist 2026-04-29 21:07:55 -07:00
teknium1 dd2d1ba5e6 refactor(reload-skills): queue note for next turn, drop cache invalidation + agent tool
Salvage-follow-up to @shannonsands's /reload-skills PR. Trims the feature to
match the design: user-initiated rescan, no prompt-cache reset, no new
schema surface, no phantom user turn, and the next-turn note carries each
added/removed skill's 60-char description (not just its name).

Changes vs the original PR:

* Drop the in-process skills prompt-cache clear in reload_skills(). Skills
  are invoked at runtime via /skill-name, skills_list, or skill_view —
  they don't need to live in the system prompt for the model to use them.
  Keeping the cache intact preserves prefix caching across the reload so
  /reload-skills pays no cache-reset cost. (MCP has to break the cache
  because tool schemas must be known at conversation start; skills do not.)

* Drop the skills_reload agent tool and SKILLS_RELOAD_SCHEMA from
  tools/skills_tool.py, plus the four skills_reload enumerations in
  toolsets.py. No new schema surface — agents can already see a freshly-
  installed skill via skill_view / skills_list the moment it's on disk.

* Replace the phantom 'role: user' turn injection with a one-shot queued
  note. CLI uses self._pending_skills_reload_note (same pattern as
  _pending_model_switch_note, prepended to the next API call and cleared).
  Gateway uses self._pending_skills_reload_notes[session_key]. The note
  is prepended to the NEXT real user message in this session, so message
  alternation stays intact and nothing out-of-band is persisted to the
  transcript.

* reload_skills() now returns added/removed as
  [{'name': str, 'description': str}, ...] (description truncated to 60
  chars — matches the curator / gateway adapter budget). The injected
  next-turn note formats each entry as 'name — description' so the model
  can actually reason about which new skills to call without running
  skills_list first.

* Only emit the note when the diff is non-empty. On empty diff, print
  'No new skills detected' and do nothing else.

* Tests rewritten to cover the queue semantics, the description payload,
  and a regression guard that the prompt-cache snapshot is preserved.
2026-04-29 21:07:47 -07:00
Shannon Sands 7966560fb5 feat(skills): /reload-skills slash command + skills_reload agent tool
Adds a public reload path for the in-process skill caches so newly
installed (or removed) skills become visible mid-session without a
gateway restart. Mirrors the shape of /reload-mcp.

Three surfaces:
* /reload-skills slash command — CLI (cli.py) and gateway (gateway/run.py),
  with /reload_skills alias for Telegram autocomplete and an explicit
  Discord registration.
* skills_reload agent tool (tools/skills_tool.py) — lets agents/subagents
  pick up freshly-installed skills via tool call.
* agent.skill_commands.reload_skills() — shared helper that clears
  _skill_commands, _SKILLS_PROMPT_CACHE (in-process LRU), and the
  on-disk .skills_prompt_snapshot.json, then returns an added/removed
  diff plus the new total count.

Tested:
* tests/agent/test_skill_commands_reload.py (9 cases)
* tests/cli/test_cli_reload_skills.py       (3 cases)
* tests/gateway/test_reload_skills_command.py (4 cases)

Use case: NemoClaw / OpenShell-style sandboxed orchestrators that drop
skills into ~/.hermes/skills mid-session, plus agentic flows where the
agent itself installs a skill via the shell tool and needs it bound
without a gateway restart. The Python helper
clear_skills_system_prompt_cache(clear_snapshot=True) already exists
internally — this PR just exposes it via slash command and tool.
2026-04-29 21:07:47 -07:00
teknium1 113239f6e3 fix(dashboard/models): filter empty-string model rows + simplify vendor split
- SQL: add `model != ''` to both queries in /api/analytics/models so
  sessions with empty-string model (pre-existing data integrity,
  confirmed in production DB: ~107 sessions) no longer render as
  blank-header cards.
- ModelsPage: drop the arbitrary slashIdx < 20 length gate in
  shortModelName / modelProvider. The gate was fragile for longer
  vendor prefixes (e.g. `deepseek-ai/...`). Strip on the first /
  unconditionally. Rename modelProvider -> modelVendor to avoid
  confusion with the billing provider column.
- scripts/release.py: add AUTHOR_MAP entry for yatesjalex.
2026-04-29 21:07:19 -07:00
Alex Yates e6b05eaf63 feat: add Models dashboard tab with rich per-model analytics
- New /models page in left nav (after Analytics)
- New /api/analytics/models endpoint with per-model token/cost/session
  breakdown, cache read/reasoning tokens, tool calls, avg tokens/session,
  and capabilities from models.dev (vision/tools/reasoning/context window)
- Model cards with stacked token distribution bar, capability badges,
  provider badges, cost info, and relative time
- Summary stats bar (models used, total tokens, est. cost, sessions)
- Period selector (7d/30d/90d) with refresh
- i18n support (en + zh)
2026-04-29 21:07:19 -07:00
Teknium 289cc47631 docs: resync reference, user-guide, developer-guide, and messaging pages against code (#17738)
Broad drift audit against origin/main (b52b63396).

Reference pages (most user-visible drift):
- slash-commands: add /busy, /curator, /footer, /indicator, /redraw, /steer
  that were missing; drop non-existent /terminal-setup; fix /q footnote
  (resolves to /queue, not /quit); extend CLI-only list with all 24
  CLI-only commands in the registry
- cli-commands: add dedicated sections for hermes curator / fallback /
  hooks (new subcommands not previously documented); remove stale
  hermes honcho standalone section (the plugin registers dynamically
  via hermes memory); list curator/fallback/hooks in top-level table;
  fix completion to include fish
- toolsets-reference: document the real 52-toolset count; split browser
  vs browser-cdp; add discord / discord_admin / spotify / yuanbao;
  correct hermes-cli tool count from 36 to 38; fix misleading claim
  that hermes-homeassistant adds tools (it's identical to hermes-cli)
- tools-reference: bump tool count 55 -> 68; add 7 Spotify, 5 Yuanbao,
  2 Discord toolsets; move browser_cdp/browser_dialog to their own
  browser-cdp toolset section
- environment-variables: add 40+ user-facing HERMES_* vars that were
  undocumented (--yolo, --accept-hooks, --ignore-*, inference model
  override, agent/stream/checkpoint timeouts, OAuth trace, per-platform
  batch tuning for Telegram/Discord/Matrix/Feishu/WeCom, cron knobs,
  gateway restart/connect timeouts); dedupe the Cron Scheduler section;
  replace stale QQ_SANDBOX with QQ_PORTAL_HOST

User-guide (top level):
- cli.md: compression preserves last 20 turns, not 4 (protect_last_n: 20)
- configuration.md: display.platforms is the canonical per-platform
  override key; tool_progress_overrides is deprecated and auto-migrated
- profiles.md: model.default is the config key, not model.model
- sessions.md: CLI/TUI session IDs use 6-char hex, gateway uses 8
- checkpoints-and-rollback.md: destructive-command list now matches
  _DESTRUCTIVE_PATTERNS (adds rmdir, cp, install, dd)
- docker.md: the container runs as non-root hermes (UID 10000) via
  gosu; fix install command (uv pip); add missing --insecure on the
  dashboard compose example (required for non-loopback bind)
- security.md: systemctl danger pattern also matches 'restart'
- index.md: built-in tool count 47 -> 68
- integrations/index.md: 6 STT providers, 8 memory providers
- integrations/providers.md: drop fictional dashscope/qwen aliases

Features:
- overview.md: 9 image models (not 8), 9 TTS providers (not 5),
  8 memory providers (Supermemory was missing)
- tool-gateway.md: 9 image models
- tools.md: extend common-toolsets list with search / messaging /
  spotify / discord / debugging / safe
- fallback-providers.md: add 6 real providers from PROVIDER_REGISTRY
  (lmstudio, kimi-coding-cn, stepfun, alibaba-coding-plan,
  tencent-tokenhub, azure-foundry)
- plugins.md: Available Hooks table now includes on_session_finalize,
  on_session_reset, subagent_stop
- built-in-plugins.md: add the 7 bundled plugins the page didn't
  mention (spotify, google_meet, three image_gen providers, two
  dashboard examples)
- web-dashboard.md: add --insecure and --tui flags
- cron.md: hermes cron create takes positional schedule/prompt, not
  flags

Messaging:
- telegram.md: TELEGRAM_WEBHOOK_SECRET is now REQUIRED when
  TELEGRAM_WEBHOOK_URL is set (gateway refuses to start without it
  per GHSA-3vpc-7q5r-276h). Biggest user-visible drift in the batch.
- discord.md: HERMES_DISCORD_TEXT_BATCH_SPLIT_DELAY_SECONDS default
  is 2.0, not 0.1
- dingtalk.md: document DINGTALK_REQUIRE_MENTION /
  FREE_RESPONSE_CHATS / MENTION_PATTERNS / HOME_CHANNEL /
  ALLOW_ALL_USERS that the adapter supports
- bluebubbles.md: drop fictional BLUEBUBBLES_SEND_READ_RECEIPTS env
  var; the setting lives in platforms.bluebubbles.extra only
- qqbot.md: drop dead QQ_SANDBOX; add real QQ_PORTAL_HOST and
  QQ_GROUP_ALLOWED_USERS
- wecom-callback.md: replace 'hermes gateway start' (service-only)
  with 'hermes gateway' for first-time setup

Developer-guide:
- architecture.md: refresh tool/toolset counts (61/52), terminal
  backend count (7), line counts for run_agent.py (~13.7k), cli.py
  (~11.5k), main.py (~10.4k), setup.py (~3.5k), gateway/run.py
  (~12.2k), mcp_tool.py (~3.1k); add yuanbao adapter, bump platform
  adapter count 18 -> 20
- agent-loop.md: run_agent.py line count 10.7k -> 13.7k
- tools-runtime.md: add vercel_sandbox backend
- adding-tools.md: remove stale 'Discovery import added to
  model_tools.py' checklist item (registry auto-discovery)
- adding-platform-adapters.md: mark send_typing / get_chat_info as
  concrete base methods; only connect/disconnect/send are abstract
- acp-internals.md: ACP sessions now persist to SessionDB
  (~/.hermes/state.db); acp.run_agent call uses
  use_unstable_protocol=True
- cron-internals.md: gateway runs scheduler in a dedicated background
  thread via _start_cron_ticker, not on a maintenance cycle; locking
  is cross-process via fcntl.flock (Unix) / msvcrt.locking (Windows)
- gateway-internals.md: gateway/run.py ~12k lines
- provider-runtime.md: cron DOES support fallback (run_job reads
  fallback_providers from config)
- session-storage.md: SCHEMA_VERSION = 11 (not 9); add migrations
  10 and 11 (trigram FTS, inline-mode FTS5 re-index); add
  api_call_count column to Sessions DDL; document messages_fts_trigram
  and state_meta in the architecture tree
- context-compression-and-caching.md: remove the obsolete 'context
  pressure warnings' section (warnings were removed for causing
  models to give up early)
- context-engine-plugin.md: compress() signature now includes
  focus_topic param
- extending-the-cli.md: _build_tui_layout_children signature now
  includes model_picker_widget; add to default layout

Also fixed three pre-existing broken links/anchors the build warned
about (docker.md -> api-server.md, yuanbao.md -> cron-jobs.md and
tips#background-tasks, nix-setup.md -> #container-aware-cli).

Regenerated per-skill pages via website/scripts/generate-skill-docs.py
so catalog tables and sidebar are consistent with current SKILL.md
frontmatter.

docusaurus build: clean, no broken links or anchors.
2026-04-29 20:55:59 -07:00
SHL0MS 51b44b6e3f fix(skills/comfyui): correct hallucinated node names and registry slugs
Self-review caught several errors in the previous commit:

Frontmatter
- Replace non-standard `requires_runtime` / `requires_tooling` fields with
  the documented `compatibility:` field (parsed by tools/skills_tool.py).
- Drop the `audit-v5` author tag I added unnecessarily.

MODEL_LOADERS catalog
- Remove `IPAdapterUnifiedLoader` (input `preset` is an enum, not a file).
- Remove `IPAdapterInsightFaceLoader` and `InsightFaceLoader` (input
  `provider` is a GPU backend selector, not a model file). These would have
  flagged enum values like "STANDARD" or "CUDA" as missing model files.
- Add "NB:" comment explaining `BasicGuider` has no `cfg` input
  (the original PARAM_PATTERNS entry would never have matched).
- Remove `SamplerCustomAdvanced.noise_seed` from PARAM_PATTERNS — that
  node takes a NOISE input from RandomNoise, not a seed field directly.

NODE_TO_PACKAGE registry slugs
- Verified all 18 packages against api.comfy.org and fixed:
  - `comfyui-essentials` → `comfyui_essentials` (underscore, not hyphen)
  - `comfyui-gguf` → `ComfyUI-GGUF` (case-sensitive)
  - `comfyui-photomaker-plus` → `ComfyUI-PhotoMaker-Plus`
  - `comfyui-wanvideowrapper` → `ComfyUI-WanVideoWrapper`
- ComfyUI-HunyuanVideoWrapper isn't on the registry; surface a git-URL
  install hint via new NODE_TO_GIT_URL fallback so the user can install
  via ComfyUI-Manager's /manager/queue/install endpoint.

Wrong class names
- `Canny` → `CannyEdgePreprocessor` (controlnet-aux registers the latter,
  the former never appears in /object_info).
- Add `Zoe_DepthAnythingPreprocessor` and `AnimalPosePreprocessor` while
  fixing controlnet-aux.
- Remove `Reroute (rgthree)` (rgthree's Reroute is JS-only — no Python
  class, never appears in /object_info).
- Add `Display Int (rgthree)` (sibling of Display Any).
- Move `UltralyticsDetectorProvider` from `comfyui-impact-pack` to
  `comfyui-impact-subpack` (separate package, registered there).

Tests
- Update test_packages_are_safe_for_shell to accept case-mixed slugs (the
  registry uses both ComfyUI- and comfyui_ prefixes inconsistently). Replaced
  the lowercase-only assertion with a shell-safe regex check.
- 117 tests still pass (105 unit + 8 cloud + 4 cross-host).

Attribution
- Add `SHL0MS@users.noreply.github.com` mapping to scripts/release.py
  AUTHOR_MAP so check-attribution CI passes.
2026-04-29 20:48:01 -07:00
SHL0MS a7780fe05f fix(skills/comfyui): bug fixes, cloud parity, expanded coverage, examples, tests
The audit of v4.1 surfaced ~70 issues across the five scripts and three
reference docs — most user-visible (silent file overwrites, status-error
misclassified as success, X-API-Key leaked to S3 on /api/view redirect,
Cloud endpoints that 404 because they were renamed). v5.0.0 fixes those
and fills the gaps that previously forced users to write their own glue
(WebSocket monitoring, batch/sweep, img2img upload helper, dep auto-fix,
log fetch, health check, example workflows).

Critical fixes
- run_workflow.py: poll_status now checks status_str==error BEFORE
  completed:true, so a failed run no longer reports success
- run_workflow.py: download_output streams to disk via safe_path_join,
  preserves server subfolder structure (no silent overwrites), and
  retries with exponential backoff
- run_workflow.py: refuses to overwrite a link with a literal in
  inject_params (would silently break wiring)
- _common.py: _StripSensitiveOnRedirectSession (subclasses
  requests.Session.rebuild_auth) drops X-API-Key/Cookie on cross-host
  redirects — fixes a real key-leak path through Cloud's signed-URL
  download flow. Tested
- Cloud routing (verified live): /history → /history_v2,
  /models/<f> → /experiment/models/<f>, plus folder aliases for the
  unet ↔ diffusion_models and clip ↔ text_encoders rename
- check_deps.py: distinguishes 200/empty vs 404 folder_not_found vs
  403 free-tier; emits concrete fix_command per missing dep
- extract_schema.py: prompt vs negative_prompt determined by tracing
  KSampler.{positive,negative} connections (incl. through Reroute /
  Primitive nodes) instead of meta-title heuristic; symmetric
  duplicate-name resolution; cycle-safe trace_to_node
- hardware_check.py: multi-GPU pick-best, Apple variant detection,
  Rosetta detection, WSL2, ROCm --json, disk-space check, optional
  PyTorch probe; powershell preferred over deprecated wmic
- comfyui_setup.sh: prefers pipx → uvx → pip --user (with PEP-668
  fallback); idempotent — skips relaunch if server already up;
  configurable port/workspace; persistent log; SIGINT trap

New scripts
- run_batch.py — count or sweep (cartesian product), parallel up to
  cloud tier limit
- ws_monitor.py — real-time WebSocket viewer; saves preview frames
- auto_fix_deps.py — runs comfy node install / model download for
  whatever check_deps reports missing (with --dry-run)
- health_check.py — single command that runs the verification checklist
  (comfy-cli + server + checkpoints + optional smoke test that cancels
  itself to avoid burning compute)
- fetch_logs.py — pull traceback / status messages for a prompt_id

Coverage expansion
- Param patterns now cover Flux (BasicScheduler, BasicGuider,
  RandomNoise, ModelSamplingFlux), SD3, Wan/Hunyuan/LTX video,
  IPAdapter, rgthree, easy-use, AnimateDiff
- Embedding refs in CLIPTextEncode strings extracted as model deps
- ckpt_name / vae_name / lora_name / unet_name now controllable so
  workflows can be retargeted per run

Examples
- workflows/{sd15,sdxl,flux_dev}_txt2img.json
- workflows/sdxl_{img2img,inpaint}.json
- workflows/upscale_4x.json
- workflows/{animatediff_video,wan_video_t2v}.json + README

Tests
- 117 tests (105 unit + 8 cloud integration + 4 cross-host security)
- Cloud tests auto-skip without COMFY_CLOUD_API_KEY; verified end-to-end
  against live cloud API

Backwards compatibility
- All existing CLI flags continue to work; new behavior is opt-in
  (--ws, --input-image, --randomize-seed, --flat-output, etc.)
2026-04-29 20:48:01 -07:00
ethernet 7d48a16f14 remove relaunch_chat
not needed
2026-04-29 20:33:29 -07:00
ethernet 3c673468b4 refactor(cli): derive relaunch flag table from argparse introspection
Pull the top-level + chat parser construction out of main() into
hermes_cli/_parser.py so relaunch.py can introspect parser._actions to
discover which flags exist and whether they take values, instead of
maintaining a parallel hand-rolled (flag, takes_value) tuple list.

- _parser.py: build_top_level_parser() returns (parser, subparsers,
  chat_parser); side-effect-free import.
- main.py: ~290 lines of inline parser construction collapsed to a
  helper call. Other subparsers stay inline (dispatch is bound to
  module-level cmd_* functions).
- _parser._inherited_flag(parser, ...): wraps parser.add_argument and
  sets action.inherit_on_relaunch = True. Used in place of
  parser.add_argument for the 25 flags (top-level + chat) that need to
  carry over.
- _parser.PRE_ARGPARSE_INHERITED_FLAGS: holds --profile/-p, which
  isn't on argparse (consumed earlier by main._apply_profile_override).
- relaunch.py: drops _CRITICAL_DESTS and _PRE_ARGPARSE_FLAGS; the table
  builder now filters by getattr(action, 'inherit_on_relaunch', False).
- test_ignore_user_config_flags.py: brittle inspect.getsource grep
  replaced with proper parser introspection.
- test_relaunch.py: introspection sanity tests added.

Salvaged from PR #17549; added top-level -t/--toolsets flag to
_parser.py so #17623 (fix(tui): honor launch toolsets) behavior is
preserved on current main.

Co-authored-by: ethernet <arilotter@gmail.com>
2026-04-29 20:33:29 -07:00
ethernet 95f2802f84 feat(cli): preserve --tui and other flags across internal relaunches
Extract all os.execvp('hermes', ...) calls into a utility so flags like
--tui, --dev, --profile, --model, --provider, et al. survive session
resume and post-setup relaunch.

- resolve_hermes_bin: prefers sys.argv[0] when callable, then PATH,
  then falls back to '${sys.executable} -m hermes_cli.main' (fixes nix
run relaunches)
- build_relaunch_argv: allowlists critical flags so they carry over
- cmd_sessions browse now calls relaunch(['--resume', <id>])
- _apply_profile_override skips redundant work when HERMES_HOME is
  already set (child inherits parent profile)
- setup.py replaces _resolve_hermes_chat_argv with relaunch_chat()
- added comprehensive tests for flag extraction and binary resolution

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:33:29 -07:00
Teknium 22ff6ca32b docs: two-week gap sweep — platforms, CLI, config, TUI, hooks, providers (#17727)
Covers ~60 merged PRs from Apr 15–29 that shipped user-visible behavior
without docs coverage. No functional code changes; docs + static manifest
regeneration only.

Highlights:

Stale / incorrect:
- configuration.md: auxiliary auto-routing line was wrong since #11900;
  now correctly states auto routes to the main model, with a note on the
  cost trade-off and per-task override pattern.
- integrations/providers.md + configuration.md compression intro:
  removed stale 'Gemini Flash via OpenRouter' claim.
- website/static/api/model-catalog.json: rebuilt from hermes_cli/models.py
  so the live manifest picks up tencent/hy3-preview (and remains in sync
  for future model-catalog PRs).

Platform messaging (#17417 #16997 #16193 #14315 #13151 #11794 #10610
#10283 #10246 #11564 #13178):
- Signal: native formatting (bodyRanges), reply quotes, reactions.
- Telegram: table rendering (bullets + code-block fallback),
  disable_link_previews, group_allowed_chats.
- Slack: strict_mention config.
- Discord: slash_commands disable, send_animation GIF, send_message
  native media attachments.
- DingTalk: require_mention + allowed_users.

CLI (#16052 #16539 #16566 #15841 #14798 #10043):
- New 'hermes fallback' interactive manager.
- New 'hermes update --check', '--backup' flag, and pre-update pairing
  snapshot behavior.
- 'hermes gateway start/restart --all' multi-profile flag.
- cron.md: 'hermes tools' as a platform, per-job enabled_toolsets,
  wakeAgent gate, context_from chaining.

Config keys / env vars (#17305 #17026 #17000 #15077 #14557 #14227
#14166 #14730 #17008):
- terminal.docker_run_as_host_user, display.runtime_metadata_footer,
  compression.hygiene_hard_message_limit, HINDSIGHT_TIMEOUT,
  skills.guard_agent_created, TAVILY_BASE_URL,
  security.allow_private_urls, agent.api_max_retries,
  gateway hot-reload of compression/context_length config edits.

TUI / CLI UX (#17130 #17113 #17175 #17150 #16707 #12312 #12305 #12934
#14810 #14045 #17286 #17126):
- HERMES_TUI_RESUME, HERMES_TUI_THEME, LaTeX rendering, busy-indicator
  styles, ctrl-x queued-message delete, git branch in status bar, per-
  prompt elapsed stopwatch, external-editor keybind, markdown stripping,
  TUI voice-mode parity, /agents overlay, /reload + /mouse.

Gateway features (#16506 #15027 #13428 #12116):
- Native multimodal image routing based on vision capability.
- /usage account-limits section.
- /steer slash command (added to reference + explanation in CLI).

Plugins / hooks (#12929 #12972 #10763 #16364):
- transform_tool_result, transform_terminal_output plugin hooks.
- PluginContext.dispatch_tool() documented with slash-command example.
- google_meet bundled plugin entry under built-in-plugins.md.

Other (#16576 #16572 #16383 #15878 #15608 #15606 #14809 #14767 #14231
#14232 #14307 #13683 #12373 #11891 #11291 #10066):
- hermes backup exclusions (WAL/SHM/journal + checkpoints/).
- security.md hardline blocklist (floor below --yolo).
- FHS install layout for root installs.
- openssh-client + docker-cli baked into the Docker image.
- MEDIA: tag supported extensions table (docs/office/archives/pdf).
- Remote-to-host file sync on SSH/Modal/Daytona teardown.
- 'hermes model' -> Configure Auxiliary Models interactive picker.
- Podman support via HERMES_DOCKER_BINARY.

Providers / STT / one-shot (#15045 #14473 #15704):
- alibaba-coding-plan first-class provider entry.
- xAI Grok STT as a 6th transcription option.
- 'hermes -z' scripted one-shot mode + HERMES_INFERENCE_MODEL.

Build: 'docusaurus build' succeeds. No new broken links/anchors;
pre-existing warnings unchanged.
2026-04-29 20:32:37 -07:00
Brooklyn Nicholson 8dcab19d02 fix(gateway): fail closed when session.delete can't enumerate active sessions
If a concurrent RPC mutates _sessions while session.delete is iterating
it (e.g. a parallel session.create on the thread pool), the bare except
swallowed the RuntimeError and let the delete proceed against a row
that may still be live.  Snapshot via list(_sessions.values()) and
return an error when even that raises, instead of treating "couldn't
check" as "no active sessions."
2026-04-29 20:21:16 -07:00
Brooklyn Nicholson 49fcad8cf8 fix(tui): require double-tap d to confirm session delete
Single-key confirm matches how the picker already accepts 1-9 to
resume — no separate y/n keymap to learn — and "press d again" is
self-documenting next to the cursor.
2026-04-29 20:21:16 -07:00
Brooklyn Nicholson 24b5279f43 feat(tui): delete sessions from /resume picker with d
Pressing `d` on the highlighted row in the resume picker prompts
`delete? y/n`; `y` deletes the session (DB row + on-disk transcript
files), anything else cancels.  The active session is excluded from
deletion server-side.

Adds a new `session.delete` JSON-RPC handler that wraps
`SessionDB.delete_session`, forwarding the per-profile `sessions/`
directory so transcripts get cleaned up alongside the row.
2026-04-29 20:21:16 -07:00
Teknium 0ba451d004 fix(vision): use HERMES_HOME-based cache dir instead of cwd (#17719)
vision_analyze used Path('./temp_vision_images') — a relative path that
resolved against cwd. Under Docker the image's WORKDIR is /opt/hermes,
which is root-owned and only chmoded a+rX (read + traversal). Since
#5811 landed (run as non-root hermes UID 10000, Apr 12), remote-URL
vision calls fail with PermissionError on mkdir.

Switch to get_hermes_dir('cache/vision', 'temp_vision_images'): resolves
to $HERMES_HOME/cache/vision/ (= /opt/data/cache/vision/ in Docker —
the user-owned volume mount). Existing installs with the old dir keep
using it via the get_hermes_dir back-compat path; no migration needed.

Only site in the codebase that stored runtime files via Path('./...').

Reported via Discord: https://juick.com/i/p/3089079.jpg → Telegram →
gateway → [Errno 13] Permission denied: 'temp_vision_images'.
2026-04-29 20:14:02 -07:00
brooklyn! 4cc6da84a1 fix(tui): normalize legacy Terminal.app colors (#17695)
Keep light Terminal.app TUI colors readable by normalizing non-banner theme tokens into ANSI256-safe buckets while preserving truecolor terminals.
2026-04-29 20:13:49 -07:00
Brooklyn Nicholson 87e259a678 fix(cli): tighten mouse leak sanitizer
Handle unbounded SGR mouse report coordinates and avoid regex work on ordinary prompt-buffer edits by short-circuiting before sanitizer passes.
2026-04-29 22:10:18 -05:00
Teknium 31f70d1f2a fix(ci): recover 38 failing tests on main (#17642)
CI Tests workflow has been red on main for 40+ consecutive runs. This
commit recovers every failure visible in run 25130722163 (most recent
completed run prior to this PR).

Root causes, by group:

Test-mock drift after product landed (fix: update mocks)
- test_mcp_structured_content / test_mcp_dynamic_discovery (6 tests):
  product added _rpc_lock (#02ae15222) and _schedule_tools_refresh
  (#1350d12b0) without updating sibling test files. Install a real
  asyncio.Lock inside the fake run-loop and patch at _schedule_tools_refresh.
- test_session.py: renamed normalize_whatsapp_identifier → canonical_
  whatsapp_identifier upstream; keep a local alias so the legacy tests
  keep working.
- test_run_progress_topics Slack DM test: PR #8006 made Slack default
  tool_progress=off; explicitly set it to 'all' in the test fixture so
  the progress-callback path still runs. Also read tool_progress_callback
  at call time rather than freezing it in FakeAgent.__init__ — production
  assigns it AFTER construction.
- test_tui_gateway_server session-create/close race: session.create now
  defers _start_agent_build behind a 50ms timer — wait for the build
  thread to enter _make_agent before closing, otherwise the orphan-
  cleanup path never runs.
- test_protocol session.resume: product get_messages_as_conversation now
  takes include_ancestors kwarg; accept **_kwargs in the test stub.
- test_copilot_acp_client redaction: redactor is OFF by default (snapshots
  HERMES_REDACT_SECRETS at import); patch agent.redact._REDACT_ENABLED=True
  for the duration of the test.
- test_minimax_provider: after #17171, dots in non-Anthropic model names
  stay dots even with preserve_dots=False. Assert the new invariant
  rather than the old 'broken for MiniMax' behavior.
- test_update_autostash: updater now scans `ps -A` for dashboard PIDs;
  the test's catch-all subprocess.run stub needed stdout/stderr fields.
- test_accretion_caps: read_timestamps dict is populated lazily when
  os.path.getmtime succeeds. Use .get("read_timestamps", {}) to tolerate
  CI filesystems where the stat races file creation.

Change-detector tests (fix: rewrite as structural invariants)
- test_credential_sources_registry_has_expected_steps: was a frozen set
  comparison that broke when minimax-oauth was added. Rewrite as an
  invariant check (every step has description, no dupes, core steps
  present) per AGENTS.md 'don't write change-detector tests'.

xdist ordering / test pollution (fix: reset state, use module-local patches)
- test_setup vercel: sibling test saved VERCEL_PROJECT_ID='project' to
  os.environ via save_env_value() and never cleared it. monkeypatch.delenv
  the VERCEL_* vars in the link-file test.
- test_clipboard TestIsWsl: GitHub Actions is on Azure VMs whose real
  /proc/version often contains 'microsoft'. Patching builtins.open with
  mock_open didn't reliably intercept hermes_constants.is_wsl's call in
  xdist workers that had already cached _wsl_detected=True from an
  earlier test. Patch hermes_constants.open directly and add
  teardown_method to reset the cache after each test.

Pytest-asyncio cancellation hangs (fix: bound product await with timeout)
- test_session_split_brain_11016 (3 params) + test_gateway_shutdown
  cancel-inflight: under pytest-asyncio 1.3.0, 'await task' and
  'asyncio.gather(cancelled_tasks)' can stall for 30s when the cancelled
  task's finally block awaits typing-task cleanup. Bound both with
  asyncio.wait_for(..., timeout=5.0) and asyncio.shield — the stragglers
  are released from adapter tracking and allowed to finish unwinding in
  the background. This is also a legitimate hardening: a wedged finally
  shouldn't stall the caller's dispatch or a gateway shutdown.

Orphan UI config (fix: merge tiny tab into messaging category)
- test_web_server test_no_single_field_categories: the telegram.reactions
  config field lived in its own 'telegram' schema category with no
  siblings. Fold it under 'discord' via _CATEGORY_MERGE so the dashboard
  doesn't render an orphan single-field tab.

Local verification: 38/38 originally-failing tests pass; 4044/4044
gateway tests pass; 684/684 targeted subset (all 16 touched test files)
passes.
2026-04-29 20:05:32 -07:00
Brooklyn Nicholson d05497f812 fix(tui): reset terminal modes on startup and exit
Reset sticky mouse/focus/paste terminal modes before the TUI starts and during graceful shutdown paths so stale tab state from prior crashes cannot poison the next session.
2026-04-29 21:41:51 -05:00
Brooklyn Nicholson 98a428fd61 fix(cli): recover from leaked mouse tracking escapes
Detect leaked SGR mouse-report fragments in CLI input, strip them, and reset terminal modes in-place so scroll and typing recover without reopening the tab. Add regression tests for escaped, visible, and bare leak forms.
2026-04-29 21:35:47 -05:00
brooklyn! 8cce85b819 Merge pull request #17669 from NousResearch/bb/tui-scroll-precision-mod
feat(tui): line-by-line scroll mode on modified mouse wheel
2026-04-29 18:56:17 -07:00
Brooklyn Nicholson fc0f358f37 fix(tui): add modifier-held precision wheel scrolling
Route Option/Alt or Ctrl wheel input through a gated precision path that scrolls at most one row per short interval, while preserving the existing accelerated behavior for plain wheel input. Keep precision active briefly after modifier release so queued wheel events from the same gesture do not jump into acceleration mid-stream.
2026-04-29 20:50:12 -05:00
Ben Barclay 7a4da315a2 fix(docker): add curl to apt dependencies
curl is a ubiquitous tool both for users running ad-hoc commands inside
the container (debugging, health checks, quick HTTP probes) and for
agent workflows — many bundled skills and hub skills lean on curl for
HTTP calls, API exploration, and installer bootstrapping. Its absence
causes silent workflow failures with "curl: command not found" until
the user manually apt-installs it.

Add curl to the single apt-get install layer alongside the other base
utilities (build-essential, nodejs, git, openssh-client, etc.) so it
ships in the image with zero extra layers and negligible size impact
(~400 KB).

- Dockerfile: add curl to the apt-get install list
2026-04-30 11:49:40 +10:00
Brooklyn Nicholson b978fd8b26 feat(tui): preserve modifiers on mouse wheel events
Decode Shift, Meta, and Ctrl bits from SGR and legacy X10 wheel event button bytes so TUI input handlers can distinguish modified wheel gestures from plain scrolling.
2026-04-29 20:39:39 -05:00
ethernet 9fc9c15b4a fix(banner): show correct update status on nix-built hermes (#17550)
check_for_updates() looked at __file__.parent.parent for a .git dir to
  diff against origin/main. A nix-built hermes lives in /nix/store with
  no .git there, so the check fell through to whatever editable-install
  dev checkout last populated ~/.hermes/.update_check, producing stale
  "X commits behind" warnings right after a fresh `nix run --refresh`.

  Embed the locked flake rev into the wrapper as HERMES_REVISION (only
on
  clean builds — dirty refs don't represent any upstream commit). When
  set, banner.py compares it to upstream main via `git ls-remote`
instead
  of inspecting a local checkout, and the cache key includes the rev so
  nix updates invalidate immediately. Without local history we can't
  count commits, so the message is a plain "update available" with no
  suggested command — nix users may install via `nix run`, profile,
  system flake, or home-manager, and we don't know which.

  Also bump web/package-lock.json npmDepsHash via `nix run
.#fix-lockfiles`.
2026-04-30 07:03:00 +05:30
brooklyn! fc7f55f490 fix(tui): responsive /compress with live progress + CLI-parity feedback (#17661)
* fix(tui): offload manual compaction RPC

Route TUI session compression through the existing long-handler pool so slow compaction does not block other gateway RPCs.

* fix(tui): show compaction progress immediately

Print a local status line before the compress RPC starts so slow manual compaction does not look like a no-op.

* feat(tui): rich /compress feedback parity with CLI

Show pre-compaction message count and rough token estimate immediately, emit a status update so the bottom bar reflects ongoing compaction, and report a multi-line summary (headline + token delta + optional note) using the shared summarize_manual_compression helper.

* fix(tui): show live compaction estimate in transcript

Mirror compression progress status into the transcript so users see the backend message count and token estimate while /compress is still running.

* fix(tui): single live compaction line with spinner glyph

Drop the redundant local "compressing context..." placeholder and prefix the live backend status line with a braille spinner glyph so /compress reads as a single in-progress row.

* fix(tui): address review nits on /compress feedback

Reuse the precomputed token estimate inside _compress_session_history so the gateway does not redo the O(n) work while holding history_lock, keep the status bar pinned during long manual compactions instead of auto-restoring after 4s, and drop the redundant noop bullet that doubled with the system role glyph.

* fix(tui): release history_lock during compaction LLM call

Move the snapshot/commit pattern into _compress_session_history so the lock is held only across the in-memory bookkeeping, not during agent._compress_context. Also emit a final neutral status update from session.compress so the pinned compressing indicator clears even on errors.

* fix(tui): rebuild prompt cleanly + sync session_key after compress

Pass system_message=None so AIAgent._compress_context rebuilds the system prompt without nesting the cached identity block. Reuse the handler's pre-snapshotted history inside _compress_session_history to avoid a second O(n) copy under the lock. After compaction, when AIAgent._compress_context rotates session_id, sync the gateway session_key, migrate approval notify + yolo state, restart the slash worker, and clear the stale pending title. Mirrors HermesCLI._manual_compress.

* Avoid /compress lock re-entry in slash side effects.

Stop pre-locking history before _compress_session_history in slash command mirroring, keep session-key sync parity with manual compression, and add a regression test that asserts /compress is invoked without holding history_lock.
2026-04-29 18:01:18 -07:00
Austin Pickett cb0e2e2f36 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-29 15:23:30 -04:00
vincez-hms-coder 4c0cc77e94 fix(dashboard): keep ui imports browser-safe after rebase 2026-04-29 01:47:13 -04:00
vincez-hms-coder 9b62c98170 chore(dashboard): restore package lock metadata 2026-04-29 01:43:21 -04:00
vincez-hms-coder 469e4df3c2 fix(profiles): preserve skills on dashboard profile creation 2026-04-29 01:42:51 -04:00
vincez-hms-coder ae11a31058 feat(profiles): add profile setup command endpoint and wrapper creation 2026-04-29 01:42:51 -04:00
vincez-hms-coder 3e200b64fb fix(profiles): update terminal command for copying based on profile name
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 01:42:51 -04:00
vincez-hms-coder 1745cfc6d7 fix(dashboard): avoid node-only ui imports in browser 2026-04-29 01:42:50 -04:00
vincez-hms-coder 58c07867e3 fix(dashboard): keep profiles list resilient 2026-04-29 01:39:52 -04:00
vincez-hms-coder 4523965de9 feat(dashboard): add profiles management page
Copy profile dashboard changes onto a fresh branch under the vincez-hms-coder account.

Includes:
- Profiles dashboard route and sidebar entry
- Profile lifecycle REST endpoints
- SOUL.md read/write support
- i18n labels and helper text updates
- Targeted profile API tests

Test plan:
- pytest tests/hermes_cli/test_web_server.py -k profile -q
- cd web && npm run build
2026-04-29 01:39:51 -04:00
498 changed files with 58645 additions and 3864 deletions
+16
View File
@@ -398,3 +398,19 @@ IMAGE_TOOLS_DEBUG=false
# Override STT provider endpoints (for proxies or self-hosted instances)
# GROQ_BASE_URL=https://api.groq.com/openai/v1
# STT_OPENAI_BASE_URL=https://api.openai.com/v1
# =============================================================================
# MICROSOFT TEAMS INTEGRATION
# =============================================================================
# Register a Bot in Azure: https://dev.botframework.com/ → "Register a bot"
# Or use Azure Portal: Azure Active Directory → App registrations → New registration
# Then add the bot to Teams via the Bot Framework or App Studio.
#
# TEAMS_CLIENT_ID= # Azure AD App (client) ID
# TEAMS_CLIENT_SECRET= # Azure AD client secret value
# TEAMS_TENANT_ID= # Azure AD tenant ID (or "common" for multi-tenant)
# TEAMS_ALLOWED_USERS= # Comma-separated AAD object IDs or UPNs
# TEAMS_ALLOW_ALL_USERS=false # Set true to skip the allowlist
# TEAMS_HOME_CHANNEL= # Default channel/chat ID for cron delivery
# TEAMS_HOME_CHANNEL_NAME= # Display name for the home channel
# TEAMS_PORT=3978 # Webhook listen port (Bot Framework default)
+12 -2
View File
@@ -1,8 +1,18 @@
name: 'Setup Nix'
description: 'Install Nix with DeterminateSystems and enable magic-nix-cache'
description: 'Install Nix and configure Cachix binary cache'
inputs:
cachix-auth-token:
description: 'Cachix auth token (enables push). Omit for read-only.'
required: false
default: ''
runs:
using: composite
steps:
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
- uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
with:
name: hermes-agent
authToken: ${{ inputs.cachix-auth-token }}
continue-on-error: true
-74
View File
@@ -1,74 +0,0 @@
name: Nix Lockfile Check
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
pull-requests: write
concurrency:
group: nix-lockfile-check-${{ github.ref }}
cancel-in-progress: true
jobs:
nix-lockfile-check:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/nix-setup
- name: Resolve head SHA
id: sha
shell: bash
run: |
FULL="${{ github.event.pull_request.head.sha || github.sha }}"
echo "full=$FULL" >> "$GITHUB_OUTPUT"
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
- name: Check lockfile hashes
id: check
continue-on-error: true
env:
LINK_SHA: ${{ steps.sha.outputs.full }}
run: nix run .#fix-lockfiles -- --check
- name: Fail if check crashed without reporting
if: steps.check.outputs.stale != 'true' && steps.check.outputs.stale != 'false'
run: |
echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure"
exit 1
- name: Post sticky PR comment (stale)
if: steps.check.outputs.stale == 'true' && github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
message: |
### ⚠️ npm lockfile hash out of date
Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time).
The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`:
${{ steps.check.outputs.report }}
#### Apply the fix
- [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
- Or locally: `nix run .#fix-lockfiles -- --apply` and commit the diff
- name: Clear sticky PR comment (resolved)
if: steps.check.outputs.stale == 'false' && github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
delete: true
- name: Fail if stale
if: steps.check.outputs.stale == 'true'
run: exit 1
+6 -2
View File
@@ -28,7 +28,7 @@ concurrency:
jobs:
# ── Auto-fix on main ───────────────────────────────────────────────
# Fires when a push to main touches package.json or package-lock.json
# in ui-tui/ or web/. Runs fix-lockfiles --apply and pushes the hash
# in ui-tui/ or web/. Runs fix-lockfiles and pushes the hash
# update commit directly to main so Nix builds never stay broken.
#
# Safety invariants:
@@ -62,6 +62,8 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
- uses: ./.github/actions/nix-setup
with:
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Apply lockfile hashes
id: apply
@@ -200,10 +202,12 @@ jobs:
fetch-depth: 0
- uses: ./.github/actions/nix-setup
with:
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Apply lockfile hashes
id: apply
run: nix run .#fix-lockfiles -- --apply
run: nix run .#fix-lockfiles
- name: Commit & push
if: steps.apply.outputs.changed == 'true'
+84
View File
@@ -7,6 +7,7 @@ on:
permissions:
contents: read
pull-requests: write
concurrency:
group: nix-${{ github.ref }}
@@ -22,12 +23,95 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/nix-setup
with:
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Resolve head SHA
if: github.event_name == 'pull_request'
id: sha
shell: bash
run: |
FULL="${{ github.event.pull_request.head.sha || github.sha }}"
echo "full=$FULL" >> "$GITHUB_OUTPUT"
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
- name: Check flake
id: flake
if: runner.os == 'Linux'
continue-on-error: true
run: nix flake check --print-build-logs
- name: Build package
id: build
if: runner.os == 'Linux'
continue-on-error: true
run: nix build --print-build-logs
# When the real Nix build fails, run a targeted diagnostic to see if
# the failure is specifically a stale npm lockfile hash in one of the
# known npm subpackages (tui / web). This avoids surfacing a generic
# "build failed" message when the fix is a single known command.
- name: Diagnose npm lockfile hashes
id: hash_check
if: (steps.flake.outcome == 'failure' || steps.build.outcome == 'failure') && runner.os == 'Linux'
continue-on-error: true
env:
LINK_SHA: ${{ steps.sha.outputs.full }}
run: nix run .#fix-lockfiles -- --check
# If fix-lockfiles itself crashes (infrastructure blip, cache throttle,
# etc.) it won't set stale=true/false. Treat that as a distinct failure
# mode rather than silently ignoring it.
- name: Fail if hash check crashed without reporting
if: steps.hash_check.outcome == 'failure' && steps.hash_check.outputs.stale != 'true' && steps.hash_check.outputs.stale != 'false'
run: |
echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure"
exit 1
- name: Post sticky PR comment (stale hashes)
if: steps.hash_check.outputs.stale == 'true' && github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
message: |
### ⚠️ npm lockfile hash out of date
Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time).
The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`:
${{ steps.hash_check.outputs.report }}
#### Apply the fix
- [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
- Or locally: `nix run .#fix-lockfiles` and commit the diff
# Clear the sticky comment when either the build passed outright (no
# hash check needed) or the hash check explicitly returned stale=false
# (build failed for a non-hash reason).
- name: Clear sticky PR comment (resolved)
if: |
github.event_name == 'pull_request' &&
runner.os == 'Linux' &&
(steps.hash_check.outputs.stale == 'false' ||
(steps.flake.outcome == 'success' && steps.build.outcome == 'success'))
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
delete: true
- name: Final fail if build or flake failed
if: steps.flake.outcome == 'failure' || steps.build.outcome == 'failure'
run: |
if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then
echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles"
else
echo "::error::Nix build/flake check failed. See logs above."
fi
exit 1
- name: Evaluate flake (macOS)
if: runner.os == 'macOS'
run: nix flake show --json > /dev/null
+1 -1
View File
@@ -14,7 +14,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# that would otherwise accumulate when hermes runs as PID 1. See #15012.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli tini && \
build-essential curl nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli tini && \
rm -rf /var/lib/apt/lists/*
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
+505
View File
@@ -0,0 +1,505 @@
# Hermes Agent v0.12.0 (v2026.4.30)
**Release Date:** April 30, 2026
**Since v0.11.0:** 1,096 commits · 550 merged PRs · 1,270 files changed · 217,776 insertions · 213 community contributors (including co-authors)
> The Curator release — Hermes Agent now maintains itself. An autonomous background Curator grades, prunes, and consolidates your skill library on its own schedule. The self-improvement loop that reviews what to save got a substantial upgrade. Four new inference providers, a 18th messaging platform, a 19th via Teams plugin, native Spotify + Google Meet integrations, ComfyUI and TouchDesigner-MCP moved from optional to bundled-by-default, and a ~57% cut to visible TUI cold start.
---
## ✨ Highlights
- **Autonomous Curator** — `hermes curator` runs as a background agent on the gateway's cron ticker (7-day cycle default). It grades your skill library, consolidates related skills, prunes dead ones, and writes per-run reports to `logs/curator/run.json` + `REPORT.md`. Archived skills are classified consolidated-vs-pruned via model + heuristic. Defense-in-depth gates protect bundled/hub skills from mutation. Unified under `auxiliary.curator` — pick the curator's model in `hermes model`, manage it from the dashboard. `hermes curator status` ranks skills by usage (most-used / least-used). ([#17277](https://github.com/NousResearch/hermes-agent/pull/17277), [#17307](https://github.com/NousResearch/hermes-agent/pull/17307), [#17941](https://github.com/NousResearch/hermes-agent/pull/17941), [#17868](https://github.com/NousResearch/hermes-agent/pull/17868), [#18033](https://github.com/NousResearch/hermes-agent/pull/18033))
- **Self-improvement loop — substantially upgraded** — The background review fork (the core of Hermes' self-improvement: after each turn it decides what memories/skills to save or update) is now class-first (rubric-based rather than free-form), active-update biased (prefers the skill the agent just loaded), handles `references/`/`templates/` sub-files, and properly inherits the parent's live runtime (provider, model, credentials actually propagate). Restricted to memory + skills toolsets so it can't sprawl. Memory providers shut down cleanly. Prior-turn tool messages excluded from the summary so the fork sees a clean context. ([#16026](https://github.com/NousResearch/hermes-agent/pull/16026), [#17213](https://github.com/NousResearch/hermes-agent/pull/17213), [#16099](https://github.com/NousResearch/hermes-agent/pull/16099), [#16569](https://github.com/NousResearch/hermes-agent/pull/16569), [#16204](https://github.com/NousResearch/hermes-agent/pull/16204), [#15057](https://github.com/NousResearch/hermes-agent/pull/15057))
- **Skill integrations — major expansion** — **ComfyUI v5** with official CLI + REST + hardware-gated local install, moved from optional to **built-in by default** ([#17610](https://github.com/NousResearch/hermes-agent/pull/17610), [#17631](https://github.com/NousResearch/hermes-agent/pull/17631), [#17734](https://github.com/NousResearch/hermes-agent/pull/17734)). **TouchDesigner-MCP** bundled by default, expanded with GLSL, post-FX, audio, geometry, and 9 new reference docs ([#16753](https://github.com/NousResearch/hermes-agent/pull/16753), [#16624](https://github.com/NousResearch/hermes-agent/pull/16624), [#16768](https://github.com/NousResearch/hermes-agent/pull/16768) — @kshitijk4poor + @SHL0MS). **Humanizer** skill ports a text-cleaner that strips AI-isms ([#16787](https://github.com/NousResearch/hermes-agent/pull/16787)). **claude-design** HTML artifact skill + design-md (Google DESIGN.md spec) + airtable salvage + `skill_manage` edits in `external_dirs` + direct-URL skill install + `/reload-skills` slash command. ([#16358](https://github.com/NousResearch/hermes-agent/pull/16358), [#14876](https://github.com/NousResearch/hermes-agent/pull/14876), [#16291](https://github.com/NousResearch/hermes-agent/pull/16291), [#17512](https://github.com/NousResearch/hermes-agent/pull/17512), [#16323](https://github.com/NousResearch/hermes-agent/pull/16323), [#17744](https://github.com/NousResearch/hermes-agent/pull/17744))
- **LM Studio — first-class provider** — upgraded from a custom-endpoint alias to a full-blown native provider: dedicated auth, `hermes doctor` checks, reasoning transport, live `/models` listing. (Salvage of @kshitijk4poor's #17061.) ([#17102](https://github.com/NousResearch/hermes-agent/pull/17102))
- **Four more new inference providers** — **GMI Cloud** (first-class, salvage of #11955@isaachuangGMICLOUD), **Azure AI Foundry** with auto-detection, **MiniMax OAuth** with PKCE browser flow (salvage #15203), **Tencent Tokenhub** (salvage of #16860). ([#16663](https://github.com/NousResearch/hermes-agent/pull/16663), [#15845](https://github.com/NousResearch/hermes-agent/pull/15845), [#17524](https://github.com/NousResearch/hermes-agent/pull/17524), [#16960](https://github.com/NousResearch/hermes-agent/pull/16960))
- **Pluggable gateway platforms + Microsoft Teams** — the gateway is now a plugin host. Drop-in messaging adapters live outside the core, and Microsoft Teams is the first plugin-shipped platform. (Salvage of #17664.) ([#17751](https://github.com/NousResearch/hermes-agent/pull/17751), [#17828](https://github.com/NousResearch/hermes-agent/pull/17828))
- **Tencent 元宝 (Yuanbao) — 18th messaging platform** — native gateway adapter with text + media delivery. ([#16298](https://github.com/NousResearch/hermes-agent/pull/16298), [#17424](https://github.com/NousResearch/hermes-agent/pull/17424))
- **Spotify — native tools + bundled skill + wizard** — 7 tools (play, search, queue, playlists, devices) behind PKCE OAuth, interactive setup wizard, bundled skill, surfacing in `hermes tools`, cron usage documented. ([#15121](https://github.com/NousResearch/hermes-agent/pull/15121), [#15130](https://github.com/NousResearch/hermes-agent/pull/15130), [#15154](https://github.com/NousResearch/hermes-agent/pull/15154), [#15180](https://github.com/NousResearch/hermes-agent/pull/15180))
- **Google Meet plugin** — join calls, transcribe, speak, follow up. Realtime OpenAI transport + Node bot server, full pipeline bundled as a plugin. ([#16364](https://github.com/NousResearch/hermes-agent/pull/16364))
- **`hermes -z` one-shot mode + `hermes update --check`** — non-interactive `hermes -z <prompt>` with `--model`/`--provider`/`HERMES_INFERENCE_MODEL`. `hermes update --check` preflight. Opt-in pre-update HERMES_HOME backup. ([#15702](https://github.com/NousResearch/hermes-agent/pull/15702), [#15704](https://github.com/NousResearch/hermes-agent/pull/15704), [#15841](https://github.com/NousResearch/hermes-agent/pull/15841), [#16539](https://github.com/NousResearch/hermes-agent/pull/16539), [#16566](https://github.com/NousResearch/hermes-agent/pull/16566))
- **Models dashboard tab + in-browser model config** — rich per-model analytics, switch main + auxiliary models from the dashboard. ([#17745](https://github.com/NousResearch/hermes-agent/pull/17745), [#17802](https://github.com/NousResearch/hermes-agent/pull/17802))
- **Remote model catalog manifest** — OpenRouter + Nous Portal model catalogs are now pulled from a remote manifest so new models show up without a release. ([#16033](https://github.com/NousResearch/hermes-agent/pull/16033))
- **Native multimodal image routing** — images now route based on the model's actual vision capability rather than provider defaults. ([#16506](https://github.com/NousResearch/hermes-agent/pull/16506))
- **Gateway media parity** — native multi-image sending across Telegram, Discord, Slack, Mattermost, Email, and Signal; centralized audio routing with FLAC support + Telegram document fallback. ([#17909](https://github.com/NousResearch/hermes-agent/pull/17909), [#17833](https://github.com/NousResearch/hermes-agent/pull/17833))
- **TUI catches up to (and past) the classic CLI** — LaTeX rendering (@austinpickett), `/reload` .env hot-reload, pluggable busy-indicator styles (@OutThisLife, #13610), opt-in auto-resume of last session, expanded light-terminal auto-detection, session delete from `/resume` picker with `d`, modified mouse-wheel line scroll, and a `/mouse` toggle that kills ConPTY's phantom mouse injection (@kevin-ho). ([#17175](https://github.com/NousResearch/hermes-agent/pull/17175), [#17286](https://github.com/NousResearch/hermes-agent/pull/17286), [#17150](https://github.com/NousResearch/hermes-agent/pull/17150), [#17130](https://github.com/NousResearch/hermes-agent/pull/17130), [#17113](https://github.com/NousResearch/hermes-agent/pull/17113), [#17668](https://github.com/NousResearch/hermes-agent/pull/17668), [#17669](https://github.com/NousResearch/hermes-agent/pull/17669), [#15488](https://github.com/NousResearch/hermes-agent/pull/15488))
- **Observability + achievements plugins** — bundled Langfuse observability plugin (salvage #16845) + bundled hermes-achievements plugin that scans full session history. ([#16917](https://github.com/NousResearch/hermes-agent/pull/16917), [#17754](https://github.com/NousResearch/hermes-agent/pull/17754))
- **TTS provider registry + Piper local TTS** — pluggable `tts.providers.<name>` registry; Piper ships as a native local TTS provider. (Closes #8508.) ([#17843](https://github.com/NousResearch/hermes-agent/pull/17843), [#17885](https://github.com/NousResearch/hermes-agent/pull/17885))
- **Vercel Sandbox backend** — Vercel sandboxes as an execute_code/terminal backend (@kshitijk4poor). ([#17445](https://github.com/NousResearch/hermes-agent/pull/17445))
- **Secret redaction off by default** — default flipped to off. Prevents the long-standing patch-corruption incidents where fake secret-shaped substrings mangled tool outputs. Opt in via `redaction.enabled: true` when you need it. ([#16794](https://github.com/NousResearch/hermes-agent/pull/16794))
- **Cold-start performance** — visible TUI cold start cut **~57%** via lazy agent init (@OutThisLife), lazy imports of OpenAI / Anthropic / Firecrawl / account_usage, mtime-cached `load_config()`, memoized `get_tool_definitions()` with TTL-cached `check_fn` results, precompiled dangerous-command patterns. ([#17190](https://github.com/NousResearch/hermes-agent/pull/17190), [#17046](https://github.com/NousResearch/hermes-agent/pull/17046), [#17041](https://github.com/NousResearch/hermes-agent/pull/17041), [#17098](https://github.com/NousResearch/hermes-agent/pull/17098), [#17206](https://github.com/NousResearch/hermes-agent/pull/17206))
- **Configurable prompt cache TTL** — `prompt_caching.cache_ttl` (5m default, 1h opt-in — cost savings for bursty sessions that keep cache warm). Salvage of #12659. ([#15065](https://github.com/NousResearch/hermes-agent/pull/15065))
---
## 🧠 Autonomous Curator & Self-Improvement Loop
### Curator — autonomous skill maintenance
- **`hermes curator` as a background agent** — runs on the gateway's cron ticker, 7-day cycle by default, umbrella-first prompt, inherits parent config, unbounded iterations ([#17277](https://github.com/NousResearch/hermes-agent/pull/17277) — issue #7816)
- **Per-run reports** — `logs/curator/run.json` + `REPORT.md` per cycle ([#17307](https://github.com/NousResearch/hermes-agent/pull/17307))
- **Consolidated vs pruned classification** — archived skills split with model + heuristic ([#17941](https://github.com/NousResearch/hermes-agent/pull/17941))
- **`hermes curator status`** — ranks skills by usage, shows most-used and least-used ([#18033](https://github.com/NousResearch/hermes-agent/pull/18033))
- **Unified under `auxiliary.curator`** — pick the model in `hermes model`, configure from the dashboard ([#17868](https://github.com/NousResearch/hermes-agent/pull/17868))
- **Documentation** — dedicated curator feature page on the docs site ([#17563](https://github.com/NousResearch/hermes-agent/pull/17563))
- Fix: seed defaults on update, create `logs/curator/` directory, defer fire import ([#17927](https://github.com/NousResearch/hermes-agent/pull/17927))
- Fix: scan nested archive subdirs in `restore_skill` (@0xDevNinja) ([#17951](https://github.com/NousResearch/hermes-agent/pull/17951))
- Fix: use actual skill activity in curator status (@y0shua1ee) ([#17953](https://github.com/NousResearch/hermes-agent/pull/17953))
- Fix: `skill_manage` refuses writes on pinned skills; pinning now blocks curator writes ([#17562](https://github.com/NousResearch/hermes-agent/pull/17562), [#17578](https://github.com/NousResearch/hermes-agent/pull/17578))
- Fix: `bump_use()` wired into skill invocation + preload + skill_view (salvage #17782) ([#17932](https://github.com/NousResearch/hermes-agent/pull/17932))
### Self-improvement loop (background review fork)
- **Class-first skill-review prompt** — rubric-based grading rather than free-form "should this update" ([#16026](https://github.com/NousResearch/hermes-agent/pull/16026))
- **Active-update bias** — prefers updating skills the agent just loaded, handles `references/` + `templates/` sub-files ([#17213](https://github.com/NousResearch/hermes-agent/pull/17213))
- **Fork inherits parent's live runtime** — provider, model, credentials actually propagate now ([#16099](https://github.com/NousResearch/hermes-agent/pull/16099))
- **Scoped toolsets** — review fork restricted to memory + skills (no shell, no web) ([#16569](https://github.com/NousResearch/hermes-agent/pull/16569))
- **Clean shutdown** — background review memory providers exit properly (salvage #15289) ([#16204](https://github.com/NousResearch/hermes-agent/pull/16204))
- **Clean context** — prior-history tool messages excluded from review summary (salvage #14967) ([#15057](https://github.com/NousResearch/hermes-agent/pull/15057))
---
## 🧩 Skills Ecosystem
### Skill integrations — newly bundled or promoted
- **ComfyUI v5** — official CLI + REST + hardware-gated local install; **moved from optional to built-in** ([#17610](https://github.com/NousResearch/hermes-agent/pull/17610), [#17631](https://github.com/NousResearch/hermes-agent/pull/17631), [#17734](https://github.com/NousResearch/hermes-agent/pull/17734), [#17612](https://github.com/NousResearch/hermes-agent/pull/17612))
- **TouchDesigner-MCP** — **bundled by default** ([#16753](https://github.com/NousResearch/hermes-agent/pull/16753) — @kshitijk4poor), expanded with GLSL, post-FX, audio, geometry references ([#16624](https://github.com/NousResearch/hermes-agent/pull/16624)), 9 new reference docs ([#16768](https://github.com/NousResearch/hermes-agent/pull/16768) — @SHL0MS)
- **Humanizer** — strips AI-isms from text ([#16787](https://github.com/NousResearch/hermes-agent/pull/16787))
- **claude-design** — HTML artifact skill with disambiguation from other design skills ([#16358](https://github.com/NousResearch/hermes-agent/pull/16358))
- **design-md** — Google's DESIGN.md spec skill ([#14876](https://github.com/NousResearch/hermes-agent/pull/14876))
- **airtable** — salvaged skill + skill API keys wired into `.env` (#15838) ([#16291](https://github.com/NousResearch/hermes-agent/pull/16291))
- **pretext** — creative browser demos with @chenglou/pretext ([#17259](https://github.com/NousResearch/hermes-agent/pull/17259))
- **spike** + **sketch** — throwaway experiments + HTML mockups, adapted from gsd-build ([#17421](https://github.com/NousResearch/hermes-agent/pull/17421))
### Skills UX
- **Install skills from a direct HTTP(S) URL** — `hermes skills install <url>` ([#16323](https://github.com/NousResearch/hermes-agent/pull/16323))
- **`/reload-skills`** slash command (salvage #17670) ([#17744](https://github.com/NousResearch/hermes-agent/pull/17744))
- **`hermes skills list`** shows enabled/disabled status ([#16129](https://github.com/NousResearch/hermes-agent/pull/16129))
- **`skill_manage` refuses writes on pinned skills** ([#17562](https://github.com/NousResearch/hermes-agent/pull/17562))
- **`skill_manage` edits external_dirs skills in place** (salvage #9966) ([#17512](https://github.com/NousResearch/hermes-agent/pull/17512), [#17289](https://github.com/NousResearch/hermes-agent/pull/17289))
- Fix: inline-shell rendering in `skill_view` ([#15376](https://github.com/NousResearch/hermes-agent/pull/15376))
- Fix: exclude `.archive/` from skill index walk (salvage #17639) ([#17931](https://github.com/NousResearch/hermes-agent/pull/17931))
- Fix: dedicated docs page per bundled + optional skill ([#14929](https://github.com/NousResearch/hermes-agent/pull/14929))
- Fix: `google-workspace` shared HERMES_HOME helper + ship deps as optional extra ([#15405](https://github.com/NousResearch/hermes-agent/pull/15405))
- Fix: auto-wrap ASCII-art code blocks in generated skill pages ([#16497](https://github.com/NousResearch/hermes-agent/pull/16497))
- Point agent at `hermes-agent` skill + docs site for Hermes questions ([#16535](https://github.com/NousResearch/hermes-agent/pull/16535))
---
## 🏗️ Core Agent & Architecture
### Provider & Model Support
#### New providers
- **GMI Cloud** — first-class API-key provider on par with Arcee/Kilocode/Xiaomi (salvage of #11955@isaachuangGMICLOUD) ([#16663](https://github.com/NousResearch/hermes-agent/pull/16663))
- **Azure AI Foundry** — auto-detection, full wiring ([#15845](https://github.com/NousResearch/hermes-agent/pull/15845))
- **LM Studio** — upgraded from custom-endpoint alias to first-class provider: dedicated auth, doctor checks, reasoning transport, live `/models` (salvage of #17061@kshitijk4poor) ([#17102](https://github.com/NousResearch/hermes-agent/pull/17102))
- **MiniMax OAuth** — PKCE browser flow with full OAuth integration (salvage #15203) ([#17524](https://github.com/NousResearch/hermes-agent/pull/17524))
- **Tencent Tokenhub** — new provider (salvage of #16860) ([#16960](https://github.com/NousResearch/hermes-agent/pull/16960))
#### Model catalog
- **Remote model catalog manifest** — OpenRouter + Nous Portal catalogs pulled from remote manifest so new models show up without a release ([#16033](https://github.com/NousResearch/hermes-agent/pull/16033))
- `openai/gpt-5.5` and `gpt-5.5-pro` added to OpenRouter + Nous Portal ([#15343](https://github.com/NousResearch/hermes-agent/pull/15343))
- `deepseek-v4-pro` and `deepseek-v4-flash` added ([#14934](https://github.com/NousResearch/hermes-agent/pull/14934))
- `qwen3.6-plus` added to Alibaba-supported models ([#16896](https://github.com/NousResearch/hermes-agent/pull/16896))
- Gemini free-tier keys blocked at setup with 429 guidance surfacing ([#15100](https://github.com/NousResearch/hermes-agent/pull/15100))
#### Model configuration
- **Configurable `prompt_caching.cache_ttl`** — 5m default, 1h opt-in (salvage #12659) ([#15065](https://github.com/NousResearch/hermes-agent/pull/15065))
- `/fast` whitelist broadened to all OpenAI + Anthropic models ([#16883](https://github.com/NousResearch/hermes-agent/pull/16883))
- `auxiliary.extra_body.reasoning` translates into Codex Responses API ([#17004](https://github.com/NousResearch/hermes-agent/pull/17004))
- `hermes fallback` command for managing fallback providers ([#16052](https://github.com/NousResearch/hermes-agent/pull/16052))
### Agent Loop & Conversation
- **Native multimodal image routing** — based on model vision capability, not provider defaults ([#16506](https://github.com/NousResearch/hermes-agent/pull/16506))
- **Delegate `child_timeout_seconds` default bumped to 600s** ([#14809](https://github.com/NousResearch/hermes-agent/pull/14809))
- **Diagnostic dump when subagent times out with 0 API calls** ([#15105](https://github.com/NousResearch/hermes-agent/pull/15105))
- **Gateway busts cached agent on compression/context_length config edits** ([#17008](https://github.com/NousResearch/hermes-agent/pull/17008))
- **Opt-in runtime-metadata footer on final replies** ([#17026](https://github.com/NousResearch/hermes-agent/pull/17026))
- `/reload-mcp` awareness — rebuild cached agents + prompt-cache cost confirmation ([#17729](https://github.com/NousResearch/hermes-agent/pull/17729))
- Fix: repair CamelCase + `_tool` suffix tool-call emissions ([#15124](https://github.com/NousResearch/hermes-agent/pull/15124))
- Fix: retry on `json.JSONDecodeError` instead of treating as local validation error ([#15107](https://github.com/NousResearch/hermes-agent/pull/15107))
- Fix: handle unescaped control chars in `tool_call.arguments` ([#15356](https://github.com/NousResearch/hermes-agent/pull/15356))
- Fix: ordering fix in `_copy_reasoning_content_for_api` — cross-provider reasoning isolation (@Zjianru) ([#15749](https://github.com/NousResearch/hermes-agent/pull/15749))
- Fix: inject empty `reasoning_content` for DeepSeek/Kimi `tool_calls` unconditionally (@Zjianru) ([#15762](https://github.com/NousResearch/hermes-agent/pull/15762))
- Fix: persist streamed `reasoning_content` on assistant turns (#16844) ([#16892](https://github.com/NousResearch/hermes-agent/pull/16892))
- Fix: cancel coroutine on timeout so worker thread exits; full traceback on tool failure ([#17428](https://github.com/NousResearch/hermes-agent/pull/17428))
- Fix: isolate `get_tool_definitions` quiet_mode cache + dedup LCM injection (#17335) ([#17889](https://github.com/NousResearch/hermes-agent/pull/17889))
- Fix: serialize concurrent `hermes_tools` RPC calls from `execute_code` (#17770) ([#17894](https://github.com/NousResearch/hermes-agent/pull/17894), [#17902](https://github.com/NousResearch/hermes-agent/pull/17902))
- Fix: rename `[SYSTEM:``[IMPORTANT:` in all user-injected markers (dodges Azure content filter) ([#16114](https://github.com/NousResearch/hermes-agent/pull/16114))
### Compression
- **Retry summary on main model for unknown errors before giving up** ([#16774](https://github.com/NousResearch/hermes-agent/pull/16774))
- **Notify users when configured aux model fails even if main-model fallback recovers** ([#16775](https://github.com/NousResearch/hermes-agent/pull/16775))
- `/compress` wrapped in `_busy_command` to block input during compression ([#15388](https://github.com/NousResearch/hermes-agent/pull/15388))
- Fix: reserve system + tools headroom when aux binds threshold ([#15631](https://github.com/NousResearch/hermes-agent/pull/15631))
- Fix: use text-char sum for multimodal token estimation in `_find_tail_cut_by_tokens` ([#16369](https://github.com/NousResearch/hermes-agent/pull/16369))
### Session, Memory & State
- **Trigram FTS5 index for CJK search, replace LIKE fallback** (@alt-glitch) ([#16651](https://github.com/NousResearch/hermes-agent/pull/16651))
- **Index `tool_name` + `tool_calls` in FTS5, with repair + migration** (salvages #16866) ([#16914](https://github.com/NousResearch/hermes-agent/pull/16914))
- **Checkpoints: auto-prune orphan and stale shadow repos at startup** ([#16303](https://github.com/NousResearch/hermes-agent/pull/16303))
- **Memory providers notified on mid-process session_id rotation** (#6672) ([#17409](https://github.com/NousResearch/hermes-agent/pull/17409))
- Fix: quote underscored terms in FTS5 query sanitization ([#16915](https://github.com/NousResearch/hermes-agent/pull/16915))
- Fix: resolve viking_read 500/412 on file URIs + pseudo-summary URIs (salvage #5886) ([#17869](https://github.com/NousResearch/hermes-agent/pull/17869))
- Fix: skip external-provider sync on interrupted turns ([#15395](https://github.com/NousResearch/hermes-agent/pull/15395))
- Fix: close embedded Hindsight async client cleanly (salvage #14605) ([#16209](https://github.com/NousResearch/hermes-agent/pull/16209))
- Fix: pass session transcript to `shutdown_memory_provider` on gateway + CLI (#15165) ([#16571](https://github.com/NousResearch/hermes-agent/pull/16571))
- Fix: write-origin metadata seam ([#15346](https://github.com/NousResearch/hermes-agent/pull/15346))
- Fix: preserve symlinks during atomic file writes ([#16980](https://github.com/NousResearch/hermes-agent/pull/16980))
- Refactor: remove `flush_memories` entirely ([#15696](https://github.com/NousResearch/hermes-agent/pull/15696))
### Auxiliary models
- Fix: surface auxiliary failures in UI (previously silent) ([#15324](https://github.com/NousResearch/hermes-agent/pull/15324))
- Fix: surface title-gen auxiliary failures instead of silently dropping ([#16371](https://github.com/NousResearch/hermes-agent/pull/16371))
- Fix: generalize unsupported-parameter detector and harden `max_tokens` retry ([#15633](https://github.com/NousResearch/hermes-agent/pull/15633))
---
## 📱 Messaging Platforms (Gateway)
### New Platforms
- **Microsoft Teams (19th platform)** — as a plugin, + xdist collision guard ([#17828](https://github.com/NousResearch/hermes-agent/pull/17828))
- **Yuanbao (Tencent 元宝, 18th platform)** — native adapter with text + media delivery ([#16298](https://github.com/NousResearch/hermes-agent/pull/16298), [#17424](https://github.com/NousResearch/hermes-agent/pull/17424), [#16880](https://github.com/NousResearch/hermes-agent/pull/16880))
### Pluggable Gateway Platforms
- **Drop-in messaging adapters** — the gateway is now a plugin host for platforms (salvage of #17664) ([#17751](https://github.com/NousResearch/hermes-agent/pull/17751))
### Telegram
- **Chat allowlists for groups and forums** (@web3blind) ([#15027](https://github.com/NousResearch/hermes-agent/pull/15027))
- **Send fresh finals for stale preview streams** (port openclaw#72038) ([#16261](https://github.com/NousResearch/hermes-agent/pull/16261))
- **Render markdown tables as row-group bullets + prompt hint** ([#16997](https://github.com/NousResearch/hermes-agent/pull/16997))
- Document fallback in centralized audio routing ([#17833](https://github.com/NousResearch/hermes-agent/pull/17833))
- Native multi-image sending ([#17909](https://github.com/NousResearch/hermes-agent/pull/17909))
### Discord
- **Opt-in toolsets + ID injection + tool split + Feishu wiring** (salvage #15457, #15458) ([#15610](https://github.com/NousResearch/hermes-agent/pull/15610), [#15613](https://github.com/NousResearch/hermes-agent/pull/15613))
- Fix: coerce `limit` parameter to int before `min()` call ([#16319](https://github.com/NousResearch/hermes-agent/pull/16319))
### Slack
- **Register every gateway command as a native slash (Discord/Telegram parity)** ([#16164](https://github.com/NousResearch/hermes-agent/pull/16164))
- **`strict_mention` config** — prevents thread auto-engagement ([#16193](https://github.com/NousResearch/hermes-agent/pull/16193))
- **`channel_skill_bindings`** — bind specific skills to specific Slack channels ([#16283](https://github.com/NousResearch/hermes-agent/pull/16283))
### Signal
- **Native formatting** — markdown → bodyRanges, reply quotes, reactions ([#17417](https://github.com/NousResearch/hermes-agent/pull/17417))
- Native multi-image sending ([#17909](https://github.com/NousResearch/hermes-agent/pull/17909))
### Feishu / Mattermost / Email / Signal
- All participate in **native multi-image sending** ([#17909](https://github.com/NousResearch/hermes-agent/pull/17909))
### Gateway Core
- **Centralized audio routing + FLAC support + Telegram doc fallback** ([#17833](https://github.com/NousResearch/hermes-agent/pull/17833))
- **Native multi-image sending** across Telegram, Discord, Slack, Mattermost, Email, Signal ([#17909](https://github.com/NousResearch/hermes-agent/pull/17909))
- **Make hygiene hard message limit configurable** ([#17000](https://github.com/NousResearch/hermes-agent/pull/17000))
- **Opt-in runtime-metadata footer on final replies** ([#17026](https://github.com/NousResearch/hermes-agent/pull/17026))
- **`pre_gateway_dispatch` hook** — plugins can intercept before dispatch ([#15050](https://github.com/NousResearch/hermes-agent/pull/15050))
- **`pre_approval_request` / `post_approval_response` hooks** ([#16776](https://github.com/NousResearch/hermes-agent/pull/16776))
- Fix: timeouts — guard `load_config()` call against runtime exceptions ([#16318](https://github.com/NousResearch/hermes-agent/pull/16318))
- Fix: support passing handler tools via registry ([#15613](https://github.com/NousResearch/hermes-agent/pull/15613))
---
## 🔧 Tool System
### Plugin-first architecture
- **Pluggable gateway platforms** — platforms can ship as plugins ([#17751](https://github.com/NousResearch/hermes-agent/pull/17751))
- **Microsoft Teams as first plugin-shipped platform** ([#17828](https://github.com/NousResearch/hermes-agent/pull/17828))
- **`pre_gateway_dispatch` hook** ([#15050](https://github.com/NousResearch/hermes-agent/pull/15050))
- **`pre_approval_request` + `post_approval_response` hooks** ([#16776](https://github.com/NousResearch/hermes-agent/pull/16776))
- **`duration_ms` on `post_tool_call`** (inspired by Claude Code 2.1.119) ([#15429](https://github.com/NousResearch/hermes-agent/pull/15429))
- **Bundled plugins**: Spotify ([#15174](https://github.com/NousResearch/hermes-agent/pull/15174)), Google Meet ([#16364](https://github.com/NousResearch/hermes-agent/pull/16364)), Langfuse observability ([#16917](https://github.com/NousResearch/hermes-agent/pull/16917)), hermes-achievements ([#17754](https://github.com/NousResearch/hermes-agent/pull/17754))
- **Page-scoped plugin slots for built-in dashboard pages** ([#15658](https://github.com/NousResearch/hermes-agent/pull/15658))
- **Declarative plugin installation for NixOS module** (@alt-glitch) ([#15953](https://github.com/NousResearch/hermes-agent/pull/15953))
### Browser
- **CDP supervisor** — dialog detection + response + cross-origin iframe eval ([#14540](https://github.com/NousResearch/hermes-agent/pull/14540))
- **Auto-spawn local Chromium for LAN/localhost URLs** when cloud provider is configured ([#16136](https://github.com/NousResearch/hermes-agent/pull/16136))
### Execute code / Terminal
- **Vercel Sandbox backend** for `execute_code` / terminal (@kshitijk4poor) ([#17445](https://github.com/NousResearch/hermes-agent/pull/17445))
- **Collapse subagent `task_id`s to shared container** ([#16177](https://github.com/NousResearch/hermes-agent/pull/16177))
- **Docker: run container as host user** to avoid root-owned bind mounts (@benbarclay) ([#17305](https://github.com/NousResearch/hermes-agent/pull/17305))
- Fix: safely quote `~/` subpaths in wrapped `cd` commands ([#15394](https://github.com/NousResearch/hermes-agent/pull/15394))
- Fix: close file descriptor in `LocalEnvironment._update_cwd` ([#17300](https://github.com/NousResearch/hermes-agent/pull/17300))
- Fix: SSH — prevent tar from overwriting remote home dir permissions ([#17898](https://github.com/NousResearch/hermes-agent/pull/17898), [#17867](https://github.com/NousResearch/hermes-agent/pull/17867))
### Image generation
- See Provider section for updates; no new image providers this window.
### TTS / Voice
- **Pluggable TTS provider registry** under `tts.providers.<name>` ([#17843](https://github.com/NousResearch/hermes-agent/pull/17843))
- **Piper** as native local TTS provider (closes #8508) ([#17885](https://github.com/NousResearch/hermes-agent/pull/17885))
- **Voice mode CLI parity in the TUI** — VAD loop + TTS + crash forensics ([#14810](https://github.com/NousResearch/hermes-agent/pull/14810))
- Fix: vision — use HERMES_HOME-based cache dir instead of cwd ([#17719](https://github.com/NousResearch/hermes-agent/pull/17719))
### Cron
- **Honor `hermes tools` config for the cron platform** ([#14798](https://github.com/NousResearch/hermes-agent/pull/14798))
- **Per-job `workdir`** — project-aware cron runs ([#15110](https://github.com/NousResearch/hermes-agent/pull/15110))
- **`context_from` field** — chain cron job outputs ([#15606](https://github.com/NousResearch/hermes-agent/pull/15606))
- Fix: promote `croniter` to a core dependency ([#17577](https://github.com/NousResearch/hermes-agent/pull/17577))
### Web search
- **Expose `limit` for `web_search`** ([#16934](https://github.com/NousResearch/hermes-agent/pull/16934))
### Maps
- Fix: include seconds in timezone UTC offset output ([#16300](https://github.com/NousResearch/hermes-agent/pull/16300))
### Approvals
- **Hardline blocklist for unrecoverable commands** ([#15878](https://github.com/NousResearch/hermes-agent/pull/15878))
- Perf: precompile DANGEROUS_PATTERNS and HARDLINE_PATTERNS ([#17206](https://github.com/NousResearch/hermes-agent/pull/17206))
### ACP
- **Advertise and forward image prompts** ([#18030](https://github.com/NousResearch/hermes-agent/pull/18030))
### API Server
- **POST `/v1/runs/{run_id}/stop`** (salvage of #15656) ([#15842](https://github.com/NousResearch/hermes-agent/pull/15842))
- **Expose run status for external UIs** (#17085) ([#17458](https://github.com/NousResearch/hermes-agent/pull/17458))
### Nix
- **Declarative plugin installation for NixOS module** (@alt-glitch) ([#15953](https://github.com/NousResearch/hermes-agent/pull/15953))
- Fix: use `--rebuild` in fix-lockfiles to bypass cached FOD store paths ([#15444](https://github.com/NousResearch/hermes-agent/pull/15444))
- Fix: `extraPackages` now actually works via per-user profile ([#17047](https://github.com/NousResearch/hermes-agent/pull/17047))
- Fix: refresh web/ npm-deps hash to unblock main builds ([#17174](https://github.com/NousResearch/hermes-agent/pull/17174))
- Fix: replace magic-nix-cache with Cachix ([#17928](https://github.com/NousResearch/hermes-agent/pull/17928))
---
## 🖥️ TUI
### New features
- **LaTeX rendering** (@austinpickett) ([#17175](https://github.com/NousResearch/hermes-agent/pull/17175))
- **`/reload` .env hot-reload** — ported from the classic CLI ([#17286](https://github.com/NousResearch/hermes-agent/pull/17286))
- **Pluggable busy-indicator styles** (@OutThisLife, #13610) ([#17150](https://github.com/NousResearch/hermes-agent/pull/17150))
- **Opt-in auto-resume of the most recent session** (@OutThisLife) ([#17130](https://github.com/NousResearch/hermes-agent/pull/17130))
- **Expanded light-terminal auto-detection** — `HERMES_TUI_THEME` + background hex (@OutThisLife) ([#17113](https://github.com/NousResearch/hermes-agent/pull/17113))
- **Delete sessions from `/resume` picker with `d`** (@OutThisLife) ([#17668](https://github.com/NousResearch/hermes-agent/pull/17668))
- **Line-by-line scroll on modified mouse wheel** (@OutThisLife) ([#17669](https://github.com/NousResearch/hermes-agent/pull/17669))
- **Delete queued message while editing with ctrl-x / cancel with esc** (@OutThisLife) ([#16707](https://github.com/NousResearch/hermes-agent/pull/16707))
- **Per-section visibility for the details accordion** (@OutThisLife) ([#14968](https://github.com/NousResearch/hermes-agent/pull/14968))
- **Voice mode CLI parity** — VAD loop + TTS + crash forensics ([#14810](https://github.com/NousResearch/hermes-agent/pull/14810))
- **Contextual first-touch hints ported to TUI** — `/busy`, `/verbose` ([#16054](https://github.com/NousResearch/hermes-agent/pull/16054))
- **Mini help menu on `?` in the input field** (@ethernet8023) ([#18043](https://github.com/NousResearch/hermes-agent/pull/18043))
### Fixes
- Fix: proactive mouse disable on ConPTY + `/mouse` toggle command (@kevin-ho, WSL2 ghost-mouse fix) ([#15488](https://github.com/NousResearch/hermes-agent/pull/15488))
- Fix: restore skills search RPC ([#15870](https://github.com/NousResearch/hermes-agent/pull/15870))
- Perf: cache text measurements across yoga flex re-passes ([#14818](https://github.com/NousResearch/hermes-agent/pull/14818))
- Perf: stabilize long-session scrolling ([#15926](https://github.com/NousResearch/hermes-agent/pull/15926))
- Perf: lazily seed virtual history heights ([#16523](https://github.com/NousResearch/hermes-agent/pull/16523))
- Perf: cut visible cold start ~57% with lazy agent init ([#17190](https://github.com/NousResearch/hermes-agent/pull/17190))
---
## 🖱️ CLI & User Experience
### New commands
- **`hermes -z <prompt>`** — non-interactive one-shot mode ([#15702](https://github.com/NousResearch/hermes-agent/pull/15702))
- **`hermes -z` with `--model` / `--provider` / `HERMES_INFERENCE_MODEL`** ([#15704](https://github.com/NousResearch/hermes-agent/pull/15704))
- **`hermes update --check`** preflight flag ([#15841](https://github.com/NousResearch/hermes-agent/pull/15841))
- **`hermes fallback`** command for managing fallback providers ([#16052](https://github.com/NousResearch/hermes-agent/pull/16052))
- **`/busy`** slash command for busy input mode ([#15382](https://github.com/NousResearch/hermes-agent/pull/15382))
- **`/busy` input mode 'steer'** as a third option ([#16279](https://github.com/NousResearch/hermes-agent/pull/16279))
- **`/btw` as alias for `/background`** ([#16053](https://github.com/NousResearch/hermes-agent/pull/16053))
- **`/reload-skills`** slash command (salvage #17670) ([#17744](https://github.com/NousResearch/hermes-agent/pull/17744))
- **Surface `/queue`, `/bg`, `/steer` in agent-running placeholder** ([#16118](https://github.com/NousResearch/hermes-agent/pull/16118))
### Setup / onboarding
- **Auto-reconfigure on existing installs** ([#15879](https://github.com/NousResearch/hermes-agent/pull/15879))
- **Contextual first-touch hints for `/busy` and `/verbose`** ([#16046](https://github.com/NousResearch/hermes-agent/pull/16046))
- **Cost-saving tips from the April 30 tip-of-the-day** ([#17841](https://github.com/NousResearch/hermes-agent/pull/17841))
- **Hyperlink startup banner title to the latest GitHub Release** ([#14945](https://github.com/NousResearch/hermes-agent/pull/14945))
### Update / backup
- **Snapshot pairing data before `git pull`** ([#16383](https://github.com/NousResearch/hermes-agent/pull/16383))
- **Auto-backup HERMES_HOME before `hermes update`** (opt-in, off by default) ([#16539](https://github.com/NousResearch/hermes-agent/pull/16539), [#16566](https://github.com/NousResearch/hermes-agent/pull/16566))
- **Exclude `checkpoints/` from backups** ([#16572](https://github.com/NousResearch/hermes-agent/pull/16572))
- **Exclude SQLite WAL/SHM/journal sidecars from backups** ([#16576](https://github.com/NousResearch/hermes-agent/pull/16576))
- **Installer FHS layout for root installs on Linux** ([#15608](https://github.com/NousResearch/hermes-agent/pull/15608))
- Fix: kill stale dashboards instead of warning ([#17832](https://github.com/NousResearch/hermes-agent/pull/17832))
- Fix: show correct update status on nix-built hermes ([#17550](https://github.com/NousResearch/hermes-agent/pull/17550))
### Slash-command housekeeping
- Refactor: drop `/provider`, `/plan` handler, and clean up slash registry ([#15047](https://github.com/NousResearch/hermes-agent/pull/15047))
- Refactor: drop `persist_session` plumbing + fix broken `/btw` mid-turn bypass ([#16075](https://github.com/NousResearch/hermes-agent/pull/16075))
### OpenClaw migration (for folks coming from OpenClaw)
- **Hardened OpenClaw import** — plan-first apply, redaction, pre-migration backup ([#16911](https://github.com/NousResearch/hermes-agent/pull/16911))
- Fix: case-preserving brand rewrite + one-time `~/.openclaw` residue banner ([#16327](https://github.com/NousResearch/hermes-agent/pull/16327))
- Fix: resolve `openclaw` workspace files from `agents.defaults.workspace` ([#16879](https://github.com/NousResearch/hermes-agent/pull/16879))
- Fix: resolve model aliases against real OpenClaw catalog schema (salvage #16778) ([#16977](https://github.com/NousResearch/hermes-agent/pull/16977))
---
## 📊 Web Dashboard
- **Models tab** — rich per-model analytics ([#17745](https://github.com/NousResearch/hermes-agent/pull/17745))
- **Configure main + auxiliary models from the Models page** ([#17802](https://github.com/NousResearch/hermes-agent/pull/17802))
- **Dashboard Chat tab — xterm.js + JSON-RPC sidecar** (supersedes #12710 + #13379, @OutThisLife) ([#14890](https://github.com/NousResearch/hermes-agent/pull/14890))
- **Dashboard layout refresh** (@austinpickett) ([#14899](https://github.com/NousResearch/hermes-agent/pull/14899))
- **`--stop` and `--status` flags** on the dashboard CLI ([#17840](https://github.com/NousResearch/hermes-agent/pull/17840))
- **Page-scoped plugin slots for built-in pages** ([#15658](https://github.com/NousResearch/hermes-agent/pull/15658))
- Fix: replace all buttons for design system buttons ([#17007](https://github.com/NousResearch/hermes-agent/pull/17007))
---
## ⚡ Performance
- **TUI visible cold start cut ~57%** via lazy agent init ([#17190](https://github.com/NousResearch/hermes-agent/pull/17190))
- **Lazy-import OpenAI, Anthropic, Firecrawl, account_usage** ([#17046](https://github.com/NousResearch/hermes-agent/pull/17046))
- **mtime-cache `load_config()` and `read_raw_config()`** ([#17041](https://github.com/NousResearch/hermes-agent/pull/17041))
- **Memoize `get_tool_definitions()` + TTL-cache `check_fn` results** ([#17098](https://github.com/NousResearch/hermes-agent/pull/17098))
- **Precompile DANGEROUS_PATTERNS and HARDLINE_PATTERNS** ([#17206](https://github.com/NousResearch/hermes-agent/pull/17206))
- **Cache Ink text measurements across yoga flex re-passes** ([#14818](https://github.com/NousResearch/hermes-agent/pull/14818))
- **Stabilize long-session scrolling** ([#15926](https://github.com/NousResearch/hermes-agent/pull/15926))
- **Lazily seed virtual history heights** ([#16523](https://github.com/NousResearch/hermes-agent/pull/16523))
---
## 🔒 Security & Reliability
- **Secret redaction off by default** — stops corrupting patches / API payloads with fake-key substitutions. Opt in via `redaction.enabled: true` ([#16794](https://github.com/NousResearch/hermes-agent/pull/16794))
- **`[SYSTEM:``[IMPORTANT:`** in all user-injected markers (Azure content filter dodge) ([#16114](https://github.com/NousResearch/hermes-agent/pull/16114))
- **Hardline blocklist for unrecoverable commands** ([#15878](https://github.com/NousResearch/hermes-agent/pull/15878))
- **Canonical `mask_secret` helper; fix status.py DIM drift** ([#17207](https://github.com/NousResearch/hermes-agent/pull/17207))
- **Sweep expired paste.rs uploads on a real timer** ([#16431](https://github.com/NousResearch/hermes-agent/pull/16431))
- **Preserve symlinks during atomic file writes** ([#16980](https://github.com/NousResearch/hermes-agent/pull/16980))
- **Probe `/dev/tty` by opening it, not bare existence** ([#17024](https://github.com/NousResearch/hermes-agent/pull/17024))
---
## 🐛 Notable Bug Fixes
This window includes 360 `fix:` PRs. Selected highlights from across the stack:
- **Background review fork inherits parent's live runtime** — provider/model/creds now propagate correctly ([#16099](https://github.com/NousResearch/hermes-agent/pull/16099))
- **Hindsight configurable `HINDSIGHT_TIMEOUT` env var** ([#15077](https://github.com/NousResearch/hermes-agent/pull/15077))
- **Tools: normalize numeric entries + clear stale `no_mcp` in `_save_platform_tools`** ([#15607](https://github.com/NousResearch/hermes-agent/pull/15607))
- **MCP: rewrite `definitions` refs to `$defs` in input schemas** — closes provider-side 400s
- **Azure content filter compatibility** — renamed `[SYSTEM:` markers so Azure's content filter stops flagging them ([#16114](https://github.com/NousResearch/hermes-agent/pull/16114))
- **Vision cache uses HERMES_HOME instead of cwd** ([#17719](https://github.com/NousResearch/hermes-agent/pull/17719))
- **FTS5 search** — tool_name + tool_calls indexing with repair + migration ([#16914](https://github.com/NousResearch/hermes-agent/pull/16914))
- **Streaming reasoning persists on assistant turns** ([#16892](https://github.com/NousResearch/hermes-agent/pull/16892))
- **execute_code concurrent RPC serialization** (#17770) ([#17894](https://github.com/NousResearch/hermes-agent/pull/17894), [#17902](https://github.com/NousResearch/hermes-agent/pull/17902))
- **Background reviewer scoped to memory + skills toolsets** — no more accidental web/shell escapes ([#16569](https://github.com/NousResearch/hermes-agent/pull/16569))
- **Compression recovery** — retry on main before giving up; notify user when aux fails ([#16774](https://github.com/NousResearch/hermes-agent/pull/16774), [#16775](https://github.com/NousResearch/hermes-agent/pull/16775))
- **`croniter` promoted to a core dependency** ([#17577](https://github.com/NousResearch/hermes-agent/pull/17577))
- **Discord tool `limit` parameter coerced to int** before `min()` call ([#16319](https://github.com/NousResearch/hermes-agent/pull/16319))
- **Yuanbao messaging platform entrance fix** ([#16880](https://github.com/NousResearch/hermes-agent/pull/16880))
- **ACP advertise and forward image prompts** ([#18030](https://github.com/NousResearch/hermes-agent/pull/18030))
- **DeepSeek / Kimi reasoning content isolation** across cross-provider histories (@Zjianru) ([#15749](https://github.com/NousResearch/hermes-agent/pull/15749), [#15762](https://github.com/NousResearch/hermes-agent/pull/15762))
- **Preserve reasoning_content replay on DeepSeek v4 + Kimi/Moonshot thinking** ([#18045](https://github.com/NousResearch/hermes-agent/pull/18045))
The vast majority of the 360 fixes landed in the streaming/compression/tool-calling paths across all providers — DeepSeek, Kimi, Moonshot, GLM, Qwen, MiniMax, Gemini, Anthropic, OpenAI — alongside TUI polish (resize, scroll, sticky-prompt) and gateway platform-specific edge cases.
---
## 🧪 Testing & CI
- Hermetic test parity (`scripts/run_tests.sh`) held across this window
- **Microsoft Teams xdist collision guard** — prevents worker collisions when Teams platform tests run in parallel ([#17828](https://github.com/NousResearch/hermes-agent/pull/17828))
- Chore: remove unused imports and dead locals (ruff F401, F841) ([#17010](https://github.com/NousResearch/hermes-agent/pull/17010))
---
## 📚 Documentation
- **Curator feature page** added to docs site ([#17563](https://github.com/NousResearch/hermes-agent/pull/17563))
- **Document pin also blocking `skill_manage` writes** ([#17578](https://github.com/NousResearch/hermes-agent/pull/17578))
- **Direct-URL skill install documented** across features, reference, guide, and `hermes-agent` skill ([#16355](https://github.com/NousResearch/hermes-agent/pull/16355))
- **Hooks tutorial — build a BOOT.md startup checklist** (replaces the removed built-in hook) ([#17202](https://github.com/NousResearch/hermes-agent/pull/17202))
- **ComfyUI docs: ask local vs cloud FIRST before hardware check** ([#17612](https://github.com/NousResearch/hermes-agent/pull/17612))
- **Obliteratus skill: link YouTube video guide in SKILL.md** ([#15808](https://github.com/NousResearch/hermes-agent/pull/15808))
- Per-skill docs pages generated for bundled + optional skills; ASCII art code blocks auto-wrapped ([#14929](https://github.com/NousResearch/hermes-agent/pull/14929), [#16497](https://github.com/NousResearch/hermes-agent/pull/16497))
---
## ⚖️ Removed / Reverted
- **Kanban multi-profile collaboration board** — landed in #16081, reverted in ([#16098](https://github.com/NousResearch/hermes-agent/pull/16098)) while the design is reworked
- **computer-use cua-driver** — 3 preparatory PRs landed then were reverted in ([#16927](https://github.com/NousResearch/hermes-agent/pull/16927))
- **BOOT.md built-in hook** removed ([#17093](https://github.com/NousResearch/hermes-agent/pull/17093)); the hooks tutorial ([#17202](https://github.com/NousResearch/hermes-agent/pull/17202)) shows how to build the same workflow yourself with a shell hook
- **`/provider` + `/plan` slash commands dropped** ([#15047](https://github.com/NousResearch/hermes-agent/pull/15047))
- **`flush_memories` removed entirely** ([#15696](https://github.com/NousResearch/hermes-agent/pull/15696))
---
## 👥 Contributors
### Core
- **@teknium1** (Teknium)
### Top Community Contributors (by merged PR count since v0.11.0)
- **@OutThisLife** (Brooklyn) — 52 PRs · TUI — light-terminal detection + pluggable busy styles + auto-resume + session-delete from /resume + mouse-wheel scrolling + xterm.js dashboard Chat tab + cold-start cut + accordion polish
- **@kshitijk4poor** — 12 PRs · LM Studio first-class provider (salvage), Vercel Sandbox backend, GMI Cloud salvage, bundled-by-default touchdesigner-mcp, many tool-call / reasoning fixes
- **@helix4u** — 10 PRs · MCP schema robustness, assorted stability fixes
- **@alt-glitch** — 8 PRs · trigram FTS5 CJK search, declarative Nix plugin install, matrix/feishu hints and fixes
- **@ethernet8023** — 4 PRs
- **@austinpickett** — 4 PRs · LaTeX rendering in TUI, dashboard layout refresh
- **@benbarclay** — 3 PRs · Docker run-as-host-user so bind mounts don't get root-owned
- **@vominh1919** — 2 PRs
- **@stephenschoettler** — 2 PRs
- **@kevin-ho** — ConPTY mouse-injection fix (#15488)
- **@Zjianru** — cross-provider reasoning_content isolation + DeepSeek/Kimi empty-reasoning injection (#15749, #15762)
- **@web3blind** — Telegram chat allowlists for groups and forums (#15027)
- **@SHL0MS** — 9 new TouchDesigner-MCP reference docs (#16768)
- **@0xDevNinja** — curator `restore_skill` nested-archive fix (#17951)
- **@y0shua1ee** — curator `use` activity fix (#17953)
### Also contributing
Salvaged or co-authored work from **@isaachuangGMICLOUD** (GMI Cloud), earlier upstream PRs from the original author of each salvage chain, and a long tail of one-shot fixes, documentation nudges, and skill contributions from the community.
### All Contributors (alphabetical, excluding @teknium1)
@0xbyt4, @0xharryriddle, @0xDevNinja, @0z1-ghb, @5park1e, @A-FdL-Prog, @aj-nt, @akhater, @alblez, @alexg0bot,
@alexzhu0, @AllardQuek, @alt-glitch, @amanning3390, @amanuel2, @AndreKurait, @andrewhosf, @Andy283, @andyylin,
@angel12, @AntAISecurityLab, @ash, @austinpickett, @badgerbees, @BadTechBandit, @Bartok9, @beenherebefore,
@beesrsj2500, @BeliefanX, @benbarclay, @benjaminsehl, @BlackishGreen33, @bloodcarter, @BlueBirdBack,
@briandevans, @brooklynnicholson, @bsgdigital, @buray, @bwjoke, @camaragon, @cdanis, @cgarwood82,
@charles-brooks, @chen1749144759, @chengoak, @ching-kaching, @Contentment003111, @crayfish-ai, @CruxExperts,
@cyclingwithelephants, @dandaka, @danklynn, @ddupont808, @dhabibi, @difujia, @dimitrovi, @dlkakbs,
@dontcallmejames, @EKKOLearnAI, @emozilla, @ericnicolaides, @Erosika, @ethernet8023, @exiao, @Feranmi10,
@flobo3, @foxion37, @georgeglessner, @georgex8001, @ghostmfr, @H-Ali13381, @HangGlidersRule, @harryplusplus,
@haru398801, @heathley, @hejuntt1014, @hekaru-agent, @helix4u, @Heltman, @HenkDz, @heyitsaamir, @hharry11,
@hhhonzik, @hhuang91, @HiddenPuppy, @htsh, @iamagenius00, @in-liberty420, @innocarpe, @irispillars, @iRonin,
@isaachuangGMICLOUD, @Ito-69, @j3ffffff, @jackjin1997, @jakubkrcmar, @Jason2031, @JayGwod, @jerome-benoit,
@johnncenae, @Kailigithub, @keiravoss94, @kevin-ho, @knockyai, @konsisumer, @kshitijk4poor, @kunlabs, @l0hde,
@Leihb, @leoneparise, @LeonSGP43, @liizfq, @liuhao1024, @loongzhao, @lsdsjy, @luyao618, @ma-pony, @Magaav,
@MagicRay1217, @math0r-be, @MattMaximo, @maxims-oss, @MaxyMoos, @maymuneth, @mcndjxlefnd, @memosr,
@MestreY0d4-Uninter, @mewwts, @Mirac1eSky, @MorAlekss, @mrhwick, @mrunmayee17, @mssteuer, @Nanako0129,
@nazirulhafiy, @Nerijusas, @Nicecsh, @nicoloboschi, @nightq, @ningfangbin, @octo-patch, @Octopus,
@OutThisLife, @Paperclip, @pein892, @perlowja, @prasadus92, @qike-ms, @qiyin-code, @Readon, @ReginaldasR,
@revaraver, @rfilgueiras, @rmoen, @romanornr, @rugvedS07, @rylena, @samrusani, @Sanjays2402, @sasha-id,
@Satoshi-agi, @scheidti, @scotttrinh, @season179, @SeeYangZhi, @sgaofen, @shamork, @shannonsands, @SHL0MS,
@simbam99, @Societus, @socrates1024, @Sonoyunchu, @sprmn24, @stephenschoettler, @tangyuanjc, @TechPrototyper,
@tekgnosis-net, @ThomassJonax, @tmimmanuel, @tochukwuada, @Tosko4, @Tranquil-Flow, @twozle, @txbxxx,
@UgwujaGeorge, @Versun, @vlwkaos, @voidborne-d, @vominh1919, @Wang-tianhao, @Wangshengyang2004, @web3blind,
@westers, @Wysie, @xandersbell, @xiahu88988, @XieNBi, @xinbenlv, @xnbi, @y0shua1ee, @yatesjalex, @yes999zc,
@yeyitech, @Yoimex, @YueLich, @Yukipukii1, @zhiyanliu, @zicochaos, @Zjianru, @zkl2333, @zons-zhaozhy,
@ztexydt-cqh.
Also: @Siddharth Balyan, @YuShu.
---
**Full Changelog**: [v2026.4.23...v2026.4.30](https://github.com/NousResearch/hermes-agent/compare/v2026.4.23...v2026.4.30)
+144 -6
View File
@@ -13,6 +13,7 @@ from typing import Any, Deque, Optional
import acp
from acp.schema import (
AgentCapabilities,
AgentMessageChunk,
AuthenticateResponse,
AvailableCommand,
AvailableCommandsUpdate,
@@ -30,6 +31,7 @@ from acp.schema import (
McpServerStdio,
ModelInfo,
NewSessionResponse,
PromptCapabilities,
PromptResponse,
ResumeSessionResponse,
SetSessionConfigOptionResponse,
@@ -45,6 +47,7 @@ from acp.schema import (
TextContentBlock,
UnstructuredCommandInput,
Usage,
UserMessageChunk,
)
# AuthMethodAgent was renamed from AuthMethod in agent-client-protocol 0.9.0
@@ -88,17 +91,69 @@ def _extract_text(
| EmbeddedResourceContentBlock
],
) -> str:
"""Extract plain text from ACP content blocks."""
"""Extract plain text from ACP content blocks for display/commands."""
parts: list[str] = []
for block in prompt:
if isinstance(block, TextContentBlock):
parts.append(block.text)
elif hasattr(block, "text"):
parts.append(str(block.text))
# Non-text blocks are ignored for now.
return "\n".join(parts)
def _image_block_to_openai_part(block: ImageContentBlock) -> dict[str, Any] | None:
"""Convert an ACP image content block to OpenAI-style multimodal content."""
data = str(getattr(block, "data", "") or "").strip()
uri = str(getattr(block, "uri", "") or "").strip()
mime_type = str(getattr(block, "mime_type", "") or "image/png").strip() or "image/png"
if data:
url = data if data.startswith("data:") else f"data:{mime_type};base64,{data}"
elif uri:
url = uri
else:
return None
return {"type": "image_url", "image_url": {"url": url}}
def _content_blocks_to_openai_user_content(
prompt: list[
TextContentBlock
| ImageContentBlock
| AudioContentBlock
| ResourceContentBlock
| EmbeddedResourceContentBlock
],
) -> str | list[dict[str, Any]]:
"""Convert ACP prompt blocks into a Hermes/OpenAI-compatible user content payload."""
parts: list[dict[str, Any]] = []
text_parts: list[str] = []
for block in prompt:
if isinstance(block, TextContentBlock):
if block.text:
parts.append({"type": "text", "text": block.text})
text_parts.append(block.text)
continue
if isinstance(block, ImageContentBlock):
image_part = _image_block_to_openai_part(block)
if image_part is not None:
parts.append(image_part)
continue
if not parts:
return _extract_text(prompt)
# Keep pure text prompts as strings so slash-command handling and text-only
# providers keep the exact legacy path. Switch to structured content only
# when an actual non-text block is present.
if all(part.get("type") == "text" for part in parts):
return "\n".join(text_parts)
return parts
class HermesACPAgent(acp.Agent):
"""ACP Agent implementation wrapping Hermes AIAgent."""
@@ -352,6 +407,7 @@ class HermesACPAgent(acp.Agent):
agent_info=Implementation(name="hermes-agent", version=HERMES_VERSION),
agent_capabilities=AgentCapabilities(
load_session=True,
prompt_capabilities=PromptCapabilities(image=True),
session_capabilities=SessionCapabilities(
fork=SessionForkCapabilities(),
list=SessionListCapabilities(),
@@ -377,6 +433,78 @@ class HermesACPAgent(acp.Agent):
# ---- Session management -------------------------------------------------
@staticmethod
def _history_message_text(message: dict[str, Any]) -> str:
"""Extract displayable text from a persisted OpenAI-style message."""
content = message.get("content")
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
text = item.get("text")
if isinstance(text, str):
parts.append(text)
elif item.get("type") == "text" and isinstance(item.get("content"), str):
parts.append(item["content"])
elif isinstance(item, str):
parts.append(item)
return "\n".join(part.strip() for part in parts if part and part.strip()).strip()
return ""
@staticmethod
def _history_message_update(
*,
role: str,
text: str,
) -> UserMessageChunk | AgentMessageChunk | None:
"""Build an ACP history replay update for a user/assistant message."""
block = TextContentBlock(type="text", text=text)
if role == "user":
return UserMessageChunk(
session_update="user_message_chunk",
content=block,
)
if role == "assistant":
return AgentMessageChunk(
session_update="agent_message_chunk",
content=block,
)
return None
async def _replay_session_history(self, state: SessionState) -> None:
"""Send persisted user/assistant history to clients during session/load.
Zed's ACP history UI calls ``session/load`` after the user picks an item
from the Agents sidebar. The agent must then replay the full conversation
as ``user_message_chunk`` / ``agent_message_chunk`` notifications; merely
restoring server-side state makes Hermes remember context, but leaves the
editor looking like a clean thread.
"""
if not self._conn or not state.history:
return
for message in state.history:
role = str(message.get("role") or "")
if role not in {"user", "assistant"}:
continue
text = self._history_message_text(message)
if not text:
continue
update = self._history_message_update(role=role, text=text)
if update is None:
continue
try:
await self._conn.session_update(session_id=state.session_id, update=update)
except Exception:
logger.warning(
"Failed to replay ACP history for session %s",
state.session_id,
exc_info=True,
)
return
async def new_session(
self,
cwd: str,
@@ -405,6 +533,7 @@ class HermesACPAgent(acp.Agent):
return None
await self._register_session_mcp_servers(state, mcp_servers)
logger.info("Loaded session %s", session_id)
await self._replay_session_history(state)
self._schedule_available_commands_update(session_id)
return LoadSessionResponse(models=self._build_model_state(state))
@@ -421,6 +550,7 @@ class HermesACPAgent(acp.Agent):
state = self.session_manager.create_session(cwd=cwd)
await self._register_session_mcp_servers(state, mcp_servers)
logger.info("Resumed session %s", state.session_id)
await self._replay_session_history(state)
self._schedule_available_commands_update(state.session_id)
return ResumeSessionResponse(models=self._build_model_state(state))
@@ -517,11 +647,18 @@ class HermesACPAgent(acp.Agent):
return PromptResponse(stop_reason="refusal")
user_text = _extract_text(prompt).strip()
if not user_text:
user_content = _content_blocks_to_openai_user_content(prompt)
has_content = bool(user_text) or (
isinstance(user_content, list) and bool(user_content)
)
if not has_content:
return PromptResponse(stop_reason="end_turn")
# Intercept slash commands — handle locally without calling the LLM
if user_text.startswith("/"):
# Intercept slash commands — handle locally without calling the LLM.
# Slash commands are text-only; if the client included images/resources,
# send the whole multimodal prompt to the agent instead of treating it as
# an ACP command.
if isinstance(user_content, str) and user_text.startswith("/"):
response_text = self._handle_slash_command(user_text, state)
if response_text is not None:
if self._conn:
@@ -604,9 +741,10 @@ class HermesACPAgent(acp.Agent):
os.environ["HERMES_INTERACTIVE"] = "1"
try:
result = agent.run_conversation(
user_message=user_text,
user_message=user_content,
conversation_history=state.history,
task_id=session_id,
persist_user_message=user_text or "[Image attachment]",
)
return result
except Exception as e:
+37 -4
View File
@@ -461,7 +461,11 @@ def _requires_bearer_auth(base_url: str | None) -> bool:
return normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic"))
def _common_betas_for_base_url(base_url: str | None) -> list[str]:
def _common_betas_for_base_url(
base_url: str | None,
*,
drop_context_1m_beta: bool = False,
) -> list[str]:
"""Return the beta headers that are safe for the configured endpoint.
MiniMax's Anthropic-compatible endpoints (Bearer-auth) reject requests
@@ -472,14 +476,30 @@ def _common_betas_for_base_url(base_url: str | None) -> list[str]:
The ``context-1m-2025-08-07`` beta is also stripped for Bearer-auth
endpoints — MiniMax hosts its own models, not Claude, so the header is
irrelevant at best and risks request rejection at worst.
``drop_context_1m_beta=True`` additionally strips the 1M-context beta on
otherwise-unrelated endpoints. The OAuth retry path flips this flag after
a subscription rejects the beta with
"The long context beta is not yet available for this subscription" so
subsequent requests in the same session don't repeat the probe. See the
reactive recovery loop in ``run_agent.py`` and issue-comment history on
PR #17680 for the full rationale.
"""
if _requires_bearer_auth(base_url):
_stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA}
return [b for b in _COMMON_BETAS if b not in _stripped]
if drop_context_1m_beta:
return [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]
return _COMMON_BETAS
def build_anthropic_client(api_key: str, base_url: str = None, timeout: float = None):
def build_anthropic_client(
api_key: str,
base_url: str = None,
timeout: float = None,
*,
drop_context_1m_beta: bool = False,
):
"""Create an Anthropic client, auto-detecting setup-tokens vs API keys.
If *timeout* is provided it overrides the default 900s read timeout. The
@@ -488,6 +508,12 @@ def build_anthropic_client(api_key: str, base_url: str = None, timeout: float =
Anthropic-compatible providers respect the same knob as OpenAI-wire
providers.
``drop_context_1m_beta=True`` strips ``context-1m-2025-08-07`` from the
client-level ``anthropic-beta`` header. Used by the reactive OAuth retry
path in ``run_agent.py`` when a subscription rejects the beta; leave at
its default on fresh clients so 1M-capable subscriptions keep the
capability.
Returns an anthropic.Anthropic instance.
"""
_anthropic_sdk = _get_anthropic_sdk()
@@ -517,7 +543,10 @@ def build_anthropic_client(api_key: str, base_url: str = None, timeout: float =
kwargs["default_query"] = {"api-version": "2025-04-15"}
else:
kwargs["base_url"] = normalized_base_url
common_betas = _common_betas_for_base_url(normalized_base_url)
common_betas = _common_betas_for_base_url(
normalized_base_url,
drop_context_1m_beta=drop_context_1m_beta,
)
if _is_kimi_coding_endpoint(base_url):
# Kimi's /coding endpoint requires User-Agent: claude-code/0.1.0
@@ -1689,6 +1718,7 @@ def build_anthropic_kwargs(
context_length: Optional[int] = None,
base_url: str | None = None,
fast_mode: bool = False,
drop_context_1m_beta: bool = False,
) -> Dict[str, Any]:
"""Build kwargs for anthropic.messages.create().
@@ -1877,7 +1907,10 @@ def build_anthropic_kwargs(
kwargs.setdefault("extra_body", {})["speed"] = "fast"
# Build extra_headers with ALL applicable betas (the per-request
# extra_headers override the client-level anthropic-beta header).
betas = list(_common_betas_for_base_url(base_url))
betas = list(_common_betas_for_base_url(
base_url,
drop_context_1m_beta=drop_context_1m_beta,
))
if is_oauth:
betas.extend(_OAUTH_ONLY_BETAS)
betas.append(_FAST_MODE_BETA)
+84 -39
View File
@@ -5,11 +5,11 @@ session search, web extraction, vision analysis, browser vision) picks up
the best available backend without duplicating fallback logic.
Resolution order for text tasks (auto mode):
1. OpenRouter (OPENROUTER_API_KEY)
2. Nous Portal (~/.hermes/auth.json active provider)
3. Custom endpoint (config.yaml model.base_url + OPENAI_API_KEY)
4. Codex OAuth (Responses API via chatgpt.com with gpt-5.3-codex,
wrapped to look like a chat.completions client)
1. User's main provider + main model (used regardless of provider type —
aggregators, direct API-key providers, native Anthropic, Codex, etc.)
2. OpenRouter (OPENROUTER_API_KEY)
3. Nous Portal (~/.hermes/auth.json active provider)
4. Custom endpoint (config.yaml model.base_url + OPENAI_API_KEY)
5. Native Anthropic
6. Direct API-key providers (z.ai/GLM, Kimi/Moonshot, MiniMax, MiniMax-CN)
7. None
@@ -18,10 +18,16 @@ Resolution order for vision/multimodal tasks (auto mode):
1. Selected main provider, if it is one of the supported vision backends below
2. OpenRouter
3. Nous Portal
4. Codex OAuth (gpt-5.3-codex supports vision via Responses API)
5. Native Anthropic
6. Custom endpoint (for local vision models: Qwen-VL, LLaVA, Pixtral, etc.)
7. None
4. Native Anthropic
5. Custom endpoint (for local vision models: Qwen-VL, LLaVA, Pixtral, etc.)
6. None
Codex OAuth (ChatGPT-account auth) is intentionally NOT in either
fallback chain: OpenAI gates this endpoint behind an undocumented,
shifting model allow-list, so "just try Codex with a hardcoded model"
rots on its own. Codex is used only when the user's main provider *is*
openai-codex (Step 1 above) or when a caller explicitly requests it with
a model (auxiliary.<task>.provider + auxiliary.<task>.model).
Per-task overrides are configured in config.yaml under the ``auxiliary:`` section
(e.g. ``auxiliary.vision.provider``, ``auxiliary.compression.model``).
@@ -285,12 +291,14 @@ _NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
# Codex fallback: uses the Responses API (the only endpoint the Codex
# OAuth token can access) with a fast model for auxiliary tasks.
# ChatGPT-backed Codex accounts currently reject gpt-5.3-codex for these
# auxiliary flows, while gpt-5.2-codex remains broadly available and supports
# vision via Responses.
_CODEX_AUX_MODEL = "gpt-5.2-codex"
# Codex OAuth endpoint used when a caller explicitly requests
# provider="openai-codex". There is deliberately no hardcoded default
# model: the set of models OpenAI accepts on this endpoint for
# ChatGPT-account auth is an undocumented, shifting allow-list, and
# pinning one here has drifted silently twice (gpt-5.3-codex → gpt-5.2-codex
# → gpt-5.4 over 6 weeks in early 2026). Callers must pass the model
# they want explicitly (from config.yaml model.model, auxiliary.<task>.model,
# or the user's active Codex model selection).
_CODEX_AUX_BASE_URL = "https://chatgpt.com/backend-api/codex"
@@ -347,6 +355,13 @@ def _to_openai_base_url(base_url: str) -> str:
rewritten = url[: -len("/anthropic")] + "/v1"
logger.debug("Auxiliary client: rewrote base URL %s%s", url, rewritten)
return rewritten
if "api.kimi.com" in url and url.endswith("/coding"):
# Kimi Code uses /coding/v1/messages for Anthropic SDK (appends /v1/messages)
# but /coding/v1/chat/completions for OpenAI SDK (appends /chat/completions)
# Without /v1 here, OpenAI SDK hits /coding/chat/completions — a 404.
rewritten = url + "/v1"
logger.debug("Auxiliary client: rewrote Kimi base URL %s%s", url, rewritten)
return rewritten
return url
@@ -1078,9 +1093,8 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
if not api_key:
continue
base_url = _to_openai_base_url(
_pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
)
raw_base_url = _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
base_url = _to_openai_base_url(raw_base_url)
model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
if model is None:
continue # skip provider if we don't know a valid aux model
@@ -1098,7 +1112,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
extra["default_headers"] = copilot_default_headers()
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, base_url)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
return _client, model
creds = resolve_api_key_provider_credentials(provider_id)
@@ -1106,9 +1120,8 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
if not api_key:
continue
base_url = _to_openai_base_url(
str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
)
raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
base_url = _to_openai_base_url(raw_base_url)
model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
if model is None:
continue # skip provider if we don't know a valid aux model
@@ -1126,7 +1139,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
extra["default_headers"] = copilot_default_headers()
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, base_url)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
return _client, model
return None, None
@@ -1420,7 +1433,23 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
return _fallback_client, model
def _try_codex() -> Tuple[Optional[Any], Optional[str]]:
def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]:
"""Build a CodexAuxiliaryClient for an explicitly-requested model.
There is no auto-selection of the Codex model: the ChatGPT-account
Codex endpoint's accepted model list is an undocumented, drifting
allow-list, so any hardcoded default we pick goes stale. The caller
is responsible for passing the model (e.g. from the user's own
``model.model`` or ``auxiliary.<task>.model`` config).
Returns (None, None) when no Codex OAuth token is available.
"""
if not model:
logger.warning(
"Auxiliary client: openai-codex requested without a model; "
"pass model explicitly (auxiliary.<task>.model in config.yaml)."
)
return None, None
pool_present, entry = _select_pool_entry("openai-codex")
if pool_present:
codex_token = _pool_runtime_api_key(entry)
@@ -1436,13 +1465,13 @@ def _try_codex() -> Tuple[Optional[Any], Optional[str]]:
if not codex_token:
return None, None
base_url = _CODEX_AUX_BASE_URL
logger.debug("Auxiliary client: Codex OAuth (%s via Responses API)", _CODEX_AUX_MODEL)
logger.debug("Auxiliary client: Codex OAuth (%s via Responses API)", model)
real_client = OpenAI(
api_key=codex_token,
base_url=base_url,
default_headers=_codex_cloudflare_headers(codex_token),
)
return CodexAuxiliaryClient(real_client, _CODEX_AUX_MODEL), _CODEX_AUX_MODEL
return CodexAuxiliaryClient(real_client, model), model
def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]:
@@ -1497,7 +1526,6 @@ _AUTO_PROVIDER_LABELS = {
"_try_openrouter": "openrouter",
"_try_nous": "nous",
"_try_custom_endpoint": "local/custom",
"_try_codex": "openai-codex",
"_resolve_api_key_provider": "api-key",
}
@@ -1524,12 +1552,18 @@ def _get_provider_chain() -> List[tuple]:
Built at call time (not module level) so that test patches
on the ``_try_*`` functions are picked up correctly.
NOTE: ``openai-codex`` is deliberately NOT in this chain. The
ChatGPT-account Codex endpoint only accepts a shifting, undocumented
allow-list of model IDs, so falling back to it with a guessed model
fails more often than not. Codex is used only when the user's main
provider *is* openai-codex (see Step 1 of ``_resolve_auto``) or when
a caller explicitly requests it with a model.
"""
return [
("openrouter", _try_openrouter),
("nous", _try_nous),
("local/custom", _try_custom_endpoint),
("openai-codex", _try_codex),
("api-key", _resolve_api_key_provider),
]
@@ -2045,6 +2079,13 @@ def resolve_provider_client(
# ── OpenAI Codex (OAuth → Responses API) ─────────────────────────
if provider == "openai-codex":
if not model:
logger.warning(
"resolve_provider_client: openai-codex requested without a "
"model; pass model explicitly (e.g. model.model in config.yaml "
"or auxiliary.<task>.model for per-task aux routing)."
)
return None, None
if raw_codex:
# Return the raw OpenAI client for callers that need direct
# access to responses.stream() (e.g., the main agent loop).
@@ -2053,7 +2094,7 @@ def resolve_provider_client(
logger.warning("resolve_provider_client: openai-codex requested "
"but no Codex OAuth token found (run: hermes model)")
return None, None
final_model = _normalize_resolved_model(model or _CODEX_AUX_MODEL, provider)
final_model = _normalize_resolved_model(model, provider)
raw_client = OpenAI(
api_key=codex_token,
base_url=_CODEX_AUX_BASE_URL,
@@ -2061,7 +2102,7 @@ def resolve_provider_client(
)
return (raw_client, final_model)
# Standard path: wrap in CodexAuxiliaryClient adapter
client, default = _try_codex()
client, default = _build_codex_client(model)
if client is None:
logger.warning("resolve_provider_client: openai-codex requested "
"but no Codex OAuth token found (run: hermes model)")
@@ -2104,9 +2145,9 @@ def resolve_provider_client(
client = _wrap_if_needed(client, final_model, custom_base, custom_key)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
# Try custom first, then codex, then API-key providers
for try_fn in (_try_custom_endpoint, _try_codex,
_resolve_api_key_provider):
# Try custom first, then API-key providers (Codex excluded here:
# falling through to Codex with no model is a stale-constant trap).
for try_fn in (_try_custom_endpoint, _resolve_api_key_provider):
client, default = try_fn()
if client is not None:
final_model = _normalize_resolved_model(model or default, provider)
@@ -2148,8 +2189,10 @@ def resolve_provider_client(
# Anthropic fallback SDK still sees the original URL.
if entry_api_mode == "anthropic_messages":
openai_base = custom_base
raw_base_for_wrap = custom_base
else:
openai_base = _to_openai_base_url(custom_base)
raw_base_for_wrap = custom_base
_clean_base2, _dq2 = _extract_url_query_params(openai_base)
_extra2 = {"default_query": _dq2} if _dq2 else {}
logger.debug(
@@ -2193,7 +2236,7 @@ def resolve_provider_client(
):
client = CodexAuxiliaryClient(client, final_model)
else:
client = _wrap_if_needed(client, final_model, openai_base, custom_key)
client = _wrap_if_needed(client, final_model, raw_base_for_wrap, custom_key)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
logger.warning(
@@ -2239,9 +2282,8 @@ def resolve_provider_client(
provider, ", ".join(tried_sources))
return None, None
base_url = _to_openai_base_url(
str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
)
raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
base_url = _to_openai_base_url(raw_base_url)
default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "")
final_model = _normalize_resolved_model(model or default_model, provider)
@@ -2290,7 +2332,7 @@ def resolve_provider_client(
# Anthropic-wire endpoints (Kimi Coding Plan api.kimi.com/coding,
# /anthropic-suffixed gateways) so named providers like kimi-coding
# land on the right transport without needing per-provider branches.
client = _wrap_if_needed(client, final_model, base_url, api_key)
client = _wrap_if_needed(client, final_model, raw_base_url, api_key)
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
@@ -2453,7 +2495,10 @@ def _resolve_strict_vision_backend(
if provider == "nous":
return _try_nous(vision=True)
if provider == "openai-codex":
return _try_codex()
# Route through resolve_provider_client so the caller's explicit
# model is used. There is no safe default Codex model (shifting
# allow-list); callers must specify via auxiliary.<task>.model.
return resolve_provider_client("openai-codex", model, is_vision=True)
if provider == "anthropic":
return _try_anthropic()
if provider == "custom":
+1 -1
View File
@@ -608,7 +608,7 @@ class CopilotACPClient:
end = start + limit if isinstance(limit, int) and limit > 0 else None
content = "".join(lines[start:end])
if content:
content = redact_sensitive_text(content)
content = redact_sensitive_text(content, force=True)
response = {
"jsonrpc": "2.0",
"id": message_id,
+475 -29
View File
@@ -7,7 +7,7 @@ daemon): when the agent is idle and the last curator run was longer than
the review.
Responsibilities:
- Auto-transition lifecycle states based on last_used_at timestamps
- Auto-transition lifecycle states based on derived skill activity timestamps
- Spawn a background review agent that can pin / archive / consolidate /
patch agent-created skills via skill_manage
- Persist curator state (last_run_at, paused, etc.) in .curator_state
@@ -213,8 +213,8 @@ def should_run_now(now: Optional[datetime] = None) -> bool:
def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int]:
"""Walk every agent-created skill and move active/stale/archived based on
last_used_at. Pinned skills are never touched. Returns a counter dict
describing what changed."""
the latest real activity timestamp. Pinned skills are never touched.
Returns a counter dict describing what changed."""
from tools import skill_usage as _u
if now is None:
@@ -230,10 +230,10 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
if row.get("pinned"):
continue
last_used = _parse_iso(row.get("last_used_at"))
# If never used, treat as using created_at as the anchor so new skills
# don't immediately archive themselves.
anchor = last_used or _parse_iso(row.get("created_at")) or now
last_activity = _parse_iso(row.get("last_activity_at"))
# If never active, treat created_at as the anchor so new skills don't
# immediately archive themselves.
anchor = last_activity or _parse_iso(row.get("created_at")) or now
if anchor.tzinfo is None:
anchor = anchor.replace(tzinfo=timezone.utc)
@@ -347,10 +347,27 @@ CURATOR_REVIEW_PROMPT = (
"cluster. If you end the pass with fewer than 10 archives, you "
"stopped too early — go back and look at the clusters you left "
"alone.\n\n"
"When done, write a summary with: clusters processed, skills "
"patched/absorbed, skills demoted to references/templates/scripts, "
"skills archived, new umbrellas created, and clusters you "
"deliberately left alone with one line each."
"When done, write a human summary AND a structured machine-readable "
"block so downstream tooling can distinguish consolidation from "
"pruning. Format EXACTLY:\n\n"
"## Structured summary (required)\n"
"```yaml\n"
"consolidations:\n"
" - from: <old-skill-name>\n"
" into: <umbrella-skill-name>\n"
" reason: <one short sentence — why merged, not just 'similar'>\n"
"prunings:\n"
" - name: <skill-name>\n"
" reason: <one short sentence — why archived with no merge target>\n"
"```\n\n"
"Every skill you moved to .archive/ MUST appear in exactly one of the "
"two lists. If you consolidated X into umbrella Y (patched Y, wrote "
"a references file to Y, or created Y with X's content absorbed), X "
"goes under `consolidations` with `into: Y`. If you archived X with "
"no absorption — truly stale, irrelevant, or obsolete — X goes under "
"`prunings`. Leave a list empty (`consolidations: []`) if none. Do "
"not omit the block. The block comes AFTER your human-readable "
"summary of clusters processed, patches made, and decisions left alone."
)
@@ -365,8 +382,300 @@ def _reports_root() -> Path:
alongside ``agent.log`` and ``gateway.log`` so it's found by anyone
looking for operational telemetry, not mixed in with the user's
authored skill data in ``~/.hermes/skills/``.
``ensure_hermes_home()`` pre-creates this dir on every CLI launch and
the v22→v23 migration backfills it for existing profiles, but we
still mkdir here as a belt-and-suspenders so the curator works even
from an odd entry path (e.g. gateway-only install, bare library use)
that bypasses both.
"""
return get_hermes_home() / "logs" / "curator"
root = get_hermes_home() / "logs" / "curator"
try:
root.mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.debug("Curator reports dir create failed: %s", e)
return root
def _classify_removed_skills(
removed: List[str],
added: List[str],
after_names: Set[str],
tool_calls: List[Dict[str, Any]],
) -> Dict[str, List[Dict[str, Any]]]:
"""Split ``removed`` into consolidated vs pruned.
A removed skill is "consolidated" when the curator absorbed its content
into another skill (an umbrella) during this run — the content still
lives, just under a different name. A removed skill is "pruned" when the
curator archived it for staleness/irrelevance without preserving its
content elsewhere.
Heuristic: scan this run's ``skill_manage`` tool calls and look for
``write_file``/``patch``/``create``/``edit`` actions whose target skill
(the ``name`` argument) is NOT the removed skill and whose
``file_path`` / ``file_content`` / ``content`` arguments reference the
removed skill's name. That's the textbook "absorbed into umbrella"
signal. Ties are broken by first-match (earliest tool call wins).
Returns ``{"consolidated": [{"name", "into", "evidence"}, ...],
"pruned": [{"name"}, ...]}``.
"""
consolidated: List[Dict[str, Any]] = []
pruned: List[Dict[str, Any]] = []
# Pre-parse tool calls: we only care about skill_manage.
parsed_calls: List[Dict[str, Any]] = []
for tc in tool_calls or []:
if not isinstance(tc, dict):
continue
if tc.get("name") != "skill_manage":
continue
raw = tc.get("arguments") or ""
# Arguments can be a JSON string (standard) or a dict (defensive).
args: Dict[str, Any] = {}
if isinstance(raw, dict):
args = raw
elif isinstance(raw, str):
try:
args = json.loads(raw)
except Exception:
# Truncated or malformed — fall back to substring match on
# the raw string so we still catch the common case.
args = {"_raw": raw}
if not isinstance(args, dict):
continue
parsed_calls.append(args)
# Build a set of "destination" skill names: anything still present after
# the run plus anything newly added this run. A removed skill being
# referenced from one of these is the consolidation signal.
destinations = set(after_names) | set(added or [])
for name in removed:
if not name:
continue
into: Optional[str] = None
evidence: Optional[str] = None
# Normalise name variants we'll search for in path/content strings.
needles = {name, name.replace("-", "_"), name.replace("_", "-")}
for args in parsed_calls:
target = args.get("name")
if not isinstance(target, str) or not target:
continue
# A call that operates on the removed skill itself isn't
# consolidation evidence.
if target == name:
continue
# The target must be a surviving or newly-created skill —
# otherwise we're pointing to a skill that doesn't exist.
if target not in destinations:
continue
# Look for the removed skill's name in file_path / content / raw.
haystacks: List[str] = []
for key in ("file_path", "file_content", "content", "new_string", "_raw"):
v = args.get(key)
if isinstance(v, str):
haystacks.append(v)
hit = False
for hay in haystacks:
for needle in needles:
if needle and needle in hay:
hit = True
evidence = (
f"skill_manage action={args.get('action', '?')} "
f"on '{target}' referenced '{name}' "
f"in {hay[:80]}"
)
break
if hit:
break
if hit:
into = target
break
if into:
consolidated.append({"name": name, "into": into, "evidence": evidence})
else:
pruned.append({"name": name})
return {"consolidated": consolidated, "pruned": pruned}
def _parse_structured_summary(
llm_final: str,
) -> Dict[str, List[Dict[str, str]]]:
"""Extract the structured YAML block from the curator's final response.
The curator prompt requires a fenced ```yaml block under
``## Structured summary (required)`` with ``consolidations:`` and
``prunings:`` lists. This parses it tolerantly:
- Missing block → returns empty lists (we'll fall back to heuristic).
- Malformed YAML → returns empty lists and we rely on heuristic.
- Partial block (e.g. only consolidations) → returns what we could parse.
Returns ``{"consolidations": [{"from", "into", "reason"}, ...],
"prunings": [{"name", "reason"}, ...]}``.
"""
empty = {"consolidations": [], "prunings": []}
if not llm_final or not isinstance(llm_final, str):
return empty
# Find the YAML fenced block. We look for ```yaml ... ``` specifically
# rather than any fenced block so we don't accidentally pick up a code
# sample the model quoted elsewhere.
import re
match = re.search(
r"```ya?ml\s*\n(.*?)\n```",
llm_final,
re.DOTALL | re.IGNORECASE,
)
if not match:
return empty
body = match.group(1)
# Prefer PyYAML when available — every hermes install already has it
# (config.yaml loader). Fall back to a hand parser for paranoia.
try:
import yaml # type: ignore
data = yaml.safe_load(body)
except Exception:
return empty
if not isinstance(data, dict):
return empty
out: Dict[str, List[Dict[str, str]]] = {"consolidations": [], "prunings": []}
cons_raw = data.get("consolidations") or []
prun_raw = data.get("prunings") or []
if isinstance(cons_raw, list):
for entry in cons_raw:
if not isinstance(entry, dict):
continue
frm = entry.get("from")
into = entry.get("into")
if not (isinstance(frm, str) and frm.strip()
and isinstance(into, str) and into.strip()):
continue
reason = entry.get("reason")
out["consolidations"].append({
"from": frm.strip(),
"into": into.strip(),
"reason": (reason or "").strip() if isinstance(reason, str) else "",
})
if isinstance(prun_raw, list):
for entry in prun_raw:
if not isinstance(entry, dict):
continue
name = entry.get("name")
if not (isinstance(name, str) and name.strip()):
continue
reason = entry.get("reason")
out["prunings"].append({
"name": name.strip(),
"reason": (reason or "").strip() if isinstance(reason, str) else "",
})
return out
def _reconcile_classification(
removed: List[str],
heuristic: Dict[str, List[Dict[str, Any]]],
model_block: Dict[str, List[Dict[str, str]]],
destinations: Set[str],
) -> Dict[str, List[Dict[str, Any]]]:
"""Merge heuristic (tool-call evidence) with the model's structured block.
Rules:
- Model-declared consolidation wins when its ``into`` target exists
in ``destinations`` (survived or newly-created). This gives the
model authority over intent + rationale.
- Model-declared consolidation whose ``into`` target does NOT exist is
downgraded: the model hallucinated an umbrella. We prefer the
heuristic's finding for that skill, or fall back to pruned.
- Heuristic-only finding (model didn't mention it, tool calls confirm)
is preserved as a consolidation, marked ``source="tool-call audit"``.
- Model-declared pruning is accepted unless the heuristic has
tool-call evidence that contradicts it (rare — the heuristic would
have flagged consolidation). In that case we log both.
Every removed skill is placed in exactly one bucket.
"""
heur_cons = {e["name"]: e for e in heuristic.get("consolidated", [])}
heur_pruned = {e["name"] for e in heuristic.get("pruned", [])}
model_cons = {e["from"]: e for e in model_block.get("consolidations", [])}
model_pruned = {e["name"]: e for e in model_block.get("prunings", [])}
consolidated: List[Dict[str, Any]] = []
pruned: List[Dict[str, Any]] = []
for name in removed:
mc = model_cons.get(name)
mp = model_pruned.get(name)
hc = heur_cons.get(name)
# Model says consolidated — trust it if the destination is real.
if mc and mc.get("into") in destinations:
entry: Dict[str, Any] = {
"name": name,
"into": mc["into"],
"source": "model" + ("+audit" if hc else ""),
"reason": mc.get("reason") or "",
}
if hc and hc.get("evidence"):
entry["evidence"] = hc["evidence"]
consolidated.append(entry)
continue
# Model says consolidated but the umbrella doesn't exist —
# hallucination. Fall back to heuristic or prune.
if mc and mc.get("into") not in destinations:
if hc:
consolidated.append({
"name": name,
"into": hc["into"],
"source": "tool-call audit (model named missing umbrella)",
"reason": "",
"evidence": hc.get("evidence", ""),
"model_claimed_into": mc["into"],
})
else:
pruned.append({
"name": name,
"source": "fallback (model named missing umbrella, no tool-call evidence)",
"reason": "",
})
continue
# Heuristic found consolidation the model didn't mention.
if hc:
consolidated.append({
"name": name,
"into": hc["into"],
"source": "tool-call audit (model omitted from structured block)",
"reason": "",
"evidence": hc.get("evidence", ""),
})
continue
# Model says pruned (or no mention + no heuristic evidence).
reason = mp.get("reason", "") if mp else ""
pruned.append({
"name": name,
"source": "model" if mp else "no-evidence fallback",
"reason": reason,
})
return {"consolidated": consolidated, "pruned": pruned}
def _write_run_report(
@@ -426,6 +735,37 @@ def _write_run_report(
name = tc.get("name", "unknown")
tc_counts[name] = tc_counts.get(name, 0) + 1
# Split "removed" into consolidated (absorbed into umbrella) vs pruned
# (archived for staleness, content not preserved elsewhere). The old
# "Skills archived" section lumped both together, which misled users
# into thinking consolidated skills had been pruned.
#
# Classification strategy:
# 1. Parse the curator's structured YAML block from its final response.
# The curator is now prompted to emit consolidations/prunings lists
# with short rationale. The model has intent visibility the tool
# calls don't.
# 2. Run the tool-call heuristic as a ground-truth audit.
# 3. Reconcile: model gets authority over intent + rationale, heuristic
# catches hallucination (umbrella doesn't exist) and omission
# (model forgot to list an actual consolidation).
heuristic = _classify_removed_skills(
removed=removed,
added=added,
after_names=after_names,
tool_calls=llm_meta.get("tool_calls", []) or [],
)
model_block = _parse_structured_summary(llm_meta.get("final", "") or "")
destinations = set(after_names) | set(added or [])
classification = _reconcile_classification(
removed=removed,
heuristic=heuristic,
model_block=model_block,
destinations=destinations,
)
consolidated = classification["consolidated"]
pruned = classification["pruned"]
payload = {
"started_at": started_at.isoformat(),
"duration_seconds": round(elapsed_seconds, 2),
@@ -438,11 +778,16 @@ def _write_run_report(
"delta": len(after_names) - len(before_names),
"archived_this_run": len(removed),
"added_this_run": len(added),
"consolidated_this_run": len(consolidated),
"pruned_this_run": len(pruned),
"state_transitions": len(transitions),
"tool_calls_total": sum(tc_counts.values()),
},
"tool_call_counts": tc_counts,
"archived": removed,
"consolidated": consolidated,
"pruned": pruned,
"pruned_names": [p["name"] for p in pruned],
"added": added,
"state_transitions": transitions,
"llm_final": llm_meta.get("final", ""),
@@ -497,7 +842,7 @@ def _render_report_markdown(p: Dict[str, Any]) -> str:
lines.append("## Auto-transitions (pure, no LLM)\n")
lines.append(f"- checked: {auto.get('checked', 0)}")
lines.append(f"- marked stale: {auto.get('marked_stale', 0)}")
lines.append(f"- archived: {auto.get('archived', 0)}")
lines.append(f"- archived (no LLM, pure time-based staleness): {auto.get('archived', 0)}")
lines.append(f"- reactivated: {auto.get('reactivated', 0)}")
lines.append("")
@@ -506,24 +851,78 @@ def _render_report_markdown(p: Dict[str, Any]) -> str:
lines.append("## LLM consolidation pass\n")
lines.append(f"- tool calls: **{counts.get('tool_calls_total', 0)}** "
f"(by name: {', '.join(f'{k}={v}' for k, v in sorted(tc_counts.items())) or 'none'})")
lines.append(f"- archived this run: **{counts.get('archived_this_run', 0)}**")
lines.append(f"- consolidated into umbrellas: **{counts.get('consolidated_this_run', 0)}**")
lines.append(f"- pruned (archived for staleness): **{counts.get('pruned_this_run', 0)}**")
lines.append(f"- new skills this run: **{counts.get('added_this_run', 0)}**")
lines.append(f"- state transitions (active ↔ stale ↔ archived): "
f"**{counts.get('state_transitions', 0)}**")
lines.append("")
# Archived list
archived = p.get("archived") or []
if archived:
lines.append(f"### Skills archived ({len(archived)})\n")
lines.append("_Archived skills are at `~/.hermes/skills/.archive/`. "
"Restore any via `hermes curator restore <name>`._\n")
# Show first 50 inline, note truncation after that
# Consolidated list — content absorbed into an umbrella. The directory
# on disk still lives under ~/.hermes/skills/.archive/ (every removal is
# recoverable by design), but the "live" content for these skills
# continues to exist inside the destination umbrella.
consolidated = p.get("consolidated") or []
if consolidated:
lines.append(f"### Consolidated into umbrella skills ({len(consolidated)})\n")
lines.append(
"_These skills were **absorbed into another skill** during this run — "
"their content still lives, just under a different name. "
"The original directory was moved to `~/.hermes/skills/.archive/` for "
"safety and can be restored via `hermes curator restore <name>` if the "
"consolidation was wrong._\n"
)
SHOW = 50
for n in archived[:SHOW]:
lines.append(f"- `{n}`")
if len(archived) > SHOW:
lines.append(f"- … and {len(archived) - SHOW} more (see `run.json` for the full list)")
for entry in consolidated[:SHOW]:
name = entry.get("name", "?")
into = entry.get("into", "?")
reason = (entry.get("reason") or "").strip()
source = entry.get("source", "")
line = f"- `{name}` → merged into `{into}`"
if reason:
line += f"{reason}"
if source and source.startswith("tool-call audit"):
# The model didn't enumerate this one — surface that to the
# user so they know why the row has no rationale.
line += f" _(detected via {source})_"
lines.append(line)
if entry.get("model_claimed_into"):
lines.append(
f" ⚠ The curator's summary named `{entry['model_claimed_into']}` "
"as the umbrella but that skill doesn't exist post-run; "
"showing the tool-call audit's finding instead."
)
if len(consolidated) > SHOW:
lines.append(f"- … and {len(consolidated) - SHOW} more (see `run.json`)")
lines.append("")
# Pruned list — archived without consolidation. These are the
# "stale skill pruned" cases the UI should mark clearly.
pruned = p.get("pruned") or []
if pruned:
lines.append(f"### Pruned — archived for staleness ({len(pruned)})\n")
lines.append(
"_These skills were archived without being merged into an umbrella "
"(e.g. stale, unused, or judged irrelevant). "
"Directories live under `~/.hermes/skills/.archive/`. "
"Restore any via `hermes curator restore <name>`._\n"
)
SHOW = 50
for entry in pruned[:SHOW]:
# Entries are dicts with {name, source, reason} when written via
# the reconciler, or bare strings when an older format slipped
# through. Handle both.
if isinstance(entry, dict):
name = entry.get("name", "?")
reason = (entry.get("reason") or "").strip()
line = f"- `{name}`"
if reason:
line += f"{reason}"
lines.append(line)
else:
lines.append(f"- `{entry}`")
if len(pruned) > SHOW:
lines.append(f"- … and {len(pruned) - SHOW} more (see `run.json`)")
lines.append("")
# Added list
@@ -581,10 +980,11 @@ def _render_candidate_list() -> str:
f"- {r['name']} "
f"state={r['state']} "
f"pinned={'yes' if r.get('pinned') else 'no'} "
f"activity={r.get('activity_count', 0)} "
f"use={r.get('use_count', 0)} "
f"view={r.get('view_count', 0)} "
f"patches={r.get('patch_count', 0)} "
f"last_used={r.get('last_used_at') or 'never'}"
f"last_activity={r.get('last_activity_at') or 'never'}"
)
return "\n".join(lines)
@@ -714,6 +1114,49 @@ def run_curator_review(
}
def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]:
"""Pick (provider, model) for the curator review fork.
Curator is a regular auxiliary task slot — ``auxiliary.curator.{provider,model}``
— so it participates in the canonical aux-model plumbing (``hermes model`` →
auxiliary picker, the dashboard Models tab, ``auxiliary.curator.{timeout,
base_url,api_key,extra_body}``). ``provider: "auto"`` with an empty model
means "use the main chat model" — same default as every other aux task.
Legacy fallback: users who configured ``curator.auxiliary.{provider,model}``
under the previous one-off schema still work. Precedence:
1. ``auxiliary.curator.{provider,model}`` when both are set non-auto
2. Legacy ``curator.auxiliary.{provider,model}`` when both are set
3. Main ``model.{provider,default/model}`` pair
"""
_main = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {}
_main_provider = _main.get("provider") or "auto"
_main_model = _main.get("default") or _main.get("model") or ""
# 1. Canonical aux task slot
_aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
_cur_task = _aux.get("curator", {}) if isinstance(_aux.get("curator"), dict) else {}
_task_provider = (_cur_task.get("provider") or "").strip() or None
_task_model = (_cur_task.get("model") or "").strip() or None
if _task_provider and _task_provider != "auto" and _task_model:
return _task_provider, _task_model
# 2. Legacy curator.auxiliary.{provider,model} (deprecated, pre-unification)
_cur = cfg.get("curator", {}) if isinstance(cfg.get("curator"), dict) else {}
_legacy = _cur.get("auxiliary", {}) if isinstance(_cur.get("auxiliary"), dict) else {}
_legacy_provider = _legacy.get("provider") or None
_legacy_model = _legacy.get("model") or None
if _legacy_provider and _legacy_model:
logger.info(
"curator: using deprecated curator.auxiliary.{provider,model} "
"config — please migrate to auxiliary.curator.{provider,model}"
)
return _legacy_provider, _legacy_model
# 3. Fall through to the main chat model
return _main_provider, _main_model
def _run_llm_review(prompt: str) -> Dict[str, Any]:
"""Spawn an AIAgent fork to run the curator review prompt.
@@ -749,6 +1192,11 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
# "No models provided"). AIAgent() without explicit provider/model
# arguments hits an auto-resolution path that fails for OAuth-only
# providers and for pool-backed credentials.
#
# `_resolve_review_model()` honors `auxiliary.curator.{provider,model}`
# (canonical aux-task slot, wired through `hermes model` → auxiliary
# picker and the dashboard Models tab), with a legacy fallback to
# `curator.auxiliary.{provider,model}`. See docs/user-guide/features/curator.md.
_api_key = None
_base_url = None
_api_mode = None
@@ -758,9 +1206,7 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
from hermes_cli.config import load_config
from hermes_cli.runtime_provider import resolve_runtime_provider
_cfg = load_config()
_m = _cfg.get("model", {}) if isinstance(_cfg.get("model"), dict) else {}
_provider = _m.get("provider") or "auto"
_model_name = _m.get("default") or _m.get("model") or ""
_provider, _model_name = _resolve_review_model(_cfg)
_rp = resolve_runtime_provider(
requested=_provider, target_model=_model_name
)
+20
View File
@@ -54,6 +54,7 @@ class FailoverReason(enum.Enum):
# Provider-specific
thinking_signature = "thinking_signature" # Anthropic thinking block sig invalid
long_context_tier = "long_context_tier" # Anthropic "extra usage" tier gate
oauth_long_context_beta_forbidden = "oauth_long_context_beta_forbidden" # Anthropic OAuth subscription rejects 1M context beta — disable beta and retry
# Catch-all
unknown = "unknown" # Unclassifiable — retry with backoff
@@ -450,6 +451,25 @@ def classify_api_error(
should_compress=True,
)
# Anthropic OAuth subscription rejects the 1M-context beta header.
# Observed error body: "The long context beta is not yet available for
# this subscription." Returned as HTTP 400 from native Anthropic when
# the subscription doesn't include 1M context, even though the request
# carries ``anthropic-beta: context-1m-2025-08-07``. The recovery path
# in run_agent.py rebuilds the Anthropic client with the beta stripped
# and retries once. Pattern is narrow enough that it won't collide with
# the 429 tier-gate pattern above (different status, different phrase).
if (
status_code == 400
and "long context beta" in error_msg
and "not yet available" in error_msg
):
return _result(
FailoverReason.oauth_long_context_beta_forbidden,
retryable=True,
should_compress=False,
)
# ── 2. HTTP status code classification ──────────────────────────
if status_code is not None:
+2 -2
View File
@@ -1247,7 +1247,7 @@ def get_model_context_length(
6. Nous suffix-match via OpenRouter cache
7. models.dev registry lookup (provider-aware)
8. Thin hardcoded defaults (broad family patterns)
9. Default fallback (128K)
9. Default fallback (256K)
"""
# 0. Explicit config override — user knows best
if config_context_length is not None and isinstance(config_context_length, int) and config_context_length > 0:
@@ -1427,7 +1427,7 @@ def get_model_context_length(
save_context_length(model, base_url, local_ctx)
return local_ctx
# 10. Default fallback — 128K
# 10. Default fallback — 256K
return DEFAULT_FALLBACK_CONTEXT
+58
View File
@@ -182,6 +182,64 @@ SKILLS_GUIDANCE = (
"Skills that aren't maintained become liabilities."
)
KANBAN_GUIDANCE = (
"# You are a Kanban worker\n"
"You were spawned by the Hermes Kanban dispatcher to execute ONE task from "
"the shared board at `~/.hermes/kanban.db`. Your task id is in "
"`$HERMES_KANBAN_TASK`; your workspace is `$HERMES_KANBAN_WORKSPACE`. "
"The `kanban_*` tools in your schema are your primary coordination surface — "
"they write directly to the shared SQLite DB and work regardless of terminal "
"backend (local/docker/modal/ssh).\n"
"\n"
"## Lifecycle\n"
"\n"
"1. **Orient.** Call `kanban_show()` first (no args — it defaults to your "
"task). The response includes title, body, parent-task handoffs (summary + "
"metadata), any prior attempts on this task if you're a retry, the full "
"comment thread, and a pre-formatted `worker_context` you can treat as "
"ground truth.\n"
"2. **Work inside the workspace.** `cd $HERMES_KANBAN_WORKSPACE` before "
"any file operations. The workspace is yours for this run. Don't modify "
"files outside it unless the task explicitly asks.\n"
"3. **Heartbeat on long operations.** Call `kanban_heartbeat(note=...)` "
"every few minutes during long subprocesses (training, encoding, crawling). "
"Skip heartbeats for short tasks.\n"
"4. **Block on genuine ambiguity.** If you need a human decision you cannot "
"infer (missing credentials, UX choice, paywalled source, peer output you "
"need first), call `kanban_block(reason=\"...\")` and stop. Don't guess. "
"The user will unblock with context and the dispatcher will respawn you.\n"
"5. **Complete with structured handoff.** Call `kanban_complete(summary=..., "
"metadata=...)`. `summary` is 13 human-readable sentences naming concrete "
"artifacts. `metadata` is machine-readable facts "
"(`{changed_files: [...], tests_run: N, decisions: [...]}`). Downstream "
"workers read both via their own `kanban_show`. Never put secrets / "
"tokens / raw PII in either field — run rows are durable forever.\n"
"6. **If follow-up work appears, create it; don't do it.** Use "
"`kanban_create(title=..., assignee=<right-profile>, parents=[your-task-id])` "
"to spawn a child task for the appropriate specialist profile instead of "
"scope-creeping into the next thing.\n"
"\n"
"## Orchestrator mode\n"
"\n"
"If your task is itself a decomposition task (e.g. a planner profile given "
"a high-level goal), use `kanban_create` to fan out into child tasks — one "
"per specialist, each with an explicit `assignee` and `parents=[...]` to "
"express dependencies. Then `kanban_complete` your own task with a summary "
"of the decomposition. Do NOT execute the work yourself; your job is "
"routing, not implementation.\n"
"\n"
"## Do NOT\n"
"\n"
"- Do not shell out to `hermes kanban <verb>` for board operations. Use "
"the `kanban_*` tools — they work across all terminal backends.\n"
"- Do not complete a task you didn't actually finish. Block it.\n"
"- Do not assign follow-up work to yourself. Assign it to the right "
"specialist profile.\n"
"- Do not call `delegate_task` as a board substitute. `delegate_task` is "
"for short reasoning subtasks inside your own run; board tasks are for "
"cross-agent handoffs that outlive one API loop."
)
TOOL_USE_ENFORCEMENT_GUIDANCE = (
"# Tool-use enforcement\n"
"You MUST use your tools to take action — do not describe what you would do "
+4 -2
View File
@@ -305,11 +305,13 @@ def _redact_form_body(text: str) -> str:
return _redact_query_string(text.strip())
def redact_sensitive_text(text: str) -> str:
def redact_sensitive_text(text: str, *, force: bool = False) -> str:
"""Apply all redaction patterns to a block of text.
Safe to call on any string -- non-matching text passes through unchanged.
Disabled by default — enable via security.redact_secrets: true in config.yaml.
Set force=True for safety boundaries that must never return raw secrets
regardless of the user's global logging redaction preference.
"""
if text is None:
return None
@@ -317,7 +319,7 @@ def redact_sensitive_text(text: str) -> str:
text = str(text)
if not text:
return text
if not _REDACT_ENABLED:
if not (force or _REDACT_ENABLED):
return text
# Known prefixes (sk-, ghp_, etc.)
+82 -1
View File
@@ -234,7 +234,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
for scan_dir in dirs_to_scan:
for skill_md in iter_skill_index_files(scan_dir, "SKILL.md"):
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
if any(part in ('.git', '.github', '.hub', '.archive') for part in skill_md.parts):
continue
try:
content = skill_md.read_text(encoding='utf-8')
@@ -284,6 +284,71 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]:
return _skill_commands
def reload_skills() -> Dict[str, Any]:
"""Re-scan the skills directory and return a diff of what changed.
Rescans ``~/.hermes/skills/`` and any ``skills.external_dirs`` so the
slash-command map (``agent.skill_commands._skill_commands``) reflects
skills added or removed on disk.
This does NOT invalidate the skills system-prompt cache. Skills are
called by name via ``/skill-name``, ``skills_list``, or ``skill_view``
they don't need to be in the system prompt for the model to use them.
Keeping the prompt cache intact preserves prefix caching across the
reload, so a user invoking ``/reload-skills`` pays no cache-reset cost.
Returns:
Dict with keys::
{
"added": [{"name": str, "description": str}, ...],
"removed": [{"name": str, "description": str}, ...],
"unchanged": [skill names present before and after],
"total": total skill count after rescan,
"commands": total /slash-skill count after rescan,
}
``description`` is the skill's full SKILL.md frontmatter
``description:`` field the same string the system prompt renders
as `` - name: description`` for pre-existing skills.
"""
# Snapshot pre-reload state (name -> description) from the current
# slash-command cache. Using dicts lets the post-rescan diff carry
# descriptions for newly-visible or just-removed skills without a
# second disk walk.
def _snapshot(cmds: Dict[str, Dict[str, Any]]) -> Dict[str, str]:
out: Dict[str, str] = {}
for slash_key, info in cmds.items():
bare = slash_key.lstrip("/")
out[bare] = (info or {}).get("description") or ""
return out
before = _snapshot(_skill_commands)
# Rescan the skills dir. ``scan_skill_commands`` resets
# ``_skill_commands = {}`` internally and repopulates it.
new_commands = scan_skill_commands()
after = _snapshot(new_commands)
added_names = sorted(set(after) - set(before))
removed_names = sorted(set(before) - set(after))
unchanged = sorted(set(after) & set(before))
added = [{"name": n, "description": after[n]} for n in added_names]
# For removed skills, use the description we had cached pre-rescan
# (the skill file is gone so we can't re-read it).
removed = [{"name": n, "description": before[n]} for n in removed_names]
return {
"added": added,
"removed": removed,
"unchanged": unchanged,
"total": len(after),
"commands": len(new_commands),
}
def resolve_skill_command_key(command: str) -> Optional[str]:
"""Resolve a user-typed /command to its canonical skill_cmds key.
@@ -328,6 +393,14 @@ def build_skill_invocation_message(
return f"[Failed to load skill: {skill_info['name']}]"
loaded_skill, skill_dir, skill_name = loaded
# Track active usage for Curator lifecycle management (#17782)
try:
from tools.skill_usage import bump_use
bump_use(skill_name)
except Exception:
pass # Non-critical — skill invocation proceeds regardless
activation_note = (
f'[IMPORTANT: The user has invoked the "{skill_name}" skill, indicating they want '
"you to follow its instructions. The full skill content is loaded below.]"
@@ -367,6 +440,14 @@ def build_preloaded_skills_prompt(
continue
loaded_skill, skill_dir, skill_name = loaded
# Track active usage for Curator lifecycle management (#17782)
try:
from tools.skill_usage import bump_use
bump_use(skill_name)
except Exception:
pass # Non-critical
activation_note = (
f'[IMPORTANT: The user launched this CLI session with the "{skill_name}" skill '
"preloaded. Treat its instructions as active guidance for the duration of this "
+2 -2
View File
@@ -24,7 +24,7 @@ PLATFORM_MAP = {
"windows": "win32",
}
EXCLUDED_SKILL_DIRS = frozenset((".git", ".github", ".hub"))
EXCLUDED_SKILL_DIRS = frozenset((".git", ".github", ".hub", ".archive"))
# ── Lazy YAML loader ─────────────────────────────────────────────────────
@@ -440,7 +440,7 @@ def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
def iter_skill_index_files(skills_dir: Path, filename: str):
"""Walk skills_dir yielding sorted paths matching *filename*.
Excludes ``.git``, ``.github``, ``.hub`` directories.
Excludes ``.git``, ``.github``, ``.hub``, ``.archive`` directories.
"""
matches = []
for root, dirs, files in os.walk(skills_dir, followlinks=True):
+2
View File
@@ -58,6 +58,7 @@ class AnthropicTransport(ProviderTransport):
context_length: int | None
base_url: str | None
fast_mode: bool
drop_context_1m_beta: bool
"""
from agent.anthropic_adapter import build_anthropic_kwargs
@@ -73,6 +74,7 @@ class AnthropicTransport(ProviderTransport):
context_length=params.get("context_length"),
base_url=params.get("base_url"),
fast_mode=params.get("fast_mode", False),
drop_context_1m_beta=params.get("drop_context_1m_beta", False),
)
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
+17 -4
View File
@@ -24,6 +24,18 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) ->
if reasoning_config is None or not isinstance(reasoning_config, dict):
return None
normalized_model = (model or "").strip().lower()
if normalized_model.startswith("google/"):
normalized_model = normalized_model.split("/", 1)[1]
# ``thinking_config`` is a Gemini-only request parameter. The same
# ``gemini`` provider also serves Gemma (and historically PaLM/Bard);
# those reject the field with HTTP 400 "Unknown name 'thinking_config':
# Cannot find field" — including the polite ``{"includeThoughts": False}``
# form. Omit the field entirely on non-Gemini models. (#17426)
if not normalized_model.startswith("gemini"):
return None
if reasoning_config.get("enabled") is False:
# Gemini can hide thought parts even when internal thinking still
# happens; omit thinkingLevel to avoid model-specific validation quirks.
@@ -34,9 +46,6 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) ->
return {"includeThoughts": False}
thinking_config: Dict[str, Any] = {"includeThoughts": True}
normalized_model = (model or "").strip().lower()
if normalized_model.startswith("google/"):
normalized_model = normalized_model.split("/", 1)[1]
# Gemini 2.5 accepts thinkingBudget; don't guess a budget from Hermes'
# coarse effort levels. ``includeThoughts`` alone is enough to surface
@@ -468,9 +477,13 @@ class ChatCompletionsTransport(ProviderTransport):
# so keep them apart in provider_data rather than merging.
reasoning = getattr(msg, "reasoning", None)
reasoning_content = getattr(msg, "reasoning_content", None)
if reasoning_content is None and hasattr(msg, "model_extra"):
model_extra = getattr(msg, "model_extra", None) or {}
if isinstance(model_extra, dict) and "reasoning_content" in model_extra:
reasoning_content = model_extra["reasoning_content"]
provider_data: Dict[str, Any] = {}
if reasoning_content:
if reasoning_content is not None:
provider_data["reasoning_content"] = reasoning_content
rd = getattr(msg, "reasoning_details", None)
if rd:
+3 -1
View File
@@ -570,7 +570,7 @@ agent:
# - A preset like "hermes-cli" or "hermes-telegram" (curated tool set)
# - A list of individual toolsets to compose your own (see list below)
#
# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot
# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot, teams
#
# Examples:
#
@@ -600,6 +600,7 @@ agent:
# signal: hermes-signal (same as telegram)
# homeassistant: hermes-homeassistant (same as telegram)
# qqbot: hermes-qqbot (same as telegram)
# teams: hermes-teams (same as telegram)
#
platform_toolsets:
cli: [hermes-cli]
@@ -611,6 +612,7 @@ platform_toolsets:
homeassistant: [hermes-homeassistant]
qqbot: [hermes-qqbot]
yuanbao: [hermes-yuanbao]
teams: [hermes-teams]
# =============================================================================
# Gateway Platform Settings
+349 -11
View File
@@ -1240,8 +1240,73 @@ def _cprint(text: str):
Raw ANSI escapes written via print() are swallowed by patch_stdout's
StdoutProxy. Routing through print_formatted_text(ANSI(...)) lets
prompt_toolkit parse the escapes and render real colors.
When called from a background thread while a prompt_toolkit
``Application`` is running (the common case for the self-improvement
background review's ``💾 …`` summary, curator summaries, and other
bg-thread emissions), a direct ``_pt_print`` races with the input
area's redraw and the line can end up visually buried behind the
prompt. Route those cases through ``run_in_terminal`` via
``loop.call_soon_threadsafe``, which pauses the input area, prints
the line above it, and redraws the prompt cleanly.
"""
_pt_print(_PT_ANSI(text))
try:
from prompt_toolkit.application import get_app_or_none, run_in_terminal
except Exception:
_pt_print(_PT_ANSI(text))
return
app = None
try:
app = get_app_or_none()
except Exception:
app = None
# No active app, or we're already on the app's main thread: the
# direct prompt_toolkit print is safe and matches existing behavior
# (spinner frames, streamed tokens, tool activity prefixes, …).
if app is None or not getattr(app, "_is_running", False):
_pt_print(_PT_ANSI(text))
return
try:
loop = app.loop # type: ignore[attr-defined]
except Exception:
loop = None
if loop is None:
_pt_print(_PT_ANSI(text))
return
import asyncio as _asyncio
try:
current_loop = _asyncio.get_event_loop_policy().get_event_loop()
except Exception:
current_loop = None
# Same thread as the app's loop → safe to print directly.
if current_loop is loop and loop.is_running():
_pt_print(_PT_ANSI(text))
return
# Cross-thread emission: ask the app's event loop to schedule a
# ``run_in_terminal`` that wraps ``_pt_print``. This hides the
# prompt, prints, and redraws. Fire-and-forget — if scheduling
# fails we fall back to a direct print so the line isn't lost.
def _schedule():
try:
run_in_terminal(lambda: _pt_print(_PT_ANSI(text)))
except Exception:
try:
_pt_print(_PT_ANSI(text))
except Exception:
pass
try:
loop.call_soon_threadsafe(_schedule)
except Exception:
try:
_pt_print(_PT_ANSI(text))
except Exception:
pass
# ---------------------------------------------------------------------------
@@ -1540,9 +1605,29 @@ def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
# that appears when the ESC byte was stripped by a prior filter.
_DSR_CPR_ESC_RE = re.compile(r"\x1b\[\d+;\d+R")
_DSR_CPR_VISIBLE_RE = re.compile(r"\^\[\[\d+;\d+R")
_SGR_MOUSE_ESC_RE = re.compile(r"\x1b\[<\d+;\d+;\d+[Mm]")
_SGR_MOUSE_VISIBLE_RE = re.compile(r"\^\[\[<\d+;\d+;\d+[Mm]")
# Some terminals/filters can drop ESC and literal "^[[", leaving only
# "<btn;col;rowM" fragments in the buffer. Keep this broad on purpose:
# these fragments are extremely unlikely to be intentional user input, and
# stripping them is better than sending corrupted prompts.
_SGR_MOUSE_BARE_RE = re.compile(r"<\d+;\d+;\d+[Mm]")
_TERMINAL_INPUT_MODE_RESET_SEQ = (
"\x1b[?1006l" # disable SGR mouse
"\x1b[?1003l" # disable any-motion tracking
"\x1b[?1002l" # disable button-motion tracking
"\x1b[?1000l" # disable click tracking
"\x1b[?1004l" # disable focus events
"\x1b[?2004l" # disable bracketed paste
"\x1b[?1049l" # leave alt screen (if stuck there)
"\x1b[<u" # pop kitty keyboard mode
"\x1b[>4m" # reset modifyOtherKeys
"\x1b[0m" # reset text attributes
"\x1b[?25h" # ensure cursor visible
)
def _strip_leaked_terminal_responses(text: str) -> str:
def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]:
"""Strip leaked terminal control-response sequences from user input.
Covers Cursor Position Report (CPR / DSR) responses ``ESC[<row>;<col>R``
@@ -1552,12 +1637,43 @@ def _strip_leaked_terminal_responses(text: str) -> str:
(resize storms, multiplexer focus changes, slow PTYs) the response
lands in the input buffer as literal text and corrupts what the user
typed.
Also strips leaked SGR mouse-report fragments (``ESC[<...M/m`` and
degraded visible forms). Returns ``(cleaned_text, had_mouse_reports)``
so callers can trigger an in-place terminal mode recovery when needed.
"""
if not text:
return text
text = _DSR_CPR_ESC_RE.sub("", text)
text = _DSR_CPR_VISIBLE_RE.sub("", text)
return text
return text, False
has_esc = "\x1b[" in text
has_visible = "^[" in text
has_bare_mouse = "<" in text and ";" in text and ("M" in text or "m" in text)
if not (has_esc or has_visible or has_bare_mouse):
return text, False
had_mouse_reports = False
if has_esc:
text = _DSR_CPR_ESC_RE.sub("", text)
text, count = _SGR_MOUSE_ESC_RE.subn("", text)
had_mouse_reports = had_mouse_reports or count > 0
if has_visible:
text = _DSR_CPR_VISIBLE_RE.sub("", text)
text, count = _SGR_MOUSE_VISIBLE_RE.subn("", text)
had_mouse_reports = had_mouse_reports or count > 0
if has_bare_mouse:
text, count = _SGR_MOUSE_BARE_RE.subn("", text)
had_mouse_reports = had_mouse_reports or count > 0
return text, had_mouse_reports
def _strip_leaked_terminal_responses(text: str) -> str:
"""Compatibility wrapper returning only cleaned text."""
cleaned, _ = _strip_leaked_terminal_responses_with_meta(text)
return cleaned
def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
@@ -1931,6 +2047,8 @@ class HermesCLI:
self._stream_box_opened = False # True once the response box header is printed
self._reasoning_preview_buf = "" # Coalesce tiny reasoning chunks for [thinking] output
self._pending_edit_snapshots = {}
self._last_input_mode_recovery = 0.0
self._input_mode_recovery_notice_shown = False
# Configuration - priority: CLI args > env vars > config file
# Model comes from: CLI arg or config.yaml (single source of truth).
@@ -3107,6 +3225,8 @@ class HermesCLI:
return "Processing skills command..."
if cmd_lower == "/reload-mcp":
return "Reloading MCP servers..."
if cmd_lower == "/reload-skills" or cmd_lower == "/reload_skills":
return "Reloading skills..."
if cmd_lower.startswith("/browser"):
return "Configuring browser..."
return "Processing command..."
@@ -4120,6 +4240,37 @@ class HermesCLI:
sys.stdout.write(seq)
sys.stdout.flush()
def _recover_terminal_input_modes(self, *, reason: str) -> None:
"""Best-effort reset when leaked mouse reports indicate mode drift."""
now = time.monotonic()
# Rate-limit to avoid thrashing if a terminal floods reports.
if now - self._last_input_mode_recovery < 0.5:
return
self._last_input_mode_recovery = now
out = getattr(self, "_app", None)
output = getattr(out, "output", None) if out else None
try:
if output and hasattr(output, "write_raw"):
output.write_raw(_TERMINAL_INPUT_MODE_RESET_SEQ)
output.flush()
elif output and hasattr(output, "write"):
output.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
output.flush()
else:
sys.stdout.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
sys.stdout.flush()
except Exception:
return
logger.warning("Recovered terminal input modes after leak: %s", reason)
if not self._input_mode_recovery_notice_shown:
self._input_mode_recovery_notice_shown = True
_cprint(
f" {_DIM}Recovered terminal input modes after leaked mouse reports. "
f"If this repeats, run /new or restart this tab.{_RST}"
)
def _handle_copy_command(self, cmd_original: str) -> None:
"""Handle /copy [number] — copy assistant output to clipboard."""
parts = cmd_original.split(maxsplit=1)
@@ -5326,6 +5477,7 @@ class HermesCLI:
base_url=result.base_url or self.base_url or "",
api_key=result.api_key or self.api_key or "",
model_info=mi,
config_context_length=getattr(self.agent, "_config_context_length", None) if self.agent else None,
)
if ctx:
_cprint(f" Context: {ctx:,} tokens")
@@ -5552,6 +5704,7 @@ class HermesCLI:
base_url=result.base_url or self.base_url or "",
api_key=result.api_key or self.api_key or "",
model_info=mi,
config_context_length=getattr(self.agent, "_config_context_length", None) if self.agent else None,
)
if ctx:
_cprint(f" Context: {ctx:,} tokens")
@@ -5999,6 +6152,27 @@ class HermesCLI:
except Exception as exc:
print(f"(._.) curator: {exc}")
def _handle_kanban_command(self, cmd: str):
"""Handle the /kanban command — delegate to the shared kanban CLI.
The string form passed here is the user's full ``/kanban ...``
including the leading slash; we strip it and hand the remainder
to ``kanban.run_slash`` which returns a single formatted string.
"""
from hermes_cli.kanban import run_slash
rest = cmd.strip()
if rest.startswith("/"):
rest = rest.lstrip("/")
if rest.startswith("kanban"):
rest = rest[len("kanban"):].lstrip()
try:
output = run_slash(rest)
except Exception as exc: # pragma: no cover - defensive
output = f"(._.) kanban error: {exc}"
if output:
print(output)
def _handle_skills_command(self, cmd: str):
"""Handle /skills slash command — delegates to hermes_cli.skills_hub."""
from hermes_cli.skills_hub import handle_skills_slash
@@ -6244,6 +6418,8 @@ class HermesCLI:
self._handle_cron_command(cmd_original)
elif canonical == "curator":
self._handle_curator_command(cmd_original)
elif canonical == "kanban":
self._handle_kanban_command(cmd_original)
elif canonical == "skills":
with self._busy_command(self._slow_command_status(cmd_original)):
self._handle_skills_command(cmd_original)
@@ -6284,8 +6460,13 @@ class HermesCLI:
count = reload_env()
print(f" Reloaded .env ({count} var(s) updated)")
elif canonical == "reload-mcp":
# Interactive reload: confirm first (unless the user has opted out).
# The auto-reload path (file watcher) calls _reload_mcp directly
# without this confirmation.
self._confirm_and_reload_mcp(cmd_original)
elif canonical == "reload-skills":
with self._busy_command(self._slow_command_status(cmd_original)):
self._reload_mcp()
self._reload_skills()
elif canonical == "browser":
self._handle_browser_command(cmd_original)
elif canonical == "plugins":
@@ -7412,6 +7593,77 @@ class HermesCLI:
if _reload_thread.is_alive():
print(" ⚠️ MCP reload timed out (30s). Some servers may not have reconnected.")
def _confirm_and_reload_mcp(self, cmd_original: str = "") -> None:
"""Interactive /reload-mcp — confirm with the user, then reload.
Reloading MCP tools invalidates the provider prompt cache for the
active session (tool schemas are baked into the system prompt).
The next message re-sends full input tokens can be expensive on
long-context or high-reasoning models.
Three options: Approve Once, Always Approve (persists
``approvals.mcp_reload_confirm: false`` so future reloads run
without this prompt), Cancel. Gated by
``approvals.mcp_reload_confirm`` default on.
"""
# Gate check — respects prior "Always Approve" clicks.
try:
cfg = load_cli_config()
approvals = cfg.get("approvals") if isinstance(cfg, dict) else None
confirm_required = True
if isinstance(approvals, dict):
confirm_required = bool(approvals.get("mcp_reload_confirm", True))
except Exception:
confirm_required = True
if not confirm_required:
with self._busy_command(self._slow_command_status(cmd_original)):
self._reload_mcp()
return
# Render warning + prompt. Use a single-line prompt so the user
# sees the warning as output and types a response into the composer.
print()
print("⚠️ /reload-mcp — Prompt cache invalidation warning")
print()
print(" Reloading MCP servers rebuilds the tool set for this session and")
print(" invalidates the provider prompt cache. The next message will")
print(" re-send full input tokens (can be expensive on long-context or")
print(" high-reasoning models).")
print()
print(" [1] Approve Once — reload now")
print(" [2] Always Approve — reload now and silence this prompt permanently")
print(" [3] Cancel — leave MCP tools unchanged")
print()
raw = self._prompt_text_input("Choice [1/2/3]: ")
if raw is None:
print("🟡 /reload-mcp cancelled (no input).")
return
choice_raw = raw.strip().lower()
if choice_raw in ("1", "once", "approve", "yes", "y", "ok"):
choice = "once"
elif choice_raw in ("2", "always", "remember"):
choice = "always"
elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""):
choice = "cancel"
else:
print(f"🟡 Unrecognized choice '{raw}'. /reload-mcp cancelled.")
return
if choice == "cancel":
print("🟡 /reload-mcp cancelled. MCP tools unchanged.")
return
if choice == "always":
if save_config_value("approvals.mcp_reload_confirm", False):
print("🔒 Future /reload-mcp calls will run without confirmation.")
print(" Re-enable via `approvals.mcp_reload_confirm: true` in config.yaml.")
else:
print("⚠️ Couldn't persist opt-out — reloading once.")
with self._busy_command(self._slow_command_status(cmd_original)):
self._reload_mcp()
def _reload_mcp(self):
"""Reload MCP servers: disconnect all, re-read config.yaml, reconnect.
@@ -7497,6 +7749,78 @@ class HermesCLI:
except Exception as e:
print(f" ❌ MCP reload failed: {e}")
def _reload_skills(self) -> None:
"""Reload skills: rescan ~/.hermes/skills/ and queue a note for the
next user turn.
Skills don't need to live in the system prompt for the model to use
them (they're invoked via ``/skill-name``, ``skills_list``, or
``skill_view`` at runtime), so this does NOT clear the prompt cache.
It rescans the slash-command map, prints the diff for the user, and
if any skills were added or removed queues a one-shot note that
gets prepended to the next user message. This preserves message
alternation (no phantom user turn injected out of band) and keeps
prompt caching intact.
"""
try:
from agent.skill_commands import reload_skills
if not self._command_running:
print("🔄 Reloading skills...")
result = reload_skills()
added = result.get("added", []) # [{"name", "description"}, ...]
removed = result.get("removed", []) # [{"name", "description"}, ...]
total = result.get("total", 0)
if not added and not removed:
print(" No new skills detected.")
print(f" 📚 {total} skill(s) available")
return
def _fmt_line(item: dict) -> str:
nm = item.get("name", "")
desc = item.get("description", "")
return f" - {nm}: {desc}" if desc else f" - {nm}"
if added:
print(" Added Skills:")
for item in added:
print(f" {_fmt_line(item)}")
if removed:
print(" Removed Skills:")
for item in removed:
print(f" {_fmt_line(item)}")
print(f" 📚 {total} skill(s) available")
# Queue a one-shot note for the NEXT user turn. The CLI's agent
# loop prepends ``_pending_skills_reload_note`` (if set) to the
# API-call-local message at ~L8770, then clears it — same
# pattern as ``_pending_model_switch_note``. Nothing is written
# to conversation_history here, so message alternation stays
# intact and no out-of-band user turn is persisted.
#
# Format matches how the system prompt renders pre-existing
# skills (`` - name: description``) so the model reads the
# diff in the same shape as its original skill catalog.
sections = ["[USER INITIATED SKILLS RELOAD:"]
if added:
sections.append("")
sections.append("Added Skills:")
for item in added:
sections.append(_fmt_line(item))
if removed:
sections.append("")
sections.append("Removed Skills:")
for item in removed:
sections.append(_fmt_line(item))
sections.append("")
sections.append("Use skills_list to see the updated catalog.]")
self._pending_skills_reload_note = "\n".join(sections)
except Exception as e:
print(f" ❌ Skills reload failed: {e}")
# ====================================================================
# Tool-call generation indicator (shown during streaming)
# ====================================================================
@@ -8578,7 +8902,8 @@ class HermesCLI:
from agent.context_references import preprocess_context_references
from agent.model_metadata import get_model_context_length
_ctx_len = get_model_context_length(
self.model, base_url=self.base_url or "", api_key=self.api_key or "")
self.model, base_url=self.base_url or "", api_key=self.api_key or "",
config_context_length=getattr(self.agent, "_config_context_length", None) if self.agent else None)
_ctx_result = preprocess_context_references(
message, cwd=os.getcwd(), context_length=_ctx_len)
if _ctx_result.expanded or _ctx_result.blocked:
@@ -8705,6 +9030,13 @@ class HermesCLI:
if _msn:
agent_message = _msn + "\n\n" + agent_message
self._pending_model_switch_note = None
# Prepend pending /reload-skills note so the model sees which
# skills were added/removed before handling this turn. Same
# one-shot queue pattern as the model-switch note above.
_srn = getattr(self, '_pending_skills_reload_note', None)
if _srn:
agent_message = _srn + "\n\n" + agent_message
self._pending_skills_reload_note = None
try:
result = self.agent.run_conversation(
user_message=agent_message,
@@ -10035,7 +10367,9 @@ class HermesCLI:
# so the 5-line collapse threshold and display are consistent.
pasted_text = pasted_text.replace('\r\n', '\n').replace('\r', '\n')
pasted_text = _strip_leaked_bracketed_paste_wrappers(pasted_text)
pasted_text = _strip_leaked_terminal_responses(pasted_text)
pasted_text, _had_mouse_reports = _strip_leaked_terminal_responses_with_meta(pasted_text)
if _had_mouse_reports:
self._recover_terminal_input_modes(reason="mouse reports leaked into bracketed paste payload")
if _should_auto_attach_clipboard_image_on_paste(pasted_text) and self._try_attach_clipboard_image():
event.app.invalidate()
if pasted_text:
@@ -10189,7 +10523,9 @@ class HermesCLI:
event so it never triggers this.
"""
text = _strip_leaked_bracketed_paste_wrappers(buf.text)
text = _strip_leaked_terminal_responses(text)
text, _had_mouse_reports = _strip_leaked_terminal_responses_with_meta(text)
if _had_mouse_reports:
self._recover_terminal_input_modes(reason="mouse reports leaked into prompt buffer")
if text != buf.text:
cursor = min(buf.cursor_position, len(text))
_paste_just_collapsed[0] = True
@@ -10942,7 +11278,9 @@ class HermesCLI:
if isinstance(user_input, str):
user_input = _strip_leaked_bracketed_paste_wrappers(user_input)
user_input = _strip_leaked_terminal_responses(user_input)
user_input, _had_mouse_reports = _strip_leaked_terminal_responses_with_meta(user_input)
if _had_mouse_reports:
self._recover_terminal_input_modes(reason="mouse reports leaked into submitted input")
# Check for commands — but detect dragged/pasted file paths first.
# See _detect_file_drop() for details.
+65 -37
View File
@@ -277,13 +277,21 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:
return targets[0] if targets else None
# Media extension sets — keep in sync with gateway/platforms/base.py:_process_message_background
_AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a'})
# Media extension sets — audio routing is centralized in gateway.platforms.base
# via should_send_media_as_audio() so Telegram-specific rules stay in one place.
_VIDEO_EXTS = frozenset({'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'})
_IMAGE_EXTS = frozenset({'.jpg', '.jpeg', '.png', '.webp', '.gif'})
def _send_media_via_adapter(adapter, chat_id: str, media_files: list, metadata: dict | None, loop, job: dict) -> None:
def _send_media_via_adapter(
adapter,
chat_id: str,
media_files: list,
metadata: dict | None,
loop,
job: dict,
platform=None,
) -> None:
"""Send extracted MEDIA files as native platform attachments via a live adapter.
Routes each file to the appropriate adapter method (send_voice, send_image_file,
@@ -292,10 +300,13 @@ def _send_media_via_adapter(adapter, chat_id: str, media_files: list, metadata:
"""
from pathlib import Path
from gateway.platforms.base import should_send_media_as_audio
for media_path, _is_voice in media_files:
try:
ext = Path(media_path).suffix.lower()
if ext in _AUDIO_EXTS:
route_platform = platform if platform is not None else getattr(adapter, "platform", None)
if should_send_media_as_audio(route_platform, ext, is_voice=_is_voice):
coro = adapter.send_voice(chat_id=chat_id, audio_path=media_path, metadata=metadata)
elif ext in _VIDEO_EXTS:
coro = adapter.send_video(chat_id=chat_id, video_path=media_path, metadata=metadata)
@@ -341,27 +352,6 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
from tools.send_message_tool import _send_to_platform
from gateway.config import load_gateway_config, Platform
platform_map = {
"telegram": Platform.TELEGRAM,
"discord": Platform.DISCORD,
"slack": Platform.SLACK,
"whatsapp": Platform.WHATSAPP,
"signal": Platform.SIGNAL,
"matrix": Platform.MATRIX,
"mattermost": Platform.MATTERMOST,
"homeassistant": Platform.HOMEASSISTANT,
"dingtalk": Platform.DINGTALK,
"feishu": Platform.FEISHU,
"wecom": Platform.WECOM,
"wecom_callback": Platform.WECOM_CALLBACK,
"weixin": Platform.WEIXIN,
"email": Platform.EMAIL,
"sms": Platform.SMS,
"bluebubbles": Platform.BLUEBUBBLES,
"qqbot": Platform.QQBOT,
"yuanbao": Platform.YUANBAO,
}
# Optionally wrap the content with a header/footer so the user knows this
# is a cron delivery. Wrapping is on by default; set cron.wrap_response: false
# in config.yaml for clean output.
@@ -418,13 +408,23 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
job["id"], platform_name, chat_id, thread_id,
)
platform = platform_map.get(platform_name.lower())
if not platform:
# Built-in names resolve to their enum member; plugin platform names
# create dynamic members via Platform._missing_().
try:
platform = Platform(platform_name.lower())
except (ValueError, KeyError):
msg = f"unknown platform '{platform_name}'"
logger.warning("Job '%s': %s", job["id"], msg)
delivery_errors.append(msg)
continue
pconfig = config.platforms.get(platform)
if not pconfig or not pconfig.enabled:
msg = f"platform '{platform_name}' not configured/enabled"
logger.warning("Job '%s': %s", job["id"], msg)
delivery_errors.append(msg)
continue
# Prefer the live adapter when the gateway is running — this supports E2EE
# rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt.
runtime_adapter = (adapters or {}).get(platform)
@@ -455,7 +455,15 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
# Send extracted media files as native attachments via the live adapter
if adapter_ok and media_files:
_send_media_via_adapter(runtime_adapter, chat_id, media_files, send_metadata, loop, job)
_send_media_via_adapter(
runtime_adapter,
chat_id,
media_files,
send_metadata,
loop,
job,
platform=platform,
)
if adapter_ok:
logger.info("Job '%s': delivered to %s:%s via live adapter", job["id"], platform_name, chat_id)
@@ -467,13 +475,6 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
)
if not delivered:
pconfig = config.platforms.get(platform)
if not pconfig or not pconfig.enabled:
msg = f"platform '{platform_name}' not configured/enabled"
logger.warning("Job '%s': %s", job["id"], msg)
delivery_errors.append(msg)
continue
# Standalone path: run the async send in a fresh event loop (safe from any thread)
coro = _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files)
try:
@@ -860,6 +861,13 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
chat_id=str(origin["chat_id"]) if origin else "",
chat_name=origin.get("chat_name", "") if origin else "",
)
_cron_delivery_vars = (
"HERMES_CRON_AUTO_DELIVER_PLATFORM",
"HERMES_CRON_AUTO_DELIVER_CHAT_ID",
"HERMES_CRON_AUTO_DELIVER_THREAD_ID",
)
for _var_name in _cron_delivery_vars:
_VAR_MAP[_var_name].set("")
# Per-job working directory. When set (and validated at create/update
# time), we point TERMINAL_CWD at it so:
@@ -898,8 +906,11 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
if delivery_target:
_VAR_MAP["HERMES_CRON_AUTO_DELIVER_PLATFORM"].set(delivery_target["platform"])
_VAR_MAP["HERMES_CRON_AUTO_DELIVER_CHAT_ID"].set(str(delivery_target["chat_id"]))
if delivery_target.get("thread_id") is not None:
_VAR_MAP["HERMES_CRON_AUTO_DELIVER_THREAD_ID"].set(str(delivery_target["thread_id"]))
_VAR_MAP["HERMES_CRON_AUTO_DELIVER_THREAD_ID"].set(
""
if delivery_target.get("thread_id") is None
else str(delivery_target["thread_id"])
)
model = job.get("model") or os.getenv("HERMES_MODEL") or ""
@@ -1139,6 +1150,21 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
f"agent.run_conversation returned {type(result).__name__} instead of dict: {result!r}"
)
# If the agent itself reported failure (e.g. all retries exhausted on
# API errors, model abort, mid-run interrupt), do not silently mark the
# job as successful. run_agent populates `failed=True`/`completed=False`
# on these paths and may put the error into `final_response`, which
# would otherwise be delivered as if it were the agent's reply and the
# job's `last_status` set to "ok". Raise so the except handler below
# builds the proper failure tuple. (issue #17855)
if result.get("failed") is True or result.get("completed") is False:
_err_text = (
result.get("error")
or (result.get("final_response") or "").strip()
or "agent reported failure"
)
raise RuntimeError(_err_text)
final_response = result.get("final_response", "") or ""
# Strip leaked placeholder text that upstream may inject on empty completions.
if final_response.strip() == "(No response generated)":
@@ -1198,6 +1224,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
os.environ["TERMINAL_CWD"] = _prior_terminal_cwd
# Clean up ContextVar session/delivery state for this job.
clear_session_vars(_ctx_tokens)
for _var_name in _cron_delivery_vars:
_VAR_MAP[_var_name].set("")
if _session_db:
try:
_session_db.end_session(_cron_session_id, "cron_complete")
+7
View File
@@ -34,6 +34,13 @@ services:
# uncomment BOTH lines (API_SERVER_KEY is mandatory for auth):
# - API_SERVER_HOST=0.0.0.0
# - API_SERVER_KEY=${API_SERVER_KEY}
# Microsoft Teams — uncomment and fill in to enable Teams gateway.
# Register your bot at https://dev.botframework.com/ to get these values.
# - TEAMS_CLIENT_ID=${TEAMS_CLIENT_ID}
# - TEAMS_CLIENT_SECRET=${TEAMS_CLIENT_SECRET}
# - TEAMS_TENANT_ID=${TEAMS_TENANT_ID}
# - TEAMS_ALLOWED_USERS=${TEAMS_ALLOWED_USERS}
# - TEAMS_PORT=3978
command: ["gateway", "run"]
dashboard:
Binary file not shown.
+10
View File
@@ -86,6 +86,16 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
continue
platforms[plat_name] = _build_from_sessions(plat_name)
# Include plugin-registered platforms (dynamic enum members aren't in
# Platform.__members__, so the loop above misses them).
try:
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
if entry.name not in _SKIP_SESSION_DISCOVERY and entry.name not in platforms:
platforms[entry.name] = _build_from_sessions(entry.name)
except Exception:
pass
directory = {
"updated_at": datetime.now().isoformat(),
"platforms": platforms,
+193 -60
View File
@@ -13,7 +13,7 @@ import os
import json
from pathlib import Path
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
from typing import Dict, List, Optional, Any, Callable
from enum import Enum
from hermes_cli.config import get_hermes_home
@@ -45,8 +45,19 @@ def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> st
return default
# Module-level cache for bundled platform plugin names (lives outside the
# enum so it doesn't become an accidental enum member).
_Platform__bundled_plugin_names: Optional[set] = None
class Platform(Enum):
"""Supported messaging platforms."""
"""Supported messaging platforms.
Built-in platforms have explicit members. Plugin platforms use dynamic
members created on-demand by ``_missing_()`` so that
``Platform("irc")`` works without modifying this enum. Dynamic members
are cached in ``_value2member_map_`` for identity-stable comparisons.
"""
LOCAL = "local"
TELEGRAM = "telegram"
DISCORD = "discord"
@@ -68,6 +79,76 @@ class Platform(Enum):
BLUEBUBBLES = "bluebubbles"
QQBOT = "qqbot"
YUANBAO = "yuanbao"
@classmethod
def _missing_(cls, value):
"""Accept unknown platform names only for known plugin adapters.
Creates a pseudo-member cached in ``_value2member_map_`` so that
``Platform("irc") is Platform("irc")`` holds True (identity-stable).
Arbitrary strings are rejected to prevent enum pollution.
"""
if not isinstance(value, str) or not value.strip():
return None
# Normalise to lowercase to avoid case mismatches in config
value = value.strip().lower()
# Check cache first (another call may have created it already)
if value in cls._value2member_map_:
return cls._value2member_map_[value]
# Only create pseudo-members for bundled plugin platforms (discovered
# via filesystem scan) or runtime-registered plugin platforms.
global _Platform__bundled_plugin_names
if _Platform__bundled_plugin_names is None:
_Platform__bundled_plugin_names = cls._scan_bundled_plugin_platforms()
if value in _Platform__bundled_plugin_names:
pseudo = object.__new__(cls)
pseudo._value_ = value
pseudo._name_ = value.upper().replace("-", "_").replace(" ", "_")
cls._value2member_map_[value] = pseudo
cls._member_map_[pseudo._name_] = pseudo
return pseudo
# Runtime-registered plugins (e.g. user-installed, discovered after
# the enum was defined).
try:
from gateway.platform_registry import platform_registry
if platform_registry.is_registered(value):
pseudo = object.__new__(cls)
pseudo._value_ = value
pseudo._name_ = value.upper().replace("-", "_").replace(" ", "_")
cls._value2member_map_[value] = pseudo
cls._member_map_[pseudo._name_] = pseudo
return pseudo
except Exception:
pass
return None
@classmethod
def _scan_bundled_plugin_platforms(cls) -> set:
"""Return names of bundled platform plugins under ``plugins/platforms/``."""
names: set = set()
try:
platforms_dir = Path(__file__).parent.parent / "plugins" / "platforms"
if platforms_dir.is_dir():
for child in platforms_dir.iterdir():
if (
child.is_dir()
and (child / "__init__.py").exists()
and (
(child / "plugin.yaml").exists()
or (child / "plugin.yml").exists()
)
):
names.add(child.name.lower())
except Exception:
pass
return names
# Snapshot of built-in platform values before any dynamic _missing_ lookups.
# Used to distinguish real platforms from arbitrary strings.
_BUILTIN_PLATFORM_VALUES = frozenset(m.value for m in Platform.__members__.values())
@dataclass
@@ -231,6 +312,44 @@ class StreamingConfig:
)
# -----------------------------------------------------------------------------
# Built-in platform connection checkers
# -----------------------------------------------------------------------------
# Each callable receives a ``PlatformConfig`` and returns ``True`` when the
# platform is sufficiently configured to be considered "connected". Platforms
# that rely on the generic ``token or api_key`` check (Telegram, Discord,
# Slack, Matrix, Mattermost, HomeAssistant) do not need an entry here.
_PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] = {
Platform.WEIXIN: lambda cfg: bool(
cfg.extra.get("account_id") and (cfg.token or cfg.extra.get("token"))
),
Platform.WHATSAPP: lambda cfg: True, # bridge handles auth
Platform.SIGNAL: lambda cfg: bool(cfg.extra.get("http_url")),
Platform.EMAIL: lambda cfg: bool(cfg.extra.get("address")),
Platform.SMS: lambda cfg: bool(os.getenv("TWILIO_ACCOUNT_SID")),
Platform.API_SERVER: lambda cfg: True,
Platform.WEBHOOK: lambda cfg: True,
Platform.FEISHU: lambda cfg: bool(cfg.extra.get("app_id")),
Platform.WECOM: lambda cfg: bool(cfg.extra.get("bot_id")),
Platform.WECOM_CALLBACK: lambda cfg: bool(
cfg.extra.get("corp_id") or cfg.extra.get("apps")
),
Platform.BLUEBUBBLES: lambda cfg: bool(
cfg.extra.get("server_url") and cfg.extra.get("password")
),
Platform.QQBOT: lambda cfg: bool(
cfg.extra.get("app_id") and cfg.extra.get("client_secret")
),
Platform.YUANBAO: lambda cfg: bool(
cfg.extra.get("app_id") and cfg.extra.get("app_secret")
),
Platform.DINGTALK: lambda cfg: bool(
(cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
),
}
@dataclass
class GatewayConfig:
"""
@@ -284,61 +403,43 @@ class GatewayConfig:
for platform, config in self.platforms.items():
if not config.enabled:
continue
# Weixin requires both a token and an account_id
if platform == Platform.WEIXIN:
if config.extra.get("account_id") and (config.token or config.extra.get("token")):
connected.append(platform)
continue
# Platforms that use token/api_key auth
if config.token or config.api_key:
if self._is_platform_connected(platform, config):
connected.append(platform)
# WhatsApp uses enabled flag only (bridge handles auth)
elif platform == Platform.WHATSAPP:
connected.append(platform)
# Signal uses extra dict for config (http_url + account)
elif platform == Platform.SIGNAL and config.extra.get("http_url"):
connected.append(platform)
# Email uses extra dict for config (address + imap_host + smtp_host)
elif platform == Platform.EMAIL and config.extra.get("address"):
connected.append(platform)
# SMS uses api_key (Twilio auth token) — SID checked via env
elif platform == Platform.SMS and os.getenv("TWILIO_ACCOUNT_SID"):
connected.append(platform)
# API Server uses enabled flag only (no token needed)
elif platform == Platform.API_SERVER:
connected.append(platform)
# Webhook uses enabled flag only (secrets are per-route)
elif platform == Platform.WEBHOOK:
connected.append(platform)
# Feishu uses extra dict for app credentials
elif platform == Platform.FEISHU and config.extra.get("app_id"):
connected.append(platform)
# WeCom bot mode uses extra dict for bot credentials
elif platform == Platform.WECOM and config.extra.get("bot_id"):
connected.append(platform)
# WeCom callback mode uses corp_id or apps list
elif platform == Platform.WECOM_CALLBACK and (
config.extra.get("corp_id") or config.extra.get("apps")
):
connected.append(platform)
# BlueBubbles uses extra dict for local server config
elif platform == Platform.BLUEBUBBLES and config.extra.get("server_url") and config.extra.get("password"):
connected.append(platform)
# QQBot uses extra dict for app credentials
elif platform == Platform.QQBOT and config.extra.get("app_id") and config.extra.get("client_secret"):
connected.append(platform)
# Yuanbao uses extra dict for app credentials
elif platform == Platform.YUANBAO and config.extra.get("app_id") and config.extra.get("app_secret"):
connected.append(platform)
# DingTalk uses client_id/client_secret from config.extra or env vars
elif platform == Platform.DINGTALK and (
config.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID")
) and (
config.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET")
):
connected.append(platform)
return connected
def _is_platform_connected(self, platform: Platform, config: PlatformConfig) -> bool:
"""Check whether a single platform is sufficiently configured."""
# Weixin requires both a token and an account_id (checked first so
# the generic token branch doesn't let it through without account_id).
if platform == Platform.WEIXIN:
return bool(
config.extra.get("account_id")
and (config.token or config.extra.get("token"))
)
# Generic token/api_key auth covers Telegram, Discord, Slack, etc.
if config.token or config.api_key:
return True
# Platform-specific check
checker = _PLATFORM_CONNECTED_CHECKERS.get(platform)
if checker is not None:
return checker(config)
# Plugin-registered platforms
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(platform.value)
if entry:
if entry.is_connected is not None:
return entry.is_connected(config)
if entry.validate_config is not None:
return entry.validate_config(config)
return True
except Exception:
pass # Registry not yet initialised during early import
return False
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
"""Get the home channel for a platform."""
@@ -714,11 +815,21 @@ def load_gateway_config() -> GatewayConfig:
os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
if "proxy_url" in telegram_cfg and not os.getenv("TELEGRAM_PROXY"):
os.environ["TELEGRAM_PROXY"] = str(telegram_cfg["proxy_url"]).strip()
if "group_allowed_chats" in telegram_cfg and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"):
gac = telegram_cfg["group_allowed_chats"]
if isinstance(gac, list):
gac = ",".join(str(v) for v in gac)
os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(gac)
allowed_users = telegram_cfg.get("allow_from")
if allowed_users is not None and not os.getenv("TELEGRAM_ALLOWED_USERS"):
if isinstance(allowed_users, list):
allowed_users = ",".join(str(v) for v in allowed_users)
os.environ["TELEGRAM_ALLOWED_USERS"] = str(allowed_users)
group_allowed_users = telegram_cfg.get("group_allow_from")
if group_allowed_users is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"):
if isinstance(group_allowed_users, list):
group_allowed_users = ",".join(str(v) for v in group_allowed_users)
os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(group_allowed_users)
group_allowed_chats = telegram_cfg.get("group_allowed_chats")
if group_allowed_chats is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS"):
if isinstance(group_allowed_chats, list):
group_allowed_chats = ",".join(str(v) for v in group_allowed_chats)
os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats)
if "disable_link_previews" in telegram_cfg:
plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {})
if not isinstance(plat_data, dict):
@@ -1371,3 +1482,25 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
config.default_reset_policy.at_hour = int(reset_hour)
except ValueError:
pass
# Registry-driven enable for plugin platforms. Built-ins have explicit
# blocks above; plugins expose check_fn() which is the single source of
# truth for "are my env vars set?". When it returns True, ensure the
# platform is enabled so start() will create its adapter.
try:
from hermes_cli.plugins import discover_plugins
discover_plugins() # idempotent
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
try:
if not entry.check_fn():
continue
except Exception as e:
logger.debug("check_fn for %s raised: %s", entry.name, e)
continue
platform = Platform(entry.name)
if platform not in config.platforms:
config.platforms[platform] = PlatformConfig()
config.platforms[platform].enabled = True
except Exception as e:
logger.debug("Plugin platform enable pass failed: %s", e)
+16 -3
View File
@@ -21,6 +21,7 @@ Errors in hooks are caught and logged but never block the main pipeline.
import asyncio
import importlib.util
import sys
from typing import Any, Callable, Dict, List, Optional
import yaml
@@ -97,16 +98,28 @@ class HookRegistry:
print(f"[hooks] Skipping {hook_name}: no events declared", flush=True)
continue
# Dynamically load the handler module
# Dynamically load the handler module.
# Register in sys.modules BEFORE exec_module so Pydantic /
# dataclasses / typing introspection can resolve forward
# references (triggered by `from __future__ import annotations`
# in the handler). Without this, a handler that declares a
# Pydantic BaseModel for webhook/event payloads fails at first
# dispatch with "TypeAdapter ... is not fully defined".
module_name = f"hermes_hook_{hook_name}"
spec = importlib.util.spec_from_file_location(
f"hermes_hook_{hook_name}", handler_path
module_name, handler_path
)
if spec is None or spec.loader is None:
print(f"[hooks] Skipping {hook_name}: could not load handler.py", flush=True)
continue
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(module_name, None)
raise
handle_fn = getattr(module, "handle", None)
if handle_fn is None:
+212
View File
@@ -0,0 +1,212 @@
"""
Platform Adapter Registry
Allows platform adapters (built-in and plugin) to self-register so the gateway
can discover and instantiate them without hardcoded if/elif chains.
Built-in adapters continue to use the existing if/elif in _create_adapter()
for now. Plugin adapters register here via PluginContext.register_platform()
and are looked up first -- if nothing is found the gateway falls through to
the legacy code path.
Usage (plugin side):
from gateway.platform_registry import platform_registry, PlatformEntry
platform_registry.register(PlatformEntry(
name="irc",
label="IRC",
adapter_factory=lambda cfg: IRCAdapter(cfg),
check_fn=check_requirements,
validate_config=lambda cfg: bool(cfg.extra.get("server")),
required_env=["IRC_SERVER"],
install_hint="pip install irc",
))
Usage (gateway side):
adapter = platform_registry.create_adapter("irc", platform_config)
"""
import logging
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
@dataclass
class PlatformEntry:
"""Metadata and factory for a single platform adapter."""
# Identifier used in config.yaml (e.g. "irc", "viber").
name: str
# Human-readable label (e.g. "IRC", "Viber").
label: str
# Factory callable: receives a PlatformConfig, returns an adapter instance.
# Using a factory instead of a bare class lets plugins do custom init
# (e.g. passing extra kwargs, wrapping in try/except).
adapter_factory: Callable[[Any], Any]
# Returns True when the platform's dependencies are available.
check_fn: Callable[[], bool]
# Optional: given a PlatformConfig, is it properly configured?
# If None, the registry skips config validation and lets the adapter
# fail at connect() time with a descriptive error.
validate_config: Optional[Callable[[Any], bool]] = None
# Optional: given a PlatformConfig, is the platform connected/enabled?
# Used by ``GatewayConfig.get_connected_platforms()`` and setup UI status.
# If None, falls back to ``validate_config`` or ``check_fn``.
is_connected: Optional[Callable[[Any], bool]] = None
# Env vars this platform needs (for ``hermes setup`` display).
required_env: list = field(default_factory=list)
# Hint shown when check_fn returns False.
install_hint: str = ""
# Optional setup function for interactive configuration.
# Signature: () -> None (prompts user, saves env vars).
# If None, falls back to _setup_standard_platform (needs token_var + vars)
# or a generic "set these env vars" display.
setup_fn: Optional[Callable[[], None]] = None
# "builtin" or "plugin"
source: str = "plugin"
# Name of the plugin manifest that registered this entry (empty for
# built-ins). Used by ``hermes gateway setup`` to auto-enable the
# owning plugin when the user configures its platform.
plugin_name: str = ""
# ── Auth env var names (for _is_user_authorized integration) ──
# E.g. "IRC_ALLOWED_USERS" — checked for comma-separated user IDs.
allowed_users_env: str = ""
# E.g. "IRC_ALLOW_ALL_USERS" — if truthy, all users authorized.
allow_all_env: str = ""
# ── Message limits ──
# Max message length for smart-chunking. 0 = no limit.
max_message_length: int = 0
# ── Privacy ──
# If True, session descriptions redact PII (phone numbers, etc.)
pii_safe: bool = False
# ── Display ──
# Emoji for CLI/gateway display (e.g. "💬")
emoji: str = "🔌"
# Whether this platform should appear in _UPDATE_ALLOWED_PLATFORMS
# (allows /update command from this platform).
allow_update_command: bool = True
# ── LLM guidance ──
# Platform hint injected into the system prompt (e.g. "You are on IRC.
# Do not use markdown."). Empty string = no hint.
platform_hint: str = ""
class PlatformRegistry:
"""Central registry of platform adapters.
Thread-safe for reads (dict lookups are atomic under GIL).
Writes happen at startup during sequential discovery.
"""
def __init__(self) -> None:
self._entries: dict[str, PlatformEntry] = {}
def register(self, entry: PlatformEntry) -> None:
"""Register a platform adapter entry.
If an entry with the same name exists, it is replaced (last writer
wins -- this lets plugins override built-in adapters if desired).
"""
if entry.name in self._entries:
prev = self._entries[entry.name]
logger.info(
"Platform '%s' re-registered (was %s, now %s)",
entry.name,
prev.source,
entry.source,
)
self._entries[entry.name] = entry
logger.debug("Registered platform adapter: %s (%s)", entry.name, entry.source)
def unregister(self, name: str) -> bool:
"""Remove a platform entry. Returns True if it existed."""
return self._entries.pop(name, None) is not None
def get(self, name: str) -> Optional[PlatformEntry]:
"""Look up a platform entry by name."""
return self._entries.get(name)
def all_entries(self) -> list[PlatformEntry]:
"""Return all registered platform entries."""
return list(self._entries.values())
def plugin_entries(self) -> list[PlatformEntry]:
"""Return only plugin-registered platform entries."""
return [e for e in self._entries.values() if e.source == "plugin"]
def is_registered(self, name: str) -> bool:
return name in self._entries
def create_adapter(self, name: str, config: Any) -> Optional[Any]:
"""Create an adapter instance for the given platform name.
Returns None if:
- No entry registered for *name*
- check_fn() returns False (missing deps)
- validate_config() returns False (misconfigured)
- The factory raises an exception
"""
entry = self._entries.get(name)
if entry is None:
return None
if not entry.check_fn():
hint = f" ({entry.install_hint})" if entry.install_hint else ""
logger.warning(
"Platform '%s' requirements not met%s",
entry.label,
hint,
)
return None
if entry.validate_config is not None:
try:
if not entry.validate_config(config):
logger.warning(
"Platform '%s' config validation failed",
entry.label,
)
return None
except Exception as e:
logger.warning(
"Platform '%s' config validation error: %s",
entry.label,
e,
)
return None
try:
adapter = entry.adapter_factory(config)
return adapter
except Exception as e:
logger.error(
"Failed to create adapter for platform '%s': %s",
entry.label,
e,
exc_info=True,
)
return None
# Module-level singleton
platform_registry = PlatformRegistry()
+25 -4
View File
@@ -1,9 +1,30 @@
# Adding a New Messaging Platform
Checklist for integrating a new messaging platform into the Hermes gateway.
Use this as a reference when building a new adapter — every item here is a
real integration point that exists in the codebase. Missing any of them will
cause broken functionality, missing features, or inconsistent behavior.
There are two ways to add a platform to the Hermes gateway:
## Plugin Path (Recommended for Community/Third-Party)
Create a plugin directory in `~/.hermes/plugins/` with a `PLUGIN.yaml` and
`adapter.py`. The adapter inherits from `BasePlatformAdapter` and registers
via `ctx.register_platform()` in the `register(ctx)` entry point. This
requires **zero changes to core Hermes code**.
The plugin system automatically handles: adapter creation, config parsing,
user authorization, cron delivery, send_message routing, system prompt hints,
status display, gateway setup, and more.
See `plugins/platforms/irc/` for a complete reference implementation, and
`website/docs/developer-guide/adding-platform-adapters.md` for the full
plugin guide with code examples.
---
## Built-in Path (Core Contributors Only)
Checklist for integrating a platform directly into the Hermes core.
Use this as a reference when building a built-in adapter — every item here
is a real integration point. Missing any of them will cause broken
functionality, missing features, or inconsistent behavior.
---
+301 -84
View File
@@ -23,6 +23,45 @@ from utils import normalize_proxy_url
logger = logging.getLogger(__name__)
# Audio file extensions Hermes recognizes for native audio delivery.
# Kept in sync with tools/send_message_tool.py and cron/scheduler.py via
# should_send_media_as_audio() below.
_AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a', '.flac'})
# Telegram's Bot API sendAudio only accepts MP3 / M4A. Other audio
# formats either need to go through sendVoice (Opus/OGG) or must be
# delivered as a regular document.
_TELEGRAM_AUDIO_ATTACHMENT_EXTS = frozenset({'.mp3', '.m4a'})
_TELEGRAM_VOICE_EXTS = frozenset({'.ogg', '.opus'})
def _platform_name(platform) -> str:
"""Normalize a Platform enum / raw string into a lowercase name."""
value = getattr(platform, "value", platform)
return str(value or "").lower()
def should_send_media_as_audio(platform, ext: str, is_voice: bool = False) -> bool:
"""Return True when a media file should use the platform's audio sender.
Other platforms: every recognized audio extension routes through the
audio sender.
Telegram: the Bot API only accepts MP3/M4A for sendAudio and
Opus/OGG for sendVoice. Opus/OGG is only routed as audio when the
caller flagged ``is_voice=True`` (so we don't turn a regular audio
attachment into a voice bubble just because the file happens to be
Opus). Everything else falls through to document delivery by
returning ``False``.
"""
normalized_ext = (ext or "").lower()
if normalized_ext not in _AUDIO_EXTS:
return False
if _platform_name(platform) == "telegram":
if normalized_ext in _TELEGRAM_VOICE_EXTS:
return is_voice
return normalized_ext in _TELEGRAM_AUDIO_ATTACHMENT_EXTS
return True
def utf16_len(s: str) -> int:
"""Count UTF-16 code units in *s*.
@@ -1415,6 +1454,41 @@ class BasePlatformAdapter(ABC):
"""
return False
async def send_slash_confirm(
self,
chat_id: str,
title: str,
message: str,
session_key: str,
confirm_id: str,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a three-option slash-command confirmation prompt.
Used by the gateway's generic slash-confirm primitive (see
``GatewayRunner._request_slash_confirm``) for commands that have a
non-destructive but expensive side effect the user should explicitly
acknowledge the current caller is ``/reload-mcp``, which
invalidates the provider prompt cache.
Platforms with inline-button support (Telegram, Discord, Slack,
Matrix, Feishu) should override this to render three buttons:
Approve Once / Always Approve / Cancel. Button callbacks MUST be
routed back through the gateway by calling
``GatewayRunner._resolve_slash_confirm(confirm_id, choice)`` where
``choice`` is ``"once"`` / ``"always"`` / ``"cancel"``.
Platforms without button UIs leave this as the default and fall
through to the gateway's text fallback (which sends ``message`` as
plain text and intercepts the next ``/approve`` / ``/always`` /
``/cancel`` reply).
``confirm_id`` is a short string generated by the gateway; the
adapter stores it alongside any platform-specific state needed to
route the callback (e.g. Telegram's ``_approval_state`` dict).
"""
return SendResult(success=False, error="Not supported")
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""
Send a typing indicator.
@@ -1431,7 +1505,64 @@ class BasePlatformAdapter(ABC):
Default is a no-op for platforms with one-shot typing indicators.
"""
pass
async def send_multiple_images(
self,
chat_id: str,
images: List[Tuple[str, str]],
metadata: Optional[Dict[str, Any]] = None,
human_delay: float = 0.0,
) -> None:
"""Send a batch of images.
Accepts ``http(s)://``, ``file://`` URIs in the first tuple
element.
Default implementation sends each item individually,
routing animated GIFs through ``send_animation`` and local
files through ``send_image_file``.
Override in subclasses to bundle into a single native API call
(e.g. Signal's multi-attachment RPC)
"""
from urllib.parse import unquote as _unquote
for image_url, alt_text in images:
if human_delay > 0:
await asyncio.sleep(human_delay)
try:
logger.info(
"[%s] Sending image: %s (alt=%s)",
self.name,
safe_url_for_log(image_url),
alt_text[:30] if alt_text else "",
)
if image_url.startswith("file://"):
img_result = await self.send_image_file(
chat_id=chat_id,
image_path=_unquote(image_url[7:]),
caption=alt_text if alt_text else None,
metadata=metadata,
)
elif self._is_animation_url(image_url):
img_result = await self.send_animation(
chat_id=chat_id,
animation_url=image_url,
caption=alt_text if alt_text else None,
metadata=metadata,
)
else:
img_result = await self.send_image(
chat_id=chat_id,
image_url=image_url,
caption=alt_text if alt_text else None,
metadata=metadata,
)
if not img_result.success:
logger.error("[%s] Failed to send image: %s", self.name, img_result.error)
except Exception as img_err:
logger.error("[%s] Error sending image: %s", self.name, img_err, exc_info=True)
async def send_image(
self,
chat_id: str,
@@ -1640,7 +1771,7 @@ class BasePlatformAdapter(ABC):
# Extract MEDIA:<path> tags, allowing optional whitespace after the colon
# and quoted/backticked paths for LLM-formatted outputs.
media_pattern = re.compile(
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$)|\S+)[`"']?'''
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$)|\S+)[`"']?'''
)
for match in media_pattern.finditer(content):
path = match.group("path").strip()
@@ -1780,11 +1911,19 @@ class BasePlatformAdapter(ABC):
if stop_event is None:
await asyncio.sleep(interval)
continue
try:
await asyncio.wait_for(stop_event.wait(), timeout=interval)
except asyncio.TimeoutError:
continue
return
loop = asyncio.get_running_loop()
deadline = loop.time() + interval
while not stop_event.is_set():
remaining = deadline - loop.time()
if remaining <= 0:
break
# Poll instead of wait_for(stop_event.wait()). Cancelling
# wait_for while it owns the inner Event.wait task can leave
# shutdown paths stuck awaiting the typing task on Python
# 3.11/pytest-asyncio; sleep cancellation is immediate.
await asyncio.sleep(min(0.25, remaining))
if stop_event.is_set():
return
except asyncio.CancelledError:
pass # Normal cancellation when handler completes
finally:
@@ -2117,6 +2256,12 @@ class BasePlatformAdapter(ABC):
``release_guard=False`` keeps the adapter-level session guard in place
so reset-like commands can finish atomically before follow-up messages
are allowed to start a fresh background task.
Bounded by a 5s timeout so a wedged finally block in the cancelled
task (typing-task cleanup, on_processing_complete hook, etc.) can't
stall the calling dispatch coroutine particularly under pytest-
asyncio where the event loop's cancellation-propagation semantics
differ subtly from a bare ``asyncio.run`` harness.
"""
task = self._session_tasks.pop(session_key, None)
if task is not None and not task.done():
@@ -2128,9 +2273,15 @@ class BasePlatformAdapter(ABC):
self._expected_cancelled_tasks.add(task)
task.cancel()
try:
await task
await asyncio.wait_for(asyncio.shield(task), timeout=5.0)
except asyncio.CancelledError:
pass
except asyncio.TimeoutError:
logger.warning(
"[%s] Cancelled task for %s did not exit within 5s; "
"unblocking dispatch and letting the task unwind in the background",
self.name, session_key,
)
except Exception:
logger.debug(
"[%s] Session cancellation raised while unwinding %s",
@@ -2382,6 +2533,16 @@ class BasePlatformAdapter(ABC):
**_keep_typing_kwargs,
)
)
async def _stop_typing_task() -> None:
typing_task.cancel()
try:
await asyncio.wait_for(asyncio.shield(typing_task), timeout=0.5)
except (asyncio.CancelledError, asyncio.TimeoutError):
# Cancellation cleanup must not block adapter shutdown. The
# typing task is already cancelled; if the parent task is also
# cancelling, let this message-processing task unwind now.
pass
try:
await self._run_processing_hook("on_processing_start", event)
@@ -2483,47 +2644,57 @@ class BasePlatformAdapter(ABC):
# Send extracted images as native attachments
if images:
logger.info("[%s] Extracted %d image(s) to send as attachments", self.name, len(images))
for image_url, alt_text in images:
if human_delay > 0:
await asyncio.sleep(human_delay)
try:
logger.info(
"[%s] Sending image: %s (alt=%s)",
self.name,
safe_url_for_log(image_url),
alt_text[:30] if alt_text else "",
await self.send_multiple_images(
chat_id=event.source.chat_id,
images=images,
metadata=_thread_metadata,
human_delay=human_delay,
)
# Route animated GIFs through send_animation for proper playback
if self._is_animation_url(image_url):
img_result = await self.send_animation(
chat_id=event.source.chat_id,
animation_url=image_url,
caption=alt_text if alt_text else None,
metadata=_thread_metadata,
)
else:
img_result = await self.send_image(
chat_id=event.source.chat_id,
image_url=image_url,
caption=alt_text if alt_text else None,
metadata=_thread_metadata,
)
if not img_result.success:
logger.error("[%s] Failed to send image: %s", self.name, img_result.error)
except Exception as img_err:
logger.error("[%s] Error sending image: %s", self.name, img_err, exc_info=True)
except Exception as batch_err:
logger.warning("[%s] Error batching images: %s", self.name, batch_err, exc_info=True)
# Send extracted media files — route by file type
_AUDIO_EXTS = {'.ogg', '.opus', '.mp3', '.wav', '.m4a'}
_VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}
_IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
# Partition images out of media_files + local_files so they
# can be sent as a single batch (Signal RPC)
from urllib.parse import quote as _quote
_image_paths: list = []
_non_image_media: list = []
for media_path, is_voice in media_files:
_ext = Path(media_path).suffix.lower()
if _ext in _IMAGE_EXTS and not is_voice:
_image_paths.append(media_path)
else:
_non_image_media.append((media_path, is_voice))
_non_image_local: list = []
for file_path in local_files:
if Path(file_path).suffix.lower() in _IMAGE_EXTS:
_image_paths.append(file_path)
else:
_non_image_local.append(file_path)
if _image_paths:
try:
_batch = [(f"file://{_quote(p)}", "") for p in _image_paths]
await self.send_multiple_images(
chat_id=event.source.chat_id,
images=_batch,
metadata=_thread_metadata,
human_delay=human_delay,
)
except Exception as batch_err:
logger.warning("[%s] Error batching images: %s", self.name, batch_err, exc_info=True)
for media_path, is_voice in _non_image_media:
if human_delay > 0:
await asyncio.sleep(human_delay)
try:
ext = Path(media_path).suffix.lower()
if ext in _AUDIO_EXTS:
if should_send_media_as_audio(self.platform, ext, is_voice=is_voice):
media_result = await self.send_voice(
chat_id=event.source.chat_id,
audio_path=media_path,
@@ -2535,12 +2706,6 @@ class BasePlatformAdapter(ABC):
video_path=media_path,
metadata=_thread_metadata,
)
elif ext in _IMAGE_EXTS:
media_result = await self.send_image_file(
chat_id=event.source.chat_id,
image_path=media_path,
metadata=_thread_metadata,
)
else:
media_result = await self.send_document(
chat_id=event.source.chat_id,
@@ -2553,19 +2718,13 @@ class BasePlatformAdapter(ABC):
except Exception as media_err:
logger.warning("[%s] Error sending media: %s", self.name, media_err)
# Send auto-detected local files as native attachments
for file_path in local_files:
# Send auto-detected local non-image files as native attachments
for file_path in _non_image_local:
if human_delay > 0:
await asyncio.sleep(human_delay)
try:
ext = Path(file_path).suffix.lower()
if ext in _IMAGE_EXTS:
await self.send_image_file(
chat_id=event.source.chat_id,
image_path=file_path,
metadata=_thread_metadata,
)
elif ext in _VIDEO_EXTS:
if ext in _VIDEO_EXTS:
await self.send_video(
chat_id=event.source.chat_id,
video_path=file_path,
@@ -2604,14 +2763,28 @@ class BasePlatformAdapter(ABC):
_active = self._active_sessions.get(session_key)
if _active is not None:
_active.clear()
typing_task.cancel()
await _stop_typing_task()
# Spawn a fresh task for the pending message instead of
# recursing. Issue #17758: `await
# self._process_message_background(...)` here grew the
# call stack one frame per chained follow-up, and under
# sustained pending-queue activity the C stack would
# exhaust at ~2000 frames and SIGSEGV the process.
# Mirror the late-arrival drain pattern below: hand off
# to a new task and return so this frame can unwind.
drain_task = asyncio.create_task(
self._process_message_background(pending_event, session_key)
)
# Hand ownership of the session to the drain task so
# stale-lock detection keeps working while it runs.
self._session_tasks[session_key] = drain_task
try:
await typing_task
except asyncio.CancelledError:
self._background_tasks.add(drain_task)
drain_task.add_done_callback(self._background_tasks.discard)
except TypeError:
# Tests stub create_task() with non-hashable sentinels; tolerate.
pass
# Process pending message in new background task
await self._process_message_background(pending_event, session_key)
return # Already cleaned up
return # Drain task owns the session now.
except asyncio.CancelledError:
current_task = asyncio.current_task()
@@ -2656,11 +2829,7 @@ class BasePlatformAdapter(ABC):
except Exception:
pass
# Stop typing indicator
typing_task.cancel()
try:
await typing_task
except asyncio.CancelledError:
pass
await _stop_typing_task()
# Also cancel any platform-level persistent typing tasks (e.g. Discord)
# that may have been recreated by _keep_typing after the last stop_typing()
try:
@@ -2677,25 +2846,41 @@ class BasePlatformAdapter(ABC):
# dropped (user never gets a reply).
late_pending = self._pending_messages.pop(session_key, None)
if late_pending is not None:
logger.debug(
"[%s] Late-arrival pending message during cleanup — spawning drain task",
self.name,
)
_active = self._active_sessions.get(session_key)
if _active is not None:
_active.clear()
drain_task = asyncio.create_task(
self._process_message_background(late_pending, session_key)
)
# Hand ownership of the session to the drain task so stale-lock
# detection keeps working while it runs.
self._session_tasks[session_key] = drain_task
try:
self._background_tasks.add(drain_task)
drain_task.add_done_callback(self._background_tasks.discard)
except TypeError:
# Tests stub create_task() with non-hashable sentinels; tolerate.
pass
current_task = asyncio.current_task()
existing_task = self._session_tasks.get(session_key)
if (
existing_task is not None
and existing_task is not current_task
):
# The in-band drain (or an earlier late-arrival drain)
# already spawned a follow-up task that owns this
# session. Re-queue the late-arrival event so that
# task picks it up — avoids spawning two concurrent
# _process_message_background tasks for the same key
# (#17758 follow-up: prevents the create_task path
# from racing with itself across the in-band/finally
# boundary).
self._pending_messages[session_key] = late_pending
else:
logger.debug(
"[%s] Late-arrival pending message during cleanup — spawning drain task",
self.name,
)
_active = self._active_sessions.get(session_key)
if _active is not None:
_active.clear()
drain_task = asyncio.create_task(
self._process_message_background(late_pending, session_key)
)
# Hand ownership of the session to the drain task so stale-lock
# detection keeps working while it runs.
self._session_tasks[session_key] = drain_task
try:
self._background_tasks.add(drain_task)
drain_task.add_done_callback(self._background_tasks.discard)
except TypeError:
# Tests stub create_task() with non-hashable sentinels; tolerate.
pass
# Leave _active_sessions[session_key] populated — the drain
# task's own lifecycle will clean it up.
else:
@@ -2703,16 +2888,34 @@ class BasePlatformAdapter(ABC):
# reset-like command that already swapped in its own
# command_guard (and cancelled us) can't be accidentally
# cleared by our unwind. The command owns the session now.
#
# The owner-check also covers the in-band drain handoff
# above: when we spawned a drain_task and transferred
# ownership via ``_session_tasks[session_key] = drain_task``,
# ``_session_tasks.get(session_key) is current_task`` is
# False, so we leave _active_sessions populated. Without
# this guard, the drain task picks up the same
# interrupt_event in its own _process_message_background
# entry, _release_session_guard's guard-match succeeds,
# and we'd delete the entry while the drain task is still
# running — letting a concurrent inbound message pass
# the Level-1 guard and spawn a second handler for the
# same session.
current_task = asyncio.current_task()
if current_task is not None and self._session_tasks.get(session_key) is current_task:
del self._session_tasks[session_key]
self._release_session_guard(session_key, guard=interrupt_event)
self._release_session_guard(session_key, guard=interrupt_event)
async def cancel_background_tasks(self) -> None:
"""Cancel any in-flight background message-processing tasks.
Used during gateway shutdown/replacement so active sessions from the old
process do not keep running after adapters are being torn down.
Each cancelled task is awaited with a 5s bound so a wedged finally
(typing-task cleanup, on_processing_complete hook) can't stall the
whole shutdown path. Stragglers are released from our tracking and
allowed to finish unwinding on their own.
"""
# Loop until no new tasks appear. Without this, a message
# arriving during the `await asyncio.gather` below would spawn
@@ -2731,7 +2934,21 @@ class BasePlatformAdapter(ABC):
for task in tasks:
self._expected_cancelled_tasks.add(task)
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
try:
await asyncio.wait_for(
asyncio.gather(
*(asyncio.shield(t) for t in tasks),
return_exceptions=True,
),
timeout=5.0,
)
except asyncio.TimeoutError:
logger.warning(
"[%s] %d background task(s) did not exit within 5s; "
"releasing tracking and letting them unwind in the background",
self.name, len([t for t in tasks if not t.done()]),
)
break
# Loop: late-arrival tasks spawned during the gather above
# will be in self._background_tasks now. Re-check.
self._background_tasks.clear()
+267 -1
View File
@@ -18,7 +18,7 @@ import tempfile
import threading
import time
from collections import defaultdict
from typing import Callable, Dict, Optional, Any
from typing import Callable, Dict, List, Optional, Any, Tuple
logger = logging.getLogger(__name__)
@@ -1343,6 +1343,134 @@ class DiscordAdapter(BasePlatformAdapter):
msg = await channel.send(content=caption if caption else None, file=file)
return SendResult(success=True, message_id=str(msg.id))
async def send_multiple_images(
self,
chat_id: str,
images: List[Tuple[str, str]],
metadata: Optional[Dict[str, Any]] = None,
human_delay: float = 0.0,
) -> None:
"""Send a batch of images as a single Discord message with multiple attachments.
Discord permits up to 10 file attachments per message. Batches are
chunked accordingly. URL images are downloaded into memory and
uploaded as inline attachments (same pattern as ``send_image`` so
they render inline, not as bare links). Local files are opened
directly. On per-chunk failure the remaining images in that chunk
fall back to the base per-image loop.
"""
if not self._client:
return
if not images:
return
try:
import discord as _discord_mod
import io as _io
from urllib.parse import unquote as _unquote
except Exception: # pragma: no cover
await super().send_multiple_images(chat_id, images, metadata, human_delay)
return
try:
channel = self._client.get_channel(int(chat_id))
if not channel:
channel = await self._client.fetch_channel(int(chat_id))
if not channel:
logger.warning("[%s] Channel %s not found for multi-image send", self.name, chat_id)
return
except Exception as e:
logger.warning("[%s] Failed to resolve channel for multi-image send: %s", self.name, e)
await super().send_multiple_images(chat_id, images, metadata, human_delay)
return
CHUNK = 10
chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)]
for chunk_idx, chunk in enumerate(chunks):
if human_delay > 0 and chunk_idx > 0:
await asyncio.sleep(human_delay)
files: List[Any] = []
captions: List[str] = []
aiohttp_session = None
try:
for image_url, alt_text in chunk:
if alt_text:
captions.append(alt_text)
if image_url.startswith("file://"):
local_path = _unquote(image_url[7:])
if not os.path.exists(local_path):
logger.warning("[%s] Skipping missing image: %s", self.name, local_path)
continue
files.append(_discord_mod.File(local_path, filename=os.path.basename(local_path)))
else:
if not is_safe_url(image_url):
logger.warning("[%s] Blocked unsafe image URL in batch", self.name)
continue
# Download to BytesIO so it renders inline
try:
import aiohttp as _aiohttp
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
if aiohttp_session is None:
aiohttp_session = _aiohttp.ClientSession(**_sess_kw)
async with aiohttp_session.get(
image_url, timeout=_aiohttp.ClientTimeout(total=30), **_req_kw,
) as resp:
if resp.status != 200:
logger.warning(
"[%s] Failed to download image (HTTP %d) in batch: %s",
self.name, resp.status, image_url[:80],
)
continue
data = await resp.read()
ct = resp.headers.get("content-type", "image/png")
ext = "png"
if "jpeg" in ct or "jpg" in ct:
ext = "jpg"
elif "gif" in ct:
ext = "gif"
elif "webp" in ct:
ext = "webp"
files.append(_discord_mod.File(_io.BytesIO(data), filename=f"image_{len(files)}.{ext}"))
except Exception as dl_err:
logger.warning("[%s] Download failed for %s: %s", self.name, image_url[:80], dl_err)
continue
if not files:
continue
# Use the first caption if any (Discord only has one message body for the group)
content = captions[0] if captions else None
logger.info(
"[%s] Sending %d image(s) as single Discord message (chunk %d/%d)",
self.name, len(files), chunk_idx + 1, len(chunks),
)
if self._is_forum_parent(channel):
await self._forum_post_file(
channel,
content=(content or "").strip(),
files=files,
)
else:
await channel.send(content=content, files=files)
except Exception as e:
logger.warning(
"[%s] Multi-image Discord send failed (chunk %d/%d), falling back to per-image: %s",
self.name, chunk_idx + 1, len(chunks), e,
exc_info=True,
)
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
finally:
if aiohttp_session is not None:
try:
await aiohttp_session.close()
except Exception:
pass
async def play_tts(
self,
chat_id: str,
@@ -2270,6 +2398,10 @@ class DiscordAdapter(BasePlatformAdapter):
async def slash_reload_mcp(interaction: discord.Interaction):
await self._run_simple_slash(interaction, "/reload-mcp")
@tree.command(name="reload-skills", description="Re-scan ~/.hermes/skills/ for new or removed skills")
async def slash_reload_skills(interaction: discord.Interaction):
await self._run_simple_slash(interaction, "/reload-skills")
@tree.command(name="voice", description="Toggle voice reply mode")
@discord.app_commands.describe(mode="Voice mode: on, off, tts, channel, leave, or status")
@discord.app_commands.choices(mode=[
@@ -2906,6 +3038,43 @@ class DiscordAdapter(BasePlatformAdapter):
except Exception as e:
return SendResult(success=False, error=str(e))
async def send_slash_confirm(
self, chat_id: str, title: str, message: str, session_key: str,
confirm_id: str, metadata: Optional[dict] = None,
) -> SendResult:
"""Send a three-button slash-command confirmation prompt."""
if not self._client or not DISCORD_AVAILABLE:
return SendResult(success=False, error="Not connected")
try:
target_id = chat_id
if metadata and metadata.get("thread_id"):
target_id = metadata["thread_id"]
channel = self._client.get_channel(int(target_id))
if not channel:
channel = await self._client.fetch_channel(int(target_id))
# Embed description limit is 4096; message usually fits easily.
max_desc = 4088
body = message if len(message) <= max_desc else message[: max_desc - 3] + "..."
embed = discord.Embed(
title=title or "Confirm",
description=body,
color=discord.Color.orange(),
)
view = SlashConfirmView(
session_key=session_key,
confirm_id=confirm_id,
allowed_user_ids=self._allowed_user_ids,
)
msg = await channel.send(embed=embed, view=view)
return SendResult(success=True, message_id=str(msg.id))
except Exception as e:
return SendResult(success=False, error=str(e))
async def send_update_prompt(
self, chat_id: str, prompt: str, default: str = "",
session_key: str = "",
@@ -3639,6 +3808,103 @@ if DISCORD_AVAILABLE:
for child in self.children:
child.disabled = True
class SlashConfirmView(discord.ui.View):
"""Three-button view for generic slash-command confirmations.
Used by ``/reload-mcp`` and any future slash command routed through
``GatewayRunner._request_slash_confirm``. Buttons map to the
gateway's three choices:
* "Approve Once" ``choice="once"``
* "Always Approve" ``choice="always"``
* "Cancel" ``choice="cancel"``
Clicking calls the module-level
``tools.slash_confirm.resolve(session_key, confirm_id, choice)``
which runs the handler the runner stored for this ``session_key``.
Only users in the adapter's allowlist can click. Times out after
5 minutes (matches the gateway primitive's timeout).
"""
def __init__(self, session_key: str, confirm_id: str, allowed_user_ids: set):
super().__init__(timeout=300)
self.session_key = session_key
self.confirm_id = confirm_id
self.allowed_user_ids = allowed_user_ids
self.resolved = False
def _check_auth(self, interaction: discord.Interaction) -> bool:
if not self.allowed_user_ids:
return True
return str(interaction.user.id) in self.allowed_user_ids
async def _resolve(
self, interaction: discord.Interaction, choice: str,
color: discord.Color, label: str,
):
if self.resolved:
await interaction.response.send_message(
"This prompt has already been resolved~", ephemeral=True,
)
return
if not self._check_auth(interaction):
await interaction.response.send_message(
"You're not authorized to answer this prompt~", ephemeral=True,
)
return
self.resolved = True
embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed:
embed.color = color
embed.set_footer(text=f"{label} by {interaction.user.display_name}")
for child in self.children:
child.disabled = True
await interaction.response.edit_message(embed=embed, view=self)
# Resolve via the module-level primitive. If the handler
# returns a follow-up message, post it in the same channel.
try:
from tools import slash_confirm as _slash_confirm_mod
result_text = await _slash_confirm_mod.resolve(
self.session_key, self.confirm_id, choice,
)
if result_text:
await interaction.followup.send(result_text)
logger.info(
"Discord button resolved slash-confirm for session %s "
"(choice=%s, user=%s)",
self.session_key, choice, interaction.user.display_name,
)
except Exception as exc:
logger.error("Discord slash-confirm resolve failed: %s", exc, exc_info=True)
@discord.ui.button(label="Approve Once", style=discord.ButtonStyle.green)
async def approve_once(
self, interaction: discord.Interaction, button: discord.ui.Button,
):
await self._resolve(interaction, "once", discord.Color.green(), "Approved once")
@discord.ui.button(label="Always Approve", style=discord.ButtonStyle.blurple)
async def approve_always(
self, interaction: discord.Interaction, button: discord.ui.Button,
):
await self._resolve(interaction, "always", discord.Color.purple(), "Always approved")
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.red)
async def cancel(
self, interaction: discord.Interaction, button: discord.ui.Button,
):
await self._resolve(interaction, "cancel", discord.Color.greyple(), "Cancelled")
async def on_timeout(self):
self.resolved = True
for child in self.children:
child.disabled = True
class UpdatePromptView(discord.ui.View):
"""Interactive Yes/No buttons for ``hermes update`` prompts.
+108 -1
View File
@@ -31,7 +31,7 @@ from email.mime.base import MIMEBase
from email.utils import formatdate
from email import encoders
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
from gateway.platforms.base import (
BasePlatformAdapter,
@@ -540,6 +540,113 @@ class EmailAdapter(BasePlatformAdapter):
text += f"\n\nImage: {image_url}"
return await self.send(chat_id, text.strip(), reply_to)
async def send_multiple_images(
self,
chat_id: str,
images: List[Tuple[str, str]],
metadata: Optional[Dict[str, Any]] = None,
human_delay: float = 0.0,
) -> None:
"""Send a batch of images as a single email with multiple MIME attachments.
Local files are attached directly. URL images have their URL
appended to the body (email adapter does not download remote
images). No hard cap email clients handle dozens of
attachments fine, subject to SMTP message size limits.
"""
if not images:
return
from urllib.parse import unquote as _unquote
body_parts: List[str] = []
local_paths: List[str] = []
for image_url, alt_text in images:
if alt_text:
body_parts.append(alt_text)
if image_url.startswith("file://"):
local_path = _unquote(image_url[7:])
if Path(local_path).exists():
local_paths.append(local_path)
else:
logger.warning("[Email] Skipping missing image: %s", local_path)
else:
# Remote URLs just get linked in the body (parity with send_image)
body_parts.append(f"Image: {image_url}")
if not local_paths and not body_parts:
return
body = "\n\n".join(body_parts)
try:
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None,
self._send_email_with_attachments,
chat_id,
body,
local_paths,
)
except Exception as e:
logger.error("[Email] Multi-image send failed, falling back: %s", e, exc_info=True)
await super().send_multiple_images(chat_id, images, metadata, human_delay)
def _send_email_with_attachments(
self,
to_addr: str,
body: str,
file_paths: List[str],
) -> str:
"""Send an email with multiple file attachments via SMTP."""
msg = MIMEMultipart()
msg["From"] = self._address
msg["To"] = to_addr
ctx = self._thread_context.get(to_addr, {})
subject = ctx.get("subject", "Hermes Agent")
if not subject.startswith("Re:"):
subject = f"Re: {subject}"
msg["Subject"] = subject
original_msg_id = ctx.get("message_id")
if original_msg_id:
msg["In-Reply-To"] = original_msg_id
msg["References"] = original_msg_id
msg["Date"] = formatdate(localtime=True)
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
msg["Message-ID"] = msg_id
if body:
msg.attach(MIMEText(body, "plain", "utf-8"))
for file_path in file_paths:
p = Path(file_path)
try:
with open(p, "rb") as f:
part = MIMEBase("application", "octet-stream")
part.set_payload(f.read())
encoders.encode_base64(part)
part.add_header("Content-Disposition", f"attachment; filename={p.name}")
msg.attach(part)
except Exception as e:
logger.warning("[Email] Failed to attach %s: %s", file_path, e)
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
try:
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
finally:
try:
smtp.quit()
except Exception:
smtp.close()
logger.info("[Email] Sent multi-attachment email to %s (%d files)", to_addr, len(file_paths))
return msg_id
async def send_document(
self,
chat_id: str,
+95 -1
View File
@@ -19,7 +19,7 @@ import logging
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
from gateway.config import Platform, PlatformConfig
from gateway.platforms.helpers import MessageDeduplicator
@@ -496,6 +496,100 @@ class MattermostAdapter(BasePlatformAdapter):
return SendResult(success=False, error="Failed to post with file")
return SendResult(success=True, message_id=data["id"])
async def send_multiple_images(
self,
chat_id: str,
images: List[Tuple[str, str]],
metadata: Optional[Dict[str, Any]] = None,
human_delay: float = 0.0,
) -> None:
"""Send a batch of images as a single Mattermost post with multiple attachments.
Mattermost supports up to 5 ``file_ids`` per post. Each image is
uploaded individually (Mattermost's file API is one-at-a-time),
then a single post is created referencing all uploaded file_ids
at once. Batches larger than 5 are chunked. Falls back to the
base per-image loop on total failure.
"""
if not images:
return
import mimetypes
import aiohttp
from urllib.parse import unquote as _unquote
CHUNK = 5 # Mattermost post file_ids cap
chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)]
for chunk_idx, chunk in enumerate(chunks):
if human_delay > 0 and chunk_idx > 0:
await asyncio.sleep(human_delay)
file_ids: List[str] = []
caption_parts: List[str] = []
try:
for image_url, alt_text in chunk:
if alt_text:
caption_parts.append(alt_text)
if image_url.startswith("file://"):
local_path = _unquote(image_url[7:])
p = Path(local_path)
if not p.exists():
logger.warning("Mattermost: skipping missing image %s", local_path)
continue
fname = p.name
ct = mimetypes.guess_type(fname)[0] or "image/png"
file_data = p.read_bytes()
else:
from tools.url_safety import is_safe_url
if not is_safe_url(image_url):
logger.warning("Mattermost: blocked unsafe image URL in batch")
continue
try:
async with self._session.get(
image_url, timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status >= 400:
logger.warning(
"Mattermost: failed to download image (HTTP %d): %s",
resp.status, image_url[:80],
)
continue
file_data = await resp.read()
ct = resp.content_type or "image/png"
except Exception as dl_err:
logger.warning("Mattermost: download failed for %s: %s", image_url[:80], dl_err)
continue
fname = image_url.rsplit("/", 1)[-1].split("?")[0] or f"image_{len(file_ids)}.png"
fid = await self._upload_file(chat_id, file_data, fname, ct)
if fid:
file_ids.append(fid)
if not file_ids:
continue
payload: Dict[str, Any] = {
"channel_id": chat_id,
"message": "\n".join(caption_parts),
"file_ids": file_ids,
}
logger.info(
"Mattermost: sending %d image(s) as single post (chunk %d/%d)",
len(file_ids), chunk_idx + 1, len(chunks),
)
data = await self._api_post("posts", payload)
if not data or "id" not in data:
logger.warning("Mattermost: multi-image post failed, falling back")
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
except Exception as e:
logger.warning(
"Mattermost: multi-image send failed (chunk %d/%d), falling back: %s",
chunk_idx + 1, len(chunks), e, exc_info=True,
)
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
# ------------------------------------------------------------------
# WebSocket
# ------------------------------------------------------------------
+23
View File
@@ -976,6 +976,18 @@ class QQAdapter(BasePlatformAdapter):
if not channel_id:
return
# Apply group_policy ACL — guild channels are group-like contexts.
# Without this check any member of any guild the bot is in could
# bypass the configured allowlist.
guild_id = str(d.get("guild_id", ""))
author_id = str(author.get("id", ""))
if not self._is_group_allowed(guild_id or channel_id, author_id):
logger.debug(
"[%s] Guild message blocked by ACL: channel=%s user=%s",
self._log_tag, channel_id, author_id,
)
return
member = d.get("member") if isinstance(d.get("member"), dict) else {}
nick = str(member.get("nick", "")) or str(author.get("username", ""))
@@ -1032,6 +1044,17 @@ class QQAdapter(BasePlatformAdapter):
if not guild_id:
return
# Apply dm_policy ACL — guild DMs were previously unauthenticated.
# Without this check any member of any guild the bot is in could
# bypass the configured allowlist via direct messages.
author_id = str(author.get("id", ""))
if not self._is_dm_allowed(author_id):
logger.debug(
"[%s] Guild DM blocked by ACL: guild=%s user=%s",
self._log_tag, guild_id, author_id,
)
return
text = content
att_result = await self._process_attachments(d.get("attachments"))
image_urls = att_result["image_urls"]
+203 -4
View File
@@ -21,7 +21,7 @@ import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Any
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import quote, unquote
import httpx
@@ -39,6 +39,17 @@ from gateway.platforms.base import (
cache_image_from_url,
)
from gateway.platforms.helpers import redact_phone
from gateway.platforms.signal_rate_limit import (
SIGNAL_BATCH_PACING_NOTICE_THRESHOLD,
SIGNAL_MAX_ATTACHMENTS_PER_MSG,
SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
SignalRateLimitError,
_extract_retry_after_seconds,
_format_wait,
_is_signal_rate_limit_error,
_signal_send_timeout,
get_scheduler,
)
logger = logging.getLogger(__name__)
@@ -53,6 +64,7 @@ SSE_RETRY_DELAY_MAX = 60.0
HEALTH_CHECK_INTERVAL = 30.0 # seconds between health checks
HEALTH_CHECK_STALE_THRESHOLD = 120.0 # seconds without SSE activity before concern
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -674,6 +686,8 @@ class SignalAdapter(BasePlatformAdapter):
rpc_id: str = None,
*,
log_failures: bool = True,
raise_on_rate_limit: bool = False,
timeout: float = 30.0,
) -> Any:
"""Send a JSON-RPC 2.0 request to signal-cli daemon.
@@ -682,6 +696,11 @@ class SignalAdapter(BasePlatformAdapter):
repeated NETWORK_FAILURE spam for unreachable recipients while
still preserving visibility for the first occurrence and for
unrelated RPCs.
When ``raise_on_rate_limit=True``, a Signal ``[429]`` /
``RateLimitException`` response raises ``SignalRateLimitError``
instead of being swallowed lets callers (multi-attachment send)
opt into backoff-retry without changing default behaviour.
"""
if not self.client:
logger.warning("Signal: RPC called but client not connected")
@@ -701,20 +720,28 @@ class SignalAdapter(BasePlatformAdapter):
resp = await self.client.post(
f"{self.http_url}/api/v1/rpc",
json=payload,
timeout=30.0,
timeout=timeout,
)
resp.raise_for_status()
data = resp.json()
if "error" in data:
err = data["error"]
if raise_on_rate_limit:
if _is_signal_rate_limit_error(err):
err_msg = str(err.get("message", "")) if isinstance(err, dict) else str(err)
retry_after = _extract_retry_after_seconds(err)
raise SignalRateLimitError(err_msg, retry_after=retry_after)
if log_failures:
logger.warning("Signal RPC error (%s): %s", method, data["error"])
logger.warning("Signal RPC error (%s): %s", method, err)
else:
logger.debug("Signal RPC error (%s): %s", method, data["error"])
logger.debug("Signal RPC error (%s): %s", method, err)
return None
return data.get("result")
except SignalRateLimitError:
raise
except Exception as e:
if log_failures:
logger.warning("Signal RPC %s failed: %s", method, e)
@@ -978,6 +1005,178 @@ class SignalAdapter(BasePlatformAdapter):
self._typing_failures.pop(chat_id, None)
self._typing_skip_until.pop(chat_id, None)
async def send_multiple_images(
self,
chat_id: str,
images: List[Tuple[str, str]],
metadata: Optional[Dict[str, Any]] = None,
human_delay: float = 0.0,
) -> None:
"""Send a batch of images via chunked Signal RPC calls.
Per-image alt texts are dropped Signal's send RPC only carries
one shared message body. Bad images (download failure, missing
file, oversize) are skipped with a warning so one bad URL
doesn't lose the rest of the batch. ``human_delay`` is ignored:
the rate-limit scheduler handles inter-batch pacing.
"""
if not images:
return
scheduler = get_scheduler()
logger.info(
"Signal send_multiple_images: received %d image(s) for %s"
"scheduler state: %s",
len(images), chat_id[:30], scheduler.state(),
)
await self._stop_typing_indicator(chat_id)
attachments: List[str] = []
skipped_download = 0
skipped_missing = 0
skipped_oversize = 0
for image_url, _alt_text in images:
if image_url.startswith("file://"):
file_path = unquote(image_url[7:])
else:
try:
file_path = await cache_image_from_url(image_url)
except Exception as e:
logger.warning("Signal: failed to download image %s: %s", image_url, e)
skipped_download += 1
continue
if not file_path or not Path(file_path).exists():
logger.warning("Signal: image file not found for %s", image_url)
skipped_missing += 1
continue
file_size = Path(file_path).stat().st_size
if file_size > SIGNAL_MAX_ATTACHMENT_SIZE:
logger.warning(
"Signal: image too large (%d bytes), skipping %s", file_size, image_url
)
skipped_oversize += 1
continue
attachments.append(file_path)
if not attachments:
logger.error(
"Signal: no valid images in batch of %d "
"(download=%d missing=%d oversize=%d)",
len(images), skipped_download, skipped_missing, skipped_oversize,
)
return
logger.info(
"Signal send_multiple_images: %d/%d images valid, sending in chunks",
len(attachments), len(images),
)
base_params: Dict[str, Any] = {
"account": self.account,
"message": "",
}
if chat_id.startswith("group:"):
base_params["groupId"] = chat_id[6:]
else:
base_params["recipient"] = [await self._resolve_recipient(chat_id)]
att_batches = [
attachments[i:i + SIGNAL_MAX_ATTACHMENTS_PER_MSG]
for i in range(0, len(attachments), SIGNAL_MAX_ATTACHMENTS_PER_MSG)
]
for idx, att_batch in enumerate(att_batches):
n = len(att_batch)
estimated = scheduler.estimate_wait(n)
logger.debug(
"Signal batch %d/%d: %d attachments, estimated wait=%.1fs",
idx + 1, len(att_batches), n, estimated,
)
if estimated >= SIGNAL_BATCH_PACING_NOTICE_THRESHOLD:
await self._notify_batch_pacing(
chat_id, idx + 1, len(att_batches), estimated
)
params = dict(base_params, attachments=att_batch)
send_timeout = _signal_send_timeout(n)
for attempt in range(1, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS + 1):
await scheduler.acquire(n)
try:
_rpc_t0 = time.monotonic()
result = await self._rpc(
"send", params, raise_on_rate_limit=True, timeout=send_timeout,
)
_rpc_duration = time.monotonic() - _rpc_t0
if result is not None:
self._track_sent_timestamp(result)
await scheduler.report_rpc_duration(_rpc_duration, n)
logger.info(
"Signal batch %d/%d: %d attachments sent in %.1fs "
"(attempt %d/%d)",
idx + 1, len(att_batches), n, _rpc_duration,
attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
)
else:
# Assume the server didn't accept the batch, don't deduce tokens
logger.error(
"Signal: RPC send failed for batch %d/%d (%d attachments, "
"attempt %d/%d, rpc_duration=%.1fs)",
idx + 1, len(att_batches), n,
attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
_rpc_duration,
)
# Retry transient (non-rate-limit) failures once
if attempt < SIGNAL_RATE_LIMIT_MAX_ATTEMPTS:
backoff = 2.0 ** attempt
logger.info(
"Signal: retrying batch %d/%d after %.1fs backoff",
idx + 1, len(att_batches), backoff,
)
await asyncio.sleep(backoff)
continue
break
except SignalRateLimitError as e:
scheduler.feedback(e.retry_after, n)
if attempt >= SIGNAL_RATE_LIMIT_MAX_ATTEMPTS:
logger.error(
"Signal: rate-limit retries exhausted on batch %d/%d "
"(%d attachments lost, server retry_after=%s)",
idx + 1, len(att_batches), n,
f"{e.retry_after:.0f}s" if e.retry_after else "unknown",
)
break
logger.warning(
"Signal: rate-limited on batch %d/%d "
"(attempt %d/%d, server retry_after=%s); "
"scheduler will pace the retry",
idx + 1, len(att_batches),
attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
f"{e.retry_after:.0f}s" if e.retry_after else "unknown",
)
async def _notify_batch_pacing(
self,
chat_id: str,
next_batch_idx: int,
total_batches: int,
wait_s: float,
) -> None:
"""Inform the user when an inter-batch pacing wait crosses the
notice threshold. Best-effort; logs and continues on failure."""
try:
await self.send(
chat_id,
f"(More images coming — pausing ~{_format_wait(wait_s)} "
f"for Signal rate limit, batch {next_batch_idx}/{total_batches}.)",
)
except Exception as e:
logger.warning("Signal: failed to send pacing notice: %s", e)
async def send_image(
self,
chat_id: str,
+369
View File
@@ -0,0 +1,369 @@
"""
Signal attachment rate-limit scheduler.
Process-wide token-bucket simulator that mirrors the per-account
attachment rate limit signal-cli/Signal-Server enforce. Producers
(``SignalAdapter.send_multiple_images`` and the ``send_message`` tool's
Signal path) call ``acquire(n)`` before an attachment send; on a 429
they call ``feedback(retry_after, n)`` so the model recalibrates from
the server's authoritative hint.
The scheduler serializes concurrent calls through an ``asyncio.Lock``,
giving FIFO fairness across agent sessions sharing one signal-cli
daemon.
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
from typing import Any, Optional
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SIGNAL_MAX_ATTACHMENTS_PER_MSG = 32 # per-message attachment cap (source: Signal-{Android,Desktop} source code)
SIGNAL_RATE_LIMIT_BUCKET_CAPACITY = 50 # server-side token-bucket capacity for attachments rate limiting
SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER = 4 # fallback token refill interval for signal-cli < v0.14.3
SIGNAL_RATE_LIMIT_MAX_ATTEMPTS = 2 # initial attempt + 1 retry
SIGNAL_BATCH_PACING_NOTICE_THRESHOLD = 10.0 # if estimated waiting time > 10s, notify the user about the delay
SIGNAL_RPC_ERROR_RATELIMIT = -5 # signal-cli (v0.14.3+) JSON-RPC error code for RateLimitException
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class SignalRateLimitError(Exception):
"""
Raised by ``SignalAdapter._rpc`` for rate-limit responses when the
caller has opted in via ``raise_on_rate_limit=True``.
Carries the server-supplied per-token Retry-After (in seconds) on
signal-cli v0.14.3
``retry_after`` is None when the version doesn't expose it.
"""
def __init__(self, message: str, retry_after: Optional[float] = None) -> None:
super().__init__(message)
self.retry_after = retry_after
class SignalSchedulerError(Exception):
pass
# ---------------------------------------------------------------------------
# Detection helpers — used to fish a 429 out of signal-cli's various error
# shapes (typed code, [429] substring, libsignal-net RetryLaterException
# leaked through AttachmentInvalidException).
# ---------------------------------------------------------------------------
# "Retry after 4 seconds" / "retry after 4 second" — libsignal-net's
# RetryLaterException string form, surfaced when 429s hit during
# attachment upload (signal-cli wraps these as AttachmentInvalidException
# rather than RateLimitException, so the typed path doesn't fire).
_RETRY_AFTER_RE = re.compile(r"Retry after (\d+(?:\.\d+)?)\s*second", re.IGNORECASE)
def _extract_retry_after_seconds(err: Any) -> Optional[float]:
"""Pull the per-token Retry-After window from a signal-cli rate-limit error.
Tries two sources, in order:
1. ``error.data.response.results[*].retryAfterSeconds`` the
structured field signal-cli v0.14.3 surfaces for plain
RateLimitException.
2. ``"Retry after N seconds"`` parsed out of the message covers
libsignal-net's RetryLaterException that gets wrapped as
AttachmentInvalidException during attachment upload, where the
structured field stays null.
Returns None when neither yields a value.
"""
msg = ""
if isinstance(err, dict):
data = err.get("data") or {}
response = data.get("response") or {}
results = response.get("results") or []
candidates = [
r.get("retryAfterSeconds") for r in results
if isinstance(r, dict) and r.get("retryAfterSeconds")
]
if candidates:
return float(max(candidates))
msg = str(err.get("message", ""))
else:
msg = str(err)
match = _RETRY_AFTER_RE.search(msg)
return float(match.group(1)) if match else None
def _is_signal_rate_limit_error(err: Any) -> bool:
"""True if a signal-cli RPC error reflects a rate-limit failure.
Matches three layers:
- typed ``RATELIMIT_ERROR`` code (signal-cli v0.14.3, plain
RateLimitException)
- legacy ``[429] / RateLimitException`` substrings
- libsignal-net's ``RetryLaterException`` / ``Retry after N seconds``
surfaced inside ``AttachmentInvalidException`` when the rate
limit is hit during attachment upload signal-cli never re-tags
these as RateLimitException, so substring is the only signal.
"""
if isinstance(err, dict) and err.get("code") == SIGNAL_RPC_ERROR_RATELIMIT:
return True
message = (
str(err.get("message", ""))
if isinstance(err, dict)
else str(err)
)
msg_lower = message.lower()
return (
"[429]" in message
or "ratelimit" in msg_lower
or "retrylaterexception" in msg_lower
or "retry after" in msg_lower
)
# ---------------------------------------------------------------------------
# Misc helpers
# ---------------------------------------------------------------------------
def _format_wait(seconds: float) -> str:
"""Human-friendly wait label for user-facing pacing notices."""
s = max(0.0, seconds)
if s < 90:
return f"{int(round(s))}s"
return f"{max(1, int(round(s / 60)))} min"
def _signal_send_timeout(num_attachments: int) -> float:
"""HTTP timeout for a Signal ``send`` RPC.
signal-cli uploads attachments serially during the call, so the
server-side time scales with batch size. Default 30s is fine for
text-only sends but truncates large attachment batches mid-upload
we then log a phantom failure even though signal-cli completes the
send a few seconds later. Scale at 5s/attachment with a 60s floor.
"""
if num_attachments <= 0:
return 30.0
return max(60.0, 5.0 * num_attachments)
# ---------------------------------------------------------------------------
# Scheduler
# ---------------------------------------------------------------------------
class SignalAttachmentScheduler:
"""Process-wide token-bucket simulator for Signal attachment sends.
The bucket holds up to ``capacity`` tokens (default 50, matching
Signal's server-side rate-limit bucket size). Each attachment consumes one
token. Tokens refill at ``refill_rate`` tokens/second, calibrated
from the per-token Retry-After hint we get from the server when a
429 fires. Until we've observed one, we use the documented default
(1 token / 4 seconds).
Concurrent ``acquire(n)`` calls serialize through an
``asyncio.Lock`` natural FIFO across agent sessions hitting the
same daemon.
"""
def __init__(
self,
capacity: float = float(SIGNAL_RATE_LIMIT_BUCKET_CAPACITY),
default_retry_after: float = float(SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER),
) -> None:
self.capacity = float(capacity)
self.tokens = float(capacity)
self.refill_rate = 1.0 / float(default_retry_after)
self.last_refill = time.monotonic()
self._lock = asyncio.Lock()
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _refill(self) -> None:
now = time.monotonic()
elapsed = now - self.last_refill
if elapsed > 0 and self.tokens < self.capacity:
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
self.last_refill = now
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def estimate_wait(self, n: int) -> float:
"""Best-effort estimate of the seconds until ``n`` tokens would
be available. Used to decide whether to emit a user-facing
pacing notice *before* committing to an ``acquire`` that may
block silently. Lock-free; small races vs. concurrent acquires
are benign for an informational notice.
"""
now = time.monotonic()
elapsed = now - self.last_refill
projected = self.tokens
if elapsed > 0 and projected < self.capacity:
projected = min(self.capacity, projected + elapsed * self.refill_rate)
deficit = n - projected
if deficit <= 0:
return 0.0
return deficit / self.refill_rate
async def acquire(self, n: int) -> float:
"""Block until at least ``n`` tokens are available, return the
seconds slept.
Does **not** deduct tokens the bucket is a read-only model of
server-side capacity. Call ``report_rpc_duration()`` after the
RPC to synchronise the model with the server timeline.
Not perfect in case lots of coroutines try to acquire for big
uploads (``report_rpc_duration`` will take a long time to get hit)
but this is just a simulation. Signal server is ground truth and
will raise rate-limit exceptions triggering requeues.
The lock is released during ``asyncio.sleep`` so other callers
can interleave. A retry loop re-checks after each sleep in
case the deadline was pessimistic.
"""
if n <= 0:
return 0.0
if n > self.capacity:
raise SignalSchedulerError(
f"Signal scheduler was called requesting {n} tokens "
f"(max is {self.capacity})",
)
total_slept = 0.0
first_pass = True
while True:
async with self._lock:
self._refill()
if self.tokens >= n:
if not first_pass or total_slept > 0:
logger.debug(
"Signal scheduler: tokens sufficient for %d "
"(remaining=%.1f, total_slept=%.1fs)",
n, self.tokens, total_slept,
)
return total_slept
deficit = n - self.tokens
wait = deficit / self.refill_rate
if first_pass:
logger.info(
"Signal scheduler: pausing %.1fs for %d tokens "
"(available=%.1f, deficit=%.1f, refill=%.4f/s ≈ %.1fs/token)",
wait, n, self.tokens, deficit,
self.refill_rate, 1.0 / self.refill_rate,
)
first_pass = False
await asyncio.sleep(wait)
total_slept += wait
async def report_rpc_duration(self, rpc_duration: float, n_attachments: int) -> None:
"""Record an attachment-send RPC that just completed.
Deducts ``n_attachments`` tokens without crediting refill during
the upload window. Signal's server checks the bucket at RPC start
and does *not* refill during request processing refill resumes
after the response. Crediting upload-time refill causes cumulative
drift that eventually triggers 429s.
Advances ``last_refill`` so the next ``acquire`` / ``_refill``
starts counting from this point.
"""
if n_attachments <= 0:
return
async with self._lock:
now = time.monotonic()
token_before = self.tokens
self.tokens = max(0.0, token_before - float(n_attachments))
self.last_refill = now
logger.log(
logging.INFO if rpc_duration > 10 and n_attachments > 5 else logging.DEBUG,
"Signal scheduler: RPC for %d att took %.1fs — "
"tokens %.1f%.1f (deducted=%d, no upload refill credited, refill=%.4fs⁻¹)",
n_attachments, rpc_duration,
token_before, self.tokens,
n_attachments, self.refill_rate,
)
def feedback(self, retry_after: Optional[float], n_attempted: int) -> None:
"""Apply server feedback after a 429.
``retry_after`` is the per-*token* refill window the server
reports (None when signal-cli is older than v0.14.3 and didn't
surface it).
When present we calibrate ``refill_rate`` from it:
the server is authoritative.
"""
if retry_after and retry_after > 0:
new_rate = 1.0 / float(retry_after)
if new_rate != self.refill_rate:
logger.info(
"Signal scheduler: calibrating refill_rate to %.4f tokens/sec "
"(server retry_after=%.1fs per token)",
new_rate, retry_after,
)
self.refill_rate = new_rate
self.tokens = 0.0
self.last_refill = time.monotonic()
def state(self) -> dict:
"""Return current scheduler state for diagnostic logging (read-only).
Does not advance ``last_refill`` safe to call from logging paths
without perturbing the bucket.
"""
now = time.monotonic()
elapsed = now - self.last_refill
projected = self.tokens
if elapsed > 0 and projected < self.capacity:
projected = min(self.capacity, projected + elapsed * self.refill_rate)
return {
"tokens": round(projected, 1),
"capacity": int(self.capacity),
"refill_rate": round(self.refill_rate, 4),
"refill_seconds_per_token": round(1.0 / self.refill_rate, 1) if self.refill_rate > 0 else float("inf"),
}
# ---------------------------------------------------------------------------
# Process-wide singleton
# ---------------------------------------------------------------------------
_scheduler: Optional[SignalAttachmentScheduler] = None
def get_scheduler() -> SignalAttachmentScheduler:
"""Return the process-wide scheduler, creating it on first access."""
global _scheduler
if _scheduler is None:
_scheduler = SignalAttachmentScheduler()
logger.info(
"Signal scheduler: created (capacity=%d tokens, refill=%.4f/s ≈ %.1fs/token)",
int(_scheduler.capacity),
_scheduler.refill_rate,
1.0 / _scheduler.refill_rate,
)
return _scheduler
def _reset_scheduler() -> None:
"""Drop the cached scheduler so the next ``get_scheduler`` call
builds a fresh one. Test-only never call from production paths."""
global _scheduler
_scheduler = None
+276
View File
@@ -514,6 +514,15 @@ class SlackAdapter(BasePlatformAdapter):
):
self._app.action(_action_id)(self._handle_approval_action)
# Register Block Kit action handlers for slash-confirm buttons
# (generic three-option prompts; see tools/slash_confirm.py).
for _action_id in (
"hermes_confirm_once",
"hermes_confirm_always",
"hermes_confirm_cancel",
):
self._app.action(_action_id)(self._handle_slash_confirm_action)
# Start Socket Mode handler in background
self._handler = AsyncSocketModeHandler(self._app, app_token, proxy=proxy_url)
_apply_slack_proxy(self._handler.client, proxy_url)
@@ -783,6 +792,111 @@ class SlackAdapter(BasePlatformAdapter):
raise last_exc
async def send_multiple_images(
self,
chat_id: str,
images: List[Tuple[str, str]],
metadata: Optional[Dict[str, Any]] = None,
human_delay: float = 0.0,
) -> None:
"""Send a batch of images as a single Slack message with multiple file uploads.
Uses ``files_upload_v2`` with its ``file_uploads`` parameter so all
images show up attached to one ``initial_comment`` message instead
of N separate messages. Falls back to the base per-image loop on
any failure.
The batch limit is 10 file uploads per call (Slack server-side cap).
"""
if not self._app:
return
if not images:
return
try:
import httpx as _httpx
from urllib.parse import unquote as _unquote
from tools.url_safety import is_safe_url as _is_safe_url
except Exception:
await super().send_multiple_images(chat_id, images, metadata, human_delay)
return
thread_ts = self._resolve_thread_ts(None, metadata)
CHUNK = 10
chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)]
for chunk_idx, chunk in enumerate(chunks):
if human_delay > 0 and chunk_idx > 0:
await asyncio.sleep(human_delay)
file_uploads: List[Dict[str, Any]] = []
initial_comment_parts: List[str] = []
try:
async with _httpx.AsyncClient(timeout=30.0, follow_redirects=True) as http_client:
for image_url, alt_text in chunk:
if alt_text:
initial_comment_parts.append(alt_text)
if image_url.startswith("file://"):
local_path = _unquote(image_url[7:])
if not os.path.exists(local_path):
logger.warning("[Slack] Skipping missing image: %s", local_path)
continue
file_uploads.append({
"file": local_path,
"filename": os.path.basename(local_path),
})
else:
if not _is_safe_url(image_url):
logger.warning("[Slack] Blocked unsafe image URL in batch")
continue
try:
response = await http_client.get(image_url)
response.raise_for_status()
ext = "png"
ct = response.headers.get("content-type", "")
if "jpeg" in ct or "jpg" in ct:
ext = "jpg"
elif "gif" in ct:
ext = "gif"
elif "webp" in ct:
ext = "webp"
file_uploads.append({
"content": response.content,
"filename": f"image_{len(file_uploads)}.{ext}",
})
except Exception as dl_err:
logger.warning(
"[Slack] Download failed for %s: %s",
safe_url_for_log(image_url), dl_err,
)
continue
if not file_uploads:
continue
initial_comment = "\n".join(initial_comment_parts) if initial_comment_parts else ""
logger.info(
"[Slack] Sending %d image(s) in single files_upload_v2 (chunk %d/%d)",
len(file_uploads), chunk_idx + 1, len(chunks),
)
result = await self._get_client(chat_id).files_upload_v2(
channel=chat_id,
file_uploads=file_uploads,
initial_comment=initial_comment,
thread_ts=thread_ts,
)
self._record_uploaded_file_thread(chat_id, thread_ts)
_ = result
except Exception as e:
logger.warning(
"[Slack] Multi-image files_upload_v2 failed (chunk %d/%d), falling back to per-image: %s",
chunk_idx + 1, len(chunks), e,
exc_info=True,
)
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
def _record_uploaded_file_thread(self, chat_id: str, thread_ts: Optional[str]) -> None:
"""Treat successful file uploads as bot participation in a thread."""
if not thread_ts:
@@ -1931,6 +2045,168 @@ class SlackAdapter(BasePlatformAdapter):
logger.error("[Slack] send_exec_approval failed: %s", e, exc_info=True)
return SendResult(success=False, error=str(e))
async def send_slash_confirm(
self, chat_id: str, title: str, message: str, session_key: str,
confirm_id: str, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a Block Kit three-option slash-command confirmation prompt."""
if not self._app:
return SendResult(success=False, error="Not connected")
try:
body = message[:2900] + "..." if len(message) > 2900 else message
thread_ts = self._resolve_thread_ts(None, metadata)
# Encode session_key and confirm_id into the button value so the
# callback handler can resolve without extra bookkeeping.
value = f"{session_key}|{confirm_id}"
blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*{title or 'Confirm'}*\n\n{body}",
},
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "Approve Once"},
"style": "primary",
"action_id": "hermes_confirm_once",
"value": value,
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Always Approve"},
"action_id": "hermes_confirm_always",
"value": value,
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Cancel"},
"style": "danger",
"action_id": "hermes_confirm_cancel",
"value": value,
},
],
},
]
kwargs: Dict[str, Any] = {
"channel": chat_id,
"text": f"{title or 'Confirm'}: {body[:100]}",
"blocks": blocks,
}
if thread_ts:
kwargs["thread_ts"] = thread_ts
result = await self._get_client(chat_id).chat_postMessage(**kwargs)
return SendResult(success=True, message_id=result.get("ts", ""), raw_response=result)
except Exception as e:
logger.error("[Slack] send_slash_confirm failed: %s", e, exc_info=True)
return SendResult(success=False, error=str(e))
async def _handle_slash_confirm_action(self, ack, body, action) -> None:
"""Handle a slash-confirm button click from Block Kit."""
await ack()
action_id = action.get("action_id", "")
value = action.get("value", "")
message = body.get("message", {})
msg_ts = message.get("ts", "")
channel_id = body.get("channel", {}).get("id", "")
user_name = body.get("user", {}).get("name", "unknown")
user_id = body.get("user", {}).get("id", "")
# Authorization — reuse the exec-approval allowlist.
allowed_csv = os.getenv("SLACK_ALLOWED_USERS", "").strip()
if allowed_csv:
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
if "*" not in allowed_ids and user_id not in allowed_ids:
logger.warning(
"[Slack] Unauthorized slash-confirm click by %s (%s) — ignoring",
user_name, user_id,
)
return
# Parse session_key|confirm_id back out
if "|" not in value:
logger.warning("[Slack] Malformed slash-confirm value: %s", value)
return
session_key, confirm_id = value.split("|", 1)
choice_map = {
"hermes_confirm_once": "once",
"hermes_confirm_always": "always",
"hermes_confirm_cancel": "cancel",
}
choice = choice_map.get(action_id, "cancel")
label_map = {
"once": f"✅ Approved once by {user_name}",
"always": f"🔒 Always approved by {user_name}",
"cancel": f"❌ Cancelled by {user_name}",
}
decision_text = label_map.get(choice, f"Resolved by {user_name}")
# Pull original prompt body out of the section block so we can show
# the decision inline without losing context.
original_text = ""
for block in message.get("blocks", []):
if block.get("type") == "section":
original_text = block.get("text", {}).get("text", "")
break
updated_blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": original_text or "Confirmation prompt",
},
},
{
"type": "context",
"elements": [
{"type": "mrkdwn", "text": decision_text},
],
},
]
try:
await self._get_client(channel_id).chat_update(
channel=channel_id,
ts=msg_ts,
text=decision_text,
blocks=updated_blocks,
)
except Exception as e:
logger.warning("[Slack] Failed to update slash-confirm message: %s", e)
# Resolve via the module-level primitive and post any follow-up.
try:
from tools import slash_confirm as _slash_confirm_mod
result_text = await _slash_confirm_mod.resolve(session_key, confirm_id, choice)
if result_text:
post_kwargs: Dict[str, Any] = {
"channel": channel_id,
"text": result_text,
}
# Inherit the thread so the reply stays in the same place.
thread_ts = message.get("thread_ts") or msg_ts
if thread_ts:
post_kwargs["thread_ts"] = thread_ts
await self._get_client(channel_id).chat_postMessage(**post_kwargs)
logger.info(
"Slack button resolved slash-confirm for session %s (choice=%s, user=%s)",
session_key, choice, user_name,
)
except Exception as exc:
logger.error("Failed to resolve slash-confirm from Slack button: %s", exc, exc_info=True)
async def _handle_approval_action(self, ack, body, action) -> None:
"""Handle an approval button click from Block Kit."""
await ack()
+245 -16
View File
@@ -237,14 +237,14 @@ def _wrap_markdown_tables(text: str) -> str:
class TelegramAdapter(BasePlatformAdapter):
"""
Telegram bot adapter.
Handles:
- Receiving messages from users and groups
- Sending responses with Telegram markdown
- Forum topics (thread_id support)
- Media messages
"""
# Telegram message limits
MAX_MESSAGE_LENGTH = 4096
# Threshold for detecting Telegram client-side message splits.
@@ -252,7 +252,7 @@ class TelegramAdapter(BasePlatformAdapter):
_SPLIT_THRESHOLD = 4000
MEDIA_GROUP_WAIT_SECONDS = 0.8
_GENERAL_TOPIC_THREAD_ID = "1"
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.TELEGRAM)
self._app: Optional[Application] = None
@@ -286,6 +286,9 @@ class TelegramAdapter(BasePlatformAdapter):
self._model_picker_state: Dict[str, dict] = {}
# Approval button state: message_id → session_key
self._approval_state: Dict[int, str] = {}
# Slash-confirm button state: confirm_id → session_key (for /reload-mcp
# and any other slash-confirm prompts; see GatewayRunner._request_slash_confirm).
self._slash_confirm_state: Dict[str, str] = {}
@staticmethod
def _is_callback_user_authorized(user_id: str) -> bool:
@@ -994,7 +997,7 @@ class TelegramAdapter(BasePlatformAdapter):
self._set_fatal_error("telegram_connect_error", message, retryable=True)
logger.error("[%s] Failed to connect to Telegram: %s", self.name, e, exc_info=True)
return False
async def disconnect(self) -> None:
"""Stop polling/webhook, cancel pending album flushes, and disconnect."""
pending_media_group_tasks = list(self._media_group_tasks.values())
@@ -1411,6 +1414,48 @@ class TelegramAdapter(BasePlatformAdapter):
logger.warning("[%s] send_exec_approval failed: %s", self.name, e)
return SendResult(success=False, error=str(e))
async def send_slash_confirm(
self, chat_id: str, title: str, message: str, session_key: str,
confirm_id: str, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Render a three-button slash-command confirmation prompt."""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
# Message body: render as plain text (message already contains
# markdown formatting from the gateway primitive).
preview = message if len(message) <= 3800 else message[:3800] + "..."
keyboard = InlineKeyboardMarkup([
[
InlineKeyboardButton("✅ Approve Once", callback_data=f"sc:once:{confirm_id}"),
InlineKeyboardButton("🔒 Always Approve", callback_data=f"sc:always:{confirm_id}"),
],
[
InlineKeyboardButton("❌ Cancel", callback_data=f"sc:cancel:{confirm_id}"),
],
])
thread_id = self._metadata_thread_id(metadata)
kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"text": preview,
"parse_mode": ParseMode.MARKDOWN,
"reply_markup": keyboard,
**self._link_preview_kwargs(),
}
message_thread_id = self._message_thread_id_for_send(thread_id)
if message_thread_id is not None:
kwargs["message_thread_id"] = message_thread_id
msg = await self._bot.send_message(**kwargs)
self._slash_confirm_state[confirm_id] = session_key
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.warning("[%s] send_slash_confirm failed: %s", self.name, e)
return SendResult(success=False, error=str(e))
async def send_model_picker(
self,
chat_id: str,
@@ -1779,6 +1824,68 @@ class TelegramAdapter(BasePlatformAdapter):
logger.error("Failed to resolve gateway approval from Telegram button: %s", exc)
return
# --- Slash-confirm callbacks (sc:choice:confirm_id) ---
if data.startswith("sc:"):
parts = data.split(":", 2)
if len(parts) == 3:
choice = parts[1] # once, always, cancel
confirm_id = parts[2]
caller_id = str(getattr(query.from_user, "id", ""))
if not self._is_callback_user_authorized(caller_id):
await query.answer(text="⛔ You are not authorized to answer this prompt.")
return
session_key = self._slash_confirm_state.pop(confirm_id, None)
if not session_key:
await query.answer(text="This prompt has already been resolved.")
return
label_map = {
"once": "✅ Approved once",
"always": "🔒 Always approve",
"cancel": "❌ Cancelled",
}
user_display = getattr(query.from_user, "first_name", "User")
label = label_map.get(choice, "Resolved")
await query.answer(text=label)
try:
await query.edit_message_text(
text=f"{label} by {user_display}",
parse_mode=ParseMode.MARKDOWN,
reply_markup=None,
)
except Exception:
pass
# Resolve via the module-level primitive. The runner stored
# a handler keyed by session_key; we run it on the event
# loop and (if it returns a string) send it as a follow-up
# message in the same chat.
try:
from tools import slash_confirm as _slash_confirm_mod
result_text = await _slash_confirm_mod.resolve(
session_key, confirm_id, choice,
)
if result_text and query.message:
# Inherit the prompt message's thread so the reply
# lands in the same supergroup topic / reply chain.
thread_id = getattr(query.message, "message_thread_id", None)
send_kwargs: Dict[str, Any] = {
"chat_id": int(query.message.chat_id),
"text": result_text,
"parse_mode": ParseMode.MARKDOWN,
**self._link_preview_kwargs(),
}
if thread_id is not None:
send_kwargs["message_thread_id"] = thread_id
await self._bot.send_message(**send_kwargs)
except Exception as exc:
logger.error("[%s] slash-confirm callback failed: %s", self.name, exc, exc_info=True)
return
# --- Update prompt callbacks ---
if not data.startswith("update_prompt:"):
return
@@ -1844,8 +1951,9 @@ class TelegramAdapter(BasePlatformAdapter):
return SendResult(success=False, error=self._missing_media_path_error("Audio", audio_path))
with open(audio_path, "rb") as audio_file:
# .ogg files -> send as voice (round playable bubble)
if audio_path.endswith((".ogg", ".opus")):
ext = os.path.splitext(audio_path)[1].lower()
# .ogg / .opus files -> send as voice (round playable bubble)
if ext in (".ogg", ".opus"):
_voice_thread = self._metadata_thread_id(metadata)
msg = await self._bot.send_voice(
chat_id=int(chat_id),
@@ -1854,8 +1962,8 @@ class TelegramAdapter(BasePlatformAdapter):
reply_to_message_id=int(reply_to) if reply_to else None,
message_thread_id=self._message_thread_id_for_send(_voice_thread),
)
else:
# .mp3 and others -> send as audio file
elif ext in (".mp3", ".m4a"):
# Telegram's Bot API sendAudio only accepts MP3 / M4A.
_audio_thread = self._metadata_thread_id(metadata)
msg = await self._bot.send_audio(
chat_id=int(chat_id),
@@ -1864,6 +1972,16 @@ class TelegramAdapter(BasePlatformAdapter):
reply_to_message_id=int(reply_to) if reply_to else None,
message_thread_id=self._message_thread_id_for_send(_audio_thread),
)
else:
# Formats Telegram can't play natively (.wav, .flac, ...)
# — fall back to document delivery instead of raising.
return await self.send_document(
chat_id=chat_id,
file_path=audio_path,
caption=caption,
reply_to=reply_to,
metadata=metadata,
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.error(
@@ -1873,7 +1991,118 @@ class TelegramAdapter(BasePlatformAdapter):
exc_info=True,
)
return await super().send_voice(chat_id, audio_path, caption, reply_to)
async def send_multiple_images(
self,
chat_id: str,
images: List[tuple],
metadata: Optional[Dict[str, Any]] = None,
human_delay: float = 0.0,
) -> None:
"""Send a batch of images natively via Telegram's media group API.
Telegram's ``send_media_group`` bundles up to 10 photos/videos into
a single album. Larger batches are chunked. Animated GIFs cannot
go into a media group (they require ``send_animation``), so they
are peeled off and sent individually via the base default path.
URL-based photos go into the group directly; local files are
opened as byte streams. On failure the whole batch falls back to
the base adapter's per-image loop.
"""
if not self._bot:
return
if not images:
return
try:
from telegram import InputMediaPhoto
except Exception as exc: # pragma: no cover - missing SDK
logger.warning(
"[%s] InputMediaPhoto unavailable, falling back to per-image send: %s",
self.name, exc,
)
await super().send_multiple_images(chat_id, images, metadata, human_delay)
return
# Peel off animations — they need send_animation, not send_media_group
animations: List[tuple] = []
photos: List[tuple] = []
for image_url, alt_text in images:
if not image_url.startswith("file://") and self._is_animation_url(image_url):
animations.append((image_url, alt_text))
else:
photos.append((image_url, alt_text))
# Animations: route through the base default (per-image send_animation)
if animations:
await super().send_multiple_images(
chat_id, animations, metadata, human_delay=human_delay,
)
if not photos:
return
from urllib.parse import unquote as _unquote
_thread = self._metadata_thread_id(metadata)
_thread_id = self._message_thread_id_for_send(_thread)
# Chunk into groups of 10 (Telegram's album limit)
CHUNK = 10
chunks = [photos[i:i + CHUNK] for i in range(0, len(photos), CHUNK)]
for chunk_idx, chunk in enumerate(chunks):
if human_delay > 0 and chunk_idx > 0:
await asyncio.sleep(human_delay)
media: List[Any] = []
opened_files: List[Any] = []
try:
for image_url, alt_text in chunk:
caption = alt_text[:1024] if alt_text else None
if image_url.startswith("file://"):
local_path = _unquote(image_url[7:])
if not os.path.exists(local_path):
logger.warning(
"[%s] Skipping missing image in media group: %s",
self.name, local_path,
)
continue
fh = open(local_path, "rb")
opened_files.append(fh)
media.append(InputMediaPhoto(media=fh, caption=caption))
else:
media.append(InputMediaPhoto(media=image_url, caption=caption))
if not media:
continue
logger.info(
"[%s] Sending media group of %d photo(s) (chunk %d/%d)",
self.name, len(media), chunk_idx + 1, len(chunks),
)
await self._bot.send_media_group(
chat_id=int(chat_id),
media=media,
message_thread_id=_thread_id,
)
except Exception as e:
logger.warning(
"[%s] send_media_group failed (chunk %d/%d), falling back to per-image: %s",
self.name, chunk_idx + 1, len(chunks), e,
exc_info=True,
)
# Fallback: send each photo in this chunk individually
await super().send_multiple_images(
chat_id, chunk, metadata, human_delay=human_delay,
)
finally:
for fh in opened_files:
try:
fh.close()
except Exception:
pass
async def send_image_file(
self,
chat_id: str,
@@ -2040,7 +2269,7 @@ class TelegramAdapter(BasePlatformAdapter):
)
# Final fallback: send URL as text
return await super().send_image(chat_id, image_url, caption, reply_to)
async def send_animation(
self,
chat_id: str,
@@ -2102,7 +2331,7 @@ class TelegramAdapter(BasePlatformAdapter):
e,
exc_info=True,
)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Get information about a Telegram chat."""
if not self._bot:
@@ -2136,7 +2365,7 @@ class TelegramAdapter(BasePlatformAdapter):
exc_info=True,
)
return {"name": str(chat_id), "type": "dm", "error": str(e)}
def format_message(self, content: str) -> str:
"""
Convert standard markdown to Telegram MarkdownV2 format.
@@ -2308,7 +2537,7 @@ class TelegramAdapter(BasePlatformAdapter):
text = ''.join(_safe_parts)
return text
# ── Group mention gating ──────────────────────────────────────────────
def _telegram_require_mention(self) -> bool:
@@ -2523,7 +2752,7 @@ class TelegramAdapter(BasePlatformAdapter):
event = self._build_message_event(update.message, MessageType.TEXT, update_id=update.update_id)
event.text = self._clean_bot_trigger_text(event.text)
self._enqueue_text_event(event)
async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle incoming command messages."""
if not update.message or not update.message.text:
@@ -2533,7 +2762,7 @@ class TelegramAdapter(BasePlatformAdapter):
event = self._build_message_event(update.message, MessageType.COMMAND, update_id=update.update_id)
await self.handle_message(event)
async def _handle_location_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle incoming location/venue pin messages."""
if not update.message:
@@ -2891,7 +3120,7 @@ class TelegramAdapter(BasePlatformAdapter):
return
await self.handle_message(event)
async def _queue_media_group_event(self, media_group_id: str, event: MessageEvent) -> None:
"""Buffer Telegram media-group items so albums arrive as one logical event.
+16 -20
View File
@@ -202,26 +202,22 @@ class WebhookAdapter(BasePlatformAdapter):
if deliver_type == "github_comment":
return await self._deliver_github_comment(content, delivery)
# Cross-platform delivery — any platform with a gateway adapter
if self.gateway_runner and deliver_type in (
"telegram",
"discord",
"slack",
"signal",
"sms",
"whatsapp",
"matrix",
"mattermost",
"homeassistant",
"email",
"dingtalk",
"feishu",
"wecom",
"wecom_callback",
"weixin",
"bluebubbles",
"qqbot",
):
# Cross-platform delivery — any platform with a gateway adapter.
# Check both built-in names and plugin-registered platforms.
_BUILTIN_DELIVER_PLATFORMS = {
"telegram", "discord", "slack", "signal", "sms", "whatsapp",
"matrix", "mattermost", "homeassistant", "email", "dingtalk",
"feishu", "wecom", "wecom_callback", "weixin", "bluebubbles",
"qqbot", "yuanbao",
}
_is_known_platform = deliver_type in _BUILTIN_DELIVER_PLATFORMS
if not _is_known_platform:
try:
from gateway.platform_registry import platform_registry
_is_known_platform = platform_registry.is_registered(deliver_type)
except Exception:
pass
if self.gateway_runner and _is_known_platform:
return await self._deliver_cross_platform(
deliver_type, content, delivery
)
+1 -1
View File
@@ -1620,7 +1620,7 @@ class WeixinAdapter(BasePlatformAdapter):
_, image_cleaned = self.extract_images(cleaned_content)
local_files, final_content = self.extract_local_files(image_cleaned)
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"}
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
+1217 -94
View File
File diff suppressed because it is too large Load Diff
+24 -13
View File
@@ -62,6 +62,7 @@ from .config import (
)
from .whatsapp_identity import (
canonical_whatsapp_identifier,
normalize_whatsapp_identifier, # noqa: F401 - re-exported for gateway.session callers
)
from utils import atomic_replace
@@ -234,7 +235,7 @@ def build_session_context_prompt(
) -> str:
"""
Build the dynamic system prompt section that tells the agent about its context.
This is injected into the system prompt so the agent knows:
- Where messages are coming from
- What platforms are connected
@@ -246,13 +247,23 @@ def build_session_context_prompt(
Platforms like Discord are excluded because mentions need real IDs.
Routing still uses the original values (they stay in SessionSource).
"""
# Only apply redaction on platforms where IDs aren't needed for mentions
redact_pii = redact_pii and context.source.platform in _PII_SAFE_PLATFORMS
# Only apply redaction on platforms where IDs aren't needed for mentions.
# Check both the hardcoded set (builtins) and the plugin registry.
_is_pii_safe = context.source.platform in _PII_SAFE_PLATFORMS
if not _is_pii_safe:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(context.source.platform.value)
if entry and entry.pii_safe:
_is_pii_safe = True
except Exception:
pass
redact_pii = redact_pii and _is_pii_safe
lines = [
"## Current Session Context",
"",
]
# Source info
platform_name = context.source.platform.value.title()
if context.source.platform == Platform.LOCAL:
@@ -277,7 +288,7 @@ def build_session_context_prompt(
else:
desc = src.description
lines.append(f"**Source:** {platform_name} ({desc})")
# Channel topic (if available - provides context about the channel's purpose)
if context.source.chat_topic:
lines.append(f"**Channel Topic:** {context.source.chat_topic}")
@@ -302,7 +313,7 @@ def build_session_context_prompt(
if redact_pii:
uid = _hash_sender_id(uid)
lines.append(f"**User ID:** {uid}")
# Platform-specific behavioral notes
if context.source.platform == Platform.SLACK:
lines.append("")
@@ -368,9 +379,9 @@ def build_session_context_prompt(
for p in context.connected_platforms:
if p != Platform.LOCAL:
platforms_list.append(f"{p.value}: Connected ✓")
lines.append(f"**Connected Platforms:** {', '.join(platforms_list)}")
# Home channels
if context.home_channels:
lines.append("")
@@ -378,11 +389,11 @@ def build_session_context_prompt(
for platform, home in context.home_channels.items():
hc_id = _hash_chat_id(home.chat_id) if redact_pii else home.chat_id
lines.append(f" - {platform.value}: {home.name} (ID: {hc_id})")
# Delivery options for scheduled tasks
lines.append("")
lines.append("**Delivery options for scheduled tasks:**")
from hermes_constants import display_hermes_home
# Origin delivery
@@ -398,15 +409,15 @@ def build_session_context_prompt(
lines.append(
f"- `\"local\"` → Save to local files only ({display_hermes_home()}/cron/output/)"
)
# Platform home channels
for platform, home in context.home_channels.items():
lines.append(f"- `\"{platform.value}\"` → Home channel ({home.name})")
# Note about explicit targeting
lines.append("")
lines.append("*For explicit targeting, use `\"platform:chat_id\"` format if the user provides a specific chat ID.*")
return "\n".join(lines)
+2 -2
View File
@@ -11,5 +11,5 @@ Provides subcommands for:
- hermes cron - Manage cron jobs
"""
__version__ = "0.11.0"
__release_date__ = "2026.4.23"
__version__ = "0.12.0"
__release_date__ = "2026.4.30"
+373
View File
@@ -0,0 +1,373 @@
"""
Top-level argparse construction for the hermes CLI.
Lives in its own module so other modules (e.g. ``relaunch.py``) can
introspect the parser to discover which flags exist without running the
``main`` fn.
Only the top-level parser and the ``chat`` subparser live here. Every other
subparser (model, gateway, sessions, ) is built inline in ``main.py``
because its dispatch is tightly coupled to module-level ``cmd_*`` functions.
"""
import argparse
# `--profile` / `-p` is consumed by ``main._apply_profile_override`` before
# argparse runs (it sets ``HERMES_HOME`` and strips itself from ``sys.argv``),
# so it isn't on the parser. Listed here so all "carry over on relaunch"
# metadata lives in one file.
PRE_ARGPARSE_INHERITED_FLAGS: list[tuple[str, bool]] = [
("--profile", True),
("-p", True),
]
def _inherited_flag(parser, *args, **kwargs):
"""Register a flag that ``hermes_cli.relaunch`` should carry over when
the CLI re-execs itself (e.g. after ``sessions browse`` picks a session,
or after the setup wizard launches chat).
Equivalent to ``parser.add_argument(...)`` plus tagging the resulting
Action with ``inherit_on_relaunch = True`` so the relaunch table builder
can find it via introspection.
"""
action = parser.add_argument(*args, **kwargs)
action.inherit_on_relaunch = True
return action
_EPILOGUE = """
Examples:
hermes Start interactive chat
hermes chat -q "Hello" Single query mode
hermes -c Resume the most recent session
hermes -c "my project" Resume a session by name (latest in lineage)
hermes --resume <session_id> Resume a specific session by ID
hermes setup Run setup wizard
hermes logout Clear stored authentication
hermes auth add <provider> Add a pooled credential
hermes auth list List pooled credentials
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
hermes auth reset <provider> Clear exhaustion status for a provider
hermes model Select default model
hermes fallback [list] Show fallback provider chain
hermes fallback add Add a fallback provider (same picker as `hermes model`)
hermes fallback remove Remove a fallback provider from the chain
hermes config View configuration
hermes config edit Edit config in $EDITOR
hermes config set model gpt-4 Set a config value
hermes gateway Run messaging gateway
hermes -s hermes-agent-dev,github-auth
hermes -w Start in isolated git worktree
hermes gateway install Install gateway background service
hermes sessions list List past sessions
hermes sessions browse Interactive session picker
hermes sessions rename ID T Rename/title a session
hermes logs View agent.log (last 50 lines)
hermes logs -f Follow agent.log in real time
hermes logs errors View errors.log
hermes logs --since 1h Lines from the last hour
hermes debug share Upload debug report for support
hermes update Update to latest version
For more help on a command:
hermes <command> --help
"""
def build_top_level_parser():
"""Build the top-level parser, the subparsers action, and the ``chat`` subparser.
Returns ``(parser, subparsers, chat_parser)``. The caller wires
``chat_parser.set_defaults(func=cmd_chat)`` and continues registering
other subparsers via ``subparsers.add_parser(...)``.
"""
parser = argparse.ArgumentParser(
prog="hermes",
description="Hermes Agent - AI assistant with tool-calling capabilities",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=_EPILOGUE,
)
parser.add_argument(
"--version", "-V", action="store_true", help="Show version and exit"
)
parser.add_argument(
"-z",
"--oneshot",
metavar="PROMPT",
default=None,
help=(
"One-shot mode: send a single prompt and print ONLY the final "
"response text to stdout. No banner, no spinner, no tool "
"previews, no session_id line. Tools, memory, rules, and "
"AGENTS.md in the CWD are loaded as normal; approvals are "
"auto-bypassed. Intended for scripts / pipes."
),
)
# --model / --provider are accepted at the top level so they can pair
# with -z without needing the `chat` subcommand. If neither -z nor a
# subcommand consumes them, they fall through harmlessly as None.
# Mirrors `hermes chat --model ... --provider ...` semantics.
_inherited_flag(
parser,
"-m",
"--model",
default=None,
help=(
"Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). "
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var."
),
)
_inherited_flag(
parser,
"--provider",
default=None,
help=(
"Provider override for this invocation (e.g. openrouter, anthropic). "
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var."
),
)
parser.add_argument(
"-t",
"--toolsets",
default=None,
help="Comma-separated toolsets to enable for this invocation. Applies to -z/--oneshot and --tui.",
)
parser.add_argument(
"--resume",
"-r",
metavar="SESSION",
default=None,
help="Resume a previous session by ID or title",
)
parser.add_argument(
"--continue",
"-c",
dest="continue_last",
nargs="?",
const=True,
default=None,
metavar="SESSION_NAME",
help="Resume a session by name, or the most recent if no name given",
)
parser.add_argument(
"--worktree",
"-w",
action="store_true",
default=False,
help="Run in an isolated git worktree (for parallel agents)",
)
_inherited_flag(
parser,
"--accept-hooks",
action="store_true",
default=False,
help=(
"Auto-approve any unseen shell hooks declared in config.yaml "
"without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or "
"hooks_auto_accept: true in config.yaml. Use on CI / headless "
"runs that can't prompt."
),
)
_inherited_flag(
parser,
"--skills",
"-s",
action="append",
default=None,
help="Preload one or more skills for the session (repeat flag or comma-separate)",
)
_inherited_flag(
parser,
"--yolo",
action="store_true",
default=False,
help="Bypass all dangerous command approval prompts (use at your own risk)",
)
_inherited_flag(
parser,
"--pass-session-id",
action="store_true",
default=False,
help="Include the session ID in the agent's system prompt",
)
_inherited_flag(
parser,
"--ignore-user-config",
action="store_true",
default=False,
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)",
)
_inherited_flag(
parser,
"--ignore-rules",
action="store_true",
default=False,
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
)
_inherited_flag(
parser,
"--tui",
action="store_true",
default=False,
help="Launch the modern TUI instead of the classic REPL",
)
_inherited_flag(
parser,
"--dev",
dest="tui_dev",
action="store_true",
default=False,
help="With --tui: run TypeScript sources via tsx (skip dist build)",
)
subparsers = parser.add_subparsers(dest="command", help="Command to run")
# =========================================================================
# chat command
# =========================================================================
chat_parser = subparsers.add_parser(
"chat",
help="Interactive chat with the agent",
description="Start an interactive chat session with Hermes Agent",
)
chat_parser.add_argument(
"-q", "--query", help="Single query (non-interactive mode)"
)
chat_parser.add_argument(
"--image", help="Optional local image path to attach to a single query"
)
_inherited_flag(
chat_parser,
"-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)",
)
chat_parser.add_argument(
"-t", "--toolsets", help="Comma-separated toolsets to enable"
)
_inherited_flag(
chat_parser,
"-s",
"--skills",
action="append",
default=argparse.SUPPRESS,
help="Preload one or more skills for the session (repeat flag or comma-separate)",
)
_inherited_flag(
chat_parser,
"--provider",
# No `choices=` here: user-defined providers from config.yaml `providers:`
# are also valid values, and runtime resolution (resolve_runtime_provider)
# handles validation/error reporting consistently with the top-level
# `--provider` flag.
default=None,
help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
)
chat_parser.add_argument(
"-v", "--verbose", action="store_true", help="Verbose output"
)
chat_parser.add_argument(
"-Q",
"--quiet",
action="store_true",
help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info.",
)
chat_parser.add_argument(
"--resume",
"-r",
metavar="SESSION_ID",
default=argparse.SUPPRESS,
help="Resume a previous session by ID (shown on exit)",
)
chat_parser.add_argument(
"--continue",
"-c",
dest="continue_last",
nargs="?",
const=True,
default=argparse.SUPPRESS,
metavar="SESSION_NAME",
help="Resume a session by name, or the most recent if no name given",
)
chat_parser.add_argument(
"--worktree",
"-w",
action="store_true",
default=argparse.SUPPRESS,
help="Run in an isolated git worktree (for parallel agents on the same repo)",
)
_inherited_flag(
chat_parser,
"--accept-hooks",
action="store_true",
default=argparse.SUPPRESS,
help=(
"Auto-approve any unseen shell hooks declared in config.yaml "
"without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and "
"hooks_auto_accept: in config.yaml)."
),
)
chat_parser.add_argument(
"--checkpoints",
action="store_true",
default=False,
help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)",
)
chat_parser.add_argument(
"--max-turns",
type=int,
default=None,
metavar="N",
help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)",
)
_inherited_flag(
chat_parser,
"--yolo",
action="store_true",
default=argparse.SUPPRESS,
help="Bypass all dangerous command approval prompts (use at your own risk)",
)
_inherited_flag(
chat_parser,
"--pass-session-id",
action="store_true",
default=argparse.SUPPRESS,
help="Include the session ID in the agent's system prompt",
)
_inherited_flag(
chat_parser,
"--ignore-user-config",
action="store_true",
default=argparse.SUPPRESS,
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.",
)
_inherited_flag(
chat_parser,
"--ignore-rules",
action="store_true",
default=argparse.SUPPRESS,
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.",
)
chat_parser.add_argument(
"--source",
default=None,
help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.",
)
_inherited_flag(
chat_parser,
"--tui",
action="store_true",
default=False,
help="Launch the modern TUI instead of the classic REPL",
)
_inherited_flag(
chat_parser,
"--dev",
dest="tui_dev",
action="store_true",
default=False,
help="With --tui: run TypeScript sources via tsx (skip dist build)",
)
return parser, subparsers, chat_parser
+85 -37
View File
@@ -5,6 +5,7 @@ Pure display functions with no HermesCLI state dependency.
import json
import logging
import os
import shutil
import subprocess
import threading
@@ -122,35 +123,36 @@ def get_available_skills() -> Dict[str, List[str]]:
# Cache update check results for 6 hours to avoid repeated git fetches
_UPDATE_CHECK_CACHE_SECONDS = 6 * 3600
# Sentinel returned when we know an update exists but can't count commits
# (e.g. nix-built hermes — no local git history to count against).
UPDATE_AVAILABLE_NO_COUNT = -1
def check_for_updates() -> Optional[int]:
"""Check how many commits behind origin/main the local repo is.
_UPSTREAM_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"
Does a ``git fetch`` at most once every 6 hours (cached to
``~/.hermes/.update_check``). Returns the number of commits behind,
or ``None`` if the check fails or isn't applicable.
def _check_via_rev(local_rev: str) -> Optional[int]:
"""Compare an embedded git revision to upstream main via ls-remote.
Returns 0 if up-to-date, ``UPDATE_AVAILABLE_NO_COUNT`` if behind,
or ``None`` on failure.
"""
hermes_home = get_hermes_home()
repo_dir = hermes_home / "hermes-agent"
cache_file = hermes_home / ".update_check"
# Must be a git repo — fall back to project root for dev installs
if not (repo_dir / ".git").exists():
repo_dir = Path(__file__).parent.parent.resolve()
if not (repo_dir / ".git").exists():
return None
# Read cache
now = time.time()
try:
if cache_file.exists():
cached = json.loads(cache_file.read_text())
if now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS:
return cached.get("behind")
result = subprocess.run(
["git", "ls-remote", _UPSTREAM_REPO_URL, "refs/heads/main"],
capture_output=True, text=True, timeout=10,
)
except Exception:
pass
return None
if result.returncode != 0 or not result.stdout:
return None
upstream_rev = result.stdout.split()[0]
if not upstream_rev:
return None
return 0 if upstream_rev == local_rev else UPDATE_AVAILABLE_NO_COUNT
# Fetch latest refs (fast — only downloads ref metadata, no files)
def _check_via_local_git(repo_dir: Path) -> Optional[int]:
"""Count commits behind origin/main in a local checkout."""
try:
subprocess.run(
["git", "fetch", "origin", "--quiet"],
@@ -160,7 +162,6 @@ def check_for_updates() -> Optional[int]:
except Exception:
pass # Offline or timeout — use stale refs, that's fine
# Count commits behind
try:
result = subprocess.run(
["git", "rev-list", "--count", "HEAD..origin/main"],
@@ -168,15 +169,52 @@ def check_for_updates() -> Optional[int]:
cwd=str(repo_dir),
)
if result.returncode == 0:
behind = int(result.stdout.strip())
else:
behind = None
return int(result.stdout.strip())
except Exception:
behind = None
pass
return None
# Write cache
def check_for_updates() -> Optional[int]:
"""Check whether a Hermes update is available.
Two paths: if ``HERMES_REVISION`` is set (nix builds embed it), compare
it to upstream main via ``git ls-remote``. Otherwise look for a local
git checkout and count commits behind ``origin/main``.
Returns the number of commits behind, ``UPDATE_AVAILABLE_NO_COUNT`` (-1)
if behind but the count is unknown, ``0`` if up-to-date, or ``None`` if
the check failed or doesn't apply. Cached for 6 hours.
"""
hermes_home = get_hermes_home()
cache_file = hermes_home / ".update_check"
embedded_rev = os.environ.get("HERMES_REVISION") or None
# Read cache — invalidate if the embedded rev has changed since last check
now = time.time()
try:
cache_file.write_text(json.dumps({"ts": now, "behind": behind}))
if cache_file.exists():
cached = json.loads(cache_file.read_text())
if (
now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS
and cached.get("rev") == embedded_rev
):
return cached.get("behind")
except Exception:
pass
if embedded_rev:
behind = _check_via_rev(embedded_rev)
else:
repo_dir = hermes_home / "hermes-agent"
if not (repo_dir / ".git").exists():
repo_dir = Path(__file__).parent.parent.resolve()
if not (repo_dir / ".git").exists():
return None
behind = _check_via_local_git(repo_dir)
try:
cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev}))
except Exception:
pass
@@ -549,13 +587,23 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
# Update check — use prefetched result if available
try:
behind = get_update_result(timeout=0.5)
if behind and behind > 0:
from hermes_cli.config import recommended_update_command
commits_word = "commit" if behind == 1 else "commits"
right_lines.append(
f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
)
if behind is not None and behind != 0:
from hermes_cli.config import get_managed_update_command, recommended_update_command
if behind > 0:
commits_word = "commit" if behind == 1 else "commits"
right_lines.append(
f"[bold yellow]{behind} {commits_word} behind[/]"
f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
)
else:
# UPDATE_AVAILABLE_NO_COUNT: nix-built hermes; we know an update
# exists but not by how much, and we don't know how the user
# installed it (nix run, profile, system flake, home-manager).
managed_cmd = get_managed_update_command()
line = "[bold yellow]⚠ update available[/]"
if managed_cmd:
line += f"[dim yellow] — run [bold]{managed_cmd}[/bold][/]"
right_lines.append(line)
except Exception:
pass # Never break the banner over an update check
+7
View File
@@ -151,10 +151,17 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("curator", "Background skill maintenance (status, run, pin, archive)",
"Tools & Skills", args_hint="[subcommand]",
subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore")),
CommandDef("kanban", "Multi-profile collaboration board (tasks, links, comments)",
"Tools & Skills", args_hint="[subcommand]",
subcommands=("list", "ls", "show", "create", "assign", "link", "unlink",
"claim", "comment", "complete", "block", "unblock", "archive",
"tail", "dispatch", "context", "init", "gc")),
CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills",
cli_only=True),
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
aliases=("reload_mcp",)),
CommandDef("reload-skills", "Re-scan ~/.hermes/skills/ for newly installed or removed skills",
"Tools & Skills", aliases=("reload_skills",)),
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
cli_only=True, args_hint="[connect|disconnect|status]",
subcommands=("connect", "disconnect", "status")),
+239 -34
View File
@@ -73,6 +73,8 @@ _EXTRA_ENV_KEYS = frozenset({
"QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", # legacy aliases (pre-rename, still read for back-compat)
"QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT",
"QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL",
"IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL",
"IRC_USE_TLS", "IRC_SERVER_PASSWORD", "IRC_NICKSERV_PASSWORD",
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
@@ -348,7 +350,7 @@ def ensure_hermes_home():
else:
home.mkdir(parents=True, exist_ok=True)
_secure_dir(home)
for subdir in ("cron", "sessions", "logs", "memories"):
for subdir in ("cron", "sessions", "logs", "logs/curator", "memories"):
d = home / subdir
d.mkdir(parents=True, exist_ok=True)
_secure_dir(d)
@@ -369,6 +371,10 @@ def _ensure_hermes_home_managed(home: Path):
f"{d} does not exist. "
"Run 'sudo nixos-rebuild switch' first."
)
# Curator reports dir is a sub-path of logs/; create it if missing.
# In managed mode the activation script may not know about this subdir,
# so we mkdir it ourselves (it's inside an already-secured logs/ dir).
(home / "logs" / "curator").mkdir(parents=True, exist_ok=True)
# Inside umask(0o007) scope — SOUL.md will be created as 0660
_ensure_default_soul_md(home)
@@ -707,6 +713,19 @@ DEFAULT_CONFIG = {
"timeout": 30,
"extra_body": {},
},
# Curator — skill-usage review fork. Timeout is generous because the
# review pass can take several minutes on reasoning models (umbrella
# building over hundreds of candidate skills). "auto" = use main chat
# model; override via `hermes model` → auxiliary → Curator to route
# to a cheaper aux model (e.g. openrouter google/gemini-3-flash-preview).
"curator": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 600,
"extra_body": {},
},
},
"display": {
@@ -764,7 +783,7 @@ DEFAULT_CONFIG = {
# limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware,
# Gemini 5000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
"tts": {
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "neutts" (local)
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "neutts" (local) | "kittentts" (local) | "piper" (local)
"edge": {
"voice": "en-US-AriaNeural",
# Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural
@@ -794,6 +813,19 @@ DEFAULT_CONFIG = {
"model": "neuphonic/neutts-air-q4-gguf", # HuggingFace model repo
"device": "cpu", # cpu, cuda, or mps
},
"piper": {
# Voice name (e.g. "en_US-lessac-medium") downloaded on first
# use, OR an absolute path to a pre-downloaded .onnx file.
# Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md
"voice": "en_US-lessac-medium",
# "voices_dir": "", # Override voice cache dir; default = ~/.hermes/cache/piper-voices/
# "use_cuda": False, # Requires onnxruntime-gpu
# "length_scale": 1.0, # 2.0 = twice as slow
# "noise_scale": 0.667,
# "noise_w_scale": 0.8,
# "volume": 1.0,
# "normalize_audio": True,
},
},
"stt": {
@@ -947,12 +979,6 @@ DEFAULT_CONFIG = {
# Archive a skill (move to skills/.archive/) after this many days
# without use. Archived skills are recoverable — no auto-deletion.
"archive_after_days": 90,
# Optional per-task override for the curator's aux model. Leave null
# to use Hermes' main auxiliary client resolution.
"auxiliary": {
"provider": None,
"model": None,
},
},
# Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
@@ -1018,6 +1044,14 @@ DEFAULT_CONFIG = {
"mode": "manual",
"timeout": 60,
"cron_mode": "deny",
# When true, /reload-mcp asks the user to confirm before rebuilding
# the MCP tool set for the active session. Reloading invalidates
# the provider prompt cache (tool schemas are baked into the system
# prompt), so the next message re-sends full input tokens — this can
# be expensive on long-context or high-reasoning models. Users click
# "Always Approve" to silence the prompt permanently; that flips
# this key to false.
"mcp_reload_confirm": True,
},
# Permanently allowed dangerous command patterns (added via "always" approval)
@@ -1070,6 +1104,24 @@ DEFAULT_CONFIG = {
"max_parallel_jobs": None,
},
# Kanban multi-agent coordination — controls the dispatcher loop that
# spawns workers for ready tasks. The dispatcher ticks every N seconds
# (default 60), reclaims stale claims, promotes dependency-satisfied
# todos to ready, and fires `hermes -p <assignee> chat -q ...` for
# each claimable ready task. One dispatcher per profile is sufficient;
# running more than one on the same kanban.db will race for claims.
"kanban": {
# Run the dispatcher inside the gateway process. On by default —
# the cost is ~300µs every `dispatch_interval_seconds` when idle,
# and gateway is the supervisor users already have. Set to false
# only if you run the dispatcher as a separate systemd unit or
# don't want the gateway to spawn workers.
"dispatch_in_gateway": True,
# Seconds between dispatcher ticks (idle or not). Lower = snappier
# pickup of newly-ready tasks; higher = less SQL pressure.
"dispatch_interval_seconds": 60,
},
# execute_code settings — controls the tool used for programmatic tool calls.
"code_execution": {
# Execution mode:
@@ -1171,7 +1223,7 @@ DEFAULT_CONFIG = {
},
# Config schema version - bump this when adding new required fields
"_config_version": 22,
"_config_version": 23,
}
# =============================================================================
@@ -2076,6 +2128,43 @@ OPTIONAL_ENV_VARS = {
"prompt": "QQ Sandbox Mode",
"category": "messaging",
},
"IRC_SERVER": {
"description": "IRC server hostname (e.g. irc.libera.chat)",
"prompt": "IRC server",
"url": None,
"password": False,
"category": "messaging",
},
"IRC_CHANNEL": {
"description": "IRC channel to join (e.g. #hermes)",
"prompt": "IRC channel",
"url": None,
"password": False,
"category": "messaging",
},
"IRC_NICKNAME": {
"description": "Bot nickname on IRC (default: hermes-bot)",
"prompt": "IRC nickname",
"url": None,
"password": False,
"category": "messaging",
},
"IRC_SERVER_PASSWORD": {
"description": "IRC server password (if required)",
"prompt": "IRC server password",
"url": None,
"password": True,
"category": "messaging",
"advanced": True,
},
"IRC_NICKSERV_PASSWORD": {
"description": "NickServ password for nick identification",
"prompt": "NickServ password",
"url": None,
"password": True,
"category": "messaging",
"advanced": True,
},
"GATEWAY_ALLOW_ALL_USERS": {
"description": "Allow all users to interact with messaging bots (true/false). Default: false.",
"prompt": "Allow all users (true/false)",
@@ -2238,19 +2327,55 @@ def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
return missing
def _set_nested(config: dict, dotted_key: str, value):
def _set_nested(config, dotted_key: str, value):
"""Set a value at an arbitrarily nested dotted key path.
Creates intermediate dicts as needed, e.g. ``_set_nested(c, "a.b.c", 1)``
ensures ``c["a"]["b"]["c"] == 1``.
Supports both dict and list navigation:
_set_nested(c, "a.b.c", 1) c["a"]["b"]["c"] = 1
_set_nested(c, "a.0.b", 1) c["a"][0]["b"] = 1
_set_nested(c, "providers.1", "x") c["providers"][1] = "x"
Intermediate dicts are created on demand. List indices are parsed
from numeric path segments; the referenced index must already exist
(we do not grow lists the user is navigating into structure they
wrote themselves). If a segment targets a non-container leaf
(scalar), the leaf is replaced with a fresh dict so the write can
proceed this preserves the pre-existing behavior for bare scalar
overrides (e.g. setting ``a.b.c`` where ``a.b`` was previously a
string).
Guards against #17876: before this fix the code unconditionally
replaced any non-dict value (including lists) with ``{}``, silently
destroying list-typed config like ``custom_providers`` whenever a
caller used an indexed path.
"""
parts = dotted_key.split(".")
current = config
for part in parts[:-1]:
if part not in current or not isinstance(current.get(part), dict):
current[part] = {}
current = current[part]
current[parts[-1]] = value
if isinstance(current, list):
try:
idx = int(part)
except (TypeError, ValueError):
raise TypeError(
f"Cannot navigate into list at key {dotted_key!r}: "
f"segment {part!r} is not a numeric index"
)
current = current[idx]
elif isinstance(current, dict):
existing = current.get(part)
# Preserve dicts and lists; replace missing/scalar with a fresh dict.
if part not in current or not isinstance(existing, (dict, list)):
current[part] = {}
current = current[part]
else:
raise TypeError(
f"Cannot navigate into {type(current).__name__} at key {dotted_key!r}"
)
last = parts[-1]
if isinstance(current, list):
current[int(last)] = value
else:
current[last] = value
def get_missing_config_fields() -> List[Dict[str, Any]]:
@@ -3171,6 +3296,90 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
"Use `hermes plugins enable <name>` to activate."
)
# ── Version 22 → 23: seed curator defaults + create logs/curator/ ──
# The curator (background skill maintenance) was added in PR #16049, but
# existing configs from before that PR (or before the April 2026
# unification under `auxiliary.curator`) never wrote the curator section
# to disk. The runtime deep-merge in `load_config()` fills defaults at
# read time, so the curator *functions*; but users can't see/edit the
# settings in their `config.yaml`, and `hermes curator status` has no
# stable logs dir to point at until the first run mkdir's it.
#
# This migration:
# 1. Writes the `curator` top-level section to config.yaml (enabled,
# interval_hours, min_idle_hours, stale_after_days, archive_after_days)
# — only keys the user hasn't already overridden.
# 2. Writes the `auxiliary.curator` aux-task slot (provider, model,
# base_url, api_key, timeout, extra_body) — canonical slot for
# routing the curator fork to a cheaper aux model.
# 3. Creates `~/.hermes/logs/curator/` if missing (belt-and-suspenders
# on top of ensure_hermes_home() — old profiles that predate this
# migration still benefit).
if current_ver < 23:
try:
curator_dir = get_hermes_home() / "logs" / "curator"
curator_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
results["warnings"].append(f"Could not create {curator_dir}: {e}")
config = read_raw_config()
touched = False
# (1) Top-level curator section — only add missing keys
_curator_defaults = DEFAULT_CONFIG.get("curator", {})
raw_curator = config.get("curator")
if not isinstance(raw_curator, dict):
raw_curator = {}
added_curator: List[str] = []
for k, v in _curator_defaults.items():
if k not in raw_curator:
raw_curator[k] = copy.deepcopy(v)
added_curator.append(k)
if added_curator:
config["curator"] = raw_curator
touched = True
# (2) auxiliary.curator task slot
_aux_curator_defaults = (
DEFAULT_CONFIG.get("auxiliary", {}).get("curator", {})
)
raw_aux = config.get("auxiliary")
if not isinstance(raw_aux, dict):
raw_aux = {}
raw_aux_curator = raw_aux.get("curator")
if not isinstance(raw_aux_curator, dict):
raw_aux_curator = {}
added_aux: List[str] = []
for k, v in _aux_curator_defaults.items():
if k not in raw_aux_curator:
raw_aux_curator[k] = copy.deepcopy(v)
added_aux.append(k)
if added_aux:
raw_aux["curator"] = raw_aux_curator
config["auxiliary"] = raw_aux
touched = True
if touched:
save_config(config)
if added_curator:
results["config_added"].append(
f"curator ({len(added_curator)} default key(s))"
)
if not quiet:
print(
" ✓ Seeded curator defaults in config.yaml: "
f"{', '.join(added_curator)}"
)
if added_aux:
results["config_added"].append(
f"auxiliary.curator ({len(added_aux)} default key(s))"
)
if not quiet:
print(
" ✓ Seeded auxiliary.curator defaults in config.yaml: "
f"{', '.join(added_aux)}"
)
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")
@@ -3443,17 +3652,17 @@ def _preserve_env_ref_templates(current, raw, loaded_expanded=None):
def _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:
"""Move stale root-level provider/base_url into model section.
"""Move stale root-level provider/base_url/context_length into model section.
Some users (or older code) placed ``provider:`` and ``base_url:`` at the
config root instead of inside ``model:``. These root-level keys are only
used as a fallback when the corresponding ``model.*`` key is empty they
never override an existing ``model.provider`` or ``model.base_url``.
Some users (or older code) placed ``provider:``, ``base_url:``, or
``context_length:`` at the config root instead of inside ``model:``.
These root-level keys are only used as a fallback when the corresponding
``model.*`` key is empty they never override an existing value.
After migration the root-level keys are removed so they can't cause
confusion on subsequent loads.
"""
# Only act if there are root-level keys to migrate
has_root = any(config.get(k) for k in ("provider", "base_url"))
has_root = any(config.get(k) for k in ("provider", "base_url", "context_length"))
if not has_root:
return config
@@ -3463,7 +3672,7 @@ def _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:
model = {"default": model} if model else {}
config["model"] = model
for key in ("provider", "base_url"):
for key in ("provider", "base_url", "context_length"):
root_val = config.get(key)
if root_val and not model.get(key):
model[key] = root_val
@@ -4354,15 +4563,11 @@ def set_config_value(key: str, value: str):
except Exception:
user_config = {}
# Handle nested keys (e.g., "tts.provider")
parts = key.split('.')
current = user_config
for part in parts[:-1]:
if part not in current or not isinstance(current.get(part), dict):
current[part] = {}
current = current[part]
# Handle nested keys (e.g., "tts.provider") including numeric list
# indices (e.g., "custom_providers.0.api_key"). Delegates to
# _set_nested which preserves list-typed nodes; before #17876 the
# inline navigation here silently overwrote lists with dicts.
# Convert value to appropriate type
if value.lower() in ('true', 'yes', 'on'):
value = True
@@ -4372,8 +4577,8 @@ def set_config_value(key: str, value: str):
value = int(value)
elif value.replace('.', '', 1).isdigit():
value = float(value)
current[parts[-1]] = value
_set_nested(user_config, key, value)
# Write only user config back (not the full merged defaults)
ensure_hermes_home()
+57 -5
View File
@@ -88,16 +88,68 @@ def _cmd_status(args) -> int:
if pinned:
print(f"\npinned ({len(pinned)}): {', '.join(pinned)}")
# Show top 5 least-recently-used active skills
# Show top 5 least-recently-active skills. Views and edits are activity too:
# curator should not report a skill as "never used" right after skill_view()
# or skill_manage() touched it.
active = sorted(
by_state.get("active", []),
key=lambda r: r.get("last_used_at") or r.get("created_at") or "",
key=lambda r: r.get("last_activity_at") or r.get("created_at") or "",
)[:5]
if active:
print("\nleast recently used (top 5):")
print("\nleast recently active (top 5):")
for r in active:
last = _fmt_ts(r.get("last_used_at"))
print(f" {r['name']:40s} use={r.get('use_count', 0):3d} last_used={last}")
last = _fmt_ts(r.get("last_activity_at"))
print(
f" {r['name']:40s} "
f"activity={r.get('activity_count', 0):3d} "
f"use={r.get('use_count', 0):3d} "
f"view={r.get('view_count', 0):3d} "
f"patches={r.get('patch_count', 0):3d} "
f"last_activity={last}"
)
# Show top 5 most-active and least-active skills by activity_count
# (use + view + patch). This is a different signal from
# least-recently-active: activity_count reflects frequency,
# last_activity_at reflects recency. A skill touched 30 times a year
# ago is high-frequency but stale; a skill touched once yesterday is
# recent but low-frequency. Both can matter.
active_all = by_state.get("active", [])
if active_all:
most_active = sorted(
active_all,
key=lambda r: (r.get("activity_count") or 0, r.get("last_activity_at") or ""),
reverse=True,
)[:5]
if most_active and (most_active[0].get("activity_count") or 0) > 0:
print("\nmost active (top 5):")
for r in most_active:
last = _fmt_ts(r.get("last_activity_at"))
print(
f" {r['name']:40s} "
f"activity={r.get('activity_count', 0):3d} "
f"use={r.get('use_count', 0):3d} "
f"view={r.get('view_count', 0):3d} "
f"patches={r.get('patch_count', 0):3d} "
f"last_activity={last}"
)
least_active = sorted(
active_all,
key=lambda r: (r.get("activity_count") or 0, r.get("last_activity_at") or ""),
)[:5]
if least_active:
print("\nleast active (top 5):")
for r in least_active:
last = _fmt_ts(r.get("last_activity_at"))
print(
f" {r['name']:40s} "
f"activity={r.get('activity_count', 0):3d} "
f"use={r.get('use_count', 0):3d} "
f"view={r.get('view_count', 0):3d} "
f"patches={r.get('patch_count', 0):3d} "
f"last_activity={last}"
)
return 0
+42 -9
View File
@@ -78,6 +78,14 @@ def _system_package_install_cmd(pkg: str) -> str:
return f"sudo apt install {pkg}"
def _safe_which(cmd: str) -> str | None:
"""shutil.which wrapper resilient to platform monkeypatching in tests."""
try:
return shutil.which(cmd)
except Exception:
return None
def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
steps: list[str] = []
step = 1
@@ -579,7 +587,7 @@ def run_doctor(args):
except Exception as e:
check_warn("Auth provider status", f"(could not check: {e})")
if shutil.which("codex"):
if _safe_which("codex"):
check_ok("codex CLI")
else:
# Native OAuth uses Hermes' own device-code flow — the Codex CLI is
@@ -797,13 +805,13 @@ def run_doctor(args):
print(color("◆ External Tools", Colors.CYAN, Colors.BOLD))
# Git
if shutil.which("git"):
if _safe_which("git"):
check_ok("git")
else:
check_warn("git not found", "(optional)")
# ripgrep (optional, for faster file search)
if shutil.which("rg"):
if _safe_which("rg"):
check_ok("ripgrep (rg)", "(faster file search)")
else:
check_warn("ripgrep (rg) not found", "(file search uses grep fallback)")
@@ -812,7 +820,7 @@ def run_doctor(args):
# Docker (optional)
terminal_env = os.getenv("TERMINAL_ENV", "local")
if terminal_env == "docker":
if shutil.which("docker"):
if _safe_which("docker"):
# Check if docker daemon is running
try:
result = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
@@ -827,7 +835,7 @@ def run_doctor(args):
check_fail("docker not found", "(required for TERMINAL_ENV=docker)")
issues.append("Install Docker or change TERMINAL_ENV")
else:
if shutil.which("docker"):
if _safe_which("docker"):
check_ok("docker", "(optional)")
else:
if _is_termux():
@@ -918,7 +926,7 @@ def run_doctor(args):
check_info("Vercel persistence: ephemeral filesystem")
# Node.js + agent-browser (for browser automation tools)
if shutil.which("node"):
if _safe_which("node"):
check_ok("Node.js")
# Check if agent-browser is installed
agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser"
@@ -944,7 +952,7 @@ def run_doctor(args):
check_warn("Node.js not found", "(optional, needed for browser tools)")
# npm audit for all Node.js packages
if shutil.which("npm"):
if _safe_which("npm"):
npm_dirs = [
(PROJECT_ROOT, "Browser tools (agent-browser)"),
(PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge"),
@@ -1023,10 +1031,16 @@ def run_doctor(args):
print(" Checking Anthropic API...", end="", flush=True)
try:
import httpx
from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS
from agent.anthropic_adapter import (
_is_oauth_token,
_COMMON_BETAS,
_OAUTH_ONLY_BETAS,
_CONTEXT_1M_BETA,
)
headers = {"anthropic-version": "2023-06-01"}
if _is_oauth_token(anthropic_key):
is_oauth = _is_oauth_token(anthropic_key)
if is_oauth:
headers["Authorization"] = f"Bearer {anthropic_key}"
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
else:
@@ -1036,6 +1050,25 @@ def run_doctor(args):
headers=headers,
timeout=10
)
# Reactive recovery: OAuth subscriptions that don't include 1M
# context reject the request with 400 "long context beta is not
# yet available for this subscription". Retry once with that
# beta stripped so the doctor check doesn't falsely report the
# Anthropic API as unreachable for those users.
if (
is_oauth
and response.status_code == 400
and "long context beta" in response.text.lower()
and "not yet available" in response.text.lower()
):
headers["anthropic-beta"] = ",".join(
[b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)
)
response = httpx.get(
"https://api.anthropic.com/v1/models",
headers=headers,
timeout=10,
)
if response.status_code == 200:
print(f"\r {color('', Colors.GREEN)} Anthropic API ")
elif response.status_code == 401:
+157 -32
View File
@@ -2368,7 +2368,11 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
# Exit with code 1 if gateway fails to connect any platform,
# so systemd Restart=on-failure will retry on transient errors
verbosity = None if quiet else verbose
success = asyncio.run(start_gateway(replace=replace, verbosity=verbosity))
try:
success = asyncio.run(start_gateway(replace=replace, verbosity=verbosity))
except KeyboardInterrupt:
print("\nGateway stopped.")
return
if not success:
sys.exit(1)
@@ -2761,15 +2765,77 @@ _PLATFORMS = [
],
},
]
def _all_platforms() -> list[dict]:
"""Return the full list of platforms for setup menus.
Combines the built-in ``_PLATFORMS`` with plugin platforms registered via
``platform_registry``. Plugins are discovered on first call so bundled
platforms (like IRC, which auto-load via ``kind: platform``) appear in
``hermes setup gateway`` without needing the gateway to be running.
Built-ins keep their dict shape; plugin entries are adapted to the same
shape with ``_registry_entry`` holding the source.
"""
# Populate the registry so plugin platforms are visible. Idempotent.
# Bundled platform plugins (``kind: platform``) auto-load unconditionally,
# so every shipped messaging channel appears in the setup menu by default.
# User-installed platform plugins under ~/.hermes/plugins/ still require
# opt-in via ``plugins.enabled`` (untrusted code).
try:
from hermes_cli.plugins import discover_plugins
discover_plugins()
except Exception as e:
logger.debug("plugin discovery failed during platform enumeration: %s", e)
platforms = [dict(p) for p in _PLATFORMS]
by_key = {p["key"]: p for p in platforms}
try:
from gateway.platform_registry import platform_registry
except Exception:
return platforms
for entry in platform_registry.all_entries():
if entry.name in by_key:
continue # built-in already covers it
platforms.append({
"key": entry.name,
"label": entry.label,
"emoji": entry.emoji,
"token_var": entry.required_env[0] if entry.required_env else "",
"install_hint": entry.install_hint,
"_registry_entry": entry,
})
return platforms
def _platform_status(platform: dict) -> str:
"""Return a plain-text status string for a platform.
Returns uncolored text so it can safely be embedded in
simple_term_menu items (ANSI codes break width calculation).
curses menu items (ANSI codes break width calculation).
"""
token_var = platform["token_var"]
entry = platform.get("_registry_entry")
if entry is not None:
configured = False
# Prefer is_connected (checks both env and config.yaml) over
# check_fn (typically just dependency / env presence).
if entry.is_connected is not None:
try:
from gateway.config import PlatformConfig
synthetic = PlatformConfig(enabled=True)
configured = bool(entry.is_connected(synthetic))
except Exception:
configured = False
if not configured:
try:
configured = bool(entry.check_fn())
except Exception:
configured = False
return "configured" if configured else "not configured"
token_var = platform.get("token_var", "")
if not token_var:
return "not configured"
val = get_env_value(token_var)
if token_var == "WHATSAPP_ENABLED":
if val and val.lower() == "true":
@@ -3727,6 +3793,71 @@ def _setup_signal():
print_info(f" Groups: {'enabled' if get_env_value('SIGNAL_GROUP_ALLOWED_USERS') else 'disabled'}")
def _builtin_setup_fn(key: str):
"""Resolve the interactive setup function for a built-in platform key.
Late-bound to avoid a circular import with ``hermes_cli.setup`` (which
imports from this module for the remaining bespoke flows).
"""
from hermes_cli import setup as _s
return {
"telegram": _s._setup_telegram,
"discord": _s._setup_discord,
"slack": _s._setup_slack,
"matrix": _s._setup_matrix,
"mattermost": _s._setup_mattermost,
"bluebubbles": _s._setup_bluebubbles,
"webhooks": _s._setup_webhooks,
"signal": _setup_signal,
"whatsapp": _setup_whatsapp,
"weixin": _setup_weixin,
"dingtalk": _setup_dingtalk,
"feishu": _setup_feishu,
"wecom": _setup_wecom,
"qqbot": _setup_qqbot,
}.get(key)
def _configure_platform(platform: dict) -> None:
"""Run the interactive setup flow for a single platform.
Dispatch order:
1. Plugin-provided ``setup_fn`` on the registry entry.
2. Built-in setup function matched by platform key.
3. ``_setup_standard_platform`` when the entry has a ``vars`` schema.
4. Env-var hint fallback for plugins that offer no setup helper.
Bundled platform plugins (e.g. IRC) auto-load, so no plugin enable step
is needed here. User-installed platform plugins under ~/.hermes/plugins/
must already be in ``plugins.enabled`` before they appear in this menu.
"""
entry = platform.get("_registry_entry")
if entry is not None and entry.setup_fn is not None:
entry.setup_fn()
return
fn = _builtin_setup_fn(platform["key"])
if fn is not None:
fn()
return
if platform.get("vars"):
_setup_standard_platform(platform)
return
# Plugin with no setup helper — show env-var instructions.
label = platform.get("label", platform["key"])
emoji = platform.get("emoji", "🔌")
print()
print(color(f" ─── {emoji} {label} Setup ───", Colors.CYAN))
required = entry.required_env if entry else []
if required:
print_info(f" Set these env vars in ~/.hermes/.env: {', '.join(required)}")
else:
print_info(f" Configure {label} in config.yaml under gateway.platforms.{platform['key']}")
if platform.get("install_hint"):
print_info(f" {platform['install_hint']}")
def gateway_setup():
"""Interactive setup for messaging platforms + gateway service."""
if is_managed():
@@ -3779,42 +3910,36 @@ def gateway_setup():
print()
print_header("Messaging Platforms")
menu_items = []
for plat in _PLATFORMS:
status = _platform_status(plat)
menu_items.append(f"{plat['label']} ({status})")
platforms = _all_platforms()
menu_items = [
f"{p['emoji']} {p['label']} ({_platform_status(p)})"
for p in platforms
]
menu_items.append("Done")
choice = prompt_choice("Select a platform to configure:", menu_items, len(menu_items) - 1)
if choice == len(_PLATFORMS):
if choice == len(platforms):
break
platform = _PLATFORMS[choice]
if platform["key"] == "whatsapp":
_setup_whatsapp()
elif platform["key"] == "signal":
_setup_signal()
elif platform["key"] == "weixin":
_setup_weixin()
elif platform["key"] == "dingtalk":
_setup_dingtalk()
elif platform["key"] == "feishu":
_setup_feishu()
elif platform["key"] == "qqbot":
_setup_qqbot()
elif platform["key"] == "wecom":
_setup_wecom()
else:
_setup_standard_platform(platform)
_configure_platform(platforms[choice])
# ── Post-setup: offer to install/restart gateway ──
# Consider any platform (built-in or plugin) where the user has made
# meaningful progress. ``_platform_status`` already handles plugin
# entries via their check_fn and per-platform dual-states like
# WhatsApp's "enabled, not paired".
def _is_progress(status: str) -> bool:
s = status.lower()
return not (
s == "not configured"
or s.startswith("partially")
or s.startswith("plugin disabled")
)
any_configured = any(
bool(get_env_value(p["token_var"]))
for p in _PLATFORMS
if p["key"] != "whatsapp"
) or (get_env_value("WHATSAPP_ENABLED") or "").lower() == "true"
_is_progress(_platform_status(p)) for p in _all_platforms()
)
if any_configured:
print()
@@ -4252,4 +4377,4 @@ def _gateway_command_inner(args):
if not supports_systemd_services() and not is_macos():
print("Legacy unit migration only applies to systemd-based Linux hosts.")
return
remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run)
remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run)
+1393
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+214 -333
View File
@@ -114,6 +114,12 @@ def _apply_profile_override() -> None:
consume = 1
break
# 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it.
# This lets child processes (relaunch, subprocess) inherit the parent's
# profile choice without having to pass --profile again.
if profile_name is None and os.environ.get("HERMES_HOME"):
return
# 2. If no flag, check active_profile in the hermes root
if profile_name is None:
try:
@@ -1921,6 +1927,7 @@ _AUX_TASKS: list[tuple[str, str, str]] = [
("mcp", "MCP", "MCP tool reasoning"),
("title_generation", "Title generation", "session titles"),
("skills_hub", "Skills hub", "skills search/install"),
("curator", "Curator", "skill-usage review pass"),
]
@@ -5034,6 +5041,13 @@ def cmd_slack(args):
return 1
def cmd_kanban(args):
"""Multi-profile collaboration board."""
from hermes_cli.kanban import kanban_command
return kanban_command(args)
def cmd_hooks(args):
"""Shell-hook inspection and management."""
from hermes_cli.hooks import hooks_command
@@ -5329,8 +5343,8 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
return True
def _warn_stale_dashboard_processes() -> None:
"""Warn about running dashboard processes that still hold pre-update code.
def _find_stale_dashboard_pids() -> list[int]:
"""Return PIDs of ``hermes dashboard`` processes other than ourselves.
``hermes dashboard`` is a long-lived server process commonly started and
forgotten. When ``hermes update`` replaces files on disk, the running
@@ -5338,9 +5352,13 @@ def _warn_stale_dashboard_processes() -> None:
disk is updated, causing a silent frontend/backend mismatch (e.g. new
auth headers the old backend doesn't recognise → every API call 401s).
Unlike the gateway, the dashboard has no service manager (systemd /
launchd), so we can only warn we don't auto-kill user-managed
background processes.
The dashboard has no service manager (systemd / launchd), no PID file,
and we can't know the original launch args — so the only sane action
after an update is to kill the stale process and let the user restart
it. This helper is just the detection step; see
``_kill_stale_dashboard_processes`` for the kill.
Returns an empty list on any scan error (missing ps/wmic, timeout, etc.).
"""
patterns = [
"hermes dashboard",
@@ -5366,7 +5384,7 @@ def _warn_stale_dashboard_processes() -> None:
encoding="utf-8", errors="ignore",
)
if result.returncode != 0 or result.stdout is None:
return
return []
current_cmd = ""
for line in result.stdout.split("\n"):
line = line.strip()
@@ -5392,7 +5410,7 @@ def _warn_stale_dashboard_processes() -> None:
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0:
for line in result.stdout.split("\n"):
for line in getattr(result, "stdout", "").split("\n"):
stripped = line.strip()
if not stripped or "grep" in stripped:
continue
@@ -5408,20 +5426,112 @@ def _warn_stale_dashboard_processes() -> None:
and pid != self_pid):
dashboard_pids.append(pid)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return
return []
if not dashboard_pids:
return dashboard_pids
def _kill_stale_dashboard_processes(
reason: str = "the running backend no longer matches the updated frontend",
) -> None:
"""Kill running ``hermes dashboard`` processes.
Called at the end of ``hermes update`` (default ``reason``) and also
from ``hermes dashboard --stop`` (which overrides ``reason``). The
dashboard has no service manager, so after a code update the running
process is guaranteed to be serving stale Python against a
freshly-updated JS bundle. Leaving it alive produces silent
frontend/backend mismatches (new auth headers the old backend doesn't
recognise every API call 401s).
POSIX: SIGTERM, wait up to ~3s for graceful exit, SIGKILL any survivors.
Windows: ``taskkill /PID <pid> /F`` since there's no clean SIGTERM
equivalent for background console apps.
The dashboard isn't auto-restarted because we don't know the original
launch args (--host, --port, --insecure, --tui, --no-open). The user
restarts it manually; a hint is printed.
"""
pids = _find_stale_dashboard_pids()
if not pids:
return
print()
print(f" {len(dashboard_pids)} dashboard process(es) still running "
f"with the previous version:")
for pid in dashboard_pids:
print(f" PID {pid}")
print(" The running backend may not match the updated frontend,")
print(" causing silent auth failures or empty data.")
print(" Restart them to pick up the changes:")
print(" kill <pid> && hermes dashboard --port <port> ...")
print(f"⟲ Stopping {len(pids)} dashboard process(es) ({reason})")
killed: list[int] = []
failed: list[tuple[int, str]] = []
if sys.platform == "win32":
for pid in pids:
try:
result = subprocess.run(
["taskkill", "/PID", str(pid), "/F"],
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0:
killed.append(pid)
else:
failed.append((pid, (result.stderr or result.stdout or "").strip()))
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
failed.append((pid, str(e)))
else:
import signal as _signal
import time as _time
# SIGTERM first — give each process a chance to shut down cleanly
# (uvicorn closes its socket, flushes logs, etc.).
for pid in pids:
try:
os.kill(pid, _signal.SIGTERM)
except ProcessLookupError:
# Already gone — count as killed.
killed.append(pid)
except (PermissionError, OSError) as e:
failed.append((pid, str(e)))
# Poll for exit up to ~3s total.
deadline = _time.monotonic() + 3.0
pending = [p for p in pids if p not in killed
and p not in {f[0] for f in failed}]
while pending and _time.monotonic() < deadline:
_time.sleep(0.1)
still_pending = []
for pid in pending:
try:
os.kill(pid, 0) # probe
except ProcessLookupError:
killed.append(pid)
except (PermissionError, OSError):
# Can't probe — assume still there.
still_pending.append(pid)
else:
still_pending.append(pid)
pending = still_pending
# SIGKILL any survivors.
for pid in pending:
try:
os.kill(pid, _signal.SIGKILL)
killed.append(pid)
except ProcessLookupError:
killed.append(pid)
except (PermissionError, OSError) as e:
failed.append((pid, str(e)))
for pid in killed:
print(f" ✓ stopped PID {pid}")
for pid, reason in failed:
print(f" ✗ failed to stop PID {pid}: {reason}")
if killed:
print(" Restart the dashboard when you're ready:")
print(" hermes dashboard --port <port>")
# Back-compat alias: some tests and any external callers may import the old
# warn-only name. The new behaviour (kill stale processes) replaces it.
_warn_stale_dashboard_processes = _kill_stale_dashboard_processes
def _update_via_zip(args):
@@ -5558,7 +5668,7 @@ def _update_via_zip(args):
print()
print("✓ Update complete!")
_warn_stale_dashboard_processes()
_kill_stale_dashboard_processes()
def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]:
@@ -7375,9 +7485,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
except Exception as e:
logger.debug("Legacy unit check during update failed: %s", e)
# Warn about stale dashboard processes — the dashboard has no
# service manager, so we can only tell the user to restart them.
_warn_stale_dashboard_processes()
# Kill stale dashboard processes — the dashboard has no service
# manager, so leaving it alive after a code update produces a
# silent frontend/backend mismatch. We can't auto-restart it
# (no saved launch args) but we can stop it, and a hint is
# printed for the user to re-launch.
_kill_stale_dashboard_processes()
print()
print("Tip: You can now select a provider and model:")
@@ -7576,7 +7689,7 @@ def cmd_profile(args):
if clone_all:
print(f"Full copy from {source_label}.")
else:
print(f"Cloned config, .env, SOUL.md from {source_label}.")
print(f"Cloned config, .env, SOUL.md, and skills from {source_label}.")
# Auto-clone Honcho config for the new profile (only with --clone/--clone-all)
if clone or clone_all:
@@ -7768,8 +7881,59 @@ def cmd_profile(args):
sys.exit(1)
def _report_dashboard_status() -> int:
"""Print ``hermes dashboard`` PIDs and return the count.
Uses the same detection logic as ``_find_stale_dashboard_pids`` (the
current process is excluded, but since ``hermes dashboard --status``
runs in a short-lived CLI process that never matches the pattern,
the exclusion is irrelevant here).
"""
pids = _find_stale_dashboard_pids()
if not pids:
print("No hermes dashboard processes running.")
return 0
print(f"{len(pids)} hermes dashboard process(es) running:")
for pid in pids:
# Best-effort: show the full cmdline so users can tell profiles apart.
cmdline = ""
try:
if sys.platform != "win32":
cmdline_path = f"/proc/{pid}/cmdline"
if os.path.exists(cmdline_path):
with open(cmdline_path, "rb") as f:
cmdline = f.read().replace(b"\x00", b" ").decode(
"utf-8", errors="replace").strip()
except (OSError, ValueError):
pass
if cmdline:
print(f" PID {pid}: {cmdline}")
else:
print(f" PID {pid}")
return len(pids)
def cmd_dashboard(args):
"""Start the web UI server."""
"""Start the web UI server, or (with --stop/--status) manage running ones."""
# --status: report running dashboards and exit, no deps needed.
if getattr(args, "status", False):
count = _report_dashboard_status()
sys.exit(0 if count == 0 else 0) # status is informational, always 0
# --stop: kill any running dashboards and exit, no deps needed.
if getattr(args, "stop", False):
pids = _find_stale_dashboard_pids()
if not pids:
print("No hermes dashboard processes running.")
sys.exit(0)
# Reuse the same SIGTERM-grace-SIGKILL path used after `hermes update`.
_kill_stale_dashboard_processes(reason="requested via --stop")
# _kill_stale_dashboard_processes prints outcomes itself. Exit 0 if
# we killed at least one, 1 if they were all unkillable.
remaining = _find_stale_dashboard_pids()
sys.exit(1 if remaining else 0)
try:
import fastapi # noqa: F401
import uvicorn # noqa: F401
@@ -7836,308 +8000,9 @@ def cmd_logs(args):
def main():
"""Main entry point for hermes CLI."""
parser = argparse.ArgumentParser(
prog="hermes",
description="Hermes Agent - AI assistant with tool-calling capabilities",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
hermes Start interactive chat
hermes chat -q "Hello" Single query mode
hermes -c Resume the most recent session
hermes -c "my project" Resume a session by name (latest in lineage)
hermes --resume <session_id> Resume a specific session by ID
hermes setup Run setup wizard
hermes logout Clear stored authentication
hermes auth add <provider> Add a pooled credential
hermes auth list List pooled credentials
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
hermes auth reset <provider> Clear exhaustion status for a provider
hermes model Select default model
hermes fallback [list] Show fallback provider chain
hermes fallback add Add a fallback provider (same picker as `hermes model`)
hermes fallback remove Remove a fallback provider from the chain
hermes config View configuration
hermes config edit Edit config in $EDITOR
hermes config set model gpt-4 Set a config value
hermes gateway Run messaging gateway
hermes -s hermes-agent-dev,github-auth
hermes -w Start in isolated git worktree
hermes gateway install Install gateway background service
hermes sessions list List past sessions
hermes sessions browse Interactive session picker
hermes sessions rename ID T Rename/title a session
hermes logs View agent.log (last 50 lines)
hermes logs -f Follow agent.log in real time
hermes logs errors View errors.log
hermes logs --since 1h Lines from the last hour
hermes debug share Upload debug report for support
hermes update Update to latest version
from hermes_cli._parser import build_top_level_parser
For more help on a command:
hermes <command> --help
""",
)
parser.add_argument(
"--version", "-V", action="store_true", help="Show version and exit"
)
parser.add_argument(
"-z",
"--oneshot",
metavar="PROMPT",
default=None,
help=(
"One-shot mode: send a single prompt and print ONLY the final "
"response text to stdout. No banner, no spinner, no tool "
"previews, no session_id line. Tools, memory, rules, and "
"AGENTS.md in the CWD are loaded as normal; approvals are "
"auto-bypassed. Intended for scripts / pipes."
),
)
# --model / --provider are accepted at the top level so they can pair
# with -z without needing the `chat` subcommand. If neither -z nor a
# subcommand consumes them, they fall through harmlessly as None.
# Mirrors `hermes chat --model ... --provider ...` semantics.
parser.add_argument(
"-m",
"--model",
default=None,
help=(
"Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). "
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var."
),
)
parser.add_argument(
"--provider",
default=None,
help=(
"Provider override for this invocation (e.g. openrouter, anthropic). "
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var."
),
)
parser.add_argument(
"-t",
"--toolsets",
default=None,
help="Comma-separated toolsets to enable for this invocation. Applies to -z/--oneshot and --tui.",
)
parser.add_argument(
"--resume",
"-r",
metavar="SESSION",
default=None,
help="Resume a previous session by ID or title",
)
parser.add_argument(
"--continue",
"-c",
dest="continue_last",
nargs="?",
const=True,
default=None,
metavar="SESSION_NAME",
help="Resume a session by name, or the most recent if no name given",
)
parser.add_argument(
"--worktree",
"-w",
action="store_true",
default=False,
help="Run in an isolated git worktree (for parallel agents)",
)
parser.add_argument(
"--accept-hooks",
action="store_true",
default=False,
help=(
"Auto-approve any unseen shell hooks declared in config.yaml "
"without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or "
"hooks_auto_accept: true in config.yaml. Use on CI / headless "
"runs that can't prompt."
),
)
parser.add_argument(
"--skills",
"-s",
action="append",
default=None,
help="Preload one or more skills for the session (repeat flag or comma-separate)",
)
parser.add_argument(
"--yolo",
action="store_true",
default=False,
help="Bypass all dangerous command approval prompts (use at your own risk)",
)
parser.add_argument(
"--pass-session-id",
action="store_true",
default=False,
help="Include the session ID in the agent's system prompt",
)
parser.add_argument(
"--ignore-user-config",
action="store_true",
default=False,
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)",
)
parser.add_argument(
"--ignore-rules",
action="store_true",
default=False,
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
)
parser.add_argument(
"--tui",
action="store_true",
default=False,
help="Launch the modern TUI instead of the classic REPL",
)
parser.add_argument(
"--dev",
dest="tui_dev",
action="store_true",
default=False,
help="With --tui: run TypeScript sources via tsx (skip dist build)",
)
subparsers = parser.add_subparsers(dest="command", help="Command to run")
# =========================================================================
# chat command
# =========================================================================
chat_parser = subparsers.add_parser(
"chat",
help="Interactive chat with the agent",
description="Start an interactive chat session with Hermes Agent",
)
chat_parser.add_argument(
"-q", "--query", help="Single query (non-interactive mode)"
)
chat_parser.add_argument(
"--image", help="Optional local image path to attach to a single query"
)
chat_parser.add_argument(
"-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)"
)
chat_parser.add_argument(
"-t", "--toolsets", help="Comma-separated toolsets to enable"
)
chat_parser.add_argument(
"-s",
"--skills",
action="append",
default=argparse.SUPPRESS,
help="Preload one or more skills for the session (repeat flag or comma-separate)",
)
chat_parser.add_argument(
"--provider",
# No `choices=` here: user-defined providers from config.yaml `providers:`
# are also valid values, and runtime resolution (resolve_runtime_provider)
# handles validation/error reporting consistently with the top-level
# `--provider` flag.
default=None,
help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
)
chat_parser.add_argument(
"-v", "--verbose", action="store_true", help="Verbose output"
)
chat_parser.add_argument(
"-Q",
"--quiet",
action="store_true",
help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info.",
)
chat_parser.add_argument(
"--resume",
"-r",
metavar="SESSION_ID",
default=argparse.SUPPRESS,
help="Resume a previous session by ID (shown on exit)",
)
chat_parser.add_argument(
"--continue",
"-c",
dest="continue_last",
nargs="?",
const=True,
default=argparse.SUPPRESS,
metavar="SESSION_NAME",
help="Resume a session by name, or the most recent if no name given",
)
chat_parser.add_argument(
"--worktree",
"-w",
action="store_true",
default=argparse.SUPPRESS,
help="Run in an isolated git worktree (for parallel agents on the same repo)",
)
chat_parser.add_argument(
"--accept-hooks",
action="store_true",
default=argparse.SUPPRESS,
help=(
"Auto-approve any unseen shell hooks declared in config.yaml "
"without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and "
"hooks_auto_accept: in config.yaml)."
),
)
chat_parser.add_argument(
"--checkpoints",
action="store_true",
default=False,
help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)",
)
chat_parser.add_argument(
"--max-turns",
type=int,
default=None,
metavar="N",
help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)",
)
chat_parser.add_argument(
"--yolo",
action="store_true",
default=argparse.SUPPRESS,
help="Bypass all dangerous command approval prompts (use at your own risk)",
)
chat_parser.add_argument(
"--pass-session-id",
action="store_true",
default=argparse.SUPPRESS,
help="Include the session ID in the agent's system prompt",
)
chat_parser.add_argument(
"--ignore-user-config",
action="store_true",
default=argparse.SUPPRESS,
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.",
)
chat_parser.add_argument(
"--ignore-rules",
action="store_true",
default=argparse.SUPPRESS,
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.",
)
chat_parser.add_argument(
"--source",
default=None,
help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.",
)
chat_parser.add_argument(
"--tui",
action="store_true",
default=False,
help="Launch the modern TUI instead of the classic REPL",
)
chat_parser.add_argument(
"--dev",
dest="tui_dev",
action="store_true",
default=False,
help="With --tui: run TypeScript sources via tsx (skip dist build)",
)
parser, subparsers, chat_parser = build_top_level_parser()
chat_parser.set_defaults(func=cmd_chat)
# =========================================================================
@@ -8782,6 +8647,13 @@ For more help on a command:
webhook_parser.set_defaults(func=cmd_webhook)
# =========================================================================
# kanban command — multi-profile collaboration board
# =========================================================================
from hermes_cli.kanban import build_parser as _build_kanban_parser
kanban_parser = _build_kanban_parser(subparsers)
kanban_parser.set_defaults(func=cmd_kanban)
# =========================================================================
# hooks command — shell-hook inspection and management
# =========================================================================
@@ -9807,15 +9679,8 @@ Examples:
# Launch hermes --resume <id> by replacing the current process
print(f"Resuming session: {selected_id}")
hermes_bin = shutil.which("hermes")
if hermes_bin:
os.execvp(hermes_bin, ["hermes", "--resume", selected_id])
else:
# Fallback: re-invoke via python -m
os.execvp(
sys.executable,
[sys.executable, "-m", "hermes_cli.main", "--resume", selected_id],
)
from hermes_cli.relaunch import relaunch
relaunch(["--resume", selected_id])
return # won't reach here after execvp
elif action == "stats":
@@ -10173,6 +10038,22 @@ Examples:
"Alternatively set HERMES_DASHBOARD_TUI=1."
),
)
# Lifecycle flags — mutually exclusive with each other and with the
# start-a-server flags above (if both are passed, --stop / --status win
# because they exit before the server is started). The dashboard has
# no service manager and no PID file, so these scan the process table
# for `hermes dashboard` cmdlines and SIGTERM them directly — the same
# path `hermes update` uses to clean up stale dashboards.
dashboard_parser.add_argument(
"--stop",
action="store_true",
help="Stop all running hermes dashboard processes and exit",
)
dashboard_parser.add_argument(
"--status",
action="store_true",
help="List running hermes dashboard processes and exit",
)
dashboard_parser.set_defaults(func=cmd_dashboard)
# =========================================================================
+10 -1
View File
@@ -539,6 +539,7 @@ def resolve_display_context_length(
api_key: str = "",
model_info: Optional[ModelInfo] = None,
custom_providers: list | None = None,
config_context_length: int | None = None,
) -> Optional[int]:
"""Resolve the context length to show in /model output.
@@ -565,6 +566,7 @@ def resolve_display_context_length(
api_key=api_key or "",
provider=provider or None,
custom_providers=custom_providers,
config_context_length=config_context_length,
)
if ctx:
return int(ctx)
@@ -1501,7 +1503,14 @@ def list_authenticated_providers(
current_base_url
and api_url == current_base_url.strip().rstrip("/")
):
slug = current_provider or custom_provider_slug(display_name)
# Guard against bare "custom" slug left by a prior
# failed switch — always resolve to the canonical
# custom:<name> form. (GH #17478)
slug = (
current_provider
if current_provider and current_provider != "custom"
else custom_provider_slug(display_name)
)
else:
slug = custom_provider_slug(display_name)
groups[group_key] = {
+45 -16
View File
@@ -40,6 +40,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("anthropic/claude-sonnet-4.5", ""),
("anthropic/claude-haiku-4.5", ""),
("openrouter/elephant-alpha", "free"),
("openrouter/owl-alpha", "free"),
("openai/gpt-5.5", ""),
("openai/gpt-5.4-mini", ""),
("xiaomi/mimo-v2.5-pro", ""),
@@ -2034,28 +2035,56 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
return None
headers: dict[str, str] = {"anthropic-version": "2023-06-01"}
if _is_oauth_token(token):
is_oauth = _is_oauth_token(token)
if is_oauth:
headers["Authorization"] = f"Bearer {token}"
from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS
from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
else:
headers["x-api-key"] = token
req = urllib.request.Request(
"https://api.anthropic.com/v1/models",
headers=headers,
)
try:
def _do_request(h: dict[str, str]):
req = urllib.request.Request(
"https://api.anthropic.com/v1/models",
headers=h,
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode())
models = [m["id"] for m in data.get("data", []) if m.get("id")]
# Sort: latest/largest first (opus > sonnet > haiku, higher version first)
return sorted(models, key=lambda m: (
"opus" not in m, # opus first
"sonnet" not in m, # then sonnet
"haiku" not in m, # then haiku
m, # alphabetical within tier
))
return json.loads(resp.read().decode())
try:
try:
data = _do_request(headers)
except urllib.error.HTTPError as http_err:
# Reactive recovery for OAuth subscriptions that reject the 1M
# context beta with 400 "long context beta is not yet available
# for this subscription". Retry once without the beta; re-raise
# anything else so the outer except logs it.
if (
is_oauth
and http_err.code == 400
):
try:
body_text = http_err.read().decode(errors="ignore").lower()
except Exception:
body_text = ""
if "long context beta" in body_text and "not yet available" in body_text:
headers["anthropic-beta"] = ",".join(
[b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]
+ list(_OAUTH_ONLY_BETAS)
)
data = _do_request(headers)
else:
raise
else:
raise
models = [m["id"] for m in data.get("data", []) if m.get("id")]
# Sort: latest/largest first (opus > sonnet > haiku, higher version first)
return sorted(models, key=lambda m: (
"opus" not in m, # opus first
"sonnet" not in m, # then sonnet
"haiku" not in m, # then haiku
m, # alphabetical within tier
))
except Exception as e:
import logging
logging.getLogger(__name__).debug("Failed to fetch Anthropic models: %s", e)
+36 -2
View File
@@ -44,6 +44,40 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
def platform_label(key: str, default: str = "") -> str:
"""Return the display label for a platform key, or *default*."""
"""Return the display label for a platform key, or *default*.
Checks the static PLATFORMS dict first, then the plugin platform
registry for dynamically registered platforms.
"""
info = PLATFORMS.get(key)
return info.label if info is not None else default
if info is not None:
return info.label
# Check plugin registry
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(key)
if entry:
return f"{entry.emoji} {entry.label}" if entry.emoji else entry.label
except Exception:
pass
return default
def get_all_platforms() -> "OrderedDict[str, PlatformInfo]":
"""Return PLATFORMS merged with any plugin-registered platforms.
Plugin platforms are appended after builtins. This is the function
that tools_config and skills_config should use for platform menus.
"""
merged = OrderedDict(PLATFORMS)
try:
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
if entry.name not in merged:
merged[entry.name] = PlatformInfo(
label=f"{entry.emoji} {entry.label}" if entry.emoji else entry.label,
default_toolset=f"hermes-{entry.name}",
)
except Exception:
pass
return merged
+89 -6
View File
@@ -37,6 +37,7 @@ import importlib
import importlib.metadata
import importlib.util
import logging
import os
import sys
import types
from dataclasses import dataclass, field
@@ -47,6 +48,19 @@ from hermes_constants import get_hermes_home
from utils import env_var_enabled
from hermes_cli.config import cfg_get
def get_bundled_plugins_dir() -> Path:
"""Locate the bundled ``plugins/`` directory.
Honours ``HERMES_BUNDLED_PLUGINS`` (set by the Nix wrapper / packaged
installs) so read-only store paths are consulted first. Falls back to
the in-repo path used during development.
"""
env_override = os.getenv("HERMES_BUNDLED_PLUGINS")
if env_override:
return Path(env_override)
return Path(__file__).resolve().parent.parent / "plugins"
try:
import yaml
except ImportError: # pragma: no cover yaml is optional at import time
@@ -156,7 +170,7 @@ def _get_enabled_plugins() -> Optional[set]:
# Data classes
# ---------------------------------------------------------------------------
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive"}
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform"}
@dataclass
@@ -182,6 +196,11 @@ class PluginManifest:
# Selection via ``<category>.provider`` config key; the
# category's own discovery system handles loading and the
# general scanner skips these.
# ``platform``: gateway messaging platform adapter (e.g. IRC). Bundled
# platform plugins auto-load so every shipped platform is
# available out of the box; user-installed platform plugins
# in ~/.hermes/plugins/ still gated by ``plugins.enabled``
# (untrusted code).
kind: str = "standalone"
# Registry key — path-derived, used by ``plugins.enabled``/``disabled``
# lookups and by ``hermes plugins list``. For a flat plugin at
@@ -445,6 +464,62 @@ class PluginContext:
self.manifest.name, provider.name,
)
# -- platform adapter registration ---------------------------------------
def register_platform(
self,
name: str,
label: str,
adapter_factory: Callable,
check_fn: Callable,
validate_config: Callable | None = None,
required_env: list | None = None,
install_hint: str = "",
**entry_kwargs: Any,
) -> None:
"""Register a gateway platform adapter.
The adapter_factory receives a ``PlatformConfig`` and returns a
``BasePlatformAdapter`` subclass instance. The gateway calls
``check_fn()`` before instantiation to verify dependencies.
Extra keyword arguments are forwarded to ``PlatformEntry`` (e.g.
``setup_fn``, ``emoji``, ``allowed_users_env``, ``platform_hint``).
Unknown keys raise TypeError from the dataclass constructor.
Example::
ctx.register_platform(
name="irc",
label="IRC",
adapter_factory=lambda cfg: IRCAdapter(cfg),
check_fn=lambda: True,
emoji="💬",
setup_fn=irc_interactive_setup,
)
"""
from gateway.platform_registry import platform_registry, PlatformEntry
entry_kwargs.setdefault("plugin_name", self.manifest.name)
entry = PlatformEntry(
name=name,
label=label,
adapter_factory=adapter_factory,
check_fn=check_fn,
validate_config=validate_config,
required_env=required_env or [],
install_hint=install_hint,
source="plugin",
**entry_kwargs,
)
platform_registry.register(entry)
self._manager._plugin_platform_names.add(name)
logger.debug(
"Plugin %s registered platform: %s",
self.manifest.name,
name,
)
# -- hook registration --------------------------------------------------
def register_hook(self, hook_name: str, callback: Callable) -> None:
@@ -523,6 +598,7 @@ class PluginManager:
self._plugins: Dict[str, LoadedPlugin] = {}
self._hooks: Dict[str, List[Callable]] = {}
self._plugin_tool_names: Set[str] = set()
self._plugin_platform_names: Set[str] = set()
self._cli_commands: Dict[str, dict] = {}
self._context_engine = None # Set by a plugin via register_context_engine()
self._plugin_commands: Dict[str, dict] = {} # Slash commands registered by plugins
@@ -565,16 +641,19 @@ class PluginManager:
# - category: ``plugins/image_gen/openai/plugin.yaml`` (backend)
#
# ``memory/`` and ``context_engine/`` are skipped at the top level —
# they have their own discovery systems. Porting those to the
# category-namespace ``kind: exclusive`` model is a future PR.
repo_plugins = Path(__file__).resolve().parent.parent / "plugins"
# they have their own discovery systems. ``platforms/`` is a category
# holding platform adapters (scanned one level deeper below).
repo_plugins = get_bundled_plugins_dir()
manifests.extend(
self._scan_directory(
repo_plugins,
source="bundled",
skip_names={"memory", "context_engine"},
skip_names={"memory", "context_engine", "platforms"},
)
)
manifests.extend(
self._scan_directory(repo_plugins / "platforms", source="bundled")
)
# 2. User plugins (~/.hermes/plugins/)
user_dir = get_hermes_home() / "plugins"
@@ -631,7 +710,11 @@ class PluginManager:
# just work. Selection among them (e.g. which image_gen backend
# services calls) is driven by ``<category>.provider`` config,
# enforced by the tool wrapper.
if manifest.kind == "backend" and manifest.source == "bundled":
#
# Bundled platform plugins (gateway adapters like IRC) auto-load
# for the same reason: every platform Hermes ships must be
# available out of the box without the user having to opt in.
if manifest.source == "bundled" and manifest.kind in ("backend", "platform"):
self._load_plugin(manifest)
continue
+5 -6
View File
@@ -630,10 +630,9 @@ def _plugin_exists(name: str) -> bool:
manifest = _read_manifest(child)
if manifest.get("name") == name:
return True
# Bundled: <repo>/plugins/<name>/
from pathlib import Path as _P
import hermes_cli
repo_plugins = _P(hermes_cli.__file__).resolve().parent.parent / "plugins"
# Bundled: <repo>/plugins/<name>/ (or HERMES_BUNDLED_PLUGINS on Nix).
from hermes_cli.plugins import get_bundled_plugins_dir
repo_plugins = get_bundled_plugins_dir()
if repo_plugins.is_dir():
candidate = repo_plugins / name
if candidate.is_dir() and (
@@ -660,8 +659,8 @@ def _discover_all_plugins() -> list:
seen: dict = {} # name -> (name, version, description, source, path)
# Bundled (<repo>/plugins/<name>/), excluding memory/ and context_engine/
import hermes_cli
repo_plugins = Path(hermes_cli.__file__).resolve().parent.parent / "plugins"
from hermes_cli.plugins import get_bundled_plugins_dir
repo_plugins = get_bundled_plugins_dir()
for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")):
if not base.is_dir():
continue
+11 -2
View File
@@ -11,7 +11,7 @@ zero migration needed.
Usage::
hermes profile create coder # fresh profile + bundled skills
hermes profile create coder --clone # also copy config, .env, SOUL.md
hermes profile create coder --clone # also copy config, .env, SOUL.md, skills
hermes profile create coder --clone-all # full copy of source profile
coder chat # use via wrapper alias
hermes -p coder chat # or via flag
@@ -411,7 +411,8 @@ def create_profile(
clone_all:
If True, do a full copytree of the source (all state).
clone_config:
If True, copy only config files (config.yaml, .env, SOUL.md).
If True, copy config files (config.yaml, .env, SOUL.md), installed
skills, and selected profile identity files from the source profile.
no_alias:
If True, skip wrapper script creation.
@@ -469,6 +470,14 @@ def create_profile(
if src.exists():
shutil.copy2(src, profile_dir / filename)
# Clone installed skills from the source profile. The dashboard's
# "clone from default" flow is expected to preserve both bundled
# and user-installed skills so the new profile immediately has the
# same agent capabilities as the source profile.
source_skills = source_dir / "skills"
if source_skills.is_dir():
shutil.copytree(source_skills, profile_dir / "skills", dirs_exist_ok=True)
# Clone memory and other subdirectory files
for relpath in _CLONE_SUBDIR_FILES:
src = source_dir / relpath
+25
View File
@@ -585,6 +585,12 @@ def resolve_custom_provider(
if not requested:
return None
# If the stored provider is the bare string "custom" (corrupt state
# from a prior model-switch bug), fall back to the first custom
# provider entry so existing configs self-heal. (GH #17478)
bare_custom_fallback = requested == "custom"
first_valid = None
for entry in custom_providers:
if not isinstance(entry, dict):
continue
@@ -599,6 +605,10 @@ def resolve_custom_provider(
if not display_name or not api_url:
continue
# Stash the first valid entry for bare-"custom" fallback
if first_valid is None:
first_valid = (display_name, api_url)
slug = custom_provider_slug(display_name)
if requested not in {display_name.lower(), slug}:
continue
@@ -614,6 +624,21 @@ def resolve_custom_provider(
source="user-config",
)
# Self-heal: bare "custom" matched nothing — return first valid entry
if bare_custom_fallback and first_valid:
dname, aurl = first_valid
slug = custom_provider_slug(dname)
return ProviderDef(
id=slug,
name=dname,
transport="openai_chat",
api_key_env_vars=(),
base_url=aurl,
is_aggregator=False,
auth_type="api_key",
source="user-config",
)
return None
+149
View File
@@ -0,0 +1,149 @@
"""
Unified self-relaunch for Hermes CLI.
Preserves critical flags (--tui, --dev, --profile, --model, etc.) across
process replacement so that ``hermes sessions browse`` or post-setup relaunch
doesn't silently drop the user's UI mode or other preferences.
Also works when ``hermes`` is not on PATH (e.g. ``nix run`` or ``python -m``).
"""
import os
import shutil
import sys
from typing import Optional, Sequence
from hermes_cli._parser import (
PRE_ARGPARSE_INHERITED_FLAGS,
build_top_level_parser,
)
def _build_inherited_flag_table() -> list[tuple[str, bool]]:
"""Build the ``(option_string, takes_value)`` table of flags that must
survive a self-relaunch, by introspecting the real parser used by
``hermes`` itself.
A flag participates if its argparse Action carries
``inherit_on_relaunch = True`` set by ``_parser._inherited_flag``.
"""
parser, _subparsers, chat_parser = build_top_level_parser()
table: list[tuple[str, bool]] = []
seen: set[tuple[str, bool]] = set()
for p in (parser, chat_parser):
for action in p._actions:
if not action.option_strings:
continue # positional / no flag form
if not getattr(action, "inherit_on_relaunch", False):
continue
takes_value = action.nargs != 0 # store_true/false set nargs=0
for opt in action.option_strings:
key = (opt, takes_value)
if key not in seen:
seen.add(key)
table.append(key)
table.extend(PRE_ARGPARSE_INHERITED_FLAGS)
return table
_INHERITED_FLAGS_TABLE = _build_inherited_flag_table()
def _extract_inherited_flags(argv: Sequence[str]) -> list[str]:
"""Pull out flags that should carry over into a self-relaunched hermes."""
flags: list[str] = []
i = 0
while i < len(argv):
arg = argv[i]
if "=" in arg:
key = arg.split("=", 1)[0]
for flag, _ in _INHERITED_FLAGS_TABLE:
if key == flag:
flags.append(arg)
break
i += 1
continue
for flag, takes_value in _INHERITED_FLAGS_TABLE:
if arg == flag:
flags.append(arg)
if takes_value and i + 1 < len(argv) and not argv[i + 1].startswith("-"):
flags.append(argv[i + 1])
i += 1
break
i += 1
return flags
def resolve_hermes_bin() -> Optional[str]:
"""Find the hermes entry point.
Priority:
1. ``sys.argv[0]`` if it resolves to a real executable.
2. ``shutil.which("hermes")`` on PATH.
3. ``None`` caller should fall back to ``python -m hermes_cli.main``.
"""
argv0 = sys.argv[0]
# Absolute path to an executable (covers nix store, venv wrappers, etc.)
if os.path.isabs(argv0) and os.path.isfile(argv0) and os.access(argv0, os.X_OK):
return argv0
# Relative path — resolve against CWD
if not argv0.startswith("-") and os.path.isfile(argv0):
abs_path = os.path.abspath(argv0)
if os.access(abs_path, os.X_OK):
return abs_path
# PATH lookup
path_bin = shutil.which("hermes")
if path_bin:
return path_bin
return None
def build_relaunch_argv(
extra_args: Sequence[str],
*,
preserve_inherited: bool = True,
original_argv: Optional[Sequence[str]] = None,
) -> list[str]:
"""Construct an argv list for replacing the current process with hermes.
Args:
extra_args: Arguments to append (e.g. ``["--resume", id]``).
preserve_inherited: Whether to carry over UI / behaviour flags
tagged with ``inherit_on_relaunch`` in the parser.
original_argv: The original argv to scan for flags (defaults to
``sys.argv[1:]``).
"""
bin_path = resolve_hermes_bin()
if bin_path:
argv = [bin_path]
else:
argv = [sys.executable, "-m", "hermes_cli.main"]
src = list(original_argv) if original_argv is not None else list(sys.argv[1:])
if preserve_inherited:
argv.extend(_extract_inherited_flags(src))
argv.extend(extra_args)
return argv
def relaunch(
extra_args: Sequence[str],
*,
preserve_inherited: bool = True,
original_argv: Optional[Sequence[str]] = None,
) -> None:
"""Replace the current process with a fresh hermes invocation."""
new_argv = build_relaunch_argv(
extra_args, preserve_inherited=preserve_inherited, original_argv=original_argv
)
os.execvp(new_argv[0], new_argv)
+9 -2
View File
@@ -391,7 +391,14 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
"api_key": resolved_api_key,
"model": entry.get("default_model", ""),
}
api_mode = _parse_api_mode(entry.get("api_mode"))
# The v11→v12 migration writes the API mode under the new
# ``transport`` field, but hand-edited configs may still
# use the legacy ``api_mode`` spelling. Accept both —
# the runtime normaliser ``_normalize_custom_provider_entry``
# already does, so without this lift every migrated config
# silently downgrades codex_responses / anthropic_messages
# providers to chat_completions in the resolved runtime.
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
if api_mode:
result["api_mode"] = api_mode
return result
@@ -409,7 +416,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
"api_key": resolved_api_key,
"model": entry.get("default_model", ""),
}
api_mode = _parse_api_mode(entry.get("api_mode"))
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
if api_mode:
result["api_mode"] = api_mode
return result
+38 -153
View File
@@ -2204,80 +2204,7 @@ def _setup_mattermost():
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
if home_channel:
save_env_value("MATTERMOST_HOME_CHANNEL", home_channel)
def _setup_whatsapp():
"""Configure WhatsApp bridge."""
print_header("WhatsApp")
existing = get_env_value("WHATSAPP_ENABLED")
if existing:
print_info("WhatsApp: already enabled")
return
print_info("WhatsApp connects via a built-in bridge (Baileys).")
print_info("Requires Node.js. Run 'hermes whatsapp' for guided setup.")
print()
if prompt_yes_no("Enable WhatsApp now?", True):
save_env_value("WHATSAPP_ENABLED", "true")
print_success("WhatsApp enabled")
print_info("Run 'hermes whatsapp' to choose your mode (separate bot number")
print_info("or personal self-chat) and pair via QR code.")
def _setup_weixin():
"""Configure Weixin (personal WeChat) via iLink Bot API QR login."""
from hermes_cli.gateway import _setup_weixin as _gateway_setup_weixin
_gateway_setup_weixin()
def _setup_signal():
"""Configure Signal via gateway setup."""
from hermes_cli.gateway import _setup_signal as _gateway_setup_signal
_gateway_setup_signal()
def _setup_email():
"""Configure Email via gateway setup."""
from hermes_cli.gateway import _setup_email as _gateway_setup_email
_gateway_setup_email()
def _setup_sms():
"""Configure SMS (Twilio) via gateway setup."""
from hermes_cli.gateway import _setup_sms as _gateway_setup_sms
_gateway_setup_sms()
def _setup_dingtalk():
"""Configure DingTalk via gateway setup."""
from hermes_cli.gateway import _setup_dingtalk as _gateway_setup_dingtalk
_gateway_setup_dingtalk()
def _setup_feishu():
"""Configure Feishu / Lark via gateway setup."""
from hermes_cli.gateway import _setup_feishu as _gateway_setup_feishu
_gateway_setup_feishu()
def _setup_yuanbao():
"""Configure Yuanbao via gateway setup."""
from hermes_cli.gateway import _setup_yuanbao as _gateway_setup_yuanbao
_gateway_setup_yuanbao()
def _setup_wecom():
"""Configure WeCom (Enterprise WeChat) via gateway setup."""
from hermes_cli.gateway import _setup_wecom as _gateway_setup_wecom
_gateway_setup_wecom()
def _setup_wecom_callback():
"""Configure WeCom Callback (self-built app) via gateway setup."""
from hermes_cli.gateway import _setup_wecom_callback as _gw_setup
_gw_setup()
print_info(" Open config in your editor: hermes config edit")
def _setup_bluebubbles():
@@ -2395,49 +2322,27 @@ def _setup_webhooks():
print_info(" https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/#configuring-routes")
print()
print_info(" Open config in your editor: hermes config edit")
# Platform registry for the gateway checklist
_GATEWAY_PLATFORMS = [
("Telegram", "TELEGRAM_BOT_TOKEN", _setup_telegram),
("Discord", "DISCORD_BOT_TOKEN", _setup_discord),
("Slack", "SLACK_BOT_TOKEN", _setup_slack),
("Signal", "SIGNAL_HTTP_URL", _setup_signal),
("Email", "EMAIL_ADDRESS", _setup_email),
("SMS (Twilio)", "TWILIO_ACCOUNT_SID", _setup_sms),
("Matrix", "MATRIX_ACCESS_TOKEN", _setup_matrix),
("Mattermost", "MATTERMOST_TOKEN", _setup_mattermost),
("WhatsApp", "WHATSAPP_ENABLED", _setup_whatsapp),
("DingTalk", "DINGTALK_CLIENT_ID", _setup_dingtalk),
("Feishu / Lark", "FEISHU_APP_ID", _setup_feishu),
("Yuanbao", "YUANBAO_APP_ID", _setup_yuanbao),
("WeCom (Enterprise WeChat)", "WECOM_BOT_ID", _setup_wecom),
("WeCom Callback (Self-Built App)", "WECOM_CALLBACK_CORP_ID", _setup_wecom_callback),
("Weixin (WeChat)", "WEIXIN_ACCOUNT_ID", _setup_weixin),
("BlueBubbles (iMessage)", "BLUEBUBBLES_SERVER_URL", _setup_bluebubbles),
("QQ Bot", "QQ_APP_ID", _setup_qqbot),
("Webhooks (GitHub, GitLab, etc.)", "WEBHOOK_ENABLED", _setup_webhooks),
]
print_info(" Open config in your editor: hermes config edit")
def setup_gateway(config: dict):
"""Configure messaging platform integrations."""
from hermes_cli.gateway import _all_platforms, _platform_status, _configure_platform
print_header("Messaging Platforms")
print_info("Connect to messaging platforms to chat with Hermes from anywhere.")
print_info("Toggle with Space, confirm with Enter.")
print()
# Build checklist items, pre-selecting already-configured platforms
platforms = _all_platforms()
# Build checklist, pre-selecting already-configured platforms.
items = []
pre_selected = []
for i, (name, env_var, _func) in enumerate(_GATEWAY_PLATFORMS):
# Matrix has two possible env vars
is_configured = bool(get_env_value(env_var))
if name == "Matrix" and not is_configured:
is_configured = bool(get_env_value("MATRIX_PASSWORD"))
label = f"{name} (configured)" if is_configured else name
items.append(label)
if is_configured:
for i, plat in enumerate(platforms):
status = _platform_status(plat)
items.append(f"{plat['emoji']} {plat['label']} ({status})")
if status == "configured":
pre_selected.append(i)
selected = prompt_checklist("Select platforms to configure:", items, pre_selected)
@@ -2447,28 +2352,22 @@ def setup_gateway(config: dict):
return
for idx in selected:
name, _env_var, setup_func = _GATEWAY_PLATFORMS[idx]
setup_func()
_configure_platform(platforms[idx])
# ── Gateway Service Setup ──
any_messaging = (
get_env_value("TELEGRAM_BOT_TOKEN")
or get_env_value("DISCORD_BOT_TOKEN")
or get_env_value("SLACK_BOT_TOKEN")
or get_env_value("SIGNAL_HTTP_URL")
or get_env_value("EMAIL_ADDRESS")
or get_env_value("TWILIO_ACCOUNT_SID")
or get_env_value("MATTERMOST_TOKEN")
or get_env_value("MATRIX_ACCESS_TOKEN")
or get_env_value("MATRIX_PASSWORD")
or get_env_value("WHATSAPP_ENABLED")
or get_env_value("DINGTALK_CLIENT_ID")
or get_env_value("FEISHU_APP_ID")
or get_env_value("WECOM_BOT_ID")
or get_env_value("WEIXIN_ACCOUNT_ID")
or get_env_value("BLUEBUBBLES_SERVER_URL")
or get_env_value("QQ_APP_ID")
or get_env_value("WEBHOOK_ENABLED")
# Count any platform (built-in or plugin) the user configured during this
# setup pass — reuses ``_platform_status`` so plugin platforms like IRC
# are picked up without another hard-coded env-var list.
def _is_progress(status: str) -> bool:
s = status.lower()
return not (
s == "not configured"
or s.startswith("partially")
or s.startswith("plugin disabled")
)
any_messaging = any(
_is_progress(_platform_status(p)) for p in _all_platforms()
)
if any_messaging:
print()
@@ -2738,13 +2637,18 @@ def _get_section_config_summary(config: dict, section_key: str) -> Optional[str]
return f"max turns: {max_turns}"
elif section_key == "gateway":
platforms = [
_gateway_platform_short_label(label)
for label, env_var, _ in _GATEWAY_PLATFORMS
if get_env_value(env_var)
from hermes_cli.gateway import _all_platforms, _platform_status
# Count any non-empty status other than the "not configured" sentinel —
# platforms like WhatsApp ("enabled, not paired"), Matrix ("configured
# + E2EE"), and Signal ("partially configured") all indicate the user
# has already started setup and we shouldn't force the section to rerun.
configured = [
_gateway_platform_short_label(plat["label"])
for plat in _all_platforms()
if _platform_status(plat) and _platform_status(plat) != "not configured"
]
if platforms:
return ", ".join(platforms)
if configured:
return ", ".join(configured)
return None # No platforms configured — section must run
elif section_key == "tools":
@@ -3249,33 +3153,14 @@ def run_setup_wizard(args):
_offer_launch_chat()
def _resolve_hermes_chat_argv() -> Optional[list[str]]:
"""Resolve argv for launching ``hermes chat`` in a fresh process."""
hermes_bin = shutil.which("hermes")
if hermes_bin:
return [hermes_bin, "chat"]
try:
if importlib.util.find_spec("hermes_cli") is not None:
return [sys.executable, "-m", "hermes_cli.main", "chat"]
except Exception:
pass
return None
def _offer_launch_chat():
"""Prompt the user to jump straight into chat after setup."""
print()
if not prompt_yes_no("Launch hermes chat now?", True):
return
chat_argv = _resolve_hermes_chat_argv()
if not chat_argv:
print_info("Could not relaunch Hermes automatically. Run 'hermes chat' manually.")
return
os.execvp(chat_argv[0], chat_argv)
from hermes_cli.relaunch import relaunch
relaunch(["chat"])
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
+30 -19
View File
@@ -91,12 +91,12 @@ def show_status(args):
"""Show status of all Hermes Agent components."""
show_all = getattr(args, 'all', False)
deep = getattr(args, 'deep', False)
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
print(color("│ ⚕ Hermes Agent Status │", Colors.CYAN))
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
# =========================================================================
# Environment
# =========================================================================
@@ -104,7 +104,7 @@ def show_status(args):
print(color("◆ Environment", Colors.CYAN, Colors.BOLD))
print(f" Project: {PROJECT_ROOT}")
print(f" Python: {sys.version.split()[0]}")
env_path = get_env_path()
print(f" .env file: {check_mark(env_path.exists())} {'exists' if env_path.exists() else 'not found'}")
@@ -115,13 +115,13 @@ def show_status(args):
print(f" Model: {_configured_model_label(config)}")
print(f" Provider: {_effective_provider_label()}")
# =========================================================================
# API Keys
# =========================================================================
print()
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
keys = {
"OpenRouter": "OPENROUTER_API_KEY",
"OpenAI": "OPENAI_API_KEY",
@@ -140,7 +140,7 @@ def show_status(args):
"ElevenLabs": "ELEVENLABS_API_KEY",
"GitHub": "GITHUB_TOKEN",
}
for name, env_var in keys.items():
value = get_env_value(env_var) or ""
has_key = bool(value)
@@ -322,13 +322,13 @@ def show_status(args):
# =========================================================================
print()
print(color("◆ Terminal Backend", Colors.CYAN, Colors.BOLD))
terminal_cfg = config.get("terminal", {}) if isinstance(config.get("terminal"), dict) else {}
terminal_env = os.getenv("TERMINAL_ENV", "")
if not terminal_env:
terminal_env = terminal_cfg.get("backend", "local")
print(f" Backend: {terminal_env}")
if terminal_env == "ssh":
ssh_host = os.getenv("TERMINAL_SSH_HOST", "")
ssh_user = os.getenv("TERMINAL_SSH_USER", "")
@@ -357,16 +357,16 @@ def show_status(args):
print(f" Auth detail: {line}")
print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}")
print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation")
sudo_password = os.getenv("SUDO_PASSWORD", "")
print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}")
# =========================================================================
# Messaging Platforms
# =========================================================================
print()
print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD))
platforms = {
"Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"),
"Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"),
@@ -384,7 +384,7 @@ def show_status(args):
"QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
"Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL"),
}
for name, (token_var, home_var) in platforms.items():
token = os.getenv(token_var, "")
has_token = bool(token)
@@ -401,7 +401,18 @@ def show_status(args):
status += f" (home: {home_channel})"
print(f" {name:<12} {check_mark(has_token)} {status}")
# Plugin-registered platforms
try:
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
configured = entry.check_fn()
status_str = "configured" if configured else "not configured"
label = entry.label
print(f" {label:<12} {check_mark(configured)} {status_str} (plugin)")
except Exception:
pass
# =========================================================================
# Gateway Status
# =========================================================================
@@ -437,13 +448,13 @@ def show_status(args):
else:
print(f" Status: {color('N/A', Colors.DIM)}")
print(" Manager: (not supported on this platform)")
# =========================================================================
# Cron Jobs
# =========================================================================
print()
print(color("◆ Scheduled Jobs", Colors.CYAN, Colors.BOLD))
jobs_file = get_hermes_home() / "cron" / "jobs.json"
if jobs_file.exists():
import json
@@ -457,13 +468,13 @@ def show_status(args):
print(" Jobs: (error reading jobs file)")
else:
print(" Jobs: 0")
# =========================================================================
# Sessions
# =========================================================================
print()
print(color("◆ Sessions", Colors.CYAN, Colors.BOLD))
sessions_file = get_hermes_home() / "sessions" / "sessions.json"
if sessions_file.exists():
import json
@@ -475,7 +486,7 @@ def show_status(args):
print(" Active: (error reading sessions file)")
else:
print(" Active: 0")
# =========================================================================
# Deep checks
# =========================================================================
@@ -511,7 +522,7 @@ def show_status(args):
print(f" Port 18789: {'in use' if port_in_use else 'available'}")
except OSError:
pass
print()
print(color("" * 60, Colors.DIM))
print(color(" Run 'hermes doctor' for detailed diagnostics", Colors.DIM))
+3
View File
@@ -100,6 +100,9 @@ TIPS = [
"hermes gateway install sets up Hermes as a system service (systemd/launchd).",
"hermes memory setup lets you configure an external memory provider (Honcho, Mem0, etc.).",
"hermes webhook subscribe creates event-driven webhook routes with HMAC validation.",
"Save money: hermes tools disables unused tools, hermes skills config trims skills down.",
"/reasoning low or /reasoning minimal cuts thinking depth below the default (medium) — faster, cheaper responses.",
"hermes models routes vision, compression, and aux tasks to cheaper models — cuts background token cost 85%+ without downgrading your main chat model.",
# --- Configuration ---
"Set display.bell_on_complete: true in config.yaml to hear a bell when long tasks finish.",
+44 -2
View File
@@ -227,6 +227,14 @@ TOOL_CATEGORIES = {
"tts_provider": "kittentts",
"post_setup": "kittentts",
},
{
"name": "Piper",
"badge": "local · free",
"tag": "Local neural TTS, 44 languages (voices ~20-90MB)",
"env_vars": [],
"tts_provider": "piper",
"post_setup": "piper",
},
],
},
"web": {
@@ -624,6 +632,33 @@ def _run_post_setup(post_setup_key: str):
_print_warning(" kittentts install timed out (>5min)")
_print_info(f" Run manually: python -m pip install -U '{wheel_url}' soundfile")
elif post_setup_key == "piper":
try:
__import__("piper")
_print_success(" piper-tts is already installed")
except ImportError:
import subprocess
_print_info(" Installing piper-tts (~14MB wheel, voices downloaded on first use)...")
try:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "-U", "piper-tts", "--quiet"],
capture_output=True, text=True, timeout=300,
)
if result.returncode == 0:
_print_success(" piper-tts installed")
else:
_print_warning(" piper-tts install failed:")
_print_info(f" {result.stderr.strip()[:300]}")
_print_info(" Run manually: python -m pip install -U piper-tts")
return
except subprocess.TimeoutExpired:
_print_warning(" piper-tts install timed out (>5min)")
_print_info(" Run manually: python -m pip install -U piper-tts")
return
_print_info(" Default voice: en_US-lessac-medium (downloaded on first TTS call)")
_print_info(" Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md")
_print_info(" Switch voices by setting tts.piper.voice in ~/.hermes/config.yaml")
elif post_setup_key == "spotify":
# Run the full `hermes auth spotify` flow — if the user has no
# client_id yet, this drops them into the interactive wizard
@@ -781,7 +816,12 @@ def _get_platform_tools(
toolset_names = platform_toolsets.get(platform)
if toolset_names is None or not isinstance(toolset_names, list):
default_ts = PLATFORMS[platform]["default_toolset"]
plat_info = PLATFORMS.get(platform)
if plat_info:
default_ts = plat_info["default_toolset"]
else:
# Plugin platform — derive toolset name from platform key
default_ts = f"hermes-{platform}"
toolset_names = [default_ts]
# YAML may parse bare numeric names (e.g. ``12306:``) as int.
@@ -844,7 +884,9 @@ def _get_platform_tools(
# checklist or in a user-saved config. Must run in BOTH branches —
# otherwise saving via `hermes tools` (which flips has_explicit_config
# to True) silently drops them.
platform_tool_universe = set(resolve_toolset(PLATFORMS[platform]["default_toolset"]))
_plat_info = PLATFORMS.get(platform)
_default_ts = _plat_info["default_toolset"] if _plat_info else f"hermes-{platform}"
platform_tool_universe = set(resolve_toolset(_default_ts))
configurable_tool_universe = set()
for ck in configurable_keys:
configurable_tool_universe.update(resolve_toolset(ck))
+579 -7
View File
@@ -23,7 +23,7 @@ import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
import yaml
@@ -345,6 +345,10 @@ _CATEGORY_MERGE: Dict[str, str] = {
"dashboard": "display",
"code_execution": "agent",
"prompt_caching": "agent",
# Only `telegram.reactions` currently lives under telegram — fold it in
# with the other messaging-platform config (discord) so it isn't an
# orphan tab of one field.
"telegram": "discord",
}
# Display order for tabs — unlisted categories sort alphabetically after these.
@@ -441,6 +445,20 @@ class EnvVarReveal(BaseModel):
key: str
class ModelAssignment(BaseModel):
"""Payload for POST /api/model/set — assign a provider/model to a slot.
scope="main" writes model.provider + model.default
scope="auxiliary" writes auxiliary.<task>.provider + auxiliary.<task>.model
scope="auxiliary" with task="" applied to every auxiliary.* slot
scope="auxiliary" with task="__reset__" resets every slot to provider="auto"
"""
scope: str
provider: str
model: str
task: str = ""
_GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL")
try:
_GATEWAY_HEALTH_TIMEOUT = float(os.getenv("GATEWAY_HEALTH_TIMEOUT", "3"))
@@ -917,6 +935,207 @@ def get_model_info():
return dict(_EMPTY_MODEL_INFO)
# ---------------------------------------------------------------------------
# Model assignment — pick provider+model for main slot or auxiliary slots.
# Mirrors the model.options JSON-RPC from tui_gateway but uses REST so the
# Models page (which has no chat PTY open) can drive it.
# ---------------------------------------------------------------------------
# Canonical auxiliary task slots. Keep in sync with DEFAULT_CONFIG["auxiliary"]
# in hermes_cli/config.py — listed here for deterministic ordering in the UI.
_AUX_TASK_SLOTS: Tuple[str, ...] = (
"vision",
"web_extract",
"compression",
"session_search",
"skills_hub",
"approval",
"mcp",
"title_generation",
"curator",
)
@app.get("/api/model/options")
def get_model_options():
"""Return authenticated providers + their curated model lists.
REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the
dashboard Models page can render the picker without a live chat session.
The response shape matches ``model.options`` 1:1 so ``ModelPickerDialog``
can share the same types.
"""
try:
from hermes_cli.model_switch import list_authenticated_providers
cfg = load_config()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
current_model = model_cfg.get("default", model_cfg.get("name", "")) or ""
current_provider = model_cfg.get("provider", "") or ""
current_base_url = model_cfg.get("base_url", "") or ""
else:
current_model = str(model_cfg) if model_cfg else ""
current_provider = ""
current_base_url = ""
user_providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
custom_providers = (
cfg.get("custom_providers")
if isinstance(cfg.get("custom_providers"), list)
else []
)
providers = list_authenticated_providers(
current_provider=current_provider,
current_base_url=current_base_url,
current_model=current_model,
user_providers=user_providers,
custom_providers=custom_providers,
max_models=50,
)
return {
"providers": providers,
"model": current_model,
"provider": current_provider,
}
except Exception:
_log.exception("GET /api/model/options failed")
raise HTTPException(status_code=500, detail="Failed to list model options")
@app.get("/api/model/auxiliary")
def get_auxiliary_models():
"""Return current auxiliary task assignments.
Shape:
{
"tasks": [
{"task": "vision", "provider": "auto", "model": "", "base_url": ""},
...
],
"main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"},
}
"""
try:
cfg = load_config()
aux_cfg = cfg.get("auxiliary", {})
if not isinstance(aux_cfg, dict):
aux_cfg = {}
tasks = []
for slot in _AUX_TASK_SLOTS:
slot_cfg = aux_cfg.get(slot, {}) if isinstance(aux_cfg.get(slot), dict) else {}
tasks.append({
"task": slot,
"provider": str(slot_cfg.get("provider", "auto") or "auto"),
"model": str(slot_cfg.get("model", "") or ""),
"base_url": str(slot_cfg.get("base_url", "") or ""),
})
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
main = {
"provider": str(model_cfg.get("provider", "") or ""),
"model": str(model_cfg.get("default", model_cfg.get("name", "")) or ""),
}
else:
main = {"provider": "", "model": str(model_cfg) if model_cfg else ""}
return {"tasks": tasks, "main": main}
except Exception:
_log.exception("GET /api/model/auxiliary failed")
raise HTTPException(status_code=500, detail="Failed to read auxiliary config")
@app.post("/api/model/set")
async def set_model_assignment(body: ModelAssignment):
"""Assign a model to the main slot or an auxiliary task slot.
Writes to ``~/.hermes/config.yaml`` applies to **new** sessions only.
The currently running chat PTY (if any) is not affected; use the
``/model`` slash command inside a chat to hot-swap that specific session.
"""
scope = (body.scope or "").strip().lower()
provider = (body.provider or "").strip()
model = (body.model or "").strip()
task = (body.task or "").strip().lower()
if scope not in ("main", "auxiliary"):
raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'")
try:
cfg = load_config()
if scope == "main":
if not provider or not model:
raise HTTPException(status_code=400, detail="provider and model required for main")
model_cfg = cfg.get("model", {})
if not isinstance(model_cfg, dict):
model_cfg = {}
model_cfg["provider"] = provider
model_cfg["default"] = model
# Clear stale base_url so the resolver picks the provider's own default.
if "base_url" in model_cfg and model_cfg.get("base_url"):
model_cfg["base_url"] = ""
# Also clear hardcoded context_length override — new model may have
# a different context window.
if "context_length" in model_cfg:
model_cfg.pop("context_length", None)
cfg["model"] = model_cfg
save_config(cfg)
return {"ok": True, "scope": "main", "provider": provider, "model": model}
# scope == "auxiliary"
aux = cfg.get("auxiliary")
if not isinstance(aux, dict):
aux = {}
if task == "__reset__":
# Reset every slot to provider="auto", model="" — keeps other fields intact.
for slot in _AUX_TASK_SLOTS:
slot_cfg = aux.get(slot)
if not isinstance(slot_cfg, dict):
slot_cfg = {}
slot_cfg["provider"] = "auto"
slot_cfg["model"] = ""
aux[slot] = slot_cfg
cfg["auxiliary"] = aux
save_config(cfg)
return {"ok": True, "scope": "auxiliary", "reset": True}
if not provider:
raise HTTPException(status_code=400, detail="provider required for auxiliary")
targets = [task] if task else list(_AUX_TASK_SLOTS)
for slot in targets:
if slot not in _AUX_TASK_SLOTS:
raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}")
slot_cfg = aux.get(slot)
if not isinstance(slot_cfg, dict):
slot_cfg = {}
slot_cfg["provider"] = provider
slot_cfg["model"] = model
aux[slot] = slot_cfg
cfg["auxiliary"] = aux
save_config(cfg)
return {
"ok": True,
"scope": "auxiliary",
"tasks": targets,
"provider": provider,
"model": model,
}
except HTTPException:
raise
except Exception:
_log.exception("POST /api/model/set failed")
raise HTTPException(status_code=500, detail="Failed to save model assignment")
def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
"""Reverse _normalize_config_for_web before saving.
@@ -2125,6 +2344,254 @@ async def delete_cron_job(job_id: str):
return {"ok": True}
# ---------------------------------------------------------------------------
# Profile management endpoints (minimal — list/create/rename/delete + SOUL.md)
# ---------------------------------------------------------------------------
class ProfileCreate(BaseModel):
name: str
clone_from_default: bool = False
class ProfileRename(BaseModel):
new_name: str
class ProfileSoulUpdate(BaseModel):
content: str
def _profile_attr(info, name: str, default: Any = None) -> Any:
try:
return getattr(info, name)
except Exception:
return default
def _profile_to_dict(info) -> Dict[str, Any]:
return {
"name": _profile_attr(info, "name", ""),
"path": str(_profile_attr(info, "path", "")),
"is_default": bool(_profile_attr(info, "is_default", False)),
"model": _profile_attr(info, "model"),
"provider": _profile_attr(info, "provider"),
"has_env": bool(_profile_attr(info, "has_env", False)),
"skill_count": int(_profile_attr(info, "skill_count", 0) or 0),
}
def _fallback_profile_dicts(profiles_mod) -> List[Dict[str, Any]]:
def _safe(callable_, default):
try:
return callable_()
except Exception:
return default
profiles: List[Dict[str, Any]] = []
default_home = profiles_mod._get_default_hermes_home()
if default_home.is_dir():
model, provider = _safe(lambda: profiles_mod._read_config_model(default_home), (None, None))
profiles.append({
"name": "default",
"path": str(default_home),
"is_default": True,
"model": model,
"provider": provider,
"has_env": (default_home / ".env").exists(),
"skill_count": _safe(lambda: profiles_mod._count_skills(default_home), 0),
})
profiles_root = profiles_mod._get_profiles_root()
if profiles_root.is_dir():
for entry in sorted(profiles_root.iterdir()):
if not entry.is_dir() or not profiles_mod._PROFILE_ID_RE.match(entry.name):
continue
model, provider = _safe(lambda entry=entry: profiles_mod._read_config_model(entry), (None, None))
profiles.append({
"name": entry.name,
"path": str(entry),
"is_default": False,
"model": model,
"provider": provider,
"has_env": (entry / ".env").exists(),
"skill_count": _safe(lambda entry=entry: profiles_mod._count_skills(entry), 0),
})
return profiles
def _resolve_profile_dir(name: str) -> Path:
"""Validate ``name`` and resolve to its directory or raise an HTTPException."""
from hermes_cli import profiles as profiles_mod
try:
profiles_mod.validate_profile_name(name)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not profiles_mod.profile_exists(name):
raise HTTPException(status_code=404, detail=f"Profile '{name}' does not exist.")
return profiles_mod.get_profile_dir(name)
def _profile_setup_command(name: str) -> str:
"""Return the shell command used to configure a profile in the CLI."""
_resolve_profile_dir(name)
return "hermes setup" if name == "default" else f"{name} setup"
@app.get("/api/profiles")
async def list_profiles_endpoint():
from hermes_cli import profiles as profiles_mod
try:
return {"profiles": [_profile_to_dict(p) for p in profiles_mod.list_profiles()]}
except Exception:
_log.exception("GET /api/profiles failed; falling back to profile directory scan")
return {"profiles": _fallback_profile_dicts(profiles_mod)}
@app.post("/api/profiles")
async def create_profile_endpoint(body: ProfileCreate):
from hermes_cli import profiles as profiles_mod
try:
path = profiles_mod.create_profile(
name=body.name,
clone_from="default" if body.clone_from_default else None,
clone_config=body.clone_from_default,
)
# Match the CLI's profile-create flow: fresh named profiles get the
# bundled skills installed. When cloning from default, create_profile()
# has already copied the source profile's skills, including any
# user-installed skills.
if not body.clone_from_default:
profiles_mod.seed_profile_skills(path, quiet=True)
# Match the CLI's profile-create flow: named profiles should get a
# wrapper in ~/.local/bin when the alias is safe to create.
collision = profiles_mod.check_alias_collision(body.name)
if not collision:
profiles_mod.create_wrapper_script(body.name)
except (ValueError, FileExistsError, FileNotFoundError) as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
_log.exception("POST /api/profiles failed")
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "name": body.name, "path": str(path)}
@app.get("/api/profiles/{name}/setup-command")
async def get_profile_setup_command(name: str):
return {"command": _profile_setup_command(name)}
@app.post("/api/profiles/{name}/open-terminal")
async def open_profile_terminal_endpoint(name: str):
try:
command = _profile_setup_command(name)
if sys.platform.startswith("win"):
subprocess.Popen(["cmd.exe", "/c", "start", "", command])
elif sys.platform == "darwin":
escaped = command.replace("\\", "\\\\").replace('"', '\\"')
applescript = (
'tell application "Terminal"\n'
"activate\n"
f'do script "{escaped}"\n'
"end tell"
)
subprocess.Popen(["osascript", "-e", applescript])
else:
terminal_commands = [
("x-terminal-emulator", ["x-terminal-emulator", "-e", "sh", "-lc", command]),
("gnome-terminal", ["gnome-terminal", "--", "sh", "-lc", command]),
("konsole", ["konsole", "-e", "sh", "-lc", command]),
("xfce4-terminal", ["xfce4-terminal", "-e", f"sh -lc '{command}'"]),
("mate-terminal", ["mate-terminal", "-e", f"sh -lc '{command}'"]),
("lxterminal", ["lxterminal", "-e", f"sh -lc '{command}'"]),
("tilix", ["tilix", "-e", "sh", "-lc", command]),
("alacritty", ["alacritty", "-e", "sh", "-lc", command]),
("kitty", ["kitty", "sh", "-lc", command]),
("xterm", ["xterm", "-e", "sh", "-lc", command]),
]
for executable, popen_args in terminal_commands:
if subprocess.call(
["which", executable],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
) == 0:
subprocess.Popen(popen_args)
break
else:
raise HTTPException(
status_code=400,
detail="No supported terminal emulator found",
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
_log.exception("POST /api/profiles/%s/open-terminal failed", name)
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "command": command}
@app.patch("/api/profiles/{name}")
async def rename_profile_endpoint(name: str, body: ProfileRename):
from hermes_cli import profiles as profiles_mod
try:
path = profiles_mod.rename_profile(name, body.new_name)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except (ValueError, FileExistsError) as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
_log.exception("PATCH /api/profiles/%s failed", name)
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "name": body.new_name, "path": str(path)}
@app.delete("/api/profiles/{name}")
async def delete_profile_endpoint(name: str):
"""Delete a profile. The dashboard collects the user's confirmation in
its own dialog before this request, so we always pass ``yes=True`` to
skip the CLI's interactive prompt."""
from hermes_cli import profiles as profiles_mod
try:
path = profiles_mod.delete_profile(name, yes=True)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
_log.exception("DELETE /api/profiles/%s failed", name)
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "path": str(path)}
@app.get("/api/profiles/{name}/soul")
async def get_profile_soul(name: str):
soul_path = _resolve_profile_dir(name) / "SOUL.md"
if soul_path.exists():
try:
return {"content": soul_path.read_text(encoding="utf-8"), "exists": True}
except OSError as e:
raise HTTPException(status_code=500, detail=f"Could not read SOUL.md: {e}")
return {"content": "", "exists": False}
@app.put("/api/profiles/{name}/soul")
async def update_profile_soul(name: str, body: ProfileSoulUpdate):
soul_path = _resolve_profile_dir(name) / "SOUL.md"
try:
soul_path.write_text(body.content, encoding="utf-8")
except OSError as e:
_log.exception("PUT /api/profiles/%s/soul failed", name)
raise HTTPException(status_code=500, detail=f"Could not write SOUL.md: {e}")
return {"ok": True}
# ---------------------------------------------------------------------------
# Skills & Tools endpoints
# ---------------------------------------------------------------------------
@@ -2295,6 +2762,99 @@ async def get_usage_analytics(days: int = 30):
db.close()
@app.get("/api/analytics/models")
async def get_models_analytics(days: int = 30):
"""Rich per-model analytics for the Models dashboard page.
Returns token/cost/session breakdown per model plus capability metadata
from models.dev (context window, vision, tools, reasoning, etc.).
"""
from hermes_state import SessionDB
db = SessionDB()
try:
cutoff = time.time() - (days * 86400)
cur = db._conn.execute("""
SELECT model,
billing_provider,
SUM(input_tokens) as input_tokens,
SUM(output_tokens) as output_tokens,
SUM(cache_read_tokens) as cache_read_tokens,
SUM(reasoning_tokens) as reasoning_tokens,
COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
COALESCE(SUM(actual_cost_usd), 0) as actual_cost,
COUNT(*) as sessions,
SUM(COALESCE(api_call_count, 0)) as api_calls,
SUM(tool_call_count) as tool_calls,
MAX(started_at) as last_used_at,
AVG(input_tokens + output_tokens) as avg_tokens_per_session
FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != ''
GROUP BY model, billing_provider
ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
""", (cutoff,))
rows = [dict(r) for r in cur.fetchall()]
models = []
for row in rows:
provider = row.get("billing_provider") or ""
model_name = row["model"]
caps = {}
try:
from agent.models_dev import get_model_capabilities
mc = get_model_capabilities(provider=provider, model=model_name)
if mc is not None:
caps = {
"supports_tools": mc.supports_tools,
"supports_vision": mc.supports_vision,
"supports_reasoning": mc.supports_reasoning,
"context_window": mc.context_window,
"max_output_tokens": mc.max_output_tokens,
"model_family": mc.model_family,
}
except Exception:
pass
models.append({
"model": model_name,
"provider": provider,
"input_tokens": row["input_tokens"],
"output_tokens": row["output_tokens"],
"cache_read_tokens": row["cache_read_tokens"],
"reasoning_tokens": row["reasoning_tokens"],
"estimated_cost": row["estimated_cost"],
"actual_cost": row["actual_cost"],
"sessions": row["sessions"],
"api_calls": row["api_calls"],
"tool_calls": row["tool_calls"],
"last_used_at": row["last_used_at"],
"avg_tokens_per_session": row["avg_tokens_per_session"],
"capabilities": caps,
})
totals_cur = db._conn.execute("""
SELECT COUNT(DISTINCT model) as distinct_models,
SUM(input_tokens) as total_input,
SUM(output_tokens) as total_output,
SUM(cache_read_tokens) as total_cache_read,
SUM(reasoning_tokens) as total_reasoning,
COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost,
COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost,
COUNT(*) as total_sessions,
SUM(COALESCE(api_call_count, 0)) as total_api_calls
FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != ''
""", (cutoff,))
totals = dict(totals_cur.fetchone())
return {
"models": models,
"totals": totals,
"period_days": days,
}
finally:
db.close()
# ---------------------------------------------------------------------------
# /api/pty — PTY-over-WebSocket bridge for the dashboard "Chat" tab.
#
@@ -2977,10 +3537,12 @@ def _discover_dashboard_plugins() -> list:
plugins = []
seen_names: set = set()
from hermes_cli.plugins import get_bundled_plugins_dir
bundled_root = get_bundled_plugins_dir()
search_dirs = [
(get_hermes_home() / "plugins", "user"),
(PROJECT_ROOT / "plugins" / "memory", "bundled"),
(PROJECT_ROOT / "plugins", "bundled"),
(bundled_root / "memory", "bundled"),
(bundled_root, "bundled"),
]
if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"):
search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project"))
@@ -3125,13 +3687,23 @@ def _mount_plugin_api_routes():
_log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name)
continue
try:
spec = importlib.util.spec_from_file_location(
f"hermes_dashboard_plugin_{plugin['name']}", api_path,
)
module_name = f"hermes_dashboard_plugin_{plugin['name']}"
spec = importlib.util.spec_from_file_location(module_name, api_path)
if spec is None or spec.loader is None:
continue
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
# Register in sys.modules BEFORE exec_module so pydantic/FastAPI
# can resolve forward references (e.g. models defined in a file
# that uses `from __future__ import annotations`). Without this,
# TypeAdapter lazy-build fails at first request with
# "is not fully defined" because the module namespace isn't
# reachable by name for string-annotation resolution.
sys.modules[module_name] = mod
try:
spec.loader.exec_module(mod)
except Exception:
sys.modules.pop(module_name, None)
raise
router = getattr(mod, "router", None)
if router is None:
_log.warning("Plugin %s api file has no 'router' attribute", plugin["name"])
+8
View File
@@ -320,7 +320,15 @@ def get_tool_definitions(
result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode)
if quiet_mode:
# Cache the freshly-computed list, but hand callers a shallow copy so
# downstream mutations (e.g. run_agent appending memory/LCM tool
# schemas to self.tools) don't poison the cache. Without this, a
# long-lived Gateway process accumulates duplicate tool names across
# agent inits and providers that enforce unique tool names
# (DeepSeek, Xiaomi MiMo, Moonshot Kimi) reject the request with
# HTTP 400. Mirrors the cache-hit path above. (issue #17335)
_tool_defs_cache[cache_key] = result
return list(result)
return result
+23 -3
View File
@@ -4,9 +4,9 @@
# transitive deps like onnxruntime that lack compatible wheels on
# aarch64-darwin. The package and devShell still work on macOS.
{ inputs, ... }: {
perSystem = { pkgs, system, lib, ... }:
perSystem = { pkgs, lib, self', ... }:
let
hermes-agent = inputs.self.packages.${system}.default;
hermes-agent = self'.packages.default;
hermesVenv = hermes-agent.hermesVenv;
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
@@ -51,7 +51,7 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
failMsg = lib.concatMapStringsSep "\n" (r: " - ${r.sys}") failures;
in pkgs.runCommand "hermes-cross-eval" { } (
if failures != [] then
builtins.throw "Package fails to evaluate on:\n${failMsg}"
throw "Package fails to evaluate on:\n${failMsg}"
else ''
echo "PASS: package evaluates on all ${toString (builtins.length targetSystems)} platforms"
mkdir -p $out
@@ -124,6 +124,26 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
echo "ok" > $out/result
'';
# Verify bundled plugins (platforms, memory, context_engine) are present
bundled-plugins = pkgs.runCommand "hermes-bundled-plugins" { } ''
set -e
echo "=== Checking bundled plugins ==="
test -d ${hermes-agent}/share/hermes-agent/plugins || (echo "FAIL: plugins directory missing"; exit 1)
echo "PASS: plugins directory exists"
test -f ${hermes-agent}/share/hermes-agent/plugins/platforms/irc/plugin.yaml || \
(echo "FAIL: irc plugin manifest missing"; exit 1)
echo "PASS: irc plugin manifest present"
grep -q "HERMES_BUNDLED_PLUGINS" ${hermes-agent}/bin/hermes || \
(echo "FAIL: HERMES_BUNDLED_PLUGINS not in wrapper"; exit 1)
echo "PASS: HERMES_BUNDLED_PLUGINS set in wrapper"
echo "=== All bundled plugins checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify bundled TUI is present and compiled
bundled-tui = pkgs.runCommand "hermes-bundled-tui" { } ''
set -e
+19 -18
View File
@@ -1,29 +1,30 @@
# nix/devShell.nix — Dev shell that delegates setup to each package
#
# Each package in inputsFrom exposes passthru.devShellHook — a bash snippet
# Each package in inputsFrom might expose passthru.devShellHook — a bash snippet
# with stamp-checked setup logic. This file collects and runs them all.
{ inputs, ... }: {
perSystem = { pkgs, system, ... }:
{ ... }:
{
perSystem =
{ pkgs, self', ... }:
let
hermes-agent = inputs.self.packages.${system}.default;
hermes-tui = inputs.self.packages.${system}.tui;
hermes-web = inputs.self.packages.${system}.web;
packages = [ hermes-agent hermes-tui hermes-web ];
in {
packages = builtins.attrValues self'.packages;
in
{
devShells.default = pkgs.mkShell {
inputsFrom = packages;
packages = with pkgs; [
python312 uv nodejs_22 ripgrep git openssh ffmpeg
uv
];
shellHook = let
hooks = map (p: p.passthru.devShellHook or "") packages;
combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks);
in ''
echo "Hermes Agent dev shell"
${combined}
echo "Ready. Run 'hermes' to start."
'';
shellHook =
let
hooks = map (p: p.passthru.devShellHook or "") packages;
combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks);
in
''
echo "Hermes Agent dev shell"
${combined}
echo "Ready. Run 'hermes' to start."
'';
};
};
}
+66 -44
View File
@@ -19,16 +19,21 @@
pyproject-nix,
pyproject-build-systems,
npm-lockfile-fix,
# Locked git revision of the flake source — embedded so banner.py can
# check for updates without needing a local .git directory. Null for
# impure / dirty builds where flakes can't determine a rev.
rev ? null,
# Overridable parameters
extraPythonPackages ? [ ],
}:
let
nodejs = nodejs_22;
hermesVenv = callPackage ./python.nix {
inherit uv2nix pyproject-nix pyproject-build-systems;
};
hermesNpmLib = callPackage ./lib.nix {
inherit npm-lockfile-fix;
inherit npm-lockfile-fix nodejs;
};
hermesTui = callPackage ./tui.nix {
@@ -44,8 +49,16 @@ let
filter = path: _type: !(lib.hasInfix "/index-cache/" path);
};
# Import bundled plugins (memory, context_engine, platforms/*). Keeping
# them out of the Python site-packages keeps import semantics identical
# to a dev checkout — the loader reads them from HERMES_BUNDLED_PLUGINS.
bundledPlugins = lib.cleanSourceWith {
src = ../plugins;
filter = path: _type: !(lib.hasInfix "/__pycache__/" path);
};
runtimeDeps = [
nodejs_22
nodejs
ripgrep
git
openssh
@@ -70,10 +83,49 @@ let
builtins.hashString "sha256" (builtins.readFile ../uv.lock)
else
"none";
checkPackageCollisions = ''
import pathlib, sys, re
def canonical(name):
return re.sub(r'[-_.]+', '-', name).lower()
# Collect core venv package names
core = set()
venv_sp = pathlib.Path('${hermesVenv}/${sitePackagesPath}')
for di in venv_sp.glob('*.dist-info'):
meta = di / 'METADATA'
if meta.exists():
for line in meta.read_text().splitlines():
if line.startswith('Name:'):
core.add(canonical(line.split(':', 1)[1].strip()))
break
# Check each extra package for collisions
extras_dirs = [${lib.concatMapStringsSep ", " (p: "'${toString p}'") allExtraPythonPackages}]
for edir in extras_dirs:
sp = pathlib.Path(edir) / '${sitePackagesPath}'
if not sp.exists():
continue
for di in sp.glob('*.dist-info'):
meta = di / 'METADATA'
if not meta.exists():
continue
for line in meta.read_text().splitlines():
if line.startswith('Name:'):
pkg = canonical(line.split(':', 1)[1].strip())
if pkg in core:
print(f'ERROR: plugin package \"{pkg}\" collides with a package in hermes sealed venv', file=sys.stderr)
print(f' from: {di}', file=sys.stderr)
print(f' Remove this dependency from extraPythonPackages.', file=sys.stderr)
sys.exit(1)
break
print('No collisions found.')
'';
in
stdenv.mkDerivation {
pname = "hermes-agent";
version = (builtins.fromTOML (builtins.readFile ../pyproject.toml)).project.version;
version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version;
dontUnpack = true;
dontBuild = true;
@@ -84,6 +136,7 @@ stdenv.mkDerivation {
mkdir -p $out/share/hermes-agent $out/bin
cp -r ${bundledSkills} $out/share/hermes-agent/skills
cp -r ${bundledPlugins} $out/share/hermes-agent/plugins
cp -r ${hermesWeb} $out/share/hermes-agent/web_dist
mkdir -p $out/ui-tui
@@ -94,10 +147,12 @@ stdenv.mkDerivation {
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
--suffix PATH : "${runtimePath}" \
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \
--set HERMES_BUNDLED_PLUGINS $out/share/hermes-agent/plugins \
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
--set HERMES_TUI_DIR $out/ui-tui \
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
--set HERMES_NODE ${nodejs_22}/bin/node \
--set HERMES_NODE ${lib.getExe nodejs} \
${lib.optionalString (rev != null) ''--set HERMES_REVISION ${rev} \''}
${lib.optionalString (extraPythonPackages != [ ]) ''--suffix PYTHONPATH : "${pythonPath}"''}
'')
[
@@ -109,45 +164,7 @@ stdenv.mkDerivation {
${lib.optionalString (extraPythonPackages != [ ]) ''
echo "=== Checking for plugin/core package collisions ==="
${hermesVenv}/bin/python3 -c "
import pathlib, sys, re
def canonical(name):
return re.sub(r'[-_.]+', '-', name).lower()
# Collect core venv package names
core = set()
venv_sp = pathlib.Path('${hermesVenv}/${sitePackagesPath}')
for di in venv_sp.glob('*.dist-info'):
meta = di / 'METADATA'
if meta.exists():
for line in meta.read_text().splitlines():
if line.startswith('Name:'):
core.add(canonical(line.split(':', 1)[1].strip()))
break
# Check each extra package for collisions
extras_dirs = [${lib.concatMapStringsSep ", " (p: "'${toString p}'") allExtraPythonPackages}]
for edir in extras_dirs:
sp = pathlib.Path(edir) / '${sitePackagesPath}'
if not sp.exists():
continue
for di in sp.glob('*.dist-info'):
meta = di / 'METADATA'
if not meta.exists():
continue
for line in meta.read_text().splitlines():
if line.startswith('Name:'):
pkg = canonical(line.split(':', 1)[1].strip())
if pkg in core:
print(f'ERROR: plugin package \"{pkg}\" collides with a package in hermes sealed venv', file=sys.stderr)
print(f' from: {di}', file=sys.stderr)
print(f' Remove this dependency from extraPythonPackages.', file=sys.stderr)
sys.exit(1)
break
print('No collisions found.')
"
${hermesVenv}/bin/python3 -c "${checkPackageCollisions}"
echo "=== No collisions ==="
''}
@@ -155,7 +172,12 @@ print('No collisions found.')
'';
passthru = {
inherit hermesTui hermesWeb hermesNpmLib hermesVenv;
inherit
hermesTui
hermesWeb
hermesNpmLib
hermesVenv
;
devShellHook = ''
STAMP=".nix-stamps/hermes-agent"
+17 -10
View File
@@ -1,11 +1,16 @@
# nix/lib.nix — Shared helpers for nix stuff
{ pkgs, npm-lockfile-fix }:
{
pkgs,
npm-lockfile-fix,
nodejs,
}:
{
# Returns a buildNpmPackage-compatible attrs set that provides:
# patchPhase — ensures lockfile has exactly one trailing newline
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
# patchPhase — ensures lockfile has exactly one trailing newline
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
# passthru.devShellHook — stamp-checked npm install + hash auto-update
# passthru.npmLockfile — metadata for mkFixLockfiles
# nodejs — fixed nodejs version for all packages we use in the repo
#
# NOTE: npmConfigHook runs `diff` between the source lockfile and the
# npm-deps cache lockfile. fetchNpmDeps preserves whatever trailing
@@ -24,6 +29,7 @@
nixFile ? "nix/${attr}.nix", # defaults to nix/<attr>.nix
}:
{
inherit nodejs;
patchPhase = ''
runHook prePatch
# Normalize trailing newlines so source and npm-deps always match,
@@ -56,8 +62,8 @@
cd "$REPO_ROOT/${folder}"
rm -rf node_modules/
npm cache clean --force
CI=true npm install
${pkgs.lib.getExe' nodejs "npm"} cache clean --force
CI=true ${pkgs.lib.getExe' nodejs "npm"} install
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
NIX_FILE="$REPO_ROOT/${nixFile}"
@@ -83,7 +89,7 @@
STAMP_VALUE="$(_hermes_npm_stamp)"
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
echo "${pname}: installing npm dependencies..."
( cd ${folder} && CI=true npm install --silent --no-fund --no-audit 2>/dev/null )
( cd ${folder} && CI=true ${pkgs.lib.getExe' nodejs "npm"} install --silent --no-fund --no-audit 2>/dev/null )
# Auto-update the nix hash so it stays in sync with the lockfile
echo "${pname}: prefetching npm deps..."
@@ -92,7 +98,7 @@
sed -i "s|hash = \"sha256-[A-Za-z0-9+/=]+\"|hash = \"$NEW_HASH\";|" "$NIX_FILE"
echo "${pname}: updated hash to $NEW_HASH"
else
echo "${pname}: warning: prefetch failed, run 'nix run .#fix-lockfiles -- --apply' manually" >&2
echo "${pname}: warning: prefetch failed, run 'nix run .#fix-lockfiles' manually" >&2
fi
mkdir -p .nix-stamps
@@ -112,6 +118,7 @@
# Invocations:
# fix-lockfiles --check # exit 1 if any hash is stale
# fix-lockfiles --apply # rewrite stale hashes in place
# fix-lockfiles # alias of --apply
# Writes machine-readable fields (stale, changed, report) to $GITHUB_OUTPUT
# when set, so CI workflows can post a sticky PR comment directly.
mkFixLockfiles =
@@ -124,7 +131,7 @@
in
pkgs.writeShellScriptBin "fix-lockfiles" ''
set -uox pipefail
MODE="''${1:---check}"
MODE="''${1:---apply}"
case "$MODE" in
--check|--apply) ;;
-h|--help)
@@ -156,7 +163,7 @@
for entry in "''${ENTRIES[@]}"; do
IFS=":" read -r ATTR FOLDER NIX_FILE <<< "$entry"
echo "==> .#$ATTR ($FOLDER -> $NIX_FILE)"
OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --rebuild --print-build-logs 2>&1)
OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --print-build-logs 2>&1)
STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo " ok"
@@ -222,7 +229,7 @@
if [ "$STALE" -eq 1 ] && [ "$MODE" = "--check" ]; then
echo
echo "Stale lockfile hashes detected. Run:"
echo " nix run .#fix-lockfiles -- --apply"
echo " nix run .#fix-lockfiles"
exit 1
fi
+18 -8
View File
@@ -647,6 +647,16 @@
}];
}
# ── Assertions ─────────────────────────────────────────────────────
{
assertions = let
names = map lib.getName cfg.extraPlugins;
in [{
assertion = (lib.length names) == (lib.length (lib.unique names));
message = "services.hermes-agent.extraPlugins: duplicate plugin names detected: ${toString names}. If using fetchFromGitHub, set name = \"plugin-name\" to disambiguate.";
}];
}
# ── Warnings ──────────────────────────────────────────────────────
# ── Per-user profile for extraPackages ───────────────────────────
# Wire extraPackages into the hermes user's per-user profile so the
@@ -730,12 +740,12 @@
# is disabled so the host CLI falls back to native execution.
${if cfg.container.enable then ''
cat > ${cfg.stateDir}/.hermes/.container-mode <<'HERMES_CONTAINER_MODE_EOF'
# Written by NixOS activation script. Do not edit manually.
backend=${cfg.container.backend}
container_name=${containerName}
exec_user=${cfg.user}
hermes_bin=${containerDataDir}/current-package/bin/hermes
HERMES_CONTAINER_MODE_EOF
# Written by NixOS activation script. Do not edit manually.
backend=${cfg.container.backend}
container_name=${containerName}
exec_user=${cfg.user}
hermes_bin=${containerDataDir}/current-package/bin/hermes
HERMES_CONTAINER_MODE_EOF
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/.container-mode
chmod 0644 ${cfg.stateDir}/.hermes/.container-mode
'' else ''
@@ -796,8 +806,8 @@ HERMES_CONTAINER_MODE_EOF
ENV_FILE="${cfg.stateDir}/.hermes/.env"
install -o ${cfg.user} -g ${cfg.group} -m 0640 /dev/null "$ENV_FILE"
cat > "$ENV_FILE" <<'HERMES_NIX_ENV_EOF'
${envFileContent}
HERMES_NIX_ENV_EOF
${envFileContent}
HERMES_NIX_ENV_EOF
${lib.concatStringsSep "\n" (map (f: ''
if [ -f "${f}" ]; then
echo "" >> "$ENV_FILE"
+1
View File
@@ -5,6 +5,7 @@
hermes-agent = final.callPackage ./hermes-agent.nix {
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
npm-lockfile-fix = inputs.npm-lockfile-fix.packages.${final.stdenv.hostPlatform.system}.default;
rev = inputs.self.rev or null;
};
};
}
+3
View File
@@ -7,6 +7,9 @@
hermesAgent = pkgs.callPackage ./hermes-agent.nix {
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
# Only embed clean revs — dirtyRev doesn't represent any upstream
# commit, so comparing it would always claim "update available".
rev = inputs.self.rev or null;
};
in
{
+1 -1
View File
@@ -4,7 +4,7 @@ let
src = ../ui-tui;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-a/HGI9OgVcTnZrMXA7xFMGnFoVxyHe95fulVz+WNYB0=";
hash = "sha256-Chz+NW9NXqboXHOa6PKwf5bhAkkcFtKNhvKWwg2XSPc=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
+5 -3
View File
@@ -4,15 +4,17 @@ let
src = ../web;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-+B2+Fe4djPzHHcUXRx+m0cuyaopAhW0PcHsMgYfV5VE=";
hash = "sha256-HWB1piIPglTXbzQHXFYHLgVZIbDb60esupXSQGa1+lI=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json"));
version = packageJson.version;
in
pkgs.buildNpmPackage (npm // {
pname = "hermes-web";
version = "0.0.0";
inherit src npmDeps;
inherit src npmDeps version;
doCheck = false;
@@ -0,0 +1,372 @@
---
name: shopify
description: Shopify Admin & Storefront GraphQL APIs via curl. Products, orders, customers, inventory, metafields.
version: 1.0.0
author: community
license: MIT
prerequisites:
env_vars: [SHOPIFY_ACCESS_TOKEN, SHOPIFY_STORE_DOMAIN]
commands: [curl, jq]
required_environment_variables:
- name: SHOPIFY_ACCESS_TOKEN
prompt: Shopify Admin API access token (starts with shpat_)
help: "Shopify admin → Settings → Apps and sales channels → Develop apps → Create an app → API credentials. Token shown ONCE on install."
- name: SHOPIFY_STORE_DOMAIN
prompt: Your shop subdomain without protocol (e.g. my-store.myshopify.com)
help: "The permanent myshopify.com domain, not your custom domain."
- name: SHOPIFY_API_VERSION
prompt: Shopify API version (default 2026-01)
help: "Stable quarterly version. Override if you need an older one."
metadata:
hermes:
tags: [Shopify, E-commerce, Commerce, API, GraphQL]
related_skills: [airtable, xurl]
homepage: https://shopify.dev/docs/api/admin-graphql
---
# Shopify — Admin & Storefront GraphQL APIs
Work with Shopify stores directly through `curl`: list products, manage inventory, pull orders, update customers, read metafields. No SDK, no app framework — just the GraphQL endpoint and a custom-app access token.
The REST Admin API is legacy since 2024-04 and only receives security fixes. **Use GraphQL Admin** for all admin work. Use **Storefront GraphQL** for read-only customer-facing queries (products, collections, cart).
## Prerequisites
1. In Shopify admin: **Settings → Apps and sales channels → Develop apps → Create an app**.
2. Click **Configure Admin API scopes**, select what you need (examples below), save.
3. **Install app** → the Admin API access token appears ONCE. Copy it immediately — Shopify will never show it again. Tokens start with `shpat_`.
4. Save to `~/.hermes/.env`:
```
SHOPIFY_ACCESS_TOKEN=shpat_xxxxxxxxxxxxxxxxxxxx
SHOPIFY_STORE_DOMAIN=my-store.myshopify.com
SHOPIFY_API_VERSION=2026-01
```
> **Heads up:** As of January 1, 2026, new "legacy custom apps" created in the Shopify admin are gone. New setups should use the **Dev Dashboard** (`shopify.dev/docs/apps/build/dev-dashboard`). Existing admin-created apps keep working. If the user's shop has no existing custom app and it's after 2026-01-01, direct them to Dev Dashboard instead of the admin flow.
Common scopes by task:
- Products / collections: `read_products`, `write_products`
- Inventory: `read_inventory`, `write_inventory`, `read_locations`
- Orders: `read_orders`, `write_orders` (30 most recent without `read_all_orders`)
- Customers: `read_customers`, `write_customers`
- Draft orders: `read_draft_orders`, `write_draft_orders`
- Fulfillments: `read_fulfillments`, `write_fulfillments`
- Metafields / metaobjects: covered by the matching resource scopes
## API Basics
- **Endpoint:** `https://$SHOPIFY_STORE_DOMAIN/admin/api/$SHOPIFY_API_VERSION/graphql.json`
- **Auth header:** `X-Shopify-Access-Token: $SHOPIFY_ACCESS_TOKEN` (NOT `Authorization: Bearer`)
- **Method:** always `POST`, always `Content-Type: application/json`, body is `{"query": "...", "variables": {...}}`
- **HTTP 200 does not mean success.** GraphQL returns errors in a top-level `errors` array and per-field `userErrors`. Always check both.
- **IDs are GID strings:** `gid://shopify/Product/10079467700516`, `gid://shopify/Variant/...`, `gid://shopify/Order/...`. Pass these verbatim — don't strip the prefix.
- **Rate limit:** calculated via query cost (leaky bucket). Each response has `extensions.cost` with `requestedQueryCost`, `actualQueryCost`, `throttleStatus.{currentlyAvailable, maximumAvailable, restoreRate}`. Back off when `currentlyAvailable` drops below your next query's cost. Standard shops = 100 points bucket, 50/s restore; Plus = 1000/100.
Base curl pattern (reusable):
```bash
shop_gql() {
local query="$1"
local variables="${2:-{}}"
curl -sS -X POST \
"https://${SHOPIFY_STORE_DOMAIN}/admin/api/${SHOPIFY_API_VERSION:-2026-01}/graphql.json" \
-H "Content-Type: application/json" \
-H "X-Shopify-Access-Token: ${SHOPIFY_ACCESS_TOKEN}" \
--data "$(jq -nc --arg q "$query" --argjson v "$variables" '{query: $q, variables: $v}')"
}
```
Pipe through `jq` for readable output. `-sS` keeps errors visible but hides the progress bar.
## Discovery
### Shop info + current API version
```bash
shop_gql '{ shop { name myshopifyDomain primaryDomain { url } currencyCode plan { displayName } } }' | jq
```
### List all supported API versions
```bash
shop_gql '{ publicApiVersions { handle supported } }' | jq '.data.publicApiVersions[] | select(.supported)'
```
## Products
### Search products (first 20 matching query)
```bash
shop_gql '
query($q: String!) {
products(first: 20, query: $q) {
edges { node { id title handle status totalInventory variants(first: 5) { edges { node { id sku price inventoryQuantity } } } } }
pageInfo { hasNextPage endCursor }
}
}' '{"q":"hoodie status:active"}' | jq
```
Query syntax supports `title:`, `sku:`, `vendor:`, `product_type:`, `status:active`, `tag:`, `created_at:>2025-01-01`. Full grammar: https://shopify.dev/docs/api/usage/search-syntax
### Paginate products (cursor)
```bash
shop_gql '
query($cursor: String) {
products(first: 100, after: $cursor) {
edges { cursor node { id handle } }
pageInfo { hasNextPage endCursor }
}
}' '{"cursor":null}'
# subsequent calls: pass the previous endCursor
```
### Get a product with variants + metafields
```bash
shop_gql '
query($id: ID!) {
product(id: $id) {
id title handle descriptionHtml tags status
variants(first: 20) { edges { node { id sku price compareAtPrice inventoryQuantity selectedOptions { name value } } } }
metafields(first: 20) { edges { node { namespace key type value } } }
}
}' '{"id":"gid://shopify/Product/10079467700516"}' | jq
```
### Create a product with one variant
```bash
shop_gql '
mutation($input: ProductCreateInput!) {
productCreate(product: $input) {
product { id handle }
userErrors { field message }
}
}' '{"input":{"title":"Test Hoodie","status":"DRAFT","vendor":"Hermes","productType":"Apparel","tags":["test"]}}'
```
Variants now have their own mutations in recent versions:
```bash
# Add variants after creating the product
shop_gql '
mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkCreate(productId: $productId, variants: $variants) {
productVariants { id sku price }
userErrors { field message }
}
}' '{"productId":"gid://shopify/Product/...","variants":[{"optionValues":[{"optionName":"Size","name":"M"}],"price":"49.00","inventoryItem":{"sku":"HD-M","tracked":true}}]}'
```
### Update price / SKU
```bash
shop_gql '
mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants { id sku price }
userErrors { field message }
}
}' '{"productId":"gid://shopify/Product/...","variants":[{"id":"gid://shopify/ProductVariant/...","price":"55.00"}]}'
```
## Orders
### List recent orders (last 30 by default without `read_all_orders`)
```bash
shop_gql '
{
orders(first: 20, reverse: true, query: "financial_status:paid") {
edges { node {
id name createdAt displayFinancialStatus displayFulfillmentStatus
totalPriceSet { shopMoney { amount currencyCode } }
customer { id displayName email }
lineItems(first: 10) { edges { node { title quantity sku } } }
} }
}
}' | jq
```
Useful order query filters: `financial_status:paid|pending|refunded`, `fulfillment_status:unfulfilled|fulfilled`, `created_at:>2025-01-01`, `tag:gift`, `email:foo@example.com`.
### Fetch a single order with shipping address
```bash
shop_gql '
query($id: ID!) {
order(id: $id) {
id name email
shippingAddress { name address1 address2 city province country zip phone }
lineItems(first: 50) { edges { node { title quantity variant { sku } originalUnitPriceSet { shopMoney { amount currencyCode } } } } }
transactions { id kind status amountSet { shopMoney { amount currencyCode } } }
}
}' '{"id":"gid://shopify/Order/...."}' | jq
```
## Customers
```bash
# Search
shop_gql '
{
customers(first: 10, query: "email:*@example.com") {
edges { node { id email displayName numberOfOrders amountSpent { amount currencyCode } } }
}
}'
# Create
shop_gql '
mutation($input: CustomerInput!) {
customerCreate(input: $input) {
customer { id email }
userErrors { field message }
}
}' '{"input":{"email":"test@example.com","firstName":"Test","lastName":"User","tags":["api-created"]}}'
```
## Inventory
Inventory lives on **inventory items** tied to variants, quantities tracked per **location**.
```bash
# Get inventory for a variant across all locations
shop_gql '
query($id: ID!) {
productVariant(id: $id) {
id sku
inventoryItem {
id tracked
inventoryLevels(first: 10) {
edges { node { location { id name } quantities(names: ["available","on_hand","committed"]) { name quantity } } }
}
}
}
}' '{"id":"gid://shopify/ProductVariant/..."}'
```
Adjust stock (delta) — uses `inventoryAdjustQuantities`:
```bash
shop_gql '
mutation($input: InventoryAdjustQuantitiesInput!) {
inventoryAdjustQuantities(input: $input) {
inventoryAdjustmentGroup { reason changes { name delta } }
userErrors { field message }
}
}' '{
"input": {
"reason": "correction",
"name": "available",
"changes": [{"delta": 5, "inventoryItemId": "gid://shopify/InventoryItem/...", "locationId": "gid://shopify/Location/..."}]
}
}'
```
Set absolute stock (not delta) — `inventorySetQuantities`:
```bash
shop_gql '
mutation($input: InventorySetQuantitiesInput!) {
inventorySetQuantities(input: $input) {
inventoryAdjustmentGroup { id }
userErrors { field message }
}
}' '{"input":{"reason":"correction","name":"available","ignoreCompareQuantity":true,"quantities":[{"inventoryItemId":"gid://shopify/InventoryItem/...","locationId":"gid://shopify/Location/...","quantity":100}]}}'
```
## Metafields & Metaobjects
Metafields attach custom data to resources (products, customers, orders, shop).
```bash
# Read
shop_gql '
query($id: ID!) {
product(id: $id) {
metafields(first: 10, namespace: "custom") {
edges { node { key type value } }
}
}
}' '{"id":"gid://shopify/Product/..."}'
# Write (works for any owner type)
shop_gql '
mutation($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { id key namespace }
userErrors { field message code }
}
}' '{"metafields":[{"ownerId":"gid://shopify/Product/...","namespace":"custom","key":"care_instructions","type":"multi_line_text_field","value":"Wash cold. Tumble dry low."}]}'
```
## Storefront API (public read-only)
Different endpoint, different token, used for customer-facing apps/hydrogen-style headless setups. Headers differ:
- **Endpoint:** `https://$SHOPIFY_STORE_DOMAIN/api/$SHOPIFY_API_VERSION/graphql.json`
- **Auth header (public):** `X-Shopify-Storefront-Access-Token: <public token>` — embeddable in browser
- **Auth header (private):** `Shopify-Storefront-Private-Token: <private token>` — server-only
```bash
curl -sS -X POST \
"https://${SHOPIFY_STORE_DOMAIN}/api/${SHOPIFY_API_VERSION:-2026-01}/graphql.json" \
-H "Content-Type: application/json" \
-H "X-Shopify-Storefront-Access-Token: ${SHOPIFY_STOREFRONT_TOKEN}" \
-d '{"query":"{ shop { name } products(first: 5) { edges { node { id title handle } } } }"}' | jq
```
## Bulk Operations
For dumps larger than rate limits allow (full product catalog, all orders for a year):
```bash
# 1. Start bulk query
shop_gql '
mutation {
bulkOperationRunQuery(query: """
{ products { edges { node { id title handle variants { edges { node { sku price } } } } } } }
""") {
bulkOperation { id status }
userErrors { field message }
}
}'
# 2. Poll status
shop_gql '{ currentBulkOperation { id status errorCode objectCount fileSize url partialDataUrl } }'
# 3. When status=COMPLETED, download the JSONL file
curl -sS "$URL" > products.jsonl
```
Each JSONL line is a node, and nested connections are emitted as separate lines with `__parentId`. Reassemble client-side if needed.
## Webhooks
Subscribe to events so you don't have to poll:
```bash
shop_gql '
mutation($topic: WebhookSubscriptionTopic!, $sub: WebhookSubscriptionInput!) {
webhookSubscriptionCreate(topic: $topic, webhookSubscription: $sub) {
webhookSubscription { id topic endpoint { __typename ... on WebhookHttpEndpoint { callbackUrl } } }
userErrors { field message }
}
}' '{"topic":"ORDERS_CREATE","sub":{"callbackUrl":"https://example.com/webhook","format":"JSON"}}'
```
Verify incoming webhook HMAC using the app's client secret (not the access token):
```bash
echo -n "$REQUEST_BODY" | openssl dgst -sha256 -hmac "$APP_SECRET" -binary | base64
# Compare to X-Shopify-Hmac-Sha256 header
```
## Pitfalls
- **REST endpoints still exist but are frozen.** Don't write new integrations against `/admin/api/.../products.json`. Use GraphQL.
- **Token format check.** Admin tokens start with `shpat_`. Storefront public tokens with `shpua_`. If you have one and the wrong header, every request returns 401 without a useful error body.
- **403 with a valid token = missing scope.** Shopify returns `{"errors":[{"message":"Access denied for ..."}]}`. Re-configure Admin API scopes on the app, then reinstall to regenerate the token.
- **`userErrors` is empty != success.** Also check `data.<mutation>.<resource>` is non-null. Some failures populate neither — inspect the whole response.
- **GID vs numeric ID.** Legacy REST gave numeric IDs; GraphQL wants full GID strings. To convert: `gid://shopify/Product/<numeric>`.
- **Rate limit surprise.** A single `products(first: 250)` with deep nesting can cost 1000+ points and throttle immediately on a standard-plan shop. Start narrow, read `extensions.cost`, adjust.
- **Pagination order.** `products(first: N, reverse: true)` sorts by `id DESC`, not `created_at`. Use `sortKey: CREATED_AT, reverse: true` for "newest first."
- **`read_all_orders` for historical data.** Without it, `orders(...)` silently caps at the 60-day window. You won't get an error, just fewer results than expected. For Shopify Plus merchants with many orders, request this scope via the app's protected-data settings.
- **Currencies are strings.** Amounts come back as `"49.00"` not `49.0`. Don't `jq tonumber` blindly if you care about zero-padding.
- **Multi-currency Money fields** have `shopMoney` (store's currency) AND `presentmentMoney` (customer's). Pick one consistently.
## Safety
Mutations in Shopify are real — they create products, charge refunds, cancel orders, ship fulfillments. Before running `productDelete`, `orderCancel`, `refundCreate`, or any bulk mutation: state clearly what the change is, on which shop, and confirm with the user. There is no staging clone of production data unless the user has a separate dev store.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Hermes Achievements contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+148
View File
@@ -0,0 +1,148 @@
# Hermes Achievements
> **Bundled with Hermes Agent.** Originally authored by [@PCinkusz](https://github.com/PCinkusz) at https://github.com/PCinkusz/hermes-achievements — vendored into `plugins/hermes-achievements/` so it ships with the dashboard out-of-the-box and stays in lockstep with Hermes feature changes. Upstream repo remains the staging ground for new badges and UI iteration.
>
> When Hermes is installed via `pip install hermes-agent` or cloned from source, this plugin auto-registers as a dashboard tab on first `hermes dashboard` launch. No separate install step. See [Built-in Plugins → hermes-achievements](../../website/docs/user-guide/features/built-in-plugins.md) in the main docs.
Achievement system for the Hermes Dashboard: collectible, tiered badges generated from real local Hermes session history.
![Hermes Achievements dashboard](docs/assets/achievements-dashboard-hd.png)
The screenshots use temporary demo tier data to show the full visual range. The plugin itself reads real local Hermes session history by default.
> **Update notice (2026-04-29):** If you installed this plugin before today, update to the latest version. The achievements scan path was refactored for much faster warm loads (snapshot cache + incremental checkpoint scan).
## What it does
Hermes Achievements scans local Hermes sessions and unlocks badges based on real agent behavior:
- autonomous tool chains
- debugging and recovery patterns
- vibe-coding file edits
- Hermes-native skills, memory, cron, and plugin usage
- web research and browser automation
- model/provider workflows
- lifestyle patterns such as weekend or night sessions
Achievements have three visible states:
- **Unlocked** — earned at least one tier
- **Discovered** — known achievement, progress visible, not earned yet
- **Secret** — hidden until Hermes detects the first related signal
Most achievements level through:
```text
Copper → Silver → Gold → Diamond → Olympian
```
Each card has a collapsible **What counts** section showing the exact tracked metric or requirement once the user wants details.
Version `0.2.x` expands the catalog to 60+ achievements, including model/provider badges such as **Five-Model Flight**, **Provider Polyglot**, **Claude Confidant**, **Gemini Cartographer**, and **Open Weights Pilgrim**.
## Examples
- Let Him Cook
- Toolchain Maxxer
- Red Text Connoisseur
- Port 3000 Is Taken
- This Was Supposed To Be Quick
- One More Small Change
- Skillsmith
- Memory Keeper
- Context Dragon
- Plugin Goblin
- Rabbit Hole Certified
## Install
Clone into your Hermes plugins directory:
```bash
git clone https://github.com/PCinkusz/hermes-achievements ~/.hermes/plugins/hermes-achievements
```
For local development, keep the repo elsewhere and symlink it:
```bash
git clone https://github.com/PCinkusz/hermes-achievements ~/hermes-achievements
ln -s ~/hermes-achievements ~/.hermes/plugins/hermes-achievements
```
Then rescan dashboard plugins:
```bash
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
```
If backend API routes 404, restart `hermes dashboard`; plugin APIs are mounted at dashboard startup.
## Updating
If you installed with git:
```bash
cd ~/.hermes/plugins/hermes-achievements
git pull --ff-only
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
```
If the update changes backend routes or `plugin_api.py`, restart `hermes dashboard` after pulling.
As of 2026-04-29, updating is strongly recommended because scan performance changed significantly:
- removed duplicate `/overview` scan path
- added cached `/achievements` snapshot
- added incremental checkpoint reuse for unchanged sessions
Achievement unlock state is stored locally in `state.json` and is not overwritten by git updates. New achievements are evaluated from your existing Hermes session history. Achievement IDs are stable and should not be renamed casually because they are the unlock-state keys.
Releases are tagged in git, for example:
```bash
git fetch --tags
git checkout v0.2.0
```
## Files
```text
dashboard/
├── manifest.json
├── plugin_api.py
└── dist/
├── index.js
└── style.css
```
## API
Routes are mounted under:
```text
/api/plugins/hermes-achievements/
```
Endpoints:
```text
GET /achievements
GET /scan-status
GET /recent-unlocks
GET /sessions/{session_id}/badges
POST /rescan
POST /reset-state
```
## Development
Run checks:
```bash
node --check dashboard/dist/index.js
python3 -m py_compile dashboard/plugin_api.py
python3 -m unittest tests/test_achievement_engine.py -v
```
## License
MIT
File diff suppressed because one or more lines are too long
+120
View File
@@ -0,0 +1,120 @@
/* hermes-achievements dashboard styles
* Originally authored by @PCinkusz https://github.com/PCinkusz/hermes-achievements (MIT).
* Bundled into hermes-agent. The in-progress scan banner rules at the bottom
* (.ha-scan-banner*) are a small addition layered on top of the original bundle.
*/
.ha-page { display: flex; flex-direction: column; gap: 1rem; }
.ha-hero { position: relative; overflow: hidden; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; border: 1px solid var(--color-border); background: radial-gradient(circle at 12% 0, rgba(103,232,249,.13), transparent 30%), linear-gradient(135deg, color-mix(in srgb, var(--color-card) 88%, transparent), color-mix(in srgb, var(--color-primary) 10%, transparent)); padding: 1.25rem; }
.ha-hero:before { content: ""; position: absolute; inset: auto -10% -80% -10%; height: 180%; pointer-events: none; background: radial-gradient(circle, rgba(242,201,76,.12), transparent 55%); }
.ha-hero h1 { position: relative; margin: 0; font-size: clamp(2rem, 4vw, 4.2rem); line-height: .9; letter-spacing: -0.06em; }
.ha-hero p { position: relative; max-width: 52rem; margin: .65rem 0 0; color: var(--color-muted-foreground); }
.ha-kicker { position: relative; color: var(--color-muted-foreground); text-transform: uppercase; letter-spacing: .18em; font-size: .72rem; font-family: var(--font-mono, ui-monospace, monospace); }
.ha-refresh { position: relative; white-space: nowrap; }
.ha-stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .75rem; }
.ha-stat-content { padding: 1rem !important; }
.ha-stat-label { color: var(--color-muted-foreground); font-size: .75rem; text-transform: uppercase; letter-spacing: .12em; }
.ha-stat-value { margin-top: .35rem; font-size: 1.4rem; font-weight: 750; letter-spacing: -0.035em; }
.ha-stat-hint { margin-top: .2rem; color: var(--color-muted-foreground); font-size: .75rem; }
.ha-toolbar { display: flex; justify-content: space-between; gap: .75rem; align-items: center; flex-wrap: wrap; }
.ha-pills { display: flex; gap: .35rem; flex-wrap: wrap; }
.ha-pills button { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 72%, transparent); color: var(--color-muted-foreground); padding: .35rem .6rem; font-size: .78rem; cursor: pointer; }
.ha-pills button.active, .ha-pills button:hover { color: var(--color-foreground); border-color: var(--ha-tier, var(--color-ring)); background: color-mix(in srgb, var(--color-primary) 16%, var(--color-card)); }
.ha-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: .9rem; }
.ha-card { --ha-tier: var(--color-border); position: relative; overflow: hidden; min-height: 214px; border: 1px solid color-mix(in srgb, var(--ha-tier) 46%, var(--color-border)); background: radial-gradient(circle at 2.6rem 2.2rem, color-mix(in srgb, var(--ha-tier) 16%, transparent), transparent 34%), linear-gradient(180deg, rgba(255,255,255,.04), transparent), color-mix(in srgb, var(--color-card) 92%, #000); transition: transform .16s ease, border-color .16s ease, opacity .16s ease, box-shadow .16s ease; }
.ha-card:hover { transform: translateY(-2px); border-color: var(--ha-tier); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ha-tier) 16%, transparent); }
.ha-card-content { position: relative; z-index: 1; padding: 1rem !important; display: flex; flex-direction: column; gap: .75rem; height: 100%; }
.ha-card-head { display: grid; grid-template-columns: 3.1rem minmax(0, 1fr) auto; gap: .85rem; align-items: start; }
.ha-icon { display: grid; place-items: center; width: 2.9rem; height: 2.9rem; color: var(--ha-tier); }
.ha-lucide { width: 1.78rem; height: 1.78rem; stroke: currentColor; stroke-width: 2.15; filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ha-tier) 24%, transparent)); }
.ha-card-title { font-weight: 780; line-height: 1.05; letter-spacing: -0.025em; }
.ha-card-category { margin-top: .28rem; color: var(--color-muted-foreground); font-size: .76rem; }
.ha-badges { display: flex; flex-direction: column; align-items: flex-end; gap: .25rem; }
.ha-tier-badge, .ha-state-badge { border: 1px solid var(--ha-tier); color: var(--ha-tier); background: color-mix(in srgb, var(--ha-tier) 10%, transparent); padding: .16rem .38rem; font-size: .67rem; text-transform: uppercase; letter-spacing: .08em; font-family: var(--font-mono, ui-monospace, monospace); }
.ha-description { margin: 0; color: var(--color-muted-foreground); font-size: .86rem; line-height: 1.45; min-height: 2.4em; }
.ha-criteria { border: 1px solid color-mix(in srgb, var(--ha-tier) 28%, var(--color-border)); background: color-mix(in srgb, var(--ha-tier) 5%, transparent); }
.ha-criteria summary { cursor: pointer; padding: .5rem .65rem; color: var(--ha-tier); text-transform: uppercase; letter-spacing: .1em; font-size: .66rem; font-family: var(--font-mono, ui-monospace, monospace); user-select: none; }
.ha-criteria summary:hover { background: color-mix(in srgb, var(--ha-tier) 8%, transparent); }
.ha-criteria p { margin: 0; border-top: 1px solid color-mix(in srgb, var(--ha-tier) 18%, var(--color-border)); padding: .55rem .65rem .65rem; color: color-mix(in srgb, var(--color-foreground) 78%, var(--color-muted-foreground)); font-size: .76rem; line-height: 1.38; }
.ha-progress-row { display: flex; align-items: center; gap: .55rem; margin-top: 0; }
.ha-progress-track { flex: 1; height: .48rem; border: 1px solid color-mix(in srgb, var(--ha-tier) 34%, var(--color-border)); background: rgba(0,0,0,.22); overflow: hidden; }
.ha-progress-fill { height: 100%; background: linear-gradient(90deg, var(--ha-tier), color-mix(in srgb, var(--ha-tier) 48%, white)); }
.ha-progress-text { min-width: 5.4rem; text-align: right; font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-muted-foreground); font-size: .72rem; }
.ha-evidence-slot { min-height: 1.65rem; margin-top: auto; display: flex; align-items: flex-end; }
.ha-evidence { width: 100%; display: flex; align-items: center; gap: .4rem; color: var(--color-muted-foreground); font-size: .72rem; min-width: 0; }
.ha-evidence-label { text-transform: uppercase; letter-spacing: .09em; font-family: var(--font-mono, ui-monospace, monospace); flex: 0 0 auto; }
.ha-evidence-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: color-mix(in srgb, var(--color-foreground) 84%, var(--color-muted-foreground)); }
.ha-evidence-empty { visibility: hidden; }
.ha-latest h2 { margin: 0 0 .5rem; font-size: 1rem; }
.ha-latest-row { display: flex; gap: .5rem; flex-wrap: wrap; }
.ha-chip { display: inline-flex; align-items: center; gap: .35rem; border: 1px solid var(--ha-tier); color: var(--ha-tier); background: color-mix(in srgb, var(--ha-tier) 10%, transparent); padding: .35rem .55rem; font-size: .8rem; }
.ha-chip-icon .ha-lucide { width: .95rem; height: .95rem; }
.ha-slot { border-style: dashed; }
.ha-slot-content { display: flex; gap: .6rem; align-items: center; padding: .65rem .8rem !important; font-size: .82rem; }
.ha-slot-star { color: #67e8f9; }
.ha-slot-muted { color: var(--color-muted-foreground); margin-left: auto; }
.ha-error { border-color: #ef4444; color: #fecaca; }
.ha-loading { color: var(--color-muted-foreground); font-family: var(--font-mono, ui-monospace, monospace); padding: 2rem; border: 1px dashed var(--color-border); }
.ha-guide { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr); gap: .75rem; }
.ha-guide > div { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 82%, transparent); padding: .85rem 1rem; }
.ha-guide strong { display: block; margin-bottom: .45rem; font-size: .78rem; text-transform: uppercase; letter-spacing: .12em; font-family: var(--font-mono, ui-monospace, monospace); }
.ha-guide p { margin: 0; color: var(--color-muted-foreground); font-size: .84rem; line-height: 1.45; }
.ha-tier-legend { display: flex; align-items: center; gap: .45rem; flex-wrap: wrap; }
.ha-tier-step { --ha-tier: var(--color-border); display: inline-flex; align-items: center; gap: .32rem; color: var(--ha-tier); border: 1px solid color-mix(in srgb, var(--ha-tier) 52%, var(--color-border)); background: color-mix(in srgb, var(--ha-tier) 8%, transparent); padding: .28rem .45rem; font-size: .72rem; font-family: var(--font-mono, ui-monospace, monospace); text-transform: uppercase; letter-spacing: .06em; }
.ha-tier-step i { width: .55rem; height: .55rem; background: var(--ha-tier); display: inline-block; }
.ha-tier-arrow { color: var(--color-muted-foreground); }
.ha-state-discovered { opacity: .92; }
.ha-state-discovered .ha-card-title { color: color-mix(in srgb, var(--color-foreground) 82%, var(--ha-tier)); }
.ha-state-secret { opacity: .5; filter: grayscale(.55); }
.ha-state-secret:after { content: ""; position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(-45deg, transparent 0 8px, rgba(255,255,255,.035) 8px 10px); }
.ha-tier-pending { --ha-tier: color-mix(in srgb, var(--color-muted-foreground) 64%, transparent); }
.ha-tier-copper { --ha-tier: #b87333; }
.ha-tier-silver { --ha-tier: #c0c7d2; }
.ha-tier-gold { --ha-tier: #f2c94c; box-shadow: 0 0 22px rgba(242,201,76,.08); }
.ha-tier-diamond { --ha-tier: #67e8f9; box-shadow: 0 0 24px rgba(103,232,249,.1); }
.ha-tier-olympian { --ha-tier: #c084fc; box-shadow: 0 0 34px rgba(192,132,252,.18), 0 0 12px rgba(242,201,76,.1); }
@media (max-width: 980px) { .ha-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .ha-guide { grid-template-columns: 1fr; } }
@media (max-width: 800px) { .ha-stats { grid-template-columns: 1fr; } .ha-hero { flex-direction: column; align-items: stretch; } .ha-card-head { grid-template-columns: 3.1rem 1fr; } .ha-badges { grid-column: 1 / -1; align-items: flex-start; flex-direction: row; } }
.ha-secret-empty-content { padding: 1rem !important; }
.ha-secret-empty strong { display: block; margin-bottom: .35rem; }
.ha-secret-empty p { margin: 0; color: var(--color-muted-foreground); font-size: .86rem; line-height: 1.45; }
.ha-page-loading { animation: ha-fade-in .18s ease-out; }
.ha-loading-hero { align-items: center; }
.ha-scan-status { position: relative; z-index: 1; display: flex; align-items: center; gap: .8rem; min-width: 18rem; border: 1px solid color-mix(in srgb, #67e8f9 35%, var(--color-border)); background: color-mix(in srgb, var(--color-card) 78%, transparent); padding: .8rem .95rem; color: var(--color-foreground); }
.ha-scan-status strong { display: block; font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono, ui-monospace, monospace); }
.ha-scan-status p { margin: .25rem 0 0; font-size: .78rem; line-height: 1.35; color: var(--color-muted-foreground); }
.ha-scan-pulse { width: .72rem; height: .72rem; flex: 0 0 auto; border-radius: 999px; background: #67e8f9; box-shadow: 0 0 0 0 rgba(103,232,249,.55); animation: ha-pulse 1.35s ease-out infinite; }
.ha-skeleton-card { pointer-events: none; }
.ha-skeleton { position: relative; overflow: hidden; border-radius: 0; background: color-mix(in srgb, var(--color-muted-foreground) 16%, transparent); }
.ha-skeleton:after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, rgba(255,255,255,.14), transparent); animation: ha-shimmer 1.35s infinite; }
.ha-skeleton-stack { display: flex; flex-direction: column; gap: .45rem; padding-top: .15rem; }
.ha-skeleton-icon { width: 2.9rem; height: 2.9rem; }
.ha-skeleton-title { width: 72%; height: .95rem; }
.ha-skeleton-meta { width: 45%; height: .65rem; }
.ha-skeleton-badge { width: 4.4rem; height: 1.05rem; }
.ha-skeleton-badge-short { width: 3.6rem; }
.ha-skeleton-line { height: .78rem; width: 92%; }
.ha-skeleton-line-short { width: 68%; }
.ha-skeleton-criteria { height: 2.2rem; width: 100%; border: 1px solid color-mix(in srgb, var(--color-muted-foreground) 18%, var(--color-border)); }
.ha-skeleton-evidence { width: 58%; height: .8rem; }
.ha-skeleton-progress { flex: 1; height: .48rem; }
.ha-skeleton-progress-text { width: 4.6rem; height: .75rem; }
.ha-skeleton-stat-value { width: 56%; height: 1.35rem; margin-top: .55rem; }
.ha-skeleton-stat-hint { width: 76%; height: .7rem; margin-top: .55rem; }
.ha-loading-guide p { color: var(--color-muted-foreground); }
@keyframes ha-shimmer { 100% { transform: translateX(100%); } }
@keyframes ha-pulse { 0% { box-shadow: 0 0 0 0 rgba(103,232,249,.48); } 70% { box-shadow: 0 0 0 .65rem rgba(103,232,249,0); } 100% { box-shadow: 0 0 0 0 rgba(103,232,249,0); } }
@keyframes ha-fade-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
.ha-loading-hero p, .ha-scan-status p, .ha-loading-guide p { text-transform: none; letter-spacing: normal; }
/* In-progress scan banner shown on the main page while the background scan
* is still walking through session history, so the user sees continuous
* progress (X / Y sessions · Z%) instead of guessing whether anything is
* happening. Reuses .ha-scan-pulse + ha-pulse keyframes from the loading page.
*/
.ha-scan-banner { display: flex; flex-direction: column; gap: .6rem; border: 1px solid color-mix(in srgb, #67e8f9 35%, var(--color-border)); background: color-mix(in srgb, var(--color-card) 78%, transparent); padding: .8rem .95rem; animation: ha-fade-in .18s ease-out; }
.ha-scan-banner-head { display: flex; align-items: center; gap: .8rem; }
.ha-scan-banner-text strong { display: block; font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-foreground); }
.ha-scan-banner-text p { margin: .25rem 0 0; font-size: .78rem; line-height: 1.35; color: var(--color-muted-foreground); text-transform: none; letter-spacing: normal; }
.ha-scan-progress-track { height: .4rem; border: 1px solid color-mix(in srgb, #67e8f9 28%, var(--color-border)); background: rgba(0,0,0,.22); overflow: hidden; }
.ha-scan-progress-fill { height: 100%; background: linear-gradient(90deg, #67e8f9, color-mix(in srgb, #67e8f9 48%, white)); transition: width .4s ease-out; }
@@ -0,0 +1,11 @@
{
"name": "hermes-achievements",
"label": "Achievements",
"description": "Steam-style achievements for vibe coding and agentic Hermes workflows.",
"icon": "Star",
"version": "0.3.1",
"tab": { "path": "/achievements", "position": "after:analytics" },
"entry": "dist/index.js",
"css": "dist/style.css",
"api": "plugin_api.py"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,157 @@
# Hermes Achievements Performance Implementation Plan
Status: Ready for execution after hackathon review window
Constraint: Plugin remains frozen until judging is complete
Decision: `/overview` and top-banner slots are out of scope and will be removed.
---
## Phase 0 — Baseline & Safety (no behavior change)
### Task 0.1: Add perf benchmark script (local)
Objective: Repro baseline before/after.
Acceptance:
- Can print endpoint timings for `/achievements` (3 runs each, cold + warm).
### Task 0.2: Define acceptance thresholds
Objective: Lock success criteria now.
Acceptance:
- Documented SLOs:
- `/achievements` p95 < 1s (cached)
- max active scan jobs = 1
---
## Phase 1 — Remove unused overview/slot surface (highest certainty)
### Task 1.1: Remove `/overview` backend route
Objective: Eliminate duplicate heavy endpoint path.
Acceptance:
- `plugin_api.py` no longer exposes `/overview`.
### Task 1.2: Remove slot registration and SummarySlot frontend code
Objective: Remove cross-tab banner fetch behavior.
Acceptance:
- No `registerSlot(..."sessions:top"...)` or `registerSlot(..."analytics:top"...)`.
- No frontend call to `api("/overview")`.
### Task 1.3: Update plugin manifest
Objective: Reflect final UI scope.
Acceptance:
- `manifest.json` removes `slots` declarations.
- Tab registration remains intact.
---
## Phase 2 — Shared snapshot persistence + single-flight for `/achievements`
### Task 2.1: Introduce snapshot store abstraction + on-disk persistence
Objective: Single source of truth for Achievements data that survives process restarts.
Acceptance:
- One structure contains dataset consumed by `/achievements`.
- Repeated requests do not recompute when cache is fresh.
- Snapshot persisted at `~/.hermes/plugins/hermes-achievements/scan_snapshot.json`.
### Task 2.2: Single-flight scan coordinator
Objective: Prevent concurrent recomputes.
Acceptance:
- Simultaneous requests result in one compute run.
### Task 2.3: Refactor `/achievements` to read snapshot
Objective: Remove direct repeated compute from request path.
Acceptance:
- `/achievements` does not run independent full recompute per request when cache is valid.
---
## Phase 3 — Stale-While-Revalidate
### Task 3.1: TTL state (`FRESH`/`STALE`)
Objective: Serve immediately when stale, refresh in background.
Acceptance:
- Cached response returned quickly even when expired.
- Refresh is asynchronous.
### Task 3.2: Add `scan-status` endpoint (optional)
Objective: Let UI/ops inspect scan state.
Acceptance:
- Returns state, last success time, last duration, last error.
### Task 3.3: Add metadata fields to `/achievements`
Objective: Improve transparency.
Acceptance:
- Response includes `generated_at`, `is_stale`, maybe `scan_id`.
---
## Phase 4 — Incremental Scanning (optional but recommended)
### Task 4.1: Add per-session checkpoint file
Objective: Track session-level changes, not just global scan time.
Acceptance:
- Checkpoint persisted at `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json`.
- For each session: `session_id`, fingerprint (`updated_at`/message_count/hash), and cached contribution.
### Task 4.2: Incremental aggregation
Objective: Recompute only changed/new sessions and reuse unchanged contributions.
Acceptance:
- Typical refresh time drops materially below full scan.
- Aggregate rebuild uses: subtract old contribution + add new contribution for changed sessions.
### Task 4.3: Full rebuild fallback
Objective: Preserve correctness.
Acceptance:
- Manual full rescan always possible.
- Schema/version changes invalidate checkpoint and force full rebuild.
---
## Test Plan
1. Unit tests
- Snapshot lifecycle transitions
- Dedupe logic under parallel requests
- `/achievements` response compatibility
2. Integration tests
- Opening Achievements repeatedly causes <=1 heavy scan while in-flight
- `/achievements` warm-cache load is fast
- manual rescan updates snapshot and timestamps
3. Manual benchmarks
- Compare pre/post `/achievements` timings with same history dataset
---
## Rollout Plan
1. Release internal branch with Phase 1 (remove overview/slots).
2. Validate no UI regression in Achievements tab.
3. Add Phase 2 snapshot/dedupe.
4. Add Phase 3 stale-while-revalidate + status metadata.
5. Optional: incremental scanner.
Rollback: keep old compute path behind temporary feature flag for one release window.
---
## Definition of Done
- Achievements tab remains fully functional (counts, latest, tiers, cards, filters).
- No `/overview` endpoint or slot calls remain.
- Repeated Achievements loads feel immediate after warm cache.
- Metrics/unlocks remain unchanged versus baseline.
@@ -0,0 +1,219 @@
# Hermes Achievements Implementation Spec (Detailed)
This document is implementation-facing detail to execute the performance refactor later.
Decision scope: keep only Achievements tab flow; remove `/overview` + top-banner slot integration.
---
## A) Current Behavior Summary
- `evaluate_all()` performs:
- full `scan_sessions()`
- `SessionDB.list_sessions_rich(...)`
- `db.get_messages(session_id)` for each session
- text/tool regex analysis + aggregation + evaluation
- `/overview` and `/achievements` both currently call `evaluate_all()` directly.
- slot calls (`sessions:top`, `analytics:top`) currently invoke `/overview`.
Consequence: repeated full recomputes and contention.
---
## B) De-scope/Removal Changes
1. Remove backend route:
- `GET /overview`
2. Remove frontend slot usage:
- `SummarySlot` component
- `registerSlot("sessions:top")`
- `registerSlot("analytics:top")`
3. Remove manifest slot declarations:
- `"slots": ["sessions:top", "analytics:top"]`
4. Keep:
- tab route/page for Achievements
- `/achievements` endpoint and full tab rendering
---
## C) Target Internal Interfaces
### 1) `SnapshotStore`
Responsibilities:
- hold latest computed snapshot in memory
- persist/load snapshot from disk
- expose age and staleness checks
Storage path:
- `~/.hermes/plugins/hermes-achievements/scan_snapshot.json`
Methods (conceptual):
- `get()` -> snapshot | null
- `set(snapshot)`
- `is_stale(ttl_seconds)`
### 2) `ScanCoordinator`
Responsibilities:
- single-flight guard for compute jobs
- track scan status
Methods:
- `run_if_needed(force: bool = false)`
- `get_status()`
State fields:
- `state`: `idle|running|failed`
- `started_at`, `finished_at`
- `last_error`
- `run_count`
### 3) `build_snapshot()`
Responsibilities:
- execute current compute logic once
- on first run, perform full scan and materialize per-session contributions
- on subsequent runs, process only changed/new sessions via checkpoint fingerprints
- produce shape consumed by `/achievements`
Output:
- `achievements`
- count fields
- optional `scan_meta`
---
## D) Endpoint Behavior Matrix (No `/overview`)
| Endpoint | Cache fresh | Cache stale | No cache | Force rescan |
|---|---|---|---|---|
| `/achievements` | return cached | return stale + trigger bg refresh | blocking bootstrap scan | n/a |
| `/rescan` | trigger refresh | trigger refresh | trigger refresh | yes |
| `/scan-status` | status only | status only | status only | status only |
Notes:
- At most one scan run active.
- Other callers either await same run or receive stale snapshot according to policy.
---
## E) Data Shape (Proposed)
```json
{
"generated_at": 0,
"is_stale": false,
"scan_meta": {
"duration_ms": 0,
"sessions_scanned": 0,
"messages_scanned": 0,
"mode": "full",
"error": null
},
"achievements": [],
"unlocked_count": 0,
"discovered_count": 0,
"secret_count": 0,
"total_count": 0,
"error": null
}
```
Compatibility guidance:
- Keep existing `/achievements` keys.
- Add metadata keys without breaking old callers.
Checkpoint file (new):
- `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json`
Suggested checkpoint shape:
```json
{
"schema_version": 1,
"generated_at": 0,
"sessions": {
"<session_id>": {
"fingerprint": {
"updated_at": 0,
"message_count": 0,
"hash": "optional"
},
"contribution": {
"metrics": {}
}
}
}
}
```
Notes:
- fingerprint mismatch => recompute that session contribution only.
- unchanged fingerprint => reuse stored contribution.
---
## F) Concurrency Contract
- Any request path that needs fresh data must pass through single-flight coordinator.
- If a scan is running:
- do not start second scan
- either await in-flight run (bounded) or serve stale snapshot immediately
- lock scope must include scan start/finish state transitions.
---
## G) Error Handling Contract
- If refresh fails and prior snapshot exists:
- return prior snapshot with `is_stale=true` and error metadata
- If refresh fails and no prior snapshot:
- return explicit error response (current behavior equivalent)
- `scan-status` should always return last known state/error.
---
## H) Frontend Integration Contract
- Achievements page:
- one fetch on mount to `/achievements`
- optional background refresh indicator if stale
- no top-banner slot integration
- avoid duplicate in-flight calls during fast navigation by cancellation/debounce.
---
## I) Validation Checklist
- [ ] `/overview` route removed
- [ ] manifest has no `sessions:top`/`analytics:top` slots
- [ ] frontend has no `api("/overview")` calls
- [ ] repeated Achievements navigation does not create multiple heavy scans
- [ ] average warm load times meet SLOs
- [ ] unlock totals match pre-refactor baseline for same history
- [ ] no schema regression in `/achievements` response
---
## J) Suggested File Placement for Future Work
- backend changes: `dashboard/plugin_api.py`
- optional extraction:
- `dashboard/perf_snapshot.py`
- `dashboard/perf_scan_coordinator.py`
- frontend request hygiene: `dashboard/dist/index.js` (or source if available)
- plugin metadata: `dashboard/manifest.json`
- persisted runtime files:
- `~/.hermes/plugins/hermes-achievements/state.json` (existing unlock state)
- `~/.hermes/plugins/hermes-achievements/scan_snapshot.json` (new)
- `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json` (new)
---
## K) Post-Implementation Reporting Template
Record:
- dataset size (sessions/messages/tool calls)
- pre/post `/achievements` timings (cold/warm)
- whether single-flight dedupe triggered under repeated tab open
- any behavioral diffs in unlock counts
@@ -0,0 +1,174 @@
# Hermes Achievements Performance Spec (Post-Hackathon)
Status: Draft (no code changes yet)
Owner: hermes-achievements plugin
Scope: `dashboard/plugin_api.py` + `dashboard/dist/index.js` request behavior
Decision: **Drop `/overview` and top-banner slots**; keep only Achievements tab data path.
---
## 1) Problem Statement
Current plugin endpoints `/achievements` and `/overview` both execute a full history recomputation (`evaluate_all()`), which performs a full SessionDB scan each request.
Observed on this machine/repo:
- ~83 sessions
- ~7,125 messages
- ~3,623 tool calls
- `evaluate_all()` ~1316s per call
- `/achievements` ~1315s per call
- `/overview` ~1215s per call
- Overlap between endpoints increases perceived wait.
Given current product direction, `/overview` and cross-tab top-banner slots are not needed.
---
## 2) Goals
- Keep achievement correctness unchanged.
- Keep all Achievements-tab UX/data (unlocked/discovered/secrets/highest/latest/cards).
- Remove unused summary path (`/overview`) and slot wiring.
- Make Achievements tab faster by avoiding duplicate endpoint pathways.
- Ensure at most one heavy scan can run at a time.
Non-goals (phase 1):
- Rewriting achievement rules.
- Changing badge semantics/states.
---
## 3) Endpoint Semantics (Target)
### `GET /api/plugins/hermes-achievements/achievements`
Single source endpoint for Achievements UI.
Returns full payload used by the tab:
- `achievements`
- `unlocked_count`
- `discovered_count`
- `secret_count`
- `total_count`
- `error`
### `POST /api/plugins/hermes-achievements/rescan` (optional)
Manual refresh trigger.
Prefer async trigger + immediate status response.
### `GET /api/plugins/hermes-achievements/scan-status` (optional new)
Reports scan state for UX/ops.
### Removed
- `GET /api/plugins/hermes-achievements/overview`
---
## 4) UI Scope (Target)
Keep:
- Achievements page/tab (`/achievements` in plugin tab manifest)
- All existing Achievements tab stats/cards/filters
Remove:
- Top-banner summary slot components using `sessions:top` and `analytics:top`
- Any frontend call path to `/overview`
---
## 5) Runtime State Machine (for `/achievements`)
- `FRESH`: cached snapshot age <= TTL
- `STALE`: snapshot exists but expired
- `SCANNING`: background recompute running
- `FAILED`: last recompute failed, last good snapshot still served
Rules:
1. FRESH -> serve immediately.
2. STALE + not scanning -> serve stale snapshot immediately and launch background refresh.
3. SCANNING -> do not start another scan; join single-flight in-flight job.
4. No snapshot yet -> allow one blocking bootstrap scan.
---
## 6) Caching & Invalidation
### Phase 1
- In-memory cache + persisted snapshot file.
- TTL: 60180 seconds (configurable).
- Single-flight dedupe for scan requests.
- Persist plugin data under:
- `~/.hermes/plugins/hermes-achievements/scan_snapshot.json`
### Phase 2
- Incremental scan checkpoints with per-session fingerprints.
- Persist checkpoint data under:
- `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json`
- Checkpoint stores, per session:
- `session_id`
- fingerprint (`updated_at`, message_count, or hash)
- cached per-session contribution used for aggregate recomposition
- Scan policy:
- First run: full scan and materialize snapshot + checkpoint.
- Next runs: process only new/changed sessions, reuse unchanged contributions.
- Full rebuild only on:
- schema/version change
- checkpoint corruption
- explicit full rescan
---
## 7) Frontend Contract
- Achievements tab requests `/achievements` once on mount.
- No slot-based summary fetches.
- If response says `is_stale=true`, UI may display “Updating in background”.
- Avoid duplicate mount-triggered calls and cancel stale requests on navigation.
---
## 8) SLO Targets
- `/achievements` p95 < 1s (cached)
- Max concurrent heavy scans: 1
- Background refresh should not block UI
---
## 9) Observability Requirements
Track:
- scan count
- scan duration avg/p95
- dedupe hit count (joined in-flight scans)
- stale-served count
- failures + last error
Expose minimal diagnostics in `/scan-status`.
---
## 10) Backward Compatibility
- Keep `/achievements` response shape backward-compatible.
- Removing `/overview` is acceptable because slot UI is intentionally removed.
- If temporary compatibility is needed, `/overview` can return static deprecation response for one release.
---
## 11) Risks
- Stale data confusion -> mitigate with `generated_at` and explicit refresh status.
- Cache invalidation bugs -> start with conservative TTL + manual rescan.
- Concurrency bugs -> protect scan section with lock/single-flight guard.
- Session mutation edge cases -> use per-session fingerprint invalidation (not global timestamp only).
---
## 12) Persistence Files (Explicit)
Plugin state directory:
- `~/.hermes/plugins/hermes-achievements/`
Files:
- `state.json` (existing): unlock tracking
- `scan_snapshot.json` (new): latest materialized achievements payload
- `scan_checkpoint.json` (new): per-session fingerprints + contributions for incremental refresh
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,156 @@
import importlib.util
import unittest
from pathlib import Path
MODULE_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "plugin_api.py"
spec = importlib.util.spec_from_file_location("plugin_api", MODULE_PATH)
plugin_api = importlib.util.module_from_spec(spec)
spec.loader.exec_module(plugin_api)
class AchievementEngineTests(unittest.TestCase):
def test_tool_call_stats_detect_tool_names_and_errors(self):
messages = [
{"role": "assistant", "tool_calls": [{"function": {"name": "terminal"}}]},
{"role": "tool", "tool_name": "terminal", "content": "Error: port 3000 already in use"},
{"role": "assistant", "tool_calls": [{"function": {"name": "web_search"}}]},
]
stats = plugin_api.analyze_messages("s1", "Fix dev server", messages)
self.assertEqual(stats["tool_call_count"], 2)
self.assertEqual(stats["tool_names"], {"terminal", "web_search"})
self.assertEqual(stats["error_count"], 1)
self.assertIs(stats["port_conflict"], True)
def test_tiered_achievement_reaches_highest_matching_tier(self):
definition = {
"id": "let_him_cook",
"threshold_metric": "max_tool_calls_in_session",
"tiers": [
{"name": "Copper", "threshold": 10},
{"name": "Silver", "threshold": 25},
{"name": "Gold", "threshold": 50},
],
}
aggregate = {"max_tool_calls_in_session": 28}
result = plugin_api.evaluate_tiered(definition, aggregate)
self.assertIs(result["unlocked"], True)
self.assertEqual(result["tier"], "Silver")
self.assertEqual(result["progress"], 28)
self.assertEqual(result["next_tier"], "Gold")
def test_tiered_achievement_can_be_discovered_without_unlocking(self):
definition = {
"id": "terminal_goblin",
"threshold_metric": "total_terminal_calls",
"tiers": [{"name": "Copper", "threshold": 50}],
}
aggregate = {"total_terminal_calls": 12}
result = plugin_api.evaluate_tiered(definition, aggregate)
self.assertIs(result["unlocked"], False)
self.assertIs(result["discovered"], True)
self.assertEqual(result["state"], "discovered")
self.assertEqual(result["progress"], 12)
self.assertEqual(result["next_threshold"], 50)
def test_secret_achievement_stays_hidden_without_progress(self):
definition = {
"id": "permission_denied_any_percent",
"name": "Permission Denied Any%",
"secret": True,
"requirements": [{"metric": "permission_denied_events", "gte": 3}],
}
aggregate = {"permission_denied_events": 0}
result = plugin_api.evaluate_requirements(definition, aggregate)
display = plugin_api.display_achievement({**definition, **result})
self.assertEqual(result["state"], "secret")
self.assertEqual(display["name"], "???")
self.assertNotIn("Permission", display["description"])
def test_multi_condition_unlock_requires_all_requirements(self):
definition = {
"id": "full_send",
"requirements": [
{"metric": "max_terminal_calls_in_session", "gte": 10},
{"metric": "max_file_tool_calls_in_session", "gte": 5},
{"metric": "max_web_calls_in_session", "gte": 2},
],
}
partial = plugin_api.evaluate_requirements(definition, {
"max_terminal_calls_in_session": 12,
"max_file_tool_calls_in_session": 2,
"max_web_calls_in_session": 0,
})
complete = plugin_api.evaluate_requirements(definition, {
"max_terminal_calls_in_session": 12,
"max_file_tool_calls_in_session": 6,
"max_web_calls_in_session": 2,
})
self.assertEqual(partial["state"], "discovered")
self.assertIs(partial["unlocked"], False)
self.assertLess(partial["progress_pct"], 100)
self.assertEqual(complete["state"], "unlocked")
self.assertIs(complete["unlocked"], True)
def test_catalog_has_60_plus_unique_achievements(self):
ids = [achievement["id"] for achievement in plugin_api.ACHIEVEMENTS]
self.assertGreaterEqual(len(ids), 60)
self.assertEqual(len(ids), len(set(ids)))
def test_model_provider_metrics_are_aggregated(self):
sessions = [
{"model_names": {"openai/gpt-5", "anthropic/claude-sonnet-4"}},
{"model_names": {"google/gemini-pro", "mistral/large"}},
{"model_names": {"qwen/qwen3"}},
]
aggregate = plugin_api.aggregate_stats(sessions)
self.assertEqual(aggregate["distinct_model_count"], 5)
self.assertEqual(aggregate["distinct_provider_count"], 5)
result = plugin_api.evaluate_definition(
next(a for a in plugin_api.ACHIEVEMENTS if a["id"] == "five_model_flight"),
aggregate,
)
self.assertEqual(result["state"], "unlocked")
self.assertEqual(result["tier"], "Copper")
def test_removed_noisy_achievements_are_not_in_catalog(self):
ids = {achievement["id"] for achievement in plugin_api.ACHIEVEMENTS}
self.assertNotIn("fallback_pilot", ids)
self.assertNotIn("browser_sleuth", ids)
self.assertNotIn("release_ritualist", ids)
def test_open_weights_pilgrim_counts_only_local_model_metadata(self):
aggregate_mentions_only = plugin_api.aggregate_stats([
{"model_names": {"openai/gpt-5"}, "local_model_events": 999},
])
aggregate_local_chat = plugin_api.aggregate_stats([
{"model_names": {"openai/gpt-5"}},
{"model_names": {"ollama/llama3"}},
])
definition = next(a for a in plugin_api.ACHIEVEMENTS if a["id"] == "open_weights_pilgrim")
self.assertEqual(aggregate_mentions_only["local_model_chat_sessions"], 0)
self.assertEqual(plugin_api.evaluate_definition(definition, aggregate_mentions_only)["state"], "discovered")
self.assertEqual(aggregate_local_chat["local_model_chat_sessions"], 1)
self.assertEqual(plugin_api.evaluate_definition(definition, aggregate_local_chat)["state"], "unlocked")
def test_config_surgeon_ignores_generic_config_mentions(self):
stats = plugin_api.analyze_messages("s1", "Config talk", [{"content": "config config configuration not configured"}])
self.assertEqual(stats["config_events"], 0)
stats = plugin_api.analyze_messages("s2", "Real config", [{"content": "edited config.yaml, manifest.json, and .env.local"}])
self.assertGreaterEqual(stats["config_events"], 3)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+752
View File
@@ -0,0 +1,752 @@
/*
* Hermes Kanban dashboard plugin styles.
*
* All colors reference theme CSS vars so the board reskins with the
* active dashboard theme. No hardcoded palette.
*/
.hermes-kanban {
width: 100%;
}
/* ---- Columns layout -------------------------------------------------- */
.hermes-kanban-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 0.75rem;
align-items: start;
}
.hermes-kanban-column {
display: flex;
flex-direction: column;
background: color-mix(in srgb, var(--color-card) 85%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 0.5rem;
min-height: 200px;
max-height: calc(100vh - 220px);
transition: border-color 120ms ease, background-color 120ms ease;
}
.hermes-kanban-column--drop {
border-color: var(--color-ring);
background: color-mix(in srgb, var(--color-ring) 8%, var(--color-card));
}
.hermes-kanban-column-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.25rem 0.35rem;
font-weight: 600;
font-size: 0.85rem;
color: var(--color-foreground);
}
.hermes-kanban-column-label {
flex: 1;
letter-spacing: 0.01em;
}
.hermes-kanban-column-count {
font-variant-numeric: tabular-nums;
color: var(--color-muted-foreground);
font-size: 0.75rem;
font-weight: 500;
}
.hermes-kanban-column-add {
appearance: none;
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-foreground);
border-radius: var(--radius-sm, 0.25rem);
width: 22px;
height: 22px;
line-height: 1;
font-size: 1rem;
cursor: pointer;
}
.hermes-kanban-column-add:hover {
background: color-mix(in srgb, var(--color-foreground) 8%, transparent);
}
.hermes-kanban-column-sub {
padding: 0 0.25rem 0.5rem;
font-size: 0.7rem;
color: var(--color-muted-foreground);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
margin-bottom: 0.5rem;
}
.hermes-kanban-column-body {
display: flex;
flex-direction: column;
gap: 0.45rem;
overflow-y: auto;
padding-right: 0.1rem;
}
.hermes-kanban-empty {
padding: 1.5rem 0.5rem;
text-align: center;
font-size: 0.75rem;
color: var(--color-muted-foreground);
border: 1px dashed color-mix(in srgb, var(--color-border) 70%, transparent);
border-radius: var(--radius-sm, 0.25rem);
}
/* ---- Status dots ----------------------------------------------------- */
.hermes-kanban-dot {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: var(--color-muted-foreground);
}
.hermes-kanban-dot-triage { background: #b47dd6; } /* lilac — fresh/unspecified */
.hermes-kanban-dot-todo { background: var(--color-muted-foreground); }
.hermes-kanban-dot-ready { background: #d4b348; } /* amber */
.hermes-kanban-dot-running { background: #3fb97d; } /* green */
.hermes-kanban-dot-blocked { background: var(--color-destructive, #d14a4a); }
.hermes-kanban-dot-done { background: #4a8cd1; } /* blue */
.hermes-kanban-dot-archived { background: var(--color-border); }
/* ---- Progress pill (N/M child tasks done) --------------------------- */
.hermes-kanban-progress {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.62rem;
padding: 0.05rem 0.35rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-foreground) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-border) 80%, transparent);
color: var(--color-muted-foreground);
letter-spacing: 0.02em;
}
.hermes-kanban-progress--full {
background: color-mix(in srgb, #3fb97d 22%, transparent);
border-color: color-mix(in srgb, #3fb97d 45%, transparent);
color: var(--color-foreground);
}
/* ---- Lanes (per-profile sub-grouping inside Running) ---------------- */
.hermes-kanban-lane {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.25rem 0 0.35rem;
border-top: 1px dashed color-mix(in srgb, var(--color-border) 70%, transparent);
}
.hermes-kanban-lane:first-child {
border-top: 0;
padding-top: 0;
}
.hermes-kanban-lane-head {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-muted-foreground);
padding: 0 0.1rem;
}
.hermes-kanban-lane-name {
font-weight: 600;
font-family: var(--font-mono, ui-monospace, monospace);
}
.hermes-kanban-lane-count {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
/* ---- Card ------------------------------------------------------------ */
.hermes-kanban-card {
cursor: grab;
transition: transform 100ms ease, box-shadow 100ms ease;
}
.hermes-kanban-card:hover {
box-shadow: 0 1px 0 0 var(--color-ring) inset, 0 0 0 1px var(--color-ring) inset;
}
.hermes-kanban-card:active {
cursor: grabbing;
transform: scale(0.995);
}
.hermes-kanban-card-content {
padding: 0.5rem 0.6rem !important;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.hermes-kanban-card-row {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.hermes-kanban-card-id {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.65rem;
color: var(--color-muted-foreground);
letter-spacing: 0.03em;
}
.hermes-kanban-card-title {
font-size: 0.85rem;
font-weight: 500;
line-height: 1.3;
color: var(--color-foreground);
word-break: break-word;
}
.hermes-kanban-card-meta {
font-size: 0.7rem;
color: var(--color-muted-foreground);
gap: 0.55rem;
}
.hermes-kanban-priority {
font-size: 0.6rem !important;
padding: 0.05rem 0.3rem !important;
background: color-mix(in srgb, var(--color-ring) 18%, transparent);
color: var(--color-foreground);
border: 1px solid color-mix(in srgb, var(--color-ring) 40%, transparent);
}
.hermes-kanban-tag {
font-size: 0.6rem !important;
padding: 0.05rem 0.3rem !important;
}
.hermes-kanban-assignee {
font-weight: 500;
color: color-mix(in srgb, var(--color-foreground) 80%, var(--color-muted-foreground));
}
.hermes-kanban-unassigned {
font-style: italic;
}
.hermes-kanban-ago {
margin-left: auto;
}
/* ---- Inline create --------------------------------------------------- */
.hermes-kanban-inline-create {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: color-mix(in srgb, var(--color-card) 70%, transparent);
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm, 0.25rem);
}
/* ---- Drawer (task detail side panel) --------------------------------- */
.hermes-kanban-drawer-shade {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 60;
display: flex;
justify-content: flex-end;
}
.hermes-kanban-drawer {
width: min(480px, 92vw);
height: 100vh;
background: var(--color-card);
border-left: 1px solid var(--color-border);
display: flex;
flex-direction: column;
box-shadow: -4px 0 18px rgba(0, 0, 0, 0.35);
animation: hermes-kanban-drawer-in 180ms ease-out;
}
@keyframes hermes-kanban-drawer-in {
from { transform: translateX(100%); opacity: 0.3; }
to { transform: translateX(0); opacity: 1; }
}
.hermes-kanban-drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--color-border);
font-family: var(--font-mono, ui-monospace, monospace);
}
.hermes-kanban-drawer-close {
appearance: none;
background: transparent;
border: 0;
color: var(--color-muted-foreground);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
padding: 0 0.25rem;
}
.hermes-kanban-drawer-close:hover { color: var(--color-foreground); }
.hermes-kanban-drawer-body {
flex: 1;
overflow-y: auto;
padding: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.hermes-kanban-drawer-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.hermes-kanban-drawer-meta {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.5rem 0.6rem;
background: color-mix(in srgb, var(--color-foreground) 4%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 0.25rem);
}
.hermes-kanban-meta-row {
display: flex;
gap: 0.5rem;
font-size: 0.72rem;
}
.hermes-kanban-meta-label {
width: 92px;
color: var(--color-muted-foreground);
}
.hermes-kanban-meta-value {
color: var(--color-foreground);
word-break: break-word;
}
.hermes-kanban-actions {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.hermes-kanban-section {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.hermes-kanban-section-head {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-muted-foreground);
}
.hermes-kanban-pre {
margin: 0;
padding: 0.45rem 0.55rem;
white-space: pre-wrap;
word-break: break-word;
background: color-mix(in srgb, var(--color-foreground) 4%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 0.25rem);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.72rem;
color: var(--color-foreground);
}
.hermes-kanban-comment {
border-left: 2px solid color-mix(in srgb, var(--color-ring) 35%, transparent);
padding-left: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.hermes-kanban-comment-head {
display: flex;
gap: 0.5rem;
font-size: 0.7rem;
}
.hermes-kanban-comment-author {
font-weight: 600;
color: var(--color-foreground);
}
.hermes-kanban-comment-ago {
color: var(--color-muted-foreground);
}
.hermes-kanban-event {
display: flex;
gap: 0.5rem;
font-size: 0.7rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono, ui-monospace, monospace);
}
.hermes-kanban-event-kind {
color: var(--color-foreground);
min-width: 6rem;
}
.hermes-kanban-event-payload {
color: var(--color-muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 280px;
}
.hermes-kanban-drawer-comment-row {
display: flex;
gap: 0.4rem;
padding: 0.55rem 0.75rem;
border-top: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-card) 90%, transparent);
}
.hermes-kanban-count {
display: inline-flex;
gap: 0.2rem;
align-items: center;
}
/* ---- Selection chrome ----------------------------------------------- */
.hermes-kanban-card--selected :where(.hermes-kanban-card-content) {
box-shadow: 0 0 0 2px var(--color-ring) inset,
0 0 0 1px var(--color-ring) inset;
background: color-mix(in srgb, var(--color-ring) 6%, var(--color-card));
}
.hermes-kanban-card-check {
width: 0.85rem;
height: 0.85rem;
margin: 0;
cursor: pointer;
accent-color: var(--color-ring);
}
/* ---- Bulk action bar ------------------------------------------------ */
.hermes-kanban-bulk {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: color-mix(in srgb, var(--color-ring) 10%, var(--color-card));
border: 1px solid color-mix(in srgb, var(--color-ring) 40%, var(--color-border));
border-radius: var(--radius-sm, 0.25rem);
flex-wrap: wrap;
}
.hermes-kanban-bulk-count {
font-weight: 600;
font-size: 0.75rem;
padding-right: 0.25rem;
}
.hermes-kanban-bulk-btn {
height: 1.7rem !important;
padding: 0 0.5rem !important;
font-size: 0.7rem !important;
border: 1px solid var(--color-border);
cursor: pointer;
}
.hermes-kanban-bulk-btn:hover {
background: color-mix(in srgb, var(--color-foreground) 8%, transparent);
}
.hermes-kanban-bulk-reassign {
display: flex;
align-items: center;
gap: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
}
/* ---- Dependency editor chips --------------------------------------- */
.hermes-kanban-deps-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.hermes-kanban-deps-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-muted-foreground);
min-width: 4rem;
}
.hermes-kanban-deps-chips {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
flex: 1;
}
.hermes-kanban-deps-empty {
font-size: 0.7rem;
color: var(--color-muted-foreground);
font-style: italic;
}
.hermes-kanban-dep-chip {
display: inline-flex;
align-items: center;
gap: 0.15rem;
padding: 0.1rem 0.35rem;
background: color-mix(in srgb, var(--color-foreground) 6%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 0.25rem);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.68rem;
color: var(--color-foreground);
}
.hermes-kanban-dep-chip-x {
appearance: none;
background: transparent;
border: 0;
color: var(--color-muted-foreground);
cursor: pointer;
font-size: 0.85rem;
line-height: 1;
padding: 0 0.15rem;
}
.hermes-kanban-dep-chip-x:hover { color: var(--color-destructive, #d14a4a); }
/* ---- Inline edit affordances --------------------------------------- */
.hermes-kanban-editable {
cursor: pointer;
border-bottom: 1px dotted color-mix(in srgb, var(--color-border) 80%, transparent);
}
.hermes-kanban-editable:hover {
color: var(--color-foreground);
border-bottom-color: var(--color-ring);
}
.hermes-kanban-drawer-title-text {
cursor: pointer;
}
.hermes-kanban-drawer-title-text:hover {
text-decoration: underline;
text-decoration-color: var(--color-ring);
text-decoration-style: dotted;
text-underline-offset: 3px;
}
.hermes-kanban-edit-row {
display: flex;
align-items: center;
gap: 0.35rem;
width: 100%;
}
.hermes-kanban-section-head-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.hermes-kanban-edit-link {
appearance: none;
background: transparent;
border: 0;
color: var(--color-muted-foreground);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
padding: 0;
}
.hermes-kanban-edit-link:hover { color: var(--color-ring); }
.hermes-kanban-textarea {
width: 100%;
min-height: 8rem;
background: var(--color-card);
color: var(--color-foreground);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 0.25rem);
padding: 0.5rem 0.6rem;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8rem;
line-height: 1.5;
resize: vertical;
}
.hermes-kanban-textarea:focus {
outline: none;
border-color: var(--color-ring);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-ring) 30%, transparent);
}
/* ---- Markdown rendering -------------------------------------------- */
.hermes-kanban-md {
font-size: 0.8rem;
line-height: 1.55;
color: var(--color-foreground);
}
.hermes-kanban-md p { margin: 0.25rem 0; }
.hermes-kanban-md h1,
.hermes-kanban-md h2,
.hermes-kanban-md h3,
.hermes-kanban-md h4 {
margin: 0.6rem 0 0.2rem;
line-height: 1.25;
}
.hermes-kanban-md h1 { font-size: 1.05rem; }
.hermes-kanban-md h2 { font-size: 0.95rem; }
.hermes-kanban-md h3 { font-size: 0.88rem; }
.hermes-kanban-md h4 { font-size: 0.82rem; }
.hermes-kanban-md ul {
margin: 0.25rem 0 0.25rem 1.1rem;
padding: 0;
}
.hermes-kanban-md li { margin: 0.1rem 0; }
.hermes-kanban-md a {
color: var(--color-ring);
text-decoration: underline;
}
.hermes-kanban-md code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.75rem;
padding: 0.05rem 0.3rem;
background: color-mix(in srgb, var(--color-foreground) 8%, transparent);
border-radius: 3px;
}
.hermes-kanban-md-code {
margin: 0.35rem 0;
padding: 0.5rem 0.6rem;
background: color-mix(in srgb, var(--color-foreground) 5%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 0.25rem);
overflow-x: auto;
}
.hermes-kanban-md-code code {
background: transparent;
padding: 0;
font-size: 0.75rem;
white-space: pre;
}
.hermes-kanban-md strong { font-weight: 600; }
/* ---- Touch-drag proxy ---------------------------------------------- */
.hermes-kanban-touch-proxy {
pointer-events: none;
opacity: 0.85;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
transform: scale(1.02);
transition: none;
}
/* ---- Staleness tiers ------------------------------------------------ */
.hermes-kanban-card--stale-amber :where(.hermes-kanban-card-content) {
box-shadow: 0 0 0 1px #d4b34888 inset;
}
.hermes-kanban-card--stale-amber:hover :where(.hermes-kanban-card-content) {
box-shadow: 0 0 0 2px #d4b348 inset;
}
.hermes-kanban-card--stale-red :where(.hermes-kanban-card-content) {
box-shadow: 0 0 0 1px var(--color-destructive, #d14a4a) inset,
0 0 8px color-mix(in srgb, var(--color-destructive, #d14a4a) 30%, transparent);
}
.hermes-kanban-card--stale-red:hover :where(.hermes-kanban-card-content) {
box-shadow: 0 0 0 2px var(--color-destructive, #d14a4a) inset,
0 0 10px color-mix(in srgb, var(--color-destructive, #d14a4a) 45%, transparent);
}
/* ---- Worker log pane ------------------------------------------------ */
.hermes-kanban-log {
max-height: 340px;
overflow: auto;
white-space: pre;
font-size: 0.7rem;
line-height: 1.45;
}
/* ---- Run history (per-attempt log in the drawer) ------------------- */
.hermes-kanban-run {
border-left: 2px solid var(--color-border);
padding: 0.35rem 0.5rem;
margin-bottom: 0.4rem;
background: color-mix(in srgb, var(--color-foreground) 3%, transparent);
border-radius: var(--radius-sm, 0.25rem);
}
.hermes-kanban-run--active { border-left-color: #3fb97d; }
.hermes-kanban-run--completed { border-left-color: #4a8cd1; }
.hermes-kanban-run--ended { border-left-color: #6b7280; } /* generic fallback when outcome is unset */
.hermes-kanban-run--blocked { border-left-color: var(--color-destructive, #d14a4a); }
.hermes-kanban-run--crashed,
.hermes-kanban-run--timed_out,
.hermes-kanban-run--gave_up,
.hermes-kanban-run--spawn_failed {
border-left-color: var(--color-destructive, #d14a4a);
background: color-mix(in srgb, var(--color-destructive, #d14a4a) 6%, transparent);
}
.hermes-kanban-run--reclaimed { border-left-color: #d4b348; }
.hermes-kanban-run-head {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.7rem;
}
.hermes-kanban-run-outcome {
font-family: var(--font-mono, ui-monospace, monospace);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-foreground);
}
.hermes-kanban-run-profile {
color: var(--color-muted-foreground);
}
.hermes-kanban-run-elapsed {
font-variant-numeric: tabular-nums;
color: var(--color-muted-foreground);
}
.hermes-kanban-run-ago {
margin-left: auto;
color: var(--color-muted-foreground);
}
.hermes-kanban-run-summary {
font-size: 0.75rem;
padding: 0.2rem 0 0;
color: var(--color-foreground);
}
.hermes-kanban-run-error {
font-size: 0.7rem;
color: var(--color-destructive, #d14a4a);
padding: 0.15rem 0 0;
font-family: var(--font-mono, ui-monospace, monospace);
}
.hermes-kanban-run-meta {
display: block;
font-size: 0.65rem;
padding: 0.15rem 0 0;
color: var(--color-muted-foreground);
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono, ui-monospace, monospace);
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "kanban",
"label": "Kanban",
"description": "Multi-agent collaboration board — drag-drop cards across columns, read comment threads, see which profile is running what",
"icon": "Package",
"version": "1.0.0",
"tab": {
"path": "/kanban",
"position": "after:skills"
},
"entry": "dist/index.js",
"css": "dist/style.css",
"api": "plugin_api.py"
}
+845
View File
@@ -0,0 +1,845 @@
"""Kanban dashboard plugin — backend API routes.
Mounted at /api/plugins/kanban/ by the dashboard plugin system.
This layer is intentionally thin: every handler is a small wrapper around
``hermes_cli.kanban_db`` or a direct SQL query. Writes use the same code
paths the CLI and gateway ``/kanban`` command use, so the three surfaces
cannot drift.
Live updates arrive via the ``/events`` WebSocket, which tails the
append-only ``task_events`` table on a short poll interval (WAL mode lets
reads run alongside the dispatcher's IMMEDIATE write transactions).
Security note
-------------
The dashboard's HTTP auth middleware (``web_server.auth_middleware``)
explicitly skips ``/api/plugins/`` plugin routes are unauthenticated by
design because the dashboard binds to localhost by default. For the
WebSocket we still require the session token as a ``?token=`` query
parameter (browsers cannot set the ``Authorization`` header on an upgrade
request), matching the established pattern used by the in-browser PTY
bridge in ``hermes_cli/web_server.py``. If you run the dashboard with
``--host 0.0.0.0``, every plugin route kanban included becomes
reachable from the network. Don't do that on a shared host.
"""
from __future__ import annotations
import asyncio
import hmac
import json
import logging
import sqlite3
import time
from dataclasses import asdict
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, status as http_status
from pydantic import BaseModel, Field
from hermes_cli import kanban_db
log = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Auth helper — WebSocket only (HTTP routes live behind the dashboard's
# existing plugin-bypass; this is documented above).
# ---------------------------------------------------------------------------
def _check_ws_token(provided: Optional[str]) -> bool:
"""Constant-time compare against the dashboard session token.
Imported lazily so the plugin still loads in test contexts where the
dashboard web_server module isn't importable (e.g. the bare-FastAPI
test harness).
"""
if not provided:
return False
try:
from hermes_cli import web_server as _ws
except Exception:
# No dashboard context (tests). Accept so the tail loop is still
# testable; in production the dashboard module always imports
# cleanly because it's the caller.
return True
expected = getattr(_ws, "_SESSION_TOKEN", None)
if not expected:
return True
return hmac.compare_digest(str(provided), str(expected))
def _conn():
"""Open a kanban_db connection, creating the schema on first use.
Every handler that mutates the DB goes through this so the plugin
self-heals on a fresh install (no user-visible "no such table"
error if somebody hits POST /tasks before GET /board).
``init_db`` is idempotent.
"""
try:
kanban_db.init_db()
except Exception as exc:
log.warning("kanban init_db failed: %s", exc)
return kanban_db.connect()
# ---------------------------------------------------------------------------
# Serialization helpers
# ---------------------------------------------------------------------------
# Columns shown by the dashboard, in left-to-right order. "archived" is
# available via a filter toggle rather than a visible column.
BOARD_COLUMNS: list[str] = [
"triage", "todo", "ready", "running", "blocked", "done",
]
def _task_dict(task: kanban_db.Task) -> dict[str, Any]:
d = asdict(task)
# Add derived age metrics so the UI can colour stale cards without
# computing deltas client-side.
d["age"] = kanban_db.task_age(task)
# Keep body short on list endpoints; full body comes from /tasks/:id.
return d
def _event_dict(event: kanban_db.Event) -> dict[str, Any]:
return {
"id": event.id,
"task_id": event.task_id,
"kind": event.kind,
"payload": event.payload,
"created_at": event.created_at,
"run_id": event.run_id,
}
def _comment_dict(c: kanban_db.Comment) -> dict[str, Any]:
return {
"id": c.id,
"task_id": c.task_id,
"author": c.author,
"body": c.body,
"created_at": c.created_at,
}
def _run_dict(r: kanban_db.Run) -> dict[str, Any]:
"""Serialise a Run for the drawer's Run history section."""
return {
"id": r.id,
"task_id": r.task_id,
"profile": r.profile,
"step_key": r.step_key,
"status": r.status,
"claim_lock": r.claim_lock,
"claim_expires": r.claim_expires,
"worker_pid": r.worker_pid,
"max_runtime_seconds": r.max_runtime_seconds,
"last_heartbeat_at": r.last_heartbeat_at,
"started_at": r.started_at,
"ended_at": r.ended_at,
"outcome": r.outcome,
"summary": r.summary,
"metadata": r.metadata,
"error": r.error,
}
def _links_for(conn: sqlite3.Connection, task_id: str) -> dict[str, list[str]]:
"""Return {'parents': [...], 'children': [...]} for a task."""
parents = [
r["parent_id"]
for r in conn.execute(
"SELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_id",
(task_id,),
)
]
children = [
r["child_id"]
for r in conn.execute(
"SELECT child_id FROM task_links WHERE parent_id = ? ORDER BY child_id",
(task_id,),
)
]
return {"parents": parents, "children": children}
# ---------------------------------------------------------------------------
# GET /board
# ---------------------------------------------------------------------------
@router.get("/board")
def get_board(
tenant: Optional[str] = Query(None, description="Filter to a single tenant"),
include_archived: bool = Query(False),
):
"""Return the full board grouped by status column.
``_conn()`` auto-initializes ``kanban.db`` on first call so a fresh
install doesn't surface a "failed to load" error on the plugin tab.
"""
conn = _conn()
try:
tasks = kanban_db.list_tasks(
conn, tenant=tenant, include_archived=include_archived
)
# Pre-fetch link counts per task (cheap: one query).
link_counts: dict[str, dict[str, int]] = {}
for row in conn.execute(
"SELECT parent_id, child_id FROM task_links"
).fetchall():
link_counts.setdefault(row["parent_id"], {"parents": 0, "children": 0})[
"children"
] += 1
link_counts.setdefault(row["child_id"], {"parents": 0, "children": 0})[
"parents"
] += 1
# Comment + event counts (both cheap aggregates).
comment_counts: dict[str, int] = {
r["task_id"]: r["n"]
for r in conn.execute(
"SELECT task_id, COUNT(*) AS n FROM task_comments GROUP BY task_id"
)
}
# Progress rollup: for each parent, how many children are done / total.
# One pass over task_links joined with child status — cheaper than
# N per-task queries and the plugin uses it to render "N/M".
progress: dict[str, dict[str, int]] = {}
for row in conn.execute(
"SELECT l.parent_id AS pid, t.status AS cstatus "
"FROM task_links l JOIN tasks t ON t.id = l.child_id"
).fetchall():
p = progress.setdefault(row["pid"], {"done": 0, "total": 0})
p["total"] += 1
if row["cstatus"] == "done":
p["done"] += 1
latest_event_id = conn.execute(
"SELECT COALESCE(MAX(id), 0) AS m FROM task_events"
).fetchone()["m"]
columns: dict[str, list[dict]] = {c: [] for c in BOARD_COLUMNS}
if include_archived:
columns["archived"] = []
for t in tasks:
d = _task_dict(t)
d["link_counts"] = link_counts.get(t.id, {"parents": 0, "children": 0})
d["comment_count"] = comment_counts.get(t.id, 0)
d["progress"] = progress.get(t.id) # None when the task has no children
col = t.status if t.status in columns else "todo"
columns[col].append(d)
# Stable per-column ordering already applied by list_tasks
# (priority DESC, created_at ASC), keep as-is.
# List of known tenants for the UI filter dropdown.
tenants = [
r["tenant"]
for r in conn.execute(
"SELECT DISTINCT tenant FROM tasks WHERE tenant IS NOT NULL ORDER BY tenant"
)
]
# List of distinct assignees for the lane-by-profile sub-grouping.
assignees = [
r["assignee"]
for r in conn.execute(
"SELECT DISTINCT assignee FROM tasks WHERE assignee IS NOT NULL "
"AND status != 'archived' ORDER BY assignee"
)
]
return {
"columns": [
{"name": name, "tasks": columns[name]} for name in columns.keys()
],
"tenants": tenants,
"assignees": assignees,
"latest_event_id": int(latest_event_id),
"now": int(time.time()),
}
finally:
conn.close()
# ---------------------------------------------------------------------------
# GET /tasks/:id
# ---------------------------------------------------------------------------
@router.get("/tasks/{task_id}")
def get_task(task_id: str):
conn = _conn()
try:
task = kanban_db.get_task(conn, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
return {
"task": _task_dict(task),
"comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)],
"events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)],
"links": _links_for(conn, task_id),
"runs": [_run_dict(r) for r in kanban_db.list_runs(conn, task_id)],
}
finally:
conn.close()
# ---------------------------------------------------------------------------
# POST /tasks
# ---------------------------------------------------------------------------
class CreateTaskBody(BaseModel):
title: str
body: Optional[str] = None
assignee: Optional[str] = None
tenant: Optional[str] = None
priority: int = 0
workspace_kind: str = "scratch"
workspace_path: Optional[str] = None
parents: list[str] = Field(default_factory=list)
triage: bool = False
idempotency_key: Optional[str] = None
max_runtime_seconds: Optional[int] = None
skills: Optional[list[str]] = None
@router.post("/tasks")
def create_task(payload: CreateTaskBody):
conn = _conn()
try:
task_id = kanban_db.create_task(
conn,
title=payload.title,
body=payload.body,
assignee=payload.assignee,
created_by="dashboard",
workspace_kind=payload.workspace_kind,
workspace_path=payload.workspace_path,
tenant=payload.tenant,
priority=payload.priority,
parents=payload.parents,
triage=payload.triage,
idempotency_key=payload.idempotency_key,
max_runtime_seconds=payload.max_runtime_seconds,
skills=payload.skills,
)
task = kanban_db.get_task(conn, task_id)
body: dict[str, Any] = {"task": _task_dict(task) if task else None}
# Surface a dispatcher-presence warning so the UI can show a
# banner when a `ready` task would otherwise sit idle because no
# gateway is running (or dispatch_in_gateway=false). Only emit
# for ready+assigned tasks; triage/todo are expected to wait,
# and unassigned tasks can't be dispatched regardless.
if task and task.status == "ready" and task.assignee:
try:
from hermes_cli.kanban import _check_dispatcher_presence
running, message = _check_dispatcher_presence()
if not running and message:
body["warning"] = message
except Exception:
# Probe failure must never block the create itself.
pass
return body
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
finally:
conn.close()
# ---------------------------------------------------------------------------
# PATCH /tasks/:id (status / assignee / priority / title / body)
# ---------------------------------------------------------------------------
class UpdateTaskBody(BaseModel):
status: Optional[str] = None
assignee: Optional[str] = None
priority: Optional[int] = None
title: Optional[str] = None
body: Optional[str] = None
result: Optional[str] = None
block_reason: Optional[str] = None
# Structured handoff fields — forwarded to complete_task when status
# transitions to 'done'. Dashboard parity with ``hermes kanban
# complete --summary ... --metadata ...``.
summary: Optional[str] = None
metadata: Optional[dict] = None
@router.patch("/tasks/{task_id}")
def update_task(task_id: str, payload: UpdateTaskBody):
conn = _conn()
try:
task = kanban_db.get_task(conn, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
# --- assignee ----------------------------------------------------
if payload.assignee is not None:
try:
ok = kanban_db.assign_task(
conn, task_id, payload.assignee or None,
)
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
if not ok:
raise HTTPException(status_code=404, detail="task not found")
# --- status -------------------------------------------------------
if payload.status is not None:
s = payload.status
ok = True
if s == "done":
ok = kanban_db.complete_task(
conn, task_id,
result=payload.result,
summary=payload.summary,
metadata=payload.metadata,
)
elif s == "blocked":
ok = kanban_db.block_task(conn, task_id, reason=payload.block_reason)
elif s == "ready":
# Re-open a blocked task, or just an explicit status set.
current = kanban_db.get_task(conn, task_id)
if current and current.status == "blocked":
ok = kanban_db.unblock_task(conn, task_id)
else:
# Direct status write for drag-drop (todo -> ready etc).
ok = _set_status_direct(conn, task_id, "ready")
elif s == "archived":
ok = kanban_db.archive_task(conn, task_id)
elif s in ("todo", "running", "triage"):
ok = _set_status_direct(conn, task_id, s)
else:
raise HTTPException(status_code=400, detail=f"unknown status: {s}")
if not ok:
raise HTTPException(
status_code=409,
detail=f"status transition to {s!r} not valid from current state",
)
# --- priority -----------------------------------------------------
if payload.priority is not None:
with kanban_db.write_txn(conn):
conn.execute(
"UPDATE tasks SET priority = ? WHERE id = ?",
(int(payload.priority), task_id),
)
conn.execute(
"INSERT INTO task_events (task_id, kind, payload, created_at) "
"VALUES (?, 'reprioritized', ?, ?)",
(task_id, json.dumps({"priority": int(payload.priority)}),
int(time.time())),
)
# --- title / body -------------------------------------------------
if payload.title is not None or payload.body is not None:
with kanban_db.write_txn(conn):
sets, vals = [], []
if payload.title is not None:
if not payload.title.strip():
raise HTTPException(status_code=400, detail="title cannot be empty")
sets.append("title = ?")
vals.append(payload.title.strip())
if payload.body is not None:
sets.append("body = ?")
vals.append(payload.body)
vals.append(task_id)
conn.execute(
f"UPDATE tasks SET {', '.join(sets)} WHERE id = ?", vals,
)
conn.execute(
"INSERT INTO task_events (task_id, kind, payload, created_at) "
"VALUES (?, 'edited', NULL, ?)",
(task_id, int(time.time())),
)
updated = kanban_db.get_task(conn, task_id)
return {"task": _task_dict(updated) if updated else None}
finally:
conn.close()
def _set_status_direct(
conn: sqlite3.Connection, task_id: str, new_status: str,
) -> bool:
"""Direct status write for drag-drop moves that aren't covered by the
structured complete/block/unblock/archive verbs (e.g. todo<->ready,
running<->ready). Appends a ``status`` event row for the live feed.
When this transitions OFF ``running`` to anything other than the
terminal verbs above (which own their own run closing), we close the
active run with outcome='reclaimed' so attempt history isn't
orphaned. ``running -> ready`` via drag-drop is the common case
(user yanking a stuck worker back to the queue).
"""
with kanban_db.write_txn(conn):
# Snapshot current state so we know whether to close a run.
prev = conn.execute(
"SELECT status, current_run_id FROM tasks WHERE id = ?",
(task_id,),
).fetchone()
if prev is None:
return False
was_running = prev["status"] == "running"
cur = conn.execute(
"UPDATE tasks SET status = ?, "
" claim_lock = CASE WHEN ? = 'running' THEN claim_lock ELSE NULL END, "
" claim_expires = CASE WHEN ? = 'running' THEN claim_expires ELSE NULL END, "
" worker_pid = CASE WHEN ? = 'running' THEN worker_pid ELSE NULL END "
"WHERE id = ?",
(new_status, new_status, new_status, new_status, task_id),
)
if cur.rowcount != 1:
return False
run_id = None
if was_running and new_status != "running" and prev["current_run_id"]:
run_id = kanban_db._end_run(
conn, task_id,
outcome="reclaimed", status="reclaimed",
summary=f"status changed to {new_status} (dashboard/direct)",
)
conn.execute(
"INSERT INTO task_events (task_id, run_id, kind, payload, created_at) "
"VALUES (?, ?, 'status', ?, ?)",
(task_id, run_id, json.dumps({"status": new_status}), int(time.time())),
)
# If we re-opened something, children may have gone stale.
if new_status in ("done", "ready"):
kanban_db.recompute_ready(conn)
return True
# ---------------------------------------------------------------------------
# Comments
# ---------------------------------------------------------------------------
class CommentBody(BaseModel):
body: str
author: Optional[str] = "dashboard"
@router.post("/tasks/{task_id}/comments")
def add_comment(task_id: str, payload: CommentBody):
if not payload.body.strip():
raise HTTPException(status_code=400, detail="body is required")
conn = _conn()
try:
if kanban_db.get_task(conn, task_id) is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
kanban_db.add_comment(
conn, task_id, author=payload.author or "dashboard", body=payload.body,
)
return {"ok": True}
finally:
conn.close()
# ---------------------------------------------------------------------------
# Links
# ---------------------------------------------------------------------------
class LinkBody(BaseModel):
parent_id: str
child_id: str
@router.post("/links")
def add_link(payload: LinkBody):
conn = _conn()
try:
kanban_db.link_tasks(conn, payload.parent_id, payload.child_id)
return {"ok": True}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
finally:
conn.close()
@router.delete("/links")
def delete_link(parent_id: str = Query(...), child_id: str = Query(...)):
conn = _conn()
try:
ok = kanban_db.unlink_tasks(conn, parent_id, child_id)
return {"ok": bool(ok)}
finally:
conn.close()
# ---------------------------------------------------------------------------
# Bulk actions (multi-select on the board)
# ---------------------------------------------------------------------------
class BulkTaskBody(BaseModel):
ids: list[str]
status: Optional[str] = None
assignee: Optional[str] = None # "" or None = unassign
priority: Optional[int] = None
archive: bool = False
@router.post("/tasks/bulk")
def bulk_update(payload: BulkTaskBody):
"""Apply the same patch to every id in ``payload.ids``.
This is an *independent* iteration per-task failures don't abort
siblings. Returns per-id outcome so the UI can surface partials.
"""
ids = [i for i in (payload.ids or []) if i]
if not ids:
raise HTTPException(status_code=400, detail="ids is required")
results: list[dict] = []
conn = _conn()
try:
for tid in ids:
entry: dict[str, Any] = {"id": tid, "ok": True}
try:
task = kanban_db.get_task(conn, tid)
if task is None:
entry.update(ok=False, error="not found")
results.append(entry)
continue
if payload.archive:
if not kanban_db.archive_task(conn, tid):
entry.update(ok=False, error="archive refused")
if payload.status is not None and not payload.archive:
s = payload.status
if s == "done":
ok = kanban_db.complete_task(conn, tid)
elif s == "blocked":
ok = kanban_db.block_task(conn, tid)
elif s == "ready":
cur = kanban_db.get_task(conn, tid)
if cur and cur.status == "blocked":
ok = kanban_db.unblock_task(conn, tid)
else:
ok = _set_status_direct(conn, tid, "ready")
elif s in ("todo", "running", "triage"):
ok = _set_status_direct(conn, tid, s)
else:
entry.update(ok=False, error=f"unknown status {s!r}")
results.append(entry)
continue
if not ok:
entry.update(ok=False, error=f"transition to {s!r} refused")
if payload.assignee is not None:
try:
if not kanban_db.assign_task(
conn, tid, payload.assignee or None,
):
entry.update(ok=False, error="assign refused")
except RuntimeError as e:
entry.update(ok=False, error=str(e))
if payload.priority is not None:
with kanban_db.write_txn(conn):
conn.execute(
"UPDATE tasks SET priority = ? WHERE id = ?",
(int(payload.priority), tid),
)
conn.execute(
"INSERT INTO task_events (task_id, kind, payload, created_at) "
"VALUES (?, 'reprioritized', ?, ?)",
(tid, json.dumps({"priority": int(payload.priority)}),
int(time.time())),
)
except Exception as e: # defensive — one bad id shouldn't kill the batch
entry.update(ok=False, error=str(e))
results.append(entry)
return {"results": results}
finally:
conn.close()
# ---------------------------------------------------------------------------
# Plugin config (read dashboard.kanban.* defaults from config.yaml)
# ---------------------------------------------------------------------------
@router.get("/config")
def get_config():
"""Return kanban dashboard preferences from ~/.hermes/config.yaml.
Reads the ``dashboard.kanban`` section if present; defaults otherwise.
Used by the UI to pre-select tenant filters, toggle markdown rendering,
or set column-width preferences without a round-trip per page load.
"""
try:
from hermes_cli.config import load_config
cfg = load_config() or {}
except Exception:
cfg = {}
dash_cfg = (cfg.get("dashboard") or {})
# dashboard.kanban may itself be a dict; fall back to {}.
k_cfg = dash_cfg.get("kanban") or {}
return {
"default_tenant": k_cfg.get("default_tenant") or "",
"lane_by_profile": bool(k_cfg.get("lane_by_profile", True)),
"include_archived_by_default": bool(k_cfg.get("include_archived_by_default", False)),
"render_markdown": bool(k_cfg.get("render_markdown", True)),
}
# ---------------------------------------------------------------------------
# Stats (per-profile / per-status counts + oldest-ready age)
# ---------------------------------------------------------------------------
@router.get("/stats")
def get_stats():
"""Per-status + per-assignee counts + oldest-ready age.
Designed for the dashboard HUD and for router profiles that need to
answer "is this specialist overloaded?" without scanning the whole
board themselves.
"""
conn = _conn()
try:
return kanban_db.board_stats(conn)
finally:
conn.close()
@router.get("/assignees")
def get_assignees():
"""Known profiles + per-profile task counts.
Returns the union of ``~/.hermes/profiles/*`` on disk and every
distinct assignee currently used on the board. The dashboard uses
this to populate its assignee dropdown so a freshly-created profile
appears in the picker before it's been given any task.
"""
conn = _conn()
try:
return {"assignees": kanban_db.known_assignees(conn)}
finally:
conn.close()
# ---------------------------------------------------------------------------
# Worker log (read-only; file written by _default_spawn)
# ---------------------------------------------------------------------------
@router.get("/tasks/{task_id}/log")
def get_task_log(task_id: str, tail: Optional[int] = Query(None, ge=1, le=2_000_000)):
"""Return the worker's stdout/stderr log.
``tail`` caps the response size (bytes) so the dashboard drawer
doesn't paginate megabytes into the browser. Returns 404 if the task
has never spawned. The on-disk log is rotated at 2 MiB per
``_rotate_worker_log`` a single ``.log.1`` is kept, no further
generations, so disk usage per task is bounded at ~4 MiB.
"""
conn = _conn()
try:
task = kanban_db.get_task(conn, task_id)
finally:
conn.close()
if task is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
content = kanban_db.read_worker_log(task_id, tail_bytes=tail)
log_path = kanban_db.worker_log_path(task_id)
size = log_path.stat().st_size if log_path.exists() else 0
return {
"task_id": task_id,
"path": str(log_path),
"exists": content is not None,
"size_bytes": size,
"content": content or "",
# Truncated when the on-disk file was larger than the tail cap.
"truncated": bool(tail and size > tail),
}
# ---------------------------------------------------------------------------
# Dispatch nudge (optional quick-path so the UI doesn't wait 60 s)
# ---------------------------------------------------------------------------
@router.post("/dispatch")
def dispatch(dry_run: bool = Query(False), max_n: int = Query(8, alias="max")):
conn = _conn()
try:
result = kanban_db.dispatch_once(
conn, dry_run=dry_run, max_spawn=max_n,
)
# DispatchResult is a dataclass.
try:
return asdict(result)
except TypeError:
return {"result": str(result)}
finally:
conn.close()
# ---------------------------------------------------------------------------
# WebSocket: /events?since=<event_id>
# ---------------------------------------------------------------------------
# Poll interval for the event tail loop. SQLite WAL + 300 ms polling is
# the simplest and most robust approach; it adds a fraction of a percent
# of CPU and has no shared state to synchronize across workers.
_EVENT_POLL_SECONDS = 0.3
@router.websocket("/events")
async def stream_events(ws: WebSocket):
# Enforce the dashboard session token as a query param — browsers can't
# set Authorization on a WS upgrade. This matches how the PTY bridge
# authenticates in hermes_cli/web_server.py.
token = ws.query_params.get("token")
if not _check_ws_token(token):
await ws.close(code=http_status.WS_1008_POLICY_VIOLATION)
return
await ws.accept()
try:
since_raw = ws.query_params.get("since", "0")
try:
cursor = int(since_raw)
except ValueError:
cursor = 0
def _fetch_new(cursor_val: int) -> tuple[int, list[dict]]:
conn = kanban_db.connect()
try:
rows = conn.execute(
"SELECT id, task_id, run_id, kind, payload, created_at "
"FROM task_events WHERE id > ? ORDER BY id ASC LIMIT 200",
(cursor_val,),
).fetchall()
out: list[dict] = []
new_cursor = cursor_val
for r in rows:
try:
payload = json.loads(r["payload"]) if r["payload"] else None
except Exception:
payload = None
out.append({
"id": r["id"],
"task_id": r["task_id"],
"run_id": r["run_id"],
"kind": r["kind"],
"payload": payload,
"created_at": r["created_at"],
})
new_cursor = r["id"]
return new_cursor, out
finally:
conn.close()
while True:
cursor, events = await asyncio.to_thread(_fetch_new, cursor)
if events:
await ws.send_json({"events": events, "cursor": cursor})
await asyncio.sleep(_EVENT_POLL_SECONDS)
except WebSocketDisconnect:
return
except Exception as exc: # defensive: never crash the dashboard worker
log.warning("Kanban event stream error: %s", exc)
try:
await ws.close()
except Exception:
pass
@@ -0,0 +1,32 @@
# DEPRECATED — the kanban dispatcher now runs inside the gateway by
# default (config key: kanban.dispatch_in_gateway, default true). To
# migrate:
#
# systemctl --user disable --now hermes-kanban-dispatcher.service
# # then make sure a gateway is running; e.g. a systemd user unit
# # for `hermes gateway start`. The gateway hosts the dispatcher.
#
# This unit is kept for users who truly cannot run the gateway (host
# policy forbids long-lived services, etc.). It now invokes the
# standalone dispatcher via the explicit --force flag, so nobody
# accidentally keeps two dispatchers racing against the same
# kanban.db. Running this unit AND a gateway with
# dispatch_in_gateway=true is NOT supported.
[Unit]
Description=Hermes Kanban dispatcher (DEPRECATED standalone daemon — prefer gateway-embedded dispatch)
Documentation=https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/env hermes kanban daemon --force --interval 60 --pidfile %t/hermes-kanban-dispatcher.pid
Restart=on-failure
RestartSec=5
# Log to the journal via stdout/stderr; the dispatcher also writes per-task
# worker output to $HERMES_HOME/kanban/logs/<task>.log.
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
+118 -25
View File
@@ -528,6 +528,46 @@ class OpenVikingMemoryProvider(MemoryProvider):
# -- Tool implementations ------------------------------------------------
@staticmethod
def _unwrap_result(resp: Any) -> Any:
"""Return OpenViking payload body regardless of wrapped/unwrapped shape."""
if isinstance(resp, dict) and "result" in resp:
return resp.get("result")
return resp
@staticmethod
def _normalize_summary_uri(uri: str) -> str:
"""Map pseudo summary files to their parent directory URI for L0/L1 reads."""
if not uri:
return uri
for suffix in ("/.abstract.md", "/.overview.md", "/.read.md", "/.full.md"):
if uri.endswith(suffix):
return uri[: -len(suffix)] or "viking://"
return uri
def _is_directory_uri(self, uri: str) -> bool | None:
"""Probe fs/stat to decide if a URI is a directory.
Returns True/False when the server answers cleanly, and None when the
probe itself fails (network error, unexpected shape). Callers should
treat None as "unknown" and fall back to the exception-based path.
"""
try:
resp = self._client.get("/api/v1/fs/stat", params={"uri": uri})
except Exception:
return None
result = self._unwrap_result(resp)
if isinstance(result, dict):
if "isDir" in result:
return bool(result.get("isDir"))
if "is_dir" in result:
return bool(result.get("is_dir"))
if result.get("type") == "dir":
return True
if result.get("type") == "file":
return False
return None
def _tool_search(self, args: dict) -> str:
query = args.get("query", "")
if not query:
@@ -576,27 +616,72 @@ class OpenVikingMemoryProvider(MemoryProvider):
return tool_error("uri is required")
level = args.get("level", "overview")
# Map our level names to OpenViking GET endpoints
if level == "abstract":
resp = self._client.get("/api/v1/content/abstract", params={"uri": uri})
elif level == "full":
summary_level = level in ("abstract", "overview")
# OpenViking expects directory URIs for pseudo summary files
# (e.g. viking://user/hermes/.overview.md).
resolved_uri = self._normalize_summary_uri(uri) if summary_level else uri
used_fallback = False
# abstract/overview endpoints are directory-only on OpenViking
# (v0.3.x returns 500/412 for file URIs). When the caller asks for a
# summary level on a non-pseudo URI, probe fs/stat first and route
# file URIs straight to /content/read instead of eating a failing
# round-trip. The pseudo-URI path already points at a directory, so
# skip the probe there.
if summary_level and resolved_uri == uri:
is_dir = self._is_directory_uri(uri)
if is_dir is False:
resolved_uri = uri
used_fallback = True
# Map our level names to OpenViking GET endpoints.
endpoint = "/api/v1/content/read"
if not used_fallback:
if level == "abstract":
endpoint = "/api/v1/content/abstract"
elif level == "overview":
endpoint = "/api/v1/content/overview"
try:
resp = self._client.get(endpoint, params={"uri": resolved_uri})
except Exception:
# OpenViking may return HTTP 500 for abstract/overview reads on normal
# file URIs (mem_*.md). For those, gracefully fallback to full read.
if not summary_level or resolved_uri != uri or used_fallback:
raise
resp = self._client.get("/api/v1/content/read", params={"uri": uri})
else: # overview
resp = self._client.get("/api/v1/content/overview", params={"uri": uri})
used_fallback = True
result = resp.get("result", "")
# result is a plain string from the content endpoints
content = result if isinstance(result, str) else result.get("content", "")
result = self._unwrap_result(resp)
# Content endpoints may return either plain strings or objects.
if isinstance(result, str):
content = result
elif isinstance(result, dict):
content = result.get("content", "") or result.get("text", "")
else:
content = ""
# Truncate very long content to avoid flooding the context
if len(content) > 8000:
content = content[:8000] + "\n\n[... truncated, use a more specific URI or abstract level]"
# Truncate long content to avoid flooding context.
max_len = 8000
if level == "overview":
max_len = 4000
elif level == "abstract":
max_len = 1200
return json.dumps({
if len(content) > max_len:
content = content[:max_len] + "\n\n[... truncated, use a more specific URI or full level]"
payload = {
"uri": uri,
"resolved_uri": resolved_uri,
"level": level,
"content": content,
}, ensure_ascii=False)
}
if used_fallback:
payload["fallback"] = "content/read"
return json.dumps(payload, ensure_ascii=False)
def _tool_browse(self, args: dict) -> str:
action = args.get("action", "list")
@@ -606,19 +691,27 @@ class OpenVikingMemoryProvider(MemoryProvider):
endpoint_map = {"tree": "/api/v1/fs/tree", "list": "/api/v1/fs/ls", "stat": "/api/v1/fs/stat"}
endpoint = endpoint_map.get(action, "/api/v1/fs/ls")
resp = self._client.get(endpoint, params={"uri": path})
result = resp.get("result", {})
result = self._unwrap_result(resp)
# Format list/tree results for readability
if action in ("list", "tree") and isinstance(result, list):
entries = []
for e in result[:50]: # cap at 50 entries
entries.append({
"name": e.get("rel_path", e.get("name", "")),
"uri": e.get("uri", ""),
"type": "dir" if e.get("isDir") else "file",
"abstract": e.get("abstract", ""),
})
return json.dumps({"path": path, "entries": entries}, ensure_ascii=False)
if action in ("list", "tree"):
raw_entries = result
if isinstance(result, dict):
raw_entries = result.get("entries") or result.get("items") or result.get("children") or []
if isinstance(raw_entries, list):
entries = []
for e in raw_entries[:50]: # cap at 50 entries
uri = e.get("uri", "")
name = e.get("rel_path") or e.get("name") or (uri.rsplit("/", 1)[-1] if uri else "")
is_dir = bool(e.get("isDir") or e.get("is_dir") or e.get("type") == "dir")
entries.append({
"name": name,
"uri": uri,
"type": "dir" if is_dir else "file",
"abstract": e.get("abstract", ""),
})
return json.dumps({"path": path, "entries": entries}, ensure_ascii=False)
return json.dumps(result, ensure_ascii=False)
+3
View File
@@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]
+686
View File
@@ -0,0 +1,686 @@
"""
IRC Platform Adapter for Hermes Agent.
A plugin-based gateway adapter that connects to an IRC server and relays
messages to/from the Hermes agent. Zero external dependencies uses
Python's stdlib asyncio for the IRC protocol.
Configuration in config.yaml::
gateway:
platforms:
irc:
enabled: true
extra:
server: irc.libera.chat
port: 6697
nickname: hermes-bot
channel: "#hermes"
use_tls: true
server_password: "" # optional server password
nickserv_password: "" # optional NickServ identification
allowed_users: [] # empty = allow all, or list of nicks
max_message_length: 450 # IRC line limit (safe default)
Or via environment variables (overrides config.yaml):
IRC_SERVER, IRC_PORT, IRC_NICKNAME, IRC_CHANNEL, IRC_USE_TLS,
IRC_SERVER_PASSWORD, IRC_NICKSERV_PASSWORD
"""
import asyncio
import logging
import os
import re
import ssl
import time
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Lazy import: BasePlatformAdapter and friends live in the main repo.
# We import at function/class level to avoid import errors when the plugin
# is discovered but the gateway hasn't been fully initialised yet.
# ---------------------------------------------------------------------------
from gateway.platforms.base import (
BasePlatformAdapter,
SendResult,
MessageEvent,
MessageType,
)
from gateway.session import SessionSource
from gateway.config import PlatformConfig, Platform
def _ensure_imports():
"""No-op — kept for backward compatibility with any call sites."""
pass
# ---------------------------------------------------------------------------
# IRC protocol helpers
# ---------------------------------------------------------------------------
def _parse_irc_message(raw: str) -> dict:
"""Parse a raw IRC protocol line into components.
Returns dict with keys: prefix, command, params.
"""
prefix = ""
trailing = ""
if raw.startswith(":"):
try:
prefix, raw = raw[1:].split(" ", 1)
except ValueError:
prefix = raw[1:]
raw = ""
if " :" in raw:
raw, trailing = raw.split(" :", 1)
parts = raw.split()
command = parts[0] if parts else ""
params = parts[1:] if len(parts) > 1 else []
if trailing:
params.append(trailing)
return {"prefix": prefix, "command": command, "params": params}
def _extract_nick(prefix: str) -> str:
"""Extract nickname from IRC prefix (nick!user@host)."""
return prefix.split("!")[0] if "!" in prefix else prefix
# ---------------------------------------------------------------------------
# IRC Adapter
# ---------------------------------------------------------------------------
class IRCAdapter(BasePlatformAdapter):
"""Async IRC adapter implementing the BasePlatformAdapter interface.
This class is instantiated by the adapter_factory passed to
register_platform().
"""
def __init__(self, config, **kwargs):
platform = Platform("irc")
super().__init__(config=config, platform=platform)
extra = getattr(config, "extra", {}) or {}
# Connection settings (env vars override config.yaml)
self.server = os.getenv("IRC_SERVER") or extra.get("server", "")
self.port = int(os.getenv("IRC_PORT") or extra.get("port", 6697))
self.nickname = os.getenv("IRC_NICKNAME") or extra.get("nickname", "hermes-bot")
self.channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
self.use_tls = (
os.getenv("IRC_USE_TLS", "").lower() in ("1", "true", "yes")
if os.getenv("IRC_USE_TLS")
else extra.get("use_tls", True)
)
self.server_password = os.getenv("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
self.nickserv_password = os.getenv("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "")
# Auth
self.allowed_users: list = extra.get("allowed_users", [])
# IRC nicks are case-insensitive — normalise for lookups
self._allowed_users_lower: set = {u.lower() for u in self.allowed_users if isinstance(u, str)}
# IRC limits
max_msg = extra.get("max_message_length")
if max_msg is None:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get("irc")
if entry and entry.max_message_length:
max_msg = entry.max_message_length
except Exception:
pass
self.max_message_length = int(max_msg or 450)
# Runtime state
self._reader: Optional[asyncio.StreamReader] = None
self._writer: Optional[asyncio.StreamWriter] = None
self._recv_task: Optional[asyncio.Task] = None
self._current_nick = self.nickname
self._registered = False # IRC registration complete
self._registration_event = asyncio.Event()
@property
def name(self) -> str:
return "IRC"
# ── Connection lifecycle ──────────────────────────────────────────────
async def connect(self) -> bool:
"""Connect to the IRC server, register, and join the channel."""
if not self.server or not self.channel:
logger.error("IRC: server and channel must be configured")
self._set_fatal_error(
"config_missing",
"IRC_SERVER and IRC_CHANNEL must be set",
retryable=False,
)
return False
# Prevent two profiles from using the same IRC identity
try:
from gateway.status import acquire_scoped_lock, release_scoped_lock
lock_key = f"{self.server}:{self.nickname}"
if not acquire_scoped_lock("irc", lock_key):
logger.error("IRC: %s@%s already in use by another profile", self.nickname, self.server)
self._set_fatal_error("lock_conflict", "IRC identity in use by another profile", retryable=False)
return False
self._lock_key = lock_key
except ImportError:
self._lock_key = None # status module not available (e.g. tests)
try:
ssl_ctx = None
if self.use_tls:
ssl_ctx = ssl.create_default_context()
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(self.server, self.port, ssl=ssl_ctx),
timeout=30.0,
)
except Exception as e:
logger.error("IRC: failed to connect to %s:%s%s", self.server, self.port, e)
self._set_fatal_error("connect_failed", str(e), retryable=True)
return False
# IRC registration sequence
if self.server_password:
await self._send_raw(f"PASS {self.server_password}")
await self._send_raw(f"NICK {self.nickname}")
await self._send_raw(f"USER {self.nickname} 0 * :Hermes Agent")
# Start receive loop
self._recv_task = asyncio.create_task(self._receive_loop())
# Wait for registration (001 RPL_WELCOME) with timeout
try:
await asyncio.wait_for(self._registration_event.wait(), timeout=30.0)
except asyncio.TimeoutError:
logger.error("IRC: registration timed out")
await self.disconnect()
self._set_fatal_error("registration_timeout", "IRC server did not send RPL_WELCOME", retryable=True)
return False
# NickServ identification
if self.nickserv_password:
await self._send_raw(f"PRIVMSG NickServ :IDENTIFY {self.nickserv_password}")
await asyncio.sleep(2) # Give NickServ time to process
# Join channel
await self._send_raw(f"JOIN {self.channel}")
self._mark_connected()
logger.info("IRC: connected to %s:%s as %s, joined %s", self.server, self.port, self._current_nick, self.channel)
return True
async def disconnect(self) -> None:
"""Quit and close the connection."""
# Release the scoped lock so another profile can use this identity
if getattr(self, "_lock_key", None):
try:
from gateway.status import release_scoped_lock
release_scoped_lock("irc", self._lock_key)
except Exception:
pass
self._mark_disconnected()
if self._writer and not self._writer.is_closing():
try:
await self._send_raw("QUIT :Hermes Agent shutting down")
await asyncio.sleep(0.5)
except Exception:
pass
try:
self._writer.close()
await self._writer.wait_closed()
except Exception:
pass
if self._recv_task and not self._recv_task.done():
self._recv_task.cancel()
try:
await self._recv_task
except asyncio.CancelledError:
pass
self._reader = None
self._writer = None
self._registered = False
self._registration_event.clear()
# ── Sending ───────────────────────────────────────────────────────────
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
):
if not self._writer or self._writer.is_closing():
return SendResult(success=False, error="Not connected")
target = chat_id # channel name or nick for DMs
lines = self._split_message(content, target)
for line in lines:
try:
await self._send_raw(f"PRIVMSG {target} :{line}")
# Basic rate limiting to avoid excess flood
await asyncio.sleep(0.3)
except Exception as e:
return SendResult(success=False, error=str(e))
return SendResult(success=True, message_id=str(int(time.time() * 1000)))
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""IRC has no typing indicator — no-op."""
pass
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
is_channel = chat_id.startswith("#") or chat_id.startswith("&")
return {
"name": chat_id,
"type": "group" if is_channel else "dm",
}
# ── Message splitting ─────────────────────────────────────────────────
def _split_message(self, content: str, target: str) -> List[str]:
"""Split a long message into IRC-safe chunks.
IRC has a ~512 byte line limit. After accounting for protocol
overhead (``PRIVMSG <target> :``), we split content into chunks.
"""
# Strip markdown formatting that doesn't render in IRC
content = self._strip_markdown(content)
overhead = len(f"PRIVMSG {target} :".encode("utf-8")) + 2 # +2 for \r\n
max_bytes = 510 - overhead
user_limit = self.max_message_length
lines: List[str] = []
for paragraph in content.split("\n"):
if not paragraph.strip():
continue
while True:
para_bytes = paragraph.encode("utf-8")
limit = min(user_limit, max_bytes)
if len(para_bytes) <= limit:
if paragraph.strip():
lines.append(paragraph)
break
# Binary search for a safe character boundary <= limit
low, high = 1, len(paragraph)
best = 0
while low <= high:
mid = (low + high) // 2
if len(paragraph[:mid].encode("utf-8")) <= limit:
best = mid
low = mid + 1
else:
high = mid - 1
split_at = best
# Prefer a space boundary
space = paragraph.rfind(" ", 0, split_at)
if space > split_at // 3:
split_at = space
lines.append(paragraph[:split_at].rstrip())
paragraph = paragraph[split_at:].lstrip()
return lines if lines else [""]
@staticmethod
def _strip_markdown(text: str) -> str:
"""Convert basic markdown to plain text for IRC."""
# Bold: **text** or __text__ → text
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
text = re.sub(r"__(.+?)__", r"\1", text)
# Italic: *text* or _text_ → text
text = re.sub(r"\*(.+?)\*", r"\1", text)
text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text)
# Inline code: `text` → text
text = re.sub(r"`(.+?)`", r"\1", text)
# Code blocks: ```...``` → content
text = re.sub(r"```\w*\n?", "", text)
# Images: ![alt](url) → url (must come BEFORE links)
text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\2", text)
# Links: [text](url) → text (url)
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", text)
return text
# ── Raw IRC I/O ──────────────────────────────────────────────────────
async def _send_raw(self, line: str) -> None:
"""Send a raw IRC protocol line."""
if not self._writer or self._writer.is_closing():
return
encoded = (line + "\r\n").encode("utf-8")
self._writer.write(encoded)
await self._writer.drain()
async def _receive_loop(self) -> None:
"""Main receive loop — reads lines and dispatches them."""
buffer = b""
try:
while self._reader and not self._reader.at_eof():
data = await self._reader.read(4096)
if not data:
break
buffer += data
while b"\r\n" in buffer:
line, buffer = buffer.split(b"\r\n", 1)
try:
decoded = line.decode("utf-8", errors="replace")
await self._handle_line(decoded)
except Exception as e:
logger.warning("IRC: error handling line: %s", e)
except asyncio.CancelledError:
raise
except Exception as e:
logger.error("IRC: receive loop error: %s", e)
finally:
if self.is_connected:
logger.warning("IRC: connection lost, marking disconnected")
self._set_fatal_error("connection_lost", "IRC connection closed unexpectedly", retryable=True)
await self._notify_fatal_error()
async def _handle_line(self, raw: str) -> None:
"""Dispatch a single IRC protocol line."""
msg = _parse_irc_message(raw)
command = msg["command"]
params = msg["params"]
# PING/PONG keepalive
if command == "PING":
payload = params[0] if params else ""
await self._send_raw(f"PONG :{payload}")
return
# RPL_WELCOME (001) — registration complete
if command == "001":
self._registered = True
self._registration_event.set()
if params:
# Server may confirm our nick in the first param
self._current_nick = params[0]
return
# ERR_NICKNAMEINUSE (433) — nick collision during registration
if command == "433":
# Retry with incrementing suffix: hermes_, hermes_1, hermes_2...
base = self.nickname.rstrip("_0123456789")
suffix_match = re.search(r"_(\d+)$", self._current_nick)
if suffix_match:
next_num = int(suffix_match.group(1)) + 1
self._current_nick = f"{base}_{next_num}"
elif self._current_nick == self.nickname:
self._current_nick = self.nickname + "_"
else:
self._current_nick = self.nickname + "_1"
await self._send_raw(f"NICK {self._current_nick}")
return
# PRIVMSG — incoming message (channel or DM)
if command == "PRIVMSG" and len(params) >= 2:
sender_nick = _extract_nick(msg["prefix"])
target = params[0]
text = params[1]
# Ignore our own messages
if sender_nick.lower() == self._current_nick.lower():
return
# CTCP ACTION (/me) — convert to text
if text.startswith("\x01ACTION ") and text.endswith("\x01"):
text = f"* {sender_nick} {text[8:-1]}"
# Ignore other CTCP
if text.startswith("\x01"):
return
# Determine if this is a channel message or DM
is_channel = target.startswith("#") or target.startswith("&")
chat_id = target if is_channel else sender_nick
chat_type = "group" if is_channel else "dm"
# In channels, only respond if addressed (nick: or nick,)
if is_channel:
addressed = False
for prefix in (f"{self._current_nick}:", f"{self._current_nick},",
f"{self._current_nick} "):
if text.lower().startswith(prefix.lower()):
text = text[len(prefix):].strip()
addressed = True
break
if not addressed:
return # Ignore unaddressed channel messages
# Auth check (case-insensitive)
if self._allowed_users_lower and sender_nick.lower() not in self._allowed_users_lower:
logger.debug("IRC: ignoring message from unauthorized user %s", sender_nick)
return
await self._dispatch_message(
text=text,
chat_id=chat_id,
chat_type=chat_type,
user_id=sender_nick,
user_name=sender_nick,
)
# NICK — track our own nick changes
if command == "NICK" and _extract_nick(msg["prefix"]).lower() == self._current_nick.lower():
if params:
self._current_nick = params[0]
async def _dispatch_message(
self,
text: str,
chat_id: str,
chat_type: str,
user_id: str,
user_name: str,
) -> None:
"""Build a MessageEvent and hand it to the base class handler."""
if not self._message_handler:
return
source = self.build_source(
chat_id=chat_id,
chat_name=chat_id,
chat_type=chat_type,
user_id=user_id,
user_name=user_name,
)
event = MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
message_id=str(int(time.time() * 1000)),
timestamp=__import__("datetime").datetime.now(),
)
await self.handle_message(event)
# ---------------------------------------------------------------------------
# Plugin registration
# ---------------------------------------------------------------------------
def check_requirements() -> bool:
"""Check if IRC is configured.
Only requires the server and channel no external pip packages needed.
"""
server = os.getenv("IRC_SERVER", "")
channel = os.getenv("IRC_CHANNEL", "")
# Also accept config.yaml-only configuration (no env vars).
# The gateway passes PlatformConfig; we just check env for the
# hermes setup / requirements check path.
return bool(server and channel)
def validate_config(config) -> bool:
"""Validate that the platform config has enough info to connect."""
extra = getattr(config, "extra", {}) or {}
server = os.getenv("IRC_SERVER") or extra.get("server", "")
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
return bool(server and channel)
def interactive_setup() -> None:
"""Interactive `hermes gateway setup` flow for the IRC platform.
Lazy-imports ``hermes_cli.setup`` helpers so the plugin stays importable
in non-CLI contexts (gateway runtime, tests).
"""
from hermes_cli.setup import (
prompt,
prompt_yes_no,
save_env_value,
get_env_value,
print_header,
print_info,
print_warning,
print_success,
)
print_header("IRC")
existing_server = get_env_value("IRC_SERVER")
if existing_server:
print_info(f"IRC: already configured (server: {existing_server})")
if not prompt_yes_no("Reconfigure IRC?", False):
return
print_info("Connect Hermes to an IRC network. Uses Python stdlib — no extra packages needed.")
print_info(" Works with Libera.Chat, OFTC, your own ZNC/InspIRCd, etc.")
print()
server = prompt("IRC server hostname (e.g. irc.libera.chat)", default=existing_server or "")
if not server:
print_warning("Server is required — skipping IRC setup")
return
save_env_value("IRC_SERVER", server.strip())
use_tls = prompt_yes_no("Use TLS (recommended)?", True)
save_env_value("IRC_USE_TLS", "true" if use_tls else "false")
default_port = "6697" if use_tls else "6667"
port = prompt(f"Port (default {default_port})", default=get_env_value("IRC_PORT") or "")
if port:
try:
save_env_value("IRC_PORT", str(int(port)))
except ValueError:
print_warning(f"Invalid port — using default {default_port}")
elif get_env_value("IRC_PORT"):
# User cleared the prompt; drop the override so the default applies.
save_env_value("IRC_PORT", "")
nickname = prompt(
"Bot nickname (e.g. hermes-bot)",
default=get_env_value("IRC_NICKNAME") or "",
)
if not nickname:
print_warning("Nickname is required — skipping IRC setup")
return
save_env_value("IRC_NICKNAME", nickname.strip())
channel = prompt(
"Channel to join (e.g. #hermes — comma-separate for multiple)",
default=get_env_value("IRC_CHANNEL") or "",
)
if not channel:
print_warning("Channel is required — skipping IRC setup")
return
save_env_value("IRC_CHANNEL", channel.strip())
print()
print_info("🔑 Optional authentication")
print_info(" Leave blank to skip.")
if prompt_yes_no("Configure a server password (PASS command)?", False):
server_password = prompt("Server password", password=True)
if server_password:
save_env_value("IRC_SERVER_PASSWORD", server_password)
if prompt_yes_no("Identify with NickServ on connect?", False):
nickserv = prompt("NickServ password", password=True)
if nickserv:
save_env_value("IRC_NICKSERV_PASSWORD", nickserv)
print()
print_info("🔒 Access control: restrict who can message the bot")
print_info(" IRC nicks are not authenticated — anyone can claim any nick.")
print_info(" For public channels, pair with NickServ-only mode on your network")
print_info(" if you want stronger identity guarantees.")
allow_all = prompt_yes_no("Allow all users in the channel to talk to the bot?", False)
if allow_all:
save_env_value("IRC_ALLOW_ALL_USERS", "true")
save_env_value("IRC_ALLOWED_USERS", "")
print_warning("⚠️ Open access — any nick in the channel can command the bot.")
else:
save_env_value("IRC_ALLOW_ALL_USERS", "false")
allowed = prompt(
"Allowed nicks (comma-separated, leave empty to deny everyone)",
default=get_env_value("IRC_ALLOWED_USERS") or "",
)
if allowed:
save_env_value("IRC_ALLOWED_USERS", allowed.replace(" ", ""))
print_success("Allowlist configured")
else:
save_env_value("IRC_ALLOWED_USERS", "")
print_info("No nicks allowed — the bot will ignore all messages until you add nicks.")
print()
print_success("IRC configuration saved to ~/.hermes/.env")
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
def is_connected(config) -> bool:
"""Check whether IRC is configured (env or config.yaml)."""
extra = getattr(config, "extra", {}) or {}
server = os.getenv("IRC_SERVER") or extra.get("server", "")
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
return bool(server and channel)
def register(ctx):
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="irc",
label="IRC",
adapter_factory=lambda cfg: IRCAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
required_env=["IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"],
install_hint="No extra packages needed (stdlib only)",
setup_fn=interactive_setup,
# Auth env vars for _is_user_authorized() integration
allowed_users_env="IRC_ALLOWED_USERS",
allow_all_env="IRC_ALLOW_ALL_USERS",
# IRC line limit after protocol overhead
max_message_length=450,
# Display
emoji="💬",
# IRC doesn't have phone numbers to redact
pii_safe=False,
allow_update_command=True,
# LLM guidance
platform_hint=(
"You are chatting via IRC. IRC does not support markdown formatting "
"— use plain text only. Messages are limited to ~450 characters per "
"line (long messages are automatically split). In channels, users "
"address you by prefixing your nick. Keep responses concise and "
"conversational."
),
)
+13
View File
@@ -0,0 +1,13 @@
name: irc-platform
kind: platform
version: 1.0.0
description: >
IRC gateway adapter for Hermes Agent.
Connects to an IRC server and relays messages between an IRC channel
(or DMs) and the Hermes agent. No external dependencies — uses
Python's stdlib asyncio for the IRC protocol.
author: Nous Research
requires_env:
- IRC_SERVER
- IRC_CHANNEL
- IRC_NICKNAME

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