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 +110 -0
- package/bin/clawculator.js +110 -0
- package/logo.png +0 -0
- package/package.json +34 -0
- package/src/analyzer.js +571 -0
- package/src/htmlReport.js +158 -0
- package/src/mdReport.js +114 -0
- package/src/reporter.js +115 -0
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
|
+
[](https://badge.fury.io/js/clawculator)
|
|
12
|
+
[](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
|
+
}
|
package/src/analyzer.js
ADDED
|
@@ -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 };
|
package/src/mdReport.js
ADDED
|
@@ -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 };
|
package/src/reporter.js
ADDED
|
@@ -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
|