dual-brain 0.2.8 → 0.2.10
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 +208 -42
- package/package.json +9 -2
- package/src/agents/registry.mjs +405 -0
- package/src/collaboration.mjs +545 -0
- package/src/detect.mjs +73 -1
- package/src/dispatch.mjs +47 -5
- package/src/head.mjs +705 -263
- package/src/pipeline.mjs +387 -163
- package/src/profile.mjs +82 -1
- package/src/provider-context.mjs +257 -0
package/src/profile.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { createInterface } from 'readline';
|
|
29
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'fs';
|
|
30
30
|
import { homedir } from 'os';
|
|
31
31
|
import { join } from 'path';
|
|
32
32
|
import { execSync } from 'child_process';
|
|
@@ -188,6 +188,71 @@ async function detectCapabilities(cwd) {
|
|
|
188
188
|
const checkpointsBin = existsSync(join(replitToolsDir, 'checkpoints'))
|
|
189
189
|
|| existsSync('/usr/local/bin/replit-checkpoint');
|
|
190
190
|
|
|
191
|
+
// --- MCP servers: check Claude settings files ---
|
|
192
|
+
let mcpServers = [];
|
|
193
|
+
try {
|
|
194
|
+
const claudeSettings = join(homedir(), '.claude', 'settings.json');
|
|
195
|
+
if (existsSync(claudeSettings)) {
|
|
196
|
+
const settings = JSON.parse(readFileSync(claudeSettings, 'utf8'));
|
|
197
|
+
if (settings.mcpServers) {
|
|
198
|
+
mcpServers = Object.keys(settings.mcpServers);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Also check project-local
|
|
202
|
+
const localSettings = join(root, '.claude', 'settings.json');
|
|
203
|
+
if (existsSync(localSettings)) {
|
|
204
|
+
const local = JSON.parse(readFileSync(localSettings, 'utf8'));
|
|
205
|
+
if (local.mcpServers) {
|
|
206
|
+
mcpServers.push(...Object.keys(local.mcpServers));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch {}
|
|
210
|
+
|
|
211
|
+
// --- Claude plugins: check installed plugin marketplaces ---
|
|
212
|
+
let claudePlugins = [];
|
|
213
|
+
try {
|
|
214
|
+
const pluginDir = join(root, '.replit-tools', '.claude-persistent', 'plugins', 'marketplaces');
|
|
215
|
+
if (existsSync(pluginDir)) {
|
|
216
|
+
const marketplaces = readdirSync(pluginDir);
|
|
217
|
+
for (const m of marketplaces) {
|
|
218
|
+
const mDir = join(pluginDir, m, 'plugins');
|
|
219
|
+
if (existsSync(mDir)) {
|
|
220
|
+
claudePlugins.push(...readdirSync(mDir));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} catch {}
|
|
225
|
+
|
|
226
|
+
// --- Codex plugins: check available plugins ---
|
|
227
|
+
let codexPlugins = [];
|
|
228
|
+
try {
|
|
229
|
+
const pluginDir = join(root, '.replit-tools', '.codex-persistent', '.tmp', 'plugins', 'plugins');
|
|
230
|
+
if (existsSync(pluginDir)) {
|
|
231
|
+
codexPlugins = readdirSync(pluginDir).filter(f => !f.startsWith('.'));
|
|
232
|
+
}
|
|
233
|
+
} catch {}
|
|
234
|
+
|
|
235
|
+
// --- Shell snapshots: count .sh files ---
|
|
236
|
+
let shellSnapshots = 0;
|
|
237
|
+
try {
|
|
238
|
+
const snapDir = join(root, '.replit-tools', '.claude-persistent', 'shell-snapshots');
|
|
239
|
+
if (existsSync(snapDir)) {
|
|
240
|
+
shellSnapshots = readdirSync(snapDir).filter(f => f.endsWith('.sh')).length;
|
|
241
|
+
}
|
|
242
|
+
} catch {}
|
|
243
|
+
|
|
244
|
+
// --- Configured hooks: count by type from settings.local.json ---
|
|
245
|
+
let configuredHooks = { PreToolUse: 0, PostToolUse: 0, Stop: 0, Notification: 0 };
|
|
246
|
+
try {
|
|
247
|
+
const localSettings = join(root, '.claude', 'settings.local.json');
|
|
248
|
+
if (existsSync(localSettings)) {
|
|
249
|
+
const s = JSON.parse(readFileSync(localSettings, 'utf8'));
|
|
250
|
+
for (const hookType of Object.keys(configuredHooks)) {
|
|
251
|
+
configuredHooks[hookType] = s.hooks?.[hookType]?.length || 0;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch {}
|
|
255
|
+
|
|
191
256
|
return {
|
|
192
257
|
claude: {
|
|
193
258
|
available: claudeAvailable,
|
|
@@ -205,6 +270,11 @@ async function detectCapabilities(cwd) {
|
|
|
205
270
|
available: replitToolsAvailable,
|
|
206
271
|
checkpoints: checkpointsBin,
|
|
207
272
|
},
|
|
273
|
+
mcpServers,
|
|
274
|
+
claudePlugins,
|
|
275
|
+
codexPlugins,
|
|
276
|
+
shellSnapshots,
|
|
277
|
+
configuredHooks,
|
|
208
278
|
};
|
|
209
279
|
}
|
|
210
280
|
|
|
@@ -998,6 +1068,9 @@ export async function getCapabilityManifest(cwd = process.cwd()) {
|
|
|
998
1068
|
return 'unknown';
|
|
999
1069
|
}
|
|
1000
1070
|
|
|
1071
|
+
// ── Environment capabilities (MCP, plugins, hooks, snapshots) ─────────
|
|
1072
|
+
const envCaps = await detectCapabilities(cwd);
|
|
1073
|
+
|
|
1001
1074
|
// ── Health states ──────────────────────────────────────────────────────
|
|
1002
1075
|
let healthStates = {};
|
|
1003
1076
|
try {
|
|
@@ -1158,6 +1231,14 @@ export async function getCapabilityManifest(cwd = process.cwd()) {
|
|
|
1158
1231
|
recommendedAction,
|
|
1159
1232
|
zeroProviderMode: !hasAnyProvider,
|
|
1160
1233
|
},
|
|
1234
|
+
environment: {
|
|
1235
|
+
mcpServers: envCaps.mcpServers,
|
|
1236
|
+
claudePlugins: envCaps.claudePlugins,
|
|
1237
|
+
codexPlugins: envCaps.codexPlugins,
|
|
1238
|
+
shellSnapshots: envCaps.shellSnapshots,
|
|
1239
|
+
configuredHooks: envCaps.configuredHooks,
|
|
1240
|
+
replitTools: envCaps.replitTools,
|
|
1241
|
+
},
|
|
1161
1242
|
timestamp: new Date().toISOString(),
|
|
1162
1243
|
};
|
|
1163
1244
|
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// ── Provider capabilities registry ──────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const PROVIDER_CAPS = {
|
|
7
|
+
claude: {
|
|
8
|
+
name: 'Claude Code',
|
|
9
|
+
cli: 'claude',
|
|
10
|
+
hasNativeCompaction: true,
|
|
11
|
+
compactionStrategy: 'automatic',
|
|
12
|
+
contextFormat: 'markdown-blocks',
|
|
13
|
+
supportsSpecialists: true,
|
|
14
|
+
supportsSituationBrief: true,
|
|
15
|
+
maxContextTokens: 200_000,
|
|
16
|
+
sessionStorage: 'claude-internal',
|
|
17
|
+
authCheck: 'claude --version',
|
|
18
|
+
resumeSupport: 'receipt + handoff',
|
|
19
|
+
},
|
|
20
|
+
openai: {
|
|
21
|
+
name: 'Codex CLI',
|
|
22
|
+
cli: 'codex',
|
|
23
|
+
hasNativeCompaction: false,
|
|
24
|
+
compactionStrategy: 'none',
|
|
25
|
+
contextFormat: 'plain-text',
|
|
26
|
+
supportsSpecialists: false,
|
|
27
|
+
supportsSituationBrief: true,
|
|
28
|
+
maxContextTokens: 128_000,
|
|
29
|
+
sessionStorage: '~/.codex/sessions/YYYY/MM/DD/*.jsonl',
|
|
30
|
+
authCheck: 'codex --version',
|
|
31
|
+
resumeSupport: 'handoff-only',
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function getProviderCaps(provider) {
|
|
36
|
+
return PROVIDER_CAPS[provider] || PROVIDER_CAPS.claude;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Provider-agnostic context injection ─────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build a context block that works for any provider.
|
|
43
|
+
* Adapts format based on provider capabilities.
|
|
44
|
+
*/
|
|
45
|
+
export function buildContextBlock(provider, sections) {
|
|
46
|
+
const caps = getProviderCaps(provider);
|
|
47
|
+
const lines = [];
|
|
48
|
+
|
|
49
|
+
if (caps.contextFormat === 'markdown-blocks') {
|
|
50
|
+
for (const [label, content] of Object.entries(sections)) {
|
|
51
|
+
if (!content) continue;
|
|
52
|
+
lines.push(`[${label.toUpperCase()}]`);
|
|
53
|
+
lines.push(typeof content === 'string' ? content : JSON.stringify(content));
|
|
54
|
+
lines.push(`[/${label.toUpperCase()}]`);
|
|
55
|
+
lines.push('');
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
for (const [label, content] of Object.entries(sections)) {
|
|
59
|
+
if (!content) continue;
|
|
60
|
+
lines.push(`--- ${label} ---`);
|
|
61
|
+
lines.push(typeof content === 'string' ? content : JSON.stringify(content));
|
|
62
|
+
lines.push('');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return lines.join('\n').trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Compaction survival for both providers ───────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build a compaction survival block tuned for the target provider.
|
|
73
|
+
*
|
|
74
|
+
* Claude: uses tagged blocks that survive automatic context compression.
|
|
75
|
+
* Codex: no native compaction, but we prepend a compact state header that
|
|
76
|
+
* stays at the top of the context window and serves as a quick-reference
|
|
77
|
+
* for the model when the conversation gets long.
|
|
78
|
+
*/
|
|
79
|
+
export function buildSurvivalBlock(provider, state) {
|
|
80
|
+
const caps = getProviderCaps(provider);
|
|
81
|
+
|
|
82
|
+
const coreLines = [];
|
|
83
|
+
if (state.activeTask) coreLines.push(`TASK: ${state.activeTask}`);
|
|
84
|
+
if (state.provider) coreLines.push(`PROVIDER: ${state.provider}/${state.model || 'default'}`);
|
|
85
|
+
if (state.tier) coreLines.push(`TIER: ${state.tier}`);
|
|
86
|
+
if (state.risk) coreLines.push(`RISK: ${state.risk}`);
|
|
87
|
+
if (state.filesInProgress?.length) coreLines.push(`FILES: ${state.filesInProgress.slice(0, 10).join(', ')}`);
|
|
88
|
+
if (state.decisions?.length) coreLines.push(`DECISIONS: ${state.decisions.slice(0, 3).join('; ')}`);
|
|
89
|
+
if (state.warnings?.length) coreLines.push(`WARNINGS: ${state.warnings.slice(0, 3).join('; ')}`);
|
|
90
|
+
if (state.routingRules?.length) coreLines.push(`ROUTING: ${state.routingRules.join('; ')}`);
|
|
91
|
+
|
|
92
|
+
if (caps.hasNativeCompaction) {
|
|
93
|
+
return `[DUAL-BRAIN CONTINUITY]\n${coreLines.join('\n')}\n[/DUAL-BRAIN CONTINUITY]`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Codex: compact header block — placed at prompt start for max visibility
|
|
97
|
+
return `## dual-brain state\n${coreLines.join('\n')}\n---`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Provider-agnostic handoff ───────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Generate a handoff receipt that works for both providers.
|
|
104
|
+
* Accounts for Codex's lack of native resume support by writing
|
|
105
|
+
* to a shared .dual-brain/handoffs/ directory that both providers can read.
|
|
106
|
+
*/
|
|
107
|
+
export function generateProviderHandoff(sessionState, provider) {
|
|
108
|
+
const caps = getProviderCaps(provider);
|
|
109
|
+
|
|
110
|
+
const handoff = {
|
|
111
|
+
version: 2,
|
|
112
|
+
provider,
|
|
113
|
+
providerCaps: caps.name,
|
|
114
|
+
timestamp: new Date().toISOString(),
|
|
115
|
+
task: sessionState.taskDescription || null,
|
|
116
|
+
progress: {
|
|
117
|
+
filesChanged: (sessionState.filesChanged || []).slice(0, 20),
|
|
118
|
+
testsRun: sessionState.testsRun || [],
|
|
119
|
+
decisions: (sessionState.decisions || []).slice(0, 5),
|
|
120
|
+
},
|
|
121
|
+
unresolved: (sessionState.unresolved || []).slice(0, 5),
|
|
122
|
+
routing: {
|
|
123
|
+
lastProvider: provider,
|
|
124
|
+
lastModel: sessionState.routingHistory?.lastModel || null,
|
|
125
|
+
failedProviders: sessionState.routingHistory?.failedProviders || [],
|
|
126
|
+
},
|
|
127
|
+
resumeHint: sessionState.resumeHint || null,
|
|
128
|
+
resumeStrategy: caps.resumeSupport,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return handoff;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build a resume brief from the latest handoff, adapted for the resuming provider.
|
|
136
|
+
* Codex gets a more verbose brief since it has no native session memory.
|
|
137
|
+
*/
|
|
138
|
+
export function buildProviderResumeBrief(cwd, targetProvider) {
|
|
139
|
+
const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
|
|
140
|
+
if (!existsSync(dir)) return null;
|
|
141
|
+
|
|
142
|
+
const files = readdirSync(dir)
|
|
143
|
+
.filter(f => f.startsWith('handoff-') && f.endsWith('.json'))
|
|
144
|
+
.sort()
|
|
145
|
+
.reverse();
|
|
146
|
+
if (files.length === 0) return null;
|
|
147
|
+
|
|
148
|
+
let handoff;
|
|
149
|
+
try {
|
|
150
|
+
handoff = JSON.parse(readFileSync(join(dir, files[0]), 'utf8'));
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const age = (Date.now() - Date.parse(handoff.timestamp)) / 3600000;
|
|
156
|
+
if (age > 48) return null;
|
|
157
|
+
|
|
158
|
+
const caps = getProviderCaps(targetProvider);
|
|
159
|
+
const lines = [];
|
|
160
|
+
|
|
161
|
+
const ageLabel = age < 1 ? 'just now' : age < 24 ? `${Math.round(age)}h ago` : `${Math.round(age / 24)}d ago`;
|
|
162
|
+
lines.push(`Resuming from previous session (${ageLabel}, ran on ${handoff.provider || 'unknown'}):`);
|
|
163
|
+
|
|
164
|
+
if (handoff.task) lines.push(` Task: ${handoff.task}`);
|
|
165
|
+
if (handoff.resumeHint) lines.push(` Next: ${handoff.resumeHint}`);
|
|
166
|
+
|
|
167
|
+
if (handoff.progress?.filesChanged?.length) {
|
|
168
|
+
const shown = handoff.progress.filesChanged.slice(0, caps.hasNativeCompaction ? 5 : 10);
|
|
169
|
+
lines.push(` Changed: ${shown.join(', ')}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (handoff.unresolved?.length) {
|
|
173
|
+
lines.push(` Unresolved: ${handoff.unresolved.join('; ')}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Codex gets extra context since it has no native session memory
|
|
177
|
+
if (!caps.hasNativeCompaction && handoff.progress?.decisions?.length) {
|
|
178
|
+
lines.push(` Prior routing: ${handoff.progress.decisions.map(d => `${d.provider}/${d.model}`).join(', ')}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (handoff.routing?.failedProviders?.length) {
|
|
182
|
+
lines.push(` Note: ${handoff.routing.failedProviders.join(', ')} failed last session`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return lines.join('\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Specialist/plugin injection (provider-aware) ────────────────────────────
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build a capability hint block for the target provider.
|
|
192
|
+
* Claude: specialist prompts from agents/specialists/.
|
|
193
|
+
* Codex: plugin hints from matched Codex plugins.
|
|
194
|
+
*/
|
|
195
|
+
export function buildCapabilityHint(provider, prompt, cwd) {
|
|
196
|
+
if (provider === 'claude') {
|
|
197
|
+
return null; // Handled by dispatch.mjs specialist injection
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Codex: try to match plugins
|
|
201
|
+
try {
|
|
202
|
+
const { matchPluginsForTask } = require('./replit.mjs');
|
|
203
|
+
const matched = matchPluginsForTask(prompt, undefined, cwd);
|
|
204
|
+
if (matched.length > 0) {
|
|
205
|
+
const names = matched.slice(0, 3).map(m => m.plugin.id).join(', ');
|
|
206
|
+
return `[Available Codex plugins: ${names}. Consider using matching plugins for direct API access.]`;
|
|
207
|
+
}
|
|
208
|
+
} catch {}
|
|
209
|
+
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Context budget tracking (provider-aware) ────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Estimate remaining context budget for a provider.
|
|
217
|
+
*/
|
|
218
|
+
export function estimateContextBudget(provider, usedTokens) {
|
|
219
|
+
const caps = getProviderCaps(provider);
|
|
220
|
+
const remaining = caps.maxContextTokens - usedTokens;
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
provider,
|
|
224
|
+
maxTokens: caps.maxContextTokens,
|
|
225
|
+
usedTokens,
|
|
226
|
+
remainingTokens: Math.max(0, remaining),
|
|
227
|
+
utilizationPct: Math.round((usedTokens / caps.maxContextTokens) * 100),
|
|
228
|
+
compactionRisk: remaining < 20_000 ? 'critical' : remaining < 50_000 ? 'high' : remaining < 100_000 ? 'medium' : 'low',
|
|
229
|
+
hasNativeCompaction: caps.hasNativeCompaction,
|
|
230
|
+
action: caps.hasNativeCompaction
|
|
231
|
+
? (remaining < 20_000 ? 'survival-kit-injected' : 'none')
|
|
232
|
+
: (remaining < 30_000 ? 'manual-handoff-recommended' : 'none'),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Cross-provider compatibility helpers ────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get the opposite provider for cross-review.
|
|
240
|
+
* Handles the case where the opposite provider isn't available.
|
|
241
|
+
*/
|
|
242
|
+
export function getReviewProvider(workProvider, availableProviders) {
|
|
243
|
+
const opposite = workProvider === 'claude' ? 'openai' : 'claude';
|
|
244
|
+
if (availableProviders?.includes(opposite)) return opposite;
|
|
245
|
+
|
|
246
|
+
// Same-provider review with different model if opposite unavailable
|
|
247
|
+
return workProvider;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Check if both providers are available for true dual-brain operation.
|
|
252
|
+
*/
|
|
253
|
+
export function isDualProviderAvailable(profile) {
|
|
254
|
+
const claude = profile?.providers?.claude?.enabled !== false;
|
|
255
|
+
const openai = profile?.providers?.openai?.enabled && profile?.providers?.openai?.plan;
|
|
256
|
+
return { claude, openai, dual: claude && openai };
|
|
257
|
+
}
|