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.
- package/bin/dual-brain.mjs +130 -4
- 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 +532 -0
- package/src/continuity.mjs +6 -6
- 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 +128 -78
- 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,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
|
+
}
|
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 {
|