dual-brain 0.2.13 → 0.2.15

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,193 @@
1
+ // memory-tiers.mjs — Hot/Warm/Cold memory with active paging.
2
+ //
3
+ // Hot: loaded every turn (narrative + active simmer). Always in HEAD's context.
4
+ // Warm: loaded on demand (recent debriefs, narrative history, relevant past decisions).
5
+ // Cold: past sessions, archived patterns. Only retrieved when explicitly needed.
6
+ //
7
+ // The paging mechanism: HEAD doesn't decide what to load — this module does,
8
+ // based on what the current situation seems to need.
9
+
10
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import * as narrative from './narrative.mjs';
13
+ import * as simmer from './simmer.mjs';
14
+
15
+ const STATE_DIR = join(process.cwd(), '.dualbrain');
16
+
17
+ /**
18
+ * @typedef {object} MemoryContext
19
+ * @property {string} narrative - Current running narrative
20
+ * @property {string} simmerBrief - What's brewing
21
+ * @property {Array} warmItems - Paged-in warm memory items
22
+ * @property {string} combined - Single string ready to inject into HEAD's context
23
+ */
24
+
25
+ /**
26
+ * Load hot memory — always returned, every turn.
27
+ * This is the minimum context HEAD needs to be "in the song."
28
+ *
29
+ * @returns {{narrative: string, simmerBrief: string, combined: string}}
30
+ */
31
+ export function loadHot() {
32
+ const narr = narrative.load();
33
+ const simmering = simmer.brief();
34
+
35
+ const parts = [];
36
+ if (narr) parts.push(narr);
37
+ if (simmering) parts.push(`[Simmering]\n${simmering}`);
38
+
39
+ return {
40
+ narrative: narr,
41
+ simmerBrief: simmering,
42
+ combined: parts.join('\n\n'),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Load warm memory — contextually relevant items paged in based on signals.
48
+ *
49
+ * @param {object} signals - What the current turn is about
50
+ * @param {string} signals.userMessage - The user's message
51
+ * @param {string[]} signals.files - Files being discussed
52
+ * @param {string} signals.intent - Detected intent (from HEAD's perception)
53
+ * @returns {Array<{source: string, content: string}>}
54
+ */
55
+ export function loadWarm(signals = {}) {
56
+ const items = [];
57
+
58
+ // Recent narrative history if we're resuming or context feels thin
59
+ if (_looksLikeResume(signals.userMessage) || !narrative.load()) {
60
+ const history = narrative.recentHistory(3);
61
+ if (history.length > 0) {
62
+ items.push({
63
+ source: 'narrative-history',
64
+ content: history.map(h => h.text).join('\n---\n'),
65
+ });
66
+ }
67
+ }
68
+
69
+ // Recent debriefs if we're continuing dispatch work
70
+ if (signals.intent === 'dispatch' || signals.intent === 'proceed') {
71
+ const debriefs = _loadRecentDebriefs(3);
72
+ if (debriefs.length > 0) {
73
+ items.push({
74
+ source: 'recent-debriefs',
75
+ content: debriefs.map(d => `[${d.status}] ${d.objective || d.summary || ''}`).join('\n'),
76
+ });
77
+ }
78
+ }
79
+
80
+ // Routing decisions if making a new routing choice
81
+ if (signals.intent === 'route' || signals.intent === 'dispatch') {
82
+ const decisions = _loadRecentDecisions(5);
83
+ if (decisions.length > 0) {
84
+ items.push({
85
+ source: 'routing-history',
86
+ content: decisions.map(d => `${d.provider}/${d.model}: ${d.reason || ''}`).join('\n'),
87
+ });
88
+ }
89
+ }
90
+
91
+ return items;
92
+ }
93
+
94
+ /**
95
+ * Load cold memory — only when explicitly requested or when signals strongly indicate need.
96
+ *
97
+ * @param {string} query - What we're looking for
98
+ * @returns {Array<{source: string, content: string}>}
99
+ */
100
+ export function loadCold(query) {
101
+ const items = [];
102
+
103
+ // Search past handoffs (from continuity.mjs)
104
+ const handoffs = _searchHandoffs(query);
105
+ if (handoffs.length > 0) {
106
+ items.push({
107
+ source: 'past-sessions',
108
+ content: handoffs.map(h => `[${h.timestamp}] ${h.task || ''}: ${h.resumeHint || ''}`).join('\n'),
109
+ });
110
+ }
111
+
112
+ return items;
113
+ }
114
+
115
+ /**
116
+ * Full context assembly — combines hot + warm based on current signals.
117
+ * This is what gets injected into HEAD's turn context.
118
+ *
119
+ * @param {object} signals
120
+ * @returns {MemoryContext}
121
+ */
122
+ export function assemble(signals = {}) {
123
+ const hot = loadHot();
124
+ const warm = loadWarm(signals);
125
+
126
+ const parts = [];
127
+ if (hot.combined) parts.push(hot.combined);
128
+
129
+ if (warm.length > 0) {
130
+ const warmText = warm.map(w => `[${w.source}]\n${w.content}`).join('\n\n');
131
+ parts.push(warmText);
132
+ }
133
+
134
+ return {
135
+ narrative: hot.narrative,
136
+ simmerBrief: hot.simmerBrief,
137
+ warmItems: warm,
138
+ combined: parts.join('\n\n---\n\n'),
139
+ };
140
+ }
141
+
142
+ // ── Internal helpers ──────────────────────────────────────────────────────────
143
+
144
+ function _looksLikeResume(msg) {
145
+ if (!msg) return false;
146
+ const lower = msg.toLowerCase();
147
+ return /continue|where were we|pick up|resume|what's next|whats next/.test(lower);
148
+ }
149
+
150
+ function _loadRecentDebriefs(n) {
151
+ try {
152
+ const loopFile = join(STATE_DIR, 'cognitive-loop.json');
153
+ if (!existsSync(loopFile)) return [];
154
+ const loop = JSON.parse(readFileSync(loopFile, 'utf8'));
155
+ return (loop.debriefs || []).slice(-n);
156
+ } catch {
157
+ return [];
158
+ }
159
+ }
160
+
161
+ function _loadRecentDecisions(n) {
162
+ try {
163
+ const file = join(STATE_DIR, 'decisions.jsonl');
164
+ if (!existsSync(file)) return [];
165
+ const lines = readFileSync(file, 'utf8').trim().split('\n').filter(Boolean);
166
+ return lines.slice(-n).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
167
+ } catch {
168
+ return [];
169
+ }
170
+ }
171
+
172
+ function _searchHandoffs(query) {
173
+ try {
174
+ const handoffDir = join(STATE_DIR, 'handoffs');
175
+ if (!existsSync(handoffDir)) return [];
176
+ const files = readdirSync(handoffDir).filter(f => f.endsWith('.json')).slice(-10);
177
+ const results = [];
178
+ const queryLower = query.toLowerCase();
179
+
180
+ for (const f of files) {
181
+ try {
182
+ const data = JSON.parse(readFileSync(join(handoffDir, f), 'utf8'));
183
+ const text = JSON.stringify(data).toLowerCase();
184
+ if (text.includes(queryLower)) {
185
+ results.push(data);
186
+ }
187
+ } catch {}
188
+ }
189
+ return results.slice(-3);
190
+ } catch {
191
+ return [];
192
+ }
193
+ }
@@ -0,0 +1,169 @@
1
+ // narrative.mjs — HEAD's running narrative: prose it writes to itself between turns.
2
+ // Not structured data. A paragraph or two that captures where we are, what just
3
+ // happened, what's brewing. Loaded at the top of each turn so HEAD is immediately
4
+ // "in the song" without reconstructing from scattered JSON.
5
+
6
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ const STATE_DIR = join(process.cwd(), '.dualbrain');
10
+ const NARRATIVE_FILE = join(STATE_DIR, 'narrative.md');
11
+ const NARRATIVE_HISTORY = join(STATE_DIR, 'narrative-history.jsonl');
12
+
13
+ const MAX_NARRATIVE_LENGTH = 2000;
14
+ const MAX_HISTORY_ENTRIES = 20;
15
+
16
+ /**
17
+ * Load the current running narrative. Returns empty string if none exists.
18
+ * This is meant to be injected at the top of HEAD's context each turn.
19
+ */
20
+ export function load() {
21
+ try {
22
+ if (existsSync(NARRATIVE_FILE)) {
23
+ return readFileSync(NARRATIVE_FILE, 'utf8').trim();
24
+ }
25
+ } catch {}
26
+ return '';
27
+ }
28
+
29
+ /**
30
+ * Write a new narrative, replacing the old one.
31
+ * The old narrative is archived to history before overwrite.
32
+ *
33
+ * @param {string} prose - HEAD's current understanding in prose form.
34
+ * Should answer: Where are we? What just happened? What's brewing?
35
+ * What did the user care about? What should I not forget?
36
+ */
37
+ export function write(prose) {
38
+ if (!prose || typeof prose !== 'string') return;
39
+
40
+ const trimmed = prose.slice(0, MAX_NARRATIVE_LENGTH).trim();
41
+ if (!trimmed) return;
42
+
43
+ mkdirSync(STATE_DIR, { recursive: true });
44
+
45
+ // Archive current before overwriting
46
+ const current = load();
47
+ if (current) {
48
+ _appendHistory(current);
49
+ }
50
+
51
+ writeFileSync(NARRATIVE_FILE, trimmed + '\n');
52
+ }
53
+
54
+ /**
55
+ * Evolve the narrative — append new observations without replacing everything.
56
+ * Used after dispatches return, after user says something illuminating,
57
+ * or after a wave completes.
58
+ *
59
+ * @param {string} addition - New prose to weave into the narrative.
60
+ * @param {object} opts
61
+ * @param {boolean} opts.replace - If true, replace entirely instead of appending.
62
+ */
63
+ export function evolve(addition, { replace = false } = {}) {
64
+ if (!addition || typeof addition !== 'string') return;
65
+
66
+ if (replace) {
67
+ write(addition);
68
+ return;
69
+ }
70
+
71
+ const current = load();
72
+ const combined = current
73
+ ? current + '\n\n' + addition.trim()
74
+ : addition.trim();
75
+
76
+ // If too long, keep the newest portion (recency bias for immersion)
77
+ const final = combined.length > MAX_NARRATIVE_LENGTH
78
+ ? combined.slice(-MAX_NARRATIVE_LENGTH)
79
+ : combined;
80
+
81
+ mkdirSync(STATE_DIR, { recursive: true });
82
+ writeFileSync(NARRATIVE_FILE, final.trim() + '\n');
83
+ }
84
+
85
+ /**
86
+ * Generate a narrative excerpt suitable for a dispatch envelope.
87
+ * Shorter than the full narrative — just enough context for a worker
88
+ * to understand the "why" without the full stream of consciousness.
89
+ *
90
+ * @param {number} maxLength - Max chars for the excerpt (default 500)
91
+ * @returns {string}
92
+ */
93
+ export function excerpt(maxLength = 500) {
94
+ const full = load();
95
+ if (!full) return '';
96
+ if (full.length <= maxLength) return full;
97
+
98
+ // Take the last N chars — most recent context is most relevant for workers
99
+ const trimmed = full.slice(-maxLength);
100
+ // Find the first sentence boundary to avoid mid-thought cuts
101
+ const firstPeriod = trimmed.indexOf('. ');
102
+ if (firstPeriod > 0 && firstPeriod < maxLength * 0.4) {
103
+ return trimmed.slice(firstPeriod + 2);
104
+ }
105
+ return trimmed;
106
+ }
107
+
108
+ /**
109
+ * Get recent narrative history entries (for warm memory tier).
110
+ * @param {number} n - Number of recent entries to retrieve
111
+ * @returns {Array<{ts: number, text: string}>}
112
+ */
113
+ export function recentHistory(n = 5) {
114
+ try {
115
+ if (!existsSync(NARRATIVE_HISTORY)) return [];
116
+ const lines = readFileSync(NARRATIVE_HISTORY, 'utf8').trim().split('\n').filter(Boolean);
117
+ return lines.slice(-n).map(line => {
118
+ try { return JSON.parse(line); } catch { return null; }
119
+ }).filter(Boolean);
120
+ } catch {
121
+ return [];
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Persist the current narrative for precompact survival.
127
+ * Called by the precompact hook before context compression.
128
+ * Returns the narrative that was persisted (for confirmation).
129
+ */
130
+ export function persist() {
131
+ const current = load();
132
+ if (!current) return '';
133
+ // Narrative is already on disk — this just ensures it's fresh
134
+ // and archives a snapshot with explicit "precompact" marker
135
+ _appendHistory(current, { reason: 'precompact' });
136
+ return current;
137
+ }
138
+
139
+ /**
140
+ * Clear the narrative (used in testing or session reset).
141
+ */
142
+ export function clear() {
143
+ try {
144
+ if (existsSync(NARRATIVE_FILE)) {
145
+ writeFileSync(NARRATIVE_FILE, '');
146
+ }
147
+ } catch {}
148
+ }
149
+
150
+ // ── Internal ──────────────────────────────────────────────────────────────────
151
+
152
+ function _appendHistory(text, meta = {}) {
153
+ try {
154
+ const entry = JSON.stringify({ ts: Date.now(), text: text.slice(0, 800), ...meta });
155
+ mkdirSync(STATE_DIR, { recursive: true });
156
+
157
+ // Cap history file
158
+ let existing = '';
159
+ if (existsSync(NARRATIVE_HISTORY)) {
160
+ existing = readFileSync(NARRATIVE_HISTORY, 'utf8');
161
+ const lines = existing.trim().split('\n').filter(Boolean);
162
+ if (lines.length >= MAX_HISTORY_ENTRIES) {
163
+ existing = lines.slice(-MAX_HISTORY_ENTRIES + 1).join('\n') + '\n';
164
+ }
165
+ }
166
+
167
+ writeFileSync(NARRATIVE_HISTORY, existing + entry + '\n');
168
+ } catch {}
169
+ }
@@ -0,0 +1,250 @@
1
+ /** Predictive Dispatch — Layer 3: anticipates failure modes BEFORE dispatching. */
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ const DIAG_DIR = join(process.cwd(), '.dualbrain', 'diagnostic');
6
+ const STATE_PATH = join(DIAG_DIR, 'current.json');
7
+ const WEIGHTS_PATH = join(DIAG_DIR, 'pattern-weights.json');
8
+
9
+ export function loadSessionPatterns() {
10
+ const empty = { frequencies: [], avgSeverity: 0, precedingTools: {}, timeTrends: [] };
11
+ if (!existsSync(STATE_PATH)) return empty;
12
+
13
+ let state;
14
+ try { state = JSON.parse(readFileSync(STATE_PATH, 'utf8')); }
15
+ catch { return empty; }
16
+
17
+ const noticings = state.noticings || [];
18
+ if (!noticings.length) return empty;
19
+
20
+ const counts = {};
21
+ const severityMap = { low: 1, medium: 2, high: 3 };
22
+ let totalSeverity = 0;
23
+ for (const n of noticings) {
24
+ counts[n.type] = (counts[n.type] || 0) + 1;
25
+ totalSeverity += severityMap[n.severity] || 1;
26
+ }
27
+ const frequencies = Object.entries(counts)
28
+ .map(([type, count]) => ({ type, count }))
29
+ .sort((a, b) => b.count - a.count);
30
+
31
+ const toolCalls = state.toolCalls || [];
32
+ const precedingTools = {};
33
+ for (const n of noticings) {
34
+ const before = toolCalls.filter(tc => tc.ts < n.ts).slice(-3).map(tc => tc.tool);
35
+ if (!precedingTools[n.type]) precedingTools[n.type] = [];
36
+ precedingTools[n.type].push(...before);
37
+ }
38
+
39
+ const sessionStart = state.startedAt || noticings[0]?.ts || 0;
40
+ const sessionEnd = state.lastActivity || noticings[noticings.length - 1]?.ts || Date.now();
41
+ const duration = sessionEnd - sessionStart || 1;
42
+ const timeTrends = noticings.map(n => ({
43
+ type: n.type, position: (n.ts - sessionStart) / duration,
44
+ }));
45
+
46
+ return { frequencies, avgSeverity: totalSeverity / noticings.length, precedingTools, timeTrends };
47
+ }
48
+
49
+ function loadWeights() {
50
+ if (!existsSync(WEIGHTS_PATH)) return {};
51
+ try { return JSON.parse(readFileSync(WEIGHTS_PATH, 'utf8')); }
52
+ catch { return {}; }
53
+ }
54
+
55
+ export function predictFailureModes(agentSpec, context = {}) {
56
+ const predictions = [];
57
+ const patterns = context.patterns || loadSessionPatterns();
58
+ const weights = loadWeights();
59
+ const objective = agentSpec.objective || '';
60
+ const scope = agentSpec.scope || {};
61
+ const files = scope.files || [];
62
+ const tier = agentSpec.tier || '';
63
+ const priorFailures = context.priorFailures || [];
64
+ const activeWaves = context.activeWaves || [];
65
+ const lastReadAge = context.lastReadAge || 0; // ms
66
+
67
+ const applyWeight = (mode, base) => {
68
+ const w = weights[mode];
69
+ if (!w) return base;
70
+ // Shift likelihood based on historical accuracy
71
+ return Math.min(1, Math.max(0, base + (w.accuracy - 0.5) * 0.3));
72
+ };
73
+
74
+ // scope-explosion
75
+ const multiTarget = files.length > 3 || /\band\b|multiple|several|all/i.test(objective);
76
+ const priorScopeCreep = patterns.frequencies.some(f => f.type === 'scope-creep' && f.count >= 2);
77
+ if (multiTarget || priorScopeCreep) {
78
+ const base = multiTarget && priorScopeCreep ? 0.8 : 0.7;
79
+ predictions.push({
80
+ mode: 'scope-explosion',
81
+ likelihood: applyWeight('scope-explosion', base),
82
+ basis: multiTarget
83
+ ? `Objective references ${files.length || 'multiple'} targets`
84
+ : 'Prior waves encountered scope creep',
85
+ prevention: `If scope grows beyond ${Math.max(files.length, 3)} files, STOP and report back rather than continuing.`,
86
+ });
87
+ }
88
+
89
+ // missing-context
90
+ const noFiles = files.length === 0;
91
+ const executeWithoutSearch = tier === 'execute' && !(context.priorSearchWave);
92
+ if (noFiles || executeWithoutSearch) {
93
+ const base = 0.6;
94
+ predictions.push({
95
+ mode: 'missing-context',
96
+ likelihood: applyWeight('missing-context', base),
97
+ basis: noFiles
98
+ ? 'No files specified in scope — agent may waste tokens searching'
99
+ : 'Execute tier dispatched without prior search wave',
100
+ prevention: `Read the current state of target files before making changes. If unsure what to modify, list candidates first.`,
101
+ });
102
+ }
103
+
104
+ // wrong-approach
105
+ const hasStuckLoop = patterns.frequencies.some(f => f.type === 'stuck-loop' && f.count >= 2);
106
+ const objectiveFailed = priorFailures.some(f =>
107
+ f.objective && objective.toLowerCase().includes(f.objective.toLowerCase().slice(0, 20))
108
+ );
109
+ if (hasStuckLoop || objectiveFailed) {
110
+ const base = objectiveFailed ? 0.8 : 0.6;
111
+ predictions.push({
112
+ mode: 'wrong-approach',
113
+ likelihood: applyWeight('wrong-approach', base),
114
+ basis: objectiveFailed
115
+ ? 'Prior attempt at this objective failed'
116
+ : 'Diagnostic shows stuck-loop pattern in session',
117
+ prevention: `Try a fundamentally different approach than previous attempts. If stuck after 2 tries, report back with what was tried.`,
118
+ });
119
+ }
120
+
121
+ // blocked-dependency
122
+ const refsOtherWave = activeWaves.some(w =>
123
+ objective.toLowerCase().includes(w.output?.toLowerCase()?.slice(0, 20) || '___none')
124
+ );
125
+ if (refsOtherWave) {
126
+ predictions.push({
127
+ mode: 'blocked-dependency',
128
+ likelihood: applyWeight('blocked-dependency', 0.5),
129
+ basis: 'Objective references output from an in-progress wave',
130
+ prevention: `If blocked by a dependency from another wave, pivot to independent work or report the blocker.`,
131
+ });
132
+ }
133
+
134
+ // stale-assumption
135
+ if (lastReadAge > 5 * 60 * 1000 && files.length > 0) {
136
+ predictions.push({
137
+ mode: 'stale-assumption',
138
+ likelihood: applyWeight('stale-assumption', 0.4),
139
+ basis: `Files in scope were last read ${Math.round(lastReadAge / 60000)}m ago — may have changed`,
140
+ prevention: `Re-read ${files.slice(0, 3).join(', ')} before assuming current state.`,
141
+ });
142
+ }
143
+
144
+ return predictions;
145
+ }
146
+
147
+ export function generatePreventions(predictions) {
148
+ const actionable = predictions
149
+ .filter(p => p.likelihood >= 0.4)
150
+ .sort((a, b) => b.likelihood - a.likelihood)
151
+ .slice(0, 5);
152
+
153
+ if (!actionable.length) return '';
154
+
155
+ const lines = actionable.map(p => `- [${p.prevention}]`);
156
+ return `⚠ Pre-flight awareness:\n${lines.join('\n')}`;
157
+ }
158
+
159
+ export function scoreDispatchReadiness(agentSpec, wavePlan = {}, predictions = []) {
160
+ const blockers = [];
161
+ const warnings = [];
162
+ let score = 1.0;
163
+
164
+ const files = agentSpec.scope?.files || [];
165
+ const priorSearch = wavePlan.completedSearchWave || false;
166
+ const hasContext = files.length > 0 || priorSearch;
167
+
168
+ // Scope researched?
169
+ if (!hasContext) {
170
+ blockers.push('No files in scope and no prior search wave — context unknown');
171
+ score -= 0.3;
172
+ }
173
+
174
+ // High-likelihood predictions
175
+ const highRisk = predictions.filter(p => p.likelihood >= 0.7);
176
+ const medRisk = predictions.filter(p => p.likelihood >= 0.4 && p.likelihood < 0.7);
177
+
178
+ for (const p of highRisk) {
179
+ blockers.push(`High-risk: ${p.mode} (${Math.round(p.likelihood * 100)}%) — ${p.basis}`);
180
+ score -= 0.25;
181
+ }
182
+ for (const p of medRisk) {
183
+ warnings.push(`${p.mode} (${Math.round(p.likelihood * 100)}%) — ${p.basis}`);
184
+ score -= 0.1;
185
+ }
186
+
187
+ // Unresolved blockers from prior waves
188
+ const unresolvedBlockers = wavePlan.unresolvedBlockers || [];
189
+ for (const b of unresolvedBlockers) {
190
+ blockers.push(`Unresolved from prior wave: ${b}`);
191
+ score -= 0.2;
192
+ }
193
+
194
+ score = Math.max(0, Math.min(1, score));
195
+ const ready = score >= 0.5 && blockers.length === 0;
196
+
197
+ let suggestion = '';
198
+ if (!ready) {
199
+ if (blockers.some(b => b.includes('context unknown'))) {
200
+ suggestion = 'Run a search/read wave first to establish context before executing.';
201
+ } else if (highRisk.length) {
202
+ suggestion = `Address high-risk predictions: ${highRisk.map(p => p.mode).join(', ')}. Add preventions to prompt or restructure scope.`;
203
+ } else {
204
+ suggestion = 'Resolve listed blockers before dispatching.';
205
+ }
206
+ }
207
+
208
+ return { ready, score, blockers, warnings, suggestion };
209
+ }
210
+
211
+ export function evolvePatterns(newDebrief, predictions) {
212
+ const weights = loadWeights();
213
+
214
+ for (const pred of predictions) {
215
+ if (!weights[pred.mode]) {
216
+ weights[pred.mode] = { correct: 0, incorrect: 0, accuracy: 0.5 };
217
+ }
218
+
219
+ const w = weights[pred.mode];
220
+ const occurred = debriefContainsPattern(newDebrief, pred.mode);
221
+
222
+ if (occurred && pred.likelihood >= 0.4) {
223
+ w.correct++;
224
+ } else if (!occurred && pred.likelihood >= 0.4) {
225
+ w.incorrect++;
226
+ } else if (occurred && pred.likelihood < 0.4) {
227
+ // Missed prediction — we should have predicted higher
228
+ w.incorrect++;
229
+ }
230
+
231
+ const total = w.correct + w.incorrect;
232
+ w.accuracy = total > 0 ? w.correct / total : 0.5;
233
+ }
234
+
235
+ mkdirSync(DIAG_DIR, { recursive: true });
236
+ writeFileSync(WEIGHTS_PATH, JSON.stringify(weights, null, 2));
237
+ }
238
+
239
+ function debriefContainsPattern(debrief, mode) {
240
+ if (!debrief) return false;
241
+ const modeMap = {
242
+ 'scope-explosion': ['scope-creep', 'scope_explosion', 'expanded'],
243
+ 'missing-context': ['missing-context', 'no_context', 'searched'],
244
+ 'wrong-approach': ['stuck-loop', 'wrong_approach', 'retry'],
245
+ 'blocked-dependency': ['blocked', 'dependency', 'waiting'],
246
+ 'stale-assumption': ['stale', 'outdated', 'changed'],
247
+ };
248
+ const text = JSON.stringify([...(debrief.issues || []), ...(debrief.patterns || [])]).toLowerCase();
249
+ return (modeMap[mode] || [mode]).some(k => text.includes(k));
250
+ }
@@ -102,7 +102,7 @@ export function buildSurvivalBlock(provider, state) {
102
102
  /**
103
103
  * Generate a handoff receipt that works for both providers.
104
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.
105
+ * to a shared .dualbrain/handoffs/ directory that both providers can read.
106
106
  */
107
107
  export function generateProviderHandoff(sessionState, provider) {
108
108
  const caps = getProviderCaps(provider);
@@ -136,7 +136,7 @@ export function generateProviderHandoff(sessionState, provider) {
136
136
  * Codex gets a more verbose brief since it has no native session memory.
137
137
  */
138
138
  export function buildProviderResumeBrief(cwd, targetProvider) {
139
- const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
139
+ const dir = join(cwd || process.cwd(), '.dualbrain', 'handoffs');
140
140
  if (!existsSync(dir)) return null;
141
141
 
142
142
  const files = readdirSync(dir)
package/src/receipt.mjs CHANGED
@@ -111,7 +111,7 @@ export function formatFailureReceipt(receipt, failureContext) {
111
111
 
112
112
  // ─── Persistent session receipt ──────────────────────────────────────────────
113
113
 
114
- const RECEIPTS_DIR = '.dual-brain/receipts';
114
+ const RECEIPTS_DIR = '.dualbrain/receipts';
115
115
 
116
116
  function receiptsDir(cwd) {
117
117
  return join(cwd, RECEIPTS_DIR);
@@ -130,7 +130,7 @@ function gitChangedFiles(cwd) {
130
130
 
131
131
  function readDecisionsRecent(cwd, limit = 5) {
132
132
  try {
133
- const raw = readFileSync(join(cwd, '.dual-brain', 'decisions.jsonl'), 'utf8');
133
+ const raw = readFileSync(join(cwd, '.dualbrain', 'decisions.jsonl'), 'utf8');
134
134
  const lines = raw.split('\n').filter(l => l.trim());
135
135
  return lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
136
136
  } catch {