dual-brain 0.1.8 → 0.1.9

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.
@@ -1241,107 +1241,22 @@ function detectInterruptedWork(sessions, cwd) {
1241
1241
  };
1242
1242
  }
1243
1243
 
1244
- // ─── Budget sparkline helpers ─────────────────────────────────────────────────
1245
-
1246
- /** Token quotas per plan (5-hour window aggregate). Mirrors src/decide.mjs SUB_QUOTAS. */
1247
- const _SPARKLINE_QUOTAS = {
1248
- claude: { '$20': 402_500, '$100': 1_638_000, '$200': 4_120_000 },
1249
- openai: { '$20': 400_000, '$100': 1_050_000, '$200': 1_900_000 },
1250
- };
1244
+ // ─── Provider status helpers ───────────────────────────────────────────────────
1251
1245
 
1252
1246
  const _PLAN_PRICE_MAP = {
1253
1247
  pro: '$20', max5: '$100', max20: '$200',
1254
1248
  plus: '$20', pro100: '$100', pro200: '$200',
1255
1249
  };
1256
1250
 
1257
- /**
1258
- * Read 5-hour usage entries from .dualbrain/usage/ logs.
1259
- * @param {string} cwd
1260
- * @returns {Array<object>}
1261
- */
1262
- function _readFiveHrUsage(cwd) {
1263
- const FIVE_HRS_MS = 5 * 60 * 60 * 1000;
1264
- const now = Date.now();
1265
- const cutoff = now - FIVE_HRS_MS;
1266
- const usageDir = join(cwd, '.dualbrain', 'usage');
1267
- const entries = [];
1268
- for (let i = 0; i <= 1; i++) {
1269
- const date = new Date(now - i * 86_400_000).toISOString().slice(0, 10);
1270
- const file = join(usageDir, `usage-${date}.jsonl`);
1271
- if (!existsSync(file)) continue;
1272
- let raw;
1273
- try { raw = readFileSync(file, 'utf8'); } catch { continue; }
1274
- for (const line of raw.split('\n')) {
1275
- if (!line.trim()) continue;
1276
- let rec;
1277
- try { rec = JSON.parse(line); } catch { continue; }
1278
- const ts = Date.parse(rec.timestamp);
1279
- if (!isNaN(ts) && ts >= cutoff) entries.push(rec);
1280
- }
1281
- }
1282
- return entries;
1283
- }
1284
-
1285
- /**
1286
- * Build a 5-char sparkline bar: \u2593\u2593\u2593\u2591\u2591 where \u2593 = used quota.
1287
- * @param {number} used tokens used
1288
- * @param {number} quota total token quota
1289
- * @returns {string}
1290
- */
1291
- function _sparkBar(used, quota) {
1292
- if (!quota || quota <= 0) return '\u2591\u2591\u2591\u2591\u2591';
1293
- const filled = Math.min(5, Math.round((used / quota) * 5));
1294
- return '\u2593'.repeat(filled) + '\u2591'.repeat(5 - filled);
1295
- }
1296
-
1297
- /**
1298
- * Return per-sub usage bar strings for a provider.
1299
- * @param {string} provKey 'claude' | 'openai'
1300
- * @param {object} profile
1301
- * @param {Array<object>} fiveHrEntries
1302
- * @returns {string} e.g. "$100 \u2593\u2593\u2591\u2591\u2591 $100 \u2593\u2591\u2591\u2591\u2591"
1303
- */
1304
- function _buildSubBars(provKey, profile, fiveHrEntries) {
1305
- const providerCfg = profile?.providers?.[provKey];
1306
- if (!providerCfg) return '';
1307
-
1308
- const rawSubs = providerCfg.subs?.length
1309
- ? providerCfg.subs
1310
- : providerCfg.plan
1311
- ? [{ plan: providerCfg.plan }]
1312
- : [];
1313
- if (rawSubs.length === 0) return '';
1314
-
1315
- let totalUsed = 0;
1316
- for (const e of fiveHrEntries) {
1317
- if (e.provider !== provKey) continue;
1318
- const inp = e.input_tokens ?? 0;
1319
- const out = e.output_tokens ?? 0;
1320
- totalUsed += inp + out > 0 ? inp + out : 8_000;
1321
- }
1322
- const perSub = rawSubs.length > 1 ? Math.round(totalUsed / rawSubs.length) : totalUsed;
1323
-
1324
- return rawSubs.map(s => {
1325
- const planKey = _PLAN_PRICE_MAP[s.plan] || s.plan || '$100';
1326
- const quota = _SPARKLINE_QUOTAS[provKey]?.[planKey] ?? 1_000_000;
1327
- const sparkline = _sparkBar(perSub, quota);
1328
- return `${planKey} ${sparkline}`;
1329
- }).join(' ');
1330
- }
1331
-
1332
1251
  /**
1333
1252
  * Build a provider status string for the dashboard status line.
1334
- * Shows per-sub usage sparkline bars: "\u25cf Claude $100 \u2593\u2593\u2591\u2591\u2591 \u25cf OpenAI $100 \u2593\u2591\u2591\u2591\u2591"
1253
+ * Shows: " Claude $100 OpenAI $100"
1335
1254
  * Uses ANSI color codes for the dots (no emoji width issues).
1336
1255
  */
1337
1256
  function buildProviderStatusLine(profile, auth) {
1338
- const GREEN = '\x1b[32m\u25cf\x1b[0m';
1339
- const RED = '\x1b[31m\u25cf\x1b[0m';
1257
+ const GREEN = '●';
1258
+ const RED = '●';
1340
1259
  const now = Date.now();
1341
- const cwd = process.cwd();
1342
-
1343
- let fiveHrEntries = [];
1344
- try { fiveHrEntries = _readFiveHrUsage(cwd); } catch {}
1345
1260
 
1346
1261
  function providerSegment(provKey, displayName) {
1347
1262
  const sub = profile?.providers?.[provKey];
@@ -1351,11 +1266,18 @@ function buildProviderStatusLine(profile, auth) {
1351
1266
  const expired = sub?.expiresAt && Date.parse(sub.expiresAt) < now;
1352
1267
  if (expired) return `${RED} ${displayName}: expired`;
1353
1268
 
1354
- const dot = GREEN;
1355
- const bars = _buildSubBars(provKey, profile, fiveHrEntries);
1356
- return bars
1357
- ? `${dot} ${displayName} ${bars}`
1358
- : `${dot} ${displayName}: connected`;
1269
+ const rawSubs = sub?.subs?.length
1270
+ ? sub.subs
1271
+ : sub?.plan
1272
+ ? [{ plan: sub.plan }]
1273
+ : [];
1274
+ const planLabel = rawSubs.length > 0
1275
+ ? rawSubs.map(s => _PLAN_PRICE_MAP[s.plan] || s.plan || '$100').join(' + ')
1276
+ : null;
1277
+
1278
+ return planLabel
1279
+ ? `${GREEN} ${displayName} ${planLabel}`
1280
+ : `${GREEN} ${displayName}: connected`;
1359
1281
  }
1360
1282
 
1361
1283
  const parts = [];
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * budget-balancer.mjs — Core budget balancing module for the Dual-Brain Orchestrator.
3
+ * budget-balancer.mjs — Session-level provider balance tracker for the Dual-Brain Orchestrator.
4
4
  *
5
- * Tracks rolling usage pressure across Claude and OpenAI providers and recommends
6
- * which provider should handle incoming work.
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() → current pressure per provider/tier
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
- // Exported: getProviderStatus()
173
+ // Session usage aggregation
221
174
  // ---------------------------------------------------------------------------
222
175
 
223
176
  /**
224
- * Sum actual tokens from usage entries for a provider/tier.
225
- * Uses real input_tokens + output_tokens when available, falls back to estimate.
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 sumTokens(entries) {
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 realTokenCalls = {
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 || !tier || !tokens[provider] || tokens[provider][tier] === undefined) continue;
202
+ if (!provider || !calls[provider]) continue;
203
+ const t = (tier && calls[provider][tier] !== undefined) ? tier : null;
254
204
 
255
- calls[provider][tier]++;
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
- 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
- }
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 { tokens, calls, realTokenCalls };
218
+ return { calls, tokens };
268
219
  }
269
220
 
270
221
  /**
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 }
222
+ * Determine lean direction: which provider has been used more this session.
223
+ * Returns "claude", "openai", or "balanced".
275
224
  */
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
- }
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
- 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
- }
236
+ // ---------------------------------------------------------------------------
237
+ // Exported: getProviderStatus()
238
+ // ---------------------------------------------------------------------------
327
239
 
328
- status[provider]._label = budgets[provider].label;
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 status;
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 PRESSURE_PENALTY = {
371
- healthy: 0,
372
- warm: 15,
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
- score -= PRESSURE_PENALTY[tierStatus.state] ?? 0;
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
- const downgraded = downgradeModel(model, winner, config);
455
- model = downgraded;
361
+ model = downgradeModel(model, winner, config);
456
362
  }
457
363
 
458
- const ws = status[winner]?.[tier] || {};
459
- const ls = status[loser]?.[tier] || {};
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
- 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)}%)`);
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 helpers
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 printStatusTable(status) {
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 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);
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 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";
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(" (token-based, real limits)"),
481
+ h(" (session-relative, last 5 hours)"),
636
482
  `╠${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"),
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.filter(Boolean).join("\n"));
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
- printStatusTable(status);
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, getSubscriptionBudgets, estimateWaveCost, estimateTokensForTask, isModelAvailable, downgradeModel, SUBSCRIPTION_TIERS };
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, estimateWaveCost, estimateTokensForTask } from './budget-balancer.mjs';
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: Math.max(
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 spend estimate
1231
+ // Pre-dispatch token estimate (indicative only — no subscription quota math)
1231
1232
  const allTasks = manifest.waves.flatMap(w => w.tasks);
1232
- const costEstimate = estimateWaveCost(allTasks);
1233
- if (costEstimate.impact.claude || costEstimate.impact.openai) {
1234
- console.log('\n--- Pre-dispatch cost estimate ---');
1235
- for (const [prov, est] of Object.entries(costEstimate.impact)) {
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}: ~${(est.estimatedTokens / 1000).toFixed(1)}K tokens (${est.pctOfBudget}% of 5hr budget, ${(est.remaining / 1000).toFixed(1)}K remaining)`);
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 spend check
1279
- const waveCost = estimateWaveCost(wave.tasks);
1280
- for (const [prov, est] of Object.entries(waveCost.impact)) {
1281
- if (est.wouldExceed) {
1282
- console.error(`[SPEND CAP] Wave ${wave.waveId} would exceed ${prov} budget (${(est.estimatedTokens / 1000).toFixed(1)}K estimated, ${(est.remaining / 1000).toFixed(1)}K remaining). Pausing.`);
1283
- manifest.status = 'paused';
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
package/src/decide.mjs CHANGED
@@ -6,25 +6,19 @@
6
6
  * to use and explains why in one sentence.
7
7
  *
8
8
  * Exports: decideRoute, getModelCapabilities, getAvailableModels,
9
- * estimateBudgetPressure, shouldDualBrain, explainDecision, getFailoverOrder,
10
- * getOptimalSub
9
+ * estimateBudgetPressure, shouldDualBrain, explainDecision, getFailoverOrder
11
10
  *
12
11
  * CLI: node src/decide.mjs --profile /path/to/profile.json \
13
12
  * --detection '{"intent":"edit","risk":"low","complexity":"simple","effort":"medium","tier":"execute"}'
14
13
  */
15
14
 
16
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs';
15
+ import { readFileSync } from 'fs';
17
16
  import { join, dirname } from 'path';
18
17
  import { fileURLToPath } from 'url';
19
18
  import { getProviderScore, checkCooldown } from './health.mjs';
20
19
 
21
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
21
  const WORKSPACE = join(__dirname, '..');
23
- const USAGE_DIR = join(WORKSPACE, '.dualbrain', 'usage');
24
- const AUDIT_DIR = join(WORKSPACE, '.dualbrain', 'audit');
25
- const FIVE_HRS_MS = 5 * 60 * 60 * 1000;
26
- const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
27
-
28
22
  // ─── Subscription token quotas (mirrors budget-balancer.mjs) ─────────────────
29
23
 
30
24
  /** Per-plan aggregate token budgets for the two rolling windows. */
@@ -133,9 +127,6 @@ const OPENAI_MODELS_BY_PLAN = {
133
127
  '$200': ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'],
134
128
  };
135
129
 
136
- // Token fallback estimates per tier (no real usage data)
137
- const TOKEN_FALLBACK = { search: 2_500, execute: 8_000, think: 15_000 };
138
-
139
130
  // ─── Exported: getModelCapabilities ──────────────────────────────────────────
140
131
 
141
132
  /**
@@ -489,217 +480,6 @@ export function parsePreferences(preferences) {
489
480
  return signals;
490
481
  }
491
482
 
492
- // ─── Exported: getOptimalSub ─────────────────────────────────────────────────
493
-
494
- /**
495
- * Read usage log entries within a given window (ms).
496
- * Scans .dualbrain/usage/usage-YYYY-MM-DD.jsonl files.
497
- * @param {number} windowMs
498
- * @returns {Array<object>}
499
- */
500
- function _readUsageInWindow(windowMs) {
501
- const now = Date.now();
502
- const cutoff = now - windowMs;
503
- const entries = [];
504
- const daysBack = Math.ceil(windowMs / 86_400_000) + 1;
505
- const seen = new Set();
506
- for (let i = 0; i < daysBack; i++) {
507
- const date = new Date(now - i * 86_400_000).toISOString().slice(0, 10);
508
- if (seen.has(date)) continue;
509
- seen.add(date);
510
- const file = join(USAGE_DIR, `usage-${date}.jsonl`);
511
- if (!existsSync(file)) continue;
512
- let raw;
513
- try { raw = readFileSync(file, 'utf8'); } catch { continue; }
514
- for (const line of raw.split('\n')) {
515
- if (!line.trim()) continue;
516
- let rec;
517
- try { rec = JSON.parse(line); } catch { continue; }
518
- const ts = Date.parse(rec.timestamp);
519
- if (!isNaN(ts) && ts >= cutoff) entries.push(rec);
520
- }
521
- }
522
- return entries;
523
- }
524
-
525
- /**
526
- * Sum tokens used by a specific provider from usage entries.
527
- * @param {Array<object>} entries
528
- * @param {string} provider
529
- * @returns {number}
530
- */
531
- function _sumProviderTokens(entries, provider) {
532
- let total = 0;
533
- for (const e of entries) {
534
- if (e.provider !== provider) continue;
535
- const inp = e.input_tokens ?? 0;
536
- const out = e.output_tokens ?? 0;
537
- total += inp + out > 0 ? inp + out : 8_000; // fallback estimate
538
- }
539
- return total;
540
- }
541
-
542
- /**
543
- * Log an autopilot routing decision to .dualbrain/audit/budget-autopilot.jsonl.
544
- * @param {object} entry
545
- */
546
- function _logAutopilot(entry) {
547
- try {
548
- if (!existsSync(AUDIT_DIR)) mkdirSync(AUDIT_DIR, { recursive: true });
549
- const line = JSON.stringify({ ts: new Date().toISOString(), ...entry });
550
- appendFileSync(join(AUDIT_DIR, 'budget-autopilot.jsonl'), line + '\n', 'utf8');
551
- } catch {
552
- // Non-fatal: logging should never block routing
553
- }
554
- }
555
-
556
- /**
557
- * Given a provider and profile, pick the subscription with the most remaining
558
- * quota — use-it-or-lose-it scoring: `remaining * (1 / hoursUntilReset)`.
559
- *
560
- * Handles single-sub and multi-sub profiles uniformly. When only one sub
561
- * exists, returns it immediately (no log written for single-sub case since
562
- * there is no real routing decision to make).
563
- *
564
- * @param {'claude'|'openai'} provider
565
- * @param {'search'|'execute'|'think'} tier
566
- * @param {object} profile
567
- * @returns {{
568
- * subIndex: number,
569
- * plan: string,
570
- * label: string|null,
571
- * fiveHrUsed: number,
572
- * weeklyUsed: number,
573
- * fiveHrQuota: number,
574
- * weeklyQuota: number,
575
- * fiveHrRemaining: number,
576
- * weeklyRemaining: number,
577
- * score: number,
578
- * reason: string,
579
- * }|null}
580
- */
581
- export function getOptimalSub(provider, tier, profile) {
582
- const providerCfg = profile?.providers?.[provider];
583
- if (!providerCfg) return null;
584
-
585
- // Normalise to an array of subs
586
- const subs = providerCfg.subs?.length
587
- ? providerCfg.subs
588
- : providerCfg.plan
589
- ? [{ plan: providerCfg.plan, label: providerCfg.label || null }]
590
- : [];
591
-
592
- if (subs.length === 0) return null;
593
-
594
- // Short-circuit for single-sub: skip usage read and logging overhead
595
- if (subs.length === 1) {
596
- const s = subs[0];
597
- const plan = s.plan || '$100';
598
- const quotas = SUB_QUOTAS[provider]?.[plan] ?? { fiveHr: 1_000_000, weekly: 7_000_000 };
599
- return {
600
- subIndex: 0,
601
- plan,
602
- label: s.label ?? null,
603
- fiveHrUsed: 0,
604
- weeklyUsed: 0,
605
- fiveHrQuota: quotas.fiveHr,
606
- weeklyQuota: quotas.weekly,
607
- fiveHrRemaining: quotas.fiveHr,
608
- weeklyRemaining: quotas.weekly,
609
- score: quotas.fiveHr,
610
- reason: 'only sub available',
611
- };
612
- }
613
-
614
- // Multi-sub: read usage logs once for both windows
615
- const fiveHrEntries = _readUsageInWindow(FIVE_HRS_MS);
616
- const weeklyEntries = _readUsageInWindow(SEVEN_DAY_MS);
617
-
618
- // We cannot distinguish sub-level usage from the log (no subIndex field),
619
- // so we divide total provider usage evenly across subs as a best-effort proxy.
620
- const fiveHrTotal = _sumProviderTokens(fiveHrEntries, provider);
621
- const weeklyTotal = _sumProviderTokens(weeklyEntries, provider);
622
- const perSubFiveHr = Math.round(fiveHrTotal / subs.length);
623
- const perSubWeekly = Math.round(weeklyTotal / subs.length);
624
-
625
- // Score each sub
626
- const now = Date.now();
627
- const fiveHrResetMs = FIVE_HRS_MS; // window always resets from now in a rolling sense
628
- const weeklyResetMs = SEVEN_DAY_MS;
629
-
630
- let best = null;
631
- let bestScore = -Infinity;
632
- const alternatives = [];
633
-
634
- subs.forEach((s, i) => {
635
- const plan = s.plan || '$100';
636
- const quotas = SUB_QUOTAS[provider]?.[plan] ?? { fiveHr: 1_000_000, weekly: 7_000_000 };
637
-
638
- const fiveHrRemaining = Math.max(0, quotas.fiveHr - perSubFiveHr);
639
- const weeklyRemaining = Math.max(0, quotas.weekly - perSubWeekly);
640
-
641
- // Binding constraint: whichever window is tighter
642
- const remaining = Math.min(fiveHrRemaining, weeklyRemaining);
643
-
644
- // Hours until the tighter window resets (rolling → effectively "now + window")
645
- const hoursUntilReset = fiveHrRemaining <= weeklyRemaining
646
- ? (fiveHrResetMs / 3_600_000)
647
- : (weeklyResetMs / 3_600_000);
648
-
649
- // Use-it-or-lose-it: higher score = more remaining + resets sooner
650
- const score = hoursUntilReset > 0 ? remaining * (1 / hoursUntilReset) : remaining;
651
-
652
- const pctRemaining = quotas.fiveHr > 0
653
- ? Math.round((fiveHrRemaining / quotas.fiveHr) * 100)
654
- : 100;
655
- const resets = fiveHrRemaining <= weeklyRemaining ? '5h' : '7d';
656
- const reason = `${pctRemaining}% remaining, resets in ${resets === '5h' ? 5 : 168}h`;
657
-
658
- const info = {
659
- subIndex: i,
660
- plan,
661
- label: s.label ?? null,
662
- fiveHrUsed: perSubFiveHr,
663
- weeklyUsed: perSubWeekly,
664
- fiveHrQuota: quotas.fiveHr,
665
- weeklyQuota: quotas.weekly,
666
- fiveHrRemaining,
667
- weeklyRemaining,
668
- score,
669
- reason,
670
- };
671
-
672
- alternatives.push(info);
673
-
674
- if (score > bestScore) {
675
- bestScore = score;
676
- best = info;
677
- }
678
- });
679
-
680
- // Log the autopilot decision
681
- if (best) {
682
- _logAutopilot({
683
- provider,
684
- tier,
685
- subIndex: best.subIndex,
686
- plan: best.plan,
687
- label: best.label,
688
- reason: best.reason,
689
- alternatives: alternatives.map(a => ({
690
- subIndex: a.subIndex,
691
- plan: a.plan,
692
- label: a.label,
693
- score: Math.round(a.score),
694
- fiveHrRemaining: a.fiveHrRemaining,
695
- weeklyRemaining: a.weeklyRemaining,
696
- })),
697
- });
698
- }
699
-
700
- return best;
701
- }
702
-
703
483
  // ─── Internal: safety floor for critical-risk tasks ───────────────────────────
704
484
 
705
485
  /**
@@ -812,9 +592,6 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
812
592
  );
813
593
  const degradedDualBrain = !!(dual && detection.designImpact && !hasBothProviders);
814
594
 
815
- // Budget autopilot: pick optimal sub when multiple subs exist for chosen provider
816
- const optimalSub = getOptimalSub(provider, tier, profile);
817
-
818
595
  const decision = {
819
596
  provider,
820
597
  model,
@@ -822,8 +599,6 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
822
599
  tier,
823
600
  dualBrain: dual,
824
601
  ...(degradedDualBrain && { degradedDualBrain: true }),
825
- ...(optimalSub && optimalSub.label != null && { subLabel: optimalSub.label }),
826
- ...(optimalSub && { subIndex: optimalSub.subIndex }),
827
602
  modes,
828
603
  sandbox,
829
604
  explanation: '',