dual-brain 0.2.2 → 0.2.4
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 +971 -411
- package/package.json +1 -1
- package/src/dispatch.mjs +14 -0
- package/src/pipeline.mjs +6 -0
- package/src/profile.mjs +535 -10
- package/src/receipt.mjs +213 -0
package/package.json
CHANGED
package/src/dispatch.mjs
CHANGED
|
@@ -672,6 +672,20 @@ async function dispatch(input = {}) {
|
|
|
672
672
|
// Safety gate: redact secrets before anything reaches a subprocess or log
|
|
673
673
|
prompt = redact(prompt);
|
|
674
674
|
|
|
675
|
+
// ── Resume brief injection ───────────────────────────────────────────────────
|
|
676
|
+
// Inject the last session's receipt as context when no situationBrief is already set.
|
|
677
|
+
// This closes the receipt → brief → next session loop automatically.
|
|
678
|
+
if (!input.situationBrief) {
|
|
679
|
+
try {
|
|
680
|
+
const { buildResumeBrief } = await import('./receipt.mjs');
|
|
681
|
+
const brief = buildResumeBrief(cwd);
|
|
682
|
+
if (brief) {
|
|
683
|
+
input = { ...input, situationBrief: brief };
|
|
684
|
+
}
|
|
685
|
+
} catch { /* non-blocking */ }
|
|
686
|
+
}
|
|
687
|
+
// ── End resume brief injection ───────────────────────────────────────────────
|
|
688
|
+
|
|
675
689
|
// ── Related session context injection ────────────────────────────────────────
|
|
676
690
|
// Find past sessions related to this task and prepend a context block.
|
|
677
691
|
// Only injected when confidence is high (score > 5). Fast: index-only, no JSONL parsing.
|
package/src/pipeline.mjs
CHANGED
|
@@ -1134,6 +1134,12 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
1134
1134
|
return { success: false, gateFailure: 'outcome', reason: run.gates.outcome.reason, run };
|
|
1135
1135
|
}
|
|
1136
1136
|
|
|
1137
|
+
// Post-session receipt — capture what happened and seed next session's context
|
|
1138
|
+
try {
|
|
1139
|
+
const { generateReceipt } = await import('./receipt.mjs');
|
|
1140
|
+
generateReceipt(run, cwd);
|
|
1141
|
+
} catch { /* non-blocking */ }
|
|
1142
|
+
|
|
1137
1143
|
// Persist decision for future recall
|
|
1138
1144
|
if (run.result && !run.result?.error) {
|
|
1139
1145
|
try {
|
package/src/profile.mjs
CHANGED
|
@@ -151,9 +151,9 @@ async function detectCapabilities(cwd) {
|
|
|
151
151
|
if (process.env.CLAUDE_CODE) {
|
|
152
152
|
claudeAvailable = true;
|
|
153
153
|
claudeSource = 'claude-code';
|
|
154
|
-
} else if (process.env.ANTHROPIC_API_KEY) {
|
|
154
|
+
} else if (process.env.ANTHROPIC_API_KEY?.length > 0) {
|
|
155
155
|
claudeAvailable = true;
|
|
156
|
-
claudeSource = '
|
|
156
|
+
claudeSource = 'api-key';
|
|
157
157
|
} else {
|
|
158
158
|
// Check for ~/.claude directory (Claude Code installation)
|
|
159
159
|
const claudeDir = join(homedir(), '.claude');
|
|
@@ -164,9 +164,8 @@ async function detectCapabilities(cwd) {
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
// --- OpenAI: check for OPENAI_API_KEY (metered billing) ---
|
|
168
|
-
const
|
|
169
|
-
const openaiAvailable = !!(openaiKey && openaiKey.length > 0);
|
|
167
|
+
// --- OpenAI: check for OPENAI_API_KEY presence (metered billing) ---
|
|
168
|
+
const openaiAvailable = !!process.env.OPENAI_API_KEY?.length;
|
|
170
169
|
|
|
171
170
|
// --- Codex: check if 'codex' is in PATH ---
|
|
172
171
|
let codexAvailable = false;
|
|
@@ -201,9 +200,9 @@ async function detectCapabilities(cwd) {
|
|
|
201
200
|
source: claudeSource,
|
|
202
201
|
},
|
|
203
202
|
openai: {
|
|
204
|
-
available: openaiAvailable,
|
|
205
|
-
source: openaiAvailable ? '
|
|
206
|
-
metered: openaiAvailable
|
|
203
|
+
available: openaiAvailable || codexAvailable,
|
|
204
|
+
source: openaiAvailable ? 'api-key' : codexAvailable ? 'codex-cli' : null,
|
|
205
|
+
metered: openaiAvailable && !codexAvailable,
|
|
207
206
|
},
|
|
208
207
|
codex: {
|
|
209
208
|
available: codexAvailable,
|
|
@@ -252,7 +251,7 @@ function getOnboardingMessage(capabilities, workStyle = 'balanced') {
|
|
|
252
251
|
const lines = [];
|
|
253
252
|
if (found.length === 0) {
|
|
254
253
|
lines.push('No providers detected');
|
|
255
|
-
lines.push('
|
|
254
|
+
lines.push(' Run: claude login or install Claude Code to get started');
|
|
256
255
|
return lines.join('\n');
|
|
257
256
|
}
|
|
258
257
|
|
|
@@ -261,7 +260,7 @@ function getOnboardingMessage(capabilities, workStyle = 'balanced') {
|
|
|
261
260
|
|
|
262
261
|
// Tip: suggest OpenAI if only Claude is available
|
|
263
262
|
if (capabilities?.claude?.available && !capabilities?.openai?.available && !capabilities?.codex?.available) {
|
|
264
|
-
lines.push(' Tip:
|
|
263
|
+
lines.push(' Tip: Run codex login for dual-brain collaboration');
|
|
265
264
|
}
|
|
266
265
|
|
|
267
266
|
// Warn about metered billing
|
|
@@ -814,10 +813,535 @@ function listSubscriptions(cwd) {
|
|
|
814
813
|
return profile.providers || {};
|
|
815
814
|
}
|
|
816
815
|
|
|
816
|
+
// ---------------------------------------------------------------------------
|
|
817
|
+
// Credential Registry
|
|
818
|
+
// ---------------------------------------------------------------------------
|
|
819
|
+
|
|
820
|
+
const credentialsPath = (cwd) => join(cwd || process.cwd(), '.dualbrain', 'credentials.json');
|
|
821
|
+
|
|
822
|
+
function defaultCredentials() {
|
|
823
|
+
return { version: 1, credentials: [] };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export function loadCredentials(cwd = process.cwd()) {
|
|
827
|
+
try {
|
|
828
|
+
const p = credentialsPath(cwd);
|
|
829
|
+
if (!existsSync(p)) return defaultCredentials();
|
|
830
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
831
|
+
} catch {
|
|
832
|
+
return defaultCredentials();
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export function saveCredentials(data, cwd = process.cwd()) {
|
|
837
|
+
try {
|
|
838
|
+
const p = credentialsPath(cwd);
|
|
839
|
+
const dir = p.slice(0, p.lastIndexOf('/'));
|
|
840
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
841
|
+
// Ensure no raw secret values are stored
|
|
842
|
+
const safe = {
|
|
843
|
+
...data,
|
|
844
|
+
credentials: (data.credentials || []).map(c => {
|
|
845
|
+
const clean = { ...c };
|
|
846
|
+
delete clean.secret;
|
|
847
|
+
delete clean.token;
|
|
848
|
+
delete clean.api_key;
|
|
849
|
+
delete clean.password;
|
|
850
|
+
return clean;
|
|
851
|
+
}),
|
|
852
|
+
};
|
|
853
|
+
const tmp = p + '.tmp.' + process.pid;
|
|
854
|
+
writeFileSync(tmp, JSON.stringify(safe, null, 2) + '\n');
|
|
855
|
+
renameSync(tmp, p);
|
|
856
|
+
return p;
|
|
857
|
+
} catch { /* non-fatal */ }
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export function addCredential(cred, cwd = process.cwd()) {
|
|
861
|
+
const required = ['id', 'provider', 'auth_type', 'source'];
|
|
862
|
+
for (const f of required) {
|
|
863
|
+
if (!cred[f]) throw new Error(`addCredential: missing required field '${f}'`);
|
|
864
|
+
}
|
|
865
|
+
const data = loadCredentials(cwd);
|
|
866
|
+
const idx = data.credentials.findIndex(c => c.id === cred.id);
|
|
867
|
+
const entry = {
|
|
868
|
+
id: cred.id,
|
|
869
|
+
provider: cred.provider,
|
|
870
|
+
auth_type: cred.auth_type,
|
|
871
|
+
source: cred.source,
|
|
872
|
+
owner: cred.owner || 'user',
|
|
873
|
+
scope: cred.scope || 'local',
|
|
874
|
+
plan_hint: cred.plan_hint || null,
|
|
875
|
+
enabled: cred.enabled !== false,
|
|
876
|
+
health: cred.health || 'unknown',
|
|
877
|
+
last_checked_at: cred.last_checked_at || null,
|
|
878
|
+
};
|
|
879
|
+
if (idx >= 0) data.credentials[idx] = entry;
|
|
880
|
+
else data.credentials.push(entry);
|
|
881
|
+
saveCredentials(data, cwd);
|
|
882
|
+
return entry;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export function removeCredential(id, cwd = process.cwd()) {
|
|
886
|
+
const data = loadCredentials(cwd);
|
|
887
|
+
data.credentials = data.credentials.filter(c => c.id !== id);
|
|
888
|
+
saveCredentials(data, cwd);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export function getHealthyCredentials(provider = null, cwd = process.cwd()) {
|
|
892
|
+
const data = loadCredentials(cwd);
|
|
893
|
+
return data.credentials.filter(c =>
|
|
894
|
+
c.enabled !== false &&
|
|
895
|
+
c.health !== 'unhealthy' &&
|
|
896
|
+
(provider === null || c.provider === provider)
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export async function checkCredentialHealth(cred, cwd = process.cwd()) {
|
|
901
|
+
let health = 'unknown';
|
|
902
|
+
try {
|
|
903
|
+
if (cred.auth_type === 'cli_oauth') {
|
|
904
|
+
try { execSync('claude --version', { stdio: 'pipe', timeout: 3000 }); } catch { return { ...cred, health: 'unhealthy', last_checked_at: new Date().toISOString() }; }
|
|
905
|
+
try {
|
|
906
|
+
const { getAuthStatus } = await import('./replit.mjs');
|
|
907
|
+
const status = getAuthStatus(cwd);
|
|
908
|
+
health = (status.available && status.tokenStatus !== 'expired') ? 'healthy' : 'degraded';
|
|
909
|
+
} catch {
|
|
910
|
+
health = 'healthy'; // cli works, auth check unavailable
|
|
911
|
+
}
|
|
912
|
+
} else if (cred.auth_type === 'api_key') {
|
|
913
|
+
if (cred.source === 'replit_secret') {
|
|
914
|
+
try {
|
|
915
|
+
const { hasSecret } = await import('./replit.mjs');
|
|
916
|
+
health = hasSecret(cred.env_var || cred.id.toUpperCase().replace(/-/g, '_')) ? 'healthy' : 'unhealthy';
|
|
917
|
+
} catch { health = 'unknown'; }
|
|
918
|
+
} else {
|
|
919
|
+
// env source
|
|
920
|
+
const varName = cred.env_var || cred.id.toUpperCase().replace(/-/g, '_');
|
|
921
|
+
health = (process.env[varName] && process.env[varName].length > 0) ? 'healthy' : 'unhealthy';
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
} catch { health = 'unknown'; }
|
|
925
|
+
return { ...cred, health, last_checked_at: new Date().toISOString() };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export async function detectCredentials(cwd = process.cwd()) {
|
|
929
|
+
const found = [];
|
|
930
|
+
|
|
931
|
+
// Claude CLI / oauth
|
|
932
|
+
const claudeDir = join(homedir(), '.claude');
|
|
933
|
+
const replitClaudeDir = join(cwd, '.replit-tools', '.claude-persistent');
|
|
934
|
+
const claudeAvail = process.env.CLAUDE_CODE || existsSync(claudeDir) || existsSync(replitClaudeDir);
|
|
935
|
+
if (claudeAvail) {
|
|
936
|
+
let health = 'unknown';
|
|
937
|
+
try { execSync('claude --version', { stdio: 'pipe', timeout: 3000 }); health = 'healthy'; } catch { health = 'degraded'; }
|
|
938
|
+
found.push({
|
|
939
|
+
id: 'claude-local-user',
|
|
940
|
+
provider: 'claude',
|
|
941
|
+
auth_type: 'cli_oauth',
|
|
942
|
+
source: 'local_cli',
|
|
943
|
+
owner: 'user',
|
|
944
|
+
scope: 'local',
|
|
945
|
+
plan_hint: null,
|
|
946
|
+
enabled: true,
|
|
947
|
+
health,
|
|
948
|
+
last_checked_at: new Date().toISOString(),
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// ANTHROPIC_API_KEY
|
|
953
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
954
|
+
found.push({
|
|
955
|
+
id: 'anthropic-api-key',
|
|
956
|
+
provider: 'claude',
|
|
957
|
+
auth_type: 'api_key',
|
|
958
|
+
source: 'env',
|
|
959
|
+
env_var: 'ANTHROPIC_API_KEY',
|
|
960
|
+
owner: 'user',
|
|
961
|
+
scope: 'local',
|
|
962
|
+
plan_hint: null,
|
|
963
|
+
enabled: true,
|
|
964
|
+
health: 'healthy',
|
|
965
|
+
last_checked_at: new Date().toISOString(),
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// OPENAI_API_KEY (env)
|
|
970
|
+
if (process.env.OPENAI_API_KEY) {
|
|
971
|
+
found.push({
|
|
972
|
+
id: 'openai-api-key',
|
|
973
|
+
provider: 'openai',
|
|
974
|
+
auth_type: 'api_key',
|
|
975
|
+
source: 'env',
|
|
976
|
+
env_var: 'OPENAI_API_KEY',
|
|
977
|
+
owner: 'user',
|
|
978
|
+
scope: 'local',
|
|
979
|
+
plan_hint: null,
|
|
980
|
+
enabled: true,
|
|
981
|
+
health: 'healthy',
|
|
982
|
+
last_checked_at: new Date().toISOString(),
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Replit secrets
|
|
987
|
+
try {
|
|
988
|
+
const { hasSecret } = await import('./replit.mjs');
|
|
989
|
+
const secretsToCheck = ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'];
|
|
990
|
+
for (const name of secretsToCheck) {
|
|
991
|
+
if (!process.env[name] && hasSecret(name)) {
|
|
992
|
+
const provider = name.startsWith('OPENAI') ? 'openai' : 'claude';
|
|
993
|
+
const id = name.toLowerCase().replace(/_/g, '-') + '-replit';
|
|
994
|
+
found.push({
|
|
995
|
+
id,
|
|
996
|
+
provider,
|
|
997
|
+
auth_type: 'api_key',
|
|
998
|
+
source: 'replit_secret',
|
|
999
|
+
env_var: name,
|
|
1000
|
+
owner: 'user',
|
|
1001
|
+
scope: 'workspace',
|
|
1002
|
+
plan_hint: null,
|
|
1003
|
+
enabled: true,
|
|
1004
|
+
health: 'healthy',
|
|
1005
|
+
last_checked_at: new Date().toISOString(),
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
} catch { /* replit.mjs unavailable */ }
|
|
1010
|
+
|
|
1011
|
+
return found;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
export function getCredentialSummary(cwd = process.cwd()) {
|
|
1015
|
+
const data = loadCredentials(cwd);
|
|
1016
|
+
const creds = data.credentials || [];
|
|
1017
|
+
const byProvider = { claude: 0, openai: 0 };
|
|
1018
|
+
let healthy = 0, degraded = 0;
|
|
1019
|
+
for (const c of creds) {
|
|
1020
|
+
if (c.enabled === false) continue;
|
|
1021
|
+
if (byProvider[c.provider] !== undefined) byProvider[c.provider]++;
|
|
1022
|
+
if (c.health === 'healthy') healthy++;
|
|
1023
|
+
else if (c.health === 'degraded' || c.health === 'unknown') degraded++;
|
|
1024
|
+
}
|
|
1025
|
+
const total = creds.filter(c => c.enabled !== false).length;
|
|
1026
|
+
let teamCapacity = 'none';
|
|
1027
|
+
if (healthy >= 4) teamCapacity = 'high';
|
|
1028
|
+
else if (healthy >= 2) teamCapacity = 'medium';
|
|
1029
|
+
else if (healthy >= 1) teamCapacity = 'low';
|
|
1030
|
+
return { total, byProvider, healthy, degraded, teamCapacity };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
817
1033
|
// ---------------------------------------------------------------------------
|
|
818
1034
|
// CLI
|
|
819
1035
|
// ---------------------------------------------------------------------------
|
|
820
1036
|
|
|
1037
|
+
// ---------------------------------------------------------------------------
|
|
1038
|
+
// Capability Manifest — single runtime view of all provider/subscription state
|
|
1039
|
+
// ---------------------------------------------------------------------------
|
|
1040
|
+
|
|
1041
|
+
/** 60-second in-process cache for the manifest. */
|
|
1042
|
+
let _manifestCache = null;
|
|
1043
|
+
let _manifestCachedAt = 0;
|
|
1044
|
+
const MANIFEST_TTL_MS = 60_000;
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Build a normalized capability manifest that consolidates provider health,
|
|
1048
|
+
* subscription config, user preferences, policy, and learning data.
|
|
1049
|
+
*
|
|
1050
|
+
* @param {string} [cwd]
|
|
1051
|
+
* @returns {Promise<object>}
|
|
1052
|
+
*/
|
|
1053
|
+
export async function getCapabilityManifest(cwd = process.cwd()) {
|
|
1054
|
+
const now = Date.now();
|
|
1055
|
+
if (_manifestCache && now - _manifestCachedAt < MANIFEST_TTL_MS) {
|
|
1056
|
+
return _manifestCache;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// ── Read orchestrator.json for subscription config ─────────────────────
|
|
1060
|
+
let orchConfig = {};
|
|
1061
|
+
try {
|
|
1062
|
+
const orchPath = join(cwd, 'orchestrator.json');
|
|
1063
|
+
orchConfig = JSON.parse(readFileSync(orchPath, 'utf8'));
|
|
1064
|
+
} catch { /* missing or malformed — fall through */ }
|
|
1065
|
+
|
|
1066
|
+
const orchSubs = orchConfig.subscriptions ?? {};
|
|
1067
|
+
const orchProv = orchConfig.providers ?? {};
|
|
1068
|
+
|
|
1069
|
+
// ── Plan normalizer (orchestrator.json uses "$100", "max-5x", "pro" etc) ─
|
|
1070
|
+
function normalizePlan(raw) {
|
|
1071
|
+
if (!raw) return 'unknown';
|
|
1072
|
+
const s = String(raw).toLowerCase();
|
|
1073
|
+
if (s.includes('max') && s.includes('20')) return 'max20';
|
|
1074
|
+
if (s.includes('max') && (s.includes('5') || s.includes('5x'))) return 'max5';
|
|
1075
|
+
if (s.includes('pro')) return 'pro';
|
|
1076
|
+
if (s.includes('plus')) return 'plus';
|
|
1077
|
+
if (s === '$20' || s === '20') return 'pro';
|
|
1078
|
+
if (s === '$100' || s === '100') return 'max5';
|
|
1079
|
+
if (s === '$200' || s === '200') return 'max20';
|
|
1080
|
+
return 'unknown';
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// ── Health states ──────────────────────────────────────────────────────
|
|
1084
|
+
let healthStates = {};
|
|
1085
|
+
try {
|
|
1086
|
+
const { getHealth } = await import('./health.mjs');
|
|
1087
|
+
healthStates = getHealth(cwd).states ?? {};
|
|
1088
|
+
} catch { /* health.mjs unavailable */ }
|
|
1089
|
+
|
|
1090
|
+
function deriveHealth(providerKey) {
|
|
1091
|
+
// Aggregate across all model classes for the provider
|
|
1092
|
+
const entries = Object.entries(healthStates)
|
|
1093
|
+
.filter(([k]) => k.startsWith(providerKey + ':'))
|
|
1094
|
+
.map(([, v]) => v?.status ?? 'healthy');
|
|
1095
|
+
if (entries.length === 0) return 'healthy';
|
|
1096
|
+
if (entries.some(s => s === 'hot')) return 'rate-limited';
|
|
1097
|
+
if (entries.some(s => s === 'degraded')) return 'degraded';
|
|
1098
|
+
if (entries.some(s => s === 'probing')) return 'degraded';
|
|
1099
|
+
return 'healthy';
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// ── Budget pressure from health file (simple proxy) ────────────────────
|
|
1103
|
+
function deriveBudget(providerKey) {
|
|
1104
|
+
const hotEntries = Object.entries(healthStates)
|
|
1105
|
+
.filter(([k]) => k.startsWith(providerKey + ':'))
|
|
1106
|
+
.filter(([, v]) => v?.status === 'hot');
|
|
1107
|
+
if (hotEntries.length === 0) return { pressure5h: 0, pressure7d: 0 };
|
|
1108
|
+
// Clamp to 0.9 when hot — we don't have real token data here
|
|
1109
|
+
const pressure = Math.min(0.9, 0.5 + hotEntries.length * 0.15);
|
|
1110
|
+
return { pressure5h: pressure, pressure7d: pressure * 0.6 };
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ── Credential registry (when available, overrides detection) ─────────
|
|
1114
|
+
const _credData = loadCredentials(cwd);
|
|
1115
|
+
const _hasCreds = _credData.credentials && _credData.credentials.length > 0;
|
|
1116
|
+
|
|
1117
|
+
// ── Claude provider ────────────────────────────────────────────────────
|
|
1118
|
+
const claudeProvider = { available: false, authenticated: false, plan: 'unknown',
|
|
1119
|
+
models: ['opus', 'sonnet', 'haiku'], health: 'healthy',
|
|
1120
|
+
budget: { pressure5h: 0, pressure7d: 0 }, source: 'none' };
|
|
1121
|
+
|
|
1122
|
+
try {
|
|
1123
|
+
// available: claude CLI or CLAUDE_CODE env or replit-tools claude dir
|
|
1124
|
+
const claudeDir = join(homedir(), '.claude');
|
|
1125
|
+
const replitClaudeDir = join(cwd, '.replit-tools', '.claude-persistent');
|
|
1126
|
+
if (process.env.CLAUDE_CODE || process.env.ANTHROPIC_API_KEY) {
|
|
1127
|
+
claudeProvider.available = true;
|
|
1128
|
+
claudeProvider.source = process.env.ANTHROPIC_API_KEY ? 'env' : 'credentials';
|
|
1129
|
+
} else if (existsSync(claudeDir) || existsSync(replitClaudeDir)) {
|
|
1130
|
+
claudeProvider.available = true;
|
|
1131
|
+
claudeProvider.source = existsSync(replitClaudeDir) ? 'replit-tools' : 'credentials';
|
|
1132
|
+
} else {
|
|
1133
|
+
try { execSync('which claude', { stdio: 'pipe', timeout: 2000 }); claudeProvider.available = true; claudeProvider.source = 'credentials'; } catch { /* not found */ }
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// authenticated: use getAuthHealthStatus
|
|
1137
|
+
const { getAuthHealthStatus } = await import('./health.mjs');
|
|
1138
|
+
const authStatus = await getAuthHealthStatus(cwd);
|
|
1139
|
+
claudeProvider.authenticated = authStatus.ok;
|
|
1140
|
+
if (authStatus.source === 'replit-tools') claudeProvider.source = 'replit-tools';
|
|
1141
|
+
} catch { /* getAuthHealthStatus unavailable */ }
|
|
1142
|
+
|
|
1143
|
+
claudeProvider.plan = normalizePlan(orchProv.claude?.subscription ?? orchSubs.claude?.plan);
|
|
1144
|
+
claudeProvider.health = claudeProvider.authenticated ? deriveHealth('claude') : 'down';
|
|
1145
|
+
claudeProvider.budget = deriveBudget('claude');
|
|
1146
|
+
|
|
1147
|
+
// Override with registry data when credentials.json exists
|
|
1148
|
+
if (_hasCreds) {
|
|
1149
|
+
const claudeCreds = _credData.credentials.filter(c => c.provider === 'claude' && c.enabled !== false);
|
|
1150
|
+
if (claudeCreds.length > 0) {
|
|
1151
|
+
claudeProvider.available = true;
|
|
1152
|
+
claudeProvider.authenticated = claudeCreds.some(c => c.health === 'healthy');
|
|
1153
|
+
claudeProvider.health = claudeCreds.some(c => c.health === 'healthy') ? deriveHealth('claude')
|
|
1154
|
+
: claudeCreds.some(c => c.health === 'degraded') ? 'degraded' : 'down';
|
|
1155
|
+
const planHint = claudeCreds.find(c => c.plan_hint)?.plan_hint;
|
|
1156
|
+
if (planHint) claudeProvider.plan = normalizePlan(planHint);
|
|
1157
|
+
claudeProvider.source = claudeCreds[0].source;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// ── OpenAI provider ────────────────────────────────────────────────────
|
|
1162
|
+
const openaiProvider = { available: false, authenticated: false, plan: 'unknown',
|
|
1163
|
+
models: ['gpt-5.5', 'o3', 'gpt-4o', 'gpt-4o-mini'], health: 'healthy',
|
|
1164
|
+
budget: { pressure5h: 0, pressure7d: 0 }, source: 'none' };
|
|
1165
|
+
|
|
1166
|
+
try {
|
|
1167
|
+
let hasSecret = false;
|
|
1168
|
+
try { const { hasSecret: hs } = await import('./replit.mjs'); hasSecret = hs('OPENAI_API_KEY'); } catch { hasSecret = !!(process.env.OPENAI_API_KEY); }
|
|
1169
|
+
|
|
1170
|
+
let codexAvailable = false;
|
|
1171
|
+
try { execSync('which codex', { stdio: 'pipe', timeout: 2000 }); codexAvailable = true; } catch { /* not in PATH */ }
|
|
1172
|
+
|
|
1173
|
+
openaiProvider.available = hasSecret || codexAvailable;
|
|
1174
|
+
openaiProvider.authenticated = hasSecret;
|
|
1175
|
+
openaiProvider.source = hasSecret ? 'env' : codexAvailable ? 'codex-config' : 'none';
|
|
1176
|
+
} catch { /* detection failed */ }
|
|
1177
|
+
|
|
1178
|
+
openaiProvider.plan = normalizePlan(orchProv.openai?.subscription ?? orchSubs.openai?.plan);
|
|
1179
|
+
openaiProvider.health = openaiProvider.authenticated ? deriveHealth('openai') : 'down';
|
|
1180
|
+
openaiProvider.budget = deriveBudget('openai');
|
|
1181
|
+
|
|
1182
|
+
// Override with registry data when credentials.json exists
|
|
1183
|
+
if (_hasCreds) {
|
|
1184
|
+
const openaiCreds = _credData.credentials.filter(c => c.provider === 'openai' && c.enabled !== false);
|
|
1185
|
+
if (openaiCreds.length > 0) {
|
|
1186
|
+
openaiProvider.available = true;
|
|
1187
|
+
openaiProvider.authenticated = openaiCreds.some(c => c.health === 'healthy');
|
|
1188
|
+
openaiProvider.health = openaiCreds.some(c => c.health === 'healthy') ? deriveHealth('openai')
|
|
1189
|
+
: openaiCreds.some(c => c.health === 'degraded') ? 'degraded' : 'down';
|
|
1190
|
+
const planHint = openaiCreds.find(c => c.plan_hint)?.plan_hint;
|
|
1191
|
+
if (planHint) openaiProvider.plan = normalizePlan(planHint);
|
|
1192
|
+
openaiProvider.source = openaiCreds[0].source;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// ── Preferences ────────────────────────────────────────────────────────
|
|
1197
|
+
let preferences = { bias: 'auto', forbiddenModels: [], preferredModels: [],
|
|
1198
|
+
costBias: 0.5, confirmBeforeExpensive: false };
|
|
1199
|
+
try {
|
|
1200
|
+
const profile = loadProfile(cwd);
|
|
1201
|
+
const bias = profile.bias ?? profile.workStyle ?? 'auto';
|
|
1202
|
+
preferences.bias = ['auto','balanced','cost-saver','quality-first'].includes(bias) ? bias : 'auto';
|
|
1203
|
+
preferences.forbiddenModels = profile.forbiddenModels ?? [];
|
|
1204
|
+
preferences.preferredModels = profile.preferredModels ?? [];
|
|
1205
|
+
preferences.costBias = profile.costBias ?? (bias === 'cost-saver' ? 0.8 : bias === 'quality-first' ? 0.1 : 0.5);
|
|
1206
|
+
preferences.confirmBeforeExpensive = profile.apiGuardrail ?? false;
|
|
1207
|
+
} catch { /* profile unavailable */ }
|
|
1208
|
+
|
|
1209
|
+
// ── Policy ─────────────────────────────────────────────────────────────
|
|
1210
|
+
const policy = {
|
|
1211
|
+
highRiskRequiresBestAvailable: true,
|
|
1212
|
+
failoverMode: 'tell',
|
|
1213
|
+
dualBrainThreshold: 'high',
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
// ── Learning ───────────────────────────────────────────────────────────
|
|
1217
|
+
let learning = {};
|
|
1218
|
+
try {
|
|
1219
|
+
const { getModelSuccessRates } = await import('./doctor.mjs');
|
|
1220
|
+
learning = getModelSuccessRates(cwd);
|
|
1221
|
+
} catch { /* doctor.mjs unavailable */ }
|
|
1222
|
+
|
|
1223
|
+
// ── Setup summary ──────────────────────────────────────────────────────
|
|
1224
|
+
const hasAnyProvider = (claudeProvider.available && claudeProvider.authenticated) ||
|
|
1225
|
+
(openaiProvider.available && openaiProvider.authenticated);
|
|
1226
|
+
|
|
1227
|
+
let recommendedAction = null;
|
|
1228
|
+
if (!claudeProvider.available && !openaiProvider.available) {
|
|
1229
|
+
recommendedAction = 'connect-claude';
|
|
1230
|
+
} else if (!claudeProvider.authenticated && !openaiProvider.authenticated) {
|
|
1231
|
+
recommendedAction = 'refresh-auth';
|
|
1232
|
+
} else if (!openaiProvider.available) {
|
|
1233
|
+
recommendedAction = 'connect-openai';
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const manifest = {
|
|
1237
|
+
providers: { claude: claudeProvider, openai: openaiProvider },
|
|
1238
|
+
preferences,
|
|
1239
|
+
policy,
|
|
1240
|
+
learning,
|
|
1241
|
+
setup: {
|
|
1242
|
+
hasAnyProvider,
|
|
1243
|
+
recommendedAction,
|
|
1244
|
+
zeroProviderMode: !hasAnyProvider,
|
|
1245
|
+
},
|
|
1246
|
+
timestamp: new Date().toISOString(),
|
|
1247
|
+
};
|
|
1248
|
+
|
|
1249
|
+
_manifestCache = manifest;
|
|
1250
|
+
_manifestCachedAt = now;
|
|
1251
|
+
return manifest;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Compute the effective routing policy for a specific task, applying rules in order:
|
|
1256
|
+
* 1. Safety constraints (high-risk → best available model)
|
|
1257
|
+
* 2. Provider availability
|
|
1258
|
+
* 3. Task tier fit (search→haiku, execute→sonnet, think→opus)
|
|
1259
|
+
* 4. User preferences (cost bias, forbidden models)
|
|
1260
|
+
* 5. Learning (prefer models with ≥90% success rate for this task type)
|
|
1261
|
+
*
|
|
1262
|
+
* @param {object} manifest — from getCapabilityManifest()
|
|
1263
|
+
* @param {{ tier?: string, risk?: string, taskType?: string }} taskContext
|
|
1264
|
+
* @returns {{ provider: string, model: string, tier: string, reason: string, overrides: string[] }}
|
|
1265
|
+
*/
|
|
1266
|
+
export function getEffectivePolicy(manifest, taskContext = {}) {
|
|
1267
|
+
const { providers, preferences, policy, learning } = manifest;
|
|
1268
|
+
const tier = taskContext.tier ?? 'execute';
|
|
1269
|
+
const risk = taskContext.risk ?? 'medium';
|
|
1270
|
+
const taskType = taskContext.taskType ?? 'general';
|
|
1271
|
+
const overrides = [];
|
|
1272
|
+
|
|
1273
|
+
// Tier → default model mapping
|
|
1274
|
+
const tierModelMap = { search: 'haiku', execute: 'sonnet', think: 'opus' };
|
|
1275
|
+
let preferredModel = tierModelMap[tier] ?? 'sonnet';
|
|
1276
|
+
let preferredProvider = 'claude';
|
|
1277
|
+
|
|
1278
|
+
// 1. Safety: high/critical risk → best available model
|
|
1279
|
+
if (policy.highRiskRequiresBestAvailable && (risk === 'high' || risk === 'critical')) {
|
|
1280
|
+
preferredModel = 'opus';
|
|
1281
|
+
overrides.push(`risk=${risk} → upgraded to opus`);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// 2. Provider availability — fall back to openai if claude is down
|
|
1285
|
+
const claudeOk = providers.claude.available && providers.claude.authenticated &&
|
|
1286
|
+
providers.claude.health !== 'down';
|
|
1287
|
+
const openaiOk = providers.openai.available && providers.openai.authenticated &&
|
|
1288
|
+
providers.openai.health !== 'down';
|
|
1289
|
+
|
|
1290
|
+
if (!claudeOk && openaiOk) {
|
|
1291
|
+
preferredProvider = 'openai';
|
|
1292
|
+
// Remap model names for openai
|
|
1293
|
+
const openaiTierMap = { search: 'gpt-4o-mini', execute: 'gpt-4o', think: 'gpt-5.5' };
|
|
1294
|
+
preferredModel = risk === 'high' || risk === 'critical' ? 'gpt-5.5' : (openaiTierMap[tier] ?? 'gpt-4o');
|
|
1295
|
+
overrides.push('claude unavailable → routed to openai');
|
|
1296
|
+
} else if (!claudeOk && !openaiOk) {
|
|
1297
|
+
return { provider: 'none', model: 'none', tier, reason: 'no providers available', overrides };
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// 3. Task fit already applied via tierModelMap above
|
|
1301
|
+
|
|
1302
|
+
// 4. User preferences: forbidden models, cost bias
|
|
1303
|
+
const forbidden = preferences.forbiddenModels ?? [];
|
|
1304
|
+
if (forbidden.includes(preferredModel)) {
|
|
1305
|
+
// Downgrade one step
|
|
1306
|
+
const fallback = preferredProvider === 'claude'
|
|
1307
|
+
? (preferredModel === 'opus' ? 'sonnet' : 'haiku')
|
|
1308
|
+
: (preferredModel === 'gpt-5.5' ? 'gpt-4o' : 'gpt-4o-mini');
|
|
1309
|
+
overrides.push(`${preferredModel} forbidden → downgraded to ${fallback}`);
|
|
1310
|
+
preferredModel = fallback;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
if (preferences.costBias > 0.7 && preferredModel === 'opus' && risk !== 'high' && risk !== 'critical') {
|
|
1314
|
+
preferredModel = 'sonnet';
|
|
1315
|
+
overrides.push('cost-bias > 0.7 → downgraded from opus to sonnet');
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// 5. Learning: if another model has ≥90% success for this task type, prefer it
|
|
1319
|
+
const successRates = learning ?? {};
|
|
1320
|
+
let bestLearnedModel = null;
|
|
1321
|
+
let bestRate = 0.9; // threshold
|
|
1322
|
+
for (const [model, stats] of Object.entries(successRates)) {
|
|
1323
|
+
if (stats.rate >= bestRate && stats.total >= 5 && !forbidden.includes(model)) {
|
|
1324
|
+
// Only prefer if it's on the right provider
|
|
1325
|
+
const isClaudeModel = ['opus', 'sonnet', 'haiku'].includes(model);
|
|
1326
|
+
if ((preferredProvider === 'claude' && isClaudeModel) ||
|
|
1327
|
+
(preferredProvider === 'openai' && !isClaudeModel)) {
|
|
1328
|
+
bestLearnedModel = model;
|
|
1329
|
+
bestRate = stats.rate;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
if (bestLearnedModel && bestLearnedModel !== preferredModel) {
|
|
1334
|
+
overrides.push(`learning: ${bestLearnedModel} has ${Math.round(bestRate * 100)}% success → preferred`);
|
|
1335
|
+
preferredModel = bestLearnedModel;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const reason = overrides.length > 0
|
|
1339
|
+
? overrides[0]
|
|
1340
|
+
: `tier=${tier} → ${preferredProvider}/${preferredModel}`;
|
|
1341
|
+
|
|
1342
|
+
return { provider: preferredProvider, model: preferredModel, tier, reason, overrides };
|
|
1343
|
+
}
|
|
1344
|
+
|
|
821
1345
|
async function main() {
|
|
822
1346
|
const args = process.argv.slice(2);
|
|
823
1347
|
const cwd = process.cwd();
|
|
@@ -887,4 +1411,5 @@ export {
|
|
|
887
1411
|
// backward-compat stubs (deprecated)
|
|
888
1412
|
detectExistingAuth, detectPlans, saveSubscription, listSubscriptions,
|
|
889
1413
|
defaultProfile,
|
|
1414
|
+
// credential registry (functions already exported inline above)
|
|
890
1415
|
};
|