dual-brain 3.8.1 → 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.
- package/README.md +1 -1
- package/hooks/control-panel.mjs +27 -3
- package/hooks/cost-logger.mjs +2 -3
- package/hooks/enforce-tier.mjs +15 -18
- package/hooks/failure-detector.mjs +15 -1
- package/hooks/plan-generator.mjs +544 -0
- package/hooks/profiles.mjs +35 -4
- package/hooks/test-orchestrator.mjs +67 -3
- package/hooks/vibe-memory.mjs +463 -0
- package/hooks/vibe-router.mjs +262 -0
- package/install.mjs +33 -15
- package/package.json +1 -1
|
@@ -693,8 +693,8 @@ test('enforce-tier: burst mode suppresses duplicate warnings', () => {
|
|
|
693
693
|
|
|
694
694
|
// In burst mode: either no duplicate warning at all, or a [Wave]-prefixed one
|
|
695
695
|
const msg = parsed.systemMessage || '';
|
|
696
|
-
const hasDuplicateWarning = msg.toLowerCase().includes('duplicate');
|
|
697
|
-
if (hasDuplicateWarning && !msg.includes('[Wave]'))
|
|
696
|
+
const hasDuplicateWarning = msg.toLowerCase().includes('duplicate') || msg.toLowerCase().includes('similar task');
|
|
697
|
+
if (hasDuplicateWarning && !msg.includes('[Wave]') && !msg.includes('wave detected'))
|
|
698
698
|
return `expected no duplicate warning or [Wave]-prefixed in burst mode, got: ${msg}`;
|
|
699
699
|
return true;
|
|
700
700
|
} finally {
|
|
@@ -720,7 +720,7 @@ test('enforce-tier: non-burst mode still warns on duplicates', () => {
|
|
|
720
720
|
if (!parsed) return 'no valid JSON output';
|
|
721
721
|
|
|
722
722
|
const msg = parsed.systemMessage || '';
|
|
723
|
-
if (!msg.toLowerCase().includes('duplicate'))
|
|
723
|
+
if (!msg.toLowerCase().includes('similar task') && !msg.toLowerCase().includes('duplicate'))
|
|
724
724
|
return `expected duplicate warning in non-burst mode, got: ${msg || '(empty)'}`;
|
|
725
725
|
return true;
|
|
726
726
|
} finally {
|
|
@@ -1007,6 +1007,70 @@ test('failure decay: pruneOldFailures removes stale entries', () => {
|
|
|
1007
1007
|
}
|
|
1008
1008
|
});
|
|
1009
1009
|
|
|
1010
|
+
// ─── Test 40: adaptive loop end-to-end hash match ─────────────────────────
|
|
1011
|
+
test('adaptive loop: end-to-end hash match', () => {
|
|
1012
|
+
const LEDGER = resolve(HOOKS, 'decision-ledger.jsonl');
|
|
1013
|
+
const backup = existsSync(LEDGER) ? readFileSync(LEDGER, 'utf8') : null;
|
|
1014
|
+
|
|
1015
|
+
try {
|
|
1016
|
+
// Start with a clean ledger so prior failures don't interfere
|
|
1017
|
+
writeFileSync(LEDGER, '', 'utf8');
|
|
1018
|
+
|
|
1019
|
+
// Step 1: Define a specific Agent payload used consistently across all steps
|
|
1020
|
+
const toolInput = { prompt: 'fix the auth bug', description: 'patch auth module' };
|
|
1021
|
+
const agentPayload = JSON.stringify({ tool_name: 'Agent', tool_input: toolInput });
|
|
1022
|
+
|
|
1023
|
+
// Step 2: Run enforce-tier with this payload (computes and may log a promptHash)
|
|
1024
|
+
const firstRun = run(ENFORCE_TIER, agentPayload);
|
|
1025
|
+
if (firstRun.status !== 0) return `first enforce-tier run failed with status: ${firstRun.status}`;
|
|
1026
|
+
if (!firstRun.parsed) return `first enforce-tier run produced no valid JSON`;
|
|
1027
|
+
|
|
1028
|
+
// Step 3: Simulate 2 failures via cost-logger with the SAME tool_input
|
|
1029
|
+
const errorPayload = JSON.stringify({
|
|
1030
|
+
tool_name: 'Agent',
|
|
1031
|
+
tool_input: toolInput,
|
|
1032
|
+
error: 'test failure',
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
const fail1 = runStream(COST_LOGGER, errorPayload);
|
|
1036
|
+
if (fail1.status !== 0) return `first cost-logger failure run failed with status: ${fail1.status}`;
|
|
1037
|
+
|
|
1038
|
+
const fail2 = runStream(COST_LOGGER, errorPayload);
|
|
1039
|
+
if (fail2.status !== 0) return `second cost-logger failure run failed with status: ${fail2.status}`;
|
|
1040
|
+
|
|
1041
|
+
// Verify cost-logger actually wrote failure entries to the ledger
|
|
1042
|
+
if (!existsSync(LEDGER)) return 'ledger file not created after cost-logger failures';
|
|
1043
|
+
const ledgerLines = readFileSync(LEDGER, 'utf8').split('\n').filter(Boolean);
|
|
1044
|
+
const failureEntries = ledgerLines
|
|
1045
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
1046
|
+
.filter(e => e && e.type === 'failure' && e.success === false);
|
|
1047
|
+
if (failureEntries.length < 2)
|
|
1048
|
+
return `expected >= 2 failure entries in ledger, got: ${failureEntries.length}`;
|
|
1049
|
+
|
|
1050
|
+
// Step 4: Run enforce-tier again with the same Agent payload
|
|
1051
|
+
const secondRun = run(ENFORCE_TIER, agentPayload);
|
|
1052
|
+
if (secondRun.status !== 0) return `second enforce-tier run failed with status: ${secondRun.status}`;
|
|
1053
|
+
if (!secondRun.parsed) return `second enforce-tier run produced no valid JSON`;
|
|
1054
|
+
|
|
1055
|
+
// Step 5: The second enforce-tier run should detect the failure loop
|
|
1056
|
+
// and mention escalation or failure loop in its systemMessage
|
|
1057
|
+
const msg = (secondRun.parsed.systemMessage || '').toLowerCase();
|
|
1058
|
+
if (!msg.includes('failure') && !msg.includes('escalat') && !msg.includes('loop') && !msg.includes('dual-brain'))
|
|
1059
|
+
return `expected failure loop / escalation in second enforce-tier systemMessage, got: "${secondRun.parsed.systemMessage || '(empty)'}"`;
|
|
1060
|
+
|
|
1061
|
+
// Bonus: verify the hashes match — the failure entries recorded by cost-logger
|
|
1062
|
+
// should have the same prompt_hash that enforce-tier uses for checkFailureLoop
|
|
1063
|
+
const failureHashes = [...new Set(failureEntries.map(e => e.prompt_hash))];
|
|
1064
|
+
if (failureHashes.length !== 1)
|
|
1065
|
+
return `expected all failure entries to share one hash, got ${failureHashes.length} distinct hashes: ${failureHashes.join(', ')}`;
|
|
1066
|
+
|
|
1067
|
+
return true;
|
|
1068
|
+
} finally {
|
|
1069
|
+
if (backup !== null) writeFileSync(LEDGER, backup, 'utf8');
|
|
1070
|
+
else try { writeFileSync(LEDGER, '', 'utf8'); } catch {}
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1010
1074
|
// ─── Summary ─────────────────────────────────────────────────────────────────
|
|
1011
1075
|
const total = passed + failed;
|
|
1012
1076
|
console.log(`\n${passed}/${total} tests passed`);
|
|
@@ -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
|
+
}
|