clawculator 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawculator",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
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": {
@@ -528,13 +528,16 @@ function parseTranscript(jsonlPath) {
528
528
 
529
529
  // Only assistant messages with usage blocks have cost data
530
530
  if (entry.type !== 'message') continue;
531
- if (!entry.usage) continue;
531
+
532
+ // Usage can be at entry.usage (some formats) or entry.message.usage (standard format)
533
+ const u = entry.usage || entry.message?.usage;
534
+ if (!u) continue;
532
535
 
533
536
  messageCount++;
534
- const u = entry.usage;
535
537
 
536
- // Use model from transcript (most accurate)
537
- if (entry.model && !model) model = entry.model;
538
+ // Model can be at entry.model or entry.message.model
539
+ const entryModel = entry.model || entry.message?.model;
540
+ if (entryModel && !model) model = entryModel;
538
541
 
539
542
  input += u.input || 0;
540
543
  output += u.output || 0;
@@ -706,13 +709,19 @@ function analyzeSessions(sessionsPath) {
706
709
  // Scan for untracked .jsonl files (sessions not in sessions.json)
707
710
  const trackedIds = new Set(Object.values(sessions).map(s => s.sessionId).filter(Boolean));
708
711
  let untrackedCount = 0, untrackedCost = 0, untrackedTokens = 0;
712
+ let untrackedRecentCount = 0, untrackedRecentCost = 0;
713
+ const NOW = Date.now();
714
+ const TODAY_START = new Date(); TODAY_START.setHours(0, 0, 0, 0);
715
+ const todayMs = TODAY_START.getTime();
716
+
709
717
  if (sessionsDir) {
710
718
  try {
711
719
  for (const file of fs.readdirSync(sessionsDir)) {
712
720
  if (!file.endsWith('.jsonl')) continue;
713
721
  const fileId = file.replace('.jsonl', '');
714
722
  if (trackedIds.has(fileId)) continue; // already counted
715
- const transcript = parseTranscript(path.join(sessionsDir, file));
723
+ const filePath = path.join(sessionsDir, file);
724
+ const transcript = parseTranscript(filePath);
716
725
  if (transcript && transcript.messageCount > 0) {
717
726
  untrackedCount++;
718
727
  untrackedCost += transcript.totalCost;
@@ -720,6 +729,14 @@ function analyzeSessions(sessionsPath) {
720
729
  totalRealCost += transcript.totalCost;
721
730
  totalCacheRead += transcript.cacheRead;
722
731
  totalCacheWrite += transcript.cacheWrite;
732
+
733
+ // Check if this file was modified today (recent vs historical)
734
+ let fileMtime = null;
735
+ try { fileMtime = fs.statSync(filePath).mtimeMs; } catch {}
736
+ const isRecent = fileMtime && (NOW - fileMtime < 48 * 3600 * 1000);
737
+ const isToday = fileMtime && fileMtime >= todayMs;
738
+ if (isRecent) { untrackedRecentCount++; untrackedRecentCost += transcript.totalCost; }
739
+
723
740
  breakdown.push({
724
741
  key: `untracked:${fileId.slice(0, 8)}`, sessionId: fileId, model: transcript.model,
725
742
  modelLabel: transcript.model ? (MODEL_PRICING[resolveModel(transcript.model)]?.label || transcript.model) : 'unknown',
@@ -729,8 +746,9 @@ function analyzeSessions(sessionsPath) {
729
746
  hasTranscript: true, isSharedSession: false,
730
747
  messageCount: transcript.messageCount,
731
748
  updatedAt: transcript.lastTs ? new Date(transcript.lastTs).toISOString() : null,
732
- ageMs: transcript.lastTs ? Date.now() - transcript.lastTs : null,
749
+ ageMs: transcript.lastTs ? NOW - transcript.lastTs : null,
733
750
  dailyCost: null, isOrphaned: false, isUntracked: true,
751
+ isRecent, isToday,
734
752
  });
735
753
  }
736
754
  }
@@ -738,11 +756,14 @@ function analyzeSessions(sessionsPath) {
738
756
  }
739
757
 
740
758
  if (untrackedCount > 0) {
759
+ const detail = [`${untrackedTokens.toLocaleString()} tokens · $${untrackedCost.toFixed(4)} total API costs`];
760
+ if (untrackedRecentCount > 0) detail.push(`${untrackedRecentCount} active in last 48h ($${untrackedRecentCost.toFixed(4)})`);
761
+ detail.push('These are sessions not listed in sessions.json — old/deleted or spawned by subprocesses');
741
762
  findings.push({
742
- severity: untrackedCost > 1 ? 'high' : 'medium',
763
+ severity: untrackedRecentCost > 1 ? 'high' : (untrackedCost > 1 ? 'medium' : 'info'),
743
764
  source: 'sessions',
744
- message: `${untrackedCount} untracked session(s) found .jsonl files not in sessions.json`,
745
- detail: `${untrackedTokens.toLocaleString()} tokens · $${untrackedCost.toFixed(4)} in API costs\nThese are old/deleted sessions still on disk with real spend data`,
765
+ message: `${untrackedCount} untracked session(s) found (${untrackedRecentCount} recent)`,
766
+ detail: detail.join('\n'),
746
767
  });
747
768
  }
748
769
 
@@ -791,10 +812,22 @@ function analyzeSessions(sessionsPath) {
791
812
  }
792
813
  }
793
814
 
815
+ // Compute today's cost across all sessions (tracked + untracked)
816
+ let todayCost = 0;
817
+ for (const s of breakdown) {
818
+ if (s.isToday) { todayCost += s.cost; continue; }
819
+ // For tracked sessions, check if updatedAt is today
820
+ if (!s.isUntracked && s.updatedAt) {
821
+ const upd = new Date(s.updatedAt).getTime();
822
+ if (upd >= todayMs) todayCost += s.cost;
823
+ }
824
+ }
825
+
794
826
  return {
795
827
  exists: true, findings, sessions: breakdown,
796
828
  totalInputTokens: totalIn, totalOutputTokens: totalOut,
797
829
  totalCost, totalCacheRead, totalCacheWrite, totalRealCost,
830
+ todayCost,
798
831
  sessionCount: Object.keys(sessions).length,
799
832
  };
800
833
  }
@@ -902,6 +935,7 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
902
935
  totalCacheWrite: sessionResult.totalCacheWrite || 0,
903
936
  totalRealCost: realCost,
904
937
  totalEstimatedCost: sessionResult.totalCost || 0,
938
+ todayCost: sessionResult.todayCost || 0,
905
939
  },
906
940
  sessions: sessionResult.sessions || [],
907
941
  webChatSessions,
@@ -40,9 +40,9 @@ 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);
43
+ // Burn summary calculation — only tracked sessions (not historical untracked)
44
+ const trackedSessions = (sessions || []).filter(s => !s.isUntracked && s.dailyCost);
45
+ const totalDaily = trackedSessions.reduce((sum, s) => sum + (s.dailyCost || 0), 0);
46
46
  const totalMonthly = totalDaily * 30;
47
47
  const burnSummary = totalDaily > 0 ? `
48
48
  <div class="section">
@@ -50,7 +50,7 @@ async function generateHTMLReport(analysis, outPath) {
50
50
  <div style="display:flex; gap:32px; flex-wrap:wrap;">
51
51
  <div>
52
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>
53
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Combined daily burn rate across ${trackedSessions.length} active session${trackedSessions.length !== 1 ? 's' : ''}</div>
54
54
  </div>
55
55
  <div>
56
56
  <div style="font-size:24px; font-weight:800; color:#f97316;">$${totalMonthly.toFixed(2)}/month</div>
@@ -73,10 +73,7 @@ async function generateHTMLReport(analysis, outPath) {
73
73
  </div>
74
74
  `).join('');
75
75
 
76
- const sessionRows = (sessions || [])
77
- .sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens))
78
- .slice(0, 20)
79
- .map(s => {
76
+ const makeSessionRow = (s) => {
80
77
  const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '…' : s.key;
81
78
  const flag = s.isOrphaned ? ' ⚠️' : '';
82
79
  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>';
@@ -84,16 +81,33 @@ async function generateHTMLReport(analysis, outPath) {
84
81
  const absDate = s.updatedAt ? new Date(s.updatedAt).toLocaleString() : '';
85
82
  const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}/day` : '—';
86
83
  const cacheTitle = (s.cacheRead || s.cacheWrite) ? `Cache R: ${(s.cacheRead||0).toLocaleString()} · W: ${(s.cacheWrite||0).toLocaleString()}` : '';
84
+ const rowBg = s.isOrphaned ? 'background:#fff7ed' : (s.isRecent ? 'background:#fffbeb' : '');
87
85
  return `
88
- <tr style="${s.isOrphaned ? 'background:#fff7ed' : ''}">
86
+ <tr style="${rowBg}">
89
87
  <td style="padding:8px 12px; font-family:monospace; font-size:13px">${keyDisplay}${flag}${txBadge}</td>
90
88
  <td style="padding:8px 12px">${s.modelLabel || s.model}</td>
91
89
  <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>
92
- <td style="padding:8px 12px; text-align:right; color:${s.cost > 0.01 ? '#ef4444' : '#22c55e'}">$${s.cost.toFixed(6)}</td>
90
+ <td style="padding:8px 12px; text-align:right; color:${s.cost > 0.01 ? '#ef4444' : '#22c55e'}">$${s.cost.toFixed(4)}</td>
93
91
  <td style="padding:8px 12px; text-align:right; color:#f59e0b">${daily}</td>
94
92
  <td style="padding:8px 12px; color:#6b7280; font-size:13px" title="${absDate}">${age}</td>
95
- </tr>
96
- `}).join('');
93
+ </tr>`;
94
+ };
95
+
96
+ const activeSessions = (sessions || []).filter(s => !s.isUntracked);
97
+ const untrackedSessions = (sessions || []).filter(s => s.isUntracked);
98
+
99
+ const activeRows = activeSessions
100
+ .sort((a, b) => b.cost - a.cost)
101
+ .slice(0, 20)
102
+ .map(makeSessionRow).join('');
103
+
104
+ const untrackedRows = untrackedSessions
105
+ .sort((a, b) => b.cost - a.cost)
106
+ .slice(0, 10)
107
+ .map(makeSessionRow).join('');
108
+
109
+ const untrackedTotal = untrackedSessions.reduce((sum, s) => sum + s.cost, 0);
110
+ const untrackedHidden = Math.max(0, untrackedSessions.length - 10);
97
111
 
98
112
  const html = `<!DOCTYPE html>
99
113
  <html lang="en">
@@ -144,9 +158,14 @@ async function generateHTMLReport(analysis, outPath) {
144
158
  <div class="section" style="border:1px solid #f59e0b;">
145
159
  <div class="section-title" style="color:#f59e0b;">💰 Actual API Spend (from transcripts)</div>
146
160
  <div style="display:flex; gap:32px; flex-wrap:wrap; align-items:center;">
161
+ ${summary.todayCost > 0 ? `
162
+ <div>
163
+ <div style="font-size:36px; font-weight:900; color:#ef4444;">$${summary.todayCost.toFixed(2)}</div>
164
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Today's spend</div>
165
+ </div>` : ''}
147
166
  <div>
148
- <div style="font-size:36px; font-weight:900; color:#fbbf24;">$${summary.totalRealCost.toFixed(4)}</div>
149
- <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Total real cost (API-reported)</div>
167
+ <div style="font-size:36px; font-weight:900; color:#fbbf24;">$${summary.totalRealCost.toFixed(2)}</div>
168
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">All-time total (API-reported)</div>
150
169
  </div>
151
170
  ${summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1 ? `
152
171
  <div style="background:#451a03; padding:10px 16px; border-radius:8px; border:1px solid #92400e;">
@@ -199,17 +218,32 @@ async function generateHTMLReport(analysis, outPath) {
199
218
  </div>
200
219
  </div>
201
220
 
202
- ${sessionRows ? `
221
+ ${activeRows ? `
203
222
  <div class="section">
204
- <div class="section-title">Session Breakdown</div>
223
+ <div class="section-title">Active Sessions</div>
205
224
  <div style="overflow-x:auto">
206
225
  <table>
207
226
  <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>
208
- <tbody>${sessionRows}</tbody>
227
+ <tbody>${activeRows}</tbody>
209
228
  </table>
210
229
  </div>
211
- </div>
212
- ${burnSummary}` : ''}
230
+ <div style="margin-top:8px; font-size:12px; color:#64748b;">● = cost from transcript · ○ = estimated · ⚠️ = orphaned</div>
231
+ </div>` : ''}
232
+
233
+ ${untrackedRows ? `
234
+ <div class="section" style="border:1px solid #475569;">
235
+ <div class="section-title" style="color:#94a3b8;">📦 Historical Sessions — $${untrackedTotal.toFixed(2)} total</div>
236
+ <div style="font-size:13px; color:#64748b; margin-bottom:12px;">Old/deleted sessions still on disk. Top 10 by cost.</div>
237
+ <div style="overflow-x:auto">
238
+ <table>
239
+ <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>
240
+ <tbody>${untrackedRows}</tbody>
241
+ </table>
242
+ </div>
243
+ ${untrackedHidden > 0 ? `<div style="margin-top:8px; font-size:12px; color:#64748b;">+ ${untrackedHidden} more not shown</div>` : ''}
244
+ </div>` : ''}
245
+
246
+ ${burnSummary} : ''}
213
247
 
214
248
  </div>
215
249
  <div class="footer">
@@ -87,30 +87,51 @@ function generateTerminalReport(analysis) {
87
87
  }
88
88
  }
89
89
 
90
- // Session breakdown
91
- if (sessions?.length > 0) {
92
- console.log(`${C}━━━ Top Sessions by Token Usage ━━━${R}\n`);
93
- const sorted = [...sessions].sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens)).slice(0, 8);
90
+ // Session breakdown — split active from historical
91
+ const activeSess = (sessions || []).filter(s => !s.isUntracked);
92
+ const untrackedSess = (sessions || []).filter(s => s.isUntracked);
93
+
94
+ if (activeSess.length > 0) {
95
+ console.log(`${C}━━━ Active Sessions ━━━${R}\n`);
96
+ const sorted = [...activeSess].sort((a, b) => b.cost - a.cost).slice(0, 8);
94
97
  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}`);
98
+ console.log(` ${D}${'\u2500'.repeat(95)}${R}`);
96
99
  for (const s of sorted) {
97
100
  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;
101
+ const flag = s.isOrphaned ? ' \u26a0\ufe0f' : '';
102
+ const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '\u2026' : s.key;
100
103
  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}`);
104
+ const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}` : '\u2014';
105
+ console.log(` ${(keyDisplay + flag).padEnd(16)} ${(s.modelLabel || s.model || 'unknown').slice(0, 20).padEnd(20)} ${tok.padEnd(10)} $${s.cost.toFixed(4).padEnd(11)} ${daily.padEnd(10)} ${D}${age}${R}`);
103
106
  }
104
107
 
105
- // Daily burn rate summary
106
- const totalDailyRate = sessions.filter(s => s.dailyCost).reduce((sum, s) => sum + s.dailyCost, 0);
108
+ // Daily burn rate — tracked sessions only
109
+ const totalDailyRate = activeSess.filter(s => s.dailyCost).reduce((sum, s) => sum + s.dailyCost, 0);
107
110
  if (totalDailyRate > 0) {
108
111
  console.log();
109
- console.log(` ${D}Combined burn rate: ${R}${RED}$${totalDailyRate.toFixed(4)}/day${R}${D} · ~$${(totalDailyRate * 30).toFixed(2)}/month${R}`);
112
+ console.log(` ${D}Combined burn rate: ${R}${RED}$${totalDailyRate.toFixed(4)}/day${R}${D} \u00b7 ~$${(totalDailyRate * 30).toFixed(2)}/month${R}`);
113
+ }
114
+ console.log();
115
+ }
116
+
117
+ if (untrackedSess.length > 0) {
118
+ const untrackedTotal = untrackedSess.reduce((sum, s) => sum + s.cost, 0);
119
+ console.log(`${C}━━━ Historical Sessions (${untrackedSess.length} untracked) \u2014 $${untrackedTotal.toFixed(2)} total ━━━${R}\n`);
120
+ const sorted = [...untrackedSess].sort((a, b) => b.cost - a.cost).slice(0, 5);
121
+ console.log(` ${D}${'Session'.padEnd(16)} ${'Model'.padEnd(20)} ${'Tokens'.padEnd(10)} ${'Total Cost'.padEnd(12)} Last Active${R}`);
122
+ console.log(` ${D}${'\u2500'.repeat(80)}${R}`);
123
+ for (const s of sorted) {
124
+ const tok = (s.inputTokens + s.outputTokens).toLocaleString();
125
+ const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '\u2026' : s.key;
126
+ const age = s.ageMs ? `${relativeAge(s.ageMs)} (${new Date(s.updatedAt).toLocaleDateString()})` : 'unknown';
127
+ console.log(` ${keyDisplay.padEnd(16)} ${(s.modelLabel || s.model || 'unknown').slice(0, 20).padEnd(20)} ${tok.padEnd(10)} $${s.cost.toFixed(4).padEnd(11)} ${D}${age}${R}`);
110
128
  }
129
+ const hidden = Math.max(0, untrackedSess.length - 5);
130
+ if (hidden > 0) console.log(` ${D}+ ${hidden} more not shown${R}`);
111
131
  console.log();
112
132
  }
113
133
 
134
+
114
135
  // Summary
115
136
  console.log(`${C}━━━ Summary ━━━${R}`);
116
137
  console.log(` 🔴 ${RED}${summary.critical}${R} critical 🟠 ${summary.high} high 🟡 ${summary.medium} medium 🔵 ${summary.low||0} low ✅ ${summary.info} ok`);
@@ -119,7 +140,11 @@ function generateTerminalReport(analysis) {
119
140
  console.log(` ${D}Cache tokens: ${(summary.totalCacheRead||0).toLocaleString()} read · ${(summary.totalCacheWrite||0).toLocaleString()} write${R}`);
120
141
  }
121
142
  if (summary.totalRealCost > 0) {
122
- console.log(` ${B}Actual API spend: ${RED}$${summary.totalRealCost.toFixed(4)}${R}${B} (from .jsonl transcripts)${R}`);
143
+ if (summary.todayCost > 0) {
144
+ console.log(` ${B}Today's spend: ${RED}$${summary.todayCost.toFixed(2)}${R}${B} · All-time: ${RED}$${summary.totalRealCost.toFixed(2)}${R}${B} (from .jsonl transcripts)${R}`);
145
+ } else {
146
+ console.log(` ${B}Actual API spend: ${RED}$${summary.totalRealCost.toFixed(2)}${R}${B} (from .jsonl transcripts)${R}`);
147
+ }
123
148
  if (summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1) {
124
149
  console.log(` ${D}sessions.json estimate: $${summary.totalEstimatedCost.toFixed(4)} — ${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x gap (cache tokens)${R}`);
125
150
  }
package/src/analyzer.js CHANGED
@@ -528,13 +528,16 @@ function parseTranscript(jsonlPath) {
528
528
 
529
529
  // Only assistant messages with usage blocks have cost data
530
530
  if (entry.type !== 'message') continue;
531
- if (!entry.usage) continue;
531
+
532
+ // Usage can be at entry.usage (some formats) or entry.message.usage (standard format)
533
+ const u = entry.usage || entry.message?.usage;
534
+ if (!u) continue;
532
535
 
533
536
  messageCount++;
534
- const u = entry.usage;
535
537
 
536
- // Use model from transcript (most accurate)
537
- if (entry.model && !model) model = entry.model;
538
+ // Model can be at entry.model or entry.message.model
539
+ const entryModel = entry.model || entry.message?.model;
540
+ if (entryModel && !model) model = entryModel;
538
541
 
539
542
  input += u.input || 0;
540
543
  output += u.output || 0;
@@ -706,13 +709,19 @@ function analyzeSessions(sessionsPath) {
706
709
  // Scan for untracked .jsonl files (sessions not in sessions.json)
707
710
  const trackedIds = new Set(Object.values(sessions).map(s => s.sessionId).filter(Boolean));
708
711
  let untrackedCount = 0, untrackedCost = 0, untrackedTokens = 0;
712
+ let untrackedRecentCount = 0, untrackedRecentCost = 0;
713
+ const NOW = Date.now();
714
+ const TODAY_START = new Date(); TODAY_START.setHours(0, 0, 0, 0);
715
+ const todayMs = TODAY_START.getTime();
716
+
709
717
  if (sessionsDir) {
710
718
  try {
711
719
  for (const file of fs.readdirSync(sessionsDir)) {
712
720
  if (!file.endsWith('.jsonl')) continue;
713
721
  const fileId = file.replace('.jsonl', '');
714
722
  if (trackedIds.has(fileId)) continue; // already counted
715
- const transcript = parseTranscript(path.join(sessionsDir, file));
723
+ const filePath = path.join(sessionsDir, file);
724
+ const transcript = parseTranscript(filePath);
716
725
  if (transcript && transcript.messageCount > 0) {
717
726
  untrackedCount++;
718
727
  untrackedCost += transcript.totalCost;
@@ -720,6 +729,14 @@ function analyzeSessions(sessionsPath) {
720
729
  totalRealCost += transcript.totalCost;
721
730
  totalCacheRead += transcript.cacheRead;
722
731
  totalCacheWrite += transcript.cacheWrite;
732
+
733
+ // Check if this file was modified today (recent vs historical)
734
+ let fileMtime = null;
735
+ try { fileMtime = fs.statSync(filePath).mtimeMs; } catch {}
736
+ const isRecent = fileMtime && (NOW - fileMtime < 48 * 3600 * 1000);
737
+ const isToday = fileMtime && fileMtime >= todayMs;
738
+ if (isRecent) { untrackedRecentCount++; untrackedRecentCost += transcript.totalCost; }
739
+
723
740
  breakdown.push({
724
741
  key: `untracked:${fileId.slice(0, 8)}`, sessionId: fileId, model: transcript.model,
725
742
  modelLabel: transcript.model ? (MODEL_PRICING[resolveModel(transcript.model)]?.label || transcript.model) : 'unknown',
@@ -729,8 +746,9 @@ function analyzeSessions(sessionsPath) {
729
746
  hasTranscript: true, isSharedSession: false,
730
747
  messageCount: transcript.messageCount,
731
748
  updatedAt: transcript.lastTs ? new Date(transcript.lastTs).toISOString() : null,
732
- ageMs: transcript.lastTs ? Date.now() - transcript.lastTs : null,
749
+ ageMs: transcript.lastTs ? NOW - transcript.lastTs : null,
733
750
  dailyCost: null, isOrphaned: false, isUntracked: true,
751
+ isRecent, isToday,
734
752
  });
735
753
  }
736
754
  }
@@ -738,11 +756,14 @@ function analyzeSessions(sessionsPath) {
738
756
  }
739
757
 
740
758
  if (untrackedCount > 0) {
759
+ const detail = [`${untrackedTokens.toLocaleString()} tokens · $${untrackedCost.toFixed(4)} total API costs`];
760
+ if (untrackedRecentCount > 0) detail.push(`${untrackedRecentCount} active in last 48h ($${untrackedRecentCost.toFixed(4)})`);
761
+ detail.push('These are sessions not listed in sessions.json — old/deleted or spawned by subprocesses');
741
762
  findings.push({
742
- severity: untrackedCost > 1 ? 'high' : 'medium',
763
+ severity: untrackedRecentCost > 1 ? 'high' : (untrackedCost > 1 ? 'medium' : 'info'),
743
764
  source: 'sessions',
744
- message: `${untrackedCount} untracked session(s) found .jsonl files not in sessions.json`,
745
- detail: `${untrackedTokens.toLocaleString()} tokens · $${untrackedCost.toFixed(4)} in API costs\nThese are old/deleted sessions still on disk with real spend data`,
765
+ message: `${untrackedCount} untracked session(s) found (${untrackedRecentCount} recent)`,
766
+ detail: detail.join('\n'),
746
767
  });
747
768
  }
748
769
 
@@ -791,10 +812,22 @@ function analyzeSessions(sessionsPath) {
791
812
  }
792
813
  }
793
814
 
815
+ // Compute today's cost across all sessions (tracked + untracked)
816
+ let todayCost = 0;
817
+ for (const s of breakdown) {
818
+ if (s.isToday) { todayCost += s.cost; continue; }
819
+ // For tracked sessions, check if updatedAt is today
820
+ if (!s.isUntracked && s.updatedAt) {
821
+ const upd = new Date(s.updatedAt).getTime();
822
+ if (upd >= todayMs) todayCost += s.cost;
823
+ }
824
+ }
825
+
794
826
  return {
795
827
  exists: true, findings, sessions: breakdown,
796
828
  totalInputTokens: totalIn, totalOutputTokens: totalOut,
797
829
  totalCost, totalCacheRead, totalCacheWrite, totalRealCost,
830
+ todayCost,
798
831
  sessionCount: Object.keys(sessions).length,
799
832
  };
800
833
  }
@@ -902,6 +935,7 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
902
935
  totalCacheWrite: sessionResult.totalCacheWrite || 0,
903
936
  totalRealCost: realCost,
904
937
  totalEstimatedCost: sessionResult.totalCost || 0,
938
+ todayCost: sessionResult.todayCost || 0,
905
939
  },
906
940
  sessions: sessionResult.sessions || [],
907
941
  webChatSessions,
package/src/htmlReport.js CHANGED
@@ -40,9 +40,9 @@ 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);
43
+ // Burn summary calculation — only tracked sessions (not historical untracked)
44
+ const trackedSessions = (sessions || []).filter(s => !s.isUntracked && s.dailyCost);
45
+ const totalDaily = trackedSessions.reduce((sum, s) => sum + (s.dailyCost || 0), 0);
46
46
  const totalMonthly = totalDaily * 30;
47
47
  const burnSummary = totalDaily > 0 ? `
48
48
  <div class="section">
@@ -50,7 +50,7 @@ async function generateHTMLReport(analysis, outPath) {
50
50
  <div style="display:flex; gap:32px; flex-wrap:wrap;">
51
51
  <div>
52
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>
53
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Combined daily burn rate across ${trackedSessions.length} active session${trackedSessions.length !== 1 ? 's' : ''}</div>
54
54
  </div>
55
55
  <div>
56
56
  <div style="font-size:24px; font-weight:800; color:#f97316;">$${totalMonthly.toFixed(2)}/month</div>
@@ -73,10 +73,7 @@ async function generateHTMLReport(analysis, outPath) {
73
73
  </div>
74
74
  `).join('');
75
75
 
76
- const sessionRows = (sessions || [])
77
- .sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens))
78
- .slice(0, 20)
79
- .map(s => {
76
+ const makeSessionRow = (s) => {
80
77
  const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '…' : s.key;
81
78
  const flag = s.isOrphaned ? ' ⚠️' : '';
82
79
  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>';
@@ -84,16 +81,33 @@ async function generateHTMLReport(analysis, outPath) {
84
81
  const absDate = s.updatedAt ? new Date(s.updatedAt).toLocaleString() : '';
85
82
  const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}/day` : '—';
86
83
  const cacheTitle = (s.cacheRead || s.cacheWrite) ? `Cache R: ${(s.cacheRead||0).toLocaleString()} · W: ${(s.cacheWrite||0).toLocaleString()}` : '';
84
+ const rowBg = s.isOrphaned ? 'background:#fff7ed' : (s.isRecent ? 'background:#fffbeb' : '');
87
85
  return `
88
- <tr style="${s.isOrphaned ? 'background:#fff7ed' : ''}">
86
+ <tr style="${rowBg}">
89
87
  <td style="padding:8px 12px; font-family:monospace; font-size:13px">${keyDisplay}${flag}${txBadge}</td>
90
88
  <td style="padding:8px 12px">${s.modelLabel || s.model}</td>
91
89
  <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>
92
- <td style="padding:8px 12px; text-align:right; color:${s.cost > 0.01 ? '#ef4444' : '#22c55e'}">$${s.cost.toFixed(6)}</td>
90
+ <td style="padding:8px 12px; text-align:right; color:${s.cost > 0.01 ? '#ef4444' : '#22c55e'}">$${s.cost.toFixed(4)}</td>
93
91
  <td style="padding:8px 12px; text-align:right; color:#f59e0b">${daily}</td>
94
92
  <td style="padding:8px 12px; color:#6b7280; font-size:13px" title="${absDate}">${age}</td>
95
- </tr>
96
- `}).join('');
93
+ </tr>`;
94
+ };
95
+
96
+ const activeSessions = (sessions || []).filter(s => !s.isUntracked);
97
+ const untrackedSessions = (sessions || []).filter(s => s.isUntracked);
98
+
99
+ const activeRows = activeSessions
100
+ .sort((a, b) => b.cost - a.cost)
101
+ .slice(0, 20)
102
+ .map(makeSessionRow).join('');
103
+
104
+ const untrackedRows = untrackedSessions
105
+ .sort((a, b) => b.cost - a.cost)
106
+ .slice(0, 10)
107
+ .map(makeSessionRow).join('');
108
+
109
+ const untrackedTotal = untrackedSessions.reduce((sum, s) => sum + s.cost, 0);
110
+ const untrackedHidden = Math.max(0, untrackedSessions.length - 10);
97
111
 
98
112
  const html = `<!DOCTYPE html>
99
113
  <html lang="en">
@@ -144,9 +158,14 @@ async function generateHTMLReport(analysis, outPath) {
144
158
  <div class="section" style="border:1px solid #f59e0b;">
145
159
  <div class="section-title" style="color:#f59e0b;">💰 Actual API Spend (from transcripts)</div>
146
160
  <div style="display:flex; gap:32px; flex-wrap:wrap; align-items:center;">
161
+ ${summary.todayCost > 0 ? `
162
+ <div>
163
+ <div style="font-size:36px; font-weight:900; color:#ef4444;">$${summary.todayCost.toFixed(2)}</div>
164
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Today's spend</div>
165
+ </div>` : ''}
147
166
  <div>
148
- <div style="font-size:36px; font-weight:900; color:#fbbf24;">$${summary.totalRealCost.toFixed(4)}</div>
149
- <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Total real cost (API-reported)</div>
167
+ <div style="font-size:36px; font-weight:900; color:#fbbf24;">$${summary.totalRealCost.toFixed(2)}</div>
168
+ <div style="font-size:13px; color:#94a3b8; margin-top:4px;">All-time total (API-reported)</div>
150
169
  </div>
151
170
  ${summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1 ? `
152
171
  <div style="background:#451a03; padding:10px 16px; border-radius:8px; border:1px solid #92400e;">
@@ -199,17 +218,32 @@ async function generateHTMLReport(analysis, outPath) {
199
218
  </div>
200
219
  </div>
201
220
 
202
- ${sessionRows ? `
221
+ ${activeRows ? `
203
222
  <div class="section">
204
- <div class="section-title">Session Breakdown</div>
223
+ <div class="section-title">Active Sessions</div>
205
224
  <div style="overflow-x:auto">
206
225
  <table>
207
226
  <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>
208
- <tbody>${sessionRows}</tbody>
227
+ <tbody>${activeRows}</tbody>
209
228
  </table>
210
229
  </div>
211
- </div>
212
- ${burnSummary}` : ''}
230
+ <div style="margin-top:8px; font-size:12px; color:#64748b;">● = cost from transcript · ○ = estimated · ⚠️ = orphaned</div>
231
+ </div>` : ''}
232
+
233
+ ${untrackedRows ? `
234
+ <div class="section" style="border:1px solid #475569;">
235
+ <div class="section-title" style="color:#94a3b8;">📦 Historical Sessions — $${untrackedTotal.toFixed(2)} total</div>
236
+ <div style="font-size:13px; color:#64748b; margin-bottom:12px;">Old/deleted sessions still on disk. Top 10 by cost.</div>
237
+ <div style="overflow-x:auto">
238
+ <table>
239
+ <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>
240
+ <tbody>${untrackedRows}</tbody>
241
+ </table>
242
+ </div>
243
+ ${untrackedHidden > 0 ? `<div style="margin-top:8px; font-size:12px; color:#64748b;">+ ${untrackedHidden} more not shown</div>` : ''}
244
+ </div>` : ''}
245
+
246
+ ${burnSummary} : ''}
213
247
 
214
248
  </div>
215
249
  <div class="footer">
package/src/reporter.js CHANGED
@@ -87,30 +87,51 @@ function generateTerminalReport(analysis) {
87
87
  }
88
88
  }
89
89
 
90
- // Session breakdown
91
- if (sessions?.length > 0) {
92
- console.log(`${C}━━━ Top Sessions by Token Usage ━━━${R}\n`);
93
- const sorted = [...sessions].sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens)).slice(0, 8);
90
+ // Session breakdown — split active from historical
91
+ const activeSess = (sessions || []).filter(s => !s.isUntracked);
92
+ const untrackedSess = (sessions || []).filter(s => s.isUntracked);
93
+
94
+ if (activeSess.length > 0) {
95
+ console.log(`${C}━━━ Active Sessions ━━━${R}\n`);
96
+ const sorted = [...activeSess].sort((a, b) => b.cost - a.cost).slice(0, 8);
94
97
  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}`);
98
+ console.log(` ${D}${'\u2500'.repeat(95)}${R}`);
96
99
  for (const s of sorted) {
97
100
  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;
101
+ const flag = s.isOrphaned ? ' \u26a0\ufe0f' : '';
102
+ const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '\u2026' : s.key;
100
103
  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}`);
104
+ const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}` : '\u2014';
105
+ console.log(` ${(keyDisplay + flag).padEnd(16)} ${(s.modelLabel || s.model || 'unknown').slice(0, 20).padEnd(20)} ${tok.padEnd(10)} $${s.cost.toFixed(4).padEnd(11)} ${daily.padEnd(10)} ${D}${age}${R}`);
103
106
  }
104
107
 
105
- // Daily burn rate summary
106
- const totalDailyRate = sessions.filter(s => s.dailyCost).reduce((sum, s) => sum + s.dailyCost, 0);
108
+ // Daily burn rate — tracked sessions only
109
+ const totalDailyRate = activeSess.filter(s => s.dailyCost).reduce((sum, s) => sum + s.dailyCost, 0);
107
110
  if (totalDailyRate > 0) {
108
111
  console.log();
109
- console.log(` ${D}Combined burn rate: ${R}${RED}$${totalDailyRate.toFixed(4)}/day${R}${D} · ~$${(totalDailyRate * 30).toFixed(2)}/month${R}`);
112
+ console.log(` ${D}Combined burn rate: ${R}${RED}$${totalDailyRate.toFixed(4)}/day${R}${D} \u00b7 ~$${(totalDailyRate * 30).toFixed(2)}/month${R}`);
113
+ }
114
+ console.log();
115
+ }
116
+
117
+ if (untrackedSess.length > 0) {
118
+ const untrackedTotal = untrackedSess.reduce((sum, s) => sum + s.cost, 0);
119
+ console.log(`${C}━━━ Historical Sessions (${untrackedSess.length} untracked) \u2014 $${untrackedTotal.toFixed(2)} total ━━━${R}\n`);
120
+ const sorted = [...untrackedSess].sort((a, b) => b.cost - a.cost).slice(0, 5);
121
+ console.log(` ${D}${'Session'.padEnd(16)} ${'Model'.padEnd(20)} ${'Tokens'.padEnd(10)} ${'Total Cost'.padEnd(12)} Last Active${R}`);
122
+ console.log(` ${D}${'\u2500'.repeat(80)}${R}`);
123
+ for (const s of sorted) {
124
+ const tok = (s.inputTokens + s.outputTokens).toLocaleString();
125
+ const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '\u2026' : s.key;
126
+ const age = s.ageMs ? `${relativeAge(s.ageMs)} (${new Date(s.updatedAt).toLocaleDateString()})` : 'unknown';
127
+ console.log(` ${keyDisplay.padEnd(16)} ${(s.modelLabel || s.model || 'unknown').slice(0, 20).padEnd(20)} ${tok.padEnd(10)} $${s.cost.toFixed(4).padEnd(11)} ${D}${age}${R}`);
110
128
  }
129
+ const hidden = Math.max(0, untrackedSess.length - 5);
130
+ if (hidden > 0) console.log(` ${D}+ ${hidden} more not shown${R}`);
111
131
  console.log();
112
132
  }
113
133
 
134
+
114
135
  // Summary
115
136
  console.log(`${C}━━━ Summary ━━━${R}`);
116
137
  console.log(` 🔴 ${RED}${summary.critical}${R} critical 🟠 ${summary.high} high 🟡 ${summary.medium} medium 🔵 ${summary.low||0} low ✅ ${summary.info} ok`);
@@ -119,7 +140,11 @@ function generateTerminalReport(analysis) {
119
140
  console.log(` ${D}Cache tokens: ${(summary.totalCacheRead||0).toLocaleString()} read · ${(summary.totalCacheWrite||0).toLocaleString()} write${R}`);
120
141
  }
121
142
  if (summary.totalRealCost > 0) {
122
- console.log(` ${B}Actual API spend: ${RED}$${summary.totalRealCost.toFixed(4)}${R}${B} (from .jsonl transcripts)${R}`);
143
+ if (summary.todayCost > 0) {
144
+ console.log(` ${B}Today's spend: ${RED}$${summary.todayCost.toFixed(2)}${R}${B} · All-time: ${RED}$${summary.totalRealCost.toFixed(2)}${R}${B} (from .jsonl transcripts)${R}`);
145
+ } else {
146
+ console.log(` ${B}Actual API spend: ${RED}$${summary.totalRealCost.toFixed(2)}${R}${B} (from .jsonl transcripts)${R}`);
147
+ }
123
148
  if (summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1) {
124
149
  console.log(` ${D}sessions.json estimate: $${summary.totalEstimatedCost.toFixed(4)} — ${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x gap (cache tokens)${R}`);
125
150
  }