diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index e22d5f5831..bd2c8d65cc 100644 --- a/hermes_cli/curator.py +++ b/hermes_cli/curator.py @@ -108,6 +108,49 @@ def _cmd_status(args) -> int: f"last_activity={last}" ) + # Show top 5 most-active and least-active skills by activity_count + # (use + view + patch). This is a different signal from + # least-recently-active: activity_count reflects frequency, + # last_activity_at reflects recency. A skill touched 30 times a year + # ago is high-frequency but stale; a skill touched once yesterday is + # recent but low-frequency. Both can matter. + active_all = by_state.get("active", []) + if active_all: + most_active = sorted( + active_all, + key=lambda r: (r.get("activity_count") or 0, r.get("last_activity_at") or ""), + reverse=True, + )[:5] + if most_active and (most_active[0].get("activity_count") or 0) > 0: + print("\nmost active (top 5):") + for r in most_active: + last = _fmt_ts(r.get("last_activity_at")) + print( + f" {r['name']:40s} " + f"activity={r.get('activity_count', 0):3d} " + f"use={r.get('use_count', 0):3d} " + f"view={r.get('view_count', 0):3d} " + f"patches={r.get('patch_count', 0):3d} " + f"last_activity={last}" + ) + + least_active = sorted( + active_all, + key=lambda r: (r.get("activity_count") or 0, r.get("last_activity_at") or ""), + )[:5] + if least_active: + print("\nleast active (top 5):") + for r in least_active: + last = _fmt_ts(r.get("last_activity_at")) + print( + f" {r['name']:40s} " + f"activity={r.get('activity_count', 0):3d} " + f"use={r.get('use_count', 0):3d} " + f"view={r.get('view_count', 0):3d} " + f"patches={r.get('patch_count', 0):3d} " + f"last_activity={last}" + ) + return 0 diff --git a/tests/hermes_cli/test_curator_status.py b/tests/hermes_cli/test_curator_status.py index eb179a8e82..3be5862592 100644 --- a/tests/hermes_cli/test_curator_status.py +++ b/tests/hermes_cli/test_curator_status.py @@ -1,7 +1,21 @@ -"""Tests for the curator CLI status renderer.""" +"""Tests for `hermes curator status` output. +Covers: +- y0shualee's "least recently active" semantic (view/patch/use all count as activity). +- The most-used / least-used rankings by activity_count so users can see which + skills actually get exercised. +""" + +from __future__ import annotations + +import io +from argparse import Namespace +from contextlib import redirect_stdout +from pathlib import Path from types import SimpleNamespace +import pytest + def test_status_uses_last_activity_not_only_last_used(monkeypatch, capsys): import agent.curator as curator_state @@ -41,3 +55,115 @@ def test_status_uses_last_activity_not_only_last_used(monkeypatch, capsys): assert "activity= 4" in out assert "last_activity=never" not in out assert "last_used=never" not in out + + +@pytest.fixture +def curator_status_env(tmp_path, monkeypatch): + """Isolated HERMES_HOME with real agent-created skills on disk.""" + home = tmp_path / ".hermes" + skills = home / "skills" + skills.mkdir(parents=True) + (home / "logs").mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + import importlib + import hermes_constants + importlib.reload(hermes_constants) + from tools import skill_usage + importlib.reload(skill_usage) + from agent import curator + importlib.reload(curator) + from hermes_cli import curator as curator_cli + importlib.reload(curator_cli) + + def _write_skill(name: str) -> None: + d = skills / name + d.mkdir() + (d / "SKILL.md").write_text( + "---\n" + f"name: {name}\n" + "description: test\n" + "version: 1.0.0\n" + "metadata:\n" + " hermes:\n" + " agent_created: true\n" + "---\n" + f"# {name}\n" + ) + + return { + "home": home, + "skills": skills, + "make_skill": _write_skill, + "skill_usage": skill_usage, + "curator_cli": curator_cli, + } + + +def _capture_status(curator_cli) -> str: + buf = io.StringIO() + with redirect_stdout(buf): + rc = curator_cli._cmd_status(Namespace()) + assert rc == 0 + return buf.getvalue() + + +def test_status_shows_most_and_least_used_sections(curator_status_env): + env = curator_status_env + env["make_skill"]("top-dog") + env["make_skill"]("middling") + env["make_skill"]("never-used") + + # Bump use_count differentially. All three counters (use/view/patch) feed + # into activity_count, so bumping use alone is enough to make activity + # diverge between skills. + for _ in range(10): + env["skill_usage"].bump_use("top-dog") + for _ in range(2): + env["skill_usage"].bump_use("middling") + + out = _capture_status(env["curator_cli"]) + + # Both new sections present + assert "most active (top 5):" in out + assert "least active (top 5):" in out + # y0shualee's section preserved + assert "least recently active (top 5):" in out + + # most-active lists top-dog FIRST (highest activity_count) + most_section = out.split("most active (top 5):")[1].split("\n\n")[0] + top_line = most_section.strip().split("\n")[0] + assert "top-dog" in top_line + assert "activity= 10" in top_line + + # least-active lists never-used FIRST (activity=0) + least_section = out.split("least active (top 5):")[1].split("\n\n")[0] + bottom_line = least_section.strip().split("\n")[0] + assert "never-used" in bottom_line + assert "activity= 0" in bottom_line + + +def test_status_hides_most_active_when_all_zero(curator_status_env): + """If no skills have any activity, skip the most-active block — it's noise. + Least-active still shows so the user sees their catalog.""" + env = curator_status_env + env["make_skill"]("a") + env["make_skill"]("b") + # No bumps. + + out = _capture_status(env["curator_cli"]) + + # most-active section is hidden because the top is 0 + assert "most active (top 5):" not in out + # least-active still renders — it's part of the catalog overview + assert "least active (top 5):" in out + + +def test_status_no_skills_produces_clean_empty_output(curator_status_env): + env = curator_status_env + out = _capture_status(env["curator_cli"]) + assert "no agent-created skills" in out + # None of the ranking sections render + assert "most active" not in out + assert "least active" not in out