Skip to content

Commit a5d1bdc

Browse files
teknium1LeonSGP43elmatadorgh
authored andcommitted
feat(curator): add archive and prune subcommands (NousResearch#20200)
* fix(curator): protect hub skills by frontmatter name * test(skill_usage): add mark_agent_created to regression test The cherry-picked test predates NousResearch#19618/NousResearch#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 NousResearch#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 NousResearch#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 NousResearch#19384 Co-authored-by: elmatadorgh <coktinbaran5@gmail.com> --------- Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com> Co-authored-by: elmatadorgh <coktinbaran5@gmail.com>
1 parent 595ffd8 commit a5d1bdc

3 files changed

Lines changed: 400 additions & 0 deletions

File tree

hermes_cli/curator.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,111 @@ def _cmd_restore(args) -> int:
245245
return 0 if ok else 1
246246

247247

248+
def _cmd_archive(args) -> int:
249+
"""Manually archive an agent-created skill. Refuses if pinned.
250+
251+
The auto-curator archives stale skills on its own schedule; this verb is
252+
for the user who wants to archive *now* without waiting for a run.
253+
"""
254+
from tools import skill_usage
255+
if skill_usage.get_record(args.skill).get("pinned"):
256+
print(
257+
f"curator: '{args.skill}' is pinned — unpin first with "
258+
f"`hermes curator unpin {args.skill}`"
259+
)
260+
return 1
261+
ok, msg = skill_usage.archive_skill(args.skill)
262+
print(f"curator: {msg}")
263+
return 0 if ok else 1
264+
265+
266+
def _idle_days(record: dict) -> Optional[int]:
267+
"""Days since the skill's last activity (view / use / patch).
268+
269+
Falls back to ``created_at`` so a skill that was authored but never used
270+
can still be pruned — otherwise never-touched skills would be immortal.
271+
Returns None only when both fields are missing or unparseable.
272+
"""
273+
ts = record.get("last_activity_at") or record.get("created_at")
274+
if not ts:
275+
return None
276+
try:
277+
dt = datetime.fromisoformat(str(ts))
278+
except (TypeError, ValueError):
279+
return None
280+
if dt.tzinfo is None:
281+
dt = dt.replace(tzinfo=timezone.utc)
282+
return max(0, (datetime.now(timezone.utc) - dt).days)
283+
284+
285+
def _cmd_prune(args) -> int:
286+
"""Bulk-archive agent-created skills idle for >= N days.
287+
288+
Pinned skills are exempt. Already-archived skills are skipped. Default
289+
``--days 90`` matches a conservative read of the curator's own archive
290+
threshold; adjust with ``--days``. Use ``--dry-run`` to preview.
291+
"""
292+
from tools import skill_usage
293+
days = getattr(args, "days", 90)
294+
if days < 1:
295+
print(f"curator: --days must be >= 1 (got {days})", file=sys.stderr)
296+
return 2
297+
298+
dry_run = bool(getattr(args, "dry_run", False))
299+
skip_confirm = bool(getattr(args, "yes", False))
300+
301+
candidates = []
302+
for r in skill_usage.agent_created_report():
303+
if r.get("pinned"):
304+
continue
305+
if r.get("state") == skill_usage.STATE_ARCHIVED:
306+
continue
307+
idle = _idle_days(r)
308+
if idle is None or idle < days:
309+
continue
310+
candidates.append((r["name"], idle))
311+
312+
if not candidates:
313+
print(f"curator: nothing to prune (no unpinned skills idle >= {days}d)")
314+
return 0
315+
316+
candidates.sort(key=lambda c: -c[1])
317+
print(f"curator: {len(candidates)} skill(s) idle >= {days}d:")
318+
for name, idle in candidates:
319+
print(f" {name:40s} idle {idle}d")
320+
321+
if dry_run:
322+
print("\n(dry run — no changes made)")
323+
return 0
324+
325+
if not skip_confirm:
326+
try:
327+
reply = input(f"\nArchive {len(candidates)} skill(s)? [y/N] ").strip().lower()
328+
except (EOFError, KeyboardInterrupt):
329+
print("\ncurator: aborted")
330+
return 1
331+
if reply not in ("y", "yes"):
332+
print("curator: aborted")
333+
return 1
334+
335+
archived = 0
336+
failures = []
337+
for name, _ in candidates:
338+
ok, msg = skill_usage.archive_skill(name)
339+
if ok:
340+
archived += 1
341+
else:
342+
failures.append((name, msg))
343+
344+
print(f"\ncurator: archived {archived}/{len(candidates)}")
345+
if failures:
346+
print("failures:")
347+
for name, msg in failures:
348+
print(f" {name}: {msg}")
349+
return 1
350+
return 0
351+
352+
248353
def _cmd_backup(args) -> int:
249354
"""Take a manual snapshot of the skills tree. Same mechanism as the
250355
automatic pre-run snapshot, just user-initiated."""
@@ -383,6 +488,31 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
383488
p_restore.add_argument("skill", help="Skill name")
384489
p_restore.set_defaults(func=_cmd_restore)
385490

491+
p_archive = subs.add_parser(
492+
"archive",
493+
help="Manually archive a skill (move to .archive/, excluded from prompt)",
494+
)
495+
p_archive.add_argument("skill", help="Skill name")
496+
p_archive.set_defaults(func=_cmd_archive)
497+
498+
p_prune = subs.add_parser(
499+
"prune",
500+
help="Bulk-archive agent-created skills idle for >= N days (default 90)",
501+
)
502+
p_prune.add_argument(
503+
"--days", type=int, default=90,
504+
help="Archive skills idle for at least N days (default: 90)",
505+
)
506+
p_prune.add_argument(
507+
"-y", "--yes", action="store_true",
508+
help="Skip the confirmation prompt",
509+
)
510+
p_prune.add_argument(
511+
"--dry-run", dest="dry_run", action="store_true",
512+
help="Show what would be archived without doing it",
513+
)
514+
p_prune.set_defaults(func=_cmd_prune)
515+
386516
p_backup = subs.add_parser(
387517
"backup",
388518
help="Take a manual tar.gz snapshot of ~/.hermes/skills/ "

scripts/release.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@
268268
"hakanerten02@hotmail.com": "teyrebaz33",
269269
"linux2010@users.noreply.github.com": "Linux2010",
270270
"elmatadorgh@users.noreply.github.com": "elmatadorgh",
271+
"coktinbaran5@gmail.com": "elmatadorgh",
271272
"alexazzjjtt@163.com": "alexzhu0",
272273
"1180176+Swift42@users.noreply.github.com": "Swift42",
273274
"ruzzgarcn@gmail.com": "Ruzzgar",

0 commit comments

Comments
 (0)