2026-03-27 10:54:02 -07:00
|
|
|
"""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
|
2026-04-14 10:32:00 -07:00
|
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
2026-03-27 10:54:02 -07:00
|
|
|
|
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
|
2026-03-27 10:54:02 -07:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# ── Platform mapping ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
PLATFORM_MAP = {
|
|
|
|
|
"macos": "darwin",
|
|
|
|
|
"linux": "linux",
|
|
|
|
|
"windows": "win32",
|
|
|
|
|
}
|
|
|
|
|
|
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",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-16 03:45:56 +02:00
|
|
|
# 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"))
|
|
|
|
|
|
2026-05-21 14:18:02 -07:00
|
|
|
|
|
|
|
|
def is_excluded_skill_path(path) -> bool:
|
2026-06-16 03:45:56 +02:00
|
|
|
"""True if *path* should be skipped by active skill scanners.
|
2026-05-21 14:18:02 -07:00
|
|
|
|
2026-06-16 03:45:56 +02: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.
|
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
|
2026-06-16 03:45:56 +02:00
|
|
|
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
|
2026-05-21 14:18:02 -07:00
|
|
|
|
2026-03-27 10:54: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.
|
2026-03-27 10:54:02 -07:00
|
|
|
"""
|
|
|
|
|
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()
|
2026-03-27 10:54:02 -07:00
|
|
|
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
|
2026-03-27 10:54:02 -07:00
|
|
|
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
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-03-27 10:54:02 -07:00
|
|
|
# ── Disabled skills ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 11:14:58 -07:00
|
|
|
_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
|
|
|
|
|
|
|
|
|
|
|
2026-04-03 10:10:53 -07:00
|
|
|
def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
|
2026-03-27 10:54:02 -07:00
|
|
|
"""Read disabled skill names from config.yaml.
|
|
|
|
|
|
2026-04-03 10:10:53 -07:00
|
|
|
Args:
|
|
|
|
|
platform: Explicit platform name (e.g. ``"telegram"``). When
|
|
|
|
|
*None*, resolves from ``HERMES_PLATFORM`` or
|
2026-06-14 22:54:54 +05:30
|
|
|
``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).
|
2026-04-03 10:10:53 -07:00
|
|
|
|
|
|
|
|
Reads the config file directly (no CLI config imports) to stay
|
|
|
|
|
lightweight.
|
2026-03-27 10:54:02 -07:00
|
|
|
"""
|
2026-06-14 11:14:58 -07:00
|
|
|
parsed = _load_raw_config()
|
|
|
|
|
if not parsed:
|
2026-03-27 10:54:02 -07:00
|
|
|
return set()
|
|
|
|
|
|
|
|
|
|
skills_cfg = parsed.get("skills")
|
|
|
|
|
if not isinstance(skills_cfg, dict):
|
|
|
|
|
return set()
|
|
|
|
|
|
fix(gateway): replace os.environ session state with contextvars for concurrency safety
When two gateway messages arrived concurrently, _set_session_env wrote
HERMES_SESSION_PLATFORM/CHAT_ID/CHAT_NAME/THREAD_ID into the process-global
os.environ. Because asyncio tasks share the same process, Message B would
overwrite Message A's values mid-flight, causing background-task notifications
and tool calls to route to the wrong thread/chat.
Replace os.environ with Python's contextvars.ContextVar. Each asyncio task
(and any run_in_executor thread it spawns) gets its own copy, so concurrent
messages never interfere.
Changes:
- New gateway/session_context.py with ContextVar definitions, set/clear/get
helpers, and os.environ fallback for CLI/cron/test backward compatibility
- gateway/run.py: _set_session_env returns reset tokens, _clear_session_env
accepts them for proper cleanup in finally blocks
- All tool consumers updated: cronjob_tools, send_message_tool, skills_tool,
terminal_tool (both notify_on_complete AND check_interval blocks), tts_tool,
agent/skill_utils, agent/prompt_builder
- Tests updated for new contextvar-based API
Fixes #7358
Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-04-10 16:50:56 -07:00
|
|
|
from gateway.session_context import get_session_env
|
2026-04-03 10:10:53 -07:00
|
|
|
resolved_platform = (
|
|
|
|
|
platform
|
|
|
|
|
or os.getenv("HERMES_PLATFORM")
|
fix(gateway): replace os.environ session state with contextvars for concurrency safety
When two gateway messages arrived concurrently, _set_session_env wrote
HERMES_SESSION_PLATFORM/CHAT_ID/CHAT_NAME/THREAD_ID into the process-global
os.environ. Because asyncio tasks share the same process, Message B would
overwrite Message A's values mid-flight, causing background-task notifications
and tool calls to route to the wrong thread/chat.
Replace os.environ with Python's contextvars.ContextVar. Each asyncio task
(and any run_in_executor thread it spawns) gets its own copy, so concurrent
messages never interfere.
Changes:
- New gateway/session_context.py with ContextVar definitions, set/clear/get
helpers, and os.environ fallback for CLI/cron/test backward compatibility
- gateway/run.py: _set_session_env returns reset tokens, _clear_session_env
accepts them for proper cleanup in finally blocks
- All tool consumers updated: cronjob_tools, send_message_tool, skills_tool,
terminal_tool (both notify_on_complete AND check_interval blocks), tts_tool,
agent/skill_utils, agent/prompt_builder
- Tests updated for new contextvar-based API
Fixes #7358
Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-04-10 16:50:56 -07:00
|
|
|
or get_session_env("HERMES_SESSION_PLATFORM")
|
2026-04-03 10:10:53 -07:00
|
|
|
)
|
2026-06-14 22:52:51 +05:30
|
|
|
global_disabled = _normalize_string_set(skills_cfg.get("disabled"))
|
2026-03-27 10:54:02 -07:00
|
|
|
if resolved_platform:
|
|
|
|
|
platform_disabled = (skills_cfg.get("platform_disabled") or {}).get(
|
|
|
|
|
resolved_platform
|
|
|
|
|
)
|
|
|
|
|
if platform_disabled is not None:
|
2026-06-14 22:52:51 +05:30
|
|
|
return global_disabled | _normalize_string_set(platform_disabled)
|
|
|
|
|
return global_disabled
|
2026-03-27 10:54:02 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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()}
|
|
|
|
|
|
|
|
|
|
|
2026-03-29 00:33:30 -07:00
|
|
|
# ── 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()
|
2026-06-14 11:14:58 -07:00
|
|
|
_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
|
|
|
|
2026-03-29 00:33:30 -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.
|
2026-03-29 00:33:30 -07:00
|
|
|
"""
|
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()
|
2026-03-29 00:33:30 -07:00
|
|
|
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)
|
|
|
|
|
|
2026-06-14 11:14:58 -07:00
|
|
|
parsed = _load_raw_config()
|
|
|
|
|
if not parsed:
|
2026-03-29 00:33:30 -07:00
|
|
|
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
|
2026-03-29 00:33:30 -07:00
|
|
|
if isinstance(raw_dirs, str):
|
|
|
|
|
raw_dirs = [raw_dirs]
|
|
|
|
|
if not isinstance(raw_dirs, list):
|
|
|
|
|
return []
|
|
|
|
|
|
2026-04-15 08:59:51 +08:00
|
|
|
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()
|
2026-03-29 00:33:30 -07:00
|
|
|
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 = []
|
2026-03-29 00:33:30 -07:00
|
|
|
|
|
|
|
|
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))
|
2026-04-15 08:59:51 +08:00
|
|
|
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()
|
2026-03-29 00:33:30 -07:00
|
|
|
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)
|
2026-03-29 00:33:30 -07:00
|
|
|
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()]
|
2026-03-29 00:33:30 -07:00
|
|
|
dirs.extend(get_external_skills_dirs())
|
|
|
|
|
return dirs
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 20:28:34 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-03-27 10:54:02 -07:00
|
|
|
# ── Condition extraction ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_skill_conditions(frontmatter: Dict[str, Any]) -> Dict[str, List]:
|
|
|
|
|
"""Extract conditional activation fields from parsed frontmatter."""
|
2026-04-01 18:27:22 +08:00
|
|
|
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 = {}
|
2026-03-27 10:54:02 -07:00
|
|
|
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", []),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
feat(skills): add skill config interface + llm-wiki skill (#5635)
Skills can now declare config.yaml settings via metadata.hermes.config
in their SKILL.md frontmatter. Values are stored under skills.config.*
namespace, prompted during hermes config migrate, shown in hermes config
show, and injected into the skill context at load time.
Also adds the llm-wiki skill (Karpathy's LLM Wiki pattern) as the first
skill to use the new config interface, declaring wiki.path.
Skill config interface (new):
- agent/skill_utils.py: extract_skill_config_vars(), discover_all_skill_config_vars(),
resolve_skill_config_values(), SKILL_CONFIG_PREFIX
- agent/skill_commands.py: _inject_skill_config() injects resolved values
into skill messages as [Skill config: ...] block
- hermes_cli/config.py: get_missing_skill_config_vars(), skill config
prompting in migrate_config(), Skill Settings in show_config()
LLM Wiki skill (skills/research/llm-wiki/SKILL.md):
- Three-layer architecture (raw sources, wiki pages, schema)
- Three operations (ingest, query, lint)
- Session orientation, page thresholds, tag taxonomy, update policy,
scaling guidance, log rotation, archiving workflow
Docs: creating-skills.md, configuration.md, skills.md, skills-catalog.md
Closes #5100
2026-04-06 13:49:13 -07:00
|
|
|
# ── 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``.
|
|
|
|
|
"""
|
2026-06-14 11:14:58 -07:00
|
|
|
config = _load_raw_config()
|
feat(skills): add skill config interface + llm-wiki skill (#5635)
Skills can now declare config.yaml settings via metadata.hermes.config
in their SKILL.md frontmatter. Values are stored under skills.config.*
namespace, prompted during hermes config migrate, shown in hermes config
show, and injected into the skill context at load time.
Also adds the llm-wiki skill (Karpathy's LLM Wiki pattern) as the first
skill to use the new config interface, declaring wiki.path.
Skill config interface (new):
- agent/skill_utils.py: extract_skill_config_vars(), discover_all_skill_config_vars(),
resolve_skill_config_values(), SKILL_CONFIG_PREFIX
- agent/skill_commands.py: _inject_skill_config() injects resolved values
into skill messages as [Skill config: ...] block
- hermes_cli/config.py: get_missing_skill_config_vars(), skill config
prompting in migrate_config(), Skill Settings in show_config()
LLM Wiki skill (skills/research/llm-wiki/SKILL.md):
- Three-layer architecture (raw sources, wiki pages, schema)
- Three operations (ingest, query, lint)
- Session orientation, page thresholds, tag taxonomy, update policy,
scaling guidance, log rotation, archiving workflow
Docs: creating-skills.md, configuration.md, skills.md, skills-catalog.md
Closes #5100
2026-04-06 13:49:13 -07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-03-27 10:54:02 -07:00
|
|
|
# ── 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*.
|
|
|
|
|
|
2026-06-16 03:45:56 +02:00
|
|
|
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.
|
2026-03-27 10:54:02 -07:00
|
|
|
"""
|
|
|
|
|
matches = []
|
2026-04-18 02:04:23 +03:00
|
|
|
for root, dirs, files in os.walk(skills_dir, followlinks=True):
|
2026-06-16 03:45:56 +02:00
|
|
|
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)
|
|
|
|
|
]
|
2026-03-27 10:54:02 -07:00
|
|
|
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
|
2026-04-14 10:32:00 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 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))
|