"""Skill and curator telemetry collectors for the /stats dashboard.""" from __future__ import annotations from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List from hermes_constants import get_hermes_home from tools.skill_usage import ( STATE_ARCHIVED, activity_count, latest_activity_at, load_usage, ) def _parse_dt(value: Any): if not value: return None try: dt = datetime.fromisoformat(str(value)) except (TypeError, ValueError): return None if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt def _activity_count(record: Dict[str, Any]) -> int: return activity_count(record) def collect_skill_stats(limit: int = 5) -> Dict[str, Any]: usage = load_usage() rows: List[Dict[str, Any]] = [] for name, record in usage.items(): if not isinstance(record, dict): continue count = _activity_count(record) rows.append({ "name": str(name), "activity_count": count, "use_count": int(record.get("use_count") or 0), "view_count": int(record.get("view_count") or 0), "patch_count": int(record.get("patch_count") or 0), "last_activity_at": latest_activity_at(record), "state": record.get("state") or "active", }) rows.sort(key=lambda r: (r["activity_count"], r["name"]), reverse=True) return {"top_skills": rows[:limit], "usage_records": len(rows)} def collect_curator_prunes(days: int = 7, limit: int = 3) -> Dict[str, Any]: cutoff = datetime.now(timezone.utc) - timedelta(days=days) usage = load_usage() archived = [] for name, record in usage.items(): if not isinstance(record, dict): continue if record.get("state") != STATE_ARCHIVED: continue ts = record.get("archived_at") or record.get("last_patched_at") or record.get("created_at") dt = _parse_dt(ts) if dt is not None and dt < cutoff: continue archived.append({"name": str(name), "archived_at": ts}) # Also inspect the archive directory so manually restored/old usage sidecars # still have a real filesystem source for the dashboard. archive_dir = get_hermes_home() / "skills" / ".archive" if archive_dir.exists(): for path in archive_dir.iterdir(): if not path.is_dir(): continue try: dt = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) except OSError: continue if dt >= cutoff and not any(row["name"] == path.name for row in archived): archived.append({"name": path.name, "archived_at": dt.isoformat()}) archived.sort(key=lambda r: str(r.get("archived_at") or ""), reverse=True) return {"recent_prunes": archived[:limit], "recent_prune_count": len(archived), "days": days}