dual-brain 0.2.24 → 0.2.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -2
- package/src/outcome.mjs +73 -1
- package/src/pipeline.mjs +60 -5
- package/src/recommendations.mjs +291 -0
- package/src/routing-advisor.mjs +138 -0
- package/src/setup-flow.mjs +215 -0
- package/src/signal.mjs +114 -0
- package/src/subscription.mjs +212 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dual-brain",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.26",
|
|
4
4
|
"description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -47,7 +47,12 @@
|
|
|
47
47
|
"./envelope": "./src/envelope.mjs",
|
|
48
48
|
"./session-lock": "./src/session-lock.mjs",
|
|
49
49
|
"./governance": "./src/governance.mjs",
|
|
50
|
-
"./context-intel": "./src/context-intel.mjs"
|
|
50
|
+
"./context-intel": "./src/context-intel.mjs",
|
|
51
|
+
"./signal": "./src/signal.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"
|
|
51
56
|
},
|
|
52
57
|
"keywords": [
|
|
53
58
|
"claude-code",
|
|
@@ -134,6 +139,11 @@
|
|
|
134
139
|
"src/session-lock.mjs",
|
|
135
140
|
"src/governance.mjs",
|
|
136
141
|
"src/context-intel.mjs",
|
|
142
|
+
"src/signal.mjs",
|
|
143
|
+
"src/routing-advisor.mjs",
|
|
144
|
+
"src/subscription.mjs",
|
|
145
|
+
"src/recommendations.mjs",
|
|
146
|
+
"src/setup-flow.mjs",
|
|
137
147
|
"bin/*.mjs",
|
|
138
148
|
"hooks/enforce-tier.mjs",
|
|
139
149
|
"hooks/cost-logger.mjs",
|
package/src/outcome.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { mkdirSync, appendFileSync, writeFileSync, readFileSync, existsSync } from 'fs';
|
|
1
|
+
import { mkdirSync, appendFileSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
4
5
|
|
|
5
6
|
const STOP_WORDS = new Set([
|
|
6
7
|
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'to', 'from',
|
|
@@ -204,6 +205,77 @@ export async function getRelevantOutcomes(prompt, files = [], cwd, options = {})
|
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
export async function checkFileSurvival(cwd) {
|
|
209
|
+
try {
|
|
210
|
+
const dir = join(cwd, '.dualbrain', 'outcomes');
|
|
211
|
+
if (!existsSync(dir)) return [];
|
|
212
|
+
|
|
213
|
+
// Collect up to the last 20 individual outcome JSON files
|
|
214
|
+
let files;
|
|
215
|
+
try {
|
|
216
|
+
files = readdirSync(dir)
|
|
217
|
+
.filter(f => f.startsWith('outcome_') && f.endsWith('.json'))
|
|
218
|
+
.sort()
|
|
219
|
+
.slice(-20);
|
|
220
|
+
} catch {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Get current git-modified files (best-effort)
|
|
225
|
+
let modifiedFiles = new Set();
|
|
226
|
+
try {
|
|
227
|
+
const gitOut = execSync('git diff --name-only', { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString();
|
|
228
|
+
for (const f of gitOut.split('\n').map(l => l.trim()).filter(Boolean)) {
|
|
229
|
+
modifiedFiles.add(f);
|
|
230
|
+
modifiedFiles.add(join(cwd, f));
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
// git unavailable — proceed without modified-file check
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const scored = [];
|
|
237
|
+
|
|
238
|
+
for (const fname of files) {
|
|
239
|
+
const fpath = join(dir, fname);
|
|
240
|
+
let record;
|
|
241
|
+
try {
|
|
242
|
+
record = JSON.parse(readFileSync(fpath, 'utf8'));
|
|
243
|
+
} catch {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Skip if already scored or no filesChanged list
|
|
248
|
+
if (record.survivalScore !== undefined) continue;
|
|
249
|
+
const changedFiles = record.result?.filesChanged;
|
|
250
|
+
if (!Array.isArray(changedFiles) || changedFiles.length === 0) continue;
|
|
251
|
+
|
|
252
|
+
let survived = 0;
|
|
253
|
+
for (const f of changedFiles) {
|
|
254
|
+
const absPath = f.startsWith('/') ? f : join(cwd, f);
|
|
255
|
+
const exists = existsSync(absPath);
|
|
256
|
+
const modified = modifiedFiles.has(f) || modifiedFiles.has(absPath);
|
|
257
|
+
if (exists && !modified) survived++;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const survivalScore = survived / changedFiles.length;
|
|
261
|
+
record.survivalScore = survivalScore;
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
writeFileSync(fpath, JSON.stringify(record, null, 2), 'utf8');
|
|
265
|
+
} catch {
|
|
266
|
+
// write failed — skip
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
scored.push({ id: record.id, survivalScore });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return scored;
|
|
274
|
+
} catch {
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
207
279
|
export async function getOutcomeStats(cwd, days = 7) {
|
|
208
280
|
try {
|
|
209
281
|
const allFiles = last7DaysFiles(cwd).slice(0, days);
|
package/src/pipeline.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { detectTask } from './detect.mjs';
|
|
|
10
10
|
import { decideRoute, getWorkStyle, WORK_STYLES } from './decide.mjs';
|
|
11
11
|
import { dispatch } from './dispatch.mjs';
|
|
12
12
|
import { loadProfile } from './profile.mjs';
|
|
13
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { buildContextPack as buildContextPackIntel } from './context.mjs';
|
|
16
16
|
import { compilePacket } from './context-intel.mjs';
|
|
@@ -708,6 +708,18 @@ async function preDispatchThink(prompt, files, decision, cwd, profile, opts = {}
|
|
|
708
708
|
// profile unavailable — proceed
|
|
709
709
|
}
|
|
710
710
|
|
|
711
|
+
// Auto-disable if ROI is bad (< 30% hit rate after 10+ observations)
|
|
712
|
+
{
|
|
713
|
+
const metricsPath = join(cwd, '.dualbrain', 'think-metrics.json');
|
|
714
|
+
let metrics = { hits: 0, misses: 0, totalTokens: 0 };
|
|
715
|
+
try { metrics = JSON.parse(readFileSync(metricsPath, 'utf8')); } catch {}
|
|
716
|
+
if (metrics.hits + metrics.misses >= 10 && metrics.hits / (metrics.hits + metrics.misses) < 0.3) {
|
|
717
|
+
const verbose = opts.verbose ?? false;
|
|
718
|
+
if (verbose) process.stderr.write('[dual-brain] pre-dispatch think disabled: hit rate below 30%\n');
|
|
719
|
+
return { refined: false, reason: 'think ROI too low, auto-disabled' };
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
711
723
|
try {
|
|
712
724
|
log('[dual-brain] pre-dispatch think: refining work spec...');
|
|
713
725
|
|
|
@@ -756,12 +768,14 @@ async function preDispatchThink(prompt, files, decision, cwd, profile, opts = {}
|
|
|
756
768
|
if (!parsed || typeof parsed.confidence !== 'number' || parsed.confidence <= 0.7) {
|
|
757
769
|
const reason = !parsed ? 'unparseable response' : `confidence ${parsed.confidence} <= 0.7`;
|
|
758
770
|
log(`[dual-brain] pre-dispatch think: skipped (${reason})`);
|
|
771
|
+
_recordThinkMetrics(false, cwd);
|
|
759
772
|
return { refined: false };
|
|
760
773
|
}
|
|
761
774
|
|
|
762
775
|
const ws = parsed.workSpec;
|
|
763
776
|
if (!ws || !ws.objective) {
|
|
764
777
|
log('[dual-brain] pre-dispatch think: skipped (no workSpec.objective)');
|
|
778
|
+
_recordThinkMetrics(false, cwd);
|
|
765
779
|
return { refined: false };
|
|
766
780
|
}
|
|
767
781
|
|
|
@@ -774,19 +788,44 @@ async function preDispatchThink(prompt, files, decision, cwd, profile, opts = {}
|
|
|
774
788
|
|
|
775
789
|
log(`[dual-brain] think refined: "${newObjective.slice(0, 60)}..." (confidence: ${parsed.confidence})`);
|
|
776
790
|
|
|
791
|
+
_recordThinkMetrics(true, cwd);
|
|
777
792
|
return {
|
|
778
|
-
refined:
|
|
779
|
-
prompt:
|
|
780
|
-
files:
|
|
781
|
-
decision:
|
|
793
|
+
refined: true,
|
|
794
|
+
prompt: newObjective,
|
|
795
|
+
files: newFiles,
|
|
796
|
+
decision: newDecision,
|
|
797
|
+
confidence: parsed.confidence,
|
|
782
798
|
};
|
|
783
799
|
} catch (err) {
|
|
784
800
|
// Non-blocking on any failure
|
|
785
801
|
log(`[dual-brain] pre-dispatch think: skipped (error: ${err.message})`);
|
|
802
|
+
_recordThinkMetrics(false, cwd);
|
|
786
803
|
return { refined: false };
|
|
787
804
|
}
|
|
788
805
|
}
|
|
789
806
|
|
|
807
|
+
/**
|
|
808
|
+
* Record a think hit or miss into think-metrics.json (non-blocking).
|
|
809
|
+
* @param {boolean} hit — true if the think agent produced a usable refinement
|
|
810
|
+
* @param {string} cwd
|
|
811
|
+
*/
|
|
812
|
+
function _recordThinkMetrics(hit, cwd) {
|
|
813
|
+
try {
|
|
814
|
+
const metricsPath = join(cwd, '.dualbrain', 'think-metrics.json');
|
|
815
|
+
let metrics = { hits: 0, misses: 0, totalTokens: 0 };
|
|
816
|
+
try { metrics = JSON.parse(readFileSync(metricsPath, 'utf8')); } catch {}
|
|
817
|
+
if (hit) {
|
|
818
|
+
metrics.hits++;
|
|
819
|
+
} else {
|
|
820
|
+
metrics.misses++;
|
|
821
|
+
}
|
|
822
|
+
metrics.totalTokens += 3000; // budget per think call
|
|
823
|
+
metrics.lastUpdated = new Date().toISOString();
|
|
824
|
+
mkdirSync(join(cwd, '.dualbrain'), { recursive: true });
|
|
825
|
+
writeFileSync(metricsPath, JSON.stringify(metrics, null, 2) + '\n');
|
|
826
|
+
} catch { /* non-blocking */ }
|
|
827
|
+
}
|
|
828
|
+
|
|
790
829
|
// ─── Main entry point ─────────────────────────────────────────────────────────
|
|
791
830
|
|
|
792
831
|
/**
|
|
@@ -1230,6 +1269,22 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
1230
1269
|
run._thinkRefinedPrompt = thinkRefinement.prompt;
|
|
1231
1270
|
run._thinkRefinedFiles = thinkRefinement.files;
|
|
1232
1271
|
decision = thinkRefinement.decision;
|
|
1272
|
+
|
|
1273
|
+
// Cascade: if think agent is highly confident and task is simple, downgrade worker model
|
|
1274
|
+
if (thinkRefinement.decision) {
|
|
1275
|
+
const thinkConf = thinkRefinement.confidence || 0;
|
|
1276
|
+
const currentModel = decision.model || 'sonnet';
|
|
1277
|
+
if (thinkConf >= 0.9 && currentModel !== 'haiku') {
|
|
1278
|
+
// High confidence from thinker = clear spec = cheaper model can execute
|
|
1279
|
+
const prevModel = decision.model;
|
|
1280
|
+
decision.model = 'haiku';
|
|
1281
|
+
if (verbose || run?.verbose) process.stderr.write(`[dual-brain] cascade: think confidence ${thinkConf} → downgraded ${prevModel || 'sonnet'} to haiku\n`);
|
|
1282
|
+
} else if (thinkConf >= 0.75 && currentModel === 'opus') {
|
|
1283
|
+
// Moderate confidence but spec is clear enough for sonnet
|
|
1284
|
+
decision.model = 'sonnet';
|
|
1285
|
+
if (verbose || run?.verbose) process.stderr.write(`[dual-brain] cascade: think confidence ${thinkConf} → downgraded opus to sonnet\n`);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1233
1288
|
}
|
|
1234
1289
|
}
|
|
1235
1290
|
|
|
@@ -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,138 @@
|
|
|
1
|
+
// routing-advisor.mjs — EMA + epsilon-greedy routing advisor
|
|
2
|
+
// Learns which model works best for which task type from outcome signals.
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const ALPHA = 0.3;
|
|
8
|
+
const MIN_EPSILON = 0.1;
|
|
9
|
+
const MIN_OBSERVATIONS = 5;
|
|
10
|
+
const PRIOR_WEIGHT = 5;
|
|
11
|
+
|
|
12
|
+
const STATIC_PRIORS = {
|
|
13
|
+
'search:haiku': 0.85, 'search:sonnet': 0.70, 'search:opus': 0.50,
|
|
14
|
+
'execute:haiku': 0.55, 'execute:sonnet': 0.80, 'execute:opus': 0.85,
|
|
15
|
+
'think:haiku': 0.30, 'think:sonnet': 0.70, 'think:opus': 0.90,
|
|
16
|
+
'review:haiku': 0.40, 'review:sonnet': 0.75, 'review:opus': 0.85,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const VALID_MODELS = {
|
|
20
|
+
search: ['haiku', 'sonnet'],
|
|
21
|
+
execute: ['haiku', 'sonnet', 'opus'],
|
|
22
|
+
think: ['sonnet', 'opus'],
|
|
23
|
+
review: ['sonnet', 'opus'],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function stateFile(cwd) { return join(cwd || process.cwd(), '.dualbrain', 'routing-state.json'); }
|
|
27
|
+
|
|
28
|
+
function loadState(cwd) {
|
|
29
|
+
try {
|
|
30
|
+
const p = stateFile(cwd);
|
|
31
|
+
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {};
|
|
32
|
+
} catch { return {}; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function saveState(state, cwd) {
|
|
36
|
+
try {
|
|
37
|
+
const dir = join(cwd || process.cwd(), '.dualbrain');
|
|
38
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
39
|
+
const p = stateFile(cwd), tmp = p + '.tmp';
|
|
40
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
|
|
41
|
+
renameSync(tmp, p);
|
|
42
|
+
} catch { /* non-throwing */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const staticPrior = (tier, model) => STATIC_PRIORS[`${tier}:${model}`] ?? 0.5;
|
|
46
|
+
const cellObs = (state, key) => Object.values(state[key] ?? {}).reduce((s, m) => s + (m.observations ?? 0), 0);
|
|
47
|
+
const blended = (ema, n, tier, model) =>
|
|
48
|
+
(n / (n + PRIOR_WEIGHT)) * ema + (PRIOR_WEIGHT / (n + PRIOR_WEIGHT)) * staticPrior(tier, model);
|
|
49
|
+
|
|
50
|
+
// taskProfile: { intent, tier, risk, files?, complexity? }
|
|
51
|
+
// Returns: { model, reason, confidence, explored }
|
|
52
|
+
export function adviseModel(taskProfile, cwd) {
|
|
53
|
+
try {
|
|
54
|
+
const { tier, intent } = taskProfile ?? {};
|
|
55
|
+
const validTier = tier && VALID_MODELS[tier] ? tier : 'execute';
|
|
56
|
+
const cellKey = `${validTier}:${intent ?? 'implement'}`;
|
|
57
|
+
const models = VALID_MODELS[validTier];
|
|
58
|
+
|
|
59
|
+
const state = loadState(cwd);
|
|
60
|
+
const totalObs = cellObs(state, cellKey);
|
|
61
|
+
|
|
62
|
+
if (totalObs < MIN_OBSERVATIONS) {
|
|
63
|
+
// Heuristic: pick highest static prior
|
|
64
|
+
const best = models.reduce((a, b) => staticPrior(validTier, a) >= staticPrior(validTier, b) ? a : b);
|
|
65
|
+
return { model: best, reason: 'insufficient data, using heuristic', confidence: 0.3, explored: false };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const epsilon = Math.max(MIN_EPSILON, 0.5 * Math.pow(0.9, totalObs));
|
|
69
|
+
const explored = Math.random() < epsilon;
|
|
70
|
+
|
|
71
|
+
if (explored) {
|
|
72
|
+
const model = models[Math.floor(Math.random() * models.length)];
|
|
73
|
+
return { model, reason: 'exploration', confidence: epsilon, explored: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Exploitation: pick highest blended score
|
|
77
|
+
const cell = state[cellKey] ?? {};
|
|
78
|
+
let bestModel = models[0];
|
|
79
|
+
let bestScore = -Infinity;
|
|
80
|
+
for (const m of models) {
|
|
81
|
+
const entry = cell[m];
|
|
82
|
+
const ema = entry?.ema ?? staticPrior(validTier, m);
|
|
83
|
+
const n = entry?.observations ?? 0;
|
|
84
|
+
const score = blended(ema, n, validTier, m);
|
|
85
|
+
if (score > bestScore) { bestScore = score; bestModel = m; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { model: bestModel, reason: 'exploitation', confidence: 1 - epsilon, explored: false };
|
|
89
|
+
} catch {
|
|
90
|
+
return { model: 'sonnet', reason: 'error fallback', confidence: 0.1, explored: false };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// reward: number in [0, 1]
|
|
95
|
+
export function recordReward(cellKey, model, reward, cwd) {
|
|
96
|
+
try {
|
|
97
|
+
const state = loadState(cwd);
|
|
98
|
+
if (!state[cellKey]) state[cellKey] = {};
|
|
99
|
+
const entry = state[cellKey][model] ?? { ema: reward, observations: 0 };
|
|
100
|
+
entry.ema = ALPHA * reward + (1 - ALPHA) * entry.ema;
|
|
101
|
+
entry.observations = (entry.observations ?? 0) + 1;
|
|
102
|
+
entry.lastUpdated = new Date().toISOString();
|
|
103
|
+
entry.lastReward = reward;
|
|
104
|
+
state[cellKey][model] = entry;
|
|
105
|
+
saveState(state, cwd);
|
|
106
|
+
} catch {
|
|
107
|
+
// non-throwing
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getRoutingStats(cwd) {
|
|
112
|
+
try {
|
|
113
|
+
const state = loadState(cwd);
|
|
114
|
+
const cells = {}, flat = [];
|
|
115
|
+
let totalObservations = 0;
|
|
116
|
+
for (const [cellKey, models] of Object.entries(state)) {
|
|
117
|
+
cells[cellKey] ??= {};
|
|
118
|
+
for (const [model, entry] of Object.entries(models)) {
|
|
119
|
+
const obs = entry.observations ?? 0;
|
|
120
|
+
cells[cellKey][model] = { ema: entry.ema, observations: obs };
|
|
121
|
+
totalObservations += obs;
|
|
122
|
+
flat.push({ cell: cellKey, model, ema: entry.ema, observations: obs });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
flat.sort((a, b) => b.ema - a.ema);
|
|
126
|
+
return { cells, totalObservations, topPerformers: flat.slice(0, 5), worstPerformers: flat.slice(-5).reverse() };
|
|
127
|
+
} catch {
|
|
128
|
+
return { cells: {}, totalObservations: 0, topPerformers: [], worstPerformers: [] };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function resetAdvisor(cwd) {
|
|
133
|
+
try {
|
|
134
|
+
saveState({}, cwd);
|
|
135
|
+
} catch {
|
|
136
|
+
// non-throwing
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -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
|
+
}
|
package/src/signal.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// signal.mjs — Compound outcome signal scoring
|
|
2
|
+
// Combines multiple weak signals into one reliable reward score.
|
|
3
|
+
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
export const EXPECTED_DURATION_MS = { search: 15000, execute: 45000, think: 30000, review: 40000 };
|
|
9
|
+
|
|
10
|
+
export function scoreDurationRatio(durationMs, tier) {
|
|
11
|
+
try {
|
|
12
|
+
const expected = EXPECTED_DURATION_MS[tier] ?? EXPECTED_DURATION_MS.execute;
|
|
13
|
+
const ratio = durationMs / expected;
|
|
14
|
+
if (ratio >= 0.5 && ratio <= 1.5) return 1.0;
|
|
15
|
+
if (ratio < 0.2) return 0.5;
|
|
16
|
+
if (ratio > 3.0) return 0.3;
|
|
17
|
+
if (ratio < 0.5) return 0.5 + ((ratio - 0.2) / (0.5 - 0.2)) * 0.5;
|
|
18
|
+
// ratio 1.5–3.0
|
|
19
|
+
return 1.0 - ((ratio - 1.5) / (3.0 - 1.5)) * 0.7;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function measureFileSurvival(outcome, cwd) {
|
|
26
|
+
try {
|
|
27
|
+
const files = Array.isArray(outcome.filesChanged)
|
|
28
|
+
? outcome.filesChanged
|
|
29
|
+
: [];
|
|
30
|
+
if (files.length === 0) return 1.0;
|
|
31
|
+
|
|
32
|
+
let changed;
|
|
33
|
+
try {
|
|
34
|
+
changed = new Set(
|
|
35
|
+
execSync('git diff --name-only', { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
|
|
36
|
+
.split('\n')
|
|
37
|
+
.map(f => f.trim())
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
);
|
|
40
|
+
} catch {
|
|
41
|
+
changed = new Set();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const survived = files.filter(f => {
|
|
45
|
+
const abs = join(cwd, f);
|
|
46
|
+
return existsSync(abs) && !changed.has(f);
|
|
47
|
+
});
|
|
48
|
+
return survived.length / files.length;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function scoreOutcome(outcome, context = {}) {
|
|
55
|
+
try {
|
|
56
|
+
const tier = outcome.tier ?? 'execute';
|
|
57
|
+
const signals = [];
|
|
58
|
+
|
|
59
|
+
// Signal 1: exit success (weight 0.3)
|
|
60
|
+
let exitVal;
|
|
61
|
+
if (outcome.success === true) exitVal = 1.0;
|
|
62
|
+
else if (outcome.status === 'partial') exitVal = 0.4;
|
|
63
|
+
else exitVal = 0.0;
|
|
64
|
+
signals.push({ name: 'exitSuccess', value: exitVal, weight: 0.3 });
|
|
65
|
+
|
|
66
|
+
// Signal 2: duration ratio (weight 0.25)
|
|
67
|
+
const durationMs = outcome.durationMs ?? 0;
|
|
68
|
+
const durVal = durationMs > 0 ? scoreDurationRatio(durationMs, tier) : null;
|
|
69
|
+
signals.push({ name: 'durationRatio', value: durVal, weight: 0.25 });
|
|
70
|
+
|
|
71
|
+
// Signal 3: token efficiency (weight 0.25)
|
|
72
|
+
let effVal = null;
|
|
73
|
+
const filesChanged = outcome.filesChanged ?? 0;
|
|
74
|
+
const fileCount = typeof filesChanged === 'number' ? filesChanged : filesChanged.length;
|
|
75
|
+
if (!(fileCount === 0 && tier === 'think')) {
|
|
76
|
+
const tokensUsed =
|
|
77
|
+
outcome.tokensUsed?.output ??
|
|
78
|
+
(durationMs > 0 ? Math.round(durationMs / 100) : null);
|
|
79
|
+
if (tokensUsed !== null) {
|
|
80
|
+
const efficiency = fileCount / Math.max(1, tokensUsed / 1000);
|
|
81
|
+
if (efficiency > 2) effVal = 1.0;
|
|
82
|
+
else if (efficiency >= 0.5) effVal = 0.5 + ((efficiency - 0.5) / 1.5) * 0.5;
|
|
83
|
+
else if (efficiency < 0.1) effVal = 0.2;
|
|
84
|
+
else effVal = 0.2 + ((efficiency - 0.1) / 0.4) * 0.3;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
signals.push({ name: 'tokenEfficiency', value: effVal, weight: 0.25 });
|
|
88
|
+
|
|
89
|
+
// Signal 4: file survival (weight 0.2) — delayed, may be null
|
|
90
|
+
const survivalVal = context.fileSurvival ?? null;
|
|
91
|
+
signals.push({ name: 'fileSurvival', value: survivalVal, weight: 0.2 });
|
|
92
|
+
|
|
93
|
+
// Compound score with weight redistribution
|
|
94
|
+
const active = signals.filter(s => s.value !== null);
|
|
95
|
+
const totalWeight = active.reduce((sum, s) => sum + s.weight, 0);
|
|
96
|
+
const reward = totalWeight > 0
|
|
97
|
+
? active.reduce((sum, s) => sum + (s.value * s.weight / totalWeight), 0)
|
|
98
|
+
: 0;
|
|
99
|
+
const confidence = totalWeight;
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
reward: Math.min(1, Math.max(0, reward)),
|
|
103
|
+
confidence: Math.min(1, confidence),
|
|
104
|
+
signals: {
|
|
105
|
+
exitSuccess: exitVal,
|
|
106
|
+
durationRatio: durVal,
|
|
107
|
+
tokenEfficiency: effVal,
|
|
108
|
+
fileSurvival: survivalVal,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
} catch {
|
|
112
|
+
return { reward: 0, confidence: 0, signals: { exitSuccess: false, durationRatio: null, tokenEfficiency: null, fileSurvival: null } };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -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
|
+
}
|