dual-brain 0.2.30 → 0.3.1
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/.dual-brain/docs/claude-code-extension-points.md +32 -0
- package/.dual-brain/docs/data-tools-capabilities.md +181 -0
- package/.dual-brain/docs/ecosystem-tools.md +91 -0
- package/.dual-brain/docs/panel-handoff.md +124 -0
- package/.dual-brain/docs/ruflo-analysis.md +48 -0
- package/bin/dual-brain.mjs +56 -56
- package/dist/mcp-server/index.d.ts +27 -0
- package/dist/mcp-server/index.js +359 -0
- package/dist/mcp-server/index.js.map +1 -0
- package/dist/src/agent-protocol.d.ts +163 -0
- package/dist/src/agent-protocol.js +368 -0
- package/dist/src/agent-protocol.js.map +1 -0
- package/dist/src/agents/registry.d.ts +52 -0
- package/dist/src/agents/registry.js +393 -0
- package/dist/src/agents/registry.js.map +1 -0
- package/dist/src/awareness.d.ts +93 -0
- package/dist/src/awareness.js +406 -0
- package/dist/src/awareness.js.map +1 -0
- package/dist/src/brief.d.ts +48 -0
- package/dist/src/brief.js +179 -0
- package/dist/src/brief.js.map +1 -0
- package/dist/src/calibration.d.ts +32 -0
- package/dist/src/calibration.js +133 -0
- package/dist/src/calibration.js.map +1 -0
- package/dist/src/checkpoint.d.ts +33 -0
- package/dist/src/checkpoint.js +99 -0
- package/dist/src/checkpoint.js.map +1 -0
- package/dist/src/ci-triage.d.ts +33 -0
- package/dist/src/ci-triage.js +193 -0
- package/dist/src/ci-triage.js.map +1 -0
- package/dist/src/cognitive-loop.d.ts +56 -0
- package/dist/src/cognitive-loop.js +495 -0
- package/dist/src/cognitive-loop.js.map +1 -0
- package/dist/src/collaboration.d.ts +147 -0
- package/dist/src/collaboration.js +438 -0
- package/dist/src/collaboration.js.map +1 -0
- package/dist/src/context-intel.d.ts +47 -0
- package/dist/src/context-intel.js +156 -0
- package/dist/src/context-intel.js.map +1 -0
- package/dist/src/context.d.ts +53 -0
- package/dist/src/context.js +332 -0
- package/dist/src/context.js.map +1 -0
- package/dist/src/continuity.d.ts +89 -0
- package/dist/src/continuity.js +230 -0
- package/dist/src/continuity.js.map +1 -0
- package/dist/src/cost-tracker.d.ts +47 -0
- package/dist/src/cost-tracker.js +170 -0
- package/dist/src/cost-tracker.js.map +1 -0
- package/dist/src/debrief.d.ts +53 -0
- package/dist/src/debrief.js +222 -0
- package/dist/src/debrief.js.map +1 -0
- package/dist/src/decide.d.ts +96 -0
- package/dist/src/decide.js +744 -0
- package/dist/src/decide.js.map +1 -0
- package/dist/src/decompose.d.ts +39 -0
- package/dist/src/decompose.js +218 -0
- package/dist/src/decompose.js.map +1 -0
- package/dist/src/detect.d.ts +91 -0
- package/dist/src/detect.js +544 -0
- package/dist/src/detect.js.map +1 -0
- package/dist/src/dispatch.d.ts +154 -0
- package/dist/src/dispatch.js +1306 -0
- package/dist/src/dispatch.js.map +1 -0
- package/dist/src/doctor.d.ts +421 -0
- package/dist/src/doctor.js +1689 -0
- package/dist/src/doctor.js.map +1 -0
- package/dist/src/engine.d.ts +70 -0
- package/dist/src/engine.js +155 -0
- package/dist/src/engine.js.map +1 -0
- package/dist/src/envelope.d.ts +36 -0
- package/dist/src/envelope.js +80 -0
- package/dist/src/envelope.js.map +1 -0
- package/dist/src/failure-memory.d.ts +55 -0
- package/dist/src/failure-memory.js +175 -0
- package/dist/src/failure-memory.js.map +1 -0
- package/dist/src/fx.d.ts +87 -0
- package/dist/src/fx.js +272 -0
- package/dist/src/fx.js.map +1 -0
- package/dist/src/governance.d.ts +93 -0
- package/dist/src/governance.js +261 -0
- package/dist/src/governance.js.map +1 -0
- package/dist/src/handoff.d.ts +11 -0
- package/dist/src/handoff.js +90 -0
- package/dist/src/handoff.js.map +1 -0
- package/dist/src/head-protocol.d.ts +76 -0
- package/dist/src/head-protocol.js +109 -0
- package/dist/src/head-protocol.js.map +1 -0
- package/dist/src/head.d.ts +222 -0
- package/dist/src/head.js +765 -0
- package/dist/src/head.js.map +1 -0
- package/dist/src/health.d.ts +132 -0
- package/dist/src/health.js +435 -0
- package/dist/src/health.js.map +1 -0
- package/dist/src/inbox.d.ts +70 -0
- package/dist/src/inbox.js +218 -0
- package/dist/src/inbox.js.map +1 -0
- package/dist/src/index.d.ts +33 -0
- package/dist/src/index.js +38 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/install-hooks.d.ts +13 -0
- package/dist/src/install-hooks.js +88 -0
- package/dist/src/install-hooks.js.map +1 -0
- package/dist/src/integrity.d.ts +59 -0
- package/dist/src/integrity.js +206 -0
- package/dist/src/integrity.js.map +1 -0
- package/dist/src/intelligence.d.ts +104 -0
- package/dist/src/intelligence.js +391 -0
- package/dist/src/intelligence.js.map +1 -0
- package/dist/src/ledger.d.ts +54 -0
- package/dist/src/ledger.js +179 -0
- package/dist/src/ledger.js.map +1 -0
- package/dist/src/living-docs.d.ts +14 -0
- package/dist/src/living-docs.js +197 -0
- package/dist/src/living-docs.js.map +1 -0
- package/dist/src/memory-tiers.d.ts +37 -0
- package/dist/src/memory-tiers.js +160 -0
- package/dist/src/memory-tiers.js.map +1 -0
- package/dist/src/model-profiles.d.ts +65 -0
- package/dist/src/model-profiles.js +568 -0
- package/dist/src/model-profiles.js.map +1 -0
- package/dist/src/models.d.ts +58 -0
- package/dist/src/models.js +327 -0
- package/dist/src/models.js.map +1 -0
- package/dist/src/narrative.d.ts +54 -0
- package/dist/src/narrative.js +163 -0
- package/dist/src/narrative.js.map +1 -0
- package/dist/src/nextstep.d.ts +16 -0
- package/dist/src/nextstep.js +103 -0
- package/dist/src/nextstep.js.map +1 -0
- package/dist/src/observer.d.ts +18 -0
- package/dist/src/observer.js +251 -0
- package/dist/src/observer.js.map +1 -0
- package/dist/src/outcome.d.ts +110 -0
- package/dist/src/outcome.js +377 -0
- package/dist/src/outcome.js.map +1 -0
- package/dist/src/pipeline.d.ts +167 -0
- package/dist/src/pipeline.js +1503 -0
- package/dist/src/pipeline.js.map +1 -0
- package/dist/src/playbook.d.ts +59 -0
- package/dist/src/playbook.js +238 -0
- package/dist/src/playbook.js.map +1 -0
- package/dist/src/pr-agent.d.ts +97 -0
- package/dist/src/pr-agent.js +195 -0
- package/dist/src/pr-agent.js.map +1 -0
- package/dist/src/predictive.d.ts +57 -0
- package/dist/src/predictive.js +230 -0
- package/dist/src/predictive.js.map +1 -0
- package/dist/src/profile.d.ts +294 -0
- package/dist/src/profile.js +1347 -0
- package/dist/src/profile.js.map +1 -0
- package/dist/src/prompt-audit.d.ts +22 -0
- package/dist/src/prompt-audit.js +194 -0
- package/dist/src/prompt-audit.js.map +1 -0
- package/dist/src/prompt-intel.d.ts +12 -0
- package/dist/src/prompt-intel.js +321 -0
- package/dist/src/prompt-intel.js.map +1 -0
- package/dist/src/provider-context.d.ts +121 -0
- package/dist/src/provider-context.js +222 -0
- package/dist/src/provider-context.js.map +1 -0
- package/dist/src/provider-manager.d.ts +92 -0
- package/dist/src/provider-manager.js +428 -0
- package/dist/src/provider-manager.js.map +1 -0
- package/dist/src/receipt.d.ts +87 -0
- package/dist/src/receipt.js +326 -0
- package/dist/src/receipt.js.map +1 -0
- package/dist/src/recommendations.d.ts +13 -0
- package/dist/src/recommendations.js +291 -0
- package/dist/src/recommendations.js.map +1 -0
- package/dist/src/redact.d.ts +15 -0
- package/dist/src/redact.js +129 -0
- package/dist/src/redact.js.map +1 -0
- package/dist/src/replit.d.ts +397 -0
- package/dist/src/replit.js +1160 -0
- package/dist/src/replit.js.map +1 -0
- package/dist/src/repo.d.ts +149 -0
- package/dist/src/repo.js +416 -0
- package/dist/src/repo.js.map +1 -0
- package/dist/src/revert.d.ts +30 -0
- package/dist/src/revert.js +166 -0
- package/dist/src/revert.js.map +1 -0
- package/dist/src/room.d.ts +102 -0
- package/dist/src/room.js +212 -0
- package/dist/src/room.js.map +1 -0
- package/dist/src/routing-advisor.d.ts +57 -0
- package/dist/src/routing-advisor.js +221 -0
- package/dist/src/routing-advisor.js.map +1 -0
- package/dist/src/self-correct.d.ts +40 -0
- package/dist/src/self-correct.js +137 -0
- package/dist/src/self-correct.js.map +1 -0
- package/dist/src/session-lock.d.ts +35 -0
- package/dist/src/session-lock.js +134 -0
- package/dist/src/session-lock.js.map +1 -0
- package/dist/src/session.d.ts +267 -0
- package/dist/src/session.js +1660 -0
- package/dist/src/session.js.map +1 -0
- package/dist/src/settings-tui.d.ts +5 -0
- package/dist/src/settings-tui.js +422 -0
- package/dist/src/settings-tui.js.map +1 -0
- package/dist/src/setup-flow.d.ts +63 -0
- package/dist/src/setup-flow.js +233 -0
- package/dist/src/setup-flow.js.map +1 -0
- package/dist/src/signal.d.ts +19 -0
- package/dist/src/signal.js +122 -0
- package/dist/src/signal.js.map +1 -0
- package/dist/src/simmer.d.ts +85 -0
- package/dist/src/simmer.js +224 -0
- package/dist/src/simmer.js.map +1 -0
- package/dist/src/state-export.d.ts +129 -0
- package/dist/src/state-export.js +233 -0
- package/dist/src/state-export.js.map +1 -0
- package/dist/src/strategy.d.ts +54 -0
- package/dist/src/strategy.js +95 -0
- package/dist/src/strategy.js.map +1 -0
- package/dist/src/subscription.d.ts +40 -0
- package/dist/src/subscription.js +189 -0
- package/dist/src/subscription.js.map +1 -0
- package/dist/src/templates.d.ts +208 -0
- package/dist/src/templates.js +238 -0
- package/dist/src/templates.js.map +1 -0
- package/dist/src/test.d.ts +9 -0
- package/dist/src/test.js +1173 -0
- package/dist/src/test.js.map +1 -0
- package/dist/src/think-engine.d.ts +67 -0
- package/dist/src/think-engine.js +412 -0
- package/dist/src/think-engine.js.map +1 -0
- package/dist/src/tui.d.ts +71 -0
- package/dist/src/tui.js +242 -0
- package/dist/src/tui.js.map +1 -0
- package/dist/src/types.d.ts +177 -0
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/update-check.d.ts +7 -0
- package/dist/src/update-check.js +36 -0
- package/dist/src/update-check.js.map +1 -0
- package/dist/src/wave-planner.d.ts +30 -0
- package/dist/src/wave-planner.js +281 -0
- package/dist/src/wave-planner.js.map +1 -0
- package/hooks/head-guard.sh +41 -0
- package/hooks/precompact.mjs +3 -3
- package/hooks/session-end.mjs +3 -3
- package/hooks/task-classifier.mjs +328 -0
- package/hooks/vibe-router.mjs +387 -0
- package/install.mjs +2 -2
- package/package.json +29 -153
- package/src/agents/registry.mjs +0 -405
- package/src/awareness.mjs +0 -425
- package/src/brief.mjs +0 -266
- package/src/calibration.mjs +0 -148
- package/src/checkpoint.mjs +0 -109
- package/src/ci-triage.mjs +0 -191
- package/src/cognitive-loop.mjs +0 -562
- package/src/collaboration.mjs +0 -545
- package/src/context-intel.mjs +0 -158
- package/src/context.mjs +0 -389
- package/src/continuity.mjs +0 -298
- package/src/cost-tracker.mjs +0 -184
- package/src/debrief.mjs +0 -228
- package/src/decide.mjs +0 -1099
- package/src/decompose.mjs +0 -331
- package/src/detect.mjs +0 -702
- package/src/dispatch.mjs +0 -1447
- package/src/doctor.mjs +0 -1607
- package/src/envelope.mjs +0 -139
- package/src/failure-memory.mjs +0 -178
- package/src/fx.mjs +0 -276
- package/src/governance.mjs +0 -279
- package/src/handoff.mjs +0 -87
- package/src/head-protocol.mjs +0 -128
- package/src/head.mjs +0 -952
- package/src/health.mjs +0 -528
- package/src/inbox.mjs +0 -195
- package/src/index.mjs +0 -44
- package/src/install-hooks.mjs +0 -100
- package/src/integrity.mjs +0 -245
- package/src/intelligence.mjs +0 -447
- package/src/ledger.mjs +0 -196
- package/src/living-docs.mjs +0 -210
- package/src/memory-tiers.mjs +0 -193
- package/src/models.mjs +0 -363
- package/src/narrative.mjs +0 -169
- package/src/nextstep.mjs +0 -100
- package/src/observer.mjs +0 -241
- package/src/outcome.mjs +0 -400
- package/src/pipeline.mjs +0 -1711
- package/src/playbook.mjs +0 -257
- package/src/pr-agent.mjs +0 -214
- package/src/predictive.mjs +0 -250
- package/src/profile.mjs +0 -1411
- package/src/prompt-audit.mjs +0 -231
- package/src/prompt-intel.mjs +0 -325
- package/src/provider-context.mjs +0 -257
- package/src/receipt.mjs +0 -344
- package/src/recommendations.mjs +0 -296
- package/src/redact.mjs +0 -192
- package/src/replit.mjs +0 -1210
- package/src/repo.mjs +0 -445
- package/src/revert.mjs +0 -149
- package/src/routing-advisor.mjs +0 -204
- package/src/self-correct.mjs +0 -147
- package/src/session-lock.mjs +0 -160
- package/src/session.mjs +0 -1655
- package/src/settings-tui.mjs +0 -373
- package/src/setup-flow.mjs +0 -223
- package/src/signal.mjs +0 -115
- package/src/simmer.mjs +0 -241
- package/src/strategy.mjs +0 -235
- package/src/subscription.mjs +0 -212
- package/src/templates.mjs +0 -260
- package/src/think-engine.mjs +0 -428
- package/src/tui.mjs +0 -276
- package/src/update-check.mjs +0 -35
- package/src/wave-planner.mjs +0 -294
package/src/decide.mjs
DELETED
|
@@ -1,1099 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* decide.mjs — Routing decision module for the Dual-Brain Orchestrator.
|
|
4
|
-
*
|
|
5
|
-
* Given a task detection + user profile, decides which provider/model/effort/mode
|
|
6
|
-
* to use and explains why in one sentence.
|
|
7
|
-
*
|
|
8
|
-
* Exports: decideRoute, getModelCapabilities, getAvailableModels,
|
|
9
|
-
* WORK_STYLES, getWorkStyle, estimateBudgetPressure,
|
|
10
|
-
* shouldDualBrain, explainDecision, getFailoverOrder
|
|
11
|
-
*
|
|
12
|
-
* CLI: node src/decide.mjs --profile /path/to/profile.json \
|
|
13
|
-
* --detection '{"intent":"edit","risk":"low","complexity":"simple","effort":"medium","tier":"execute"}'
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { readFileSync } from 'fs';
|
|
17
|
-
import { join, dirname } from 'path';
|
|
18
|
-
import { fileURLToPath } from 'url';
|
|
19
|
-
import { getProviderScore, checkCooldown } from './health.mjs';
|
|
20
|
-
|
|
21
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
-
const WORKSPACE = join(__dirname, '..');
|
|
23
|
-
|
|
24
|
-
// ─── Model Registry (optional, lazy-loaded) ───────────────────────────────────
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Cached reference to models.mjs exports. Populated on first successful import.
|
|
28
|
-
* Remains null if models.mjs is unavailable — all callers fall back to
|
|
29
|
-
* the existing hardcoded model selection logic in that case.
|
|
30
|
-
*/
|
|
31
|
-
let modelRegistry = null;
|
|
32
|
-
let _registryLoadAttempted = false;
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Attempt to load models.mjs once. Subsequent calls return immediately.
|
|
36
|
-
* This is intentionally fire-and-forget: decideRoute stays synchronous and
|
|
37
|
-
* reads `modelRegistry` after the Promise resolves.
|
|
38
|
-
*/
|
|
39
|
-
function _loadModelRegistry() {
|
|
40
|
-
if (_registryLoadAttempted) return;
|
|
41
|
-
_registryLoadAttempted = true;
|
|
42
|
-
import('./models.mjs').then(mod => {
|
|
43
|
-
modelRegistry = mod;
|
|
44
|
-
}).catch(() => {
|
|
45
|
-
// models.mjs unavailable — fall back to hardcoded logic
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Kick off the load immediately so it is ready before the first routing call.
|
|
50
|
-
_loadModelRegistry();
|
|
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
|
-
|
|
74
|
-
// ─── Work Styles ─────────────────────────────────────────────────────────────
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Work styles control how aggressively the router uses stronger models,
|
|
78
|
-
* challenger (dual-brain) reviews, and checkpoints.
|
|
79
|
-
* The user picks a style regardless of provider or plan — no price gating.
|
|
80
|
-
*/
|
|
81
|
-
export const WORK_STYLES = {
|
|
82
|
-
fast: {
|
|
83
|
-
label: 'Fast',
|
|
84
|
-
defaultWorker: 'claude-sonnet-4-6',
|
|
85
|
-
complexWorker: 'claude-sonnet-4-6',
|
|
86
|
-
challengerPolicy: 'never',
|
|
87
|
-
checkpointPolicy: 'never',
|
|
88
|
-
reviewPolicy: 'skip',
|
|
89
|
-
description: 'Quick answers, single model, minimal reviews',
|
|
90
|
-
},
|
|
91
|
-
balanced: {
|
|
92
|
-
label: 'Balanced',
|
|
93
|
-
defaultWorker: 'claude-sonnet-4-6',
|
|
94
|
-
complexWorker: 'claude-opus-4-6',
|
|
95
|
-
challengerPolicy: 'high-risk', // only on high/critical risk
|
|
96
|
-
checkpointPolicy: 'risky-ops', // before risky operations
|
|
97
|
-
reviewPolicy: 'important', // important changes only
|
|
98
|
-
description: 'Smart routing, reviews on important changes',
|
|
99
|
-
},
|
|
100
|
-
fullpower: {
|
|
101
|
-
label: 'Full Power',
|
|
102
|
-
defaultWorker: 'claude-sonnet-4-6',
|
|
103
|
-
complexWorker: 'claude-opus-4-6',
|
|
104
|
-
challengerPolicy: 'medium-risk', // medium+ risk
|
|
105
|
-
checkpointPolicy: 'all-edits', // before all edits
|
|
106
|
-
reviewPolicy: 'non-trivial', // everything non-trivial
|
|
107
|
-
description: 'Deep reasoning, dual-brain on everything that matters',
|
|
108
|
-
},
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Read the active work style from the profile.
|
|
113
|
-
* Falls back to 'balanced' if not set or unrecognized.
|
|
114
|
-
* @param {object} profile
|
|
115
|
-
* @returns {object} The matching WORK_STYLES entry, with a `key` property added.
|
|
116
|
-
*/
|
|
117
|
-
export function getWorkStyle(profile) {
|
|
118
|
-
const key = profile?.workStyle || profile?.work_style || 'balanced';
|
|
119
|
-
const style = WORK_STYLES[key] ?? WORK_STYLES.balanced;
|
|
120
|
-
return { ...style, key: WORK_STYLES[key] ? key : 'balanced' };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// ─── Slim Model Capabilities (routing-relevant only) ─────────────────────────
|
|
124
|
-
|
|
125
|
-
/** @type {Record<string, {provider, tierFit, contextWindow, strengths, weaknesses, effortLevels, costTier}>} */
|
|
126
|
-
const MODEL_CAPABILITIES = {
|
|
127
|
-
haiku: {
|
|
128
|
-
provider: 'claude',
|
|
129
|
-
tierFit: ['search'],
|
|
130
|
-
contextWindow: 200_000,
|
|
131
|
-
strengths: ['search', 'format', 'lookup', 'classification', 'grep-analysis'],
|
|
132
|
-
weaknesses: ['complex-edits', 'architecture', 'security', 'multi-file-refactor'],
|
|
133
|
-
effortLevels: null,
|
|
134
|
-
costTier: 'cheap',
|
|
135
|
-
},
|
|
136
|
-
sonnet: {
|
|
137
|
-
provider: 'claude',
|
|
138
|
-
tierFit: ['execute', 'search'],
|
|
139
|
-
contextWindow: 200_000,
|
|
140
|
-
strengths: ['edit', 'refactor', 'test', 'debug', 'code-generation', 'tool-use'],
|
|
141
|
-
weaknesses: ['deep-architecture', 'ambiguous-requirements', 'frontier-reasoning'],
|
|
142
|
-
effortLevels: ['low', 'medium', 'high', 'xhigh'],
|
|
143
|
-
costTier: 'medium',
|
|
144
|
-
},
|
|
145
|
-
opus: {
|
|
146
|
-
provider: 'claude',
|
|
147
|
-
tierFit: ['think', 'execute'],
|
|
148
|
-
contextWindow: 200_000,
|
|
149
|
-
strengths: ['architecture', 'security', 'complex-debug', 'review', 'planning', 'threat-modeling'],
|
|
150
|
-
weaknesses: ['cost', 'overkill-for-simple-tasks'],
|
|
151
|
-
effortLevels: ['low', 'medium', 'high', 'xhigh'],
|
|
152
|
-
costTier: 'expensive',
|
|
153
|
-
},
|
|
154
|
-
'gpt-4.1-mini': {
|
|
155
|
-
provider: 'openai',
|
|
156
|
-
tierFit: ['search'],
|
|
157
|
-
contextWindow: 1_047_576,
|
|
158
|
-
strengths: ['search', 'format', 'classification', 'fast-lookups'],
|
|
159
|
-
weaknesses: ['complex-refactors', 'architecture', 'multi-file-edits'],
|
|
160
|
-
effortLevels: ['low', 'medium', 'high'],
|
|
161
|
-
costTier: 'cheap',
|
|
162
|
-
},
|
|
163
|
-
'gpt-4.1': {
|
|
164
|
-
provider: 'openai',
|
|
165
|
-
tierFit: ['execute', 'search'],
|
|
166
|
-
contextWindow: 1_047_576,
|
|
167
|
-
strengths: ['edit', 'code-generation', 'simple-refactor'],
|
|
168
|
-
weaknesses: ['architecture', 'security', 'complex-debug'],
|
|
169
|
-
effortLevels: ['low', 'medium', 'high'],
|
|
170
|
-
costTier: 'medium',
|
|
171
|
-
},
|
|
172
|
-
'gpt-4o': {
|
|
173
|
-
provider: 'openai',
|
|
174
|
-
tierFit: ['execute', 'think'],
|
|
175
|
-
contextWindow: 128_000,
|
|
176
|
-
strengths: ['refactor', 'debug', 'code-generation', 'test', 'multimodal'],
|
|
177
|
-
weaknesses: ['cost vs mini'],
|
|
178
|
-
effortLevels: ['low', 'medium', 'high'],
|
|
179
|
-
costTier: 'medium',
|
|
180
|
-
},
|
|
181
|
-
'gpt-4o-mini': {
|
|
182
|
-
provider: 'openai',
|
|
183
|
-
tierFit: ['search'],
|
|
184
|
-
contextWindow: 128_000,
|
|
185
|
-
costTier: 'cheap',
|
|
186
|
-
strengths: ['quick-tasks', 'search', 'classification'],
|
|
187
|
-
weaknesses: ['complex-edits', 'architecture'],
|
|
188
|
-
effortLevels: null,
|
|
189
|
-
},
|
|
190
|
-
'o3': {
|
|
191
|
-
provider: 'openai',
|
|
192
|
-
tierFit: ['think'],
|
|
193
|
-
contextWindow: 200_000,
|
|
194
|
-
strengths: ['architecture', 'security', 'review', 'planning', 'complex-debug', 'deep-reasoning'],
|
|
195
|
-
weaknesses: ['cost', 'latency'],
|
|
196
|
-
effortLevels: ['low', 'medium', 'high'],
|
|
197
|
-
costTier: 'expensive',
|
|
198
|
-
},
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
// ─── Canonical Work Model Names ──────────────────────────────────────────────
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* These are the authoritative model IDs used when dispatching work.
|
|
205
|
-
* The session model (what the user runs Claude Code with) is separate and
|
|
206
|
-
* does not need to be changed — the router assigns work models independently.
|
|
207
|
-
*
|
|
208
|
-
* Role → model mapping:
|
|
209
|
-
* execute → claude-sonnet-4-6 (native tool use, reliable workhorse)
|
|
210
|
-
* think → claude-opus-4-6 (deep reasoning, complex single-brain tasks)
|
|
211
|
-
* search → claude-haiku-4-5-20251001 / gpt-4o-mini (cheap, fast, disposable)
|
|
212
|
-
* challenger → o3 or gpt-4o (independence — different training = different blind spots)
|
|
213
|
-
*/
|
|
214
|
-
const WORK_MODELS = {
|
|
215
|
-
execute: 'claude-sonnet-4-6',
|
|
216
|
-
think: 'claude-opus-4-6',
|
|
217
|
-
search: 'claude-haiku-4-5-20251001',
|
|
218
|
-
challengerGpt: 'o3', // preferred challenger; falls back to gpt-4o when o3 unavailable
|
|
219
|
-
challengerGptFallback: 'gpt-4o',
|
|
220
|
-
searchGpt: 'gpt-4o-mini', // GPT-side search/classify
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
/** Always recommend Sonnet as the session model. */
|
|
224
|
-
const RECOMMENDED_SESSION_MODEL = 'claude-sonnet-4-6';
|
|
225
|
-
const RECOMMENDED_SESSION_REASON =
|
|
226
|
-
'Sonnet has native tool use and is the most cost-effective session model for orchestrating work agents.';
|
|
227
|
-
|
|
228
|
-
// ─── Exported: getModelCapabilities ──────────────────────────────────────────
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Look up a model's routing-relevant capabilities.
|
|
232
|
-
* @param {string} model
|
|
233
|
-
* @returns {object|null}
|
|
234
|
-
*/
|
|
235
|
-
export function getModelCapabilities(model) {
|
|
236
|
-
return MODEL_CAPABILITIES[model] ?? null;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// ─── Exported: getAvailableModels ─────────────────────────────────────────────
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Return which models the user can access.
|
|
243
|
-
* All known models are available by default; providers can explicitly restrict
|
|
244
|
-
* via profile.providers.<provider>.models (array of allowed model short names).
|
|
245
|
-
* This does NOT gate on price or configured plan — we cannot verify those from here.
|
|
246
|
-
* @param {{ providers?: { claude?: { enabled?: boolean, models?: string[] }, openai?: { enabled?: boolean, models?: string[] } } }} profile
|
|
247
|
-
* @returns {{ claude: string[], openai: string[] }}
|
|
248
|
-
*/
|
|
249
|
-
export function getAvailableModels(profile) {
|
|
250
|
-
const ALL_CLAUDE = ['haiku', 'sonnet', 'opus'];
|
|
251
|
-
const ALL_OPENAI = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
|
|
252
|
-
|
|
253
|
-
const claudeModels = profile?.providers?.claude?.models;
|
|
254
|
-
const openaiModels = profile?.providers?.openai?.models;
|
|
255
|
-
|
|
256
|
-
return {
|
|
257
|
-
claude: Array.isArray(claudeModels) ? claudeModels : ALL_CLAUDE,
|
|
258
|
-
openai: Array.isArray(openaiModels) ? openaiModels : ALL_OPENAI,
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// ─── Internal: challenger model selection ────────────────────────────────────
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Pick the best challenger model from the opposing provider.
|
|
266
|
-
* Claude primary → GPT challenger (o3 preferred, gpt-4o fallback).
|
|
267
|
-
* GPT primary → Claude Opus challenger (Sonnet fallback).
|
|
268
|
-
* Falls back gracefully when the other provider is not available.
|
|
269
|
-
*
|
|
270
|
-
* @param {string} primaryProvider 'claude'|'openai'
|
|
271
|
-
* @param {object} available Result of getAvailableModels()
|
|
272
|
-
* @returns {string|null}
|
|
273
|
-
*/
|
|
274
|
-
function pickChallengerModel(primaryProvider, available) {
|
|
275
|
-
if (primaryProvider === 'claude') {
|
|
276
|
-
// Claude is primary → use GPT as challenger
|
|
277
|
-
if (available.openai.includes(WORK_MODELS.challengerGpt)) return WORK_MODELS.challengerGpt;
|
|
278
|
-
if (available.openai.includes(WORK_MODELS.challengerGptFallback)) return WORK_MODELS.challengerGptFallback;
|
|
279
|
-
return null; // OpenAI not available
|
|
280
|
-
} else {
|
|
281
|
-
// OpenAI is primary → use Claude Opus as challenger
|
|
282
|
-
if (available.claude.includes('opus')) return WORK_MODELS.think;
|
|
283
|
-
if (available.claude.includes('sonnet')) return WORK_MODELS.execute;
|
|
284
|
-
return null; // Claude not available
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Decide whether to trigger a challenger based on the work style policy and task risk.
|
|
290
|
-
* When only one provider is available, challenger is never triggered (no cross-provider review possible).
|
|
291
|
-
* @param {string} challengerPolicy 'never'|'high-risk'|'medium-risk'
|
|
292
|
-
* @param {'low'|'medium'|'high'|'critical'} risk
|
|
293
|
-
* @param {boolean} hasBothProviders
|
|
294
|
-
* @returns {boolean}
|
|
295
|
-
*/
|
|
296
|
-
function shouldTriggerChallenger(challengerPolicy, risk, hasBothProviders) {
|
|
297
|
-
if (challengerPolicy === 'never' || !hasBothProviders) return false;
|
|
298
|
-
if (challengerPolicy === 'high-risk') return ['high', 'critical'].includes(risk);
|
|
299
|
-
if (challengerPolicy === 'medium-risk') return ['medium', 'high', 'critical'].includes(risk);
|
|
300
|
-
return false;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ─── Exported: estimateBudgetPressure (deprecated stub) ──────────────────────
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* @deprecated Replaced by the health-based router in health.mjs.
|
|
307
|
-
* Returns an empty object so callers that still import this don't crash.
|
|
308
|
-
* The budget-balancer.mjs hook file is separate and can keep using usage logs.
|
|
309
|
-
* @returns {{ claude: number, openai: number }}
|
|
310
|
-
*/
|
|
311
|
-
export function estimateBudgetPressure(_profile, _cwd) {
|
|
312
|
-
return { claude: 0, openai: 0 };
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// ─── Internal: health-based provider scoring ──────────────────────────────────
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Return a 0-100 routing score for each provider using health.mjs state.
|
|
319
|
-
* For each provider we check its primary model class for the given tier.
|
|
320
|
-
* @param {'search'|'execute'|'think'} tier
|
|
321
|
-
* @param {string} [cwd]
|
|
322
|
-
* @returns {{ claude: number, openai: number }}
|
|
323
|
-
*/
|
|
324
|
-
function getHealthScores(tier, cwd) {
|
|
325
|
-
// Map tier to representative model class per provider
|
|
326
|
-
const claudeClass = tier === 'search' ? 'haiku'
|
|
327
|
-
: tier === 'think' ? 'opus'
|
|
328
|
-
: 'sonnet';
|
|
329
|
-
const openaiClass = tier === 'search' ? 'gpt-4o-mini'
|
|
330
|
-
: tier === 'think' ? 'o3'
|
|
331
|
-
: 'gpt-4o';
|
|
332
|
-
|
|
333
|
-
// Trigger cooldown expiry check (transitions hot→probing automatically)
|
|
334
|
-
checkCooldown('claude', claudeClass, cwd);
|
|
335
|
-
checkCooldown('openai', openaiClass, cwd);
|
|
336
|
-
|
|
337
|
-
return {
|
|
338
|
-
claude: getProviderScore('claude', claudeClass, cwd),
|
|
339
|
-
openai: getProviderScore('openai', openaiClass, cwd),
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// ─── Exported: shouldDualBrain ────────────────────────────────────────────────
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Return true if both providers should analyze this task.
|
|
347
|
-
* Requires: (critical risk OR architecture/security intent OR complex+high-risk)
|
|
348
|
-
* AND profile has both providers available with dual mode enabled.
|
|
349
|
-
*
|
|
350
|
-
* designImpact bypasses the hasBothProviders check — it is a mandatory review
|
|
351
|
-
* gate, not optional collaboration. When only one provider is available the
|
|
352
|
-
* caller should check degradedDualBrain on the decision output.
|
|
353
|
-
* @param {{ intent?: string, risk?: string, complexity?: string, designImpact?: boolean }} detection
|
|
354
|
-
* @param {object} profile
|
|
355
|
-
* @returns {boolean}
|
|
356
|
-
*/
|
|
357
|
-
export function shouldDualBrain(detection, profile) {
|
|
358
|
-
const { intent = '', risk = 'low', complexity = 'simple', designImpact = false } = detection;
|
|
359
|
-
const dualEnabled = profile?.dual_brain_enabled !== false;
|
|
360
|
-
if (!dualEnabled) return false;
|
|
361
|
-
|
|
362
|
-
const hasBothProviders = !!(
|
|
363
|
-
profile?.providers?.claude?.enabled &&
|
|
364
|
-
profile?.providers?.claude?.plan &&
|
|
365
|
-
profile?.providers?.openai?.enabled &&
|
|
366
|
-
profile?.providers?.openai?.plan
|
|
367
|
-
);
|
|
368
|
-
|
|
369
|
-
if (designImpact) return true;
|
|
370
|
-
|
|
371
|
-
if (!hasBothProviders) return false;
|
|
372
|
-
|
|
373
|
-
const criticalRisk = risk === 'critical';
|
|
374
|
-
const archOrSecurity = ['architecture', 'security'].includes(intent);
|
|
375
|
-
const complexHighRisk = complexity === 'complex' && risk === 'high';
|
|
376
|
-
|
|
377
|
-
return criticalRisk || archOrSecurity || complexHighRisk;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// ─── Internal: select model for provider ─────────────────────────────────────
|
|
381
|
-
|
|
382
|
-
const THINK_INTENTS = ['architecture', 'security', 'review', 'planning', 'compare'];
|
|
383
|
-
const SEARCH_INTENTS = ['search', 'format', 'explain', 'lookup'];
|
|
384
|
-
|
|
385
|
-
function pickClaudeModel(detection, available) {
|
|
386
|
-
const { intent = '', risk = 'low', effort = 'medium' } = detection;
|
|
387
|
-
const needsOpus = THINK_INTENTS.includes(intent) || risk === 'critical' || effort === 'xhigh';
|
|
388
|
-
const needsHaiku = SEARCH_INTENTS.includes(intent) && !['high', 'critical'].includes(risk);
|
|
389
|
-
|
|
390
|
-
if (needsOpus && available.includes('opus')) return 'opus';
|
|
391
|
-
if (needsHaiku && available.includes('haiku')) return 'haiku';
|
|
392
|
-
return available.includes('sonnet') ? 'sonnet' : available[available.length - 1];
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function pickOpenAIModel(detection, available) {
|
|
396
|
-
const { intent = '', risk = 'low', complexity = 'simple', effort = 'medium' } = detection;
|
|
397
|
-
const needsTop = THINK_INTENTS.includes(intent) || risk === 'critical' || effort === 'xhigh';
|
|
398
|
-
const needsMini = SEARCH_INTENTS.includes(intent) && effort === 'low';
|
|
399
|
-
const needsCodex = ['refactor', 'debug'].includes(intent) && complexity !== 'trivial';
|
|
400
|
-
|
|
401
|
-
const pref = needsTop ? 'o3'
|
|
402
|
-
: needsMini ? 'gpt-4o-mini'
|
|
403
|
-
: needsCodex ? 'gpt-4o'
|
|
404
|
-
: 'gpt-4o';
|
|
405
|
-
|
|
406
|
-
// Walk down rank until we find an available model
|
|
407
|
-
const rank = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
|
|
408
|
-
const idx = rank.indexOf(pref);
|
|
409
|
-
for (let i = idx; i >= 0; i--) {
|
|
410
|
-
if (available.includes(rank[i])) return rank[i];
|
|
411
|
-
}
|
|
412
|
-
return available[0] ?? 'gpt-4o-mini';
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Normalize a full model ID (e.g. 'claude-sonnet-4-6') to the short name used
|
|
417
|
-
* by the internal ranking arrays (e.g. 'sonnet'). Pass-through for names already
|
|
418
|
-
* in short form or OpenAI model IDs that don't need normalization.
|
|
419
|
-
* @param {string} model
|
|
420
|
-
* @param {string} provider 'claude'|'openai'
|
|
421
|
-
* @returns {string}
|
|
422
|
-
*/
|
|
423
|
-
function toShortName(model, provider) {
|
|
424
|
-
if (!model) return model;
|
|
425
|
-
const m = model.toLowerCase();
|
|
426
|
-
if (provider === 'claude') {
|
|
427
|
-
if (m.includes('haiku')) return 'haiku';
|
|
428
|
-
if (m.includes('opus')) return 'opus';
|
|
429
|
-
if (m.includes('sonnet')) return 'sonnet';
|
|
430
|
-
}
|
|
431
|
-
// OpenAI and already-short names pass through unchanged
|
|
432
|
-
return model;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Resolve a short model name back to the best full model ID from the registry.
|
|
437
|
-
* Used after the internal pipeline (health downgrade, profile bias, etc.) finalizes
|
|
438
|
-
* the short name, to restore the full ID when the registry is available.
|
|
439
|
-
* @param {string} shortName e.g. 'sonnet', 'opus', 'haiku'
|
|
440
|
-
* @param {string} provider 'claude'|'openai'
|
|
441
|
-
* @param {string} tier 'search'|'execute'|'think'
|
|
442
|
-
* @returns {string} Full model ID, or shortName if registry unavailable
|
|
443
|
-
*/
|
|
444
|
-
function toFullModelId(shortName, provider, tier) {
|
|
445
|
-
if (!modelRegistry) return shortName;
|
|
446
|
-
const registryProvider = provider === 'claude' ? 'anthropic' : 'openai';
|
|
447
|
-
// Map short name back to a taskType for the registry lookup
|
|
448
|
-
const taskType = tier === 'search' ? 'search' : tier === 'think' ? 'think' : 'execute';
|
|
449
|
-
const candidates = modelRegistry.getModelsForTask(taskType, registryProvider);
|
|
450
|
-
// Find the registry entry whose name substring matches the short name
|
|
451
|
-
const match = candidates.find(m => m.id.toLowerCase().includes(shortName.toLowerCase()));
|
|
452
|
-
return match ? match.id : shortName;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function applyHealthDowngrade(model, score, provider, available, isHighStakes) {
|
|
456
|
-
// score=100 healthy, score=50 degraded, score=25 probing, score=0 hot
|
|
457
|
-
// If score is 0 (hot) and this isn't high-stakes, downgrade one tier
|
|
458
|
-
if (score >= 50 || isHighStakes) return model;
|
|
459
|
-
|
|
460
|
-
if (provider === 'claude') {
|
|
461
|
-
const claudeRank = ['haiku', 'sonnet', 'opus'];
|
|
462
|
-
const idx = claudeRank.indexOf(model);
|
|
463
|
-
const steps = score === 0 ? 2 : 1;
|
|
464
|
-
const downIdx = Math.max(0, idx - steps);
|
|
465
|
-
for (let i = downIdx; i <= idx; i++) {
|
|
466
|
-
if (available.includes(claudeRank[i])) return claudeRank[i];
|
|
467
|
-
}
|
|
468
|
-
return available[0] ?? 'haiku';
|
|
469
|
-
} else {
|
|
470
|
-
const oaiRank = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
|
|
471
|
-
const idx = oaiRank.indexOf(model);
|
|
472
|
-
const steps = score === 0 ? 2 : 1;
|
|
473
|
-
const downIdx = Math.max(0, idx - steps);
|
|
474
|
-
for (let i = downIdx; i <= idx; i++) {
|
|
475
|
-
if (available.includes(oaiRank[i])) return oaiRank[i];
|
|
476
|
-
}
|
|
477
|
-
return available[0] ?? 'gpt-4o-mini';
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function applyProfileBias(model, profile, provider, available, tier) {
|
|
482
|
-
const mode = profile?.mode || profile?.profile || 'auto';
|
|
483
|
-
if (mode === 'cost-saver') {
|
|
484
|
-
// Prefer cheapest available that also fits the required tier
|
|
485
|
-
const ranks = {
|
|
486
|
-
claude: ['haiku', 'sonnet', 'opus'],
|
|
487
|
-
openai: ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'],
|
|
488
|
-
};
|
|
489
|
-
for (const m of ranks[provider]) {
|
|
490
|
-
if (!available.includes(m)) continue;
|
|
491
|
-
const caps = MODEL_CAPABILITIES[m];
|
|
492
|
-
if (tier && caps && !caps.tierFit.includes(tier)) continue;
|
|
493
|
-
return m;
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
if (mode === 'quality-first') {
|
|
497
|
-
// Prefer best available, keep current if already best
|
|
498
|
-
const ranks = {
|
|
499
|
-
claude: ['opus', 'sonnet', 'haiku'],
|
|
500
|
-
openai: ['o3', 'o4-mini', 'gpt-4o', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4o-mini'],
|
|
501
|
-
};
|
|
502
|
-
for (const m of ranks[provider]) {
|
|
503
|
-
if (available.includes(m)) return m;
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
// Check user preferences (e.g. { prefer: 'opus', for: 'security' })
|
|
507
|
-
const prefs = profile?.preferences || [];
|
|
508
|
-
for (const pref of prefs) {
|
|
509
|
-
if (pref.model && available.includes(pref.model) &&
|
|
510
|
-
pref.for && MODEL_CAPABILITIES[pref.model]?.strengths?.includes(pref.for)) {
|
|
511
|
-
return pref.model;
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
return model;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
function pickEffort(model, detection) {
|
|
518
|
-
const caps = MODEL_CAPABILITIES[model];
|
|
519
|
-
if (!caps?.effortLevels) return null;
|
|
520
|
-
const { risk = 'low', complexity = 'simple', effort } = detection;
|
|
521
|
-
if (effort && caps.effortLevels.includes(effort)) return effort;
|
|
522
|
-
if (risk === 'critical' || complexity === 'complex') return 'xhigh';
|
|
523
|
-
if (risk === 'high' || complexity === 'moderate') return 'high';
|
|
524
|
-
if (risk === 'low' && complexity === 'trivial') return 'low';
|
|
525
|
-
return 'medium';
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
function pickModes(model, detection) {
|
|
529
|
-
const { intent = '', complexity = 'simple' } = detection;
|
|
530
|
-
const caps = MODEL_CAPABILITIES[model] ?? {};
|
|
531
|
-
const thinkingModels = ['sonnet', 'opus', 'o3', 'gpt-4o'];
|
|
532
|
-
const lightIntents = ['search', 'format', 'explain', 'lookup'];
|
|
533
|
-
|
|
534
|
-
return {
|
|
535
|
-
extendedThinking: thinkingModels.includes(model)
|
|
536
|
-
&& ['moderate', 'complex'].includes(complexity)
|
|
537
|
-
&& !lightIntents.includes(intent),
|
|
538
|
-
fastMode: model === 'opus',
|
|
539
|
-
extendedContext: ['sonnet', 'opus'].includes(model),
|
|
540
|
-
webSearch: ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o'].includes(model),
|
|
541
|
-
};
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
function pickSandbox(model, detection) {
|
|
545
|
-
const { tier = 'execute' } = detection;
|
|
546
|
-
if (tier === 'search') return 'read-only';
|
|
547
|
-
if (MODEL_CAPABILITIES[model]?.provider === 'openai') return 'danger-full-access';
|
|
548
|
-
return 'workspace-write';
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
function chooseProvider(detection, profile, healthScores) {
|
|
552
|
-
const { tier = 'execute', intent = '' } = detection;
|
|
553
|
-
const claudeScore = healthScores.claude;
|
|
554
|
-
const openaiScore = healthScores.openai;
|
|
555
|
-
|
|
556
|
-
// OpenAI not configured or not enabled → always use Claude
|
|
557
|
-
if (!profile?.providers?.openai?.enabled) return 'claude';
|
|
558
|
-
|
|
559
|
-
// Both hot (score=0) → pick the one with the higher score; if tied, prefer Claude
|
|
560
|
-
if (claudeScore === 0 && openaiScore === 0) {
|
|
561
|
-
return claudeScore >= openaiScore ? 'claude' : 'openai';
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// Think-tier strongly prefers Claude (session context coupling), unless Claude is hot
|
|
565
|
-
if (THINK_INTENTS.includes(intent) && claudeScore > 0) return 'claude';
|
|
566
|
-
|
|
567
|
-
// Claude hot → route to OpenAI if available
|
|
568
|
-
if (claudeScore === 0 && openaiScore > 0) return 'openai';
|
|
569
|
-
|
|
570
|
-
// Isolated execute tasks: route to OpenAI if Claude is degraded/probing but OpenAI is healthy
|
|
571
|
-
if (tier === 'execute' && !THINK_INTENTS.includes(intent)) {
|
|
572
|
-
if (claudeScore < 100 && openaiScore > claudeScore) return 'openai';
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// Default: Claude (lower session-context overhead, higher score wins)
|
|
576
|
-
return claudeScore >= openaiScore ? 'claude' : 'openai';
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// ─── Exported: explainDecision ────────────────────────────────────────────────
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* Generate a one-sentence explanation for the routing decision.
|
|
583
|
-
* @param {object} decision
|
|
584
|
-
* @param {object} detection
|
|
585
|
-
* @param {object} profile
|
|
586
|
-
* @returns {string}
|
|
587
|
-
*/
|
|
588
|
-
export function explainDecision(decision, detection, profile) {
|
|
589
|
-
const { provider, model, effort, dualBrain, workStyle, challengerModel } = decision;
|
|
590
|
-
const { intent = 'task', risk = 'low', complexity = 'simple', tier = 'execute' } = detection;
|
|
591
|
-
const healthScores = decision._healthScores || {};
|
|
592
|
-
const mode = profile?.mode || profile?.profile || 'auto';
|
|
593
|
-
|
|
594
|
-
const ws = decision._workStyle ?? getWorkStyle(profile);
|
|
595
|
-
const wsLabel = ws.label ?? workStyle ?? 'Balanced';
|
|
596
|
-
const modelLabel = effort ? `${model} ${effort}` : model;
|
|
597
|
-
|
|
598
|
-
if (dualBrain && challengerModel) {
|
|
599
|
-
return `${wsLabel} mode: ${modelLabel} for ${intent}, ${challengerModel} challenger on ${risk}-risk changes.`;
|
|
600
|
-
}
|
|
601
|
-
if (dualBrain) {
|
|
602
|
-
return `${wsLabel} mode: ${modelLabel} with dual-brain review because this ${intent} change is ${risk} risk.`;
|
|
603
|
-
}
|
|
604
|
-
// Health-based explanations
|
|
605
|
-
const claudeScore = healthScores.claude ?? 100;
|
|
606
|
-
const providerScore = healthScores[provider] ?? 100;
|
|
607
|
-
if (claudeScore === 0 && provider === 'openai') {
|
|
608
|
-
return `${wsLabel} mode: using ${modelLabel} because Claude is rate-limited and this is an isolated ${tier} task.`;
|
|
609
|
-
}
|
|
610
|
-
if (providerScore < 50) {
|
|
611
|
-
return `${wsLabel} mode: using ${modelLabel} (downgraded due to rate-limit cooldown) for this ${complexity} ${intent}.`;
|
|
612
|
-
}
|
|
613
|
-
if (mode === 'cost-saver') {
|
|
614
|
-
return `${wsLabel} mode: using ${modelLabel} (cost-saver bias) for ${risk}-risk ${intent}.`;
|
|
615
|
-
}
|
|
616
|
-
if (mode === 'quality-first') {
|
|
617
|
-
return `${wsLabel} mode: using ${modelLabel} (quality-first bias) for ${intent}.`;
|
|
618
|
-
}
|
|
619
|
-
if (THINK_INTENTS.includes(intent)) {
|
|
620
|
-
return `${wsLabel} mode: ${modelLabel} for ${intent} — deep reasoning needed.`;
|
|
621
|
-
}
|
|
622
|
-
if (tier === 'search' || SEARCH_INTENTS.includes(intent)) {
|
|
623
|
-
return `${wsLabel} mode: ${modelLabel} for lightweight ${intent} lookup.`;
|
|
624
|
-
}
|
|
625
|
-
return `${wsLabel} mode: ${modelLabel} for ${intent} (${risk} risk, ${provider} healthy).`;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// ─── Exported: parsePreferences ──────────────────────────────────────────────
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Parse free-text user preferences into routing-relevant signals.
|
|
632
|
-
* @param {Array<{text: string, enabled: boolean, scope: string}>} preferences
|
|
633
|
-
* @returns {{
|
|
634
|
-
* biasOverride: 'cost-saver'|'quality-first'|null,
|
|
635
|
-
* preferProvider: 'claude'|'openai'|null,
|
|
636
|
-
* avoidProvider: 'claude'|'openai'|null,
|
|
637
|
-
* alwaysDualBrain: boolean,
|
|
638
|
-
* neverDualBrain: boolean,
|
|
639
|
-
* preferModel: 'opus'|'sonnet'|'haiku'|null,
|
|
640
|
-
* }}
|
|
641
|
-
*/
|
|
642
|
-
export function parsePreferences(preferences) {
|
|
643
|
-
const active = (preferences || []).filter(p => p.enabled);
|
|
644
|
-
const signals = {
|
|
645
|
-
biasOverride: null,
|
|
646
|
-
preferProvider: null,
|
|
647
|
-
avoidProvider: null,
|
|
648
|
-
alwaysDualBrain: false,
|
|
649
|
-
neverDualBrain: false,
|
|
650
|
-
preferModel: null,
|
|
651
|
-
};
|
|
652
|
-
|
|
653
|
-
for (const pref of active) {
|
|
654
|
-
const t = pref.text.toLowerCase();
|
|
655
|
-
// Cost/quality bias signals
|
|
656
|
-
if (/cheap|save|budget|frugal|economical|cost/i.test(t)) signals.biasOverride = 'cost-saver';
|
|
657
|
-
if (/quality|best|thorough|careful|premium/i.test(t)) signals.biasOverride = 'quality-first';
|
|
658
|
-
// Provider preference signals
|
|
659
|
-
if (/prefer claude|use claude|claude first/i.test(t)) signals.preferProvider = 'claude';
|
|
660
|
-
if (/prefer (openai|gpt|chatgpt)|use (openai|gpt)/i.test(t)) signals.preferProvider = 'openai';
|
|
661
|
-
if (/avoid claude|no claude/i.test(t)) signals.avoidProvider = 'claude';
|
|
662
|
-
if (/avoid (openai|gpt)|no (openai|gpt)/i.test(t)) signals.avoidProvider = 'openai';
|
|
663
|
-
// Dual-brain signals
|
|
664
|
-
if (/always/.test(t) && /(consensus|dual.brain|two.brain|dual)/i.test(t)) signals.alwaysDualBrain = true;
|
|
665
|
-
if (/never (consensus|dual)|skip (review|consensus)|solo/i.test(t)) signals.neverDualBrain = true;
|
|
666
|
-
// Model preference signals
|
|
667
|
-
if (/prefer opus|use opus/i.test(t)) signals.preferModel = 'opus';
|
|
668
|
-
if (/prefer sonnet|use sonnet/i.test(t)) signals.preferModel = 'sonnet';
|
|
669
|
-
if (/prefer haiku|use haiku/i.test(t)) signals.preferModel = 'haiku';
|
|
670
|
-
}
|
|
671
|
-
return signals;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
// ─── Internal: safety floor for critical-risk tasks ───────────────────────────
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
* Ensure critical-risk tasks are never handled by the cheapest (haiku/gpt-4.1-mini) model.
|
|
678
|
-
* Cost-saver mode is the main culprit; escalate silently but emit a stderr warning.
|
|
679
|
-
* @param {string} model
|
|
680
|
-
* @param {string} provider
|
|
681
|
-
* @param {string[]} available
|
|
682
|
-
* @param {'low'|'medium'|'high'|'critical'} risk
|
|
683
|
-
* @returns {string}
|
|
684
|
-
*/
|
|
685
|
-
function applyCriticalRiskFloor(model, provider, available, risk) {
|
|
686
|
-
if (risk !== 'critical') return model;
|
|
687
|
-
|
|
688
|
-
const cheapModels = { claude: 'haiku', openai: 'gpt-4.1-mini' };
|
|
689
|
-
const floorModels = { claude: 'sonnet', openai: 'gpt-4.1' };
|
|
690
|
-
|
|
691
|
-
if (model === cheapModels[provider]) {
|
|
692
|
-
const floor = floorModels[provider];
|
|
693
|
-
const escalated = available.includes(floor) ? floor : available[available.length - 1] ?? model;
|
|
694
|
-
process.stderr.write(
|
|
695
|
-
`[dual-brain] Warning: cost-saver selected ${model} for a critical-risk task. ` +
|
|
696
|
-
`Escalating to ${escalated} (safety floor).\n`
|
|
697
|
-
);
|
|
698
|
-
return escalated;
|
|
699
|
-
}
|
|
700
|
-
return model;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
// ─── Exported: decideRoute ────────────────────────────────────────────────────
|
|
704
|
-
|
|
705
|
-
/**
|
|
706
|
-
* Main routing decision function.
|
|
707
|
-
* @param {{ profile: object, detection: object, cwd?: string, thinkResult?: object, sessionContext?: object }} input
|
|
708
|
-
* @returns {object} Routing decision
|
|
709
|
-
*/
|
|
710
|
-
export function decideRoute({ profile = {}, detection = {}, cwd, thinkResult, sessionContext = null } = {}) {
|
|
711
|
-
const available = getAvailableModels(profile);
|
|
712
|
-
|
|
713
|
-
// Resolve active work style
|
|
714
|
-
const workStyle = getWorkStyle(profile);
|
|
715
|
-
|
|
716
|
-
// Parse free-text user preferences into routing signals
|
|
717
|
-
const prefSignals = parsePreferences(profile.preferences);
|
|
718
|
-
|
|
719
|
-
// Apply bias override from preferences (takes precedence over profile.bias)
|
|
720
|
-
const profileWithEffectiveBias = prefSignals.biasOverride
|
|
721
|
-
? { ...profile, mode: prefSignals.biasOverride }
|
|
722
|
-
: profile;
|
|
723
|
-
|
|
724
|
-
const { tier = 'execute', risk = 'low', complexity = 'simple', effort: detectionEffort } = detection;
|
|
725
|
-
const isHighStakes = ['critical', 'high'].includes(risk);
|
|
726
|
-
|
|
727
|
-
// Determine whether to use the complexWorker (Opus) or defaultWorker (Sonnet).
|
|
728
|
-
// "High reasoning depth" means: think-tier intent, high/critical risk, or complex+high-risk.
|
|
729
|
-
const needsDeepReasoning =
|
|
730
|
-
THINK_INTENTS.includes(detection.intent || '') ||
|
|
731
|
-
risk === 'critical' ||
|
|
732
|
-
(complexity === 'complex' && ['high', 'critical'].includes(risk)) ||
|
|
733
|
-
detectionEffort === 'xhigh';
|
|
734
|
-
|
|
735
|
-
// Get health scores for current tier
|
|
736
|
-
const healthScores = getHealthScores(tier, cwd);
|
|
737
|
-
|
|
738
|
-
// Choose provider (using the bias-patched profile so chooseProvider sees the right mode)
|
|
739
|
-
let provider = chooseProvider(detection, profileWithEffectiveBias, healthScores);
|
|
740
|
-
|
|
741
|
-
// Apply preferProvider / avoidProvider signals from preferences
|
|
742
|
-
if (prefSignals.preferProvider) {
|
|
743
|
-
const preferred = prefSignals.preferProvider;
|
|
744
|
-
const prefEnabled = profile?.providers?.[preferred]?.enabled;
|
|
745
|
-
const prefScore = healthScores[preferred] ?? 0;
|
|
746
|
-
if (prefEnabled && prefScore > 0) provider = preferred;
|
|
747
|
-
}
|
|
748
|
-
if (prefSignals.avoidProvider && provider === prefSignals.avoidProvider) {
|
|
749
|
-
const other = prefSignals.avoidProvider === 'claude' ? 'openai' : 'claude';
|
|
750
|
-
const otherEnabled = profile?.providers?.[other]?.enabled;
|
|
751
|
-
const otherScore = healthScores[other] ?? 0;
|
|
752
|
-
if (otherEnabled && otherScore > 0) provider = other;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
// Select base model using work style worker assignments.
|
|
756
|
-
// For Claude primary: use complexWorker (opus) on deep reasoning, defaultWorker (sonnet) otherwise.
|
|
757
|
-
// For OpenAI primary: mirror the same logic using GPT equivalents.
|
|
758
|
-
//
|
|
759
|
-
// Hardcoded fallback models (used when model registry is unavailable):
|
|
760
|
-
const _fallbackClaude = (() => {
|
|
761
|
-
const wantOpus = needsDeepReasoning && workStyle.key !== 'fast';
|
|
762
|
-
const fb = wantOpus && available.claude.includes('opus') ? 'opus' : 'sonnet';
|
|
763
|
-
return available.claude.includes(fb) ? fb : (available.claude[available.claude.length - 1] ?? 'sonnet');
|
|
764
|
-
})();
|
|
765
|
-
const _fallbackOpenAI = (() => {
|
|
766
|
-
const wantO3 = needsDeepReasoning && workStyle.key === 'fullpower';
|
|
767
|
-
const fb = wantO3 && available.openai.includes('o3') ? 'o3' : 'gpt-4o';
|
|
768
|
-
return available.openai.includes(fb) ? fb : (available.openai[available.openai.length - 1] ?? 'gpt-4o');
|
|
769
|
-
})();
|
|
770
|
-
|
|
771
|
-
let model;
|
|
772
|
-
if (modelRegistry) {
|
|
773
|
-
// Use registry to pick best model for the tier/provider.
|
|
774
|
-
// Map decide.mjs tier to registry taskType and constraints.
|
|
775
|
-
const registryProvider = provider === 'claude' ? 'anthropic' : 'openai';
|
|
776
|
-
const taskType = tier === 'search' ? 'search'
|
|
777
|
-
: tier === 'think' ? 'think'
|
|
778
|
-
: 'execute';
|
|
779
|
-
const constraints = {
|
|
780
|
-
provider: registryProvider,
|
|
781
|
-
...(tier === 'search' && { preferSpeed: true }),
|
|
782
|
-
...(tier === 'think' && { requireReasoning: true }),
|
|
783
|
-
...(!needsDeepReasoning && workStyle.key === 'fast' && { maxCost: 'medium' }),
|
|
784
|
-
};
|
|
785
|
-
const registryResult = modelRegistry.getBestModel(taskType, constraints);
|
|
786
|
-
if (registryResult) {
|
|
787
|
-
// Registry returns full model IDs (e.g. 'claude-sonnet-4-6').
|
|
788
|
-
// dispatch.mjs mapToAgentModel handles both short names and full IDs.
|
|
789
|
-
model = registryResult.id;
|
|
790
|
-
} else {
|
|
791
|
-
// Registry found no match — use hardcoded fallback
|
|
792
|
-
model = provider === 'claude' ? _fallbackClaude : _fallbackOpenAI;
|
|
793
|
-
}
|
|
794
|
-
} else {
|
|
795
|
-
// Registry unavailable — use existing hardcoded selection
|
|
796
|
-
model = provider === 'claude' ? _fallbackClaude : _fallbackOpenAI;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// The internal pipeline (health downgrade, profile bias, safety floor) operates on
|
|
800
|
-
// short model names ('haiku', 'sonnet', 'opus', 'gpt-4o', etc.) and the available[]
|
|
801
|
-
// arrays use the same short names. Normalize a full model ID to short name first so
|
|
802
|
-
// that rank lookups work correctly, then restore the full ID at the end.
|
|
803
|
-
model = toShortName(model, provider);
|
|
804
|
-
|
|
805
|
-
// Apply health-based downgrade (only if score < 50 and not high-stakes)
|
|
806
|
-
model = applyHealthDowngrade(model, healthScores[provider], provider, available[provider], isHighStakes);
|
|
807
|
-
|
|
808
|
-
// Apply profile mode bias (cost-saver / quality-first / preferences) using patched profile
|
|
809
|
-
model = applyProfileBias(model, profileWithEffectiveBias, provider, available[provider], detection.tier);
|
|
810
|
-
|
|
811
|
-
// Think-engine tier hint: use as a HINT to allow cheaper model when think-engine
|
|
812
|
-
// classifies the task as recall/quick. Never escalate — only downgrade when safe to do so.
|
|
813
|
-
let thinkTier = null;
|
|
814
|
-
try {
|
|
815
|
-
if (thinkResult?.tier) thinkTier = thinkResult.tier;
|
|
816
|
-
} catch (e) {}
|
|
817
|
-
|
|
818
|
-
if (thinkTier && !isHighStakes) {
|
|
819
|
-
const claudeRankAsc = ['haiku', 'sonnet', 'opus'];
|
|
820
|
-
const openaiRankAsc = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
|
|
821
|
-
|
|
822
|
-
if (thinkTier === 'recall' && provider === 'claude') {
|
|
823
|
-
// recall → haiku is fine if available
|
|
824
|
-
const target = 'haiku';
|
|
825
|
-
const currentIdx = claudeRankAsc.indexOf(model);
|
|
826
|
-
const targetIdx = claudeRankAsc.indexOf(target);
|
|
827
|
-
if (targetIdx !== -1 && targetIdx < currentIdx && available.claude.includes(target)) {
|
|
828
|
-
model = target;
|
|
829
|
-
}
|
|
830
|
-
} else if (thinkTier === 'recall' && provider === 'openai') {
|
|
831
|
-
const target = 'gpt-4o-mini';
|
|
832
|
-
const currentIdx = openaiRankAsc.indexOf(model);
|
|
833
|
-
const targetIdx = openaiRankAsc.indexOf(target);
|
|
834
|
-
if (targetIdx !== -1 && targetIdx < currentIdx && available.openai.includes(target)) {
|
|
835
|
-
model = target;
|
|
836
|
-
}
|
|
837
|
-
} else if (thinkTier === 'quick' && provider === 'claude') {
|
|
838
|
-
// quick → sonnet is sufficient
|
|
839
|
-
const target = 'sonnet';
|
|
840
|
-
const currentIdx = claudeRankAsc.indexOf(model);
|
|
841
|
-
const targetIdx = claudeRankAsc.indexOf(target);
|
|
842
|
-
if (targetIdx !== -1 && targetIdx < currentIdx && available.claude.includes(target)) {
|
|
843
|
-
model = target;
|
|
844
|
-
}
|
|
845
|
-
} else if (thinkTier === 'quick' && provider === 'openai') {
|
|
846
|
-
const target = 'gpt-4o';
|
|
847
|
-
const currentIdx = openaiRankAsc.indexOf(model);
|
|
848
|
-
const targetIdx = openaiRankAsc.indexOf(target);
|
|
849
|
-
if (targetIdx !== -1 && targetIdx < currentIdx && available.openai.includes(target)) {
|
|
850
|
-
model = target;
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
// 'standard', 'deep', 'ultra' — leave model unchanged; existing routing already picked correctly
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
// Session context: escalate or prefer model based on cross-session history
|
|
857
|
-
if (sessionContext) {
|
|
858
|
-
const sessionAttempts = Array.isArray(sessionContext.priorAttempts) ? sessionContext.priorAttempts : [];
|
|
859
|
-
const sessionFailures = sessionAttempts.filter(a => a && (a.failed || a.status === 'failed'));
|
|
860
|
-
const sessionSuccesses = sessionAttempts.filter(a => a && !a.failed && a.status !== 'failed');
|
|
861
|
-
|
|
862
|
-
// Prior failures on similar work → escalate from sonnet to opus (Claude) or gpt-4o to o3 (OpenAI)
|
|
863
|
-
if (sessionFailures.length >= 2 && !isHighStakes) {
|
|
864
|
-
if (provider === 'claude') {
|
|
865
|
-
const claudeRank = ['haiku', 'sonnet', 'opus'];
|
|
866
|
-
const currentIdx = claudeRank.indexOf(toShortName(model, 'claude'));
|
|
867
|
-
if (currentIdx !== -1 && currentIdx < claudeRank.length - 1) {
|
|
868
|
-
const escalated = claudeRank[currentIdx + 1];
|
|
869
|
-
if (available.claude.includes(escalated)) model = escalated;
|
|
870
|
-
}
|
|
871
|
-
} else {
|
|
872
|
-
const oaiRank = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
|
|
873
|
-
const currentIdx = oaiRank.indexOf(model);
|
|
874
|
-
if (currentIdx !== -1 && currentIdx < oaiRank.length - 1) {
|
|
875
|
-
const escalated = oaiRank[currentIdx + 1];
|
|
876
|
-
if (available.openai.includes(escalated)) model = escalated;
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
// Prior successful approach → prefer same provider/model that worked before
|
|
882
|
-
if (sessionSuccesses.length > 0) {
|
|
883
|
-
const lastSuccess = sessionSuccesses[sessionSuccesses.length - 1];
|
|
884
|
-
if (lastSuccess.provider && lastSuccess.model && !isHighStakes) {
|
|
885
|
-
const successProvider = lastSuccess.provider;
|
|
886
|
-
const successModel = lastSuccess.model;
|
|
887
|
-
const providerEnabled = profile?.providers?.[successProvider]?.enabled;
|
|
888
|
-
const providerHealthy = (healthScores[successProvider] ?? 0) > 0;
|
|
889
|
-
if (providerEnabled && providerHealthy) {
|
|
890
|
-
const shortSuccess = toShortName(successModel, successProvider);
|
|
891
|
-
if (available[successProvider]?.includes(shortSuccess)) {
|
|
892
|
-
provider = successProvider;
|
|
893
|
-
model = shortSuccess;
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// Safety floor: critical-risk tasks must never use haiku/gpt-4.1-mini even in cost-saver mode
|
|
901
|
-
model = applyCriticalRiskFloor(model, provider, available[provider], detection.risk);
|
|
902
|
-
|
|
903
|
-
// Apply preferModel signal from preferences (override after all other picks)
|
|
904
|
-
if (prefSignals.preferModel) {
|
|
905
|
-
const wantedModel = prefSignals.preferModel;
|
|
906
|
-
if (available[provider]?.includes(wantedModel)) {
|
|
907
|
-
model = wantedModel;
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
// Restore full model ID from registry if the pipeline kept the same short name it started with.
|
|
912
|
-
// If the pipeline changed the model (downgrade/bias/floor), resolve the new short name to a full ID.
|
|
913
|
-
model = toFullModelId(model, provider, tier);
|
|
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
|
-
|
|
937
|
-
// ── Challenger / dual-brain decision ─────────────────────────────────────
|
|
938
|
-
const hasBothProviders = !!(
|
|
939
|
-
profile?.providers?.claude?.enabled &&
|
|
940
|
-
profile?.providers?.openai?.enabled
|
|
941
|
-
);
|
|
942
|
-
|
|
943
|
-
// Work-style challenger: triggered by challengerPolicy + risk level
|
|
944
|
-
const challengerTriggered = shouldTriggerChallenger(
|
|
945
|
-
workStyle.challengerPolicy,
|
|
946
|
-
risk,
|
|
947
|
-
hasBothProviders,
|
|
948
|
-
);
|
|
949
|
-
|
|
950
|
-
// Legacy designImpact dual-brain gate (mandatory review, bypass hasBothProviders check)
|
|
951
|
-
const legacyDualBrain = !!(detection.designImpact && profile?.dual_brain_enabled !== false);
|
|
952
|
-
|
|
953
|
-
// Preference overrides
|
|
954
|
-
let dual = challengerTriggered || legacyDualBrain || shouldDualBrain(detection, profile);
|
|
955
|
-
if (prefSignals.alwaysDualBrain) dual = true;
|
|
956
|
-
if (prefSignals.neverDualBrain) dual = false;
|
|
957
|
-
|
|
958
|
-
// When only one provider available and challenger was the reason, downgrade to single-brain
|
|
959
|
-
if (dual && !hasBothProviders && !legacyDualBrain) dual = false;
|
|
960
|
-
|
|
961
|
-
const degradedDualBrain = !!(legacyDualBrain && !hasBothProviders);
|
|
962
|
-
|
|
963
|
-
// Pick challenger model (from the opposing provider)
|
|
964
|
-
const challengerModel = dual ? pickChallengerModel(provider, available) : null;
|
|
965
|
-
|
|
966
|
-
// Determine effort, modes, sandbox
|
|
967
|
-
const effort = pickEffort(model, detection);
|
|
968
|
-
const modes = pickModes(model, detection);
|
|
969
|
-
const sandbox = pickSandbox(model, detection);
|
|
970
|
-
|
|
971
|
-
const decision = {
|
|
972
|
-
provider,
|
|
973
|
-
model,
|
|
974
|
-
effort,
|
|
975
|
-
tier,
|
|
976
|
-
dualBrain: dual,
|
|
977
|
-
...(degradedDualBrain && { degradedDualBrain: true }),
|
|
978
|
-
...(challengerModel && { challengerModel }),
|
|
979
|
-
workStyle: workStyle.key,
|
|
980
|
-
modes,
|
|
981
|
-
sandbox,
|
|
982
|
-
explanation: '',
|
|
983
|
-
_healthScores: healthScores,
|
|
984
|
-
_workStyle: workStyle,
|
|
985
|
-
...(_advisorOverride && { _advisorOverride }),
|
|
986
|
-
};
|
|
987
|
-
|
|
988
|
-
decision.explanation = explainDecision(decision, detection, profileWithEffectiveBias);
|
|
989
|
-
|
|
990
|
-
// Remove internal fields from public output
|
|
991
|
-
const { _healthScores, _workStyle, ...result } = decision;
|
|
992
|
-
return result;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
// ─── Exported: getFailoverOrder ──────────────────────────────────────────────
|
|
996
|
-
|
|
997
|
-
/**
|
|
998
|
-
* Given a failed routing decision and the active profile, return an ordered list
|
|
999
|
-
* of fallback options to try next.
|
|
1000
|
-
*
|
|
1001
|
-
* Priority order:
|
|
1002
|
-
* 1. Other subscriptions of the same provider (e.g. Claude Max #2 before Claude Pro)
|
|
1003
|
-
* 2. Other provider (OpenAI or Claude, whichever wasn't tried)
|
|
1004
|
-
*
|
|
1005
|
-
* Within each group, options are ordered by capability match for the tier
|
|
1006
|
-
* (best fit first, cheapest last).
|
|
1007
|
-
*
|
|
1008
|
-
* @param {object} decision The routing decision that just failed (provider, model, tier)
|
|
1009
|
-
* @param {object} profile Active profile with providers/subscriptions info
|
|
1010
|
-
* @returns {Array<{ provider: string, model: string, plan: string, label: string }>}
|
|
1011
|
-
*/
|
|
1012
|
-
export function getFailoverOrder(decision, profile) {
|
|
1013
|
-
const { provider: failedProvider, model: failedModel, tier = 'execute' } = decision;
|
|
1014
|
-
const available = getAvailableModels(profile);
|
|
1015
|
-
|
|
1016
|
-
// Build a ranked model list for Claude (best capability for tier → cheapest)
|
|
1017
|
-
const claudeRankByTier = {
|
|
1018
|
-
think: ['opus', 'sonnet', 'haiku'],
|
|
1019
|
-
execute: ['sonnet', 'opus', 'haiku'],
|
|
1020
|
-
search: ['haiku', 'sonnet', 'opus'],
|
|
1021
|
-
};
|
|
1022
|
-
const openaiRankByTier = {
|
|
1023
|
-
think: ['o3', 'gpt-4o', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4o-mini'],
|
|
1024
|
-
execute: ['gpt-4o', 'gpt-4.1', 'o3', 'gpt-4.1-mini', 'gpt-4o-mini'],
|
|
1025
|
-
search: ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o3'],
|
|
1026
|
-
};
|
|
1027
|
-
|
|
1028
|
-
const claudeRank = claudeRankByTier[tier] ?? claudeRankByTier.execute;
|
|
1029
|
-
const openaiRank = openaiRankByTier[tier] ?? openaiRankByTier.execute;
|
|
1030
|
-
|
|
1031
|
-
const claudeEnabled = !!(profile?.providers?.claude?.enabled);
|
|
1032
|
-
const openaiEnabled = !!(profile?.providers?.openai?.enabled);
|
|
1033
|
-
|
|
1034
|
-
const fallbacks = [];
|
|
1035
|
-
|
|
1036
|
-
if (failedProvider === 'claude') {
|
|
1037
|
-
// Same-provider fallbacks: other Claude models (skip the one that just failed)
|
|
1038
|
-
for (const m of claudeRank) {
|
|
1039
|
-
if (m === failedModel) continue;
|
|
1040
|
-
if (!available.claude.includes(m)) continue;
|
|
1041
|
-
fallbacks.push({ provider: 'claude', model: m, label: `Claude ${m}` });
|
|
1042
|
-
}
|
|
1043
|
-
// Cross-provider fallbacks: OpenAI models
|
|
1044
|
-
if (openaiEnabled) {
|
|
1045
|
-
for (const m of openaiRank) {
|
|
1046
|
-
if (!available.openai.includes(m)) continue;
|
|
1047
|
-
fallbacks.push({ provider: 'openai', model: m, label: `OpenAI ${m}` });
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
} else {
|
|
1051
|
-
// Same-provider fallbacks: other OpenAI models (skip the one that just failed)
|
|
1052
|
-
for (const m of openaiRank) {
|
|
1053
|
-
if (m === failedModel) continue;
|
|
1054
|
-
if (!available.openai.includes(m)) continue;
|
|
1055
|
-
fallbacks.push({ provider: 'openai', model: m, label: `OpenAI ${m}` });
|
|
1056
|
-
}
|
|
1057
|
-
// Cross-provider fallbacks: Claude models
|
|
1058
|
-
if (claudeEnabled) {
|
|
1059
|
-
for (const m of claudeRank) {
|
|
1060
|
-
if (!available.claude.includes(m)) continue;
|
|
1061
|
-
fallbacks.push({ provider: 'claude', model: m, label: `Claude ${m}` });
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
return fallbacks;
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
1070
|
-
|
|
1071
|
-
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
1072
|
-
const args = process.argv.slice(2);
|
|
1073
|
-
let profilePath, detectionJson, cwd;
|
|
1074
|
-
|
|
1075
|
-
for (let i = 0; i < args.length; i++) {
|
|
1076
|
-
if (args[i] === '--profile' && args[i + 1]) { profilePath = args[++i]; }
|
|
1077
|
-
if (args[i] === '--detection' && args[i + 1]) { detectionJson = args[++i]; }
|
|
1078
|
-
if (args[i] === '--cwd' && args[i + 1]) { cwd = args[++i]; }
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
let profile = {};
|
|
1082
|
-
let detection = {};
|
|
1083
|
-
|
|
1084
|
-
if (profilePath) {
|
|
1085
|
-
try { profile = JSON.parse(readFileSync(profilePath, 'utf8')); } catch (e) {
|
|
1086
|
-
console.error(`Failed to load profile: ${e.message}`);
|
|
1087
|
-
process.exit(1);
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
if (detectionJson) {
|
|
1091
|
-
try { detection = JSON.parse(detectionJson); } catch (e) {
|
|
1092
|
-
console.error(`Failed to parse detection JSON: ${e.message}`);
|
|
1093
|
-
process.exit(1);
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
const result = decideRoute({ profile, detection, cwd });
|
|
1098
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1099
|
-
}
|