feat(curator): show rename map in user-visible summary (#22910)

* 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.
This commit is contained in:
Teknium 2026-05-09 18:43:40 -07:00 committed by GitHub
parent b67ea7ff47
commit 4375b82cd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 499 additions and 1 deletions

View file

@ -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)"

View file

@ -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: