dual-brain 0.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/AGENTS.md +97 -0
- package/CLAUDE.md +147 -0
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/agents/implementer.md +22 -0
- package/agents/researcher.md +25 -0
- package/agents/verifier.md +30 -0
- package/bin/dual-brain.mjs +2868 -0
- package/hooks/auto-update-wrapper.mjs +102 -0
- package/hooks/auto-update.sh +67 -0
- package/hooks/budget-balancer.mjs +679 -0
- package/hooks/control-panel.mjs +1195 -0
- package/hooks/cost-logger.mjs +286 -0
- package/hooks/cost-report.mjs +351 -0
- package/hooks/decision-ledger.mjs +299 -0
- package/hooks/dual-brain-review.mjs +404 -0
- package/hooks/dual-brain-think.mjs +393 -0
- package/hooks/enforce-tier.mjs +469 -0
- package/hooks/failure-detector.mjs +138 -0
- package/hooks/gpt-work-dispatcher.mjs +512 -0
- package/hooks/head-guard.mjs +105 -0
- package/hooks/health-check.mjs +444 -0
- package/hooks/install-git-hooks.mjs +106 -0
- package/hooks/model-registry.mjs +859 -0
- package/hooks/plan-generator.mjs +544 -0
- package/hooks/profiles.mjs +254 -0
- package/hooks/quality-gate.mjs +355 -0
- package/hooks/risk-classifier.mjs +41 -0
- package/hooks/session-report.mjs +514 -0
- package/hooks/setup-wizard.mjs +130 -0
- package/hooks/summary-checkpoint.mjs +432 -0
- package/hooks/task-classifier.mjs +328 -0
- package/hooks/test-orchestrator.mjs +1077 -0
- package/hooks/vibe-memory.mjs +463 -0
- package/hooks/vibe-router.mjs +387 -0
- package/hooks/wave-orchestrator.mjs +1397 -0
- package/install.mjs +1541 -0
- package/mcp-server/README.md +81 -0
- package/mcp-server/index.mjs +388 -0
- package/orchestrator.json +215 -0
- package/package.json +108 -0
- package/playbooks/debug.json +49 -0
- package/playbooks/refactor.json +57 -0
- package/playbooks/security-audit.json +57 -0
- package/playbooks/security.json +38 -0
- package/playbooks/test-gen.json +48 -0
- package/plugin.json +22 -0
- package/review-rules.md +17 -0
- package/shell-hook.sh +26 -0
- package/skills/go.md +22 -0
- package/skills/review.md +19 -0
- package/skills/status.md +13 -0
- package/skills/think.md +22 -0
- package/src/brief.mjs +266 -0
- package/src/decide.mjs +635 -0
- package/src/decompose.mjs +331 -0
- package/src/detect.mjs +345 -0
- package/src/dispatch.mjs +942 -0
- package/src/health.mjs +253 -0
- package/src/index.mjs +44 -0
- package/src/install-hooks.mjs +100 -0
- package/src/playbook.mjs +257 -0
- package/src/profile.mjs +990 -0
- package/src/redact.mjs +192 -0
- package/src/repo.mjs +292 -0
- package/src/session.mjs +1036 -0
- package/src/tui.mjs +197 -0
- package/src/update-check.mjs +35 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* budget-balancer.mjs — Core budget balancing module for the Dual-Brain Orchestrator.
|
|
4
|
+
*
|
|
5
|
+
* Tracks rolling usage pressure across Claude and OpenAI providers and recommends
|
|
6
|
+
* which provider should handle incoming work.
|
|
7
|
+
*
|
|
8
|
+
* Exported API:
|
|
9
|
+
* getProviderStatus() → current pressure per provider/tier
|
|
10
|
+
* chooseProvider(taskProfile) → recommended provider + model + rationale
|
|
11
|
+
* recordUsageEvent(event) → append a usage event to today's log
|
|
12
|
+
*
|
|
13
|
+
* Also works as a standalone CLI: node .claude/hooks/budget-balancer.mjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
|
|
17
|
+
import { dirname, join } from "path";
|
|
18
|
+
import { fileURLToPath } from "url";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Paths
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const ORCHESTRATOR_CONFIG = join(__dirname, "..", "orchestrator.json");
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Constants
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
31
|
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Subscription tier definitions with real token budgets.
|
|
35
|
+
* Token limits are per-model-class per rolling window.
|
|
36
|
+
* Sources: Anthropic pricing page, OpenAI subscription docs (May 2025).
|
|
37
|
+
* These are best-effort estimates — providers adjust limits dynamically.
|
|
38
|
+
*/
|
|
39
|
+
const SUBSCRIPTION_TIERS = {
|
|
40
|
+
claude: {
|
|
41
|
+
"$20": { label: "Claude Pro $20", fiveHr: { think: 22_000, execute: 80_000, search: 300_000 }, weekly: { think: 150_000, execute: 600_000, search: 2_000_000 } },
|
|
42
|
+
"$100": { label: "Claude Max x5", fiveHr: { think: 88_000, execute: 350_000, search: 1_200_000 }, weekly: { think: 600_000, execute: 2_500_000, search: 8_000_000 } },
|
|
43
|
+
"$200": { label: "Claude Max x20", fiveHr: { think: 220_000, execute: 900_000, search: 3_000_000 }, weekly: { think: 1_500_000, execute: 6_000_000, search: 20_000_000 } },
|
|
44
|
+
},
|
|
45
|
+
openai: {
|
|
46
|
+
"$20": { label: "ChatGPT Plus $20", fiveHr: { think: 20_000, execute: 80_000, search: 300_000 }, weekly: { think: 140_000, execute: 560_000, search: 2_000_000 } },
|
|
47
|
+
"$100": { label: "ChatGPT Pro $100", fiveHr: { think: 50_000, execute: 200_000, search: 800_000 }, weekly: { think: 350_000, execute: 1_400_000, search: 5_000_000 } },
|
|
48
|
+
"$200": { label: "ChatGPT Pro $200", fiveHr: { think: 100_000, execute: 400_000, search: 1_500_000 }, weekly: { think: 700_000, execute: 2_800_000, search: 10_000_000 } },
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Fallback tokens-per-call when usage log has no real token data for an entry */
|
|
53
|
+
const TOKENS_PER_CALL_FALLBACK = {
|
|
54
|
+
search: 2_500,
|
|
55
|
+
execute: 8_000,
|
|
56
|
+
think: 15_000,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function getSubscriptionBudgets(config) {
|
|
60
|
+
const claudePlan = config?.subscriptions?.claude?.plan || "$100";
|
|
61
|
+
const openaiPlan = config?.subscriptions?.openai?.plan || "$20";
|
|
62
|
+
const claudeTier = SUBSCRIPTION_TIERS.claude[claudePlan] || SUBSCRIPTION_TIERS.claude["$100"];
|
|
63
|
+
const openaiTier = SUBSCRIPTION_TIERS.openai[openaiPlan] || SUBSCRIPTION_TIERS.openai["$20"];
|
|
64
|
+
return {
|
|
65
|
+
claude: { fiveHr: claudeTier.fiveHr, weekly: claudeTier.weekly, label: claudeTier.label },
|
|
66
|
+
openai: { fiveHr: openaiTier.fiveHr, weekly: openaiTier.weekly, label: openaiTier.label },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULT_THRESHOLDS = {
|
|
71
|
+
warm: 0.55,
|
|
72
|
+
hot: 0.75,
|
|
73
|
+
throttled: 0.90,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Default model mapping when orchestrator.json is missing provider config */
|
|
77
|
+
const DEFAULT_MODELS = {
|
|
78
|
+
claude: { think: "opus", execute: "sonnet", search: "haiku" },
|
|
79
|
+
openai: { think: "gpt-5.5", execute: "gpt-5.4", search: "gpt-4.1-mini" },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Config helpers
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
function loadConfig() {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(readFileSync(ORCHESTRATOR_CONFIG, "utf8"));
|
|
89
|
+
} catch {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getThresholds(config, provider) {
|
|
95
|
+
return (
|
|
96
|
+
config?.providers?.[provider]?.pressure_thresholds || DEFAULT_THRESHOLDS
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getProviderModels(config, provider) {
|
|
101
|
+
return config?.providers?.[provider]?.models || DEFAULT_MODELS[provider];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Provider / tier detection from model name
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Given a model string, return { provider, tier } or null if unrecognised.
|
|
110
|
+
*/
|
|
111
|
+
function classifyModel(model) {
|
|
112
|
+
if (!model) return null;
|
|
113
|
+
const m = String(model).toLowerCase();
|
|
114
|
+
|
|
115
|
+
if (m.includes("opus")) return { provider: "claude", tier: "think" };
|
|
116
|
+
if (m.includes("sonnet")) return { provider: "claude", tier: "execute" };
|
|
117
|
+
if (m.includes("haiku")) return { provider: "claude", tier: "search" };
|
|
118
|
+
if (m.includes("gpt-5.5")) return { provider: "openai", tier: "think" };
|
|
119
|
+
if (m === "gpt-4.1-mini") return { provider: "openai", tier: "search" };
|
|
120
|
+
if (m === "gpt-4.1") return { provider: "openai", tier: "execute" };
|
|
121
|
+
if (m.includes("gpt-5.") || m.includes("gpt-4.")) return { provider: "openai", tier: "execute" };
|
|
122
|
+
if (m.includes("mini")) return { provider: "openai", tier: "search" };
|
|
123
|
+
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Return models available for a subscription tier.
|
|
129
|
+
* Pro ($20) → no opus, limited models. Max ($100/$200) → full access.
|
|
130
|
+
*/
|
|
131
|
+
function getAvailableModels(provider, plan) {
|
|
132
|
+
if (provider === 'claude') {
|
|
133
|
+
if (plan === '$20') return ['haiku', 'sonnet'];
|
|
134
|
+
return ['haiku', 'sonnet', 'opus'];
|
|
135
|
+
}
|
|
136
|
+
if (provider === 'openai') {
|
|
137
|
+
if (plan === '$20') return ['gpt-4.1-mini', 'gpt-4.1', 'gpt-5.2', 'gpt-5.4-mini'];
|
|
138
|
+
return ['gpt-4.1-mini', 'gpt-4.1', 'gpt-5.2', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.4', 'gpt-5.5'];
|
|
139
|
+
}
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isModelAvailable(model, provider, config) {
|
|
144
|
+
const plan = config?.subscriptions?.[provider]?.plan || (provider === 'claude' ? '$100' : '$20');
|
|
145
|
+
const available = getAvailableModels(provider, plan);
|
|
146
|
+
return available.includes(model);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function downgradeModel(model, provider, config) {
|
|
150
|
+
const plan = config?.subscriptions?.[provider]?.plan || (provider === 'claude' ? '$100' : '$20');
|
|
151
|
+
const available = getAvailableModels(provider, plan);
|
|
152
|
+
if (available.includes(model)) return model;
|
|
153
|
+
|
|
154
|
+
if (provider === 'claude') {
|
|
155
|
+
if (model === 'opus') return available.includes('sonnet') ? 'sonnet' : 'haiku';
|
|
156
|
+
return 'haiku';
|
|
157
|
+
}
|
|
158
|
+
const rank = ['gpt-4.1-mini', 'gpt-4.1', 'gpt-5.2', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.4', 'gpt-5.5'];
|
|
159
|
+
const idx = rank.indexOf(model);
|
|
160
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
161
|
+
if (available.includes(rank[i])) return rank[i];
|
|
162
|
+
}
|
|
163
|
+
return available[0] || 'gpt-4.1-mini';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Usage log helpers
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
function usageFilePath(date) {
|
|
171
|
+
const d = date || new Date().toISOString().slice(0, 10);
|
|
172
|
+
return join(__dirname, `usage-${d}.jsonl`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Read usage entries within a time window.
|
|
177
|
+
* Scans log files covering the window range.
|
|
178
|
+
*/
|
|
179
|
+
function readEntriesInWindow(windowMs) {
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
const cutoff = now - windowMs;
|
|
182
|
+
const entries = [];
|
|
183
|
+
|
|
184
|
+
const daysBack = Math.ceil(windowMs / 86_400_000) + 1;
|
|
185
|
+
const seen = new Set();
|
|
186
|
+
for (let i = 0; i < daysBack; i++) {
|
|
187
|
+
const date = new Date(now - i * 86_400_000).toISOString().slice(0, 10);
|
|
188
|
+
if (seen.has(date)) continue;
|
|
189
|
+
seen.add(date);
|
|
190
|
+
const file = usageFilePath(date);
|
|
191
|
+
if (!existsSync(file)) continue;
|
|
192
|
+
let raw;
|
|
193
|
+
try {
|
|
194
|
+
raw = readFileSync(file, "utf8");
|
|
195
|
+
} catch {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
for (const line of raw.split("\n")) {
|
|
199
|
+
if (!line.trim()) continue;
|
|
200
|
+
let record;
|
|
201
|
+
try {
|
|
202
|
+
record = JSON.parse(line);
|
|
203
|
+
} catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const ts = Date.parse(record.timestamp);
|
|
207
|
+
if (!isNaN(ts) && ts >= cutoff) {
|
|
208
|
+
entries.push(record);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return entries;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function readRecentEntries() {
|
|
216
|
+
return readEntriesInWindow(FIVE_HOURS_MS);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Exported: getProviderStatus()
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Sum actual tokens from usage entries for a provider/tier.
|
|
225
|
+
* Uses real input_tokens + output_tokens when available, falls back to estimate.
|
|
226
|
+
*/
|
|
227
|
+
function sumTokens(entries) {
|
|
228
|
+
const tokens = {
|
|
229
|
+
claude: { think: 0, execute: 0, search: 0 },
|
|
230
|
+
openai: { think: 0, execute: 0, search: 0 },
|
|
231
|
+
};
|
|
232
|
+
const calls = {
|
|
233
|
+
claude: { think: 0, execute: 0, search: 0 },
|
|
234
|
+
openai: { think: 0, execute: 0, search: 0 },
|
|
235
|
+
};
|
|
236
|
+
const realTokenCalls = {
|
|
237
|
+
claude: { think: 0, execute: 0, search: 0 },
|
|
238
|
+
openai: { think: 0, execute: 0, search: 0 },
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
let provider = entry.provider;
|
|
243
|
+
let tier = entry.tier;
|
|
244
|
+
|
|
245
|
+
if (!provider && entry.model) {
|
|
246
|
+
const classified = classifyModel(entry.model);
|
|
247
|
+
if (classified) {
|
|
248
|
+
provider = classified.provider;
|
|
249
|
+
tier = classified.tier;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!provider || !tier || !tokens[provider] || tokens[provider][tier] === undefined) continue;
|
|
254
|
+
|
|
255
|
+
calls[provider][tier]++;
|
|
256
|
+
|
|
257
|
+
const inp = entry.input_tokens;
|
|
258
|
+
const out = entry.output_tokens;
|
|
259
|
+
if (inp != null && out != null && (inp > 0 || out > 0)) {
|
|
260
|
+
tokens[provider][tier] += inp + out;
|
|
261
|
+
realTokenCalls[provider][tier]++;
|
|
262
|
+
} else {
|
|
263
|
+
tokens[provider][tier] += TOKENS_PER_CALL_FALLBACK[tier] || 8_000;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { tokens, calls, realTokenCalls };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Compute rolling pressure for each provider/tier using actual token sums
|
|
272
|
+
* against real subscription budgets. Returns both 5hr and weekly pressure.
|
|
273
|
+
*
|
|
274
|
+
* @returns {object} Status keyed by provider → tier → { pressure, weeklyPressure, state, calls, tokens, budget, weeklyBudget }
|
|
275
|
+
*/
|
|
276
|
+
function getProviderStatus() {
|
|
277
|
+
const config = loadConfig();
|
|
278
|
+
const budgets = getSubscriptionBudgets(config);
|
|
279
|
+
|
|
280
|
+
const fiveHrEntries = readEntriesInWindow(FIVE_HOURS_MS);
|
|
281
|
+
const weeklyEntries = readEntriesInWindow(SEVEN_DAYS_MS);
|
|
282
|
+
|
|
283
|
+
const fiveHr = sumTokens(fiveHrEntries);
|
|
284
|
+
const weekly = sumTokens(weeklyEntries);
|
|
285
|
+
|
|
286
|
+
const status = {};
|
|
287
|
+
|
|
288
|
+
for (const provider of ["claude", "openai"]) {
|
|
289
|
+
const thresholds = getThresholds(config, provider);
|
|
290
|
+
status[provider] = {};
|
|
291
|
+
|
|
292
|
+
for (const tier of ["think", "execute", "search"]) {
|
|
293
|
+
const tokensUsed = fiveHr.tokens[provider][tier];
|
|
294
|
+
const budget = budgets[provider].fiveHr[tier];
|
|
295
|
+
const pressure = budget > 0 ? tokensUsed / budget : 0;
|
|
296
|
+
|
|
297
|
+
const weeklyTokens = weekly.tokens[provider][tier];
|
|
298
|
+
const weeklyBudget = budgets[provider].weekly[tier];
|
|
299
|
+
const weeklyPressure = weeklyBudget > 0 ? weeklyTokens / weeklyBudget : 0;
|
|
300
|
+
|
|
301
|
+
const effectivePressure = Math.max(pressure, weeklyPressure);
|
|
302
|
+
|
|
303
|
+
let state;
|
|
304
|
+
if (effectivePressure >= (thresholds.throttled ?? DEFAULT_THRESHOLDS.throttled)) {
|
|
305
|
+
state = "throttled";
|
|
306
|
+
} else if (effectivePressure >= (thresholds.hot ?? DEFAULT_THRESHOLDS.hot)) {
|
|
307
|
+
state = "hot";
|
|
308
|
+
} else if (effectivePressure >= (thresholds.warm ?? DEFAULT_THRESHOLDS.warm)) {
|
|
309
|
+
state = "warm";
|
|
310
|
+
} else {
|
|
311
|
+
state = "healthy";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
status[provider][tier] = {
|
|
315
|
+
pressure,
|
|
316
|
+
weeklyPressure,
|
|
317
|
+
effectivePressure,
|
|
318
|
+
state,
|
|
319
|
+
calls: fiveHr.calls[provider][tier],
|
|
320
|
+
tokens: tokensUsed,
|
|
321
|
+
budget,
|
|
322
|
+
weeklyTokens,
|
|
323
|
+
weeklyBudget,
|
|
324
|
+
realTokenCalls: fiveHr.realTokenCalls[provider][tier],
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
status[provider]._label = budgets[provider].label;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return status;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Exported: chooseProvider(taskProfile)
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Recommend a provider for an incoming task.
|
|
340
|
+
*
|
|
341
|
+
* @param {object} taskProfile
|
|
342
|
+
* @param {string} taskProfile.tier - search | execute | think
|
|
343
|
+
* @param {number} [taskProfile.estimatedDurationMs] - expected task duration
|
|
344
|
+
* @param {string} [taskProfile.contextCoupling] - low | medium | high
|
|
345
|
+
* @param {string} [taskProfile.isolation] - low | medium | high
|
|
346
|
+
* @returns {{ provider, model, reason, scores }}
|
|
347
|
+
*/
|
|
348
|
+
function chooseProvider(taskProfile = {}) {
|
|
349
|
+
const {
|
|
350
|
+
tier = "execute",
|
|
351
|
+
estimatedDurationMs = 0,
|
|
352
|
+
contextCoupling = "low",
|
|
353
|
+
isolation = "low",
|
|
354
|
+
} = taskProfile;
|
|
355
|
+
|
|
356
|
+
const config = loadConfig();
|
|
357
|
+
const status = getProviderStatus();
|
|
358
|
+
|
|
359
|
+
let profileBias = 0;
|
|
360
|
+
try {
|
|
361
|
+
const profilePath = join(__dirname, '..', 'dual-brain.profile.json');
|
|
362
|
+
if (existsSync(profilePath)) {
|
|
363
|
+
const profile = JSON.parse(readFileSync(profilePath, 'utf8'));
|
|
364
|
+
const active = profile.active || 'balanced';
|
|
365
|
+
if (active === 'cost-saver') profileBias = -20;
|
|
366
|
+
else if (active === 'quality-first') profileBias = 10;
|
|
367
|
+
}
|
|
368
|
+
} catch {}
|
|
369
|
+
|
|
370
|
+
const PRESSURE_PENALTY = {
|
|
371
|
+
healthy: 0,
|
|
372
|
+
warm: 15,
|
|
373
|
+
hot: 40,
|
|
374
|
+
throttled: 100,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const scores = {};
|
|
378
|
+
|
|
379
|
+
for (const provider of ["claude", "openai"]) {
|
|
380
|
+
const tierStatus = status[provider]?.[tier] || { effectivePressure: 0, state: "healthy" };
|
|
381
|
+
const otherProvider = provider === "claude" ? "openai" : "claude";
|
|
382
|
+
const otherTierStatus = status[otherProvider]?.[tier] || { effectivePressure: 0, state: "healthy" };
|
|
383
|
+
|
|
384
|
+
let score = 50;
|
|
385
|
+
|
|
386
|
+
if (provider === "claude") {
|
|
387
|
+
if (contextCoupling === "high") score += 20;
|
|
388
|
+
else if (contextCoupling === "medium") score += 10;
|
|
389
|
+
} else {
|
|
390
|
+
if (isolation === "high") score += 20;
|
|
391
|
+
else if (isolation === "medium") score += 10;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
score -= PRESSURE_PENALTY[tierStatus.state] ?? 0;
|
|
395
|
+
|
|
396
|
+
if (provider === 'openai') score += profileBias;
|
|
397
|
+
|
|
398
|
+
if (provider === "openai") {
|
|
399
|
+
let minTaskMs = 180_000;
|
|
400
|
+
try {
|
|
401
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
402
|
+
const summaryPath = join(__dirname, `usage-summary-${today}.json`);
|
|
403
|
+
const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
|
|
404
|
+
const latencies = (summary.codex_latencies || []).map(l => l.startup_ms).filter(Boolean);
|
|
405
|
+
if (latencies.length >= 5) {
|
|
406
|
+
const sorted = latencies.sort((a, b) => a - b);
|
|
407
|
+
const p75 = sorted[Math.floor(sorted.length * 0.75)];
|
|
408
|
+
minTaskMs = Math.max(90_000, p75 * 4);
|
|
409
|
+
}
|
|
410
|
+
} catch {}
|
|
411
|
+
|
|
412
|
+
if (estimatedDurationMs < minTaskMs) {
|
|
413
|
+
score -= 25;
|
|
414
|
+
} else if (estimatedDurationMs < 600_000) {
|
|
415
|
+
score -= 10;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (
|
|
420
|
+
tierStatus.effectivePressure < 0.3 &&
|
|
421
|
+
otherTierStatus.effectivePressure > 0.5
|
|
422
|
+
) {
|
|
423
|
+
score += 20;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
scores[provider] = Math.round(score);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Both-providers-throttled hard stop
|
|
430
|
+
const claudeState = status.claude?.[tier]?.state;
|
|
431
|
+
const openaiState = status.openai?.[tier]?.state;
|
|
432
|
+
if (claudeState === 'throttled' && openaiState === 'throttled') {
|
|
433
|
+
const claudeP = status.claude[tier].effectivePressure;
|
|
434
|
+
const openaiP = status.openai[tier].effectivePressure;
|
|
435
|
+
const lessThrottled = claudeP <= openaiP ? 'claude' : 'openai';
|
|
436
|
+
const m = getProviderModels(config, lessThrottled);
|
|
437
|
+
return {
|
|
438
|
+
provider: lessThrottled,
|
|
439
|
+
model: m?.[tier] || DEFAULT_MODELS[lessThrottled][tier],
|
|
440
|
+
reason: `BOTH PROVIDERS THROTTLED (claude ${Math.round(claudeP * 100)}%, openai ${Math.round(openaiP * 100)}%). Using ${lessThrottled} as least-throttled. Consider waiting or downgrading tier.`,
|
|
441
|
+
scores,
|
|
442
|
+
bothThrottled: true,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const winner = scores.claude >= scores.openai ? "claude" : "openai";
|
|
447
|
+
const loser = winner === "claude" ? "openai" : "claude";
|
|
448
|
+
|
|
449
|
+
const models = getProviderModels(config, winner);
|
|
450
|
+
let model = models?.[tier] || DEFAULT_MODELS[winner][tier];
|
|
451
|
+
|
|
452
|
+
// Gate model by subscription tier
|
|
453
|
+
if (!isModelAvailable(model, winner, config)) {
|
|
454
|
+
const downgraded = downgradeModel(model, winner, config);
|
|
455
|
+
model = downgraded;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const ws = status[winner]?.[tier] || {};
|
|
459
|
+
const ls = status[loser]?.[tier] || {};
|
|
460
|
+
|
|
461
|
+
let reasonParts = [];
|
|
462
|
+
if (winner === "claude" && contextCoupling !== "low") {
|
|
463
|
+
reasonParts.push(`high session context coupling`);
|
|
464
|
+
}
|
|
465
|
+
if (winner === "openai" && isolation !== "low") {
|
|
466
|
+
reasonParts.push(`isolated task`);
|
|
467
|
+
}
|
|
468
|
+
const wp = (ws.effectivePressure ?? 0);
|
|
469
|
+
const lp = (ls.effectivePressure ?? 0);
|
|
470
|
+
if (wp < lp) {
|
|
471
|
+
reasonParts.push(`${winner} ${Math.round(wp * 100)}% vs ${loser} ${Math.round(lp * 100)}%`);
|
|
472
|
+
}
|
|
473
|
+
if (ws.weeklyPressure > ws.pressure) {
|
|
474
|
+
reasonParts.push(`weekly limit is binding (${Math.round(ws.weeklyPressure * 100)}%)`);
|
|
475
|
+
}
|
|
476
|
+
if (!reasonParts.length) {
|
|
477
|
+
reasonParts.push(`${winner} scored ${scores[winner]} vs ${scores[loser]}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
provider: winner,
|
|
482
|
+
model,
|
|
483
|
+
reason: reasonParts.join(", "),
|
|
484
|
+
scores,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// Exported: estimateWaveCost(tasks)
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
function estimateWaveCost(tasks) {
|
|
493
|
+
const config = loadConfig();
|
|
494
|
+
const budgets = getSubscriptionBudgets(config);
|
|
495
|
+
const status = getProviderStatus();
|
|
496
|
+
|
|
497
|
+
let totalTokens = { claude: 0, openai: 0 };
|
|
498
|
+
for (const task of tasks) {
|
|
499
|
+
const provider = task.provider || 'claude';
|
|
500
|
+
const tier = task.tier || 'execute';
|
|
501
|
+
const estimate = TOKENS_PER_CALL_FALLBACK[tier] || 8_000;
|
|
502
|
+
totalTokens[provider] += estimate;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const impact = {};
|
|
506
|
+
for (const provider of ['claude', 'openai']) {
|
|
507
|
+
if (totalTokens[provider] === 0) continue;
|
|
508
|
+
const tierStatus = status[provider]?.execute || {};
|
|
509
|
+
const remaining = Math.max(0, (tierStatus.budget || 0) - (tierStatus.tokens || 0));
|
|
510
|
+
const pctOfBudget = tierStatus.budget > 0 ? (totalTokens[provider] / tierStatus.budget) * 100 : 0;
|
|
511
|
+
impact[provider] = {
|
|
512
|
+
estimatedTokens: totalTokens[provider],
|
|
513
|
+
remaining,
|
|
514
|
+
pctOfBudget: Math.round(pctOfBudget * 10) / 10,
|
|
515
|
+
wouldExceed: totalTokens[provider] > remaining,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return { totalTokens, impact, taskCount: tasks.length };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
// Exported: estimateTokensForTask(task)
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
function estimateTokensForTask(task) {
|
|
527
|
+
const tier = task?.tier || 'execute';
|
|
528
|
+
const fileCount = Math.max(1, (task?.files?.length || 0));
|
|
529
|
+
const base = TOKENS_PER_CALL_FALLBACK[tier] || 8_000;
|
|
530
|
+
const effortMultiplier = { low: 0.5, medium: 1, high: 1.5, xhigh: 2.5 };
|
|
531
|
+
const mult = effortMultiplier[task?.effort] || 1;
|
|
532
|
+
return Math.round(base * mult * Math.sqrt(fileCount));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// Exported: recordUsageEvent(event)
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Append a usage event to today's daily log file.
|
|
541
|
+
* Automatically adds `provider` field if not present.
|
|
542
|
+
*
|
|
543
|
+
* @param {object} event - Usage event (see cost-logger.mjs schema)
|
|
544
|
+
*/
|
|
545
|
+
function recordUsageEvent(event = {}) {
|
|
546
|
+
// Infer provider from model name if not supplied
|
|
547
|
+
let provider = event.provider;
|
|
548
|
+
if (!provider && event.model) {
|
|
549
|
+
const classified = classifyModel(event.model);
|
|
550
|
+
provider = classified?.provider || "claude";
|
|
551
|
+
}
|
|
552
|
+
if (!provider) provider = "claude";
|
|
553
|
+
|
|
554
|
+
const entry = JSON.stringify({
|
|
555
|
+
schema_version: 2,
|
|
556
|
+
timestamp: event.timestamp || new Date().toISOString(),
|
|
557
|
+
provider,
|
|
558
|
+
...event,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const file = usageFilePath();
|
|
562
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
appendFileSync(file, entry + "\n", { encoding: "utf8", flag: "a" });
|
|
566
|
+
} catch (err) {
|
|
567
|
+
// Non-fatal — log to stderr but don't crash callers
|
|
568
|
+
process.stderr.write(`[budget-balancer] Failed to write usage event: ${err.message}\n`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
// CLI rendering helpers
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
function pressureBar(pressure, width = 10) {
|
|
577
|
+
const filled = Math.min(width, Math.round(pressure * width));
|
|
578
|
+
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function stateLabel(state) {
|
|
582
|
+
return state.padEnd(8);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function formatPercent(pressure) {
|
|
586
|
+
return String(Math.round(pressure * 100)).padStart(3) + "%";
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function formatTokens(n) {
|
|
590
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
591
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
592
|
+
return String(n);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function printStatusTable(status) {
|
|
596
|
+
const LINE_WIDTH = 62;
|
|
597
|
+
const border = "═".repeat(LINE_WIDTH - 2);
|
|
598
|
+
const blank = " ".repeat(LINE_WIDTH - 4);
|
|
599
|
+
|
|
600
|
+
const h = (text) => {
|
|
601
|
+
const padded = ` ${text}`.padEnd(LINE_WIDTH - 4);
|
|
602
|
+
return `║ ${padded} ║`;
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const row = (label, tier) => {
|
|
606
|
+
const s = status[label]?.[tier] || { effectivePressure: 0, pressure: 0, state: "healthy", tokens: 0, budget: 0 };
|
|
607
|
+
const bar = pressureBar(s.effectivePressure);
|
|
608
|
+
const pct = formatPercent(s.effectivePressure);
|
|
609
|
+
const lbl = stateLabel(s.state);
|
|
610
|
+
const used = formatTokens(s.tokens || 0);
|
|
611
|
+
const cap = formatTokens(s.budget || 0);
|
|
612
|
+
const tierLabel = tier.charAt(0).toUpperCase() + tier.slice(1);
|
|
613
|
+
const line = ` ${tierLabel.padEnd(7)}: ${bar} ${pct} ${lbl} ${used}/${cap}`;
|
|
614
|
+
return h(line);
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const weeklyRow = (label, tier) => {
|
|
618
|
+
const s = status[label]?.[tier] || {};
|
|
619
|
+
if (!s.weeklyPressure || s.weeklyPressure <= 0) return null;
|
|
620
|
+
const pct = Math.round((s.weeklyPressure || 0) * 100);
|
|
621
|
+
const used = formatTokens(s.weeklyTokens || 0);
|
|
622
|
+
const cap = formatTokens(s.weeklyBudget || 0);
|
|
623
|
+
return h(` weekly: ${pct}% (${used}/${cap})`);
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const claudeLabel = status.claude?._label || "Claude Max $100";
|
|
627
|
+
const openaiLabel = status.openai?._label || "ChatGPT Plus $20";
|
|
628
|
+
|
|
629
|
+
const rec = chooseProvider({ tier: "execute", estimatedDurationMs: 300_000, isolation: "high", contextCoupling: "low" });
|
|
630
|
+
const recText = `Route execution to ${rec.provider === "openai" ? "OpenAI" : "Claude"}`;
|
|
631
|
+
|
|
632
|
+
const lines = [
|
|
633
|
+
`╔${border}╗`,
|
|
634
|
+
h(" Provider Balance Status"),
|
|
635
|
+
h(" (token-based, real limits)"),
|
|
636
|
+
`╠${border}╣`,
|
|
637
|
+
h(claudeLabel),
|
|
638
|
+
row("claude", "think"),
|
|
639
|
+
weeklyRow("claude", "think"),
|
|
640
|
+
row("claude", "execute"),
|
|
641
|
+
weeklyRow("claude", "execute"),
|
|
642
|
+
row("claude", "search"),
|
|
643
|
+
h(blank),
|
|
644
|
+
h(openaiLabel),
|
|
645
|
+
row("openai", "think"),
|
|
646
|
+
weeklyRow("openai", "think"),
|
|
647
|
+
row("openai", "execute"),
|
|
648
|
+
weeklyRow("openai", "execute"),
|
|
649
|
+
row("openai", "search"),
|
|
650
|
+
`╠${border}╣`,
|
|
651
|
+
h(`Recommendation: ${recText}`),
|
|
652
|
+
h(`Reason: ${rec.reason}`),
|
|
653
|
+
`╚${border}╝`,
|
|
654
|
+
];
|
|
655
|
+
|
|
656
|
+
console.log(lines.filter(Boolean).join("\n"));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
// CLI entry point
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
|
|
663
|
+
async function main() {
|
|
664
|
+
const status = getProviderStatus();
|
|
665
|
+
printStatusTable(status);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Run as CLI only when invoked directly
|
|
669
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
670
|
+
main().catch((err) => {
|
|
671
|
+
process.stderr.write(`[budget-balancer] ${err.message}\n`);
|
|
672
|
+
process.exit(1);
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
// Exports
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
export { getProviderStatus, chooseProvider, recordUsageEvent, getSubscriptionBudgets, estimateWaveCost, estimateTokensForTask, isModelAvailable, downgradeModel, SUBSCRIPTION_TIERS };
|