(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; // useI18n is a hook so each component that needs translations calls it // locally (see AchievementsPage, AchievementCard, ShareDialog, LoadingPage). // Older host dashboards may not expose useI18n yet; fall back to a no-op // shim that returns en values so the bundle still renders against an older // host SDK. English fallback strings live alongside each call site. const useI18n = SDK.useI18n || function () { return { t: { achievements: null }, locale: "en" }; }; // Resolve a translation by dotted path (e.g. "card.share_text"); fall back to // the English string passed in. Used inside components after they call // useI18n() so they can still render against an older host SDK that doesn't // expose the achievements namespace yet. function tx(t, path, fallback, vars) { let node = t && t.achievements; if (node) { const parts = path.split("."); for (let i = 0; i < parts.length; i++) { if (node && typeof node === "object" && parts[i] in node) { node = node[parts[i]]; } else { node = null; break; } } } let str = (typeof node === "string") ? node : fallback; if (vars) { for (const k in vars) { str = str.replace(new RegExp("\\{" + k + "\\}", "g"), vars[k]); } } return str; } 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 token = window.__HERMES_SESSION_TOKEN__ || ""; const headers = { ...((options && options.headers) || {}) }; if (token) headers["X-Hermes-Session-Token"] = token; const res = await fetch(url, { ...(options || {}), headers }); 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", }); } const TIER_HEX = { "Copper": "#b87333", "Silver": "#c0c7d2", "Gold": "#f2c94c", "Diamond": "#67e8f9", "Olympian": "#c084fc", }; function tierHex(tier) { return TIER_HEX[tier] || "#67e8f9"; } // Render a LUCIDE icon path fragment into a standalone SVG string with an // explicit stroke color so it can be rasterized onto a via Image. // The normal render path uses stroke="currentColor" which browsers honor in // DOM but NOT when the SVG is drawn to a canvas from a data URL. function iconSvgForCanvas(iconKey, strokeColor) { const paths = LUCIDE[iconKey] || LUCIDE.secret; return "" + paths + ""; } function loadSvgImage(svgString) { return new Promise(function (resolve, reject) { const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" }); const url = URL.createObjectURL(blob); const img = new Image(); img.onload = function () { URL.revokeObjectURL(url); resolve(img); }; img.onerror = function (e) { URL.revokeObjectURL(url); reject(e); }; img.src = url; }); } function wrapText(ctx, text, maxWidth) { const words = String(text || "").split(/\s+/).filter(Boolean); const lines = []; let current = ""; for (let i = 0; i < words.length; i++) { const candidate = current ? current + " " + words[i] : words[i]; if (ctx.measureText(candidate).width <= maxWidth) { current = candidate; } else { if (current) lines.push(current); current = words[i]; } } if (current) lines.push(current); return lines; } // Build a 1200x630 share card PNG for a single achievement. Returns a Blob. // Pure client-side render via Canvas2D — no external deps, no network. async function buildShareImage(achievement) { const W = 1200; const H = 630; const canvas = document.createElement("canvas"); canvas.width = W; canvas.height = H; const ctx = canvas.getContext("2d"); const tier = achievement.tier || achievement.next_tier || "Copper"; const color = tierHex(tier); // Background: dark charcoal with a tier-tinted radial highlight on the // top-left, echoing the card visual language. ctx.fillStyle = "#0b0d11"; ctx.fillRect(0, 0, W, H); const bgGrad = ctx.createRadialGradient(260, 220, 60, 260, 220, 820); bgGrad.addColorStop(0, color + "33"); bgGrad.addColorStop(0.55, color + "0a"); bgGrad.addColorStop(1, "#0b0d1100"); ctx.fillStyle = bgGrad; ctx.fillRect(0, 0, W, H); // Outer border ctx.strokeStyle = color + "66"; ctx.lineWidth = 2; ctx.strokeRect(1, 1, W - 2, H - 2); // Icon block — 380x380 on the left try { const svg = iconSvgForCanvas(achievement.icon || "secret", color); const iconImg = await loadSvgImage(svg); const ix = 90; const iy = 125; const isize = 380; // Icon glow ctx.save(); ctx.shadowColor = color; ctx.shadowBlur = 40; ctx.drawImage(iconImg, ix, iy, isize, isize); ctx.restore(); } catch (_) { // Icon render failure is non-fatal; card still useful without it. } // Right column text layout const rx = 520; const rMaxWidth = W - rx - 70; // Category label (kicker) ctx.fillStyle = "#8b95a8"; ctx.font = "600 22px ui-monospace, 'SF Mono', Menlo, monospace"; ctx.textBaseline = "top"; ctx.fillText((achievement.category || "").toUpperCase(), rx, 112); // Achievement name — wrap to 2 lines if needed ctx.fillStyle = "#ffffff"; ctx.font = "780 68px system-ui, -apple-system, 'Segoe UI', sans-serif"; const nameLines = wrapText(ctx, achievement.name || "Achievement", rMaxWidth).slice(0, 2); let cursorY = 150; for (let i = 0; i < nameLines.length; i++) { ctx.fillText(nameLines[i], rx, cursorY); cursorY += 76; } // Tier badge pill const badgeLabel = tier.toUpperCase() + " TIER"; ctx.font = "700 22px ui-monospace, 'SF Mono', Menlo, monospace"; const badgeWidth = ctx.measureText(badgeLabel).width + 32; const badgeX = rx; const badgeY = cursorY + 14; const badgeH = 40; ctx.fillStyle = color + "1f"; ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.rect(badgeX, badgeY, badgeWidth, badgeH); ctx.fill(); ctx.stroke(); ctx.fillStyle = color; ctx.textBaseline = "middle"; ctx.fillText(badgeLabel, badgeX + 16, badgeY + badgeH / 2 + 1); ctx.textBaseline = "top"; // Description — wrap up to 3 lines ctx.fillStyle = "#c3cad6"; ctx.font = "400 26px system-ui, -apple-system, 'Segoe UI', sans-serif"; const descLines = wrapText(ctx, achievement.description || "", rMaxWidth).slice(0, 3); let descY = badgeY + badgeH + 28; for (let i = 0; i < descLines.length; i++) { ctx.fillText(descLines[i], rx, descY); descY += 34; } // Progress / stat line (if meaningful) const progressValue = achievement.progress; const threshold = achievement.next_threshold; let statLine = null; if (progressValue && threshold) { statLine = progressValue.toLocaleString() + " / " + threshold.toLocaleString(); } else if (progressValue) { statLine = progressValue.toLocaleString(); } if (statLine) { ctx.fillStyle = color; ctx.font = "700 28px ui-monospace, 'SF Mono', Menlo, monospace"; ctx.fillText(statLine, rx, descY + 14); } // Footer watermark ctx.fillStyle = "#8b95a8"; ctx.font = "600 20px ui-monospace, 'SF Mono', Menlo, monospace"; ctx.textBaseline = "bottom"; ctx.fillText("HERMES AGENT · hermes-agent.nousresearch.com", 70, H - 40); // "UNLOCKED" stamp upper-right ctx.textBaseline = "top"; ctx.fillStyle = color; ctx.font = "800 24px ui-monospace, 'SF Mono', Menlo, monospace"; const stamp = "◆ UNLOCKED"; const stampW = ctx.measureText(stamp).width; ctx.fillText(stamp, W - 70 - stampW, 70); return await new Promise(function (resolve, reject) { canvas.toBlob(function (blob) { if (blob) resolve(blob); else reject(new Error("canvas.toBlob returned null")); }, "image/png"); }); } function ShareDialog({ achievement, onClose }) { const { t } = useI18n(); const [status, setStatus] = hooks.useState("rendering"); // rendering | ready | copied | error const [errorMsg, setErrorMsg] = hooks.useState(null); const [previewUrl, setPreviewUrl] = hooks.useState(null); const blobRef = React.useRef(null); hooks.useEffect(function () { let cancelled = false; let createdUrl = null; buildShareImage(achievement).then(function (blob) { if (cancelled) return; blobRef.current = blob; createdUrl = URL.createObjectURL(blob); setPreviewUrl(createdUrl); setStatus("ready"); }).catch(function (err) { if (cancelled) return; setErrorMsg(String(err && err.message || err)); setStatus("error"); }); return function () { cancelled = true; if (createdUrl) URL.revokeObjectURL(createdUrl); }; }, [achievement.id]); function download() { if (!blobRef.current) return; const url = URL.createObjectURL(blobRef.current); const a = document.createElement("a"); a.href = url; a.download = "hermes-achievement-" + (achievement.id || "badge") + ".png"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(url); }, 1000); } async function copyToClipboard() { if (!blobRef.current) return; try { if (!navigator.clipboard || !window.ClipboardItem) { throw new Error(tx(t, "share.clipboard_unsupported", "Clipboard image copy not supported in this browser — use Download instead.")); } await navigator.clipboard.write([ new window.ClipboardItem({ "image/png": blobRef.current }), ]); setStatus("copied"); setTimeout(function () { setStatus("ready"); }, 1800); } catch (err) { setErrorMsg(String(err && err.message || err)); setStatus("error"); } } // Build the pre-filled tweet text. Keep it short so X doesn't truncate // when the user hasn't attached the PNG yet — they'll copy-image and // paste in the same flow. function tweetText() { const tierPart = achievement.tier ? (achievement.tier + " tier ") : ""; const tmpl = tx(t, "share.tweet_text", "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", { tier_part: tierPart, name: achievement.name, }); return tmpl + "\n\n@NousResearch · https://hermes-agent.nousresearch.com"; } function shareOnX() { const url = "https://x.com/intent/post?text=" + encodeURIComponent(tweetText()); window.open(url, "_blank", "noopener,noreferrer"); } return React.createElement("div", { className: "ha-share-backdrop", onClick: function (e) { if (e.target === e.currentTarget) onClose(); }, }, React.createElement("div", { className: "ha-share-dialog", role: "dialog", "aria-label": tx(t, "share.dialog_label", "Share achievement") }, React.createElement("div", { className: "ha-share-head" }, React.createElement("strong", null, tx(t, "share.header", "Share: {name}", { name: achievement.name })), React.createElement("button", { className: "ha-share-close", onClick: onClose, "aria-label": tx(t, "share.close", "Close") }, "×") ), React.createElement("div", { className: "ha-share-preview" }, status === "rendering" && React.createElement("div", { className: "ha-share-placeholder" }, tx(t, "share.rendering", "Rendering…")), previewUrl && React.createElement("img", { src: previewUrl, alt: tx(t, "share.card_alt", "{name} share card", { name: achievement.name }) }) ), status === "error" && React.createElement("div", { className: "ha-share-error" }, errorMsg || tx(t, "share.error_generic", "Something went wrong.")), React.createElement("div", { className: "ha-share-actions" }, React.createElement("button", { className: "ha-share-btn ha-share-btn-primary", onClick: shareOnX, title: tx(t, "share.x_title", "Opens X with a pre-filled post"), }, tx(t, "share.x_button", "Share on X")), React.createElement("button", { className: "ha-share-btn", onClick: copyToClipboard, disabled: status !== "ready" && status !== "copied", title: tx(t, "share.copy_title", "Copy the image to paste into your post"), }, status === "copied" ? tx(t, "share.copied", "Copied ✓") : tx(t, "share.copy_button", "Copy image")), React.createElement("button", { className: "ha-share-btn", onClick: download, disabled: status !== "ready" && status !== "copied", }, tx(t, "share.download_button", "Download PNG")) ), React.createElement("p", { className: "ha-share-hint" }, tx(t, "share.hint", "Share on X opens a pre-filled post in a new tab. Click Copy image first if you want the 1200×630 badge attached — X lets you paste it right into the tweet composer. Download PNG saves the file for use anywhere.") ) ) ); } 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() { const { t } = useI18n(); 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" }, tx(t, "hero.kicker", "Agentic Gamerscore")), React.createElement("h1", null, tx(t, "hero.title", "Hermes Achievements")), React.createElement("p", null, tx(t, "hero.scan_subtitle", "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, tx(t, "scan.building_headline", "Building achievement profile…")), React.createElement("p", null, tx(t, "scan.building_detail", "Reading sessions, tool calls, model metadata, and unlock state.")) ) ) ), React.createElement("div", { className: "ha-stats" }, [ { key: "stats.unlocked", fallback: "Unlocked" }, { key: "stats.discovered", fallback: "Discovered" }, { key: "stats.secrets", fallback: "Secrets" }, { key: "stats.highest_tier", fallback: "Highest tier" }, { key: "stats.latest", fallback: "Latest" }, ].map(function (entry) { const label = tx(t, entry.key, entry.fallback); return React.createElement(C.Card, { key: entry.key, 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, tx(t, "guide.scan_status_header", "Scan status")), React.createElement("p", null, tx(t, "guide.scan_status_body", "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, tx(t, "guide.what_scanned_header", "What is scanned")), React.createElement("p", null, tx(t, "guide.what_scanned_body", "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 { t } = useI18n(); 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" ? tx(t, "state.unlocked", "Unlocked") : (state === "secret" ? tx(t, "state.secret", "Secret") : tx(t, "state.discovered", "Discovered")); const targetTier = achievement.next_tier || achievement.tier; let tierLabel; if (achievement.tier) { tierLabel = achievement.tier; } else if (targetTier) { tierLabel = tx(t, "tier.target", "Target {tier}", { tier: targetTier }); } else if (state === "secret") { tierLabel = tx(t, "tier.hidden", "Hidden"); } else if (unlocked) { tierLabel = tx(t, "tier.complete", "Complete"); } else { tierLabel = tx(t, "tier.objective", "Objective"); } const progressText = state === "secret" ? tx(t, "progress.hidden", "hidden") : (progress + (achievement.next_threshold ? " / " + achievement.next_threshold : "")); const [shareOpen, setShareOpen] = hooks.useState(false); 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), state === "unlocked" && React.createElement("button", { className: "ha-share-trigger", onClick: function () { setShareOpen(true); }, title: tx(t, "card.share_title", "Share this achievement"), "aria-label": tx(t, "card.share_label", "Share {name}", { name: achievement.name }), }, tx(t, "card.share_text", "Share")) ) ), React.createElement("p", { className: "ha-description" }, achievement.description), achievement.criteria && React.createElement("details", { className: "ha-criteria" }, React.createElement("summary", null, state === "secret" ? tx(t, "card.how_to_reveal", "How to reveal") : tx(t, "card.what_counts", "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" }, tx(t, "card.evidence_label", "Evidence")), React.createElement("span", { className: "ha-evidence-title" }, achievement.evidence.title || achievement.evidence.session_id || tx(t, "card.evidence_session_fallback", "session")) ) : React.createElement("div", { className: "ha-evidence ha-evidence-empty", "aria-hidden": "true" }, tx(t, "card.no_evidence", "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) ) ), shareOpen && React.createElement(ShareDialog, { achievement: achievement, onClose: function () { setShareOpen(false); }, }) ); } function AchievementsPage() { const { t } = useI18n(); 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; }); }) || tx(t, "stats.none_yet", "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" ? tx(t, "scan.starting_headline", "Starting achievement scan…") : tx(t, "scan.building_headline", "Building achievement profile…"); const detail = total > 0 ? tx(t, "scan.progress_detail", "Scanned {scanned} of {total} sessions · {pct}%. Badges unlock as more history streams in.", { scanned: scanned.toLocaleString(), total: total.toLocaleString(), pct: String(pct), }) : tx(t, "scan.idle_detail", "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); } // Translate the "All" category pill but keep the underlying state ("All") // as the canonical key the API matches against. const allCategoryLabel = tx(t, "filters.all_categories", "All"); const visibilityLabels = { all: tx(t, "filters.visibility_all", "all"), unlocked: tx(t, "filters.visibility_unlocked", "unlocked"), discovered: tx(t, "filters.visibility_discovered", "discovered"), secret: tx(t, "filters.visibility_secret", "secret"), }; return React.createElement("div", { className: "ha-page" }, React.createElement("section", { className: "ha-hero" }, React.createElement("div", null, React.createElement("div", { className: "ha-kicker" }, tx(t, "hero.kicker", "Agentic Gamerscore")), React.createElement("h1", null, tx(t, "hero.title", "Hermes Achievements")), React.createElement("p", null, tx(t, "hero.subtitle", "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" }, tx(t, "actions.rescan", "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: tx(t, "stats.unlocked", "Unlocked"), value: (data ? data.unlocked_count : 0) + " / " + (data ? data.total_count : 0), hint: tx(t, "stats.unlocked_hint", "earned badges") }), React.createElement(StatCard, { label: tx(t, "stats.discovered", "Discovered"), value: discovered.length, hint: tx(t, "stats.discovered_hint", "known, not earned yet") }), React.createElement(StatCard, { label: tx(t, "stats.secrets", "Secrets"), value: secret.length, hint: tx(t, "stats.secrets_hint", "hidden until first signal") }), React.createElement(StatCard, { label: tx(t, "stats.highest_tier", "Highest tier"), value: highest, hint: tx(t, "stats.highest_tier_hint", "Copper → Silver → Gold → Diamond → Olympian") }), React.createElement(StatCard, { label: tx(t, "stats.latest", "Latest"), value: latest[0] ? latest[0].name : tx(t, "stats.none_yet", "None yet"), hint: latest[0] ? latest[0].category : tx(t, "stats.latest_hint_empty", "run Hermes more") }) ), React.createElement("section", { className: "ha-guide" }, React.createElement("div", null, React.createElement("strong", null, tx(t, "guide.tiers_header", "Tiers")), React.createElement(TierLegend, null) ), React.createElement("div", null, React.createElement("strong", null, tx(t, "guide.secret_header", "Secret achievements")), React.createElement("p", null, tx(t, "guide.secret_body", "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) { // Render the localized "All" pill but keep the underlying value // unchanged so the filter logic still compares against "All". const pillLabel = cat === "All" ? allCategoryLabel : cat; return React.createElement("button", { key: cat, onClick: function () { setCategory(cat); }, className: cat === category ? "active" : "" }, pillLabel); })), 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" : "" }, visibilityLabels[v] || v); })) ), latest.length > 0 && React.createElement("section", { className: "ha-latest" }, React.createElement("h2", null, tx(t, "latest.header", "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, tx(t, "empty.no_secrets_header", "No hidden secrets left in this scan.")), React.createElement("p", null, tx(t, "empty.no_secrets_body", "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); })();