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
package/src/head.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { readDiagnosticNoticings } from '../hooks/diagnostic-companion.mjs';
|
|
3
4
|
|
|
4
5
|
const STATE_DIR = join(process.cwd(), '.dualbrain');
|
|
5
6
|
const STATE_FILE = join(STATE_DIR, 'head-state.json');
|
|
@@ -62,9 +63,14 @@ export function assessDepth(signals) {
|
|
|
62
63
|
score += (LEVEL_SCORES[level] || 0) * cfg.weight;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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';
|
|
68
74
|
return 'deep';
|
|
69
75
|
}
|
|
70
76
|
|
|
@@ -123,7 +129,9 @@ function _inferTaskShape(message, context) {
|
|
|
123
129
|
const lower = message.toLowerCase();
|
|
124
130
|
|
|
125
131
|
// Scope: how big is this?
|
|
126
|
-
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
|
|
127
135
|
const scope = fileCount > 5 ? 'large' : fileCount > 2 ? 'medium' : lower.length > 500 ? 'medium' : 'small';
|
|
128
136
|
|
|
129
137
|
// Risk: what could go wrong?
|
|
@@ -148,7 +156,9 @@ function _inferTaskShape(message, context) {
|
|
|
148
156
|
if (/\b(maybe|might|could|should we|not sure|thinking about|what if|somehow)\b/i.test(message)) ambiguitySignals.push('hedging-language');
|
|
149
157
|
if (/\b(or|versus|vs|either|option|alternative)\b/i.test(message)) ambiguitySignals.push('considering-alternatives');
|
|
150
158
|
if (message.split('?').length > 2) ambiguitySignals.push('multiple-questions');
|
|
151
|
-
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');
|
|
152
162
|
|
|
153
163
|
const ambiguity = ambiguitySignals.length >= 2 ? 'high' : ambiguitySignals.length >= 1 ? 'medium' : 'low';
|
|
154
164
|
|
|
@@ -250,78 +260,32 @@ function _assessRelationship(message, context, taskShape) {
|
|
|
250
260
|
* and what would change HEAD's mind.
|
|
251
261
|
*/
|
|
252
262
|
export function assessUncertainty(situation, context = {}) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (situation.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
ledger.push({
|
|
280
|
-
claim: 'I understand what the user actually needs',
|
|
281
|
-
confidence: 0.5,
|
|
282
|
-
basis: `Explicit: "${situation.explicitAsk.slice(0, 80)}" but inferred: "${situation.inferredGoal}"`,
|
|
283
|
-
wouldChangeIf: 'User clarifies their actual goal',
|
|
284
|
-
});
|
|
285
|
-
} else if (situation.taskShape.ambiguity === 'high') {
|
|
286
|
-
ledger.push({
|
|
287
|
-
claim: 'I understand the request',
|
|
288
|
-
confidence: 0.4,
|
|
289
|
-
basis: `Ambiguity signals: ${situation.taskShape.ambiguitySignals.join(', ')}`,
|
|
290
|
-
wouldChangeIf: 'User provides more specific criteria or constraints',
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Prior failure uncertainty
|
|
295
|
-
if (situation.priorFailures >= 1) {
|
|
296
|
-
ledger.push({
|
|
297
|
-
claim: 'The same approach will work this time',
|
|
298
|
-
confidence: Math.max(0.1, 0.8 - (situation.priorFailures * 0.25)),
|
|
299
|
-
basis: `${situation.priorFailures} prior failure(s) on similar task`,
|
|
300
|
-
wouldChangeIf: 'A fundamentally different approach is tried',
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Material safety
|
|
305
|
-
if (situation.material.fragileAreas.length > 0) {
|
|
306
|
-
ledger.push({
|
|
307
|
-
claim: 'Changes won\'t break existing functionality',
|
|
308
|
-
confidence: 0.5,
|
|
309
|
-
basis: `Fragile areas: ${situation.material.fragileAreas.map(a => a.file).join(', ')}`,
|
|
310
|
-
wouldChangeIf: 'Tests exist and pass for the affected areas',
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Context freshness
|
|
315
|
-
if (context.contextAge === 'stale') {
|
|
316
|
-
ledger.push({
|
|
317
|
-
claim: 'My understanding of the codebase is current',
|
|
318
|
-
confidence: 0.3,
|
|
319
|
-
basis: 'Context may be outdated — files could have changed since last read',
|
|
320
|
-
wouldChangeIf: 'Fresh file reads confirm current state',
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
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
|
+
}];
|
|
325
289
|
}
|
|
326
290
|
|
|
327
291
|
/**
|
|
@@ -398,6 +362,30 @@ export function deriveObligations(situation) {
|
|
|
398
362
|
return active;
|
|
399
363
|
}
|
|
400
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
|
+
|
|
401
389
|
// ── Noticings: what HEAD observes passively ─────────────────────────────────
|
|
402
390
|
|
|
403
391
|
/**
|
|
@@ -408,6 +396,25 @@ export function deriveObligations(situation) {
|
|
|
408
396
|
export function notice(situation, state, context = {}) {
|
|
409
397
|
const noticings = [];
|
|
410
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
|
+
|
|
411
418
|
// Drift: are we doing something different from what was discussed?
|
|
412
419
|
if (state.declaredGoal && situation.inferredGoal && state.declaredGoal !== situation.inferredGoal) {
|
|
413
420
|
noticings.push({
|
|
@@ -483,6 +490,34 @@ export function notice(situation, state, context = {}) {
|
|
|
483
490
|
}
|
|
484
491
|
}
|
|
485
492
|
|
|
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
|
+
|
|
508
|
+
// Diagnostic companion: feed tool-call pattern observations into deliberation
|
|
509
|
+
try {
|
|
510
|
+
const diagnosticNoticings = readDiagnosticNoticings();
|
|
511
|
+
for (const dn of diagnosticNoticings) {
|
|
512
|
+
noticings.push({
|
|
513
|
+
type: 'diagnostic',
|
|
514
|
+
severity: dn.severity || 'medium',
|
|
515
|
+
observation: dn.observation,
|
|
516
|
+
shouldSurface: dn.severity === 'high',
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
} catch {}
|
|
520
|
+
|
|
486
521
|
return noticings;
|
|
487
522
|
}
|
|
488
523
|
|
|
@@ -776,6 +811,7 @@ export function freshState() {
|
|
|
776
811
|
declaredGoal: null,
|
|
777
812
|
originalScope: null,
|
|
778
813
|
turns: [],
|
|
814
|
+
dispatches: [], // { ts, type, objective, outcome, durationMs }
|
|
779
815
|
contextEstimate: { messages: 0, estimatedTokens: 0 },
|
|
780
816
|
lastActivity: Date.now(),
|
|
781
817
|
created: Date.now(),
|
|
@@ -788,8 +824,22 @@ export function saveState(state) {
|
|
|
788
824
|
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
789
825
|
}
|
|
790
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
|
+
|
|
791
841
|
// ── Exports ─────────────────────────────────────────────────────────────────
|
|
792
842
|
// Core pipeline: perceive, assessUncertainty, deriveObligations, notice, deliberate
|
|
793
|
-
// Convenience: processTurn, assessDepth, summarizeConfidence
|
|
843
|
+
// Convenience: processTurn, assessDepth, summarizeConfidence, queryRecentTurns
|
|
794
844
|
// State: loadState, freshState, saveState
|
|
795
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);
|