clawculator 2.1.4 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clawculator-v2.2.0.patch +1233 -0
- package/package.json +1 -1
- package/skills/clawculator/analyzer.js +327 -13
- package/skills/clawculator/htmlReport.js +25 -2
- package/skills/clawculator/mdReport.js +8 -0
- package/skills/clawculator/reporter.js +9 -0
- package/src/analyzer.js +326 -12
- package/src/clawculator.js +123 -0
- package/src/htmlReport.js +25 -2
- package/src/mdReport.js +8 -0
- package/src/reporter.js +9 -0
|
@@ -0,0 +1,1233 @@
|
|
|
1
|
+
diff --git a/skills/clawculator/analyzer.js b/skills/clawculator/analyzer.js
|
|
2
|
+
index 2d640a9..dfe3ab6 100644
|
|
3
|
+
--- a/skills/clawculator/analyzer.js
|
|
4
|
+
+++ b/skills/clawculator/analyzer.js
|
|
5
|
+
@@ -347,8 +347,8 @@ function analyzeConfig(configPath) {
|
|
6
|
+
const hooks = config.hooks?.internal?.entries || config.hooks || {};
|
|
7
|
+
const hookNames = Object.keys(hooks).filter(k => k !== 'enabled' && k !== 'token' && k !== 'path');
|
|
8
|
+
let hookIssues = 0;
|
|
9
|
+
-
|
|
10
|
+
let haikuHooks = 0;
|
|
11
|
+
+
|
|
12
|
+
for (const name of hookNames) {
|
|
13
|
+
const hook = typeof hooks[name] === 'object' ? hooks[name] : {};
|
|
14
|
+
if (hook.enabled === false) continue;
|
|
15
|
+
@@ -500,14 +500,110 @@ function analyzeConfig(configPath) {
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Session analysis ──────────────────────────────────────────────
|
|
19
|
+
+
|
|
20
|
+
+/**
|
|
21
|
+
+ * Parse a .jsonl session transcript file and sum up real usage/cost data.
|
|
22
|
+
+ * Returns { input, output, cacheRead, cacheWrite, totalTokens, totalCost, messageCount, model, firstTs, lastTs }
|
|
23
|
+
+ */
|
|
24
|
+
+function parseTranscript(jsonlPath) {
|
|
25
|
+
+ try {
|
|
26
|
+
+ const content = fs.readFileSync(jsonlPath, 'utf8').trim();
|
|
27
|
+
+ if (!content) return null;
|
|
28
|
+
+
|
|
29
|
+
+ let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, totalTokens = 0, totalCost = 0;
|
|
30
|
+
+ let messageCount = 0, model = null, firstTs = null, lastTs = null;
|
|
31
|
+
+
|
|
32
|
+
+ for (const line of content.split('\n')) {
|
|
33
|
+
+ if (!line.trim()) continue;
|
|
34
|
+
+ let entry;
|
|
35
|
+
+ try { entry = JSON.parse(line); } catch { continue; }
|
|
36
|
+
+
|
|
37
|
+
+ // Track timestamps from all message types
|
|
38
|
+
+ const ts = entry.timestamp || entry.message?.timestamp;
|
|
39
|
+
+ if (ts) {
|
|
40
|
+
+ const t = typeof ts === 'number' ? ts : new Date(ts).getTime();
|
|
41
|
+
+ if (!firstTs || t < firstTs) firstTs = t;
|
|
42
|
+
+ if (!lastTs || t > lastTs) lastTs = t;
|
|
43
|
+
+ }
|
|
44
|
+
+
|
|
45
|
+
+ // Only assistant messages with usage blocks have cost data
|
|
46
|
+
+ if (entry.type !== 'message') continue;
|
|
47
|
+
+ if (!entry.usage) continue;
|
|
48
|
+
+
|
|
49
|
+
+ messageCount++;
|
|
50
|
+
+ const u = entry.usage;
|
|
51
|
+
+
|
|
52
|
+
+ // Use model from transcript (most accurate)
|
|
53
|
+
+ if (entry.model && !model) model = entry.model;
|
|
54
|
+
+
|
|
55
|
+
+ input += u.input || 0;
|
|
56
|
+
+ output += u.output || 0;
|
|
57
|
+
+ cacheRead += u.cacheRead || 0;
|
|
58
|
+
+ cacheWrite += u.cacheWrite || 0;
|
|
59
|
+
+ totalTokens += u.totalTokens || 0;
|
|
60
|
+
+
|
|
61
|
+
+ // Prefer API-reported cost (already accounts for cache pricing)
|
|
62
|
+
+ if (u.cost) {
|
|
63
|
+
+ if (typeof u.cost === 'object' && u.cost.total != null) {
|
|
64
|
+
+ totalCost += u.cost.total;
|
|
65
|
+
+ } else if (typeof u.cost === 'number') {
|
|
66
|
+
+ totalCost += u.cost;
|
|
67
|
+
+ }
|
|
68
|
+
+ }
|
|
69
|
+
+ }
|
|
70
|
+
+
|
|
71
|
+
+ if (messageCount === 0) return null;
|
|
72
|
+
+
|
|
73
|
+
+ return { input, output, cacheRead, cacheWrite, totalTokens, totalCost, messageCount, model, firstTs, lastTs };
|
|
74
|
+
+ } catch {
|
|
75
|
+
+ return null;
|
|
76
|
+
+ }
|
|
77
|
+
+}
|
|
78
|
+
+
|
|
79
|
+
+/**
|
|
80
|
+
+ * Discover all agent session directories (not just main).
|
|
81
|
+
+ */
|
|
82
|
+
+function discoverAgentDirs() {
|
|
83
|
+
+ const agentsDir = path.join(os.homedir(), '.openclaw', 'agents');
|
|
84
|
+
+ const dirs = [];
|
|
85
|
+
+ try {
|
|
86
|
+
+ for (const agent of fs.readdirSync(agentsDir)) {
|
|
87
|
+
+ const sessionsDir = path.join(agentsDir, agent, 'sessions');
|
|
88
|
+
+ if (fs.existsSync(sessionsDir) && fs.statSync(sessionsDir).isDirectory()) {
|
|
89
|
+
+ dirs.push({ agent, sessionsDir });
|
|
90
|
+
+ }
|
|
91
|
+
+ }
|
|
92
|
+
+ } catch { /* agents dir doesn't exist */ }
|
|
93
|
+
+ return dirs;
|
|
94
|
+
+}
|
|
95
|
+
+
|
|
96
|
+
+/**
|
|
97
|
+
+ * Discover web-chat session transcripts.
|
|
98
|
+
+ */
|
|
99
|
+
+function discoverWebChatSessions() {
|
|
100
|
+
+ const webChatDir = path.join(os.homedir(), '.openclaw', 'web-chat');
|
|
101
|
+
+ const sessions = [];
|
|
102
|
+
+ try {
|
|
103
|
+
+ for (const file of fs.readdirSync(webChatDir)) {
|
|
104
|
+
+ if (file.endsWith('.jsonl')) {
|
|
105
|
+
+ sessions.push(path.join(webChatDir, file));
|
|
106
|
+
+ }
|
|
107
|
+
+ }
|
|
108
|
+
+ } catch { /* web-chat dir doesn't exist */ }
|
|
109
|
+
+ return sessions;
|
|
110
|
+
+}
|
|
111
|
+
+
|
|
112
|
+
function analyzeSessions(sessionsPath) {
|
|
113
|
+
const findings = [];
|
|
114
|
+
const sessions = readJSON(sessionsPath);
|
|
115
|
+
+ const sessionsDir = sessionsPath ? path.dirname(sessionsPath) : null;
|
|
116
|
+
|
|
117
|
+
- if (!sessions) return { exists: false, findings: [], sessions: [], totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0, sessionCount: 0 };
|
|
118
|
+
+ if (!sessions) return { exists: false, findings: [], sessions: [], totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0, totalCacheRead: 0, totalCacheWrite: 0, totalRealCost: 0, sessionCount: 0 };
|
|
119
|
+
|
|
120
|
+
let totalIn = 0, totalOut = 0, totalCost = 0;
|
|
121
|
+
+ let totalCacheRead = 0, totalCacheWrite = 0, totalRealCost = 0;
|
|
122
|
+
const breakdown = [], orphaned = [], large = [];
|
|
123
|
+
+ let transcriptHits = 0, transcriptMisses = 0;
|
|
124
|
+
|
|
125
|
+
for (const key of Object.keys(sessions)) {
|
|
126
|
+
const s = sessions[key];
|
|
127
|
+
@@ -515,31 +611,121 @@ function analyzeSessions(sessionsPath) {
|
|
128
|
+
const modelKey = resolveModel(model);
|
|
129
|
+
const inTok = s.inputTokens || s.tokensIn || s.tokens?.input || 0;
|
|
130
|
+
const outTok = s.outputTokens || s.tokensOut || s.tokens?.output || 0;
|
|
131
|
+
- const cost = costPerCall(modelKey, inTok, outTok);
|
|
132
|
+
+ const estimatedCost = costPerCall(modelKey, inTok, outTok);
|
|
133
|
+
const updatedAt = s.updatedAt || s.lastActive || null;
|
|
134
|
+
|
|
135
|
+
- totalIn += inTok;
|
|
136
|
+
- totalOut += outTok;
|
|
137
|
+
- totalCost += cost;
|
|
138
|
+
+ // Try to load transcript for real cost data
|
|
139
|
+
+ let transcript = null;
|
|
140
|
+
+ if (sessionsDir) {
|
|
141
|
+
+ const jsonlPath = path.join(sessionsDir, `${key}.jsonl`);
|
|
142
|
+
+ transcript = parseTranscript(jsonlPath);
|
|
143
|
+
+ }
|
|
144
|
+
+
|
|
145
|
+
+ // Use transcript data when available, fall back to sessions.json estimates
|
|
146
|
+
+ let realIn, realOut, realCacheRead, realCacheWrite, realCost, realModel, realTotalTokens;
|
|
147
|
+
+ if (transcript) {
|
|
148
|
+
+ transcriptHits++;
|
|
149
|
+
+ realIn = transcript.input;
|
|
150
|
+
+ realOut = transcript.output;
|
|
151
|
+
+ realCacheRead = transcript.cacheRead;
|
|
152
|
+
+ realCacheWrite = transcript.cacheWrite;
|
|
153
|
+
+ realCost = transcript.totalCost;
|
|
154
|
+
+ realModel = transcript.model || model;
|
|
155
|
+
+ realTotalTokens = transcript.totalTokens;
|
|
156
|
+
+ } else {
|
|
157
|
+
+ transcriptMisses++;
|
|
158
|
+
+ realIn = inTok;
|
|
159
|
+
+ realOut = outTok;
|
|
160
|
+
+ realCacheRead = 0;
|
|
161
|
+
+ realCacheWrite = 0;
|
|
162
|
+
+ realCost = estimatedCost;
|
|
163
|
+
+ realModel = model;
|
|
164
|
+
+ realTotalTokens = inTok + outTok;
|
|
165
|
+
+ }
|
|
166
|
+
+
|
|
167
|
+
+ totalIn += realIn;
|
|
168
|
+
+ totalOut += realOut;
|
|
169
|
+
+ totalCacheRead += realCacheRead;
|
|
170
|
+
+ totalCacheWrite += realCacheWrite;
|
|
171
|
+
+ totalCost += estimatedCost;
|
|
172
|
+
+ totalRealCost += realCost;
|
|
173
|
+
+
|
|
174
|
+
+ const allTokens = realTotalTokens || (realIn + realOut + realCacheRead + realCacheWrite);
|
|
175
|
+
|
|
176
|
+
const isOrphaned = key.includes('cron') || key.includes('deleted') ||
|
|
177
|
+
(updatedAt && Date.now() - new Date(updatedAt).getTime() > 48 * 3600 * 1000 && !key.includes('main'));
|
|
178
|
+
|
|
179
|
+
- if (isOrphaned) orphaned.push({ key, model, tokens: inTok + outTok, cost });
|
|
180
|
+
- if (inTok + outTok > 50000) large.push({ key, model, tokens: inTok + outTok });
|
|
181
|
+
+ if (isOrphaned) orphaned.push({ key, model: realModel, tokens: allTokens, cost: realCost });
|
|
182
|
+
+ if (allTokens > 50000) large.push({ key, model: realModel, tokens: allTokens });
|
|
183
|
+
|
|
184
|
+
const ageMs = updatedAt ? Date.now() - new Date(updatedAt).getTime() : null;
|
|
185
|
+
const ageDays = ageMs ? ageMs / (1000 * 3600 * 24) : null;
|
|
186
|
+
- const dailyCost = (ageDays && ageDays > 0.01 && cost > 0) ? cost / ageDays : null;
|
|
187
|
+
-
|
|
188
|
+
- breakdown.push({ key, model, modelLabel: modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown', inputTokens: inTok, outputTokens: outTok, cost, updatedAt, ageMs, dailyCost, isOrphaned });
|
|
189
|
+
+ const dailyCost = (ageDays && ageDays > 0.01 && realCost > 0) ? realCost / ageDays : null;
|
|
190
|
+
+
|
|
191
|
+
+ const realModelKey = resolveModel(realModel);
|
|
192
|
+
+ breakdown.push({
|
|
193
|
+
+ key, model: realModel,
|
|
194
|
+
+ modelLabel: realModelKey ? (MODEL_PRICING[realModelKey]?.label || realModelKey) : (modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown'),
|
|
195
|
+
+ inputTokens: realIn, outputTokens: realOut,
|
|
196
|
+
+ cacheRead: realCacheRead, cacheWrite: realCacheWrite,
|
|
197
|
+
+ cost: realCost, estimatedCost,
|
|
198
|
+
+ hasTranscript: !!transcript,
|
|
199
|
+
+ messageCount: transcript?.messageCount || 0,
|
|
200
|
+
+ updatedAt, ageMs, dailyCost, isOrphaned,
|
|
201
|
+
+ });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
+ // Findings
|
|
205
|
+
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 });
|
|
206
|
+
if (large.length > 0) findings.push({ severity: 'medium', source: 'sessions', message: `${large.length} session(s) with >50k tokens per conversation`, detail: large.map(s => `${s.key}: ${s.tokens.toLocaleString()} tokens`).join('\n '), ...FIXES.LARGE_SESSIONS });
|
|
207
|
+
if (Object.keys(sessions).length > 0 && !orphaned.length && !large.length) findings.push({ severity: 'info', source: 'sessions', message: `${Object.keys(sessions).length} session(s) healthy ✓`, detail: `Total tokens: ${(totalIn + totalOut).toLocaleString()}` });
|
|
208
|
+
|
|
209
|
+
- return { exists: true, findings, sessions: breakdown, totalInputTokens: totalIn, totalOutputTokens: totalOut, totalCost, sessionCount: Object.keys(sessions).length };
|
|
210
|
+
+ // Cache cost finding
|
|
211
|
+
+ if (totalCacheWrite > 0 || totalCacheRead > 0) {
|
|
212
|
+
+ const cacheDetail = [];
|
|
213
|
+
+ if (totalCacheRead > 0) cacheDetail.push(`Cache reads: ${totalCacheRead.toLocaleString()} tokens`);
|
|
214
|
+
+ if (totalCacheWrite > 0) cacheDetail.push(`Cache writes: ${totalCacheWrite.toLocaleString()} tokens`);
|
|
215
|
+
+ const cacheCostPortion = totalRealCost - totalCost;
|
|
216
|
+
+ if (cacheCostPortion > 0.01) {
|
|
217
|
+
+ findings.push({
|
|
218
|
+
+ severity: cacheCostPortion > 1 ? 'high' : 'medium',
|
|
219
|
+
+ source: 'sessions',
|
|
220
|
+
+ message: `Prompt caching added $${cacheCostPortion.toFixed(4)} beyond base token costs`,
|
|
221
|
+
+ detail: cacheDetail.join('\n ') + `\n Cache writes are 3.75x input cost · Cache reads are 0.1x input cost`,
|
|
222
|
+
+ monthlyCost: 0, // already counted in session costs, not a recurring config bleed
|
|
223
|
+
+ });
|
|
224
|
+
+ } else {
|
|
225
|
+
+ findings.push({ severity: 'info', source: 'sessions', message: `Prompt caching active — ${(totalCacheRead + totalCacheWrite).toLocaleString()} cache tokens tracked`, detail: cacheDetail.join(' · ') });
|
|
226
|
+
+ }
|
|
227
|
+
+ }
|
|
228
|
+
+
|
|
229
|
+
+ // Transcript coverage finding
|
|
230
|
+
+ if (transcriptHits > 0 || transcriptMisses > 0) {
|
|
231
|
+
+ const total = transcriptHits + transcriptMisses;
|
|
232
|
+
+ if (transcriptMisses > 0 && transcriptHits > 0) {
|
|
233
|
+
+ findings.push({ severity: 'info', source: 'sessions', message: `Transcript data: ${transcriptHits}/${total} sessions have .jsonl transcripts (${transcriptMisses} estimated)`, detail: `Sessions with transcripts show real API-reported costs including cache tokens` });
|
|
234
|
+
+ }
|
|
235
|
+
+ }
|
|
236
|
+
+
|
|
237
|
+
+ // Cost discrepancy finding
|
|
238
|
+
+ if (totalRealCost > 0 && totalCost > 0) {
|
|
239
|
+
+ const ratio = totalRealCost / totalCost;
|
|
240
|
+
+ if (ratio > 2) {
|
|
241
|
+
+ findings.push({
|
|
242
|
+
+ severity: 'high',
|
|
243
|
+
+ source: 'sessions',
|
|
244
|
+
+ message: `Real cost $${totalRealCost.toFixed(4)} is ${ratio.toFixed(1)}x higher than sessions.json estimate ($${totalCost.toFixed(4)})`,
|
|
245
|
+
+ detail: `sessions.json only tracks input/output tokens — cache tokens, which are the bulk of real spending, are only in .jsonl transcripts`,
|
|
246
|
+
+ });
|
|
247
|
+
+ }
|
|
248
|
+
+ }
|
|
249
|
+
+
|
|
250
|
+
+ return {
|
|
251
|
+
+ exists: true, findings, sessions: breakdown,
|
|
252
|
+
+ totalInputTokens: totalIn, totalOutputTokens: totalOut,
|
|
253
|
+
+ totalCost, totalCacheRead, totalCacheWrite, totalRealCost,
|
|
254
|
+
+ sessionCount: Object.keys(sessions).length,
|
|
255
|
+
+ };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Workspace analysis ────────────────────────────────────────────
|
|
259
|
+
@@ -568,12 +754,64 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
|
|
260
|
+
const sessionResult = analyzeSessions(sessionsPath);
|
|
261
|
+
const workspaceResult = analyzeWorkspace();
|
|
262
|
+
|
|
263
|
+
- const allFindings = [...configResult.findings, ...sessionResult.findings, ...workspaceResult.findings];
|
|
264
|
+
+ // Scan additional agent folders beyond main
|
|
265
|
+
+ const agentDirs = discoverAgentDirs();
|
|
266
|
+
+ const additionalAgentSessions = [];
|
|
267
|
+
+ for (const { agent, sessionsDir } of agentDirs) {
|
|
268
|
+
+ const sjPath = path.join(sessionsDir, 'sessions.json');
|
|
269
|
+
+ // Skip the primary sessions path (already analyzed above)
|
|
270
|
+
+ if (sjPath === sessionsPath) continue;
|
|
271
|
+
+ if (fs.existsSync(sjPath)) {
|
|
272
|
+
+ const extra = analyzeSessions(sjPath);
|
|
273
|
+
+ if (extra.exists) {
|
|
274
|
+
+ additionalAgentSessions.push({ agent, ...extra });
|
|
275
|
+
+ }
|
|
276
|
+
+ }
|
|
277
|
+
+ }
|
|
278
|
+
+
|
|
279
|
+
+ // Scan web-chat sessions
|
|
280
|
+
+ const webChatPaths = discoverWebChatSessions();
|
|
281
|
+
+ const webChatSessions = [];
|
|
282
|
+
+ for (const wcp of webChatPaths) {
|
|
283
|
+
+ const transcript = parseTranscript(wcp);
|
|
284
|
+
+ if (transcript) {
|
|
285
|
+
+ const sessionId = path.basename(wcp, '.jsonl');
|
|
286
|
+
+ webChatSessions.push({ key: `web-chat/${sessionId}`, ...transcript });
|
|
287
|
+
+ }
|
|
288
|
+
+ }
|
|
289
|
+
+
|
|
290
|
+
+ // Merge all findings
|
|
291
|
+
+ const allFindings = [
|
|
292
|
+
+ ...configResult.findings,
|
|
293
|
+
+ ...sessionResult.findings,
|
|
294
|
+
+ ...workspaceResult.findings,
|
|
295
|
+
+ ];
|
|
296
|
+
+
|
|
297
|
+
+ // Add additional agent findings
|
|
298
|
+
+ for (const extra of additionalAgentSessions) {
|
|
299
|
+
+ if (extra.findings.length > 0) {
|
|
300
|
+
+ allFindings.push({ severity: 'info', source: 'sessions', message: `Agent "${extra.agent}": ${extra.sessionCount} session(s) found`, detail: `Tokens: ${(extra.totalInputTokens + extra.totalOutputTokens).toLocaleString()} · Cost: $${extra.totalRealCost.toFixed(4)}` });
|
|
301
|
+
+ }
|
|
302
|
+
+ // Merge their sessions into the main breakdown
|
|
303
|
+
+ sessionResult.sessions.push(...(extra.sessions || []));
|
|
304
|
+
+ sessionResult.totalRealCost += extra.totalRealCost || 0;
|
|
305
|
+
+ sessionResult.totalCacheRead += extra.totalCacheRead || 0;
|
|
306
|
+
+ sessionResult.totalCacheWrite += extra.totalCacheWrite || 0;
|
|
307
|
+
+ }
|
|
308
|
+
+
|
|
309
|
+
+ // Add web-chat finding if any
|
|
310
|
+
+ if (webChatSessions.length > 0) {
|
|
311
|
+
+ const wcTotal = webChatSessions.reduce((sum, s) => sum + s.totalCost, 0);
|
|
312
|
+
+ const wcTokens = webChatSessions.reduce((sum, s) => sum + s.totalTokens, 0);
|
|
313
|
+
+ allFindings.push({ severity: 'info', source: 'sessions', message: `${webChatSessions.length} web-chat session(s) found`, detail: `Tokens: ${wcTokens.toLocaleString()} · Cost: $${wcTotal.toFixed(4)}` });
|
|
314
|
+
+ }
|
|
315
|
+
|
|
316
|
+
const estimatedMonthlyBleed = allFindings
|
|
317
|
+
.filter(f => f.monthlyCost && f.severity !== 'info')
|
|
318
|
+
.reduce((sum, f) => sum + f.monthlyCost, 0);
|
|
319
|
+
|
|
320
|
+
+ const realCost = sessionResult.totalRealCost || 0;
|
|
321
|
+
+
|
|
322
|
+
return {
|
|
323
|
+
scannedAt: new Date().toISOString(),
|
|
324
|
+
configPath,
|
|
325
|
+
@@ -589,10 +827,15 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
|
|
326
|
+
estimatedMonthlyBleed,
|
|
327
|
+
sessionsAnalyzed: sessionResult.sessionCount,
|
|
328
|
+
totalTokensFound: (sessionResult.totalInputTokens || 0) + (sessionResult.totalOutputTokens || 0),
|
|
329
|
+
+ totalCacheRead: sessionResult.totalCacheRead || 0,
|
|
330
|
+
+ totalCacheWrite: sessionResult.totalCacheWrite || 0,
|
|
331
|
+
+ totalRealCost: realCost,
|
|
332
|
+
+ totalEstimatedCost: sessionResult.totalCost || 0,
|
|
333
|
+
},
|
|
334
|
+
sessions: sessionResult.sessions || [],
|
|
335
|
+
+ webChatSessions,
|
|
336
|
+
config: configResult.config,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
-module.exports = { runAnalysis, MODEL_PRICING, resolveModel, costPerCall };
|
|
341
|
+
+module.exports = { runAnalysis, MODEL_PRICING, resolveModel, costPerCall, parseTranscript, discoverAgentDirs, discoverWebChatSessions };
|
|
342
|
+
diff --git a/skills/clawculator/htmlReport.js b/skills/clawculator/htmlReport.js
|
|
343
|
+
index 6b944d1..87a692e 100644
|
|
344
|
+
--- a/skills/clawculator/htmlReport.js
|
|
345
|
+
+++ b/skills/clawculator/htmlReport.js
|
|
346
|
+
@@ -5,15 +5,15 @@ const path = require('path');
|
|
347
|
+
const os = require('os');
|
|
348
|
+
|
|
349
|
+
function severityColor(severity) {
|
|
350
|
+
- return { critical: '#ef4444', high: '#f97316', medium: '#eab308', info: '#22c55e' }[severity] || '#6b7280';
|
|
351
|
+
+ return { critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#06b6d4', info: '#22c55e' }[severity] || '#6b7280';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function severityBg(severity) {
|
|
355
|
+
- return { critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', info: '#f0fdf4' }[severity] || '#f9fafb';
|
|
356
|
+
+ return { critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', low: '#ecfeff', info: '#f0fdf4' }[severity] || '#f9fafb';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function severityIcon(severity) {
|
|
360
|
+
- return { critical: '🔴', high: '🟠', medium: '🟡', info: '✅' }[severity] || '⚪';
|
|
361
|
+
+ return { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵', info: '✅' }[severity] || '⚪';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function relativeAge(ageMs) {
|
|
365
|
+
@@ -40,6 +40,25 @@ async function generateHTMLReport(analysis, outPath) {
|
|
366
|
+
const { summary, findings, sessions } = analysis;
|
|
367
|
+
const bleed = summary.estimatedMonthlyBleed;
|
|
368
|
+
|
|
369
|
+
+ // Burn summary calculation
|
|
370
|
+
+ const activeSessions = (sessions || []).filter(s => s.dailyCost);
|
|
371
|
+
+ const totalDaily = activeSessions.reduce((sum, s) => sum + (s.dailyCost || 0), 0);
|
|
372
|
+
+ const totalMonthly = totalDaily * 30;
|
|
373
|
+
+ const burnSummary = totalDaily > 0 ? `
|
|
374
|
+
+ <div class="section">
|
|
375
|
+
+ <div class="section-title">Session Burn Rate</div>
|
|
376
|
+
+ <div style="display:flex; gap:32px; flex-wrap:wrap;">
|
|
377
|
+
+ <div>
|
|
378
|
+
+ <div style="font-size:24px; font-weight:800; color:#f59e0b;">$${totalDaily.toFixed(4)}/day</div>
|
|
379
|
+
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Combined daily burn rate across ${activeSessions.length} active session${activeSessions.length !== 1 ? 's' : ''}</div>
|
|
380
|
+
+ </div>
|
|
381
|
+
+ <div>
|
|
382
|
+
+ <div style="font-size:24px; font-weight:800; color:#f97316;">$${totalMonthly.toFixed(2)}/month</div>
|
|
383
|
+
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Projected monthly from active sessions</div>
|
|
384
|
+
+ </div>
|
|
385
|
+
+ </div>
|
|
386
|
+
+ </div>` : '';
|
|
387
|
+
+
|
|
388
|
+
const findingCards = findings.map(f => `
|
|
389
|
+
<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;">
|
|
390
|
+
<div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
|
|
391
|
+
@@ -60,14 +79,16 @@ async function generateHTMLReport(analysis, outPath) {
|
|
392
|
+
.map(s => {
|
|
393
|
+
const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '…' : s.key;
|
|
394
|
+
const flag = s.isOrphaned ? ' ⚠️' : '';
|
|
395
|
+
+ const txBadge = s.hasTranscript ? '<span style="color:#22c55e; font-size:10px; margin-left:4px" title="Cost from .jsonl transcript (API-reported)">●</span>' : '<span style="color:#6b7280; font-size:10px; margin-left:4px" title="Estimated cost (no transcript found)">○</span>';
|
|
396
|
+
const age = s.ageMs ? relativeAge(s.ageMs) : 'unknown';
|
|
397
|
+
const absDate = s.updatedAt ? new Date(s.updatedAt).toLocaleString() : '';
|
|
398
|
+
const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}/day` : '—';
|
|
399
|
+
+ const cacheTitle = (s.cacheRead || s.cacheWrite) ? `Cache R: ${(s.cacheRead||0).toLocaleString()} · W: ${(s.cacheWrite||0).toLocaleString()}` : '';
|
|
400
|
+
return `
|
|
401
|
+
<tr style="${s.isOrphaned ? 'background:#fff7ed' : ''}">
|
|
402
|
+
- <td style="padding:8px 12px; font-family:monospace; font-size:13px">${keyDisplay}${flag}</td>
|
|
403
|
+
+ <td style="padding:8px 12px; font-family:monospace; font-size:13px">${keyDisplay}${flag}${txBadge}</td>
|
|
404
|
+
<td style="padding:8px 12px">${s.modelLabel || s.model}</td>
|
|
405
|
+
- <td style="padding:8px 12px; text-align:right">${(s.inputTokens + s.outputTokens).toLocaleString()}</td>
|
|
406
|
+
+ <td style="padding:8px 12px; text-align:right" title="${cacheTitle}">${(s.inputTokens + s.outputTokens).toLocaleString()}${(s.cacheRead || s.cacheWrite) ? `<span style="color:#6b7280; font-size:11px;"> +${((s.cacheRead||0)+(s.cacheWrite||0)).toLocaleString()} cache</span>` : ''}</td>
|
|
407
|
+
<td style="padding:8px 12px; text-align:right; color:${s.cost > 0.01 ? '#ef4444' : '#22c55e'}">$${s.cost.toFixed(6)}</td>
|
|
408
|
+
<td style="padding:8px 12px; text-align:right; color:#f59e0b">${daily}</td>
|
|
409
|
+
<td style="padding:8px 12px; color:#6b7280; font-size:13px" title="${absDate}">${age}</td>
|
|
410
|
+
@@ -87,7 +108,7 @@ async function generateHTMLReport(analysis, outPath) {
|
|
411
|
+
.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; }
|
|
412
|
+
.tagline { color: #94a3b8; margin-top: 8px; font-size: 16px; }
|
|
413
|
+
.container { max-width: 1000px; margin: 0 auto; padding: 32px 24px; }
|
|
414
|
+
- .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
|
415
|
+
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
|
416
|
+
.card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; }
|
|
417
|
+
.card-value { font-size: 32px; font-weight: 800; }
|
|
418
|
+
.card-label { font-size: 13px; color: #94a3b8; margin-top: 4px; }
|
|
419
|
+
@@ -119,6 +140,27 @@ async function generateHTMLReport(analysis, outPath) {
|
|
420
|
+
<div style="color:#86efac; font-size:18px; font-weight:700">✅ No significant cost bleed detected</div>
|
|
421
|
+
</div>`}
|
|
422
|
+
|
|
423
|
+
+ ${summary.totalRealCost > 0 ? `
|
|
424
|
+
+ <div class="section" style="border:1px solid #f59e0b;">
|
|
425
|
+
+ <div class="section-title" style="color:#f59e0b;">💰 Actual API Spend (from transcripts)</div>
|
|
426
|
+
+ <div style="display:flex; gap:32px; flex-wrap:wrap; align-items:center;">
|
|
427
|
+
+ <div>
|
|
428
|
+
+ <div style="font-size:36px; font-weight:900; color:#fbbf24;">$${summary.totalRealCost.toFixed(4)}</div>
|
|
429
|
+
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Total real cost (API-reported)</div>
|
|
430
|
+
+ </div>
|
|
431
|
+
+ ${summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1 ? `
|
|
432
|
+
+ <div style="background:#451a03; padding:10px 16px; border-radius:8px; border:1px solid #92400e;">
|
|
433
|
+
+ <div style="font-size:14px; color:#fbbf24; font-weight:600;">${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x higher than sessions.json estimate</div>
|
|
434
|
+
+ <div style="font-size:12px; color:#d97706; margin-top:2px;">sessions.json: $${summary.totalEstimatedCost.toFixed(4)} — missing cache token costs</div>
|
|
435
|
+
+ </div>` : ''}
|
|
436
|
+
+ </div>
|
|
437
|
+
+ ${summary.totalCacheRead > 0 || summary.totalCacheWrite > 0 ? `
|
|
438
|
+
+ <div style="margin-top:16px; display:flex; gap:24px; flex-wrap:wrap;">
|
|
439
|
+
+ ${summary.totalCacheWrite > 0 ? `<div style="color:#f97316; font-size:14px;">📝 Cache writes: <strong>${summary.totalCacheWrite.toLocaleString()}</strong> tokens</div>` : ''}
|
|
440
|
+
+ ${summary.totalCacheRead > 0 ? `<div style="color:#22c55e; font-size:14px;">📖 Cache reads: <strong>${summary.totalCacheRead.toLocaleString()}</strong> tokens</div>` : ''}
|
|
441
|
+
+ </div>` : ''}
|
|
442
|
+
+ </div>` : ''}
|
|
443
|
+
+
|
|
444
|
+
<div class="cards">
|
|
445
|
+
<div class="card">
|
|
446
|
+
<div class="card-value" style="color:#ef4444">${summary.critical}</div>
|
|
447
|
+
@@ -132,6 +174,10 @@ async function generateHTMLReport(analysis, outPath) {
|
|
448
|
+
<div class="card-value" style="color:#eab308">${summary.medium}</div>
|
|
449
|
+
<div class="card-label">🟡 Medium</div>
|
|
450
|
+
</div>
|
|
451
|
+
+ <div class="card">
|
|
452
|
+
+ <div class="card-value" style="color:#06b6d4">${summary.low || 0}</div>
|
|
453
|
+
+ <div class="card-label">🔵 Low</div>
|
|
454
|
+
+ </div>
|
|
455
|
+
<div class="card">
|
|
456
|
+
<div class="card-value" style="color:#22c55e">${summary.info}</div>
|
|
457
|
+
<div class="card-label">✅ OK</div>
|
|
458
|
+
@@ -162,7 +208,8 @@ async function generateHTMLReport(analysis, outPath) {
|
|
459
|
+
<tbody>${sessionRows}</tbody>
|
|
460
|
+
</table>
|
|
461
|
+
</div>
|
|
462
|
+
- </div>` : ''}
|
|
463
|
+
+ </div>
|
|
464
|
+
+ ${burnSummary}` : ''}
|
|
465
|
+
|
|
466
|
+
</div>
|
|
467
|
+
<div class="footer">
|
|
468
|
+
@@ -172,7 +219,7 @@ async function generateHTMLReport(analysis, outPath) {
|
|
469
|
+
</html>`;
|
|
470
|
+
|
|
471
|
+
if (!outPath) {
|
|
472
|
+
- outPath = path.join(os.tmpdir(), `clawculator-report-${Date.now()}.html`);
|
|
473
|
+
+ outPath = path.join(process.cwd(), `clawculator-report-${Date.now()}.html`);
|
|
474
|
+
}
|
|
475
|
+
fs.writeFileSync(outPath, html, 'utf8');
|
|
476
|
+
return outPath;
|
|
477
|
+
diff --git a/skills/clawculator/mdReport.js b/skills/clawculator/mdReport.js
|
|
478
|
+
index 0f8971c..bdab93f 100644
|
|
479
|
+
--- a/skills/clawculator/mdReport.js
|
|
480
|
+
+++ b/skills/clawculator/mdReport.js
|
|
481
|
+
@@ -1,6 +1,6 @@
|
|
482
|
+
'use strict';
|
|
483
|
+
|
|
484
|
+
-const SEVERITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', info: '✅' };
|
|
485
|
+
+const SEVERITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵', info: '✅' };
|
|
486
|
+
const SOURCE_LABELS = {
|
|
487
|
+
heartbeat: '💓 Heartbeat', hooks: '🪝 Hooks', whatsapp: '📱 WhatsApp',
|
|
488
|
+
subagents: '🤖 Subagents', skills: '🔧 Skills', memory: '🧠 Memory',
|
|
489
|
+
@@ -56,6 +56,7 @@ function generateMarkdownReport(analysis) {
|
|
490
|
+
lines.push(`| 🔴 Critical | ${summary.critical} |`);
|
|
491
|
+
lines.push(`| 🟠 High | ${summary.high} |`);
|
|
492
|
+
lines.push(`| 🟡 Medium | ${summary.medium} |`);
|
|
493
|
+
+ lines.push(`| 🔵 Low | ${summary.low || 0} |`);
|
|
494
|
+
lines.push(`| ✅ OK | ${summary.info} |`);
|
|
495
|
+
lines.push(`| Sessions analyzed | ${summary.sessionsAnalyzed} |`);
|
|
496
|
+
lines.push(`| Total tokens found | ${(summary.totalTokensFound || 0).toLocaleString()} |`);
|
|
497
|
+
@@ -68,13 +69,21 @@ function generateMarkdownReport(analysis) {
|
|
498
|
+
if (totalCost > 0) lines.push(`| Total session cost (lifetime) | $${totalCost.toFixed(4)} |`);
|
|
499
|
+
if (totalDailyRate > 0) lines.push(`| Avg daily burn rate | $${totalDailyRate.toFixed(4)}/day (~$${(totalDailyRate * 30).toFixed(2)}/month) |`);
|
|
500
|
+
}
|
|
501
|
+
+ if (summary.totalRealCost > 0) {
|
|
502
|
+
+ lines.push(`| 💰 **Actual API spend** | **$${summary.totalRealCost.toFixed(4)}** |`);
|
|
503
|
+
+ if (summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1) {
|
|
504
|
+
+ lines.push(`| sessions.json estimate | $${summary.totalEstimatedCost.toFixed(4)} (${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x gap) |`);
|
|
505
|
+
+ }
|
|
506
|
+
+ }
|
|
507
|
+
+ if (summary.totalCacheWrite > 0) lines.push(`| Cache writes | ${summary.totalCacheWrite.toLocaleString()} tokens |`);
|
|
508
|
+
+ if (summary.totalCacheRead > 0) lines.push(`| Cache reads | ${summary.totalCacheRead.toLocaleString()} tokens |`);
|
|
509
|
+
lines.push('');
|
|
510
|
+
|
|
511
|
+
// Findings by severity
|
|
512
|
+
lines.push('## Findings');
|
|
513
|
+
lines.push('');
|
|
514
|
+
|
|
515
|
+
- for (const severity of ['critical', 'high', 'medium', 'info']) {
|
|
516
|
+
+ for (const severity of ['critical', 'high', 'medium', 'low', 'info']) {
|
|
517
|
+
const group = findings.filter(f => f.severity === severity);
|
|
518
|
+
if (!group.length) continue;
|
|
519
|
+
|
|
520
|
+
diff --git a/skills/clawculator/reporter.js b/skills/clawculator/reporter.js
|
|
521
|
+
index 8e35519..9e8b694 100644
|
|
522
|
+
--- a/skills/clawculator/reporter.js
|
|
523
|
+
+++ b/skills/clawculator/reporter.js
|
|
524
|
+
@@ -115,6 +115,15 @@ function generateTerminalReport(analysis) {
|
|
525
|
+
console.log(`${C}━━━ Summary ━━━${R}`);
|
|
526
|
+
console.log(` 🔴 ${RED}${summary.critical}${R} critical 🟠 ${summary.high} high 🟡 ${summary.medium} medium 🔵 ${summary.low||0} low ✅ ${summary.info} ok`);
|
|
527
|
+
console.log(` Sessions analyzed: ${summary.sessionsAnalyzed} · Tokens found: ${(summary.totalTokensFound||0).toLocaleString()}`);
|
|
528
|
+
+ if (summary.totalCacheRead > 0 || summary.totalCacheWrite > 0) {
|
|
529
|
+
+ console.log(` ${D}Cache tokens: ${(summary.totalCacheRead||0).toLocaleString()} read · ${(summary.totalCacheWrite||0).toLocaleString()} write${R}`);
|
|
530
|
+
+ }
|
|
531
|
+
+ if (summary.totalRealCost > 0) {
|
|
532
|
+
+ console.log(` ${B}Actual API spend: ${RED}$${summary.totalRealCost.toFixed(4)}${R}${B} (from .jsonl transcripts)${R}`);
|
|
533
|
+
+ if (summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1) {
|
|
534
|
+
+ console.log(` ${D}sessions.json estimate: $${summary.totalEstimatedCost.toFixed(4)} — ${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x gap (cache tokens)${R}`);
|
|
535
|
+
+ }
|
|
536
|
+
+ }
|
|
537
|
+
if (bleed > 0) {
|
|
538
|
+
console.log(` ${RED}${B}Estimated monthly bleed: $${bleed.toFixed(2)}/month${R}`);
|
|
539
|
+
} else {
|
|
540
|
+
diff --git a/src/analyzer.js b/src/analyzer.js
|
|
541
|
+
index 9c5b3b9..dfe3ab6 100644
|
|
542
|
+
--- a/src/analyzer.js
|
|
543
|
+
+++ b/src/analyzer.js
|
|
544
|
+
@@ -143,7 +143,12 @@ function isLocalModel(modelStr) {
|
|
545
|
+
|
|
546
|
+
function isHaikuTier(modelStr) {
|
|
547
|
+
if (!modelStr) return false;
|
|
548
|
+
- return modelStr.toLowerCase().includes('haiku');
|
|
549
|
+
+ const lower = modelStr.toLowerCase();
|
|
550
|
+
+ return lower.includes('haiku');
|
|
551
|
+
+}
|
|
552
|
+
+
|
|
553
|
+
+function isAcceptableForHooks(modelStr) {
|
|
554
|
+
+ return isLocalModel(modelStr) || isHaikuTier(modelStr);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function isOpenRouter(modelStr) {
|
|
558
|
+
@@ -495,14 +500,110 @@ function analyzeConfig(configPath) {
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ── Session analysis ──────────────────────────────────────────────
|
|
562
|
+
+
|
|
563
|
+
+/**
|
|
564
|
+
+ * Parse a .jsonl session transcript file and sum up real usage/cost data.
|
|
565
|
+
+ * Returns { input, output, cacheRead, cacheWrite, totalTokens, totalCost, messageCount, model, firstTs, lastTs }
|
|
566
|
+
+ */
|
|
567
|
+
+function parseTranscript(jsonlPath) {
|
|
568
|
+
+ try {
|
|
569
|
+
+ const content = fs.readFileSync(jsonlPath, 'utf8').trim();
|
|
570
|
+
+ if (!content) return null;
|
|
571
|
+
+
|
|
572
|
+
+ let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, totalTokens = 0, totalCost = 0;
|
|
573
|
+
+ let messageCount = 0, model = null, firstTs = null, lastTs = null;
|
|
574
|
+
+
|
|
575
|
+
+ for (const line of content.split('\n')) {
|
|
576
|
+
+ if (!line.trim()) continue;
|
|
577
|
+
+ let entry;
|
|
578
|
+
+ try { entry = JSON.parse(line); } catch { continue; }
|
|
579
|
+
+
|
|
580
|
+
+ // Track timestamps from all message types
|
|
581
|
+
+ const ts = entry.timestamp || entry.message?.timestamp;
|
|
582
|
+
+ if (ts) {
|
|
583
|
+
+ const t = typeof ts === 'number' ? ts : new Date(ts).getTime();
|
|
584
|
+
+ if (!firstTs || t < firstTs) firstTs = t;
|
|
585
|
+
+ if (!lastTs || t > lastTs) lastTs = t;
|
|
586
|
+
+ }
|
|
587
|
+
+
|
|
588
|
+
+ // Only assistant messages with usage blocks have cost data
|
|
589
|
+
+ if (entry.type !== 'message') continue;
|
|
590
|
+
+ if (!entry.usage) continue;
|
|
591
|
+
+
|
|
592
|
+
+ messageCount++;
|
|
593
|
+
+ const u = entry.usage;
|
|
594
|
+
+
|
|
595
|
+
+ // Use model from transcript (most accurate)
|
|
596
|
+
+ if (entry.model && !model) model = entry.model;
|
|
597
|
+
+
|
|
598
|
+
+ input += u.input || 0;
|
|
599
|
+
+ output += u.output || 0;
|
|
600
|
+
+ cacheRead += u.cacheRead || 0;
|
|
601
|
+
+ cacheWrite += u.cacheWrite || 0;
|
|
602
|
+
+ totalTokens += u.totalTokens || 0;
|
|
603
|
+
+
|
|
604
|
+
+ // Prefer API-reported cost (already accounts for cache pricing)
|
|
605
|
+
+ if (u.cost) {
|
|
606
|
+
+ if (typeof u.cost === 'object' && u.cost.total != null) {
|
|
607
|
+
+ totalCost += u.cost.total;
|
|
608
|
+
+ } else if (typeof u.cost === 'number') {
|
|
609
|
+
+ totalCost += u.cost;
|
|
610
|
+
+ }
|
|
611
|
+
+ }
|
|
612
|
+
+ }
|
|
613
|
+
+
|
|
614
|
+
+ if (messageCount === 0) return null;
|
|
615
|
+
+
|
|
616
|
+
+ return { input, output, cacheRead, cacheWrite, totalTokens, totalCost, messageCount, model, firstTs, lastTs };
|
|
617
|
+
+ } catch {
|
|
618
|
+
+ return null;
|
|
619
|
+
+ }
|
|
620
|
+
+}
|
|
621
|
+
+
|
|
622
|
+
+/**
|
|
623
|
+
+ * Discover all agent session directories (not just main).
|
|
624
|
+
+ */
|
|
625
|
+
+function discoverAgentDirs() {
|
|
626
|
+
+ const agentsDir = path.join(os.homedir(), '.openclaw', 'agents');
|
|
627
|
+
+ const dirs = [];
|
|
628
|
+
+ try {
|
|
629
|
+
+ for (const agent of fs.readdirSync(agentsDir)) {
|
|
630
|
+
+ const sessionsDir = path.join(agentsDir, agent, 'sessions');
|
|
631
|
+
+ if (fs.existsSync(sessionsDir) && fs.statSync(sessionsDir).isDirectory()) {
|
|
632
|
+
+ dirs.push({ agent, sessionsDir });
|
|
633
|
+
+ }
|
|
634
|
+
+ }
|
|
635
|
+
+ } catch { /* agents dir doesn't exist */ }
|
|
636
|
+
+ return dirs;
|
|
637
|
+
+}
|
|
638
|
+
+
|
|
639
|
+
+/**
|
|
640
|
+
+ * Discover web-chat session transcripts.
|
|
641
|
+
+ */
|
|
642
|
+
+function discoverWebChatSessions() {
|
|
643
|
+
+ const webChatDir = path.join(os.homedir(), '.openclaw', 'web-chat');
|
|
644
|
+
+ const sessions = [];
|
|
645
|
+
+ try {
|
|
646
|
+
+ for (const file of fs.readdirSync(webChatDir)) {
|
|
647
|
+
+ if (file.endsWith('.jsonl')) {
|
|
648
|
+
+ sessions.push(path.join(webChatDir, file));
|
|
649
|
+
+ }
|
|
650
|
+
+ }
|
|
651
|
+
+ } catch { /* web-chat dir doesn't exist */ }
|
|
652
|
+
+ return sessions;
|
|
653
|
+
+}
|
|
654
|
+
+
|
|
655
|
+
function analyzeSessions(sessionsPath) {
|
|
656
|
+
const findings = [];
|
|
657
|
+
const sessions = readJSON(sessionsPath);
|
|
658
|
+
+ const sessionsDir = sessionsPath ? path.dirname(sessionsPath) : null;
|
|
659
|
+
|
|
660
|
+
- if (!sessions) return { exists: false, findings: [], sessions: [], totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0, sessionCount: 0 };
|
|
661
|
+
+ if (!sessions) return { exists: false, findings: [], sessions: [], totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0, totalCacheRead: 0, totalCacheWrite: 0, totalRealCost: 0, sessionCount: 0 };
|
|
662
|
+
|
|
663
|
+
let totalIn = 0, totalOut = 0, totalCost = 0;
|
|
664
|
+
+ let totalCacheRead = 0, totalCacheWrite = 0, totalRealCost = 0;
|
|
665
|
+
const breakdown = [], orphaned = [], large = [];
|
|
666
|
+
+ let transcriptHits = 0, transcriptMisses = 0;
|
|
667
|
+
|
|
668
|
+
for (const key of Object.keys(sessions)) {
|
|
669
|
+
const s = sessions[key];
|
|
670
|
+
@@ -510,27 +611,121 @@ function analyzeSessions(sessionsPath) {
|
|
671
|
+
const modelKey = resolveModel(model);
|
|
672
|
+
const inTok = s.inputTokens || s.tokensIn || s.tokens?.input || 0;
|
|
673
|
+
const outTok = s.outputTokens || s.tokensOut || s.tokens?.output || 0;
|
|
674
|
+
- const cost = costPerCall(modelKey, inTok, outTok);
|
|
675
|
+
+ const estimatedCost = costPerCall(modelKey, inTok, outTok);
|
|
676
|
+
const updatedAt = s.updatedAt || s.lastActive || null;
|
|
677
|
+
|
|
678
|
+
- totalIn += inTok;
|
|
679
|
+
- totalOut += outTok;
|
|
680
|
+
- totalCost += cost;
|
|
681
|
+
+ // Try to load transcript for real cost data
|
|
682
|
+
+ let transcript = null;
|
|
683
|
+
+ if (sessionsDir) {
|
|
684
|
+
+ const jsonlPath = path.join(sessionsDir, `${key}.jsonl`);
|
|
685
|
+
+ transcript = parseTranscript(jsonlPath);
|
|
686
|
+
+ }
|
|
687
|
+
+
|
|
688
|
+
+ // Use transcript data when available, fall back to sessions.json estimates
|
|
689
|
+
+ let realIn, realOut, realCacheRead, realCacheWrite, realCost, realModel, realTotalTokens;
|
|
690
|
+
+ if (transcript) {
|
|
691
|
+
+ transcriptHits++;
|
|
692
|
+
+ realIn = transcript.input;
|
|
693
|
+
+ realOut = transcript.output;
|
|
694
|
+
+ realCacheRead = transcript.cacheRead;
|
|
695
|
+
+ realCacheWrite = transcript.cacheWrite;
|
|
696
|
+
+ realCost = transcript.totalCost;
|
|
697
|
+
+ realModel = transcript.model || model;
|
|
698
|
+
+ realTotalTokens = transcript.totalTokens;
|
|
699
|
+
+ } else {
|
|
700
|
+
+ transcriptMisses++;
|
|
701
|
+
+ realIn = inTok;
|
|
702
|
+
+ realOut = outTok;
|
|
703
|
+
+ realCacheRead = 0;
|
|
704
|
+
+ realCacheWrite = 0;
|
|
705
|
+
+ realCost = estimatedCost;
|
|
706
|
+
+ realModel = model;
|
|
707
|
+
+ realTotalTokens = inTok + outTok;
|
|
708
|
+
+ }
|
|
709
|
+
+
|
|
710
|
+
+ totalIn += realIn;
|
|
711
|
+
+ totalOut += realOut;
|
|
712
|
+
+ totalCacheRead += realCacheRead;
|
|
713
|
+
+ totalCacheWrite += realCacheWrite;
|
|
714
|
+
+ totalCost += estimatedCost;
|
|
715
|
+
+ totalRealCost += realCost;
|
|
716
|
+
+
|
|
717
|
+
+ const allTokens = realTotalTokens || (realIn + realOut + realCacheRead + realCacheWrite);
|
|
718
|
+
|
|
719
|
+
const isOrphaned = key.includes('cron') || key.includes('deleted') ||
|
|
720
|
+
(updatedAt && Date.now() - new Date(updatedAt).getTime() > 48 * 3600 * 1000 && !key.includes('main'));
|
|
721
|
+
|
|
722
|
+
- if (isOrphaned) orphaned.push({ key, model, tokens: inTok + outTok, cost });
|
|
723
|
+
- if (inTok + outTok > 50000) large.push({ key, model, tokens: inTok + outTok });
|
|
724
|
+
-
|
|
725
|
+
- breakdown.push({ key, model, modelLabel: modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown', inputTokens: inTok, outputTokens: outTok, cost, updatedAt, isOrphaned });
|
|
726
|
+
+ if (isOrphaned) orphaned.push({ key, model: realModel, tokens: allTokens, cost: realCost });
|
|
727
|
+
+ if (allTokens > 50000) large.push({ key, model: realModel, tokens: allTokens });
|
|
728
|
+
+
|
|
729
|
+
+ const ageMs = updatedAt ? Date.now() - new Date(updatedAt).getTime() : null;
|
|
730
|
+
+ const ageDays = ageMs ? ageMs / (1000 * 3600 * 24) : null;
|
|
731
|
+
+ const dailyCost = (ageDays && ageDays > 0.01 && realCost > 0) ? realCost / ageDays : null;
|
|
732
|
+
+
|
|
733
|
+
+ const realModelKey = resolveModel(realModel);
|
|
734
|
+
+ breakdown.push({
|
|
735
|
+
+ key, model: realModel,
|
|
736
|
+
+ modelLabel: realModelKey ? (MODEL_PRICING[realModelKey]?.label || realModelKey) : (modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown'),
|
|
737
|
+
+ inputTokens: realIn, outputTokens: realOut,
|
|
738
|
+
+ cacheRead: realCacheRead, cacheWrite: realCacheWrite,
|
|
739
|
+
+ cost: realCost, estimatedCost,
|
|
740
|
+
+ hasTranscript: !!transcript,
|
|
741
|
+
+ messageCount: transcript?.messageCount || 0,
|
|
742
|
+
+ updatedAt, ageMs, dailyCost, isOrphaned,
|
|
743
|
+
+ });
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
+ // Findings
|
|
747
|
+
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 });
|
|
748
|
+
if (large.length > 0) findings.push({ severity: 'medium', source: 'sessions', message: `${large.length} session(s) with >50k tokens per conversation`, detail: large.map(s => `${s.key}: ${s.tokens.toLocaleString()} tokens`).join('\n '), ...FIXES.LARGE_SESSIONS });
|
|
749
|
+
if (Object.keys(sessions).length > 0 && !orphaned.length && !large.length) findings.push({ severity: 'info', source: 'sessions', message: `${Object.keys(sessions).length} session(s) healthy ✓`, detail: `Total tokens: ${(totalIn + totalOut).toLocaleString()}` });
|
|
750
|
+
|
|
751
|
+
- return { exists: true, findings, sessions: breakdown, totalInputTokens: totalIn, totalOutputTokens: totalOut, totalCost, sessionCount: Object.keys(sessions).length };
|
|
752
|
+
+ // Cache cost finding
|
|
753
|
+
+ if (totalCacheWrite > 0 || totalCacheRead > 0) {
|
|
754
|
+
+ const cacheDetail = [];
|
|
755
|
+
+ if (totalCacheRead > 0) cacheDetail.push(`Cache reads: ${totalCacheRead.toLocaleString()} tokens`);
|
|
756
|
+
+ if (totalCacheWrite > 0) cacheDetail.push(`Cache writes: ${totalCacheWrite.toLocaleString()} tokens`);
|
|
757
|
+
+ const cacheCostPortion = totalRealCost - totalCost;
|
|
758
|
+
+ if (cacheCostPortion > 0.01) {
|
|
759
|
+
+ findings.push({
|
|
760
|
+
+ severity: cacheCostPortion > 1 ? 'high' : 'medium',
|
|
761
|
+
+ source: 'sessions',
|
|
762
|
+
+ message: `Prompt caching added $${cacheCostPortion.toFixed(4)} beyond base token costs`,
|
|
763
|
+
+ detail: cacheDetail.join('\n ') + `\n Cache writes are 3.75x input cost · Cache reads are 0.1x input cost`,
|
|
764
|
+
+ monthlyCost: 0, // already counted in session costs, not a recurring config bleed
|
|
765
|
+
+ });
|
|
766
|
+
+ } else {
|
|
767
|
+
+ findings.push({ severity: 'info', source: 'sessions', message: `Prompt caching active — ${(totalCacheRead + totalCacheWrite).toLocaleString()} cache tokens tracked`, detail: cacheDetail.join(' · ') });
|
|
768
|
+
+ }
|
|
769
|
+
+ }
|
|
770
|
+
+
|
|
771
|
+
+ // Transcript coverage finding
|
|
772
|
+
+ if (transcriptHits > 0 || transcriptMisses > 0) {
|
|
773
|
+
+ const total = transcriptHits + transcriptMisses;
|
|
774
|
+
+ if (transcriptMisses > 0 && transcriptHits > 0) {
|
|
775
|
+
+ findings.push({ severity: 'info', source: 'sessions', message: `Transcript data: ${transcriptHits}/${total} sessions have .jsonl transcripts (${transcriptMisses} estimated)`, detail: `Sessions with transcripts show real API-reported costs including cache tokens` });
|
|
776
|
+
+ }
|
|
777
|
+
+ }
|
|
778
|
+
+
|
|
779
|
+
+ // Cost discrepancy finding
|
|
780
|
+
+ if (totalRealCost > 0 && totalCost > 0) {
|
|
781
|
+
+ const ratio = totalRealCost / totalCost;
|
|
782
|
+
+ if (ratio > 2) {
|
|
783
|
+
+ findings.push({
|
|
784
|
+
+ severity: 'high',
|
|
785
|
+
+ source: 'sessions',
|
|
786
|
+
+ message: `Real cost $${totalRealCost.toFixed(4)} is ${ratio.toFixed(1)}x higher than sessions.json estimate ($${totalCost.toFixed(4)})`,
|
|
787
|
+
+ detail: `sessions.json only tracks input/output tokens — cache tokens, which are the bulk of real spending, are only in .jsonl transcripts`,
|
|
788
|
+
+ });
|
|
789
|
+
+ }
|
|
790
|
+
+ }
|
|
791
|
+
+
|
|
792
|
+
+ return {
|
|
793
|
+
+ exists: true, findings, sessions: breakdown,
|
|
794
|
+
+ totalInputTokens: totalIn, totalOutputTokens: totalOut,
|
|
795
|
+
+ totalCost, totalCacheRead, totalCacheWrite, totalRealCost,
|
|
796
|
+
+ sessionCount: Object.keys(sessions).length,
|
|
797
|
+
+ };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// ── Workspace analysis ────────────────────────────────────────────
|
|
801
|
+
@@ -559,12 +754,64 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
|
|
802
|
+
const sessionResult = analyzeSessions(sessionsPath);
|
|
803
|
+
const workspaceResult = analyzeWorkspace();
|
|
804
|
+
|
|
805
|
+
- const allFindings = [...configResult.findings, ...sessionResult.findings, ...workspaceResult.findings];
|
|
806
|
+
+ // Scan additional agent folders beyond main
|
|
807
|
+
+ const agentDirs = discoverAgentDirs();
|
|
808
|
+
+ const additionalAgentSessions = [];
|
|
809
|
+
+ for (const { agent, sessionsDir } of agentDirs) {
|
|
810
|
+
+ const sjPath = path.join(sessionsDir, 'sessions.json');
|
|
811
|
+
+ // Skip the primary sessions path (already analyzed above)
|
|
812
|
+
+ if (sjPath === sessionsPath) continue;
|
|
813
|
+
+ if (fs.existsSync(sjPath)) {
|
|
814
|
+
+ const extra = analyzeSessions(sjPath);
|
|
815
|
+
+ if (extra.exists) {
|
|
816
|
+
+ additionalAgentSessions.push({ agent, ...extra });
|
|
817
|
+
+ }
|
|
818
|
+
+ }
|
|
819
|
+
+ }
|
|
820
|
+
+
|
|
821
|
+
+ // Scan web-chat sessions
|
|
822
|
+
+ const webChatPaths = discoverWebChatSessions();
|
|
823
|
+
+ const webChatSessions = [];
|
|
824
|
+
+ for (const wcp of webChatPaths) {
|
|
825
|
+
+ const transcript = parseTranscript(wcp);
|
|
826
|
+
+ if (transcript) {
|
|
827
|
+
+ const sessionId = path.basename(wcp, '.jsonl');
|
|
828
|
+
+ webChatSessions.push({ key: `web-chat/${sessionId}`, ...transcript });
|
|
829
|
+
+ }
|
|
830
|
+
+ }
|
|
831
|
+
+
|
|
832
|
+
+ // Merge all findings
|
|
833
|
+
+ const allFindings = [
|
|
834
|
+
+ ...configResult.findings,
|
|
835
|
+
+ ...sessionResult.findings,
|
|
836
|
+
+ ...workspaceResult.findings,
|
|
837
|
+
+ ];
|
|
838
|
+
+
|
|
839
|
+
+ // Add additional agent findings
|
|
840
|
+
+ for (const extra of additionalAgentSessions) {
|
|
841
|
+
+ if (extra.findings.length > 0) {
|
|
842
|
+
+ allFindings.push({ severity: 'info', source: 'sessions', message: `Agent "${extra.agent}": ${extra.sessionCount} session(s) found`, detail: `Tokens: ${(extra.totalInputTokens + extra.totalOutputTokens).toLocaleString()} · Cost: $${extra.totalRealCost.toFixed(4)}` });
|
|
843
|
+
+ }
|
|
844
|
+
+ // Merge their sessions into the main breakdown
|
|
845
|
+
+ sessionResult.sessions.push(...(extra.sessions || []));
|
|
846
|
+
+ sessionResult.totalRealCost += extra.totalRealCost || 0;
|
|
847
|
+
+ sessionResult.totalCacheRead += extra.totalCacheRead || 0;
|
|
848
|
+
+ sessionResult.totalCacheWrite += extra.totalCacheWrite || 0;
|
|
849
|
+
+ }
|
|
850
|
+
+
|
|
851
|
+
+ // Add web-chat finding if any
|
|
852
|
+
+ if (webChatSessions.length > 0) {
|
|
853
|
+
+ const wcTotal = webChatSessions.reduce((sum, s) => sum + s.totalCost, 0);
|
|
854
|
+
+ const wcTokens = webChatSessions.reduce((sum, s) => sum + s.totalTokens, 0);
|
|
855
|
+
+ allFindings.push({ severity: 'info', source: 'sessions', message: `${webChatSessions.length} web-chat session(s) found`, detail: `Tokens: ${wcTokens.toLocaleString()} · Cost: $${wcTotal.toFixed(4)}` });
|
|
856
|
+
+ }
|
|
857
|
+
|
|
858
|
+
const estimatedMonthlyBleed = allFindings
|
|
859
|
+
.filter(f => f.monthlyCost && f.severity !== 'info')
|
|
860
|
+
.reduce((sum, f) => sum + f.monthlyCost, 0);
|
|
861
|
+
|
|
862
|
+
+ const realCost = sessionResult.totalRealCost || 0;
|
|
863
|
+
+
|
|
864
|
+
return {
|
|
865
|
+
scannedAt: new Date().toISOString(),
|
|
866
|
+
configPath,
|
|
867
|
+
@@ -575,14 +822,20 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
|
|
868
|
+
critical: allFindings.filter(f => f.severity === 'critical').length,
|
|
869
|
+
high: allFindings.filter(f => f.severity === 'high').length,
|
|
870
|
+
medium: allFindings.filter(f => f.severity === 'medium').length,
|
|
871
|
+
+ low: allFindings.filter(f => f.severity === 'low').length,
|
|
872
|
+
info: allFindings.filter(f => f.severity === 'info').length,
|
|
873
|
+
estimatedMonthlyBleed,
|
|
874
|
+
sessionsAnalyzed: sessionResult.sessionCount,
|
|
875
|
+
totalTokensFound: (sessionResult.totalInputTokens || 0) + (sessionResult.totalOutputTokens || 0),
|
|
876
|
+
+ totalCacheRead: sessionResult.totalCacheRead || 0,
|
|
877
|
+
+ totalCacheWrite: sessionResult.totalCacheWrite || 0,
|
|
878
|
+
+ totalRealCost: realCost,
|
|
879
|
+
+ totalEstimatedCost: sessionResult.totalCost || 0,
|
|
880
|
+
},
|
|
881
|
+
sessions: sessionResult.sessions || [],
|
|
882
|
+
+ webChatSessions,
|
|
883
|
+
config: configResult.config,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
-module.exports = { runAnalysis, MODEL_PRICING, resolveModel, costPerCall };
|
|
888
|
+
+module.exports = { runAnalysis, MODEL_PRICING, resolveModel, costPerCall, parseTranscript, discoverAgentDirs, discoverWebChatSessions };
|
|
889
|
+
diff --git a/src/htmlReport.js b/src/htmlReport.js
|
|
890
|
+
index 30cae47..87a692e 100644
|
|
891
|
+
--- a/src/htmlReport.js
|
|
892
|
+
+++ b/src/htmlReport.js
|
|
893
|
+
@@ -5,15 +5,28 @@ const path = require('path');
|
|
894
|
+
const os = require('os');
|
|
895
|
+
|
|
896
|
+
function severityColor(severity) {
|
|
897
|
+
- return { critical: '#ef4444', high: '#f97316', medium: '#eab308', info: '#22c55e' }[severity] || '#6b7280';
|
|
898
|
+
+ return { critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#06b6d4', info: '#22c55e' }[severity] || '#6b7280';
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function severityBg(severity) {
|
|
902
|
+
- return { critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', info: '#f0fdf4' }[severity] || '#f9fafb';
|
|
903
|
+
+ return { critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', low: '#ecfeff', info: '#f0fdf4' }[severity] || '#f9fafb';
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function severityIcon(severity) {
|
|
907
|
+
- return { critical: '🔴', high: '🟠', medium: '🟡', info: '✅' }[severity] || '⚪';
|
|
908
|
+
+ return { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵', info: '✅' }[severity] || '⚪';
|
|
909
|
+
+}
|
|
910
|
+
+
|
|
911
|
+
+function relativeAge(ageMs) {
|
|
912
|
+
+ if (!ageMs) return 'unknown';
|
|
913
|
+
+ const s = Math.floor(ageMs / 1000);
|
|
914
|
+
+ if (s < 60) return `${s}s ago`;
|
|
915
|
+
+ const m = Math.floor(s / 60);
|
|
916
|
+
+ if (m < 60) return `${m}m ago`;
|
|
917
|
+
+ const h = Math.floor(m / 60);
|
|
918
|
+
+ if (h < 24) return `${h}h ago`;
|
|
919
|
+
+ const d = Math.floor(h / 24);
|
|
920
|
+
+ if (d < 30) return `${d}d ago`;
|
|
921
|
+
+ return `${Math.floor(d / 30)}mo ago`;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const SOURCE_LABELS = {
|
|
925
|
+
@@ -27,6 +40,25 @@ async function generateHTMLReport(analysis, outPath) {
|
|
926
|
+
const { summary, findings, sessions } = analysis;
|
|
927
|
+
const bleed = summary.estimatedMonthlyBleed;
|
|
928
|
+
|
|
929
|
+
+ // Burn summary calculation
|
|
930
|
+
+ const activeSessions = (sessions || []).filter(s => s.dailyCost);
|
|
931
|
+
+ const totalDaily = activeSessions.reduce((sum, s) => sum + (s.dailyCost || 0), 0);
|
|
932
|
+
+ const totalMonthly = totalDaily * 30;
|
|
933
|
+
+ const burnSummary = totalDaily > 0 ? `
|
|
934
|
+
+ <div class="section">
|
|
935
|
+
+ <div class="section-title">Session Burn Rate</div>
|
|
936
|
+
+ <div style="display:flex; gap:32px; flex-wrap:wrap;">
|
|
937
|
+
+ <div>
|
|
938
|
+
+ <div style="font-size:24px; font-weight:800; color:#f59e0b;">$${totalDaily.toFixed(4)}/day</div>
|
|
939
|
+
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Combined daily burn rate across ${activeSessions.length} active session${activeSessions.length !== 1 ? 's' : ''}</div>
|
|
940
|
+
+ </div>
|
|
941
|
+
+ <div>
|
|
942
|
+
+ <div style="font-size:24px; font-weight:800; color:#f97316;">$${totalMonthly.toFixed(2)}/month</div>
|
|
943
|
+
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Projected monthly from active sessions</div>
|
|
944
|
+
+ </div>
|
|
945
|
+
+ </div>
|
|
946
|
+
+ </div>` : '';
|
|
947
|
+
+
|
|
948
|
+
const findingCards = findings.map(f => `
|
|
949
|
+
<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;">
|
|
950
|
+
<div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
|
|
951
|
+
@@ -44,14 +76,24 @@ async function generateHTMLReport(analysis, outPath) {
|
|
952
|
+
const sessionRows = (sessions || [])
|
|
953
|
+
.sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens))
|
|
954
|
+
.slice(0, 20)
|
|
955
|
+
- .map(s => `
|
|
956
|
+
+ .map(s => {
|
|
957
|
+
+ const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '…' : s.key;
|
|
958
|
+
+ const flag = s.isOrphaned ? ' ⚠️' : '';
|
|
959
|
+
+ const txBadge = s.hasTranscript ? '<span style="color:#22c55e; font-size:10px; margin-left:4px" title="Cost from .jsonl transcript (API-reported)">●</span>' : '<span style="color:#6b7280; font-size:10px; margin-left:4px" title="Estimated cost (no transcript found)">○</span>';
|
|
960
|
+
+ const age = s.ageMs ? relativeAge(s.ageMs) : 'unknown';
|
|
961
|
+
+ const absDate = s.updatedAt ? new Date(s.updatedAt).toLocaleString() : '';
|
|
962
|
+
+ const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}/day` : '—';
|
|
963
|
+
+ const cacheTitle = (s.cacheRead || s.cacheWrite) ? `Cache R: ${(s.cacheRead||0).toLocaleString()} · W: ${(s.cacheWrite||0).toLocaleString()}` : '';
|
|
964
|
+
+ return `
|
|
965
|
+
<tr style="${s.isOrphaned ? 'background:#fff7ed' : ''}">
|
|
966
|
+
- <td style="padding:8px 12px; font-family:monospace; font-size:13px">${s.key}${s.isOrphaned ? ' ⚠️' : ''}</td>
|
|
967
|
+
+ <td style="padding:8px 12px; font-family:monospace; font-size:13px">${keyDisplay}${flag}${txBadge}</td>
|
|
968
|
+
<td style="padding:8px 12px">${s.modelLabel || s.model}</td>
|
|
969
|
+
- <td style="padding:8px 12px; text-align:right">${(s.inputTokens + s.outputTokens).toLocaleString()}</td>
|
|
970
|
+
+ <td style="padding:8px 12px; text-align:right" title="${cacheTitle}">${(s.inputTokens + s.outputTokens).toLocaleString()}${(s.cacheRead || s.cacheWrite) ? `<span style="color:#6b7280; font-size:11px;"> +${((s.cacheRead||0)+(s.cacheWrite||0)).toLocaleString()} cache</span>` : ''}</td>
|
|
971
|
+
<td style="padding:8px 12px; text-align:right; color:${s.cost > 0.01 ? '#ef4444' : '#22c55e'}">$${s.cost.toFixed(6)}</td>
|
|
972
|
+
+ <td style="padding:8px 12px; text-align:right; color:#f59e0b">${daily}</td>
|
|
973
|
+
+ <td style="padding:8px 12px; color:#6b7280; font-size:13px" title="${absDate}">${age}</td>
|
|
974
|
+
</tr>
|
|
975
|
+
- `).join('');
|
|
976
|
+
+ `}).join('');
|
|
977
|
+
|
|
978
|
+
const html = `<!DOCTYPE html>
|
|
979
|
+
<html lang="en">
|
|
980
|
+
@@ -66,7 +108,7 @@ async function generateHTMLReport(analysis, outPath) {
|
|
981
|
+
.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; }
|
|
982
|
+
.tagline { color: #94a3b8; margin-top: 8px; font-size: 16px; }
|
|
983
|
+
.container { max-width: 1000px; margin: 0 auto; padding: 32px 24px; }
|
|
984
|
+
- .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
|
985
|
+
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
|
986
|
+
.card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; }
|
|
987
|
+
.card-value { font-size: 32px; font-weight: 800; }
|
|
988
|
+
.card-label { font-size: 13px; color: #94a3b8; margin-top: 4px; }
|
|
989
|
+
@@ -98,6 +140,27 @@ async function generateHTMLReport(analysis, outPath) {
|
|
990
|
+
<div style="color:#86efac; font-size:18px; font-weight:700">✅ No significant cost bleed detected</div>
|
|
991
|
+
</div>`}
|
|
992
|
+
|
|
993
|
+
+ ${summary.totalRealCost > 0 ? `
|
|
994
|
+
+ <div class="section" style="border:1px solid #f59e0b;">
|
|
995
|
+
+ <div class="section-title" style="color:#f59e0b;">💰 Actual API Spend (from transcripts)</div>
|
|
996
|
+
+ <div style="display:flex; gap:32px; flex-wrap:wrap; align-items:center;">
|
|
997
|
+
+ <div>
|
|
998
|
+
+ <div style="font-size:36px; font-weight:900; color:#fbbf24;">$${summary.totalRealCost.toFixed(4)}</div>
|
|
999
|
+
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Total real cost (API-reported)</div>
|
|
1000
|
+
+ </div>
|
|
1001
|
+
+ ${summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1 ? `
|
|
1002
|
+
+ <div style="background:#451a03; padding:10px 16px; border-radius:8px; border:1px solid #92400e;">
|
|
1003
|
+
+ <div style="font-size:14px; color:#fbbf24; font-weight:600;">${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x higher than sessions.json estimate</div>
|
|
1004
|
+
+ <div style="font-size:12px; color:#d97706; margin-top:2px;">sessions.json: $${summary.totalEstimatedCost.toFixed(4)} — missing cache token costs</div>
|
|
1005
|
+
+ </div>` : ''}
|
|
1006
|
+
+ </div>
|
|
1007
|
+
+ ${summary.totalCacheRead > 0 || summary.totalCacheWrite > 0 ? `
|
|
1008
|
+
+ <div style="margin-top:16px; display:flex; gap:24px; flex-wrap:wrap;">
|
|
1009
|
+
+ ${summary.totalCacheWrite > 0 ? `<div style="color:#f97316; font-size:14px;">📝 Cache writes: <strong>${summary.totalCacheWrite.toLocaleString()}</strong> tokens</div>` : ''}
|
|
1010
|
+
+ ${summary.totalCacheRead > 0 ? `<div style="color:#22c55e; font-size:14px;">📖 Cache reads: <strong>${summary.totalCacheRead.toLocaleString()}</strong> tokens</div>` : ''}
|
|
1011
|
+
+ </div>` : ''}
|
|
1012
|
+
+ </div>` : ''}
|
|
1013
|
+
+
|
|
1014
|
+
<div class="cards">
|
|
1015
|
+
<div class="card">
|
|
1016
|
+
<div class="card-value" style="color:#ef4444">${summary.critical}</div>
|
|
1017
|
+
@@ -111,6 +174,10 @@ async function generateHTMLReport(analysis, outPath) {
|
|
1018
|
+
<div class="card-value" style="color:#eab308">${summary.medium}</div>
|
|
1019
|
+
<div class="card-label">🟡 Medium</div>
|
|
1020
|
+
</div>
|
|
1021
|
+
+ <div class="card">
|
|
1022
|
+
+ <div class="card-value" style="color:#06b6d4">${summary.low || 0}</div>
|
|
1023
|
+
+ <div class="card-label">🔵 Low</div>
|
|
1024
|
+
+ </div>
|
|
1025
|
+
<div class="card">
|
|
1026
|
+
<div class="card-value" style="color:#22c55e">${summary.info}</div>
|
|
1027
|
+
<div class="card-label">✅ OK</div>
|
|
1028
|
+
@@ -137,11 +204,12 @@ async function generateHTMLReport(analysis, outPath) {
|
|
1029
|
+
<div class="section-title">Session Breakdown</div>
|
|
1030
|
+
<div style="overflow-x:auto">
|
|
1031
|
+
<table>
|
|
1032
|
+
- <thead><tr><th>Session</th><th>Model</th><th style="text-align:right">Tokens</th><th style="text-align:right">Cost</th></tr></thead>
|
|
1033
|
+
+ <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>
|
|
1034
|
+
<tbody>${sessionRows}</tbody>
|
|
1035
|
+
</table>
|
|
1036
|
+
</div>
|
|
1037
|
+
- </div>` : ''}
|
|
1038
|
+
+ </div>
|
|
1039
|
+
+ ${burnSummary}` : ''}
|
|
1040
|
+
|
|
1041
|
+
</div>
|
|
1042
|
+
<div class="footer">
|
|
1043
|
+
@@ -151,7 +219,7 @@ async function generateHTMLReport(analysis, outPath) {
|
|
1044
|
+
</html>`;
|
|
1045
|
+
|
|
1046
|
+
if (!outPath) {
|
|
1047
|
+
- outPath = path.join(os.tmpdir(), `clawculator-report-${Date.now()}.html`);
|
|
1048
|
+
+ outPath = path.join(process.cwd(), `clawculator-report-${Date.now()}.html`);
|
|
1049
|
+
}
|
|
1050
|
+
fs.writeFileSync(outPath, html, 'utf8');
|
|
1051
|
+
return outPath;
|
|
1052
|
+
diff --git a/src/mdReport.js b/src/mdReport.js
|
|
1053
|
+
index 630818e..bdab93f 100644
|
|
1054
|
+
--- a/src/mdReport.js
|
|
1055
|
+
+++ b/src/mdReport.js
|
|
1056
|
+
@@ -1,6 +1,6 @@
|
|
1057
|
+
'use strict';
|
|
1058
|
+
|
|
1059
|
+
-const SEVERITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', info: '✅' };
|
|
1060
|
+
+const SEVERITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵', info: '✅' };
|
|
1061
|
+
const SOURCE_LABELS = {
|
|
1062
|
+
heartbeat: '💓 Heartbeat', hooks: '🪝 Hooks', whatsapp: '📱 WhatsApp',
|
|
1063
|
+
subagents: '🤖 Subagents', skills: '🔧 Skills', memory: '🧠 Memory',
|
|
1064
|
+
@@ -8,6 +8,25 @@ const SOURCE_LABELS = {
|
|
1065
|
+
workspace: '📁 Workspace', config: '📄 Config',
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
+function relativeAge(ageMs) {
|
|
1069
|
+
+ if (!ageMs) return 'unknown';
|
|
1070
|
+
+ const s = Math.floor(ageMs / 1000);
|
|
1071
|
+
+ if (s < 60) return `${s}s ago`;
|
|
1072
|
+
+ const m = Math.floor(s / 60);
|
|
1073
|
+
+ if (m < 60) return `${m}m ago`;
|
|
1074
|
+
+ const h = Math.floor(m / 60);
|
|
1075
|
+
+ if (h < 24) return `${h}h ago`;
|
|
1076
|
+
+ const d = Math.floor(h / 24);
|
|
1077
|
+
+ if (d < 30) return `${d}d ago`;
|
|
1078
|
+
+ const mo = Math.floor(d / 30);
|
|
1079
|
+
+ return `${mo}mo ago`;
|
|
1080
|
+
+}
|
|
1081
|
+
+
|
|
1082
|
+
+function absoluteDate(updatedAt) {
|
|
1083
|
+
+ if (!updatedAt) return '';
|
|
1084
|
+
+ return new Date(updatedAt).toLocaleString();
|
|
1085
|
+
+}
|
|
1086
|
+
+
|
|
1087
|
+
function generateMarkdownReport(analysis) {
|
|
1088
|
+
const { summary, findings, sessions, primaryModel, scannedAt } = analysis;
|
|
1089
|
+
const bleed = summary.estimatedMonthlyBleed;
|
|
1090
|
+
@@ -32,21 +51,39 @@ function generateMarkdownReport(analysis) {
|
|
1091
|
+
// Summary table
|
|
1092
|
+
lines.push('## Summary');
|
|
1093
|
+
lines.push('');
|
|
1094
|
+
- lines.push('| Severity | Count |');
|
|
1095
|
+
- lines.push('|----------|-------|');
|
|
1096
|
+
+ lines.push('| Metric | Value |');
|
|
1097
|
+
+ lines.push('|--------|-------|');
|
|
1098
|
+
lines.push(`| 🔴 Critical | ${summary.critical} |`);
|
|
1099
|
+
lines.push(`| 🟠 High | ${summary.high} |`);
|
|
1100
|
+
lines.push(`| 🟡 Medium | ${summary.medium} |`);
|
|
1101
|
+
+ lines.push(`| 🔵 Low | ${summary.low || 0} |`);
|
|
1102
|
+
lines.push(`| ✅ OK | ${summary.info} |`);
|
|
1103
|
+
lines.push(`| Sessions analyzed | ${summary.sessionsAnalyzed} |`);
|
|
1104
|
+
lines.push(`| Total tokens found | ${(summary.totalTokensFound || 0).toLocaleString()} |`);
|
|
1105
|
+
+
|
|
1106
|
+
+ // Session cost summary
|
|
1107
|
+
+ if (sessions?.length > 0) {
|
|
1108
|
+
+ const totalCost = sessions.reduce((sum, s) => sum + s.cost, 0);
|
|
1109
|
+
+ const dailyRates = sessions.filter(s => s.dailyCost).map(s => s.dailyCost);
|
|
1110
|
+
+ const totalDailyRate = dailyRates.reduce((sum, r) => sum + r, 0);
|
|
1111
|
+
+ if (totalCost > 0) lines.push(`| Total session cost (lifetime) | $${totalCost.toFixed(4)} |`);
|
|
1112
|
+
+ if (totalDailyRate > 0) lines.push(`| Avg daily burn rate | $${totalDailyRate.toFixed(4)}/day (~$${(totalDailyRate * 30).toFixed(2)}/month) |`);
|
|
1113
|
+
+ }
|
|
1114
|
+
+ if (summary.totalRealCost > 0) {
|
|
1115
|
+
+ lines.push(`| 💰 **Actual API spend** | **$${summary.totalRealCost.toFixed(4)}** |`);
|
|
1116
|
+
+ if (summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1) {
|
|
1117
|
+
+ lines.push(`| sessions.json estimate | $${summary.totalEstimatedCost.toFixed(4)} (${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x gap) |`);
|
|
1118
|
+
+ }
|
|
1119
|
+
+ }
|
|
1120
|
+
+ if (summary.totalCacheWrite > 0) lines.push(`| Cache writes | ${summary.totalCacheWrite.toLocaleString()} tokens |`);
|
|
1121
|
+
+ if (summary.totalCacheRead > 0) lines.push(`| Cache reads | ${summary.totalCacheRead.toLocaleString()} tokens |`);
|
|
1122
|
+
lines.push('');
|
|
1123
|
+
|
|
1124
|
+
// Findings by severity
|
|
1125
|
+
lines.push('## Findings');
|
|
1126
|
+
lines.push('');
|
|
1127
|
+
|
|
1128
|
+
- for (const severity of ['critical', 'high', 'medium', 'info']) {
|
|
1129
|
+
+ for (const severity of ['critical', 'high', 'medium', 'low', 'info']) {
|
|
1130
|
+
const group = findings.filter(f => f.severity === severity);
|
|
1131
|
+
if (!group.length) continue;
|
|
1132
|
+
|
|
1133
|
+
@@ -78,19 +115,24 @@ function generateMarkdownReport(analysis) {
|
|
1134
|
+
if (sessions?.length > 0) {
|
|
1135
|
+
lines.push('## Session Breakdown');
|
|
1136
|
+
lines.push('');
|
|
1137
|
+
- lines.push('| Session | Model | Tokens | Cost |');
|
|
1138
|
+
- lines.push('|---------|-------|--------|------|');
|
|
1139
|
+
+ lines.push('| Session | Model | Tokens | Total Cost | $/day | Last Active |');
|
|
1140
|
+
+ lines.push('|---------|-------|--------|-----------|-------|-------------|');
|
|
1141
|
+
|
|
1142
|
+
[...sessions]
|
|
1143
|
+
.sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens))
|
|
1144
|
+
.slice(0, 20)
|
|
1145
|
+
.forEach(s => {
|
|
1146
|
+
- const tok = (s.inputTokens + s.outputTokens).toLocaleString();
|
|
1147
|
+
- const flag = s.isOrphaned ? ' ⚠️' : '';
|
|
1148
|
+
- lines.push(`| \`${s.key}${flag}\` | ${s.modelLabel || s.model} | ${tok} | $${s.cost.toFixed(6)} |`);
|
|
1149
|
+
+ const tok = (s.inputTokens + s.outputTokens).toLocaleString();
|
|
1150
|
+
+ const flag = s.isOrphaned ? ' ⚠️' : '';
|
|
1151
|
+
+ const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '…' : s.key;
|
|
1152
|
+
+ const age = s.ageMs ? `${relativeAge(s.ageMs)} (${absoluteDate(s.updatedAt)})` : 'unknown';
|
|
1153
|
+
+ const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}` : '—';
|
|
1154
|
+
+ lines.push(`| \`${keyDisplay}${flag}\` | ${s.modelLabel || s.model} | ${tok} | $${s.cost.toFixed(6)} | ${daily} | ${age} |`);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
lines.push('');
|
|
1158
|
+
+ lines.push('> ⚠️ = orphaned session · $/day = total cost ÷ session age');
|
|
1159
|
+
+ lines.push('');
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Quick wins
|
|
1163
|
+
diff --git a/src/reporter.js b/src/reporter.js
|
|
1164
|
+
index d850c4a..9e8b694 100644
|
|
1165
|
+
--- a/src/reporter.js
|
|
1166
|
+
+++ b/src/reporter.js
|
|
1167
|
+
@@ -36,6 +36,19 @@ function formatCost(cost) {
|
|
1168
|
+
return `\x1b[31m$${cost.toFixed(2)}/mo\x1b[0m`;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
+function relativeAge(ageMs) {
|
|
1172
|
+
+ if (!ageMs) return 'unknown';
|
|
1173
|
+
+ const s = Math.floor(ageMs / 1000);
|
|
1174
|
+
+ if (s < 60) return `${s}s ago`;
|
|
1175
|
+
+ const m = Math.floor(s / 60);
|
|
1176
|
+
+ if (m < 60) return `${m}m ago`;
|
|
1177
|
+
+ const h = Math.floor(m / 60);
|
|
1178
|
+
+ if (h < 24) return `${h}h ago`;
|
|
1179
|
+
+ const d = Math.floor(h / 24);
|
|
1180
|
+
+ if (d < 30) return `${d}d ago`;
|
|
1181
|
+
+ return `${Math.floor(d / 30)}mo ago`;
|
|
1182
|
+
+}
|
|
1183
|
+
+
|
|
1184
|
+
function generateTerminalReport(analysis) {
|
|
1185
|
+
const { summary, findings, sessions } = analysis;
|
|
1186
|
+
const R = '\x1b[0m';
|
|
1187
|
+
@@ -78,12 +91,22 @@ function generateTerminalReport(analysis) {
|
|
1188
|
+
if (sessions?.length > 0) {
|
|
1189
|
+
console.log(`${C}━━━ Top Sessions by Token Usage ━━━${R}\n`);
|
|
1190
|
+
const sorted = [...sessions].sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens)).slice(0, 8);
|
|
1191
|
+
- console.log(` ${D}${'Session'.padEnd(42)} ${'Model'.padEnd(22)} ${'Tokens'.padEnd(10)} Cost${R}`);
|
|
1192
|
+
- console.log(` ${D}${'─'.repeat(85)}${R}`);
|
|
1193
|
+
+ console.log(` ${D}${'Session'.padEnd(16)} ${'Model'.padEnd(20)} ${'Tokens'.padEnd(10)} ${'Total Cost'.padEnd(12)} ${'$/day'.padEnd(10)} Last Active${R}`);
|
|
1194
|
+
+ console.log(` ${D}${'─'.repeat(95)}${R}`);
|
|
1195
|
+
for (const s of sorted) {
|
|
1196
|
+
- const tok = (s.inputTokens + s.outputTokens).toLocaleString();
|
|
1197
|
+
- const flag = s.isOrphaned ? ' ⚠️' : '';
|
|
1198
|
+
- 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)}`);
|
|
1199
|
+
+ const tok = (s.inputTokens + s.outputTokens).toLocaleString();
|
|
1200
|
+
+ const flag = s.isOrphaned ? ' ⚠️' : '';
|
|
1201
|
+
+ const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '…' : s.key;
|
|
1202
|
+
+ const age = s.ageMs ? `${relativeAge(s.ageMs)} (${new Date(s.updatedAt).toLocaleDateString()})` : 'unknown';
|
|
1203
|
+
+ const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}` : '—';
|
|
1204
|
+
+ 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}`);
|
|
1205
|
+
+ }
|
|
1206
|
+
+
|
|
1207
|
+
+ // Daily burn rate summary
|
|
1208
|
+
+ const totalDailyRate = sessions.filter(s => s.dailyCost).reduce((sum, s) => sum + s.dailyCost, 0);
|
|
1209
|
+
+ if (totalDailyRate > 0) {
|
|
1210
|
+
+ console.log();
|
|
1211
|
+
+ console.log(` ${D}Combined burn rate: ${R}${RED}$${totalDailyRate.toFixed(4)}/day${R}${D} · ~$${(totalDailyRate * 30).toFixed(2)}/month${R}`);
|
|
1212
|
+
}
|
|
1213
|
+
console.log();
|
|
1214
|
+
}
|
|
1215
|
+
@@ -92,8 +115,17 @@ function generateTerminalReport(analysis) {
|
|
1216
|
+
console.log(`${C}━━━ Summary ━━━${R}`);
|
|
1217
|
+
console.log(` 🔴 ${RED}${summary.critical}${R} critical 🟠 ${summary.high} high 🟡 ${summary.medium} medium 🔵 ${summary.low||0} low ✅ ${summary.info} ok`);
|
|
1218
|
+
console.log(` Sessions analyzed: ${summary.sessionsAnalyzed} · Tokens found: ${(summary.totalTokensFound||0).toLocaleString()}`);
|
|
1219
|
+
+ if (summary.totalCacheRead > 0 || summary.totalCacheWrite > 0) {
|
|
1220
|
+
+ console.log(` ${D}Cache tokens: ${(summary.totalCacheRead||0).toLocaleString()} read · ${(summary.totalCacheWrite||0).toLocaleString()} write${R}`);
|
|
1221
|
+
+ }
|
|
1222
|
+
+ if (summary.totalRealCost > 0) {
|
|
1223
|
+
+ console.log(` ${B}Actual API spend: ${RED}$${summary.totalRealCost.toFixed(4)}${R}${B} (from .jsonl transcripts)${R}`);
|
|
1224
|
+
+ if (summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1) {
|
|
1225
|
+
+ console.log(` ${D}sessions.json estimate: $${summary.totalEstimatedCost.toFixed(4)} — ${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x gap (cache tokens)${R}`);
|
|
1226
|
+
+ }
|
|
1227
|
+
+ }
|
|
1228
|
+
if (bleed > 0) {
|
|
1229
|
+
- console.log(` ${RED}${B}Monthly bleed: $${bleed.toFixed(2)}/month${R}`);
|
|
1230
|
+
+ console.log(` ${RED}${B}Estimated monthly bleed: $${bleed.toFixed(2)}/month${R}`);
|
|
1231
|
+
} else {
|
|
1232
|
+
console.log(` ${GRN}No significant cost bleed detected ✓${R}`);
|
|
1233
|
+
}
|