clawculator 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ <p align="center">
2
+ <img src="logo.png" width="200" />
3
+ </p>
4
+
5
+ # Clawculator
6
+
7
+ > **Your friendly penny pincher.**
8
+
9
+ AI cost forensics for OpenClaw and multi-model setups. One command. Full analysis. 100% offline. Zero AI. Pure deterministic logic.
10
+
11
+ [![npm version](https://badge.fury.io/js/clawculator.svg)](https://badge.fury.io/js/clawculator)
12
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
13
+
14
+ ---
15
+
16
+ ## The problem
17
+
18
+ You set up OpenClaw. It runs great. Then your API bill arrives and you have no idea why it's $150. Was it the heartbeat? A skill running a polling loop? WhatsApp groups processing every message on Sonnet? Orphaned sessions? Hooks on the wrong model?
19
+
20
+ It could be any of these. Clawculator finds all of them โ€” with zero AI, zero guesswork, and zero data leaving your machine.
21
+
22
+ ---
23
+
24
+ ## One command
25
+
26
+ ```bash
27
+ npx clawculator
28
+ ```
29
+
30
+ No install. No account. No config. Auto-detects your OpenClaw setup. Full deterministic report in seconds.
31
+
32
+ ---
33
+
34
+ ## ๐Ÿ”’ 100% offline. Zero AI.
35
+
36
+ Clawculator uses **pure switch/case deterministic logic** โ€” no LLM, no Ollama, no model of any kind. Every finding and recommendation is hardcoded. Results are 100% reproducible and non-negotiable.
37
+
38
+ Your `openclaw.json`, session logs, and API keys never leave your machine. There is no server. Disconnect your internet and run it โ€” it works.
39
+
40
+ ---
41
+
42
+ ## What it finds
43
+
44
+ | Source | What it catches | Severity |
45
+ |--------|----------------|----------|
46
+ | ๐Ÿ’“ Heartbeat | Running on paid model instead of Ollama | ๐Ÿ”ด Critical |
47
+ | ๐Ÿ’“ Heartbeat | target not set to "none" (v2026.2.24+) | ๐ŸŸ  High |
48
+ | ๐Ÿ”ง Skills | Polling/cron loops on paid model | ๐Ÿ”ด Critical |
49
+ | ๐Ÿ“ฑ WhatsApp | Groups auto-joined on primary model | ๐Ÿ”ด Critical |
50
+ | ๐Ÿช Hooks | boot-md, command-logger, session-memory on Sonnet | ๐ŸŸ  High |
51
+ | ๐Ÿ’ฌ Sessions | Orphaned sessions still holding tokens | ๐ŸŸ  High |
52
+ | ๐Ÿค– Subagents | maxConcurrent too high โ€” burst multiplier | ๐ŸŸ  High |
53
+ | ๐Ÿ“ Workspace | Too many root .md files inflating context | ๐ŸŸก Medium |
54
+ | ๐Ÿง  Memory | memoryFlush on primary model | ๐ŸŸก Medium |
55
+ | โš™๏ธ Primary model | Cost awareness of chosen model tier | โ„น๏ธ Info |
56
+
57
+ ---
58
+
59
+ ## Usage
60
+
61
+ ```bash
62
+ npx clawculator # Terminal analysis (default)
63
+ npx clawculator --md # Markdown report (readable by your AI agent)
64
+ npx clawculator --report # Visual HTML dashboard
65
+ npx clawculator --json # JSON for piping into other tools
66
+ npx clawculator --md --out=~/cost.md # Custom output path
67
+ npx clawculator --config=/path/to/openclaw.json
68
+ npx clawculator --help
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Output formats
74
+
75
+ **Terminal** โ€” color-coded findings by severity with cost estimates and exact fix commands.
76
+
77
+ **Markdown (`--md`)** โ€” structured report your OpenClaw agent can read directly. Drop it in your workspace and ask your agent "what's my cost status?" It reads `clawculator-report.md` and answers.
78
+
79
+ **HTML (`--report`)** โ€” visual dashboard with session breakdown table, cost exposure banner, opens in browser locally.
80
+
81
+ **JSON (`--json`)** โ€” machine-readable, pipeable:
82
+ ```bash
83
+ npx clawculator --json | jq '.summary'
84
+ npx clawculator --json > cost-report.json
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Why deterministic?
90
+
91
+ Every recommendation is a hardcoded switch/case โ€” not generated by an AI. This means:
92
+
93
+ - Results are identical every time for the same input
94
+ - No hallucinations, no surprises
95
+ - Works completely offline with no model dependency
96
+ - Fast โ€” analysis runs in under a second
97
+
98
+ ---
99
+
100
+ ## Built by
101
+
102
+ [Ed Choudhry](https://github.com/echoudhry) โ€” after personally losing hundreds of dollars to silent API cost bleed. Every cost source in this tool was discovered the hard way.
103
+
104
+ If this saved you money, star the repo and share it in the OpenClaw Discord.
105
+
106
+ ---
107
+
108
+ ## License
109
+
110
+ MIT โ€” free forever, open source, no telemetry, no accounts.
@@ -0,0 +1,110 @@
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 configPath = flags.config || path.join(os.homedir(), '.openclaw', 'openclaw.json');
65
+ const sessionsPath = path.join(os.homedir(), '.openclaw', 'agents', 'main', 'sessions', 'sessions.json');
66
+ const logsDir = '/tmp/openclaw';
67
+
68
+ let analysis;
69
+ try {
70
+ analysis = await runAnalysis({ configPath, sessionsPath, logsDir });
71
+ } catch (err) {
72
+ console.error('\x1b[31mError:\x1b[0m', err.message);
73
+ process.exit(1);
74
+ }
75
+
76
+ if (flags.json) {
77
+ console.log(JSON.stringify(analysis, null, 2));
78
+ process.exit(0);
79
+ }
80
+
81
+ if (flags.md) {
82
+ const outPath = flags.out || path.join(process.cwd(), 'clawculator-report.md');
83
+ fs.writeFileSync(outPath, generateMarkdownReport(analysis), 'utf8');
84
+ console.log(`\x1b[32mโœ“ Markdown report saved:\x1b[0m ${outPath}`);
85
+ generateTerminalReport(analysis);
86
+ process.exit(0);
87
+ }
88
+
89
+ if (flags.report) {
90
+ const outPath = flags.out || path.join(os.tmpdir(), `clawculator-${Date.now()}.html`);
91
+ const { generateHTMLReport } = require('../src/htmlReport');
92
+ await generateHTMLReport(analysis, outPath);
93
+ const { exec } = require('child_process');
94
+ exec(`open "${outPath}" 2>/dev/null || xdg-open "${outPath}" 2>/dev/null`);
95
+ console.log(`\x1b[32mโœ“ HTML report saved:\x1b[0m ${outPath}`);
96
+ generateTerminalReport(analysis);
97
+ process.exit(0);
98
+ }
99
+
100
+ generateTerminalReport(analysis);
101
+ console.log('\x1b[90mโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\x1b[0m');
102
+ console.log('\x1b[36mClawculator\x1b[0m ยท github.com/echoudhry/clawculator ยท Your friendly penny pincher.');
103
+ console.log('\x1b[90mTip: --md saves a report your AI agent can read directly\x1b[0m');
104
+ console.log('\x1b[90mโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\x1b[0m\n');
105
+ }
106
+
107
+ main().catch(err => {
108
+ console.error('\x1b[31mFatal:\x1b[0m', err.message);
109
+ process.exit(1);
110
+ });
package/logo.png ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "clawculator",
3
+ "version": "2.0.0",
4
+ "description": "AI cost forensics for OpenClaw and multi-model setups. Your friendly penny pincher. 100% offline. Zero AI. Pure deterministic logic.",
5
+ "main": "src/analyzer.js",
6
+ "bin": {
7
+ "clawculator": "bin/clawculator.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/clawculator.js",
11
+ "test": "node -e \"const {runAnalysis} = require('./src/analyzer'); runAnalysis({configPath:'~/.openclaw/openclaw.json',sessionsPath:'',logsDir:''}).then(r => console.log('OK:', r.summary))\""
12
+ },
13
+ "keywords": [
14
+ "openclaw",
15
+ "claude",
16
+ "anthropic",
17
+ "ai-cost",
18
+ "token-usage",
19
+ "cost-optimization",
20
+ "llm-cost",
21
+ "ai-forensics",
22
+ "multi-model"
23
+ ],
24
+ "author": "Ed Choudhry",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/echoudhry/clawculator.git"
29
+ },
30
+ "engines": {
31
+ "node": ">=18.0.0"
32
+ },
33
+ "dependencies": {}
34
+ }
@@ -0,0 +1,571 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ // โ”€โ”€ Model pricing (per million tokens, input/output) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
8
+ const MODEL_PRICING = {
9
+ 'claude-opus-4-6': { input: 5.00, output: 25.00, label: 'Claude Opus 4.6' },
10
+ 'claude-opus-4-5': { input: 5.00, output: 25.00, label: 'Claude Opus 4.5' },
11
+ 'claude-sonnet-4-5-20250929': { input: 3.00, output: 15.00, label: 'Claude Sonnet 4.5' },
12
+ 'claude-sonnet-4-6': { input: 3.00, output: 15.00, label: 'Claude Sonnet 4.6' },
13
+ 'claude-haiku-4-5-20251001': { input: 0.80, output: 4.00, label: 'Claude Haiku 4.5' },
14
+ 'claude-haiku-4-5': { input: 0.80, output: 4.00, label: 'Claude Haiku 4.5' },
15
+ 'claude-3-5-sonnet-20241022': { input: 3.00, output: 15.00, label: 'Claude 3.5 Sonnet' },
16
+ 'claude-3-5-haiku-20241022': { input: 0.80, output: 4.00, label: 'Claude 3.5 Haiku' },
17
+ 'claude-3-opus-20240229': { input: 15.00, output: 75.00, label: 'Claude 3 Opus' },
18
+ 'gpt-4o': { input: 2.50, output: 10.00, label: 'GPT-4o' },
19
+ 'gpt-4o-mini': { input: 0.15, output: 0.60, label: 'GPT-4o Mini' },
20
+ 'gpt-4-turbo': { input: 10.00, output: 30.00, label: 'GPT-4 Turbo' },
21
+ 'gpt-5.2': { input: 10.00, output: 40.00, label: 'GPT-5.2' },
22
+ 'gpt-5-mini': { input: 0.40, output: 1.60, label: 'GPT-5 Mini' },
23
+ 'gpt-5.3-codex': { input: 0, output: 0, label: 'Codex (subscription)', subscription: true },
24
+ 'gemini-1.5-pro': { input: 1.25, output: 5.00, label: 'Gemini 1.5 Pro' },
25
+ 'gemini-1.5-flash': { input: 0.075, output: 0.30, label: 'Gemini 1.5 Flash' },
26
+ 'gemini-2.0-flash': { input: 0.10, output: 0.40, label: 'Gemini 2.0 Flash' },
27
+ 'gemini-2.5-flash': { input: 0.15, output: 0.60, label: 'Gemini 2.5 Flash' },
28
+ 'gemini-2.5-pro': { input: 1.25, output: 10.00, label: 'Gemini 2.5 Pro' },
29
+ };
30
+
31
+ // OpenRouter adds ~8-10% markup on top of provider pricing
32
+ const OPENROUTER_MARKUP = 0.10;
33
+
34
+ // โ”€โ”€ Deterministic fix recommendations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
35
+ const FIXES = {
36
+ HEARTBEAT_PAID_MODEL: {
37
+ fix: 'Set heartbeat.model to a free local Ollama model',
38
+ command: 'ollama pull qwen2.5:0.5b โ†’ set heartbeat.model = "ollama/qwen2.5:0.5b"',
39
+ },
40
+ HEARTBEAT_TARGET: {
41
+ fix: 'Set heartbeat.target to "none" (required since v2026.2.24)',
42
+ command: 'openclaw config set agents.defaults.heartbeat.target none',
43
+ },
44
+ WHATSAPP_GROUPS_OPEN: {
45
+ fix: 'Set whatsapp.groups to {} to block all groups from auto-processing',
46
+ command: 'openclaw config set channels.whatsapp.groups \'{}\'',
47
+ },
48
+ WHATSAPP_GROUPS_ACTIVE: {
49
+ fix: 'Review each active WhatsApp group โ€” every message costs tokens on your primary model',
50
+ command: 'Remove group IDs from channels.whatsapp.groups to stop processing them',
51
+ },
52
+ TELEGRAM_OPEN: {
53
+ fix: 'Telegram dmPolicy is "open" โ€” anyone can find and message your bot, running up your bill',
54
+ command: 'openclaw config set channels.telegram.dmPolicy allowlist',
55
+ },
56
+ DISCORD_OPEN: {
57
+ fix: 'Discord DM policy is "open" โ€” restrict to allowlist to prevent unknown users billing you',
58
+ command: 'openclaw config set channels.discord.dm.policy allowlist',
59
+ },
60
+ SIGNAL_OPEN: {
61
+ fix: 'Signal has no allowlist โ€” anyone who has your number can message your agent',
62
+ command: 'Add allowFrom list to channels.signal config',
63
+ },
64
+ HOOK_PAID_MODEL: (name) => ({
65
+ fix: `Switch hook "${name}" to Haiku โ€” 80% cheaper than Sonnet for simple tasks`,
66
+ command: `openclaw config set hooks.${name}.model claude-haiku-4-5-20251001`,
67
+ }),
68
+ SKILL_POLLING: (name) => ({
69
+ fix: `Skill "${name}" is polling on a paid model โ€” switch to Ollama or increase interval`,
70
+ command: `Set skills.${name}.model = "ollama/qwen2.5:0.5b" in openclaw.json`,
71
+ }),
72
+ CRON_PAID_MODEL: (name, interval) => ({
73
+ fix: `Cron job "${name}" running every ${interval} on a paid model`,
74
+ command: `Add model: "ollama/qwen2.5:0.5b" to cron job "${name}" config, or increase interval`,
75
+ }),
76
+ MAX_CONCURRENT: {
77
+ fix: 'Reduce maxConcurrent to 2 โ€” high concurrency multiplies cost spikes',
78
+ command: 'openclaw config set agents.defaults.subagents.maxConcurrent 2',
79
+ },
80
+ ORPHANED_SESSIONS: {
81
+ fix: 'Delete sessions.json to clear orphaned sessions โ€” they auto-rebuild on next use',
82
+ command: 'rm ~/.openclaw/agents/main/sessions/sessions.json',
83
+ },
84
+ LARGE_SESSIONS: {
85
+ fix: 'Reduce root-level .md files in your workspace to shrink session context size',
86
+ command: 'Move inactive files to ~/clawd/archive/ and ~/clawd/projects/',
87
+ },
88
+ WORKSPACE_BLOAT: {
89
+ fix: 'Move inactive files to /archive/ and /projects/ subdirectories',
90
+ command: 'mkdir -p ~/clawd/archive ~/clawd/projects # then move unused .md files',
91
+ },
92
+ MEMORY_FLUSH_PAID: {
93
+ fix: 'memoryFlush inherits your primary model โ€” consider routing compaction to Haiku',
94
+ command: 'Check openclaw.json for memoryFlush.model support in your version',
95
+ },
96
+ CONTEXT_PRUNING_MISSING: {
97
+ fix: 'Add contextPruning to prevent unbounded session growth โ€” each message re-sends full history',
98
+ command: 'openclaw config set agents.defaults.session.contextPruning.mode sliding',
99
+ },
100
+ FALLBACK_EXPENSIVE: (model, position) => ({
101
+ fix: `Fallback position ${position} is an expensive model (${model}) โ€” silent cost escalation on rate limits`,
102
+ command: `Replace ${model} in fallbacks array with a cheaper model like claude-haiku-4-5-20251001`,
103
+ }),
104
+ OPENROUTER_MARKUP: {
105
+ fix: 'OpenRouter adds ~10% markup over direct provider pricing โ€” use direct API keys where possible',
106
+ command: 'Replace openrouter/* model refs with direct anthropic/* or openai/* equivalents',
107
+ },
108
+ IMAGE_DIMENSION: {
109
+ fix: 'Lower imageMaxDimensionPx to reduce vision token costs โ€” default 1200px is expensive',
110
+ command: 'openclaw config set agents.defaults.imageMaxDimensionPx 800',
111
+ },
112
+ MULTI_AGENT_PAID: (agentId) => ({
113
+ fix: `Agent "${agentId}" has its own expensive model config โ€” each agent bills independently`,
114
+ command: `Review agents.list[${agentId}].model config and apply same cost rules as primary agent`,
115
+ }),
116
+ };
117
+
118
+ // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
119
+ function readJSON(filePath) {
120
+ try {
121
+ if (!fs.existsSync(filePath)) return null;
122
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
123
+ } catch { return null; }
124
+ }
125
+
126
+ function resolveModel(modelStr) {
127
+ if (!modelStr) return null;
128
+ // Strip provider prefix for lookup (anthropic/claude-sonnet โ†’ claude-sonnet)
129
+ const stripped = modelStr.toLowerCase().replace(/^(anthropic|openai|google|openrouter\/[^/]+)\//, '');
130
+ if (MODEL_PRICING[stripped]) return stripped;
131
+ for (const key of Object.keys(MODEL_PRICING)) {
132
+ if (stripped.includes(key) || key.includes(stripped)) return key;
133
+ }
134
+ return isLocalModel(modelStr) ? 'ollama' : null;
135
+ }
136
+
137
+ function isLocalModel(modelStr) {
138
+ if (!modelStr) return false;
139
+ const lower = modelStr.toLowerCase();
140
+ return lower.startsWith('ollama/') || lower === 'ollama' ||
141
+ ['qwen', 'llama', 'mistral', 'phi', 'gemma', 'deepseek', 'kimi'].some(m => lower.includes(m));
142
+ }
143
+
144
+ function isOpenRouter(modelStr) {
145
+ return modelStr?.toLowerCase().startsWith('openrouter/');
146
+ }
147
+
148
+ function costPerCall(modelKey, inputTok = 1000, outputTok = 500) {
149
+ if (!modelKey || modelKey === 'ollama') return 0;
150
+ const p = MODEL_PRICING[modelKey];
151
+ if (!p || p.subscription) return 0;
152
+ return (inputTok / 1e6) * p.input + (outputTok / 1e6) * p.output;
153
+ }
154
+
155
+ function modelTier(modelKey) {
156
+ if (!modelKey || modelKey === 'ollama') return 'free';
157
+ const p = MODEL_PRICING[modelKey];
158
+ if (!p) return 'unknown';
159
+ if (p.input >= 5) return 'expensive';
160
+ if (p.input >= 1) return 'medium';
161
+ if (p.input >= 0.5) return 'cheap';
162
+ return 'very-cheap';
163
+ }
164
+
165
+ // โ”€โ”€ Rule: Analyze a single agent config block โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
166
+ function analyzeAgentBlock(agentCfg, primaryModel, agentId = 'main') {
167
+ const findings = [];
168
+ const prefix = agentId === 'main' ? '' : `[Agent: ${agentId}] `;
169
+
170
+ // โ”€โ”€ Heartbeat โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
171
+ const hb = agentCfg.heartbeat;
172
+ if (hb) {
173
+ const hbModel = hb.model || primaryModel;
174
+ const hbKey = resolveModel(hbModel);
175
+ const hbInterval = typeof hb.interval === 'number' ? hb.interval :
176
+ typeof hb.every === 'string' ? parseInterval(hb.every) : 60;
177
+ const hbPerDay = Math.floor(86400 / Math.max(hbInterval, 1));
178
+
179
+ if (!isLocalModel(hbModel)) {
180
+ const daily = costPerCall(hbKey, 500, 100) * hbPerDay;
181
+ const monthly = daily * 30;
182
+ findings.push({
183
+ severity: 'critical', source: 'heartbeat',
184
+ message: `${prefix}Heartbeat running on paid model: ${hbModel || 'primary'}`,
185
+ detail: `${hbPerDay} pings/day ยท $${daily.toFixed(4)}/day`,
186
+ monthlyCost: monthly,
187
+ ...FIXES.HEARTBEAT_PAID_MODEL,
188
+ });
189
+ } else {
190
+ findings.push({
191
+ severity: 'info', source: 'heartbeat',
192
+ message: `${prefix}Heartbeat using local model (${hbModel}) โœ“`,
193
+ detail: `${hbPerDay} pings/day ยท $0.00 cost`, monthlyCost: 0,
194
+ });
195
+ }
196
+
197
+ if (hb.target && hb.target !== 'none') {
198
+ findings.push({
199
+ severity: 'high', source: 'heartbeat',
200
+ message: `${prefix}Heartbeat target is "${hb.target}" โ€” must be "none" (v2026.2.24+)`,
201
+ detail: 'Non-"none" targets silently trigger paid model sessions',
202
+ ...FIXES.HEARTBEAT_TARGET,
203
+ });
204
+ }
205
+ }
206
+
207
+ // โ”€โ”€ Fallback chain โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
208
+ const fallbacks = agentCfg.model?.fallbacks || agentCfg.models?.fallbacks || [];
209
+ if (fallbacks.length > 0) {
210
+ fallbacks.forEach((fb, i) => {
211
+ const fbKey = resolveModel(fb);
212
+ const tier = modelTier(fbKey);
213
+ const isOR = isOpenRouter(fb);
214
+ if (tier === 'expensive' || tier === 'medium') {
215
+ const monthly = costPerCall(fbKey, 2000, 500) * 50 * 30;
216
+ findings.push({
217
+ severity: tier === 'expensive' ? 'high' : 'medium',
218
+ source: 'fallbacks',
219
+ message: `${prefix}Fallback #${i + 1} is expensive (${fb}) โ€” silent cost spike on rate limits`,
220
+ detail: `If primary hits rate limit, falls back to ${fb} at ~$${monthly.toFixed(2)}/month equivalent`,
221
+ monthlyCost: 0, // only triggered on rate limit, not always-on
222
+ ...FIXES.FALLBACK_EXPENSIVE(fb, i + 1),
223
+ });
224
+ }
225
+ if (isOR) {
226
+ findings.push({
227
+ severity: 'medium', source: 'fallbacks',
228
+ message: `${prefix}Fallback uses OpenRouter (${fb}) โ€” adds ~10% markup over direct pricing`,
229
+ detail: 'OpenRouter routes through their infrastructure and charges a markup',
230
+ ...FIXES.OPENROUTER_MARKUP,
231
+ });
232
+ }
233
+ });
234
+ }
235
+
236
+ // โ”€โ”€ contextPruning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
237
+ const pruning = agentCfg.session?.contextPruning || agentCfg.contextPruning;
238
+ if (!pruning || pruning.mode === 'none') {
239
+ findings.push({
240
+ severity: 'medium', source: 'context',
241
+ message: `${prefix}contextPruning is not set โ€” sessions grow unbounded`,
242
+ detail: 'Every message re-sends full conversation history as input tokens. A 30-message chat can cost 5-10x a fresh session.',
243
+ ...FIXES.CONTEXT_PRUNING_MISSING,
244
+ });
245
+ } else {
246
+ findings.push({
247
+ severity: 'info', source: 'context',
248
+ message: `${prefix}contextPruning mode: "${pruning.mode}" โœ“`, monthlyCost: 0,
249
+ });
250
+ }
251
+
252
+ // โ”€โ”€ Image dimension โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
253
+ const imgDim = agentCfg.imageMaxDimensionPx ?? agentCfg.media?.imageMaxDimensionPx;
254
+ if (imgDim === undefined || imgDim === null || imgDim > 800) {
255
+ const dimVal = imgDim ?? 1200;
256
+ findings.push({
257
+ severity: 'medium', source: 'vision',
258
+ message: `${prefix}imageMaxDimensionPx is ${dimVal} โ€” high-res vision tokens are expensive`,
259
+ detail: 'Each screenshot at 1200px can cost 1000-3000 extra tokens. Reduce to 800px or lower.',
260
+ ...FIXES.IMAGE_DIMENSION,
261
+ });
262
+ }
263
+
264
+ return findings;
265
+ }
266
+
267
+ // โ”€โ”€ Parse human interval strings like "30m", "2h", "1d" โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
268
+ function parseInterval(str) {
269
+ if (!str) return 60;
270
+ const match = str.match(/^(\d+)(s|m|h|d)$/);
271
+ if (!match) return 60;
272
+ const val = parseInt(match[1]);
273
+ return { s: val, m: val * 60, h: val * 3600, d: val * 86400 }[match[2]] || 60;
274
+ }
275
+
276
+ // โ”€โ”€ Config analysis โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
277
+ function analyzeConfig(configPath) {
278
+ const findings = [];
279
+ const config = readJSON(configPath);
280
+
281
+ if (!config) {
282
+ return {
283
+ exists: false,
284
+ findings: [{ severity: 'info', source: 'config', message: `openclaw.json not found at ${configPath}`, detail: 'Skipping config analysis โ€” run from a machine with OpenClaw installed' }],
285
+ config: null, primaryModel: null,
286
+ };
287
+ }
288
+
289
+ const agentDefaults = config.agents?.defaults || config.agent || {};
290
+ const primaryModel = agentDefaults.model?.primary || agentDefaults.model || config.model || null;
291
+ const primaryKey = resolveModel(primaryModel);
292
+
293
+ // โ”€โ”€ Primary model OpenRouter check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
294
+ if (primaryModel && isOpenRouter(primaryModel)) {
295
+ findings.push({
296
+ severity: 'medium', source: 'primary_model',
297
+ message: `Primary model is routed via OpenRouter (${primaryModel})`,
298
+ detail: 'OpenRouter adds ~10% markup. Direct provider access is cheaper.',
299
+ ...FIXES.OPENROUTER_MARKUP,
300
+ });
301
+ }
302
+
303
+ // โ”€โ”€ Analyze main agent block โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
304
+ findings.push(...analyzeAgentBlock(agentDefaults, primaryModel, 'main'));
305
+
306
+ // โ”€โ”€ Multi-agent scanning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
307
+ const agentList = config.agents?.list || [];
308
+ for (const agent of agentList) {
309
+ if (!agent.id || agent.id === 'main') continue;
310
+ const agentModel = agent.model?.primary || agent.model || primaryModel;
311
+ const agentKey = resolveModel(agentModel);
312
+
313
+ // Flag expensive per-agent model overrides
314
+ if (agentModel && !isLocalModel(agentModel) && agentKey) {
315
+ const tier = modelTier(agentKey);
316
+ if (tier === 'expensive' || tier === 'medium') {
317
+ const monthly = costPerCall(agentKey, 2000, 500) * 30 * 30;
318
+ findings.push({
319
+ severity: tier === 'expensive' ? 'high' : 'medium',
320
+ source: 'multi_agent',
321
+ message: `Agent "${agent.id}" using expensive model: ${agentModel}`,
322
+ detail: `Each agent has its own sessions, heartbeat, and hooks โ€” all bill independently`,
323
+ monthlyCost: monthly,
324
+ ...FIXES.MULTI_AGENT_PAID(agent.id),
325
+ });
326
+ }
327
+ }
328
+
329
+ // Run full rule engine on each agent's own config
330
+ const agentBlock = { ...agentDefaults, ...agent };
331
+ const agentFindings = analyzeAgentBlock(agentBlock, agentModel, agent.id)
332
+ .filter(f => f.severity !== 'info'); // only surface issues for secondary agents
333
+ findings.push(...agentFindings);
334
+ }
335
+
336
+ // โ”€โ”€ Hooks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
337
+ const hooks = config.hooks?.internal?.entries || config.hooks || {};
338
+ const hookNames = Object.keys(hooks).filter(k => k !== 'enabled' && k !== 'token' && k !== 'path');
339
+ let hookIssues = 0;
340
+
341
+ for (const name of hookNames) {
342
+ const hook = typeof hooks[name] === 'object' ? hooks[name] : {};
343
+ if (hook.enabled === false) continue;
344
+ const hookModel = hook.model || primaryModel;
345
+ if (!isLocalModel(hookModel) && resolveModel(hookModel)) {
346
+ const monthly = costPerCall(resolveModel(hookModel), 1000, 200) * 50 * 30;
347
+ hookIssues++;
348
+ findings.push({
349
+ severity: 'high', source: 'hooks',
350
+ message: `Hook "${name}" running on paid model: ${hookModel || 'primary'}`,
351
+ detail: `~50 fires/day estimated ยท $${monthly.toFixed(2)}/month`,
352
+ monthlyCost: monthly,
353
+ ...FIXES.HOOK_PAID_MODEL(name),
354
+ });
355
+ }
356
+ }
357
+ if (hookNames.length > 0 && hookIssues === 0) {
358
+ findings.push({ severity: 'info', source: 'hooks', message: `All ${hookNames.length} hooks on cheap/local models โœ“`, monthlyCost: 0 });
359
+ }
360
+
361
+ // โ”€โ”€ WhatsApp โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
362
+ const wa = config.channels?.whatsapp || {};
363
+ const groups = wa.groups;
364
+ if (wa.enabled !== false) {
365
+ if (groups === undefined || groups === null) {
366
+ findings.push({ severity: 'critical', source: 'whatsapp', message: 'WhatsApp groups policy unset โ€” ALL groups auto-joined on primary model', detail: 'Every message from every group you\'re in hits your primary model', ...FIXES.WHATSAPP_GROUPS_OPEN });
367
+ } else if (typeof groups === 'object' && Object.keys(groups).length > 0) {
368
+ findings.push({ severity: 'high', source: 'whatsapp', message: `${Object.keys(groups).length} WhatsApp group(s) actively processing on primary model`, detail: `Group IDs: ${Object.keys(groups).join(', ')}`, ...FIXES.WHATSAPP_GROUPS_ACTIVE });
369
+ } else {
370
+ findings.push({ severity: 'info', source: 'whatsapp', message: 'WhatsApp groups blocked โœ“', monthlyCost: 0 });
371
+ }
372
+ }
373
+
374
+ // โ”€โ”€ Telegram โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
375
+ const tg = config.channels?.telegram || {};
376
+ if (tg.enabled !== false && tg.botToken) {
377
+ const policy = tg.dmPolicy || tg.dm?.policy || 'pairing';
378
+ if (policy === 'open') {
379
+ findings.push({ severity: 'critical', source: 'telegram', message: 'Telegram dmPolicy is "open" โ€” anyone can message your bot and bill you', detail: 'Any Telegram user who finds your bot can trigger paid API calls', ...FIXES.TELEGRAM_OPEN });
380
+ } else {
381
+ findings.push({ severity: 'info', source: 'telegram', message: `Telegram dmPolicy: "${policy}" โœ“`, monthlyCost: 0 });
382
+ }
383
+ }
384
+
385
+ // โ”€โ”€ Discord โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
386
+ const dc = config.channels?.discord || {};
387
+ if (dc.enabled !== false && dc.token) {
388
+ const dcPolicy = dc.dm?.policy || dc.dmPolicy || 'pairing';
389
+ if (dcPolicy === 'open') {
390
+ findings.push({ severity: 'high', source: 'discord', message: 'Discord DM policy is "open" โ€” unknown users can trigger paid API calls', detail: 'Anyone in your server can DM your bot', ...FIXES.DISCORD_OPEN });
391
+ } else {
392
+ findings.push({ severity: 'info', source: 'discord', message: `Discord DM policy: "${dcPolicy}" โœ“`, monthlyCost: 0 });
393
+ }
394
+ }
395
+
396
+ // โ”€โ”€ Signal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
397
+ const sig = config.channels?.signal || {};
398
+ if (sig.enabled !== false && sig.phoneNumber) {
399
+ if (!sig.allowFrom || sig.allowFrom.length === 0) {
400
+ findings.push({ severity: 'high', source: 'signal', message: 'Signal has no allowFrom list โ€” anyone with your number can message your agent', detail: 'No sender restriction on Signal channel', ...FIXES.SIGNAL_OPEN });
401
+ }
402
+ }
403
+
404
+ // โ”€โ”€ Cron jobs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
405
+ const cronJobs = config.cron?.jobs || config.cron || {};
406
+ if (typeof cronJobs === 'object' && !Array.isArray(cronJobs)) {
407
+ for (const [name, job] of Object.entries(cronJobs)) {
408
+ if (typeof job !== 'object' || job.enabled === false) continue;
409
+ const jobModel = job.model || primaryModel;
410
+ const jobKey = resolveModel(jobModel);
411
+ const interval = job.every ? parseInterval(job.every) : (job.interval || 3600);
412
+ const perDay = Math.floor(86400 / Math.max(interval, 1));
413
+
414
+ if (!isLocalModel(jobModel) && jobKey) {
415
+ const monthly = costPerCall(jobKey, 2000, 500) * perDay * 30;
416
+ findings.push({
417
+ severity: perDay > 24 ? 'critical' : 'high',
418
+ source: 'cron',
419
+ message: `Cron job "${name}" runs ${perDay}x/day on paid model: ${jobModel || 'primary'}`,
420
+ detail: `Interval: ${job.every || interval + 's'} ยท $${monthly.toFixed(2)}/month estimated`,
421
+ monthlyCost: monthly,
422
+ ...FIXES.CRON_PAID_MODEL(name, job.every || `${interval}s`),
423
+ });
424
+ }
425
+ }
426
+ }
427
+
428
+ // โ”€โ”€ Skills polling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
429
+ const skills = config.skills?.entries || config.skills || {};
430
+ for (const [name, skill] of Object.entries(skills)) {
431
+ if (typeof skill !== 'object' || skill.enabled === false) continue;
432
+ if (skill.interval || skill.cron || skill.poll) {
433
+ const interval = typeof skill.interval === 'number' ? skill.interval : 60;
434
+ const perDay = Math.floor(86400 / interval);
435
+ const skillModel = skill.model || primaryModel;
436
+ const skillKey = resolveModel(skillModel);
437
+ if (!isLocalModel(skillModel) && skillKey) {
438
+ const monthly = costPerCall(skillKey, 2000, 500) * perDay * 30;
439
+ findings.push({
440
+ severity: 'critical', source: 'skills',
441
+ message: `Skill "${name}" has polling loop on paid model: ${skillModel || 'primary'}`,
442
+ detail: `~${perDay} calls/day ยท $${monthly.toFixed(2)}/month estimated`,
443
+ monthlyCost: monthly,
444
+ ...FIXES.SKILL_POLLING(name),
445
+ });
446
+ }
447
+ }
448
+ }
449
+
450
+ // โ”€โ”€ Subagents concurrency โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
451
+ const maxC = agentDefaults.subagents?.maxConcurrent ?? config.subagents?.maxConcurrent ?? null;
452
+ if (maxC !== null && maxC > 2) {
453
+ findings.push({ severity: 'high', source: 'subagents', message: `maxConcurrent = ${maxC} โ€” ${maxC}x cost multiplier during bursts`, detail: `${maxC} paid model calls can fire simultaneously`, ...FIXES.MAX_CONCURRENT });
454
+ } else if (maxC !== null) {
455
+ findings.push({ severity: 'info', source: 'subagents', message: `maxConcurrent = ${maxC} โœ“`, monthlyCost: 0 });
456
+ }
457
+
458
+ // โ”€โ”€ memoryFlush โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
459
+ const mfModel = config.memory?.flushModel || config.memoryFlush?.model || primaryModel;
460
+ if (mfModel && !isLocalModel(mfModel)) {
461
+ findings.push({ severity: 'medium', source: 'memory', message: `memoryFlush using paid model: ${mfModel}`, detail: 'Runs on every session compaction โ€” cost scales with context size', ...FIXES.MEMORY_FLUSH_PAID });
462
+ }
463
+
464
+ // โ”€โ”€ Primary model awareness โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
465
+ if (primaryKey && MODEL_PRICING[primaryKey] && !MODEL_PRICING[primaryKey].subscription) {
466
+ const p = MODEL_PRICING[primaryKey];
467
+ const monthly = costPerCall(primaryKey, 2000, 500) * 50 * 30;
468
+ findings.push({
469
+ severity: p.input >= 5 ? 'high' : p.input >= 1 ? 'medium' : 'info',
470
+ source: 'primary_model',
471
+ message: `Primary model: ${p.label} ยท $${p.input}/$${p.output} per MTok`,
472
+ detail: `Baseline at 50 queries/day: ~$${monthly.toFixed(2)}/month`,
473
+ monthlyCost: monthly,
474
+ });
475
+ }
476
+
477
+ return { exists: true, findings, config, primaryModel };
478
+ }
479
+
480
+ // โ”€โ”€ Session analysis โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
481
+ function analyzeSessions(sessionsPath) {
482
+ const findings = [];
483
+ const sessions = readJSON(sessionsPath);
484
+
485
+ if (!sessions) return { exists: false, findings: [], sessions: [], totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0, sessionCount: 0 };
486
+
487
+ let totalIn = 0, totalOut = 0, totalCost = 0;
488
+ const breakdown = [], orphaned = [], large = [];
489
+
490
+ for (const key of Object.keys(sessions)) {
491
+ const s = sessions[key];
492
+ const model = s.model || s.primaryModel || null;
493
+ const modelKey = resolveModel(model);
494
+ const inTok = s.inputTokens || s.tokensIn || s.tokens?.input || 0;
495
+ const outTok = s.outputTokens || s.tokensOut || s.tokens?.output || 0;
496
+ const cost = costPerCall(modelKey, inTok, outTok);
497
+ const updatedAt = s.updatedAt || s.lastActive || null;
498
+
499
+ totalIn += inTok;
500
+ totalOut += outTok;
501
+ totalCost += cost;
502
+
503
+ const isOrphaned = key.includes('cron') || key.includes('deleted') ||
504
+ (updatedAt && Date.now() - new Date(updatedAt).getTime() > 48 * 3600 * 1000 && !key.includes('main'));
505
+
506
+ if (isOrphaned) orphaned.push({ key, model, tokens: inTok + outTok, cost });
507
+ if (inTok + outTok > 50000) large.push({ key, model, tokens: inTok + outTok });
508
+
509
+ breakdown.push({ key, model, modelLabel: modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown', inputTokens: inTok, outputTokens: outTok, cost, updatedAt, isOrphaned });
510
+ }
511
+
512
+ 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 });
513
+ 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 });
514
+ 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()}` });
515
+
516
+ return { exists: true, findings, sessions: breakdown, totalInputTokens: totalIn, totalOutputTokens: totalOut, totalCost, sessionCount: Object.keys(sessions).length };
517
+ }
518
+
519
+ // โ”€โ”€ Workspace analysis โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
520
+ function analyzeWorkspace() {
521
+ const findings = [];
522
+ const workspaceDir = path.join(os.homedir(), 'clawd');
523
+ if (!fs.existsSync(workspaceDir)) return { exists: false, findings: [] };
524
+
525
+ try {
526
+ const rootFiles = fs.readdirSync(workspaceDir).filter(f => f.endsWith('.md') || f.endsWith('.txt'));
527
+ const count = rootFiles.length;
528
+ const estimatedTokens = count * 500;
529
+ if (count > 20) {
530
+ findings.push({ severity: 'medium', source: 'workspace', message: `${count} files at workspace root โ€” all loaded into every session context`, detail: `~${estimatedTokens.toLocaleString()} tokens/session from workspace files`, ...FIXES.WORKSPACE_BLOAT });
531
+ } else {
532
+ findings.push({ severity: 'info', source: 'workspace', message: `${count} files at workspace root โ€” lean โœ“`, detail: `~${estimatedTokens.toLocaleString()} tokens estimated` });
533
+ }
534
+ } catch { /* not readable */ }
535
+
536
+ return { exists: true, findings };
537
+ }
538
+
539
+ // โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
540
+ async function runAnalysis({ configPath, sessionsPath, logsDir }) {
541
+ const configResult = analyzeConfig(configPath);
542
+ const sessionResult = analyzeSessions(sessionsPath);
543
+ const workspaceResult = analyzeWorkspace();
544
+
545
+ const allFindings = [...configResult.findings, ...sessionResult.findings, ...workspaceResult.findings];
546
+
547
+ const estimatedMonthlyBleed = allFindings
548
+ .filter(f => f.monthlyCost && f.severity !== 'info')
549
+ .reduce((sum, f) => sum + f.monthlyCost, 0);
550
+
551
+ return {
552
+ scannedAt: new Date().toISOString(),
553
+ configPath,
554
+ sessionsPath,
555
+ primaryModel: configResult.primaryModel,
556
+ findings: allFindings,
557
+ summary: {
558
+ critical: allFindings.filter(f => f.severity === 'critical').length,
559
+ high: allFindings.filter(f => f.severity === 'high').length,
560
+ medium: allFindings.filter(f => f.severity === 'medium').length,
561
+ info: allFindings.filter(f => f.severity === 'info').length,
562
+ estimatedMonthlyBleed,
563
+ sessionsAnalyzed: sessionResult.sessionCount,
564
+ totalTokensFound: (sessionResult.totalInputTokens || 0) + (sessionResult.totalOutputTokens || 0),
565
+ },
566
+ sessions: sessionResult.sessions || [],
567
+ config: configResult.config,
568
+ };
569
+ }
570
+
571
+ module.exports = { runAnalysis, MODEL_PRICING, resolveModel, costPerCall };
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ function severityColor(severity) {
8
+ return { critical: '#ef4444', high: '#f97316', medium: '#eab308', info: '#22c55e' }[severity] || '#6b7280';
9
+ }
10
+
11
+ function severityBg(severity) {
12
+ return { critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', info: '#f0fdf4' }[severity] || '#f9fafb';
13
+ }
14
+
15
+ function severityIcon(severity) {
16
+ return { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', info: 'โœ…' }[severity] || 'โšช';
17
+ }
18
+
19
+ const SOURCE_LABELS = {
20
+ heartbeat: '๐Ÿ’“ Heartbeat', hooks: '๐Ÿช Hooks', whatsapp: '๐Ÿ“ฑ WhatsApp',
21
+ subagents: '๐Ÿค– Subagents', skills: '๐Ÿ”ง Skills', memory: '๐Ÿง  Memory',
22
+ primary_model: 'โš™๏ธ Primary Model', sessions: '๐Ÿ’ฌ Sessions',
23
+ workspace: '๐Ÿ“ Workspace', config: '๐Ÿ“„ Config',
24
+ };
25
+
26
+ async function generateHTMLReport(analysis) {
27
+ const { summary, findings, sessions } = analysis;
28
+ const bleed = summary.estimatedMonthlyBleed;
29
+
30
+ const findingCards = findings.map(f => `
31
+ <div class="finding" style="border-left: 4px solid ${severityColor(f.severity)}; background: ${severityBg(f.severity)}; padding: 16px; margin-bottom: 12px; border-radius: 0 8px 8px 0;">
32
+ <div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
33
+ <span style="font-size:18px">${severityIcon(f.severity)}</span>
34
+ <strong style="color:${severityColor(f.severity)}">${f.severity.toUpperCase()}</strong>
35
+ <span style="color:#6b7280; font-size:14px">${SOURCE_LABELS[f.source] || f.source}</span>
36
+ ${f.monthlyCost ? `<span style="margin-left:auto; color:${severityColor(f.severity)}; font-weight:bold">$${f.monthlyCost.toFixed(2)}/mo</span>` : ''}
37
+ </div>
38
+ <div style="font-weight:600; color:#111; margin-bottom:4px">${f.message}</div>
39
+ ${f.detail ? `<div style="color:#555; font-size:14px; margin-bottom:6px; white-space:pre-line">${f.detail}</div>` : ''}
40
+ ${f.recommendation ? `<div style="color:#16a34a; font-size:14px; margin-top:8px">โ†’ Fix: ${f.recommendation}</div>` : ''}
41
+ </div>
42
+ `).join('');
43
+
44
+ const sessionRows = (sessions || [])
45
+ .sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens))
46
+ .slice(0, 20)
47
+ .map(s => `
48
+ <tr style="${s.isOrphaned ? 'background:#fff7ed' : ''}">
49
+ <td style="padding:8px 12px; font-family:monospace; font-size:13px">${s.key}${s.isOrphaned ? ' โš ๏ธ' : ''}</td>
50
+ <td style="padding:8px 12px">${s.modelLabel || s.model}</td>
51
+ <td style="padding:8px 12px; text-align:right">${(s.inputTokens + s.outputTokens).toLocaleString()}</td>
52
+ <td style="padding:8px 12px; text-align:right; color:${s.cost > 0.01 ? '#ef4444' : '#22c55e'}">$${s.cost.toFixed(6)}</td>
53
+ </tr>
54
+ `).join('');
55
+
56
+ const html = `<!DOCTYPE html>
57
+ <html lang="en">
58
+ <head>
59
+ <meta charset="UTF-8">
60
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
61
+ <title>Clawculator Report โ€” ${new Date(analysis.scannedAt).toLocaleString()}</title>
62
+ <style>
63
+ * { box-sizing: border-box; margin: 0; padding: 0; }
64
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; }
65
+ .header { background: linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%); padding: 48px 32px; text-align: center; border-bottom: 1px solid #1e40af; }
66
+ .logo { font-size: 42px; font-weight: 900; letter-spacing: -2px; background: linear-gradient(90deg, #38bdf8, #818cf8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
67
+ .tagline { color: #94a3b8; margin-top: 8px; font-size: 16px; }
68
+ .container { max-width: 1000px; margin: 0 auto; padding: 32px 24px; }
69
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 32px; }
70
+ .card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; }
71
+ .card-value { font-size: 32px; font-weight: 800; }
72
+ .card-label { font-size: 13px; color: #94a3b8; margin-top: 4px; }
73
+ .section { background: #1e293b; border-radius: 12px; padding: 24px; margin-bottom: 24px; border: 1px solid #334155; }
74
+ .section-title { font-size: 18px; font-weight: 700; margin-bottom: 16px; color: #f1f5f9; }
75
+ table { width: 100%; border-collapse: collapse; }
76
+ th { background: #0f172a; padding: 10px 12px; text-align: left; font-size: 13px; color: #94a3b8; }
77
+ tr:nth-child(even) { background: #0f172a33; }
78
+ .footer { text-align: center; color: #475569; font-size: 13px; padding: 32px; }
79
+ .bleed { background: linear-gradient(135deg, #7f1d1d, #991b1b); border-radius: 12px; padding: 20px 24px; margin-bottom: 24px; border: 1px solid #ef4444; }
80
+ .bleed-amount { font-size: 36px; font-weight: 900; color: #fca5a5; }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <div class="header">
85
+ <div class="logo">CLAWCULATOR</div>
86
+ <div class="tagline">Your friendly penny pincher. ยท 100% offline ยท Zero AI ยท Pure deterministic logic</div>
87
+ <div style="color:#64748b; font-size:13px; margin-top:12px">Report generated: ${new Date(analysis.scannedAt).toLocaleString()}</div>
88
+ </div>
89
+
90
+ <div class="container">
91
+ ${bleed > 0 ? `
92
+ <div class="bleed">
93
+ <div style="color:#fca5a5; font-size:14px; font-weight:600; margin-bottom:4px">โš ๏ธ ESTIMATED MONTHLY COST EXPOSURE</div>
94
+ <div class="bleed-amount">$${bleed.toFixed(2)}/month</div>
95
+ <div style="color:#fca5a5; font-size:14px; margin-top:4px">Based on current config โ€” fix the critical issues below to stop the bleed</div>
96
+ </div>` : `
97
+ <div style="background:#14532d; border-radius:12px; padding:20px 24px; margin-bottom:24px; border:1px solid #22c55e">
98
+ <div style="color:#86efac; font-size:18px; font-weight:700">โœ… No significant cost bleed detected</div>
99
+ </div>`}
100
+
101
+ <div class="cards">
102
+ <div class="card">
103
+ <div class="card-value" style="color:#ef4444">${summary.critical}</div>
104
+ <div class="card-label">๐Ÿ”ด Critical</div>
105
+ </div>
106
+ <div class="card">
107
+ <div class="card-value" style="color:#f97316">${summary.high}</div>
108
+ <div class="card-label">๐ŸŸ  High</div>
109
+ </div>
110
+ <div class="card">
111
+ <div class="card-value" style="color:#eab308">${summary.medium}</div>
112
+ <div class="card-label">๐ŸŸก Medium</div>
113
+ </div>
114
+ <div class="card">
115
+ <div class="card-value" style="color:#22c55e">${summary.info}</div>
116
+ <div class="card-label">โœ… OK</div>
117
+ </div>
118
+ <div class="card">
119
+ <div class="card-value" style="color:#38bdf8">${summary.sessionsAnalyzed}</div>
120
+ <div class="card-label">Sessions Analyzed</div>
121
+ </div>
122
+ <div class="card">
123
+ <div class="card-value" style="color:#818cf8">${(summary.totalTokensFound || 0).toLocaleString()}</div>
124
+ <div class="card-label">Total Tokens Found</div>
125
+ </div>
126
+ </div>
127
+
128
+ <div class="section">
129
+ <div class="section-title">Findings</div>
130
+ <div style="color:#0f172a">
131
+ ${findingCards}
132
+ </div>
133
+ </div>
134
+
135
+ ${sessionRows ? `
136
+ <div class="section">
137
+ <div class="section-title">Session Breakdown</div>
138
+ <div style="overflow-x:auto">
139
+ <table>
140
+ <thead><tr><th>Session</th><th>Model</th><th style="text-align:right">Tokens</th><th style="text-align:right">Cost</th></tr></thead>
141
+ <tbody>${sessionRows}</tbody>
142
+ </table>
143
+ </div>
144
+ </div>` : ''}
145
+
146
+ </div>
147
+ <div class="footer">
148
+ Clawculator ยท github.com/echoudhry/clawculator ยท Your friendly penny pincher.
149
+ </div>
150
+ </body>
151
+ </html>`;
152
+
153
+ const outPath = path.join(os.tmpdir(), `clawculator-report-${Date.now()}.html`);
154
+ fs.writeFileSync(outPath, html, 'utf8');
155
+ return outPath;
156
+ }
157
+
158
+ module.exports = { generateHTMLReport };
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ const SEVERITY_ICON = { critical: '๐Ÿ”ด', high: '๐ŸŸ ', medium: '๐ŸŸก', info: 'โœ…' };
4
+ const SOURCE_LABELS = {
5
+ heartbeat: '๐Ÿ’“ Heartbeat', hooks: '๐Ÿช Hooks', whatsapp: '๐Ÿ“ฑ WhatsApp',
6
+ subagents: '๐Ÿค– Subagents', skills: '๐Ÿ”ง Skills', memory: '๐Ÿง  Memory',
7
+ primary_model: 'โš™๏ธ Primary Model', sessions: '๐Ÿ’ฌ Sessions',
8
+ workspace: '๐Ÿ“ Workspace', config: '๐Ÿ“„ Config',
9
+ };
10
+
11
+ function generateMarkdownReport(analysis) {
12
+ const { summary, findings, sessions, primaryModel, scannedAt } = analysis;
13
+ const bleed = summary.estimatedMonthlyBleed;
14
+ const lines = [];
15
+
16
+ lines.push('# Clawculator Report');
17
+ lines.push('> Your friendly penny pincher. ยท 100% offline ยท Zero AI ยท Pure deterministic logic');
18
+ lines.push('');
19
+ lines.push(`**Scanned:** ${new Date(scannedAt).toLocaleString()}`);
20
+ if (primaryModel) lines.push(`**Primary model:** ${primaryModel}`);
21
+ lines.push('');
22
+
23
+ // Cost alert
24
+ if (bleed > 0) {
25
+ lines.push(`## โš ๏ธ Estimated Monthly Cost Exposure: $${bleed.toFixed(2)}/month`);
26
+ lines.push('');
27
+ } else {
28
+ lines.push('## โœ… No significant cost bleed detected');
29
+ lines.push('');
30
+ }
31
+
32
+ // Summary table
33
+ lines.push('## Summary');
34
+ lines.push('');
35
+ lines.push('| Severity | Count |');
36
+ lines.push('|----------|-------|');
37
+ lines.push(`| ๐Ÿ”ด Critical | ${summary.critical} |`);
38
+ lines.push(`| ๐ŸŸ  High | ${summary.high} |`);
39
+ lines.push(`| ๐ŸŸก Medium | ${summary.medium} |`);
40
+ lines.push(`| โœ… OK | ${summary.info} |`);
41
+ lines.push(`| Sessions analyzed | ${summary.sessionsAnalyzed} |`);
42
+ lines.push(`| Total tokens found | ${(summary.totalTokensFound || 0).toLocaleString()} |`);
43
+ lines.push('');
44
+
45
+ // Findings by severity
46
+ lines.push('## Findings');
47
+ lines.push('');
48
+
49
+ for (const severity of ['critical', 'high', 'medium', 'info']) {
50
+ const group = findings.filter(f => f.severity === severity);
51
+ if (!group.length) continue;
52
+
53
+ lines.push(`### ${SEVERITY_ICON[severity]} ${severity.toUpperCase()} (${group.length})`);
54
+ lines.push('');
55
+
56
+ for (const f of group) {
57
+ lines.push(`#### ${SOURCE_LABELS[f.source] || f.source}`);
58
+ lines.push('');
59
+ lines.push(`**${f.message}**`);
60
+ lines.push('');
61
+ if (f.detail) lines.push(`${f.detail}`);
62
+ if (f.monthlyCost > 0) lines.push(`**Monthly cost:** $${f.monthlyCost.toFixed(2)}/month`);
63
+ if (f.fix) {
64
+ lines.push('');
65
+ lines.push(`**Fix:** ${f.fix}`);
66
+ }
67
+ if (f.command) {
68
+ lines.push('');
69
+ lines.push('```bash');
70
+ lines.push(f.command);
71
+ lines.push('```');
72
+ }
73
+ lines.push('');
74
+ }
75
+ }
76
+
77
+ // Session breakdown
78
+ if (sessions?.length > 0) {
79
+ lines.push('## Session Breakdown');
80
+ lines.push('');
81
+ lines.push('| Session | Model | Tokens | Cost |');
82
+ lines.push('|---------|-------|--------|------|');
83
+
84
+ [...sessions]
85
+ .sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens))
86
+ .slice(0, 20)
87
+ .forEach(s => {
88
+ const tok = (s.inputTokens + s.outputTokens).toLocaleString();
89
+ const flag = s.isOrphaned ? ' โš ๏ธ' : '';
90
+ lines.push(`| \`${s.key}${flag}\` | ${s.modelLabel || s.model} | ${tok} | $${s.cost.toFixed(6)} |`);
91
+ });
92
+
93
+ lines.push('');
94
+ }
95
+
96
+ // Quick wins
97
+ const wins = findings.filter(f => f.fix && f.severity !== 'info');
98
+ if (wins.length > 0) {
99
+ lines.push('## Quick Wins');
100
+ lines.push('');
101
+ wins.slice(0, 5).forEach((f, i) => {
102
+ lines.push(`${i + 1}. **${f.fix}**`);
103
+ if (f.command) lines.push(` \`${f.command}\``);
104
+ });
105
+ lines.push('');
106
+ }
107
+
108
+ lines.push('---');
109
+ lines.push('*Generated by [Clawculator](https://github.com/echoudhry/clawculator) ยท Your friendly penny pincher.*');
110
+
111
+ return lines.join('\n');
112
+ }
113
+
114
+ module.exports = { generateMarkdownReport };
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ const SEVERITY_CONFIG = {
4
+ critical: { color: '\x1b[31m', icon: '๐Ÿ”ด', label: 'CRITICAL' },
5
+ high: { color: '\x1b[33m', icon: '๐ŸŸ ', label: 'HIGH' },
6
+ medium: { color: '\x1b[33m', icon: '๐ŸŸก', label: 'MEDIUM' },
7
+ info: { color: '\x1b[32m', icon: 'โœ…', label: 'OK' },
8
+ };
9
+
10
+ const SOURCE_LABELS = {
11
+ heartbeat: '๐Ÿ’“ Heartbeat',
12
+ hooks: '๐Ÿช Hooks',
13
+ whatsapp: '๐Ÿ“ฑ WhatsApp',
14
+ subagents: '๐Ÿค– Subagents',
15
+ skills: '๐Ÿ”ง Skills',
16
+ memory: '๐Ÿง  Memory',
17
+ primary_model: 'โš™๏ธ Primary Model',
18
+ sessions: '๐Ÿ’ฌ Sessions',
19
+ workspace: '๐Ÿ“ Workspace',
20
+ context: '๐Ÿ“ Context Pruning',
21
+ vision: '๐Ÿ–ผ๏ธ Vision Tokens',
22
+ fallbacks: '๐Ÿ”€ Model Fallbacks',
23
+ multi_agent: '๐Ÿ‘ฅ Multi-Agent',
24
+ cron: 'โฐ Cron Jobs',
25
+ telegram: 'โœˆ๏ธ Telegram',
26
+ discord: '๐Ÿ’ฌ Discord',
27
+ signal: '๐Ÿ“ก Signal',
28
+ config: '๐Ÿ“„ Config',
29
+ };
30
+
31
+ function formatCost(cost) {
32
+ if (!cost) return '';
33
+ if (cost === 0) return '\x1b[32m$0.00/mo\x1b[0m';
34
+ if (cost < 1) return `\x1b[33m$${cost.toFixed(4)}/mo\x1b[0m`;
35
+ return `\x1b[31m$${cost.toFixed(2)}/mo\x1b[0m`;
36
+ }
37
+
38
+ function generateTerminalReport(analysis) {
39
+ const { summary, findings, sessions } = analysis;
40
+ const R = '\x1b[0m';
41
+ const B = '\x1b[1m';
42
+ const D = '\x1b[90m';
43
+ const C = '\x1b[36m';
44
+ const RED = '\x1b[31m';
45
+ const GRN = '\x1b[32m';
46
+
47
+ console.log(`${C}โ”โ”โ” Scan Complete โ”โ”โ”${R}`);
48
+ console.log(`${D}${new Date(analysis.scannedAt).toLocaleString()}${R}`);
49
+ if (analysis.primaryModel) console.log(`${D}Primary model: ${analysis.primaryModel}${R}`);
50
+ console.log();
51
+
52
+ const bleed = summary.estimatedMonthlyBleed;
53
+ if (bleed > 0) {
54
+ console.log(`${B}${RED}โš ๏ธ Estimated monthly cost exposure: $${bleed.toFixed(2)}/month${R}\n`);
55
+ }
56
+
57
+ for (const severity of ['critical', 'high', 'medium', 'info']) {
58
+ const group = findings.filter(f => f.severity === severity);
59
+ if (!group.length) continue;
60
+
61
+ const cfg = SEVERITY_CONFIG[severity];
62
+ console.log(`${cfg.color}${B}${cfg.icon} ${cfg.label} (${group.length})${R}`);
63
+ console.log(`${C}${'โ”€'.repeat(60)}${R}`);
64
+
65
+ for (const f of group) {
66
+ console.log(` ${B}${SOURCE_LABELS[f.source] || f.source}${R}`);
67
+ console.log(` ${f.message}`);
68
+ if (f.detail) console.log(` ${D}${f.detail}${R}`);
69
+ if (f.monthlyCost > 0) console.log(` ${D}Cost: ${R}${formatCost(f.monthlyCost)}`);
70
+ if (f.fix) console.log(` ${GRN}โ†’ ${f.fix}${R}`);
71
+ if (f.command) console.log(` ${D} ${f.command}${R}`);
72
+ console.log();
73
+ }
74
+ }
75
+
76
+ // Session breakdown
77
+ if (sessions?.length > 0) {
78
+ console.log(`${C}โ”โ”โ” Top Sessions by Token Usage โ”โ”โ”${R}\n`);
79
+ const sorted = [...sessions].sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens)).slice(0, 8);
80
+ console.log(` ${D}${'Session'.padEnd(42)} ${'Model'.padEnd(22)} ${'Tokens'.padEnd(10)} Cost${R}`);
81
+ console.log(` ${D}${'โ”€'.repeat(85)}${R}`);
82
+ for (const s of sorted) {
83
+ const tok = (s.inputTokens + s.outputTokens).toLocaleString();
84
+ const flag = s.isOrphaned ? ' โš ๏ธ' : '';
85
+ console.log(` ${(s.key + flag).slice(0, 42).padEnd(42)} ${(s.modelLabel || s.model || 'unknown').slice(0, 22).padEnd(22)} ${tok.padEnd(10)} $${s.cost.toFixed(6)}`);
86
+ }
87
+ console.log();
88
+ }
89
+
90
+ // Summary
91
+ console.log(`${C}โ”โ”โ” Summary โ”โ”โ”${R}`);
92
+ console.log(` ๐Ÿ”ด ${RED}${summary.critical}${R} critical ๐ŸŸ  ${summary.high} high ๐ŸŸก ${summary.medium} medium โœ… ${summary.info} ok`);
93
+ console.log(` Sessions analyzed: ${summary.sessionsAnalyzed} ยท Tokens found: ${(summary.totalTokensFound||0).toLocaleString()}`);
94
+ if (bleed > 0) {
95
+ console.log(` ${RED}${B}Monthly bleed: $${bleed.toFixed(2)}/month${R}`);
96
+ } else {
97
+ console.log(` ${GRN}No significant cost bleed detected โœ“${R}`);
98
+ }
99
+ console.log();
100
+
101
+ // Quick wins
102
+ const wins = findings.filter(f => f.fix && f.severity !== 'info');
103
+ if (wins.length > 0) {
104
+ console.log(`${C}โ”โ”โ” Quick Wins โ”โ”โ”${R}`);
105
+ wins.slice(0, 5).forEach((f, i) => {
106
+ console.log(` ${i + 1}. ${GRN}${f.fix}${R}`);
107
+ if (f.command) console.log(` ${D}${f.command}${R}`);
108
+ });
109
+ console.log();
110
+ }
111
+ }
112
+
113
+ module.exports = { generateTerminalReport };
114
+
115
+ // Source label additions for new sources