squeezr-ai 1.80.15 → 1.80.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard.d.ts +1 -1
- package/dist/dashboard.js +56 -17
- package/package.json +69 -69
package/dist/dashboard.d.ts
CHANGED
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export declare const LOGO_PATH_D = "M354.982 369.122C349.882 371.592 338.752 371.792 330.442 369.562C314.752 365.342 292.762 350.502 274.462 331.772L269.022 326.202L268.322 308.932C267.942 299.432 267.332 289.862 266.972 287.662C266.612 285.462 265.842 280.742 265.252 277.162C261.922 256.872 253.782 233.162 245.022 218.222C241.322 211.902 240.442 208.162 242.662 208.162C246.992 208.162 272.062 220.332 283.912 228.172C307.882 244.042 340.042 276.312 356.142 300.642C361.992 309.492 368.862 323.942 370.862 331.632C372.842 339.222 372.822 343.952 370.782 350.952C368.862 357.572 361.602 365.922 354.982 369.122ZM218.282 179.832C214.632 182.212 211.352 184.162 210.992 184.162C209.782 184.162 209.192 181.162 209.872 178.472C211.522 171.892 223.622 148.592 229.912 139.892C238.462 128.072 255.812 107.752 262.572 101.652C289.962 76.9417 301.752 68.0317 317.642 60.0417C337.182 50.2217 355.782 51.3217 365.342 62.8817C368.722 66.9617 372.412 77.3217 372.412 82.7217C372.412 92.2417 366.082 109.302 358.222 120.942C352.882 128.862 338.112 146.372 331.782 152.282L327.912 155.902L306.412 157.012C275.532 158.602 257.232 162.282 234.992 171.372C229.442 173.642 221.922 177.442 218.282 179.832ZM192.352 192.912C191.862 194.152 190.962 195.162 190.372 195.162C188.672 195.162 180.862 177.712 177.542 166.472C172.022 147.832 170.142 131.892 170.112 103.662C170.072 54.9617 176.632 25.5317 190.962 10.2117C203.612 -3.30832 223.802 -3.41833 235.432 9.97167C246.502 22.7117 252.932 45.4017 254.122 75.8417L254.712 91.0217L243.802 102.342C216.642 130.552 201.082 156.292 194.952 183.172C194.012 187.292 192.842 191.672 192.352 192.912ZM226.572 421.482C220.292 424.892 210.782 425.902 205.022 423.752C191.282 418.632 180.692 404.292 175.412 383.662C172.812 373.502 170.052 347.602 170.692 339.372L171.192 333.072L181.082 322.872C198.422 304.992 208.702 290.782 219.092 270.362C225.192 258.372 231.412 241.452 231.412 236.842C231.412 233.222 233.922 230.432 236.032 231.722C240.442 234.432 249.472 263.122 253.062 285.842C255.302 300.022 255.592 348.712 253.532 363.662C249.272 394.512 240.142 414.092 226.572 421.482ZM182.052 209.772C186.532 217.502 181.062 217.082 163.912 208.392C143.982 198.302 124.292 183.882 105.542 165.662C69.9124 131.042 51.3724 100.292 53.7824 79.7817C54.5824 72.9217 59.4624 62.9817 63.5724 59.8517C83.1924 44.8917 111.392 54.5117 145.162 87.6917L156.412 98.7517L156.422 107.702C156.442 128.452 159.472 151.202 164.592 169.092C169.372 185.812 172.862 193.952 182.052 209.772ZM96.0524 369.042C86.6124 371.962 77.4224 371.862 70.9124 368.772C60.5924 363.872 53.4124 352.582 53.4124 341.252C53.4124 325.142 66.4924 301.572 87.9324 279.062C93.8424 272.852 96.3824 270.182 99.4924 269.032C101.842 268.152 104.522 268.152 109.242 268.152C131.112 268.132 157.482 264.312 174.912 258.642C183.912 255.722 201.562 247.552 208.502 243.102C211.022 241.482 213.612 240.162 214.252 240.162C216.572 240.162 215.322 246.362 211.052 256.062C201.052 278.772 190.362 293.692 165.912 319.032C140.952 344.912 115.702 362.982 96.0524 369.042ZM368.762 251.622C362.292 252.472 351.732 253.162 345.312 253.162H333.622L328.262 247.552C314.672 233.322 289.892 214.982 271.112 205.242C261.472 200.242 244.592 193.972 237.082 192.602C232.942 191.852 231.502 189.962 233.362 187.722C235.062 185.672 250.402 179.462 259.532 177.132C280.552 171.762 296.062 170.162 326.772 170.172C369.092 170.192 394.642 174.902 410.892 185.692C419.312 191.282 423.272 197.242 425.392 207.512C426.482 212.822 426.382 214.032 424.312 220.512C422.492 226.212 420.942 228.802 416.682 233.292C413.742 236.392 408.742 240.302 405.572 241.992C398.302 245.872 383.912 249.632 368.762 251.622ZM146.412 251.272C136.432 253.162 130.742 253.482 103.412 253.742C80.9524 253.952 69.0424 253.652 61.9124 252.682C28.4924 248.142 11.0524 240.212 3.39238 226.092C0.252382 220.292 -0.0776191 218.932 0.0123809 212.162C0.142381 202.882 1.92238 198.622 8.60238 191.562C20.8324 178.622 39.5124 173.102 76.4624 171.502L91.0124 170.862L104.492 182.762C125.782 201.552 128.872 203.902 142.912 211.932C160.322 221.882 182.112 231.162 188.092 231.162C189.152 231.162 190.902 231.842 191.972 232.662C193.862 234.132 193.832 234.242 190.912 236.652C184.902 241.622 167.932 247.202 146.412 251.272Z";
|
|
7
7
|
export declare const LOGO_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 427 425\"><path d=\"M354.982 369.122C349.882 371.592 338.752 371.792 330.442 369.562C314.752 365.342 292.762 350.502 274.462 331.772L269.022 326.202L268.322 308.932C267.942 299.432 267.332 289.862 266.972 287.662C266.612 285.462 265.842 280.742 265.252 277.162C261.922 256.872 253.782 233.162 245.022 218.222C241.322 211.902 240.442 208.162 242.662 208.162C246.992 208.162 272.062 220.332 283.912 228.172C307.882 244.042 340.042 276.312 356.142 300.642C361.992 309.492 368.862 323.942 370.862 331.632C372.842 339.222 372.822 343.952 370.782 350.952C368.862 357.572 361.602 365.922 354.982 369.122ZM218.282 179.832C214.632 182.212 211.352 184.162 210.992 184.162C209.782 184.162 209.192 181.162 209.872 178.472C211.522 171.892 223.622 148.592 229.912 139.892C238.462 128.072 255.812 107.752 262.572 101.652C289.962 76.9417 301.752 68.0317 317.642 60.0417C337.182 50.2217 355.782 51.3217 365.342 62.8817C368.722 66.9617 372.412 77.3217 372.412 82.7217C372.412 92.2417 366.082 109.302 358.222 120.942C352.882 128.862 338.112 146.372 331.782 152.282L327.912 155.902L306.412 157.012C275.532 158.602 257.232 162.282 234.992 171.372C229.442 173.642 221.922 177.442 218.282 179.832ZM192.352 192.912C191.862 194.152 190.962 195.162 190.372 195.162C188.672 195.162 180.862 177.712 177.542 166.472C172.022 147.832 170.142 131.892 170.112 103.662C170.072 54.9617 176.632 25.5317 190.962 10.2117C203.612 -3.30832 223.802 -3.41833 235.432 9.97167C246.502 22.7117 252.932 45.4017 254.122 75.8417L254.712 91.0217L243.802 102.342C216.642 130.552 201.082 156.292 194.952 183.172C194.012 187.292 192.842 191.672 192.352 192.912ZM226.572 421.482C220.292 424.892 210.782 425.902 205.022 423.752C191.282 418.632 180.692 404.292 175.412 383.662C172.812 373.502 170.052 347.602 170.692 339.372L171.192 333.072L181.082 322.872C198.422 304.992 208.702 290.782 219.092 270.362C225.192 258.372 231.412 241.452 231.412 236.842C231.412 233.222 233.922 230.432 236.032 231.722C240.442 234.432 249.472 263.122 253.062 285.842C255.302 300.022 255.592 348.712 253.532 363.662C249.272 394.512 240.142 414.092 226.572 421.482ZM182.052 209.772C186.532 217.502 181.062 217.082 163.912 208.392C143.982 198.302 124.292 183.882 105.542 165.662C69.9124 131.042 51.3724 100.292 53.7824 79.7817C54.5824 72.9217 59.4624 62.9817 63.5724 59.8517C83.1924 44.8917 111.392 54.5117 145.162 87.6917L156.412 98.7517L156.422 107.702C156.442 128.452 159.472 151.202 164.592 169.092C169.372 185.812 172.862 193.952 182.052 209.772ZM96.0524 369.042C86.6124 371.962 77.4224 371.862 70.9124 368.772C60.5924 363.872 53.4124 352.582 53.4124 341.252C53.4124 325.142 66.4924 301.572 87.9324 279.062C93.8424 272.852 96.3824 270.182 99.4924 269.032C101.842 268.152 104.522 268.152 109.242 268.152C131.112 268.132 157.482 264.312 174.912 258.642C183.912 255.722 201.562 247.552 208.502 243.102C211.022 241.482 213.612 240.162 214.252 240.162C216.572 240.162 215.322 246.362 211.052 256.062C201.052 278.772 190.362 293.692 165.912 319.032C140.952 344.912 115.702 362.982 96.0524 369.042ZM368.762 251.622C362.292 252.472 351.732 253.162 345.312 253.162H333.622L328.262 247.552C314.672 233.322 289.892 214.982 271.112 205.242C261.472 200.242 244.592 193.972 237.082 192.602C232.942 191.852 231.502 189.962 233.362 187.722C235.062 185.672 250.402 179.462 259.532 177.132C280.552 171.762 296.062 170.162 326.772 170.172C369.092 170.192 394.642 174.902 410.892 185.692C419.312 191.282 423.272 197.242 425.392 207.512C426.482 212.822 426.382 214.032 424.312 220.512C422.492 226.212 420.942 228.802 416.682 233.292C413.742 236.392 408.742 240.302 405.572 241.992C398.302 245.872 383.912 249.632 368.762 251.622ZM146.412 251.272C136.432 253.162 130.742 253.482 103.412 253.742C80.9524 253.952 69.0424 253.652 61.9124 252.682C28.4924 248.142 11.0524 240.212 3.39238 226.092C0.252382 220.292 -0.0776191 218.932 0.0123809 212.162C0.142381 202.882 1.92238 198.622 8.60238 191.562C20.8324 178.622 39.5124 173.102 76.4624 171.502L91.0124 170.862L104.492 182.762C125.782 201.552 128.872 203.902 142.912 211.932C160.322 221.882 182.112 231.162 188.092 231.162C189.152 231.162 190.902 231.842 191.972 232.662C193.862 234.132 193.832 234.242 190.912 236.652C184.902 241.622 167.932 247.202 146.412 251.272Z\" fill=\"#e6e6e6\"/></svg>";
|
|
8
|
-
export declare const DASHBOARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/squeezr/favicon.svg\">\n<title>Squeezr</title>\n<style>\n*{box-sizing:border-box;margin:0;padding:0}\n\n:root{\n --bg:#0a0a0a;\n --surface:#111111;\n --surface2:#161616;\n --surface3:#1c1c1c;\n --border:#222222;\n --border2:#2e2e2e;\n --text:#f0f0f0;\n --text2:#a0a0a0;\n --text3:#606060;\n --brand:#16a34a;\n --brand2:#4ade80;\n --brand-dim:rgba(22,163,74,.12);\n --brand-dim2:rgba(22,163,74,.06);\n --red:#f87171;\n --yellow:#fbbf24;\n --blue:#60a5fa;\n --shadow:0 1px 3px rgba(0,0,0,.5),0 4px 16px rgba(0,0,0,.3);\n}\nhtml:not(.dark){\n --bg:#f5f5f5;\n --surface:#ffffff;\n --surface2:#fafafa;\n --surface3:#f0f0f0;\n --border:#e0e0e0;\n --border2:#d0d0d0;\n --text:#111111;\n --text2:#555555;\n --text3:#999999;\n --brand-dim:rgba(22,163,74,.08);\n --brand-dim2:rgba(22,163,74,.04);\n --shadow:0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06);\n}\n\nhtml,body{\n height:100%;\n background:var(--bg);\n color:var(--text);\n font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Helvetica Neue',sans-serif;\n font-size:14px;line-height:1.5;\n transition:background .2s,color .2s;\n -webkit-font-smoothing:antialiased;\n}\ncode{font-family:'Cascadia Code','SF Mono',Consolas,monospace;font-size:.9em}\n\n/* \u2500\u2500 Layout \u2500\u2500 */\n#app{display:flex;flex-direction:column;height:100vh;overflow:hidden}\n\n/* \u2500\u2500 Top navbar \u2500\u2500 */\n#navbar{\n flex-shrink:0;\n height:52px;\n background:var(--surface);\n border-bottom:1px solid var(--border);\n display:flex;align-items:center;\n padding:0 24px;gap:0;\n transition:background .2s,border-color .2s\n}\n\n/* Logo */\n.nb-brand{\n display:flex;align-items:center;gap:9px;\n margin-right:24px;flex-shrink:0\n}\n.nb-brand svg{width:24px;height:24px;color:var(--brand)}\n.nb-brand-name{font-size:15px;font-weight:700;letter-spacing:-.3px;color:var(--text)}\n.nb-brand-ver{font-size:11px;color:var(--text3);margin-left:6px;margin-top:1px}\n\n/* Divider */\n.nb-sep{width:1px;height:22px;background:var(--border2);margin-right:20px;flex-shrink:0}\n\n/* Tabs */\n.nb-tabs{display:flex;align-items:stretch;gap:2px;height:100%}\n.nb-tab{\n display:flex;align-items:center;gap:7px;\n padding:0 16px;\n font-size:13px;font-weight:500;color:var(--text2);\n cursor:pointer;user-select:none;\n border-bottom:2px solid transparent;\n transition:color .12s,border-color .12s;\n white-space:nowrap\n}\n.nb-tab:hover{color:var(--text)}\n.nb-tab.active{color:var(--brand);border-bottom-color:var(--brand)}\n.nb-tab svg{width:14px;height:14px;flex-shrink:0;stroke-width:2}\n\n/* Right side */\n.nb-right{\n margin-left:auto;display:flex;align-items:center;gap:10px\n}\n.conn-dot{\n width:7px;height:7px;border-radius:50%;background:var(--text3);flex-shrink:0;\n transition:background .3s\n}\n.conn-dot.online{background:var(--brand);box-shadow:0 0 6px var(--brand)}\n.conn-dot.offline{background:var(--red)}\n.conn-label{font-size:12px;color:var(--text3)}\n\n.theme-btn{\n display:flex;align-items:center;justify-content:center;\n width:32px;height:32px;border-radius:8px;\n background:none;border:1px solid var(--border2);cursor:pointer;\n color:var(--text2);transition:background .12s,color .12s\n}\n.theme-btn:hover{background:var(--surface3);color:var(--text)}\n\n/* \u2500\u2500 Main \u2500\u2500 */\n#main{\n flex:1;overflow-y:auto;padding:28px 32px;\n background:var(--bg)\n}\n@media(max-width:600px){\n .nb-brand-ver{display:none}\n #main{padding:16px 14px}\n}\n\n.page-header{margin-bottom:24px}\n.page-title{font-size:22px;font-weight:700;letter-spacing:-.4px;color:var(--text)}\n.page-sub{font-size:13px;color:var(--text3);margin-top:3px}\n\n/* \u2500\u2500 Hero cards \u2500\u2500 */\n.hero-grid{\n display:grid;\n grid-template-columns:repeat(auto-fit,minmax(170px,1fr));\n gap:12px;margin-bottom:20px\n}\n.hero-card{\n background:var(--surface);\n border:1px solid var(--border);\n border-radius:12px;padding:18px 20px;\n box-shadow:var(--shadow);\n transition:border-color .15s\n}\n.hero-card:hover{border-color:var(--border2)}\n.hero-card.accent{\n background:var(--brand-dim);\n border-color:rgba(22,163,74,.25)\n}\n.hc-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--text3);margin-bottom:8px}\n.hc-val{font-size:30px;font-weight:800;letter-spacing:-.5px;color:var(--text);line-height:1}\n.hero-card.accent .hc-val{color:var(--brand2)}\n.hc-sub{font-size:11px;color:var(--text3);margin-top:6px}\n\n/* \u2500\u2500 Sections \u2500\u2500 */\n.section{\n background:var(--surface);border:1px solid var(--border);\n border-radius:12px;margin-bottom:16px;overflow:hidden;\n box-shadow:var(--shadow)\n}\n.section-head{\n padding:14px 20px;border-bottom:1px solid var(--border);\n display:flex;align-items:center;justify-content:space-between\n}\n.section-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--text3)}\n.section-body{padding:16px 20px}\n\n/* \u2500\u2500 Tools bars \u2500\u2500 */\n.tool-row{display:flex;align-items:center;gap:12px;margin-bottom:10px}\n.tool-row:last-child{margin-bottom:0}\n.tool-name{font-size:13px;color:var(--text2);width:90px;flex-shrink:0;font-weight:500}\n.tool-track{flex:1;height:6px;background:var(--surface3);border-radius:3px;overflow:hidden}\n.tool-fill{height:100%;background:var(--brand);border-radius:3px;transition:width .4s}\n.tool-count{font-size:12px;color:var(--text3);width:50px;text-align:right;font-variant-numeric:tabular-nums}\n\n/* \u2500\u2500 Latency pills \u2500\u2500 */\n.lat-row{display:flex;gap:10px;flex-wrap:wrap}\n.lat-pill{\n flex:1;min-width:80px;\n background:var(--surface2);border:1px solid var(--border2);\n border-radius:10px;padding:12px 16px;text-align:center\n}\n.lat-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);display:block;margin-bottom:4px}\n.lat-val{font-size:22px;font-weight:700;color:var(--text)}\n.lat-unit{font-size:11px;color:var(--text3)}\n\n/* \u2500\u2500 Cache row \u2500\u2500 */\n.cache-row{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}\n.cache-card{\n background:var(--surface2);border:1px solid var(--border2);\n border-radius:10px;padding:14px;text-align:center\n}\n.cache-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:6px}\n.cache-val{font-size:22px;font-weight:700;color:var(--text)}\n\n/* \u2500\u2500 Limits \u2500\u2500 */\n.limits-grid{display:flex;flex-direction:column;gap:10px}\n.lim-row{display:flex;align-items:center;gap:14px}\n.lim-name{font-size:12px;color:var(--text2);width:100px;flex-shrink:0;font-weight:500}\n.lim-track{flex:1;height:8px;background:var(--surface3);border-radius:4px;overflow:hidden}\n.lim-fill{height:100%;border-radius:4px;transition:width .5s,background .3s}\n.lim-fill.ok{background:var(--brand)}\n.lim-fill.warn{background:var(--yellow)}\n.lim-fill.crit{background:var(--red)}\n.lim-text{font-size:12px;color:var(--text3);width:90px;text-align:right;font-variant-numeric:tabular-nums}\n.lim-nodata{font-size:13px;color:var(--text3);padding:8px 0}\n\n/* \u2500\u2500 Rate Limits + Live Log row \u2500\u2500 */\n.rl-row{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}\n@media (max-width:760px){.rl-row{grid-template-columns:1fr}}\n\n/* \u2500\u2500 Live Log feed (newest at the BOTTOM; older scroll up and out) \u2500\u2500 */\n#livelog-body{height:236px;overflow:hidden;padding:12px 14px;scroll-behavior:smooth}\n.ll-list{display:flex;flex-direction:column;gap:5px}\n.ll-empty{font-size:13px;color:var(--text3);padding:8px 0}\n.ll-row{\n display:flex;align-items:baseline;gap:8px;\n font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.45;\n padding:5px 9px;border-radius:7px;background:var(--surface2);border:1px solid var(--border2);\n white-space:nowrap;overflow:hidden;\n}\n.ll-row.ll-new{animation:llRise .45s cubic-bezier(.22,1,.36,1)}\n@keyframes llRise{\n 0%{opacity:0;transform:translateY(14px)}\n 100%{opacity:1;transform:translateY(0)}\n}\n.ll-tag{font-weight:700;flex-shrink:0}\n.ll-tag.det{color:#60a5fa}\n.ll-tag.dedup{color:#c084fc}\n.ll-tag.ai{color:var(--brand2)}\n.ll-tag.tooldesc{color:#fbbf24}\n.ll-tag.other{color:var(--text3)}\n.ll-msg{color:var(--text2);overflow:hidden;text-overflow:ellipsis}\n.ll-msg .num{color:var(--brand2);font-weight:700}\n.ll-time{font-size:10.5px;color:var(--text3);flex-shrink:0;margin-left:auto}\n\n/* \u2500\u2500 Mode controls \u2500\u2500 */\n.controls-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}\n.mode-btn{\n padding:7px 16px;border-radius:8px;\n border:1px solid var(--border2);background:var(--surface2);\n color:var(--text2);font-size:12px;font-weight:600;font-family:inherit;\n cursor:pointer;transition:all .12s\n}\n.mode-btn:hover{border-color:var(--brand);color:var(--brand)}\n.mode-btn.active{background:var(--brand-dim);border-color:rgba(22,163,74,.4);color:var(--brand2)}\n.mode-btn.active-off{background:rgba(248,113,113,.1);border-color:rgba(248,113,113,.3);color:var(--red)}\n.divider-v{width:1px;height:24px;background:var(--border2)}\n.bypass-btn{\n padding:7px 16px;border-radius:8px;\n border:1px solid var(--border2);background:var(--surface2);\n color:var(--text2);font-size:12px;font-weight:600;font-family:inherit;\n cursor:pointer;transition:all .12s\n}\n.bypass-btn:hover{border-color:var(--yellow);color:var(--yellow)}\n.bypass-btn.active{background:rgba(251,191,36,.08);border-color:rgba(251,191,36,.3);color:var(--yellow)}\n\n/* \u2500\u2500 Status badges \u2500\u2500 */\n.badge-row{display:flex;gap:8px;margin-bottom:14px}\n.badge{\n font-size:11px;font-weight:600;padding:3px 10px;border-radius:20px;\n border:1px solid var(--border2);color:var(--text3);background:var(--surface2)\n}\n.badge.green{background:var(--brand-dim);border-color:rgba(22,163,74,.3);color:var(--brand2)}\n.badge.yellow{background:rgba(251,191,36,.08);border-color:rgba(251,191,36,.25);color:var(--yellow)}\n.badge.red{background:rgba(248,113,113,.08);border-color:rgba(248,113,113,.25);color:var(--red)}\n\n/* \u2500\u2500 Settings \u2500\u2500 */\n.settings-block{\n background:var(--surface);border:1px solid var(--border);\n border-radius:12px;overflow:hidden;margin-bottom:16px;\n box-shadow:var(--shadow)\n}\n.settings-head{\n padding:12px 20px;border-bottom:1px solid var(--border);\n font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;\n color:var(--text3);background:var(--surface2)\n}\n.settings-row{\n display:flex;align-items:center;justify-content:space-between;\n padding:13px 20px;border-bottom:1px solid var(--border);gap:16px\n}\n.settings-row:last-child{border-bottom:none}\n.s-key{font-size:13px;color:var(--text2);font-weight:500}\n.s-val{font-size:13px;color:var(--text);font-family:'Cascadia Code','SF Mono',Consolas,monospace}\n.s-val code{\n background:var(--surface3);padding:2px 8px;border-radius:5px;\n border:1px solid var(--border2);color:var(--text)\n}\n\n/* \u2500\u2500 Action buttons \u2500\u2500 */\n.action-btn{padding:7px 18px;border-radius:8px;border:1px solid var(--border2);background:var(--surface2);color:var(--text);font-size:13px;font-family:inherit;cursor:pointer;font-weight:500;transition:all .12s}\n.action-btn:hover{border-color:var(--brand);color:var(--brand)}\n.action-btn.danger{border-color:rgba(248,113,113,.3);color:var(--red)}\n.action-btn.danger:hover{background:rgba(248,113,113,.08)}\n.action-result{margin-top:10px;font-size:12px;padding:8px 12px;border-radius:6px;display:none}\n.action-result.ok{background:rgba(22,163,74,.08);color:#4ade80;border:1px solid rgba(22,163,74,.2)}\n.action-result.err{background:rgba(248,113,113,.08);color:#f87171;border:1px solid rgba(248,113,113,.2)}\n\n/* \u2500\u2500 CLI chips \u2500\u2500 */\n.chips{display:flex;flex-wrap:wrap;gap:7px;padding:16px 20px}\n.chip{\n display:flex;align-items:center;gap:5px;\n padding:5px 12px;border-radius:20px;\n background:var(--surface2);border:1px solid var(--border2);\n font-size:12px;color:var(--text2);font-weight:500\n}\n.chip-dot{width:5px;height:5px;border-radius:50%;background:var(--brand);flex-shrink:0}\n\n/* \u2500\u2500 Chart \u2500\u2500 */\n.chart-bar rect.bar{transition:opacity .15s,filter .15s}\n.chart-bar:hover rect.bar{opacity:.85;filter:brightness(1.15)}\n.chart-wrap{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:16px 12px 8px}\n\n/* \u2500\u2500 Skeleton \u2500\u2500 */\n.sk{\n background:linear-gradient(90deg,var(--surface2) 25%,var(--surface3) 50%,var(--surface2) 75%);\n background-size:200% 100%;animation:sk 1.4s infinite;border-radius:6px\n}\n@keyframes sk{0%{background-position:200% 0}100%{background-position:-200% 0}}\n\n::-webkit-scrollbar{width:5px;height:5px}\n::-webkit-scrollbar-track{background:transparent}\n::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}\n</style>\n</head>\n<body>\n<div id=\"app\">\n\n <!-- Top navbar -->\n <header id=\"navbar\">\n <div class=\"nb-brand\">\n <svg viewBox=\"0 0 427 425\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" aria-hidden=\"true\">\n <path d=\"M354.982 369.122C349.882 371.592 338.752 371.792 330.442 369.562C314.752 365.342 292.762 350.502 274.462 331.772L269.022 326.202L268.322 308.932C267.942 299.432 267.332 289.862 266.972 287.662C266.612 285.462 265.842 280.742 265.252 277.162C261.922 256.872 253.782 233.162 245.022 218.222C241.322 211.902 240.442 208.162 242.662 208.162C246.992 208.162 272.062 220.332 283.912 228.172C307.882 244.042 340.042 276.312 356.142 300.642C361.992 309.492 368.862 323.942 370.862 331.632C372.842 339.222 372.822 343.952 370.782 350.952C368.862 357.572 361.602 365.922 354.982 369.122ZM218.282 179.832C214.632 182.212 211.352 184.162 210.992 184.162C209.782 184.162 209.192 181.162 209.872 178.472C211.522 171.892 223.622 148.592 229.912 139.892C238.462 128.072 255.812 107.752 262.572 101.652C289.962 76.9417 301.752 68.0317 317.642 60.0417C337.182 50.2217 355.782 51.3217 365.342 62.8817C368.722 66.9617 372.412 77.3217 372.412 82.7217C372.412 92.2417 366.082 109.302 358.222 120.942C352.882 128.862 338.112 146.372 331.782 152.282L327.912 155.902L306.412 157.012C275.532 158.602 257.232 162.282 234.992 171.372C229.442 173.642 221.922 177.442 218.282 179.832ZM192.352 192.912C191.862 194.152 190.962 195.162 190.372 195.162C188.672 195.162 180.862 177.712 177.542 166.472C172.022 147.832 170.142 131.892 170.112 103.662C170.072 54.9617 176.632 25.5317 190.962 10.2117C203.612 -3.30832 223.802 -3.41833 235.432 9.97167C246.502 22.7117 252.932 45.4017 254.122 75.8417L254.712 91.0217L243.802 102.342C216.642 130.552 201.082 156.292 194.952 183.172C194.012 187.292 192.842 191.672 192.352 192.912ZM226.572 421.482C220.292 424.892 210.782 425.902 205.022 423.752C191.282 418.632 180.692 404.292 175.412 383.662C172.812 373.502 170.052 347.602 170.692 339.372L171.192 333.072L181.082 322.872C198.422 304.992 208.702 290.782 219.092 270.362C225.192 258.372 231.412 241.452 231.412 236.842C231.412 233.222 233.922 230.432 236.032 231.722C240.442 234.432 249.472 263.122 253.062 285.842C255.302 300.022 255.592 348.712 253.532 363.662C249.272 394.512 240.142 414.092 226.572 421.482ZM182.052 209.772C186.532 217.502 181.062 217.082 163.912 208.392C143.982 198.302 124.292 183.882 105.542 165.662C69.9124 131.042 51.3724 100.292 53.7824 79.7817C54.5824 72.9217 59.4624 62.9817 63.5724 59.8517C83.1924 44.8917 111.392 54.5117 145.162 87.6917L156.412 98.7517L156.422 107.702C156.442 128.452 159.472 151.202 164.592 169.092C169.372 185.812 172.862 193.952 182.052 209.772ZM96.0524 369.042C86.6124 371.962 77.4224 371.862 70.9124 368.772C60.5924 363.872 53.4124 352.582 53.4124 341.252C53.4124 325.142 66.4924 301.572 87.9324 279.062C93.8424 272.852 96.3824 270.182 99.4924 269.032C101.842 268.152 104.522 268.152 109.242 268.152C131.112 268.132 157.482 264.312 174.912 258.642C183.912 255.722 201.562 247.552 208.502 243.102C211.022 241.482 213.612 240.162 214.252 240.162C216.572 240.162 215.322 246.362 211.052 256.062C201.052 278.772 190.362 293.692 165.912 319.032C140.952 344.912 115.702 362.982 96.0524 369.042ZM368.762 251.622C362.292 252.472 351.732 253.162 345.312 253.162H333.622L328.262 247.552C314.672 233.322 289.892 214.982 271.112 205.242C261.472 200.242 244.592 193.972 237.082 192.602C232.942 191.852 231.502 189.962 233.362 187.722C235.062 185.672 250.402 179.462 259.532 177.132C280.552 171.762 296.062 170.162 326.772 170.172C369.092 170.192 394.642 174.902 410.892 185.692C419.312 191.282 423.272 197.242 425.392 207.512C426.482 212.822 426.382 214.032 424.312 220.512C422.492 226.212 420.942 228.802 416.682 233.292C413.742 236.392 408.742 240.302 405.572 241.992C398.302 245.872 383.912 249.632 368.762 251.622ZM146.412 251.272C136.432 253.162 130.742 253.482 103.412 253.742C80.9524 253.952 69.0424 253.652 61.9124 252.682C28.4924 248.142 11.0524 240.212 3.39238 226.092C0.252382 220.292 -0.0776191 218.932 0.0123809 212.162C0.142381 202.882 1.92238 198.622 8.60238 191.562C20.8324 178.622 39.5124 173.102 76.4624 171.502L91.0124 170.862L104.492 182.762C125.782 201.552 128.872 203.902 142.912 211.932C160.322 221.882 182.112 231.162 188.092 231.162C189.152 231.162 190.902 231.842 191.972 232.662C193.862 234.132 193.832 234.242 190.912 236.652C184.902 241.622 167.932 247.202 146.412 251.272Z\" fill=\"currentColor\"/>\n </svg>\n <span class=\"nb-brand-name\">Squeezr</span>\n <span class=\"nb-brand-ver\" id=\"sb-ver\">\u2014</span>\n </div>\n\n <div class=\"nb-sep\"></div>\n\n <div class=\"nb-tabs\">\n <div class=\"nb-tab active\" data-page=\"overview\" onclick=\"go('overview')\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1.5\"/><rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1.5\"/>\n <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1.5\"/><rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1.5\"/>\n </svg>\n Overview\n </div>\n <div class=\"nb-tab\" data-page=\"savings\" onclick=\"go('savings')\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"23\"/><path d=\"M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6\"/>\n </svg>\n Savings\n </div>\n <div class=\"nb-tab\" data-page=\"settings\" onclick=\"go('settings')\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <circle cx=\"12\" cy=\"12\" r=\"3\"/>\n <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z\"/>\n </svg>\n Settings\n </div>\n </div>\n\n <div class=\"nb-right\">\n <div class=\"conn-dot\" id=\"conn-dot\"></div>\n <span class=\"conn-label\" id=\"conn-label\">Connecting\u2026</span>\n <button class=\"theme-btn\" onclick=\"toggleTheme()\" title=\"Toggle theme\">\n <svg id=\"theme-icon\" width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\">\n <circle cx=\"12\" cy=\"12\" r=\"5\"/><line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\"/><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"/>\n <line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\"/><line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\"/>\n <line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\"/><line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\"/>\n <line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\"/><line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\"/>\n </svg>\n </button>\n </div>\n </header>\n\n <!-- Main -->\n <main id=\"main\">\n\n <!-- \u2500\u2500 Overview page \u2500\u2500 -->\n <div id=\"page-overview\">\n\n<!-- Hero stats \u2014 TODAY (local calendar day, 00:00\u2013now) from date-stamped daily counters -->\n <div style=\"display:flex;align-items:center;gap:8px;margin-bottom:12px\">\n <span style=\"font-size:13px;font-weight:600;color:var(--text)\">Overview</span>\n <span class=\"badge\" id=\"overview-period\" style=\"font-size:11px;color:var(--text3)\">today</span>\n </div>\n <div class=\"hero-grid\">\n <div class=\"hero-card accent\">\n <div class=\"hc-label\">Tokens Saved</div>\n <div class=\"hc-val\" id=\"h-saved\">\u2014</div>\n <div class=\"hc-sub\">of <span id=\"h-in\">\u2014</span> processed</div>\n </div>\n <div class=\"hero-card\">\n <div class=\"hc-label\">Ratio</div>\n <div style=\"display:flex;align-items:flex-end;gap:18px\">\n <div>\n<div class=\"hc-val\" id=\"h-ratio\">\u2014</div>\n <div style=\"font-size:11px;color:var(--text3)\" title=\"% medio comprimido sobre todo lo enviado hoy (acumulado del d\u00EDa) \u2014 cifra estable\">del total (hoy)</div>\n </div>\n <div>\n <div class=\"hc-val\" id=\"h-engine\" style=\"color:var(--text3)\">\u2014</div>\n <div style=\"font-size:11px;color:var(--text3)\" title=\"% comprimido en la \u00DALTIMA request \u2014 cambia en cada turno seg\u00FAn el contenido\">\u00FAltima request</div>\n </div>\n </div>\n <div class=\"hc-sub\" style=\"margin-top:6px\"><span id=\"h-perreq\">\u2014</span></div>\n </div>\n <div class=\"hero-card\">\n <div class=\"hc-label\">Cost Saved</div>\n <div class=\"hc-val\" id=\"h-cost\">\u2014</div>\n <div class=\"hc-sub\">estimated USD</div>\n </div>\n <div class=\"hero-card\">\n <div class=\"hc-label\">Requests</div>\n <div class=\"hc-val\" id=\"h-reqs\">\u2014</div>\n <div class=\"hc-sub\"><span id=\"h-comp\">\u2014</span> AI-compressed \u00B7 det. always on</div>\n </div>\n </div>\n\n <!-- Controls -->\n <div class=\"section\">\n <div class=\"section-head\">\n <span class=\"section-title\">Compression Mode</span>\n <div class=\"badge-row\" style=\"margin:0\">\n <span class=\"badge\" id=\"mode-badge\">\u2014</span>\n <span class=\"badge\" id=\"bypass-badge\">\u2014</span>\n <span class=\"badge\" id=\"ai-comp-badge\">\u2014</span>\n </div>\n </div>\n <div class=\"section-body\">\n <div class=\"controls-row\">\n <button class=\"mode-btn\" data-mode=\"off\" onclick=\"setMode('off')\">Off</button>\n <button class=\"mode-btn\" data-mode=\"low\" onclick=\"setMode('low')\">Low</button>\n <button class=\"mode-btn active\" data-mode=\"normal\" onclick=\"setMode('normal')\">Normal</button>\n <button class=\"mode-btn\" data-mode=\"aggressive\" onclick=\"setMode('aggressive')\">Aggressive</button>\n <div class=\"divider-v\"></div>\n <button class=\"bypass-btn\" id=\"bypass-btn\" onclick=\"toggleBypass()\">Toggle Bypass</button>\n <button class=\"bypass-btn\" id=\"ai-comp-btn\" onclick=\"toggleAiCompression()\" title=\"AI compression (costs tokens). Off = deterministic only (free)\">AI Compression: \u2014</button>\n </div>\n </div>\n </div>\n\n <!-- Rate Limits + Live Log \u2014 two cards on the same row -->\n <div class=\"rl-row\">\n <div class=\"section\" style=\"margin:0\">\n <div class=\"section-head\"><span class=\"section-title\">Rate Limits</span></div>\n <div class=\"section-body\" id=\"limits-body\">\n <div class=\"lim-nodata\">Loading\u2026</div>\n </div>\n </div>\n <div class=\"section\" style=\"margin:0\">\n <div class=\"section-head\">\n <span class=\"section-title\">Live Log</span>\n <span style=\"font-size:11px;color:var(--text3)\">real-time \u00B7 compression events</span>\n </div>\n <div class=\"section-body\" id=\"livelog-body\">\n <div class=\"ll-empty\">Waiting for activity\u2026</div>\n </div>\n </div>\n </div>\n\n <!-- Three-col grid -->\n <div style=\"display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:16px\">\n <!-- Tools -->\n <div class=\"section\" style=\"margin:0\">\n <div class=\"section-head\"><span class=\"section-title\">Top Tools</span></div>\n <div class=\"section-body\" id=\"tools-body\">\n <div class=\"sk\" style=\"height:14px;margin-bottom:8px\"></div>\n <div class=\"sk\" style=\"height:14px;margin-bottom:8px;width:80%\"></div>\n <div class=\"sk\" style=\"height:14px;width:65%\"></div>\n </div>\n </div>\n<!-- Session Cache -->\n <div class=\"section\" style=\"margin:0\">\n <div class=\"section-head\"><span class=\"section-title\">Session Cache</span><span style=\"font-size:11px;color:var(--text3)\">AI layer only</span></div>\n <div class=\"section-body\">\n <div class=\"cache-row\">\n <div class=\"cache-card\"><div class=\"cache-label\">Reuses</div><div class=\"cache-val\" id=\"c-hits\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Expands</div><div class=\"cache-val\" id=\"c-miss\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">LRU Size</div><div class=\"cache-val\" id=\"c-rate\">\u2014</div></div>\n </div>\n <div style=\"margin-top:8px;font-size:11px;color:var(--text3);text-align:center\">0 here is normal when AI compression is off</div>\n </div>\n </div>\n <!-- AI Compression -->\n <div class=\"section\" style=\"margin:0\">\n <div class=\"section-head\"><span class=\"section-title\">AI Compression</span><span style=\"font-size:11px;color:var(--text3)\">today \u00B7 persisted</span></div>\n <div class=\"section-body\">\n <div class=\"cache-row\">\n <div class=\"cache-card\"><div class=\"cache-label\">Calls</div><div class=\"cache-val\" id=\"ai-calls\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Saved</div><div class=\"cache-val\" id=\"ai-saved\" style=\"color:var(--brand2)\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Spent</div><div class=\"cache-val\" id=\"ai-spent\">\u2014</div></div>\n </div>\n <div id=\"ai-net\" style=\"margin-top:8px;font-size:11px;color:var(--text3);text-align:center\">\u2014</div>\n </div>\n </div>\n </div>\n\n <!-- Prompt Cache health (Anthropic) \u2014 the metric that caught the 2026-06-04 over-billing -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">Prompt Cache (Anthropic)</span><span style=\"font-size:11px;color:var(--text3)\">persisted \u00B7 read=cheap (0.1x) \u00B7 creation=re-billed (1.25x)</span></div>\n <div class=\"section-body\">\n <div class=\"cache-row\">\n <div class=\"cache-card\"><div class=\"cache-label\">Cache Read</div><div class=\"cache-val\" id=\"pc-read\" style=\"color:var(--brand2)\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Cache Creation</div><div class=\"cache-val\" id=\"pc-create\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Hit Health</div><div class=\"cache-val\" id=\"pc-health\">\u2014</div></div>\n </div>\n <div id=\"pc-note\" style=\"margin-top:8px;font-size:11px;color:var(--text3);text-align:center\">High read vs creation = cache working. High creation = something is invalidating the prefix.</div>\n </div>\n </div>\n\n <!-- Spend: theoretical vs real -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">Cost Comparison</span><span style=\"font-size:11px;color:var(--text3)\" id=\"cost-note\">per-model pricing</span></div>\n <div class=\"section-body\">\n <div style=\"display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px\">\n <div style=\"text-align:center\">\n <div style=\"font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:6px\">Without Squeezr</div>\n <div style=\"font-size:24px;font-weight:700;color:var(--text)\" id=\"sp-without\">\u2014</div>\n <div style=\"font-size:11px;color:var(--text3);margin-top:4px\" id=\"sp-without-tok\">\u2014</div>\n </div>\n <div style=\"text-align:center\">\n <div style=\"font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:6px\">With Squeezr</div>\n <div style=\"font-size:24px;font-weight:700;color:var(--brand2)\" id=\"sp-with\">\u2014</div>\n <div style=\"font-size:11px;color:var(--text3);margin-top:4px\" id=\"sp-with-tok\">\u2014</div>\n </div>\n <div style=\"text-align:center\">\n <div style=\"font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:6px\">Saved</div>\n <div style=\"font-size:24px;font-weight:700;color:var(--brand2)\" id=\"sp-saved\">\u2014</div>\n <div style=\"font-size:11px;color:var(--text3);margin-top:4px\" id=\"sp-saved-pct\">\u2014</div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Model breakdown -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">By model</span><span style=\"font-size:11px;color:var(--text3)\">today \u00B7 real pricing</span></div>\n <div class=\"section-body\" id=\"model-body\">\n <div style=\"font-size:13px;color:var(--text3)\">No model data yet.</div>\n </div>\n </div>\n\n<!-- Savings by client -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">Savings by client</span><span style=\"font-size:11px;color:var(--text3)\">today</span></div>\n <div class=\"section-body\" id=\"client-body-overview\">\n <div style=\"font-size:13px;color:var(--text3)\">No data yet \u2014 starts after first request.</div>\n </div>\n </div>\n\n <!-- Savings by compression type (today, persisted) -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">Savings by type</span><span style=\"font-size:11px;color:var(--text3)\">today</span></div>\n <div class=\"section-body\" id=\"breakdown-body\">\n <div style=\"font-size:13px;color:var(--text3)\">No data yet.</div>\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500 Savings page \u2500\u2500 -->\n <div id=\"page-savings\" style=\"display:none\">\n\n <!-- Period selector + navigation -->\n <div style=\"display:flex;gap:8px;margin-bottom:20px;align-items:center;flex-wrap:wrap\">\n <span style=\"font-size:13px;color:var(--text2);font-weight:500\">View:</span>\n <button class=\"mode-btn active\" id=\"period-day\" onclick=\"setSavingsPeriod('day')\">Day</button>\n <button class=\"mode-btn\" id=\"period-week\" onclick=\"setSavingsPeriod('week')\">Week</button>\n <button class=\"mode-btn\" id=\"period-month\" onclick=\"setSavingsPeriod('month')\">Month</button>\n <button class=\"mode-btn\" id=\"period-all\" onclick=\"setSavingsPeriod('all')\">All time</button>\n <div style=\"flex:1\"></div>\n <button class=\"mode-btn\" onclick=\"navigatePeriod(-1)\" title=\"Previous\">\u25C0</button>\n <span id=\"period-label\" style=\"font-size:13px;color:var(--text);font-weight:600;min-width:160px;text-align:center\">\u2014</span>\n <button class=\"mode-btn\" onclick=\"navigatePeriod(1)\" title=\"Next\">\u25B6</button>\n <button class=\"mode-btn\" onclick=\"navigatePeriod(0)\" title=\"Today\">Today</button>\n </div>\n\n <!-- Period hero -->\n <div class=\"hero-grid\" id=\"savings-hero\">\n <div class=\"hero-card accent\">\n <div class=\"hc-label\">Tokens Saved</div>\n <div class=\"hc-val\" id=\"sv-tokens\">\u2014</div>\n <div class=\"hc-sub\" id=\"sv-tokens-sub\">\u2014</div>\n </div>\n <div class=\"hero-card\">\n <div class=\"hc-label\">Est. Cost Saved</div>\n <div class=\"hc-val\" id=\"sv-cost\">\u2014</div>\n <div class=\"hc-sub\" id=\"sv-cost-note\">per-model pricing</div>\n </div>\n <div class=\"hero-card\">\n <div class=\"hc-label\">Sessions</div>\n <div class=\"hc-val\" id=\"sv-sessions\">\u2014</div>\n <div class=\"hc-sub\" id=\"sv-requests\">\u2014</div>\n </div>\n <div class=\"hero-card\">\n <div class=\"hc-label\">Avg Saving</div>\n <div class=\"hc-val\" id=\"sv-pct\">\u2014</div>\n <div class=\"hc-sub\"><span id=\"sv-engine\">\u2014</span></div>\n </div>\n </div>\n\n <!-- Daily breakdown chart (bar chart) -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\" id=\"savings-chart-title\">Daily breakdown</span></div>\n <div class=\"section-body\" id=\"savings-chart\">\n <div class=\"sk\" style=\"height:80px\"></div>\n </div>\n </div>\n\n <!-- By model -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">By model</span><span style=\"font-size:11px;color:var(--text3)\">selected period \u00B7 real pricing</span></div>\n <div class=\"section-body\" id=\"model-body-savings\">\n <div style=\"font-size:13px;color:var(--text3)\">No model data yet.</div>\n </div>\n </div>\n\n <!-- By client -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">By client</span><span style=\"font-size:11px;color:var(--text3)\">selected period</span></div>\n <div class=\"section-body\" id=\"client-body-savings\">\n <div style=\"font-size:13px;color:var(--text3)\">No client data yet.</div>\n </div>\n </div>\n\n <!-- Top Tools (period) -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">Top Tools</span><span style=\"font-size:11px;color:var(--text3)\">selected period</span></div>\n <div class=\"section-body\" id=\"tools-body-savings\">\n <div style=\"font-size:13px;color:var(--text3)\">No tool data yet.</div>\n </div>\n </div>\n\n <!-- AI Compression + Session Cache (period) -->\n <div style=\"display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px\">\n <div class=\"section\" style=\"margin:0\">\n <div class=\"section-head\"><span class=\"section-title\">AI Compression</span><span style=\"font-size:11px;color:var(--text3)\">selected period</span></div>\n <div class=\"section-body\">\n <div class=\"cache-row\">\n <div class=\"cache-card\"><div class=\"cache-label\">Calls</div><div class=\"cache-val\" id=\"sv-ai-calls\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Saved</div><div class=\"cache-val\" id=\"sv-ai-saved\" style=\"color:var(--brand2)\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Spent</div><div class=\"cache-val\" id=\"sv-ai-spent\">\u2014</div></div>\n </div>\n <div id=\"sv-ai-net\" style=\"margin-top:8px;font-size:11px;color:var(--text3);text-align:center\">\u2014</div>\n </div>\n </div>\n <div class=\"section\" style=\"margin:0\">\n <div class=\"section-head\"><span class=\"section-title\">Session Cache</span><span style=\"font-size:11px;color:var(--text3)\">selected period</span></div>\n <div class=\"section-body\">\n <div class=\"cache-row\">\n <div class=\"cache-card\"><div class=\"cache-label\">Reuses</div><div class=\"cache-val\" id=\"sv-cache-reuses\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Expands</div><div class=\"cache-val\" id=\"sv-cache-expands\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Sessions</div><div class=\"cache-val\" id=\"sv-cache-sessions\">\u2014</div></div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500 Settings page \u2500\u2500 -->\n <div id=\"page-settings\" style=\"display:none\">\n\n <div id=\"update-banner\" style=\"display:none;background:rgba(251,191,36,.08);border:1px solid rgba(251,191,36,.25);border-radius:12px;padding:14px 20px;margin-bottom:20px;align-items:center;justify-content:space-between\">\n <div>\n <span style=\"color:#fbbf24;font-weight:600;font-size:13px\">Update available</span>\n <span id=\"update-text\" style=\"color:var(--text2);font-size:13px;margin-left:8px\"></span>\n </div>\n <button class=\"action-btn\" onclick=\"runAction('update')\" style=\"border-color:rgba(251,191,36,.4);color:#fbbf24\">Update now</button>\n </div>\n\n\n <div class=\"settings-block\">\n <div class=\"settings-head\">Proxy endpoints</div>\n <div class=\"settings-row\">\n <span class=\"s-key\" title=\"Claude Code, Claude Desktop, Aider, OpenCode\">Anthropic <span style=\"font-size:11px;color:var(--text3)\">(Claude)</span></span>\n <span class=\"s-val\"><code id=\"cfg-url-val\">\u2014</code></span>\n </div>\n <div class=\"settings-row\">\n <span class=\"s-key\" title=\"Codex Desktop app, Continue, Cline, Cursor \u2014 use openai_base_url\">Codex Desktop <span style=\"font-size:11px;color:var(--text3)\">/ OpenAI apps</span></span>\n <span class=\"s-val\"><code id=\"cfg-oai-val\">\u2014</code><span style=\"font-size:11px;color:var(--text3);margin-left:6px\">/v1 \u00B7 openai_base_url</span></span>\n </div>\n <div class=\"settings-row\">\n <span class=\"s-key\" title=\"Gemini CLI \u2014 GEMINI_API_BASE_URL\">Gemini CLI</span>\n <span class=\"s-val\"><code id=\"cfg-gem-val\">\u2014</code><span style=\"font-size:11px;color:var(--text3);margin-left:6px\">GEMINI_API_BASE_URL</span></span>\n </div>\n <div class=\"settings-row\">\n <span class=\"s-key\" title=\"Codex CLI (terminal) \u2014 WebSocket TLS intercept, set HTTPS_PROXY per session\">Codex CLI <span style=\"font-size:11px;color:var(--text3)\">(terminal)</span></span>\n <span class=\"s-val\"><code id=\"cfg-mitm-val\">\u2014</code><span style=\"font-size:11px;color:var(--text3);margin-left:6px\">HTTPS_PROXY \u00B7 TLS intercept</span></span>\n </div>\n <div class=\"settings-row\">\n <span class=\"s-key\">Version</span>\n <span class=\"s-val\" id=\"cfg-ver\">\u2014</span>\n </div>\n <div class=\"settings-row\">\n <span class=\"s-key\">Uptime</span>\n <span class=\"s-val\" id=\"cfg-uptime\" style=\"color:var(--brand2)\">\u2014</span>\n </div>\n </div>\n\n <div class=\"settings-block\">\n <div class=\"settings-head\">Compression</div>\n <div class=\"settings-row\">\n <span class=\"s-key\">Mode</span>\n <span class=\"s-val\"><code id=\"cfg-mode\">\u2014</code></span>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start;gap:4px\">\n <div style=\"display:flex;justify-content:space-between;width:100%;align-items:center\">\n <span class=\"s-key\">AI Compression</span>\n <button class=\"action-btn\" id=\"ai-comp-btn-settings\" onclick=\"toggleAiCompression()\">\u2014</button>\n </div>\n <div style=\"font-size:12px;color:var(--text3);line-height:1.4\">\n Master switch for AI compression calls (Haiku/GPT/Gemini). When <strong style=\"color:var(--text2)\">off</strong>, only free deterministic compression runs \u2014 <em>zero</em> token cost. \u26A0\uFE0F With a Claude Code subscription token, leaving this <strong style=\"color:var(--text2)\">on</strong> bills compression against your own 5h plan limit. Persists across restarts.\n </div>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start;gap:4px\">\n <div style=\"display:flex;justify-content:space-between;width:100%;align-items:center\">\n <span class=\"s-key\">Bypass</span>\n <span class=\"s-val\"><code id=\"cfg-bypass\">\u2014</code></span>\n </div>\n <div style=\"font-size:12px;color:var(--text3);line-height:1.4\">\n When <strong style=\"color:var(--text2)\">enabled</strong>, all requests pass through to the API <em>without compression</em> \u2014 useful to check if Squeezr is causing any issue. Stats are still logged. Resets automatically when the proxy restarts.\n </div>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start;gap:4px\">\n <div style=\"display:flex;justify-content:space-between;width:100%;align-items:center\">\n <span class=\"s-key\">Circuit Breaker</span>\n <span class=\"s-val\"><code id=\"cfg-cb\">\u2014</code></span>\n </div>\n <div style=\"font-size:12px;color:var(--text3);line-height:1.4\">\n Protects against latency spikes. If the local AI compression model (Ollama) fails <strong style=\"color:var(--text2)\">3 times in a row</strong>, it auto-disables AI compression and falls back to deterministic rules only. Returns to normal after 60s without errors. Deterministic compression always stays active.\n </div>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start;gap:8px\">\n <div style=\"display:flex;justify-content:space-between;width:100%;align-items:center;flex-wrap:wrap;gap:6px\">\n <span class=\"s-key\">Compression backend</span>\n <div style=\"display:flex;gap:4px;flex-wrap:wrap\">\n <button class=\"mode-btn\" data-backend=\"local\" onclick=\"setBackend('local')\">\u26A1 Zest (local \u00B7 free)</button>\n <button class=\"mode-btn\" data-backend=\"haiku\" onclick=\"setBackend('haiku')\">Haiku (API \u00B7 billed)</button>\n <button class=\"mode-btn\" data-backend=\"auto\" onclick=\"setBackend('auto')\">Auto</button>\n <button class=\"mode-btn\" data-backend=\"gpt-mini\" onclick=\"setBackend('gpt-mini')\">GPT-4o-mini</button>\n <button class=\"mode-btn\" data-backend=\"gemini-flash\" onclick=\"setBackend('gemini-flash')\">Gemini Flash</button>\n </div>\n </div>\n <div style=\"font-size:12px;color:var(--text3);line-height:1.4\">\n Las dos formas de AI compression: <strong style=\"color:var(--brand2)\">\u26A1 Zest</strong> comprime con el modelo local v\u00EDa Ollama \u2014 gratis, sin red, no consume tu cuota. <strong style=\"color:var(--text2)\">Haiku</strong> comprime con la API de Anthropic. El resto fuerza ese modelo. La elecci\u00F3n se guarda en <code>squeezr.toml</code> y sobrevive reinicios.\n </div>\n <div id=\"backend-warn\" style=\"display:none;font-size:12px;line-height:1.4;color:#fbbf24;background:rgba(251,191,36,.08);border:1px solid rgba(251,191,36,.3);border-radius:8px;padding:8px 10px\">\n \u26A0\uFE0F <strong>Haiku con suscripci\u00F3n Claude Code (token OAuth):</strong> cada llamada de compresi\u00F3n se factura contra tu cuota del plan de 5h \u2014 te lo come en minutos. Squeezr la <strong>bloquea autom\u00E1ticamente</strong> en este caso. Usa <strong>\u26A1 Zest (local)</strong> para AI compression gratis, o una API key facturada aparte.\n </div>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start;gap:8px\">\n <div style=\"display:flex;justify-content:space-between;width:100%;align-items:center\">\n <span class=\"s-key\">Anthropic Native Compact <span style=\"font-size:10px;background:var(--brand-dim);color:var(--brand2);padding:1px 6px;border-radius:3px;margin-left:4px\">beta</span></span>\n <button class=\"mode-btn\" id=\"native-compact-btn\" onclick=\"toggleNativeCompact()\" style=\"min-width:80px\">\u2014</button>\n </div>\n <div style=\"font-size:12px;color:var(--text3);line-height:1.4\">\n Activa el header <code style=\"font-size:11px\">anthropic-beta: compact-2026-01-12</code>. Cuando el contexto excede el threshold, Anthropic <strong style=\"color:var(--text2)\">resume tu conversaci\u00F3n autom\u00E1ticamente en sus servidores</strong>. Stacks con la compresi\u00F3n de Squeezr \u2014 comprimes primero, ellos resumen lo que queda. <strong>Solo Claude</strong> (no afecta OpenAI/Gemini). Reseteable.\n </div>\n </div>\n </div>\n\n <!-- Token savings by client \u2014 toggle -->\n <div class=\"settings-block\">\n <div class=\"settings-head\" style=\"cursor:pointer;display:flex;align-items:center;justify-content:space-between\" onclick=\"toggleClientBreakdown()\">\n <span>Token savings by client</span>\n <svg id=\"cli-chevron\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" style=\"transition:transform .2s;color:var(--text3)\">\n <polyline points=\"6 9 12 15 18 9\"/>\n </svg>\n </div>\n <div id=\"cli-breakdown\" style=\"display:none\">\n <div id=\"cli-breakdown-body\" style=\"padding:14px 20px\">\n <span style=\"font-size:13px;color:var(--text3)\">No client data yet \u2014 starts tracking after first request.</span>\n </div>\n </div>\n </div>\n\n <div class=\"settings-block\">\n <div class=\"settings-head\">Connected CLIs & Apps</div>\n <div class=\"chips\">\n <div class=\"chip\"><div class=\"chip-dot\"></div>Claude Code</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Claude Desktop</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Codex Desktop</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Codex CLI</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Aider</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Gemini CLI</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Cursor</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Continue.dev</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Windsurf</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Cline</div>\n </div>\n </div>\n\n <div class=\"settings-block\">\n <div class=\"settings-head\">Actions</div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;width:100%\">\n <span class=\"s-key\">Status</span>\n <button class=\"action-btn\" onclick=\"runAction('status')\">Check Status</button>\n </div>\n <div class=\"action-result\" id=\"action-result-status\"></div>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;width:100%\">\n <span class=\"s-key\">Restart Proxy</span>\n <button class=\"action-btn\" onclick=\"runAction('restart')\">Restart</button>\n </div>\n <div class=\"action-result\" id=\"action-result-restart\"></div>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;width:100%\">\n <span class=\"s-key\">Stop Proxy</span>\n <button class=\"action-btn danger\" onclick=\"runAction('stop')\">Stop Proxy</button>\n </div>\n <div class=\"action-result\" id=\"action-result-stop\"></div>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;width:100%\">\n <span class=\"s-key\">Update Squeezr</span>\n <button class=\"action-btn\" onclick=\"runAction('update')\">Update to latest</button>\n </div>\n <div class=\"action-result\" id=\"action-result-update\"></div>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;width:100%;gap:12px\">\n <span class=\"s-key\" style=\"flex-shrink:0\">Ports</span>\n <div style=\"display:flex;align-items:center;gap:8px;flex:1;justify-content:flex-end\">\n <input id=\"inp-http-port\" type=\"number\" placeholder=\"HTTP\" style=\"width:80px;padding:5px 10px;border-radius:7px;border:1px solid var(--border2);background:var(--surface2);color:var(--text);font-size:12px;font-family:inherit\">\n <input id=\"inp-mitm-port\" type=\"number\" placeholder=\"MITM\" style=\"width:80px;padding:5px 10px;border-radius:7px;border:1px solid var(--border2);background:var(--surface2);color:var(--text);font-size:12px;font-family:inherit\">\n <button class=\"action-btn\" onclick=\"runAction('ports')\">Apply</button>\n </div>\n </div>\n <div class=\"action-result\" id=\"action-result-ports\"></div>\n </div>\n </div>\n </div>\n\n </main>\n</div>\n\n<script>\n// \u2500\u2500 Theme \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nvar MOON = '<circle cx=\"12\" cy=\"12\" r=\"5\"/><line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\"/><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"/><line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\"/><line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\"/><line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\"/><line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\"/><line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\"/><line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\"/>';\nvar SUN = '<path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\"/>';\n\n(function(){\n var t = localStorage.getItem('sq-theme') || 'dark';\n setTheme(t, false);\n})();\n\nfunction setTheme(t, save) {\n if (t === 'dark') {\n document.documentElement.classList.add('dark');\n document.getElementById('theme-icon').innerHTML = MOON;\n document.querySelector('.theme-label') && (document.querySelector('.theme-label').textContent = 'Light mode');\n } else {\n document.documentElement.classList.remove('dark');\n document.getElementById('theme-icon').innerHTML = SUN;\n document.querySelector('.theme-label') && (document.querySelector('.theme-label').textContent = 'Dark mode');\n }\n if (save !== false) localStorage.setItem('sq-theme', t);\n}\n\nfunction toggleTheme() {\n var isDark = document.documentElement.classList.contains('dark');\n setTheme(isDark ? 'light' : 'dark');\n}\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\nfunction go(page) {\n document.querySelectorAll('.nb-tab').forEach(function(el) {\n el.classList.toggle('active', el.dataset.page === page);\n });\n document.getElementById('page-overview').style.display = page === 'overview' ? '' : 'none';\n document.getElementById('page-savings').style.display = page === 'savings' ? '' : 'none';\n document.getElementById('page-settings').style.display = page === 'settings' ? '' : 'none';\n if (page === 'savings') loadSavings();\n try { localStorage.setItem('sq-page', page); } catch(e) {}\n}\n\n// Restore last tab on load\n(function(){\n var saved = localStorage.getItem('sq-page');\n if (saved && saved !== 'overview') go(saved);\n})();\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\nfunction fmt(n) {\n if (n == null || isNaN(n)) return '\u2014';\n if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';\n if (n >= 1000) return (n / 1000).toFixed(1) + 'k';\n return String(Math.round(n));\n}\nfunction fmtUsd(n) {\n if (n == null || isNaN(n) || n === 0) return '\u2014';\n if (n < 0.01) return '<$0.01';\n return '$' + Number(n).toFixed(2);\n}\nfunction fmtRatio(r) {\n if (r == null) return '\u2014';\n return Math.round((1 - r) * 100) + '%';\n}\nfunction fmtUptime(s) {\n if (s == null) return '\u2014';\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 esc(s) {\n return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n}\n\n// \u2500\u2500 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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nvar lastStats = null;\n\nfunction render(d) {\n if (!d) return;\n lastStats = d;\n\n // \u2500\u2500 Normalize field names (API uses snake_case with various naming conventions) \u2500\u2500\n // tokens \u2014 server uses CHARS_PER_TOKEN=3.5, match it here for consistency\n var tokensSaved = d.total_saved_tokens || d.tokens_saved || 0;\n var tokensIn = Math.round((d.total_original_chars || 0) / 3.5); // same ratio as stats.ts\n // ratio: API gives savings_pct (0-100), or compression_ratio (0-1)\n var ratioPct = d.savings_pct != null ? d.savings_pct\n : d.compression_ratio != null ? Math.round((1 - d.compression_ratio) * 100)\n : null;\n // cost estimate: if not provided, estimate from saved tokens at ~$3/1M tokens\n var costUsd = d.cost_saved_usd != null ? d.cost_saved_usd\n : tokensSaved > 0 ? tokensSaved * 0.000003 : null;\n // requests\n var reqs = d.requests != null ? d.requests : (d.total_requests || 0);\n var aiComps = d.compressions != null ? d.compressions : (d.compressed || 0);\n var cacheHitsAi = (d.session_cache_hits != null ? d.session_cache_hits : 0);\n var comps = aiComps + cacheHitsAi; // AI calls + session cache reuses\n // latency: nested object { total: { p50, p95, p99 } } or flat\n var lat = (d.latency && d.latency.total) ? d.latency.total : d.latency || {};\n var p50 = lat.p50 != null ? lat.p50 : d.latency_p50;\n var p95 = lat.p95 != null ? lat.p95 : d.latency_p95;\n var p99 = lat.p99 != null ? lat.p99 : d.latency_p99;\n // Session cache: reuses = session_cache_hits, expands = Claude expand calls, lru = AI compression LRU size\n var cacheHits = d.session_cache_hits || 0;\n var cacheMiss = (d.expand && d.expand.calls != null) ? d.expand.calls : 0;\n var cacheSize = (d.cache && d.cache.size != null) ? d.cache.size : 0;\n // bypass\n var byp = !!(d.bypassed || d.bypass);\n var mode = d.mode || 'normal';\n\n // Sidebar version\n if (d.version) document.getElementById('sb-ver').textContent = 'v' + d.version;\n\n // Overview = TODAY: model/client breakdowns come from today's date-stamped data,\n // not all-time. Fall back to {} when there's no data yet today.\n var todayByModel = (d.today && d.today.by_model) || {};\n var todayByClient = (d.today && d.today.by_client) || {};\n // Cost comparison (#7) \u2014 weighted by actual models used today (computed first so hero card can use it)\n var modelCosts = calcCostFromModels(todayByModel, true);\n\n// Hero cards \u2014 OVERVIEW = TODAY (local calendar day, 00:00\u2013now). Sourced from the\n // date-stamped daily counters in stats.json, NOT the all-time totals. If no request\n // has happened since midnight, these are 0 (never falls back to all-time).\n var today = d.today || {};\n var tSaved = today.saved_tokens || 0;\n var tIn = today.original_tokens || 0;\n var tRatio = today.savings_pct != null ? today.savings_pct : null;\n var tReqs = today.requests || 0;\n var tComps = today.ai_calls || 0;\n var tCost = tSaved > 0 ? tSaved * 0.000003 : null;\n document.getElementById('h-saved').textContent = fmt(tSaved);\n document.getElementById('h-in').textContent = fmt(tIn);\n // LEFT ratio = overall today wire reduction (saved/original, cumulative day).\n // Stable by nature \u2014 it's a daily average. (The old \"non-cached\" metric measured\n // the post-barrier tail, which in Claude Code is the recent UNcompressed messages\n // \u2192 always ~0 \u2192 it silently fell back to this same number, so both read equal.)\n document.getElementById('h-ratio').textContent = tRatio != null ? Math.round(tRatio) + '%' : '\u2014';\n document.getElementById('h-cost').textContent = fmtUsd(tCost);\n document.getElementById('h-reqs').textContent = fmt(tReqs);\n document.getElementById('h-comp').textContent = fmt(tComps);\nvar perEl = document.getElementById('overview-period');\n if (perEl && today.date) perEl.textContent = 'today \u00B7 ' + today.date;\n // (No quality banner in the Overview \u2014 only genuine quality issues, high expand\n // rate, are logged to the Live Log by the governor. Benign reject-rate is silent.)\n // Per-request metric (stable, doesn't dilute): avg tokens saved per request + last request %\n var avgPerReq = (tReqs > 0) ? Math.round(tSaved / tReqs) : 0;\n var lastOrig = d.last_original_chars || 0;\n var lastComp = d.last_compressed_chars || 0;\n var lastPct = lastOrig > 0 ? Math.round((lastOrig - lastComp) / lastOrig * 100) : null;\n var prEl = document.getElementById('h-perreq');\n // Efficiency = % saved on the content we actually compress (not diluted by the\n // recent/kept/uncompressible payload). This is the \"fair\" compression number.\nvar eff = (today.efficiency_pct != null) ? today.efficiency_pct : null;\n // Two big percentages side by side: left = total saved (wire reduction),\n // right = engine efficiency (% on the blocks we actually compress).\n// Right number = overall % over the WHOLE request (dragged down by the cached\n // prefix). Shown next to the non-cached % so the difference is visible.\n void eff;\n // RIGHT ratio = the LAST request's reduction \u2014 changes every turn (directly\n // answers \"why is it always the same?\": this one moves with the actual content).\n var engEl = document.getElementById('h-engine');\n if (engEl) engEl.textContent = lastPct != null ? lastPct + '%' : '\u2014';\n if (prEl) prEl.textContent = avgPerReq > 0 ? '~' + fmt(avgPerReq) + ' tok/req' : '\u2014';\n\n // Latency (elements removed from Overview but kept for potential future use)\n var lp = function(id, v){ var e = document.getElementById(id); if(e) e.textContent = v != null ? v : '\u2014'; };\n lp('l-50', p50); lp('l-95', p95); lp('l-99', p99);\n\n// Session cache\n document.getElementById('c-hits').textContent = fmt(cacheHits);\n document.getElementById('c-miss').textContent = fmt(cacheMiss);\n document.getElementById('c-rate').textContent = cacheSize > 0 ? fmt(cacheSize) : '\u2014';\n // AI Compression card \u2014 TODAY-scoped (consistent with the hero), persists across\n // restart and resets at midnight. Calls/Spent = real backend usage today; Saved =\n // today's AI char savings. Avoids the all-time-vs-today mismatch that made the\n // deterministic share look impossibly small.\n if (d.today) {\n var aiCalls = d.today.ai_calls || 0; // real AI backend calls today\n var localCalls = d.today.ai_local_calls || 0;\n var cloudCalls = aiCalls - localCalls;\n var aiSpentTok = d.today.ai_spent_tokens || 0; // cloud tokens (local is free)\n var aiSavedTok = d.today.ai_saved_tokens || 0;\n var setAi = function(id, v){ var e = document.getElementById(id); if(e) e.textContent = v; };\n setAi('ai-calls', fmt(aiCalls));\n setAi('ai-saved', aiSavedTok > 0 ? fmt(aiSavedTok) : '\u2014');\n setAi('ai-spent', localCalls > 0 && cloudCalls === 0 ? 'free' : (aiSpentTok > 0 ? fmt(aiSpentTok) : '\u2014'));\n var netEl = document.getElementById('ai-net');\n if (netEl) {\n if (aiCalls === 0 && aiSavedTok > 0) {\n // Savings with no real backend calls = blocks compressed on an earlier\n // request and replayed for free from the compression cache (session/LRU).\n netEl.textContent = fmt(aiSavedTok) + ' tokens saved \u2014 reused from compression cache (no AI calls needed)';\n netEl.style.color = 'var(--brand2)';\n } else if (aiCalls === 0) {\n netEl.textContent = 'No AI calls yet';\n } else if (cloudCalls === 0) {\n // All local: 100% free savings, no spend.\n netEl.textContent = localCalls + ' local Zest call(s) \u00B7 ' + fmt(aiSavedTok) + ' tokens saved (free)';\n netEl.style.color = 'var(--brand2)';\n } else {\n var net = aiSavedTok - aiSpentTok;\n var sign = net >= 0 ? '+' : '\u2212';\n netEl.textContent = 'Net: ' + sign + fmt(Math.abs(net)) + ' tokens (saved \u2212 spent) \u00B7 ' + localCalls + ' local + ' + cloudCalls + ' cloud';\n netEl.style.color = net >= 0 ? 'var(--brand2)' : 'var(--red, #e5484d)';\n }\n }\n }\n\n // Tools\n renderTools(d.by_tool || d.tools);\n\n // Limits\n renderLimits(d.limits);\n // Live Log feed\n renderLiveLog(d.activity);\n // Prompt cache health (Anthropic) \u2014 read vs creation tokens this session\n var au = d.limits && d.limits.anthropic && d.limits.anthropic.usage;\n if (au) {\n var pcRead = au.cacheReadSession || 0;\n var pcCreate = au.cacheCreationSession || 0;\n var setPc = function(id, v){ var e = document.getElementById(id); if(e) e.textContent = v; };\n setPc('pc-read', pcRead > 0 ? fmt(pcRead) : '\u2014');\n setPc('pc-create', pcCreate > 0 ? fmt(pcCreate) : '\u2014');\n var healthEl = document.getElementById('pc-health');\n if (healthEl) {\n if (pcRead + pcCreate === 0) {\n healthEl.textContent = '\u2014';\n } else {\n var hitPct = Math.round(pcRead / (pcRead + pcCreate) * 100);\n healthEl.textContent = hitPct + '%';\n healthEl.style.color = hitPct >= 80 ? 'var(--brand2)' : hitPct >= 50 ? '#fbbf24' : 'var(--red, #e5484d)';\n }\n }\n }\n // Cost Comparison is OVERVIEW = TODAY: use today's tokens (not all-time), so the\n // sub-lines match the today money. (modelCosts already comes from today's models.)\n var cmpIn = today.original_tokens || 0;\n var cmpSaved = today.saved_tokens || 0;\n var cmpActual = cmpIn - cmpSaved;\n var cmpRatio = today.savings_pct != null ? today.savings_pct : null;\n var costSaved, costWithout, costWith, priceNote;\n if (modelCosts && modelCosts.totalCost > 0) {\n // Precise: model-weighted pricing\n costSaved = modelCosts.savedCost;\n costWithout = modelCosts.totalCost;\n costWith = modelCosts.totalCost - modelCosts.savedCost;\n priceNote = 'model-weighted pricing';\n } else {\n // Fallback: flat $3/1M\n var flat = 0.000003;\n costSaved = cmpSaved * flat;\n costWithout = cmpIn * flat;\n costWith = cmpActual * flat;\n priceNote = 'est. $3/1M (no model data)';\n }\n var setTxt = function(id, v){ var e = document.getElementById(id); if(e) e.textContent = v; };\n setTxt('sp-without', costWithout > 0 ? fmtUsd(costWithout) : '\u2014');\n setTxt('sp-without-tok', cmpIn > 0 ? '~' + fmt(cmpIn) + ' tokens' : '\u2014');\n setTxt('sp-with', costWith > 0 ? fmtUsd(costWith) : '\u2014');\n setTxt('sp-with-tok', cmpActual > 0 ? '~' + fmt(cmpActual) + ' tokens' : '\u2014');\n setTxt('sp-saved', costSaved > 0 ? fmtUsd(costSaved) : '\u2014');\n setTxt('sp-saved-pct', (cmpRatio != null ? Math.round(cmpRatio) + '% \u00B7 ' : '') + priceNote);\n var noteEl = document.getElementById('cost-note'); if(noteEl) noteEl.textContent = priceNote;\n // Model breakdown section (today)\n renderModelBreakdown(todayByModel, d.ai_usage && d.ai_usage.by_model);\n\n // CLI breakdown (#8) (today)\n renderClientBreakdown(todayByClient);\n // Overview = TODAY: use today's per-technique breakdown + today's net saved.\n renderBreakdown((d.today && d.today.breakdown) || d.breakdown, tSaved);\n\n // Mode & bypass\n updateMode(mode, byp);\n\n // AI compression toggle state (overview badge + button, settings button)\n var aiOn = !!d.ai_compression_enabled;\n var aiBadge = document.getElementById('ai-comp-badge');\n if (aiBadge) {\n aiBadge.textContent = 'AI: ' + (aiOn ? 'on' : 'off');\n aiBadge.className = 'badge' + (aiOn ? ' yellow' : '');\n }\n var aiBtn = document.getElementById('ai-comp-btn');\n if (aiBtn) {\n aiBtn.textContent = 'AI Compression: ' + (aiOn ? 'ON' : 'OFF');\n aiBtn.className = 'bypass-btn' + (aiOn ? ' active' : '');\n }\n var aiBtnSet = document.getElementById('ai-comp-btn-settings');\n if (aiBtnSet) aiBtnSet.textContent = aiOn ? 'ON \u2014 turn off' : 'OFF \u2014 turn on';\n\n // Settings page \u2014 ports come from health endpoint (d.port / d.mitm_port)\n var httpPort = d.port || window.location.port || '8080';\n var mitmPort = d.mitm_port || (parseInt(String(httpPort)) + 1);\n var setEl = function(id, v){ var e = document.getElementById(id); if(e) e.textContent = v; };\n setEl('cfg-url-val', 'http://localhost:' + httpPort);\n setEl('cfg-oai-val', 'http://localhost:' + httpPort + '/v1');\n setEl('cfg-gem-val', 'http://localhost:' + httpPort);\n setEl('cfg-mitm-val', 'http://localhost:' + mitmPort);\n var ih = document.getElementById('inp-http-port'); if(ih && !ih.value) ih.value = String(httpPort);\n var im = document.getElementById('inp-mitm-port'); if(im && !im.value) im.value = String(mitmPort);\n if (d.version) document.getElementById('cfg-ver').textContent = d.version;\n if (d.uptime_seconds != null) document.getElementById('cfg-uptime').textContent = fmtUptime(d.uptime_seconds);\n document.getElementById('cfg-mode').textContent = mode;\n document.getElementById('cfg-bypass').textContent = byp ? 'enabled' : 'disabled';\n // Backend selector state\n if (d.compression_backend) updateBackendButtons(d.compression_backend);\n // Native compact toggle state\n var ncBtn = document.getElementById('native-compact-btn');\n if (ncBtn) {\n var ncOn = !!d.anthropic_native_compact;\n ncBtn.textContent = ncOn ? 'ON' : 'OFF';\n ncBtn.style.background = ncOn ? 'var(--brand)' : '';\n ncBtn.style.color = ncOn ? 'white' : '';\n }\n if (d.circuit_breaker) {\n var cb = d.circuit_breaker;\n document.getElementById('cfg-cb').textContent = cb.state + (cb.total_trips ? ' \u00B7 ' + cb.total_trips + ' trips' : '');\n }\n}\n\n// Build HTML for a tools object \u2014 used by overview (all-time) and savings (per-period)\nfunction buildToolsHtml(tools) {\n var empty = '<span style=\"font-size:13px;color:var(--text3)\">No tool data for this period.</span>';\n if (!tools || typeof tools !== 'object') return empty;\n // tools can be { ToolName: count } or { ToolName: { count, saved_tokens } }\n var entries = Object.entries(tools)\n .map(function(e){ return [e[0], typeof e[1] === 'object' ? e[1].count || 0 : e[1]]; })\n .filter(function(e){ return e[1] > 0; })\n .sort(function(a,b){ return b[1]-a[1]; })\n .slice(0,6);\n if (!entries.length) return empty;\n var max = entries[0][1];\n return entries.map(function(e){\n var pct = max > 0 ? Math.round(e[1]/max*100) : 0;\n return '<div class=\"tool-row\"><span class=\"tool-name\">'+esc(e[0])+'</span>'+\n '<div class=\"tool-track\"><div class=\"tool-fill\" style=\"width:'+pct+'%\"></div></div>'+\n '<span class=\"tool-count\">'+fmt(e[1])+'</span></div>';\n }).join('');\n}\nfunction renderTools(tools) {\n var el = document.getElementById('tools-body');\n if (el) el.innerHTML = buildToolsHtml(tools);\n}\n\n// \u2500\u2500 Live Log feed \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Shows the REAL compression log lines (e.g. \"[squeezr/det] Deterministic:\n// -32,323 chars (~9235 tokens) across 54 block(s)\"), newest at the BOTTOM.\nvar llMaxId = 0;\nfunction llTimeAgo(ts) {\n var s = Math.max(0, Math.round((Date.now() - ts) / 1000));\n if (s < 60) return s + 's';\n var m = Math.floor(s / 60);\n if (m < 60) return m + 'm';\n return Math.floor(m / 60) + 'h';\n}\n// Map the \"[squeezr/xxx]\" tag to a colour class.\nfunction llTagClass(tag) {\n if (/dedup/.test(tag)) return 'dedup';\n if (/ai|haiku|gpt|gemini|zest|ollama/.test(tag)) return 'ai';\n if (/tool-desc|mcp/.test(tag)) return 'tooldesc';\n if (/det/.test(tag)) return 'det';\n return 'other';\n}\nfunction renderLiveLog(activity) {\n var el = document.getElementById('livelog-body');\n if (!el) return;\n if (!activity || !activity.length) {\n if (!el.querySelector('.ll-list')) el.innerHTML = '<div class=\"ll-empty\">Waiting for activity\u2026</div>';\n return;\n }\n // Oldest \u2192 newest so the newest ends up at the bottom of the column.\n var rows = activity.slice(-30);\n var html = rows.map(function(e){\n var isNew = e.id > llMaxId && llMaxId > 0;\n // Split \"[tag] rest of the line\" so we can colour the tag.\n var m = String(e.text).match(/^([[^]]*])s*([sS]*)$/);\n var tag = m ? m[1] : '';\n var rest = m ? m[2] : String(e.text);\n var cls = llTagClass(tag);\n // Highlight the \"-N chars\" / \"~N tokens\" numbers.\n var msg = esc(rest).replace(/(-?[d.,]+s*(?:chars|tokens))/g, '<span class=\"num\">$1</span>');\n return '<div class=\"ll-row' + (isNew ? ' ll-new' : '') + '\" data-id=\"' + e.id + '\" title=\"' + esc(e.text) + '\">' +\n '<span class=\"ll-tag ' + cls + '\">' + esc(tag) + '</span>' +\n '<span class=\"ll-msg\">' + msg + '</span>' +\n '<span class=\"ll-time\">' + llTimeAgo(e.ts) + '</span>' +\n '</div>';\n }).join('');\n el.innerHTML = '<div class=\"ll-list\">' + html + '</div>';\n // Always keep the newest line (last child, at the bottom) in view: scroll to the\n // bottom so older lines scroll up and out of the top edge (terminal-tail style).\n el.scrollTop = el.scrollHeight;\n for (var i = 0; i < rows.length; i++) if (rows[i].id > llMaxId) llMaxId = rows[i].id;\n}\n\nfunction renderLimits(lim) {\n var el = document.getElementById('limits-body');\n if (!lim || typeof lim !== 'object') {\n el.innerHTML = '<div class=\"lim-nodata\">No limit data yet.</div>';\n return;\n }\n var claudeRows = [];\n var openaiRows = [];\n var rows = claudeRows; // default alias for Claude section\n\n // \u2500\u2500 Claude / Anthropic \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 var a = lim.anthropic;\n if (a) {\n // unified = Claude Code Max / subscription plan rate limits\n var u = a.unified;\n if (u && u.hasData) {\n var p5 = Math.round(u.fiveHourUtilization * 100);\n var p7 = Math.round(u.sevenDayUtilization * 100);\n var c5 = p5 > 90 ? 'crit' : p5 > 70 ? 'warn' : 'ok';\n var c7 = p7 > 90 ? 'crit' : p7 > 70 ? 'warn' : 'ok';\n var reset5 = u.fiveHourResetEpoch ? new Date(u.fiveHourResetEpoch).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '';\n var reset7 = u.sevenDayResetEpoch ? new Date(u.sevenDayResetEpoch).toLocaleDateString([], {month:'short',day:'numeric'}) : '';\n rows.push(limRow('Claude 5h window', p5, c5, p5 + '% used' + (reset5 ? ' \u00B7 resets ' + reset5 : '')));\n rows.push(limRow('Claude 7d window', p7, c7, p7 + '% used' + (reset7 ? ' \u00B7 resets ' + reset7 : '')));\n }\n // rl = standard API rate limit headers (available with API key, not subscription)\n var rl = a.rl;\n if (rl && rl.hasData) {\n if (rl.tokensLimit > 0) {\n var used = rl.tokensLimit - rl.tokensRemaining;\n var pp = Math.round(used / rl.tokensLimit * 100);\n rows.push(limRow('Claude tokens/min', pp, pp > 90 ? 'crit' : pp > 70 ? 'warn' : 'ok',\n fmt(used) + ' / ' + fmt(rl.tokensLimit)));\n }\n if (rl.requestsLimit > 0) {\n var usedR = rl.requestsLimit - rl.requestsRemaining;\n var ppR = Math.round(usedR / rl.requestsLimit * 100);\n rows.push(limRow('Claude req/min', ppR, ppR > 90 ? 'crit' : ppR > 70 ? 'warn' : 'ok',\n usedR + ' / ' + rl.requestsLimit));\n }\n }\n }\n\n // \u2500\u2500 OpenAI / Codex \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 rows = openaiRows; // switch alias\n var o = lim.openai;\n if (o) {\n var os = o.session;\n if (os && os.hasData && os.primary) {\n var pp2 = os.primary.usedPercent || 0;\n var c2 = pp2 > 90 ? 'crit' : pp2 > 70 ? 'warn' : 'ok';\n var resetTs = os.primary.resetsAt ? new Date(os.primary.resetsAt * 1000).toLocaleDateString([],{month:'short',day:'numeric'}) : '';\n rows.push(limRow('Codex ' + (os.primary.windowDurationMins >= 10080 ? '7d' : os.primary.windowDurationMins + 'min'),\n pp2, c2, pp2 + '% used' + (resetTs ? ' \u00B7 resets ' + resetTs : '')));\n }\n if (o.usage && o.usage.inputSession) {\n rows.push('<div style=\"margin-top:10px;padding-top:10px;border-top:1px solid var(--border)\">' +\n '<div style=\"font-size:11px;color:var(--text3);margin-bottom:4px\">Codex tokens this session</div>' +\n '<div style=\"font-size:13px;color:var(--text2)\">' +\n 'In: <strong style=\"color:var(--text)\">' + fmt(o.usage.inputSession) + '</strong> ' +\n 'Out: <strong style=\"color:var(--text)\">' + fmt(o.usage.outputSession) + '</strong>' +\n '</div></div>');\n }\n }\n\n if (!claudeRows.length && !openaiRows.length) {\n el.innerHTML = '<div class=\"lim-nodata\">No limit data yet \u2014 appears after first API call.</div>';\n return;\n }\n\n var col = function(title, content) {\n return '<div>' +\n '<div style=\"font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:10px\">' + title + '</div>' +\n (content || '<div class=\"lim-nodata\" style=\"padding:0\">No data</div>') +\n '</div>';\n };\n\n el.innerHTML = '<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:20px\">' +\n col('Claude', claudeRows.length ? '<div class=\"limits-grid\">' + claudeRows.join('') + '</div>' : null) +\n col('Codex / OpenAI', openaiRows.length ? '<div class=\"limits-grid\">' + openaiRows.join('') + '</div>' : null) +\n '</div>';\n}\n\nfunction limRow(name, pct, cls, label) {\n return '<div class=\"lim-row\">'+\n '<span class=\"lim-name\">'+esc(name)+'</span>'+\n '<div class=\"lim-track\"><div class=\"lim-fill '+cls+'\" style=\"width:'+pct+'%\"></div></div>'+\n '<span class=\"lim-text\">'+esc(label)+'</span></div>';\n}\n\n// \u2500\u2500 Pricing table ($/1M tokens) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nvar PRICING = {\n // \u2500\u2500 Claude (verified May 2026 \u2014 claude.com/pricing) \u2500\u2500\n 'claude-opus-4-7': { input: 5, output: 25 }, // current flagship, Apr 2026\n 'claude-opus-4-6': { input: 5, output: 25 },\n 'claude-opus-4-5': { input: 5, output: 25 },\n 'claude-opus-4-1': { input: 15, output: 75 }, // legacy\n 'claude-opus-4': { input: 15, output: 75 }, // legacy\n 'claude-opus-3': { input: 15, output: 75 }, // legacy\n 'claude-sonnet-4-6': { input: 3, output: 15 },\n 'claude-sonnet-4-5': { input: 3, output: 15 },\n 'claude-sonnet-4': { input: 3, output: 15 },\n 'claude-3-7-sonnet': { input: 3, output: 15 },\n 'claude-3-5-sonnet': { input: 3, output: 15 },\n 'claude-3-sonnet': { input: 3, output: 15 },\n 'claude-haiku-4-5': { input: 1, output: 5 }, // current\n 'claude-haiku-3-5': { input: 0.8, output: 4 },\n 'claude-3-5-haiku': { input: 0.8, output: 4 },\n 'claude-3-haiku': { input: 0.25, output: 1.25 },\n // \u2500\u2500 OpenAI (verified May 2026 \u2014 openai.com/api/pricing) \u2500\u2500\n 'gpt-5-5-pro': { input: 30, output: 180 }, // research-grade\n 'gpt-5-5': { input: 5, output: 30 }, // current flagship\n 'gpt-5-4-pro': { input: 30, output: 180 },\n 'gpt-5-4': { input: 2.5, output: 15 }, // production workhorse\n 'gpt-5-4-mini': { input: 0.75, output: 4.5 },\n 'gpt-5-4-nano': { input: 0.20, output: 1.25 },\n 'gpt-5-3-codex': { input: 2.5, output: 15 },\n 'gpt-4o': { input: 2.5, output: 10 },\n 'gpt-4o-mini': { input: 0.15, output: 0.6 },\n 'gpt-4-1': { input: 2, output: 8 },\n 'gpt-4-1-mini': { input: 0.40, output: 1.6 },\n 'gpt-4-1-nano': { input: 0.10, output: 0.4 },\n 'gpt-4-turbo': { input: 10, output: 30 },\n 'gpt-4': { input: 30, output: 60 },\n 'o3': { input: 2, output: 8 }, // cut from $10 to $2 in 2026\n 'o3-mini': { input: 1.1, output: 4.4 },\n 'o4-mini': { input: 1.1, output: 4.4 },\n 'o1': { input: 15, output: 60 }, // legacy\n 'o1-mini': { input: 3, output: 12 },\n 'o1-pro': { input: 150, output: 600 },\n 'codex-mini-latest': { input: 1.5, output: 6 },\n // \u2500\u2500 Gemini \u2500\u2500\n 'gemini-2.5-pro': { input: 1.25, output: 10 },\n 'gemini-2.5-flash': { input: 0.075,output: 0.3 },\n 'gemini-2.0-flash': { input: 0.1, output: 0.4 },\n 'gemini-2.0-flash-lite': { input: 0.075,output: 0.3 },\n 'gemini-1.5-pro': { input: 1.25, output: 5 },\n 'gemini-1.5-flash': { input: 0.075,output: 0.3 },\n};\nvar DEFAULT_PRICE_INPUT = 3; // Claude Sonnet fallback\n\nfunction getModelPrice(model) {\n if (!model) return DEFAULT_PRICE_INPUT;\n var m = model.toLowerCase();\n // exact match first\n if (PRICING[m]) return PRICING[m].input;\n // prefix match (handles date-stamped variants like claude-sonnet-4-5-20251101)\n for (var key in PRICING) {\n if (m.startsWith(key) || m.includes(key)) return PRICING[key].input;\n }\n return DEFAULT_PRICE_INPUT;\n}\n\nfunction calcCostFromModels(byModel, getOriginal) {\n // If we have per-model breakdown, weight by actual model price\n if (!byModel || !Object.keys(byModel).length) return null;\n var totalCost = 0;\n var savedCost = 0;\n for (var model in byModel) {\n var data = byModel[model];\n var price = getModelPrice(model) / 1000000; // $/token\n var origTok = getOriginal ? data.original_tokens : 0;\n var savedTok = data.saved_tokens || 0;\n totalCost += origTok * price;\n savedCost += savedTok * price;\n }\n return { totalCost: totalCost, savedCost: savedCost };\n}\n\n// \u2500\u2500 Model breakdown \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Build HTML for a by_model object \u2014 used by overview (all-time) and savings (per-period)\nfunction buildModelHtml(byModel) {\n var noData = '<span style=\"font-size:13px;color:var(--text3)\">No model data for this period.</span>';\n if (!byModel || !Object.keys(byModel).length) return noData;\n var rows = Object.entries(byModel)\n .filter(function(e){ return e[1].requests > 0; })\n .sort(function(a,b){ return b[1].saved_tokens - a[1].saved_tokens; });\n if (!rows.length) return noData;\n var maxSaved = rows[0][1].saved_tokens || 1;\n return rows.map(function(e){\n var model = e[0];\n var data = e[1];\n var priceIn = getModelPrice(model);\n var savedCost = data.saved_tokens * priceIn / 1000000;\n var origCost = data.original_tokens * priceIn / 1000000;\n var pct = Math.round((data.saved_tokens / maxSaved) * 100);\n var priceLabel = priceIn === DEFAULT_PRICE_INPUT ? '$' + priceIn + '/1M (est.)' : '$' + priceIn + '/1M input';\n return '<div style=\"margin-bottom:14px\">' +\n '<div style=\"display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px\">' +\n '<span style=\"font-size:13px;font-weight:600;color:var(--text);font-family:monospace\">' + esc(model) + '</span>' +\n '<span style=\"font-size:12px;color:var(--text3)\">' + priceLabel + '</span>' +\n '</div>' +\n '<div style=\"display:flex;justify-content:space-between;align-items:baseline;margin-bottom:5px\">' +\n '<span style=\"font-size:12px;color:var(--text2)\">' + fmt(data.saved_tokens) + ' tokens saved \u00B7 ' + data.savings_pct + '%</span>' +\n '<span style=\"font-size:12px;color:var(--brand2);font-weight:600\">' + fmtUsd(savedCost) + ' saved</span>' +\n '</div>' +\n '<div style=\"height:6px;background:var(--surface3);border-radius:3px;overflow:hidden;margin-bottom:3px\">' +\n '<div style=\"height:100%;width:' + pct + '%;background:var(--brand);border-radius:3px;transition:width .4s\"></div>' +\n '</div>' +\n '<div style=\"font-size:11px;color:var(--text3)\">' +\n data.requests + ' req \u00B7 without Squeezr: ' + fmtUsd(origCost) + ' \u00B7 with: ' + fmtUsd(origCost - savedCost) +\n '</div>' +\n '</div>';\n }).join('');\n}\n\n// Build the \"compression cost\" rows \u2014 what each compression backend (Haiku,\n// GPT-mini, etc.) actually SPENT in tokens this session. Shown below the By Model\n// savings so the user sees the cost side of the AI compression layer.\nfunction buildCompressionCostHtml(aiByModel) {\n if (!aiByModel || !Object.keys(aiByModel).length) return '';\n var rows = Object.entries(aiByModel)\n .filter(function(e){ return (e[1].calls || 0) > 0; })\n .sort(function(a,b){ return (b[1].inputTokens + b[1].outputTokens) - (a[1].inputTokens + a[1].outputTokens); });\n if (!rows.length) return '';\n var html = '<div style=\"margin-top:14px;padding-top:12px;border-top:1px dashed var(--surface3)\">' +\n '<div style=\"font-size:11px;color:var(--text3);margin-bottom:10px;text-transform:uppercase;letter-spacing:.5px\">Compression cost (what the AI layer spends)</div>';\n html += rows.map(function(e){\n var model = e[0];\n var data = e[1];\n var spentTok = (data.inputTokens || 0) + (data.outputTokens || 0);\n var isLocal = model.indexOf('local:') === 0;\n var priceIn = isLocal ? 0 : getModelPrice(model);\n var spentCost = spentTok * priceIn / 1000000;\n return '<div style=\"margin-bottom:10px\">' +\n '<div style=\"display:flex;justify-content:space-between;align-items:baseline;margin-bottom:3px\">' +\n '<span style=\"font-size:12px;font-weight:600;color:var(--text);font-family:monospace\">' + esc(model.replace('local:', '')) + (isLocal ? ' <span style=\"color:var(--brand2);font-weight:400\">(local \u00B7 free)</span>' : '') + '</span>' +\n '<span style=\"font-size:12px;color:var(--text2)\">' + (isLocal ? '\u2014' : fmtUsd(spentCost)) + ' spent</span>' +\n '</div>' +\n '<div style=\"font-size:11px;color:var(--text3)\">' +\n data.calls + ' call' + (data.calls !== 1 ? 's' : '') + ' \u00B7 ' + fmt(spentTok) + ' tokens (' + fmt(data.inputTokens) + ' in / ' + fmt(data.outputTokens) + ' out)' +\n '</div>' +\n '</div>';\n }).join('');\n html += '</div>';\n return html;\n}\nfunction renderModelBreakdown(byModel, aiByModel) {\n var el = document.getElementById('model-body');\n if (el) el.innerHTML = buildModelHtml(byModel) + buildCompressionCostHtml(aiByModel);\n}\n\n// \u2500\u2500 Client breakdown (#8) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nvar clientOpen = false;\nvar CLIENT_LABELS = {\n claude_code: 'Claude Code',\n claude_desktop: 'Claude Desktop',\n aider: 'Aider',\n opencode: 'OpenCode',\n codex_desktop: 'Codex Desktop',\n cursor: 'Cursor',\n continue: 'Continue.dev',\n cline: 'Cline / Roo',\n windsurf: 'Windsurf',\n openai_other: 'OpenAI (other)',\n gemini: 'Gemini CLI',\n mitm: 'Codex CLI',\n};\n\nfunction setBackend(name) {\n fetch('/squeezr/backend', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ backend: name }),\n })\n .then(function(r){ return r.json(); })\n .then(function(d){ updateBackendButtons(d.backend); })\n .catch(function(){});\n}\n\nfunction updateBackendButtons(active) {\n var btns = document.querySelectorAll('button[data-backend]');\n for (var i = 0; i < btns.length; i++) {\n var b = btns[i];\n var isActive = b.getAttribute('data-backend') === active;\n b.className = 'mode-btn' + (isActive ? ' active' : '');\n }\n // Warn whenever the active backend can route to Haiku (auto/haiku). On an OAuth\n // subscription token Squeezr blocks it, but the user should understand why.\n var warn = document.getElementById('backend-warn');\n if (warn) warn.style.display = (active === 'haiku' || active === 'auto') ? 'block' : 'none';\n}\n\nfunction toggleNativeCompact() {\n fetch('/squeezr/native-compact', { method: 'POST' })\n .then(function(r){ return r.json(); })\n .then(function(d){\n var btn = document.getElementById('native-compact-btn');\n if (btn) {\n btn.textContent = d.enabled ? 'ON' : 'OFF';\n btn.style.background = d.enabled ? 'var(--brand)' : '';\n btn.style.color = d.enabled ? 'white' : '';\n }\n })\n .catch(function(){});\n}\n\nfunction toggleClientBreakdown() {\n clientOpen = !clientOpen;\n document.getElementById('cli-breakdown').style.display = clientOpen ? '' : 'none';\n document.getElementById('cli-chevron').style.transform = clientOpen ? 'rotate(180deg)' : '';\n}\n\n// Build HTML for a by_client object \u2014 used by overview, settings and savings (per-period)\nfunction buildClientHtml(byClient) {\n var noData = '<span style=\"font-size:13px;color:var(--text3)\">No client data for this period.</span>';\n if (!byClient || !Object.keys(byClient).length) return noData;\n var rows = Object.entries(byClient)\n .filter(function(e){ return e[1].requests > 0; })\n .sort(function(a,b){ return b[1].saved_tokens - a[1].saved_tokens; });\n if (!rows.length) return noData;\n var maxSaved = rows[0][1].saved_tokens || 1;\n return rows.map(function(e){\n var label = CLIENT_LABELS[e[0]] || e[0];\n var data = e[1];\n var pct = Math.round((data.saved_tokens / maxSaved) * 100);\n return '<div style=\"margin-bottom:12px\">' +\n '<div style=\"display:flex;justify-content:space-between;align-items:baseline;margin-bottom:5px\">' +\n '<span style=\"font-size:13px;font-weight:600;color:var(--text)\">' + esc(label) + '</span>' +\n '<span style=\"font-size:12px;color:var(--brand2);font-weight:600\">' + fmt(data.saved_tokens) + ' saved <span style=\"color:var(--text3);font-weight:400\">\u00B7 ' + data.savings_pct + '%</span></span>' +\n '</div>' +\n '<div style=\"height:6px;background:var(--surface3);border-radius:3px;overflow:hidden;margin-bottom:3px\">' +\n '<div style=\"height:100%;width:' + pct + '%;background:var(--brand);border-radius:3px;transition:width .4s\"></div>' +\n '</div>' +\n '<div style=\"font-size:11px;color:var(--text3)\">' + data.requests + ' req \u00B7 ~' + fmt(data.original_tokens) + ' tokens in</div>' +\n '</div>';\n }).join('');\n}\n\nfunction renderClientBreakdown(byClient) {\n var html = buildClientHtml(byClient);\n var elOverview = document.getElementById('client-body-overview');\n var elSettings = document.getElementById('cli-breakdown-body');\n if (elOverview) elOverview.innerHTML = html;\n if (elSettings) elSettings.innerHTML = html;\n}\n\n// \u2500\u2500 Savings by compression type \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// breakdown values are CHARS saved per technique (all-time, persisted in stats.json).\nvar BREAKDOWN_LABELS = {\n tool_results_det: 'Deterministic (tool output)',\n tool_results_ai: 'AI compression',\n read_dedup: 'Repeated-read dedup',\n tool_desc: 'Tool descriptions',\n mcp_filter: 'MCP tool filtering',\n stale_turns: 'Stale turn summaries',\n skill_dedup: 'Skill/plugin dedup',\n system_prompt: 'System prompt',\n};\nfunction renderBreakdown(bd, netTokens) {\n var el = document.getElementById('breakdown-body');\n if (!el) return;\n if (!bd || typeof bd !== 'object') {\n el.innerHTML = '<div style=\"font-size:13px;color:var(--text3)\">No data yet.</div>';\n return;\n }\n // Build rows (chars \u2192 tokens). Skip overhead/ai_calls (not savings) and zeros.\n var rows = Object.keys(BREAKDOWN_LABELS)\n .map(function(k){ return { key: k, label: BREAKDOWN_LABELS[k], chars: bd[k] || 0 }; })\n .filter(function(r){ return r.chars > 0; })\n .sort(function(a,b){ return b.chars - a.chars; });\n if (!rows.length) {\n el.innerHTML = '<div style=\"font-size:13px;color:var(--text3)\">No savings recorded yet.</div>';\n return;\n }\n var maxChars = rows[0].chars;\n var totalTok = rows.reduce(function(s,r){ return s + Math.round(r.chars/3.5); }, 0);\n var html = rows.map(function(r){\n var tok = Math.round(r.chars / 3.5);\n var pct = maxChars > 0 ? Math.round(r.chars / maxChars * 100) : 0;\n return '<div style=\"margin-bottom:12px\">' +\n '<div style=\"display:flex;justify-content:space-between;align-items:baseline;margin-bottom:5px\">' +\n '<span style=\"font-size:13px;font-weight:600;color:var(--text)\">' + esc(r.label) + '</span>' +\n '<span style=\"font-size:12px;color:var(--brand2);font-weight:600\">' + fmt(tok) + ' tokens</span>' +\n '</div>' +\n '<div style=\"height:6px;background:var(--surface3);border-radius:3px;overflow:hidden\">' +\n '<div style=\"height:100%;width:' + pct + '%;background:var(--brand);border-radius:3px;transition:width .4s\"></div>' +\n '</div>' +\n '</div>';\n }).join('');\n // The per-technique numbers are GROSS (each counts what it removed on its own).\n // They overlap and don't subtract the [squeezr:ID] tag overhead, so their sum is\n // larger than the real net saving. Show the net (same as the hero card) as the\n // authoritative total, and label the gross sum separately so it's not confusing.\n var grossTok = totalTok;\n var netTok = (netTokens != null && netTokens > 0) ? netTokens : grossTok;\n html += '<div style=\"margin-top:6px;padding-top:10px;border-top:1px solid var(--surface3)\">' +\n '<div style=\"display:flex;justify-content:space-between;font-size:12px;margin-bottom:3px\">' +\n '<span style=\"color:var(--text2);font-weight:600\">Net saved (real)</span>' +\n '<span style=\"color:var(--brand2);font-weight:700\">' + fmt(netTok) + ' tokens</span></div>' +\n '<div style=\"display:flex;justify-content:space-between;font-size:11px;color:var(--text3)\">' +\n '<span>gross per-technique (overlaps + tag overhead)</span>' +\n '<span>' + fmt(grossTok) + '</span></div>' +\n '</div>';\n el.innerHTML = html;\n}\n\nfunction updateMode(mode, byp) {\n var mb = document.getElementById('mode-badge');\n mb.textContent = 'mode: ' + mode;\n mb.className = 'badge' + (mode === 'off' ? ' red' : ' green');\n\n var bb = document.getElementById('bypass-badge');\n bb.textContent = byp ? 'bypass: on' : 'bypass: off';\n bb.className = 'badge' + (byp ? ' yellow' : '');\n\n document.querySelectorAll('.mode-btn').forEach(function(btn) {\n btn.className = 'mode-btn' + (btn.dataset.mode === mode ? (mode === 'off' ? ' active-off' : ' active') : '');\n });\n document.getElementById('bypass-btn').className = 'bypass-btn' + (byp ? ' active' : '');\n}\n\n// \u2500\u2500 Controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 setMode(mode) {\n fetch('/squeezr/config', {\n method:'POST', headers:{'Content-Type':'application/json'},\n body: JSON.stringify({mode: mode})\n }).then(function(r){\n if (r.ok && lastStats) { lastStats.mode = mode; updateMode(mode, !!(lastStats.bypass || lastStats.bypassed)); }\n });\n}\n\nfunction toggleBypass() {\n fetch('/squeezr/bypass', {method:'POST'}).then(function(r){ if(r.ok) poll(); });\n}\n\nfunction toggleAiCompression() {\n fetch('/squeezr/ai-compression', {method:'POST'}).then(function(r){ if(r.ok) poll(); });\n}\n\n// \u2500\u2500 Connection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nvar pollTimer = null, sseOk = false;\n\nfunction poll() {\n fetch('/squeezr/stats')\n .then(function(r){ return r.json(); })\n .then(function(d){\n setConn(true);\n try { render(d); } catch(e){ console.error('[squeezr] render error:', e); }\n })\n .catch(function(){ setConn(false); });\n}\n\nfunction setConn(ok) {\n document.getElementById('conn-dot').className = 'conn-dot ' + (ok ? 'online' : 'offline');\n document.getElementById('conn-label').textContent = ok ? 'Connected' : 'Offline';\n}\n\nfunction startPoll() {\n if (!pollTimer) { pollTimer = setInterval(poll, 5000); poll(); }\n}\n\nfunction connect() {\n var es;\n try { es = new EventSource('/squeezr/events'); }\n catch(e) { setConn(false); startPoll(); return; }\n\n var timer = setTimeout(function(){ if (!sseOk){ es.close(); setConn(false); startPoll(); } }, 6000);\n\n es.onopen = function(){ clearTimeout(timer); sseOk = true; setConn(true); clearInterval(pollTimer); pollTimer = null; };\n es.onmessage = function(ev){\n clearTimeout(timer);\n if (!sseOk){ sseOk = true; setConn(true); clearInterval(pollTimer); pollTimer = null; }\n try { render(JSON.parse(ev.data)); } catch(e){}\n };\n es.addEventListener('stats', function(ev){ try { render(JSON.parse(ev.data)); } catch(e){} });\n es.onerror = function(){\n clearTimeout(timer); sseOk = false; es.close(); setConn(false); startPoll();\n setTimeout(connect, 10000);\n };\n}\n\n// \u2500\u2500 Actions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 showResult(id, cls, msg) {\n var el = document.getElementById('action-result-' + id);\n if (!el) return;\n el.className = 'action-result ' + cls;\n el.textContent = msg;\n el.style.display = 'block';\n}\n\nfunction runAction(action) {\n if (action === 'status') {\n fetch('/squeezr/health').then(function(r) { return r.json(); }).then(function(h) {\n var msg = 'version: ' + (h.version || '?');\n if (h.uptime != null) msg += ' | uptime: ' + fmtUptime(h.uptime);\n if (h.mode) msg += ' | mode: ' + h.mode;\n showResult('status', 'ok', msg);\n }).catch(function(e) {\n showResult('status', 'err', 'Error: ' + e.message);\n });\n } else if (action === 'restart') {\n showResult('restart', 'ok', 'Restarting\u2026');\n fetch('/squeezr/control/restart', {method:'POST'}).then(function(r) {\n if (r.ok) {\n showResult('restart', 'ok', 'Restarted \u2014 reconnecting in 3s');\n setTimeout(function(){ connect(); loadSavings(); }, 3000);\n } else {\n showResult('restart', 'err', 'Run in terminal: squeezr restart');\n }\n }).catch(function() {\n showResult('restart', 'err', 'Run in terminal: squeezr restart');\n });\n } else if (action === 'stop') {\n fetch('/squeezr/control/stop', {method:'POST'}).then(function(r) {\n if (r.ok) {\n showResult('stop', 'ok', 'Proxy stopped');\n } else {\n showResult('stop', 'err', 'Run in terminal: squeezr stop');\n }\n }).catch(function() {\n showResult('stop', 'err', 'Run in terminal: squeezr stop');\n });\n } else if (action === 'update') {\n showResult('update', 'ok', 'Run in terminal: squeezr update');\n } else if (action === 'ports') {\n var httpVal = document.getElementById('inp-http-port').value.trim();\n var mitmVal = document.getElementById('inp-mitm-port').value.trim();\n var httpN = parseInt(httpVal);\n var mitmN = parseInt(mitmVal);\n if (!httpN || httpN < 1024 || httpN > 65535 || !mitmN || mitmN < 1024 || mitmN > 65535) {\n showResult('ports', 'err', 'Invalid ports \u2014 must be between 1024 and 65535');\n return;\n }\n if (httpN === mitmN) {\n showResult('ports', 'err', 'HTTP and MITM ports must be different');\n return;\n }\n fetch('/squeezr/ports', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ port: httpN, mitm_port: mitmN })\n }).then(function(r) {\n if (r.ok) {\nshowResult('ports', 'ok', 'Ports saved to squeezr.toml \u2014 run: squeezr restart');\n } else {\n r.text().then(function(t) { showResult('ports', 'err', 'Failed: ' + t); });\n }\n }).catch(function(e) { showResult('ports', 'err', e.message); });\n }\n}\n\n// \u2500\u2500 Version check \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Returns 1 if a > b, -1 if a < b, 0 if equal. Compares numeric major.minor.patch;\n// any non-numeric prerelease tail makes the version \"lower\" (so 1.46.0 > 1.46.0-rc.1).\nfunction compareSemver(a, b) {\n function parse(v) {\n var m = String(v || '').match(/^(d+).(d+).(d+)(?:-(.+))?/);\n if (!m) return [0, 0, 0, ''];\n return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10), m[4] || ''];\n }\n var pa = parse(a), pb = parse(b);\n for (var i = 0; i < 3; i++) {\n if (pa[i] > pb[i]) return 1;\n if (pa[i] < pb[i]) return -1;\n }\n if (pa[3] && !pb[3]) return -1;\n if (!pa[3] && pb[3]) return 1;\n return 0;\n}\n\nfunction checkLatestVersion() {\n fetch('/squeezr/health').then(function(r) { return r.json(); }).then(function(h) {\n var current = h.version;\n fetch('https://registry.npmjs.org/squeezr-ai/latest')\n .then(function(r) { return r.json(); }).then(function(npm) {\n var latest = npm.version;\n if (latest && current && compareSemver(latest, current) > 0) {\n var banner = document.getElementById('update-banner');\n document.getElementById('update-text').textContent = 'v' + current + ' \u2192 v' + latest;\n banner.style.display = 'flex';\n }\n }).catch(function(){});\n }).catch(function(){});\n}\n\n// \u2500\u2500 Savings 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\nvar savingsPeriod = 'day';\nvar savingsOffset = 0; // 0 = current period, -1 = previous, +1 = next\nvar savingsCache = null;\n\nfunction setSavingsPeriod(p) {\n savingsPeriod = p;\n savingsOffset = 0; // reset to current when changing scale\n ['day','week','month','all'].forEach(function(k){\n document.getElementById('period-' + k).className = 'mode-btn' + (k === p ? ' active' : '');\n });\n if (savingsCache) renderSavingsData(savingsCache);\n}\n\nfunction navigatePeriod(dir) {\n if (dir === 0) { savingsOffset = 0; }\n else { savingsOffset += dir; if (savingsOffset > 0) savingsOffset = 0; }\n if (savingsCache) renderSavingsData(savingsCache);\n}\n\n// Get [start, end] timestamps for the selected period+offset\nfunction getPeriodRange() {\n var now = new Date();\n var start, end, label;\n if (savingsPeriod === 'day') {\n var d = new Date(now); d.setDate(d.getDate() + savingsOffset);\n start = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n end = start + 86400000;\n label = d.toLocaleDateString([], {weekday:'short', day:'numeric', month:'short'});\n if (savingsOffset === 0) label = 'Today \u00B7 ' + label;\n else if (savingsOffset === -1) label = 'Yesterday \u00B7 ' + label;\n } else if (savingsPeriod === 'week') {\n var d = new Date(now); d.setDate(d.getDate() - d.getDay() + (savingsOffset * 7));\n start = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n end = start + 7 * 86400000;\n var endDate = new Date(end - 1);\n label = new Date(start).toLocaleDateString([], {day:'numeric', month:'short'}) + ' \u2013 ' + endDate.toLocaleDateString([], {day:'numeric', month:'short'});\n } else if (savingsPeriod === 'month') {\n var d = new Date(now.getFullYear(), now.getMonth() + savingsOffset, 1);\n start = d.getTime();\n end = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();\n label = d.toLocaleDateString([], {month:'long', year:'numeric'});\n } else { // all\n start = 0;\n end = Number.MAX_SAFE_INTEGER;\n label = 'All time';\n }\n return { start: start, end: end, label: label };\n}\n\nfunction loadSavings() {\n fetch('/squeezr/history').then(function(r){ return r.json(); }).then(function(d){\n savingsCache = d;\n renderSavingsData(d);\n }).catch(function(){\n document.getElementById('savings-chart').innerHTML = '<div class=\"lim-nodata\">Could not load history.</div>';\n });\n}\n\nfunction renderSavingsData(d) {\n var sessions = (d.sessions || []).slice();\n if (d.current && d.current.requests > 0) sessions.push(d.current);\n var range = getPeriodRange();\n var labelEl = document.getElementById('period-label');\n if (labelEl) labelEl.textContent = range.label;\n var filtered = sessions.filter(function(s){ return s.startTime >= range.start && s.startTime < range.end && s.savedTokens != null; });\n\n // Hero stats\n var totalSaved = 0, totalReqs = 0, totalOrig = 0, hasOrigData = true;\n filtered.forEach(function(s){\n totalSaved += s.savedTokens||0;\n totalReqs += s.requests||0;\n if (s.originalChars) {\n totalOrig += Math.round(s.originalChars / 3.5);\n } else {\n hasOrigData = false; // at least one session without original data\n }\n });\n var avgPct = totalOrig > 0 ? Math.round(totalSaved / (totalSaved + (totalOrig - totalSaved)) * 100) : 0;\n\n // Keep the \"Day\" headline identical to the Overview hero (same date-stamped\n // today_* source), instead of summing history sessions which over-count across\n // restarts. Fixes the \"Savings says 22M but Overview says 19M\" inconsistency.\n if (savingsPeriod === 'day' && lastStats && lastStats.today) {\n totalSaved = lastStats.today.saved_tokens || totalSaved;\n totalOrig = lastStats.today.original_tokens || totalOrig;\n totalReqs = lastStats.today.requests || totalReqs;\n hasOrigData = true;\n avgPct = lastStats.today.savings_pct != null ? Math.round(lastStats.today.savings_pct) : avgPct;\n }\n\n // Cost: use model-weighted if available from current stats, else flat $3/1M\n // Compute cost for this period using the blended $/saved-token rate from all-time model data.\n // This avoids the \"scale\" trick which produced inconsistent numbers vs the By Model section.\n var svModelCosts = (lastStats && lastStats.by_model) ? calcCostFromModels(lastStats.by_model, true) : null;\n var svCost, svCostNote;\n if (svModelCosts && svModelCosts.savedCost > 0) {\n var allTimeSavedTok = 0;\n if (lastStats && lastStats.by_model) {\n Object.keys(lastStats.by_model).forEach(function(m){ allTimeSavedTok += (lastStats.by_model[m].saved_tokens || 0); });\n }\n // Blended rate = all-time saved cost / all-time saved tokens \u2192 apply to period tokens\n var blendedRate = allTimeSavedTok > 0 ? svModelCosts.savedCost / allTimeSavedTok : 0.000003;\n svCost = totalSaved * blendedRate;\n svCostNote = 'model-weighted pricing';\n } else {\n svCost = totalSaved * 0.000003;\n svCostNote = 'est. at $3/1M tokens';\n }\ndocument.getElementById('sv-tokens').textContent = fmt(totalSaved);\n document.getElementById('sv-tokens-sub').textContent = hasOrigData && totalOrig > 0\n ? 'of ~' + fmt(totalOrig) + ' processed'\n : 'tokens saved';\n document.getElementById('sv-cost').textContent = fmtUsd(svCost);\n var svCostNoteEl = document.getElementById('sv-cost-note'); if(svCostNoteEl) svCostNoteEl.textContent = svCostNote;\n document.getElementById('sv-sessions').textContent = String(filtered.length);\n document.getElementById('sv-requests').textContent = totalReqs + ' requests';\n document.getElementById('sv-pct').textContent = avgPct > 0 ? avgPct + '%' : '\u2014';\n // Engine efficiency (% on compressed blocks) \u2014 only meaningful for \"Day\" (today's\n // date-stamped counters); other periods aren't tracked historically.\n var svEngineEl = document.getElementById('sv-engine');\n if (svEngineEl) {\n var svEff = (savingsPeriod === 'day' && lastStats && lastStats.today && lastStats.today.efficiency_pct != null) ? lastStats.today.efficiency_pct : null;\n svEngineEl.innerHTML = svEff != null && svEff > 0\n ? 'engine <strong style=\"color:var(--brand2)\">' + Math.round(svEff) + '%</strong> on compressed'\n : 'total saved (of all sent)';\n }\n // NOTE: the Overview hero is NOT synced from here anymore. It uses stats.json\n // (all-time net) as the single source of truth. This Savings page keeps its own\n // per-period view from history (which is fine for relative comparison between\n // periods, even if absolute sums over-count across restarts).\n\n // Chart title\n var titles = { day: 'Today (by session)', week: 'Last 7 days', month: 'Last 30 days', all: 'All time' };\n document.getElementById('savings-chart-title').textContent = titles[savingsPeriod] || '';\n\n // Bar chart: group by day for week/month/all, by session for day\n renderSavingsChart(filtered, savingsPeriod);\n\n // Per-period model/client breakdown \u2014 aggregated from the filtered sessions.\n // Session records store byModel/byClient since v1.56.0 (camelCase, token units).\n var aggModel = {}, aggClient = {};\n filtered.forEach(function(s){\n var bm = s.byModel || {};\n Object.keys(bm).forEach(function(m){\n if (!aggModel[m]) aggModel[m] = { requests: 0, original_tokens: 0, saved_tokens: 0, savings_pct: 0 };\n aggModel[m].requests += bm[m].requests || 0;\n aggModel[m].original_tokens += bm[m].originalTokens || 0;\n aggModel[m].saved_tokens += bm[m].savedTokens || 0;\n });\n var bc = s.byClient || {};\n Object.keys(bc).forEach(function(cl){\n if (!aggClient[cl]) aggClient[cl] = { requests: 0, original_tokens: 0, saved_tokens: 0, savings_pct: 0 };\n aggClient[cl].requests += bc[cl].requests || 0;\n aggClient[cl].original_tokens += bc[cl].originalTokens || 0;\n aggClient[cl].saved_tokens += bc[cl].savedTokens || 0;\n });\n });\n Object.keys(aggModel).forEach(function(m){\n var a = aggModel[m];\n a.savings_pct = a.original_tokens > 0 ? Math.round(a.saved_tokens / a.original_tokens * 1000) / 10 : 0;\n });\n Object.keys(aggClient).forEach(function(cl){\n var a = aggClient[cl];\n a.savings_pct = a.original_tokens > 0 ? Math.round(a.saved_tokens / a.original_tokens * 1000) / 10 : 0;\n });\n var mEl = document.getElementById('model-body-savings');\n if (mEl) mEl.innerHTML = buildModelHtml(aggModel);\n var cEl = document.getElementById('client-body-savings');\n if (cEl) cEl.innerHTML = buildClientHtml(aggClient);\n\n // Per-period Top Tools (byTool persists since the original SessionRecord)\n var aggTools = {};\n filtered.forEach(function(s){\n var bt = s.byTool || {};\n Object.keys(bt).forEach(function(t){\n if (!aggTools[t]) aggTools[t] = { count: 0, saved_tokens: 0 };\n aggTools[t].count += bt[t].count || 0;\n aggTools[t].saved_tokens += bt[t].savedTokens || 0;\n });\n });\n var tEl = document.getElementById('tools-body-savings');\n if (tEl) tEl.innerHTML = buildToolsHtml(aggTools);\n\n // Per-period AI compression + session cache (persisted since v1.61.0)\n var aiCalls = 0, aiInTok = 0, aiOutTok = 0, aiSavedTok = 0, cacheReuses = 0, cacheExpands = 0, withExtras = 0;\n filtered.forEach(function(s){\n if (s.aiUsage) {\n aiCalls += s.aiUsage.calls || 0;\n aiInTok += s.aiUsage.inputTokens || 0;\n aiOutTok += s.aiUsage.outputTokens || 0;\n aiSavedTok += s.aiUsage.savedTokens || 0;\n withExtras++;\n }\n if (s.sessionCache) {\n cacheReuses += s.sessionCache.reuses || 0;\n cacheExpands += s.sessionCache.expands || 0;\n }\n });\n var sv = function(id, v){ var e = document.getElementById(id); if(e) e.textContent = v; };\n var aiSpent = aiInTok + aiOutTok;\n sv('sv-ai-calls', fmt(aiCalls));\n sv('sv-ai-saved', aiSavedTok > 0 ? fmt(aiSavedTok) : '\u2014');\n sv('sv-ai-spent', aiSpent > 0 ? fmt(aiSpent) : '\u2014');\n var svNet = document.getElementById('sv-ai-net');\n if (svNet) {\n if (withExtras === 0) { svNet.textContent = 'No AI data for this period'; svNet.style.color = 'var(--text3)'; }\n else if (aiCalls === 0) { svNet.textContent = 'AI compression off \u2014 0 calls'; svNet.style.color = 'var(--text3)'; }\n else {\n var net = aiSavedTok - aiSpent;\n svNet.textContent = 'Net: ' + (net >= 0 ? '+' : '\u2212') + fmt(Math.abs(net)) + ' tokens (saved \u2212 spent)';\n svNet.style.color = net >= 0 ? 'var(--brand2)' : 'var(--red, #e5484d)';\n }\n }\n sv('sv-cache-reuses', fmt(cacheReuses));\n sv('sv-cache-expands', fmt(cacheExpands));\n sv('sv-cache-sessions', String(filtered.length));\n}\n\nfunction fmtY(v) {\n if (v >= 1000000) return (v/1000000).toFixed(1).replace(/.0$/,'') + 'M';\n if (v >= 1000) return Math.round(v/1000) + 'k';\n return String(Math.round(v));\n}\n\nfunction renderSavingsChart(sessions, period) {\n var el = document.getElementById('savings-chart');\n\n // Build empty buckets covering the full range (even if no sessions)\n var range = getPeriodRange();\n var entries = [];\n if (period === 'day') {\n // 5-hour windows (matches Claude Code's 5h rate limit window)\n var windows = [\n { start: 0, end: 5, label: '00\u201305' },\n { start: 5, end: 10, label: '05\u201310' },\n { start: 10, end: 15, label: '10\u201315' },\n { start: 15, end: 20, label: '15\u201320' },\n { start: 20, end: 24, label: '20\u201324' },\n ];\n windows.forEach(function(w, i) {\n var t = range.start + w.start * 3600000;\n entries.push({ key: i, saved: 0, reqs: 0, label: w.label, start: t, end: range.start + w.end * 3600000 });\n });\n } else if (period === 'week') {\n // 7 days\n for (var i = 0; i < 7; i++) {\n var t = range.start + i * 86400000;\n var d = new Date(t);\n entries.push({ key: i, saved: 0, reqs: 0, label: d.toLocaleDateString([], {weekday:'short', day:'numeric'}), start: t, end: t + 86400000 });\n }\n } else if (period === 'month') {\n // All days in the month\n var startD = new Date(range.start);\n var endD = new Date(range.end);\n var cursor = new Date(startD);\n while (cursor < endD) {\n var t = cursor.getTime();\n var nextDay = new Date(cursor); nextDay.setDate(nextDay.getDate() + 1);\n entries.push({ key: cursor.getDate(), saved: 0, reqs: 0, label: String(cursor.getDate()), start: t, end: nextDay.getTime() });\n cursor = nextDay;\n }\n } else { // all time \u2192 group by month, span from first session to current\n var minTs = sessions.length ? Math.min.apply(null, sessions.map(function(s){return s.startTime;})) : Date.now();\n var startD = new Date(new Date(minTs).getFullYear(), new Date(minTs).getMonth(), 1);\n var endD = new Date();\n var cursor = new Date(startD);\n while (cursor <= endD) {\n var t = cursor.getTime();\n var nextMonth = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);\n entries.push({ key: cursor.getFullYear() + '-' + cursor.getMonth(), saved: 0, reqs: 0, label: cursor.toLocaleDateString([], {month:'short', year:'2-digit'}), start: t, end: nextMonth.getTime() });\n cursor = nextMonth;\n }\n }\n\n // Fill buckets with session data\n sessions.forEach(function(s) {\n for (var i = 0; i < entries.length; i++) {\n if (s.startTime >= entries[i].start && s.startTime < entries[i].end) {\n entries[i].saved += s.savedTokens || 0;\n entries[i].reqs += s.requests || 0;\n break;\n }\n }\n });\n\n var maxVal = Math.max.apply(null, entries.map(function(e){ return e.saved; })) || 1;\n var totalSaved = entries.reduce(function(a,e){ return a + e.saved; }, 0);\n var totalReqs = entries.reduce(function(a,e){ return a + e.reqs; }, 0);\n var n = entries.length;\n var chartH = 110;\n\n if (totalSaved === 0 && totalReqs === 0) {\n el.innerHTML = '<div class=\"lim-nodata\">No data in this period.</div>';\n return;\n }\n\n // Y-axis ticks\n var yTicks = [0.25, 0.5, 0.75, 1.0];\n\n // Build grid + bars as nested HTML\n var gridLines = yTicks.map(function(t){\n var pct = (1 - t) * 100;\n return '<div style=\"position:absolute;left:38px;right:0;top:' + pct + '%;height:1px;border-top:1px dashed var(--border);pointer-events:none\">' +\n '<span style=\"position:absolute;right:calc(100% + 4px);transform:translateY(-50%);font-size:9px;color:var(--text3);white-space:nowrap\">' + fmtY(t * maxVal) + '</span>' +\n '</div>';\n }).join('');\n\n // Baseline\n gridLines += '<div style=\"position:absolute;left:38px;right:0;bottom:0;height:1px;background:var(--border2)\"></div>';\n\n var bars = entries.map(function(data) {\n var ratio = data.saved / maxVal;\n var hPct = Math.max(2, Math.round(ratio * 100));\n var isMax = data.saved === maxVal;\n var color = isMax ? 'var(--brand)' : 'rgba(22,163,74,0.35)';\n var tip = data.label + ' \u00B7 ' + fmt(data.saved) + ' tokens saved \u00B7 ' + data.reqs + ' req';\n return '<div style=\"flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;gap:0;height:' + chartH + 'px;position:relative\" title=\"' + esc(tip) + '\">' +\n (isMax ? '<div style=\"font-size:9px;color:var(--brand2);font-weight:700;margin-bottom:2px;white-space:nowrap\">' + fmtY(data.saved) + '</div>' : '') +\n '<div class=\"bar\" style=\"width:calc(100% - 2px);height:' + hPct + '%;background:' + color + ';border-radius:3px 3px 0 0;transition:background .15s,opacity .15s\"></div>' +\n '</div>';\n }).join('');\n\n // Smart label spacing: show every N-th label to avoid clutter\n var labelEvery = n <= 12 ? 1 : n <= 24 ? 2 : n <= 31 ? 5 : Math.ceil(n / 12);\n var labels = entries.map(function(data, i) {\n var show = i % labelEvery === 0 || i === n - 1;\n var content = show ? esc(data.label) : '';\n return '<div style=\"flex:1;min-width:0;text-align:center;font-size:9px;color:var(--text3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 1px\">' + content + '</div>';\n }).join('');\n\n var unitLabel = period === 'day' ? '5h windows' : period === 'week' || period === 'month' ? 'days' : 'months';\n\n el.innerHTML =\n '<div class=\"chart-wrap\" style=\"padding:12px 12px 8px\">' +\n '<div style=\"position:relative;height:' + chartH + 'px;margin-left:38px\">' +\n gridLines +\n '<div style=\"position:absolute;inset:0;display:flex;gap:3px;align-items:flex-end\">' + bars + '</div>' +\n '</div>' +\n '<div style=\"display:flex;gap:3px;margin-left:38px;margin-top:4px\">' + labels + '</div>' +\n '<div style=\"margin-top:6px;font-size:11px;color:var(--text3);display:flex;justify-content:space-between\">' +\n '<span>' + totalReqs + ' request' + (totalReqs !== 1 ? 's' : '') + ' across ' + n + ' ' + unitLabel + '</span>' +\n '<span style=\"color:var(--brand2);font-weight:600\">' + fmtY(totalSaved) + ' tokens saved</span>' +\n '</div>' +\n '</div>';\n}\n\npoll();\nconnect();\n// Load history immediately so Overview shows Today's data without needing to visit Savings tab\nloadSavings();\ncheckLatestVersion();\n</script>\n</body>\n</html>";
|
|
8
|
+
export declare const DASHBOARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/squeezr/favicon.svg\">\n<title>Squeezr</title>\n<style>\n*{box-sizing:border-box;margin:0;padding:0}\n\n:root{\n --bg:#0a0a0a;\n --surface:#111111;\n --surface2:#161616;\n --surface3:#1c1c1c;\n --border:#222222;\n --border2:#2e2e2e;\n --text:#f0f0f0;\n --text2:#a0a0a0;\n --text3:#606060;\n --brand:#16a34a;\n --brand2:#4ade80;\n --brand-dim:rgba(22,163,74,.12);\n --brand-dim2:rgba(22,163,74,.06);\n --red:#f87171;\n --yellow:#fbbf24;\n --blue:#60a5fa;\n --shadow:0 1px 3px rgba(0,0,0,.5),0 4px 16px rgba(0,0,0,.3);\n}\nhtml:not(.dark){\n --bg:#f5f5f5;\n --surface:#ffffff;\n --surface2:#fafafa;\n --surface3:#f0f0f0;\n --border:#e0e0e0;\n --border2:#d0d0d0;\n --text:#111111;\n --text2:#555555;\n --text3:#999999;\n --brand-dim:rgba(22,163,74,.08);\n --brand-dim2:rgba(22,163,74,.04);\n --shadow:0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06);\n}\n\nhtml,body{\n height:100%;\n background:var(--bg);\n color:var(--text);\n font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Helvetica Neue',sans-serif;\n font-size:14px;line-height:1.5;\n transition:background .2s,color .2s;\n -webkit-font-smoothing:antialiased;\n}\ncode{font-family:'Cascadia Code','SF Mono',Consolas,monospace;font-size:.9em}\n\n/* \u2500\u2500 Layout \u2500\u2500 */\n#app{display:flex;flex-direction:column;height:100vh;overflow:hidden}\n\n/* \u2500\u2500 Top navbar \u2500\u2500 */\n#navbar{\n flex-shrink:0;\n height:52px;\n background:var(--surface);\n border-bottom:1px solid var(--border);\n display:flex;align-items:center;\n padding:0 24px;gap:0;\n transition:background .2s,border-color .2s\n}\n\n/* Logo */\n.nb-brand{\n display:flex;align-items:center;gap:9px;\n margin-right:24px;flex-shrink:0\n}\n.nb-brand svg{width:24px;height:24px;color:var(--brand)}\n.nb-brand-name{font-size:15px;font-weight:700;letter-spacing:-.3px;color:var(--text)}\n.nb-brand-ver{font-size:11px;color:var(--text3);margin-left:6px;margin-top:1px}\n\n/* Divider */\n.nb-sep{width:1px;height:22px;background:var(--border2);margin-right:20px;flex-shrink:0}\n\n/* Tabs */\n.nb-tabs{display:flex;align-items:stretch;gap:2px;height:100%}\n.nb-tab{\n display:flex;align-items:center;gap:7px;\n padding:0 16px;\n font-size:13px;font-weight:500;color:var(--text2);\n cursor:pointer;user-select:none;\n border-bottom:2px solid transparent;\n transition:color .12s,border-color .12s;\n white-space:nowrap\n}\n.nb-tab:hover{color:var(--text)}\n.nb-tab.active{color:var(--brand);border-bottom-color:var(--brand)}\n.nb-tab svg{width:14px;height:14px;flex-shrink:0;stroke-width:2}\n\n/* Right side */\n.nb-right{\n margin-left:auto;display:flex;align-items:center;gap:10px\n}\n.conn-dot{\n width:7px;height:7px;border-radius:50%;background:var(--text3);flex-shrink:0;\n transition:background .3s\n}\n.conn-dot.online{background:var(--brand);box-shadow:0 0 6px var(--brand)}\n.conn-dot.offline{background:var(--red)}\n.conn-label{font-size:12px;color:var(--text3)}\n\n.theme-btn{\n display:flex;align-items:center;justify-content:center;\n width:32px;height:32px;border-radius:8px;\n background:none;border:1px solid var(--border2);cursor:pointer;\n color:var(--text2);transition:background .12s,color .12s\n}\n.theme-btn:hover{background:var(--surface3);color:var(--text)}\n\n/* \u2500\u2500 Main \u2500\u2500 */\n#main{\n flex:1;overflow-y:auto;padding:28px 32px;\n background:var(--bg)\n}\n@media(max-width:600px){\n .nb-brand-ver{display:none}\n #main{padding:16px 14px}\n}\n\n.page-header{margin-bottom:24px}\n.page-title{font-size:22px;font-weight:700;letter-spacing:-.4px;color:var(--text)}\n.page-sub{font-size:13px;color:var(--text3);margin-top:3px}\n\n/* \u2500\u2500 Hero cards \u2500\u2500 */\n.hero-grid{\n display:grid;\n grid-template-columns:repeat(auto-fit,minmax(170px,1fr));\n gap:12px;margin-bottom:20px\n}\n.hero-card{\n background:var(--surface);\n border:1px solid var(--border);\n border-radius:12px;padding:18px 20px;\n box-shadow:var(--shadow);\n transition:border-color .15s\n}\n.hero-card:hover{border-color:var(--border2)}\n.hero-card.accent{\n background:var(--brand-dim);\n border-color:rgba(22,163,74,.25)\n}\n.hc-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--text3);margin-bottom:8px}\n.hc-val{font-size:30px;font-weight:800;letter-spacing:-.5px;color:var(--text);line-height:1}\n.hero-card.accent .hc-val{color:var(--brand2)}\n.hc-sub{font-size:11px;color:var(--text3);margin-top:6px}\n\n/* \u2500\u2500 Sections \u2500\u2500 */\n.section{\n background:var(--surface);border:1px solid var(--border);\n border-radius:12px;margin-bottom:16px;overflow:hidden;\n box-shadow:var(--shadow)\n}\n.section-head{\n padding:14px 20px;border-bottom:1px solid var(--border);\n display:flex;align-items:center;justify-content:space-between\n}\n.section-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--text3)}\n.section-body{padding:16px 20px}\n\n/* \u2500\u2500 Tools bars \u2500\u2500 */\n.tool-row{display:flex;align-items:center;gap:12px;margin-bottom:10px}\n.tool-row:last-child{margin-bottom:0}\n.tool-name{font-size:13px;color:var(--text2);width:90px;flex-shrink:0;font-weight:500}\n.tool-track{flex:1;height:6px;background:var(--surface3);border-radius:3px;overflow:hidden}\n.tool-fill{height:100%;background:var(--brand);border-radius:3px;transition:width .4s}\n.tool-count{font-size:12px;color:var(--text3);width:50px;text-align:right;font-variant-numeric:tabular-nums}\n\n/* \u2500\u2500 Latency pills \u2500\u2500 */\n.lat-row{display:flex;gap:10px;flex-wrap:wrap}\n.lat-pill{\n flex:1;min-width:80px;\n background:var(--surface2);border:1px solid var(--border2);\n border-radius:10px;padding:12px 16px;text-align:center\n}\n.lat-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);display:block;margin-bottom:4px}\n.lat-val{font-size:22px;font-weight:700;color:var(--text)}\n.lat-unit{font-size:11px;color:var(--text3)}\n\n/* \u2500\u2500 Cache row \u2500\u2500 */\n.cache-row{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}\n.cache-card{\n background:var(--surface2);border:1px solid var(--border2);\n border-radius:10px;padding:14px;text-align:center\n}\n.cache-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:6px}\n.cache-val{font-size:22px;font-weight:700;color:var(--text)}\n\n/* \u2500\u2500 Limits \u2500\u2500 */\n.limits-grid{display:flex;flex-direction:column;gap:10px}\n.lim-row{display:flex;align-items:center;gap:14px}\n.lim-name{font-size:12px;color:var(--text2);width:100px;flex-shrink:0;font-weight:500}\n.lim-track{flex:1;height:8px;background:var(--surface3);border-radius:4px;overflow:hidden}\n.lim-fill{height:100%;border-radius:4px;transition:width .5s,background .3s}\n.lim-fill.ok{background:var(--brand)}\n.lim-fill.warn{background:var(--yellow)}\n.lim-fill.crit{background:var(--red)}\n.lim-text{font-size:12px;color:var(--text3);width:90px;text-align:right;font-variant-numeric:tabular-nums}\n.lim-nodata{font-size:13px;color:var(--text3);padding:8px 0}\n\n/* \u2500\u2500 Rate Limits + Live Log row \u2500\u2500 */\n.rl-row{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}\n@media (max-width:760px){.rl-row{grid-template-columns:1fr}}\n\n/* \u2500\u2500 Live Log feed (newest at the BOTTOM; older scroll up and out) \u2500\u2500 */\n#livelog-body{height:236px;overflow:hidden;padding:12px 14px;scroll-behavior:smooth}\n.ll-list{display:flex;flex-direction:column;gap:5px}\n.ll-empty{font-size:13px;color:var(--text3);padding:8px 0}\n.ll-row{\n display:flex;align-items:baseline;gap:8px;\n font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.45;\n padding:5px 9px;border-radius:7px;background:var(--surface2);border:1px solid var(--border2);\n white-space:nowrap;overflow:hidden;\n}\n.ll-row.ll-new{animation:llRise .45s cubic-bezier(.22,1,.36,1)}\n@keyframes llRise{\n 0%{opacity:0;transform:translateY(14px)}\n 100%{opacity:1;transform:translateY(0)}\n}\n.ll-tag{font-weight:700;flex-shrink:0}\n.ll-tag.det{color:#60a5fa}\n.ll-tag.dedup{color:#c084fc}\n.ll-tag.ai{color:var(--brand2)}\n.ll-tag.tooldesc{color:#fbbf24}\n.ll-tag.other{color:var(--text3)}\n.ll-msg{color:var(--text2);overflow:hidden;text-overflow:ellipsis}\n.ll-msg .num{color:var(--brand2);font-weight:700}\n.ll-time{font-size:10.5px;color:var(--text3);flex-shrink:0;margin-left:auto}\n\n/* \u2500\u2500 Mode controls \u2500\u2500 */\n.controls-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}\n.mode-btn{\n padding:7px 16px;border-radius:8px;\n border:1px solid var(--border2);background:var(--surface2);\n color:var(--text2);font-size:12px;font-weight:600;font-family:inherit;\n cursor:pointer;transition:all .12s\n}\n.mode-btn:hover{border-color:var(--brand);color:var(--brand)}\n.mode-btn.active{background:var(--brand-dim);border-color:rgba(22,163,74,.4);color:var(--brand2)}\n.mode-btn.active-off{background:rgba(248,113,113,.1);border-color:rgba(248,113,113,.3);color:var(--red)}\n.divider-v{width:1px;height:24px;background:var(--border2)}\n.bypass-btn{\n padding:7px 16px;border-radius:8px;\n border:1px solid var(--border2);background:var(--surface2);\n color:var(--text2);font-size:12px;font-weight:600;font-family:inherit;\n cursor:pointer;transition:all .12s\n}\n.bypass-btn:hover{border-color:var(--yellow);color:var(--yellow)}\n.bypass-btn.active{background:rgba(251,191,36,.08);border-color:rgba(251,191,36,.3);color:var(--yellow)}\n\n/* \u2500\u2500 Status badges \u2500\u2500 */\n.badge-row{display:flex;gap:8px;margin-bottom:14px}\n.badge{\n font-size:11px;font-weight:600;padding:3px 10px;border-radius:20px;\n border:1px solid var(--border2);color:var(--text3);background:var(--surface2)\n}\n.badge.green{background:var(--brand-dim);border-color:rgba(22,163,74,.3);color:var(--brand2)}\n.badge.yellow{background:rgba(251,191,36,.08);border-color:rgba(251,191,36,.25);color:var(--yellow)}\n.badge.red{background:rgba(248,113,113,.08);border-color:rgba(248,113,113,.25);color:var(--red)}\n\n/* \u2500\u2500 Settings \u2500\u2500 */\n.settings-block{\n background:var(--surface);border:1px solid var(--border);\n border-radius:12px;overflow:hidden;margin-bottom:16px;\n box-shadow:var(--shadow)\n}\n.settings-head{\n padding:12px 20px;border-bottom:1px solid var(--border);\n font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;\n color:var(--text3);background:var(--surface2)\n}\n.settings-row{\n display:flex;align-items:center;justify-content:space-between;\n padding:13px 20px;border-bottom:1px solid var(--border);gap:16px\n}\n.settings-row:last-child{border-bottom:none}\n.s-key{font-size:13px;color:var(--text2);font-weight:500}\n.s-val{font-size:13px;color:var(--text);font-family:'Cascadia Code','SF Mono',Consolas,monospace}\n.s-val code{\n background:var(--surface3);padding:2px 8px;border-radius:5px;\n border:1px solid var(--border2);color:var(--text)\n}\n.ai-dev-banner{\n font-size:12.5px;line-height:1.55;color:var(--text2);width:100%;\n background:rgba(251,191,36,.06);border:1px solid rgba(251,191,36,.25);\n border-radius:8px;padding:11px 13px\n}\n.ai-dev-banner .ai-dev-title{display:block;margin-bottom:4px;font-weight:700;color:var(--yellow)}\n.ai-risks{margin:0;padding-left:18px;font-size:12px;line-height:1.55;color:var(--text3);display:flex;flex-direction:column;gap:7px}\n.ai-risks li{margin:0}\n.ai-risks strong{color:var(--text2)}\n\n/* \u2500\u2500 Action buttons \u2500\u2500 */\n.action-btn{padding:7px 18px;border-radius:8px;border:1px solid var(--border2);background:var(--surface2);color:var(--text);font-size:13px;font-family:inherit;cursor:pointer;font-weight:500;transition:all .12s}\n.action-btn:hover{border-color:var(--brand);color:var(--brand)}\n.action-btn.danger{border-color:rgba(248,113,113,.3);color:var(--red)}\n.action-btn.danger:hover{background:rgba(248,113,113,.08)}\n.action-result{margin-top:10px;font-size:12px;padding:8px 12px;border-radius:6px;display:none}\n.action-result.ok{background:rgba(22,163,74,.08);color:#4ade80;border:1px solid rgba(22,163,74,.2)}\n.action-result.err{background:rgba(248,113,113,.08);color:#f87171;border:1px solid rgba(248,113,113,.2)}\n\n/* \u2500\u2500 CLI chips \u2500\u2500 */\n.chips{display:flex;flex-wrap:wrap;gap:7px;padding:16px 20px}\n.chip{\n display:flex;align-items:center;gap:5px;\n padding:5px 12px;border-radius:20px;\n background:var(--surface2);border:1px solid var(--border2);\n font-size:12px;color:var(--text2);font-weight:500\n}\n.chip-dot{width:5px;height:5px;border-radius:50%;background:var(--brand);flex-shrink:0}\n\n/* \u2500\u2500 Chart \u2500\u2500 */\n.chart-bar rect.bar{transition:opacity .15s,filter .15s}\n.chart-bar:hover rect.bar{opacity:.85;filter:brightness(1.15)}\n.chart-wrap{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:16px 12px 8px}\n\n/* \u2500\u2500 Skeleton \u2500\u2500 */\n.sk{\n background:linear-gradient(90deg,var(--surface2) 25%,var(--surface3) 50%,var(--surface2) 75%);\n background-size:200% 100%;animation:sk 1.4s infinite;border-radius:6px\n}\n@keyframes sk{0%{background-position:200% 0}100%{background-position:-200% 0}}\n\n::-webkit-scrollbar{width:5px;height:5px}\n::-webkit-scrollbar-track{background:transparent}\n::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}\n</style>\n</head>\n<body>\n<div id=\"app\">\n\n <!-- Top navbar -->\n <header id=\"navbar\">\n <div class=\"nb-brand\">\n <svg viewBox=\"0 0 427 425\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" aria-hidden=\"true\">\n <path d=\"M354.982 369.122C349.882 371.592 338.752 371.792 330.442 369.562C314.752 365.342 292.762 350.502 274.462 331.772L269.022 326.202L268.322 308.932C267.942 299.432 267.332 289.862 266.972 287.662C266.612 285.462 265.842 280.742 265.252 277.162C261.922 256.872 253.782 233.162 245.022 218.222C241.322 211.902 240.442 208.162 242.662 208.162C246.992 208.162 272.062 220.332 283.912 228.172C307.882 244.042 340.042 276.312 356.142 300.642C361.992 309.492 368.862 323.942 370.862 331.632C372.842 339.222 372.822 343.952 370.782 350.952C368.862 357.572 361.602 365.922 354.982 369.122ZM218.282 179.832C214.632 182.212 211.352 184.162 210.992 184.162C209.782 184.162 209.192 181.162 209.872 178.472C211.522 171.892 223.622 148.592 229.912 139.892C238.462 128.072 255.812 107.752 262.572 101.652C289.962 76.9417 301.752 68.0317 317.642 60.0417C337.182 50.2217 355.782 51.3217 365.342 62.8817C368.722 66.9617 372.412 77.3217 372.412 82.7217C372.412 92.2417 366.082 109.302 358.222 120.942C352.882 128.862 338.112 146.372 331.782 152.282L327.912 155.902L306.412 157.012C275.532 158.602 257.232 162.282 234.992 171.372C229.442 173.642 221.922 177.442 218.282 179.832ZM192.352 192.912C191.862 194.152 190.962 195.162 190.372 195.162C188.672 195.162 180.862 177.712 177.542 166.472C172.022 147.832 170.142 131.892 170.112 103.662C170.072 54.9617 176.632 25.5317 190.962 10.2117C203.612 -3.30832 223.802 -3.41833 235.432 9.97167C246.502 22.7117 252.932 45.4017 254.122 75.8417L254.712 91.0217L243.802 102.342C216.642 130.552 201.082 156.292 194.952 183.172C194.012 187.292 192.842 191.672 192.352 192.912ZM226.572 421.482C220.292 424.892 210.782 425.902 205.022 423.752C191.282 418.632 180.692 404.292 175.412 383.662C172.812 373.502 170.052 347.602 170.692 339.372L171.192 333.072L181.082 322.872C198.422 304.992 208.702 290.782 219.092 270.362C225.192 258.372 231.412 241.452 231.412 236.842C231.412 233.222 233.922 230.432 236.032 231.722C240.442 234.432 249.472 263.122 253.062 285.842C255.302 300.022 255.592 348.712 253.532 363.662C249.272 394.512 240.142 414.092 226.572 421.482ZM182.052 209.772C186.532 217.502 181.062 217.082 163.912 208.392C143.982 198.302 124.292 183.882 105.542 165.662C69.9124 131.042 51.3724 100.292 53.7824 79.7817C54.5824 72.9217 59.4624 62.9817 63.5724 59.8517C83.1924 44.8917 111.392 54.5117 145.162 87.6917L156.412 98.7517L156.422 107.702C156.442 128.452 159.472 151.202 164.592 169.092C169.372 185.812 172.862 193.952 182.052 209.772ZM96.0524 369.042C86.6124 371.962 77.4224 371.862 70.9124 368.772C60.5924 363.872 53.4124 352.582 53.4124 341.252C53.4124 325.142 66.4924 301.572 87.9324 279.062C93.8424 272.852 96.3824 270.182 99.4924 269.032C101.842 268.152 104.522 268.152 109.242 268.152C131.112 268.132 157.482 264.312 174.912 258.642C183.912 255.722 201.562 247.552 208.502 243.102C211.022 241.482 213.612 240.162 214.252 240.162C216.572 240.162 215.322 246.362 211.052 256.062C201.052 278.772 190.362 293.692 165.912 319.032C140.952 344.912 115.702 362.982 96.0524 369.042ZM368.762 251.622C362.292 252.472 351.732 253.162 345.312 253.162H333.622L328.262 247.552C314.672 233.322 289.892 214.982 271.112 205.242C261.472 200.242 244.592 193.972 237.082 192.602C232.942 191.852 231.502 189.962 233.362 187.722C235.062 185.672 250.402 179.462 259.532 177.132C280.552 171.762 296.062 170.162 326.772 170.172C369.092 170.192 394.642 174.902 410.892 185.692C419.312 191.282 423.272 197.242 425.392 207.512C426.482 212.822 426.382 214.032 424.312 220.512C422.492 226.212 420.942 228.802 416.682 233.292C413.742 236.392 408.742 240.302 405.572 241.992C398.302 245.872 383.912 249.632 368.762 251.622ZM146.412 251.272C136.432 253.162 130.742 253.482 103.412 253.742C80.9524 253.952 69.0424 253.652 61.9124 252.682C28.4924 248.142 11.0524 240.212 3.39238 226.092C0.252382 220.292 -0.0776191 218.932 0.0123809 212.162C0.142381 202.882 1.92238 198.622 8.60238 191.562C20.8324 178.622 39.5124 173.102 76.4624 171.502L91.0124 170.862L104.492 182.762C125.782 201.552 128.872 203.902 142.912 211.932C160.322 221.882 182.112 231.162 188.092 231.162C189.152 231.162 190.902 231.842 191.972 232.662C193.862 234.132 193.832 234.242 190.912 236.652C184.902 241.622 167.932 247.202 146.412 251.272Z\" fill=\"currentColor\"/>\n </svg>\n <span class=\"nb-brand-name\">Squeezr</span>\n <span class=\"nb-brand-ver\" id=\"sb-ver\">\u2014</span>\n </div>\n\n <div class=\"nb-sep\"></div>\n\n <div class=\"nb-tabs\">\n <div class=\"nb-tab active\" data-page=\"overview\" onclick=\"go('overview')\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1.5\"/><rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1.5\"/>\n <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1.5\"/><rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1.5\"/>\n </svg>\n Overview\n </div>\n <div class=\"nb-tab\" data-page=\"savings\" onclick=\"go('savings')\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"23\"/><path d=\"M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6\"/>\n </svg>\n Savings\n </div>\n <div class=\"nb-tab\" data-page=\"settings\" onclick=\"go('settings')\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <circle cx=\"12\" cy=\"12\" r=\"3\"/>\n <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z\"/>\n </svg>\n Settings\n </div>\n </div>\n\n <div class=\"nb-right\">\n <div class=\"conn-dot\" id=\"conn-dot\"></div>\n <span class=\"conn-label\" id=\"conn-label\">Connecting\u2026</span>\n <button class=\"theme-btn\" onclick=\"toggleTheme()\" title=\"Toggle theme\">\n <svg id=\"theme-icon\" width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\">\n <circle cx=\"12\" cy=\"12\" r=\"5\"/><line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\"/><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"/>\n <line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\"/><line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\"/>\n <line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\"/><line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\"/>\n <line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\"/><line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\"/>\n </svg>\n </button>\n </div>\n </header>\n\n <!-- Main -->\n <main id=\"main\">\n\n <!-- \u2500\u2500 Overview page \u2500\u2500 -->\n <div id=\"page-overview\">\n\n<!-- Hero stats \u2014 TODAY (local calendar day, 00:00\u2013now) from date-stamped daily counters -->\n <div style=\"display:flex;align-items:center;gap:8px;margin-bottom:12px\">\n <span style=\"font-size:13px;font-weight:600;color:var(--text)\">Overview</span>\n <span class=\"badge\" id=\"overview-period\" style=\"font-size:11px;color:var(--text3)\">today</span>\n </div>\n <div class=\"hero-grid\">\n <div class=\"hero-card accent\">\n <div class=\"hc-label\">Tokens Saved</div>\n <div class=\"hc-val\" id=\"h-saved\">\u2014</div>\n <div class=\"hc-sub\">of <span id=\"h-in\">\u2014</span> processed</div>\n </div>\n <div class=\"hero-card\">\n <div class=\"hc-label\">Ratio</div>\n <div style=\"display:flex;align-items:flex-end;gap:18px\">\n <div>\n<div class=\"hc-val\" id=\"h-ratio\">\u2014</div>\n <div style=\"font-size:11px;color:var(--text3)\" title=\"% medio comprimido sobre todo lo enviado hoy (acumulado del d\u00EDa) \u2014 cifra estable\">del total (hoy)</div>\n </div>\n <div>\n <div class=\"hc-val\" id=\"h-engine\" style=\"color:var(--text3)\">\u2014</div>\n <div style=\"font-size:11px;color:var(--text3)\" title=\"% comprimido en la \u00DALTIMA request \u2014 cambia en cada turno seg\u00FAn el contenido\">\u00FAltima request</div>\n </div>\n </div>\n <div class=\"hc-sub\" style=\"margin-top:6px\"><span id=\"h-perreq\">\u2014</span></div>\n </div>\n <div class=\"hero-card\">\n <div class=\"hc-label\">Cost Saved</div>\n <div class=\"hc-val\" id=\"h-cost\">\u2014</div>\n <div class=\"hc-sub\">estimated USD</div>\n </div>\n <div class=\"hero-card\">\n <div class=\"hc-label\">Requests</div>\n <div class=\"hc-val\" id=\"h-reqs\">\u2014</div>\n <div class=\"hc-sub\"><span id=\"h-comp\">\u2014</span> AI-compressed \u00B7 det. always on</div>\n </div>\n </div>\n\n <!-- Controls -->\n <div class=\"section\">\n <div class=\"section-head\">\n <span class=\"section-title\">Compression Mode</span>\n <div class=\"badge-row\" style=\"margin:0\">\n <span class=\"badge\" id=\"mode-badge\">\u2014</span>\n <span class=\"badge\" id=\"bypass-badge\">\u2014</span>\n <span class=\"badge\" id=\"ai-comp-badge\">\u2014</span>\n </div>\n </div>\n <div class=\"section-body\">\n <div class=\"controls-row\">\n <button class=\"mode-btn\" data-mode=\"off\" onclick=\"setMode('off')\">Off</button>\n <button class=\"mode-btn\" data-mode=\"low\" onclick=\"setMode('low')\">Low</button>\n <button class=\"mode-btn active\" data-mode=\"normal\" onclick=\"setMode('normal')\">Normal</button>\n <button class=\"mode-btn\" data-mode=\"aggressive\" onclick=\"setMode('aggressive')\">Aggressive</button>\n <div class=\"divider-v\"></div>\n <button class=\"bypass-btn\" id=\"bypass-btn\" onclick=\"toggleBypass()\">Toggle Bypass</button>\n </div>\n </div>\n </div>\n\n <!-- Rate Limits + Live Log \u2014 two cards on the same row -->\n <div class=\"rl-row\">\n <div class=\"section\" style=\"margin:0\">\n <div class=\"section-head\"><span class=\"section-title\">Rate Limits</span></div>\n <div class=\"section-body\" id=\"limits-body\">\n <div class=\"lim-nodata\">Loading\u2026</div>\n </div>\n </div>\n <div class=\"section\" style=\"margin:0\">\n <div class=\"section-head\">\n <span class=\"section-title\">Live Log</span>\n <span style=\"font-size:11px;color:var(--text3)\">real-time \u00B7 compression events</span>\n </div>\n <div class=\"section-body\" id=\"livelog-body\">\n <div class=\"ll-empty\">Waiting for activity\u2026</div>\n </div>\n </div>\n </div>\n\n <!-- Three-col grid -->\n <div style=\"display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:16px\">\n <!-- Tools -->\n <div class=\"section\" style=\"margin:0\">\n <div class=\"section-head\"><span class=\"section-title\">Top Tools</span></div>\n <div class=\"section-body\" id=\"tools-body\">\n <div class=\"sk\" style=\"height:14px;margin-bottom:8px\"></div>\n <div class=\"sk\" style=\"height:14px;margin-bottom:8px;width:80%\"></div>\n <div class=\"sk\" style=\"height:14px;width:65%\"></div>\n </div>\n </div>\n<!-- Session Cache -->\n <div class=\"section\" style=\"margin:0\">\n <div class=\"section-head\"><span class=\"section-title\">Session Cache</span><span style=\"font-size:11px;color:var(--text3)\">AI layer only</span></div>\n <div class=\"section-body\">\n <div class=\"cache-row\">\n <div class=\"cache-card\"><div class=\"cache-label\">Reuses</div><div class=\"cache-val\" id=\"c-hits\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Expands</div><div class=\"cache-val\" id=\"c-miss\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">LRU Size</div><div class=\"cache-val\" id=\"c-rate\">\u2014</div></div>\n </div>\n <div style=\"margin-top:8px;font-size:11px;color:var(--text3);text-align:center\">0 here is normal when AI compression is off</div>\n </div>\n </div>\n <!-- AI Compression -->\n <div class=\"section\" style=\"margin:0\">\n <div class=\"section-head\"><span class=\"section-title\">AI Compression</span><span style=\"font-size:11px;color:var(--text3)\">today \u00B7 persisted</span></div>\n <div class=\"section-body\">\n <div class=\"cache-row\">\n <div class=\"cache-card\"><div class=\"cache-label\">Calls</div><div class=\"cache-val\" id=\"ai-calls\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Saved</div><div class=\"cache-val\" id=\"ai-saved\" style=\"color:var(--brand2)\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Spent</div><div class=\"cache-val\" id=\"ai-spent\">\u2014</div></div>\n </div>\n <div id=\"ai-net\" style=\"margin-top:8px;font-size:11px;color:var(--text3);text-align:center\">\u2014</div>\n </div>\n </div>\n </div>\n\n <!-- Prompt Cache health (Anthropic) \u2014 the metric that caught the 2026-06-04 over-billing -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">Prompt Cache (Anthropic)</span><span style=\"font-size:11px;color:var(--text3)\">persisted \u00B7 read=cheap (0.1x) \u00B7 creation=re-billed (1.25x)</span></div>\n <div class=\"section-body\">\n <div class=\"cache-row\">\n <div class=\"cache-card\"><div class=\"cache-label\">Cache Read</div><div class=\"cache-val\" id=\"pc-read\" style=\"color:var(--brand2)\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Cache Creation</div><div class=\"cache-val\" id=\"pc-create\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Hit Health</div><div class=\"cache-val\" id=\"pc-health\">\u2014</div></div>\n </div>\n <div id=\"pc-note\" style=\"margin-top:8px;font-size:11px;color:var(--text3);text-align:center\">High read vs creation = cache working. High creation = something is invalidating the prefix.</div>\n </div>\n </div>\n\n <!-- Spend: theoretical vs real -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">Cost Comparison</span><span style=\"font-size:11px;color:var(--text3)\" id=\"cost-note\">per-model pricing</span></div>\n <div class=\"section-body\">\n <div style=\"display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px\">\n <div style=\"text-align:center\">\n <div style=\"font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:6px\">Without Squeezr</div>\n <div style=\"font-size:24px;font-weight:700;color:var(--text)\" id=\"sp-without\">\u2014</div>\n <div style=\"font-size:11px;color:var(--text3);margin-top:4px\" id=\"sp-without-tok\">\u2014</div>\n </div>\n <div style=\"text-align:center\">\n <div style=\"font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:6px\">With Squeezr</div>\n <div style=\"font-size:24px;font-weight:700;color:var(--brand2)\" id=\"sp-with\">\u2014</div>\n <div style=\"font-size:11px;color:var(--text3);margin-top:4px\" id=\"sp-with-tok\">\u2014</div>\n </div>\n <div style=\"text-align:center\">\n <div style=\"font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:6px\">Saved</div>\n <div style=\"font-size:24px;font-weight:700;color:var(--brand2)\" id=\"sp-saved\">\u2014</div>\n <div style=\"font-size:11px;color:var(--text3);margin-top:4px\" id=\"sp-saved-pct\">\u2014</div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Model breakdown -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">By model</span><span style=\"font-size:11px;color:var(--text3)\">today \u00B7 real pricing</span></div>\n <div class=\"section-body\" id=\"model-body\">\n <div style=\"font-size:13px;color:var(--text3)\">No model data yet.</div>\n </div>\n </div>\n\n<!-- Savings by client -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">Savings by client</span><span style=\"font-size:11px;color:var(--text3)\">today</span></div>\n <div class=\"section-body\" id=\"client-body-overview\">\n <div style=\"font-size:13px;color:var(--text3)\">No data yet \u2014 starts after first request.</div>\n </div>\n </div>\n\n <!-- Savings by compression type (today, persisted) -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">Savings by type</span><span style=\"font-size:11px;color:var(--text3)\">today</span></div>\n <div class=\"section-body\" id=\"breakdown-body\">\n <div style=\"font-size:13px;color:var(--text3)\">No data yet.</div>\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500 Savings page \u2500\u2500 -->\n <div id=\"page-savings\" style=\"display:none\">\n\n <!-- Period selector + navigation -->\n <div style=\"display:flex;gap:8px;margin-bottom:20px;align-items:center;flex-wrap:wrap\">\n <span style=\"font-size:13px;color:var(--text2);font-weight:500\">View:</span>\n <button class=\"mode-btn active\" id=\"period-day\" onclick=\"setSavingsPeriod('day')\">Day</button>\n <button class=\"mode-btn\" id=\"period-week\" onclick=\"setSavingsPeriod('week')\">Week</button>\n <button class=\"mode-btn\" id=\"period-month\" onclick=\"setSavingsPeriod('month')\">Month</button>\n <button class=\"mode-btn\" id=\"period-all\" onclick=\"setSavingsPeriod('all')\">All time</button>\n <div style=\"flex:1\"></div>\n <button class=\"mode-btn\" onclick=\"navigatePeriod(-1)\" title=\"Previous\">\u25C0</button>\n <span id=\"period-label\" style=\"font-size:13px;color:var(--text);font-weight:600;min-width:160px;text-align:center\">\u2014</span>\n <button class=\"mode-btn\" onclick=\"navigatePeriod(1)\" title=\"Next\">\u25B6</button>\n <button class=\"mode-btn\" onclick=\"navigatePeriod(0)\" title=\"Today\">Today</button>\n </div>\n\n <!-- Period hero -->\n <div class=\"hero-grid\" id=\"savings-hero\">\n <div class=\"hero-card accent\">\n <div class=\"hc-label\">Tokens Saved</div>\n <div class=\"hc-val\" id=\"sv-tokens\">\u2014</div>\n <div class=\"hc-sub\" id=\"sv-tokens-sub\">\u2014</div>\n </div>\n <div class=\"hero-card\">\n <div class=\"hc-label\">Est. Cost Saved</div>\n <div class=\"hc-val\" id=\"sv-cost\">\u2014</div>\n <div class=\"hc-sub\" id=\"sv-cost-note\">per-model pricing</div>\n </div>\n <div class=\"hero-card\">\n <div class=\"hc-label\">Sessions</div>\n <div class=\"hc-val\" id=\"sv-sessions\">\u2014</div>\n <div class=\"hc-sub\" id=\"sv-requests\">\u2014</div>\n </div>\n <div class=\"hero-card\">\n <div class=\"hc-label\">Avg Saving</div>\n <div class=\"hc-val\" id=\"sv-pct\">\u2014</div>\n <div class=\"hc-sub\"><span id=\"sv-engine\">\u2014</span></div>\n </div>\n </div>\n\n <!-- Daily breakdown chart (bar chart) -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\" id=\"savings-chart-title\">Daily breakdown</span></div>\n <div class=\"section-body\" id=\"savings-chart\">\n <div class=\"sk\" style=\"height:80px\"></div>\n </div>\n </div>\n\n <!-- By model -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">By model</span><span style=\"font-size:11px;color:var(--text3)\">selected period \u00B7 real pricing</span></div>\n <div class=\"section-body\" id=\"model-body-savings\">\n <div style=\"font-size:13px;color:var(--text3)\">No model data yet.</div>\n </div>\n </div>\n\n <!-- By client -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">By client</span><span style=\"font-size:11px;color:var(--text3)\">selected period</span></div>\n <div class=\"section-body\" id=\"client-body-savings\">\n <div style=\"font-size:13px;color:var(--text3)\">No client data yet.</div>\n </div>\n </div>\n\n <!-- Top Tools (period) -->\n <div class=\"section\">\n <div class=\"section-head\"><span class=\"section-title\">Top Tools</span><span style=\"font-size:11px;color:var(--text3)\">selected period</span></div>\n <div class=\"section-body\" id=\"tools-body-savings\">\n <div style=\"font-size:13px;color:var(--text3)\">No tool data yet.</div>\n </div>\n </div>\n\n <!-- AI Compression + Session Cache (period) -->\n <div style=\"display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px\">\n <div class=\"section\" style=\"margin:0\">\n <div class=\"section-head\"><span class=\"section-title\">AI Compression</span><span style=\"font-size:11px;color:var(--text3)\">selected period</span></div>\n <div class=\"section-body\">\n <div class=\"cache-row\">\n <div class=\"cache-card\"><div class=\"cache-label\">Calls</div><div class=\"cache-val\" id=\"sv-ai-calls\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Saved</div><div class=\"cache-val\" id=\"sv-ai-saved\" style=\"color:var(--brand2)\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Spent</div><div class=\"cache-val\" id=\"sv-ai-spent\">\u2014</div></div>\n </div>\n <div id=\"sv-ai-net\" style=\"margin-top:8px;font-size:11px;color:var(--text3);text-align:center\">\u2014</div>\n </div>\n </div>\n <div class=\"section\" style=\"margin:0\">\n <div class=\"section-head\"><span class=\"section-title\">Session Cache</span><span style=\"font-size:11px;color:var(--text3)\">selected period</span></div>\n <div class=\"section-body\">\n <div class=\"cache-row\">\n <div class=\"cache-card\"><div class=\"cache-label\">Reuses</div><div class=\"cache-val\" id=\"sv-cache-reuses\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Expands</div><div class=\"cache-val\" id=\"sv-cache-expands\">\u2014</div></div>\n <div class=\"cache-card\"><div class=\"cache-label\">Sessions</div><div class=\"cache-val\" id=\"sv-cache-sessions\">\u2014</div></div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500 Settings page \u2500\u2500 -->\n <div id=\"page-settings\" style=\"display:none\">\n\n <div id=\"update-banner\" style=\"display:none;background:rgba(251,191,36,.08);border:1px solid rgba(251,191,36,.25);border-radius:12px;padding:14px 20px;margin-bottom:20px;align-items:center;justify-content:space-between\">\n <div>\n <span style=\"color:#fbbf24;font-weight:600;font-size:13px\">Update available</span>\n <span id=\"update-text\" style=\"color:var(--text2);font-size:13px;margin-left:8px\"></span>\n </div>\n <button class=\"action-btn\" onclick=\"runAction('update')\" style=\"border-color:rgba(251,191,36,.4);color:#fbbf24\">Update now</button>\n </div>\n\n\n <div class=\"settings-block\">\n <div class=\"settings-head\">Proxy endpoints</div>\n <div class=\"settings-row\">\n <span class=\"s-key\" title=\"Claude Code, Claude Desktop, Aider, OpenCode\">Anthropic <span style=\"font-size:11px;color:var(--text3)\">(Claude)</span></span>\n <span class=\"s-val\"><code id=\"cfg-url-val\">\u2014</code></span>\n </div>\n <div class=\"settings-row\">\n <span class=\"s-key\" title=\"Codex Desktop app, Continue, Cline, Cursor \u2014 use openai_base_url\">Codex Desktop <span style=\"font-size:11px;color:var(--text3)\">/ OpenAI apps</span></span>\n <span class=\"s-val\"><code id=\"cfg-oai-val\">\u2014</code><span style=\"font-size:11px;color:var(--text3);margin-left:6px\">/v1 \u00B7 openai_base_url</span></span>\n </div>\n <div class=\"settings-row\">\n <span class=\"s-key\" title=\"Gemini CLI \u2014 GEMINI_API_BASE_URL\">Gemini CLI</span>\n <span class=\"s-val\"><code id=\"cfg-gem-val\">\u2014</code><span style=\"font-size:11px;color:var(--text3);margin-left:6px\">GEMINI_API_BASE_URL</span></span>\n </div>\n <div class=\"settings-row\">\n <span class=\"s-key\" title=\"Codex CLI (terminal) \u2014 WebSocket TLS intercept, set HTTPS_PROXY per session\">Codex CLI <span style=\"font-size:11px;color:var(--text3)\">(terminal)</span></span>\n <span class=\"s-val\"><code id=\"cfg-mitm-val\">\u2014</code><span style=\"font-size:11px;color:var(--text3);margin-left:6px\">HTTPS_PROXY \u00B7 TLS intercept</span></span>\n </div>\n <div class=\"settings-row\">\n <span class=\"s-key\">Version</span>\n <span class=\"s-val\" id=\"cfg-ver\">\u2014</span>\n </div>\n <div class=\"settings-row\">\n <span class=\"s-key\">Uptime</span>\n <span class=\"s-val\" id=\"cfg-uptime\" style=\"color:var(--brand2)\">\u2014</span>\n </div>\n </div>\n\n <div class=\"settings-block\">\n <div class=\"settings-head\">Compression</div>\n <div class=\"settings-row\">\n <span class=\"s-key\">Mode</span>\n <span class=\"s-val\"><code id=\"cfg-mode\">\u2014</code></span>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start;gap:4px\">\n <div style=\"display:flex;justify-content:space-between;width:100%;align-items:center\">\n <span class=\"s-key\">Bypass</span>\n <span class=\"s-val\"><code id=\"cfg-bypass\">\u2014</code></span>\n </div>\n <div style=\"font-size:12px;color:var(--text3);line-height:1.4\">\n When <strong style=\"color:var(--text2)\">enabled</strong>, all requests pass through to the API <em>without compression</em> \u2014 useful to check if Squeezr is causing any issue. Stats are still logged. Resets automatically when the proxy restarts.\n </div>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start;gap:8px\">\n <div style=\"display:flex;justify-content:space-between;width:100%;align-items:center\">\n <span class=\"s-key\">Anthropic Native Compact <span style=\"font-size:10px;background:var(--brand-dim);color:var(--brand2);padding:1px 6px;border-radius:3px;margin-left:4px\">beta</span></span>\n <button class=\"mode-btn\" id=\"native-compact-btn\" onclick=\"toggleNativeCompact()\" style=\"min-width:80px\">\u2014</button>\n </div>\n <div style=\"font-size:12px;color:var(--text3);line-height:1.4\">\n Activa el header <code style=\"font-size:11px\">anthropic-beta: compact-2026-01-12</code>. Cuando el contexto excede el threshold, Anthropic <strong style=\"color:var(--text2)\">resume tu conversaci\u00F3n autom\u00E1ticamente en sus servidores</strong>. Stacks con la compresi\u00F3n de Squeezr \u2014 comprimes primero, ellos resumen lo que queda. <strong>Solo Claude</strong> (no afecta OpenAI/Gemini). Reseteable.\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500 AI Compression (apartado dedicado) \u2500\u2500 -->\n <div class=\"settings-block\">\n <div class=\"settings-head\">AI Compression <span style=\"font-size:10px;background:rgba(251,191,36,.12);color:var(--yellow);padding:1px 6px;border-radius:3px;margin-left:4px\">experimental \u00B7 en desarrollo</span></div>\n\n <!-- Estado de desarrollo -->\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start\">\n <div class=\"ai-dev-banner\">\n <span class=\"ai-dev-title\">\uD83D\uDEA7 Todav\u00EDa no rinde en producci\u00F3n \u2014 desactivada por defecto.</span>\n La compresi\u00F3n por IA a\u00FAn no aporta ahorro neto real. El modelo local <strong>Zest</strong> aprendi\u00F3 a recortar tokens \"duros\" (rutas, c\u00F3digos de error, IDs) que el guard de runtime rechaza, as\u00ED que hoy el guard tumba casi todas sus salidas \u2192 ~0 de ahorro. Lo estamos <strong>reentrenando con un dataset guard-compliant</strong> (Zest v4) usando un port fiel del validador. Mientras tanto la <strong>compresi\u00F3n determin\u00EDstica</strong> \u2014gratis y siempre activa\u2014 es la que hace el trabajo de verdad. Enciende esto solo si quieres experimentar.\n </div>\n </div>\n\n <!-- Toggle maestro -->\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start;gap:4px\">\n <div style=\"display:flex;justify-content:space-between;width:100%;align-items:center\">\n <span class=\"s-key\">Activar AI Compression</span>\n <button class=\"action-btn\" id=\"ai-comp-btn-settings\" onclick=\"toggleAiCompression()\">\u2014</button>\n </div>\n <div style=\"font-size:12px;color:var(--text3);line-height:1.4\">\n Interruptor maestro de las llamadas de compresi\u00F3n por IA. En <strong style=\"color:var(--text2)\">off</strong> solo corre la determin\u00EDstica (coste cero de tokens). Persiste entre reinicios. La determin\u00EDstica sigue activa pase lo que pase.\n </div>\n </div>\n\n <!-- Qu\u00E9 puedes usar hoy -->\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start;gap:8px\">\n <div style=\"display:flex;justify-content:space-between;width:100%;align-items:center;flex-wrap:wrap;gap:6px\">\n <span class=\"s-key\">Qu\u00E9 puedes usar hoy</span>\n <div style=\"display:flex;gap:4px;flex-wrap:wrap\">\n <button class=\"mode-btn\" data-backend=\"local\" onclick=\"setBackend('local')\">\u26A1 Zest (local \u00B7 free)</button>\n <button class=\"mode-btn\" data-backend=\"haiku\" onclick=\"setBackend('haiku')\">Haiku (API \u00B7 billed)</button>\n <button class=\"mode-btn\" data-backend=\"auto\" onclick=\"setBackend('auto')\">Auto</button>\n <button class=\"mode-btn\" data-backend=\"gpt-mini\" onclick=\"setBackend('gpt-mini')\">GPT-4o-mini</button>\n <button class=\"mode-btn\" data-backend=\"gemini-flash\" onclick=\"setBackend('gemini-flash')\">Gemini Flash</button>\n </div>\n </div>\n <div style=\"font-size:12px;color:var(--text3);line-height:1.4\">\n <strong style=\"color:var(--brand2)\">\u26A1 Zest (local \u00B7 gratis):</strong> comprime con el modelo local v\u00EDa Ollama \u2014 sin red, sin coste, no toca tu cuota. Es la opci\u00F3n recomendada para experimentar (aunque hoy a\u00FAn rinde poco, ver arriba).<br>\n <strong style=\"color:var(--text2)\">Haiku / GPT-4o-mini / Gemini Flash (API):</strong> comprimen con un modelo en la nube. M\u00E1s calidad de resumen, pero <em>cuestan</em>: o una API key facturada aparte, o \u2014ojo\u2014 tu propia suscripci\u00F3n (ver riesgos). La elecci\u00F3n se guarda en <code>squeezr.toml</code> y sobrevive reinicios.\n </div>\n <div id=\"backend-warn\" style=\"display:none;font-size:12px;line-height:1.4;color:#fbbf24;background:rgba(251,191,36,.08);border:1px solid rgba(251,191,36,.3);border-radius:8px;padding:8px 10px\">\n \u26A0\uFE0F <strong>Haiku con suscripci\u00F3n Claude Code (token OAuth):</strong> cada llamada de compresi\u00F3n se factura contra tu cuota del plan de 5h \u2014 te lo come en minutos. Squeezr la <strong>bloquea autom\u00E1ticamente</strong> en este caso. Usa <strong>\u26A1 Zest (local)</strong> para AI compression gratis, o una API key facturada aparte.\n </div>\n </div>\n\n <!-- Riesgos -->\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start;gap:6px\">\n <span class=\"s-key\">\u26A0\uFE0F Riesgos que debes conocer</span>\n <ul class=\"ai-risks\">\n <li><strong>Coste contra tu suscripci\u00F3n:</strong> con un token OAuth de Claude Code, los backends <em>Haiku</em> y <em>Auto</em> facturar\u00EDan cada compresi\u00F3n contra tu plan de 5h. Squeezr lo bloquea autom\u00E1ticamente, pero por eso aparece el aviso. <strong>Zest local nunca consume cuota.</strong></li>\n <li><strong>P\u00E9rdida de fidelidad:</strong> la IA <em>resume</em> y puede omitir detalle. Los datos estructurados (JSON, JSONL, tablas) est\u00E1n protegidos y nunca pasan por IA, y todo bloque comprimido es recuperable con <code style=\"font-size:11px\">squeezr_expand</code>.</li>\n <li><strong>Latencia:</strong> cada llamada de IA a\u00F1ade tiempo a la petici\u00F3n. El circuit breaker desactiva la IA tras 3 fallos seguidos y vuelve a determin\u00EDstica.</li>\n <li><strong>Ahorro negativo en bloques peque\u00F1os:</strong> por debajo de ~1.5k caracteres la IA suele <em>expandir</em> en lugar de comprimir; por eso hay un m\u00EDnimo de tama\u00F1o antes de llamarla.</li>\n </ul>\n </div>\n\n <!-- Circuit breaker -->\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start;gap:4px\">\n <div style=\"display:flex;justify-content:space-between;width:100%;align-items:center\">\n <span class=\"s-key\">Circuit Breaker</span>\n <span class=\"s-val\"><code id=\"cfg-cb\">\u2014</code></span>\n </div>\n <div style=\"font-size:12px;color:var(--text3);line-height:1.4\">\n Protege contra picos de latencia. Si el modelo de IA falla <strong style=\"color:var(--text2)\">3 veces seguidas</strong>, auto-desactiva la IA y cae a reglas determin\u00EDsticas. Vuelve a la normalidad tras 60s sin errores. La determin\u00EDstica siempre sigue activa.\n </div>\n </div>\n </div>\n\n <!-- Token savings by client \u2014 toggle -->\n <div class=\"settings-block\">\n <div class=\"settings-head\" style=\"cursor:pointer;display:flex;align-items:center;justify-content:space-between\" onclick=\"toggleClientBreakdown()\">\n <span>Token savings by client</span>\n <svg id=\"cli-chevron\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" style=\"transition:transform .2s;color:var(--text3)\">\n <polyline points=\"6 9 12 15 18 9\"/>\n </svg>\n </div>\n <div id=\"cli-breakdown\" style=\"display:none\">\n <div id=\"cli-breakdown-body\" style=\"padding:14px 20px\">\n <span style=\"font-size:13px;color:var(--text3)\">No client data yet \u2014 starts tracking after first request.</span>\n </div>\n </div>\n </div>\n\n <div class=\"settings-block\">\n <div class=\"settings-head\">Connected CLIs & Apps</div>\n <div class=\"chips\">\n <div class=\"chip\"><div class=\"chip-dot\"></div>Claude Code</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Claude Desktop</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Codex Desktop</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Codex CLI</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Aider</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Gemini CLI</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Cursor</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Continue.dev</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Windsurf</div>\n <div class=\"chip\"><div class=\"chip-dot\"></div>Cline</div>\n </div>\n </div>\n\n <div class=\"settings-block\">\n <div class=\"settings-head\">Actions</div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;width:100%\">\n <span class=\"s-key\">Status</span>\n <button class=\"action-btn\" onclick=\"runAction('status')\">Check Status</button>\n </div>\n <div class=\"action-result\" id=\"action-result-status\"></div>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;width:100%\">\n <span class=\"s-key\">Restart Proxy</span>\n <button class=\"action-btn\" onclick=\"runAction('restart')\">Restart</button>\n </div>\n <div class=\"action-result\" id=\"action-result-restart\"></div>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;width:100%\">\n <span class=\"s-key\">Stop Proxy</span>\n <button class=\"action-btn danger\" onclick=\"runAction('stop')\">Stop Proxy</button>\n </div>\n <div class=\"action-result\" id=\"action-result-stop\"></div>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;width:100%\">\n <span class=\"s-key\">Update Squeezr</span>\n <button class=\"action-btn\" onclick=\"runAction('update')\">Update to latest</button>\n </div>\n <div class=\"action-result\" id=\"action-result-update\"></div>\n </div>\n <div class=\"settings-row\" style=\"flex-direction:column;align-items:flex-start\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;width:100%;gap:12px\">\n <span class=\"s-key\" style=\"flex-shrink:0\">Ports</span>\n <div style=\"display:flex;align-items:center;gap:8px;flex:1;justify-content:flex-end\">\n <input id=\"inp-http-port\" type=\"number\" placeholder=\"HTTP\" style=\"width:80px;padding:5px 10px;border-radius:7px;border:1px solid var(--border2);background:var(--surface2);color:var(--text);font-size:12px;font-family:inherit\">\n <input id=\"inp-mitm-port\" type=\"number\" placeholder=\"MITM\" style=\"width:80px;padding:5px 10px;border-radius:7px;border:1px solid var(--border2);background:var(--surface2);color:var(--text);font-size:12px;font-family:inherit\">\n <button class=\"action-btn\" onclick=\"runAction('ports')\">Apply</button>\n </div>\n </div>\n <div class=\"action-result\" id=\"action-result-ports\"></div>\n </div>\n </div>\n </div>\n\n </main>\n</div>\n\n<script>\n// \u2500\u2500 Theme \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nvar MOON = '<circle cx=\"12\" cy=\"12\" r=\"5\"/><line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\"/><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"/><line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\"/><line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\"/><line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\"/><line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\"/><line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\"/><line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\"/>';\nvar SUN = '<path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\"/>';\n\n(function(){\n var t = localStorage.getItem('sq-theme') || 'dark';\n setTheme(t, false);\n})();\n\nfunction setTheme(t, save) {\n if (t === 'dark') {\n document.documentElement.classList.add('dark');\n document.getElementById('theme-icon').innerHTML = MOON;\n document.querySelector('.theme-label') && (document.querySelector('.theme-label').textContent = 'Light mode');\n } else {\n document.documentElement.classList.remove('dark');\n document.getElementById('theme-icon').innerHTML = SUN;\n document.querySelector('.theme-label') && (document.querySelector('.theme-label').textContent = 'Dark mode');\n }\n if (save !== false) localStorage.setItem('sq-theme', t);\n}\n\nfunction toggleTheme() {\n var isDark = document.documentElement.classList.contains('dark');\n setTheme(isDark ? 'light' : 'dark');\n}\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\nfunction go(page) {\n document.querySelectorAll('.nb-tab').forEach(function(el) {\n el.classList.toggle('active', el.dataset.page === page);\n });\n document.getElementById('page-overview').style.display = page === 'overview' ? '' : 'none';\n document.getElementById('page-savings').style.display = page === 'savings' ? '' : 'none';\n document.getElementById('page-settings').style.display = page === 'settings' ? '' : 'none';\n if (page === 'savings') loadSavings();\n try { localStorage.setItem('sq-page', page); } catch(e) {}\n}\n\n// Restore last tab on load\n(function(){\n var saved = localStorage.getItem('sq-page');\n if (saved && saved !== 'overview') go(saved);\n})();\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\nfunction fmt(n) {\n if (n == null || isNaN(n)) return '\u2014';\n if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';\n if (n >= 1000) return (n / 1000).toFixed(1) + 'k';\n return String(Math.round(n));\n}\nfunction fmtUsd(n) {\n if (n == null || isNaN(n) || n === 0) return '\u2014';\n if (n < 0.01) return '<$0.01';\n return '$' + Number(n).toFixed(2);\n}\nfunction fmtRatio(r) {\n if (r == null) return '\u2014';\n return Math.round((1 - r) * 100) + '%';\n}\nfunction fmtUptime(s) {\n if (s == null) return '\u2014';\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 esc(s) {\n return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n}\n\n// \u2500\u2500 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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nvar lastStats = null;\n\nfunction render(d) {\n if (!d) return;\n lastStats = d;\n\n // \u2500\u2500 Normalize field names (API uses snake_case with various naming conventions) \u2500\u2500\n // tokens \u2014 server uses CHARS_PER_TOKEN=3.5, match it here for consistency\n var tokensSaved = d.total_saved_tokens || d.tokens_saved || 0;\n var tokensIn = Math.round((d.total_original_chars || 0) / 3.5); // same ratio as stats.ts\n // ratio: API gives savings_pct (0-100), or compression_ratio (0-1)\n var ratioPct = d.savings_pct != null ? d.savings_pct\n : d.compression_ratio != null ? Math.round((1 - d.compression_ratio) * 100)\n : null;\n // cost estimate: if not provided, estimate from saved tokens at ~$3/1M tokens\n var costUsd = d.cost_saved_usd != null ? d.cost_saved_usd\n : tokensSaved > 0 ? tokensSaved * 0.000003 : null;\n // requests\n var reqs = d.requests != null ? d.requests : (d.total_requests || 0);\n var aiComps = d.compressions != null ? d.compressions : (d.compressed || 0);\n var cacheHitsAi = (d.session_cache_hits != null ? d.session_cache_hits : 0);\n var comps = aiComps + cacheHitsAi; // AI calls + session cache reuses\n // latency: nested object { total: { p50, p95, p99 } } or flat\n var lat = (d.latency && d.latency.total) ? d.latency.total : d.latency || {};\n var p50 = lat.p50 != null ? lat.p50 : d.latency_p50;\n var p95 = lat.p95 != null ? lat.p95 : d.latency_p95;\n var p99 = lat.p99 != null ? lat.p99 : d.latency_p99;\n // Session cache: reuses = session_cache_hits, expands = Claude expand calls, lru = AI compression LRU size\n var cacheHits = d.session_cache_hits || 0;\n var cacheMiss = (d.expand && d.expand.calls != null) ? d.expand.calls : 0;\n var cacheSize = (d.cache && d.cache.size != null) ? d.cache.size : 0;\n // bypass\n var byp = !!(d.bypassed || d.bypass);\n var mode = d.mode || 'normal';\n\n // Sidebar version\n if (d.version) document.getElementById('sb-ver').textContent = 'v' + d.version;\n\n // Overview = TODAY: model/client breakdowns come from today's date-stamped data,\n // not all-time. Fall back to {} when there's no data yet today.\n var todayByModel = (d.today && d.today.by_model) || {};\n var todayByClient = (d.today && d.today.by_client) || {};\n // Cost comparison (#7) \u2014 weighted by actual models used today (computed first so hero card can use it)\n var modelCosts = calcCostFromModels(todayByModel, true);\n\n// Hero cards \u2014 OVERVIEW = TODAY (local calendar day, 00:00\u2013now). Sourced from the\n // date-stamped daily counters in stats.json, NOT the all-time totals. If no request\n // has happened since midnight, these are 0 (never falls back to all-time).\n var today = d.today || {};\n var tSaved = today.saved_tokens || 0;\n var tIn = today.original_tokens || 0;\n var tRatio = today.savings_pct != null ? today.savings_pct : null;\n var tReqs = today.requests || 0;\n var tComps = today.ai_calls || 0;\n var tCost = tSaved > 0 ? tSaved * 0.000003 : null;\n document.getElementById('h-saved').textContent = fmt(tSaved);\n document.getElementById('h-in').textContent = fmt(tIn);\n // LEFT ratio = overall today wire reduction (saved/original, cumulative day).\n // Stable by nature \u2014 it's a daily average. (The old \"non-cached\" metric measured\n // the post-barrier tail, which in Claude Code is the recent UNcompressed messages\n // \u2192 always ~0 \u2192 it silently fell back to this same number, so both read equal.)\n document.getElementById('h-ratio').textContent = tRatio != null ? Math.round(tRatio) + '%' : '\u2014';\n document.getElementById('h-cost').textContent = fmtUsd(tCost);\n document.getElementById('h-reqs').textContent = fmt(tReqs);\n document.getElementById('h-comp').textContent = fmt(tComps);\nvar perEl = document.getElementById('overview-period');\n if (perEl && today.date) perEl.textContent = 'today \u00B7 ' + today.date;\n // (No quality banner in the Overview \u2014 only genuine quality issues, high expand\n // rate, are logged to the Live Log by the governor. Benign reject-rate is silent.)\n // Per-request metric (stable, doesn't dilute): avg tokens saved per request + last request %\n var avgPerReq = (tReqs > 0) ? Math.round(tSaved / tReqs) : 0;\n var lastOrig = d.last_original_chars || 0;\n var lastComp = d.last_compressed_chars || 0;\n var lastPct = lastOrig > 0 ? Math.round((lastOrig - lastComp) / lastOrig * 100) : null;\n var prEl = document.getElementById('h-perreq');\n // Efficiency = % saved on the content we actually compress (not diluted by the\n // recent/kept/uncompressible payload). This is the \"fair\" compression number.\nvar eff = (today.efficiency_pct != null) ? today.efficiency_pct : null;\n // Two big percentages side by side: left = total saved (wire reduction),\n // right = engine efficiency (% on the blocks we actually compress).\n// Right number = overall % over the WHOLE request (dragged down by the cached\n // prefix). Shown next to the non-cached % so the difference is visible.\n void eff;\n // RIGHT ratio = the LAST request's reduction \u2014 changes every turn (directly\n // answers \"why is it always the same?\": this one moves with the actual content).\n var engEl = document.getElementById('h-engine');\n if (engEl) engEl.textContent = lastPct != null ? lastPct + '%' : '\u2014';\n if (prEl) prEl.textContent = avgPerReq > 0 ? '~' + fmt(avgPerReq) + ' tok/req' : '\u2014';\n\n // Latency (elements removed from Overview but kept for potential future use)\n var lp = function(id, v){ var e = document.getElementById(id); if(e) e.textContent = v != null ? v : '\u2014'; };\n lp('l-50', p50); lp('l-95', p95); lp('l-99', p99);\n\n// Session cache\n document.getElementById('c-hits').textContent = fmt(cacheHits);\n document.getElementById('c-miss').textContent = fmt(cacheMiss);\n document.getElementById('c-rate').textContent = cacheSize > 0 ? fmt(cacheSize) : '\u2014';\n // AI Compression card \u2014 TODAY-scoped (consistent with the hero), persists across\n // restart and resets at midnight. Calls/Spent = real backend usage today; Saved =\n // today's AI char savings. Avoids the all-time-vs-today mismatch that made the\n // deterministic share look impossibly small.\n if (d.today) {\n var aiCalls = d.today.ai_calls || 0; // real AI backend calls today\n var localCalls = d.today.ai_local_calls || 0;\n var cloudCalls = aiCalls - localCalls;\n var aiSpentTok = d.today.ai_spent_tokens || 0; // cloud tokens (local is free)\n var aiSavedTok = d.today.ai_saved_tokens || 0;\n var setAi = function(id, v){ var e = document.getElementById(id); if(e) e.textContent = v; };\n setAi('ai-calls', fmt(aiCalls));\n setAi('ai-saved', aiSavedTok > 0 ? fmt(aiSavedTok) : '\u2014');\n setAi('ai-spent', localCalls > 0 && cloudCalls === 0 ? 'free' : (aiSpentTok > 0 ? fmt(aiSpentTok) : '\u2014'));\n var netEl = document.getElementById('ai-net');\n if (netEl) {\n if (aiCalls === 0 && aiSavedTok > 0) {\n // Savings with no real backend calls = blocks compressed on an earlier\n // request and replayed for free from the compression cache (session/LRU).\n netEl.textContent = fmt(aiSavedTok) + ' tokens saved \u2014 reused from compression cache (no AI calls needed)';\n netEl.style.color = 'var(--brand2)';\n } else if (aiCalls === 0) {\n netEl.textContent = 'No AI calls yet';\n } else if (cloudCalls === 0) {\n // All local: 100% free savings, no spend.\n netEl.textContent = localCalls + ' local Zest call(s) \u00B7 ' + fmt(aiSavedTok) + ' tokens saved (free)';\n netEl.style.color = 'var(--brand2)';\n } else {\n var net = aiSavedTok - aiSpentTok;\n var sign = net >= 0 ? '+' : '\u2212';\n netEl.textContent = 'Net: ' + sign + fmt(Math.abs(net)) + ' tokens (saved \u2212 spent) \u00B7 ' + localCalls + ' local + ' + cloudCalls + ' cloud';\n netEl.style.color = net >= 0 ? 'var(--brand2)' : 'var(--red, #e5484d)';\n }\n }\n }\n\n // Tools\n renderTools(d.by_tool || d.tools);\n\n // Limits\n renderLimits(d.limits);\n // Live Log feed\n renderLiveLog(d.activity);\n // Prompt cache health (Anthropic) \u2014 read vs creation tokens this session\n var au = d.limits && d.limits.anthropic && d.limits.anthropic.usage;\n if (au) {\n var pcRead = au.cacheReadSession || 0;\n var pcCreate = au.cacheCreationSession || 0;\n var setPc = function(id, v){ var e = document.getElementById(id); if(e) e.textContent = v; };\n setPc('pc-read', pcRead > 0 ? fmt(pcRead) : '\u2014');\n setPc('pc-create', pcCreate > 0 ? fmt(pcCreate) : '\u2014');\n var healthEl = document.getElementById('pc-health');\n if (healthEl) {\n if (pcRead + pcCreate === 0) {\n healthEl.textContent = '\u2014';\n } else {\n var hitPct = Math.round(pcRead / (pcRead + pcCreate) * 100);\n healthEl.textContent = hitPct + '%';\n healthEl.style.color = hitPct >= 80 ? 'var(--brand2)' : hitPct >= 50 ? '#fbbf24' : 'var(--red, #e5484d)';\n }\n }\n }\n // Cost Comparison is OVERVIEW = TODAY: use today's tokens (not all-time), so the\n // sub-lines match the today money. (modelCosts already comes from today's models.)\n var cmpIn = today.original_tokens || 0;\n var cmpSaved = today.saved_tokens || 0;\n var cmpActual = cmpIn - cmpSaved;\n var cmpRatio = today.savings_pct != null ? today.savings_pct : null;\n var costSaved, costWithout, costWith, priceNote;\n if (modelCosts && modelCosts.totalCost > 0) {\n // Precise: model-weighted pricing\n costSaved = modelCosts.savedCost;\n costWithout = modelCosts.totalCost;\n costWith = modelCosts.totalCost - modelCosts.savedCost;\n priceNote = 'model-weighted pricing';\n } else {\n // Fallback: flat $3/1M\n var flat = 0.000003;\n costSaved = cmpSaved * flat;\n costWithout = cmpIn * flat;\n costWith = cmpActual * flat;\n priceNote = 'est. $3/1M (no model data)';\n }\n var setTxt = function(id, v){ var e = document.getElementById(id); if(e) e.textContent = v; };\n setTxt('sp-without', costWithout > 0 ? fmtUsd(costWithout) : '\u2014');\n setTxt('sp-without-tok', cmpIn > 0 ? '~' + fmt(cmpIn) + ' tokens' : '\u2014');\n setTxt('sp-with', costWith > 0 ? fmtUsd(costWith) : '\u2014');\n setTxt('sp-with-tok', cmpActual > 0 ? '~' + fmt(cmpActual) + ' tokens' : '\u2014');\n setTxt('sp-saved', costSaved > 0 ? fmtUsd(costSaved) : '\u2014');\n setTxt('sp-saved-pct', (cmpRatio != null ? Math.round(cmpRatio) + '% \u00B7 ' : '') + priceNote);\n var noteEl = document.getElementById('cost-note'); if(noteEl) noteEl.textContent = priceNote;\n // Model breakdown section (today)\n renderModelBreakdown(todayByModel, d.ai_usage && d.ai_usage.by_model);\n\n // CLI breakdown (#8) (today)\n renderClientBreakdown(todayByClient);\n // Overview = TODAY: use today's per-technique breakdown + today's net saved.\n renderBreakdown((d.today && d.today.breakdown) || d.breakdown, tSaved);\n\n // Mode & bypass\n updateMode(mode, byp);\n\n // AI compression toggle state (overview badge + button, settings button)\n var aiOn = !!d.ai_compression_enabled;\n var aiBadge = document.getElementById('ai-comp-badge');\n if (aiBadge) {\n aiBadge.textContent = 'AI: ' + (aiOn ? 'on' : 'off');\n aiBadge.className = 'badge' + (aiOn ? ' yellow' : '');\n }\n var aiBtn = document.getElementById('ai-comp-btn');\n if (aiBtn) {\n aiBtn.textContent = 'AI Compression: ' + (aiOn ? 'ON' : 'OFF');\n aiBtn.className = 'bypass-btn' + (aiOn ? ' active' : '');\n }\n var aiBtnSet = document.getElementById('ai-comp-btn-settings');\n if (aiBtnSet) aiBtnSet.textContent = aiOn ? 'ON \u2014 turn off' : 'OFF \u2014 turn on';\n\n // Settings page \u2014 ports come from health endpoint (d.port / d.mitm_port)\n var httpPort = d.port || window.location.port || '8080';\n var mitmPort = d.mitm_port || (parseInt(String(httpPort)) + 1);\n var setEl = function(id, v){ var e = document.getElementById(id); if(e) e.textContent = v; };\n setEl('cfg-url-val', 'http://localhost:' + httpPort);\n setEl('cfg-oai-val', 'http://localhost:' + httpPort + '/v1');\n setEl('cfg-gem-val', 'http://localhost:' + httpPort);\n setEl('cfg-mitm-val', 'http://localhost:' + mitmPort);\n var ih = document.getElementById('inp-http-port'); if(ih && !ih.value) ih.value = String(httpPort);\n var im = document.getElementById('inp-mitm-port'); if(im && !im.value) im.value = String(mitmPort);\n if (d.version) document.getElementById('cfg-ver').textContent = d.version;\n if (d.uptime_seconds != null) document.getElementById('cfg-uptime').textContent = fmtUptime(d.uptime_seconds);\n document.getElementById('cfg-mode').textContent = mode;\n document.getElementById('cfg-bypass').textContent = byp ? 'enabled' : 'disabled';\n // Backend selector state\n if (d.compression_backend) updateBackendButtons(d.compression_backend);\n // Native compact toggle state\n var ncBtn = document.getElementById('native-compact-btn');\n if (ncBtn) {\n var ncOn = !!d.anthropic_native_compact;\n ncBtn.textContent = ncOn ? 'ON' : 'OFF';\n ncBtn.style.background = ncOn ? 'var(--brand)' : '';\n ncBtn.style.color = ncOn ? 'white' : '';\n }\n if (d.circuit_breaker) {\n var cb = d.circuit_breaker;\n document.getElementById('cfg-cb').textContent = cb.state + (cb.total_trips ? ' \u00B7 ' + cb.total_trips + ' trips' : '');\n }\n}\n\n// Build HTML for a tools object \u2014 used by overview (all-time) and savings (per-period)\nfunction buildToolsHtml(tools) {\n var empty = '<span style=\"font-size:13px;color:var(--text3)\">No tool data for this period.</span>';\n if (!tools || typeof tools !== 'object') return empty;\n // tools can be { ToolName: count } or { ToolName: { count, saved_tokens } }\n var entries = Object.entries(tools)\n .map(function(e){ return [e[0], typeof e[1] === 'object' ? e[1].count || 0 : e[1]]; })\n .filter(function(e){ return e[1] > 0; })\n .sort(function(a,b){ return b[1]-a[1]; })\n .slice(0,6);\n if (!entries.length) return empty;\n var max = entries[0][1];\n return entries.map(function(e){\n var pct = max > 0 ? Math.round(e[1]/max*100) : 0;\n return '<div class=\"tool-row\"><span class=\"tool-name\">'+esc(e[0])+'</span>'+\n '<div class=\"tool-track\"><div class=\"tool-fill\" style=\"width:'+pct+'%\"></div></div>'+\n '<span class=\"tool-count\">'+fmt(e[1])+'</span></div>';\n }).join('');\n}\nfunction renderTools(tools) {\n var el = document.getElementById('tools-body');\n if (el) el.innerHTML = buildToolsHtml(tools);\n}\n\n// \u2500\u2500 Live Log feed \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Shows the REAL compression log lines (e.g. \"[squeezr/det] Deterministic:\n// -32,323 chars (~9235 tokens) across 54 block(s)\"), newest at the BOTTOM.\nvar llMaxId = 0;\nfunction llTimeAgo(ts) {\n var s = Math.max(0, Math.round((Date.now() - ts) / 1000));\n if (s < 60) return s + 's';\n var m = Math.floor(s / 60);\n if (m < 60) return m + 'm';\n return Math.floor(m / 60) + 'h';\n}\n// Map the \"[squeezr/xxx]\" tag to a colour class.\nfunction llTagClass(tag) {\n if (/dedup/.test(tag)) return 'dedup';\n if (/ai|haiku|gpt|gemini|zest|ollama/.test(tag)) return 'ai';\n if (/tool-desc|mcp/.test(tag)) return 'tooldesc';\n if (/det/.test(tag)) return 'det';\n return 'other';\n}\nfunction renderLiveLog(activity) {\n var el = document.getElementById('livelog-body');\n if (!el) return;\n if (!activity || !activity.length) {\n if (!el.querySelector('.ll-list')) el.innerHTML = '<div class=\"ll-empty\">Waiting for activity\u2026</div>';\n return;\n }\n // Oldest \u2192 newest so the newest ends up at the bottom of the column.\n var rows = activity.slice(-30);\n var html = rows.map(function(e){\n var isNew = e.id > llMaxId && llMaxId > 0;\n // Split \"[tag] rest of the line\" so we can colour the tag.\n var m = String(e.text).match(/^([[^]]*])s*([sS]*)$/);\n var tag = m ? m[1] : '';\n var rest = m ? m[2] : String(e.text);\n var cls = llTagClass(tag);\n // Highlight the \"-N chars\" / \"~N tokens\" numbers.\n var msg = esc(rest).replace(/(-?[d.,]+s*(?:chars|tokens))/g, '<span class=\"num\">$1</span>');\n return '<div class=\"ll-row' + (isNew ? ' ll-new' : '') + '\" data-id=\"' + e.id + '\" title=\"' + esc(e.text) + '\">' +\n '<span class=\"ll-tag ' + cls + '\">' + esc(tag) + '</span>' +\n '<span class=\"ll-msg\">' + msg + '</span>' +\n '<span class=\"ll-time\">' + llTimeAgo(e.ts) + '</span>' +\n '</div>';\n }).join('');\n el.innerHTML = '<div class=\"ll-list\">' + html + '</div>';\n // Always keep the newest line (last child, at the bottom) in view: scroll to the\n // bottom so older lines scroll up and out of the top edge (terminal-tail style).\n el.scrollTop = el.scrollHeight;\n for (var i = 0; i < rows.length; i++) if (rows[i].id > llMaxId) llMaxId = rows[i].id;\n}\n\nfunction renderLimits(lim) {\n var el = document.getElementById('limits-body');\n if (!lim || typeof lim !== 'object') {\n el.innerHTML = '<div class=\"lim-nodata\">No limit data yet.</div>';\n return;\n }\n var claudeRows = [];\n var openaiRows = [];\n var rows = claudeRows; // default alias for Claude section\n\n // \u2500\u2500 Claude / Anthropic \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 var a = lim.anthropic;\n if (a) {\n // unified = Claude Code Max / subscription plan rate limits\n var u = a.unified;\n if (u && u.hasData) {\n var p5 = Math.round(u.fiveHourUtilization * 100);\n var p7 = Math.round(u.sevenDayUtilization * 100);\n var c5 = p5 > 90 ? 'crit' : p5 > 70 ? 'warn' : 'ok';\n var c7 = p7 > 90 ? 'crit' : p7 > 70 ? 'warn' : 'ok';\n var reset5 = u.fiveHourResetEpoch ? new Date(u.fiveHourResetEpoch).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '';\n var reset7 = u.sevenDayResetEpoch ? new Date(u.sevenDayResetEpoch).toLocaleDateString([], {month:'short',day:'numeric'}) : '';\n rows.push(limRow('Claude 5h window', p5, c5, p5 + '% used' + (reset5 ? ' \u00B7 resets ' + reset5 : '')));\n rows.push(limRow('Claude 7d window', p7, c7, p7 + '% used' + (reset7 ? ' \u00B7 resets ' + reset7 : '')));\n }\n // rl = standard API rate limit headers (available with API key, not subscription)\n var rl = a.rl;\n if (rl && rl.hasData) {\n if (rl.tokensLimit > 0) {\n var used = rl.tokensLimit - rl.tokensRemaining;\n var pp = Math.round(used / rl.tokensLimit * 100);\n rows.push(limRow('Claude tokens/min', pp, pp > 90 ? 'crit' : pp > 70 ? 'warn' : 'ok',\n fmt(used) + ' / ' + fmt(rl.tokensLimit)));\n }\n if (rl.requestsLimit > 0) {\n var usedR = rl.requestsLimit - rl.requestsRemaining;\n var ppR = Math.round(usedR / rl.requestsLimit * 100);\n rows.push(limRow('Claude req/min', ppR, ppR > 90 ? 'crit' : ppR > 70 ? 'warn' : 'ok',\n usedR + ' / ' + rl.requestsLimit));\n }\n }\n }\n\n // \u2500\u2500 OpenAI / Codex \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 rows = openaiRows; // switch alias\n var o = lim.openai;\n if (o) {\n var os = o.session;\n if (os && os.hasData && os.primary) {\n var pp2 = os.primary.usedPercent || 0;\n var c2 = pp2 > 90 ? 'crit' : pp2 > 70 ? 'warn' : 'ok';\n var resetTs = os.primary.resetsAt ? new Date(os.primary.resetsAt * 1000).toLocaleDateString([],{month:'short',day:'numeric'}) : '';\n rows.push(limRow('Codex ' + (os.primary.windowDurationMins >= 10080 ? '7d' : os.primary.windowDurationMins + 'min'),\n pp2, c2, pp2 + '% used' + (resetTs ? ' \u00B7 resets ' + resetTs : '')));\n }\n if (o.usage && o.usage.inputSession) {\n rows.push('<div style=\"margin-top:10px;padding-top:10px;border-top:1px solid var(--border)\">' +\n '<div style=\"font-size:11px;color:var(--text3);margin-bottom:4px\">Codex tokens this session</div>' +\n '<div style=\"font-size:13px;color:var(--text2)\">' +\n 'In: <strong style=\"color:var(--text)\">' + fmt(o.usage.inputSession) + '</strong> ' +\n 'Out: <strong style=\"color:var(--text)\">' + fmt(o.usage.outputSession) + '</strong>' +\n '</div></div>');\n }\n }\n\n if (!claudeRows.length && !openaiRows.length) {\n el.innerHTML = '<div class=\"lim-nodata\">No limit data yet \u2014 appears after first API call.</div>';\n return;\n }\n\n var col = function(title, content) {\n return '<div>' +\n '<div style=\"font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:10px\">' + title + '</div>' +\n (content || '<div class=\"lim-nodata\" style=\"padding:0\">No data</div>') +\n '</div>';\n };\n\n el.innerHTML = '<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:20px\">' +\n col('Claude', claudeRows.length ? '<div class=\"limits-grid\">' + claudeRows.join('') + '</div>' : null) +\n col('Codex / OpenAI', openaiRows.length ? '<div class=\"limits-grid\">' + openaiRows.join('') + '</div>' : null) +\n '</div>';\n}\n\nfunction limRow(name, pct, cls, label) {\n return '<div class=\"lim-row\">'+\n '<span class=\"lim-name\">'+esc(name)+'</span>'+\n '<div class=\"lim-track\"><div class=\"lim-fill '+cls+'\" style=\"width:'+pct+'%\"></div></div>'+\n '<span class=\"lim-text\">'+esc(label)+'</span></div>';\n}\n\n// \u2500\u2500 Pricing table ($/1M tokens) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nvar PRICING = {\n // \u2500\u2500 Claude (verified May 2026 \u2014 claude.com/pricing) \u2500\u2500\n 'claude-opus-4-7': { input: 5, output: 25 }, // current flagship, Apr 2026\n 'claude-opus-4-6': { input: 5, output: 25 },\n 'claude-opus-4-5': { input: 5, output: 25 },\n 'claude-opus-4-1': { input: 15, output: 75 }, // legacy\n 'claude-opus-4': { input: 15, output: 75 }, // legacy\n 'claude-opus-3': { input: 15, output: 75 }, // legacy\n 'claude-sonnet-4-6': { input: 3, output: 15 },\n 'claude-sonnet-4-5': { input: 3, output: 15 },\n 'claude-sonnet-4': { input: 3, output: 15 },\n 'claude-3-7-sonnet': { input: 3, output: 15 },\n 'claude-3-5-sonnet': { input: 3, output: 15 },\n 'claude-3-sonnet': { input: 3, output: 15 },\n 'claude-haiku-4-5': { input: 1, output: 5 }, // current\n 'claude-haiku-3-5': { input: 0.8, output: 4 },\n 'claude-3-5-haiku': { input: 0.8, output: 4 },\n 'claude-3-haiku': { input: 0.25, output: 1.25 },\n // \u2500\u2500 OpenAI (verified May 2026 \u2014 openai.com/api/pricing) \u2500\u2500\n 'gpt-5-5-pro': { input: 30, output: 180 }, // research-grade\n 'gpt-5-5': { input: 5, output: 30 }, // current flagship\n 'gpt-5-4-pro': { input: 30, output: 180 },\n 'gpt-5-4': { input: 2.5, output: 15 }, // production workhorse\n 'gpt-5-4-mini': { input: 0.75, output: 4.5 },\n 'gpt-5-4-nano': { input: 0.20, output: 1.25 },\n 'gpt-5-3-codex': { input: 2.5, output: 15 },\n 'gpt-4o': { input: 2.5, output: 10 },\n 'gpt-4o-mini': { input: 0.15, output: 0.6 },\n 'gpt-4-1': { input: 2, output: 8 },\n 'gpt-4-1-mini': { input: 0.40, output: 1.6 },\n 'gpt-4-1-nano': { input: 0.10, output: 0.4 },\n 'gpt-4-turbo': { input: 10, output: 30 },\n 'gpt-4': { input: 30, output: 60 },\n 'o3': { input: 2, output: 8 }, // cut from $10 to $2 in 2026\n 'o3-mini': { input: 1.1, output: 4.4 },\n 'o4-mini': { input: 1.1, output: 4.4 },\n 'o1': { input: 15, output: 60 }, // legacy\n 'o1-mini': { input: 3, output: 12 },\n 'o1-pro': { input: 150, output: 600 },\n 'codex-mini-latest': { input: 1.5, output: 6 },\n // \u2500\u2500 Gemini \u2500\u2500\n 'gemini-2.5-pro': { input: 1.25, output: 10 },\n 'gemini-2.5-flash': { input: 0.075,output: 0.3 },\n 'gemini-2.0-flash': { input: 0.1, output: 0.4 },\n 'gemini-2.0-flash-lite': { input: 0.075,output: 0.3 },\n 'gemini-1.5-pro': { input: 1.25, output: 5 },\n 'gemini-1.5-flash': { input: 0.075,output: 0.3 },\n};\nvar DEFAULT_PRICE_INPUT = 3; // Claude Sonnet fallback\n\nfunction getModelPrice(model) {\n if (!model) return DEFAULT_PRICE_INPUT;\n var m = model.toLowerCase();\n // exact match first\n if (PRICING[m]) return PRICING[m].input;\n // prefix match (handles date-stamped variants like claude-sonnet-4-5-20251101)\n for (var key in PRICING) {\n if (m.startsWith(key) || m.includes(key)) return PRICING[key].input;\n }\n return DEFAULT_PRICE_INPUT;\n}\n\nfunction calcCostFromModels(byModel, getOriginal) {\n // If we have per-model breakdown, weight by actual model price\n if (!byModel || !Object.keys(byModel).length) return null;\n var totalCost = 0;\n var savedCost = 0;\n for (var model in byModel) {\n var data = byModel[model];\n var price = getModelPrice(model) / 1000000; // $/token\n var origTok = getOriginal ? data.original_tokens : 0;\n var savedTok = data.saved_tokens || 0;\n totalCost += origTok * price;\n savedCost += savedTok * price;\n }\n return { totalCost: totalCost, savedCost: savedCost };\n}\n\n// \u2500\u2500 Model breakdown \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Build HTML for a by_model object \u2014 used by overview (all-time) and savings (per-period)\nfunction buildModelHtml(byModel) {\n var noData = '<span style=\"font-size:13px;color:var(--text3)\">No model data for this period.</span>';\n if (!byModel || !Object.keys(byModel).length) return noData;\n var rows = Object.entries(byModel)\n .filter(function(e){ return e[1].requests > 0; })\n .sort(function(a,b){ return b[1].saved_tokens - a[1].saved_tokens; });\n if (!rows.length) return noData;\n var maxSaved = rows[0][1].saved_tokens || 1;\n return rows.map(function(e){\n var model = e[0];\n var data = e[1];\n var priceIn = getModelPrice(model);\n var savedCost = data.saved_tokens * priceIn / 1000000;\n var origCost = data.original_tokens * priceIn / 1000000;\n var pct = Math.round((data.saved_tokens / maxSaved) * 100);\n var priceLabel = priceIn === DEFAULT_PRICE_INPUT ? '$' + priceIn + '/1M (est.)' : '$' + priceIn + '/1M input';\n return '<div style=\"margin-bottom:14px\">' +\n '<div style=\"display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px\">' +\n '<span style=\"font-size:13px;font-weight:600;color:var(--text);font-family:monospace\">' + esc(model) + '</span>' +\n '<span style=\"font-size:12px;color:var(--text3)\">' + priceLabel + '</span>' +\n '</div>' +\n '<div style=\"display:flex;justify-content:space-between;align-items:baseline;margin-bottom:5px\">' +\n '<span style=\"font-size:12px;color:var(--text2)\">' + fmt(data.saved_tokens) + ' tokens saved \u00B7 ' + data.savings_pct + '%</span>' +\n '<span style=\"font-size:12px;color:var(--brand2);font-weight:600\">' + fmtUsd(savedCost) + ' saved</span>' +\n '</div>' +\n '<div style=\"height:6px;background:var(--surface3);border-radius:3px;overflow:hidden;margin-bottom:3px\">' +\n '<div style=\"height:100%;width:' + pct + '%;background:var(--brand);border-radius:3px;transition:width .4s\"></div>' +\n '</div>' +\n '<div style=\"font-size:11px;color:var(--text3)\">' +\n data.requests + ' req \u00B7 without Squeezr: ' + fmtUsd(origCost) + ' \u00B7 with: ' + fmtUsd(origCost - savedCost) +\n '</div>' +\n '</div>';\n }).join('');\n}\n\n// Build the \"compression cost\" rows \u2014 what each compression backend (Haiku,\n// GPT-mini, etc.) actually SPENT in tokens this session. Shown below the By Model\n// savings so the user sees the cost side of the AI compression layer.\nfunction buildCompressionCostHtml(aiByModel) {\n if (!aiByModel || !Object.keys(aiByModel).length) return '';\n var rows = Object.entries(aiByModel)\n .filter(function(e){ return (e[1].calls || 0) > 0; })\n .sort(function(a,b){ return (b[1].inputTokens + b[1].outputTokens) - (a[1].inputTokens + a[1].outputTokens); });\n if (!rows.length) return '';\n var html = '<div style=\"margin-top:14px;padding-top:12px;border-top:1px dashed var(--surface3)\">' +\n '<div style=\"font-size:11px;color:var(--text3);margin-bottom:10px;text-transform:uppercase;letter-spacing:.5px\">Compression cost (what the AI layer spends)</div>';\n html += rows.map(function(e){\n var model = e[0];\n var data = e[1];\n var spentTok = (data.inputTokens || 0) + (data.outputTokens || 0);\n var isLocal = model.indexOf('local:') === 0;\n var priceIn = isLocal ? 0 : getModelPrice(model);\n var spentCost = spentTok * priceIn / 1000000;\n return '<div style=\"margin-bottom:10px\">' +\n '<div style=\"display:flex;justify-content:space-between;align-items:baseline;margin-bottom:3px\">' +\n '<span style=\"font-size:12px;font-weight:600;color:var(--text);font-family:monospace\">' + esc(model.replace('local:', '')) + (isLocal ? ' <span style=\"color:var(--brand2);font-weight:400\">(local \u00B7 free)</span>' : '') + '</span>' +\n '<span style=\"font-size:12px;color:var(--text2)\">' + (isLocal ? '\u2014' : fmtUsd(spentCost)) + ' spent</span>' +\n '</div>' +\n '<div style=\"font-size:11px;color:var(--text3)\">' +\n data.calls + ' call' + (data.calls !== 1 ? 's' : '') + ' \u00B7 ' + fmt(spentTok) + ' tokens (' + fmt(data.inputTokens) + ' in / ' + fmt(data.outputTokens) + ' out)' +\n '</div>' +\n '</div>';\n }).join('');\n html += '</div>';\n return html;\n}\nfunction renderModelBreakdown(byModel, aiByModel) {\n var el = document.getElementById('model-body');\n if (el) el.innerHTML = buildModelHtml(byModel) + buildCompressionCostHtml(aiByModel);\n}\n\n// \u2500\u2500 Client breakdown (#8) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nvar clientOpen = false;\nvar CLIENT_LABELS = {\n claude_code: 'Claude Code',\n claude_desktop: 'Claude Desktop',\n aider: 'Aider',\n opencode: 'OpenCode',\n codex_desktop: 'Codex Desktop',\n cursor: 'Cursor',\n continue: 'Continue.dev',\n cline: 'Cline / Roo',\n windsurf: 'Windsurf',\n openai_other: 'OpenAI (other)',\n gemini: 'Gemini CLI',\n mitm: 'Codex CLI',\n};\n\nfunction setBackend(name) {\n fetch('/squeezr/backend', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ backend: name }),\n })\n .then(function(r){ return r.json(); })\n .then(function(d){ updateBackendButtons(d.backend); })\n .catch(function(){});\n}\n\nfunction updateBackendButtons(active) {\n var btns = document.querySelectorAll('button[data-backend]');\n for (var i = 0; i < btns.length; i++) {\n var b = btns[i];\n var isActive = b.getAttribute('data-backend') === active;\n b.className = 'mode-btn' + (isActive ? ' active' : '');\n }\n // Warn whenever the active backend can route to Haiku (auto/haiku). On an OAuth\n // subscription token Squeezr blocks it, but the user should understand why.\n var warn = document.getElementById('backend-warn');\n if (warn) warn.style.display = (active === 'haiku' || active === 'auto') ? 'block' : 'none';\n}\n\nfunction toggleNativeCompact() {\n fetch('/squeezr/native-compact', { method: 'POST' })\n .then(function(r){ return r.json(); })\n .then(function(d){\n var btn = document.getElementById('native-compact-btn');\n if (btn) {\n btn.textContent = d.enabled ? 'ON' : 'OFF';\n btn.style.background = d.enabled ? 'var(--brand)' : '';\n btn.style.color = d.enabled ? 'white' : '';\n }\n })\n .catch(function(){});\n}\n\nfunction toggleClientBreakdown() {\n clientOpen = !clientOpen;\n document.getElementById('cli-breakdown').style.display = clientOpen ? '' : 'none';\n document.getElementById('cli-chevron').style.transform = clientOpen ? 'rotate(180deg)' : '';\n}\n\n// Build HTML for a by_client object \u2014 used by overview, settings and savings (per-period)\nfunction buildClientHtml(byClient) {\n var noData = '<span style=\"font-size:13px;color:var(--text3)\">No client data for this period.</span>';\n if (!byClient || !Object.keys(byClient).length) return noData;\n var rows = Object.entries(byClient)\n .filter(function(e){ return e[1].requests > 0; })\n .sort(function(a,b){ return b[1].saved_tokens - a[1].saved_tokens; });\n if (!rows.length) return noData;\n var maxSaved = rows[0][1].saved_tokens || 1;\n return rows.map(function(e){\n var label = CLIENT_LABELS[e[0]] || e[0];\n var data = e[1];\n var pct = Math.round((data.saved_tokens / maxSaved) * 100);\n return '<div style=\"margin-bottom:12px\">' +\n '<div style=\"display:flex;justify-content:space-between;align-items:baseline;margin-bottom:5px\">' +\n '<span style=\"font-size:13px;font-weight:600;color:var(--text)\">' + esc(label) + '</span>' +\n '<span style=\"font-size:12px;color:var(--brand2);font-weight:600\">' + fmt(data.saved_tokens) + ' saved <span style=\"color:var(--text3);font-weight:400\">\u00B7 ' + data.savings_pct + '%</span></span>' +\n '</div>' +\n '<div style=\"height:6px;background:var(--surface3);border-radius:3px;overflow:hidden;margin-bottom:3px\">' +\n '<div style=\"height:100%;width:' + pct + '%;background:var(--brand);border-radius:3px;transition:width .4s\"></div>' +\n '</div>' +\n '<div style=\"font-size:11px;color:var(--text3)\">' + data.requests + ' req \u00B7 ~' + fmt(data.original_tokens) + ' tokens in</div>' +\n '</div>';\n }).join('');\n}\n\nfunction renderClientBreakdown(byClient) {\n var html = buildClientHtml(byClient);\n var elOverview = document.getElementById('client-body-overview');\n var elSettings = document.getElementById('cli-breakdown-body');\n if (elOverview) elOverview.innerHTML = html;\n if (elSettings) elSettings.innerHTML = html;\n}\n\n// \u2500\u2500 Savings by compression type \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// breakdown values are CHARS saved per technique (all-time, persisted in stats.json).\nvar BREAKDOWN_LABELS = {\n tool_results_det: 'Deterministic (tool output)',\n tool_results_ai: 'AI compression',\n read_dedup: 'Repeated-read dedup',\n tool_desc: 'Tool descriptions',\n mcp_filter: 'MCP tool filtering',\n stale_turns: 'Stale turn summaries',\n skill_dedup: 'Skill/plugin dedup',\n system_prompt: 'System prompt',\n};\nfunction renderBreakdown(bd, netTokens) {\n var el = document.getElementById('breakdown-body');\n if (!el) return;\n if (!bd || typeof bd !== 'object') {\n el.innerHTML = '<div style=\"font-size:13px;color:var(--text3)\">No data yet.</div>';\n return;\n }\n // Build rows (chars \u2192 tokens). Skip overhead/ai_calls (not savings) and zeros.\n var rows = Object.keys(BREAKDOWN_LABELS)\n .map(function(k){ return { key: k, label: BREAKDOWN_LABELS[k], chars: bd[k] || 0 }; })\n .filter(function(r){ return r.chars > 0; })\n .sort(function(a,b){ return b.chars - a.chars; });\n if (!rows.length) {\n el.innerHTML = '<div style=\"font-size:13px;color:var(--text3)\">No savings recorded yet.</div>';\n return;\n }\n var maxChars = rows[0].chars;\n var totalTok = rows.reduce(function(s,r){ return s + Math.round(r.chars/3.5); }, 0);\n var html = rows.map(function(r){\n var tok = Math.round(r.chars / 3.5);\n var pct = maxChars > 0 ? Math.round(r.chars / maxChars * 100) : 0;\n return '<div style=\"margin-bottom:12px\">' +\n '<div style=\"display:flex;justify-content:space-between;align-items:baseline;margin-bottom:5px\">' +\n '<span style=\"font-size:13px;font-weight:600;color:var(--text)\">' + esc(r.label) + '</span>' +\n '<span style=\"font-size:12px;color:var(--brand2);font-weight:600\">' + fmt(tok) + ' tokens</span>' +\n '</div>' +\n '<div style=\"height:6px;background:var(--surface3);border-radius:3px;overflow:hidden\">' +\n '<div style=\"height:100%;width:' + pct + '%;background:var(--brand);border-radius:3px;transition:width .4s\"></div>' +\n '</div>' +\n '</div>';\n }).join('');\n // The per-technique numbers are GROSS (each counts what it removed on its own).\n // They overlap and don't subtract the [squeezr:ID] tag overhead, so their sum is\n // larger than the real net saving. Show the net (same as the hero card) as the\n // authoritative total, and label the gross sum separately so it's not confusing.\n var grossTok = totalTok;\n var netTok = (netTokens != null && netTokens > 0) ? netTokens : grossTok;\n html += '<div style=\"margin-top:6px;padding-top:10px;border-top:1px solid var(--surface3)\">' +\n '<div style=\"display:flex;justify-content:space-between;font-size:12px;margin-bottom:3px\">' +\n '<span style=\"color:var(--text2);font-weight:600\">Net saved (real)</span>' +\n '<span style=\"color:var(--brand2);font-weight:700\">' + fmt(netTok) + ' tokens</span></div>' +\n '<div style=\"display:flex;justify-content:space-between;font-size:11px;color:var(--text3)\">' +\n '<span>gross per-technique (overlaps + tag overhead)</span>' +\n '<span>' + fmt(grossTok) + '</span></div>' +\n '</div>';\n el.innerHTML = html;\n}\n\nfunction updateMode(mode, byp) {\n var mb = document.getElementById('mode-badge');\n mb.textContent = 'mode: ' + mode;\n mb.className = 'badge' + (mode === 'off' ? ' red' : ' green');\n\n var bb = document.getElementById('bypass-badge');\n bb.textContent = byp ? 'bypass: on' : 'bypass: off';\n bb.className = 'badge' + (byp ? ' yellow' : '');\n\n document.querySelectorAll('.mode-btn').forEach(function(btn) {\n btn.className = 'mode-btn' + (btn.dataset.mode === mode ? (mode === 'off' ? ' active-off' : ' active') : '');\n });\n document.getElementById('bypass-btn').className = 'bypass-btn' + (byp ? ' active' : '');\n}\n\n// \u2500\u2500 Controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 setMode(mode) {\n fetch('/squeezr/config', {\n method:'POST', headers:{'Content-Type':'application/json'},\n body: JSON.stringify({mode: mode})\n }).then(function(r){\n if (r.ok && lastStats) { lastStats.mode = mode; updateMode(mode, !!(lastStats.bypass || lastStats.bypassed)); }\n });\n}\n\nfunction toggleBypass() {\n fetch('/squeezr/bypass', {method:'POST'}).then(function(r){ if(r.ok) poll(); });\n}\n\nfunction toggleAiCompression() {\n fetch('/squeezr/ai-compression', {method:'POST'}).then(function(r){ if(r.ok) poll(); });\n}\n\n// \u2500\u2500 Connection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nvar pollTimer = null, sseOk = false;\n\nfunction poll() {\n fetch('/squeezr/stats')\n .then(function(r){ return r.json(); })\n .then(function(d){\n setConn(true);\n try { render(d); } catch(e){ console.error('[squeezr] render error:', e); }\n })\n .catch(function(){ setConn(false); });\n}\n\nfunction setConn(ok) {\n document.getElementById('conn-dot').className = 'conn-dot ' + (ok ? 'online' : 'offline');\n document.getElementById('conn-label').textContent = ok ? 'Connected' : 'Offline';\n}\n\nfunction startPoll() {\n if (!pollTimer) { pollTimer = setInterval(poll, 5000); poll(); }\n}\n\nfunction connect() {\n var es;\n try { es = new EventSource('/squeezr/events'); }\n catch(e) { setConn(false); startPoll(); return; }\n\n var timer = setTimeout(function(){ if (!sseOk){ es.close(); setConn(false); startPoll(); } }, 6000);\n\n es.onopen = function(){ clearTimeout(timer); sseOk = true; setConn(true); clearInterval(pollTimer); pollTimer = null; };\n es.onmessage = function(ev){\n clearTimeout(timer);\n if (!sseOk){ sseOk = true; setConn(true); clearInterval(pollTimer); pollTimer = null; }\n try { render(JSON.parse(ev.data)); } catch(e){}\n };\n es.addEventListener('stats', function(ev){ try { render(JSON.parse(ev.data)); } catch(e){} });\n es.onerror = function(){\n clearTimeout(timer); sseOk = false; es.close(); setConn(false); startPoll();\n setTimeout(connect, 10000);\n };\n}\n\n// \u2500\u2500 Actions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 showResult(id, cls, msg) {\n var el = document.getElementById('action-result-' + id);\n if (!el) return;\n el.className = 'action-result ' + cls;\n el.textContent = msg;\n el.style.display = 'block';\n}\n\nfunction runAction(action) {\n if (action === 'status') {\n fetch('/squeezr/health').then(function(r) { return r.json(); }).then(function(h) {\n var msg = 'version: ' + (h.version || '?');\n if (h.uptime != null) msg += ' | uptime: ' + fmtUptime(h.uptime);\n if (h.mode) msg += ' | mode: ' + h.mode;\n showResult('status', 'ok', msg);\n }).catch(function(e) {\n showResult('status', 'err', 'Error: ' + e.message);\n });\n } else if (action === 'restart') {\n showResult('restart', 'ok', 'Restarting\u2026');\n fetch('/squeezr/control/restart', {method:'POST'}).then(function(r) {\n if (r.ok) {\n showResult('restart', 'ok', 'Restarted \u2014 reconnecting in 3s');\n setTimeout(function(){ connect(); loadSavings(); }, 3000);\n } else {\n showResult('restart', 'err', 'Run in terminal: squeezr restart');\n }\n }).catch(function() {\n showResult('restart', 'err', 'Run in terminal: squeezr restart');\n });\n } else if (action === 'stop') {\n fetch('/squeezr/control/stop', {method:'POST'}).then(function(r) {\n if (r.ok) {\n showResult('stop', 'ok', 'Proxy stopped');\n } else {\n showResult('stop', 'err', 'Run in terminal: squeezr stop');\n }\n }).catch(function() {\n showResult('stop', 'err', 'Run in terminal: squeezr stop');\n });\n } else if (action === 'update') {\n showResult('update', 'ok', 'Run in terminal: squeezr update');\n } else if (action === 'ports') {\n var httpVal = document.getElementById('inp-http-port').value.trim();\n var mitmVal = document.getElementById('inp-mitm-port').value.trim();\n var httpN = parseInt(httpVal);\n var mitmN = parseInt(mitmVal);\n if (!httpN || httpN < 1024 || httpN > 65535 || !mitmN || mitmN < 1024 || mitmN > 65535) {\n showResult('ports', 'err', 'Invalid ports \u2014 must be between 1024 and 65535');\n return;\n }\n if (httpN === mitmN) {\n showResult('ports', 'err', 'HTTP and MITM ports must be different');\n return;\n }\n fetch('/squeezr/ports', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ port: httpN, mitm_port: mitmN })\n }).then(function(r) {\n if (r.ok) {\nshowResult('ports', 'ok', 'Ports saved to squeezr.toml \u2014 run: squeezr restart');\n } else {\n r.text().then(function(t) { showResult('ports', 'err', 'Failed: ' + t); });\n }\n }).catch(function(e) { showResult('ports', 'err', e.message); });\n }\n}\n\n// \u2500\u2500 Version check \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Returns 1 if a > b, -1 if a < b, 0 if equal. Compares numeric major.minor.patch;\n// any non-numeric prerelease tail makes the version \"lower\" (so 1.46.0 > 1.46.0-rc.1).\nfunction compareSemver(a, b) {\n function parse(v) {\n var m = String(v || '').match(/^(d+).(d+).(d+)(?:-(.+))?/);\n if (!m) return [0, 0, 0, ''];\n return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10), m[4] || ''];\n }\n var pa = parse(a), pb = parse(b);\n for (var i = 0; i < 3; i++) {\n if (pa[i] > pb[i]) return 1;\n if (pa[i] < pb[i]) return -1;\n }\n if (pa[3] && !pb[3]) return -1;\n if (!pa[3] && pb[3]) return 1;\n return 0;\n}\n\nfunction checkLatestVersion() {\n fetch('/squeezr/health').then(function(r) { return r.json(); }).then(function(h) {\n var current = h.version;\n fetch('https://registry.npmjs.org/squeezr-ai/latest')\n .then(function(r) { return r.json(); }).then(function(npm) {\n var latest = npm.version;\n if (latest && current && compareSemver(latest, current) > 0) {\n var banner = document.getElementById('update-banner');\n document.getElementById('update-text').textContent = 'v' + current + ' \u2192 v' + latest;\n banner.style.display = 'flex';\n }\n }).catch(function(){});\n }).catch(function(){});\n}\n\n// \u2500\u2500 Savings 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\nvar savingsPeriod = 'day';\nvar savingsOffset = 0; // 0 = current period, -1 = previous, +1 = next\nvar savingsCache = null;\n\nfunction setSavingsPeriod(p) {\n savingsPeriod = p;\n savingsOffset = 0; // reset to current when changing scale\n ['day','week','month','all'].forEach(function(k){\n document.getElementById('period-' + k).className = 'mode-btn' + (k === p ? ' active' : '');\n });\n if (savingsCache) renderSavingsData(savingsCache);\n}\n\nfunction navigatePeriod(dir) {\n if (dir === 0) { savingsOffset = 0; }\n else { savingsOffset += dir; if (savingsOffset > 0) savingsOffset = 0; }\n if (savingsCache) renderSavingsData(savingsCache);\n}\n\n// Get [start, end] timestamps for the selected period+offset\nfunction getPeriodRange() {\n var now = new Date();\n var start, end, label;\n if (savingsPeriod === 'day') {\n var d = new Date(now); d.setDate(d.getDate() + savingsOffset);\n start = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n end = start + 86400000;\n label = d.toLocaleDateString([], {weekday:'short', day:'numeric', month:'short'});\n if (savingsOffset === 0) label = 'Today \u00B7 ' + label;\n else if (savingsOffset === -1) label = 'Yesterday \u00B7 ' + label;\n } else if (savingsPeriod === 'week') {\n var d = new Date(now); d.setDate(d.getDate() - d.getDay() + (savingsOffset * 7));\n start = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n end = start + 7 * 86400000;\n var endDate = new Date(end - 1);\n label = new Date(start).toLocaleDateString([], {day:'numeric', month:'short'}) + ' \u2013 ' + endDate.toLocaleDateString([], {day:'numeric', month:'short'});\n } else if (savingsPeriod === 'month') {\n var d = new Date(now.getFullYear(), now.getMonth() + savingsOffset, 1);\n start = d.getTime();\n end = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();\n label = d.toLocaleDateString([], {month:'long', year:'numeric'});\n } else { // all\n start = 0;\n end = Number.MAX_SAFE_INTEGER;\n label = 'All time';\n }\n return { start: start, end: end, label: label };\n}\n\nfunction loadSavings() {\n fetch('/squeezr/history').then(function(r){ return r.json(); }).then(function(d){\n savingsCache = d;\n renderSavingsData(d);\n }).catch(function(){\n document.getElementById('savings-chart').innerHTML = '<div class=\"lim-nodata\">Could not load history.</div>';\n });\n}\n\nfunction renderSavingsData(d) {\n var sessions = (d.sessions || []).slice();\n if (d.current && d.current.requests > 0) sessions.push(d.current);\n var range = getPeriodRange();\n var labelEl = document.getElementById('period-label');\n if (labelEl) labelEl.textContent = range.label;\n var filtered = sessions.filter(function(s){ return s.startTime >= range.start && s.startTime < range.end && s.savedTokens != null; });\n\n // Hero stats\n var totalSaved = 0, totalReqs = 0, totalOrig = 0, hasOrigData = true;\n filtered.forEach(function(s){\n totalSaved += s.savedTokens||0;\n totalReqs += s.requests||0;\n if (s.originalChars) {\n totalOrig += Math.round(s.originalChars / 3.5);\n } else {\n hasOrigData = false; // at least one session without original data\n }\n });\n var avgPct = totalOrig > 0 ? Math.round(totalSaved / (totalSaved + (totalOrig - totalSaved)) * 100) : 0;\n\n // Keep the \"Day\" headline identical to the Overview hero (same date-stamped\n // today_* source), instead of summing history sessions which over-count across\n // restarts. Fixes the \"Savings says 22M but Overview says 19M\" inconsistency.\n if (savingsPeriod === 'day' && lastStats && lastStats.today) {\n totalSaved = lastStats.today.saved_tokens || totalSaved;\n totalOrig = lastStats.today.original_tokens || totalOrig;\n totalReqs = lastStats.today.requests || totalReqs;\n hasOrigData = true;\n avgPct = lastStats.today.savings_pct != null ? Math.round(lastStats.today.savings_pct) : avgPct;\n }\n\n // Cost: use model-weighted if available from current stats, else flat $3/1M\n // Compute cost for this period using the blended $/saved-token rate from all-time model data.\n // This avoids the \"scale\" trick which produced inconsistent numbers vs the By Model section.\n var svModelCosts = (lastStats && lastStats.by_model) ? calcCostFromModels(lastStats.by_model, true) : null;\n var svCost, svCostNote;\n if (svModelCosts && svModelCosts.savedCost > 0) {\n var allTimeSavedTok = 0;\n if (lastStats && lastStats.by_model) {\n Object.keys(lastStats.by_model).forEach(function(m){ allTimeSavedTok += (lastStats.by_model[m].saved_tokens || 0); });\n }\n // Blended rate = all-time saved cost / all-time saved tokens \u2192 apply to period tokens\n var blendedRate = allTimeSavedTok > 0 ? svModelCosts.savedCost / allTimeSavedTok : 0.000003;\n svCost = totalSaved * blendedRate;\n svCostNote = 'model-weighted pricing';\n } else {\n svCost = totalSaved * 0.000003;\n svCostNote = 'est. at $3/1M tokens';\n }\ndocument.getElementById('sv-tokens').textContent = fmt(totalSaved);\n document.getElementById('sv-tokens-sub').textContent = hasOrigData && totalOrig > 0\n ? 'of ~' + fmt(totalOrig) + ' processed'\n : 'tokens saved';\n document.getElementById('sv-cost').textContent = fmtUsd(svCost);\n var svCostNoteEl = document.getElementById('sv-cost-note'); if(svCostNoteEl) svCostNoteEl.textContent = svCostNote;\n document.getElementById('sv-sessions').textContent = String(filtered.length);\n document.getElementById('sv-requests').textContent = totalReqs + ' requests';\n document.getElementById('sv-pct').textContent = avgPct > 0 ? avgPct + '%' : '\u2014';\n // Engine efficiency (% on compressed blocks) \u2014 only meaningful for \"Day\" (today's\n // date-stamped counters); other periods aren't tracked historically.\n var svEngineEl = document.getElementById('sv-engine');\n if (svEngineEl) {\n var svEff = (savingsPeriod === 'day' && lastStats && lastStats.today && lastStats.today.efficiency_pct != null) ? lastStats.today.efficiency_pct : null;\n svEngineEl.innerHTML = svEff != null && svEff > 0\n ? 'engine <strong style=\"color:var(--brand2)\">' + Math.round(svEff) + '%</strong> on compressed'\n : 'total saved (of all sent)';\n }\n // NOTE: the Overview hero is NOT synced from here anymore. It uses stats.json\n // (all-time net) as the single source of truth. This Savings page keeps its own\n // per-period view from history (which is fine for relative comparison between\n // periods, even if absolute sums over-count across restarts).\n\n // Chart title\n var titles = { day: 'Today (by session)', week: 'Last 7 days', month: 'Last 30 days', all: 'All time' };\n document.getElementById('savings-chart-title').textContent = titles[savingsPeriod] || '';\n\n // Bar chart: group by day for week/month/all, by session for day\n renderSavingsChart(filtered, savingsPeriod);\n\n // Per-period model/client breakdown \u2014 aggregated from the filtered sessions.\n // Session records store byModel/byClient since v1.56.0 (camelCase, token units).\n var aggModel = {}, aggClient = {};\n filtered.forEach(function(s){\n var bm = s.byModel || {};\n Object.keys(bm).forEach(function(m){\n if (!aggModel[m]) aggModel[m] = { requests: 0, original_tokens: 0, saved_tokens: 0, savings_pct: 0 };\n aggModel[m].requests += bm[m].requests || 0;\n aggModel[m].original_tokens += bm[m].originalTokens || 0;\n aggModel[m].saved_tokens += bm[m].savedTokens || 0;\n });\n var bc = s.byClient || {};\n Object.keys(bc).forEach(function(cl){\n if (!aggClient[cl]) aggClient[cl] = { requests: 0, original_tokens: 0, saved_tokens: 0, savings_pct: 0 };\n aggClient[cl].requests += bc[cl].requests || 0;\n aggClient[cl].original_tokens += bc[cl].originalTokens || 0;\n aggClient[cl].saved_tokens += bc[cl].savedTokens || 0;\n });\n });\n Object.keys(aggModel).forEach(function(m){\n var a = aggModel[m];\n a.savings_pct = a.original_tokens > 0 ? Math.round(a.saved_tokens / a.original_tokens * 1000) / 10 : 0;\n });\n Object.keys(aggClient).forEach(function(cl){\n var a = aggClient[cl];\n a.savings_pct = a.original_tokens > 0 ? Math.round(a.saved_tokens / a.original_tokens * 1000) / 10 : 0;\n });\n var mEl = document.getElementById('model-body-savings');\n if (mEl) mEl.innerHTML = buildModelHtml(aggModel);\n var cEl = document.getElementById('client-body-savings');\n if (cEl) cEl.innerHTML = buildClientHtml(aggClient);\n\n // Per-period Top Tools (byTool persists since the original SessionRecord)\n var aggTools = {};\n filtered.forEach(function(s){\n var bt = s.byTool || {};\n Object.keys(bt).forEach(function(t){\n if (!aggTools[t]) aggTools[t] = { count: 0, saved_tokens: 0 };\n aggTools[t].count += bt[t].count || 0;\n aggTools[t].saved_tokens += bt[t].savedTokens || 0;\n });\n });\n var tEl = document.getElementById('tools-body-savings');\n if (tEl) tEl.innerHTML = buildToolsHtml(aggTools);\n\n // Per-period AI compression + session cache (persisted since v1.61.0)\n var aiCalls = 0, aiInTok = 0, aiOutTok = 0, aiSavedTok = 0, cacheReuses = 0, cacheExpands = 0, withExtras = 0;\n filtered.forEach(function(s){\n if (s.aiUsage) {\n aiCalls += s.aiUsage.calls || 0;\n aiInTok += s.aiUsage.inputTokens || 0;\n aiOutTok += s.aiUsage.outputTokens || 0;\n aiSavedTok += s.aiUsage.savedTokens || 0;\n withExtras++;\n }\n if (s.sessionCache) {\n cacheReuses += s.sessionCache.reuses || 0;\n cacheExpands += s.sessionCache.expands || 0;\n }\n });\n var sv = function(id, v){ var e = document.getElementById(id); if(e) e.textContent = v; };\n var aiSpent = aiInTok + aiOutTok;\n sv('sv-ai-calls', fmt(aiCalls));\n sv('sv-ai-saved', aiSavedTok > 0 ? fmt(aiSavedTok) : '\u2014');\n sv('sv-ai-spent', aiSpent > 0 ? fmt(aiSpent) : '\u2014');\n var svNet = document.getElementById('sv-ai-net');\n if (svNet) {\n if (withExtras === 0) { svNet.textContent = 'No AI data for this period'; svNet.style.color = 'var(--text3)'; }\n else if (aiCalls === 0) { svNet.textContent = 'AI compression off \u2014 0 calls'; svNet.style.color = 'var(--text3)'; }\n else {\n var net = aiSavedTok - aiSpent;\n svNet.textContent = 'Net: ' + (net >= 0 ? '+' : '\u2212') + fmt(Math.abs(net)) + ' tokens (saved \u2212 spent)';\n svNet.style.color = net >= 0 ? 'var(--brand2)' : 'var(--red, #e5484d)';\n }\n }\n sv('sv-cache-reuses', fmt(cacheReuses));\n sv('sv-cache-expands', fmt(cacheExpands));\n sv('sv-cache-sessions', String(filtered.length));\n}\n\nfunction fmtY(v) {\n if (v >= 1000000) return (v/1000000).toFixed(1).replace(/.0$/,'') + 'M';\n if (v >= 1000) return Math.round(v/1000) + 'k';\n return String(Math.round(v));\n}\n\nfunction renderSavingsChart(sessions, period) {\n var el = document.getElementById('savings-chart');\n\n // Build empty buckets covering the full range (even if no sessions)\n var range = getPeriodRange();\n var entries = [];\n if (period === 'day') {\n // 5-hour windows (matches Claude Code's 5h rate limit window)\n var windows = [\n { start: 0, end: 5, label: '00\u201305' },\n { start: 5, end: 10, label: '05\u201310' },\n { start: 10, end: 15, label: '10\u201315' },\n { start: 15, end: 20, label: '15\u201320' },\n { start: 20, end: 24, label: '20\u201324' },\n ];\n windows.forEach(function(w, i) {\n var t = range.start + w.start * 3600000;\n entries.push({ key: i, saved: 0, reqs: 0, label: w.label, start: t, end: range.start + w.end * 3600000 });\n });\n } else if (period === 'week') {\n // 7 days\n for (var i = 0; i < 7; i++) {\n var t = range.start + i * 86400000;\n var d = new Date(t);\n entries.push({ key: i, saved: 0, reqs: 0, label: d.toLocaleDateString([], {weekday:'short', day:'numeric'}), start: t, end: t + 86400000 });\n }\n } else if (period === 'month') {\n // All days in the month\n var startD = new Date(range.start);\n var endD = new Date(range.end);\n var cursor = new Date(startD);\n while (cursor < endD) {\n var t = cursor.getTime();\n var nextDay = new Date(cursor); nextDay.setDate(nextDay.getDate() + 1);\n entries.push({ key: cursor.getDate(), saved: 0, reqs: 0, label: String(cursor.getDate()), start: t, end: nextDay.getTime() });\n cursor = nextDay;\n }\n } else { // all time \u2192 group by month, span from first session to current\n var minTs = sessions.length ? Math.min.apply(null, sessions.map(function(s){return s.startTime;})) : Date.now();\n var startD = new Date(new Date(minTs).getFullYear(), new Date(minTs).getMonth(), 1);\n var endD = new Date();\n var cursor = new Date(startD);\n while (cursor <= endD) {\n var t = cursor.getTime();\n var nextMonth = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);\n entries.push({ key: cursor.getFullYear() + '-' + cursor.getMonth(), saved: 0, reqs: 0, label: cursor.toLocaleDateString([], {month:'short', year:'2-digit'}), start: t, end: nextMonth.getTime() });\n cursor = nextMonth;\n }\n }\n\n // Fill buckets with session data\n sessions.forEach(function(s) {\n for (var i = 0; i < entries.length; i++) {\n if (s.startTime >= entries[i].start && s.startTime < entries[i].end) {\n entries[i].saved += s.savedTokens || 0;\n entries[i].reqs += s.requests || 0;\n break;\n }\n }\n });\n\n var maxVal = Math.max.apply(null, entries.map(function(e){ return e.saved; })) || 1;\n var totalSaved = entries.reduce(function(a,e){ return a + e.saved; }, 0);\n var totalReqs = entries.reduce(function(a,e){ return a + e.reqs; }, 0);\n var n = entries.length;\n var chartH = 110;\n\n if (totalSaved === 0 && totalReqs === 0) {\n el.innerHTML = '<div class=\"lim-nodata\">No data in this period.</div>';\n return;\n }\n\n // Y-axis ticks\n var yTicks = [0.25, 0.5, 0.75, 1.0];\n\n // Build grid + bars as nested HTML\n var gridLines = yTicks.map(function(t){\n var pct = (1 - t) * 100;\n return '<div style=\"position:absolute;left:38px;right:0;top:' + pct + '%;height:1px;border-top:1px dashed var(--border);pointer-events:none\">' +\n '<span style=\"position:absolute;right:calc(100% + 4px);transform:translateY(-50%);font-size:9px;color:var(--text3);white-space:nowrap\">' + fmtY(t * maxVal) + '</span>' +\n '</div>';\n }).join('');\n\n // Baseline\n gridLines += '<div style=\"position:absolute;left:38px;right:0;bottom:0;height:1px;background:var(--border2)\"></div>';\n\n var bars = entries.map(function(data) {\n var ratio = data.saved / maxVal;\n var hPct = Math.max(2, Math.round(ratio * 100));\n var isMax = data.saved === maxVal;\n var color = isMax ? 'var(--brand)' : 'rgba(22,163,74,0.35)';\n var tip = data.label + ' \u00B7 ' + fmt(data.saved) + ' tokens saved \u00B7 ' + data.reqs + ' req';\n return '<div style=\"flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;gap:0;height:' + chartH + 'px;position:relative\" title=\"' + esc(tip) + '\">' +\n (isMax ? '<div style=\"font-size:9px;color:var(--brand2);font-weight:700;margin-bottom:2px;white-space:nowrap\">' + fmtY(data.saved) + '</div>' : '') +\n '<div class=\"bar\" style=\"width:calc(100% - 2px);height:' + hPct + '%;background:' + color + ';border-radius:3px 3px 0 0;transition:background .15s,opacity .15s\"></div>' +\n '</div>';\n }).join('');\n\n // Smart label spacing: show every N-th label to avoid clutter\n var labelEvery = n <= 12 ? 1 : n <= 24 ? 2 : n <= 31 ? 5 : Math.ceil(n / 12);\n var labels = entries.map(function(data, i) {\n var show = i % labelEvery === 0 || i === n - 1;\n var content = show ? esc(data.label) : '';\n return '<div style=\"flex:1;min-width:0;text-align:center;font-size:9px;color:var(--text3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 1px\">' + content + '</div>';\n }).join('');\n\n var unitLabel = period === 'day' ? '5h windows' : period === 'week' || period === 'month' ? 'days' : 'months';\n\n el.innerHTML =\n '<div class=\"chart-wrap\" style=\"padding:12px 12px 8px\">' +\n '<div style=\"position:relative;height:' + chartH + 'px;margin-left:38px\">' +\n gridLines +\n '<div style=\"position:absolute;inset:0;display:flex;gap:3px;align-items:flex-end\">' + bars + '</div>' +\n '</div>' +\n '<div style=\"display:flex;gap:3px;margin-left:38px;margin-top:4px\">' + labels + '</div>' +\n '<div style=\"margin-top:6px;font-size:11px;color:var(--text3);display:flex;justify-content:space-between\">' +\n '<span>' + totalReqs + ' request' + (totalReqs !== 1 ? 's' : '') + ' across ' + n + ' ' + unitLabel + '</span>' +\n '<span style=\"color:var(--brand2);font-weight:600\">' + fmtY(totalSaved) + ' tokens saved</span>' +\n '</div>' +\n '</div>';\n}\n\npoll();\nconnect();\n// Load history immediately so Overview shows Today's data without needing to visit Savings tab\nloadSavings();\ncheckLatestVersion();\n</script>\n</body>\n</html>";
|
package/dist/dashboard.js
CHANGED
|
@@ -293,6 +293,15 @@ code{font-family:'Cascadia Code','SF Mono',Consolas,monospace;font-size:.9em}
|
|
|
293
293
|
background:var(--surface3);padding:2px 8px;border-radius:5px;
|
|
294
294
|
border:1px solid var(--border2);color:var(--text)
|
|
295
295
|
}
|
|
296
|
+
.ai-dev-banner{
|
|
297
|
+
font-size:12.5px;line-height:1.55;color:var(--text2);width:100%;
|
|
298
|
+
background:rgba(251,191,36,.06);border:1px solid rgba(251,191,36,.25);
|
|
299
|
+
border-radius:8px;padding:11px 13px
|
|
300
|
+
}
|
|
301
|
+
.ai-dev-banner .ai-dev-title{display:block;margin-bottom:4px;font-weight:700;color:var(--yellow)}
|
|
302
|
+
.ai-risks{margin:0;padding-left:18px;font-size:12px;line-height:1.55;color:var(--text3);display:flex;flex-direction:column;gap:7px}
|
|
303
|
+
.ai-risks li{margin:0}
|
|
304
|
+
.ai-risks strong{color:var(--text2)}
|
|
296
305
|
|
|
297
306
|
/* ── Action buttons ── */
|
|
298
307
|
.action-btn{padding:7px 18px;border-radius:8px;border:1px solid var(--border2);background:var(--surface2);color:var(--text);font-size:13px;font-family:inherit;cursor:pointer;font-weight:500;transition:all .12s}
|
|
@@ -443,7 +452,6 @@ code{font-family:'Cascadia Code','SF Mono',Consolas,monospace;font-size:.9em}
|
|
|
443
452
|
<button class="mode-btn" data-mode="aggressive" onclick="setMode('aggressive')">Aggressive</button>
|
|
444
453
|
<div class="divider-v"></div>
|
|
445
454
|
<button class="bypass-btn" id="bypass-btn" onclick="toggleBypass()">Toggle Bypass</button>
|
|
446
|
-
<button class="bypass-btn" id="ai-comp-btn" onclick="toggleAiCompression()" title="AI compression (costs tokens). Off = deterministic only (free)">AI Compression: —</button>
|
|
447
455
|
</div>
|
|
448
456
|
</div>
|
|
449
457
|
</div>
|
|
@@ -713,34 +721,51 @@ code{font-family:'Cascadia Code','SF Mono',Consolas,monospace;font-size:.9em}
|
|
|
713
721
|
</div>
|
|
714
722
|
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:4px">
|
|
715
723
|
<div style="display:flex;justify-content:space-between;width:100%;align-items:center">
|
|
716
|
-
<span class="s-key">
|
|
717
|
-
<
|
|
724
|
+
<span class="s-key">Bypass</span>
|
|
725
|
+
<span class="s-val"><code id="cfg-bypass">—</code></span>
|
|
718
726
|
</div>
|
|
719
727
|
<div style="font-size:12px;color:var(--text3);line-height:1.4">
|
|
720
|
-
|
|
728
|
+
When <strong style="color:var(--text2)">enabled</strong>, all requests pass through to the API <em>without compression</em> — useful to check if Squeezr is causing any issue. Stats are still logged. Resets automatically when the proxy restarts.
|
|
721
729
|
</div>
|
|
722
730
|
</div>
|
|
723
|
-
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:
|
|
731
|
+
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:8px">
|
|
724
732
|
<div style="display:flex;justify-content:space-between;width:100%;align-items:center">
|
|
725
|
-
<span class="s-key">
|
|
726
|
-
<
|
|
733
|
+
<span class="s-key">Anthropic Native Compact <span style="font-size:10px;background:var(--brand-dim);color:var(--brand2);padding:1px 6px;border-radius:3px;margin-left:4px">beta</span></span>
|
|
734
|
+
<button class="mode-btn" id="native-compact-btn" onclick="toggleNativeCompact()" style="min-width:80px">—</button>
|
|
727
735
|
</div>
|
|
728
736
|
<div style="font-size:12px;color:var(--text3);line-height:1.4">
|
|
729
|
-
|
|
737
|
+
Activa el header <code style="font-size:11px">anthropic-beta: compact-2026-01-12</code>. Cuando el contexto excede el threshold, Anthropic <strong style="color:var(--text2)">resume tu conversación automáticamente en sus servidores</strong>. Stacks con la compresión de Squeezr — comprimes primero, ellos resumen lo que queda. <strong>Solo Claude</strong> (no afecta OpenAI/Gemini). Reseteable.
|
|
730
738
|
</div>
|
|
731
739
|
</div>
|
|
740
|
+
</div>
|
|
741
|
+
|
|
742
|
+
<!-- ── AI Compression (apartado dedicado) ── -->
|
|
743
|
+
<div class="settings-block">
|
|
744
|
+
<div class="settings-head">AI Compression <span style="font-size:10px;background:rgba(251,191,36,.12);color:var(--yellow);padding:1px 6px;border-radius:3px;margin-left:4px">experimental · en desarrollo</span></div>
|
|
745
|
+
|
|
746
|
+
<!-- Estado de desarrollo -->
|
|
747
|
+
<div class="settings-row" style="flex-direction:column;align-items:flex-start">
|
|
748
|
+
<div class="ai-dev-banner">
|
|
749
|
+
<span class="ai-dev-title">🚧 Todavía no rinde en producción — desactivada por defecto.</span>
|
|
750
|
+
La compresión por IA aún no aporta ahorro neto real. El modelo local <strong>Zest</strong> aprendió a recortar tokens "duros" (rutas, códigos de error, IDs) que el guard de runtime rechaza, así que hoy el guard tumba casi todas sus salidas → ~0 de ahorro. Lo estamos <strong>reentrenando con un dataset guard-compliant</strong> (Zest v4) usando un port fiel del validador. Mientras tanto la <strong>compresión determinística</strong> —gratis y siempre activa— es la que hace el trabajo de verdad. Enciende esto solo si quieres experimentar.
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
|
|
754
|
+
<!-- Toggle maestro -->
|
|
732
755
|
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:4px">
|
|
733
756
|
<div style="display:flex;justify-content:space-between;width:100%;align-items:center">
|
|
734
|
-
<span class="s-key">
|
|
735
|
-
<
|
|
757
|
+
<span class="s-key">Activar AI Compression</span>
|
|
758
|
+
<button class="action-btn" id="ai-comp-btn-settings" onclick="toggleAiCompression()">—</button>
|
|
736
759
|
</div>
|
|
737
760
|
<div style="font-size:12px;color:var(--text3);line-height:1.4">
|
|
738
|
-
|
|
761
|
+
Interruptor maestro de las llamadas de compresión por IA. En <strong style="color:var(--text2)">off</strong> solo corre la determinística (coste cero de tokens). Persiste entre reinicios. La determinística sigue activa pase lo que pase.
|
|
739
762
|
</div>
|
|
740
763
|
</div>
|
|
764
|
+
|
|
765
|
+
<!-- Qué puedes usar hoy -->
|
|
741
766
|
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:8px">
|
|
742
767
|
<div style="display:flex;justify-content:space-between;width:100%;align-items:center;flex-wrap:wrap;gap:6px">
|
|
743
|
-
<span class="s-key">
|
|
768
|
+
<span class="s-key">Qué puedes usar hoy</span>
|
|
744
769
|
<div style="display:flex;gap:4px;flex-wrap:wrap">
|
|
745
770
|
<button class="mode-btn" data-backend="local" onclick="setBackend('local')">⚡ Zest (local · free)</button>
|
|
746
771
|
<button class="mode-btn" data-backend="haiku" onclick="setBackend('haiku')">Haiku (API · billed)</button>
|
|
@@ -750,19 +775,33 @@ code{font-family:'Cascadia Code','SF Mono',Consolas,monospace;font-size:.9em}
|
|
|
750
775
|
</div>
|
|
751
776
|
</div>
|
|
752
777
|
<div style="font-size:12px;color:var(--text3);line-height:1.4">
|
|
753
|
-
|
|
778
|
+
<strong style="color:var(--brand2)">⚡ Zest (local · gratis):</strong> comprime con el modelo local vía Ollama — sin red, sin coste, no toca tu cuota. Es la opción recomendada para experimentar (aunque hoy aún rinde poco, ver arriba).<br>
|
|
779
|
+
<strong style="color:var(--text2)">Haiku / GPT-4o-mini / Gemini Flash (API):</strong> comprimen con un modelo en la nube. Más calidad de resumen, pero <em>cuestan</em>: o una API key facturada aparte, o —ojo— tu propia suscripción (ver riesgos). La elección se guarda en <code>squeezr.toml</code> y sobrevive reinicios.
|
|
754
780
|
</div>
|
|
755
781
|
<div id="backend-warn" style="display:none;font-size:12px;line-height:1.4;color:#fbbf24;background:rgba(251,191,36,.08);border:1px solid rgba(251,191,36,.3);border-radius:8px;padding:8px 10px">
|
|
756
782
|
⚠️ <strong>Haiku con suscripción Claude Code (token OAuth):</strong> cada llamada de compresión se factura contra tu cuota del plan de 5h — te lo come en minutos. Squeezr la <strong>bloquea automáticamente</strong> en este caso. Usa <strong>⚡ Zest (local)</strong> para AI compression gratis, o una API key facturada aparte.
|
|
757
783
|
</div>
|
|
758
784
|
</div>
|
|
759
|
-
|
|
785
|
+
|
|
786
|
+
<!-- Riesgos -->
|
|
787
|
+
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
|
|
788
|
+
<span class="s-key">⚠️ Riesgos que debes conocer</span>
|
|
789
|
+
<ul class="ai-risks">
|
|
790
|
+
<li><strong>Coste contra tu suscripción:</strong> con un token OAuth de Claude Code, los backends <em>Haiku</em> y <em>Auto</em> facturarían cada compresión contra tu plan de 5h. Squeezr lo bloquea automáticamente, pero por eso aparece el aviso. <strong>Zest local nunca consume cuota.</strong></li>
|
|
791
|
+
<li><strong>Pérdida de fidelidad:</strong> la IA <em>resume</em> y puede omitir detalle. Los datos estructurados (JSON, JSONL, tablas) están protegidos y nunca pasan por IA, y todo bloque comprimido es recuperable con <code style="font-size:11px">squeezr_expand</code>.</li>
|
|
792
|
+
<li><strong>Latencia:</strong> cada llamada de IA añade tiempo a la petición. El circuit breaker desactiva la IA tras 3 fallos seguidos y vuelve a determinística.</li>
|
|
793
|
+
<li><strong>Ahorro negativo en bloques pequeños:</strong> por debajo de ~1.5k caracteres la IA suele <em>expandir</em> en lugar de comprimir; por eso hay un mínimo de tamaño antes de llamarla.</li>
|
|
794
|
+
</ul>
|
|
795
|
+
</div>
|
|
796
|
+
|
|
797
|
+
<!-- Circuit breaker -->
|
|
798
|
+
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:4px">
|
|
760
799
|
<div style="display:flex;justify-content:space-between;width:100%;align-items:center">
|
|
761
|
-
<span class="s-key">
|
|
762
|
-
<
|
|
800
|
+
<span class="s-key">Circuit Breaker</span>
|
|
801
|
+
<span class="s-val"><code id="cfg-cb">—</code></span>
|
|
763
802
|
</div>
|
|
764
803
|
<div style="font-size:12px;color:var(--text3);line-height:1.4">
|
|
765
|
-
|
|
804
|
+
Protege contra picos de latencia. Si el modelo de IA falla <strong style="color:var(--text2)">3 veces seguidas</strong>, auto-desactiva la IA y cae a reglas determinísticas. Vuelve a la normalidad tras 60s sin errores. La determinística siempre sigue activa.
|
|
766
805
|
</div>
|
|
767
806
|
</div>
|
|
768
807
|
</div>
|
package/package.json
CHANGED
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "squeezr-ai",
|
|
3
|
-
"version": "1.80.
|
|
4
|
-
"description": "AI proxy that compresses Claude Code, Claude Desktop, Codex, Codex Desktop, Aider, Gemini CLI and Ollama context windows to save thousands of tokens per session",
|
|
5
|
-
"keywords": [
|
|
6
|
-
"claude",
|
|
7
|
-
"claude-code",
|
|
8
|
-
"codex",
|
|
9
|
-
"ollama",
|
|
10
|
-
"aider",
|
|
11
|
-
"gemini",
|
|
12
|
-
"token",
|
|
13
|
-
"compression",
|
|
14
|
-
"proxy",
|
|
15
|
-
"llm",
|
|
16
|
-
"ai"
|
|
17
|
-
],
|
|
18
|
-
"license": "MIT",
|
|
19
|
-
"repository": {
|
|
20
|
-
"type": "git",
|
|
21
|
-
"url": "git+https://github.com/sergioramosv/Squeezr.git"
|
|
22
|
-
},
|
|
23
|
-
"homepage": "https://github.com/sergioramosv/Squeezr#readme",
|
|
24
|
-
"type": "module",
|
|
25
|
-
"bin": {
|
|
26
|
-
"squeezr": "bin/squeezr.js",
|
|
27
|
-
"squeezr-mcp": "dist/mcp.js"
|
|
28
|
-
},
|
|
29
|
-
"scripts": {
|
|
30
|
-
"build": "tsc",
|
|
31
|
-
"prepack": "node -e \"['dist/cursorMitm.js','dist/cursorMitm.d.ts','dist/__tests__/cursorMitm.test.js','dist/__tests__/cursorMitm.test.d.ts'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\"",
|
|
32
|
-
"dev": "tsx src/index.ts",
|
|
33
|
-
"start": "node dist/index.js",
|
|
34
|
-
"gain": "node dist/gain.js",
|
|
35
|
-
"discover": "node dist/discover.js",
|
|
36
|
-
"test": "vitest run",
|
|
37
|
-
"test:watch": "vitest",
|
|
38
|
-
"test:quality": "vitest run src/__tests__/qualityHarness.test.ts"
|
|
39
|
-
},
|
|
40
|
-
"files": [
|
|
41
|
-
"bin/",
|
|
42
|
-
"dist/",
|
|
43
|
-
"squeezr.toml"
|
|
44
|
-
],
|
|
45
|
-
"dependencies": {
|
|
46
|
-
"@anthropic-ai/sdk": "^0.39.0",
|
|
47
|
-
"@bufbuild/protobuf": "^2.11.0",
|
|
48
|
-
"@hono/node-server": "^1.13.7",
|
|
49
|
-
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
50
|
-
"@types/diff": "^7.0.2",
|
|
51
|
-
"diff": "^9.0.0",
|
|
52
|
-
"hono": "^4.7.5",
|
|
53
|
-
"node-forge": "^1.4.0",
|
|
54
|
-
"openai": "^4.93.0",
|
|
55
|
-
"smol-toml": "^1.3.1",
|
|
56
|
-
"zod": "^3.24.0"
|
|
57
|
-
},
|
|
58
|
-
"devDependencies": {
|
|
59
|
-
"@types/node": "^22.14.0",
|
|
60
|
-
"@types/node-forge": "^1.3.14",
|
|
61
|
-
"@vitest/coverage-v8": "^4.1.2",
|
|
62
|
-
"tsx": "^4.19.3",
|
|
63
|
-
"typescript": "^5.8.3",
|
|
64
|
-
"vitest": "^4.1.2"
|
|
65
|
-
},
|
|
66
|
-
"engines": {
|
|
67
|
-
"node": ">=18"
|
|
68
|
-
}
|
|
69
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "squeezr-ai",
|
|
3
|
+
"version": "1.80.16",
|
|
4
|
+
"description": "AI proxy that compresses Claude Code, Claude Desktop, Codex, Codex Desktop, Aider, Gemini CLI and Ollama context windows to save thousands of tokens per session",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude",
|
|
7
|
+
"claude-code",
|
|
8
|
+
"codex",
|
|
9
|
+
"ollama",
|
|
10
|
+
"aider",
|
|
11
|
+
"gemini",
|
|
12
|
+
"token",
|
|
13
|
+
"compression",
|
|
14
|
+
"proxy",
|
|
15
|
+
"llm",
|
|
16
|
+
"ai"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/sergioramosv/Squeezr.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/sergioramosv/Squeezr#readme",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"squeezr": "bin/squeezr.js",
|
|
27
|
+
"squeezr-mcp": "dist/mcp.js"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc",
|
|
31
|
+
"prepack": "node -e \"['dist/cursorMitm.js','dist/cursorMitm.d.ts','dist/__tests__/cursorMitm.test.js','dist/__tests__/cursorMitm.test.d.ts'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\"",
|
|
32
|
+
"dev": "tsx src/index.ts",
|
|
33
|
+
"start": "node dist/index.js",
|
|
34
|
+
"gain": "node dist/gain.js",
|
|
35
|
+
"discover": "node dist/discover.js",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest",
|
|
38
|
+
"test:quality": "vitest run src/__tests__/qualityHarness.test.ts"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"bin/",
|
|
42
|
+
"dist/",
|
|
43
|
+
"squeezr.toml"
|
|
44
|
+
],
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@anthropic-ai/sdk": "^0.39.0",
|
|
47
|
+
"@bufbuild/protobuf": "^2.11.0",
|
|
48
|
+
"@hono/node-server": "^1.13.7",
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
50
|
+
"@types/diff": "^7.0.2",
|
|
51
|
+
"diff": "^9.0.0",
|
|
52
|
+
"hono": "^4.7.5",
|
|
53
|
+
"node-forge": "^1.4.0",
|
|
54
|
+
"openai": "^4.93.0",
|
|
55
|
+
"smol-toml": "^1.3.1",
|
|
56
|
+
"zod": "^3.24.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "^22.14.0",
|
|
60
|
+
"@types/node-forge": "^1.3.14",
|
|
61
|
+
"@vitest/coverage-v8": "^4.1.2",
|
|
62
|
+
"tsx": "^4.19.3",
|
|
63
|
+
"typescript": "^5.8.3",
|
|
64
|
+
"vitest": "^4.1.2"
|
|
65
|
+
},
|
|
66
|
+
"engines": {
|
|
67
|
+
"node": ">=18"
|
|
68
|
+
}
|
|
69
|
+
}
|