hermes-bsd/agent/skill_utils.py

768 lines
28 KiB
Python
Raw Normal View History

"""Lightweight skill metadata utilities shared by prompt_builder and skills_tool.
This module intentionally avoids importing the tool registry, CLI config, or any
heavy dependency chain. It is safe to import at module level without triggering
tool registration or provider resolution.
"""
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
fix(skills): load Linux-tagged skills on Termux (android sys.platform) Reported by @LikiusInik in Discord: on Termux only 3 built-in skills appeared and /gh-pr-workflow + every other slash-skill from github/productivity/mlops was missing. Root cause: skill_matches_platform() compares sys.platform.startswith() against the skill's platforms list. Termux is a Linux userland on Android, but Python 3.13+ reports sys.platform == "android" instead of "linux" — so the ~60 built-in skills tagged platforms:[linux,macos, windows] (github-pr-workflow, google-workspace, github-auth, huggingface-hub, etc.) all got filtered out at the listing step in tools/skills_tool.py:_find_all_skills and never appeared as /slash commands or in skill_view. Fix: when is_termux() detects we're running inside Termux, accept "linux" platform tags regardless of whether sys.platform is "linux" (pre-3.13) or "android" (3.13+). Also accept explicit platforms:[termux] / [android] tags. macOS-only and Windows-only skills correctly remain excluded. E2E (simulated TERMUX_VERSION=set + sys.platform="android"): Before: _find_all_skills() returned ~3 skills. After: _find_all_skills() returns 84 skills including github-pr-workflow, google-workspace, github-auth, huggingface-hub. Apple-only skills remain excluded. Non-Termux Linux/macOS/Windows behavior unchanged (verified). Tests: tests/agent/test_skill_utils.py — 9 new cases covering android-as-Termux, the [linux,macos,windows] case, macOS-only exclusion, explicit termux/android tags, non-Termux Android safety, and unchanged behavior on real Linux/macOS.
2026-05-21 17:24:41 -07:00
from hermes_constants import get_config_path, get_skills_dir, is_termux
logger = logging.getLogger(__name__)
# ── Platform mapping ──────────────────────────────────────────────────────
PLATFORM_MAP = {
"macos": "darwin",
"linux": "linux",
"windows": "win32",
}
fix(skills): prune dependency/venv dirs from all skill scanners (#30042) * fix(skills): skip dependency dirs in skill scan * fix(skills): widen sibling rglob scanners to use shared exclusion set Follow-up to PR #29968. The contributor's PR widened EXCLUDED_SKILL_DIRS in the canonical walker (iter_skill_index_files), which fixes the user-visible discovery path. This commit sweeps the ~12 other rglob('SKILL.md') sites that did their own ad-hoc filtering — most only checked .git/.hub, some had no filter at all — so dependency dirs (.venv, node_modules, site-packages, etc.) cannot leak ghost skills through the secondary paths. Adds agent.skill_utils.is_excluded_skill_path(path) helper. Migrates all 13 sites to use it. Removes 3 hardcoded duplicate filter sets. Sites touched: agent/curator_backup.py - skill backup file count gateway/run.py - disabled-skill response (2 sites) hermes_cli/dump.py - skill count in env dump hermes_cli/profile_describer.py- profile description (2 sites) hermes_cli/profile_distribution.py - profile install count hermes_cli/profiles.py - profile skill count hermes_cli/skills_hub.py - category detection tools/skill_manager_tool.py - skill name lookup (already used set, now uses helper) tools/skill_usage.py - usage tracking + skill dir lookup (2 sites) tools/skills_hub.py - optional skills find + scan (2 sites) tools/skills_sync.py - bundled skills sync E2E verified with the exact reported shape (bring/scripts/.venv/.../typer/.agents/skills/typer/SKILL.md): no sibling site picks up the ghost skill, all five legit-skill counts still return 1. * chore(infographic): retro-pop-grid bento for PR #30042 skill-scanner sweep --------- Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com>
2026-05-21 14:18:02 -07:00
EXCLUDED_SKILL_DIRS = frozenset(
(
".git",
".github",
".hub",
".archive",
".venv",
"venv",
"node_modules",
"site-packages",
"__pycache__",
".tox",
".nox",
".pytest_cache",
".mypy_cache",
".ruff_cache",
)
)
# Supporting files live inside a skill package and are loaded explicitly via
# skill_view(skill, file_path=...). They are not standalone skills and must not
# be scanned for active SKILL.md/DESCRIPTION.md entries, even if a Curator or
# archive workflow preserves a complete old skill package under references/.
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
fix(skills): prune dependency/venv dirs from all skill scanners (#30042) * fix(skills): skip dependency dirs in skill scan * fix(skills): widen sibling rglob scanners to use shared exclusion set Follow-up to PR #29968. The contributor's PR widened EXCLUDED_SKILL_DIRS in the canonical walker (iter_skill_index_files), which fixes the user-visible discovery path. This commit sweeps the ~12 other rglob('SKILL.md') sites that did their own ad-hoc filtering — most only checked .git/.hub, some had no filter at all — so dependency dirs (.venv, node_modules, site-packages, etc.) cannot leak ghost skills through the secondary paths. Adds agent.skill_utils.is_excluded_skill_path(path) helper. Migrates all 13 sites to use it. Removes 3 hardcoded duplicate filter sets. Sites touched: agent/curator_backup.py - skill backup file count gateway/run.py - disabled-skill response (2 sites) hermes_cli/dump.py - skill count in env dump hermes_cli/profile_describer.py- profile description (2 sites) hermes_cli/profile_distribution.py - profile install count hermes_cli/profiles.py - profile skill count hermes_cli/skills_hub.py - category detection tools/skill_manager_tool.py - skill name lookup (already used set, now uses helper) tools/skill_usage.py - usage tracking + skill dir lookup (2 sites) tools/skills_hub.py - optional skills find + scan (2 sites) tools/skills_sync.py - bundled skills sync E2E verified with the exact reported shape (bring/scripts/.venv/.../typer/.agents/skills/typer/SKILL.md): no sibling site picks up the ghost skill, all five legit-skill counts still return 1. * chore(infographic): retro-pop-grid bento for PR #30042 skill-scanner sweep --------- Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com>
2026-05-21 14:18:02 -07:00
def is_excluded_skill_path(path) -> bool:
"""True if *path* should be skipped by active skill scanners.
fix(skills): prune dependency/venv dirs from all skill scanners (#30042) * fix(skills): skip dependency dirs in skill scan * fix(skills): widen sibling rglob scanners to use shared exclusion set Follow-up to PR #29968. The contributor's PR widened EXCLUDED_SKILL_DIRS in the canonical walker (iter_skill_index_files), which fixes the user-visible discovery path. This commit sweeps the ~12 other rglob('SKILL.md') sites that did their own ad-hoc filtering — most only checked .git/.hub, some had no filter at all — so dependency dirs (.venv, node_modules, site-packages, etc.) cannot leak ghost skills through the secondary paths. Adds agent.skill_utils.is_excluded_skill_path(path) helper. Migrates all 13 sites to use it. Removes 3 hardcoded duplicate filter sets. Sites touched: agent/curator_backup.py - skill backup file count gateway/run.py - disabled-skill response (2 sites) hermes_cli/dump.py - skill count in env dump hermes_cli/profile_describer.py- profile description (2 sites) hermes_cli/profile_distribution.py - profile install count hermes_cli/profiles.py - profile skill count hermes_cli/skills_hub.py - category detection tools/skill_manager_tool.py - skill name lookup (already used set, now uses helper) tools/skill_usage.py - usage tracking + skill dir lookup (2 sites) tools/skills_hub.py - optional skills find + scan (2 sites) tools/skills_sync.py - bundled skills sync E2E verified with the exact reported shape (bring/scripts/.venv/.../typer/.agents/skills/typer/SKILL.md): no sibling site picks up the ghost skill, all five legit-skill counts still return 1. * chore(infographic): retro-pop-grid bento for PR #30042 skill-scanner sweep --------- Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com>
2026-05-21 14:18:02 -07:00
Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to
prune dependency, virtualenv, VCS, cache, and progressive-disclosure
support-package paths. Centralising the check here keeps every
skill-scanning site in sync with the shared exclusion set.
fix(skills): prune dependency/venv dirs from all skill scanners (#30042) * fix(skills): skip dependency dirs in skill scan * fix(skills): widen sibling rglob scanners to use shared exclusion set Follow-up to PR #29968. The contributor's PR widened EXCLUDED_SKILL_DIRS in the canonical walker (iter_skill_index_files), which fixes the user-visible discovery path. This commit sweeps the ~12 other rglob('SKILL.md') sites that did their own ad-hoc filtering — most only checked .git/.hub, some had no filter at all — so dependency dirs (.venv, node_modules, site-packages, etc.) cannot leak ghost skills through the secondary paths. Adds agent.skill_utils.is_excluded_skill_path(path) helper. Migrates all 13 sites to use it. Removes 3 hardcoded duplicate filter sets. Sites touched: agent/curator_backup.py - skill backup file count gateway/run.py - disabled-skill response (2 sites) hermes_cli/dump.py - skill count in env dump hermes_cli/profile_describer.py- profile description (2 sites) hermes_cli/profile_distribution.py - profile install count hermes_cli/profiles.py - profile skill count hermes_cli/skills_hub.py - category detection tools/skill_manager_tool.py - skill name lookup (already used set, now uses helper) tools/skill_usage.py - usage tracking + skill dir lookup (2 sites) tools/skills_hub.py - optional skills find + scan (2 sites) tools/skills_sync.py - bundled skills sync E2E verified with the exact reported shape (bring/scripts/.venv/.../typer/.agents/skills/typer/SKILL.md): no sibling site picks up the ghost skill, all five legit-skill counts still return 1. * chore(infographic): retro-pop-grid bento for PR #30042 skill-scanner sweep --------- Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com>
2026-05-21 14:18:02 -07:00
Accepts a Path or string.
"""
try:
parts = path.parts # Path
except AttributeError:
from pathlib import PurePath
parts = PurePath(str(path)).parts
return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path(
path
)
def is_skill_support_path(path) -> bool:
"""True if *path* is under a support dir of an actual skill root.
``references/``, ``templates/``, ``assets/``, and ``scripts/`` are
progressive-disclosure support areas when they sit directly inside a skill
directory containing ``SKILL.md``. They are not active discovery roots for
standalone skills. A preserved package such as
``some-skill/references/old-skill-package/SKILL.md`` is documentation data
unless the caller explicitly loads it via ``file_path``.
Legitimate categories or skill names such as ``skills/scripts/foo`` remain
discoverable because their ``scripts`` component is not directly under a
directory that contains ``SKILL.md``.
"""
path_obj = path if isinstance(path, Path) else Path(str(path))
parts = path_obj.parts
# Last component may be a file or candidate skill directory name. Only
# components before the leaf can be containing support directories.
for idx, part in enumerate(parts[:-1]):
if part not in SKILL_SUPPORT_DIRS or idx == 0:
continue
skill_root = Path(*parts[:idx])
if (skill_root / "SKILL.md").exists():
return True
return False
fix(skills): prune dependency/venv dirs from all skill scanners (#30042) * fix(skills): skip dependency dirs in skill scan * fix(skills): widen sibling rglob scanners to use shared exclusion set Follow-up to PR #29968. The contributor's PR widened EXCLUDED_SKILL_DIRS in the canonical walker (iter_skill_index_files), which fixes the user-visible discovery path. This commit sweeps the ~12 other rglob('SKILL.md') sites that did their own ad-hoc filtering — most only checked .git/.hub, some had no filter at all — so dependency dirs (.venv, node_modules, site-packages, etc.) cannot leak ghost skills through the secondary paths. Adds agent.skill_utils.is_excluded_skill_path(path) helper. Migrates all 13 sites to use it. Removes 3 hardcoded duplicate filter sets. Sites touched: agent/curator_backup.py - skill backup file count gateway/run.py - disabled-skill response (2 sites) hermes_cli/dump.py - skill count in env dump hermes_cli/profile_describer.py- profile description (2 sites) hermes_cli/profile_distribution.py - profile install count hermes_cli/profiles.py - profile skill count hermes_cli/skills_hub.py - category detection tools/skill_manager_tool.py - skill name lookup (already used set, now uses helper) tools/skill_usage.py - usage tracking + skill dir lookup (2 sites) tools/skills_hub.py - optional skills find + scan (2 sites) tools/skills_sync.py - bundled skills sync E2E verified with the exact reported shape (bring/scripts/.venv/.../typer/.agents/skills/typer/SKILL.md): no sibling site picks up the ghost skill, all five legit-skill counts still return 1. * chore(infographic): retro-pop-grid bento for PR #30042 skill-scanner sweep --------- Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com>
2026-05-21 14:18:02 -07:00
# ── Lazy YAML loader ─────────────────────────────────────────────────────
_yaml_load_fn = None
def yaml_load(content: str):
"""Parse YAML with lazy import and CSafeLoader preference."""
global _yaml_load_fn
if _yaml_load_fn is None:
import yaml
loader = getattr(yaml, "CSafeLoader", None) or yaml.SafeLoader
def _load(value: str):
return yaml.load(value, Loader=loader)
_yaml_load_fn = _load
return _yaml_load_fn(content)
# ── Frontmatter parsing ──────────────────────────────────────────────────
def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
"""Parse YAML frontmatter from a markdown string.
Uses yaml with CSafeLoader for full YAML support (nested metadata, lists)
with a fallback to simple key:value splitting for robustness.
Returns:
(frontmatter_dict, remaining_body)
"""
frontmatter: Dict[str, Any] = {}
body = content
if not content.startswith("---"):
return frontmatter, body
end_match = re.search(r"\n---\s*\n", content[3:])
if not end_match:
return frontmatter, body
yaml_content = content[3 : end_match.start() + 3]
body = content[end_match.end() + 3 :]
try:
parsed = yaml_load(yaml_content)
if isinstance(parsed, dict):
frontmatter = parsed
except Exception:
# Fallback: simple key:value parsing for malformed YAML
for line in yaml_content.strip().split("\n"):
if ":" not in line:
continue
key, value = line.split(":", 1)
frontmatter[key.strip()] = value.strip()
return frontmatter, body
# ── Platform matching ─────────────────────────────────────────────────────
def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
"""Return True when the skill is compatible with the current OS.
Skills declare platform requirements via a top-level ``platforms`` list
in their YAML frontmatter::
platforms: [macos] # macOS only
platforms: [macos, linux] # macOS and Linux
If the field is absent or empty the skill is compatible with **all**
platforms (backward-compatible default).
fix(skills): load Linux-tagged skills on Termux (android sys.platform) Reported by @LikiusInik in Discord: on Termux only 3 built-in skills appeared and /gh-pr-workflow + every other slash-skill from github/productivity/mlops was missing. Root cause: skill_matches_platform() compares sys.platform.startswith() against the skill's platforms list. Termux is a Linux userland on Android, but Python 3.13+ reports sys.platform == "android" instead of "linux" — so the ~60 built-in skills tagged platforms:[linux,macos, windows] (github-pr-workflow, google-workspace, github-auth, huggingface-hub, etc.) all got filtered out at the listing step in tools/skills_tool.py:_find_all_skills and never appeared as /slash commands or in skill_view. Fix: when is_termux() detects we're running inside Termux, accept "linux" platform tags regardless of whether sys.platform is "linux" (pre-3.13) or "android" (3.13+). Also accept explicit platforms:[termux] / [android] tags. macOS-only and Windows-only skills correctly remain excluded. E2E (simulated TERMUX_VERSION=set + sys.platform="android"): Before: _find_all_skills() returned ~3 skills. After: _find_all_skills() returns 84 skills including github-pr-workflow, google-workspace, github-auth, huggingface-hub. Apple-only skills remain excluded. Non-Termux Linux/macOS/Windows behavior unchanged (verified). Tests: tests/agent/test_skill_utils.py — 9 new cases covering android-as-Termux, the [linux,macos,windows] case, macOS-only exclusion, explicit termux/android tags, non-Termux Android safety, and unchanged behavior on real Linux/macOS.
2026-05-21 17:24:41 -07:00
Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
older Pythons but became ``"android"`` on Python 3.13+. Termux is a
Linux userland riding on the Android kernel, so skills tagged
``linux`` are treated as compatible in Termux regardless of which
``sys.platform`` value Python reports. Individual Linux commands
inside a skill may still misbehave (no systemd, BusyBox utils, no
apt/dnf, etc.) but that is on the skill, not on platform gating.
"""
platforms = frontmatter.get("platforms")
if not platforms:
return True
if not isinstance(platforms, list):
platforms = [platforms]
current = sys.platform
fix(skills): load Linux-tagged skills on Termux (android sys.platform) Reported by @LikiusInik in Discord: on Termux only 3 built-in skills appeared and /gh-pr-workflow + every other slash-skill from github/productivity/mlops was missing. Root cause: skill_matches_platform() compares sys.platform.startswith() against the skill's platforms list. Termux is a Linux userland on Android, but Python 3.13+ reports sys.platform == "android" instead of "linux" — so the ~60 built-in skills tagged platforms:[linux,macos, windows] (github-pr-workflow, google-workspace, github-auth, huggingface-hub, etc.) all got filtered out at the listing step in tools/skills_tool.py:_find_all_skills and never appeared as /slash commands or in skill_view. Fix: when is_termux() detects we're running inside Termux, accept "linux" platform tags regardless of whether sys.platform is "linux" (pre-3.13) or "android" (3.13+). Also accept explicit platforms:[termux] / [android] tags. macOS-only and Windows-only skills correctly remain excluded. E2E (simulated TERMUX_VERSION=set + sys.platform="android"): Before: _find_all_skills() returned ~3 skills. After: _find_all_skills() returns 84 skills including github-pr-workflow, google-workspace, github-auth, huggingface-hub. Apple-only skills remain excluded. Non-Termux Linux/macOS/Windows behavior unchanged (verified). Tests: tests/agent/test_skill_utils.py — 9 new cases covering android-as-Termux, the [linux,macos,windows] case, macOS-only exclusion, explicit termux/android tags, non-Termux Android safety, and unchanged behavior on real Linux/macOS.
2026-05-21 17:24:41 -07:00
running_in_termux = is_termux()
for platform in platforms:
normalized = str(platform).lower().strip()
mapped = PLATFORM_MAP.get(normalized, normalized)
if current.startswith(mapped):
return True
fix(skills): load Linux-tagged skills on Termux (android sys.platform) Reported by @LikiusInik in Discord: on Termux only 3 built-in skills appeared and /gh-pr-workflow + every other slash-skill from github/productivity/mlops was missing. Root cause: skill_matches_platform() compares sys.platform.startswith() against the skill's platforms list. Termux is a Linux userland on Android, but Python 3.13+ reports sys.platform == "android" instead of "linux" — so the ~60 built-in skills tagged platforms:[linux,macos, windows] (github-pr-workflow, google-workspace, github-auth, huggingface-hub, etc.) all got filtered out at the listing step in tools/skills_tool.py:_find_all_skills and never appeared as /slash commands or in skill_view. Fix: when is_termux() detects we're running inside Termux, accept "linux" platform tags regardless of whether sys.platform is "linux" (pre-3.13) or "android" (3.13+). Also accept explicit platforms:[termux] / [android] tags. macOS-only and Windows-only skills correctly remain excluded. E2E (simulated TERMUX_VERSION=set + sys.platform="android"): Before: _find_all_skills() returned ~3 skills. After: _find_all_skills() returns 84 skills including github-pr-workflow, google-workspace, github-auth, huggingface-hub. Apple-only skills remain excluded. Non-Termux Linux/macOS/Windows behavior unchanged (verified). Tests: tests/agent/test_skill_utils.py — 9 new cases covering android-as-Termux, the [linux,macos,windows] case, macOS-only exclusion, explicit termux/android tags, non-Termux Android safety, and unchanged behavior on real Linux/macOS.
2026-05-21 17:24:41 -07:00
# Termux runs a Linux userland on Android. Accept linux-tagged
# skills regardless of whether sys.platform is "linux" (pre-3.13
# Termux) or "android" (Python 3.13+ Termux, and any other
# Android runtime).
if running_in_termux and mapped == "linux":
return True
# Explicit termux/android tags match a Termux session too.
if running_in_termux and mapped in ("termux", "android"):
return True
return False
refactor(skills): clean up bundled skill set + add environments: relevance gate (#39028) * refactor(skills): clean up bundled skill set + add environments: relevance gate Bundled skills cleanup pass plus a new offer-time relevance gate. Removals (redundant / dead): - spotify (covered by the spotify plugin's 7 native tools) - linear (covered by `hermes mcp install linear`) - kanban-codex-lane, debugging-hermes-tui-commands - empty category markers: diagramming, gifs, inference-sh, mlops/training, mlops/vector-databases - domain (stale orphan dup of optional/research/domain-intel) Bundled -> optional: - baoyu-article-illustrator, baoyu-comic, creative-ideation, pixel-art - dspy, subagent-driven-development - minecraft-modpack-server, pokemon-player - hermes-s6-container-supervision (-> optional/devops) Consolidation: - webhook-subscriptions + native-mcp folded into the hermes-agent skill as references/webhooks.md + references/native-mcp.md with SKILL.md pointers - writing-plans merged into plan (v2.0.0); related_skills + prose refs updated New: environments: frontmatter gate (agent/skill_utils.skill_matches_environment) - Offer-time relevance filter (kanban / docker / s6), parallel to platforms:. - Wired into the 3 OFFER surfaces only (prompt_builder skills index, skills_tool.list_skills, skill_commands slash discovery). - Explicit loads (skill_view, --skills preload) intentionally BYPASS it, so load-bearing force-loads like the kanban dispatcher's `--skills kanban-worker` always resolve. Verified via E2E. - kanban-orchestrator/kanban-worker tagged environments: [kanban]; hermes-s6-container-supervision tagged environments: [s6] + platforms: [linux]. Validation: 8/8 E2E gating assertions (incl force-load invariant); 442 targeted tests green (agent, skills_tool, skill_commands, kanban worker). * docs: regenerate skill catalogs + pages for the bundled cleanup Regenerated per-skill doc pages, catalogs, and sidebar to match the skill moves/removals in the parent commit. Moved skills' pages relocate bundled -> optional (history preserved); removed skills' pages deleted; edited skills' pages refreshed (hermes-agent now embeds the webhook + native-mcp reference pointers). zh-Hans i18n mirror: stale bundled pages and catalog rows for moved/removed skills pruned (new optional translations land via the translation pipeline). * test: drop regression test for removed kanban-codex-lane skill The kanban-codex-lane skill was removed in the bundled-skills cleanup; its dedicated regression test read the now-deleted SKILL.md and failed with FileNotFoundError on CI shard 6.
2026-06-04 06:11:22 -07:00
# ── Environment matching ──────────────────────────────────────────────────
# Recognized environment tags and how each is detected. An environment tag is
# a *relevance* gate, not a hard-compatibility gate (that is what ``platforms:``
# is for). A skill tagged for an environment it isn't relevant to is hidden from
# the skills index / offer surfaces so it does not add noise for users who will
# never need it — but it can ALWAYS still be loaded explicitly (``skill_view``,
# ``--skills``), because an explicit request is explicit consent.
#
# Detection is cached for the process lifetime via ``_ENV_DETECT_CACHE``.
_KNOWN_ENVIRONMENTS = frozenset({"kanban", "docker", "s6"})
_ENV_DETECT_CACHE: Dict[str, bool] = {}
def _detect_environment(env: str) -> bool:
"""Return True when the named runtime environment is currently active.
Cached per process. Unknown env names return True (fail-open: never hide a
skill because of a tag we don't understand).
"""
if env in _ENV_DETECT_CACHE:
return _ENV_DETECT_CACHE[env]
result = True
if env == "kanban":
# Kanban is "active" either as a dispatcher-spawned worker (the
# dispatcher sets ``HERMES_KANBAN_TASK`` / ``HERMES_KANBAN_BOARD`` in the
# worker env) or as an orchestrator profile that has opted into the
# kanban toolset. Mirror the same signals the kanban tools themselves
# gate on (``tools/kanban_tools.py``) so the offer filter agrees with
# tool availability.
if os.getenv("HERMES_KANBAN_TASK") or os.getenv("HERMES_KANBAN_BOARD"):
result = True
else:
try:
from tools.kanban_tools import _profile_has_kanban_toolset
result = bool(_profile_has_kanban_toolset())
except Exception:
result = False
elif env == "docker":
try:
from hermes_constants import is_container
result = is_container()
except Exception:
result = False
elif env == "s6":
# The Hermes Docker image runs s6-overlay as PID 1 (/init). s6 plants
# its runtime scaffolding under /run/s6 and ships its admin tree under
# /package/admin/s6-overlay. Either marker means we're inside an
# s6-supervised container.
result = os.path.isdir("/run/s6") or os.path.isdir(
"/package/admin/s6-overlay"
)
_ENV_DETECT_CACHE[env] = result
return result
def skill_matches_environment(frontmatter: Dict[str, Any]) -> bool:
"""Return True when the skill is relevant to the current runtime environment.
Skills may declare an ``environments`` list in their YAML frontmatter::
environments: [kanban] # only relevant when kanban is active
environments: [s6] # only relevant inside the s6 Docker image
environments: [docker] # only relevant inside any container
If the field is absent or empty the skill is relevant in **all**
environments (backward-compatible default).
This is an OFFER-time filter: it controls whether a skill shows up in the
skills index / autocomplete / slash-command list. It is intentionally NOT
enforced by ``skill_view`` or ``--skills`` preloading an explicit load is
refactor(kanban): fold worker/orchestrator skills into injected guidance (#50473) The kanban-worker and kanban-orchestrator bundled skills existed only to be force-loaded into dispatcher-spawned workers, gated by environments:[kanban] so they wouldn't leak into normal CLI listings. That gating was fragile (the leak that #50443 patched) and the --skills auto-load was already best-effort — most workers ran without it because the bundled skill isn't present in profile-scoped skills dirs. Remove the skills entirely and promote their load-bearing content (workspace kinds, deliverable artifacts, created-card integrity, profile discovery) into KANBAN_GUIDANCE, which is already injected into every kanban worker's system prompt. Net result: every worker reliably gets the guidance, nothing can leak into a CLI/blank-slate session, and the gating machinery is gone. - agent/prompt_builder.py: promote the 4 load-bearing rules into KANBAN_GUIDANCE - hermes_cli/kanban_db.py: drop --skills kanban-worker auto-injection + _kanban_worker_skill_available probe - hermes_cli/kanban_swarm.py: drop skills=[kanban-orchestrator] on the root card - hermes_cli/kanban.py: drop kanban-init skill seeding; fix help text - delete skills/devops/kanban-{worker,orchestrator} - docs: delete the two skill pages (EN+zh), fix sidebars/catalog/kanban.md/kanban-worker-lanes.md and the video-orchestrator + codex-lane references - tests: update spawn-argv expectations; re-bound the guidance-size guard Supersedes the skill-leak half of #50443 (credit @helix4u for flagging the area).
2026-06-21 17:06:48 -07:00
explicit consent, and load-bearing force-loads (e.g. a dispatcher pinning
a task to a specialist skill via ``--skills``) must always succeed
regardless of how the offer surfaces filter the skill.
refactor(skills): clean up bundled skill set + add environments: relevance gate (#39028) * refactor(skills): clean up bundled skill set + add environments: relevance gate Bundled skills cleanup pass plus a new offer-time relevance gate. Removals (redundant / dead): - spotify (covered by the spotify plugin's 7 native tools) - linear (covered by `hermes mcp install linear`) - kanban-codex-lane, debugging-hermes-tui-commands - empty category markers: diagramming, gifs, inference-sh, mlops/training, mlops/vector-databases - domain (stale orphan dup of optional/research/domain-intel) Bundled -> optional: - baoyu-article-illustrator, baoyu-comic, creative-ideation, pixel-art - dspy, subagent-driven-development - minecraft-modpack-server, pokemon-player - hermes-s6-container-supervision (-> optional/devops) Consolidation: - webhook-subscriptions + native-mcp folded into the hermes-agent skill as references/webhooks.md + references/native-mcp.md with SKILL.md pointers - writing-plans merged into plan (v2.0.0); related_skills + prose refs updated New: environments: frontmatter gate (agent/skill_utils.skill_matches_environment) - Offer-time relevance filter (kanban / docker / s6), parallel to platforms:. - Wired into the 3 OFFER surfaces only (prompt_builder skills index, skills_tool.list_skills, skill_commands slash discovery). - Explicit loads (skill_view, --skills preload) intentionally BYPASS it, so load-bearing force-loads like the kanban dispatcher's `--skills kanban-worker` always resolve. Verified via E2E. - kanban-orchestrator/kanban-worker tagged environments: [kanban]; hermes-s6-container-supervision tagged environments: [s6] + platforms: [linux]. Validation: 8/8 E2E gating assertions (incl force-load invariant); 442 targeted tests green (agent, skills_tool, skill_commands, kanban worker). * docs: regenerate skill catalogs + pages for the bundled cleanup Regenerated per-skill doc pages, catalogs, and sidebar to match the skill moves/removals in the parent commit. Moved skills' pages relocate bundled -> optional (history preserved); removed skills' pages deleted; edited skills' pages refreshed (hermes-agent now embeds the webhook + native-mcp reference pointers). zh-Hans i18n mirror: stale bundled pages and catalog rows for moved/removed skills pruned (new optional translations land via the translation pipeline). * test: drop regression test for removed kanban-codex-lane skill The kanban-codex-lane skill was removed in the bundled-skills cleanup; its dedicated regression test read the now-deleted SKILL.md and failed with FileNotFoundError on CI shard 6.
2026-06-04 06:11:22 -07:00
A skill matches when ANY of its declared environments is currently active
(OR semantics, mirroring ``platforms``). Unknown env tags fail open.
"""
environments = frontmatter.get("environments")
if not environments:
return True
if not isinstance(environments, list):
environments = [environments]
for env in environments:
normalized = str(env).lower().strip()
if not normalized:
continue
if normalized not in _KNOWN_ENVIRONMENTS:
# Tag we don't understand — don't hide the skill over it.
return True
if _detect_environment(normalized):
return True
return False
# ── Disabled skills ───────────────────────────────────────────────────────
_RAW_CONFIG_CACHE: Dict[Tuple[str, int, int], Dict[str, Any]] = {}
def _raw_config_cache_clear() -> None:
"""Test hook — drop the shared raw config cache."""
_RAW_CONFIG_CACHE.clear()
def _load_raw_config() -> Dict[str, Any]:
"""Read config.yaml with a shared mtime+size keyed cache.
This module intentionally avoids importing ``hermes_cli.config`` on the
skill prompt/build path. A tiny local cache gives the same repeated-read
win without pulling the heavier CLI config stack into startup.
"""
config_path = get_config_path()
if not config_path.exists():
return {}
try:
stat = config_path.stat()
cache_key = (str(config_path), stat.st_mtime_ns, stat.st_size)
except OSError:
cache_key = None
if cache_key is not None:
cached = _RAW_CONFIG_CACHE.get(cache_key)
if cached is not None:
return cached
try:
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
except Exception as e:
logger.debug("Could not read skill config %s: %s", config_path, e)
return {}
if not isinstance(parsed, dict):
return {}
if cache_key is not None:
_RAW_CONFIG_CACHE.clear()
_RAW_CONFIG_CACHE[cache_key] = parsed
return parsed
def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
"""Read disabled skill names from config.yaml.
Args:
platform: Explicit platform name (e.g. ``"telegram"``). When
*None*, resolves from ``HERMES_PLATFORM`` or
``HERMES_SESSION_PLATFORM`` env vars. Returns the global
disabled list, unioned with the platform-specific list when a
platform is resolved (a globally-disabled skill stays disabled
on every platform).
Reads the config file directly (no CLI config imports) to stay
lightweight.
"""
parsed = _load_raw_config()
if not parsed:
return set()
skills_cfg = parsed.get("skills")
if not isinstance(skills_cfg, dict):
return set()
from gateway.session_context import get_session_env
resolved_platform = (
platform
or os.getenv("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM")
)
global_disabled = _normalize_string_set(skills_cfg.get("disabled"))
if resolved_platform:
platform_disabled = (skills_cfg.get("platform_disabled") or {}).get(
resolved_platform
)
if platform_disabled is not None:
return global_disabled | _normalize_string_set(platform_disabled)
return global_disabled
def _normalize_string_set(values) -> Set[str]:
if values is None:
return set()
if isinstance(values, str):
values = [values]
return {str(v).strip() for v in values if str(v).strip()}
# ── External skills directories ──────────────────────────────────────────
perf(cli): cut ~19s from 'hermes' cold start (skills cache + lazy Feishu + no Nous HTTP) (#22138) Interactive `hermes` launch drops from ~21s to ~2.5s. Three independent fixes, each targets a distinct hot spot in the banner / tool-registration path that fires on every CLI invocation. 1. `get_external_skills_dirs()` in-process mtime cache (~10s saved) The function re-read + YAML-parsed the full ~/.hermes/config.yaml on every call. Banner build invokes it once per skill to resolve the category column, which on a 120-skill install meant ~120 reparses of a 15 KB config (~85 ms each). Added a `(config_path, mtime_ns) -> list[Path]` memo; stat() is ~2 us vs ~85 ms for the parse. Edits to config.yaml invalidate the cache on the next call via mtime. 2. Feishu availability probe uses `importlib.util.find_spec` (~5.2s saved) `tools/feishu_doc_tool.py::_check_feishu` and the identical helper in `feishu_drive_tool.py` were calling `import lark_oapi` purely to detect whether the SDK was installed. Executing the real import pulls in websockets + dispatcher + every v2 API model — ~5 seconds of work that fires at every tool-registry bootstrap. `find_spec` answers the same question ("is lark_oapi importable?") without executing the module. The actual tool handlers still do the real import on invoke, so runtime behavior is unchanged. 3. `_web_requires_env` no longer triggers Nous portal refresh (~800ms saved) `tools/web_tools.py::_web_requires_env` used `managed_nous_tools_enabled()` to gate four gateway env-var names in the returned list. The gate called `get_nous_auth_status()` -> `resolve_nous_runtime_credentials()` -> live HTTP POST to the portal on every tool-registry bootstrap. But the list is pure metadata — if the env var is set at runtime, the tool lights up; otherwise it doesn't. Including the four names unconditionally is harmless for unsubscribed users (vars just aren't set) and eliminates the sync HTTP round trip from startup. Test: - tests/agent/test_external_skills_dirs_cache.py (new, 6 cases): returns config'd dir, caches on second call (yaml_load patched to raise — never invoked), invalidates on mtime bump, empty when config missing, returned list is a defensive copy, per-HERMES_HOME cache key isolation. - Existing tests/agent/test_external_skills.py and tests/tools/ continue to pass modulo pre-existing flakes on main (test_delegate, test_send_message — unrelated, pass in isolation). Measured: bare `hermes` (cold → REPL ready) 21,519ms -> 2,618ms on Teknium's install (119 skills, 15 KB config.yaml, Nous auth logged in, lark_oapi installed). 8x faster.
2026-05-08 16:39:32 -07:00
# (config_path_str, mtime_ns) -> resolved external dirs list. Keyed by
# mtime_ns so a config.yaml edit mid-run is picked up automatically;
# otherwise every call would re-read + re-YAML-parse the 15KB config,
# which becomes the dominant cost of ``hermes`` startup when ~120 skills
# each trigger a category lookup during banner construction (10+ seconds
# of pure waste).
_EXTERNAL_DIRS_CACHE: Dict[Tuple[str, int], List[Path]] = {}
def _external_dirs_cache_clear() -> None:
"""Test hook — drop the in-process cache."""
_EXTERNAL_DIRS_CACHE.clear()
_raw_config_cache_clear()
perf(cli): cut ~19s from 'hermes' cold start (skills cache + lazy Feishu + no Nous HTTP) (#22138) Interactive `hermes` launch drops from ~21s to ~2.5s. Three independent fixes, each targets a distinct hot spot in the banner / tool-registration path that fires on every CLI invocation. 1. `get_external_skills_dirs()` in-process mtime cache (~10s saved) The function re-read + YAML-parsed the full ~/.hermes/config.yaml on every call. Banner build invokes it once per skill to resolve the category column, which on a 120-skill install meant ~120 reparses of a 15 KB config (~85 ms each). Added a `(config_path, mtime_ns) -> list[Path]` memo; stat() is ~2 us vs ~85 ms for the parse. Edits to config.yaml invalidate the cache on the next call via mtime. 2. Feishu availability probe uses `importlib.util.find_spec` (~5.2s saved) `tools/feishu_doc_tool.py::_check_feishu` and the identical helper in `feishu_drive_tool.py` were calling `import lark_oapi` purely to detect whether the SDK was installed. Executing the real import pulls in websockets + dispatcher + every v2 API model — ~5 seconds of work that fires at every tool-registry bootstrap. `find_spec` answers the same question ("is lark_oapi importable?") without executing the module. The actual tool handlers still do the real import on invoke, so runtime behavior is unchanged. 3. `_web_requires_env` no longer triggers Nous portal refresh (~800ms saved) `tools/web_tools.py::_web_requires_env` used `managed_nous_tools_enabled()` to gate four gateway env-var names in the returned list. The gate called `get_nous_auth_status()` -> `resolve_nous_runtime_credentials()` -> live HTTP POST to the portal on every tool-registry bootstrap. But the list is pure metadata — if the env var is set at runtime, the tool lights up; otherwise it doesn't. Including the four names unconditionally is harmless for unsubscribed users (vars just aren't set) and eliminates the sync HTTP round trip from startup. Test: - tests/agent/test_external_skills_dirs_cache.py (new, 6 cases): returns config'd dir, caches on second call (yaml_load patched to raise — never invoked), invalidates on mtime bump, empty when config missing, returned list is a defensive copy, per-HERMES_HOME cache key isolation. - Existing tests/agent/test_external_skills.py and tests/tools/ continue to pass modulo pre-existing flakes on main (test_delegate, test_send_message — unrelated, pass in isolation). Measured: bare `hermes` (cold → REPL ready) 21,519ms -> 2,618ms on Teknium's install (119 skills, 15 KB config.yaml, Nous auth logged in, lark_oapi installed). 8x faster.
2026-05-08 16:39:32 -07:00
def get_external_skills_dirs() -> List[Path]:
"""Read ``skills.external_dirs`` from config.yaml and return validated paths.
Each entry is expanded (``~`` and ``${VAR}``) and resolved to an absolute
path. Only directories that actually exist are returned. Duplicates and
paths that resolve to the local ``~/.hermes/skills/`` are silently skipped.
perf(cli): cut ~19s from 'hermes' cold start (skills cache + lazy Feishu + no Nous HTTP) (#22138) Interactive `hermes` launch drops from ~21s to ~2.5s. Three independent fixes, each targets a distinct hot spot in the banner / tool-registration path that fires on every CLI invocation. 1. `get_external_skills_dirs()` in-process mtime cache (~10s saved) The function re-read + YAML-parsed the full ~/.hermes/config.yaml on every call. Banner build invokes it once per skill to resolve the category column, which on a 120-skill install meant ~120 reparses of a 15 KB config (~85 ms each). Added a `(config_path, mtime_ns) -> list[Path]` memo; stat() is ~2 us vs ~85 ms for the parse. Edits to config.yaml invalidate the cache on the next call via mtime. 2. Feishu availability probe uses `importlib.util.find_spec` (~5.2s saved) `tools/feishu_doc_tool.py::_check_feishu` and the identical helper in `feishu_drive_tool.py` were calling `import lark_oapi` purely to detect whether the SDK was installed. Executing the real import pulls in websockets + dispatcher + every v2 API model — ~5 seconds of work that fires at every tool-registry bootstrap. `find_spec` answers the same question ("is lark_oapi importable?") without executing the module. The actual tool handlers still do the real import on invoke, so runtime behavior is unchanged. 3. `_web_requires_env` no longer triggers Nous portal refresh (~800ms saved) `tools/web_tools.py::_web_requires_env` used `managed_nous_tools_enabled()` to gate four gateway env-var names in the returned list. The gate called `get_nous_auth_status()` -> `resolve_nous_runtime_credentials()` -> live HTTP POST to the portal on every tool-registry bootstrap. But the list is pure metadata — if the env var is set at runtime, the tool lights up; otherwise it doesn't. Including the four names unconditionally is harmless for unsubscribed users (vars just aren't set) and eliminates the sync HTTP round trip from startup. Test: - tests/agent/test_external_skills_dirs_cache.py (new, 6 cases): returns config'd dir, caches on second call (yaml_load patched to raise — never invoked), invalidates on mtime bump, empty when config missing, returned list is a defensive copy, per-HERMES_HOME cache key isolation. - Existing tests/agent/test_external_skills.py and tests/tools/ continue to pass modulo pre-existing flakes on main (test_delegate, test_send_message — unrelated, pass in isolation). Measured: bare `hermes` (cold → REPL ready) 21,519ms -> 2,618ms on Teknium's install (119 skills, 15 KB config.yaml, Nous auth logged in, lark_oapi installed). 8x faster.
2026-05-08 16:39:32 -07:00
Cached in-process, keyed on ``config.yaml`` mtime the function is
called once per skill during banner / tool-registry scans, and YAML
parsing a non-trivial config dominates ``hermes`` cold-start time
when the cache is absent.
"""
refactor: extract shared helpers to deduplicate repeated code patterns (#7917) * refactor: add shared helper modules for code deduplication New modules: - gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator, strip_markdown, ThreadParticipationTracker, redact_phone - hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers - tools/path_security.py: validate_within_dir, has_traversal_component - utils.py additions: safe_json_loads, read_json_file, read_jsonl, append_jsonl, env_str/lower/int/bool helpers - hermes_constants.py additions: get_config_path, get_skills_dir, get_logs_dir, get_env_path * refactor: migrate gateway adapters to shared helpers - MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost - strip_markdown: bluebubbles, feishu, sms - redact_phone: sms, signal - ThreadParticipationTracker: discord, matrix - _acquire/_release_platform_lock: telegram, discord, slack, whatsapp, signal, weixin Net -316 lines across 19 files. * refactor: migrate CLI modules to shared helpers - tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines) - setup.py: use cli_output print helpers + curses_radiolist (-101 lines) - mcp_config.py: use cli_output prompt (-15 lines) - memory_setup.py: use curses_radiolist (-86 lines) Net -263 lines across 5 files. * refactor: migrate to shared utility helpers - safe_json_loads: agent/display.py (4 sites) - get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py - get_skills_dir: skill_utils.py, prompt_builder.py - Token estimation dedup: skills_tool.py imports from model_metadata - Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files - Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write - Platform dict: new platforms.py, skills_config + tools_config derive from it - Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main * test: update tests for shared helper migrations - test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate() - test_mattermost: use _dedup instead of _seen_posts/_prune_seen - test_signal: import redact_phone from helpers instead of signal - test_discord_connect: _platform_lock_identity instead of _token_lock_identity - test_telegram_conflict: updated lock error message format - test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
config_path = get_config_path()
if not config_path.exists():
return []
perf(cli): cut ~19s from 'hermes' cold start (skills cache + lazy Feishu + no Nous HTTP) (#22138) Interactive `hermes` launch drops from ~21s to ~2.5s. Three independent fixes, each targets a distinct hot spot in the banner / tool-registration path that fires on every CLI invocation. 1. `get_external_skills_dirs()` in-process mtime cache (~10s saved) The function re-read + YAML-parsed the full ~/.hermes/config.yaml on every call. Banner build invokes it once per skill to resolve the category column, which on a 120-skill install meant ~120 reparses of a 15 KB config (~85 ms each). Added a `(config_path, mtime_ns) -> list[Path]` memo; stat() is ~2 us vs ~85 ms for the parse. Edits to config.yaml invalidate the cache on the next call via mtime. 2. Feishu availability probe uses `importlib.util.find_spec` (~5.2s saved) `tools/feishu_doc_tool.py::_check_feishu` and the identical helper in `feishu_drive_tool.py` were calling `import lark_oapi` purely to detect whether the SDK was installed. Executing the real import pulls in websockets + dispatcher + every v2 API model — ~5 seconds of work that fires at every tool-registry bootstrap. `find_spec` answers the same question ("is lark_oapi importable?") without executing the module. The actual tool handlers still do the real import on invoke, so runtime behavior is unchanged. 3. `_web_requires_env` no longer triggers Nous portal refresh (~800ms saved) `tools/web_tools.py::_web_requires_env` used `managed_nous_tools_enabled()` to gate four gateway env-var names in the returned list. The gate called `get_nous_auth_status()` -> `resolve_nous_runtime_credentials()` -> live HTTP POST to the portal on every tool-registry bootstrap. But the list is pure metadata — if the env var is set at runtime, the tool lights up; otherwise it doesn't. Including the four names unconditionally is harmless for unsubscribed users (vars just aren't set) and eliminates the sync HTTP round trip from startup. Test: - tests/agent/test_external_skills_dirs_cache.py (new, 6 cases): returns config'd dir, caches on second call (yaml_load patched to raise — never invoked), invalidates on mtime bump, empty when config missing, returned list is a defensive copy, per-HERMES_HOME cache key isolation. - Existing tests/agent/test_external_skills.py and tests/tools/ continue to pass modulo pre-existing flakes on main (test_delegate, test_send_message — unrelated, pass in isolation). Measured: bare `hermes` (cold → REPL ready) 21,519ms -> 2,618ms on Teknium's install (119 skills, 15 KB config.yaml, Nous auth logged in, lark_oapi installed). 8x faster.
2026-05-08 16:39:32 -07:00
# Cache key: (absolute path, mtime_ns). stat() is ~2us vs ~85ms for
# the full YAML parse, so the fast path is nearly free.
try:
stat = config_path.stat()
cache_key: Tuple[str, int] = (str(config_path), stat.st_mtime_ns)
except OSError:
cache_key = None # type: ignore[assignment]
if cache_key is not None:
cached = _EXTERNAL_DIRS_CACHE.get(cache_key)
if cached is not None:
# Return a copy so callers can't mutate the cached list.
return list(cached)
parsed = _load_raw_config()
if not parsed:
return []
skills_cfg = parsed.get("skills")
if not isinstance(skills_cfg, dict):
return []
raw_dirs = skills_cfg.get("external_dirs")
if not raw_dirs:
perf(cli): cut ~19s from 'hermes' cold start (skills cache + lazy Feishu + no Nous HTTP) (#22138) Interactive `hermes` launch drops from ~21s to ~2.5s. Three independent fixes, each targets a distinct hot spot in the banner / tool-registration path that fires on every CLI invocation. 1. `get_external_skills_dirs()` in-process mtime cache (~10s saved) The function re-read + YAML-parsed the full ~/.hermes/config.yaml on every call. Banner build invokes it once per skill to resolve the category column, which on a 120-skill install meant ~120 reparses of a 15 KB config (~85 ms each). Added a `(config_path, mtime_ns) -> list[Path]` memo; stat() is ~2 us vs ~85 ms for the parse. Edits to config.yaml invalidate the cache on the next call via mtime. 2. Feishu availability probe uses `importlib.util.find_spec` (~5.2s saved) `tools/feishu_doc_tool.py::_check_feishu` and the identical helper in `feishu_drive_tool.py` were calling `import lark_oapi` purely to detect whether the SDK was installed. Executing the real import pulls in websockets + dispatcher + every v2 API model — ~5 seconds of work that fires at every tool-registry bootstrap. `find_spec` answers the same question ("is lark_oapi importable?") without executing the module. The actual tool handlers still do the real import on invoke, so runtime behavior is unchanged. 3. `_web_requires_env` no longer triggers Nous portal refresh (~800ms saved) `tools/web_tools.py::_web_requires_env` used `managed_nous_tools_enabled()` to gate four gateway env-var names in the returned list. The gate called `get_nous_auth_status()` -> `resolve_nous_runtime_credentials()` -> live HTTP POST to the portal on every tool-registry bootstrap. But the list is pure metadata — if the env var is set at runtime, the tool lights up; otherwise it doesn't. Including the four names unconditionally is harmless for unsubscribed users (vars just aren't set) and eliminates the sync HTTP round trip from startup. Test: - tests/agent/test_external_skills_dirs_cache.py (new, 6 cases): returns config'd dir, caches on second call (yaml_load patched to raise — never invoked), invalidates on mtime bump, empty when config missing, returned list is a defensive copy, per-HERMES_HOME cache key isolation. - Existing tests/agent/test_external_skills.py and tests/tools/ continue to pass modulo pre-existing flakes on main (test_delegate, test_send_message — unrelated, pass in isolation). Measured: bare `hermes` (cold → REPL ready) 21,519ms -> 2,618ms on Teknium's install (119 skills, 15 KB config.yaml, Nous auth logged in, lark_oapi installed). 8x faster.
2026-05-08 16:39:32 -07:00
result: List[Path] = []
if cache_key is not None:
_EXTERNAL_DIRS_CACHE[cache_key] = list(result)
return result
if isinstance(raw_dirs, str):
raw_dirs = [raw_dirs]
if not isinstance(raw_dirs, list):
return []
from hermes_constants import get_hermes_home
hermes_home = get_hermes_home()
refactor: extract shared helpers to deduplicate repeated code patterns (#7917) * refactor: add shared helper modules for code deduplication New modules: - gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator, strip_markdown, ThreadParticipationTracker, redact_phone - hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers - tools/path_security.py: validate_within_dir, has_traversal_component - utils.py additions: safe_json_loads, read_json_file, read_jsonl, append_jsonl, env_str/lower/int/bool helpers - hermes_constants.py additions: get_config_path, get_skills_dir, get_logs_dir, get_env_path * refactor: migrate gateway adapters to shared helpers - MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost - strip_markdown: bluebubbles, feishu, sms - redact_phone: sms, signal - ThreadParticipationTracker: discord, matrix - _acquire/_release_platform_lock: telegram, discord, slack, whatsapp, signal, weixin Net -316 lines across 19 files. * refactor: migrate CLI modules to shared helpers - tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines) - setup.py: use cli_output print helpers + curses_radiolist (-101 lines) - mcp_config.py: use cli_output prompt (-15 lines) - memory_setup.py: use curses_radiolist (-86 lines) Net -263 lines across 5 files. * refactor: migrate to shared utility helpers - safe_json_loads: agent/display.py (4 sites) - get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py - get_skills_dir: skill_utils.py, prompt_builder.py - Token estimation dedup: skills_tool.py imports from model_metadata - Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files - Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write - Platform dict: new platforms.py, skills_config + tools_config derive from it - Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main * test: update tests for shared helper migrations - test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate() - test_mattermost: use _dedup instead of _seen_posts/_prune_seen - test_signal: import redact_phone from helpers instead of signal - test_discord_connect: _platform_lock_identity instead of _token_lock_identity - test_telegram_conflict: updated lock error message format - test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
local_skills = get_skills_dir().resolve()
seen: Set[Path] = set()
perf(cli): cut ~19s from 'hermes' cold start (skills cache + lazy Feishu + no Nous HTTP) (#22138) Interactive `hermes` launch drops from ~21s to ~2.5s. Three independent fixes, each targets a distinct hot spot in the banner / tool-registration path that fires on every CLI invocation. 1. `get_external_skills_dirs()` in-process mtime cache (~10s saved) The function re-read + YAML-parsed the full ~/.hermes/config.yaml on every call. Banner build invokes it once per skill to resolve the category column, which on a 120-skill install meant ~120 reparses of a 15 KB config (~85 ms each). Added a `(config_path, mtime_ns) -> list[Path]` memo; stat() is ~2 us vs ~85 ms for the parse. Edits to config.yaml invalidate the cache on the next call via mtime. 2. Feishu availability probe uses `importlib.util.find_spec` (~5.2s saved) `tools/feishu_doc_tool.py::_check_feishu` and the identical helper in `feishu_drive_tool.py` were calling `import lark_oapi` purely to detect whether the SDK was installed. Executing the real import pulls in websockets + dispatcher + every v2 API model — ~5 seconds of work that fires at every tool-registry bootstrap. `find_spec` answers the same question ("is lark_oapi importable?") without executing the module. The actual tool handlers still do the real import on invoke, so runtime behavior is unchanged. 3. `_web_requires_env` no longer triggers Nous portal refresh (~800ms saved) `tools/web_tools.py::_web_requires_env` used `managed_nous_tools_enabled()` to gate four gateway env-var names in the returned list. The gate called `get_nous_auth_status()` -> `resolve_nous_runtime_credentials()` -> live HTTP POST to the portal on every tool-registry bootstrap. But the list is pure metadata — if the env var is set at runtime, the tool lights up; otherwise it doesn't. Including the four names unconditionally is harmless for unsubscribed users (vars just aren't set) and eliminates the sync HTTP round trip from startup. Test: - tests/agent/test_external_skills_dirs_cache.py (new, 6 cases): returns config'd dir, caches on second call (yaml_load patched to raise — never invoked), invalidates on mtime bump, empty when config missing, returned list is a defensive copy, per-HERMES_HOME cache key isolation. - Existing tests/agent/test_external_skills.py and tests/tools/ continue to pass modulo pre-existing flakes on main (test_delegate, test_send_message — unrelated, pass in isolation). Measured: bare `hermes` (cold → REPL ready) 21,519ms -> 2,618ms on Teknium's install (119 skills, 15 KB config.yaml, Nous auth logged in, lark_oapi installed). 8x faster.
2026-05-08 16:39:32 -07:00
result = []
for entry in raw_dirs:
entry = str(entry).strip()
if not entry:
continue
# Expand ~ and environment variables
expanded = os.path.expanduser(os.path.expandvars(entry))
p = Path(expanded)
# Resolve relative paths against HERMES_HOME, not cwd
if not p.is_absolute():
p = (hermes_home / p).resolve()
else:
p = p.resolve()
if p == local_skills:
continue
if p in seen:
continue
if p.is_dir():
seen.add(p)
result.append(p)
else:
logger.debug("External skills dir does not exist, skipping: %s", p)
perf(cli): cut ~19s from 'hermes' cold start (skills cache + lazy Feishu + no Nous HTTP) (#22138) Interactive `hermes` launch drops from ~21s to ~2.5s. Three independent fixes, each targets a distinct hot spot in the banner / tool-registration path that fires on every CLI invocation. 1. `get_external_skills_dirs()` in-process mtime cache (~10s saved) The function re-read + YAML-parsed the full ~/.hermes/config.yaml on every call. Banner build invokes it once per skill to resolve the category column, which on a 120-skill install meant ~120 reparses of a 15 KB config (~85 ms each). Added a `(config_path, mtime_ns) -> list[Path]` memo; stat() is ~2 us vs ~85 ms for the parse. Edits to config.yaml invalidate the cache on the next call via mtime. 2. Feishu availability probe uses `importlib.util.find_spec` (~5.2s saved) `tools/feishu_doc_tool.py::_check_feishu` and the identical helper in `feishu_drive_tool.py` were calling `import lark_oapi` purely to detect whether the SDK was installed. Executing the real import pulls in websockets + dispatcher + every v2 API model — ~5 seconds of work that fires at every tool-registry bootstrap. `find_spec` answers the same question ("is lark_oapi importable?") without executing the module. The actual tool handlers still do the real import on invoke, so runtime behavior is unchanged. 3. `_web_requires_env` no longer triggers Nous portal refresh (~800ms saved) `tools/web_tools.py::_web_requires_env` used `managed_nous_tools_enabled()` to gate four gateway env-var names in the returned list. The gate called `get_nous_auth_status()` -> `resolve_nous_runtime_credentials()` -> live HTTP POST to the portal on every tool-registry bootstrap. But the list is pure metadata — if the env var is set at runtime, the tool lights up; otherwise it doesn't. Including the four names unconditionally is harmless for unsubscribed users (vars just aren't set) and eliminates the sync HTTP round trip from startup. Test: - tests/agent/test_external_skills_dirs_cache.py (new, 6 cases): returns config'd dir, caches on second call (yaml_load patched to raise — never invoked), invalidates on mtime bump, empty when config missing, returned list is a defensive copy, per-HERMES_HOME cache key isolation. - Existing tests/agent/test_external_skills.py and tests/tools/ continue to pass modulo pre-existing flakes on main (test_delegate, test_send_message — unrelated, pass in isolation). Measured: bare `hermes` (cold → REPL ready) 21,519ms -> 2,618ms on Teknium's install (119 skills, 15 KB config.yaml, Nous auth logged in, lark_oapi installed). 8x faster.
2026-05-08 16:39:32 -07:00
if cache_key is not None:
_EXTERNAL_DIRS_CACHE[cache_key] = list(result)
return result
def get_all_skills_dirs() -> List[Path]:
"""Return all skill directories: local ``~/.hermes/skills/`` first, then external.
The local dir is always first (and always included even if it doesn't exist
yet callers handle that). External dirs follow in config order.
"""
refactor: extract shared helpers to deduplicate repeated code patterns (#7917) * refactor: add shared helper modules for code deduplication New modules: - gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator, strip_markdown, ThreadParticipationTracker, redact_phone - hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers - tools/path_security.py: validate_within_dir, has_traversal_component - utils.py additions: safe_json_loads, read_json_file, read_jsonl, append_jsonl, env_str/lower/int/bool helpers - hermes_constants.py additions: get_config_path, get_skills_dir, get_logs_dir, get_env_path * refactor: migrate gateway adapters to shared helpers - MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost - strip_markdown: bluebubbles, feishu, sms - redact_phone: sms, signal - ThreadParticipationTracker: discord, matrix - _acquire/_release_platform_lock: telegram, discord, slack, whatsapp, signal, weixin Net -316 lines across 19 files. * refactor: migrate CLI modules to shared helpers - tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines) - setup.py: use cli_output print helpers + curses_radiolist (-101 lines) - mcp_config.py: use cli_output prompt (-15 lines) - memory_setup.py: use curses_radiolist (-86 lines) Net -263 lines across 5 files. * refactor: migrate to shared utility helpers - safe_json_loads: agent/display.py (4 sites) - get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py - get_skills_dir: skill_utils.py, prompt_builder.py - Token estimation dedup: skills_tool.py imports from model_metadata - Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files - Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write - Platform dict: new platforms.py, skills_config + tools_config derive from it - Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main * test: update tests for shared helper migrations - test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate() - test_mattermost: use _dedup instead of _seen_posts/_prune_seen - test_signal: import redact_phone from helpers instead of signal - test_discord_connect: _platform_lock_identity instead of _token_lock_identity - test_telegram_conflict: updated lock error message format - test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
dirs = [get_skills_dir()]
dirs.extend(get_external_skills_dirs())
return dirs
def _resolve_for_skill_ownership(path) -> Path:
path_obj = path if isinstance(path, Path) else Path(str(path))
try:
return path_obj.expanduser().resolve()
except (OSError, RuntimeError):
return path_obj.expanduser().absolute()
def is_external_skill_path(path) -> bool:
"""Return True when ``path`` lives under a configured external skills dir.
``skills.external_dirs`` are externally owned: Hermes can discover and view
their skills, and foreground user-directed tool calls may still edit them,
but autonomous lifecycle maintenance must treat them as read-only. This
helper centralizes the ownership boundary so curator/reporting/tool paths do
not each need to re-interpret the config.
"""
candidate = _resolve_for_skill_ownership(path)
for root in get_external_skills_dirs():
resolved_root = _resolve_for_skill_ownership(root)
try:
candidate.relative_to(resolved_root)
return True
except ValueError:
continue
return False
# ── Condition extraction ──────────────────────────────────────────────────
def extract_skill_conditions(frontmatter: Dict[str, Any]) -> Dict[str, List]:
"""Extract conditional activation fields from parsed frontmatter."""
metadata = frontmatter.get("metadata")
# Handle cases where metadata is not a dict (e.g., a string from malformed YAML)
if not isinstance(metadata, dict):
metadata = {}
hermes = metadata.get("hermes") or {}
if not isinstance(hermes, dict):
hermes = {}
return {
"fallback_for_toolsets": hermes.get("fallback_for_toolsets", []),
"requires_toolsets": hermes.get("requires_toolsets", []),
"fallback_for_tools": hermes.get("fallback_for_tools", []),
"requires_tools": hermes.get("requires_tools", []),
}
# ── Skill config extraction ───────────────────────────────────────────────
def extract_skill_config_vars(frontmatter: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Extract config variable declarations from parsed frontmatter.
Skills declare config.yaml settings they need via::
metadata:
hermes:
config:
- key: wiki.path
description: Path to the LLM Wiki knowledge base directory
default: "~/wiki"
prompt: Wiki directory path
Returns a list of dicts with keys: ``key``, ``description``, ``default``,
``prompt``. Invalid or incomplete entries are silently skipped.
"""
metadata = frontmatter.get("metadata")
if not isinstance(metadata, dict):
return []
hermes = metadata.get("hermes")
if not isinstance(hermes, dict):
return []
raw = hermes.get("config")
if not raw:
return []
if isinstance(raw, dict):
raw = [raw]
if not isinstance(raw, list):
return []
result: List[Dict[str, Any]] = []
seen: set = set()
for item in raw:
if not isinstance(item, dict):
continue
key = str(item.get("key", "")).strip()
if not key or key in seen:
continue
# Must have at least key and description
desc = str(item.get("description", "")).strip()
if not desc:
continue
entry: Dict[str, Any] = {
"key": key,
"description": desc,
}
default = item.get("default")
if default is not None:
entry["default"] = default
prompt_text = item.get("prompt")
if isinstance(prompt_text, str) and prompt_text.strip():
entry["prompt"] = prompt_text.strip()
else:
entry["prompt"] = desc
seen.add(key)
result.append(entry)
return result
def discover_all_skill_config_vars() -> List[Dict[str, Any]]:
"""Scan all enabled skills and collect their config variable declarations.
Walks every skills directory, parses each SKILL.md frontmatter, and returns
a deduplicated list of config var dicts. Each dict also includes a
``skill`` key with the skill name for attribution.
Disabled and platform-incompatible skills are excluded.
"""
all_vars: List[Dict[str, Any]] = []
seen_keys: set = set()
disabled = get_disabled_skill_names()
for skills_dir in get_all_skills_dirs():
if not skills_dir.is_dir():
continue
for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"):
try:
raw = skill_file.read_text(encoding="utf-8")
frontmatter, _ = parse_frontmatter(raw)
except Exception:
continue
skill_name = frontmatter.get("name") or skill_file.parent.name
if str(skill_name) in disabled:
continue
if not skill_matches_platform(frontmatter):
continue
config_vars = extract_skill_config_vars(frontmatter)
for var in config_vars:
if var["key"] not in seen_keys:
var["skill"] = str(skill_name)
all_vars.append(var)
seen_keys.add(var["key"])
return all_vars
# Storage prefix: all skill config vars are stored under skills.config.*
# in config.yaml. Skill authors declare logical keys (e.g. "wiki.path");
# the system adds this prefix for storage and strips it for display.
SKILL_CONFIG_PREFIX = "skills.config"
def _resolve_dotpath(config: Dict[str, Any], dotted_key: str):
"""Walk a nested dict following a dotted key. Returns None if any part is missing."""
parts = dotted_key.split(".")
current = config
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
else:
return None
return current
def resolve_skill_config_values(
config_vars: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Resolve current values for skill config vars from config.yaml.
Skill config is stored under ``skills.config.<key>`` in config.yaml.
Returns a dict mapping **logical** keys (as declared by skills) to their
current values (or the declared default if the key isn't set).
Path values are expanded via ``os.path.expanduser``.
"""
config = _load_raw_config()
resolved: Dict[str, Any] = {}
for var in config_vars:
logical_key = var["key"]
storage_key = f"{SKILL_CONFIG_PREFIX}.{logical_key}"
value = _resolve_dotpath(config, storage_key)
if value is None or (isinstance(value, str) and not value.strip()):
value = var.get("default", "")
# Expand ~ in path-like values
if isinstance(value, str) and ("~" in value or "${" in value):
value = os.path.expanduser(os.path.expandvars(value))
resolved[logical_key] = value
return resolved
# ── Description extraction ────────────────────────────────────────────────
def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
"""Extract a truncated description from parsed frontmatter."""
raw_desc = frontmatter.get("description", "")
if not raw_desc:
return ""
desc = str(raw_desc).strip().strip("'\"")
if len(desc) > 60:
return desc[:57] + "..."
return desc
# ── File iteration ────────────────────────────────────────────────────────
def iter_skill_index_files(skills_dir: Path, filename: str):
"""Walk skills_dir yielding sorted paths matching *filename*.
Excludes Hermes metadata, VCS, virtualenv/dependency, cache, and skill
support directories. Support directories (references/templates/assets/
scripts) can contain arbitrary markdown and even archived package
``SKILL.md`` files, but they are progressive-disclosure data loaded through
``skill_view(..., file_path=...)`` rather than active skill roots.
"""
matches = []
for root, dirs, files in os.walk(skills_dir, followlinks=True):
has_skill_md = "SKILL.md" in files
dirs[:] = [
d
for d in dirs
if d not in EXCLUDED_SKILL_DIRS
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
]
if filename in files:
matches.append(Path(root) / filename)
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
yield path
# ── Namespace helpers for plugin-provided skills ───────────────────────────
_NAMESPACE_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
def parse_qualified_name(name: str) -> Tuple[Optional[str], str]:
"""Split ``'namespace:skill-name'`` into ``(namespace, bare_name)``.
Returns ``(None, name)`` when there is no ``':'``.
"""
if ":" not in name:
return None, name
return tuple(name.split(":", 1)) # type: ignore[return-value]
def is_valid_namespace(candidate: Optional[str]) -> bool:
"""Check whether *candidate* is a valid namespace (``[a-zA-Z0-9_-]+``)."""
if not candidate:
return False
return bool(_NAMESPACE_RE.match(candidate))