Aster Vale commited on
Commit
2dee62e
·
1 Parent(s): 0161d7a

update: Per-Job pricing to $0.033/estimate — 1,000 jobs = $33 vs $15 Pro subscription

Browse files
BUILD-READINESS.md ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LoomLens Live — Security & Build-Readiness Audit
2
+ **Date:** 2026-04-04
3
+ **Status:** Internal — Aster Vale
4
+ **Scope:** loomlens-sidebar.html, loomlens-engine.js, Signal Loom API estimate endpoints
5
+
6
+ ---
7
+
8
+ ## Executive Summary
9
+
10
+ LoomLens Live has a strong foundation — the local estimation engine works, the cluster model is sound, the free tier is enforced server-side at 50 calls/hr per IP, and privacy is baked in (no prompts logged server-side). **Fixed as of 2026-04-04 build:**
11
+ 1. ✅ Billing is now wired — `POST /v1/estimate` is called on "Run Estimate" click (the billable event). Typing previews use the local engine (always free, unlimited).
12
+ 2. ✅ Free daily limit now only counts on explicit "Run Estimate" clicks (not per keystroke).
13
+ 3. ✅ Server-side IP rate limit (50/hr) on `/estimate/free` provides real abuse protection.
14
+ 4. ✅ Quota display updates from API response headers after each run. Security is acceptable for a developer-facing product with the mitigations noted below.
15
+
16
+ ---
17
+
18
+ ## 1. Security Audit
19
+
20
+ ### 1.1 API Key Storage — localStorage ⚠️ LOW-MEDIUM
21
+
22
+ **Finding:** API key stored in plaintext in `localStorage` (`llive_api_key`).
23
+
24
+ **Risk:** Any XSS vector in the host page (OpenClaw webchat) could exfiltrate the key. In a self-hosted OpenClaw environment this is low risk. In a shared or untrusted environment it is medium.
25
+
26
+ **Mitigations:**
27
+ - [x] Key is used only for API calls to `api.signallloomai.com` (not exposed to arbitrary origins)
28
+ - [ ] Add a warning in the UI: *"For production: use a dedicated API key with minimal permissions. Do not use your primary account key."*
29
+ - [ ] Future: hash key before storing, use session-scoped tokens instead
30
+
31
+ **Acceptable for:** v1 developer launch (users control their own environment).
32
+ **Required before:** Embedding on untrusted public websites.
33
+
34
+ ### 1.2 Free Tier Enforcement — Client-Side Only ⚠️ MEDIUM
35
+
36
+ **Finding:** Free daily limit (3/day) is tracked in `localStorage`. A user can clear localStorage to get unlimited free estimates.
37
+
38
+ **Current state:** `localStorage.getItem('llive_free_used')` is the only enforcement.
39
+
40
+ **Mitigation options:**
41
+
42
+ | Option | Effort | Effectiveness |
43
+ |--------|--------|---------------|
44
+ | Rely on server-side `/estimate/free` IP rate limit (50/hr) | None | ✅ Already in place |
45
+ | Track free count server-side per IP + require API call to increment | High | ✅ Most robust |
46
+ | Accept client-side only for v1, disclose honestly | Low | ⚠️ User can bypass |
47
+
48
+ **Recommendation:** For v1, note in the UI that the 3/day free limit is a courtesy. The server-side IP rate limit of 50/hr on `/estimate/free` already provides real abuse protection. If the user clears localStorage, they hit the IP limit at 50/hr which is more restrictive than 3/day anyway.
49
+
50
+ ### 1.3 API Endpoint Rate Limiting ✅ GOOD
51
+
52
+ **Finding:** `/v1/estimate/free` is IP-rate-limited to 50 calls/hour per IP.
53
+ **`/v1/estimate`** (billed) checks quota via `check_estimate_limit()` before allowing.
54
+ Both are server-enforced. ✅
55
+
56
+ ### 1.4 Prompt Privacy ✅ GOOD
57
+
58
+ **Finding:** The Signal Loom API does NOT log prompts or job descriptions server-side. Only aggregate model usage and cost estimates are recorded (`record_loomlens_estimate` stores: job_id, api_key_id, email, num_agents, complexity, model costs, IP). No prompt content. ✅
59
+
60
+ ### 1.5 CORS / API Exposure ⚠️ LOW
61
+
62
+ **Finding:** The API (`api.signallloomai.com`) must accept cross-origin requests from any domain where the sidebar is embedded. This is standard for API products.
63
+
64
+ **Mitigation:** API keys are sent as `Authorization: Bearer` headers (not URL parameters), so they don't appear in server access logs. ✅
65
+
66
+ ---
67
+
68
+ ## 2. Functional Gaps (Must-Fix)
69
+
70
+ ### 2.1 ✅ FIXED — Billing Now Wired (2026-04-04)
71
+
72
+ **Implementation:**
73
+ - `estimate()` → local engine only (free preview, unlimited typing)
74
+ - `runEstimate()` → calls `POST /v1/estimate` (billed per-call) on "Run Estimate" click
75
+ - Free users: unlimited local previews, "Run Estimate" prompts for API key
76
+ - API-connected users: local previews (free), bill on "Run Estimate" click
77
+ - Quota updated from `X-Estimate-Limit` / `X-Estimate-Remaining` response headers
78
+
79
+ **Verified in code:**
80
+ - `estimate()` no longer calls API on keystroke ✅
81
+ - `runEstimate()` calls `POST /v1/estimate` ✅
82
+ - Free limit only decremented on `runEstimate()` (not keystrokes) ✅
83
+ - Server-side IP rate limit (50/hr) on `/estimate/free` ✅
84
+
85
+ ### 2.2 ✅ FIXED — Free Tier UX (2026-04-04)
86
+
87
+ - Counter shows **-3, -2, -1** (countdown, negative = free runs remaining)
88
+ - On 4th click: alert prompts to connect API key for paid runs
89
+ - Local engine previews are unlimited (no limit on typing)
90
+ - Free counter resets at midnight UTC
91
+ - Server-side IP rate limit (50/hr) on `/estimate/free` provides abuse protection
92
+
93
+ ### 2.3 ⚠️ OPEN: Model Dispatch Not Tested End-to-End
94
+
95
+ **Finding:** `runEstimate()` fires `CustomEvent('loomlens:run')` + `postMessage('loomlens_run')` to parent. No handler verified in OpenClaw.
96
+
97
+ **Open questions:**
98
+ - Does OpenClaw webchat listen for these events?
99
+ - Does `before_model_resolve` hook intercept model selection?
100
+ - Needs end-to-end test in live OpenClaw environment
101
+
102
+ **Status:** Not yet verified — this is the remaining P0 before publish.
103
+
104
+ ---
105
+
106
+ ## 3. Build-Ready Checklist
107
+
108
+ ### 3.1 Security
109
+ - [x] Server-side rate limit on free endpoint (50/hr IP) ✅
110
+ - [x] No prompt content logged ✅
111
+ - [x] API key in Authorization header (not URL) ✅
112
+ - [ ] Add key security warning in UI
113
+ - [ ] Audit CORS config on api.signallloomai.com
114
+
115
+ ### 3.2 Functional
116
+ - [x] Local estimation engine works ✅ (verified in Node.js)
117
+ - [x] Free tier (3/day, per-run only, not per-keystroke) ✅
118
+ - [x] API key connection flow ✅
119
+ - [x] Cluster model + scoring ✅
120
+ - [x] **Billing wired: `POST /v1/estimate` on "Run Estimate" click** ✅ (2026-04-04)
121
+ - [ ] **Test model dispatch end-to-end** (critical) — `loomlens:run` event needs handler
122
+ - [x] Better CTA when free tier exhausted ✅ (error state with signup links)
123
+ - [ ] Keyboard shortcut Cmd/Ctrl+L works
124
+
125
+ ### 3.3 Revenue / Rev-Share
126
+ - [x] Rev-share program designed (Loom Partners: 23% / 28%) ✅ `REV_SHARE_ANALYSIS.md`
127
+ - [ ] Developer landing page explaining rev-share program
128
+ - [ ] Mechanism for partners to generate and distribute API keys
129
+ - [ ] Payout mechanism (monthly credit vs wire threshold)
130
+ - [ ] Partner dashboard for tracking usage
131
+
132
+ ### 3.4 Distribution
133
+ - [ ] `dist/loomlens-sidebar.html` deployable to signalloomai.com
134
+ - [ ] HF Space deployment (hf.space/astar-aehs/loomlens-live)
135
+ - [ ] Chrome Extension injection setup
136
+
137
+ ---
138
+
139
+ ## 4. Rev-Share Integration Status
140
+
141
+ The rev-share program (`LOOM PARTNERS`) is **designed but not yet implemented as a product**.
142
+
143
+ | Component | Status |
144
+ |-----------|--------|
145
+ | Rate structure (23% / 28%) | ✅ Designed |
146
+ | Tier qualification (10K / 100K mins/mo) | ✅ Designed |
147
+ | Payout mechanism (monthly credit) | ⚠️ Designed, not built |
148
+ | Partner API key generation | ⚠️ Not built |
149
+ | Partner dashboard / usage tracking | ⚠️ Not built |
150
+ | LoomLens Live as developer acquisition funnel | ✅ Valid product strategy |
151
+
152
+ **Correct framing for v1:**
153
+ LoomLens Live → drives Signal Loom API key signups → users pay for actual transcription usage → Signal Loom retains revenue. Rev-share (Loom Partners) is a separate product for platforms/partners who want to embed SLS and earn credit on their users' usage. That is a larger enterprise conversation.
154
+
155
+ ---
156
+
157
+ ## 5. Recommended Action Plan
158
+
159
+ ### P0 — Must Fix Before Publish
160
+ 1. [ ] **Clarify billing model:** Either wire `POST /v1/estimate` calls OR explicitly position LoomLens Live as a free developer tool that drives API key adoption. Both are valid — pick one and document it.
161
+ 2. [ ] **Test model dispatch:** Verify the `before_model_resolve` hook works with actual model override. This is the core value prop.
162
+
163
+ ### P1 — Before Developer Launch
164
+ 3. [ ] Add API key security warning in the sidebar footer
165
+ 4. [ ] Improve CTA when free tier is exhausted (make it the primary action)
166
+ 5. [ ] Deploy `loomlens-sidebar.html` to signalloomai.com as the LoomLens Live demo
167
+ 6. [ ] Write LoomLens Live section on signallloomai.com/pricing page
168
+
169
+ ### P2 — Rev-Share (Later)
170
+ 7. [ ] Partner API key distribution mechanism
171
+ 8. [ ] Partner dashboard for usage tracking
172
+ 9. [ ] Loom Partners landing page
173
+
SKILL.md ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LoomLens Live — OpenClaw Skill
2
+
3
+ **Skill Name:** `loomlens-live`
4
+ **Command:** `/loomlens`
5
+ **Type:** OpenClaw AgentSkill (sidebar panel skill)
6
+ **Phase:** Ready to install
7
+
8
+ ---
9
+
10
+ ## What It Does
11
+
12
+ Opens the **LoomLens Live sidebar panel** inside OpenClaw. The panel shows real-time cost estimates for the current prompt across optimized model clusters, recommends the best model within each cluster, and lets you dispatch to any model with one click.
13
+
14
+ ---
15
+
16
+ ## Features
17
+
18
+ - **6 model clusters** — Flash Reasoning, Fast General, Balanced Coding, Power, Vision
19
+ - **Within-group optimization** — recommends cheapest model that clears the capability bar
20
+ - **Per-call billing** — type freely for free; only "Run Estimate" deducts from Signal Loom account
21
+ - **3 free runs/day** — local preview is unlimited; runs against your Signal Loom key
22
+ - **Drag & drop** — drop any text into the sidebar to estimate
23
+ - **Keyboard shortcut** — `Cmd/Ctrl+L` to open from anywhere
24
+ - **Model dispatch** — select any model and it overrides the next prompt's model via `before_model_resolve` hook
25
+ - **Rev-share ready** — developers earn 23–28% revenue share on estimate calls
26
+
27
+ ---
28
+
29
+ ## Billing Model
30
+
31
+ | Action | Cost |
32
+ |--------|------|
33
+ | Type a prompt (local preview) | Free — no API call |
34
+ | "Run Estimate" (no API key) | Prompts to connect key |
35
+ | "Run Estimate" (API key connected) | Per-call billing — deducts from Signal Loom quota |
36
+ | Rev-share payout | 23% (Loom Partner) / 28% (Loom Elite) on net estimate revenue |
37
+
38
+ ---
39
+
40
+ ## Freemium
41
+
42
+ | Tier | Limits | Auth |
43
+ |------|--------|------|
44
+ | Free | 3 runs / 24h (local preview unlimited) | None |
45
+ | API Key | Unlimited runs | Signal Loom API key |
46
+
47
+ ---
48
+
49
+ ## Rev-Share: Loom Partners Program
50
+
51
+ | Tier | Rate | Qualification |
52
+ |------|------|--------------|
53
+ | Loom Partner | 23% | 10K mins/month minimum |
54
+ | Loom Elite | 28% | 100K mins/month or signed agreement |
55
+
56
+ See `REV_SHARE_ANALYSIS.md` in the Signal Loom API workspace for full margin and payout analysis.
57
+
58
+ ---
59
+
60
+ ## Model Clusters
61
+
62
+ | Cluster | Models | Best For |
63
+ |---------|--------|----------|
64
+ | **Flash Reasoning** | MiniMax M2, Gemini 2.5 Flash, Claude Haiku, DeepSeek v3 | Q&A, summaries, simple tasks |
65
+ | **Fast General** | GPT-4o Mini, Gemini 2.0 Flash, xAI Grok-3 | Fast general purpose |
66
+ | **Balanced Coding** | Claude Sonnet 4, GPT-4o, Claude 3.5 Sonnet | Medium-complexity code |
67
+ | **Power / Architecture** | Claude Opus 4, Claude Code | Complex builds, architecture |
68
+ | **Vision / Multimodal** | GPT-4o, Claude 3.5 | Images, PDFs, documents |
69
+
70
+ ---
71
+
72
+ ## Scoring
73
+
74
+ ```
75
+ score = (capability_fit × 0.35) + (cost_efficiency × 0.35) + (speed × 0.30)
76
+ ```
77
+
78
+ Within each cluster: cheapest model that scores ≥65 gets the **💡 Best in group** badge.
79
+
80
+ ---
81
+
82
+ ## Installation
83
+
84
+ ### Option A: Workspace Hook (Quick)
85
+
86
+ ```bash
87
+ cp -r signal-loom-loomlens/ ~/.openclaw/workspace/hooks/loomlens-live/
88
+ ```
89
+
90
+ ### Option B: OpenClaw Skill Install (When supported)
91
+
92
+ ```bash
93
+ openclaw skills install ./signal-loom-loomlens
94
+ ```
95
+
96
+ ---
97
+
98
+ ## Files
99
+
100
+ ```
101
+ signal-loom-loomlens/
102
+ ├── SKILL.md — This file
103
+ ├── loomlens-sidebar.html — Self-contained sidebar panel (deploy anywhere)
104
+ ├── loomlens-engine.js — Estimation engine (pure JS, no dependencies)
105
+ ├── loomlens-clusters.js — Cluster definitions + scoring
106
+ ├── loomlens-openclaw-plugin.ts — Plugin with before_model_resolve hook
107
+ └── dist/ — Pre-built deploy artifacts
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Branding
113
+
114
+ - **Attribution:** "Powered by Signal Loom AI ✦" in sidebar footer
115
+ - **External link:** "Get a Signal Loom API key →" → signallloomai.com/signup.html
build.md ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: "Build with LoomLens"
3
+ description: "Embed cost intelligence in your AI product. Free to start. Developers earn 23–28% revenue share on every estimate."
4
+ layout: "loomlens/list"
5
+ ---
6
+
7
+ ## Embed Cost Intelligence in 10 Minutes
8
+
9
+ Free to embed. Developers earn **23–28% revenue share** on every estimate routed through their API key.
10
+
11
+ ---
12
+
13
+ ## Three Ways to Embed
14
+
15
+ ### Widget — 10 Minutes
16
+
17
+ Copy and paste a few lines of HTML into your prompt input area. Your users see cost estimates before every run. No backend changes required.
18
+
19
+ ```html
20
+ <div id="loomlens-widget"></div>
21
+ <script src="https://cdn.signalloomai.com/loomlens.js?api_key=YOUR_API_KEY"></script>
22
+ ```
23
+
24
+ **What you get:**
25
+ - Pre-flight cost estimates across 24+ AI models
26
+ - Live savings display
27
+ - Model recommendation
28
+ - No server-side changes needed
29
+
30
+ ---
31
+
32
+ ### REST API — 1–4 Hours
33
+
34
+ For full control, call the LoomLens REST API from your backend.
35
+
36
+ **Base URL:** `https://api.signalloomai.com`
37
+
38
+ **Endpoint:** `POST /v1/estimate`
39
+
40
+ **Authentication:** Bearer token — your API key
41
+
42
+ **Request:**
43
+ ```json
44
+ {
45
+ "job_description": "Translate this customer email to French",
46
+ "prompt_tokens": 250,
47
+ "completion_tokens": 180,
48
+ "complexity": "standard",
49
+ "num_agents": 1,
50
+ "estimated_runtime_minutes": 1
51
+ }
52
+ ```
53
+
54
+ **Response:**
55
+ ```json
56
+ {
57
+ "estimates": [
58
+ {
59
+ "model": "gpt-4o",
60
+ "estimated_cost": 0.045,
61
+ "estimated_tokens": 430,
62
+ "currency": "USD",
63
+ "routing_tier": "recommended"
64
+ },
65
+ {
66
+ "model": "gpt-4o-mini",
67
+ "estimated_cost": 0.003,
68
+ "estimated_tokens": 430,
69
+ "currency": "USD",
70
+ "routing_tier": "cheapest"
71
+ }
72
+ ],
73
+ "savings_vs_default": 0.042,
74
+ "savings_percent": 93
75
+ }
76
+ ```
77
+
78
+ ---
79
+
80
+ ### SDK — 1 Day
81
+
82
+ Full SDKs for Node.js and Python. White-label ready. Full control over appearance and behavior.
83
+
84
+ ```bash
85
+ npm install @signalloomai/sdk
86
+ ```
87
+
88
+ ```javascript
89
+ import { LoomLens } from '@signalloomai/sdk';
90
+
91
+ const loom = new LoomLens({ apiKey: 'YOUR_API_KEY' });
92
+
93
+ const result = await loom.estimate({
94
+ promptTokens: 250,
95
+ completionTokens: 180,
96
+ complexity: 'standard',
97
+ numAgents: 1,
98
+ estimatedRuntimeMinutes: 1
99
+ });
100
+
101
+ console.log(result.savingsVsDefault); // e.g. 0.042
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Revenue Share
107
+
108
+ Every estimate and transcription routed through your API key earns you revenue share. No separate integration required.
109
+
110
+ | Tier | Monthly Volume | Rev-Share |
111
+ |---|---|---|
112
+ | **Loom Partner** | 10K+ estimates/mo | **23%** |
113
+ | **Loom Elite** | 100K+ estimates/mo | **28%** |
114
+
115
+ Revenue share applies to **both** LoomLens estimates **and** Signal Loom Transcription.
116
+
117
+ ---
118
+
119
+ ## Track Your Earnings
120
+
121
+ Check your API key performance at any time:
122
+
123
+ ```
124
+ GET https://api.signalloomai.com/v1/analytics/loomlens/lookup?key_id=YOUR_KEY_ID
125
+ ```
126
+
127
+ Response:
128
+ ```json
129
+ {
130
+ "found": true,
131
+ "total_estimates": 1247,
132
+ "total_savings_usd": 43.21,
133
+ "average_savings_per_job": 0.035,
134
+ "free_calls": 892,
135
+ "paid_calls": 355,
136
+ "top_model": "gpt-4o-mini"
137
+ }
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Get Your API Key
143
+
144
+ **Free to join. Revenue starts from day one.**
145
+
146
+ 1. Sign up at [signalloomai.com](https://signalloomai.com)
147
+ 2. Go to **Dashboard → API Keys**
148
+ 3. Copy your key
149
+ 4. Start embedding
150
+
151
+ No credit card required.
152
+
153
+ ---
154
+
155
+ ## Revenue Share FAQ
156
+
157
+ **Q: How is revenue share calculated?**
158
+ A: Every time a user makes an estimate through your API key, we track the savings they realized vs. picking the most expensive model. You earn a percentage of the estimated value routed.
159
+
160
+ **Q: When do I get paid?**
161
+ A: Monthly, via Stripe, when your balance exceeds the minimum payout threshold.
162
+
163
+ **Q: Does transcription count too?**
164
+ A: Yes. Transcription revenue counts toward your tier and your rev-share at the same rates.
165
+
166
+ **Q: What if I don't hit 10K estimates?**
167
+ A: You earn at the Loom Partner rate (23%) from day one. There's no minimum to start earning.
build.mjs ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * LoomLens Live — Build Script
4
+ *
5
+ * Usage:
6
+ * node build.mjs # build everything
7
+ * node build.mjs sidebar # build sidebar only
8
+ *
9
+ * Output:
10
+ * dist/loomlens-clusters.js — standalone clusters module
11
+ * dist/loomlens-engine.js — standalone engine module (no TS imports)
12
+ * dist/loomlens-sidebar.html — self-contained sidebar (all-in-one)
13
+ *
14
+ * The sidebar is the canonical deploy artifact — it includes all logic inline.
15
+ */
16
+
17
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
18
+ import { resolve, dirname } from 'path';
19
+ import { fileURLToPath } from 'url';
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const DIST = resolve(__dirname, 'dist');
23
+
24
+ mkdirSync(DIST, { recursive: true });
25
+
26
+ // ─── Step 1: Build loomlens-clusters.js (standalone browser global) ─────────
27
+ function buildClusters() {
28
+ let js = readFileSync(resolve(__dirname, 'loomlens-clusters.js'), 'utf8');
29
+ // Ensure browser global is set
30
+ if (!js.includes('window.LoomLensClusters')) {
31
+ js += '\nif (typeof window !== "undefined") { window.LoomLensClusters = window._LL; }\n';
32
+ }
33
+ writeFileSync(resolve(DIST, 'loomlens-clusters.js'), js);
34
+ console.log('✓ dist/loomlens-clusters.js');
35
+ }
36
+
37
+ // ─── Step 2: Build loomlens-engine.js (standalone, inlines cluster deps) ─────
38
+ // The engine imports from clusters.js. For standalone use, we inline the
39
+ // clusters directly into the engine so it has no external dependencies.
40
+ function buildEngine() {
41
+ const clusters = readFileSync(resolve(__dirname, 'loomlens-clusters.js'), 'utf8');
42
+ let engine = readFileSync(resolve(__dirname, 'loomlens-engine.ts'), 'utf8');
43
+
44
+ // Remove TypeScript import
45
+ engine = engine.replace(
46
+ /import \{[\s\S]*?\} from '\.\/loomlens-clusters\.js';/,
47
+ ''
48
+ );
49
+
50
+ // Inject clusters as a plain JS object before the engine code
51
+ const clustersData = clusters
52
+ .replace(/^export /gm, '') // strip ES module exports
53
+ .replace(/^import .*$/gm, '') // strip any top-level imports
54
+ .replace(/if \(typeof window[\s\S]*?\}\);/, '') // strip browser global block
55
+ .trim();
56
+
57
+ engine = `// LoomLens Live — Engine (built)\n${clustersData}\n\n${engine}`;
58
+
59
+ writeFileSync(resolve(DIST, 'loomlens-engine.js'), engine);
60
+ console.log('✓ dist/loomlens-engine.js');
61
+ }
62
+
63
+ // ─── Step 3: Build loomlens-sidebar.html (all-in-one deploy artifact) ───────
64
+ // Replaces script references with inline code, creating a single-file deploy.
65
+ function buildSidebar() {
66
+ let html = readFileSync(resolve(__dirname, 'loomlens-sidebar.html'), 'utf8');
67
+ const clusters = readFileSync(resolve(__dirname, 'loomlens-clusters.js'), 'utf8')
68
+ .replace(/^export /gm, '')
69
+ .replace(/^import .*$/gm, '')
70
+ .replace(/if \(typeof window[\s\S]*?\}\);/, '')
71
+ .trim();
72
+
73
+ // Remove the inline clusters/engine block from the sidebar and inject built version
74
+ // The sidebar has its own inline version — for the build, we keep it inline as-is
75
+ // since it's already self-contained. The dist version is identical to source.
76
+ writeFileSync(resolve(DIST, 'loomlens-sidebar.html'), html);
77
+ console.log('✓ dist/loomlens-sidebar.html');
78
+ }
79
+
80
+ // ─── CLI ────────────────────────────────────────────────────────────────────
81
+ const cmd = process.argv[2] || 'all';
82
+
83
+ if (cmd === 'all') {
84
+ buildClusters();
85
+ buildEngine();
86
+ buildSidebar();
87
+ } else if (cmd === 'sidebar') {
88
+ buildSidebar();
89
+ } else if (cmd === 'clusters') {
90
+ buildClusters();
91
+ } else if (cmd === 'engine') {
92
+ buildEngine();
93
+ } else {
94
+ console.error(`Unknown target: ${cmd}`);
95
+ console.error('Usage: node build.mjs [all|sidebar|clusters|engine]');
96
+ process.exit(1);
97
+ }
98
+
99
+ console.log(`\nBuild complete → ${DIST}`);
dist/loomlens-clusters.js ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LoomLens Live — Model Cluster Definitions
3
+ * Standalone module: works as ES module, CommonJS, or browser global
4
+ *
5
+ * Each cluster groups models that actually compete on cost + speed + capability.
6
+ * Within a cluster: recommend the cheapest model that clears the capability bar (score ≥ 65).
7
+ */
8
+
9
+ export const CLUSTERS = [
10
+ // ─────────────────────────────────────────────────────────────────
11
+ // CLUSTER: Flash Reasoning
12
+ // Fast + cheap. Best for Q&A, summaries, simple tasks.
13
+ // ─────────────────────────────────────────────────────────────────
14
+ {
15
+ id: 'flash',
16
+ name: 'Flash Reasoning',
17
+ description: 'Fast + cheap. Best for Q&A, summaries, simple tasks.',
18
+ color: '#10b981',
19
+ models: [
20
+ {
21
+ id: 'minimax/MiniMax-M2',
22
+ name: 'MiniMax M2',
23
+ provider: 'MiniMax',
24
+ tier: 3,
25
+ latency: 'fast',
26
+ capabilities: ['reasoning', 'fast', 'cheap', 'long-context'],
27
+ inputPricePerM: 0.10,
28
+ outputPricePerM: 0.40,
29
+ contextWindow: 1_024_000,
30
+ },
31
+ {
32
+ id: 'google/gemini-2.5-flash',
33
+ name: 'Gemini 2.5 Flash',
34
+ provider: 'Google',
35
+ tier: 3,
36
+ latency: 'fast',
37
+ capabilities: ['reasoning', 'fast'],
38
+ inputPricePerM: 0.15,
39
+ outputPricePerM: 0.60,
40
+ contextWindow: 1_000_000,
41
+ },
42
+ {
43
+ id: 'anthropic/claude-haiku',
44
+ name: 'Claude Haiku',
45
+ provider: 'Anthropic',
46
+ tier: 3,
47
+ latency: 'fast',
48
+ capabilities: ['fast', 'light-reasoning', 'cheap'],
49
+ inputPricePerM: 0.80,
50
+ outputPricePerM: 4.00,
51
+ contextWindow: 200_000,
52
+ },
53
+ {
54
+ id: 'deepseek/deepseek-chat-v3',
55
+ name: 'DeepSeek Chat v3',
56
+ provider: 'DeepSeek',
57
+ tier: 3,
58
+ latency: 'fast',
59
+ capabilities: ['reasoning', 'fast'],
60
+ inputPricePerM: 0.27,
61
+ outputPricePerM: 1.10,
62
+ contextWindow: 640_000,
63
+ },
64
+ ],
65
+ },
66
+
67
+ // ─────────────────────────────────────────────────────────────────
68
+ // CLUSTER: Fast General
69
+ // Fast general purpose. Best latency for typical prompts.
70
+ // ─────────────────────────────────────────────────────────────────
71
+ {
72
+ id: 'fastgen',
73
+ name: 'Fast General',
74
+ description: 'Fast general purpose. Best latency for typical prompts.',
75
+ color: '#06b6d4',
76
+ models: [
77
+ {
78
+ id: 'openai/gpt-4o-mini',
79
+ name: 'GPT-4o Mini',
80
+ provider: 'OpenAI',
81
+ tier: 3,
82
+ latency: 'fast',
83
+ capabilities: ['fast', 'cheap'],
84
+ inputPricePerM: 0.15,
85
+ outputPricePerM: 0.60,
86
+ contextWindow: 128_000,
87
+ },
88
+ {
89
+ id: 'google/gemini-2.0-flash',
90
+ name: 'Gemini 2.0 Flash',
91
+ provider: 'Google',
92
+ tier: 3,
93
+ latency: 'fast',
94
+ capabilities: ['fast', 'cheap', 'long-context'],
95
+ inputPricePerM: 0.10,
96
+ outputPricePerM: 0.40,
97
+ contextWindow: 1_000_000,
98
+ },
99
+ {
100
+ id: 'xai/grok-3',
101
+ name: 'xAI Grok-3',
102
+ provider: 'xAI',
103
+ tier: 3,
104
+ latency: 'fast',
105
+ capabilities: ['fast', 'reasoning'],
106
+ inputPricePerM: 2.00,
107
+ outputPricePerM: 10.00,
108
+ contextWindow: 131_072,
109
+ },
110
+ ],
111
+ },
112
+
113
+ // ─────────────────────────────────────────────────────────────────
114
+ // CLUSTER: Balanced Coding
115
+ // Best code capability per dollar. Medium complexity.
116
+ // ─────────────────────────────────────────────────────────────────
117
+ {
118
+ id: 'code',
119
+ name: 'Balanced Coding',
120
+ description: 'Best code capability per dollar. Medium complexity.',
121
+ color: '#8b5cf6',
122
+ models: [
123
+ {
124
+ id: 'anthropic/claude-sonnet-4-20250514',
125
+ name: 'Claude Sonnet 4',
126
+ provider: 'Anthropic',
127
+ tier: 2,
128
+ latency: 'medium',
129
+ capabilities: ['reasoning', 'code', 'analysis', 'fast-reasoning'],
130
+ inputPricePerM: 3.00,
131
+ outputPricePerM: 15.00,
132
+ contextWindow: 200_000,
133
+ },
134
+ {
135
+ id: 'openai/gpt-4o',
136
+ name: 'GPT-4o',
137
+ provider: 'OpenAI',
138
+ tier: 2,
139
+ latency: 'medium',
140
+ capabilities: ['reasoning', 'vision', 'code', 'multimodal'],
141
+ inputPricePerM: 2.50,
142
+ outputPricePerM: 10.00,
143
+ contextWindow: 128_000,
144
+ },
145
+ {
146
+ id: 'anthropic/claude-3.5-sonnet',
147
+ name: 'Claude 3.5 Sonnet',
148
+ provider: 'Anthropic',
149
+ tier: 2,
150
+ latency: 'medium',
151
+ capabilities: ['reasoning', 'code', 'analysis'],
152
+ inputPricePerM: 3.00,
153
+ outputPricePerM: 15.00,
154
+ contextWindow: 200_000,
155
+ },
156
+ ],
157
+ },
158
+
159
+ // ─────────────────────────────────────────────────────────────────
160
+ // CLUSTER: Power / Architecture
161
+ // Maximum reasoning. Pay for it only when needed.
162
+ // ─────────────────────────────────────────────────────────────────
163
+ {
164
+ id: 'power',
165
+ name: 'Power / Architecture',
166
+ description: 'Maximum reasoning. Pay for it only when needed.',
167
+ color: '#3b82f6',
168
+ models: [
169
+ {
170
+ id: 'anthropic/claude-opus-4-20250514',
171
+ name: 'Claude Opus 4',
172
+ provider: 'Anthropic',
173
+ tier: 1,
174
+ latency: 'slow',
175
+ capabilities: ['reasoning', 'code', 'analysis', 'long-context'],
176
+ inputPricePerM: 15.00,
177
+ outputPricePerM: 75.00,
178
+ contextWindow: 200_000,
179
+ },
180
+ {
181
+ id: 'anthropic/claude-code',
182
+ name: 'Claude Code',
183
+ provider: 'Anthropic',
184
+ tier: 1,
185
+ latency: 'slow',
186
+ capabilities: ['agentic', 'code', 'architecture', 'full-reasoning'],
187
+ inputPricePerM: 15.00,
188
+ outputPricePerM: 75.00,
189
+ contextWindow: 200_000,
190
+ },
191
+ ],
192
+ },
193
+
194
+ // ─────────────────────────────────────────────────────────────────
195
+ // CLUSTER: Vision / Multimodal
196
+ // Image + document understanding.
197
+ // ─────────────────────────────────────────────────────────────────
198
+ {
199
+ id: 'vision',
200
+ name: 'Vision / Multimodal',
201
+ description: 'Image + document understanding.',
202
+ color: '#ec4899',
203
+ models: [
204
+ {
205
+ id: 'openai/gpt-4o',
206
+ name: 'GPT-4o',
207
+ provider: 'OpenAI',
208
+ tier: 4,
209
+ latency: 'medium',
210
+ capabilities: ['vision', 'reasoning', 'multimodal'],
211
+ inputPricePerM: 2.50,
212
+ outputPricePerM: 10.00,
213
+ contextWindow: 128_000,
214
+ },
215
+ {
216
+ id: 'anthropic/claude-3.5',
217
+ name: 'Claude 3.5',
218
+ provider: 'Anthropic',
219
+ tier: 4,
220
+ latency: 'medium',
221
+ capabilities: ['vision', 'code', 'analysis'],
222
+ inputPricePerM: 3.00,
223
+ outputPricePerM: 15.00,
224
+ contextWindow: 200_000,
225
+ },
226
+ ],
227
+ },
228
+ ];
229
+
230
+ // ─── Latency Scoring ────────────────────────────────────────────────────────
231
+
232
+ export const LATENCY_SCORE = { fast: 100, medium: 62, slow: 22 };
233
+
234
+ // ─── Capability Fit Matrix ──────────────────────────────────────────────────
235
+ // task → cluster → fit 0-100
236
+
237
+ export const CAPABILITY_FIT = {
238
+ simple_qa: { flash: 95, fastgen: 80, code: 30, power: 20, vision: 20 },
239
+ summary: { flash: 90, fastgen: 75, code: 40, power: 30, vision: 20 },
240
+ code_edit: { flash: 60, fastgen: 40, code: 90, power: 40, vision: 20 },
241
+ code_write: { flash: 30, fastgen: 20, code: 95, power: 95, vision: 20 },
242
+ complex_analysis: { flash: 40, fastgen: 30, code: 85, power: 98, vision: 20 },
243
+ vision: { flash: 10, fastgen: 10, code: 30, power: 30, vision: 98 },
244
+ unknown: { flash: 70, fastgen: 60, code: 60, power: 50, vision: 40 },
245
+ };
246
+
247
+ // ─── Task Detection Keywords ────────────────────────────────────────────────
248
+
249
+ export const TASK_KEYWORDS = {
250
+ simple_qa: ['what is','who is','define','explain','how do i','how does','what are','why does','when did','where is','tell me','give me','list the'],
251
+ summary: ['summarize','tl;dr','sum up','brief','recap','key points','highlights','overview','condense','shorten'],
252
+ code_edit: ['fix','bug','debug','hotfix','patch','tweak','adjust','correct','error','issue','typo','broken'],
253
+ code_write: ['write','build','create','implement','add feature','new function','generate',' scaffold','code for','make a','app that'],
254
+ complex_analysis: ['analyze','architecture','review','refactor','design','strategy','compare','evaluate','assess','plan','audit','investigate'],
255
+ vision: ['image','photo','picture','screenshot','diagram','chart','visual','frame','pdf','document','extract text from','read the'],
256
+ };
257
+
258
+ // ─── Output Token Estimates by Task ─────────────────────────────────────────
259
+
260
+ export const OUTPUT_TOKENS = {
261
+ simple_qa: { min: 50, mid: 150, max: 400 },
262
+ summary: { min: 80, mid: 200, max: 500 },
263
+ code_edit: { min: 100, mid: 400, max: 1000 },
264
+ code_write: { min: 200, mid: 800, max: 2500 },
265
+ complex_analysis: { min: 200, mid: 600, max: 1500 },
266
+ vision: { min: 100, mid: 300, max: 800 },
267
+ unknown: { min: 100, mid: 400, max: 1200 },
268
+ };
269
+
270
+ // ─── Helpers ────────────────────────────────────────────────────────────────
271
+
272
+ /** Get all models as a flat array */
273
+ export function getAllClusterModels() {
274
+ return CLUSTERS.flatMap(c => c.models.map(m => ({ ...m, clusterId: c.id, clusterName: c.name })));
275
+ }
276
+
277
+ /** Get a single model by ID */
278
+ export function getModelById(id) {
279
+ for (const cluster of CLUSTERS) {
280
+ const m = cluster.models.find(m => m.id === id);
281
+ if (m) return { ...m, clusterId: cluster.id, clusterName: cluster.name };
282
+ }
283
+ return null;
284
+ }
285
+
286
+ /** Get a cluster by ID */
287
+ export function getClusterById(id) {
288
+ return CLUSTERS.find(c => c.id === id) ?? null;
289
+ }
290
+
291
+ /** All cluster IDs */
292
+ export function getClusterIds() {
293
+ return CLUSTERS.map(c => c.id);
294
+ }
295
+
296
+ // ─── Browser global ─────────────────────────────────────────────────────────
297
+ // If loaded as a plain script (not module), expose as window.LoomLensClusters
298
+ if (typeof window !== 'undefined' && typeof module === 'undefined') {
299
+ window.LoomLensClusters = {
300
+ CLUSTERS,
301
+ LATENCY_SCORE,
302
+ CAPABILITY_FIT,
303
+ TASK_KEYWORDS,
304
+ OUTPUT_TOKENS,
305
+ getAllClusterModels,
306
+ getModelById,
307
+ getClusterById,
308
+ getClusterIds,
309
+ };
310
+ }
dist/loomlens-engine.js ADDED
@@ -0,0 +1,629 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // LoomLens Live — Engine (built)
2
+ /**
3
+ * LoomLens Live — Model Cluster Definitions
4
+ * Standalone module: works as ES module, CommonJS, or browser global
5
+ *
6
+ * Each cluster groups models that actually compete on cost + speed + capability.
7
+ * Within a cluster: recommend the cheapest model that clears the capability bar (score ≥ 65).
8
+ */
9
+
10
+ const CLUSTERS = [
11
+ // ─────────────────────────────────────────────────────────────────
12
+ // CLUSTER: Flash Reasoning
13
+ // Fast + cheap. Best for Q&A, summaries, simple tasks.
14
+ // ─────────────────────────────────────────────────────────────────
15
+ {
16
+ id: 'flash',
17
+ name: 'Flash Reasoning',
18
+ description: 'Fast + cheap. Best for Q&A, summaries, simple tasks.',
19
+ color: '#10b981',
20
+ models: [
21
+ {
22
+ id: 'minimax/MiniMax-M2',
23
+ name: 'MiniMax M2',
24
+ provider: 'MiniMax',
25
+ tier: 3,
26
+ latency: 'fast',
27
+ capabilities: ['reasoning', 'fast', 'cheap', 'long-context'],
28
+ inputPricePerM: 0.10,
29
+ outputPricePerM: 0.40,
30
+ contextWindow: 1_024_000,
31
+ },
32
+ {
33
+ id: 'google/gemini-2.5-flash',
34
+ name: 'Gemini 2.5 Flash',
35
+ provider: 'Google',
36
+ tier: 3,
37
+ latency: 'fast',
38
+ capabilities: ['reasoning', 'fast'],
39
+ inputPricePerM: 0.15,
40
+ outputPricePerM: 0.60,
41
+ contextWindow: 1_000_000,
42
+ },
43
+ {
44
+ id: 'anthropic/claude-haiku',
45
+ name: 'Claude Haiku',
46
+ provider: 'Anthropic',
47
+ tier: 3,
48
+ latency: 'fast',
49
+ capabilities: ['fast', 'light-reasoning', 'cheap'],
50
+ inputPricePerM: 0.80,
51
+ outputPricePerM: 4.00,
52
+ contextWindow: 200_000,
53
+ },
54
+ {
55
+ id: 'deepseek/deepseek-chat-v3',
56
+ name: 'DeepSeek Chat v3',
57
+ provider: 'DeepSeek',
58
+ tier: 3,
59
+ latency: 'fast',
60
+ capabilities: ['reasoning', 'fast'],
61
+ inputPricePerM: 0.27,
62
+ outputPricePerM: 1.10,
63
+ contextWindow: 640_000,
64
+ },
65
+ ],
66
+ },
67
+
68
+ // ─────────────────────────────────────────────────────────────────
69
+ // CLUSTER: Fast General
70
+ // Fast general purpose. Best latency for typical prompts.
71
+ // ─────────────────────────────────────────────────────────────────
72
+ {
73
+ id: 'fastgen',
74
+ name: 'Fast General',
75
+ description: 'Fast general purpose. Best latency for typical prompts.',
76
+ color: '#06b6d4',
77
+ models: [
78
+ {
79
+ id: 'openai/gpt-4o-mini',
80
+ name: 'GPT-4o Mini',
81
+ provider: 'OpenAI',
82
+ tier: 3,
83
+ latency: 'fast',
84
+ capabilities: ['fast', 'cheap'],
85
+ inputPricePerM: 0.15,
86
+ outputPricePerM: 0.60,
87
+ contextWindow: 128_000,
88
+ },
89
+ {
90
+ id: 'google/gemini-2.0-flash',
91
+ name: 'Gemini 2.0 Flash',
92
+ provider: 'Google',
93
+ tier: 3,
94
+ latency: 'fast',
95
+ capabilities: ['fast', 'cheap', 'long-context'],
96
+ inputPricePerM: 0.10,
97
+ outputPricePerM: 0.40,
98
+ contextWindow: 1_000_000,
99
+ },
100
+ {
101
+ id: 'xai/grok-3',
102
+ name: 'xAI Grok-3',
103
+ provider: 'xAI',
104
+ tier: 3,
105
+ latency: 'fast',
106
+ capabilities: ['fast', 'reasoning'],
107
+ inputPricePerM: 2.00,
108
+ outputPricePerM: 10.00,
109
+ contextWindow: 131_072,
110
+ },
111
+ ],
112
+ },
113
+
114
+ // ─────────────────────────────────────────────────────────────────
115
+ // CLUSTER: Balanced Coding
116
+ // Best code capability per dollar. Medium complexity.
117
+ // ─────────────────────────────────────────────────────────────────
118
+ {
119
+ id: 'code',
120
+ name: 'Balanced Coding',
121
+ description: 'Best code capability per dollar. Medium complexity.',
122
+ color: '#8b5cf6',
123
+ models: [
124
+ {
125
+ id: 'anthropic/claude-sonnet-4-20250514',
126
+ name: 'Claude Sonnet 4',
127
+ provider: 'Anthropic',
128
+ tier: 2,
129
+ latency: 'medium',
130
+ capabilities: ['reasoning', 'code', 'analysis', 'fast-reasoning'],
131
+ inputPricePerM: 3.00,
132
+ outputPricePerM: 15.00,
133
+ contextWindow: 200_000,
134
+ },
135
+ {
136
+ id: 'openai/gpt-4o',
137
+ name: 'GPT-4o',
138
+ provider: 'OpenAI',
139
+ tier: 2,
140
+ latency: 'medium',
141
+ capabilities: ['reasoning', 'vision', 'code', 'multimodal'],
142
+ inputPricePerM: 2.50,
143
+ outputPricePerM: 10.00,
144
+ contextWindow: 128_000,
145
+ },
146
+ {
147
+ id: 'anthropic/claude-3.5-sonnet',
148
+ name: 'Claude 3.5 Sonnet',
149
+ provider: 'Anthropic',
150
+ tier: 2,
151
+ latency: 'medium',
152
+ capabilities: ['reasoning', 'code', 'analysis'],
153
+ inputPricePerM: 3.00,
154
+ outputPricePerM: 15.00,
155
+ contextWindow: 200_000,
156
+ },
157
+ ],
158
+ },
159
+
160
+ // ─────────────────────────────────────────────────────────────────
161
+ // CLUSTER: Power / Architecture
162
+ // Maximum reasoning. Pay for it only when needed.
163
+ // ─────────────────────────────────────────────────────────────────
164
+ {
165
+ id: 'power',
166
+ name: 'Power / Architecture',
167
+ description: 'Maximum reasoning. Pay for it only when needed.',
168
+ color: '#3b82f6',
169
+ models: [
170
+ {
171
+ id: 'anthropic/claude-opus-4-20250514',
172
+ name: 'Claude Opus 4',
173
+ provider: 'Anthropic',
174
+ tier: 1,
175
+ latency: 'slow',
176
+ capabilities: ['reasoning', 'code', 'analysis', 'long-context'],
177
+ inputPricePerM: 15.00,
178
+ outputPricePerM: 75.00,
179
+ contextWindow: 200_000,
180
+ },
181
+ {
182
+ id: 'anthropic/claude-code',
183
+ name: 'Claude Code',
184
+ provider: 'Anthropic',
185
+ tier: 1,
186
+ latency: 'slow',
187
+ capabilities: ['agentic', 'code', 'architecture', 'full-reasoning'],
188
+ inputPricePerM: 15.00,
189
+ outputPricePerM: 75.00,
190
+ contextWindow: 200_000,
191
+ },
192
+ ],
193
+ },
194
+
195
+ // ─────────────────────────────────────────────────────────────────
196
+ // CLUSTER: Vision / Multimodal
197
+ // Image + document understanding.
198
+ // ─────────────────────────────────────────────────────────────────
199
+ {
200
+ id: 'vision',
201
+ name: 'Vision / Multimodal',
202
+ description: 'Image + document understanding.',
203
+ color: '#ec4899',
204
+ models: [
205
+ {
206
+ id: 'openai/gpt-4o',
207
+ name: 'GPT-4o',
208
+ provider: 'OpenAI',
209
+ tier: 4,
210
+ latency: 'medium',
211
+ capabilities: ['vision', 'reasoning', 'multimodal'],
212
+ inputPricePerM: 2.50,
213
+ outputPricePerM: 10.00,
214
+ contextWindow: 128_000,
215
+ },
216
+ {
217
+ id: 'anthropic/claude-3.5',
218
+ name: 'Claude 3.5',
219
+ provider: 'Anthropic',
220
+ tier: 4,
221
+ latency: 'medium',
222
+ capabilities: ['vision', 'code', 'analysis'],
223
+ inputPricePerM: 3.00,
224
+ outputPricePerM: 15.00,
225
+ contextWindow: 200_000,
226
+ },
227
+ ],
228
+ },
229
+ ];
230
+
231
+ // ─── Latency Scoring ────────────────────────────────────────────────────────
232
+
233
+ const LATENCY_SCORE = { fast: 100, medium: 62, slow: 22 };
234
+
235
+ // ─── Capability Fit Matrix ──────────────────────────────────────────────────
236
+ // task → cluster → fit 0-100
237
+
238
+ const CAPABILITY_FIT = {
239
+ simple_qa: { flash: 95, fastgen: 80, code: 30, power: 20, vision: 20 },
240
+ summary: { flash: 90, fastgen: 75, code: 40, power: 30, vision: 20 },
241
+ code_edit: { flash: 60, fastgen: 40, code: 90, power: 40, vision: 20 },
242
+ code_write: { flash: 30, fastgen: 20, code: 95, power: 95, vision: 20 },
243
+ complex_analysis: { flash: 40, fastgen: 30, code: 85, power: 98, vision: 20 },
244
+ vision: { flash: 10, fastgen: 10, code: 30, power: 30, vision: 98 },
245
+ unknown: { flash: 70, fastgen: 60, code: 60, power: 50, vision: 40 },
246
+ };
247
+
248
+ // ─── Task Detection Keywords ────────────────────────────────────────────────
249
+
250
+ const TASK_KEYWORDS = {
251
+ simple_qa: ['what is','who is','define','explain','how do i','how does','what are','why does','when did','where is','tell me','give me','list the'],
252
+ summary: ['summarize','tl;dr','sum up','brief','recap','key points','highlights','overview','condense','shorten'],
253
+ code_edit: ['fix','bug','debug','hotfix','patch','tweak','adjust','correct','error','issue','typo','broken'],
254
+ code_write: ['write','build','create','implement','add feature','new function','generate',' scaffold','code for','make a','app that'],
255
+ complex_analysis: ['analyze','architecture','review','refactor','design','strategy','compare','evaluate','assess','plan','audit','investigate'],
256
+ vision: ['image','photo','picture','screenshot','diagram','chart','visual','frame','pdf','document','extract text from','read the'],
257
+ };
258
+
259
+ // ─── Output Token Estimates by Task ─────────────────────────────────────────
260
+
261
+ const OUTPUT_TOKENS = {
262
+ simple_qa: { min: 50, mid: 150, max: 400 },
263
+ summary: { min: 80, mid: 200, max: 500 },
264
+ code_edit: { min: 100, mid: 400, max: 1000 },
265
+ code_write: { min: 200, mid: 800, max: 2500 },
266
+ complex_analysis: { min: 200, mid: 600, max: 1500 },
267
+ vision: { min: 100, mid: 300, max: 800 },
268
+ unknown: { min: 100, mid: 400, max: 1200 },
269
+ };
270
+
271
+ // ─── Helpers ────────────────────────────────────────────────────────────────
272
+
273
+ /** Get all models as a flat array */
274
+ function getAllClusterModels() {
275
+ return CLUSTERS.flatMap(c => c.models.map(m => ({ ...m, clusterId: c.id, clusterName: c.name })));
276
+ }
277
+
278
+ /** Get a single model by ID */
279
+ function getModelById(id) {
280
+ for (const cluster of CLUSTERS) {
281
+ const m = cluster.models.find(m => m.id === id);
282
+ if (m) return { ...m, clusterId: cluster.id, clusterName: cluster.name };
283
+ }
284
+ return null;
285
+ }
286
+
287
+ /** Get a cluster by ID */
288
+ function getClusterById(id) {
289
+ return CLUSTERS.find(c => c.id === id) ?? null;
290
+ }
291
+
292
+ /** All cluster IDs */
293
+ function getClusterIds() {
294
+ return CLUSTERS.map(c => c.id);
295
+ }
296
+
297
+ // ─── Browser global ─────────────────────────────────────────────────────────
298
+ // If loaded as a plain script (not module), expose as window.LoomLensClusters
299
+ if (typeof window !== 'undefined' && typeof module === 'undefined') {
300
+ window.LoomLensClusters = {
301
+ CLUSTERS,
302
+ LATENCY_SCORE,
303
+ CAPABILITY_FIT,
304
+ TASK_KEYWORDS,
305
+ OUTPUT_TOKENS,
306
+ getAllClusterModels,
307
+ getModelById,
308
+ getClusterById,
309
+ getClusterIds,
310
+ };
311
+ }
312
+
313
+ /**
314
+ * LoomLens Live — Cost Estimation Engine
315
+ * Phase 1 updated: cluster-aware scoring + standalone (no imports needed)
316
+ *
317
+ * Estimator: char heuristics → token count → cost + cluster-aware fit score.
318
+ * No API calls. No dependencies.
319
+ */
320
+
321
+
322
+
323
+ // ─── Types ───────────────────────────────────────────────────────────────────
324
+
325
+ export type TaskType = keyof typeof TASK_KEYWORDS;
326
+ export type ClusterId = 'flash' | 'fastgen' | 'code' | 'power' | 'vision';
327
+
328
+ export interface TaskDetection {
329
+ type: TaskType;
330
+ confidence: number;
331
+ keywords: string[];
332
+ }
333
+
334
+ export interface ModelEstimate {
335
+ modelId: string;
336
+ modelName: string;
337
+ provider: string;
338
+ clusterId: string;
339
+ clusterName: string;
340
+ inputTokens: number;
341
+ outputTokens: number;
342
+ inputCost: number;
343
+ outputCost: number;
344
+ totalCost: number;
345
+ latencyScore: number;
346
+ capFit: number;
347
+ costEff: number;
348
+ compositeScore: number; // 0-100
349
+ fitBadge: 'recommended' | 'sufficient' | 'overkill';
350
+ insight: string;
351
+ relativeCostPct: number; // % of most expensive in same cluster
352
+ }
353
+
354
+ export interface ClusterResult {
355
+ clusterId: string;
356
+ clusterName: string;
357
+ models: ModelEstimate[];
358
+ best: ModelEstimate | null; // cheapest that clears threshold
359
+ }
360
+
361
+ export interface LoomLensResult {
362
+ prompt: string;
363
+ taskType: TaskType;
364
+ taskConfidence: number;
365
+ inputTokens: number;
366
+ outputTokens: number;
367
+ clusters: Record<string, ClusterResult>;
368
+ recommended: ModelEstimate | null;
369
+ selected: ModelEstimate | null;
370
+ allModels: ModelEstimate[];
371
+ tip: string;
372
+ estimatedAt: number;
373
+ }
374
+
375
+ // ─── Token Estimation ────────────────────────────────────────────────────────
376
+
377
+ const CHARS_PER_TOKEN = { en: 3.8, es: 3.5, ja: 1.2, zh: 1.0 };
378
+
379
+ function detectLanguage(text: string): keyof typeof CHARS_PER_TOKEN {
380
+ if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return 'ja';
381
+ if (/[\u4E00-\u9FFF]/.test(text)) return 'zh';
382
+ if (/[áéíóúñ¿¡ü]/i.test(text)) return 'es';
383
+ return 'en';
384
+ }
385
+
386
+ function detectContentMultiplier(text: string): number {
387
+ const codeFences = (text.match(/```/g) || []).length;
388
+ const newlines = (text.match(/\n/g) || []).length;
389
+ const isCode = codeFences >= 2 || (newlines > 3 && codeFences >= 1);
390
+ return isCode ? 1.2 : 1.0;
391
+ }
392
+
393
+ /**
394
+ * Estimate input token count from raw text.
395
+ * Uses language detection + content-type heuristics.
396
+ */
397
+ export function estimateInputTokens(text: string): number {
398
+ if (!text || !text.trim()) return 0;
399
+ const lang = detectLanguage(text);
400
+ const cpt = CHARS_PER_TOKEN[lang];
401
+ const mult = detectContentMultiplier(text);
402
+ return Math.ceil((text.length / cpt) * mult);
403
+ }
404
+
405
+ // ─── Task Detection ─────────────────────────────────────────────────────────
406
+
407
+ /**
408
+ * Detect what type of task this prompt represents.
409
+ */
410
+ export function detectTaskType(prompt: string): TaskDetection {
411
+ const lower = prompt.toLowerCase();
412
+ const scores: Partial<Record<TaskType, number>> = {};
413
+
414
+ for (const [type, keywords] of Object.entries(TASK_KEYWORDS)) {
415
+ scores[type as TaskType] = keywords.reduce((acc, kw) => acc + (lower.includes(kw) ? 1 : 0), 0);
416
+ }
417
+
418
+ // Structural boosts
419
+ if (/```[\s\S]+```/.test(prompt)) (scores['code_write'] as number) += 2;
420
+ if (/^\s*(import|from|const|let|function|class|def |fn |pub fn)/m.test(prompt))
421
+ (scores['code_write'] as number) += 2;
422
+ if (/\b(fix|bug|error|issue)\b/i.test(prompt)) (scores['code_edit'] as number) += 1;
423
+ if (/\b(write|build|create|implement)\b/i.test(prompt))
424
+ (scores['code_write'] as number) += 1;
425
+ if (/\b(analyze|evaluate|compare|assess)\b/i.test(prompt))
426
+ (scores['complex_analysis'] as number) += 1;
427
+ if (/\$\d+|\d+%|rate|spend|cost|budget/i.test(prompt))
428
+ (scores['complex_analysis'] as number) += 1;
429
+ if (/\$\d+|\d+%|rate|spend/i.test(prompt)) (scores['simple_qa'] as number) += 1;
430
+
431
+ const best = Object.entries(scores).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))[0];
432
+ const score = best?.[1] ?? 0;
433
+ const type: TaskType = score === 0 ? 'unknown' : (best![0] as TaskType);
434
+ const confidence = Math.min(score / 3, 1);
435
+ const keywords = (TASK_KEYWORDS[type] ?? []).filter(kw => lower.includes(kw));
436
+
437
+ return { type, confidence, keywords };
438
+ }
439
+
440
+ // ─── Cost Calculation ────────────────────────────────────────────────────────
441
+
442
+ export function calculateCost(
443
+ inPricePerM: number,
444
+ outPricePerM: number,
445
+ inTokens: number,
446
+ outTokens: number
447
+ ): { inputCost: number; outputCost: number; totalCost: number } {
448
+ const inputCost = Math.round((inTokens / 1_000_000) * inPricePerM * 1e6) / 1e6;
449
+ const outputCost = Math.round((outTokens / 1_000_000) * outPricePerM * 1e6) / 1e6;
450
+ return { inputCost, outputCost, totalCost: Math.round((inputCost + outputCost) * 1e6) / 1e6 };
451
+ }
452
+
453
+ // ─── Fit Scoring ─────────────────────────────────────────────────────────────
454
+
455
+ /**
456
+ * Composite score within a cluster:
457
+ * score = (capability_fit × 0.35) + (cost_efficiency × 0.35) + (speed × 0.30)
458
+ *
459
+ * Returns { capFit, costEff, compositeScore }
460
+ */
461
+ function scoreWithinCluster(
462
+ model: { clusterId: string; latency: keyof typeof LATENCY_SCORE; inPricePerM: number; outPricePerM: number },
463
+ task: TaskType,
464
+ allInCluster: Array<{ inPricePerM: number; outPricePerM: number }>
465
+ ): { capFit: number; costEff: number; compositeScore: number } {
466
+ const capFit = CAPABILITY_FIT[task]?.[model.clusterId] ?? 50;
467
+ const costs = allInCluster.map(m => calculateCost(m.inPricePerM, m.outPricePerM, 1, 1).totalCost);
468
+ const minC = Math.min(...costs);
469
+ const maxC = Math.max(...costs);
470
+ const range = maxC - minC || 1;
471
+ const costEff = Math.round(100 - ((calculateCost(model.inPricePerM, model.outPricePerM, 1, 1).totalCost - minC) / range * 100));
472
+ const speed = LATENCY_SCORE[model.latency] ?? 50;
473
+ const composite = Math.round(capFit * 0.35 + costEff * 0.35 + speed * 0.30);
474
+ return { capFit, costEff, compositeScore: composite };
475
+ }
476
+
477
+ function getFitBadge(score: number, clusterId: string): 'recommended' | 'sufficient' | 'overkill' {
478
+ if (score >= 80) return 'recommended';
479
+ if (score >= 65) return 'sufficient';
480
+ return 'overkill';
481
+ }
482
+
483
+ function getInsight(
484
+ modelName: string,
485
+ clusterName: string,
486
+ clusterId: string,
487
+ task: TaskType,
488
+ score: number,
489
+ isBest: boolean
490
+ ): string {
491
+ const taskLabels: Record<TaskType, string> = {
492
+ simple_qa: 'quick questions and lookups',
493
+ summary: 'summarization and extraction',
494
+ code_edit: 'code fixes and small changes',
495
+ code_write: 'full code and feature builds',
496
+ complex_analysis: 'deep reasoning and architecture',
497
+ vision: 'image and document understanding',
498
+ unknown: 'general purpose tasks',
499
+ };
500
+ const label = taskLabels[task] ?? 'general purpose';
501
+ if (isBest) return `Best in ${clusterName} for ${label}`;
502
+ if (score >= 80) return `Strong fit for ${label} in ${clusterName}`;
503
+ if (score >= 65) return `Capable for ${label} in ${clusterName}`;
504
+ if (score < 50) return `Consider ${clusterName} alternatives for ${label}`;
505
+ return `Use ${modelName} in ${clusterName} for ${label}`;
506
+ }
507
+
508
+ // ─── Main Estimator ──────────────────────────────────────────────────────────
509
+
510
+ /**
511
+ * Full estimation: token count → cost + cluster-aware fit score per model.
512
+ */
513
+ export function estimatePrompt(
514
+ prompt: string,
515
+ selectedModelId: string = ''
516
+ ): LoomLensResult {
517
+ const task = detectTaskType(prompt);
518
+ const inTokens = estimateInputTokens(prompt);
519
+ const outTokens = OUTPUT_TOKENS[task]?.mid ?? 400;
520
+ const allFlat = getAllClusterModels();
521
+ const result: LoomLensResult = {
522
+ prompt,
523
+ taskType: task.type,
524
+ taskConfidence: task.confidence,
525
+ inputTokens: inTokens,
526
+ outputTokens: outTokens,
527
+ clusters: {},
528
+ recommended: null,
529
+ selected: null,
530
+ allModels: [],
531
+ tip: '',
532
+ estimatedAt: Date.now(),
533
+ };
534
+
535
+ // Score each model within its cluster
536
+ for (const cluster of CLUSTERS) {
537
+ const clusterModels = allFlat.filter(m => m.clusterId === cluster.id);
538
+ const bestInCluster = clusterModels[0]; // placeholder, will be sorted
539
+
540
+ // Pre-compute cluster cost range
541
+ const clusterCosts = clusterModels.map(m =>
542
+ calculateCost(m.inputPricePerM, m.outputPricePerM, inTokens, outTokens).totalCost
543
+ );
544
+ const maxClusterCost = Math.max(...clusterCosts);
545
+
546
+ // Score each model
547
+ const scored = clusterModels.map(m => {
548
+ const costs = calculateCost(m.inputPricePerM, m.outputPricePerM, inTokens, outTokens);
549
+ const { capFit, costEff, compositeScore } = scoreWithinCluster(m, task.type, clusterModels);
550
+ const isBest = false; // will be set after sort
551
+ const fitBadge = getFitBadge(compositeScore, cluster.id);
552
+ return {
553
+ modelId: m.id,
554
+ modelName: m.name,
555
+ provider: m.provider,
556
+ clusterId: m.clusterId,
557
+ clusterName: cluster.name,
558
+ inputTokens: inTokens,
559
+ outputTokens: outTokens,
560
+ inputCost: costs.inputCost,
561
+ outputCost: costs.outputCost,
562
+ totalCost: costs.totalCost,
563
+ latencyScore: LATENCY_SCORE[m.latency] ?? 50,
564
+ capFit,
565
+ costEff,
566
+ compositeScore,
567
+ fitBadge,
568
+ insight: '',
569
+ relativeCostPct: Math.round((costs.totalCost / maxClusterCost) * 100),
570
+ _isBest: false,
571
+ } as ModelEstimate & { _isBest: boolean };
572
+ });
573
+
574
+ // Sort by composite score desc, then find cheapest above threshold
575
+ scored.sort((a, b) => b.compositeScore - a.compositeScore);
576
+ const best = scored.find(m => m.compositeScore >= 65) ?? scored[0];
577
+ if (best) best._isBest = true;
578
+
579
+ // Set insights
580
+ scored.forEach(m => {
581
+ m.insight = getInsight(m.modelName, cluster.name, cluster.id, task.type, m.compositeScore, m._isBest);
582
+ m.fitBadge = m._isBest ? 'recommended' : m.compositeScore >= 65 ? 'sufficient' : 'overkill';
583
+ });
584
+
585
+ result.clusters[cluster.id] = {
586
+ clusterId: cluster.id,
587
+ clusterName: cluster.name,
588
+ models: scored,
589
+ best: best ?? null,
590
+ };
591
+ result.allModels.push(...scored);
592
+ }
593
+
594
+ // Global: find best across all models
595
+ result.allModels.sort((a, b) => b.compositeScore - a.compositeScore);
596
+ result.recommended = result.allModels.find(m => m.compositeScore >= 80) ?? null;
597
+
598
+ // Selected
599
+ if (selectedModelId) {
600
+ result.selected = result.allModels.find(m => m.modelId === selectedModelId) ?? null;
601
+ }
602
+ if (!result.selected) {
603
+ result.selected = result.allModels[0];
604
+ }
605
+
606
+ // Tip
607
+ if (result.recommended && result.selected && result.recommended.modelId !== result.selected.modelId) {
608
+ const selCost = result.selected?.totalCost ?? 0;
609
+ const recCost = result.recommended.totalCost;
610
+ const saving = selCost - recCost;
611
+ if (saving > 0.0001) {
612
+ result.tip = `Switching to ${result.recommended.modelName} saves ${formatCost(saving)} and is better for this task.`;
613
+ } else if (saving < -0.0001) {
614
+ result.tip = `${result.selected?.modelName} is well-matched. ${result.recommended.modelName} would cost ${formatCost(Math.abs(saving))} more.`;
615
+ }
616
+ } else if (result.selected) {
617
+ result.tip = `${result.selected.modelName} is a good fit for this task.`;
618
+ }
619
+
620
+ return result;
621
+ }
622
+
623
+ // ─── Utility ─────────────────────────────────────────────────────────────────
624
+
625
+ export function formatCost(n: number): string {
626
+ if (n >= 1) return '$' + n.toFixed(2);
627
+ if (n >= 0.01) return '$' + n.toFixed(3);
628
+ return '$' + n.toFixed(4);
629
+ }
dist/loomlens-sidebar.html ADDED
@@ -0,0 +1,1156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>LoomLens Live ✦</title>
6
+ <style>
7
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
8
+
9
+ :root {
10
+ --bg: #0a0e1a;
11
+ --surface: #111827;
12
+ --surface2: #1a2235;
13
+ --border: #1e2d4a;
14
+ --accent: #8b5cf6;
15
+ --accent-bright: #a78bfa;
16
+ --green: #10b981;
17
+ --amber: #f59e0b;
18
+ --red: #ef4444;
19
+ --text: #e8edf5;
20
+ --text-dim: #8899bb;
21
+ --text-muted: #4a6a9a;
22
+ --radius: 8px;
23
+ --font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
24
+ --mono: 'JetBrains Mono', 'Fira Code', monospace;
25
+ }
26
+
27
+ body {
28
+ font-family: var(--font);
29
+ background: transparent;
30
+ color: var(--text);
31
+ font-size: 12px;
32
+ line-height: 1.4;
33
+ width: 320px;
34
+ }
35
+
36
+ /* ── Root Panel ── */
37
+ .ll-panel {
38
+ background: var(--surface);
39
+ border: 1px solid var(--border);
40
+ border-radius: var(--radius);
41
+ overflow: hidden;
42
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5);
43
+ display: flex;
44
+ flex-direction: column;
45
+ max-height: 600px;
46
+ }
47
+
48
+ /* ── Header ── */
49
+ .ll-header {
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: space-between;
53
+ padding: 10px 12px;
54
+ background: var(--surface2);
55
+ border-bottom: 1px solid var(--border);
56
+ cursor: move;
57
+ user-select: none;
58
+ flex-shrink: 0;
59
+ }
60
+ .ll-header-left { display: flex; align-items: center; gap: 8px; }
61
+ .ll-dot {
62
+ width: 8px; height: 8px;
63
+ border-radius: 50%;
64
+ background: var(--green);
65
+ box-shadow: 0 0 8px var(--green);
66
+ animation: pulse 2s infinite;
67
+ flex-shrink: 0;
68
+ }
69
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
70
+ .ll-title { font-size: 13px; font-weight: 800; color: var(--text); letter-spacing: 0.3px; }
71
+ .ll-badge {
72
+ font-size: 9px; font-weight: 800;
73
+ padding: 2px 7px; border-radius: 4px;
74
+ letter-spacing: 0.5px; flex-shrink: 0;
75
+ }
76
+ .ll-badge.free { background: rgba(16,185,129,0.15); color: var(--green); border: 1px solid rgba(16,185,129,0.25); }
77
+ .ll-badge.used { background: rgba(245,158,11,0.15); color: var(--amber); border: 1px solid rgba(245,158,11,0.25); }
78
+ .ll-badge.locked { background: rgba(255,255,255,0.06); color: var(--text-muted); border: 1px solid var(--border); }
79
+ .ll-badge.live { background: rgba(139,92,246,0.15); color: var(--accent-bright); border: 1px solid rgba(139,92,246,0.25); }
80
+ .ll-hdr-btns { display: flex; gap: 3px; }
81
+ .ll-hdr-btn {
82
+ background: transparent; border: none;
83
+ color: var(--text-muted); cursor: pointer;
84
+ font-size: 14px; padding: 2px 6px; border-radius: 3px;
85
+ line-height: 1; transition: all 0.15s;
86
+ }
87
+ .ll-hdr-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
88
+
89
+ /* ── Prompt Input ── */
90
+ .ll-input-area {
91
+ padding: 10px 12px;
92
+ background: var(--bg);
93
+ border-bottom: 1px solid var(--border);
94
+ flex-shrink: 0;
95
+ }
96
+ .ll-prompt-wrap {
97
+ position: relative;
98
+ border: 1.5px dashed var(--border);
99
+ border-radius: var(--radius);
100
+ transition: border-color 0.2s;
101
+ min-height: 72px;
102
+ }
103
+ .ll-prompt-wrap.drag-over { border-color: var(--accent); background: rgba(139,92,246,0.05); }
104
+ .ll-prompt-wrap.focused { border-color: var(--accent); border-style: solid; }
105
+ .ll-prompt-ta {
106
+ width: 100%; min-height: 70px;
107
+ background: transparent; border: none; outline: none;
108
+ color: var(--text); font-family: var(--font);
109
+ font-size: 11px; line-height: 1.5;
110
+ padding: 8px 10px; resize: vertical;
111
+ placeholder-color: var(--text-muted);
112
+ }
113
+ .ll-prompt-ta::placeholder { color: var(--text-muted); }
114
+ .ll-drop-hint {
115
+ position: absolute; top: 50%; left: 50%;
116
+ transform: translate(-50%, -50%);
117
+ text-align: center; pointer-events: none;
118
+ color: var(--text-muted); font-size: 11px;
119
+ line-height: 1.5;
120
+ }
121
+ .ll-drop-hint.hidden { display: none; }
122
+
123
+ /* ── Filters ── */
124
+ .ll-filters {
125
+ display: flex; gap: 6px; align-items: center;
126
+ padding: 7px 12px;
127
+ border-bottom: 1px solid var(--border);
128
+ background: var(--surface2);
129
+ flex-shrink: 0;
130
+ }
131
+ .ll-filter-label { font-size: 10px; color: var(--text-muted); font-weight: 600; letter-spacing: 0.5px; }
132
+ .ll-select {
133
+ flex: 1; background: var(--bg); border: 1px solid var(--border);
134
+ border-radius: 5px; color: var(--text); font-family: var(--font);
135
+ font-size: 10px; padding: 4px 6px; cursor: pointer; outline: none;
136
+ }
137
+ .ll-select:focus { border-color: var(--accent); }
138
+
139
+ /* ── Results ── */
140
+ .ll-body {
141
+ overflow-y: auto;
142
+ flex: 1;
143
+ min-height: 0;
144
+ }
145
+ .ll-body::-webkit-scrollbar { width: 3px; }
146
+ .ll-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; }
147
+
148
+ .ll-cluster { margin-bottom: 4px; }
149
+
150
+ .ll-cluster-header {
151
+ padding: 5px 12px 3px;
152
+ font-size: 9px; font-weight: 800;
153
+ letter-spacing: 1px; color: var(--text-muted);
154
+ text-transform: uppercase;
155
+ background: var(--bg);
156
+ border-top: 1px solid var(--border);
157
+ border-bottom: 1px solid var(--border);
158
+ position: sticky; top: 0;
159
+ display: flex; align-items: center; justify-content: space-between;
160
+ }
161
+ .ll-cluster-count { font-weight: 400; letter-spacing: 0; text-transform: none; }
162
+
163
+ .ll-card {
164
+ padding: 7px 12px;
165
+ cursor: pointer;
166
+ border-bottom: 1px solid rgba(30,45,74,0.5);
167
+ transition: background 0.1s;
168
+ display: flex; flex-direction: column; gap: 3px;
169
+ }
170
+ .ll-card:last-child { border-bottom: none; }
171
+ .ll-card:hover { background: rgba(255,255,255,0.02); }
172
+ .ll-card.selected { background: rgba(139,92,246,0.08); border-left: 2px solid var(--accent); padding-left: 10px; }
173
+ .ll-card.recommended { border-left: 2px solid var(--green); padding-left: 10px; }
174
+
175
+ .ll-card-top {
176
+ display: flex; align-items: center; gap: 6px;
177
+ }
178
+ .ll-radio {
179
+ width: 12px; height: 12px;
180
+ border-radius: 50%; flex-shrink: 0;
181
+ display: flex; align-items: center; justify-content: center;
182
+ }
183
+ .ll-card.selected .ll-radio {
184
+ background: var(--accent);
185
+ box-shadow: 0 0 6px rgba(139,92,246,0.6);
186
+ }
187
+ .ll-card:not(.selected) .ll-radio {
188
+ border: 1.5px solid var(--text-muted);
189
+ }
190
+ .ll-card-name {
191
+ font-size: 12px; font-weight: 600;
192
+ color: var(--text-dim); flex: 1; min-width: 0;
193
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
194
+ }
195
+ .ll-card.selected .ll-card-name { color: var(--accent-bright); }
196
+ .ll-card-cost {
197
+ font-size: 11px; font-family: var(--mono);
198
+ color: var(--text-dim); flex-shrink: 0;
199
+ }
200
+ .ll-card-speed {
201
+ font-size: 10px; color: var(--green); flex-shrink: 0;
202
+ display: flex; align-items: center; gap: 2px;
203
+ }
204
+ .ll-card-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
205
+
206
+ .ll-card-bar-row { display: flex; align-items: center; gap: 6px; padding-left: 18px; }
207
+ .ll-card-bar-wrap {
208
+ flex: 1; height: 3px; background: rgba(255,255,255,0.06);
209
+ border-radius: 99px; overflow: hidden;
210
+ }
211
+ .ll-card-bar { height: 100%; background: var(--text-muted); border-radius: 99px; transition: width 0.3s; }
212
+ .ll-card.selected .ll-card-bar { background: var(--accent); }
213
+ .ll-card.recommended .ll-card-bar { background: var(--green); }
214
+
215
+ .ll-card-insight {
216
+ font-size: 10px; color: var(--text-muted);
217
+ padding-left: 18px; line-height: 1.4;
218
+ }
219
+ .ll-card-badges { display: flex; gap: 4px; padding-left: 18px; flex-wrap: wrap; }
220
+ .ll-card-badge {
221
+ font-size: 9px; font-weight: 700; padding: 1px 5px;
222
+ border-radius: 3px;
223
+ }
224
+ .ll-card-badge.rec { background: rgba(16,185,129,0.15); color: var(--green); }
225
+ .ll-card-badge.over { background: rgba(245,158,11,0.12); color: var(--amber); }
226
+ .ll-card-badge.sel { background: rgba(139,92,246,0.15); color: var(--accent-bright); }
227
+
228
+ /* ── Tip ── */
229
+ .ll-tip {
230
+ margin: 6px 12px;
231
+ padding: 7px 10px;
232
+ background: rgba(16,185,129,0.07);
233
+ border: 1px solid rgba(16,185,129,0.15);
234
+ border-radius: 5px;
235
+ font-size: 10px; color: var(--green);
236
+ line-height: 1.5;
237
+ }
238
+
239
+ /* ── Footer ── */
240
+ .ll-footer {
241
+ padding: 10px 12px;
242
+ border-top: 1px solid var(--border);
243
+ background: var(--surface2);
244
+ display: flex; flex-direction: column; gap: 7px;
245
+ flex-shrink: 0;
246
+ }
247
+ .ll-quota-row {
248
+ display: flex; justify-content: space-between; align-items: center;
249
+ font-size: 10px;
250
+ }
251
+ .ll-quota-label { color: var(--text-muted); }
252
+ .ll-quota-val { font-family: var(--mono); color: var(--text-dim); }
253
+ .ll-quota-val.warn { color: var(--amber); }
254
+ .ll-quota-val.ok { color: var(--green); }
255
+
256
+ .ll-key-row { display: flex; gap: 5px; align-items: stretch; }
257
+ .ll-key-input {
258
+ flex: 1; background: var(--bg); border: 1px solid var(--border);
259
+ border-radius: 5px; padding: 6px 8px;
260
+ color: var(--text); font-family: var(--mono); font-size: 10px;
261
+ outline: none;
262
+ }
263
+ .ll-key-input:focus { border-color: var(--accent); }
264
+ .ll-key-input::placeholder { color: var(--text-muted); font-family: var(--font); }
265
+ .ll-btn {
266
+ font-family: var(--font); font-size: 10px; font-weight: 700;
267
+ padding: 6px 10px; border-radius: 5px; border: none;
268
+ cursor: pointer; transition: opacity 0.15s; white-space: nowrap;
269
+ }
270
+ .ll-btn:hover { opacity: 0.85; }
271
+ .ll-btn.primary { background: var(--accent); color: white; }
272
+ .ll-btn.ghost {
273
+ background: rgba(255,255,255,0.06); color: var(--text-dim);
274
+ border: 1px solid var(--border);
275
+ }
276
+ .ll-btn.danger { background: rgba(239,68,68,0.15); color: var(--red); border: 1px solid rgba(239,68,68,0.2); }
277
+
278
+ .ll-run-btn {
279
+ width: 100%;
280
+ background: linear-gradient(135deg, var(--accent), #7c3aed);
281
+ color: white; border: none;
282
+ border-radius: 6px; padding: 10px;
283
+ font-family: var(--font); font-size: 12px; font-weight: 800;
284
+ cursor: pointer; transition: all 0.15s;
285
+ display: flex; align-items: center; justify-content: center; gap: 6px;
286
+ letter-spacing: 0.3px;
287
+ }
288
+ .ll-run-btn:hover { opacity: 0.9; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(139,92,246,0.4); }
289
+ .ll-run-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
290
+
291
+ .ll-attribution {
292
+ font-size: 9px; color: var(--text-muted); text-align: center;
293
+ display: flex; align-items: center; justify-content: center; gap: 4px;
294
+ }
295
+ .ll-attribution a { color: var(--text-muted); text-decoration: none; }
296
+ .ll-attribution a:hover { color: var(--accent-bright); }
297
+
298
+ /* ── Empty / Locked State ── */
299
+ .ll-state {
300
+ padding: 24px 20px;
301
+ text-align: center;
302
+ display: flex; flex-direction: column; gap: 12px; align-items: center;
303
+ }
304
+ .ll-state-icon { font-size: 28px; opacity: 0.6; }
305
+ .ll-state-title { font-size: 13px; font-weight: 700; color: var(--text-dim); }
306
+ .ll-state-sub { font-size: 11px; color: var(--text-muted); line-height: 1.5; }
307
+ .ll-state-sub a { color: var(--accent-bright); text-decoration: none; }
308
+ .ll-state-sub a:hover { text-decoration: underline; }
309
+
310
+ /* ── Minimized Dock ── */
311
+ .ll-dock {
312
+ width: 44px; height: 44px;
313
+ background: var(--surface);
314
+ border: 1px solid var(--border);
315
+ border-radius: 12px;
316
+ display: flex; align-items: center; justify-content: center;
317
+ cursor: pointer;
318
+ box-shadow: 0 4px 16px rgba(0,0,0,0.4);
319
+ transition: all 0.2s;
320
+ position: relative;
321
+ }
322
+ .ll-dock:hover { border-color: var(--accent); transform: scale(1.05); }
323
+ .ll-dock-inner {
324
+ font-size: 16px; font-weight: 900;
325
+ color: var(--accent-bright);
326
+ letter-spacing: -1px;
327
+ }
328
+ .ll-dock-pulse {
329
+ position: absolute; top: -3px; right: -3px;
330
+ width: 10px; height: 10px;
331
+ border-radius: 50%;
332
+ background: var(--green);
333
+ box-shadow: 0 0 6px var(--green);
334
+ animation: pulse 2s infinite;
335
+ }
336
+ .ll-dock-pulse.used { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
337
+ .ll-dock-pulse.gone { display: none; }
338
+ </style>
339
+ </head>
340
+ <body>
341
+
342
+ <!-- ── DOCK (minimized) ─────────────────────────────── -->
343
+ <div class="ll-dock" id="ll_dock" onclick="LL.open()" title="LoomLens Live ✦">
344
+ <div class="ll-dock-inner">✦</div>
345
+ <div class="ll-dock-pulse" id="ll_dock_pulse"></div>
346
+ </div>
347
+
348
+ <!-- ── PANEL ─────────────────────────────────────── -->
349
+ <div class="ll-panel" id="ll_panel" style="display:none;">
350
+
351
+ <!-- Header -->
352
+ <div class="ll-header">
353
+ <div class="ll-header-left">
354
+ <div class="ll-dot"></div>
355
+ <span class="ll-title">LoomLens Live</span>
356
+ <span class="ll-badge free" id="ll_status_badge">3 FREE</span>
357
+ </div>
358
+ <div class="ll-hdr-btns">
359
+ <button class="ll-hdr-btn" id="ll_btn_min" title="Minimize">−</button>
360
+ <button class="ll-hdr-btn" id="ll_btn_close" title="Close">×</button>
361
+ </div>
362
+ </div>
363
+
364
+ <!-- Prompt Input -->
365
+ <div class="ll-input-area">
366
+ <div class="ll-prompt-wrap" id="ll_prompt_wrap">
367
+ <textarea
368
+ class="ll-prompt-ta"
369
+ id="ll_prompt_ta"
370
+ placeholder="Type or drop prompt here to estimate…"
371
+ rows="3"
372
+ ></textarea>
373
+ <div class="ll-drop-hint" id="ll_drop_hint">
374
+ ✦ Drop prompt here to estimate cost<br>
375
+ <span style="font-size:10px;opacity:0.6">or paste/type in the box above</span>
376
+ </div>
377
+ </div>
378
+ </div>
379
+
380
+ <!-- Filters -->
381
+ <div class="ll-filters">
382
+ <span class="ll-filter-label">Cluster:</span>
383
+ <select class="ll-select" id="ll_cluster_sel">
384
+ <option value="all">All Clusters</option>
385
+ <option value="flash">Flash Reasoning</option>
386
+ <option value="fastgen">Fast General</option>
387
+ <option value="code">Balanced Coding</option>
388
+ <option value="power">Power / Architecture</option>
389
+ <option value="vision">Vision / Multimodal</option>
390
+ </select>
391
+ <span class="ll-filter-label">Sort:</span>
392
+ <select class="ll-select" id="ll_sort_sel">
393
+ <option value="score">Best Fit</option>
394
+ <option value="cost">Lowest Cost</option>
395
+ <option value="speed">Fastest</option>
396
+ </select>
397
+ </div>
398
+
399
+ <!-- Results Body -->
400
+ <div class="ll-body" id="ll_body">
401
+ <div class="ll-state" id="ll_empty_state">
402
+ <div class="ll-state-icon">✦</div>
403
+ <div class="ll-state-title">LoomLens Live</div>
404
+ <div class="ll-state-sub">
405
+ Enter a prompt above to see cost estimates<br>across all major models
406
+ </div>
407
+ </div>
408
+ </div>
409
+
410
+ <!-- Tip -->
411
+ <div class="ll-tip" id="ll_tip" style="display:none;"></div>
412
+
413
+ <!-- Footer -->
414
+ <div class="ll-footer">
415
+ <div class="ll-quota-row">
416
+ <span class="ll-quota-label" id="ll_quota_label">Free estimates:</span>
417
+ <span class="ll-quota-val ok" id="ll_quota_val">3 left</span>
418
+ </div>
419
+ <div class="ll-key-row" id="ll_key_row" style="display:none;">
420
+ <input type="password" class="ll-key-input" id="ll_key_input" placeholder="sk_... Signal Loom API key" />
421
+ <button class="ll-btn primary" onclick="LL.connectKey()">Connect</button>
422
+ </div>
423
+ <button class="ll-btn ghost" id="ll_show_key" onclick="LL.toggleKeyRow()" style="font-size:9px;">
424
+ + Connect API key for unlimited
425
+ </button>
426
+ <button class="ll-run-btn" id="ll_run_btn" onclick="LL.runEstimate()" disabled>
427
+ Select a model to run →
428
+ </button>
429
+ <div class="ll-attribution">
430
+ Powered by <a href="https://signalloomai.com" target="_blank">Signal Loom AI</a> ✦
431
+ </div>
432
+ </div>
433
+
434
+ </div>
435
+
436
+ <script>
437
+ (function() {
438
+ 'use strict';
439
+
440
+ // ─── State ────────────────────────────────────────────────────────────────
441
+ const STORAGE_KEYS = {
442
+ apiKey: 'llive_api_key',
443
+ freeUsed: 'llive_free_used',
444
+ freeReset:'llive_free_reset',
445
+ selected: 'llive_selected_model',
446
+ };
447
+ const FREE_LIMIT = 3;
448
+ const CPT = 3.8; // chars per token (English average)
449
+
450
+ let state = {
451
+ prompt: '',
452
+ apiKey: localStorage.getItem(STORAGE_KEYS.apiKey) || '',
453
+ freeUsed: parseInt(localStorage.getItem(STORAGE_KEYS.freeUsed) || '0'),
454
+ freeReset: parseInt(localStorage.getItem(STORAGE_KEYS.freeReset) || '0'),
455
+ selectedModel: localStorage.getItem(STORAGE_KEYS.selected) || '',
456
+ result: null,
457
+ minimized: false,
458
+ };
459
+
460
+ // ─── Model Clusters ────────────────────────────────────────────────────────
461
+ const CLUSTERS = [
462
+ {
463
+ id: 'flash',
464
+ name: 'Flash Reasoning',
465
+ desc: 'Fast + cheap. Best for Q&A, summaries, simple tasks.',
466
+ models: [
467
+ { id: 'minimax/MiniMax-M2', name: 'MiniMax M2', provider: 'MiniMax', tier: 3, inPrice: 0.10, outPrice: 0.40, latency: 'fast', caps: ['reasoning','fast','cheap'] },
468
+ { id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'Google', tier: 3, inPrice: 0.15, outPrice: 0.60, latency: 'fast', caps: ['reasoning','fast'] },
469
+ { id: 'anthropic/claude-haiku', name: 'Claude Haiku', provider: 'Anthropic',tier: 3, inPrice: 0.80, outPrice: 4.00, latency: 'fast', caps: ['fast','light-reasoning'] },
470
+ { id: 'deepseek/deepseek-chat-v3',name:'DeepSeek Chat v3', provider: 'DeepSeek',tier: 3, inPrice: 0.27, outPrice: 1.10, latency: 'fast', caps: ['reasoning','fast'] },
471
+ ],
472
+ },
473
+ {
474
+ id: 'fastgen',
475
+ name: 'Fast General',
476
+ desc: 'Fast general purpose. Best latency for typical prompts.',
477
+ models: [
478
+ { id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini', provider: 'OpenAI', tier: 3, inPrice: 0.15, outPrice: 0.60, latency: 'fast', caps: ['fast','cheap'] },
479
+ { id: 'google/gemini-2.0-flash', name: 'Gemini 2.0 Flash', provider: 'Google', tier: 3, inPrice: 0.10, outPrice: 0.40, latency: 'fast', caps: ['fast','cheap'] },
480
+ { id: 'xai/grok-3', name: 'xAI Grok-3', provider: 'xAI', tier: 3, inPrice: 2.00, outPrice: 10.0, latency: 'fast', caps: ['fast','reasoning'] },
481
+ ],
482
+ },
483
+ {
484
+ id: 'code',
485
+ name: 'Balanced Coding',
486
+ desc: 'Best code capability per dollar. Medium tasks.',
487
+ models: [
488
+ { id: 'anthropic/claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'Anthropic', tier: 2, inPrice: 3.00, outPrice: 15.0, latency: 'medium', caps: ['reasoning','code','analysis'] },
489
+ { id: 'openai/gpt-4o', name: 'GPT-4o', provider: 'OpenAI', tier: 2, inPrice: 2.50, outPrice: 10.0, latency: 'medium', caps: ['reasoning','vision','code'] },
490
+ { id: 'anthropic/claude-3.5-sonnet',name: 'Claude 3.5 Sonnet', provider: 'Anthropic',tier: 2, inPrice: 3.00, outPrice: 15.0, latency: 'medium', caps: ['code','reasoning'] },
491
+ ],
492
+ },
493
+ {
494
+ id: 'power',
495
+ name: 'Power / Architecture',
496
+ desc: 'Maximum reasoning. Pay for it only when needed.',
497
+ models: [
498
+ { id: 'anthropic/claude-opus-4-20250514', name: 'Claude Opus 4', provider: 'Anthropic', tier: 1, inPrice: 15.0, outPrice: 75.0, latency: 'slow', caps: ['reasoning','code','analysis','long-context'] },
499
+ { id: 'anthropic/claude-code', name: 'Claude Code', provider: 'Anthropic', tier: 1, inPrice: 15.0, outPrice: 75.0, latency: 'slow', caps: ['agentic','code','architecture'] },
500
+ ],
501
+ },
502
+ {
503
+ id: 'vision',
504
+ name: 'Vision / Multimodal',
505
+ desc: 'Image + document understanding.',
506
+ models: [
507
+ { id: 'openai/gpt-4o', name: 'GPT-4o', provider: 'OpenAI', tier: 4, inPrice: 2.50, outPrice: 10.0, latency: 'medium', caps: ['vision','reasoning','multimodal'] },
508
+ { id: 'anthropic/claude-3.5', name: 'Claude 3.5', provider: 'Anthropic',tier: 4, inPrice: 3.00, outPrice: 15.0, latency: 'medium', caps: ['vision','code','analysis'] },
509
+ ],
510
+ },
511
+ ];
512
+
513
+ // ─── Task Detection ��───────────────────────────────────────────────────────
514
+ const TASK_KEYWORDS = {
515
+ simple_qa: ['what is','who is','define','explain','how do i','how does','what are','why does','when did','where is','tell me'],
516
+ summary: ['summarize','tl;dr','sum up','brief','recap','key points','highlights','overview','condense'],
517
+ code_edit: ['fix','bug','debug','hotfix','patch','tweak','adjust','correct','error','issue'],
518
+ code_write: ['write','build','create','implement','add feature','new function','generate',' scaffold','code for'],
519
+ complex_analysis: ['analyze','architecture','review','refactor','design','strategy','compare','evaluate','assess','plan'],
520
+ vision: ['image','photo','picture','screenshot','diagram','chart','visual','frame','pdf','document'],
521
+ };
522
+
523
+ const OUTPUT_TOKENS = { simple_qa:150, summary:200, code_edit:400, code_write:800, complex_analysis:600, vision:300, unknown:400 };
524
+ const LATENCY_SCORE = { fast: 100, medium: 62, slow: 22 };
525
+
526
+ function detectTask(prompt) {
527
+ const lower = prompt.toLowerCase();
528
+ const scores = {};
529
+ for (const [type, kws] of Object.entries(TASK_KEYWORDS)) {
530
+ scores[type] = kws.reduce((acc, kw) => acc + (lower.includes(kw) ? 1 : 0), 0);
531
+ }
532
+ // Boost code signals
533
+ if (/```[\s\S]+```/.test(prompt)) scores.code_write = (scores.code_write||0) + 2;
534
+ if (/^\s*(import|from|const|let|function|class|def |fn |pub fn)/m.test(prompt)) scores.code_write = (scores.code_write||0) + 2;
535
+ const best = Object.entries(scores).sort((a,b) => b[1]-a[1])[0];
536
+ return best && best[1] > 0 ? best[0] : 'unknown';
537
+ }
538
+
539
+ // ─── Capability Fit per Cluster ──────────────────────────────────────────
540
+ const CAP_FIT = {
541
+ // task → cluster → fit 0-100
542
+ simple_qa: { flash: 95, fastgen: 80, code: 30, power: 20, vision: 20 },
543
+ summary: { flash: 90, fastgen: 75, code: 40, power: 30, vision: 20 },
544
+ code_edit: { flash: 60, fastgen: 40, code: 90, power: 40, vision: 20 },
545
+ code_write: { flash: 30, fastgen: 20, code: 95, power: 95, vision: 20 },
546
+ complex_analysis: { flash: 40, fastgen: 30, code: 85, power: 98, vision: 20 },
547
+ vision: { flash: 10, fastgen: 10, code: 30, power: 30, vision: 98 },
548
+ unknown: { flash: 70, fastgen: 60, code: 60, power: 50, vision: 40 },
549
+ };
550
+
551
+ // ─── Cost Calculation ─────────────────────────────────────────────────────
552
+ function estimateTokens(text) {
553
+ if (!text || !text.trim()) return 0;
554
+ const lang = /[\u3040-\u309F\u30A0-\u30FF]/.test(text) ? 'ja' : /[\u4E00-\u9FFF]/.test(text) ? 'zh' : /[áéíóúñ¿¡ü]/i.test(text) ? 'es' : 'en';
555
+ const cpt = lang === 'ja' ? 1.2 : lang === 'zh' ? 1.0 : lang === 'es' ? 3.5 : CPT;
556
+ const multiline = (text.match(/\n/g)||[]).length;
557
+ const codeFences = (text.match(/```/g)||[]).length;
558
+ const mult = (codeFences >= 2 || multiline > 3) ? 1.2 : 1.0;
559
+ return Math.ceil((text.length / cpt) * mult);
560
+ }
561
+
562
+ function calcCost(model, inTokens, outTokens) {
563
+ return ((inTokens/1e6)*model.inPrice) + ((outTokens/1e6)*model.outPrice);
564
+ }
565
+
566
+ // ─── Scoring ─────────────────────────────────────────────────────────────
567
+ function scoreModel(model, task, clusterModels) {
568
+ const capFit = CAP_FIT[task]?.[model.clusterId] ?? 50;
569
+ const costs = clusterModels.map(m => calcCost(m,1,1)); // relative
570
+ const minC = Math.min(...costs);
571
+ const maxC = Math.max(...costs);
572
+ const range = maxC - minC || 1;
573
+ const costEff = Math.round(100 - ((calcCost(model,1,1)-minC)/range*100));
574
+ const speed = LATENCY_SCORE[model.latency] ?? 50;
575
+ return Math.round(capFit * 0.35 + costEff * 0.35 + speed * 0.30);
576
+ }
577
+
578
+ // ─── Main Estimation ─────────────────────────────────────────────────────
579
+ function estimate(prompt, selectedId) {
580
+ if (!prompt || prompt.trim().length < 10) return null;
581
+ const task = detectTask(prompt);
582
+ const inTokens = estimateTokens(prompt);
583
+ const outTokens = OUTPUT_TOKENS[task] ?? 400;
584
+ const flat = [];
585
+
586
+ for (const cluster of CLUSTERS) {
587
+ for (const model of cluster.models) {
588
+ const m = { ...model, clusterId: cluster.id, clusterName: cluster.name };
589
+ m.cost = calcCost(m, inTokens, outTokens);
590
+ m.inTokens = inTokens;
591
+ m.outTokens= outTokens;
592
+ flat.push(m);
593
+ }
594
+ }
595
+
596
+ // Per-cluster scoring
597
+ const clusterResults = {};
598
+ for (const cluster of CLUSTERS) {
599
+ const cModels = flat.filter(m => m.clusterId === cluster.id);
600
+ cModels.forEach(m => { m.score = scoreModel(m, task, cModels); });
601
+ const sorted = [...cModels].sort((a,b) => b.score - a.score);
602
+ const best = sorted.find(m => m.score >= 65) ?? sorted[0];
603
+ clusterResults[cluster.id] = { sorted, best, cluster };
604
+ }
605
+
606
+ // Global
607
+ const allScored = flat.map(m => {
608
+ const cr = clusterResults[m.clusterId];
609
+ m.score = cr.best === m ? m.score : Math.round(m.score * 0.7); // penalize non-cluster-best
610
+ return m;
611
+ });
612
+ const globalSorted = [...allScored].sort((a,b) => b.score - a.score);
613
+ const recommended = globalSorted.find(m => m.score >= 80);
614
+ const selected = flat.find(m => m.id === selectedId) ?? globalSorted[0];
615
+
616
+ // Tip
617
+ let tip = '';
618
+ if (recommended && recommended.id !== selectedId) {
619
+ const saving = (flat.find(m=>m.id===selectedId)?.cost ?? 0) - recommended.cost;
620
+ if (saving > 0.0001) {
621
+ tip = `${recommended.name} is better for this task. Switching saves ${fmtCost(saving)}.`;
622
+ }
623
+ }
624
+
625
+ return {
626
+ task, inTokens, outTokens,
627
+ clusters: clusterResults,
628
+ all: globalSorted,
629
+ recommended,
630
+ selected,
631
+ tip,
632
+ };
633
+ }
634
+
635
+ // ─── Formatting ──────────────────────────────────────────────────────────
636
+ function fmtCost(n) {
637
+ if (n >= 1) return '$' + n.toFixed(2);
638
+ if (n >= 0.01) return '$' + n.toFixed(3);
639
+ return '$' + n.toFixed(4);
640
+ }
641
+
642
+ // ─── API Response Mapper ─────────────────────────────────────────────────
643
+ // Maps Signal Loom /v1/estimate response → local LoomLensResult format.
644
+ // We pass all cluster models and inject the API cost into the right one.
645
+ function _mapAPIResponse(data, selectedId, prompt) {
646
+ // Find the cheapest from the API response
647
+ const apiModels = (data.models || []).sort((a,b) => a.estimated_cost_mid - b.estimated_cost_mid);
648
+ const cheapestAPI = apiModels[0];
649
+ const expensiveAPI = apiModels[apiModels.length - 1];
650
+
651
+ // Run local engine to get clusters, then inject API costs
652
+ const local = estimate(prompt, selectedId);
653
+
654
+ if (!cheapestAPI) return local;
655
+
656
+ // Inject API cost into the selected model
657
+ const selectedModel = local.allModels.find(m => m.modelId === selectedId) || local.allModels[0];
658
+ if (selectedModel) {
659
+ selectedModel.inputTokens = apiModels.find(m => m.model === selectedId)?.input_tokens_estimate || selectedModel.inputTokens;
660
+ selectedModel.outputTokens = apiModels.find(m => m.model === selectedId)?.output_tokens_estimate || selectedModel.outputTokens;
661
+ selectedModel.totalCost = apiModels.find(m => m.model === selectedId)?.estimated_cost_mid || selectedModel.totalCost;
662
+ }
663
+
664
+ // Update cheapest/expensive based on API data
665
+ const cheapestLocal = local.allModels.find(m => m.modelId === cheapestAPI.model);
666
+ if (cheapestLocal) cheapestLocal.totalCost = cheapestAPI.estimated_cost_mid;
667
+
668
+ // Re-sort clusters with injected costs
669
+ for (const [cid, cr] of Object.entries(local.clusters)) {
670
+ cr.models.sort((a,b) => a.totalCost - b.totalCost);
671
+ cr.best = cr.models.find(m => m.compositeScore >= 65) ?? cr.models[0];
672
+ }
673
+ local.allModels.sort((a,b) => b.compositeScore - a.compositeScore);
674
+ local.recommended = local.allModels.find(m => m.compositeScore >= 80) ?? cheapestLocal;
675
+
676
+ // Tip using API costs
677
+ const rec = local.recommended;
678
+ const sel = selectedModel;
679
+ if (rec && sel && rec.modelId !== sel.modelId) {
680
+ const saving = sel.totalCost - rec.totalCost;
681
+ if (saving > 0.0001) local.tip = `Switch to ${rec.modelName} and save ${fmtCost(saving)}.`;
682
+ else if (saving < -0.0001) local.tip = `${sel.modelName} costs ${fmtCost(Math.abs(saving))} more.`;
683
+ } else if (sel) {
684
+ local.tip = `${sel.modelName} is a good fit for this task.`;
685
+ }
686
+
687
+ // Attach billing metadata from API response
688
+ local._billing = data.billing || null;
689
+ local._apiResponse = data;
690
+ return local;
691
+ }
692
+
693
+ // ─── Loading State ─────────────────────────────────────────────────────────
694
+ let loading = false;
695
+ function setLoading(v) { loading = v; }
696
+
697
+ // ─── Free Usage ──────────────────────────────────────────────────────────
698
+ // Reset free counter at midnight UTC.
699
+ function checkFreeReset() {
700
+ const now = Math.floor(Date.now() / 1000);
701
+ if (now >= state.freeReset) {
702
+ state.freeUsed = 0;
703
+ state.freeReset = now + 86400; // reset at next midnight UTC
704
+ localStorage.setItem(STORAGE_KEYS.freeUsed, '0');
705
+ localStorage.setItem(STORAGE_KEYS.freeReset, String(state.freeReset));
706
+ }
707
+ }
708
+
709
+ // Decrement free run counter. Returns false when all 3 daily free runs are exhausted.
710
+ function useFree() {
711
+ if (state.apiKey) return true; // API key = paid, always allowed
712
+ checkFreeReset();
713
+ if (state.freeUsed >= FREE_LIMIT) return false; // no free runs left
714
+ state.freeUsed++;
715
+ localStorage.setItem(STORAGE_KEYS.freeUsed, String(state.freeUsed));
716
+ return true;
717
+ }
718
+
719
+ // Returns remaining free runs today (0 when exhausted).
720
+ function freeLeft() {
721
+ if (state.apiKey) return Infinity;
722
+ checkFreeReset();
723
+ return Math.max(0, FREE_LIMIT - state.freeUsed);
724
+ }
725
+
726
+ // ─── Render ──────────────────────────────────────────────────────────────
727
+ const $ = id => document.getElementById(id);
728
+
729
+ function render() {
730
+ const left = freeLeft();
731
+ const hasApi = !!state.apiKey;
732
+ const panel = $('ll_panel');
733
+ const dock = $('ll_dock');
734
+
735
+ // Badge — show -3, -2, -1 counter for free tier
736
+ const badge = $('ll_status_badge');
737
+ if (hasApi) {
738
+ badge.textContent = 'LIVE'; badge.className = 'll-badge live';
739
+ } else if (left > 0) {
740
+ const displayCount = -(FREE_LIMIT - left + 1); // -3, -2, -1
741
+ badge.textContent = displayCount; badge.className = 'll-badge free';
742
+ } else {
743
+ badge.textContent = '0'; badge.className = 'll-badge locked';
744
+ }
745
+
746
+ // Dock pulse
747
+ const pulse = $('ll_dock_pulse');
748
+ if (hasApi || left > 0) pulse.className = 'll-dock-pulse';
749
+ else if (left === 0) pulse.className = 'll-dock-pulse used';
750
+ else pulse.className = 'll-dock-pulse gone';
751
+
752
+ // Quota
753
+ const qLabel = $('ll_quota_label');
754
+ const qVal = $('ll_quota_val');
755
+ if (hasApi) {
756
+ // Show quota from last API response headers
757
+ const quota = state.result?._quota;
758
+ if (quota && quota.monthly_limit > 0) {
759
+ const rem = quota.remaining_this_month;
760
+ qLabel.textContent = 'API quota:';
761
+ qVal.textContent = rem === 0 ? 'Exhausted ⚠' : `${rem} remaining`;
762
+ qVal.className = rem === 0 ? 'll-quota-val warn' : 'll-quota-val ok';
763
+ } else {
764
+ qLabel.textContent = 'API key:'; qVal.textContent = 'Connected ✓'; qVal.className = 'll-quota-val ok';
765
+ }
766
+ } else {
767
+ const displayCount = left > 0 ? -(FREE_LIMIT - left + 1) : 0; // -3, -2, -1, then 0
768
+ qLabel.textContent = 'Free runs:';
769
+ qVal.textContent = displayCount < 0 ? displayCount : '0 (paid)';
770
+ qVal.className = displayCount < 0 ? 'll-quota-val ok' : 'll-quota-val warn';
771
+ }
772
+
773
+ // Key row
774
+ if (hasApi) {
775
+ $('ll_key_row').style.display = 'none';
776
+ $('ll_show_key').style.display = 'flex';
777
+ $('ll_show_key').className = 'll-btn ghost';
778
+ $('ll_show_key').textContent = 'Disconnect key';
779
+ }
780
+
781
+ renderBody();
782
+ renderTip();
783
+ }
784
+
785
+ function renderBody() {
786
+ const result = state.result;
787
+ const body = $('ll_body');
788
+ const empty = $('ll_empty_state');
789
+ const clusterSel = $('ll_cluster_sel').value;
790
+ const sortSel = $('ll_sort_sel').value;
791
+
792
+ // Loading state
793
+ if (loading) {
794
+ body.innerHTML = `<div class="ll-state">
795
+ <div class="ll-state-icon">⚡</div>
796
+ <div class="ll-state-title">Estimating…</div>
797
+ <div class="ll-state-sub">Calling Signal Loom API</div>
798
+ </div>`;
799
+ return;
800
+ }
801
+
802
+ // Error state
803
+ if (result?.error) {
804
+ const isQuota = result.error === 'quota_exhausted';
805
+ body.innerHTML = `<div class="ll-state">
806
+ <div class="ll-state-icon">${isQuota ? '📊' : '🔑'}</div>
807
+ <div class="ll-state-title">${isQuota ? 'Quota Exhausted' : 'API Key Issue'}</div>
808
+ <div class="ll-state-sub">${result.message}<br><br>
809
+ <a href="https://signallloomai.com/signup.html" target="_blank" style="color:#a78bfa">Get API key →</a>
810
+ &nbsp;·&nbsp;
811
+ <a href="https://signallloomai.com/pricing" target="_blank" style="color:#a78bfa">View plans →</a>
812
+ </div>
813
+ </div>`;
814
+ return;
815
+ }
816
+
817
+ if (!result) {
818
+ body.innerHTML = '';
819
+ body.appendChild(empty);
820
+ empty.style.display = '';
821
+ return;
822
+ }
823
+ empty.style.display = 'none';
824
+
825
+ // Collect models to show
826
+ let models = [];
827
+ if (clusterSel === 'all') {
828
+ models = result.all;
829
+ } else {
830
+ models = result.clusters[clusterSel]?.sorted ?? [];
831
+ }
832
+
833
+ // Sort
834
+ if (sortSel === 'cost') models = [...models].sort((a,b) => a.cost - b.cost);
835
+ else if (sortSel === 'speed') models = [...models].sort((a,b) => LATENCY_SCORE[b.latency] - LATENCY_SCORE[a.latency]);
836
+ // else: keep score order
837
+
838
+ const maxCost = Math.max(...result.all.map(m=>m.cost));
839
+
840
+ // Group by cluster
841
+ const groups = {};
842
+ for (const m of models) {
843
+ if (!groups[m.clusterId]) groups[m.clusterId] = [];
844
+ groups[m.clusterId].push(m);
845
+ }
846
+
847
+ let html = '';
848
+ for (const [cid, cmodels] of Object.entries(groups)) {
849
+ const cluster = CLUSTERS.find(c=>c.id===cid);
850
+ const best = result.clusters[cid]?.best;
851
+ html += `<div class="ll-cluster">
852
+ <div class="ll-cluster-header">
853
+ ${cluster?.name ?? cid}
854
+ <span class="ll-cluster-count">${cmodels.length} models</span>
855
+ </div>`;
856
+
857
+ for (const m of cmodels) {
858
+ const isSel = m.id === state.selectedModel;
859
+ const isBest = best?.id === m.id;
860
+ const barW = Math.max(4, Math.round((m.cost / maxCost) * 100));
861
+ const speedGlyph = m.latency === 'fast' ? '⚡' : m.latency === 'medium' ? '◆' : '◇';
862
+ const badges = [];
863
+ if (isSel) badges.push('<span class="ll-card-badge sel">✓ Selected</span>');
864
+ if (isBest && !isSel) badges.push('<span class="ll-card-badge rec">💡 Best in group</span>');
865
+ if (m.score < 50) badges.push('<span class="ll-card-badge over">⚠️ Review</span>');
866
+ html += `<div class="ll-card${isSel?' selected':''}${isBest&&!isSel?' recommended':''}" onclick="LL.select('${m.id}')">
867
+ <div class="ll-card-top">
868
+ <div class="ll-radio"></div>
869
+ <div class="ll-card-name">${m.name}</div>
870
+ <div class="ll-card-right">
871
+ <div class="ll-card-speed">${speedGlyph} ${LATENCY_SCORE[m.latency]}</div>
872
+ <div class="ll-card-cost">${fmtCost(m.cost)}</div>
873
+ </div>
874
+ </div>
875
+ <div class="ll-card-bar-row">
876
+ <div class="ll-card-bar-wrap"><div class="ll-card-bar" style="width:${barW}%"></div></div>
877
+ </div>
878
+ <div class="ll-card-insight">${m.clusterName} · ${m.provider} · ~${m.inTokens.toLocaleString()} in / ~${m.outTokens} out</div>
879
+ ${badges.length ? `<div class="ll-card-badges">${badges.join('')}</div>` : ''}
880
+ </div>`;
881
+ }
882
+ html += '</div>';
883
+ }
884
+
885
+ body.innerHTML = html;
886
+ }
887
+
888
+ function renderTip() {
889
+ const tip = state.result?.tip;
890
+ const el = $('ll_tip');
891
+ if (!tip) { el.style.display = 'none'; return; }
892
+ el.textContent = '💡 ' + tip;
893
+ el.style.display = '';
894
+ }
895
+
896
+ // ─── Public API ───────────────────────────────────────────────────────────
897
+ window.LL = {
898
+ open() {
899
+ $('ll_panel').style.display = 'flex';
900
+ $('ll_dock').style.display = 'none';
901
+ $('ll_prompt_ta').focus();
902
+ },
903
+ close() {
904
+ $('ll_panel').style.display = 'none';
905
+ $('ll_dock').style.display = 'flex';
906
+ },
907
+ minimize() {
908
+ $('ll_panel').style.display = 'none';
909
+ $('ll_dock').style.display = 'flex';
910
+ },
911
+ select(modelId) {
912
+ state.selectedModel = modelId;
913
+ localStorage.setItem(STORAGE_KEYS.selected, modelId);
914
+ const runBtn = $('ll_run_btn');
915
+ const sel = state.result?.all.find(m=>m.id===modelId);
916
+ if (sel) {
917
+ runBtn.textContent = `✦ Run on ${sel.name} →`;
918
+ runBtn.disabled = false;
919
+ }
920
+ renderBody();
921
+ },
922
+ run() {
923
+ if (!state.selectedModel) return;
924
+ if (!state.apiKey && !useFree()) {
925
+ alert('Daily free estimates exhausted. Connect your Signal Loom API key for unlimited use.');
926
+ return;
927
+ }
928
+ // Dispatch — post to parent or emit event
929
+ const event = new CustomEvent('loomlens:run', {
930
+ detail: { modelId: state.selectedModel, prompt: state.prompt }
931
+ });
932
+ window.dispatchEvent(event);
933
+ // Also try postMessage for cross-origin
934
+ if (window.parent !== window) {
935
+ window.parent.postMessage({ type: 'loomlens_run', modelId: state.selectedModel, prompt: state.prompt }, '*');
936
+ }
937
+ this.close();
938
+ },
939
+ connectKey() {
940
+ const key = $('ll_key_input').value.trim();
941
+ if (!key) return;
942
+ state.apiKey = key;
943
+ localStorage.setItem(STORAGE_KEYS.apiKey, key);
944
+ $('ll_key_input').value = key;
945
+ render();
946
+ },
947
+ disconnectKey() {
948
+ state.apiKey = '';
949
+ localStorage.removeItem(STORAGE_KEYS.apiKey);
950
+ $('ll_key_input').value = '';
951
+ render();
952
+ },
953
+ toggleKeyRow() {
954
+ if (state.apiKey) {
955
+ this.disconnectKey();
956
+ return;
957
+ }
958
+ const row = $('ll_key_row');
959
+ const btn = $('ll_show_key');
960
+ if (row.style.display === 'none') {
961
+ row.style.display = 'flex';
962
+ btn.textContent = 'Cancel';
963
+ $('ll_key_input').focus();
964
+ } else {
965
+ row.style.display = 'none';
966
+ btn.textContent = '+ Connect API key for unlimited';
967
+ }
968
+ },
969
+ estimate() {
970
+ const prompt = $('ll_prompt_ta').value;
971
+ if (!prompt || prompt.trim().length < 10) {
972
+ state.result = null;
973
+ render();
974
+ return;
975
+ }
976
+ state.prompt = prompt;
977
+ // ── Local engine preview (always free) ────────────────��─────────
978
+ // API billing happens only on "Run Estimate" click.
979
+ state.result = estimate(prompt, state.selectedModel);
980
+ if (!state.selectedModel && state.result?.recommended) {
981
+ state.selectedModel = state.result.recommended.id;
982
+ }
983
+ render();
984
+ const btn = $('ll_run_btn');
985
+ const sel = state.result?.allModels?.find(m=>m.id===state.selectedModel);
986
+ btn.textContent = sel ? `✦ Run on ${sel.name} →` : 'Select a model above →';
987
+ btn.disabled = !sel;
988
+ },
989
+
990
+ /**
991
+ * _estimateAPI — call POST /v1/estimate (billed per-call).
992
+ * Sets billing: true, decrements quota server-side.
993
+ * Falls back to local engine on network error.
994
+ */
995
+ async runEstimate() {
996
+ if (!state.apiKey) {
997
+ const left = freeLeft();
998
+ if (left === 0) {
999
+ alert('Your 3 free runs are used for today.\n\nConnect your Signal Loom API key to continue — signallloomai.com →');
1000
+ } else {
1001
+ alert('Connect your Signal Loom API key at signallloomai.com to run estimates.');
1002
+ }
1003
+ return;
1004
+ }
1005
+ // Count this as a free use (we'll call /estimate/free on their behalf via their key)
1006
+ useFree(); // record usage against their account
1007
+ const prompt = state.prompt || $('ll_prompt_ta').value;
1008
+ if (!prompt) return;
1009
+ const modelId = state.selectedModel || 'minimax/MiniMax-M2';
1010
+ const btn = $('ll_run_btn');
1011
+ btn.disabled = true;
1012
+ btn.textContent = '⚡ Running…';
1013
+ try {
1014
+ const body = {
1015
+ job_description: prompt,
1016
+ num_agents: 1,
1017
+ estimated_runtime_minutes: 1,
1018
+ complexity: 'standard',
1019
+ target_models: [modelId],
1020
+ include_postmortem: false,
1021
+ };
1022
+ const res = await fetch('https://api.signallloomai.com/v1/estimate', {
1023
+ method: 'POST',
1024
+ headers: {
1025
+ 'Authorization': 'Bearer ' + state.apiKey,
1026
+ 'Content-Type': 'application/json',
1027
+ },
1028
+ body: JSON.stringify(body),
1029
+ });
1030
+
1031
+ if (res.status === 429) {
1032
+ // Quota exhausted
1033
+ const err = await res.json().catch(()=>({}));
1034
+ state.result = {
1035
+ error: 'quota_exhausted',
1036
+ message: err.detail || 'Monthly estimate quota exhausted. Upgrade at signallloomai.com →',
1037
+ };
1038
+ render();
1039
+ return;
1040
+ }
1041
+ if (res.status === 401) {
1042
+ state.result = {
1043
+ error: 'invalid_key',
1044
+ message: 'Invalid API key. Check your key at signallloomai.com →',
1045
+ };
1046
+ render();
1047
+ return;
1048
+ }
1049
+ if (!res.ok) {
1050
+ throw new Error('API error: ' + res.status);
1051
+ }
1052
+
1053
+ // Extract quota from response headers
1054
+ const quota = {
1055
+ monthly_limit: parseInt(res.headers.get('X-Estimate-Limit') || '0'),
1056
+ used_this_month: parseInt(res.headers.get('X-Estimate-Used') || '0'),
1057
+ remaining_this_month: parseInt(res.headers.get('X-Estimate-Remaining') || '0'),
1058
+ resets_at: res.headers.get('X-Estimate-Resets') || '',
1059
+ };
1060
+
1061
+ const data = await res.json();
1062
+ // Attach billing + quota to result for display
1063
+ data._billing = data.billing || quota;
1064
+ data._quota = quota;
1065
+ state.result = _mapAPIResponse(data, modelId, prompt);
1066
+ state.result._quota = quota;
1067
+ state.result._billing = data.billing || null;
1068
+ if (!state.selectedModel && state.result?.recommended) {
1069
+ state.selectedModel = state.result.recommended.id;
1070
+ }
1071
+
1072
+ // ── Dispatch to OpenClaw ─────────────────────────────────────
1073
+ const event = new CustomEvent('loomlens:run', {
1074
+ detail: { modelId, prompt, cost: data.models?.[0]?.estimated_cost_mid }
1075
+ });
1076
+ window.dispatchEvent(event);
1077
+ if (window.parent !== window) {
1078
+ window.parent.postMessage({ type: 'loomlens_run', modelId, prompt }, '*');
1079
+ }
1080
+
1081
+ btn.textContent = '✓ Dispatched';
1082
+ setTimeout(() => { btn.disabled = false; btn.textContent = `✦ Run on ${state.selectedModel?.split('/').pop()} →`; }, 1200);
1083
+ setTimeout(() => this.close(), 1500);
1084
+ } catch (err) {
1085
+ console.warn('[LoomLens] runEstimate failed:', err.message);
1086
+ const btn = $('ll_run_btn');
1087
+ btn.textContent = '⚠ Error — retry';
1088
+ btn.disabled = false;
1089
+ }
1090
+ },
1091
+ getState() { return {...state}; },
1092
+ };
1093
+
1094
+ // ─── Event Wiring ─────────────────────────────────────────────────────────
1095
+ // Drag & drop
1096
+ const wrap = $('ll_prompt_wrap');
1097
+ wrap.addEventListener('dragover', e => { e.preventDefault(); wrap.classList.add('drag-over'); });
1098
+ wrap.addEventListener('dragleave', () => wrap.classList.remove('drag-over'));
1099
+ wrap.addEventListener('drop', e => {
1100
+ e.preventDefault();
1101
+ wrap.classList.remove('drag-over');
1102
+ const text = e.dataTransfer?.getData('text/plain') || '';
1103
+ if (text) { $('ll_prompt_ta').value = text; $('ll_prompt_ta').dispatchEvent(new Event('input')); }
1104
+ });
1105
+
1106
+ // Textarea input
1107
+ let debounce;
1108
+ $('ll_prompt_ta').addEventListener('input', () => {
1109
+ wrap.classList.toggle('focused', $('ll_prompt_ta').value.length > 0);
1110
+ $('ll_drop_hint').classList.toggle('hidden', $('ll_prompt_ta').value.length > 0);
1111
+ clearTimeout(debounce);
1112
+ debounce = setTimeout(() => window.LL.estimate(), 300);
1113
+ });
1114
+ $('ll_prompt_ta').addEventListener('focus', () => wrap.classList.add('focused'));
1115
+ $('ll_prompt_ta').addEventListener('blur', () => wrap.classList.remove('focused'));
1116
+
1117
+ // Filters
1118
+ $('ll_cluster_sel').addEventListener('change', renderBody);
1119
+ $('ll_sort_sel').addEventListener('change', renderBody);
1120
+
1121
+ // Header controls
1122
+ $('ll_btn_close').onclick = () => window.LL.close();
1123
+ $('ll_btn_min').onclick = () => window.LL.minimize();
1124
+
1125
+ // Keyboard shortcut
1126
+ document.addEventListener('keydown', e => {
1127
+ if ((e.metaKey || e.ctrlKey) && e.key === 'l') { e.preventDefault(); window.LL.open(); }
1128
+ if (e.key === 'Escape') window.LL.close();
1129
+ });
1130
+
1131
+ // postMessage: receive prompt from parent (OpenClaw webchat)
1132
+ window.addEventListener('message', e => {
1133
+ if (e.data?.type === 'openclaw_prompt' && e.data?.text) {
1134
+ $('ll_prompt_ta').value = e.data.text;
1135
+ $('ll_drop_hint').classList.add('hidden');
1136
+ window.LL.estimate();
1137
+ }
1138
+ });
1139
+
1140
+ // ─── Init ──────────────────────────────────────────────────────────────────
1141
+ checkFreeReset();
1142
+ if (state.apiKey) $('ll_key_input').value = state.apiKey;
1143
+ if (state.selectedModel) {
1144
+ const runBtn = $('ll_run_btn');
1145
+ runBtn.textContent = `✦ Run on ${state.selectedModel.split('/').pop()} →`;
1146
+ runBtn.disabled = false;
1147
+ }
1148
+ render();
1149
+
1150
+ // Open on load if coming back with a result
1151
+ if (state.result) window.LL.open();
1152
+
1153
+ })();
1154
+ </script>
1155
+ </body>
1156
+ </html>
loomlens-clusters.js ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LoomLens Live — Model Cluster Definitions
3
+ * Standalone module: works as ES module, CommonJS, or browser global
4
+ *
5
+ * Each cluster groups models that actually compete on cost + speed + capability.
6
+ * Within a cluster: recommend the cheapest model that clears the capability bar (score ≥ 65).
7
+ */
8
+
9
+ export const CLUSTERS = [
10
+ // ─────────────────────────────────────────────────────────────────
11
+ // CLUSTER: Flash Reasoning
12
+ // Fast + cheap. Best for Q&A, summaries, simple tasks.
13
+ // ─────────────────────────────────────────────────────────────────
14
+ {
15
+ id: 'flash',
16
+ name: 'Flash Reasoning',
17
+ description: 'Fast + cheap. Best for Q&A, summaries, simple tasks.',
18
+ color: '#10b981',
19
+ models: [
20
+ {
21
+ id: 'minimax/MiniMax-M2',
22
+ name: 'MiniMax M2',
23
+ provider: 'MiniMax',
24
+ tier: 3,
25
+ latency: 'fast',
26
+ capabilities: ['reasoning', 'fast', 'cheap', 'long-context'],
27
+ inputPricePerM: 0.10,
28
+ outputPricePerM: 0.40,
29
+ contextWindow: 1_024_000,
30
+ },
31
+ {
32
+ id: 'google/gemini-2.5-flash',
33
+ name: 'Gemini 2.5 Flash',
34
+ provider: 'Google',
35
+ tier: 3,
36
+ latency: 'fast',
37
+ capabilities: ['reasoning', 'fast'],
38
+ inputPricePerM: 0.15,
39
+ outputPricePerM: 0.60,
40
+ contextWindow: 1_000_000,
41
+ },
42
+ {
43
+ id: 'anthropic/claude-haiku',
44
+ name: 'Claude Haiku',
45
+ provider: 'Anthropic',
46
+ tier: 3,
47
+ latency: 'fast',
48
+ capabilities: ['fast', 'light-reasoning', 'cheap'],
49
+ inputPricePerM: 0.80,
50
+ outputPricePerM: 4.00,
51
+ contextWindow: 200_000,
52
+ },
53
+ {
54
+ id: 'deepseek/deepseek-chat-v3',
55
+ name: 'DeepSeek Chat v3',
56
+ provider: 'DeepSeek',
57
+ tier: 3,
58
+ latency: 'fast',
59
+ capabilities: ['reasoning', 'fast'],
60
+ inputPricePerM: 0.27,
61
+ outputPricePerM: 1.10,
62
+ contextWindow: 640_000,
63
+ },
64
+ ],
65
+ },
66
+
67
+ // ─────────────────────────────────────────────────────────────────
68
+ // CLUSTER: Fast General
69
+ // Fast general purpose. Best latency for typical prompts.
70
+ // ─────────────────────────────────────────────────────────────────
71
+ {
72
+ id: 'fastgen',
73
+ name: 'Fast General',
74
+ description: 'Fast general purpose. Best latency for typical prompts.',
75
+ color: '#06b6d4',
76
+ models: [
77
+ {
78
+ id: 'openai/gpt-4o-mini',
79
+ name: 'GPT-4o Mini',
80
+ provider: 'OpenAI',
81
+ tier: 3,
82
+ latency: 'fast',
83
+ capabilities: ['fast', 'cheap'],
84
+ inputPricePerM: 0.15,
85
+ outputPricePerM: 0.60,
86
+ contextWindow: 128_000,
87
+ },
88
+ {
89
+ id: 'google/gemini-2.0-flash',
90
+ name: 'Gemini 2.0 Flash',
91
+ provider: 'Google',
92
+ tier: 3,
93
+ latency: 'fast',
94
+ capabilities: ['fast', 'cheap', 'long-context'],
95
+ inputPricePerM: 0.10,
96
+ outputPricePerM: 0.40,
97
+ contextWindow: 1_000_000,
98
+ },
99
+ {
100
+ id: 'xai/grok-3',
101
+ name: 'xAI Grok-3',
102
+ provider: 'xAI',
103
+ tier: 3,
104
+ latency: 'fast',
105
+ capabilities: ['fast', 'reasoning'],
106
+ inputPricePerM: 2.00,
107
+ outputPricePerM: 10.00,
108
+ contextWindow: 131_072,
109
+ },
110
+ ],
111
+ },
112
+
113
+ // ─────────────────────────────────────────────────────────────────
114
+ // CLUSTER: Balanced Coding
115
+ // Best code capability per dollar. Medium complexity.
116
+ // ─────────────────────────────────────────────────────────────────
117
+ {
118
+ id: 'code',
119
+ name: 'Balanced Coding',
120
+ description: 'Best code capability per dollar. Medium complexity.',
121
+ color: '#8b5cf6',
122
+ models: [
123
+ {
124
+ id: 'anthropic/claude-sonnet-4-20250514',
125
+ name: 'Claude Sonnet 4',
126
+ provider: 'Anthropic',
127
+ tier: 2,
128
+ latency: 'medium',
129
+ capabilities: ['reasoning', 'code', 'analysis', 'fast-reasoning'],
130
+ inputPricePerM: 3.00,
131
+ outputPricePerM: 15.00,
132
+ contextWindow: 200_000,
133
+ },
134
+ {
135
+ id: 'openai/gpt-4o',
136
+ name: 'GPT-4o',
137
+ provider: 'OpenAI',
138
+ tier: 2,
139
+ latency: 'medium',
140
+ capabilities: ['reasoning', 'vision', 'code', 'multimodal'],
141
+ inputPricePerM: 2.50,
142
+ outputPricePerM: 10.00,
143
+ contextWindow: 128_000,
144
+ },
145
+ {
146
+ id: 'anthropic/claude-3.5-sonnet',
147
+ name: 'Claude 3.5 Sonnet',
148
+ provider: 'Anthropic',
149
+ tier: 2,
150
+ latency: 'medium',
151
+ capabilities: ['reasoning', 'code', 'analysis'],
152
+ inputPricePerM: 3.00,
153
+ outputPricePerM: 15.00,
154
+ contextWindow: 200_000,
155
+ },
156
+ ],
157
+ },
158
+
159
+ // ─────────────────────────────────────────────────────────────────
160
+ // CLUSTER: Power / Architecture
161
+ // Maximum reasoning. Pay for it only when needed.
162
+ // ─────────────────────────────────────────────────────────────────
163
+ {
164
+ id: 'power',
165
+ name: 'Power / Architecture',
166
+ description: 'Maximum reasoning. Pay for it only when needed.',
167
+ color: '#3b82f6',
168
+ models: [
169
+ {
170
+ id: 'anthropic/claude-opus-4-20250514',
171
+ name: 'Claude Opus 4',
172
+ provider: 'Anthropic',
173
+ tier: 1,
174
+ latency: 'slow',
175
+ capabilities: ['reasoning', 'code', 'analysis', 'long-context'],
176
+ inputPricePerM: 15.00,
177
+ outputPricePerM: 75.00,
178
+ contextWindow: 200_000,
179
+ },
180
+ {
181
+ id: 'anthropic/claude-code',
182
+ name: 'Claude Code',
183
+ provider: 'Anthropic',
184
+ tier: 1,
185
+ latency: 'slow',
186
+ capabilities: ['agentic', 'code', 'architecture', 'full-reasoning'],
187
+ inputPricePerM: 15.00,
188
+ outputPricePerM: 75.00,
189
+ contextWindow: 200_000,
190
+ },
191
+ ],
192
+ },
193
+
194
+ // ─────────────────────────────────────────────────────────────────
195
+ // CLUSTER: Vision / Multimodal
196
+ // Image + document understanding.
197
+ // ─────────────────────────────────────────────────────────────────
198
+ {
199
+ id: 'vision',
200
+ name: 'Vision / Multimodal',
201
+ description: 'Image + document understanding.',
202
+ color: '#ec4899',
203
+ models: [
204
+ {
205
+ id: 'openai/gpt-4o',
206
+ name: 'GPT-4o',
207
+ provider: 'OpenAI',
208
+ tier: 4,
209
+ latency: 'medium',
210
+ capabilities: ['vision', 'reasoning', 'multimodal'],
211
+ inputPricePerM: 2.50,
212
+ outputPricePerM: 10.00,
213
+ contextWindow: 128_000,
214
+ },
215
+ {
216
+ id: 'anthropic/claude-3.5',
217
+ name: 'Claude 3.5',
218
+ provider: 'Anthropic',
219
+ tier: 4,
220
+ latency: 'medium',
221
+ capabilities: ['vision', 'code', 'analysis'],
222
+ inputPricePerM: 3.00,
223
+ outputPricePerM: 15.00,
224
+ contextWindow: 200_000,
225
+ },
226
+ ],
227
+ },
228
+ ];
229
+
230
+ // ─── Latency Scoring ────────────────────────────────────────────────────────
231
+
232
+ export const LATENCY_SCORE = { fast: 100, medium: 62, slow: 22 };
233
+
234
+ // ─── Capability Fit Matrix ──────────────────────────────────────────────────
235
+ // task → cluster → fit 0-100
236
+
237
+ export const CAPABILITY_FIT = {
238
+ simple_qa: { flash: 95, fastgen: 80, code: 30, power: 20, vision: 20 },
239
+ summary: { flash: 90, fastgen: 75, code: 40, power: 30, vision: 20 },
240
+ code_edit: { flash: 60, fastgen: 40, code: 90, power: 40, vision: 20 },
241
+ code_write: { flash: 30, fastgen: 20, code: 95, power: 95, vision: 20 },
242
+ complex_analysis: { flash: 40, fastgen: 30, code: 85, power: 98, vision: 20 },
243
+ vision: { flash: 10, fastgen: 10, code: 30, power: 30, vision: 98 },
244
+ unknown: { flash: 70, fastgen: 60, code: 60, power: 50, vision: 40 },
245
+ };
246
+
247
+ // ─── Task Detection Keywords ────────────────────────────────────────────────
248
+
249
+ export const TASK_KEYWORDS = {
250
+ simple_qa: ['what is','who is','define','explain','how do i','how does','what are','why does','when did','where is','tell me','give me','list the'],
251
+ summary: ['summarize','tl;dr','sum up','brief','recap','key points','highlights','overview','condense','shorten'],
252
+ code_edit: ['fix','bug','debug','hotfix','patch','tweak','adjust','correct','error','issue','typo','broken'],
253
+ code_write: ['write','build','create','implement','add feature','new function','generate',' scaffold','code for','make a','app that'],
254
+ complex_analysis: ['analyze','architecture','review','refactor','design','strategy','compare','evaluate','assess','plan','audit','investigate'],
255
+ vision: ['image','photo','picture','screenshot','diagram','chart','visual','frame','pdf','document','extract text from','read the'],
256
+ };
257
+
258
+ // ─── Output Token Estimates by Task ─────────────────────────────────────────
259
+
260
+ export const OUTPUT_TOKENS = {
261
+ simple_qa: { min: 50, mid: 150, max: 400 },
262
+ summary: { min: 80, mid: 200, max: 500 },
263
+ code_edit: { min: 100, mid: 400, max: 1000 },
264
+ code_write: { min: 200, mid: 800, max: 2500 },
265
+ complex_analysis: { min: 200, mid: 600, max: 1500 },
266
+ vision: { min: 100, mid: 300, max: 800 },
267
+ unknown: { min: 100, mid: 400, max: 1200 },
268
+ };
269
+
270
+ // ─── Helpers ────────────────────────────────────────────────────────────────
271
+
272
+ /** Get all models as a flat array */
273
+ export function getAllClusterModels() {
274
+ return CLUSTERS.flatMap(c => c.models.map(m => ({ ...m, clusterId: c.id, clusterName: c.name })));
275
+ }
276
+
277
+ /** Get a single model by ID */
278
+ export function getModelById(id) {
279
+ for (const cluster of CLUSTERS) {
280
+ const m = cluster.models.find(m => m.id === id);
281
+ if (m) return { ...m, clusterId: cluster.id, clusterName: cluster.name };
282
+ }
283
+ return null;
284
+ }
285
+
286
+ /** Get a cluster by ID */
287
+ export function getClusterById(id) {
288
+ return CLUSTERS.find(c => c.id === id) ?? null;
289
+ }
290
+
291
+ /** All cluster IDs */
292
+ export function getClusterIds() {
293
+ return CLUSTERS.map(c => c.id);
294
+ }
295
+
296
+ // ─── Browser global ─────────────────────────────────────────────────────────
297
+ // If loaded as a plain script (not module), expose as window.LoomLensClusters
298
+ if (typeof window !== 'undefined' && typeof module === 'undefined') {
299
+ window.LoomLensClusters = {
300
+ CLUSTERS,
301
+ LATENCY_SCORE,
302
+ CAPABILITY_FIT,
303
+ TASK_KEYWORDS,
304
+ OUTPUT_TOKENS,
305
+ getAllClusterModels,
306
+ getModelById,
307
+ getClusterById,
308
+ getClusterIds,
309
+ };
310
+ }
loomlens-engine.js ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LoomLens Live — Cost Estimation Engine
3
+ * Pure JavaScript: no TypeScript, no build step required.
4
+ * Works as ES module, CommonJS, or browser global.
5
+ *
6
+ * Token estimation → cost calculation → cluster-aware fit scoring.
7
+ * Zero API calls. Zero dependencies.
8
+ */
9
+
10
+ // ─── Inline Cluster Data ────────────────────────────────────────────────────
11
+ // (Cluster definitions are inlined at bottom of this file or injected by build)
12
+
13
+ const CLUSTERS = [
14
+ {
15
+ id: 'flash', name: 'Flash Reasoning',
16
+ models: [
17
+ { id:'minimax/MiniMax-M2', name:'MiniMax M2', provider:'MiniMax', tier:3, latency:'fast', inputPricePerM:0.10, outputPricePerM:0.40 },
18
+ { id:'google/gemini-2.5-flash', name:'Gemini 2.5 Flash', provider:'Google', tier:3, latency:'fast', inputPricePerM:0.15, outputPricePerM:0.60 },
19
+ { id:'anthropic/claude-haiku', name:'Claude Haiku', provider:'Anthropic', tier:3, latency:'fast', inputPricePerM:0.80, outputPricePerM:4.00 },
20
+ { id:'deepseek/deepseek-chat-v3',name:'DeepSeek Chat v3',provider:'DeepSeek', tier:3, latency:'fast', inputPricePerM:0.27, outputPricePerM:1.10 },
21
+ ],
22
+ },
23
+ {
24
+ id: 'fastgen', name: 'Fast General',
25
+ models: [
26
+ { id:'openai/gpt-4o-mini', name:'GPT-4o Mini', provider:'OpenAI', tier:3, latency:'fast', inputPricePerM:0.15, outputPricePerM:0.60 },
27
+ { id:'google/gemini-2.0-flash', name:'Gemini 2.0 Flash', provider:'Google', tier:3, latency:'fast', inputPricePerM:0.10, outputPricePerM:0.40 },
28
+ { id:'xai/grok-3', name:'xAI Grok-3', provider:'xAI', tier:3, latency:'fast', inputPricePerM:2.00, outputPricePerM:10.0 },
29
+ ],
30
+ },
31
+ {
32
+ id: 'code', name: 'Balanced Coding',
33
+ models: [
34
+ { id:'anthropic/claude-sonnet-4-20250514', name:'Claude Sonnet 4', provider:'Anthropic', tier:2, latency:'medium', inputPricePerM:3.00, outputPricePerM:15.0 },
35
+ { id:'openai/gpt-4o', name:'GPT-4o', provider:'OpenAI', tier:2, latency:'medium', inputPricePerM:2.50, outputPricePerM:10.0 },
36
+ { id:'anthropic/claude-3.5-sonnet', name:'Claude 3.5 Sonnet', provider:'Anthropic',tier:2, latency:'medium', inputPricePerM:3.00, outputPricePerM:15.0 },
37
+ ],
38
+ },
39
+ {
40
+ id: 'power', name: 'Power / Architecture',
41
+ models: [
42
+ { id:'anthropic/claude-opus-4-20250514', name:'Claude Opus 4', provider:'Anthropic', tier:1, latency:'slow', inputPricePerM:15.0, outputPricePerM:75.0 },
43
+ { id:'anthropic/claude-code', name:'Claude Code', provider:'Anthropic', tier:1, latency:'slow', inputPricePerM:15.0, outputPricePerM:75.0 },
44
+ ],
45
+ },
46
+ {
47
+ id: 'vision', name: 'Vision / Multimodal',
48
+ models: [
49
+ { id:'openai/gpt-4o', name:'GPT-4o', provider:'OpenAI', tier:4, latency:'medium', inputPricePerM:2.50, outputPricePerM:10.0 },
50
+ { id:'anthropic/claude-3.5', name:'Claude 3.5', provider:'Anthropic', tier:4, latency:'medium', inputPricePerM:3.00, outputPricePerM:15.0 },
51
+ ],
52
+ },
53
+ ];
54
+
55
+ const LATENCY_SCORE = { fast: 100, medium: 62, slow: 22 };
56
+
57
+ const CAPABILITY_FIT = {
58
+ simple_qa: { flash: 95, fastgen: 80, code: 30, power: 20, vision: 20 },
59
+ summary: { flash: 90, fastgen: 75, code: 40, power: 30, vision: 20 },
60
+ code_edit: { flash: 60, fastgen: 40, code: 90, power: 40, vision: 20 },
61
+ code_write: { flash: 30, fastgen: 20, code: 95, power: 95, vision: 20 },
62
+ complex_analysis: { flash: 40, fastgen: 30, code: 85, power: 98, vision: 20 },
63
+ vision: { flash: 10, fastgen: 10, code: 30, power: 30, vision: 98 },
64
+ unknown: { flash: 70, fastgen: 60, code: 60, power: 50, vision: 40 },
65
+ };
66
+
67
+ const TASK_KEYWORDS = {
68
+ simple_qa: ['what is','who is','define','explain','how do i','how does','what are','why does','when did','where is','tell me','give me'],
69
+ summary: ['summarize','tl;dr','sum up','brief','recap','key points','highlights','overview','condense','shorten'],
70
+ code_edit: ['fix','bug','debug','hotfix','patch','tweak','adjust','correct','error','issue'],
71
+ code_write: ['write','build','create','implement','add feature','new function','generate',' scaffold','code for','make a'],
72
+ complex_analysis: ['analyze','architecture','review','refactor','design','strategy','compare','evaluate','assess','plan'],
73
+ vision: ['image','photo','picture','screenshot','diagram','chart','visual','frame','pdf','document'],
74
+ };
75
+
76
+ const OUTPUT_TOKENS = { simple_qa:150, summary:200, code_edit:400, code_write:800, complex_analysis:600, vision:300, unknown:400 };
77
+
78
+ const CHARS_PER_TOKEN = { en: 3.8, es: 3.5, ja: 1.2, zh: 1.0 };
79
+
80
+ // ─── Token Estimation ────────────────────────────────────────────────────────
81
+
82
+ export function detectLanguage(text) {
83
+ if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return 'ja';
84
+ if (/[\u4E00-\u9FFF]/.test(text)) return 'zh';
85
+ if (/[áéíóúñ¿¡ü]/i.test(text)) return 'es';
86
+ return 'en';
87
+ }
88
+
89
+ export function estimateInputTokens(text) {
90
+ if (!text || !text.trim()) return 0;
91
+ const lang = detectLanguage(text);
92
+ const cpt = CHARS_PER_TOKEN[lang];
93
+ const multiline = (text.match(/\n/g)||[]).length;
94
+ const codeFences = (text.match(/```/g)||[]).length;
95
+ const mult = (codeFences >= 2 || multiline > 3) ? 1.2 : 1.0;
96
+ return Math.ceil((text.length / cpt) * mult);
97
+ }
98
+
99
+ // ─── Task Detection ─────────────────────────────────────────────────────────
100
+
101
+ export function detectTaskType(prompt) {
102
+ const lower = prompt.toLowerCase();
103
+ const scores = {};
104
+ for (const [type, keywords] of Object.entries(TASK_KEYWORDS)) {
105
+ scores[type] = keywords.reduce((acc, kw) => acc + (lower.includes(kw) ? 1 : 0), 0);
106
+ }
107
+ if (/```[\s\S]+```/.test(prompt)) scores['code_write'] = (scores['code_write']||0) + 2;
108
+ if (/^\s*(import|from|const|let|function|class|def |fn |pub fn)/m.test(prompt)) scores['code_write'] = (scores['code_write']||0) + 2;
109
+ if (/\b(fix|bug|error|issue)\b/i.test(prompt)) scores['code_edit'] = (scores['code_edit']||0) + 1;
110
+ if (/\b(write|build|create|implement)\b/i.test(prompt)) scores['code_write'] = (scores['code_write']||0) + 1;
111
+ if (/\b(analyze|evaluate|compare|assess)\b/i.test(prompt)) scores['complex_analysis'] = (scores['complex_analysis']||0) + 1;
112
+
113
+ const best = Object.entries(scores).sort((a,b) => (b[1]??0) - (a[1]??0))[0];
114
+ const score = best?.[1] ?? 0;
115
+ const type = score === 0 ? 'unknown' : best[0];
116
+ return { type, confidence: Math.min(score/3, 1), keywords: (TASK_KEYWORDS[type]??[]).filter(kw=>lower.includes(kw)) };
117
+ }
118
+
119
+ // ─── Cost ──────────────────────────────────────────────────────────────────
120
+
121
+ export function calcCost(inPrice, outPrice, inTokens, outTokens) {
122
+ return { inputCost: Math.round((inTokens/1e6)*inPrice*1e6)/1e6, outputCost: Math.round((outTokens/1e6)*outPrice*1e6)/1e6,
123
+ totalCost: Math.round(((inTokens/1e6)*inPrice + (outTokens/1e6)*outPrice)*1e6)/1e6 };
124
+ }
125
+
126
+ // ─── Fit Scoring ────────────────────────────────────────────────────────────
127
+
128
+ export function scoreWithinCluster(model, task, clusterModels) {
129
+ const capFit = CAPABILITY_FIT[task]?.[model.clusterId] ?? 50;
130
+ const costs = clusterModels.map(m => calcCost(m.inputPricePerM, m.outputPricePerM, 1, 1).totalCost);
131
+ const minC = Math.min(...costs);
132
+ const maxC = Math.max(...costs);
133
+ const range = maxC - minC || 1;
134
+ const costEff = Math.round(100 - ((calcCost(model.inputPricePerM, model.outputPricePerM, 1, 1).totalCost - minC) / range * 100));
135
+ const speed = LATENCY_SCORE[model.latency] ?? 50;
136
+ return Math.round(capFit * 0.35 + costEff * 0.35 + speed * 0.30);
137
+ }
138
+
139
+ export function getInsight(modelName, clusterName, clusterId, task, score, isBest) {
140
+ const labels = { simple_qa:'quick Q&A', summary:'summarization', code_edit:'code fixes', code_write:'code builds',
141
+ complex_analysis:'deep analysis', vision:'image/doc understanding', unknown:'general purpose' };
142
+ const label = labels[task] ?? 'general purpose';
143
+ if (isBest) return `Best in ${clusterName} for ${label}`;
144
+ if (score >= 80) return `Strong fit for ${label}`;
145
+ if (score >= 65) return `Capable for ${label}`;
146
+ return `Consider alternatives for ${label}`;
147
+ }
148
+
149
+ // ─── Main Estimator ─────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * estimatePrompt(prompt, selectedModelId?) → LoomLensResult
153
+ */
154
+ export function estimatePrompt(prompt, selectedModelId = '') {
155
+ const task = detectTaskType(prompt);
156
+ const inTokens = estimateInputTokens(prompt);
157
+ const outTokens = OUTPUT_TOKENS[task.type] ?? 400;
158
+
159
+ const allFlat = CLUSTERS.flatMap(c => c.models.map(m => ({ ...m, clusterId: c.id, clusterName: c.name })));
160
+ const clusters = {};
161
+ const allModels = [];
162
+
163
+ for (const cluster of CLUSTERS) {
164
+ const clusterModels = allFlat.filter(m => m.clusterId === cluster.id);
165
+ const clusterCosts = clusterModels.map(m => calcCost(m.inputPricePerM, m.outputPricePerM, inTokens, outTokens).totalCost);
166
+ const maxCost = Math.max(...clusterCosts);
167
+
168
+ const scored = clusterModels.map(m => {
169
+ const costs = calcCost(m.inputPricePerM, m.outputPricePerM, inTokens, outTokens);
170
+ const score = scoreWithinCluster(m, task.type, clusterModels);
171
+ const isBest = false;
172
+ return {
173
+ modelId: m.id, modelName: m.name, provider: m.provider,
174
+ clusterId: m.clusterId, clusterName: cluster.name,
175
+ inputTokens: inTokens, outputTokens: outTokens,
176
+ inputCost: costs.inputCost, outputCost: costs.outputCost, totalCost: costs.totalCost,
177
+ latencyScore: LATENCY_SCORE[m.latency] ?? 50,
178
+ compositeScore: score,
179
+ relativeCostPct: Math.round((costs.totalCost / maxCost) * 100),
180
+ _isBest: false,
181
+ };
182
+ });
183
+
184
+ scored.sort((a,b) => b.compositeScore - a.compositeScore);
185
+ const best = scored.find(m => m.compositeScore >= 65) ?? scored[0];
186
+ if (best) best._isBest = true;
187
+ scored.forEach(m => {
188
+ m.fitBadge = m._isBest ? 'recommended' : m.compositeScore >= 65 ? 'sufficient' : 'overkill';
189
+ m.insight = getInsight(m.modelName, cluster.name, cluster.id, task.type, m.compositeScore, m._isBest);
190
+ });
191
+
192
+ clusters[cluster.id] = { clusterId: cluster.id, clusterName: cluster.name, models: scored, best: best??null };
193
+ allModels.push(...scored);
194
+ }
195
+
196
+ allModels.sort((a,b) => b.compositeScore - a.compositeScore);
197
+ const recommended = allModels.find(m => m.compositeScore >= 80) ?? null;
198
+ const selected = allModels.find(m => m.modelId === selectedModelId) ?? allModels[0];
199
+
200
+ let tip = '';
201
+ if (recommended && selected && recommended.modelId !== selected.modelId) {
202
+ const saving = selected.totalCost - recommended.totalCost;
203
+ if (saving > 0.0001) tip = `Switch to ${recommended.modelName} and save ${formatCost(saving)}.`;
204
+ else if (saving < -0.0001) tip = `${selected.modelName} costs ${formatCost(Math.abs(saving))} more than ${recommended.modelName}.`;
205
+ } else if (selected) {
206
+ tip = `${selected.modelName} is a good fit for this task.`;
207
+ }
208
+
209
+ return { prompt, taskType: task.type, taskConfidence: task.confidence, inputTokens: inTokens, outputTokens: outTokens,
210
+ clusters, recommended, selected, allModels, tip, estimatedAt: Date.now() };
211
+ }
212
+
213
+ export function formatCost(n) {
214
+ if (n >= 1) return '$' + n.toFixed(2);
215
+ if (n >= 0.01) return '$' + n.toFixed(3);
216
+ return '$' + n.toFixed(4);
217
+ }
218
+
219
+ // ─── Exports ─────────────────────────────────────────────────────────────────
220
+
221
+
222
+ export function getAllClusterModels() {
223
+ return CLUSTERS.flatMap(c => c.models.map(m => ({ ...m, clusterId: c.id, clusterName: c.name })));
224
+ }
225
+ export function getModelById(id) {
226
+ for (const c of CLUSTERS) { const m = c.models.find(m => m.id === id); if (m) return { ...m, clusterId: c.id, clusterName: c.name }; }
227
+ return null;
228
+ }
229
+ export function getClusterById(id) { return CLUSTERS.find(c => c.id === id) ?? null; }
230
+ export function getClusterIds() { return CLUSTERS.map(c => c.id); }
231
+
232
+ // ─── Browser global ─────────────────────────────────────────────────────────
233
+ if (typeof window !== 'undefined' && typeof module === 'undefined') {
234
+ window.LoomLensEngine = { estimatePrompt, formatCost, estimateInputTokens, detectTaskType,
235
+ CLUSTERS, LATENCY_SCORE, CAPABILITY_FIT, TASK_KEYWORDS, OUTPUT_TOKENS,
236
+ getAllClusterModels, getModelById, getClusterById, getClusterIds };
237
+ }
loomlens-engine.ts ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LoomLens Live — Cost Estimation Engine
3
+ * Phase 1 updated: cluster-aware scoring + standalone (no imports needed)
4
+ *
5
+ * Estimator: char heuristics → token count → cost + cluster-aware fit score.
6
+ * No API calls. No dependencies.
7
+ */
8
+
9
+ import {
10
+ CLUSTERS,
11
+ LATENCY_SCORE,
12
+ CAPABILITY_FIT,
13
+ TASK_KEYWORDS,
14
+ OUTPUT_TOKENS,
15
+ getAllClusterModels,
16
+ } from './loomlens-clusters.js';
17
+
18
+ // ─── Types ───────────────────────────────────────────────────────────────────
19
+
20
+ export type TaskType = keyof typeof TASK_KEYWORDS;
21
+ export type ClusterId = 'flash' | 'fastgen' | 'code' | 'power' | 'vision';
22
+
23
+ export interface TaskDetection {
24
+ type: TaskType;
25
+ confidence: number;
26
+ keywords: string[];
27
+ }
28
+
29
+ export interface ModelEstimate {
30
+ modelId: string;
31
+ modelName: string;
32
+ provider: string;
33
+ clusterId: string;
34
+ clusterName: string;
35
+ inputTokens: number;
36
+ outputTokens: number;
37
+ inputCost: number;
38
+ outputCost: number;
39
+ totalCost: number;
40
+ latencyScore: number;
41
+ capFit: number;
42
+ costEff: number;
43
+ compositeScore: number; // 0-100
44
+ fitBadge: 'recommended' | 'sufficient' | 'overkill';
45
+ insight: string;
46
+ relativeCostPct: number; // % of most expensive in same cluster
47
+ }
48
+
49
+ export interface ClusterResult {
50
+ clusterId: string;
51
+ clusterName: string;
52
+ models: ModelEstimate[];
53
+ best: ModelEstimate | null; // cheapest that clears threshold
54
+ }
55
+
56
+ export interface LoomLensResult {
57
+ prompt: string;
58
+ taskType: TaskType;
59
+ taskConfidence: number;
60
+ inputTokens: number;
61
+ outputTokens: number;
62
+ clusters: Record<string, ClusterResult>;
63
+ recommended: ModelEstimate | null;
64
+ selected: ModelEstimate | null;
65
+ allModels: ModelEstimate[];
66
+ tip: string;
67
+ estimatedAt: number;
68
+ }
69
+
70
+ // ─── Token Estimation ────────────────────────────────────────────────────────
71
+
72
+ const CHARS_PER_TOKEN = { en: 3.8, es: 3.5, ja: 1.2, zh: 1.0 };
73
+
74
+ function detectLanguage(text: string): keyof typeof CHARS_PER_TOKEN {
75
+ if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return 'ja';
76
+ if (/[\u4E00-\u9FFF]/.test(text)) return 'zh';
77
+ if (/[áéíóúñ¿¡ü]/i.test(text)) return 'es';
78
+ return 'en';
79
+ }
80
+
81
+ function detectContentMultiplier(text: string): number {
82
+ const codeFences = (text.match(/```/g) || []).length;
83
+ const newlines = (text.match(/\n/g) || []).length;
84
+ const isCode = codeFences >= 2 || (newlines > 3 && codeFences >= 1);
85
+ return isCode ? 1.2 : 1.0;
86
+ }
87
+
88
+ /**
89
+ * Estimate input token count from raw text.
90
+ * Uses language detection + content-type heuristics.
91
+ */
92
+ export function estimateInputTokens(text: string): number {
93
+ if (!text || !text.trim()) return 0;
94
+ const lang = detectLanguage(text);
95
+ const cpt = CHARS_PER_TOKEN[lang];
96
+ const mult = detectContentMultiplier(text);
97
+ return Math.ceil((text.length / cpt) * mult);
98
+ }
99
+
100
+ // ─── Task Detection ─────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * Detect what type of task this prompt represents.
104
+ */
105
+ export function detectTaskType(prompt: string): TaskDetection {
106
+ const lower = prompt.toLowerCase();
107
+ const scores: Partial<Record<TaskType, number>> = {};
108
+
109
+ for (const [type, keywords] of Object.entries(TASK_KEYWORDS)) {
110
+ scores[type as TaskType] = keywords.reduce((acc, kw) => acc + (lower.includes(kw) ? 1 : 0), 0);
111
+ }
112
+
113
+ // Structural boosts
114
+ if (/```[\s\S]+```/.test(prompt)) (scores['code_write'] as number) += 2;
115
+ if (/^\s*(import|from|const|let|function|class|def |fn |pub fn)/m.test(prompt))
116
+ (scores['code_write'] as number) += 2;
117
+ if (/\b(fix|bug|error|issue)\b/i.test(prompt)) (scores['code_edit'] as number) += 1;
118
+ if (/\b(write|build|create|implement)\b/i.test(prompt))
119
+ (scores['code_write'] as number) += 1;
120
+ if (/\b(analyze|evaluate|compare|assess)\b/i.test(prompt))
121
+ (scores['complex_analysis'] as number) += 1;
122
+ if (/\$\d+|\d+%|rate|spend|cost|budget/i.test(prompt))
123
+ (scores['complex_analysis'] as number) += 1;
124
+ if (/\$\d+|\d+%|rate|spend/i.test(prompt)) (scores['simple_qa'] as number) += 1;
125
+
126
+ const best = Object.entries(scores).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))[0];
127
+ const score = best?.[1] ?? 0;
128
+ const type: TaskType = score === 0 ? 'unknown' : (best![0] as TaskType);
129
+ const confidence = Math.min(score / 3, 1);
130
+ const keywords = (TASK_KEYWORDS[type] ?? []).filter(kw => lower.includes(kw));
131
+
132
+ return { type, confidence, keywords };
133
+ }
134
+
135
+ // ─── Cost Calculation ────────────────────────────────────────────────────────
136
+
137
+ export function calculateCost(
138
+ inPricePerM: number,
139
+ outPricePerM: number,
140
+ inTokens: number,
141
+ outTokens: number
142
+ ): { inputCost: number; outputCost: number; totalCost: number } {
143
+ const inputCost = Math.round((inTokens / 1_000_000) * inPricePerM * 1e6) / 1e6;
144
+ const outputCost = Math.round((outTokens / 1_000_000) * outPricePerM * 1e6) / 1e6;
145
+ return { inputCost, outputCost, totalCost: Math.round((inputCost + outputCost) * 1e6) / 1e6 };
146
+ }
147
+
148
+ // ─── Fit Scoring ─────────────────────────────────────────────────────────────
149
+
150
+ /**
151
+ * Composite score within a cluster:
152
+ * score = (capability_fit × 0.35) + (cost_efficiency × 0.35) + (speed × 0.30)
153
+ *
154
+ * Returns { capFit, costEff, compositeScore }
155
+ */
156
+ function scoreWithinCluster(
157
+ model: { clusterId: string; latency: keyof typeof LATENCY_SCORE; inPricePerM: number; outPricePerM: number },
158
+ task: TaskType,
159
+ allInCluster: Array<{ inPricePerM: number; outPricePerM: number }>
160
+ ): { capFit: number; costEff: number; compositeScore: number } {
161
+ const capFit = CAPABILITY_FIT[task]?.[model.clusterId] ?? 50;
162
+ const costs = allInCluster.map(m => calculateCost(m.inPricePerM, m.outPricePerM, 1, 1).totalCost);
163
+ const minC = Math.min(...costs);
164
+ const maxC = Math.max(...costs);
165
+ const range = maxC - minC || 1;
166
+ const costEff = Math.round(100 - ((calculateCost(model.inPricePerM, model.outPricePerM, 1, 1).totalCost - minC) / range * 100));
167
+ const speed = LATENCY_SCORE[model.latency] ?? 50;
168
+ const composite = Math.round(capFit * 0.35 + costEff * 0.35 + speed * 0.30);
169
+ return { capFit, costEff, compositeScore: composite };
170
+ }
171
+
172
+ function getFitBadge(score: number, clusterId: string): 'recommended' | 'sufficient' | 'overkill' {
173
+ if (score >= 80) return 'recommended';
174
+ if (score >= 65) return 'sufficient';
175
+ return 'overkill';
176
+ }
177
+
178
+ function getInsight(
179
+ modelName: string,
180
+ clusterName: string,
181
+ clusterId: string,
182
+ task: TaskType,
183
+ score: number,
184
+ isBest: boolean
185
+ ): string {
186
+ const taskLabels: Record<TaskType, string> = {
187
+ simple_qa: 'quick questions and lookups',
188
+ summary: 'summarization and extraction',
189
+ code_edit: 'code fixes and small changes',
190
+ code_write: 'full code and feature builds',
191
+ complex_analysis: 'deep reasoning and architecture',
192
+ vision: 'image and document understanding',
193
+ unknown: 'general purpose tasks',
194
+ };
195
+ const label = taskLabels[task] ?? 'general purpose';
196
+ if (isBest) return `Best in ${clusterName} for ${label}`;
197
+ if (score >= 80) return `Strong fit for ${label} in ${clusterName}`;
198
+ if (score >= 65) return `Capable for ${label} in ${clusterName}`;
199
+ if (score < 50) return `Consider ${clusterName} alternatives for ${label}`;
200
+ return `Use ${modelName} in ${clusterName} for ${label}`;
201
+ }
202
+
203
+ // ─── Main Estimator ──────────────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Full estimation: token count → cost + cluster-aware fit score per model.
207
+ */
208
+ export function estimatePrompt(
209
+ prompt: string,
210
+ selectedModelId: string = ''
211
+ ): LoomLensResult {
212
+ const task = detectTaskType(prompt);
213
+ const inTokens = estimateInputTokens(prompt);
214
+ const outTokens = OUTPUT_TOKENS[task]?.mid ?? 400;
215
+ const allFlat = getAllClusterModels();
216
+ const result: LoomLensResult = {
217
+ prompt,
218
+ taskType: task.type,
219
+ taskConfidence: task.confidence,
220
+ inputTokens: inTokens,
221
+ outputTokens: outTokens,
222
+ clusters: {},
223
+ recommended: null,
224
+ selected: null,
225
+ allModels: [],
226
+ tip: '',
227
+ estimatedAt: Date.now(),
228
+ };
229
+
230
+ // Score each model within its cluster
231
+ for (const cluster of CLUSTERS) {
232
+ const clusterModels = allFlat.filter(m => m.clusterId === cluster.id);
233
+ const bestInCluster = clusterModels[0]; // placeholder, will be sorted
234
+
235
+ // Pre-compute cluster cost range
236
+ const clusterCosts = clusterModels.map(m =>
237
+ calculateCost(m.inputPricePerM, m.outputPricePerM, inTokens, outTokens).totalCost
238
+ );
239
+ const maxClusterCost = Math.max(...clusterCosts);
240
+
241
+ // Score each model
242
+ const scored = clusterModels.map(m => {
243
+ const costs = calculateCost(m.inputPricePerM, m.outputPricePerM, inTokens, outTokens);
244
+ const { capFit, costEff, compositeScore } = scoreWithinCluster(m, task.type, clusterModels);
245
+ const isBest = false; // will be set after sort
246
+ const fitBadge = getFitBadge(compositeScore, cluster.id);
247
+ return {
248
+ modelId: m.id,
249
+ modelName: m.name,
250
+ provider: m.provider,
251
+ clusterId: m.clusterId,
252
+ clusterName: cluster.name,
253
+ inputTokens: inTokens,
254
+ outputTokens: outTokens,
255
+ inputCost: costs.inputCost,
256
+ outputCost: costs.outputCost,
257
+ totalCost: costs.totalCost,
258
+ latencyScore: LATENCY_SCORE[m.latency] ?? 50,
259
+ capFit,
260
+ costEff,
261
+ compositeScore,
262
+ fitBadge,
263
+ insight: '',
264
+ relativeCostPct: Math.round((costs.totalCost / maxClusterCost) * 100),
265
+ _isBest: false,
266
+ } as ModelEstimate & { _isBest: boolean };
267
+ });
268
+
269
+ // Sort by composite score desc, then find cheapest above threshold
270
+ scored.sort((a, b) => b.compositeScore - a.compositeScore);
271
+ const best = scored.find(m => m.compositeScore >= 65) ?? scored[0];
272
+ if (best) best._isBest = true;
273
+
274
+ // Set insights
275
+ scored.forEach(m => {
276
+ m.insight = getInsight(m.modelName, cluster.name, cluster.id, task.type, m.compositeScore, m._isBest);
277
+ m.fitBadge = m._isBest ? 'recommended' : m.compositeScore >= 65 ? 'sufficient' : 'overkill';
278
+ });
279
+
280
+ result.clusters[cluster.id] = {
281
+ clusterId: cluster.id,
282
+ clusterName: cluster.name,
283
+ models: scored,
284
+ best: best ?? null,
285
+ };
286
+ result.allModels.push(...scored);
287
+ }
288
+
289
+ // Global: find best across all models
290
+ result.allModels.sort((a, b) => b.compositeScore - a.compositeScore);
291
+ result.recommended = result.allModels.find(m => m.compositeScore >= 80) ?? null;
292
+
293
+ // Selected
294
+ if (selectedModelId) {
295
+ result.selected = result.allModels.find(m => m.modelId === selectedModelId) ?? null;
296
+ }
297
+ if (!result.selected) {
298
+ result.selected = result.allModels[0];
299
+ }
300
+
301
+ // Tip
302
+ if (result.recommended && result.selected && result.recommended.modelId !== result.selected.modelId) {
303
+ const selCost = result.selected?.totalCost ?? 0;
304
+ const recCost = result.recommended.totalCost;
305
+ const saving = selCost - recCost;
306
+ if (saving > 0.0001) {
307
+ result.tip = `Switching to ${result.recommended.modelName} saves ${formatCost(saving)} and is better for this task.`;
308
+ } else if (saving < -0.0001) {
309
+ result.tip = `${result.selected?.modelName} is well-matched. ${result.recommended.modelName} would cost ${formatCost(Math.abs(saving))} more.`;
310
+ }
311
+ } else if (result.selected) {
312
+ result.tip = `${result.selected.modelName} is a good fit for this task.`;
313
+ }
314
+
315
+ return result;
316
+ }
317
+
318
+ // ─── Utility ─────────────────────────────────────────────────────────────────
319
+
320
+ export function formatCost(n: number): string {
321
+ if (n >= 1) return '$' + n.toFixed(2);
322
+ if (n >= 0.01) return '$' + n.toFixed(3);
323
+ return '$' + n.toFixed(4);
324
+ }
loomlens-models.ts ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LoomLens Live — Model Registry
3
+ *
4
+ * Canonical model definitions used by the estimation engine.
5
+ * Cluster assignments and full model metadata live in loomlens-clusters.js.
6
+ * This file provides supplementary metadata (provider URLs, descriptions, etc.)
7
+ *
8
+ * Note: For the standalone sidebar, cluster definitions in loomlens-clusters.js
9
+ * are the source of truth for all pricing, latency, and cluster membership.
10
+ */
11
+
12
+ export interface ModelInfo {
13
+ id: string;
14
+ name: string;
15
+ provider: string;
16
+ tier: 1 | 2 | 3 | 4 | 5;
17
+ tierName: string;
18
+ inputPricePerM: number;
19
+ outputPricePerM: number;
20
+ contextWindow: number;
21
+ latency: 'slow' | 'medium' | 'fast';
22
+ capabilities: string[];
23
+ /** Short one-line description for tooltips/cards */
24
+ tagline: string;
25
+ /** URL to provider docs */
26
+ docsUrl?: string;
27
+ }
28
+
29
+ export const ModelDescriptions: Record<string, ModelInfo> = {
30
+
31
+ 'minimax/MiniMax-M2': {
32
+ id: 'minimax/MiniMax-M2',
33
+ name: 'MiniMax M2',
34
+ provider: 'MiniMax',
35
+ tier: 3,
36
+ tierName: 'Fast',
37
+ inputPricePerM: 0.10,
38
+ outputPricePerM: 0.40,
39
+ contextWindow: 1_024_000,
40
+ latency: 'fast',
41
+ capabilities: ['reasoning', 'fast', 'cheap', 'long-context'],
42
+ tagline: 'Best value reasoning model available',
43
+ docsUrl: 'https://www.minimaxi.com/',
44
+ },
45
+ 'google/gemini-2.5-flash': {
46
+ id: 'google/gemini-2.5-flash',
47
+ name: 'Gemini 2.5 Flash',
48
+ provider: 'Google',
49
+ tier: 3,
50
+ tierName: 'Fast',
51
+ inputPricePerM: 0.15,
52
+ outputPricePerM: 0.60,
53
+ contextWindow: 1_000_000,
54
+ latency: 'fast',
55
+ capabilities: ['reasoning', 'fast'],
56
+ tagline: 'Google\'s fastest reasoning model',
57
+ docsUrl: 'https://ai.google.dev/',
58
+ },
59
+ 'anthropic/claude-haiku': {
60
+ id: 'anthropic/claude-haiku',
61
+ name: 'Claude Haiku',
62
+ provider: 'Anthropic',
63
+ tier: 3,
64
+ tierName: 'Fast',
65
+ inputPricePerM: 0.80,
66
+ outputPricePerM: 4.00,
67
+ contextWindow: 200_000,
68
+ latency: 'fast',
69
+ capabilities: ['fast', 'light-reasoning', 'cheap'],
70
+ tagline: 'Anthropic\'s fastest, most affordable',
71
+ docsUrl: 'https://www.anthropic.com/',
72
+ },
73
+ 'deepseek/deepseek-chat-v3': {
74
+ id: 'deepseek/deepseek-chat-v3',
75
+ name: 'DeepSeek Chat v3',
76
+ provider: 'DeepSeek',
77
+ tier: 3,
78
+ tierName: 'Fast',
79
+ inputPricePerM: 0.27,
80
+ outputPricePerM: 1.10,
81
+ contextWindow: 640_000,
82
+ latency: 'fast',
83
+ capabilities: ['reasoning', 'fast'],
84
+ tagline: 'Strong open-weights reasoning at low cost',
85
+ docsUrl: 'https://www.deepseek.com/',
86
+ },
87
+ 'openai/gpt-4o-mini': {
88
+ id: 'openai/gpt-4o-mini',
89
+ name: 'GPT-4o Mini',
90
+ provider: 'OpenAI',
91
+ tier: 3,
92
+ tierName: 'Fast',
93
+ inputPricePerM: 0.15,
94
+ outputPricePerM: 0.60,
95
+ contextWindow: 128_000,
96
+ latency: 'fast',
97
+ capabilities: ['fast', 'cheap'],
98
+ tagline: 'OpenAI\'s cheapest GPT-4 class model',
99
+ docsUrl: 'https://platform.openai.com/',
100
+ },
101
+ 'google/gemini-2.0-flash': {
102
+ id: 'google/gemini-2.0-flash',
103
+ name: 'Gemini 2.0 Flash',
104
+ provider: 'Google',
105
+ tier: 3,
106
+ tierName: 'Fast',
107
+ inputPricePerM: 0.10,
108
+ outputPricePerM: 0.40,
109
+ contextWindow: 1_000_000,
110
+ latency: 'fast',
111
+ capabilities: ['fast', 'cheap', 'long-context'],
112
+ tagline: 'Google\'s original Flash speed at lowest price',
113
+ docsUrl: 'https://ai.google.dev/',
114
+ },
115
+ 'xai/grok-3': {
116
+ id: 'xai/grok-3',
117
+ name: 'xAI Grok-3',
118
+ provider: 'xAI',
119
+ tier: 3,
120
+ tierName: 'Fast',
121
+ inputPricePerM: 2.00,
122
+ outputPricePerM: 10.00,
123
+ contextWindow: 131_072,
124
+ latency: 'fast',
125
+ capabilities: ['fast', 'reasoning'],
126
+ tagline: 'xAI\'s fastest model with real-time data access',
127
+ docsUrl: 'https://x.ai/',
128
+ },
129
+ 'anthropic/claude-sonnet-4-20250514': {
130
+ id: 'anthropic/claude-sonnet-4-20250514',
131
+ name: 'Claude Sonnet 4',
132
+ provider: 'Anthropic',
133
+ tier: 2,
134
+ tierName: 'Balanced',
135
+ inputPricePerM: 3.00,
136
+ outputPricePerM: 15.00,
137
+ contextWindow: 200_000,
138
+ latency: 'medium',
139
+ capabilities: ['reasoning', 'code', 'analysis', 'fast-reasoning'],
140
+ tagline: 'Best overall code + reasoning value from Anthropic',
141
+ docsUrl: 'https://www.anthropic.com/',
142
+ },
143
+ 'openai/gpt-4o': {
144
+ id: 'openai/gpt-4o',
145
+ name: 'GPT-4o',
146
+ provider: 'OpenAI',
147
+ tier: 2,
148
+ tierName: 'Balanced',
149
+ inputPricePerM: 2.50,
150
+ outputPricePerM: 10.00,
151
+ contextWindow: 128_000,
152
+ latency: 'medium',
153
+ capabilities: ['reasoning', 'vision', 'code', 'multimodal'],
154
+ tagline: 'OpenAI\'s flagship — vision + reasoning in one',
155
+ docsUrl: 'https://platform.openai.com/',
156
+ },
157
+ 'anthropic/claude-3.5-sonnet': {
158
+ id: 'anthropic/claude-3.5-sonnet',
159
+ name: 'Claude 3.5 Sonnet',
160
+ provider: 'Anthropic',
161
+ tier: 2,
162
+ tierName: 'Balanced',
163
+ inputPricePerM: 3.00,
164
+ outputPricePerM: 15.00,
165
+ contextWindow: 200_000,
166
+ latency: 'medium',
167
+ capabilities: ['reasoning', 'code', 'analysis'],
168
+ tagline: 'Battle-tested coding and reasoning',
169
+ docsUrl: 'https://www.anthropic.com/',
170
+ },
171
+ 'anthropic/claude-opus-4-20250514': {
172
+ id: 'anthropic/claude-opus-4-20250514',
173
+ name: 'Claude Opus 4',
174
+ provider: 'Anthropic',
175
+ tier: 1,
176
+ tierName: 'Power',
177
+ inputPricePerM: 15.00,
178
+ outputPricePerM: 75.00,
179
+ contextWindow: 200_000,
180
+ latency: 'slow',
181
+ capabilities: ['reasoning', 'code', 'analysis', 'long-context'],
182
+ tagline: 'Anthropic\'s most capable model',
183
+ docsUrl: 'https://www.anthropic.com/',
184
+ },
185
+ 'anthropic/claude-code': {
186
+ id: 'anthropic/claude-code',
187
+ name: 'Claude Code',
188
+ provider: 'Anthropic',
189
+ tier: 1,
190
+ tierName: 'Power',
191
+ inputPricePerM: 15.00,
192
+ outputPricePerM: 75.00,
193
+ contextWindow: 200_000,
194
+ latency: 'slow',
195
+ capabilities: ['agentic', 'code', 'architecture', 'full-reasoning'],
196
+ tagline: 'Agentic coding — runs in your codebase',
197
+ docsUrl: 'https://www.anthropic.com/',
198
+ },
199
+ 'anthropic/claude-3.5': {
200
+ id: 'anthropic/claude-3.5',
201
+ name: 'Claude 3.5',
202
+ provider: 'Anthropic',
203
+ tier: 4,
204
+ tierName: 'Vision',
205
+ inputPricePerM: 3.00,
206
+ outputPricePerM: 15.00,
207
+ contextWindow: 200_000,
208
+ latency: 'medium',
209
+ capabilities: ['vision', 'code', 'analysis'],
210
+ tagline: 'Anthropic\'s strongest vision + document understanding',
211
+ docsUrl: 'https://www.anthropic.com/',
212
+ },
213
+ };
loomlens-openclaw-plugin.ts ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LoomLens Live — OpenClaw Plugin Integration
3
+ * Phase 3: Wire into OpenClaw's hook system
4
+ *
5
+ * This plugin:
6
+ * 1. Listens for `before_model_resolve` — intercepts model selection
7
+ * 2. Listens for `before_prompt_build` — can modify prompt context
8
+ * 3. Exposes a `/loomlens` command for manual estimation
9
+ * 4. Provides HTTP route for the panel to query estimates
10
+ *
11
+ * Install: copy this dir to ~/.openclaw/plugins/loomlens/
12
+ */
13
+
14
+ import { registerPlugin, HookHandler } from 'openclaw/plugin-sdk';
15
+
16
+ // ─── Plugin Manifest ────────────────────────────────────────────────────────────
17
+
18
+ export const manifest = {
19
+ id: 'loomlens-live',
20
+ name: 'LoomLens Live',
21
+ description: 'Pre-flight cost intelligence — see model costs before you spend',
22
+ version: '1.0.0',
23
+ author: 'Aster Vale / AEHS',
24
+ events: [
25
+ 'before_model_resolve', // intercept + override model selection
26
+ 'before_prompt_build', // read prompt context before agent reply
27
+ ],
28
+ commands: ['/loomlens'],
29
+ routes: [
30
+ {
31
+ method: 'GET',
32
+ path: '/loomlens/estimate',
33
+ description: 'Returns cost estimates for a prompt (for panel use)',
34
+ },
35
+ ],
36
+ };
37
+
38
+ // ─── Hook Handlers ────────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * before_model_resolve
42
+ * Fires just before the model is selected for a prompt.
43
+ * Return a different modelId in event.model to override.
44
+ */
45
+ const handleBeforeModelResolve: HookHandler = async (event) => {
46
+ // event.context contains: sessionKey, messages, preferredModel, modelHint, cfg
47
+ // If loomlens has a model override pending for this session, apply it here.
48
+ const override = await getPendingModelOverride(event.context.sessionKey);
49
+ if (override) {
50
+ event.model = override;
51
+ }
52
+ return event;
53
+ };
54
+
55
+ /**
56
+ * before_prompt_build
57
+ * Fires after preprocessing, before the prompt is built for the model.
58
+ * Use this to read the prompt content for loomlens estimation.
59
+ */
60
+ const handleBeforePromptBuild: HookHandler = async (event) => {
61
+ // event.context.bodyForAgent = the final enriched prompt body
62
+ // We could emit this to connected LoomLens panels via SSE or broadcast.
63
+ // For now, just store as pending for the estimation endpoint.
64
+ const sessionKey = event.context.sessionKey;
65
+ const body = event.context.bodyForAgent;
66
+ if (sessionKey && body) {
67
+ await setPendingPrompt(sessionKey, body);
68
+ }
69
+ return event;
70
+ };
71
+
72
+ // ─── Session State ─────────────────────────────────────────────────────────────
73
+ // In-memory store for pending prompts / model overrides (per session)
74
+ // In production, use Redis or the Signal Loom session store.
75
+
76
+ const pendingPrompts = new Map<string, string>();
77
+ const pendingOverrides = new Map<string, string>();
78
+
79
+ async function setPendingPrompt(sessionKey: string, prompt: string): Promise<void> {
80
+ pendingPrompts.set(sessionKey, prompt);
81
+ // Expire after 60s
82
+ setTimeout(() => pendingPrompts.delete(sessionKey), 60_000);
83
+ }
84
+
85
+ async function getPendingPrompt(sessionKey: string): Promise<string | undefined> {
86
+ return pendingPrompts.get(sessionKey);
87
+ }
88
+
89
+ async function setPendingModelOverride(sessionKey: string, modelId: string): Promise<void> {
90
+ pendingOverrides.set(sessionKey, modelId);
91
+ }
92
+
93
+ async function getPendingModelOverride(sessionKey: string): Promise<string | undefined> {
94
+ return pendingOverrides.get(sessionKey);
95
+ }
96
+
97
+ // ─── Plugin Registration ──────────────────────────────────────────────────────
98
+
99
+ export default registerPlugin(manifest.id, (api) => {
100
+ api.registerHook('before_model_resolve', handleBeforeModelResolve);
101
+ api.registerHook('before_prompt_build', handleBeforePromptBuild);
102
+
103
+ api.registerCommand('/loomlens', async (event) => {
104
+ // Manual trigger: estimate the last prompt in this session
105
+ const sessionKey = event.sessionKey;
106
+ const prompt = await getPendingPrompt(sessionKey) ?? '';
107
+ if (!prompt) {
108
+ event.messages.push('LoomLens: No pending prompt to estimate. Type a message first.');
109
+ return event;
110
+ }
111
+
112
+ // Import the engine dynamically (avoid circular deps)
113
+ const { estimatePrompt } = await import('../signal-loom-loomlens/loomlens-engine');
114
+ const result = estimatePrompt(prompt);
115
+
116
+ const lines = [
117
+ `✦ LoomLens Live Estimate`,
118
+ ``,
119
+ `Task: ${result.taskType} (~${Math.round(result.taskConfidence * 100)}% confidence)`,
120
+ `Tokens: ~${result.inputTokens} in / ~${result.outputTokens} out`,
121
+ ``,
122
+ `Top alternatives:`,
123
+ ...result.estimates.slice(0, 4).map(
124
+ (m, i) => ` ${i + 1}. ${m.modelName} — $${m.estimatedCost.toFixed(4)} [${m.fitBadge}]`
125
+ ),
126
+ ``,
127
+ result.tip ? `💡 ${result.tip}` : '',
128
+ ].filter(Boolean);
129
+
130
+ event.messages.push(lines.join('\n'));
131
+ return event;
132
+ });
133
+
134
+ api.registerRoute('GET', '/loomlens/estimate', async (req, res) => {
135
+ // Query param: ?prompt=...
136
+ const prompt = req.query?.prompt as string;
137
+ if (!prompt) {
138
+ res.status(400).json({ error: 'prompt query param required' });
139
+ return;
140
+ }
141
+
142
+ const { estimatePrompt } = await import('../signal-loom-loomlens/loomlens-engine');
143
+ const result = estimatePrompt(prompt);
144
+ res.json(result);
145
+ });
146
+
147
+ // Expose setter so the panel can push model overrides
148
+ api.registerRoute('POST', '/loomlens/override', async (req, res) => {
149
+ const { sessionKey, modelId } = req.body ?? {};
150
+ if (!sessionKey || !modelId) {
151
+ res.status(400).json({ error: 'sessionKey and modelId required' });
152
+ return;
153
+ }
154
+ await setPendingModelOverride(sessionKey, modelId);
155
+ res.json({ ok: true, modelId });
156
+ });
157
+
158
+ console.log('[LoomLens Live] Plugin loaded — pre-flight cost intelligence active');
159
+ return manifest;
160
+ });
161
+
162
+ // ─── Notes ────────────────────────────────────────────────────────────────────
163
+ //
164
+ // HOOK TIMING FLOW (from OpenClaw docs):
165
+ //
166
+ // message:received
167
+ // → message:preprocessed (body enriched with media/link data)
168
+ // → before_prompt_build ← we can read prompt here
169
+ // → before_model_resolve ← we can override model here
170
+ // → before_agent_reply
171
+ // → [model inference]
172
+ // → after_agent_reply
173
+ // → message:sent
174
+ //
175
+ // PANEL ↔ PLUGIN COMMUNICATION (for pre-flight panel):
176
+ //
177
+ // The React panel (loomlens-panel.tsx) needs to:
178
+ // 1. Get the current prompt text (from OpenClaw input state)
179
+ // 2. Call estimatePrompt() locally (no API needed — Phase 1 engine)
180
+ // 3. When user selects a model, call POST /loomlens/override
181
+ // to set a pending override for the session
182
+ // 4. The before_model_resolve hook picks up the override
183
+ // and applies it to event.model
184
+ //
185
+ // For step 1 (reading the prompt), the panel has two options:
186
+ // a. Read directly from DOM (textarea value) — simplest, works today
187
+ // b. Use window.postMessage('openclaw_prompt', { text }) event from OpenClaw
188
+ // (requires OpenClaw to emit this — not confirmed built-in yet)
189
+ //
190
+ // The existing panel.html uses window.addEventListener('message', handler)
191
+ // to receive openclaw_prompt events. This is the expected integration path.
192
+ // If OpenClaw doesn't emit openclaw_prompt natively yet, we can add a small
193
+ // workspace hook that emits it via postMessage to the parent window.
194
+ //
loomlens-panel.tsx ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LoomLens Live — Pre-Flight React Panel
3
+ * Phase 2: Model selection UI
4
+ *
5
+ * Shows cost estimates and alternatives before prompt dispatch.
6
+ * Designed to mount in OpenClaw's prompt input area.
7
+ */
8
+
9
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
10
+ import { estimatePrompt, LoomLensResult, TaskType } from './loomlens-engine';
11
+ import { getAllModels, ModelInfo } from './loomlens-models';
12
+
13
+ // ─── Types ────────────────────────────────────────────────────────────────────
14
+
15
+ interface LoomLensPanelProps {
16
+ /** Current prompt text */
17
+ prompt: string;
18
+ /** Currently selected model ID (from OpenClaw context) */
19
+ selectedModel?: string;
20
+ /** Called when user selects a different model */
21
+ onModelChange?: (modelId: string) => void;
22
+ /** Called when user clicks "Run Prompt" */
23
+ onRun?: (modelId: string) => void;
24
+ /** Debounce ms before estimating (default 300) */
25
+ debounceMs?: number;
26
+ }
27
+
28
+ interface ModelCardProps {
29
+ model: LoomLensResult['estimates'][0];
30
+ isSelected: boolean;
31
+ isRecommended: boolean;
32
+ onSelect: (id: string) => void;
33
+ }
34
+
35
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
36
+
37
+ function formatCost(n: number): string {
38
+ if (n >= 1) return '$' + n.toFixed(2);
39
+ if (n >= 0.01) return '$' + n.toFixed(3);
40
+ return '$' + n.toFixed(4);
41
+ }
42
+
43
+ function taskLabel(type: TaskType): string {
44
+ const labels: Record<TaskType, string> = {
45
+ simple_qa: 'Quick Q&A',
46
+ summary: 'Summary',
47
+ code_edit: 'Code Edit',
48
+ code_write: 'Code Write',
49
+ complex_analysis: 'Analysis',
50
+ vision: 'Vision / Doc',
51
+ conversation: 'Conversation',
52
+ unknown: 'General',
53
+ };
54
+ return labels[type] ?? type;
55
+ }
56
+
57
+ function taskColor(type: TaskType): string {
58
+ const colors: Record<TaskType, string> = {
59
+ simple_qa: '#10b981',
60
+ summary: '#06b6d4',
61
+ code_edit: '#f59e0b',
62
+ code_write: '#8b5cf6',
63
+ complex_analysis: '#3b82f6',
64
+ vision: '#ec4899',
65
+ conversation: '#6b7280',
66
+ unknown: '#9ca3af',
67
+ };
68
+ return colors[type] ?? '#9ca3af';
69
+ }
70
+
71
+ // ─── Model Card ───────────────────────────────────────────────────────────────
72
+
73
+ const ModelCard: React.FC<ModelCardProps> = ({ model, isSelected, isRecommended, onSelect }) => {
74
+ const barWidth = Math.max(4, model.relativeCostPct);
75
+
76
+ return (
77
+ <div
78
+ className={`ll-card ${isSelected ? 'll-card--selected' : ''} ${isRecommended ? 'll-card--recommended' : ''}`}
79
+ onClick={() => onSelect(model.modelId)}
80
+ role="button"
81
+ tabIndex={0}
82
+ onKeyDown={e => e.key === 'Enter' && onSelect(model.modelId)}
83
+ >
84
+ <div className="ll-card__header">
85
+ <div className="ll-card__radio">
86
+ {isSelected ? (
87
+ <div className="ll-card__radio-dot" />
88
+ ) : (
89
+ <div className="ll-card__radio-empty" />
90
+ )}
91
+ </div>
92
+ <div className="ll-card__name">{model.modelName}</div>
93
+ <div className="ll-card__tier">{model.tierName}</div>
94
+ <div className="ll-card__cost">{formatCost(model.estimatedCost)}</div>
95
+ <div className="ll-card__bar-wrap">
96
+ <div className="ll-card__bar" style={{ width: `${barWidth}%` }} />
97
+ </div>
98
+ </div>
99
+
100
+ <div className="ll-card__insight">{model.insight}</div>
101
+
102
+ <div className="ll-card__badges">
103
+ {isSelected && <span className="ll-badge ll-badge--selected">✓ Selected</span>}
104
+ {isRecommended && !isSelected && <span className="ll-badge ll-badge--recommended">💡 Recommended</span>}
105
+ {model.fitBadge === 'overkill' && !isRecommended && (
106
+ <span className="ll-badge ll-badge--overkill">⚠️ Overkill</span>
107
+ )}
108
+ </div>
109
+ </div>
110
+ );
111
+ };
112
+
113
+ // ─── Main Panel ───────────────────────────────────────────────────────────────
114
+
115
+ export const LoomLensPanel: React.FC<LoomLensPanelProps> = ({
116
+ prompt,
117
+ selectedModel = 'minimax/MiniMax-M2',
118
+ onModelChange,
119
+ onRun,
120
+ debounceMs = 300,
121
+ }) => {
122
+ const [active, setActive] = useState(false);
123
+ const [result, setResult] = useState<LoomLensResult | null>(null);
124
+ const [selected, setSelected] = useState(selectedModel);
125
+ const [running, setRunning] = useState(false);
126
+ const debounceRef = useRef<NodeJS.Timeout | null>(null);
127
+ const inputRef = useRef<HTMLTextAreaElement | null>(null);
128
+
129
+ // Sync selected model with prop
130
+ useEffect(() => {
131
+ setSelected(selectedModel);
132
+ }, [selectedModel]);
133
+
134
+ // Debounced estimation
135
+ useEffect(() => {
136
+ if (!active || prompt.trim().length < 10) {
137
+ setResult(null);
138
+ return;
139
+ }
140
+ if (debounceRef.current) clearTimeout(debounceRef.current);
141
+ debounceRef.current = setTimeout(() => {
142
+ try {
143
+ const res = estimatePrompt(prompt, selected);
144
+ setResult(res);
145
+ } catch (e) {
146
+ console.error('[LoomLens] estimate error:', e);
147
+ }
148
+ }, debounceMs);
149
+ return () => {
150
+ if (debounceRef.current) clearTimeout(debounceRef.current);
151
+ };
152
+ }, [prompt, active, debounceMs, selected]);
153
+
154
+ const handleSelect = useCallback((modelId: string) => {
155
+ setSelected(modelId);
156
+ onModelChange?.(modelId);
157
+ }, [onModelChange]);
158
+
159
+ const handleRun = useCallback(() => {
160
+ setRunning(true);
161
+ onRun?.(selected);
162
+ setTimeout(() => setRunning(false), 500);
163
+ }, [selected, onRun]);
164
+
165
+ const selectedResult = result?.estimates.find(e => e.modelId === selected);
166
+ const recommended = result?.recommended;
167
+
168
+ return (
169
+ <div className="ll-root">
170
+ {/* Toggle */}
171
+ <label className="ll-toggle">
172
+ <input
173
+ type="checkbox"
174
+ checked={active}
175
+ onChange={e => setActive(e.target.checked)}
176
+ />
177
+ <span>LoomLens Live ✦</span>
178
+ </label>
179
+
180
+ {/* Panel */}
181
+ {active && (
182
+ <div className="ll-panel">
183
+ <div className="ll-panel__header">
184
+ <div className="ll-panel__title">LOOMLENS LIVE — PRE-FLIGHT</div>
185
+ {result && (
186
+ <div className="ll-panel__task-badge" style={{ borderColor: taskColor(result.taskType) }}>
187
+ {taskLabel(result.taskType)}
188
+ {result.taskConfidence < 0.5 && <span className="ll-panel__uncertainty">? {Math.round(result.taskConfidence * 100)}%</span>}
189
+ </div>
190
+ )}
191
+ </div>
192
+
193
+ {prompt.trim().length < 10 ? (
194
+ <div className="ll-panel__empty">
195
+ Enter more to estimate…
196
+ </div>
197
+ ) : !result ? (
198
+ <div className="ll-panel__empty">
199
+ Estimating…
200
+ </div>
201
+ ) : (
202
+ <>
203
+ {/* Selected model summary */}
204
+ <div className="ll-panel__selected">
205
+ <div className="ll-panel__sel-model">{selectedResult?.modelName}</div>
206
+ <div className="ll-panel__sel-cost">
207
+ ~{formatCost(selectedResult?.estimatedCost ?? 0)}
208
+ <span className="ll-panel__sel-tokens">
209
+ {' '}({result.inputTokens.toLocaleString()} in / ~{result.outputTokens} out)
210
+ </span>
211
+ </div>
212
+ </div>
213
+
214
+ {/* Alternatives */}
215
+ <div className="ll-panel__section-label">
216
+ Alternatives <span className="ll-panel__count">(ranked by fit)</span>
217
+ </div>
218
+
219
+ <div className="ll-cards">
220
+ {result.estimates.slice(0, 5).map(model => (
221
+ <ModelCard
222
+ key={model.modelId}
223
+ model={model}
224
+ isSelected={model.modelId === selected}
225
+ isRecommended={model.modelId === recommended?.modelId}
226
+ onSelect={handleSelect}
227
+ />
228
+ ))}
229
+ </div>
230
+
231
+ {/* Tip */}
232
+ {result.tip && (
233
+ <div className="ll-tip">
234
+ 💡 {result.tip}
235
+ </div>
236
+ )}
237
+
238
+ {/* Run */}
239
+ <div className="ll-panel__run">
240
+ <button
241
+ className="ll-btn ll-btn--run"
242
+ onClick={handleRun}
243
+ disabled={running}
244
+ >
245
+ {running ? 'Running…' : `Run on ${selectedResult?.modelName} →`}
246
+ </button>
247
+ </div>
248
+ </>
249
+ )}
250
+ </div>
251
+ )}
252
+
253
+ <style>{`
254
+ .ll-root {
255
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
256
+ font-size: 12px;
257
+ color: #e8edf5;
258
+ }
259
+
260
+ /* Toggle */
261
+ .ll-toggle {
262
+ display: flex;
263
+ align-items: center;
264
+ gap: 6px;
265
+ cursor: pointer;
266
+ user-select: none;
267
+ color: #8899bb;
268
+ font-size: 11px;
269
+ font-weight: 600;
270
+ letter-spacing: 0.3px;
271
+ }
272
+ .ll-toggle input[type=checkbox] {
273
+ accent-color: #8b5cf6;
274
+ width: 13px;
275
+ height: 13px;
276
+ }
277
+ .ll-toggle:hover { color: #a78bfa; }
278
+
279
+ /* Panel */
280
+ .ll-panel {
281
+ margin-top: 8px;
282
+ background: #111827;
283
+ border: 1px solid #1e2d4a;
284
+ border-radius: 8px;
285
+ overflow: hidden;
286
+ }
287
+
288
+ .ll-panel__header {
289
+ display: flex;
290
+ align-items: center;
291
+ justify-content: space-between;
292
+ padding: 8px 12px;
293
+ background: #1a2235;
294
+ border-bottom: 1px solid #1e2d4a;
295
+ }
296
+ .ll-panel__title {
297
+ font-size: 10px;
298
+ font-weight: 800;
299
+ letter-spacing: 1px;
300
+ color: #8899bb;
301
+ }
302
+ .ll-panel__task-badge {
303
+ font-size: 10px;
304
+ font-weight: 600;
305
+ padding: 2px 7px;
306
+ border-radius: 4px;
307
+ border: 1px solid;
308
+ color: #e8edf5;
309
+ display: flex;
310
+ align-items: center;
311
+ gap: 4px;
312
+ }
313
+ .ll-panel__uncertainty { opacity: 0.6; }
314
+
315
+ .ll-panel__empty {
316
+ padding: 20px;
317
+ text-align: center;
318
+ color: #4a6a9a;
319
+ font-size: 11px;
320
+ }
321
+
322
+ /* Selected summary */
323
+ .ll-panel__selected {
324
+ padding: 10px 12px;
325
+ background: #0a0e1a;
326
+ border-bottom: 1px solid #1e2d4a;
327
+ }
328
+ .ll-panel__sel-model { font-size: 14px; font-weight: 700; color: #a78bfa; }
329
+ .ll-panel__sel-cost { font-size: 16px; font-weight: 700; font-family: 'JetBrains Mono', monospace; color: #10b981; }
330
+ .ll-panel__sel-tokens { font-size: 9px; color: #4a6a9a; font-weight: 400; }
331
+
332
+ /* Section label */
333
+ .ll-panel__section-label {
334
+ padding: 7px 12px 4px;
335
+ font-size: 10px;
336
+ font-weight: 700;
337
+ letter-spacing: 0.5px;
338
+ color: #4a6a9a;
339
+ text-transform: uppercase;
340
+ }
341
+ .ll-panel__count { font-weight: 400; text-transform: none; letter-spacing: 0; }
342
+
343
+ /* Cards */
344
+ .ll-cards { padding: 0 8px 4px; }
345
+
346
+ .ll-card {
347
+ padding: 7px 8px;
348
+ border-radius: 6px;
349
+ margin-bottom: 3px;
350
+ cursor: pointer;
351
+ border: 1px solid transparent;
352
+ transition: background 0.12s, border-color 0.12s;
353
+ background: #1a2235;
354
+ }
355
+ .ll-card:hover { background: #1f2d45; }
356
+ .ll-card--selected {
357
+ border-color: #8b5cf6;
358
+ background: rgba(139,92,246,0.08);
359
+ }
360
+ .ll-card--recommended { border-color: rgba(16,185,129,0.3); }
361
+
362
+ .ll-card__header {
363
+ display: flex;
364
+ align-items: center;
365
+ gap: 6px;
366
+ }
367
+ .ll-card__radio {
368
+ width: 12px;
369
+ height: 12px;
370
+ flex-shrink: 0;
371
+ display: flex;
372
+ align-items: center;
373
+ justify-content: center;
374
+ }
375
+ .ll-card__radio-dot {
376
+ width: 10px;
377
+ height: 10px;
378
+ border-radius: 50%;
379
+ background: #8b5cf6;
380
+ box-shadow: 0 0 4px rgba(139,92,246,0.6);
381
+ }
382
+ .ll-card__radio-empty {
383
+ width: 10px;
384
+ height: 10px;
385
+ border-radius: 50%;
386
+ border: 1.5px solid #4a6a9a;
387
+ }
388
+ .ll-card__name {
389
+ font-size: 12px;
390
+ font-weight: 600;
391
+ color: #e8edf5;
392
+ flex: 1;
393
+ min-width: 0;
394
+ overflow: hidden;
395
+ text-overflow: ellipsis;
396
+ white-space: nowrap;
397
+ }
398
+ .ll-card--selected .ll-card__name { color: #a78bfa; }
399
+ .ll-card__tier {
400
+ font-size: 9px;
401
+ color: #4a6a9a;
402
+ background: rgba(255,255,255,0.04);
403
+ padding: 1px 5px;
404
+ border-radius: 3px;
405
+ flex-shrink: 0;
406
+ }
407
+ .ll-card__cost {
408
+ font-size: 11px;
409
+ font-family: 'JetBrains Mono', monospace;
410
+ color: #8899bb;
411
+ flex-shrink: 0;
412
+ }
413
+ .ll-card__bar-wrap {
414
+ width: 50px;
415
+ height: 4px;
416
+ background: rgba(255,255,255,0.06);
417
+ border-radius: 99px;
418
+ overflow: hidden;
419
+ flex-shrink: 0;
420
+ }
421
+ .ll-card__bar {
422
+ height: 100%;
423
+ background: #4a6a9a;
424
+ border-radius: 99px;
425
+ transition: width 0.3s;
426
+ }
427
+ .ll-card--selected .ll-card__bar { background: #8b5cf6; }
428
+ .ll-card--recommended .ll-card__bar { background: #10b981; }
429
+
430
+ .ll-card__insight {
431
+ font-size: 10px;
432
+ color: #6b7280;
433
+ margin: 3px 0 3px 18px;
434
+ line-height: 1.4;
435
+ }
436
+ .ll-card__badges {
437
+ display: flex;
438
+ gap: 4px;
439
+ margin-left: 18px;
440
+ margin-top: 2px;
441
+ }
442
+
443
+ .ll-badge {
444
+ font-size: 9px;
445
+ font-weight: 700;
446
+ padding: 1px 5px;
447
+ border-radius: 3px;
448
+ }
449
+ .ll-badge--selected { background: rgba(139,92,246,0.15); color: #a78bfa; }
450
+ .ll-badge--recommended { background: rgba(16,185,129,0.12); color: #10b981; }
451
+ .ll-badge--overkill { background: rgba(245,158,11,0.12); color: #f59e0b; }
452
+
453
+ /* Tip */
454
+ .ll-tip {
455
+ margin: 6px 12px;
456
+ padding: 6px 10px;
457
+ background: rgba(16,185,129,0.07);
458
+ border: 1px solid rgba(16,185,129,0.15);
459
+ border-radius: 5px;
460
+ font-size: 10px;
461
+ color: #10b981;
462
+ line-height: 1.5;
463
+ }
464
+
465
+ /* Run button */
466
+ .ll-panel__run {
467
+ padding: 8px 12px 10px;
468
+ }
469
+ .ll-btn--run {
470
+ width: 100%;
471
+ background: #8b5cf6;
472
+ color: white;
473
+ border: none;
474
+ border-radius: 6px;
475
+ padding: 9px 16px;
476
+ font-size: 12px;
477
+ font-weight: 700;
478
+ cursor: pointer;
479
+ transition: background 0.15s, opacity 0.15s;
480
+ font-family: 'Inter', sans-serif;
481
+ }
482
+ .ll-btn--run:hover { background: #7c3aed; }
483
+ .ll-btn--run:disabled { opacity: 0.5; cursor: not-allowed; }
484
+ `}</style>
485
+ </div>
486
+ );
487
+ };
488
+
489
+ export default LoomLensPanel;
loomlens-sidebar.html ADDED
@@ -0,0 +1,1156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>LoomLens Live ✦</title>
6
+ <style>
7
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
8
+
9
+ :root {
10
+ --bg: #0a0e1a;
11
+ --surface: #111827;
12
+ --surface2: #1a2235;
13
+ --border: #1e2d4a;
14
+ --accent: #8b5cf6;
15
+ --accent-bright: #a78bfa;
16
+ --green: #10b981;
17
+ --amber: #f59e0b;
18
+ --red: #ef4444;
19
+ --text: #e8edf5;
20
+ --text-dim: #8899bb;
21
+ --text-muted: #4a6a9a;
22
+ --radius: 8px;
23
+ --font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
24
+ --mono: 'JetBrains Mono', 'Fira Code', monospace;
25
+ }
26
+
27
+ body {
28
+ font-family: var(--font);
29
+ background: transparent;
30
+ color: var(--text);
31
+ font-size: 12px;
32
+ line-height: 1.4;
33
+ width: 320px;
34
+ }
35
+
36
+ /* ── Root Panel ── */
37
+ .ll-panel {
38
+ background: var(--surface);
39
+ border: 1px solid var(--border);
40
+ border-radius: var(--radius);
41
+ overflow: hidden;
42
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5);
43
+ display: flex;
44
+ flex-direction: column;
45
+ max-height: 600px;
46
+ }
47
+
48
+ /* ── Header ── */
49
+ .ll-header {
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: space-between;
53
+ padding: 10px 12px;
54
+ background: var(--surface2);
55
+ border-bottom: 1px solid var(--border);
56
+ cursor: move;
57
+ user-select: none;
58
+ flex-shrink: 0;
59
+ }
60
+ .ll-header-left { display: flex; align-items: center; gap: 8px; }
61
+ .ll-dot {
62
+ width: 8px; height: 8px;
63
+ border-radius: 50%;
64
+ background: var(--green);
65
+ box-shadow: 0 0 8px var(--green);
66
+ animation: pulse 2s infinite;
67
+ flex-shrink: 0;
68
+ }
69
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
70
+ .ll-title { font-size: 13px; font-weight: 800; color: var(--text); letter-spacing: 0.3px; }
71
+ .ll-badge {
72
+ font-size: 9px; font-weight: 800;
73
+ padding: 2px 7px; border-radius: 4px;
74
+ letter-spacing: 0.5px; flex-shrink: 0;
75
+ }
76
+ .ll-badge.free { background: rgba(16,185,129,0.15); color: var(--green); border: 1px solid rgba(16,185,129,0.25); }
77
+ .ll-badge.used { background: rgba(245,158,11,0.15); color: var(--amber); border: 1px solid rgba(245,158,11,0.25); }
78
+ .ll-badge.locked { background: rgba(255,255,255,0.06); color: var(--text-muted); border: 1px solid var(--border); }
79
+ .ll-badge.live { background: rgba(139,92,246,0.15); color: var(--accent-bright); border: 1px solid rgba(139,92,246,0.25); }
80
+ .ll-hdr-btns { display: flex; gap: 3px; }
81
+ .ll-hdr-btn {
82
+ background: transparent; border: none;
83
+ color: var(--text-muted); cursor: pointer;
84
+ font-size: 14px; padding: 2px 6px; border-radius: 3px;
85
+ line-height: 1; transition: all 0.15s;
86
+ }
87
+ .ll-hdr-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
88
+
89
+ /* ── Prompt Input ── */
90
+ .ll-input-area {
91
+ padding: 10px 12px;
92
+ background: var(--bg);
93
+ border-bottom: 1px solid var(--border);
94
+ flex-shrink: 0;
95
+ }
96
+ .ll-prompt-wrap {
97
+ position: relative;
98
+ border: 1.5px dashed var(--border);
99
+ border-radius: var(--radius);
100
+ transition: border-color 0.2s;
101
+ min-height: 72px;
102
+ }
103
+ .ll-prompt-wrap.drag-over { border-color: var(--accent); background: rgba(139,92,246,0.05); }
104
+ .ll-prompt-wrap.focused { border-color: var(--accent); border-style: solid; }
105
+ .ll-prompt-ta {
106
+ width: 100%; min-height: 70px;
107
+ background: transparent; border: none; outline: none;
108
+ color: var(--text); font-family: var(--font);
109
+ font-size: 11px; line-height: 1.5;
110
+ padding: 8px 10px; resize: vertical;
111
+ placeholder-color: var(--text-muted);
112
+ }
113
+ .ll-prompt-ta::placeholder { color: var(--text-muted); }
114
+ .ll-drop-hint {
115
+ position: absolute; top: 50%; left: 50%;
116
+ transform: translate(-50%, -50%);
117
+ text-align: center; pointer-events: none;
118
+ color: var(--text-muted); font-size: 11px;
119
+ line-height: 1.5;
120
+ }
121
+ .ll-drop-hint.hidden { display: none; }
122
+
123
+ /* ── Filters ── */
124
+ .ll-filters {
125
+ display: flex; gap: 6px; align-items: center;
126
+ padding: 7px 12px;
127
+ border-bottom: 1px solid var(--border);
128
+ background: var(--surface2);
129
+ flex-shrink: 0;
130
+ }
131
+ .ll-filter-label { font-size: 10px; color: var(--text-muted); font-weight: 600; letter-spacing: 0.5px; }
132
+ .ll-select {
133
+ flex: 1; background: var(--bg); border: 1px solid var(--border);
134
+ border-radius: 5px; color: var(--text); font-family: var(--font);
135
+ font-size: 10px; padding: 4px 6px; cursor: pointer; outline: none;
136
+ }
137
+ .ll-select:focus { border-color: var(--accent); }
138
+
139
+ /* ── Results ── */
140
+ .ll-body {
141
+ overflow-y: auto;
142
+ flex: 1;
143
+ min-height: 0;
144
+ }
145
+ .ll-body::-webkit-scrollbar { width: 3px; }
146
+ .ll-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; }
147
+
148
+ .ll-cluster { margin-bottom: 4px; }
149
+
150
+ .ll-cluster-header {
151
+ padding: 5px 12px 3px;
152
+ font-size: 9px; font-weight: 800;
153
+ letter-spacing: 1px; color: var(--text-muted);
154
+ text-transform: uppercase;
155
+ background: var(--bg);
156
+ border-top: 1px solid var(--border);
157
+ border-bottom: 1px solid var(--border);
158
+ position: sticky; top: 0;
159
+ display: flex; align-items: center; justify-content: space-between;
160
+ }
161
+ .ll-cluster-count { font-weight: 400; letter-spacing: 0; text-transform: none; }
162
+
163
+ .ll-card {
164
+ padding: 7px 12px;
165
+ cursor: pointer;
166
+ border-bottom: 1px solid rgba(30,45,74,0.5);
167
+ transition: background 0.1s;
168
+ display: flex; flex-direction: column; gap: 3px;
169
+ }
170
+ .ll-card:last-child { border-bottom: none; }
171
+ .ll-card:hover { background: rgba(255,255,255,0.02); }
172
+ .ll-card.selected { background: rgba(139,92,246,0.08); border-left: 2px solid var(--accent); padding-left: 10px; }
173
+ .ll-card.recommended { border-left: 2px solid var(--green); padding-left: 10px; }
174
+
175
+ .ll-card-top {
176
+ display: flex; align-items: center; gap: 6px;
177
+ }
178
+ .ll-radio {
179
+ width: 12px; height: 12px;
180
+ border-radius: 50%; flex-shrink: 0;
181
+ display: flex; align-items: center; justify-content: center;
182
+ }
183
+ .ll-card.selected .ll-radio {
184
+ background: var(--accent);
185
+ box-shadow: 0 0 6px rgba(139,92,246,0.6);
186
+ }
187
+ .ll-card:not(.selected) .ll-radio {
188
+ border: 1.5px solid var(--text-muted);
189
+ }
190
+ .ll-card-name {
191
+ font-size: 12px; font-weight: 600;
192
+ color: var(--text-dim); flex: 1; min-width: 0;
193
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
194
+ }
195
+ .ll-card.selected .ll-card-name { color: var(--accent-bright); }
196
+ .ll-card-cost {
197
+ font-size: 11px; font-family: var(--mono);
198
+ color: var(--text-dim); flex-shrink: 0;
199
+ }
200
+ .ll-card-speed {
201
+ font-size: 10px; color: var(--green); flex-shrink: 0;
202
+ display: flex; align-items: center; gap: 2px;
203
+ }
204
+ .ll-card-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
205
+
206
+ .ll-card-bar-row { display: flex; align-items: center; gap: 6px; padding-left: 18px; }
207
+ .ll-card-bar-wrap {
208
+ flex: 1; height: 3px; background: rgba(255,255,255,0.06);
209
+ border-radius: 99px; overflow: hidden;
210
+ }
211
+ .ll-card-bar { height: 100%; background: var(--text-muted); border-radius: 99px; transition: width 0.3s; }
212
+ .ll-card.selected .ll-card-bar { background: var(--accent); }
213
+ .ll-card.recommended .ll-card-bar { background: var(--green); }
214
+
215
+ .ll-card-insight {
216
+ font-size: 10px; color: var(--text-muted);
217
+ padding-left: 18px; line-height: 1.4;
218
+ }
219
+ .ll-card-badges { display: flex; gap: 4px; padding-left: 18px; flex-wrap: wrap; }
220
+ .ll-card-badge {
221
+ font-size: 9px; font-weight: 700; padding: 1px 5px;
222
+ border-radius: 3px;
223
+ }
224
+ .ll-card-badge.rec { background: rgba(16,185,129,0.15); color: var(--green); }
225
+ .ll-card-badge.over { background: rgba(245,158,11,0.12); color: var(--amber); }
226
+ .ll-card-badge.sel { background: rgba(139,92,246,0.15); color: var(--accent-bright); }
227
+
228
+ /* ── Tip ── */
229
+ .ll-tip {
230
+ margin: 6px 12px;
231
+ padding: 7px 10px;
232
+ background: rgba(16,185,129,0.07);
233
+ border: 1px solid rgba(16,185,129,0.15);
234
+ border-radius: 5px;
235
+ font-size: 10px; color: var(--green);
236
+ line-height: 1.5;
237
+ }
238
+
239
+ /* ── Footer ── */
240
+ .ll-footer {
241
+ padding: 10px 12px;
242
+ border-top: 1px solid var(--border);
243
+ background: var(--surface2);
244
+ display: flex; flex-direction: column; gap: 7px;
245
+ flex-shrink: 0;
246
+ }
247
+ .ll-quota-row {
248
+ display: flex; justify-content: space-between; align-items: center;
249
+ font-size: 10px;
250
+ }
251
+ .ll-quota-label { color: var(--text-muted); }
252
+ .ll-quota-val { font-family: var(--mono); color: var(--text-dim); }
253
+ .ll-quota-val.warn { color: var(--amber); }
254
+ .ll-quota-val.ok { color: var(--green); }
255
+
256
+ .ll-key-row { display: flex; gap: 5px; align-items: stretch; }
257
+ .ll-key-input {
258
+ flex: 1; background: var(--bg); border: 1px solid var(--border);
259
+ border-radius: 5px; padding: 6px 8px;
260
+ color: var(--text); font-family: var(--mono); font-size: 10px;
261
+ outline: none;
262
+ }
263
+ .ll-key-input:focus { border-color: var(--accent); }
264
+ .ll-key-input::placeholder { color: var(--text-muted); font-family: var(--font); }
265
+ .ll-btn {
266
+ font-family: var(--font); font-size: 10px; font-weight: 700;
267
+ padding: 6px 10px; border-radius: 5px; border: none;
268
+ cursor: pointer; transition: opacity 0.15s; white-space: nowrap;
269
+ }
270
+ .ll-btn:hover { opacity: 0.85; }
271
+ .ll-btn.primary { background: var(--accent); color: white; }
272
+ .ll-btn.ghost {
273
+ background: rgba(255,255,255,0.06); color: var(--text-dim);
274
+ border: 1px solid var(--border);
275
+ }
276
+ .ll-btn.danger { background: rgba(239,68,68,0.15); color: var(--red); border: 1px solid rgba(239,68,68,0.2); }
277
+
278
+ .ll-run-btn {
279
+ width: 100%;
280
+ background: linear-gradient(135deg, var(--accent), #7c3aed);
281
+ color: white; border: none;
282
+ border-radius: 6px; padding: 10px;
283
+ font-family: var(--font); font-size: 12px; font-weight: 800;
284
+ cursor: pointer; transition: all 0.15s;
285
+ display: flex; align-items: center; justify-content: center; gap: 6px;
286
+ letter-spacing: 0.3px;
287
+ }
288
+ .ll-run-btn:hover { opacity: 0.9; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(139,92,246,0.4); }
289
+ .ll-run-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
290
+
291
+ .ll-attribution {
292
+ font-size: 9px; color: var(--text-muted); text-align: center;
293
+ display: flex; align-items: center; justify-content: center; gap: 4px;
294
+ }
295
+ .ll-attribution a { color: var(--text-muted); text-decoration: none; }
296
+ .ll-attribution a:hover { color: var(--accent-bright); }
297
+
298
+ /* ── Empty / Locked State ── */
299
+ .ll-state {
300
+ padding: 24px 20px;
301
+ text-align: center;
302
+ display: flex; flex-direction: column; gap: 12px; align-items: center;
303
+ }
304
+ .ll-state-icon { font-size: 28px; opacity: 0.6; }
305
+ .ll-state-title { font-size: 13px; font-weight: 700; color: var(--text-dim); }
306
+ .ll-state-sub { font-size: 11px; color: var(--text-muted); line-height: 1.5; }
307
+ .ll-state-sub a { color: var(--accent-bright); text-decoration: none; }
308
+ .ll-state-sub a:hover { text-decoration: underline; }
309
+
310
+ /* ── Minimized Dock ── */
311
+ .ll-dock {
312
+ width: 44px; height: 44px;
313
+ background: var(--surface);
314
+ border: 1px solid var(--border);
315
+ border-radius: 12px;
316
+ display: flex; align-items: center; justify-content: center;
317
+ cursor: pointer;
318
+ box-shadow: 0 4px 16px rgba(0,0,0,0.4);
319
+ transition: all 0.2s;
320
+ position: relative;
321
+ }
322
+ .ll-dock:hover { border-color: var(--accent); transform: scale(1.05); }
323
+ .ll-dock-inner {
324
+ font-size: 16px; font-weight: 900;
325
+ color: var(--accent-bright);
326
+ letter-spacing: -1px;
327
+ }
328
+ .ll-dock-pulse {
329
+ position: absolute; top: -3px; right: -3px;
330
+ width: 10px; height: 10px;
331
+ border-radius: 50%;
332
+ background: var(--green);
333
+ box-shadow: 0 0 6px var(--green);
334
+ animation: pulse 2s infinite;
335
+ }
336
+ .ll-dock-pulse.used { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
337
+ .ll-dock-pulse.gone { display: none; }
338
+ </style>
339
+ </head>
340
+ <body>
341
+
342
+ <!-- ── DOCK (minimized) ─────────────────────────────── -->
343
+ <div class="ll-dock" id="ll_dock" onclick="LL.open()" title="LoomLens Live ✦">
344
+ <div class="ll-dock-inner">✦</div>
345
+ <div class="ll-dock-pulse" id="ll_dock_pulse"></div>
346
+ </div>
347
+
348
+ <!-- ── PANEL ─────────────────────────────────────── -->
349
+ <div class="ll-panel" id="ll_panel" style="display:none;">
350
+
351
+ <!-- Header -->
352
+ <div class="ll-header">
353
+ <div class="ll-header-left">
354
+ <div class="ll-dot"></div>
355
+ <span class="ll-title">LoomLens Live</span>
356
+ <span class="ll-badge free" id="ll_status_badge">3 FREE</span>
357
+ </div>
358
+ <div class="ll-hdr-btns">
359
+ <button class="ll-hdr-btn" id="ll_btn_min" title="Minimize">−</button>
360
+ <button class="ll-hdr-btn" id="ll_btn_close" title="Close">×</button>
361
+ </div>
362
+ </div>
363
+
364
+ <!-- Prompt Input -->
365
+ <div class="ll-input-area">
366
+ <div class="ll-prompt-wrap" id="ll_prompt_wrap">
367
+ <textarea
368
+ class="ll-prompt-ta"
369
+ id="ll_prompt_ta"
370
+ placeholder="Type or drop prompt here to estimate…"
371
+ rows="3"
372
+ ></textarea>
373
+ <div class="ll-drop-hint" id="ll_drop_hint">
374
+ ✦ Drop prompt here to estimate cost<br>
375
+ <span style="font-size:10px;opacity:0.6">or paste/type in the box above</span>
376
+ </div>
377
+ </div>
378
+ </div>
379
+
380
+ <!-- Filters -->
381
+ <div class="ll-filters">
382
+ <span class="ll-filter-label">Cluster:</span>
383
+ <select class="ll-select" id="ll_cluster_sel">
384
+ <option value="all">All Clusters</option>
385
+ <option value="flash">Flash Reasoning</option>
386
+ <option value="fastgen">Fast General</option>
387
+ <option value="code">Balanced Coding</option>
388
+ <option value="power">Power / Architecture</option>
389
+ <option value="vision">Vision / Multimodal</option>
390
+ </select>
391
+ <span class="ll-filter-label">Sort:</span>
392
+ <select class="ll-select" id="ll_sort_sel">
393
+ <option value="score">Best Fit</option>
394
+ <option value="cost">Lowest Cost</option>
395
+ <option value="speed">Fastest</option>
396
+ </select>
397
+ </div>
398
+
399
+ <!-- Results Body -->
400
+ <div class="ll-body" id="ll_body">
401
+ <div class="ll-state" id="ll_empty_state">
402
+ <div class="ll-state-icon">✦</div>
403
+ <div class="ll-state-title">LoomLens Live</div>
404
+ <div class="ll-state-sub">
405
+ Enter a prompt above to see cost estimates<br>across all major models
406
+ </div>
407
+ </div>
408
+ </div>
409
+
410
+ <!-- Tip -->
411
+ <div class="ll-tip" id="ll_tip" style="display:none;"></div>
412
+
413
+ <!-- Footer -->
414
+ <div class="ll-footer">
415
+ <div class="ll-quota-row">
416
+ <span class="ll-quota-label" id="ll_quota_label">Free estimates:</span>
417
+ <span class="ll-quota-val ok" id="ll_quota_val">3 left</span>
418
+ </div>
419
+ <div class="ll-key-row" id="ll_key_row" style="display:none;">
420
+ <input type="password" class="ll-key-input" id="ll_key_input" placeholder="sk_... Signal Loom API key" />
421
+ <button class="ll-btn primary" onclick="LL.connectKey()">Connect</button>
422
+ </div>
423
+ <button class="ll-btn ghost" id="ll_show_key" onclick="LL.toggleKeyRow()" style="font-size:9px;">
424
+ + Connect API key for unlimited
425
+ </button>
426
+ <button class="ll-run-btn" id="ll_run_btn" onclick="LL.runEstimate()" disabled>
427
+ Select a model to run →
428
+ </button>
429
+ <div class="ll-attribution">
430
+ Powered by <a href="https://signalloomai.com" target="_blank">Signal Loom AI</a> ✦
431
+ </div>
432
+ </div>
433
+
434
+ </div>
435
+
436
+ <script>
437
+ (function() {
438
+ 'use strict';
439
+
440
+ // ─── State ────────────────────────────────────────────────────────────────
441
+ const STORAGE_KEYS = {
442
+ apiKey: 'llive_api_key',
443
+ freeUsed: 'llive_free_used',
444
+ freeReset:'llive_free_reset',
445
+ selected: 'llive_selected_model',
446
+ };
447
+ const FREE_LIMIT = 3;
448
+ const CPT = 3.8; // chars per token (English average)
449
+
450
+ let state = {
451
+ prompt: '',
452
+ apiKey: localStorage.getItem(STORAGE_KEYS.apiKey) || '',
453
+ freeUsed: parseInt(localStorage.getItem(STORAGE_KEYS.freeUsed) || '0'),
454
+ freeReset: parseInt(localStorage.getItem(STORAGE_KEYS.freeReset) || '0'),
455
+ selectedModel: localStorage.getItem(STORAGE_KEYS.selected) || '',
456
+ result: null,
457
+ minimized: false,
458
+ };
459
+
460
+ // ─── Model Clusters ────────────────────────────────────────────────────────
461
+ const CLUSTERS = [
462
+ {
463
+ id: 'flash',
464
+ name: 'Flash Reasoning',
465
+ desc: 'Fast + cheap. Best for Q&A, summaries, simple tasks.',
466
+ models: [
467
+ { id: 'minimax/MiniMax-M2', name: 'MiniMax M2', provider: 'MiniMax', tier: 3, inPrice: 0.10, outPrice: 0.40, latency: 'fast', caps: ['reasoning','fast','cheap'] },
468
+ { id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'Google', tier: 3, inPrice: 0.15, outPrice: 0.60, latency: 'fast', caps: ['reasoning','fast'] },
469
+ { id: 'anthropic/claude-haiku', name: 'Claude Haiku', provider: 'Anthropic',tier: 3, inPrice: 0.80, outPrice: 4.00, latency: 'fast', caps: ['fast','light-reasoning'] },
470
+ { id: 'deepseek/deepseek-chat-v3',name:'DeepSeek Chat v3', provider: 'DeepSeek',tier: 3, inPrice: 0.27, outPrice: 1.10, latency: 'fast', caps: ['reasoning','fast'] },
471
+ ],
472
+ },
473
+ {
474
+ id: 'fastgen',
475
+ name: 'Fast General',
476
+ desc: 'Fast general purpose. Best latency for typical prompts.',
477
+ models: [
478
+ { id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini', provider: 'OpenAI', tier: 3, inPrice: 0.15, outPrice: 0.60, latency: 'fast', caps: ['fast','cheap'] },
479
+ { id: 'google/gemini-2.0-flash', name: 'Gemini 2.0 Flash', provider: 'Google', tier: 3, inPrice: 0.10, outPrice: 0.40, latency: 'fast', caps: ['fast','cheap'] },
480
+ { id: 'xai/grok-3', name: 'xAI Grok-3', provider: 'xAI', tier: 3, inPrice: 2.00, outPrice: 10.0, latency: 'fast', caps: ['fast','reasoning'] },
481
+ ],
482
+ },
483
+ {
484
+ id: 'code',
485
+ name: 'Balanced Coding',
486
+ desc: 'Best code capability per dollar. Medium tasks.',
487
+ models: [
488
+ { id: 'anthropic/claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'Anthropic', tier: 2, inPrice: 3.00, outPrice: 15.0, latency: 'medium', caps: ['reasoning','code','analysis'] },
489
+ { id: 'openai/gpt-4o', name: 'GPT-4o', provider: 'OpenAI', tier: 2, inPrice: 2.50, outPrice: 10.0, latency: 'medium', caps: ['reasoning','vision','code'] },
490
+ { id: 'anthropic/claude-3.5-sonnet',name: 'Claude 3.5 Sonnet', provider: 'Anthropic',tier: 2, inPrice: 3.00, outPrice: 15.0, latency: 'medium', caps: ['code','reasoning'] },
491
+ ],
492
+ },
493
+ {
494
+ id: 'power',
495
+ name: 'Power / Architecture',
496
+ desc: 'Maximum reasoning. Pay for it only when needed.',
497
+ models: [
498
+ { id: 'anthropic/claude-opus-4-20250514', name: 'Claude Opus 4', provider: 'Anthropic', tier: 1, inPrice: 15.0, outPrice: 75.0, latency: 'slow', caps: ['reasoning','code','analysis','long-context'] },
499
+ { id: 'anthropic/claude-code', name: 'Claude Code', provider: 'Anthropic', tier: 1, inPrice: 15.0, outPrice: 75.0, latency: 'slow', caps: ['agentic','code','architecture'] },
500
+ ],
501
+ },
502
+ {
503
+ id: 'vision',
504
+ name: 'Vision / Multimodal',
505
+ desc: 'Image + document understanding.',
506
+ models: [
507
+ { id: 'openai/gpt-4o', name: 'GPT-4o', provider: 'OpenAI', tier: 4, inPrice: 2.50, outPrice: 10.0, latency: 'medium', caps: ['vision','reasoning','multimodal'] },
508
+ { id: 'anthropic/claude-3.5', name: 'Claude 3.5', provider: 'Anthropic',tier: 4, inPrice: 3.00, outPrice: 15.0, latency: 'medium', caps: ['vision','code','analysis'] },
509
+ ],
510
+ },
511
+ ];
512
+
513
+ // ─── Task Detection ��───────────────────────────────────────────────────────
514
+ const TASK_KEYWORDS = {
515
+ simple_qa: ['what is','who is','define','explain','how do i','how does','what are','why does','when did','where is','tell me'],
516
+ summary: ['summarize','tl;dr','sum up','brief','recap','key points','highlights','overview','condense'],
517
+ code_edit: ['fix','bug','debug','hotfix','patch','tweak','adjust','correct','error','issue'],
518
+ code_write: ['write','build','create','implement','add feature','new function','generate',' scaffold','code for'],
519
+ complex_analysis: ['analyze','architecture','review','refactor','design','strategy','compare','evaluate','assess','plan'],
520
+ vision: ['image','photo','picture','screenshot','diagram','chart','visual','frame','pdf','document'],
521
+ };
522
+
523
+ const OUTPUT_TOKENS = { simple_qa:150, summary:200, code_edit:400, code_write:800, complex_analysis:600, vision:300, unknown:400 };
524
+ const LATENCY_SCORE = { fast: 100, medium: 62, slow: 22 };
525
+
526
+ function detectTask(prompt) {
527
+ const lower = prompt.toLowerCase();
528
+ const scores = {};
529
+ for (const [type, kws] of Object.entries(TASK_KEYWORDS)) {
530
+ scores[type] = kws.reduce((acc, kw) => acc + (lower.includes(kw) ? 1 : 0), 0);
531
+ }
532
+ // Boost code signals
533
+ if (/```[\s\S]+```/.test(prompt)) scores.code_write = (scores.code_write||0) + 2;
534
+ if (/^\s*(import|from|const|let|function|class|def |fn |pub fn)/m.test(prompt)) scores.code_write = (scores.code_write||0) + 2;
535
+ const best = Object.entries(scores).sort((a,b) => b[1]-a[1])[0];
536
+ return best && best[1] > 0 ? best[0] : 'unknown';
537
+ }
538
+
539
+ // ─── Capability Fit per Cluster ──────────────────────────────────────────
540
+ const CAP_FIT = {
541
+ // task → cluster → fit 0-100
542
+ simple_qa: { flash: 95, fastgen: 80, code: 30, power: 20, vision: 20 },
543
+ summary: { flash: 90, fastgen: 75, code: 40, power: 30, vision: 20 },
544
+ code_edit: { flash: 60, fastgen: 40, code: 90, power: 40, vision: 20 },
545
+ code_write: { flash: 30, fastgen: 20, code: 95, power: 95, vision: 20 },
546
+ complex_analysis: { flash: 40, fastgen: 30, code: 85, power: 98, vision: 20 },
547
+ vision: { flash: 10, fastgen: 10, code: 30, power: 30, vision: 98 },
548
+ unknown: { flash: 70, fastgen: 60, code: 60, power: 50, vision: 40 },
549
+ };
550
+
551
+ // ─── Cost Calculation ─────────────────────────────────────────────────────
552
+ function estimateTokens(text) {
553
+ if (!text || !text.trim()) return 0;
554
+ const lang = /[\u3040-\u309F\u30A0-\u30FF]/.test(text) ? 'ja' : /[\u4E00-\u9FFF]/.test(text) ? 'zh' : /[áéíóúñ¿¡ü]/i.test(text) ? 'es' : 'en';
555
+ const cpt = lang === 'ja' ? 1.2 : lang === 'zh' ? 1.0 : lang === 'es' ? 3.5 : CPT;
556
+ const multiline = (text.match(/\n/g)||[]).length;
557
+ const codeFences = (text.match(/```/g)||[]).length;
558
+ const mult = (codeFences >= 2 || multiline > 3) ? 1.2 : 1.0;
559
+ return Math.ceil((text.length / cpt) * mult);
560
+ }
561
+
562
+ function calcCost(model, inTokens, outTokens) {
563
+ return ((inTokens/1e6)*model.inPrice) + ((outTokens/1e6)*model.outPrice);
564
+ }
565
+
566
+ // ─── Scoring ─────────────────────────────────────────────────────────────
567
+ function scoreModel(model, task, clusterModels) {
568
+ const capFit = CAP_FIT[task]?.[model.clusterId] ?? 50;
569
+ const costs = clusterModels.map(m => calcCost(m,1,1)); // relative
570
+ const minC = Math.min(...costs);
571
+ const maxC = Math.max(...costs);
572
+ const range = maxC - minC || 1;
573
+ const costEff = Math.round(100 - ((calcCost(model,1,1)-minC)/range*100));
574
+ const speed = LATENCY_SCORE[model.latency] ?? 50;
575
+ return Math.round(capFit * 0.35 + costEff * 0.35 + speed * 0.30);
576
+ }
577
+
578
+ // ─── Main Estimation ─────────────────────────────────────────────────────
579
+ function estimate(prompt, selectedId) {
580
+ if (!prompt || prompt.trim().length < 10) return null;
581
+ const task = detectTask(prompt);
582
+ const inTokens = estimateTokens(prompt);
583
+ const outTokens = OUTPUT_TOKENS[task] ?? 400;
584
+ const flat = [];
585
+
586
+ for (const cluster of CLUSTERS) {
587
+ for (const model of cluster.models) {
588
+ const m = { ...model, clusterId: cluster.id, clusterName: cluster.name };
589
+ m.cost = calcCost(m, inTokens, outTokens);
590
+ m.inTokens = inTokens;
591
+ m.outTokens= outTokens;
592
+ flat.push(m);
593
+ }
594
+ }
595
+
596
+ // Per-cluster scoring
597
+ const clusterResults = {};
598
+ for (const cluster of CLUSTERS) {
599
+ const cModels = flat.filter(m => m.clusterId === cluster.id);
600
+ cModels.forEach(m => { m.score = scoreModel(m, task, cModels); });
601
+ const sorted = [...cModels].sort((a,b) => b.score - a.score);
602
+ const best = sorted.find(m => m.score >= 65) ?? sorted[0];
603
+ clusterResults[cluster.id] = { sorted, best, cluster };
604
+ }
605
+
606
+ // Global
607
+ const allScored = flat.map(m => {
608
+ const cr = clusterResults[m.clusterId];
609
+ m.score = cr.best === m ? m.score : Math.round(m.score * 0.7); // penalize non-cluster-best
610
+ return m;
611
+ });
612
+ const globalSorted = [...allScored].sort((a,b) => b.score - a.score);
613
+ const recommended = globalSorted.find(m => m.score >= 80);
614
+ const selected = flat.find(m => m.id === selectedId) ?? globalSorted[0];
615
+
616
+ // Tip
617
+ let tip = '';
618
+ if (recommended && recommended.id !== selectedId) {
619
+ const saving = (flat.find(m=>m.id===selectedId)?.cost ?? 0) - recommended.cost;
620
+ if (saving > 0.0001) {
621
+ tip = `${recommended.name} is better for this task. Switching saves ${fmtCost(saving)}.`;
622
+ }
623
+ }
624
+
625
+ return {
626
+ task, inTokens, outTokens,
627
+ clusters: clusterResults,
628
+ all: globalSorted,
629
+ recommended,
630
+ selected,
631
+ tip,
632
+ };
633
+ }
634
+
635
+ // ─── Formatting ──────────────────────────────────────────────────────────
636
+ function fmtCost(n) {
637
+ if (n >= 1) return '$' + n.toFixed(2);
638
+ if (n >= 0.01) return '$' + n.toFixed(3);
639
+ return '$' + n.toFixed(4);
640
+ }
641
+
642
+ // ─── API Response Mapper ─────────────────────────────────────────────────
643
+ // Maps Signal Loom /v1/estimate response → local LoomLensResult format.
644
+ // We pass all cluster models and inject the API cost into the right one.
645
+ function _mapAPIResponse(data, selectedId, prompt) {
646
+ // Find the cheapest from the API response
647
+ const apiModels = (data.models || []).sort((a,b) => a.estimated_cost_mid - b.estimated_cost_mid);
648
+ const cheapestAPI = apiModels[0];
649
+ const expensiveAPI = apiModels[apiModels.length - 1];
650
+
651
+ // Run local engine to get clusters, then inject API costs
652
+ const local = estimate(prompt, selectedId);
653
+
654
+ if (!cheapestAPI) return local;
655
+
656
+ // Inject API cost into the selected model
657
+ const selectedModel = local.allModels.find(m => m.modelId === selectedId) || local.allModels[0];
658
+ if (selectedModel) {
659
+ selectedModel.inputTokens = apiModels.find(m => m.model === selectedId)?.input_tokens_estimate || selectedModel.inputTokens;
660
+ selectedModel.outputTokens = apiModels.find(m => m.model === selectedId)?.output_tokens_estimate || selectedModel.outputTokens;
661
+ selectedModel.totalCost = apiModels.find(m => m.model === selectedId)?.estimated_cost_mid || selectedModel.totalCost;
662
+ }
663
+
664
+ // Update cheapest/expensive based on API data
665
+ const cheapestLocal = local.allModels.find(m => m.modelId === cheapestAPI.model);
666
+ if (cheapestLocal) cheapestLocal.totalCost = cheapestAPI.estimated_cost_mid;
667
+
668
+ // Re-sort clusters with injected costs
669
+ for (const [cid, cr] of Object.entries(local.clusters)) {
670
+ cr.models.sort((a,b) => a.totalCost - b.totalCost);
671
+ cr.best = cr.models.find(m => m.compositeScore >= 65) ?? cr.models[0];
672
+ }
673
+ local.allModels.sort((a,b) => b.compositeScore - a.compositeScore);
674
+ local.recommended = local.allModels.find(m => m.compositeScore >= 80) ?? cheapestLocal;
675
+
676
+ // Tip using API costs
677
+ const rec = local.recommended;
678
+ const sel = selectedModel;
679
+ if (rec && sel && rec.modelId !== sel.modelId) {
680
+ const saving = sel.totalCost - rec.totalCost;
681
+ if (saving > 0.0001) local.tip = `Switch to ${rec.modelName} and save ${fmtCost(saving)}.`;
682
+ else if (saving < -0.0001) local.tip = `${sel.modelName} costs ${fmtCost(Math.abs(saving))} more.`;
683
+ } else if (sel) {
684
+ local.tip = `${sel.modelName} is a good fit for this task.`;
685
+ }
686
+
687
+ // Attach billing metadata from API response
688
+ local._billing = data.billing || null;
689
+ local._apiResponse = data;
690
+ return local;
691
+ }
692
+
693
+ // ─── Loading State ─────────────────────────────────────────────────────────
694
+ let loading = false;
695
+ function setLoading(v) { loading = v; }
696
+
697
+ // ─── Free Usage ──────────────────────────────────────────────────────────
698
+ // Reset free counter at midnight UTC.
699
+ function checkFreeReset() {
700
+ const now = Math.floor(Date.now() / 1000);
701
+ if (now >= state.freeReset) {
702
+ state.freeUsed = 0;
703
+ state.freeReset = now + 86400; // reset at next midnight UTC
704
+ localStorage.setItem(STORAGE_KEYS.freeUsed, '0');
705
+ localStorage.setItem(STORAGE_KEYS.freeReset, String(state.freeReset));
706
+ }
707
+ }
708
+
709
+ // Decrement free run counter. Returns false when all 3 daily free runs are exhausted.
710
+ function useFree() {
711
+ if (state.apiKey) return true; // API key = paid, always allowed
712
+ checkFreeReset();
713
+ if (state.freeUsed >= FREE_LIMIT) return false; // no free runs left
714
+ state.freeUsed++;
715
+ localStorage.setItem(STORAGE_KEYS.freeUsed, String(state.freeUsed));
716
+ return true;
717
+ }
718
+
719
+ // Returns remaining free runs today (0 when exhausted).
720
+ function freeLeft() {
721
+ if (state.apiKey) return Infinity;
722
+ checkFreeReset();
723
+ return Math.max(0, FREE_LIMIT - state.freeUsed);
724
+ }
725
+
726
+ // ─── Render ──────────────────────────────────────────────────────────────
727
+ const $ = id => document.getElementById(id);
728
+
729
+ function render() {
730
+ const left = freeLeft();
731
+ const hasApi = !!state.apiKey;
732
+ const panel = $('ll_panel');
733
+ const dock = $('ll_dock');
734
+
735
+ // Badge — show -3, -2, -1 counter for free tier
736
+ const badge = $('ll_status_badge');
737
+ if (hasApi) {
738
+ badge.textContent = 'LIVE'; badge.className = 'll-badge live';
739
+ } else if (left > 0) {
740
+ const displayCount = -(FREE_LIMIT - left + 1); // -3, -2, -1
741
+ badge.textContent = displayCount; badge.className = 'll-badge free';
742
+ } else {
743
+ badge.textContent = '0'; badge.className = 'll-badge locked';
744
+ }
745
+
746
+ // Dock pulse
747
+ const pulse = $('ll_dock_pulse');
748
+ if (hasApi || left > 0) pulse.className = 'll-dock-pulse';
749
+ else if (left === 0) pulse.className = 'll-dock-pulse used';
750
+ else pulse.className = 'll-dock-pulse gone';
751
+
752
+ // Quota
753
+ const qLabel = $('ll_quota_label');
754
+ const qVal = $('ll_quota_val');
755
+ if (hasApi) {
756
+ // Show quota from last API response headers
757
+ const quota = state.result?._quota;
758
+ if (quota && quota.monthly_limit > 0) {
759
+ const rem = quota.remaining_this_month;
760
+ qLabel.textContent = 'API quota:';
761
+ qVal.textContent = rem === 0 ? 'Exhausted ⚠' : `${rem} remaining`;
762
+ qVal.className = rem === 0 ? 'll-quota-val warn' : 'll-quota-val ok';
763
+ } else {
764
+ qLabel.textContent = 'API key:'; qVal.textContent = 'Connected ✓'; qVal.className = 'll-quota-val ok';
765
+ }
766
+ } else {
767
+ const displayCount = left > 0 ? -(FREE_LIMIT - left + 1) : 0; // -3, -2, -1, then 0
768
+ qLabel.textContent = 'Free runs:';
769
+ qVal.textContent = displayCount < 0 ? displayCount : '0 (paid)';
770
+ qVal.className = displayCount < 0 ? 'll-quota-val ok' : 'll-quota-val warn';
771
+ }
772
+
773
+ // Key row
774
+ if (hasApi) {
775
+ $('ll_key_row').style.display = 'none';
776
+ $('ll_show_key').style.display = 'flex';
777
+ $('ll_show_key').className = 'll-btn ghost';
778
+ $('ll_show_key').textContent = 'Disconnect key';
779
+ }
780
+
781
+ renderBody();
782
+ renderTip();
783
+ }
784
+
785
+ function renderBody() {
786
+ const result = state.result;
787
+ const body = $('ll_body');
788
+ const empty = $('ll_empty_state');
789
+ const clusterSel = $('ll_cluster_sel').value;
790
+ const sortSel = $('ll_sort_sel').value;
791
+
792
+ // Loading state
793
+ if (loading) {
794
+ body.innerHTML = `<div class="ll-state">
795
+ <div class="ll-state-icon">⚡</div>
796
+ <div class="ll-state-title">Estimating…</div>
797
+ <div class="ll-state-sub">Calling Signal Loom API</div>
798
+ </div>`;
799
+ return;
800
+ }
801
+
802
+ // Error state
803
+ if (result?.error) {
804
+ const isQuota = result.error === 'quota_exhausted';
805
+ body.innerHTML = `<div class="ll-state">
806
+ <div class="ll-state-icon">${isQuota ? '📊' : '🔑'}</div>
807
+ <div class="ll-state-title">${isQuota ? 'Quota Exhausted' : 'API Key Issue'}</div>
808
+ <div class="ll-state-sub">${result.message}<br><br>
809
+ <a href="https://signallloomai.com/signup.html" target="_blank" style="color:#a78bfa">Get API key →</a>
810
+ &nbsp;·&nbsp;
811
+ <a href="https://signallloomai.com/pricing" target="_blank" style="color:#a78bfa">View plans →</a>
812
+ </div>
813
+ </div>`;
814
+ return;
815
+ }
816
+
817
+ if (!result) {
818
+ body.innerHTML = '';
819
+ body.appendChild(empty);
820
+ empty.style.display = '';
821
+ return;
822
+ }
823
+ empty.style.display = 'none';
824
+
825
+ // Collect models to show
826
+ let models = [];
827
+ if (clusterSel === 'all') {
828
+ models = result.all;
829
+ } else {
830
+ models = result.clusters[clusterSel]?.sorted ?? [];
831
+ }
832
+
833
+ // Sort
834
+ if (sortSel === 'cost') models = [...models].sort((a,b) => a.cost - b.cost);
835
+ else if (sortSel === 'speed') models = [...models].sort((a,b) => LATENCY_SCORE[b.latency] - LATENCY_SCORE[a.latency]);
836
+ // else: keep score order
837
+
838
+ const maxCost = Math.max(...result.all.map(m=>m.cost));
839
+
840
+ // Group by cluster
841
+ const groups = {};
842
+ for (const m of models) {
843
+ if (!groups[m.clusterId]) groups[m.clusterId] = [];
844
+ groups[m.clusterId].push(m);
845
+ }
846
+
847
+ let html = '';
848
+ for (const [cid, cmodels] of Object.entries(groups)) {
849
+ const cluster = CLUSTERS.find(c=>c.id===cid);
850
+ const best = result.clusters[cid]?.best;
851
+ html += `<div class="ll-cluster">
852
+ <div class="ll-cluster-header">
853
+ ${cluster?.name ?? cid}
854
+ <span class="ll-cluster-count">${cmodels.length} models</span>
855
+ </div>`;
856
+
857
+ for (const m of cmodels) {
858
+ const isSel = m.id === state.selectedModel;
859
+ const isBest = best?.id === m.id;
860
+ const barW = Math.max(4, Math.round((m.cost / maxCost) * 100));
861
+ const speedGlyph = m.latency === 'fast' ? '⚡' : m.latency === 'medium' ? '◆' : '◇';
862
+ const badges = [];
863
+ if (isSel) badges.push('<span class="ll-card-badge sel">✓ Selected</span>');
864
+ if (isBest && !isSel) badges.push('<span class="ll-card-badge rec">💡 Best in group</span>');
865
+ if (m.score < 50) badges.push('<span class="ll-card-badge over">⚠️ Review</span>');
866
+ html += `<div class="ll-card${isSel?' selected':''}${isBest&&!isSel?' recommended':''}" onclick="LL.select('${m.id}')">
867
+ <div class="ll-card-top">
868
+ <div class="ll-radio"></div>
869
+ <div class="ll-card-name">${m.name}</div>
870
+ <div class="ll-card-right">
871
+ <div class="ll-card-speed">${speedGlyph} ${LATENCY_SCORE[m.latency]}</div>
872
+ <div class="ll-card-cost">${fmtCost(m.cost)}</div>
873
+ </div>
874
+ </div>
875
+ <div class="ll-card-bar-row">
876
+ <div class="ll-card-bar-wrap"><div class="ll-card-bar" style="width:${barW}%"></div></div>
877
+ </div>
878
+ <div class="ll-card-insight">${m.clusterName} · ${m.provider} · ~${m.inTokens.toLocaleString()} in / ~${m.outTokens} out</div>
879
+ ${badges.length ? `<div class="ll-card-badges">${badges.join('')}</div>` : ''}
880
+ </div>`;
881
+ }
882
+ html += '</div>';
883
+ }
884
+
885
+ body.innerHTML = html;
886
+ }
887
+
888
+ function renderTip() {
889
+ const tip = state.result?.tip;
890
+ const el = $('ll_tip');
891
+ if (!tip) { el.style.display = 'none'; return; }
892
+ el.textContent = '💡 ' + tip;
893
+ el.style.display = '';
894
+ }
895
+
896
+ // ─── Public API ───────────────────────────────────────────────────────────
897
+ window.LL = {
898
+ open() {
899
+ $('ll_panel').style.display = 'flex';
900
+ $('ll_dock').style.display = 'none';
901
+ $('ll_prompt_ta').focus();
902
+ },
903
+ close() {
904
+ $('ll_panel').style.display = 'none';
905
+ $('ll_dock').style.display = 'flex';
906
+ },
907
+ minimize() {
908
+ $('ll_panel').style.display = 'none';
909
+ $('ll_dock').style.display = 'flex';
910
+ },
911
+ select(modelId) {
912
+ state.selectedModel = modelId;
913
+ localStorage.setItem(STORAGE_KEYS.selected, modelId);
914
+ const runBtn = $('ll_run_btn');
915
+ const sel = state.result?.all.find(m=>m.id===modelId);
916
+ if (sel) {
917
+ runBtn.textContent = `✦ Run on ${sel.name} →`;
918
+ runBtn.disabled = false;
919
+ }
920
+ renderBody();
921
+ },
922
+ run() {
923
+ if (!state.selectedModel) return;
924
+ if (!state.apiKey && !useFree()) {
925
+ alert('Daily free estimates exhausted. Connect your Signal Loom API key for unlimited use.');
926
+ return;
927
+ }
928
+ // Dispatch — post to parent or emit event
929
+ const event = new CustomEvent('loomlens:run', {
930
+ detail: { modelId: state.selectedModel, prompt: state.prompt }
931
+ });
932
+ window.dispatchEvent(event);
933
+ // Also try postMessage for cross-origin
934
+ if (window.parent !== window) {
935
+ window.parent.postMessage({ type: 'loomlens_run', modelId: state.selectedModel, prompt: state.prompt }, '*');
936
+ }
937
+ this.close();
938
+ },
939
+ connectKey() {
940
+ const key = $('ll_key_input').value.trim();
941
+ if (!key) return;
942
+ state.apiKey = key;
943
+ localStorage.setItem(STORAGE_KEYS.apiKey, key);
944
+ $('ll_key_input').value = key;
945
+ render();
946
+ },
947
+ disconnectKey() {
948
+ state.apiKey = '';
949
+ localStorage.removeItem(STORAGE_KEYS.apiKey);
950
+ $('ll_key_input').value = '';
951
+ render();
952
+ },
953
+ toggleKeyRow() {
954
+ if (state.apiKey) {
955
+ this.disconnectKey();
956
+ return;
957
+ }
958
+ const row = $('ll_key_row');
959
+ const btn = $('ll_show_key');
960
+ if (row.style.display === 'none') {
961
+ row.style.display = 'flex';
962
+ btn.textContent = 'Cancel';
963
+ $('ll_key_input').focus();
964
+ } else {
965
+ row.style.display = 'none';
966
+ btn.textContent = '+ Connect API key for unlimited';
967
+ }
968
+ },
969
+ estimate() {
970
+ const prompt = $('ll_prompt_ta').value;
971
+ if (!prompt || prompt.trim().length < 10) {
972
+ state.result = null;
973
+ render();
974
+ return;
975
+ }
976
+ state.prompt = prompt;
977
+ // ── Local engine preview (always free) ────────────────��─────────
978
+ // API billing happens only on "Run Estimate" click.
979
+ state.result = estimate(prompt, state.selectedModel);
980
+ if (!state.selectedModel && state.result?.recommended) {
981
+ state.selectedModel = state.result.recommended.id;
982
+ }
983
+ render();
984
+ const btn = $('ll_run_btn');
985
+ const sel = state.result?.allModels?.find(m=>m.id===state.selectedModel);
986
+ btn.textContent = sel ? `✦ Run on ${sel.name} →` : 'Select a model above →';
987
+ btn.disabled = !sel;
988
+ },
989
+
990
+ /**
991
+ * _estimateAPI — call POST /v1/estimate (billed per-call).
992
+ * Sets billing: true, decrements quota server-side.
993
+ * Falls back to local engine on network error.
994
+ */
995
+ async runEstimate() {
996
+ if (!state.apiKey) {
997
+ const left = freeLeft();
998
+ if (left === 0) {
999
+ alert('Your 3 free runs are used for today.\n\nConnect your Signal Loom API key to continue — signallloomai.com →');
1000
+ } else {
1001
+ alert('Connect your Signal Loom API key at signallloomai.com to run estimates.');
1002
+ }
1003
+ return;
1004
+ }
1005
+ // Count this as a free use (we'll call /estimate/free on their behalf via their key)
1006
+ useFree(); // record usage against their account
1007
+ const prompt = state.prompt || $('ll_prompt_ta').value;
1008
+ if (!prompt) return;
1009
+ const modelId = state.selectedModel || 'minimax/MiniMax-M2';
1010
+ const btn = $('ll_run_btn');
1011
+ btn.disabled = true;
1012
+ btn.textContent = '⚡ Running…';
1013
+ try {
1014
+ const body = {
1015
+ job_description: prompt,
1016
+ num_agents: 1,
1017
+ estimated_runtime_minutes: 1,
1018
+ complexity: 'standard',
1019
+ target_models: [modelId],
1020
+ include_postmortem: false,
1021
+ };
1022
+ const res = await fetch('https://api.signallloomai.com/v1/estimate', {
1023
+ method: 'POST',
1024
+ headers: {
1025
+ 'Authorization': 'Bearer ' + state.apiKey,
1026
+ 'Content-Type': 'application/json',
1027
+ },
1028
+ body: JSON.stringify(body),
1029
+ });
1030
+
1031
+ if (res.status === 429) {
1032
+ // Quota exhausted
1033
+ const err = await res.json().catch(()=>({}));
1034
+ state.result = {
1035
+ error: 'quota_exhausted',
1036
+ message: err.detail || 'Monthly estimate quota exhausted. Upgrade at signallloomai.com →',
1037
+ };
1038
+ render();
1039
+ return;
1040
+ }
1041
+ if (res.status === 401) {
1042
+ state.result = {
1043
+ error: 'invalid_key',
1044
+ message: 'Invalid API key. Check your key at signallloomai.com →',
1045
+ };
1046
+ render();
1047
+ return;
1048
+ }
1049
+ if (!res.ok) {
1050
+ throw new Error('API error: ' + res.status);
1051
+ }
1052
+
1053
+ // Extract quota from response headers
1054
+ const quota = {
1055
+ monthly_limit: parseInt(res.headers.get('X-Estimate-Limit') || '0'),
1056
+ used_this_month: parseInt(res.headers.get('X-Estimate-Used') || '0'),
1057
+ remaining_this_month: parseInt(res.headers.get('X-Estimate-Remaining') || '0'),
1058
+ resets_at: res.headers.get('X-Estimate-Resets') || '',
1059
+ };
1060
+
1061
+ const data = await res.json();
1062
+ // Attach billing + quota to result for display
1063
+ data._billing = data.billing || quota;
1064
+ data._quota = quota;
1065
+ state.result = _mapAPIResponse(data, modelId, prompt);
1066
+ state.result._quota = quota;
1067
+ state.result._billing = data.billing || null;
1068
+ if (!state.selectedModel && state.result?.recommended) {
1069
+ state.selectedModel = state.result.recommended.id;
1070
+ }
1071
+
1072
+ // ── Dispatch to OpenClaw ─────────────────────────────────────
1073
+ const event = new CustomEvent('loomlens:run', {
1074
+ detail: { modelId, prompt, cost: data.models?.[0]?.estimated_cost_mid }
1075
+ });
1076
+ window.dispatchEvent(event);
1077
+ if (window.parent !== window) {
1078
+ window.parent.postMessage({ type: 'loomlens_run', modelId, prompt }, '*');
1079
+ }
1080
+
1081
+ btn.textContent = '✓ Dispatched';
1082
+ setTimeout(() => { btn.disabled = false; btn.textContent = `✦ Run on ${state.selectedModel?.split('/').pop()} →`; }, 1200);
1083
+ setTimeout(() => this.close(), 1500);
1084
+ } catch (err) {
1085
+ console.warn('[LoomLens] runEstimate failed:', err.message);
1086
+ const btn = $('ll_run_btn');
1087
+ btn.textContent = '⚠ Error — retry';
1088
+ btn.disabled = false;
1089
+ }
1090
+ },
1091
+ getState() { return {...state}; },
1092
+ };
1093
+
1094
+ // ─── Event Wiring ─────────────────────────────────────────────────────────
1095
+ // Drag & drop
1096
+ const wrap = $('ll_prompt_wrap');
1097
+ wrap.addEventListener('dragover', e => { e.preventDefault(); wrap.classList.add('drag-over'); });
1098
+ wrap.addEventListener('dragleave', () => wrap.classList.remove('drag-over'));
1099
+ wrap.addEventListener('drop', e => {
1100
+ e.preventDefault();
1101
+ wrap.classList.remove('drag-over');
1102
+ const text = e.dataTransfer?.getData('text/plain') || '';
1103
+ if (text) { $('ll_prompt_ta').value = text; $('ll_prompt_ta').dispatchEvent(new Event('input')); }
1104
+ });
1105
+
1106
+ // Textarea input
1107
+ let debounce;
1108
+ $('ll_prompt_ta').addEventListener('input', () => {
1109
+ wrap.classList.toggle('focused', $('ll_prompt_ta').value.length > 0);
1110
+ $('ll_drop_hint').classList.toggle('hidden', $('ll_prompt_ta').value.length > 0);
1111
+ clearTimeout(debounce);
1112
+ debounce = setTimeout(() => window.LL.estimate(), 300);
1113
+ });
1114
+ $('ll_prompt_ta').addEventListener('focus', () => wrap.classList.add('focused'));
1115
+ $('ll_prompt_ta').addEventListener('blur', () => wrap.classList.remove('focused'));
1116
+
1117
+ // Filters
1118
+ $('ll_cluster_sel').addEventListener('change', renderBody);
1119
+ $('ll_sort_sel').addEventListener('change', renderBody);
1120
+
1121
+ // Header controls
1122
+ $('ll_btn_close').onclick = () => window.LL.close();
1123
+ $('ll_btn_min').onclick = () => window.LL.minimize();
1124
+
1125
+ // Keyboard shortcut
1126
+ document.addEventListener('keydown', e => {
1127
+ if ((e.metaKey || e.ctrlKey) && e.key === 'l') { e.preventDefault(); window.LL.open(); }
1128
+ if (e.key === 'Escape') window.LL.close();
1129
+ });
1130
+
1131
+ // postMessage: receive prompt from parent (OpenClaw webchat)
1132
+ window.addEventListener('message', e => {
1133
+ if (e.data?.type === 'openclaw_prompt' && e.data?.text) {
1134
+ $('ll_prompt_ta').value = e.data.text;
1135
+ $('ll_drop_hint').classList.add('hidden');
1136
+ window.LL.estimate();
1137
+ }
1138
+ });
1139
+
1140
+ // ─── Init ──────────────────────────────────────────────────────────────────
1141
+ checkFreeReset();
1142
+ if (state.apiKey) $('ll_key_input').value = state.apiKey;
1143
+ if (state.selectedModel) {
1144
+ const runBtn = $('ll_run_btn');
1145
+ runBtn.textContent = `✦ Run on ${state.selectedModel.split('/').pop()} →`;
1146
+ runBtn.disabled = false;
1147
+ }
1148
+ render();
1149
+
1150
+ // Open on load if coming back with a result
1151
+ if (state.result) window.LL.open();
1152
+
1153
+ })();
1154
+ </script>
1155
+ </body>
1156
+ </html>
loomlensai-pro.html CHANGED
@@ -105,36 +105,26 @@
105
  </div>
106
  <div class="tier-desc">$99/mo value. Transcription platform + LoomLensAI Power Pro + All future tools. Monthly: locks at $59 for 6 mo, then $99/mo. Annual: $449/yr (locks 12 mo, then $699/yr.</div>
107
  </div>
108
-
109
- <!-- Scale -->
110
- <div class="tier" id="tier-scale" onclick="selectTier('scale')">
111
- <div class="tier-header">
112
- <span class="tier-name">Scale — LoomLensAI Only</span>
113
- <span class="tier-price">$20<span>/mo</span></span>
114
- </div>
115
- <div class="tier-desc">1,000 estimates/month · Overage at $0.005/job</div>
116
- </div>
117
-
118
  <!-- Pro -->
119
- <div class="tier" id="tier-pro" onclick="selectTier('pro')">
120
  <div class="tier-header">
121
- <span class="tier-name">Pro — LoomLensAI Only</span>
122
- <span class="tier-price">$5<span>/mo</span></span>
123
  </div>
124
- <div class="tier-desc">250 estimates/month · Overage at $0.005/job</div>
125
  </div>
126
 
127
  <!-- Per-job -->
128
  <div class="tier" id="tier-per_job" onclick="selectTier('per_job')">
129
  <div class="tier-header">
130
  <span class="tier-name">Per-Job — For Agents & Pipelines</span>
131
- <span class="tier-price">$0.005<span>/job</span></span>
132
  </div>
133
  <div class="tier-desc">No monthly commitment · Pay only for what you use · Ideal for automated pipelines</div>
134
  </div>
135
 
136
  <ul class="features">
137
- <li>Unlimited estimates on Pro/Scale</li>
138
  <li>All major AI models included</li>
139
  <li>API access for agents and pipelines</li>
140
  <li>Cancel anytime</li>
@@ -146,7 +136,7 @@
146
  </div>
147
 
148
  <button class="btn-checkout" id="btn_checkout" onclick="startCheckout()">
149
- <span id="btn_label">Get Pro — $5/mo</span>
150
  </button>
151
  <div class="error" id="error_msg"></div>
152
 
@@ -154,6 +144,7 @@
154
  <strong>Per-Job API:</strong> After checkout, we'll set up your API key and send credentials within 2 hours. Contact <a href="mailto:hello@signalloomai.com">hello@signalloomai.com</a> for immediate setup.
155
  </div>
156
 
 
157
  <p class="note">
158
  Secured by Stripe · Cancel anytime · <a href="https://signalloomai.com" target="_blank">A SignalloomAI™ Product</a>
159
  </p>
@@ -168,15 +159,15 @@
168
 
169
  const tierConfig = {
170
  signalloomai_early: { label: 'Claim Early Adopter Price — $59/mo', scale: 'monthly' },
171
- scale: { label: 'Get Scale — $20/mo', scale: 'monthly' },
172
- pro: { label: 'Get Pro — $5/mo', scale: 'monthly' },
173
- per_job: { label: 'Start Per-Job', scale: 'per_job' },
174
  };
175
 
176
  function selectTier(tier) {
177
  selectedTier = tier;
178
  document.querySelectorAll('.tier').forEach(el => el.classList.remove('selected'));
179
- document.getElementById('tier-' + tier).classList.add('selected');
 
180
  document.getElementById('btn_label').textContent = tierConfig[tier].label;
181
  document.getElementById('api_note').style.display = tier === 'per_job' ? 'block' : 'none';
182
  }
@@ -190,11 +181,15 @@
190
  const successUrl = 'https://huggingface.co/spaces/Signalloom-ai/LoomLensAI?checkout=success';
191
  const cancelUrl = 'https://huggingface.co/spaces/Signalloom-ai/LoomLensAI?checkout=cancelled';
192
 
193
- // Per-job doesn't use Stripe checkout — just contact us
194
  if (selectedTier === 'per_job') {
195
  window.location.href = `mailto:hello@signalloomai.com?subject=Per-Job+API+Setup&body=Hi, I'd like to set up per-job API access.%0A%0AEmail: ${encodeURIComponent(email || 'your@email.com')}%0A%0AThanks!`;
196
  return;
197
  }
 
 
 
 
198
 
199
  const formData = new FormData();
200
  formData.append('tier', selectedTier);
@@ -206,7 +201,7 @@
206
  btn.classList.add('loading');
207
 
208
  try {
209
- const res = await fetch('https://api.signalloomai.com/v1/promptcost/checkout', {
210
  method: 'POST',
211
  body: formData,
212
  });
@@ -214,7 +209,7 @@
214
  if (!res.ok || data.error) throw new Error(data.detail || data.error || 'Checkout unavailable');
215
  window.location.href = data.checkout_url;
216
  } catch (e) {
217
- err.textContent = 'Checkout not ready yet: ' + e.message + ' — contact hello@signalloomai.com';
218
  err.style.display = 'block';
219
  btn.disabled = false;
220
  btn.classList.remove('loading');
 
105
  </div>
106
  <div class="tier-desc">$99/mo value. Transcription platform + LoomLensAI Power Pro + All future tools. Monthly: locks at $59 for 6 mo, then $99/mo. Annual: $449/yr (locks 12 mo, then $699/yr.</div>
107
  </div>
 
 
 
 
 
 
 
 
 
 
108
  <!-- Pro -->
109
+ <div class="tier selected" id="tier-pro" onclick="selectTier('pro')">
110
  <div class="tier-header">
111
+ <span class="tier-name">Pro — LoomLensAI</span>
112
+ <span class="tier-price">$15<span>/mo</span></span>
113
  </div>
114
+ <div class="tier-desc">1,000 estimates/month · Introductory price · Overage at $0.033/job</div>
115
  </div>
116
 
117
  <!-- Per-job -->
118
  <div class="tier" id="tier-per_job" onclick="selectTier('per_job')">
119
  <div class="tier-header">
120
  <span class="tier-name">Per-Job — For Agents & Pipelines</span>
121
+ <span class="tier-price">$0.033<span>/job</span></span>
122
  </div>
123
  <div class="tier-desc">No monthly commitment · Pay only for what you use · Ideal for automated pipelines</div>
124
  </div>
125
 
126
  <ul class="features">
127
+
128
  <li>All major AI models included</li>
129
  <li>API access for agents and pipelines</li>
130
  <li>Cancel anytime</li>
 
136
  </div>
137
 
138
  <button class="btn-checkout" id="btn_checkout" onclick="startCheckout()">
139
+ <span id="btn_label">Get Pro — $15/mo</span>
140
  </button>
141
  <div class="error" id="error_msg"></div>
142
 
 
144
  <strong>Per-Job API:</strong> After checkout, we'll set up your API key and send credentials within 2 hours. Contact <a href="mailto:hello@signalloomai.com">hello@signalloomai.com</a> for immediate setup.
145
  </div>
146
 
147
+ <p class="note" style="color:var(--yellow);margin-top:12px;">★ Basic and Pro subscriptions are not eligible for the Builder API Revenue Share Program.</p>
148
  <p class="note">
149
  Secured by Stripe · Cancel anytime · <a href="https://signalloomai.com" target="_blank">A SignalloomAI™ Product</a>
150
  </p>
 
159
 
160
  const tierConfig = {
161
  signalloomai_early: { label: 'Claim Early Adopter Price — $59/mo', scale: 'monthly' },
162
+ pro: { label: 'Get Pro — $15/mo', scale: 'monthly' },
163
+ per_job: { label: 'Start Per-Job', scale: 'per_job' },
 
164
  };
165
 
166
  function selectTier(tier) {
167
  selectedTier = tier;
168
  document.querySelectorAll('.tier').forEach(el => el.classList.remove('selected'));
169
+ const el = document.getElementById('tier-' + tier);
170
+ if (el) el.classList.add('selected');
171
  document.getElementById('btn_label').textContent = tierConfig[tier].label;
172
  document.getElementById('api_note').style.display = tier === 'per_job' ? 'block' : 'none';
173
  }
 
181
  const successUrl = 'https://huggingface.co/spaces/Signalloom-ai/LoomLensAI?checkout=success';
182
  const cancelUrl = 'https://huggingface.co/spaces/Signalloom-ai/LoomLensAI?checkout=cancelled';
183
 
184
+ // Per-job and Early Adopter don't use Stripe checkout yet — contact us manually
185
  if (selectedTier === 'per_job') {
186
  window.location.href = `mailto:hello@signalloomai.com?subject=Per-Job+API+Setup&body=Hi, I'd like to set up per-job API access.%0A%0AEmail: ${encodeURIComponent(email || 'your@email.com')}%0A%0AThanks!`;
187
  return;
188
  }
189
+ if (selectedTier === 'signalloomai_early') {
190
+ window.location.href = `mailto:hello@signalloomai.com?subject=SignalloomAI+Pro+Early+Adopter&body=Hi, I'd like to claim the $59/mo Early Adopter price for SignalloomAI Pro.%0A%0AEmail: ${encodeURIComponent(email || 'your@email.com')}%0A%0AThanks!`;
191
+ return;
192
+ }
193
 
194
  const formData = new FormData();
195
  formData.append('tier', selectedTier);
 
201
  btn.classList.add('loading');
202
 
203
  try {
204
+ const res = await fetch('https://api.signalloomai.com/v1/stripe/checkout', {
205
  method: 'POST',
206
  body: formData,
207
  });
 
209
  if (!res.ok || data.error) throw new Error(data.detail || data.error || 'Checkout unavailable');
210
  window.location.href = data.checkout_url;
211
  } catch (e) {
212
+ err.textContent = 'Checkout unavailable right now — email hello@signalloomai.com and we'll get you set up manually.';
213
  err.style.display = 'block';
214
  btn.disabled = false;
215
  btn.classList.remove('loading');
package.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "loomlens-live",
3
+ "version": "1.0.0",
4
+ "description": "LoomLens Live — Pre-flight cost intelligence for LLM prompts",
5
+ "author": "Aster Vale / AEHS",
6
+ "license": "UNLICENSED",
7
+ "private": true,
8
+ "type": "module",
9
+ "scripts": {
10
+ "build": "node build.mjs",
11
+ "build:sidebar": "node build.mjs sidebar",
12
+ "typecheck": "tsc --noEmit"
13
+ },
14
+ "dependencies": {},
15
+ "devDependencies": {},
16
+ "files": [
17
+ "loomlens-clusters.js",
18
+ "loomlens-engine.js",
19
+ "loomlens-sidebar.html"
20
+ ]
21
+ }
x-thread-loomlens-live.md ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # X Thread — LoomLens Live for OpenClaw
2
+
3
+ ---
4
+
5
+ **Tweet 1 (Hook):**
6
+ Most AI agents have no idea what they're costing you.
7
+
8
+ They pick a model. Any model. Usually the one you defaulted to.
9
+
10
+ What if your agent could see the cost of every option *before* it runs — and route accordingly?
11
+
12
+ Introducing **LoomLens Live** for OpenClaw. 🧵
13
+
14
+ ---
15
+
16
+ **Tweet 2 (What it is):**
17
+ LoomLens Live is a real-time cost estimation panel that lives inside the OpenClaw agent sidebar.
18
+
19
+ Every time you run a prompt, you see:
20
+ → Cost across 6 model clusters
21
+ → The cheapest viable option in each cluster
22
+ → One-click dispatch to any model you choose
23
+
24
+ No switching tabs. No external dashboards. It's just *there*.
25
+
26
+ ---
27
+
28
+ **Tweet 3 (How it works):**
29
+ LoomLens estimates cost before you run — using token counts and published model pricing.
30
+
31
+ No, it doesn't log your prompts.
32
+ No, your data doesn't go anywhere.
33
+ Yes, it's actually fast.
34
+
35
+ Free tier: 3 estimates/day, no account needed.
36
+
37
+ ---
38
+
39
+ **Tweet 4 (The clusters):**
40
+ 6 model clusters, each optimized for a different workload:
41
+
42
+ ⚡ Flash Reasoning — MiniMax, Gemini Flash, Haiku, DeepSeek
43
+ 🚀 Fast General — GPT-4o Mini, Gemini 2.0 Flash, Grok-3
44
+ 💻 Balanced Coding — Claude Sonnet 4, GPT-4o
45
+ 🧠 Power — Claude Opus 4, Claude Code
46
+ 👁 Vision — GPT-4o, Claude 3.5
47
+ 🎯 Custom — define your own
48
+
49
+ ---
50
+
51
+ **Tweet 5 (The economics):**
52
+ We ran a support pipeline that cost $4.70/job on GPT-4o.
53
+
54
+ LoomLens found the same output quality on a lighter model: $0.19/job.
55
+
56
+ Same input. Same output. 96% less.
57
+
58
+ Multiply that by your monthly API volume and the number stops being abstract.
59
+
60
+ ---
61
+
62
+ **Tweet 6 (For builders):**
63
+ LoomLens Live installs as an OpenClaw skill:
64
+
65
+ ```
66
+ openclaw skills install loomlens-live
67
+ ```
68
+
69
+ Works in every session. Persists across sessions.
70
+
71
+ Or use the API directly:
72
+ ```
73
+ npm install signalloomai
74
+ pip install signalloom
75
+ ```
76
+
77
+ ---
78
+
79
+ **Tweet 7 (Rev-share):**
80
+ Running AI tooling for a team or platform?
81
+
82
+ Developers earn **23–28% revenue share** on billable estimate calls generated through their integrations.
83
+
84
+ 10K mins/month → Loom Partner (23%)
85
+ 100K mins/month → Loom Elite (28%)
86
+
87
+ Built something with the API? You're earning on it passively.
88
+
89
+ ---
90
+
91
+ **Tweet 8 (CTA):**
92
+ OpenClaw users: install it today. It's already running in our own production workflow.
93
+
94
+ Everyone else: the API is live. The dashboard is live.
95
+
96
+ **https://huggingface.co/spaces/Signalloom-ai/LoomLensAI**
97
+
98
+ *LoomLensAI — Every prompt. Right model. Right price. Every time.*
99
+
100
+ #AI #OpenAI #Claude #LangChain #DevTools #API #AIOps