clawculator 2.0.1 → 2.1.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 +38 -0
- package/package.json +5 -12
- package/skills/clawculator/README.md +153 -0
- package/skills/clawculator/SKILL.md +57 -0
- package/skills/clawculator/analyzer.js +598 -0
- package/skills/clawculator/htmlReport.js +186 -0
- package/skills/clawculator/mdReport.js +147 -0
- package/skills/clawculator/reporter.js +139 -0
- package/skills/clawculator/run.js +102 -0
- package/logo.png +0 -0
|
@@ -0,0 +1,598 @@
|
|
|
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 isHaikuTier(modelStr) {
|
|
145
|
+
if (!modelStr) return false;
|
|
146
|
+
const lower = modelStr.toLowerCase();
|
|
147
|
+
return lower.includes('haiku');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isAcceptableForHooks(modelStr) {
|
|
151
|
+
return isLocalModel(modelStr) || isHaikuTier(modelStr);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isOpenRouter(modelStr) {
|
|
155
|
+
return modelStr?.toLowerCase().startsWith('openrouter/');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function costPerCall(modelKey, inputTok = 1000, outputTok = 500) {
|
|
159
|
+
if (!modelKey || modelKey === 'ollama') return 0;
|
|
160
|
+
const p = MODEL_PRICING[modelKey];
|
|
161
|
+
if (!p || p.subscription) return 0;
|
|
162
|
+
return (inputTok / 1e6) * p.input + (outputTok / 1e6) * p.output;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function modelTier(modelKey) {
|
|
166
|
+
if (!modelKey || modelKey === 'ollama') return 'free';
|
|
167
|
+
const p = MODEL_PRICING[modelKey];
|
|
168
|
+
if (!p) return 'unknown';
|
|
169
|
+
if (p.input >= 5) return 'expensive';
|
|
170
|
+
if (p.input >= 1) return 'medium';
|
|
171
|
+
if (p.input >= 0.5) return 'cheap';
|
|
172
|
+
return 'very-cheap';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Rule: Analyze a single agent config block ─────────────────────
|
|
176
|
+
function analyzeAgentBlock(agentCfg, primaryModel, agentId = 'main') {
|
|
177
|
+
const findings = [];
|
|
178
|
+
const prefix = agentId === 'main' ? '' : `[Agent: ${agentId}] `;
|
|
179
|
+
|
|
180
|
+
// ── Heartbeat ──────────────────────────────────────────────────
|
|
181
|
+
const hb = agentCfg.heartbeat;
|
|
182
|
+
if (hb) {
|
|
183
|
+
const hbModel = hb.model || primaryModel;
|
|
184
|
+
const hbKey = resolveModel(hbModel);
|
|
185
|
+
const hbInterval = typeof hb.interval === 'number' ? hb.interval :
|
|
186
|
+
typeof hb.every === 'string' ? parseInterval(hb.every) : 60;
|
|
187
|
+
const hbPerDay = Math.floor(86400 / Math.max(hbInterval, 1));
|
|
188
|
+
|
|
189
|
+
if (!isLocalModel(hbModel)) {
|
|
190
|
+
const daily = costPerCall(hbKey, 500, 100) * hbPerDay;
|
|
191
|
+
const monthly = daily * 30;
|
|
192
|
+
findings.push({
|
|
193
|
+
severity: 'critical', source: 'heartbeat',
|
|
194
|
+
message: `${prefix}Heartbeat running on paid model: ${hbModel || 'primary'}`,
|
|
195
|
+
detail: `${hbPerDay} pings/day · $${daily.toFixed(4)}/day`,
|
|
196
|
+
monthlyCost: monthly,
|
|
197
|
+
...FIXES.HEARTBEAT_PAID_MODEL,
|
|
198
|
+
});
|
|
199
|
+
} else {
|
|
200
|
+
findings.push({
|
|
201
|
+
severity: 'info', source: 'heartbeat',
|
|
202
|
+
message: `${prefix}Heartbeat using local model (${hbModel}) ✓`,
|
|
203
|
+
detail: `${hbPerDay} pings/day · $0.00 cost`, monthlyCost: 0,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (hb.target && hb.target !== 'none') {
|
|
208
|
+
findings.push({
|
|
209
|
+
severity: 'high', source: 'heartbeat',
|
|
210
|
+
message: `${prefix}Heartbeat target is "${hb.target}" — must be "none" (v2026.2.24+)`,
|
|
211
|
+
detail: 'Non-"none" targets silently trigger paid model sessions',
|
|
212
|
+
...FIXES.HEARTBEAT_TARGET,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Fallback chain ─────────────────────────────────────────────
|
|
218
|
+
const fallbacks = agentCfg.model?.fallbacks || agentCfg.models?.fallbacks || [];
|
|
219
|
+
if (fallbacks.length > 0) {
|
|
220
|
+
fallbacks.forEach((fb, i) => {
|
|
221
|
+
const fbKey = resolveModel(fb);
|
|
222
|
+
const tier = modelTier(fbKey);
|
|
223
|
+
const isOR = isOpenRouter(fb);
|
|
224
|
+
if (tier === 'expensive' || tier === 'medium') {
|
|
225
|
+
const monthly = costPerCall(fbKey, 2000, 500) * 50 * 30;
|
|
226
|
+
findings.push({
|
|
227
|
+
severity: tier === 'expensive' ? 'high' : 'medium',
|
|
228
|
+
source: 'fallbacks',
|
|
229
|
+
message: `${prefix}Fallback #${i + 1} is expensive (${fb}) — silent cost spike on rate limits`,
|
|
230
|
+
detail: `If primary hits rate limit, falls back to ${fb} at ~$${monthly.toFixed(2)}/month equivalent`,
|
|
231
|
+
monthlyCost: 0, // only triggered on rate limit, not always-on
|
|
232
|
+
...FIXES.FALLBACK_EXPENSIVE(fb, i + 1),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (isOR) {
|
|
236
|
+
findings.push({
|
|
237
|
+
severity: 'medium', source: 'fallbacks',
|
|
238
|
+
message: `${prefix}Fallback uses OpenRouter (${fb}) — adds ~10% markup over direct pricing`,
|
|
239
|
+
detail: 'OpenRouter routes through their infrastructure and charges a markup',
|
|
240
|
+
...FIXES.OPENROUTER_MARKUP,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── contextPruning ─────────────────────────────────────────────
|
|
247
|
+
const pruning = agentCfg.session?.contextPruning || agentCfg.contextPruning;
|
|
248
|
+
if (!pruning || pruning.mode === 'none') {
|
|
249
|
+
findings.push({
|
|
250
|
+
severity: 'medium', source: 'context',
|
|
251
|
+
message: `${prefix}contextPruning is not set — sessions grow unbounded`,
|
|
252
|
+
detail: 'Every message re-sends full conversation history as input tokens. A 30-message chat can cost 5-10x a fresh session.',
|
|
253
|
+
...FIXES.CONTEXT_PRUNING_MISSING,
|
|
254
|
+
});
|
|
255
|
+
} else {
|
|
256
|
+
findings.push({
|
|
257
|
+
severity: 'info', source: 'context',
|
|
258
|
+
message: `${prefix}contextPruning mode: "${pruning.mode}" ✓`, monthlyCost: 0,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Image dimension ────────────────────────────────────────────
|
|
263
|
+
const imgDim = agentCfg.imageMaxDimensionPx ?? agentCfg.media?.imageMaxDimensionPx;
|
|
264
|
+
if (imgDim === undefined || imgDim === null || imgDim > 800) {
|
|
265
|
+
const dimVal = imgDim ?? 1200;
|
|
266
|
+
findings.push({
|
|
267
|
+
severity: 'medium', source: 'vision',
|
|
268
|
+
message: `${prefix}imageMaxDimensionPx is ${dimVal} — high-res vision tokens are expensive`,
|
|
269
|
+
detail: 'Each screenshot at 1200px can cost 1000-3000 extra tokens. Reduce to 800px or lower.',
|
|
270
|
+
...FIXES.IMAGE_DIMENSION,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return findings;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Parse human interval strings like "30m", "2h", "1d" ──────────
|
|
278
|
+
function parseInterval(str) {
|
|
279
|
+
if (!str) return 60;
|
|
280
|
+
const match = str.match(/^(\d+)(s|m|h|d)$/);
|
|
281
|
+
if (!match) return 60;
|
|
282
|
+
const val = parseInt(match[1]);
|
|
283
|
+
return { s: val, m: val * 60, h: val * 3600, d: val * 86400 }[match[2]] || 60;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── Config analysis ───────────────────────────────────────────────
|
|
287
|
+
function analyzeConfig(configPath) {
|
|
288
|
+
const findings = [];
|
|
289
|
+
const config = readJSON(configPath);
|
|
290
|
+
|
|
291
|
+
if (!config) {
|
|
292
|
+
return {
|
|
293
|
+
exists: false,
|
|
294
|
+
findings: [{ severity: 'info', source: 'config', message: `openclaw.json not found at ${configPath}`, detail: 'Skipping config analysis — run from a machine with OpenClaw installed' }],
|
|
295
|
+
config: null, primaryModel: null,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const agentDefaults = config.agents?.defaults || config.agent || {};
|
|
300
|
+
const primaryModel = agentDefaults.model?.primary || agentDefaults.model || config.model || null;
|
|
301
|
+
const primaryKey = resolveModel(primaryModel);
|
|
302
|
+
|
|
303
|
+
// ── Primary model OpenRouter check ────────────────────────────
|
|
304
|
+
if (primaryModel && isOpenRouter(primaryModel)) {
|
|
305
|
+
findings.push({
|
|
306
|
+
severity: 'medium', source: 'primary_model',
|
|
307
|
+
message: `Primary model is routed via OpenRouter (${primaryModel})`,
|
|
308
|
+
detail: 'OpenRouter adds ~10% markup. Direct provider access is cheaper.',
|
|
309
|
+
...FIXES.OPENROUTER_MARKUP,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Analyze main agent block ───────────────────────────────────
|
|
314
|
+
findings.push(...analyzeAgentBlock(agentDefaults, primaryModel, 'main'));
|
|
315
|
+
|
|
316
|
+
// ── Multi-agent scanning ───────────────────────────────────────
|
|
317
|
+
const agentList = config.agents?.list || [];
|
|
318
|
+
for (const agent of agentList) {
|
|
319
|
+
if (!agent.id || agent.id === 'main') continue;
|
|
320
|
+
const agentModel = agent.model?.primary || agent.model || primaryModel;
|
|
321
|
+
const agentKey = resolveModel(agentModel);
|
|
322
|
+
|
|
323
|
+
// Flag expensive per-agent model overrides
|
|
324
|
+
if (agentModel && !isLocalModel(agentModel) && agentKey) {
|
|
325
|
+
const tier = modelTier(agentKey);
|
|
326
|
+
if (tier === 'expensive' || tier === 'medium') {
|
|
327
|
+
const monthly = costPerCall(agentKey, 2000, 500) * 30 * 30;
|
|
328
|
+
findings.push({
|
|
329
|
+
severity: tier === 'expensive' ? 'high' : 'medium',
|
|
330
|
+
source: 'multi_agent',
|
|
331
|
+
message: `Agent "${agent.id}" using expensive model: ${agentModel}`,
|
|
332
|
+
detail: `Each agent has its own sessions, heartbeat, and hooks — all bill independently`,
|
|
333
|
+
monthlyCost: monthly,
|
|
334
|
+
...FIXES.MULTI_AGENT_PAID(agent.id),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Run full rule engine on each agent's own config
|
|
340
|
+
const agentBlock = { ...agentDefaults, ...agent };
|
|
341
|
+
const agentFindings = analyzeAgentBlock(agentBlock, agentModel, agent.id)
|
|
342
|
+
.filter(f => f.severity !== 'info'); // only surface issues for secondary agents
|
|
343
|
+
findings.push(...agentFindings);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Hooks ──────────────────────────────────────────────────────
|
|
347
|
+
const hooks = config.hooks?.internal?.entries || config.hooks || {};
|
|
348
|
+
const hookNames = Object.keys(hooks).filter(k => k !== 'enabled' && k !== 'token' && k !== 'path');
|
|
349
|
+
let hookIssues = 0;
|
|
350
|
+
|
|
351
|
+
let haikuHooks = 0;
|
|
352
|
+
for (const name of hookNames) {
|
|
353
|
+
const hook = typeof hooks[name] === 'object' ? hooks[name] : {};
|
|
354
|
+
if (hook.enabled === false) continue;
|
|
355
|
+
const hookModel = hook.model || primaryModel;
|
|
356
|
+
if (isLocalModel(hookModel)) {
|
|
357
|
+
// local = free, no finding needed
|
|
358
|
+
} else if (isHaikuTier(hookModel) && resolveModel(hookModel)) {
|
|
359
|
+
const monthly = costPerCall(resolveModel(hookModel), 1000, 200) * 50 * 30;
|
|
360
|
+
haikuHooks++;
|
|
361
|
+
findings.push({
|
|
362
|
+
severity: 'low', source: 'hooks',
|
|
363
|
+
message: `Hook "${name}" on Haiku — minimal cost, good choice`,
|
|
364
|
+
detail: `~50 fires/day estimated · $${monthly.toFixed(2)}/month`,
|
|
365
|
+
monthlyCost: monthly,
|
|
366
|
+
});
|
|
367
|
+
} else if (resolveModel(hookModel)) {
|
|
368
|
+
const monthly = costPerCall(resolveModel(hookModel), 1000, 200) * 50 * 30;
|
|
369
|
+
hookIssues++;
|
|
370
|
+
findings.push({
|
|
371
|
+
severity: 'high', source: 'hooks',
|
|
372
|
+
message: `Hook "${name}" running on ${hookModel} — switch to Haiku or local`,
|
|
373
|
+
detail: `~50 fires/day estimated · $${monthly.toFixed(2)}/month`,
|
|
374
|
+
monthlyCost: monthly,
|
|
375
|
+
...FIXES.HOOK_PAID_MODEL(name),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (hookNames.length > 0 && hookIssues === 0 && haikuHooks === 0) {
|
|
380
|
+
findings.push({ severity: 'info', source: 'hooks', message: `All ${hookNames.length} hooks on local models ✓`, monthlyCost: 0 });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── WhatsApp ───────────────────────────────────────────────────
|
|
384
|
+
const wa = config.channels?.whatsapp || {};
|
|
385
|
+
const groups = wa.groups;
|
|
386
|
+
if (wa.enabled !== false) {
|
|
387
|
+
if (groups === undefined || groups === null) {
|
|
388
|
+
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 });
|
|
389
|
+
} else if (typeof groups === 'object' && Object.keys(groups).length > 0) {
|
|
390
|
+
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 });
|
|
391
|
+
} else {
|
|
392
|
+
findings.push({ severity: 'info', source: 'whatsapp', message: 'WhatsApp groups blocked ✓', monthlyCost: 0 });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── Telegram ───────────────────────────────────────────────────
|
|
397
|
+
const tg = config.channels?.telegram || {};
|
|
398
|
+
if (tg.enabled !== false && tg.botToken) {
|
|
399
|
+
const policy = tg.dmPolicy || tg.dm?.policy || 'pairing';
|
|
400
|
+
if (policy === 'open') {
|
|
401
|
+
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 });
|
|
402
|
+
} else {
|
|
403
|
+
findings.push({ severity: 'info', source: 'telegram', message: `Telegram dmPolicy: "${policy}" ✓`, monthlyCost: 0 });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── Discord ────────────────────────────────────────────────────
|
|
408
|
+
const dc = config.channels?.discord || {};
|
|
409
|
+
if (dc.enabled !== false && dc.token) {
|
|
410
|
+
const dcPolicy = dc.dm?.policy || dc.dmPolicy || 'pairing';
|
|
411
|
+
if (dcPolicy === 'open') {
|
|
412
|
+
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 });
|
|
413
|
+
} else {
|
|
414
|
+
findings.push({ severity: 'info', source: 'discord', message: `Discord DM policy: "${dcPolicy}" ✓`, monthlyCost: 0 });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── Signal ─────────────────────────────────────────────────────
|
|
419
|
+
const sig = config.channels?.signal || {};
|
|
420
|
+
if (sig.enabled !== false && sig.phoneNumber) {
|
|
421
|
+
if (!sig.allowFrom || sig.allowFrom.length === 0) {
|
|
422
|
+
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 });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ── Cron jobs ──────────────────────────────────────────────────
|
|
427
|
+
const cronJobs = config.cron?.jobs || config.cron || {};
|
|
428
|
+
if (typeof cronJobs === 'object' && !Array.isArray(cronJobs)) {
|
|
429
|
+
for (const [name, job] of Object.entries(cronJobs)) {
|
|
430
|
+
if (typeof job !== 'object' || job.enabled === false) continue;
|
|
431
|
+
const jobModel = job.model || primaryModel;
|
|
432
|
+
const jobKey = resolveModel(jobModel);
|
|
433
|
+
const interval = job.every ? parseInterval(job.every) : (job.interval || 3600);
|
|
434
|
+
const perDay = Math.floor(86400 / Math.max(interval, 1));
|
|
435
|
+
|
|
436
|
+
if (!isLocalModel(jobModel) && jobKey) {
|
|
437
|
+
const monthly = costPerCall(jobKey, 2000, 500) * perDay * 30;
|
|
438
|
+
findings.push({
|
|
439
|
+
severity: perDay > 24 ? 'critical' : 'high',
|
|
440
|
+
source: 'cron',
|
|
441
|
+
message: `Cron job "${name}" runs ${perDay}x/day on paid model: ${jobModel || 'primary'}`,
|
|
442
|
+
detail: `Interval: ${job.every || interval + 's'} · $${monthly.toFixed(2)}/month estimated`,
|
|
443
|
+
monthlyCost: monthly,
|
|
444
|
+
...FIXES.CRON_PAID_MODEL(name, job.every || `${interval}s`),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── Skills polling ─────────────────────────────────────────────
|
|
451
|
+
const skills = config.skills?.entries || config.skills || {};
|
|
452
|
+
for (const [name, skill] of Object.entries(skills)) {
|
|
453
|
+
if (typeof skill !== 'object' || skill.enabled === false) continue;
|
|
454
|
+
if (skill.interval || skill.cron || skill.poll) {
|
|
455
|
+
const interval = typeof skill.interval === 'number' ? skill.interval : 60;
|
|
456
|
+
const perDay = Math.floor(86400 / interval);
|
|
457
|
+
const skillModel = skill.model || primaryModel;
|
|
458
|
+
const skillKey = resolveModel(skillModel);
|
|
459
|
+
if (!isLocalModel(skillModel) && skillKey) {
|
|
460
|
+
const monthly = costPerCall(skillKey, 2000, 500) * perDay * 30;
|
|
461
|
+
findings.push({
|
|
462
|
+
severity: 'critical', source: 'skills',
|
|
463
|
+
message: `Skill "${name}" has polling loop on paid model: ${skillModel || 'primary'}`,
|
|
464
|
+
detail: `~${perDay} calls/day · $${monthly.toFixed(2)}/month estimated`,
|
|
465
|
+
monthlyCost: monthly,
|
|
466
|
+
...FIXES.SKILL_POLLING(name),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── Subagents concurrency ──────────────────────────────────────
|
|
473
|
+
const maxC = agentDefaults.subagents?.maxConcurrent ?? config.subagents?.maxConcurrent ?? null;
|
|
474
|
+
if (maxC !== null && maxC > 2) {
|
|
475
|
+
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 });
|
|
476
|
+
} else if (maxC !== null) {
|
|
477
|
+
findings.push({ severity: 'info', source: 'subagents', message: `maxConcurrent = ${maxC} ✓`, monthlyCost: 0 });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ── memoryFlush ────────────────────────────────────────────────
|
|
481
|
+
const mfModel = config.memory?.flushModel || config.memoryFlush?.model || primaryModel;
|
|
482
|
+
if (mfModel && !isLocalModel(mfModel)) {
|
|
483
|
+
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 });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── Primary model awareness ────────────────────────────────────
|
|
487
|
+
if (primaryKey && MODEL_PRICING[primaryKey] && !MODEL_PRICING[primaryKey].subscription) {
|
|
488
|
+
const p = MODEL_PRICING[primaryKey];
|
|
489
|
+
const monthly = costPerCall(primaryKey, 2000, 500) * 50 * 30;
|
|
490
|
+
findings.push({
|
|
491
|
+
severity: p.input >= 5 ? 'high' : p.input >= 1 ? 'medium' : 'info',
|
|
492
|
+
source: 'primary_model',
|
|
493
|
+
message: `Primary model: ${p.label} · $${p.input}/$${p.output} per MTok`,
|
|
494
|
+
detail: `Baseline at 50 queries/day: ~$${monthly.toFixed(2)}/month`,
|
|
495
|
+
monthlyCost: monthly,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return { exists: true, findings, config, primaryModel };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── Session analysis ──────────────────────────────────────────────
|
|
503
|
+
function analyzeSessions(sessionsPath) {
|
|
504
|
+
const findings = [];
|
|
505
|
+
const sessions = readJSON(sessionsPath);
|
|
506
|
+
|
|
507
|
+
if (!sessions) return { exists: false, findings: [], sessions: [], totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0, sessionCount: 0 };
|
|
508
|
+
|
|
509
|
+
let totalIn = 0, totalOut = 0, totalCost = 0;
|
|
510
|
+
const breakdown = [], orphaned = [], large = [];
|
|
511
|
+
|
|
512
|
+
for (const key of Object.keys(sessions)) {
|
|
513
|
+
const s = sessions[key];
|
|
514
|
+
const model = s.model || s.primaryModel || null;
|
|
515
|
+
const modelKey = resolveModel(model);
|
|
516
|
+
const inTok = s.inputTokens || s.tokensIn || s.tokens?.input || 0;
|
|
517
|
+
const outTok = s.outputTokens || s.tokensOut || s.tokens?.output || 0;
|
|
518
|
+
const cost = costPerCall(modelKey, inTok, outTok);
|
|
519
|
+
const updatedAt = s.updatedAt || s.lastActive || null;
|
|
520
|
+
|
|
521
|
+
totalIn += inTok;
|
|
522
|
+
totalOut += outTok;
|
|
523
|
+
totalCost += cost;
|
|
524
|
+
|
|
525
|
+
const isOrphaned = key.includes('cron') || key.includes('deleted') ||
|
|
526
|
+
(updatedAt && Date.now() - new Date(updatedAt).getTime() > 48 * 3600 * 1000 && !key.includes('main'));
|
|
527
|
+
|
|
528
|
+
if (isOrphaned) orphaned.push({ key, model, tokens: inTok + outTok, cost });
|
|
529
|
+
if (inTok + outTok > 50000) large.push({ key, model, tokens: inTok + outTok });
|
|
530
|
+
|
|
531
|
+
const ageMs = updatedAt ? Date.now() - new Date(updatedAt).getTime() : null;
|
|
532
|
+
const ageDays = ageMs ? ageMs / (1000 * 3600 * 24) : null;
|
|
533
|
+
const dailyCost = (ageDays && ageDays > 0.01 && cost > 0) ? cost / ageDays : null;
|
|
534
|
+
|
|
535
|
+
breakdown.push({ key, model, modelLabel: modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown', inputTokens: inTok, outputTokens: outTok, cost, updatedAt, ageMs, dailyCost, isOrphaned });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (orphaned.length > 0) findings.push({ severity: 'high', source: 'sessions', message: `${orphaned.length} orphaned session(s) — still holding tokens on paid models`, detail: orphaned.map(s => `${s.key}: ${s.tokens.toLocaleString()} tokens ($${s.cost.toFixed(4)})`).join('\n '), ...FIXES.ORPHANED_SESSIONS });
|
|
539
|
+
if (large.length > 0) findings.push({ severity: 'medium', source: 'sessions', message: `${large.length} session(s) with >50k tokens per conversation`, detail: large.map(s => `${s.key}: ${s.tokens.toLocaleString()} tokens`).join('\n '), ...FIXES.LARGE_SESSIONS });
|
|
540
|
+
if (Object.keys(sessions).length > 0 && !orphaned.length && !large.length) findings.push({ severity: 'info', source: 'sessions', message: `${Object.keys(sessions).length} session(s) healthy ✓`, detail: `Total tokens: ${(totalIn + totalOut).toLocaleString()}` });
|
|
541
|
+
|
|
542
|
+
return { exists: true, findings, sessions: breakdown, totalInputTokens: totalIn, totalOutputTokens: totalOut, totalCost, sessionCount: Object.keys(sessions).length };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── Workspace analysis ────────────────────────────────────────────
|
|
546
|
+
function analyzeWorkspace() {
|
|
547
|
+
const findings = [];
|
|
548
|
+
const workspaceDir = path.join(os.homedir(), 'clawd');
|
|
549
|
+
if (!fs.existsSync(workspaceDir)) return { exists: false, findings: [] };
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
const rootFiles = fs.readdirSync(workspaceDir).filter(f => f.endsWith('.md') || f.endsWith('.txt'));
|
|
553
|
+
const count = rootFiles.length;
|
|
554
|
+
const estimatedTokens = count * 500;
|
|
555
|
+
if (count > 20) {
|
|
556
|
+
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 });
|
|
557
|
+
} else {
|
|
558
|
+
findings.push({ severity: 'info', source: 'workspace', message: `${count} files at workspace root — lean ✓`, detail: `~${estimatedTokens.toLocaleString()} tokens estimated` });
|
|
559
|
+
}
|
|
560
|
+
} catch { /* not readable */ }
|
|
561
|
+
|
|
562
|
+
return { exists: true, findings };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ── Main ──────────────────────────────────────────────────────────
|
|
566
|
+
async function runAnalysis({ configPath, sessionsPath, logsDir }) {
|
|
567
|
+
const configResult = analyzeConfig(configPath);
|
|
568
|
+
const sessionResult = analyzeSessions(sessionsPath);
|
|
569
|
+
const workspaceResult = analyzeWorkspace();
|
|
570
|
+
|
|
571
|
+
const allFindings = [...configResult.findings, ...sessionResult.findings, ...workspaceResult.findings];
|
|
572
|
+
|
|
573
|
+
const estimatedMonthlyBleed = allFindings
|
|
574
|
+
.filter(f => f.monthlyCost && f.severity !== 'info')
|
|
575
|
+
.reduce((sum, f) => sum + f.monthlyCost, 0);
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
scannedAt: new Date().toISOString(),
|
|
579
|
+
configPath,
|
|
580
|
+
sessionsPath,
|
|
581
|
+
primaryModel: configResult.primaryModel,
|
|
582
|
+
findings: allFindings,
|
|
583
|
+
summary: {
|
|
584
|
+
critical: allFindings.filter(f => f.severity === 'critical').length,
|
|
585
|
+
high: allFindings.filter(f => f.severity === 'high').length,
|
|
586
|
+
medium: allFindings.filter(f => f.severity === 'medium').length,
|
|
587
|
+
low: allFindings.filter(f => f.severity === 'low').length,
|
|
588
|
+
info: allFindings.filter(f => f.severity === 'info').length,
|
|
589
|
+
estimatedMonthlyBleed,
|
|
590
|
+
sessionsAnalyzed: sessionResult.sessionCount,
|
|
591
|
+
totalTokensFound: (sessionResult.totalInputTokens || 0) + (sessionResult.totalOutputTokens || 0),
|
|
592
|
+
},
|
|
593
|
+
sessions: sessionResult.sessions || [],
|
|
594
|
+
config: configResult.config,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
module.exports = { runAnalysis, MODEL_PRICING, resolveModel, costPerCall };
|