dual-brain 7.1.25 → 7.1.27
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 +60 -5
- package/package.json +7 -2
- package/src/awareness.mjs +343 -0
- package/src/decide.mjs +115 -8
- package/src/models.mjs +363 -0
- package/src/pipeline.mjs +86 -4
- package/src/prompt-intel.mjs +325 -0
package/bin/dual-brain.mjs
CHANGED
|
@@ -1518,12 +1518,20 @@ function detectInterruptedWork(sessions, cwd) {
|
|
|
1518
1518
|
* Shows: "● Claude ● OpenAI ⚖️ Balanced"
|
|
1519
1519
|
* Uses ANSI color codes for the dots — no dollar amounts or usage bars.
|
|
1520
1520
|
*/
|
|
1521
|
-
function buildProviderStatusLine(profile, auth) {
|
|
1521
|
+
function buildProviderStatusLine(profile, auth, envReport = null) {
|
|
1522
1522
|
const GREEN = '\x1b[32m●\x1b[0m';
|
|
1523
1523
|
const RED = '\x1b[31m●\x1b[0m';
|
|
1524
1524
|
|
|
1525
|
-
|
|
1526
|
-
const
|
|
1525
|
+
// Use envReport secrets when available; fall back to auth detection
|
|
1526
|
+
const claudeAvailable = envReport
|
|
1527
|
+
? envReport.secrets.ANTHROPIC_API_KEY || auth.claude.found
|
|
1528
|
+
: auth.claude.found;
|
|
1529
|
+
const openaiAvailable = envReport
|
|
1530
|
+
? envReport.secrets.OPENAI_API_KEY || auth.openai.found
|
|
1531
|
+
: auth.openai.found;
|
|
1532
|
+
|
|
1533
|
+
const claudeDot = claudeAvailable ? GREEN : RED;
|
|
1534
|
+
const openaiDot = openaiAvailable ? GREEN : RED;
|
|
1527
1535
|
|
|
1528
1536
|
const WORK_STYLE_LABELS = {
|
|
1529
1537
|
'auto': '⚡ Fast',
|
|
@@ -1703,8 +1711,15 @@ async function mainScreen(rl, ask) {
|
|
|
1703
1711
|
// 's' → fall through to normal dashboard
|
|
1704
1712
|
}
|
|
1705
1713
|
|
|
1714
|
+
// ── Environment awareness (powers Box 1 dots + Box 3) ────────────────────
|
|
1715
|
+
let envReport = null;
|
|
1716
|
+
try {
|
|
1717
|
+
const { scanEnvironment } = await import('../src/awareness.mjs');
|
|
1718
|
+
envReport = scanEnvironment(cwd);
|
|
1719
|
+
} catch { /* non-fatal */ }
|
|
1720
|
+
|
|
1706
1721
|
// ── Box 1 — Header row data ─────────────────────────────────────────────
|
|
1707
|
-
const providerLine = buildProviderStatusLine(profile, auth);
|
|
1722
|
+
const providerLine = buildProviderStatusLine(profile, auth, envReport);
|
|
1708
1723
|
|
|
1709
1724
|
// ── Box 2 — Workspace: gather git data ───────────────────────────────────
|
|
1710
1725
|
let gitBranch = 'unknown';
|
|
@@ -1771,6 +1786,7 @@ async function mainScreen(rl, ask) {
|
|
|
1771
1786
|
let awarenessLine2 = '\x1b[2m📋 No roadmap yet\x1b[0m';
|
|
1772
1787
|
let awarenessLine3 = '\x1b[32m✓\x1b[0m No risk flags';
|
|
1773
1788
|
|
|
1789
|
+
// Line 1: observer data first; fall back to envReport-derived observations
|
|
1774
1790
|
let quickObservations = [];
|
|
1775
1791
|
try {
|
|
1776
1792
|
const observerMod = await import('../src/observer.mjs');
|
|
@@ -1793,7 +1809,27 @@ async function mainScreen(rl, ask) {
|
|
|
1793
1809
|
}
|
|
1794
1810
|
} catch { /* non-fatal — observer may not exist */ }
|
|
1795
1811
|
|
|
1796
|
-
//
|
|
1812
|
+
// If observer produced nothing, derive from envReport
|
|
1813
|
+
if (awarenessLine1 === '\x1b[2m💡\x1b[0m Ready to work' && envReport) {
|
|
1814
|
+
if (envReport.replit?.hasDatabase) {
|
|
1815
|
+
awarenessLine1 = '\x1b[2m💡\x1b[0m PostgreSQL available';
|
|
1816
|
+
} else if (gitUncommitted > 0) {
|
|
1817
|
+
awarenessLine1 = `\x1b[2m💡\x1b[0m ${gitUncommitted} file${gitUncommitted === 1 ? '' : 's'} ready to commit`;
|
|
1818
|
+
} else if (envReport.dualBrain?.hasFailureMemory) {
|
|
1819
|
+
// Check for recent failures
|
|
1820
|
+
try {
|
|
1821
|
+
const failureMem = await getFailureMem();
|
|
1822
|
+
if (failureMem.getRecentFailures) {
|
|
1823
|
+
const recent = failureMem.getRecentFailures(cwd, 2);
|
|
1824
|
+
if (recent?.length > 0) {
|
|
1825
|
+
awarenessLine1 = `\x1b[33m⚠\x1b[0m ${recent.length} recent failure${recent.length === 1 ? '' : 's'} — check before proceeding`;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
} catch { /* non-fatal */ }
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// Line 2: roadmap file, then ledger open tasks as fallback
|
|
1797
1833
|
try {
|
|
1798
1834
|
const roadmapPath = join(cwd, '.dual-brain', 'roadmap.md');
|
|
1799
1835
|
if (existsSync(roadmapPath)) {
|
|
@@ -1808,6 +1844,25 @@ async function mainScreen(rl, ask) {
|
|
|
1808
1844
|
}
|
|
1809
1845
|
} catch { /* non-fatal */ }
|
|
1810
1846
|
|
|
1847
|
+
if (awarenessLine2 === '\x1b[2m📋 No roadmap yet\x1b[0m') {
|
|
1848
|
+
try {
|
|
1849
|
+
const { getOpenTasks } = await import('../src/ledger.mjs');
|
|
1850
|
+
const open = getOpenTasks(cwd);
|
|
1851
|
+
if (open.length > 0) {
|
|
1852
|
+
awarenessLine2 = '📋 Next: ' + open[0].intent.slice(0, 45);
|
|
1853
|
+
}
|
|
1854
|
+
} catch { /* non-fatal */ }
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// Line 3: model registry age warning
|
|
1858
|
+
try {
|
|
1859
|
+
const { getRegistryAge } = await import('../src/models.mjs');
|
|
1860
|
+
const age = getRegistryAge();
|
|
1861
|
+
if (age > 30 && awarenessLine3 === '\x1b[32m✓\x1b[0m No risk flags') {
|
|
1862
|
+
awarenessLine3 = `\x1b[33m⚠\x1b[0m Model registry ${age} days old`;
|
|
1863
|
+
}
|
|
1864
|
+
} catch { /* non-fatal */ }
|
|
1865
|
+
|
|
1811
1866
|
const awarenessRows = [
|
|
1812
1867
|
row(awarenessLine1),
|
|
1813
1868
|
row(awarenessLine2),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dual-brain",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.27",
|
|
4
4
|
"description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
"./decompose": "./src/decompose.mjs",
|
|
21
21
|
"./brief": "./src/brief.mjs",
|
|
22
22
|
"./redact": "./src/redact.mjs",
|
|
23
|
-
"./calibration": "./src/calibration.mjs"
|
|
23
|
+
"./calibration": "./src/calibration.mjs",
|
|
24
|
+
"./models": "./src/models.mjs",
|
|
25
|
+
"./prompt-intel": "./src/prompt-intel.mjs"
|
|
24
26
|
},
|
|
25
27
|
"keywords": [
|
|
26
28
|
"claude-code",
|
|
@@ -60,6 +62,7 @@
|
|
|
60
62
|
"src/brief.mjs",
|
|
61
63
|
"src/redact.mjs",
|
|
62
64
|
"src/calibration.mjs",
|
|
65
|
+
"src/models.mjs",
|
|
63
66
|
"src/pipeline.mjs",
|
|
64
67
|
"src/context.mjs",
|
|
65
68
|
"src/outcome.mjs",
|
|
@@ -71,10 +74,12 @@
|
|
|
71
74
|
"src/index.mjs",
|
|
72
75
|
"src/ledger.mjs",
|
|
73
76
|
"src/intelligence.mjs",
|
|
77
|
+
"src/awareness.mjs",
|
|
74
78
|
"src/tui.mjs",
|
|
75
79
|
"src/living-docs.mjs",
|
|
76
80
|
"src/install-hooks.mjs",
|
|
77
81
|
"src/update-check.mjs",
|
|
82
|
+
"src/prompt-intel.mjs",
|
|
78
83
|
"bin/*.mjs",
|
|
79
84
|
"hooks/enforce-tier.mjs",
|
|
80
85
|
"hooks/cost-logger.mjs",
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* awareness.mjs — Environment awareness layer for dual-brain.
|
|
3
|
+
* Scans runtime environment once on startup and caches results with TTL.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
|
|
11
|
+
let _cache = null;
|
|
12
|
+
let _cacheTime = 0;
|
|
13
|
+
|
|
14
|
+
function safeExec(cmd, timeoutMs = 2000) {
|
|
15
|
+
try {
|
|
16
|
+
return execSync(cmd, {
|
|
17
|
+
encoding: 'utf8',
|
|
18
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
19
|
+
timeout: timeoutMs,
|
|
20
|
+
}).trim();
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function extractVersion(output) {
|
|
27
|
+
if (!output) return null;
|
|
28
|
+
const m = output.match(/(\d+\.\d+[\.\d]*)/);
|
|
29
|
+
return m ? m[1] : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function probeToolAvailability(name) {
|
|
33
|
+
const path = safeExec(`which ${name}`);
|
|
34
|
+
if (!path) return { available: false, version: null };
|
|
35
|
+
if (name === 'rg' || name === 'replit' || name === 'gh') {
|
|
36
|
+
return { available: true };
|
|
37
|
+
}
|
|
38
|
+
const versionOutput = safeExec(`${name} --version`);
|
|
39
|
+
return { available: true, version: extractVersion(versionOutput) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function detectContainerType() {
|
|
43
|
+
const env = process.env;
|
|
44
|
+
if (env.REPL_ID || env.REPL_SLUG) return 'replit';
|
|
45
|
+
if (env.CODESPACES) return 'codespace';
|
|
46
|
+
if (env.CI || env.GITHUB_ACTIONS || env.GITLAB_CI || env.JENKINS_URL) return 'ci';
|
|
47
|
+
return 'local';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function scanSecrets() {
|
|
51
|
+
const keys = [
|
|
52
|
+
'OPENAI_API_KEY',
|
|
53
|
+
'ANTHROPIC_API_KEY',
|
|
54
|
+
'NPM_TOKEN',
|
|
55
|
+
'DATABASE_URL',
|
|
56
|
+
'GITHUB_TOKEN',
|
|
57
|
+
'REPLIT_DB_URL',
|
|
58
|
+
];
|
|
59
|
+
const result = {};
|
|
60
|
+
for (const key of keys) {
|
|
61
|
+
result[key] = Boolean(process.env[key]);
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function scanReplitTools() {
|
|
67
|
+
const home = homedir();
|
|
68
|
+
const candidates = [
|
|
69
|
+
join(home, '.replit-tools'),
|
|
70
|
+
join('/home/runner/workspace', '.replit-tools'),
|
|
71
|
+
join(process.cwd(), '.replit-tools'),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
let toolsDir = null;
|
|
75
|
+
for (const c of candidates) {
|
|
76
|
+
if (existsSync(c)) {
|
|
77
|
+
toolsDir = c;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!toolsDir) {
|
|
83
|
+
return { installed: false, version: null, sessionArchivePath: null, capabilities: [] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let version = null;
|
|
87
|
+
const versionFile = join(toolsDir, '.version');
|
|
88
|
+
if (existsSync(versionFile)) {
|
|
89
|
+
try { version = readFileSync(versionFile, 'utf8').trim() || null; } catch { /* skip */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const persistentBase = join(toolsDir, '.claude-persistent');
|
|
93
|
+
const projectDir = join(persistentBase, 'projects');
|
|
94
|
+
let sessionArchivePath = null;
|
|
95
|
+
if (existsSync(projectDir)) {
|
|
96
|
+
sessionArchivePath = projectDir;
|
|
97
|
+
} else if (existsSync(persistentBase)) {
|
|
98
|
+
sessionArchivePath = persistentBase;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const capabilities = [];
|
|
102
|
+
if (existsSync(join(toolsDir, '.claude-persistent'))) capabilities.push('sessions');
|
|
103
|
+
const hasSearch = existsSync(join(toolsDir, 'search')) || existsSync(join(toolsDir, 'search.mjs'));
|
|
104
|
+
if (hasSearch) capabilities.push('search');
|
|
105
|
+
const hasContext = existsSync(join(toolsDir, 'context')) || existsSync(join(toolsDir, 'context.mjs'));
|
|
106
|
+
if (hasContext) capabilities.push('context');
|
|
107
|
+
const hasMcp = existsSync(join(toolsDir, 'mcp-server')) || existsSync(join(toolsDir, 'mcp'));
|
|
108
|
+
if (hasMcp) capabilities.push('mcp');
|
|
109
|
+
|
|
110
|
+
return { installed: true, version, sessionArchivePath, capabilities };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function scanReplit(cwd) {
|
|
114
|
+
const env = process.env;
|
|
115
|
+
const isReplit = Boolean(env.REPL_ID || env.REPL_SLUG);
|
|
116
|
+
|
|
117
|
+
let hasDeployments = false;
|
|
118
|
+
const replitConfigPath = join(cwd, '.replit');
|
|
119
|
+
if (existsSync(replitConfigPath)) {
|
|
120
|
+
try {
|
|
121
|
+
const content = readFileSync(replitConfigPath, 'utf8');
|
|
122
|
+
hasDeployments = content.includes('[deployment]');
|
|
123
|
+
} catch { /* skip */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
isReplit,
|
|
128
|
+
replId: env.REPL_ID || null,
|
|
129
|
+
replSlug: env.REPL_SLUG || null,
|
|
130
|
+
hasDatabase: Boolean(env.DATABASE_URL),
|
|
131
|
+
hasKV: Boolean(env.REPLIT_DB_URL),
|
|
132
|
+
hasObjectStorage: Boolean(env.REPLIT_BUCKET_URL || env.OBJECT_STORAGE_URL),
|
|
133
|
+
hasAuth: existsSync(join(cwd, '.replit-auth')) || Boolean(env.REPLIT_AUTH),
|
|
134
|
+
hasDeployments,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function scanClaudeCode(cwd) {
|
|
139
|
+
const claudeDir = join(cwd, '.claude');
|
|
140
|
+
const homeClaudeDir = join(homedir(), '.claude');
|
|
141
|
+
const isInsideClaude = Boolean(
|
|
142
|
+
process.env.CLAUDE_CODE || process.env.CLAUDE_AGENT || process.env.ANTHROPIC_CLAUDE_CODE
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
let hooksDir = null;
|
|
146
|
+
const localHooks = join(claudeDir, 'hooks');
|
|
147
|
+
const rootHooks = join(cwd, 'hooks');
|
|
148
|
+
if (existsSync(localHooks)) {
|
|
149
|
+
hooksDir = localHooks;
|
|
150
|
+
} else if (existsSync(rootHooks)) {
|
|
151
|
+
hooksDir = rootHooks;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let mcpConfigured = false;
|
|
155
|
+
const mcpPaths = [
|
|
156
|
+
join(claudeDir, 'mcp.json'),
|
|
157
|
+
join(claudeDir, 'mcp_servers.json'),
|
|
158
|
+
join(homeClaudeDir, 'mcp.json'),
|
|
159
|
+
];
|
|
160
|
+
for (const p of mcpPaths) {
|
|
161
|
+
if (existsSync(p)) { mcpConfigured = true; break; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let settingsPath = null;
|
|
165
|
+
const settingsCandidates = [
|
|
166
|
+
join(claudeDir, 'settings.json'),
|
|
167
|
+
join(homeClaudeDir, 'settings.json'),
|
|
168
|
+
];
|
|
169
|
+
for (const p of settingsCandidates) {
|
|
170
|
+
if (existsSync(p)) { settingsPath = p; break; }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { isInsideClaude, hooksDir, mcpConfigured, settingsPath };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function scanDualBrain(cwd) {
|
|
177
|
+
let version = '0.0.0';
|
|
178
|
+
try {
|
|
179
|
+
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
|
|
180
|
+
version = pkg.version ?? '0.0.0';
|
|
181
|
+
} catch { /* skip */ }
|
|
182
|
+
|
|
183
|
+
const livingDocsDir = join(cwd, '.dual-brain');
|
|
184
|
+
const livingDocsInit = existsSync(livingDocsDir);
|
|
185
|
+
|
|
186
|
+
let sessionCount = 0;
|
|
187
|
+
const sessionDir = join(cwd, '.dualbrain', 'sessions');
|
|
188
|
+
if (existsSync(sessionDir)) {
|
|
189
|
+
try {
|
|
190
|
+
sessionCount = readdirSync(sessionDir).filter(f => f.endsWith('.jsonl')).length;
|
|
191
|
+
} catch { /* skip */ }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const hasLedger = existsSync(join(cwd, '.dualbrain', 'ledger.jsonl'));
|
|
195
|
+
const hasFailureMemory = existsSync(join(cwd, '.dualbrain', 'failures.jsonl'));
|
|
196
|
+
|
|
197
|
+
return { version, livingDocsInit, sessionCount, hasLedger, hasFailureMemory };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function scanEnvironment(cwd, options = {}) {
|
|
201
|
+
const ttl = options.ttl ?? 300000;
|
|
202
|
+
if (_cache && Date.now() - _cacheTime < ttl && !options.force) return _cache;
|
|
203
|
+
|
|
204
|
+
const resolvedCwd = resolve(cwd || process.cwd());
|
|
205
|
+
|
|
206
|
+
const container = {
|
|
207
|
+
type: detectContainerType(),
|
|
208
|
+
hostname: process.env.HOSTNAME || process.env.REPL_ID || 'unknown',
|
|
209
|
+
nodeVersion: process.version,
|
|
210
|
+
platform: process.platform,
|
|
211
|
+
arch: process.arch,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const toolNames = ['git', 'node', 'npm', 'codex', 'claude'];
|
|
215
|
+
const flagOnlyTools = ['rg', 'replit', 'gh'];
|
|
216
|
+
const tools = {};
|
|
217
|
+
|
|
218
|
+
for (const name of toolNames) {
|
|
219
|
+
tools[name] = probeToolAvailability(name);
|
|
220
|
+
}
|
|
221
|
+
for (const name of flagOnlyTools) {
|
|
222
|
+
const path = safeExec(`which ${name}`);
|
|
223
|
+
tools[name] = { available: Boolean(path) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const secrets = scanSecrets();
|
|
227
|
+
const replitTools = scanReplitTools();
|
|
228
|
+
const replit = scanReplit(resolvedCwd);
|
|
229
|
+
const claudeCode = scanClaudeCode(resolvedCwd);
|
|
230
|
+
const dualBrain = scanDualBrain(resolvedCwd);
|
|
231
|
+
|
|
232
|
+
const report = {
|
|
233
|
+
scannedAt: Date.now(),
|
|
234
|
+
ttl,
|
|
235
|
+
container,
|
|
236
|
+
tools,
|
|
237
|
+
secrets,
|
|
238
|
+
replitTools,
|
|
239
|
+
replit,
|
|
240
|
+
claudeCode,
|
|
241
|
+
dualBrain,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
_cache = report;
|
|
245
|
+
_cacheTime = Date.now();
|
|
246
|
+
return report;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function formatEnvironment(report) {
|
|
250
|
+
const { container, tools, secrets, replitTools, replit, dualBrain } = report;
|
|
251
|
+
|
|
252
|
+
const nodeShort = container.nodeVersion.replace(/^v/, '').split('.')[0];
|
|
253
|
+
const containerLabel = container.type === 'replit'
|
|
254
|
+
? 'Replit'
|
|
255
|
+
: container.type.charAt(0).toUpperCase() + container.type.slice(1);
|
|
256
|
+
|
|
257
|
+
const lines = [];
|
|
258
|
+
|
|
259
|
+
lines.push(`Environment: ${containerLabel} (node ${nodeShort}.x)`);
|
|
260
|
+
|
|
261
|
+
const toolEntries = [];
|
|
262
|
+
for (const [name, info] of Object.entries(tools)) {
|
|
263
|
+
if (info.available) toolEntries.push(`${name} ✓`);
|
|
264
|
+
}
|
|
265
|
+
if (toolEntries.length) lines.push(`Tools: ${toolEntries.join(' ')}`);
|
|
266
|
+
|
|
267
|
+
const secretMap = {
|
|
268
|
+
OPENAI_API_KEY: 'OpenAI',
|
|
269
|
+
ANTHROPIC_API_KEY: 'Anthropic',
|
|
270
|
+
NPM_TOKEN: 'npm',
|
|
271
|
+
GITHUB_TOKEN: 'GitHub',
|
|
272
|
+
DATABASE_URL: 'PostgreSQL',
|
|
273
|
+
REPLIT_DB_URL: 'KV',
|
|
274
|
+
};
|
|
275
|
+
const secretEntries = [];
|
|
276
|
+
for (const [key, label] of Object.entries(secretMap)) {
|
|
277
|
+
if (secrets[key]) secretEntries.push(`${label} ✓`);
|
|
278
|
+
}
|
|
279
|
+
if (secretEntries.length) lines.push(`Secrets: ${secretEntries.join(' ')}`);
|
|
280
|
+
|
|
281
|
+
const platformParts = [];
|
|
282
|
+
if (replit.hasDatabase) platformParts.push('PostgreSQL ✓');
|
|
283
|
+
if (replit.hasKV) platformParts.push('KV ✓');
|
|
284
|
+
if (replit.hasObjectStorage) platformParts.push('ObjectStorage ✓');
|
|
285
|
+
if (replit.hasAuth) platformParts.push('Auth ✓');
|
|
286
|
+
if (replit.hasDeployments) platformParts.push('Deployments ✓');
|
|
287
|
+
if (platformParts.length) lines.push(`Platform: ${platformParts.join(' ')}`);
|
|
288
|
+
|
|
289
|
+
if (replitTools.installed) {
|
|
290
|
+
const ver = replitTools.version ? `v${replitTools.version}` : 'installed';
|
|
291
|
+
const caps = replitTools.capabilities.join(', ');
|
|
292
|
+
lines.push(`replit-tools: ${ver}${caps ? ` (${caps})` : ''}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const dbFlag = dualBrain.hasLedger ? ', ledger ✓' : '';
|
|
296
|
+
const docsFlag = dualBrain.livingDocsInit ? 'living docs ✓' : 'living docs ✗';
|
|
297
|
+
lines.push(`dual-brain: v${dualBrain.version} (${docsFlag}${dbFlag})`);
|
|
298
|
+
|
|
299
|
+
return lines.join('\n');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function getCapabilitySummary(report) {
|
|
303
|
+
const caps = [];
|
|
304
|
+
|
|
305
|
+
if (report.container.type !== 'unknown') {
|
|
306
|
+
caps.push(`${report.container.type}-container`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (report.replit.hasDatabase) caps.push('postgresql');
|
|
310
|
+
if (report.replit.hasKV) caps.push('replit-kv');
|
|
311
|
+
if (report.replit.hasObjectStorage) caps.push('object-storage');
|
|
312
|
+
if (report.replit.hasAuth) caps.push('replit-auth');
|
|
313
|
+
if (report.replit.hasDeployments) caps.push('replit-deployments');
|
|
314
|
+
|
|
315
|
+
if (report.tools.codex?.available) caps.push('codex-cli');
|
|
316
|
+
if (report.tools.claude?.available) caps.push('claude-cli');
|
|
317
|
+
if (report.tools.git?.available) caps.push('git');
|
|
318
|
+
if (report.tools.gh?.available) caps.push('github-cli');
|
|
319
|
+
if (report.tools.rg?.available) caps.push('ripgrep');
|
|
320
|
+
|
|
321
|
+
if (report.secrets.OPENAI_API_KEY) caps.push('openai-key');
|
|
322
|
+
if (report.secrets.ANTHROPIC_API_KEY) caps.push('anthropic-key');
|
|
323
|
+
|
|
324
|
+
if (report.replitTools.installed) {
|
|
325
|
+
for (const c of report.replitTools.capabilities) {
|
|
326
|
+
caps.push(`replit-tools-${c}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (report.claudeCode.mcpConfigured) caps.push('mcp-configured');
|
|
331
|
+
if (report.claudeCode.hooksDir) caps.push('claude-hooks');
|
|
332
|
+
|
|
333
|
+
if (report.dualBrain.hasLedger) caps.push('dual-brain-ledger');
|
|
334
|
+
if (report.dualBrain.hasFailureMemory) caps.push('dual-brain-failure-memory');
|
|
335
|
+
if (report.dualBrain.livingDocsInit) caps.push('dual-brain-living-docs');
|
|
336
|
+
|
|
337
|
+
return caps;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function invalidateCache() {
|
|
341
|
+
_cache = null;
|
|
342
|
+
_cacheTime = 0;
|
|
343
|
+
}
|
package/src/decide.mjs
CHANGED
|
@@ -21,6 +21,34 @@ import { getProviderScore, checkCooldown } from './health.mjs';
|
|
|
21
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
22
|
const WORKSPACE = join(__dirname, '..');
|
|
23
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
|
+
|
|
24
52
|
// ─── Work Styles ─────────────────────────────────────────────────────────────
|
|
25
53
|
|
|
26
54
|
/**
|
|
@@ -362,6 +390,46 @@ function pickOpenAIModel(detection, available) {
|
|
|
362
390
|
return available[0] ?? 'gpt-4o-mini';
|
|
363
391
|
}
|
|
364
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Normalize a full model ID (e.g. 'claude-sonnet-4-6') to the short name used
|
|
395
|
+
* by the internal ranking arrays (e.g. 'sonnet'). Pass-through for names already
|
|
396
|
+
* in short form or OpenAI model IDs that don't need normalization.
|
|
397
|
+
* @param {string} model
|
|
398
|
+
* @param {string} provider 'claude'|'openai'
|
|
399
|
+
* @returns {string}
|
|
400
|
+
*/
|
|
401
|
+
function toShortName(model, provider) {
|
|
402
|
+
if (!model) return model;
|
|
403
|
+
const m = model.toLowerCase();
|
|
404
|
+
if (provider === 'claude') {
|
|
405
|
+
if (m.includes('haiku')) return 'haiku';
|
|
406
|
+
if (m.includes('opus')) return 'opus';
|
|
407
|
+
if (m.includes('sonnet')) return 'sonnet';
|
|
408
|
+
}
|
|
409
|
+
// OpenAI and already-short names pass through unchanged
|
|
410
|
+
return model;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Resolve a short model name back to the best full model ID from the registry.
|
|
415
|
+
* Used after the internal pipeline (health downgrade, profile bias, etc.) finalizes
|
|
416
|
+
* the short name, to restore the full ID when the registry is available.
|
|
417
|
+
* @param {string} shortName e.g. 'sonnet', 'opus', 'haiku'
|
|
418
|
+
* @param {string} provider 'claude'|'openai'
|
|
419
|
+
* @param {string} tier 'search'|'execute'|'think'
|
|
420
|
+
* @returns {string} Full model ID, or shortName if registry unavailable
|
|
421
|
+
*/
|
|
422
|
+
function toFullModelId(shortName, provider, tier) {
|
|
423
|
+
if (!modelRegistry) return shortName;
|
|
424
|
+
const registryProvider = provider === 'claude' ? 'anthropic' : 'openai';
|
|
425
|
+
// Map short name back to a taskType for the registry lookup
|
|
426
|
+
const taskType = tier === 'search' ? 'search' : tier === 'think' ? 'think' : 'execute';
|
|
427
|
+
const candidates = modelRegistry.getModelsForTask(taskType, registryProvider);
|
|
428
|
+
// Find the registry entry whose name substring matches the short name
|
|
429
|
+
const match = candidates.find(m => m.id.toLowerCase().includes(shortName.toLowerCase()));
|
|
430
|
+
return match ? match.id : shortName;
|
|
431
|
+
}
|
|
432
|
+
|
|
365
433
|
function applyHealthDowngrade(model, score, provider, available, isHighStakes) {
|
|
366
434
|
// score=100 healthy, score=50 degraded, score=25 probing, score=0 hot
|
|
367
435
|
// If score is 0 (hot) and this isn't high-stakes, downgrade one tier
|
|
@@ -665,18 +733,53 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
|
|
|
665
733
|
// Select base model using work style worker assignments.
|
|
666
734
|
// For Claude primary: use complexWorker (opus) on deep reasoning, defaultWorker (sonnet) otherwise.
|
|
667
735
|
// For OpenAI primary: mirror the same logic using GPT equivalents.
|
|
668
|
-
|
|
669
|
-
|
|
736
|
+
//
|
|
737
|
+
// Hardcoded fallback models (used when model registry is unavailable):
|
|
738
|
+
const _fallbackClaude = (() => {
|
|
670
739
|
const wantOpus = needsDeepReasoning && workStyle.key !== 'fast';
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
}
|
|
674
|
-
|
|
740
|
+
const fb = wantOpus && available.claude.includes('opus') ? 'opus' : 'sonnet';
|
|
741
|
+
return available.claude.includes(fb) ? fb : (available.claude[available.claude.length - 1] ?? 'sonnet');
|
|
742
|
+
})();
|
|
743
|
+
const _fallbackOpenAI = (() => {
|
|
675
744
|
const wantO3 = needsDeepReasoning && workStyle.key === 'fullpower';
|
|
676
|
-
|
|
677
|
-
|
|
745
|
+
const fb = wantO3 && available.openai.includes('o3') ? 'o3' : 'gpt-4o';
|
|
746
|
+
return available.openai.includes(fb) ? fb : (available.openai[available.openai.length - 1] ?? 'gpt-4o');
|
|
747
|
+
})();
|
|
748
|
+
|
|
749
|
+
let model;
|
|
750
|
+
if (modelRegistry) {
|
|
751
|
+
// Use registry to pick best model for the tier/provider.
|
|
752
|
+
// Map decide.mjs tier to registry taskType and constraints.
|
|
753
|
+
const registryProvider = provider === 'claude' ? 'anthropic' : 'openai';
|
|
754
|
+
const taskType = tier === 'search' ? 'search'
|
|
755
|
+
: tier === 'think' ? 'think'
|
|
756
|
+
: 'execute';
|
|
757
|
+
const constraints = {
|
|
758
|
+
provider: registryProvider,
|
|
759
|
+
...(tier === 'search' && { preferSpeed: true }),
|
|
760
|
+
...(tier === 'think' && { requireReasoning: true }),
|
|
761
|
+
...(!needsDeepReasoning && workStyle.key === 'fast' && { maxCost: 'medium' }),
|
|
762
|
+
};
|
|
763
|
+
const registryResult = modelRegistry.getBestModel(taskType, constraints);
|
|
764
|
+
if (registryResult) {
|
|
765
|
+
// Registry returns full model IDs (e.g. 'claude-sonnet-4-6').
|
|
766
|
+
// dispatch.mjs mapToAgentModel handles both short names and full IDs.
|
|
767
|
+
model = registryResult.id;
|
|
768
|
+
} else {
|
|
769
|
+
// Registry found no match — use hardcoded fallback
|
|
770
|
+
model = provider === 'claude' ? _fallbackClaude : _fallbackOpenAI;
|
|
771
|
+
}
|
|
772
|
+
} else {
|
|
773
|
+
// Registry unavailable — use existing hardcoded selection
|
|
774
|
+
model = provider === 'claude' ? _fallbackClaude : _fallbackOpenAI;
|
|
678
775
|
}
|
|
679
776
|
|
|
777
|
+
// The internal pipeline (health downgrade, profile bias, safety floor) operates on
|
|
778
|
+
// short model names ('haiku', 'sonnet', 'opus', 'gpt-4o', etc.) and the available[]
|
|
779
|
+
// arrays use the same short names. Normalize a full model ID to short name first so
|
|
780
|
+
// that rank lookups work correctly, then restore the full ID at the end.
|
|
781
|
+
model = toShortName(model, provider);
|
|
782
|
+
|
|
680
783
|
// Apply health-based downgrade (only if score < 50 and not high-stakes)
|
|
681
784
|
model = applyHealthDowngrade(model, healthScores[provider], provider, available[provider], isHighStakes);
|
|
682
785
|
|
|
@@ -694,6 +797,10 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
|
|
|
694
797
|
}
|
|
695
798
|
}
|
|
696
799
|
|
|
800
|
+
// Restore full model ID from registry if the pipeline kept the same short name it started with.
|
|
801
|
+
// If the pipeline changed the model (downgrade/bias/floor), resolve the new short name to a full ID.
|
|
802
|
+
model = toFullModelId(model, provider, tier);
|
|
803
|
+
|
|
697
804
|
// ── Challenger / dual-brain decision ─────────────────────────────────────
|
|
698
805
|
const hasBothProviders = !!(
|
|
699
806
|
profile?.providers?.claude?.enabled &&
|