dual-brain 0.2.14 → 0.2.16
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 +149 -4
- package/hooks/auto-update-wrapper.mjs +56 -58
- package/hooks/diagnostic-companion.mjs +422 -0
- package/hooks/precompact.mjs +53 -0
- package/hooks/session-end.mjs +122 -0
- package/package.json +26 -2
- package/src/cognitive-loop.mjs +557 -0
- package/src/continuity.mjs +9 -8
- package/src/cost-tracker.mjs +3 -3
- package/src/debrief.mjs +228 -0
- package/src/doctor.mjs +13 -13
- package/src/envelope.mjs +139 -0
- package/src/head-protocol.mjs +128 -0
- package/src/head.mjs +114 -79
- package/src/inbox.mjs +195 -0
- package/src/ledger.mjs +2 -2
- package/src/living-docs.mjs +2 -2
- package/src/memory-tiers.mjs +193 -0
- package/src/narrative.mjs +169 -0
- package/src/predictive.mjs +250 -0
- package/src/provider-context.mjs +2 -2
- package/src/receipt.mjs +2 -2
- package/src/session-lock.mjs +154 -0
- package/src/simmer.mjs +241 -0
- package/src/wave-planner.mjs +294 -0
|
@@ -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
|
+
}
|
package/src/provider-context.mjs
CHANGED
|
@@ -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 .
|
|
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(), '.
|
|
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 = '.
|
|
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, '.
|
|
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 {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// session-lock.mjs — Ensures one active HEAD session at a time.
|
|
2
|
+
// If two shells/chats open, only one owns the cognitive state.
|
|
3
|
+
// The other gets read-only access (can observe but not dispatch).
|
|
4
|
+
//
|
|
5
|
+
// "One ring rules them all" — no split-brain.
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const STATE_DIR = join(process.cwd(), '.dualbrain');
|
|
11
|
+
const LOCK_FILE = join(STATE_DIR, 'session.lock');
|
|
12
|
+
|
|
13
|
+
const STALE_THRESHOLD_MS = 90_000; // 90 seconds without heartbeat = stale
|
|
14
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
let _heartbeatTimer = null;
|
|
17
|
+
let _sessionId = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} LockResult
|
|
21
|
+
* @property {boolean} acquired - Whether this session owns HEAD
|
|
22
|
+
* @property {string} sessionId - This session's ID
|
|
23
|
+
* @property {string|null} existingSession - ID of the session that already holds the lock (if not acquired)
|
|
24
|
+
* @property {string} mode - 'primary' | 'takeover' | 'readonly'
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Attempt to acquire the session lock.
|
|
29
|
+
* - If no lock exists or lock is stale: acquire as primary
|
|
30
|
+
* - If lock is fresh and held by another: return readonly
|
|
31
|
+
*
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
* @param {boolean} opts.force - Force takeover even if existing session is fresh
|
|
34
|
+
* @returns {LockResult}
|
|
35
|
+
*/
|
|
36
|
+
export function acquire({ force = false } = {}) {
|
|
37
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
38
|
+
_sessionId = _generateSessionId();
|
|
39
|
+
|
|
40
|
+
const existing = _readLock();
|
|
41
|
+
|
|
42
|
+
if (!existing) {
|
|
43
|
+
// No lock — claim it
|
|
44
|
+
_writeLock(_sessionId);
|
|
45
|
+
_startHeartbeat();
|
|
46
|
+
return { acquired: true, sessionId: _sessionId, existingSession: null, mode: 'primary' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const age = Date.now() - existing.heartbeat;
|
|
50
|
+
|
|
51
|
+
if (age > STALE_THRESHOLD_MS || force) {
|
|
52
|
+
// Stale or forced takeover
|
|
53
|
+
_writeLock(_sessionId);
|
|
54
|
+
_startHeartbeat();
|
|
55
|
+
return { acquired: true, sessionId: _sessionId, existingSession: existing.sessionId, mode: 'takeover' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Another session is active — go readonly
|
|
59
|
+
return { acquired: false, sessionId: _sessionId, existingSession: existing.sessionId, mode: 'readonly' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Release the session lock (called at session end).
|
|
64
|
+
*/
|
|
65
|
+
export function release() {
|
|
66
|
+
if (_heartbeatTimer) {
|
|
67
|
+
clearInterval(_heartbeatTimer);
|
|
68
|
+
_heartbeatTimer = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const existing = _readLock();
|
|
73
|
+
if (existing && existing.sessionId === _sessionId) {
|
|
74
|
+
unlinkSync(LOCK_FILE);
|
|
75
|
+
}
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if this session currently holds the lock.
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
export function isOwner() {
|
|
84
|
+
const existing = _readLock();
|
|
85
|
+
return existing?.sessionId === _sessionId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get current lock status without modifying it.
|
|
90
|
+
* @returns {{active: boolean, sessionId: string|null, age: number|null}}
|
|
91
|
+
*/
|
|
92
|
+
export function status() {
|
|
93
|
+
const existing = _readLock();
|
|
94
|
+
if (!existing) return { active: false, sessionId: null, age: null };
|
|
95
|
+
return {
|
|
96
|
+
active: (Date.now() - existing.heartbeat) < STALE_THRESHOLD_MS,
|
|
97
|
+
sessionId: existing.sessionId,
|
|
98
|
+
age: Date.now() - existing.heartbeat,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Manually heartbeat (useful if the automatic timer isn't running).
|
|
104
|
+
*/
|
|
105
|
+
export function heartbeat() {
|
|
106
|
+
if (!_sessionId) return;
|
|
107
|
+
const existing = _readLock();
|
|
108
|
+
if (existing && existing.sessionId === _sessionId) {
|
|
109
|
+
_writeLock(_sessionId);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Internal ──────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
function _generateSessionId() {
|
|
116
|
+
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _readLock() {
|
|
120
|
+
try {
|
|
121
|
+
if (!existsSync(LOCK_FILE)) return null;
|
|
122
|
+
return JSON.parse(readFileSync(LOCK_FILE, 'utf8'));
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function _writeLock(sessionId) {
|
|
129
|
+
const lock = {
|
|
130
|
+
sessionId,
|
|
131
|
+
heartbeat: Date.now(),
|
|
132
|
+
pid: process.pid,
|
|
133
|
+
};
|
|
134
|
+
writeFileSync(LOCK_FILE, JSON.stringify(lock));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function _startHeartbeat() {
|
|
138
|
+
if (_heartbeatTimer) clearInterval(_heartbeatTimer);
|
|
139
|
+
_heartbeatTimer = setInterval(() => {
|
|
140
|
+
try {
|
|
141
|
+
const existing = _readLock();
|
|
142
|
+
if (existing && existing.sessionId === _sessionId) {
|
|
143
|
+
_writeLock(_sessionId);
|
|
144
|
+
} else {
|
|
145
|
+
// Someone else took over — stop heartbeating
|
|
146
|
+
clearInterval(_heartbeatTimer);
|
|
147
|
+
_heartbeatTimer = null;
|
|
148
|
+
}
|
|
149
|
+
} catch {}
|
|
150
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
151
|
+
|
|
152
|
+
// Don't keep the process alive just for heartbeats
|
|
153
|
+
if (_heartbeatTimer.unref) _heartbeatTimer.unref();
|
|
154
|
+
}
|