squeezr-ai 1.21.3 → 1.21.6
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/bin/squeezr.js +30 -4
- package/dist/dashboard.d.ts +1 -1
- package/dist/dashboard.js +71 -25
- package/dist/limits.d.ts +23 -0
- package/dist/limits.js +135 -15
- package/dist/server.js +8 -6
- package/package.json +1 -1
package/bin/squeezr.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawn, execSync } from 'child_process'
|
|
4
4
|
import http from 'http'
|
|
@@ -693,6 +693,9 @@ async function uninstall() {
|
|
|
693
693
|
try { execSync('nssm stop SqueezrProxy', { stdio: 'pipe' }) } catch {}
|
|
694
694
|
try { execSync('nssm remove SqueezrProxy confirm', { stdio: 'pipe' }) } catch {}
|
|
695
695
|
try { execSync('schtasks /Delete /TN "Squeezr" /F', { stdio: 'pipe' }); console.log(' [ok] Removed scheduled task') } catch {}
|
|
696
|
+
// Remove Startup folder VBS (fallback auto-start)
|
|
697
|
+
const startupVbs = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'squeezr-start.vbs')
|
|
698
|
+
try { fs.unlinkSync(startupVbs); console.log(' [ok] Removed startup VBS script') } catch {}
|
|
696
699
|
} else if (process.platform === 'darwin') {
|
|
697
700
|
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.squeezr.plist')
|
|
698
701
|
try { execSync(`launchctl unload "${plistPath}"`, { stdio: 'pipe' }) } catch {}
|
|
@@ -837,7 +840,7 @@ function setupWindows() {
|
|
|
837
840
|
}
|
|
838
841
|
|
|
839
842
|
if (!autoStartOk) {
|
|
840
|
-
// Fallback: Task Scheduler (no crash recovery, but
|
|
843
|
+
// Fallback: Task Scheduler (no crash recovery, but works without admin)
|
|
841
844
|
const taskName = 'Squeezr'
|
|
842
845
|
const nodeArg = `${nodeExe} \`"${distIndex}\`"`
|
|
843
846
|
const ps = [
|
|
@@ -846,13 +849,36 @@ function setupWindows() {
|
|
|
846
849
|
`$a = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-WindowStyle Hidden -NonInteractive -Command "${nodeArg}"' -WorkingDirectory '${ROOT}'`,
|
|
847
850
|
`$t = New-ScheduledTaskTrigger -AtLogon`,
|
|
848
851
|
`$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit 0 -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1)`,
|
|
849
|
-
`Register-ScheduledTask -TaskName '${taskName}' -Action $a -Trigger $t -Settings $s -
|
|
852
|
+
`Register-ScheduledTask -TaskName '${taskName}' -Action $a -Trigger $t -Settings $s -Force | Out-Null`,
|
|
850
853
|
].join('; ')
|
|
851
854
|
try {
|
|
852
855
|
execSync(`powershell -NoProfile -Command "${ps}"`, { stdio: 'pipe' })
|
|
853
856
|
console.log(` [ok] Auto-start registered in Task Scheduler (install NSSM for crash recovery)`)
|
|
857
|
+
autoStartOk = true
|
|
854
858
|
} catch {
|
|
855
|
-
|
|
859
|
+
// ignore — will fall through to Startup folder VBS
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (!autoStartOk) {
|
|
864
|
+
// Final fallback: VBS script in user Startup folder (no admin, no special tools)
|
|
865
|
+
try {
|
|
866
|
+
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
|
|
867
|
+
const squeezrCmd = path.join(appData, 'npm', 'squeezr.cmd')
|
|
868
|
+
const startupDir = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
|
|
869
|
+
const vbsPath = path.join(startupDir, 'squeezr-start.vbs')
|
|
870
|
+
const cmdToRun = fs.existsSync(squeezrCmd) ? squeezrCmd : nodeExe
|
|
871
|
+
const cmdArg = fs.existsSync(squeezrCmd) ? 'start' : `"${distIndex}"`
|
|
872
|
+
const vbsContent = [
|
|
873
|
+
'Set WshShell = CreateObject("WScript.Shell")',
|
|
874
|
+
`WshShell.Run """${cmdToRun}"" ${cmdArg}", 0, False`,
|
|
875
|
+
'',
|
|
876
|
+
].join('\r\n')
|
|
877
|
+
fs.mkdirSync(startupDir, { recursive: true })
|
|
878
|
+
fs.writeFileSync(vbsPath, vbsContent)
|
|
879
|
+
console.log(` [ok] Auto-start registered in Startup folder (${vbsPath})`)
|
|
880
|
+
} catch (err) {
|
|
881
|
+
console.log(` [warn] Auto-start failed — run as admin or install NSSM: https://nssm.cc`)
|
|
856
882
|
}
|
|
857
883
|
}
|
|
858
884
|
|
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:#09090b;--bg2:#111113;--bg3:#1a1a1e;--bg4:#252529;\n --border:#2a2a2e;--text:#e4e4e7;--muted:#71717a;\n --green:#22c55e;--yellow:#eab308;--red:#ef4444;\n --blue:#22c55e;--purple:#a78bfa;--orange:#f59e0b;--accent:#16a34a\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\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#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\" style=\"display:flex;align-items:center;gap:8px\">\n <svg width=\"24\" height=\"24\" viewBox=\"0 0 80 80\" fill=\"none\"><rect width=\"80\" height=\"80\" rx=\"16\" fill=\"#16a34a\"/><rect x=\"8\" y=\"14\" width=\"64\" height=\"10\" rx=\"5\" fill=\"white\" opacity=\"0.35\"/><rect x=\"16\" y=\"35\" width=\"48\" height=\"10\" rx=\"5\" fill=\"white\" opacity=\"0.65\"/><rect x=\"24\" y=\"56\" width=\"32\" height=\"10\" rx=\"5\" fill=\"white\" opacity=\"1\"/></svg>\n Squee<span>zr</span>\n </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>\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=\"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 <svg width=\"16\" height=\"16\" fill=\"var(--text)\" viewBox=\"0 0 16 16\" style=\"vertical-align:middle;margin-right:6px\"><path d=\"m3.127 10.604 3.135-1.76.053-.153-.053-.085H6.11l-.525-.032-1.791-.048-1.554-.065-1.505-.08-.38-.081L0 7.832l.036-.234.32-.214.455.04 1.009.069 1.513.105 1.097.064 1.626.17h.259l.036-.105-.089-.065-.068-.064-1.566-1.062-1.695-1.121-.887-.646-.48-.327-.243-.306-.104-.67.435-.48.585.04.15.04.593.456 1.267.981 1.654 1.218.242.202.097-.068.012-.049-.109-.181-.9-1.626-.96-1.655-.428-.686-.113-.411a2 2 0 0 1-.068-.484l.496-.674L4.446 0l.662.089.279.242.411.94.666 1.48 1.033 2.014.302.597.162.553.06.17h.105v-.097l.085-1.134.157-1.392.154-1.792.052-.504.25-.605.497-.327.387.186.319.456-.045.294-.19 1.23-.37 1.93-.243 1.29h.142l.161-.16.654-.868 1.097-1.372.484-.545.565-.601.363-.287h.686l.505.751-.226.775-.707.895-.585.759-.839 1.13-.524.904.048.072.125-.012 1.897-.403 1.024-.186 1.223-.21.553.258.06.263-.218.536-1.307.323-1.533.307-2.284.54-.028.02.032.04 1.029.098.44.024h1.077l2.005.15.525.346.315.424-.053.323-.807.411-3.631-.863-.872-.218h-.12v.073l.726.71 1.331 1.202 1.667 1.55.084.383-.214.302-.226-.032-1.464-1.101-.565-.497-1.28-1.077h-.084v.113l.295.432 1.557 2.34.08.718-.112.234-.404.141-.444-.08-.911-1.28-.94-1.44-.759-1.291-.093.053-.448 4.821-.21.246-.484.186-.403-.307-.214-.496.214-.98.258-1.28.21-1.016.19-1.263.112-.42-.008-.028-.092.012-.953 1.307-1.448 1.957-1.146 1.227-.274.109-.477-.247.045-.44.266-.39 1.586-2.018.956-1.25.617-.723-.004-.105h-.036l-4.212 2.736-.75.096-.324-.302.04-.496.154-.162 1.267-.871z\"/></svg><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 id=\"ant-tok-label\">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 id=\"ant-req-label\">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 id=\"ant-inp-label\">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 id=\"ant-out-label\">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 <svg width=\"16\" height=\"16\" fill=\"var(--text)\" viewBox=\"0 0 16 16\" style=\"vertical-align:middle;margin-right:6px\"><path d=\"M14.949 6.547a3.94 3.94 0 0 0-.348-3.273 4.11 4.11 0 0 0-4.4-1.934A4.1 4.1 0 0 0 8.423.2 4.15 4.15 0 0 0 6.305.086a4.1 4.1 0 0 0-1.891.948 4.04 4.04 0 0 0-1.158 1.753 4.1 4.1 0 0 0-1.563.679A4 4 0 0 0 .554 4.72a3.99 3.99 0 0 0 .502 4.731 3.94 3.94 0 0 0 .346 3.274 4.11 4.11 0 0 0 4.402 1.933c.382.425.852.764 1.377.995.526.231 1.095.35 1.67.346 1.78.002 3.358-1.132 3.901-2.804a4.1 4.1 0 0 0 1.563-.68 4 4 0 0 0 1.14-1.253 3.99 3.99 0 0 0-.506-4.716m-6.097 8.406a3.05 3.05 0 0 1-1.945-.694l.096-.054 3.23-1.838a.53.53 0 0 0 .265-.455v-4.49l1.366.778q.02.011.025.035v3.722c-.003 1.653-1.361 2.992-3.037 2.996m-6.53-2.75a2.95 2.95 0 0 1-.36-2.01l.095.057 3.233 1.84a.53.53 0 0 0 .527 0l3.949-2.246v1.555a.05.05 0 0 1-.022.041L6.473 13.3c-1.454.826-3.311.335-4.15-1.098m-.85-6.94A3.02 3.02 0 0 1 3.07 3.949v3.785a.51.51 0 0 0 .262.451l3.93 2.237-1.366.779a.05.05 0 0 1-.048 0L2.585 9.342a2.98 2.98 0 0 1-1.113-4.094zm11.216 2.571L8.747 5.576l1.362-.776a.05.05 0 0 1 .048 0l3.265 1.86a3 3 0 0 1 1.173 1.207 2.96 2.96 0 0 1-.27 3.2 3.05 3.05 0 0 1-1.36.997V8.279a.52.52 0 0 0-.276-.445m1.36-2.015-.097-.057-3.226-1.855a.53.53 0 0 0-.53 0L6.249 6.153V4.598a.04.04 0 0 1 .019-.04L9.533 2.7a3.07 3.07 0 0 1 3.257.139c.474.325.843.778 1.066 1.303.223.526.289 1.103.191 1.664zM5.503 8.575 4.139 7.8a.05.05 0 0 1-.026-.037V4.049c0-.57.166-1.127.476-1.607s.752-.864 1.275-1.105a3.08 3.08 0 0 1 3.234.41l-.096.054-3.23 1.838a.53.53 0 0 0-.265.455zm.742-1.577 1.758-1 1.762 1v2l-1.755 1-1.762-1z\"/></svg><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 <svg width=\"16\" height=\"16\" fill=\"var(--text)\" viewBox=\"0 0 16 16\" style=\"vertical-align:middle;margin-right:6px\"><path d=\"M15.545 6.558a9.4 9.4 0 0 1 .139 1.626c0 2.434-.87 4.492-2.384 5.885h.002C11.978 15.292 10.158 16 8 16A8 8 0 1 1 8 0a7.7 7.7 0 0 1 5.352 2.082l-2.284 2.284A4.35 4.35 0 0 0 8 3.166c-2.087 0-3.86 1.408-4.492 3.304a4.8 4.8 0 0 0 0 3.063h.003c.635 1.893 2.405 3.301 4.492 3.301 1.078 0 2.004-.276 2.722-.764h-.003a3.7 3.7 0 0 0 1.599-2.431H8v-3.08z\"/></svg><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 // uptime removed from UI\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 (anthropic?.unified?.hasData) {\n // Subscription (OAuth): unified rate limits with 5h/7d windows\n const u = anthropic.unified\n document.getElementById('ant-badge').className = 'limits-cli-badge live'\n document.getElementById('ant-badge').textContent = 'subscription'\n // Relabel gauges for subscription windows\n document.getElementById('ant-tok-label').textContent = '5-hour window'\n document.getElementById('ant-req-label').textContent = '7-day window'\n document.getElementById('ant-inp-label').textContent = 'Session input'\n document.getElementById('ant-out-label').textContent = 'Session output'\n // 5-hour window\n const pct5h = Math.round(u.fiveHourUtilization * 100)\n document.getElementById('ant-tok-fill').style.width = pct5h + '%'\n document.getElementById('ant-tok-fill').style.background = gaugeColor(pct5h)\n document.getElementById('ant-tok-pct').textContent = pct5h + '%'\n document.getElementById('ant-tok-pct').style.color = gaugeColor(pct5h)\n const secs5h = u.fiveHourResetEpoch > 0 ? Math.max(0, Math.round((u.fiveHourResetEpoch - Date.now()) / 1000)) : 0\n const resetStr5h = secs5h > 3600 ? Math.floor(secs5h/3600) + 'h ' + Math.floor((secs5h%3600)/60) + 'm'\n : secs5h > 60 ? Math.floor(secs5h/60) + 'm'\n : secs5h > 0 ? secs5h + 's' : ''\n document.getElementById('ant-tok-rem').textContent = u.fiveHourStatus === 'allowed'\n ? (pct5h >= 80 ? pct5h + '% used' : (100-pct5h) + '% free')\n : 'throttled \u2014 resets in ' + resetStr5h\n document.getElementById('ant-tok-reset').textContent = resetStr5h ? 'resets in ' + resetStr5h : ''\n // 7-day window\n const pct7d = Math.round(u.sevenDayUtilization * 100)\n document.getElementById('ant-req-fill').style.width = pct7d + '%'\n document.getElementById('ant-req-fill').style.background = gaugeColor(pct7d)\n document.getElementById('ant-req-pct').textContent = pct7d + '%'\n document.getElementById('ant-req-pct').style.color = gaugeColor(pct7d)\n const secs7d = u.sevenDayResetEpoch > 0 ? Math.max(0, Math.round((u.sevenDayResetEpoch - Date.now()) / 1000)) : 0\n const resetStr7d = secs7d > 86400 ? Math.floor(secs7d/86400) + 'd ' + Math.floor((secs7d%86400)/3600) + 'h'\n : secs7d > 3600 ? Math.floor(secs7d/3600) + 'h ' + Math.floor((secs7d%3600)/60) + 'm'\n : secs7d > 0 ? Math.floor(secs7d/60) + 'm' : ''\n document.getElementById('ant-req-rem').textContent = u.sevenDayStatus === 'allowed'\n ? (pct7d >= 80 ? pct7d + '% used' : (100-pct7d) + '% free')\n : 'throttled \u2014 resets in ' + resetStr7d\n document.getElementById('ant-req-reset').textContent = resetStr7d ? 'resets in ' + resetStr7d : ''\n // Input/output: show session totals\n document.getElementById('ant-inp-pct').textContent = fmtTokens(au?.inputSession || 0)\n document.getElementById('ant-inp-rem').textContent = 'session input'\n document.getElementById('ant-out-pct').textContent = fmtTokens(au?.outputSession || 0)\n document.getElementById('ant-out-rem').textContent = 'session output'\n } else if (antHasUsage) {\n // Fallback: no rate limit headers at all, but usage is tracked\n document.getElementById('ant-badge').className = 'limits-cli-badge live'\n document.getElementById('ant-badge').textContent = 'tracking'\n document.getElementById('ant-tok-pct').textContent = fmtTokens((au?.inputSession || 0) + (au?.outputSession || 0))\n document.getElementById('ant-tok-rem').textContent = 'session total'\n document.getElementById('ant-req-pct').textContent = (au?.requestsSession || 0) + ' reqs'\n document.getElementById('ant-req-rem').textContent = 'session total'\n document.getElementById('ant-inp-pct').textContent = fmtTokens(au?.inputSession || 0)\n document.getElementById('ant-inp-rem').textContent = 'session input'\n document.getElementById('ant-out-pct').textContent = fmtTokens(au?.outputSession || 0)\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 = 'Connected'\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:#09090b;--bg2:#111113;--bg3:#1a1a1e;--bg4:#252529;\n --border:#2a2a2e;--text:#e4e4e7;--muted:#71717a;\n --green:#22c55e;--yellow:#eab308;--red:#ef4444;\n --blue:#22c55e;--purple:#a78bfa;--orange:#f59e0b;--accent:#16a34a\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\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#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\" style=\"display:flex;align-items:center;gap:8px\">\n <svg width=\"24\" height=\"24\" viewBox=\"0 0 80 80\" fill=\"none\"><rect width=\"80\" height=\"80\" rx=\"16\" fill=\"#16a34a\"/><rect x=\"8\" y=\"14\" width=\"64\" height=\"10\" rx=\"5\" fill=\"white\" opacity=\"0.35\"/><rect x=\"16\" y=\"35\" width=\"48\" height=\"10\" rx=\"5\" fill=\"white\" opacity=\"0.65\"/><rect x=\"24\" y=\"56\" width=\"32\" height=\"10\" rx=\"5\" fill=\"white\" opacity=\"1\"/></svg>\n Squee<span>zr</span>\n </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>\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=\"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 <svg width=\"16\" height=\"16\" fill=\"var(--text)\" viewBox=\"0 0 16 16\" style=\"vertical-align:middle;margin-right:6px\"><path d=\"m3.127 10.604 3.135-1.76.053-.153-.053-.085H6.11l-.525-.032-1.791-.048-1.554-.065-1.505-.08-.38-.081L0 7.832l.036-.234.32-.214.455.04 1.009.069 1.513.105 1.097.064 1.626.17h.259l.036-.105-.089-.065-.068-.064-1.566-1.062-1.695-1.121-.887-.646-.48-.327-.243-.306-.104-.67.435-.48.585.04.15.04.593.456 1.267.981 1.654 1.218.242.202.097-.068.012-.049-.109-.181-.9-1.626-.96-1.655-.428-.686-.113-.411a2 2 0 0 1-.068-.484l.496-.674L4.446 0l.662.089.279.242.411.94.666 1.48 1.033 2.014.302.597.162.553.06.17h.105v-.097l.085-1.134.157-1.392.154-1.792.052-.504.25-.605.497-.327.387.186.319.456-.045.294-.19 1.23-.37 1.93-.243 1.29h.142l.161-.16.654-.868 1.097-1.372.484-.545.565-.601.363-.287h.686l.505.751-.226.775-.707.895-.585.759-.839 1.13-.524.904.048.072.125-.012 1.897-.403 1.024-.186 1.223-.21.553.258.06.263-.218.536-1.307.323-1.533.307-2.284.54-.028.02.032.04 1.029.098.44.024h1.077l2.005.15.525.346.315.424-.053.323-.807.411-3.631-.863-.872-.218h-.12v.073l.726.71 1.331 1.202 1.667 1.55.084.383-.214.302-.226-.032-1.464-1.101-.565-.497-1.28-1.077h-.084v.113l.295.432 1.557 2.34.08.718-.112.234-.404.141-.444-.08-.911-1.28-.94-1.44-.759-1.291-.093.053-.448 4.821-.21.246-.484.186-.403-.307-.214-.496.214-.98.258-1.28.21-1.016.19-1.263.112-.42-.008-.028-.092.012-.953 1.307-1.448 1.957-1.146 1.227-.274.109-.477-.247.045-.44.266-.39 1.586-2.018.956-1.25.617-.723-.004-.105h-.036l-4.212 2.736-.75.096-.324-.302.04-.496.154-.162 1.267-.871z\"/></svg><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 id=\"ant-tok-label\">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 id=\"ant-req-label\">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 id=\"ant-inp-label\">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 id=\"ant-out-label\">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 <svg width=\"16\" height=\"16\" fill=\"var(--text)\" viewBox=\"0 0 16 16\" style=\"vertical-align:middle;margin-right:6px\"><path d=\"M14.949 6.547a3.94 3.94 0 0 0-.348-3.273 4.11 4.11 0 0 0-4.4-1.934A4.1 4.1 0 0 0 8.423.2 4.15 4.15 0 0 0 6.305.086a4.1 4.1 0 0 0-1.891.948 4.04 4.04 0 0 0-1.158 1.753 4.1 4.1 0 0 0-1.563.679A4 4 0 0 0 .554 4.72a3.99 3.99 0 0 0 .502 4.731 3.94 3.94 0 0 0 .346 3.274 4.11 4.11 0 0 0 4.402 1.933c.382.425.852.764 1.377.995.526.231 1.095.35 1.67.346 1.78.002 3.358-1.132 3.901-2.804a4.1 4.1 0 0 0 1.563-.68 4 4 0 0 0 1.14-1.253 3.99 3.99 0 0 0-.506-4.716m-6.097 8.406a3.05 3.05 0 0 1-1.945-.694l.096-.054 3.23-1.838a.53.53 0 0 0 .265-.455v-4.49l1.366.778q.02.011.025.035v3.722c-.003 1.653-1.361 2.992-3.037 2.996m-6.53-2.75a2.95 2.95 0 0 1-.36-2.01l.095.057 3.233 1.84a.53.53 0 0 0 .527 0l3.949-2.246v1.555a.05.05 0 0 1-.022.041L6.473 13.3c-1.454.826-3.311.335-4.15-1.098m-.85-6.94A3.02 3.02 0 0 1 3.07 3.949v3.785a.51.51 0 0 0 .262.451l3.93 2.237-1.366.779a.05.05 0 0 1-.048 0L2.585 9.342a2.98 2.98 0 0 1-1.113-4.094zm11.216 2.571L8.747 5.576l1.362-.776a.05.05 0 0 1 .048 0l3.265 1.86a3 3 0 0 1 1.173 1.207 2.96 2.96 0 0 1-.27 3.2 3.05 3.05 0 0 1-1.36.997V8.279a.52.52 0 0 0-.276-.445m1.36-2.015-.097-.057-3.226-1.855a.53.53 0 0 0-.53 0L6.249 6.153V4.598a.04.04 0 0 1 .019-.04L9.533 2.7a3.07 3.07 0 0 1 3.257.139c.474.325.843.778 1.066 1.303.223.526.289 1.103.191 1.664zM5.503 8.575 4.139 7.8a.05.05 0 0 1-.026-.037V4.049c0-.57.166-1.127.476-1.607s.752-.864 1.275-1.105a3.08 3.08 0 0 1 3.234.41l-.096.054-3.23 1.838a.53.53 0 0 0-.265.455zm.742-1.577 1.758-1 1.762 1v2l-1.755 1-1.762-1z\"/></svg><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 id=\"oai-tok-label\">Session window</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 id=\"oai-req-label\">Weekly window</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 <svg width=\"16\" height=\"16\" fill=\"var(--text)\" viewBox=\"0 0 16 16\" style=\"vertical-align:middle;margin-right:6px\"><path d=\"M15.545 6.558a9.4 9.4 0 0 1 .139 1.626c0 2.434-.87 4.492-2.384 5.885h.002C11.978 15.292 10.158 16 8 16A8 8 0 1 1 8 0a7.7 7.7 0 0 1 5.352 2.082l-2.284 2.284A4.35 4.35 0 0 0 8 3.166c-2.087 0-3.86 1.408-4.492 3.304a4.8 4.8 0 0 0 0 3.063h.003c.635 1.893 2.405 3.301 4.492 3.301 1.078 0 2.004-.276 2.722-.764h-.003a3.7 3.7 0 0 0 1.599-2.431H8v-3.08z\"/></svg><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 // uptime removed from UI\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 formatResetCountdown(resetEpoch) {\n const secs = resetEpoch > 0 ? Math.max(0, Math.round((resetEpoch - Date.now()) / 1000)) : 0\n if (secs <= 0) return ''\n if (secs >= 86400) return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h'\n if (secs >= 3600) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm'\n if (secs >= 60) return Math.floor(secs / 60) + 'm'\n return secs + 's'\n}\n\nfunction fillPercentGauge(fillId, pctId, remId, resetId, usedPercent, resetEpoch) {\n if (usedPercent == null) {\n document.getElementById(fillId).style.width = '0%'\n document.getElementById(pctId).textContent = '\u00E2\u20AC\u201D'\n document.getElementById(remId).textContent = '\u00E2\u20AC\u201D'\n if (resetId) document.getElementById(resetId).textContent = ''\n return\n }\n const pct = Math.max(0, Math.min(100, Math.round(usedPercent || 0)))\n const fill = document.getElementById(fillId)\n fill.style.width = pct + '%'\n fill.style.background = gaugeColor(pct)\n document.getElementById(pctId).textContent = pct + '%'\n document.getElementById(pctId).style.color = gaugeColor(pct)\n document.getElementById(remId).textContent = pct >= 80 ? pct + '% used' : (100 - pct) + '% free'\n const resetStr = formatResetCountdown(resetEpoch)\n if (resetId) document.getElementById(resetId).textContent = resetStr ? 'resets in ' + resetStr : ''\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 (anthropic?.unified?.hasData) {\n // Subscription (OAuth): unified rate limits with 5h/7d windows\n const u = anthropic.unified\n document.getElementById('ant-badge').className = 'limits-cli-badge live'\n document.getElementById('ant-badge').textContent = 'subscription'\n // Relabel gauges for subscription windows\n document.getElementById('ant-tok-label').textContent = '5-hour window'\n document.getElementById('ant-req-label').textContent = '7-day window'\n document.getElementById('ant-inp-label').textContent = 'Session input'\n document.getElementById('ant-out-label').textContent = 'Session output'\n // 5-hour window\n const pct5h = Math.round(u.fiveHourUtilization * 100)\n document.getElementById('ant-tok-fill').style.width = pct5h + '%'\n document.getElementById('ant-tok-fill').style.background = gaugeColor(pct5h)\n document.getElementById('ant-tok-pct').textContent = pct5h + '%'\n document.getElementById('ant-tok-pct').style.color = gaugeColor(pct5h)\n const secs5h = u.fiveHourResetEpoch > 0 ? Math.max(0, Math.round((u.fiveHourResetEpoch - Date.now()) / 1000)) : 0\n const resetStr5h = secs5h > 3600 ? Math.floor(secs5h/3600) + 'h ' + Math.floor((secs5h%3600)/60) + 'm'\n : secs5h > 60 ? Math.floor(secs5h/60) + 'm'\n : secs5h > 0 ? secs5h + 's' : ''\n document.getElementById('ant-tok-rem').textContent = u.fiveHourStatus === 'allowed'\n ? (pct5h >= 80 ? pct5h + '% used' : (100-pct5h) + '% free')\n : 'throttled \u2014 resets in ' + resetStr5h\n document.getElementById('ant-tok-reset').textContent = resetStr5h ? 'resets in ' + resetStr5h : ''\n // 7-day window\n const pct7d = Math.round(u.sevenDayUtilization * 100)\n document.getElementById('ant-req-fill').style.width = pct7d + '%'\n document.getElementById('ant-req-fill').style.background = gaugeColor(pct7d)\n document.getElementById('ant-req-pct').textContent = pct7d + '%'\n document.getElementById('ant-req-pct').style.color = gaugeColor(pct7d)\n const secs7d = u.sevenDayResetEpoch > 0 ? Math.max(0, Math.round((u.sevenDayResetEpoch - Date.now()) / 1000)) : 0\n const resetStr7d = secs7d > 86400 ? Math.floor(secs7d/86400) + 'd ' + Math.floor((secs7d%86400)/3600) + 'h'\n : secs7d > 3600 ? Math.floor(secs7d/3600) + 'h ' + Math.floor((secs7d%3600)/60) + 'm'\n : secs7d > 0 ? Math.floor(secs7d/60) + 'm' : ''\n document.getElementById('ant-req-rem').textContent = u.sevenDayStatus === 'allowed'\n ? (pct7d >= 80 ? pct7d + '% used' : (100-pct7d) + '% free')\n : 'throttled \u2014 resets in ' + resetStr7d\n document.getElementById('ant-req-reset').textContent = resetStr7d ? 'resets in ' + resetStr7d : ''\n // Input/output: show session totals\n document.getElementById('ant-inp-pct').textContent = fmtTokens(au?.inputSession || 0)\n document.getElementById('ant-inp-rem').textContent = 'session input'\n document.getElementById('ant-out-pct').textContent = fmtTokens(au?.outputSession || 0)\n document.getElementById('ant-out-rem').textContent = 'session output'\n } else if (antHasUsage) {\n // Fallback: no rate limit headers at all, but usage is tracked\n document.getElementById('ant-badge').className = 'limits-cli-badge live'\n document.getElementById('ant-badge').textContent = 'tracking'\n document.getElementById('ant-tok-pct').textContent = fmtTokens((au?.inputSession || 0) + (au?.outputSession || 0))\n document.getElementById('ant-tok-rem').textContent = 'session total'\n document.getElementById('ant-req-pct').textContent = (au?.requestsSession || 0) + ' reqs'\n document.getElementById('ant-req-rem').textContent = 'session total'\n document.getElementById('ant-inp-pct').textContent = fmtTokens(au?.inputSession || 0)\n document.getElementById('ant-inp-rem').textContent = 'session input'\n document.getElementById('ant-out-pct').textContent = fmtTokens(au?.outputSession || 0)\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 os = openai?.session\n const orl = openai?.rl\n const ou = openai?.usage\n const oaiHasUsage = ou && (ou.inputSession > 0 || ou.outputSession > 0)\n if (os?.hasData) {\n document.getElementById('oai-badge').className = 'limits-cli-badge live'\n document.getElementById('oai-badge').textContent = os.planType || 'session'\n document.getElementById('oai-tok-label').textContent = (os.primary?.windowDurationMins || 300) >= 300 ? '5-hour window' : 'session window'\n document.getElementById('oai-req-label').textContent = (os.secondary?.windowDurationMins || 0) >= 10080 ? '7-day window' : 'weekly window'\n fillPercentGauge('oai-tok-fill','oai-tok-pct','oai-tok-rem','oai-tok-reset', os.primary?.usedPercent, os.primary?.resetsAt || 0)\n fillPercentGauge('oai-req-fill','oai-req-pct','oai-req-rem','oai-req-reset', os.secondary?.usedPercent, os.secondary?.resetsAt || 0)\n } else if (orl?.hasData) {\n document.getElementById('oai-badge').className = 'limits-cli-badge live'\n document.getElementById('oai-badge').textContent = 'live'\n document.getElementById('oai-tok-label').textContent = 'tokens / minute'\n document.getElementById('oai-req-label').textContent = 'requests / minute'\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-label').textContent = 'session total'\n document.getElementById('oai-req-label').textContent = 'session requests'\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 if (d?.openai?.session?.hasData) {\n const tok = d.openai.session.primary?.resetsAt || 0\n const req = d.openai.session.secondary?.resetsAt || 0\n updateReset('oai-tok-reset', tok)\n updateReset('oai-req-reset', req)\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 = 'Connected'\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
|
@@ -496,7 +496,7 @@ tr:last-child td{border-bottom:none}
|
|
|
496
496
|
<div class="limits-gauge-grid">
|
|
497
497
|
<div class="limits-gauge">
|
|
498
498
|
<div class="limits-gauge-label">
|
|
499
|
-
<span>
|
|
499
|
+
<span id="oai-tok-label">Session window</span>
|
|
500
500
|
<span id="oai-tok-pct" style="color:var(--muted)">—</span>
|
|
501
501
|
</div>
|
|
502
502
|
<div class="limits-gauge-bar"><div class="limits-gauge-fill" id="oai-tok-fill" style="width:0%"></div></div>
|
|
@@ -507,7 +507,7 @@ tr:last-child td{border-bottom:none}
|
|
|
507
507
|
</div>
|
|
508
508
|
<div class="limits-gauge">
|
|
509
509
|
<div class="limits-gauge-label">
|
|
510
|
-
<span>
|
|
510
|
+
<span id="oai-req-label">Weekly window</span>
|
|
511
511
|
<span id="oai-req-pct" style="color:var(--muted)">—</span>
|
|
512
512
|
</div>
|
|
513
513
|
<div class="limits-gauge-bar"><div class="limits-gauge-fill" id="oai-req-fill" style="width:0%"></div></div>
|
|
@@ -1032,7 +1032,7 @@ function gaugeColor(pct) {
|
|
|
1032
1032
|
return 'var(--green)'
|
|
1033
1033
|
}
|
|
1034
1034
|
|
|
1035
|
-
function fillGauge(fillId, pctId, remId, resetId, remaining, limit, resetEpoch) {
|
|
1035
|
+
function fillGauge(fillId, pctId, remId, resetId, remaining, limit, resetEpoch) {
|
|
1036
1036
|
if (!limit) {
|
|
1037
1037
|
document.getElementById(fillId).style.width = '0%'
|
|
1038
1038
|
document.getElementById(pctId).textContent = '—'
|
|
@@ -1051,8 +1051,36 @@ function fillGauge(fillId, pctId, remId, resetId, remaining, limit, resetEpoch)
|
|
|
1051
1051
|
if (resetId && resetEpoch) {
|
|
1052
1052
|
const secs = Math.max(0, Math.round((resetEpoch - Date.now()) / 1000))
|
|
1053
1053
|
document.getElementById(resetId).textContent = secs > 0 ? 'resets in ' + secs + 's' : 'resetting…'
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function formatResetCountdown(resetEpoch) {
|
|
1058
|
+
const secs = resetEpoch > 0 ? Math.max(0, Math.round((resetEpoch - Date.now()) / 1000)) : 0
|
|
1059
|
+
if (secs <= 0) return ''
|
|
1060
|
+
if (secs >= 86400) return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h'
|
|
1061
|
+
if (secs >= 3600) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm'
|
|
1062
|
+
if (secs >= 60) return Math.floor(secs / 60) + 'm'
|
|
1063
|
+
return secs + 's'
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function fillPercentGauge(fillId, pctId, remId, resetId, usedPercent, resetEpoch) {
|
|
1067
|
+
if (usedPercent == null) {
|
|
1068
|
+
document.getElementById(fillId).style.width = '0%'
|
|
1069
|
+
document.getElementById(pctId).textContent = '—'
|
|
1070
|
+
document.getElementById(remId).textContent = '—'
|
|
1071
|
+
if (resetId) document.getElementById(resetId).textContent = ''
|
|
1072
|
+
return
|
|
1073
|
+
}
|
|
1074
|
+
const pct = Math.max(0, Math.min(100, Math.round(usedPercent || 0)))
|
|
1075
|
+
const fill = document.getElementById(fillId)
|
|
1076
|
+
fill.style.width = pct + '%'
|
|
1077
|
+
fill.style.background = gaugeColor(pct)
|
|
1078
|
+
document.getElementById(pctId).textContent = pct + '%'
|
|
1079
|
+
document.getElementById(pctId).style.color = gaugeColor(pct)
|
|
1080
|
+
document.getElementById(remId).textContent = pct >= 80 ? pct + '% used' : (100 - pct) + '% free'
|
|
1081
|
+
const resetStr = formatResetCountdown(resetEpoch)
|
|
1082
|
+
if (resetId) document.getElementById(resetId).textContent = resetStr ? 'resets in ' + resetStr : ''
|
|
1083
|
+
}
|
|
1056
1084
|
|
|
1057
1085
|
function renderLimits(d) {
|
|
1058
1086
|
if (!d) return
|
|
@@ -1133,20 +1161,32 @@ function renderLimits(d) {
|
|
|
1133
1161
|
}
|
|
1134
1162
|
|
|
1135
1163
|
// ── OpenAI ──
|
|
1136
|
-
const
|
|
1137
|
-
const
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
document.getElementById('oai-badge').
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
document.getElementById('oai-
|
|
1149
|
-
document.getElementById('oai-
|
|
1164
|
+
const os = openai?.session
|
|
1165
|
+
const orl = openai?.rl
|
|
1166
|
+
const ou = openai?.usage
|
|
1167
|
+
const oaiHasUsage = ou && (ou.inputSession > 0 || ou.outputSession > 0)
|
|
1168
|
+
if (os?.hasData) {
|
|
1169
|
+
document.getElementById('oai-badge').className = 'limits-cli-badge live'
|
|
1170
|
+
document.getElementById('oai-badge').textContent = os.planType || 'session'
|
|
1171
|
+
document.getElementById('oai-tok-label').textContent = (os.primary?.windowDurationMins || 300) >= 300 ? '5-hour window' : 'session window'
|
|
1172
|
+
document.getElementById('oai-req-label').textContent = (os.secondary?.windowDurationMins || 0) >= 10080 ? '7-day window' : 'weekly window'
|
|
1173
|
+
fillPercentGauge('oai-tok-fill','oai-tok-pct','oai-tok-rem','oai-tok-reset', os.primary?.usedPercent, os.primary?.resetsAt || 0)
|
|
1174
|
+
fillPercentGauge('oai-req-fill','oai-req-pct','oai-req-rem','oai-req-reset', os.secondary?.usedPercent, os.secondary?.resetsAt || 0)
|
|
1175
|
+
} else if (orl?.hasData) {
|
|
1176
|
+
document.getElementById('oai-badge').className = 'limits-cli-badge live'
|
|
1177
|
+
document.getElementById('oai-badge').textContent = 'live'
|
|
1178
|
+
document.getElementById('oai-tok-label').textContent = 'tokens / minute'
|
|
1179
|
+
document.getElementById('oai-req-label').textContent = 'requests / minute'
|
|
1180
|
+
fillGauge('oai-tok-fill','oai-tok-pct','oai-tok-rem','oai-tok-reset', orl.tokensRemaining, orl.tokensLimit, orl.tokensResetEpoch)
|
|
1181
|
+
fillGauge('oai-req-fill','oai-req-pct','oai-req-rem','oai-req-reset', orl.requestsRemaining, orl.requestsLimit, orl.requestsResetEpoch)
|
|
1182
|
+
} else if (oaiHasUsage) {
|
|
1183
|
+
document.getElementById('oai-badge').className = 'limits-cli-badge live'
|
|
1184
|
+
document.getElementById('oai-badge').textContent = 'tracking'
|
|
1185
|
+
document.getElementById('oai-tok-label').textContent = 'session total'
|
|
1186
|
+
document.getElementById('oai-req-label').textContent = 'session requests'
|
|
1187
|
+
document.getElementById('oai-tok-pct').textContent = fmtTokens(ou.inputSession + ou.outputSession)
|
|
1188
|
+
document.getElementById('oai-tok-rem').textContent = 'session total'
|
|
1189
|
+
document.getElementById('oai-req-pct').textContent = ou.requestsSession + ' reqs'
|
|
1150
1190
|
document.getElementById('oai-req-rem').textContent = 'session total'
|
|
1151
1191
|
}
|
|
1152
1192
|
const ob = openai?.billing
|
|
@@ -1208,12 +1248,18 @@ function startLimitsCountdown(limitsData) {
|
|
|
1208
1248
|
updateReset('ant-inp-reset', d.anthropic.rl.tokensResetEpoch)
|
|
1209
1249
|
updateReset('ant-out-reset', d.anthropic.rl.tokensResetEpoch)
|
|
1210
1250
|
}
|
|
1211
|
-
if (d?.openai?.rl?.hasData) {
|
|
1212
|
-
updateReset('oai-tok-reset', d.openai.rl.tokensResetEpoch)
|
|
1213
|
-
updateReset('oai-req-reset', d.openai.rl.requestsResetEpoch)
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
|
|
1251
|
+
if (d?.openai?.rl?.hasData) {
|
|
1252
|
+
updateReset('oai-tok-reset', d.openai.rl.tokensResetEpoch)
|
|
1253
|
+
updateReset('oai-req-reset', d.openai.rl.requestsResetEpoch)
|
|
1254
|
+
}
|
|
1255
|
+
if (d?.openai?.session?.hasData) {
|
|
1256
|
+
const tok = d.openai.session.primary?.resetsAt || 0
|
|
1257
|
+
const req = d.openai.session.secondary?.resetsAt || 0
|
|
1258
|
+
updateReset('oai-tok-reset', tok)
|
|
1259
|
+
updateReset('oai-req-reset', req)
|
|
1260
|
+
}
|
|
1261
|
+
}, 1000)
|
|
1262
|
+
}
|
|
1217
1263
|
|
|
1218
1264
|
// ── Budget logic ─────────────────────────────────────────────────────────────
|
|
1219
1265
|
let dailyBudget = parseInt(localStorage.getItem('squeezr_budget') || '0')
|
package/dist/limits.d.ts
CHANGED
|
@@ -40,6 +40,26 @@ export interface OpenAIBillingState {
|
|
|
40
40
|
softLimitUsd: number;
|
|
41
41
|
lastFetched: number;
|
|
42
42
|
}
|
|
43
|
+
export interface OpenAISessionWindow {
|
|
44
|
+
usedPercent: number;
|
|
45
|
+
resetsAt: number;
|
|
46
|
+
windowDurationMins: number;
|
|
47
|
+
}
|
|
48
|
+
export interface OpenAISessionCredits {
|
|
49
|
+
balance: string;
|
|
50
|
+
hasCredits: boolean;
|
|
51
|
+
unlimited: boolean;
|
|
52
|
+
}
|
|
53
|
+
export interface OpenAISessionRateLimitState {
|
|
54
|
+
limitId: string;
|
|
55
|
+
limitName: string;
|
|
56
|
+
planType: string;
|
|
57
|
+
primary: OpenAISessionWindow | null;
|
|
58
|
+
secondary: OpenAISessionWindow | null;
|
|
59
|
+
credits: OpenAISessionCredits | null;
|
|
60
|
+
lastFetched: number;
|
|
61
|
+
hasData: boolean;
|
|
62
|
+
}
|
|
43
63
|
export interface GeminiErrorState {
|
|
44
64
|
errorCount429: number;
|
|
45
65
|
lastErrorEpoch: number;
|
|
@@ -65,6 +85,7 @@ export declare const openaiUsage: UsageState;
|
|
|
65
85
|
export declare const geminiUsage: UsageState;
|
|
66
86
|
export declare const geminiErrors: GeminiErrorState;
|
|
67
87
|
export declare const openAIBilling: OpenAIBillingState;
|
|
88
|
+
export declare const openAISessionLimits: OpenAISessionRateLimitState;
|
|
68
89
|
export declare function storeKey(cli: 'anthropic' | 'openai', key: string): void;
|
|
69
90
|
export declare function storedKey(cli: 'anthropic' | 'openai'): string;
|
|
70
91
|
export declare function updateAnthropicFromHeaders(headers: Headers): void;
|
|
@@ -75,6 +96,7 @@ export declare function addOpenAIUsage(input: number, output: number): void;
|
|
|
75
96
|
export declare function addGeminiUsage(input: number, output: number): void;
|
|
76
97
|
export declare function makeSseUsageParser(cli: 'anthropic' | 'openai', onUsage: (input: number, output: number) => void): (chunk: string) => void;
|
|
77
98
|
export declare function maybeRefreshOpenAIBilling(apiKey: string): Promise<void>;
|
|
99
|
+
export declare function maybeRefreshOpenAISessionLimits(force?: boolean): Promise<void>;
|
|
78
100
|
export declare function limitsSnapshot(): {
|
|
79
101
|
anthropic: {
|
|
80
102
|
rl: RateLimitState;
|
|
@@ -85,6 +107,7 @@ export declare function limitsSnapshot(): {
|
|
|
85
107
|
rl: RateLimitState;
|
|
86
108
|
usage: UsageState;
|
|
87
109
|
billing: OpenAIBillingState;
|
|
110
|
+
session: OpenAISessionRateLimitState;
|
|
88
111
|
};
|
|
89
112
|
gemini: {
|
|
90
113
|
rl: RateLimitState;
|
package/dist/limits.js
CHANGED
|
@@ -1,17 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* Data sources:
|
|
5
|
-
* - Anthropic: `anthropic-ratelimit-*` headers on EVERY response ✅
|
|
6
|
-
* - OpenAI: `x-ratelimit-*` headers on EVERY response ✅
|
|
7
|
-
* `/v1/dashboard/billing/subscription` + `/v1/dashboard/billing/credit_grants` (polled every 5 min)
|
|
8
|
-
* - Gemini: only available on 429 error responses ⚠️
|
|
9
|
-
*
|
|
10
|
-
* Token usage is accumulated from:
|
|
11
|
-
* - Non-streaming: response body `usage` field
|
|
12
|
-
* - Streaming: parsed SSE events (`message_start`, `message_delta` for Anthropic;
|
|
13
|
-
* final usage chunk for OpenAI when stream_options.include_usage is set)
|
|
14
|
-
*/
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
15
4
|
// ── State singletons ──────────────────────────────────────────────────────────
|
|
16
5
|
function emptyRL() {
|
|
17
6
|
return {
|
|
@@ -44,6 +33,16 @@ export const geminiErrors = { errorCount429: 0, lastErrorEpoch: 0, hasData: fals
|
|
|
44
33
|
export const openAIBilling = {
|
|
45
34
|
creditBalanceUsd: 0, hardLimitUsd: 0, softLimitUsd: 0, lastFetched: 0,
|
|
46
35
|
};
|
|
36
|
+
export const openAISessionLimits = {
|
|
37
|
+
limitId: '',
|
|
38
|
+
limitName: '',
|
|
39
|
+
planType: '',
|
|
40
|
+
primary: null,
|
|
41
|
+
secondary: null,
|
|
42
|
+
credits: null,
|
|
43
|
+
lastFetched: 0,
|
|
44
|
+
hasData: false,
|
|
45
|
+
};
|
|
47
46
|
// Last API key seen per CLI — used for proactive billing fetches
|
|
48
47
|
let lastAnthropicKey = '';
|
|
49
48
|
let lastOpenAIKey = '';
|
|
@@ -58,6 +57,42 @@ export function storeKey(cli, key) {
|
|
|
58
57
|
export function storedKey(cli) {
|
|
59
58
|
return cli === 'anthropic' ? lastAnthropicKey : lastOpenAIKey;
|
|
60
59
|
}
|
|
60
|
+
function normalizeWindow(v) {
|
|
61
|
+
if (!v || typeof v !== 'object')
|
|
62
|
+
return null;
|
|
63
|
+
const w = v;
|
|
64
|
+
const usedPercent = Number(w.usedPercent);
|
|
65
|
+
if (!Number.isFinite(usedPercent))
|
|
66
|
+
return null;
|
|
67
|
+
return {
|
|
68
|
+
usedPercent: Math.max(0, Math.min(100, Math.round(usedPercent))),
|
|
69
|
+
resetsAt: Number(w.resetsAt) || 0,
|
|
70
|
+
windowDurationMins: Number(w.windowDurationMins) || 0,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function normalizeCredits(v) {
|
|
74
|
+
if (!v || typeof v !== 'object')
|
|
75
|
+
return null;
|
|
76
|
+
const c = v;
|
|
77
|
+
return {
|
|
78
|
+
balance: String(c.balance ?? ''),
|
|
79
|
+
hasCredits: Boolean(c.hasCredits),
|
|
80
|
+
unlimited: Boolean(c.unlimited),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function applyOpenAISessionSnapshot(v) {
|
|
84
|
+
if (!v || typeof v !== 'object')
|
|
85
|
+
return;
|
|
86
|
+
const snap = v;
|
|
87
|
+
openAISessionLimits.limitId = String(snap.limitId ?? '');
|
|
88
|
+
openAISessionLimits.limitName = String(snap.limitName ?? '');
|
|
89
|
+
openAISessionLimits.planType = String(snap.planType ?? '');
|
|
90
|
+
openAISessionLimits.primary = normalizeWindow(snap.primary);
|
|
91
|
+
openAISessionLimits.secondary = normalizeWindow(snap.secondary);
|
|
92
|
+
openAISessionLimits.credits = normalizeCredits(snap.credits);
|
|
93
|
+
openAISessionLimits.lastFetched = Date.now();
|
|
94
|
+
openAISessionLimits.hasData = Boolean(openAISessionLimits.primary || openAISessionLimits.secondary);
|
|
95
|
+
}
|
|
61
96
|
// ── Header parsers ────────────────────────────────────────────────────────────
|
|
62
97
|
/** Parses RFC 3339 timestamp from Anthropic reset headers → epoch ms */
|
|
63
98
|
function parseIsoReset(v) {
|
|
@@ -260,10 +295,95 @@ export async function maybeRefreshOpenAIBilling(apiKey) {
|
|
|
260
295
|
catch { /* ignore network errors */ }
|
|
261
296
|
}
|
|
262
297
|
// ── Snapshot for API / SSE ────────────────────────────────────────────────────
|
|
298
|
+
let openAISessionRefreshInFlight = null;
|
|
299
|
+
function codexAppServerCommand() {
|
|
300
|
+
if (process.platform === 'win32') {
|
|
301
|
+
const cmdShim = process.env.APPDATA ? join(process.env.APPDATA, 'npm', 'codex.cmd') : 'codex.cmd';
|
|
302
|
+
const codexCmd = existsSync(cmdShim) ? cmdShim : 'codex.cmd';
|
|
303
|
+
return {
|
|
304
|
+
cmd: process.env.ComSpec || 'cmd.exe',
|
|
305
|
+
args: ['/d', '/s', '/c', codexCmd, 'app-server', '--listen', 'stdio://'],
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
return { cmd: 'codex', args: ['app-server', '--listen', 'stdio://'] };
|
|
309
|
+
}
|
|
310
|
+
export async function maybeRefreshOpenAISessionLimits(force = false) {
|
|
311
|
+
if (!force && openAISessionLimits.lastFetched > 0 && Date.now() - openAISessionLimits.lastFetched < 60_000)
|
|
312
|
+
return;
|
|
313
|
+
if (openAISessionRefreshInFlight)
|
|
314
|
+
return openAISessionRefreshInFlight;
|
|
315
|
+
openAISessionRefreshInFlight = new Promise((resolve) => {
|
|
316
|
+
const { cmd, args } = codexAppServerCommand();
|
|
317
|
+
const child = spawn(cmd, args, {
|
|
318
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
319
|
+
windowsHide: true,
|
|
320
|
+
});
|
|
321
|
+
let stdoutBuf = '';
|
|
322
|
+
let finished = false;
|
|
323
|
+
const finish = () => {
|
|
324
|
+
if (finished)
|
|
325
|
+
return;
|
|
326
|
+
finished = true;
|
|
327
|
+
try {
|
|
328
|
+
child.kill();
|
|
329
|
+
}
|
|
330
|
+
catch { /* ignore */ }
|
|
331
|
+
openAISessionRefreshInFlight = null;
|
|
332
|
+
resolve();
|
|
333
|
+
};
|
|
334
|
+
const timer = setTimeout(finish, 4000);
|
|
335
|
+
const send = (msg) => {
|
|
336
|
+
try {
|
|
337
|
+
child.stdin.write(JSON.stringify(msg) + '\n');
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
finish();
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
child.on('error', finish);
|
|
344
|
+
child.on('exit', () => {
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
finish();
|
|
347
|
+
});
|
|
348
|
+
child.stderr.on('data', () => { });
|
|
349
|
+
child.stdout.on('data', (chunk) => {
|
|
350
|
+
stdoutBuf += chunk.toString('utf-8');
|
|
351
|
+
const lines = stdoutBuf.split(/\r?\n/);
|
|
352
|
+
stdoutBuf = lines.pop() ?? '';
|
|
353
|
+
for (const line of lines) {
|
|
354
|
+
const trimmed = line.trim();
|
|
355
|
+
if (!trimmed.startsWith('{'))
|
|
356
|
+
continue;
|
|
357
|
+
try {
|
|
358
|
+
const msg = JSON.parse(trimmed);
|
|
359
|
+
if (msg.id === 1) {
|
|
360
|
+
send({ jsonrpc: '2.0', id: 2, method: 'account/rateLimits/read', params: null });
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (msg.id === 2 && msg.result) {
|
|
364
|
+
const buckets = msg.result.rateLimitsByLimitId;
|
|
365
|
+
applyOpenAISessionSnapshot(buckets?.codex ?? msg.result.rateLimits);
|
|
366
|
+
clearTimeout(timer);
|
|
367
|
+
finish();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch { /* ignore malformed lines */ }
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
send({
|
|
375
|
+
jsonrpc: '2.0',
|
|
376
|
+
id: 1,
|
|
377
|
+
method: 'initialize',
|
|
378
|
+
params: { clientInfo: { name: 'squeezr', version: '1.0.0' } },
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
return openAISessionRefreshInFlight;
|
|
382
|
+
}
|
|
263
383
|
export function limitsSnapshot() {
|
|
264
384
|
return {
|
|
265
385
|
anthropic: { rl: anthropicRL, usage: anthropicUsage, unified: anthropicUnified },
|
|
266
|
-
openai: { rl: openaiRL, usage: openaiUsage, billing: openAIBilling },
|
|
386
|
+
openai: { rl: openaiRL, usage: openaiUsage, billing: openAIBilling, session: openAISessionLimits },
|
|
267
387
|
gemini: { rl: geminiRL, usage: geminiUsage, errors: geminiErrors },
|
|
268
388
|
};
|
|
269
389
|
}
|
package/dist/server.js
CHANGED
|
@@ -14,7 +14,7 @@ import { sessionCacheSize } from './sessionCache.js';
|
|
|
14
14
|
import { detPatternHits } from './deterministic.js';
|
|
15
15
|
import { VERSION } from './version.js';
|
|
16
16
|
import { recordRequest, getCurrentSession, getProjectAggregates, getAllSessionsForHistory, } from './history.js';
|
|
17
|
-
import { updateAnthropicFromHeaders, updateOpenAIFromHeaders, updateGeminiFrom429, addAnthropicUsage, addOpenAIUsage, addGeminiUsage, makeSseUsageParser, maybeRefreshOpenAIBilling, storeKey, limitsSnapshot, } from './limits.js';
|
|
17
|
+
import { updateAnthropicFromHeaders, updateOpenAIFromHeaders, updateGeminiFrom429, addAnthropicUsage, addOpenAIUsage, addGeminiUsage, makeSseUsageParser, maybeRefreshOpenAIBilling, maybeRefreshOpenAISessionLimits, storeKey, limitsSnapshot, } from './limits.js';
|
|
18
18
|
// ── Project name extraction ────────────────────────────────────────────────────
|
|
19
19
|
// Manual project override — set via /squeezr/project endpoint or MCP tool
|
|
20
20
|
let manualProject = null;
|
|
@@ -400,7 +400,8 @@ app.post('/v1beta/models/*', async (c) => {
|
|
|
400
400
|
return c.body(geminiRespBuf, resp.status, respHeaders);
|
|
401
401
|
});
|
|
402
402
|
// ── Squeezr internal endpoints ────────────────────────────────────────────────
|
|
403
|
-
function buildStatsPayload() {
|
|
403
|
+
async function buildStatsPayload() {
|
|
404
|
+
await maybeRefreshOpenAISessionLimits().catch(() => { });
|
|
404
405
|
return {
|
|
405
406
|
...stats.summary(),
|
|
406
407
|
cache: getCache(config).stats(),
|
|
@@ -415,7 +416,7 @@ function buildStatsPayload() {
|
|
|
415
416
|
};
|
|
416
417
|
}
|
|
417
418
|
app.get('/squeezr/stats', (c) => {
|
|
418
|
-
return c.json(
|
|
419
|
+
return buildStatsPayload().then(d => c.json(d));
|
|
419
420
|
});
|
|
420
421
|
app.get('/squeezr/health', (c) => {
|
|
421
422
|
return c.json({ status: 'ok', version: VERSION });
|
|
@@ -450,11 +451,11 @@ app.get('/squeezr/dashboard', (c) => {
|
|
|
450
451
|
});
|
|
451
452
|
app.get('/squeezr/events', (c) => {
|
|
452
453
|
return streamSSE(c, async (s) => {
|
|
453
|
-
await s.writeSSE({ data: JSON.stringify(buildStatsPayload()) });
|
|
454
|
+
await s.writeSSE({ data: JSON.stringify(await buildStatsPayload()) });
|
|
454
455
|
while (true) {
|
|
455
456
|
await s.sleep(2000);
|
|
456
457
|
try {
|
|
457
|
-
await s.writeSSE({ data: JSON.stringify(buildStatsPayload()) });
|
|
458
|
+
await s.writeSSE({ data: JSON.stringify(await buildStatsPayload()) });
|
|
458
459
|
}
|
|
459
460
|
catch {
|
|
460
461
|
break;
|
|
@@ -462,7 +463,8 @@ app.get('/squeezr/events', (c) => {
|
|
|
462
463
|
}
|
|
463
464
|
});
|
|
464
465
|
});
|
|
465
|
-
app.get('/squeezr/limits', (c) => {
|
|
466
|
+
app.get('/squeezr/limits', async (c) => {
|
|
467
|
+
await maybeRefreshOpenAISessionLimits().catch(() => { });
|
|
466
468
|
return c.json(limitsSnapshot());
|
|
467
469
|
});
|
|
468
470
|
// ── History + Projects endpoints ──────────────────────────────────────────────
|
package/package.json
CHANGED