dual-brain 0.2.27 → 0.2.29
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 +41 -0
- package/package.json +10 -2
- package/src/context-intel.mjs +4 -2
- package/src/handoff.mjs +85 -0
- package/src/recommendations.mjs +1 -1
- package/src/revert.mjs +149 -0
- package/src/routing-advisor.mjs +63 -1
- package/src/self-correct.mjs +1 -0
- package/src/settings-tui.mjs +373 -0
- package/src/signal.mjs +1 -1
- package/src/strategy.mjs +235 -0
package/bin/dual-brain.mjs
CHANGED
|
@@ -4227,6 +4227,30 @@ async function settingsScreen(rl, ask) {
|
|
|
4227
4227
|
return { next: 'diagnostics' };
|
|
4228
4228
|
}
|
|
4229
4229
|
|
|
4230
|
+
// Intelligence settings (routing, think, strategies)
|
|
4231
|
+
if (choice === 'i') {
|
|
4232
|
+
try {
|
|
4233
|
+
const { runSettings } = await import('../src/settings-tui.mjs');
|
|
4234
|
+
await runSettings(cwd);
|
|
4235
|
+
} catch (e) {
|
|
4236
|
+
process.stdout.write(` Intelligence settings unavailable: ${e.message}\n`);
|
|
4237
|
+
await ask(' Press Enter to continue...');
|
|
4238
|
+
}
|
|
4239
|
+
return { next: 'settings' };
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
// Revert recent changes
|
|
4243
|
+
if (choice === 'u') {
|
|
4244
|
+
try {
|
|
4245
|
+
const { runRevert } = await import('../src/revert.mjs');
|
|
4246
|
+
await runRevert(cwd);
|
|
4247
|
+
} catch (e) {
|
|
4248
|
+
process.stdout.write(` Revert unavailable: ${e.message}\n`);
|
|
4249
|
+
await ask(' Press Enter to continue...');
|
|
4250
|
+
}
|
|
4251
|
+
return { next: 'settings' };
|
|
4252
|
+
}
|
|
4253
|
+
|
|
4230
4254
|
if (choice === 'b' || choice === 'back' || raw === '\x1b') { return { next: 'main' }; }
|
|
4231
4255
|
|
|
4232
4256
|
return { next: 'main' };
|
|
@@ -6634,6 +6658,23 @@ async function main() {
|
|
|
6634
6658
|
return;
|
|
6635
6659
|
}
|
|
6636
6660
|
|
|
6661
|
+
if (cmd === 'revert' || cmd === 'undo') {
|
|
6662
|
+
const { runRevert } = await import('../src/revert.mjs');
|
|
6663
|
+
await runRevert(process.cwd());
|
|
6664
|
+
return;
|
|
6665
|
+
}
|
|
6666
|
+
|
|
6667
|
+
if (cmd === 'strategies') {
|
|
6668
|
+
const { listStrategies } = await import('../src/strategy.mjs');
|
|
6669
|
+
const strats = listStrategies();
|
|
6670
|
+
console.log('\n Available dispatch strategies:\n');
|
|
6671
|
+
for (const s of strats) {
|
|
6672
|
+
console.log(` ${s.id.padEnd(18)} ${s.description} (${s.cost}x cost)`);
|
|
6673
|
+
}
|
|
6674
|
+
console.log('');
|
|
6675
|
+
return;
|
|
6676
|
+
}
|
|
6677
|
+
|
|
6637
6678
|
// One-shot commands — run and exit
|
|
6638
6679
|
if (cmd === 'install') {
|
|
6639
6680
|
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.
|
|
3
|
+
"version": "0.2.29",
|
|
4
4
|
"description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -53,7 +53,11 @@
|
|
|
53
53
|
"./subscription": "./src/subscription.mjs",
|
|
54
54
|
"./recommendations": "./src/recommendations.mjs",
|
|
55
55
|
"./setup-flow": "./src/setup-flow.mjs",
|
|
56
|
-
"./self-correct": "./src/self-correct.mjs"
|
|
56
|
+
"./self-correct": "./src/self-correct.mjs",
|
|
57
|
+
"./settings-tui": "./src/settings-tui.mjs",
|
|
58
|
+
"./revert": "./src/revert.mjs",
|
|
59
|
+
"./strategy": "./src/strategy.mjs",
|
|
60
|
+
"./handoff": "./src/handoff.mjs"
|
|
57
61
|
},
|
|
58
62
|
"keywords": [
|
|
59
63
|
"claude-code",
|
|
@@ -146,6 +150,10 @@
|
|
|
146
150
|
"src/recommendations.mjs",
|
|
147
151
|
"src/setup-flow.mjs",
|
|
148
152
|
"src/self-correct.mjs",
|
|
153
|
+
"src/settings-tui.mjs",
|
|
154
|
+
"src/revert.mjs",
|
|
155
|
+
"src/strategy.mjs",
|
|
156
|
+
"src/handoff.mjs",
|
|
149
157
|
"bin/*.mjs",
|
|
150
158
|
"hooks/enforce-tier.mjs",
|
|
151
159
|
"hooks/cost-logger.mjs",
|
package/src/context-intel.mjs
CHANGED
|
@@ -3,7 +3,8 @@ import { join, resolve } from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
export const MODEL_FORMAT = {
|
|
5
5
|
claude: 'xml', sonnet: 'xml', haiku: 'xml', opus: 'xml',
|
|
6
|
-
gpt: 'markdown',
|
|
6
|
+
gpt: 'markdown', 'o4-mini': 'markdown',
|
|
7
|
+
o3: 'prose',
|
|
7
8
|
};
|
|
8
9
|
|
|
9
10
|
function detectFormat(targetModel, role) {
|
|
@@ -14,6 +15,7 @@ function detectFormat(targetModel, role) {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function selectRelevant(pack, role) {
|
|
18
|
+
if (!pack) return { intent: '', constraints: [], acceptanceCriteria: [] };
|
|
17
19
|
const { intent, constraints, priorAttempts, repoState, fileSummaries,
|
|
18
20
|
acceptanceCriteria, files } = pack;
|
|
19
21
|
if (role === 'thinker') {
|
|
@@ -134,7 +136,7 @@ export function attachOutputSchema(role) {
|
|
|
134
136
|
return 'Return JSON: { pass: boolean, findings: [{ severity, file, line, issue, fix }] }';
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
export function shapeForRole(pack, role, targetModel, tokenBudget) {
|
|
139
|
+
export function shapeForRole(pack, role, targetModel = 'sonnet', tokenBudget = 8000) {
|
|
138
140
|
const sections = selectRelevant(pack, role);
|
|
139
141
|
|
|
140
142
|
if (role === 'worker' && sections.inScope?.length) {
|
package/src/handoff.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// handoff.mjs — Typed handoffs between pipeline stages
|
|
2
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, unlinkSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const HANDOFF_TYPES = {
|
|
6
|
+
'think-to-work': { required: ['objective', 'files', 'criteria'], optional: ['context', 'confidence'] },
|
|
7
|
+
'work-to-review': { required: ['filesChanged', 'objective'], optional: ['diff', 'criteria', 'testsRun'] },
|
|
8
|
+
'review-to-head': { required: ['pass'], optional: ['findings', 'recommendation', 'severity'] },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const hDir = (cwd) => join(cwd || process.cwd(), '.dualbrain', 'handoffs');
|
|
12
|
+
const hPath = (id, f, t, cwd) => join(hDir(cwd), `${id}_${f}_${t}.json`);
|
|
13
|
+
|
|
14
|
+
function validate(from, to, data) {
|
|
15
|
+
const schema = HANDOFF_TYPES[`${from}-to-${to}`];
|
|
16
|
+
if (!schema) return;
|
|
17
|
+
for (const f of schema.required) {
|
|
18
|
+
if (!(f in data)) process.stderr.write(`[handoff] warn: missing required field '${f}' in ${from}-to-${to}\n`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createHandoff(fromStage, toStage, data, runId, cwd) {
|
|
23
|
+
try {
|
|
24
|
+
validate(fromStage, toStage, data);
|
|
25
|
+
const dir = hDir(cwd);
|
|
26
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
27
|
+
const record = { fromStage, toStage, runId, createdAt: new Date().toISOString(), data };
|
|
28
|
+
const dest = hPath(runId, fromStage, toStage, cwd); const tmp = dest + '.tmp';
|
|
29
|
+
writeFileSync(tmp, JSON.stringify(record, null, 2), 'utf8');
|
|
30
|
+
try { renameSync(tmp, dest); } catch { writeFileSync(dest, JSON.stringify(record, null, 2), 'utf8'); }
|
|
31
|
+
return record;
|
|
32
|
+
} catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function consumeHandoff(runId, fromStage, toStage, cwd) {
|
|
36
|
+
try {
|
|
37
|
+
const p = hPath(runId, fromStage, toStage, cwd);
|
|
38
|
+
if (!existsSync(p)) return null;
|
|
39
|
+
const record = JSON.parse(readFileSync(p, 'utf8'));
|
|
40
|
+
try { unlinkSync(p); } catch { /* best-effort */ }
|
|
41
|
+
return record;
|
|
42
|
+
} catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildHandoffContext(handoff, targetRole) {
|
|
46
|
+
if (!handoff?.data) return '';
|
|
47
|
+
const d = handoff.data;
|
|
48
|
+
const lines = (...parts) => parts.filter(Boolean).join('\n');
|
|
49
|
+
const list = (v) => Array.isArray(v) ? v.join(', ') : (v ?? '');
|
|
50
|
+
const items = (v) => Array.isArray(v) ? v.map(x => ` - ${x}`).join('\n') : (v ?? '');
|
|
51
|
+
|
|
52
|
+
if (targetRole === 'worker' && handoff.fromStage === 'thinker') return lines(
|
|
53
|
+
'## Handoff from Think Stage',
|
|
54
|
+
`**Objective:** ${d.objective ?? '(none)'}`,
|
|
55
|
+
`**Files in scope:** ${list(d.files) || 'unspecified'}`,
|
|
56
|
+
d.criteria ? `**Acceptance criteria:**\n${items(d.criteria)}` : '',
|
|
57
|
+
d.context ? `**Context:** ${d.context}` : '',
|
|
58
|
+
d.confidence != null ? `**Thinker confidence:** ${d.confidence}` : '',
|
|
59
|
+
);
|
|
60
|
+
if (targetRole === 'reviewer' && handoff.fromStage === 'worker') return lines(
|
|
61
|
+
'## Handoff from Work Stage',
|
|
62
|
+
`**Objective:** ${d.objective ?? '(none)'}`,
|
|
63
|
+
`**Files changed:** ${list(d.filesChanged) || 'unknown'}`,
|
|
64
|
+
d.criteria ? `**Original criteria:** ${Array.isArray(d.criteria) ? d.criteria.join('; ') : d.criteria}` : '',
|
|
65
|
+
d.testsRun ? `**Tests run:** ${d.testsRun}` : '',
|
|
66
|
+
d.diff ? `**Diff summary:**\n\`\`\`\n${d.diff.slice(0, 1200)}\n\`\`\`` : '',
|
|
67
|
+
);
|
|
68
|
+
if (targetRole === 'head' && handoff.fromStage === 'reviewer') return lines(
|
|
69
|
+
`## Review Result: ${d.pass ? 'PASS' : 'FAIL'}`,
|
|
70
|
+
d.findings ? `**Findings:**\n${items(d.findings)}` : '',
|
|
71
|
+
d.recommendation ? `**Recommendation:** ${d.recommendation}` : '',
|
|
72
|
+
d.severity ? `**Severity:** ${d.severity}` : '',
|
|
73
|
+
);
|
|
74
|
+
return JSON.stringify(handoff.data, null, 2);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function cleanupHandoffs(runId, cwd) {
|
|
78
|
+
try {
|
|
79
|
+
const dir = hDir(cwd);
|
|
80
|
+
if (!existsSync(dir)) return;
|
|
81
|
+
for (const name of readdirSync(dir)) {
|
|
82
|
+
if (name.startsWith(`${runId}_`)) try { unlinkSync(join(dir, name)); } catch { /* best-effort */ }
|
|
83
|
+
}
|
|
84
|
+
} catch { /* non-throwing */ }
|
|
85
|
+
}
|
package/src/recommendations.mjs
CHANGED
|
@@ -267,7 +267,7 @@ export function formatRecommendations(recs) {
|
|
|
267
267
|
const line = (content) => `│ ${pad(content)} │`;
|
|
268
268
|
|
|
269
269
|
const lines = [
|
|
270
|
-
'╭─ Recommendations ' + '─'.repeat(WIDTH -
|
|
270
|
+
'╭─ Recommendations ' + '─'.repeat(WIDTH - 20) + '╮',
|
|
271
271
|
line(''),
|
|
272
272
|
];
|
|
273
273
|
|
package/src/revert.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// revert.mjs — Undo recent auto-adjustments and applied recommendations
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
|
|
6
|
+
function dbDir(cwd) { return join(cwd || process.cwd(), '.dualbrain'); }
|
|
7
|
+
function changesPath(cwd) { return join(dbDir(cwd), 'changes.jsonl'); }
|
|
8
|
+
function configPath(cwd) { return join(dbDir(cwd), 'config.json'); }
|
|
9
|
+
|
|
10
|
+
function genId() { return 'chg_' + Math.random().toString(36).slice(2, 9); }
|
|
11
|
+
|
|
12
|
+
function readChanges(cwd) {
|
|
13
|
+
try {
|
|
14
|
+
if (!existsSync(changesPath(cwd))) return [];
|
|
15
|
+
return readFileSync(changesPath(cwd), 'utf8')
|
|
16
|
+
.split('\n').filter(Boolean).map(l => JSON.parse(l));
|
|
17
|
+
} catch { return []; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function writeChanges(records, cwd) {
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(dbDir(cwd), { recursive: true });
|
|
23
|
+
writeFileSync(changesPath(cwd), records.map(r => JSON.stringify(r)).join('\n') + '\n');
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function applyRevert(changeRecord, cwd) {
|
|
28
|
+
let config = {};
|
|
29
|
+
try { config = JSON.parse(readFileSync(configPath(cwd), 'utf8')); } catch {}
|
|
30
|
+
Object.assign(config, changeRecord.previousValue);
|
|
31
|
+
writeFileSync(configPath(cwd), JSON.stringify(config, null, 2) + '\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function relativeTime(iso) {
|
|
35
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
36
|
+
const m = Math.floor(diff / 60000);
|
|
37
|
+
if (m < 60) return `${m}m ago`;
|
|
38
|
+
const h = Math.floor(m / 60);
|
|
39
|
+
if (h < 24) return `${h}h ago`;
|
|
40
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatChange(change) {
|
|
44
|
+
const badge = change.type === 'auto' ? '(auto)' : change.type === 'recommendation' ? '(rec)' : '(manual)';
|
|
45
|
+
return `${relativeTime(change.timestamp).padEnd(8)} ${change.description} ${badge}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function recordChange({ type, category, description, previousValue, newValue }, cwd) {
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(dbDir(cwd), { recursive: true });
|
|
51
|
+
const record = {
|
|
52
|
+
id: genId(),
|
|
53
|
+
timestamp: new Date().toISOString(),
|
|
54
|
+
type, category, description, previousValue, newValue,
|
|
55
|
+
reverted: false,
|
|
56
|
+
};
|
|
57
|
+
writeFileSync(changesPath(cwd), JSON.stringify(record) + '\n', { flag: 'a' });
|
|
58
|
+
return record;
|
|
59
|
+
} catch { return null; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getRecentChanges(cwd, limit = 10) {
|
|
63
|
+
try {
|
|
64
|
+
return readChanges(cwd)
|
|
65
|
+
.filter(r => !r.reverted)
|
|
66
|
+
.reverse()
|
|
67
|
+
.slice(0, limit);
|
|
68
|
+
} catch { return []; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function revertChange(changeId, cwd) {
|
|
72
|
+
try {
|
|
73
|
+
const records = readChanges(cwd);
|
|
74
|
+
const idx = records.findIndex(r => r.id === changeId);
|
|
75
|
+
if (idx === -1) return { success: false, description: 'Change not found' };
|
|
76
|
+
const record = records[idx];
|
|
77
|
+
if (record.reverted) return { success: false, description: 'Already reverted' };
|
|
78
|
+
applyRevert(record, cwd);
|
|
79
|
+
records[idx] = { ...record, reverted: true };
|
|
80
|
+
writeChanges(records, cwd);
|
|
81
|
+
return { success: true, description: record.description };
|
|
82
|
+
} catch (e) { return { success: false, description: e.message }; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function revertAll(since, cwd) {
|
|
86
|
+
try {
|
|
87
|
+
const records = readChanges(cwd);
|
|
88
|
+
const cutoff = since ? new Date(since).getTime() : 0;
|
|
89
|
+
let count = 0;
|
|
90
|
+
for (let i = 0; i < records.length; i++) {
|
|
91
|
+
const r = records[i];
|
|
92
|
+
if (!r.reverted && new Date(r.timestamp).getTime() >= cutoff) {
|
|
93
|
+
applyRevert(r, cwd);
|
|
94
|
+
records[i] = { ...r, reverted: true };
|
|
95
|
+
count++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
writeChanges(records, cwd);
|
|
99
|
+
return { success: true, count };
|
|
100
|
+
} catch (e) { return { success: false, count: 0, error: e.message }; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function runRevert(cwd) {
|
|
104
|
+
const changes = getRecentChanges(cwd, 10);
|
|
105
|
+
const W = 59;
|
|
106
|
+
const border = '─'.repeat(W - 2);
|
|
107
|
+
const pad = s => '│ ' + s.padEnd(W - 4) + ' │';
|
|
108
|
+
|
|
109
|
+
console.log(`╭${border}╮`);
|
|
110
|
+
console.log(pad('Recent Changes'));
|
|
111
|
+
console.log(pad(''));
|
|
112
|
+
if (!changes.length) {
|
|
113
|
+
console.log(pad(' No recent changes to revert.'));
|
|
114
|
+
} else {
|
|
115
|
+
changes.forEach((c, i) => console.log(pad(` [${i + 1}] ${formatChange(c)}`)));
|
|
116
|
+
}
|
|
117
|
+
console.log(pad(''));
|
|
118
|
+
console.log(pad(' [number] revert [a] revert all [q] quit'));
|
|
119
|
+
console.log(pad(''));
|
|
120
|
+
console.log(`╰${border}╯`);
|
|
121
|
+
|
|
122
|
+
if (!changes.length) return;
|
|
123
|
+
|
|
124
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
125
|
+
const answer = await new Promise(res => rl.question('> ', res));
|
|
126
|
+
rl.close();
|
|
127
|
+
|
|
128
|
+
const input = answer.trim().toLowerCase();
|
|
129
|
+
if (input === 'q' || input === '') return;
|
|
130
|
+
if (input === 'a') {
|
|
131
|
+
const confirm = await new Promise(res => {
|
|
132
|
+
const r2 = createInterface({ input: process.stdin, output: process.stdout });
|
|
133
|
+
r2.question(`Revert all ${changes.length} changes? (y/N) `, ans => { r2.close(); res(ans); });
|
|
134
|
+
});
|
|
135
|
+
if (confirm.trim().toLowerCase() === 'y') {
|
|
136
|
+
const result = revertAll(null, cwd);
|
|
137
|
+
console.log(result.success ? `Reverted ${result.count} changes.` : `Error: ${result.error}`);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const n = parseInt(input, 10);
|
|
142
|
+
if (!isNaN(n) && n >= 1 && n <= changes.length) {
|
|
143
|
+
const target = changes[n - 1];
|
|
144
|
+
const result = revertChange(target.id, cwd);
|
|
145
|
+
console.log(result.success ? `Reverted: ${result.description}` : `Error: ${result.description}`);
|
|
146
|
+
} else {
|
|
147
|
+
console.log('Invalid selection.');
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/routing-advisor.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Learns which model works best for which task type from outcome signals.
|
|
3
3
|
|
|
4
4
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
5
|
+
import { checkFileSurvival } from './outcome.mjs';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
|
|
7
8
|
const ALPHA = 0.3;
|
|
@@ -42,6 +43,19 @@ function saveState(state, cwd) {
|
|
|
42
43
|
} catch { /* non-throwing */ }
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/** Cross-cell bias: average EMA from same-tier cells that have >= 8 observations. */
|
|
47
|
+
function getCrossCellBias(state, cellKey, model) {
|
|
48
|
+
const [tier] = cellKey.split(':');
|
|
49
|
+
let biasSum = 0, biasCount = 0;
|
|
50
|
+
for (const [key, models] of Object.entries(state)) {
|
|
51
|
+
if (key.startsWith(tier + ':') && key !== cellKey && models[model]) {
|
|
52
|
+
const entry = models[model];
|
|
53
|
+
if ((entry.observations ?? 0) >= 8) { biasSum += entry.ema; biasCount++; }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return biasCount > 0 ? biasSum / biasCount : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
const staticPrior = (tier, model) => STATIC_PRIORS[`${tier}:${model}`] ?? 0.5;
|
|
46
60
|
const cellObs = (state, key) => Object.values(state[key] ?? {}).reduce((s, m) => s + (m.observations ?? 0), 0);
|
|
47
61
|
const blended = (ema, n, tier, model) =>
|
|
@@ -58,9 +72,21 @@ export function adviseModel(taskProfile, cwd) {
|
|
|
58
72
|
|
|
59
73
|
const state = loadState(cwd);
|
|
60
74
|
const totalObs = cellObs(state, cellKey);
|
|
75
|
+
const grandTotal = Object.values(state).reduce((s, cell) =>
|
|
76
|
+
s + Object.values(cell).reduce((t, e) => t + (e.observations ?? 0), 0), 0);
|
|
61
77
|
|
|
62
78
|
if (totalObs < MIN_OBSERVATIONS) {
|
|
63
|
-
//
|
|
79
|
+
// When enough global data exists, blend cross-cell bias with static prior
|
|
80
|
+
if (grandTotal > 100) {
|
|
81
|
+
let bestModel = models[0], bestScore = -Infinity;
|
|
82
|
+
for (const m of models) {
|
|
83
|
+
const xbias = getCrossCellBias(state, cellKey, m);
|
|
84
|
+
const prior = staticPrior(validTier, m);
|
|
85
|
+
const score = xbias != null ? (xbias + prior) / 2 : prior;
|
|
86
|
+
if (score > bestScore) { bestScore = score; bestModel = m; }
|
|
87
|
+
}
|
|
88
|
+
return { model: bestModel, reason: 'cross-cell bias', confidence: 0.4, explored: false };
|
|
89
|
+
}
|
|
64
90
|
const best = models.reduce((a, b) => staticPrior(validTier, a) >= staticPrior(validTier, b) ? a : b);
|
|
65
91
|
return { model: best, reason: 'insufficient data, using heuristic', confidence: 0.3, explored: false };
|
|
66
92
|
}
|
|
@@ -129,6 +155,42 @@ export function getRoutingStats(cwd) {
|
|
|
129
155
|
}
|
|
130
156
|
}
|
|
131
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Loads cross-session routing state. If the state was last updated in a prior session,
|
|
160
|
+
* applies a mild decay (×0.95) to all EMA scores to account for staleness.
|
|
161
|
+
*/
|
|
162
|
+
export function loadCrossSessionPriors(cwd) {
|
|
163
|
+
try {
|
|
164
|
+
const state = loadState(cwd);
|
|
165
|
+
const sessionStart = state._sessionStart;
|
|
166
|
+
if (!sessionStart) return state; // no prior session marker
|
|
167
|
+
const lastMs = new Date(sessionStart).getTime();
|
|
168
|
+
if (isNaN(lastMs)) return state;
|
|
169
|
+
const stale = (Date.now() - lastMs) > 60_000; // more than 1 min old = different session
|
|
170
|
+
if (!stale) return state;
|
|
171
|
+
for (const [cellKey, models] of Object.entries(state)) {
|
|
172
|
+
if (cellKey.startsWith('_')) continue;
|
|
173
|
+
for (const entry of Object.values(models)) {
|
|
174
|
+
if (typeof entry.ema === 'number') entry.ema = entry.ema * 0.95;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return state;
|
|
178
|
+
} catch { return {}; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Records session start timestamp and triggers file survival checks.
|
|
183
|
+
* Call once at CLI session start.
|
|
184
|
+
*/
|
|
185
|
+
export async function markSessionStart(cwd) {
|
|
186
|
+
try {
|
|
187
|
+
const state = loadState(cwd);
|
|
188
|
+
state._sessionStart = new Date().toISOString();
|
|
189
|
+
saveState(state, cwd);
|
|
190
|
+
await checkFileSurvival(cwd).catch(() => {});
|
|
191
|
+
} catch { /* non-throwing */ }
|
|
192
|
+
}
|
|
193
|
+
|
|
132
194
|
export function resetAdvisor(cwd) {
|
|
133
195
|
try {
|
|
134
196
|
saveState({}, cwd);
|
package/src/self-correct.mjs
CHANGED
|
@@ -78,6 +78,7 @@ export function selectStrategy(failure, originalDecision, attemptNumber) {
|
|
|
78
78
|
case 'specification':
|
|
79
79
|
return { strategy: 'give-up', reason: 'ambiguous specification; user clarification needed' };
|
|
80
80
|
default: // unknown
|
|
81
|
+
if (tier >= 3) return { strategy: 'split', newDecision: originalDecision, reason: 'unknown failure at max tier; decomposing' };
|
|
81
82
|
return { strategy: 'escalate', newDecision: originalDecision, reason: 'unknown failure; escalating as precaution' };
|
|
82
83
|
}
|
|
83
84
|
}
|
|
@@ -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
|
+
}
|
package/src/signal.mjs
CHANGED
|
@@ -71,7 +71,7 @@ export function scoreOutcome(outcome, context = {}) {
|
|
|
71
71
|
// Signal 3: token efficiency (weight 0.25)
|
|
72
72
|
let effVal = null;
|
|
73
73
|
const filesChanged = outcome.filesChanged ?? 0;
|
|
74
|
-
const fileCount = typeof filesChanged === 'number' ? filesChanged :
|
|
74
|
+
const fileCount = Array.isArray(filesChanged) ? filesChanged.length : (typeof filesChanged === 'number' ? filesChanged : 0);
|
|
75
75
|
if (!(fileCount === 0 && tier === 'think')) {
|
|
76
76
|
const tokensUsed =
|
|
77
77
|
outcome.tokensUsed?.output ??
|
package/src/strategy.mjs
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// strategy.mjs — Dispatch strategy library + selection
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// ─── Strategy definitions ──────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export const STRATEGIES = {
|
|
8
|
+
direct: {
|
|
9
|
+
id: 'direct',
|
|
10
|
+
label: 'Direct dispatch',
|
|
11
|
+
description: 'Single agent, single task. Best for clear, focused work.',
|
|
12
|
+
applicability: { maxFiles: 3, maxComplexity: 'moderate', maxRisk: 'medium' },
|
|
13
|
+
cost: 1.0,
|
|
14
|
+
},
|
|
15
|
+
cascade: {
|
|
16
|
+
id: 'cascade',
|
|
17
|
+
label: 'Think → Execute cascade',
|
|
18
|
+
description: 'Cheap thinker refines spec, then worker executes. Best for routine-but-multi-step tasks.',
|
|
19
|
+
applicability: { minFiles: 1, minComplexity: 'moderate', maxRisk: 'high' },
|
|
20
|
+
cost: 1.3,
|
|
21
|
+
},
|
|
22
|
+
split: {
|
|
23
|
+
id: 'split',
|
|
24
|
+
label: 'Decompose → parallel dispatch',
|
|
25
|
+
description: 'Break into sub-tasks, dispatch each at optimal tier. Best for large multi-file changes.',
|
|
26
|
+
applicability: { minFiles: 4, minComplexity: 'complex' },
|
|
27
|
+
cost: 2.0,
|
|
28
|
+
},
|
|
29
|
+
'dual-review': {
|
|
30
|
+
id: 'dual-review',
|
|
31
|
+
label: 'Execute → adversarial review',
|
|
32
|
+
description: 'Worker implements, second model reviews. Best for high-risk/security code.',
|
|
33
|
+
applicability: { minRisk: 'high' },
|
|
34
|
+
cost: 1.5,
|
|
35
|
+
},
|
|
36
|
+
'architect-editor': {
|
|
37
|
+
id: 'architect-editor',
|
|
38
|
+
label: 'Architect reasons → editor implements',
|
|
39
|
+
description: 'Opus/o3 reasons freely, sonnet/haiku formats the edits. Best for complex architecture + implementation.',
|
|
40
|
+
applicability: { minComplexity: 'complex', minFiles: 3 },
|
|
41
|
+
cost: 1.8,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const COMPLEXITY_RANK = { trivial: 0, simple: 1, moderate: 2, complex: 3 };
|
|
48
|
+
const RISK_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
49
|
+
|
|
50
|
+
const COST_CAPS = {
|
|
51
|
+
frugal: 1.0,
|
|
52
|
+
'cost-saver': 1.3,
|
|
53
|
+
balanced: 2.0,
|
|
54
|
+
'quality-first': 3.0,
|
|
55
|
+
maximum: Infinity,
|
|
56
|
+
aggressive: Infinity, // maps to maximum behaviour
|
|
57
|
+
fullpower: Infinity,
|
|
58
|
+
fast: 1.3,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const SECURITY_KEYWORDS = /\b(auth|security|billing|payment|credential|secret|token|encrypt|permission|oauth|jwt)\b/i;
|
|
62
|
+
|
|
63
|
+
function costCap(workStyle) {
|
|
64
|
+
return COST_CAPS[workStyle] ?? 2.0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fileCount(detection) {
|
|
68
|
+
return detection?.fileCount ?? detection?.files ?? 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function complexityRank(detection) {
|
|
72
|
+
return COMPLEXITY_RANK[detection?.complexity] ?? 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function riskRank(detection) {
|
|
76
|
+
return RISK_RANK[detection?.risk] ?? 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function prompt(detection) {
|
|
80
|
+
return detection?.prompt ?? detection?.description ?? '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Scoring ───────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
function scoreStrategies(detection, workStyle) {
|
|
86
|
+
const files = fileCount(detection);
|
|
87
|
+
const cRank = complexityRank(detection);
|
|
88
|
+
const rRank = riskRank(detection);
|
|
89
|
+
const text = prompt(detection);
|
|
90
|
+
const frugal = workStyle === 'frugal';
|
|
91
|
+
const saver = workStyle === 'cost-saver' || workStyle === 'fast';
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
direct: 0.5,
|
|
95
|
+
|
|
96
|
+
cascade: 0
|
|
97
|
+
+ (cRank >= COMPLEXITY_RANK.moderate ? 0.3 : 0)
|
|
98
|
+
+ (files >= 2 ? 0.2 : 0)
|
|
99
|
+
- (frugal ? 0.5 : 0),
|
|
100
|
+
|
|
101
|
+
split: 0
|
|
102
|
+
+ (files >= 4 ? 0.4 : 0)
|
|
103
|
+
+ (cRank >= COMPLEXITY_RANK.complex ? 0.3 : 0)
|
|
104
|
+
- (frugal || saver ? 0.5 : 0),
|
|
105
|
+
|
|
106
|
+
'dual-review': 0
|
|
107
|
+
+ (rRank >= RISK_RANK.high ? 0.5 : 0)
|
|
108
|
+
+ (SECURITY_KEYWORDS.test(text) ? 0.3 : 0)
|
|
109
|
+
- (frugal ? 0.3 : 0),
|
|
110
|
+
|
|
111
|
+
'architect-editor': 0
|
|
112
|
+
+ (cRank >= COMPLEXITY_RANK.complex && files >= 3 ? 0.4 : 0)
|
|
113
|
+
- (saver ? 0.3 : 0),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Export 1: selectStrategy ─────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Select the best dispatch strategy for a task.
|
|
121
|
+
* @param {object} detection — from detect.mjs (detectTask output)
|
|
122
|
+
* @param {object} decision — from decide.mjs (decideRoute output)
|
|
123
|
+
* @param {object} profile — user profile (workStyle, etc.)
|
|
124
|
+
* @returns {{ strategy: string, reason: string, alternatives: string[] }}
|
|
125
|
+
*/
|
|
126
|
+
export function selectStrategy(detection, decision, profile) {
|
|
127
|
+
try {
|
|
128
|
+
const workStyle = profile?.workStyle ?? profile?.bias ?? 'balanced';
|
|
129
|
+
const cap = costCap(workStyle);
|
|
130
|
+
const scores = scoreStrategies(detection, workStyle);
|
|
131
|
+
|
|
132
|
+
// Filter by cost cap, then rank
|
|
133
|
+
const ranked = Object.entries(scores)
|
|
134
|
+
.filter(([id]) => STRATEGIES[id].cost <= cap)
|
|
135
|
+
.sort(([, a], [, b]) => b - a);
|
|
136
|
+
|
|
137
|
+
if (!ranked.length) {
|
|
138
|
+
// Fallback — always allow direct
|
|
139
|
+
return { strategy: 'direct', reason: 'Cost cap allows only direct dispatch.', alternatives: [] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const [bestId] = ranked[0];
|
|
143
|
+
const alternatives = ranked.slice(1).map(([id]) => id);
|
|
144
|
+
|
|
145
|
+
const reasons = {
|
|
146
|
+
direct: 'Clear, focused task within single-agent scope.',
|
|
147
|
+
cascade: 'Multi-step task benefits from spec refinement before execution.',
|
|
148
|
+
split: 'Large file count warrants decomposition into parallel sub-tasks.',
|
|
149
|
+
'dual-review': 'High-risk or security-sensitive work requires adversarial review.',
|
|
150
|
+
'architect-editor': 'Complex architecture + implementation benefits from dual-model reasoning.',
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
strategy: bestId,
|
|
155
|
+
reason: reasons[bestId] ?? 'Best match for task profile.',
|
|
156
|
+
alternatives,
|
|
157
|
+
};
|
|
158
|
+
} catch {
|
|
159
|
+
return { strategy: 'direct', reason: 'Fallback to direct dispatch.', alternatives: [] };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Export 2: describeStrategy ───────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Human-readable description of a strategy.
|
|
167
|
+
* @param {string} strategyId
|
|
168
|
+
* @returns {string}
|
|
169
|
+
*/
|
|
170
|
+
export function describeStrategy(strategyId) {
|
|
171
|
+
const s = STRATEGIES[strategyId];
|
|
172
|
+
if (!s) return `Unknown strategy: ${strategyId}`;
|
|
173
|
+
return `${s.label} (cost ×${s.cost})\n${s.description}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Export 3: getStrategyForTask ─────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Convenience: load profile + decision context, select strategy, return with execution plan.
|
|
180
|
+
* @param {object} detection — from detect.mjs
|
|
181
|
+
* @param {string} [cwd] — working directory (for profile loading)
|
|
182
|
+
* @returns {{ strategy: string, reason: string, alternatives: string[], plan: { steps: object[] } }}
|
|
183
|
+
*/
|
|
184
|
+
export function getStrategyForTask(detection, cwd) {
|
|
185
|
+
const dir = cwd ?? process.cwd();
|
|
186
|
+
let profile = {};
|
|
187
|
+
try {
|
|
188
|
+
const p = join(dir, '.dualbrain', 'config.json');
|
|
189
|
+
if (existsSync(p)) profile = JSON.parse(readFileSync(p, 'utf8'));
|
|
190
|
+
} catch { /* non-throwing */ }
|
|
191
|
+
|
|
192
|
+
// Minimal decision stub (model resolved from profile if available)
|
|
193
|
+
const decision = { model: profile?.models?.execute ?? 'sonnet' };
|
|
194
|
+
const selected = selectStrategy(detection, decision, profile);
|
|
195
|
+
|
|
196
|
+
return { ...selected, plan: buildPlan(selected.strategy, decision) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Plan builder ─────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
function buildPlan(strategyId, decision) {
|
|
202
|
+
const m = decision?.model ?? 'sonnet';
|
|
203
|
+
const plans = {
|
|
204
|
+
direct: [
|
|
205
|
+
{ role: 'worker', model: m, description: 'Execute task' },
|
|
206
|
+
],
|
|
207
|
+
cascade: [
|
|
208
|
+
{ role: 'thinker', model: 'sonnet', description: 'Refine spec' },
|
|
209
|
+
{ role: 'worker', model: 'from-think', description: 'Execute refined spec' },
|
|
210
|
+
],
|
|
211
|
+
split: [
|
|
212
|
+
{ role: 'thinker', model: 'sonnet', description: 'Decompose into sub-tasks' },
|
|
213
|
+
{ role: 'worker', model: 'varies', description: 'Execute each sub-task' },
|
|
214
|
+
],
|
|
215
|
+
'dual-review': [
|
|
216
|
+
{ role: 'worker', model: m, description: 'Implement' },
|
|
217
|
+
{ role: 'reviewer', model: 'sonnet', description: 'Adversarial review' },
|
|
218
|
+
],
|
|
219
|
+
'architect-editor': [
|
|
220
|
+
{ role: 'thinker', model: 'opus', description: 'Architect solution' },
|
|
221
|
+
{ role: 'worker', model: 'haiku', description: 'Format edits' },
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
return { steps: plans[strategyId] ?? plans.direct };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Export 4: listStrategies ─────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* List all strategies for display.
|
|
231
|
+
* @returns {{ id: string, label: string, description: string, cost: number }[]}
|
|
232
|
+
*/
|
|
233
|
+
export function listStrategies() {
|
|
234
|
+
return Object.values(STRATEGIES).map(({ id, label, description, cost }) => ({ id, label, description, cost }));
|
|
235
|
+
}
|