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

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

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:

View file

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

View file

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