clawculator 2.2.2 → 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.2",
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": {
@@ -709,13 +709,19 @@ function analyzeSessions(sessionsPath) {
709
709
  // Scan for untracked .jsonl files (sessions not in sessions.json)
710
710
  const trackedIds = new Set(Object.values(sessions).map(s => s.sessionId).filter(Boolean));
711
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
+
712
717
  if (sessionsDir) {
713
718
  try {
714
719
  for (const file of fs.readdirSync(sessionsDir)) {
715
720
  if (!file.endsWith('.jsonl')) continue;
716
721
  const fileId = file.replace('.jsonl', '');
717
722
  if (trackedIds.has(fileId)) continue; // already counted
718
- const transcript = parseTranscript(path.join(sessionsDir, file));
723
+ const filePath = path.join(sessionsDir, file);
724
+ const transcript = parseTranscript(filePath);
719
725
  if (transcript && transcript.messageCount > 0) {
720
726
  untrackedCount++;
721
727
  untrackedCost += transcript.totalCost;
@@ -723,6 +729,14 @@ function analyzeSessions(sessionsPath) {
723
729
  totalRealCost += transcript.totalCost;
724
730
  totalCacheRead += transcript.cacheRead;
725
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
+
726
740
  breakdown.push({
727
741
  key: `untracked:${fileId.slice(0, 8)}`, sessionId: fileId, model: transcript.model,
728
742
  modelLabel: transcript.model ? (MODEL_PRICING[resolveModel(transcript.model)]?.label || transcript.model) : 'unknown',
@@ -732,8 +746,9 @@ function analyzeSessions(sessionsPath) {
732
746
  hasTranscript: true, isSharedSession: false,
733
747
  messageCount: transcript.messageCount,
734
748
  updatedAt: transcript.lastTs ? new Date(transcript.lastTs).toISOString() : null,
735
- ageMs: transcript.lastTs ? Date.now() - transcript.lastTs : null,
749
+ ageMs: transcript.lastTs ? NOW - transcript.lastTs : null,
736
750
  dailyCost: null, isOrphaned: false, isUntracked: true,
751
+ isRecent, isToday,
737
752
  });
738
753
  }
739
754
  }
@@ -741,11 +756,14 @@ function analyzeSessions(sessionsPath) {
741
756
  }
742
757
 
743
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');
744
762
  findings.push({
745
- severity: untrackedCost > 1 ? 'high' : 'medium',
763
+ severity: untrackedRecentCost > 1 ? 'high' : (untrackedCost > 1 ? 'medium' : 'info'),
746
764
  source: 'sessions',
747
- message: `${untrackedCount} untracked session(s) found .jsonl files not in sessions.json`,
748
- 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'),
749
767
  });
750
768
  }
751
769
 
@@ -794,10 +812,22 @@ function analyzeSessions(sessionsPath) {
794
812
  }
795
813
  }
796
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
+
797
826
  return {
798
827
  exists: true, findings, sessions: breakdown,
799
828
  totalInputTokens: totalIn, totalOutputTokens: totalOut,
800
829
  totalCost, totalCacheRead, totalCacheWrite, totalRealCost,
830
+ todayCost,
801
831
  sessionCount: Object.keys(sessions).length,
802
832
  };
803
833
  }
@@ -905,6 +935,7 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
905
935
  totalCacheWrite: sessionResult.totalCacheWrite || 0,
906
936
  totalRealCost: realCost,
907
937
  totalEstimatedCost: sessionResult.totalCost || 0,
938
+ todayCost: sessionResult.todayCost || 0,
908
939
  },
909
940
  sessions: sessionResult.sessions || [],
910
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
@@ -709,13 +709,19 @@ function analyzeSessions(sessionsPath) {
709
709
  // Scan for untracked .jsonl files (sessions not in sessions.json)
710
710
  const trackedIds = new Set(Object.values(sessions).map(s => s.sessionId).filter(Boolean));
711
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
+
712
717
  if (sessionsDir) {
713
718
  try {
714
719
  for (const file of fs.readdirSync(sessionsDir)) {
715
720
  if (!file.endsWith('.jsonl')) continue;
716
721
  const fileId = file.replace('.jsonl', '');
717
722
  if (trackedIds.has(fileId)) continue; // already counted
718
- const transcript = parseTranscript(path.join(sessionsDir, file));
723
+ const filePath = path.join(sessionsDir, file);
724
+ const transcript = parseTranscript(filePath);
719
725
  if (transcript && transcript.messageCount > 0) {
720
726
  untrackedCount++;
721
727
  untrackedCost += transcript.totalCost;
@@ -723,6 +729,14 @@ function analyzeSessions(sessionsPath) {
723
729
  totalRealCost += transcript.totalCost;
724
730
  totalCacheRead += transcript.cacheRead;
725
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
+
726
740
  breakdown.push({
727
741
  key: `untracked:${fileId.slice(0, 8)}`, sessionId: fileId, model: transcript.model,
728
742
  modelLabel: transcript.model ? (MODEL_PRICING[resolveModel(transcript.model)]?.label || transcript.model) : 'unknown',
@@ -732,8 +746,9 @@ function analyzeSessions(sessionsPath) {
732
746
  hasTranscript: true, isSharedSession: false,
733
747
  messageCount: transcript.messageCount,
734
748
  updatedAt: transcript.lastTs ? new Date(transcript.lastTs).toISOString() : null,
735
- ageMs: transcript.lastTs ? Date.now() - transcript.lastTs : null,
749
+ ageMs: transcript.lastTs ? NOW - transcript.lastTs : null,
736
750
  dailyCost: null, isOrphaned: false, isUntracked: true,
751
+ isRecent, isToday,
737
752
  });
738
753
  }
739
754
  }
@@ -741,11 +756,14 @@ function analyzeSessions(sessionsPath) {
741
756
  }
742
757
 
743
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');
744
762
  findings.push({
745
- severity: untrackedCost > 1 ? 'high' : 'medium',
763
+ severity: untrackedRecentCost > 1 ? 'high' : (untrackedCost > 1 ? 'medium' : 'info'),
746
764
  source: 'sessions',
747
- message: `${untrackedCount} untracked session(s) found .jsonl files not in sessions.json`,
748
- 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'),
749
767
  });
750
768
  }
751
769
 
@@ -794,10 +812,22 @@ function analyzeSessions(sessionsPath) {
794
812
  }
795
813
  }
796
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
+
797
826
  return {
798
827
  exists: true, findings, sessions: breakdown,
799
828
  totalInputTokens: totalIn, totalOutputTokens: totalOut,
800
829
  totalCost, totalCacheRead, totalCacheWrite, totalRealCost,
830
+ todayCost,
801
831
  sessionCount: Object.keys(sessions).length,
802
832
  };
803
833
  }
@@ -905,6 +935,7 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
905
935
  totalCacheWrite: sessionResult.totalCacheWrite || 0,
906
936
  totalRealCost: realCost,
907
937
  totalEstimatedCost: sessionResult.totalCost || 0,
938
+ todayCost: sessionResult.todayCost || 0,
908
939
  },
909
940
  sessions: sessionResult.sessions || [],
910
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
  }