Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions hermes_cli/curator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
128 changes: 127 additions & 1 deletion tests/hermes_cli/test_curator_status.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Loading