From 1d9945cb0b1f7793826ebb480808f8c7a4035d6a Mon Sep 17 00:00:00 2001 From: rlaope Date: Sat, 4 Jul 2026 10:45:45 +0900 Subject: [PATCH 1/4] feat(achievements): add export endpoint and agent summary (#18472) Implement the export & agent-communication workstream from the achievements upgrades spec (issue #18472, spec PR #18151): - GET /export?format=json|markdown|svg&state=... renders the current snapshot as machine-readable JSON, a README-pasteable Markdown badge table (shields.io badges + progress bars, unlocked-only by default), or an SVG badge sheet with tier-colored markers. Unknown formats return a 400 JSON error. - GET /achievements/summary returns the compact agent profile (strengths, gaps, top tier, unlocked ids, session/tool totals). - agent_summary.json is written next to state.json after every finished scan (best-effort) and removed on /reset-state, so agents and external tools can read the profile without the dashboard running. - filter_and_sort_achievements() is the minimal state-filter seam that the filtering/sorting workstream in #18472 can extend with category, sort, and limit parameters. SVG output escapes badge names/tiers to keep markup out of rendered sheets. The agent summary path uses get_hermes_home() rather than a hardcoded ~/.hermes so Windows-aware homes keep working. FastAPI response classes are stubbed in the no-FastAPI fallback so the plugin unit tests keep running without dashboard dependencies. Tested: python3 -m unittest discover -s tests (21 tests: 13 new for filtering, JSON/Markdown/SVG formatters, escaping, agent summary builder, and agent_summary.json persistence; 8 existing engine tests unchanged), plus an async endpoint smoke over stubbed snapshot data. --- plugins/hermes-achievements/README.md | 12 + .../dashboard/plugin_api.py | 211 ++++++++++++++++++ .../tests/test_export_and_summary.py | 182 +++++++++++++++ 3 files changed, 405 insertions(+) create mode 100644 plugins/hermes-achievements/tests/test_export_and_summary.py diff --git a/plugins/hermes-achievements/README.md b/plugins/hermes-achievements/README.md index 2c1ed638281bf..0838f528cb27f 100644 --- a/plugins/hermes-achievements/README.md +++ b/plugins/hermes-achievements/README.md @@ -128,6 +128,8 @@ Endpoints: ```text GET /achievements +GET /achievements/summary +GET /export?format=json|markdown|svg&state=unlocked|discovered|secret GET /scan-status GET /recent-unlocks GET /sessions/{session_id}/badges @@ -135,6 +137,16 @@ POST /rescan POST /reset-state ``` +`GET /achievements/summary` returns a compact profile (strengths, gaps, +top tier, unlocked ids) for agent context injection. The same payload is +written to `agent_summary.json` next to `state.json` after every finished +scan, so agents and external tools can read it without the dashboard +running. + +`GET /export` renders the current snapshot as machine-readable JSON, a +Markdown badge table (README-pasteable, unlocked badges by default), or +an SVG badge sheet. + ## Development Run checks: diff --git a/plugins/hermes-achievements/dashboard/plugin_api.py b/plugins/hermes-achievements/dashboard/plugin_api.py index b419efc6c27ff..f385c7371be17 100644 --- a/plugins/hermes-achievements/dashboard/plugin_api.py +++ b/plugins/hermes-achievements/dashboard/plugin_api.py @@ -9,8 +9,10 @@ import re import threading import time +from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Set +from xml.sax.saxutils import escape as xml_escape try: from hermes_constants import get_hermes_home @@ -22,6 +24,7 @@ def get_hermes_home() -> Path: # type: ignore[misc] try: from fastapi import APIRouter + from fastapi.responses import JSONResponse, PlainTextResponse except Exception: # Allows local unit tests without dashboard dependencies. class APIRouter: # type: ignore def get(self, *_args, **_kwargs): @@ -29,6 +32,15 @@ def get(self, *_args, **_kwargs): def post(self, *_args, **_kwargs): return lambda fn: fn + class JSONResponse: # type: ignore + def __init__(self, content: Any, **_kwargs: Any) -> None: + self.content = content + + class PlainTextResponse: # type: ignore + def __init__(self, content: Any, **kwargs: Any) -> None: + self.content = content + self.media_type = kwargs.get("media_type") + router = APIRouter() SNAPSHOT_TTL_SECONDS = 120 @@ -51,6 +63,7 @@ def post(self, *_args, **_kwargs): FILE_RE = re.compile(r"(?:/home/|~/?|\./|/mnt/)[\w./-]+\.(?:py|js|ts|tsx|jsx|css|html|md|json|yaml|yml|svg|sql|sh)") TIER_NAMES = ["Copper", "Silver", "Gold", "Diamond", "Olympian"] +TIER_ORDER = {name: index for index, name in enumerate(TIER_NAMES, start=1)} def tiers(values: List[int]) -> List[Dict[str, Any]]: @@ -154,6 +167,10 @@ def checkpoint_path() -> Path: return get_hermes_home() / "plugins" / "hermes-achievements" / "scan_checkpoint.json" +def agent_summary_path() -> Path: + return get_hermes_home() / "plugins" / "hermes-achievements" / "agent_summary.json" + + def load_state() -> Dict[str, Any]: path = state_path() if not path.exists(): @@ -856,6 +873,173 @@ def _build_pending_snapshot(now: int) -> Dict[str, Any]: } +def filter_and_sort_achievements( + items: List[Dict[str, Any]], + state: Optional[str] = None, +) -> List[Dict[str, Any]]: + """Filter achievements by badge state. + + Minimal seam for the filtering/sorting workstream in #18472: the export + formatters below only need state filtering today, and the full + category/sort/limit query support can extend this same function. + """ + selected = list(items) + if state: + wanted = str(state).strip().lower() + selected = [item for item in selected if str(item.get("state", "")).lower() == wanted] + return selected + + +def export_json(data: Dict[str, Any], state: Optional[str] = None) -> str: + """Export achievements as structured JSON.""" + items = filter_and_sort_achievements(data.get("achievements", []), state=state) + export = { + "generated_at": data.get("generated_at"), + "unlocked_count": data.get("unlocked_count", 0), + "total_count": data.get("total_count", 0), + "achievements": items, + } + return json.dumps(export, indent=2, default=str) + + +def _scan_date(generated_at: Any) -> str: + try: + return datetime.fromtimestamp(int(generated_at), tz=timezone.utc).strftime("%Y-%m-%d") + except (TypeError, ValueError, OverflowError, OSError): + return "unknown" + + +def export_markdown(data: Dict[str, Any], state: Optional[str] = None) -> str: + """Export achievements as markdown with progress bars and shields.io badges.""" + items = filter_and_sort_achievements(data.get("achievements", []), state=state or "unlocked") + unlocked = data.get("unlocked_count", 0) + total = data.get("total_count", 0) + + lines = [ + "# Hermes Achievements", + "", + f"**{unlocked}/{total} unlocked** | Last scanned: {_scan_date(data.get('generated_at'))}", + "", + ] + + categories: Dict[str, List[Dict[str, Any]]] = {} + for item in items: + categories.setdefault(str(item.get("category", "Other")), []).append(item) + + tier_colors = { + "Copper": "CD7F32", "Silver": "C0C0C0", "Gold": "FFD700", + "Diamond": "B9F2FF", "Olympian": "FF00FF", + } + + for cat in sorted(categories): + lines.append(f"## {cat}") + lines.append("") + lines.append("| Achievement | Tier | Progress |") + lines.append("|---|---|---|") + for item in categories[cat]: + name = str(item.get("name", "???")).replace("|", "\\|") + tier = str(item.get("tier") or "-") + pct = int(item.get("progress_pct", 0) or 0) + color = tier_colors.get(tier, "gray") + badge = f"![{tier}](https://img.shields.io/badge/{tier}-{pct}%25-{color})" + bar_filled = max(0, min(10, pct // 10)) + bar = "█" * bar_filled + "░" * (10 - bar_filled) + f" {pct}%" + lines.append(f"| {name} | {badge} | {bar} |") + lines.append("") + + return "\n".join(lines) + + +def export_svg(data: Dict[str, Any], state: Optional[str] = None) -> str: + """Export achievements as an SVG badge sheet (unlocked badges by default).""" + items = filter_and_sort_achievements(data.get("achievements", []), state=state or "unlocked") + + tier_colors = { + "Copper": "#B87333", "Silver": "#C0C0C0", "Gold": "#FFD700", + "Diamond": "#B9F2FF", "Olympian": "#FF00FF", + } + + badge_w, badge_h, pad = 280, 28, 8 + height = len(items) * (badge_h + pad) + pad + + svg_parts = [ + f'', + '', + ] + + for i, item in enumerate(items): + y = i * (badge_h + pad) + pad + name = xml_escape(str(item.get("name", "???"))[:24]) + tier = str(item.get("tier") or "-") + color = tier_colors.get(tier, "#666") + svg_parts.append( + f'' + f'' + f'{name}' + f'{xml_escape(tier)}' + ) + + svg_parts.append("") + return "\n".join(svg_parts) + + +def _build_agent_summary(data: Dict[str, Any]) -> Dict[str, Any]: + """Build the compact agent-consumable profile from evaluated data. + + Small enough to inject as context without eating token budget: + strengths are the categories with the most unlocks, gaps are the + locked categories closest to their next unlock. + """ + items = data.get("achievements", []) + aggregate = data.get("aggregate", {}) + + cat_unlocks: Dict[str, int] = {} + for item in items: + if item.get("unlocked"): + cat = str(item.get("category", "Other")) + cat_unlocks[cat] = cat_unlocks.get(cat, 0) + 1 + strengths = sorted(cat_unlocks, key=lambda cat: (-cat_unlocks[cat], cat))[:5] + + cat_progress: Dict[str, float] = {} + for item in items: + if not item.get("unlocked"): + cat = str(item.get("category", "Other")) + cat_progress[cat] = max(cat_progress.get(cat, 0), float(item.get("progress_pct", 0) or 0)) + gaps = sorted(cat_progress, key=lambda cat: (-cat_progress[cat], cat))[:3] + + top_tier = None + for item in items: + tier = item.get("tier") + if item.get("unlocked") and tier: + if not top_tier or TIER_ORDER.get(tier, 0) > TIER_ORDER.get(top_tier, 0): + top_tier = tier + + return { + "total_sessions": aggregate.get("session_count", 0), + "total_tool_calls": aggregate.get("total_tool_calls", 0), + "unlocked_count": data.get("unlocked_count", 0), + "total_count": data.get("total_count", 0), + "top_categories": strengths, + "top_tier": top_tier, + "strengths": strengths, + "gaps": gaps, + "unlocked_ids": [a["id"] for a in items if a.get("unlocked")], + } + + +def _write_agent_summary(data: Dict[str, Any]) -> None: + """Persist agent_summary.json for context injection. Best-effort.""" + try: + path = agent_summary_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(_build_agent_summary(data), indent=2, default=str)) + except Exception: + pass # Non-critical: the summary is a best-effort context artifact. + + def _run_scan_and_update_cache(publish_partial_snapshots: bool = True) -> None: """Execute a scan + snapshot update. Called synchronously or from a thread. @@ -907,6 +1091,7 @@ def _publish_partial(partial_sessions, scanned_so_far, total): _SNAPSHOT_CACHE = _json_safe(computed) _SNAPSHOT_CACHE_AT = int(_SNAPSHOT_CACHE.get("generated_at") or int(time.time())) save_snapshot(_SNAPSHOT_CACHE) + _write_agent_summary(_SNAPSHOT_CACHE) _SCAN_STATUS["state"] = "idle" except Exception as exc: _SCAN_STATUS["state"] = "failed" @@ -1008,6 +1193,28 @@ async def achievements(): return payload +@router.get("/achievements/summary") +async def achievements_summary(): + """Compact achievement profile for agent context injection. + + Mirrors the agent_summary.json artifact written on each finished scan. + """ + return _build_agent_summary(evaluate_all()) + + +@router.get("/export") +async def export_achievements(format: str = "json", state: Optional[str] = None): + data = evaluate_all() + fmt = (format or "json").strip().lower() + if fmt == "markdown": + return PlainTextResponse(export_markdown(data, state=state), media_type="text/markdown") + if fmt == "svg": + return PlainTextResponse(export_svg(data, state=state), media_type="image/svg+xml") + if fmt != "json": + return JSONResponse({"error": f"unsupported format: {format}", "supported": ["json", "markdown", "svg"]}, status_code=400) + return JSONResponse(json.loads(export_json(data, state=state))) + + @router.get("/scan-status") async def scan_status(): return _scan_status_payload() @@ -1058,4 +1265,8 @@ async def reset_state(): checkpoint_path().unlink(missing_ok=True) except Exception: pass + try: + agent_summary_path().unlink(missing_ok=True) + except Exception: + pass return {"ok": True} diff --git a/plugins/hermes-achievements/tests/test_export_and_summary.py b/plugins/hermes-achievements/tests/test_export_and_summary.py new file mode 100644 index 0000000000000..1cff6bbbe1348 --- /dev/null +++ b/plugins/hermes-achievements/tests/test_export_and_summary.py @@ -0,0 +1,182 @@ +import importlib.util +import json +import os +import tempfile +import unittest +from pathlib import Path + +MODULE_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "plugin_api.py" +spec = importlib.util.spec_from_file_location("plugin_api", MODULE_PATH) +plugin_api = importlib.util.module_from_spec(spec) +spec.loader.exec_module(plugin_api) + + +def sample_data(): + return { + "generated_at": 1751328000, # 2025-07-01 UTC + "unlocked_count": 2, + "total_count": 4, + "aggregate": {"session_count": 12, "total_tool_calls": 340}, + "achievements": [ + { + "id": "red_text", + "name": "Red Text Connoisseur", + "category": "Debugging", + "state": "unlocked", + "unlocked": True, + "tier": "Gold", + "progress_pct": 100, + "unlocked_at": 1751200000, + }, + { + "id": "let_him_cook", + "name": "Let Him Cook", + "category": "Toolchain", + "state": "discovered", + "unlocked": False, + "tier": None, + "progress_pct": 40, + }, + { + "id": "night_owl", + "name": "Night & Friends", + "category": "Lifestyle", + "state": "unlocked", + "unlocked": True, + "tier": "Copper", + "progress_pct": 100, + "unlocked_at": 1751100000, + }, + { + "id": "model_hopper", + "name": "Model Hopper", + "category": "Models", + "state": "secret", + "unlocked": False, + "tier": None, + "progress_pct": 0, + }, + ], + } + + +class FilterTests(unittest.TestCase): + def test_filter_by_state(self): + items = sample_data()["achievements"] + + unlocked = plugin_api.filter_and_sort_achievements(items, state="unlocked") + self.assertEqual([item["id"] for item in unlocked], ["red_text", "night_owl"]) + + self.assertEqual(len(plugin_api.filter_and_sort_achievements(items)), 4) + self.assertEqual(plugin_api.filter_and_sort_achievements(items, state="nope"), []) + + +class ExportJsonTests(unittest.TestCase): + def test_export_json_structure_and_state_filter(self): + payload = json.loads(plugin_api.export_json(sample_data(), state="unlocked")) + + self.assertEqual(payload["unlocked_count"], 2) + self.assertEqual(payload["total_count"], 4) + self.assertEqual(payload["generated_at"], 1751328000) + self.assertEqual([item["id"] for item in payload["achievements"]], ["red_text", "night_owl"]) + + def test_export_json_defaults_to_all_states(self): + payload = json.loads(plugin_api.export_json(sample_data())) + + self.assertEqual(len(payload["achievements"]), 4) + + +class ExportMarkdownTests(unittest.TestCase): + def test_markdown_has_header_categories_and_badges(self): + content = plugin_api.export_markdown(sample_data()) + + self.assertIn("# Hermes Achievements", content) + self.assertIn("**2/4 unlocked** | Last scanned: 2025-07-01", content) + # Defaults to unlocked-only. + self.assertIn("## Debugging", content) + self.assertIn("## Lifestyle", content) + self.assertNotIn("## Toolchain", content) + self.assertIn("| Achievement | Tier | Progress |", content) + self.assertIn("img.shields.io/badge/Gold-100%25-FFD700", content) + self.assertIn("██████████ 100%", content) + + def test_markdown_state_override_and_bad_timestamp(self): + data = sample_data() + data["generated_at"] = "not-a-timestamp" + + content = plugin_api.export_markdown(data, state="discovered") + + self.assertIn("Last scanned: unknown", content) + self.assertIn("## Toolchain", content) + self.assertIn("████░░░░░░ 40%", content) + + +class ExportSvgTests(unittest.TestCase): + def test_svg_renders_one_row_per_unlocked_badge(self): + content = plugin_api.export_svg(sample_data()) + + self.assertTrue(content.startswith("")) + self.assertEqual(content.count('", content) + self.assertIn("Night <Owl> & Frie", content) + + def test_svg_with_no_matching_badges_is_valid(self): + content = plugin_api.export_svg(sample_data(), state="nope") + + self.assertTrue(content.startswith(" Date: Thu, 16 Jul 2026 10:12:37 +0900 Subject: [PATCH 2/4] docs: document achievements export and agent summary Signed-off-by: rlaope <105429536+rlaope@users.noreply.github.com> --- website/docs/user-guide/features/built-in-plugins.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/website/docs/user-guide/features/built-in-plugins.md b/website/docs/user-guide/features/built-in-plugins.md index b4c1d5ef08047..de65c036eff09 100644 --- a/website/docs/user-guide/features/built-in-plugins.md +++ b/website/docs/user-guide/features/built-in-plugins.md @@ -259,6 +259,8 @@ Adds a **Steam-style achievements tab to the dashboard** — 60+ collectible, ti | Endpoint | Purpose | |---|---| | `GET /achievements` | Full catalog with per-badge unlock state (returns a pending placeholder while the first cold scan is running) | +| `GET /achievements/summary` | Compact achievement profile for agent context injection: strengths, gaps, top tier, and unlocked IDs | +| `GET /export?format=json\|markdown\|svg&state=unlocked\|discovered\|secret` | Export the current snapshot as JSON, a Markdown badge table, or an SVG badge sheet; defaults to JSON and all states | | `GET /scan-status` | State of the background scanner: `idle` / `running` / `failed`, last duration, run count | | `GET /recent-unlocks` | Twenty most recently unlocked badges, newest first | | `GET /sessions/{id}/badges` | Badges earned primarily in one specific session | @@ -272,6 +274,7 @@ Adds a **Steam-style achievements tab to the dashboard** — 60+ collectible, ti | `state.json` | Unlock history: which badges you've earned and when. Stable across Hermes updates. | | `scan_snapshot.json` | Last completed scan payload (served immediately on dashboard load) | | `scan_checkpoint.json` | Per-session stats cache keyed by fingerprint (makes warm rescans fast) | +| `agent_summary.json` | Compact profile written after each completed scan for agent and external-tool context without the dashboard running. | **Performance notes:** From da4080c5bebcc6c611f901d635ab657cb5530729 Mon Sep 17 00:00:00 2001 From: rlaope <105429536+rlaope@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:44:52 +0900 Subject: [PATCH 3/4] test(achievements): cover export and summary endpoints at the route level The existing tests exercised the rendering helpers and persistence only, so the route contracts were uncovered: the 400 response for an unsupported format and the text/markdown and image/svg+xml media types could regress silently. Drive export_achievements() and achievements_summary() directly with evaluate_all() stubbed, asserting status codes, media types, and payloads for JSON, Markdown, SVG, summary, and an unsupported format. --- .../tests/test_export_and_summary.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/plugins/hermes-achievements/tests/test_export_and_summary.py b/plugins/hermes-achievements/tests/test_export_and_summary.py index 1cff6bbbe1348..6ab2d1d895a95 100644 --- a/plugins/hermes-achievements/tests/test_export_and_summary.py +++ b/plugins/hermes-achievements/tests/test_export_and_summary.py @@ -1,3 +1,4 @@ +import asyncio import importlib.util import json import os @@ -178,5 +179,64 @@ def test_write_agent_summary_persists_json(self): os.environ["HERMES_HOME"] = previous +class ExportEndpointTests(unittest.TestCase): + """Route-level contract for /export and /achievements/summary. + + The rendering helpers are covered above; these drive the endpoint + functions themselves so the status codes and response media types stay + pinned. ``evaluate_all`` is stubbed so no scan or state file is touched. + """ + + def setUp(self): + self._original_evaluate_all = plugin_api.evaluate_all + plugin_api.evaluate_all = sample_data + self.addCleanup(self._restore_evaluate_all) + + def _restore_evaluate_all(self): + plugin_api.evaluate_all = self._original_evaluate_all + + def test_export_json_returns_ok_with_export_payload(self): + resp = asyncio.run(plugin_api.export_achievements(format="json")) + + self.assertEqual(resp.status_code, 200) + payload = json.loads(resp.body) + self.assertEqual(payload["unlocked_count"], 2) + self.assertEqual(payload["total_count"], 4) + + def test_export_markdown_uses_markdown_media_type(self): + resp = asyncio.run(plugin_api.export_achievements(format="markdown")) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.media_type, "text/markdown") + self.assertIn("Red Text Connoisseur", resp.body.decode("utf-8")) + + def test_export_svg_uses_svg_media_type(self): + resp = asyncio.run(plugin_api.export_achievements(format="svg")) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.media_type, "image/svg+xml") + self.assertIn(" Date: Tue, 4 Aug 2026 08:40:59 +0900 Subject: [PATCH 4/4] fix(achievements): pass encoding to agent-summary read/write for Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare Path.read_text()/write_text() fall back to locale.getpreferredencoding() — cp1252/cp936 on Windows — so the UTF-8 agent_summary.json artifact writes mojibake or raises UnicodeDecodeError there. Pin encoding="utf-8" on the persist path and its test read, per the #71014 read_text campaign. --- plugins/hermes-achievements/dashboard/plugin_api.py | 5 ++++- plugins/hermes-achievements/tests/test_export_and_summary.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/hermes-achievements/dashboard/plugin_api.py b/plugins/hermes-achievements/dashboard/plugin_api.py index df047108b7f24..1c11ee21f1db5 100644 --- a/plugins/hermes-achievements/dashboard/plugin_api.py +++ b/plugins/hermes-achievements/dashboard/plugin_api.py @@ -1035,7 +1035,10 @@ def _write_agent_summary(data: Dict[str, Any]) -> None: try: path = agent_summary_path() path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(_build_agent_summary(data), indent=2, default=str)) + path.write_text( + json.dumps(_build_agent_summary(data), indent=2, default=str), + encoding="utf-8", + ) except Exception: pass # Non-critical: the summary is a best-effort context artifact. diff --git a/plugins/hermes-achievements/tests/test_export_and_summary.py b/plugins/hermes-achievements/tests/test_export_and_summary.py index 6ab2d1d895a95..cded01da71fdd 100644 --- a/plugins/hermes-achievements/tests/test_export_and_summary.py +++ b/plugins/hermes-achievements/tests/test_export_and_summary.py @@ -168,7 +168,7 @@ def test_write_agent_summary_persists_json(self): plugin_api._write_agent_summary(sample_data()) path = Path(tmp) / "plugins" / "hermes-achievements" / "agent_summary.json" self.assertTrue(path.exists()) - payload = json.loads(path.read_text()) + payload = json.loads(path.read_text(encoding="utf-8")) self.assertEqual(payload["top_tier"], "Gold") self.assertEqual(payload["unlocked_ids"], ["red_text", "night_owl"]) finally: