@buzzie-ai/jannal 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/bin/jannal.js +3 -0
- package/lib/plugins.js +67 -0
- package/lib/tokens.js +176 -0
- package/package.json +52 -0
- package/public/assets/index-B8dfyj9-.css +1 -0
- package/public/assets/index-CzXZ1AkJ.js +23 -0
- package/public/favicon.png +0 -0
- package/public/index.html +144 -0
- package/public/jannal-1.png +0 -0
- package/public/logo.png +0 -0
- package/server.js +1142 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arvind Naidu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Jannal
|
|
2
|
+
|
|
3
|
+
**See what's eating your context window. Then fix it.**
|
|
4
|
+
|
|
5
|
+
Jannal sits between your AI tools and the Anthropic API. It intercepts every request, visualizes how your context window is being used, and lets you filter out tools you don't need — saving tokens and money.
|
|
6
|
+
|
|
7
|
+
Works with Claude Code and any tool that speaks the Anthropic Messages API. [Cursor support is pending](#cursor-support).
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
**Inspect** — Watch every API request in real time. See exactly how many tokens go to the system prompt, tool definitions, conversation history, and tool results. The context bar shows you at a glance where your tokens are going.
|
|
16
|
+
|
|
17
|
+
**Cost tracking** — See the cost of every turn, with per-model pricing (Opus, Sonnet, Haiku). Session cost accumulates in the header so you always know what you're spending. Uses the official `count_tokens` API for accurate counts before the response even finishes.
|
|
18
|
+
|
|
19
|
+
**Filter tools** — The killer feature. If you're running Claude Code with 40+ MCP tools defined, half of them are probably irrelevant to what you're doing right now. Jannal strips them from the request before it hits the API. Create named profiles ("Coding Only", "Browser Automation") and switch between them from the UI.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx @buzzie-ai/jannal
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or install and run manually:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
git clone https://github.com/Buzzie-AI/jannal.git
|
|
31
|
+
cd jannal
|
|
32
|
+
npm install
|
|
33
|
+
npm run build
|
|
34
|
+
npm start
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then start your AI tool pointing at the proxy:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Claude Code
|
|
41
|
+
ANTHROPIC_BASE_URL=http://localhost:4455 claude
|
|
42
|
+
|
|
43
|
+
# Or any tool that supports ANTHROPIC_BASE_URL
|
|
44
|
+
ANTHROPIC_BASE_URL=http://localhost:4455 your-tool
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Open `http://localhost:4455` in your browser to see the Inspector.
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
Your AI Tool → Jannal (localhost:4455) → api.anthropic.com
|
|
53
|
+
↓
|
|
54
|
+
Inspector UI (browser)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The proxy is transparent. It forwards requests to Anthropic and pipes responses back. The only modification it makes is tool filtering when you have an active profile.
|
|
58
|
+
|
|
59
|
+
Your API key passes through the proxy in the request headers — it's never stored or logged. The proxy runs entirely on your machine.
|
|
60
|
+
|
|
61
|
+
## Features
|
|
62
|
+
|
|
63
|
+
### Context bar
|
|
64
|
+
A visual breakdown of every segment in the context window. System prompt, tools, messages, tool results — each gets a colored block proportional to its token count. Pressure indicators glow when you're approaching the context limit.
|
|
65
|
+
|
|
66
|
+
### Turn timeline
|
|
67
|
+
Every API request appears as a turn in the left panel. Click one to see its full segment breakdown. Tokens, costs, and model info at a glance.
|
|
68
|
+
|
|
69
|
+
### Full content modal
|
|
70
|
+
Click any segment to see its complete content — system prompts, tool definitions with full JSON schemas, message text. Search, copy, and switch between formatted and raw views.
|
|
71
|
+
|
|
72
|
+
### Tool filtering profiles
|
|
73
|
+
Open the Tools segment, uncheck the tools you don't need, save as a named profile. Profiles persist across restarts (stored in `profiles.json`). Switch profiles from the header dropdown. An orange "FILTERING" badge reminds you when filtering is active.
|
|
74
|
+
|
|
75
|
+
**Tool grouping by MCP server** — Tools are grouped by inferred MCP server (e.g. `github`, `filesystem`). Enable or disable an entire server at once with per-group All/None buttons.
|
|
76
|
+
|
|
77
|
+
### Accurate token counting
|
|
78
|
+
Three phases: instant char-based estimates, then exact counts via the `count_tokens` API (free, fires in parallel), then ground truth from the response. Per-segment breakdowns are proportionally scaled when exact totals arrive.
|
|
79
|
+
|
|
80
|
+
### Cost per turn
|
|
81
|
+
Pricing for all Claude models, updated to current rates. See input cost, output cost, and total per turn. Session cost accumulates in the header.
|
|
82
|
+
|
|
83
|
+
### Session export & persistence
|
|
84
|
+
Export your session as **JSON** or **CSV** for analysis. Session data (turns, costs, segments) persists across page refreshes via `localStorage` — pick up where you left off.
|
|
85
|
+
|
|
86
|
+
### Token growth chart
|
|
87
|
+
A sparkline below the context bar shows input tokens per turn over time. Spot conversation bloat at a glance and know when to start a new session.
|
|
88
|
+
|
|
89
|
+
## Request grouping
|
|
90
|
+
|
|
91
|
+
When you use Claude Code, a single user message can generate dozens of API requests — the main session, subagents, tool calls, and follow-ups. Jannal groups these into logical turns so you see conversations, not a flat stream of requests.
|
|
92
|
+
|
|
93
|
+
**How grouping works:**
|
|
94
|
+
- Requests are grouped by conversation identity (first human message) and session hash (model + system prompt text)
|
|
95
|
+
- A new group starts when: the user types a new message in the main session, or a 45-second inactivity gap elapses
|
|
96
|
+
- Subagent requests (shorter message count, different model) stay in the current group rather than creating singletons
|
|
97
|
+
- Infrastructure tags (`<system-reminder>`, `<command-message>`, etc.) are stripped before text comparison so Claude Code boilerplate doesn't create bogus boundaries
|
|
98
|
+
|
|
99
|
+
Toggle between **Grouped** and **Flat** views using the button in the request panel header.
|
|
100
|
+
|
|
101
|
+
## Plugin system
|
|
102
|
+
|
|
103
|
+
Jannal has a plugin system for extending its functionality. Plugins can hook into server lifecycle events, add custom API routes, analyze requests, and process responses.
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
const { createServer } = require("jannal");
|
|
107
|
+
|
|
108
|
+
createServer({
|
|
109
|
+
plugins: [myPlugin()]
|
|
110
|
+
}).start();
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
See `lib/plugins.js` for the available hooks: `onInit`, `onServerStart`, `onRoute`, `onRequestAnalyzed`, `onResponseComplete`, `getConnectPayload`.
|
|
114
|
+
|
|
115
|
+
## Jannal Pro
|
|
116
|
+
|
|
117
|
+
**[Jannal Pro](https://github.com/Buzzie-AI/jannal-pro)** adds premium features on top of the free core:
|
|
118
|
+
|
|
119
|
+
- **Router intelligence** — Predicts which MCP tool groups are relevant per request and identifies which can be safely stripped, saving 30-50k tokens per turn
|
|
120
|
+
- **Electron Mac app** — Menu bar tray app with profile switching, auto-updates
|
|
121
|
+
- **Embedding-based routing** — Local sentence embedding model for semantic intent matching
|
|
122
|
+
|
|
123
|
+
## Cursor support
|
|
124
|
+
|
|
125
|
+
**Current status:** Cursor IDE does not yet support overriding the Anthropic base URL. Unlike OpenAI models (which have a base URL override in Settings → Models), Anthropic models always send requests directly to `api.anthropic.com`, so Jannal cannot intercept them today.
|
|
126
|
+
|
|
127
|
+
**When Cursor adds Anthropic base URL override**, Jannal will work with zero code changes.
|
|
128
|
+
|
|
129
|
+
Track Cursor's progress on this feature: [Override Anthropic Base URL](https://forum.cursor.com/t/override-anthropic-base-url/5355).
|
|
130
|
+
|
|
131
|
+
## Configuration
|
|
132
|
+
|
|
133
|
+
| Environment variable | Default | Description |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `JANNAL_PORT` | `4455` | Port for the proxy and Inspector UI |
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
# Terminal 1: start the proxy server
|
|
141
|
+
npm run dev:server
|
|
142
|
+
|
|
143
|
+
# Terminal 2: start Vite dev server with hot reload
|
|
144
|
+
npm run dev:ui
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Open `http://localhost:5173` for the dev UI (auto-proxies API calls to the server on :4455).
|
|
148
|
+
|
|
149
|
+
## Project structure
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
jannal/
|
|
153
|
+
├── server.js # Proxy server, createServer() factory, plugin hooks
|
|
154
|
+
├── bin/jannal.js # CLI entry point (npx jannal)
|
|
155
|
+
├── lib/
|
|
156
|
+
│ ├── plugins.js # Plugin host (lifecycle hooks, route handling)
|
|
157
|
+
│ └── tokens.js # Token estimation, budget inference
|
|
158
|
+
├── src/ # Frontend source (ES modules)
|
|
159
|
+
│ ├── index.html # HTML shell
|
|
160
|
+
│ ├── main.js # Entry point
|
|
161
|
+
│ ├── styles.css # All styles
|
|
162
|
+
│ ├── state.js # App state + constants
|
|
163
|
+
│ ├── ws.js # WebSocket connection
|
|
164
|
+
│ ├── api.js # HTTP API helpers
|
|
165
|
+
│ ├── render.js # UI rendering (bar, turns, detail, token chart)
|
|
166
|
+
│ ├── modal.js # Modal lifecycle + tools view (grouped by MCP server)
|
|
167
|
+
│ ├── profiles.js # Profile management
|
|
168
|
+
│ ├── session.js # Session export & persistence
|
|
169
|
+
│ └── utils.js # Formatting + segment helpers + tool grouping
|
|
170
|
+
├── test/
|
|
171
|
+
│ └── session-hash.test.js # Session hash stability tests
|
|
172
|
+
├── public/ # Vite build output (served by server.js)
|
|
173
|
+
├── vite.config.js
|
|
174
|
+
├── package.json
|
|
175
|
+
└── profiles.json # Auto-created, stores your filtering profiles
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The backend is `server.js` with a plugin system. The frontend is split into focused modules — no framework, just vanilla JS with ES module imports. Vite handles the build.
|
|
179
|
+
|
|
180
|
+
## Limitations
|
|
181
|
+
|
|
182
|
+
- Only supports the Anthropic Messages API (not OpenAI, Google, etc. — yet)
|
|
183
|
+
- Cursor IDE is not yet supported (see [Cursor support](#cursor-support))
|
|
184
|
+
- Per-segment token counts are proportionally scaled estimates, not exact per-field counts
|
|
185
|
+
- Tool filtering modifies the request body, which means Claude won't know those tools exist — this is the point, but be aware
|
|
186
|
+
- Profiles are stored in a local JSON file, not synced across machines
|
|
187
|
+
|
|
188
|
+
## Contributing
|
|
189
|
+
|
|
190
|
+
Issues and PRs welcome. The codebase is intentionally simple — one backend file, small frontend modules, and two dependencies (`ws` + `vite`). Keep it that way.
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
package/bin/jannal.js
ADDED
package/lib/plugins.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// ─── Plugin Host ──────────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Lightweight hook system for extending the Jannal proxy server.
|
|
4
|
+
// Plugins implement named hooks that the server calls at specific points.
|
|
5
|
+
// Core runs fine with zero plugins (free tier). Pro features register via plugin.
|
|
6
|
+
|
|
7
|
+
class PluginHost {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.plugins = [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
register(plugin) {
|
|
13
|
+
this.plugins.push(plugin);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Called during server startup init (load config, init data dirs, etc.) */
|
|
17
|
+
async onInit(context) {
|
|
18
|
+
for (const p of this.plugins) {
|
|
19
|
+
if (p.onInit) await p.onInit(context);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Called after server.listen() succeeds (warm up models, etc.) */
|
|
24
|
+
async onServerStart(context) {
|
|
25
|
+
for (const p of this.plugins) {
|
|
26
|
+
if (p.onServerStart) await p.onServerStart(context);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Custom API route handler. Returns true if the plugin handled the request.
|
|
32
|
+
* Plugins are tried in registration order; first handler wins.
|
|
33
|
+
*/
|
|
34
|
+
onRoute(req, res, helpers) {
|
|
35
|
+
for (const p of this.plugins) {
|
|
36
|
+
if (p.onRoute && p.onRoute(req, res, helpers)) return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Called after request is analyzed, before forwarding to Anthropic. */
|
|
42
|
+
async onRequestAnalyzed(analysis, meta) {
|
|
43
|
+
for (const p of this.plugins) {
|
|
44
|
+
if (p.onRequestAnalyzed) await p.onRequestAnalyzed(analysis, meta);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Called after response is fully received from Anthropic. */
|
|
49
|
+
async onResponseComplete(reqId, data) {
|
|
50
|
+
for (const p of this.plugins) {
|
|
51
|
+
if (p.onResponseComplete) await p.onResponseComplete(reqId, data);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Collect extra fields for the WS connect message. */
|
|
56
|
+
getConnectPayload() {
|
|
57
|
+
let extra = {};
|
|
58
|
+
for (const p of this.plugins) {
|
|
59
|
+
if (p.getConnectPayload) {
|
|
60
|
+
Object.assign(extra, p.getConnectPayload());
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return extra;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { PluginHost };
|
package/lib/tokens.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token estimation and context window logic.
|
|
3
|
+
* Isolated for testing without UI or server.
|
|
4
|
+
*
|
|
5
|
+
* @module lib/tokens
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ─── Token estimation ────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const CHARS_PER_TOKEN = 3.8;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Estimate token count from string or JSON-serializable input.
|
|
14
|
+
* Uses ~3.8 chars/token heuristic (Anthropic averages ~3.9–4 for English).
|
|
15
|
+
*
|
|
16
|
+
* @param {string|object} input - Text or object to estimate
|
|
17
|
+
* @returns {number} Estimated token count
|
|
18
|
+
*/
|
|
19
|
+
function estimateTokens(input) {
|
|
20
|
+
if (!input) return 0;
|
|
21
|
+
const str = typeof input === "string" ? input : JSON.stringify(input);
|
|
22
|
+
return Math.ceil(str.length / CHARS_PER_TOKEN);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Estimate tokens for a single tool definition.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} tool - Tool object (name, description, input_schema, etc.)
|
|
29
|
+
* @returns {number} Estimated token count
|
|
30
|
+
*/
|
|
31
|
+
function estimateToolTokens(tool) {
|
|
32
|
+
return estimateTokens(tool);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Model budget (context window size) ──────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const MODEL_BUDGETS = {
|
|
38
|
+
"gpt-4o": 128000,
|
|
39
|
+
"gpt-4-turbo": 128000,
|
|
40
|
+
"gpt-3.5": 16385,
|
|
41
|
+
"gemini": 1000000,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Known context tiers (128k, 200k, 1M). */
|
|
45
|
+
const CONTEXT_TIERS = [128000, 200000, 1000000];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the default context window size for a model.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} model - Model identifier (e.g. "claude-sonnet-4", "claude-opus-4-5")
|
|
51
|
+
* @returns {number} Context window size in tokens
|
|
52
|
+
*/
|
|
53
|
+
function getBudget(model) {
|
|
54
|
+
if (!model) return 200000;
|
|
55
|
+
const m = model.toLowerCase();
|
|
56
|
+
|
|
57
|
+
if (m.includes("1m")) return 1000000;
|
|
58
|
+
if (
|
|
59
|
+
m.includes("opus-4-5") ||
|
|
60
|
+
m.includes("opus-4-6") ||
|
|
61
|
+
m.includes("opus-4.5") ||
|
|
62
|
+
m.includes("opus-4.6")
|
|
63
|
+
)
|
|
64
|
+
return 1000000;
|
|
65
|
+
if (m.includes("claude")) return 200000;
|
|
66
|
+
|
|
67
|
+
for (const [key, budget] of Object.entries(MODEL_BUDGETS)) {
|
|
68
|
+
if (m.includes(key)) return budget;
|
|
69
|
+
}
|
|
70
|
+
return 200000;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Infer the effective context budget for a model given current token usage.
|
|
75
|
+
* Snaps to the next tier if usage exceeds the model's default.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} model - Model identifier
|
|
78
|
+
* @param {number} tokenCount - Current estimated/actual token count
|
|
79
|
+
* @returns {number} Effective context window size
|
|
80
|
+
*/
|
|
81
|
+
function inferBudget(model, tokenCount) {
|
|
82
|
+
const base = getBudget(model);
|
|
83
|
+
if (tokenCount <= base) return base;
|
|
84
|
+
for (const tier of CONTEXT_TIERS) {
|
|
85
|
+
if (tokenCount <= tier) return tier;
|
|
86
|
+
}
|
|
87
|
+
return Math.max(base, tokenCount);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Pure segment analysis (for testing) ──────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Analyze a request body and return segments with token estimates.
|
|
94
|
+
* Pure function, no side effects.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} body - Anthropic API request body (system, tools, messages)
|
|
97
|
+
* @returns {{ segments: object[], totalEstimatedTokens: number, budget: number }}
|
|
98
|
+
*/
|
|
99
|
+
function analyzeSegments(body) {
|
|
100
|
+
const segments = [];
|
|
101
|
+
const model = body.model || "unknown";
|
|
102
|
+
|
|
103
|
+
if (body.system) {
|
|
104
|
+
const text =
|
|
105
|
+
typeof body.system === "string"
|
|
106
|
+
? body.system
|
|
107
|
+
: JSON.stringify(body.system, null, 2);
|
|
108
|
+
segments.push({
|
|
109
|
+
type: "system",
|
|
110
|
+
name: "System Prompt",
|
|
111
|
+
tokens: estimateTokens(text),
|
|
112
|
+
charLength: text.length,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (body.tools && body.tools.length > 0) {
|
|
117
|
+
const toolsJson = JSON.stringify(body.tools);
|
|
118
|
+
segments.push({
|
|
119
|
+
type: "tools",
|
|
120
|
+
name: `Tools (${body.tools.length})`,
|
|
121
|
+
tokens: estimateTokens(toolsJson),
|
|
122
|
+
count: body.tools.length,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (body.messages) {
|
|
127
|
+
for (let i = 0; i < body.messages.length; i++) {
|
|
128
|
+
const msg = body.messages[i];
|
|
129
|
+
const content =
|
|
130
|
+
typeof msg.content === "string"
|
|
131
|
+
? msg.content
|
|
132
|
+
: JSON.stringify(msg.content);
|
|
133
|
+
const isToolResult =
|
|
134
|
+
Array.isArray(msg.content) &&
|
|
135
|
+
msg.content.some((c) => c.type === "tool_result");
|
|
136
|
+
const isToolUse =
|
|
137
|
+
Array.isArray(msg.content) &&
|
|
138
|
+
msg.content.some((c) => c.type === "tool_use");
|
|
139
|
+
let type = "message";
|
|
140
|
+
let name = `${msg.role} message`;
|
|
141
|
+
if (isToolResult) {
|
|
142
|
+
type = "tool_result";
|
|
143
|
+
name = "Tool Result";
|
|
144
|
+
} else if (isToolUse) {
|
|
145
|
+
type = "tool_use";
|
|
146
|
+
name = "Tool Use (assistant)";
|
|
147
|
+
}
|
|
148
|
+
segments.push({
|
|
149
|
+
type,
|
|
150
|
+
role: msg.role,
|
|
151
|
+
name,
|
|
152
|
+
tokens: estimateTokens(content),
|
|
153
|
+
charLength: content.length,
|
|
154
|
+
index: i,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const totalEstimatedTokens = segments.reduce((s, seg) => s + seg.tokens, 0);
|
|
160
|
+
const budget = inferBudget(model, totalEstimatedTokens);
|
|
161
|
+
|
|
162
|
+
return { segments, totalEstimatedTokens, budget };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Exports ────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
CHARS_PER_TOKEN,
|
|
169
|
+
estimateTokens,
|
|
170
|
+
estimateToolTokens,
|
|
171
|
+
getBudget,
|
|
172
|
+
inferBudget,
|
|
173
|
+
MODEL_BUDGETS,
|
|
174
|
+
CONTEXT_TIERS,
|
|
175
|
+
analyzeSegments,
|
|
176
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@buzzie-ai/jannal",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Intercept, visualize, and optimize LLM context windows. Proxy that sits between your AI tools and the Anthropic API.",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./server.js"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"jannal": "./bin/jannal.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"server.js",
|
|
15
|
+
"lib/",
|
|
16
|
+
"public/",
|
|
17
|
+
"profiles.json"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"dev:server": "node server.js",
|
|
21
|
+
"dev:ui": "vite",
|
|
22
|
+
"build": "vite build",
|
|
23
|
+
"start": "node server.js",
|
|
24
|
+
"preview": "vite preview",
|
|
25
|
+
"test": "node --test test/session-hash.test.js"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"llm",
|
|
29
|
+
"context-window",
|
|
30
|
+
"proxy",
|
|
31
|
+
"anthropic",
|
|
32
|
+
"claude",
|
|
33
|
+
"token-optimization",
|
|
34
|
+
"inspector",
|
|
35
|
+
"developer-tools"
|
|
36
|
+
],
|
|
37
|
+
"author": "Arvind Naidu",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://github.com/Buzzie-AI/jannal"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18.0.0"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"ws": "^8.16.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"vite": "^8.0.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,:before,:after{box-sizing:border-box;margin:0;padding:0}:root{--bg:#09090b;--bg2:#111113;--bg3:#18181b;--bg4:#27272a;--border:#ffffff0f;--text:#fafafa;--text2:#a1a1aa;--text3:#52525b;--blue:#60a5fa;--purple:#a78bfa;--green:#34d399;--orange:#fb923c;--cyan:#22d3ee;--yellow:#fbbf24;--red:#f87171;--amber:#f59e0b;--seg-system:#60a5fa;--seg-tools:#fb923c;--seg-message:#22d3ee;--seg-assistant:#34d399;--seg-tool-result:#fbbf24;--seg-tool-use:#a78bfa;--overlay-1:#ffffff03;--overlay-2:#ffffff05;--overlay-3:#ffffff08;--overlay-4:#ffffff0a;--overlay-5:#ffffff0d;--overlay-6:#ffffff0f;--overlay-8:#ffffff14;--overlay-10:#ffffff1a;--overlay-12:#ffffff1f;--overlay-15:#ffffff26;--scrollbar-thumb:#ffffff0f;--scrollbar-thumb-hover:#ffffff1f;--modal-backdrop:#000000b3;--noise-opacity:.015;--text-hover:white;--bar-seg-text:white;--bar-seg-shadow:0 1px 2px #00000080;--line-num-color:#ffffff1f;--shadow-inset:inset 0 1px 3px #0000004d;--font-ui:"Instrument Sans", -apple-system, system-ui, sans-serif;--font-mono:"JetBrains Mono", "SF Mono", "Fira Code", monospace;--radius-sm:6px;--radius-md:10px;--radius-lg:14px;--radius-xl:20px;--shadow-sm:0 1px 2px #0000004d;--shadow-md:0 4px 12px #0006;--shadow-lg:0 12px 40px #00000080;--shadow-xl:0 24px 80px #0009;--ease-out:cubic-bezier(.16, 1, .3, 1);--ease-spring:cubic-bezier(.34, 1.56, .64, 1);--duration-fast:.15s;--duration-normal:.25s;--duration-slow:.4s}html[data-theme=light]{--bg:#fafafa;--bg2:#f4f4f5;--bg3:#e4e4e7;--bg4:#d4d4d8;--border:#00000014;--text:#18181b;--text2:#52525b;--text3:#a1a1aa;--blue:#2563eb;--purple:#7c3aed;--green:#059669;--orange:#ea580c;--cyan:#0891b2;--yellow:#d97706;--red:#dc2626;--amber:#b45309;--seg-system:#2563eb;--seg-tools:#ea580c;--seg-message:#0891b2;--seg-assistant:#059669;--seg-tool-result:#d97706;--seg-tool-use:#7c3aed;--overlay-1:#00000003;--overlay-2:#00000005;--overlay-3:#00000008;--overlay-4:#0000000a;--overlay-5:#0000000d;--overlay-6:#0000000f;--overlay-8:#0000000f;--overlay-10:#00000014;--overlay-12:#0000001a;--overlay-15:#0000001f;--scrollbar-thumb:#0000001a;--scrollbar-thumb-hover:#0003;--modal-backdrop:#0000004d;--noise-opacity:0;--text-hover:var(--text);--bar-seg-text:white;--bar-seg-shadow:0 1px 2px #0000004d;--line-num-color:#0003;--shadow-inset:inset 0 1px 3px #00000014;--shadow-sm:0 1px 2px #0000000f;--shadow-md:0 4px 12px #00000014;--shadow-lg:0 12px 40px #0000001a;--shadow-xl:0 24px 80px #0000001f}html.theme-transitioning,html.theme-transitioning *,html.theme-transitioning :before,html.theme-transitioning :after{transition:background-color .3s,color .3s,border-color .3s,box-shadow .3s!important}body{background:var(--bg);color:var(--text);font-family:var(--font-ui);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;flex-direction:column;height:100vh;display:flex;overflow:hidden}::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);transition:background var(--duration-fast);border-radius:99px}::-webkit-scrollbar-thumb:hover{background:var(--scrollbar-thumb-hover)}.noise-overlay{z-index:9999;pointer-events:none;opacity:var(--noise-opacity);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E");position:fixed;inset:0}.header{z-index:100;background:var(--bg2);justify-content:space-between;align-items:center;padding:10px 20px;display:flex;position:relative}.header:after{content:"";background:var(--border);height:1px;position:absolute;bottom:0;left:0;right:0}.header-left{align-items:center;gap:10px;display:flex}.header-brand{flex-direction:column;gap:1px;display:flex}.logo{object-fit:cover;border-radius:7px;width:28px;height:28px}.header h1{letter-spacing:-.02em;font-size:14px;font-weight:800;line-height:1}.header-brand .status{font-size:9px}.header-right{align-items:center;gap:8px;display:flex}.hdr-sep{background:var(--border);flex-shrink:0;width:1px;height:18px}.hdr-metrics{align-items:center;gap:6px;display:flex}.theme-toggle{border-radius:var(--radius-sm);border:1px solid var(--overlay-8);background:var(--overlay-3);width:26px;height:26px;color:var(--text3);cursor:pointer;transition:all var(--duration-fast);flex-shrink:0;justify-content:center;align-items:center;display:flex}.theme-toggle:hover{background:var(--overlay-8);color:var(--text);border-color:var(--overlay-12)}.theme-toggle:active{transform:scale(.95)}.status{align-items:center;gap:5px;font-size:10px;font-weight:600;display:flex}.status-dot{border-radius:50%;flex-shrink:0;width:6px;height:6px}.status-dot.connected{background:var(--green);animation:2s ease-in-out infinite statusPulse;box-shadow:0 0 6px #34d39980}.status-dot.disconnected{background:var(--red)}@keyframes statusPulse{0%,to{box-shadow:0 0 6px #34d39980}50%{box-shadow:0 0 10px #34d399b3}}.req-badge{border-radius:var(--radius-sm);background:var(--overlay-3);height:26px;color:var(--text3);font-size:10px;font-weight:600;font-family:var(--font-mono);font-variant-numeric:tabular-nums;align-items:center;padding:4px 8px;display:flex}.daily-saved{border-radius:var(--radius-sm);height:26px;color:var(--text3);font-size:10px;font-weight:700;font-family:var(--font-mono);font-variant-numeric:tabular-nums;background:#22c55e0f;border:1px solid #22c55e14;align-items:center;padding:4px 8px;display:flex}.daily-saved.has-savings{color:var(--green);background:#22c55e1a;border-color:#22c55e26}.daily-cost{border-radius:var(--radius-sm);height:26px;color:var(--amber);font-size:10px;font-weight:700;font-family:var(--font-mono);font-variant-numeric:tabular-nums;background:#f59e0b14;border:1px solid #f59e0b1f;align-items:center;padding:4px 8px;display:flex}.profile-select{border-radius:var(--radius-sm);border:1px solid var(--overlay-8);background:var(--overlay-3);height:26px;color:var(--text);font-size:10px;font-weight:600;font-family:var(--font-ui);cursor:pointer;max-width:140px;transition:all var(--duration-fast);outline:none;padding:0 8px}.profile-select:hover{background:var(--overlay-6);border-color:var(--overlay-12)}.profile-select.filtering{color:var(--orange);background:#fb923c0f;border-color:#fb923c4d}.filter-badge{border-radius:var(--radius-sm);height:26px;color:var(--orange);letter-spacing:.06em;background:#fb923c1f;border:1px solid #fb923c33;align-items:center;padding:3px 6px;font-size:8px;font-weight:800;display:flex}.router-badge-wrapper{position:relative}.router-badge{border-radius:var(--radius-sm);letter-spacing:.03em;cursor:pointer;-webkit-user-select:none;user-select:none;height:26px;transition:all var(--duration-fast);align-items:center;padding:0 8px;font-size:9px;font-weight:700;display:flex}.router-badge:hover{filter:brightness(1.2)}.router-badge--off{background:var(--overlay-3);color:var(--text3);border:1px solid var(--overlay-8)}.router-badge--shadow{color:var(--purple);background:#a78bfa1a;border:1px solid #a78bfa33}.router-badge--auto{color:var(--cyan);background:#22d3ee1a;border:1px solid #22d3ee33}.router-popover{background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius-md);z-index:300;min-width:210px;padding:6px 0;display:none;position:absolute;top:calc(100% + 8px);right:0;box-shadow:0 8px 30px #00000059}.router-popover.open{display:block}.router-popover-title{color:var(--text3);text-transform:uppercase;letter-spacing:.1em;border-bottom:1px solid var(--border);margin-bottom:2px;padding:6px 12px 8px;font-size:8px;font-weight:700}.router-popover-opt{width:100%;color:var(--text);font-size:11px;font-weight:500;font-family:var(--font-ui);cursor:pointer;text-align:left;transition:background var(--duration-fast);background:0 0;border:none;align-items:center;gap:8px;padding:7px 12px;display:flex}.router-popover-opt:hover{background:var(--overlay-6)}.router-popover-opt.active{background:var(--overlay-4);font-weight:700}.router-opt-dot{border-radius:50%;flex-shrink:0;width:7px;height:7px}.router-opt-dot--off{background:var(--text3)}.router-opt-dot--shadow{background:var(--purple)}.router-opt-dot--auto{background:var(--cyan)}.router-opt-desc{color:var(--text3);margin-left:auto;font-size:9px;font-weight:400}.global-search-wrapper{position:relative}.global-search{border-radius:var(--radius-sm);border:1px solid var(--overlay-8);background:var(--overlay-3);height:26px;color:var(--text);font-size:10px;font-family:var(--font-ui);width:170px;transition:all var(--duration-fast);outline:none;padding:0 10px}.global-search::placeholder{color:var(--text3)}.global-search:focus{border-color:#60a5fa4d;width:220px;box-shadow:0 0 0 2px #60a5fa14}.global-search-results{background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius-md);z-index:200;max-height:320px;box-shadow:var(--shadow-lg);min-width:360px;margin-top:4px;display:none;position:absolute;top:100%;left:0;right:0;overflow-y:auto}.global-search-results.open{display:block}.search-result-item{border-bottom:1px solid var(--overlay-4);cursor:pointer;transition:background var(--duration-fast);padding:8px 12px}.search-result-item:hover{background:var(--overlay-6)}.search-result-item:last-child{border-bottom:none}.search-result-turn{color:var(--blue);margin-bottom:2px;font-size:10px;font-weight:700}.search-result-snippet{color:var(--text2);font-size:11px;font-family:var(--font-mono);white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.search-result-snippet mark{color:var(--text);background:#fbbf244d;border-radius:2px}.search-no-results{color:var(--text3);text-align:center;padding:12px;font-size:11px}.bar-container{padding:14px 24px 10px}.bar-outer{background:var(--bg3);border:1px solid var(--border);height:52px;box-shadow:var(--shadow-inset);transition:box-shadow var(--duration-slow), border-color var(--duration-slow);border-radius:12px;position:relative;overflow:hidden}.bar-outer:before{content:"";z-index:0;pointer-events:none;background:radial-gradient(ellipse at 50% 100%, var(--overlay-2) 0%, transparent 70%);position:absolute;inset:0}.bar-outer.pressure-high{box-shadow:var(--shadow-inset), 0 0 24px #fb923c26;border-color:#fb923c40}.bar-outer.pressure-critical{box-shadow:var(--shadow-inset), 0 0 30px #f8717133;border-color:#f871714d}.bar-inner{z-index:1;height:100%;transition:all var(--duration-slow) var(--ease-out);display:flex;position:relative}.bar-segment{cursor:pointer;justify-content:center;align-items:center;height:100%;transition:all .35s;display:flex;overflow:hidden}.bar-segment:hover{filter:brightness(1.2)}.bar-segment span{color:var(--bar-seg-text);text-shadow:var(--bar-seg-shadow);white-space:nowrap;padding:0 3px;font-size:9px;font-weight:600}.bar-empty{flex-grow:1;justify-content:center;align-items:center;display:flex}.bar-empty span{color:var(--text3);font-size:10px}.bar-marker{border-left:1px dashed var(--overlay-10);pointer-events:none;z-index:2;position:absolute;top:0;bottom:0}.bar-marker span{color:var(--text3);font-size:7px;position:absolute;top:1px;left:3px}.bar-stats{justify-content:space-between;align-items:center;margin-top:6px;padding:0 2px;display:flex}.bar-legend{flex-wrap:wrap;gap:16px;display:flex}.legend-item{color:var(--text3);cursor:pointer;align-items:center;gap:4px;font-size:10px;display:flex}.legend-dot{border-radius:2px;width:7px;height:7px}.bar-total{font-size:12px;font-weight:700;font-family:var(--font-mono);font-variant-numeric:tabular-nums}.bar-pct{background:var(--overlay-4);font-size:10px;font-weight:600;font-family:var(--font-mono);border-radius:99px;padding:1px 6px}.token-chart-container{border-radius:var(--radius-sm);background:var(--overlay-2);border:1px solid var(--border);margin-top:8px;padding:8px 12px}.token-chart-label{color:var(--text3);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px;font-size:9px;font-weight:700}.token-chart{align-items:center;gap:8px;display:flex}.token-chart-svg{width:100%;max-width:240px;height:36px}.token-chart-hint{color:var(--text3);white-space:nowrap;font-size:10px;font-family:var(--font-mono)}.main{flex:1;display:flex;overflow:hidden}.panel{flex-direction:column;display:flex;overflow:hidden}.panel-header{border-bottom:1px solid var(--border);color:var(--text2);text-transform:uppercase;letter-spacing:.06em;flex-shrink:0;justify-content:space-between;align-items:center;padding:10px 16px;font-size:11px;font-weight:800;display:flex}.panel-actions{align-items:center;gap:6px;display:flex}.panel-btn{color:var(--text3);cursor:pointer;font-size:10px;font-family:var(--font-ui);border-radius:var(--radius-sm);transition:all var(--duration-fast);background:0 0;border:none;padding:3px 8px}.panel-btn:hover:not(:disabled){color:var(--text);background:var(--overlay-6)}.panel-btn:active:not(:disabled){transform:scale(.97)}.panel-btn:disabled{opacity:.5;cursor:not-allowed}.export-dropdown{position:relative}.export-menu{background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius-sm);z-index:100;min-width:90px;box-shadow:var(--shadow-md);margin-top:4px;padding:4px;display:none;position:absolute;top:100%;right:0}.export-menu.open{flex-direction:column;gap:2px;display:flex}.export-option{color:var(--text);cursor:pointer;font-size:11px;font-family:var(--font-ui);text-align:left;transition:background var(--duration-fast);background:0 0;border:none;border-radius:4px;padding:6px 10px}.export-option:hover{background:var(--overlay-8)}.panel-body{flex:1;overflow-y:auto}.reqs-panel{border-right:1px solid var(--border);background:var(--bg2);width:320px}.req-card{border-bottom:1px solid var(--border);cursor:pointer;transition:background var(--duration-fast), transform var(--duration-fast);padding:6px 12px}.req-card:hover{background:var(--overlay-2)}.req-card:active{transform:scale(.99)}.req-card.selected{border-left:3px solid var(--amber);background:#f59e0b0d;padding-left:9px}.req-card-head{justify-content:space-between;align-items:center;display:flex}.req-label{color:var(--text);font-size:11px;font-weight:700}.req-tokens{font-size:10px;font-weight:600;font-family:var(--font-mono);font-variant-numeric:tabular-nums}.req-mini-bar{background:var(--bg);border-radius:99px;height:3px;margin-top:2px;overflow:hidden}.req-mini-fill{height:100%;transition:width .3s var(--ease-out);box-shadow:0 0 4px var(--overlay-5);border-radius:99px}.req-meta{color:var(--text3);font-size:9px;font-family:var(--font-mono);gap:6px;margin-top:2px;display:flex}.req-io{color:var(--green);font-weight:600}.req-cost-inline{color:var(--amber);font-weight:600}.view-toggle{transition:all var(--duration-fast);font-weight:600!important}.view-toggle.active{background:#f59e0b1a;color:var(--amber)!important}.group-card{margin-bottom:2px}.group-header{cursor:pointer;transition:background var(--duration-fast);border-bottom:1px solid var(--border);background:linear-gradient(180deg, var(--overlay-2) 0%, transparent 100%);align-items:center;gap:8px;padding:10px 14px;display:flex}.group-header:hover{background:var(--overlay-3)}.group-chevron{color:var(--text3);transition:transform var(--duration-normal) var(--ease-spring);text-align:center;flex-shrink:0;width:14px;font-size:10px}.group-chevron.expanded{transform:rotate(90deg)}.group-title{color:var(--text);flex:1;min-width:0;font-size:12px;font-weight:700}.group-summary{font-size:10px;font-family:var(--font-mono);font-variant-numeric:tabular-nums;align-items:center;gap:10px;display:flex}.group-req-count{background:var(--overlay-6);color:var(--text2);border-radius:4px;padding:2px 8px;font-weight:600}.group-cost{color:var(--amber);font-weight:600}.group-tokens{color:var(--text3)}.group-children{overflow:hidden}.group-children.collapsed{display:none}.group-session-label{color:var(--text3);text-transform:uppercase;letter-spacing:.05em;align-items:center;gap:6px;margin-left:14px;padding:4px 14px 2px 26px;font-size:9px;font-weight:700;display:flex}.session-pill{letter-spacing:.03em;text-transform:capitalize;border-radius:4px;padding:2px 8px;font-size:9px;font-weight:700;display:inline-block}.session-pill.main{color:var(--purple);background:#a78bfa26}.session-pill.subagent{color:var(--cyan);background:#22d3ee26}.group-children .req-card{border-left:2px solid var(--overlay-6);margin-left:14px;padding-left:26px}.group-children .req-card.selected{border-left:2px solid var(--amber);padding-left:26px}.group-time{color:var(--text3);font-size:9px;font-family:var(--font-mono);padding:2px 14px 8px}.detail-panel{background:var(--bg);flex:1}.segment-row{border-bottom:1px solid var(--overlay-3);transition:background var(--duration-fast);cursor:pointer;align-items:center;gap:10px;padding:12px 18px;display:flex}.segment-row:hover{background:var(--overlay-3)}.seg-color{border-radius:3px;flex-shrink:0;width:5px;height:36px}.seg-info{flex:1;min-width:0}.seg-name{font-size:12px;font-weight:600}.seg-sub{color:var(--text3);white-space:nowrap;text-overflow:ellipsis;max-width:350px;margin-top:1px;font-size:10px;overflow:hidden}.seg-tokens{font-size:12px;font-weight:700;font-family:var(--font-mono);font-variant-numeric:tabular-nums;text-align:right;min-width:60px}.seg-pct{color:var(--text3);text-align:right;min-width:40px;font-size:10px;font-family:var(--font-mono)}.seg-bar{background:var(--bg3);border-radius:99px;flex-shrink:0;width:80px;height:4px;overflow:hidden}.seg-bar-fill{border-radius:99px;height:100%}.seg-expand-hint{color:var(--text3);background:var(--overlay-3);white-space:nowrap;border-radius:4px;padding:2px 6px;font-size:9px}.empty{height:100%;color:var(--text3);flex-direction:column;justify-content:center;align-items:center;display:flex}.empty-icon{opacity:.3;margin-bottom:12px;font-size:40px}.empty h2{color:var(--text2);margin-bottom:4px;font-size:14px;font-weight:700}.empty p{text-align:center;max-width:300px;font-size:12px;line-height:1.5}.copy-command-btn{border:1px solid var(--overlay-12);background:var(--overlay-4);color:var(--cyan);cursor:pointer;font-size:10px;font-family:var(--font-ui);border-radius:4px;margin-left:6px;padding:2px 8px}.copy-command-btn:hover{background:#22d3ee1f;border-color:#22d3ee40}.stats-grid{grid-template-columns:repeat(3,1fr);gap:8px;padding:12px 16px;display:grid}.stats-grid:empty{display:none}.usage-box{border-radius:var(--radius-md);background:var(--bg2);border:1px solid var(--border);padding:10px 12px}.usage-row{justify-content:space-between;align-items:center;padding:2px 0;font-size:11px;display:flex}.usage-label{color:var(--text2)}.usage-value{font-weight:700;font-family:var(--font-mono);font-variant-numeric:tabular-nums}.usage-value.actual{color:var(--green)}.usage-value.estimated{color:var(--text2)}.filter-box{border-radius:var(--radius-md);background:#fb923c0f;border:1px solid #fb923c26;padding:10px 12px}.filter-box-title{color:var(--orange);margin-bottom:6px;font-size:11px;font-weight:700}.warning-box{border-radius:var(--radius-md);background:#fbbf240f;border:1px solid #fbbf2426;padding:10px 12px}.warning-box-title{color:var(--yellow);margin-bottom:6px;font-size:11px;font-weight:700}.router-box{border-radius:var(--radius-md);background:#a78bfa0d;border:1px solid #a78bfa26;padding:10px 12px}.router-box-title{color:var(--purple);margin-bottom:6px;font-size:11px;font-weight:700}.router-mode-shadow{color:var(--purple)}.router-mode-auto{color:var(--cyan)}.router-mode-off{color:var(--text3)}.router-shadow-note{color:var(--text3);border-top:1px solid #a78bfa1f;margin-top:6px;padding-top:6px;font-size:9px;font-style:italic}.premium-locked{opacity:.5;cursor:not-allowed}.premium-locked-msg{color:var(--text3);text-align:center;padding:8px 0;font-size:10px;line-height:1.5}.router-box.premium-locked{border-style:dashed}.router-popover-opt.premium-locked{pointer-events:none}.daily-saved.premium-locked{opacity:.5;font-size:10px}.modal-overlay{z-index:1000;background:var(--modal-backdrop);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);justify-content:center;align-items:center;padding:32px;display:none;position:fixed;inset:0}.modal-overlay.open{display:flex}.modal{background:var(--bg2);border:1px solid var(--overlay-8);border-radius:var(--radius-xl);width:100%;max-width:900px;height:85vh;box-shadow:var(--shadow-xl);animation:modal-in var(--duration-normal) var(--ease-spring);flex-direction:column;display:flex;overflow:hidden}.modal-header{border-bottom:1px solid var(--border);background:var(--overlay-2);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);flex-shrink:0;align-items:center;gap:12px;padding:16px 20px;display:flex}.modal-color-bar{border-radius:2px;width:4px;height:28px}.modal-title{flex:1}.modal-title h2{font-size:15px;font-weight:700}.modal-title .modal-meta{color:var(--text3);margin-top:2px;font-size:11px}.modal-stats{align-items:center;gap:12px;display:flex}.modal-stat{text-align:center}.modal-stat-value{font-size:14px;font-weight:800;font-family:var(--font-mono);font-variant-numeric:tabular-nums}.modal-stat-label{color:var(--text3);text-transform:uppercase;letter-spacing:.05em;font-size:9px}.modal-close{border-radius:var(--radius-sm);cursor:pointer;background:var(--overlay-6);width:32px;height:32px;color:var(--text2);transition:all var(--duration-fast);border:none;justify-content:center;align-items:center;font-size:18px;display:flex}.modal-close:hover{background:var(--overlay-12);color:var(--text-hover)}.modal-close:active{transform:scale(.97)}.modal-toolbar{border-bottom:1px solid var(--border);flex-shrink:0;align-items:center;gap:6px;padding:10px 20px;display:flex}.modal-toolbar button{cursor:pointer;font-size:11px;font-weight:600;font-family:var(--font-ui);color:var(--text2);transition:all var(--duration-fast);background:0 0;border:none;border-radius:99px;padding:5px 14px}.modal-toolbar button:hover{background:var(--overlay-6);color:var(--text-hover)}.modal-toolbar button:active{transform:scale(.97)}.modal-toolbar button.active{color:var(--blue);background:#60a5fa1f;box-shadow:inset 0 0 0 1px #60a5fa33}.modal-toolbar .spacer{flex:1}.modal-toolbar .search-box{border:1px solid var(--overlay-8);background:var(--overlay-3);color:var(--text);width:180px;font-size:11px;font-family:var(--font-ui);transition:all var(--duration-fast);border-radius:99px;outline:none;padding:5px 12px}.modal-toolbar .search-box:focus{border-color:#60a5fa66;box-shadow:0 0 0 3px #60a5fa1a}.modal-body{flex:1;min-height:0;padding:0;overflow:auto}.modal-content{font-family:var(--font-mono);color:var(--text);white-space:pre-wrap;word-break:break-word;tab-size:2;padding:12px 20px;font-size:11px;line-height:1.3}.modal-content .line{border-left:1px solid var(--overlay-4);padding:1px 0 1px 48px;display:block;position:relative}.modal-content .line:nth-child(2n){background:var(--overlay-1)}.modal-content .line:hover{background:var(--overlay-3)}.modal-content .line-num{text-align:right;width:40px;color:var(--line-num-color);-webkit-user-select:none;user-select:none;padding-right:8px;font-size:10px;position:absolute;left:0}.modal-content .highlight{background:#fbbf2433;border-radius:2px}.modal-tools{padding:16px 20px}.modal-tools-header{border-radius:var(--radius-md);background:var(--overlay-2);justify-content:space-between;align-items:center;margin-bottom:12px;padding:8px 12px;display:flex}.modal-tools-header-left{color:var(--text2);font-size:11px}.modal-tools-header-left strong{color:var(--text)}.tool-section-header{color:var(--text3);text-transform:uppercase;letter-spacing:.06em;margin-top:4px;padding:12px 4px 6px;font-size:10px;font-weight:800}.tool-group{border:1px solid var(--overlay-6);border-radius:var(--radius-md);margin-bottom:10px;overflow:hidden}.tool-group-header{background:var(--overlay-3);cursor:pointer;transition:background var(--duration-fast);align-items:center;gap:8px;padding:8px 12px;display:flex}.tool-group-header:hover{background:var(--overlay-5)}.tool-group-checkbox,.tool-card input[type=checkbox]{appearance:none;border:1.5px solid var(--text3);cursor:pointer;width:16px;height:16px;transition:all var(--duration-fast);background:0 0;border-radius:4px;flex-shrink:0;position:relative}.tool-group-checkbox:checked,.tool-card input[type=checkbox]:checked{background:var(--blue);border-color:var(--blue)}.tool-group-checkbox:checked:after,.tool-card input[type=checkbox]:checked:after{content:"";border:2px solid #fff;border-width:0 2px 2px 0;width:5px;height:9px;position:absolute;top:1px;left:4px;transform:rotate(45deg)}.tool-group-chevron{color:var(--text3);transition:transform var(--duration-normal) var(--ease-spring);-webkit-user-select:none;user-select:none;flex-shrink:0;font-size:9px}.tool-group-chevron.expanded{transform:rotate(90deg)}.tool-group-title{flex-direction:column;flex:1;gap:2px;min-width:0;display:flex}.tool-group-name{color:var(--purple);font-size:12px;font-weight:700}.tool-group-meta{color:var(--text3);font-size:10px}.tool-group-actions{flex-shrink:0;gap:4px;display:flex}.tool-group-body{padding:8px}.tool-group-body.collapsed{display:none}.tool-card{border-radius:var(--radius-sm);background:var(--overlay-2);border:1px solid var(--overlay-4);transition:all var(--duration-fast);align-items:center;gap:10px;margin-bottom:4px;padding:8px 12px;display:flex}.tool-card:hover{background:var(--overlay-5);box-shadow:var(--shadow-sm)}.tool-card-info{flex:1;min-width:0}.tool-card-name{color:var(--orange);font-size:12px;font-weight:700}.tool-card-desc{color:var(--text3);white-space:nowrap;text-overflow:ellipsis;margin-top:1px;font-size:10px;overflow:hidden}.tool-card-tokens{color:var(--text3);font-size:10px;font-weight:600;font-family:var(--font-mono);font-variant-numeric:tabular-nums;white-space:nowrap}.never-used-tag{color:var(--text3);background:var(--overlay-4);vertical-align:middle;border-radius:3px;margin-left:6px;padding:1px 5px;font-size:8px;font-weight:600}.save-profile-bar{border-radius:var(--radius-md);background:var(--overlay-2);border:1px dashed var(--overlay-10);align-items:center;gap:8px;margin-top:12px;padding:10px 12px;display:flex}.save-profile-bar input{border-radius:var(--radius-sm);border:1px solid var(--overlay-10);background:var(--overlay-4);color:var(--text);font-size:11px;font-family:var(--font-ui);transition:border-color var(--duration-fast);outline:none;flex:1;padding:6px 10px}.save-profile-bar input:focus{border-color:#60a5fa66;box-shadow:0 0 0 3px #60a5fa1a}.save-profile-bar button{border-radius:var(--radius-sm);cursor:pointer;font-size:11px;font-weight:700;font-family:var(--font-ui);transition:all var(--duration-fast);border:none;padding:6px 14px}.save-profile-bar button:active{transform:scale(.97)}.btn-primary{color:var(--blue);background:#60a5fa33}.btn-primary:hover{background:#60a5fa4d}.btn-secondary{background:var(--overlay-6);color:var(--text2)}.btn-secondary:hover{background:var(--overlay-10);color:var(--text-hover)}.savings-banner{border-radius:var(--radius-sm);color:var(--green);background:#34d39914;border:1px solid #34d39926;margin-top:8px;padding:8px 12px;font-size:11px;font-weight:600}@keyframes pulse{0%,to{opacity:1}50%{opacity:.4}}.waiting{animation:2s ease-in-out infinite pulse}@keyframes modal-in{0%{opacity:0;transform:scale(.96)translateY(8px)}to{opacity:1;transform:scale(1)translateY(0)}}@keyframes fadeInUp{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.header{animation:fadeInUp var(--duration-normal) var(--ease-out) both}.bar-container{animation:fadeInUp var(--duration-normal) var(--ease-out) 60ms both}.reqs-panel{animation:fadeInUp var(--duration-normal) var(--ease-out) .12s both}.detail-panel{animation:fadeInUp var(--duration-normal) var(--ease-out) .18s both}button:active{transform:scale(.97)}
|