dual-brain 7.1.16 → 7.1.17
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 +514 -8
- package/install.mjs +37 -0
- package/package.json +1 -1
- package/src/detect.mjs +110 -6
- package/src/dispatch.mjs +78 -4
- package/src/profile.mjs +226 -0
package/bin/dual-brain.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// dual-brain — CLI entry point. Commands: init, go, status, remember, forget
|
|
3
3
|
|
|
4
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync, statSync, readdirSync, unlinkSync } from 'node:fs';
|
|
4
|
+
import { appendFileSync, existsSync, readFileSync, mkdirSync, writeFileSync, statSync, readdirSync, unlinkSync } from 'node:fs';
|
|
5
5
|
import { join, dirname } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { execSync, spawnSync as _spawnSyncTop } from 'node:child_process';
|
|
@@ -169,6 +169,14 @@ Commands:
|
|
|
169
169
|
remember "preference" Save a project-scoped preference
|
|
170
170
|
forget "preference" Remove a preference by fuzzy match
|
|
171
171
|
search "keyword" Search across all sessions
|
|
172
|
+
specialists List available specialist agents with descriptions
|
|
173
|
+
python "task" Force Python specialist for the task
|
|
174
|
+
typescript "task" Force TypeScript specialist for the task
|
|
175
|
+
html "task" Force HTML/CSS specialist for the task
|
|
176
|
+
linux "task" Force Linux/DevOps specialist for the task
|
|
177
|
+
security "task" Force Security specialist for the task
|
|
178
|
+
--dry-run (specialist commands) Show routing without executing
|
|
179
|
+
--files a,b (specialist commands) Provide file context
|
|
172
180
|
shell-hook Output bash snippet to add dual-brain to your shell
|
|
173
181
|
Usage: dual-brain shell-hook >> ~/.bashrc
|
|
174
182
|
|
|
@@ -611,6 +619,53 @@ function cmdForget(text) {
|
|
|
611
619
|
console.log('Preference removed (if matched).');
|
|
612
620
|
}
|
|
613
621
|
|
|
622
|
+
function cmdBreakGlass(reason) {
|
|
623
|
+
if (!reason) err('Usage: dual-brain break-glass "reason"');
|
|
624
|
+
const cwd = process.cwd();
|
|
625
|
+
const dualbrain = join(cwd, '.dualbrain');
|
|
626
|
+
const tokenPath = join(dualbrain, 'break-glass.json');
|
|
627
|
+
const auditDir = join(dualbrain, 'audit');
|
|
628
|
+
const auditFile = join(auditDir, 'head-audit.jsonl');
|
|
629
|
+
const TTL_MINUTES = 5;
|
|
630
|
+
|
|
631
|
+
mkdirSync(dualbrain, { recursive: true });
|
|
632
|
+
mkdirSync(auditDir, { recursive: true });
|
|
633
|
+
|
|
634
|
+
const token = {
|
|
635
|
+
createdAt: Date.now(),
|
|
636
|
+
ttlMinutes: TTL_MINUTES,
|
|
637
|
+
reason,
|
|
638
|
+
};
|
|
639
|
+
writeFileSync(tokenPath, JSON.stringify(token, null, 2));
|
|
640
|
+
|
|
641
|
+
// Audit entry
|
|
642
|
+
const auditEntry = {
|
|
643
|
+
ts: new Date().toISOString(),
|
|
644
|
+
event: 'break-glass-activated',
|
|
645
|
+
reason,
|
|
646
|
+
ttlMinutes: TTL_MINUTES,
|
|
647
|
+
expiresAt: new Date(token.createdAt + TTL_MINUTES * 60 * 1000).toISOString(),
|
|
648
|
+
};
|
|
649
|
+
try {
|
|
650
|
+
appendFileSync(auditFile, JSON.stringify(auditEntry) + '\n');
|
|
651
|
+
} catch { /* non-fatal */ }
|
|
652
|
+
|
|
653
|
+
const width = 51;
|
|
654
|
+
const inner = width - 2;
|
|
655
|
+
const pad = (s) => ' ' + s + ' '.repeat(inner - 1 - s.length);
|
|
656
|
+
const reasonLine = `Reason: ${reason}`;
|
|
657
|
+
const expiresLine = `Expires: ${TTL_MINUTES} minutes`;
|
|
658
|
+
const auditLine = 'All tool calls logged to audit.';
|
|
659
|
+
|
|
660
|
+
console.log('┌' + '─'.repeat(inner) + '┐');
|
|
661
|
+
console.log('│' + pad('🔓 Break-Glass Activated') + '│');
|
|
662
|
+
console.log('├' + '─'.repeat(inner) + '┤');
|
|
663
|
+
console.log('│' + pad(reasonLine) + '│');
|
|
664
|
+
console.log('│' + pad(expiresLine) + '│');
|
|
665
|
+
console.log('│' + pad(auditLine) + '│');
|
|
666
|
+
console.log('└' + '─'.repeat(inner) + '┘');
|
|
667
|
+
}
|
|
668
|
+
|
|
614
669
|
// ─── Screen helpers ───────────────────────────────────────────────────────────
|
|
615
670
|
|
|
616
671
|
/**
|
|
@@ -1582,6 +1637,282 @@ async function subscriptionsScreen(rl, ask) {
|
|
|
1582
1637
|
return { next: 'main' };
|
|
1583
1638
|
}
|
|
1584
1639
|
|
|
1640
|
+
// ─── Onboarding Wizard ───────────────────────────────────────────────────────
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* 5-step onboarding wizard shown on first run (no .dualbrain/profile.json).
|
|
1644
|
+
* Matches the rounded ┌─┐ box style used in mainScreen / renderHeader.
|
|
1645
|
+
* @param {{ auth, plans, existingSessions }} detection
|
|
1646
|
+
* @param {string} cwd
|
|
1647
|
+
* @param {object} rl readline interface
|
|
1648
|
+
* @returns {object|null} profile object to save, or null if cancelled/skipped
|
|
1649
|
+
*/
|
|
1650
|
+
async function runOnboardingWizard(detection, cwd, rl) {
|
|
1651
|
+
const ask = (q) => new Promise(res => rl.question(q, res));
|
|
1652
|
+
const version = readVersion();
|
|
1653
|
+
|
|
1654
|
+
// ── Rounded box helpers (matching mainScreen style) ────────────────────────
|
|
1655
|
+
const W = 51;
|
|
1656
|
+
const wTop = ` ┌${'─'.repeat(W)}┐`;
|
|
1657
|
+
const wSep = ` ├${'─'.repeat(W)}┤`;
|
|
1658
|
+
const wBottom = ` └${'─'.repeat(W)}┘`;
|
|
1659
|
+
const wPad = (s) => {
|
|
1660
|
+
const plain = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
1661
|
+
let vlen = 0;
|
|
1662
|
+
for (const ch of plain) {
|
|
1663
|
+
const cp = ch.codePointAt(0);
|
|
1664
|
+
if (
|
|
1665
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
1666
|
+
(cp >= 0x2600 && cp <= 0x27bf) ||
|
|
1667
|
+
cp === 0xfe0f || cp === 0x20e3
|
|
1668
|
+
) { vlen += 2; } else { vlen += 1; }
|
|
1669
|
+
}
|
|
1670
|
+
return s + ' '.repeat(Math.max(0, W - vlen));
|
|
1671
|
+
};
|
|
1672
|
+
const wRow = (s) => ` │ ${wPad(s)}│`;
|
|
1673
|
+
|
|
1674
|
+
// ── Collected wizard state ─────────────────────────────────────────────────
|
|
1675
|
+
const state = {
|
|
1676
|
+
claudePlan: null,
|
|
1677
|
+
openaiPlan: null,
|
|
1678
|
+
headModel: null,
|
|
1679
|
+
importSessions: false,
|
|
1680
|
+
profile: 'auto',
|
|
1681
|
+
};
|
|
1682
|
+
|
|
1683
|
+
const { auth, plans, existingSessions } = detection;
|
|
1684
|
+
const claudeReady = auth.claude.found;
|
|
1685
|
+
const openaiReady = auth.openai.found;
|
|
1686
|
+
|
|
1687
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1688
|
+
// Step 1 — Welcome & provider detection
|
|
1689
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1690
|
+
console.log('');
|
|
1691
|
+
console.log(wTop);
|
|
1692
|
+
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1693
|
+
console.log(wSep);
|
|
1694
|
+
console.log(wRow(`Step 1 of 5: Detected providers`));
|
|
1695
|
+
console.log(wSep);
|
|
1696
|
+
|
|
1697
|
+
const claudePlanLabel = claudeReady
|
|
1698
|
+
? (CLAUDE_PLAN_LABELS[plans.claude] ?? plans.claude ?? 'plan unknown')
|
|
1699
|
+
: null;
|
|
1700
|
+
const openaiPlanLabel = openaiReady
|
|
1701
|
+
? (OPENAI_PLAN_LABELS[plans.openai] ?? plans.openai ?? 'plan unknown')
|
|
1702
|
+
: null;
|
|
1703
|
+
|
|
1704
|
+
console.log(wRow(claudeReady
|
|
1705
|
+
? `✓ Claude CLI ${claudePlanLabel ? `(${claudePlanLabel})` : ''}`
|
|
1706
|
+
: `✗ Claude CLI not logged in`));
|
|
1707
|
+
console.log(wRow(openaiReady
|
|
1708
|
+
? `✓ Codex CLI ${openaiPlanLabel ? `(${openaiPlanLabel})` : ''}`
|
|
1709
|
+
: `✗ Codex CLI not logged in`));
|
|
1710
|
+
if (existingSessions.length > 0) {
|
|
1711
|
+
console.log(wRow(`✓ ${existingSessions.length} data-tools session${existingSessions.length !== 1 ? 's' : ''} found`));
|
|
1712
|
+
}
|
|
1713
|
+
console.log(wSep);
|
|
1714
|
+
console.log(wRow(`[Enter] Continue setup [s] Skip wizard`));
|
|
1715
|
+
console.log(wBottom);
|
|
1716
|
+
console.log('');
|
|
1717
|
+
|
|
1718
|
+
if (!claudeReady && !openaiReady) {
|
|
1719
|
+
console.log(' No AI provider found. Log in first:');
|
|
1720
|
+
console.log(' claude auth login — for Claude');
|
|
1721
|
+
console.log(' codex login — for OpenAI/Codex');
|
|
1722
|
+
console.log(' Then re-run: dual-brain init\n');
|
|
1723
|
+
return null;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
const step1 = (await ask(' > ')).trim().toLowerCase();
|
|
1727
|
+
if (step1 === 's') {
|
|
1728
|
+
// Skip: auto-save detected plans and proceed directly
|
|
1729
|
+
const skippedProfile = loadProfile(cwd);
|
|
1730
|
+
if (claudeReady) skippedProfile.providers.claude = { enabled: true, plan: plans.claude || 'pro' };
|
|
1731
|
+
if (openaiReady) skippedProfile.providers.openai = { enabled: true, plan: plans.openai || 'plus' };
|
|
1732
|
+
const enabledCount = [claudeReady, openaiReady].filter(Boolean).length;
|
|
1733
|
+
skippedProfile.mode = enabledCount >= 2 ? 'auto' : claudeReady ? 'solo-claude' : 'solo-openai';
|
|
1734
|
+
return skippedProfile;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1738
|
+
// Step 2 — Budget / plan selection
|
|
1739
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1740
|
+
console.log('');
|
|
1741
|
+
console.log(wTop);
|
|
1742
|
+
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1743
|
+
console.log(wSep);
|
|
1744
|
+
console.log(wRow(`Step 2 of 5: Subscription plans`));
|
|
1745
|
+
console.log(wSep);
|
|
1746
|
+
|
|
1747
|
+
if (claudeReady) {
|
|
1748
|
+
const detectedClaudePlan = plans.claude || 'pro';
|
|
1749
|
+
const detectedClaudeLabel = CLAUDE_PLAN_LABELS[detectedClaudePlan] ?? detectedClaudePlan;
|
|
1750
|
+
console.log(wRow(`Claude — detected: ${detectedClaudeLabel}`));
|
|
1751
|
+
console.log(wRow(` [1] Pro ($20/mo)`));
|
|
1752
|
+
console.log(wRow(` [2] Max x5 ($100/mo)`));
|
|
1753
|
+
console.log(wRow(` [3] Max x20 ($200/mo)`));
|
|
1754
|
+
console.log(wRow(` [Enter] Keep detected (${detectedClaudeLabel})`));
|
|
1755
|
+
console.log(wSep);
|
|
1756
|
+
const claudeChoice = (await ask(' Claude plan [1/2/3/Enter]: ')).trim();
|
|
1757
|
+
const claudePlanMap = { '1': 'pro', '2': 'max5', '3': 'max20' };
|
|
1758
|
+
state.claudePlan = claudePlanMap[claudeChoice] || detectedClaudePlan;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
if (openaiReady) {
|
|
1762
|
+
const detectedOpenaiPlan = plans.openai || 'plus';
|
|
1763
|
+
const detectedOpenaiLabel = OPENAI_PLAN_LABELS[detectedOpenaiPlan] ?? detectedOpenaiPlan;
|
|
1764
|
+
console.log(wRow(`OpenAI — detected: ${detectedOpenaiLabel}`));
|
|
1765
|
+
console.log(wRow(` [1] Plus ($20/mo)`));
|
|
1766
|
+
console.log(wRow(` [2] Pro ($100/mo)`));
|
|
1767
|
+
console.log(wRow(` [3] Pro ($200/mo higher limits)`));
|
|
1768
|
+
console.log(wRow(` [Enter] Keep detected (${detectedOpenaiLabel})`));
|
|
1769
|
+
console.log(wSep);
|
|
1770
|
+
const openaiChoice = (await ask(' OpenAI plan [1/2/3/Enter]: ')).trim();
|
|
1771
|
+
const openaiPlanMap = { '1': 'plus', '2': 'pro', '3': 'pro200' };
|
|
1772
|
+
state.openaiPlan = openaiPlanMap[openaiChoice] || detectedOpenaiPlan;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
console.log(wBottom);
|
|
1776
|
+
|
|
1777
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1778
|
+
// Step 3 — HEAD model selection
|
|
1779
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1780
|
+
const hasBigPlan = state.claudePlan === 'max5' || state.claudePlan === 'max20';
|
|
1781
|
+
const recommendedModel = hasBigPlan ? 'claude-opus-4-5' : 'claude-sonnet-4-5';
|
|
1782
|
+
const recommendedLabel = hasBigPlan
|
|
1783
|
+
? 'Opus (Max plan — best quality)'
|
|
1784
|
+
: 'Sonnet (Pro plan — balanced speed/quality)';
|
|
1785
|
+
|
|
1786
|
+
console.log('');
|
|
1787
|
+
console.log(wTop);
|
|
1788
|
+
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1789
|
+
console.log(wSep);
|
|
1790
|
+
console.log(wRow(`Step 3 of 5: HEAD model (think-tier)`));
|
|
1791
|
+
console.log(wSep);
|
|
1792
|
+
console.log(wRow(`Recommended: ${recommendedLabel}`));
|
|
1793
|
+
console.log(wSep);
|
|
1794
|
+
console.log(wRow(` [1] Haiku — fastest, lowest cost`));
|
|
1795
|
+
console.log(wRow(` [2] Sonnet — balanced (recommended for Pro)`));
|
|
1796
|
+
console.log(wRow(` [3] Opus — best quality (recommended for Max)`));
|
|
1797
|
+
console.log(wRow(` [Enter] Use recommended`));
|
|
1798
|
+
console.log(wBottom);
|
|
1799
|
+
console.log('');
|
|
1800
|
+
|
|
1801
|
+
const step3 = (await ask(' HEAD model [1/2/3/Enter]: ')).trim();
|
|
1802
|
+
const modelMap = {
|
|
1803
|
+
'1': 'claude-haiku-4-5',
|
|
1804
|
+
'2': 'claude-sonnet-4-5',
|
|
1805
|
+
'3': 'claude-opus-4-5',
|
|
1806
|
+
};
|
|
1807
|
+
state.headModel = modelMap[step3] || recommendedModel;
|
|
1808
|
+
|
|
1809
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1810
|
+
// Step 4 — Import sessions + profile selection
|
|
1811
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1812
|
+
console.log('');
|
|
1813
|
+
console.log(wTop);
|
|
1814
|
+
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1815
|
+
console.log(wSep);
|
|
1816
|
+
console.log(wRow(`Step 4 of 5: Sessions & routing profile`));
|
|
1817
|
+
console.log(wSep);
|
|
1818
|
+
|
|
1819
|
+
if (existingSessions.length > 0) {
|
|
1820
|
+
console.log(wRow(`Import ${existingSessions.length} data-tools session${existingSessions.length !== 1 ? 's' : ''}?`));
|
|
1821
|
+
console.log(wRow(` [y] Yes [Enter/n] Skip`));
|
|
1822
|
+
console.log(wSep);
|
|
1823
|
+
const importChoice = (await ask(' Import sessions [y/Enter]: ')).trim().toLowerCase();
|
|
1824
|
+
state.importSessions = importChoice === 'y';
|
|
1825
|
+
if (state.importSessions) {
|
|
1826
|
+
console.log('');
|
|
1827
|
+
console.log(` Importing ${existingSessions.length} sessions...`);
|
|
1828
|
+
const recent = existingSessions.slice(0, 5);
|
|
1829
|
+
for (const sess of recent) {
|
|
1830
|
+
console.log(` ${sess.age.padEnd(6)} ${sess.name}`);
|
|
1831
|
+
}
|
|
1832
|
+
if (existingSessions.length > 5) {
|
|
1833
|
+
console.log(` ... and ${existingSessions.length - 5} more`);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
console.log(wSep);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
console.log(wRow(`Routing profile:`));
|
|
1840
|
+
console.log(wRow(` [1] auto — adapts based on task risk & outcomes`));
|
|
1841
|
+
console.log(wRow(` [2] balanced — best model per tier, normal budgets`));
|
|
1842
|
+
console.log(wRow(` [3] cost-saver — prefer cheaper models, skip GPT`));
|
|
1843
|
+
console.log(wRow(` [4] quality-first — dual-brain for medium+ risk`));
|
|
1844
|
+
console.log(wRow(` [Enter] auto (recommended)`));
|
|
1845
|
+
console.log(wBottom);
|
|
1846
|
+
console.log('');
|
|
1847
|
+
|
|
1848
|
+
const step4 = (await ask(' Profile [1/2/3/4/Enter]: ')).trim();
|
|
1849
|
+
const profileMap = { '1': 'auto', '2': 'balanced', '3': 'cost-saver', '4': 'quality-first' };
|
|
1850
|
+
state.profile = profileMap[step4] || 'auto';
|
|
1851
|
+
|
|
1852
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1853
|
+
// Step 5 — Summary & confirm
|
|
1854
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1855
|
+
const claudeSummary = state.claudePlan
|
|
1856
|
+
? `Claude: ${CLAUDE_PLAN_LABELS[state.claudePlan] ?? state.claudePlan}`
|
|
1857
|
+
: `Claude: not configured`;
|
|
1858
|
+
const openaiSummary = state.openaiPlan
|
|
1859
|
+
? `OpenAI: ${OPENAI_PLAN_LABELS[state.openaiPlan] ?? state.openaiPlan}`
|
|
1860
|
+
: `OpenAI: not configured`;
|
|
1861
|
+
const modelSummary = `HEAD model: ${state.headModel}`;
|
|
1862
|
+
const profileSummary = `Profile: ${state.profile}`;
|
|
1863
|
+
const sessionSummary = existingSessions.length > 0
|
|
1864
|
+
? `Sessions: ${state.importSessions ? `${existingSessions.length} imported` : 'skipped'}`
|
|
1865
|
+
: null;
|
|
1866
|
+
|
|
1867
|
+
console.log('');
|
|
1868
|
+
console.log(wTop);
|
|
1869
|
+
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1870
|
+
console.log(wSep);
|
|
1871
|
+
console.log(wRow(`Step 5 of 5: Summary`));
|
|
1872
|
+
console.log(wSep);
|
|
1873
|
+
console.log(wRow(claudeSummary));
|
|
1874
|
+
console.log(wRow(openaiSummary));
|
|
1875
|
+
console.log(wRow(modelSummary));
|
|
1876
|
+
console.log(wRow(profileSummary));
|
|
1877
|
+
if (sessionSummary) console.log(wRow(sessionSummary));
|
|
1878
|
+
console.log(wSep);
|
|
1879
|
+
console.log(wRow(`[Enter] Save and start [q] Quit without saving`));
|
|
1880
|
+
console.log(wBottom);
|
|
1881
|
+
console.log('');
|
|
1882
|
+
|
|
1883
|
+
const step5 = (await ask(' > ')).trim().toLowerCase();
|
|
1884
|
+
if (step5 === 'q') {
|
|
1885
|
+
console.log('\n Setup cancelled.\n');
|
|
1886
|
+
return null;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// ── Build and return the profile object ────────────────────────────────────
|
|
1890
|
+
const finalProfile = loadProfile(cwd);
|
|
1891
|
+
|
|
1892
|
+
if (state.claudePlan) {
|
|
1893
|
+
finalProfile.providers.claude = { enabled: true, plan: state.claudePlan };
|
|
1894
|
+
} else if (claudeReady) {
|
|
1895
|
+
finalProfile.providers.claude = { enabled: true, plan: plans.claude || 'pro' };
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
if (state.openaiPlan) {
|
|
1899
|
+
finalProfile.providers.openai = { enabled: true, plan: state.openaiPlan };
|
|
1900
|
+
} else if (openaiReady) {
|
|
1901
|
+
finalProfile.providers.openai = { enabled: true, plan: plans.openai || 'plus' };
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
const enabledCount = [
|
|
1905
|
+
finalProfile.providers?.claude?.enabled,
|
|
1906
|
+
finalProfile.providers?.openai?.enabled,
|
|
1907
|
+
].filter(Boolean).length;
|
|
1908
|
+
|
|
1909
|
+
finalProfile.mode = enabledCount >= 2 ? state.profile : claudeReady ? 'solo-claude' : 'solo-openai';
|
|
1910
|
+
finalProfile.headModel = state.headModel;
|
|
1911
|
+
finalProfile.bias = state.profile;
|
|
1912
|
+
|
|
1913
|
+
return finalProfile;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1585
1916
|
// ─── Screen: dashboardScreen (kept for internal reference, unreachable) ───────
|
|
1586
1917
|
|
|
1587
1918
|
async function dashboardScreen(rl, ask) {
|
|
@@ -2223,6 +2554,151 @@ async function runScreens(startScreen = 'dashboard') {
|
|
|
2223
2554
|
rl.close();
|
|
2224
2555
|
}
|
|
2225
2556
|
|
|
2557
|
+
// ─── Specialist commands ──────────────────────────────────────────────────────
|
|
2558
|
+
|
|
2559
|
+
const SPECIALIST_DEFAULTS = {
|
|
2560
|
+
python: { name: 'Python', description: 'Python stdlib, typing, async' },
|
|
2561
|
+
typescript: { name: 'TypeScript', description: 'TS type system, React, Node' },
|
|
2562
|
+
html: { name: 'HTML/CSS', description: 'Semantic HTML, CSS, accessibility' },
|
|
2563
|
+
linux: { name: 'Linux/DevOps', description: 'Sysadmin, Docker, nginx, shell' },
|
|
2564
|
+
security: { name: 'Security', description: 'Auth, crypto, OWASP, threat model' },
|
|
2565
|
+
};
|
|
2566
|
+
|
|
2567
|
+
function loadSpecialistRegistry() {
|
|
2568
|
+
const regPath = join(__dirname, '..', 'agents', 'specialists', 'registry.json');
|
|
2569
|
+
try {
|
|
2570
|
+
const raw = JSON.parse(readFileSync(regPath, 'utf8'));
|
|
2571
|
+
const out = {};
|
|
2572
|
+
for (const [key, val] of Object.entries(raw.specialists || {})) {
|
|
2573
|
+
out[key] = { name: val.name || key, description: val.description || '' };
|
|
2574
|
+
}
|
|
2575
|
+
return out;
|
|
2576
|
+
} catch {
|
|
2577
|
+
return SPECIALIST_DEFAULTS;
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
function cmdSpecialists() {
|
|
2582
|
+
const registry = loadSpecialistRegistry();
|
|
2583
|
+
const entries = Object.entries(registry);
|
|
2584
|
+
|
|
2585
|
+
// Build padded rows
|
|
2586
|
+
const rows = entries.map(([key, val]) => {
|
|
2587
|
+
const k = key.padEnd(12);
|
|
2588
|
+
const d = val.description;
|
|
2589
|
+
return `│ ${k}${d}`;
|
|
2590
|
+
});
|
|
2591
|
+
|
|
2592
|
+
// Find longest row for width
|
|
2593
|
+
const inner = Math.max(
|
|
2594
|
+
...rows.map(r => r.length),
|
|
2595
|
+
'│ Usage: dual-brain python "task description" │'.length - 2,
|
|
2596
|
+
);
|
|
2597
|
+
const width = inner + 1; // account for trailing │
|
|
2598
|
+
|
|
2599
|
+
function pad(str) {
|
|
2600
|
+
return str + ' '.repeat(Math.max(0, width - str.length - 1)) + '│';
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
const top = '┌' + '─'.repeat(width - 1) + '┐';
|
|
2604
|
+
const title = pad('│ 🎯 Available Specialists');
|
|
2605
|
+
const divTop = '├' + '─'.repeat(width - 1) + '┤';
|
|
2606
|
+
const divBot = '├' + '─'.repeat(width - 1) + '┤';
|
|
2607
|
+
const bot = '└' + '─'.repeat(width - 1) + '┘';
|
|
2608
|
+
|
|
2609
|
+
console.log(top);
|
|
2610
|
+
console.log(title);
|
|
2611
|
+
console.log(divTop);
|
|
2612
|
+
for (const row of rows) console.log(pad(row));
|
|
2613
|
+
console.log(divBot);
|
|
2614
|
+
console.log(pad('│ Usage: dual-brain python "task description"'));
|
|
2615
|
+
console.log(pad('│ Auto-routing: off (use dual-brain go for auto)'));
|
|
2616
|
+
console.log(bot);
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
async function cmdSpecialistGo(specialist, args) {
|
|
2620
|
+
const dryRun = args.includes('--dry-run');
|
|
2621
|
+
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
2622
|
+
const filesRaw = flag(args, '--files');
|
|
2623
|
+
const files = filesRaw && typeof filesRaw === 'string'
|
|
2624
|
+
? filesRaw.split(',').map(f => f.trim()).filter(Boolean)
|
|
2625
|
+
: [];
|
|
2626
|
+
|
|
2627
|
+
const prompt = args.find(a => !a.startsWith('--') && !a.startsWith('-') && a !== (filesRaw ?? ''));
|
|
2628
|
+
if (!prompt) err(`Usage: dual-brain ${specialist} "task description" [--dry-run] [--files a,b]`);
|
|
2629
|
+
|
|
2630
|
+
const cwd = process.cwd();
|
|
2631
|
+
const profile = await ensureProfile(cwd);
|
|
2632
|
+
const detection = detectTask({ prompt, files });
|
|
2633
|
+
|
|
2634
|
+
// Override specialist, preserve everything else
|
|
2635
|
+
detection.specialist = specialist;
|
|
2636
|
+
|
|
2637
|
+
console.log(`[specialist: ${specialist}] ${detection.explanation}`);
|
|
2638
|
+
|
|
2639
|
+
if (verbose) {
|
|
2640
|
+
vtrace(`Intent: ${detection.intent} | Risk: ${detection.risk} | Complexity: ${detection.complexity} | Effort: ${detection.effort ?? 'n/a'}`);
|
|
2641
|
+
vtrace(`Tier: ${detection.tier} | Specialist override: ${specialist}`);
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
const decision = decideRoute({ profile, detection, cwd });
|
|
2645
|
+
|
|
2646
|
+
if (verbose) {
|
|
2647
|
+
const modelLabel = decision.effort ? `${decision.model} (${decision.effort})` : decision.model;
|
|
2648
|
+
vtrace(`Model selection: ${modelLabel}`);
|
|
2649
|
+
vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'}`);
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
console.log(` specialist : ${specialist}`);
|
|
2653
|
+
console.log(` provider : ${decision.provider}`);
|
|
2654
|
+
console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
|
|
2655
|
+
console.log(` tier : ${decision.tier}`);
|
|
2656
|
+
console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
|
|
2657
|
+
console.log(` reason : ${decision.explanation}`);
|
|
2658
|
+
|
|
2659
|
+
if (dryRun) {
|
|
2660
|
+
console.log('\n(dry-run — not executing)');
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
console.log('\nDispatching...');
|
|
2665
|
+
let result;
|
|
2666
|
+
if (decision.dualBrain) {
|
|
2667
|
+
result = await dispatchDualBrain({ decision, prompt, files, cwd });
|
|
2668
|
+
console.log(`\nConsensus: ${result.consensus}`);
|
|
2669
|
+
if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
|
|
2670
|
+
if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
|
|
2671
|
+
saveSession({
|
|
2672
|
+
objective: prompt,
|
|
2673
|
+
branch: null,
|
|
2674
|
+
filesChanged: files,
|
|
2675
|
+
commandsRun: [`dual-brain ${specialist} "${prompt}"`],
|
|
2676
|
+
lastResult: { status: 'success', summary: result.consensus || 'dual-brain complete' },
|
|
2677
|
+
provider: decision.provider,
|
|
2678
|
+
nextAction: null,
|
|
2679
|
+
}, cwd);
|
|
2680
|
+
} else {
|
|
2681
|
+
result = await dispatch({ decision, prompt, files, cwd });
|
|
2682
|
+
const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
|
|
2683
|
+
console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
2684
|
+
if (result.summary) console.log(result.summary);
|
|
2685
|
+
if (result.error) process.stderr.write(`${result.error}\n`);
|
|
2686
|
+
saveSession({
|
|
2687
|
+
objective: prompt,
|
|
2688
|
+
branch: null,
|
|
2689
|
+
filesChanged: files,
|
|
2690
|
+
commandsRun: [`dual-brain ${specialist} "${prompt}"`],
|
|
2691
|
+
lastResult: {
|
|
2692
|
+
status: result.status === 'completed' ? 'success' : 'failure',
|
|
2693
|
+
summary: result.summary || (result.status === 'completed' ? 'completed' : `exit ${result.exitCode}`),
|
|
2694
|
+
},
|
|
2695
|
+
provider: decision.provider,
|
|
2696
|
+
nextAction: null,
|
|
2697
|
+
}, cwd);
|
|
2698
|
+
if (result.status !== 'completed') process.exit(1);
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2226
2702
|
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
2227
2703
|
|
|
2228
2704
|
async function main() {
|
|
@@ -2246,9 +2722,20 @@ async function main() {
|
|
|
2246
2722
|
if (profileExists(cwd)) {
|
|
2247
2723
|
await runScreens('main');
|
|
2248
2724
|
} else {
|
|
2249
|
-
// First run:
|
|
2250
|
-
|
|
2251
|
-
await
|
|
2725
|
+
// First run: run the 5-step onboarding wizard, then go to main.
|
|
2726
|
+
process.stdout.write(`\ndual-brain v${readVersion()} — Setup\n\nDetecting your setup...\n`);
|
|
2727
|
+
const auth = await detectAuth();
|
|
2728
|
+
const plans = detectPlans();
|
|
2729
|
+
const existingSessions = importReplitSessions(cwd);
|
|
2730
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2731
|
+
const wizardProfile = await runOnboardingWizard({ auth, plans, existingSessions }, cwd, rl);
|
|
2732
|
+
if (wizardProfile) {
|
|
2733
|
+
saveProfile(wizardProfile, { cwd });
|
|
2734
|
+
await cmdInstall(cwd);
|
|
2735
|
+
console.log('\n ✅ Setup complete! Starting dual-brain...\n');
|
|
2736
|
+
}
|
|
2737
|
+
rl.close();
|
|
2738
|
+
await runScreens('main');
|
|
2252
2739
|
}
|
|
2253
2740
|
} else {
|
|
2254
2741
|
// Non-TTY: print status card and exit
|
|
@@ -2264,8 +2751,21 @@ async function main() {
|
|
|
2264
2751
|
|
|
2265
2752
|
if (cmd === 'init') {
|
|
2266
2753
|
if (isInteractive) {
|
|
2267
|
-
// Run
|
|
2268
|
-
|
|
2754
|
+
// Run 5-step onboarding wizard then main screen
|
|
2755
|
+
const cwd = process.cwd();
|
|
2756
|
+
process.stdout.write(`\ndual-brain v${readVersion()} — Setup\n\nDetecting your setup...\n`);
|
|
2757
|
+
const auth = await detectAuth();
|
|
2758
|
+
const plans = detectPlans();
|
|
2759
|
+
const existingSessions = importReplitSessions(cwd);
|
|
2760
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2761
|
+
const wizardProfile = await runOnboardingWizard({ auth, plans, existingSessions }, cwd, rl);
|
|
2762
|
+
if (wizardProfile) {
|
|
2763
|
+
saveProfile(wizardProfile, { cwd });
|
|
2764
|
+
await cmdInstall(cwd);
|
|
2765
|
+
console.log('\n ✅ Setup complete! Starting dual-brain...\n');
|
|
2766
|
+
}
|
|
2767
|
+
rl.close();
|
|
2768
|
+
await runScreens('main');
|
|
2269
2769
|
} else {
|
|
2270
2770
|
await cmdInit();
|
|
2271
2771
|
}
|
|
@@ -2282,8 +2782,14 @@ async function main() {
|
|
|
2282
2782
|
if (cmd === 'status') { await cmdStatus(args.slice(1)); return; }
|
|
2283
2783
|
if (cmd === 'hot') { cmdHot(args[1]); return; }
|
|
2284
2784
|
if (cmd === 'cool') { cmdCool(args[1]); return; }
|
|
2285
|
-
if (cmd === 'remember')
|
|
2286
|
-
if (cmd === 'forget')
|
|
2785
|
+
if (cmd === 'remember') { cmdRemember(args[1]); return; }
|
|
2786
|
+
if (cmd === 'forget') { cmdForget(args[1]); return; }
|
|
2787
|
+
if (cmd === 'break-glass') { cmdBreakGlass(args.slice(1).join(' ')); return; }
|
|
2788
|
+
|
|
2789
|
+
if (cmd === 'specialists') { cmdSpecialists(); return; }
|
|
2790
|
+
|
|
2791
|
+
const SPECIALIST_CMDS = new Set(Object.keys(loadSpecialistRegistry()));
|
|
2792
|
+
if (SPECIALIST_CMDS.has(cmd)) { await cmdSpecialistGo(cmd, args.slice(1)); return; }
|
|
2287
2793
|
|
|
2288
2794
|
if (cmd === 'search') {
|
|
2289
2795
|
const query = args.slice(1).filter(a => !a.startsWith('--')).join(' ');
|
package/install.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { createInterface } from 'readline';
|
|
|
14
14
|
import { dirname, join, resolve } from 'path';
|
|
15
15
|
import { fileURLToPath } from 'url';
|
|
16
16
|
import { spawnSync } from 'child_process';
|
|
17
|
+
import { createHash } from 'crypto';
|
|
17
18
|
|
|
18
19
|
// Skip hook installation during global npm install — hooks are installed
|
|
19
20
|
// when the user runs 'dual-brain install' in their project directory.
|
|
@@ -863,6 +864,39 @@ function generateGitignoreEntries(workspace) {
|
|
|
863
864
|
return { existing, needed };
|
|
864
865
|
}
|
|
865
866
|
|
|
867
|
+
// ─── Hook Manifest ──────────────────────────────────────────────────────────
|
|
868
|
+
|
|
869
|
+
function hashString(s) {
|
|
870
|
+
return createHash('sha256').update(s).digest('hex');
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function generateHookManifest(settings) {
|
|
874
|
+
const hooks = settings.hooks || {};
|
|
875
|
+
const preHooks = (hooks.PreToolUse || []).flatMap(entry =>
|
|
876
|
+
(entry.hooks || []).map(h => hashString(h.command || ''))
|
|
877
|
+
);
|
|
878
|
+
const postHooks = (hooks.PostToolUse || []).flatMap(entry =>
|
|
879
|
+
(entry.hooks || []).map(h => hashString(h.command || ''))
|
|
880
|
+
);
|
|
881
|
+
const settingsHash = hashString(JSON.stringify(hooks));
|
|
882
|
+
return {
|
|
883
|
+
generatedAt: new Date().toISOString(),
|
|
884
|
+
expectedHooks: {
|
|
885
|
+
PreToolUse: preHooks,
|
|
886
|
+
PostToolUse: postHooks,
|
|
887
|
+
},
|
|
888
|
+
settingsHash,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function writeHookManifest(workspace, settings) {
|
|
893
|
+
const dualbrain = join(workspace, '.dualbrain');
|
|
894
|
+
mkdirSync(dualbrain, { recursive: true });
|
|
895
|
+
const manifest = generateHookManifest(settings);
|
|
896
|
+
writeFileSync(join(dualbrain, 'hook-manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
897
|
+
return manifest;
|
|
898
|
+
}
|
|
899
|
+
|
|
866
900
|
// ─── Installation ───────────────────────────────────────────────────────────
|
|
867
901
|
|
|
868
902
|
function install(workspace, env, mode) {
|
|
@@ -915,6 +949,9 @@ function install(workspace, env, mode) {
|
|
|
915
949
|
writeFileSync(join(target, 'settings.json'), JSON.stringify(settings, null, 2) + '\n');
|
|
916
950
|
actions.push('✓ settings.json (hooks registered)');
|
|
917
951
|
|
|
952
|
+
writeHookManifest(workspace, settings);
|
|
953
|
+
actions.push('✓ .dualbrain/hook-manifest.json (integrity manifest)');
|
|
954
|
+
|
|
918
955
|
const claudeMd = generateClaudeMd(mode);
|
|
919
956
|
writeClaudeMd(join(target, 'CLAUDE.md'), claudeMd);
|
|
920
957
|
actions.push('✓ CLAUDE.md (session instructions)');
|
package/package.json
CHANGED
package/src/detect.mjs
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// detect.mjs — Task detection for dual-brain. Self-contained, no internal imports.
|
|
3
|
-
// Exports: detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths
|
|
3
|
+
// Exports: detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths, classifySpecialist
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'fs';
|
|
6
|
+
import { resolve, dirname } from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
4
10
|
|
|
5
11
|
// ─── Intent definitions ────────────────────────────────────────────────────────
|
|
6
12
|
|
|
@@ -118,10 +124,13 @@ function estimateComplexity({ prompt, fileCount = 0, risk = 'low', intent = 'edi
|
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
/** Map intent + risk + complexity → tier (think / search / execute). */
|
|
121
|
-
function inferTier({ intent, risk, complexity, effort }) {
|
|
127
|
+
function inferTier({ intent, risk, complexity, effort, specialistTierBias }) {
|
|
122
128
|
const thinkIntents = ['architecture', 'security', 'planning', 'compare', 'review'];
|
|
123
129
|
if (thinkIntents.includes(intent) || risk === 'critical') return 'think';
|
|
124
130
|
|
|
131
|
+
// Specialist tier_bias can elevate to think before general tier logic
|
|
132
|
+
if (specialistTierBias === 'think') return 'think';
|
|
133
|
+
|
|
125
134
|
const searchIntents = ['search', 'explain', 'format'];
|
|
126
135
|
if (searchIntents.includes(intent) && effort === 'low') return 'search';
|
|
127
136
|
|
|
@@ -193,10 +202,15 @@ function detectTask(input) {
|
|
|
193
202
|
effort = 'high';
|
|
194
203
|
}
|
|
195
204
|
|
|
196
|
-
// 6.
|
|
197
|
-
const
|
|
205
|
+
// 6. Specialist
|
|
206
|
+
const specialistResult = classifySpecialist(prompt, files);
|
|
207
|
+
const specialistDef = SPECIALIST_DEFS[specialistResult.specialist] || null;
|
|
208
|
+
const specialistTierBias = specialistDef?.tier_bias || null;
|
|
209
|
+
|
|
210
|
+
// 7. Tier
|
|
211
|
+
const tier = inferTier({ intent, risk, complexity, effort, specialistTierBias });
|
|
198
212
|
|
|
199
|
-
//
|
|
213
|
+
// 8. Explanation
|
|
200
214
|
const explanation = buildExplanation({ intent, risk, complexity, fileCount, priorFailures });
|
|
201
215
|
|
|
202
216
|
return {
|
|
@@ -210,9 +224,99 @@ function detectTask(input) {
|
|
|
210
224
|
designImpact,
|
|
211
225
|
requiresWrite: requiresWrite(intent),
|
|
212
226
|
explanation,
|
|
227
|
+
specialist: specialistResult,
|
|
213
228
|
};
|
|
214
229
|
}
|
|
215
230
|
|
|
231
|
+
// ─── Specialist registry ──────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
const SPECIALIST_REGISTRY_PATH = resolve(__dirname, '../agents/specialists/registry.json');
|
|
234
|
+
|
|
235
|
+
const DEFAULT_SPECIALISTS = {
|
|
236
|
+
python: { triggers: { extensions: ['.py', '.pyx', '.pyi'], keywords: ['python', 'pip', 'pytest', 'django', 'flask', 'asyncio'] } },
|
|
237
|
+
typescript: { triggers: { extensions: ['.ts', '.tsx', '.mts'], keywords: ['typescript', 'tsc', 'generics', 'react', 'next', 'node'] } },
|
|
238
|
+
html: { triggers: { extensions: ['.html', '.css', '.scss', '.svg'], keywords: ['html', 'css', 'accessibility', 'a11y', 'aria', 'responsive', 'tailwind'] } },
|
|
239
|
+
linux: { triggers: { extensions: ['.sh', '.bash', '.conf', '.service', '.dockerfile'], keywords: ['linux', 'bash', 'shell', 'systemd', 'nginx', 'docker', 'ssh', 'deploy'] } },
|
|
240
|
+
security: { triggers: { extensions: [], keywords: ['auth', 'oauth', 'jwt', 'credential', 'secret', 'encrypt', 'vulnerability', 'owasp', 'xss', 'csrf'] }, tier_bias: 'think' },
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
function loadSpecialistRegistry() {
|
|
244
|
+
try {
|
|
245
|
+
const raw = readFileSync(SPECIALIST_REGISTRY_PATH, 'utf8');
|
|
246
|
+
const parsed = JSON.parse(raw);
|
|
247
|
+
return parsed.specialists || DEFAULT_SPECIALISTS;
|
|
248
|
+
} catch {
|
|
249
|
+
return DEFAULT_SPECIALISTS;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const SPECIALIST_DEFS = loadSpecialistRegistry();
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Classify which specialist domain best matches the prompt and file list.
|
|
257
|
+
* Returns { specialist, confidence, triggers }.
|
|
258
|
+
*/
|
|
259
|
+
function classifySpecialist(prompt = '', files = []) {
|
|
260
|
+
const promptLower = prompt.toLowerCase();
|
|
261
|
+
const scores = {};
|
|
262
|
+
const matchedTriggers = {};
|
|
263
|
+
|
|
264
|
+
for (const [name, def] of Object.entries(SPECIALIST_DEFS)) {
|
|
265
|
+
const { extensions = [], keywords = [] } = def.triggers || {};
|
|
266
|
+
let score = 0;
|
|
267
|
+
const hits = [];
|
|
268
|
+
|
|
269
|
+
// +2 per matching file extension
|
|
270
|
+
for (const file of files) {
|
|
271
|
+
for (const ext of extensions) {
|
|
272
|
+
if (file.endsWith(ext)) {
|
|
273
|
+
score += 2;
|
|
274
|
+
hits.push(ext);
|
|
275
|
+
break; // count each file once per specialist
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// +1 per matching keyword in prompt
|
|
281
|
+
for (const kw of keywords) {
|
|
282
|
+
// Use word-boundary-aware match where possible
|
|
283
|
+
const re = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
|
|
284
|
+
if (re.test(promptLower)) {
|
|
285
|
+
score += 1;
|
|
286
|
+
hits.push(kw);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
scores[name] = score;
|
|
291
|
+
matchedTriggers[name] = hits;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Find highest score
|
|
295
|
+
let best = null;
|
|
296
|
+
let bestScore = 0;
|
|
297
|
+
let bestExtCount = 0;
|
|
298
|
+
|
|
299
|
+
for (const [name, score] of Object.entries(scores)) {
|
|
300
|
+
if (score < 2) continue;
|
|
301
|
+
const extCount = matchedTriggers[name].filter(t => t.startsWith('.')).length;
|
|
302
|
+
if (
|
|
303
|
+
score > bestScore ||
|
|
304
|
+
(score === bestScore && extCount > bestExtCount)
|
|
305
|
+
) {
|
|
306
|
+
best = name;
|
|
307
|
+
bestScore = score;
|
|
308
|
+
bestExtCount = extCount;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!best) {
|
|
313
|
+
return { specialist: 'generic', confidence: 'low', triggers: [] };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const confidence = bestScore >= 4 ? 'high' : 'medium';
|
|
317
|
+
return { specialist: best, confidence, triggers: matchedTriggers[best] };
|
|
318
|
+
}
|
|
319
|
+
|
|
216
320
|
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
217
321
|
|
|
218
322
|
if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
|
|
@@ -238,4 +342,4 @@ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
|
|
|
238
342
|
console.log(JSON.stringify(result, null, 2));
|
|
239
343
|
}
|
|
240
344
|
|
|
241
|
-
export { detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths };
|
|
345
|
+
export { detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths, classifySpecialist };
|
package/src/dispatch.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// isInsideClaude, buildNativeDispatch, normalizeResult
|
|
9
9
|
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
|
-
import { mkdirSync, appendFileSync, existsSync } from 'node:fs';
|
|
11
|
+
import { mkdirSync, appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
12
12
|
import { join, dirname } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
@@ -20,6 +20,52 @@ const USAGE_DIR = join(__dirname, '..', '.dualbrain', 'usage');
|
|
|
20
20
|
const TIER_TIMEOUT_MS = { search: 60_000, execute: 120_000, think: 180_000 };
|
|
21
21
|
const CLAUDE_MODEL_IDS = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001' };
|
|
22
22
|
|
|
23
|
+
// ─── Specialist prompt loader ─────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const SPECIALISTS_DIR = join(__dirname, '..', 'agents', 'specialists');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Load specialist registry from agents/specialists/registry.json.
|
|
29
|
+
* Returns null if registry is missing or malformed.
|
|
30
|
+
* @returns {object|null}
|
|
31
|
+
*/
|
|
32
|
+
function _loadSpecialistRegistry() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(join(SPECIALISTS_DIR, 'registry.json'), 'utf8');
|
|
35
|
+
return JSON.parse(raw);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read agents/specialists/_base.md and agents/specialists/{specialist}.md,
|
|
43
|
+
* concatenate them (base first, specialist second). Falls back gracefully:
|
|
44
|
+
* - If base is missing, only specialist content is returned.
|
|
45
|
+
* - If specialist file is missing, only base content is returned.
|
|
46
|
+
* - If both are missing, returns an empty string.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} specialist Specialist key (e.g. 'python', 'security')
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
function loadSpecialistPrompt(specialist) {
|
|
52
|
+
if (!specialist || specialist === 'generic') return '';
|
|
53
|
+
|
|
54
|
+
const tryRead = (filePath) => {
|
|
55
|
+
try { return readFileSync(filePath, 'utf8').trim(); } catch { return ''; }
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const registry = _loadSpecialistRegistry();
|
|
59
|
+
const entry = registry?.specialists?.[specialist];
|
|
60
|
+
const promptFile = entry?.prompt_file ?? `${specialist}.md`;
|
|
61
|
+
|
|
62
|
+
const base = tryRead(join(SPECIALISTS_DIR, '_base.md'));
|
|
63
|
+
const specific = tryRead(join(SPECIALISTS_DIR, promptFile));
|
|
64
|
+
|
|
65
|
+
const parts = [base, specific].filter(Boolean);
|
|
66
|
+
return parts.join('\n\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
23
69
|
// ─── Median dispatch time tracker (in-process, for slow-response detection) ──
|
|
24
70
|
// Rolling window of recent dispatch durations keyed by "provider:modelClass"
|
|
25
71
|
const _durationHistory = new Map();
|
|
@@ -561,7 +607,8 @@ function _prependDispatchMarker(prompt) {
|
|
|
561
607
|
|
|
562
608
|
// ─── Main dispatch ────────────────────────────────────────────────────────────
|
|
563
609
|
async function dispatch(input = {}) {
|
|
564
|
-
const {
|
|
610
|
+
const { files = [], cwd = process.cwd(), dryRun = false } = input;
|
|
611
|
+
let decision = input.decision ?? {};
|
|
565
612
|
let { prompt } = input;
|
|
566
613
|
|
|
567
614
|
if (!prompt) throw new Error('prompt is required');
|
|
@@ -573,6 +620,30 @@ async function dispatch(input = {}) {
|
|
|
573
620
|
// that this agent call came through the official pipeline.
|
|
574
621
|
prompt = _prependDispatchMarker(prompt);
|
|
575
622
|
|
|
623
|
+
// ── Specialist prompt injection ──────────────────────────────────────────────
|
|
624
|
+
const specialist = decision.specialist && decision.specialist !== 'generic'
|
|
625
|
+
? decision.specialist
|
|
626
|
+
: null;
|
|
627
|
+
|
|
628
|
+
if (specialist) {
|
|
629
|
+
const specialistPrompt = loadSpecialistPrompt(specialist);
|
|
630
|
+
if (specialistPrompt) {
|
|
631
|
+
prompt = `${specialistPrompt}\n\n---\n\n${prompt}`;
|
|
632
|
+
process.stderr.write(`[dual-brain] specialist: ${specialist}\n`);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Apply tier_bias from registry if decision didn't already pin a tier
|
|
636
|
+
if (!decision.tier) {
|
|
637
|
+
const registry = _loadSpecialistRegistry();
|
|
638
|
+
const tierBias = registry?.specialists?.[specialist]?.tier_bias;
|
|
639
|
+
if (tierBias) {
|
|
640
|
+
decision = { ...decision, tier: tierBias };
|
|
641
|
+
process.stderr.write(`[dual-brain] specialist tier_bias applied: ${tierBias}\n`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// ── End specialist injection ─────────────────────────────────────────────────
|
|
646
|
+
|
|
576
647
|
const tier = decision.tier ?? 'execute';
|
|
577
648
|
const timeoutMs = TIER_TIMEOUT_MS[tier] ?? 120_000;
|
|
578
649
|
|
|
@@ -651,6 +722,7 @@ async function dispatch(input = {}) {
|
|
|
651
722
|
status: 'dry-run',
|
|
652
723
|
provider: effectiveProvider,
|
|
653
724
|
model: effectiveModel,
|
|
725
|
+
specialist: specialist ?? 'generic',
|
|
654
726
|
command,
|
|
655
727
|
nativeDispatch: nativeDescriptor,
|
|
656
728
|
exitCode: null,
|
|
@@ -712,6 +784,7 @@ async function dispatch(input = {}) {
|
|
|
712
784
|
type: 'native-agent',
|
|
713
785
|
provider: effectiveProvider,
|
|
714
786
|
model: effectiveModel,
|
|
787
|
+
specialist: specialist ?? 'generic',
|
|
715
788
|
command,
|
|
716
789
|
nativeDispatch: nativeDescriptor,
|
|
717
790
|
exitCode,
|
|
@@ -725,7 +798,7 @@ async function dispatch(input = {}) {
|
|
|
725
798
|
const command = buildCommand(effectiveDecision, prompt, files, cwd);
|
|
726
799
|
|
|
727
800
|
if (dryRun) {
|
|
728
|
-
return { status: 'dry-run', provider: effectiveProvider, model: effectiveModel, command, exitCode: null, summary: null, durationMs: 0, usage: null, error: null };
|
|
801
|
+
return { status: 'dry-run', provider: effectiveProvider, model: effectiveModel, specialist: specialist ?? 'generic', command, exitCode: null, summary: null, durationMs: 0, usage: null, error: null };
|
|
729
802
|
}
|
|
730
803
|
|
|
731
804
|
// Record this dispatch against the budget
|
|
@@ -778,6 +851,7 @@ async function dispatch(input = {}) {
|
|
|
778
851
|
status: success ? 'completed' : 'failed',
|
|
779
852
|
provider: effectiveProvider,
|
|
780
853
|
model: effectiveModel,
|
|
854
|
+
specialist: specialist ?? 'generic',
|
|
781
855
|
command,
|
|
782
856
|
exitCode,
|
|
783
857
|
summary,
|
|
@@ -865,4 +939,4 @@ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
|
|
|
865
939
|
}
|
|
866
940
|
}
|
|
867
941
|
|
|
868
|
-
export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain, validateDispatch, checkWorktreeClean, getRetryBudget, isInsideClaude, buildNativeDispatch, normalizeResult };
|
|
942
|
+
export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain, validateDispatch, checkWorktreeClean, getRetryBudget, isInsideClaude, buildNativeDispatch, normalizeResult, loadSpecialistPrompt };
|
package/src/profile.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import { createInterface } from 'readline';
|
|
|
26
26
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
27
27
|
import { homedir } from 'os';
|
|
28
28
|
import { join } from 'path';
|
|
29
|
+
import { execFile } from 'child_process';
|
|
29
30
|
|
|
30
31
|
// ---------------------------------------------------------------------------
|
|
31
32
|
// Claude Code memory integration
|
|
@@ -752,6 +753,230 @@ async function autoRefreshToken(cwd) {
|
|
|
752
753
|
}
|
|
753
754
|
}
|
|
754
755
|
|
|
756
|
+
// ---------------------------------------------------------------------------
|
|
757
|
+
// detectExistingAuth — silent onboarding scan
|
|
758
|
+
// ---------------------------------------------------------------------------
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Run a CLI command with a timeout, returning stdout as a string.
|
|
762
|
+
* Resolves with null on timeout, error, or non-zero exit.
|
|
763
|
+
* @param {string} cmd
|
|
764
|
+
* @param {string[]} args
|
|
765
|
+
* @param {number} timeoutMs
|
|
766
|
+
* @returns {Promise<string|null>}
|
|
767
|
+
*/
|
|
768
|
+
function _runWithTimeout(cmd, args, timeoutMs) {
|
|
769
|
+
return new Promise(resolve => {
|
|
770
|
+
let settled = false;
|
|
771
|
+
const done = (val) => { if (!settled) { settled = true; resolve(val); } };
|
|
772
|
+
|
|
773
|
+
let child;
|
|
774
|
+
try {
|
|
775
|
+
child = execFile(cmd, args, { timeout: timeoutMs, windowsHide: true }, (err, stdout) => {
|
|
776
|
+
done(err ? null : (stdout || '').trim());
|
|
777
|
+
});
|
|
778
|
+
} catch {
|
|
779
|
+
done(null);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Belt-and-suspenders timeout fallback
|
|
784
|
+
const timer = setTimeout(() => {
|
|
785
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
786
|
+
done(null);
|
|
787
|
+
}, timeoutMs + 500);
|
|
788
|
+
|
|
789
|
+
if (child?.on) {
|
|
790
|
+
child.on('close', () => clearTimeout(timer));
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Derive a human-readable plan label from a plan tier string.
|
|
797
|
+
* @param {'claude'|'openai'} provider
|
|
798
|
+
* @param {string} plan e.g. '$20' | '$100' | '$200'
|
|
799
|
+
*/
|
|
800
|
+
function _planLabel(provider, plan) {
|
|
801
|
+
const labels = {
|
|
802
|
+
claude: { '$20': 'Claude Pro ($20)', '$100': 'Claude Max x5 ($100)', '$200': 'Claude Max x20 ($200)' },
|
|
803
|
+
openai: { '$20': 'ChatGPT Plus ($20)', '$100': 'ChatGPT Pro ($100)', '$200': 'ChatGPT Pro ($200)' },
|
|
804
|
+
};
|
|
805
|
+
return labels[provider]?.[plan] ?? `${provider} ${plan}`;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Silently scan for existing auth from all known sources and return what was
|
|
810
|
+
* found, together with smart setup recommendations.
|
|
811
|
+
*
|
|
812
|
+
* Checks (in order, all non-throwing):
|
|
813
|
+
* 1. data-tools / replit-tools — ~/.claude/credentials.json or
|
|
814
|
+
* .replit-tools/.claude-persistent/.credentials.json for a session key
|
|
815
|
+
* 2. Claude CLI — `claude auth status` with 3 s timeout
|
|
816
|
+
* 3. Codex CLI — `codex auth status` with 3 s timeout or
|
|
817
|
+
* ~/.codex/ config files
|
|
818
|
+
* 4. Existing dual-brain config — .dualbrain/profile.json
|
|
819
|
+
*
|
|
820
|
+
* Returns:
|
|
821
|
+
* {
|
|
822
|
+
* claude: { found: boolean, source: string|null, plan: string|null, expiresAt: string|null },
|
|
823
|
+
* openai: { found: boolean, source: string|null, plan: string|null },
|
|
824
|
+
* existingProfile: boolean,
|
|
825
|
+
* recommendations: { headModel: string, budget: string, profile: string },
|
|
826
|
+
* }
|
|
827
|
+
*
|
|
828
|
+
* @param {string} [cwd]
|
|
829
|
+
*/
|
|
830
|
+
async function detectExistingAuth(cwd) {
|
|
831
|
+
const home = homedir();
|
|
832
|
+
const root = cwd || process.cwd();
|
|
833
|
+
|
|
834
|
+
// -------------------------------------------------------------------------
|
|
835
|
+
// Result skeleton
|
|
836
|
+
// -------------------------------------------------------------------------
|
|
837
|
+
const result = {
|
|
838
|
+
claude: { found: false, source: null, plan: null, expiresAt: null },
|
|
839
|
+
openai: { found: false, source: null, plan: null },
|
|
840
|
+
existingProfile: false,
|
|
841
|
+
recommendations: { headModel: 'claude-sonnet-4-6', budget: '$20', profile: 'balanced' },
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
// -------------------------------------------------------------------------
|
|
845
|
+
// 1. data-tools / replit-tools — credentials.json session key
|
|
846
|
+
// -------------------------------------------------------------------------
|
|
847
|
+
const credPaths = [
|
|
848
|
+
join(root, '.replit-tools', '.claude-persistent', '.credentials.json'),
|
|
849
|
+
join(home, '.claude', '.credentials.json'),
|
|
850
|
+
// legacy replit persistent path
|
|
851
|
+
'/home/runner/workspace/.replit-tools/.claude-persistent/.credentials.json',
|
|
852
|
+
];
|
|
853
|
+
for (const credPath of credPaths) {
|
|
854
|
+
try {
|
|
855
|
+
const creds = JSON.parse(readFileSync(credPath, 'utf8'));
|
|
856
|
+
const oauth = creds?.claudeAiOauth;
|
|
857
|
+
if (oauth?.accessToken || oauth?.sessionKey) {
|
|
858
|
+
result.claude.found = true;
|
|
859
|
+
result.claude.source = credPath.includes('.replit-tools') ? 'data-tools' : 'credentials.json';
|
|
860
|
+
// Expiry
|
|
861
|
+
if (oauth.expiresAt) {
|
|
862
|
+
try { result.claude.expiresAt = new Date(oauth.expiresAt).toISOString(); } catch {}
|
|
863
|
+
}
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
} catch { /* non-fatal */ }
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// -------------------------------------------------------------------------
|
|
870
|
+
// 2. Claude CLI auth detection (config files + `claude auth status`)
|
|
871
|
+
// -------------------------------------------------------------------------
|
|
872
|
+
if (!result.claude.found) {
|
|
873
|
+
// Config-file scan (same paths as detectAuth)
|
|
874
|
+
const claudeConfigPaths = [
|
|
875
|
+
join(root, '.replit-tools', '.claude-persistent', '.claude.json'),
|
|
876
|
+
'/home/runner/workspace/.replit-tools/.claude-persistent/.claude.json',
|
|
877
|
+
join(home, '.claude', '.claude.json'),
|
|
878
|
+
];
|
|
879
|
+
for (const p of claudeConfigPaths) {
|
|
880
|
+
try {
|
|
881
|
+
const data = JSON.parse(readFileSync(p, 'utf8'));
|
|
882
|
+
if (data?.oauthAccount || (data?.apiKey && typeof data.apiKey === 'string')) {
|
|
883
|
+
result.claude.found = true;
|
|
884
|
+
result.claude.source = p.includes('.replit-tools') ? 'claude CLI (replit-tools)' : 'claude CLI';
|
|
885
|
+
break;
|
|
886
|
+
}
|
|
887
|
+
} catch { /* non-fatal */ }
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// CLI fallback: `claude auth status`
|
|
891
|
+
if (!result.claude.found) {
|
|
892
|
+
const out = await _runWithTimeout('claude', ['auth', 'status'], 3000);
|
|
893
|
+
if (out && /logged.in|authenticated|signed.in/i.test(out)) {
|
|
894
|
+
result.claude.found = true;
|
|
895
|
+
result.claude.source = 'claude CLI (auth status)';
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// -------------------------------------------------------------------------
|
|
901
|
+
// 3. Codex CLI / OpenAI auth detection
|
|
902
|
+
// -------------------------------------------------------------------------
|
|
903
|
+
const codexConfigPaths = [
|
|
904
|
+
join(root, '.replit-tools', '.codex-persistent', 'auth.json'),
|
|
905
|
+
'/home/runner/workspace/.replit-tools/.codex-persistent/auth.json',
|
|
906
|
+
join(home, '.codex', 'auth.json'),
|
|
907
|
+
];
|
|
908
|
+
for (const p of codexConfigPaths) {
|
|
909
|
+
try {
|
|
910
|
+
const data = JSON.parse(readFileSync(p, 'utf8'));
|
|
911
|
+
const accessToken = data?.tokens?.access_token || data?.access_token;
|
|
912
|
+
const idToken = data?.tokens?.id_token || data?.id_token;
|
|
913
|
+
if (accessToken || idToken) {
|
|
914
|
+
result.openai.found = true;
|
|
915
|
+
result.openai.source = p.includes('.replit-tools') ? 'codex CLI (replit-tools)' : 'codex CLI';
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
} catch { /* non-fatal */ }
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// CLI fallback: `codex auth status`
|
|
922
|
+
if (!result.openai.found) {
|
|
923
|
+
const out = await _runWithTimeout('codex', ['auth', 'status'], 3000);
|
|
924
|
+
if (out && /logged.in|authenticated|signed.in/i.test(out)) {
|
|
925
|
+
result.openai.found = true;
|
|
926
|
+
result.openai.source = 'codex CLI (auth status)';
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// -------------------------------------------------------------------------
|
|
931
|
+
// 4. Existing dual-brain profile
|
|
932
|
+
// -------------------------------------------------------------------------
|
|
933
|
+
for (const p of [projectPath(root), GLOBAL_PATH]) {
|
|
934
|
+
if (existsSync(p)) {
|
|
935
|
+
result.existingProfile = true;
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// -------------------------------------------------------------------------
|
|
941
|
+
// Plan detection (re-use detectPlans which reads the same config files)
|
|
942
|
+
// -------------------------------------------------------------------------
|
|
943
|
+
const plans = detectPlans();
|
|
944
|
+
if (result.claude.found && plans.claude) result.claude.plan = plans.claude;
|
|
945
|
+
if (result.openai.found && plans.openai) result.openai.plan = plans.openai;
|
|
946
|
+
|
|
947
|
+
// -------------------------------------------------------------------------
|
|
948
|
+
// Smart recommendations
|
|
949
|
+
// -------------------------------------------------------------------------
|
|
950
|
+
const claudeRank = PLAN_RANK[result.claude.plan] || 0;
|
|
951
|
+
const openaiRank = PLAN_RANK[result.openai.plan] || 0;
|
|
952
|
+
|
|
953
|
+
if (result.claude.found && !result.openai.found) {
|
|
954
|
+
// Solo Claude
|
|
955
|
+
result.recommendations.headModel = 'claude-sonnet-4-6';
|
|
956
|
+
result.recommendations.budget = result.claude.plan || '$20';
|
|
957
|
+
result.recommendations.profile = claudeRank >= 2 ? 'quality-first' : 'balanced';
|
|
958
|
+
} else if (result.openai.found && !result.claude.found) {
|
|
959
|
+
// Solo OpenAI
|
|
960
|
+
result.recommendations.headModel = 'gpt-4o';
|
|
961
|
+
result.recommendations.budget = result.openai.plan || '$20';
|
|
962
|
+
result.recommendations.profile = openaiRank >= 2 ? 'quality-first' : 'balanced';
|
|
963
|
+
} else if (result.claude.found && result.openai.found) {
|
|
964
|
+
// Both available — higher-ranked provider drives HEAD model
|
|
965
|
+
if (openaiRank > claudeRank) {
|
|
966
|
+
result.recommendations.headModel = 'gpt-4o';
|
|
967
|
+
} else {
|
|
968
|
+
result.recommendations.headModel = 'claude-sonnet-4-6';
|
|
969
|
+
}
|
|
970
|
+
const topPlan = openaiRank >= claudeRank ? result.openai.plan : result.claude.plan;
|
|
971
|
+
result.recommendations.budget = topPlan || '$20';
|
|
972
|
+
const topRank = Math.max(claudeRank, openaiRank);
|
|
973
|
+
result.recommendations.profile = topRank >= 2 ? 'quality-first' : 'balanced';
|
|
974
|
+
}
|
|
975
|
+
// else: no auth found — defaults remain (claude-sonnet-4-6 / $20 / balanced)
|
|
976
|
+
|
|
977
|
+
return result;
|
|
978
|
+
}
|
|
979
|
+
|
|
755
980
|
export {
|
|
756
981
|
loadProfile, saveProfile, ensureProfile, runOnboarding,
|
|
757
982
|
rememberPreference, forgetPreference, getActivePreferences,
|
|
@@ -760,4 +985,5 @@ export {
|
|
|
760
985
|
detectAuth, detectEnvironment,
|
|
761
986
|
saveSubscription, listSubscriptions,
|
|
762
987
|
defaultProfile, autoSetup, autoRefreshToken,
|
|
988
|
+
detectExistingAuth,
|
|
763
989
|
};
|