diff --git a/plugins/hermes-achievements/LICENSE b/plugins/hermes-achievements/LICENSE new file mode 100644 index 0000000000..2312b92352 --- /dev/null +++ b/plugins/hermes-achievements/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hermes Achievements contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/hermes-achievements/README.md b/plugins/hermes-achievements/README.md new file mode 100644 index 0000000000..dd360197e8 --- /dev/null +++ b/plugins/hermes-achievements/README.md @@ -0,0 +1,148 @@ +# Hermes Achievements + +> **Bundled with Hermes Agent.** Originally authored by [@PCinkusz](https://github.com/PCinkusz) at https://github.com/PCinkusz/hermes-achievements — vendored into `plugins/hermes-achievements/` so it ships with the dashboard out-of-the-box and stays in lockstep with Hermes feature changes. Upstream repo remains the staging ground for new badges and UI iteration. +> +> When Hermes is installed via `pip install hermes-agent` or cloned from source, this plugin auto-registers as a dashboard tab on first `hermes dashboard` launch. No separate install step. See [Built-in Plugins → hermes-achievements](../../website/docs/user-guide/features/built-in-plugins.md) in the main docs. + +Achievement system for the Hermes Dashboard: collectible, tiered badges generated from real local Hermes session history. + +![Hermes Achievements dashboard](docs/assets/achievements-dashboard-hd.png) + +The screenshots use temporary demo tier data to show the full visual range. The plugin itself reads real local Hermes session history by default. + +> **Update notice (2026-04-29):** If you installed this plugin before today, update to the latest version. The achievements scan path was refactored for much faster warm loads (snapshot cache + incremental checkpoint scan). + +## What it does + +Hermes Achievements scans local Hermes sessions and unlocks badges based on real agent behavior: + +- autonomous tool chains +- debugging and recovery patterns +- vibe-coding file edits +- Hermes-native skills, memory, cron, and plugin usage +- web research and browser automation +- model/provider workflows +- lifestyle patterns such as weekend or night sessions + +Achievements have three visible states: + +- **Unlocked** — earned at least one tier +- **Discovered** — known achievement, progress visible, not earned yet +- **Secret** — hidden until Hermes detects the first related signal + +Most achievements level through: + +```text +Copper → Silver → Gold → Diamond → Olympian +``` + +Each card has a collapsible **What counts** section showing the exact tracked metric or requirement once the user wants details. + +Version `0.2.x` expands the catalog to 60+ achievements, including model/provider badges such as **Five-Model Flight**, **Provider Polyglot**, **Claude Confidant**, **Gemini Cartographer**, and **Open Weights Pilgrim**. + +## Examples + +- Let Him Cook +- Toolchain Maxxer +- Red Text Connoisseur +- Port 3000 Is Taken +- This Was Supposed To Be Quick +- One More Small Change +- Skillsmith +- Memory Keeper +- Context Dragon +- Plugin Goblin +- Rabbit Hole Certified + +## Install + +Clone into your Hermes plugins directory: + +```bash +git clone https://github.com/PCinkusz/hermes-achievements ~/.hermes/plugins/hermes-achievements +``` + +For local development, keep the repo elsewhere and symlink it: + +```bash +git clone https://github.com/PCinkusz/hermes-achievements ~/hermes-achievements +ln -s ~/hermes-achievements ~/.hermes/plugins/hermes-achievements +``` + +Then rescan dashboard plugins: + +```bash +curl http://127.0.0.1:9119/api/dashboard/plugins/rescan +``` + +If backend API routes 404, restart `hermes dashboard`; plugin APIs are mounted at dashboard startup. + +## Updating + +If you installed with git: + +```bash +cd ~/.hermes/plugins/hermes-achievements +git pull --ff-only +curl http://127.0.0.1:9119/api/dashboard/plugins/rescan +``` + +If the update changes backend routes or `plugin_api.py`, restart `hermes dashboard` after pulling. + +As of 2026-04-29, updating is strongly recommended because scan performance changed significantly: +- removed duplicate `/overview` scan path +- added cached `/achievements` snapshot +- added incremental checkpoint reuse for unchanged sessions + +Achievement unlock state is stored locally in `state.json` and is not overwritten by git updates. New achievements are evaluated from your existing Hermes session history. Achievement IDs are stable and should not be renamed casually because they are the unlock-state keys. + +Releases are tagged in git, for example: + +```bash +git fetch --tags +git checkout v0.2.0 +``` + +## Files + +```text +dashboard/ +├── manifest.json +├── plugin_api.py +└── dist/ + ├── index.js + └── style.css +``` + +## API + +Routes are mounted under: + +```text +/api/plugins/hermes-achievements/ +``` + +Endpoints: + +```text +GET /achievements +GET /scan-status +GET /recent-unlocks +GET /sessions/{session_id}/badges +POST /rescan +POST /reset-state +``` + +## Development + +Run checks: + +```bash +node --check dashboard/dist/index.js +python3 -m py_compile dashboard/plugin_api.py +python3 -m unittest tests/test_achievement_engine.py -v +``` + +## License + +MIT diff --git a/plugins/hermes-achievements/dashboard/dist/index.js b/plugins/hermes-achievements/dashboard/dist/index.js new file mode 100644 index 0000000000..56b9427e84 --- /dev/null +++ b/plugins/hermes-achievements/dashboard/dist/index.js @@ -0,0 +1,351 @@ +(function () { + "use strict"; + // hermes-achievements dashboard plugin + // Originally authored by @PCinkusz — https://github.com/PCinkusz/hermes-achievements (MIT). + // Bundled into hermes-agent. Upstream repo remains the staging ground for new + // badges and UI iteration; the in-progress scan banner below is a small addition + // layered on top of the original dist bundle. + const SDK = window.__HERMES_PLUGIN_SDK__; + if (!SDK || !window.__HERMES_PLUGINS__) return; + + const React = SDK.React; + const hooks = SDK.hooks; + const C = SDK.components; + const cn = SDK.utils.cn; + + const LUCIDE = {"flame":"","avalanche":"\n ","nodes":"\n \n \n \n ","rocket":"\n \n \n ","branch":"\n \n \n ","daemon":"\n ","clock":"\n ","warning":"\n \n ","wine":"\n \n \n ","scroll":"\n \n \n ","plug":"\n \n \n \n \n ","lock":"\n \n ","package_skull":"\n \n \n \n ","restart":"\n \n \n ","key":"\n ","colon":"\n ","container":"\n \n \n \n ","melting_clock":"\n \n ","pencil":"\n ","blueprint":"\n \n \n \n ","pixel":"\n \n \n \n ","ship":"\n \n \n \n ","spark_cursor":"\n \n \n \n ","needle":"","hammer_scroll":"\n \n ","anvil":"\n \n \n \n ","crystal":"\n \n ","palace":"\n \n \n \n \n ","dragon":"","antenna":"\n \n \n \n \n \n ","puzzle":"","rewind":"\n ","spiral":"\n \n \n \n ","quote":"\n ","compass":"\n ","browser":"\n \n ","terminal":"\n ","wand":"\n \n \n \n \n \n \n ","folder":"\n \n ","eye":"\n ","wave":"","swap":"\n \n \n ","router":"\n \n \n \n \n ","codex":"\n \n ","prism":"\n \n ","marathon":"\n \n ","calendar":"\n \n \n \n \n \n \n \n \n ","moon":"","cache":"\n \n ","secret":"\n \n "}; + + const tierClass = function (tier) { + return tier ? "ha-tier-" + tier.toLowerCase() : "ha-tier-pending"; + }; + + async function api(path, options) { + const url = "/api/plugins/hermes-achievements" + path; + const res = await fetch(url, options || {}); + if (!res.ok) { + const text = await res.text().catch(function () { return res.statusText; }); + throw new Error(res.status + ": " + text); + } + const text = await res.text(); + try { + return JSON.parse(text); + } catch (_) { + return null; + } + } + + function AchievementIcon({ icon }) { + const svg = LUCIDE[icon] || LUCIDE.secret; + const ref = React.useRef(null); + React.useEffect(function () { + if (!ref.current) return; + const el = ref.current; + while (el.firstChild) el.removeChild(el.firstChild); + try { + const doc = new DOMParser().parseFromString( + "" + svg + "", + "image/svg+xml" + ); + if (!doc.querySelector("parsererror")) { + Array.from(doc.documentElement.childNodes).forEach(function (n) { + el.appendChild(document.importNode(n, true)); + }); + } + } catch (_) {} + }, [svg]); + return React.createElement("svg", { + ref: ref, + className: "ha-lucide", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 2, + strokeLinecap: "round", + strokeLinejoin: "round", + "aria-hidden": "true", + }); + } + + function StatCard(props) { + return React.createElement(C.Card, { className: "ha-stat" }, + React.createElement(C.CardContent, { className: "ha-stat-content" }, + React.createElement("div", { className: "ha-stat-label" }, props.label), + React.createElement("div", { className: "ha-stat-value" }, props.value), + props.hint && React.createElement("div", { className: "ha-stat-hint" }, props.hint) + ) + ); + } + + function TierLegend() { + return React.createElement("div", { className: "ha-tier-legend" }, + ["Copper", "Silver", "Gold", "Diamond", "Olympian"].map(function (tier, index, arr) { + return React.createElement(React.Fragment, { key: tier }, + React.createElement("span", { className: "ha-tier-step ha-tier-" + tier.toLowerCase() }, + React.createElement("i", null), + tier + ), + index < arr.length - 1 && React.createElement("span", { className: "ha-tier-arrow" }, "→") + ); + }) + ); + } + + + function LoadingSkeletonCard(props) { + return React.createElement(C.Card, { className: "ha-card ha-skeleton-card ha-tier-pending" }, + React.createElement(C.CardContent, { className: "ha-card-content" }, + React.createElement("div", { className: "ha-card-head" }, + React.createElement("div", { className: "ha-skeleton ha-skeleton-icon" }), + React.createElement("div", { className: "ha-skeleton-stack" }, + React.createElement("div", { className: "ha-skeleton ha-skeleton-title" }), + React.createElement("div", { className: "ha-skeleton ha-skeleton-meta" }) + ), + React.createElement("div", { className: "ha-badges" }, + React.createElement("div", { className: "ha-skeleton ha-skeleton-badge" }), + React.createElement("div", { className: "ha-skeleton ha-skeleton-badge ha-skeleton-badge-short" }) + ) + ), + React.createElement("div", { className: "ha-skeleton ha-skeleton-line" }), + React.createElement("div", { className: "ha-skeleton ha-skeleton-line ha-skeleton-line-short" }), + React.createElement("div", { className: "ha-skeleton ha-skeleton-criteria" }), + React.createElement("div", { className: "ha-evidence-slot" }, React.createElement("div", { className: "ha-skeleton ha-skeleton-evidence" })), + React.createElement("div", { className: "ha-progress-row" }, + React.createElement("div", { className: "ha-skeleton ha-skeleton-progress" }), + React.createElement("div", { className: "ha-skeleton ha-skeleton-progress-text" }) + ) + ) + ); + } + + function LoadingPage() { + return React.createElement("div", { className: "ha-page ha-page-loading" }, + React.createElement("section", { className: "ha-hero ha-loading-hero" }, + React.createElement("div", null, + React.createElement("div", { className: "ha-kicker" }, "Agentic Gamerscore"), + React.createElement("h1", null, "Hermes Achievements"), + React.createElement("p", null, "Scanning Hermes session history. First scan can take 5–10 seconds on large histories.") + ), + React.createElement("div", { className: "ha-scan-status", role: "status", "aria-live": "polite" }, + React.createElement("span", { className: "ha-scan-pulse", "aria-hidden": "true" }), + React.createElement("div", null, + React.createElement("strong", null, "Building achievement profile…"), + React.createElement("p", null, "Reading sessions, tool calls, model metadata, and unlock state.") + ) + ) + ), + React.createElement("div", { className: "ha-stats" }, + ["Unlocked", "Discovered", "Secrets", "Highest tier", "Latest"].map(function (label) { + return React.createElement(C.Card, { key: label, className: "ha-stat ha-skeleton-stat" }, + React.createElement(C.CardContent, { className: "ha-stat-content" }, + React.createElement("div", { className: "ha-stat-label" }, label), + React.createElement("div", { className: "ha-skeleton ha-skeleton-stat-value" }), + React.createElement("div", { className: "ha-skeleton ha-skeleton-stat-hint" }) + ) + ); + }) + ), + React.createElement("section", { className: "ha-guide ha-loading-guide" }, + React.createElement("div", null, + React.createElement("strong", null, "Scan status"), + React.createElement("p", null, "Hermes is scanning local history once, then cards will appear automatically. Nothing is stuck if this takes a few seconds.") + ), + React.createElement("div", null, + React.createElement("strong", null, "What is scanned"), + React.createElement("p", null, "Sessions, tool calls, model metadata, errors, achievements, and local unlock state.") + ) + ), + React.createElement("section", { className: "ha-grid" }, [0, 1, 2, 3, 4, 5].map(function (i) { + return React.createElement(LoadingSkeletonCard, { key: i }); + })) + ); + } + + + function AchievementCard({ achievement }) { + const unlocked = achievement.unlocked; + const progress = achievement.progress || 0; + const pct = achievement.progress_pct || (unlocked ? 100 : 0); + const state = achievement.state || (unlocked ? "unlocked" : "discovered"); + const stateLabel = state === "unlocked" ? "Unlocked" : (state === "secret" ? "Secret" : "Discovered"); + const targetTier = achievement.next_tier || achievement.tier; + const tierLabel = achievement.tier ? achievement.tier : (targetTier ? "Target " + targetTier : (state === "secret" ? "Hidden" : (unlocked ? "Complete" : "Objective"))); + const progressText = state === "secret" ? "hidden" : (progress + (achievement.next_threshold ? " / " + achievement.next_threshold : "")); + return React.createElement(C.Card, { className: cn("ha-card", "ha-state-" + state, tierClass(achievement.tier || achievement.next_tier)) }, + React.createElement(C.CardContent, { className: "ha-card-content" }, + React.createElement("div", { className: "ha-card-head" }, + React.createElement("div", { className: "ha-icon" }, React.createElement(AchievementIcon, { icon: achievement.icon || "secret" })), + React.createElement("div", { className: "ha-card-title-wrap" }, + React.createElement("div", { className: "ha-card-title" }, achievement.name), + React.createElement("div", { className: "ha-card-category" }, achievement.category) + ), + React.createElement("div", { className: "ha-badges" }, + React.createElement("span", { className: "ha-state-badge" }, stateLabel), + React.createElement("span", { className: "ha-tier-badge" }, tierLabel) + ) + ), + React.createElement("p", { className: "ha-description" }, achievement.description), + achievement.criteria && React.createElement("details", { className: "ha-criteria" }, + React.createElement("summary", null, state === "secret" ? "How to reveal" : "What counts"), + React.createElement("p", null, achievement.criteria) + ), + React.createElement("div", { className: "ha-evidence-slot" }, + achievement.evidence ? React.createElement("div", { className: "ha-evidence" }, + React.createElement("span", { className: "ha-evidence-label" }, "Evidence"), + React.createElement("span", { className: "ha-evidence-title" }, achievement.evidence.title || achievement.evidence.session_id || "session") + ) : React.createElement("div", { className: "ha-evidence ha-evidence-empty", "aria-hidden": "true" }, "No evidence yet") + ), + React.createElement("div", { className: "ha-progress-row" }, + React.createElement("div", { className: "ha-progress-track" }, + React.createElement("div", { className: "ha-progress-fill", style: { width: Math.max(state === "secret" ? 0 : 3, Math.min(100, pct)) + "%" } }) + ), + React.createElement("span", { className: "ha-progress-text" }, progressText) + ) + ) + ); + } + + function AchievementsPage() { + const [data, setData] = hooks.useState(null); + const [loading, setLoading] = hooks.useState(true); + const [error, setError] = hooks.useState(null); + const [category, setCategory] = hooks.useState("All"); + const [visibility, setVisibility] = hooks.useState("all"); + + function load() { + setLoading(true); + api("/achievements") + .then(function (payload) { setData(payload); setError((payload && payload.error) || null); }) + .catch(function (err) { setError(String(err)); }) + .finally(function () { setLoading(false); }); + } + // refresh() re-fetches without flipping the loading state — used by the + // auto-poller during an in-progress background scan so the page updates + // with growing unlock counts instead of flashing the loading skeleton. + function refresh() { + api("/achievements") + .then(function (payload) { setData(payload); setError((payload && payload.error) || null); }) + .catch(function (err) { setError(String(err)); }); + } + hooks.useEffect(load, []); + + // Auto-poll while the backend is still scanning. scan_meta.mode is + // "pending" on the very first request (no cache yet) and "in_progress" + // while the background thread is publishing partial snapshots. Once it + // flips to "full" or "incremental" the scan is done and we stop polling. + const scanMode = (data && data.scan_meta && data.scan_meta.mode) || null; + const scanInFlight = scanMode === "pending" || scanMode === "in_progress"; + hooks.useEffect(function () { + if (!scanInFlight) return undefined; + const id = setInterval(refresh, 4000); + return function () { clearInterval(id); }; + }, [scanInFlight]); + + const achievements = (data && data.achievements) || []; + const categories = ["All"].concat(Array.from(new Set(achievements.map(function (a) { return a.category; })))); + const visible = achievements.filter(function (a) { + if (category !== "All" && a.category !== category) return false; + if (visibility === "unlocked" && a.state !== "unlocked") return false; + if (visibility === "discovered" && a.state !== "discovered") return false; + if (visibility === "secret" && a.state !== "secret") return false; + return true; + }); + const unlocked = achievements.filter(function (a) { return a.state === "unlocked"; }); + const discovered = achievements.filter(function (a) { return a.state === "discovered"; }); + const secret = achievements.filter(function (a) { return a.state === "secret"; }); + const latest = unlocked.slice().sort(function (a, b) { return (b.unlocked_at || 0) - (a.unlocked_at || 0); }).slice(0, 5); + const highest = ["Olympian", "Diamond", "Gold", "Silver", "Copper"].find(function (tier) { return unlocked.some(function (a) { return a.tier === tier; }); }) || "None yet"; + + // Build the in-progress scan banner once so the JSX below stays readable. + // Shows nothing when the scan is idle. When a scan is running it renders + // a pulsing status row with "X / Y sessions · Z%" and a filling bar, so + // the user gets continuous visual feedback during long cold scans on + // large session databases (can take several minutes on 8000+ sessions). + let scanBanner = null; + if (scanInFlight) { + const meta = (data && data.scan_meta) || {}; + const scanned = Number(meta.sessions_scanned_so_far || meta.sessions_total || 0); + const total = Number(meta.sessions_expected_total || 0); + const pct = total > 0 ? Math.max(0, Math.min(100, Math.floor((scanned / total) * 100))) : 0; + const headline = scanMode === "pending" + ? "Starting achievement scan…" + : "Building achievement profile…"; + const detail = total > 0 + ? ("Scanned " + scanned.toLocaleString() + " of " + total.toLocaleString() + " sessions · " + pct + "%. Badges unlock as more history streams in.") + : "Reading sessions, tool calls, model metadata, and unlock state. Badges appear here as they unlock."; + scanBanner = React.createElement("section", { className: "ha-scan-banner", role: "status", "aria-live": "polite" }, + React.createElement("div", { className: "ha-scan-banner-head" }, + React.createElement("span", { className: "ha-scan-pulse", "aria-hidden": "true" }), + React.createElement("div", { className: "ha-scan-banner-text" }, + React.createElement("strong", null, headline), + React.createElement("p", null, detail) + ) + ), + total > 0 && React.createElement("div", { className: "ha-scan-progress-track", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": pct }, + React.createElement("div", { className: "ha-scan-progress-fill", style: { width: pct + "%" } }) + ) + ); + } + + if (loading) { + return React.createElement(LoadingPage, null); + } + + return React.createElement("div", { className: "ha-page" }, + React.createElement("section", { className: "ha-hero" }, + React.createElement("div", null, + React.createElement("div", { className: "ha-kicker" }, "Agentic Gamerscore"), + React.createElement("h1", null, "Hermes Achievements"), + React.createElement("p", null, "Collectible Hermes badges earned from real session history. Known unfinished achievements are shown as Discovered; Secret achievements stay hidden until the first matching behavior appears.") + ), + React.createElement(C.Button, { onClick: load, className: "ha-refresh" }, "Rescan") + ), + scanBanner, + error && React.createElement(C.Card, { className: "ha-error" }, React.createElement(C.CardContent, null, String(error))), + React.createElement("div", { className: "ha-stats" }, + React.createElement(StatCard, { label: "Unlocked", value: (data ? data.unlocked_count : 0) + " / " + (data ? data.total_count : 0), hint: "earned badges" }), + React.createElement(StatCard, { label: "Discovered", value: discovered.length, hint: "known, not earned yet" }), + React.createElement(StatCard, { label: "Secrets", value: secret.length, hint: "hidden until first signal" }), + React.createElement(StatCard, { label: "Highest tier", value: highest, hint: "Copper → Silver → Gold → Diamond → Olympian" }), + React.createElement(StatCard, { label: "Latest", value: latest[0] ? latest[0].name : "None yet", hint: latest[0] ? latest[0].category : "run Hermes more" }) + ), + React.createElement("section", { className: "ha-guide" }, + React.createElement("div", null, + React.createElement("strong", null, "Tiers"), + React.createElement(TierLegend, null) + ), + React.createElement("div", null, + React.createElement("strong", null, "Secret achievements"), + React.createElement("p", null, "Secrets hide their exact trigger. Once Hermes sees a related signal, the card becomes Discovered and shows its requirement.") + ) + ), + React.createElement("div", { className: "ha-toolbar" }, + React.createElement("div", { className: "ha-pills" }, categories.map(function (cat) { + return React.createElement("button", { key: cat, onClick: function () { setCategory(cat); }, className: cat === category ? "active" : "" }, cat); + })), + React.createElement("div", { className: "ha-pills" }, ["all", "unlocked", "discovered", "secret"].map(function (v) { + return React.createElement("button", { key: v, onClick: function () { setVisibility(v); }, className: v === visibility ? "active" : "" }, v); + })) + ), + latest.length > 0 && React.createElement("section", { className: "ha-latest" }, + React.createElement("h2", null, "Recent unlocks"), + React.createElement("div", { className: "ha-latest-row" }, latest.map(function (a) { + return React.createElement("div", { key: a.id, className: cn("ha-chip", tierClass(a.tier)) }, + React.createElement("span", { className: "ha-chip-icon" }, React.createElement(AchievementIcon, { icon: a.icon || "secret" })), + a.name + ); + })) + ), + visibility === "secret" && visible.length === 0 && React.createElement(C.Card, { className: "ha-secret-empty" }, + React.createElement(C.CardContent, { className: "ha-secret-empty-content" }, + React.createElement("strong", null, "No hidden secrets left in this scan."), + React.createElement("p", null, "Clue: secrets usually start from unusual failure or power-user patterns — port conflicts, permission walls, missing env vars, YAML mistakes, Docker collisions, rollback/checkpoint use, cache hits, or tiny fixes after lots of red text.") + ) + ), + React.createElement("section", { className: "ha-grid" }, visible.map(function (a) { + return React.createElement(AchievementCard, { key: a.id, achievement: a }); + })) + ); + } + + window.__HERMES_PLUGINS__.register("hermes-achievements", AchievementsPage); +})(); diff --git a/plugins/hermes-achievements/dashboard/dist/style.css b/plugins/hermes-achievements/dashboard/dist/style.css new file mode 100644 index 0000000000..fc0e138f4e --- /dev/null +++ b/plugins/hermes-achievements/dashboard/dist/style.css @@ -0,0 +1,120 @@ +/* hermes-achievements dashboard styles + * Originally authored by @PCinkusz — https://github.com/PCinkusz/hermes-achievements (MIT). + * Bundled into hermes-agent. The in-progress scan banner rules at the bottom + * (.ha-scan-banner*) are a small addition layered on top of the original bundle. + */ +.ha-page { display: flex; flex-direction: column; gap: 1rem; } +.ha-hero { position: relative; overflow: hidden; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; border: 1px solid var(--color-border); background: radial-gradient(circle at 12% 0, rgba(103,232,249,.13), transparent 30%), linear-gradient(135deg, color-mix(in srgb, var(--color-card) 88%, transparent), color-mix(in srgb, var(--color-primary) 10%, transparent)); padding: 1.25rem; } +.ha-hero:before { content: ""; position: absolute; inset: auto -10% -80% -10%; height: 180%; pointer-events: none; background: radial-gradient(circle, rgba(242,201,76,.12), transparent 55%); } +.ha-hero h1 { position: relative; margin: 0; font-size: clamp(2rem, 4vw, 4.2rem); line-height: .9; letter-spacing: -0.06em; } +.ha-hero p { position: relative; max-width: 52rem; margin: .65rem 0 0; color: var(--color-muted-foreground); } +.ha-kicker { position: relative; color: var(--color-muted-foreground); text-transform: uppercase; letter-spacing: .18em; font-size: .72rem; font-family: var(--font-mono, ui-monospace, monospace); } +.ha-refresh { position: relative; white-space: nowrap; } +.ha-stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .75rem; } +.ha-stat-content { padding: 1rem !important; } +.ha-stat-label { color: var(--color-muted-foreground); font-size: .75rem; text-transform: uppercase; letter-spacing: .12em; } +.ha-stat-value { margin-top: .35rem; font-size: 1.4rem; font-weight: 750; letter-spacing: -0.035em; } +.ha-stat-hint { margin-top: .2rem; color: var(--color-muted-foreground); font-size: .75rem; } +.ha-toolbar { display: flex; justify-content: space-between; gap: .75rem; align-items: center; flex-wrap: wrap; } +.ha-pills { display: flex; gap: .35rem; flex-wrap: wrap; } +.ha-pills button { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 72%, transparent); color: var(--color-muted-foreground); padding: .35rem .6rem; font-size: .78rem; cursor: pointer; } +.ha-pills button.active, .ha-pills button:hover { color: var(--color-foreground); border-color: var(--ha-tier, var(--color-ring)); background: color-mix(in srgb, var(--color-primary) 16%, var(--color-card)); } +.ha-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: .9rem; } +.ha-card { --ha-tier: var(--color-border); position: relative; overflow: hidden; min-height: 214px; border: 1px solid color-mix(in srgb, var(--ha-tier) 46%, var(--color-border)); background: radial-gradient(circle at 2.6rem 2.2rem, color-mix(in srgb, var(--ha-tier) 16%, transparent), transparent 34%), linear-gradient(180deg, rgba(255,255,255,.04), transparent), color-mix(in srgb, var(--color-card) 92%, #000); transition: transform .16s ease, border-color .16s ease, opacity .16s ease, box-shadow .16s ease; } +.ha-card:hover { transform: translateY(-2px); border-color: var(--ha-tier); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ha-tier) 16%, transparent); } +.ha-card-content { position: relative; z-index: 1; padding: 1rem !important; display: flex; flex-direction: column; gap: .75rem; height: 100%; } +.ha-card-head { display: grid; grid-template-columns: 3.1rem minmax(0, 1fr) auto; gap: .85rem; align-items: start; } +.ha-icon { display: grid; place-items: center; width: 2.9rem; height: 2.9rem; color: var(--ha-tier); } +.ha-lucide { width: 1.78rem; height: 1.78rem; stroke: currentColor; stroke-width: 2.15; filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ha-tier) 24%, transparent)); } +.ha-card-title { font-weight: 780; line-height: 1.05; letter-spacing: -0.025em; } +.ha-card-category { margin-top: .28rem; color: var(--color-muted-foreground); font-size: .76rem; } +.ha-badges { display: flex; flex-direction: column; align-items: flex-end; gap: .25rem; } +.ha-tier-badge, .ha-state-badge { border: 1px solid var(--ha-tier); color: var(--ha-tier); background: color-mix(in srgb, var(--ha-tier) 10%, transparent); padding: .16rem .38rem; font-size: .67rem; text-transform: uppercase; letter-spacing: .08em; font-family: var(--font-mono, ui-monospace, monospace); } +.ha-description { margin: 0; color: var(--color-muted-foreground); font-size: .86rem; line-height: 1.45; min-height: 2.4em; } +.ha-criteria { border: 1px solid color-mix(in srgb, var(--ha-tier) 28%, var(--color-border)); background: color-mix(in srgb, var(--ha-tier) 5%, transparent); } +.ha-criteria summary { cursor: pointer; padding: .5rem .65rem; color: var(--ha-tier); text-transform: uppercase; letter-spacing: .1em; font-size: .66rem; font-family: var(--font-mono, ui-monospace, monospace); user-select: none; } +.ha-criteria summary:hover { background: color-mix(in srgb, var(--ha-tier) 8%, transparent); } +.ha-criteria p { margin: 0; border-top: 1px solid color-mix(in srgb, var(--ha-tier) 18%, var(--color-border)); padding: .55rem .65rem .65rem; color: color-mix(in srgb, var(--color-foreground) 78%, var(--color-muted-foreground)); font-size: .76rem; line-height: 1.38; } +.ha-progress-row { display: flex; align-items: center; gap: .55rem; margin-top: 0; } +.ha-progress-track { flex: 1; height: .48rem; border: 1px solid color-mix(in srgb, var(--ha-tier) 34%, var(--color-border)); background: rgba(0,0,0,.22); overflow: hidden; } +.ha-progress-fill { height: 100%; background: linear-gradient(90deg, var(--ha-tier), color-mix(in srgb, var(--ha-tier) 48%, white)); } +.ha-progress-text { min-width: 5.4rem; text-align: right; font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-muted-foreground); font-size: .72rem; } +.ha-evidence-slot { min-height: 1.65rem; margin-top: auto; display: flex; align-items: flex-end; } +.ha-evidence { width: 100%; display: flex; align-items: center; gap: .4rem; color: var(--color-muted-foreground); font-size: .72rem; min-width: 0; } +.ha-evidence-label { text-transform: uppercase; letter-spacing: .09em; font-family: var(--font-mono, ui-monospace, monospace); flex: 0 0 auto; } +.ha-evidence-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: color-mix(in srgb, var(--color-foreground) 84%, var(--color-muted-foreground)); } +.ha-evidence-empty { visibility: hidden; } +.ha-latest h2 { margin: 0 0 .5rem; font-size: 1rem; } +.ha-latest-row { display: flex; gap: .5rem; flex-wrap: wrap; } +.ha-chip { display: inline-flex; align-items: center; gap: .35rem; border: 1px solid var(--ha-tier); color: var(--ha-tier); background: color-mix(in srgb, var(--ha-tier) 10%, transparent); padding: .35rem .55rem; font-size: .8rem; } +.ha-chip-icon .ha-lucide { width: .95rem; height: .95rem; } +.ha-slot { border-style: dashed; } +.ha-slot-content { display: flex; gap: .6rem; align-items: center; padding: .65rem .8rem !important; font-size: .82rem; } +.ha-slot-star { color: #67e8f9; } +.ha-slot-muted { color: var(--color-muted-foreground); margin-left: auto; } +.ha-error { border-color: #ef4444; color: #fecaca; } +.ha-loading { color: var(--color-muted-foreground); font-family: var(--font-mono, ui-monospace, monospace); padding: 2rem; border: 1px dashed var(--color-border); } +.ha-guide { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr); gap: .75rem; } +.ha-guide > div { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 82%, transparent); padding: .85rem 1rem; } +.ha-guide strong { display: block; margin-bottom: .45rem; font-size: .78rem; text-transform: uppercase; letter-spacing: .12em; font-family: var(--font-mono, ui-monospace, monospace); } +.ha-guide p { margin: 0; color: var(--color-muted-foreground); font-size: .84rem; line-height: 1.45; } +.ha-tier-legend { display: flex; align-items: center; gap: .45rem; flex-wrap: wrap; } +.ha-tier-step { --ha-tier: var(--color-border); display: inline-flex; align-items: center; gap: .32rem; color: var(--ha-tier); border: 1px solid color-mix(in srgb, var(--ha-tier) 52%, var(--color-border)); background: color-mix(in srgb, var(--ha-tier) 8%, transparent); padding: .28rem .45rem; font-size: .72rem; font-family: var(--font-mono, ui-monospace, monospace); text-transform: uppercase; letter-spacing: .06em; } +.ha-tier-step i { width: .55rem; height: .55rem; background: var(--ha-tier); display: inline-block; } +.ha-tier-arrow { color: var(--color-muted-foreground); } +.ha-state-discovered { opacity: .92; } +.ha-state-discovered .ha-card-title { color: color-mix(in srgb, var(--color-foreground) 82%, var(--ha-tier)); } +.ha-state-secret { opacity: .5; filter: grayscale(.55); } +.ha-state-secret:after { content: ""; position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(-45deg, transparent 0 8px, rgba(255,255,255,.035) 8px 10px); } +.ha-tier-pending { --ha-tier: color-mix(in srgb, var(--color-muted-foreground) 64%, transparent); } +.ha-tier-copper { --ha-tier: #b87333; } +.ha-tier-silver { --ha-tier: #c0c7d2; } +.ha-tier-gold { --ha-tier: #f2c94c; box-shadow: 0 0 22px rgba(242,201,76,.08); } +.ha-tier-diamond { --ha-tier: #67e8f9; box-shadow: 0 0 24px rgba(103,232,249,.1); } +.ha-tier-olympian { --ha-tier: #c084fc; box-shadow: 0 0 34px rgba(192,132,252,.18), 0 0 12px rgba(242,201,76,.1); } +@media (max-width: 980px) { .ha-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .ha-guide { grid-template-columns: 1fr; } } +@media (max-width: 800px) { .ha-stats { grid-template-columns: 1fr; } .ha-hero { flex-direction: column; align-items: stretch; } .ha-card-head { grid-template-columns: 3.1rem 1fr; } .ha-badges { grid-column: 1 / -1; align-items: flex-start; flex-direction: row; } } + +.ha-secret-empty-content { padding: 1rem !important; } +.ha-secret-empty strong { display: block; margin-bottom: .35rem; } +.ha-secret-empty p { margin: 0; color: var(--color-muted-foreground); font-size: .86rem; line-height: 1.45; } +.ha-page-loading { animation: ha-fade-in .18s ease-out; } +.ha-loading-hero { align-items: center; } +.ha-scan-status { position: relative; z-index: 1; display: flex; align-items: center; gap: .8rem; min-width: 18rem; border: 1px solid color-mix(in srgb, #67e8f9 35%, var(--color-border)); background: color-mix(in srgb, var(--color-card) 78%, transparent); padding: .8rem .95rem; color: var(--color-foreground); } +.ha-scan-status strong { display: block; font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono, ui-monospace, monospace); } +.ha-scan-status p { margin: .25rem 0 0; font-size: .78rem; line-height: 1.35; color: var(--color-muted-foreground); } +.ha-scan-pulse { width: .72rem; height: .72rem; flex: 0 0 auto; border-radius: 999px; background: #67e8f9; box-shadow: 0 0 0 0 rgba(103,232,249,.55); animation: ha-pulse 1.35s ease-out infinite; } +.ha-skeleton-card { pointer-events: none; } +.ha-skeleton { position: relative; overflow: hidden; border-radius: 0; background: color-mix(in srgb, var(--color-muted-foreground) 16%, transparent); } +.ha-skeleton:after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, rgba(255,255,255,.14), transparent); animation: ha-shimmer 1.35s infinite; } +.ha-skeleton-stack { display: flex; flex-direction: column; gap: .45rem; padding-top: .15rem; } +.ha-skeleton-icon { width: 2.9rem; height: 2.9rem; } +.ha-skeleton-title { width: 72%; height: .95rem; } +.ha-skeleton-meta { width: 45%; height: .65rem; } +.ha-skeleton-badge { width: 4.4rem; height: 1.05rem; } +.ha-skeleton-badge-short { width: 3.6rem; } +.ha-skeleton-line { height: .78rem; width: 92%; } +.ha-skeleton-line-short { width: 68%; } +.ha-skeleton-criteria { height: 2.2rem; width: 100%; border: 1px solid color-mix(in srgb, var(--color-muted-foreground) 18%, var(--color-border)); } +.ha-skeleton-evidence { width: 58%; height: .8rem; } +.ha-skeleton-progress { flex: 1; height: .48rem; } +.ha-skeleton-progress-text { width: 4.6rem; height: .75rem; } +.ha-skeleton-stat-value { width: 56%; height: 1.35rem; margin-top: .55rem; } +.ha-skeleton-stat-hint { width: 76%; height: .7rem; margin-top: .55rem; } +.ha-loading-guide p { color: var(--color-muted-foreground); } +@keyframes ha-shimmer { 100% { transform: translateX(100%); } } +@keyframes ha-pulse { 0% { box-shadow: 0 0 0 0 rgba(103,232,249,.48); } 70% { box-shadow: 0 0 0 .65rem rgba(103,232,249,0); } 100% { box-shadow: 0 0 0 0 rgba(103,232,249,0); } } +@keyframes ha-fade-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } } +.ha-loading-hero p, .ha-scan-status p, .ha-loading-guide p { text-transform: none; letter-spacing: normal; } + +/* In-progress scan banner — shown on the main page while the background scan + * is still walking through session history, so the user sees continuous + * progress (X / Y sessions · Z%) instead of guessing whether anything is + * happening. Reuses .ha-scan-pulse + ha-pulse keyframes from the loading page. + */ +.ha-scan-banner { display: flex; flex-direction: column; gap: .6rem; border: 1px solid color-mix(in srgb, #67e8f9 35%, var(--color-border)); background: color-mix(in srgb, var(--color-card) 78%, transparent); padding: .8rem .95rem; animation: ha-fade-in .18s ease-out; } +.ha-scan-banner-head { display: flex; align-items: center; gap: .8rem; } +.ha-scan-banner-text strong { display: block; font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-foreground); } +.ha-scan-banner-text p { margin: .25rem 0 0; font-size: .78rem; line-height: 1.35; color: var(--color-muted-foreground); text-transform: none; letter-spacing: normal; } +.ha-scan-progress-track { height: .4rem; border: 1px solid color-mix(in srgb, #67e8f9 28%, var(--color-border)); background: rgba(0,0,0,.22); overflow: hidden; } +.ha-scan-progress-fill { height: 100%; background: linear-gradient(90deg, #67e8f9, color-mix(in srgb, #67e8f9 48%, white)); transition: width .4s ease-out; } diff --git a/plugins/hermes-achievements/dashboard/manifest.json b/plugins/hermes-achievements/dashboard/manifest.json new file mode 100644 index 0000000000..02c4050f34 --- /dev/null +++ b/plugins/hermes-achievements/dashboard/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "hermes-achievements", + "label": "Achievements", + "description": "Steam-style achievements for vibe coding and agentic Hermes workflows.", + "icon": "Star", + "version": "0.3.1", + "tab": { "path": "/achievements", "position": "after:analytics" }, + "entry": "dist/index.js", + "css": "dist/style.css", + "api": "plugin_api.py" +} diff --git a/plugins/hermes-achievements/dashboard/plugin_api.py b/plugins/hermes-achievements/dashboard/plugin_api.py new file mode 100644 index 0000000000..678d49fb61 --- /dev/null +++ b/plugins/hermes-achievements/dashboard/plugin_api.py @@ -0,0 +1,1053 @@ +"""Hermes Achievements dashboard plugin backend. + +Mounted at /api/plugins/hermes-achievements/ by Hermes dashboard. +""" +from __future__ import annotations + +import json +import math +import re +import threading +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +try: + from fastapi import APIRouter +except Exception: # Allows local unit tests without dashboard dependencies. + class APIRouter: # type: ignore + def get(self, *_args, **_kwargs): + return lambda fn: fn + def post(self, *_args, **_kwargs): + return lambda fn: fn + +router = APIRouter() + +SNAPSHOT_TTL_SECONDS = 120 +_SCAN_LOCK = threading.Lock() +_SNAPSHOT_CACHE: Optional[Dict[str, Any]] = None +_SNAPSHOT_CACHE_AT = 0 +_SCAN_STATUS: Dict[str, Any] = { + "state": "idle", + "started_at": None, + "finished_at": None, + "last_error": None, + "last_duration_ms": None, + "run_count": 0, +} + +ERROR_RE = re.compile(r"\b(error|failed|failure|traceback|exception|permission denied|not found|eaddrinuse|already in use|timed out|blocked)\b", re.I) +PORT_RE = re.compile(r"\b(port\s+)?(3000|5173|8000|8080|9119)\b.*\b(in use|already|taken|eaddrinuse)\b|\beaddrinuse\b", re.I) +INSTALL_RE = re.compile(r"\b(npm|pnpm|yarn|pip|uv)\b.*\b(install|add)\b", re.I) +SUCCESS_RE = re.compile(r"\b(success|passed|built|compiled|done|exit_code[\"']?\s*[:=]\s*0|verified|ok)\b", re.I) +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"] + + +def tiers(values: List[int]) -> List[Dict[str, Any]]: + return [{"name": name, "threshold": threshold} for name, threshold in zip(TIER_NAMES, values)] + + +def req(metric: str, gte: int) -> Dict[str, Any]: + return {"metric": metric, "gte": gte} + + +ACHIEVEMENTS: List[Dict[str, Any]] = [ + # Agent Autonomy — mostly best-session feats + {"id": "let_him_cook", "name": "Let Him Cook", "description": "Let Hermes run a serious autonomous tool chain in one session.", "category": "Agent Autonomy", "kind": "best_session", "icon": "flame", "threshold_metric": "max_tool_calls_in_session", "tiers": tiers([200, 500, 1200, 3000, 8000])}, + {"id": "autonomous_avalanche", "name": "Autonomous Avalanche", "description": "Accumulate a lifetime avalanche of Hermes tool calls across sessions.", "category": "Agent Autonomy", "kind": "lifetime", "icon": "avalanche", "threshold_metric": "total_tool_calls", "tiers": tiers([1000, 3000, 8000, 20000, 50000])}, + {"id": "toolchain_maxxer", "name": "Toolchain Maxxer", "description": "Use a wide spread of distinct Hermes tools in one session.", "category": "Agent Autonomy", "kind": "best_session", "icon": "nodes", "threshold_metric": "max_distinct_tools_in_session", "tiers": tiers([18, 28, 45, 70, 100])}, + {"id": "full_send", "name": "Full Send", "description": "Terminal, files, and web/browser all get involved in one real run.", "category": "Agent Autonomy", "kind": "multi_condition", "icon": "rocket", "requirements": [req("max_terminal_calls_in_session", 180), req("max_file_tool_calls_in_session", 120), req("max_web_browser_calls_in_session", 60)]}, + {"id": "subagent_commander", "name": "Subagent Commander", "description": "Coordinate delegated agent work.", "category": "Agent Autonomy", "kind": "lifetime", "icon": "branch", "threshold_metric": "total_delegate_calls", "tiers": tiers([5, 40, 100, 1000, 5000])}, + {"id": "background_process_enjoyer", "name": "Background Process Enjoyer", "description": "Start or control enough long-running processes to deserve the title.", "category": "Agent Autonomy", "kind": "lifetime", "icon": "daemon", "threshold_metric": "total_process_calls", "tiers": tiers([300, 800, 2000, 6000, 15000])}, + {"id": "cron_necromancer", "name": "Cron Necromancer", "description": "Raise scheduled autonomous jobs from the dead.", "category": "Agent Autonomy", "kind": "lifetime", "icon": "clock", "threshold_metric": "total_cron_calls", "tiers": tiers([1000, 3000, 8000, 20000, 50000])}, + + # Debugging Chaos — higher thresholds + multi-condition events + {"id": "red_text_connoisseur", "name": "Red Text Connoisseur", "description": "Encounter enough errors to develop a palate for red text.", "category": "Debugging Chaos", "kind": "lifetime", "icon": "warning", "threshold_metric": "total_errors", "tiers": tiers([1500, 4000, 10000, 25000, 75000])}, + {"id": "stack_trace_sommelier", "name": "Stack Trace Sommelier", "description": "Taste tracebacks by the flight, not by the sip.", "category": "Debugging Chaos", "kind": "lifetime", "icon": "wine", "threshold_metric": "traceback_events", "tiers": tiers([300, 1000, 3000, 8000, 20000])}, + {"id": "actually_read_the_logs", "name": "Actually Read The Logs", "description": "Inspect logs repeatedly instead of guessing.", "category": "Debugging Chaos", "kind": "lifetime", "icon": "scroll", "threshold_metric": "log_read_events", "tiers": tiers([1000, 3000, 8000, 20000, 50000])}, + {"id": "port_3000_taken", "name": "Port 3000 Is Taken", "description": "Discover dev-server port conflict patterns enough times to become numb.", "category": "Debugging Chaos", "kind": "lifetime", "icon": "plug", "secret": True, "threshold_metric": "port_conflict_events", "tiers": tiers([15, 40, 100, 300, 1000])}, + {"id": "permission_denied_any_percent", "name": "Permission Denied Any%", "description": "Speedrun into permission walls.", "category": "Debugging Chaos", "kind": "lifetime", "icon": "lock", "secret": True, "threshold_metric": "permission_denied_events", "tiers": tiers([25, 75, 200, 600, 1500])}, + {"id": "dependency_hell_tourist", "name": "Dependency Hell Tourist", "description": "Package installs fail, then somehow life continues.", "category": "Debugging Chaos", "kind": "multi_condition", "icon": "package_skull", "requirements": [req("install_error_events", 25), req("install_success_events", 10)]}, + {"id": "the_fix_was_restarting", "name": "The Fix Was Restarting It", "description": "Restart after enough error clusters to call it a technique.", "category": "Debugging Chaos", "kind": "multi_condition", "icon": "restart", "requirements": [req("restart_after_error_events", 50), req("total_errors", 4000)]}, + {"id": "forgot_the_env_var", "name": "Forgot The Env Var", "description": "Auth or configuration failed because an environment variable was missing.", "category": "Debugging Chaos", "kind": "lifetime", "icon": "key", "secret": True, "threshold_metric": "env_var_error_events", "tiers": tiers([5000, 15000, 40000, 100000, 250000])}, + {"id": "yaml_colon_incident", "name": "YAML Colon Incident", "description": "Configuration syntax bites back.", "category": "Debugging Chaos", "kind": "lifetime", "icon": "colon", "secret": True, "threshold_metric": "yaml_error_events", "tiers": tiers([1000, 3000, 8000, 20000, 50000])}, + {"id": "docker_name_collision", "name": "Docker Name Collision", "description": "A container name already exists. Of course it does.", "category": "Debugging Chaos", "kind": "lifetime", "icon": "container", "secret": True, "threshold_metric": "docker_conflict_events", "tiers": tiers([75, 200, 600, 1500, 4000])}, + + # Vibe Coding + {"id": "supposed_to_be_quick", "name": "This Was Supposed To Be Quick", "description": "A tiny ask becomes an entire expedition.", "category": "Vibe Coding", "kind": "best_session", "icon": "melting_clock", "threshold_metric": "max_messages_in_session", "tiers": tiers([300, 600, 1200, 2500, 6000])}, + {"id": "one_more_small_change", "name": "One More Small Change", "description": "Make enough file edits in one session to invalidate the phrase small change.", "category": "Vibe Coding", "kind": "best_session", "icon": "pencil", "threshold_metric": "max_file_tool_calls_in_session", "tiers": tiers([150, 400, 1000, 3000, 8000])}, + {"id": "vibe_architect", "name": "Vibe Architect", "description": "Touch a broad surface area in one project session.", "category": "Vibe Coding", "kind": "best_session", "icon": "blueprint", "threshold_metric": "max_files_touched_in_session", "tiers": tiers([300, 700, 1500, 4000, 10000])}, + {"id": "pixel_goblin", "name": "Pixel Goblin", "description": "Do sustained frontend, CSS, SVG, or visual tuning.", "category": "Vibe Coding", "kind": "lifetime", "icon": "pixel", "threshold_metric": "frontend_activity_events", "tiers": tiers([20000, 50000, 120000, 300000, 800000])}, + {"id": "ship_first_ask_later", "name": "Ship First, Ask Later", "description": "Git activity after a serious tool chain.", "category": "Vibe Coding", "kind": "multi_condition", "icon": "ship", "requirements": [req("git_events", 50), req("max_tool_calls_in_session", 500)]}, + {"id": "css_exorcist", "name": "CSS Exorcist", "description": "Cast repeated styling demons out of the interface.", "category": "Vibe Coding", "kind": "lifetime", "icon": "spark_cursor", "threshold_metric": "css_activity_events", "tiers": tiers([10000, 30000, 80000, 200000, 500000])}, + {"id": "one_character_fix", "name": "One Character Fix", "description": "A tiny edit after a pile of errors. Painful. Beautiful.", "category": "Vibe Coding", "kind": "multi_condition", "icon": "needle", "secret": True, "requirements": [req("tiny_patch_after_errors_events", 5), req("total_errors", 4000)]}, + + # Hermes Native + {"id": "skillsmith", "name": "Skillsmith", "description": "Work with Hermes skills enough to leave fingerprints.", "category": "Hermes Native", "kind": "lifetime", "icon": "hammer_scroll", "threshold_metric": "skill_events", "tiers": tiers([5000, 15000, 40000, 100000, 250000])}, + {"id": "skill_issue_skill_created", "name": "Skill Issue? Skill Created.", "description": "Create or patch durable procedures instead of repeating yourself.", "category": "Hermes Native", "kind": "lifetime", "icon": "anvil", "threshold_metric": "skill_manage_events", "tiers": tiers([25, 75, 200, 600, 1500])}, + {"id": "memory_keeper", "name": "Memory Keeper", "description": "Persist durable knowledge with memory or Mnemosyne.", "category": "Hermes Native", "kind": "lifetime", "icon": "crystal", "threshold_metric": "memory_events", "tiers": tiers([100, 300, 1000, 3000, 8000])}, + {"id": "memory_palace", "name": "Memory Palace", "description": "Build a serious durable-memory trail.", "category": "Hermes Native", "kind": "lifetime", "icon": "palace", "threshold_metric": "memory_write_events", "tiers": tiers([100, 300, 1000, 3000, 8000])}, + {"id": "context_dragon", "name": "Context Dragon", "description": "Brush against compression, huge context, or token pressure repeatedly.", "category": "Hermes Native", "kind": "lifetime", "icon": "dragon", "threshold_metric": "context_events", "tiers": tiers([5000, 15000, 40000, 100000, 250000])}, + {"id": "gateway_dweller", "name": "Gateway Dweller", "description": "Live through gateway-connected Hermes workflows.", "category": "Hermes Native", "kind": "lifetime", "icon": "antenna", "threshold_metric": "gateway_events", "tiers": tiers([5000, 15000, 40000, 100000, 250000])}, + {"id": "plugin_goblin", "name": "Plugin Goblin", "description": "Use or develop plugins enough that the dashboard notices.", "category": "Hermes Native", "kind": "lifetime", "icon": "puzzle", "threshold_metric": "plugin_events", "tiers": tiers([1000, 3000, 8000, 20000, 50000])}, + {"id": "rollback_wizard", "name": "Rollback Wizard", "description": "Invoke rollback/checkpoint recovery magic.", "category": "Hermes Native", "kind": "lifetime", "icon": "rewind", "secret": True, "threshold_metric": "rollback_events", "tiers": tiers([500, 1500, 4000, 10000, 25000])}, + + # Research/Web + {"id": "rabbit_hole_certified", "name": "Rabbit Hole Certified", "description": "Search or extract enough web content to qualify as a research spiral.", "category": "Research/Web", "kind": "lifetime", "icon": "spiral", "threshold_metric": "total_web_calls", "tiers": tiers([400, 1200, 3000, 8000, 20000])}, + {"id": "citation_goblin", "name": "Citation Goblin", "description": "Extract enough web pages to become a tiny librarian.", "category": "Research/Web", "kind": "lifetime", "icon": "quote", "threshold_metric": "total_web_extract_calls", "tiers": tiers([100, 300, 1000, 3000, 8000])}, + {"id": "docs_archaeologist", "name": "Docs Archaeologist", "description": "Dig through documentation sources over and over.", "category": "Research/Web", "kind": "lifetime", "icon": "compass", "threshold_metric": "docs_activity_events", "tiers": tiers([5000, 15000, 40000, 100000, 250000])}, + {"id": "browser_possession", "name": "Browser Possession", "description": "Possess a browser through automation repeatedly.", "category": "Research/Web", "kind": "lifetime", "icon": "browser", "threshold_metric": "browser_calls", "tiers": tiers([75, 200, 600, 1500, 4000])}, + + # Tool Mastery + {"id": "terminal_goblin", "name": "Terminal Goblin", "description": "Spend serious time in shell-land.", "category": "Tool Mastery", "kind": "lifetime", "icon": "terminal", "threshold_metric": "total_terminal_calls", "tiers": tiers([750, 2000, 6000, 15000, 50000])}, + {"id": "patch_wizard", "name": "Patch Wizard", "description": "Bend files to your will with targeted patches.", "category": "Tool Mastery", "kind": "lifetime", "icon": "wand", "threshold_metric": "total_patch_calls", "tiers": tiers([250, 750, 2000, 6000, 15000])}, + {"id": "file_archaeologist", "name": "File Archaeologist", "description": "Dig through the filesystem with reads and searches.", "category": "Tool Mastery", "kind": "lifetime", "icon": "folder", "threshold_metric": "total_file_reads_searches", "tiers": tiers([750, 2000, 6000, 15000, 50000])}, + {"id": "image_whisperer", "name": "Image Whisperer", "description": "Use image generation or vision tools enough for visual work.", "category": "Tool Mastery", "kind": "lifetime", "icon": "eye", "threshold_metric": "image_vision_calls", "tiers": tiers([100, 300, 1000, 3000, 8000])}, + {"id": "voice_of_the_machine", "name": "Voice Of The Machine", "description": "Use text-to-speech or voice tooling repeatedly.", "category": "Tool Mastery", "kind": "lifetime", "icon": "wave", "threshold_metric": "tts_calls", "tiers": tiers([10, 30, 100, 300, 800])}, + + # Model Lore + {"id": "model_hopper", "name": "Model Hopper", "description": "Switch or inspect providers/models enough to count as a habit.", "category": "Model Lore", "kind": "lifetime", "icon": "swap", "threshold_metric": "model_events", "tiers": tiers([10000, 30000, 80000, 200000, 500000])}, + {"id": "openrouter_enjoyer", "name": "OpenRouter Enjoyer", "description": "Route model work through OpenRouter repeatedly.", "category": "Model Lore", "kind": "lifetime", "icon": "router", "threshold_metric": "openrouter_events", "tiers": tiers([250, 750, 2000, 6000, 15000])}, + {"id": "codex_conjurer", "name": "Codex Conjurer", "description": "Summon Codex-flavored assistance often enough for a ritual.", "category": "Model Lore", "kind": "lifetime", "icon": "codex", "threshold_metric": "codex_events", "tiers": tiers([500, 1500, 4000, 10000, 25000])}, + {"id": "multi_model_mage", "name": "Multi-Model Mage", "description": "Use a real spread of distinct model names across Hermes history.", "category": "Model Lore", "kind": "lifetime", "icon": "prism", "threshold_metric": "distinct_model_count", "tiers": tiers([10, 20, 40, 80, 160])}, + {"id": "five_model_flight", "name": "Five-Model Flight", "description": "Try at least five distinct LLMs instead of marrying the first model that answers.", "category": "Model Lore", "kind": "lifetime", "icon": "prism", "threshold_metric": "distinct_model_count", "tiers": tiers([5, 10, 20, 40, 80])}, + {"id": "provider_polyglot", "name": "Provider Polyglot", "description": "Use models from multiple providers across Hermes history.", "category": "Model Lore", "kind": "lifetime", "icon": "swap", "threshold_metric": "distinct_provider_count", "tiers": tiers([2, 3, 5, 8, 12])}, + {"id": "model_sommelier", "name": "Model Sommelier", "description": "Taste enough model/provider conversations to develop preferences.", "category": "Model Lore", "kind": "lifetime", "icon": "wine", "threshold_metric": "model_events", "tiers": tiers([250, 750, 2000, 6000, 15000])}, + {"id": "claude_confidant", "name": "Claude Confidant", "description": "Bring Claude-flavored reasoning into the workflow repeatedly.", "category": "Model Lore", "kind": "lifetime", "icon": "quote", "threshold_metric": "claude_events", "tiers": tiers([50, 150, 500, 1500, 4000])}, + {"id": "gemini_cartographer", "name": "Gemini Cartographer", "description": "Map enough Gemini-related workflows to know the terrain.", "category": "Model Lore", "kind": "lifetime", "icon": "compass", "threshold_metric": "gemini_events", "tiers": tiers([50, 150, 500, 1500, 4000])}, + {"id": "open_weights_pilgrim", "name": "Open Weights Pilgrim", "description": "Actually chat with local/open-weight models through Hermes session metadata.", "category": "Model Lore", "kind": "lifetime", "icon": "terminal", "threshold_metric": "local_model_chat_sessions", "tiers": tiers([1, 3, 10, 30, 100])}, + + # Workflow Intelligence + {"id": "toolset_cartographer", "name": "Toolset Cartographer", "description": "Navigate Hermes toolsets deliberately instead of treating tools as a blur.", "category": "Hermes Native", "kind": "lifetime", "icon": "compass", "threshold_metric": "toolset_events", "tiers": tiers([20, 60, 200, 600, 1500])}, + {"id": "config_surgeon", "name": "Config Surgeon", "description": "Operate on real config files, manifests, env files, and dashboard settings without flinching.", "category": "Hermes Native", "kind": "lifetime", "icon": "key", "threshold_metric": "config_events", "tiers": tiers([100, 300, 1000, 3000, 10000])}, + {"id": "rebase_acrobat", "name": "Rebase Acrobat", "description": "Handle real git history surgery: rebase, conflict, merge, fetch, push.", "category": "Vibe Coding", "kind": "lifetime", "icon": "branch", "threshold_metric": "git_history_events", "tiers": tiers([10, 30, 100, 300, 800])}, + {"id": "test_suite_tamer", "name": "Test Suite Tamer", "description": "Run enough verification commands that green text becomes part of the ritual.", "category": "Tool Mastery", "kind": "lifetime", "icon": "daemon", "threshold_metric": "test_events", "tiers": tiers([100, 300, 800, 2400, 6000])}, + {"id": "screenshot_hunter", "name": "Screenshot Hunter", "description": "Capture, inspect, and polish visual proof instead of just claiming it works.", "category": "Tool Mastery", "kind": "lifetime", "icon": "eye", "threshold_metric": "screenshot_events", "tiers": tiers([50, 150, 500, 1500, 5000])}, + + # Lifestyle + {"id": "marathon_operator", "name": "Marathon Operator", "description": "Accumulate a serious number of Hermes sessions.", "category": "Lifestyle", "kind": "lifetime", "icon": "marathon", "threshold_metric": "session_count", "tiers": tiers([75, 200, 500, 1500, 5000])}, + {"id": "weekend_warrior", "name": "Weekend Warrior", "description": "Run Hermes on weekends enough times to make it a lifestyle.", "category": "Lifestyle", "kind": "lifetime", "icon": "calendar", "threshold_metric": "weekend_sessions", "tiers": tiers([25, 75, 200, 600, 1500])}, + {"id": "night_shift_operator", "name": "Night Shift Operator", "description": "Run sessions during gremlin hours repeatedly.", "category": "Lifestyle", "kind": "lifetime", "icon": "moon", "threshold_metric": "night_sessions", "tiers": tiers([25, 75, 200, 600, 1500])}, + {"id": "cache_hit_appreciator", "name": "Cache Hit Appreciator", "description": "Notice or benefit from prompt/cache behavior.", "category": "Lifestyle", "kind": "lifetime", "icon": "cache", "secret": True, "threshold_metric": "cache_events", "tiers": tiers([100, 300, 1000, 3000, 8000])}, +] + + +def state_path() -> Path: + return Path.home() / ".hermes" / "plugins" / "hermes-achievements" / "state.json" + + +def snapshot_path() -> Path: + return Path.home() / ".hermes" / "plugins" / "hermes-achievements" / "scan_snapshot.json" + + +def checkpoint_path() -> Path: + return Path.home() / ".hermes" / "plugins" / "hermes-achievements" / "scan_checkpoint.json" + + +def load_state() -> Dict[str, Any]: + path = state_path() + if not path.exists(): + return {"unlocks": {}} + try: + return json.loads(path.read_text()) + except Exception: + return {"unlocks": {}} + + +def save_state(state: Dict[str, Any]) -> None: + path = state_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2, sort_keys=True)) + + +def _json_safe(value: Any) -> Any: + if isinstance(value, dict): + return {k: _json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(v) for v in value] + if isinstance(value, set): + return sorted(_json_safe(v) for v in value) + return value + + +def load_snapshot() -> Optional[Dict[str, Any]]: + path = snapshot_path() + if not path.exists(): + return None + try: + data = json.loads(path.read_text()) + if isinstance(data, dict): + return data + except Exception: + return None + return None + + +def save_snapshot(data: Dict[str, Any]) -> None: + path = snapshot_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(_json_safe(data), indent=2, sort_keys=True)) + + +def load_checkpoint() -> Dict[str, Any]: + path = checkpoint_path() + if not path.exists(): + return {"schema_version": 1, "generated_at": 0, "sessions": {}} + try: + data = json.loads(path.read_text()) + if isinstance(data, dict): + data.setdefault("schema_version", 1) + data.setdefault("generated_at", 0) + data.setdefault("sessions", {}) + if isinstance(data.get("sessions"), dict): + return data + except Exception: + pass + return {"schema_version": 1, "generated_at": 0, "sessions": {}} + + +def save_checkpoint(data: Dict[str, Any]) -> None: + path = checkpoint_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(_json_safe(data), indent=2, sort_keys=True)) + + +def session_fingerprint(meta: Dict[str, Any]) -> Dict[str, Any]: + return { + "last_active": meta.get("last_active"), + "started_at": meta.get("started_at"), + "model": meta.get("model"), + "title": meta.get("title") or meta.get("preview") or "Untitled", + } + + +def _cache_is_fresh(now: int) -> bool: + return _SNAPSHOT_CACHE is not None and (now - _SNAPSHOT_CACHE_AT) <= SNAPSHOT_TTL_SECONDS + + +def _is_snapshot_stale(snapshot: Optional[Dict[str, Any]], now: Optional[int] = None) -> bool: + if not isinstance(snapshot, dict): + return True + ts = int(snapshot.get("generated_at") or 0) + current = int(now or time.time()) + if ts <= 0: + return True + return (current - ts) > SNAPSHOT_TTL_SECONDS + + +def _scan_status_payload(now: Optional[int] = None) -> Dict[str, Any]: + current = int(now or time.time()) + snap = _SNAPSHOT_CACHE if isinstance(_SNAPSHOT_CACHE, dict) else None + generated_at = int((snap or {}).get("generated_at") or 0) if snap else 0 + return { + "state": _SCAN_STATUS.get("state", "idle"), + "started_at": _SCAN_STATUS.get("started_at"), + "finished_at": _SCAN_STATUS.get("finished_at"), + "last_error": _SCAN_STATUS.get("last_error"), + "last_duration_ms": _SCAN_STATUS.get("last_duration_ms"), + "run_count": _SCAN_STATUS.get("run_count", 0), + "ttl_seconds": SNAPSHOT_TTL_SECONDS, + "snapshot_generated_at": generated_at or None, + "snapshot_age_seconds": (current - generated_at) if generated_at else None, + "snapshot_stale": _is_snapshot_stale(snap, current), + } + + +def _tool_name_from_call(call: Any) -> Optional[str]: + if not isinstance(call, dict): + return None + fn = call.get("function") or {} + return call.get("name") or fn.get("name") + + +def _content(msg: Dict[str, Any]) -> str: + content = msg.get("content") + if content is None: + return "" + if isinstance(content, str): + return content + try: + return json.dumps(content) + except Exception: + return str(content) + + +def _count_tool(tool_names: List[str], *needles: str) -> int: + lowered = [name.lower() for name in tool_names] + return sum(1 for name in lowered if any(needle in name for needle in needles)) + + +def model_provider(model_name: str) -> Optional[str]: + name = (model_name or "").strip().lower() + if not name or name == "none": + return None + if "/" in name: + return name.split("/", 1)[0] + for provider in ["openai", "anthropic", "google", "gemini", "mistral", "meta", "qwen", "deepseek", "xai", "nous", "ollama", "groq", "openrouter", "codex"]: + if provider in name: + return "google" if provider == "gemini" else provider + return name.split(":", 1)[0].split("-", 1)[0] + + +def is_local_model_name(model_name: str) -> bool: + name = (model_name or "").strip().lower() + if not name or name == "none": + return False + local_markers = ["ollama", "llama.cpp", "localhost", "127.0.0.1", "local/", "local:", "gguf", "vllm-local"] + return any(marker in name for marker in local_markers) + + +def analyze_messages(session_id: str, title: str, messages: List[Dict[str, Any]]) -> Dict[str, Any]: + tool_names: Set[str] = set() + tool_sequence: List[str] = [] + files_touched: Set[str] = set() + full_text_parts: List[str] = [] + error_count = 0 + + for msg in messages: + text = _content(msg) + full_text_parts.append(text) + if msg.get("tool_name"): + name = str(msg["tool_name"]) + tool_names.add(name) + # Tool result rows name the tool that already appeared in the assistant tool_calls. + # Keep it for distinct-tool detection, but do not double-count it as a new call. + if msg.get("role") != "tool": + tool_sequence.append(name) + for call in msg.get("tool_calls") or []: + name = _tool_name_from_call(call) + if name: + tool_names.add(name) + tool_sequence.append(name) + if ERROR_RE.search(text): + error_count += 1 + blob = text + if msg.get("tool_calls"): + blob += " " + json.dumps(msg.get("tool_calls"), default=str) + files_touched.update(FILE_RE.findall(blob)) + + full_text = "\n".join(full_text_parts) + lower = full_text.lower() + terminal_calls = _count_tool(tool_sequence, "terminal") + web_calls = _count_tool(tool_sequence, "web_search", "web_extract") + web_extract_calls = _count_tool(tool_sequence, "web_extract") + browser_calls = _count_tool(tool_sequence, "browser") + web_browser_calls = web_calls + browser_calls + patch_calls = _count_tool(tool_sequence, "patch") + file_reads_searches = _count_tool(tool_sequence, "read_file", "search_files") + file_tool_calls = _count_tool(tool_sequence, "read_file", "write_file", "patch", "search_files") + delegate_calls = _count_tool(tool_sequence, "delegate_task") + process_calls = _count_tool(tool_sequence, "process") + len(re.findall(r"background\s*=\s*true", full_text, re.I)) + cron_calls = _count_tool(tool_sequence, "cronjob") + image_vision_calls = _count_tool(tool_sequence, "image", "vision") + tts_calls = _count_tool(tool_sequence, "tts", "text_to_speech") + skill_events = _count_tool(tool_sequence, "skill") + len(re.findall(r"\bskill", lower)) + skill_manage_events = _count_tool(tool_sequence, "skill_manage") + memory_events = _count_tool(tool_sequence, "memory", "mnemosyne") + memory_write_events = _count_tool(tool_sequence, "mnemosyne_remember", "memory") + + return { + "session_id": session_id, + "title": title or "Untitled session", + "message_count": len(messages), + "tool_call_count": len(tool_sequence), + "tool_names": tool_names, + "distinct_tool_count": len(tool_names), + "error_count": error_count, + "terminal_calls": terminal_calls, + "web_calls": web_calls, + "web_extract_calls": web_extract_calls, + "browser_calls": browser_calls, + "web_browser_calls": web_browser_calls, + "patch_calls": patch_calls, + "file_reads_searches": file_reads_searches, + "file_tool_calls": file_tool_calls, + "files_touched_count": len(files_touched), + "delegate_calls": delegate_calls, + "process_calls": process_calls, + "cron_calls": cron_calls, + "image_vision_calls": image_vision_calls, + "tts_calls": tts_calls, + "skill_events": skill_events, + "skill_manage_events": skill_manage_events, + "memory_events": memory_events, + "memory_write_events": memory_write_events, + "port_conflict": bool(PORT_RE.search(full_text)), + "port_conflict_events": 1 if PORT_RE.search(full_text) else 0, + "traceback_events": len(re.findall(r"traceback|exception", full_text, re.I)), + "log_read_events": len(re.findall(r"gateway\.log|errors\.log|agent\.log|/api/logs|\blogs\b", full_text, re.I)), + "permission_denied_events": len(re.findall(r"permission denied|eacces|operation not permitted", full_text, re.I)), + "install_error_events": 1 if INSTALL_RE.search(full_text) and ERROR_RE.search(full_text) else 0, + "install_success_events": 1 if INSTALL_RE.search(full_text) and SUCCESS_RE.search(full_text) else 0, + "restart_after_error_events": 1 if error_count and re.search(r"\brestart|reload|kill|start\b", full_text, re.I) else 0, + "env_var_error_events": len(re.findall(r"missing .*env|api key|environment variable|not configured|unauthorized|auth", full_text, re.I)), + "yaml_error_events": len(re.findall(r"yaml|yml|colon|parse error", full_text, re.I)) if ERROR_RE.search(full_text) else 0, + "docker_conflict_events": len(re.findall(r"docker.*(name|container).*already|container name conflict|Conflict\. The container", full_text, re.I)), + "frontend_activity_events": len(re.findall(r"\.(css|svg|tsx|jsx)|frontend|tailwind|react", full_text, re.I)), + "css_activity_events": len(re.findall(r"\.css|tailwind|style|className|visual", full_text, re.I)), + "git_events": len(re.findall(r"\bgit\s+(commit|push|merge|rebase|status|diff)", full_text, re.I)), + "tiny_patch_after_errors_events": 1 if error_count >= 5 and re.search(r"one character|single character|typo", full_text, re.I) else 0, + "context_events": len(re.findall(r"compress|context window|token|cache", full_text, re.I)), + "gateway_events": len(re.findall(r"gateway|discord|telegram|slack|api_server", full_text, re.I)), + "plugin_events": len(re.findall(r"plugin|dashboard-plugins|__HERMES_PLUGIN|manifest\.json", full_text, re.I)), + "rollback_events": len(re.findall(r"rollback|checkpoint", full_text, re.I)), + "docs_activity_events": len(re.findall(r"docs|documentation|docusaurus|README", full_text, re.I)), + "model_events": len(re.findall(r"model|provider|openrouter|codex|gemini|claude|anthropic|openai|mistral|qwen|deepseek|llama|ollama|vllm|gguf", full_text, re.I)), + "openrouter_events": len(re.findall(r"openrouter", full_text, re.I)), + "codex_events": len(re.findall(r"codex", full_text, re.I)), + "claude_events": len(re.findall(r"claude|anthropic", full_text, re.I)), + "gemini_events": len(re.findall(r"gemini|google ai|google model", full_text, re.I)), + "local_model_events": len(re.findall(r"ollama|llama\.cpp|gguf|vllm|local model|open[- ]weight|open weights", full_text, re.I)), + "toolset_events": len(re.findall(r"toolset|enabled_toolsets|browser tool|terminal tool|file tool|web tool", full_text, re.I)), + "config_events": len(re.findall(r"config\.ya?ml|\b[a-z0-9_-]+config\.(?:js|ts|json|ya?ml)|\.env(?:\b|\.)|manifest\.json|settings\.json|pyproject\.toml|package\.json", full_text, re.I)), + "git_history_events": len(re.findall(r"\bgit\s+(rebase|merge|fetch|pull|push|tag|checkout)|merge conflict|conflict\s*\(|rebase --continue", full_text, re.I)), + "test_events": len(re.findall(r"pytest|unittest|vitest|playwright|npm test|pnpm test|node --check|py_compile|tests? passed|\bOK\b", full_text, re.I)), + "screenshot_events": len(re.findall(r"screenshot|playwright|vision_analyze|browser_vision|\.png|image data", full_text, re.I)), + "release_events": len(re.findall(r"\bgit\s+tag|release|version bump|changelog|publish|pushed? tag", full_text, re.I)), + "cache_events": len(re.findall(r"cache hit|prompt caching|cache_read", full_text, re.I)), + "model_names": set(), + } + + +def evaluate_tiered(definition: Dict[str, Any], aggregate: Dict[str, Any]) -> Dict[str, Any]: + metric = definition["threshold_metric"] + progress = int(aggregate.get(metric, 0) or 0) + tiers_list = sorted(definition.get("tiers", []), key=lambda t: t["threshold"]) + achieved = [t for t in tiers_list if progress >= t["threshold"]] + next_tiers = [t for t in tiers_list if progress < t["threshold"]] + tier = achieved[-1]["name"] if achieved else None + next_tier = next_tiers[0]["name"] if next_tiers else None + next_threshold = next_tiers[0]["threshold"] if next_tiers else (tiers_list[-1]["threshold"] if tiers_list else 1) + current_threshold = achieved[-1]["threshold"] if achieved else 0 + denom = max(1, next_threshold - current_threshold) + pct = 100 if not next_tiers and achieved else max(0, min(99, math.floor(((progress - current_threshold) / denom) * 100))) + unlocked = bool(achieved) + discovered = bool(progress > 0) + state = "unlocked" if unlocked else ("secret" if definition.get("secret") and not discovered else "discovered") + return {"unlocked": unlocked, "discovered": discovered or not definition.get("secret"), "state": state, "tier": tier, "progress": progress, "next_tier": next_tier, "next_threshold": next_threshold, "progress_pct": pct} + + +def evaluate_requirements(definition: Dict[str, Any], aggregate: Dict[str, Any]) -> Dict[str, Any]: + requirements = definition.get("requirements", []) + if not requirements: + return {"unlocked": False, "discovered": not definition.get("secret"), "state": "secret" if definition.get("secret") else "discovered", "tier": None, "progress": 0, "next_tier": None, "next_threshold": 1, "progress_pct": 0} + parts = [] + any_progress = False + complete = True + for requirement in requirements: + value = int(aggregate.get(requirement["metric"], 0) or 0) + threshold = int(requirement.get("gte", 1)) + any_progress = any_progress or value > 0 + complete = complete and value >= threshold + parts.append(min(1.0, value / max(1, threshold))) + pct = math.floor((sum(parts) / len(parts)) * 100) + state = "unlocked" if complete else ("secret" if definition.get("secret") and not any_progress else "discovered") + return {"unlocked": complete, "discovered": any_progress or not definition.get("secret"), "state": state, "tier": None, "progress": pct, "next_tier": None, "next_threshold": 100, "progress_pct": 100 if complete else min(99, pct)} + + +def evaluate_boolean(definition: Dict[str, Any], aggregate: Dict[str, Any]) -> Dict[str, Any]: + # Backward-compatible helper for old tests/definitions. New catalog avoids simple booleans. + unlocked = bool(aggregate.get(definition["metric"])) + return {"unlocked": unlocked, "discovered": True, "state": "unlocked" if unlocked else "discovered", "tier": None, "progress": 1 if unlocked else 0, "next_tier": None, "next_threshold": 1, "progress_pct": 100 if unlocked else 0} + + +METRIC_LABELS = { + "max_tool_calls_in_session": "tool calls in one session", + "max_distinct_tools_in_session": "distinct Hermes tools used in one session", + "max_terminal_calls_in_session": "terminal calls in one session", + "max_file_tool_calls_in_session": "file/search/patch calls in one session", + "max_web_browser_calls_in_session": "web search/extract or browser calls in one session", + "max_messages_in_session": "messages in one session", + "max_files_touched_in_session": "files touched in one session", + "total_delegate_calls": "lifetime delegate_task calls", + "total_process_calls": "lifetime background process operations", + "total_cron_calls": "lifetime scheduled-job operations", + "total_errors": "error/failed/traceback messages observed", + "traceback_events": "traceback or exception mentions", + "log_read_events": "log inspections", + "port_conflict_events": "dev-server port conflict detections", + "permission_denied_events": "permission-denied errors", + "install_error_events": "package-install failures", + "install_success_events": "successful package installs after package work", + "restart_after_error_events": "restart/reload actions after error clusters", + "env_var_error_events": "missing auth/config/environment-variable events", + "yaml_error_events": "YAML/config parse incidents", + "docker_conflict_events": "Docker/container-name conflicts", + "frontend_activity_events": "frontend/CSS/SVG/React activity mentions", + "css_activity_events": "CSS, styling, Tailwind, or className activity", + "git_events": "git workflow commands", + "tiny_patch_after_errors_events": "tiny typo-style fixes after error clusters", + "skill_events": "Hermes skill mentions or tool use", + "skill_manage_events": "skill_manage create/patch/delete operations", + "memory_events": "memory or Mnemosyne tool events", + "memory_write_events": "durable memory writes", + "context_events": "context, compression, token, or cache-pressure mentions", + "gateway_events": "gateway/API/chat-platform activity", + "plugin_events": "dashboard plugin development or usage signals", + "rollback_events": "rollback/checkpoint recovery mentions", + "docs_activity_events": "documentation/README/docs activity", + "model_events": "model/provider-related activity", + "openrouter_events": "OpenRouter mentions", + "codex_events": "Codex mentions", + "cache_events": "prompt-cache/cache-hit mentions", + "total_web_calls": "lifetime web_search/web_extract calls", + "total_web_extract_calls": "lifetime web_extract calls", + "browser_calls": "lifetime browser automation calls", + "total_tool_calls": "lifetime Hermes tool calls", + "total_terminal_calls": "lifetime terminal calls", + "total_patch_calls": "lifetime targeted patch edits", + "total_file_reads_searches": "lifetime read_file/search_files calls", + "image_vision_calls": "image generation or vision tool calls", + "tts_calls": "text-to-speech or voice tool calls", + "distinct_model_count": "distinct model names seen in session metadata", + "distinct_provider_count": "distinct model providers inferred from session metadata", + "claude_events": "Claude/Anthropic model mentions", + "gemini_events": "Gemini/Google model mentions", + "local_model_events": "local/open-weight model mentions", + "local_model_chat_sessions": "Hermes sessions whose model metadata is local/open-weight", + "toolset_events": "toolset or tool-family mentions", + "config_events": "configuration/environment/manifest activity", + "git_history_events": "git history operations such as rebase, merge, fetch, push, or tag", + "test_events": "test/check/verification command mentions", + "screenshot_events": "screenshot, Playwright, PNG, or vision-inspection activity", + "release_events": "release, version, publish, or git tag events", + "session_count": "Hermes sessions", + "weekend_sessions": "sessions started on weekends", + "night_sessions": "sessions started late night or before dawn", +} + + +def metric_label(metric: str) -> str: + return METRIC_LABELS.get(metric, metric.replace("_", " ")) + + +def criteria_for(definition: Dict[str, Any]) -> str: + if definition.get("secret") and definition.get("state") == "secret": + return "Secret: exact requirement hidden until Hermes sees the first matching signal. Keep using Hermes across debugging, tools, memory, skills, plugins, and model workflows to reveal it." + secret_prefix = "" + if "threshold_metric" in definition: + tiers_list = sorted(definition.get("tiers", []), key=lambda t: t["threshold"]) + if not tiers_list: + return secret_prefix + "Requirement: use Hermes in the matching workflow." + metric = metric_label(definition["threshold_metric"]) + ladder = ", ".join(f"{t['name']} {t['threshold']}" for t in tiers_list) + return secret_prefix + f"Requirement: {metric}. Tier ladder: {ladder}." + requirements = definition.get("requirements") or [] + if requirements: + parts = [f"{metric_label(r['metric'])} ≥ {int(r.get('gte', 1))}" for r in requirements] + return secret_prefix + "Requirement: " + "; ".join(parts) + "." + return secret_prefix + "Requirement: complete the matching Hermes behavior." + + +def display_achievement(item: Dict[str, Any]) -> Dict[str, Any]: + clean = dict(item) + if clean.get("state") == "secret": + return {**clean, "name": "???", "description": "Secret achievement: hidden until Hermes detects the first relevant behavior in your session history.", "criteria": criteria_for(clean), "icon": "secret"} + clean["criteria"] = criteria_for(clean) + return clean + + +def scan_sessions( + limit: Optional[int] = None, + progress_callback: Optional[Any] = None, + progress_every: int = 250, +) -> Dict[str, Any]: + """Scan Hermes sessions and build per-session achievement stats. + + ``limit=None`` (the default) scans the ENTIRE session history. Prior + versions capped this at 200, which silently reduced achievement totals + to ~2% of history on long-running installs and made lifetime badges + unreachable. SQLite's ``LIMIT -1`` means "unlimited"; we map ``None`` + and non-positive values to ``-1`` so callers get the full catalog. + + Warm scans stay cheap: the checkpoint cache stores per-session stats + keyed by ``(started_at, last_active)`` and only re-analyzes sessions + whose fingerprint changed. Cold scans on large histories (thousands + of sessions) take tens of seconds to several minutes; ``evaluate_all`` + runs them on a background thread so the dashboard UI never blocks on + the first request. + + ``progress_callback(partial_sessions, scanned_so_far, total)`` — when + provided, fires every ``progress_every`` sessions with the sessions + analyzed so far and progress counters. Background scans use this to + publish intermediate snapshots so a long cold scan surfaces badges + incrementally on each dashboard refresh instead of going all-at-once + at the end. + """ + try: + from hermes_state import SessionDB + except Exception as exc: + return {"sessions": [], "aggregate": {}, "error": f"Could not import SessionDB: {exc}", "scan_meta": {"mode": "failed", "sessions_total": 0, "sessions_rescanned": 0, "sessions_reused": 0}} + + checkpoint = load_checkpoint() + previous_sessions = checkpoint.get("sessions") if isinstance(checkpoint.get("sessions"), dict) else {} + reused = 0 + rescanned = 0 + + # SQLite treats LIMIT -1 as "no limit". Map None / <=0 to -1 so the + # full session history flows through unless the caller explicitly + # requests a small sample (e.g. a smoke test). + db_limit = -1 if (limit is None or limit <= 0) else int(limit) + + db = SessionDB() + try: + sessions_meta = db.list_sessions_rich(limit=db_limit, include_children=True, project_compression_tips=False) + total_sessions = len(sessions_meta) + sessions: List[Dict[str, Any]] = [] + checkpoint_sessions: Dict[str, Any] = {} + for idx, meta in enumerate(sessions_meta, start=1): + sid = meta.get("id") + if not sid: + continue + fp = session_fingerprint(meta) + cached = previous_sessions.get(sid) if isinstance(previous_sessions, dict) else None + cached_stats = cached.get("stats") if isinstance(cached, dict) else None + cached_fp = cached.get("fingerprint") if isinstance(cached, dict) else None + + if isinstance(cached_stats, dict) and cached_fp == fp: + stats = dict(cached_stats) + reused += 1 + else: + messages = db.get_messages(sid) + stats = analyze_messages(sid, meta.get("title") or meta.get("preview") or "Untitled", messages) + rescanned += 1 + + stats["session_id"] = sid + stats["title"] = meta.get("title") or meta.get("preview") or stats.get("title") or "Untitled" + stats["started_at"] = meta.get("started_at") + stats["last_active"] = meta.get("last_active") + stats["source"] = meta.get("source") + if meta.get("model"): + stats.setdefault("model_names", set()) + if isinstance(stats["model_names"], set): + stats["model_names"].add(str(meta.get("model"))) + elif isinstance(stats["model_names"], list): + if str(meta.get("model")) not in stats["model_names"]: + stats["model_names"].append(str(meta.get("model"))) + else: + stats["model_names"] = {str(meta.get("model"))} + + sessions.append(stats) + checkpoint_sessions[sid] = {"fingerprint": fp, "stats": _json_safe(stats)} + + if progress_callback is not None and progress_every > 0 and (idx % progress_every == 0) and idx < total_sessions: + try: + progress_callback(list(sessions), idx, total_sessions) + except Exception: + # Progress callbacks are advisory — a broken publisher + # must never abort the scan itself. + pass + + save_checkpoint({ + "schema_version": 1, + "generated_at": int(time.time()), + "sessions": checkpoint_sessions, + }) + finally: + close = getattr(db, "close", None) + if close: + close() + return { + "sessions": sessions, + "aggregate": aggregate_stats(sessions), + "scan_meta": { + "mode": "incremental" if reused > 0 else "full", + "sessions_total": len(sessions), + "sessions_rescanned": rescanned, + "sessions_reused": reused, + "sessions_scanned_so_far": len(sessions), + "sessions_expected_total": total_sessions, + }, + } + + +def aggregate_stats(sessions: List[Dict[str, Any]]) -> Dict[str, Any]: + agg: Dict[str, Any] = { + "session_count": len(sessions), + "max_tool_calls_in_session": 0, + "max_distinct_tools_in_session": 0, + "max_messages_in_session": 0, + "max_terminal_calls_in_session": 0, + "max_file_tool_calls_in_session": 0, + "max_web_calls_in_session": 0, + "max_web_browser_calls_in_session": 0, + "max_files_touched_in_session": 0, + "total_errors": 0, + "total_tool_calls": 0, + "total_terminal_calls": 0, + "total_web_calls": 0, + "total_web_extract_calls": 0, + "total_patch_calls": 0, + "total_file_reads_searches": 0, + "total_delegate_calls": 0, + "total_process_calls": 0, + "total_cron_calls": 0, + "browser_calls": 0, + "image_vision_calls": 0, + "tts_calls": 0, + "distinct_model_count": 0, + "distinct_provider_count": 0, + "local_model_chat_sessions": 0, + "weekend_sessions": 0, + "night_sessions": 0, + } + sum_keys = [ + "traceback_events", "log_read_events", "port_conflict_events", "permission_denied_events", "install_error_events", "install_success_events", "restart_after_error_events", "env_var_error_events", "yaml_error_events", "docker_conflict_events", "frontend_activity_events", "css_activity_events", "git_events", "tiny_patch_after_errors_events", "skill_events", "skill_manage_events", "memory_events", "memory_write_events", "context_events", "gateway_events", "plugin_events", "rollback_events", "docs_activity_events", "model_events", "openrouter_events", "codex_events", "claude_events", "gemini_events", "local_model_events", "toolset_events", "config_events", "git_history_events", "test_events", "screenshot_events", "release_events", "cache_events", + ] + for key in sum_keys: + agg[key] = 0 + + model_names: Set[str] = set() + provider_names: Set[str] = set() + for s in sessions: + agg["max_tool_calls_in_session"] = max(agg["max_tool_calls_in_session"], s.get("tool_call_count", 0)) + agg["max_distinct_tools_in_session"] = max(agg["max_distinct_tools_in_session"], s.get("distinct_tool_count", 0)) + agg["max_messages_in_session"] = max(agg["max_messages_in_session"], s.get("message_count", 0)) + agg["max_terminal_calls_in_session"] = max(agg["max_terminal_calls_in_session"], s.get("terminal_calls", 0)) + agg["max_file_tool_calls_in_session"] = max(agg["max_file_tool_calls_in_session"], s.get("file_tool_calls", 0)) + agg["max_web_calls_in_session"] = max(agg["max_web_calls_in_session"], s.get("web_calls", 0)) + agg["max_web_browser_calls_in_session"] = max(agg["max_web_browser_calls_in_session"], s.get("web_browser_calls", 0)) + agg["max_files_touched_in_session"] = max(agg["max_files_touched_in_session"], s.get("files_touched_count", 0)) + agg["total_errors"] += s.get("error_count", 0) + agg["total_tool_calls"] += s.get("tool_call_count", 0) + agg["total_terminal_calls"] += s.get("terminal_calls", 0) + agg["total_web_calls"] += s.get("web_calls", 0) + agg["total_web_extract_calls"] += s.get("web_extract_calls", 0) + agg["total_patch_calls"] += s.get("patch_calls", 0) + agg["total_file_reads_searches"] += s.get("file_reads_searches", 0) + agg["total_delegate_calls"] += s.get("delegate_calls", 0) + agg["total_process_calls"] += s.get("process_calls", 0) + agg["total_cron_calls"] += s.get("cron_calls", 0) + agg["browser_calls"] += s.get("browser_calls", 0) + agg["image_vision_calls"] += s.get("image_vision_calls", 0) + agg["tts_calls"] += s.get("tts_calls", 0) + for key in sum_keys: + agg[key] += s.get(key, 0) + model_names.update(s.get("model_names") or set()) + session_models = s.get("model_names") or set() + for model_name in session_models: + provider = model_provider(str(model_name)) + if provider: + provider_names.add(provider) + if any(is_local_model_name(str(model_name)) for model_name in session_models): + agg["local_model_chat_sessions"] += 1 + if s.get("started_at"): + try: + lt = time.localtime(float(s.get("started_at"))) + if lt.tm_wday >= 5: + agg["weekend_sessions"] += 1 + if lt.tm_hour < 6 or lt.tm_hour >= 23: + agg["night_sessions"] += 1 + except Exception: + pass + agg["distinct_model_count"] = len({m for m in model_names if m and m != "None"}) + agg["distinct_provider_count"] = len(provider_names) + return agg + + +def evaluate_definition(definition: Dict[str, Any], aggregate: Dict[str, Any]) -> Dict[str, Any]: + if "threshold_metric" in definition: + return evaluate_tiered(definition, aggregate) + if "requirements" in definition: + return evaluate_requirements(definition, aggregate) + return evaluate_boolean(definition, aggregate) + + +def evidence_for(definition: Dict[str, Any], sessions: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if not sessions: + return None + metric = definition.get("threshold_metric") + metric_to_session_key = { + "max_tool_calls_in_session": "tool_call_count", + "max_distinct_tools_in_session": "distinct_tool_count", + "max_messages_in_session": "message_count", + "max_terminal_calls_in_session": "terminal_calls", + "max_file_tool_calls_in_session": "file_tool_calls", + "max_web_calls_in_session": "web_calls", + "max_web_browser_calls_in_session": "web_browser_calls", + "max_files_touched_in_session": "files_touched_count", + } + if metric in metric_to_session_key: + key = metric_to_session_key[metric] + s = max(sessions, key=lambda x: x.get(key, 0)) + return {"session_id": s.get("session_id"), "title": s.get("title"), "value": s.get(key, 0)} + return None + + +def _compute_from_scan(scan: Dict[str, Any], *, is_partial: bool = False) -> Dict[str, Any]: + """Evaluate every achievement definition against a scan result. + + Used by ``compute_all`` for finished scans AND by the background + progress callback for partial, in-flight snapshots. ``is_partial=True`` + skips persisting ``state.json`` unlocks — we don't want to record an + "unlock time" based on half a scan that a later session might shift. + """ + aggregate = scan.get("aggregate", {}) + state = load_state() if not is_partial else {"unlocks": {}} + unlocks = state.setdefault("unlocks", {}) + now = int(time.time()) + evaluated = [] + for definition in ACHIEVEMENTS: + result = evaluate_definition(definition, aggregate) + unlock_id = definition["id"] + if not is_partial and result["unlocked"] and unlock_id not in unlocks: + unlocks[unlock_id] = {"unlocked_at": now, "first_tier": result.get("tier"), "evidence": evidence_for(definition, scan.get("sessions", []))} + item = {**definition, **result} + if result["unlocked"]: + item["unlocked_at"] = unlocks.get(unlock_id, {}).get("unlocked_at") + item["evidence"] = unlocks.get(unlock_id, {}).get("evidence") or evidence_for(definition, scan.get("sessions", [])) + evaluated.append(display_achievement(item)) + if not is_partial: + save_state(state) + unlocked = [a for a in evaluated if a["unlocked"]] + discovered = [a for a in evaluated if a.get("state") == "discovered"] + secret = [a for a in evaluated if a.get("state") == "secret"] + return { + "achievements": evaluated, + "sessions": scan.get("sessions", []), + "aggregate": aggregate, + "scan_meta": scan.get("scan_meta", {}), + "error": scan.get("error"), + "unlocked_count": len(unlocked), + "discovered_count": len(discovered), + "secret_count": len(secret), + "total_count": len(evaluated), + "generated_at": now, + } + + +def compute_all(progress_callback: Optional[Any] = None, progress_every: int = 250) -> Dict[str, Any]: + scan = scan_sessions(progress_callback=progress_callback, progress_every=progress_every) + return _compute_from_scan(scan, is_partial=False) + + +_BACKGROUND_SCAN_THREAD: Optional[threading.Thread] = None +_BACKGROUND_SCAN_LOCK = threading.Lock() + + +def _build_pending_snapshot(now: int) -> Dict[str, Any]: + """Placeholder payload used while the first-ever scan is still running. + + Returns a structurally-complete response so the dashboard UI can render + an empty achievement list + spinner without special-casing "no data yet". + """ + evaluated = [display_achievement({**d, **{"unlocked": False, "discovered": False, "state": "secret" if d.get("secret") else "discovered", "progress": 0, "progress_pct": 0, "next_tier": (d.get("tiers") or [{}])[0].get("name"), "next_threshold": (d.get("tiers") or [{}])[0].get("threshold", 1), "tier": None}}) for d in ACHIEVEMENTS] + return { + "achievements": evaluated, + "sessions": [], + "aggregate": {}, + "scan_meta": {"mode": "pending", "sessions_total": 0, "sessions_rescanned": 0, "sessions_reused": 0}, + "error": None, + "unlocked_count": 0, + "discovered_count": sum(1 for a in evaluated if a.get("state") == "discovered"), + "secret_count": sum(1 for a in evaluated if a.get("state") == "secret"), + "total_count": len(evaluated), + "generated_at": now, + } + + +def _run_scan_and_update_cache(publish_partial_snapshots: bool = True) -> None: + """Execute a scan + snapshot update. Called synchronously or from a thread. + + When ``publish_partial_snapshots=True`` (the default for background + scans), the scanner periodically publishes an in-progress snapshot to + ``_SNAPSHOT_CACHE`` so each dashboard refresh during a long cold scan + shows more progress — badges unlock incrementally as sessions stream + in, instead of staying at zero for minutes and then jumping to the + final state. Synchronous /rescan callers pass ``False`` because they + block on the full result anyway. + """ + global _SNAPSHOT_CACHE, _SNAPSHOT_CACHE_AT + with _SCAN_LOCK: + started = int(time.time()) + _SCAN_STATUS["state"] = "running" + _SCAN_STATUS["started_at"] = started + _SCAN_STATUS["last_error"] = None + + def _publish_partial(partial_sessions, scanned_so_far, total): + global _SNAPSHOT_CACHE, _SNAPSHOT_CACHE_AT + try: + partial_scan = { + "sessions": partial_sessions, + "aggregate": aggregate_stats(partial_sessions), + "scan_meta": { + "mode": "in_progress", + "sessions_total": scanned_so_far, + "sessions_rescanned": 0, + "sessions_reused": 0, + "sessions_scanned_so_far": scanned_so_far, + "sessions_expected_total": total, + }, + } + partial = _compute_from_scan(partial_scan, is_partial=True) + # Keep the cache in the 'stale' TTL regime by NOT bumping + # _SNAPSHOT_CACHE_AT to "now". The UI treats partial + # results as stale so it keeps polling /scan-status and + # sees the final snapshot when the scan finishes. In-flight + # partials are visible but are never mistaken for finished. + _SNAPSHOT_CACHE = _json_safe(partial) + _SNAPSHOT_CACHE_AT = 0 + except Exception: + # Intermediate publication is best-effort; don't kill the scan. + pass + + callback = _publish_partial if publish_partial_snapshots else None + try: + computed = compute_all(progress_callback=callback) + _SNAPSHOT_CACHE = _json_safe(computed) + _SNAPSHOT_CACHE_AT = int(_SNAPSHOT_CACHE.get("generated_at") or int(time.time())) + save_snapshot(_SNAPSHOT_CACHE) + _SCAN_STATUS["state"] = "idle" + except Exception as exc: + _SCAN_STATUS["state"] = "failed" + _SCAN_STATUS["last_error"] = str(exc) + finally: + _SCAN_STATUS["finished_at"] = int(time.time()) + _SCAN_STATUS["last_duration_ms"] = int((_SCAN_STATUS["finished_at"] - started) * 1000) + _SCAN_STATUS["run_count"] = int(_SCAN_STATUS.get("run_count", 0)) + 1 + + +def _start_background_scan() -> None: + """Kick off a scan in a daemon thread if one isn't already running. + + Idempotent: concurrent callers see the in-flight thread and return + immediately. The thread updates ``_SNAPSHOT_CACHE`` on completion so + subsequent ``/achievements`` requests see fresh data. While running, + it also publishes partial snapshots every ~250 sessions so the UI + reflects incremental progress on long cold scans. + """ + global _BACKGROUND_SCAN_THREAD + with _BACKGROUND_SCAN_LOCK: + existing = _BACKGROUND_SCAN_THREAD + if existing is not None and existing.is_alive(): + return + thread = threading.Thread( + target=_run_scan_and_update_cache, + kwargs={"publish_partial_snapshots": True}, + name="hermes-achievements-scan", + daemon=True, + ) + _BACKGROUND_SCAN_THREAD = thread + thread.start() + + +def evaluate_all(force: bool = False) -> Dict[str, Any]: + """Return the current achievements payload. + + Behavior matrix: + + * Fresh in-memory cache → return it instantly. + * Stale on-disk snapshot → load it, kick a background rescan, return + the stale data (UI decorates it with ``is_stale=True``). + * No snapshot yet (first-ever run) → kick a background scan, return + an empty-but-valid "pending" payload so the UI can render a spinner + without blocking. + * ``force=True`` (manual /rescan) → run synchronously, block the + caller, replace the cache. + + Warm scans stay cheap (the checkpoint cache reuses per-session stats). + Cold scans on 8000+ session databases take minutes; the background + thread prevents that from ever blocking the dashboard request path. + """ + global _SNAPSHOT_CACHE, _SNAPSHOT_CACHE_AT + now = int(time.time()) + + if not force and _cache_is_fresh(now): + return _SNAPSHOT_CACHE or {} + + # Lazy-load persisted snapshot from disk so fresh process starts + # don't have to wait for a scan to serve cached data. + if _SNAPSHOT_CACHE is None: + persisted = load_snapshot() + if isinstance(persisted, dict): + generated_at = int(persisted.get("generated_at") or 0) + _SNAPSHOT_CACHE = persisted + _SNAPSHOT_CACHE_AT = generated_at or now + + if force: + # Manual /rescan — block the caller, synchronous scan path. + # No partial publishing: the caller is waiting for the final result. + _run_scan_and_update_cache(publish_partial_snapshots=False) + if _SNAPSHOT_CACHE is not None: + return _SNAPSHOT_CACHE + # Scan failed with no prior cache — surface empty payload. + return _build_pending_snapshot(now) + + # Non-force path: serve whatever we have and refresh in background. + if _SNAPSHOT_CACHE is not None: + if not _cache_is_fresh(now): + _start_background_scan() + return _SNAPSHOT_CACHE + + # First-ever run on this machine — no snapshot yet. Kick off a scan + # and return a pending placeholder. The UI polls /scan-status and + # re-fetches /achievements when the scan completes. + _start_background_scan() + return _build_pending_snapshot(now) + + +@router.get("/achievements") +async def achievements(): + data = evaluate_all() + payload = {k: data[k] for k in ["achievements", "unlocked_count", "discovered_count", "secret_count", "total_count", "error", "generated_at"] if k in data} + payload["is_stale"] = _is_snapshot_stale(data) + payload["scan_meta"] = { + **(data.get("scan_meta") or {}), + "status": _scan_status_payload(), + } + return payload + + +@router.get("/scan-status") +async def scan_status(): + return _scan_status_payload() + + +@router.get("/recent-unlocks") +async def recent_unlocks(): + data = evaluate_all() + return sorted([a for a in data["achievements"] if a["unlocked"]], key=lambda a: a.get("unlocked_at") or 0, reverse=True)[:20] + + +@router.get("/sessions/{session_id}/badges") +async def session_badges(session_id: str): + data = evaluate_all() + session = next((s for s in data["sessions"] if s["session_id"] == session_id), None) + if not session: + return {"session_id": session_id, "badges": []} + aggregate = aggregate_stats([session]) + badges = [] + for definition in ACHIEVEMENTS: + result = evaluate_definition(definition, aggregate) + if result["unlocked"]: + badges.append(display_achievement({**definition, **result})) + return {"session_id": session_id, "badges": badges} + + +@router.post("/rescan") +async def rescan(): + return {"ok": True, **evaluate_all(force=True)} + + +@router.post("/reset-state") +async def reset_state(): + global _SNAPSHOT_CACHE, _SNAPSHOT_CACHE_AT + save_state({"unlocks": {}}) + _SNAPSHOT_CACHE = None + _SNAPSHOT_CACHE_AT = 0 + _SCAN_STATUS["state"] = "idle" + _SCAN_STATUS["started_at"] = None + _SCAN_STATUS["finished_at"] = None + _SCAN_STATUS["last_error"] = None + _SCAN_STATUS["last_duration_ms"] = None + try: + snapshot_path().unlink(missing_ok=True) + except Exception: + pass + try: + checkpoint_path().unlink(missing_ok=True) + except Exception: + pass + return {"ok": True} diff --git a/plugins/hermes-achievements/docs/achievements-performance-implementation-plan.md b/plugins/hermes-achievements/docs/achievements-performance-implementation-plan.md new file mode 100644 index 0000000000..76336b9d2a --- /dev/null +++ b/plugins/hermes-achievements/docs/achievements-performance-implementation-plan.md @@ -0,0 +1,157 @@ +# Hermes Achievements Performance Implementation Plan + +Status: Ready for execution after hackathon review window +Constraint: Plugin remains frozen until judging is complete +Decision: `/overview` and top-banner slots are out of scope and will be removed. + +--- + +## Phase 0 — Baseline & Safety (no behavior change) + +### Task 0.1: Add perf benchmark script (local) +Objective: Repro baseline before/after. + +Acceptance: +- Can print endpoint timings for `/achievements` (3 runs each, cold + warm). + +### Task 0.2: Define acceptance thresholds +Objective: Lock success criteria now. + +Acceptance: +- Documented SLOs: + - `/achievements` p95 < 1s (cached) + - max active scan jobs = 1 + +--- + +## Phase 1 — Remove unused overview/slot surface (highest certainty) + +### Task 1.1: Remove `/overview` backend route +Objective: Eliminate duplicate heavy endpoint path. + +Acceptance: +- `plugin_api.py` no longer exposes `/overview`. + +### Task 1.2: Remove slot registration and SummarySlot frontend code +Objective: Remove cross-tab banner fetch behavior. + +Acceptance: +- No `registerSlot(..."sessions:top"...)` or `registerSlot(..."analytics:top"...)`. +- No frontend call to `api("/overview")`. + +### Task 1.3: Update plugin manifest +Objective: Reflect final UI scope. + +Acceptance: +- `manifest.json` removes `slots` declarations. +- Tab registration remains intact. + +--- + +## Phase 2 — Shared snapshot persistence + single-flight for `/achievements` + +### Task 2.1: Introduce snapshot store abstraction + on-disk persistence +Objective: Single source of truth for Achievements data that survives process restarts. + +Acceptance: +- One structure contains dataset consumed by `/achievements`. +- Repeated requests do not recompute when cache is fresh. +- Snapshot persisted at `~/.hermes/plugins/hermes-achievements/scan_snapshot.json`. + +### Task 2.2: Single-flight scan coordinator +Objective: Prevent concurrent recomputes. + +Acceptance: +- Simultaneous requests result in one compute run. + +### Task 2.3: Refactor `/achievements` to read snapshot +Objective: Remove direct repeated compute from request path. + +Acceptance: +- `/achievements` does not run independent full recompute per request when cache is valid. + +--- + +## Phase 3 — Stale-While-Revalidate + +### Task 3.1: TTL state (`FRESH`/`STALE`) +Objective: Serve immediately when stale, refresh in background. + +Acceptance: +- Cached response returned quickly even when expired. +- Refresh is asynchronous. + +### Task 3.2: Add `scan-status` endpoint (optional) +Objective: Let UI/ops inspect scan state. + +Acceptance: +- Returns state, last success time, last duration, last error. + +### Task 3.3: Add metadata fields to `/achievements` +Objective: Improve transparency. + +Acceptance: +- Response includes `generated_at`, `is_stale`, maybe `scan_id`. + +--- + +## Phase 4 — Incremental Scanning (optional but recommended) + +### Task 4.1: Add per-session checkpoint file +Objective: Track session-level changes, not just global scan time. + +Acceptance: +- Checkpoint persisted at `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json`. +- For each session: `session_id`, fingerprint (`updated_at`/message_count/hash), and cached contribution. + +### Task 4.2: Incremental aggregation +Objective: Recompute only changed/new sessions and reuse unchanged contributions. + +Acceptance: +- Typical refresh time drops materially below full scan. +- Aggregate rebuild uses: subtract old contribution + add new contribution for changed sessions. + +### Task 4.3: Full rebuild fallback +Objective: Preserve correctness. + +Acceptance: +- Manual full rescan always possible. +- Schema/version changes invalidate checkpoint and force full rebuild. + +--- + +## Test Plan + +1. Unit tests +- Snapshot lifecycle transitions +- Dedupe logic under parallel requests +- `/achievements` response compatibility + +2. Integration tests +- Opening Achievements repeatedly causes <=1 heavy scan while in-flight +- `/achievements` warm-cache load is fast +- manual rescan updates snapshot and timestamps + +3. Manual benchmarks +- Compare pre/post `/achievements` timings with same history dataset + +--- + +## Rollout Plan + +1. Release internal branch with Phase 1 (remove overview/slots). +2. Validate no UI regression in Achievements tab. +3. Add Phase 2 snapshot/dedupe. +4. Add Phase 3 stale-while-revalidate + status metadata. +5. Optional: incremental scanner. + +Rollback: keep old compute path behind temporary feature flag for one release window. + +--- + +## Definition of Done + +- Achievements tab remains fully functional (counts, latest, tiers, cards, filters). +- No `/overview` endpoint or slot calls remain. +- Repeated Achievements loads feel immediate after warm cache. +- Metrics/unlocks remain unchanged versus baseline. diff --git a/plugins/hermes-achievements/docs/achievements-performance-implementation-spec.md b/plugins/hermes-achievements/docs/achievements-performance-implementation-spec.md new file mode 100644 index 0000000000..b6574d9831 --- /dev/null +++ b/plugins/hermes-achievements/docs/achievements-performance-implementation-spec.md @@ -0,0 +1,219 @@ +# Hermes Achievements Implementation Spec (Detailed) + +This document is implementation-facing detail to execute the performance refactor later. + +Decision scope: keep only Achievements tab flow; remove `/overview` + top-banner slot integration. + +--- + +## A) Current Behavior Summary + +- `evaluate_all()` performs: + - full `scan_sessions()` + - `SessionDB.list_sessions_rich(...)` + - `db.get_messages(session_id)` for each session + - text/tool regex analysis + aggregation + evaluation +- `/overview` and `/achievements` both currently call `evaluate_all()` directly. +- slot calls (`sessions:top`, `analytics:top`) currently invoke `/overview`. + +Consequence: repeated full recomputes and contention. + +--- + +## B) De-scope/Removal Changes + +1. Remove backend route: +- `GET /overview` + +2. Remove frontend slot usage: +- `SummarySlot` component +- `registerSlot("sessions:top")` +- `registerSlot("analytics:top")` + +3. Remove manifest slot declarations: +- `"slots": ["sessions:top", "analytics:top"]` + +4. Keep: +- tab route/page for Achievements +- `/achievements` endpoint and full tab rendering + +--- + +## C) Target Internal Interfaces + +### 1) `SnapshotStore` +Responsibilities: +- hold latest computed snapshot in memory +- persist/load snapshot from disk +- expose age and staleness checks + +Storage path: +- `~/.hermes/plugins/hermes-achievements/scan_snapshot.json` + +Methods (conceptual): +- `get()` -> snapshot | null +- `set(snapshot)` +- `is_stale(ttl_seconds)` + +### 2) `ScanCoordinator` +Responsibilities: +- single-flight guard for compute jobs +- track scan status + +Methods: +- `run_if_needed(force: bool = false)` +- `get_status()` + +State fields: +- `state`: `idle|running|failed` +- `started_at`, `finished_at` +- `last_error` +- `run_count` + +### 3) `build_snapshot()` +Responsibilities: +- execute current compute logic once +- on first run, perform full scan and materialize per-session contributions +- on subsequent runs, process only changed/new sessions via checkpoint fingerprints +- produce shape consumed by `/achievements` + +Output: +- `achievements` +- count fields +- optional `scan_meta` + +--- + +## D) Endpoint Behavior Matrix (No `/overview`) + +| Endpoint | Cache fresh | Cache stale | No cache | Force rescan | +|---|---|---|---|---| +| `/achievements` | return cached | return stale + trigger bg refresh | blocking bootstrap scan | n/a | +| `/rescan` | trigger refresh | trigger refresh | trigger refresh | yes | +| `/scan-status` | status only | status only | status only | status only | + +Notes: +- At most one scan run active. +- Other callers either await same run or receive stale snapshot according to policy. + +--- + +## E) Data Shape (Proposed) + +```json +{ + "generated_at": 0, + "is_stale": false, + "scan_meta": { + "duration_ms": 0, + "sessions_scanned": 0, + "messages_scanned": 0, + "mode": "full", + "error": null + }, + "achievements": [], + "unlocked_count": 0, + "discovered_count": 0, + "secret_count": 0, + "total_count": 0, + "error": null +} +``` + +Compatibility guidance: +- Keep existing `/achievements` keys. +- Add metadata keys without breaking old callers. + +Checkpoint file (new): +- `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json` + +Suggested checkpoint shape: +```json +{ + "schema_version": 1, + "generated_at": 0, + "sessions": { + "": { + "fingerprint": { + "updated_at": 0, + "message_count": 0, + "hash": "optional" + }, + "contribution": { + "metrics": {} + } + } + } +} +``` + +Notes: +- fingerprint mismatch => recompute that session contribution only. +- unchanged fingerprint => reuse stored contribution. + +--- + +## F) Concurrency Contract + +- Any request path that needs fresh data must pass through single-flight coordinator. +- If a scan is running: + - do not start second scan + - either await in-flight run (bounded) or serve stale snapshot immediately +- lock scope must include scan start/finish state transitions. + +--- + +## G) Error Handling Contract + +- If refresh fails and prior snapshot exists: + - return prior snapshot with `is_stale=true` and error metadata +- If refresh fails and no prior snapshot: + - return explicit error response (current behavior equivalent) +- `scan-status` should always return last known state/error. + +--- + +## H) Frontend Integration Contract + +- Achievements page: + - one fetch on mount to `/achievements` + - optional background refresh indicator if stale +- no top-banner slot integration +- avoid duplicate in-flight calls during fast navigation by cancellation/debounce. + +--- + +## I) Validation Checklist + +- [ ] `/overview` route removed +- [ ] manifest has no `sessions:top`/`analytics:top` slots +- [ ] frontend has no `api("/overview")` calls +- [ ] repeated Achievements navigation does not create multiple heavy scans +- [ ] average warm load times meet SLOs +- [ ] unlock totals match pre-refactor baseline for same history +- [ ] no schema regression in `/achievements` response + +--- + +## J) Suggested File Placement for Future Work + +- backend changes: `dashboard/plugin_api.py` +- optional extraction: + - `dashboard/perf_snapshot.py` + - `dashboard/perf_scan_coordinator.py` +- frontend request hygiene: `dashboard/dist/index.js` (or source if available) +- plugin metadata: `dashboard/manifest.json` +- persisted runtime files: + - `~/.hermes/plugins/hermes-achievements/state.json` (existing unlock state) + - `~/.hermes/plugins/hermes-achievements/scan_snapshot.json` (new) + - `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json` (new) + +--- + +## K) Post-Implementation Reporting Template + +Record: +- dataset size (sessions/messages/tool calls) +- pre/post `/achievements` timings (cold/warm) +- whether single-flight dedupe triggered under repeated tab open +- any behavioral diffs in unlock counts diff --git a/plugins/hermes-achievements/docs/achievements-performance-spec.md b/plugins/hermes-achievements/docs/achievements-performance-spec.md new file mode 100644 index 0000000000..1355246948 --- /dev/null +++ b/plugins/hermes-achievements/docs/achievements-performance-spec.md @@ -0,0 +1,174 @@ +# Hermes Achievements Performance Spec (Post-Hackathon) + +Status: Draft (no code changes yet) +Owner: hermes-achievements plugin +Scope: `dashboard/plugin_api.py` + `dashboard/dist/index.js` request behavior +Decision: **Drop `/overview` and top-banner slots**; keep only Achievements tab data path. + +--- + +## 1) Problem Statement + +Current plugin endpoints `/achievements` and `/overview` both execute a full history recomputation (`evaluate_all()`), which performs a full SessionDB scan each request. + +Observed on this machine/repo: +- ~83 sessions +- ~7,125 messages +- ~3,623 tool calls +- `evaluate_all()` ~13–16s per call +- `/achievements` ~13–15s per call +- `/overview` ~12–15s per call +- Overlap between endpoints increases perceived wait. + +Given current product direction, `/overview` and cross-tab top-banner slots are not needed. + +--- + +## 2) Goals + +- Keep achievement correctness unchanged. +- Keep all Achievements-tab UX/data (unlocked/discovered/secrets/highest/latest/cards). +- Remove unused summary path (`/overview`) and slot wiring. +- Make Achievements tab faster by avoiding duplicate endpoint pathways. +- Ensure at most one heavy scan can run at a time. + +Non-goals (phase 1): +- Rewriting achievement rules. +- Changing badge semantics/states. + +--- + +## 3) Endpoint Semantics (Target) + +### `GET /api/plugins/hermes-achievements/achievements` +Single source endpoint for Achievements UI. +Returns full payload used by the tab: +- `achievements` +- `unlocked_count` +- `discovered_count` +- `secret_count` +- `total_count` +- `error` + +### `POST /api/plugins/hermes-achievements/rescan` (optional) +Manual refresh trigger. +Prefer async trigger + immediate status response. + +### `GET /api/plugins/hermes-achievements/scan-status` (optional new) +Reports scan state for UX/ops. + +### Removed +- `GET /api/plugins/hermes-achievements/overview` + +--- + +## 4) UI Scope (Target) + +Keep: +- Achievements page/tab (`/achievements` in plugin tab manifest) +- All existing Achievements tab stats/cards/filters + +Remove: +- Top-banner summary slot components using `sessions:top` and `analytics:top` +- Any frontend call path to `/overview` + +--- + +## 5) Runtime State Machine (for `/achievements`) + +- `FRESH`: cached snapshot age <= TTL +- `STALE`: snapshot exists but expired +- `SCANNING`: background recompute running +- `FAILED`: last recompute failed, last good snapshot still served + +Rules: +1. FRESH -> serve immediately. +2. STALE + not scanning -> serve stale snapshot immediately and launch background refresh. +3. SCANNING -> do not start another scan; join single-flight in-flight job. +4. No snapshot yet -> allow one blocking bootstrap scan. + +--- + +## 6) Caching & Invalidation + +### Phase 1 +- In-memory cache + persisted snapshot file. +- TTL: 60–180 seconds (configurable). +- Single-flight dedupe for scan requests. +- Persist plugin data under: + - `~/.hermes/plugins/hermes-achievements/scan_snapshot.json` + +### Phase 2 +- Incremental scan checkpoints with per-session fingerprints. +- Persist checkpoint data under: + - `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json` +- Checkpoint stores, per session: + - `session_id` + - fingerprint (`updated_at`, message_count, or hash) + - cached per-session contribution used for aggregate recomposition +- Scan policy: + - First run: full scan and materialize snapshot + checkpoint. + - Next runs: process only new/changed sessions, reuse unchanged contributions. +- Full rebuild only on: + - schema/version change + - checkpoint corruption + - explicit full rescan + +--- + +## 7) Frontend Contract + +- Achievements tab requests `/achievements` once on mount. +- No slot-based summary fetches. +- If response says `is_stale=true`, UI may display “Updating in background”. +- Avoid duplicate mount-triggered calls and cancel stale requests on navigation. + +--- + +## 8) SLO Targets + +- `/achievements` p95 < 1s (cached) +- Max concurrent heavy scans: 1 +- Background refresh should not block UI + +--- + +## 9) Observability Requirements + +Track: +- scan count +- scan duration avg/p95 +- dedupe hit count (joined in-flight scans) +- stale-served count +- failures + last error + +Expose minimal diagnostics in `/scan-status`. + +--- + +## 10) Backward Compatibility + +- Keep `/achievements` response shape backward-compatible. +- Removing `/overview` is acceptable because slot UI is intentionally removed. +- If temporary compatibility is needed, `/overview` can return static deprecation response for one release. + +--- + +## 11) Risks + +- Stale data confusion -> mitigate with `generated_at` and explicit refresh status. +- Cache invalidation bugs -> start with conservative TTL + manual rescan. +- Concurrency bugs -> protect scan section with lock/single-flight guard. +- Session mutation edge cases -> use per-session fingerprint invalidation (not global timestamp only). + +--- + +## 12) Persistence Files (Explicit) + +Plugin state directory: +- `~/.hermes/plugins/hermes-achievements/` + +Files: +- `state.json` (existing): unlock tracking +- `scan_snapshot.json` (new): latest materialized achievements payload +- `scan_checkpoint.json` (new): per-session fingerprints + contributions for incremental refresh diff --git a/plugins/hermes-achievements/docs/assets/achievements-dashboard-hd.png b/plugins/hermes-achievements/docs/assets/achievements-dashboard-hd.png new file mode 100644 index 0000000000..2342f548e3 Binary files /dev/null and b/plugins/hermes-achievements/docs/assets/achievements-dashboard-hd.png differ diff --git a/plugins/hermes-achievements/docs/assets/achievements-tier-showcase-hd.png b/plugins/hermes-achievements/docs/assets/achievements-tier-showcase-hd.png new file mode 100644 index 0000000000..64dfc85c60 Binary files /dev/null and b/plugins/hermes-achievements/docs/assets/achievements-tier-showcase-hd.png differ diff --git a/plugins/hermes-achievements/tests/test_achievement_engine.py b/plugins/hermes-achievements/tests/test_achievement_engine.py new file mode 100644 index 0000000000..a941c8fd14 --- /dev/null +++ b/plugins/hermes-achievements/tests/test_achievement_engine.py @@ -0,0 +1,156 @@ +import importlib.util +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) + + +class AchievementEngineTests(unittest.TestCase): + def test_tool_call_stats_detect_tool_names_and_errors(self): + messages = [ + {"role": "assistant", "tool_calls": [{"function": {"name": "terminal"}}]}, + {"role": "tool", "tool_name": "terminal", "content": "Error: port 3000 already in use"}, + {"role": "assistant", "tool_calls": [{"function": {"name": "web_search"}}]}, + ] + + stats = plugin_api.analyze_messages("s1", "Fix dev server", messages) + + self.assertEqual(stats["tool_call_count"], 2) + self.assertEqual(stats["tool_names"], {"terminal", "web_search"}) + self.assertEqual(stats["error_count"], 1) + self.assertIs(stats["port_conflict"], True) + + def test_tiered_achievement_reaches_highest_matching_tier(self): + definition = { + "id": "let_him_cook", + "threshold_metric": "max_tool_calls_in_session", + "tiers": [ + {"name": "Copper", "threshold": 10}, + {"name": "Silver", "threshold": 25}, + {"name": "Gold", "threshold": 50}, + ], + } + aggregate = {"max_tool_calls_in_session": 28} + + result = plugin_api.evaluate_tiered(definition, aggregate) + + self.assertIs(result["unlocked"], True) + self.assertEqual(result["tier"], "Silver") + self.assertEqual(result["progress"], 28) + self.assertEqual(result["next_tier"], "Gold") + + def test_tiered_achievement_can_be_discovered_without_unlocking(self): + definition = { + "id": "terminal_goblin", + "threshold_metric": "total_terminal_calls", + "tiers": [{"name": "Copper", "threshold": 50}], + } + aggregate = {"total_terminal_calls": 12} + + result = plugin_api.evaluate_tiered(definition, aggregate) + + self.assertIs(result["unlocked"], False) + self.assertIs(result["discovered"], True) + self.assertEqual(result["state"], "discovered") + self.assertEqual(result["progress"], 12) + self.assertEqual(result["next_threshold"], 50) + + def test_secret_achievement_stays_hidden_without_progress(self): + definition = { + "id": "permission_denied_any_percent", + "name": "Permission Denied Any%", + "secret": True, + "requirements": [{"metric": "permission_denied_events", "gte": 3}], + } + aggregate = {"permission_denied_events": 0} + + result = plugin_api.evaluate_requirements(definition, aggregate) + display = plugin_api.display_achievement({**definition, **result}) + + self.assertEqual(result["state"], "secret") + self.assertEqual(display["name"], "???") + self.assertNotIn("Permission", display["description"]) + + def test_multi_condition_unlock_requires_all_requirements(self): + definition = { + "id": "full_send", + "requirements": [ + {"metric": "max_terminal_calls_in_session", "gte": 10}, + {"metric": "max_file_tool_calls_in_session", "gte": 5}, + {"metric": "max_web_calls_in_session", "gte": 2}, + ], + } + + partial = plugin_api.evaluate_requirements(definition, { + "max_terminal_calls_in_session": 12, + "max_file_tool_calls_in_session": 2, + "max_web_calls_in_session": 0, + }) + complete = plugin_api.evaluate_requirements(definition, { + "max_terminal_calls_in_session": 12, + "max_file_tool_calls_in_session": 6, + "max_web_calls_in_session": 2, + }) + + self.assertEqual(partial["state"], "discovered") + self.assertIs(partial["unlocked"], False) + self.assertLess(partial["progress_pct"], 100) + self.assertEqual(complete["state"], "unlocked") + self.assertIs(complete["unlocked"], True) + + def test_catalog_has_60_plus_unique_achievements(self): + ids = [achievement["id"] for achievement in plugin_api.ACHIEVEMENTS] + self.assertGreaterEqual(len(ids), 60) + self.assertEqual(len(ids), len(set(ids))) + + def test_model_provider_metrics_are_aggregated(self): + sessions = [ + {"model_names": {"openai/gpt-5", "anthropic/claude-sonnet-4"}}, + {"model_names": {"google/gemini-pro", "mistral/large"}}, + {"model_names": {"qwen/qwen3"}}, + ] + + aggregate = plugin_api.aggregate_stats(sessions) + + self.assertEqual(aggregate["distinct_model_count"], 5) + self.assertEqual(aggregate["distinct_provider_count"], 5) + result = plugin_api.evaluate_definition( + next(a for a in plugin_api.ACHIEVEMENTS if a["id"] == "five_model_flight"), + aggregate, + ) + self.assertEqual(result["state"], "unlocked") + self.assertEqual(result["tier"], "Copper") + + def test_removed_noisy_achievements_are_not_in_catalog(self): + ids = {achievement["id"] for achievement in plugin_api.ACHIEVEMENTS} + self.assertNotIn("fallback_pilot", ids) + self.assertNotIn("browser_sleuth", ids) + self.assertNotIn("release_ritualist", ids) + + def test_open_weights_pilgrim_counts_only_local_model_metadata(self): + aggregate_mentions_only = plugin_api.aggregate_stats([ + {"model_names": {"openai/gpt-5"}, "local_model_events": 999}, + ]) + aggregate_local_chat = plugin_api.aggregate_stats([ + {"model_names": {"openai/gpt-5"}}, + {"model_names": {"ollama/llama3"}}, + ]) + definition = next(a for a in plugin_api.ACHIEVEMENTS if a["id"] == "open_weights_pilgrim") + + self.assertEqual(aggregate_mentions_only["local_model_chat_sessions"], 0) + self.assertEqual(plugin_api.evaluate_definition(definition, aggregate_mentions_only)["state"], "discovered") + self.assertEqual(aggregate_local_chat["local_model_chat_sessions"], 1) + self.assertEqual(plugin_api.evaluate_definition(definition, aggregate_local_chat)["state"], "unlocked") + + def test_config_surgeon_ignores_generic_config_mentions(self): + stats = plugin_api.analyze_messages("s1", "Config talk", [{"content": "config config configuration not configured"}]) + self.assertEqual(stats["config_events"], 0) + stats = plugin_api.analyze_messages("s2", "Real config", [{"content": "edited config.yaml, manifest.json, and .env.local"}]) + self.assertGreaterEqual(stats["config_events"], 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/plugins/test_achievements_plugin.py b/tests/plugins/test_achievements_plugin.py new file mode 100644 index 0000000000..c12503ac95 --- /dev/null +++ b/tests/plugins/test_achievements_plugin.py @@ -0,0 +1,366 @@ +"""Tests for the bundled hermes-achievements dashboard plugin. + +These target the two behaviors that matter for official integration: + +* The 200-session scan cap is removed — the plugin now walks the entire + session history by default. Lifetime badges (tens of thousands of + tool calls) were unreachable before this fix on long-running installs. +* First-ever scans run in a background thread so the dashboard request + path never blocks, even on 8000+ session databases where a cold scan + takes minutes. + +The upstream repo ships its own unittest suite under +``plugins/hermes-achievements/tests/`` covering the achievement engine +internals (tier math, secret-state handling, catalog invariants). These +tests live at the hermes-agent level and focus on the integration +contract: the plugin scans ALL of your sessions, not the first 200. +""" +from __future__ import annotations + +import importlib.util +import sys +import threading +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +import pytest + +PLUGIN_MODULE_PATH = ( + Path(__file__).resolve().parents[2] + / "plugins" + / "hermes-achievements" + / "dashboard" + / "plugin_api.py" +) + + +@pytest.fixture +def plugin_api(tmp_path, monkeypatch): + """Load plugin_api with isolated ~/.hermes so state/snapshot files don't collide. + + We load the module fresh per test because the plugin keeps module-level + caches (``_SNAPSHOT_CACHE``, ``_SCAN_STATUS``, background thread handle). + Reloading gives each test a clean world. + """ + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec = importlib.util.spec_from_file_location( + f"plugin_api_test_{id(tmp_path)}", PLUGIN_MODULE_PATH + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + yield module + + +class _FakeSessionDB: + """Stand-in for hermes_state.SessionDB that records scan calls.""" + + def __init__(self, session_count: int): + self.session_count = session_count + self.last_limit: Optional[int] = None + self.last_include_children: Optional[bool] = None + self.list_calls = 0 + self.messages_calls = 0 + + def list_sessions_rich( + self, + source: Optional[str] = None, + exclude_sources: Optional[List[str]] = None, + limit: int = 20, + offset: int = 0, + include_children: bool = False, + project_compression_tips: bool = True, + ) -> List[Dict[str, Any]]: + self.last_limit = limit + self.last_include_children = include_children + self.list_calls += 1 + # SQLite semantics: LIMIT -1 = unlimited. Honor that here. + effective = self.session_count if limit == -1 else min(self.session_count, limit) + now = int(time.time()) + return [ + { + "id": f"sess-{i}", + "title": f"Session {i}", + "preview": f"preview {i}", + "started_at": now - (self.session_count - i) * 60, + "last_active": now - (self.session_count - i) * 60 + 30, + "source": "cli", + "model": "test-model", + } + for i in range(effective) + ] + + def get_messages(self, session_id: str) -> List[Dict[str, Any]]: + self.messages_calls += 1 + return [ + {"role": "user", "content": f"ask {session_id}"}, + { + "role": "assistant", + "tool_calls": [{"function": {"name": "terminal"}}], + }, + {"role": "tool", "tool_name": "terminal", "content": "ok"}, + ] + + def close(self) -> None: + pass + + +def _install_fake_session_db(plugin_api, fake_db): + """Inject a fake SessionDB so ``scan_sessions`` finds it via its local import.""" + fake_module = type(sys)("hermes_state") + fake_module.SessionDB = lambda: fake_db + sys.modules["hermes_state"] = fake_module + + +def test_scan_sessions_default_scans_all_history_not_first_200(plugin_api): + """Bug regression: ``scan_sessions()`` used to cap at limit=200. + + A user with 8000+ sessions would only see ~2% of their history in + achievement totals, making lifetime badges unreachable. The default + now passes ``LIMIT -1`` (SQLite "unlimited") to ``list_sessions_rich``. + """ + fake_db = _FakeSessionDB(session_count=500) # > old 200 cap + _install_fake_session_db(plugin_api, fake_db) + + result = plugin_api.scan_sessions() + + assert fake_db.last_limit == -1, ( + "scan_sessions() must pass LIMIT=-1 (unlimited) to list_sessions_rich " + f"by default, got {fake_db.last_limit}" + ) + assert fake_db.last_include_children is True, ( + "scan_sessions() must include subagent/compression child sessions so " + "tool calls made in delegated agents still count toward achievements" + ) + assert len(result["sessions"]) == 500 + assert result["scan_meta"]["sessions_total"] == 500 + + +def test_scan_sessions_explicit_positive_limit_is_honored(plugin_api): + """Callers can still pass a small limit for smoke tests.""" + fake_db = _FakeSessionDB(session_count=500) + _install_fake_session_db(plugin_api, fake_db) + + result = plugin_api.scan_sessions(limit=10) + + assert fake_db.last_limit == 10 + assert len(result["sessions"]) == 10 + + +def test_scan_sessions_zero_or_negative_limit_means_unlimited(plugin_api): + """``limit=0`` and ``limit=-1`` both map to the unlimited path.""" + fake_db = _FakeSessionDB(session_count=300) + _install_fake_session_db(plugin_api, fake_db) + + plugin_api.scan_sessions(limit=0) + assert fake_db.last_limit == -1 + + plugin_api.scan_sessions(limit=-1) + assert fake_db.last_limit == -1 + + +def test_evaluate_all_first_run_returns_pending_and_starts_background_scan(plugin_api): + """First-ever evaluate_all with no cache returns a pending placeholder + immediately and kicks off a background scan thread. Cold scans on + large DBs take minutes — blocking the dashboard request path is not + acceptable. + """ + fake_db = _FakeSessionDB(session_count=50) + _install_fake_session_db(plugin_api, fake_db) + + # Wrap _run_scan_and_update_cache so we can release it on demand, + # simulating a slow cold scan without actually waiting. + scan_started = threading.Event() + allow_scan_finish = threading.Event() + original_run = plugin_api._run_scan_and_update_cache + + def gated_run(*args, **kwargs): + scan_started.set() + allow_scan_finish.wait(timeout=5) + original_run(*args, **kwargs) + + plugin_api._run_scan_and_update_cache = gated_run + + t0 = time.time() + result = plugin_api.evaluate_all() + elapsed = time.time() - t0 + + # Immediate return — should not block waiting for the scan. + assert elapsed < 1.0, f"evaluate_all blocked for {elapsed:.2f}s on first run" + assert result["scan_meta"]["mode"] == "pending" + assert result["unlocked_count"] == 0 + # Catalog still rendered so UI has something to draw. + assert result["total_count"] >= 60 + + # Background scan is running. + assert scan_started.wait(timeout=2), "background scan did not start" + + # Let the scan complete, then a second call returns real data. + allow_scan_finish.set() + # Wait for thread to finish. + thread = plugin_api._BACKGROUND_SCAN_THREAD + assert thread is not None + thread.join(timeout=5) + assert not thread.is_alive() + + second = plugin_api.evaluate_all() + assert second["scan_meta"]["mode"] != "pending" + assert second["scan_meta"].get("sessions_total") == 50 + + +def test_evaluate_all_stale_cache_serves_stale_and_refreshes_in_background(plugin_api): + """When the snapshot is on-disk but older than TTL, evaluate_all returns + the stale data immediately and kicks a background refresh. Users don't + stare at a loading spinner every time TTL expires. + """ + fake_db = _FakeSessionDB(session_count=10) + _install_fake_session_db(plugin_api, fake_db) + + # Seed a stale snapshot on disk. + stale_generated_at = int(time.time()) - plugin_api.SNAPSHOT_TTL_SECONDS - 60 + stale_payload = { + "achievements": [], + "sessions": [], + "aggregate": {}, + "scan_meta": {"mode": "full", "sessions_total": 1, "sessions_rescanned": 1, "sessions_reused": 0}, + "error": None, + "unlocked_count": 0, + "discovered_count": 0, + "secret_count": 0, + "total_count": 0, + "generated_at": stale_generated_at, + } + plugin_api.save_snapshot(stale_payload) + + t0 = time.time() + result = plugin_api.evaluate_all() + elapsed = time.time() - t0 + + assert elapsed < 1.0, f"evaluate_all blocked for {elapsed:.2f}s serving stale data" + assert result["generated_at"] == stale_generated_at + + # Background scan should be running or have completed. + thread = plugin_api._BACKGROUND_SCAN_THREAD + assert thread is not None + thread.join(timeout=5) + + fresh = plugin_api.evaluate_all() + assert fresh["generated_at"] >= stale_generated_at + + +def test_evaluate_all_force_runs_synchronously(plugin_api): + """Manual /rescan (force=True) blocks the caller — users clicking + the rescan button expect up-to-date data when the call returns. + """ + fake_db = _FakeSessionDB(session_count=25) + _install_fake_session_db(plugin_api, fake_db) + + result = plugin_api.evaluate_all(force=True) + + # Synchronous — snapshot is fresh on return. + assert result["scan_meta"].get("sessions_total") == 25 + assert result["scan_meta"]["mode"] in ("full", "incremental") + + +def test_start_background_scan_is_idempotent_while_running(plugin_api): + """Multiple concurrent dashboard requests must not spawn duplicate scans.""" + fake_db = _FakeSessionDB(session_count=5) + _install_fake_session_db(plugin_api, fake_db) + + release = threading.Event() + original_run = plugin_api._run_scan_and_update_cache + + def gated_run(*args, **kwargs): + release.wait(timeout=5) + original_run(*args, **kwargs) + + plugin_api._run_scan_and_update_cache = gated_run + + plugin_api._start_background_scan() + first_thread = plugin_api._BACKGROUND_SCAN_THREAD + assert first_thread is not None and first_thread.is_alive() + + plugin_api._start_background_scan() + plugin_api._start_background_scan() + + assert plugin_api._BACKGROUND_SCAN_THREAD is first_thread + + release.set() + first_thread.join(timeout=5) + + +def test_background_scan_publishes_partial_snapshots(plugin_api): + """The background scanner publishes intermediate snapshots to the cache + every ~N sessions. Each dashboard refresh during a long cold scan sees + more badges unlocked instead of staring at zeros for minutes and then + having everything pop at the end. + """ + fake_db = _FakeSessionDB(session_count=750) + _install_fake_session_db(plugin_api, fake_db) + + # Record every partial snapshot the scanner publishes. + partial_snapshots: List[Dict[str, Any]] = [] + original_compute_from_scan = plugin_api._compute_from_scan + + def recording_compute(scan, *, is_partial=False): + result = original_compute_from_scan(scan, is_partial=is_partial) + if is_partial: + partial_snapshots.append(result) + return result + + plugin_api._compute_from_scan = recording_compute + + # scan 750 sessions with progress_every=250 → expect 2 intermediate + # publications (at 250 and 500; the final 750 call goes through the + # finished, non-partial path). + plugin_api._run_scan_and_update_cache(publish_partial_snapshots=True) + + assert len(partial_snapshots) >= 2, ( + f"expected at least 2 partial publications on a 750-session scan with " + f"progress_every=250, got {len(partial_snapshots)}" + ) + # Partial snapshots should report growing session counts. + counts = [p["scan_meta"].get("sessions_scanned_so_far") for p in partial_snapshots] + assert counts == sorted(counts), f"partial session counts not monotonic: {counts}" + assert counts[0] < 750 and counts[-1] < 750, ( + f"partial counts should be less than the final total; got {counts}" + ) + # Every partial reports the expected end-state total so the UI can + # show an accurate progress bar. + for p in partial_snapshots: + assert p["scan_meta"].get("sessions_expected_total") == 750 + + # Final snapshot in cache is the real (non-partial) one. + final = plugin_api._SNAPSHOT_CACHE + assert final is not None + assert final["scan_meta"].get("mode") != "in_progress" + assert final["scan_meta"].get("sessions_total") == 750 + + +def test_partial_snapshots_do_not_persist_unlock_timestamps(plugin_api): + """Intermediate snapshots must not write to state.json — an unlock + that appears at 30% scan progress could disappear when a later session + rebalances the aggregate. Only the final snapshot records ``unlocked_at``. + """ + fake_db = _FakeSessionDB(session_count=10) + _install_fake_session_db(plugin_api, fake_db) + + # Seed empty state, then invoke partial compute directly. + plugin_api.save_state({"unlocks": {}}) + partial_scan = { + "sessions": [{"session_id": "x", "tool_call_count": 99999, "tool_names": set()}], + "aggregate": {"max_tool_calls_in_session": 99999, "total_tool_calls": 99999}, + "scan_meta": {"mode": "in_progress"}, + } + result = plugin_api._compute_from_scan(partial_scan, is_partial=True) + + # Some achievements should evaluate as unlocked in this aggregate... + assert any(a["unlocked"] for a in result["achievements"]) + + # ...but state.json on disk stays empty (no timestamps were recorded). + persisted = plugin_api.load_state() + assert persisted.get("unlocks", {}) == {}, ( + "partial scans must not record unlock timestamps — a later session " + "could change whether the badge deserves to be unlocked yet" + ) diff --git a/website/docs/user-guide/features/built-in-plugins.md b/website/docs/user-guide/features/built-in-plugins.md index c6ff883948..7a25ce6b19 100644 --- a/website/docs/user-guide/features/built-in-plugins.md +++ b/website/docs/user-guide/features/built-in-plugins.md @@ -62,6 +62,7 @@ The repo ships these bundled plugins under `plugins/`. All are opt-in — enable | `image_gen/openai` | image backend | OpenAI `gpt-image-2` image generation backend (alternative to FAL) | | `image_gen/openai-codex` | image backend | OpenAI image generation via Codex OAuth | | `image_gen/xai` | image backend | xAI `grok-2-image` backend | +| `hermes-achievements` | dashboard tab | Steam-style collectible badges generated from your real Hermes session history | | `example-dashboard` | dashboard example | Reference dashboard plugin for [Extending the Dashboard](./extending-the-dashboard.md) | | `strike-freedom-cockpit` | dashboard skin | Sample custom dashboard skin | @@ -208,6 +209,57 @@ The agent kicks off the meeting join, streams the transcription back into its co **Disabling:** `hermes plugins disable google_meet`. Any cached transcripts and recordings stay in `~/.hermes/cache/google_meet/` until you remove them. +### hermes-achievements + +Adds a **Steam-style achievements tab to the dashboard** — 60+ collectible, tiered badges generated from your real Hermes session history. Tool-chain feats, debugging patterns, vibe-coding streaks, skill/memory usage, model/provider variety, lifestyle quirks (weekend and night sessions). Originally authored by [@PCinkusz](https://github.com/PCinkusz) as an external plugin; brought in-tree so it stays in lockstep with Hermes feature changes. + +**How it works:** + +- Scans your entire `~/.hermes/state.db` session history on the dashboard backend +- Per-session stats are cached by `(started_at, last_active)` fingerprint, so only new or changed sessions re-analyze on subsequent scans +- First-ever scan runs in a background thread — the dashboard never blocks waiting for it, even on databases with thousands of sessions +- Unlock state is persisted to `$HERMES_HOME/plugins/hermes-achievements/state.json` + +**Tier progression:** Copper → Silver → Gold → Diamond → Olympian. Each card exposes a "What counts" section listing the exact metric being tracked. + +**Achievement states:** + +| State | Meaning | +|---|---| +| Unlocked | At least one tier achieved | +| Discovered | Known achievement, progress visible, not yet earned | +| Secret | Hidden until Hermes detects the first related signal in your history | + +**API** — routes mount under `/api/plugins/hermes-achievements/`: + +| Endpoint | Purpose | +|---|---| +| `GET /achievements` | Full catalog with per-badge unlock state (returns a pending placeholder while the first cold scan is running) | +| `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 | +| `POST /rescan` | Manual synchronous rescan (blocks; use when the user clicks the rescan button) | +| `POST /reset-state` | Clear unlock history and cached snapshot | + +**State files** — live under `$HERMES_HOME/plugins/hermes-achievements/`: + +| File | Contents | +|---|---| +| `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) | + +**Performance notes:** + +- Cold scan on ~8,000 sessions takes a few minutes. It runs in a background thread on first dashboard request; the UI sees a pending placeholder and polls `/scan-status`. +- **Incremental results during a cold scan** — the scanner publishes a partial snapshot every ~250 sessions so each dashboard refresh shows more badges unlocked as the scan progresses. No minute-long stare at zeros. +- Warm rescan reuses per-session stats for every session whose `started_at` + `last_active` fingerprint matches the checkpoint — completes in seconds even on large histories. +- The in-memory snapshot TTL is 120s; stale requests serve the old snapshot immediately and kick a background refresh. You never wait on a spinner just because TTL expired. + +**Enabling:** Nothing to enable — `hermes-achievements` is a dashboard-only plugin (no lifecycle hooks, no model-visible tools). It auto-registers as a tab in `hermes dashboard` on first launch. The `plugins.enabled` config only gates lifecycle/tool plugins; dashboard plugins are discovered purely via their `dashboard/manifest.json`. + +**Opting out:** Delete or rename `plugins/hermes-achievements/dashboard/manifest.json`, or override it with a user plugin of the same name in `~/.hermes/plugins/hermes-achievements/` that ships no dashboard. The plugin's state files under `$HERMES_HOME/plugins/hermes-achievements/` survive — reinstalling preserves your unlock history. + ## Adding a bundled plugin Bundled plugins are written exactly like any other Hermes plugin — see [Build a Hermes Plugin](/docs/guides/build-a-hermes-plugin). The only differences are: