feat(curator): show most-used and least-used skills in hermes curator status (#18033)
Alongside the existing 'least recently used' section, surface two more rankings so users can see which of their agent-created skills actually get exercised: - 'most used (top 5)' — sorted by use_count descending. Hidden when every skill has use_count=0 (noise suppression on fresh installs). - 'least used (top 5)' — sorted by use_count ascending. Always shown when the catalog is non-empty. use_count started tracking real agent skill activation in PR #17932 (bump_use wired into skill_view tool + slash invocation + --skill preload), so these rankings are now meaningful. Tests: 3 new in tests/hermes_cli/test_curator_status.py — happy path with mixed use_counts, zero-use suppression of the most-used section, and the no-skills clean-empty case.
This commit is contained in:
@@ -108,6 +108,49 @@ def _cmd_status(args) -> int:
|
|||||||
f"last_activity={last}"
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def test_status_uses_last_activity_not_only_last_used(monkeypatch, capsys):
|
def test_status_uses_last_activity_not_only_last_used(monkeypatch, capsys):
|
||||||
import agent.curator as curator_state
|
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 "activity= 4" in out
|
||||||
assert "last_activity=never" not in out
|
assert "last_activity=never" not in out
|
||||||
assert "last_used=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
|
||||||
|
|||||||
Reference in New Issue
Block a user