dual-brain 0.2.25 → 0.2.27

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.
@@ -1094,6 +1094,18 @@ async function cmdStatus(args = []) {
1094
1094
  }
1095
1095
  }
1096
1096
  } catch { /* network unavailable — skip */ }
1097
+
1098
+ // Show top recommendation if available
1099
+ try {
1100
+ const { getTopRecommendation } = await import('../src/recommendations.mjs');
1101
+ const rec = getTopRecommendation(process.cwd());
1102
+ if (rec) {
1103
+ console.log('');
1104
+ console.log(` \x1b[33m💡 ${rec.title}\x1b[0m`);
1105
+ console.log(` ${rec.description}`);
1106
+ if (rec.action) console.log(` → ${rec.action}`);
1107
+ }
1108
+ } catch { /* non-blocking */ }
1097
1109
  }
1098
1110
 
1099
1111
  // ─── cmdHot / cmdCool ─────────────────────────────────────────────────────────
@@ -6532,6 +6544,18 @@ async function main() {
6532
6544
  }
6533
6545
 
6534
6546
  if (cmd === 'init') {
6547
+ // init --reconfigure: run setup-flow reconfiguration
6548
+ if (args.includes('--reconfigure')) {
6549
+ try {
6550
+ const { runSetup } = await import('../src/setup-flow.mjs');
6551
+ await runSetup(process.cwd(), { reconfigure: true });
6552
+ } catch (e) {
6553
+ console.error('setup-flow.mjs not available — skipping reconfigure');
6554
+ if (process.env.DEBUG) console.error(e.message);
6555
+ }
6556
+ return;
6557
+ }
6558
+
6535
6559
  // init --reset: clear credentials.json and re-run wizard
6536
6560
  if (args.includes('--reset')) {
6537
6561
  const cwd = process.cwd();
@@ -6593,6 +6617,23 @@ async function main() {
6593
6617
  return;
6594
6618
  }
6595
6619
 
6620
+ if (cmd === 'setup') {
6621
+ const { runSetup } = await import('../src/setup-flow.mjs');
6622
+ await runSetup(process.cwd(), { reconfigure: args.includes('--reconfigure') });
6623
+ return;
6624
+ }
6625
+
6626
+ if (cmd === 'advice' || cmd === 'recommend') {
6627
+ const { generateRecommendations, formatRecommendations } = await import('../src/recommendations.mjs');
6628
+ const recs = generateRecommendations(process.cwd());
6629
+ if (recs.length === 0) {
6630
+ console.log(' No recommendations yet. Need 20+ dispatches to generate advice.');
6631
+ } else {
6632
+ console.log(formatRecommendations(recs));
6633
+ }
6634
+ return;
6635
+ }
6636
+
6596
6637
  // One-shot commands — run and exit
6597
6638
  if (cmd === 'install') {
6598
6639
  if (args.includes('--global')) { await installGlobal(); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,7 +49,11 @@
49
49
  "./governance": "./src/governance.mjs",
50
50
  "./context-intel": "./src/context-intel.mjs",
51
51
  "./signal": "./src/signal.mjs",
52
- "./routing-advisor": "./src/routing-advisor.mjs"
52
+ "./routing-advisor": "./src/routing-advisor.mjs",
53
+ "./subscription": "./src/subscription.mjs",
54
+ "./recommendations": "./src/recommendations.mjs",
55
+ "./setup-flow": "./src/setup-flow.mjs",
56
+ "./self-correct": "./src/self-correct.mjs"
53
57
  },
54
58
  "keywords": [
55
59
  "claude-code",
@@ -138,6 +142,10 @@
138
142
  "src/context-intel.mjs",
139
143
  "src/signal.mjs",
140
144
  "src/routing-advisor.mjs",
145
+ "src/subscription.mjs",
146
+ "src/recommendations.mjs",
147
+ "src/setup-flow.mjs",
148
+ "src/self-correct.mjs",
141
149
  "bin/*.mjs",
142
150
  "hooks/enforce-tier.mjs",
143
151
  "hooks/cost-logger.mjs",
package/src/decide.mjs CHANGED
@@ -49,6 +49,28 @@ function _loadModelRegistry() {
49
49
  // Kick off the load immediately so it is ready before the first routing call.
50
50
  _loadModelRegistry();
51
51
 
52
+ // ─── Routing Advisor (optional, lazy-loaded) ──────────────────────────────────
53
+
54
+ /**
55
+ * Cached reference to routing-advisor.mjs exports. Populated on first import.
56
+ * Remains null if unavailable — decideRoute skips advisor consultation in that case.
57
+ */
58
+ let routingAdvisor = null;
59
+ let _advisorLoadAttempted = false;
60
+
61
+ function _loadRoutingAdvisor() {
62
+ if (_advisorLoadAttempted) return;
63
+ _advisorLoadAttempted = true;
64
+ import('./routing-advisor.mjs').then(mod => {
65
+ routingAdvisor = mod;
66
+ }).catch(() => {
67
+ // routing-advisor.mjs unavailable — skip learned routing
68
+ });
69
+ }
70
+
71
+ // Kick off the load immediately so it is ready before the first routing call.
72
+ _loadRoutingAdvisor();
73
+
52
74
  // ─── Work Styles ─────────────────────────────────────────────────────────────
53
75
 
54
76
  /**
@@ -890,6 +912,28 @@ export function decideRoute({ profile = {}, detection = {}, cwd, thinkResult, se
890
912
  // If the pipeline changed the model (downgrade/bias/floor), resolve the new short name to a full ID.
891
913
  model = toFullModelId(model, provider, tier);
892
914
 
915
+ // ── Routing advisor: consult learned EMA model for this task type ─────────
916
+ // Non-blocking: only overrides when advisor has enough observations (confidence > 0.3).
917
+ // Uses short model names; advisor only covers Claude models (haiku/sonnet/opus).
918
+ let _advisorOverride = null;
919
+ if (routingAdvisor && provider === 'claude') {
920
+ try {
921
+ const advice = routingAdvisor.adviseModel(
922
+ { intent: detection.intent, tier, risk: detection.risk },
923
+ cwd
924
+ );
925
+ if (advice.confidence > 0.3 && advice.model) {
926
+ const advisorShort = advice.model; // advisor returns short names
927
+ const previousModel = toShortName(model, 'claude');
928
+ if (advisorShort !== previousModel && available.claude.includes(advisorShort)) {
929
+ const overrideFullId = toFullModelId(advisorShort, 'claude', tier);
930
+ _advisorOverride = { from: model, to: overrideFullId, reason: advice.reason, explored: advice.explored };
931
+ model = overrideFullId;
932
+ }
933
+ }
934
+ } catch { /* non-blocking */ }
935
+ }
936
+
893
937
  // ── Challenger / dual-brain decision ─────────────────────────────────────
894
938
  const hasBothProviders = !!(
895
939
  profile?.providers?.claude?.enabled &&
@@ -938,6 +982,7 @@ export function decideRoute({ profile = {}, detection = {}, cwd, thinkResult, se
938
982
  explanation: '',
939
983
  _healthScores: healthScores,
940
984
  _workStyle: workStyle,
985
+ ...(_advisorOverride && { _advisorOverride }),
941
986
  };
942
987
 
943
988
  decision.explanation = explainDecision(decision, detection, profileWithEffectiveBias);
package/src/dispatch.mjs CHANGED
@@ -1181,6 +1181,29 @@ async function dispatch(input = {}) {
1181
1181
  const { recordDispatchOutcome } = await import('./outcome.mjs');
1182
1182
  recordDispatchOutcome(input, nativeResult);
1183
1183
  } catch { /* never block */ }
1184
+
1185
+ // ── Self-correction: intelligent retry after failover exhaustion ──────────
1186
+ if (!success) {
1187
+ const attemptNumber = input._retryAttempt || 1;
1188
+ try {
1189
+ const { shouldRetry } = await import('./self-correct.mjs');
1190
+ const retry = shouldRetry(nativeResult, decision, attemptNumber);
1191
+ if (retry.retry && retry.decision) {
1192
+ if (verbose) process.stderr.write(`[dual-brain] self-correct: ${retry.strategy} (attempt ${attemptNumber + 1}, reason: ${retry.reason})\n`);
1193
+ return dispatch({
1194
+ ...input,
1195
+ decision: retry.decision,
1196
+ _retryAttempt: attemptNumber + 1,
1197
+ _skipPreDispatchThink: retry.strategy !== 'rethink',
1198
+ _skipRelatedContext: true,
1199
+ });
1200
+ } else if (verbose) {
1201
+ process.stderr.write(`[dual-brain] self-correct: giving up (${retry.reason})\n`);
1202
+ }
1203
+ } catch { /* non-blocking — if self-correct fails, return original failure */ }
1204
+ }
1205
+ // ── End self-correction ───────────────────────────────────────────────────
1206
+
1184
1207
  return nativeResult;
1185
1208
  }
1186
1209
 
@@ -1303,6 +1326,29 @@ async function dispatch(input = {}) {
1303
1326
  const { recordDispatchOutcome } = await import('./outcome.mjs');
1304
1327
  recordDispatchOutcome(input, subResult);
1305
1328
  } catch { /* never block */ }
1329
+
1330
+ // ── Self-correction: intelligent retry after failover exhaustion ──────────
1331
+ if (!success) {
1332
+ const attemptNumber = input._retryAttempt || 1;
1333
+ try {
1334
+ const { shouldRetry } = await import('./self-correct.mjs');
1335
+ const retry = shouldRetry(subResult, decision, attemptNumber);
1336
+ if (retry.retry && retry.decision) {
1337
+ if (verbose) process.stderr.write(`[dual-brain] self-correct: ${retry.strategy} (attempt ${attemptNumber + 1}, reason: ${retry.reason})\n`);
1338
+ return dispatch({
1339
+ ...input,
1340
+ decision: retry.decision,
1341
+ _retryAttempt: attemptNumber + 1,
1342
+ _skipPreDispatchThink: retry.strategy !== 'rethink',
1343
+ _skipRelatedContext: true,
1344
+ });
1345
+ } else if (verbose) {
1346
+ process.stderr.write(`[dual-brain] self-correct: giving up (${retry.reason})\n`);
1347
+ }
1348
+ } catch { /* non-blocking — if self-correct fails, return original failure */ }
1349
+ }
1350
+ // ── End self-correction ───────────────────────────────────────────────────
1351
+
1306
1352
  return subResult;
1307
1353
  }
1308
1354
 
package/src/outcome.mjs CHANGED
@@ -45,6 +45,16 @@ function last7DaysFiles(cwd) {
45
45
  return files;
46
46
  }
47
47
 
48
+ const INTENT_KEYWORDS = ['implement', 'fix', 'refactor', 'review', 'search', 'test'];
49
+
50
+ function deriveIntent(prompt, tier) {
51
+ const lower = (prompt ?? '').toLowerCase();
52
+ for (const kw of INTENT_KEYWORDS) {
53
+ if (lower.includes(kw)) return kw;
54
+ }
55
+ return tier ?? 'execute';
56
+ }
57
+
48
58
  export function recordDispatchOutcome(dispatchInput, result) {
49
59
  try {
50
60
  const cwd = dispatchInput.cwd ?? process.cwd();
@@ -69,6 +79,24 @@ export function recordDispatchOutcome(dispatchInput, result) {
69
79
 
70
80
  const filePath = join(outcomesDir(cwd), `outcome_${id}.json`);
71
81
  writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8');
82
+
83
+ // Score the outcome for the routing advisor (non-blocking)
84
+ try {
85
+ import('./signal.mjs').then(({ scoreOutcome }) =>
86
+ import('./routing-advisor.mjs').then(({ recordReward }) => {
87
+ const scored = scoreOutcome(record);
88
+ const intent = deriveIntent(record.prompt, record.tier);
89
+ const cellKey = `${record.tier}:${intent}`;
90
+ // Normalize full model ID to short name for the advisor cell
91
+ const modelId = record.model ?? 'sonnet';
92
+ const shortModel = /haiku/i.test(modelId) ? 'haiku'
93
+ : /opus/i.test(modelId) ? 'opus'
94
+ : 'sonnet';
95
+ recordReward(cellKey, shortModel, scored.reward, cwd);
96
+ })
97
+ ).catch(() => { /* non-blocking */ });
98
+ } catch { /* non-blocking */ }
99
+
72
100
  return record;
73
101
  } catch {
74
102
  return null;
@@ -0,0 +1,291 @@
1
+ // recommendations.mjs — Proactive settings recommendations from HEAD
2
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ function readJSON(path) {
6
+ try {
7
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
8
+ } catch { return null; }
9
+ }
10
+
11
+ function dbPath(cwd, ...parts) {
12
+ return join(cwd || process.cwd(), '.dualbrain', ...parts);
13
+ }
14
+
15
+ // ─── Signal loaders ───────────────────────────────────────────────────────────
16
+
17
+ function loadRoutingState(cwd) { return readJSON(dbPath(cwd, 'routing-state.json')) || {}; }
18
+ function loadThinkMetrics(cwd) { return readJSON(dbPath(cwd, 'think-metrics.json')) || {}; }
19
+ function loadGovernance(cwd) { return readJSON(dbPath(cwd, 'governance-state.json')) || {}; }
20
+ function loadSubscription(cwd) { return readJSON(dbPath(cwd, 'subscription.json')) || {}; }
21
+
22
+ function loadOutcomes(cwd) {
23
+ try {
24
+ const dir = dbPath(cwd, 'outcomes');
25
+ if (!existsSync(dir)) return [];
26
+ return readdirSync(dir)
27
+ .filter(f => f.endsWith('.json'))
28
+ .map(f => readJSON(join(dir, f)))
29
+ .filter(Boolean);
30
+ } catch { return []; }
31
+ }
32
+
33
+ // ─── Recommendation rules ─────────────────────────────────────────────────────
34
+
35
+ function thinkROI(metrics) {
36
+ const { hitRate, totalHits, totalMisses, avgTokensSaved } = metrics;
37
+ if (hitRate == null) return null;
38
+ const observations = (totalHits || 0) + (totalMisses || 0);
39
+ if (observations < 5) return null;
40
+
41
+ if (hitRate < 0.4) {
42
+ return {
43
+ id: 'think-roi-low',
44
+ priority: 'medium',
45
+ category: 'efficiency',
46
+ title: 'Think agent underperforming',
47
+ description: `${Math.round(hitRate * 100)}% hit rate — think preflight isn't saving tokens.`,
48
+ action: 'Consider disabling think triggers or narrowing trigger conditions.',
49
+ impact: 'Reduce latency and token overhead on low-complexity tasks.',
50
+ };
51
+ }
52
+ if (hitRate > 0.7) {
53
+ const savings = avgTokensSaved ? `~${Math.round(avgTokensSaved / 1000)}K tokens` : 'tokens';
54
+ return {
55
+ id: 'think-roi-high',
56
+ priority: 'medium',
57
+ category: 'efficiency',
58
+ title: 'Think agent performing well',
59
+ description: `${Math.round(hitRate * 100)}% hit rate, saving ${savings} per refined task.`,
60
+ action: 'No action needed, keep enabled.',
61
+ impact: 'Sustained token efficiency on complex dispatches.',
62
+ };
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function modelMismatch(routingState) {
68
+ const recs = [];
69
+ for (const [taskType, models] of Object.entries(routingState)) {
70
+ for (const [model, stats] of Object.entries(models)) {
71
+ const { ema, observations } = stats || {};
72
+ if (observations >= 10 && ema < 0.4) {
73
+ recs.push({
74
+ id: `model-mismatch-low-${taskType}-${model}`,
75
+ priority: 'high',
76
+ category: 'routing',
77
+ title: 'Model mismatch detected',
78
+ description: `${model} scores ${ema.toFixed(2)} on ${taskType} tasks.`,
79
+ action: `Route ${taskType} tasks away from ${model}.`,
80
+ impact: 'Better task outcomes by avoiding poor model-task fit.',
81
+ });
82
+ } else if (observations >= 10 && ema > 0.8 && (model === 'haiku' || model.includes('haiku'))) {
83
+ recs.push({
84
+ id: `model-mismatch-promote-${taskType}-${model}`,
85
+ priority: 'high',
86
+ category: 'routing',
87
+ title: 'Cheap model excelling',
88
+ description: `${model} scores ${ema.toFixed(2)} on ${taskType} tasks.`,
89
+ action: `Promote ${model} as default for ${taskType} tier — quality without the cost.`,
90
+ impact: 'Same output quality at lower token cost.',
91
+ });
92
+ }
93
+ }
94
+ }
95
+ return recs;
96
+ }
97
+
98
+ function budgetTrajectory(governance) {
99
+ const { budgetUsedPct, sessionProgressPct, workStyle } = governance;
100
+ if (budgetUsedPct == null) return null;
101
+
102
+ if (budgetUsedPct > 60 && sessionProgressPct != null && sessionProgressPct < 50) {
103
+ return {
104
+ id: 'budget-critical',
105
+ priority: 'high',
106
+ category: 'budget',
107
+ title: 'Budget burning fast',
108
+ description: `${Math.round(budgetUsedPct)}% budget used, ~${Math.round(sessionProgressPct)}% through estimated work.`,
109
+ action: 'Switch to cost-saver mode: `dual-brain config set workStyle cost-saver`.',
110
+ impact: 'Avoid hitting budget ceiling before work completes.',
111
+ };
112
+ }
113
+ if (budgetUsedPct < 20 && workStyle === 'cost-saver') {
114
+ return {
115
+ id: 'budget-underutilized',
116
+ priority: 'low',
117
+ category: 'budget',
118
+ title: 'Budget well under control',
119
+ description: `Only ${Math.round(budgetUsedPct)}% budget used in cost-saver mode.`,
120
+ action: 'You could afford quality-first mode for this session.',
121
+ impact: 'Better output quality while staying within budget.',
122
+ };
123
+ }
124
+ return null;
125
+ }
126
+
127
+ function failurePattern(outcomes) {
128
+ if (!outcomes.length) return null;
129
+ const recent = outcomes.slice(-20);
130
+ const failures = recent.filter(o => o.success === false || (o.reward != null && o.reward < 0.3));
131
+ const failRate = failures.length / recent.length;
132
+
133
+ if (failRate > 0.3) {
134
+ const modelCounts = {};
135
+ failures.forEach(o => { if (o.model) modelCounts[o.model] = (modelCounts[o.model] || 0) + 1; });
136
+ const worstModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0];
137
+ const modelNote = worstModel && worstModel[1] >= 3
138
+ ? ` Failures cluster on ${worstModel[0]}.`
139
+ : '';
140
+ return {
141
+ id: 'failure-pattern',
142
+ priority: 'high',
143
+ category: 'quality',
144
+ title: 'High failure rate detected',
145
+ description: `${Math.round(failRate * 100)}% of recent tasks failed.${modelNote}`,
146
+ action: worstModel && worstModel[1] >= 3
147
+ ? `Route away from ${worstModel[0]} — or check task ambiguity.`
148
+ : 'Review task clarity and model-task fit.',
149
+ impact: 'Fewer retries, less wasted compute.',
150
+ };
151
+ }
152
+ return null;
153
+ }
154
+
155
+ function subscriptionUtilization(subscription, routingState) {
156
+ const { tier, maxMultiplier } = subscription;
157
+ if (!tier) return null;
158
+
159
+ const opusUses = Object.values(routingState)
160
+ .flatMap(m => Object.entries(m))
161
+ .filter(([model]) => model === 'opus' || model.includes('opus'))
162
+ .reduce((s, [, stats]) => s + (stats.observations || 0), 0);
163
+
164
+ const totalUses = Object.values(routingState)
165
+ .flatMap(m => Object.values(m))
166
+ .reduce((s, stats) => s + (stats.observations || 0), 0);
167
+
168
+ if (!totalUses) return null;
169
+ const opusPct = opusUses / totalUses;
170
+
171
+ if ((tier === 'max' || (maxMultiplier && maxMultiplier >= 20)) && opusPct < 0.15) {
172
+ return {
173
+ id: 'subscription-underutilized',
174
+ priority: 'medium',
175
+ category: 'profile',
176
+ title: 'Subscription underutilized',
177
+ description: `Max ${maxMultiplier || ''}x plan but opus used only ${Math.round(opusPct * 100)}% of dispatches.`,
178
+ action: 'Consider quality-first mode for better output.',
179
+ impact: 'Get more value from your subscription tier.',
180
+ };
181
+ }
182
+ if ((tier === 'free' || tier === 'pro') && opusPct > 0.4) {
183
+ return {
184
+ id: 'subscription-aggressive',
185
+ priority: 'medium',
186
+ category: 'profile',
187
+ title: 'Routing aggressively for plan',
188
+ description: `${Math.round(opusPct * 100)}% opus usage on a ${tier} plan.`,
189
+ action: 'Switch to balanced or cost-saver to stay within limits.',
190
+ impact: 'Avoid rate limits and unexpected cost overruns.',
191
+ };
192
+ }
193
+ return null;
194
+ }
195
+
196
+ function cascadeEffectiveness(metrics, outcomes) {
197
+ const { cascadeHits } = metrics;
198
+ if (!cascadeHits || cascadeHits < 3) return null;
199
+
200
+ const cascaded = outcomes.filter(o => o.cascaded === true);
201
+ if (cascaded.length < 3) return null;
202
+
203
+ const avgReward = cascaded.reduce((s, o) => s + (o.reward || 0), 0) / cascaded.length;
204
+ if (avgReward > 0.7) {
205
+ return {
206
+ id: 'cascade-effective',
207
+ priority: 'low',
208
+ category: 'efficiency',
209
+ title: 'Cascade routing working well',
210
+ description: `${cascadeHits} cascade hits, ${avgReward.toFixed(2)} avg reward on cascaded tasks.`,
211
+ action: 'Keep cascade enabled — it\'s delivering quality results.',
212
+ impact: 'Continued token efficiency on eligible tasks.',
213
+ };
214
+ }
215
+ return {
216
+ id: 'cascade-poor',
217
+ priority: 'low',
218
+ category: 'efficiency',
219
+ title: 'Cascade delivering poor results',
220
+ description: `${cascadeHits} cascade hits but only ${avgReward.toFixed(2)} avg reward.`,
221
+ action: 'Consider disabling cascade: `dual-brain config set cascade false`.',
222
+ impact: 'Better outcomes by routing cascade tasks to full models.',
223
+ };
224
+ }
225
+
226
+ // ─── Export 1: generateRecommendations ────────────────────────────────────────
227
+
228
+ export function generateRecommendations(cwd) {
229
+ try {
230
+ const routingState = loadRoutingState(cwd);
231
+ const thinkMetrics = loadThinkMetrics(cwd);
232
+ const governance = loadGovernance(cwd);
233
+ const subscription = loadSubscription(cwd);
234
+ const outcomes = loadOutcomes(cwd);
235
+
236
+ const recs = [
237
+ ...modelMismatch(routingState),
238
+ failurePattern(outcomes),
239
+ budgetTrajectory(governance),
240
+ thinkROI(thinkMetrics),
241
+ subscriptionUtilization(subscription, routingState),
242
+ cascadeEffectiveness(thinkMetrics, outcomes),
243
+ ].filter(Boolean);
244
+
245
+ const order = { high: 0, medium: 1, low: 2 };
246
+ return recs.sort((a, b) => (order[a.priority] ?? 9) - (order[b.priority] ?? 9));
247
+ } catch { return []; }
248
+ }
249
+
250
+ // ─── Export 2: formatRecommendations ─────────────────────────────────────────
251
+
252
+ const ICONS = { high: '⚡', medium: '💡', low: '📊' };
253
+
254
+ export function formatRecommendations(recs) {
255
+ const top = recs.slice(0, 4);
256
+ if (!top.length) {
257
+ return '╭─ Recommendations ─────────────────────────────────────────────╮\n' +
258
+ '│ No recommendations — configuration looks healthy. │\n' +
259
+ '╰───────────────────────────────────────────────────────────────╯';
260
+ }
261
+
262
+ const WIDTH = 63;
263
+ // Truncate + pad to fit inside box: WIDTH - 4 accounts for '│ ' and ' │'
264
+ const INNER = WIDTH - 4;
265
+ const clip = (str) => str.length > INNER ? str.slice(0, INNER - 1) + '…' : str;
266
+ const pad = (str) => clip(str).padEnd(INNER);
267
+ const line = (content) => `│ ${pad(content)} │`;
268
+
269
+ const lines = [
270
+ '╭─ Recommendations ' + '─'.repeat(WIDTH - 19) + '╮',
271
+ line(''),
272
+ ];
273
+
274
+ for (const rec of top) {
275
+ const icon = ICONS[rec.priority] || '•';
276
+ lines.push(line(`${icon} ${rec.priority.toUpperCase()}: ${rec.title}`));
277
+ lines.push(line(` ${rec.description}`));
278
+ lines.push(line(` → ${rec.action}`));
279
+ lines.push(line(''));
280
+ }
281
+
282
+ lines.push('╰' + '─'.repeat(WIDTH - 2) + '╯');
283
+ return lines.join('\n');
284
+ }
285
+
286
+ // ─── Export 3: getTopRecommendation ──────────────────────────────────────────
287
+
288
+ export function getTopRecommendation(cwd) {
289
+ const recs = generateRecommendations(cwd);
290
+ return recs.length ? recs[0] : null;
291
+ }
@@ -0,0 +1,145 @@
1
+ // self-correct.mjs — Failure analysis and retry strategy selection
2
+
3
+ const MODEL_TIER = { 'haiku': 1, 'sonnet': 2, 'opus': 3 };
4
+ const TIER_MODEL = { 1: 'haiku', 2: 'sonnet', 3: 'opus' };
5
+ const MAX_ATTEMPTS = 3;
6
+
7
+ function modelTier(model = '') {
8
+ const m = model.toLowerCase();
9
+ if (m.includes('haiku')) return 1;
10
+ if (m.includes('opus')) return 3;
11
+ return 2; // sonnet default
12
+ }
13
+
14
+ function matchesAny(text, keywords) {
15
+ const t = text.toLowerCase();
16
+ return keywords.some(k => t.includes(k));
17
+ }
18
+
19
+ // Export 1: classifyFailure(result)
20
+ export function classifyFailure(result) {
21
+ try {
22
+ const err = String(result?.error || result?.stderr || '');
23
+ const out = String(result?.output || result?.stdout || '');
24
+ const combined = err + ' ' + out;
25
+ const duration = result?.durationMs ?? 0;
26
+ const timeoutThreshold = result?.timeoutMs ?? 60_000;
27
+
28
+ if (matchesAny(combined, ['rate limit', 'ratelimit', '429', 'quota exceeded', 'capacity'])) {
29
+ return { type: 'rate-limit', confidence: 0.95, retryable: true };
30
+ }
31
+ if (matchesAny(combined, ['timeout', 'timed out']) || duration > timeoutThreshold) {
32
+ return { type: 'timeout', confidence: 0.9, retryable: true };
33
+ }
34
+ if (matchesAny(combined, ['context length', 'token limit', 'too long', 'maximum context', 'context window'])) {
35
+ return { type: 'context-overflow', confidence: 0.9, retryable: true };
36
+ }
37
+ if (matchesAny(combined, ['ambiguous', 'unclear', 'did you mean', 'which one', 'could you clarify', 'please clarify'])) {
38
+ return { type: 'specification', confidence: 0.85, retryable: false };
39
+ }
40
+ if (matchesAny(combined, ['unable to', "i don't know how", 'beyond my', 'cannot complete', 'incomplete'])) {
41
+ return { type: 'capability', confidence: 0.8, retryable: true };
42
+ }
43
+ // Heuristic: low quality output without explicit error signals capability gap
44
+ const quality = result?.quality ?? result?.score ?? null;
45
+ if (quality !== null && quality < 0.5) {
46
+ return { type: 'capability', confidence: 0.7, retryable: true };
47
+ }
48
+
49
+ return { type: 'unknown', confidence: 0.5, retryable: true };
50
+ } catch {
51
+ return { type: 'unknown', confidence: 0, retryable: true };
52
+ }
53
+ }
54
+
55
+ // Export 2: selectStrategy(failure, originalDecision, attemptNumber)
56
+ export function selectStrategy(failure, originalDecision, attemptNumber) {
57
+ try {
58
+ if (!failure.retryable) {
59
+ return { strategy: 'give-up', reason: `failure type '${failure.type}' requires user input` };
60
+ }
61
+ if (attemptNumber >= MAX_ATTEMPTS) {
62
+ return { strategy: 'give-up', reason: `max attempts (${MAX_ATTEMPTS}) reached` };
63
+ }
64
+
65
+ const tier = modelTier(originalDecision?.model);
66
+
67
+ if (attemptNumber === 1) {
68
+ switch (failure.type) {
69
+ case 'capability':
70
+ if (tier >= 3) return { strategy: 'split', newDecision: originalDecision, reason: 'already at max tier; decompose task' };
71
+ return { strategy: 'escalate', newDecision: originalDecision, reason: 'model lacked capability; escalating tier' };
72
+ case 'timeout':
73
+ return { strategy: 'wait-retry', newDecision: originalDecision, reason: 'timed out; retrying with delay' };
74
+ case 'rate-limit':
75
+ return { strategy: 'wait-retry', newDecision: originalDecision, reason: 'rate limited; retrying after delay' };
76
+ case 'context-overflow':
77
+ return { strategy: 'compress', newDecision: originalDecision, reason: 'context too large; compressing' };
78
+ case 'specification':
79
+ return { strategy: 'give-up', reason: 'ambiguous specification; user clarification needed' };
80
+ default: // unknown
81
+ return { strategy: 'escalate', newDecision: originalDecision, reason: 'unknown failure; escalating as precaution' };
82
+ }
83
+ }
84
+
85
+ if (attemptNumber === 2) {
86
+ if (tier >= 3) {
87
+ return { strategy: 'split', newDecision: originalDecision, reason: 'max tier reached; splitting task' };
88
+ }
89
+ return { strategy: 'escalate', newDecision: originalDecision, reason: 'retry failed; escalating one final tier' };
90
+ }
91
+
92
+ return { strategy: 'give-up', reason: 'exhausted retry budget' };
93
+ } catch {
94
+ return { strategy: 'give-up', reason: 'internal error in strategy selection' };
95
+ }
96
+ }
97
+
98
+ // Export 3: buildRetryDecision(originalDecision, strategy, failure)
99
+ export function buildRetryDecision(originalDecision, strategy, failure) {
100
+ try {
101
+ const base = {
102
+ ...originalDecision,
103
+ _retryAttempt: (originalDecision?._retryAttempt ?? 0) + 1,
104
+ _retryReason: failure.type,
105
+ _retryStrategy: strategy,
106
+ };
107
+
108
+ switch (strategy) {
109
+ case 'escalate': {
110
+ const tier = modelTier(originalDecision?.model);
111
+ const nextTier = Math.min(tier + 1, 3);
112
+ return { ...base, model: TIER_MODEL[nextTier] };
113
+ }
114
+ case 'compress':
115
+ return { ...base, _contextBudget: 0.5 };
116
+ case 'wait-retry':
117
+ return { ...base, _delayMs: 5000 };
118
+ case 'rethink':
119
+ return { ...base, tier: 'think', _retryAsThink: true };
120
+ case 'split':
121
+ return { ...base, _shouldDecompose: true };
122
+ default:
123
+ return base;
124
+ }
125
+ } catch {
126
+ return { ...originalDecision, _retryAttempt: 1, _retryReason: 'error', _retryStrategy: strategy };
127
+ }
128
+ }
129
+
130
+ // Export 4: shouldRetry(result, originalDecision, attemptNumber)
131
+ export function shouldRetry(result, originalDecision, attemptNumber = 1) {
132
+ try {
133
+ const failure = classifyFailure(result);
134
+ const { strategy, newDecision, reason } = selectStrategy(failure, originalDecision, attemptNumber);
135
+
136
+ if (strategy === 'give-up') {
137
+ return { retry: false, reason, strategy };
138
+ }
139
+
140
+ const decision = buildRetryDecision(newDecision ?? originalDecision, strategy, failure);
141
+ return { retry: true, decision, reason, strategy };
142
+ } catch {
143
+ return { retry: false, reason: 'internal error in shouldRetry', strategy: 'give-up' };
144
+ }
145
+ }
@@ -0,0 +1,215 @@
1
+ // setup-flow.mjs — Interactive first-run setup for dual-brain
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { createInterface } from 'node:readline';
5
+ import { execSync } from 'node:child_process';
6
+
7
+ // ── ANSI helpers ──────────────────────────────────────────────────────────────
8
+ const c = {
9
+ bold: s => `\x1b[1m${s}\x1b[0m`,
10
+ dim: s => `\x1b[2m${s}\x1b[0m`,
11
+ green: s => `\x1b[32m${s}\x1b[0m`,
12
+ yellow: s => `\x1b[33m${s}\x1b[0m`,
13
+ cyan: s => `\x1b[36m${s}\x1b[0m`,
14
+ red: s => `\x1b[31m${s}\x1b[0m`,
15
+ };
16
+
17
+ // ── Detection ─────────────────────────────────────────────────────────────────
18
+ export function detectEnvironment(cwd) {
19
+ const tryCmd = cmd => { try { execSync(cmd, { stdio: 'pipe' }); return true; } catch { return false; } };
20
+
21
+ let language = 'unknown';
22
+ if (existsSync(join(cwd, 'package.json'))) language = 'node';
23
+ else if (existsSync(join(cwd, 'pyproject.toml')) ||
24
+ existsSync(join(cwd, 'setup.py'))) language = 'python';
25
+ else if (existsSync(join(cwd, 'go.mod'))) language = 'go';
26
+ else if (existsSync(join(cwd, 'Cargo.toml'))) language = 'rust';
27
+ else if (existsSync(join(cwd, 'pom.xml'))) language = 'java';
28
+
29
+ let gitBranch = null;
30
+ try { gitBranch = execSync('git -C "' + cwd + '" branch --show-current', { stdio: 'pipe' }).toString().trim(); } catch {}
31
+
32
+ return {
33
+ claude: tryCmd('claude --version'),
34
+ codex: tryCmd('codex --version'),
35
+ git: !!gitBranch,
36
+ gitBranch: gitBranch || null,
37
+ language,
38
+ existingConfig: existsSync(join(cwd, '.dualbrain', 'config.json')),
39
+ };
40
+ }
41
+
42
+ // ── Welcome banner ────────────────────────────────────────────────────────────
43
+ export function renderWelcome(detected) {
44
+ const row = (ok, label) => c.cyan('│') + ` ${ok ? c.green('✓') : c.dim('✗')} ${ok ? label : c.dim(label)}`.padEnd(49) + c.cyan('│');
45
+ const bar = s => c.cyan('│') + s.padEnd(49) + c.cyan('│');
46
+ return [
47
+ c.cyan('╭' + '─'.repeat(49) + '╮'),
48
+ bar(''),
49
+ bar(` ${c.bold('dual-brain')} — intelligent model orchestration`),
50
+ bar(''),
51
+ bar(' Detected:'),
52
+ row(detected.claude, 'Claude CLI available'),
53
+ row(detected.codex, 'Codex CLI available'),
54
+ row(detected.git, `Git repository (${detected.gitBranch || 'no branch'} branch)`),
55
+ row(detected.language !== 'unknown', `${detected.language} project`),
56
+ bar(''),
57
+ c.cyan('╰' + '─'.repeat(49) + '╯'),
58
+ ].join('\n');
59
+ }
60
+
61
+ const SUB_LABELS = {
62
+ 'claude-pro': 'Claude Pro', 'claude-max-5x': 'Claude Max 5x',
63
+ 'claude-max-20x': 'Claude Max 20x', 'chatgpt-plus': 'ChatGPT Plus',
64
+ 'chatgpt-pro': 'ChatGPT Pro', 'dual-pro': 'Both Pro tiers', 'dual-max': 'Max + Pro tiers',
65
+ };
66
+
67
+ // ── Confirmation display ──────────────────────────────────────────────────────
68
+ export function renderConfirmation(config) {
69
+ const row = (k, v) => ` ${c.dim(k.padEnd(16))} ${c.cyan(v)}`;
70
+ return [
71
+ '', c.bold(' Configuration:'), '',
72
+ row('Subscription:', SUB_LABELS[config.subscription] || config.subscription),
73
+ row('Work style:', config.workStyle),
74
+ row('Primary model:', config.models.execute),
75
+ row('Think agent:', config.routing.thinkEnabled ? 'enabled' : 'disabled'),
76
+ row('Learning:', config.routing.learningEnabled ? 'on' : 'off'),
77
+ '',
78
+ ].join('\n');
79
+ }
80
+
81
+ // ── Config builder ────────────────────────────────────────────────────────────
82
+ export function buildConfig(answers, detected) {
83
+ const { subscription = 'claude-pro', workStyle = 'balanced', advanced = {}, setupMode = 'quick' } = answers;
84
+ const dual = subscription.startsWith('dual-');
85
+ const isMax = subscription.includes('max') || subscription === 'chatgpt-pro';
86
+ const topModel = isMax ? 'opus' : 'sonnet';
87
+ const exploreRate = { aggressive: 0.3, conservative: 0.1, auto: 0.25 }[workStyle] ?? 0.2;
88
+ return {
89
+ version: 1, subscription, workStyle,
90
+ providers: { claude: subscription.startsWith('claude-') || dual, openai: subscription.startsWith('chatgpt-') || dual },
91
+ routing: {
92
+ thinkEnabled: advanced.thinkEnabled ?? true,
93
+ cascadeEnabled: advanced.cascadeEnabled ?? true,
94
+ learningEnabled: advanced.learningEnabled ?? true,
95
+ explorationRate: advanced.explorationRate ?? exploreRate,
96
+ },
97
+ models: advanced.models || { search: 'haiku', execute: 'sonnet', think: topModel, review: topModel },
98
+ budget: { sessionLimitTokens: advanced.sessionLimitTokens ?? null, warnAtPercent: advanced.warnAtPercent ?? 80 },
99
+ configuredAt: new Date().toISOString(),
100
+ setupMode,
101
+ detectedEnv: { claude: detected.claude, codex: detected.codex, language: detected.language },
102
+ };
103
+ }
104
+
105
+ // ── Save config ───────────────────────────────────────────────────────────────
106
+ export function saveConfig(config, cwd) {
107
+ const dir = join(cwd, '.dualbrain');
108
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
109
+ writeFileSync(join(dir, 'config.json'), JSON.stringify(config, null, 2), 'utf8');
110
+ const orchPath = join(cwd, '.claude', 'orchestrator.json');
111
+ if (existsSync(orchPath)) {
112
+ try {
113
+ const orch = JSON.parse(readFileSync(orchPath, 'utf8'));
114
+ if (!orch.providers) orch.providers = {};
115
+ orch.providers.claude = { ...(orch.providers.claude || {}), enabled: config.providers.claude, subscription: config.subscription };
116
+ orch.providers.openai = { ...(orch.providers.openai || {}), enabled: config.providers.openai };
117
+ if (config.routing) orch.routing = { ...(orch.routing || {}), ...config.routing };
118
+ writeFileSync(orchPath, JSON.stringify(orch, null, 2), 'utf8');
119
+ } catch { /* non-fatal */ }
120
+ }
121
+ }
122
+
123
+ // ── Readline prompt helper ────────────────────────────────────────────────────
124
+ async function ask(rl, question, options) {
125
+ const lines = options.map((o, i) => ` ${c.cyan(String(i + 1) + ')')} ${o.label}${o.description ? c.dim(' — ' + o.description) : ''}`);
126
+ const prompt = `\n${c.bold(question)}\n${lines.join('\n')}\n${c.dim('> ')}`;
127
+ return new Promise(resolve => {
128
+ rl.question(prompt, answer => {
129
+ const trimmed = answer.trim();
130
+ const idx = parseInt(trimmed, 10) - 1;
131
+ if (idx >= 0 && idx < options.length) resolve(options[idx].value);
132
+ else resolve(options[0].value);
133
+ });
134
+ });
135
+ }
136
+
137
+ async function askYN(rl, question, defaultYes = true) {
138
+ return new Promise(resolve => {
139
+ rl.question(`\n${c.bold(question)} ${c.dim(defaultYes ? '(Y/n)' : '(y/N)')} `, answer => {
140
+ const t = answer.trim().toLowerCase();
141
+ if (!t) resolve(defaultYes);
142
+ else resolve(t === 'y' || t === 'yes');
143
+ });
144
+ });
145
+ }
146
+
147
+ // ── Main entry point ──────────────────────────────────────────────────────────
148
+ export async function runSetup(cwd, options = {}) {
149
+ const detected = detectEnvironment(cwd);
150
+
151
+ // Non-interactive fast path
152
+ if (options.nonInteractive) {
153
+ const config = buildConfig({
154
+ subscription: options.subscription || 'claude-pro',
155
+ workStyle: options.workStyle || 'balanced',
156
+ setupMode: 'non-interactive',
157
+ }, detected);
158
+ saveConfig(config, cwd);
159
+ return config;
160
+ }
161
+
162
+ // Already configured?
163
+ if (detected.existingConfig && !options.reconfigure) {
164
+ console.log('\n' + c.yellow('dual-brain is already configured.') + ' Pass --reconfigure to change settings.\n');
165
+ return JSON.parse(readFileSync(join(cwd, '.dualbrain', 'config.json'), 'utf8'));
166
+ }
167
+
168
+ console.log('\n' + renderWelcome(detected) + '\n');
169
+
170
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
171
+ const close = () => rl.close();
172
+
173
+ try {
174
+ const mode = await ask(rl, 'Setup mode:', [
175
+ { label: 'Quick setup', value: 'quick', description: '3 questions, ~20 seconds' },
176
+ { label: 'Advanced', value: 'advanced', description: 'full control over routing, budgets, models' },
177
+ ]);
178
+ const subscription = await ask(rl, 'Your AI subscription:', [
179
+ { label: 'Claude Pro ($20/mo)', value: 'claude-pro' },
180
+ { label: 'Claude Max 5x ($100/mo)', value: 'claude-max-5x' },
181
+ { label: 'Claude Max 20x ($200/mo)', value: 'claude-max-20x' },
182
+ { label: 'ChatGPT Plus ($20/mo)', value: 'chatgpt-plus' },
183
+ { label: 'ChatGPT Pro ($200/mo)', value: 'chatgpt-pro' },
184
+ { label: 'Both providers (Pro tiers)', value: 'dual-pro' },
185
+ { label: 'Both providers (Max tiers)', value: 'dual-max' },
186
+ ]);
187
+ const workStyle = await ask(rl, 'How should dual-brain route your work?', [
188
+ { label: 'Balanced', value: 'balanced', description: 'smart defaults, asks before expensive ops' },
189
+ { label: 'Conservative', value: 'conservative', description: 'minimize tokens, prefer cheaper models' },
190
+ { label: 'Aggressive', value: 'aggressive', description: 'best model available, maximize quality' },
191
+ { label: 'Full auto', value: 'auto', description: 'never ask, optimize silently' },
192
+ ]);
193
+ let advanced = {};
194
+ if (mode === 'advanced') {
195
+ const thinkEnabled = await askYN(rl, 'Enable think agent?', true);
196
+ const cascadeEnabled = await askYN(rl, 'Enable cascade routing?', true);
197
+ const learningEnabled = await askYN(rl, 'Enable learning (improves routing over time)?', true);
198
+ const explorationRate = await ask(rl, 'Routing exploration rate:', [
199
+ { label: 'Low (0.1)', value: 0.1, description: 'rarely tries new routes' },
200
+ { label: 'Medium (0.2)', value: 0.2, description: 'balanced' },
201
+ { label: 'High (0.3)', value: 0.3, description: 'frequently explores alternatives' },
202
+ ]);
203
+ advanced = { thinkEnabled, cascadeEnabled, learningEnabled, explorationRate };
204
+ }
205
+ const config = buildConfig({ subscription, workStyle, advanced, setupMode: mode }, detected);
206
+ console.log(renderConfirmation(config));
207
+ if (!await askYN(rl, 'Save and start?', true)) {
208
+ console.log('\n' + c.yellow('Setup cancelled.') + '\n');
209
+ close(); return null;
210
+ }
211
+ saveConfig(config, cwd);
212
+ console.log('\n' + c.green('✓') + ' ' + c.bold('dual-brain configured.') + ' Config saved to ' + c.cyan('.dualbrain/config.json') + '\n');
213
+ close(); return config;
214
+ } catch (err) { close(); throw err; }
215
+ }
@@ -0,0 +1,212 @@
1
+ // subscription.mjs — Subscription-aware routing defaults
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ export const SUBSCRIPTIONS = {
6
+ // Claude subscriptions
7
+ 'claude-pro': {
8
+ label: 'Claude Pro ($20/mo)',
9
+ provider: 'claude',
10
+ tokenBudget: 'moderate', // 5-hr rolling window, weekly cap
11
+ recommendedProfile: 'balanced',
12
+ modelWeights: { haiku: 0.4, sonnet: 0.5, opus: 0.1 },
13
+ notes: 'One extended Opus session can use 20% of your allocation. Prefer sonnet for routine work.',
14
+ },
15
+ 'claude-max-5x': {
16
+ label: 'Claude Max 5x ($100/mo)',
17
+ provider: 'claude',
18
+ tokenBudget: 'generous',
19
+ recommendedProfile: 'quality-first',
20
+ modelWeights: { haiku: 0.2, sonnet: 0.5, opus: 0.3 },
21
+ notes: '5x Pro capacity. Opus is available for complex/creative work without worry.',
22
+ },
23
+ 'claude-max-20x': {
24
+ label: 'Claude Max 20x ($200/mo)',
25
+ provider: 'claude',
26
+ tokenBudget: 'unlimited',
27
+ recommendedProfile: 'quality-first',
28
+ modelWeights: { haiku: 0.1, sonnet: 0.4, opus: 0.5 },
29
+ notes: 'Effectively unlimited. Use the best model for every task.',
30
+ },
31
+ 'claude-team': {
32
+ label: 'Claude Team ($30/seat/mo)',
33
+ provider: 'claude',
34
+ tokenBudget: 'moderate',
35
+ recommendedProfile: 'balanced',
36
+ modelWeights: { haiku: 0.3, sonnet: 0.5, opus: 0.2 },
37
+ notes: 'Team tier with admin controls. Collaboration triggers recommended.',
38
+ },
39
+ // ChatGPT subscriptions
40
+ 'chatgpt-plus': {
41
+ label: 'ChatGPT Plus ($20/mo)',
42
+ provider: 'openai',
43
+ tokenBudget: 'limited', // 50 o3/day on Plus
44
+ recommendedProfile: 'cost-saver',
45
+ modelWeights: { 'o4-mini': 0.6, 'gpt-4.1': 0.3, 'o3': 0.1 },
46
+ notes: '50 o3 messages/day limit. Heavy on o4-mini for routine, save o3 for critical decisions.',
47
+ },
48
+ 'chatgpt-pro': {
49
+ label: 'ChatGPT Pro ($200/mo)',
50
+ provider: 'openai',
51
+ tokenBudget: 'generous',
52
+ recommendedProfile: 'quality-first',
53
+ modelWeights: { 'o4-mini': 0.3, 'gpt-4.1': 0.4, 'o3': 0.3 },
54
+ notes: 'Unlimited access to all models. Use o3 freely for complex reasoning.',
55
+ },
56
+ // Dual subscription (both providers)
57
+ 'dual-pro': {
58
+ label: 'Both Pro tiers',
59
+ provider: 'both',
60
+ tokenBudget: 'moderate',
61
+ recommendedProfile: 'balanced',
62
+ modelWeights: { haiku: 0.2, sonnet: 0.3, 'gpt-4.1': 0.3, 'o4-mini': 0.2 },
63
+ notes: 'Split load across providers. Route by model strength: Claude for code, GPT for reasoning.',
64
+ },
65
+ 'dual-max': {
66
+ label: 'Max + Pro (or both Max)',
67
+ provider: 'both',
68
+ tokenBudget: 'unlimited',
69
+ recommendedProfile: 'quality-first',
70
+ modelWeights: { sonnet: 0.3, opus: 0.2, 'gpt-4.1': 0.2, 'o3': 0.3 },
71
+ notes: 'Full power from both providers. Route by task fit, not by cost.',
72
+ },
73
+ };
74
+
75
+ const DEFAULT_WEIGHTS = {
76
+ modelWeights: { haiku: 0.3, sonnet: 0.5, opus: 0.2 },
77
+ profile: 'balanced',
78
+ notes: 'No subscription configured. Using balanced defaults.',
79
+ };
80
+
81
+ function subFile(cwd) {
82
+ return join(cwd || process.cwd(), '.dualbrain', 'subscription.json');
83
+ }
84
+
85
+ /** Returns the subscription config object or null. */
86
+ export function getSubscription(subType) {
87
+ return SUBSCRIPTIONS[subType] ?? null;
88
+ }
89
+
90
+ /** Returns { modelWeights, profile, notes } for the subscription. Falls back to balanced defaults. */
91
+ export function getRecommendedWeights(subType) {
92
+ const sub = SUBSCRIPTIONS[subType];
93
+ if (!sub) return DEFAULT_WEIGHTS;
94
+ return {
95
+ modelWeights: sub.modelWeights,
96
+ profile: sub.recommendedProfile,
97
+ notes: sub.notes,
98
+ };
99
+ }
100
+
101
+ /** Writes { subscription, configuredAt } to .dualbrain/subscription.json. */
102
+ export function saveUserSubscription(subType, cwd) {
103
+ const dir = join(cwd || process.cwd(), '.dualbrain');
104
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
105
+ writeFileSync(
106
+ subFile(cwd),
107
+ JSON.stringify({ subscription: subType, configuredAt: new Date().toISOString() }, null, 2),
108
+ 'utf8'
109
+ );
110
+ }
111
+
112
+ /** Reads the saved subscription. Returns subType string or null. */
113
+ export function loadUserSubscription(cwd) {
114
+ try {
115
+ const p = subFile(cwd);
116
+ if (!existsSync(p)) return null;
117
+ const data = JSON.parse(readFileSync(p, 'utf8'));
118
+ return data.subscription ?? null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Generates a text recommendation based on subscription + current routing stats.
126
+ * routingStats: return value of getRoutingStats() from routing-advisor.mjs
127
+ */
128
+ export function generateRecommendation(subType, routingStats) {
129
+ const sub = SUBSCRIPTIONS[subType];
130
+ if (!sub) return 'No subscription configured. Run `dual-brain subscription set <type>` to enable smart routing defaults.';
131
+
132
+ // Tally actual model usage from routing stats cells
133
+ const actualUsage = {};
134
+ let totalObs = 0;
135
+ for (const models of Object.values(routingStats?.cells ?? {})) {
136
+ for (const [model, entry] of Object.entries(models)) {
137
+ actualUsage[model] = (actualUsage[model] ?? 0) + (entry.observations ?? 0);
138
+ totalObs += entry.observations ?? 0;
139
+ }
140
+ }
141
+
142
+ if (totalObs === 0) {
143
+ return `You're on ${sub.label}. No routing history yet — recommended profile is ${sub.recommendedProfile}. ${sub.notes}`;
144
+ }
145
+
146
+ // Compute actual share per model
147
+ const actualShare = {};
148
+ for (const [model, count] of Object.entries(actualUsage)) {
149
+ actualShare[model] = count / totalObs;
150
+ }
151
+
152
+ const rec = sub.modelWeights;
153
+ const budget = sub.tokenBudget;
154
+ const lines = [];
155
+
156
+ // Check expensive model utilization vs. recommended
157
+ const expensiveModels = ['opus', 'o3'];
158
+ for (const model of expensiveModels) {
159
+ const recW = rec[model] ?? 0;
160
+ const actW = actualShare[model] ?? 0;
161
+
162
+ if (recW > 0 && budget === 'unlimited' && actW < recW * 0.5) {
163
+ lines.push(
164
+ `You're on ${sub.label} but only using ${model} ${Math.round(actW * 100)}% of the time` +
165
+ ` (recommended: ${Math.round(recW * 100)}%). You're paying for capacity you're not using.` +
166
+ ` Consider switching to ${sub.recommendedProfile} mode.`
167
+ );
168
+ } else if (recW < 0.2 && budget === 'limited' && actW > recW * 2 && actW > 0.1) {
169
+ lines.push(
170
+ `Your ${sub.label} subscription is token-limited. ${model} usage at ${Math.round(actW * 100)}%` +
171
+ ` may exhaust daily limits — recommended cap is ~${Math.round(recW * 100)}%.`
172
+ );
173
+ }
174
+ }
175
+
176
+ // Cheap model suggestions on budget-constrained plans
177
+ const cheapModels = ['haiku', 'o4-mini'];
178
+ if (budget === 'moderate' || budget === 'limited') {
179
+ for (const model of cheapModels) {
180
+ const recW = rec[model] ?? 0;
181
+ const actW = actualShare[model] ?? 0;
182
+ if (recW > 0 && actW < recW * 0.5) {
183
+ lines.push(
184
+ `Your ${sub.label} subscription has a ${budget} budget. Increasing ${model} usage` +
185
+ ` (currently ${Math.round(actW * 100)}%, recommended ${Math.round(recW * 100)}%)` +
186
+ ` for search and routine tasks would preserve your allocation.`
187
+ );
188
+ break; // one cheap-model tip is enough
189
+ }
190
+ }
191
+ }
192
+
193
+ // Dominant model confirmation — find the most-used model
194
+ const topModel = Object.entries(actualShare).sort((a, b) => b[1] - a[1])[0];
195
+ if (lines.length === 0 && topModel) {
196
+ lines.push(
197
+ `Your ${sub.label} subscription is well-matched. ${Math.round(topModel[1] * 100)}% of dispatches` +
198
+ ` use ${topModel[0]} — a good fit for your ${budget} budget. ${sub.notes}`
199
+ );
200
+ }
201
+
202
+ return lines.slice(0, 3).join(' ');
203
+ }
204
+
205
+ /** Returns array of { key, label, provider } for display in UX. */
206
+ export function listSubscriptions() {
207
+ return Object.entries(SUBSCRIPTIONS).map(([key, sub]) => ({
208
+ key,
209
+ label: sub.label,
210
+ provider: sub.provider,
211
+ }));
212
+ }