Files
hermes-agent/tools/skill_usage.py

971 lines
47 KiB
Python

"""Skill usage telemetry + provenance tracking for the Curator feature.
Per-skill usage metadata lives in a sidecar JSON file (~/.hermes/skills/.usage.json) keyed by skill name.
Counters are bumped by the 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, so a broken
sidecar never breaks the underlying tool call; and 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`` is a boolean opt-out from auto transitions,
orthogonal to state.
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
from contextlib import contextmanager, suppress
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Set, Tuple
from hermes_constants import get_hermes_home
from agent.skill_utils import is_excluded_skill_path, is_external_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}
# Load-bearing bundled built-ins the curator must NEVER archive or consolidate, regardless of
# ``curator.prune_builtins``, pin state, or LLM judgment. These back advertised UX paths; silently archiving one
# turns its slash command into "Unknown command" with no signal to the user. Protection is by skill ``name``
# (frontmatter ``name:``), matching the keys used throughout this module. Keep this list tiny and intentional —
# it is not a substitute for ``curator.prune_builtins: false``, which exempts ALL built-ins. (``plan`` used to
# live here; it is now a first-class built-in command with no skill on disk, so the set is currently empty.)
PROTECTED_BUILTIN_SKILLS: Set[str] = set()
def is_protected_builtin(skill_name: str) -> bool:
"""Whether *skill_name* is a load-bearing built-in the curator never touches.
Protected built-ins are exempt from archival and consolidation on every path: the automatic state-transition walk,
the LLM consolidation pass (dropped from the candidate list), and direct ``archive_skill`` calls."""
return skill_name in PROTECTED_BUILTIN_SKILLS
def _skills_dir() -> Path:
return get_hermes_home() / "skills"
def _usage_file() -> Path:
return _skills_dir() / ".usage.json"
def _archive_dir() -> Path:
return _skills_dir() / ".archive"
def _flock(fd, lock: bool) -> None:
if fcntl:
fcntl.flock(fd, fcntl.LOCK_EX if lock else fcntl.LOCK_UN)
else:
fd.seek(0)
msvcrt.locking(fd.fileno(), msvcrt.LK_LOCK if lock else msvcrt.LK_UNLCK, 1)
@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:
_flock(fd, True)
yield
finally:
with suppress(OSError, IOError):
_flock(fd, False)
fd.close()
def _atomic_write(path: Path, prefix: str, write: Callable[[Any], None]) -> None:
"""Write *path* via tempfile + fsync + os.replace; the temp file is removed on failure."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=prefix, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
write(f)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
with suppress(OSError):
os.unlink(tmp)
raise
def _read_lines(path: Path, fail_log: str) -> List[str]:
"""Stripped, non-empty lines of a small metadata file ([] if missing/unreadable)."""
if not path.exists():
return []
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError as e:
logger.debug(fail_log, e)
return []
return [s for s in (line.strip() for line in lines) if s]
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
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else 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."""
stamps = [
(dt, str(raw)) for raw in (record.get(k) for k in ("last_used_at", "last_viewed_at", "last_patched_at"))
if (dt := _parse_iso_timestamp(raw)) is not None
]
return max(stamps, key=lambda t: t[0])[1] if stamps else None
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]:
"""Skill names seeded from the bundled repo: ~/.hermes/skills/.bundled_manifest ("name:hash" per line). Empty set
if the file is missing or unreadable."""
lines = _read_lines(_skills_dir() / ".bundled_manifest", "Failed to read bundled manifest: %s")
return {n for n in (line.split(":", 1)[0].strip() for line in lines) if n}
def _read_hub_installed_names() -> Set[str]:
"""Skill names installed via the Skills Hub, read from ~/.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:
# Tolerate non-UTF-8 bytes in the lock file. Hub descriptions can carry Windows-1252 typographic chars
# (em-dash 0x97, smart quotes, bullets) written as single high bytes; a strict utf-8 read raises
# UnicodeDecodeError, which is a ValueError sibling (not OSError/JSONDecodeError) so it escapes the
# handler below and 500s the whole /api/skills endpoint. errors="replace" degrades the offending byte to
# U+FFFD, keeping the (structurally valid) JSON — and every other skill — readable. See #68053.
data = json.loads(lock_path.read_text(encoding="utf-8", errors="replace"))
installed = (data.get("installed") or {}) if isinstance(data, dict) else None
if not isinstance(installed, dict):
return set()
names = {str(k) for k in installed}
skills_dir = _skills_dir()
for entry in installed.values():
install_path = entry.get("install_path") if isinstance(entry, dict) else None
if not isinstance(install_path, str) or not install_path.strip():
continue
try:
resolved = (skills_dir / install_path).resolve()
resolved.relative_to(skills_dir.resolve())
except (OSError, ValueError):
continue
if (resolved / "SKILL.md").exists():
names.add(_read_skill_name(resolved / "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 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."""
lines = _read_lines(_skills_dir() / ".curator_suppressed", "Failed to read curator suppression list: %s")
return {line for line in lines if not line.startswith("#")}
def _toggle_suppressed_name(skill_name: str, *, add: bool) -> None:
if not skill_name or (skill_name in (names := read_suppressed_names())) == add:
return
(names.add if add else names.discard)(skill_name)
data = "\n".join(sorted(names)) + ("\n" if names else "")
try:
_atomic_write(_skills_dir() / ".curator_suppressed", ".curator_suppressed_", lambda f: f.write(data))
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."""
_toggle_suppressed_name(skill_name, add=True)
def remove_suppressed_name(skill_name: str) -> None:
"""Clear a built-in's suppression entry (e.g. on restore)."""
_toggle_suppressed_name(skill_name, add=False)
def _iter_skill_mds(base: Path, *, local_only: bool) -> Iterator[Tuple[str, Path]]:
"""Yield ``(frontmatter name, SKILL.md)`` for every skill under *base* — the flat layout AND nested
category/skill/SKILL.md — skipping Hermes metadata, VCS, virtualenv/dependency, and cache dirs. With
*local_only*, external skill dirs mounted below the local tree are skipped too: discovery may see them,
but autonomous lifecycle curation must not."""
for skill_md in base.rglob("SKILL.md"):
if not (is_excluded_skill_path(skill_md) or (local_only and is_external_skill_path(skill_md))):
yield _read_skill_name(skill_md, fallback=skill_md.parent.name), skill_md
def _scan_local_skills(keep: Callable[[str, Path, Set[str], Dict[str, Any]], bool]) -> List[str]:
"""Sorted, de-duplicated names of local skills passing *keep(name, skill_md, bundled, usage)*.
Hub-installed skills are always off-limits, and protected built-ins are never curation candidates
(exempt from the automatic transition walk AND the LLM consolidation pass), so neither ever reaches
*keep*."""
base = _skills_dir()
if not base.exists():
return []
hub, bundled, usage = _read_hub_installed_names(), _read_bundled_manifest_names(), load_usage()
return sorted({
name for name, skill_md in _iter_skill_mds(base, local_only=True)
if name not in hub and not is_protected_builtin(name) and keep(name, skill_md, bundled, usage)
})
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."""
if not _skills_dir().exists():
return []
prune_builtins = _prune_builtins_enabled() # read once, before the walk
def _keep(name: str, _skill_md: Path, bundled: Set[str], usage: Dict[str, Any]) -> bool:
# Built-ins are only candidates when pruning is enabled; they never carry a curator-managed record, so
# the record gate is skipped. Agent-authored (or local-manual) skills must opt in via their record.
return prune_builtins if name in bundled else _is_curator_managed_record(usage.get(name))
return _scan_local_skills(_keep)
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()
return sorted({p.name for p in archive_root.iterdir() if p.is_dir()}) if archive_root.exists() else []
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 stripped in (line.strip() for line in text.split("\n")):
if stripped == "---":
if in_frontmatter:
break
in_frontmatter = True
elif 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."""
if skill_name in _read_bundled_manifest_names() | _read_hub_installed_names():
return False
return _find_skill_dir(skill_name) is not None or _find_external_skill_dir(skill_name) is None
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 _external_read_only_message(skill_name: str) -> str:
return f"skill '{skill_name}' lives in skills.external_dirs; external skills are read-only to the curator"
def is_curation_eligible(skill_name: str, skill_path: Optional[Path] = None) -> 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 and external skill-dir skills are NEVER eligible — they have an external upstream owner.
Org-shared skills ARE eligible for improvement (the curator may patch them like any other skill; edits stay local
until proposed) but are protected from ARCHIVE/DELETE elsewhere — removing a shared skill is an org-admin action,
not a local curation decision. Protected built-ins (``PROTECTED_BUILTIN_SKILLS``) are NEVER eligible regardless of
any flag — they back load-bearing UX and must never be archived or consolidated."""
if (skill_path is not None and is_external_skill_path(skill_path)) or is_protected_builtin(skill_name) or is_hub_installed(skill_name):
return False
if is_bundled(skill_name):
return _prune_builtins_enabled()
local_dir = _find_skill_dir(skill_name)
return not is_external_skill_path(local_dir) if local_dir is not None else _find_external_skill_dir(skill_name) is None
def _is_curator_managed_record(record: Any) -> bool:
"""Return True when a usage record opts a skill into curator management.
NAMING (issue #67140): the on-disk field is ``created_by``, which reads like provenance but is consumed as a
**curator-management opt-in policy flag**. The two are not the same question: provenance = "who authored this
file" — historical fact, unrecoverable for records written before the marker existed; management = "may autonomous
curation mutate/archive this" — a policy decision the user can change at any time via ``hermes curator adopt``.
``created_by: "agent"`` therefore means "curator-managed", NOT "proof the agent wrote it". The field name is
retained because it is already on disk in every user's ``.usage.json``; renaming it would strand those records.
Read it as policy, and prefer ``is_curator_managed()`` at call sites so the intent is unambiguous."""
return isinstance(record, dict) and (record.get("created_by") == "agent" or record.get("agent_created") is True)
def is_curator_managed(skill_name: str) -> bool:
"""Whether *skill_name* is opted into curator management — the policy-intent alias for the ``created_by``-marker
check (see ``_is_curator_managed_record`` for the field-name story)."""
return _is_curator_managed_record(load_usage().get(skill_name))
def list_unmanaged_skill_names() -> List[str]:
"""Enumerate curation-ELIGIBLE skills that carry no provenance marker.
These are skills the curator *could* manage (not hub-installed, not external, not protected built-ins) but never
will, because nothing ever wrote ``created_by: agent`` onto their usage record. Two ways a skill lands here: it
predates the provenance mechanism entirely (records written before ``created_by`` existed carry no key at all, so
authorship is unknowable from the record alone), or it was created by a FOREGROUND
``skill_manage(action="create")`` call, which deliberately does not mark provenance (skills a user asks for belong
to the user).
Either way the skill is invisible to ``curated_report()`` and therefore to every automatic transition. ``hermes
curator status`` surfaces this count so the blind spot is legible instead of silent, and ``hermes curator adopt``
lets the user hand specific skills over explicitly.
Provenance is a DECLARATION, never an inference: this function only reports, and callers must not auto-adopt what
it returns. Heavy patch or use counts are evidence of maintenance, not of authorship — the agent edits
user-authored skills on the user's behalf routinely."""
def _keep(name: str, skill_md: Path, bundled: Set[str], usage: Dict[str, Any]) -> bool:
# Anything with an external owner or a bundled/protected identity is outside the adoption question.
return name not in bundled and not _is_curator_managed_record(usage.get(name)) and is_curation_eligible(name, skill_md)
return _scan_local_skills(_keep)
def unmanaged_report() -> List[Dict[str, Any]]:
"""Rows for every skill :func:`list_unmanaged_skill_names` returns.
Each row carries the usual activity fields plus ``has_provenance_key``: False when the record has no
``created_by`` key at all (pre-dates the mechanism), True when the key is present but unset (a foreground create
under the current policy). The distinction matters for explaining WHY a skill is unmanaged; it is not a signal to
adopt on."""
usage = load_usage()
return [
_report_row(
name, dict(raw) if isinstance(raw, dict) else None,
has_provenance_key=isinstance(raw, dict) and "created_by" in raw, has_record=isinstance(raw, dict),
)
for name, raw in ((n, usage.get(n)) for n in list_unmanaged_skill_names())
]
def adopt_skill(skill_name: str) -> Tuple[bool, str]:
"""Hand *skill_name* to the curator by user declaration.
Writes the same ``created_by: agent`` marker the background review fork writes, so the skill joins
``curated_report()`` and the automatic transition walk. The inactivity clock is NOT reset: the skill's existing
``last_activity_at`` still governs staleness, so adopting something idle for months does not buy it a fresh window
(nor does it archive it on the spot — the state machine decides on the next pass).
Returns (ok, message). Refuses hub-installed, external, and protected built-in skills, which have an owner other
than the user."""
if not skill_name:
return False, "no skill name given"
if is_protected_builtin(skill_name):
return False, f"'{skill_name}' is a protected built-in; the curator never manages it"
if is_hub_installed(skill_name):
return False, f"'{skill_name}' is hub-installed; its upstream owns it"
if is_bundled(skill_name):
# Bundled skills already fall under the curator via ``curator.prune_builtins``; stamping created_by=agent
# on one would claim Hermes' own shipped skill was agent-authored and change nothing about its eligibility.
return False, f"'{skill_name}' is a bundled built-in — it is governed by curator.prune_builtins, not by adoption"
skill_dir = _find_skill_dir(skill_name)
if skill_dir is None:
if _find_external_skill_dir(skill_name) is not None:
return False, f"'{skill_name}' lives in skills.external_dirs and is read-only to the curator"
return False, f"skill '{skill_name}' not found"
if is_external_skill_path(skill_dir):
return False, _external_read_only_message(skill_name)
if _is_curator_managed_record(load_usage().get(skill_name)):
return True, f"'{skill_name}' is already curator-managed"
mark_agent_created(skill_name)
if not _is_curator_managed_record(load_usage().get(skill_name)):
return False, f"could not mark '{skill_name}' as curator-managed"
return True, f"adopted '{skill_name}' into curator management"
# --- 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, "patch_generation": 0, "last_reused_patch_generation": 0, "last_patched_at": None,
"created_at": _now_iso(), "state": STATE_ACTIVE, "pinned": False, "archived_at": None,
}
def _backfilled(rec: Any) -> Dict[str, Any]:
"""*rec* with every missing default key filled in (a fresh record when not a dict), so
callers never need to handle old files that predate newer keys."""
if not isinstance(rec, dict):
return _empty_record()
for k, v in _empty_record().items():
rec.setdefault(k, v)
return rec
def _report_row(name: str, raw: Any, **extra: Any) -> Dict[str, Any]:
"""Build one report row: name + backfilled record + *extra* + derived activity fields."""
row = {"name": name, **_backfilled(raw), **extra}
row.update(last_activity_at=latest_activity_at(row), activity_count=activity_count(row))
return row
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 {}
# Defensive: drop any non-dict values
return {str(k): v for k, v in data.items() if isinstance(v, dict)} if isinstance(data, dict) else {}
def save_usage(data: Dict[str, Dict[str, Any]]) -> bool:
"""Write the usage map atomically and report whether it committed."""
path = _usage_file()
try:
_atomic_write(path, ".usage_", lambda f: json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False))
return True
except Exception as e:
logger.debug("Failed to write %s: %s", path, e, exc_info=True)
return False
def get_record(skill_name: str) -> Dict[str, Any]:
"""Return the record for *skill_name*, creating a fresh one if missing."""
return _backfilled(load_usage().get(skill_name))
def _locked_update(
skill_name: str, op: Callable[[Dict[str, Dict[str, Any]]], Tuple[Any, bool]], fail_log: str,
guard: Optional[Callable[[], bool]] = None,
) -> Any:
"""Run *op(data)* on the loaded usage map under the file lock. Best-effort. *guard* (if given) is evaluated before
the lock is taken; a False result skips the update. *op* returns a (result, dirty) pair; the map is saved only
when dirty. Returns the result, or None when the guard failed, the save did not land, or anything raised (logged
at DEBUG via *fail_log*)."""
try:
if guard is not None and not guard():
return None
with _usage_file_lock():
data = load_usage()
result, dirty = op(data)
return None if dirty and not save_usage(data) else result
except Exception as e:
logger.debug(fail_log, skill_name, e, exc_info=True)
return None
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
def _seed(data):
if missing := not isinstance(data.get(skill_name), dict):
data[skill_name] = _empty_record()
return None, missing
_locked_update(skill_name, _seed, "skill_usage.seed_record_if_missing(%s) failed: %s")
def _mutate(skill_name: str, mutator, *, require_curation_eligible: bool = False) -> Any:
"""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 None
def _apply(data):
rec = data[skill_name] = data[skill_name] if isinstance(data.get(skill_name), dict) else _empty_record()
return mutator(rec), True
guard = (lambda: is_curation_eligible(skill_name)) if require_curation_eligible else None
return _locked_update(skill_name, _apply, "skill_usage._mutate(%s) failed: %s", guard)
def _set_field(skill_name: str, key: str, value: Any) -> bool:
"""Curation-gated single-field write; True only when the write landed."""
def _apply(rec: Dict[str, Any]) -> bool:
rec[key] = value
return True # non-None sentinel: _mutate propagates the mutator result
return bool(_mutate(skill_name, _apply, require_curation_eligible=True))
def _non_negative_int(value: Any) -> int:
if isinstance(value, bool):
return 0
try:
return max(0, int(value or 0))
except (TypeError, ValueError):
return 0
def _bump(rec: Dict[str, Any], count_key: str, ts_key: str) -> None:
rec[count_key] = _non_negative_int(rec.get(count_key)) + 1
rec[ts_key] = _now_iso()
def telemetry_provenance(skill_name: str, record: Optional[Dict[str, Any]] = None) -> str:
"""Return the bounded provenance used by shared skill metrics."""
if is_hub_installed(skill_name) or is_bundled(skill_name):
return "installed"
if ":" in skill_name:
with suppress(Exception):
from hermes_cli.plugins import get_plugin_manager
if get_plugin_manager().find_plugin_skill(skill_name) is not None:
return "installed"
created_by = record.get("created_by") if isinstance(record, dict) else None
if created_by in ("installed", "agent"):
return {"installed": "installed", "agent": "agent_created"}[created_by]
if _find_external_skill_dir(skill_name) is not None:
return "external"
if _find_skill_dir(skill_name) is not None or isinstance(record, dict):
return "local"
return "unknown"
def _emit_skill_lifecycle(
skill_name: str, action: str, *, record: Optional[Dict[str, Any]] = None,
task_id: Optional[str] = None, session_id: Optional[str] = None, **facts: Any,
) -> None:
"""Emit one best-effort lifecycle fact after authoritative state changes. *facts* may carry ``use_count`` /
``reused`` / ``reuse_after_patch``; absent ones are sent as None."""
try:
from hermes_cli.lifecycle import has_hook, invoke_hook
if not has_hook("on_skill_lifecycle"):
return
invoke_hook(
"on_skill_lifecycle", action=action, skill_name=skill_name,
provenance=telemetry_provenance(skill_name, record), task_id=task_id or "", session_id=session_id or "",
use_count=facts.get("use_count"), reused=facts.get("reused"), reuse_after_patch=facts.get("reuse_after_patch"),
)
except Exception:
logger.debug("skill_usage lifecycle hook failed for %s/%s", skill_name, action, exc_info=True)
def _mutate_and_emit(skill_name: str, action: str, mutator: Callable[[Dict[str, Any]], Dict[str, Any]], **hook_kwargs: Any) -> None:
"""``_mutate`` then emit *action* with the mutator's facts as the record — only if the write landed. Any
``use_count`` / ``reused`` / ``reuse_after_patch`` facts are forwarded to the hook."""
facts = _mutate(skill_name, mutator)
if isinstance(facts, dict):
hook_kwargs.update({k: facts[k] for k in ("use_count", "reused", "reuse_after_patch") if k in facts})
_emit_skill_lifecycle(skill_name, action, record=facts, **hook_kwargs)
# --- 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."""
_mutate(skill_name, lambda rec: _bump(rec, "view_count", "last_viewed_at"))
def bump_use(skill_name: str, *, task_id: Optional[str] = None, session_id: Optional[str] = None) -> 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]) -> Dict[str, Any]:
previous_use_count = _non_negative_int(rec.get("use_count"))
patch_generation = _non_negative_int(rec.get("patch_generation"))
last_reused_generation = min(_non_negative_int(rec.get("last_reused_patch_generation")), patch_generation)
reused = previous_use_count > 0
reuse_after_patch = reused and patch_generation > last_reused_generation
rec.update(
use_count=previous_use_count + 1, last_used_at=_now_iso(), patch_generation=patch_generation,
last_reused_patch_generation=patch_generation if reuse_after_patch else last_reused_generation,
)
return {
"created_by": rec.get("created_by"), "use_count": rec["use_count"],
"reused": reused, "reuse_after_patch": reuse_after_patch,
}
_mutate_and_emit(skill_name, "loaded", _apply, task_id=task_id, session_id=session_id)
def bump_patch(
skill_name: str, *, action: str = "patch", task_id: Optional[str] = None, session_id: Optional[str] = None
) -> 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]) -> Dict[str, Any]:
_bump(rec, "patch_count", "last_patched_at")
rec["patch_generation"] = _non_negative_int(rec.get("patch_generation")) + 1
return {"created_by": rec.get("created_by")}
lifecycle_action = "patched" if action == "patch" else "edited"
_mutate_and_emit(skill_name, lifecycle_action, _apply, task_id=task_id, session_id=session_id)
def record_created(
skill_name: str, *, agent_created: bool, task_id: Optional[str] = None, session_id: Optional[str] = None
) -> None:
"""Persist explicit creation provenance and emit a successful create fact."""
def _apply(rec: Dict[str, Any]) -> Dict[str, Any]:
# A successful create is a new logical skill even if stale sidecar state survived an earlier deletion or
# manual filesystem change.
rec.clear()
rec.update(_empty_record(), **({"created_by": "agent"} if agent_created else {}))
return {"created_by": rec["created_by"]}
_mutate_and_emit(skill_name, "created", _apply, task_id=task_id, session_id=session_id)
def record_installed(skill_name: str) -> None:
"""Record a successful Skills Hub install without exporting its name."""
def _apply(rec: Dict[str, Any]) -> Dict[str, Any]:
rec.update(created_by="installed", state=STATE_ACTIVE, archived_at=None)
return {"created_by": rec["created_by"]}
_mutate_and_emit(skill_name, "installed", _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."""
_set_field(skill_name, "created_by", "agent")
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]) -> Dict[str, Any]:
previous_state = rec.get("state")
facts = {"changed": previous_state != state, "created_by": rec.get("created_by")}
if facts["changed"]:
rec["state"] = state
if state != STATE_STALE:
rec["archived_at"] = _now_iso() if state == STATE_ARCHIVED else None
facts["previous_state"] = previous_state
return facts
facts = _mutate(skill_name, _apply, require_curation_eligible=True)
if not isinstance(facts, dict) or not facts.get("changed"):
return
restored = state == STATE_ACTIVE and facts.get("previous_state") == STATE_ARCHIVED # active<-stale emits nothing
action = "restored" if restored else {STATE_ARCHIVED: "archived", STATE_STALE: "stale"}.get(state)
if action is not None:
_emit_skill_lifecycle(skill_name, action, record=facts)
def set_pinned(skill_name: str, pinned: bool) -> bool:
"""Set/clear the pin flag. Returns False when the write did not land (skill not curation-eligible), True on
success — so callers can report failure instead of a false success (issue #92993)."""
return _set_field(skill_name, "pinned", bool(pinned))
def set_sync(skill_name: str, sync: bool) -> None:
"""Set the sync opt-in flag on a skill's usage record.
Sync is OPT-IN: nothing propagates to the sync plane unless the user marks a skill with ``sync: true`` here. Sits
alongside ``pinned``/``created_by`` on the ``.usage.json`` sidecar and is read by
``tools.skills_sync_client.list_synced_skill_names``. Gated on curation eligibility so bundled/hub/external skills
(which never sync) can't be marked. Provisional per the M1-D default."""
_set_field(skill_name, "sync", bool(sync))
def is_sync_enabled(skill_name: str) -> bool:
"""Whether a skill is opted into sync (``sync: true`` in its record)."""
return get_record(skill_name).get("sync") is True
def forget(skill_name: str) -> None:
"""Drop a skill's usage entry entirely. Called when the skill is deleted."""
if skill_name:
_locked_update(skill_name, lambda d: (None, d.pop(skill_name, None) is not None), "skill_usage.forget(%s) failed: %s")
# --- Archive / restore ---
def _relocate(src: Path, dest: Path, skill_name: str, action: str, **capture_kwargs: Any) -> Tuple[bool, str]:
"""Move *src* to *dest* for *action* ("archive" | "restore") with an audit-ledger entry around it, then apply the
suppression + state side effects for that direction.
Ledger pre-capture is best-effort and never blocks the move; if it is unavailable the post-move record is skipped
too. The rename falls back to shutil.move across devices. Returns (ok, message)."""
try:
from tools import skill_ledger as _ledger
_ledger_before = _ledger.capture_before(src, **capture_kwargs)
except Exception:
_ledger = _ledger_before = None # type: ignore[assignment]
try:
src.rename(dest)
except OSError:
import shutil
try:
shutil.move(str(src), str(dest))
except Exception as e:
return False, f"failed to {action}: {e}"
if action == "archive":
# 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)
else:
# Restoring a pruned built-in lifts its suppression so updates can manage it.
remove_suppressed_name(skill_name)
set_state(skill_name, STATE_ACTIVE)
with suppress(Exception):
if _ledger is not None:
_ledger.record_mutation(
action, skill_name, before=_ledger_before if _ledger_before is not None else [], after_root=dest
)
return True, f"{action}d to {dest}"
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."""
skill_dir = _find_skill_dir(skill_name)
if skill_dir is None and _find_external_skill_dir(skill_name) is not None:
return False, _external_read_only_message(skill_name)
if not is_curation_eligible(skill_name, skill_dir):
if is_protected_builtin(skill_name):
return False, f"skill '{skill_name}' is a protected built-in; it backs load-bearing UX and is never archived or consolidated"
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"
if skill_dir is None:
return False, f"skill '{skill_name}' not found"
if is_external_skill_path(skill_dir):
return False, _external_read_only_message(skill_name)
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; on collision, 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')}"
# complete_package: consolidation may have re-homed support files out of the tree first, so a disk-only
# capture can come back hollow; the fill from the newest curator backup keeps rollback restorable (#96962).
return _relocate(skill_dir, dest, skill_name, "archive", complete_package=True, skill=skill_name)
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 the timestamped-duplicate fallback. 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:
# A name collision makes archive_skill() disambiguate by appending its UTC timestamp
# ("<skill>-YYYYMMDDHHMMSS", a 14-digit suffix), so only that exact shape is another copy of THIS skill.
# A bare startswith(f"{skill_name}-") also swallows unrelated sibling skills — restoring "git" would
# otherwise pull an archived "git-helpers" out of the archive and rename it to "git", destroying the
# sibling's only copy. Require the suffix to be the timestamp archive_skill writes.
prefix = f"{skill_name}-"
candidates = sorted(
(p for p in archive_root.rglob("*") if p.is_dir() and p.name.startswith(prefix)
and len(p.name) - len(prefix) == 14 and p.name[len(prefix):].isdigit()),
reverse=True,
)
if not candidates:
return False, f"skill '{skill_name}' not found in archive"
dest = _skills_dir() / skill_name
if dest.exists():
return False, f"destination already exists: {dest}"
return _relocate(candidates[0], dest, skill_name, "restore")
def _match_skill_dir(skill_mds: Iterable[Path], skill_name: str) -> Optional[Path]:
return next((p.parent for p in skill_mds if _read_skill_name(p, fallback=p.parent.name) == skill_name), None)
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. Uses the gated index iterator so M2 org mirrors resolve
ONLY for the active org (stale ``_org/<other>/`` trees never match)."""
base = _skills_dir()
if not base.exists():
return None
from agent.skill_utils import iter_skill_index_files
return _match_skill_dir(
(p for p in iter_skill_index_files(base, "SKILL.md") if not is_external_skill_path(p)), skill_name
)
def _find_external_skill_dir(skill_name: str) -> Optional[Path]:
"""Locate a skill under configured external dirs by frontmatter name."""
from agent.skill_utils import get_all_skills_dirs
for base in (b for b in get_all_skills_dirs()[1:] if b.exists()):
found = _match_skill_dir((p for p in base.rglob("SKILL.md") if not is_excluded_skill_path(p)), skill_name)
if found is not None:
return found
return None
# --- Reporting — for the curator CLI / slash command ---
def curated_report() -> List[Dict[str, Any]]:
"""Return a list of {name, provenance, state, pinned, last_activity_at, ...} records for every curator-managed
skill. Missing usage records are backfilled with defaults so callers can always index fields.
``provenance`` is 'agent', 'bundled', or 'hub' (see :func:`provenance`). Bundled skills are only included when
``curator.prune_builtins`` is enabled. Hub-installed skills are never included.
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()
names = set(list_agent_created_skill_names())
# Issue #92993: a successfully pinned skill must be visible in the report even when it lacks the created_by
# marker (eligible-but-unmanaged), or its pin silently vanishes from `curator status`. The local-dir guard
# keeps stale records for deleted skill dirs from rendering as ghost rows; `curator unpin` is the cleanup path.
names.update(
name for name, rec in data.items()
if isinstance(rec, dict) and rec.get("pinned") and is_curation_eligible(name) and _find_skill_dir(name) is not None
)
rows = [_report_row(name, data.get(name), _persisted=isinstance(data.get(name), dict)) for name in sorted(names)]
for row in rows:
row["provenance"] = provenance(row["name"])
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."""
return "hub" if is_hub_installed(skill_name) else "bundled" if is_bundled(skill_name) else "agent"
def usage_report() -> List[Dict[str, Any]]:
"""Return usage telemetry for EVERY skill on disk, with provenance.
Unlike ``curated_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: Dict[str, Dict[str, Any]] = {}
for name, _skill_md in _iter_skill_mds(base, local_only=False):
if name not in rows:
rows[name] = _report_row(name, data.get(name), provenance=provenance(name), _persisted=isinstance(data.get(name), dict))
return [rows[name] for name in sorted(rows)]