Files
hermes-agent/tools/skill_usage.py
Teknium 70e1571d89 feat(curator): prune built-in skills after inactivity + track usage for all skills (#36701)
Two related changes to the skill curator:

1. Built-in pruning. New curator.prune_builtins config (default on) lets the
   curator archive bundled built-in skills after the inactivity period, not
   just agent-created ones. A .curator_suppressed list tells the update-time
   re-seeder (tools/skills_sync) to leave pruned built-ins archived, so the
   prune is durable across `hermes update`. Built-ins are seeded with a
   baseline record on first sight, so the inactivity clock starts at upgrade
   time -- no mass-prune on the first run. Hub-installed skills are never
   pruned regardless of the flag. Restoring a built-in clears its suppression.

2. Usage tracking for all skills. Telemetry (view/use/patch) was wrongly gated
   behind curation-eligibility, so built-ins were tracked only when prunable
   and hub skills never. Telemetry is observability and is now decoupled from
   curation: every skill accrues usage counts regardless of provenance, while
   lifecycle mutators (set_state/set_pinned/mark_agent_created) stay
   curation-gated. New usage_report() + provenance() expose all skills with an
   agent/bundled/hub tag.
2026-06-01 02:07:32 -07:00

853 lines
31 KiB
Python

"""Skill usage telemetry + provenance tracking for the Curator feature.
Tracks per-skill usage metadata in a sidecar JSON file (~/.hermes/skills/.usage.json)
keyed by skill name. Counters are bumped by the existing skill tools (skill_view,
skill_manage); the curator orchestrator reads the derived activity timestamp to
decide lifecycle transitions.
Design notes:
- Sidecar, not frontmatter. Keeps operational telemetry out of user-authored
SKILL.md content and avoids conflict pressure for bundled/hub skills.
- Atomic writes via tempfile + os.replace (same pattern as .bundled_manifest).
- All counter bumps are best-effort: failures log at DEBUG and return silently.
A broken sidecar never breaks the underlying tool call.
- Provenance filter: curator-managed skills are explicitly marked when
created through skill_manage. Bundled / hub-installed skills stay
off-limits, and manually authored skills are not inferred from location.
Lifecycle states:
active -> default
stale -> unused > stale_after_days (config)
archived -> unused > archive_after_days (config); moved to .archive/
pinned -> opt-out from auto transitions (boolean flag, orthogonal to state)
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import get_hermes_home
from agent.skill_utils import is_excluded_skill_path
logger = logging.getLogger(__name__)
# fcntl is Unix-only; on Windows use msvcrt for file locking.
msvcrt = None
try:
import fcntl
except ImportError: # pragma: no cover - platform-specific fallback
fcntl = None
try:
import msvcrt
except ImportError:
pass
STATE_ACTIVE = "active"
STATE_STALE = "stale"
STATE_ARCHIVED = "archived"
_VALID_STATES = {STATE_ACTIVE, STATE_STALE, STATE_ARCHIVED}
def _skills_dir() -> Path:
return get_hermes_home() / "skills"
def _usage_file() -> Path:
return _skills_dir() / ".usage.json"
@contextmanager
def _usage_file_lock():
"""Serialize .usage.json read-modify-write cycles across processes."""
lock_path = _usage_file().with_suffix(".json.lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
if fcntl is None and msvcrt is None:
yield
return
if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0):
lock_path.write_text(" ", encoding="utf-8")
fd = open(lock_path, "r+" if msvcrt else "a+", encoding="utf-8")
try:
if fcntl:
fcntl.flock(fd, fcntl.LOCK_EX)
else:
fd.seek(0)
msvcrt.locking(fd.fileno(), msvcrt.LK_LOCK, 1)
yield
finally:
if fcntl:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except (OSError, IOError):
pass
elif msvcrt:
try:
fd.seek(0)
msvcrt.locking(fd.fileno(), msvcrt.LK_UNLCK, 1)
except (OSError, IOError):
pass
fd.close()
def _archive_dir() -> Path:
return _skills_dir() / ".archive"
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_iso_timestamp(value: Any) -> Optional[datetime]:
"""Parse an ISO timestamp defensively for activity comparisons."""
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value))
except (TypeError, ValueError):
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def latest_activity_at(record: Dict[str, Any]) -> Optional[str]:
"""Return the newest actual activity timestamp for a usage record.
"Activity" means a skill was used, viewed, or patched. Creation time is
intentionally excluded so callers can still distinguish never-active skills;
lifecycle code can fall back to ``created_at`` as its own anchor.
"""
latest_dt: Optional[datetime] = None
latest_raw: Optional[str] = None
for key in ("last_used_at", "last_viewed_at", "last_patched_at"):
raw = record.get(key)
dt = _parse_iso_timestamp(raw)
if dt is None:
continue
if latest_dt is None or dt > latest_dt:
latest_dt = dt
latest_raw = str(raw)
return latest_raw
def activity_count(record: Dict[str, Any]) -> int:
"""Return the total observed activity count across use/view/patch events."""
total = 0
for key in ("use_count", "view_count", "patch_count"):
try:
total += int(record.get(key) or 0)
except (TypeError, ValueError):
continue
return total
# ---------------------------------------------------------------------------
# Provenance — which skills are agent-created (and thus eligible for curation)
# ---------------------------------------------------------------------------
def _read_bundled_manifest_names() -> Set[str]:
"""Return the set of skill names that were seeded from the bundled repo.
Reads ~/.hermes/skills/.bundled_manifest (format: "name:hash" per line).
Returns empty set if the file is missing or unreadable.
"""
manifest = _skills_dir() / ".bundled_manifest"
if not manifest.exists():
return set()
names: Set[str] = set()
try:
for line in manifest.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
name = line.split(":", 1)[0].strip()
if name:
names.add(name)
except OSError as e:
logger.debug("Failed to read bundled manifest: %s", e)
return names
def _read_hub_installed_names() -> Set[str]:
"""Return the set of skill names installed via the Skills Hub.
Reads ~/.hermes/skills/.hub/lock.json (see tools/skills_hub.py :: HubLockFile).
"""
lock_path = _skills_dir() / ".hub" / "lock.json"
if not lock_path.exists():
return set()
try:
data = json.loads(lock_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
installed = data.get("installed") or {}
if isinstance(installed, dict):
names = {str(k) for k in installed.keys()}
skills_dir = _skills_dir()
for entry in installed.values():
if not isinstance(entry, dict):
continue
install_path = entry.get("install_path")
if not isinstance(install_path, str) or not install_path.strip():
continue
skill_dir = Path(install_path)
if not skill_dir.is_absolute():
skill_dir = skills_dir / skill_dir
try:
resolved = skill_dir.resolve()
resolved.relative_to(skills_dir.resolve())
except (OSError, ValueError):
continue
skill_md = resolved / "SKILL.md"
if skill_md.exists():
names.add(_read_skill_name(skill_md, fallback=resolved.name))
return names
except (OSError, json.JSONDecodeError) as e:
logger.debug("Failed to read hub lock file: %s", e)
return set()
def _prune_builtins_enabled() -> bool:
"""Whether bundled built-in skills are eligible for curator pruning.
Reads ``curator.prune_builtins`` from config (default True). Lazy import
keeps this module importable without the CLI config layer (e.g. in the
update/sync context); on any failure we fall back to the default. The real
safety against a mass-prune is the curator's seed-on-first-sight, not this
flag — built-ins only archive after a fresh inactivity window.
"""
try:
from hermes_cli.config import load_config
cfg = load_config()
cur = cfg.get("curator") if isinstance(cfg, dict) else None
if isinstance(cur, dict):
return bool(cur.get("prune_builtins", True))
except Exception as e: # pragma: no cover — best-effort config read
logger.debug("Failed to read curator.prune_builtins: %s", e)
return True
def _suppressed_file() -> Path:
return _skills_dir() / ".curator_suppressed"
def read_suppressed_names() -> Set[str]:
"""Built-in skills the curator pruned — the re-seeder must leave archived.
One skill name per line in ``~/.hermes/skills/.curator_suppressed``. This is
what makes pruning a built-in durable: without it, ``hermes update`` would
re-copy the bundled skill on the next sync.
"""
path = _suppressed_file()
if not path.exists():
return set()
names: Set[str] = set()
try:
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
names.add(line)
except OSError as e:
logger.debug("Failed to read curator suppression list: %s", e)
return names
def _write_suppressed_names(names: Set[str]) -> None:
path = _suppressed_file()
try:
path.parent.mkdir(parents=True, exist_ok=True)
data = "\n".join(sorted(names)) + ("\n" if names else "")
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".curator_suppressed_", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except Exception as e:
logger.debug("Failed to write curator suppression list: %s", e, exc_info=True)
def add_suppressed_name(skill_name: str) -> None:
"""Record that a built-in skill was pruned, so sync won't restore it."""
if not skill_name:
return
names = read_suppressed_names()
if skill_name not in names:
names.add(skill_name)
_write_suppressed_names(names)
def remove_suppressed_name(skill_name: str) -> None:
"""Clear a built-in's suppression entry (e.g. on restore)."""
if not skill_name:
return
names = read_suppressed_names()
if skill_name in names:
names.discard(skill_name)
_write_suppressed_names(names)
def list_agent_created_skill_names() -> List[str]:
"""Enumerate skills the curator may manage.
Always includes agent-authored skills (those marked in ``.usage.json`` via
``skill_manage(action="create")``). When ``curator.prune_builtins`` is
enabled, bundled built-in skills are ALSO included even though they have no
agent-created usage record — their inactivity clock is anchored on first
sight (see ``apply_automatic_transitions``). Hub-installed skills are never
included; manually authored skills are not inferred from filesystem
location.
"""
base = _skills_dir()
if not base.exists():
return []
hub = _read_hub_installed_names()
bundled = _read_bundled_manifest_names()
prune_builtins = _prune_builtins_enabled()
usage = load_usage()
names: List[str] = []
# Top-level SKILL.md files (flat layout) AND nested category/skill/SKILL.md
for skill_md in base.rglob("SKILL.md"):
# Skip Hermes metadata, VCS, virtualenv/dependency, and cache dirs
if is_excluded_skill_path(skill_md):
continue
try:
skill_md.relative_to(base)
except ValueError:
continue
name = _read_skill_name(skill_md, fallback=skill_md.parent.name)
# Hub-installed skills are always off-limits.
if name in hub:
continue
if name in bundled:
# Built-ins are only candidates when pruning is enabled. They never
# carry a curator-managed record, so the record gate is skipped.
if not prune_builtins:
continue
names.append(name)
continue
# Agent-authored (or local-manual) skills must opt in via their record.
if not _is_curator_managed_record(usage.get(name)):
continue
names.append(name)
return sorted(set(names))
def list_archived_skill_names() -> List[str]:
"""Enumerate skills in ``~/.hermes/skills/.archive/``.
Archive layout is flat (``.archive/<skill>/``) as set by ``archive_skill``,
so the directory name is the skill name. Used by ``hermes curator
list-archived`` to help users pass a name to ``hermes curator restore``.
"""
archive_root = _archive_dir()
if not archive_root.exists():
return []
return sorted({p.name for p in archive_root.iterdir() if p.is_dir()})
def _read_skill_name(skill_md: Path, fallback: str) -> str:
"""Parse the `name:` field from a SKILL.md YAML frontmatter."""
try:
text = skill_md.read_text(encoding="utf-8", errors="replace")[:4000]
except OSError:
return fallback
in_frontmatter = False
for line in text.split("\n"):
stripped = line.strip()
if stripped == "---":
if in_frontmatter:
break
in_frontmatter = True
continue
if in_frontmatter and stripped.startswith("name:"):
value = stripped.split(":", 1)[1].strip().strip("\"'")
if value:
return value
return fallback
def is_agent_created(skill_name: str) -> bool:
"""Whether *skill_name* is neither bundled nor hub-installed."""
off_limits = _read_bundled_manifest_names() | _read_hub_installed_names()
return skill_name not in off_limits
def is_hub_installed(skill_name: str) -> bool:
"""Whether *skill_name* was installed via the Skills Hub."""
return skill_name in _read_hub_installed_names()
def is_bundled(skill_name: str) -> bool:
"""Whether *skill_name* was seeded from the bundled repo skills."""
return skill_name in _read_bundled_manifest_names()
def is_curation_eligible(skill_name: str) -> bool:
"""Whether the curator may track/archive *skill_name*.
Agent-created skills are always eligible. Bundled built-ins become eligible
only when ``curator.prune_builtins`` is enabled. Hub-installed skills are
NEVER eligible — they have an external upstream owner.
"""
if is_hub_installed(skill_name):
return False
if is_bundled(skill_name):
return _prune_builtins_enabled()
return True
def _is_curator_managed_record(record: Any) -> bool:
"""Return True when a usage record opts a skill into curator management."""
if not isinstance(record, dict):
return False
return record.get("created_by") == "agent" or record.get("agent_created") is True
# ---------------------------------------------------------------------------
# Sidecar I/O
# ---------------------------------------------------------------------------
def _empty_record() -> Dict[str, Any]:
return {
"created_by": None,
"use_count": 0,
"view_count": 0,
"last_used_at": None,
"last_viewed_at": None,
"patch_count": 0,
"last_patched_at": None,
"created_at": _now_iso(),
"state": STATE_ACTIVE,
"pinned": False,
"archived_at": None,
}
def load_usage() -> Dict[str, Dict[str, Any]]:
"""Read the entire .usage.json map. Returns empty dict on missing/corrupt."""
path = _usage_file()
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
logger.debug("Failed to read %s: %s", path, e)
return {}
if not isinstance(data, dict):
return {}
# Defensive: coerce any non-dict values to a fresh empty record
clean: Dict[str, Dict[str, Any]] = {}
for k, v in data.items():
if isinstance(v, dict):
clean[str(k)] = v
return clean
def save_usage(data: Dict[str, Dict[str, Any]]) -> None:
"""Write the usage map atomically. Best-effort — errors are logged, not raised."""
path = _usage_file()
try:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(
dir=str(path.parent), prefix=".usage_", suffix=".tmp"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
except Exception as e:
logger.debug("Failed to write %s: %s", path, e, exc_info=True)
def get_record(skill_name: str) -> Dict[str, Any]:
"""Return the record for *skill_name*, creating a fresh one if missing."""
data = load_usage()
rec = data.get(skill_name)
if not isinstance(rec, dict):
return _empty_record()
# Backfill any missing keys so callers don't need to handle old files
base = _empty_record()
for k, v in base.items():
rec.setdefault(k, v)
return rec
def seed_record_if_missing(skill_name: str) -> None:
"""Persist a baseline usage record for a curation-eligible skill.
Built-ins carry no usage record until something touches them, which leaves
their inactivity clock with no anchor. Seeding a record here fixes
``created_at`` to the moment the curator first sees the skill, so the
archive/stale clock measures non-use FROM THEN — not from epoch. No-op when
a record already exists or the skill isn't curation-eligible.
"""
if not skill_name or not is_curation_eligible(skill_name):
return
try:
with _usage_file_lock():
data = load_usage()
if isinstance(data.get(skill_name), dict):
return
data[skill_name] = _empty_record()
save_usage(data)
except Exception as e:
logger.debug("skill_usage.seed_record_if_missing(%s) failed: %s", skill_name, e, exc_info=True)
def _mutate(skill_name: str, mutator, *, require_curation_eligible: bool = False) -> None:
"""Load, apply *mutator(record)* in place, save. Best-effort.
By default this records telemetry for ANY skill — bundled, hub-installed,
or agent-created — because usage tracking is pure observability and is
orthogonal to whether a skill is ever curated. Lifecycle mutators
(``set_state``, ``set_pinned``, ``mark_agent_created``) pass
``require_curation_eligible=True`` so they never write meaningless state
onto a skill the curator can't manage (e.g. an ``archived`` flag on a
hub-installed skill).
"""
if not skill_name:
return
try:
if require_curation_eligible and not is_curation_eligible(skill_name):
return
with _usage_file_lock():
data = load_usage()
rec = data.get(skill_name)
if not isinstance(rec, dict):
rec = _empty_record()
mutator(rec)
data[skill_name] = rec
save_usage(data)
except Exception as e:
logger.debug("skill_usage._mutate(%s) failed: %s", skill_name, e, exc_info=True)
# ---------------------------------------------------------------------------
# Public counter-bump helpers — telemetry for ALL skills (observability only)
# ---------------------------------------------------------------------------
def bump_view(skill_name: str) -> None:
"""Bump view_count and last_viewed_at. Called from skill_view().
Tracks every skill regardless of provenance — built-ins and hub skills
included. Usage telemetry is observability, not a curation signal.
"""
def _apply(rec: Dict[str, Any]) -> None:
rec["view_count"] = int(rec.get("view_count") or 0) + 1
rec["last_viewed_at"] = _now_iso()
_mutate(skill_name, _apply)
def bump_use(skill_name: str) -> None:
"""Bump use_count and last_used_at. Called when a skill is actively used
(e.g. loaded into the prompt path or referenced from an assistant turn).
Tracks every skill regardless of provenance.
"""
def _apply(rec: Dict[str, Any]) -> None:
rec["use_count"] = int(rec.get("use_count") or 0) + 1
rec["last_used_at"] = _now_iso()
_mutate(skill_name, _apply)
def bump_patch(skill_name: str) -> None:
"""Bump patch_count and last_patched_at. Called from skill_manage (patch/edit).
Tracks every skill regardless of provenance.
"""
def _apply(rec: Dict[str, Any]) -> None:
rec["patch_count"] = int(rec.get("patch_count") or 0) + 1
rec["last_patched_at"] = _now_iso()
_mutate(skill_name, _apply)
def mark_agent_created(skill_name: str) -> None:
"""Opt a skill created by skill_manage into curator management.
Viewing or invoking a manually authored skill may still create telemetry,
but only this explicit marker makes it eligible for automatic curation.
"""
def _apply(rec: Dict[str, Any]) -> None:
rec["created_by"] = "agent"
_mutate(skill_name, _apply, require_curation_eligible=True)
def set_state(skill_name: str, state: str) -> None:
"""Set lifecycle state. No-op if *state* is invalid or the skill isn't
curator-manageable (hub skills, or built-ins with pruning disabled)."""
if state not in _VALID_STATES:
logger.debug("set_state: invalid state %r for %s", state, skill_name)
return
def _apply(rec: Dict[str, Any]) -> None:
rec["state"] = state
if state == STATE_ARCHIVED:
rec["archived_at"] = _now_iso()
elif state == STATE_ACTIVE:
rec["archived_at"] = None
_mutate(skill_name, _apply, require_curation_eligible=True)
def set_pinned(skill_name: str, pinned: bool) -> None:
def _apply(rec: Dict[str, Any]) -> None:
rec["pinned"] = bool(pinned)
_mutate(skill_name, _apply, require_curation_eligible=True)
def forget(skill_name: str) -> None:
"""Drop a skill's usage entry entirely. Called when the skill is deleted."""
if not skill_name:
return
try:
with _usage_file_lock():
data = load_usage()
if skill_name in data:
del data[skill_name]
save_usage(data)
except Exception as e:
logger.debug("skill_usage.forget(%s) failed: %s", skill_name, e, exc_info=True)
# ---------------------------------------------------------------------------
# Archive / restore
# ---------------------------------------------------------------------------
def archive_skill(skill_name: str) -> Tuple[bool, str]:
"""Move a curator-eligible skill directory to ~/.hermes/skills/.archive/.
Returns (ok, message). Never archives hub-installed skills. Bundled
built-ins are only archivable when ``curator.prune_builtins`` is enabled;
when one is archived, its name is added to the suppression list so the
update-time re-seeder leaves it archived instead of restoring it.
"""
if not is_curation_eligible(skill_name):
if is_hub_installed(skill_name):
return False, f"skill '{skill_name}' is hub-installed; never archive"
return False, (
f"skill '{skill_name}' is a bundled built-in; enable "
"curator.prune_builtins to allow pruning it"
)
skill_dir = _find_skill_dir(skill_name)
if skill_dir is None:
return False, f"skill '{skill_name}' not found"
archive_root = _archive_dir()
try:
archive_root.mkdir(parents=True, exist_ok=True)
except OSError as e:
return False, f"failed to create archive dir: {e}"
# Flatten any category nesting into a single ".archive/<skill>/" so restores
# are simple. If a collision exists, append a timestamp.
dest = archive_root / skill_dir.name
if dest.exists():
dest = archive_root / f"{skill_dir.name}-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}"
try:
skill_dir.rename(dest)
except OSError as e:
# Cross-device — fall back to shutil.move
import shutil
try:
shutil.move(str(skill_dir), str(dest))
except Exception as e2:
return False, f"failed to archive: {e2}"
# Pruning a built-in only sticks if the re-seeder is told to leave it alone.
if is_bundled(skill_name):
add_suppressed_name(skill_name)
set_state(skill_name, STATE_ARCHIVED)
return True, f"archived to {dest}"
def restore_skill(skill_name: str) -> Tuple[bool, str]:
"""Move an archived skill back to ~/.hermes/skills/. Restores to the flat
top-level layout; original category nesting is NOT reconstructed.
Refuses to restore under a name that now collides with a hub-installed
skill — that would shadow the upstream version. Also refuses to restore
over a bundled built-in UNLESS ``curator.prune_builtins`` is enabled (in
which case built-ins are curator-managed and restoring is the documented
way to lift a prune). Restoring clears any suppression entry so future
updates may re-seed the built-in again.
"""
# Hub skills always have an external upstream owner — never shadow them.
if is_hub_installed(skill_name):
return False, (
f"skill '{skill_name}' is now hub-installed; "
"restore would shadow the upstream version"
)
# A bundled built-in is upstream-owned UNLESS prune_builtins is on. With the
# flag off, restoring over it would shadow the bundled version.
if is_bundled(skill_name) and not _prune_builtins_enabled():
return False, (
f"skill '{skill_name}' is now bundled; "
"restore would shadow the upstream version"
)
archive_root = _archive_dir()
if not archive_root.exists():
return False, "no archive directory"
# Try exact name match first, then any prefix match (for timestamped dupes).
# Recursive walk handles nested archive layouts (e.g. .archive/<category>/<skill>/)
# left behind by older archive paths or external imports.
candidates = [p for p in archive_root.rglob("*") if p.is_dir() and p.name == skill_name]
if not candidates:
candidates = sorted(
[p for p in archive_root.rglob("*")
if p.is_dir() and p.name.startswith(f"{skill_name}-")],
reverse=True,
)
if not candidates:
return False, f"skill '{skill_name}' not found in archive"
src = candidates[0]
dest = _skills_dir() / skill_name
if dest.exists():
return False, f"destination already exists: {dest}"
try:
src.rename(dest)
except OSError:
import shutil
try:
shutil.move(str(src), str(dest))
except Exception as e:
return False, f"failed to restore: {e}"
# Restoring a pruned built-in lifts its suppression so updates can manage it.
remove_suppressed_name(skill_name)
set_state(skill_name, STATE_ACTIVE)
return True, f"restored to {dest}"
def _find_skill_dir(skill_name: str) -> Optional[Path]:
"""Locate the directory for a skill by its frontmatter `name:` field.
Handles both flat (~/.hermes/skills/<skill>/SKILL.md) and category-nested
(~/.hermes/skills/<category>/<skill>/SKILL.md) layouts.
"""
base = _skills_dir()
if not base.exists():
return None
for skill_md in base.rglob("SKILL.md"):
if is_excluded_skill_path(skill_md):
continue
if _read_skill_name(skill_md, fallback=skill_md.parent.name) == skill_name:
return skill_md.parent
return None
# ---------------------------------------------------------------------------
# Reporting — for the curator CLI / slash command
# ---------------------------------------------------------------------------
def agent_created_report() -> List[Dict[str, Any]]:
"""Return a list of {name, state, pinned, last_activity_at, ...}
records for every curator-managed skill. Missing usage records are
backfilled with defaults so callers can always index fields.
Each row carries ``_persisted``: True when a real record exists in
``.usage.json``, False when the row is a fresh backfill (e.g. a built-in
seen for the first time). The curator uses this to seed the inactivity
clock instead of treating an unrecorded skill as ancient.
"""
data = load_usage()
rows: List[Dict[str, Any]] = []
for name in list_agent_created_skill_names():
raw = data.get(name)
persisted = isinstance(raw, dict)
rec: Dict[str, Any] = raw if isinstance(raw, dict) else _empty_record()
base = _empty_record()
for k, v in base.items():
rec.setdefault(k, v)
row = {"name": name, **rec, "_persisted": persisted}
row["last_activity_at"] = latest_activity_at(row)
row["activity_count"] = activity_count(row)
rows.append(row)
return rows
def provenance(skill_name: str) -> str:
"""Classify a skill's origin: 'hub', 'bundled', or 'agent'.
'agent' covers both agent-authored and local manually-authored skills —
anything not seeded from the bundled repo or installed via the hub.
"""
if is_hub_installed(skill_name):
return "hub"
if is_bundled(skill_name):
return "bundled"
return "agent"
def usage_report() -> List[Dict[str, Any]]:
"""Return usage telemetry for EVERY skill on disk, with provenance.
Unlike ``agent_created_report()`` (which is scoped to curator-managed
candidates), this surfaces all skills — bundled built-ins and
hub-installed included — so callers can answer "how often is this skill
used" independent of whether it's ever curated. Rows carry a
``provenance`` field ('agent' | 'bundled' | 'hub') and ``_persisted``
(whether a real ``.usage.json`` record backs the row).
"""
base = _skills_dir()
if not base.exists():
return []
data = load_usage()
rows: List[Dict[str, Any]] = []
seen: set = set()
for skill_md in base.rglob("SKILL.md"):
if is_excluded_skill_path(skill_md):
continue
name = _read_skill_name(skill_md, fallback=skill_md.parent.name)
if name in seen:
continue
seen.add(name)
raw = data.get(name)
persisted = isinstance(raw, dict)
rec: Dict[str, Any] = raw if isinstance(raw, dict) else _empty_record()
base_rec = _empty_record()
for k, v in base_rec.items():
rec.setdefault(k, v)
row = {
"name": name,
**rec,
"provenance": provenance(name),
"_persisted": persisted,
}
row["last_activity_at"] = latest_activity_at(row)
row["activity_count"] = activity_count(row)
rows.append(row)
return sorted(rows, key=lambda r: r["name"])