squeezr-ai 1.19.0 → 1.21.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.
- package/dist/dashboard.d.ts +1 -1
- package/dist/dashboard.js +9 -1
- package/dist/gain.js +173 -70
- package/dist/mcp.js +88 -4
- package/dist/server.d.ts +2 -0
- package/dist/server.js +39 -13
- package/package.json +1 -1
package/dist/dashboard.d.ts
CHANGED
|
@@ -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\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>";
|
|
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 session totals in gauge areas since we don't have rate limits\n document.getElementById('ant-tok-pct').textContent = fmtTokens(au.inputSession + au.outputSession)\n document.getElementById('ant-tok-rem').textContent = 'session total'\n document.getElementById('ant-req-pct').textContent = au.requestsSession + ' reqs'\n document.getElementById('ant-req-rem').textContent = 'session total'\n document.getElementById('ant-inp-pct').textContent = fmtTokens(au.inputSession)\n document.getElementById('ant-inp-rem').textContent = 'session input'\n document.getElementById('ant-out-pct').textContent = fmtTokens(au.outputSession)\n document.getElementById('ant-out-rem').textContent = 'session output'\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-tok-pct').textContent = fmtTokens(ou.inputSession + ou.outputSession)\n document.getElementById('oai-tok-rem').textContent = 'session total'\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
|
@@ -1075,9 +1075,15 @@ function renderLimits(d) {
|
|
|
1075
1075
|
// Subscription/OAuth: no rate limit headers, but usage is tracked
|
|
1076
1076
|
document.getElementById('ant-badge').className = 'limits-cli-badge live'
|
|
1077
1077
|
document.getElementById('ant-badge').textContent = 'subscription'
|
|
1078
|
-
// Show
|
|
1078
|
+
// Show session totals in gauge areas since we don't have rate limits
|
|
1079
|
+
document.getElementById('ant-tok-pct').textContent = fmtTokens(au.inputSession + au.outputSession)
|
|
1080
|
+
document.getElementById('ant-tok-rem').textContent = 'session total'
|
|
1079
1081
|
document.getElementById('ant-req-pct').textContent = au.requestsSession + ' reqs'
|
|
1080
1082
|
document.getElementById('ant-req-rem').textContent = 'session total'
|
|
1083
|
+
document.getElementById('ant-inp-pct').textContent = fmtTokens(au.inputSession)
|
|
1084
|
+
document.getElementById('ant-inp-rem').textContent = 'session input'
|
|
1085
|
+
document.getElementById('ant-out-pct').textContent = fmtTokens(au.outputSession)
|
|
1086
|
+
document.getElementById('ant-out-rem').textContent = 'session output'
|
|
1081
1087
|
}
|
|
1082
1088
|
if (au) {
|
|
1083
1089
|
document.getElementById('ant-u-inp-s').textContent = fmtTokens(au.inputSession)
|
|
@@ -1098,6 +1104,8 @@ function renderLimits(d) {
|
|
|
1098
1104
|
} else if (oaiHasUsage) {
|
|
1099
1105
|
document.getElementById('oai-badge').className = 'limits-cli-badge live'
|
|
1100
1106
|
document.getElementById('oai-badge').textContent = 'tracking'
|
|
1107
|
+
document.getElementById('oai-tok-pct').textContent = fmtTokens(ou.inputSession + ou.outputSession)
|
|
1108
|
+
document.getElementById('oai-tok-rem').textContent = 'session total'
|
|
1101
1109
|
document.getElementById('oai-req-pct').textContent = ou.requestsSession + ' reqs'
|
|
1102
1110
|
document.getElementById('oai-req-rem').textContent = 'session total'
|
|
1103
1111
|
}
|
package/dist/gain.js
CHANGED
|
@@ -4,6 +4,7 @@ import { homedir } from 'os';
|
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { existsSync, unlinkSync } from 'fs';
|
|
6
6
|
const args = process.argv.slice(2);
|
|
7
|
+
// ── Reset ────────────────────────────────────────────────────────────────────
|
|
7
8
|
if (args.includes('--reset')) {
|
|
8
9
|
const statsFile = join(homedir(), '.squeezr', 'stats.json');
|
|
9
10
|
const cacheFile = join(homedir(), '.squeezr', 'cache.json');
|
|
@@ -17,81 +18,183 @@ if (args.includes('--reset')) {
|
|
|
17
18
|
console.log('Stats reset.');
|
|
18
19
|
process.exit(0);
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
console.log('No stats yet. Start Squeezr and make some requests.');
|
|
23
|
-
process.exit(0);
|
|
24
|
-
}
|
|
21
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
22
|
+
const W = 58; // inner width between │ and │
|
|
25
23
|
const CPT = 3.5;
|
|
26
|
-
const tok = (c) => Math.round(c / CPT);
|
|
27
24
|
const fmtN = (n) => n.toLocaleString();
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
25
|
+
const tok = (c) => Math.round(c / CPT);
|
|
26
|
+
const fmtCh = (c) => `${fmtN(c)} ch`;
|
|
27
|
+
const fmtTk = (c) => `~${fmtN(tok(c))} tk`;
|
|
28
|
+
/** Print a line that is EXACTLY W chars between the two │ borders */
|
|
29
|
+
function row(content) {
|
|
30
|
+
const visible = content.length;
|
|
31
|
+
if (visible >= W) {
|
|
32
|
+
console.log(`│${content.slice(0, W)}│`);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
console.log(`│${content}${' '.repeat(W - visible)}│`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Two-column row: label (left-aligned) + value (right-aligned) */
|
|
39
|
+
function kv(label, value) {
|
|
40
|
+
const gap = W - 2 - label.length - value.length;
|
|
41
|
+
if (gap < 1) {
|
|
42
|
+
row(` ${label} ${value}`);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
row(` ${label}${' '.repeat(gap)}${value}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Three-column row: label + chars + tokens */
|
|
49
|
+
function kv3(label, chars, prefix = '-') {
|
|
50
|
+
const ch = `${prefix}${fmtCh(chars)}`;
|
|
51
|
+
const tk = fmtTk(chars);
|
|
52
|
+
const col1 = 20;
|
|
53
|
+
const col2 = 16;
|
|
54
|
+
const padLabel = label.padEnd(col1);
|
|
55
|
+
const padCh = ch.padStart(col2);
|
|
56
|
+
const padTk = tk.padStart(W - 2 - col1 - col2 - 2);
|
|
57
|
+
row(` ${padLabel}${padCh} ${padTk}`);
|
|
58
|
+
}
|
|
59
|
+
function sep() { row(' ' + ' '.repeat(20) + '─'.repeat(16) + ' ' + '─'.repeat(W - 2 - 20 - 16 - 2)); }
|
|
60
|
+
function blank() { row(''); }
|
|
61
|
+
function topLine() { console.log(`┌${'─'.repeat(W)}┐`); }
|
|
62
|
+
function midLine() { console.log(`├${'─'.repeat(W)}┤`); }
|
|
63
|
+
function botLine() { console.log(`└${'─'.repeat(W)}┘`); }
|
|
64
|
+
function fmtUptime(secs) {
|
|
65
|
+
if (secs < 60)
|
|
66
|
+
return secs + 's';
|
|
67
|
+
if (secs < 3600)
|
|
68
|
+
return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
|
|
69
|
+
return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
|
|
52
70
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const t = `~${fmtN(cost)} tokens cost`;
|
|
72
|
-
console.log(`│ ${'AI compress'.padEnd(18)} ${c.padEnd(22)} ${t.padEnd(W - 43)}│`);
|
|
71
|
+
function loadHistoric() {
|
|
72
|
+
const data = Stats.loadGlobal();
|
|
73
|
+
if (!data || !data.requests)
|
|
74
|
+
return null;
|
|
75
|
+
const byTool = (data.by_tool ?? {});
|
|
76
|
+
return {
|
|
77
|
+
title: 'Token Savings (all-time)',
|
|
78
|
+
requests: data.requests,
|
|
79
|
+
detSaved: data.det_saved_chars ?? 0,
|
|
80
|
+
aiSaved: data.ai_saved_chars ?? 0,
|
|
81
|
+
dedupSaved: data.dedup_saved_chars ?? 0,
|
|
82
|
+
syspromptSaved: data.sysprompt_saved_chars ?? 0,
|
|
83
|
+
overheadAdded: data.overhead_chars ?? 0,
|
|
84
|
+
aiCalls: data.ai_compression_calls ?? 0,
|
|
85
|
+
originalChars: data.total_original_chars ?? 0,
|
|
86
|
+
savedChars: data.total_saved_chars ?? 0,
|
|
87
|
+
byTool,
|
|
88
|
+
};
|
|
73
89
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
async function loadSession() {
|
|
91
|
+
const port = process.env.SQUEEZR_PORT ?? '8080';
|
|
92
|
+
try {
|
|
93
|
+
const resp = await fetch(`http://localhost:${port}/squeezr/stats`);
|
|
94
|
+
if (!resp.ok)
|
|
95
|
+
return null;
|
|
96
|
+
const d = await resp.json();
|
|
97
|
+
const bd = (d.breakdown ?? {});
|
|
98
|
+
const byTool = (d.by_tool ?? {});
|
|
99
|
+
// Convert by_tool format from summary() to gain format
|
|
100
|
+
const bt = {};
|
|
101
|
+
for (const [tool, t] of Object.entries(byTool)) {
|
|
102
|
+
bt[tool] = { count: t.count, savedChars: t.saved_chars, originalChars: Math.round(t.saved_chars / Math.max(t.avg_pct / 100, 0.01)) };
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
title: 'Session Savings (live)',
|
|
106
|
+
requests: d.requests ?? 0,
|
|
107
|
+
detSaved: bd.deterministic ?? 0,
|
|
108
|
+
aiSaved: bd.ai_compression ?? 0,
|
|
109
|
+
dedupSaved: bd.read_dedup ?? 0,
|
|
110
|
+
syspromptSaved: bd.system_prompt ?? 0,
|
|
111
|
+
overheadAdded: bd.overhead ?? 0,
|
|
112
|
+
aiCalls: bd.ai_calls ?? 0,
|
|
113
|
+
originalChars: d.total_original_chars ?? 0,
|
|
114
|
+
savedChars: d.total_saved_chars ?? 0,
|
|
115
|
+
byTool: bt,
|
|
116
|
+
project: d.current_project ?? undefined,
|
|
117
|
+
uptime: d.uptime_seconds ?? undefined,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
88
122
|
}
|
|
89
123
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
124
|
+
// ── Render ────────────────────────────────────────────────────────────────────
|
|
125
|
+
function render(d, showTools) {
|
|
126
|
+
const grossChars = d.detSaved + d.aiSaved + d.dedupSaved + d.syspromptSaved;
|
|
127
|
+
const aiCostChars = d.aiCalls * 1650 * CPT; // convert tokens back to chars for consistent display
|
|
128
|
+
const netChars = grossChars - aiCostChars;
|
|
129
|
+
const ctxPct = d.originalChars > 0 ? Math.round((d.savedChars / d.originalChars) * 1000) / 10 : 0;
|
|
130
|
+
topLine();
|
|
131
|
+
row(` Squeezr — ${d.title}`);
|
|
132
|
+
midLine();
|
|
133
|
+
if (d.project && d.project !== 'unknown')
|
|
134
|
+
kv('Project', d.project);
|
|
135
|
+
kv('Requests', String(d.requests));
|
|
136
|
+
if (d.uptime)
|
|
137
|
+
kv('Uptime', fmtUptime(d.uptime));
|
|
138
|
+
blank();
|
|
139
|
+
if (d.detSaved > 0)
|
|
140
|
+
kv3('Deterministic', d.detSaved);
|
|
141
|
+
if (d.aiSaved > 0)
|
|
142
|
+
kv3('AI compression', d.aiSaved);
|
|
143
|
+
if (d.dedupSaved > 0)
|
|
144
|
+
kv3('Read dedup', d.dedupSaved);
|
|
145
|
+
if (d.syspromptSaved > 0)
|
|
146
|
+
kv3('System prompt', d.syspromptSaved);
|
|
147
|
+
if (d.overheadAdded > 0)
|
|
148
|
+
kv3('Tag overhead', d.overheadAdded, '+');
|
|
149
|
+
if (d.aiCalls > 0) {
|
|
150
|
+
// AI compression cost: tokens spent on Haiku/GPT-mini calls
|
|
151
|
+
kv3('AI compress cost', Math.round(aiCostChars), '+');
|
|
152
|
+
}
|
|
153
|
+
sep();
|
|
154
|
+
kv3('NET saved', Math.max(0, Math.round(netChars)), ' ');
|
|
155
|
+
kv('Context reduction', `${ctxPct}%`);
|
|
156
|
+
const toolEntries = Object.entries(d.byTool)
|
|
157
|
+
.filter(([, t]) => t.savedChars > 0)
|
|
158
|
+
.sort((a, b) => b[1].savedChars - a[1].savedChars);
|
|
159
|
+
if (showTools && toolEntries.length > 0) {
|
|
160
|
+
midLine();
|
|
161
|
+
row(' By Tool');
|
|
162
|
+
for (const [tool, t] of toolEntries) {
|
|
163
|
+
const pct = t.originalChars > 0 ? Math.round((t.savedChars / t.originalChars) * 1000) / 10 : 0;
|
|
164
|
+
kv3(`${tool} (${t.count}x)`, t.savedChars);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
botLine();
|
|
168
|
+
console.log(' ~3.5 chars/token');
|
|
169
|
+
if (d.aiCalls > 0) {
|
|
170
|
+
const cost = d.aiCalls * 1650;
|
|
171
|
+
if (cost > tok(netChars)) {
|
|
172
|
+
console.log(`\n ⚠ AI compression cost exceeds savings. Consider deterministic-only mode.`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
177
|
+
async function main() {
|
|
178
|
+
const showDetails = args.includes('--details') || args.includes('-d');
|
|
179
|
+
const showSession = args.includes('--session') || args.includes('-s');
|
|
180
|
+
if (showSession) {
|
|
181
|
+
const session = await loadSession();
|
|
182
|
+
if (!session) {
|
|
183
|
+
console.log('Squeezr is not running. Start it with: squeezr start');
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
if (session.requests === 0) {
|
|
187
|
+
console.log('No requests in this session yet.');
|
|
188
|
+
process.exit(0);
|
|
189
|
+
}
|
|
190
|
+
render(session, true);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const data = loadHistoric();
|
|
194
|
+
if (!data) {
|
|
195
|
+
console.log('No stats yet. Start Squeezr and make some requests.');
|
|
196
|
+
process.exit(0);
|
|
96
197
|
}
|
|
198
|
+
render(data, showDetails);
|
|
97
199
|
}
|
|
200
|
+
main();
|
package/dist/mcp.js
CHANGED
|
@@ -87,6 +87,32 @@ function fmtUptime(s) {
|
|
|
87
87
|
}
|
|
88
88
|
// ── MCP Server setup ──────────────────────────────────────────────────────────
|
|
89
89
|
const pkg = require('../package.json');
|
|
90
|
+
// ── Auto-update check (runs in background, non-blocking) ─────────────────────
|
|
91
|
+
let latestVersion = null;
|
|
92
|
+
let lastUpdateCheck = 0;
|
|
93
|
+
const UPDATE_CHECK_INTERVAL = 30 * 60_000; // 30 minutes
|
|
94
|
+
async function checkForUpdate() {
|
|
95
|
+
if (Date.now() - lastUpdateCheck < UPDATE_CHECK_INTERVAL && latestVersion !== null) {
|
|
96
|
+
return latestVersion !== pkg.version ? latestVersion : null;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch('https://registry.npmjs.org/squeezr-ai/latest', {
|
|
100
|
+
signal: AbortSignal.timeout(5000),
|
|
101
|
+
});
|
|
102
|
+
if (res.ok) {
|
|
103
|
+
const data = await res.json();
|
|
104
|
+
latestVersion = data.version;
|
|
105
|
+
lastUpdateCheck = Date.now();
|
|
106
|
+
return latestVersion !== pkg.version ? latestVersion : null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch { /* ignore */ }
|
|
110
|
+
lastUpdateCheck = Date.now();
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
function updateBanner(newVersion) {
|
|
114
|
+
return `\n\n🆕 Squeezr v${newVersion} available (you have v${pkg.version}). Run: squeezr update`;
|
|
115
|
+
}
|
|
90
116
|
const server = new Server({ name: 'squeezr', version: pkg.version }, { capabilities: { tools: {} } });
|
|
91
117
|
// ── Tool definitions ──────────────────────────────────────────────────────────
|
|
92
118
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -161,11 +187,36 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
161
187
|
'After updating, restart the proxy with: squeezr start',
|
|
162
188
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
163
189
|
},
|
|
190
|
+
{
|
|
191
|
+
name: 'squeezr_set_project',
|
|
192
|
+
description: 'Set or change the current project name for Squeezr tracking. ' +
|
|
193
|
+
'Useful when auto-detection shows the wrong name. ' +
|
|
194
|
+
'Pass project name to set, or null/empty to clear and use auto-detection. ' +
|
|
195
|
+
'The project name appears in the dashboard, history, and gain reports.',
|
|
196
|
+
inputSchema: {
|
|
197
|
+
type: 'object',
|
|
198
|
+
properties: {
|
|
199
|
+
project: {
|
|
200
|
+
type: 'string',
|
|
201
|
+
description: 'Project name to set, or empty string to clear and use auto-detection.',
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
required: ['project'],
|
|
205
|
+
},
|
|
206
|
+
},
|
|
164
207
|
],
|
|
165
208
|
}));
|
|
166
209
|
// ── Tool handlers ─────────────────────────────────────────────────────────────
|
|
167
210
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
168
211
|
const { name, arguments: args } = req.params;
|
|
212
|
+
// Check for updates in background (non-blocking, cached for 30 min)
|
|
213
|
+
const newVer = await checkForUpdate();
|
|
214
|
+
const appendUpdate = (result) => {
|
|
215
|
+
if (newVer && result.content[0]?.type === 'text') {
|
|
216
|
+
result.content[0].text += updateBanner(newVer);
|
|
217
|
+
}
|
|
218
|
+
return result;
|
|
219
|
+
};
|
|
169
220
|
// ── squeezr_status ──────────────────────────────────────────────────────────
|
|
170
221
|
if (name === 'squeezr_status') {
|
|
171
222
|
const health = await proxyGet('/squeezr/health');
|
|
@@ -189,7 +240,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
189
240
|
const uptime = stats ? fmtUptime(stats.uptime_seconds ?? 0) : '?';
|
|
190
241
|
const mode = stats ? (stats.mode ?? 'normal') : 'normal';
|
|
191
242
|
const dryRun = stats ? stats.dry_run : false;
|
|
192
|
-
return {
|
|
243
|
+
return appendUpdate({
|
|
193
244
|
content: [{
|
|
194
245
|
type: 'text',
|
|
195
246
|
text: [
|
|
@@ -200,7 +251,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
200
251
|
` Dashboard: ${BASE_URL}/squeezr/dashboard`,
|
|
201
252
|
].join('\n'),
|
|
202
253
|
}],
|
|
203
|
-
};
|
|
254
|
+
});
|
|
204
255
|
}
|
|
205
256
|
// ── squeezr_stats ───────────────────────────────────────────────────────────
|
|
206
257
|
if (name === 'squeezr_stats') {
|
|
@@ -227,7 +278,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
227
278
|
.sort((a, b) => b[1].saved_tokens - a[1].saved_tokens)
|
|
228
279
|
.slice(0, 8)
|
|
229
280
|
.map(([tool, t]) => ` ${tool.padEnd(16)} ${fmtNum(t.saved_tokens).padStart(7)} tokens ${t.avg_pct}% avg ×${t.count}`);
|
|
230
|
-
return {
|
|
281
|
+
return appendUpdate({
|
|
231
282
|
content: [{
|
|
232
283
|
type: 'text',
|
|
233
284
|
text: [
|
|
@@ -242,9 +293,16 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
242
293
|
`Uptime : ${uptime}`,
|
|
243
294
|
'',
|
|
244
295
|
toolLines.length > 0 ? 'By tool:\n' + toolLines.join('\n') : 'No tool results compressed yet.',
|
|
296
|
+
'',
|
|
297
|
+
'── Savings Breakdown ──',
|
|
298
|
+
,
|
|
299
|
+
,
|
|
300
|
+
,
|
|
301
|
+
,
|
|
302
|
+
,
|
|
245
303
|
].join('\n'),
|
|
246
304
|
}],
|
|
247
|
-
};
|
|
305
|
+
});
|
|
248
306
|
}
|
|
249
307
|
// ── squeezr_set_mode ────────────────────────────────────────────────────────
|
|
250
308
|
if (name === 'squeezr_set_mode') {
|
|
@@ -489,6 +547,32 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
489
547
|
};
|
|
490
548
|
}
|
|
491
549
|
}
|
|
550
|
+
// ── squeezr_set_project ───────────────────────────────────────────────────
|
|
551
|
+
if (name === 'squeezr_set_project') {
|
|
552
|
+
const parsed = z.object({ project: z.string() }).parse(args);
|
|
553
|
+
const projName = parsed.project.trim();
|
|
554
|
+
const result = await proxyPost('/squeezr/project', {
|
|
555
|
+
project: projName || null,
|
|
556
|
+
});
|
|
557
|
+
if (!result) {
|
|
558
|
+
return {
|
|
559
|
+
content: [{ type: 'text', text: '❌ Squeezr proxy is not running. Start with: squeezr start' }],
|
|
560
|
+
isError: false,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
const isManual = result.manual;
|
|
564
|
+
const name_ = result.project;
|
|
565
|
+
return {
|
|
566
|
+
content: [{
|
|
567
|
+
type: 'text',
|
|
568
|
+
text: isManual
|
|
569
|
+
? `✅ Project set to: ${name_}
|
|
570
|
+
All future requests will be tracked under this project.
|
|
571
|
+
Clear with: squeezr_set_project({ project: "" })`
|
|
572
|
+
: `✅ Project cleared. Auto-detection active (current: ${name_}).`,
|
|
573
|
+
}],
|
|
574
|
+
};
|
|
575
|
+
}
|
|
492
576
|
return {
|
|
493
577
|
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
494
578
|
isError: true,
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { Stats } from './stats.js';
|
|
3
|
+
export declare function setManualProject(name: string | null): void;
|
|
4
|
+
export declare function getManualProject(): string | null;
|
|
3
5
|
export declare const stats: Stats;
|
|
4
6
|
export declare const app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
package/dist/server.js
CHANGED
|
@@ -16,9 +16,19 @@ import { VERSION } from './version.js';
|
|
|
16
16
|
import { recordRequest, getCurrentSession, getProjectAggregates, getAllSessionsForHistory, } from './history.js';
|
|
17
17
|
import { updateAnthropicFromHeaders, updateOpenAIFromHeaders, updateGeminiFrom429, addAnthropicUsage, addOpenAIUsage, addGeminiUsage, makeSseUsageParser, maybeRefreshOpenAIBilling, storeKey, limitsSnapshot, } from './limits.js';
|
|
18
18
|
// ── Project name extraction ────────────────────────────────────────────────────
|
|
19
|
+
// Manual project override — set via /squeezr/project endpoint or MCP tool
|
|
20
|
+
let manualProject = null;
|
|
21
|
+
export function setManualProject(name) {
|
|
22
|
+
manualProject = name;
|
|
23
|
+
}
|
|
24
|
+
export function getManualProject() {
|
|
25
|
+
return manualProject;
|
|
26
|
+
}
|
|
19
27
|
// Reads the CWD from Claude Code's system prompt (injected as <cwd>…</cwd> or
|
|
20
28
|
// "current working directory: …") and returns the last path component.
|
|
21
29
|
function extractProjectName(body) {
|
|
30
|
+
if (manualProject)
|
|
31
|
+
return manualProject;
|
|
22
32
|
try {
|
|
23
33
|
const system = body.system;
|
|
24
34
|
let text = '';
|
|
@@ -44,18 +54,18 @@ function extractProjectName(body) {
|
|
|
44
54
|
if (parts.length)
|
|
45
55
|
return parts[parts.length - 1];
|
|
46
56
|
}
|
|
47
|
-
// Fallback:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return m[1];
|
|
57
|
-
}
|
|
57
|
+
// Fallback: extract LAST meaningful path segment from system prompt
|
|
58
|
+
// e.g. C:\Users\Ramos\Documents\InvoiceApp\src → InvoiceApp
|
|
59
|
+
const pathMatch = text.match(/(?:[A-Za-z]:[\\/]|\/(?:Users|home|workspace|projects|Documents)[\\/])[^\s<>"*?|]+/i);
|
|
60
|
+
if (pathMatch) {
|
|
61
|
+
const parts = pathMatch[0].replace(/\\/g, '/').split('/').filter(Boolean);
|
|
62
|
+
const skip = new Set(['users', 'home', 'documents', 'workspace', 'projects', 'desktop', 'dev', 'src', 'repos']);
|
|
63
|
+
for (const pt of parts) {
|
|
64
|
+
if (!skip.has(pt.toLowerCase()) && !/^[a-z]:$/i.test(pt))
|
|
65
|
+
return pt;
|
|
58
66
|
}
|
|
67
|
+
if (parts.length)
|
|
68
|
+
return parts[parts.length - 1];
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
71
|
catch { /* ignore */ }
|
|
@@ -234,7 +244,7 @@ app.post('/v1/chat/completions', async (c) => {
|
|
|
234
244
|
const openAIKey = extractOpenAIKey(c.req.raw.headers);
|
|
235
245
|
const isLocal = config.isLocalKey(openAIKey);
|
|
236
246
|
const upstream = isLocal ? `${config.localUpstreamUrl.replace(/\/$/, '')}/v1/chat/completions` : `${OPENAI_API}/v1/chat/completions`;
|
|
237
|
-
// Extract project name BEFORE compressing system prompt
|
|
247
|
+
// Extract project name BEFORE compressing system prompt
|
|
238
248
|
const oaiProject = extractProjectName(body);
|
|
239
249
|
const messages = (body.messages ?? []);
|
|
240
250
|
// Compress system message for non-local
|
|
@@ -348,7 +358,6 @@ app.post('/v1beta/models/*', async (c) => {
|
|
|
348
358
|
break;
|
|
349
359
|
await s.write(value);
|
|
350
360
|
gemBuf += decoder.decode(value, { stream: true });
|
|
351
|
-
// Try to extract usageMetadata from each JSON chunk
|
|
352
361
|
const metaMatch = gemBuf.match(/"usageMetadata"\s*:\s*\{[^}]+\}/);
|
|
353
362
|
if (metaMatch) {
|
|
354
363
|
try {
|
|
@@ -406,6 +415,23 @@ app.get('/squeezr/stats', (c) => {
|
|
|
406
415
|
app.get('/squeezr/health', (c) => {
|
|
407
416
|
return c.json({ status: 'ok', version: VERSION });
|
|
408
417
|
});
|
|
418
|
+
// ── Project management ─────────────────────────────────────────────────────
|
|
419
|
+
app.get('/squeezr/project', (c) => {
|
|
420
|
+
return c.json({ project: getManualProject() ?? stats.currentProjectName() });
|
|
421
|
+
});
|
|
422
|
+
app.post('/squeezr/project', async (c) => {
|
|
423
|
+
const body = await c.req.json();
|
|
424
|
+
if (body.project === null || body.project === '') {
|
|
425
|
+
setManualProject(null);
|
|
426
|
+
return c.json({ project: stats.currentProjectName(), manual: false });
|
|
427
|
+
}
|
|
428
|
+
if (typeof body.project === 'string') {
|
|
429
|
+
setManualProject(body.project);
|
|
430
|
+
stats.setProject(body.project);
|
|
431
|
+
return c.json({ project: body.project, manual: true });
|
|
432
|
+
}
|
|
433
|
+
return c.json({ error: 'Invalid project name' }, 400);
|
|
434
|
+
});
|
|
409
435
|
app.get('/squeezr/expand/:id', (c) => {
|
|
410
436
|
const id = c.req.param('id');
|
|
411
437
|
const original = retrieveOriginal(id);
|
package/package.json
CHANGED