dual-brain 0.2.26 → 0.2.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dual-brain.mjs +82 -0
- package/package.json +12 -2
- package/src/decide.mjs +45 -0
- package/src/dispatch.mjs +46 -0
- package/src/handoff.mjs +85 -0
- package/src/outcome.mjs +28 -0
- package/src/revert.mjs +149 -0
- package/src/routing-advisor.mjs +63 -1
- package/src/self-correct.mjs +145 -0
- package/src/settings-tui.mjs +373 -0
- package/src/strategy.mjs +235 -0
|
@@ -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,373 @@
|
|
|
1
|
+
// settings-tui.mjs — Interactive settings menu for `dual-brain settings`
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
|
|
6
|
+
// ─── ANSI helpers ─────────────────────────────────────────────────────────────
|
|
7
|
+
const c = {
|
|
8
|
+
bold: s => `\x1b[1m${s}\x1b[0m`,
|
|
9
|
+
dim: s => `\x1b[2m${s}\x1b[0m`,
|
|
10
|
+
green: s => `\x1b[32m${s}\x1b[0m`,
|
|
11
|
+
yellow: s => `\x1b[33m${s}\x1b[0m`,
|
|
12
|
+
cyan: s => `\x1b[36m${s}\x1b[0m`,
|
|
13
|
+
red: s => `\x1b[31m${s}\x1b[0m`,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// ─── readline helper ──────────────────────────────────────────────────────────
|
|
17
|
+
async function prompt(rl, question) {
|
|
18
|
+
return new Promise(resolve => rl.question(question, resolve));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── Config helpers ───────────────────────────────────────────────────────────
|
|
22
|
+
function loadCurrentConfig(cwd) {
|
|
23
|
+
try {
|
|
24
|
+
const p = join(cwd, '.dualbrain', 'config.json');
|
|
25
|
+
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {};
|
|
26
|
+
} catch { return {}; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function saveConfig(cfg, cwd) {
|
|
30
|
+
const dir = join(cwd, '.dualbrain');
|
|
31
|
+
mkdirSync(dir, { recursive: true });
|
|
32
|
+
writeFileSync(join(dir, 'config.json'), JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Dial position map ────────────────────────────────────────────────────────
|
|
36
|
+
const DIAL_POSITIONS = {
|
|
37
|
+
1: { label: 'Frugal', workStyle: 'frugal', models: { search: 'haiku', execute: 'haiku', think: 'sonnet', review: 'sonnet' }, thinkEnabled: false, budget: 3 },
|
|
38
|
+
2: { label: 'Save Usage', workStyle: 'conservative', models: { search: 'haiku', execute: 'sonnet', think: 'sonnet', review: 'sonnet' }, thinkEnabled: 'auto', budget: null },
|
|
39
|
+
3: { label: 'Balanced', workStyle: 'balanced', models: { search: 'haiku', execute: 'sonnet', think: 'opus', review: 'sonnet' }, thinkEnabled: true, budget: null },
|
|
40
|
+
4: { label: 'Quality', workStyle: 'quality', models: { search: 'sonnet',execute: 'sonnet', think: 'opus', review: 'opus' }, thinkEnabled: true, budget: null },
|
|
41
|
+
5: { label: 'Maximum', workStyle: 'aggressive', models: { search: 'sonnet',execute: 'opus', think: 'opus', review: 'opus' }, thinkEnabled: true, budget: null },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function saveDialPosition(position, cwd) {
|
|
45
|
+
const dial = DIAL_POSITIONS[position];
|
|
46
|
+
if (!dial) return;
|
|
47
|
+
const cfg = loadCurrentConfig(cwd);
|
|
48
|
+
cfg.workStyle = dial.workStyle;
|
|
49
|
+
cfg.models = { ...(cfg.models ?? {}), ...dial.models };
|
|
50
|
+
cfg.routing = cfg.routing ?? {};
|
|
51
|
+
cfg.routing.thinkEnabled = dial.thinkEnabled === 'auto' ? true : dial.thinkEnabled;
|
|
52
|
+
if (dial.budget !== null) {
|
|
53
|
+
cfg.budget = cfg.budget ?? {};
|
|
54
|
+
cfg.budget.sessionLimitUsd = dial.budget;
|
|
55
|
+
} else {
|
|
56
|
+
if (cfg.budget) delete cfg.budget.sessionLimitUsd;
|
|
57
|
+
}
|
|
58
|
+
cfg.dialPosition = position;
|
|
59
|
+
saveConfig(cfg, cwd);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Header helpers ───────────────────────────────────────────────────────────
|
|
63
|
+
function inferDialLabel(cfg) {
|
|
64
|
+
const pos = cfg.dialPosition;
|
|
65
|
+
if (pos && DIAL_POSITIONS[pos]) return DIAL_POSITIONS[pos].label;
|
|
66
|
+
const ws = cfg.workStyle ?? '';
|
|
67
|
+
const map = { frugal: 'Frugal', conservative: 'Save Usage', balanced: 'Balanced', quality: 'Quality', aggressive: 'Maximum' };
|
|
68
|
+
return map[ws] ?? 'Balanced';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function inferSubLabel(cwd) {
|
|
72
|
+
try {
|
|
73
|
+
const p = join(cwd, '.dualbrain', 'subscription.json');
|
|
74
|
+
if (!existsSync(p)) return 'unknown';
|
|
75
|
+
const { subscription } = JSON.parse(readFileSync(p, 'utf8'));
|
|
76
|
+
const labels = {
|
|
77
|
+
'claude-pro': 'Claude Pro', 'claude-max-5x': 'Claude Max 5x',
|
|
78
|
+
'claude-max-20x': 'Claude Max 20x', 'chatgpt-plus': 'ChatGPT Plus',
|
|
79
|
+
'chatgpt-pro': 'ChatGPT Pro', 'dual-pro': 'Both Pro', 'dual-max': 'Both Max',
|
|
80
|
+
};
|
|
81
|
+
return labels[subscription] ?? subscription;
|
|
82
|
+
} catch { return 'unknown'; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Subscreens ───────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export async function dialScreen(rl, cwd) {
|
|
88
|
+
const cfg = loadCurrentConfig(cwd);
|
|
89
|
+
const cur = cfg.dialPosition ?? 3;
|
|
90
|
+
console.log('');
|
|
91
|
+
console.log(c.bold(' Routing Dial'));
|
|
92
|
+
console.log('');
|
|
93
|
+
console.log(` Current: ${c.cyan(`[${cur}] ${DIAL_POSITIONS[cur]?.label ?? '?'}`)}`);
|
|
94
|
+
console.log('');
|
|
95
|
+
console.log(' 1) Frugal — minimize token usage');
|
|
96
|
+
console.log(' 2) Save Usage — prefer cheaper models');
|
|
97
|
+
console.log(' 3) Balanced — smart defaults');
|
|
98
|
+
console.log(' 4) Quality — best available for each task');
|
|
99
|
+
console.log(' 5) Maximum — always use most capable');
|
|
100
|
+
console.log('');
|
|
101
|
+
const ans = (await prompt(rl, ` Enter number (1-5) or [esc] to cancel: `)).trim();
|
|
102
|
+
if (ans === '\x1b' || ans === '' || ans === 'esc') return;
|
|
103
|
+
const n = parseInt(ans, 10);
|
|
104
|
+
if (n >= 1 && n <= 5) {
|
|
105
|
+
saveDialPosition(n, cwd);
|
|
106
|
+
console.log(c.green(` Dial set to [${n}] ${DIAL_POSITIONS[n].label}`));
|
|
107
|
+
} else {
|
|
108
|
+
console.log(c.red(' Invalid choice.'));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function routingScreen(rl, cwd) {
|
|
113
|
+
const cfg = loadCurrentConfig(cwd);
|
|
114
|
+
const models = cfg.models ?? {};
|
|
115
|
+
console.log('');
|
|
116
|
+
console.log(c.bold(' Tier Assignments'));
|
|
117
|
+
console.log('');
|
|
118
|
+
for (const [tier, model] of Object.entries(models)) {
|
|
119
|
+
console.log(` ${tier.padEnd(8)}: ${c.cyan(model)}`);
|
|
120
|
+
}
|
|
121
|
+
console.log('');
|
|
122
|
+
console.log(c.bold(' Learned Preferences') + c.dim(' (from routing advisor)'));
|
|
123
|
+
console.log('');
|
|
124
|
+
let stats = { topPerformers: [], totalObservations: 0 };
|
|
125
|
+
try {
|
|
126
|
+
const { getRoutingStats } = await import('./routing-advisor.mjs');
|
|
127
|
+
stats = getRoutingStats(cwd);
|
|
128
|
+
} catch {}
|
|
129
|
+
if (stats.topPerformers.length === 0) {
|
|
130
|
+
console.log(c.dim(' No observations yet.'));
|
|
131
|
+
} else {
|
|
132
|
+
for (const p of stats.topPerformers.slice(0, 5)) {
|
|
133
|
+
console.log(` ${p.cell.padEnd(22)} → ${c.cyan(p.model)} (EMA ${p.ema.toFixed(2)}, n=${p.observations})`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
console.log('');
|
|
137
|
+
const ans = (await prompt(rl, ' [o] Override tier [r] Reset learned data [esc] back: ')).trim().toLowerCase();
|
|
138
|
+
if (ans === 'r') {
|
|
139
|
+
try {
|
|
140
|
+
const { resetAdvisor } = await import('./routing-advisor.mjs');
|
|
141
|
+
resetAdvisor(cwd);
|
|
142
|
+
console.log(c.green(' Routing advisor state cleared.'));
|
|
143
|
+
} catch { console.log(c.red(' Failed to reset.')); }
|
|
144
|
+
} else if (ans === 'o') {
|
|
145
|
+
const tier = (await prompt(rl, ' Tier to override (search/execute/think/review): ')).trim();
|
|
146
|
+
const model = (await prompt(rl, ' Model (haiku/sonnet/opus): ')).trim();
|
|
147
|
+
if (tier && model) {
|
|
148
|
+
const cfg2 = loadCurrentConfig(cwd);
|
|
149
|
+
cfg2.models = cfg2.models ?? {};
|
|
150
|
+
cfg2.models[tier] = model;
|
|
151
|
+
saveConfig(cfg2, cwd);
|
|
152
|
+
console.log(c.green(` ${tier} → ${model} saved.`));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function thinkScreen(rl, cwd) {
|
|
158
|
+
let metrics = { hits: 0, misses: 0, totalTokens: 0 };
|
|
159
|
+
try {
|
|
160
|
+
const p = join(cwd, '.dualbrain', 'think-metrics.json');
|
|
161
|
+
if (existsSync(p)) metrics = JSON.parse(readFileSync(p, 'utf8'));
|
|
162
|
+
} catch {}
|
|
163
|
+
const cfg = loadCurrentConfig(cwd);
|
|
164
|
+
const enabled = cfg.routing?.thinkEnabled !== false;
|
|
165
|
+
const total = metrics.hits + metrics.misses;
|
|
166
|
+
const hitRate = total > 0 ? Math.round((metrics.hits / total) * 100) : 0;
|
|
167
|
+
console.log('');
|
|
168
|
+
console.log(c.bold(' Think Pre-flight'));
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log(` Status: ${enabled ? c.green('enabled') : c.red('disabled')}`);
|
|
171
|
+
console.log(` Hit rate: ${hitRate}% (${metrics.hits} hits / ${metrics.misses} misses)`);
|
|
172
|
+
console.log(` Tokens: ~${((metrics.totalTokens ?? 0) / 1000).toFixed(0)}K`);
|
|
173
|
+
console.log(` Auto-disable threshold: 30%`);
|
|
174
|
+
console.log('');
|
|
175
|
+
const ans = (await prompt(rl, ' [t] Toggle [r] Reset metrics [esc] back: ')).trim().toLowerCase();
|
|
176
|
+
if (ans === 't') {
|
|
177
|
+
const cfg2 = loadCurrentConfig(cwd);
|
|
178
|
+
cfg2.routing = cfg2.routing ?? {};
|
|
179
|
+
cfg2.routing.thinkEnabled = !enabled;
|
|
180
|
+
saveConfig(cfg2, cwd);
|
|
181
|
+
console.log(c.green(` Think ${!enabled ? 'enabled' : 'disabled'}.`));
|
|
182
|
+
} else if (ans === 'r') {
|
|
183
|
+
try {
|
|
184
|
+
const p = join(cwd, '.dualbrain', 'think-metrics.json');
|
|
185
|
+
writeFileSync(p, JSON.stringify({ hits: 0, misses: 0, totalTokens: 0 }, null, 2) + '\n');
|
|
186
|
+
console.log(c.green(' Think metrics reset.'));
|
|
187
|
+
} catch { console.log(c.red(' Failed to reset.')); }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function budgetScreen(rl, cwd) {
|
|
192
|
+
let budget = { spent: 0, remaining: 10, limit: 10, warning: false };
|
|
193
|
+
try {
|
|
194
|
+
const { loadGovernanceState, checkBudget } = await import('./governance.mjs');
|
|
195
|
+
const cfg = loadCurrentConfig(cwd);
|
|
196
|
+
budget = checkBudget(cwd, cfg);
|
|
197
|
+
} catch {}
|
|
198
|
+
const pct = budget.limit > 0 ? Math.round((budget.spent / budget.limit) * 100) : 0;
|
|
199
|
+
const cfg = loadCurrentConfig(cwd);
|
|
200
|
+
const warnAt = cfg.budget?.warnAtPercent ?? 80;
|
|
201
|
+
console.log('');
|
|
202
|
+
console.log(c.bold(' Budget'));
|
|
203
|
+
console.log('');
|
|
204
|
+
console.log(` Session limit: $${budget.limit.toFixed(2)} (estimated)`);
|
|
205
|
+
console.log(` Current session: $${budget.spent.toFixed(2)} spent (${pct}%)`);
|
|
206
|
+
console.log(` Warning at: ${warnAt}%`);
|
|
207
|
+
console.log('');
|
|
208
|
+
const ans = (await prompt(rl, ' [l] Set limit [w] Set warning % [esc] back: ')).trim().toLowerCase();
|
|
209
|
+
if (ans === 'l') {
|
|
210
|
+
const val = (await prompt(rl, ' New session limit ($): ')).trim();
|
|
211
|
+
const n = parseFloat(val);
|
|
212
|
+
if (!isNaN(n) && n >= 0) {
|
|
213
|
+
const cfg2 = loadCurrentConfig(cwd);
|
|
214
|
+
cfg2.budget = cfg2.budget ?? {};
|
|
215
|
+
cfg2.budget.sessionLimitUsd = n;
|
|
216
|
+
saveConfig(cfg2, cwd);
|
|
217
|
+
console.log(c.green(` Session limit set to $${n}.`));
|
|
218
|
+
} else { console.log(c.red(' Invalid value.')); }
|
|
219
|
+
} else if (ans === 'w') {
|
|
220
|
+
const val = (await prompt(rl, ' Warn at percent (0-100): ')).trim();
|
|
221
|
+
const n = parseInt(val, 10);
|
|
222
|
+
if (!isNaN(n) && n >= 0 && n <= 100) {
|
|
223
|
+
const cfg2 = loadCurrentConfig(cwd);
|
|
224
|
+
cfg2.budget = cfg2.budget ?? {};
|
|
225
|
+
cfg2.budget.warnAtPercent = n;
|
|
226
|
+
saveConfig(cfg2, cwd);
|
|
227
|
+
console.log(c.green(` Warning threshold set to ${n}%.`));
|
|
228
|
+
} else { console.log(c.red(' Invalid value.')); }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function subscriptionScreen(rl, cwd) {
|
|
233
|
+
let curSub = 'unknown';
|
|
234
|
+
try {
|
|
235
|
+
const p = join(cwd, '.dualbrain', 'subscription.json');
|
|
236
|
+
if (existsSync(p)) curSub = JSON.parse(readFileSync(p, 'utf8')).subscription ?? 'unknown';
|
|
237
|
+
} catch {}
|
|
238
|
+
const subs = [
|
|
239
|
+
['claude-pro', 'Claude Pro ($20/mo)'],
|
|
240
|
+
['claude-max-5x', 'Claude Max 5x ($100/mo)'],
|
|
241
|
+
['claude-max-20x', 'Claude Max 20x ($200/mo)'],
|
|
242
|
+
['chatgpt-plus', 'ChatGPT Plus ($20/mo)'],
|
|
243
|
+
['chatgpt-pro', 'ChatGPT Pro ($200/mo)'],
|
|
244
|
+
['dual-pro', 'Both Pro tiers'],
|
|
245
|
+
['dual-max', 'Both Max tiers'],
|
|
246
|
+
];
|
|
247
|
+
console.log('');
|
|
248
|
+
console.log(c.bold(' Subscription'));
|
|
249
|
+
console.log('');
|
|
250
|
+
console.log(` Current: ${c.cyan(curSub)}`);
|
|
251
|
+
console.log('');
|
|
252
|
+
subs.forEach(([key, label], i) => console.log(` ${i + 1}) ${label}`));
|
|
253
|
+
console.log('');
|
|
254
|
+
const ans = (await prompt(rl, ' Enter number or [esc] to cancel: ')).trim();
|
|
255
|
+
if (ans === '' || ans === 'esc' || ans === '\x1b') return;
|
|
256
|
+
const n = parseInt(ans, 10);
|
|
257
|
+
if (n >= 1 && n <= subs.length) {
|
|
258
|
+
const [subType, label] = subs[n - 1];
|
|
259
|
+
try {
|
|
260
|
+
const { saveUserSubscription } = await import('./subscription.mjs');
|
|
261
|
+
saveUserSubscription(subType, cwd);
|
|
262
|
+
console.log(c.green(` Subscription set to: ${label}`));
|
|
263
|
+
} catch { console.log(c.red(' Failed to save subscription.')); }
|
|
264
|
+
} else { console.log(c.red(' Invalid choice.')); }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function resetScreen(rl, cwd) {
|
|
268
|
+
let obs = 0;
|
|
269
|
+
try {
|
|
270
|
+
const { getRoutingStats } = await import('./routing-advisor.mjs');
|
|
271
|
+
obs = getRoutingStats(cwd).totalObservations;
|
|
272
|
+
} catch {}
|
|
273
|
+
console.log('');
|
|
274
|
+
console.log(c.bold(c.red(' Reset Learned Data')));
|
|
275
|
+
console.log('');
|
|
276
|
+
console.log(' This will clear:');
|
|
277
|
+
console.log(` - Routing advisor state (${obs} observations)`);
|
|
278
|
+
console.log(' - Think metrics');
|
|
279
|
+
console.log(' - Outcome history');
|
|
280
|
+
console.log('');
|
|
281
|
+
const ans = (await prompt(rl, ' Are you sure? (y/N): ')).trim().toLowerCase();
|
|
282
|
+
if (ans !== 'y') { console.log(c.dim(' Cancelled.')); return; }
|
|
283
|
+
let cleared = 0;
|
|
284
|
+
const targets = ['routing-state.json', 'routing-weights.json', 'think-metrics.json', 'outcomes.json'];
|
|
285
|
+
for (const f of targets) {
|
|
286
|
+
try {
|
|
287
|
+
const p = join(cwd, '.dualbrain', f);
|
|
288
|
+
if (existsSync(p)) { writeFileSync(p, '{}\n'); cleared++; }
|
|
289
|
+
} catch {}
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
const { resetAdvisor } = await import('./routing-advisor.mjs');
|
|
293
|
+
resetAdvisor(cwd);
|
|
294
|
+
} catch {}
|
|
295
|
+
console.log(c.green(` Cleared. (${cleared} files reset)`));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─── Main menu ────────────────────────────────────────────────────────────────
|
|
299
|
+
export async function runSettings(cwd) {
|
|
300
|
+
cwd = cwd ?? process.cwd();
|
|
301
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
302
|
+
|
|
303
|
+
const box = (lines) => {
|
|
304
|
+
const W = 65;
|
|
305
|
+
const hr = '─'.repeat(W - 2);
|
|
306
|
+
console.log(`╭${hr}╮`);
|
|
307
|
+
for (const l of lines) {
|
|
308
|
+
const visible = l.replace(/\x1b\[[0-9;]*m/g, '');
|
|
309
|
+
const pad = W - 2 - visible.length;
|
|
310
|
+
console.log(`│ ${l}${' '.repeat(Math.max(0, pad - 1))}│`);
|
|
311
|
+
}
|
|
312
|
+
console.log(`╰${hr}╯`);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const showMenu = () => {
|
|
316
|
+
const cfg = loadCurrentConfig(cwd);
|
|
317
|
+
const profile = inferDialLabel(cfg);
|
|
318
|
+
const sub = inferSubLabel(cwd);
|
|
319
|
+
let obs = 0;
|
|
320
|
+
try {
|
|
321
|
+
const p = join(cwd, '.dualbrain', 'routing-state.json');
|
|
322
|
+
if (existsSync(p)) {
|
|
323
|
+
const state = JSON.parse(readFileSync(p, 'utf8'));
|
|
324
|
+
for (const models of Object.values(state)) {
|
|
325
|
+
for (const e of Object.values(models)) obs += e.observations ?? 0;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} catch {}
|
|
329
|
+
const learning = cfg.routing?.learningEnabled !== false
|
|
330
|
+
? c.green(`active (${obs} observations)`)
|
|
331
|
+
: c.dim('disabled');
|
|
332
|
+
|
|
333
|
+
console.log('');
|
|
334
|
+
box([
|
|
335
|
+
c.bold(' dual-brain settings'),
|
|
336
|
+
'',
|
|
337
|
+
` Profile: ${c.cyan(profile.padEnd(20))} Subscription: ${c.cyan(sub)}`,
|
|
338
|
+
` Learning: ${learning}`,
|
|
339
|
+
'',
|
|
340
|
+
'─'.repeat(63),
|
|
341
|
+
'',
|
|
342
|
+
` ${c.bold('[d]')} Dial Adjust routing aggression`,
|
|
343
|
+
` ${c.bold('[r]')} Routing Model preferences & learned data`,
|
|
344
|
+
` ${c.bold('[t]')} Think Pre-flight settings & metrics`,
|
|
345
|
+
` ${c.bold('[b]')} Budget Limits and session caps`,
|
|
346
|
+
` ${c.bold('[s]')} Subscription Change plan type`,
|
|
347
|
+
` ${c.bold('[x]')} Reset Clear learned data`,
|
|
348
|
+
'',
|
|
349
|
+
` ${c.dim('[q]')} quit`,
|
|
350
|
+
'',
|
|
351
|
+
]);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
let running = true;
|
|
355
|
+
while (running) {
|
|
356
|
+
showMenu();
|
|
357
|
+
const key = (await prompt(rl, ' > ')).trim().toLowerCase();
|
|
358
|
+
switch (key) {
|
|
359
|
+
case 'd': await dialScreen(rl, cwd); break;
|
|
360
|
+
case 'r': await routingScreen(rl, cwd); break;
|
|
361
|
+
case 't': await thinkScreen(rl, cwd); break;
|
|
362
|
+
case 'b': await budgetScreen(rl, cwd); break;
|
|
363
|
+
case 's': await subscriptionScreen(rl, cwd); break;
|
|
364
|
+
case 'x': await resetScreen(rl, cwd); break;
|
|
365
|
+
case 'q': case '': running = false; break;
|
|
366
|
+
default: console.log(c.dim(' Unknown option.'));
|
|
367
|
+
}
|
|
368
|
+
if (running && key !== '') await prompt(rl, c.dim('\n Press enter to continue...'));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
rl.close();
|
|
372
|
+
console.log(c.dim('\n Settings closed.\n'));
|
|
373
|
+
}
|