dual-brain 0.1.7 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
package/src/decide.mjs CHANGED
@@ -6,21 +6,40 @@
6
6
  * to use and explains why in one sentence.
7
7
  *
8
8
  * Exports: decideRoute, getModelCapabilities, getAvailableModels,
9
- * estimateBudgetPressure, shouldDualBrain, explainDecision, getFailoverOrder
9
+ * estimateBudgetPressure, shouldDualBrain, explainDecision, getFailoverOrder,
10
+ * getOptimalSub
10
11
  *
11
12
  * CLI: node src/decide.mjs --profile /path/to/profile.json \
12
13
  * --detection '{"intent":"edit","risk":"low","complexity":"simple","effort":"medium","tier":"execute"}'
13
14
  */
14
15
 
15
- import { existsSync, readFileSync } from 'fs';
16
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs';
16
17
  import { join, dirname } from 'path';
17
18
  import { fileURLToPath } from 'url';
18
19
  import { getProviderScore, checkCooldown } from './health.mjs';
19
20
 
20
- const __dirname = dirname(fileURLToPath(import.meta.url));
21
- const WORKSPACE = join(__dirname, '..');
22
- const USAGE_DIR = join(WORKSPACE, '.dualbrain', 'usage');
23
- const FIVE_HRS_MS = 5 * 60 * 60 * 1000;
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const WORKSPACE = join(__dirname, '..');
23
+ const USAGE_DIR = join(WORKSPACE, '.dualbrain', 'usage');
24
+ const AUDIT_DIR = join(WORKSPACE, '.dualbrain', 'audit');
25
+ const FIVE_HRS_MS = 5 * 60 * 60 * 1000;
26
+ const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
27
+
28
+ // ─── Subscription token quotas (mirrors budget-balancer.mjs) ─────────────────
29
+
30
+ /** Per-plan aggregate token budgets for the two rolling windows. */
31
+ const SUB_QUOTAS = {
32
+ claude: {
33
+ '$20': { fiveHr: 402_500, weekly: 2_750_000 },
34
+ '$100': { fiveHr: 1_638_000, weekly: 11_100_000 },
35
+ '$200': { fiveHr: 4_120_000, weekly: 27_500_000 },
36
+ },
37
+ openai: {
38
+ '$20': { fiveHr: 400_000, weekly: 2_700_000 },
39
+ '$100': { fiveHr: 1_050_000, weekly: 6_750_000 },
40
+ '$200': { fiveHr: 1_900_000, weekly: 17_500_000 },
41
+ },
42
+ };
24
43
 
25
44
  // ─── Slim Model Capabilities (routing-relevant only) ─────────────────────────
26
45
 
@@ -470,6 +489,217 @@ export function parsePreferences(preferences) {
470
489
  return signals;
471
490
  }
472
491
 
492
+ // ─── Exported: getOptimalSub ─────────────────────────────────────────────────
493
+
494
+ /**
495
+ * Read usage log entries within a given window (ms).
496
+ * Scans .dualbrain/usage/usage-YYYY-MM-DD.jsonl files.
497
+ * @param {number} windowMs
498
+ * @returns {Array<object>}
499
+ */
500
+ function _readUsageInWindow(windowMs) {
501
+ const now = Date.now();
502
+ const cutoff = now - windowMs;
503
+ const entries = [];
504
+ const daysBack = Math.ceil(windowMs / 86_400_000) + 1;
505
+ const seen = new Set();
506
+ for (let i = 0; i < daysBack; i++) {
507
+ const date = new Date(now - i * 86_400_000).toISOString().slice(0, 10);
508
+ if (seen.has(date)) continue;
509
+ seen.add(date);
510
+ const file = join(USAGE_DIR, `usage-${date}.jsonl`);
511
+ if (!existsSync(file)) continue;
512
+ let raw;
513
+ try { raw = readFileSync(file, 'utf8'); } catch { continue; }
514
+ for (const line of raw.split('\n')) {
515
+ if (!line.trim()) continue;
516
+ let rec;
517
+ try { rec = JSON.parse(line); } catch { continue; }
518
+ const ts = Date.parse(rec.timestamp);
519
+ if (!isNaN(ts) && ts >= cutoff) entries.push(rec);
520
+ }
521
+ }
522
+ return entries;
523
+ }
524
+
525
+ /**
526
+ * Sum tokens used by a specific provider from usage entries.
527
+ * @param {Array<object>} entries
528
+ * @param {string} provider
529
+ * @returns {number}
530
+ */
531
+ function _sumProviderTokens(entries, provider) {
532
+ let total = 0;
533
+ for (const e of entries) {
534
+ if (e.provider !== provider) continue;
535
+ const inp = e.input_tokens ?? 0;
536
+ const out = e.output_tokens ?? 0;
537
+ total += inp + out > 0 ? inp + out : 8_000; // fallback estimate
538
+ }
539
+ return total;
540
+ }
541
+
542
+ /**
543
+ * Log an autopilot routing decision to .dualbrain/audit/budget-autopilot.jsonl.
544
+ * @param {object} entry
545
+ */
546
+ function _logAutopilot(entry) {
547
+ try {
548
+ if (!existsSync(AUDIT_DIR)) mkdirSync(AUDIT_DIR, { recursive: true });
549
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...entry });
550
+ appendFileSync(join(AUDIT_DIR, 'budget-autopilot.jsonl'), line + '\n', 'utf8');
551
+ } catch {
552
+ // Non-fatal: logging should never block routing
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Given a provider and profile, pick the subscription with the most remaining
558
+ * quota — use-it-or-lose-it scoring: `remaining * (1 / hoursUntilReset)`.
559
+ *
560
+ * Handles single-sub and multi-sub profiles uniformly. When only one sub
561
+ * exists, returns it immediately (no log written for single-sub case since
562
+ * there is no real routing decision to make).
563
+ *
564
+ * @param {'claude'|'openai'} provider
565
+ * @param {'search'|'execute'|'think'} tier
566
+ * @param {object} profile
567
+ * @returns {{
568
+ * subIndex: number,
569
+ * plan: string,
570
+ * label: string|null,
571
+ * fiveHrUsed: number,
572
+ * weeklyUsed: number,
573
+ * fiveHrQuota: number,
574
+ * weeklyQuota: number,
575
+ * fiveHrRemaining: number,
576
+ * weeklyRemaining: number,
577
+ * score: number,
578
+ * reason: string,
579
+ * }|null}
580
+ */
581
+ export function getOptimalSub(provider, tier, profile) {
582
+ const providerCfg = profile?.providers?.[provider];
583
+ if (!providerCfg) return null;
584
+
585
+ // Normalise to an array of subs
586
+ const subs = providerCfg.subs?.length
587
+ ? providerCfg.subs
588
+ : providerCfg.plan
589
+ ? [{ plan: providerCfg.plan, label: providerCfg.label || null }]
590
+ : [];
591
+
592
+ if (subs.length === 0) return null;
593
+
594
+ // Short-circuit for single-sub: skip usage read and logging overhead
595
+ if (subs.length === 1) {
596
+ const s = subs[0];
597
+ const plan = s.plan || '$100';
598
+ const quotas = SUB_QUOTAS[provider]?.[plan] ?? { fiveHr: 1_000_000, weekly: 7_000_000 };
599
+ return {
600
+ subIndex: 0,
601
+ plan,
602
+ label: s.label ?? null,
603
+ fiveHrUsed: 0,
604
+ weeklyUsed: 0,
605
+ fiveHrQuota: quotas.fiveHr,
606
+ weeklyQuota: quotas.weekly,
607
+ fiveHrRemaining: quotas.fiveHr,
608
+ weeklyRemaining: quotas.weekly,
609
+ score: quotas.fiveHr,
610
+ reason: 'only sub available',
611
+ };
612
+ }
613
+
614
+ // Multi-sub: read usage logs once for both windows
615
+ const fiveHrEntries = _readUsageInWindow(FIVE_HRS_MS);
616
+ const weeklyEntries = _readUsageInWindow(SEVEN_DAY_MS);
617
+
618
+ // We cannot distinguish sub-level usage from the log (no subIndex field),
619
+ // so we divide total provider usage evenly across subs as a best-effort proxy.
620
+ const fiveHrTotal = _sumProviderTokens(fiveHrEntries, provider);
621
+ const weeklyTotal = _sumProviderTokens(weeklyEntries, provider);
622
+ const perSubFiveHr = Math.round(fiveHrTotal / subs.length);
623
+ const perSubWeekly = Math.round(weeklyTotal / subs.length);
624
+
625
+ // Score each sub
626
+ const now = Date.now();
627
+ const fiveHrResetMs = FIVE_HRS_MS; // window always resets from now in a rolling sense
628
+ const weeklyResetMs = SEVEN_DAY_MS;
629
+
630
+ let best = null;
631
+ let bestScore = -Infinity;
632
+ const alternatives = [];
633
+
634
+ subs.forEach((s, i) => {
635
+ const plan = s.plan || '$100';
636
+ const quotas = SUB_QUOTAS[provider]?.[plan] ?? { fiveHr: 1_000_000, weekly: 7_000_000 };
637
+
638
+ const fiveHrRemaining = Math.max(0, quotas.fiveHr - perSubFiveHr);
639
+ const weeklyRemaining = Math.max(0, quotas.weekly - perSubWeekly);
640
+
641
+ // Binding constraint: whichever window is tighter
642
+ const remaining = Math.min(fiveHrRemaining, weeklyRemaining);
643
+
644
+ // Hours until the tighter window resets (rolling → effectively "now + window")
645
+ const hoursUntilReset = fiveHrRemaining <= weeklyRemaining
646
+ ? (fiveHrResetMs / 3_600_000)
647
+ : (weeklyResetMs / 3_600_000);
648
+
649
+ // Use-it-or-lose-it: higher score = more remaining + resets sooner
650
+ const score = hoursUntilReset > 0 ? remaining * (1 / hoursUntilReset) : remaining;
651
+
652
+ const pctRemaining = quotas.fiveHr > 0
653
+ ? Math.round((fiveHrRemaining / quotas.fiveHr) * 100)
654
+ : 100;
655
+ const resets = fiveHrRemaining <= weeklyRemaining ? '5h' : '7d';
656
+ const reason = `${pctRemaining}% remaining, resets in ${resets === '5h' ? 5 : 168}h`;
657
+
658
+ const info = {
659
+ subIndex: i,
660
+ plan,
661
+ label: s.label ?? null,
662
+ fiveHrUsed: perSubFiveHr,
663
+ weeklyUsed: perSubWeekly,
664
+ fiveHrQuota: quotas.fiveHr,
665
+ weeklyQuota: quotas.weekly,
666
+ fiveHrRemaining,
667
+ weeklyRemaining,
668
+ score,
669
+ reason,
670
+ };
671
+
672
+ alternatives.push(info);
673
+
674
+ if (score > bestScore) {
675
+ bestScore = score;
676
+ best = info;
677
+ }
678
+ });
679
+
680
+ // Log the autopilot decision
681
+ if (best) {
682
+ _logAutopilot({
683
+ provider,
684
+ tier,
685
+ subIndex: best.subIndex,
686
+ plan: best.plan,
687
+ label: best.label,
688
+ reason: best.reason,
689
+ alternatives: alternatives.map(a => ({
690
+ subIndex: a.subIndex,
691
+ plan: a.plan,
692
+ label: a.label,
693
+ score: Math.round(a.score),
694
+ fiveHrRemaining: a.fiveHrRemaining,
695
+ weeklyRemaining: a.weeklyRemaining,
696
+ })),
697
+ });
698
+ }
699
+
700
+ return best;
701
+ }
702
+
473
703
  // ─── Internal: safety floor for critical-risk tasks ───────────────────────────
474
704
 
475
705
  /**
@@ -582,6 +812,9 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
582
812
  );
583
813
  const degradedDualBrain = !!(dual && detection.designImpact && !hasBothProviders);
584
814
 
815
+ // Budget autopilot: pick optimal sub when multiple subs exist for chosen provider
816
+ const optimalSub = getOptimalSub(provider, tier, profile);
817
+
585
818
  const decision = {
586
819
  provider,
587
820
  model,
@@ -589,6 +822,8 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
589
822
  tier,
590
823
  dualBrain: dual,
591
824
  ...(degradedDualBrain && { degradedDualBrain: true }),
825
+ ...(optimalSub && optimalSub.label != null && { subLabel: optimalSub.label }),
826
+ ...(optimalSub && { subIndex: optimalSub.subIndex }),
592
827
  modes,
593
828
  sandbox,
594
829
  explanation: '',
package/src/dispatch.mjs CHANGED
@@ -644,6 +644,23 @@ function _prependDispatchMarker(prompt) {
644
644
  return `<!-- dual-brain-dispatch: ${runId} -->\n${prompt}`;
645
645
  }
646
646
 
647
+ // ─── Related session age label ────────────────────────────────────────────────
648
+
649
+ /**
650
+ * Human-readable age label for a related session date string.
651
+ * @param {string} isoDate
652
+ * @returns {string}
653
+ */
654
+ function _relatedSessionAge(isoDate) {
655
+ const diff = Date.now() - Date.parse(isoDate);
656
+ const mins = Math.floor(diff / 60000);
657
+ if (mins < 60) return `${mins}m ago`;
658
+ const hours = Math.floor(mins / 60);
659
+ if (hours < 24) return `${hours}h ago`;
660
+ const days = Math.floor(hours / 24);
661
+ return `${days}d ago`;
662
+ }
663
+
647
664
  // ─── Main dispatch ────────────────────────────────────────────────────────────
648
665
  async function dispatch(input = {}) {
649
666
  const { files = [], cwd = process.cwd(), dryRun = false, verbose = false } = input;
@@ -655,6 +672,32 @@ async function dispatch(input = {}) {
655
672
  // Safety gate: redact secrets before anything reaches a subprocess or log
656
673
  prompt = redact(prompt);
657
674
 
675
+ // ── Related session context injection ────────────────────────────────────────
676
+ // Find past sessions related to this task and prepend a context block.
677
+ // Only injected when confidence is high (score > 5). Fast: index-only, no JSONL parsing.
678
+ if (!input._skipRelatedContext) {
679
+ try {
680
+ const { findRelatedSessions } = await import('./session.mjs');
681
+ const related = findRelatedSessions(prompt, files, cwd);
682
+ const highConfidence = related.filter(r => r.score > 5);
683
+ if (highConfidence.length > 0) {
684
+ const lines = highConfidence.map(r => {
685
+ const dateLabel = r.date ? _relatedSessionAge(r.date) : null;
686
+ const datePart = dateLabel ? `, ${dateLabel}` : '';
687
+ const msgPart = r.messageCount > 0 ? `, ${r.messageCount} messages` : '';
688
+ const fileList = r.matchedFiles.length > 0
689
+ ? `: touched ${r.matchedFiles.map(f => f.split('/').pop()).join(', ')}`
690
+ : '';
691
+ return `- "${r.smartName}"${datePart}${msgPart}${fileList}`;
692
+ });
693
+ const contextBlock = `[Prior context from related sessions:]\n${lines.join('\n')}\n[End prior context]\n\n`;
694
+ prompt = contextBlock + prompt;
695
+ if (verbose) process.stderr.write(`[dual-brain] injected related session context (${highConfidence.length} sessions)\n`);
696
+ }
697
+ } catch { /* non-fatal — never block dispatch */ }
698
+ }
699
+ // ── End related session context ──────────────────────────────────────────────
700
+
658
701
  // Stamp the prompt with the dispatch marker so enforce-tier.mjs can recognise
659
702
  // that this agent call came through the official pipeline.
660
703
  prompt = _prependDispatchMarker(prompt);
package/src/session.mjs CHANGED
@@ -1207,6 +1207,112 @@ export function searchSessions(query, cwd = process.cwd()) {
1207
1207
  return results.sort((a, b) => b._score - a._score);
1208
1208
  }
1209
1209
 
1210
+ /**
1211
+ * Find sessions related to a new task prompt and file list.
1212
+ * Uses the session index (topics + files) — does not parse full JSONL files.
1213
+ *
1214
+ * Scoring:
1215
+ * +3 for each file in common between the new task and a past session
1216
+ * +2 for each topic keyword in common
1217
+ * +1 for matching intent words (fix, refactor, test, etc.)
1218
+ *
1219
+ * Returns top 3 matches with score > 3, sorted by score desc.
1220
+ * Excludes sessions from the last hour (likely the current session).
1221
+ *
1222
+ * @param {string} prompt New task prompt
1223
+ * @param {string[]} files File paths from the new task
1224
+ * @param {string} [cwd]
1225
+ * @returns {Array<{
1226
+ * sessionId: string, smartName: string, score: number,
1227
+ * matchedFiles: string[], matchedTopics: string[],
1228
+ * date: string|null, messageCount: number
1229
+ * }>}
1230
+ */
1231
+ export function findRelatedSessions(prompt, files = [], cwd = process.cwd()) {
1232
+ const indexPath = join(cwd, '.dualbrain', 'session-index.json');
1233
+ let index = {};
1234
+ try { index = JSON.parse(readFileSync(indexPath, 'utf8')); } catch { return []; }
1235
+
1236
+ if (Object.keys(index).length === 0) return [];
1237
+
1238
+ // Intent words for +1 scoring
1239
+ const INTENT_WORDS = ['fix', 'refactor', 'test', 'add', 'update', 'review', 'debug', 'build', 'remove', 'migrate', 'deploy', 'implement', 'create'];
1240
+
1241
+ // Normalize the new task's prompt into words
1242
+ const promptLower = (prompt || '').toLowerCase();
1243
+ const promptWords = new Set(promptLower.split(/\W+/).filter(w => w.length > 3));
1244
+
1245
+ // Normalize the new task's file paths for comparison
1246
+ const normalizeFile = (f) => (f || '').split('/').pop().toLowerCase().replace(/\.[^.]+$/, '');
1247
+ const newFileNames = new Set((files || []).map(normalizeFile).filter(Boolean));
1248
+
1249
+ // One-hour cutoff for excluding likely-current session
1250
+ const oneHourAgo = Date.now() - 60 * 60 * 1000;
1251
+
1252
+ const results = [];
1253
+
1254
+ for (const session of Object.values(index)) {
1255
+ // Skip archived sessions
1256
+ if (session.archived) continue;
1257
+
1258
+ // Skip sessions from the last hour
1259
+ const sessionTs = session.date ? Date.parse(session.date) : 0;
1260
+ if (sessionTs > oneHourAgo) continue;
1261
+
1262
+ let score = 0;
1263
+ const matchedFiles = [];
1264
+ const matchedTopics = [];
1265
+
1266
+ // +3 for each file in common
1267
+ for (const sessionFile of (session.files || [])) {
1268
+ const sessionFileName = normalizeFile(sessionFile);
1269
+ if (sessionFileName && newFileNames.has(sessionFileName)) {
1270
+ score += 3;
1271
+ matchedFiles.push(sessionFile);
1272
+ }
1273
+ }
1274
+
1275
+ // +2 for each topic keyword in common with prompt words
1276
+ for (const topic of (session.topics || [])) {
1277
+ if (topic && promptWords.has(topic)) {
1278
+ score += 2;
1279
+ matchedTopics.push(topic);
1280
+ }
1281
+ }
1282
+
1283
+ // +1 for matching intent words found in both prompt and session topics/prompts
1284
+ const sessionText = [
1285
+ ...(session.topics || []),
1286
+ session.prompts?.first || '',
1287
+ session.prompts?.last || '',
1288
+ ].join(' ').toLowerCase();
1289
+
1290
+ for (const word of INTENT_WORDS) {
1291
+ if (promptLower.includes(word) && sessionText.includes(word)) {
1292
+ score += 1;
1293
+ break; // only +1 total for intent words
1294
+ }
1295
+ }
1296
+
1297
+ if (score > 3) {
1298
+ results.push({
1299
+ sessionId: session.id,
1300
+ smartName: session.smartName || session.prompts?.first?.slice(0, 40) || session.id.slice(0, 8),
1301
+ score,
1302
+ matchedFiles,
1303
+ matchedTopics,
1304
+ date: session.date,
1305
+ messageCount: session.messageCount || 0,
1306
+ });
1307
+ }
1308
+ }
1309
+
1310
+ // Return top 3 sorted by score descending
1311
+ return results
1312
+ .sort((a, b) => b.score - a.score)
1313
+ .slice(0, 3);
1314
+ }
1315
+
1210
1316
  /**
1211
1317
  * Get detailed context for a session (for smart resume preview).
1212
1318
  * Reads the last 20 lines of the session JSONL to surface the most recent prompt