dual-brain 0.1.2 → 0.1.4

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.
@@ -1113,6 +1113,22 @@ async function mainScreen(rl, ask) {
1113
1113
 
1114
1114
  // ── Header: one line above the box ────────────────────────────────────────
1115
1115
  process.stdout.write(`\n🧠 dual-brain v${version}\n`);
1116
+ {
1117
+ let gitName = '';
1118
+ try {
1119
+ const { execSync } = await import('node:child_process');
1120
+ gitName = execSync('git config user.name', { encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
1121
+ } catch { /* ignore */ }
1122
+ if (gitName) {
1123
+ const hour = new Date().getHours();
1124
+ let greet;
1125
+ if (hour >= 5 && hour <= 11) greet = 'Good morning';
1126
+ else if (hour >= 12 && hour <= 16) greet = 'Good afternoon';
1127
+ else if (hour >= 17 && hour <= 21) greet = 'Good evening';
1128
+ else greet = 'Late night';
1129
+ process.stdout.write(`\x1b[2m${greet}, ${gitName}\x1b[0m\n`);
1130
+ }
1131
+ }
1116
1132
 
1117
1133
  // ── Status section ────────────────────────────────────────────────────────
1118
1134
  const providerLine = buildProviderStatusLine(profile, auth);
@@ -1164,11 +1180,126 @@ async function mainScreen(rl, ask) {
1164
1180
  bot,
1165
1181
  ];
1166
1182
  process.stdout.write(lines.join('\n') + '\n');
1167
- process.stdout.write(`\x1b[2mBuilt on data-tools by Steve Moraco\x1b[0m\n\n`);
1183
+ process.stdout.write(`\x1b[2mPowered by data-tools · Steve Moraco\x1b[0m\n\n`);
1168
1184
 
1169
1185
  // ── Key handling ──────────────────────────────────────────────────────────
1170
- const raw = (await ask('')).trim();
1171
- const choice = raw.toLowerCase();
1186
+ // Use raw keypress mode so we can show a live type-to-start buffer.
1187
+ // Single-key commands (n, s, q, /, 1-9, Enter) only fire when buffer is empty.
1188
+ let taskBuffer = '';
1189
+
1190
+ const readline = await import('node:readline');
1191
+
1192
+ // Render the type-ahead line below the box (overwrites the current cursor line)
1193
+ const renderBuffer = (buf) => {
1194
+ // Move to the prompt line (we're already at it after printing the box + footer)
1195
+ // Use carriage return + clear-to-end-of-line to overwrite
1196
+ if (buf.length === 0) {
1197
+ process.stdout.write('\r\x1b[K');
1198
+ } else {
1199
+ const display = buf.length > W - 4 ? buf.slice(-(W - 4)) : buf;
1200
+ process.stdout.write(`\r\x1b[K> ${display}\x1b[7m \x1b[0m`);
1201
+ }
1202
+ };
1203
+
1204
+ // Enable keypress events on stdin (safe to call multiple times)
1205
+ readline.emitKeypressEvents(process.stdin, rl);
1206
+
1207
+ const raw = await new Promise((resolve) => {
1208
+ // Switch to raw mode if possible (TTY only)
1209
+ const wasRaw = process.stdin.isRaw;
1210
+ const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
1211
+ if (canRaw) process.stdin.setRawMode(true);
1212
+
1213
+ const cleanup = () => {
1214
+ process.stdin.removeListener('keypress', onKey);
1215
+ if (canRaw) {
1216
+ try { process.stdin.setRawMode(wasRaw || false); } catch {}
1217
+ }
1218
+ };
1219
+
1220
+ const onKey = (str, key) => {
1221
+ if (!key) return;
1222
+
1223
+ const name = key.name || '';
1224
+ const seq = key.sequence || str || '';
1225
+
1226
+ // Ctrl-C / Ctrl-D → exit
1227
+ if (key.ctrl && (name === 'c' || name === 'd')) {
1228
+ cleanup();
1229
+ process.stdout.write('\n');
1230
+ resolve('q');
1231
+ return;
1232
+ }
1233
+
1234
+ // Enter key
1235
+ if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
1236
+ cleanup();
1237
+ if (taskBuffer.length > 0) {
1238
+ process.stdout.write('\n');
1239
+ resolve(`__task__:${taskBuffer}`);
1240
+ } else {
1241
+ resolve('');
1242
+ }
1243
+ return;
1244
+ }
1245
+
1246
+ // Escape → clear buffer
1247
+ if (name === 'escape') {
1248
+ taskBuffer = '';
1249
+ renderBuffer('');
1250
+ return;
1251
+ }
1252
+
1253
+ // Backspace / delete
1254
+ if (name === 'backspace' || name === 'delete') {
1255
+ if (taskBuffer.length > 0) {
1256
+ taskBuffer = taskBuffer.slice(0, -1);
1257
+ renderBuffer(taskBuffer);
1258
+ }
1259
+ return;
1260
+ }
1261
+
1262
+ // Ignore non-printable / control keys
1263
+ if (key.ctrl || key.meta || !str || str.length === 0) return;
1264
+ const code = str.codePointAt(0);
1265
+ if (code < 32 || code === 127) return;
1266
+
1267
+ // Single-key commands only fire when buffer is empty
1268
+ if (taskBuffer.length === 0) {
1269
+ const lower = str.toLowerCase();
1270
+ if (lower === 'n' || lower === 's' || lower === 'q' || lower === '/') {
1271
+ cleanup();
1272
+ process.stdout.write('\n');
1273
+ resolve(lower);
1274
+ return;
1275
+ }
1276
+ const digit = parseInt(str, 10);
1277
+ if (!isNaN(digit) && digit >= 1 && digit <= 9) {
1278
+ cleanup();
1279
+ process.stdout.write('\n');
1280
+ resolve(str);
1281
+ return;
1282
+ }
1283
+ }
1284
+
1285
+ // Accumulate into buffer
1286
+ taskBuffer += str;
1287
+ renderBuffer(taskBuffer);
1288
+ };
1289
+
1290
+ process.stdin.on('keypress', onKey);
1291
+ });
1292
+
1293
+ const choice = typeof raw === 'string' ? raw.toLowerCase() : '';
1294
+
1295
+ // Typed task → dispatch as "dual-brain go"
1296
+ if (raw.startsWith('__task__:')) {
1297
+ const prompt = raw.slice('__task__:'.length).trim();
1298
+ if (prompt) {
1299
+ return { next: 'go', prompt };
1300
+ }
1301
+ return { next: 'main' };
1302
+ }
1172
1303
 
1173
1304
  // Enter (empty) → resume most recent session
1174
1305
  if (raw === '' || choice === '\r') {
@@ -2455,13 +2586,40 @@ async function runScreens(startScreen = 'dashboard') {
2455
2586
  let current = startScreen;
2456
2587
  let ctx = {};
2457
2588
  while (current && current !== 'exit') {
2589
+ // Handle type-to-start dispatch from mainScreen
2590
+ if (current === 'go' && ctx.prompt) {
2591
+ const prompt = ctx.prompt;
2592
+ const cwd = process.cwd();
2593
+ const profile = loadProfile(cwd);
2594
+ const detection = detectTask({ prompt });
2595
+ const decision = decideRoute({ profile, detection, cwd });
2596
+ process.stdout.write(`\n Routing: ${decision.provider}/${decision.model} (${decision.tier})\n`);
2597
+ process.stdout.write(` Reason: ${decision.explanation}\n\n`);
2598
+ const { spawnSync } = await import('node:child_process');
2599
+ const launchTool = decision.provider === 'openai' ? 'codex' : 'claude';
2600
+ if (launchTool === 'codex') {
2601
+ spawnSync('codex', [prompt], { stdio: 'inherit' });
2602
+ } else {
2603
+ spawnSync('claude', ['-p', prompt], { stdio: 'inherit' });
2604
+ }
2605
+ const freshSessions = importReplitSessions(cwd);
2606
+ if (freshSessions.length > 0) {
2607
+ saveTerminalState(cwd, getTerminalId(), freshSessions[0].id, launchTool);
2608
+ }
2609
+ current = 'main';
2610
+ ctx = {};
2611
+ continue;
2612
+ }
2613
+
2458
2614
  const screen = SCREENS[current];
2459
2615
  if (!screen) break;
2460
2616
  try {
2461
2617
  const result = await screen(rl, ask, ctx);
2462
2618
  current = result?.next || 'exit';
2463
- // Pass through context (e.g. selected session) to next screen
2464
- ctx = result?.session ? { session: result.session } : {};
2619
+ // Pass through context (e.g. selected session, typed prompt) to next screen
2620
+ ctx = result?.session ? { session: result.session }
2621
+ : result?.prompt ? { prompt: result.prompt }
2622
+ : {};
2465
2623
  } catch (e) {
2466
2624
  console.error(`Error: ${e.message}`);
2467
2625
  current = 'main';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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/profile.mjs CHANGED
@@ -387,8 +387,6 @@ function loadProfile(cwd) {
387
387
  if (!profile.providers[provider]) continue;
388
388
  const stored = profile.providers[provider].plan;
389
389
  if (stored !== detectedPlan) {
390
- const providerName = provider === 'claude' ? 'Claude' : 'OpenAI';
391
- process.stderr.write(`[dual-brain] ${providerName}: plan updated to ${detectedPlan} (from auth config)\n`);
392
390
  profile.providers[provider].plan = detectedPlan;
393
391
  }
394
392
  }
package/src/session.mjs CHANGED
@@ -461,23 +461,38 @@ export function importReplitSessions(cwd = process.cwd()) {
461
461
  const windowMs = windowHours * 60 * 60 * 1000;
462
462
  const cutoff = Date.now() - windowMs;
463
463
 
464
+ // Load existing session index for smartName lookup (best-effort, non-fatal)
465
+ let sessionIndex = {};
466
+ try {
467
+ const indexPath = join(cwd, '.dualbrain', 'session-index.json');
468
+ if (existsSync(indexPath)) {
469
+ sessionIndex = JSON.parse(readFileSync(indexPath, 'utf8'));
470
+ }
471
+ } catch { /* non-fatal */ }
472
+
464
473
  // Build session list
465
474
  for (const [id, sess] of bySession) {
466
475
  // Skip sessions outside the recency window (timestamps are in ms)
467
476
  if (sess.lastTimestamp < cutoff) continue;
468
- // Derive display name
469
- let name = sess.firstPrompt;
477
+
478
+ // Use smartName from index if available, otherwise fall back to first prompt
479
+ let name = sessionIndex[id]?.smartName || null;
480
+
470
481
  if (!name) {
471
- // Fallback: use first non-login display
472
- const firstReal = sess.entries.find(e => e.display && e.display !== 'login');
473
- name = firstReal?.display || `Session ${id.slice(0, 8)}`;
482
+ // Classic fallback: first meaningful prompt
483
+ name = sess.firstPrompt;
484
+ if (!name) {
485
+ const firstReal = sess.entries.find(e => e.display && e.display !== 'login');
486
+ name = firstReal?.display || `Session ${id.slice(0, 8)}`;
487
+ }
488
+ // Truncate long names that came from raw prompts
489
+ if (name.length > 60) name = name.slice(0, 57) + '...';
474
490
  }
475
- // Truncate long names
476
- if (name.length > 60) name = name.slice(0, 57) + '...';
477
491
 
478
492
  sessions.push({
479
493
  id: sess.sessionId,
480
494
  name,
495
+ smartName: sessionIndex[id]?.smartName || null,
481
496
  project: sess.project,
482
497
  promptCount: sess.entries.length,
483
498
  lastActive: new Date(sess.lastTimestamp).toISOString(),
@@ -741,6 +756,159 @@ export function syncSessionMirror(cwd = process.cwd()) {
741
756
  return { copied: totalCopied, grew: totalGrew };
742
757
  }
743
758
 
759
+ // ─── Smart session naming ─────────────────────────────────────────────────────
760
+
761
+ /**
762
+ * File pattern → human label mapping (checked in order, first match wins).
763
+ * Each entry: { pattern: RegExp, label: string, action?: string }
764
+ */
765
+ const FILE_PATTERN_RULES = [
766
+ { pattern: /auth/i, label: 'Auth', action: 'Refactor' },
767
+ { pattern: /test|spec/i, label: 'Tests', action: 'Fix' },
768
+ { pattern: /dispatch/i, label: 'Dispatch', action: 'Update' },
769
+ { pattern: /session/i, label: 'Session', action: 'Update' },
770
+ { pattern: /profile/i, label: 'Profile', action: 'Update' },
771
+ { pattern: /detect/i, label: 'Detection', action: 'Update' },
772
+ { pattern: /decide/i, label: 'Routing', action: 'Update' },
773
+ { pattern: /budget/i, label: 'Budget', action: 'Update' },
774
+ { pattern: /hook/i, label: 'Hooks', action: 'Update' },
775
+ { pattern: /install/i, label: 'Install', action: 'Update' },
776
+ { pattern: /config/i, label: 'Config', action: 'Update' },
777
+ { pattern: /migrate/i, label: 'Migration', action: 'Add' },
778
+ ];
779
+
780
+ /**
781
+ * Topic words that suggest a dominant action verb.
782
+ */
783
+ const TOPIC_ACTION_MAP = [
784
+ { words: ['fix', 'bug', 'error', 'crash', 'broken', 'fail'], action: 'Fix' },
785
+ { words: ['refactor', 'cleanup', 'clean', 'reorganize'], action: 'Refactor' },
786
+ { words: ['add', 'implement', 'create', 'build', 'write'], action: 'Add' },
787
+ { words: ['update', 'upgrade', 'bump', 'patch'], action: 'Update' },
788
+ { words: ['test', 'spec', 'coverage'], action: 'Fix' },
789
+ { words: ['deploy', 'release', 'publish'], action: 'Deploy' },
790
+ { words: ['audit', 'review', 'check'], action: 'Review' },
791
+ ];
792
+
793
+ /**
794
+ * Convert a string to Title Case.
795
+ * @param {string} str
796
+ * @returns {string}
797
+ */
798
+ function toTitleCase(str) {
799
+ return str.replace(/\b\w/g, c => c.toUpperCase());
800
+ }
801
+
802
+ /**
803
+ * Strip file extensions from a name candidate.
804
+ * @param {string} name
805
+ * @returns {string}
806
+ */
807
+ function stripExtensions(name) {
808
+ return name.replace(/\.(mjs|js|ts|tsx|jsx|json|md|css|html|py|sh|sql|toml|yaml|yml)\b/gi, '');
809
+ }
810
+
811
+ /**
812
+ * Truncate a string to maxLen characters, preserving whole words where possible.
813
+ * @param {string} str
814
+ * @param {number} maxLen
815
+ * @returns {string}
816
+ */
817
+ function truncate(str, maxLen = 40) {
818
+ if (str.length <= maxLen) return str;
819
+ const cut = str.slice(0, maxLen).replace(/\s+\S*$/, '');
820
+ return cut || str.slice(0, maxLen);
821
+ }
822
+
823
+ /**
824
+ * Generate a smart human-readable session name from session index data.
825
+ *
826
+ * Priority:
827
+ * 1. Dominant file pattern (e.g. auth*.mjs → "Refactor Auth Module")
828
+ * 2. Top topics (e.g. ['auth','token','refresh'] → "Auth Token Refresh")
829
+ * 3. Fallback: first prompt truncated to 40 chars
830
+ *
831
+ * Rules: ≤40 chars, Title Case, no file extensions, action-prefixed when detectable.
832
+ *
833
+ * @param {{ topics?: string[], files?: string[], prompts?: { first?: string } }} sessionData
834
+ * @returns {string}
835
+ */
836
+ export function generateSmartName(sessionData) {
837
+ const topics = sessionData.topics || [];
838
+ const files = sessionData.files || [];
839
+ const firstPrompt = sessionData.prompts?.first || '';
840
+
841
+ // ── Step 1: Detect dominant action from topics ─────────────────────────────
842
+ let detectedAction = null;
843
+ for (const { words, action } of TOPIC_ACTION_MAP) {
844
+ if (topics.some(t => words.includes(t))) {
845
+ detectedAction = action;
846
+ break;
847
+ }
848
+ }
849
+
850
+ // ── Step 2: Try file pattern match ─────────────────────────────────────────
851
+ if (files.length > 0) {
852
+ // Flatten all filenames for pattern matching
853
+ const fileNames = files.map(f => f.split('/').pop()).join(' ');
854
+
855
+ for (const { pattern, label, action } of FILE_PATTERN_RULES) {
856
+ if (pattern.test(fileNames)) {
857
+ const actionWord = detectedAction || action || 'Update';
858
+ const candidate = `${actionWord} ${label}`;
859
+ return truncate(toTitleCase(candidate));
860
+ }
861
+ }
862
+
863
+ // No named pattern — derive a label from the most common directory or base name
864
+ const basenames = files.map(f => {
865
+ const base = f.split('/').pop() || f;
866
+ // Strip extension and convert camelCase/kebab to words
867
+ return stripExtensions(base)
868
+ .replace(/[-_]/g, ' ')
869
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
870
+ .trim();
871
+ }).filter(Boolean);
872
+
873
+ if (basenames.length > 0) {
874
+ // Use the most common prefix or first significant basename
875
+ const label = basenames[0];
876
+ const actionWord = detectedAction || 'Update';
877
+ const candidate = `${actionWord} ${label}`;
878
+ return truncate(toTitleCase(stripExtensions(candidate)));
879
+ }
880
+ }
881
+
882
+ // ── Step 3: Try top topics ─────────────────────────────────────────────────
883
+ if (topics.length >= 2) {
884
+ // Take top 3 topics and compose a name
885
+ const topTopics = topics.slice(0, 3);
886
+ const actionWord = detectedAction || null;
887
+
888
+ let candidate;
889
+ if (actionWord) {
890
+ // Use action + remaining topics
891
+ candidate = [actionWord, ...topTopics.filter(t => t !== actionWord.toLowerCase())].slice(0, 3).join(' ');
892
+ } else {
893
+ candidate = topTopics.join(' ');
894
+ }
895
+
896
+ return truncate(toTitleCase(candidate));
897
+ }
898
+
899
+ if (topics.length === 1) {
900
+ const actionWord = detectedAction || 'Work on';
901
+ return truncate(toTitleCase(`${actionWord} ${topics[0]}`));
902
+ }
903
+
904
+ // ── Step 4: Fallback — first prompt truncated ──────────────────────────────
905
+ if (firstPrompt) {
906
+ return truncate(firstPrompt);
907
+ }
908
+
909
+ return 'Session';
910
+ }
911
+
744
912
  // ─── Session index ────────────────────────────────────────────────────────────
745
913
 
746
914
  /**
@@ -841,7 +1009,7 @@ export function buildSessionIndex(cwd = process.cwd()) {
841
1009
  .slice(0, 10)
842
1010
  .map(([w]) => w);
843
1011
 
844
- index[sessionId] = {
1012
+ const sessionEntry = {
845
1013
  id: sessionId,
846
1014
  topics,
847
1015
  files: [...fileSet].slice(0, 20),
@@ -851,6 +1019,8 @@ export function buildSessionIndex(cwd = process.cwd()) {
851
1019
  tool: 'claude',
852
1020
  _fileSize: fileSize,
853
1021
  };
1022
+ sessionEntry.smartName = generateSmartName(sessionEntry);
1023
+ index[sessionId] = sessionEntry;
854
1024
  } catch { continue; }
855
1025
  }
856
1026
  }
@@ -904,12 +1074,14 @@ export function buildSessionIndex(cwd = process.cwd()) {
904
1074
  } catch { continue; }
905
1075
  }
906
1076
 
907
- index[id] = {
1077
+ const codexEntry = {
908
1078
  id, topics: [], files: [],
909
1079
  prompts: { first: firstPrompt || '', last: lastPrompt || '' },
910
1080
  date: lastTimestamp ? new Date(lastTimestamp * 1000).toISOString() : null,
911
1081
  messageCount, tool: 'codex', _fileSize: fileSize,
912
1082
  };
1083
+ codexEntry.smartName = generateSmartName(codexEntry);
1084
+ index[id] = codexEntry;
913
1085
  } catch { continue; }
914
1086
  }
915
1087
  }