Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,911 @@
|
||||
"""Shared slash command helpers for skills.
|
||||
|
||||
Shared between CLI (cli.py) and gateway (gateway/run.py) so both surfaces
|
||||
can invoke skills via /skill-name commands.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_constants import display_hermes_home
|
||||
from agent.prompt_cache_boundary import register_stable_prefix
|
||||
from agent.skill_preprocessing import (
|
||||
expand_inline_shell as _expand_inline_shell,
|
||||
load_skills_config as _load_skills_config,
|
||||
substitute_template_vars as _substitute_template_vars,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_skill_commands: Dict[str, Dict[str, Any]] = {}
|
||||
_skill_commands_platform: Optional[str] = None
|
||||
_skill_commands_home: Optional[str] = None
|
||||
# Guards the (map, platform-tag, home-tag) triple so publication and the
|
||||
# freshness lookup always see a consistent snapshot. Scanning itself stays
|
||||
# outside this lock.
|
||||
_publish_lock = threading.Lock()
|
||||
# Patterns for sanitizing skill names into clean hyphen-separated slugs.
|
||||
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
|
||||
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skill-scaffolding markers and the canonical extractor.
|
||||
#
|
||||
# When a user invokes a /skill (or /bundle), Hermes expands the turn into a
|
||||
# model-facing message that embeds the full skill body plus scaffolding. That
|
||||
# expanded text is what flows into the agent loop — and into memory providers
|
||||
# via MemoryManager. Providers that store or embed the raw user turn (mem0,
|
||||
# openviking, hindsight, retaindb, byterover, honcho, supermemory) would
|
||||
# otherwise capture the entire skill body instead of what the user actually
|
||||
# asked. ``extract_user_instruction_from_skill_message`` recovers just the
|
||||
# user's instruction so memory stays clean.
|
||||
#
|
||||
# These markers MUST stay byte-identical to the builders below
|
||||
# (``_build_skill_message`` here, ``build_bundle_invocation_message`` in
|
||||
# agent/skill_bundles.py). They are co-located with the single-skill builder
|
||||
# on purpose, and the bundle markers are asserted against the bundle builder in
|
||||
# tests/openviking_plugin/test_openviking.py::test_skill_markers_match_hermes_scaffolding.
|
||||
# ---------------------------------------------------------------------------
|
||||
_SKILL_INVOCATION_PREFIX = "[IMPORTANT: The user has invoked the "
|
||||
_SINGLE_SKILL_MARKER = "The full skill content is loaded below.]"
|
||||
_SINGLE_SKILL_INSTRUCTION = (
|
||||
"The user has provided the following instruction alongside the skill invocation: "
|
||||
)
|
||||
_RUNTIME_NOTE = "\n\n[Runtime note:"
|
||||
_BUNDLE_MARKER = " skill bundle,"
|
||||
_BUNDLE_USER_INSTRUCTION = "\nUser instruction: "
|
||||
_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the "
|
||||
|
||||
# The skill name sits in the first quoted span of the activation note, for both
|
||||
# the single-skill and the bundle header ("work" / "/clean /work").
|
||||
_SKILL_NAME_RE = re.compile(re.escape(_SKILL_INVOCATION_PREFIX) + r'"([^"]*)"')
|
||||
|
||||
# SQL LIKE pattern matching a skill-expanded turn, for listing queries that
|
||||
# have to recognize scaffolding before the row reaches Python. The prefix
|
||||
# contains no LIKE wildcards (`%`, `_`), so it needs no ESCAPE clause.
|
||||
SKILL_SCAFFOLD_SQL_LIKE = _SKILL_INVOCATION_PREFIX + "%"
|
||||
|
||||
# Marks where a preview query joined the head and tail of a long scaffolded
|
||||
# message. ``describe_skill_invocation`` may hand back a span that runs across
|
||||
# the joint (a bundle instruction cut off by the head window); callers cut the
|
||||
# description there rather than show the skill body on the far side.
|
||||
SKILL_EXCERPT_JOINT = "\x1e"
|
||||
|
||||
|
||||
def append_user_instruction(parts: list, instruction: str) -> str:
|
||||
"""Append the instruction line to ``parts``; return the stable prefix.
|
||||
|
||||
Shared by every builder that ends a static skill scaffold with the
|
||||
caller-supplied volatile instruction (single-skill invocations, cron job
|
||||
prompts). The returned prefix ends exactly at the instruction marker, so
|
||||
registering it with ``agent.prompt_cache_boundary`` lets the Anthropic
|
||||
cache planner put a breakpoint on the scaffold instead of caching the
|
||||
whole message as one atomic block (#81867). Keeping construction in one
|
||||
place guarantees the registered prefix stays a byte-prefix of the built
|
||||
message — the invariant the request-time split depends on.
|
||||
"""
|
||||
stable_prefix = "\n".join(parts) + "\n" + _SINGLE_SKILL_INSTRUCTION
|
||||
parts.append(f"{_SINGLE_SKILL_INSTRUCTION}{instruction}")
|
||||
return stable_prefix
|
||||
|
||||
|
||||
def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
|
||||
"""Recover the user's instruction from a slash-skill-expanded turn.
|
||||
|
||||
Returns:
|
||||
- The original string unchanged when it is NOT skill scaffolding
|
||||
(a normal user message passes straight through).
|
||||
- The extracted user instruction when the scaffolding carried one.
|
||||
- ``None`` when the content is skill scaffolding with no user
|
||||
instruction (i.e. a bare ``/skill`` invocation). Callers that feed
|
||||
memory providers should skip the turn in that case — there is no
|
||||
user content worth storing.
|
||||
"""
|
||||
if not isinstance(content, str):
|
||||
return None
|
||||
|
||||
if not content.startswith(_SKILL_INVOCATION_PREFIX):
|
||||
return content
|
||||
|
||||
if _BUNDLE_MARKER in content:
|
||||
return _extract_bundle_user_instruction(content)
|
||||
|
||||
if _SINGLE_SKILL_MARKER in content:
|
||||
return _extract_single_skill_user_instruction(content)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def describe_skill_invocation(content: Any, separator: str = " — ") -> Optional[str]:
|
||||
"""Render a slash-skill-expanded turn the way the user typed it.
|
||||
|
||||
The expanded message embeds the whole skill body, so any surface that
|
||||
summarizes a user turn from its raw content — session titles, sidebar
|
||||
previews, the ``/rewind`` picker — otherwise shows the skill's own prose
|
||||
as if the user had written it. That is how a skill's opening line ends up
|
||||
as a session title.
|
||||
|
||||
Returns ``"/work — fix the title leak"``, or ``"/work"`` for a bare
|
||||
invocation, or ``None`` when *content* is not skill scaffolding (the
|
||||
caller should then summarize it as an ordinary message).
|
||||
|
||||
*separator* joins the command and the instruction. Previews use the
|
||||
default em dash; pass ``" "`` for the literal invocation the user typed,
|
||||
which is what chat transcripts render.
|
||||
"""
|
||||
if not isinstance(content, str) or not content.startswith(_SKILL_INVOCATION_PREFIX):
|
||||
return None
|
||||
|
||||
match = _SKILL_NAME_RE.match(content)
|
||||
name = (match.group(1) if match else "").strip()
|
||||
# Bundle headers already carry their typed "/a /b" keys; a single skill is
|
||||
# a bare name.
|
||||
label = name if name.startswith("/") else f"/{name}"
|
||||
|
||||
instruction = extract_user_instruction_from_skill_message(content)
|
||||
if instruction and instruction is not content:
|
||||
# An excerpted message (head + tail, joined by SKILL_EXCERPT_JOINT) can
|
||||
# put the joint inside the matched span — keep only the side the
|
||||
# instruction marker was found on.
|
||||
instruction = instruction.split(SKILL_EXCERPT_JOINT)[0]
|
||||
instruction = " ".join(instruction.split())
|
||||
if instruction:
|
||||
return f"{label}{separator}{instruction}" if name else instruction
|
||||
|
||||
return label if name else None
|
||||
|
||||
|
||||
def _extract_single_skill_user_instruction(message: str) -> Optional[str]:
|
||||
# Single-skill format appends the user instruction after the skill body, so
|
||||
# the last occurrence is the user-provided one; the body may quote this text.
|
||||
marker_idx = message.rfind(_SINGLE_SKILL_INSTRUCTION)
|
||||
if marker_idx < 0:
|
||||
return None
|
||||
|
||||
instruction = message[marker_idx + len(_SINGLE_SKILL_INSTRUCTION):]
|
||||
runtime_idx = instruction.find(_RUNTIME_NOTE)
|
||||
if runtime_idx >= 0:
|
||||
instruction = instruction[:runtime_idx]
|
||||
instruction = instruction.strip()
|
||||
return instruction or None
|
||||
|
||||
|
||||
def _extract_bundle_user_instruction(message: str) -> Optional[str]:
|
||||
# Bundle format puts the user instruction before the loaded skills, so the
|
||||
# first occurrence is the user-provided one.
|
||||
marker_idx = message.find(_BUNDLE_USER_INSTRUCTION)
|
||||
if marker_idx < 0:
|
||||
return None
|
||||
|
||||
instruction = message[marker_idx + len(_BUNDLE_USER_INSTRUCTION):]
|
||||
first_skill_idx = instruction.find(_BUNDLE_FIRST_SKILL_BLOCK)
|
||||
if first_skill_idx >= 0:
|
||||
instruction = instruction[:first_skill_idx]
|
||||
instruction = instruction.strip()
|
||||
return instruction or None
|
||||
|
||||
|
||||
def _resolve_skill_commands_platform() -> Optional[str]:
|
||||
"""Return the current platform scope used for disabled-skill filtering.
|
||||
|
||||
Used to detect when the active platform has shifted so
|
||||
:func:`get_skill_commands` can drop a stale cache that was populated
|
||||
for a different platform's ``skills.platform_disabled`` view (#14536).
|
||||
|
||||
Resolves from (in order) ``HERMES_PLATFORM`` env var and
|
||||
``HERMES_SESSION_PLATFORM`` from the gateway session context. Returns
|
||||
``None`` when no platform scope is active (e.g. classic CLI, RL
|
||||
rollouts, standalone scripts).
|
||||
"""
|
||||
try:
|
||||
from gateway.session_context import get_session_env
|
||||
|
||||
resolved_platform = (
|
||||
os.getenv("HERMES_PLATFORM")
|
||||
or get_session_env("HERMES_SESSION_PLATFORM")
|
||||
)
|
||||
except Exception:
|
||||
resolved_platform = os.getenv("HERMES_PLATFORM")
|
||||
return resolved_platform or None
|
||||
|
||||
|
||||
def _resolve_skill_commands_home() -> str:
|
||||
"""Return the effective Hermes home the skill scan should be scoped to.
|
||||
|
||||
A gateway session can switch between profiles that each carry their own
|
||||
``skills.external_dirs`` (via ``set_hermes_home_override``), but the
|
||||
module-level scan only tracked ``_resolve_skill_commands_platform()``.
|
||||
Switching profiles without a platform change left the previous profile's
|
||||
skill list cached, so ``get_skill_commands()`` reported a cache miss for
|
||||
skills that only exist under the new profile (#88023).
|
||||
"""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
return str(get_hermes_home())
|
||||
|
||||
|
||||
def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tuple[dict[str, Any], Path | None, str] | None:
|
||||
"""Load a skill by name/path and return (loaded_payload, skill_dir, display_name)."""
|
||||
raw_identifier = (skill_identifier or "").strip()
|
||||
if not raw_identifier:
|
||||
return None
|
||||
|
||||
try:
|
||||
from tools.skills_tool import _skills_dir, skill_view
|
||||
from agent.skill_utils import normalize_skill_lookup_name
|
||||
|
||||
normalized = normalize_skill_lookup_name(raw_identifier)
|
||||
|
||||
loaded_skill = json.loads(
|
||||
skill_view(normalized, task_id=task_id, preprocess=False)
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not loaded_skill.get("success"):
|
||||
return None
|
||||
|
||||
skill_name = str(loaded_skill.get("name") or normalized)
|
||||
skill_path = str(loaded_skill.get("path") or "")
|
||||
skill_dir = None
|
||||
# Prefer the absolute skill_dir returned by skill_view() — this is
|
||||
# correct for both local and external skills. Fall back to the old
|
||||
# SKILLS_DIR-relative reconstruction only when skill_dir is absent
|
||||
# (e.g. legacy skill_view responses).
|
||||
abs_skill_dir = loaded_skill.get("skill_dir")
|
||||
if abs_skill_dir:
|
||||
skill_dir = Path(abs_skill_dir)
|
||||
elif skill_path:
|
||||
try:
|
||||
skill_dir = _skills_dir() / Path(skill_path).parent
|
||||
except Exception:
|
||||
skill_dir = None
|
||||
|
||||
return loaded_skill, skill_dir, skill_name
|
||||
|
||||
|
||||
def _inject_skill_config(loaded_skill: dict[str, Any], parts: list[str]) -> None:
|
||||
"""Resolve and inject skill-declared config values into the message parts.
|
||||
|
||||
If the loaded skill's frontmatter declares ``metadata.hermes.config``
|
||||
entries, their current values (from config.yaml or defaults) are appended
|
||||
as a ``[Skill config: ...]`` block so the agent knows the configured values
|
||||
without needing to read config.yaml itself.
|
||||
"""
|
||||
try:
|
||||
from agent.skill_utils import (
|
||||
extract_skill_config_vars,
|
||||
parse_frontmatter,
|
||||
resolve_skill_config_values,
|
||||
)
|
||||
|
||||
# The loaded_skill dict contains the raw content which includes frontmatter
|
||||
raw_content = str(loaded_skill.get("raw_content") or loaded_skill.get("content") or "")
|
||||
if not raw_content:
|
||||
return
|
||||
|
||||
frontmatter, _ = parse_frontmatter(raw_content)
|
||||
config_vars = extract_skill_config_vars(frontmatter)
|
||||
if not config_vars:
|
||||
return
|
||||
|
||||
resolved = resolve_skill_config_values(config_vars)
|
||||
if not resolved:
|
||||
return
|
||||
|
||||
lines = ["", f"[Skill config (from {display_hermes_home()}/config.yaml):"]
|
||||
for key, value in resolved.items():
|
||||
display_val = str(value) if value else "(not set)"
|
||||
lines.append(f" {key} = {display_val}")
|
||||
lines.append("]")
|
||||
parts.extend(lines)
|
||||
except Exception:
|
||||
pass # Non-critical — skill still loads without config injection
|
||||
|
||||
|
||||
def _build_skill_message(
|
||||
loaded_skill: dict[str, Any],
|
||||
skill_dir: Path | None,
|
||||
activation_note: str,
|
||||
user_instruction: str = "",
|
||||
runtime_note: str = "",
|
||||
session_id: str | None = None,
|
||||
) -> str:
|
||||
"""Format a loaded skill into a user/system message payload."""
|
||||
from tools.skills_tool import _skills_dir
|
||||
|
||||
content = str(loaded_skill.get("content") or "")
|
||||
|
||||
# ── Template substitution and inline-shell expansion ──
|
||||
# Done before anything else so downstream blocks (setup notes,
|
||||
# supporting-file hints) see the expanded content.
|
||||
skills_cfg = _load_skills_config()
|
||||
if skills_cfg.get("template_vars", True):
|
||||
content = _substitute_template_vars(content, skill_dir, session_id)
|
||||
if skills_cfg.get("inline_shell", False):
|
||||
timeout = int(skills_cfg.get("inline_shell_timeout", 10) or 10)
|
||||
content = _expand_inline_shell(content, skill_dir, timeout)
|
||||
|
||||
parts = [activation_note, "", content.strip()]
|
||||
|
||||
# ── Inject the absolute skill directory so the agent can reference
|
||||
# bundled scripts without an extra skill_view() round-trip. ──
|
||||
if skill_dir:
|
||||
parts.append("")
|
||||
parts.append(f"[Skill directory: {skill_dir}]")
|
||||
parts.append(
|
||||
"Resolve any relative paths in this skill (e.g. `scripts/foo.js`, "
|
||||
"`templates/config.yaml`) against that directory, then run them "
|
||||
"with the terminal tool using the absolute path."
|
||||
)
|
||||
|
||||
# ── Inject resolved skill config values ──
|
||||
_inject_skill_config(loaded_skill, parts)
|
||||
|
||||
if loaded_skill.get("setup_skipped"):
|
||||
parts.extend(
|
||||
[
|
||||
"",
|
||||
"[Skill setup note: Required environment setup was skipped. Continue loading the skill and explain any reduced functionality if it matters.]",
|
||||
]
|
||||
)
|
||||
elif loaded_skill.get("gateway_setup_hint"):
|
||||
parts.extend(
|
||||
[
|
||||
"",
|
||||
f"[Skill setup note: {loaded_skill['gateway_setup_hint']}]",
|
||||
]
|
||||
)
|
||||
elif loaded_skill.get("setup_needed") and loaded_skill.get("setup_note"):
|
||||
parts.extend(
|
||||
[
|
||||
"",
|
||||
f"[Skill setup note: {loaded_skill['setup_note']}]",
|
||||
]
|
||||
)
|
||||
|
||||
supporting = []
|
||||
linked_files = loaded_skill.get("linked_files") or {}
|
||||
for entries in linked_files.values():
|
||||
if isinstance(entries, list):
|
||||
supporting.extend(entries)
|
||||
|
||||
if not supporting and skill_dir:
|
||||
for subdir in ("references", "templates", "scripts", "assets"):
|
||||
subdir_path = skill_dir / subdir
|
||||
if subdir_path.exists():
|
||||
for f in sorted(subdir_path.rglob("*")):
|
||||
if f.is_file() and not f.is_symlink():
|
||||
rel = str(f.relative_to(skill_dir))
|
||||
supporting.append(rel)
|
||||
|
||||
if supporting and skill_dir:
|
||||
try:
|
||||
skill_view_target = str(skill_dir.relative_to(_skills_dir()))
|
||||
except ValueError:
|
||||
# Skill is from an external dir — use the skill name instead
|
||||
skill_view_target = skill_dir.name
|
||||
parts.append("")
|
||||
parts.append(
|
||||
"[This skill has supporting files (paths relative to the skill "
|
||||
"directory above):]"
|
||||
)
|
||||
for sf in supporting:
|
||||
parts.append(f"- {sf}")
|
||||
parts.append(
|
||||
f'\nLoad any of these with skill_view(name="{skill_view_target}", '
|
||||
f'file_path="<path>"), or run scripts directly by absolute path '
|
||||
f"(e.g. `node {skill_dir}/scripts/foo.js`)."
|
||||
)
|
||||
|
||||
stable_prefix = None
|
||||
if user_instruction:
|
||||
parts.append("")
|
||||
# Everything before the caller-supplied instruction is a stable
|
||||
# scaffold; declare the exact boundary so the Anthropic cache planner
|
||||
# can put a breakpoint on it instead of caching the whole message as
|
||||
# one atomic block (#81867). The static instruction prose stays on
|
||||
# the stable side; the volatile instruction (webhook payload, ticket
|
||||
# IDs, timestamps) and any runtime note ride in the tail.
|
||||
stable_prefix = append_user_instruction(parts, user_instruction)
|
||||
|
||||
if runtime_note:
|
||||
parts.append("")
|
||||
parts.append(f"[Runtime note: {runtime_note}]")
|
||||
|
||||
message = "\n".join(parts)
|
||||
if stable_prefix is not None and message.startswith(stable_prefix) and len(message) > len(stable_prefix):
|
||||
register_stable_prefix(stable_prefix)
|
||||
return message
|
||||
|
||||
|
||||
def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||
"""Scan ~/.hermes/skills/ and return a mapping of /command -> skill info.
|
||||
|
||||
Returns:
|
||||
Dict mapping "/skill-name" to {name, description, skill_md_path, skill_dir}.
|
||||
"""
|
||||
global _skill_commands, _skill_commands_platform, _skill_commands_home
|
||||
platform = _resolve_skill_commands_platform()
|
||||
home = _resolve_skill_commands_home()
|
||||
# Build into a local map and publish once, at the end. Writing straight
|
||||
# into the global made a scan's partial results visible to everything
|
||||
# else in the process: a second, overlapping scan deduped against its own
|
||||
# (empty) ``seen_names`` but collided against the first scan's already-
|
||||
# published slugs, logging one bogus "already claimed" warning per skill —
|
||||
# each naming the same skill as its own incumbent (#74574).
|
||||
commands: Dict[str, Dict[str, Any]] = {}
|
||||
try:
|
||||
from tools.skills_tool import _skills_dir, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names
|
||||
from agent.skill_utils import (
|
||||
get_external_skills_dirs,
|
||||
get_project_skills_dirs,
|
||||
iter_project_skill_files,
|
||||
iter_skill_index_files,
|
||||
)
|
||||
from hermes_cli.commands import resolve_command
|
||||
disabled = _get_disabled_skill_names()
|
||||
seen_names: set = set()
|
||||
|
||||
# Scan project dirs first (highest precedence), then local, then external.
|
||||
# Project dirs iterate through the quarantine chokepoint.
|
||||
project_dirs = list(get_project_skills_dirs())
|
||||
dirs_to_scan = list(project_dirs)
|
||||
# Resolve at call time: the import-time SKILLS_DIR is frozen to the
|
||||
# launch home, so a multiplexed profile scope (set_hermes_home_override)
|
||||
# would still scan the default profile's skills (#67277).
|
||||
skills_dir = _skills_dir()
|
||||
if skills_dir.exists():
|
||||
dirs_to_scan.append(skills_dir)
|
||||
dirs_to_scan.extend(get_external_skills_dirs())
|
||||
|
||||
for scan_dir in dirs_to_scan:
|
||||
_iter = (
|
||||
iter_project_skill_files(scan_dir)
|
||||
if scan_dir in project_dirs
|
||||
else iter_skill_index_files(scan_dir, "SKILL.md")
|
||||
)
|
||||
for skill_md in _iter:
|
||||
if any(part in {'.git', '.github', '.hub', '.archive'} for part in skill_md.parts):
|
||||
continue
|
||||
try:
|
||||
content = skill_md.read_text(encoding='utf-8')
|
||||
frontmatter, body = _parse_frontmatter(content)
|
||||
# Skip skills incompatible with the current OS platform
|
||||
if not skill_matches_platform(frontmatter):
|
||||
continue
|
||||
# Skip skills not relevant to the current runtime env
|
||||
# (kanban/docker/s6). Offer-time only; explicit load bypasses.
|
||||
if not skill_matches_environment(frontmatter):
|
||||
continue
|
||||
name = frontmatter.get('name', skill_md.parent.name)
|
||||
if name in seen_names:
|
||||
continue
|
||||
# Respect user's disabled skills config
|
||||
if name in disabled:
|
||||
continue
|
||||
description = frontmatter.get('description', '')
|
||||
if not description:
|
||||
for line in body.strip().split('\n'):
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
description = line[:80]
|
||||
break
|
||||
seen_names.add(name)
|
||||
# Normalize to hyphen-separated slug, stripping
|
||||
# non-alnum chars (e.g. +, /) to avoid invalid
|
||||
# Telegram command names downstream.
|
||||
cmd_name = name.lower().replace(' ', '-').replace('_', '-')
|
||||
cmd_name = _SKILL_INVALID_CHARS.sub('', cmd_name)
|
||||
cmd_name = _SKILL_MULTI_HYPHEN.sub('-', cmd_name).strip('-')
|
||||
if not cmd_name:
|
||||
continue
|
||||
# Skip if this skill's auto-generated /command collides
|
||||
# with a core Hermes slash command (name or alias). The
|
||||
# skill remains fully loadable via /skill <name>.
|
||||
# Uses resolve_command() so aliases and case variants are
|
||||
# covered without maintaining a separate cache.
|
||||
if resolve_command(cmd_name) is not None:
|
||||
logger.warning(
|
||||
"Skill %r generates slash command '/%s' which "
|
||||
"collides with a core Hermes command; skipping "
|
||||
"auto-registration. Use '/skill %s' instead.",
|
||||
name, cmd_name, name,
|
||||
)
|
||||
continue
|
||||
# Dedup on the resolved slug, not just the raw name: two
|
||||
# distinct frontmatter names can normalize to the same
|
||||
# slug (e.g. "git_helper" vs "git-helper"). First-wins
|
||||
# preserves local-before-external precedence.
|
||||
cmd_key = f"/{cmd_name}"
|
||||
if cmd_key in commands:
|
||||
logger.warning(
|
||||
"Skill %r maps to slash command %s already claimed "
|
||||
"by %r; keeping the first and skipping this one.",
|
||||
name, cmd_key, commands[cmd_key]["name"],
|
||||
)
|
||||
continue
|
||||
commands[cmd_key] = {
|
||||
"name": name,
|
||||
"description": description or f"Invoke the {name} skill",
|
||||
"skill_md_path": str(skill_md),
|
||||
"skill_dir": str(skill_md.parent),
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
# Publish the finished map and the platform/home it was scanned for as
|
||||
# ONE step. Bare assignments are not atomic together: a reader landing
|
||||
# between them sees the NEW map still carrying the OLD platform tag, and
|
||||
# if that stale tag happens to match its own platform it accepts the map
|
||||
# without rescanning — serving another platform's disabled-skill view,
|
||||
# exactly the leak #14536 closed. Only the publish/lookup pair is locked;
|
||||
# the scan above (file I/O, deferred imports) stays outside it.
|
||||
with _publish_lock:
|
||||
_skill_commands = commands
|
||||
_skill_commands_platform = platform
|
||||
_skill_commands_home = home
|
||||
return commands
|
||||
|
||||
|
||||
def get_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||
"""Return the current skill commands mapping (scan first if empty).
|
||||
|
||||
Rescans when the active platform scope changes (e.g. a gateway
|
||||
process serving Telegram and Discord concurrently) so each platform
|
||||
sees its own ``skills.platform_disabled`` view (#14536), and when the
|
||||
active profile's Hermes home changes (e.g. Desktop switching profiles
|
||||
mid-session) so each profile sees its own ``skills.external_dirs`` (#88023).
|
||||
"""
|
||||
current_platform = _resolve_skill_commands_platform()
|
||||
current_home = _resolve_skill_commands_home()
|
||||
# Read the map and its tags under the same lock that publishes them, so
|
||||
# the freshness decision is made against a consistent snapshot.
|
||||
with _publish_lock:
|
||||
commands = _skill_commands
|
||||
is_fresh = (
|
||||
bool(commands)
|
||||
and _skill_commands_platform == current_platform
|
||||
and _skill_commands_home == current_home
|
||||
)
|
||||
if is_fresh:
|
||||
return commands
|
||||
# Scan outside the lock — it does file I/O and deferred imports, and
|
||||
# concurrent scans are already safe (each builds its own map).
|
||||
return scan_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. Note: the system prompt skill index
|
||||
truncates this to the first 57 chars; see ``extract_skill_description``.
|
||||
"""
|
||||
# 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.
|
||||
|
||||
Skills are always stored with hyphens — ``scan_skill_commands`` normalizes
|
||||
spaces and underscores to hyphens when building the key. Hyphens and
|
||||
underscores are treated interchangeably in user input: this matches
|
||||
``_check_unavailable_skill`` and accommodates Telegram bot-command names
|
||||
(which disallow hyphens, so ``/claude-code`` is registered as
|
||||
``/claude_code`` and comes back in the underscored form).
|
||||
|
||||
Returns the matching ``/slug`` key from ``get_skill_commands()`` or
|
||||
``None`` if no match.
|
||||
"""
|
||||
if not command:
|
||||
return None
|
||||
cmd_key = f"/{command.replace('_', '-')}"
|
||||
return cmd_key if cmd_key in get_skill_commands() else None
|
||||
|
||||
|
||||
def build_skill_invocation_message(
|
||||
cmd_key: str,
|
||||
user_instruction: str = "",
|
||||
task_id: str | None = None,
|
||||
runtime_note: str = "",
|
||||
) -> Optional[str]:
|
||||
"""Build the user message content for a skill slash command invocation.
|
||||
|
||||
Args:
|
||||
cmd_key: The command key including leading slash (e.g., "/gif-search").
|
||||
user_instruction: Optional text the user typed after the command.
|
||||
|
||||
Returns:
|
||||
The formatted message string, or None if the skill wasn't found.
|
||||
"""
|
||||
commands = get_skill_commands()
|
||||
skill_info = commands.get(cmd_key)
|
||||
if not skill_info:
|
||||
return None
|
||||
|
||||
loaded = _load_skill_payload(skill_info["skill_dir"], task_id=task_id)
|
||||
if not loaded:
|
||||
return None
|
||||
|
||||
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, task_id=task_id)
|
||||
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.]"
|
||||
)
|
||||
return _build_skill_message(
|
||||
loaded_skill,
|
||||
skill_dir,
|
||||
activation_note,
|
||||
user_instruction=user_instruction,
|
||||
runtime_note=runtime_note,
|
||||
session_id=task_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stacked slash-skill invocations — `/skill-a /skill-b do XYZ` loads every
|
||||
# leading skill (up to _MAX_STACKED_SKILLS), not just the first.
|
||||
#
|
||||
# Inspired by Claude Code v2.1.199 (July 2, 2026): "Stacked slash-skill
|
||||
# invocations like /skill-a /skill-b do XYZ now load all leading skills
|
||||
# (up to 5), not just the first."
|
||||
#
|
||||
# The generated message deliberately reuses the BUNDLE scaffolding markers
|
||||
# ("skill bundle," header + "[Loaded as part of the " block prefix) so
|
||||
# extract_user_instruction_from_skill_message() recovers the user's
|
||||
# instruction without any new marker plumbing — memory providers keep
|
||||
# storing what the user actually asked, not N skill bodies.
|
||||
# ---------------------------------------------------------------------------
|
||||
_MAX_STACKED_SKILLS = 5
|
||||
|
||||
|
||||
def split_stacked_skill_commands(rest: str) -> tuple[list[str], str]:
|
||||
"""Consume additional leading ``/skill`` tokens from *rest*.
|
||||
|
||||
*rest* is the text that follows the FIRST matched skill command (the
|
||||
caller has already resolved that one). Leading whitespace-delimited
|
||||
tokens that start with ``/`` and resolve to installed skill commands are
|
||||
consumed, up to ``_MAX_STACKED_SKILLS`` total leading skills (i.e. at
|
||||
most ``_MAX_STACKED_SKILLS - 1`` extra keys here). Parsing stops at the
|
||||
first token that is not a resolvable skill command — that token and
|
||||
everything after it become the user instruction.
|
||||
|
||||
Returns:
|
||||
``(extra_cmd_keys, remaining_instruction)`` where ``extra_cmd_keys``
|
||||
are canonical ``/slug`` keys from :func:`get_skill_commands`.
|
||||
"""
|
||||
keys: list[str] = []
|
||||
remaining = rest or ""
|
||||
while len(keys) < _MAX_STACKED_SKILLS - 1:
|
||||
stripped = remaining.lstrip()
|
||||
if not stripped.startswith("/"):
|
||||
break
|
||||
parts = stripped.split(None, 1)
|
||||
token = parts[0]
|
||||
tail = parts[1] if len(parts) > 1 else ""
|
||||
cmd_key = resolve_skill_command_key(token.lstrip("/"))
|
||||
if cmd_key is None or cmd_key in keys:
|
||||
break
|
||||
keys.append(cmd_key)
|
||||
remaining = tail
|
||||
return keys, remaining.strip()
|
||||
|
||||
|
||||
def build_stacked_skill_invocation_message(
|
||||
cmd_keys: list[str],
|
||||
user_instruction: str = "",
|
||||
task_id: str | None = None,
|
||||
) -> Optional[tuple[str, list[str], list[str]]]:
|
||||
"""Build the user message for a stacked multi-skill slash invocation.
|
||||
|
||||
Args:
|
||||
cmd_keys: Canonical ``/slug`` keys, in the order the user typed them.
|
||||
user_instruction: Text remaining after the leading skill commands.
|
||||
|
||||
Returns:
|
||||
``(message, loaded_skill_names, missing_skill_names)`` or ``None``
|
||||
when no skill could be loaded at all.
|
||||
"""
|
||||
commands = get_skill_commands()
|
||||
|
||||
loaded_names: list[str] = []
|
||||
missing: list[str] = []
|
||||
skill_blocks: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for cmd_key in cmd_keys:
|
||||
if not cmd_key or cmd_key in seen:
|
||||
continue
|
||||
seen.add(cmd_key)
|
||||
|
||||
skill_info = commands.get(cmd_key)
|
||||
if not skill_info:
|
||||
missing.append(cmd_key.lstrip("/"))
|
||||
continue
|
||||
|
||||
loaded = _load_skill_payload(skill_info["skill_dir"], task_id=task_id)
|
||||
if not loaded:
|
||||
missing.append(cmd_key.lstrip("/"))
|
||||
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, task_id=task_id)
|
||||
except Exception:
|
||||
pass # Non-critical
|
||||
|
||||
# NOTE: must start with "[Loaded as part of the " — that prefix is
|
||||
# the bundle block marker the memory-scaffolding extractor cuts on.
|
||||
activation_note = (
|
||||
f'[Loaded as part of the stacked skill invocation "{skill_name}".]'
|
||||
)
|
||||
skill_blocks.append(
|
||||
_build_skill_message(
|
||||
loaded_skill,
|
||||
skill_dir,
|
||||
activation_note,
|
||||
session_id=task_id,
|
||||
)
|
||||
)
|
||||
loaded_names.append(skill_name)
|
||||
|
||||
if not skill_blocks:
|
||||
return None
|
||||
|
||||
# Header — must contain " skill bundle," so the bundle-format extractor
|
||||
# in extract_user_instruction_from_skill_message() applies unchanged.
|
||||
typed = " ".join(k for k in cmd_keys if k)
|
||||
header_lines = [
|
||||
f'[IMPORTANT: The user has invoked the "{typed}" stacked skill bundle, '
|
||||
f"loading {len(loaded_names)} skills together. Treat every skill below "
|
||||
"as active guidance for this turn.]",
|
||||
"",
|
||||
f"Skills loaded: {', '.join(loaded_names)}",
|
||||
]
|
||||
if missing:
|
||||
header_lines.append(f"Skills missing (skipped): {', '.join(missing)}")
|
||||
if user_instruction:
|
||||
header_lines.extend(["", f"User instruction: {user_instruction}"])
|
||||
|
||||
header = "\n".join(header_lines)
|
||||
return ("\n\n".join([header, *skill_blocks]), loaded_names, missing)
|
||||
|
||||
|
||||
def build_preloaded_skills_prompt(
|
||||
skill_identifiers: list[str],
|
||||
task_id: str | None = None,
|
||||
) -> tuple[str, list[str], list[str]]:
|
||||
"""Load one or more skills for session-wide CLI/TUI preloading.
|
||||
|
||||
Returns (prompt_text, loaded_skill_names, missing_identifiers).
|
||||
|
||||
Disabled skills are treated the same as missing ones: this loads via a
|
||||
raw identifier straight into ``_load_skill_payload``, bypassing
|
||||
``get_skill_commands()``'s scan-time disabled filter — mirrors the
|
||||
bundle-invocation gate (#59156). Without this, ``hermes -s <skill>`` or
|
||||
a deployment's ``HERMES_TUI_SKILLS`` env var could force-load a skill an
|
||||
operator disabled via ``skills.disabled``/``skills.platform_disabled``.
|
||||
"""
|
||||
prompt_parts: list[str] = []
|
||||
loaded_names: list[str] = []
|
||||
missing: list[str] = []
|
||||
|
||||
try:
|
||||
from agent.skill_utils import get_disabled_skill_names
|
||||
disabled_names = get_disabled_skill_names()
|
||||
except Exception:
|
||||
disabled_names = set()
|
||||
|
||||
seen: set[str] = set()
|
||||
for raw_identifier in skill_identifiers:
|
||||
identifier = (raw_identifier or "").strip()
|
||||
if not identifier or identifier in seen:
|
||||
continue
|
||||
seen.add(identifier)
|
||||
|
||||
loaded = _load_skill_payload(identifier, task_id=task_id)
|
||||
if not loaded:
|
||||
missing.append(identifier)
|
||||
continue
|
||||
|
||||
loaded_skill, skill_dir, skill_name = loaded
|
||||
|
||||
if skill_name in disabled_names or identifier in disabled_names:
|
||||
missing.append(identifier)
|
||||
continue
|
||||
|
||||
# Track active usage for Curator lifecycle management (#17782)
|
||||
try:
|
||||
from tools.skill_usage import bump_use
|
||||
bump_use(skill_name, task_id=task_id)
|
||||
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 "
|
||||
"session unless the user overrides them.]"
|
||||
)
|
||||
prompt_parts.append(
|
||||
_build_skill_message(
|
||||
loaded_skill,
|
||||
skill_dir,
|
||||
activation_note,
|
||||
session_id=task_id,
|
||||
)
|
||||
)
|
||||
loaded_names.append(skill_name)
|
||||
|
||||
return "\n\n".join(prompt_parts), loaded_names, missing
|
||||
Reference in New Issue
Block a user