clawculator 2.1.3 โ†’ 2.1.4

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,452 @@
1
+ diff --git a/skills/clawculator/htmlReport.js b/skills/clawculator/htmlReport.js
2
+ index 6b944d1..05f9616 100644
3
+ --- a/skills/clawculator/htmlReport.js
4
+ +++ b/skills/clawculator/htmlReport.js
5
+ @@ -5,15 +5,15 @@ const path = require('path');
6
+ const os = require('os');
7
+
8
+ function severityColor(severity) {
9
+ - return { critical: '#ef4444', high: '#f97316', medium: '#eab308', info: '#22c55e' }[severity] || '#6b7280';
10
+ + return { critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#06b6d4', info: '#22c55e' }[severity] || '#6b7280';
11
+ }
12
+
13
+ function severityBg(severity) {
14
+ - return { critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', info: '#f0fdf4' }[severity] || '#f9fafb';
15
+ + return { critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', low: '#ecfeff', info: '#f0fdf4' }[severity] || '#f9fafb';
16
+ }
17
+
18
+ function severityIcon(severity) {
19
+ - return { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', info: 'โœ…' }[severity] || 'โšช';
20
+ + return { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', low: '๐Ÿ”ต', info: 'โœ…' }[severity] || 'โšช';
21
+ }
22
+
23
+ function relativeAge(ageMs) {
24
+ @@ -40,6 +40,25 @@ async function generateHTMLReport(analysis, outPath) {
25
+ const { summary, findings, sessions } = analysis;
26
+ const bleed = summary.estimatedMonthlyBleed;
27
+
28
+ + // Burn summary calculation
29
+ + const activeSessions = (sessions || []).filter(s => s.dailyCost);
30
+ + const totalDaily = activeSessions.reduce((sum, s) => sum + (s.dailyCost || 0), 0);
31
+ + const totalMonthly = totalDaily * 30;
32
+ + const burnSummary = totalDaily > 0 ? `
33
+ + <div class="section">
34
+ + <div class="section-title">Session Burn Rate</div>
35
+ + <div style="display:flex; gap:32px; flex-wrap:wrap;">
36
+ + <div>
37
+ + <div style="font-size:24px; font-weight:800; color:#f59e0b;">$${totalDaily.toFixed(4)}/day</div>
38
+ + <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Combined daily burn rate across ${activeSessions.length} active session${activeSessions.length !== 1 ? 's' : ''}</div>
39
+ + </div>
40
+ + <div>
41
+ + <div style="font-size:24px; font-weight:800; color:#f97316;">$${totalMonthly.toFixed(2)}/month</div>
42
+ + <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Projected monthly from active sessions</div>
43
+ + </div>
44
+ + </div>
45
+ + </div>` : '';
46
+ +
47
+ const findingCards = findings.map(f => `
48
+ <div class="finding" style="border-left: 4px solid ${severityColor(f.severity)}; background: ${severityBg(f.severity)}; padding: 16px; margin-bottom: 12px; border-radius: 0 8px 8px 0;">
49
+ <div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
50
+ @@ -87,7 +106,7 @@ async function generateHTMLReport(analysis, outPath) {
51
+ .logo { font-size: 42px; font-weight: 900; letter-spacing: -2px; background: linear-gradient(90deg, #38bdf8, #818cf8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
52
+ .tagline { color: #94a3b8; margin-top: 8px; font-size: 16px; }
53
+ .container { max-width: 1000px; margin: 0 auto; padding: 32px 24px; }
54
+ - .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 32px; }
55
+ + .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; margin-bottom: 32px; }
56
+ .card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; }
57
+ .card-value { font-size: 32px; font-weight: 800; }
58
+ .card-label { font-size: 13px; color: #94a3b8; margin-top: 4px; }
59
+ @@ -132,6 +151,10 @@ async function generateHTMLReport(analysis, outPath) {
60
+ <div class="card-value" style="color:#eab308">${summary.medium}</div>
61
+ <div class="card-label">๐ŸŸก Medium</div>
62
+ </div>
63
+ + <div class="card">
64
+ + <div class="card-value" style="color:#06b6d4">${summary.low || 0}</div>
65
+ + <div class="card-label">๐Ÿ”ต Low</div>
66
+ + </div>
67
+ <div class="card">
68
+ <div class="card-value" style="color:#22c55e">${summary.info}</div>
69
+ <div class="card-label">โœ… OK</div>
70
+ @@ -162,7 +185,8 @@ async function generateHTMLReport(analysis, outPath) {
71
+ <tbody>${sessionRows}</tbody>
72
+ </table>
73
+ </div>
74
+ - </div>` : ''}
75
+ + </div>
76
+ + ${burnSummary}` : ''}
77
+
78
+ </div>
79
+ <div class="footer">
80
+ @@ -172,7 +196,7 @@ async function generateHTMLReport(analysis, outPath) {
81
+ </html>`;
82
+
83
+ if (!outPath) {
84
+ - outPath = path.join(os.tmpdir(), `clawculator-report-${Date.now()}.html`);
85
+ + outPath = path.join(process.cwd(), `clawculator-report-${Date.now()}.html`);
86
+ }
87
+ fs.writeFileSync(outPath, html, 'utf8');
88
+ return outPath;
89
+ diff --git a/skills/clawculator/mdReport.js b/skills/clawculator/mdReport.js
90
+ index 0f8971c..7a0a839 100644
91
+ --- a/skills/clawculator/mdReport.js
92
+ +++ b/skills/clawculator/mdReport.js
93
+ @@ -1,6 +1,6 @@
94
+ 'use strict';
95
+
96
+ -const SEVERITY_ICON = { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', info: 'โœ…' };
97
+ +const SEVERITY_ICON = { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', low: '๐Ÿ”ต', info: 'โœ…' };
98
+ const SOURCE_LABELS = {
99
+ heartbeat: '๐Ÿ’“ Heartbeat', hooks: '๐Ÿช Hooks', whatsapp: '๐Ÿ“ฑ WhatsApp',
100
+ subagents: '๐Ÿค– Subagents', skills: '๐Ÿ”ง Skills', memory: '๐Ÿง  Memory',
101
+ @@ -56,6 +56,7 @@ function generateMarkdownReport(analysis) {
102
+ lines.push(`| ๐Ÿ”ด Critical | ${summary.critical} |`);
103
+ lines.push(`| ๐ŸŸ  High | ${summary.high} |`);
104
+ lines.push(`| ๐ŸŸก Medium | ${summary.medium} |`);
105
+ + lines.push(`| ๐Ÿ”ต Low | ${summary.low || 0} |`);
106
+ lines.push(`| โœ… OK | ${summary.info} |`);
107
+ lines.push(`| Sessions analyzed | ${summary.sessionsAnalyzed} |`);
108
+ lines.push(`| Total tokens found | ${(summary.totalTokensFound || 0).toLocaleString()} |`);
109
+ @@ -74,7 +75,7 @@ function generateMarkdownReport(analysis) {
110
+ lines.push('## Findings');
111
+ lines.push('');
112
+
113
+ - for (const severity of ['critical', 'high', 'medium', 'info']) {
114
+ + for (const severity of ['critical', 'high', 'medium', 'low', 'info']) {
115
+ const group = findings.filter(f => f.severity === severity);
116
+ if (!group.length) continue;
117
+
118
+ diff --git a/src/analyzer.js b/src/analyzer.js
119
+ index 9c5b3b9..4659b5c 100644
120
+ --- a/src/analyzer.js
121
+ +++ b/src/analyzer.js
122
+ @@ -143,7 +143,12 @@ function isLocalModel(modelStr) {
123
+
124
+ function isHaikuTier(modelStr) {
125
+ if (!modelStr) return false;
126
+ - return modelStr.toLowerCase().includes('haiku');
127
+ + const lower = modelStr.toLowerCase();
128
+ + return lower.includes('haiku');
129
+ +}
130
+ +
131
+ +function isAcceptableForHooks(modelStr) {
132
+ + return isLocalModel(modelStr) || isHaikuTier(modelStr);
133
+ }
134
+
135
+ function isOpenRouter(modelStr) {
136
+ @@ -523,7 +528,11 @@ function analyzeSessions(sessionsPath) {
137
+ if (isOrphaned) orphaned.push({ key, model, tokens: inTok + outTok, cost });
138
+ if (inTok + outTok > 50000) large.push({ key, model, tokens: inTok + outTok });
139
+
140
+ - breakdown.push({ key, model, modelLabel: modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown', inputTokens: inTok, outputTokens: outTok, cost, updatedAt, isOrphaned });
141
+ + const ageMs = updatedAt ? Date.now() - new Date(updatedAt).getTime() : null;
142
+ + const ageDays = ageMs ? ageMs / (1000 * 3600 * 24) : null;
143
+ + const dailyCost = (ageDays && ageDays > 0.01 && cost > 0) ? cost / ageDays : null;
144
+ +
145
+ + breakdown.push({ key, model, modelLabel: modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown', inputTokens: inTok, outputTokens: outTok, cost, updatedAt, ageMs, dailyCost, isOrphaned });
146
+ }
147
+
148
+ if (orphaned.length > 0) findings.push({ severity: 'high', source: 'sessions', message: `${orphaned.length} orphaned session(s) โ€” still holding tokens on paid models`, detail: orphaned.map(s => `${s.key}: ${s.tokens.toLocaleString()} tokens ($${s.cost.toFixed(4)})`).join('\n '), ...FIXES.ORPHANED_SESSIONS });
149
+ @@ -575,6 +584,7 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
150
+ critical: allFindings.filter(f => f.severity === 'critical').length,
151
+ high: allFindings.filter(f => f.severity === 'high').length,
152
+ medium: allFindings.filter(f => f.severity === 'medium').length,
153
+ + low: allFindings.filter(f => f.severity === 'low').length,
154
+ info: allFindings.filter(f => f.severity === 'info').length,
155
+ estimatedMonthlyBleed,
156
+ sessionsAnalyzed: sessionResult.sessionCount,
157
+ diff --git a/src/htmlReport.js b/src/htmlReport.js
158
+ index 30cae47..05f9616 100644
159
+ --- a/src/htmlReport.js
160
+ +++ b/src/htmlReport.js
161
+ @@ -5,15 +5,28 @@ const path = require('path');
162
+ const os = require('os');
163
+
164
+ function severityColor(severity) {
165
+ - return { critical: '#ef4444', high: '#f97316', medium: '#eab308', info: '#22c55e' }[severity] || '#6b7280';
166
+ + return { critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#06b6d4', info: '#22c55e' }[severity] || '#6b7280';
167
+ }
168
+
169
+ function severityBg(severity) {
170
+ - return { critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', info: '#f0fdf4' }[severity] || '#f9fafb';
171
+ + return { critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', low: '#ecfeff', info: '#f0fdf4' }[severity] || '#f9fafb';
172
+ }
173
+
174
+ function severityIcon(severity) {
175
+ - return { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', info: 'โœ…' }[severity] || 'โšช';
176
+ + return { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', low: '๐Ÿ”ต', info: 'โœ…' }[severity] || 'โšช';
177
+ +}
178
+ +
179
+ +function relativeAge(ageMs) {
180
+ + if (!ageMs) return 'unknown';
181
+ + const s = Math.floor(ageMs / 1000);
182
+ + if (s < 60) return `${s}s ago`;
183
+ + const m = Math.floor(s / 60);
184
+ + if (m < 60) return `${m}m ago`;
185
+ + const h = Math.floor(m / 60);
186
+ + if (h < 24) return `${h}h ago`;
187
+ + const d = Math.floor(h / 24);
188
+ + if (d < 30) return `${d}d ago`;
189
+ + return `${Math.floor(d / 30)}mo ago`;
190
+ }
191
+
192
+ const SOURCE_LABELS = {
193
+ @@ -27,6 +40,25 @@ async function generateHTMLReport(analysis, outPath) {
194
+ const { summary, findings, sessions } = analysis;
195
+ const bleed = summary.estimatedMonthlyBleed;
196
+
197
+ + // Burn summary calculation
198
+ + const activeSessions = (sessions || []).filter(s => s.dailyCost);
199
+ + const totalDaily = activeSessions.reduce((sum, s) => sum + (s.dailyCost || 0), 0);
200
+ + const totalMonthly = totalDaily * 30;
201
+ + const burnSummary = totalDaily > 0 ? `
202
+ + <div class="section">
203
+ + <div class="section-title">Session Burn Rate</div>
204
+ + <div style="display:flex; gap:32px; flex-wrap:wrap;">
205
+ + <div>
206
+ + <div style="font-size:24px; font-weight:800; color:#f59e0b;">$${totalDaily.toFixed(4)}/day</div>
207
+ + <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Combined daily burn rate across ${activeSessions.length} active session${activeSessions.length !== 1 ? 's' : ''}</div>
208
+ + </div>
209
+ + <div>
210
+ + <div style="font-size:24px; font-weight:800; color:#f97316;">$${totalMonthly.toFixed(2)}/month</div>
211
+ + <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Projected monthly from active sessions</div>
212
+ + </div>
213
+ + </div>
214
+ + </div>` : '';
215
+ +
216
+ const findingCards = findings.map(f => `
217
+ <div class="finding" style="border-left: 4px solid ${severityColor(f.severity)}; background: ${severityBg(f.severity)}; padding: 16px; margin-bottom: 12px; border-radius: 0 8px 8px 0;">
218
+ <div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
219
+ @@ -44,14 +76,22 @@ async function generateHTMLReport(analysis, outPath) {
220
+ const sessionRows = (sessions || [])
221
+ .sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens))
222
+ .slice(0, 20)
223
+ - .map(s => `
224
+ + .map(s => {
225
+ + const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + 'โ€ฆ' : s.key;
226
+ + const flag = s.isOrphaned ? ' โš ๏ธ' : '';
227
+ + const age = s.ageMs ? relativeAge(s.ageMs) : 'unknown';
228
+ + const absDate = s.updatedAt ? new Date(s.updatedAt).toLocaleString() : '';
229
+ + const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}/day` : 'โ€”';
230
+ + return `
231
+ <tr style="${s.isOrphaned ? 'background:#fff7ed' : ''}">
232
+ - <td style="padding:8px 12px; font-family:monospace; font-size:13px">${s.key}${s.isOrphaned ? ' โš ๏ธ' : ''}</td>
233
+ + <td style="padding:8px 12px; font-family:monospace; font-size:13px">${keyDisplay}${flag}</td>
234
+ <td style="padding:8px 12px">${s.modelLabel || s.model}</td>
235
+ <td style="padding:8px 12px; text-align:right">${(s.inputTokens + s.outputTokens).toLocaleString()}</td>
236
+ <td style="padding:8px 12px; text-align:right; color:${s.cost > 0.01 ? '#ef4444' : '#22c55e'}">$${s.cost.toFixed(6)}</td>
237
+ + <td style="padding:8px 12px; text-align:right; color:#f59e0b">${daily}</td>
238
+ + <td style="padding:8px 12px; color:#6b7280; font-size:13px" title="${absDate}">${age}</td>
239
+ </tr>
240
+ - `).join('');
241
+ + `}).join('');
242
+
243
+ const html = `<!DOCTYPE html>
244
+ <html lang="en">
245
+ @@ -66,7 +106,7 @@ async function generateHTMLReport(analysis, outPath) {
246
+ .logo { font-size: 42px; font-weight: 900; letter-spacing: -2px; background: linear-gradient(90deg, #38bdf8, #818cf8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
247
+ .tagline { color: #94a3b8; margin-top: 8px; font-size: 16px; }
248
+ .container { max-width: 1000px; margin: 0 auto; padding: 32px 24px; }
249
+ - .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 32px; }
250
+ + .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; margin-bottom: 32px; }
251
+ .card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; }
252
+ .card-value { font-size: 32px; font-weight: 800; }
253
+ .card-label { font-size: 13px; color: #94a3b8; margin-top: 4px; }
254
+ @@ -111,6 +151,10 @@ async function generateHTMLReport(analysis, outPath) {
255
+ <div class="card-value" style="color:#eab308">${summary.medium}</div>
256
+ <div class="card-label">๐ŸŸก Medium</div>
257
+ </div>
258
+ + <div class="card">
259
+ + <div class="card-value" style="color:#06b6d4">${summary.low || 0}</div>
260
+ + <div class="card-label">๐Ÿ”ต Low</div>
261
+ + </div>
262
+ <div class="card">
263
+ <div class="card-value" style="color:#22c55e">${summary.info}</div>
264
+ <div class="card-label">โœ… OK</div>
265
+ @@ -137,11 +181,12 @@ async function generateHTMLReport(analysis, outPath) {
266
+ <div class="section-title">Session Breakdown</div>
267
+ <div style="overflow-x:auto">
268
+ <table>
269
+ - <thead><tr><th>Session</th><th>Model</th><th style="text-align:right">Tokens</th><th style="text-align:right">Cost</th></tr></thead>
270
+ + <thead><tr><th>Session</th><th>Model</th><th style="text-align:right">Tokens</th><th style="text-align:right">Total Cost</th><th style="text-align:right">$/day</th><th>Last Active</th></tr></thead>
271
+ <tbody>${sessionRows}</tbody>
272
+ </table>
273
+ </div>
274
+ - </div>` : ''}
275
+ + </div>
276
+ + ${burnSummary}` : ''}
277
+
278
+ </div>
279
+ <div class="footer">
280
+ @@ -151,7 +196,7 @@ async function generateHTMLReport(analysis, outPath) {
281
+ </html>`;
282
+
283
+ if (!outPath) {
284
+ - outPath = path.join(os.tmpdir(), `clawculator-report-${Date.now()}.html`);
285
+ + outPath = path.join(process.cwd(), `clawculator-report-${Date.now()}.html`);
286
+ }
287
+ fs.writeFileSync(outPath, html, 'utf8');
288
+ return outPath;
289
+ diff --git a/src/mdReport.js b/src/mdReport.js
290
+ index 630818e..7a0a839 100644
291
+ --- a/src/mdReport.js
292
+ +++ b/src/mdReport.js
293
+ @@ -1,6 +1,6 @@
294
+ 'use strict';
295
+
296
+ -const SEVERITY_ICON = { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', info: 'โœ…' };
297
+ +const SEVERITY_ICON = { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', low: '๐Ÿ”ต', info: 'โœ…' };
298
+ const SOURCE_LABELS = {
299
+ heartbeat: '๐Ÿ’“ Heartbeat', hooks: '๐Ÿช Hooks', whatsapp: '๐Ÿ“ฑ WhatsApp',
300
+ subagents: '๐Ÿค– Subagents', skills: '๐Ÿ”ง Skills', memory: '๐Ÿง  Memory',
301
+ @@ -8,6 +8,25 @@ const SOURCE_LABELS = {
302
+ workspace: '๐Ÿ“ Workspace', config: '๐Ÿ“„ Config',
303
+ };
304
+
305
+ +function relativeAge(ageMs) {
306
+ + if (!ageMs) return 'unknown';
307
+ + const s = Math.floor(ageMs / 1000);
308
+ + if (s < 60) return `${s}s ago`;
309
+ + const m = Math.floor(s / 60);
310
+ + if (m < 60) return `${m}m ago`;
311
+ + const h = Math.floor(m / 60);
312
+ + if (h < 24) return `${h}h ago`;
313
+ + const d = Math.floor(h / 24);
314
+ + if (d < 30) return `${d}d ago`;
315
+ + const mo = Math.floor(d / 30);
316
+ + return `${mo}mo ago`;
317
+ +}
318
+ +
319
+ +function absoluteDate(updatedAt) {
320
+ + if (!updatedAt) return '';
321
+ + return new Date(updatedAt).toLocaleString();
322
+ +}
323
+ +
324
+ function generateMarkdownReport(analysis) {
325
+ const { summary, findings, sessions, primaryModel, scannedAt } = analysis;
326
+ const bleed = summary.estimatedMonthlyBleed;
327
+ @@ -32,21 +51,31 @@ function generateMarkdownReport(analysis) {
328
+ // Summary table
329
+ lines.push('## Summary');
330
+ lines.push('');
331
+ - lines.push('| Severity | Count |');
332
+ - lines.push('|----------|-------|');
333
+ + lines.push('| Metric | Value |');
334
+ + lines.push('|--------|-------|');
335
+ lines.push(`| ๐Ÿ”ด Critical | ${summary.critical} |`);
336
+ lines.push(`| ๐ŸŸ  High | ${summary.high} |`);
337
+ lines.push(`| ๐ŸŸก Medium | ${summary.medium} |`);
338
+ + lines.push(`| ๐Ÿ”ต Low | ${summary.low || 0} |`);
339
+ lines.push(`| โœ… OK | ${summary.info} |`);
340
+ lines.push(`| Sessions analyzed | ${summary.sessionsAnalyzed} |`);
341
+ lines.push(`| Total tokens found | ${(summary.totalTokensFound || 0).toLocaleString()} |`);
342
+ +
343
+ + // Session cost summary
344
+ + if (sessions?.length > 0) {
345
+ + const totalCost = sessions.reduce((sum, s) => sum + s.cost, 0);
346
+ + const dailyRates = sessions.filter(s => s.dailyCost).map(s => s.dailyCost);
347
+ + const totalDailyRate = dailyRates.reduce((sum, r) => sum + r, 0);
348
+ + if (totalCost > 0) lines.push(`| Total session cost (lifetime) | $${totalCost.toFixed(4)} |`);
349
+ + if (totalDailyRate > 0) lines.push(`| Avg daily burn rate | $${totalDailyRate.toFixed(4)}/day (~$${(totalDailyRate * 30).toFixed(2)}/month) |`);
350
+ + }
351
+ lines.push('');
352
+
353
+ // Findings by severity
354
+ lines.push('## Findings');
355
+ lines.push('');
356
+
357
+ - for (const severity of ['critical', 'high', 'medium', 'info']) {
358
+ + for (const severity of ['critical', 'high', 'medium', 'low', 'info']) {
359
+ const group = findings.filter(f => f.severity === severity);
360
+ if (!group.length) continue;
361
+
362
+ @@ -78,19 +107,24 @@ function generateMarkdownReport(analysis) {
363
+ if (sessions?.length > 0) {
364
+ lines.push('## Session Breakdown');
365
+ lines.push('');
366
+ - lines.push('| Session | Model | Tokens | Cost |');
367
+ - lines.push('|---------|-------|--------|------|');
368
+ + lines.push('| Session | Model | Tokens | Total Cost | $/day | Last Active |');
369
+ + lines.push('|---------|-------|--------|-----------|-------|-------------|');
370
+
371
+ [...sessions]
372
+ .sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens))
373
+ .slice(0, 20)
374
+ .forEach(s => {
375
+ - const tok = (s.inputTokens + s.outputTokens).toLocaleString();
376
+ - const flag = s.isOrphaned ? ' โš ๏ธ' : '';
377
+ - lines.push(`| \`${s.key}${flag}\` | ${s.modelLabel || s.model} | ${tok} | $${s.cost.toFixed(6)} |`);
378
+ + const tok = (s.inputTokens + s.outputTokens).toLocaleString();
379
+ + const flag = s.isOrphaned ? ' โš ๏ธ' : '';
380
+ + const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + 'โ€ฆ' : s.key;
381
+ + const age = s.ageMs ? `${relativeAge(s.ageMs)} (${absoluteDate(s.updatedAt)})` : 'unknown';
382
+ + const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}` : 'โ€”';
383
+ + lines.push(`| \`${keyDisplay}${flag}\` | ${s.modelLabel || s.model} | ${tok} | $${s.cost.toFixed(6)} | ${daily} | ${age} |`);
384
+ });
385
+
386
+ lines.push('');
387
+ + lines.push('> โš ๏ธ = orphaned session ยท $/day = total cost รท session age');
388
+ + lines.push('');
389
+ }
390
+
391
+ // Quick wins
392
+ diff --git a/src/reporter.js b/src/reporter.js
393
+ index d850c4a..8e35519 100644
394
+ --- a/src/reporter.js
395
+ +++ b/src/reporter.js
396
+ @@ -36,6 +36,19 @@ function formatCost(cost) {
397
+ return `\x1b[31m$${cost.toFixed(2)}/mo\x1b[0m`;
398
+ }
399
+
400
+ +function relativeAge(ageMs) {
401
+ + if (!ageMs) return 'unknown';
402
+ + const s = Math.floor(ageMs / 1000);
403
+ + if (s < 60) return `${s}s ago`;
404
+ + const m = Math.floor(s / 60);
405
+ + if (m < 60) return `${m}m ago`;
406
+ + const h = Math.floor(m / 60);
407
+ + if (h < 24) return `${h}h ago`;
408
+ + const d = Math.floor(h / 24);
409
+ + if (d < 30) return `${d}d ago`;
410
+ + return `${Math.floor(d / 30)}mo ago`;
411
+ +}
412
+ +
413
+ function generateTerminalReport(analysis) {
414
+ const { summary, findings, sessions } = analysis;
415
+ const R = '\x1b[0m';
416
+ @@ -78,12 +91,22 @@ function generateTerminalReport(analysis) {
417
+ if (sessions?.length > 0) {
418
+ console.log(`${C}โ”โ”โ” Top Sessions by Token Usage โ”โ”โ”${R}\n`);
419
+ const sorted = [...sessions].sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens)).slice(0, 8);
420
+ - console.log(` ${D}${'Session'.padEnd(42)} ${'Model'.padEnd(22)} ${'Tokens'.padEnd(10)} Cost${R}`);
421
+ - console.log(` ${D}${'โ”€'.repeat(85)}${R}`);
422
+ + console.log(` ${D}${'Session'.padEnd(16)} ${'Model'.padEnd(20)} ${'Tokens'.padEnd(10)} ${'Total Cost'.padEnd(12)} ${'$/day'.padEnd(10)} Last Active${R}`);
423
+ + console.log(` ${D}${'โ”€'.repeat(95)}${R}`);
424
+ for (const s of sorted) {
425
+ - const tok = (s.inputTokens + s.outputTokens).toLocaleString();
426
+ - const flag = s.isOrphaned ? ' โš ๏ธ' : '';
427
+ - console.log(` ${(s.key + flag).slice(0, 42).padEnd(42)} ${(s.modelLabel || s.model || 'unknown').slice(0, 22).padEnd(22)} ${tok.padEnd(10)} $${s.cost.toFixed(6)}`);
428
+ + const tok = (s.inputTokens + s.outputTokens).toLocaleString();
429
+ + const flag = s.isOrphaned ? ' โš ๏ธ' : '';
430
+ + const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + 'โ€ฆ' : s.key;
431
+ + const age = s.ageMs ? `${relativeAge(s.ageMs)} (${new Date(s.updatedAt).toLocaleDateString()})` : 'unknown';
432
+ + const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}` : 'โ€”';
433
+ + console.log(` ${(keyDisplay + flag).padEnd(16)} ${(s.modelLabel || s.model || 'unknown').slice(0, 20).padEnd(20)} ${tok.padEnd(10)} $${s.cost.toFixed(6).padEnd(11)} ${daily.padEnd(10)} ${D}${age}${R}`);
434
+ + }
435
+ +
436
+ + // Daily burn rate summary
437
+ + const totalDailyRate = sessions.filter(s => s.dailyCost).reduce((sum, s) => sum + s.dailyCost, 0);
438
+ + if (totalDailyRate > 0) {
439
+ + console.log();
440
+ + console.log(` ${D}Combined burn rate: ${R}${RED}$${totalDailyRate.toFixed(4)}/day${R}${D} ยท ~$${(totalDailyRate * 30).toFixed(2)}/month${R}`);
441
+ }
442
+ console.log();
443
+ }
444
+ @@ -93,7 +116,7 @@ function generateTerminalReport(analysis) {
445
+ console.log(` ๐Ÿ”ด ${RED}${summary.critical}${R} critical ๐ŸŸ  ${summary.high} high ๐ŸŸก ${summary.medium} medium ๐Ÿ”ต ${summary.low||0} low โœ… ${summary.info} ok`);
446
+ console.log(` Sessions analyzed: ${summary.sessionsAnalyzed} ยท Tokens found: ${(summary.totalTokensFound||0).toLocaleString()}`);
447
+ if (bleed > 0) {
448
+ - console.log(` ${RED}${B}Monthly bleed: $${bleed.toFixed(2)}/month${R}`);
449
+ + console.log(` ${RED}${B}Estimated monthly bleed: $${bleed.toFixed(2)}/month${R}`);
450
+ } else {
451
+ console.log(` ${GRN}No significant cost bleed detected โœ“${R}`);
452
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawculator",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
4
4
  "description": "AI cost forensics for OpenClaw and multi-model setups. Your friendly penny pincher. 100% offline. Zero AI. Pure deterministic logic.",
5
5
  "main": "src/analyzer.js",
6
6
  "bin": {
@@ -5,15 +5,15 @@ const path = require('path');
5
5
  const os = require('os');
6
6
 
7
7
  function severityColor(severity) {
8
- return { critical: '#ef4444', high: '#f97316', medium: '#eab308', info: '#22c55e' }[severity] || '#6b7280';
8
+ return { critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#06b6d4', info: '#22c55e' }[severity] || '#6b7280';
9
9
  }
10
10
 
11
11
  function severityBg(severity) {
12
- return { critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', info: '#f0fdf4' }[severity] || '#f9fafb';
12
+ return { critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', low: '#ecfeff', info: '#f0fdf4' }[severity] || '#f9fafb';
13
13
  }
14
14
 
15
15
  function severityIcon(severity) {
16
- return { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', info: 'โœ…' }[severity] || 'โšช';
16
+ return { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', low: '๐Ÿ”ต', info: 'โœ…' }[severity] || 'โšช';
17
17
  }
18
18
 
19
19
  function relativeAge(ageMs) {
@@ -40,6 +40,25 @@ async function generateHTMLReport(analysis, outPath) {
40
40
  const { summary, findings, sessions } = analysis;
41
41
  const bleed = summary.estimatedMonthlyBleed;
42
42
 
43
+ // Burn summary calculation
44
+ const activeSessions = (sessions || []).filter(s => s.dailyCost);
45
+ const totalDaily = activeSessions.reduce((sum, s) => sum + (s.dailyCost || 0), 0);
46
+ const totalMonthly = totalDaily * 30;
47
+ const burnSummary = totalDaily > 0 ? `
48
+ <div class="section">
49
+ <div class="section-title">Session Burn Rate</div>
50
+ <div style="display:flex; gap:32px; flex-wrap:wrap;">
51
+ <div>
52
+ <div style="font-size:24px; font-weight:800; color:#f59e0b;">$${totalDaily.toFixed(4)}/day</div>
53
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Combined daily burn rate across ${activeSessions.length} active session${activeSessions.length !== 1 ? 's' : ''}</div>
54
+ </div>
55
+ <div>
56
+ <div style="font-size:24px; font-weight:800; color:#f97316;">$${totalMonthly.toFixed(2)}/month</div>
57
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Projected monthly from active sessions</div>
58
+ </div>
59
+ </div>
60
+ </div>` : '';
61
+
43
62
  const findingCards = findings.map(f => `
44
63
  <div class="finding" style="border-left: 4px solid ${severityColor(f.severity)}; background: ${severityBg(f.severity)}; padding: 16px; margin-bottom: 12px; border-radius: 0 8px 8px 0;">
45
64
  <div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
@@ -87,7 +106,7 @@ async function generateHTMLReport(analysis, outPath) {
87
106
  .logo { font-size: 42px; font-weight: 900; letter-spacing: -2px; background: linear-gradient(90deg, #38bdf8, #818cf8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
88
107
  .tagline { color: #94a3b8; margin-top: 8px; font-size: 16px; }
89
108
  .container { max-width: 1000px; margin: 0 auto; padding: 32px 24px; }
90
- .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 32px; }
109
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; margin-bottom: 32px; }
91
110
  .card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; }
92
111
  .card-value { font-size: 32px; font-weight: 800; }
93
112
  .card-label { font-size: 13px; color: #94a3b8; margin-top: 4px; }
@@ -132,6 +151,10 @@ async function generateHTMLReport(analysis, outPath) {
132
151
  <div class="card-value" style="color:#eab308">${summary.medium}</div>
133
152
  <div class="card-label">๐ŸŸก Medium</div>
134
153
  </div>
154
+ <div class="card">
155
+ <div class="card-value" style="color:#06b6d4">${summary.low || 0}</div>
156
+ <div class="card-label">๐Ÿ”ต Low</div>
157
+ </div>
135
158
  <div class="card">
136
159
  <div class="card-value" style="color:#22c55e">${summary.info}</div>
137
160
  <div class="card-label">โœ… OK</div>
@@ -162,7 +185,8 @@ async function generateHTMLReport(analysis, outPath) {
162
185
  <tbody>${sessionRows}</tbody>
163
186
  </table>
164
187
  </div>
165
- </div>` : ''}
188
+ </div>
189
+ ${burnSummary}` : ''}
166
190
 
167
191
  </div>
168
192
  <div class="footer">
@@ -172,7 +196,7 @@ async function generateHTMLReport(analysis, outPath) {
172
196
  </html>`;
173
197
 
174
198
  if (!outPath) {
175
- outPath = path.join(os.tmpdir(), `clawculator-report-${Date.now()}.html`);
199
+ outPath = path.join(process.cwd(), `clawculator-report-${Date.now()}.html`);
176
200
  }
177
201
  fs.writeFileSync(outPath, html, 'utf8');
178
202
  return outPath;
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const SEVERITY_ICON = { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', info: 'โœ…' };
3
+ const SEVERITY_ICON = { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', low: '๐Ÿ”ต', info: 'โœ…' };
4
4
  const SOURCE_LABELS = {
5
5
  heartbeat: '๐Ÿ’“ Heartbeat', hooks: '๐Ÿช Hooks', whatsapp: '๐Ÿ“ฑ WhatsApp',
6
6
  subagents: '๐Ÿค– Subagents', skills: '๐Ÿ”ง Skills', memory: '๐Ÿง  Memory',
@@ -56,6 +56,7 @@ function generateMarkdownReport(analysis) {
56
56
  lines.push(`| ๐Ÿ”ด Critical | ${summary.critical} |`);
57
57
  lines.push(`| ๐ŸŸ  High | ${summary.high} |`);
58
58
  lines.push(`| ๐ŸŸก Medium | ${summary.medium} |`);
59
+ lines.push(`| ๐Ÿ”ต Low | ${summary.low || 0} |`);
59
60
  lines.push(`| โœ… OK | ${summary.info} |`);
60
61
  lines.push(`| Sessions analyzed | ${summary.sessionsAnalyzed} |`);
61
62
  lines.push(`| Total tokens found | ${(summary.totalTokensFound || 0).toLocaleString()} |`);
@@ -74,7 +75,7 @@ function generateMarkdownReport(analysis) {
74
75
  lines.push('## Findings');
75
76
  lines.push('');
76
77
 
77
- for (const severity of ['critical', 'high', 'medium', 'info']) {
78
+ for (const severity of ['critical', 'high', 'medium', 'low', 'info']) {
78
79
  const group = findings.filter(f => f.severity === severity);
79
80
  if (!group.length) continue;
80
81
 
package/src/analyzer.js CHANGED
@@ -143,7 +143,12 @@ function isLocalModel(modelStr) {
143
143
 
144
144
  function isHaikuTier(modelStr) {
145
145
  if (!modelStr) return false;
146
- return modelStr.toLowerCase().includes('haiku');
146
+ const lower = modelStr.toLowerCase();
147
+ return lower.includes('haiku');
148
+ }
149
+
150
+ function isAcceptableForHooks(modelStr) {
151
+ return isLocalModel(modelStr) || isHaikuTier(modelStr);
147
152
  }
148
153
 
149
154
  function isOpenRouter(modelStr) {
@@ -523,7 +528,11 @@ function analyzeSessions(sessionsPath) {
523
528
  if (isOrphaned) orphaned.push({ key, model, tokens: inTok + outTok, cost });
524
529
  if (inTok + outTok > 50000) large.push({ key, model, tokens: inTok + outTok });
525
530
 
526
- breakdown.push({ key, model, modelLabel: modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown', inputTokens: inTok, outputTokens: outTok, cost, updatedAt, isOrphaned });
531
+ const ageMs = updatedAt ? Date.now() - new Date(updatedAt).getTime() : null;
532
+ const ageDays = ageMs ? ageMs / (1000 * 3600 * 24) : null;
533
+ const dailyCost = (ageDays && ageDays > 0.01 && cost > 0) ? cost / ageDays : null;
534
+
535
+ breakdown.push({ key, model, modelLabel: modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown', inputTokens: inTok, outputTokens: outTok, cost, updatedAt, ageMs, dailyCost, isOrphaned });
527
536
  }
528
537
 
529
538
  if (orphaned.length > 0) findings.push({ severity: 'high', source: 'sessions', message: `${orphaned.length} orphaned session(s) โ€” still holding tokens on paid models`, detail: orphaned.map(s => `${s.key}: ${s.tokens.toLocaleString()} tokens ($${s.cost.toFixed(4)})`).join('\n '), ...FIXES.ORPHANED_SESSIONS });
@@ -575,6 +584,7 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
575
584
  critical: allFindings.filter(f => f.severity === 'critical').length,
576
585
  high: allFindings.filter(f => f.severity === 'high').length,
577
586
  medium: allFindings.filter(f => f.severity === 'medium').length,
587
+ low: allFindings.filter(f => f.severity === 'low').length,
578
588
  info: allFindings.filter(f => f.severity === 'info').length,
579
589
  estimatedMonthlyBleed,
580
590
  sessionsAnalyzed: sessionResult.sessionCount,
package/src/htmlReport.js CHANGED
@@ -5,15 +5,28 @@ const path = require('path');
5
5
  const os = require('os');
6
6
 
7
7
  function severityColor(severity) {
8
- return { critical: '#ef4444', high: '#f97316', medium: '#eab308', info: '#22c55e' }[severity] || '#6b7280';
8
+ return { critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#06b6d4', info: '#22c55e' }[severity] || '#6b7280';
9
9
  }
10
10
 
11
11
  function severityBg(severity) {
12
- return { critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', info: '#f0fdf4' }[severity] || '#f9fafb';
12
+ return { critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', low: '#ecfeff', info: '#f0fdf4' }[severity] || '#f9fafb';
13
13
  }
14
14
 
15
15
  function severityIcon(severity) {
16
- return { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', info: 'โœ…' }[severity] || 'โšช';
16
+ return { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', low: '๐Ÿ”ต', info: 'โœ…' }[severity] || 'โšช';
17
+ }
18
+
19
+ function relativeAge(ageMs) {
20
+ if (!ageMs) return 'unknown';
21
+ const s = Math.floor(ageMs / 1000);
22
+ if (s < 60) return `${s}s ago`;
23
+ const m = Math.floor(s / 60);
24
+ if (m < 60) return `${m}m ago`;
25
+ const h = Math.floor(m / 60);
26
+ if (h < 24) return `${h}h ago`;
27
+ const d = Math.floor(h / 24);
28
+ if (d < 30) return `${d}d ago`;
29
+ return `${Math.floor(d / 30)}mo ago`;
17
30
  }
18
31
 
19
32
  const SOURCE_LABELS = {
@@ -27,6 +40,25 @@ async function generateHTMLReport(analysis, outPath) {
27
40
  const { summary, findings, sessions } = analysis;
28
41
  const bleed = summary.estimatedMonthlyBleed;
29
42
 
43
+ // Burn summary calculation
44
+ const activeSessions = (sessions || []).filter(s => s.dailyCost);
45
+ const totalDaily = activeSessions.reduce((sum, s) => sum + (s.dailyCost || 0), 0);
46
+ const totalMonthly = totalDaily * 30;
47
+ const burnSummary = totalDaily > 0 ? `
48
+ <div class="section">
49
+ <div class="section-title">Session Burn Rate</div>
50
+ <div style="display:flex; gap:32px; flex-wrap:wrap;">
51
+ <div>
52
+ <div style="font-size:24px; font-weight:800; color:#f59e0b;">$${totalDaily.toFixed(4)}/day</div>
53
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Combined daily burn rate across ${activeSessions.length} active session${activeSessions.length !== 1 ? 's' : ''}</div>
54
+ </div>
55
+ <div>
56
+ <div style="font-size:24px; font-weight:800; color:#f97316;">$${totalMonthly.toFixed(2)}/month</div>
57
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Projected monthly from active sessions</div>
58
+ </div>
59
+ </div>
60
+ </div>` : '';
61
+
30
62
  const findingCards = findings.map(f => `
31
63
  <div class="finding" style="border-left: 4px solid ${severityColor(f.severity)}; background: ${severityBg(f.severity)}; padding: 16px; margin-bottom: 12px; border-radius: 0 8px 8px 0;">
32
64
  <div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
@@ -44,14 +76,22 @@ async function generateHTMLReport(analysis, outPath) {
44
76
  const sessionRows = (sessions || [])
45
77
  .sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens))
46
78
  .slice(0, 20)
47
- .map(s => `
79
+ .map(s => {
80
+ const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + 'โ€ฆ' : s.key;
81
+ const flag = s.isOrphaned ? ' โš ๏ธ' : '';
82
+ const age = s.ageMs ? relativeAge(s.ageMs) : 'unknown';
83
+ const absDate = s.updatedAt ? new Date(s.updatedAt).toLocaleString() : '';
84
+ const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}/day` : 'โ€”';
85
+ return `
48
86
  <tr style="${s.isOrphaned ? 'background:#fff7ed' : ''}">
49
- <td style="padding:8px 12px; font-family:monospace; font-size:13px">${s.key}${s.isOrphaned ? ' โš ๏ธ' : ''}</td>
87
+ <td style="padding:8px 12px; font-family:monospace; font-size:13px">${keyDisplay}${flag}</td>
50
88
  <td style="padding:8px 12px">${s.modelLabel || s.model}</td>
51
89
  <td style="padding:8px 12px; text-align:right">${(s.inputTokens + s.outputTokens).toLocaleString()}</td>
52
90
  <td style="padding:8px 12px; text-align:right; color:${s.cost > 0.01 ? '#ef4444' : '#22c55e'}">$${s.cost.toFixed(6)}</td>
91
+ <td style="padding:8px 12px; text-align:right; color:#f59e0b">${daily}</td>
92
+ <td style="padding:8px 12px; color:#6b7280; font-size:13px" title="${absDate}">${age}</td>
53
93
  </tr>
54
- `).join('');
94
+ `}).join('');
55
95
 
56
96
  const html = `<!DOCTYPE html>
57
97
  <html lang="en">
@@ -66,7 +106,7 @@ async function generateHTMLReport(analysis, outPath) {
66
106
  .logo { font-size: 42px; font-weight: 900; letter-spacing: -2px; background: linear-gradient(90deg, #38bdf8, #818cf8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
67
107
  .tagline { color: #94a3b8; margin-top: 8px; font-size: 16px; }
68
108
  .container { max-width: 1000px; margin: 0 auto; padding: 32px 24px; }
69
- .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 32px; }
109
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; margin-bottom: 32px; }
70
110
  .card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; }
71
111
  .card-value { font-size: 32px; font-weight: 800; }
72
112
  .card-label { font-size: 13px; color: #94a3b8; margin-top: 4px; }
@@ -111,6 +151,10 @@ async function generateHTMLReport(analysis, outPath) {
111
151
  <div class="card-value" style="color:#eab308">${summary.medium}</div>
112
152
  <div class="card-label">๐ŸŸก Medium</div>
113
153
  </div>
154
+ <div class="card">
155
+ <div class="card-value" style="color:#06b6d4">${summary.low || 0}</div>
156
+ <div class="card-label">๐Ÿ”ต Low</div>
157
+ </div>
114
158
  <div class="card">
115
159
  <div class="card-value" style="color:#22c55e">${summary.info}</div>
116
160
  <div class="card-label">โœ… OK</div>
@@ -137,11 +181,12 @@ async function generateHTMLReport(analysis, outPath) {
137
181
  <div class="section-title">Session Breakdown</div>
138
182
  <div style="overflow-x:auto">
139
183
  <table>
140
- <thead><tr><th>Session</th><th>Model</th><th style="text-align:right">Tokens</th><th style="text-align:right">Cost</th></tr></thead>
184
+ <thead><tr><th>Session</th><th>Model</th><th style="text-align:right">Tokens</th><th style="text-align:right">Total Cost</th><th style="text-align:right">$/day</th><th>Last Active</th></tr></thead>
141
185
  <tbody>${sessionRows}</tbody>
142
186
  </table>
143
187
  </div>
144
- </div>` : ''}
188
+ </div>
189
+ ${burnSummary}` : ''}
145
190
 
146
191
  </div>
147
192
  <div class="footer">
@@ -151,7 +196,7 @@ async function generateHTMLReport(analysis, outPath) {
151
196
  </html>`;
152
197
 
153
198
  if (!outPath) {
154
- outPath = path.join(os.tmpdir(), `clawculator-report-${Date.now()}.html`);
199
+ outPath = path.join(process.cwd(), `clawculator-report-${Date.now()}.html`);
155
200
  }
156
201
  fs.writeFileSync(outPath, html, 'utf8');
157
202
  return outPath;
package/src/mdReport.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const SEVERITY_ICON = { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', info: 'โœ…' };
3
+ const SEVERITY_ICON = { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', low: '๐Ÿ”ต', info: 'โœ…' };
4
4
  const SOURCE_LABELS = {
5
5
  heartbeat: '๐Ÿ’“ Heartbeat', hooks: '๐Ÿช Hooks', whatsapp: '๐Ÿ“ฑ WhatsApp',
6
6
  subagents: '๐Ÿค– Subagents', skills: '๐Ÿ”ง Skills', memory: '๐Ÿง  Memory',
@@ -8,6 +8,25 @@ const SOURCE_LABELS = {
8
8
  workspace: '๐Ÿ“ Workspace', config: '๐Ÿ“„ Config',
9
9
  };
10
10
 
11
+ function relativeAge(ageMs) {
12
+ if (!ageMs) return 'unknown';
13
+ const s = Math.floor(ageMs / 1000);
14
+ if (s < 60) return `${s}s ago`;
15
+ const m = Math.floor(s / 60);
16
+ if (m < 60) return `${m}m ago`;
17
+ const h = Math.floor(m / 60);
18
+ if (h < 24) return `${h}h ago`;
19
+ const d = Math.floor(h / 24);
20
+ if (d < 30) return `${d}d ago`;
21
+ const mo = Math.floor(d / 30);
22
+ return `${mo}mo ago`;
23
+ }
24
+
25
+ function absoluteDate(updatedAt) {
26
+ if (!updatedAt) return '';
27
+ return new Date(updatedAt).toLocaleString();
28
+ }
29
+
11
30
  function generateMarkdownReport(analysis) {
12
31
  const { summary, findings, sessions, primaryModel, scannedAt } = analysis;
13
32
  const bleed = summary.estimatedMonthlyBleed;
@@ -32,21 +51,31 @@ function generateMarkdownReport(analysis) {
32
51
  // Summary table
33
52
  lines.push('## Summary');
34
53
  lines.push('');
35
- lines.push('| Severity | Count |');
36
- lines.push('|----------|-------|');
54
+ lines.push('| Metric | Value |');
55
+ lines.push('|--------|-------|');
37
56
  lines.push(`| ๐Ÿ”ด Critical | ${summary.critical} |`);
38
57
  lines.push(`| ๐ŸŸ  High | ${summary.high} |`);
39
58
  lines.push(`| ๐ŸŸก Medium | ${summary.medium} |`);
59
+ lines.push(`| ๐Ÿ”ต Low | ${summary.low || 0} |`);
40
60
  lines.push(`| โœ… OK | ${summary.info} |`);
41
61
  lines.push(`| Sessions analyzed | ${summary.sessionsAnalyzed} |`);
42
62
  lines.push(`| Total tokens found | ${(summary.totalTokensFound || 0).toLocaleString()} |`);
63
+
64
+ // Session cost summary
65
+ if (sessions?.length > 0) {
66
+ const totalCost = sessions.reduce((sum, s) => sum + s.cost, 0);
67
+ const dailyRates = sessions.filter(s => s.dailyCost).map(s => s.dailyCost);
68
+ const totalDailyRate = dailyRates.reduce((sum, r) => sum + r, 0);
69
+ if (totalCost > 0) lines.push(`| Total session cost (lifetime) | $${totalCost.toFixed(4)} |`);
70
+ if (totalDailyRate > 0) lines.push(`| Avg daily burn rate | $${totalDailyRate.toFixed(4)}/day (~$${(totalDailyRate * 30).toFixed(2)}/month) |`);
71
+ }
43
72
  lines.push('');
44
73
 
45
74
  // Findings by severity
46
75
  lines.push('## Findings');
47
76
  lines.push('');
48
77
 
49
- for (const severity of ['critical', 'high', 'medium', 'info']) {
78
+ for (const severity of ['critical', 'high', 'medium', 'low', 'info']) {
50
79
  const group = findings.filter(f => f.severity === severity);
51
80
  if (!group.length) continue;
52
81
 
@@ -78,19 +107,24 @@ function generateMarkdownReport(analysis) {
78
107
  if (sessions?.length > 0) {
79
108
  lines.push('## Session Breakdown');
80
109
  lines.push('');
81
- lines.push('| Session | Model | Tokens | Cost |');
82
- lines.push('|---------|-------|--------|------|');
110
+ lines.push('| Session | Model | Tokens | Total Cost | $/day | Last Active |');
111
+ lines.push('|---------|-------|--------|-----------|-------|-------------|');
83
112
 
84
113
  [...sessions]
85
114
  .sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens))
86
115
  .slice(0, 20)
87
116
  .forEach(s => {
88
- const tok = (s.inputTokens + s.outputTokens).toLocaleString();
89
- const flag = s.isOrphaned ? ' โš ๏ธ' : '';
90
- lines.push(`| \`${s.key}${flag}\` | ${s.modelLabel || s.model} | ${tok} | $${s.cost.toFixed(6)} |`);
117
+ const tok = (s.inputTokens + s.outputTokens).toLocaleString();
118
+ const flag = s.isOrphaned ? ' โš ๏ธ' : '';
119
+ const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + 'โ€ฆ' : s.key;
120
+ const age = s.ageMs ? `${relativeAge(s.ageMs)} (${absoluteDate(s.updatedAt)})` : 'unknown';
121
+ const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}` : 'โ€”';
122
+ lines.push(`| \`${keyDisplay}${flag}\` | ${s.modelLabel || s.model} | ${tok} | $${s.cost.toFixed(6)} | ${daily} | ${age} |`);
91
123
  });
92
124
 
93
125
  lines.push('');
126
+ lines.push('> โš ๏ธ = orphaned session ยท $/day = total cost รท session age');
127
+ lines.push('');
94
128
  }
95
129
 
96
130
  // Quick wins
package/src/reporter.js CHANGED
@@ -36,6 +36,19 @@ function formatCost(cost) {
36
36
  return `\x1b[31m$${cost.toFixed(2)}/mo\x1b[0m`;
37
37
  }
38
38
 
39
+ function relativeAge(ageMs) {
40
+ if (!ageMs) return 'unknown';
41
+ const s = Math.floor(ageMs / 1000);
42
+ if (s < 60) return `${s}s ago`;
43
+ const m = Math.floor(s / 60);
44
+ if (m < 60) return `${m}m ago`;
45
+ const h = Math.floor(m / 60);
46
+ if (h < 24) return `${h}h ago`;
47
+ const d = Math.floor(h / 24);
48
+ if (d < 30) return `${d}d ago`;
49
+ return `${Math.floor(d / 30)}mo ago`;
50
+ }
51
+
39
52
  function generateTerminalReport(analysis) {
40
53
  const { summary, findings, sessions } = analysis;
41
54
  const R = '\x1b[0m';
@@ -78,12 +91,22 @@ function generateTerminalReport(analysis) {
78
91
  if (sessions?.length > 0) {
79
92
  console.log(`${C}โ”โ”โ” Top Sessions by Token Usage โ”โ”โ”${R}\n`);
80
93
  const sorted = [...sessions].sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens)).slice(0, 8);
81
- console.log(` ${D}${'Session'.padEnd(42)} ${'Model'.padEnd(22)} ${'Tokens'.padEnd(10)} Cost${R}`);
82
- console.log(` ${D}${'โ”€'.repeat(85)}${R}`);
94
+ console.log(` ${D}${'Session'.padEnd(16)} ${'Model'.padEnd(20)} ${'Tokens'.padEnd(10)} ${'Total Cost'.padEnd(12)} ${'$/day'.padEnd(10)} Last Active${R}`);
95
+ console.log(` ${D}${'โ”€'.repeat(95)}${R}`);
83
96
  for (const s of sorted) {
84
- const tok = (s.inputTokens + s.outputTokens).toLocaleString();
85
- const flag = s.isOrphaned ? ' โš ๏ธ' : '';
86
- console.log(` ${(s.key + flag).slice(0, 42).padEnd(42)} ${(s.modelLabel || s.model || 'unknown').slice(0, 22).padEnd(22)} ${tok.padEnd(10)} $${s.cost.toFixed(6)}`);
97
+ const tok = (s.inputTokens + s.outputTokens).toLocaleString();
98
+ const flag = s.isOrphaned ? ' โš ๏ธ' : '';
99
+ const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + 'โ€ฆ' : s.key;
100
+ const age = s.ageMs ? `${relativeAge(s.ageMs)} (${new Date(s.updatedAt).toLocaleDateString()})` : 'unknown';
101
+ const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}` : 'โ€”';
102
+ console.log(` ${(keyDisplay + flag).padEnd(16)} ${(s.modelLabel || s.model || 'unknown').slice(0, 20).padEnd(20)} ${tok.padEnd(10)} $${s.cost.toFixed(6).padEnd(11)} ${daily.padEnd(10)} ${D}${age}${R}`);
103
+ }
104
+
105
+ // Daily burn rate summary
106
+ const totalDailyRate = sessions.filter(s => s.dailyCost).reduce((sum, s) => sum + s.dailyCost, 0);
107
+ if (totalDailyRate > 0) {
108
+ console.log();
109
+ console.log(` ${D}Combined burn rate: ${R}${RED}$${totalDailyRate.toFixed(4)}/day${R}${D} ยท ~$${(totalDailyRate * 30).toFixed(2)}/month${R}`);
87
110
  }
88
111
  console.log();
89
112
  }
@@ -93,7 +116,7 @@ function generateTerminalReport(analysis) {
93
116
  console.log(` ๐Ÿ”ด ${RED}${summary.critical}${R} critical ๐ŸŸ  ${summary.high} high ๐ŸŸก ${summary.medium} medium ๐Ÿ”ต ${summary.low||0} low โœ… ${summary.info} ok`);
94
117
  console.log(` Sessions analyzed: ${summary.sessionsAnalyzed} ยท Tokens found: ${(summary.totalTokensFound||0).toLocaleString()}`);
95
118
  if (bleed > 0) {
96
- console.log(` ${RED}${B}Monthly bleed: $${bleed.toFixed(2)}/month${R}`);
119
+ console.log(` ${RED}${B}Estimated monthly bleed: $${bleed.toFixed(2)}/month${R}`);
97
120
  } else {
98
121
  console.log(` ${GRN}No significant cost bleed detected โœ“${R}`);
99
122
  }