dual-brain 0.2.7 → 0.2.9

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.
@@ -0,0 +1,245 @@
1
+ /**
2
+ * integrity.mjs — State integrity primitives for dual-brain
3
+ *
4
+ * Provides:
5
+ * - atomicWriteJson / readJsonSafe — safe JSON file I/O with schema versioning
6
+ * - acquireLock / releaseLock / withLock — advisory file locks
7
+ * - lockedUpdate — locked atomic read-modify-write
8
+ * - atomicAppend — append-only ledger with lock
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
12
+ import { dirname } from 'node:path';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // 1. Atomic JSON writes
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Write JSON to filePath atomically via a temp file + rename.
20
+ * Adds _schemaVersion and _writtenAt to plain objects.
21
+ *
22
+ * @param {string} filePath - Destination file path
23
+ * @param {*} data - Value to serialize
24
+ * @param {object} opts
25
+ * @param {number} [opts.schemaVersion=1] - Schema version stamped into data
26
+ * @param {boolean}[opts.backup=false] - Keep a .bak copy of the previous file
27
+ */
28
+ export function atomicWriteJson(filePath, data, opts = {}) {
29
+ const { schemaVersion = 1, backup = false } = opts;
30
+
31
+ // Stamp schema version onto plain objects
32
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
33
+ data._schemaVersion = schemaVersion;
34
+ data._writtenAt = new Date().toISOString();
35
+ }
36
+
37
+ const dir = dirname(filePath);
38
+ mkdirSync(dir, { recursive: true });
39
+
40
+ const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
41
+ const json = JSON.stringify(data, null, 2) + '\n';
42
+
43
+ // Write to temp file
44
+ writeFileSync(tmpPath, json);
45
+
46
+ // Validate the temp file is parseable before committing
47
+ try {
48
+ JSON.parse(readFileSync(tmpPath, 'utf8'));
49
+ } catch (err) {
50
+ unlinkSync(tmpPath);
51
+ throw new Error(`atomicWrite: validation failed for ${filePath}: ${err.message}`);
52
+ }
53
+
54
+ // Optionally back up the existing file
55
+ if (backup && existsSync(filePath)) {
56
+ const backupPath = filePath + '.bak';
57
+ try { renameSync(filePath, backupPath); } catch {}
58
+ }
59
+
60
+ // Atomic rename — either fully succeeds or the original is untouched
61
+ renameSync(tmpPath, filePath);
62
+ }
63
+
64
+ /**
65
+ * Read and parse a JSON file safely, with optional schema migration.
66
+ * Falls back to a .bak copy on parse failure.
67
+ * Returns null when the file is absent or unrecoverable.
68
+ *
69
+ * @param {string} filePath
70
+ * @param {object} opts
71
+ * @param {number} [opts.expectedVersion] - Schema version to verify
72
+ * @param {Function}[opts.migrate] - (data, fromVersion, toVersion) => data
73
+ * @returns {*|null}
74
+ */
75
+ export function readJsonSafe(filePath, opts = {}) {
76
+ const { expectedVersion, migrate } = opts;
77
+
78
+ if (!existsSync(filePath)) return null;
79
+
80
+ let data;
81
+ try {
82
+ data = JSON.parse(readFileSync(filePath, 'utf8'));
83
+ } catch {
84
+ // Primary file corrupt — try backup
85
+ const bakPath = filePath + '.bak';
86
+ if (existsSync(bakPath)) {
87
+ try {
88
+ data = JSON.parse(readFileSync(bakPath, 'utf8'));
89
+ } catch { return null; }
90
+ } else {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ // Schema version check with optional migration
96
+ if (expectedVersion !== undefined && data?._schemaVersion !== expectedVersion) {
97
+ if (migrate && typeof migrate === 'function') {
98
+ data = migrate(data, data?._schemaVersion, expectedVersion);
99
+ }
100
+ // Tolerant read: return data even without a migrator
101
+ }
102
+
103
+ return data;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // 2. Advisory file locks
108
+ // ---------------------------------------------------------------------------
109
+
110
+ const LOCK_TIMEOUT_MS = 10_000; // stale lock threshold
111
+ const LOCK_RETRY_MS = 50; // busy-wait interval
112
+ const LOCK_MAX_RETRIES = 100; // max retries (~5 s)
113
+
114
+ /**
115
+ * Acquire an advisory lock for filePath by creating filePath.lock.
116
+ * Stale locks (> LOCK_TIMEOUT_MS old) are cleared automatically.
117
+ *
118
+ * @param {string} filePath
119
+ * @returns {{ acquired: boolean, lockPath: string, reason?: string }}
120
+ */
121
+ export function acquireLock(filePath) {
122
+ const lockPath = filePath + '.lock';
123
+
124
+ // Clear stale or corrupt lock
125
+ if (existsSync(lockPath)) {
126
+ try {
127
+ const lockData = JSON.parse(readFileSync(lockPath, 'utf8'));
128
+ const age = Date.now() - (lockData.createdAt || 0);
129
+ if (age > LOCK_TIMEOUT_MS) {
130
+ unlinkSync(lockPath);
131
+ }
132
+ } catch {
133
+ try { unlinkSync(lockPath); } catch {}
134
+ }
135
+ }
136
+
137
+ // Spin-try to create the lock exclusively
138
+ let retries = 0;
139
+ while (retries < LOCK_MAX_RETRIES) {
140
+ try {
141
+ writeFileSync(lockPath, JSON.stringify({
142
+ pid: process.pid,
143
+ createdAt: Date.now(),
144
+ holder: process.argv[1] || 'unknown',
145
+ }), { flag: 'wx' }); // 'wx' = exclusive create, EEXIST if present
146
+ return { acquired: true, lockPath };
147
+ } catch (err) {
148
+ if (err.code === 'EEXIST') {
149
+ retries++;
150
+ // Synchronous busy-wait — intentional; only triggered under contention
151
+ const start = Date.now();
152
+ while (Date.now() - start < LOCK_RETRY_MS) {}
153
+ continue;
154
+ }
155
+ throw err; // Unexpected error — propagate
156
+ }
157
+ }
158
+
159
+ return { acquired: false, lockPath, reason: 'timeout' };
160
+ }
161
+
162
+ /**
163
+ * Release a previously acquired lock.
164
+ *
165
+ * @param {{ lockPath?: string }} lockResult - Return value of acquireLock
166
+ */
167
+ export function releaseLock(lockResult) {
168
+ if (lockResult?.lockPath) {
169
+ try { unlinkSync(lockResult.lockPath); } catch {}
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Run fn while holding an advisory lock on filePath.
175
+ * Throws if the lock cannot be acquired within the retry window.
176
+ *
177
+ * @param {string} filePath
178
+ * @param {Function} fn
179
+ * @returns {*} Return value of fn
180
+ */
181
+ export function withLock(filePath, fn) {
182
+ const lock = acquireLock(filePath);
183
+ if (!lock.acquired) {
184
+ throw new Error(`Could not acquire lock for ${filePath}: ${lock.reason}`);
185
+ }
186
+ try {
187
+ return fn();
188
+ } finally {
189
+ releaseLock(lock);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Locked atomic read-modify-write.
195
+ * Reads the current JSON, passes it to updateFn, then writes the result.
196
+ * If updateFn returns undefined the file is left unchanged.
197
+ *
198
+ * @param {string} filePath
199
+ * @param {Function} updateFn - (currentData: *|null) => updatedData | undefined
200
+ * @param {object} opts - Forwarded to readJsonSafe and atomicWriteJson
201
+ * @returns {*} Return value of updateFn
202
+ */
203
+ export function lockedUpdate(filePath, updateFn, opts = {}) {
204
+ return withLock(filePath, () => {
205
+ const current = readJsonSafe(filePath, opts);
206
+ const updated = updateFn(current);
207
+ if (updated !== undefined) {
208
+ atomicWriteJson(filePath, updated, opts);
209
+ }
210
+ return updated;
211
+ });
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // 3. Append-only ledger with lock
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /**
219
+ * Append a NDJSON record to filePath under an advisory lock.
220
+ * On lock failure the write is attempted without a lock (best-effort).
221
+ *
222
+ * @param {string} filePath
223
+ * @param {*} record - Value to serialize as one JSON line
224
+ */
225
+ export async function atomicAppend(filePath, record) {
226
+ const { appendFileSync } = await import('node:fs');
227
+ const line = JSON.stringify(record) + '\n';
228
+
229
+ const lock = acquireLock(filePath);
230
+ if (!lock.acquired) {
231
+ // Non-fatal: best-effort append without lock
232
+ try {
233
+ mkdirSync(dirname(filePath), { recursive: true });
234
+ appendFileSync(filePath, line);
235
+ } catch {}
236
+ return;
237
+ }
238
+
239
+ try {
240
+ mkdirSync(dirname(filePath), { recursive: true });
241
+ appendFileSync(filePath, line);
242
+ } finally {
243
+ releaseLock(lock);
244
+ }
245
+ }
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,231 @@
1
+ import { existsSync, mkdirSync, readFileSync, appendFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const AUDIT_DIR = join(process.cwd(), '.dualbrain', 'prompt-audit');
5
+
6
+ /**
7
+ * Score a prompt for quality before sending to a provider.
8
+ * Returns score 0-100 and specific feedback.
9
+ */
10
+ export function scorePrompt(prompt, opts = {}) {
11
+ const { type = 'think', maxTokenBudget = 2000 } = opts;
12
+
13
+ const issues = [];
14
+ const strengths = [];
15
+ let score = 100;
16
+
17
+ // Length efficiency
18
+ const words = prompt.split(/\s+/).length;
19
+ const chars = prompt.length;
20
+
21
+ if (words < 20) {
22
+ issues.push({ rule: 'too-short', msg: 'Prompt under 20 words — likely missing context', penalty: 15 });
23
+ score -= 15;
24
+ }
25
+ if (words > 500) {
26
+ issues.push({ rule: 'too-long', msg: `Prompt is ${words} words — consider trimming`, penalty: 10 });
27
+ score -= 10;
28
+ }
29
+
30
+ // Structure checks
31
+ if (type === 'think') {
32
+ if (!prompt.includes('?')) {
33
+ issues.push({ rule: 'no-question', msg: 'Think prompt has no question mark — unclear what decision is needed', penalty: 10 });
34
+ score -= 10;
35
+ }
36
+ if (!/\b(should|how|what|which|why|when|where|recommend|decide|choose|compare|tradeoff)\b/i.test(prompt)) {
37
+ issues.push({ rule: 'no-decision-language', msg: 'No decision-making language found', penalty: 5 });
38
+ score -= 5;
39
+ }
40
+ if (/\b(at least \d+ ideas|generate.*list|brainstorm)\b/i.test(prompt)) {
41
+ strengths.push('Requests specific output quantity');
42
+ }
43
+ }
44
+
45
+ // Context quality
46
+ if (/\b(this project|the codebase|our system)\b/i.test(prompt) && !/\b(module|file|function|export|import)\b/i.test(prompt)) {
47
+ issues.push({ rule: 'vague-context', msg: 'References "the project" without naming specific modules/files', penalty: 10 });
48
+ score -= 10;
49
+ }
50
+
51
+ if (/\b(src\/\w+|\.mjs|\.js|\.ts)\b/.test(prompt)) {
52
+ strengths.push('Names specific files/modules');
53
+ }
54
+
55
+ // Constraint quality
56
+ if (/\b(at least|minimum|maximum|no more than|ranked|ordered|prioritized)\b/i.test(prompt)) {
57
+ strengths.push('Includes output constraints');
58
+ }
59
+
60
+ // Anti-patterns
61
+ if (/\b(please|could you|would you mind)\b/i.test(prompt)) {
62
+ issues.push({ rule: 'politeness-waste', msg: 'Politeness tokens wasted on AI — be direct', penalty: 2 });
63
+ score -= 2;
64
+ }
65
+
66
+ if (/\b(I think|I believe|maybe|perhaps|possibly)\b/i.test(prompt) && type === 'think') {
67
+ issues.push({ rule: 'hedging', msg: 'Hedging language in think prompt — state positions directly', penalty: 5 });
68
+ score -= 5;
69
+ }
70
+
71
+ // Token efficiency estimate
72
+ const estimatedTokens = Math.ceil(chars / 4);
73
+ const efficiency = Math.min(100, Math.round((words / estimatedTokens) * 100));
74
+
75
+ // Duplication check
76
+ const sentences = prompt.split(/[.!?]+/).filter(s => s.trim().length > 10);
77
+ const unique = new Set(sentences.map(s => s.trim().toLowerCase()));
78
+ if (sentences.length > 3 && unique.size < sentences.length * 0.7) {
79
+ issues.push({ rule: 'repetitive', msg: `${sentences.length - unique.size} near-duplicate sentences`, penalty: 10 });
80
+ score -= 10;
81
+ }
82
+
83
+ score = Math.max(0, Math.min(100, score));
84
+
85
+ return {
86
+ score,
87
+ grade: score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F',
88
+ issues,
89
+ strengths,
90
+ stats: {
91
+ words,
92
+ chars,
93
+ estimatedTokens,
94
+ efficiency,
95
+ sentences: sentences.length,
96
+ },
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Log a prompt exchange for auditing.
102
+ */
103
+ export function logPromptExchange(exchange) {
104
+ const {
105
+ type = 'think',
106
+ round = 1,
107
+ prompt,
108
+ response,
109
+ provider = 'gpt',
110
+ model,
111
+ durationMs,
112
+ promptScore,
113
+ } = exchange;
114
+
115
+ mkdirSync(AUDIT_DIR, { recursive: true });
116
+
117
+ const entry = {
118
+ timestamp: new Date().toISOString(),
119
+ type,
120
+ round,
121
+ provider,
122
+ model,
123
+ durationMs,
124
+ promptScore: promptScore?.score,
125
+ promptGrade: promptScore?.grade,
126
+ promptWords: promptScore?.stats?.words,
127
+ promptTokens: promptScore?.stats?.estimatedTokens,
128
+ responseWords: response ? response.split(/\s+/).length : 0,
129
+ responseTokens: response ? Math.ceil(response.length / 4) : 0,
130
+ issues: promptScore?.issues?.map(i => i.rule) || [],
131
+ };
132
+
133
+ const logFile = join(AUDIT_DIR, 'exchanges.jsonl');
134
+ appendFileSync(logFile, JSON.stringify(entry) + '\n');
135
+
136
+ return entry;
137
+ }
138
+
139
+ /**
140
+ * Get prompt quality statistics over time.
141
+ */
142
+ export function getPromptStats(opts = {}) {
143
+ const { days = 7 } = opts;
144
+ const logFile = join(AUDIT_DIR, 'exchanges.jsonl');
145
+
146
+ if (!existsSync(logFile)) return { available: false };
147
+
148
+ const cutoff = new Date(Date.now() - days * 86400000).toISOString();
149
+ const lines = readFileSync(logFile, 'utf8').trim().split('\n').filter(Boolean);
150
+
151
+ const entries = [];
152
+ for (const line of lines) {
153
+ try {
154
+ const entry = JSON.parse(line);
155
+ if (entry.timestamp >= cutoff) entries.push(entry);
156
+ } catch {}
157
+ }
158
+
159
+ if (entries.length === 0) return { available: true, count: 0 };
160
+
161
+ const avgScore = entries.reduce((s, e) => s + (e.promptScore || 0), 0) / entries.length;
162
+ const avgPromptTokens = entries.reduce((s, e) => s + (e.promptTokens || 0), 0) / entries.length;
163
+ const avgResponseTokens = entries.reduce((s, e) => s + (e.responseTokens || 0), 0) / entries.length;
164
+ const avgDuration = entries.reduce((s, e) => s + (e.durationMs || 0), 0) / entries.length;
165
+
166
+ const grades = {};
167
+ for (const e of entries) {
168
+ grades[e.promptGrade] = (grades[e.promptGrade] || 0) + 1;
169
+ }
170
+
171
+ const commonIssues = {};
172
+ for (const e of entries) {
173
+ for (const issue of (e.issues || [])) {
174
+ commonIssues[issue] = (commonIssues[issue] || 0) + 1;
175
+ }
176
+ }
177
+
178
+ const topIssues = Object.entries(commonIssues)
179
+ .sort((a, b) => b[1] - a[1])
180
+ .slice(0, 5)
181
+ .map(([rule, count]) => ({ rule, count, pct: Math.round(count / entries.length * 100) }));
182
+
183
+ return {
184
+ available: true,
185
+ count: entries.length,
186
+ avgScore: Math.round(avgScore),
187
+ avgPromptTokens: Math.round(avgPromptTokens),
188
+ avgResponseTokens: Math.round(avgResponseTokens),
189
+ avgDurationMs: Math.round(avgDuration),
190
+ totalPromptTokens: entries.reduce((s, e) => s + (e.promptTokens || 0), 0),
191
+ totalResponseTokens: entries.reduce((s, e) => s + (e.responseTokens || 0), 0),
192
+ grades,
193
+ topIssues,
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Suggest improvements for a prompt.
199
+ */
200
+ export function suggestImprovements(prompt, type = 'think') {
201
+ const score = scorePrompt(prompt, { type });
202
+ const suggestions = [];
203
+
204
+ for (const issue of score.issues) {
205
+ switch (issue.rule) {
206
+ case 'too-short':
207
+ suggestions.push('Add context: what modules are involved, what decision is needed, what constraints exist');
208
+ break;
209
+ case 'too-long':
210
+ suggestions.push('Trim: remove background the AI already knows from CLAUDE.md. Focus on what\'s unique to this question');
211
+ break;
212
+ case 'no-question':
213
+ suggestions.push('End with a clear question or decision point');
214
+ break;
215
+ case 'vague-context':
216
+ suggestions.push('Name specific files, functions, or modules instead of "the project"');
217
+ break;
218
+ case 'politeness-waste':
219
+ suggestions.push('Remove "please", "could you" — direct prompts produce better output');
220
+ break;
221
+ case 'hedging':
222
+ suggestions.push('State positions directly — "X is better because Y" not "I think maybe X"');
223
+ break;
224
+ case 'repetitive':
225
+ suggestions.push('Remove duplicate sentences — each sentence should add new information');
226
+ break;
227
+ }
228
+ }
229
+
230
+ return { score, suggestions };
231
+ }