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
package/bin/dual-brain.mjs
CHANGED
|
@@ -1094,6 +1094,18 @@ async function cmdStatus(args = []) {
|
|
|
1094
1094
|
}
|
|
1095
1095
|
}
|
|
1096
1096
|
} catch { /* network unavailable — skip */ }
|
|
1097
|
+
|
|
1098
|
+
// Show top recommendation if available
|
|
1099
|
+
try {
|
|
1100
|
+
const { getTopRecommendation } = await import('../src/recommendations.mjs');
|
|
1101
|
+
const rec = getTopRecommendation(process.cwd());
|
|
1102
|
+
if (rec) {
|
|
1103
|
+
console.log('');
|
|
1104
|
+
console.log(` \x1b[33m💡 ${rec.title}\x1b[0m`);
|
|
1105
|
+
console.log(` ${rec.description}`);
|
|
1106
|
+
if (rec.action) console.log(` → ${rec.action}`);
|
|
1107
|
+
}
|
|
1108
|
+
} catch { /* non-blocking */ }
|
|
1097
1109
|
}
|
|
1098
1110
|
|
|
1099
1111
|
// ─── cmdHot / cmdCool ─────────────────────────────────────────────────────────
|
|
@@ -4215,6 +4227,30 @@ async function settingsScreen(rl, ask) {
|
|
|
4215
4227
|
return { next: 'diagnostics' };
|
|
4216
4228
|
}
|
|
4217
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
|
+
|
|
4218
4254
|
if (choice === 'b' || choice === 'back' || raw === '\x1b') { return { next: 'main' }; }
|
|
4219
4255
|
|
|
4220
4256
|
return { next: 'main' };
|
|
@@ -6532,6 +6568,18 @@ async function main() {
|
|
|
6532
6568
|
}
|
|
6533
6569
|
|
|
6534
6570
|
if (cmd === 'init') {
|
|
6571
|
+
// init --reconfigure: run setup-flow reconfiguration
|
|
6572
|
+
if (args.includes('--reconfigure')) {
|
|
6573
|
+
try {
|
|
6574
|
+
const { runSetup } = await import('../src/setup-flow.mjs');
|
|
6575
|
+
await runSetup(process.cwd(), { reconfigure: true });
|
|
6576
|
+
} catch (e) {
|
|
6577
|
+
console.error('setup-flow.mjs not available — skipping reconfigure');
|
|
6578
|
+
if (process.env.DEBUG) console.error(e.message);
|
|
6579
|
+
}
|
|
6580
|
+
return;
|
|
6581
|
+
}
|
|
6582
|
+
|
|
6535
6583
|
// init --reset: clear credentials.json and re-run wizard
|
|
6536
6584
|
if (args.includes('--reset')) {
|
|
6537
6585
|
const cwd = process.cwd();
|
|
@@ -6593,6 +6641,40 @@ async function main() {
|
|
|
6593
6641
|
return;
|
|
6594
6642
|
}
|
|
6595
6643
|
|
|
6644
|
+
if (cmd === 'setup') {
|
|
6645
|
+
const { runSetup } = await import('../src/setup-flow.mjs');
|
|
6646
|
+
await runSetup(process.cwd(), { reconfigure: args.includes('--reconfigure') });
|
|
6647
|
+
return;
|
|
6648
|
+
}
|
|
6649
|
+
|
|
6650
|
+
if (cmd === 'advice' || cmd === 'recommend') {
|
|
6651
|
+
const { generateRecommendations, formatRecommendations } = await import('../src/recommendations.mjs');
|
|
6652
|
+
const recs = generateRecommendations(process.cwd());
|
|
6653
|
+
if (recs.length === 0) {
|
|
6654
|
+
console.log(' No recommendations yet. Need 20+ dispatches to generate advice.');
|
|
6655
|
+
} else {
|
|
6656
|
+
console.log(formatRecommendations(recs));
|
|
6657
|
+
}
|
|
6658
|
+
return;
|
|
6659
|
+
}
|
|
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
|
+
|
|
6596
6678
|
// One-shot commands — run and exit
|
|
6597
6679
|
if (cmd === 'install') {
|
|
6598
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.28",
|
|
4
4
|
"description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -52,7 +52,12 @@
|
|
|
52
52
|
"./routing-advisor": "./src/routing-advisor.mjs",
|
|
53
53
|
"./subscription": "./src/subscription.mjs",
|
|
54
54
|
"./recommendations": "./src/recommendations.mjs",
|
|
55
|
-
"./setup-flow": "./src/setup-flow.mjs"
|
|
55
|
+
"./setup-flow": "./src/setup-flow.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"
|
|
56
61
|
},
|
|
57
62
|
"keywords": [
|
|
58
63
|
"claude-code",
|
|
@@ -144,6 +149,11 @@
|
|
|
144
149
|
"src/subscription.mjs",
|
|
145
150
|
"src/recommendations.mjs",
|
|
146
151
|
"src/setup-flow.mjs",
|
|
152
|
+
"src/self-correct.mjs",
|
|
153
|
+
"src/settings-tui.mjs",
|
|
154
|
+
"src/revert.mjs",
|
|
155
|
+
"src/strategy.mjs",
|
|
156
|
+
"src/handoff.mjs",
|
|
147
157
|
"bin/*.mjs",
|
|
148
158
|
"hooks/enforce-tier.mjs",
|
|
149
159
|
"hooks/cost-logger.mjs",
|
package/src/decide.mjs
CHANGED
|
@@ -49,6 +49,28 @@ function _loadModelRegistry() {
|
|
|
49
49
|
// Kick off the load immediately so it is ready before the first routing call.
|
|
50
50
|
_loadModelRegistry();
|
|
51
51
|
|
|
52
|
+
// ─── Routing Advisor (optional, lazy-loaded) ──────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Cached reference to routing-advisor.mjs exports. Populated on first import.
|
|
56
|
+
* Remains null if unavailable — decideRoute skips advisor consultation in that case.
|
|
57
|
+
*/
|
|
58
|
+
let routingAdvisor = null;
|
|
59
|
+
let _advisorLoadAttempted = false;
|
|
60
|
+
|
|
61
|
+
function _loadRoutingAdvisor() {
|
|
62
|
+
if (_advisorLoadAttempted) return;
|
|
63
|
+
_advisorLoadAttempted = true;
|
|
64
|
+
import('./routing-advisor.mjs').then(mod => {
|
|
65
|
+
routingAdvisor = mod;
|
|
66
|
+
}).catch(() => {
|
|
67
|
+
// routing-advisor.mjs unavailable — skip learned routing
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Kick off the load immediately so it is ready before the first routing call.
|
|
72
|
+
_loadRoutingAdvisor();
|
|
73
|
+
|
|
52
74
|
// ─── Work Styles ─────────────────────────────────────────────────────────────
|
|
53
75
|
|
|
54
76
|
/**
|
|
@@ -890,6 +912,28 @@ export function decideRoute({ profile = {}, detection = {}, cwd, thinkResult, se
|
|
|
890
912
|
// If the pipeline changed the model (downgrade/bias/floor), resolve the new short name to a full ID.
|
|
891
913
|
model = toFullModelId(model, provider, tier);
|
|
892
914
|
|
|
915
|
+
// ── Routing advisor: consult learned EMA model for this task type ─────────
|
|
916
|
+
// Non-blocking: only overrides when advisor has enough observations (confidence > 0.3).
|
|
917
|
+
// Uses short model names; advisor only covers Claude models (haiku/sonnet/opus).
|
|
918
|
+
let _advisorOverride = null;
|
|
919
|
+
if (routingAdvisor && provider === 'claude') {
|
|
920
|
+
try {
|
|
921
|
+
const advice = routingAdvisor.adviseModel(
|
|
922
|
+
{ intent: detection.intent, tier, risk: detection.risk },
|
|
923
|
+
cwd
|
|
924
|
+
);
|
|
925
|
+
if (advice.confidence > 0.3 && advice.model) {
|
|
926
|
+
const advisorShort = advice.model; // advisor returns short names
|
|
927
|
+
const previousModel = toShortName(model, 'claude');
|
|
928
|
+
if (advisorShort !== previousModel && available.claude.includes(advisorShort)) {
|
|
929
|
+
const overrideFullId = toFullModelId(advisorShort, 'claude', tier);
|
|
930
|
+
_advisorOverride = { from: model, to: overrideFullId, reason: advice.reason, explored: advice.explored };
|
|
931
|
+
model = overrideFullId;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
} catch { /* non-blocking */ }
|
|
935
|
+
}
|
|
936
|
+
|
|
893
937
|
// ── Challenger / dual-brain decision ─────────────────────────────────────
|
|
894
938
|
const hasBothProviders = !!(
|
|
895
939
|
profile?.providers?.claude?.enabled &&
|
|
@@ -938,6 +982,7 @@ export function decideRoute({ profile = {}, detection = {}, cwd, thinkResult, se
|
|
|
938
982
|
explanation: '',
|
|
939
983
|
_healthScores: healthScores,
|
|
940
984
|
_workStyle: workStyle,
|
|
985
|
+
...(_advisorOverride && { _advisorOverride }),
|
|
941
986
|
};
|
|
942
987
|
|
|
943
988
|
decision.explanation = explainDecision(decision, detection, profileWithEffectiveBias);
|
package/src/dispatch.mjs
CHANGED
|
@@ -1181,6 +1181,29 @@ async function dispatch(input = {}) {
|
|
|
1181
1181
|
const { recordDispatchOutcome } = await import('./outcome.mjs');
|
|
1182
1182
|
recordDispatchOutcome(input, nativeResult);
|
|
1183
1183
|
} catch { /* never block */ }
|
|
1184
|
+
|
|
1185
|
+
// ── Self-correction: intelligent retry after failover exhaustion ──────────
|
|
1186
|
+
if (!success) {
|
|
1187
|
+
const attemptNumber = input._retryAttempt || 1;
|
|
1188
|
+
try {
|
|
1189
|
+
const { shouldRetry } = await import('./self-correct.mjs');
|
|
1190
|
+
const retry = shouldRetry(nativeResult, decision, attemptNumber);
|
|
1191
|
+
if (retry.retry && retry.decision) {
|
|
1192
|
+
if (verbose) process.stderr.write(`[dual-brain] self-correct: ${retry.strategy} (attempt ${attemptNumber + 1}, reason: ${retry.reason})\n`);
|
|
1193
|
+
return dispatch({
|
|
1194
|
+
...input,
|
|
1195
|
+
decision: retry.decision,
|
|
1196
|
+
_retryAttempt: attemptNumber + 1,
|
|
1197
|
+
_skipPreDispatchThink: retry.strategy !== 'rethink',
|
|
1198
|
+
_skipRelatedContext: true,
|
|
1199
|
+
});
|
|
1200
|
+
} else if (verbose) {
|
|
1201
|
+
process.stderr.write(`[dual-brain] self-correct: giving up (${retry.reason})\n`);
|
|
1202
|
+
}
|
|
1203
|
+
} catch { /* non-blocking — if self-correct fails, return original failure */ }
|
|
1204
|
+
}
|
|
1205
|
+
// ── End self-correction ───────────────────────────────────────────────────
|
|
1206
|
+
|
|
1184
1207
|
return nativeResult;
|
|
1185
1208
|
}
|
|
1186
1209
|
|
|
@@ -1303,6 +1326,29 @@ async function dispatch(input = {}) {
|
|
|
1303
1326
|
const { recordDispatchOutcome } = await import('./outcome.mjs');
|
|
1304
1327
|
recordDispatchOutcome(input, subResult);
|
|
1305
1328
|
} catch { /* never block */ }
|
|
1329
|
+
|
|
1330
|
+
// ── Self-correction: intelligent retry after failover exhaustion ──────────
|
|
1331
|
+
if (!success) {
|
|
1332
|
+
const attemptNumber = input._retryAttempt || 1;
|
|
1333
|
+
try {
|
|
1334
|
+
const { shouldRetry } = await import('./self-correct.mjs');
|
|
1335
|
+
const retry = shouldRetry(subResult, decision, attemptNumber);
|
|
1336
|
+
if (retry.retry && retry.decision) {
|
|
1337
|
+
if (verbose) process.stderr.write(`[dual-brain] self-correct: ${retry.strategy} (attempt ${attemptNumber + 1}, reason: ${retry.reason})\n`);
|
|
1338
|
+
return dispatch({
|
|
1339
|
+
...input,
|
|
1340
|
+
decision: retry.decision,
|
|
1341
|
+
_retryAttempt: attemptNumber + 1,
|
|
1342
|
+
_skipPreDispatchThink: retry.strategy !== 'rethink',
|
|
1343
|
+
_skipRelatedContext: true,
|
|
1344
|
+
});
|
|
1345
|
+
} else if (verbose) {
|
|
1346
|
+
process.stderr.write(`[dual-brain] self-correct: giving up (${retry.reason})\n`);
|
|
1347
|
+
}
|
|
1348
|
+
} catch { /* non-blocking — if self-correct fails, return original failure */ }
|
|
1349
|
+
}
|
|
1350
|
+
// ── End self-correction ───────────────────────────────────────────────────
|
|
1351
|
+
|
|
1306
1352
|
return subResult;
|
|
1307
1353
|
}
|
|
1308
1354
|
|
package/src/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/outcome.mjs
CHANGED
|
@@ -45,6 +45,16 @@ function last7DaysFiles(cwd) {
|
|
|
45
45
|
return files;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const INTENT_KEYWORDS = ['implement', 'fix', 'refactor', 'review', 'search', 'test'];
|
|
49
|
+
|
|
50
|
+
function deriveIntent(prompt, tier) {
|
|
51
|
+
const lower = (prompt ?? '').toLowerCase();
|
|
52
|
+
for (const kw of INTENT_KEYWORDS) {
|
|
53
|
+
if (lower.includes(kw)) return kw;
|
|
54
|
+
}
|
|
55
|
+
return tier ?? 'execute';
|
|
56
|
+
}
|
|
57
|
+
|
|
48
58
|
export function recordDispatchOutcome(dispatchInput, result) {
|
|
49
59
|
try {
|
|
50
60
|
const cwd = dispatchInput.cwd ?? process.cwd();
|
|
@@ -69,6 +79,24 @@ export function recordDispatchOutcome(dispatchInput, result) {
|
|
|
69
79
|
|
|
70
80
|
const filePath = join(outcomesDir(cwd), `outcome_${id}.json`);
|
|
71
81
|
writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8');
|
|
82
|
+
|
|
83
|
+
// Score the outcome for the routing advisor (non-blocking)
|
|
84
|
+
try {
|
|
85
|
+
import('./signal.mjs').then(({ scoreOutcome }) =>
|
|
86
|
+
import('./routing-advisor.mjs').then(({ recordReward }) => {
|
|
87
|
+
const scored = scoreOutcome(record);
|
|
88
|
+
const intent = deriveIntent(record.prompt, record.tier);
|
|
89
|
+
const cellKey = `${record.tier}:${intent}`;
|
|
90
|
+
// Normalize full model ID to short name for the advisor cell
|
|
91
|
+
const modelId = record.model ?? 'sonnet';
|
|
92
|
+
const shortModel = /haiku/i.test(modelId) ? 'haiku'
|
|
93
|
+
: /opus/i.test(modelId) ? 'opus'
|
|
94
|
+
: 'sonnet';
|
|
95
|
+
recordReward(cellKey, shortModel, scored.reward, cwd);
|
|
96
|
+
})
|
|
97
|
+
).catch(() => { /* non-blocking */ });
|
|
98
|
+
} catch { /* non-blocking */ }
|
|
99
|
+
|
|
72
100
|
return record;
|
|
73
101
|
} catch {
|
|
74
102
|
return null;
|
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);
|