dual-brain 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/CLAUDE.md +40 -0
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/hookify.orchestrator-cost.local.md +16 -0
- package/hookify.orchestrator-gate.local.md +19 -0
- package/hookify.orchestrator-route.local.md +23 -0
- package/hooks/budget-balancer.mjs +463 -0
- package/hooks/cost-logger.mjs +250 -0
- package/hooks/cost-report.mjs +344 -0
- package/hooks/dual-brain-review.mjs +302 -0
- package/hooks/dual-brain-think.mjs +321 -0
- package/hooks/enforce-tier.mjs +282 -0
- package/hooks/gpt-work-dispatcher.mjs +254 -0
- package/hooks/health-check.mjs +390 -0
- package/hooks/install-git-hooks.mjs +106 -0
- package/hooks/quality-gate.mjs +283 -0
- package/hooks/session-report.mjs +514 -0
- package/hooks/setup-wizard.mjs +130 -0
- package/hooks/test-orchestrator.mjs +316 -0
- package/install.mjs +153 -0
- package/orchestrator.json +215 -0
- package/package.json +38 -0
- package/review-rules.md +17 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cost-logger.mjs — PostToolUse hook for the Dual-Brain orchestrator.
|
|
4
|
+
*
|
|
5
|
+
* Reads a Claude Code PostToolUse JSON payload from stdin, classifies the
|
|
6
|
+
* call by tier, then appends one line to usage.jsonl.
|
|
7
|
+
*
|
|
8
|
+
* Output contract: must print "{}" to stdout and exit 0 within ~100 ms.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
12
|
+
import { dirname, join } from "path";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Paths
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
function usageFile(date) {
|
|
21
|
+
const d = date || new Date().toISOString().slice(0, 10);
|
|
22
|
+
return join(__dirname, `usage-${d}.jsonl`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Ensure the hooks dir exists (idempotent, defensive)
|
|
26
|
+
mkdirSync(__dirname, { recursive: true });
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Tier classification
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Tools that are pure read-only lookups → "search" tier.
|
|
34
|
+
* Everything else defaults to "execute"; "think" is only detected when an
|
|
35
|
+
* Agent sub-agent call carries a model hint in its parameters.
|
|
36
|
+
*/
|
|
37
|
+
const SEARCH_TOOLS = new Set([
|
|
38
|
+
"Read",
|
|
39
|
+
"Glob",
|
|
40
|
+
"Grep",
|
|
41
|
+
"LS",
|
|
42
|
+
"WebSearch",
|
|
43
|
+
"WebFetch",
|
|
44
|
+
"mcp__github__search_repositories",
|
|
45
|
+
"mcp__github__get_file_contents",
|
|
46
|
+
"mcp__github__list_commits",
|
|
47
|
+
"mcp__github__list_issues",
|
|
48
|
+
"mcp__github__list_pull_requests",
|
|
49
|
+
"mcp__github__search_code",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const THINK_TOOLS = new Set([
|
|
53
|
+
"TodoWrite", // planning artefact
|
|
54
|
+
"WebFetch", // sometimes used for deep research; included in both sets so
|
|
55
|
+
// the model param check below can upgrade it
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/** Map a Claude model string → canonical tier name */
|
|
59
|
+
function modelToTier(model) {
|
|
60
|
+
if (!model) return null;
|
|
61
|
+
const m = String(model).toLowerCase();
|
|
62
|
+
if (m.includes("opus")) return "think";
|
|
63
|
+
if (m.includes("sonnet")) return "execute";
|
|
64
|
+
if (m.includes("haiku")) return "search";
|
|
65
|
+
if (m.includes("gpt-5.5") || m.includes("gpt4.5")) return "think";
|
|
66
|
+
if (m.includes("mini")) return "search";
|
|
67
|
+
if (m.includes("gpt-5.4") || m.includes("gpt-4.1")) return "execute";
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Detect the provider from a model name */
|
|
72
|
+
function detectProvider(model) {
|
|
73
|
+
if (!model || model === 'main-session') return 'claude';
|
|
74
|
+
const m = String(model).toLowerCase();
|
|
75
|
+
if (m.includes('gpt') || m.includes('o1') || m.includes('o3') || m.includes('o4')) return 'openai';
|
|
76
|
+
if (m.includes('opus') || m.includes('sonnet') || m.includes('haiku') || m.includes('claude')) return 'claude';
|
|
77
|
+
return 'claude'; // default to claude since we're in Claude Code
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Extract canonical model name from an arbitrary model string */
|
|
81
|
+
function canonicalModel(model) {
|
|
82
|
+
if (!model) return "main-session";
|
|
83
|
+
const m = String(model).toLowerCase();
|
|
84
|
+
if (m.includes("opus")) return "opus";
|
|
85
|
+
if (m.includes("sonnet")) return "sonnet";
|
|
86
|
+
if (m.includes("haiku")) return "haiku";
|
|
87
|
+
if (m.includes("gpt-5.5")) return "gpt-5.5";
|
|
88
|
+
if (m.includes("gpt-5.4")) return "gpt-5.4";
|
|
89
|
+
if (m.includes("gpt-4.1-mini") || m.includes("mini")) return "gpt-4.1-mini";
|
|
90
|
+
return model;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Classify a tool call into { tier, model }.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} toolName
|
|
97
|
+
* @param {object} toolInput — raw input parameters from the hook payload
|
|
98
|
+
* @param {string|null} agentModel — model hint from the outer agent context
|
|
99
|
+
*/
|
|
100
|
+
function classify(toolName, toolInput = {}, agentModel = null) {
|
|
101
|
+
// 1. If there's an explicit model hint in the input params (sub-agent call),
|
|
102
|
+
// let it drive the tier.
|
|
103
|
+
const inputModel =
|
|
104
|
+
toolInput?.model ||
|
|
105
|
+
toolInput?.Model ||
|
|
106
|
+
toolInput?.modelId ||
|
|
107
|
+
null;
|
|
108
|
+
|
|
109
|
+
const effectiveModel = inputModel || agentModel;
|
|
110
|
+
const tierFromModel = modelToTier(effectiveModel);
|
|
111
|
+
|
|
112
|
+
if (toolName === "Agent" || toolName === "Task") {
|
|
113
|
+
return {
|
|
114
|
+
tier: tierFromModel || "think", // sub-agents default to "think"
|
|
115
|
+
model: canonicalModel(effectiveModel),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (THINK_TOOLS.has(toolName) && tierFromModel) {
|
|
120
|
+
return { tier: tierFromModel, model: canonicalModel(effectiveModel) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (SEARCH_TOOLS.has(toolName)) {
|
|
124
|
+
return { tier: "search", model: canonicalModel(effectiveModel) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Everything else: edit / bash / write / test → execute
|
|
128
|
+
return {
|
|
129
|
+
tier: tierFromModel || "execute",
|
|
130
|
+
model: canonicalModel(effectiveModel),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Budget alerts
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function checkBudget() {
|
|
139
|
+
let config;
|
|
140
|
+
try {
|
|
141
|
+
config = JSON.parse(readFileSync(join(__dirname, '..', 'orchestrator.json'), 'utf8'));
|
|
142
|
+
} catch { return null; }
|
|
143
|
+
|
|
144
|
+
const budgets = config.budgets;
|
|
145
|
+
if (!budgets) return null;
|
|
146
|
+
|
|
147
|
+
// Rate limit alerts
|
|
148
|
+
const cooldownFile = join(__dirname, '.budget-alerted');
|
|
149
|
+
const cooldownMin = budgets.alert_cooldown_minutes || 15;
|
|
150
|
+
try {
|
|
151
|
+
const lastAlert = readFileSync(cooldownFile, 'utf8').trim();
|
|
152
|
+
if (Date.now() - Date.parse(lastAlert) < cooldownMin * 60 * 1000) return null;
|
|
153
|
+
} catch {}
|
|
154
|
+
|
|
155
|
+
// Calculate today's estimated cost
|
|
156
|
+
const todayFile = usageFile();
|
|
157
|
+
let records = [];
|
|
158
|
+
try {
|
|
159
|
+
records = readFileSync(todayFile, 'utf8').split('\n').filter(Boolean).map(l => {
|
|
160
|
+
try { return JSON.parse(l); } catch { return null; }
|
|
161
|
+
}).filter(Boolean);
|
|
162
|
+
} catch { return null; }
|
|
163
|
+
|
|
164
|
+
// Simple cost estimate using tier heuristics
|
|
165
|
+
const RATES = { search: 0.003, execute: 0.012, think: 0.055 };
|
|
166
|
+
const totalCost = records.reduce((sum, r) => sum + (RATES[r.tier] || RATES.execute), 0);
|
|
167
|
+
|
|
168
|
+
let msg = null;
|
|
169
|
+
if (budgets.daily_limit_usd && totalCost >= budgets.daily_limit_usd) {
|
|
170
|
+
msg = `**[Budget Alert]** Daily cost estimate (~$${totalCost.toFixed(2)}) has reached the $${budgets.daily_limit_usd} limit. Consider pausing non-essential work.`;
|
|
171
|
+
} else if (budgets.daily_warn_usd && totalCost >= budgets.daily_warn_usd) {
|
|
172
|
+
msg = `**[Budget Alert]** Daily cost estimate (~$${totalCost.toFixed(2)}) has passed the $${budgets.daily_warn_usd} warning threshold.`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (msg) {
|
|
176
|
+
try { writeFileSync(cooldownFile, new Date().toISOString()); } catch {}
|
|
177
|
+
return msg;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Main — read stdin, classify, append, respond
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
async function main() {
|
|
187
|
+
// Read all stdin (non-blocking-safe with a short timeout)
|
|
188
|
+
let raw = "";
|
|
189
|
+
try {
|
|
190
|
+
for await (const chunk of process.stdin) {
|
|
191
|
+
raw += chunk;
|
|
192
|
+
if (raw.length > 64 * 1024) break; // safety cap
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// stdin closed or empty — not fatal
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let payload = {};
|
|
199
|
+
try {
|
|
200
|
+
payload = JSON.parse(raw);
|
|
201
|
+
} catch {
|
|
202
|
+
// Malformed JSON — proceed with empty payload
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const toolName = payload?.tool_name || payload?.toolName || "unknown";
|
|
206
|
+
const toolInput = payload?.tool_input || payload?.toolInput || {};
|
|
207
|
+
const agentModel = payload?.model || payload?.agent_model || null;
|
|
208
|
+
|
|
209
|
+
const { tier, model } = classify(toolName, toolInput, agentModel);
|
|
210
|
+
|
|
211
|
+
// Extract actual token counts from payload (location varies by hook version)
|
|
212
|
+
const usage = payload?.usage || toolInput?.usage || {};
|
|
213
|
+
const inputTokens = usage.input_tokens ?? payload?.input_tokens ?? null;
|
|
214
|
+
const outputTokens = usage.output_tokens ?? payload?.output_tokens ?? null;
|
|
215
|
+
|
|
216
|
+
const status = (payload?.error || payload?.tool_response?.error || payload?.is_error) ? 'error' : 'ok';
|
|
217
|
+
|
|
218
|
+
const entry = JSON.stringify({
|
|
219
|
+
schema_version: 2,
|
|
220
|
+
timestamp: new Date().toISOString(),
|
|
221
|
+
tier,
|
|
222
|
+
tool: toolName,
|
|
223
|
+
model,
|
|
224
|
+
provider: detectProvider(model),
|
|
225
|
+
dispatcher: 'claude-code',
|
|
226
|
+
status,
|
|
227
|
+
session_id: process.env.CLAUDE_SESSION_ID || null,
|
|
228
|
+
input_tokens: inputTokens,
|
|
229
|
+
output_tokens: outputTokens,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
appendFileSync(usageFile(), entry + "\n", { encoding: "utf8", flag: "a" });
|
|
234
|
+
} catch {
|
|
235
|
+
// Disk write failed — silently ignore so the hook never blocks the IDE
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Check budget thresholds and emit a systemMessage if over limit
|
|
239
|
+
const budgetMsg = checkBudget();
|
|
240
|
+
|
|
241
|
+
// PostToolUse hooks must emit a JSON object to stdout
|
|
242
|
+
if (budgetMsg) {
|
|
243
|
+
process.stdout.write(JSON.stringify({ systemMessage: budgetMsg }) + "\n");
|
|
244
|
+
} else {
|
|
245
|
+
process.stdout.write("{}\n");
|
|
246
|
+
}
|
|
247
|
+
process.exit(0);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
main();
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cost-report.mjs — Dual-Brain Cost Report CLI
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node .claude/hooks/cost-report.mjs # show today + all-time
|
|
7
|
+
* node .claude/hooks/cost-report.mjs --all # show all-time only
|
|
8
|
+
* node .claude/hooks/cost-report.mjs --today # show today only (default)
|
|
9
|
+
*
|
|
10
|
+
* Reads:
|
|
11
|
+
* .claude/hooks/usage.jsonl — tool call log written by cost-logger.mjs
|
|
12
|
+
* .claude/orchestrator.json — cost rates per model
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
16
|
+
import { dirname, join } from "path";
|
|
17
|
+
import { fileURLToPath } from "url";
|
|
18
|
+
import { execSync } from "child_process";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Paths
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const WORKSPACE = join(__dirname, "..", ".."); // workspace root
|
|
25
|
+
const CONFIG_FILE = join(__dirname, "..", "orchestrator.json");
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Load orchestrator config
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
function loadConfig() {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a flat map: { "haiku": { input_per_mtok, output_per_mtok, tier }, … }
|
|
40
|
+
* from orchestrator.json's subscriptions block.
|
|
41
|
+
*/
|
|
42
|
+
function buildRateMap(config) {
|
|
43
|
+
const rates = {};
|
|
44
|
+
if (!config?.subscriptions) return rates;
|
|
45
|
+
for (const provider of Object.values(config.subscriptions)) {
|
|
46
|
+
for (const [modelKey, data] of Object.entries(provider.models || {})) {
|
|
47
|
+
rates[modelKey] = {
|
|
48
|
+
tier: data.tier,
|
|
49
|
+
input_per_mtok: data.input_per_mtok,
|
|
50
|
+
output_per_mtok: data.output_per_mtok,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return rates;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Load & parse usage log
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
function loadUsage() {
|
|
61
|
+
const files = readdirSync(__dirname)
|
|
62
|
+
.filter(f => f.startsWith('usage-') && f.endsWith('.jsonl'))
|
|
63
|
+
.sort();
|
|
64
|
+
|
|
65
|
+
// Also check legacy usage.jsonl for backwards compat
|
|
66
|
+
if (existsSync(join(__dirname, 'usage.jsonl'))) {
|
|
67
|
+
files.unshift('usage.jsonl');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const records = [];
|
|
71
|
+
for (const f of files) {
|
|
72
|
+
try {
|
|
73
|
+
const lines = readFileSync(join(__dirname, f), 'utf8').split('\n').filter(Boolean);
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
try { records.push(JSON.parse(line)); } catch {}
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
return records;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Cost estimation
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Very rough token estimate per tool call.
|
|
88
|
+
* Without actual token counts from the session files, we use a conservative
|
|
89
|
+
* heuristic based on typical Claude Code usage patterns.
|
|
90
|
+
*/
|
|
91
|
+
const TOKEN_HEURISTICS = {
|
|
92
|
+
// { input_tok, output_tok }
|
|
93
|
+
search: { input: 2_000, output: 500 },
|
|
94
|
+
execute: { input: 4_000, output: 1_500 },
|
|
95
|
+
think: { input: 8_000, output: 3_000 },
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function estimateCost(tier, model, rateMap, record = {}) {
|
|
99
|
+
const heuristic = TOKEN_HEURISTICS[tier] || TOKEN_HEURISTICS.execute;
|
|
100
|
+
// Use actual tokens if logged, otherwise fall back to heuristics
|
|
101
|
+
const hasActual = record.input_tokens != null && record.output_tokens != null;
|
|
102
|
+
const inputTok = hasActual ? record.input_tokens : heuristic.input;
|
|
103
|
+
const outputTok = hasActual ? record.output_tokens : heuristic.output;
|
|
104
|
+
const rate = rateMap[model] || rateMap["main-session"];
|
|
105
|
+
if (!rate) {
|
|
106
|
+
// Fallback: use tier-matched rate from whatever model we know about
|
|
107
|
+
// "main-session" and "unknown" map to think-tier (Opus) since that's the session model
|
|
108
|
+
const fallbackTier = (model === "main-session" || model === "unknown") ? "think" : tier;
|
|
109
|
+
const tierRate = Object.values(rateMap).find((r) => r.tier === fallbackTier)
|
|
110
|
+
|| Object.values(rateMap).find((r) => r.tier === tier);
|
|
111
|
+
if (!tierRate) return 0;
|
|
112
|
+
return (
|
|
113
|
+
(inputTok / 1_000_000) * tierRate.input_per_mtok +
|
|
114
|
+
(outputTok / 1_000_000) * tierRate.output_per_mtok
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return (
|
|
118
|
+
(inputTok / 1_000_000) * rate.input_per_mtok +
|
|
119
|
+
(outputTok / 1_000_000) * rate.output_per_mtok
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Git log fallback — estimate work volume when usage.jsonl is empty
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
function gitFallbackSummary() {
|
|
127
|
+
try {
|
|
128
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
129
|
+
const log = execSync(
|
|
130
|
+
`git -C "${WORKSPACE}" log --oneline --since="${today} 00:00" --until="${today} 23:59"`,
|
|
131
|
+
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
|
|
132
|
+
).trim();
|
|
133
|
+
const commits = log ? log.split("\n").length : 0;
|
|
134
|
+
return commits;
|
|
135
|
+
} catch {
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Aggregation
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
function todayPrefix() {
|
|
145
|
+
return new Date().toISOString().slice(0, 10); // "YYYY-MM-DD"
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Aggregate records into { [tier]: { model, calls, cost } }
|
|
150
|
+
* where model is the most-seen model for that tier.
|
|
151
|
+
*/
|
|
152
|
+
function aggregate(records, rateMap, datePrefix = null) {
|
|
153
|
+
const filtered = datePrefix
|
|
154
|
+
? records.filter((r) => r.timestamp?.startsWith(datePrefix))
|
|
155
|
+
: records;
|
|
156
|
+
|
|
157
|
+
// tier → { calls: number, costSum: number, modelCounts: { model: count } }
|
|
158
|
+
const buckets = {};
|
|
159
|
+
|
|
160
|
+
for (const record of filtered) {
|
|
161
|
+
const tier = record.tier || "execute";
|
|
162
|
+
const model = record.model || "unknown";
|
|
163
|
+
if (!buckets[tier]) {
|
|
164
|
+
buckets[tier] = { calls: 0, costSum: 0, modelCounts: {} };
|
|
165
|
+
}
|
|
166
|
+
buckets[tier].calls += 1;
|
|
167
|
+
buckets[tier].costSum += estimateCost(tier, model, rateMap, record);
|
|
168
|
+
buckets[tier].modelCounts[model] = (buckets[tier].modelCounts[model] || 0) + 1;
|
|
169
|
+
if (record.input_tokens != null && record.output_tokens != null) buckets[tier].actualCount = (buckets[tier].actualCount || 0) + 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Resolve dominant model per tier
|
|
173
|
+
const result = {};
|
|
174
|
+
for (const [tier, data] of Object.entries(buckets)) {
|
|
175
|
+
const dominantModel = Object.entries(data.modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || "unknown";
|
|
176
|
+
result[tier] = {
|
|
177
|
+
model: dominantModel,
|
|
178
|
+
calls: data.calls,
|
|
179
|
+
cost: data.costSum,
|
|
180
|
+
actualCount: data.actualCount || 0,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Opus all-in cost (for savings calculation)
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
function allOpusCost(records, rateMap, datePrefix = null) {
|
|
190
|
+
const filtered = datePrefix
|
|
191
|
+
? records.filter((r) => r.timestamp?.startsWith(datePrefix))
|
|
192
|
+
: records;
|
|
193
|
+
|
|
194
|
+
return filtered.reduce((sum, record) => {
|
|
195
|
+
return sum + estimateCost("think", "opus", rateMap, record);
|
|
196
|
+
}, 0);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Formatting helpers
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
const TIER_ORDER = ["search", "execute", "think"];
|
|
204
|
+
|
|
205
|
+
const TIER_LABELS = {
|
|
206
|
+
search: "Search ",
|
|
207
|
+
execute: "Execute",
|
|
208
|
+
think: "Think ",
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
function fmt$(n) {
|
|
212
|
+
return "$" + n.toFixed(2);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function pad(str, len, align = "left") {
|
|
216
|
+
str = String(str);
|
|
217
|
+
if (str.length >= len) return str.slice(0, len);
|
|
218
|
+
const spaces = " ".repeat(len - str.length);
|
|
219
|
+
return align === "right" ? spaces + str : str + spaces;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderTable(title, aggregated, allOpus, records = []) {
|
|
223
|
+
const totalCost = Object.values(aggregated).reduce((s, v) => s + v.cost, 0);
|
|
224
|
+
const savings = allOpus - totalCost;
|
|
225
|
+
const savingsPct = allOpus > 0 ? Math.round((savings / allOpus) * 100) : 0;
|
|
226
|
+
|
|
227
|
+
const W = 50; // total inner width (between ║ chars)
|
|
228
|
+
|
|
229
|
+
const line = (s) => `║ ${pad(s, W - 2)} ║`;
|
|
230
|
+
const border = (l, r, m) => l + "═".repeat(W) + r;
|
|
231
|
+
const sep = () => "╠" + "═".repeat(W) + "╣";
|
|
232
|
+
|
|
233
|
+
const rows = TIER_ORDER
|
|
234
|
+
.filter((t) => aggregated[t])
|
|
235
|
+
.map((t) => {
|
|
236
|
+
const { model, calls, cost } = aggregated[t];
|
|
237
|
+
const tierLbl = pad(TIER_LABELS[t] || t, 8);
|
|
238
|
+
const modelLbl = pad(model, 10);
|
|
239
|
+
const callsLbl = pad(String(calls), 5, "right");
|
|
240
|
+
const costLbl = pad(fmt$(cost), 12, "right");
|
|
241
|
+
return line(`${tierLbl} │ ${modelLbl} │ ${callsLbl} │ ${costLbl}`);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const header = line(`Tier │ Model │ Calls │ Est. Cost `);
|
|
245
|
+
const hline = line(`─────────┼────────────┼───────┼────────────`);
|
|
246
|
+
|
|
247
|
+
const totalCalls = Object.values(aggregated).reduce((s, v) => s + v.calls, 0);
|
|
248
|
+
const actualCalls = Object.values(aggregated).reduce((s, v) => s + (v.actualCount || 0), 0);
|
|
249
|
+
const confidence = actualCalls === 0 ? 'low (heuristic only)' :
|
|
250
|
+
actualCalls === totalCalls ? 'high (actual tokens)' :
|
|
251
|
+
`medium (${Math.round(actualCalls/totalCalls*100)}% actual)`;
|
|
252
|
+
|
|
253
|
+
// Data quality stats
|
|
254
|
+
const totalRecords = Object.values(aggregated).reduce((s, v) => s + v.calls, 0);
|
|
255
|
+
const unknownModels = records.filter(r => !r.model || r.model === 'unknown').length;
|
|
256
|
+
const v2Records = records.filter(r => r.schema_version >= 2).length;
|
|
257
|
+
const errorRecords = records.filter(r => r.status === 'error').length;
|
|
258
|
+
|
|
259
|
+
const lines = [
|
|
260
|
+
border("╔", "╗"),
|
|
261
|
+
line(pad(title, W - 2)),
|
|
262
|
+
sep(),
|
|
263
|
+
header,
|
|
264
|
+
hline,
|
|
265
|
+
...rows,
|
|
266
|
+
sep(),
|
|
267
|
+
line(`Total estimated: ${fmt$(totalCost)}`),
|
|
268
|
+
line(`Savings vs all-Opus: ~${fmt$(Math.max(0, savings))} (${savingsPct}%)`),
|
|
269
|
+
line(`Confidence: ${confidence}`),
|
|
270
|
+
border("╚", "╝"),
|
|
271
|
+
];
|
|
272
|
+
|
|
273
|
+
if (unknownModels > 0 || errorRecords > 0) {
|
|
274
|
+
lines.splice(-1, 0,
|
|
275
|
+
line(`Unknown models: ${unknownModels}/${totalRecords} entries`),
|
|
276
|
+
line(`Errors: ${errorRecords} tool calls failed`),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return lines.join("\n");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function renderEmpty() {
|
|
284
|
+
const border = (l, r) => l + "═".repeat(W) + r;
|
|
285
|
+
const ln = (s) => `║ ${pad(s, W - 2)} ║`;
|
|
286
|
+
return [
|
|
287
|
+
border("╔", "╗"),
|
|
288
|
+
ln("Activity & Cost Estimate"),
|
|
289
|
+
border("╠", "╣"),
|
|
290
|
+
ln("No usage data yet."),
|
|
291
|
+
ln(""),
|
|
292
|
+
ln("Install cost-logger.mjs as a PostToolUse hook"),
|
|
293
|
+
ln("to start tracking usage."),
|
|
294
|
+
border("╚", "╝"),
|
|
295
|
+
].join("\n");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Main
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
function main() {
|
|
303
|
+
const args = process.argv.slice(2);
|
|
304
|
+
const showAll = args.includes("--all");
|
|
305
|
+
|
|
306
|
+
const config = loadConfig();
|
|
307
|
+
const rateMap = buildRateMap(config);
|
|
308
|
+
const records = loadUsage();
|
|
309
|
+
|
|
310
|
+
if (records.length === 0) {
|
|
311
|
+
// Try git log fallback for a rough mention
|
|
312
|
+
const commits = gitFallbackSummary();
|
|
313
|
+
console.log(renderEmpty());
|
|
314
|
+
if (commits > 0) {
|
|
315
|
+
console.log(`\n (Git log shows ${commits} commit(s) today — no tool-level data available.)`);
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const today = todayPrefix();
|
|
321
|
+
|
|
322
|
+
if (!showAll) {
|
|
323
|
+
// Today's report
|
|
324
|
+
const todayAgg = aggregate(records, rateMap, today);
|
|
325
|
+
const todayOpus = allOpusCost(records, rateMap, today);
|
|
326
|
+
const todayRecords = records.filter(r => r.timestamp?.startsWith(today));
|
|
327
|
+
const hasTodayData = Object.keys(todayAgg).length > 0;
|
|
328
|
+
|
|
329
|
+
if (hasTodayData) {
|
|
330
|
+
console.log(renderTable("Activity & Cost Estimate — Today", todayAgg, todayOpus, todayRecords));
|
|
331
|
+
} else {
|
|
332
|
+
console.log(" No activity recorded for today yet.");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
console.log(); // blank line separator
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// All-time report
|
|
339
|
+
const allAgg = aggregate(records, rateMap);
|
|
340
|
+
const allOpus = allOpusCost(records, rateMap);
|
|
341
|
+
console.log(renderTable("Activity & Cost Estimate — All Time", allAgg, allOpus, records));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
main();
|