* feat(wisdom): add trusted publish and install foundation
* feat(wisdom): add private contribution loop
* feat(wisdom): add managed consumption workflows
* fix(wisdom): close cross-repository safety gaps
* fix(wisdom): align local package and lifecycle policy
* fix(wisdom): require explicit profile setup
* docs(wisdom): repin reconciled gateway head
* fix(wisdom): fence content downloads and approval receipts
* docs(wisdom): record generation-fenced downloads
* docs(wisdom): record unified delivery PR
* fix(ci): stop passing invalid classifier inputs
* docs(wisdom): remove internal requirements ledger
* feat(wisdom): localize dashboard and desktop copy
* feat(wisdom): complete local contribution and consumption UX
* style(wisdom): satisfy desktop lint
* chore(wisdom): refresh requirements pin
* test(dashboard): allow formatted profile copy
* test(wisdom): stabilize desktop interaction coverage
* fix(wisdom): surface dashboard action failures
* fix(wisdom): add repeatable Portal demo login
* feat(wisdom): add actionable skill notifications
* feat(wisdom): add notification install and update actions
* fix(wisdom): make Telegram skill alerts actionable
* fix(wisdom): always refresh demo Agent login
* feat(wisdom): embed Telegram notification actions
* fix(wisdom): preserve Telegram notifications after actions
* fix(wisdom): keep Telegram notification cards readable
* feat(wisdom): add Telegram candidate approval flow
* feat(wisdom): explain Telegram qualification reasons
* fix(wisdom): reconcile cross-surface candidate actions
* feat(telegram): add Collective Wisdom management command
* chore(wisdom): refresh Gateway contract pin
* chore(wisdom): advance Gateway contract pin
* feat(wisdom): align command UX across clients
* feat(slack): add Collective Wisdom management parity
* feat(wisdom): add security and professionalism reviews
* feat(wisdom): add first-time qualification guidance
* feat(wisdom): simplify qualification sharing choices
* feat(skills): add optional editorial metadata
* feat(wisdom): enrich legacy skill presentation
* fix(wisdom): harden review and update boundaries
* fix(wisdom): emit canonical review timestamps
* fix(wisdom): align with merged gateway and main
* wisdom: add agent-led sharing core (policy, evidence, schemas, templates, delivery, weekly job, share/install flows)
- hermes_wisdom/agent_led/: policy resolution (server > local > defaults),
7-day evidence builder that excludes bundled/hub/managed skills and
dismissed/handled/recently-suggested content hashes, strict pydantic
schemas for agent output with repair-or-reject, fixed copy templates
(Share / Teammate / Published / Update / Mute), idempotent retried
delivery ledger with stale-action resolution, weekly review job,
resumable Share and Install flows.
- prompts/: candidate review, recipient recommendation, share packaging.
- tests/wisdom/test_agent_led.py: 30 tests.
* wisdom: agent-led renderers and button action dispatcher
- render.py: Telegram HTML, Slack blocks, Desktop payload; editorial name
is the emphasized line, product label stays separate.
- actions.py: resolve opaque wa:<action>:<dedup> targets via the delivery
ledger; Not now -> dismissal, Mute -> fixed options, Share -> resumable
packaging flow, Install/Update -> plan command. Never publishes/installs.
* wisdom: CLI verbs, agent_led config default, conversational catalog skill
- hermes wisdom browse/review-week/act/share/dismiss/mute (all --json).
- wisdom.agent_led config block, default enabled.
- SKILL.md rewritten so natural-language catalog questions map to the CLI
verbs, share/install flows and fixed notification templates.
* wisdom: wire agent-led weekly review into gateway tick and Telegram buttons
- gateway housekeeping tick calls maybe_run_weekly_review with a home
channel sender when a Telegram adapter is available.
- Telegram: wa: callbacks resolved through the ledger (stale-safe), mute
duration keyboard, send_wisdom_agent_recommendation rich card + fallback.
* fix(wisdom): integrate local mediation and harden model and setup boundaries
* fix(wisdom): honor authoritative recommendation policy and defer on failure
* fix(wisdom): synchronize opaque suppression and recheck delivery preferences
* feat(wisdom): route weekly selection through the session-owned assessment queue
* fix(wisdom): prepare and submit the reviewed generated share package
* feat(wisdom): separate native Share preparation from publication consent
* feat(wisdom): sync native mute choices through a leased preference outbox
* feat(wisdom): bind native mute controls to durable preference choices
* feat(wisdom): add scoped desktop and dashboard notification settings
* fix(wisdom): revalidate feed recommendations before assessment and delivery
* fix(wisdom): persist validated delivery receipts before completing notices
* feat(wisdom): add private notification claim and receipt client
* Persist Wisdom send reservations and recover delivery acknowledgements
* Route legacy Wisdom controls through current native review
* Add typed private Wisdom operation outcome client
* fix(wisdom): make agent-led advice usable in the local demo
* fix(wisdom): keep requested consent outside proactive limits
* fix(wisdom): distinguish unavailable assessments and preserve digest text
* fix(wisdom): assess ongoing usefulness beyond the current task
* fix(wisdom): restore immediate qualification sharing controls
* fix(wisdom): separate qualification review from installation advice
* fix(wisdom): collapse review checklists and simplify sharing copy
* fix(wisdom): show compact sharing progress and publication receipts
* fix(wisdom): require credential prefixes rather than matching skill names
* fix(wisdom): finish package checks before presenting sharing consent
* fix(wisdom): scan local skills before qualification cards
* fix(wisdom): update moderation results on existing sharing cards
* fix(wisdom): keep sharing review accessible from receipt cards
* fix(wisdom): align mediated review cards and collapsible checks
* fix(wisdom): clarify clean security summary wording
* fix(wisdom): normalize consent plans and add explicit recheck
* fix(wisdom): keep install and update receipts concise
* fix(wisdom): collapse assessments and deduplicate operation cards
* fix(wisdom): restore private Portal review from native cards
* fix(wisdom): sync Portal publication to original consent card
* fix(wisdom): show local skill version on sharing cards
* fix(wisdom): skip agent recommendations for self-published versions
* fix(wisdom): simplify candidate notices and local-edit recovery copy
* feat(wisdom): submit locally reviewed packages with one confirmation
* feat(wisdom): expose safe receipt and outcome sync recovery
* wisdom: onboarding notice says detect and share, names the user's own skill
Copy review from the product owner on the first and returning
qualification notices (fixed delivery mode):
- the feature blurb now says the org enabled detection *and sharing*
- both notices say the detected skill is one the user created
- both close with an exclamation mark
Applied identically to hermes_wisdom.notice, the desktop and web i18n
strings, and the tests that assert the sentences.
* wisdom: one opener, no approval line, ask to share after the skill is shown
Product owner review of the candidate card.
- The Hermes written card now opens with the same sentence as the fixed card
("Your organisation has enabled Collective Wisdom, a feature designed to
automatically detect and share useful skills across all team members.")
instead of its own blurb, so there is one first time message.
- "Nothing is shared without your approval." removed from Telegram, Slack
and Desktop. The buttons already make the permission explicit.
- "Would you like to share?" no longer appears before the skill is named.
It is now the last line, after the skill name, description, why suggested
and the checks, and reads "Would you like to share it?" (matching the
agent led template wording).
Tests updated for the new order; proposalNotice removed from all desktop locales.
* wisdom: American spelling, organization
Product owner decision: user facing copy uses American spelling.
Changes "Your organisation" to "Your organization" in the chat notice,
the Hermes written card opener, the desktop and web strings, and the
tests that assert them. Identifiers such as nas_organisation:* and the
German and French locales are untouched.
* wisdom: candidate card copy round 4 (owner review)
Apply the product owner's round 4 copy decisions to the Hermes Collective
Wisdom candidate card on Telegram, Slack, Desktop and the shared views:
1. Hermes-written cards are titled "Hermes Collective Wisdom" instead of
the bare "Collective Wisdom".
2. The "Reusable skill ready to review" line is gone from the candidate
card (Telegram rich card and plain fallback, legacy agent-led share
template).
3. The skill name and description are labelled: "Skill name: <name>" and
"What it does: <description>" (Telegram, Slack, Desktop).
4. "Why suggested:" is now "Why others might benefit:".
5. A passing professionalism review reads "Safe to share at work ✓ (no
inappropriate content found)" with no per-check bullets and no "Pass";
a failed review reads "Needs a look before sharing at work (possible
inappropriate content)" and lists only the checks that flagged
something. Pending/unavailable wording is unchanged.
6. Telegram button toasts: "Will ask later...", "Preparing more
details...", "Sharing...".
7. Qualification reasons: "You used this skill consistently across many
days." and "You've really refined this skill."
8. prompts/wisdom_candidate_review.md asks for a compelling
editorial_name, a simple one_line_description and a compelling
why_coworkers_benefit under 300 characters; "Be concise and
convincing." becomes "Be concise and compelling: the goal is that the
user wants to share it."
Tests updated for the new strings; review_text() gains direct coverage.
* wisdom: re-apply owner copy after rebase
- Native share cards (advice_view/interaction_view): drop the approval line, ask "Would you like to share it?" as the last line after the checks
- Hermes-written completion card titled "Hermes Collective Wisdom"
- Qualification reasons use the owner wording (consistently across many days / really refined)
- American spelling (organization) in remaining English copy
- Desktop test asserts the current Share button; web test matches the returning notice
* fix(wisdom): pin reconciled Gateway and verify Unicode hash vectors
Pin Gateway 60cd2d6b613ae3cd4a6e65155d1142006d907e78 and byte-identical producer artifacts. Verify every content-order case and package-manifest binding. Validation: 186 focused Python tests, Ruff and contract verifier.
* fix(wisdom): reconcile optional SDK tests and frontend lint
* fix(wisdom): default to agent-written notification summaries
* fix(wisdom): restore deferred install review and browse controls
* feat(wisdom): inspect installed setup with exact package provenance
* feat(wisdom): run native-approved installed setup steps with durable evidence
* fix(wisdom): recover interrupted setup with explicit native consent
* feat(wisdom): hand native installs into guided setup review
* fix(wisdom): continue requested setup with fixed notification copy
* fix(wisdom): preserve setup while waiting for a session model
* fix(wisdom): expose canonical setup review controls on desktop
* fix(wisdom): resume setup after recorded automatic updates
* fix(wisdom): make missing setup prerequisites recheckable
* chore(wisdom): align Agent with verified Gateway contract
* fix(wisdom): stop guessing team slugs in portal links
* fix(wisdom): retire pending advice on account sign-out
* fix(wisdom): cancel advice after terminal account revocation
* fix(wisdom): fence feed responses across account sign-out
* fix(wisdom): checkpoint signed-out feed before reactivation
* fix(wisdom): link proactive advice to scoped notification settings
* fix(wisdom): coalesce queued publication recommendations by version
* fix(wisdom): keep package review navigation local and deferable
* fix(wisdom): reflect installed state in discovery controls
* fix(wisdom): show exact checks before command confirmation
* chore(wisdom): pin bounded analytics privacy contract
* chore(wisdom): pin retired legacy notification contract
* feat(wisdom): review publisher usage with exact sharing copy
* fix(wisdom): align discovery and review check summaries
* fix(wisdom): show expired consent before confirmation
* fix(wisdom): require fresh review for legacy install controls
* fix(wisdom): preserve review expiry across check toggles
* fix(wisdom): retain update policy in native install reviews
* fix(wisdom): surface failed native card edits
* fix(wisdom): persist local command approval reviews
* fix(wisdom): use saved approvals for messaging commands
* test(wisdom): provide scan result in setup handoff fixture
* test(wisdom): exercise Telegram approvals with saved review state
* fix(wisdom): retain suppression policy for offline deferral
* fix(wisdom): reconsider candidates after deferred suppression expires
* fix(wisdom): bind review checks and report verified readiness separately
* fix(wisdom): persist accepted publication intent and recover exact outcomes
* fix(sync): pin UTF-8 tree ordering across writers
* chore(wisdom): pin organisation-scoped Gateway authorization
* fix(wisdom): restrict consent delivery to user-facing sessions
* chore(wisdom): refresh reviewed Gateway contract pin
* fix(wisdom): preserve kept tools in Blank Slate exclusions
* test(auth): reset anonymous fixture with a profile-scoped cache
* fix(wisdom): gate local surfaces and work on current profile entitlement
* fix(wisdom): invalidate quiet tool cache on entitlement changes
* test(wisdom): authorize local consent gateway fixtures
* fix(wisdom): keep entitlement decoding free of native crypto imports
* test(wisdom): provide local entitlement to demo CLI subprocess
* ci: leave upstream workflow unchanged in Wisdom PR
* fix(wisdom): ship package and contracts in Nix wheels
---------
Co-authored-by: hbizi <36184542+hbizi@users.noreply.github.com>
798 lines
43 KiB
Python
798 lines
43 KiB
Python
"""Slash command registry for the Hermes CLI and gateway.
|
|
|
|
Every consumer -- CLI help, gateway dispatch, Telegram BotCommands, Slack
|
|
subcommand mapping, autocomplete -- derives from ``COMMAND_REGISTRY``. To add a
|
|
command, append a ``CommandDef``; to add an alias, set ``aliases=("short",)``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Mapping
|
|
|
|
from utils import is_truthy_value
|
|
from hermes_constants import INDICATOR_STYLES
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CommandDef:
|
|
"""Definition of a single slash command."""
|
|
name: str # canonical name without slash: "background"
|
|
description: str # human-readable description
|
|
category: str # "Session", "Configuration", etc.
|
|
aliases: tuple[str, ...] = () # alternative names: ("bg",)
|
|
args_hint: str = "" # argument placeholder: "<prompt>", "[name]"
|
|
subcommands: tuple[str, ...] = () # tab-completable subcommands
|
|
cli_only: bool = False # only available in CLI
|
|
gateway_only: bool = False # only available in gateway/messaging
|
|
gateway_config_gate: str | None = None # config dotpath; truthy overrides cli_only for gateway
|
|
# Mid-run (agent busy) gateway behavior (gateway/run.py Guard-2 dispatcher): "dispatch" = run
|
|
# while busy (normal handler or the ``busy_handler`` variant); "reject" = refuse mid-run
|
|
# (generic "Agent is running" unless ``busy_handler`` names a reject message);
|
|
# "interrupt_then_dispatch" = interrupt first (/stop, /new, /reset; Guard 1, platforms/base.py).
|
|
busy_policy: str = "reject"
|
|
busy_handler: str | None = None # key of a special mid-run handler in Guard-2 table
|
|
# Key in ``hermes_cli.slash_exec.EXECUTORS`` (a string, not a callable: keeps this module
|
|
# import-light for the gateway).
|
|
execute: str | None = None
|
|
argument_mode: str | None = None # desktop composer: options|text|mixed; None inferred
|
|
# Desktop availability: None = offered; "hidden" = runs but out of the popover; else a reason.
|
|
desktop: str | None = None
|
|
# Optional help copy for static subcommands. Completion surfaces display it
|
|
# beside the subcommand name; callers that only understand ``subcommands``
|
|
# continue to receive the existing flat tuple.
|
|
subcommand_descriptions: Mapping[str, str] = field(default_factory=dict)
|
|
|
|
|
|
VALID_BUSY_POLICIES: frozenset[str] = frozenset({"dispatch", "reject", "interrupt_then_dispatch"})
|
|
|
|
|
|
WISDOM_SUBCOMMAND_HELP: dict[str, str] = {
|
|
"setup": "Configure this profile for Collective Wisdom",
|
|
"status": "Show account, organization, setup, and Gateway health",
|
|
"browse": "[query] — Search skills published by your team",
|
|
"show": "<skill> — View its description, requirements, scan, and install state",
|
|
"versions": "<skill> — Browse immutable published versions",
|
|
"candidates": "[all|query] — Review qualified or manually eligible local skills",
|
|
"submit": "<local-skill> — Prepare an owner-private contribution draft",
|
|
"drafts": "List your drafts and moderation states",
|
|
"review": "<draft> — Review scans, policy, hashes, and available actions",
|
|
"install": "<id|URL|id@vN> — Plan and confirm a managed installation",
|
|
"installed": "List and manage skills installed on this device",
|
|
"check": "Check installed skills and apply eligible automatic updates",
|
|
"update": "<skill|all> — Plan and confirm available updates",
|
|
"uninstall": "<skill> — Remove a managed skill after confirmation",
|
|
"notifications": "Review unseen publication, install, and update events",
|
|
"mute": "[status|1d|1w|30d|forever|off] — Manage your organization's proactive notifications",
|
|
"sync": "[status|retry] — Check or retry saved notification receipts and operation reports",
|
|
"inbox": "Read shared agent advice and pending Wisdom consent",
|
|
"consent": "<id> <inspect|defer|confirm> — Use an exact pending consent control in the local CLI",
|
|
"help": "Show this guide and command examples",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Central registry -- single source of truth
|
|
# ---------------------------------------------------------------------------
|
|
COMMAND_REGISTRY: list[CommandDef] = [
|
|
# Session
|
|
CommandDef("start", "Acknowledge platform start pings without a reply", "Session",
|
|
gateway_only=True, busy_policy="dispatch", busy_handler="start"),
|
|
CommandDef("new", "Start a new session (fresh session ID + history)", "Session",
|
|
aliases=("reset",), args_hint="[name]",
|
|
busy_policy="interrupt_then_dispatch", busy_handler="new"),
|
|
CommandDef("topic", "Enable or inspect Telegram DM topic sessions", "Session",
|
|
gateway_only=True, args_hint="[off|help|session-id]"),
|
|
CommandDef("clear", "Clear screen and start a new session", "Session",
|
|
cli_only=True, desktop="terminal"),
|
|
CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session",
|
|
cli_only=True, desktop="terminal"),
|
|
CommandDef("history", "Show conversation history", "Session",
|
|
cli_only=True, desktop="terminal"),
|
|
CommandDef("save", "Export the current conversation (bare /save shows usage)", "Session",
|
|
args_hint="<json|md|html> [filename] [redact]"),
|
|
CommandDef("retry", "Retry the last message (resend to agent)", "Session"),
|
|
CommandDef("prompt", "Compose your next prompt in $EDITOR (markdown), then send it", "Session",
|
|
cli_only=True, args_hint="[initial text]", aliases=("compose",)),
|
|
CommandDef("undo", "Back up N user turns and re-prompt (default 1)", "Session",
|
|
args_hint="[N]"),
|
|
CommandDef("title", "Set a title for the current session", "Session", args_hint="[name]"),
|
|
CommandDef("handoff", "Hand off this session to a messaging platform (Telegram, Discord, etc.)", "Session",
|
|
args_hint="<platform>", cli_only=True, argument_mode="options"),
|
|
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
|
|
aliases=("fork",), args_hint="[name]"),
|
|
CommandDef("worktree", "Show, list, create, or prune isolated git worktrees", "Session",
|
|
cli_only=True, args_hint="[new [name]|list|prune [--dry-run]]",
|
|
subcommands=("new", "list", "prune")),
|
|
CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns; --preview shows what would happen)", "Session",
|
|
aliases=("compact",), args_hint="[here [N] | focus topic | --preview|--dry-run]"),
|
|
CommandDef("rollback", "List or restore filesystem checkpoints (restores keep your hand-edits; --all overrides)", "Session",
|
|
args_hint="[number] [--all]"),
|
|
CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session",
|
|
cli_only=True, aliases=("snap",), args_hint="[create|restore <id>|prune]",
|
|
desktop="terminal"),
|
|
CommandDef("export", "Export a profile (config, skills, theme) to a shareable archive", "Configuration",
|
|
cli_only=True, args_hint="[profile] [-o output.tar.gz]"),
|
|
CommandDef("import", "Import a shared profile archive as a new profile", "Configuration",
|
|
cli_only=True, args_hint="<archive.tar.gz> [--name <name>]"),
|
|
CommandDef("stop", "Kill all running background processes", "Session",
|
|
busy_policy="interrupt_then_dispatch", busy_handler="stop"),
|
|
CommandDef("pause", "Pause new work globally (emergency stop); '/pause off' resumes", "Session",
|
|
gateway_only=True, args_hint="[reason | off]", busy_policy="dispatch"),
|
|
CommandDef("approve", "Approve a pending dangerous command", "Session",
|
|
gateway_only=True, args_hint="[session|always]", busy_policy="dispatch",
|
|
desktop="messaging"),
|
|
CommandDef("deny", "Deny a pending dangerous command (optionally with a reason)", "Session",
|
|
gateway_only=True, args_hint="[all] [reason]", busy_policy="dispatch",
|
|
desktop="messaging"),
|
|
CommandDef("bg", "Run a prompt in a separate background session", "Session",
|
|
args_hint="<prompt>", busy_policy="dispatch"),
|
|
CommandDef("btw", "Ask a side question about the current conversation without interrupting it", "Session",
|
|
args_hint="<question>", busy_policy="dispatch"),
|
|
CommandDef("agents", "Show active agents and running tasks", "Session",
|
|
aliases=("tasks",), busy_policy="dispatch"),
|
|
CommandDef("journey", "Open the learning journey timeline",
|
|
"Session", aliases=("learning", "memory-graph"), cli_only=True,
|
|
args_hint="[list|delete <id>|edit <id>]", subcommands=("list", "delete", "edit")),
|
|
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
|
|
aliases=("q",), args_hint="<prompt>", busy_policy="dispatch", busy_handler="queue"),
|
|
CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session",
|
|
args_hint="<prompt>", busy_policy="dispatch", busy_handler="steer"),
|
|
CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session",
|
|
args_hint="[text | draft <text> | show | gate add <cmd> | pause | resume | clear | status | wait <pid> | unwait]",
|
|
argument_mode="mixed", busy_policy="dispatch", busy_handler="goal"),
|
|
CommandDef("heartbeat", "Set a recurring prompt that re-enters this session when idle", "Session",
|
|
aliases=("hb",), args_hint="[every <interval> <prompt> | status | pause | resume | clear]",
|
|
subcommands=("status", "pause", "resume", "clear"),
|
|
busy_policy="dispatch"),
|
|
CommandDef("refine", "Review this conversation now and save lessons to memory/skills", "Session",
|
|
args_hint="[focus instructions]"),
|
|
CommandDef("review", "Spawn an independent subagent to review the work just discussed (PR, code, docs)", "Session",
|
|
args_hint="[review instructions]"),
|
|
CommandDef("loop", "Re-run a prompt on a recurring interval in this session", "Session",
|
|
aliases=("proactive",),
|
|
args_hint="[interval] <prompt> [--times N] [--until <condition>] | status | pause | resume | stop",
|
|
argument_mode="mixed", busy_policy="dispatch", busy_handler="loop"),
|
|
CommandDef("plan", "Write a markdown implementation plan to .hermes/plans/ without executing anything", "Session",
|
|
args_hint="[task]"),
|
|
CommandDef("moa", "Run one prompt through the default Mixture of Agents preset, then restore your model", "Session",
|
|
args_hint="<prompt>", busy_policy="reject", busy_handler="moa"),
|
|
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
|
|
args_hint="[text | remove N | clear]", busy_policy="dispatch"),
|
|
CommandDef("status", "Show session, model, token, and context info", "Session",
|
|
busy_policy="dispatch"),
|
|
CommandDef("egress", "Show Docker egress proxy status", "Session",
|
|
args_hint="[status]", subcommands=("status",), busy_policy="dispatch",
|
|
busy_handler="egress", execute="egress"),
|
|
CommandDef("context", "Show detailed context window view with usage gauge, category breakdown, compression stats, and throughput", "Session",
|
|
aliases=("ctx",), args_hint="[all]", subcommands=("all",), busy_policy="dispatch"),
|
|
CommandDef("whoami", "Show your slash command access (admin / user)", "Info"),
|
|
CommandDef("profile", "Show active profile name and home directory", "Info",
|
|
busy_policy="dispatch", execute="profile"),
|
|
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
|
gateway_only=True, aliases=("set-home",), desktop="terminal"),
|
|
CommandDef("resume", "Resume a previously-named session", "Session",
|
|
args_hint="[name]", argument_mode="mixed"),
|
|
CommandDef("sessions", "Browse and resume previous sessions", "Session"),
|
|
|
|
# Configuration
|
|
CommandDef("config", "Show current configuration", "Configuration",
|
|
cli_only=True, desktop="terminal"),
|
|
CommandDef("model", "Switch model (session-scoped; --global to persist)", "Configuration",
|
|
args_hint="[model] [--provider name] [--global|--session] [--refresh]",
|
|
busy_policy="reject", busy_handler="model", desktop="hidden"),
|
|
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
|
|
"Configuration", aliases=("codex_runtime",), args_hint="[auto|codex_app_server]",
|
|
busy_policy="reject", busy_handler="codex-runtime"),
|
|
CommandDef("personality", "Set a predefined personality", "Configuration",
|
|
args_hint="[name]", argument_mode="options"),
|
|
CommandDef("statusbar", "Toggle the context/model status bar", "Configuration",
|
|
cli_only=True, aliases=("sb",), desktop="terminal"),
|
|
CommandDef("battery", "Toggle a color-coded battery indicator in the status bar",
|
|
"Configuration", cli_only=True, args_hint="[on|off|status]",
|
|
subcommands=("on", "off", "status")),
|
|
CommandDef("timestamps", "Toggle [HH:MM] timestamps on messages and /history", "Configuration",
|
|
cli_only=True, args_hint="[on|off|status]",
|
|
subcommands=("on", "off", "status"), aliases=("ts",)),
|
|
CommandDef("diff", "Show git changes in the working directory", "Info",
|
|
args_hint="[staged|all|session] [--stat] [path...]",
|
|
subcommands=("staged", "all", "session")),
|
|
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
|
|
"Configuration", cli_only=True, gateway_config_gate="display.tool_progress_command",
|
|
busy_policy="dispatch", desktop="terminal"),
|
|
CommandDef("focus", "Toggle focus view — show only your prompt and the final response",
|
|
"Configuration", cli_only=True, args_hint="[on|off|status]",
|
|
subcommands=("on", "off", "status")),
|
|
CommandDef("footer", "Toggle gateway runtime-metadata footer on final replies",
|
|
"Configuration", args_hint="[on|off|status]", subcommands=("on", "off", "status"),
|
|
busy_policy="dispatch", desktop="terminal"),
|
|
CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)",
|
|
"Configuration", busy_policy="dispatch"),
|
|
CommandDef("approvals", "Show or set the persistent dangerous-command approval mode",
|
|
"Configuration", args_hint="[manual|smart|off]",
|
|
subcommands=("manual", "smart", "off")),
|
|
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
|
|
args_hint="[level|show|hide|full|clamp] [--global]",
|
|
subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp", "--global"),
|
|
desktop="advanced"),
|
|
CommandDef("fast", "Fast mode — OpenAI Priority Processing / Anthropic Fast Mode (normal/fast/auto/cold)", "Configuration",
|
|
args_hint="[normal|fast|auto|cold|status] [--global]",
|
|
subcommands=("normal", "fast", "auto", "cold", "status", "on", "off", "--global"),
|
|
desktop="advanced"),
|
|
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
|
|
cli_only=True, args_hint="[name]", argument_mode="options"),
|
|
CommandDef("indicator", "Pick the TUI busy-indicator style", "Configuration",
|
|
cli_only=True, args_hint=f"[{'|'.join(INDICATOR_STYLES)}]",
|
|
subcommands=INDICATOR_STYLES, desktop="terminal"),
|
|
CommandDef("voice", "Toggle voice mode", "Configuration",
|
|
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status"),
|
|
desktop="composer-voice"),
|
|
CommandDef("wake", "Toggle the 'Hey Hermes' wake word listener", "Configuration",
|
|
cli_only=True, args_hint="[on|off|status]", subcommands=("on", "off", "status")),
|
|
CommandDef("busy", "Control how messages behave while Hermes is working", "Configuration",
|
|
args_hint="[queue|steer|interrupt|status]",
|
|
subcommands=("queue", "steer", "interrupt", "status"),
|
|
busy_policy="dispatch", desktop="terminal"),
|
|
|
|
# Tools & Skills
|
|
CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills",
|
|
args_hint="[list|disable|enable] [name...]", cli_only=True, argument_mode="options"),
|
|
CommandDef("toolsets", "List available toolsets", "Tools & Skills",
|
|
cli_only=True, desktop="terminal"),
|
|
CommandDef("skills", "Search, install, inspect, or manage skills",
|
|
"Tools & Skills", cli_only=True,
|
|
gateway_config_gate="skills.write_approval",
|
|
subcommands=("search", "browse", "inspect", "install", "audit",
|
|
"pending", "approve", "reject", "diff", "approval"),
|
|
desktop="settings"),
|
|
CommandDef(
|
|
"wisdom",
|
|
"Browse, contribute, install, and manage Collective Wisdom skills",
|
|
"Tools & Skills",
|
|
aliases=("collective-wisdom-install",),
|
|
args_hint="[keyword]",
|
|
subcommands=tuple(WISDOM_SUBCOMMAND_HELP),
|
|
subcommand_descriptions=WISDOM_SUBCOMMAND_HELP,
|
|
busy_policy="reject",
|
|
argument_mode="mixed",
|
|
),
|
|
CommandDef("memory", "Review pending memory writes / toggle the approval gate",
|
|
"Tools & Skills", args_hint="[pending|approve|reject|approval] [id|on|off]",
|
|
subcommands=("pending", "approve", "reject", "approval")),
|
|
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
|
|
"Tools & Skills", execute="bundles"),
|
|
CommandDef("pet", "Toggle or adopt a petdex mascot (/pet, /pet list, /pet <slug>)", "Tools & Skills",
|
|
cli_only=True, args_hint="[toggle|list|scale <n>|<slug>]", subcommands=("toggle", "list", "scale", "off")),
|
|
CommandDef("hatch", "Generate a new petdex pet from a description",
|
|
"Tools & Skills", cli_only=True, aliases=("generate-pet",), args_hint="[description]"),
|
|
CommandDef("learn", "Learn a reusable skill from anything you describe (dirs, URLs, this chat, notes)",
|
|
"Tools & Skills", args_hint="<what to learn from>"),
|
|
CommandDef("init", "Generate or update AGENTS.md project instructions from a repo scan",
|
|
"Tools & Skills", args_hint="[notes]"),
|
|
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
|
cli_only=True, args_hint="[subcommand]",
|
|
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove"),
|
|
desktop="terminal"),
|
|
CommandDef("suggestions", "Review suggested automations (accept/dismiss)",
|
|
"Tools & Skills", aliases=("suggest",), args_hint="[accept|dismiss N | catalog]",
|
|
subcommands=("accept", "dismiss", "catalog", "clear")),
|
|
CommandDef("blueprint", "Set up an automation from a blueprint template",
|
|
"Tools & Skills", aliases=("bp",), args_hint="[name] [slot=value ...]"),
|
|
CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)",
|
|
"Tools & Skills", args_hint="[subcommand]",
|
|
subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived"),
|
|
desktop="advanced"),
|
|
CommandDef("kanban", "Multi-profile collaboration board (tasks, links, comments)",
|
|
"Tools & Skills", args_hint="[subcommand]",
|
|
subcommands=("init", "boards", "create", "list", "ls", "show", "assign",
|
|
"reclaim", "reassign", "diagnostics", "diag", "link", "unlink",
|
|
"claim", "comment", "complete", "edit", "block", "unblock",
|
|
"archive", "tail", "dispatch", "stats", "notify-subscribe",
|
|
"notify-list", "notify-unsubscribe", "log", "runs",
|
|
"heartbeat", "assignees", "context", "specify", "gc"),
|
|
busy_policy="dispatch", desktop="advanced"),
|
|
CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills",
|
|
cli_only=True, desktop="terminal"),
|
|
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
|
aliases=("reload_mcp",), desktop="advanced"),
|
|
CommandDef("reload-skills", "Re-scan ~/.hermes/skills/ for newly installed or removed skills",
|
|
"Tools & Skills", aliases=("reload_skills",), desktop="advanced"),
|
|
CommandDef("browser", "Connect browser tools to your live Chromium-family browser via CDP, or switch to Browser Use mode", "Tools & Skills",
|
|
cli_only=True, args_hint="[connect|disconnect|status|use]",
|
|
subcommands=("connect", "disconnect", "status", "use")),
|
|
CommandDef("plugins", "List installed plugins and their status",
|
|
"Tools & Skills", cli_only=True, desktop="terminal"),
|
|
|
|
# Info
|
|
CommandDef("commands", "Browse all commands and skills (paginated)", "Info",
|
|
gateway_only=True, args_hint="[page]", busy_policy="dispatch",
|
|
execute="gateway_commands"),
|
|
CommandDef("help", "Show available commands (/help skills lists skill commands, /help <text> filters)", "Info", busy_policy="dispatch",
|
|
execute="gateway_help", args_hint="[skills|<filter>]"),
|
|
CommandDef("palette", "Open the fuzzy command palette (also Ctrl+P)", "Info",
|
|
cli_only=True, busy_policy="dispatch"),
|
|
CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session",
|
|
gateway_only=True, busy_policy="dispatch", desktop="terminal"),
|
|
CommandDef("usage", "Show token usage and rate limits; `reset` redeems a banked Codex limit reset", "Info",
|
|
args_hint="[reset [--force]]"),
|
|
CommandDef("subscription", "View your Nous plan and change it in the browser", "Info",
|
|
cli_only=True, aliases=("upgrade",)),
|
|
CommandDef("login", "Sign in with a Nous account (keeps your connectors)", "Info",
|
|
busy_policy="dispatch", desktop="settings"),
|
|
CommandDef("topup", "Show your Nous balance and manage billing on the portal", "Info"),
|
|
CommandDef("insights", "Show usage insights and analytics", "Info",
|
|
args_hint="[days]", desktop="advanced"),
|
|
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
|
|
cli_only=True, aliases=("gateway",), desktop="terminal"),
|
|
CommandDef("platform", "Pause, resume, or list a failing gateway platform", "Info",
|
|
gateway_only=True, args_hint="<pause|resume|list> [name]"),
|
|
CommandDef("copy", "Copy the last assistant response to clipboard", "Info",
|
|
cli_only=True, args_hint="[number]", desktop="terminal"),
|
|
CommandDef("paste", "Attach clipboard image from your clipboard", "Info",
|
|
cli_only=True, desktop="terminal"),
|
|
CommandDef("image", "Attach a local image file for your next prompt", "Info",
|
|
cli_only=True, args_hint="<path>", desktop="terminal"),
|
|
CommandDef("update", "Update Hermes Agent to the latest version", "Info",
|
|
busy_policy="dispatch", desktop="terminal"),
|
|
CommandDef("version", "Show Hermes Agent version", "Info", aliases=("v",),
|
|
busy_policy="dispatch", execute="version"),
|
|
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info",
|
|
args_hint="[nous|local]"),
|
|
|
|
# Exit
|
|
CommandDef("quit", "Exit the CLI (use --delete to also remove session history)", "Exit",
|
|
cli_only=True, aliases=("exit",), args_hint="[--delete]", desktop="terminal")]
|
|
|
|
|
|
# Distinguishes ``mixed`` (subcommands plus free-text) from ``options``; no subcommands => ``text``.
|
|
_PROSE_HINTS = ("<prompt>", "[text", "instructions", "[interval]", "<what")
|
|
|
|
|
|
def infer_argument_mode(cmd: CommandDef) -> str | None:
|
|
"""Composer mode: explicit on the CommandDef, else inferred from its args."""
|
|
if cmd.argument_mode in {"options", "text", "mixed"}:
|
|
return cmd.argument_mode
|
|
hint = (cmd.args_hint or "").strip()
|
|
if cmd.subcommands:
|
|
prose = hint and any(token in hint.lower() for token in _PROSE_HINTS)
|
|
return "mixed" if prose else "options"
|
|
return "text" if hint else None
|
|
|
|
|
|
def command_desktop_meta(cmd: CommandDef) -> dict[str, str | None]:
|
|
"""Wire shape for ``commands.catalog`` — reads the CommandDef, nothing else."""
|
|
return {"argument_mode": infer_argument_mode(cmd), "desktop": cmd.desktop}
|
|
|
|
|
|
# Every name and alias -> its CommandDef.
|
|
_COMMAND_LOOKUP: dict[str, CommandDef] = {
|
|
key: cmd for cmd in COMMAND_REGISTRY for key in (cmd.name, *cmd.aliases)}
|
|
|
|
|
|
def resolve_command(name: str) -> CommandDef | None:
|
|
"""Resolve a command name or alias (leading slash optional) to its CommandDef."""
|
|
return _COMMAND_LOOKUP.get(name.lower().lstrip("/"))
|
|
|
|
|
|
def command_available(command: CommandDef | str) -> bool:
|
|
"""Return whether a registry command may be presented or dispatched locally.
|
|
|
|
The registry and lookup tables deliberately remain static. Availability is a
|
|
fresh, refresh-free projection so login/logout and token replacement take effect
|
|
without mutating command state held by a running conversation.
|
|
"""
|
|
cmd = command if isinstance(command, CommandDef) else resolve_command(command)
|
|
if cmd is None:
|
|
return False
|
|
if cmd.name != "wisdom":
|
|
return True
|
|
try:
|
|
from hermes_wisdom.entitlement import is_entitled
|
|
return bool(is_entitled())
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _build_description(cmd: CommandDef) -> str:
|
|
"""CLI-facing description including the usage hint."""
|
|
if not cmd.args_hint:
|
|
return cmd.description
|
|
return f"{cmd.description} (usage: /{cmd.name} {cmd.args_hint})"
|
|
|
|
|
|
# Flat "/command" -> description, and the same grouped by category; both exclude gateway_only.
|
|
COMMANDS: dict[str, str] = {}
|
|
COMMANDS_BY_CATEGORY: dict[str, dict[str, str]] = {}
|
|
# Subcommands lookup: "/cmd" -> ["sub1", ...]; explicit ``subcommands`` first (in
|
|
# registry order), then pipe patterns in args_hint ("[on|off|status]") as fallback.
|
|
SUBCOMMANDS: dict[str, list[str]] = {
|
|
f"/{_cmd.name}": list(_cmd.subcommands) for _cmd in COMMAND_REGISTRY if _cmd.subcommands}
|
|
SUBCOMMAND_DESCRIPTIONS: dict[str, dict[str, str]] = {
|
|
f"/{cmd.name}": {sub: str(cmd.subcommand_descriptions.get(sub) or "") for sub in cmd.subcommands}
|
|
for cmd in COMMAND_REGISTRY if cmd.subcommands
|
|
}
|
|
for _cmd in COMMAND_REGISTRY:
|
|
if _cmd.gateway_only:
|
|
continue
|
|
_entries = {f"/{_cmd.name}": _build_description(_cmd)}
|
|
for _alias in _cmd.aliases:
|
|
_entries[f"/{_alias}"] = f"{_cmd.description} (alias for /{_cmd.name})"
|
|
COMMANDS.update(_entries)
|
|
COMMANDS_BY_CATEGORY.setdefault(_cmd.category, {}).update(_entries)
|
|
|
|
_PIPE_SUBS_RE = re.compile(r"[a-z]+(?:\|[a-z]+)+")
|
|
for _cmd in COMMAND_REGISTRY:
|
|
_m = _PIPE_SUBS_RE.search(_cmd.args_hint) if _cmd.args_hint else None
|
|
if _m and f"/{_cmd.name}" not in SUBCOMMANDS:
|
|
SUBCOMMANDS[f"/{_cmd.name}"] = _m.group(0).split("|")
|
|
|
|
|
|
# /help sub-groups for the large "Session" category (category itself is load-bearing for gateway
|
|
# help, so commands are not re-tagged); unlisted Session commands fall under the base header.
|
|
HELP_SESSION_SUBGROUPS: dict[str, tuple[str, ...]] = {
|
|
"Context": ("compress", "compact", "context", "ctx", "status"),
|
|
"Background & Automation": (
|
|
"bg", "btw", "agents", "tasks", "queue", "q", "steer", "goal", "subgoal", "heartbeat", "hb",
|
|
"refine", "loop", "proactive", "moa", "journey", "learning", "memory-graph")}
|
|
|
|
# All names + aliases the gateway dispatches. Config-gated commands are
|
|
# included; their handler checks the gate at runtime.
|
|
GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset(
|
|
name for cmd in COMMAND_REGISTRY if not cmd.cli_only or cmd.gateway_config_gate
|
|
for name in (cmd.name, *cmd.aliases))
|
|
|
|
|
|
def is_gateway_known_command(name: str | None) -> bool:
|
|
"""True if ``name`` is a built-in or plugin gateway slash command (plugins looked
|
|
up lazily); decides whether the gateway emits ``command:<name>`` hooks."""
|
|
if not name:
|
|
return False
|
|
return name in GATEWAY_KNOWN_COMMANDS or any(
|
|
plugin_name == name for plugin_name, _d, _h in _iter_plugin_command_entries())
|
|
|
|
|
|
# Commands with explicit mid-run handling (busy_policy != "reject"). Kept
|
|
# under its historical name for introspection/tests; the real bypass set is
|
|
# every resolvable command (see should_bypass_active_session).
|
|
ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
|
|
cmd.name for cmd in COMMAND_REGISTRY if cmd.busy_policy != "reject")
|
|
|
|
|
|
def is_interrupt_then_dispatch(command_name: str | None) -> bool:
|
|
"""Guard 1 (gateway/platforms/base.py) routes these through the cancel-handoff path."""
|
|
cmd = resolve_command(command_name) if command_name else None
|
|
return cmd is not None and cmd.busy_policy == "interrupt_then_dispatch"
|
|
|
|
|
|
def should_bypass_active_session(command_name: str | None) -> bool:
|
|
"""True for any resolvable slash command: every recognized command is dispatched mid-run
|
|
(Guard-2 handler or the "busy" catch-all), never queued — gateway.run's safety net discards
|
|
command text reaching the pending queue, so a queued mid-run /model (or /reasoning, /voice,
|
|
/insights, /title, /resume, /retry, /undo, /compress, /usage, /reload-mcp, /sethome, /reset)
|
|
would silently interrupt the agent AND get discarded — a zero-char response. See issue
|
|
#5057 / PRs #6252, #10370, #4665. ACTIVE_SESSION_BYPASS_COMMANDS remains the subset with
|
|
explicit Level-2 handlers; the rest fall through to the catch-all.
|
|
|
|
See #10370, #4665, #5057, #6252.
|
|
"""
|
|
return resolve_command(command_name) is not None if command_name else False
|
|
|
|
|
|
def _resolve_config_gates() -> set[str]:
|
|
"""Canonical names of commands whose ``gateway_config_gate`` dotpath is truthy in
|
|
config.yaml (empty set on any error)."""
|
|
gated = [c for c in COMMAND_REGISTRY if c.gateway_config_gate]
|
|
if not gated:
|
|
return set()
|
|
try:
|
|
from hermes_cli.config import cfg_get, read_raw_config
|
|
cfg = read_raw_config()
|
|
except Exception:
|
|
return set()
|
|
return {cmd.name for cmd in gated
|
|
if is_truthy_value(cfg_get(cfg, *cmd.gateway_config_gate.split(".")), default=False)}
|
|
|
|
|
|
def _is_gateway_available(cmd: CommandDef, config_overrides: set[str] | None = None) -> bool:
|
|
"""Not ``cli_only``, or its config gate is truthy (*config_overrides* from
|
|
``_resolve_config_gates()`` avoids re-reading config per command), and passes
|
|
any refresh-free account entitlement gate."""
|
|
if not command_available(cmd):
|
|
return False
|
|
if not cmd.cli_only:
|
|
return True
|
|
if not cmd.gateway_config_gate:
|
|
return False
|
|
overrides = config_overrides if config_overrides is not None else _resolve_config_gates()
|
|
return cmd.name in overrides
|
|
|
|
|
|
def gateway_help_lines() -> list[str]:
|
|
"""Generate gateway help text lines from the registry."""
|
|
overrides = _resolve_config_gates()
|
|
lines: list[str] = []
|
|
for cmd in COMMAND_REGISTRY:
|
|
if not _is_gateway_available(cmd, overrides):
|
|
continue
|
|
args = f" {cmd.args_hint}" if cmd.args_hint else ""
|
|
# Skip internal aliases like reload_mcp (underscore variant of the name).
|
|
alias_parts = [f"`/{a}`" for a in cmd.aliases
|
|
if not (a.replace("-", "_") == cmd.name.replace("-", "_") and a != cmd.name)]
|
|
alias_note = f" (alias: {', '.join(alias_parts)})" if alias_parts else ""
|
|
lines.append(f"`/{cmd.name}{args}` -- {cmd.description}{alias_note}")
|
|
return lines
|
|
|
|
|
|
def _iter_plugin_command_entries() -> list[tuple[str, str, str]]:
|
|
"""(name, description, args_hint) for ``PluginContext.register_command`` slash commands.
|
|
Lazy so importing this module never forces plugin discovery."""
|
|
try:
|
|
from hermes_cli.plugins import get_plugin_commands
|
|
commands = get_plugin_commands() or {}
|
|
except Exception:
|
|
return []
|
|
return [(name, str(meta.get("description") or f"Run /{name}"),
|
|
str(meta.get("args_hint") or "").strip())
|
|
for name, meta in commands.items() if isinstance(name, str) and isinstance(meta, dict)]
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
from typing import Any # noqa: F401,E402
|
|
from collections.abc import Callable # noqa: F401,E402
|
|
from typing import Dict # noqa: F401,E402
|
|
from collections.abc import Mapping # noqa: F401,E402
|
|
from typing import Optional # noqa: F401,E402
|
|
from collections.abc import Sequence # noqa: F401,E402
|
|
from typing import Tuple # noqa: F401,E402
|
|
from dataclasses import field # noqa: F401,E402
|
|
import os # noqa: F401,E402
|
|
import shutil # noqa: F401,E402
|
|
import subprocess # noqa: F401,E402
|
|
import time # noqa: F401,E402
|
|
|
|
def _requires_argument(args_hint: str) -> bool:
|
|
"""Return True when selecting a command without text would be incomplete."""
|
|
return args_hint.strip().startswith("<")
|
|
|
|
_CMD_NAME_LIMIT = 32
|
|
|
|
def _clamp_command_names(
|
|
entries: Sequence[tuple[str, ...]],
|
|
reserved: set[str],
|
|
) -> list[tuple[str, ...]]:
|
|
"""Enforce 32-char command name limit with collision avoidance.
|
|
|
|
Both Telegram and Discord cap slash command names at 32 characters.
|
|
Names exceeding the limit are truncated. If truncation creates a duplicate
|
|
(against *reserved* names or earlier entries in the same batch), the name is
|
|
shortened to 31 chars and a digit ``0``-``9`` is appended to differentiate.
|
|
If all 10 digit slots are taken the entry is silently dropped.
|
|
|
|
Accepts tuples of any length >= 2. Extra elements beyond ``(name, desc)``
|
|
(e.g. ``cmd_key``) are passed through unchanged, so callers can attach
|
|
metadata that survives the rename.
|
|
"""
|
|
used: set[str] = set(reserved)
|
|
result: list[tuple] = []
|
|
for entry in entries:
|
|
name, desc, *extra = entry
|
|
if len(name) > _CMD_NAME_LIMIT:
|
|
candidate = name[:_CMD_NAME_LIMIT]
|
|
if candidate in used:
|
|
prefix = name[:_CMD_NAME_LIMIT - 1]
|
|
for digit in range(10):
|
|
candidate = f"{prefix}{digit}"
|
|
if candidate not in used:
|
|
break
|
|
else:
|
|
# All 10 digit slots exhausted — skip entry
|
|
continue
|
|
name = candidate
|
|
if name in used:
|
|
continue
|
|
used.add(name)
|
|
result.append((name, desc, *extra))
|
|
return result
|
|
|
|
def _collect_gateway_skill_entries(
|
|
platform: str,
|
|
max_slots: int | None,
|
|
reserved_names: set[str],
|
|
desc_limit: int = 100,
|
|
sanitize_name: "Callable[[str], str] | None" = None,
|
|
) -> tuple[list[tuple[str, str, str, str]], int]:
|
|
"""Collect plugin + skill entries for a gateway platform.
|
|
|
|
Priority order:
|
|
1. Plugin slash commands (take precedence over skills)
|
|
2. Built-in skill commands (fill remaining slots, alphabetical)
|
|
|
|
Only skills are trimmed when the cap is reached.
|
|
Hub-installed skills are excluded. Per-platform disabled skills are
|
|
excluded.
|
|
|
|
Args:
|
|
platform: Platform identifier for per-platform skill filtering
|
|
(``"telegram"``, ``"discord"``, etc.).
|
|
max_slots: Maximum number of entries to return (remaining slots after
|
|
built-in/core commands), or ``None`` to return every eligible
|
|
plugin and skill candidate for a caller that applies a global cap.
|
|
reserved_names: Names already taken by built-in commands. Mutated
|
|
in-place as new names are added.
|
|
desc_limit: Max description length (40 for Telegram, 100 for Discord).
|
|
sanitize_name: Optional name transform applied before clamping, e.g.
|
|
:func:`_sanitize_telegram_name` for Telegram. May return an
|
|
empty string to signal "skip this entry".
|
|
|
|
Returns:
|
|
``(entries, hidden_count)`` where *entries* contains
|
|
``(name, description, cmd_key, raw_name)`` tuples. ``cmd_key`` is the
|
|
original skill key (empty for plugins); ``raw_name`` is the sanitized
|
|
pre-clamp name used for configured priority matching.
|
|
"""
|
|
all_entries: list[tuple[str, str, str, str]] = []
|
|
|
|
# --- Tier 1: Plugin slash commands (never trimmed) ---------------------
|
|
plugin_pairs: list[tuple[str, str, str]] = []
|
|
try:
|
|
from hermes_cli.plugins import get_plugin_commands
|
|
plugin_cmds = get_plugin_commands()
|
|
for cmd_name in sorted(plugin_cmds):
|
|
if platform == "telegram":
|
|
args_hint = str(plugin_cmds[cmd_name].get("args_hint") or "").strip()
|
|
if _requires_argument(args_hint):
|
|
continue
|
|
name = sanitize_name(cmd_name) if sanitize_name else cmd_name
|
|
if not name:
|
|
continue
|
|
desc = plugin_cmds[cmd_name].get("description", "Plugin command")
|
|
if len(desc) > desc_limit:
|
|
desc = desc[:desc_limit - 3] + "..."
|
|
plugin_pairs.append((name, desc, name))
|
|
except Exception:
|
|
pass
|
|
|
|
plugin_pairs = [
|
|
(name, desc, raw_name)
|
|
for name, desc, raw_name in _clamp_command_names(plugin_pairs, reserved_names)
|
|
]
|
|
reserved_names.update(n for n, _d, _raw_name in plugin_pairs)
|
|
# Plugins have no cmd_key — use empty string as placeholder.
|
|
for name, desc, raw_name in plugin_pairs:
|
|
all_entries.append((name, desc, "", raw_name))
|
|
|
|
# --- Tier 2: Built-in skill commands (trimmed at cap) -----------------
|
|
_platform_disabled: set[str] = set()
|
|
try:
|
|
from agent.skill_utils import get_disabled_skill_names
|
|
_platform_disabled = get_disabled_skill_names(platform=platform)
|
|
except Exception:
|
|
pass
|
|
|
|
skill_entries: list[tuple[str, str, str, str]] = []
|
|
try:
|
|
from agent.skill_commands import get_skill_commands
|
|
from tools.skills_tool import SKILLS_DIR
|
|
from agent.skill_utils import get_external_skills_dirs, get_project_skills_dirs
|
|
_skills_dir = str(SKILLS_DIR.resolve())
|
|
_hub_dir = str((SKILLS_DIR / ".hub").resolve()).rstrip("/") + "/"
|
|
# Build set of allowed directory prefixes: local skills dir + any
|
|
# user-configured ``skills.external_dirs`` + trusted project dirs.
|
|
# Ensure each prefix ends
|
|
# with ``/`` so ``/my-skills`` does not also match ``/my-skills-extra``.
|
|
# Without this widening, external skills are visible in
|
|
# ``hermes skills list`` and the agent's ``/skill-name`` dispatch but
|
|
# silently excluded from gateway slash menus (#8110).
|
|
_allowed_prefixes = [_skills_dir.rstrip("/") + "/"]
|
|
_allowed_prefixes.extend(
|
|
str(d).rstrip("/") + "/" for d in get_external_skills_dirs()
|
|
)
|
|
_allowed_prefixes.extend(
|
|
str(d).rstrip("/") + "/" for d in get_project_skills_dirs()
|
|
)
|
|
skill_cmds = get_skill_commands()
|
|
for cmd_key in sorted(skill_cmds):
|
|
info = skill_cmds[cmd_key]
|
|
skill_path = info.get("skill_md_path", "")
|
|
if not skill_path:
|
|
continue
|
|
if not any(skill_path.startswith(prefix) for prefix in _allowed_prefixes):
|
|
continue
|
|
if skill_path.startswith(_hub_dir):
|
|
continue
|
|
skill_name = info.get("name", "")
|
|
if skill_name in _platform_disabled:
|
|
continue
|
|
raw_name = cmd_key.lstrip("/")
|
|
name = sanitize_name(raw_name) if sanitize_name else raw_name
|
|
if not name:
|
|
continue
|
|
desc = info.get("description", "")
|
|
if len(desc) > desc_limit:
|
|
desc = desc[:desc_limit - 3] + "..."
|
|
skill_entries.append((name, desc, cmd_key, name))
|
|
except Exception:
|
|
pass
|
|
|
|
# Clamp names; cmd_key and raw_name survive any clamp-induced rename.
|
|
skill_entries = [
|
|
(name, desc, cmd_key, raw_name)
|
|
for name, desc, cmd_key, raw_name in _clamp_command_names(
|
|
skill_entries, reserved_names
|
|
)
|
|
]
|
|
|
|
if max_slots is None:
|
|
return all_entries + skill_entries, 0
|
|
|
|
# Skills fill remaining slots — only tier that gets trimmed
|
|
remaining = max(0, max_slots - len(all_entries))
|
|
hidden_count = max(0, len(skill_entries) - remaining)
|
|
for name, desc, cmd_key, raw_name in skill_entries[:remaining]:
|
|
all_entries.append((name, desc, cmd_key, raw_name))
|
|
|
|
return all_entries[:max_slots], hidden_count
|
|
|
|
def discord_skill_commands(
|
|
max_slots: int,
|
|
reserved_names: set[str],
|
|
) -> tuple[list[tuple[str, str, str]], int]:
|
|
"""Return skill entries for Discord slash command registration.
|
|
|
|
Same priority and filtering logic as :func:`telegram_menu_commands`
|
|
(plugins > skills, hub excluded, per-platform disabled excluded), but
|
|
adapted for Discord's constraints:
|
|
|
|
- Hyphens are allowed in names (no ``-`` → ``_`` sanitization)
|
|
- Descriptions capped at 100 chars (Discord's per-field max)
|
|
|
|
Args:
|
|
max_slots: Available command slots (100 minus existing built-in count).
|
|
reserved_names: Names of already-registered built-in commands.
|
|
|
|
Returns:
|
|
``(entries, hidden_count)`` where *entries* is a list of
|
|
``(discord_name, description, cmd_key)`` triples. ``cmd_key`` is
|
|
the original ``/skill-name`` key needed for the slash handler callback.
|
|
"""
|
|
entries, hidden_count = _collect_gateway_skill_entries(
|
|
platform="discord",
|
|
max_slots=max_slots,
|
|
reserved_names=set(reserved_names), # copy — don't mutate caller's set
|
|
desc_limit=100,
|
|
)
|
|
return [
|
|
(name, desc, cmd_key) for name, desc, cmd_key, _raw_name in entries
|
|
], hidden_count
|
|
|
|
|
|
_PLUGIN_COMPAT_LAZY = {
|
|
'SlashCommandAutoSuggest': ('hermes_cli.commands_completion', 'SlashCommandAutoSuggest'),
|
|
'SlashCommandCompleter': ('hermes_cli.commands_completion', 'SlashCommandCompleter'),
|
|
'discord_skill_commands_by_category': ('hermes_cli.commands_platforms', 'discord_skill_commands_by_category'),
|
|
'slack_app_manifest': ('hermes_cli.commands_platforms', 'slack_app_manifest'),
|
|
'slack_native_slashes': ('hermes_cli.commands_platforms', 'slack_native_slashes'),
|
|
'slack_subcommand_map': ('hermes_cli.commands_platforms', 'slack_subcommand_map'),
|
|
'telegram_bot_commands': ('hermes_cli.commands_platforms', 'telegram_bot_commands'),
|
|
'telegram_menu_commands': ('hermes_cli.commands_platforms', 'telegram_menu_commands'),
|
|
'telegram_menu_max_commands': ('hermes_cli.commands_platforms', 'telegram_menu_max_commands'),
|
|
}
|
|
|
|
|
|
def __getattr__(name): # PEP 562 — lazy so no import cycles
|
|
target = _PLUGIN_COMPAT_LAZY.get(name)
|
|
if target is None:
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
import importlib
|
|
from hermes_cli.plugin_compat import warn_once
|
|
warn_once(__name__, name, *target)
|
|
return getattr(importlib.import_module(target[0]), target[1])
|
|
# ---- END PLUGIN-COMPAT ----
|