dual-brain 0.2.28 → 0.2.30
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 +72 -1
- package/package.json +1 -1
- package/src/context-intel.mjs +4 -2
- package/src/handoff.mjs +5 -3
- package/src/outcome.mjs +15 -1
- package/src/pipeline.mjs +22 -0
- package/src/recommendations.mjs +8 -3
- package/src/routing-advisor.mjs +5 -1
- package/src/self-correct.mjs +2 -0
- package/src/setup-flow.mjs +8 -0
- package/src/signal.mjs +4 -3
package/bin/dual-brain.mjs
CHANGED
|
@@ -1106,6 +1106,29 @@ async function cmdStatus(args = []) {
|
|
|
1106
1106
|
if (rec.action) console.log(` → ${rec.action}`);
|
|
1107
1107
|
}
|
|
1108
1108
|
} catch { /* non-blocking */ }
|
|
1109
|
+
|
|
1110
|
+
// Intelligence layer status
|
|
1111
|
+
try {
|
|
1112
|
+
const { getRoutingStats } = await import('../src/routing-advisor.mjs');
|
|
1113
|
+
const { getThinkingStats } = await import('../src/think-engine.mjs');
|
|
1114
|
+
const stats = getRoutingStats(cwd);
|
|
1115
|
+
const thinkStats = getThinkingStats(cwd);
|
|
1116
|
+
|
|
1117
|
+
if (stats.totalObservations > 0 || thinkStats.totalDecisions > 0) {
|
|
1118
|
+
console.log('');
|
|
1119
|
+
console.log(' \x1b[2m─── Intelligence ───\x1b[0m');
|
|
1120
|
+
if (stats.totalObservations > 0) {
|
|
1121
|
+
console.log(` Routing: ${stats.totalObservations} observations, learning ${stats.totalObservations >= 5 ? 'active' : 'warming up'}`);
|
|
1122
|
+
if (stats.topPerformers?.length > 0) {
|
|
1123
|
+
const top = stats.topPerformers[0];
|
|
1124
|
+
console.log(` Best: ${top.model} on ${top.cell} (${(top.ema * 100).toFixed(0)}% EMA, n=${top.observations})`);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
if (thinkStats.totalDecisions > 0) {
|
|
1128
|
+
console.log(` Think: ${thinkStats.totalDecisions} decisions, ${(thinkStats.cacheHitRate * 100).toFixed(0)}% cache hit rate`);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
} catch { /* non-blocking */ }
|
|
1109
1132
|
}
|
|
1110
1133
|
|
|
1111
1134
|
// ─── cmdHot / cmdCool ─────────────────────────────────────────────────────────
|
|
@@ -6510,6 +6533,12 @@ async function main() {
|
|
|
6510
6533
|
const args = process.argv.slice(2);
|
|
6511
6534
|
const cmd = args[0];
|
|
6512
6535
|
|
|
6536
|
+
// Session start marker — feeds routing advisor with cross-session timing signals
|
|
6537
|
+
try {
|
|
6538
|
+
const { markSessionStart } = await import('../src/routing-advisor.mjs');
|
|
6539
|
+
markSessionStart(process.cwd());
|
|
6540
|
+
} catch { /* non-blocking */ }
|
|
6541
|
+
|
|
6513
6542
|
if (cmd === '--help' || cmd === '-h') { printHelp(); return; }
|
|
6514
6543
|
if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
|
|
6515
6544
|
|
|
@@ -6651,13 +6680,55 @@ async function main() {
|
|
|
6651
6680
|
const { generateRecommendations, formatRecommendations } = await import('../src/recommendations.mjs');
|
|
6652
6681
|
const recs = generateRecommendations(process.cwd());
|
|
6653
6682
|
if (recs.length === 0) {
|
|
6654
|
-
console.log('
|
|
6683
|
+
console.log('');
|
|
6684
|
+
console.log(' \x1b[2m─── HEAD Analysis ───\x1b[0m');
|
|
6685
|
+
console.log('');
|
|
6686
|
+
const { getRoutingStats } = await import('../src/routing-advisor.mjs');
|
|
6687
|
+
const stats = getRoutingStats(process.cwd());
|
|
6688
|
+
if (stats.totalObservations < 20) {
|
|
6689
|
+
console.log(` Need more data: ${stats.totalObservations}/20 observations before recommendations.`);
|
|
6690
|
+
console.log(` Keep dispatching — the system learns from every task.`);
|
|
6691
|
+
} else {
|
|
6692
|
+
console.log(' No recommendations — current configuration is performing well.');
|
|
6693
|
+
}
|
|
6694
|
+
console.log('');
|
|
6655
6695
|
} else {
|
|
6656
6696
|
console.log(formatRecommendations(recs));
|
|
6657
6697
|
}
|
|
6658
6698
|
return;
|
|
6659
6699
|
}
|
|
6660
6700
|
|
|
6701
|
+
if (cmd === 'stats' || cmd === 'intelligence') {
|
|
6702
|
+
const { getRoutingStats } = await import('../src/routing-advisor.mjs');
|
|
6703
|
+
const { getThinkingStats } = await import('../src/think-engine.mjs');
|
|
6704
|
+
const stats = getRoutingStats(process.cwd());
|
|
6705
|
+
const thinkStats = getThinkingStats(process.cwd());
|
|
6706
|
+
|
|
6707
|
+
console.log('');
|
|
6708
|
+
console.log(' \x1b[1mdual-brain intelligence report\x1b[0m');
|
|
6709
|
+
console.log('');
|
|
6710
|
+
console.log(` Routing observations: ${stats.totalObservations}`);
|
|
6711
|
+
if (stats.topPerformers?.length > 0) {
|
|
6712
|
+
console.log(' Top performers:');
|
|
6713
|
+
for (const p of stats.topPerformers.slice(0, 5)) {
|
|
6714
|
+
console.log(` ${p.cell} → ${p.model} (${(p.ema * 100).toFixed(0)}%, n=${p.observations})`);
|
|
6715
|
+
}
|
|
6716
|
+
}
|
|
6717
|
+
if (stats.worstPerformers?.length > 0) {
|
|
6718
|
+
console.log(' Underperformers:');
|
|
6719
|
+
for (const p of stats.worstPerformers.slice(0, 3)) {
|
|
6720
|
+
console.log(` ${p.cell} → ${p.model} (${(p.ema * 100).toFixed(0)}%, n=${p.observations})`);
|
|
6721
|
+
}
|
|
6722
|
+
}
|
|
6723
|
+
console.log('');
|
|
6724
|
+
console.log(` Think decisions: ${thinkStats.totalDecisions}`);
|
|
6725
|
+
console.log(` Cache hit rate: ${(thinkStats.cacheHitRate * 100).toFixed(0)}%`);
|
|
6726
|
+
console.log(` Tokens saved: ~${(thinkStats.totalTokensSaved / 1000).toFixed(0)}K`);
|
|
6727
|
+
console.log(` Tier distribution: recall=${thinkStats.tierDistribution.recall}, quick=${thinkStats.tierDistribution.quick}, standard=${thinkStats.tierDistribution.standard}, deep=${thinkStats.tierDistribution.deep}, ultra=${thinkStats.tierDistribution.ultra}`);
|
|
6728
|
+
console.log('');
|
|
6729
|
+
return;
|
|
6730
|
+
}
|
|
6731
|
+
|
|
6661
6732
|
if (cmd === 'revert' || cmd === 'undo') {
|
|
6662
6733
|
const { runRevert } = await import('../src/revert.mjs');
|
|
6663
6734
|
await runRevert(process.cwd());
|
package/package.json
CHANGED
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
CHANGED
|
@@ -22,10 +22,11 @@ function validate(from, to, data) {
|
|
|
22
22
|
export function createHandoff(fromStage, toStage, data, runId, cwd) {
|
|
23
23
|
try {
|
|
24
24
|
validate(fromStage, toStage, data);
|
|
25
|
+
const safeRunId = String(runId).replace(/[^a-z0-9_-]/gi, '_').slice(0, 50);
|
|
25
26
|
const dir = hDir(cwd);
|
|
26
27
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
27
|
-
const record = { fromStage, toStage, runId, createdAt: new Date().toISOString(), data };
|
|
28
|
-
const dest = hPath(
|
|
28
|
+
const record = { fromStage, toStage, runId: safeRunId, createdAt: new Date().toISOString(), data };
|
|
29
|
+
const dest = hPath(safeRunId, fromStage, toStage, cwd); const tmp = dest + '.tmp';
|
|
29
30
|
writeFileSync(tmp, JSON.stringify(record, null, 2), 'utf8');
|
|
30
31
|
try { renameSync(tmp, dest); } catch { writeFileSync(dest, JSON.stringify(record, null, 2), 'utf8'); }
|
|
31
32
|
return record;
|
|
@@ -34,7 +35,8 @@ export function createHandoff(fromStage, toStage, data, runId, cwd) {
|
|
|
34
35
|
|
|
35
36
|
export function consumeHandoff(runId, fromStage, toStage, cwd) {
|
|
36
37
|
try {
|
|
37
|
-
const
|
|
38
|
+
const safeRunId = String(runId).replace(/[^a-z0-9_-]/gi, '_').slice(0, 50);
|
|
39
|
+
const p = hPath(safeRunId, fromStage, toStage, cwd);
|
|
38
40
|
if (!existsSync(p)) return null;
|
|
39
41
|
const record = JSON.parse(readFileSync(p, 'utf8'));
|
|
40
42
|
try { unlinkSync(p); } catch { /* best-effort */ }
|
package/src/outcome.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, appendFileSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'fs';
|
|
1
|
+
import { mkdirSync, appendFileSync, writeFileSync, readFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
import { execSync } from 'child_process';
|
|
@@ -55,6 +55,19 @@ function deriveIntent(prompt, tier) {
|
|
|
55
55
|
return tier ?? 'execute';
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
function pruneOutcomes(cwd) {
|
|
59
|
+
const dir = join(cwd, '.dualbrain', 'outcomes');
|
|
60
|
+
try {
|
|
61
|
+
const files = readdirSync(dir).filter(f => f.startsWith('outcome_')).sort();
|
|
62
|
+
if (files.length > 500) {
|
|
63
|
+
const toDelete = files.slice(0, files.length - 400);
|
|
64
|
+
for (const f of toDelete) {
|
|
65
|
+
try { unlinkSync(join(dir, f)); } catch {}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
export function recordDispatchOutcome(dispatchInput, result) {
|
|
59
72
|
try {
|
|
60
73
|
const cwd = dispatchInput.cwd ?? process.cwd();
|
|
@@ -97,6 +110,7 @@ export function recordDispatchOutcome(dispatchInput, result) {
|
|
|
97
110
|
).catch(() => { /* non-blocking */ });
|
|
98
111
|
} catch { /* non-blocking */ }
|
|
99
112
|
|
|
113
|
+
pruneOutcomes(cwd);
|
|
100
114
|
return record;
|
|
101
115
|
} catch {
|
|
102
116
|
return null;
|
package/src/pipeline.mjs
CHANGED
|
@@ -1270,6 +1270,17 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
1270
1270
|
run._thinkRefinedFiles = thinkRefinement.files;
|
|
1271
1271
|
decision = thinkRefinement.decision;
|
|
1272
1272
|
|
|
1273
|
+
// Record the think→work handoff for cross-agent context continuity
|
|
1274
|
+
try {
|
|
1275
|
+
const { createHandoff } = await import('./handoff.mjs');
|
|
1276
|
+
createHandoff('thinker', 'worker', {
|
|
1277
|
+
objective: thinkRefinement.prompt,
|
|
1278
|
+
files: thinkRefinement.files,
|
|
1279
|
+
criteria: thinkRefinement.decision?.criteria || [],
|
|
1280
|
+
confidence: thinkRefinement.confidence,
|
|
1281
|
+
}, run.id || Date.now().toString(36), cwd);
|
|
1282
|
+
} catch { /* non-blocking */ }
|
|
1283
|
+
|
|
1273
1284
|
// Cascade: if think agent is highly confident and task is simple, downgrade worker model
|
|
1274
1285
|
if (thinkRefinement.decision) {
|
|
1275
1286
|
const thinkConf = thinkRefinement.confidence || 0;
|
|
@@ -1288,6 +1299,17 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
1288
1299
|
}
|
|
1289
1300
|
}
|
|
1290
1301
|
|
|
1302
|
+
// Strategy selection — may override dispatch pattern
|
|
1303
|
+
try {
|
|
1304
|
+
const { selectStrategy } = await import('./strategy.mjs');
|
|
1305
|
+
const strategyResult = selectStrategy(run.context.detection, decision, run.context.profile);
|
|
1306
|
+
if (strategyResult.strategy !== 'direct') {
|
|
1307
|
+
decision._strategy = strategyResult.strategy;
|
|
1308
|
+
decision._strategyReason = strategyResult.reason;
|
|
1309
|
+
if (verbose) process.stderr.write(`[dual-brain] strategy: ${strategyResult.strategy} (${strategyResult.reason})\n`);
|
|
1310
|
+
}
|
|
1311
|
+
} catch { /* non-blocking */ }
|
|
1312
|
+
|
|
1291
1313
|
// Resolve the (possibly refined) prompt and file list for dispatch
|
|
1292
1314
|
const dispatchPrompt = run._thinkRefinedPrompt ?? effectivePrompt;
|
|
1293
1315
|
const dispatchFiles = run._thinkRefinedFiles ?? files;
|
package/src/recommendations.mjs
CHANGED
|
@@ -67,6 +67,7 @@ function thinkROI(metrics) {
|
|
|
67
67
|
function modelMismatch(routingState) {
|
|
68
68
|
const recs = [];
|
|
69
69
|
for (const [taskType, models] of Object.entries(routingState)) {
|
|
70
|
+
if (taskType.startsWith('_')) continue; // skip metadata keys
|
|
70
71
|
for (const [model, stats] of Object.entries(models)) {
|
|
71
72
|
const { ema, observations } = stats || {};
|
|
72
73
|
if (observations >= 10 && ema < 0.4) {
|
|
@@ -156,12 +157,16 @@ function subscriptionUtilization(subscription, routingState) {
|
|
|
156
157
|
const { tier, maxMultiplier } = subscription;
|
|
157
158
|
if (!tier) return null;
|
|
158
159
|
|
|
159
|
-
const
|
|
160
|
+
const routingCells = Object.entries(routingState)
|
|
161
|
+
.filter(([k]) => !k.startsWith('_')) // skip metadata keys
|
|
162
|
+
.map(([, v]) => v);
|
|
163
|
+
|
|
164
|
+
const opusUses = routingCells
|
|
160
165
|
.flatMap(m => Object.entries(m))
|
|
161
166
|
.filter(([model]) => model === 'opus' || model.includes('opus'))
|
|
162
167
|
.reduce((s, [, stats]) => s + (stats.observations || 0), 0);
|
|
163
168
|
|
|
164
|
-
const totalUses =
|
|
169
|
+
const totalUses = routingCells
|
|
165
170
|
.flatMap(m => Object.values(m))
|
|
166
171
|
.reduce((s, stats) => s + (stats.observations || 0), 0);
|
|
167
172
|
|
|
@@ -267,7 +272,7 @@ export function formatRecommendations(recs) {
|
|
|
267
272
|
const line = (content) => `│ ${pad(content)} │`;
|
|
268
273
|
|
|
269
274
|
const lines = [
|
|
270
|
-
'╭─ Recommendations ' + '─'.repeat(WIDTH -
|
|
275
|
+
'╭─ Recommendations ' + '─'.repeat(WIDTH - 20) + '╮',
|
|
271
276
|
line(''),
|
|
272
277
|
];
|
|
273
278
|
|
package/src/routing-advisor.mjs
CHANGED
|
@@ -29,7 +29,11 @@ function stateFile(cwd) { return join(cwd || process.cwd(), '.dualbrain', 'routi
|
|
|
29
29
|
function loadState(cwd) {
|
|
30
30
|
try {
|
|
31
31
|
const p = stateFile(cwd);
|
|
32
|
-
|
|
32
|
+
if (!existsSync(p)) return {};
|
|
33
|
+
const raw = readFileSync(p, 'utf8');
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
36
|
+
return parsed;
|
|
33
37
|
} catch { return {}; }
|
|
34
38
|
}
|
|
35
39
|
|
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
|
}
|
|
@@ -130,6 +131,7 @@ export function buildRetryDecision(originalDecision, strategy, failure) {
|
|
|
130
131
|
// Export 4: shouldRetry(result, originalDecision, attemptNumber)
|
|
131
132
|
export function shouldRetry(result, originalDecision, attemptNumber = 1) {
|
|
132
133
|
try {
|
|
134
|
+
if (attemptNumber >= MAX_ATTEMPTS) return { retry: false, reason: `max attempts (${MAX_ATTEMPTS}) reached`, strategy: 'give-up' };
|
|
133
135
|
const failure = classifyFailure(result);
|
|
134
136
|
const { strategy, newDecision, reason } = selectStrategy(failure, originalDecision, attemptNumber);
|
|
135
137
|
|
package/src/setup-flow.mjs
CHANGED
|
@@ -148,6 +148,14 @@ async function askYN(rl, question, defaultYes = true) {
|
|
|
148
148
|
export async function runSetup(cwd, options = {}) {
|
|
149
149
|
const detected = detectEnvironment(cwd);
|
|
150
150
|
|
|
151
|
+
// Non-TTY fast path — stdin is piped or in CI
|
|
152
|
+
if (!process.stdin.isTTY && !options.nonInteractive) {
|
|
153
|
+
process.stderr.write('[dual-brain] Non-interactive terminal detected. Use --non-interactive flag or run in a TTY.\n');
|
|
154
|
+
const config = buildConfig({ subscription: 'claude-pro', workStyle: 'balanced' }, detected);
|
|
155
|
+
saveConfig(config, cwd);
|
|
156
|
+
return config;
|
|
157
|
+
}
|
|
158
|
+
|
|
151
159
|
// Non-interactive fast path
|
|
152
160
|
if (options.nonInteractive) {
|
|
153
161
|
const config = buildConfig({
|
package/src/signal.mjs
CHANGED
|
@@ -9,8 +9,9 @@ export const EXPECTED_DURATION_MS = { search: 15000, execute: 45000, think: 3000
|
|
|
9
9
|
|
|
10
10
|
export function scoreDurationRatio(durationMs, tier) {
|
|
11
11
|
try {
|
|
12
|
-
|
|
13
|
-
const
|
|
12
|
+
if (durationMs <= 0) return null;
|
|
13
|
+
const expectedMs = EXPECTED_DURATION_MS[tier] || EXPECTED_DURATION_MS.execute;
|
|
14
|
+
const ratio = durationMs / expectedMs;
|
|
14
15
|
if (ratio >= 0.5 && ratio <= 1.5) return 1.0;
|
|
15
16
|
if (ratio < 0.2) return 0.5;
|
|
16
17
|
if (ratio > 3.0) return 0.3;
|
|
@@ -71,7 +72,7 @@ export function scoreOutcome(outcome, context = {}) {
|
|
|
71
72
|
// Signal 3: token efficiency (weight 0.25)
|
|
72
73
|
let effVal = null;
|
|
73
74
|
const filesChanged = outcome.filesChanged ?? 0;
|
|
74
|
-
const fileCount = typeof filesChanged === 'number' ? filesChanged :
|
|
75
|
+
const fileCount = Array.isArray(filesChanged) ? filesChanged.length : (typeof filesChanged === 'number' ? filesChanged : 0);
|
|
75
76
|
if (!(fileCount === 0 && tier === 'think')) {
|
|
76
77
|
const tokensUsed =
|
|
77
78
|
outcome.tokensUsed?.output ??
|