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
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 '
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (situation.
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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 = '.
|
|
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(), '.
|
|
22
|
+
mkdirSync(join(cwd || process.cwd(), '.dualbrain'), { recursive: true });
|
|
23
23
|
writeFileSync(p, JSON.stringify(entry) + '\n', { flag: 'a' });
|
|
24
24
|
}
|
|
25
25
|
|
package/src/living-docs.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// living-docs.mjs — Living document system for .
|
|
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 = '.
|
|
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
|
+
}
|