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/src/head.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { readDiagnosticNoticings } from '../.claude/hooks/diagnostic-companion.mjs';
3
+ import { readDiagnosticNoticings } from '../hooks/diagnostic-companion.mjs';
4
4
 
5
5
  const STATE_DIR = join(process.cwd(), '.dualbrain');
6
6
  const STATE_FILE = join(STATE_DIR, 'head-state.json');
@@ -63,9 +63,14 @@ export function assessDepth(signals) {
63
63
  score += (LEVEL_SCORES[level] || 0) * cfg.weight;
64
64
  }
65
65
 
66
- if (score <= 4) return 'reflexive';
67
- if (score <= 12) return 'light';
68
- if (score <= 22) return 'full';
66
+ // Task type floor: work requests are never reflexive
67
+ const taskType = signals.taskShape?.type || signals.type;
68
+ const isWorkRequest = ['edit', 'debug', 'review', 'research'].includes(taskType);
69
+ if (isWorkRequest && score < 3) score = 3;
70
+
71
+ if (score <= 2) return 'reflexive';
72
+ if (score <= 8) return 'light';
73
+ if (score <= 18) return 'full';
69
74
  return 'deep';
70
75
  }
71
76
 
@@ -124,7 +129,9 @@ function _inferTaskShape(message, context) {
124
129
  const lower = message.toLowerCase();
125
130
 
126
131
  // Scope: how big is this?
127
- const fileCount = context.files?.length || 0;
132
+ const files = context.files || [];
133
+ const dirCount = files.filter(f => f.endsWith('/') || !f.includes('.')).length;
134
+ const fileCount = files.length + (dirCount * 4); // directories imply multiple files
128
135
  const scope = fileCount > 5 ? 'large' : fileCount > 2 ? 'medium' : lower.length > 500 ? 'medium' : 'small';
129
136
 
130
137
  // Risk: what could go wrong?
@@ -149,7 +156,9 @@ function _inferTaskShape(message, context) {
149
156
  if (/\b(maybe|might|could|should we|not sure|thinking about|what if|somehow)\b/i.test(message)) ambiguitySignals.push('hedging-language');
150
157
  if (/\b(or|versus|vs|either|option|alternative)\b/i.test(message)) ambiguitySignals.push('considering-alternatives');
151
158
  if (message.split('?').length > 2) ambiguitySignals.push('multiple-questions');
152
- if (!context.files?.length && /\b(it|this|that|these|those)\b/i.test(message) && !context.recentFiles?.length) ambiguitySignals.push('vague-reference');
159
+ if (!context.files?.length && /\b(it|this|that|these|those)\b/i.test(message) && !context.recentFiles?.length && !/\?\s*$/.test(message.trim())) ambiguitySignals.push('vague-reference');
160
+ if (/\b(everything|all|entire|whole|every)\b/i.test(message)) ambiguitySignals.push('unbounded-scope');
161
+ if (/\b(better|improve|enhance|optimize|clean up)\b/i.test(message) && !context.files?.length) ambiguitySignals.push('vague-goal');
153
162
 
154
163
  const ambiguity = ambiguitySignals.length >= 2 ? 'high' : ambiguitySignals.length >= 1 ? 'medium' : 'low';
155
164
 
@@ -251,78 +260,32 @@ function _assessRelationship(message, context, taskShape) {
251
260
  * and what would change HEAD's mind.
252
261
  */
253
262
  export function assessUncertainty(situation, context = {}) {
254
- const ledger = [];
255
-
256
- // Scope certainty
257
- if (situation.taskShape.scope !== 'small') {
258
- ledger.push({
259
- claim: 'The change is contained to the identified files',
260
- confidence: situation.material.touchedFiles.length > 0 ? 0.7 : 0.3,
261
- basis: situation.material.touchedFiles.length > 0
262
- ? `${situation.material.touchedFiles.length} files identified`
263
- : 'No files explicitly identified scope unknown',
264
- wouldChangeIf: 'Dependency analysis reveals cross-cutting concerns',
265
- });
266
- }
267
-
268
- // Risk assessment certainty
269
- if (situation.taskShape.risk !== 'low') {
270
- ledger.push({
271
- claim: `Risk level is ${situation.taskShape.risk}`,
272
- confidence: situation.taskShape.riskSignals.length >= 2 ? 0.85 : 0.6,
273
- basis: `Signals: ${situation.taskShape.riskSignals.join(', ') || 'none'}`,
274
- wouldChangeIf: 'Closer inspection reveals the risky-looking code is actually isolated/tested',
275
- });
276
- }
277
-
278
- // Goal understanding certainty
279
- if (situation.inferredGoal) {
280
- ledger.push({
281
- claim: 'I understand what the user actually needs',
282
- confidence: 0.5,
283
- basis: `Explicit: "${situation.explicitAsk.slice(0, 80)}" but inferred: "${situation.inferredGoal}"`,
284
- wouldChangeIf: 'User clarifies their actual goal',
285
- });
286
- } else if (situation.taskShape.ambiguity === 'high') {
287
- ledger.push({
288
- claim: 'I understand the request',
289
- confidence: 0.4,
290
- basis: `Ambiguity signals: ${situation.taskShape.ambiguitySignals.join(', ')}`,
291
- wouldChangeIf: 'User provides more specific criteria or constraints',
292
- });
293
- }
294
-
295
- // Prior failure uncertainty
296
- if (situation.priorFailures >= 1) {
297
- ledger.push({
298
- claim: 'The same approach will work this time',
299
- confidence: Math.max(0.1, 0.8 - (situation.priorFailures * 0.25)),
300
- basis: `${situation.priorFailures} prior failure(s) on similar task`,
301
- wouldChangeIf: 'A fundamentally different approach is tried',
302
- });
303
- }
304
-
305
- // Material safety
306
- if (situation.material.fragileAreas.length > 0) {
307
- ledger.push({
308
- claim: 'Changes won\'t break existing functionality',
309
- confidence: 0.5,
310
- basis: `Fragile areas: ${situation.material.fragileAreas.map(a => a.file).join(', ')}`,
311
- wouldChangeIf: 'Tests exist and pass for the affected areas',
312
- });
313
- }
314
-
315
- // Context freshness
316
- if (context.contextAge === 'stale') {
317
- ledger.push({
318
- claim: 'My understanding of the codebase is current',
319
- confidence: 0.3,
320
- basis: 'Context may be outdated — files could have changed since last read',
321
- wouldChangeIf: 'Fresh file reads confirm current state',
322
- });
323
- }
324
-
325
- return ledger;
263
+ let score = 0.8; // default: reasonably confident
264
+ let blocker = null;
265
+ let shouldVerify = false;
266
+
267
+ // Confidence drops
268
+ if (situation.taskShape.ambiguity === 'high') score -= 0.3;
269
+ else if (situation.taskShape.ambiguity === 'medium') score -= 0.1;
270
+
271
+ if (situation.priorFailures >= 2) { score -= 0.3; blocker = 'Repeated failures suggest wrong approach'; }
272
+ else if (situation.priorFailures >= 1) score -= 0.15;
273
+
274
+ if (situation.material.fragileAreas.length > 0) { score -= 0.15; shouldVerify = true; }
275
+ if (situation.taskShape.scope === 'large') { score -= 0.1; shouldVerify = true; }
276
+ if (!situation.material.touchedFiles.length && situation.taskShape.type === 'edit') { score -= 0.2; blocker = blocker || 'No files identified for edit task'; }
277
+ if (context.contextAge === 'stale') score -= 0.2;
278
+ if (situation.inferredGoal) score -= 0.15;
279
+
280
+ score = Math.max(0.1, Math.min(1.0, score));
281
+
282
+ // Return same interface summarizeConfidence produces (so callers don't break)
283
+ return [{
284
+ claim: blocker || 'Task assessment',
285
+ confidence: score,
286
+ basis: `score=${score.toFixed(2)}`,
287
+ wouldChangeIf: blocker ? 'Different approach tried' : 'n/a',
288
+ }];
326
289
  }
327
290
 
328
291
  /**
@@ -399,6 +362,30 @@ export function deriveObligations(situation) {
399
362
  return active;
400
363
  }
401
364
 
365
+ // ── Turn history query ────────────────────────────────────────────────────────
366
+
367
+ export function queryRecentTurns(state, n = 3) {
368
+ if (!state.turns?.length) return { count: 0, lastActions: [], failureStreak: 0, sameActionCount: 0 };
369
+
370
+ const recent = state.turns.slice(-n);
371
+ const lastActions = recent.map(t => t.action);
372
+
373
+ // Detect same action repeated
374
+ const lastAction = lastActions[lastActions.length - 1];
375
+ const sameActionCount = lastActions.filter(a => a === lastAction).length;
376
+
377
+ // Detect failure streak (low confidence runs)
378
+ const failureStreak = [...recent].reverse().findIndex(t => t.confidence > 0.6);
379
+
380
+ return {
381
+ count: state.turns.length,
382
+ lastActions,
383
+ failureStreak: failureStreak === -1 ? recent.length : failureStreak,
384
+ sameActionCount,
385
+ avgConfidence: recent.reduce((s, t) => s + (t.confidence || 0), 0) / recent.length,
386
+ };
387
+ }
388
+
402
389
  // ── Noticings: what HEAD observes passively ─────────────────────────────────
403
390
 
404
391
  /**
@@ -409,6 +396,25 @@ export function deriveObligations(situation) {
409
396
  export function notice(situation, state, context = {}) {
410
397
  const noticings = [];
411
398
 
399
+ // Self-awareness: detect stuck patterns from turn history
400
+ const turnHistory = queryRecentTurns(state);
401
+ if (turnHistory.sameActionCount >= 3) {
402
+ noticings.push({
403
+ type: 'self-awareness',
404
+ severity: 'high',
405
+ observation: `Same action "${turnHistory.lastActions[turnHistory.lastActions.length-1]}" repeated ${turnHistory.sameActionCount} times — may be stuck`,
406
+ shouldSurface: true,
407
+ });
408
+ }
409
+ if (turnHistory.failureStreak >= 2) {
410
+ noticings.push({
411
+ type: 'self-awareness',
412
+ severity: 'medium',
413
+ observation: `${turnHistory.failureStreak} turns with low confidence — consider changing approach`,
414
+ shouldSurface: true,
415
+ });
416
+ }
417
+
412
418
  // Drift: are we doing something different from what was discussed?
413
419
  if (state.declaredGoal && situation.inferredGoal && state.declaredGoal !== situation.inferredGoal) {
414
420
  noticings.push({
@@ -485,6 +491,20 @@ export function notice(situation, state, context = {}) {
485
491
  }
486
492
 
487
493
 
494
+ // Self-awareness: detect repeated dispatch failures
495
+ if (state.dispatches?.length >= 2) {
496
+ const recent = state.dispatches.slice(-3);
497
+ const failures = recent.filter(d => d.outcome === 'failure');
498
+ if (failures.length >= 2) {
499
+ noticings.push({
500
+ type: 'self-awareness',
501
+ severity: 'high',
502
+ observation: `${failures.length} of last ${recent.length} dispatches failed — approach may need to change`,
503
+ shouldSurface: true,
504
+ });
505
+ }
506
+ }
507
+
488
508
  // Diagnostic companion: feed tool-call pattern observations into deliberation
489
509
  try {
490
510
  const diagnosticNoticings = readDiagnosticNoticings();
@@ -791,6 +811,7 @@ export function freshState() {
791
811
  declaredGoal: null,
792
812
  originalScope: null,
793
813
  turns: [],
814
+ dispatches: [], // { ts, type, objective, outcome, durationMs }
794
815
  contextEstimate: { messages: 0, estimatedTokens: 0 },
795
816
  lastActivity: Date.now(),
796
817
  created: Date.now(),
@@ -803,8 +824,22 @@ export function saveState(state) {
803
824
  writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
804
825
  }
805
826
 
827
+ export function recordDispatchOutcome(state, outcome) {
828
+ if (!state.dispatches) state.dispatches = [];
829
+ state.dispatches.push({
830
+ ts: Date.now(),
831
+ type: outcome.type || 'unknown',
832
+ objective: (outcome.objective || '').slice(0, 100),
833
+ outcome: outcome.status || 'unknown', // 'success' | 'failure' | 'partial'
834
+ durationMs: outcome.durationMs || 0,
835
+ });
836
+ // Keep last 10 dispatches only
837
+ if (state.dispatches.length > 10) state.dispatches = state.dispatches.slice(-10);
838
+ saveState(state);
839
+ }
840
+
806
841
  // ── Exports ─────────────────────────────────────────────────────────────────
807
842
  // Core pipeline: perceive, assessUncertainty, deriveObligations, notice, deliberate
808
- // Convenience: processTurn, assessDepth, summarizeConfidence
843
+ // Convenience: processTurn, assessDepth, summarizeConfidence, queryRecentTurns
809
844
  // State: loadState, freshState, saveState
810
845
  // Values: HEAD_VALUES
package/src/inbox.mjs ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * inbox.mjs — Cross-session signal system for dual-brain orchestrator
3
+ *
4
+ * Agents write messages to .dualbrain/inbox/. HEAD and the cognitive loop
5
+ * check the inbox at entry points. Messages have TTL and recipient types.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { randomUUID } from 'node:crypto';
11
+
12
+ const INBOX_DIR = join(process.cwd(), '.dualbrain', 'inbox');
13
+ const INDEX_PATH = join(INBOX_DIR, '_index.json');
14
+ const DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24h
15
+ const MAX_ACTIVE = 50;
16
+ const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
17
+
18
+ function ensureDir() {
19
+ if (!existsSync(INBOX_DIR)) mkdirSync(INBOX_DIR, { recursive: true });
20
+ }
21
+
22
+ function readIndex() {
23
+ try {
24
+ if (existsSync(INDEX_PATH)) return JSON.parse(readFileSync(INDEX_PATH, 'utf8'));
25
+ } catch { /* rebuild */ }
26
+ return rebuildIndex();
27
+ }
28
+
29
+ function rebuildIndex() {
30
+ ensureDir();
31
+ const entries = [];
32
+ for (const f of readdirSync(INBOX_DIR)) {
33
+ if (f === '_index.json' || !f.endsWith('.json')) continue;
34
+ try {
35
+ const msg = JSON.parse(readFileSync(join(INBOX_DIR, f), 'utf8'));
36
+ entries.push({ id: msg.id, to: msg.to, type: msg.type, priority: msg.priority, createdAt: msg.createdAt, expired: Date.now() > msg.createdAt + msg.ttl });
37
+ } catch { /* skip corrupt */ }
38
+ }
39
+ writeFileSync(INDEX_PATH, JSON.stringify(entries, null, 2));
40
+ return entries;
41
+ }
42
+
43
+ function writeIndex(entries) {
44
+ ensureDir();
45
+ writeFileSync(INDEX_PATH, JSON.stringify(entries, null, 2));
46
+ }
47
+
48
+ function readMessage(id) {
49
+ try {
50
+ return JSON.parse(readFileSync(join(INBOX_DIR, `${id}.json`), 'utf8'));
51
+ } catch { return null; }
52
+ }
53
+
54
+ function matchesRecipient(msgTo, recipient) {
55
+ if (msgTo === 'all' || msgTo === recipient) return true;
56
+ if (msgTo === 'worker:*' && recipient.startsWith('worker:')) return true;
57
+ return false;
58
+ }
59
+
60
+ /** Write a message to the inbox. */
61
+ export function send(partial) {
62
+ if (!partial.to || !partial.type || !partial.subject || !partial.body) {
63
+ throw new Error('inbox.send requires: to, type, subject, body');
64
+ }
65
+ ensureDir();
66
+ const msg = {
67
+ id: randomUUID(),
68
+ from: partial.from || 'system',
69
+ to: partial.to,
70
+ type: partial.type,
71
+ priority: partial.priority || 'medium',
72
+ subject: partial.subject,
73
+ body: partial.body,
74
+ ttl: partial.ttl ?? DEFAULT_TTL,
75
+ createdAt: Date.now(),
76
+ readBy: partial.readBy || [],
77
+ relatedFiles: partial.relatedFiles || [],
78
+ tags: partial.tags || [],
79
+ };
80
+ // Enforce cap — purge oldest if over limit
81
+ const index = readIndex();
82
+ const active = index.filter(e => !e.expired);
83
+ if (active.length >= MAX_ACTIVE) {
84
+ const sorted = [...active].sort((a, b) => a.createdAt - b.createdAt);
85
+ const toRemove = sorted.slice(0, active.length - MAX_ACTIVE + 1);
86
+ for (const e of toRemove) {
87
+ try { unlinkSync(join(INBOX_DIR, `${e.id}.json`)); } catch { /* ok */ }
88
+ }
89
+ const removeIds = new Set(toRemove.map(e => e.id));
90
+ const trimmed = index.filter(e => !removeIds.has(e.id));
91
+ trimmed.push({ id: msg.id, to: msg.to, type: msg.type, priority: msg.priority, createdAt: msg.createdAt, expired: false });
92
+ writeIndex(trimmed);
93
+ } else {
94
+ index.push({ id: msg.id, to: msg.to, type: msg.type, priority: msg.priority, createdAt: msg.createdAt, expired: false });
95
+ writeIndex(index);
96
+ }
97
+ writeFileSync(join(INBOX_DIR, `${msg.id}.json`), JSON.stringify(msg, null, 2));
98
+ return msg;
99
+ }
100
+
101
+ /** Read messages for a recipient. */
102
+ export function check(recipient, options = {}) {
103
+ const { unreadOnly = false, types, minPriority, limit } = options;
104
+ const index = readIndex();
105
+ const now = Date.now();
106
+ const minP = minPriority ? PRIORITY_ORDER[minPriority] ?? 3 : 3;
107
+ let results = [];
108
+ for (const entry of index) {
109
+ if (now > entry.createdAt + DEFAULT_TTL) continue; // rough TTL check
110
+ if (!matchesRecipient(entry.to, recipient)) continue;
111
+ if (types && !types.includes(entry.type)) continue;
112
+ if ((PRIORITY_ORDER[entry.priority] ?? 3) > minP) continue;
113
+ const msg = readMessage(entry.id);
114
+ if (!msg) continue;
115
+ if (now > msg.createdAt + msg.ttl) continue; // precise TTL
116
+ if (unreadOnly && msg.readBy.includes(recipient)) continue;
117
+ results.push(msg);
118
+ }
119
+ results.sort((a, b) => (PRIORITY_ORDER[a.priority] ?? 3) - (PRIORITY_ORDER[b.priority] ?? 3) || b.createdAt - a.createdAt);
120
+ if (limit) results = results.slice(0, limit);
121
+ return results;
122
+ }
123
+
124
+ /** Mark a message as read by a specific reader. */
125
+ export function markRead(messageId, reader) {
126
+ const path = join(INBOX_DIR, `${messageId}.json`);
127
+ const msg = readMessage(messageId);
128
+ if (!msg) return;
129
+ if (!msg.readBy.includes(reader)) {
130
+ msg.readBy.push(reader);
131
+ writeFileSync(path, JSON.stringify(msg, null, 2));
132
+ }
133
+ }
134
+
135
+ /** Clean up expired and fully-read messages. */
136
+ export function purge(options = {}) {
137
+ const { purgeRead = false } = options;
138
+ const index = readIndex();
139
+ const now = Date.now();
140
+ let expired = 0, read = 0;
141
+ const keep = [];
142
+ for (const entry of index) {
143
+ const msg = readMessage(entry.id);
144
+ if (!msg) continue;
145
+ if (now > msg.createdAt + msg.ttl) {
146
+ try { unlinkSync(join(INBOX_DIR, `${entry.id}.json`)); } catch { /* ok */ }
147
+ expired++;
148
+ continue;
149
+ }
150
+ if (purgeRead && msg.readBy.length > 0 && msg.to !== 'all') {
151
+ try { unlinkSync(join(INBOX_DIR, `${entry.id}.json`)); } catch { /* ok */ }
152
+ read++;
153
+ continue;
154
+ }
155
+ keep.push(entry);
156
+ }
157
+ writeIndex(keep);
158
+ return { expired, read };
159
+ }
160
+
161
+ /** Produce a concise text summary for prompt injection. */
162
+ export function generateInboxBrief(recipient) {
163
+ const msgs = check(recipient, { unreadOnly: true, limit: 5 });
164
+ if (!msgs.length) return '';
165
+ const lines = [`\u{1F4EC} Inbox (${msgs.length} unread):`];
166
+ let len = lines[0].length;
167
+ for (const m of msgs) {
168
+ const line = `• [${m.priority}] ${m.from !== 'system' ? `From ${m.from}: ` : ''}${m.subject}`;
169
+ if (len + line.length > 480) { lines.push('• ...'); break; }
170
+ lines.push(line);
171
+ len += line.length;
172
+ }
173
+ return lines.join('\n');
174
+ }
175
+
176
+ /** Convenience: send a continuation message when session ends. */
177
+ export function sendContinuation(context) {
178
+ return send({
179
+ from: context.from || 'head',
180
+ to: 'session:next',
181
+ type: 'continuation',
182
+ priority: 'high',
183
+ subject: context.subject || 'Session continuation state',
184
+ body: typeof context.body === 'string' ? context.body : JSON.stringify(context.state || context, null, 2),
185
+ relatedFiles: context.relatedFiles || [],
186
+ tags: ['continuation', ...(context.tags || [])],
187
+ ttl: context.ttl ?? DEFAULT_TTL * 3, // 72h for continuations
188
+ });
189
+ }
190
+
191
+ /** Convenience: check for the most recent unread continuation. */
192
+ export function checkContinuation(reader = 'head') {
193
+ const msgs = check('session:next', { unreadOnly: true, types: ['continuation'], limit: 1 });
194
+ return msgs.length ? msgs[0] : null;
195
+ }
package/src/ledger.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
4
4
  import { join } from 'path';
5
5
 
6
- const LEDGER_PATH = '.dual-brain/ledger.jsonl';
6
+ const LEDGER_PATH = '.dualbrain/ledger.jsonl';
7
7
 
8
8
  function ledgerPath(cwd) {
9
9
  return join(cwd || process.cwd(), LEDGER_PATH);
@@ -19,7 +19,7 @@ function readAllEntries(cwd) {
19
19
 
20
20
  function appendEntry(entry, cwd) {
21
21
  const p = ledgerPath(cwd);
22
- mkdirSync(join(cwd || process.cwd(), '.dual-brain'), { recursive: true });
22
+ mkdirSync(join(cwd || process.cwd(), '.dualbrain'), { recursive: true });
23
23
  writeFileSync(p, JSON.stringify(entry) + '\n', { flag: 'a' });
24
24
  }
25
25
 
@@ -1,11 +1,11 @@
1
- // living-docs.mjs — Living document system for .dual-brain/.
1
+ // living-docs.mjs — Living document system for .dualbrain/.
2
2
  // Manages project.json, vision.md, roadmap.md, state.md, actions.jsonl, decisions.jsonl, checkpoints.jsonl.
3
3
 
4
4
  import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { execSync } from 'node:child_process';
7
7
 
8
- const DIR = '.dual-brain';
8
+ const DIR = '.dualbrain';
9
9
 
10
10
  function docsDir(cwd = process.cwd()) {
11
11
  return join(cwd, DIR);
@@ -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
+ }