clawculator 2.6.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,324 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Generate a shareable cost snapshot card.
8
+ * Square format — works on Twitter, Instagram, TikTok, Discord.
9
+ * Shows grade, cost range, setup complexity — no exact dollars or session names.
10
+ */
11
+ function generateSnapshotCard(analysis, outputDir) {
12
+ const s = analysis.summary;
13
+
14
+ // ── Compute grade ──────────────────────────────────────
15
+ const criticals = s.critical || 0;
16
+ const highs = s.high || 0;
17
+ const mediums = s.medium || 0;
18
+ const totalFindings = criticals + highs + mediums;
19
+
20
+ let grade, gradeColor, gradeGlow, gradeEmoji;
21
+ if (criticals === 0 && highs === 0 && mediums === 0) {
22
+ grade = 'A+'; gradeColor = '#22c55e'; gradeGlow = 'rgba(34,197,94,0.3)'; gradeEmoji = '🏆';
23
+ } else if (criticals === 0 && highs <= 1) {
24
+ grade = 'A'; gradeColor = '#22c55e'; gradeGlow = 'rgba(34,197,94,0.2)'; gradeEmoji = '✅';
25
+ } else if (criticals <= 1 && highs <= 2) {
26
+ grade = 'B'; gradeColor = '#f59e0b'; gradeGlow = 'rgba(245,158,11,0.2)'; gradeEmoji = '👍';
27
+ } else if (criticals <= 2) {
28
+ grade = 'C'; gradeColor = '#f97316'; gradeGlow = 'rgba(249,115,22,0.2)'; gradeEmoji = '⚠️';
29
+ } else {
30
+ grade = 'D'; gradeColor = '#ef4444'; gradeGlow = 'rgba(239,68,68,0.2)'; gradeEmoji = '🔥';
31
+ }
32
+
33
+ // ── Cost range (bucketed, not exact) ───────────────────
34
+ const todayCost = s.todayCost || 0;
35
+ let costRange, costEmoji;
36
+ if (todayCost === 0) { costRange = 'Free tier'; costEmoji = '🆓'; }
37
+ else if (todayCost < 0.50) { costRange = 'Under $0.50/day'; costEmoji = '💲'; }
38
+ else if (todayCost < 1) { costRange = 'Under $1/day'; costEmoji = '💲'; }
39
+ else if (todayCost < 5) { costRange = '$1–5/day'; costEmoji = '💵'; }
40
+ else if (todayCost < 10) { costRange = '$5–10/day'; costEmoji = '💰'; }
41
+ else if (todayCost < 25) { costRange = '$10–25/day'; costEmoji = '💰'; }
42
+ else if (todayCost < 50) { costRange = '$25–50/day'; costEmoji = '🔥'; }
43
+ else { costRange = '$50+/day'; costEmoji = '🚨'; }
44
+
45
+ // ── Setup complexity from config ───────────────────────
46
+ const config = analysis.config || {};
47
+
48
+ const channels = [];
49
+ if (config.channels?.whatsapp) channels.push('WhatsApp');
50
+ if (config.channels?.telegram) channels.push('Telegram');
51
+ if (config.channels?.discord) channels.push('Discord');
52
+ if (config.channels?.signal) channels.push('Signal');
53
+ if (config.channels?.slack) channels.push('Slack');
54
+ if (config.webchat || config.channels?.webchat) channels.push('Webchat');
55
+
56
+ const skillCount = config.skills ? Object.keys(config.skills).length : 0;
57
+ const cronCount = config.cron ? Object.keys(config.cron).length : 0;
58
+ const hookCount = config.hooks ? Object.keys(config.hooks).length : 0;
59
+ const agentCount = config.agents?.list ? Object.keys(config.agents.list).length : 1;
60
+ const sessionCount = s.sessionsAnalyzed || 0;
61
+
62
+ const modelSet = new Set();
63
+ for (const sess of (analysis.sessions || [])) {
64
+ if (sess.model || sess.modelLabel) modelSet.add(sess.modelLabel || sess.model);
65
+ }
66
+ const modelCount = modelSet.size || 1;
67
+
68
+ const totalTokens = s.totalTokensFound || 1;
69
+ const cacheEfficiency = Math.round((s.totalCacheRead || 0) / totalTokens * 100);
70
+
71
+ // ── Build stat pills ──────────────────────────────────
72
+ const pills = [];
73
+ if (channels.length > 0) pills.push({ icon: '📱', label: `${channels.length} channel${channels.length>1?'s':''}` });
74
+ if (skillCount > 0) pills.push({ icon: '🔧', label: `${skillCount} skill${skillCount>1?'s':''}` });
75
+ if (cronCount > 0) pills.push({ icon: '⏰', label: `${cronCount} cron${cronCount>1?'s':''}` });
76
+ if (hookCount > 0) pills.push({ icon: '🪝', label: `${hookCount} hook${hookCount>1?'s':''}` });
77
+ if (agentCount > 1) pills.push({ icon: '🤖', label: `${agentCount} agents` });
78
+ if (sessionCount > 0) pills.push({ icon: '💬', label: `${sessionCount} sessions` });
79
+ pills.push({ icon: '🧠', label: `${modelCount} model${modelCount>1?'s':''}` });
80
+ if (cacheEfficiency > 0) pills.push({ icon: '⚡', label: `${cacheEfficiency}% cache` });
81
+
82
+ // ── Findings summary ──────────────────────────────────
83
+ const findingSummary = [];
84
+ if (criticals > 0) findingSummary.push(`🔴 ${criticals} critical`);
85
+ if (highs > 0) findingSummary.push(`🟠 ${highs} high`);
86
+ if (mediums > 0) findingSummary.push(`🟡 ${mediums} medium`);
87
+
88
+ const html = `<!DOCTYPE html>
89
+ <html lang="en">
90
+ <head>
91
+ <meta charset="UTF-8">
92
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
93
+ <title>Clawculator Snapshot</title>
94
+ <style>
95
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&family=Outfit:wght@400;500;600;700;800;900&display=swap');
96
+
97
+ * { box-sizing: border-box; margin: 0; padding: 0; }
98
+
99
+ html, body {
100
+ min-height: 100vh;
101
+ font-family: 'Outfit', sans-serif;
102
+ background: #020408;
103
+ color: #e2e8f0;
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: center;
107
+ }
108
+
109
+ .card {
110
+ width: 480px; max-width: 96vw;
111
+ background: #05080f;
112
+ border-radius: 24px;
113
+ border: 1px solid #1a2744;
114
+ padding: 32px 28px 24px;
115
+ display: flex; flex-direction: column;
116
+ align-items: center;
117
+ position: relative;
118
+ overflow: hidden;
119
+ box-shadow: 0 0 80px rgba(34,211,238,0.04), 0 20px 60px rgba(0,0,0,0.5);
120
+ }
121
+
122
+ /* Grid bg */
123
+ .card::before {
124
+ content: '';
125
+ position: absolute; top: 0; left: 0; right: 0; bottom: 0;
126
+ background:
127
+ linear-gradient(rgba(34,211,238,0.02) 1px, transparent 1px),
128
+ linear-gradient(90deg, rgba(34,211,238,0.02) 1px, transparent 1px);
129
+ background-size: 32px 32px;
130
+ pointer-events: none;
131
+ }
132
+
133
+ .card > * { position: relative; z-index: 1; }
134
+
135
+ /* Header */
136
+ .header {
137
+ display: flex; align-items: center; gap: 10px;
138
+ margin-bottom: 20px;
139
+ }
140
+ .logo-claw { font-size: 28px; }
141
+ .logo-text {
142
+ font-family: 'JetBrains Mono', monospace;
143
+ font-weight: 800; font-size: 18px; letter-spacing: -0.5px;
144
+ background: linear-gradient(135deg, #22d3ee, #818cf8);
145
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
146
+ }
147
+
148
+ /* Grade */
149
+ .grade-container {
150
+ display: flex; flex-direction: column; align-items: center;
151
+ margin-bottom: 16px;
152
+ }
153
+ .grade-ring {
154
+ width: 120px; height: 120px;
155
+ border-radius: 50%;
156
+ border: 3px solid ${gradeColor}44;
157
+ display: flex; align-items: center; justify-content: center;
158
+ box-shadow: 0 0 40px ${gradeGlow}, inset 0 0 20px ${gradeGlow};
159
+ position: relative;
160
+ }
161
+ .grade-ring::before {
162
+ content: '';
163
+ position: absolute; inset: -2px;
164
+ border-radius: 50%;
165
+ border: 2px solid ${gradeColor};
166
+ opacity: 0.5;
167
+ }
168
+ .grade-letter {
169
+ font-family: 'JetBrains Mono', monospace;
170
+ font-weight: 900; font-size: 52px;
171
+ color: ${gradeColor};
172
+ text-shadow: 0 0 20px ${gradeGlow};
173
+ line-height: 1;
174
+ }
175
+ .grade-label {
176
+ font-size: 11px; font-weight: 600; color: #64748b;
177
+ text-transform: uppercase; letter-spacing: 1.5px;
178
+ margin-top: 8px;
179
+ }
180
+
181
+ /* Cost */
182
+ .cost-range {
183
+ font-family: 'JetBrains Mono', monospace;
184
+ font-weight: 700; font-size: 22px;
185
+ color: #e2e8f0;
186
+ margin-bottom: 14px;
187
+ text-align: center;
188
+ }
189
+
190
+ /* Divider */
191
+ .divider {
192
+ width: 60px; height: 1px;
193
+ background: linear-gradient(90deg, transparent, #1e3a5f, transparent);
194
+ margin-bottom: 14px;
195
+ }
196
+
197
+ /* Pills */
198
+ .pills {
199
+ display: flex; flex-wrap: wrap; justify-content: center;
200
+ gap: 6px;
201
+ margin-bottom: 14px;
202
+ max-width: 400px;
203
+ }
204
+ .pill {
205
+ display: flex; align-items: center; gap: 4px;
206
+ background: #0b1120;
207
+ border: 1px solid #1a2744;
208
+ border-radius: 14px;
209
+ padding: 5px 12px;
210
+ font-size: 12px; font-weight: 500;
211
+ color: #94a3b8;
212
+ }
213
+
214
+ /* Findings */
215
+ .findings {
216
+ text-align: center;
217
+ margin-bottom: 14px;
218
+ }
219
+ .findings-badges {
220
+ display: flex; justify-content: center; gap: 8px;
221
+ flex-wrap: wrap;
222
+ }
223
+ .findings-badge {
224
+ font-family: 'JetBrains Mono', monospace;
225
+ font-size: 12px; font-weight: 600;
226
+ padding: 4px 10px; border-radius: 6px;
227
+ }
228
+ .findings-clean {
229
+ font-size: 14px; color: #22c55e; font-weight: 600;
230
+ }
231
+
232
+ /* CTA */
233
+ .cta {
234
+ margin-top: auto;
235
+ text-align: center;
236
+ width: 100%;
237
+ }
238
+ .cta-divider {
239
+ width: 100%; height: 1px;
240
+ background: linear-gradient(90deg, transparent, #1a2744, transparent);
241
+ margin-bottom: 14px;
242
+ }
243
+ .cta-prompt {
244
+ font-size: 13px; color: #64748b;
245
+ margin-bottom: 8px;
246
+ }
247
+ .cta-command {
248
+ font-family: 'JetBrains Mono', monospace;
249
+ font-size: 14px; font-weight: 700;
250
+ color: #22d3ee;
251
+ background: #0b1120;
252
+ border: 1px solid #1a2744;
253
+ border-radius: 8px;
254
+ padding: 10px 18px;
255
+ display: inline-block;
256
+ margin-bottom: 6px;
257
+ }
258
+ .cta-sub {
259
+ font-size: 10px; color: #334155;
260
+ }
261
+ </style>
262
+ </head>
263
+ <body>
264
+ <div class="card">
265
+
266
+ <div class="header">
267
+ <div class="logo-claw">🦞</div>
268
+ <div class="logo-text">CLAWCULATOR</div>
269
+ </div>
270
+
271
+ <div class="grade-container">
272
+ <div class="grade-ring">
273
+ <div class="grade-letter">${grade}</div>
274
+ </div>
275
+ <div class="grade-label">${gradeEmoji} cost health</div>
276
+ </div>
277
+
278
+ <div class="cost-range">${costEmoji} ${costRange}</div>
279
+
280
+ <div class="divider"></div>
281
+
282
+ <div class="pills">
283
+ ${pills.map(p => `<div class="pill"><span>${p.icon}</span>${p.label}</div>`).join('\n ')}
284
+ </div>
285
+
286
+ <div class="findings">
287
+ ${findingSummary.length > 0 ?
288
+ `<div class="findings-badges">${findingSummary.map(f => {
289
+ const c = f.startsWith('🔴') ? '#ef4444' : f.startsWith('🟠') ? '#f97316' : '#eab308';
290
+ return `<div class="findings-badge" style="background:${c}18;color:${c}">${f}</div>`;
291
+ }).join('')}</div>` :
292
+ `<div class="findings-clean">✅ No issues found</div>`
293
+ }
294
+ </div>
295
+
296
+ <div class="cta">
297
+ <div class="cta-divider"></div>
298
+ <div class="cta-prompt">Get your OpenClaw cost grade</div>
299
+ <div class="cta-command">npx clawculator --snapshot</div>
300
+ <div class="cta-sub">100% offline · your data never leaves your machine</div>
301
+ </div>
302
+
303
+ </div>
304
+ </body>
305
+ </html>`;
306
+
307
+ const htmlPath = path.join(outputDir, 'clawculator-snapshot.html');
308
+ fs.writeFileSync(htmlPath, html, 'utf8');
309
+
310
+ // Terminal summary
311
+ console.log(`\n ${gradeEmoji} Grade: \x1b[1m${grade}\x1b[0m`);
312
+ console.log(` ${costEmoji} Cost: ${costRange}`);
313
+ console.log(` 📦 Setup: ${pills.map(p => p.label).join(' · ')}`);
314
+ if (findingSummary.length > 0) {
315
+ console.log(` 🔍 ${findingSummary.join(' · ')}`);
316
+ } else {
317
+ console.log(` ✅ Clean — no issues`);
318
+ }
319
+ console.log('');
320
+
321
+ return { htmlPath, grade, costRange };
322
+ }
323
+
324
+ module.exports = { generateSnapshotCard };
@@ -18,7 +18,13 @@ function initDB(dbPath) {
18
18
  try {
19
19
  Database = require('better-sqlite3');
20
20
  } catch {
21
- console.error('\x1b[31mError:\x1b[0m better-sqlite3 not installed. Run: npm install better-sqlite3');
21
+ console.error('\n\x1b[31m ✗ better-sqlite3 is required for --web\x1b[0m\n');
22
+ console.error(' Install it with:\n');
23
+ console.error(' \x1b[36mnpm install -g better-sqlite3\x1b[0m');
24
+ console.error(' \x1b[90m# or, if installed locally:\x1b[0m');
25
+ console.error(' \x1b[36mcd $(npm root -g)/clawculator && npm install better-sqlite3\x1b[0m\n');
26
+ console.error(' \x1b[90mThis is a native module that compiles on install.\x1b[0m');
27
+ console.error(' \x1b[90mRequires: Node.js 18+, Python 3, and a C++ compiler (Xcode CLI tools on macOS).\x1b[0m\n');
22
28
  process.exit(1);
23
29
  }
24
30
 
package/src/analyzer.js CHANGED
@@ -5,6 +5,10 @@ const path = require('path');
5
5
  const os = require('os');
6
6
 
7
7
  // ── Model pricing (per million tokens, input/output) ─────────────
8
+ // Last updated: 2026-02-28 — Update when Anthropic/OpenAI/Google change pricing
9
+ const PRICING_UPDATED = '2026-02-28';
10
+ const PRICING_STALE_DAYS = 60;
11
+
8
12
  const MODEL_PRICING = {
9
13
  'claude-opus-4-6': { input: 5.00, output: 25.00, label: 'Claude Opus 4.6' },
10
14
  'claude-opus-4-5': { input: 5.00, output: 25.00, label: 'Claude Opus 4.5' },
@@ -109,6 +113,10 @@ const FIXES = {
109
113
  fix: 'Lower imageMaxDimensionPx to reduce vision token costs — default 1200px is expensive',
110
114
  command: 'openclaw config set agents.defaults.imageMaxDimensionPx 800',
111
115
  },
116
+ PRICING_STALE: {
117
+ fix: 'Update clawculator to get the latest model pricing data',
118
+ command: 'npm update -g clawculator',
119
+ },
112
120
  MULTI_AGENT_PAID: (agentId) => ({
113
121
  fix: `Agent "${agentId}" has its own expensive model config — each agent bills independently`,
114
122
  command: `Review agents.list[${agentId}].model config and apply same cost rules as primary agent`,
@@ -503,6 +511,10 @@ function analyzeConfig(configPath) {
503
511
 
504
512
  /**
505
513
  * Parse a .jsonl session transcript file and sum up real usage/cost data.
514
+ * Version-aware: handles multiple OpenClaw schema formats:
515
+ * - v2026.2.x+: entry.message.usage (standard)
516
+ * - v2026.1.x: entry.usage (legacy)
517
+ * - Anthropic raw: usage.cache_creation_input_tokens / usage.cache_read_input_tokens
506
518
  * Returns { input, output, cacheRead, cacheWrite, totalTokens, totalCost, messageCount, model, firstTs, lastTs }
507
519
  */
508
520
  function parseTranscript(jsonlPath) {
@@ -512,6 +524,7 @@ function parseTranscript(jsonlPath) {
512
524
 
513
525
  let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, totalTokens = 0, totalCost = 0;
514
526
  let messageCount = 0, model = null, firstTs = null, lastTs = null;
527
+ let schemaDetected = null; // track which schema we're seeing
515
528
 
516
529
  for (const line of content.split('\n')) {
517
530
  if (!line.trim()) continue;
@@ -527,23 +540,29 @@ function parseTranscript(jsonlPath) {
527
540
  }
528
541
 
529
542
  // Only assistant messages with usage blocks have cost data
530
- if (entry.type !== 'message') continue;
543
+ // Some schemas use entry.type === 'message', others use entry.role === 'assistant'
544
+ if (entry.type !== 'message' && entry.role !== 'assistant') continue;
531
545
 
532
- // Usage can be at entry.usage (some formats) or entry.message.usage (standard format)
533
- const u = entry.usage || entry.message?.usage;
546
+ // Usage can be in multiple locations depending on OpenClaw version
547
+ const u = entry.usage || entry.message?.usage || entry.response?.usage;
534
548
  if (!u) continue;
535
549
 
550
+ if (!schemaDetected) {
551
+ schemaDetected = entry.usage ? 'legacy' : entry.message?.usage ? 'standard' : 'response';
552
+ }
553
+
536
554
  messageCount++;
537
555
 
538
- // Model can be at entry.model or entry.message.model
539
- const entryModel = entry.model || entry.message?.model;
556
+ // Model can be at multiple locations
557
+ const entryModel = entry.model || entry.message?.model || entry.response?.model;
540
558
  if (entryModel && !model) model = entryModel;
541
559
 
542
- input += u.input || 0;
543
- output += u.output || 0;
544
- cacheRead += u.cacheRead || 0;
545
- cacheWrite += u.cacheWrite || 0;
546
- totalTokens += u.totalTokens || 0;
560
+ // Token fields: handle both camelCase (OpenClaw) and snake_case (raw Anthropic API)
561
+ input += u.input || u.input_tokens || 0;
562
+ output += u.output || u.output_tokens || 0;
563
+ cacheRead += u.cacheRead || u.cache_read_input_tokens || 0;
564
+ cacheWrite += u.cacheWrite || u.cache_creation_input_tokens || 0;
565
+ totalTokens += u.totalTokens || ((u.input || u.input_tokens || 0) + (u.output || u.output_tokens || 0)) || 0;
547
566
 
548
567
  // Prefer API-reported cost (already accounts for cache pricing)
549
568
  if (u.cost) {
@@ -557,7 +576,7 @@ function parseTranscript(jsonlPath) {
557
576
 
558
577
  if (messageCount === 0) return null;
559
578
 
560
- return { input, output, cacheRead, cacheWrite, totalTokens, totalCost, messageCount, model, firstTs, lastTs };
579
+ return { input, output, cacheRead, cacheWrite, totalTokens, totalCost, messageCount, model, firstTs, lastTs, schemaDetected };
561
580
  } catch {
562
581
  return null;
563
582
  }
@@ -916,6 +935,19 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
916
935
 
917
936
  const realCost = sessionResult.totalRealCost || 0;
918
937
 
938
+ // Check pricing staleness — only affects cost estimates for config findings
939
+ // (actual transcript costs use API-reported cost.total, not the pricing table)
940
+ const pricingAge = Math.floor((Date.now() - new Date(PRICING_UPDATED).getTime()) / 86400000);
941
+ if (pricingAge > PRICING_STALE_DAYS) {
942
+ allFindings.push({
943
+ severity: 'low',
944
+ source: 'pricing',
945
+ title: `Model pricing table is ${pricingAge} days old`,
946
+ detail: `Pricing was last updated ${PRICING_UPDATED}. Actual costs from transcripts are unaffected (they use API-reported totals). Config-based cost estimates (heartbeat bleed, cron projections) may be slightly off. Update: npm update -g clawculator`,
947
+ ...FIXES.PRICING_STALE,
948
+ });
949
+ }
950
+
919
951
  return {
920
952
  scannedAt: new Date().toISOString(),
921
953
  configPath,