squeezr-ai 1.21.0 → 1.21.1

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/README.md CHANGED
@@ -1,217 +1,274 @@
1
- # Squeezr
2
-
3
- **Token compression proxy for AI coding CLIs.** Sits between your CLI and the API, compresses context on the fly, saves thousands of tokens per session.
4
-
5
- [![npm](https://img.shields.io/npm/v/squeezr-ai)](https://www.npmjs.com/package/squeezr-ai) [![license](https://img.shields.io/npm/l/squeezr-ai)](LICENSE) [![tests](https://img.shields.io/badge/tests-237%20passing-brightgreen)]()
6
-
7
- ## Supported CLIs
8
-
9
- | CLI | Protocol | Proxy method |
10
- |-----|----------|-------------|
11
- | Claude Code | HTTP to Anthropic API | `ANTHROPIC_BASE_URL=http://localhost:8080` |
12
- | Aider | HTTP to Anthropic/OpenAI API | `ANTHROPIC_BASE_URL` / `openai_base_url` |
13
- | OpenCode | HTTP to Anthropic/OpenAI API | `ANTHROPIC_BASE_URL` / `openai_base_url` |
14
- | Gemini CLI | HTTP to Gemini API | `GEMINI_API_BASE_URL=http://localhost:8080` |
15
- | Ollama | HTTP (local) | Transparent via dummy API key detection |
16
- | **Codex** | **WebSocket to chatgpt.com** | **TLS-terminating MITM proxy on :8081** |
17
- | **Cursor IDE** | **ConnectRPC/HTTP2 to api2.cursor.sh** | **`squeezr cursor` — MITM proxy on :8082** |
18
- | Continue (VS Code) | HTTP to OpenAI-compat | `apiBase: http://localhost:8080/v1` |
19
-
20
- ## Quick start
21
-
22
- ```bash
23
- npm install -g squeezr-ai
24
- squeezr setup # configures env vars, auto-start, and CA trust
25
- squeezr start
26
- ```
27
-
28
- `squeezr setup` handles everything automatically:
29
- - Sets `ANTHROPIC_BASE_URL`, `GEMINI_API_BASE_URL`, `NODE_EXTRA_CA_CERTS`
30
- - Installs a shell wrapper (PowerShell on Windows, bash/zsh on Linux/macOS/WSL) that auto-refreshes env vars after `squeezr start/setup/update` no need to restart the terminal
31
- - Registers auto-start (launchd on macOS, systemd on Linux, Task Scheduler/NSSM on Windows)
32
- - **Windows:** imports the MITM CA into the Windows Certificate Store (user-level, no admin required) so Rust-based CLIs like Codex trust the proxy's TLS certificates
33
- - **macOS/Linux/WSL:** generates a CA bundle at `~/.squeezr/mitm-ca/bundle.crt` for `SSL_CERT_FILE`
34
-
35
- ## How it works
36
-
37
- Every request from your AI CLI passes through Squeezr on `localhost:8080`. The proxy applies three compression layers before forwarding to the upstream API:
38
-
39
- ### Layer 1: System prompt compression
40
-
41
- The system prompt (~13KB for Claude Code) is compressed once using an AI model and cached. Subsequent requests reuse the cached version. Saves ~3,000 tokens per request.
42
-
43
- ### Layer 2: Deterministic preprocessing
44
-
45
- Zero-latency, rule-based transformations applied to every tool result:
46
-
47
- - **Noise removal:** ANSI escape codes, progress bars, timestamps, spinner output
48
- - **Deduplication:** repeated stack frames, duplicate lines, redundant git hunks
49
- - **Minification:** JSON whitespace, collapsed blank lines
50
-
51
- ### Layer 3: Tool-specific patterns (~30 rules)
52
-
53
- Each tool result is matched against specialized compression rules:
54
-
55
- | Category | Tools | What it does |
56
- |----------|-------|-------------|
57
- | Git | diff, log, status, branch | 1-line diff context, capped log, compact status |
58
- | JS/TS | vitest, jest, playwright, tsc, eslint, biome, prettier | Failures/errors only, grouped by file |
59
- | Package managers | pnpm, npm | Install summary, list capped at 30, outdated only |
60
- | Build | next build, cargo build | Errors only |
61
- | Test | cargo test, pytest, go test | FAIL blocks + tracebacks only |
62
- | Infra | terraform, docker, kubectl | Resource changes, compact tables, last 50 log lines |
63
- | Other | prisma, gh CLI, curl/wget | Strip ASCII art, cap output, remove verbose headers |
64
-
65
- ### Exclusive patterns
66
-
67
- Applied to specific content types regardless of tool:
68
-
69
- - **Lockfiles** (package-lock.json, Cargo.lock, etc.) → dependency count summary
70
- - **Large code files** (>500 lines) imports + function/class signatures only
71
- - **Long output** (>200 lines) → head + tail + omission note
72
- - **Grep results** grouped by file, matches capped
73
- - **Glob results** (>30 files) → directory tree summary
74
- - **Noisy output** (>50% non-essential) → auto-extract errors/warnings
75
-
76
- ### Adaptive pressure
77
-
78
- Compression aggressiveness scales with context window usage:
79
-
80
- | Context usage | Threshold | Behavior |
81
- |--------------|-----------|----------|
82
- | < 50% | 1,500 chars | Light — only compress large results |
83
- | 50–75% | 800 chars | Normal — standard compression |
84
- | 75–90% | 400 chars | Aggressive — compress most results |
85
- | > 90% | 150 chars | Critical — compress everything, 0 git diff context |
86
-
87
- ### Session optimizations
88
-
89
- - **Session cache:** After ~50 tool results, older results are batch-summarized into a single compact block
90
- - **KV cache warming:** Deterministic MD5-based IDs keep compressed content prefix-stable across requests
91
- - **Cross-turn dedup:** If the same file is read multiple times, earlier reads are replaced with reference pointers
92
- - **Expand on demand:** Compressed blocks include a `squeezr_expand(id)` callback to retrieve full content
93
-
94
- ## Codex support (MITM proxy)
95
-
96
- Codex uses WebSocket over TLS to `chatgpt.com` with OAuth authentication — it cannot be proxied via `OPENAI_BASE_URL`. Squeezr runs a TLS-terminating MITM proxy on port 8081 that intercepts and compresses WebSocket frames. See [CODEX.md](CODEX.md) for the full technical breakdown.
97
-
98
- The MITM proxy **only intercepts `chatgpt.com`** traffic. All other HTTPS requests (npm, git, curl, etc.) pass through as a transparent TCP tunnel — no certificate needed, no interference.
99
-
100
- ## Configuration
101
-
102
- ### Global config: `squeezr.toml` (next to the binary)
103
-
104
- ```toml
105
- [proxy]
106
- port = 8080 # HTTP proxy (Claude, Aider, Gemini)
107
- mitm_port = 8081 # MITM proxy (Codex) defaults to port + 1
108
-
109
- [compression]
110
- threshold = 800 # min chars to trigger compression
111
- keep_recent = 3 # last N results left uncompressed
112
- compress_system_prompt = true
113
- compress_conversation = false # aggressive: compress assistant messages too
114
- # skip_tools = ["Read"] # skip ALL compression for these tools (deterministic + AI)
115
- # only_tools = ["Bash"] # only compress these tools
116
- ai_skip_tools = ["Read"] # skip AI compression only (default); deterministic still runs
117
-
118
- [cache]
119
- enabled = true
120
- max_entries = 1000
121
-
122
- [adaptive]
123
- enabled = true
124
- low_threshold = 1500
125
- mid_threshold = 800
126
- high_threshold = 400
127
- critical_threshold = 150
128
-
129
- [local]
130
- enabled = true
131
- upstream_url = "http://localhost:11434" # Ollama
132
- compression_model = "qwen2.5-coder:1.5b"
133
- ```
134
-
135
- ### Project config: `.squeezr.toml` (in project root)
136
-
137
- Project-level config is deep-merged over global config. Useful for per-repo tuning.
138
-
139
- ### Environment variables
140
-
141
- | Variable | Default | Description |
142
- |----------|---------|-------------|
143
- | `SQUEEZR_PORT` | `8080` | HTTP proxy port (Claude, Aider, Gemini) |
144
- | `SQUEEZR_MITM_PORT` | `8081` | MITM proxy port (Codex) defaults to SQUEEZR_PORT + 1 |
145
- | `SQUEEZR_THRESHOLD` | `800` | Min chars to compress |
146
- | `SQUEEZR_KEEP_RECENT` | `3` | Recent results to skip |
147
- | `SQUEEZR_DISABLED` | `false` | Disable all compression |
148
- | `SQUEEZR_DRY_RUN` | `false` | Log savings without compressing |
149
- | `SQUEEZR_LOCAL_UPSTREAM` | `http://localhost:11434` | Ollama/LM Studio URL |
150
- | `SQUEEZR_LOCAL_MODEL` | `qwen2.5-coder:1.5b` | Local model for compression |
151
-
152
- ### Per-command skip
153
-
154
- Add `# squeezr:skip` anywhere in a Bash command to bypass compression for that result.
155
-
156
- ## Compression backends
157
-
158
- Squeezr uses cheap/free models for AI compression (the deterministic layer is pure regex, no API calls):
159
-
160
- | Backend | Model | Used for | Cost |
161
- |---------|-------|----------|------|
162
- | Anthropic | Haiku | System prompt, session cache | ~$0.0001/call |
163
- | OpenAI | GPT-4o-mini | Fallback compression | ~$0.0001/call |
164
- | Gemini | Flash-8B | Fallback compression | Free |
165
- | Local | qwen2.5-coder:1.5b | Compression when using Ollama | Free |
166
- | ChatGPT (WS) | GPT-5.4-mini | Codex frame compression | $0 (same subscription) |
167
-
168
- ### Typical savings
169
-
170
- - **Per tool result:** 70–95% reduction depending on tool
171
- - **Per session (2 hours):** ~200K tokens → ~80K tokens (60% savings)
172
- - **System prompt:** ~13KB → ~600 tokens (cached)
173
-
174
- ## CLI commands
175
-
176
- ```bash
177
- squeezr setup # configure env vars, auto-start, CA trust, install MCP server
178
- squeezr start # start the proxy (auto-restarts if version mismatch after update)
179
- squeezr update # kill old processes, install latest from npm, restart
180
- squeezr stop # stop the proxy
181
- squeezr status # check if proxy is running
182
- squeezr logs # show last 50 log lines
183
- squeezr config # print current config
184
- squeezr ports # change HTTP and MITM proxy ports
185
- squeezr gain # estimate token savings for a directory
186
- squeezr discover # detect which AI CLIs are installed
187
- squeezr mcp install # register MCP server in Claude Code, Cursor, Windsurf, Cline
188
- squeezr mcp uninstall # remove MCP server registration
189
- squeezr uninstall # remove Squeezr completely (env vars, CA, auto-start, logs)
190
- squeezr version # print version
191
- ```
192
-
193
- ## MCP server
194
-
195
- Squeezr ships with a built-in MCP server (`squeezr-mcp`) that gives any MCP-capable AI CLI real-time awareness of Squeezr's state and control over it.
196
-
197
- **Installed automatically** by `squeezr setup` into Claude Code, Cursor, Windsurf, and Cline.
198
-
199
- Available MCP tools:
200
-
201
- | Tool | Description |
202
- |---|---|
203
- | `squeezr_status` | Is proxy running? Version, port, uptime, mode |
204
- | `squeezr_stats` | Token savings, compression %, cost saved, per-tool breakdown |
205
- | `squeezr_set_mode` | Change compression mode instantly (soft / normal / aggressive / critical) |
206
- | `squeezr_config` | Current thresholds, keepRecent, cache sizes |
207
- | `squeezr_habits` | Detect wasteful patterns this session (duplicate reads, high Bash count, cache efficiency) |
208
-
209
- ## Requirements
210
-
211
- - Node.js 18+ (compatible with Node.js 24)
212
- - For Codex MITM: set `HTTPS_PROXY=http://localhost:8081` in the terminal where you run Codex (not set globally to avoid interfering with other tools)
213
- - For local compression: [Ollama](https://ollama.ai) with `qwen2.5-coder:1.5b`
214
-
215
- ## License
216
-
217
- MIT
1
+ # Squeezr
2
+
3
+ **Token compression proxy for AI coding CLIs.** Sits between your CLI and the API, compresses context on the fly, saves thousands of tokens per session. Includes a real-time web dashboard and MCP integration.
4
+
5
+ [![npm](https://img.shields.io/npm/v/squeezr-ai)](https://www.npmjs.com/package/squeezr-ai) [![license](https://img.shields.io/npm/l/squeezr-ai)](LICENSE)
6
+
7
+ ## Supported CLIs
8
+
9
+ | CLI | Protocol | Proxy method |
10
+ |-----|----------|--------------|
11
+ | Claude Code | HTTP to Anthropic API | `ANTHROPIC_BASE_URL=http://localhost:8080` |
12
+ | Aider | HTTP to Anthropic/OpenAI API | `ANTHROPIC_BASE_URL` / `openai_base_url` |
13
+ | OpenCode | HTTP to Anthropic/OpenAI API | `ANTHROPIC_BASE_URL` / `openai_base_url` |
14
+ | Gemini CLI | HTTP to Gemini API | `GEMINI_API_BASE_URL=http://localhost:8080` |
15
+ | Ollama | HTTP (local) | Transparent via dummy API key detection |
16
+ | **Codex** | **WebSocket to chatgpt.com** | **TLS-terminating MITM proxy on :8081** |
17
+ | **Cursor IDE** | **ConnectRPC/HTTP2 to api2.cursor.sh** | **`squeezr cursor` — MITM proxy on :8082** |
18
+ | Continue (VS Code) | HTTP to OpenAI-compat | `apiBase: http://localhost:8080/v1` |
19
+
20
+ Works with both API keys and subscription plans (OAuth) — Claude Code Max/Pro, OpenAI Plus, etc.
21
+
22
+ ## Quick start
23
+
24
+ ```bash
25
+ npm install -g squeezr-ai
26
+ squeezr setup # configures env vars, auto-start, CA trust, and MCP server
27
+ squeezr start
28
+ ```
29
+
30
+ `squeezr setup` handles everything automatically:
31
+ - Sets `ANTHROPIC_BASE_URL`, `GEMINI_API_BASE_URL`, `NODE_EXTRA_CA_CERTS`
32
+ - Installs a shell wrapper (PowerShell on Windows, bash/zsh on Linux/macOS/WSL) that auto-refreshes env vars after `squeezr start/setup/update` no need to restart the terminal
33
+ - Registers auto-start (launchd on macOS, systemd on Linux, Task Scheduler/NSSM on Windows)
34
+ - Registers the MCP server in Claude Code, Cursor, Windsurf, and Cline
35
+ - **Windows:** imports the MITM CA into the Windows Certificate Store (user-level, no admin required) so Rust-based CLIs like Codex trust the proxy's TLS certificates
36
+ - **macOS/Linux/WSL:** generates a CA bundle at `~/.squeezr/mitm-ca/bundle.crt` for `NODE_EXTRA_CA_CERTS`
37
+
38
+ ## How it works
39
+
40
+ Every request from your AI CLI passes through Squeezr on `localhost:8080`. The proxy applies three compression layers before forwarding to the upstream API:
41
+
42
+ ### Layer 1: System prompt compression
43
+
44
+ The system prompt (~13KB for Claude Code) is compressed once using an AI model and cached. Subsequent requests reuse the cached version. Saves ~3,000 tokens per request.
45
+
46
+ ### Layer 2: Deterministic preprocessing
47
+
48
+ Zero-latency, rule-based transformations applied to every tool result:
49
+
50
+ - **Noise removal:** ANSI escape codes, progress bars, timestamps, spinner output
51
+ - **Deduplication:** repeated stack frames, duplicate lines, redundant git hunks
52
+ - **Minification:** JSON whitespace, collapsed blank lines
53
+
54
+ ### Layer 3: Tool-specific patterns (~30 rules)
55
+
56
+ Each tool result is matched against specialized compression rules:
57
+
58
+ | Category | Tools | What it does |
59
+ |----------|-------|--------------|
60
+ | Git | diff, log, status, branch | 1-line diff context, capped log, compact status |
61
+ | JS/TS | vitest, jest, playwright, tsc, eslint, biome, prettier | Failures/errors only, grouped by file |
62
+ | Package managers | pnpm, npm | Install summary, list capped at 30, outdated only |
63
+ | Build | next build, cargo build | Errors only |
64
+ | Test | cargo test, pytest, go test | FAIL blocks + tracebacks only |
65
+ | Infra | terraform, docker, kubectl | Resource changes, compact tables, last 50 log lines |
66
+ | Other | prisma, gh CLI, curl/wget | Strip ASCII art, cap output, remove verbose headers |
67
+
68
+ ### Exclusive patterns
69
+
70
+ Applied to specific content types regardless of tool:
71
+
72
+ - **Lockfiles** (package-lock.json, Cargo.lock, etc.) dependency count summary
73
+ - **Large code files** (>500 lines) → imports + function/class signatures only
74
+ - **Long output** (>200 lines) → head + tail + omission note
75
+ - **Grep results** → grouped by file, matches capped
76
+ - **Glob results** (>30 files) → directory tree summary
77
+ - **Noisy output** (>50% non-essential) → auto-extract errors/warnings
78
+
79
+ ### Adaptive pressure
80
+
81
+ Compression aggressiveness scales with context window usage:
82
+
83
+ | Context usage | Threshold | Behavior |
84
+ |---------------|-----------|----------|
85
+ | < 50% | 1,500 chars | Lightonly compress large results |
86
+ | 50–75% | 800 chars | Normal — standard compression |
87
+ | 75–90% | 400 chars | Aggressive — compress most results |
88
+ | > 90% | 150 chars | Critical — compress everything, 0 git diff context |
89
+
90
+ ### Session optimizations
91
+
92
+ - **Session cache:** After ~50 tool results, older results are batch-summarized into a single compact block
93
+ - **KV cache warming:** Deterministic MD5-based IDs keep compressed content prefix-stable across requests
94
+ - **Cross-turn dedup:** If the same file is read multiple times, earlier reads are replaced with reference pointers
95
+ - **Expand on demand:** Compressed blocks include a `squeezr_expand(id)` callback to retrieve full content
96
+
97
+ ## Web dashboard
98
+
99
+ Live dashboard at `http://localhost:PORT/squeezr/dashboard` with 5 pages:
100
+
101
+ | Page | What it shows |
102
+ |------|---------------|
103
+ | **Overview** | Tokens saved, compression %, requests, cost saved, per-tool breakdown, sparkline chart, context pressure bars, active project badge, savings breakdown (deterministic, AI, dedup, system prompt, overhead) |
104
+ | **Projects** | Per-project aggregate stats across all sessions, auto-detected from working directory or set manually via MCP |
105
+ | **History** | Past proxy sessions grouped by project and day — start/end time, duration, request count, tokens saved, relative timestamps |
106
+ | **Limits** | Real-time rate limit gauges per CLI: Anthropic token/request limits, OpenAI billing & credit balance, Gemini 429 tracking, input/output token usage (session + daily), personal monthly budget bar |
107
+ | **Settings** | Compression mode selector (Soft/Normal/Aggressive/Critical), threshold tuning |
108
+
109
+ Updates every 2 seconds via SSE. Works with both API key and subscription (OAuth) authentication.
110
+
111
+ ## MCP server
112
+
113
+ Built-in MCP server (`squeezr-mcp`) that gives any MCP-capable AI CLI real-time awareness and control of Squeezr.
114
+
115
+ **Installed automatically** by `squeezr setup` into Claude Code, Cursor, Windsurf, and Cline.
116
+
117
+ | Tool | Description |
118
+ |------|-------------|
119
+ | `squeezr_status` | Is proxy running? Version, port, uptime, mode, dry-run state |
120
+ | `squeezr_stats` | Token savings, compression %, cost saved, savings breakdown (deterministic/AI/dedup/system prompt/overhead), per-tool breakdown |
121
+ | `squeezr_set_mode` | Change compression mode instantly (soft / normal / aggressive / critical) |
122
+ | `squeezr_config` | Current thresholds, keepRecent, cache sizes, AI-skipped tools |
123
+ | `squeezr_habits` | Detect wasteful patterns this session (duplicate reads, high Bash count, cache efficiency) |
124
+ | `squeezr_stop` | Stop the proxy gracefully (persists caches before exit) |
125
+ | `squeezr_check_updates` | Check npm for newer Squeezr version |
126
+ | `squeezr_update` | Update to latest version via `npm install -g squeezr-ai@latest` |
127
+ | `squeezr_set_project` | Manually set/clear the current project name (overrides auto-detection) |
128
+
129
+ Every MCP tool response automatically checks for updates and appends a notification banner when a new version is available.
130
+
131
+ ## Honest savings tracking
132
+
133
+ Squeezr tracks token savings with full transparency. `squeezr gain` and the dashboard break down savings by source:
134
+
135
+ | Source | Description |
136
+ |--------|-------------|
137
+ | Deterministic | Rule-based preprocessing (ANSI strip, dedup, minification) free, zero latency |
138
+ | AI compression | Haiku/GPT-mini summarization of tool results — near-free, slight latency |
139
+ | Read dedup | Cross-turn deduplication of repeated file reads |
140
+ | System prompt | One-time AI compression of the system prompt, cached across requests |
141
+ | Tag overhead | Bytes added by `[squeezr:ID]` markers (subtracted from savings) |
142
+ | AI cost | Estimated token cost of compression API calls (subtracted from NET) |
143
+
144
+ **NET savings** = total savings tag overhead AI compression cost.
145
+
146
+ ### `squeezr gain` subcommands
147
+
148
+ ```bash
149
+ squeezr gain # all-time savings summary
150
+ squeezr gain --session # live session savings from the running proxy
151
+ squeezr gain --details # all-time stats with per-tool breakdown
152
+ squeezr gain --reset # reset all-time counters
153
+ ```
154
+
155
+ ## Project tracking
156
+
157
+ Squeezr automatically detects the active project from the CLI's working directory (e.g. Claude Code's `<cwd>` tag in the system prompt). Per-project stats are tracked across sessions.
158
+
159
+ - **Auto-detection:** extracts the project name from the last meaningful path segment
160
+ - **Manual override:** `squeezr_set_project` MCP tool or `POST /squeezr/project` REST endpoint
161
+ - **Per-project stats:** visible on the Dashboard's Projects page and in `squeezr gain --session`
162
+
163
+ ## Codex support (MITM proxy)
164
+
165
+ Codex uses WebSocket over TLS to `chatgpt.com` with OAuth authentication — it cannot be proxied via `OPENAI_BASE_URL`. Squeezr runs a TLS-terminating MITM proxy on port 8081 that intercepts and compresses WebSocket frames. See [CODEX.md](CODEX.md) for the full technical breakdown.
166
+
167
+ The MITM proxy **only intercepts `chatgpt.com`** traffic. All other HTTPS requests (npm, git, curl, etc.) pass through as a transparent TCP tunnel — no certificate needed, no interference.
168
+
169
+ ## Configuration
170
+
171
+ ### Global config: `squeezr.toml` (next to the binary)
172
+
173
+ ```toml
174
+ # Compression thresholds
175
+ threshold = 800 # min chars to apply compression
176
+ keep_recent = 3 # skip the N most recent tool results
177
+ ai_compression = false # enable AI (Haiku) for tool result compression
178
+
179
+ # Ports
180
+ port = 8080 # HTTP proxy port
181
+ mitm_port = 8081 # MITM proxy port (Codex)
182
+
183
+ # Models
184
+ local_model = "qwen2.5-coder:1.5b" # model for local compression
185
+ local_upstream = "http://localhost:11434"
186
+
187
+ # Tools to never AI-compress (deterministic-only)
188
+ ai_skip_tools = ["Read", "View"]
189
+
190
+ # Compression modes override thresholds
191
+ [modes.soft]
192
+ threshold = 1500
193
+ keep_recent = 10
194
+ ai_compression = false
195
+
196
+ [modes.normal]
197
+ threshold = 800
198
+ keep_recent = 3
199
+
200
+ [modes.aggressive]
201
+ threshold = 200
202
+ keep_recent = 1
203
+ ai_compression = true
204
+
205
+ [modes.critical]
206
+ threshold = 50
207
+ keep_recent = 0
208
+ ai_compression = true
209
+ ```
210
+
211
+ ### Project-level config: `squeezr.project.toml` (in project root)
212
+
213
+ Project-level config is deep-merged over global config. Useful for per-repo tuning.
214
+
215
+ ### Environment variables
216
+
217
+ | Variable | Default | Description |
218
+ |----------|---------|-------------|
219
+ | `SQUEEZR_PORT` | `8080` | HTTP proxy port (Claude, Aider, Gemini) |
220
+ | `SQUEEZR_MITM_PORT` | `8081` | MITM proxy port (Codex) — defaults to SQUEEZR_PORT + 1 |
221
+ | `SQUEEZR_THRESHOLD` | `800` | Min chars to compress |
222
+ | `SQUEEZR_KEEP_RECENT` | `3` | Recent results to skip |
223
+ | `SQUEEZR_DISABLED` | `false` | Disable all compression |
224
+ | `SQUEEZR_DRY_RUN` | `false` | Log savings without compressing |
225
+ | `SQUEEZR_LOCAL_UPSTREAM` | `http://localhost:11434` | Ollama/LM Studio URL |
226
+ | `SQUEEZR_LOCAL_MODEL` | `qwen2.5-coder:1.5b` | Local model for compression |
227
+
228
+ ### Per-command skip
229
+
230
+ Add `# squeezr:skip` anywhere in a Bash command to bypass compression for that result.
231
+
232
+ ## CLI commands
233
+
234
+ ```bash
235
+ squeezr setup # configure env vars, auto-start, CA trust, install MCP server
236
+ squeezr start # start the proxy (auto-restarts if version mismatch after update)
237
+ squeezr update # kill old processes, install latest from npm, restart
238
+ squeezr stop # stop the proxy
239
+ squeezr status # check if proxy is running
240
+ squeezr logs # show last 50 log lines
241
+ squeezr config # print current config
242
+ squeezr ports # change HTTP and MITM proxy ports
243
+ squeezr gain # all-time token savings summary
244
+ squeezr gain --session # live session savings from the running proxy
245
+ squeezr gain --details # all-time stats with per-tool breakdown
246
+ squeezr gain --reset # reset all-time counters
247
+ squeezr discover # detect which AI CLIs are installed
248
+ squeezr mcp install # register MCP server in Claude Code, Cursor, Windsurf, Cline
249
+ squeezr mcp uninstall # remove MCP server registration
250
+ squeezr uninstall # remove Squeezr completely (env vars, CA, auto-start, logs)
251
+ squeezr version # print version
252
+ ```
253
+
254
+ ## Compression backends
255
+
256
+ Squeezr uses cheap/free models for AI compression (the deterministic layer is pure regex, no API calls):
257
+
258
+ | Backend | Model | Used for | Cost |
259
+ |---------|-------|----------|------|
260
+ | Anthropic | Haiku | System prompt, session cache | ~$0.0001/call |
261
+ | OpenAI | GPT-4o-mini | Fallback compression | ~$0.0001/call |
262
+ | Gemini | Flash-8B | Fallback compression | Free |
263
+ | Local | qwen2.5-coder:1.5b | Compression when using Ollama | Free |
264
+ | ChatGPT (WS) | GPT-5.4-mini | Codex frame compression | $0 (same subscription) |
265
+
266
+ ## Requirements
267
+
268
+ - Node.js 18+ (compatible with Node.js 24)
269
+ - For Codex MITM: set `HTTPS_PROXY=http://localhost:8081` in the terminal where you run Codex (not set globally to avoid interfering with other tools)
270
+ - For local compression: [Ollama](https://ollama.ai) with `qwen2.5-coder:1.5b`
271
+
272
+ ## License
273
+
274
+ MIT
package/bin/squeezr.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  import { spawn, execSync } from 'child_process'
4
4
  import http from 'http'
@@ -3,4 +3,4 @@
3
3
  * Dark GitHub-style theme, sidebar navigation, 4 pages.
4
4
  * All data via SSE (/squeezr/events) + REST (/squeezr/history, /squeezr/projects).
5
5
  */
6
- export declare const DASHBOARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Squeezr Dashboard</title>\n<style>\n*{box-sizing:border-box;margin:0;padding:0}\n:root{\n --bg:#0d1117;--bg2:#161b22;--bg3:#21262d;--bg4:#2d333b;\n --border:#30363d;--text:#e6edf3;--muted:#8b949e;\n --green:#3fb950;--yellow:#d29922;--red:#f85149;\n --blue:#58a6ff;--purple:#bc8cff;--orange:#ffa657;--accent:#238636\n}\nhtml,body{height:100%;background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,-apple-system,sans-serif;font-size:13px;line-height:1.5}\na{color:var(--blue);text-decoration:none}\ncode{font-family:'Cascadia Code','Fira Mono','Consolas',monospace}\n\n/* \u2500\u2500 App shell \u2500\u2500 */\n#app{display:flex;height:100vh;overflow:hidden}\n\n/* \u2500\u2500 Sidebar \u2500\u2500 */\n#sidebar{\n width:200px;flex-shrink:0;background:var(--bg2);\n border-right:1px solid var(--border);\n display:flex;flex-direction:column;overflow:hidden\n}\n#sidebar-brand{padding:16px 16px 12px;border-bottom:1px solid var(--border)}\n#sidebar-brand .logo{font-size:18px;font-weight:700;letter-spacing:.3px;line-height:1}\n#sidebar-brand .logo span{color:var(--blue)}\n#sidebar-brand .ver{font-size:11px;color:var(--muted);margin-top:3px}\n\nnav{flex:1;padding:8px 0;overflow-y:auto}\n.nav-item{\n display:flex;align-items:center;gap:9px;padding:8px 16px;\n color:var(--muted);cursor:pointer;border-radius:0;\n transition:background .1s,color .1s;user-select:none\n}\n.nav-item:hover{background:var(--bg3);color:var(--text)}\n.nav-item.active{background:var(--bg3);color:var(--blue)}\n.nav-item svg{flex-shrink:0;opacity:.8}\n.nav-item.active svg{opacity:1}\n.nav-label{font-size:13px}\n\n#sidebar-footer{padding:12px 16px;border-top:1px solid var(--border)}\n.status-row{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted)}\n.dot{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 5px var(--green);flex-shrink:0}\n.dot.off{background:var(--red);box-shadow:0 0 5px var(--red)}\n#uptime-small{font-size:11px;color:var(--muted);margin-top:4px}\n\n/* \u2500\u2500 Main content \u2500\u2500 */\n#content{flex:1;display:flex;flex-direction:column;overflow:hidden}\n#page-header{\n display:flex;align-items:center;gap:10px;padding:12px 20px;\n background:var(--bg2);border-bottom:1px solid var(--border);flex-shrink:0\n}\n#page-title{font-size:15px;font-weight:600}\n#project-badge{\n font-size:11px;background:var(--bg3);border:1px solid var(--border);\n border-radius:12px;padding:2px 10px;color:var(--blue);font-weight:500\n}\n#header-uptime{font-size:11px;color:var(--muted);margin-left:auto}\n#conn-pill{\n font-size:11px;padding:2px 8px;border-radius:10px;\n background:rgba(63,185,80,.15);color:var(--green);border:1px solid rgba(63,185,80,.3)\n}\n#conn-pill.err{background:rgba(248,81,73,.15);color:var(--red);border-color:rgba(248,81,73,.3)}\n\n#pages{flex:1;overflow-y:auto;padding:16px 20px}\n.page{display:none}\n.page.active{display:block}\n\n/* \u2500\u2500 Cards \u2500\u2500 */\n.cards-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(175px,1fr));gap:10px;margin-bottom:14px}\n.card{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px 16px}\n.card-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}\n.card-value{font-size:26px;font-weight:700;line-height:1.1}\n.card-sub{font-size:11px;color:var(--muted);margin-top:3px}\n.c-green .card-value{color:var(--green)}\n.c-blue .card-value{color:var(--blue)}\n.c-yellow .card-value{color:var(--yellow)}\n.c-orange .card-value{color:var(--orange)}\n\n/* \u2500\u2500 Sections \u2500\u2500 */\n.section{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:14px}\n.section-title{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;font-weight:600}\n\n/* \u2500\u2500 Bars \u2500\u2500 */\n.bar-row{display:flex;align-items:center;gap:8px;margin-bottom:7px}\n.bar-label{font-size:12px;color:var(--muted);width:130px;flex-shrink:0}\n.bar-track{flex:1;height:7px;background:var(--bg3);border-radius:4px;overflow:hidden}\n.bar-fill{height:100%;border-radius:4px;transition:width .4s,background .4s}\n.bar-val{font-size:11px;width:36px;text-align:right;flex-shrink:0;color:var(--muted)}\n\n/* \u2500\u2500 Sparkline \u2500\u2500 */\ncanvas#sparkline{width:100%;height:72px;display:block}\n\n/* \u2500\u2500 Tables \u2500\u2500 */\ntable{width:100%;border-collapse:collapse}\nth{font-size:11px;color:var(--muted);text-align:left;padding:4px 8px;border-bottom:1px solid var(--border);font-weight:500;letter-spacing:.3px;text-transform:uppercase}\ntd{padding:6px 8px;font-size:12px;border-bottom:1px solid var(--border)}\ntr:last-child td{border-bottom:none}\n.td-right{text-align:right;font-variant-numeric:tabular-nums}\n.mini-bar{display:inline-block;height:5px;border-radius:2px;vertical-align:middle;margin-right:5px;opacity:.75}\n.tag{display:inline-block;background:var(--bg3);border:1px solid var(--border);border-radius:3px;padding:1px 6px;font-size:11px;font-family:monospace}\n\n/* \u2500\u2500 Cache row \u2500\u2500 */\n.cache-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px}\n.cache-card{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:10px 14px}\n.cache-card .cache-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px}\n.cache-card .cache-val{font-size:18px;font-weight:600;color:var(--purple)}\n\n/* \u2500\u2500 Mode buttons \u2500\u2500 */\n.mode-btns{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px}\n.mode-btn{\n display:flex;align-items:center;gap:6px;padding:6px 14px;\n border-radius:6px;border:1px solid var(--border);background:var(--bg3);\n color:var(--muted);cursor:pointer;font-size:12px;transition:all .15s\n}\n.mode-btn:hover{border-color:var(--blue);color:var(--text)}\n.mode-btn.active{border-color:var(--accent);background:var(--accent);color:#fff}\n.mode-btn.active svg{stroke:white}\n#mode-desc{font-size:12px;color:var(--muted);min-height:16px}\n\n/* \u2500\u2500 Projects page \u2500\u2500 */\n.project-table td:first-child code{font-size:12px}\n.project-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}\n\n/* \u2500\u2500 History page \u2500\u2500 */\n#hist-layout{display:grid;grid-template-columns:220px 1fr;gap:12px;min-height:400px}\n#hist-projects{background:var(--bg2);border:1px solid var(--border);border-radius:8px;overflow:hidden}\n#hist-sessions{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:0}\n.hist-proj-item{\n padding:9px 14px;cursor:pointer;border-bottom:1px solid var(--border);\n display:flex;justify-content:space-between;align-items:center;\n font-size:12px;color:var(--muted);transition:background .1s\n}\n.hist-proj-item:last-child{border-bottom:none}\n.hist-proj-item:hover{background:var(--bg3)}\n.hist-proj-item.active{background:var(--bg3);color:var(--blue)}\n.hist-proj-count{font-size:11px;background:var(--bg4);border-radius:10px;padding:1px 7px}\n.hist-sessions-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;font-weight:600}\n.session-card{padding:12px 16px;border-bottom:1px solid var(--border)}\n.session-card:last-child{border-bottom:none}\n.session-date{font-size:12px;font-weight:600;color:var(--text);margin-bottom:4px}\n.session-time{font-size:11px;color:var(--muted);margin-bottom:6px}\n.session-stats{display:flex;gap:14px;flex-wrap:wrap}\n.session-stat{font-size:11px;color:var(--muted)}\n.session-stat span{color:var(--text);font-weight:500}\n.session-project-badge{font-size:10px;background:var(--bg4);border:1px solid var(--border);border-radius:10px;padding:1px 8px;color:var(--blue);margin-left:6px}\n.empty-msg{padding:32px 16px;text-align:center;color:var(--muted);font-size:12px}\n\n/* \u2500\u2500 Settings \u2500\u2500 */\n.config-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border);font-size:12px}\n.config-row:last-child{border-bottom:none}\n.config-key{color:var(--muted)}\n.config-val{font-family:monospace;color:var(--text)}\n\n/* \u2500\u2500 Limits page \u2500\u2500 */\n.limits-cli-section{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:14px}\n.limits-cli-header{display:flex;align-items:center;gap:8px;margin-bottom:12px}\n.limits-cli-name{font-size:13px;font-weight:600;color:var(--text)}\n.limits-cli-badge{font-size:10px;padding:1px 7px;border-radius:10px;border:1px solid;margin-left:2px}\n.limits-cli-badge.live{border-color:rgba(63,185,80,.4);color:var(--green);background:rgba(63,185,80,.1)}\n.limits-cli-badge.error{border-color:rgba(248,81,73,.4);color:var(--red);background:rgba(248,81,73,.1)}\n.limits-cli-badge.warn{border-color:rgba(210,153,34,.4);color:var(--yellow);background:rgba(210,153,34,.1)}\n.limits-cli-badge.none{border-color:var(--border);color:var(--muted);background:transparent}\n.limits-gauge-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-bottom:10px}\n.limits-gauge{background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:10px 12px}\n.limits-gauge-label{font-size:11px;color:var(--muted);margin-bottom:6px;display:flex;justify-content:space-between}\n.limits-gauge-bar{height:6px;background:var(--bg4);border-radius:3px;overflow:hidden;margin-bottom:5px}\n.limits-gauge-fill{height:100%;border-radius:3px;transition:width .5s,background .5s}\n.limits-gauge-bottom{display:flex;justify-content:space-between;font-size:11px}\n.limits-gauge-remaining{color:var(--text);font-weight:500}\n.limits-gauge-reset{color:var(--muted)}\n.limits-usage-row{display:flex;gap:16px;flex-wrap:wrap;padding-top:8px;border-top:1px solid var(--border);margin-top:4px}\n.limits-usage-item{font-size:12px;color:var(--muted)}\n.limits-usage-item span{color:var(--text);font-weight:500}\n.limits-no-data{padding:16px;text-align:center;color:var(--muted);font-size:12px}\n.limits-billing-row{display:flex;gap:10px;flex-wrap:wrap;padding:8px 0 2px}\n.limits-credit-card{flex:1;min-width:120px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:10px 12px}\n.limits-credit-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}\n.limits-credit-val{font-size:20px;font-weight:600;color:var(--green)}\n.limits-budget-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px}\n.limits-budget-input{background:var(--bg3);border:1px solid var(--border);border-radius:5px;padding:5px 10px;color:var(--text);font-size:12px;width:140px;outline:none}\n.limits-budget-input:focus{border-color:var(--blue)}\n.limits-budget-label{font-size:12px;color:var(--muted)}\n\n/* \u2500\u2500 Footer bar \u2500\u2500 */\n#footer{padding:7px 20px;border-top:1px solid var(--border);background:var(--bg2);font-size:11px;color:var(--muted);display:flex;gap:16px;flex-shrink:0}\n#footer a{color:var(--muted)}#footer a:hover{color:var(--blue)}\n</style>\n</head>\n<body>\n<div id=\"app\">\n\n<!-- \u2500\u2500 Sidebar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<div id=\"sidebar\">\n <div id=\"sidebar-brand\">\n <div class=\"logo\">Squee<span>zr</span></div>\n <div class=\"ver\" id=\"sb-ver\">v\u2014</div>\n </div>\n\n <nav>\n <div class=\"nav-item active\" data-page=\"overview\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M1 2.5A1.5 1.5 0 012.5 1h3A1.5 1.5 0 017 2.5v3A1.5 1.5 0 015.5 7h-3A1.5 1.5 0 011 5.5v-3zm8 0A1.5 1.5 0 0110.5 1h3A1.5 1.5 0 0115 2.5v3A1.5 1.5 0 0113.5 7h-3A1.5 1.5 0 019 5.5v-3zm-8 8A1.5 1.5 0 012.5 9h3A1.5 1.5 0 017 10.5v3A1.5 1.5 0 015.5 15h-3A1.5 1.5 0 011 13.5v-3zm8 0A1.5 1.5 0 0110.5 9h3a1.5 1.5 0 011.5 1.5v3A1.5 1.5 0 0113.5 15h-3A1.5 1.5 0 019 13.5v-3z\"/>\n </svg>\n <span class=\"nav-label\">Overview</span>\n </div>\n <div class=\"nav-item\" data-page=\"projects\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M9.828 3h3.982a2 2 0 011.992 2.181l-.637 7A2 2 0 0113.174 14H2.826a2 2 0 01-1.991-1.819l-.637-7a1.99 1.99 0 01.342-1.31L.5 3a2 2 0 012-2h3.672a2 2 0 011.414.586l.828.828A2 2 0 009.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 006.172 2H2.5a1 1 0 00-1 .981l.006.139z\"/>\n </svg>\n <span class=\"nav-label\">Projects</span>\n </div>\n <div class=\"nav-item\" data-page=\"history\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M8 3.5a.5.5 0 00-1 0V9a.5.5 0 00.252.434l3.5 2a.5.5 0 00.496-.868L8 8.71V3.5z\"/>\n <path d=\"M8 16A8 8 0 108 0a8 8 0 000 16zm7-8A7 7 0 111 8a7 7 0 0114 0z\"/>\n </svg>\n <span class=\"nav-label\">History</span>\n </div>\n <div class=\"nav-item\" data-page=\"limits\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <line x1=\"18\" y1=\"20\" x2=\"18\" y2=\"10\"/>\n <line x1=\"12\" y1=\"20\" x2=\"12\" y2=\"4\"/>\n <line x1=\"6\" y1=\"20\" x2=\"6\" y2=\"14\"/>\n <line x1=\"2\" y1=\"20\" x2=\"22\" y2=\"20\"/>\n </svg>\n <span class=\"nav-label\">Limits</span>\n </div>\n <div class=\"nav-item\" data-page=\"settings\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M8 4.754a3.246 3.246 0 100 6.492 3.246 3.246 0 000-6.492zM5.754 8a2.246 2.246 0 114.492 0 2.246 2.246 0 01-4.492 0z\"/>\n <path d=\"M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 01-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 01-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 01.52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 011.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 011.255-.52l.292.16c1.64.892 3.433-.902 2.54-2.541l-.159-.292a.873.873 0 01.52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 01-.52-1.255l.16-.292c.892-1.64-.901-3.433-2.541-2.54l-.292.159a.873.873 0 01-1.255-.52l-.094-.319z\"/>\n </svg>\n <span class=\"nav-label\">Settings</span>\n </div>\n </nav>\n\n <div id=\"sidebar-footer\">\n <div class=\"status-row\">\n <div class=\"dot\" id=\"status-dot\"></div>\n <span id=\"status-text\">Connecting\u2026</span>\n </div>\n <div id=\"uptime-small\"></div>\n </div>\n</div>\n\n<!-- \u2500\u2500 Main content \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<div id=\"content\">\n <div id=\"page-header\">\n <span id=\"page-title\">Overview</span>\n <span id=\"project-badge\" style=\"display:none\"></span>\n <span id=\"header-uptime\"></span>\n <span id=\"conn-pill\">\u25CF live</span>\n </div>\n\n <div id=\"pages\">\n\n <!-- \u2500\u2500\u2500 Overview \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page active\" id=\"page-overview\">\n <div class=\"cards-grid\">\n <div class=\"card c-green\">\n <div class=\"card-label\">Tokens Saved</div>\n <div class=\"card-value\" id=\"c-tokens\">\u2014</div>\n <div class=\"card-sub\" id=\"c-chars\">\u2014 chars</div>\n </div>\n <div class=\"card c-blue\">\n <div class=\"card-label\">Compression</div>\n <div class=\"card-value\" id=\"c-pct\">\u2014</div>\n <div class=\"card-sub\">of tool results</div>\n </div>\n <div class=\"card c-yellow\">\n <div class=\"card-label\">Requests</div>\n <div class=\"card-value\" id=\"c-req\">\u2014</div>\n <div class=\"card-sub\" id=\"c-compressions\">\u2014 compressions</div>\n </div>\n <div class=\"card c-orange\">\n <div class=\"card-label\">Est. Cost Saved</div>\n <div class=\"card-value\" id=\"c-cost\">\u2014</div>\n <div class=\"card-sub\">@ $3 / MTok</div>\n </div>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">Context pressure \u2014 last request</div>\n <div class=\"bar-row\">\n <span class=\"bar-label\">Before compression</span>\n <div class=\"bar-track\"><div class=\"bar-fill\" id=\"bar-msg\" style=\"width:0%\"></div></div>\n <span class=\"bar-val\" id=\"pct-msg\">0%</span>\n </div>\n <div class=\"bar-row\">\n <span class=\"bar-label\">After compression</span>\n <div class=\"bar-track\"><div class=\"bar-fill\" id=\"bar-out\" style=\"width:0%\"></div></div>\n <span class=\"bar-val\" id=\"pct-out\">0%</span>\n </div>\n <div class=\"bar-row\" style=\"margin-bottom:0\">\n <span class=\"bar-label\">Session cache hits</span>\n <div class=\"bar-track\"><div class=\"bar-fill\" id=\"bar-cache\" style=\"width:0%;background:var(--purple)\"></div></div>\n <span class=\"bar-val\" id=\"pct-cache\">0</span>\n </div>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">Activity \u2014 tokens saved per request <span style=\"font-weight:400;text-transform:none;letter-spacing:0\">(last 60)</span></div>\n <canvas id=\"sparkline\"></canvas>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">By tool</div>\n <table>\n <thead>\n <tr>\n <th>Tool</th>\n <th class=\"td-right\">Calls</th>\n <th class=\"td-right\">Tokens saved</th>\n <th>Savings</th>\n </tr>\n </thead>\n <tbody id=\"tools-body\">\n <tr><td colspan=\"4\" style=\"color:var(--muted);padding:14px 8px;text-align:center\">No data yet\u2026</td></tr>\n </tbody>\n </table>\n </div>\n\n <div class=\"cache-row\">\n <div class=\"cache-card\">\n <div class=\"cache-label\">Session cache</div>\n <div class=\"cache-val\" id=\"c-scache\">\u2014</div>\n </div>\n <div class=\"cache-card\">\n <div class=\"cache-label\">Expand store</div>\n <div class=\"cache-val\" id=\"c-expand\">\u2014</div>\n </div>\n <div class=\"cache-card\">\n <div class=\"cache-label\">LRU cache</div>\n <div class=\"cache-val\" id=\"c-lru\">\u2014</div>\n </div>\n <div class=\"cache-card\">\n <div class=\"cache-label\">Pattern hits</div>\n <div class=\"cache-val\" id=\"c-patterns\">\u2014</div>\n </div>\n </div>\n\n <!-- Savings breakdown -->\n <div class=\"section\">\n <div class=\"section-title\">Savings Breakdown</div>\n <div class=\"cache-grid\" style=\"grid-template-columns:1fr 1fr 1fr\">\n <div class=\"cache-item\">\n <div class=\"cache-label\">Deterministic</div>\n <div class=\"cache-val\" id=\"bd-det\" style=\"color:var(--green)\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">AI compression</div>\n <div class=\"cache-val\" id=\"bd-ai\" style=\"color:var(--blue)\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">Read dedup</div>\n <div class=\"cache-val\" id=\"bd-dedup\" style=\"color:var(--purple)\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">System prompt</div>\n <div class=\"cache-val\" id=\"bd-sysprompt\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">Tag overhead</div>\n <div class=\"cache-val\" id=\"bd-overhead\" style=\"color:var(--muted)\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">AI calls</div>\n <div class=\"cache-val\" id=\"bd-aicalls\" style=\"color:var(--muted)\">\u2014</div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 Projects \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-projects\">\n <div class=\"section\" style=\"margin-bottom:0\">\n <div class=\"section-title\" id=\"projects-section-title\">All projects \u2014 this session + history</div>\n <table class=\"project-table\">\n <thead>\n <tr>\n <th>Project</th>\n <th class=\"td-right\">Sessions</th>\n <th class=\"td-right\">Requests</th>\n <th class=\"td-right\">Tokens saved</th>\n <th class=\"td-right\">Last seen</th>\n </tr>\n </thead>\n <tbody id=\"projects-body\">\n <tr><td colspan=\"5\" style=\"color:var(--muted);padding:20px 8px;text-align:center\">Loading\u2026</td></tr>\n </tbody>\n </table>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 History \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-history\">\n <div id=\"hist-layout\">\n <div id=\"hist-projects\">\n <div style=\"padding:10px 14px;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid var(--border)\">Projects</div>\n <div id=\"hist-proj-list\"></div>\n </div>\n <div id=\"hist-sessions\">\n <div class=\"hist-sessions-header\" id=\"hist-sessions-header\">Select a project</div>\n <div id=\"hist-sessions-list\"><div class=\"empty-msg\">Select a project on the left to view sessions.</div></div>\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 Limits \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-limits\">\n\n <!-- Anthropic -->\n <div class=\"limits-cli-section\" id=\"lim-anthropic\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=\"color:var(--orange)\">\n <path d=\"M13.83 2.34a2.09 2.09 0 0 0-3.66 0L1.13 18.9A2.09 2.09 0 0 0 2.96 22h18.08a2.09 2.09 0 0 0 1.83-3.1L13.83 2.34ZM12 8a1 1 0 0 1 1 1v5a1 1 0 0 1-2 0V9a1 1 0 0 1 1-1Zm0 10a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z\"/>\n </svg>\n <span class=\"limits-cli-name\">Anthropic \u00B7 Claude Code</span>\n <span class=\"limits-cli-badge none\" id=\"ant-badge\">no data yet</span>\n </div>\n <div class=\"limits-gauge-grid\">\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Tokens / minute</span>\n <span id=\"ant-tok-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-tok-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-tok-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-tok-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Requests / minute</span>\n <span id=\"ant-req-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-req-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-req-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-req-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Input tokens / minute</span>\n <span id=\"ant-inp-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-inp-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-inp-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-inp-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Output tokens / minute</span>\n <span id=\"ant-out-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-out-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-out-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-out-reset\"></span>\n </div>\n </div>\n </div>\n <div class=\"limits-usage-row\">\n <div class=\"limits-usage-item\">Session input: <span id=\"ant-u-inp-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Session output: <span id=\"ant-u-out-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today input: <span id=\"ant-u-inp-d\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today output: <span id=\"ant-u-out-d\">\u2014</span></div>\n <div class=\"limits-usage-item\" style=\"margin-left:auto\">\n <a href=\"https://console.anthropic.com/settings/usage\" target=\"_blank\" style=\"color:var(--muted);font-size:11px\">View billing \u2197</a>\n </div>\n </div>\n </div>\n\n <!-- OpenAI -->\n <div class=\"limits-cli-section\" id=\"lim-openai\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=\"color:var(--text)\">\n <path d=\"M22.28 9.27a6.17 6.17 0 0 0-.53-5.06 6.24 6.24 0 0 0-6.7-2.99A6.23 6.23 0 0 0 10.36 0a6.24 6.24 0 0 0-5.95 4.32 6.23 6.23 0 0 0-4.16 3.02 6.24 6.24 0 0 0 .77 7.32 6.17 6.17 0 0 0 .53 5.06 6.24 6.24 0 0 0 6.7 2.99A6.23 6.23 0 0 0 13.64 24a6.25 6.25 0 0 0 5.96-4.33 6.23 6.23 0 0 0 4.15-3.02 6.24 6.24 0 0 0-.77-7.31l.3-.07ZM13.64 22.5a4.63 4.63 0 0 1-2.97-1.08l.15-.08 4.93-2.85a.82.82 0 0 0 .41-.71v-6.96l2.08 1.2a.08.08 0 0 1 .04.06v5.76a4.65 4.65 0 0 1-4.64 4.66Zm-9.95-4.27a4.63 4.63 0 0 1-.55-3.12l.14.09 4.93 2.85a.82.82 0 0 0 .82 0l6.02-3.47v2.4a.08.08 0 0 1-.03.06L10.06 20a4.65 4.65 0 0 1-6.37-1.77Zm-1.28-10.8a4.63 4.63 0 0 1 2.42-2.04v5.88a.82.82 0 0 0 .41.71l6.01 3.47-2.08 1.2a.08.08 0 0 1-.08 0L4.22 13.7a4.65 4.65 0 0 1-.81-6.27Zm17.09 3.99-6.02-3.48L15.56 7a.08.08 0 0 1 .08 0l4.87 2.81a4.64 4.64 0 0 1-.72 8.38v-5.88a.82.82 0 0 0-.39-.69Zm2.07-3.14-.14-.09-4.92-2.87a.82.82 0 0 0-.83 0L9.67 9.79V7.4a.08.08 0 0 1 .03-.06L14.6 4.5a4.64 4.64 0 0 1 6.9 4.81l.07-.03Zm-13.03 4.28-2.08-1.2a.08.08 0 0 1-.04-.06V5.5a4.64 4.64 0 0 1 7.62-3.56l-.15.08L7.9 4.87a.82.82 0 0 0-.41.71l-.01 6.98Zm1.13-2.43 2.68-1.55 2.68 1.55v3.1l-2.68 1.54-2.68-1.54v-3.1Z\"/>\n </svg>\n <span class=\"limits-cli-name\">OpenAI \u00B7 Codex</span>\n <span class=\"limits-cli-badge none\" id=\"oai-badge\">no data yet</span>\n </div>\n <div class=\"limits-gauge-grid\">\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Tokens / minute</span>\n <span id=\"oai-tok-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"oai-tok-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"oai-tok-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"oai-tok-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Requests / minute</span>\n <span id=\"oai-req-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"oai-req-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"oai-req-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"oai-req-reset\"></span>\n </div>\n </div>\n </div>\n <div class=\"limits-billing-row\" id=\"oai-billing-row\" style=\"display:none\">\n <div class=\"limits-credit-card\">\n <div class=\"limits-credit-label\">Credits remaining</div>\n <div class=\"limits-credit-val\" id=\"oai-credits\">\u2014</div>\n </div>\n <div class=\"limits-credit-card\">\n <div class=\"limits-credit-label\">Hard limit</div>\n <div class=\"limits-credit-val\" style=\"color:var(--yellow)\" id=\"oai-hard-lim\">\u2014</div>\n </div>\n </div>\n <div class=\"limits-usage-row\">\n <div class=\"limits-usage-item\">Session input: <span id=\"oai-u-inp-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Session output: <span id=\"oai-u-out-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today input: <span id=\"oai-u-inp-d\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today output: <span id=\"oai-u-out-d\">\u2014</span></div>\n <div class=\"limits-usage-item\" style=\"margin-left:auto\">\n <a href=\"https://platform.openai.com/usage\" target=\"_blank\" style=\"color:var(--muted);font-size:11px\">View billing \u2197</a>\n </div>\n </div>\n </div>\n\n <!-- Gemini -->\n <div class=\"limits-cli-section\" id=\"lim-gemini\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=\"color:var(--blue)\">\n <path d=\"M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0zm0 22C6.49 22 2 17.51 2 12S6.49 2 12 2s10 4.49 10 10-4.49 10-10 10zm-1-14h2v7h-2zm0 9h2v2h-2z\"/>\n </svg>\n <span class=\"limits-cli-name\">Google \u00B7 Gemini CLI</span>\n <span class=\"limits-cli-badge warn\" id=\"gem-badge\">only on 429 errors</span>\n </div>\n <div id=\"gem-nodata\" class=\"limits-no-data\">\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" style=\"margin-bottom:6px;display:block;margin-inline:auto;opacity:.4\">\n <circle cx=\"12\" cy=\"12\" r=\"10\"/><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"/><line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"/>\n </svg>\n Google does not expose quota headers on successful responses.<br>\n Data appears here only after a 429 rate-limit error.<br>\n <a href=\"https://aistudio.google.com/app/usage\" target=\"_blank\" style=\"margin-top:8px;display:inline-block\">View quotas in AI Studio \u2197</a>\n </div>\n <div id=\"gem-data\" style=\"display:none\">\n <div class=\"limits-gauge-grid\">\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\"><span>Last known token limit</span></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"gem-tok-lim\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"gem-errors\">0 errors</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"limits-usage-row\">\n <div class=\"limits-usage-item\">Session input: <span id=\"gem-u-inp-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Session output: <span id=\"gem-u-out-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today input: <span id=\"gem-u-inp-d\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today output: <span id=\"gem-u-out-d\">\u2014</span></div>\n <div class=\"limits-usage-item\" style=\"margin-left:auto\">\n <a href=\"https://aistudio.google.com/app/usage\" target=\"_blank\" style=\"color:var(--muted);font-size:11px\">View quotas \u2197</a>\n </div>\n </div>\n </div>\n\n <!-- Personal budget -->\n <div class=\"limits-cli-section\" style=\"margin-bottom:0\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\">\n <circle cx=\"12\" cy=\"12\" r=\"10\"/>\n <path d=\"M12 8v4l3 3\"/>\n </svg>\n <span class=\"limits-cli-name\">Personal daily budget</span>\n <span class=\"limits-cli-badge none\">optional</span>\n </div>\n <div class=\"limits-budget-row\">\n <input class=\"limits-budget-input\" id=\"budget-input\" type=\"number\" placeholder=\"e.g. 5000000\" min=\"0\">\n <span class=\"limits-budget-label\">tokens / day</span>\n <button class=\"btn-save\" id=\"budget-save\" style=\"padding:4px 12px;font-size:11px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--muted);cursor:pointer;transition:all .15s\" onmouseover=\"this.style.borderColor='var(--blue)';this.style.color='var(--text)'\" onmouseout=\"this.style.borderColor='var(--border)';this.style.color='var(--muted)'\">Save</button>\n </div>\n <div id=\"budget-bar-wrap\" style=\"margin-top:10px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px\">\n <span>Tokens used today through Squeezr</span>\n <span id=\"budget-pct-label\">0%</span>\n </div>\n <div class=\"limits-gauge-bar\" style=\"height:10px\">\n <div class=\"limits-gauge-fill\" id=\"budget-bar\" style=\"width:0%\"></div>\n </div>\n <div style=\"display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-top:4px\">\n <span id=\"budget-used-label\">0 used</span>\n <span id=\"budget-limit-label\">of \u2014</span>\n </div>\n </div>\n </div>\n\n </div>\n\n <!-- \u2500\u2500\u2500 Settings \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-settings\">\n <div class=\"section\" style=\"margin-bottom:14px\">\n <div class=\"section-title\">Compression mode</div>\n <div class=\"mode-btns\">\n <button class=\"mode-btn\" data-mode=\"soft\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\">\n <path d=\"M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2\"/>\n </svg>\n Soft\n </button>\n <button class=\"mode-btn active\" data-mode=\"normal\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <line x1=\"4\" y1=\"21\" x2=\"4\" y2=\"14\"/><line x1=\"4\" y1=\"10\" x2=\"4\" y2=\"3\"/>\n <line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"12\"/><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"3\"/>\n <line x1=\"20\" y1=\"21\" x2=\"20\" y2=\"16\"/><line x1=\"20\" y1=\"12\" x2=\"20\" y2=\"3\"/>\n <line x1=\"1\" y1=\"14\" x2=\"7\" y2=\"14\"/><line x1=\"9\" y1=\"8\" x2=\"15\" y2=\"8\"/>\n <line x1=\"17\" y1=\"16\" x2=\"23\" y2=\"16\"/>\n </svg>\n Normal\n </button>\n <button class=\"mode-btn\" data-mode=\"aggressive\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/>\n </svg>\n Aggressive\n </button>\n <button class=\"mode-btn\" data-mode=\"critical\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"/>\n <line x1=\"12\" y1=\"9\" x2=\"12\" y2=\"13\"/>\n <line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"/>\n </svg>\n Critical\n </button>\n </div>\n <div id=\"mode-desc\">Normal \u2014 threshold 800 chars, last 3 results uncompressed</div>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">Configuration</div>\n <div id=\"config-rows\">\n <div class=\"config-row\"><span class=\"config-key\">Mode</span><span class=\"config-val\" id=\"cfg-mode\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Port</span><span class=\"config-val\" id=\"cfg-port\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Dry-run</span><span class=\"config-val\" id=\"cfg-dryrun\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">LRU cache entries</span><span class=\"config-val\" id=\"cfg-lru\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Session cache entries</span><span class=\"config-val\" id=\"cfg-scache\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Version</span><span class=\"config-val\" id=\"cfg-version\">\u2014</span></div>\n </div>\n </div>\n\n <div class=\"section\" style=\"margin-bottom:0\">\n <div class=\"section-title\">Links</div>\n <div style=\"display:flex;gap:16px;flex-wrap:wrap;font-size:12px\">\n <a href=\"/squeezr/stats\" target=\"_blank\">/squeezr/stats JSON</a>\n <a href=\"/squeezr/history\" target=\"_blank\">/squeezr/history JSON</a>\n <a href=\"/squeezr/projects\" target=\"_blank\">/squeezr/projects JSON</a>\n <a href=\"https://github.com/sergioramosv/Squeezr\" target=\"_blank\">GitHub</a>\n </div>\n </div>\n </div>\n\n </div><!-- /pages -->\n\n <div id=\"footer\">\n <span>Squeezr v<span id=\"f-version\">\u2014</span></span>\n <span id=\"f-mode\">mode: active</span>\n <span id=\"f-port\"></span>\n <span id=\"conn-status\" style=\"margin-left:auto;color:var(--green)\">\u25CF connected</span>\n </div>\n</div><!-- /content -->\n\n</div><!-- /app -->\n\n<script>\n// \u2500\u2500 Sparkline \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst MAX_PTS = 60\nconst sparkData = []\nlet lastTokens = 0\nfunction pushSpark(t) {\n sparkData.push(Math.max(0, t - lastTokens))\n lastTokens = t\n if (sparkData.length > MAX_PTS) sparkData.shift()\n}\nfunction drawSpark() {\n const cv = document.getElementById('sparkline')\n if (!cv) return\n const dpr = window.devicePixelRatio || 1\n const r = cv.getBoundingClientRect()\n cv.width = r.width * dpr; cv.height = r.height * dpr\n const ctx = cv.getContext('2d')\n ctx.scale(dpr, dpr)\n const w = r.width, h = r.height\n const mx = Math.max(...sparkData, 1)\n ctx.clearRect(0, 0, w, h)\n if (sparkData.length < 2) return\n const step = w / (MAX_PTS - 1)\n ctx.beginPath(); ctx.moveTo(0, h)\n sparkData.forEach((v, i) => ctx.lineTo(i * step, h - (v / mx) * (h - 4)))\n ctx.lineTo((sparkData.length - 1) * step, h)\n ctx.closePath()\n const g = ctx.createLinearGradient(0, 0, 0, h)\n g.addColorStop(0, 'rgba(63,185,80,.3)'); g.addColorStop(1, 'rgba(63,185,80,0)')\n ctx.fillStyle = g; ctx.fill()\n ctx.beginPath()\n sparkData.forEach((v, i) => {\n const x = i * step, y = h - (v / mx) * (h - 4)\n i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)\n })\n ctx.strokeStyle = '#3fb950'; ctx.lineWidth = 1.5; ctx.stroke()\n}\nwindow.addEventListener('resize', drawSpark)\n\n// \u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction fmtN(n) {\n if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'\n if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'\n return String(n)\n}\nfunction fmtCost(tok) {\n const u = (tok / 1e6) * 3\n return u < 0.01 ? '<$0.01' : u < 1 ? '$' + u.toFixed(3) : '$' + u.toFixed(2)\n}\nfunction fmtUptime(s) {\n if (s < 60) return s + 's'\n if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's'\n return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'\n}\nfunction fmtTs(ms) {\n if (!ms) return '\u2014'\n const d = new Date(ms)\n return d.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})\n}\nfunction fmtTime(ms) {\n if (!ms) return '\u2014'\n const d = new Date(ms)\n return d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false})\n}\nfunction fmtDur(startMs, endMs) {\n const s = Math.round((endMs - startMs) / 1000)\n if (s < 60) return s + 's'\n if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's'\n return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'\n}\nfunction timeAgo(ms) {\n if (!ms) return ''\n const diff = Math.round((Date.now() - ms) / 1000)\n if (diff < 60) return 'just now'\n if (diff < 3600) return Math.floor(diff / 60) + 'm ago'\n if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'\n if (diff < 172800) return 'yesterday'\n return Math.floor(diff / 86400) + 'd ago'\n}\nfunction barColor(p) {\n if (p >= 90) return 'var(--red)'\n if (p >= 75) return 'var(--yellow)'\n if (p >= 50) return 'var(--orange)'\n return 'var(--blue)'\n}\nfunction setBar(bid, vid, pct, label, noColor) {\n const b = document.getElementById(bid), v = document.getElementById(vid)\n b.style.width = Math.min(pct, 100) + '%'\n if (!noColor) b.style.background = barColor(pct)\n v.textContent = label\n}\nconst PROJECT_COLORS = ['#58a6ff','#3fb950','#ffa657','#bc8cff','#d29922','#f85149','#79c0ff','#56d364']\nfunction projectColor(name) {\n let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffff\n return PROJECT_COLORS[h % PROJECT_COLORS.length]\n}\n\n// \u2500\u2500 Overview render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction renderOverview(d) {\n document.getElementById('c-tokens').textContent = fmtN(d.total_saved_tokens)\n document.getElementById('c-chars').textContent = (d.total_saved_chars || 0).toLocaleString() + ' chars'\n document.getElementById('c-pct').textContent = (d.savings_pct || 0) + '%'\n document.getElementById('c-req').textContent = fmtN(d.requests || 0)\n document.getElementById('c-compressions').textContent = (d.compressions || 0) + ' compressions'\n document.getElementById('c-cost').textContent = fmtCost(d.total_saved_tokens || 0)\n document.getElementById('f-version').textContent = d.version || '\u2014'\n document.getElementById('sb-ver').textContent = 'v' + (d.version || '\u2014')\n document.getElementById('f-mode').textContent = 'mode: ' + (d.dry_run ? 'dry-run' : 'active')\n document.getElementById('f-port').textContent = 'port: ' + (d.port || '\u2014')\n document.getElementById('header-uptime').textContent = 'uptime ' + fmtUptime(d.uptime_seconds || 0)\n document.getElementById('uptime-small').textContent = fmtUptime(d.uptime_seconds || 0)\n\n // Project badge\n const proj = d.current_project\n const badge = document.getElementById('project-badge')\n if (proj && proj !== 'unknown') {\n badge.textContent = proj\n badge.style.display = ''\n badge.style.borderColor = projectColor(proj)\n badge.style.color = projectColor(proj)\n } else {\n badge.style.display = 'none'\n }\n\n // Pressure bars\n const msgPct = Math.min(Math.round((d.last_original_chars || 0) / 80), 100)\n const outPct = Math.min(Math.round((d.last_compressed_chars || 0) / 80), 100)\n const ch = d.session_cache_hits || 0\n const cachePct = Math.round((ch / Math.max(ch + (d.compressions || 1), 1)) * 100)\n setBar('bar-msg', 'pct-msg', msgPct, msgPct + '%')\n setBar('bar-out', 'pct-out', outPct, outPct + '%')\n setBar('bar-cache', 'pct-cache', cachePct, ch, true)\n\n // Sparkline\n pushSpark(d.total_saved_tokens || 0)\n drawSpark()\n\n // Tool table\n const bt = d.by_tool || {}\n const rows = Object.entries(bt).sort((a, b) => b[1].saved_tokens - a[1].saved_tokens)\n const maxSaved = rows[0]?.[1]?.saved_tokens || 1\n const tbody = document.getElementById('tools-body')\n if (rows.length === 0) {\n tbody.innerHTML = '<tr><td colspan=\"4\" style=\"color:var(--muted);padding:14px 8px;text-align:center\">No tool results compressed yet\u2026</td></tr>'\n } else {\n tbody.innerHTML = rows.map(([tool, t]) => {\n const bw = Math.round((t.saved_tokens / maxSaved) * 72)\n return `<tr>\n <td><code class=\"tag\">${tool}</code></td>\n <td class=\"td-right\" style=\"color:var(--muted)\">${t.count}</td>\n <td class=\"td-right\">${fmtN(t.saved_tokens)}</td>\n <td><span class=\"mini-bar\" style=\"width:${bw}px;background:var(--green)\"></span>${t.avg_pct}%</td>\n </tr>`\n }).join('')\n }\n\n // Cache stats\n document.getElementById('c-scache').textContent = d.session_cache_size ?? '\u2014'\n document.getElementById('c-expand').textContent = d.expand_store_size ?? '\u2014'\n document.getElementById('c-lru').textContent = d.cache?.size ?? '\u2014'\n document.getElementById('c-patterns').textContent = d.pattern_hits\n ? Object.values(d.pattern_hits).reduce((s, v) => s + v, 0).toLocaleString()\n : '\u2014'\n\n // Settings config panel\n document.getElementById('cfg-mode').textContent = d.mode || '\u2014'\n document.getElementById('cfg-port').textContent = d.port || '\u2014'\n document.getElementById('cfg-dryrun').textContent = d.dry_run ? 'yes' : 'no'\n document.getElementById('cfg-lru').textContent = d.cache?.size ?? '\u2014'\n document.getElementById('cfg-scache').textContent = d.session_cache_size ?? '\u2014'\n document.getElementById('cfg-version').textContent = d.version || '\u2014'\n\n // Sync active mode button\n document.querySelectorAll('.mode-btn').forEach(b => {\n b.classList.toggle('active', b.dataset.mode === d.mode)\n })\n const modeMap = {\n soft: 'Soft \u2014 threshold 3000 chars, last 10 results uncompressed, no AI',\n normal: 'Normal \u2014 threshold 800 chars, last 3 results uncompressed',\n aggressive: 'Aggressive \u2014 threshold 200 chars, last 1 result uncompressed',\n critical: 'Critical \u2014 threshold 50 chars, everything compressed'\n }\n document.getElementById('mode-desc').textContent = modeMap[d.mode] || ''\n\n // Savings breakdown\n const bd = d.breakdown\n if (bd) {\n const fmtC = (n) => n > 0 ? '-' + fmtN(n) : '0'\n document.getElementById('bd-det').textContent = fmtC(bd.deterministic)\n document.getElementById('bd-ai').textContent = fmtC(bd.ai_compression)\n document.getElementById('bd-dedup').textContent = fmtC(bd.read_dedup)\n document.getElementById('bd-sysprompt').textContent = fmtC(bd.system_prompt)\n document.getElementById('bd-overhead').textContent = bd.overhead > 0 ? '+' + fmtN(bd.overhead) : '0'\n document.getElementById('bd-aicalls').textContent = bd.ai_calls > 0 ? bd.ai_calls + ' calls' : '0'\n }\n}\n\n// \u2500\u2500 Projects page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function loadProjects() {\n try {\n const r = await fetch('/squeezr/projects')\n const { projects } = await r.json()\n const tbody = document.getElementById('projects-body')\n const entries = Object.entries(projects).sort((a, b) => b[1].savedTokens - a[1].savedTokens)\n if (entries.length === 0) {\n tbody.innerHTML = '<tr><td colspan=\"5\" style=\"color:var(--muted);padding:20px 8px;text-align:center\">No project data yet \u2014 start making requests.</td></tr>'\n return\n }\n tbody.innerHTML = entries.map(([name, p]) => `<tr>\n <td><span class=\"project-dot\" style=\"background:${projectColor(name)}\"></span><code>${name}</code></td>\n <td class=\"td-right\" style=\"color:var(--muted)\">${p.sessions}</td>\n <td class=\"td-right\">${p.requests}</td>\n <td class=\"td-right\" style=\"color:var(--green)\">${fmtN(p.savedTokens)}</td>\n <td class=\"td-right\" style=\"color:var(--muted);font-size:11px\">${p.lastSeen ? fmtTs(p.lastSeen) : '\u2014'}</td>\n </tr>`).join('')\n } catch {\n document.getElementById('projects-body').innerHTML = '<tr><td colspan=\"5\" style=\"color:var(--muted);padding:20px 8px;text-align:center\">Failed to load projects.</td></tr>'\n }\n}\n\n// \u2500\u2500 History page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet histData = null\nlet selectedHistProj = '__all__'\n\nasync function loadHistory() {\n try {\n const r = await fetch('/squeezr/history')\n histData = await r.json()\n renderHistProjects()\n renderHistSessions()\n } catch {\n document.getElementById('hist-proj-list').innerHTML = '<div class=\"empty-msg\">Failed to load history.</div>'\n }\n}\n\nfunction renderHistProjects() {\n if (!histData) return\n const all = [...histData.sessions]\n if (histData.current && histData.current.requests > 0) {\n const idx = all.findIndex(s => s.id === histData.current.id)\n if (idx >= 0) all[idx] = histData.current; else all.push(histData.current)\n }\n\n // Group by project\n const byProj = {}\n for (const s of all) {\n if (!byProj[s.project]) byProj[s.project] = 0\n byProj[s.project]++\n }\n\n const list = document.getElementById('hist-proj-list')\n let html = `<div class=\"hist-proj-item${selectedHistProj === '__all__' ? ' active' : ''}\" data-proj=\"__all__\">\n <span>All projects</span>\n <span class=\"hist-proj-count\">${all.length}</span>\n </div>`\n for (const [name, cnt] of Object.entries(byProj).sort((a, b) => b[1] - a[1])) {\n const active = selectedHistProj === name ? ' active' : ''\n html += `<div class=\"hist-proj-item${active}\" data-proj=\"${name}\">\n <span><span class=\"project-dot\" style=\"background:${projectColor(name)}\"></span>${name}</span>\n <span class=\"hist-proj-count\">${cnt}</span>\n </div>`\n }\n list.innerHTML = html\n\n list.querySelectorAll('.hist-proj-item').forEach(el => {\n el.addEventListener('click', () => {\n selectedHistProj = el.dataset.proj\n list.querySelectorAll('.hist-proj-item').forEach(x => x.classList.remove('active'))\n el.classList.add('active')\n renderHistSessions()\n })\n })\n}\n\nfunction renderHistSessions() {\n if (!histData) return\n let sessions = [...histData.sessions]\n if (histData.current && histData.current.requests > 0) {\n const idx = sessions.findIndex(s => s.id === histData.current.id)\n if (idx >= 0) sessions[idx] = histData.current; else sessions.push(histData.current)\n }\n // Filter empty sessions and sort newest first\n sessions = sessions.filter(s => s.requests > 0)\n sessions.sort((a, b) => b.startTime - a.startTime)\n\n if (selectedHistProj !== '__all__') {\n sessions = sessions.filter(s => s.project === selectedHistProj)\n }\n\n const header = document.getElementById('hist-sessions-header')\n header.textContent = selectedHistProj === '__all__'\n ? `All sessions (${sessions.length})`\n : `${selectedHistProj} \u2014 ${sessions.length} session${sessions.length !== 1 ? 's' : ''}`\n\n const list = document.getElementById('hist-sessions-list')\n if (sessions.length === 0) {\n list.innerHTML = '<div class=\"empty-msg\">No sessions found.</div>'\n return\n }\n\n // Group by day\n const byDay = {}\n for (const s of sessions) {\n const day = new Date(s.startTime).toLocaleDateString('en-US',{weekday:'short',month:'short',day:'numeric',year:'numeric'})\n if (!byDay[day]) byDay[day] = []\n byDay[day].push(s)\n }\n\n let html = ''\n for (const [day, daySessions] of Object.entries(byDay)) {\n html += `<div style=\"padding:8px 16px;font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.4px;background:var(--bg3);border-bottom:1px solid var(--border)\">${day}</div>`\n for (const s of daySessions) {\n const isCurrent = s.id === histData.current?.id\n const projBadge = selectedHistProj === '__all__' ? `<span class=\"session-project-badge\">${s.project}</span>` : ''\n html += `<div class=\"session-card\">\n <div class=\"session-date\">\n ${fmtTime(s.startTime)} \u2192 ${fmtTime(s.endTime)}\n <span style=\"color:var(--muted);font-weight:400\"> (${fmtDur(s.startTime, s.endTime)})</span>\n <span style=\"color:var(--muted);font-weight:400;margin-left:6px\">${timeAgo(s.endTime)}</span>\n ${isCurrent ? '<span style=\"font-size:10px;color:var(--green);margin-left:8px\">\u25CF active</span>' : ''}\n ${projBadge}\n </div>\n <div class=\"session-stats\">\n <div class=\"session-stat\">Requests: <span>${s.requests}</span></div>\n <div class=\"session-stat\">Tokens saved: <span style=\"color:var(--green)\">${fmtN(s.savedTokens)}</span></div>\n <div class=\"session-stat\">Compressions: <span>${s.compressions}</span></div>\n </div>\n </div>`\n }\n }\n list.innerHTML = html\n}\n\n// \u2500\u2500 Limits page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet limitsCountdownTimer = null\n\nfunction fmtTokens(n) {\n if (!n && n !== 0) return '\u2014'\n if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'\n if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'\n return String(n)\n}\n\nfunction gaugeColor(pct) {\n if (pct >= 90) return 'var(--red)'\n if (pct >= 70) return 'var(--yellow)'\n if (pct >= 40) return 'var(--orange)'\n return 'var(--green)'\n}\n\nfunction fillGauge(fillId, pctId, remId, resetId, remaining, limit, resetEpoch) {\n if (!limit) {\n document.getElementById(fillId).style.width = '0%'\n document.getElementById(pctId).textContent = '\u2014'\n document.getElementById(remId).textContent = '\u2014'\n if (resetId) document.getElementById(resetId).textContent = ''\n return\n }\n const used = limit - remaining\n const pct = Math.max(0, Math.min(100, Math.round((used / limit) * 100)))\n const fill = document.getElementById(fillId)\n fill.style.width = pct + '%'\n fill.style.background = gaugeColor(pct)\n document.getElementById(pctId).textContent = pct + '% used'\n document.getElementById(pctId).style.color = gaugeColor(pct)\n document.getElementById(remId).textContent = fmtTokens(remaining) + ' remaining'\n if (resetId && resetEpoch) {\n const secs = Math.max(0, Math.round((resetEpoch - Date.now()) / 1000))\n document.getElementById(resetId).textContent = secs > 0 ? 'resets in ' + secs + 's' : 'resetting\u2026'\n }\n}\n\nfunction renderLimits(d) {\n if (!d) return\n const { anthropic, openai, gemini } = d\n\n // \u2500\u2500 Anthropic \u2500\u2500\n const arl = anthropic?.rl\n const au = anthropic?.usage\n const antHasUsage = au && (au.inputSession > 0 || au.outputSession > 0)\n if (arl?.hasData) {\n document.getElementById('ant-badge').className = 'limits-cli-badge live'\n document.getElementById('ant-badge').textContent = 'live'\n fillGauge('ant-tok-fill','ant-tok-pct','ant-tok-rem','ant-tok-reset', arl.tokensRemaining, arl.tokensLimit, arl.tokensResetEpoch)\n fillGauge('ant-req-fill','ant-req-pct','ant-req-rem','ant-req-reset', arl.requestsRemaining, arl.requestsLimit, arl.requestsResetEpoch)\n fillGauge('ant-inp-fill','ant-inp-pct','ant-inp-rem','ant-inp-reset', arl.inputTokensRemaining, arl.inputTokensLimit, arl.tokensResetEpoch)\n fillGauge('ant-out-fill','ant-out-pct','ant-out-rem','ant-out-reset', arl.outputTokensRemaining, arl.outputTokensLimit, arl.tokensResetEpoch)\n } else if (antHasUsage) {\n // Subscription/OAuth: no rate limit headers, but usage is tracked\n document.getElementById('ant-badge').className = 'limits-cli-badge live'\n document.getElementById('ant-badge').textContent = 'subscription'\n // Show session totals in gauge areas since we don't have rate limits\n document.getElementById('ant-tok-pct').textContent = fmtTokens(au.inputSession + au.outputSession)\n document.getElementById('ant-tok-rem').textContent = 'session total'\n document.getElementById('ant-req-pct').textContent = au.requestsSession + ' reqs'\n document.getElementById('ant-req-rem').textContent = 'session total'\n document.getElementById('ant-inp-pct').textContent = fmtTokens(au.inputSession)\n document.getElementById('ant-inp-rem').textContent = 'session input'\n document.getElementById('ant-out-pct').textContent = fmtTokens(au.outputSession)\n document.getElementById('ant-out-rem').textContent = 'session output'\n }\n if (au) {\n document.getElementById('ant-u-inp-s').textContent = fmtTokens(au.inputSession)\n document.getElementById('ant-u-out-s').textContent = fmtTokens(au.outputSession)\n document.getElementById('ant-u-inp-d').textContent = fmtTokens(au.inputToday)\n document.getElementById('ant-u-out-d').textContent = fmtTokens(au.outputToday)\n }\n\n // \u2500\u2500 OpenAI \u2500\u2500\n const orl = openai?.rl\n const ou = openai?.usage\n const oaiHasUsage = ou && (ou.inputSession > 0 || ou.outputSession > 0)\n if (orl?.hasData) {\n document.getElementById('oai-badge').className = 'limits-cli-badge live'\n document.getElementById('oai-badge').textContent = 'live'\n fillGauge('oai-tok-fill','oai-tok-pct','oai-tok-rem','oai-tok-reset', orl.tokensRemaining, orl.tokensLimit, orl.tokensResetEpoch)\n fillGauge('oai-req-fill','oai-req-pct','oai-req-rem','oai-req-reset', orl.requestsRemaining, orl.requestsLimit, orl.requestsResetEpoch)\n } else if (oaiHasUsage) {\n document.getElementById('oai-badge').className = 'limits-cli-badge live'\n document.getElementById('oai-badge').textContent = 'tracking'\n document.getElementById('oai-tok-pct').textContent = fmtTokens(ou.inputSession + ou.outputSession)\n document.getElementById('oai-tok-rem').textContent = 'session total'\n document.getElementById('oai-req-pct').textContent = ou.requestsSession + ' reqs'\n document.getElementById('oai-req-rem').textContent = 'session total'\n }\n const ob = openai?.billing\n if (ob?.hardLimitUsd > 0) {\n document.getElementById('oai-billing-row').style.display = 'flex'\n document.getElementById('oai-credits').textContent = '$' + (ob.creditBalanceUsd || 0).toFixed(2)\n document.getElementById('oai-hard-lim').textContent = '$' + ob.hardLimitUsd.toFixed(2)\n }\n if (ou) {\n document.getElementById('oai-u-inp-s').textContent = fmtTokens(ou.inputSession)\n document.getElementById('oai-u-out-s').textContent = fmtTokens(ou.outputSession)\n document.getElementById('oai-u-inp-d').textContent = fmtTokens(ou.inputToday)\n document.getElementById('oai-u-out-d').textContent = fmtTokens(ou.outputToday)\n }\n\n // \u2500\u2500 Gemini \u2500\u2500\n const ge = gemini?.errors\n const gu = gemini?.usage\n const gemHasUsage = gu && (gu.inputSession > 0 || gu.outputSession > 0)\n if (ge?.hasData) {\n document.getElementById('gem-nodata').style.display = 'none'\n document.getElementById('gem-data').style.display = 'block'\n document.getElementById('gem-tok-lim').textContent = fmtTokens(gemini.rl?.tokensLimit)\n document.getElementById('gem-errors').textContent = ge.errorCount429 + ' rate-limit errors'\n document.getElementById('gem-badge').className = 'limits-cli-badge error'\n document.getElementById('gem-badge').textContent = ge.errorCount429 + ' 429 errors'\n } else if (gemHasUsage) {\n document.getElementById('gem-nodata').style.display = 'none'\n document.getElementById('gem-data').style.display = 'block'\n document.getElementById('gem-badge').className = 'limits-cli-badge live'\n document.getElementById('gem-badge').textContent = 'tracking'\n }\n if (gu) {\n document.getElementById('gem-u-inp-s').textContent = fmtTokens(gu.inputSession)\n document.getElementById('gem-u-out-s').textContent = fmtTokens(gu.outputSession)\n document.getElementById('gem-u-inp-d').textContent = fmtTokens(gu.inputToday)\n document.getElementById('gem-u-out-d').textContent = fmtTokens(gu.outputToday)\n }\n\n // \u2500\u2500 Budget \u2500\u2500\n updateBudgetBar(au, ou, gu)\n}\n\n// Countdown ticker \u2014 updates reset countdowns every second without SSE\nfunction startLimitsCountdown(limitsData) {\n if (limitsCountdownTimer) clearInterval(limitsCountdownTimer)\n limitsCountdownTimer = setInterval(() => {\n const updateReset = (id, resetEpoch) => {\n if (!resetEpoch) return\n const el = document.getElementById(id)\n if (!el) return\n const secs = Math.max(0, Math.round((resetEpoch - Date.now()) / 1000))\n el.textContent = secs > 0 ? 'resets in ' + secs + 's' : 'resetting\u2026'\n }\n const d = limitsData\n if (d?.anthropic?.rl?.hasData) {\n updateReset('ant-tok-reset', d.anthropic.rl.tokensResetEpoch)\n updateReset('ant-req-reset', d.anthropic.rl.requestsResetEpoch)\n updateReset('ant-inp-reset', d.anthropic.rl.tokensResetEpoch)\n updateReset('ant-out-reset', d.anthropic.rl.tokensResetEpoch)\n }\n if (d?.openai?.rl?.hasData) {\n updateReset('oai-tok-reset', d.openai.rl.tokensResetEpoch)\n updateReset('oai-req-reset', d.openai.rl.requestsResetEpoch)\n }\n }, 1000)\n}\n\n// \u2500\u2500 Budget logic \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet dailyBudget = parseInt(localStorage.getItem('squeezr_budget') || '0')\n\nfunction updateBudgetBar(au, ou, gu) {\n const budget = dailyBudget\n const budgetInput = document.getElementById('budget-input')\n if (budgetInput && !budgetInput.value) budgetInput.value = budget || ''\n\n const wrap = document.getElementById('budget-bar-wrap')\n if (!budget) { wrap.style.display = 'none'; return }\n wrap.style.display = 'block'\n\n const totalToday = ((au?.inputToday || 0) + (au?.outputToday || 0) +\n (ou?.inputToday || 0) + (ou?.outputToday || 0) +\n (gu?.inputToday || 0) + (gu?.outputToday || 0))\n const pct = Math.min(100, Math.round((totalToday / budget) * 100))\n const fill = document.getElementById('budget-bar')\n fill.style.width = pct + '%'\n fill.style.background = gaugeColor(pct)\n document.getElementById('budget-pct-label').textContent = pct + '%'\n document.getElementById('budget-pct-label').style.color = gaugeColor(pct)\n document.getElementById('budget-used-label').textContent = fmtTokens(totalToday) + ' used today'\n document.getElementById('budget-limit-label').textContent = 'of ' + fmtTokens(budget) + ' / day'\n}\n\ndocument.getElementById('budget-save').addEventListener('click', () => {\n const val = parseInt(document.getElementById('budget-input').value || '0')\n dailyBudget = val\n localStorage.setItem('squeezr_budget', String(val))\n document.getElementById('budget-save').textContent = '\u2713 Saved'\n setTimeout(() => document.getElementById('budget-save').textContent = 'Save', 2000)\n // Re-render budget bar with latest limits data\n if (lastLimitsData) {\n const u = lastLimitsData.usage\n updateBudgetBar(u?.anthropic, u?.openai, u?.gemini)\n }\n})\n\n// Restore budget from localStorage on load\nconst savedBudget = localStorage.getItem('squeezr_budget')\nif (savedBudget) document.getElementById('budget-input').value = savedBudget\n\n// \u2500\u2500 Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst pageTitles = { overview: 'Overview', projects: 'Projects', history: 'History', limits: 'Limits', settings: 'Settings' }\n\ndocument.querySelectorAll('.nav-item').forEach(item => {\n item.addEventListener('click', () => {\n const page = item.dataset.page\n document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'))\n document.querySelectorAll('.page').forEach(p => p.classList.remove('active'))\n item.classList.add('active')\n document.getElementById('page-' + page).classList.add('active')\n document.getElementById('page-title').textContent = pageTitles[page] || page\n if (page === 'projects') loadProjects()\n if (page === 'history') loadHistory()\n if (page === 'limits') {\n if (lastLimitsData) {\n renderLimits(lastLimitsData)\n startLimitsCountdown(lastLimitsData)\n }\n }\n if (page !== 'limits' && limitsCountdownTimer) {\n clearInterval(limitsCountdownTimer)\n limitsCountdownTimer = null\n }\n })\n})\n\n// \u2500\u2500 Mode selector \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\ndocument.querySelectorAll('.mode-btn[data-mode]').forEach(btn => {\n btn.addEventListener('click', async () => {\n const mode = btn.dataset.mode\n if (!mode) return\n const prevActive = document.querySelector('.mode-btn.active')\n document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'))\n btn.classList.add('active')\n try {\n const res = await fetch('/squeezr/config', {\n method: 'POST',\n headers: {'content-type':'application/json'},\n body: JSON.stringify({ mode })\n })\n if (!res.ok) throw new Error('HTTP ' + res.status)\n } catch(e) {\n // Revert to previous mode on failure\n btn.classList.remove('active')\n if (prevActive) prevActive.classList.add('active')\n console.error('mode update failed', e)\n }\n })\n})\n\n// \u2500\u2500 SSE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst dot = document.getElementById('status-dot')\nconst statusText = document.getElementById('status-text')\nconst connPill = document.getElementById('conn-pill')\nconst connStatus = document.getElementById('conn-status')\nlet lastLimitsData = null\n\nfunction connect() {\n const es = new EventSource('/squeezr/events')\n es.onmessage = e => {\n try {\n const d = JSON.parse(e.data)\n renderOverview(d)\n if (d.limits) {\n lastLimitsData = d.limits\n // Only render limits page if it's currently visible\n const limPage = document.getElementById('page-limits')\n if (limPage && limPage.classList.contains('active')) {\n renderLimits(d.limits)\n if (!limitsCountdownTimer) startLimitsCountdown(d.limits)\n else { /* update the data reference for the countdown */ lastLimitsData = d.limits }\n }\n }\n } catch(err) { console.error(err) }\n }\n es.onopen = () => {\n dot.classList.remove('off')\n statusText.textContent = 'Running'\n connPill.className = ''\n connPill.textContent = '\u25CF live'\n connStatus.style.color = 'var(--green)'\n connStatus.textContent = '\u25CF connected'\n }\n es.onerror = () => {\n dot.classList.add('off')\n statusText.textContent = 'Reconnecting\u2026'\n connPill.className = 'err'\n connPill.textContent = '\u25CF offline'\n connStatus.style.color = 'var(--red)'\n connStatus.textContent = '\u25CF reconnecting\u2026'\n es.close()\n setTimeout(connect, 3000)\n }\n}\nconnect()\n</script>\n</body>\n</html>";
6
+ export declare const DASHBOARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Squeezr Dashboard</title>\n<style>\n*{box-sizing:border-box;margin:0;padding:0}\n:root{\n --bg:#0d1117;--bg2:#161b22;--bg3:#21262d;--bg4:#2d333b;\n --border:#30363d;--text:#e6edf3;--muted:#8b949e;\n --green:#3fb950;--yellow:#d29922;--red:#f85149;\n --blue:#58a6ff;--purple:#bc8cff;--orange:#ffa657;--accent:#238636\n}\nhtml,body{height:100%;background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,-apple-system,sans-serif;font-size:13px;line-height:1.5}\na{color:var(--blue);text-decoration:none}\ncode{font-family:'Cascadia Code','Fira Mono','Consolas',monospace}\n\n/* \u2500\u2500 App shell \u2500\u2500 */\n#app{display:flex;height:100vh;overflow:hidden}\n\n/* \u2500\u2500 Sidebar \u2500\u2500 */\n#sidebar{\n width:200px;flex-shrink:0;background:var(--bg2);\n border-right:1px solid var(--border);\n display:flex;flex-direction:column;overflow:hidden\n}\n#sidebar-brand{padding:16px 16px 12px;border-bottom:1px solid var(--border)}\n#sidebar-brand .logo{font-size:18px;font-weight:700;letter-spacing:.3px;line-height:1}\n#sidebar-brand .logo span{color:var(--blue)}\n#sidebar-brand .ver{font-size:11px;color:var(--muted);margin-top:3px}\n\nnav{flex:1;padding:8px 0;overflow-y:auto}\n.nav-item{\n display:flex;align-items:center;gap:9px;padding:8px 16px;\n color:var(--muted);cursor:pointer;border-radius:0;\n transition:background .1s,color .1s;user-select:none\n}\n.nav-item:hover{background:var(--bg3);color:var(--text)}\n.nav-item.active{background:var(--bg3);color:var(--blue)}\n.nav-item svg{flex-shrink:0;opacity:.8}\n.nav-item.active svg{opacity:1}\n.nav-label{font-size:13px}\n\n#sidebar-footer{padding:12px 16px;border-top:1px solid var(--border)}\n.status-row{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted)}\n.dot{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 5px var(--green);flex-shrink:0}\n.dot.off{background:var(--red);box-shadow:0 0 5px var(--red)}\n#uptime-small{font-size:11px;color:var(--muted);margin-top:4px}\n\n/* \u2500\u2500 Main content \u2500\u2500 */\n#content{flex:1;display:flex;flex-direction:column;overflow:hidden}\n#page-header{\n display:flex;align-items:center;gap:10px;padding:12px 20px;\n background:var(--bg2);border-bottom:1px solid var(--border);flex-shrink:0\n}\n#page-title{font-size:15px;font-weight:600}\n#project-badge{\n font-size:11px;background:var(--bg3);border:1px solid var(--border);\n border-radius:12px;padding:2px 10px;color:var(--blue);font-weight:500\n}\n#header-uptime{font-size:11px;color:var(--muted);margin-left:auto}\n#conn-pill{\n font-size:11px;padding:2px 8px;border-radius:10px;\n background:rgba(63,185,80,.15);color:var(--green);border:1px solid rgba(63,185,80,.3)\n}\n#conn-pill.err{background:rgba(248,81,73,.15);color:var(--red);border-color:rgba(248,81,73,.3)}\n\n#pages{flex:1;overflow-y:auto;padding:16px 20px}\n.page{display:none}\n.page.active{display:block}\n\n/* \u2500\u2500 Cards \u2500\u2500 */\n.cards-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(175px,1fr));gap:10px;margin-bottom:14px}\n.card{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px 16px}\n.card-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}\n.card-value{font-size:26px;font-weight:700;line-height:1.1}\n.card-sub{font-size:11px;color:var(--muted);margin-top:3px}\n.c-green .card-value{color:var(--green)}\n.c-blue .card-value{color:var(--blue)}\n.c-yellow .card-value{color:var(--yellow)}\n.c-orange .card-value{color:var(--orange)}\n\n/* \u2500\u2500 Sections \u2500\u2500 */\n.section{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:14px}\n.section-title{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;font-weight:600}\n\n/* \u2500\u2500 Bars \u2500\u2500 */\n.bar-row{display:flex;align-items:center;gap:8px;margin-bottom:7px}\n.bar-label{font-size:12px;color:var(--muted);width:130px;flex-shrink:0}\n.bar-track{flex:1;height:7px;background:var(--bg3);border-radius:4px;overflow:hidden}\n.bar-fill{height:100%;border-radius:4px;transition:width .4s,background .4s}\n.bar-val{font-size:11px;width:36px;text-align:right;flex-shrink:0;color:var(--muted)}\n\n/* \u2500\u2500 Sparkline \u2500\u2500 */\ncanvas#sparkline{width:100%;height:72px;display:block}\n\n/* \u2500\u2500 Tables \u2500\u2500 */\ntable{width:100%;border-collapse:collapse}\nth{font-size:11px;color:var(--muted);text-align:left;padding:4px 8px;border-bottom:1px solid var(--border);font-weight:500;letter-spacing:.3px;text-transform:uppercase}\ntd{padding:6px 8px;font-size:12px;border-bottom:1px solid var(--border)}\ntr:last-child td{border-bottom:none}\n.td-right{text-align:right;font-variant-numeric:tabular-nums}\n.mini-bar{display:inline-block;height:5px;border-radius:2px;vertical-align:middle;margin-right:5px;opacity:.75}\n.tag{display:inline-block;background:var(--bg3);border:1px solid var(--border);border-radius:3px;padding:1px 6px;font-size:11px;font-family:monospace}\n\n/* \u2500\u2500 Cache row \u2500\u2500 */\n.cache-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px}\n.cache-card{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:10px 14px}\n.cache-card .cache-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px}\n.cache-card .cache-val{font-size:18px;font-weight:600;color:var(--purple)}\n\n/* \u2500\u2500 Mode buttons \u2500\u2500 */\n.mode-btns{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px}\n.mode-btn{\n display:flex;align-items:center;gap:6px;padding:6px 14px;\n border-radius:6px;border:1px solid var(--border);background:var(--bg3);\n color:var(--muted);cursor:pointer;font-size:12px;transition:all .15s\n}\n.mode-btn:hover{border-color:var(--blue);color:var(--text)}\n.mode-btn.active{border-color:var(--accent);background:var(--accent);color:#fff}\n.mode-btn.active svg{stroke:white}\n#mode-desc{font-size:12px;color:var(--muted);min-height:16px}\n\n/* \u2500\u2500 Projects page \u2500\u2500 */\n.project-table td:first-child code{font-size:12px}\n.project-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}\n\n/* \u2500\u2500 History page \u2500\u2500 */\n#hist-layout{display:grid;grid-template-columns:220px 1fr;gap:12px;min-height:400px}\n#hist-projects{background:var(--bg2);border:1px solid var(--border);border-radius:8px;overflow:hidden}\n#hist-sessions{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:0}\n.hist-proj-item{\n padding:9px 14px;cursor:pointer;border-bottom:1px solid var(--border);\n display:flex;justify-content:space-between;align-items:center;\n font-size:12px;color:var(--muted);transition:background .1s\n}\n.hist-proj-item:last-child{border-bottom:none}\n.hist-proj-item:hover{background:var(--bg3)}\n.hist-proj-item.active{background:var(--bg3);color:var(--blue)}\n.hist-proj-count{font-size:11px;background:var(--bg4);border-radius:10px;padding:1px 7px}\n.hist-sessions-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;font-weight:600}\n.session-card{padding:12px 16px;border-bottom:1px solid var(--border)}\n.session-card:last-child{border-bottom:none}\n.session-date{font-size:12px;font-weight:600;color:var(--text);margin-bottom:4px}\n.session-time{font-size:11px;color:var(--muted);margin-bottom:6px}\n.session-stats{display:flex;gap:14px;flex-wrap:wrap}\n.session-stat{font-size:11px;color:var(--muted)}\n.session-stat span{color:var(--text);font-weight:500}\n.session-project-badge{font-size:10px;background:var(--bg4);border:1px solid var(--border);border-radius:10px;padding:1px 8px;color:var(--blue);margin-left:6px}\n.empty-msg{padding:32px 16px;text-align:center;color:var(--muted);font-size:12px}\n\n/* \u2500\u2500 Settings \u2500\u2500 */\n.config-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border);font-size:12px}\n.config-row:last-child{border-bottom:none}\n.config-key{color:var(--muted)}\n.config-val{font-family:monospace;color:var(--text)}\n\n/* \u2500\u2500 Limits page \u2500\u2500 */\n.limits-cli-section{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:14px}\n.limits-cli-header{display:flex;align-items:center;gap:8px;margin-bottom:12px}\n.limits-cli-name{font-size:13px;font-weight:600;color:var(--text)}\n.limits-cli-badge{font-size:10px;padding:1px 7px;border-radius:10px;border:1px solid;margin-left:2px}\n.limits-cli-badge.live{border-color:rgba(63,185,80,.4);color:var(--green);background:rgba(63,185,80,.1)}\n.limits-cli-badge.error{border-color:rgba(248,81,73,.4);color:var(--red);background:rgba(248,81,73,.1)}\n.limits-cli-badge.warn{border-color:rgba(210,153,34,.4);color:var(--yellow);background:rgba(210,153,34,.1)}\n.limits-cli-badge.none{border-color:var(--border);color:var(--muted);background:transparent}\n.limits-gauge-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-bottom:10px}\n.limits-gauge{background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:10px 12px}\n.limits-gauge-label{font-size:11px;color:var(--muted);margin-bottom:6px;display:flex;justify-content:space-between}\n.limits-gauge-bar{height:6px;background:var(--bg4);border-radius:3px;overflow:hidden;margin-bottom:5px}\n.limits-gauge-fill{height:100%;border-radius:3px;transition:width .5s,background .5s}\n.limits-gauge-bottom{display:flex;justify-content:space-between;font-size:11px}\n.limits-gauge-remaining{color:var(--text);font-weight:500}\n.limits-gauge-reset{color:var(--muted)}\n.limits-usage-row{display:flex;gap:16px;flex-wrap:wrap;padding-top:8px;border-top:1px solid var(--border);margin-top:4px}\n.limits-usage-item{font-size:12px;color:var(--muted)}\n.limits-usage-item span{color:var(--text);font-weight:500}\n.limits-no-data{padding:16px;text-align:center;color:var(--muted);font-size:12px}\n.limits-billing-row{display:flex;gap:10px;flex-wrap:wrap;padding:8px 0 2px}\n.limits-credit-card{flex:1;min-width:120px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:10px 12px}\n.limits-credit-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}\n.limits-credit-val{font-size:20px;font-weight:600;color:var(--green)}\n.limits-budget-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px}\n.limits-budget-input{background:var(--bg3);border:1px solid var(--border);border-radius:5px;padding:5px 10px;color:var(--text);font-size:12px;width:140px;outline:none}\n.limits-budget-input:focus{border-color:var(--blue)}\n.limits-budget-label{font-size:12px;color:var(--muted)}\n\n/* \u2500\u2500 Footer bar \u2500\u2500 */\n#footer{padding:7px 20px;border-top:1px solid var(--border);background:var(--bg2);font-size:11px;color:var(--muted);display:flex;gap:16px;flex-shrink:0}\n#footer a{color:var(--muted)}#footer a:hover{color:var(--blue)}\n</style>\n</head>\n<body>\n<div id=\"app\">\n\n<!-- \u2500\u2500 Sidebar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<div id=\"sidebar\">\n <div id=\"sidebar-brand\">\n <div class=\"logo\">Squee<span>zr</span></div>\n <div class=\"ver\" id=\"sb-ver\">v\u2014</div>\n </div>\n\n <nav>\n <div class=\"nav-item active\" data-page=\"overview\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M1 2.5A1.5 1.5 0 012.5 1h3A1.5 1.5 0 017 2.5v3A1.5 1.5 0 015.5 7h-3A1.5 1.5 0 011 5.5v-3zm8 0A1.5 1.5 0 0110.5 1h3A1.5 1.5 0 0115 2.5v3A1.5 1.5 0 0113.5 7h-3A1.5 1.5 0 019 5.5v-3zm-8 8A1.5 1.5 0 012.5 9h3A1.5 1.5 0 017 10.5v3A1.5 1.5 0 015.5 15h-3A1.5 1.5 0 011 13.5v-3zm8 0A1.5 1.5 0 0110.5 9h3a1.5 1.5 0 011.5 1.5v3A1.5 1.5 0 0113.5 15h-3A1.5 1.5 0 019 13.5v-3z\"/>\n </svg>\n <span class=\"nav-label\">Overview</span>\n </div>\n <div class=\"nav-item\" data-page=\"projects\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M9.828 3h3.982a2 2 0 011.992 2.181l-.637 7A2 2 0 0113.174 14H2.826a2 2 0 01-1.991-1.819l-.637-7a1.99 1.99 0 01.342-1.31L.5 3a2 2 0 012-2h3.672a2 2 0 011.414.586l.828.828A2 2 0 009.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 006.172 2H2.5a1 1 0 00-1 .981l.006.139z\"/>\n </svg>\n <span class=\"nav-label\">Projects</span>\n </div>\n <div class=\"nav-item\" data-page=\"history\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M8 3.5a.5.5 0 00-1 0V9a.5.5 0 00.252.434l3.5 2a.5.5 0 00.496-.868L8 8.71V3.5z\"/>\n <path d=\"M8 16A8 8 0 108 0a8 8 0 000 16zm7-8A7 7 0 111 8a7 7 0 0114 0z\"/>\n </svg>\n <span class=\"nav-label\">History</span>\n </div>\n <div class=\"nav-item\" data-page=\"limits\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <line x1=\"18\" y1=\"20\" x2=\"18\" y2=\"10\"/>\n <line x1=\"12\" y1=\"20\" x2=\"12\" y2=\"4\"/>\n <line x1=\"6\" y1=\"20\" x2=\"6\" y2=\"14\"/>\n <line x1=\"2\" y1=\"20\" x2=\"22\" y2=\"20\"/>\n </svg>\n <span class=\"nav-label\">Limits</span>\n </div>\n <div class=\"nav-item\" data-page=\"settings\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n <path d=\"M8 4.754a3.246 3.246 0 100 6.492 3.246 3.246 0 000-6.492zM5.754 8a2.246 2.246 0 114.492 0 2.246 2.246 0 01-4.492 0z\"/>\n <path d=\"M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 01-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 01-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 01.52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 011.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 011.255-.52l.292.16c1.64.892 3.433-.902 2.54-2.541l-.159-.292a.873.873 0 01.52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 01-.52-1.255l.16-.292c.892-1.64-.901-3.433-2.541-2.54l-.292.159a.873.873 0 01-1.255-.52l-.094-.319z\"/>\n </svg>\n <span class=\"nav-label\">Settings</span>\n </div>\n </nav>\n\n <div id=\"sidebar-footer\">\n <div class=\"status-row\">\n <div class=\"dot\" id=\"status-dot\"></div>\n <span id=\"status-text\">Connecting\u2026</span>\n </div>\n <div id=\"uptime-small\"></div>\n </div>\n</div>\n\n<!-- \u2500\u2500 Main content \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<div id=\"content\">\n <div id=\"page-header\">\n <span id=\"page-title\">Overview</span>\n <span id=\"project-badge\" style=\"display:none\"></span>\n <span id=\"header-uptime\"></span>\n <span id=\"conn-pill\">\u25CF live</span>\n </div>\n\n <div id=\"pages\">\n\n <!-- \u2500\u2500\u2500 Overview \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page active\" id=\"page-overview\">\n <div class=\"cards-grid\">\n <div class=\"card c-green\">\n <div class=\"card-label\">Tokens Saved</div>\n <div class=\"card-value\" id=\"c-tokens\">\u2014</div>\n <div class=\"card-sub\" id=\"c-chars\">\u2014 chars</div>\n </div>\n <div class=\"card c-blue\">\n <div class=\"card-label\">Compression</div>\n <div class=\"card-value\" id=\"c-pct\">\u2014</div>\n <div class=\"card-sub\">of tool results</div>\n </div>\n <div class=\"card c-yellow\">\n <div class=\"card-label\">Requests</div>\n <div class=\"card-value\" id=\"c-req\">\u2014</div>\n <div class=\"card-sub\" id=\"c-compressions\">\u2014 compressions</div>\n </div>\n <div class=\"card c-orange\">\n <div class=\"card-label\">Est. Cost Saved</div>\n <div class=\"card-value\" id=\"c-cost\">\u2014</div>\n <div class=\"card-sub\">@ $3 / MTok</div>\n </div>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">Context pressure \u2014 last request</div>\n <div class=\"bar-row\">\n <span class=\"bar-label\">Before compression</span>\n <div class=\"bar-track\"><div class=\"bar-fill\" id=\"bar-msg\" style=\"width:0%\"></div></div>\n <span class=\"bar-val\" id=\"pct-msg\">0%</span>\n </div>\n <div class=\"bar-row\">\n <span class=\"bar-label\">After compression</span>\n <div class=\"bar-track\"><div class=\"bar-fill\" id=\"bar-out\" style=\"width:0%\"></div></div>\n <span class=\"bar-val\" id=\"pct-out\">0%</span>\n </div>\n <div class=\"bar-row\" style=\"margin-bottom:0\">\n <span class=\"bar-label\">Session cache hits</span>\n <div class=\"bar-track\"><div class=\"bar-fill\" id=\"bar-cache\" style=\"width:0%;background:var(--purple)\"></div></div>\n <span class=\"bar-val\" id=\"pct-cache\">0</span>\n </div>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">Activity \u2014 tokens saved per request <span style=\"font-weight:400;text-transform:none;letter-spacing:0\">(last 60)</span></div>\n <canvas id=\"sparkline\"></canvas>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">By tool</div>\n <table>\n <thead>\n <tr>\n <th>Tool</th>\n <th class=\"td-right\">Calls</th>\n <th class=\"td-right\">Tokens saved</th>\n <th>Savings</th>\n </tr>\n </thead>\n <tbody id=\"tools-body\">\n <tr><td colspan=\"4\" style=\"color:var(--muted);padding:14px 8px;text-align:center\">No data yet\u2026</td></tr>\n </tbody>\n </table>\n </div>\n\n <div class=\"cache-row\">\n <div class=\"cache-card\">\n <div class=\"cache-label\">Session cache</div>\n <div class=\"cache-val\" id=\"c-scache\">\u2014</div>\n </div>\n <div class=\"cache-card\">\n <div class=\"cache-label\">Expand store</div>\n <div class=\"cache-val\" id=\"c-expand\">\u2014</div>\n </div>\n <div class=\"cache-card\">\n <div class=\"cache-label\">LRU cache</div>\n <div class=\"cache-val\" id=\"c-lru\">\u2014</div>\n </div>\n <div class=\"cache-card\">\n <div class=\"cache-label\">Pattern hits</div>\n <div class=\"cache-val\" id=\"c-patterns\">\u2014</div>\n </div>\n </div>\n\n <!-- Savings breakdown -->\n <div class=\"section\">\n <div class=\"section-title\">Savings Breakdown</div>\n <div class=\"cache-grid\" style=\"grid-template-columns:1fr 1fr 1fr\">\n <div class=\"cache-item\">\n <div class=\"cache-label\">Deterministic</div>\n <div class=\"cache-val\" id=\"bd-det\" style=\"color:var(--green)\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">AI compression</div>\n <div class=\"cache-val\" id=\"bd-ai\" style=\"color:var(--blue)\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">Read dedup</div>\n <div class=\"cache-val\" id=\"bd-dedup\" style=\"color:var(--purple)\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">System prompt</div>\n <div class=\"cache-val\" id=\"bd-sysprompt\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">Tag overhead</div>\n <div class=\"cache-val\" id=\"bd-overhead\" style=\"color:var(--muted)\">\u2014</div>\n </div>\n <div class=\"cache-item\">\n <div class=\"cache-label\">AI calls</div>\n <div class=\"cache-val\" id=\"bd-aicalls\" style=\"color:var(--muted)\">\u2014</div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 Projects \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-projects\">\n <div class=\"section\" style=\"margin-bottom:0\">\n <div class=\"section-title\" id=\"projects-section-title\">All projects \u2014 this session + history</div>\n <table class=\"project-table\">\n <thead>\n <tr>\n <th>Project</th>\n <th class=\"td-right\">Sessions</th>\n <th class=\"td-right\">Requests</th>\n <th class=\"td-right\">Tokens saved</th>\n <th class=\"td-right\">Last seen</th>\n </tr>\n </thead>\n <tbody id=\"projects-body\">\n <tr><td colspan=\"5\" style=\"color:var(--muted);padding:20px 8px;text-align:center\">Loading\u2026</td></tr>\n </tbody>\n </table>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 History \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-history\">\n <div id=\"hist-layout\">\n <div id=\"hist-projects\">\n <div style=\"padding:10px 14px;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid var(--border)\">Projects</div>\n <div id=\"hist-proj-list\"></div>\n </div>\n <div id=\"hist-sessions\">\n <div class=\"hist-sessions-header\" id=\"hist-sessions-header\">Select a project</div>\n <div id=\"hist-sessions-list\"><div class=\"empty-msg\">Select a project on the left to view sessions.</div></div>\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 Limits \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-limits\">\n\n <!-- Anthropic -->\n <div class=\"limits-cli-section\" id=\"lim-anthropic\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=\"color:var(--orange)\">\n <path d=\"M13.83 2.34a2.09 2.09 0 0 0-3.66 0L1.13 18.9A2.09 2.09 0 0 0 2.96 22h18.08a2.09 2.09 0 0 0 1.83-3.1L13.83 2.34ZM12 8a1 1 0 0 1 1 1v5a1 1 0 0 1-2 0V9a1 1 0 0 1 1-1Zm0 10a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z\"/>\n </svg>\n <span class=\"limits-cli-name\">Anthropic \u00B7 Claude Code</span>\n <span class=\"limits-cli-badge none\" id=\"ant-badge\">no data yet</span>\n </div>\n <div class=\"limits-gauge-grid\">\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span id=\"ant-tok-label\">Tokens / minute</span>\n <span id=\"ant-tok-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-tok-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-tok-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-tok-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span id=\"ant-req-label\">Requests / minute</span>\n <span id=\"ant-req-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-req-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-req-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-req-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span id=\"ant-inp-label\">Input tokens / minute</span>\n <span id=\"ant-inp-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-inp-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-inp-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-inp-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span id=\"ant-out-label\">Output tokens / minute</span>\n <span id=\"ant-out-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"ant-out-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"ant-out-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"ant-out-reset\"></span>\n </div>\n </div>\n </div>\n <div class=\"limits-usage-row\">\n <div class=\"limits-usage-item\">Session input: <span id=\"ant-u-inp-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Session output: <span id=\"ant-u-out-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today input: <span id=\"ant-u-inp-d\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today output: <span id=\"ant-u-out-d\">\u2014</span></div>\n <div class=\"limits-usage-item\" style=\"margin-left:auto\">\n <a href=\"https://console.anthropic.com/settings/usage\" target=\"_blank\" style=\"color:var(--muted);font-size:11px\">View billing \u2197</a>\n </div>\n </div>\n </div>\n\n <!-- OpenAI -->\n <div class=\"limits-cli-section\" id=\"lim-openai\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=\"color:var(--text)\">\n <path d=\"M22.28 9.27a6.17 6.17 0 0 0-.53-5.06 6.24 6.24 0 0 0-6.7-2.99A6.23 6.23 0 0 0 10.36 0a6.24 6.24 0 0 0-5.95 4.32 6.23 6.23 0 0 0-4.16 3.02 6.24 6.24 0 0 0 .77 7.32 6.17 6.17 0 0 0 .53 5.06 6.24 6.24 0 0 0 6.7 2.99A6.23 6.23 0 0 0 13.64 24a6.25 6.25 0 0 0 5.96-4.33 6.23 6.23 0 0 0 4.15-3.02 6.24 6.24 0 0 0-.77-7.31l.3-.07ZM13.64 22.5a4.63 4.63 0 0 1-2.97-1.08l.15-.08 4.93-2.85a.82.82 0 0 0 .41-.71v-6.96l2.08 1.2a.08.08 0 0 1 .04.06v5.76a4.65 4.65 0 0 1-4.64 4.66Zm-9.95-4.27a4.63 4.63 0 0 1-.55-3.12l.14.09 4.93 2.85a.82.82 0 0 0 .82 0l6.02-3.47v2.4a.08.08 0 0 1-.03.06L10.06 20a4.65 4.65 0 0 1-6.37-1.77Zm-1.28-10.8a4.63 4.63 0 0 1 2.42-2.04v5.88a.82.82 0 0 0 .41.71l6.01 3.47-2.08 1.2a.08.08 0 0 1-.08 0L4.22 13.7a4.65 4.65 0 0 1-.81-6.27Zm17.09 3.99-6.02-3.48L15.56 7a.08.08 0 0 1 .08 0l4.87 2.81a4.64 4.64 0 0 1-.72 8.38v-5.88a.82.82 0 0 0-.39-.69Zm2.07-3.14-.14-.09-4.92-2.87a.82.82 0 0 0-.83 0L9.67 9.79V7.4a.08.08 0 0 1 .03-.06L14.6 4.5a4.64 4.64 0 0 1 6.9 4.81l.07-.03Zm-13.03 4.28-2.08-1.2a.08.08 0 0 1-.04-.06V5.5a4.64 4.64 0 0 1 7.62-3.56l-.15.08L7.9 4.87a.82.82 0 0 0-.41.71l-.01 6.98Zm1.13-2.43 2.68-1.55 2.68 1.55v3.1l-2.68 1.54-2.68-1.54v-3.1Z\"/>\n </svg>\n <span class=\"limits-cli-name\">OpenAI \u00B7 Codex</span>\n <span class=\"limits-cli-badge none\" id=\"oai-badge\">no data yet</span>\n </div>\n <div class=\"limits-gauge-grid\">\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Tokens / minute</span>\n <span id=\"oai-tok-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"oai-tok-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"oai-tok-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"oai-tok-reset\"></span>\n </div>\n </div>\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\">\n <span>Requests / minute</span>\n <span id=\"oai-req-pct\" style=\"color:var(--muted)\">\u2014</span>\n </div>\n <div class=\"limits-gauge-bar\"><div class=\"limits-gauge-fill\" id=\"oai-req-fill\" style=\"width:0%\"></div></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"oai-req-rem\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"oai-req-reset\"></span>\n </div>\n </div>\n </div>\n <div class=\"limits-billing-row\" id=\"oai-billing-row\" style=\"display:none\">\n <div class=\"limits-credit-card\">\n <div class=\"limits-credit-label\">Credits remaining</div>\n <div class=\"limits-credit-val\" id=\"oai-credits\">\u2014</div>\n </div>\n <div class=\"limits-credit-card\">\n <div class=\"limits-credit-label\">Hard limit</div>\n <div class=\"limits-credit-val\" style=\"color:var(--yellow)\" id=\"oai-hard-lim\">\u2014</div>\n </div>\n </div>\n <div class=\"limits-usage-row\">\n <div class=\"limits-usage-item\">Session input: <span id=\"oai-u-inp-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Session output: <span id=\"oai-u-out-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today input: <span id=\"oai-u-inp-d\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today output: <span id=\"oai-u-out-d\">\u2014</span></div>\n <div class=\"limits-usage-item\" style=\"margin-left:auto\">\n <a href=\"https://platform.openai.com/usage\" target=\"_blank\" style=\"color:var(--muted);font-size:11px\">View billing \u2197</a>\n </div>\n </div>\n </div>\n\n <!-- Gemini -->\n <div class=\"limits-cli-section\" id=\"lim-gemini\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=\"color:var(--blue)\">\n <path d=\"M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0zm0 22C6.49 22 2 17.51 2 12S6.49 2 12 2s10 4.49 10 10-4.49 10-10 10zm-1-14h2v7h-2zm0 9h2v2h-2z\"/>\n </svg>\n <span class=\"limits-cli-name\">Google \u00B7 Gemini CLI</span>\n <span class=\"limits-cli-badge warn\" id=\"gem-badge\">only on 429 errors</span>\n </div>\n <div id=\"gem-nodata\" class=\"limits-no-data\">\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" style=\"margin-bottom:6px;display:block;margin-inline:auto;opacity:.4\">\n <circle cx=\"12\" cy=\"12\" r=\"10\"/><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"/><line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"/>\n </svg>\n Google does not expose quota headers on successful responses.<br>\n Data appears here only after a 429 rate-limit error.<br>\n <a href=\"https://aistudio.google.com/app/usage\" target=\"_blank\" style=\"margin-top:8px;display:inline-block\">View quotas in AI Studio \u2197</a>\n </div>\n <div id=\"gem-data\" style=\"display:none\">\n <div class=\"limits-gauge-grid\">\n <div class=\"limits-gauge\">\n <div class=\"limits-gauge-label\"><span>Last known token limit</span></div>\n <div class=\"limits-gauge-bottom\">\n <span class=\"limits-gauge-remaining\" id=\"gem-tok-lim\">\u2014</span>\n <span class=\"limits-gauge-reset\" id=\"gem-errors\">0 errors</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"limits-usage-row\">\n <div class=\"limits-usage-item\">Session input: <span id=\"gem-u-inp-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Session output: <span id=\"gem-u-out-s\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today input: <span id=\"gem-u-inp-d\">\u2014</span></div>\n <div class=\"limits-usage-item\">Today output: <span id=\"gem-u-out-d\">\u2014</span></div>\n <div class=\"limits-usage-item\" style=\"margin-left:auto\">\n <a href=\"https://aistudio.google.com/app/usage\" target=\"_blank\" style=\"color:var(--muted);font-size:11px\">View quotas \u2197</a>\n </div>\n </div>\n </div>\n\n <!-- Personal budget -->\n <div class=\"limits-cli-section\" style=\"margin-bottom:0\">\n <div class=\"limits-cli-header\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\">\n <circle cx=\"12\" cy=\"12\" r=\"10\"/>\n <path d=\"M12 8v4l3 3\"/>\n </svg>\n <span class=\"limits-cli-name\">Personal daily budget</span>\n <span class=\"limits-cli-badge none\">optional</span>\n </div>\n <div class=\"limits-budget-row\">\n <input class=\"limits-budget-input\" id=\"budget-input\" type=\"number\" placeholder=\"e.g. 5000000\" min=\"0\">\n <span class=\"limits-budget-label\">tokens / day</span>\n <button class=\"btn-save\" id=\"budget-save\" style=\"padding:4px 12px;font-size:11px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--muted);cursor:pointer;transition:all .15s\" onmouseover=\"this.style.borderColor='var(--blue)';this.style.color='var(--text)'\" onmouseout=\"this.style.borderColor='var(--border)';this.style.color='var(--muted)'\">Save</button>\n </div>\n <div id=\"budget-bar-wrap\" style=\"margin-top:10px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px\">\n <span>Tokens used today through Squeezr</span>\n <span id=\"budget-pct-label\">0%</span>\n </div>\n <div class=\"limits-gauge-bar\" style=\"height:10px\">\n <div class=\"limits-gauge-fill\" id=\"budget-bar\" style=\"width:0%\"></div>\n </div>\n <div style=\"display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-top:4px\">\n <span id=\"budget-used-label\">0 used</span>\n <span id=\"budget-limit-label\">of \u2014</span>\n </div>\n </div>\n </div>\n\n </div>\n\n <!-- \u2500\u2500\u2500 Settings \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"page\" id=\"page-settings\">\n <div class=\"section\" style=\"margin-bottom:14px\">\n <div class=\"section-title\">Compression mode</div>\n <div class=\"mode-btns\">\n <button class=\"mode-btn\" data-mode=\"soft\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\">\n <path d=\"M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2\"/>\n </svg>\n Soft\n </button>\n <button class=\"mode-btn active\" data-mode=\"normal\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <line x1=\"4\" y1=\"21\" x2=\"4\" y2=\"14\"/><line x1=\"4\" y1=\"10\" x2=\"4\" y2=\"3\"/>\n <line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"12\"/><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"3\"/>\n <line x1=\"20\" y1=\"21\" x2=\"20\" y2=\"16\"/><line x1=\"20\" y1=\"12\" x2=\"20\" y2=\"3\"/>\n <line x1=\"1\" y1=\"14\" x2=\"7\" y2=\"14\"/><line x1=\"9\" y1=\"8\" x2=\"15\" y2=\"8\"/>\n <line x1=\"17\" y1=\"16\" x2=\"23\" y2=\"16\"/>\n </svg>\n Normal\n </button>\n <button class=\"mode-btn\" data-mode=\"aggressive\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/>\n </svg>\n Aggressive\n </button>\n <button class=\"mode-btn\" data-mode=\"critical\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"/>\n <line x1=\"12\" y1=\"9\" x2=\"12\" y2=\"13\"/>\n <line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"/>\n </svg>\n Critical\n </button>\n </div>\n <div id=\"mode-desc\">Normal \u2014 threshold 800 chars, last 3 results uncompressed</div>\n </div>\n\n <div class=\"section\">\n <div class=\"section-title\">Configuration</div>\n <div id=\"config-rows\">\n <div class=\"config-row\"><span class=\"config-key\">Mode</span><span class=\"config-val\" id=\"cfg-mode\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Port</span><span class=\"config-val\" id=\"cfg-port\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Dry-run</span><span class=\"config-val\" id=\"cfg-dryrun\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">LRU cache entries</span><span class=\"config-val\" id=\"cfg-lru\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Session cache entries</span><span class=\"config-val\" id=\"cfg-scache\">\u2014</span></div>\n <div class=\"config-row\"><span class=\"config-key\">Version</span><span class=\"config-val\" id=\"cfg-version\">\u2014</span></div>\n </div>\n </div>\n\n <div class=\"section\" style=\"margin-bottom:0\">\n <div class=\"section-title\">Links</div>\n <div style=\"display:flex;gap:16px;flex-wrap:wrap;font-size:12px\">\n <a href=\"/squeezr/stats\" target=\"_blank\">/squeezr/stats JSON</a>\n <a href=\"/squeezr/history\" target=\"_blank\">/squeezr/history JSON</a>\n <a href=\"/squeezr/projects\" target=\"_blank\">/squeezr/projects JSON</a>\n <a href=\"https://github.com/sergioramosv/Squeezr\" target=\"_blank\">GitHub</a>\n </div>\n </div>\n </div>\n\n </div><!-- /pages -->\n\n <div id=\"footer\">\n <span>Squeezr v<span id=\"f-version\">\u2014</span></span>\n <span id=\"f-mode\">mode: active</span>\n <span id=\"f-port\"></span>\n <span id=\"conn-status\" style=\"margin-left:auto;color:var(--green)\">\u25CF connected</span>\n </div>\n</div><!-- /content -->\n\n</div><!-- /app -->\n\n<script>\n// \u2500\u2500 Sparkline \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst MAX_PTS = 60\nconst sparkData = []\nlet lastTokens = 0\nfunction pushSpark(t) {\n sparkData.push(Math.max(0, t - lastTokens))\n lastTokens = t\n if (sparkData.length > MAX_PTS) sparkData.shift()\n}\nfunction drawSpark() {\n const cv = document.getElementById('sparkline')\n if (!cv) return\n const dpr = window.devicePixelRatio || 1\n const r = cv.getBoundingClientRect()\n cv.width = r.width * dpr; cv.height = r.height * dpr\n const ctx = cv.getContext('2d')\n ctx.scale(dpr, dpr)\n const w = r.width, h = r.height\n const mx = Math.max(...sparkData, 1)\n ctx.clearRect(0, 0, w, h)\n if (sparkData.length < 2) return\n const step = w / (MAX_PTS - 1)\n ctx.beginPath(); ctx.moveTo(0, h)\n sparkData.forEach((v, i) => ctx.lineTo(i * step, h - (v / mx) * (h - 4)))\n ctx.lineTo((sparkData.length - 1) * step, h)\n ctx.closePath()\n const g = ctx.createLinearGradient(0, 0, 0, h)\n g.addColorStop(0, 'rgba(63,185,80,.3)'); g.addColorStop(1, 'rgba(63,185,80,0)')\n ctx.fillStyle = g; ctx.fill()\n ctx.beginPath()\n sparkData.forEach((v, i) => {\n const x = i * step, y = h - (v / mx) * (h - 4)\n i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)\n })\n ctx.strokeStyle = '#3fb950'; ctx.lineWidth = 1.5; ctx.stroke()\n}\nwindow.addEventListener('resize', drawSpark)\n\n// \u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction fmtN(n) {\n if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'\n if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'\n return String(n)\n}\nfunction fmtCost(tok) {\n const u = (tok / 1e6) * 3\n return u < 0.01 ? '<$0.01' : u < 1 ? '$' + u.toFixed(3) : '$' + u.toFixed(2)\n}\nfunction fmtUptime(s) {\n if (s < 60) return s + 's'\n if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's'\n return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'\n}\nfunction fmtTs(ms) {\n if (!ms) return '\u2014'\n const d = new Date(ms)\n return d.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})\n}\nfunction fmtTime(ms) {\n if (!ms) return '\u2014'\n const d = new Date(ms)\n return d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false})\n}\nfunction fmtDur(startMs, endMs) {\n const s = Math.round((endMs - startMs) / 1000)\n if (s < 60) return s + 's'\n if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's'\n return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'\n}\nfunction timeAgo(ms) {\n if (!ms) return ''\n const diff = Math.round((Date.now() - ms) / 1000)\n if (diff < 60) return 'just now'\n if (diff < 3600) return Math.floor(diff / 60) + 'm ago'\n if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'\n if (diff < 172800) return 'yesterday'\n return Math.floor(diff / 86400) + 'd ago'\n}\nfunction barColor(p) {\n if (p >= 90) return 'var(--red)'\n if (p >= 75) return 'var(--yellow)'\n if (p >= 50) return 'var(--orange)'\n return 'var(--blue)'\n}\nfunction setBar(bid, vid, pct, label, noColor) {\n const b = document.getElementById(bid), v = document.getElementById(vid)\n b.style.width = Math.min(pct, 100) + '%'\n if (!noColor) b.style.background = barColor(pct)\n v.textContent = label\n}\nconst PROJECT_COLORS = ['#58a6ff','#3fb950','#ffa657','#bc8cff','#d29922','#f85149','#79c0ff','#56d364']\nfunction projectColor(name) {\n let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffff\n return PROJECT_COLORS[h % PROJECT_COLORS.length]\n}\n\n// \u2500\u2500 Overview render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction renderOverview(d) {\n document.getElementById('c-tokens').textContent = fmtN(d.total_saved_tokens)\n document.getElementById('c-chars').textContent = (d.total_saved_chars || 0).toLocaleString() + ' chars'\n document.getElementById('c-pct').textContent = (d.savings_pct || 0) + '%'\n document.getElementById('c-req').textContent = fmtN(d.requests || 0)\n document.getElementById('c-compressions').textContent = (d.compressions || 0) + ' compressions'\n document.getElementById('c-cost').textContent = fmtCost(d.total_saved_tokens || 0)\n document.getElementById('f-version').textContent = d.version || '\u2014'\n document.getElementById('sb-ver').textContent = 'v' + (d.version || '\u2014')\n document.getElementById('f-mode').textContent = 'mode: ' + (d.dry_run ? 'dry-run' : 'active')\n document.getElementById('f-port').textContent = 'port: ' + (d.port || '\u2014')\n document.getElementById('header-uptime').textContent = 'uptime ' + fmtUptime(d.uptime_seconds || 0)\n document.getElementById('uptime-small').textContent = fmtUptime(d.uptime_seconds || 0)\n\n // Project badge\n const proj = d.current_project\n const badge = document.getElementById('project-badge')\n if (proj && proj !== 'unknown') {\n badge.textContent = proj\n badge.style.display = ''\n badge.style.borderColor = projectColor(proj)\n badge.style.color = projectColor(proj)\n } else {\n badge.style.display = 'none'\n }\n\n // Pressure bars\n const msgPct = Math.min(Math.round((d.last_original_chars || 0) / 80), 100)\n const outPct = Math.min(Math.round((d.last_compressed_chars || 0) / 80), 100)\n const ch = d.session_cache_hits || 0\n const cachePct = Math.round((ch / Math.max(ch + (d.compressions || 1), 1)) * 100)\n setBar('bar-msg', 'pct-msg', msgPct, msgPct + '%')\n setBar('bar-out', 'pct-out', outPct, outPct + '%')\n setBar('bar-cache', 'pct-cache', cachePct, ch, true)\n\n // Sparkline\n pushSpark(d.total_saved_tokens || 0)\n drawSpark()\n\n // Tool table\n const bt = d.by_tool || {}\n const rows = Object.entries(bt).sort((a, b) => b[1].saved_tokens - a[1].saved_tokens)\n const maxSaved = rows[0]?.[1]?.saved_tokens || 1\n const tbody = document.getElementById('tools-body')\n if (rows.length === 0) {\n tbody.innerHTML = '<tr><td colspan=\"4\" style=\"color:var(--muted);padding:14px 8px;text-align:center\">No tool results compressed yet\u2026</td></tr>'\n } else {\n tbody.innerHTML = rows.map(([tool, t]) => {\n const bw = Math.round((t.saved_tokens / maxSaved) * 72)\n return `<tr>\n <td><code class=\"tag\">${tool}</code></td>\n <td class=\"td-right\" style=\"color:var(--muted)\">${t.count}</td>\n <td class=\"td-right\">${fmtN(t.saved_tokens)}</td>\n <td><span class=\"mini-bar\" style=\"width:${bw}px;background:var(--green)\"></span>${t.avg_pct}%</td>\n </tr>`\n }).join('')\n }\n\n // Cache stats\n document.getElementById('c-scache').textContent = d.session_cache_size ?? '\u2014'\n document.getElementById('c-expand').textContent = d.expand_store_size ?? '\u2014'\n document.getElementById('c-lru').textContent = d.cache?.size ?? '\u2014'\n document.getElementById('c-patterns').textContent = d.pattern_hits\n ? Object.values(d.pattern_hits).reduce((s, v) => s + v, 0).toLocaleString()\n : '\u2014'\n\n // Settings config panel\n document.getElementById('cfg-mode').textContent = d.mode || '\u2014'\n document.getElementById('cfg-port').textContent = d.port || '\u2014'\n document.getElementById('cfg-dryrun').textContent = d.dry_run ? 'yes' : 'no'\n document.getElementById('cfg-lru').textContent = d.cache?.size ?? '\u2014'\n document.getElementById('cfg-scache').textContent = d.session_cache_size ?? '\u2014'\n document.getElementById('cfg-version').textContent = d.version || '\u2014'\n\n // Sync active mode button\n document.querySelectorAll('.mode-btn').forEach(b => {\n b.classList.toggle('active', b.dataset.mode === d.mode)\n })\n const modeMap = {\n soft: 'Soft \u2014 threshold 3000 chars, last 10 results uncompressed, no AI',\n normal: 'Normal \u2014 threshold 800 chars, last 3 results uncompressed',\n aggressive: 'Aggressive \u2014 threshold 200 chars, last 1 result uncompressed',\n critical: 'Critical \u2014 threshold 50 chars, everything compressed'\n }\n document.getElementById('mode-desc').textContent = modeMap[d.mode] || ''\n\n // Savings breakdown\n const bd = d.breakdown\n if (bd) {\n const fmtC = (n) => n > 0 ? '-' + fmtN(n) : '0'\n document.getElementById('bd-det').textContent = fmtC(bd.deterministic)\n document.getElementById('bd-ai').textContent = fmtC(bd.ai_compression)\n document.getElementById('bd-dedup').textContent = fmtC(bd.read_dedup)\n document.getElementById('bd-sysprompt').textContent = fmtC(bd.system_prompt)\n document.getElementById('bd-overhead').textContent = bd.overhead > 0 ? '+' + fmtN(bd.overhead) : '0'\n document.getElementById('bd-aicalls').textContent = bd.ai_calls > 0 ? bd.ai_calls + ' calls' : '0'\n }\n}\n\n// \u2500\u2500 Projects page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function loadProjects() {\n try {\n const r = await fetch('/squeezr/projects')\n const { projects } = await r.json()\n const tbody = document.getElementById('projects-body')\n const entries = Object.entries(projects).sort((a, b) => b[1].savedTokens - a[1].savedTokens)\n if (entries.length === 0) {\n tbody.innerHTML = '<tr><td colspan=\"5\" style=\"color:var(--muted);padding:20px 8px;text-align:center\">No project data yet \u2014 start making requests.</td></tr>'\n return\n }\n tbody.innerHTML = entries.map(([name, p]) => `<tr>\n <td><span class=\"project-dot\" style=\"background:${projectColor(name)}\"></span><code>${name}</code></td>\n <td class=\"td-right\" style=\"color:var(--muted)\">${p.sessions}</td>\n <td class=\"td-right\">${p.requests}</td>\n <td class=\"td-right\" style=\"color:var(--green)\">${fmtN(p.savedTokens)}</td>\n <td class=\"td-right\" style=\"color:var(--muted);font-size:11px\">${p.lastSeen ? fmtTs(p.lastSeen) : '\u2014'}</td>\n </tr>`).join('')\n } catch {\n document.getElementById('projects-body').innerHTML = '<tr><td colspan=\"5\" style=\"color:var(--muted);padding:20px 8px;text-align:center\">Failed to load projects.</td></tr>'\n }\n}\n\n// \u2500\u2500 History page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet histData = null\nlet selectedHistProj = '__all__'\n\nasync function loadHistory() {\n try {\n const r = await fetch('/squeezr/history')\n histData = await r.json()\n renderHistProjects()\n renderHistSessions()\n } catch {\n document.getElementById('hist-proj-list').innerHTML = '<div class=\"empty-msg\">Failed to load history.</div>'\n }\n}\n\nfunction renderHistProjects() {\n if (!histData) return\n const all = [...histData.sessions]\n if (histData.current && histData.current.requests > 0) {\n const idx = all.findIndex(s => s.id === histData.current.id)\n if (idx >= 0) all[idx] = histData.current; else all.push(histData.current)\n }\n\n // Group by project\n const byProj = {}\n for (const s of all) {\n if (!byProj[s.project]) byProj[s.project] = 0\n byProj[s.project]++\n }\n\n const list = document.getElementById('hist-proj-list')\n let html = `<div class=\"hist-proj-item${selectedHistProj === '__all__' ? ' active' : ''}\" data-proj=\"__all__\">\n <span>All projects</span>\n <span class=\"hist-proj-count\">${all.length}</span>\n </div>`\n for (const [name, cnt] of Object.entries(byProj).sort((a, b) => b[1] - a[1])) {\n const active = selectedHistProj === name ? ' active' : ''\n html += `<div class=\"hist-proj-item${active}\" data-proj=\"${name}\">\n <span><span class=\"project-dot\" style=\"background:${projectColor(name)}\"></span>${name}</span>\n <span class=\"hist-proj-count\">${cnt}</span>\n </div>`\n }\n list.innerHTML = html\n\n list.querySelectorAll('.hist-proj-item').forEach(el => {\n el.addEventListener('click', () => {\n selectedHistProj = el.dataset.proj\n list.querySelectorAll('.hist-proj-item').forEach(x => x.classList.remove('active'))\n el.classList.add('active')\n renderHistSessions()\n })\n })\n}\n\nfunction renderHistSessions() {\n if (!histData) return\n let sessions = [...histData.sessions]\n if (histData.current && histData.current.requests > 0) {\n const idx = sessions.findIndex(s => s.id === histData.current.id)\n if (idx >= 0) sessions[idx] = histData.current; else sessions.push(histData.current)\n }\n // Filter empty sessions and sort newest first\n sessions = sessions.filter(s => s.requests > 0)\n sessions.sort((a, b) => b.startTime - a.startTime)\n\n if (selectedHistProj !== '__all__') {\n sessions = sessions.filter(s => s.project === selectedHistProj)\n }\n\n const header = document.getElementById('hist-sessions-header')\n header.textContent = selectedHistProj === '__all__'\n ? `All sessions (${sessions.length})`\n : `${selectedHistProj} \u2014 ${sessions.length} session${sessions.length !== 1 ? 's' : ''}`\n\n const list = document.getElementById('hist-sessions-list')\n if (sessions.length === 0) {\n list.innerHTML = '<div class=\"empty-msg\">No sessions found.</div>'\n return\n }\n\n // Group by day\n const byDay = {}\n for (const s of sessions) {\n const day = new Date(s.startTime).toLocaleDateString('en-US',{weekday:'short',month:'short',day:'numeric',year:'numeric'})\n if (!byDay[day]) byDay[day] = []\n byDay[day].push(s)\n }\n\n let html = ''\n for (const [day, daySessions] of Object.entries(byDay)) {\n html += `<div style=\"padding:8px 16px;font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.4px;background:var(--bg3);border-bottom:1px solid var(--border)\">${day}</div>`\n for (const s of daySessions) {\n const isCurrent = s.id === histData.current?.id\n const projBadge = selectedHistProj === '__all__' ? `<span class=\"session-project-badge\">${s.project}</span>` : ''\n html += `<div class=\"session-card\">\n <div class=\"session-date\">\n ${fmtTime(s.startTime)} \u2192 ${fmtTime(s.endTime)}\n <span style=\"color:var(--muted);font-weight:400\"> (${fmtDur(s.startTime, s.endTime)})</span>\n <span style=\"color:var(--muted);font-weight:400;margin-left:6px\">${timeAgo(s.endTime)}</span>\n ${isCurrent ? '<span style=\"font-size:10px;color:var(--green);margin-left:8px\">\u25CF active</span>' : ''}\n ${projBadge}\n </div>\n <div class=\"session-stats\">\n <div class=\"session-stat\">Requests: <span>${s.requests}</span></div>\n <div class=\"session-stat\">Tokens saved: <span style=\"color:var(--green)\">${fmtN(s.savedTokens)}</span></div>\n <div class=\"session-stat\">Compressions: <span>${s.compressions}</span></div>\n </div>\n </div>`\n }\n }\n list.innerHTML = html\n}\n\n// \u2500\u2500 Limits page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet limitsCountdownTimer = null\n\nfunction fmtTokens(n) {\n if (!n && n !== 0) return '\u2014'\n if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'\n if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'\n return String(n)\n}\n\nfunction gaugeColor(pct) {\n if (pct >= 90) return 'var(--red)'\n if (pct >= 70) return 'var(--yellow)'\n if (pct >= 40) return 'var(--orange)'\n return 'var(--green)'\n}\n\nfunction fillGauge(fillId, pctId, remId, resetId, remaining, limit, resetEpoch) {\n if (!limit) {\n document.getElementById(fillId).style.width = '0%'\n document.getElementById(pctId).textContent = '\u2014'\n document.getElementById(remId).textContent = '\u2014'\n if (resetId) document.getElementById(resetId).textContent = ''\n return\n }\n const used = limit - remaining\n const pct = Math.max(0, Math.min(100, Math.round((used / limit) * 100)))\n const fill = document.getElementById(fillId)\n fill.style.width = pct + '%'\n fill.style.background = gaugeColor(pct)\n document.getElementById(pctId).textContent = pct + '% used'\n document.getElementById(pctId).style.color = gaugeColor(pct)\n document.getElementById(remId).textContent = fmtTokens(remaining) + ' remaining'\n if (resetId && resetEpoch) {\n const secs = Math.max(0, Math.round((resetEpoch - Date.now()) / 1000))\n document.getElementById(resetId).textContent = secs > 0 ? 'resets in ' + secs + 's' : 'resetting\u2026'\n }\n}\n\nfunction renderLimits(d) {\n if (!d) return\n const { anthropic, openai, gemini } = d\n\n // \u2500\u2500 Anthropic \u2500\u2500\n const arl = anthropic?.rl\n const au = anthropic?.usage\n const antHasUsage = au && (au.inputSession > 0 || au.outputSession > 0)\n if (arl?.hasData) {\n document.getElementById('ant-badge').className = 'limits-cli-badge live'\n document.getElementById('ant-badge').textContent = 'live'\n fillGauge('ant-tok-fill','ant-tok-pct','ant-tok-rem','ant-tok-reset', arl.tokensRemaining, arl.tokensLimit, arl.tokensResetEpoch)\n fillGauge('ant-req-fill','ant-req-pct','ant-req-rem','ant-req-reset', arl.requestsRemaining, arl.requestsLimit, arl.requestsResetEpoch)\n fillGauge('ant-inp-fill','ant-inp-pct','ant-inp-rem','ant-inp-reset', arl.inputTokensRemaining, arl.inputTokensLimit, arl.tokensResetEpoch)\n fillGauge('ant-out-fill','ant-out-pct','ant-out-rem','ant-out-reset', arl.outputTokensRemaining, arl.outputTokensLimit, arl.tokensResetEpoch)\n } else if (anthropic?.unified?.hasData) {\n // Subscription (OAuth): unified rate limits with 5h/7d windows\n const u = anthropic.unified\n document.getElementById('ant-badge').className = 'limits-cli-badge live'\n document.getElementById('ant-badge').textContent = 'subscription'\n // Relabel gauges for subscription windows\n document.getElementById('ant-tok-label').textContent = '5-hour window'\n document.getElementById('ant-req-label').textContent = '7-day window'\n document.getElementById('ant-inp-label').textContent = 'Session input'\n document.getElementById('ant-out-label').textContent = 'Session output'\n // 5-hour window\n const pct5h = Math.round(u.fiveHourUtilization * 100)\n document.getElementById('ant-tok-fill').style.width = pct5h + '%'\n document.getElementById('ant-tok-fill').style.background = gaugeColor(pct5h)\n document.getElementById('ant-tok-pct').textContent = pct5h + '%'\n document.getElementById('ant-tok-pct').style.color = gaugeColor(pct5h)\n document.getElementById('ant-tok-rem').textContent = '5h window'\n // 7-day window\n const pct7d = Math.round(u.sevenDayUtilization * 100)\n document.getElementById('ant-req-fill').style.width = pct7d + '%'\n document.getElementById('ant-req-fill').style.background = gaugeColor(pct7d)\n document.getElementById('ant-req-pct').textContent = pct7d + '%'\n document.getElementById('ant-req-pct').style.color = gaugeColor(pct7d)\n document.getElementById('ant-req-rem').textContent = '7d window'\n // Input/output: show session totals\n document.getElementById('ant-inp-pct').textContent = fmtTokens(au?.inputSession || 0)\n document.getElementById('ant-inp-rem').textContent = 'session input'\n document.getElementById('ant-out-pct').textContent = fmtTokens(au?.outputSession || 0)\n document.getElementById('ant-out-rem').textContent = 'session output'\n } else if (antHasUsage) {\n // Fallback: no rate limit headers at all, but usage is tracked\n document.getElementById('ant-badge').className = 'limits-cli-badge live'\n document.getElementById('ant-badge').textContent = 'tracking'\n document.getElementById('ant-tok-pct').textContent = fmtTokens((au?.inputSession || 0) + (au?.outputSession || 0))\n document.getElementById('ant-tok-rem').textContent = 'session total'\n document.getElementById('ant-req-pct').textContent = (au?.requestsSession || 0) + ' reqs'\n document.getElementById('ant-req-rem').textContent = 'session total'\n document.getElementById('ant-inp-pct').textContent = fmtTokens(au?.inputSession || 0)\n document.getElementById('ant-inp-rem').textContent = 'session input'\n document.getElementById('ant-out-pct').textContent = fmtTokens(au?.outputSession || 0)\n document.getElementById('ant-out-rem').textContent = 'session output'\n }\n if (au) {\n document.getElementById('ant-u-inp-s').textContent = fmtTokens(au.inputSession)\n document.getElementById('ant-u-out-s').textContent = fmtTokens(au.outputSession)\n document.getElementById('ant-u-inp-d').textContent = fmtTokens(au.inputToday)\n document.getElementById('ant-u-out-d').textContent = fmtTokens(au.outputToday)\n }\n\n // \u2500\u2500 OpenAI \u2500\u2500\n const orl = openai?.rl\n const ou = openai?.usage\n const oaiHasUsage = ou && (ou.inputSession > 0 || ou.outputSession > 0)\n if (orl?.hasData) {\n document.getElementById('oai-badge').className = 'limits-cli-badge live'\n document.getElementById('oai-badge').textContent = 'live'\n fillGauge('oai-tok-fill','oai-tok-pct','oai-tok-rem','oai-tok-reset', orl.tokensRemaining, orl.tokensLimit, orl.tokensResetEpoch)\n fillGauge('oai-req-fill','oai-req-pct','oai-req-rem','oai-req-reset', orl.requestsRemaining, orl.requestsLimit, orl.requestsResetEpoch)\n } else if (oaiHasUsage) {\n document.getElementById('oai-badge').className = 'limits-cli-badge live'\n document.getElementById('oai-badge').textContent = 'tracking'\n document.getElementById('oai-tok-pct').textContent = fmtTokens(ou.inputSession + ou.outputSession)\n document.getElementById('oai-tok-rem').textContent = 'session total'\n document.getElementById('oai-req-pct').textContent = ou.requestsSession + ' reqs'\n document.getElementById('oai-req-rem').textContent = 'session total'\n }\n const ob = openai?.billing\n if (ob?.hardLimitUsd > 0) {\n document.getElementById('oai-billing-row').style.display = 'flex'\n document.getElementById('oai-credits').textContent = '$' + (ob.creditBalanceUsd || 0).toFixed(2)\n document.getElementById('oai-hard-lim').textContent = '$' + ob.hardLimitUsd.toFixed(2)\n }\n if (ou) {\n document.getElementById('oai-u-inp-s').textContent = fmtTokens(ou.inputSession)\n document.getElementById('oai-u-out-s').textContent = fmtTokens(ou.outputSession)\n document.getElementById('oai-u-inp-d').textContent = fmtTokens(ou.inputToday)\n document.getElementById('oai-u-out-d').textContent = fmtTokens(ou.outputToday)\n }\n\n // \u2500\u2500 Gemini \u2500\u2500\n const ge = gemini?.errors\n const gu = gemini?.usage\n const gemHasUsage = gu && (gu.inputSession > 0 || gu.outputSession > 0)\n if (ge?.hasData) {\n document.getElementById('gem-nodata').style.display = 'none'\n document.getElementById('gem-data').style.display = 'block'\n document.getElementById('gem-tok-lim').textContent = fmtTokens(gemini.rl?.tokensLimit)\n document.getElementById('gem-errors').textContent = ge.errorCount429 + ' rate-limit errors'\n document.getElementById('gem-badge').className = 'limits-cli-badge error'\n document.getElementById('gem-badge').textContent = ge.errorCount429 + ' 429 errors'\n } else if (gemHasUsage) {\n document.getElementById('gem-nodata').style.display = 'none'\n document.getElementById('gem-data').style.display = 'block'\n document.getElementById('gem-badge').className = 'limits-cli-badge live'\n document.getElementById('gem-badge').textContent = 'tracking'\n }\n if (gu) {\n document.getElementById('gem-u-inp-s').textContent = fmtTokens(gu.inputSession)\n document.getElementById('gem-u-out-s').textContent = fmtTokens(gu.outputSession)\n document.getElementById('gem-u-inp-d').textContent = fmtTokens(gu.inputToday)\n document.getElementById('gem-u-out-d').textContent = fmtTokens(gu.outputToday)\n }\n\n // \u2500\u2500 Budget \u2500\u2500\n updateBudgetBar(au, ou, gu)\n}\n\n// Countdown ticker \u2014 updates reset countdowns every second without SSE\nfunction startLimitsCountdown(limitsData) {\n if (limitsCountdownTimer) clearInterval(limitsCountdownTimer)\n limitsCountdownTimer = setInterval(() => {\n const updateReset = (id, resetEpoch) => {\n if (!resetEpoch) return\n const el = document.getElementById(id)\n if (!el) return\n const secs = Math.max(0, Math.round((resetEpoch - Date.now()) / 1000))\n el.textContent = secs > 0 ? 'resets in ' + secs + 's' : 'resetting\u2026'\n }\n const d = limitsData\n if (d?.anthropic?.rl?.hasData) {\n updateReset('ant-tok-reset', d.anthropic.rl.tokensResetEpoch)\n updateReset('ant-req-reset', d.anthropic.rl.requestsResetEpoch)\n updateReset('ant-inp-reset', d.anthropic.rl.tokensResetEpoch)\n updateReset('ant-out-reset', d.anthropic.rl.tokensResetEpoch)\n }\n if (d?.openai?.rl?.hasData) {\n updateReset('oai-tok-reset', d.openai.rl.tokensResetEpoch)\n updateReset('oai-req-reset', d.openai.rl.requestsResetEpoch)\n }\n }, 1000)\n}\n\n// \u2500\u2500 Budget logic \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet dailyBudget = parseInt(localStorage.getItem('squeezr_budget') || '0')\n\nfunction updateBudgetBar(au, ou, gu) {\n const budget = dailyBudget\n const budgetInput = document.getElementById('budget-input')\n if (budgetInput && !budgetInput.value) budgetInput.value = budget || ''\n\n const wrap = document.getElementById('budget-bar-wrap')\n if (!budget) { wrap.style.display = 'none'; return }\n wrap.style.display = 'block'\n\n const totalToday = ((au?.inputToday || 0) + (au?.outputToday || 0) +\n (ou?.inputToday || 0) + (ou?.outputToday || 0) +\n (gu?.inputToday || 0) + (gu?.outputToday || 0))\n const pct = Math.min(100, Math.round((totalToday / budget) * 100))\n const fill = document.getElementById('budget-bar')\n fill.style.width = pct + '%'\n fill.style.background = gaugeColor(pct)\n document.getElementById('budget-pct-label').textContent = pct + '%'\n document.getElementById('budget-pct-label').style.color = gaugeColor(pct)\n document.getElementById('budget-used-label').textContent = fmtTokens(totalToday) + ' used today'\n document.getElementById('budget-limit-label').textContent = 'of ' + fmtTokens(budget) + ' / day'\n}\n\ndocument.getElementById('budget-save').addEventListener('click', () => {\n const val = parseInt(document.getElementById('budget-input').value || '0')\n dailyBudget = val\n localStorage.setItem('squeezr_budget', String(val))\n document.getElementById('budget-save').textContent = '\u2713 Saved'\n setTimeout(() => document.getElementById('budget-save').textContent = 'Save', 2000)\n // Re-render budget bar with latest limits data\n if (lastLimitsData) {\n const u = lastLimitsData.usage\n updateBudgetBar(u?.anthropic, u?.openai, u?.gemini)\n }\n})\n\n// Restore budget from localStorage on load\nconst savedBudget = localStorage.getItem('squeezr_budget')\nif (savedBudget) document.getElementById('budget-input').value = savedBudget\n\n// \u2500\u2500 Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst pageTitles = { overview: 'Overview', projects: 'Projects', history: 'History', limits: 'Limits', settings: 'Settings' }\n\ndocument.querySelectorAll('.nav-item').forEach(item => {\n item.addEventListener('click', () => {\n const page = item.dataset.page\n document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'))\n document.querySelectorAll('.page').forEach(p => p.classList.remove('active'))\n item.classList.add('active')\n document.getElementById('page-' + page).classList.add('active')\n document.getElementById('page-title').textContent = pageTitles[page] || page\n if (page === 'projects') loadProjects()\n if (page === 'history') loadHistory()\n if (page === 'limits') {\n if (lastLimitsData) {\n renderLimits(lastLimitsData)\n startLimitsCountdown(lastLimitsData)\n }\n }\n if (page !== 'limits' && limitsCountdownTimer) {\n clearInterval(limitsCountdownTimer)\n limitsCountdownTimer = null\n }\n })\n})\n\n// \u2500\u2500 Mode selector \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\ndocument.querySelectorAll('.mode-btn[data-mode]').forEach(btn => {\n btn.addEventListener('click', async () => {\n const mode = btn.dataset.mode\n if (!mode) return\n const prevActive = document.querySelector('.mode-btn.active')\n document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'))\n btn.classList.add('active')\n try {\n const res = await fetch('/squeezr/config', {\n method: 'POST',\n headers: {'content-type':'application/json'},\n body: JSON.stringify({ mode })\n })\n if (!res.ok) throw new Error('HTTP ' + res.status)\n } catch(e) {\n // Revert to previous mode on failure\n btn.classList.remove('active')\n if (prevActive) prevActive.classList.add('active')\n console.error('mode update failed', e)\n }\n })\n})\n\n// \u2500\u2500 SSE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst dot = document.getElementById('status-dot')\nconst statusText = document.getElementById('status-text')\nconst connPill = document.getElementById('conn-pill')\nconst connStatus = document.getElementById('conn-status')\nlet lastLimitsData = null\n\nfunction connect() {\n const es = new EventSource('/squeezr/events')\n es.onmessage = e => {\n try {\n const d = JSON.parse(e.data)\n renderOverview(d)\n if (d.limits) {\n lastLimitsData = d.limits\n // Only render limits page if it's currently visible\n const limPage = document.getElementById('page-limits')\n if (limPage && limPage.classList.contains('active')) {\n renderLimits(d.limits)\n if (!limitsCountdownTimer) startLimitsCountdown(d.limits)\n else { /* update the data reference for the countdown */ lastLimitsData = d.limits }\n }\n }\n } catch(err) { console.error(err) }\n }\n es.onopen = () => {\n dot.classList.remove('off')\n statusText.textContent = 'Running'\n connPill.className = ''\n connPill.textContent = '\u25CF live'\n connStatus.style.color = 'var(--green)'\n connStatus.textContent = '\u25CF connected'\n }\n es.onerror = () => {\n dot.classList.add('off')\n statusText.textContent = 'Reconnecting\u2026'\n connPill.className = 'err'\n connPill.textContent = '\u25CF offline'\n connStatus.style.color = 'var(--red)'\n connStatus.textContent = '\u25CF reconnecting\u2026'\n es.close()\n setTimeout(connect, 3000)\n }\n}\nconnect()\n</script>\n</body>\n</html>";
package/dist/dashboard.js CHANGED
@@ -431,7 +431,7 @@ tr:last-child td{border-bottom:none}
431
431
  <div class="limits-gauge-grid">
432
432
  <div class="limits-gauge">
433
433
  <div class="limits-gauge-label">
434
- <span>Tokens / minute</span>
434
+ <span id="ant-tok-label">Tokens / minute</span>
435
435
  <span id="ant-tok-pct" style="color:var(--muted)">—</span>
436
436
  </div>
437
437
  <div class="limits-gauge-bar"><div class="limits-gauge-fill" id="ant-tok-fill" style="width:0%"></div></div>
@@ -442,7 +442,7 @@ tr:last-child td{border-bottom:none}
442
442
  </div>
443
443
  <div class="limits-gauge">
444
444
  <div class="limits-gauge-label">
445
- <span>Requests / minute</span>
445
+ <span id="ant-req-label">Requests / minute</span>
446
446
  <span id="ant-req-pct" style="color:var(--muted)">—</span>
447
447
  </div>
448
448
  <div class="limits-gauge-bar"><div class="limits-gauge-fill" id="ant-req-fill" style="width:0%"></div></div>
@@ -453,7 +453,7 @@ tr:last-child td{border-bottom:none}
453
453
  </div>
454
454
  <div class="limits-gauge">
455
455
  <div class="limits-gauge-label">
456
- <span>Input tokens / minute</span>
456
+ <span id="ant-inp-label">Input tokens / minute</span>
457
457
  <span id="ant-inp-pct" style="color:var(--muted)">—</span>
458
458
  </div>
459
459
  <div class="limits-gauge-bar"><div class="limits-gauge-fill" id="ant-inp-fill" style="width:0%"></div></div>
@@ -464,7 +464,7 @@ tr:last-child td{border-bottom:none}
464
464
  </div>
465
465
  <div class="limits-gauge">
466
466
  <div class="limits-gauge-label">
467
- <span>Output tokens / minute</span>
467
+ <span id="ant-out-label">Output tokens / minute</span>
468
468
  <span id="ant-out-pct" style="color:var(--muted)">—</span>
469
469
  </div>
470
470
  <div class="limits-gauge-bar"><div class="limits-gauge-fill" id="ant-out-fill" style="width:0%"></div></div>
@@ -1071,18 +1071,46 @@ function renderLimits(d) {
1071
1071
  fillGauge('ant-req-fill','ant-req-pct','ant-req-rem','ant-req-reset', arl.requestsRemaining, arl.requestsLimit, arl.requestsResetEpoch)
1072
1072
  fillGauge('ant-inp-fill','ant-inp-pct','ant-inp-rem','ant-inp-reset', arl.inputTokensRemaining, arl.inputTokensLimit, arl.tokensResetEpoch)
1073
1073
  fillGauge('ant-out-fill','ant-out-pct','ant-out-rem','ant-out-reset', arl.outputTokensRemaining, arl.outputTokensLimit, arl.tokensResetEpoch)
1074
- } else if (antHasUsage) {
1075
- // Subscription/OAuth: no rate limit headers, but usage is tracked
1074
+ } else if (anthropic?.unified?.hasData) {
1075
+ // Subscription (OAuth): unified rate limits with 5h/7d windows
1076
+ const u = anthropic.unified
1076
1077
  document.getElementById('ant-badge').className = 'limits-cli-badge live'
1077
1078
  document.getElementById('ant-badge').textContent = 'subscription'
1078
- // Show session totals in gauge areas since we don't have rate limits
1079
- document.getElementById('ant-tok-pct').textContent = fmtTokens(au.inputSession + au.outputSession)
1079
+ // Relabel gauges for subscription windows
1080
+ document.getElementById('ant-tok-label').textContent = '5-hour window'
1081
+ document.getElementById('ant-req-label').textContent = '7-day window'
1082
+ document.getElementById('ant-inp-label').textContent = 'Session input'
1083
+ document.getElementById('ant-out-label').textContent = 'Session output'
1084
+ // 5-hour window
1085
+ const pct5h = Math.round(u.fiveHourUtilization * 100)
1086
+ document.getElementById('ant-tok-fill').style.width = pct5h + '%'
1087
+ document.getElementById('ant-tok-fill').style.background = gaugeColor(pct5h)
1088
+ document.getElementById('ant-tok-pct').textContent = pct5h + '%'
1089
+ document.getElementById('ant-tok-pct').style.color = gaugeColor(pct5h)
1090
+ document.getElementById('ant-tok-rem').textContent = '5h window'
1091
+ // 7-day window
1092
+ const pct7d = Math.round(u.sevenDayUtilization * 100)
1093
+ document.getElementById('ant-req-fill').style.width = pct7d + '%'
1094
+ document.getElementById('ant-req-fill').style.background = gaugeColor(pct7d)
1095
+ document.getElementById('ant-req-pct').textContent = pct7d + '%'
1096
+ document.getElementById('ant-req-pct').style.color = gaugeColor(pct7d)
1097
+ document.getElementById('ant-req-rem').textContent = '7d window'
1098
+ // Input/output: show session totals
1099
+ document.getElementById('ant-inp-pct').textContent = fmtTokens(au?.inputSession || 0)
1100
+ document.getElementById('ant-inp-rem').textContent = 'session input'
1101
+ document.getElementById('ant-out-pct').textContent = fmtTokens(au?.outputSession || 0)
1102
+ document.getElementById('ant-out-rem').textContent = 'session output'
1103
+ } else if (antHasUsage) {
1104
+ // Fallback: no rate limit headers at all, but usage is tracked
1105
+ document.getElementById('ant-badge').className = 'limits-cli-badge live'
1106
+ document.getElementById('ant-badge').textContent = 'tracking'
1107
+ document.getElementById('ant-tok-pct').textContent = fmtTokens((au?.inputSession || 0) + (au?.outputSession || 0))
1080
1108
  document.getElementById('ant-tok-rem').textContent = 'session total'
1081
- document.getElementById('ant-req-pct').textContent = au.requestsSession + ' reqs'
1109
+ document.getElementById('ant-req-pct').textContent = (au?.requestsSession || 0) + ' reqs'
1082
1110
  document.getElementById('ant-req-rem').textContent = 'session total'
1083
- document.getElementById('ant-inp-pct').textContent = fmtTokens(au.inputSession)
1111
+ document.getElementById('ant-inp-pct').textContent = fmtTokens(au?.inputSession || 0)
1084
1112
  document.getElementById('ant-inp-rem').textContent = 'session input'
1085
- document.getElementById('ant-out-pct').textContent = fmtTokens(au.outputSession)
1113
+ document.getElementById('ant-out-pct').textContent = fmtTokens(au?.outputSession || 0)
1086
1114
  document.getElementById('ant-out-rem').textContent = 'session output'
1087
1115
  }
1088
1116
  if (au) {
package/dist/limits.d.ts CHANGED
@@ -45,9 +45,21 @@ export interface GeminiErrorState {
45
45
  lastErrorEpoch: number;
46
46
  hasData: boolean;
47
47
  }
48
+ export interface UnifiedRateLimitState {
49
+ fiveHourUtilization: number;
50
+ fiveHourResetEpoch: number;
51
+ fiveHourStatus: string;
52
+ sevenDayUtilization: number;
53
+ sevenDayResetEpoch: number;
54
+ sevenDayStatus: string;
55
+ overageUtilization: number;
56
+ overageStatus: string;
57
+ hasData: boolean;
58
+ }
48
59
  export declare const anthropicRL: RateLimitState;
49
60
  export declare const openaiRL: RateLimitState;
50
61
  export declare const geminiRL: RateLimitState;
62
+ export declare const anthropicUnified: UnifiedRateLimitState;
51
63
  export declare const anthropicUsage: UsageState;
52
64
  export declare const openaiUsage: UsageState;
53
65
  export declare const geminiUsage: UsageState;
@@ -67,6 +79,7 @@ export declare function limitsSnapshot(): {
67
79
  anthropic: {
68
80
  rl: RateLimitState;
69
81
  usage: UsageState;
82
+ unified: UnifiedRateLimitState;
70
83
  };
71
84
  openai: {
72
85
  rl: RateLimitState;
package/dist/limits.js CHANGED
@@ -31,6 +31,12 @@ function emptyUsage() {
31
31
  export const anthropicRL = emptyRL();
32
32
  export const openaiRL = emptyRL();
33
33
  export const geminiRL = emptyRL();
34
+ export const anthropicUnified = {
35
+ fiveHourUtilization: 0, fiveHourResetEpoch: 0, fiveHourStatus: '',
36
+ sevenDayUtilization: 0, sevenDayResetEpoch: 0, sevenDayStatus: '',
37
+ overageUtilization: 0, overageStatus: '',
38
+ hasData: false,
39
+ };
34
40
  export const anthropicUsage = emptyUsage();
35
41
  export const openaiUsage = emptyUsage();
36
42
  export const geminiUsage = emptyUsage();
@@ -88,6 +94,20 @@ function h(headers, name) {
88
94
  }
89
95
  // ── Rate limit update from headers ────────────────────────────────────────────
90
96
  export function updateAnthropicFromHeaders(headers) {
97
+ // Subscription (OAuth) sends unified rate limits instead of per-minute limits
98
+ const unified5hUtil = headers.get('anthropic-ratelimit-unified-5h-utilization');
99
+ if (unified5hUtil !== null) {
100
+ anthropicUnified.fiveHourUtilization = parseFloat(unified5hUtil) || 0;
101
+ anthropicUnified.fiveHourResetEpoch = parseInt(headers.get('anthropic-ratelimit-unified-5h-reset') ?? '0') * 1000;
102
+ anthropicUnified.fiveHourStatus = headers.get('anthropic-ratelimit-unified-5h-status') ?? '';
103
+ anthropicUnified.sevenDayUtilization = parseFloat(headers.get('anthropic-ratelimit-unified-7d-utilization') ?? '0') || 0;
104
+ anthropicUnified.sevenDayResetEpoch = parseInt(headers.get('anthropic-ratelimit-unified-7d-reset') ?? '0') * 1000;
105
+ anthropicUnified.sevenDayStatus = headers.get('anthropic-ratelimit-unified-7d-status') ?? '';
106
+ anthropicUnified.overageUtilization = parseFloat(headers.get('anthropic-ratelimit-unified-overage-utilization') ?? '0') || 0;
107
+ anthropicUnified.overageStatus = headers.get('anthropic-ratelimit-unified-overage-status') ?? '';
108
+ anthropicUnified.hasData = true;
109
+ }
110
+ // API key users get standard per-minute rate limits
91
111
  if (!headers.get('anthropic-ratelimit-requests-limit') &&
92
112
  !headers.get('anthropic-ratelimit-tokens-limit'))
93
113
  return;
@@ -242,7 +262,7 @@ export async function maybeRefreshOpenAIBilling(apiKey) {
242
262
  // ── Snapshot for API / SSE ────────────────────────────────────────────────────
243
263
  export function limitsSnapshot() {
244
264
  return {
245
- anthropic: { rl: anthropicRL, usage: anthropicUsage },
265
+ anthropic: { rl: anthropicRL, usage: anthropicUsage, unified: anthropicUnified },
246
266
  openai: { rl: openaiRL, usage: openaiUsage, billing: openAIBilling },
247
267
  gemini: { rl: geminiRL, usage: geminiUsage, errors: geminiErrors },
248
268
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squeezr-ai",
3
- "version": "1.21.0",
3
+ "version": "1.21.1",
4
4
  "description": "AI proxy that compresses Claude Code, Codex, Aider, Gemini CLI and Ollama context windows to save thousands of tokens per session",
5
5
  "keywords": [
6
6
  "claude",