dual-brain 3.9.0 → 4.0.0

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,463 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * vibe-memory.mjs — Durable preference and context memory for vibe coding.
4
+ *
5
+ * Persists user workflow preferences, risk tolerance, and active work context
6
+ * across sessions. Loaded by control-panel and routing hooks.
7
+ *
8
+ * Exports:
9
+ * loadMemory() → memory object
10
+ * updateMemory(key, value) → void
11
+ * recordSessionEnd(summary) → void
12
+ * getActiveThreads() → array of recent work threads
13
+ * inferPreferences() → { suggestions, confidence }
14
+ */
15
+
16
+ import { createHash } from 'crypto';
17
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
18
+ import { dirname, join } from 'path';
19
+ import { fileURLToPath } from 'url';
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const MEMORY_FILE = join(__dirname, '..', 'dual-brain.memory.json');
23
+
24
+ const EMPTY_MEMORY = {
25
+ schema_version: 1,
26
+ preferences: {
27
+ default_profile: null,
28
+ risk_tolerance: 'normal',
29
+ verbosity: 'normal',
30
+ auto_dual_brain: true,
31
+ preferred_provider: null,
32
+ },
33
+ threads: [],
34
+ insights: {
35
+ total_sessions: 0,
36
+ profile_switches: {},
37
+ common_risk_domains: [],
38
+ dual_brain_useful_rate: null,
39
+ },
40
+ };
41
+
42
+ // ─── Helpers ──────────────────────────────────────────────────────────────
43
+
44
+ function atomicWrite(path, data) {
45
+ const tmp = path + '.tmp.' + process.pid;
46
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
47
+ renameSync(tmp, path);
48
+ }
49
+
50
+ function deepMerge(defaults, override) {
51
+ const result = { ...defaults };
52
+ for (const key of Object.keys(defaults)) {
53
+ if (override[key] === undefined) continue;
54
+ if (
55
+ defaults[key] !== null &&
56
+ typeof defaults[key] === 'object' &&
57
+ !Array.isArray(defaults[key]) &&
58
+ typeof override[key] === 'object' &&
59
+ !Array.isArray(override[key]) &&
60
+ override[key] !== null
61
+ ) {
62
+ result[key] = deepMerge(defaults[key], override[key]);
63
+ } else {
64
+ result[key] = override[key];
65
+ }
66
+ }
67
+ // Preserve any extra keys from override not in defaults
68
+ for (const key of Object.keys(override)) {
69
+ if (!(key in defaults)) {
70
+ result[key] = override[key];
71
+ }
72
+ }
73
+ return result;
74
+ }
75
+
76
+ function setNestedKey(obj, dotPath, value) {
77
+ const parts = dotPath.split('.');
78
+ let current = obj;
79
+ for (let i = 0; i < parts.length - 1; i++) {
80
+ const part = parts[i];
81
+ if (current[part] === undefined || typeof current[part] !== 'object' || current[part] === null) {
82
+ current[part] = {};
83
+ }
84
+ current = current[part];
85
+ }
86
+ current[parts[parts.length - 1]] = value;
87
+ }
88
+
89
+ function threadId(summary) {
90
+ return createHash('sha256').update(summary).digest('hex').slice(0, 16);
91
+ }
92
+
93
+ // ─── Core API ─────────────────────────────────────────────────────────────
94
+
95
+ function loadMemory() {
96
+ let stored = {};
97
+ try {
98
+ stored = JSON.parse(readFileSync(MEMORY_FILE, 'utf8'));
99
+ } catch {
100
+ // File missing or corrupt — start fresh
101
+ }
102
+ return deepMerge(EMPTY_MEMORY, stored);
103
+ }
104
+
105
+ function updateMemory(key, value) {
106
+ const memory = loadMemory();
107
+ setNestedKey(memory, key, value);
108
+ atomicWrite(MEMORY_FILE, memory);
109
+ }
110
+
111
+ function recordSessionEnd(summary) {
112
+ const memory = loadMemory();
113
+
114
+ // Increment total sessions
115
+ memory.insights.total_sessions++;
116
+
117
+ // Track profile used
118
+ let profileUsed = 'auto';
119
+ try {
120
+ const profileFile = join(__dirname, '..', 'dual-brain.profile.json');
121
+ const profileData = JSON.parse(readFileSync(profileFile, 'utf8'));
122
+ profileUsed = profileData.active || 'auto';
123
+ } catch {}
124
+
125
+ if (!memory.insights.profile_switches) memory.insights.profile_switches = {};
126
+ memory.insights.profile_switches[profileUsed] =
127
+ (memory.insights.profile_switches[profileUsed] || 0) + 1;
128
+
129
+ // Add/update thread if summary has content
130
+ if (summary && typeof summary === 'object' && summary.description) {
131
+ const desc = summary.description;
132
+ const id = threadId(desc);
133
+ const now = new Date().toISOString();
134
+
135
+ const existingIdx = memory.threads.findIndex(t => t.id === id);
136
+ if (existingIdx >= 0) {
137
+ // Update existing thread
138
+ memory.threads[existingIdx].last_active = now;
139
+ memory.threads[existingIdx].profile_used = profileUsed;
140
+ if (summary.files_touched) {
141
+ const merged = new Set([
142
+ ...(memory.threads[existingIdx].files_touched || []),
143
+ ...summary.files_touched,
144
+ ]);
145
+ memory.threads[existingIdx].files_touched = [...merged];
146
+ }
147
+ if (summary.status) memory.threads[existingIdx].status = summary.status;
148
+ } else {
149
+ // New thread
150
+ memory.threads.push({
151
+ id,
152
+ summary: desc,
153
+ started_at: now,
154
+ last_active: now,
155
+ profile_used: profileUsed,
156
+ files_touched: summary.files_touched || [],
157
+ risk_domains: summary.risk_domains || [],
158
+ status: summary.status || 'active',
159
+ });
160
+ }
161
+
162
+ // Track common risk domains
163
+ if (summary.risk_domains && summary.risk_domains.length > 0) {
164
+ const domainCounts = {};
165
+ for (const d of memory.insights.common_risk_domains || []) {
166
+ domainCounts[d] = (domainCounts[d] || 0) + 1;
167
+ }
168
+ for (const d of summary.risk_domains) {
169
+ domainCounts[d] = (domainCounts[d] || 0) + 1;
170
+ }
171
+ // Keep top domains sorted by frequency
172
+ memory.insights.common_risk_domains = Object.entries(domainCounts)
173
+ .sort((a, b) => b[1] - a[1])
174
+ .slice(0, 10)
175
+ .map(([d]) => d);
176
+ }
177
+ } else if (summary && typeof summary === 'string' && summary.trim()) {
178
+ // Simple string summary — create a basic thread
179
+ const id = threadId(summary);
180
+ const now = new Date().toISOString();
181
+ const existingIdx = memory.threads.findIndex(t => t.id === id);
182
+ if (existingIdx >= 0) {
183
+ memory.threads[existingIdx].last_active = now;
184
+ memory.threads[existingIdx].profile_used = profileUsed;
185
+ } else {
186
+ memory.threads.push({
187
+ id,
188
+ summary,
189
+ started_at: now,
190
+ last_active: now,
191
+ profile_used: profileUsed,
192
+ files_touched: [],
193
+ risk_domains: [],
194
+ status: 'active',
195
+ });
196
+ }
197
+ }
198
+
199
+ // Prune threads older than 7 days, keep max 10
200
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
201
+ memory.threads = memory.threads
202
+ .filter(t => Date.parse(t.last_active) >= sevenDaysAgo)
203
+ .sort((a, b) => Date.parse(b.last_active) - Date.parse(a.last_active))
204
+ .slice(0, 10);
205
+
206
+ atomicWrite(MEMORY_FILE, memory);
207
+ }
208
+
209
+ function getActiveThreads() {
210
+ const memory = loadMemory();
211
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
212
+ return memory.threads
213
+ .filter(t => Date.parse(t.last_active) >= sevenDaysAgo)
214
+ .sort((a, b) => Date.parse(b.last_active) - Date.parse(a.last_active));
215
+ }
216
+
217
+ function inferPreferences() {
218
+ const memory = loadMemory();
219
+ const suggestions = [];
220
+ const switches = memory.insights.profile_switches || {};
221
+ const totalSessions = memory.insights.total_sessions || 0;
222
+
223
+ // Need at least 5 sessions to make suggestions
224
+ if (totalSessions < 5) {
225
+ return { suggestions: [], confidence: 'low' };
226
+ }
227
+
228
+ // Check profile usage pattern
229
+ const totalSwitches = Object.values(switches).reduce((a, b) => a + b, 0);
230
+ if (totalSwitches > 0) {
231
+ for (const [profile, count] of Object.entries(switches)) {
232
+ const pct = (count / totalSwitches) * 100;
233
+ if (pct > 60 && profile !== 'auto') {
234
+ suggestions.push({
235
+ key: 'preferences.default_profile',
236
+ value: profile,
237
+ reason: `You use "${profile}" ${Math.round(pct)}% of the time — consider making it your default.`,
238
+ });
239
+ }
240
+ }
241
+ }
242
+
243
+ // Check risk domain patterns
244
+ const highRiskDomains = ['auth', 'billing', 'secrets', 'migrations', 'security', 'payments'];
245
+ const domains = memory.insights.common_risk_domains || [];
246
+ const highRiskCount = domains.filter(d => highRiskDomains.includes(d)).length;
247
+ if (highRiskCount >= 2 && memory.preferences.risk_tolerance !== 'careful') {
248
+ suggestions.push({
249
+ key: 'preferences.risk_tolerance',
250
+ value: 'careful',
251
+ reason: `You frequently work in high-risk domains (${domains.filter(d => highRiskDomains.includes(d)).join(', ')}) — "careful" mode adds extra review.`,
252
+ });
253
+ }
254
+
255
+ // Check if user works mostly in low-risk areas
256
+ const lowRiskDomains = ['docs', 'tests', 'config', 'styles'];
257
+ const lowRiskCount = domains.filter(d => lowRiskDomains.includes(d)).length;
258
+ if (lowRiskCount >= 2 && highRiskCount === 0 && memory.preferences.risk_tolerance !== 'aggressive') {
259
+ suggestions.push({
260
+ key: 'preferences.risk_tolerance',
261
+ value: 'aggressive',
262
+ reason: `Your work is mostly in low-risk domains (${domains.filter(d => lowRiskDomains.includes(d)).join(', ')}) — "aggressive" skips unnecessary reviews.`,
263
+ });
264
+ }
265
+
266
+ // Determine confidence
267
+ let confidence = 'low';
268
+ if (totalSessions >= 20 && suggestions.length > 0) confidence = 'high';
269
+ else if (totalSessions >= 10 && suggestions.length > 0) confidence = 'medium';
270
+ else if (suggestions.length > 0) confidence = 'low';
271
+
272
+ return { suggestions, confidence };
273
+ }
274
+
275
+ export {
276
+ loadMemory,
277
+ updateMemory,
278
+ recordSessionEnd,
279
+ getActiveThreads,
280
+ inferPreferences,
281
+ };
282
+
283
+ // ─── CLI ──────────────────────────────────────────────────────────────────
284
+
285
+ const noColor = !!process.env.NO_COLOR;
286
+ const e = (code, s) => noColor ? s : `\x1b[${code}m${s}\x1b[0m`;
287
+ const bold = s => e('1', s);
288
+ const dim = s => e('2', s);
289
+ const cyan = s => e('36', s);
290
+ const green = s => e('32', s);
291
+ const yellow = s => e('33', s);
292
+
293
+ function printMemory() {
294
+ const memory = loadMemory();
295
+ console.log('');
296
+ console.log(` ${bold('Vibe Memory')} ${dim(MEMORY_FILE)}`);
297
+ console.log('');
298
+
299
+ console.log(` ${bold('Preferences:')}`);
300
+ for (const [k, v] of Object.entries(memory.preferences)) {
301
+ console.log(` ${k.padEnd(22)} ${v === null ? dim('(auto)') : cyan(String(v))}`);
302
+ }
303
+ console.log('');
304
+
305
+ console.log(` ${bold('Insights:')}`);
306
+ console.log(` total_sessions ${memory.insights.total_sessions}`);
307
+
308
+ const switches = memory.insights.profile_switches || {};
309
+ if (Object.keys(switches).length > 0) {
310
+ const parts = Object.entries(switches).map(([k, v]) => `${k}: ${v}`).join(', ');
311
+ console.log(` profile_switches ${parts}`);
312
+ } else {
313
+ console.log(` profile_switches ${dim('(none)')}`);
314
+ }
315
+
316
+ const domains = memory.insights.common_risk_domains || [];
317
+ console.log(` common_risk_domains ${domains.length > 0 ? domains.join(', ') : dim('(none)')}`);
318
+
319
+ const rate = memory.insights.dual_brain_useful_rate;
320
+ console.log(` dual_brain_useful ${rate !== null ? rate + '%' : dim('(not enough data)')}`);
321
+ console.log('');
322
+
323
+ const threads = getActiveThreads();
324
+ if (threads.length > 0) {
325
+ console.log(` ${bold('Active Threads')} (${threads.length}):`);
326
+ for (const t of threads) {
327
+ const ago = timeAgo(Date.parse(t.last_active));
328
+ const status = t.status === 'completed' ? green('done') : yellow('active');
329
+ console.log(` ${status} ${dim(ago.padEnd(10))} ${t.summary.slice(0, 50)}`);
330
+ if (t.files_touched.length > 0) {
331
+ console.log(` ${dim('files: ' + t.files_touched.slice(0, 3).join(', ') + (t.files_touched.length > 3 ? ` +${t.files_touched.length - 3}` : ''))}`);
332
+ }
333
+ }
334
+ } else {
335
+ console.log(` ${bold('Active Threads:')} ${dim('(none)')}`);
336
+ }
337
+ console.log('');
338
+ }
339
+
340
+ function printThreads() {
341
+ const threads = getActiveThreads();
342
+ console.log('');
343
+ if (threads.length === 0) {
344
+ console.log(` ${dim('No active threads in the last 7 days.')}`);
345
+ console.log('');
346
+ return;
347
+ }
348
+
349
+ console.log(` ${bold('Active Threads')} (last 7 days):`);
350
+ console.log('');
351
+ for (const t of threads) {
352
+ const ago = timeAgo(Date.parse(t.last_active));
353
+ const status = t.status === 'completed' ? green('done') : yellow('active');
354
+ console.log(` ${status} ${bold(t.summary)}`);
355
+ console.log(` ${dim('id:')} ${t.id} ${dim('profile:')} ${t.profile_used} ${dim('last:')} ${ago}`);
356
+ if (t.files_touched.length > 0) {
357
+ console.log(` ${dim('files:')} ${t.files_touched.join(', ')}`);
358
+ }
359
+ if (t.risk_domains.length > 0) {
360
+ console.log(` ${dim('risk:')} ${t.risk_domains.join(', ')}`);
361
+ }
362
+ console.log('');
363
+ }
364
+ }
365
+
366
+ function printInfer() {
367
+ const { suggestions, confidence } = inferPreferences();
368
+ console.log('');
369
+ console.log(` ${bold('Preference Suggestions')} ${dim('confidence: ' + confidence)}`);
370
+ console.log('');
371
+
372
+ if (suggestions.length === 0) {
373
+ const memory = loadMemory();
374
+ if (memory.insights.total_sessions < 5) {
375
+ console.log(` ${dim('Not enough data yet — need at least 5 sessions.')}`);
376
+ console.log(` ${dim(`Current: ${memory.insights.total_sessions} sessions recorded.`)}`);
377
+ } else {
378
+ console.log(` ${dim('No suggestions — your current preferences look good.')}`);
379
+ }
380
+ } else {
381
+ for (const s of suggestions) {
382
+ console.log(` ${yellow('suggestion:')} ${bold(s.key)} = ${cyan(String(s.value))}`);
383
+ console.log(` ${s.reason}`);
384
+ console.log(` ${dim(`Apply: node vibe-memory.mjs --set ${s.key}=${s.value}`)}`);
385
+ console.log('');
386
+ }
387
+ }
388
+ console.log('');
389
+ }
390
+
391
+ function handleSet(arg) {
392
+ const eqIdx = arg.indexOf('=');
393
+ if (eqIdx < 0) {
394
+ console.error(` Invalid --set format. Use: --set key=value`);
395
+ console.error(` Example: --set preferences.risk_tolerance=careful`);
396
+ process.exit(1);
397
+ }
398
+
399
+ const key = arg.slice(0, eqIdx);
400
+ let value = arg.slice(eqIdx + 1);
401
+
402
+ // Parse value types
403
+ if (value === 'null') value = null;
404
+ else if (value === 'true') value = true;
405
+ else if (value === 'false') value = false;
406
+ else if (/^\d+$/.test(value)) value = parseInt(value, 10);
407
+ else if (/^\d+\.\d+$/.test(value)) value = parseFloat(value);
408
+
409
+ updateMemory(key, value);
410
+ console.log(` ${green('updated:')} ${key} = ${value === null ? 'null' : String(value)}`);
411
+ }
412
+
413
+ function timeAgo(ts) {
414
+ const mins = Math.round((Date.now() - ts) / 60000);
415
+ if (mins < 1) return 'just now';
416
+ if (mins < 60) return mins + 'm ago';
417
+ const h = Math.round(mins / 60);
418
+ if (h < 24) return h + 'h ago';
419
+ const d = Math.round(h / 24);
420
+ return d + 'd ago';
421
+ }
422
+
423
+ // ─── CLI Entry ────────────────────────────────────────────────────────────
424
+
425
+ const isMain = process.argv[1] &&
426
+ (process.argv[1].endsWith('vibe-memory.mjs') ||
427
+ process.argv[1].endsWith('vibe-memory'));
428
+
429
+ if (isMain) {
430
+ const args = process.argv.slice(2);
431
+
432
+ if (args.includes('--help') || args.includes('-h')) {
433
+ console.log(`
434
+ vibe-memory.mjs — Durable preference and context memory
435
+
436
+ Usage:
437
+ node vibe-memory.mjs Show current memory
438
+ node vibe-memory.mjs --set preferences.verbosity=quiet Set a preference
439
+ node vibe-memory.mjs --threads Show active threads
440
+ node vibe-memory.mjs --infer Suggest preferences
441
+ `);
442
+ process.exit(0);
443
+ }
444
+
445
+ const setIdx = args.findIndex(a => a.startsWith('--set'));
446
+ if (setIdx >= 0) {
447
+ let setArg = args[setIdx];
448
+ if (setArg === '--set' && args[setIdx + 1]) {
449
+ setArg = args[setIdx + 1];
450
+ } else if (setArg.startsWith('--set=')) {
451
+ setArg = setArg.slice(6);
452
+ } else {
453
+ setArg = setArg.slice(5); // --setkey=value (shouldn't happen, but handle)
454
+ }
455
+ handleSet(setArg);
456
+ } else if (args.includes('--threads')) {
457
+ printThreads();
458
+ } else if (args.includes('--infer')) {
459
+ printInfer();
460
+ } else {
461
+ printMemory();
462
+ }
463
+ }