From 4375b82cd9b6173a58456db600aa1f4ead21af6f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 9 May 2026 18:43:40 -0700 Subject: [PATCH] feat(curator): show rename map in user-visible summary (#22910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(curator): show rename map (where skills went) in user-visible summary The full data has always been on disk in REPORT.md, but the user-visible curator summary (gateway 💾 line, CLI session-start panel, `hermes curator status`) was counts-only — "consolidated 4 into 2 umbrellas" with no names. Users only discovered renames when something they expected was gone. New `_build_rename_summary()` formats the rename map and appends it to `final_summary`: auto: 1 marked stale; llm: consolidated 2 into 1, pruned 1 archived 3 skill(s): • docx-extraction → document-tools • pdf-extraction → document-tools • old-stale-thing — pruned (stale) full report: hermes curator status Empty on no-op ticks (no archives), so most ticks add zero log noise. Cap of 10 entries keeps agent.log readable when a 50-skill consolidation lands; the full list is always in REPORT.md. `hermes curator status` indents continuation lines so the multi-line summary reads as one logical field. 5 new tests in tests/agent/test_curator_classification.py covering empty / consolidation / pruning / cap / mixed cases. * feat(curator): show recent run summary once on `hermes update` The rename map is now visible from where users actually look — the update flow they explicitly run, instead of just the live gateway log or transient CLI session-start panel. Behavior: - After `hermes update`, if the most recent curator run produced a rename map (multi-line summary) that the user hasn't seen yet, print it once with a 'last run Xh ago' header and a one-time-message footer. - Stamp `last_run_summary_shown_at = last_run_at` after printing so subsequent `hermes update` invocations are silent until a newer curator run lands. - Silent on no-op runs (single-line summary like 'auto: no changes; llm: no change'). Still stamps shown so we don't reconsider on every update. - Silent when the curator has never run (the existing first-run notice handles that case). Output: ℹ Skill curator — last run 4h ago auto: 1 marked stale; llm: consolidated 2 into 1, pruned 1 archived 3 skill(s): • docx-extraction → document-tools • pdf-extraction → document-tools • old-stale-thing — pruned (stale) full report: hermes curator status (This message shows once per curator run. View anytime: hermes curator status) State migration: - `_default_state()` gains `last_run_summary_shown_at: None`. Existing state files lack the field; `.get()` returns None; the comparison treats any prior run as 'not yet shown' and prints once on next update. Self-healing. Wiring: - Both `hermes update` paths in main.py call the new `_print_curator_recent_run_notice()` right after the existing first-run notice. Best-effort try/except so a state-load bug never breaks the update flow. 6 tests in tests/hermes_cli/test_curator_recent_run_notice.py: no-run / single-line / multi-line / show-once / new-run-resets / time-formatter buckets. --- agent/curator.py | 93 ++++++++++ hermes_cli/curator.py | 11 +- hermes_cli/main.py | 100 +++++++++++ tests/agent/test_curator_classification.py | 134 +++++++++++++++ .../test_curator_recent_run_notice.py | 162 ++++++++++++++++++ 5 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_curator_recent_run_notice.py diff --git a/agent/curator.py b/agent/curator.py index 3626f5d2345..f9c10d05656 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -72,6 +72,7 @@ def _default_state() -> Dict[str, Any]: "last_run_at": None, "last_run_duration_seconds": None, "last_run_summary": None, + "last_run_summary_shown_at": None, "last_report_path": None, "paused": False, "run_count": 0, @@ -876,6 +877,82 @@ def _reconcile_classification( return {"consolidated": consolidated, "pruned": pruned} +def _build_rename_summary( + *, + before_names: Set[str], + after_report: List[Dict[str, Any]], + tool_calls: List[Dict[str, Any]], + model_final: str, +) -> str: + """Format the user-visible rename map for a curator run. + + Renders the "where did my skills go?" lines that get appended to the + `final_summary` string fed to gateway/CLI receivers. Empty string when + nothing was archived this run — most ticks are no-op and shouldn't add + extra log noise. + + Format:: + + archived 4 skill(s): + • pdf-extraction → document-tools + • docx-extraction → document-tools + • flaky-thing — pruned (stale) + • old-utility → spreadsheet-ops + full report: hermes curator status + + Cap is 10 entries so a 50-skill consolidation doesn't blow up + agent.log; the full list is always in REPORT.md. + """ + after_by_name = {r.get("name"): r for r in after_report if isinstance(r, dict)} + after_names = set(after_by_name.keys()) + removed = sorted(before_names - after_names) + added = sorted(after_names - before_names) + if not removed: + return "" + + heuristic = _classify_removed_skills( + removed=removed, + added=added, + after_names=after_names, + tool_calls=tool_calls, + ) + model_block = _parse_structured_summary(model_final) + destinations = set(after_names) | set(added) + absorbed_declarations = _extract_absorbed_into_declarations(tool_calls) + classification = _reconcile_classification( + removed=removed, + heuristic=heuristic, + model_block=model_block, + destinations=destinations, + absorbed_declarations=absorbed_declarations, + ) + consolidated = classification["consolidated"] + pruned = classification["pruned"] + + SHOW = 10 + lines: List[str] = [] + total = len(consolidated) + len(pruned) + lines.append(f"archived {total} skill(s):") + shown = 0 + for entry in consolidated: + if shown >= SHOW: + break + name = entry.get("name", "?") + into = entry.get("into", "?") + lines.append(f" • {name} → {into}") + shown += 1 + for entry in pruned: + if shown >= SHOW: + break + name = entry.get("name", "?") if isinstance(entry, dict) else str(entry) + lines.append(f" • {name} — pruned (stale)") + shown += 1 + if total > SHOW: + lines.append(f" … and {total - SHOW} more") + lines.append("full report: hermes curator status") + return "\n".join(lines) + + def _write_run_report( *, started_at: datetime, @@ -1398,6 +1475,22 @@ def run_curator_review( "error": str(e), } + # Append the rename map (`old-name → umbrella`) to the user-visible + # summary so people don't have to dig into REPORT.md to find out where + # their skills went. Best-effort: classification is pure but never + # block the run on a formatting issue. + try: + rename_lines = _build_rename_summary( + before_names=before_names, + after_report=skill_usage.agent_created_report(), + tool_calls=llm_meta.get("tool_calls", []) or [], + model_final=llm_meta.get("final", "") or "", + ) + if rename_lines: + final_summary = f"{final_summary}\n{rename_lines}" + except Exception as e: + logger.debug("Curator rename summary build failed: %s", e, exc_info=True) + elapsed = (datetime.now(timezone.utc) - start).total_seconds() state2 = load_state() state2["last_run_duration_seconds"] = elapsed diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index 318c4a09720..38675b93ab8 100644 --- a/hermes_cli/curator.py +++ b/hermes_cli/curator.py @@ -55,7 +55,16 @@ def _cmd_status(args) -> int: print(f"curator: {status_line}") print(f" runs: {runs}") print(f" last run: {_fmt_ts(last_run)}") - print(f" last summary: {summary}") + # Summary may be multi-line when the curator archived skills (the rename + # map gets appended as `name → umbrella` lines). Indent continuation + # lines so the block reads as one logical field. + if "\n" in summary: + first, *rest = summary.splitlines() + print(f" last summary: {first}") + for line in rest: + print(f" {line}") + else: + print(f" last summary: {summary}") _report = state.get("last_report_path") if _report: suffix = "" if Path(_report).exists() else " (missing)" diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 04540d395fd..c10043f825e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5744,6 +5744,92 @@ def _print_curator_first_run_notice() -> None: ) +def _print_curator_recent_run_notice() -> None: + """Print the most recent curator run summary, exactly once. + + The curator runs in the background (gateway tick + CLI session start), + so users learn about skill consolidations only by stumbling into a + rename. ``hermes update`` is a high-attention surface — surface the + most recent run's rename map here, once. + + Show-once: state stamps ``last_run_summary_shown_at`` after printing. + Subsequent ``hermes update`` invocations skip the block until a newer + curator run lands. Silent when the curator has never run, when the + most recent summary has already been shown, or when the summary has + no rename information to display (no archives). + """ + try: + from agent import curator + except Exception: + return + try: + state = curator.load_state() + except Exception: + return + + last_run_at = state.get("last_run_at") + if not last_run_at: + return # no curator run yet — first-run notice handles this case + + if state.get("last_run_summary_shown_at") == last_run_at: + return # already shown for this run + + summary = state.get("last_run_summary") or "" + if not summary: + return + + # Only print when there's something interesting to show — i.e. the + # rename map block was appended (multi-line summary). A bare "auto: + # no changes; llm: no change" doesn't warrant interrupting the + # update flow. + if "\n" not in summary: + # Still stamp it shown so we don't reconsider it on every update. + try: + state["last_run_summary_shown_at"] = last_run_at + curator.save_state(state) + except Exception: + pass + return + + # Format the timestamp as "Xh ago" for readability. + when = _format_time_ago(last_run_at) + print() + print(f"ℹ Skill curator — last run {when}") + for line in summary.splitlines(): + print(f" {line}") + print( + " (This message shows once per curator run. " + "View anytime: hermes curator status)" + ) + + # Stamp shown so we don't repeat on the next update. + try: + state["last_run_summary_shown_at"] = last_run_at + curator.save_state(state) + except Exception: + pass + + +def _format_time_ago(iso_ts: str) -> str: + """Render an ISO timestamp as `Xh ago` / `Xd ago` / `Xm ago`. Best effort.""" + try: + from datetime import datetime, timezone + ts = datetime.fromisoformat(iso_ts.replace("Z", "+00:00")) + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + delta = datetime.now(timezone.utc) - ts + secs = int(delta.total_seconds()) + if secs < 60: + return "just now" + if secs < 3600: + return f"{secs // 60}m ago" + if secs < 86400: + return f"{secs // 3600}h ago" + return f"{secs // 86400}d ago" + except Exception: + return "recently" + + def _kill_stale_dashboard_processes( reason: str = "the running backend no longer matches the updated frontend", ) -> None: @@ -5989,6 +6075,10 @@ def _update_via_zip(args): _print_curator_first_run_notice() except Exception as e: logger.debug("Curator first-run notice failed: %s", e) + try: + _print_curator_recent_run_notice() + except Exception as e: + logger.debug("Curator recent-run notice failed: %s", e) _kill_stale_dashboard_processes() @@ -7602,6 +7692,16 @@ def _cmd_update_impl(args, gateway_mode: bool): except Exception as e: logger.debug("Curator first-run notice failed: %s", e) + # Most-recent curator run notice — show-once per run. Surfaces the + # rename map (`old-name → umbrella`) on the high-attention update + # surface so users learn about consolidations without having to + # check `hermes curator status`. Self-stamps after printing so it + # never repeats for the same run. + try: + _print_curator_recent_run_notice() + except Exception as e: + logger.debug("Curator recent-run notice failed: %s", e) + # Repair RHEL-family root installs where /usr/local/bin isn't on PATH # for non-login interactive shells. No-op on every other platform. try: diff --git a/tests/agent/test_curator_classification.py b/tests/agent/test_curator_classification.py index 625776f5373..29187c5a641 100644 --- a/tests/agent/test_curator_classification.py +++ b/tests/agent/test_curator_classification.py @@ -886,3 +886,137 @@ def test_reconcile_mixed_declarations_and_legacy_calls(curator_env): assert "legacy-prune" in pruned_by_name assert "no-evidence fallback" in pruned_by_name["legacy-prune"]["source"] + + +# --------------------------------------------------------------------------- +# _build_rename_summary — surfaces the "where did my skills go?" map to the +# user-visible curator summary (gateway 💾 line, CLI Rich panel, +# `hermes curator status`). The full data has always been in REPORT.md on +# disk; this helper makes it visible without digging. +# --------------------------------------------------------------------------- + + +def test_rename_summary_empty_when_nothing_archived(curator_env): + """No removals = empty string (no log noise on no-op ticks).""" + result = curator_env._build_rename_summary( + before_names={"alpha", "beta"}, + after_report=[ + {"name": "alpha", "state": "active"}, + {"name": "beta", "state": "active"}, + ], + tool_calls=[], + model_final="", + ) + assert result == "" + + +def test_rename_summary_consolidation_shows_target(curator_env): + """Consolidated skills render as `name → umbrella` with the actual target.""" + result = curator_env._build_rename_summary( + before_names={"pdf-extraction", "docx-extraction", "document-tools"}, + after_report=[{"name": "document-tools", "state": "active"}], + tool_calls=[ + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "pdf-extraction", + "absorbed_into": "document-tools", + }), + }, + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "docx-extraction", + "absorbed_into": "document-tools", + }), + }, + ], + model_final="", + ) + assert "archived 2 skill(s):" in result + assert "pdf-extraction → document-tools" in result + assert "docx-extraction → document-tools" in result + assert "full report: hermes curator status" in result + + +def test_rename_summary_pruned_marked_explicitly(curator_env): + """Pruned skills (no umbrella) say `pruned (stale)` so users don't think they were merged.""" + result = curator_env._build_rename_summary( + before_names={"old-flaky-thing", "keeper"}, + after_report=[{"name": "keeper", "state": "active"}], + tool_calls=[ + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "old-flaky-thing", + "absorbed_into": "", + }), + }, + ], + model_final="", + ) + assert "old-flaky-thing — pruned (stale)" in result + assert "→" not in result.split("old-flaky-thing")[1].splitlines()[0] + + +def test_rename_summary_caps_at_ten_with_more_indicator(curator_env): + """Large consolidations don't blow up the log line — cap + `… and N more`.""" + removed = [f"skill-{i}" for i in range(15)] + tool_calls = [ + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": name, + "absorbed_into": "umbrella", + }), + } + for name in removed + ] + result = curator_env._build_rename_summary( + before_names=set(removed) | {"umbrella"}, + after_report=[{"name": "umbrella", "state": "active"}], + tool_calls=tool_calls, + model_final="", + ) + assert "archived 15 skill(s):" in result + assert "… and 5 more" in result + # Exactly 10 bullets shown + bullet_count = sum(1 for ln in result.splitlines() if ln.startswith(" • ")) + assert bullet_count == 10 + + +def test_rename_summary_mixed_consolidation_and_pruning(curator_env): + """Consolidated entries come first, pruned entries follow — matches REPORT.md ordering.""" + result = curator_env._build_rename_summary( + before_names={"merge-me", "drop-me", "umbrella"}, + after_report=[{"name": "umbrella", "state": "active"}], + tool_calls=[ + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "merge-me", + "absorbed_into": "umbrella", + }), + }, + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "drop-me", + "absorbed_into": "", + }), + }, + ], + model_final="", + ) + lines = result.splitlines() + merge_idx = next(i for i, ln in enumerate(lines) if "merge-me" in ln) + drop_idx = next(i for i, ln in enumerate(lines) if "drop-me" in ln) + assert merge_idx < drop_idx, "consolidated should render before pruned" + assert "merge-me → umbrella" in lines[merge_idx] + assert "drop-me — pruned (stale)" in lines[drop_idx] diff --git a/tests/hermes_cli/test_curator_recent_run_notice.py b/tests/hermes_cli/test_curator_recent_run_notice.py new file mode 100644 index 00000000000..4f7b06199a8 --- /dev/null +++ b/tests/hermes_cli/test_curator_recent_run_notice.py @@ -0,0 +1,162 @@ +"""Tests for `_print_curator_recent_run_notice`. + +The notice prints the most recent curator run summary on `hermes update`, +exactly once per run. Show-once is enforced by stamping +`last_run_summary_shown_at` in curator state after printing. + +Why this matters: the curator runs in the background (gateway tick + CLI +session start) so users normally never see the rename map. `hermes update` +is the high-attention surface where consolidations should land. +""" + +from __future__ import annotations + +import importlib +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + + +@pytest.fixture +def curator_env(tmp_path, monkeypatch, capsys): + home = tmp_path / ".hermes" + home.mkdir() + (home / "skills").mkdir() + (home / "logs").mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + import hermes_constants + importlib.reload(hermes_constants) + from agent import curator + importlib.reload(curator) + from hermes_cli import main as hermes_main + importlib.reload(hermes_main) + + yield { + "curator": curator, + "main": hermes_main, + "capsys": capsys, + } + + +def _set_state(curator_mod, **fields): + state = curator_mod.load_state() + state.update(fields) + curator_mod.save_state(state) + + +def test_silent_when_no_curator_run_yet(curator_env): + """First-run notice handles this case; recent-run notice stays silent.""" + curator_env["main"]._print_curator_recent_run_notice() + out = curator_env["capsys"].readouterr().out + assert "Skill curator — last run" not in out + + +def test_silent_when_summary_is_single_line(curator_env): + """No archives = no rename map = nothing to surface. But still stamps shown.""" + now = datetime.now(timezone.utc).isoformat() + _set_state( + curator_env["curator"], + last_run_at=now, + last_run_summary="auto: no changes; llm: no change", + ) + curator_env["main"]._print_curator_recent_run_notice() + out = curator_env["capsys"].readouterr().out + assert "Skill curator — last run" not in out + # Should still mark shown so we don't reconsider on every update. + state = curator_env["curator"].load_state() + assert state["last_run_summary_shown_at"] == now + + +def test_prints_multiline_summary_with_rename_map(curator_env): + """Multi-line summary (rename map appended) prints with timestamp + footer.""" + now = datetime.now(timezone.utc).isoformat() + summary = ( + "auto: 1 marked stale; llm: consolidated 2 into 1\n" + "archived 2 skill(s):\n" + " • pdf-extraction → document-tools\n" + " • docx-extraction → document-tools\n" + "full report: hermes curator status" + ) + _set_state( + curator_env["curator"], + last_run_at=now, + last_run_summary=summary, + ) + curator_env["main"]._print_curator_recent_run_notice() + out = curator_env["capsys"].readouterr().out + assert "Skill curator — last run" in out + assert "pdf-extraction → document-tools" in out + assert "docx-extraction → document-tools" in out + assert "shows once per curator run" in out + + +def test_show_once_semantics(curator_env): + """Calling twice prints once; second call is silent until a new run lands.""" + now = datetime.now(timezone.utc).isoformat() + summary = ( + "auto: no changes; llm: consolidated 1 into 1\n" + "archived 1 skill(s):\n" + " • old → new\n" + "full report: hermes curator status" + ) + _set_state( + curator_env["curator"], + last_run_at=now, + last_run_summary=summary, + ) + + curator_env["main"]._print_curator_recent_run_notice() + first = curator_env["capsys"].readouterr().out + assert "old → new" in first + + curator_env["main"]._print_curator_recent_run_notice() + second = curator_env["capsys"].readouterr().out + assert second == "", "second call must be silent (already shown)" + + +def test_new_run_resets_show_once(curator_env): + """A newer curator run with rename data prints again, even though one was already shown.""" + older = (datetime.now(timezone.utc) - timedelta(hours=8)).isoformat() + _set_state( + curator_env["curator"], + last_run_at=older, + last_run_summary=( + "auto: no changes; llm: consolidated 1 into 1\n" + "archived 1 skill(s):\n" + " • thing-a → umbrella\n" + "full report: hermes curator status" + ), + ) + curator_env["main"]._print_curator_recent_run_notice() + curator_env["capsys"].readouterr() # drain + + # New run lands. + newer = datetime.now(timezone.utc).isoformat() + _set_state( + curator_env["curator"], + last_run_at=newer, + last_run_summary=( + "auto: no changes; llm: consolidated 1 into 1\n" + "archived 1 skill(s):\n" + " • thing-b → umbrella\n" + "full report: hermes curator status" + ), + ) + curator_env["main"]._print_curator_recent_run_notice() + out = curator_env["capsys"].readouterr().out + assert "thing-b → umbrella" in out + assert "thing-a" not in out # only the newer run shows + + +def test_format_time_ago_buckets(curator_env): + """Smoke test the time formatter — drives the `last run Xh ago` line.""" + fmt = curator_env["main"]._format_time_ago + now = datetime.now(timezone.utc) + assert fmt((now - timedelta(seconds=10)).isoformat()) == "just now" + assert fmt((now - timedelta(minutes=5)).isoformat()) == "5m ago" + assert fmt((now - timedelta(hours=3)).isoformat()) == "3h ago" + assert fmt((now - timedelta(days=2)).isoformat()) == "2d ago" + assert fmt("not-a-real-iso-string") == "recently"