{"repo":"NousResearch/hermes-agent","public_id":"3342","display_label":"Skills metadata caching and startup performance work","summary":"A collection of performance-focused changes targeting time-to-first-token and agent startup overhead, primarily through caching and optimizing how skills prompt metadata and snapshots are built, stored, and validated across agents and processes.","subsystem":"agent","counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":3421},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-08T21:11:25+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22714285714285712},"fix_edges":[{"issue_number":3352,"pr_number":3421,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":3354,"pr_number":3421,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":3354,"pr_number":61131,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":3356,"pr_number":61131,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":3342,"title":"[superseded] perf(ttft): add a disk-backed skills metadata snapshot for fresh processes","url":null,"state":"closed"},{"kind":"issue","number":3354,"title":"perf(ttft): add a disk-backed skills metadata snapshot for fresh processes","url":null,"state":"closed"},{"kind":"pr","number":3345,"title":"perf(ttft): reduce pre-request startup overhead","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3361","number":3361},{"kind":"pr","value":"#3348","number":3348},{"kind":"pr","value":"#3362","number":3362},{"kind":"pr","value":"#3349","number":3349},{"kind":"pr","value":"#3363","number":3363},{"kind":"pr","value":"#3350","number":3350},{"kind":"pr","value":"#3364","number":3364},{"kind":"pr","value":"#3351","number":3351},{"kind":"pr","value":"#3365","number":3365},{"kind":"pr","value":"#3353","number":3353},{"kind":"pr","value":"#3366","number":3366},{"kind":"pr","value":"#3352","number":3352},{"kind":"pr","value":"#3354","number":3354},{"kind":"pr","value":"#3367","number":3367},{"kind":"pr","value":"#3355","number":3355}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":3365,"title":"perf(ttft): move runtime metadata out of cached prompt","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3395","number":3395},{"kind":"pr","value":"#3421","number":3421}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":3366,"title":"perf(ttft): cache skills prompt metadata across agents and processes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3421","number":3421}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":3421,"title":"perf(ttft): cache skills prompt with shared skill_utils module (salvage #3366)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/prompt_builder.py","agent/skill_utils.py","hermes_cli/skills_hub.py","tests/agent/test_prompt_builder.py","tests/agent/test_skill_commands.py","tests/tools/test_skills_tool.py","tools/skill_manager_tool.py","tools/skills_tool.py"],"fix_hunks":[{"file":"agent/prompt_builder.py","hunks":["@@ -4,14 +4,27 @@\n assemble pieces, then combines them with memory and ephemeral prompts.\n \"\"\"\n \n+import json\n import logging\n import os\n import re\n+import threading\n+from collections import OrderedDict\n from pathlib import Path\n \n from hermes_constants import get_hermes_home\n from typing import Optional\n \n+from agent.skill_utils import (\n+    extract_skill_conditions,\n+    extract_skill_description,\n+    get_disabled_skill_names,\n+    iter_skill_index_files,\n+    parse_frontmatter,\n+    skill_matches_platform,\n+)\n+from utils import atomic_json_write\n+\n logger = logging.getLogger(__name__)\n \n # ---------------------------------------------------------------------------","@@ -230,6 +243,111 @@ def _strip_yaml_frontmatter(content: str) -> str:\n CONTEXT_TRUNCATE_TAIL_RATIO = 0.2\n \n \n+# =========================================================================\n+# Skills prompt cache\n+# =========================================================================\n+\n+_SKILLS_PROMPT_CACHE_MAX = 8\n+_SKILLS_PROMPT_CACHE: OrderedDict[tuple, str] = OrderedDict()\n+_SKILLS_PROMPT_CACHE_LOCK = threading.Lock()\n+_SKILLS_SNAPSHOT_VERSION = 1\n+\n+\n+def _skills_prompt_snapshot_path() -> Path:\n+    return get_hermes_home() / \".skills_prompt_snapshot.json\"\n+\n+\n+def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None:\n+    \"\"\"Drop the in-process skills prompt cache (and optionally the disk snapshot).\"\"\"\n+    with _SKILLS_PROMPT_CACHE_LOCK:\n+        _SKILLS_PROMPT_CACHE.clear()\n+    if clear_snapshot:\n+        try:\n+            _skills_prompt_snapshot_path().unlink(missing_ok=True)\n+        except OSError as e:\n+            logger.debug(\"Could not remove skills prompt snapshot: %s\", e)\n+\n+\n+def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]:\n+    \"\"\"Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files.\"\"\"\n+    manifest: dict[str, list[int]] = {}\n+    for filename in (\"SKILL.md\", \"DESCRIPTION.md\"):\n+        for path in iter_skill_index_files(skills_dir, filename):\n+            try:\n+                st = path.stat()\n+            except OSError:\n+                continue\n+            manifest[str(path.relative_to(skills_dir))] = [st.st_mtime_ns, st.st_size]\n+    return manifest\n+\n+\n+def _load_skills_snapshot(skills_dir: Path) -> Optional[dict]:\n+    \"\"\"Load the disk snapshot if it exists and its manifest still matches.\"\"\"\n+    snapshot_path = _skills_prompt_snapshot_path()\n+    if not snapshot_path.exists():\n+        return None\n+    try:\n+        snapshot = json.loads(snapshot_path.read_text(encoding=\"utf-8\"))\n+    except Exception:\n+        return None\n+    if not isinstance(snapshot, dict):\n+        return None\n+    if snapshot.get(\"version\") != _SKILLS_SNAPSHOT_VERSION:\n+        return None\n+    if snapshot.get(\"manifest\") != _build_skills_manifest(skills_dir):\n+        return None\n+    return snapshot\n+\n+\n+def _write_skills_snapshot(\n+    skills_dir: Path,\n+    manifest: dict[str, list[int]],\n+    skill_entries: list[dict],\n+    category_descriptions: dict[str, str],\n+) -> None:\n+    \"\"\"Persist skill metadata to disk for fast cold-start reuse.\"\"\"\n+    payload = {\n+        \"version\": _SKILLS_SNAPSHOT_VERSION,\n+        \"manifest\": manifest,\n+        \"skills\": skill_entries,\n+        \"category_descriptions\": category_descriptions,\n+    }\n+    try:\n+        atomic_json_write(_skills_prompt_snapshot_path(), payload)\n+    except Exception as e:\n+        logger.debug(\"Could not write skills prompt snapshot: %s\", e)\n+\n+\n+def _build_snapshot_entry(\n+    skill_file: Path,\n+    skills_dir: Path,\n+    frontmatter: dict,\n+    description: str,\n+) -> dict:\n+    \"\"\"Build a serialisable metadata dict for one skill.\"\"\"\n+    rel_path = skill_file.relative_to(skills_dir)\n+    parts = rel_path.parts\n+    if len(parts) >= 2:\n+        skill_name = parts[-2]\n+        category = \"/\".join(parts[:-2]) if len(parts) > 2 else parts[0]\n+    else:\n+        category = \"general\"\n+        skill_name = skill_file.parent.name\n+\n+    platforms = frontmatter.get(\"platforms\") or []\n+    if isinstance(platforms, str):\n+        platforms = [platforms]\n+\n+    return {\n+        \"skill_name\": skill_name,\n+        \"category\": category,\n+        \"frontmatter_name\": str(frontmatter.get(\"name\", skill_name)),\n+        \"description\": description,\n+        \"platforms\": [str(p).strip() for p in platforms if str(p).strip()],\n+        \"conditions\": extract_skill_conditions(frontmatter),\n+    }\n+\n+\n # =========================================================================\n # Skills index\n # =========================================================================","@@ -241,22 +359,13 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:\n     (True, {}, \"\") to err on the side of showing the skill.\n     \"\"\"\n     try:\n-        from tools.skills_tool import _parse_frontmatter, skill_matches_platform\n-\n         raw = skill_file.read_text(encoding=\"utf-8\")[:2000]\n-        frontmatter, _ = _parse_frontmatter(raw)\n+        frontmatter, _ = parse_frontmatter(raw)\n \n         if not skill_matches_platform(frontmatter):\n-            return False, {}, \"\"\n+            return False, frontmatter, \"\"\n \n-        desc = \"\"\n-        raw_desc = frontmatter.get(\"description\", \"\")\n-        if raw_desc:\n-            desc = str(raw_desc).strip().strip(\"'\\\"\")\n-            if len(desc) > 60:\n-                desc = desc[:57] + \"...\"\n-\n-        return True, frontmatter, desc\n+        return True, frontmatter, extract_skill_description(frontmatter)\n     except Exception as e:\n         logger.debug(\"Failed to parse skill file %s: %s\", skill_file, e)\n         return True, {}, \"\"","@@ -265,16 +374,9 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:\n def _read_skill_conditions(skill_file: Path) -> dict:\n     \"\"\"Extract conditional activation fields from SKILL.md frontmatter.\"\"\"\n     try:\n-        from tools.skills_tool import _parse_frontmatter\n         raw = skill_file.read_text(encoding=\"utf-8\")[:2000]\n-        frontmatter, _ = _parse_frontmatter(raw)\n-        hermes = frontmatter.get(\"metadata\", {}).get(\"hermes\", {})\n-        return {\n-            \"fallback_for_toolsets\": hermes.get(\"fallback_for_toolsets\", []),\n-            \"requires_toolsets\": hermes.get(\"requires_toolsets\", []),\n-            \"fallback_for_tools\": hermes.get(\"fallback_for_tools\", []),\n-            \"requires_tools\": hermes.get(\"requires_tools\", []),\n-        }\n+        frontmatter, _ = parse_frontmatter(raw)\n+        return extract_skill_conditions(frontmatter)\n     except Exception as e:\n         logger.debug(\"Failed to read skill conditions from %s: %s\", skill_file, e)\n         return {}","@@ -317,10 +419,12 @@ def build_skills_system_prompt(\n ) -> str:\n     \"\"\"Build a compact skill index for the system prompt.\n \n-    Scans ~/.hermes/skills/ for SKILL.md files grouped by category.\n-    Includes per-skill descriptions from frontmatter so the model can\n-    match skills by meaning, not just name.\n-    Filters out skills incompatible with the current OS platform.\n+    Two-layer cache:\n+      1. In-process LRU dict keyed by (skills_dir, tools, toolsets)\n+      2. Disk snapshot (``.skills_prompt_snapshot.json``) validated by\n+         mtime/size manifest — survives process restarts\n+\n+    Falls back to a full filesystem scan when both layers miss.\n     \"\"\"\n     hermes_home = get_hermes_home()\n     skills_dir = hermes_home / \"skills\"","@@ -328,98 +432,140 @@ def build_skills_system_prompt(\n     if not skills_dir.exists():\n         return \"\"\n \n-    # Collect skills with descriptions, grouped by category.\n-    # Each entry: (skill_name, description)\n-    # Supports sub-categories: skills/mlops/training/axolotl/SKILL.md\n-    # -> category \"mlops/training\", skill \"axolotl\"\n-    # Load disabled skill names once for the entire scan\n-    try:\n-        from tools.skills_tool import _get_disabled_skill_names\n-        disabled = _get_disabled_skill_names()\n-    except Exception:\n-        disabled = set()\n+    # ── Layer 1: in-process LRU cache ─────────────────────────────────\n+    cache_key = (\n+        str(skills_dir.resolve()),\n+        tuple(sorted(str(t) for t in (available_tools or set()))),\n+        tuple(sorted(str(ts) for ts in (available_toolsets or set()))),\n+    )\n+    with _SKILLS_PROMPT_CACHE_LOCK:\n+        cached = _SKILLS_PROMPT_CACHE.get(cache_key)\n+        if cached is not None:\n+            _SKILLS_PROMPT_CACHE.move_to_end(cache_key)\n+            return cached\n+\n+    disabled = get_disabled_skill_names()\n+\n+    # ── Layer 2: disk snapshot ────────────────────────────────────────\n+    snapshot = _load_skills_snapshot(skills_dir)\n \n     skills_by_category: dict[str, list[tuple[str, str]]] = {}\n-    for skill_file in skills_dir.rglob(\"SKILL.md\"):\n-        is_compatible, frontmatter, desc = _parse_skill_file(skill_file)\n-        if not is_compatible:\n-            continue\n-        rel_path = skill_file.relative_to(skills_dir)\n-        parts = rel_path.parts\n-        if len(parts) >= 2:\n-            skill_name = parts[-2]\n-            category = \"/\".join(parts[:-2]) if len(parts) > 2 else parts[0]\n-        else:\n-            category = \"general\"\n-            skill_name = skill_file.parent.name\n-        # Respect user's disabled skills config\n-        fm_name = frontmatter.get(\"name\", skill_name)\n-        if fm_name in disabled or skill_name in disabled:\n-            continue\n-        # Extract conditions inline from already-parsed frontmatter\n-        # (avoids redundant file re-read that _read_skill_conditions would do)\n-        hermes_meta = (frontmatter.get(\"metadata\") or {}).get(\"hermes\") or {}\n-        conditions = {\n-            \"fallback_for_toolsets\": hermes_meta.get(\"fallback_for_toolsets\", []),\n-            \"requires_toolsets\": hermes_meta.get(\"requires_toolsets\", []),\n-            \"fallback_for_tools\": hermes_meta.get(\"fallback_for_tools\", []),\n-            \"requires_tools\": hermes_meta.get(\"requires_tools\", []),\n-        }\n-        if not _skill_should_show(conditions, available_tools, available_toolsets):\n-            continue\n-        skills_by_category.setdefault(category, []).append((skill_name, desc))\n+    category_descriptions: dict[str, str] = {}\n \n-    if not skills_by_category:\n-        return \"\"\n+    if snapshot is not None:\n+        # Fast path: use pre-parsed metadata from disk\n+        for entry in snapshot.get(\"skills\", []):\n+            if not isinstance(entry, dict):\n+                continue\n+            skill_name = entry.get(\"skill_name\") or \"\"\n+            category = entry.get(\"category\") or \"general\"\n+            frontmatter_name = entry.get(\"frontmatter_name\") or skill_name\n+            platforms = entry.get(\"platforms\") or []\n+            if not skill_matches_platform({\"platforms\": platforms}):\n+                continue\n+            if frontmatter_name in disabled or skill_name in disabled:\n+                continue\n+            if not _skill_should_show(\n+                entry.get(\"conditions\") or {},\n+                available_tools,\n+                available_toolsets,\n+            ):\n+                continue\n+            skills_by_category.setdefault(category, []).append(\n+                (skill_name, entry.get(\"description\", \"\"))\n+            )\n+        category_descriptions = {\n+            str(k): str(v)\n+            for k, v in (snapshot.get(\"category_descriptions\") or {}).items()\n+        }\n+    else:\n+        # Cold path: full filesystem scan + write snapshot for next time\n+        skill_entries: list[dict] = []\n+        for skill_file in iter_skill_index_files(skills_dir, \"SKILL.md\"):\n+            is_compatible, frontmatter, desc = _parse_skill_file(skill_file)\n+            entry = _build_snapshot_entry(skill_file, skills_dir, frontmatter, desc)\n+            skill_entries.append(entry)\n+            if not is_compatible:\n+                continue\n+            skill_name = entry[\"skill_name\"]\n+            if entry[\"frontmatter_name\"] in disabled or skill_name in disabled:\n+                continue\n+            if not _skill_should_show(\n+                extract_skill_conditions(frontmatter),\n+                available_tools,\n+                available_toolsets,\n+            ):\n+                continue\n+            skills_by_category.setdefault(entry[\"category\"], []).append(\n+                (skill_name, entry[\"description\"])\n+            )\n \n-    # Read category-level descriptions from DESCRIPTION.md\n-    # Checks both the exact category path and parent directories\n-    category_descriptions = {}\n-    for category in skills_by_category:\n-        cat_path = Path(category)\n-        desc_file = skills_dir / cat_path / \"DESCRIPTION.md\"\n-        if desc_file.exists():\n+        # Read category-level DESCRIPTION.md files\n+        for desc_file in iter_skill_index_files(skills_dir, \"DESCRIPTION.md\"):\n             try:\n                 content = desc_file.read_text(encoding=\"utf-8\")\n-                match = re.search(r\"^---\\s*\\n.*?description:\\s*(.+?)\\s*\\n.*?^---\", content, re.MULTILINE | re.DOTALL)\n-                if match:\n-                    category_descriptions[category] = match.group(1).strip()\n+                fm, _ = parse_frontmatter(content)\n+                cat_desc = fm.get(\"description\")\n+                if not cat_desc:\n+                    continue\n+                rel = desc_file.relative_to(skills_dir)\n+                cat = \"/\".join(rel.parts[:-1]) if len(rel.parts) > 1 else \"general\"\n+                category_descriptions[cat] = str(cat_desc).strip().strip(\"'\\\"\")\n             except Exception as e:\n                 logger.debug(\"Could not read skill description %s: %s\", desc_file, e)\n \n-    index_lines = []\n-    for category in sorted(skills_by_category.keys()):\n-        cat_desc = category_descriptions.get(category, \"\")\n-        if cat_desc:\n-            index_lines.append(f\"  {category}: {cat_desc}\")\n-        else:\n-            index_lines.append(f\"  {category}:\")\n-        # Deduplicate and sort skills within each category\n-        seen = set()\n-        for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]):\n-            if name in seen:\n-                continue\n-            seen.add(name)\n-            if desc:\n-                index_lines.append(f\"    - {name}: {desc}\")\n+        _write_skills_snapshot(\n+            skills_dir,\n+            _build_skills_manifest(skills_dir),\n+            skill_entries,\n+            category_descriptions,\n+        )\n+\n+    if not skills_by_category:\n+        result = \"\"\n+    else:\n+        index_lines = []\n+        for category in sorted(skills_by_category.keys()):\n+            cat_desc = category_descriptions.get(category, \"\")\n+            if cat_desc:\n+                index_lines.append(f\"  {category}: {cat_desc}\")\n             else:\n-                index_lines.append(f\"    - {name}\")\n-\n-    return (\n-        \"## Skills (mandatory)\\n\"\n-        \"Before replying, scan the skills below. If one clearly matches your task, \"\n-        \"load it with skill_view(name) and follow its instructions. \"\n-        \"If a skill has issues, fix it with skill_manage(action='patch').\\n\"\n-        \"After difficult/iterative tasks, offer to save as a skill. \"\n-        \"If a skill you loaded was missing steps, had wrong commands, or needed \"\n-        \"pitfalls you discovered, update it before finishing.\\n\"\n-        \"\\n\"\n-        \"<available_skills>\\n\"\n-        + \"\\n\".join(index_lines) + \"\\n\"\n-        \"</available_skills>\\n\"\n-        \"\\n\"\n-        \"If none match, proceed normally without loading a skill.\"\n-    )\n+                index_lines.append(f\"  {category}:\")\n+            # Deduplicate and sort skills within each category\n+            seen = set()\n+            for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]):\n+                if name in seen:\n+                    continue\n+                seen.add(name)\n+                if desc:\n+                    index_lines.append(f\"    - {name}: {desc}\")\n+                else:\n+                    index_lines.append(f\"    - {name}\")\n+\n+        result = (\n+            \"## Skills (mandatory)\\n\"\n+            \"Before replying, scan the skills below. If one clearly matches your task, \"\n+            \"load it with skill_view(name) and follow its instructions. \"\n+            \"If a skill has issues, fix it with skill_manage(action='patch').\\n\"\n+            \"After difficult/iterative tasks, offer to save as a skill. \"\n+            \"If a skill you loaded was missing steps, had wrong commands, or needed \"\n+            \"pitfalls you discovered, update it before finishing.\\n\"\n+            \"\\n\"\n+            \"<available_skills>\\n\"\n+            + \"\\n\".join(index_lines) + \"\\n\"\n+            \"</available_skills>\\n\"\n+            \"\\n\"\n+            \"If none match, proceed normally without loading a skill.\"\n+        )\n+\n+    # ── Store in LRU cache ────────────────────────────────────────────\n+    with _SKILLS_PROMPT_CACHE_LOCK:\n+        _SKILLS_PROMPT_CACHE[cache_key] = result\n+        _SKILLS_PROMPT_CACHE.move_to_end(cache_key)\n+        while len(_SKILLS_PROMPT_CACHE) > _SKILLS_PROMPT_CACHE_MAX:\n+            _SKILLS_PROMPT_CACHE.popitem(last=False)\n+\n+    return result\n \n \n # ========================================================================="]},{"file":"agent/skill_utils.py","hunks":["@@ -0,0 +1,203 @@\n+\"\"\"Lightweight skill metadata utilities shared by prompt_builder and skills_tool.\n+\n+This module intentionally avoids importing the tool registry, CLI config, or any\n+heavy dependency chain.  It is safe to import at module level without triggering\n+tool registration or provider resolution.\n+\"\"\"\n+\n+import logging\n+import os\n+import re\n+import sys\n+from pathlib import Path\n+from typing import Any, Dict, List, Optional, Set, Tuple\n+\n+from hermes_constants import get_hermes_home\n+\n+logger = logging.getLogger(__name__)\n+\n+# ── Platform mapping ──────────────────────────────────────────────────────\n+\n+PLATFORM_MAP = {\n+    \"macos\": \"darwin\",\n+    \"linux\": \"linux\",\n+    \"windows\": \"win32\",\n+}\n+\n+EXCLUDED_SKILL_DIRS = frozenset((\".git\", \".github\", \".hub\"))\n+\n+# ── Lazy YAML loader ─────────────────────────────────────────────────────\n+\n+_yaml_load_fn = None\n+\n+\n+def yaml_load(content: str):\n+    \"\"\"Parse YAML with lazy import and CSafeLoader preference.\"\"\"\n+    global _yaml_load_fn\n+    if _yaml_load_fn is None:\n+        import yaml\n+\n+        loader = getattr(yaml, \"CSafeLoader\", None) or yaml.SafeLoader\n+\n+        def _load(value: str):\n+            return yaml.load(value, Loader=loader)\n+\n+        _yaml_load_fn = _load\n+    return _yaml_load_fn(content)\n+\n+\n+# ── Frontmatter parsing ──────────────────────────────────────────────────\n+\n+\n+def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:\n+    \"\"\"Parse YAML frontmatter from a markdown string.\n+\n+    Uses yaml with CSafeLoader for full YAML support (nested metadata, lists)\n+    with a fallback to simple key:value splitting for robustness.\n+\n+    Returns:\n+        (frontmatter_dict, remaining_body)\n+    \"\"\"\n+    frontmatter: Dict[str, Any] = {}\n+    body = content\n+\n+    if not content.startswith(\"---\"):\n+        return frontmatter, body\n+\n+    end_match = re.search(r\"\\n---\\s*\\n\", content[3:])\n+    if not end_match:\n+        return frontmatter, body\n+\n+    yaml_content = content[3 : end_match.start() + 3]\n+    body = content[end_match.end() + 3 :]\n+\n+    try:\n+        parsed = yaml_load(yaml_content)\n+        if isinstance(parsed, dict):\n+            frontmatter = parsed\n+    except Exception:\n+        # Fallback: simple key:value parsing for malformed YAML\n+        for line in yaml_content.strip().split(\"\\n\"):\n+            if \":\" not in line:\n+                continue\n+            key, value = line.split(\":\", 1)\n+            frontmatter[key.strip()] = value.strip()\n+\n+    return frontmatter, body\n+\n+\n+# ── Platform matching ─────────────────────────────────────────────────────\n+\n+\n+def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:\n+    \"\"\"Return True when the skill is compatible with the current OS.\n+\n+    Skills declare platform requirements via a top-level ``platforms`` list\n+    in their YAML frontmatter::\n+\n+        platforms: [macos]          # macOS only\n+        platforms: [macos, linux]   # macOS and Linux\n+\n+    If the field is absent or empty the skill is compatible with **all**\n+    platforms (backward-compatible default).\n+    \"\"\"\n+    platforms = frontmatter.get(\"platforms\")\n+    if not platforms:\n+        return True\n+    if not isinstance(platforms, list):\n+        platforms = [platforms]\n+    current = sys.platform\n+    for platform in platforms:\n+        normalized = str(platform).lower().strip()\n+        mapped = PLATFORM_MAP.get(normalized, normalized)\n+        if current.startswith(mapped):\n+            return True\n+    return False\n+\n+\n+# ── Disabled skills ───────────────────────────────────────────────────────\n+\n+\n+def get_disabled_skill_names() -> Set[str]:\n+    \"\"\"Read disabled skill names from config.yaml.\n+\n+    Resolves platform from ``HERMES_PLATFORM`` env var, falls back to\n+    the global disabled list.  Reads the config file directly (no CLI\n+    config imports) to stay lightweight.\n+    \"\"\"\n+    config_path = get_hermes_home() / \"config.yaml\"\n+    if not config_path.exists():\n+        return set()\n+    try:\n+        parsed = yaml_load(config_path.read_text(encoding=\"utf-8\"))\n+    except Exception as e:\n+        logger.debug(\"Could not read skill config %s: %s\", config_path, e)\n+        return set()\n+    if not isinstance(parsed, dict):\n+        return set()\n+\n+    skills_cfg = parsed.get(\"skills\")\n+    if not isinstance(skills_cfg, dict):\n+        return set()\n+\n+    resolved_platform = os.getenv(\"HERMES_PLATFORM\")\n+    if resolved_platform:\n+        platform_disabled = (skills_cfg.get(\"platform_disabled\") or {}).get(\n+            resolved_platform\n+        )\n+        if platform_disabled is not None:\n+            return _normalize_string_set(platform_disabled)\n+    return _normalize_string_set(skills_cfg.get(\"disabled\"))\n+\n+\n+def _normalize_string_set(values) -> Set[str]:\n+    if values is None:\n+        return set()\n+    if isinstance(values, str):\n+        values = [values]\n+    return {str(v).strip() for v in values if str(v).strip()}\n+\n+\n+# ── Condition extraction ──────────────────────────────────────────────────\n+\n+\n+def extract_skill_conditions(frontmatter: Dict[str, Any]) -> Dict[str, List]:\n+    \"\"\"Extract conditional activation fields from parsed frontmatter.\"\"\"\n+    hermes = (frontmatter.get(\"metadata\") or {}).get(\"hermes\") or {}\n+    return {\n+        \"fallback_for_toolsets\": hermes.get(\"fallback_for_toolsets\", []),\n+        \"requires_toolsets\": hermes.get(\"requires_toolsets\", []),\n+        \"fallback_for_tools\": hermes.get(\"fallback_for_tools\", []),\n+        \"requires_tools\": hermes.get(\"requires_tools\", []),\n+    }\n+\n+\n+# ── Description extraction ────────────────────────────────────────────────\n+\n+\n+def extract_skill_description(frontmatter: Dict[str, Any]) -> str:\n+    \"\"\"Extract a truncated description from parsed frontmatter.\"\"\"\n+    raw_desc = frontmatter.get(\"description\", \"\")\n+    if not raw_desc:\n+        return \"\"\n+    desc = str(raw_desc).strip().strip(\"'\\\"\")\n+    if len(desc) > 60:\n+        return desc[:57] + \"...\"\n+    return desc\n+\n+\n+# ── File iteration ────────────────────────────────────────────────────────\n+\n+\n+def iter_skill_index_files(skills_dir: Path, filename: str):\n+    \"\"\"Walk skills_dir yielding sorted paths matching *filename*.\n+\n+    Excludes ``.git``, ``.github``, ``.hub`` directories.\n+    \"\"\"\n+    matches = []\n+    for root, dirs, files in os.walk(skills_dir):\n+        dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]\n+        if filename in files:\n+            matches.append(Path(root) / filename)\n+    for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):\n+        yield path"]},{"file":"hermes_cli/skills_hub.py","hunks":["@@ -417,6 +417,13 @@ def do_install(identifier: str, category: str = \"\", force: bool = False,\n     c.print(f\"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}\")\n     c.print(f\"[dim]Files: {', '.join(bundle.files.keys())}[/]\\n\")\n \n+    # Invalidate the skills prompt cache so the new skill appears immediately\n+    try:\n+        from agent.prompt_builder import clear_skills_system_prompt_cache\n+        clear_skills_system_prompt_cache(clear_snapshot=True)\n+    except Exception:\n+        pass\n+\n \n def do_inspect(identifier: str, console: Optional[Console] = None) -> None:\n     \"\"\"Preview a skill's SKILL.md content without installing.\"\"\"","@@ -623,6 +630,11 @@ def do_uninstall(name: str, console: Optional[Console] = None,\n     success, msg = uninstall_skill(name)\n     if success:\n         c.print(f\"[bold green]{msg}[/]\\n\")\n+        try:\n+            from agent.prompt_builder import clear_skills_system_prompt_cache\n+            clear_skills_system_prompt_cache(clear_snapshot=True)\n+        except Exception:\n+            pass\n     else:\n         c.print(f\"[bold red]Error:[/] {msg}\\n\")\n "]},{"file":"tests/agent/test_prompt_builder.py","hunks":["@@ -232,7 +232,18 @@ def guarded_import(name, globals=None, locals=None, fromlist=(), level=0):\n # =========================================================================\n \n \n+import pytest\n+\n+\n class TestBuildSkillsSystemPrompt:\n+    @pytest.fixture(autouse=True)\n+    def _clear_skills_cache(self):\n+        \"\"\"Ensure the in-process skills prompt cache doesn't leak between tests.\"\"\"\n+        from agent.prompt_builder import clear_skills_system_prompt_cache\n+        clear_skills_system_prompt_cache(clear_snapshot=True)\n+        yield\n+        clear_skills_system_prompt_cache(clear_snapshot=True)\n+\n     def test_empty_when_no_skills_dir(self, monkeypatch, tmp_path):\n         monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n         result = build_skills_system_prompt()","@@ -302,7 +313,7 @@ def test_includes_matching_platform_skills(self, monkeypatch, tmp_path):\n \n         from unittest.mock import patch\n \n-        with patch(\"tools.skills_tool.sys\") as mock_sys:\n+        with patch(\"agent.skill_utils.sys\") as mock_sys:\n             mock_sys.platform = \"darwin\"\n             result = build_skills_system_prompt()\n ","@@ -330,7 +341,7 @@ def test_excludes_disabled_skills(self, monkeypatch, tmp_path):\n         from unittest.mock import patch\n \n         with patch(\n-            \"tools.skills_tool._get_disabled_skill_names\",\n+            \"agent.prompt_builder.get_disabled_skill_names\",\n             return_value={\"old-tool\"},\n         ):\n             result = build_skills_system_prompt()","@@ -804,6 +815,13 @@ def test_requires_tools_shown_when_tool_available(self):\n \n \n class TestBuildSkillsSystemPromptConditional:\n+    @pytest.fixture(autouse=True)\n+    def _clear_skills_cache(self):\n+        from agent.prompt_builder import clear_skills_system_prompt_cache\n+        clear_skills_system_prompt_cache(clear_snapshot=True)\n+        yield\n+        clear_skills_system_prompt_cache(clear_snapshot=True)\n+\n     def test_fallback_skill_hidden_when_primary_available(self, monkeypatch, tmp_path):\n         monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n         skill_dir = tmp_path / \"skills\" / \"search\" / \"duckduckgo\""]},{"file":"tests/agent/test_skill_commands.py","hunks":["@@ -54,7 +54,7 @@ def test_excludes_incompatible_platform(self, tmp_path):\n         \"\"\"macOS-only skills should not register slash commands on Linux.\"\"\"\n         with (\n             patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path),\n-            patch(\"tools.skills_tool.sys\") as mock_sys,\n+            patch(\"agent.skill_utils.sys\") as mock_sys,\n         ):\n             mock_sys.platform = \"linux\"\n             _make_skill(tmp_path, \"imessage\", frontmatter_extra=\"platforms: [macos]\\n\")","@@ -67,7 +67,7 @@ def test_includes_matching_platform(self, tmp_path):\n         \"\"\"macOS-only skills should register slash commands on macOS.\"\"\"\n         with (\n             patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path),\n-            patch(\"tools.skills_tool.sys\") as mock_sys,\n+            patch(\"agent.skill_utils.sys\") as mock_sys,\n         ):\n             mock_sys.platform = \"darwin\"\n             _make_skill(tmp_path, \"imessage\", frontmatter_extra=\"platforms: [macos]\\n\")","@@ -78,7 +78,7 @@ def test_universal_skill_on_any_platform(self, tmp_path):\n         \"\"\"Skills without platforms field should register on any platform.\"\"\"\n         with (\n             patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path),\n-            patch(\"tools.skills_tool.sys\") as mock_sys,\n+            patch(\"agent.skill_utils.sys\") as mock_sys,\n         ):\n             mock_sys.platform = \"win32\"\n             _make_skill(tmp_path, \"generic-tool\")"]},{"file":"tests/tools/test_skills_tool.py","hunks":["@@ -589,38 +589,38 @@ def test_empty_platforms_matches_everything(self):\n         assert skill_matches_platform({\"platforms\": None}) is True\n \n     def test_macos_on_darwin(self):\n-        with patch(\"tools.skills_tool.sys\") as mock_sys:\n+        with patch(\"agent.skill_utils.sys\") as mock_sys:\n             mock_sys.platform = \"darwin\"\n             assert skill_matches_platform({\"platforms\": [\"macos\"]}) is True\n \n     def test_macos_on_linux(self):\n-        with patch(\"tools.skills_tool.sys\") as mock_sys:\n+        with patch(\"agent.skill_utils.sys\") as mock_sys:\n             mock_sys.platform = \"linux\"\n             assert skill_matches_platform({\"platforms\": [\"macos\"]}) is False\n \n     def test_linux_on_linux(self):\n-        with patch(\"tools.skills_tool.sys\") as mock_sys:\n+        with patch(\"agent.skill_utils.sys\") as mock_sys:\n             mock_sys.platform = \"linux\"\n             assert skill_matches_platform({\"platforms\": [\"linux\"]}) is True\n \n     def test_linux_on_darwin(self):\n-        with patch(\"tools.skills_tool.sys\") as mock_sys:\n+        with patch(\"agent.skill_utils.sys\") as mock_sys:\n             mock_sys.platform = \"darwin\"\n             assert skill_matches_platform({\"platforms\": [\"linux\"]}) is False\n \n     def test_windows_on_win32(self):\n-        with patch(\"tools.skills_tool.sys\") as mock_sys:\n+        with patch(\"agent.skill_utils.sys\") as mock_sys:\n             mock_sys.platform = \"win32\"\n             assert skill_matches_platform({\"platforms\": [\"windows\"]}) is True\n \n     def test_windows_on_linux(self):\n-        with patch(\"tools.skills_tool.sys\") as mock_sys:\n+        with patch(\"agent.skill_utils.sys\") as mock_sys:\n             mock_sys.platform = \"linux\"\n             assert skill_matches_platform({\"platforms\": [\"windows\"]}) is False\n \n     def test_multi_platform_match(self):\n         \"\"\"Skills listing multiple platforms should match any of them.\"\"\"\n-        with patch(\"tools.skills_tool.sys\") as mock_sys:\n+        with patch(\"agent.skill_utils.sys\") as mock_sys:\n             mock_sys.platform = \"darwin\"\n             assert skill_matches_platform({\"platforms\": [\"macos\", \"linux\"]}) is True\n             mock_sys.platform = \"linux\"","@@ -630,20 +630,20 @@ def test_multi_platform_match(self):\n \n     def test_string_instead_of_list(self):\n         \"\"\"A single string value should be treated as a one-element list.\"\"\"\n-        with patch(\"tools.skills_tool.sys\") as mock_sys:\n+        with patch(\"agent.skill_utils.sys\") as mock_sys:\n             mock_sys.platform = \"darwin\"\n             assert skill_matches_platform({\"platforms\": \"macos\"}) is True\n             mock_sys.platform = \"linux\"\n             assert skill_matches_platform({\"platforms\": \"macos\"}) is False\n \n     def test_case_insensitive(self):\n-        with patch(\"tools.skills_tool.sys\") as mock_sys:\n+        with patch(\"agent.skill_utils.sys\") as mock_sys:\n             mock_sys.platform = \"darwin\"\n             assert skill_matches_platform({\"platforms\": [\"MacOS\"]}) is True\n             assert skill_matches_platform({\"platforms\": [\"MACOS\"]}) is True\n \n     def test_unknown_platform_no_match(self):\n-        with patch(\"tools.skills_tool.sys\") as mock_sys:\n+        with patch(\"agent.skill_utils.sys\") as mock_sys:\n             mock_sys.platform = \"linux\"\n             assert skill_matches_platform({\"platforms\": [\"freebsd\"]}) is False\n ","@@ -659,7 +659,7 @@ class TestFindAllSkillsPlatformFiltering:\n     def test_excludes_incompatible_platform(self, tmp_path):\n         with (\n             patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path),\n-            patch(\"tools.skills_tool.sys\") as mock_sys,\n+            patch(\"agent.skill_utils.sys\") as mock_sys,\n         ):\n             mock_sys.platform = \"linux\"\n             _make_skill(tmp_path, \"universal-skill\")","@@ -672,7 +672,7 @@ def test_excludes_incompatible_platform(self, tmp_path):\n     def test_includes_matching_platform(self, tmp_path):\n         with (\n             patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path),\n-            patch(\"tools.skills_tool.sys\") as mock_sys,\n+            patch(\"agent.skill_utils.sys\") as mock_sys,\n         ):\n             mock_sys.platform = \"darwin\"\n             _make_skill(tmp_path, \"mac-only\", frontmatter_extra=\"platforms: [macos]\\n\")","@@ -684,7 +684,7 @@ def test_no_platforms_always_included(self, tmp_path):\n         \"\"\"Skills without platforms field should appear on any platform.\"\"\"\n         with (\n             patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path),\n-            patch(\"tools.skills_tool.sys\") as mock_sys,\n+            patch(\"agent.skill_utils.sys\") as mock_sys,\n         ):\n             mock_sys.platform = \"win32\"\n             _make_skill(tmp_path, \"generic-skill\")","@@ -695,7 +695,7 @@ def test_no_platforms_always_included(self, tmp_path):\n     def test_multi_platform_skill(self, tmp_path):\n         with (\n             patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path),\n-            patch(\"tools.skills_tool.sys\") as mock_sys,\n+            patch(\"agent.skill_utils.sys\") as mock_sys,\n         ):\n             _make_skill(\n                 tmp_path, \"cross-plat\", frontmatter_extra=\"platforms: [macos, linux]\\n\""]},{"file":"tools/skill_manager_tool.py","hunks":["@@ -547,6 +547,13 @@ def skill_manage(\n     else:\n         result = {\"success\": False, \"error\": f\"Unknown action '{action}'. Use: create, edit, patch, delete, write_file, remove_file\"}\n \n+    if result.get(\"success\"):\n+        try:\n+            from agent.prompt_builder import clear_skills_system_prompt_cache\n+            clear_skills_system_prompt_cache(clear_snapshot=True)\n+        except Exception:\n+            pass\n+\n     return json.dumps(result, ensure_ascii=False)\n \n "]},{"file":"tools/skills_tool.py","hunks":["@@ -120,28 +120,11 @@ def set_secret_capture_callback(callback) -> None:\n def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:\n     \"\"\"Check if a skill is compatible with the current OS platform.\n \n-    Skills declare platform requirements via a top-level ``platforms`` list\n-    in their YAML frontmatter::\n-\n-        platforms: [macos]          # macOS only\n-        platforms: [macos, linux]   # macOS and Linux\n-\n-    Valid values: ``macos``, ``linux``, ``windows``.\n-\n-    If the field is absent or empty the skill is compatible with **all**\n-    platforms (backward-compatible default).\n+    Delegates to ``agent.skill_utils.skill_matches_platform`` — kept here\n+    as a public re-export so existing callers don't need updating.\n     \"\"\"\n-    platforms = frontmatter.get(\"platforms\")\n-    if not platforms:\n-        return True  # No restriction → loads everywhere\n-    if not isinstance(platforms, list):\n-        platforms = [platforms]\n-    current = sys.platform\n-    for p in platforms:\n-        mapped = _PLATFORM_MAP.get(str(p).lower().strip(), str(p).lower().strip())\n-        if current.startswith(mapped):\n-            return True\n-    return False\n+    from agent.skill_utils import skill_matches_platform as _impl\n+    return _impl(frontmatter)\n \n \n def _normalize_prerequisite_values(value: Any) -> List[str]:","@@ -419,40 +402,13 @@ def check_skills_requirements() -> bool:\n \n \n def _parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:\n-    \"\"\"\n-    Parse YAML frontmatter from markdown content.\n-\n-    Uses yaml.safe_load for full YAML support (nested metadata, lists, etc.)\n-    with a fallback to simple key:value splitting for robustness.\n+    \"\"\"Parse YAML frontmatter from markdown content.\n \n-    Args:\n-        content: Full markdown file content\n-\n-    Returns:\n-        Tuple of (frontmatter dict, remaining content)\n+    Delegates to ``agent.skill_utils.parse_frontmatter`` — kept here\n+    as a public re-export so existing callers don't need updating.\n     \"\"\"\n-    frontmatter = {}\n-    body = content\n-\n-    if content.startswith(\"---\"):\n-        end_match = re.search(r\"\\n---\\s*\\n\", content[3:])\n-        if end_match:\n-            yaml_content = content[3 : end_match.start() + 3]\n-            body = content[end_match.end() + 3 :]\n-\n-            try:\n-                parsed = yaml.safe_load(yaml_content)\n-                if isinstance(parsed, dict):\n-                    frontmatter = parsed\n-                # yaml.safe_load returns None for empty frontmatter\n-            except yaml.YAMLError:\n-                # Fallback: simple key:value parsing for malformed YAML\n-                for line in yaml_content.strip().split(\"\\n\"):\n-                    if \":\" in line:\n-                        key, value = line.split(\":\", 1)\n-                        frontmatter[key.strip()] = value.strip()\n-\n-    return frontmatter, body\n+    from agent.skill_utils import parse_frontmatter\n+    return parse_frontmatter(content)\n \n \n def _get_category_from_path(skill_path: Path) -> Optional[str]:","@@ -516,24 +472,13 @@ def _parse_tags(tags_value) -> List[str]:\n \n \n def _get_disabled_skill_names() -> Set[str]:\n-    \"\"\"Load disabled skill names from config (once per call).\n+    \"\"\"Load disabled skill names from config.\n \n-    Resolves platform from ``HERMES_PLATFORM`` env var, falls back to\n-    the global disabled list.\n+    Delegates to ``agent.skill_utils.get_disabled_skill_names`` — kept here\n+    as a public re-export so existing callers don't need updating.\n     \"\"\"\n-    import os\n-    try:\n-        from hermes_cli.config import load_config\n-        config = load_config()\n-        skills_cfg = config.get(\"skills\", {})\n-        resolved_platform = os.getenv(\"HERMES_PLATFORM\")\n-        if resolved_platform:\n-            platform_disabled = skills_cfg.get(\"platform_disabled\", {}).get(resolved_platform)\n-            if platform_disabled is not None:\n-                return set(platform_disabled)\n-        return set(skills_cfg.get(\"disabled\", []))\n-    except Exception:\n-        return set()\n+    from agent.skill_utils import get_disabled_skill_names\n+    return get_disabled_skill_names()\n \n \n def _is_skill_disabled(name: str, platform: str = None) -> bool:\n"]}]},{"kind":"pr","number":39407,"title":"perf(agent): fast-path skills snapshot validation via directory mtime (#3354)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e21ba9122","number":0},{"kind":"pr","value":"#60339","number":60339},{"kind":"pr","value":"#61131","number":61131}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":61131,"title":"perf(skills): speed up snapshot manifest builds ~5x (#60339 salvage)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/prompt_builder.py","agent/skill_utils.py","scripts/release.py","tests/agent/test_skill_utils.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}