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 +1 -1
- package/skills/clawculator/analyzer.js +330 -13
- package/skills/clawculator/htmlReport.js +25 -2
- package/skills/clawculator/mdReport.js +8 -0
- package/skills/clawculator/reporter.js +9 -0
- package/src/analyzer.js +329 -12
- package/src/clawculator.js +123 -0
- package/src/htmlReport.js +25 -2
- package/src/mdReport.js +8 -0
- package/src/reporter.js +9 -0
package/package.json
CHANGED
|
@@ -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
|
|
624
|
+
const estimatedCost = costPerCall(modelKey, inTok, outTok);
|
|
519
625
|
const updatedAt = s.updatedAt || s.lastActive || null;
|
|
520
626
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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:
|
|
529
|
-
if (
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
624
|
+
const estimatedCost = costPerCall(modelKey, inTok, outTok);
|
|
519
625
|
const updatedAt = s.updatedAt || s.lastActive || null;
|
|
520
626
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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:
|
|
529
|
-
if (
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|