squeezr-ai 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,4 +3,4 @@
3
3
  * Dark GitHub-style theme, sidebar navigation, 4 pages.
4
4
  * All data via SSE (/squeezr/events) + REST (/squeezr/history, /squeezr/projects).
5
5
  */
6
- export declare const DASHBOARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Squeezr Dashboard</title>\n<style>\n*{box-sizing:border-box;margin:0;padding:0}\n:root{\n --bg:#0d1117;--bg2:#161b22;--bg3:#21262d;--bg4:#2d333b;\n --border:#30363d;--text:#e6edf3;--muted:#8b949e;\n --green:#3fb950;--yellow:#d29922;--red:#f85149;\n --blue:#58a6ff;--purple:#bc8cff;--orange:#ffa657;--accent:#238636\n}\nhtml,body{height:100%;background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,-apple-system,sans-serif;font-size:13px;line-height:1.5}\na{color:var(--blue);text-decoration:none}\ncode{font-family:'Cascadia Code','Fira Mono','Consolas',monospace}\n\n/* \u2500\u2500 App shell \u2500\u2500 */\n#app{display:flex;height:100vh;overflow:hidden}\n\n/* \u2500\u2500 Sidebar \u2500\u2500 */\n#sidebar{\n width:200px;flex-shrink:0;background:var(--bg2);\n border-right:1px solid var(--border);\n display:flex;flex-direction:column;overflow:hidden\n}\n#sidebar-brand{padding:16px 16px 12px;border-bottom:1px solid var(--border)}\n#sidebar-brand .logo{font-size:18px;font-weight:700;letter-spacing:.3px;line-height:1}\n#sidebar-brand .logo span{color:var(--blue)}\n#sidebar-brand .ver{font-size:11px;color:var(--muted);margin-top:3px}\n\nnav{flex:1;padding:8px 0;overflow-y:auto}\n.nav-item{\n display:flex;align-items:center;gap:9px;padding:8px 16px;\n color:var(--muted);cursor:pointer;border-radius:0;\n transition:background .1s,color .1s;user-select:none\n}\n.nav-item:hover{background:var(--bg3);color:var(--text)}\n.nav-item.active{background:var(--bg3);color:var(--blue)}\n.nav-item svg{flex-shrink:0;opacity:.8}\n.nav-item.active svg{opacity:1}\n.nav-label{font-size:13px}\n\n#sidebar-footer{padding:12px 16px;border-top:1px solid var(--border)}\n.status-row{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted)}\n.dot{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 5px var(--green);flex-shrink:0}\n.dot.off{background:var(--red);box-shadow:0 0 5px var(--red)}\n#uptime-small{font-size:11px;color:var(--muted);margin-top:4px}\n\n/* \u2500\u2500 Main content \u2500\u2500 */\n#content{flex:1;display:flex;flex-direction:column;overflow:hidden}\n#page-header{\n display:flex;align-items:center;gap:10px;padding:12px 20px;\n background:var(--bg2);border-bottom:1px solid var(--border);flex-shrink:0\n}\n#page-title{font-size:15px;font-weight:600}\n#project-badge{\n font-size:11px;background:var(--bg3);border:1px solid var(--border);\n border-radius:12px;padding:2px 10px;color:var(--blue);font-weight:500\n}\n#header-uptime{font-size:11px;color:var(--muted);margin-left:auto}\n#conn-pill{\n font-size:11px;padding:2px 8px;border-radius:10px;\n background:rgba(63,185,80,.15);color:var(--green);border:1px solid rgba(63,185,80,.3)\n}\n#conn-pill.err{background:rgba(248,81,73,.15);color:var(--red);border-color:rgba(248,81,73,.3)}\n\n#pages{flex:1;overflow-y:auto;padding:16px 20px}\n.page{display:none}\n.page.active{display:block}\n\n/* \u2500\u2500 Cards \u2500\u2500 */\n.cards-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(175px,1fr));gap:10px;margin-bottom:14px}\n.card{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px 16px}\n.card-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}\n.card-value{font-size:26px;font-weight:700;line-height:1.1}\n.card-sub{font-size:11px;color:var(--muted);margin-top:3px}\n.c-green .card-value{color:var(--green)}\n.c-blue .card-value{color:var(--blue)}\n.c-yellow .card-value{color:var(--yellow)}\n.c-orange .card-value{color:var(--orange)}\n\n/* \u2500\u2500 Sections \u2500\u2500 */\n.section{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:14px}\n.section-title{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;font-weight:600}\n\n/* \u2500\u2500 Bars \u2500\u2500 */\n.bar-row{display:flex;align-items:center;gap:8px;margin-bottom:7px}\n.bar-label{font-size:12px;color:var(--muted);width:130px;flex-shrink:0}\n.bar-track{flex:1;height:7px;background:var(--bg3);border-radius:4px;overflow:hidden}\n.bar-fill{height:100%;border-radius:4px;transition:width .4s,background .4s}\n.bar-val{font-size:11px;width:36px;text-align:right;flex-shrink:0;color:var(--muted)}\n\n/* \u2500\u2500 Sparkline \u2500\u2500 */\ncanvas#sparkline{width:100%;height:72px;display:block}\n\n/* \u2500\u2500 Tables \u2500\u2500 */\ntable{width:100%;border-collapse:collapse}\nth{font-size:11px;color:var(--muted);text-align:left;padding:4px 8px;border-bottom:1px solid var(--border);font-weight:500;letter-spacing:.3px;text-transform:uppercase}\ntd{padding:6px 8px;font-size:12px;border-bottom:1px solid var(--border)}\ntr:last-child td{border-bottom:none}\n.td-right{text-align:right;font-variant-numeric:tabular-nums}\n.mini-bar{display:inline-block;height:5px;border-radius:2px;vertical-align:middle;margin-right:5px;opacity:.75}\n.tag{display:inline-block;background:var(--bg3);border:1px solid var(--border);border-radius:3px;padding:1px 6px;font-size:11px;font-family:monospace}\n\n/* \u2500\u2500 Cache row \u2500\u2500 */\n.cache-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px}\n.cache-card{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:10px 14px}\n.cache-card .cache-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px}\n.cache-card .cache-val{font-size:18px;font-weight:600;color:var(--purple)}\n\n/* \u2500\u2500 Mode buttons \u2500\u2500 */\n.mode-btns{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px}\n.mode-btn{\n display:flex;align-items:center;gap:6px;padding:6px 14px;\n border-radius:6px;border:1px solid var(--border);background:var(--bg3);\n color:var(--muted);cursor:pointer;font-size:12px;transition:all .15s\n}\n.mode-btn:hover{border-color:var(--blue);color:var(--text)}\n.mode-btn.active{border-color:var(--accent);background:var(--accent);color:#fff}\n.mode-btn.active svg{stroke:white}\n#mode-desc{font-size:12px;color:var(--muted);min-height:16px}\n\n/* \u2500\u2500 Projects page \u2500\u2500 */\n.project-table td:first-child code{font-size:12px}\n.project-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}\n\n/* \u2500\u2500 History page \u2500\u2500 */\n#hist-layout{display:grid;grid-template-columns:220px 1fr;gap:12px;min-height:400px}\n#hist-projects{background:var(--bg2);border:1px solid var(--border);border-radius:8px;overflow:hidden}\n#hist-sessions{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:0}\n.hist-proj-item{\n padding:9px 14px;cursor:pointer;border-bottom:1px solid var(--border);\n display:flex;justify-content:space-between;align-items:center;\n font-size:12px;color:var(--muted);transition:background .1s\n}\n.hist-proj-item:last-child{border-bottom:none}\n.hist-proj-item:hover{background:var(--bg3)}\n.hist-proj-item.active{background:var(--bg3);color:var(--blue)}\n.hist-proj-count{font-size:11px;background:var(--bg4);border-radius:10px;padding:1px 7px}\n.hist-sessions-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;font-weight:600}\n.session-card{padding:12px 16px;border-bottom:1px solid var(--border)}\n.session-card:last-child{border-bottom:none}\n.session-date{font-size:12px;font-weight:600;color:var(--text);margin-bottom:4px}\n.session-time{font-size:11px;color:var(--muted);margin-bottom:6px}\n.session-stats{display:flex;gap:14px;flex-wrap:wrap}\n.session-stat{font-size:11px;color:var(--muted)}\n.session-stat span{color:var(--text);font-weight:500}\n.session-project-badge{font-size:10px;background:var(--bg4);border:1px solid var(--border);border-radius:10px;padding:1px 8px;color:var(--blue);margin-left:6px}\n.empty-msg{padding:32px 16px;text-align:center;color:var(--muted);font-size:12px}\n\n/* \u2500\u2500 Settings \u2500\u2500 */\n.config-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border);font-size:12px}\n.config-row:last-child{border-bottom:none}\n.config-key{color:var(--muted)}\n.config-val{font-family:monospace;color:var(--text)}\n\n/* \u2500\u2500 Limits page \u2500\u2500 */\n.limits-cli-section{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:14px}\n.limits-cli-header{display:flex;align-items:center;gap:8px;margin-bottom:12px}\n.limits-cli-name{font-size:13px;font-weight:600;color:var(--text)}\n.limits-cli-badge{font-size:10px;padding:1px 7px;border-radius:10px;border:1px solid;margin-left:2px}\n.limits-cli-badge.live{border-color:rgba(63,185,80,.4);color:var(--green);background:rgba(63,185,80,.1)}\n.limits-cli-badge.error{border-color:rgba(248,81,73,.4);color:var(--red);background:rgba(248,81,73,.1)}\n.limits-cli-badge.warn{border-color:rgba(210,153,34,.4);color:var(--yellow);background:rgba(210,153,34,.1)}\n.limits-cli-badge.none{border-color:var(--border);color:var(--muted);background:transparent}\n.limits-gauge-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-bottom:10px}\n.limits-gauge{background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:10px 12px}\n.limits-gauge-label{font-size:11px;color:var(--muted);margin-bottom:6px;display:flex;justify-content:space-between}\n.limits-gauge-bar{height:6px;background:var(--bg4);border-radius:3px;overflow:hidden;margin-bottom:5px}\n.limits-gauge-fill{height:100%;border-radius:3px;transition:width .5s,background .5s}\n.limits-gauge-bottom{display:flex;justify-content:space-between;font-size:11px}\n.limits-gauge-remaining{color:var(--text);font-weight:500}\n.limits-gauge-reset{color:var(--muted)}\n.limits-usage-row{display:flex;gap:16px;flex-wrap:wrap;padding-top:8px;border-top:1px solid var(--border);margin-top:4px}\n.limits-usage-item{font-size:12px;color:var(--muted)}\n.limits-usage-item span{color:var(--text);font-weight:500}\n.limits-no-data{padding:16px;text-align:center;color:var(--muted);font-size:12px}\n.limits-billing-row{display:flex;gap:10px;flex-wrap:wrap;padding:8px 0 2px}\n.limits-credit-card{flex:1;min-width:120px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:10px 12px}\n.limits-credit-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}\n.limits-credit-val{font-size:20px;font-weight:600;color:var(--green)}\n.limits-budget-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px}\n.limits-budget-input{background:var(--bg3);border:1px solid var(--border);border-radius:5px;padding:5px 10px;color:var(--text);font-size:12px;width:140px;outline:none}\n.limits-budget-input:focus{border-color:var(--blue)}\n.limits-budget-label{font-size:12px;color:var(--muted)}\n\n/* \u2500\u2500 Footer bar \u2500\u2500 */\n#footer{padding:7px 20px;border-top:1px solid var(--border);background:var(--bg2);font-size:11px;color:var(--muted);display:flex;gap:16px;flex-shrink:0}\n#footer a{color:var(--muted)}#footer a:hover{color:var(--blue)}\n</style>\n</head>\n<body>\n<div id=\"app\">\n\n<!-- \u2500\u2500 Sidebar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<div id=\"sidebar\">\n <div id=\"sidebar-brand\">\n <div class=\"logo\">Squee<span>zr</span></div>\n <div class=\"ver\" id=\"sb-ver\">v\u2014</div>\n </div>\n\n <nav>\n <div class=\"nav-item active\" data-page=\"overview\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M1 2.5A1.5 1.5 0 012.5 1h3A1.5 1.5 0 017 2.5v3A1.5 1.5 0 015.5 7h-3A1.5 1.5 0 011 5.5v-3zm8 0A1.5 1.5 0 0110.5 1h3A1.5 1.5 0 0115 2.5v3A1.5 1.5 0 0113.5 7h-3A1.5 1.5 0 019 5.5v-3zm-8 8A1.5 1.5 0 012.5 9h3A1.5 1.5 0 017 10.5v3A1.5 1.5 0 015.5 15h-3A1.5 1.5 0 011 13.5v-3zm8 0A1.5 1.5 0 0110.5 9h3a1.5 1.5 0 011.5 1.5v3A1.5 1.5 0 0113.5 15h-3A1.5 1.5 0 019 13.5v-3z\"/>\n </svg>\n <span class=\"nav-label\">Overview</span>\n </div>\n <div class=\"nav-item\" data-page=\"projects\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M9.828 3h3.982a2 2 0 011.992 2.181l-.637 7A2 2 0 0113.174 14H2.826a2 2 0 01-1.991-1.819l-.637-7a1.99 1.99 0 01.342-1.31L.5 3a2 2 0 012-2h3.672a2 2 0 011.414.586l.828.828A2 2 0 009.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 006.172 2H2.5a1 1 0 00-1 .981l.006.139z\"/>\n </svg>\n <span class=\"nav-label\">Projects</span>\n </div>\n <div class=\"nav-item\" data-page=\"history\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M8 3.5a.5.5 0 00-1 0V9a.5.5 0 00.252.434l3.5 2a.5.5 0 00.496-.868L8 8.71V3.5z\"/>\n <path d=\"M8 16A8 8 0 108 0a8 8 0 000 16zm7-8A7 7 0 111 8a7 7 0 0114 0z\"/>\n </svg>\n <span class=\"nav-label\">History</span>\n </div>\n <div class=\"nav-item\" data-page=\"limits\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <line x1=\"18\" y1=\"20\" x2=\"18\" y2=\"10\"/>\n <line x1=\"12\" y1=\"20\" x2=\"12\" y2=\"4\"/>\n <line x1=\"6\" y1=\"20\" x2=\"6\" y2=\"14\"/>\n <line x1=\"2\" y1=\"20\" x2=\"22\" y2=\"20\"/>\n </svg>\n <span class=\"nav-label\">Limits</span>\n </div>\n <div class=\"nav-item\" data-page=\"settings\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M8 4.754a3.246 3.246 0 100 6.492 3.246 3.246 0 000-6.492zM5.754 8a2.246 2.246 0 114.492 0 2.246 2.246 0 01-4.492 0z\"/>\n <path d=\"M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 01-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 01-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 01.52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 011.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 011.255-.52l.292.16c1.64.892 3.433-.902 2.54-2.541l-.159-.292a.873.873 0 01.52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 01-.52-1.255l.16-.292c.892-1.64-.901-3.433-2.541-2.54l-.292.159a.873.873 0 01-1.255-.52l-.094-.319z\"/>\n </svg>\n <span class=\"nav-label\">Settings</span>\n </div>\n </nav>\n\n <div id=\"sidebar-footer\">\n <div class=\"status-row\">\n <div class=\"dot\" id=\"status-dot\"></div>\n <span id=\"status-text\">Connecting\u2026</span>\n </div>\n <div id=\"uptime-small\"></div>\n </div>\n</div>\n\n<!-- \u2500\u2500 Main content \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<div id=\"content\">\n <div id=\"page-header\">\n <span id=\"page-title\">Overview</span>\n <span id=\"project-badge\" style=\"display:none\"></span>\n <span id=\"header-uptime\"></span>\n <span id=\"conn-pill\">\u25CF live</span>\n </div>\n\n <div id=\"pages\">\n\n <!-- \u2500\u2500\u2500 Overview \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page active\" id=\"page-overview\">\n <div class=\"cards-grid\">\n <div class=\"card c-green\">\n <div class=\"card-label\">Tokens Saved</div>\n <div class=\"card-value\" id=\"c-tokens\">\u2014</div>\n <div class=\"card-sub\" id=\"c-chars\">\u2014 chars</div>\n </div>\n <div class=\"card c-blue\">\n <div class=\"card-label\">Compression</div>\n <div class=\"card-value\" id=\"c-pct\">\u2014</div>\n <div class=\"card-sub\">of tool results</div>\n </div>\n <div class=\"card c-yellow\">\n <div class=\"card-label\">Requests</div>\n <div class=\"card-value\" id=\"c-req\">\u2014</div>\n <div class=\"card-sub\" id=\"c-compressions\">\u2014 compressions</div>\n </div>\n <div class=\"card c-orange\">\n <div class=\"card-label\">Est. Cost Saved</div>\n <div class=\"card-value\" id=\"c-cost\">\u2014</div>\n <div class=\"card-sub\">@ $3 / MTok</div>\n </div>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">Context pressure \u2014 last request</div>\n <div class=\"bar-row\">\n <span class=\"bar-label\">Before compression</span>\n <div class=\"bar-track\"><div class=\"bar-fill\" id=\"bar-msg\" style=\"width:0%\"></div></div>\n <span class=\"bar-val\" id=\"pct-msg\">0%</span>\n </div>\n <div class=\"bar-row\">\n <span class=\"bar-label\">After compression</span>\n <div class=\"bar-track\"><div class=\"bar-fill\" id=\"bar-out\" style=\"width:0%\"></div></div>\n <span class=\"bar-val\" id=\"pct-out\">0%</span>\n </div>\n <div class=\"bar-row\" style=\"margin-bottom:0\">\n <span class=\"bar-label\">Session cache hits</span>\n <div class=\"bar-track\"><div class=\"bar-fill\" id=\"bar-cache\" style=\"width:0%;background:var(--purple)\"></div></div>\n <span class=\"bar-val\" id=\"pct-cache\">0</span>\n </div>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">Activity \u2014 tokens saved per request <span style=\"font-weight:400;text-transform:none;letter-spacing:0\">(last 60)</span></div>\n <canvas id=\"sparkline\"></canvas>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">By tool</div>\n <table>\n <thead>\n <tr>\n <th>Tool</th>\n <th class=\"td-right\">Calls</th>\n <th class=\"td-right\">Tokens saved</th>\n <th>Savings</th>\n </tr>\n </thead>\n <tbody id=\"tools-body\">\n <tr><td colspan=\"4\" style=\"color:var(--muted);padding:14px 8px;text-align:center\">No data yet\u2026</td></tr>\n </tbody>\n </table>\n </div>\n\n <div class=\"cache-row\">\n <div class=\"cache-card\">\n <div class=\"cache-label\">Session cache</div>\n <div class=\"cache-val\" id=\"c-scache\">\u2014</div>\n </div>\n <div class=\"cache-card\">\n <div class=\"cache-label\">Expand store</div>\n <div class=\"cache-val\" id=\"c-expand\">\u2014</div>\n </div>\n <div class=\"cache-card\">\n <div class=\"cache-label\">LRU cache</div>\n <div class=\"cache-val\" id=\"c-lru\">\u2014</div>\n </div>\n <div class=\"cache-card\">\n <div class=\"cache-label\">Pattern hits</div>\n <div class=\"cache-val\" id=\"c-patterns\">\u2014</div>\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 Projects \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-projects\">\n <div class=\"section\" style=\"margin-bottom:0\">\n <div class=\"section-title\" id=\"projects-section-title\">All projects \u2014 this session + history</div>\n <table class=\"project-table\">\n <thead>\n <tr>\n <th>Project</th>\n <th class=\"td-right\">Sessions</th>\n <th class=\"td-right\">Requests</th>\n <th class=\"td-right\">Tokens saved</th>\n <th class=\"td-right\">Last seen</th>\n </tr>\n </thead>\n <tbody id=\"projects-body\">\n <tr><td colspan=\"5\" style=\"color:var(--muted);padding:20px 8px;text-align:center\">Loading\u2026</td></tr>\n </tbody>\n </table>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 History \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-history\">\n <div id=\"hist-layout\">\n <div id=\"hist-projects\">\n <div style=\"padding:10px 14px;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid var(--border)\">Projects</div>\n <div id=\"hist-proj-list\"></div>\n </div>\n <div id=\"hist-sessions\">\n <div class=\"hist-sessions-header\" id=\"hist-sessions-header\">Select a project</div>\n <div id=\"hist-sessions-list\"><div class=\"empty-msg\">Select a project on the left to view sessions.</div></div>\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 Limits \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-limits\">\n\n <!-- Anthropic -->\n <div class=\"limits-cli-section\" id=\"lim-anthropic\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=\"color:var(--orange)\">\n <path d=\"M13.83 2.34a2.09 2.09 0 0 0-3.66 0L1.13 18.9A2.09 2.09 0 0 0 2.96 22h18.08a2.09 2.09 0 0 0 1.83-3.1L13.83 2.34ZM12 8a1 1 0 0 1 1 1v5a1 1 0 0 1-2 0V9a1 1 0 0 1 1-1Zm0 10a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z\"/>\n </svg>\n <span class=\"limits-cli-name\">Anthropic \u00B7 Claude Code</span>\n <span class=\"limits-cli-badge none\" id=\"ant-badge\">no data yet</span>\n </div>\n <div class=\"limits-gauge-grid\">\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Tokens / minute</span>\n <span id=\"ant-tok-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-tok-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-tok-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-tok-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Requests / minute</span>\n <span id=\"ant-req-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-req-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-req-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-req-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Input tokens / minute</span>\n <span id=\"ant-inp-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-inp-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-inp-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-inp-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Output tokens / minute</span>\n <span id=\"ant-out-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-out-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-out-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-out-reset\"></span>\n </div>\n </div>\n </div>\n <div class=\"limits-usage-row\">\n <div class=\"limits-usage-item\">Session input: <span id=\"ant-u-inp-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Session output: <span id=\"ant-u-out-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today input: <span id=\"ant-u-inp-d\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today output: <span id=\"ant-u-out-d\">\u2014</span></div>\n <div class=\"limits-usage-item\" style=\"margin-left:auto\">\n <a href=\"https://console.anthropic.com/settings/usage\" target=\"_blank\" style=\"color:var(--muted);font-size:11px\">View billing \u2197</a>\n </div>\n </div>\n </div>\n\n <!-- OpenAI -->\n <div class=\"limits-cli-section\" id=\"lim-openai\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=\"color:var(--text)\">\n <path d=\"M22.28 9.27a6.17 6.17 0 0 0-.53-5.06 6.24 6.24 0 0 0-6.7-2.99A6.23 6.23 0 0 0 10.36 0a6.24 6.24 0 0 0-5.95 4.32 6.23 6.23 0 0 0-4.16 3.02 6.24 6.24 0 0 0 .77 7.32 6.17 6.17 0 0 0 .53 5.06 6.24 6.24 0 0 0 6.7 2.99A6.23 6.23 0 0 0 13.64 24a6.25 6.25 0 0 0 5.96-4.33 6.23 6.23 0 0 0 4.15-3.02 6.24 6.24 0 0 0-.77-7.31l.3-.07ZM13.64 22.5a4.63 4.63 0 0 1-2.97-1.08l.15-.08 4.93-2.85a.82.82 0 0 0 .41-.71v-6.96l2.08 1.2a.08.08 0 0 1 .04.06v5.76a4.65 4.65 0 0 1-4.64 4.66Zm-9.95-4.27a4.63 4.63 0 0 1-.55-3.12l.14.09 4.93 2.85a.82.82 0 0 0 .82 0l6.02-3.47v2.4a.08.08 0 0 1-.03.06L10.06 20a4.65 4.65 0 0 1-6.37-1.77Zm-1.28-10.8a4.63 4.63 0 0 1 2.42-2.04v5.88a.82.82 0 0 0 .41.71l6.01 3.47-2.08 1.2a.08.08 0 0 1-.08 0L4.22 13.7a4.65 4.65 0 0 1-.81-6.27Zm17.09 3.99-6.02-3.48L15.56 7a.08.08 0 0 1 .08 0l4.87 2.81a4.64 4.64 0 0 1-.72 8.38v-5.88a.82.82 0 0 0-.39-.69Zm2.07-3.14-.14-.09-4.92-2.87a.82.82 0 0 0-.83 0L9.67 9.79V7.4a.08.08 0 0 1 .03-.06L14.6 4.5a4.64 4.64 0 0 1 6.9 4.81l.07-.03Zm-13.03 4.28-2.08-1.2a.08.08 0 0 1-.04-.06V5.5a4.64 4.64 0 0 1 7.62-3.56l-.15.08L7.9 4.87a.82.82 0 0 0-.41.71l-.01 6.98Zm1.13-2.43 2.68-1.55 2.68 1.55v3.1l-2.68 1.54-2.68-1.54v-3.1Z\"/>\n </svg>\n <span class=\"limits-cli-name\">OpenAI \u00B7 Codex</span>\n <span class=\"limits-cli-badge none\" id=\"oai-badge\">no data yet</span>\n </div>\n <div class=\"limits-gauge-grid\">\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Tokens / minute</span>\n <span id=\"oai-tok-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"oai-tok-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"oai-tok-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"oai-tok-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Requests / minute</span>\n <span id=\"oai-req-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"oai-req-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"oai-req-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"oai-req-reset\"></span>\n </div>\n </div>\n </div>\n <div class=\"limits-billing-row\" id=\"oai-billing-row\" style=\"display:none\">\n <div class=\"limits-credit-card\">\n <div class=\"limits-credit-label\">Credits remaining</div>\n <div class=\"limits-credit-val\" id=\"oai-credits\">\u2014</div>\n </div>\n <div class=\"limits-credit-card\">\n <div class=\"limits-credit-label\">Hard limit</div>\n <div class=\"limits-credit-val\" style=\"color:var(--yellow)\" id=\"oai-hard-lim\">\u2014</div>\n </div>\n </div>\n <div class=\"limits-usage-row\">\n <div class=\"limits-usage-item\">Session input: <span id=\"oai-u-inp-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Session output: <span id=\"oai-u-out-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today input: <span id=\"oai-u-inp-d\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today output: <span id=\"oai-u-out-d\">\u2014</span></div>\n <div class=\"limits-usage-item\" style=\"margin-left:auto\">\n <a href=\"https://platform.openai.com/usage\" target=\"_blank\" style=\"color:var(--muted);font-size:11px\">View billing \u2197</a>\n </div>\n </div>\n </div>\n\n <!-- Gemini -->\n <div class=\"limits-cli-section\" id=\"lim-gemini\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=\"color:var(--blue)\">\n <path d=\"M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0zm0 22C6.49 22 2 17.51 2 12S6.49 2 12 2s10 4.49 10 10-4.49 10-10 10zm-1-14h2v7h-2zm0 9h2v2h-2z\"/>\n </svg>\n <span class=\"limits-cli-name\">Google \u00B7 Gemini CLI</span>\n <span class=\"limits-cli-badge warn\" id=\"gem-badge\">only on 429 errors</span>\n </div>\n <div id=\"gem-nodata\" class=\"limits-no-data\">\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" style=\"margin-bottom:6px;display:block;margin-inline:auto;opacity:.4\">\n <circle cx=\"12\" cy=\"12\" r=\"10\"/><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"/><line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"/>\n </svg>\n Google does not expose quota headers on successful responses.<br>\n Data appears here only after a 429 rate-limit error.<br>\n <a href=\"https://aistudio.google.com/app/usage\" target=\"_blank\" style=\"margin-top:8px;display:inline-block\">View quotas in AI Studio \u2197</a>\n </div>\n <div id=\"gem-data\" style=\"display:none\">\n <div class=\"limits-gauge-grid\">\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\"><span>Last known token limit</span></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"gem-tok-lim\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"gem-errors\">0 errors</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"limits-usage-row\">\n <div class=\"limits-usage-item\">Session input: <span id=\"gem-u-inp-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Session output: <span id=\"gem-u-out-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today input: <span id=\"gem-u-inp-d\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today output: <span id=\"gem-u-out-d\">\u2014</span></div>\n <div class=\"limits-usage-item\" style=\"margin-left:auto\">\n <a href=\"https://aistudio.google.com/app/usage\" target=\"_blank\" style=\"color:var(--muted);font-size:11px\">View quotas \u2197</a>\n </div>\n </div>\n </div>\n\n <!-- Personal budget -->\n <div class=\"limits-cli-section\" style=\"margin-bottom:0\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\">\n <circle cx=\"12\" cy=\"12\" r=\"10\"/>\n <path d=\"M12 8v4l3 3\"/>\n </svg>\n <span class=\"limits-cli-name\">Personal daily budget</span>\n <span class=\"limits-cli-badge none\">optional</span>\n </div>\n <div class=\"limits-budget-row\">\n <input class=\"limits-budget-input\" id=\"budget-input\" type=\"number\" placeholder=\"e.g. 5000000\" min=\"0\">\n <span class=\"limits-budget-label\">tokens / day</span>\n <button class=\"btn-save\" id=\"budget-save\" style=\"padding:4px 12px;font-size:11px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--muted);cursor:pointer;transition:all .15s\" onmouseover=\"this.style.borderColor='var(--blue)';this.style.color='var(--text)'\" onmouseout=\"this.style.borderColor='var(--border)';this.style.color='var(--muted)'\">Save</button>\n </div>\n <div id=\"budget-bar-wrap\" style=\"margin-top:10px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px\">\n <span>Tokens used today through Squeezr</span>\n <span id=\"budget-pct-label\">0%</span>\n </div>\n <div class=\"limits-gauge-bar\" style=\"height:10px\">\n <div class=\"limits-gauge-fill\" id=\"budget-bar\" style=\"width:0%\"></div>\n </div>\n <div style=\"display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-top:4px\">\n <span id=\"budget-used-label\">0 used</span>\n <span id=\"budget-limit-label\">of \u2014</span>\n </div>\n </div>\n </div>\n\n </div>\n\n <!-- \u2500\u2500\u2500 Settings \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-settings\">\n <div class=\"section\" style=\"margin-bottom:14px\">\n <div class=\"section-title\">Compression mode</div>\n <div class=\"mode-btns\">\n <button class=\"mode-btn\" data-mode=\"soft\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\">\n <path d=\"M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2\"/>\n </svg>\n Soft\n </button>\n <button class=\"mode-btn active\" data-mode=\"normal\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <line x1=\"4\" y1=\"21\" x2=\"4\" y2=\"14\"/><line x1=\"4\" y1=\"10\" x2=\"4\" y2=\"3\"/>\n <line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"12\"/><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"3\"/>\n <line x1=\"20\" y1=\"21\" x2=\"20\" y2=\"16\"/><line x1=\"20\" y1=\"12\" x2=\"20\" y2=\"3\"/>\n <line x1=\"1\" y1=\"14\" x2=\"7\" y2=\"14\"/><line x1=\"9\" y1=\"8\" x2=\"15\" y2=\"8\"/>\n <line x1=\"17\" y1=\"16\" x2=\"23\" y2=\"16\"/>\n </svg>\n Normal\n </button>\n <button class=\"mode-btn\" data-mode=\"aggressive\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/>\n </svg>\n Aggressive\n </button>\n <button class=\"mode-btn\" data-mode=\"critical\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"/>\n <line x1=\"12\" y1=\"9\" x2=\"12\" y2=\"13\"/>\n <line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"/>\n </svg>\n Critical\n </button>\n </div>\n <div id=\"mode-desc\">Normal \u2014 threshold 800 chars, last 3 results uncompressed</div>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">Configuration</div>\n <div id=\"config-rows\">\n <div class=\"config-row\"><span class=\"config-key\">Mode</span><span class=\"config-val\" id=\"cfg-mode\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Port</span><span class=\"config-val\" id=\"cfg-port\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Dry-run</span><span class=\"config-val\" id=\"cfg-dryrun\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">LRU cache entries</span><span class=\"config-val\" id=\"cfg-lru\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Session cache entries</span><span class=\"config-val\" id=\"cfg-scache\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Version</span><span class=\"config-val\" id=\"cfg-version\">\u2014</span></div>\n </div>\n </div>\n\n <div class=\"section\" style=\"margin-bottom:0\">\n <div class=\"section-title\">Links</div>\n <div style=\"display:flex;gap:16px;flex-wrap:wrap;font-size:12px\">\n <a href=\"/squeezr/stats\" target=\"_blank\">/squeezr/stats JSON</a>\n <a href=\"/squeezr/history\" target=\"_blank\">/squeezr/history JSON</a>\n <a href=\"/squeezr/projects\" target=\"_blank\">/squeezr/projects JSON</a>\n <a href=\"https://github.com/sergioramosv/Squeezr\" target=\"_blank\">GitHub</a>\n </div>\n </div>\n </div>\n\n </div><!-- /pages -->\n\n <div id=\"footer\">\n <span>Squeezr v<span id=\"f-version\">\u2014</span></span>\n <span id=\"f-mode\">mode: active</span>\n <span id=\"f-port\"></span>\n <span id=\"conn-status\" style=\"margin-left:auto;color:var(--green)\">\u25CF connected</span>\n </div>\n</div><!-- /content -->\n\n</div><!-- /app -->\n\n<script>\n// \u2500\u2500 Sparkline \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst MAX_PTS = 60\nconst sparkData = []\nlet lastTokens = 0\nfunction pushSpark(t) {\n sparkData.push(Math.max(0, t - lastTokens))\n lastTokens = t\n if (sparkData.length > MAX_PTS) sparkData.shift()\n}\nfunction drawSpark() {\n const cv = document.getElementById('sparkline')\n if (!cv) return\n const dpr = window.devicePixelRatio || 1\n const r = cv.getBoundingClientRect()\n cv.width = r.width * dpr; cv.height = r.height * dpr\n const ctx = cv.getContext('2d')\n ctx.scale(dpr, dpr)\n const w = r.width, h = r.height\n const mx = Math.max(...sparkData, 1)\n ctx.clearRect(0, 0, w, h)\n if (sparkData.length < 2) return\n const step = w / (MAX_PTS - 1)\n ctx.beginPath(); ctx.moveTo(0, h)\n sparkData.forEach((v, i) => ctx.lineTo(i * step, h - (v / mx) * (h - 4)))\n ctx.lineTo((sparkData.length - 1) * step, h)\n ctx.closePath()\n const g = ctx.createLinearGradient(0, 0, 0, h)\n g.addColorStop(0, 'rgba(63,185,80,.3)'); g.addColorStop(1, 'rgba(63,185,80,0)')\n ctx.fillStyle = g; ctx.fill()\n ctx.beginPath()\n sparkData.forEach((v, i) => {\n const x = i * step, y = h - (v / mx) * (h - 4)\n i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)\n })\n ctx.strokeStyle = '#3fb950'; ctx.lineWidth = 1.5; ctx.stroke()\n}\nwindow.addEventListener('resize', drawSpark)\n\n// \u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction fmtN(n) {\n if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'\n if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'\n return String(n)\n}\nfunction fmtCost(tok) {\n const u = (tok / 1e6) * 3\n return u < 0.01 ? '<$0.01' : u < 1 ? '$' + u.toFixed(3) : '$' + u.toFixed(2)\n}\nfunction fmtUptime(s) {\n if (s < 60) return s + 's'\n if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's'\n return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'\n}\nfunction fmtTs(ms) {\n if (!ms) return '\u2014'\n const d = new Date(ms)\n return d.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})\n}\nfunction fmtTime(ms) {\n if (!ms) return '\u2014'\n const d = new Date(ms)\n return d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false})\n}\nfunction fmtDur(startMs, endMs) {\n const s = Math.round((endMs - startMs) / 1000)\n if (s < 60) return s + 's'\n if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's'\n return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'\n}\nfunction barColor(p) {\n if (p >= 90) return 'var(--red)'\n if (p >= 75) return 'var(--yellow)'\n if (p >= 50) return 'var(--orange)'\n return 'var(--blue)'\n}\nfunction setBar(bid, vid, pct, label, noColor) {\n const b = document.getElementById(bid), v = document.getElementById(vid)\n b.style.width = Math.min(pct, 100) + '%'\n if (!noColor) b.style.background = barColor(pct)\n v.textContent = label\n}\nconst PROJECT_COLORS = ['#58a6ff','#3fb950','#ffa657','#bc8cff','#d29922','#f85149','#79c0ff','#56d364']\nfunction projectColor(name) {\n let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffff\n return PROJECT_COLORS[h % PROJECT_COLORS.length]\n}\n\n// \u2500\u2500 Overview render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction renderOverview(d) {\n document.getElementById('c-tokens').textContent = fmtN(d.total_saved_tokens)\n document.getElementById('c-chars').textContent = (d.total_saved_chars || 0).toLocaleString() + ' chars'\n document.getElementById('c-pct').textContent = (d.savings_pct || 0) + '%'\n document.getElementById('c-req').textContent = fmtN(d.requests || 0)\n document.getElementById('c-compressions').textContent = (d.compressions || 0) + ' compressions'\n document.getElementById('c-cost').textContent = fmtCost(d.total_saved_tokens || 0)\n document.getElementById('f-version').textContent = d.version || '\u2014'\n document.getElementById('sb-ver').textContent = 'v' + (d.version || '\u2014')\n document.getElementById('f-mode').textContent = 'mode: ' + (d.dry_run ? 'dry-run' : 'active')\n document.getElementById('f-port').textContent = 'port: ' + (d.port || '\u2014')\n document.getElementById('header-uptime').textContent = 'uptime ' + fmtUptime(d.uptime_seconds || 0)\n document.getElementById('uptime-small').textContent = fmtUptime(d.uptime_seconds || 0)\n\n // Project badge\n const proj = d.current_project\n const badge = document.getElementById('project-badge')\n if (proj && proj !== 'unknown') {\n badge.textContent = proj\n badge.style.display = ''\n badge.style.borderColor = projectColor(proj)\n badge.style.color = projectColor(proj)\n } else {\n badge.style.display = 'none'\n }\n\n // Pressure bars\n const msgPct = Math.min(Math.round((d.last_original_chars || 0) / 80), 100)\n const outPct = Math.min(Math.round((d.last_compressed_chars || 0) / 80), 100)\n const ch = d.session_cache_hits || 0\n const cachePct = Math.round((ch / Math.max(ch + (d.compressions || 1), 1)) * 100)\n setBar('bar-msg', 'pct-msg', msgPct, msgPct + '%')\n setBar('bar-out', 'pct-out', outPct, outPct + '%')\n setBar('bar-cache', 'pct-cache', cachePct, ch, true)\n\n // Sparkline\n pushSpark(d.total_saved_tokens || 0)\n drawSpark()\n\n // Tool table\n const bt = d.by_tool || {}\n const rows = Object.entries(bt).sort((a, b) => b[1].saved_tokens - a[1].saved_tokens)\n const maxSaved = rows[0]?.[1]?.saved_tokens || 1\n const tbody = document.getElementById('tools-body')\n if (rows.length === 0) {\n tbody.innerHTML = '<tr><td colspan=\"4\" style=\"color:var(--muted);padding:14px 8px;text-align:center\">No tool results compressed yet\u2026</td></tr>'\n } else {\n tbody.innerHTML = rows.map(([tool, t]) => {\n const bw = Math.round((t.saved_tokens / maxSaved) * 72)\n return `<tr>\n <td><code class=\"tag\">${tool}</code></td>\n <td class=\"td-right\" style=\"color:var(--muted)\">${t.count}</td>\n <td class=\"td-right\">${fmtN(t.saved_tokens)}</td>\n <td><span class=\"mini-bar\" style=\"width:${bw}px;background:var(--green)\"></span>${t.avg_pct}%</td>\n </tr>`\n }).join('')\n }\n\n // Cache stats\n document.getElementById('c-scache').textContent = d.session_cache_size ?? '\u2014'\n document.getElementById('c-expand').textContent = d.expand_store_size ?? '\u2014'\n document.getElementById('c-lru').textContent = d.cache?.size ?? '\u2014'\n document.getElementById('c-patterns').textContent = d.pattern_hits\n ? Object.values(d.pattern_hits).reduce((s, v) => s + v, 0).toLocaleString()\n : '\u2014'\n\n // Settings config panel\n document.getElementById('cfg-mode').textContent = d.mode || '\u2014'\n document.getElementById('cfg-port').textContent = d.port || '\u2014'\n document.getElementById('cfg-dryrun').textContent = d.dry_run ? 'yes' : 'no'\n document.getElementById('cfg-lru').textContent = d.cache?.size ?? '\u2014'\n document.getElementById('cfg-scache').textContent = d.session_cache_size ?? '\u2014'\n document.getElementById('cfg-version').textContent = d.version || '\u2014'\n\n // Sync active mode button\n document.querySelectorAll('.mode-btn').forEach(b => {\n b.classList.toggle('active', b.dataset.mode === d.mode)\n })\n const modeMap = {\n soft: 'Soft \u2014 threshold 3000 chars, last 10 results uncompressed, no AI',\n normal: 'Normal \u2014 threshold 800 chars, last 3 results uncompressed',\n aggressive: 'Aggressive \u2014 threshold 200 chars, last 1 result uncompressed',\n critical: 'Critical \u2014 threshold 50 chars, everything compressed'\n }\n document.getElementById('mode-desc').textContent = modeMap[d.mode] || ''\n}\n\n// \u2500\u2500 Projects page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function loadProjects() {\n try {\n const r = await fetch('/squeezr/projects')\n const { projects } = await r.json()\n const tbody = document.getElementById('projects-body')\n const entries = Object.entries(projects).sort((a, b) => b[1].savedTokens - a[1].savedTokens)\n if (entries.length === 0) {\n tbody.innerHTML = '<tr><td colspan=\"5\" style=\"color:var(--muted);padding:20px 8px;text-align:center\">No project data yet \u2014 start making requests.</td></tr>'\n return\n }\n tbody.innerHTML = entries.map(([name, p]) => `<tr>\n <td><span class=\"project-dot\" style=\"background:${projectColor(name)}\"></span><code>${name}</code></td>\n <td class=\"td-right\" style=\"color:var(--muted)\">${p.sessions}</td>\n <td class=\"td-right\">${p.requests}</td>\n <td class=\"td-right\" style=\"color:var(--green)\">${fmtN(p.savedTokens)}</td>\n <td class=\"td-right\" style=\"color:var(--muted);font-size:11px\">${p.lastSeen ? fmtTs(p.lastSeen) : '\u2014'}</td>\n </tr>`).join('')\n } catch {\n document.getElementById('projects-body').innerHTML = '<tr><td colspan=\"5\" style=\"color:var(--muted);padding:20px 8px;text-align:center\">Failed to load projects.</td></tr>'\n }\n}\n\n// \u2500\u2500 History page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet histData = null\nlet selectedHistProj = '__all__'\n\nasync function loadHistory() {\n try {\n const r = await fetch('/squeezr/history')\n histData = await r.json()\n renderHistProjects()\n renderHistSessions()\n } catch {\n document.getElementById('hist-proj-list').innerHTML = '<div class=\"empty-msg\">Failed to load history.</div>'\n }\n}\n\nfunction renderHistProjects() {\n if (!histData) return\n const all = [...histData.sessions]\n if (histData.current && histData.current.requests > 0) {\n const idx = all.findIndex(s => s.id === histData.current.id)\n if (idx >= 0) all[idx] = histData.current; else all.push(histData.current)\n }\n\n // Group by project\n const byProj = {}\n for (const s of all) {\n if (!byProj[s.project]) byProj[s.project] = 0\n byProj[s.project]++\n }\n\n const list = document.getElementById('hist-proj-list')\n let html = `<div class=\"hist-proj-item${selectedHistProj === '__all__' ? ' active' : ''}\" data-proj=\"__all__\">\n <span>All projects</span>\n <span class=\"hist-proj-count\">${all.length}</span>\n </div>`\n for (const [name, cnt] of Object.entries(byProj).sort((a, b) => b[1] - a[1])) {\n const active = selectedHistProj === name ? ' active' : ''\n html += `<div class=\"hist-proj-item${active}\" data-proj=\"${name}\">\n <span><span class=\"project-dot\" style=\"background:${projectColor(name)}\"></span>${name}</span>\n <span class=\"hist-proj-count\">${cnt}</span>\n </div>`\n }\n list.innerHTML = html\n\n list.querySelectorAll('.hist-proj-item').forEach(el => {\n el.addEventListener('click', () => {\n selectedHistProj = el.dataset.proj\n list.querySelectorAll('.hist-proj-item').forEach(x => x.classList.remove('active'))\n el.classList.add('active')\n renderHistSessions()\n })\n })\n}\n\nfunction renderHistSessions() {\n if (!histData) return\n let sessions = [...histData.sessions]\n if (histData.current && histData.current.requests > 0) {\n const idx = sessions.findIndex(s => s.id === histData.current.id)\n if (idx >= 0) sessions[idx] = histData.current; else sessions.push(histData.current)\n }\n // Sort newest first\n sessions.sort((a, b) => b.startTime - a.startTime)\n\n if (selectedHistProj !== '__all__') {\n sessions = sessions.filter(s => s.project === selectedHistProj)\n }\n\n const header = document.getElementById('hist-sessions-header')\n header.textContent = selectedHistProj === '__all__'\n ? `All sessions (${sessions.length})`\n : `${selectedHistProj} \u2014 ${sessions.length} session${sessions.length !== 1 ? 's' : ''}`\n\n const list = document.getElementById('hist-sessions-list')\n if (sessions.length === 0) {\n list.innerHTML = '<div class=\"empty-msg\">No sessions found.</div>'\n return\n }\n\n // Group by day\n const byDay = {}\n for (const s of sessions) {\n const day = new Date(s.startTime).toLocaleDateString('en-US',{weekday:'short',month:'short',day:'numeric',year:'numeric'})\n if (!byDay[day]) byDay[day] = []\n byDay[day].push(s)\n }\n\n let html = ''\n for (const [day, daySessions] of Object.entries(byDay)) {\n html += `<div style=\"padding:8px 16px;font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.4px;background:var(--bg3);border-bottom:1px solid var(--border)\">${day}</div>`\n for (const s of daySessions) {\n const isCurrent = s.id === histData.current?.id\n const projBadge = selectedHistProj === '__all__' ? `<span class=\"session-project-badge\">${s.project}</span>` : ''\n html += `<div class=\"session-card\">\n <div class=\"session-date\">\n ${fmtTime(s.startTime)} \u2192 ${fmtTime(s.endTime)}\n <span style=\"color:var(--muted);font-weight:400\"> (${fmtDur(s.startTime, s.endTime)})</span>\n ${isCurrent ? '<span style=\"font-size:10px;color:var(--green);margin-left:8px\">\u25CF active</span>' : ''}\n ${projBadge}\n </div>\n <div class=\"session-stats\">\n <div class=\"session-stat\">Requests: <span>${s.requests}</span></div>\n <div class=\"session-stat\">Tokens saved: <span style=\"color:var(--green)\">${fmtN(s.savedTokens)}</span></div>\n <div class=\"session-stat\">Compressions: <span>${s.compressions}</span></div>\n </div>\n </div>`\n }\n }\n list.innerHTML = html\n}\n\n// \u2500\u2500 Limits page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet limitsCountdownTimer = null\n\nfunction fmtTokens(n) {\n if (!n && n !== 0) return '\u2014'\n if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'\n if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'\n return String(n)\n}\n\nfunction gaugeColor(pct) {\n if (pct >= 90) return 'var(--red)'\n if (pct >= 70) return 'var(--yellow)'\n if (pct >= 40) return 'var(--orange)'\n return 'var(--green)'\n}\n\nfunction fillGauge(fillId, pctId, remId, resetId, remaining, limit, resetEpoch) {\n if (!limit) {\n document.getElementById(fillId).style.width = '0%'\n document.getElementById(pctId).textContent = '\u2014'\n document.getElementById(remId).textContent = '\u2014'\n if (resetId) document.getElementById(resetId).textContent = ''\n return\n }\n const used = limit - remaining\n const pct = Math.max(0, Math.min(100, Math.round((used / limit) * 100)))\n const fill = document.getElementById(fillId)\n fill.style.width = pct + '%'\n fill.style.background = gaugeColor(pct)\n document.getElementById(pctId).textContent = pct + '% used'\n document.getElementById(pctId).style.color = gaugeColor(pct)\n document.getElementById(remId).textContent = fmtTokens(remaining) + ' remaining'\n if (resetId && resetEpoch) {\n const secs = Math.max(0, Math.round((resetEpoch - Date.now()) / 1000))\n document.getElementById(resetId).textContent = secs > 0 ? 'resets in ' + secs + 's' : 'resetting\u2026'\n }\n}\n\nfunction renderLimits(d) {\n if (!d) return\n const { anthropic, openai, gemini } = d\n\n // \u2500\u2500 Anthropic \u2500\u2500\n const arl = anthropic?.rl\n if (arl?.hasData) {\n document.getElementById('ant-badge').className = 'limits-cli-badge live'\n document.getElementById('ant-badge').textContent = 'live'\n fillGauge('ant-tok-fill','ant-tok-pct','ant-tok-rem','ant-tok-reset', arl.tokensRemaining, arl.tokensLimit, arl.tokensResetEpoch)\n fillGauge('ant-req-fill','ant-req-pct','ant-req-rem','ant-req-reset', arl.requestsRemaining, arl.requestsLimit, arl.requestsResetEpoch)\n fillGauge('ant-inp-fill','ant-inp-pct','ant-inp-rem','ant-inp-reset', arl.inputTokensRemaining, arl.inputTokensLimit, arl.tokensResetEpoch)\n fillGauge('ant-out-fill','ant-out-pct','ant-out-rem','ant-out-reset', arl.outputTokensRemaining, arl.outputTokensLimit, arl.tokensResetEpoch)\n }\n const au = anthropic?.usage\n if (au) {\n document.getElementById('ant-u-inp-s').textContent = fmtTokens(au.inputSession)\n document.getElementById('ant-u-out-s').textContent = fmtTokens(au.outputSession)\n document.getElementById('ant-u-inp-d').textContent = fmtTokens(au.inputToday)\n document.getElementById('ant-u-out-d').textContent = fmtTokens(au.outputToday)\n }\n\n // \u2500\u2500 OpenAI \u2500\u2500\n const orl = openai?.rl\n if (orl?.hasData) {\n document.getElementById('oai-badge').className = 'limits-cli-badge live'\n document.getElementById('oai-badge').textContent = 'live'\n fillGauge('oai-tok-fill','oai-tok-pct','oai-tok-rem','oai-tok-reset', orl.tokensRemaining, orl.tokensLimit, orl.tokensResetEpoch)\n fillGauge('oai-req-fill','oai-req-pct','oai-req-rem','oai-req-reset', orl.requestsRemaining, orl.requestsLimit, orl.requestsResetEpoch)\n }\n const ob = openai?.billing\n if (ob?.hardLimitUsd > 0) {\n document.getElementById('oai-billing-row').style.display = 'flex'\n document.getElementById('oai-credits').textContent = '$' + (ob.creditBalanceUsd || 0).toFixed(2)\n document.getElementById('oai-hard-lim').textContent = '$' + ob.hardLimitUsd.toFixed(2)\n }\n const ou = openai?.usage\n if (ou) {\n document.getElementById('oai-u-inp-s').textContent = fmtTokens(ou.inputSession)\n document.getElementById('oai-u-out-s').textContent = fmtTokens(ou.outputSession)\n document.getElementById('oai-u-inp-d').textContent = fmtTokens(ou.inputToday)\n document.getElementById('oai-u-out-d').textContent = fmtTokens(ou.outputToday)\n }\n\n // \u2500\u2500 Gemini \u2500\u2500\n const ge = gemini?.errors\n if (ge?.hasData) {\n document.getElementById('gem-nodata').style.display = 'none'\n document.getElementById('gem-data').style.display = 'block'\n document.getElementById('gem-tok-lim').textContent = fmtTokens(gemini.rl?.tokensLimit)\n document.getElementById('gem-errors').textContent = ge.errorCount429 + ' rate-limit errors'\n document.getElementById('gem-badge').className = 'limits-cli-badge error'\n document.getElementById('gem-badge').textContent = ge.errorCount429 + ' 429 errors'\n }\n const gu = gemini?.usage\n if (gu) {\n document.getElementById('gem-u-inp-s').textContent = fmtTokens(gu.inputSession)\n document.getElementById('gem-u-out-s').textContent = fmtTokens(gu.outputSession)\n document.getElementById('gem-u-inp-d').textContent = fmtTokens(gu.inputToday)\n document.getElementById('gem-u-out-d').textContent = fmtTokens(gu.outputToday)\n }\n\n // \u2500\u2500 Budget \u2500\u2500\n updateBudgetBar(au, ou, gu)\n}\n\n// Countdown ticker \u2014 updates reset countdowns every second without SSE\nfunction startLimitsCountdown(limitsData) {\n if (limitsCountdownTimer) clearInterval(limitsCountdownTimer)\n limitsCountdownTimer = setInterval(() => {\n const updateReset = (id, resetEpoch) => {\n if (!resetEpoch) return\n const el = document.getElementById(id)\n if (!el) return\n const secs = Math.max(0, Math.round((resetEpoch - Date.now()) / 1000))\n el.textContent = secs > 0 ? 'resets in ' + secs + 's' : 'resetting\u2026'\n }\n const d = limitsData\n if (d?.anthropic?.rl?.hasData) {\n updateReset('ant-tok-reset', d.anthropic.rl.tokensResetEpoch)\n updateReset('ant-req-reset', d.anthropic.rl.requestsResetEpoch)\n updateReset('ant-inp-reset', d.anthropic.rl.tokensResetEpoch)\n updateReset('ant-out-reset', d.anthropic.rl.tokensResetEpoch)\n }\n if (d?.openai?.rl?.hasData) {\n updateReset('oai-tok-reset', d.openai.rl.tokensResetEpoch)\n updateReset('oai-req-reset', d.openai.rl.requestsResetEpoch)\n }\n }, 1000)\n}\n\n// \u2500\u2500 Budget logic \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet dailyBudget = parseInt(localStorage.getItem('squeezr_budget') || '0')\n\nfunction updateBudgetBar(au, ou, gu) {\n const budget = dailyBudget\n const budgetInput = document.getElementById('budget-input')\n if (budgetInput && !budgetInput.value) budgetInput.value = budget || ''\n\n const wrap = document.getElementById('budget-bar-wrap')\n if (!budget) { wrap.style.display = 'none'; return }\n wrap.style.display = 'block'\n\n const totalToday = ((au?.inputToday || 0) + (au?.outputToday || 0) +\n (ou?.inputToday || 0) + (ou?.outputToday || 0) +\n (gu?.inputToday || 0) + (gu?.outputToday || 0))\n const pct = Math.min(100, Math.round((totalToday / budget) * 100))\n const fill = document.getElementById('budget-bar')\n fill.style.width = pct + '%'\n fill.style.background = gaugeColor(pct)\n document.getElementById('budget-pct-label').textContent = pct + '%'\n document.getElementById('budget-pct-label').style.color = gaugeColor(pct)\n document.getElementById('budget-used-label').textContent = fmtTokens(totalToday) + ' used today'\n document.getElementById('budget-limit-label').textContent = 'of ' + fmtTokens(budget) + ' / day'\n}\n\ndocument.getElementById('budget-save').addEventListener('click', () => {\n const val = parseInt(document.getElementById('budget-input').value || '0')\n dailyBudget = val\n localStorage.setItem('squeezr_budget', String(val))\n document.getElementById('budget-save').textContent = '\u2713 Saved'\n setTimeout(() => document.getElementById('budget-save').textContent = 'Save', 2000)\n updateBudgetBar(null, null, null)\n})\n\n// Restore budget from localStorage on load\nconst savedBudget = localStorage.getItem('squeezr_budget')\nif (savedBudget) document.getElementById('budget-input').value = savedBudget\n\n// \u2500\u2500 Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst pageTitles = { overview: 'Overview', projects: 'Projects', history: 'History', limits: 'Limits', settings: 'Settings' }\n\ndocument.querySelectorAll('.nav-item').forEach(item => {\n item.addEventListener('click', () => {\n const page = item.dataset.page\n document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'))\n document.querySelectorAll('.page').forEach(p => p.classList.remove('active'))\n item.classList.add('active')\n document.getElementById('page-' + page).classList.add('active')\n document.getElementById('page-title').textContent = pageTitles[page] || page\n if (page === 'projects') loadProjects()\n if (page === 'history') loadHistory()\n if (page === 'limits') {\n if (lastLimitsData) {\n renderLimits(lastLimitsData)\n startLimitsCountdown(lastLimitsData)\n }\n }\n if (page !== 'limits' && limitsCountdownTimer) {\n clearInterval(limitsCountdownTimer)\n limitsCountdownTimer = null\n }\n })\n})\n\n// \u2500\u2500 Mode selector \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\ndocument.querySelectorAll('.mode-btn[data-mode]').forEach(btn => {\n btn.addEventListener('click', async () => {\n const mode = btn.dataset.mode\n if (!mode) return\n document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'))\n btn.classList.add('active')\n try {\n await fetch('/squeezr/config', {\n method: 'POST',\n headers: {'content-type':'application/json'},\n body: JSON.stringify({ mode })\n })\n } catch(e) { console.error('mode update failed', e) }\n })\n})\n\n// \u2500\u2500 SSE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst dot = document.getElementById('status-dot')\nconst statusText = document.getElementById('status-text')\nconst connPill = document.getElementById('conn-pill')\nconst connStatus = document.getElementById('conn-status')\nlet lastLimitsData = null\n\nfunction connect() {\n const es = new EventSource('/squeezr/events')\n es.onmessage = e => {\n try {\n const d = JSON.parse(e.data)\n renderOverview(d)\n if (d.limits) {\n lastLimitsData = d.limits\n // Only render limits page if it's currently visible\n const limPage = document.getElementById('page-limits')\n if (limPage && limPage.classList.contains('active')) {\n renderLimits(d.limits)\n if (!limitsCountdownTimer) startLimitsCountdown(d.limits)\n else { /* update the data reference for the countdown */ lastLimitsData = d.limits }\n }\n }\n } catch(err) { console.error(err) }\n }\n es.onopen = () => {\n dot.classList.remove('off')\n statusText.textContent = 'Running'\n connPill.className = ''\n connPill.textContent = '\u25CF live'\n connStatus.style.color = 'var(--green)'\n connStatus.textContent = '\u25CF connected'\n }\n es.onerror = () => {\n dot.classList.add('off')\n statusText.textContent = 'Reconnecting\u2026'\n connPill.className = 'err'\n connPill.textContent = '\u25CF offline'\n connStatus.style.color = 'var(--red)'\n connStatus.textContent = '\u25CF reconnecting\u2026'\n es.close()\n setTimeout(connect, 3000)\n }\n}\nconnect()\n</script>\n</body>\n</html>";
6
+ export declare const DASHBOARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Squeezr Dashboard</title>\n<style>\n*{box-sizing:border-box;margin:0;padding:0}\n:root{\n --bg:#0d1117;--bg2:#161b22;--bg3:#21262d;--bg4:#2d333b;\n --border:#30363d;--text:#e6edf3;--muted:#8b949e;\n --green:#3fb950;--yellow:#d29922;--red:#f85149;\n --blue:#58a6ff;--purple:#bc8cff;--orange:#ffa657;--accent:#238636\n}\nhtml,body{height:100%;background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,-apple-system,sans-serif;font-size:13px;line-height:1.5}\na{color:var(--blue);text-decoration:none}\ncode{font-family:'Cascadia Code','Fira Mono','Consolas',monospace}\n\n/* \u2500\u2500 App shell \u2500\u2500 */\n#app{display:flex;height:100vh;overflow:hidden}\n\n/* \u2500\u2500 Sidebar \u2500\u2500 */\n#sidebar{\n width:200px;flex-shrink:0;background:var(--bg2);\n border-right:1px solid var(--border);\n display:flex;flex-direction:column;overflow:hidden\n}\n#sidebar-brand{padding:16px 16px 12px;border-bottom:1px solid var(--border)}\n#sidebar-brand .logo{font-size:18px;font-weight:700;letter-spacing:.3px;line-height:1}\n#sidebar-brand .logo span{color:var(--blue)}\n#sidebar-brand .ver{font-size:11px;color:var(--muted);margin-top:3px}\n\nnav{flex:1;padding:8px 0;overflow-y:auto}\n.nav-item{\n display:flex;align-items:center;gap:9px;padding:8px 16px;\n color:var(--muted);cursor:pointer;border-radius:0;\n transition:background .1s,color .1s;user-select:none\n}\n.nav-item:hover{background:var(--bg3);color:var(--text)}\n.nav-item.active{background:var(--bg3);color:var(--blue)}\n.nav-item svg{flex-shrink:0;opacity:.8}\n.nav-item.active svg{opacity:1}\n.nav-label{font-size:13px}\n\n#sidebar-footer{padding:12px 16px;border-top:1px solid var(--border)}\n.status-row{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted)}\n.dot{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 5px var(--green);flex-shrink:0}\n.dot.off{background:var(--red);box-shadow:0 0 5px var(--red)}\n#uptime-small{font-size:11px;color:var(--muted);margin-top:4px}\n\n/* \u2500\u2500 Main content \u2500\u2500 */\n#content{flex:1;display:flex;flex-direction:column;overflow:hidden}\n#page-header{\n display:flex;align-items:center;gap:10px;padding:12px 20px;\n background:var(--bg2);border-bottom:1px solid var(--border);flex-shrink:0\n}\n#page-title{font-size:15px;font-weight:600}\n#project-badge{\n font-size:11px;background:var(--bg3);border:1px solid var(--border);\n border-radius:12px;padding:2px 10px;color:var(--blue);font-weight:500\n}\n#header-uptime{font-size:11px;color:var(--muted);margin-left:auto}\n#conn-pill{\n font-size:11px;padding:2px 8px;border-radius:10px;\n background:rgba(63,185,80,.15);color:var(--green);border:1px solid rgba(63,185,80,.3)\n}\n#conn-pill.err{background:rgba(248,81,73,.15);color:var(--red);border-color:rgba(248,81,73,.3)}\n\n#pages{flex:1;overflow-y:auto;padding:16px 20px}\n.page{display:none}\n.page.active{display:block}\n\n/* \u2500\u2500 Cards \u2500\u2500 */\n.cards-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(175px,1fr));gap:10px;margin-bottom:14px}\n.card{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px 16px}\n.card-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}\n.card-value{font-size:26px;font-weight:700;line-height:1.1}\n.card-sub{font-size:11px;color:var(--muted);margin-top:3px}\n.c-green .card-value{color:var(--green)}\n.c-blue .card-value{color:var(--blue)}\n.c-yellow .card-value{color:var(--yellow)}\n.c-orange .card-value{color:var(--orange)}\n\n/* \u2500\u2500 Sections \u2500\u2500 */\n.section{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:14px}\n.section-title{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;font-weight:600}\n\n/* \u2500\u2500 Bars \u2500\u2500 */\n.bar-row{display:flex;align-items:center;gap:8px;margin-bottom:7px}\n.bar-label{font-size:12px;color:var(--muted);width:130px;flex-shrink:0}\n.bar-track{flex:1;height:7px;background:var(--bg3);border-radius:4px;overflow:hidden}\n.bar-fill{height:100%;border-radius:4px;transition:width .4s,background .4s}\n.bar-val{font-size:11px;width:36px;text-align:right;flex-shrink:0;color:var(--muted)}\n\n/* \u2500\u2500 Sparkline \u2500\u2500 */\ncanvas#sparkline{width:100%;height:72px;display:block}\n\n/* \u2500\u2500 Tables \u2500\u2500 */\ntable{width:100%;border-collapse:collapse}\nth{font-size:11px;color:var(--muted);text-align:left;padding:4px 8px;border-bottom:1px solid var(--border);font-weight:500;letter-spacing:.3px;text-transform:uppercase}\ntd{padding:6px 8px;font-size:12px;border-bottom:1px solid var(--border)}\ntr:last-child td{border-bottom:none}\n.td-right{text-align:right;font-variant-numeric:tabular-nums}\n.mini-bar{display:inline-block;height:5px;border-radius:2px;vertical-align:middle;margin-right:5px;opacity:.75}\n.tag{display:inline-block;background:var(--bg3);border:1px solid var(--border);border-radius:3px;padding:1px 6px;font-size:11px;font-family:monospace}\n\n/* \u2500\u2500 Cache row \u2500\u2500 */\n.cache-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px}\n.cache-card{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:10px 14px}\n.cache-card .cache-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px}\n.cache-card .cache-val{font-size:18px;font-weight:600;color:var(--purple)}\n\n/* \u2500\u2500 Mode buttons \u2500\u2500 */\n.mode-btns{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px}\n.mode-btn{\n display:flex;align-items:center;gap:6px;padding:6px 14px;\n border-radius:6px;border:1px solid var(--border);background:var(--bg3);\n color:var(--muted);cursor:pointer;font-size:12px;transition:all .15s\n}\n.mode-btn:hover{border-color:var(--blue);color:var(--text)}\n.mode-btn.active{border-color:var(--accent);background:var(--accent);color:#fff}\n.mode-btn.active svg{stroke:white}\n#mode-desc{font-size:12px;color:var(--muted);min-height:16px}\n\n/* \u2500\u2500 Projects page \u2500\u2500 */\n.project-table td:first-child code{font-size:12px}\n.project-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}\n\n/* \u2500\u2500 History page \u2500\u2500 */\n#hist-layout{display:grid;grid-template-columns:220px 1fr;gap:12px;min-height:400px}\n#hist-projects{background:var(--bg2);border:1px solid var(--border);border-radius:8px;overflow:hidden}\n#hist-sessions{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:0}\n.hist-proj-item{\n padding:9px 14px;cursor:pointer;border-bottom:1px solid var(--border);\n display:flex;justify-content:space-between;align-items:center;\n font-size:12px;color:var(--muted);transition:background .1s\n}\n.hist-proj-item:last-child{border-bottom:none}\n.hist-proj-item:hover{background:var(--bg3)}\n.hist-proj-item.active{background:var(--bg3);color:var(--blue)}\n.hist-proj-count{font-size:11px;background:var(--bg4);border-radius:10px;padding:1px 7px}\n.hist-sessions-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;font-weight:600}\n.session-card{padding:12px 16px;border-bottom:1px solid var(--border)}\n.session-card:last-child{border-bottom:none}\n.session-date{font-size:12px;font-weight:600;color:var(--text);margin-bottom:4px}\n.session-time{font-size:11px;color:var(--muted);margin-bottom:6px}\n.session-stats{display:flex;gap:14px;flex-wrap:wrap}\n.session-stat{font-size:11px;color:var(--muted)}\n.session-stat span{color:var(--text);font-weight:500}\n.session-project-badge{font-size:10px;background:var(--bg4);border:1px solid var(--border);border-radius:10px;padding:1px 8px;color:var(--blue);margin-left:6px}\n.empty-msg{padding:32px 16px;text-align:center;color:var(--muted);font-size:12px}\n\n/* \u2500\u2500 Settings \u2500\u2500 */\n.config-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border);font-size:12px}\n.config-row:last-child{border-bottom:none}\n.config-key{color:var(--muted)}\n.config-val{font-family:monospace;color:var(--text)}\n\n/* \u2500\u2500 Limits page \u2500\u2500 */\n.limits-cli-section{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:14px}\n.limits-cli-header{display:flex;align-items:center;gap:8px;margin-bottom:12px}\n.limits-cli-name{font-size:13px;font-weight:600;color:var(--text)}\n.limits-cli-badge{font-size:10px;padding:1px 7px;border-radius:10px;border:1px solid;margin-left:2px}\n.limits-cli-badge.live{border-color:rgba(63,185,80,.4);color:var(--green);background:rgba(63,185,80,.1)}\n.limits-cli-badge.error{border-color:rgba(248,81,73,.4);color:var(--red);background:rgba(248,81,73,.1)}\n.limits-cli-badge.warn{border-color:rgba(210,153,34,.4);color:var(--yellow);background:rgba(210,153,34,.1)}\n.limits-cli-badge.none{border-color:var(--border);color:var(--muted);background:transparent}\n.limits-gauge-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-bottom:10px}\n.limits-gauge{background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:10px 12px}\n.limits-gauge-label{font-size:11px;color:var(--muted);margin-bottom:6px;display:flex;justify-content:space-between}\n.limits-gauge-bar{height:6px;background:var(--bg4);border-radius:3px;overflow:hidden;margin-bottom:5px}\n.limits-gauge-fill{height:100%;border-radius:3px;transition:width .5s,background .5s}\n.limits-gauge-bottom{display:flex;justify-content:space-between;font-size:11px}\n.limits-gauge-remaining{color:var(--text);font-weight:500}\n.limits-gauge-reset{color:var(--muted)}\n.limits-usage-row{display:flex;gap:16px;flex-wrap:wrap;padding-top:8px;border-top:1px solid var(--border);margin-top:4px}\n.limits-usage-item{font-size:12px;color:var(--muted)}\n.limits-usage-item span{color:var(--text);font-weight:500}\n.limits-no-data{padding:16px;text-align:center;color:var(--muted);font-size:12px}\n.limits-billing-row{display:flex;gap:10px;flex-wrap:wrap;padding:8px 0 2px}\n.limits-credit-card{flex:1;min-width:120px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:10px 12px}\n.limits-credit-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}\n.limits-credit-val{font-size:20px;font-weight:600;color:var(--green)}\n.limits-budget-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px}\n.limits-budget-input{background:var(--bg3);border:1px solid var(--border);border-radius:5px;padding:5px 10px;color:var(--text);font-size:12px;width:140px;outline:none}\n.limits-budget-input:focus{border-color:var(--blue)}\n.limits-budget-label{font-size:12px;color:var(--muted)}\n\n/* \u2500\u2500 Footer bar \u2500\u2500 */\n#footer{padding:7px 20px;border-top:1px solid var(--border);background:var(--bg2);font-size:11px;color:var(--muted);display:flex;gap:16px;flex-shrink:0}\n#footer a{color:var(--muted)}#footer a:hover{color:var(--blue)}\n</style>\n</head>\n<body>\n<div id=\"app\">\n\n<!-- \u2500\u2500 Sidebar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<div id=\"sidebar\">\n <div id=\"sidebar-brand\">\n <div class=\"logo\">Squee<span>zr</span></div>\n <div class=\"ver\" id=\"sb-ver\">v\u2014</div>\n </div>\n\n <nav>\n <div class=\"nav-item active\" data-page=\"overview\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M1 2.5A1.5 1.5 0 012.5 1h3A1.5 1.5 0 017 2.5v3A1.5 1.5 0 015.5 7h-3A1.5 1.5 0 011 5.5v-3zm8 0A1.5 1.5 0 0110.5 1h3A1.5 1.5 0 0115 2.5v3A1.5 1.5 0 0113.5 7h-3A1.5 1.5 0 019 5.5v-3zm-8 8A1.5 1.5 0 012.5 9h3A1.5 1.5 0 017 10.5v3A1.5 1.5 0 015.5 15h-3A1.5 1.5 0 011 13.5v-3zm8 0A1.5 1.5 0 0110.5 9h3a1.5 1.5 0 011.5 1.5v3A1.5 1.5 0 0113.5 15h-3A1.5 1.5 0 019 13.5v-3z\"/>\n </svg>\n <span class=\"nav-label\">Overview</span>\n </div>\n <div class=\"nav-item\" data-page=\"projects\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M9.828 3h3.982a2 2 0 011.992 2.181l-.637 7A2 2 0 0113.174 14H2.826a2 2 0 01-1.991-1.819l-.637-7a1.99 1.99 0 01.342-1.31L.5 3a2 2 0 012-2h3.672a2 2 0 011.414.586l.828.828A2 2 0 009.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 006.172 2H2.5a1 1 0 00-1 .981l.006.139z\"/>\n </svg>\n <span class=\"nav-label\">Projects</span>\n </div>\n <div class=\"nav-item\" data-page=\"history\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M8 3.5a.5.5 0 00-1 0V9a.5.5 0 00.252.434l3.5 2a.5.5 0 00.496-.868L8 8.71V3.5z\"/>\n <path d=\"M8 16A8 8 0 108 0a8 8 0 000 16zm7-8A7 7 0 111 8a7 7 0 0114 0z\"/>\n </svg>\n <span class=\"nav-label\">History</span>\n </div>\n <div class=\"nav-item\" data-page=\"limits\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <line x1=\"18\" y1=\"20\" x2=\"18\" y2=\"10\"/>\n <line x1=\"12\" y1=\"20\" x2=\"12\" y2=\"4\"/>\n <line x1=\"6\" y1=\"20\" x2=\"6\" y2=\"14\"/>\n <line x1=\"2\" y1=\"20\" x2=\"22\" y2=\"20\"/>\n </svg>\n <span class=\"nav-label\">Limits</span>\n </div>\n <div class=\"nav-item\" data-page=\"settings\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M8 4.754a3.246 3.246 0 100 6.492 3.246 3.246 0 000-6.492zM5.754 8a2.246 2.246 0 114.492 0 2.246 2.246 0 01-4.492 0z\"/>\n <path d=\"M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 01-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 01-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 01.52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 011.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 011.255-.52l.292.16c1.64.892 3.433-.902 2.54-2.541l-.159-.292a.873.873 0 01.52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 01-.52-1.255l.16-.292c.892-1.64-.901-3.433-2.541-2.54l-.292.159a.873.873 0 01-1.255-.52l-.094-.319z\"/>\n </svg>\n <span class=\"nav-label\">Settings</span>\n </div>\n </nav>\n\n <div id=\"sidebar-footer\">\n <div class=\"status-row\">\n <div class=\"dot\" id=\"status-dot\"></div>\n <span id=\"status-text\">Connecting\u2026</span>\n </div>\n <div id=\"uptime-small\"></div>\n </div>\n</div>\n\n<!-- \u2500\u2500 Main content \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<div id=\"content\">\n <div id=\"page-header\">\n <span id=\"page-title\">Overview</span>\n <span id=\"project-badge\" style=\"display:none\"></span>\n <span id=\"header-uptime\"></span>\n <span id=\"conn-pill\">\u25CF live</span>\n </div>\n\n <div id=\"pages\">\n\n <!-- \u2500\u2500\u2500 Overview \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page active\" id=\"page-overview\">\n <div class=\"cards-grid\">\n <div class=\"card c-green\">\n <div class=\"card-label\">Tokens Saved</div>\n <div class=\"card-value\" id=\"c-tokens\">\u2014</div>\n <div class=\"card-sub\" id=\"c-chars\">\u2014 chars</div>\n </div>\n <div class=\"card c-blue\">\n <div class=\"card-label\">Compression</div>\n <div class=\"card-value\" id=\"c-pct\">\u2014</div>\n <div class=\"card-sub\">of tool results</div>\n </div>\n <div class=\"card c-yellow\">\n <div class=\"card-label\">Requests</div>\n <div class=\"card-value\" id=\"c-req\">\u2014</div>\n <div class=\"card-sub\" id=\"c-compressions\">\u2014 compressions</div>\n </div>\n <div class=\"card c-orange\">\n <div class=\"card-label\">Est. Cost Saved</div>\n <div class=\"card-value\" id=\"c-cost\">\u2014</div>\n <div class=\"card-sub\">@ $3 / MTok</div>\n </div>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">Context pressure \u2014 last request</div>\n <div class=\"bar-row\">\n <span class=\"bar-label\">Before compression</span>\n <div class=\"bar-track\"><div class=\"bar-fill\" id=\"bar-msg\" style=\"width:0%\"></div></div>\n <span class=\"bar-val\" id=\"pct-msg\">0%</span>\n </div>\n <div class=\"bar-row\">\n <span class=\"bar-label\">After compression</span>\n <div class=\"bar-track\"><div class=\"bar-fill\" id=\"bar-out\" style=\"width:0%\"></div></div>\n <span class=\"bar-val\" id=\"pct-out\">0%</span>\n </div>\n <div class=\"bar-row\" style=\"margin-bottom:0\">\n <span class=\"bar-label\">Session cache hits</span>\n <div class=\"bar-track\"><div class=\"bar-fill\" id=\"bar-cache\" style=\"width:0%;background:var(--purple)\"></div></div>\n <span class=\"bar-val\" id=\"pct-cache\">0</span>\n </div>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">Activity \u2014 tokens saved per request <span style=\"font-weight:400;text-transform:none;letter-spacing:0\">(last 60)</span></div>\n <canvas id=\"sparkline\"></canvas>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">By tool</div>\n <table>\n <thead>\n <tr>\n <th>Tool</th>\n <th class=\"td-right\">Calls</th>\n <th class=\"td-right\">Tokens saved</th>\n <th>Savings</th>\n </tr>\n </thead>\n <tbody id=\"tools-body\">\n <tr><td colspan=\"4\" style=\"color:var(--muted);padding:14px 8px;text-align:center\">No data yet\u2026</td></tr>\n </tbody>\n </table>\n </div>\n\n <div class=\"cache-row\">\n <div class=\"cache-card\">\n <div class=\"cache-label\">Session cache</div>\n <div class=\"cache-val\" id=\"c-scache\">\u2014</div>\n </div>\n <div class=\"cache-card\">\n <div class=\"cache-label\">Expand store</div>\n <div class=\"cache-val\" id=\"c-expand\">\u2014</div>\n </div>\n <div class=\"cache-card\">\n <div class=\"cache-label\">LRU cache</div>\n <div class=\"cache-val\" id=\"c-lru\">\u2014</div>\n </div>\n <div class=\"cache-card\">\n <div class=\"cache-label\">Pattern hits</div>\n <div class=\"cache-val\" id=\"c-patterns\">\u2014</div>\n </div>\n </div>\n\n <!-- Savings breakdown -->\n <div class=\"section\">\n <div class=\"section-title\">Savings Breakdown</div>\n <div class=\"cache-grid\" style=\"grid-template-columns:1fr 1fr 1fr\">\n <div class=\"cache-item\">\n <div class=\"cache-label\">Deterministic</div>\n <div class=\"cache-val\" id=\"bd-det\" style=\"color:var(--green)\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">AI compression</div>\n <div class=\"cache-val\" id=\"bd-ai\" style=\"color:var(--blue)\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">Read dedup</div>\n <div class=\"cache-val\" id=\"bd-dedup\" style=\"color:var(--purple)\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">System prompt</div>\n <div class=\"cache-val\" id=\"bd-sysprompt\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">Tag overhead</div>\n <div class=\"cache-val\" id=\"bd-overhead\" style=\"color:var(--muted)\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">AI calls</div>\n <div class=\"cache-val\" id=\"bd-aicalls\" style=\"color:var(--muted)\">\u2014</div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 Projects \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-projects\">\n <div class=\"section\" style=\"margin-bottom:0\">\n <div class=\"section-title\" id=\"projects-section-title\">All projects \u2014 this session + history</div>\n <table class=\"project-table\">\n <thead>\n <tr>\n <th>Project</th>\n <th class=\"td-right\">Sessions</th>\n <th class=\"td-right\">Requests</th>\n <th class=\"td-right\">Tokens saved</th>\n <th class=\"td-right\">Last seen</th>\n </tr>\n </thead>\n <tbody id=\"projects-body\">\n <tr><td colspan=\"5\" style=\"color:var(--muted);padding:20px 8px;text-align:center\">Loading\u2026</td></tr>\n </tbody>\n </table>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 History \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-history\">\n <div id=\"hist-layout\">\n <div id=\"hist-projects\">\n <div style=\"padding:10px 14px;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid var(--border)\">Projects</div>\n <div id=\"hist-proj-list\"></div>\n </div>\n <div id=\"hist-sessions\">\n <div class=\"hist-sessions-header\" id=\"hist-sessions-header\">Select a project</div>\n <div id=\"hist-sessions-list\"><div class=\"empty-msg\">Select a project on the left to view sessions.</div></div>\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 Limits \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-limits\">\n\n <!-- Anthropic -->\n <div class=\"limits-cli-section\" id=\"lim-anthropic\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=\"color:var(--orange)\">\n <path d=\"M13.83 2.34a2.09 2.09 0 0 0-3.66 0L1.13 18.9A2.09 2.09 0 0 0 2.96 22h18.08a2.09 2.09 0 0 0 1.83-3.1L13.83 2.34ZM12 8a1 1 0 0 1 1 1v5a1 1 0 0 1-2 0V9a1 1 0 0 1 1-1Zm0 10a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z\"/>\n </svg>\n <span class=\"limits-cli-name\">Anthropic \u00B7 Claude Code</span>\n <span class=\"limits-cli-badge none\" id=\"ant-badge\">no data yet</span>\n </div>\n <div class=\"limits-gauge-grid\">\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Tokens / minute</span>\n <span id=\"ant-tok-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-tok-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-tok-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-tok-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Requests / minute</span>\n <span id=\"ant-req-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-req-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-req-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-req-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Input tokens / minute</span>\n <span id=\"ant-inp-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-inp-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-inp-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-inp-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Output tokens / minute</span>\n <span id=\"ant-out-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-out-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-out-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-out-reset\"></span>\n </div>\n </div>\n </div>\n <div class=\"limits-usage-row\">\n <div class=\"limits-usage-item\">Session input: <span id=\"ant-u-inp-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Session output: <span id=\"ant-u-out-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today input: <span id=\"ant-u-inp-d\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today output: <span id=\"ant-u-out-d\">\u2014</span></div>\n <div class=\"limits-usage-item\" style=\"margin-left:auto\">\n <a href=\"https://console.anthropic.com/settings/usage\" target=\"_blank\" style=\"color:var(--muted);font-size:11px\">View billing \u2197</a>\n </div>\n </div>\n </div>\n\n <!-- OpenAI -->\n <div class=\"limits-cli-section\" id=\"lim-openai\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=\"color:var(--text)\">\n <path d=\"M22.28 9.27a6.17 6.17 0 0 0-.53-5.06 6.24 6.24 0 0 0-6.7-2.99A6.23 6.23 0 0 0 10.36 0a6.24 6.24 0 0 0-5.95 4.32 6.23 6.23 0 0 0-4.16 3.02 6.24 6.24 0 0 0 .77 7.32 6.17 6.17 0 0 0 .53 5.06 6.24 6.24 0 0 0 6.7 2.99A6.23 6.23 0 0 0 13.64 24a6.25 6.25 0 0 0 5.96-4.33 6.23 6.23 0 0 0 4.15-3.02 6.24 6.24 0 0 0-.77-7.31l.3-.07ZM13.64 22.5a4.63 4.63 0 0 1-2.97-1.08l.15-.08 4.93-2.85a.82.82 0 0 0 .41-.71v-6.96l2.08 1.2a.08.08 0 0 1 .04.06v5.76a4.65 4.65 0 0 1-4.64 4.66Zm-9.95-4.27a4.63 4.63 0 0 1-.55-3.12l.14.09 4.93 2.85a.82.82 0 0 0 .82 0l6.02-3.47v2.4a.08.08 0 0 1-.03.06L10.06 20a4.65 4.65 0 0 1-6.37-1.77Zm-1.28-10.8a4.63 4.63 0 0 1 2.42-2.04v5.88a.82.82 0 0 0 .41.71l6.01 3.47-2.08 1.2a.08.08 0 0 1-.08 0L4.22 13.7a4.65 4.65 0 0 1-.81-6.27Zm17.09 3.99-6.02-3.48L15.56 7a.08.08 0 0 1 .08 0l4.87 2.81a4.64 4.64 0 0 1-.72 8.38v-5.88a.82.82 0 0 0-.39-.69Zm2.07-3.14-.14-.09-4.92-2.87a.82.82 0 0 0-.83 0L9.67 9.79V7.4a.08.08 0 0 1 .03-.06L14.6 4.5a4.64 4.64 0 0 1 6.9 4.81l.07-.03Zm-13.03 4.28-2.08-1.2a.08.08 0 0 1-.04-.06V5.5a4.64 4.64 0 0 1 7.62-3.56l-.15.08L7.9 4.87a.82.82 0 0 0-.41.71l-.01 6.98Zm1.13-2.43 2.68-1.55 2.68 1.55v3.1l-2.68 1.54-2.68-1.54v-3.1Z\"/>\n </svg>\n <span class=\"limits-cli-name\">OpenAI \u00B7 Codex</span>\n <span class=\"limits-cli-badge none\" id=\"oai-badge\">no data yet</span>\n </div>\n <div class=\"limits-gauge-grid\">\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Tokens / minute</span>\n <span id=\"oai-tok-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"oai-tok-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"oai-tok-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"oai-tok-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Requests / minute</span>\n <span id=\"oai-req-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"oai-req-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"oai-req-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"oai-req-reset\"></span>\n </div>\n </div>\n </div>\n <div class=\"limits-billing-row\" id=\"oai-billing-row\" style=\"display:none\">\n <div class=\"limits-credit-card\">\n <div class=\"limits-credit-label\">Credits remaining</div>\n <div class=\"limits-credit-val\" id=\"oai-credits\">\u2014</div>\n </div>\n <div class=\"limits-credit-card\">\n <div class=\"limits-credit-label\">Hard limit</div>\n <div class=\"limits-credit-val\" style=\"color:var(--yellow)\" id=\"oai-hard-lim\">\u2014</div>\n </div>\n </div>\n <div class=\"limits-usage-row\">\n <div class=\"limits-usage-item\">Session input: <span id=\"oai-u-inp-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Session output: <span id=\"oai-u-out-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today input: <span id=\"oai-u-inp-d\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today output: <span id=\"oai-u-out-d\">\u2014</span></div>\n <div class=\"limits-usage-item\" style=\"margin-left:auto\">\n <a href=\"https://platform.openai.com/usage\" target=\"_blank\" style=\"color:var(--muted);font-size:11px\">View billing \u2197</a>\n </div>\n </div>\n </div>\n\n <!-- Gemini -->\n <div class=\"limits-cli-section\" id=\"lim-gemini\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=\"color:var(--blue)\">\n <path d=\"M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0zm0 22C6.49 22 2 17.51 2 12S6.49 2 12 2s10 4.49 10 10-4.49 10-10 10zm-1-14h2v7h-2zm0 9h2v2h-2z\"/>\n </svg>\n <span class=\"limits-cli-name\">Google \u00B7 Gemini CLI</span>\n <span class=\"limits-cli-badge warn\" id=\"gem-badge\">only on 429 errors</span>\n </div>\n <div id=\"gem-nodata\" class=\"limits-no-data\">\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" style=\"margin-bottom:6px;display:block;margin-inline:auto;opacity:.4\">\n <circle cx=\"12\" cy=\"12\" r=\"10\"/><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"/><line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"/>\n </svg>\n Google does not expose quota headers on successful responses.<br>\n Data appears here only after a 429 rate-limit error.<br>\n <a href=\"https://aistudio.google.com/app/usage\" target=\"_blank\" style=\"margin-top:8px;display:inline-block\">View quotas in AI Studio \u2197</a>\n </div>\n <div id=\"gem-data\" style=\"display:none\">\n <div class=\"limits-gauge-grid\">\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\"><span>Last known token limit</span></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"gem-tok-lim\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"gem-errors\">0 errors</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"limits-usage-row\">\n <div class=\"limits-usage-item\">Session input: <span id=\"gem-u-inp-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Session output: <span id=\"gem-u-out-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today input: <span id=\"gem-u-inp-d\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today output: <span id=\"gem-u-out-d\">\u2014</span></div>\n <div class=\"limits-usage-item\" style=\"margin-left:auto\">\n <a href=\"https://aistudio.google.com/app/usage\" target=\"_blank\" style=\"color:var(--muted);font-size:11px\">View quotas \u2197</a>\n </div>\n </div>\n </div>\n\n <!-- Personal budget -->\n <div class=\"limits-cli-section\" style=\"margin-bottom:0\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\">\n <circle cx=\"12\" cy=\"12\" r=\"10\"/>\n <path d=\"M12 8v4l3 3\"/>\n </svg>\n <span class=\"limits-cli-name\">Personal daily budget</span>\n <span class=\"limits-cli-badge none\">optional</span>\n </div>\n <div class=\"limits-budget-row\">\n <input class=\"limits-budget-input\" id=\"budget-input\" type=\"number\" placeholder=\"e.g. 5000000\" min=\"0\">\n <span class=\"limits-budget-label\">tokens / day</span>\n <button class=\"btn-save\" id=\"budget-save\" style=\"padding:4px 12px;font-size:11px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--muted);cursor:pointer;transition:all .15s\" onmouseover=\"this.style.borderColor='var(--blue)';this.style.color='var(--text)'\" onmouseout=\"this.style.borderColor='var(--border)';this.style.color='var(--muted)'\">Save</button>\n </div>\n <div id=\"budget-bar-wrap\" style=\"margin-top:10px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px\">\n <span>Tokens used today through Squeezr</span>\n <span id=\"budget-pct-label\">0%</span>\n </div>\n <div class=\"limits-gauge-bar\" style=\"height:10px\">\n <div class=\"limits-gauge-fill\" id=\"budget-bar\" style=\"width:0%\"></div>\n </div>\n <div style=\"display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-top:4px\">\n <span id=\"budget-used-label\">0 used</span>\n <span id=\"budget-limit-label\">of \u2014</span>\n </div>\n </div>\n </div>\n\n </div>\n\n <!-- \u2500\u2500\u2500 Settings \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-settings\">\n <div class=\"section\" style=\"margin-bottom:14px\">\n <div class=\"section-title\">Compression mode</div>\n <div class=\"mode-btns\">\n <button class=\"mode-btn\" data-mode=\"soft\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\">\n <path d=\"M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2\"/>\n </svg>\n Soft\n </button>\n <button class=\"mode-btn active\" data-mode=\"normal\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <line x1=\"4\" y1=\"21\" x2=\"4\" y2=\"14\"/><line x1=\"4\" y1=\"10\" x2=\"4\" y2=\"3\"/>\n <line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"12\"/><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"3\"/>\n <line x1=\"20\" y1=\"21\" x2=\"20\" y2=\"16\"/><line x1=\"20\" y1=\"12\" x2=\"20\" y2=\"3\"/>\n <line x1=\"1\" y1=\"14\" x2=\"7\" y2=\"14\"/><line x1=\"9\" y1=\"8\" x2=\"15\" y2=\"8\"/>\n <line x1=\"17\" y1=\"16\" x2=\"23\" y2=\"16\"/>\n </svg>\n Normal\n </button>\n <button class=\"mode-btn\" data-mode=\"aggressive\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/>\n </svg>\n Aggressive\n </button>\n <button class=\"mode-btn\" data-mode=\"critical\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"/>\n <line x1=\"12\" y1=\"9\" x2=\"12\" y2=\"13\"/>\n <line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"/>\n </svg>\n Critical\n </button>\n </div>\n <div id=\"mode-desc\">Normal \u2014 threshold 800 chars, last 3 results uncompressed</div>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">Configuration</div>\n <div id=\"config-rows\">\n <div class=\"config-row\"><span class=\"config-key\">Mode</span><span class=\"config-val\" id=\"cfg-mode\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Port</span><span class=\"config-val\" id=\"cfg-port\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Dry-run</span><span class=\"config-val\" id=\"cfg-dryrun\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">LRU cache entries</span><span class=\"config-val\" id=\"cfg-lru\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Session cache entries</span><span class=\"config-val\" id=\"cfg-scache\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Version</span><span class=\"config-val\" id=\"cfg-version\">\u2014</span></div>\n </div>\n </div>\n\n <div class=\"section\" style=\"margin-bottom:0\">\n <div class=\"section-title\">Links</div>\n <div style=\"display:flex;gap:16px;flex-wrap:wrap;font-size:12px\">\n <a href=\"/squeezr/stats\" target=\"_blank\">/squeezr/stats JSON</a>\n <a href=\"/squeezr/history\" target=\"_blank\">/squeezr/history JSON</a>\n <a href=\"/squeezr/projects\" target=\"_blank\">/squeezr/projects JSON</a>\n <a href=\"https://github.com/sergioramosv/Squeezr\" target=\"_blank\">GitHub</a>\n </div>\n </div>\n </div>\n\n </div><!-- /pages -->\n\n <div id=\"footer\">\n <span>Squeezr v<span id=\"f-version\">\u2014</span></span>\n <span id=\"f-mode\">mode: active</span>\n <span id=\"f-port\"></span>\n <span id=\"conn-status\" style=\"margin-left:auto;color:var(--green)\">\u25CF connected</span>\n </div>\n</div><!-- /content -->\n\n</div><!-- /app -->\n\n<script>\n// \u2500\u2500 Sparkline \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst MAX_PTS = 60\nconst sparkData = []\nlet lastTokens = 0\nfunction pushSpark(t) {\n sparkData.push(Math.max(0, t - lastTokens))\n lastTokens = t\n if (sparkData.length > MAX_PTS) sparkData.shift()\n}\nfunction drawSpark() {\n const cv = document.getElementById('sparkline')\n if (!cv) return\n const dpr = window.devicePixelRatio || 1\n const r = cv.getBoundingClientRect()\n cv.width = r.width * dpr; cv.height = r.height * dpr\n const ctx = cv.getContext('2d')\n ctx.scale(dpr, dpr)\n const w = r.width, h = r.height\n const mx = Math.max(...sparkData, 1)\n ctx.clearRect(0, 0, w, h)\n if (sparkData.length < 2) return\n const step = w / (MAX_PTS - 1)\n ctx.beginPath(); ctx.moveTo(0, h)\n sparkData.forEach((v, i) => ctx.lineTo(i * step, h - (v / mx) * (h - 4)))\n ctx.lineTo((sparkData.length - 1) * step, h)\n ctx.closePath()\n const g = ctx.createLinearGradient(0, 0, 0, h)\n g.addColorStop(0, 'rgba(63,185,80,.3)'); g.addColorStop(1, 'rgba(63,185,80,0)')\n ctx.fillStyle = g; ctx.fill()\n ctx.beginPath()\n sparkData.forEach((v, i) => {\n const x = i * step, y = h - (v / mx) * (h - 4)\n i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)\n })\n ctx.strokeStyle = '#3fb950'; ctx.lineWidth = 1.5; ctx.stroke()\n}\nwindow.addEventListener('resize', drawSpark)\n\n// \u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction fmtN(n) {\n if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'\n if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'\n return String(n)\n}\nfunction fmtCost(tok) {\n const u = (tok / 1e6) * 3\n return u < 0.01 ? '<$0.01' : u < 1 ? '$' + u.toFixed(3) : '$' + u.toFixed(2)\n}\nfunction fmtUptime(s) {\n if (s < 60) return s + 's'\n if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's'\n return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'\n}\nfunction fmtTs(ms) {\n if (!ms) return '\u2014'\n const d = new Date(ms)\n return d.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})\n}\nfunction fmtTime(ms) {\n if (!ms) return '\u2014'\n const d = new Date(ms)\n return d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false})\n}\nfunction fmtDur(startMs, endMs) {\n const s = Math.round((endMs - startMs) / 1000)\n if (s < 60) return s + 's'\n if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's'\n return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'\n}\nfunction timeAgo(ms) {\n if (!ms) return ''\n const diff = Math.round((Date.now() - ms) / 1000)\n if (diff < 60) return 'just now'\n if (diff < 3600) return Math.floor(diff / 60) + 'm ago'\n if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'\n if (diff < 172800) return 'yesterday'\n return Math.floor(diff / 86400) + 'd ago'\n}\nfunction barColor(p) {\n if (p >= 90) return 'var(--red)'\n if (p >= 75) return 'var(--yellow)'\n if (p >= 50) return 'var(--orange)'\n return 'var(--blue)'\n}\nfunction setBar(bid, vid, pct, label, noColor) {\n const b = document.getElementById(bid), v = document.getElementById(vid)\n b.style.width = Math.min(pct, 100) + '%'\n if (!noColor) b.style.background = barColor(pct)\n v.textContent = label\n}\nconst PROJECT_COLORS = ['#58a6ff','#3fb950','#ffa657','#bc8cff','#d29922','#f85149','#79c0ff','#56d364']\nfunction projectColor(name) {\n let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffff\n return PROJECT_COLORS[h % PROJECT_COLORS.length]\n}\n\n// \u2500\u2500 Overview render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction renderOverview(d) {\n document.getElementById('c-tokens').textContent = fmtN(d.total_saved_tokens)\n document.getElementById('c-chars').textContent = (d.total_saved_chars || 0).toLocaleString() + ' chars'\n document.getElementById('c-pct').textContent = (d.savings_pct || 0) + '%'\n document.getElementById('c-req').textContent = fmtN(d.requests || 0)\n document.getElementById('c-compressions').textContent = (d.compressions || 0) + ' compressions'\n document.getElementById('c-cost').textContent = fmtCost(d.total_saved_tokens || 0)\n document.getElementById('f-version').textContent = d.version || '\u2014'\n document.getElementById('sb-ver').textContent = 'v' + (d.version || '\u2014')\n document.getElementById('f-mode').textContent = 'mode: ' + (d.dry_run ? 'dry-run' : 'active')\n document.getElementById('f-port').textContent = 'port: ' + (d.port || '\u2014')\n document.getElementById('header-uptime').textContent = 'uptime ' + fmtUptime(d.uptime_seconds || 0)\n document.getElementById('uptime-small').textContent = fmtUptime(d.uptime_seconds || 0)\n\n // Project badge\n const proj = d.current_project\n const badge = document.getElementById('project-badge')\n if (proj && proj !== 'unknown') {\n badge.textContent = proj\n badge.style.display = ''\n badge.style.borderColor = projectColor(proj)\n badge.style.color = projectColor(proj)\n } else {\n badge.style.display = 'none'\n }\n\n // Pressure bars\n const msgPct = Math.min(Math.round((d.last_original_chars || 0) / 80), 100)\n const outPct = Math.min(Math.round((d.last_compressed_chars || 0) / 80), 100)\n const ch = d.session_cache_hits || 0\n const cachePct = Math.round((ch / Math.max(ch + (d.compressions || 1), 1)) * 100)\n setBar('bar-msg', 'pct-msg', msgPct, msgPct + '%')\n setBar('bar-out', 'pct-out', outPct, outPct + '%')\n setBar('bar-cache', 'pct-cache', cachePct, ch, true)\n\n // Sparkline\n pushSpark(d.total_saved_tokens || 0)\n drawSpark()\n\n // Tool table\n const bt = d.by_tool || {}\n const rows = Object.entries(bt).sort((a, b) => b[1].saved_tokens - a[1].saved_tokens)\n const maxSaved = rows[0]?.[1]?.saved_tokens || 1\n const tbody = document.getElementById('tools-body')\n if (rows.length === 0) {\n tbody.innerHTML = '<tr><td colspan=\"4\" style=\"color:var(--muted);padding:14px 8px;text-align:center\">No tool results compressed yet\u2026</td></tr>'\n } else {\n tbody.innerHTML = rows.map(([tool, t]) => {\n const bw = Math.round((t.saved_tokens / maxSaved) * 72)\n return `<tr>\n <td><code class=\"tag\">${tool}</code></td>\n <td class=\"td-right\" style=\"color:var(--muted)\">${t.count}</td>\n <td class=\"td-right\">${fmtN(t.saved_tokens)}</td>\n <td><span class=\"mini-bar\" style=\"width:${bw}px;background:var(--green)\"></span>${t.avg_pct}%</td>\n </tr>`\n }).join('')\n }\n\n // Cache stats\n document.getElementById('c-scache').textContent = d.session_cache_size ?? '\u2014'\n document.getElementById('c-expand').textContent = d.expand_store_size ?? '\u2014'\n document.getElementById('c-lru').textContent = d.cache?.size ?? '\u2014'\n document.getElementById('c-patterns').textContent = d.pattern_hits\n ? Object.values(d.pattern_hits).reduce((s, v) => s + v, 0).toLocaleString()\n : '\u2014'\n\n // Settings config panel\n document.getElementById('cfg-mode').textContent = d.mode || '\u2014'\n document.getElementById('cfg-port').textContent = d.port || '\u2014'\n document.getElementById('cfg-dryrun').textContent = d.dry_run ? 'yes' : 'no'\n document.getElementById('cfg-lru').textContent = d.cache?.size ?? '\u2014'\n document.getElementById('cfg-scache').textContent = d.session_cache_size ?? '\u2014'\n document.getElementById('cfg-version').textContent = d.version || '\u2014'\n\n // Sync active mode button\n document.querySelectorAll('.mode-btn').forEach(b => {\n b.classList.toggle('active', b.dataset.mode === d.mode)\n })\n const modeMap = {\n soft: 'Soft \u2014 threshold 3000 chars, last 10 results uncompressed, no AI',\n normal: 'Normal \u2014 threshold 800 chars, last 3 results uncompressed',\n aggressive: 'Aggressive \u2014 threshold 200 chars, last 1 result uncompressed',\n critical: 'Critical \u2014 threshold 50 chars, everything compressed'\n }\n document.getElementById('mode-desc').textContent = modeMap[d.mode] || ''\n\n // Savings breakdown\n const bd = d.breakdown\n if (bd) {\n const fmtC = (n) => n > 0 ? '-' + fmtN(n) : '0'\n document.getElementById('bd-det').textContent = fmtC(bd.deterministic)\n document.getElementById('bd-ai').textContent = fmtC(bd.ai_compression)\n document.getElementById('bd-dedup').textContent = fmtC(bd.read_dedup)\n document.getElementById('bd-sysprompt').textContent = fmtC(bd.system_prompt)\n document.getElementById('bd-overhead').textContent = bd.overhead > 0 ? '+' + fmtN(bd.overhead) : '0'\n document.getElementById('bd-aicalls').textContent = bd.ai_calls > 0 ? bd.ai_calls + ' calls' : '0'\n }\n}\n\n// \u2500\u2500 Projects page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function loadProjects() {\n try {\n const r = await fetch('/squeezr/projects')\n const { projects } = await r.json()\n const tbody = document.getElementById('projects-body')\n const entries = Object.entries(projects).sort((a, b) => b[1].savedTokens - a[1].savedTokens)\n if (entries.length === 0) {\n tbody.innerHTML = '<tr><td colspan=\"5\" style=\"color:var(--muted);padding:20px 8px;text-align:center\">No project data yet \u2014 start making requests.</td></tr>'\n return\n }\n tbody.innerHTML = entries.map(([name, p]) => `<tr>\n <td><span class=\"project-dot\" style=\"background:${projectColor(name)}\"></span><code>${name}</code></td>\n <td class=\"td-right\" style=\"color:var(--muted)\">${p.sessions}</td>\n <td class=\"td-right\">${p.requests}</td>\n <td class=\"td-right\" style=\"color:var(--green)\">${fmtN(p.savedTokens)}</td>\n <td class=\"td-right\" style=\"color:var(--muted);font-size:11px\">${p.lastSeen ? fmtTs(p.lastSeen) : '\u2014'}</td>\n </tr>`).join('')\n } catch {\n document.getElementById('projects-body').innerHTML = '<tr><td colspan=\"5\" style=\"color:var(--muted);padding:20px 8px;text-align:center\">Failed to load projects.</td></tr>'\n }\n}\n\n// \u2500\u2500 History page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet histData = null\nlet selectedHistProj = '__all__'\n\nasync function loadHistory() {\n try {\n const r = await fetch('/squeezr/history')\n histData = await r.json()\n renderHistProjects()\n renderHistSessions()\n } catch {\n document.getElementById('hist-proj-list').innerHTML = '<div class=\"empty-msg\">Failed to load history.</div>'\n }\n}\n\nfunction renderHistProjects() {\n if (!histData) return\n const all = [...histData.sessions]\n if (histData.current && histData.current.requests > 0) {\n const idx = all.findIndex(s => s.id === histData.current.id)\n if (idx >= 0) all[idx] = histData.current; else all.push(histData.current)\n }\n\n // Group by project\n const byProj = {}\n for (const s of all) {\n if (!byProj[s.project]) byProj[s.project] = 0\n byProj[s.project]++\n }\n\n const list = document.getElementById('hist-proj-list')\n let html = `<div class=\"hist-proj-item${selectedHistProj === '__all__' ? ' active' : ''}\" data-proj=\"__all__\">\n <span>All projects</span>\n <span class=\"hist-proj-count\">${all.length}</span>\n </div>`\n for (const [name, cnt] of Object.entries(byProj).sort((a, b) => b[1] - a[1])) {\n const active = selectedHistProj === name ? ' active' : ''\n html += `<div class=\"hist-proj-item${active}\" data-proj=\"${name}\">\n <span><span class=\"project-dot\" style=\"background:${projectColor(name)}\"></span>${name}</span>\n <span class=\"hist-proj-count\">${cnt}</span>\n </div>`\n }\n list.innerHTML = html\n\n list.querySelectorAll('.hist-proj-item').forEach(el => {\n el.addEventListener('click', () => {\n selectedHistProj = el.dataset.proj\n list.querySelectorAll('.hist-proj-item').forEach(x => x.classList.remove('active'))\n el.classList.add('active')\n renderHistSessions()\n })\n })\n}\n\nfunction renderHistSessions() {\n if (!histData) return\n let sessions = [...histData.sessions]\n if (histData.current && histData.current.requests > 0) {\n const idx = sessions.findIndex(s => s.id === histData.current.id)\n if (idx >= 0) sessions[idx] = histData.current; else sessions.push(histData.current)\n }\n // Filter empty sessions and sort newest first\n sessions = sessions.filter(s => s.requests > 0)\n sessions.sort((a, b) => b.startTime - a.startTime)\n\n if (selectedHistProj !== '__all__') {\n sessions = sessions.filter(s => s.project === selectedHistProj)\n }\n\n const header = document.getElementById('hist-sessions-header')\n header.textContent = selectedHistProj === '__all__'\n ? `All sessions (${sessions.length})`\n : `${selectedHistProj} \u2014 ${sessions.length} session${sessions.length !== 1 ? 's' : ''}`\n\n const list = document.getElementById('hist-sessions-list')\n if (sessions.length === 0) {\n list.innerHTML = '<div class=\"empty-msg\">No sessions found.</div>'\n return\n }\n\n // Group by day\n const byDay = {}\n for (const s of sessions) {\n const day = new Date(s.startTime).toLocaleDateString('en-US',{weekday:'short',month:'short',day:'numeric',year:'numeric'})\n if (!byDay[day]) byDay[day] = []\n byDay[day].push(s)\n }\n\n let html = ''\n for (const [day, daySessions] of Object.entries(byDay)) {\n html += `<div style=\"padding:8px 16px;font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.4px;background:var(--bg3);border-bottom:1px solid var(--border)\">${day}</div>`\n for (const s of daySessions) {\n const isCurrent = s.id === histData.current?.id\n const projBadge = selectedHistProj === '__all__' ? `<span class=\"session-project-badge\">${s.project}</span>` : ''\n html += `<div class=\"session-card\">\n <div class=\"session-date\">\n ${fmtTime(s.startTime)} \u2192 ${fmtTime(s.endTime)}\n <span style=\"color:var(--muted);font-weight:400\"> (${fmtDur(s.startTime, s.endTime)})</span>\n <span style=\"color:var(--muted);font-weight:400;margin-left:6px\">${timeAgo(s.endTime)}</span>\n ${isCurrent ? '<span style=\"font-size:10px;color:var(--green);margin-left:8px\">\u25CF active</span>' : ''}\n ${projBadge}\n </div>\n <div class=\"session-stats\">\n <div class=\"session-stat\">Requests: <span>${s.requests}</span></div>\n <div class=\"session-stat\">Tokens saved: <span style=\"color:var(--green)\">${fmtN(s.savedTokens)}</span></div>\n <div class=\"session-stat\">Compressions: <span>${s.compressions}</span></div>\n </div>\n </div>`\n }\n }\n list.innerHTML = html\n}\n\n// \u2500\u2500 Limits page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet limitsCountdownTimer = null\n\nfunction fmtTokens(n) {\n if (!n && n !== 0) return '\u2014'\n if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'\n if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'\n return String(n)\n}\n\nfunction gaugeColor(pct) {\n if (pct >= 90) return 'var(--red)'\n if (pct >= 70) return 'var(--yellow)'\n if (pct >= 40) return 'var(--orange)'\n return 'var(--green)'\n}\n\nfunction fillGauge(fillId, pctId, remId, resetId, remaining, limit, resetEpoch) {\n if (!limit) {\n document.getElementById(fillId).style.width = '0%'\n document.getElementById(pctId).textContent = '\u2014'\n document.getElementById(remId).textContent = '\u2014'\n if (resetId) document.getElementById(resetId).textContent = ''\n return\n }\n const used = limit - remaining\n const pct = Math.max(0, Math.min(100, Math.round((used / limit) * 100)))\n const fill = document.getElementById(fillId)\n fill.style.width = pct + '%'\n fill.style.background = gaugeColor(pct)\n document.getElementById(pctId).textContent = pct + '% used'\n document.getElementById(pctId).style.color = gaugeColor(pct)\n document.getElementById(remId).textContent = fmtTokens(remaining) + ' remaining'\n if (resetId && resetEpoch) {\n const secs = Math.max(0, Math.round((resetEpoch - Date.now()) / 1000))\n document.getElementById(resetId).textContent = secs > 0 ? 'resets in ' + secs + 's' : 'resetting\u2026'\n }\n}\n\nfunction renderLimits(d) {\n if (!d) return\n const { anthropic, openai, gemini } = d\n\n // \u2500\u2500 Anthropic \u2500\u2500\n const arl = anthropic?.rl\n const au = anthropic?.usage\n const antHasUsage = au && (au.inputSession > 0 || au.outputSession > 0)\n if (arl?.hasData) {\n document.getElementById('ant-badge').className = 'limits-cli-badge live'\n document.getElementById('ant-badge').textContent = 'live'\n fillGauge('ant-tok-fill','ant-tok-pct','ant-tok-rem','ant-tok-reset', arl.tokensRemaining, arl.tokensLimit, arl.tokensResetEpoch)\n fillGauge('ant-req-fill','ant-req-pct','ant-req-rem','ant-req-reset', arl.requestsRemaining, arl.requestsLimit, arl.requestsResetEpoch)\n fillGauge('ant-inp-fill','ant-inp-pct','ant-inp-rem','ant-inp-reset', arl.inputTokensRemaining, arl.inputTokensLimit, arl.tokensResetEpoch)\n fillGauge('ant-out-fill','ant-out-pct','ant-out-rem','ant-out-reset', arl.outputTokensRemaining, arl.outputTokensLimit, arl.tokensResetEpoch)\n } else if (antHasUsage) {\n // Subscription/OAuth: no rate limit headers, but usage is tracked\n document.getElementById('ant-badge').className = 'limits-cli-badge live'\n document.getElementById('ant-badge').textContent = 'subscription'\n // Show request count in the requests gauge area\n document.getElementById('ant-req-pct').textContent = au.requestsSession + ' reqs'\n document.getElementById('ant-req-rem').textContent = 'session total'\n }\n if (au) {\n document.getElementById('ant-u-inp-s').textContent = fmtTokens(au.inputSession)\n document.getElementById('ant-u-out-s').textContent = fmtTokens(au.outputSession)\n document.getElementById('ant-u-inp-d').textContent = fmtTokens(au.inputToday)\n document.getElementById('ant-u-out-d').textContent = fmtTokens(au.outputToday)\n }\n\n // \u2500\u2500 OpenAI \u2500\u2500\n const orl = openai?.rl\n const ou = openai?.usage\n const oaiHasUsage = ou && (ou.inputSession > 0 || ou.outputSession > 0)\n if (orl?.hasData) {\n document.getElementById('oai-badge').className = 'limits-cli-badge live'\n document.getElementById('oai-badge').textContent = 'live'\n fillGauge('oai-tok-fill','oai-tok-pct','oai-tok-rem','oai-tok-reset', orl.tokensRemaining, orl.tokensLimit, orl.tokensResetEpoch)\n fillGauge('oai-req-fill','oai-req-pct','oai-req-rem','oai-req-reset', orl.requestsRemaining, orl.requestsLimit, orl.requestsResetEpoch)\n } else if (oaiHasUsage) {\n document.getElementById('oai-badge').className = 'limits-cli-badge live'\n document.getElementById('oai-badge').textContent = 'tracking'\n document.getElementById('oai-req-pct').textContent = ou.requestsSession + ' reqs'\n document.getElementById('oai-req-rem').textContent = 'session total'\n }\n const ob = openai?.billing\n if (ob?.hardLimitUsd > 0) {\n document.getElementById('oai-billing-row').style.display = 'flex'\n document.getElementById('oai-credits').textContent = '$' + (ob.creditBalanceUsd || 0).toFixed(2)\n document.getElementById('oai-hard-lim').textContent = '$' + ob.hardLimitUsd.toFixed(2)\n }\n if (ou) {\n document.getElementById('oai-u-inp-s').textContent = fmtTokens(ou.inputSession)\n document.getElementById('oai-u-out-s').textContent = fmtTokens(ou.outputSession)\n document.getElementById('oai-u-inp-d').textContent = fmtTokens(ou.inputToday)\n document.getElementById('oai-u-out-d').textContent = fmtTokens(ou.outputToday)\n }\n\n // \u2500\u2500 Gemini \u2500\u2500\n const ge = gemini?.errors\n const gu = gemini?.usage\n const gemHasUsage = gu && (gu.inputSession > 0 || gu.outputSession > 0)\n if (ge?.hasData) {\n document.getElementById('gem-nodata').style.display = 'none'\n document.getElementById('gem-data').style.display = 'block'\n document.getElementById('gem-tok-lim').textContent = fmtTokens(gemini.rl?.tokensLimit)\n document.getElementById('gem-errors').textContent = ge.errorCount429 + ' rate-limit errors'\n document.getElementById('gem-badge').className = 'limits-cli-badge error'\n document.getElementById('gem-badge').textContent = ge.errorCount429 + ' 429 errors'\n } else if (gemHasUsage) {\n document.getElementById('gem-nodata').style.display = 'none'\n document.getElementById('gem-data').style.display = 'block'\n document.getElementById('gem-badge').className = 'limits-cli-badge live'\n document.getElementById('gem-badge').textContent = 'tracking'\n }\n if (gu) {\n document.getElementById('gem-u-inp-s').textContent = fmtTokens(gu.inputSession)\n document.getElementById('gem-u-out-s').textContent = fmtTokens(gu.outputSession)\n document.getElementById('gem-u-inp-d').textContent = fmtTokens(gu.inputToday)\n document.getElementById('gem-u-out-d').textContent = fmtTokens(gu.outputToday)\n }\n\n // \u2500\u2500 Budget \u2500\u2500\n updateBudgetBar(au, ou, gu)\n}\n\n// Countdown ticker \u2014 updates reset countdowns every second without SSE\nfunction startLimitsCountdown(limitsData) {\n if (limitsCountdownTimer) clearInterval(limitsCountdownTimer)\n limitsCountdownTimer = setInterval(() => {\n const updateReset = (id, resetEpoch) => {\n if (!resetEpoch) return\n const el = document.getElementById(id)\n if (!el) return\n const secs = Math.max(0, Math.round((resetEpoch - Date.now()) / 1000))\n el.textContent = secs > 0 ? 'resets in ' + secs + 's' : 'resetting\u2026'\n }\n const d = limitsData\n if (d?.anthropic?.rl?.hasData) {\n updateReset('ant-tok-reset', d.anthropic.rl.tokensResetEpoch)\n updateReset('ant-req-reset', d.anthropic.rl.requestsResetEpoch)\n updateReset('ant-inp-reset', d.anthropic.rl.tokensResetEpoch)\n updateReset('ant-out-reset', d.anthropic.rl.tokensResetEpoch)\n }\n if (d?.openai?.rl?.hasData) {\n updateReset('oai-tok-reset', d.openai.rl.tokensResetEpoch)\n updateReset('oai-req-reset', d.openai.rl.requestsResetEpoch)\n }\n }, 1000)\n}\n\n// \u2500\u2500 Budget logic \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet dailyBudget = parseInt(localStorage.getItem('squeezr_budget') || '0')\n\nfunction updateBudgetBar(au, ou, gu) {\n const budget = dailyBudget\n const budgetInput = document.getElementById('budget-input')\n if (budgetInput && !budgetInput.value) budgetInput.value = budget || ''\n\n const wrap = document.getElementById('budget-bar-wrap')\n if (!budget) { wrap.style.display = 'none'; return }\n wrap.style.display = 'block'\n\n const totalToday = ((au?.inputToday || 0) + (au?.outputToday || 0) +\n (ou?.inputToday || 0) + (ou?.outputToday || 0) +\n (gu?.inputToday || 0) + (gu?.outputToday || 0))\n const pct = Math.min(100, Math.round((totalToday / budget) * 100))\n const fill = document.getElementById('budget-bar')\n fill.style.width = pct + '%'\n fill.style.background = gaugeColor(pct)\n document.getElementById('budget-pct-label').textContent = pct + '%'\n document.getElementById('budget-pct-label').style.color = gaugeColor(pct)\n document.getElementById('budget-used-label').textContent = fmtTokens(totalToday) + ' used today'\n document.getElementById('budget-limit-label').textContent = 'of ' + fmtTokens(budget) + ' / day'\n}\n\ndocument.getElementById('budget-save').addEventListener('click', () => {\n const val = parseInt(document.getElementById('budget-input').value || '0')\n dailyBudget = val\n localStorage.setItem('squeezr_budget', String(val))\n document.getElementById('budget-save').textContent = '\u2713 Saved'\n setTimeout(() => document.getElementById('budget-save').textContent = 'Save', 2000)\n // Re-render budget bar with latest limits data\n if (lastLimitsData) {\n const u = lastLimitsData.usage\n updateBudgetBar(u?.anthropic, u?.openai, u?.gemini)\n }\n})\n\n// Restore budget from localStorage on load\nconst savedBudget = localStorage.getItem('squeezr_budget')\nif (savedBudget) document.getElementById('budget-input').value = savedBudget\n\n// \u2500\u2500 Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst pageTitles = { overview: 'Overview', projects: 'Projects', history: 'History', limits: 'Limits', settings: 'Settings' }\n\ndocument.querySelectorAll('.nav-item').forEach(item => {\n item.addEventListener('click', () => {\n const page = item.dataset.page\n document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'))\n document.querySelectorAll('.page').forEach(p => p.classList.remove('active'))\n item.classList.add('active')\n document.getElementById('page-' + page).classList.add('active')\n document.getElementById('page-title').textContent = pageTitles[page] || page\n if (page === 'projects') loadProjects()\n if (page === 'history') loadHistory()\n if (page === 'limits') {\n if (lastLimitsData) {\n renderLimits(lastLimitsData)\n startLimitsCountdown(lastLimitsData)\n }\n }\n if (page !== 'limits' && limitsCountdownTimer) {\n clearInterval(limitsCountdownTimer)\n limitsCountdownTimer = null\n }\n })\n})\n\n// \u2500\u2500 Mode selector \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\ndocument.querySelectorAll('.mode-btn[data-mode]').forEach(btn => {\n btn.addEventListener('click', async () => {\n const mode = btn.dataset.mode\n if (!mode) return\n const prevActive = document.querySelector('.mode-btn.active')\n document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'))\n btn.classList.add('active')\n try {\n const res = await fetch('/squeezr/config', {\n method: 'POST',\n headers: {'content-type':'application/json'},\n body: JSON.stringify({ mode })\n })\n if (!res.ok) throw new Error('HTTP ' + res.status)\n } catch(e) {\n // Revert to previous mode on failure\n btn.classList.remove('active')\n if (prevActive) prevActive.classList.add('active')\n console.error('mode update failed', e)\n }\n })\n})\n\n// \u2500\u2500 SSE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst dot = document.getElementById('status-dot')\nconst statusText = document.getElementById('status-text')\nconst connPill = document.getElementById('conn-pill')\nconst connStatus = document.getElementById('conn-status')\nlet lastLimitsData = null\n\nfunction connect() {\n const es = new EventSource('/squeezr/events')\n es.onmessage = e => {\n try {\n const d = JSON.parse(e.data)\n renderOverview(d)\n if (d.limits) {\n lastLimitsData = d.limits\n // Only render limits page if it's currently visible\n const limPage = document.getElementById('page-limits')\n if (limPage && limPage.classList.contains('active')) {\n renderLimits(d.limits)\n if (!limitsCountdownTimer) startLimitsCountdown(d.limits)\n else { /* update the data reference for the countdown */ lastLimitsData = d.limits }\n }\n }\n } catch(err) { console.error(err) }\n }\n es.onopen = () => {\n dot.classList.remove('off')\n statusText.textContent = 'Running'\n connPill.className = ''\n connPill.textContent = '\u25CF live'\n connStatus.style.color = 'var(--green)'\n connStatus.textContent = '\u25CF connected'\n }\n es.onerror = () => {\n dot.classList.add('off')\n statusText.textContent = 'Reconnecting\u2026'\n connPill.className = 'err'\n connPill.textContent = '\u25CF offline'\n connStatus.style.color = 'var(--red)'\n connStatus.textContent = '\u25CF reconnecting\u2026'\n es.close()\n setTimeout(connect, 3000)\n }\n}\nconnect()\n</script>\n</body>\n</html>";
package/dist/dashboard.js CHANGED
@@ -348,6 +348,37 @@ tr:last-child td{border-bottom:none}
348
348
  <div class="cache-val" id="c-patterns">—</div>
349
349
  </div>
350
350
  </div>
351
+
352
+ <!-- Savings breakdown -->
353
+ <div class="section">
354
+ <div class="section-title">Savings Breakdown</div>
355
+ <div class="cache-grid" style="grid-template-columns:1fr 1fr 1fr">
356
+ <div class="cache-item">
357
+ <div class="cache-label">Deterministic</div>
358
+ <div class="cache-val" id="bd-det" style="color:var(--green)">—</div>
359
+ </div>
360
+ <div class="cache-item">
361
+ <div class="cache-label">AI compression</div>
362
+ <div class="cache-val" id="bd-ai" style="color:var(--blue)">—</div>
363
+ </div>
364
+ <div class="cache-item">
365
+ <div class="cache-label">Read dedup</div>
366
+ <div class="cache-val" id="bd-dedup" style="color:var(--purple)">—</div>
367
+ </div>
368
+ <div class="cache-item">
369
+ <div class="cache-label">System prompt</div>
370
+ <div class="cache-val" id="bd-sysprompt">—</div>
371
+ </div>
372
+ <div class="cache-item">
373
+ <div class="cache-label">Tag overhead</div>
374
+ <div class="cache-val" id="bd-overhead" style="color:var(--muted)">—</div>
375
+ </div>
376
+ <div class="cache-item">
377
+ <div class="cache-label">AI calls</div>
378
+ <div class="cache-val" id="bd-aicalls" style="color:var(--muted)">—</div>
379
+ </div>
380
+ </div>
381
+ </div>
351
382
  </div>
352
383
 
353
384
  <!-- ─── Projects ──────────────────────────────────────────────────────── -->
@@ -723,6 +754,15 @@ function fmtDur(startMs, endMs) {
723
754
  if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's'
724
755
  return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'
725
756
  }
757
+ function timeAgo(ms) {
758
+ if (!ms) return ''
759
+ const diff = Math.round((Date.now() - ms) / 1000)
760
+ if (diff < 60) return 'just now'
761
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago'
762
+ if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'
763
+ if (diff < 172800) return 'yesterday'
764
+ return Math.floor(diff / 86400) + 'd ago'
765
+ }
726
766
  function barColor(p) {
727
767
  if (p >= 90) return 'var(--red)'
728
768
  if (p >= 75) return 'var(--yellow)'
@@ -827,6 +867,18 @@ function renderOverview(d) {
827
867
  critical: 'Critical — threshold 50 chars, everything compressed'
828
868
  }
829
869
  document.getElementById('mode-desc').textContent = modeMap[d.mode] || ''
870
+
871
+ // Savings breakdown
872
+ const bd = d.breakdown
873
+ if (bd) {
874
+ const fmtC = (n) => n > 0 ? '-' + fmtN(n) : '0'
875
+ document.getElementById('bd-det').textContent = fmtC(bd.deterministic)
876
+ document.getElementById('bd-ai').textContent = fmtC(bd.ai_compression)
877
+ document.getElementById('bd-dedup').textContent = fmtC(bd.read_dedup)
878
+ document.getElementById('bd-sysprompt').textContent = fmtC(bd.system_prompt)
879
+ document.getElementById('bd-overhead').textContent = bd.overhead > 0 ? '+' + fmtN(bd.overhead) : '0'
880
+ document.getElementById('bd-aicalls').textContent = bd.ai_calls > 0 ? bd.ai_calls + ' calls' : '0'
881
+ }
830
882
  }
831
883
 
832
884
  // ── Projects page ────────────────────────────────────────────────────────────
@@ -913,7 +965,8 @@ function renderHistSessions() {
913
965
  const idx = sessions.findIndex(s => s.id === histData.current.id)
914
966
  if (idx >= 0) sessions[idx] = histData.current; else sessions.push(histData.current)
915
967
  }
916
- // Sort newest first
968
+ // Filter empty sessions and sort newest first
969
+ sessions = sessions.filter(s => s.requests > 0)
917
970
  sessions.sort((a, b) => b.startTime - a.startTime)
918
971
 
919
972
  if (selectedHistProj !== '__all__') {
@@ -949,6 +1002,7 @@ function renderHistSessions() {
949
1002
  <div class="session-date">
950
1003
  \${fmtTime(s.startTime)} → \${fmtTime(s.endTime)}
951
1004
  <span style="color:var(--muted);font-weight:400"> (\${fmtDur(s.startTime, s.endTime)})</span>
1005
+ <span style="color:var(--muted);font-weight:400;margin-left:6px">\${timeAgo(s.endTime)}</span>
952
1006
  \${isCurrent ? '<span style="font-size:10px;color:var(--green);margin-left:8px">● active</span>' : ''}
953
1007
  \${projBadge}
954
1008
  </div>
@@ -1008,6 +1062,8 @@ function renderLimits(d) {
1008
1062
 
1009
1063
  // ── Anthropic ──
1010
1064
  const arl = anthropic?.rl
1065
+ const au = anthropic?.usage
1066
+ const antHasUsage = au && (au.inputSession > 0 || au.outputSession > 0)
1011
1067
  if (arl?.hasData) {
1012
1068
  document.getElementById('ant-badge').className = 'limits-cli-badge live'
1013
1069
  document.getElementById('ant-badge').textContent = 'live'
@@ -1015,8 +1071,14 @@ function renderLimits(d) {
1015
1071
  fillGauge('ant-req-fill','ant-req-pct','ant-req-rem','ant-req-reset', arl.requestsRemaining, arl.requestsLimit, arl.requestsResetEpoch)
1016
1072
  fillGauge('ant-inp-fill','ant-inp-pct','ant-inp-rem','ant-inp-reset', arl.inputTokensRemaining, arl.inputTokensLimit, arl.tokensResetEpoch)
1017
1073
  fillGauge('ant-out-fill','ant-out-pct','ant-out-rem','ant-out-reset', arl.outputTokensRemaining, arl.outputTokensLimit, arl.tokensResetEpoch)
1074
+ } else if (antHasUsage) {
1075
+ // Subscription/OAuth: no rate limit headers, but usage is tracked
1076
+ document.getElementById('ant-badge').className = 'limits-cli-badge live'
1077
+ document.getElementById('ant-badge').textContent = 'subscription'
1078
+ // Show request count in the requests gauge area
1079
+ document.getElementById('ant-req-pct').textContent = au.requestsSession + ' reqs'
1080
+ document.getElementById('ant-req-rem').textContent = 'session total'
1018
1081
  }
1019
- const au = anthropic?.usage
1020
1082
  if (au) {
1021
1083
  document.getElementById('ant-u-inp-s').textContent = fmtTokens(au.inputSession)
1022
1084
  document.getElementById('ant-u-out-s').textContent = fmtTokens(au.outputSession)
@@ -1026,11 +1088,18 @@ function renderLimits(d) {
1026
1088
 
1027
1089
  // ── OpenAI ──
1028
1090
  const orl = openai?.rl
1091
+ const ou = openai?.usage
1092
+ const oaiHasUsage = ou && (ou.inputSession > 0 || ou.outputSession > 0)
1029
1093
  if (orl?.hasData) {
1030
1094
  document.getElementById('oai-badge').className = 'limits-cli-badge live'
1031
1095
  document.getElementById('oai-badge').textContent = 'live'
1032
1096
  fillGauge('oai-tok-fill','oai-tok-pct','oai-tok-rem','oai-tok-reset', orl.tokensRemaining, orl.tokensLimit, orl.tokensResetEpoch)
1033
1097
  fillGauge('oai-req-fill','oai-req-pct','oai-req-rem','oai-req-reset', orl.requestsRemaining, orl.requestsLimit, orl.requestsResetEpoch)
1098
+ } else if (oaiHasUsage) {
1099
+ document.getElementById('oai-badge').className = 'limits-cli-badge live'
1100
+ document.getElementById('oai-badge').textContent = 'tracking'
1101
+ document.getElementById('oai-req-pct').textContent = ou.requestsSession + ' reqs'
1102
+ document.getElementById('oai-req-rem').textContent = 'session total'
1034
1103
  }
1035
1104
  const ob = openai?.billing
1036
1105
  if (ob?.hardLimitUsd > 0) {
@@ -1038,7 +1107,6 @@ function renderLimits(d) {
1038
1107
  document.getElementById('oai-credits').textContent = '$' + (ob.creditBalanceUsd || 0).toFixed(2)
1039
1108
  document.getElementById('oai-hard-lim').textContent = '$' + ob.hardLimitUsd.toFixed(2)
1040
1109
  }
1041
- const ou = openai?.usage
1042
1110
  if (ou) {
1043
1111
  document.getElementById('oai-u-inp-s').textContent = fmtTokens(ou.inputSession)
1044
1112
  document.getElementById('oai-u-out-s').textContent = fmtTokens(ou.outputSession)
@@ -1048,6 +1116,8 @@ function renderLimits(d) {
1048
1116
 
1049
1117
  // ── Gemini ──
1050
1118
  const ge = gemini?.errors
1119
+ const gu = gemini?.usage
1120
+ const gemHasUsage = gu && (gu.inputSession > 0 || gu.outputSession > 0)
1051
1121
  if (ge?.hasData) {
1052
1122
  document.getElementById('gem-nodata').style.display = 'none'
1053
1123
  document.getElementById('gem-data').style.display = 'block'
@@ -1055,8 +1125,12 @@ function renderLimits(d) {
1055
1125
  document.getElementById('gem-errors').textContent = ge.errorCount429 + ' rate-limit errors'
1056
1126
  document.getElementById('gem-badge').className = 'limits-cli-badge error'
1057
1127
  document.getElementById('gem-badge').textContent = ge.errorCount429 + ' 429 errors'
1128
+ } else if (gemHasUsage) {
1129
+ document.getElementById('gem-nodata').style.display = 'none'
1130
+ document.getElementById('gem-data').style.display = 'block'
1131
+ document.getElementById('gem-badge').className = 'limits-cli-badge live'
1132
+ document.getElementById('gem-badge').textContent = 'tracking'
1058
1133
  }
1059
- const gu = gemini?.usage
1060
1134
  if (gu) {
1061
1135
  document.getElementById('gem-u-inp-s').textContent = fmtTokens(gu.inputSession)
1062
1136
  document.getElementById('gem-u-out-s').textContent = fmtTokens(gu.outputSession)
@@ -1124,7 +1198,11 @@ document.getElementById('budget-save').addEventListener('click', () => {
1124
1198
  localStorage.setItem('squeezr_budget', String(val))
1125
1199
  document.getElementById('budget-save').textContent = '✓ Saved'
1126
1200
  setTimeout(() => document.getElementById('budget-save').textContent = 'Save', 2000)
1127
- updateBudgetBar(null, null, null)
1201
+ // Re-render budget bar with latest limits data
1202
+ if (lastLimitsData) {
1203
+ const u = lastLimitsData.usage
1204
+ updateBudgetBar(u?.anthropic, u?.openai, u?.gemini)
1205
+ }
1128
1206
  })
1129
1207
 
1130
1208
  // Restore budget from localStorage on load
@@ -1162,15 +1240,22 @@ document.querySelectorAll('.mode-btn[data-mode]').forEach(btn => {
1162
1240
  btn.addEventListener('click', async () => {
1163
1241
  const mode = btn.dataset.mode
1164
1242
  if (!mode) return
1243
+ const prevActive = document.querySelector('.mode-btn.active')
1165
1244
  document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'))
1166
1245
  btn.classList.add('active')
1167
1246
  try {
1168
- await fetch('/squeezr/config', {
1247
+ const res = await fetch('/squeezr/config', {
1169
1248
  method: 'POST',
1170
1249
  headers: {'content-type':'application/json'},
1171
1250
  body: JSON.stringify({ mode })
1172
1251
  })
1173
- } catch(e) { console.error('mode update failed', e) }
1252
+ if (!res.ok) throw new Error('HTTP ' + res.status)
1253
+ } catch(e) {
1254
+ // Revert to previous mode on failure
1255
+ btn.classList.remove('active')
1256
+ if (prevActive) prevActive.classList.add('active')
1257
+ console.error('mode update failed', e)
1258
+ }
1174
1259
  })
1175
1260
  })
1176
1261
 
package/dist/gain.js CHANGED
@@ -22,64 +22,76 @@ if (!data || !data.requests) {
22
22
  console.log('No stats yet. Start Squeezr and make some requests.');
23
23
  process.exit(0);
24
24
  }
25
- const CHARS_PER_TOKEN = 3.5;
25
+ const CPT = 3.5;
26
+ const tok = (c) => Math.round(c / CPT);
27
+ const fmtN = (n) => n.toLocaleString();
26
28
  const requests = data.requests;
27
- // Breakdown of savings by source
29
+ // Breakdown
28
30
  const detSaved = data.det_saved_chars ?? 0;
29
31
  const dedupSaved = data.dedup_saved_chars ?? 0;
30
32
  const aiSaved = data.ai_saved_chars ?? 0;
31
33
  const overheadAdded = data.overhead_chars ?? 0;
32
34
  const syspromptSaved = data.sysprompt_saved_chars ?? 0;
33
35
  const aiCalls = data.ai_compression_calls ?? 0;
34
- // Net savings = all sources minus overhead
36
+ // Net
35
37
  const netSavedChars = detSaved + dedupSaved + aiSaved + syspromptSaved;
36
- const netSavedTokens = Math.round(netSavedChars / CHARS_PER_TOKEN);
37
- // Estimated cost of AI compression calls (input ~1500 tokens + output ~150 tokens per call)
38
- const estCompressionCostTokens = aiCalls * 1650;
39
- // By-tool breakdown
40
- const byTool = (data.by_tool ?? {});
41
- const toolOriginal = Object.values(byTool).reduce((s, d) => s + d.originalChars, 0);
42
- const toolSaved = Object.values(byTool).reduce((s, d) => s + d.savedChars, 0);
43
- const toolPct = toolOriginal > 0 ? Math.round((toolSaved / toolOriginal) * 1000) / 10 : 0;
44
- // Overall context reduction
38
+ const netSavedTokens = tok(netSavedChars);
39
+ // Context reduction
45
40
  const originalChars = data.total_original_chars ?? 0;
46
41
  const savedChars = data.total_saved_chars ?? 0;
47
42
  const ctxPct = originalChars > 0 ? Math.round((savedChars / originalChars) * 1000) / 10 : 0;
48
- const W = 47;
43
+ // By-tool
44
+ const byTool = (data.by_tool ?? {});
45
+ const W = 60;
49
46
  const line = '─'.repeat(W);
50
47
  const pad = (s, w) => s.padEnd(w);
51
- const fmtN = (n) => n.toLocaleString();
48
+ function row(label, chars, prefix = '-') {
49
+ const c = `${prefix}${fmtN(chars)} chars`;
50
+ const t = `~${fmtN(tok(chars))} tokens`;
51
+ console.log(`│ ${label.padEnd(18)} ${c.padEnd(22)} ${t.padEnd(W - 43)}│`);
52
+ }
52
53
  console.log(`┌${line}┐`);
53
- console.log(`│${pad(' Squeezr — Token Savings (Honest)', W)}│`);
54
+ console.log(`│${pad(' Squeezr — Token Savings', W)}│`);
54
55
  console.log(`├${line}┤`);
55
- console.log(`│ Requests ${pad(String(requests), W - 25)}│`);
56
- console.log(`│ │`);
57
- console.log(`│ Deterministic ${pad(`-${fmtN(detSaved)} chars`, W - 25)}│`);
58
- console.log(`│ AI compression ${pad(`-${fmtN(aiSaved)} chars`, W - 25)}│`);
59
- console.log(`│ Read dedup ${pad(`-${fmtN(dedupSaved)} chars`, W - 25)}│`);
60
- console.log(`│ System prompt ${pad(`-${fmtN(syspromptSaved)} chars`, W - 25)}│`);
61
- if (overheadAdded > 0) {
62
- console.log(`│ Tag overhead ${pad(`+${fmtN(overheadAdded)} chars`, W - 25)}│`);
63
- }
56
+ console.log(`│ Requests ${pad(String(requests), W - 21)}│`);
57
+ console.log(`│${' '.repeat(W)}│`);
58
+ if (detSaved > 0)
59
+ row('Deterministic', detSaved);
60
+ if (aiSaved > 0)
61
+ row('AI compression', aiSaved);
62
+ if (dedupSaved > 0)
63
+ row('Read dedup', dedupSaved);
64
+ if (syspromptSaved > 0)
65
+ row('System prompt', syspromptSaved);
66
+ if (overheadAdded > 0)
67
+ row('Tag overhead', overheadAdded, '+');
64
68
  if (aiCalls > 0) {
65
- console.log(`│ AI compress cost ${pad(`~${fmtN(estCompressionCostTokens)} tokens (${aiCalls} calls)`, W - 25)}│`);
69
+ const cost = aiCalls * 1650;
70
+ const c = `${aiCalls} calls`;
71
+ const t = `~${fmtN(cost)} tokens cost`;
72
+ console.log(`│ ${'AI compress'.padEnd(18)} ${c.padEnd(22)} ${t.padEnd(W - 43)}│`);
66
73
  }
67
- console.log(`│ ${'─'.repeat(W - 25)}│`);
68
- console.log(`│ NET saved chars ${pad(fmtN(netSavedChars), W - 25)}│`);
69
- console.log(`│ NET saved tokens ${pad(`~${fmtN(netSavedTokens)}`, W - 25)}│`);
70
- console.log(`│ Tool result savings ${pad(`${toolPct}%`, W - 25)}│`);
71
- console.log(`│ Context reduction ${pad(`${ctxPct}%`, W - 25)}│`);
74
+ console.log(`│ ${' '.repeat(18)} ${'─'.repeat(22)} ${'─'.repeat(W - 43)}│`);
75
+ console.log(`│ ${'NET saved'.padEnd(18)} ${(fmtN(netSavedChars) + ' chars').padEnd(22)} ${'~' + fmtN(netSavedTokens) + ' tokens'}${' '.repeat(Math.max(0, W - 43 - ('~' + fmtN(netSavedTokens) + ' tokens').length))}│`);
76
+ console.log(`│ ${'Context reduction'.padEnd(18)} ${(ctxPct + '%').padEnd(W - 21)}│`);
72
77
  if (Object.keys(byTool).length > 0) {
73
78
  console.log(`├${line}┤`);
74
- console.log(`│ By Tool (AI compressed) │`);
79
+ console.log(`│${pad(' By Tool', W)}│`);
75
80
  for (const [tool, d] of Object.entries(byTool).sort((a, b) => b[1].savedChars - a[1].savedChars)) {
81
+ if (d.savedChars === 0)
82
+ continue;
76
83
  const pct = d.originalChars > 0 ? Math.round((d.savedChars / d.originalChars) * 1000) / 10 : 0;
77
- const entry = ` ${tool} (${d.count}x): -${pct}%`;
78
- console.log(`│${entry.padEnd(W)}│`);
84
+ const label = `${tool} (${d.count}x)`;
85
+ const c = `-${fmtN(d.savedChars)} chars`;
86
+ const t = `~${fmtN(tok(d.savedChars))} tok (-${pct}%)`;
87
+ console.log(`│ ${label.padEnd(18)} ${c.padEnd(22)} ${t.padEnd(W - 43)}│`);
79
88
  }
80
89
  }
81
90
  console.log(`└${line}┘`);
82
- if (estCompressionCostTokens > netSavedTokens) {
83
- console.log(`\n⚠ AI compression cost (${fmtN(estCompressionCostTokens)} tokens) exceeds savings.`);
84
- console.log(` Consider disabling AI compression and using deterministic-only mode.`);
91
+ console.log(` All token counts are approximate (~3.5 chars/token)`);
92
+ if (aiCalls > 0) {
93
+ const cost = aiCalls * 1650;
94
+ if (cost > netSavedTokens) {
95
+ console.log(`\n ⚠ AI compression cost exceeds savings. Consider deterministic-only mode.`);
96
+ }
85
97
  }
package/dist/stats.d.ts CHANGED
@@ -41,6 +41,14 @@ export declare class Stats {
41
41
  current_project: string;
42
42
  last_original_chars: number;
43
43
  last_compressed_chars: number;
44
+ breakdown: {
45
+ deterministic: number;
46
+ ai_compression: number;
47
+ read_dedup: number;
48
+ system_prompt: number;
49
+ overhead: number;
50
+ ai_calls: number;
51
+ };
44
52
  };
45
53
  /**
46
54
  * Persist ONLY the delta from this request — not cumulative session totals.
package/dist/stats.js CHANGED
@@ -101,6 +101,15 @@ export class Stats {
101
101
  current_project: this.currentProject,
102
102
  last_original_chars: this.lastOriginalChars,
103
103
  last_compressed_chars: this.lastCompressedChars,
104
+ // Savings breakdown for honest dashboard reporting
105
+ breakdown: {
106
+ deterministic: this.totalDetSaved,
107
+ ai_compression: this.totalAiSaved,
108
+ read_dedup: this.totalDedupSaved,
109
+ system_prompt: this.totalSyspromptSaved,
110
+ overhead: this.totalOverheadChars,
111
+ ai_calls: this.totalAiCompressionCalls,
112
+ },
104
113
  };
105
114
  }
106
115
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squeezr-ai",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "AI proxy that compresses Claude Code, Codex, Aider, Gemini CLI and Ollama context windows to save thousands of tokens per session",
5
5
  "keywords": [
6
6
  "claude",