mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 02:37:05 +08:00
* fix(curator): protect hub skills by frontmatter name * test(skill_usage): add mark_agent_created to regression test The cherry-picked test predates #19618/#19621 which rewrote list_agent_created_skill_names() to require an explicit created_by: 'agent' provenance marker. Without mark_agent_created(), my-skill is excluded from the list and the positive assertion fails. * feat(curator): add archive and prune subcommands Adds 'hermes curator archive <skill>' and 'hermes curator prune [--days N] [--yes] [--dry-run]' alongside the existing status, run, pause, resume, pin, unpin, restore, backup, rollback verbs. These are the two genuinely new user-facing verbs requested in #19384. The other verbs proposed there ('stats' and 'restore') already exist as 'curator status' and 'curator restore', so no duplicate surface is added — all skill lifecycle commands live under the single 'hermes curator' namespace. - archive: manual archive of an agent-created skill. Refuses pinned skills with a hint pointing at 'hermes curator unpin'. - prune: bulk-archive unpinned skills idle for >= N days (default 90). Falls back to created_at when last_activity_at is null so never-used skills can still be pruned. --dry-run previews, --yes skips prompt. Adapted from @elmatadorgh's PR #19454 which placed the same verbs under 'hermes skills' with a separate hermes_cli/skills_config.py handler and rich table for stats. The 'stats' and 'restore' parts of that PR duplicated existing surface, so only archive and prune are kept, rewritten to match hermes_cli/curator.py's existing plain-text handler style. Tests rewritten from scratch against the new handlers. Closes #19384 Co-authored-by: elmatadorgh <coktinbaran5@gmail.com> --------- Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com> Co-authored-by: elmatadorgh <coktinbaran5@gmail.com>
561 lines
19 KiB
Python
561 lines
19 KiB
Python
"""CLI subcommand: `hermes curator <subcommand>`.
|
|
|
|
Thin shell around agent/curator.py and tools/skill_usage.py. Renders a status
|
|
table, triggers a run, pauses/resumes, and pins/unpins skills.
|
|
|
|
This module intentionally has no side effects at import time — main.py wires
|
|
the argparse subparsers on demand.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
|
|
def _fmt_ts(ts: Optional[str]) -> str:
|
|
if not ts:
|
|
return "never"
|
|
try:
|
|
dt = datetime.fromisoformat(ts)
|
|
except (TypeError, ValueError):
|
|
return str(ts)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
delta = datetime.now(timezone.utc) - dt
|
|
secs = int(delta.total_seconds())
|
|
if secs < 60:
|
|
return f"{secs}s ago"
|
|
if secs < 3600:
|
|
return f"{secs // 60}m ago"
|
|
if secs < 86400:
|
|
return f"{secs // 3600}h ago"
|
|
return f"{secs // 86400}d ago"
|
|
|
|
|
|
def _cmd_status(args) -> int:
|
|
from agent import curator
|
|
from tools import skill_usage
|
|
|
|
state = curator.load_state()
|
|
enabled = curator.is_enabled()
|
|
paused = state.get("paused", False)
|
|
last_run = state.get("last_run_at")
|
|
summary = state.get("last_run_summary") or "(none)"
|
|
runs = state.get("run_count", 0)
|
|
|
|
status_line = (
|
|
"ENABLED" if enabled and not paused else
|
|
"PAUSED" if paused else
|
|
"DISABLED"
|
|
)
|
|
print(f"curator: {status_line}")
|
|
print(f" runs: {runs}")
|
|
print(f" last run: {_fmt_ts(last_run)}")
|
|
print(f" last summary: {summary}")
|
|
_report = state.get("last_report_path")
|
|
if _report:
|
|
print(f" last report: {_report}")
|
|
_ih = curator.get_interval_hours()
|
|
_interval_label = (
|
|
f"{_ih // 24}d" if _ih % 24 == 0 and _ih >= 24
|
|
else f"{_ih}h"
|
|
)
|
|
print(f" interval: every {_interval_label}")
|
|
print(f" stale after: {curator.get_stale_after_days()}d unused")
|
|
print(f" archive after: {curator.get_archive_after_days()}d unused")
|
|
|
|
rows = skill_usage.agent_created_report()
|
|
if not rows:
|
|
print("\nno agent-created skills")
|
|
return 0
|
|
|
|
by_state = {"active": [], "stale": [], "archived": []}
|
|
pinned = []
|
|
for r in rows:
|
|
state_name = r.get("state", "active")
|
|
by_state.setdefault(state_name, []).append(r)
|
|
if r.get("pinned"):
|
|
pinned.append(r["name"])
|
|
|
|
print(f"\nagent-created skills: {len(rows)} total")
|
|
for state_name in ("active", "stale", "archived"):
|
|
bucket = by_state.get(state_name, [])
|
|
print(f" {state_name:10s} {len(bucket)}")
|
|
|
|
if pinned:
|
|
print(f"\npinned ({len(pinned)}): {', '.join(pinned)}")
|
|
|
|
# Show top 5 least-recently-active skills. Views and edits are activity too:
|
|
# curator should not report a skill as "never used" right after skill_view()
|
|
# or skill_manage() touched it.
|
|
active = sorted(
|
|
by_state.get("active", []),
|
|
key=lambda r: r.get("last_activity_at") or r.get("created_at") or "",
|
|
)[:5]
|
|
if active:
|
|
print("\nleast recently active (top 5):")
|
|
for r in 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}"
|
|
)
|
|
|
|
# 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
|
|
|
|
|
|
def _cmd_run(args) -> int:
|
|
from agent import curator
|
|
if not curator.is_enabled():
|
|
print("curator: disabled via config; enable with `curator.enabled: true`")
|
|
return 1
|
|
|
|
dry = bool(getattr(args, "dry_run", False))
|
|
if dry:
|
|
print("curator: running DRY-RUN (report only, no mutations)...")
|
|
else:
|
|
print("curator: running review pass...")
|
|
|
|
def _on_summary(msg: str) -> None:
|
|
print(msg)
|
|
|
|
result = curator.run_curator_review(
|
|
on_summary=_on_summary,
|
|
synchronous=bool(args.synchronous),
|
|
dry_run=dry,
|
|
)
|
|
auto = result.get("auto_transitions", {})
|
|
if auto:
|
|
if dry:
|
|
print(
|
|
f"auto (preview): {auto.get('checked', 0)} candidate skill(s) "
|
|
"— no transitions applied in dry-run"
|
|
)
|
|
else:
|
|
print(
|
|
f"auto: checked={auto.get('checked', 0)} "
|
|
f"stale={auto.get('marked_stale', 0)} "
|
|
f"archived={auto.get('archived', 0)} "
|
|
f"reactivated={auto.get('reactivated', 0)}"
|
|
)
|
|
if not args.synchronous:
|
|
print("llm pass running in background — check `hermes curator status` later")
|
|
if dry:
|
|
print(
|
|
"dry-run: no changes applied. When the report lands, read it with "
|
|
"`hermes curator status` and run `hermes curator run` (no flag) to apply."
|
|
)
|
|
return 0
|
|
|
|
|
|
def _cmd_pause(args) -> int:
|
|
from agent import curator
|
|
curator.set_paused(True)
|
|
print("curator: paused")
|
|
return 0
|
|
|
|
|
|
def _cmd_resume(args) -> int:
|
|
from agent import curator
|
|
curator.set_paused(False)
|
|
print("curator: resumed")
|
|
return 0
|
|
|
|
|
|
def _cmd_pin(args) -> int:
|
|
from tools import skill_usage
|
|
if not skill_usage.is_agent_created(args.skill):
|
|
print(
|
|
f"curator: '{args.skill}' is bundled or hub-installed — cannot pin "
|
|
"(only agent-created skills participate in curation)"
|
|
)
|
|
return 1
|
|
skill_usage.set_pinned(args.skill, True)
|
|
print(f"curator: pinned '{args.skill}' (will bypass auto-transitions)")
|
|
return 0
|
|
|
|
|
|
def _cmd_unpin(args) -> int:
|
|
from tools import skill_usage
|
|
if not skill_usage.is_agent_created(args.skill):
|
|
print(
|
|
f"curator: '{args.skill}' is bundled or hub-installed — "
|
|
"there's nothing to unpin (curator only tracks agent-created skills)"
|
|
)
|
|
return 1
|
|
skill_usage.set_pinned(args.skill, False)
|
|
print(f"curator: unpinned '{args.skill}'")
|
|
return 0
|
|
|
|
|
|
def _cmd_restore(args) -> int:
|
|
from tools import skill_usage
|
|
ok, msg = skill_usage.restore_skill(args.skill)
|
|
print(f"curator: {msg}")
|
|
return 0 if ok else 1
|
|
|
|
|
|
def _cmd_archive(args) -> int:
|
|
"""Manually archive an agent-created skill. Refuses if pinned.
|
|
|
|
The auto-curator archives stale skills on its own schedule; this verb is
|
|
for the user who wants to archive *now* without waiting for a run.
|
|
"""
|
|
from tools import skill_usage
|
|
if skill_usage.get_record(args.skill).get("pinned"):
|
|
print(
|
|
f"curator: '{args.skill}' is pinned — unpin first with "
|
|
f"`hermes curator unpin {args.skill}`"
|
|
)
|
|
return 1
|
|
ok, msg = skill_usage.archive_skill(args.skill)
|
|
print(f"curator: {msg}")
|
|
return 0 if ok else 1
|
|
|
|
|
|
def _idle_days(record: dict) -> Optional[int]:
|
|
"""Days since the skill's last activity (view / use / patch).
|
|
|
|
Falls back to ``created_at`` so a skill that was authored but never used
|
|
can still be pruned — otherwise never-touched skills would be immortal.
|
|
Returns None only when both fields are missing or unparseable.
|
|
"""
|
|
ts = record.get("last_activity_at") or record.get("created_at")
|
|
if not ts:
|
|
return None
|
|
try:
|
|
dt = datetime.fromisoformat(str(ts))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return max(0, (datetime.now(timezone.utc) - dt).days)
|
|
|
|
|
|
def _cmd_prune(args) -> int:
|
|
"""Bulk-archive agent-created skills idle for >= N days.
|
|
|
|
Pinned skills are exempt. Already-archived skills are skipped. Default
|
|
``--days 90`` matches a conservative read of the curator's own archive
|
|
threshold; adjust with ``--days``. Use ``--dry-run`` to preview.
|
|
"""
|
|
from tools import skill_usage
|
|
days = getattr(args, "days", 90)
|
|
if days < 1:
|
|
print(f"curator: --days must be >= 1 (got {days})", file=sys.stderr)
|
|
return 2
|
|
|
|
dry_run = bool(getattr(args, "dry_run", False))
|
|
skip_confirm = bool(getattr(args, "yes", False))
|
|
|
|
candidates = []
|
|
for r in skill_usage.agent_created_report():
|
|
if r.get("pinned"):
|
|
continue
|
|
if r.get("state") == skill_usage.STATE_ARCHIVED:
|
|
continue
|
|
idle = _idle_days(r)
|
|
if idle is None or idle < days:
|
|
continue
|
|
candidates.append((r["name"], idle))
|
|
|
|
if not candidates:
|
|
print(f"curator: nothing to prune (no unpinned skills idle >= {days}d)")
|
|
return 0
|
|
|
|
candidates.sort(key=lambda c: -c[1])
|
|
print(f"curator: {len(candidates)} skill(s) idle >= {days}d:")
|
|
for name, idle in candidates:
|
|
print(f" {name:40s} idle {idle}d")
|
|
|
|
if dry_run:
|
|
print("\n(dry run — no changes made)")
|
|
return 0
|
|
|
|
if not skip_confirm:
|
|
try:
|
|
reply = input(f"\nArchive {len(candidates)} skill(s)? [y/N] ").strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\ncurator: aborted")
|
|
return 1
|
|
if reply not in ("y", "yes"):
|
|
print("curator: aborted")
|
|
return 1
|
|
|
|
archived = 0
|
|
failures = []
|
|
for name, _ in candidates:
|
|
ok, msg = skill_usage.archive_skill(name)
|
|
if ok:
|
|
archived += 1
|
|
else:
|
|
failures.append((name, msg))
|
|
|
|
print(f"\ncurator: archived {archived}/{len(candidates)}")
|
|
if failures:
|
|
print("failures:")
|
|
for name, msg in failures:
|
|
print(f" {name}: {msg}")
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def _cmd_backup(args) -> int:
|
|
"""Take a manual snapshot of the skills tree. Same mechanism as the
|
|
automatic pre-run snapshot, just user-initiated."""
|
|
from agent import curator_backup
|
|
if not curator_backup.is_enabled():
|
|
print(
|
|
"curator: backups are disabled via config "
|
|
"(`curator.backup.enabled: false`); re-enable to snapshot"
|
|
)
|
|
return 1
|
|
reason = getattr(args, "reason", None) or "manual"
|
|
snap = curator_backup.snapshot_skills(reason=reason)
|
|
if snap is None:
|
|
print("curator: snapshot failed — check logs (backup disabled or IO error)")
|
|
return 1
|
|
print(f"curator: snapshot created at ~/.hermes/skills/.curator_backups/{snap.name}")
|
|
return 0
|
|
|
|
|
|
def _cmd_rollback(args) -> int:
|
|
"""Restore the skills tree from a snapshot. Defaults to newest.
|
|
|
|
``--list`` prints available snapshots and exits. ``--id <stamp>`` picks
|
|
a specific one. Without ``-y``, prompts for confirmation. A safety
|
|
snapshot of the current tree is always taken first, so rollbacks are
|
|
themselves undoable.
|
|
"""
|
|
from agent import curator_backup
|
|
|
|
if getattr(args, "list", False):
|
|
print(curator_backup.summarize_backups())
|
|
return 0
|
|
|
|
backup_id = getattr(args, "backup_id", None)
|
|
target_path = curator_backup._resolve_backup(backup_id)
|
|
if target_path is None:
|
|
rows = curator_backup.list_backups()
|
|
if not rows:
|
|
print(
|
|
"curator: no snapshots exist yet. Take one with "
|
|
"`hermes curator backup` or wait for the next curator run."
|
|
)
|
|
else:
|
|
print(
|
|
f"curator: no snapshot matching "
|
|
f"{'id ' + repr(backup_id) if backup_id else 'your query'}."
|
|
)
|
|
print("Available:")
|
|
print(curator_backup.summarize_backups())
|
|
return 1
|
|
|
|
manifest = curator_backup._read_manifest(target_path)
|
|
print(f"Rollback target: {target_path.name}")
|
|
if manifest:
|
|
print(f" reason: {manifest.get('reason', '?')}")
|
|
print(f" created_at: {manifest.get('created_at', '?')}")
|
|
print(f" skill files: {manifest.get('skill_files', '?')}")
|
|
cron = manifest.get("cron_jobs") or {}
|
|
if isinstance(cron, dict):
|
|
if cron.get("backed_up"):
|
|
print(
|
|
f" cron jobs: {cron.get('jobs_count', 0)} "
|
|
f"(will be restored for skill-link fields only)"
|
|
)
|
|
else:
|
|
reason = cron.get("reason", "not captured")
|
|
print(f" cron jobs: not in snapshot ({reason})")
|
|
print(
|
|
"\nThis will replace the current ~/.hermes/skills/ tree (a safety "
|
|
"snapshot of the current state is taken first so this is undoable). "
|
|
"Cron jobs that still exist will have their skills/skill fields "
|
|
"restored from the snapshot; all other cron fields are left alone."
|
|
)
|
|
|
|
if not getattr(args, "yes", False):
|
|
try:
|
|
ans = input("Proceed? [y/N] ").strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\ncancelled")
|
|
return 1
|
|
if ans not in ("y", "yes"):
|
|
print("cancelled")
|
|
return 1
|
|
|
|
ok, msg, _ = curator_backup.rollback(backup_id=target_path.name)
|
|
if ok:
|
|
print(f"curator: {msg}")
|
|
return 0
|
|
print(f"curator: rollback failed — {msg}")
|
|
return 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# argparse wiring (called from hermes_cli.main)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def register_cli(parent: argparse.ArgumentParser) -> None:
|
|
"""Attach `curator` subcommands to *parent*.
|
|
|
|
main.py calls this with the ArgumentParser returned by
|
|
``subparsers.add_parser("curator", ...)``.
|
|
"""
|
|
parent.set_defaults(func=lambda a: (parent.print_help(), 0)[1])
|
|
subs = parent.add_subparsers(dest="curator_command")
|
|
|
|
p_status = subs.add_parser("status", help="Show curator status and skill stats")
|
|
p_status.set_defaults(func=_cmd_status)
|
|
|
|
p_run = subs.add_parser("run", help="Trigger a curator review now")
|
|
p_run.add_argument(
|
|
"--sync", "--synchronous", dest="synchronous", action="store_true",
|
|
help="Wait for the LLM review pass to finish (default: background thread)",
|
|
)
|
|
p_run.add_argument(
|
|
"--dry-run", dest="dry_run", action="store_true",
|
|
help="Report only — no state changes, no archives, no consolidation "
|
|
"(use this to preview what curator would do)",
|
|
)
|
|
p_run.set_defaults(func=_cmd_run)
|
|
|
|
p_pause = subs.add_parser("pause", help="Pause the curator until resumed")
|
|
p_pause.set_defaults(func=_cmd_pause)
|
|
|
|
p_resume = subs.add_parser("resume", help="Resume a paused curator")
|
|
p_resume.set_defaults(func=_cmd_resume)
|
|
|
|
p_pin = subs.add_parser("pin", help="Pin a skill so the curator never auto-transitions it")
|
|
p_pin.add_argument("skill", help="Skill name")
|
|
p_pin.set_defaults(func=_cmd_pin)
|
|
|
|
p_unpin = subs.add_parser("unpin", help="Unpin a skill")
|
|
p_unpin.add_argument("skill", help="Skill name")
|
|
p_unpin.set_defaults(func=_cmd_unpin)
|
|
|
|
p_restore = subs.add_parser("restore", help="Restore an archived skill")
|
|
p_restore.add_argument("skill", help="Skill name")
|
|
p_restore.set_defaults(func=_cmd_restore)
|
|
|
|
p_archive = subs.add_parser(
|
|
"archive",
|
|
help="Manually archive a skill (move to .archive/, excluded from prompt)",
|
|
)
|
|
p_archive.add_argument("skill", help="Skill name")
|
|
p_archive.set_defaults(func=_cmd_archive)
|
|
|
|
p_prune = subs.add_parser(
|
|
"prune",
|
|
help="Bulk-archive agent-created skills idle for >= N days (default 90)",
|
|
)
|
|
p_prune.add_argument(
|
|
"--days", type=int, default=90,
|
|
help="Archive skills idle for at least N days (default: 90)",
|
|
)
|
|
p_prune.add_argument(
|
|
"-y", "--yes", action="store_true",
|
|
help="Skip the confirmation prompt",
|
|
)
|
|
p_prune.add_argument(
|
|
"--dry-run", dest="dry_run", action="store_true",
|
|
help="Show what would be archived without doing it",
|
|
)
|
|
p_prune.set_defaults(func=_cmd_prune)
|
|
|
|
p_backup = subs.add_parser(
|
|
"backup",
|
|
help="Take a manual tar.gz snapshot of ~/.hermes/skills/ "
|
|
"(curator also does this automatically before every real run)",
|
|
)
|
|
p_backup.add_argument(
|
|
"--reason", default=None,
|
|
help="Free-text label stored in manifest.json (default: 'manual')",
|
|
)
|
|
p_backup.set_defaults(func=_cmd_backup)
|
|
|
|
p_rollback = subs.add_parser(
|
|
"rollback",
|
|
help="Restore ~/.hermes/skills/ from a curator snapshot "
|
|
"(defaults to the newest)",
|
|
)
|
|
p_rollback.add_argument(
|
|
"--list", action="store_true",
|
|
help="List available snapshots and exit without restoring",
|
|
)
|
|
p_rollback.add_argument(
|
|
"--id", dest="backup_id", default=None,
|
|
help="Snapshot id to restore (see `--list`); default: newest",
|
|
)
|
|
p_rollback.add_argument(
|
|
"-y", "--yes", action="store_true",
|
|
help="Skip confirmation prompt",
|
|
)
|
|
p_rollback.set_defaults(func=_cmd_rollback)
|
|
|
|
|
|
def cli_main(argv=None) -> int:
|
|
"""Standalone entry (also usable by hermes_cli.main fallthrough)."""
|
|
parser = argparse.ArgumentParser(prog="hermes curator")
|
|
register_cli(parser)
|
|
args = parser.parse_args(argv)
|
|
fn = getattr(args, "func", None)
|
|
if fn is None:
|
|
parser.print_help()
|
|
return 0
|
|
return int(fn(args) or 0)
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
sys.exit(cli_main())
|