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