dual-brain 7.1.21 → 7.1.23
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/bin/dual-brain.mjs +2580 -717
- package/hooks/budget-balancer.mjs +104 -266
- package/hooks/wave-orchestrator.mjs +29 -26
- package/package.json +14 -3
- package/scripts/verify-publish.mjs +26 -0
- package/src/context.mjs +389 -0
- package/src/decide.mjs +283 -60
- package/src/detect.mjs +133 -1
- package/src/dispatch.mjs +195 -30
- package/src/doctor.mjs +577 -0
- package/src/failure-memory.mjs +178 -0
- package/src/intelligence.mjs +423 -0
- package/src/nextstep.mjs +100 -0
- package/src/observer.mjs +241 -0
- package/src/outcome.mjs +256 -0
- package/src/pipeline.mjs +808 -0
- package/src/profile.mjs +357 -485
- package/src/receipt.mjs +131 -0
- package/src/session.mjs +358 -10
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* budget-balancer.mjs —
|
|
3
|
+
* budget-balancer.mjs — Session-level provider balance tracker for the Dual-Brain Orchestrator.
|
|
4
4
|
*
|
|
5
|
-
* Tracks
|
|
6
|
-
* which provider
|
|
5
|
+
* Tracks relative usage of Claude vs OpenAI within the current session (5-hour window)
|
|
6
|
+
* and recommends which provider to use next based on imbalance — not fake subscription math.
|
|
7
7
|
*
|
|
8
8
|
* Exported API:
|
|
9
|
-
* getProviderStatus() →
|
|
9
|
+
* getProviderStatus() → session call counts and lean direction per provider/tier
|
|
10
10
|
* chooseProvider(taskProfile) → recommended provider + model + rationale
|
|
11
11
|
* recordUsageEvent(event) → append a usage event to today's log
|
|
12
12
|
*
|
|
@@ -28,26 +28,6 @@ const ORCHESTRATOR_CONFIG = join(__dirname, "..", "orchestrator.json");
|
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
29
29
|
|
|
30
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
31
|
|
|
52
32
|
/** Fallback tokens-per-call when usage log has no real token data for an entry */
|
|
53
33
|
const TOKENS_PER_CALL_FALLBACK = {
|
|
@@ -56,23 +36,6 @@ const TOKENS_PER_CALL_FALLBACK = {
|
|
|
56
36
|
think: 15_000,
|
|
57
37
|
};
|
|
58
38
|
|
|
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
39
|
/** Default model mapping when orchestrator.json is missing provider config */
|
|
77
40
|
const DEFAULT_MODELS = {
|
|
78
41
|
claude: { think: "opus", execute: "sonnet", search: "haiku" },
|
|
@@ -91,12 +54,6 @@ function loadConfig() {
|
|
|
91
54
|
}
|
|
92
55
|
}
|
|
93
56
|
|
|
94
|
-
function getThresholds(config, provider) {
|
|
95
|
-
return (
|
|
96
|
-
config?.providers?.[provider]?.pressure_thresholds || DEFAULT_THRESHOLDS
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
57
|
function getProviderModels(config, provider) {
|
|
101
58
|
return config?.providers?.[provider]?.models || DEFAULT_MODELS[provider];
|
|
102
59
|
}
|
|
@@ -212,30 +169,22 @@ function readEntriesInWindow(windowMs) {
|
|
|
212
169
|
return entries;
|
|
213
170
|
}
|
|
214
171
|
|
|
215
|
-
function readRecentEntries() {
|
|
216
|
-
return readEntriesInWindow(FIVE_HOURS_MS);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
172
|
// ---------------------------------------------------------------------------
|
|
220
|
-
//
|
|
173
|
+
// Session usage aggregation
|
|
221
174
|
// ---------------------------------------------------------------------------
|
|
222
175
|
|
|
223
176
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
177
|
+
* Count calls and tokens per provider/tier from usage entries.
|
|
178
|
+
* Returns raw counts only — no percentage math against unknowable quota.
|
|
226
179
|
*/
|
|
227
|
-
function
|
|
228
|
-
const tokens = {
|
|
229
|
-
claude: { think: 0, execute: 0, search: 0 },
|
|
230
|
-
openai: { think: 0, execute: 0, search: 0 },
|
|
231
|
-
};
|
|
180
|
+
function aggregateUsage(entries) {
|
|
232
181
|
const calls = {
|
|
233
|
-
claude: { think: 0, execute: 0, search: 0 },
|
|
234
|
-
openai: { think: 0, execute: 0, search: 0 },
|
|
182
|
+
claude: { think: 0, execute: 0, search: 0, total: 0 },
|
|
183
|
+
openai: { think: 0, execute: 0, search: 0, total: 0 },
|
|
235
184
|
};
|
|
236
|
-
const
|
|
237
|
-
claude: { think: 0, execute: 0, search: 0 },
|
|
238
|
-
openai: { think: 0, execute: 0, search: 0 },
|
|
185
|
+
const tokens = {
|
|
186
|
+
claude: { think: 0, execute: 0, search: 0, total: 0 },
|
|
187
|
+
openai: { think: 0, execute: 0, search: 0, total: 0 },
|
|
239
188
|
};
|
|
240
189
|
|
|
241
190
|
for (const entry of entries) {
|
|
@@ -250,85 +199,61 @@ function sumTokens(entries) {
|
|
|
250
199
|
}
|
|
251
200
|
}
|
|
252
201
|
|
|
253
|
-
if (!provider || !
|
|
202
|
+
if (!provider || !calls[provider]) continue;
|
|
203
|
+
const t = (tier && calls[provider][tier] !== undefined) ? tier : null;
|
|
254
204
|
|
|
255
|
-
calls[provider]
|
|
205
|
+
calls[provider].total++;
|
|
206
|
+
if (t) calls[provider][t]++;
|
|
256
207
|
|
|
257
208
|
const inp = entry.input_tokens;
|
|
258
209
|
const out = entry.output_tokens;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
210
|
+
const tokCount = (inp != null && out != null && (inp > 0 || out > 0))
|
|
211
|
+
? inp + out
|
|
212
|
+
: TOKENS_PER_CALL_FALLBACK[t] || 8_000;
|
|
213
|
+
|
|
214
|
+
tokens[provider].total += tokCount;
|
|
215
|
+
if (t) tokens[provider][t] += tokCount;
|
|
265
216
|
}
|
|
266
217
|
|
|
267
|
-
return {
|
|
218
|
+
return { calls, tokens };
|
|
268
219
|
}
|
|
269
220
|
|
|
270
221
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
* @returns {object} Status keyed by provider → tier → { pressure, weeklyPressure, state, calls, tokens, budget, weeklyBudget }
|
|
222
|
+
* Determine lean direction: which provider has been used more this session.
|
|
223
|
+
* Returns "claude", "openai", or "balanced".
|
|
275
224
|
*/
|
|
276
|
-
function
|
|
277
|
-
const
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
}
|
|
225
|
+
function sessionLean(calls) {
|
|
226
|
+
const c = calls.claude.total;
|
|
227
|
+
const o = calls.openai.total;
|
|
228
|
+
const total = c + o;
|
|
229
|
+
if (total === 0) return "balanced";
|
|
230
|
+
const claudeShare = c / total;
|
|
231
|
+
if (claudeShare > 0.65) return "claude";
|
|
232
|
+
if (claudeShare < 0.35) return "openai";
|
|
233
|
+
return "balanced";
|
|
234
|
+
}
|
|
313
235
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
}
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Exported: getProviderStatus()
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
327
239
|
|
|
328
|
-
|
|
329
|
-
|
|
240
|
+
/**
|
|
241
|
+
* Return session-level usage summary per provider/tier.
|
|
242
|
+
* No subscription quota math — just raw counts from the 5-hour window.
|
|
243
|
+
*
|
|
244
|
+
* @returns {object} { claude: { calls, tokens, lean }, openai: { calls, tokens, lean }, sessionLean }
|
|
245
|
+
*/
|
|
246
|
+
function getProviderStatus() {
|
|
247
|
+
const entries = readEntriesInWindow(FIVE_HOURS_MS);
|
|
248
|
+
const { calls, tokens } = aggregateUsage(entries);
|
|
249
|
+
const lean = sessionLean(calls);
|
|
330
250
|
|
|
331
|
-
return
|
|
251
|
+
return {
|
|
252
|
+
claude: { calls: calls.claude, tokens: tokens.claude },
|
|
253
|
+
openai: { calls: calls.openai, tokens: tokens.openai },
|
|
254
|
+
sessionLean: lean,
|
|
255
|
+
totalCalls: calls.claude.total + calls.openai.total,
|
|
256
|
+
};
|
|
332
257
|
}
|
|
333
258
|
|
|
334
259
|
// ---------------------------------------------------------------------------
|
|
@@ -336,7 +261,8 @@ function getProviderStatus() {
|
|
|
336
261
|
// ---------------------------------------------------------------------------
|
|
337
262
|
|
|
338
263
|
/**
|
|
339
|
-
* Recommend a provider for an incoming task
|
|
264
|
+
* Recommend a provider for an incoming task based on session imbalance,
|
|
265
|
+
* task characteristics, and profile bias.
|
|
340
266
|
*
|
|
341
267
|
* @param {object} taskProfile
|
|
342
268
|
* @param {string} taskProfile.tier - search | execute | think
|
|
@@ -367,34 +293,39 @@ function chooseProvider(taskProfile = {}) {
|
|
|
367
293
|
}
|
|
368
294
|
} catch {}
|
|
369
295
|
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
hot: 40,
|
|
374
|
-
throttled: 100,
|
|
375
|
-
};
|
|
296
|
+
const claudeCalls = status.claude.calls.total;
|
|
297
|
+
const openaiCalls = status.openai.calls.total;
|
|
298
|
+
const totalCalls = claudeCalls + openaiCalls;
|
|
376
299
|
|
|
377
300
|
const scores = {};
|
|
378
301
|
|
|
379
302
|
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
303
|
let score = 50;
|
|
385
304
|
|
|
305
|
+
// Context coupling: Claude handles tightly-coupled context better
|
|
386
306
|
if (provider === "claude") {
|
|
387
307
|
if (contextCoupling === "high") score += 20;
|
|
388
308
|
else if (contextCoupling === "medium") score += 10;
|
|
389
309
|
} else {
|
|
310
|
+
// OpenAI better for isolated tasks
|
|
390
311
|
if (isolation === "high") score += 20;
|
|
391
312
|
else if (isolation === "medium") score += 10;
|
|
392
313
|
}
|
|
393
314
|
|
|
394
|
-
|
|
315
|
+
// Session imbalance: reward the underused provider
|
|
316
|
+
if (totalCalls >= 4) {
|
|
317
|
+
const thisShare = provider === "claude"
|
|
318
|
+
? claudeCalls / totalCalls
|
|
319
|
+
: openaiCalls / totalCalls;
|
|
320
|
+
// If heavily overused (>65% share), penalise; if underused (<35%), reward
|
|
321
|
+
if (thisShare > 0.65) score -= 20;
|
|
322
|
+
else if (thisShare < 0.35) score += 15;
|
|
323
|
+
}
|
|
395
324
|
|
|
325
|
+
// Profile bias applies to openai (positive = prefer openai more)
|
|
396
326
|
if (provider === 'openai') score += profileBias;
|
|
397
327
|
|
|
328
|
+
// Penalise OpenAI for short tasks (startup overhead not worth it)
|
|
398
329
|
if (provider === "openai") {
|
|
399
330
|
let minTaskMs = 180_000;
|
|
400
331
|
try {
|
|
@@ -416,33 +347,9 @@ function chooseProvider(taskProfile = {}) {
|
|
|
416
347
|
}
|
|
417
348
|
}
|
|
418
349
|
|
|
419
|
-
if (
|
|
420
|
-
tierStatus.effectivePressure < 0.3 &&
|
|
421
|
-
otherTierStatus.effectivePressure > 0.5
|
|
422
|
-
) {
|
|
423
|
-
score += 20;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
350
|
scores[provider] = Math.round(score);
|
|
427
351
|
}
|
|
428
352
|
|
|
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
353
|
const winner = scores.claude >= scores.openai ? "claude" : "openai";
|
|
447
354
|
const loser = winner === "claude" ? "openai" : "claude";
|
|
448
355
|
|
|
@@ -451,12 +358,11 @@ function chooseProvider(taskProfile = {}) {
|
|
|
451
358
|
|
|
452
359
|
// Gate model by subscription tier
|
|
453
360
|
if (!isModelAvailable(model, winner, config)) {
|
|
454
|
-
|
|
455
|
-
model = downgraded;
|
|
361
|
+
model = downgradeModel(model, winner, config);
|
|
456
362
|
}
|
|
457
363
|
|
|
458
|
-
const
|
|
459
|
-
const
|
|
364
|
+
const winnerCalls = winner === "claude" ? claudeCalls : openaiCalls;
|
|
365
|
+
const loserCalls = winner === "claude" ? openaiCalls : claudeCalls;
|
|
460
366
|
|
|
461
367
|
let reasonParts = [];
|
|
462
368
|
if (winner === "claude" && contextCoupling !== "low") {
|
|
@@ -465,13 +371,8 @@ function chooseProvider(taskProfile = {}) {
|
|
|
465
371
|
if (winner === "openai" && isolation !== "low") {
|
|
466
372
|
reasonParts.push(`isolated task`);
|
|
467
373
|
}
|
|
468
|
-
|
|
469
|
-
|
|
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)}%)`);
|
|
374
|
+
if (totalCalls >= 4 && winnerCalls < loserCalls) {
|
|
375
|
+
reasonParts.push(`${winner} less used this session (${winnerCalls} vs ${loserCalls} calls)`);
|
|
475
376
|
}
|
|
476
377
|
if (!reasonParts.length) {
|
|
477
378
|
reasonParts.push(`${winner} scored ${scores[winner]} vs ${scores[loser]}`);
|
|
@@ -485,40 +386,6 @@ function chooseProvider(taskProfile = {}) {
|
|
|
485
386
|
};
|
|
486
387
|
}
|
|
487
388
|
|
|
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
389
|
// ---------------------------------------------------------------------------
|
|
523
390
|
// Exported: estimateTokensForTask(task)
|
|
524
391
|
// ---------------------------------------------------------------------------
|
|
@@ -570,90 +437,60 @@ function recordUsageEvent(event = {}) {
|
|
|
570
437
|
}
|
|
571
438
|
|
|
572
439
|
// ---------------------------------------------------------------------------
|
|
573
|
-
// CLI rendering
|
|
440
|
+
// CLI rendering
|
|
574
441
|
// ---------------------------------------------------------------------------
|
|
575
442
|
|
|
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
443
|
function formatTokens(n) {
|
|
590
444
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
591
445
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
592
446
|
return String(n);
|
|
593
447
|
}
|
|
594
448
|
|
|
595
|
-
function
|
|
449
|
+
function printStatus(status, rec) {
|
|
596
450
|
const LINE_WIDTH = 62;
|
|
597
451
|
const border = "═".repeat(LINE_WIDTH - 2);
|
|
598
|
-
const blank = " ".repeat(LINE_WIDTH - 4);
|
|
599
452
|
|
|
600
453
|
const h = (text) => {
|
|
601
454
|
const padded = ` ${text}`.padEnd(LINE_WIDTH - 4);
|
|
602
455
|
return `║ ${padded} ║`;
|
|
603
456
|
};
|
|
604
457
|
|
|
605
|
-
const
|
|
606
|
-
const s = status[
|
|
607
|
-
const
|
|
608
|
-
const
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
const
|
|
614
|
-
|
|
458
|
+
const providerRow = (provider) => {
|
|
459
|
+
const s = status[provider];
|
|
460
|
+
const total = s.calls.total;
|
|
461
|
+
const toks = formatTokens(s.tokens.total);
|
|
462
|
+
const breakdown = ["think", "execute", "search"]
|
|
463
|
+
.filter(t => s.calls[t] > 0)
|
|
464
|
+
.map(t => `${t}: ${s.calls[t]}`)
|
|
465
|
+
.join(", ");
|
|
466
|
+
const label = provider === "claude" ? "Claude" : "OpenAI";
|
|
467
|
+
const detail = breakdown ? ` (${breakdown})` : "";
|
|
468
|
+
return h(` ${label.padEnd(7)}: ${total} calls, ~${toks} tokens${detail}`);
|
|
615
469
|
};
|
|
616
470
|
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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";
|
|
471
|
+
const lean = status.sessionLean;
|
|
472
|
+
const leanText = lean === "balanced"
|
|
473
|
+
? "Balanced — either provider fine"
|
|
474
|
+
: `Leaning on ${lean} — consider routing more to ${lean === "claude" ? "OpenAI" : "Claude"}`;
|
|
628
475
|
|
|
629
|
-
const rec = chooseProvider({ tier: "execute", estimatedDurationMs: 300_000, isolation: "high", contextCoupling: "low" });
|
|
630
476
|
const recText = `Route execution to ${rec.provider === "openai" ? "OpenAI" : "Claude"}`;
|
|
631
477
|
|
|
632
478
|
const lines = [
|
|
633
479
|
`╔${border}╗`,
|
|
634
480
|
h(" Provider Balance Status"),
|
|
635
|
-
h(" (
|
|
481
|
+
h(" (session-relative, last 5 hours)"),
|
|
636
482
|
`╠${border}╣`,
|
|
637
|
-
h(
|
|
638
|
-
|
|
639
|
-
|
|
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"),
|
|
483
|
+
h("Session usage:"),
|
|
484
|
+
providerRow("claude"),
|
|
485
|
+
providerRow("openai"),
|
|
650
486
|
`╠${border}╣`,
|
|
487
|
+
h(`Session lean: ${leanText}`),
|
|
651
488
|
h(`Recommendation: ${recText}`),
|
|
652
489
|
h(`Reason: ${rec.reason}`),
|
|
653
490
|
`╚${border}╝`,
|
|
654
491
|
];
|
|
655
492
|
|
|
656
|
-
console.log(lines.
|
|
493
|
+
console.log(lines.join("\n"));
|
|
657
494
|
}
|
|
658
495
|
|
|
659
496
|
// ---------------------------------------------------------------------------
|
|
@@ -662,7 +499,8 @@ function printStatusTable(status) {
|
|
|
662
499
|
|
|
663
500
|
async function main() {
|
|
664
501
|
const status = getProviderStatus();
|
|
665
|
-
|
|
502
|
+
const rec = chooseProvider({ tier: "execute", estimatedDurationMs: 300_000, isolation: "high", contextCoupling: "low" });
|
|
503
|
+
printStatus(status, rec);
|
|
666
504
|
}
|
|
667
505
|
|
|
668
506
|
// Run as CLI only when invoked directly
|
|
@@ -676,4 +514,4 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
|
676
514
|
// ---------------------------------------------------------------------------
|
|
677
515
|
// Exports
|
|
678
516
|
// ---------------------------------------------------------------------------
|
|
679
|
-
export { getProviderStatus, chooseProvider, recordUsageEvent,
|
|
517
|
+
export { getProviderStatus, chooseProvider, recordUsageEvent, estimateTokensForTask, isModelAvailable, downgradeModel, classifyModel };
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { classifyRisk, extractPaths } from './risk-classifier.mjs';
|
|
4
4
|
import { resolveDependencies } from './plan-generator.mjs';
|
|
5
5
|
import { dispatchGptTask } from './gpt-work-dispatcher.mjs';
|
|
6
|
-
import { getProviderStatus, chooseProvider,
|
|
6
|
+
import { getProviderStatus, chooseProvider, estimateTokensForTask } from './budget-balancer.mjs';
|
|
7
7
|
import { recordDecision, recordOutcome } from './decision-ledger.mjs';
|
|
8
8
|
import { classifyTask, selectModelEffort } from './task-classifier.mjs';
|
|
9
9
|
import { getCapabilities, getDispatchConfig, recommendEffort } from './model-registry.mjs';
|
|
@@ -507,11 +507,12 @@ function routeTasks(tasks) {
|
|
|
507
507
|
// Use task-classifier for capability-aware model+effort selection
|
|
508
508
|
const profile = classifyTask(task.description, { files });
|
|
509
509
|
const estimatedTokens = estimateTokensForTask({ tier, effort: profile.effort, fileCount: files.length });
|
|
510
|
+
// Derive rough pressure from session call imbalance (0=balanced, 1=all one provider)
|
|
511
|
+
const totalSessionCalls = (status.claude?.calls?.total ?? 0) + (status.openai?.calls?.total ?? 0);
|
|
512
|
+
const claudeShare = totalSessionCalls > 0 ? (status.claude?.calls?.total ?? 0) / totalSessionCalls : 0.5;
|
|
513
|
+
const sessionImbalance = Math.abs(claudeShare - 0.5) * 2; // 0=balanced, 1=fully one-sided
|
|
510
514
|
const selection = selectModelEffort(profile, {
|
|
511
|
-
budgetPressure:
|
|
512
|
-
status.claude?.[tier]?.effectivePressure ?? 0,
|
|
513
|
-
status.openai?.[tier]?.effectivePressure ?? 0,
|
|
514
|
-
) / 100,
|
|
515
|
+
budgetPressure: sessionImbalance * 0.5, // scale to [0, 0.5] — soft signal only
|
|
515
516
|
estimatedTokens,
|
|
516
517
|
isIterating: (task.retryCount || 0) > 0,
|
|
517
518
|
});
|
|
@@ -1227,20 +1228,23 @@ async function orchestrate(utterance, opts = {}) {
|
|
|
1227
1228
|
saveManifest(manifest);
|
|
1228
1229
|
printDispatchTable(manifest);
|
|
1229
1230
|
|
|
1230
|
-
// Pre-dispatch
|
|
1231
|
+
// Pre-dispatch token estimate (indicative only — no subscription quota math)
|
|
1231
1232
|
const allTasks = manifest.waves.flatMap(w => w.tasks);
|
|
1232
|
-
const
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1233
|
+
const tokensByProvider = { claude: 0, openai: 0 };
|
|
1234
|
+
for (const task of allTasks) {
|
|
1235
|
+
const prov = task.provider || 'claude';
|
|
1236
|
+
if (tokensByProvider[prov] !== undefined) {
|
|
1237
|
+
tokensByProvider[prov] += estimateTokensForTask({ tier: task.tier, effort: task.effort, files: task.files });
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
console.log('\n--- Pre-dispatch token estimate ---');
|
|
1241
|
+
for (const [prov, toks] of Object.entries(tokensByProvider)) {
|
|
1242
|
+
if (toks > 0) {
|
|
1236
1243
|
const label = prov === 'claude' ? 'Claude' : 'OpenAI';
|
|
1237
|
-
console.log(` ${label}: ~${(
|
|
1238
|
-
if (est.wouldExceed) {
|
|
1239
|
-
console.log(` WARNING: Estimated usage EXCEEDS remaining ${label} budget!`);
|
|
1240
|
-
}
|
|
1244
|
+
console.log(` ${label}: ~${(toks / 1000).toFixed(1)}K tokens estimated`);
|
|
1241
1245
|
}
|
|
1242
|
-
console.log('');
|
|
1243
1246
|
}
|
|
1247
|
+
console.log('');
|
|
1244
1248
|
|
|
1245
1249
|
if (opts.dryRun) {
|
|
1246
1250
|
manifest.status = 'dry-run';
|
|
@@ -1251,7 +1255,6 @@ async function orchestrate(utterance, opts = {}) {
|
|
|
1251
1255
|
// Auto-confirm in non-interactive mode (--yes flag or piped input)
|
|
1252
1256
|
if (!opts.confirmed && !opts.yes) {
|
|
1253
1257
|
manifest.status = 'awaiting-confirmation';
|
|
1254
|
-
manifest.costEstimate = costEstimate;
|
|
1255
1258
|
saveManifest(refreshCounts(manifest));
|
|
1256
1259
|
console.log(`Dispatch plan ready. Execute with: node hooks/wave-orchestrator.mjs --resume ${manifest.manifestId}`);
|
|
1257
1260
|
console.log('Or pass --yes to skip confirmation.');
|
|
@@ -1275,18 +1278,18 @@ async function orchestrate(utterance, opts = {}) {
|
|
|
1275
1278
|
const wave = manifest.waves[i];
|
|
1276
1279
|
if (wave.status === 'completed') continue;
|
|
1277
1280
|
|
|
1278
|
-
// Per-wave
|
|
1279
|
-
const
|
|
1280
|
-
for (const
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
manifest.pauseReason = `spend_cap:${prov}`;
|
|
1285
|
-
saveManifest(refreshCounts(manifest));
|
|
1286
|
-
console.error(`Resume with: node hooks/wave-orchestrator.mjs --resume ${manifest.manifestId}`);
|
|
1287
|
-
return manifest;
|
|
1281
|
+
// Per-wave token estimate (informational — no quota enforcement since quota is unknowable)
|
|
1282
|
+
const waveTokens = { claude: 0, openai: 0 };
|
|
1283
|
+
for (const task of wave.tasks) {
|
|
1284
|
+
const prov = task.provider || 'claude';
|
|
1285
|
+
if (waveTokens[prov] !== undefined) {
|
|
1286
|
+
waveTokens[prov] += estimateTokensForTask({ tier: task.tier, effort: task.effort, files: task.files });
|
|
1288
1287
|
}
|
|
1289
1288
|
}
|
|
1289
|
+
const waveTotalK = Object.values(waveTokens).reduce((s, v) => s + v, 0) / 1000;
|
|
1290
|
+
if (waveTotalK > 0) {
|
|
1291
|
+
console.log(`[wave ${wave.waveId}] Estimated ~${waveTotalK.toFixed(1)}K tokens across providers`);
|
|
1292
|
+
}
|
|
1290
1293
|
|
|
1291
1294
|
wave.checkpoint = gitCheckpoint(manifest, wave.waveId);
|
|
1292
1295
|
saveManifest(refreshCounts(manifest));
|