clawculator 2.2.0 → 2.2.2

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.0",
3
+ "version": "2.2.2",
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": {
@@ -347,8 +347,8 @@ function analyzeConfig(configPath) {
347
347
  const hooks = config.hooks?.internal?.entries || config.hooks || {};
348
348
  const hookNames = Object.keys(hooks).filter(k => k !== 'enabled' && k !== 'token' && k !== 'path');
349
349
  let hookIssues = 0;
350
-
351
350
  let haikuHooks = 0;
351
+
352
352
  for (const name of hookNames) {
353
353
  const hook = typeof hooks[name] === 'object' ? hooks[name] : {};
354
354
  if (hook.enabled === false) continue;
@@ -500,14 +500,120 @@ function analyzeConfig(configPath) {
500
500
  }
501
501
 
502
502
  // ── Session analysis ──────────────────────────────────────────────
503
+
504
+ /**
505
+ * Parse a .jsonl session transcript file and sum up real usage/cost data.
506
+ * Returns { input, output, cacheRead, cacheWrite, totalTokens, totalCost, messageCount, model, firstTs, lastTs }
507
+ */
508
+ function parseTranscript(jsonlPath) {
509
+ try {
510
+ const content = fs.readFileSync(jsonlPath, 'utf8').trim();
511
+ if (!content) return null;
512
+
513
+ let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, totalTokens = 0, totalCost = 0;
514
+ let messageCount = 0, model = null, firstTs = null, lastTs = null;
515
+
516
+ for (const line of content.split('\n')) {
517
+ if (!line.trim()) continue;
518
+ let entry;
519
+ try { entry = JSON.parse(line); } catch { continue; }
520
+
521
+ // Track timestamps from all message types
522
+ const ts = entry.timestamp || entry.message?.timestamp;
523
+ if (ts) {
524
+ const t = typeof ts === 'number' ? ts : new Date(ts).getTime();
525
+ if (!firstTs || t < firstTs) firstTs = t;
526
+ if (!lastTs || t > lastTs) lastTs = t;
527
+ }
528
+
529
+ // Only assistant messages with usage blocks have cost data
530
+ if (entry.type !== 'message') 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;
535
+
536
+ messageCount++;
537
+
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;
541
+
542
+ input += u.input || 0;
543
+ output += u.output || 0;
544
+ cacheRead += u.cacheRead || 0;
545
+ cacheWrite += u.cacheWrite || 0;
546
+ totalTokens += u.totalTokens || 0;
547
+
548
+ // Prefer API-reported cost (already accounts for cache pricing)
549
+ if (u.cost) {
550
+ if (typeof u.cost === 'object' && u.cost.total != null) {
551
+ totalCost += u.cost.total;
552
+ } else if (typeof u.cost === 'number') {
553
+ totalCost += u.cost;
554
+ }
555
+ }
556
+ }
557
+
558
+ if (messageCount === 0) return null;
559
+
560
+ return { input, output, cacheRead, cacheWrite, totalTokens, totalCost, messageCount, model, firstTs, lastTs };
561
+ } catch {
562
+ return null;
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Discover all agent session directories (not just main).
568
+ */
569
+ function discoverAgentDirs() {
570
+ const openclawHome = process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
571
+ const agentsDir = path.join(openclawHome, 'agents');
572
+ const dirs = [];
573
+ try {
574
+ for (const agent of fs.readdirSync(agentsDir)) {
575
+ const sessionsDir = path.join(agentsDir, agent, 'sessions');
576
+ if (fs.existsSync(sessionsDir) && fs.statSync(sessionsDir).isDirectory()) {
577
+ dirs.push({ agent, sessionsDir });
578
+ }
579
+ }
580
+ } catch { /* agents dir doesn't exist */ }
581
+ return dirs;
582
+ }
583
+
584
+ /**
585
+ * Discover web-chat session transcripts.
586
+ */
587
+ function discoverWebChatSessions() {
588
+ const openclawHome = process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
589
+ const webChatDir = path.join(openclawHome, 'web-chat');
590
+ const sessions = [];
591
+ try {
592
+ for (const file of fs.readdirSync(webChatDir)) {
593
+ if (file.endsWith('.jsonl')) {
594
+ sessions.push(path.join(webChatDir, file));
595
+ }
596
+ }
597
+ } catch { /* web-chat dir doesn't exist */ }
598
+ return sessions;
599
+ }
600
+
503
601
  function analyzeSessions(sessionsPath) {
504
602
  const findings = [];
505
603
  const sessions = readJSON(sessionsPath);
604
+ const sessionsDir = sessionsPath ? path.dirname(sessionsPath) : null;
506
605
 
507
- if (!sessions) return { exists: false, findings: [], sessions: [], totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0, sessionCount: 0 };
606
+ if (!sessions) return { exists: false, findings: [], sessions: [], totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0, totalCacheRead: 0, totalCacheWrite: 0, totalRealCost: 0, sessionCount: 0 };
508
607
 
509
608
  let totalIn = 0, totalOut = 0, totalCost = 0;
609
+ let totalCacheRead = 0, totalCacheWrite = 0, totalRealCost = 0;
510
610
  const breakdown = [], orphaned = [], large = [];
611
+ let transcriptHits = 0, transcriptMisses = 0;
612
+
613
+ // Track which sessionIds we've already parsed to avoid double-counting
614
+ // (multiple session keys can share the same sessionId / .jsonl file)
615
+ const parsedTranscripts = new Map(); // sessionId -> transcript result
616
+ const countedSessionIds = new Set();
511
617
 
512
618
  for (const key of Object.keys(sessions)) {
513
619
  const s = sessions[key];
@@ -515,31 +621,185 @@ function analyzeSessions(sessionsPath) {
515
621
  const modelKey = resolveModel(model);
516
622
  const inTok = s.inputTokens || s.tokensIn || s.tokens?.input || 0;
517
623
  const outTok = s.outputTokens || s.tokensOut || s.tokens?.output || 0;
518
- const cost = costPerCall(modelKey, inTok, outTok);
624
+ const estimatedCost = costPerCall(modelKey, inTok, outTok);
519
625
  const updatedAt = s.updatedAt || s.lastActive || null;
520
626
 
521
- totalIn += inTok;
522
- totalOut += outTok;
523
- totalCost += cost;
627
+ // Look up transcript by sessionId (not by key name)
628
+ let transcript = null;
629
+ const sessionId = s.sessionId;
630
+ if (sessionsDir && sessionId) {
631
+ if (parsedTranscripts.has(sessionId)) {
632
+ transcript = parsedTranscripts.get(sessionId);
633
+ } else {
634
+ const jsonlPath = path.join(sessionsDir, `${sessionId}.jsonl`);
635
+ transcript = parseTranscript(jsonlPath);
636
+ parsedTranscripts.set(sessionId, transcript);
637
+ }
638
+ }
639
+
640
+ // Deduplicate: if multiple keys share a sessionId, only count transcript cost once
641
+ const isSharedSession = sessionId && countedSessionIds.has(sessionId);
642
+ if (sessionId) countedSessionIds.add(sessionId);
643
+
644
+ // Use transcript data when available, fall back to sessions.json estimates
645
+ let realIn, realOut, realCacheRead, realCacheWrite, realCost, realModel, realTotalTokens;
646
+ if (transcript && !isSharedSession) {
647
+ transcriptHits++;
648
+ realIn = transcript.input;
649
+ realOut = transcript.output;
650
+ realCacheRead = transcript.cacheRead;
651
+ realCacheWrite = transcript.cacheWrite;
652
+ realCost = transcript.totalCost;
653
+ realModel = transcript.model || model;
654
+ realTotalTokens = transcript.totalTokens;
655
+ } else if (transcript && isSharedSession) {
656
+ // Shared sessionId — transcript already counted, show it but zero out cost
657
+ transcriptHits++;
658
+ realIn = 0;
659
+ realOut = 0;
660
+ realCacheRead = 0;
661
+ realCacheWrite = 0;
662
+ realCost = 0;
663
+ realModel = transcript.model || model;
664
+ realTotalTokens = 0;
665
+ } else {
666
+ transcriptMisses++;
667
+ realIn = inTok;
668
+ realOut = outTok;
669
+ realCacheRead = 0;
670
+ realCacheWrite = 0;
671
+ realCost = estimatedCost;
672
+ realModel = model;
673
+ realTotalTokens = inTok + outTok;
674
+ }
675
+
676
+ totalIn += realIn;
677
+ totalOut += realOut;
678
+ totalCacheRead += realCacheRead;
679
+ totalCacheWrite += realCacheWrite;
680
+ totalCost += estimatedCost;
681
+ totalRealCost += realCost;
682
+
683
+ const allTokens = realTotalTokens || (realIn + realOut + realCacheRead + realCacheWrite);
524
684
 
525
685
  const isOrphaned = key.includes('cron') || key.includes('deleted') ||
526
686
  (updatedAt && Date.now() - new Date(updatedAt).getTime() > 48 * 3600 * 1000 && !key.includes('main'));
527
687
 
528
- if (isOrphaned) orphaned.push({ key, model, tokens: inTok + outTok, cost });
529
- if (inTok + outTok > 50000) large.push({ key, model, tokens: inTok + outTok });
688
+ if (isOrphaned) orphaned.push({ key, model: realModel, tokens: allTokens, cost: realCost });
689
+ if (allTokens > 50000) large.push({ key, model: realModel, tokens: allTokens });
530
690
 
531
691
  const ageMs = updatedAt ? Date.now() - new Date(updatedAt).getTime() : null;
532
692
  const ageDays = ageMs ? ageMs / (1000 * 3600 * 24) : null;
533
- const dailyCost = (ageDays && ageDays > 0.01 && cost > 0) ? cost / ageDays : null;
693
+ const dailyCost = (ageDays && ageDays > 0.01 && realCost > 0) ? realCost / ageDays : null;
694
+
695
+ const realModelKey = resolveModel(realModel);
696
+ breakdown.push({
697
+ key, sessionId: sessionId || null, model: realModel,
698
+ modelLabel: realModelKey ? (MODEL_PRICING[realModelKey]?.label || realModelKey) : (modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown'),
699
+ inputTokens: realIn, outputTokens: realOut,
700
+ cacheRead: realCacheRead, cacheWrite: realCacheWrite,
701
+ cost: realCost, estimatedCost,
702
+ hasTranscript: !!transcript,
703
+ isSharedSession,
704
+ messageCount: transcript?.messageCount || 0,
705
+ updatedAt, ageMs, dailyCost, isOrphaned,
706
+ });
707
+ }
534
708
 
535
- breakdown.push({ key, model, modelLabel: modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown', inputTokens: inTok, outputTokens: outTok, cost, updatedAt, ageMs, dailyCost, isOrphaned });
709
+ // Scan for untracked .jsonl files (sessions not in sessions.json)
710
+ const trackedIds = new Set(Object.values(sessions).map(s => s.sessionId).filter(Boolean));
711
+ let untrackedCount = 0, untrackedCost = 0, untrackedTokens = 0;
712
+ if (sessionsDir) {
713
+ try {
714
+ for (const file of fs.readdirSync(sessionsDir)) {
715
+ if (!file.endsWith('.jsonl')) continue;
716
+ const fileId = file.replace('.jsonl', '');
717
+ if (trackedIds.has(fileId)) continue; // already counted
718
+ const transcript = parseTranscript(path.join(sessionsDir, file));
719
+ if (transcript && transcript.messageCount > 0) {
720
+ untrackedCount++;
721
+ untrackedCost += transcript.totalCost;
722
+ untrackedTokens += transcript.totalTokens;
723
+ totalRealCost += transcript.totalCost;
724
+ totalCacheRead += transcript.cacheRead;
725
+ totalCacheWrite += transcript.cacheWrite;
726
+ breakdown.push({
727
+ key: `untracked:${fileId.slice(0, 8)}`, sessionId: fileId, model: transcript.model,
728
+ modelLabel: transcript.model ? (MODEL_PRICING[resolveModel(transcript.model)]?.label || transcript.model) : 'unknown',
729
+ inputTokens: transcript.input, outputTokens: transcript.output,
730
+ cacheRead: transcript.cacheRead, cacheWrite: transcript.cacheWrite,
731
+ cost: transcript.totalCost, estimatedCost: 0,
732
+ hasTranscript: true, isSharedSession: false,
733
+ messageCount: transcript.messageCount,
734
+ updatedAt: transcript.lastTs ? new Date(transcript.lastTs).toISOString() : null,
735
+ ageMs: transcript.lastTs ? Date.now() - transcript.lastTs : null,
736
+ dailyCost: null, isOrphaned: false, isUntracked: true,
737
+ });
738
+ }
739
+ }
740
+ } catch { /* can't read dir */ }
536
741
  }
537
742
 
743
+ if (untrackedCount > 0) {
744
+ findings.push({
745
+ severity: untrackedCost > 1 ? 'high' : 'medium',
746
+ 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`,
749
+ });
750
+ }
751
+
752
+ // Findings
538
753
  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 });
539
754
  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 });
540
755
  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()}` });
541
756
 
542
- return { exists: true, findings, sessions: breakdown, totalInputTokens: totalIn, totalOutputTokens: totalOut, totalCost, sessionCount: Object.keys(sessions).length };
757
+ // Cache cost finding
758
+ if (totalCacheWrite > 0 || totalCacheRead > 0) {
759
+ const cacheDetail = [];
760
+ if (totalCacheRead > 0) cacheDetail.push(`Cache reads: ${totalCacheRead.toLocaleString()} tokens`);
761
+ if (totalCacheWrite > 0) cacheDetail.push(`Cache writes: ${totalCacheWrite.toLocaleString()} tokens`);
762
+ const cacheCostPortion = totalRealCost - totalCost;
763
+ if (cacheCostPortion > 0.01) {
764
+ findings.push({
765
+ severity: cacheCostPortion > 1 ? 'high' : 'medium',
766
+ source: 'sessions',
767
+ message: `Prompt caching added $${cacheCostPortion.toFixed(4)} beyond base token costs`,
768
+ detail: cacheDetail.join('\n ') + `\n Cache writes are 3.75x input cost · Cache reads are 0.1x input cost`,
769
+ monthlyCost: 0, // already counted in session costs, not a recurring config bleed
770
+ });
771
+ } else {
772
+ findings.push({ severity: 'info', source: 'sessions', message: `Prompt caching active — ${(totalCacheRead + totalCacheWrite).toLocaleString()} cache tokens tracked`, detail: cacheDetail.join(' · ') });
773
+ }
774
+ }
775
+
776
+ // Transcript coverage finding
777
+ if (transcriptHits > 0 || transcriptMisses > 0) {
778
+ const total = transcriptHits + transcriptMisses;
779
+ if (transcriptMisses > 0 && transcriptHits > 0) {
780
+ 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` });
781
+ }
782
+ }
783
+
784
+ // Cost discrepancy finding
785
+ if (totalRealCost > 0 && totalCost > 0) {
786
+ const ratio = totalRealCost / totalCost;
787
+ if (ratio > 2) {
788
+ findings.push({
789
+ severity: 'high',
790
+ source: 'sessions',
791
+ message: `Real cost $${totalRealCost.toFixed(4)} is ${ratio.toFixed(1)}x higher than sessions.json estimate ($${totalCost.toFixed(4)})`,
792
+ detail: `sessions.json only tracks input/output tokens — cache tokens, which are the bulk of real spending, are only in .jsonl transcripts`,
793
+ });
794
+ }
795
+ }
796
+
797
+ return {
798
+ exists: true, findings, sessions: breakdown,
799
+ totalInputTokens: totalIn, totalOutputTokens: totalOut,
800
+ totalCost, totalCacheRead, totalCacheWrite, totalRealCost,
801
+ sessionCount: Object.keys(sessions).length,
802
+ };
543
803
  }
544
804
 
545
805
  // ── Workspace analysis ────────────────────────────────────────────
@@ -568,12 +828,64 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
568
828
  const sessionResult = analyzeSessions(sessionsPath);
569
829
  const workspaceResult = analyzeWorkspace();
570
830
 
571
- const allFindings = [...configResult.findings, ...sessionResult.findings, ...workspaceResult.findings];
831
+ // Scan additional agent folders beyond main
832
+ const agentDirs = discoverAgentDirs();
833
+ const additionalAgentSessions = [];
834
+ for (const { agent, sessionsDir } of agentDirs) {
835
+ const sjPath = path.join(sessionsDir, 'sessions.json');
836
+ // Skip the primary sessions path (already analyzed above)
837
+ if (sjPath === sessionsPath) continue;
838
+ if (fs.existsSync(sjPath)) {
839
+ const extra = analyzeSessions(sjPath);
840
+ if (extra.exists) {
841
+ additionalAgentSessions.push({ agent, ...extra });
842
+ }
843
+ }
844
+ }
845
+
846
+ // Scan web-chat sessions
847
+ const webChatPaths = discoverWebChatSessions();
848
+ const webChatSessions = [];
849
+ for (const wcp of webChatPaths) {
850
+ const transcript = parseTranscript(wcp);
851
+ if (transcript) {
852
+ const sessionId = path.basename(wcp, '.jsonl');
853
+ webChatSessions.push({ key: `web-chat/${sessionId}`, ...transcript });
854
+ }
855
+ }
856
+
857
+ // Merge all findings
858
+ const allFindings = [
859
+ ...configResult.findings,
860
+ ...sessionResult.findings,
861
+ ...workspaceResult.findings,
862
+ ];
863
+
864
+ // Add additional agent findings
865
+ for (const extra of additionalAgentSessions) {
866
+ if (extra.findings.length > 0) {
867
+ 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)}` });
868
+ }
869
+ // Merge their sessions into the main breakdown
870
+ sessionResult.sessions.push(...(extra.sessions || []));
871
+ sessionResult.totalRealCost += extra.totalRealCost || 0;
872
+ sessionResult.totalCacheRead += extra.totalCacheRead || 0;
873
+ sessionResult.totalCacheWrite += extra.totalCacheWrite || 0;
874
+ }
875
+
876
+ // Add web-chat finding if any
877
+ if (webChatSessions.length > 0) {
878
+ const wcTotal = webChatSessions.reduce((sum, s) => sum + s.totalCost, 0);
879
+ const wcTokens = webChatSessions.reduce((sum, s) => sum + s.totalTokens, 0);
880
+ allFindings.push({ severity: 'info', source: 'sessions', message: `${webChatSessions.length} web-chat session(s) found`, detail: `Tokens: ${wcTokens.toLocaleString()} · Cost: $${wcTotal.toFixed(4)}` });
881
+ }
572
882
 
573
883
  const estimatedMonthlyBleed = allFindings
574
884
  .filter(f => f.monthlyCost && f.severity !== 'info')
575
885
  .reduce((sum, f) => sum + f.monthlyCost, 0);
576
886
 
887
+ const realCost = sessionResult.totalRealCost || 0;
888
+
577
889
  return {
578
890
  scannedAt: new Date().toISOString(),
579
891
  configPath,
@@ -589,10 +901,15 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
589
901
  estimatedMonthlyBleed,
590
902
  sessionsAnalyzed: sessionResult.sessionCount,
591
903
  totalTokensFound: (sessionResult.totalInputTokens || 0) + (sessionResult.totalOutputTokens || 0),
904
+ totalCacheRead: sessionResult.totalCacheRead || 0,
905
+ totalCacheWrite: sessionResult.totalCacheWrite || 0,
906
+ totalRealCost: realCost,
907
+ totalEstimatedCost: sessionResult.totalCost || 0,
592
908
  },
593
909
  sessions: sessionResult.sessions || [],
910
+ webChatSessions,
594
911
  config: configResult.config,
595
912
  };
596
913
  }
597
914
 
598
- module.exports = { runAnalysis, MODEL_PRICING, resolveModel, costPerCall };
915
+ module.exports = { runAnalysis, MODEL_PRICING, resolveModel, costPerCall, parseTranscript, discoverAgentDirs, discoverWebChatSessions };
@@ -79,14 +79,16 @@ async function generateHTMLReport(analysis, outPath) {
79
79
  .map(s => {
80
80
  const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '…' : s.key;
81
81
  const flag = s.isOrphaned ? ' ⚠️' : '';
82
+ 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>';
82
83
  const age = s.ageMs ? relativeAge(s.ageMs) : 'unknown';
83
84
  const absDate = s.updatedAt ? new Date(s.updatedAt).toLocaleString() : '';
84
85
  const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}/day` : '—';
86
+ const cacheTitle = (s.cacheRead || s.cacheWrite) ? `Cache R: ${(s.cacheRead||0).toLocaleString()} · W: ${(s.cacheWrite||0).toLocaleString()}` : '';
85
87
  return `
86
88
  <tr style="${s.isOrphaned ? 'background:#fff7ed' : ''}">
87
- <td style="padding:8px 12px; font-family:monospace; font-size:13px">${keyDisplay}${flag}</td>
89
+ <td style="padding:8px 12px; font-family:monospace; font-size:13px">${keyDisplay}${flag}${txBadge}</td>
88
90
  <td style="padding:8px 12px">${s.modelLabel || s.model}</td>
89
- <td style="padding:8px 12px; text-align:right">${(s.inputTokens + s.outputTokens).toLocaleString()}</td>
91
+ <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>
90
92
  <td style="padding:8px 12px; text-align:right; color:${s.cost > 0.01 ? '#ef4444' : '#22c55e'}">$${s.cost.toFixed(6)}</td>
91
93
  <td style="padding:8px 12px; text-align:right; color:#f59e0b">${daily}</td>
92
94
  <td style="padding:8px 12px; color:#6b7280; font-size:13px" title="${absDate}">${age}</td>
@@ -138,6 +140,27 @@ async function generateHTMLReport(analysis, outPath) {
138
140
  <div style="color:#86efac; font-size:18px; font-weight:700">✅ No significant cost bleed detected</div>
139
141
  </div>`}
140
142
 
143
+ ${summary.totalRealCost > 0 ? `
144
+ <div class="section" style="border:1px solid #f59e0b;">
145
+ <div class="section-title" style="color:#f59e0b;">💰 Actual API Spend (from transcripts)</div>
146
+ <div style="display:flex; gap:32px; flex-wrap:wrap; align-items:center;">
147
+ <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>
150
+ </div>
151
+ ${summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1 ? `
152
+ <div style="background:#451a03; padding:10px 16px; border-radius:8px; border:1px solid #92400e;">
153
+ <div style="font-size:14px; color:#fbbf24; font-weight:600;">${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x higher than sessions.json estimate</div>
154
+ <div style="font-size:12px; color:#d97706; margin-top:2px;">sessions.json: $${summary.totalEstimatedCost.toFixed(4)} — missing cache token costs</div>
155
+ </div>` : ''}
156
+ </div>
157
+ ${summary.totalCacheRead > 0 || summary.totalCacheWrite > 0 ? `
158
+ <div style="margin-top:16px; display:flex; gap:24px; flex-wrap:wrap;">
159
+ ${summary.totalCacheWrite > 0 ? `<div style="color:#f97316; font-size:14px;">📝 Cache writes: <strong>${summary.totalCacheWrite.toLocaleString()}</strong> tokens</div>` : ''}
160
+ ${summary.totalCacheRead > 0 ? `<div style="color:#22c55e; font-size:14px;">📖 Cache reads: <strong>${summary.totalCacheRead.toLocaleString()}</strong> tokens</div>` : ''}
161
+ </div>` : ''}
162
+ </div>` : ''}
163
+
141
164
  <div class="cards">
142
165
  <div class="card">
143
166
  <div class="card-value" style="color:#ef4444">${summary.critical}</div>
@@ -69,6 +69,14 @@ function generateMarkdownReport(analysis) {
69
69
  if (totalCost > 0) lines.push(`| Total session cost (lifetime) | $${totalCost.toFixed(4)} |`);
70
70
  if (totalDailyRate > 0) lines.push(`| Avg daily burn rate | $${totalDailyRate.toFixed(4)}/day (~$${(totalDailyRate * 30).toFixed(2)}/month) |`);
71
71
  }
72
+ if (summary.totalRealCost > 0) {
73
+ lines.push(`| 💰 **Actual API spend** | **$${summary.totalRealCost.toFixed(4)}** |`);
74
+ if (summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1) {
75
+ lines.push(`| sessions.json estimate | $${summary.totalEstimatedCost.toFixed(4)} (${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x gap) |`);
76
+ }
77
+ }
78
+ if (summary.totalCacheWrite > 0) lines.push(`| Cache writes | ${summary.totalCacheWrite.toLocaleString()} tokens |`);
79
+ if (summary.totalCacheRead > 0) lines.push(`| Cache reads | ${summary.totalCacheRead.toLocaleString()} tokens |`);
72
80
  lines.push('');
73
81
 
74
82
  // Findings by severity
@@ -115,6 +115,15 @@ function generateTerminalReport(analysis) {
115
115
  console.log(`${C}━━━ Summary ━━━${R}`);
116
116
  console.log(` 🔴 ${RED}${summary.critical}${R} critical 🟠 ${summary.high} high 🟡 ${summary.medium} medium 🔵 ${summary.low||0} low ✅ ${summary.info} ok`);
117
117
  console.log(` Sessions analyzed: ${summary.sessionsAnalyzed} · Tokens found: ${(summary.totalTokensFound||0).toLocaleString()}`);
118
+ if (summary.totalCacheRead > 0 || summary.totalCacheWrite > 0) {
119
+ console.log(` ${D}Cache tokens: ${(summary.totalCacheRead||0).toLocaleString()} read · ${(summary.totalCacheWrite||0).toLocaleString()} write${R}`);
120
+ }
121
+ if (summary.totalRealCost > 0) {
122
+ console.log(` ${B}Actual API spend: ${RED}$${summary.totalRealCost.toFixed(4)}${R}${B} (from .jsonl transcripts)${R}`);
123
+ if (summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1) {
124
+ console.log(` ${D}sessions.json estimate: $${summary.totalEstimatedCost.toFixed(4)} — ${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x gap (cache tokens)${R}`);
125
+ }
126
+ }
118
127
  if (bleed > 0) {
119
128
  console.log(` ${RED}${B}Estimated monthly bleed: $${bleed.toFixed(2)}/month${R}`);
120
129
  } else {
package/src/analyzer.js CHANGED
@@ -500,14 +500,120 @@ function analyzeConfig(configPath) {
500
500
  }
501
501
 
502
502
  // ── Session analysis ──────────────────────────────────────────────
503
+
504
+ /**
505
+ * Parse a .jsonl session transcript file and sum up real usage/cost data.
506
+ * Returns { input, output, cacheRead, cacheWrite, totalTokens, totalCost, messageCount, model, firstTs, lastTs }
507
+ */
508
+ function parseTranscript(jsonlPath) {
509
+ try {
510
+ const content = fs.readFileSync(jsonlPath, 'utf8').trim();
511
+ if (!content) return null;
512
+
513
+ let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, totalTokens = 0, totalCost = 0;
514
+ let messageCount = 0, model = null, firstTs = null, lastTs = null;
515
+
516
+ for (const line of content.split('\n')) {
517
+ if (!line.trim()) continue;
518
+ let entry;
519
+ try { entry = JSON.parse(line); } catch { continue; }
520
+
521
+ // Track timestamps from all message types
522
+ const ts = entry.timestamp || entry.message?.timestamp;
523
+ if (ts) {
524
+ const t = typeof ts === 'number' ? ts : new Date(ts).getTime();
525
+ if (!firstTs || t < firstTs) firstTs = t;
526
+ if (!lastTs || t > lastTs) lastTs = t;
527
+ }
528
+
529
+ // Only assistant messages with usage blocks have cost data
530
+ if (entry.type !== 'message') 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;
535
+
536
+ messageCount++;
537
+
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;
541
+
542
+ input += u.input || 0;
543
+ output += u.output || 0;
544
+ cacheRead += u.cacheRead || 0;
545
+ cacheWrite += u.cacheWrite || 0;
546
+ totalTokens += u.totalTokens || 0;
547
+
548
+ // Prefer API-reported cost (already accounts for cache pricing)
549
+ if (u.cost) {
550
+ if (typeof u.cost === 'object' && u.cost.total != null) {
551
+ totalCost += u.cost.total;
552
+ } else if (typeof u.cost === 'number') {
553
+ totalCost += u.cost;
554
+ }
555
+ }
556
+ }
557
+
558
+ if (messageCount === 0) return null;
559
+
560
+ return { input, output, cacheRead, cacheWrite, totalTokens, totalCost, messageCount, model, firstTs, lastTs };
561
+ } catch {
562
+ return null;
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Discover all agent session directories (not just main).
568
+ */
569
+ function discoverAgentDirs() {
570
+ const openclawHome = process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
571
+ const agentsDir = path.join(openclawHome, 'agents');
572
+ const dirs = [];
573
+ try {
574
+ for (const agent of fs.readdirSync(agentsDir)) {
575
+ const sessionsDir = path.join(agentsDir, agent, 'sessions');
576
+ if (fs.existsSync(sessionsDir) && fs.statSync(sessionsDir).isDirectory()) {
577
+ dirs.push({ agent, sessionsDir });
578
+ }
579
+ }
580
+ } catch { /* agents dir doesn't exist */ }
581
+ return dirs;
582
+ }
583
+
584
+ /**
585
+ * Discover web-chat session transcripts.
586
+ */
587
+ function discoverWebChatSessions() {
588
+ const openclawHome = process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
589
+ const webChatDir = path.join(openclawHome, 'web-chat');
590
+ const sessions = [];
591
+ try {
592
+ for (const file of fs.readdirSync(webChatDir)) {
593
+ if (file.endsWith('.jsonl')) {
594
+ sessions.push(path.join(webChatDir, file));
595
+ }
596
+ }
597
+ } catch { /* web-chat dir doesn't exist */ }
598
+ return sessions;
599
+ }
600
+
503
601
  function analyzeSessions(sessionsPath) {
504
602
  const findings = [];
505
603
  const sessions = readJSON(sessionsPath);
604
+ const sessionsDir = sessionsPath ? path.dirname(sessionsPath) : null;
506
605
 
507
- if (!sessions) return { exists: false, findings: [], sessions: [], totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0, sessionCount: 0 };
606
+ if (!sessions) return { exists: false, findings: [], sessions: [], totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0, totalCacheRead: 0, totalCacheWrite: 0, totalRealCost: 0, sessionCount: 0 };
508
607
 
509
608
  let totalIn = 0, totalOut = 0, totalCost = 0;
609
+ let totalCacheRead = 0, totalCacheWrite = 0, totalRealCost = 0;
510
610
  const breakdown = [], orphaned = [], large = [];
611
+ let transcriptHits = 0, transcriptMisses = 0;
612
+
613
+ // Track which sessionIds we've already parsed to avoid double-counting
614
+ // (multiple session keys can share the same sessionId / .jsonl file)
615
+ const parsedTranscripts = new Map(); // sessionId -> transcript result
616
+ const countedSessionIds = new Set();
511
617
 
512
618
  for (const key of Object.keys(sessions)) {
513
619
  const s = sessions[key];
@@ -515,31 +621,185 @@ function analyzeSessions(sessionsPath) {
515
621
  const modelKey = resolveModel(model);
516
622
  const inTok = s.inputTokens || s.tokensIn || s.tokens?.input || 0;
517
623
  const outTok = s.outputTokens || s.tokensOut || s.tokens?.output || 0;
518
- const cost = costPerCall(modelKey, inTok, outTok);
624
+ const estimatedCost = costPerCall(modelKey, inTok, outTok);
519
625
  const updatedAt = s.updatedAt || s.lastActive || null;
520
626
 
521
- totalIn += inTok;
522
- totalOut += outTok;
523
- totalCost += cost;
627
+ // Look up transcript by sessionId (not by key name)
628
+ let transcript = null;
629
+ const sessionId = s.sessionId;
630
+ if (sessionsDir && sessionId) {
631
+ if (parsedTranscripts.has(sessionId)) {
632
+ transcript = parsedTranscripts.get(sessionId);
633
+ } else {
634
+ const jsonlPath = path.join(sessionsDir, `${sessionId}.jsonl`);
635
+ transcript = parseTranscript(jsonlPath);
636
+ parsedTranscripts.set(sessionId, transcript);
637
+ }
638
+ }
639
+
640
+ // Deduplicate: if multiple keys share a sessionId, only count transcript cost once
641
+ const isSharedSession = sessionId && countedSessionIds.has(sessionId);
642
+ if (sessionId) countedSessionIds.add(sessionId);
643
+
644
+ // Use transcript data when available, fall back to sessions.json estimates
645
+ let realIn, realOut, realCacheRead, realCacheWrite, realCost, realModel, realTotalTokens;
646
+ if (transcript && !isSharedSession) {
647
+ transcriptHits++;
648
+ realIn = transcript.input;
649
+ realOut = transcript.output;
650
+ realCacheRead = transcript.cacheRead;
651
+ realCacheWrite = transcript.cacheWrite;
652
+ realCost = transcript.totalCost;
653
+ realModel = transcript.model || model;
654
+ realTotalTokens = transcript.totalTokens;
655
+ } else if (transcript && isSharedSession) {
656
+ // Shared sessionId — transcript already counted, show it but zero out cost
657
+ transcriptHits++;
658
+ realIn = 0;
659
+ realOut = 0;
660
+ realCacheRead = 0;
661
+ realCacheWrite = 0;
662
+ realCost = 0;
663
+ realModel = transcript.model || model;
664
+ realTotalTokens = 0;
665
+ } else {
666
+ transcriptMisses++;
667
+ realIn = inTok;
668
+ realOut = outTok;
669
+ realCacheRead = 0;
670
+ realCacheWrite = 0;
671
+ realCost = estimatedCost;
672
+ realModel = model;
673
+ realTotalTokens = inTok + outTok;
674
+ }
675
+
676
+ totalIn += realIn;
677
+ totalOut += realOut;
678
+ totalCacheRead += realCacheRead;
679
+ totalCacheWrite += realCacheWrite;
680
+ totalCost += estimatedCost;
681
+ totalRealCost += realCost;
682
+
683
+ const allTokens = realTotalTokens || (realIn + realOut + realCacheRead + realCacheWrite);
524
684
 
525
685
  const isOrphaned = key.includes('cron') || key.includes('deleted') ||
526
686
  (updatedAt && Date.now() - new Date(updatedAt).getTime() > 48 * 3600 * 1000 && !key.includes('main'));
527
687
 
528
- if (isOrphaned) orphaned.push({ key, model, tokens: inTok + outTok, cost });
529
- if (inTok + outTok > 50000) large.push({ key, model, tokens: inTok + outTok });
688
+ if (isOrphaned) orphaned.push({ key, model: realModel, tokens: allTokens, cost: realCost });
689
+ if (allTokens > 50000) large.push({ key, model: realModel, tokens: allTokens });
530
690
 
531
691
  const ageMs = updatedAt ? Date.now() - new Date(updatedAt).getTime() : null;
532
692
  const ageDays = ageMs ? ageMs / (1000 * 3600 * 24) : null;
533
- const dailyCost = (ageDays && ageDays > 0.01 && cost > 0) ? cost / ageDays : null;
693
+ const dailyCost = (ageDays && ageDays > 0.01 && realCost > 0) ? realCost / ageDays : null;
694
+
695
+ const realModelKey = resolveModel(realModel);
696
+ breakdown.push({
697
+ key, sessionId: sessionId || null, model: realModel,
698
+ modelLabel: realModelKey ? (MODEL_PRICING[realModelKey]?.label || realModelKey) : (modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown'),
699
+ inputTokens: realIn, outputTokens: realOut,
700
+ cacheRead: realCacheRead, cacheWrite: realCacheWrite,
701
+ cost: realCost, estimatedCost,
702
+ hasTranscript: !!transcript,
703
+ isSharedSession,
704
+ messageCount: transcript?.messageCount || 0,
705
+ updatedAt, ageMs, dailyCost, isOrphaned,
706
+ });
707
+ }
534
708
 
535
- breakdown.push({ key, model, modelLabel: modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown', inputTokens: inTok, outputTokens: outTok, cost, updatedAt, ageMs, dailyCost, isOrphaned });
709
+ // Scan for untracked .jsonl files (sessions not in sessions.json)
710
+ const trackedIds = new Set(Object.values(sessions).map(s => s.sessionId).filter(Boolean));
711
+ let untrackedCount = 0, untrackedCost = 0, untrackedTokens = 0;
712
+ if (sessionsDir) {
713
+ try {
714
+ for (const file of fs.readdirSync(sessionsDir)) {
715
+ if (!file.endsWith('.jsonl')) continue;
716
+ const fileId = file.replace('.jsonl', '');
717
+ if (trackedIds.has(fileId)) continue; // already counted
718
+ const transcript = parseTranscript(path.join(sessionsDir, file));
719
+ if (transcript && transcript.messageCount > 0) {
720
+ untrackedCount++;
721
+ untrackedCost += transcript.totalCost;
722
+ untrackedTokens += transcript.totalTokens;
723
+ totalRealCost += transcript.totalCost;
724
+ totalCacheRead += transcript.cacheRead;
725
+ totalCacheWrite += transcript.cacheWrite;
726
+ breakdown.push({
727
+ key: `untracked:${fileId.slice(0, 8)}`, sessionId: fileId, model: transcript.model,
728
+ modelLabel: transcript.model ? (MODEL_PRICING[resolveModel(transcript.model)]?.label || transcript.model) : 'unknown',
729
+ inputTokens: transcript.input, outputTokens: transcript.output,
730
+ cacheRead: transcript.cacheRead, cacheWrite: transcript.cacheWrite,
731
+ cost: transcript.totalCost, estimatedCost: 0,
732
+ hasTranscript: true, isSharedSession: false,
733
+ messageCount: transcript.messageCount,
734
+ updatedAt: transcript.lastTs ? new Date(transcript.lastTs).toISOString() : null,
735
+ ageMs: transcript.lastTs ? Date.now() - transcript.lastTs : null,
736
+ dailyCost: null, isOrphaned: false, isUntracked: true,
737
+ });
738
+ }
739
+ }
740
+ } catch { /* can't read dir */ }
536
741
  }
537
742
 
743
+ if (untrackedCount > 0) {
744
+ findings.push({
745
+ severity: untrackedCost > 1 ? 'high' : 'medium',
746
+ 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`,
749
+ });
750
+ }
751
+
752
+ // Findings
538
753
  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 });
539
754
  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 });
540
755
  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()}` });
541
756
 
542
- return { exists: true, findings, sessions: breakdown, totalInputTokens: totalIn, totalOutputTokens: totalOut, totalCost, sessionCount: Object.keys(sessions).length };
757
+ // Cache cost finding
758
+ if (totalCacheWrite > 0 || totalCacheRead > 0) {
759
+ const cacheDetail = [];
760
+ if (totalCacheRead > 0) cacheDetail.push(`Cache reads: ${totalCacheRead.toLocaleString()} tokens`);
761
+ if (totalCacheWrite > 0) cacheDetail.push(`Cache writes: ${totalCacheWrite.toLocaleString()} tokens`);
762
+ const cacheCostPortion = totalRealCost - totalCost;
763
+ if (cacheCostPortion > 0.01) {
764
+ findings.push({
765
+ severity: cacheCostPortion > 1 ? 'high' : 'medium',
766
+ source: 'sessions',
767
+ message: `Prompt caching added $${cacheCostPortion.toFixed(4)} beyond base token costs`,
768
+ detail: cacheDetail.join('\n ') + `\n Cache writes are 3.75x input cost · Cache reads are 0.1x input cost`,
769
+ monthlyCost: 0, // already counted in session costs, not a recurring config bleed
770
+ });
771
+ } else {
772
+ findings.push({ severity: 'info', source: 'sessions', message: `Prompt caching active — ${(totalCacheRead + totalCacheWrite).toLocaleString()} cache tokens tracked`, detail: cacheDetail.join(' · ') });
773
+ }
774
+ }
775
+
776
+ // Transcript coverage finding
777
+ if (transcriptHits > 0 || transcriptMisses > 0) {
778
+ const total = transcriptHits + transcriptMisses;
779
+ if (transcriptMisses > 0 && transcriptHits > 0) {
780
+ 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` });
781
+ }
782
+ }
783
+
784
+ // Cost discrepancy finding
785
+ if (totalRealCost > 0 && totalCost > 0) {
786
+ const ratio = totalRealCost / totalCost;
787
+ if (ratio > 2) {
788
+ findings.push({
789
+ severity: 'high',
790
+ source: 'sessions',
791
+ message: `Real cost $${totalRealCost.toFixed(4)} is ${ratio.toFixed(1)}x higher than sessions.json estimate ($${totalCost.toFixed(4)})`,
792
+ detail: `sessions.json only tracks input/output tokens — cache tokens, which are the bulk of real spending, are only in .jsonl transcripts`,
793
+ });
794
+ }
795
+ }
796
+
797
+ return {
798
+ exists: true, findings, sessions: breakdown,
799
+ totalInputTokens: totalIn, totalOutputTokens: totalOut,
800
+ totalCost, totalCacheRead, totalCacheWrite, totalRealCost,
801
+ sessionCount: Object.keys(sessions).length,
802
+ };
543
803
  }
544
804
 
545
805
  // ── Workspace analysis ────────────────────────────────────────────
@@ -568,12 +828,64 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
568
828
  const sessionResult = analyzeSessions(sessionsPath);
569
829
  const workspaceResult = analyzeWorkspace();
570
830
 
571
- const allFindings = [...configResult.findings, ...sessionResult.findings, ...workspaceResult.findings];
831
+ // Scan additional agent folders beyond main
832
+ const agentDirs = discoverAgentDirs();
833
+ const additionalAgentSessions = [];
834
+ for (const { agent, sessionsDir } of agentDirs) {
835
+ const sjPath = path.join(sessionsDir, 'sessions.json');
836
+ // Skip the primary sessions path (already analyzed above)
837
+ if (sjPath === sessionsPath) continue;
838
+ if (fs.existsSync(sjPath)) {
839
+ const extra = analyzeSessions(sjPath);
840
+ if (extra.exists) {
841
+ additionalAgentSessions.push({ agent, ...extra });
842
+ }
843
+ }
844
+ }
845
+
846
+ // Scan web-chat sessions
847
+ const webChatPaths = discoverWebChatSessions();
848
+ const webChatSessions = [];
849
+ for (const wcp of webChatPaths) {
850
+ const transcript = parseTranscript(wcp);
851
+ if (transcript) {
852
+ const sessionId = path.basename(wcp, '.jsonl');
853
+ webChatSessions.push({ key: `web-chat/${sessionId}`, ...transcript });
854
+ }
855
+ }
856
+
857
+ // Merge all findings
858
+ const allFindings = [
859
+ ...configResult.findings,
860
+ ...sessionResult.findings,
861
+ ...workspaceResult.findings,
862
+ ];
863
+
864
+ // Add additional agent findings
865
+ for (const extra of additionalAgentSessions) {
866
+ if (extra.findings.length > 0) {
867
+ 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)}` });
868
+ }
869
+ // Merge their sessions into the main breakdown
870
+ sessionResult.sessions.push(...(extra.sessions || []));
871
+ sessionResult.totalRealCost += extra.totalRealCost || 0;
872
+ sessionResult.totalCacheRead += extra.totalCacheRead || 0;
873
+ sessionResult.totalCacheWrite += extra.totalCacheWrite || 0;
874
+ }
875
+
876
+ // Add web-chat finding if any
877
+ if (webChatSessions.length > 0) {
878
+ const wcTotal = webChatSessions.reduce((sum, s) => sum + s.totalCost, 0);
879
+ const wcTokens = webChatSessions.reduce((sum, s) => sum + s.totalTokens, 0);
880
+ allFindings.push({ severity: 'info', source: 'sessions', message: `${webChatSessions.length} web-chat session(s) found`, detail: `Tokens: ${wcTokens.toLocaleString()} · Cost: $${wcTotal.toFixed(4)}` });
881
+ }
572
882
 
573
883
  const estimatedMonthlyBleed = allFindings
574
884
  .filter(f => f.monthlyCost && f.severity !== 'info')
575
885
  .reduce((sum, f) => sum + f.monthlyCost, 0);
576
886
 
887
+ const realCost = sessionResult.totalRealCost || 0;
888
+
577
889
  return {
578
890
  scannedAt: new Date().toISOString(),
579
891
  configPath,
@@ -589,10 +901,15 @@ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
589
901
  estimatedMonthlyBleed,
590
902
  sessionsAnalyzed: sessionResult.sessionCount,
591
903
  totalTokensFound: (sessionResult.totalInputTokens || 0) + (sessionResult.totalOutputTokens || 0),
904
+ totalCacheRead: sessionResult.totalCacheRead || 0,
905
+ totalCacheWrite: sessionResult.totalCacheWrite || 0,
906
+ totalRealCost: realCost,
907
+ totalEstimatedCost: sessionResult.totalCost || 0,
592
908
  },
593
909
  sessions: sessionResult.sessions || [],
910
+ webChatSessions,
594
911
  config: configResult.config,
595
912
  };
596
913
  }
597
914
 
598
- module.exports = { runAnalysis, MODEL_PRICING, resolveModel, costPerCall };
915
+ module.exports = { runAnalysis, MODEL_PRICING, resolveModel, costPerCall, parseTranscript, discoverAgentDirs, discoverWebChatSessions };
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { runAnalysis } = require('../src/analyzer');
5
+ const { generateTerminalReport } = require('../src/reporter');
6
+ const { generateMarkdownReport } = require('../src/mdReport');
7
+ const path = require('path');
8
+ const os = require('os');
9
+ const fs = require('fs');
10
+
11
+ const args = process.argv.slice(2);
12
+ const flags = {
13
+ report: args.includes('--report'),
14
+ json: args.includes('--json'),
15
+ md: args.includes('--md'),
16
+ help: args.includes('--help') || args.includes('-h'),
17
+ config: args.find(a => a.startsWith('--config='))?.split('=')[1],
18
+ out: args.find(a => a.startsWith('--out='))?.split('=')[1],
19
+ };
20
+
21
+ const BANNER = `
22
+ \x1b[36m
23
+ ██████╗██╗ █████╗ ██╗ ██╗ ██████╗ █████╗ ██╗ ██████╗
24
+ ██╔════╝██║ ██╔══██╗██║ ██║██╔════╝██╔══██╗██║ ██╔════╝
25
+ ██║ ██║ ███████║██║ █╗ ██║██║ ███████║██║ ██║
26
+ ██║ ██║ ██╔══██║██║███╗██║██║ ██╔══██║██║ ██║
27
+ ╚██████╗███████╗██║ ██║╚███╔███╔╝╚██████╗██║ ██║███████╗╚██████╗
28
+ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚═════╝
29
+ \x1b[0m
30
+ \x1b[33mYour friendly penny pincher.\x1b[0m
31
+ \x1b[90m100% offline · Zero AI · Pure deterministic logic · Your data never leaves your machine\x1b[0m
32
+ `;
33
+
34
+ const HELP = `
35
+ Usage: clawculator [options]
36
+
37
+ Options:
38
+ (no flags) Full terminal analysis
39
+ --md Save markdown report to ./clawculator-report.md
40
+ --report Generate HTML report and open in browser
41
+ --json Output raw JSON
42
+ --out=PATH Custom output path for --md or --report
43
+ --config=PATH Path to openclaw.json (auto-detected by default)
44
+ --help, -h Show this help
45
+
46
+ Examples:
47
+ npx clawculator # Terminal analysis
48
+ npx clawculator --md # Markdown report (readable by your AI agent)
49
+ npx clawculator --report # Visual HTML dashboard
50
+ npx clawculator --json # JSON for piping
51
+ npx clawculator --md --out=~/cost.md # Custom path
52
+ `;
53
+
54
+ async function main() {
55
+ if (flags.help) {
56
+ console.log(BANNER);
57
+ console.log(HELP);
58
+ process.exit(0);
59
+ }
60
+
61
+ console.log(BANNER);
62
+ console.log('\x1b[90mScanning your setup...\x1b[0m\n');
63
+
64
+ const openclawHome = process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
65
+ const configPath = flags.config || path.join(openclawHome, 'openclaw.json');
66
+
67
+ // Auto-discover sessions path: find first agent with a sessions.json
68
+ let sessionsPath = path.join(openclawHome, 'agents', 'main', 'sessions', 'sessions.json');
69
+ if (!fs.existsSync(sessionsPath)) {
70
+ const agentsDir = path.join(openclawHome, 'agents');
71
+ try {
72
+ for (const agent of fs.readdirSync(agentsDir)) {
73
+ const candidate = path.join(agentsDir, agent, 'sessions', 'sessions.json');
74
+ if (fs.existsSync(candidate)) { sessionsPath = candidate; break; }
75
+ }
76
+ } catch { /* agents dir missing */ }
77
+ }
78
+
79
+ const logsDir = '/tmp/openclaw';
80
+
81
+ let analysis;
82
+ try {
83
+ analysis = await runAnalysis({ configPath, sessionsPath, logsDir });
84
+ } catch (err) {
85
+ console.error('\x1b[31mError:\x1b[0m', err.message);
86
+ process.exit(1);
87
+ }
88
+
89
+ if (flags.json) {
90
+ console.log(JSON.stringify(analysis, null, 2));
91
+ process.exit(0);
92
+ }
93
+
94
+ if (flags.md) {
95
+ const outPath = flags.out || path.join(process.cwd(), 'clawculator-report.md');
96
+ fs.writeFileSync(outPath, generateMarkdownReport(analysis), 'utf8');
97
+ console.log(`\x1b[32m✓ Markdown report saved:\x1b[0m ${outPath}`);
98
+ generateTerminalReport(analysis);
99
+ process.exit(0);
100
+ }
101
+
102
+ if (flags.report) {
103
+ const outPath = flags.out || path.join(process.cwd(), `clawculator-report.html`);
104
+ const { generateHTMLReport } = require('../src/htmlReport');
105
+ await generateHTMLReport(analysis, outPath);
106
+ const { exec } = require('child_process');
107
+ exec(`open "${outPath}" 2>/dev/null || xdg-open "${outPath}" 2>/dev/null`);
108
+ console.log(`\x1b[32m✓ HTML report saved:\x1b[0m ${outPath}`);
109
+ generateTerminalReport(analysis);
110
+ process.exit(0);
111
+ }
112
+
113
+ generateTerminalReport(analysis);
114
+ console.log('\x1b[90m────────────────────────────────────────────────────\x1b[0m');
115
+ console.log('\x1b[36mClawculator\x1b[0m · github.com/echoudhry/clawculator · Your friendly penny pincher.');
116
+ console.log('\x1b[90mTip: --md saves a report your AI agent can read directly\x1b[0m');
117
+ console.log('\x1b[90m────────────────────────────────────────────────────\x1b[0m\n');
118
+ }
119
+
120
+ main().catch(err => {
121
+ console.error('\x1b[31mFatal:\x1b[0m', err.message);
122
+ process.exit(1);
123
+ });
package/src/htmlReport.js CHANGED
@@ -79,14 +79,16 @@ async function generateHTMLReport(analysis, outPath) {
79
79
  .map(s => {
80
80
  const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '…' : s.key;
81
81
  const flag = s.isOrphaned ? ' ⚠️' : '';
82
+ 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>';
82
83
  const age = s.ageMs ? relativeAge(s.ageMs) : 'unknown';
83
84
  const absDate = s.updatedAt ? new Date(s.updatedAt).toLocaleString() : '';
84
85
  const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}/day` : '—';
86
+ const cacheTitle = (s.cacheRead || s.cacheWrite) ? `Cache R: ${(s.cacheRead||0).toLocaleString()} · W: ${(s.cacheWrite||0).toLocaleString()}` : '';
85
87
  return `
86
88
  <tr style="${s.isOrphaned ? 'background:#fff7ed' : ''}">
87
- <td style="padding:8px 12px; font-family:monospace; font-size:13px">${keyDisplay}${flag}</td>
89
+ <td style="padding:8px 12px; font-family:monospace; font-size:13px">${keyDisplay}${flag}${txBadge}</td>
88
90
  <td style="padding:8px 12px">${s.modelLabel || s.model}</td>
89
- <td style="padding:8px 12px; text-align:right">${(s.inputTokens + s.outputTokens).toLocaleString()}</td>
91
+ <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>
90
92
  <td style="padding:8px 12px; text-align:right; color:${s.cost > 0.01 ? '#ef4444' : '#22c55e'}">$${s.cost.toFixed(6)}</td>
91
93
  <td style="padding:8px 12px; text-align:right; color:#f59e0b">${daily}</td>
92
94
  <td style="padding:8px 12px; color:#6b7280; font-size:13px" title="${absDate}">${age}</td>
@@ -138,6 +140,27 @@ async function generateHTMLReport(analysis, outPath) {
138
140
  <div style="color:#86efac; font-size:18px; font-weight:700">✅ No significant cost bleed detected</div>
139
141
  </div>`}
140
142
 
143
+ ${summary.totalRealCost > 0 ? `
144
+ <div class="section" style="border:1px solid #f59e0b;">
145
+ <div class="section-title" style="color:#f59e0b;">💰 Actual API Spend (from transcripts)</div>
146
+ <div style="display:flex; gap:32px; flex-wrap:wrap; align-items:center;">
147
+ <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>
150
+ </div>
151
+ ${summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1 ? `
152
+ <div style="background:#451a03; padding:10px 16px; border-radius:8px; border:1px solid #92400e;">
153
+ <div style="font-size:14px; color:#fbbf24; font-weight:600;">${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x higher than sessions.json estimate</div>
154
+ <div style="font-size:12px; color:#d97706; margin-top:2px;">sessions.json: $${summary.totalEstimatedCost.toFixed(4)} — missing cache token costs</div>
155
+ </div>` : ''}
156
+ </div>
157
+ ${summary.totalCacheRead > 0 || summary.totalCacheWrite > 0 ? `
158
+ <div style="margin-top:16px; display:flex; gap:24px; flex-wrap:wrap;">
159
+ ${summary.totalCacheWrite > 0 ? `<div style="color:#f97316; font-size:14px;">📝 Cache writes: <strong>${summary.totalCacheWrite.toLocaleString()}</strong> tokens</div>` : ''}
160
+ ${summary.totalCacheRead > 0 ? `<div style="color:#22c55e; font-size:14px;">📖 Cache reads: <strong>${summary.totalCacheRead.toLocaleString()}</strong> tokens</div>` : ''}
161
+ </div>` : ''}
162
+ </div>` : ''}
163
+
141
164
  <div class="cards">
142
165
  <div class="card">
143
166
  <div class="card-value" style="color:#ef4444">${summary.critical}</div>
package/src/mdReport.js CHANGED
@@ -69,6 +69,14 @@ function generateMarkdownReport(analysis) {
69
69
  if (totalCost > 0) lines.push(`| Total session cost (lifetime) | $${totalCost.toFixed(4)} |`);
70
70
  if (totalDailyRate > 0) lines.push(`| Avg daily burn rate | $${totalDailyRate.toFixed(4)}/day (~$${(totalDailyRate * 30).toFixed(2)}/month) |`);
71
71
  }
72
+ if (summary.totalRealCost > 0) {
73
+ lines.push(`| 💰 **Actual API spend** | **$${summary.totalRealCost.toFixed(4)}** |`);
74
+ if (summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1) {
75
+ lines.push(`| sessions.json estimate | $${summary.totalEstimatedCost.toFixed(4)} (${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x gap) |`);
76
+ }
77
+ }
78
+ if (summary.totalCacheWrite > 0) lines.push(`| Cache writes | ${summary.totalCacheWrite.toLocaleString()} tokens |`);
79
+ if (summary.totalCacheRead > 0) lines.push(`| Cache reads | ${summary.totalCacheRead.toLocaleString()} tokens |`);
72
80
  lines.push('');
73
81
 
74
82
  // Findings by severity
package/src/reporter.js CHANGED
@@ -115,6 +115,15 @@ function generateTerminalReport(analysis) {
115
115
  console.log(`${C}━━━ Summary ━━━${R}`);
116
116
  console.log(` 🔴 ${RED}${summary.critical}${R} critical 🟠 ${summary.high} high 🟡 ${summary.medium} medium 🔵 ${summary.low||0} low ✅ ${summary.info} ok`);
117
117
  console.log(` Sessions analyzed: ${summary.sessionsAnalyzed} · Tokens found: ${(summary.totalTokensFound||0).toLocaleString()}`);
118
+ if (summary.totalCacheRead > 0 || summary.totalCacheWrite > 0) {
119
+ console.log(` ${D}Cache tokens: ${(summary.totalCacheRead||0).toLocaleString()} read · ${(summary.totalCacheWrite||0).toLocaleString()} write${R}`);
120
+ }
121
+ if (summary.totalRealCost > 0) {
122
+ console.log(` ${B}Actual API spend: ${RED}$${summary.totalRealCost.toFixed(4)}${R}${B} (from .jsonl transcripts)${R}`);
123
+ if (summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1) {
124
+ console.log(` ${D}sessions.json estimate: $${summary.totalEstimatedCost.toFixed(4)} — ${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x gap (cache tokens)${R}`);
125
+ }
126
+ }
118
127
  if (bleed > 0) {
119
128
  console.log(` ${RED}${B}Estimated monthly bleed: $${bleed.toFixed(2)}/month${R}`);
120
129
  } else {