dual-brain 7.1.12 → 7.1.14

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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // dual-brain — CLI entry point. Commands: init, go, status, remember, forget
3
3
 
4
- import { existsSync, readFileSync } from 'node:fs';
4
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, statSync, readdirSync, unlinkSync } from 'node:fs';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { execSync, spawnSync as _spawnSyncTop } from 'node:child_process';
@@ -41,10 +41,94 @@ const PKG_PATH = join(__dirname, '..', 'package.json');
41
41
  function readVersion() {
42
42
  try { return JSON.parse(readFileSync(PKG_PATH, 'utf8')).version; } catch { return '0.0.0'; }
43
43
  }
44
+ async function checkForUpdates(currentVersion) {
45
+ try {
46
+ const { execSync } = await import('node:child_process');
47
+ const latest = execSync('npm view dual-brain version 2>/dev/null', {
48
+ encoding: 'utf8',
49
+ timeout: 3000
50
+ }).trim();
51
+ if (latest && latest !== currentVersion) {
52
+ return latest;
53
+ }
54
+ } catch {}
55
+ return null;
56
+ }
44
57
  function flag(args, name) { const i = args.indexOf(name); return i !== -1 ? (args[i + 1] ?? true) : null; }
45
58
  function err(msg) { process.stderr.write(`Error: ${msg}\n`); process.exit(1); }
46
59
  function vtrace(msg) { process.stderr.write(`[verbose] ${msg}\n`); }
47
60
 
61
+ // ─── Loop-prevention markers ──────────────────────────────────────────────────
62
+
63
+ function checkLoopMarker(cwd) {
64
+ const markerPath = join(cwd, '.dualbrain', `.prompt-shown-${process.pid}`);
65
+ if (existsSync(markerPath)) {
66
+ try {
67
+ const age = Date.now() - statSync(markerPath).mtimeMs;
68
+ if (age < 3600000) return true; // Not stale, skip prompt
69
+ } catch {}
70
+ }
71
+ return false;
72
+ }
73
+
74
+ function setLoopMarker(cwd) {
75
+ const dir = join(cwd, '.dualbrain');
76
+ try {
77
+ mkdirSync(dir, { recursive: true });
78
+ writeFileSync(join(dir, `.prompt-shown-${process.pid}`), String(Date.now()));
79
+ } catch {}
80
+ }
81
+
82
+ function cleanStaleMarkers(cwd) {
83
+ const dir = join(cwd, '.dualbrain');
84
+ try {
85
+ for (const f of readdirSync(dir)) {
86
+ if (!f.startsWith('.prompt-shown-')) continue;
87
+ const pid = f.replace('.prompt-shown-', '');
88
+ try {
89
+ process.kill(parseInt(pid, 10), 0);
90
+ } catch {
91
+ // Process dead, remove marker
92
+ try { unlinkSync(join(dir, f)); } catch {}
93
+ }
94
+ }
95
+ } catch {}
96
+ }
97
+
98
+ function buildSparkline(cwd) {
99
+ const indexPath = join(cwd, '.dualbrain', 'session-index.json');
100
+ let index = {};
101
+ try { index = JSON.parse(readFileSync(indexPath, 'utf8')); } catch { return null; }
102
+
103
+ const sessions = Object.values(index);
104
+ if (sessions.length < 2) return null;
105
+
106
+ const now = Date.now();
107
+ const days = 7;
108
+ const buckets = new Array(days).fill(0);
109
+
110
+ for (const sess of sessions) {
111
+ if (!sess.date) continue;
112
+ const age = (now - Date.parse(sess.date)) / 86400000;
113
+ const bucket = Math.floor(age);
114
+ if (bucket >= 0 && bucket < days) {
115
+ buckets[days - 1 - bucket]++;
116
+ }
117
+ }
118
+
119
+ const max = Math.max(...buckets, 1);
120
+ const blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
121
+ const spark = buckets.map(v => {
122
+ if (v === 0) return ' ';
123
+ const idx = Math.min(Math.floor((v / max) * (blocks.length - 1)), blocks.length - 1);
124
+ return blocks[idx];
125
+ }).join('');
126
+
127
+ const total = buckets.reduce((a, b) => a + b, 0);
128
+ if (total === 0) return null;
129
+ return `${spark} ${total} sessions (7d)`;
130
+ }
131
+
48
132
  function daysUntil(isoDate) {
49
133
  if (!isoDate) return null;
50
134
  const ms = Date.parse(isoDate) - Date.now();
@@ -84,6 +168,7 @@ Commands:
84
168
  cool <provider> Manually clear hot state for a provider
85
169
  remember "preference" Save a project-scoped preference
86
170
  forget "preference" Remove a preference by fuzzy match
171
+ search "keyword" Search across all sessions
87
172
  shell-hook Output bash snippet to add dual-brain to your shell
88
173
  Usage: dual-brain shell-hook >> ~/.bashrc
89
174
 
@@ -98,6 +183,30 @@ Options:
98
183
  `.trim());
99
184
  }
100
185
 
186
+ // ─── replit-tools detection ───────────────────────────────────────────────────
187
+
188
+ function detectReplitTools(cwd) {
189
+ const replitToolsDir = join(cwd, '.replit-tools');
190
+ const hasDir = existsSync(replitToolsDir);
191
+ const hasConfig = existsSync(join(replitToolsDir, 'config.json'));
192
+ const hasScripts = existsSync(join(replitToolsDir, 'scripts', 'setup-claude-code.sh'));
193
+ const hasArchive = existsSync(join(replitToolsDir, '.session-archive'));
194
+
195
+ let version = null;
196
+ try {
197
+ version = readFileSync(join(replitToolsDir, '.version'), 'utf8').trim();
198
+ } catch {}
199
+
200
+ return {
201
+ installed: hasDir,
202
+ version,
203
+ hasConfig,
204
+ hasScripts,
205
+ hasArchive,
206
+ dir: replitToolsDir,
207
+ };
208
+ }
209
+
101
210
  // ─── Subscription status table ────────────────────────────────────────────────
102
211
 
103
212
  /**
@@ -627,9 +736,26 @@ async function welcomeScreen(rl, ask) {
627
736
  detectedLines.push(` ${existingSessions.length} session${existingSessions.length !== 1 ? 's' : ''} found from data-tools`);
628
737
  }
629
738
 
739
+ // --- Detect replit-tools ---
740
+ const rt = detectReplitTools(cwd);
741
+ if (rt.installed) {
742
+ detectedLines.push(` replit-tools v${rt.version || '?'} detected`);
743
+ if (rt.hasArchive) {
744
+ try {
745
+ const archiveDir = join(rt.dir, '.session-archive', 'claude', 'projects', '-home-runner-workspace');
746
+ if (existsSync(archiveDir)) {
747
+ const count = readdirSync(archiveDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-')).length;
748
+ if (count > 0) detectedLines.push(` ${count} archived sessions available`);
749
+ }
750
+ } catch {}
751
+ }
752
+ } else {
753
+ detectedLines.push(` replit-tools not found — install with: npx replit-tools`);
754
+ }
755
+
630
756
  // Show detection results in a box
631
757
  const detectedFormatted = detectedLines.map(line => {
632
- const ok = !line.includes('not logged');
758
+ const ok = !line.includes('not logged') && !line.includes('not found');
633
759
  return `${ok ? '✅' : '⚠️ '} ${line.trim()}`;
634
760
  });
635
761
  console.log('');
@@ -649,6 +775,11 @@ async function welcomeScreen(rl, ask) {
649
775
  if (existingSessions.length > 0) {
650
776
  console.log(` [i] Import ${existingSessions.length} session${existingSessions.length !== 1 ? 's' : ''} from data-tools`);
651
777
  }
778
+ if (!rt.installed) {
779
+ console.log('');
780
+ console.log(' 💡 Tip: Install replit-tools for session persistence:');
781
+ console.log(' npx replit-tools');
782
+ }
652
783
  console.log('');
653
784
 
654
785
  const choice = (await ask(' Choice: ')).trim().toLowerCase();
@@ -685,6 +816,13 @@ async function welcomeScreen(rl, ask) {
685
816
  existing.mode = enabledCount >= 2 ? 'dual' : claudeReady ? 'solo-claude' : 'solo-openai';
686
817
  saveProfile(existing, { cwd });
687
818
  }
819
+ try {
820
+ const { ensurePersistence } = await import('../src/session.mjs');
821
+ const persisted = ensurePersistence(cwd);
822
+ if (persisted.length > 0) {
823
+ persisted.forEach(msg => console.log(` ✅ ${msg}`));
824
+ }
825
+ } catch {}
688
826
  await cmdInstall(cwd);
689
827
  return { next: 'main' };
690
828
  }
@@ -783,6 +921,42 @@ async function welcomeScreen(rl, ask) {
783
921
  return { next: 'main' };
784
922
  }
785
923
 
924
+ // ─── Running-instance + terminal helpers ─────────────────────────────────────
925
+
926
+ function countRunningInstances() {
927
+ try {
928
+ const claude = parseInt(execSync('pgrep -x claude 2>/dev/null | wc -l', { encoding: 'utf8' }).trim(), 10) || 0;
929
+ const codex = parseInt(execSync('pgrep -x codex 2>/dev/null | wc -l', { encoding: 'utf8' }).trim(), 10) || 0;
930
+ return { claude, codex };
931
+ } catch { return { claude: 0, codex: 0 }; }
932
+ }
933
+
934
+ function getTerminalId() {
935
+ try {
936
+ const tty = execSync('tty 2>/dev/null', { encoding: 'utf8' }).trim();
937
+ if (tty && tty !== 'not a tty') {
938
+ return tty.replace('/dev/', '').replace(/\//g, '-');
939
+ }
940
+ } catch {}
941
+ return `shell-${process.pid}`;
942
+ }
943
+
944
+ function saveTerminalState(cwd, terminalId, sessionId, tool) {
945
+ const dir = join(cwd, '.dualbrain');
946
+ try {
947
+ mkdirSync(dir, { recursive: true });
948
+ writeFileSync(join(dir, `terminal-${terminalId}.json`), JSON.stringify({
949
+ sessionId, tool, terminalId, timestamp: Math.floor(Date.now() / 1000),
950
+ }));
951
+ } catch {}
952
+ }
953
+
954
+ function loadTerminalState(cwd, terminalId) {
955
+ try {
956
+ return JSON.parse(readFileSync(join(cwd, '.dualbrain', `terminal-${terminalId}.json`), 'utf8'));
957
+ } catch { return null; }
958
+ }
959
+
786
960
  // ─── Screen: mainScreen ───────────────────────────────────────────────────────
787
961
 
788
962
  async function mainScreen(rl, ask) {
@@ -824,6 +998,11 @@ async function mainScreen(rl, ask) {
824
998
  ];
825
999
 
826
1000
  console.log(`📦 DATA Tools - Dual Brain v${version}`);
1001
+ const latestVersion = await checkForUpdates(version);
1002
+ if (latestVersion) {
1003
+ console.log(` ⬆️ Update available: v${version} → v${latestVersion}`);
1004
+ console.log(` Run: npx -y dual-brain@latest`);
1005
+ }
827
1006
  console.log('');
828
1007
 
829
1008
  // Help shortcuts box (matching data-tools style)
@@ -850,6 +1029,35 @@ async function mainScreen(rl, ask) {
850
1029
  console.log(` ${line}`);
851
1030
  }
852
1031
 
1032
+ // replit-tools indicator
1033
+ const rtMain = detectReplitTools(cwd);
1034
+ if (rtMain.installed && rtMain.version) {
1035
+ console.log(` 🔗 replit-tools v${rtMain.version}`);
1036
+ }
1037
+
1038
+ const sparkline = buildSparkline(cwd);
1039
+ if (sparkline) {
1040
+ console.log(` Activity: ${sparkline}`);
1041
+ }
1042
+
1043
+ // Silent OAuth token auto-refresh (like data-tools)
1044
+ try {
1045
+ const { autoRefreshToken } = await import('../src/profile.mjs');
1046
+ const refreshResult = await autoRefreshToken(cwd);
1047
+ if (refreshResult.status === 'refreshed') {
1048
+ console.log(` 🔄 Token auto-refreshed (${refreshResult.hoursRemaining}h remaining)`);
1049
+ }
1050
+ } catch {}
1051
+
1052
+ // Append-only session archive sync (like data-tools)
1053
+ try {
1054
+ const { syncSessionMirror } = await import('../src/session.mjs');
1055
+ const mirror = syncSessionMirror(cwd);
1056
+ if (mirror.copied > 0 || mirror.grew > 0) {
1057
+ console.log(` ✅ Archive mirror: +${mirror.copied} new, ${mirror.grew} updated`);
1058
+ }
1059
+ } catch {}
1060
+
853
1061
  // Auto-refresh expired subscriptions
854
1062
  if (claudeExpired || openaiExpired) {
855
1063
  const { spawnSync } = await import('node:child_process');
@@ -876,6 +1084,12 @@ async function mainScreen(rl, ask) {
876
1084
  }
877
1085
  console.log('');
878
1086
 
1087
+ // Build session index in background (powers search + smart resume)
1088
+ try {
1089
+ const { buildSessionIndex } = await import('../src/session.mjs');
1090
+ buildSessionIndex(cwd);
1091
+ } catch {}
1092
+
879
1093
  const recentSessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 7);
880
1094
 
881
1095
  if (recentSessions.length > 0) {
@@ -904,11 +1118,22 @@ async function mainScreen(rl, ask) {
904
1118
  console.log(brandBottom);
905
1119
  console.log('');
906
1120
 
1121
+ const running = countRunningInstances();
1122
+ const runningParts = [];
1123
+ if (running.claude > 0) runningParts.push(`${running.claude} claude`);
1124
+ if (running.codex > 0) runningParts.push(`${running.codex} codex`);
1125
+ if (runningParts.length > 0) {
1126
+ console.log(` (${runningParts.join(', ')} running)`);
1127
+ console.log('');
1128
+ }
1129
+
907
1130
  console.log(' [c] Continue last session');
908
1131
  console.log(' [n] New session');
909
1132
  if (recentSessions.length > 0) {
910
1133
  console.log(' [1-9] Resume numbered above');
911
1134
  }
1135
+ console.log(' [r] Resume (full list)');
1136
+ console.log(' [/] Search sessions');
912
1137
  console.log(' [e] Manage sessions');
913
1138
  console.log(' [i] Import from replit-tools');
914
1139
  console.log(' [m] Manage subscriptions');
@@ -922,24 +1147,124 @@ async function mainScreen(rl, ask) {
922
1147
  if (choice === 'n') { return { next: 'new-session' }; }
923
1148
 
924
1149
  if (choice === 'c') {
925
- const sessions = importReplitSessions(cwd);
926
- if (sessions.length === 0) {
1150
+ const termId = getTerminalId();
1151
+ const termState = loadTerminalState(cwd, termId);
1152
+ const sessions = importReplitSessions(cwd);
1153
+
1154
+ // Priority: terminal-specific last session, then global last session
1155
+ const targetId = termState?.sessionId || (sessions.length > 0 ? sessions[0].id : null);
1156
+
1157
+ if (!targetId) {
927
1158
  console.log('\n No recent sessions found.\n');
928
1159
  await ask(' Press Enter to continue...');
929
1160
  return { next: 'main' };
930
1161
  }
1162
+
1163
+ // Smart resume preview
1164
+ try {
1165
+ const { getSessionContext } = await import('../src/session.mjs');
1166
+ const ctx = getSessionContext(targetId, cwd);
1167
+ if (ctx) {
1168
+ console.log('');
1169
+ if (ctx.lastPrompt) console.log(` Last working on: ${ctx.lastPrompt}`);
1170
+ if (ctx.filesTouched.length > 0) console.log(` Files touched: ${ctx.filesTouched.join(', ')}`);
1171
+ }
1172
+ } catch {}
1173
+
931
1174
  const { spawnSync } = await import('node:child_process');
932
- console.log(`\n Launching: claude --resume ${sessions[0].id}\n`);
933
- spawnSync('claude', ['--resume', sessions[0].id], { stdio: 'inherit' });
1175
+ const tool = termState?.tool || 'claude';
1176
+ console.log(`\n Resuming: ${tool} --resume ${targetId}\n`);
1177
+ spawnSync(tool === 'codex' ? 'codex' : 'claude', ['--resume', targetId], { stdio: 'inherit' });
1178
+ saveTerminalState(cwd, termId, targetId, tool);
934
1179
  return { next: 'main' };
935
1180
  }
936
1181
 
937
1182
  const numChoice = parseInt(choice, 10);
938
1183
  if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= recentSessions.length) {
939
1184
  const sess = recentSessions[numChoice - 1];
1185
+
1186
+ // Smart resume preview
1187
+ try {
1188
+ const { getSessionContext } = await import('../src/session.mjs');
1189
+ const ctx = getSessionContext(sess.id, cwd);
1190
+ if (ctx) {
1191
+ console.log('');
1192
+ if (ctx.lastPrompt) console.log(` Last working on: ${ctx.lastPrompt}`);
1193
+ if (ctx.filesTouched.length > 0) console.log(` Files touched: ${ctx.filesTouched.join(', ')}`);
1194
+ }
1195
+ } catch {}
1196
+
940
1197
  const { spawnSync } = await import('node:child_process');
941
1198
  console.log(`\n Launching: claude --resume ${sess.id}\n`);
942
1199
  spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
1200
+ saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
1201
+ return { next: 'main' };
1202
+ }
1203
+
1204
+ if (choice === 'r') {
1205
+ const allSessions = enrichSessions(importReplitSessions(cwd), cwd);
1206
+ if (allSessions.length === 0) {
1207
+ console.log('\n No sessions found.\n');
1208
+ await ask(' Press Enter to continue...');
1209
+ return { next: 'main' };
1210
+ }
1211
+
1212
+ console.log('\n All Sessions:');
1213
+ allSessions.forEach((sess, i) => {
1214
+ const pin = sess.pinned ? '📌 ' : ' ';
1215
+ const active = sess.isActive ? ' ●' : '';
1216
+ const cat = sess.category ? ` [${sess.category}]` : '';
1217
+ const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
1218
+ console.log(` [${String(i + 1).padStart(2)}] ${pin}${tool} ${sess.age.padEnd(8)} ${sess.name}${active}${cat}`);
1219
+ });
1220
+ console.log('');
1221
+
1222
+ const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
1223
+ const num = parseInt(pick, 10);
1224
+ if (!isNaN(num) && num >= 1 && num <= allSessions.length) {
1225
+ const sess = allSessions[num - 1];
1226
+ const { spawnSync } = await import('node:child_process');
1227
+ const tool = sess.tool === 'codex' ? 'codex' : 'claude';
1228
+ console.log(`\n Launching: ${tool} --resume ${sess.id}\n`);
1229
+ spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
1230
+ }
1231
+ return { next: 'main' };
1232
+ }
1233
+
1234
+ if (choice === '/') {
1235
+ const query = (await ask(' Search: ')).trim();
1236
+ if (!query) return { next: 'main' };
1237
+
1238
+ const { searchSessions, buildSessionIndex } = await import('../src/session.mjs');
1239
+ // Build index if needed (silent)
1240
+ try { buildSessionIndex(cwd); } catch {}
1241
+
1242
+ const results = searchSessions(query, cwd);
1243
+ if (results.length === 0) {
1244
+ console.log(`\n No sessions matching "${query}"\n`);
1245
+ await ask(' Press Enter to continue...');
1246
+ return { next: 'main' };
1247
+ }
1248
+
1249
+ console.log(`\n Found ${results.length} session${results.length === 1 ? '' : 's'}:`);
1250
+ results.slice(0, 9).forEach((sess, i) => {
1251
+ const tool = sess.tool === 'codex' ? 'cdx' : 'cld';
1252
+ const date = sess.date ? new Date(sess.date).toLocaleDateString() : '?';
1253
+ const topics = sess.topics.slice(0, 3).join(', ');
1254
+ console.log(` [${i + 1}] ${tool} ${date} ${sess.prompts.first || sess.id.slice(0, 8)}`);
1255
+ if (topics) console.log(` topics: ${topics}`);
1256
+ });
1257
+ console.log('');
1258
+
1259
+ const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
1260
+ const num = parseInt(pick, 10);
1261
+ if (!isNaN(num) && num >= 1 && num <= Math.min(results.length, 9)) {
1262
+ const sess = results[num - 1];
1263
+ const { spawnSync } = await import('node:child_process');
1264
+ const tool = sess.tool === 'codex' ? 'codex' : 'claude';
1265
+ console.log(`\n Launching: ${tool} --resume ${sess.id}\n`);
1266
+ spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
1267
+ }
943
1268
  return { next: 'main' };
944
1269
  }
945
1270
 
@@ -992,12 +1317,19 @@ async function newSessionScreen(rl, ask) {
992
1317
  console.log(` Reason: ${decision.explanation}\n`);
993
1318
 
994
1319
  const { spawnSync } = await import('node:child_process');
995
- if (decision.provider === 'openai') {
1320
+ const launchTool = decision.provider === 'openai' ? 'codex' : 'claude';
1321
+ if (launchTool === 'codex') {
996
1322
  spawnSync('codex', [input], { stdio: 'inherit' });
997
1323
  } else {
998
1324
  spawnSync('claude', ['-p', input], { stdio: 'inherit' });
999
1325
  }
1000
1326
 
1327
+ // After session ends, capture the most-recent session ID so [c] can resume it
1328
+ const freshSessions = importReplitSessions(cwd);
1329
+ if (freshSessions.length > 0) {
1330
+ saveTerminalState(cwd, getTerminalId(), freshSessions[0].id, launchTool);
1331
+ }
1332
+
1001
1333
  return { next: 'main' };
1002
1334
  }
1003
1335
 
@@ -1927,6 +2259,11 @@ async function main() {
1927
2259
  if (!cmd) {
1928
2260
  if (isInteractive) {
1929
2261
  const cwd = process.cwd();
2262
+ cleanStaleMarkers(cwd);
2263
+ if (!process.argv.includes('--force') && checkLoopMarker(cwd)) {
2264
+ process.exit(0);
2265
+ }
2266
+ setLoopMarker(cwd);
1930
2267
  if (profileExists(cwd)) {
1931
2268
  await runScreens('main');
1932
2269
  } else {
@@ -1969,6 +2306,37 @@ async function main() {
1969
2306
  if (cmd === 'remember') { cmdRemember(args[1]); return; }
1970
2307
  if (cmd === 'forget') { cmdForget(args[1]); return; }
1971
2308
 
2309
+ if (cmd === 'search') {
2310
+ const query = args.slice(1).filter(a => !a.startsWith('--')).join(' ');
2311
+ if (!query) {
2312
+ console.log('Usage: dual-brain search "keyword"');
2313
+ process.exit(1);
2314
+ }
2315
+
2316
+ const { searchSessions, buildSessionIndex } = await import('../src/session.mjs');
2317
+ const cwd = process.cwd();
2318
+ try { buildSessionIndex(cwd); } catch {}
2319
+
2320
+ const results = searchSessions(query, cwd);
2321
+ if (results.length === 0) {
2322
+ console.log(`No sessions matching "${query}"`);
2323
+ process.exit(0);
2324
+ }
2325
+
2326
+ console.log(`Found ${results.length} session${results.length === 1 ? '' : 's'}:\n`);
2327
+ results.slice(0, 10).forEach((sess, i) => {
2328
+ const tool = sess.tool === 'codex' ? 'cdx' : 'cld';
2329
+ const date = sess.date ? new Date(sess.date).toLocaleDateString() : '?';
2330
+ console.log(` ${i + 1}. [${tool}] ${date} ${sess.prompts.first || sess.id.slice(0, 8)}`);
2331
+ if (sess.topics.length > 0) console.log(` topics: ${sess.topics.slice(0, 5).join(', ')}`);
2332
+ if (sess.files.length > 0) console.log(` files: ${sess.files.slice(0, 5).join(', ')}`);
2333
+ console.log(` id: ${sess.id}`);
2334
+ console.log('');
2335
+ });
2336
+
2337
+ process.exit(0);
2338
+ }
2339
+
1972
2340
  if (cmd === 'shell-hook') {
1973
2341
  // Output a bash snippet users can add to their .bashrc or source directly.
1974
2342
  const hook = `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "7.1.12",
3
+ "version": "7.1.14",
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/index.mjs CHANGED
@@ -6,14 +6,14 @@
6
6
  * orchestrate() convenience function for programmatic use.
7
7
  */
8
8
 
9
- export { loadProfile, saveProfile, ensureProfile, runOnboarding, rememberPreference, forgetPreference, getActivePreferences, getAvailableProviders, isSoloBrain, getHeadModel, detectAuth, detectEnvironment, saveSubscription, listSubscriptions } from './profile.mjs';
9
+ export { loadProfile, saveProfile, ensureProfile, runOnboarding, rememberPreference, forgetPreference, getActivePreferences, getAvailableProviders, isSoloBrain, getHeadModel, detectAuth, detectEnvironment, saveSubscription, listSubscriptions, autoRefreshToken } from './profile.mjs';
10
10
  export { detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths } from './detect.mjs';
11
11
  export { decideRoute, getModelCapabilities, getAvailableModels, shouldDualBrain, explainDecision } from './decide.mjs';
12
12
  export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain } from './dispatch.mjs';
13
13
  export { loadPlaybook, listPlaybooks, executePlaybook, createRunArtifact } from './playbook.mjs';
14
14
  export { getHealth, markHot, markDegraded, markHealthy, checkCooldown, getProviderScore, recordDispatch, getSessionStats, resetHealth, remainingCooldownMinutes } from './health.mjs';
15
15
  export { detectRepo, loadRepoCache, getTestCommand, getLintCommand } from './repo.mjs';
16
- export { loadSession, saveSession, updateSession, clearSession, formatSessionCard, importReplitSessions, renameSession, pinSession, unpinSession, categorizeSession, getSessionMeta, autoLabel, enrichSessions } from './session.mjs';
16
+ export { loadSession, saveSession, updateSession, clearSession, formatSessionCard, importReplitSessions, renameSession, pinSession, unpinSession, categorizeSession, getSessionMeta, autoLabel, enrichSessions, ensurePersistence, syncSessionMirror, buildSessionIndex, searchSessions, getSessionContext } from './session.mjs';
17
17
  export { decompose, isSimpleTask, taskGraphToWaves } from './decompose.mjs';
18
18
  export { generateBrief, compressPriorResults, listRoles } from './brief.mjs';
19
19
  export { redact, redactFiles, isSecretFile } from './redact.mjs';
package/src/profile.mjs CHANGED
@@ -666,6 +666,92 @@ async function autoSetup(cwd) {
666
666
  return result;
667
667
  }
668
668
 
669
+ // ---------------------------------------------------------------------------
670
+ // OAuth token auto-refresh
671
+ // ---------------------------------------------------------------------------
672
+
673
+ /**
674
+ * Silently refresh the Claude OAuth token before it expires.
675
+ * Mirrors the approach used by replit-tools/data-tools claude-auth-refresh.sh,
676
+ * but implemented in JavaScript.
677
+ *
678
+ * Returns one of:
679
+ * { status: 'valid', hoursRemaining }
680
+ * { status: 'refreshed', hoursRemaining }
681
+ * { status: 'expiring_no_refresh' | 'expired', hoursRemaining }
682
+ * { status: 'no_credentials' | 'parse_error' | 'no_expiry' }
683
+ * { status: 'refresh_failed', error }
684
+ *
685
+ * @param {string} [cwd]
686
+ */
687
+ async function autoRefreshToken(cwd) {
688
+ const home = process.env.HOME || '/root';
689
+ const credPaths = [
690
+ join(home, '.claude', '.credentials.json'),
691
+ join(cwd || '.', '.replit-tools', '.claude-persistent', '.credentials.json'),
692
+ ];
693
+
694
+ let credPath = null;
695
+ for (const p of credPaths) {
696
+ if (existsSync(p)) { credPath = p; break; }
697
+ }
698
+ if (!credPath) return { status: 'no_credentials' };
699
+
700
+ let creds;
701
+ try {
702
+ creds = JSON.parse(readFileSync(credPath, 'utf8'));
703
+ } catch { return { status: 'parse_error' }; }
704
+
705
+ const oauth = creds?.claudeAiOauth;
706
+ if (!oauth?.expiresAt) return { status: 'no_expiry' };
707
+
708
+ const now = Date.now();
709
+ const remainingMs = oauth.expiresAt - now;
710
+ const remainingHours = Math.floor(remainingMs / 1000 / 60 / 60);
711
+
712
+ // More than 2 hours left — no refresh needed
713
+ if (remainingHours >= 2) {
714
+ return { status: 'valid', hoursRemaining: remainingHours };
715
+ }
716
+
717
+ // Need refresh
718
+ if (!oauth.refreshToken) {
719
+ return { status: remainingMs > 0 ? 'expiring_no_refresh' : 'expired', hoursRemaining: remainingHours };
720
+ }
721
+
722
+ try {
723
+ const res = await fetch('https://console.anthropic.com/v1/oauth/token', {
724
+ method: 'POST',
725
+ headers: { 'Content-Type': 'application/json' },
726
+ body: JSON.stringify({
727
+ grant_type: 'refresh_token',
728
+ refresh_token: oauth.refreshToken,
729
+ client_id: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
730
+ }),
731
+ });
732
+
733
+ if (!res.ok) return { status: 'refresh_failed', error: `HTTP ${res.status}` };
734
+
735
+ const data = await res.json();
736
+ if (!data.access_token) return { status: 'refresh_failed', error: 'no access_token' };
737
+
738
+ // Update credentials
739
+ const newExpiresAt = now + (data.expires_in * 1000);
740
+ creds.claudeAiOauth.accessToken = data.access_token;
741
+ if (data.refresh_token) creds.claudeAiOauth.refreshToken = data.refresh_token;
742
+ creds.claudeAiOauth.expiresAt = newExpiresAt;
743
+
744
+ // Backup then write
745
+ try { writeFileSync(credPath + '.backup', readFileSync(credPath)); } catch {}
746
+ writeFileSync(credPath, JSON.stringify(creds));
747
+
748
+ const newHours = Math.floor((data.expires_in) / 60 / 60);
749
+ return { status: 'refreshed', hoursRemaining: newHours };
750
+ } catch (e) {
751
+ return { status: 'refresh_failed', error: e.message };
752
+ }
753
+ }
754
+
669
755
  export {
670
756
  loadProfile, saveProfile, ensureProfile, runOnboarding,
671
757
  rememberPreference, forgetPreference, getActivePreferences,
@@ -673,5 +759,5 @@ export {
673
759
  detectPlans, syncPreferencesToMemory,
674
760
  detectAuth, detectEnvironment,
675
761
  saveSubscription, listSubscriptions,
676
- defaultProfile, autoSetup,
762
+ defaultProfile, autoSetup, autoRefreshToken,
677
763
  };
package/src/session.mjs CHANGED
@@ -10,8 +10,8 @@
10
10
  * formatSessionCard(session, repo, health) → compact status card string (≤5 lines)
11
11
  */
12
12
 
13
- import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, renameSync, readdirSync, statSync } from 'node:fs';
14
- import { join } from 'node:path';
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, renameSync, readdirSync, statSync, copyFileSync } from 'node:fs';
14
+ import { join, dirname } from 'node:path';
15
15
 
16
16
  // ─── Constants ────────────────────────────────────────────────────────────────
17
17
 
@@ -216,6 +216,23 @@ export function formatSessionCard(session, repo, health, profile) {
216
216
 
217
217
  // ─── Replit-tools session import ──────────────────────────────────────────────
218
218
 
219
+ /**
220
+ * Returns true if the text looks like a real user prompt (not a status line,
221
+ * slash command, paste marker, or agent-generated noise).
222
+ * @param {string} text
223
+ * @returns {boolean}
224
+ */
225
+ function isRealPrompt(text) {
226
+ if (!text || !text.trim()) return false;
227
+ const t = text.trim();
228
+ if (/^[✅❌📦🔗⚠️🚀🎉🔧📝]/.test(t)) return false;
229
+ if (/Claude (history|binary|versions) symlink/.test(t)) return false;
230
+ if (t.startsWith('# AGENTS.md')) return false;
231
+ if (t.startsWith('/')) return false;
232
+ if (t.startsWith('[Pasted')) return false;
233
+ return true;
234
+ }
235
+
219
236
  /**
220
237
  * Human-readable time-ago string from a Unix timestamp (ms).
221
238
  * @param {number} timestamp
@@ -299,61 +316,61 @@ export function importReplitSessions(cwd = process.cwd()) {
299
316
  sess.entries.push(entry);
300
317
  if (entry.timestamp > sess.lastTimestamp) sess.lastTimestamp = entry.timestamp;
301
318
 
302
- // Find first meaningful user prompt (not slash commands, not login, not pastes)
303
- if (!sess.firstPrompt && entry.display
304
- && !entry.display.startsWith('/')
305
- && !entry.display.startsWith('login')
306
- && !entry.display.startsWith('[Pasted')) {
319
+ // Find first meaningful user prompt
320
+ if (!sess.firstPrompt && isRealPrompt(entry.display)) {
307
321
  sess.firstPrompt = entry.display;
308
322
  }
309
323
  } catch { continue; }
310
324
  }
311
325
 
312
- // Scan ~/.claude/projects/-home-runner-workspace/ for JSONL files not already in bySession
313
- const projectsDir = join(process.env.HOME || '/root', '.claude', 'projects', '-home-runner-workspace');
314
- if (existsSync(projectsDir)) {
315
- try {
316
- for (const f of readdirSync(projectsDir)) {
317
- if (!f.endsWith('.jsonl') || f.startsWith('agent-')) continue;
318
- const sessionId = f.replace('.jsonl', '');
319
- if (bySession.has(sessionId)) continue;
326
+ // Also read from the session archive as a fallback (contains cleaned-up sessions)
327
+ const archivePath = join(cwd, '.replit-tools', '.session-archive', 'claude', 'history.jsonl');
328
+ let archiveLines = [];
329
+ try {
330
+ if (existsSync(archivePath)) {
331
+ archiveLines = readFileSync(archivePath, 'utf8').split('\n').filter(Boolean);
332
+ }
333
+ } catch { /* non-fatal */ }
320
334
 
321
- try {
322
- const content = readFileSync(join(projectsDir, f), 'utf8');
323
- const lines = content.split('\n').filter(Boolean).slice(0, 50);
324
- let firstPrompt = null;
325
- let lastTimestamp = 0;
335
+ for (const line of archiveLines) {
336
+ try {
337
+ const entry = JSON.parse(line);
338
+ if (!entry.sessionId) continue;
339
+ if (bySession.has(entry.sessionId)) continue; // already indexed from main history
326
340
 
327
- for (const line of lines) {
328
- try {
329
- const entry = JSON.parse(line);
330
- if (entry.timestamp && entry.timestamp > lastTimestamp) lastTimestamp = entry.timestamp;
331
- if (!firstPrompt && entry.type === 'user' && entry.message?.content) {
332
- const text = typeof entry.message.content === 'string'
333
- ? entry.message.content
334
- : entry.message.content?.[0]?.text;
335
- if (text && !text.startsWith('/') && text.length < 200) {
336
- firstPrompt = text;
337
- }
338
- }
339
- } catch { continue; }
340
- }
341
+ bySession.set(entry.sessionId, {
342
+ sessionId: entry.sessionId,
343
+ project: entry.project,
344
+ entries: [],
345
+ firstPrompt: null,
346
+ lastTimestamp: 0,
347
+ });
341
348
 
342
- if (lastTimestamp === 0) {
343
- const stat = statSync(join(projectsDir, f));
344
- lastTimestamp = Math.floor(stat.mtimeMs / 1000);
345
- }
349
+ const sess = bySession.get(entry.sessionId);
350
+ sess.entries.push(entry);
351
+ if (entry.timestamp > sess.lastTimestamp) sess.lastTimestamp = entry.timestamp;
352
+ if (!sess.firstPrompt && isRealPrompt(entry.display)) {
353
+ sess.firstPrompt = entry.display;
354
+ }
355
+ } catch { continue; }
356
+ }
346
357
 
347
- bySession.set(sessionId, {
348
- sessionId,
349
- project: '-home-runner-workspace',
350
- entries: [],
351
- firstPrompt: firstPrompt || sessionId.slice(0, 8) + '...',
352
- lastTimestamp,
353
- });
354
- } catch { continue; }
358
+ // For archive sessions with multiple entries, finish accumulating them
359
+ // (second pass for sessions newly added from archive)
360
+ for (const line of archiveLines) {
361
+ try {
362
+ const entry = JSON.parse(line);
363
+ if (!entry.sessionId) continue;
364
+ const sess = bySession.get(entry.sessionId);
365
+ if (!sess) continue;
366
+ // Already pushed in first pass for new sessions; skip double-push
367
+ if (sess.entries.includes(entry)) continue;
368
+ sess.entries.push(entry);
369
+ if (entry.timestamp > sess.lastTimestamp) sess.lastTimestamp = entry.timestamp;
370
+ if (!sess.firstPrompt && isRealPrompt(entry.display)) {
371
+ sess.firstPrompt = entry.display;
355
372
  }
356
- } catch { /* non-fatal */ }
373
+ } catch { continue; }
357
374
  }
358
375
 
359
376
  // Scan ~/.codex/sessions/ for codex session JSONLs (YYYY/MM/DD tree)
@@ -431,8 +448,22 @@ export function importReplitSessions(cwd = process.cwd()) {
431
448
  } catch { /* non-fatal */ }
432
449
  }
433
450
 
451
+ // Determine recency window from config (default 48 hours)
452
+ const configPath = join(cwd, '.replit-tools', 'config.json');
453
+ let windowHours = 48;
454
+ try {
455
+ if (existsSync(configPath)) {
456
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
457
+ windowHours = cfg.recentWindowHours || 48;
458
+ }
459
+ } catch { /* non-fatal */ }
460
+ const windowMs = windowHours * 60 * 60 * 1000;
461
+ const cutoff = Date.now() - windowMs;
462
+
434
463
  // Build session list
435
464
  for (const [id, sess] of bySession) {
465
+ // Skip sessions outside the recency window (timestamps are in seconds)
466
+ if (sess.lastTimestamp * 1000 < cutoff) continue;
436
467
  // Derive display name
437
468
  let name = sess.firstPrompt;
438
469
  if (!name) {
@@ -546,6 +577,450 @@ export function enrichSessions(sessions, cwd = process.cwd()) {
546
577
  return enriched;
547
578
  }
548
579
 
580
+ // ─── Persistence settings ─────────────────────────────────────────────────────
581
+
582
+ /**
583
+ * Ensure Claude and Codex are configured to retain session history indefinitely.
584
+ * Mirrors what replit-tools does to prevent session cleanup/deletion.
585
+ *
586
+ * @param {string} [cwd]
587
+ * @returns {string[]} List of changes made (empty if already configured)
588
+ */
589
+ export function ensurePersistence(cwd = process.cwd()) {
590
+ const home = process.env.HOME || '/root';
591
+ const results = [];
592
+
593
+ // 1. Claude: set cleanupPeriodDays
594
+ const claudeSettingsPaths = [
595
+ join(home, '.claude', 'settings.json'),
596
+ join(cwd, '.replit-tools', '.claude-persistent', 'settings.json'),
597
+ ];
598
+
599
+ for (const settingsPath of claudeSettingsPaths) {
600
+ if (!existsSync(settingsPath)) continue;
601
+ try {
602
+ let settings = {};
603
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { settings = {}; }
604
+ if (settings.cleanupPeriodDays !== 365250) {
605
+ settings.cleanupPeriodDays = 365250;
606
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
607
+ results.push('Claude cleanupPeriodDays set to 365250');
608
+ }
609
+ break; // only update one
610
+ } catch { continue; }
611
+ }
612
+
613
+ // 2. Codex: set history.persistence and max_bytes
614
+ const codexConfigPaths = [
615
+ join(home, '.codex', 'config.toml'),
616
+ join(cwd, '.replit-tools', '.codex-persistent', 'config.toml'),
617
+ ];
618
+
619
+ for (const configPath of codexConfigPaths) {
620
+ if (!existsSync(configPath)) continue;
621
+ try {
622
+ let content = readFileSync(configPath, 'utf8');
623
+ let changed = false;
624
+
625
+ if (!/\[history\]/.test(content)) {
626
+ content = content.trimEnd() + '\n\n[history]\npersistence = "save-all"\nmax_bytes = 104857600\n';
627
+ changed = true;
628
+ } else {
629
+ if (!/persistence\s*=/.test(content)) {
630
+ content = content.replace(/\[history\](\s*)/, '[history]$1persistence = "save-all"\n');
631
+ changed = true;
632
+ }
633
+ if (!/max_bytes\s*=/.test(content)) {
634
+ content = content.replace(/(persistence\s*=\s*"[^"]*"\s*\n)/, '$1max_bytes = 104857600\n');
635
+ changed = true;
636
+ }
637
+ }
638
+
639
+ if (changed) {
640
+ writeFileSync(configPath, content);
641
+ results.push('Codex history persistence enabled');
642
+ }
643
+ break;
644
+ } catch { continue; }
645
+ }
646
+
647
+ return results;
648
+ }
649
+
650
+ // ─── Session archive mirror sync ─────────────────────────────────────────────
651
+
652
+ /**
653
+ * Append-only mirror sync for Claude/Codex sessions (matches what replit-tools does).
654
+ * Files in the mirror only grow — if the source deletes a session, the mirror still has it.
655
+ *
656
+ * @param {string} [cwd]
657
+ * @returns {{ copied: number, grew: number, disabled?: boolean }}
658
+ */
659
+ export function syncSessionMirror(cwd = process.cwd()) {
660
+ const home = process.env.HOME || '/root';
661
+ const mirrorBase = join(cwd, '.replit-tools', '.session-archive');
662
+
663
+ // Check if replit-tools exists
664
+ if (!existsSync(join(cwd, '.replit-tools'))) return { copied: 0, grew: 0 };
665
+
666
+ // Check config — mirror can be disabled
667
+ const configPath = join(cwd, '.replit-tools', 'config.json');
668
+ try {
669
+ if (existsSync(configPath)) {
670
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
671
+ if (cfg.mirror && cfg.mirror.enabled === false) return { copied: 0, grew: 0, disabled: true };
672
+ }
673
+ } catch {}
674
+
675
+ let totalCopied = 0, totalGrew = 0;
676
+
677
+ function syncTree(srcDir, destDir) {
678
+ if (!existsSync(srcDir)) return;
679
+
680
+ function walk(dir) {
681
+ let entries;
682
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
683
+
684
+ for (const entry of entries) {
685
+ const srcPath = join(dir, entry.name);
686
+ const relPath = srcPath.slice(srcDir.length);
687
+ const destPath = join(destDir, relPath);
688
+
689
+ if (entry.isDirectory()) {
690
+ try { mkdirSync(destPath, { recursive: true }); } catch {}
691
+ walk(srcPath);
692
+ } else if (entry.isFile()) {
693
+ let destSize = 0;
694
+ try { destSize = statSync(destPath).size; } catch {}
695
+
696
+ let srcSize = 0;
697
+ try { srcSize = statSync(srcPath).size; } catch { continue; }
698
+
699
+ // Append-only: only copy if source is larger than mirror
700
+ if (srcSize > destSize) {
701
+ try {
702
+ mkdirSync(dirname(destPath), { recursive: true });
703
+ copyFileSync(srcPath, destPath);
704
+ if (destSize === 0) totalCopied++;
705
+ else totalGrew++;
706
+ } catch {}
707
+ }
708
+ }
709
+ }
710
+ }
711
+
712
+ walk(srcDir);
713
+ }
714
+
715
+ try { mkdirSync(mirrorBase, { recursive: true }); } catch {}
716
+
717
+ // Sync Claude sessions
718
+ const claudeDir = join(home, '.claude');
719
+ syncTree(join(claudeDir, 'projects'), join(mirrorBase, 'claude', 'projects'));
720
+ // Sync history.jsonl as a single file
721
+ const histSrc = join(claudeDir, 'history.jsonl');
722
+ const histDest = join(mirrorBase, 'claude', 'history.jsonl');
723
+ if (existsSync(histSrc)) {
724
+ try {
725
+ const srcSize = statSync(histSrc).size;
726
+ let destSize = 0;
727
+ try { destSize = statSync(histDest).size; } catch {}
728
+ if (srcSize > destSize) {
729
+ mkdirSync(dirname(histDest), { recursive: true });
730
+ copyFileSync(histSrc, histDest);
731
+ if (destSize === 0) totalCopied++; else totalGrew++;
732
+ }
733
+ } catch {}
734
+ }
735
+
736
+ // Sync Codex sessions
737
+ const codexDir = join(home, '.codex');
738
+ syncTree(join(codexDir, 'sessions'), join(mirrorBase, 'codex', 'sessions'));
739
+
740
+ return { copied: totalCopied, grew: totalGrew };
741
+ }
742
+
743
+ // ─── Session index ────────────────────────────────────────────────────────────
744
+
745
+ /**
746
+ * Build/update `.dualbrain/session-index.json` from Claude and Codex JSONL session files.
747
+ * Extracts topics, file references, prompt snippets, and metadata per session.
748
+ *
749
+ * @param {string} [cwd]
750
+ * @returns {object} index — keyed by session UUID
751
+ */
752
+ export function buildSessionIndex(cwd = process.cwd()) {
753
+ const home = process.env.HOME || '/root';
754
+ const indexPath = join(cwd, '.dualbrain', 'session-index.json');
755
+
756
+ // Load existing index
757
+ let index = {};
758
+ try {
759
+ if (existsSync(indexPath)) {
760
+ index = JSON.parse(readFileSync(indexPath, 'utf8'));
761
+ }
762
+ } catch {}
763
+
764
+ // Find all session JSONLs
765
+ const sources = [
766
+ join(home, '.claude', 'projects', '-home-runner-workspace'),
767
+ join(cwd, '.replit-tools', '.session-archive', 'claude', 'projects', '-home-runner-workspace'),
768
+ ];
769
+
770
+ const STOP_WORDS = new Set(['the','and','this','that','with','from','have','been','will','would','could','should','just','also','into','about','some','what','when','where','which','their','there','then','than','them','these','those','other','more','only','very','each','most','like','make','want','need','does','dont','didnt','cant','wont','your','they','were','are','for','not','but','was','you','all','can','had','her','one','our','out','use','its','let','get','has','him','his','how','did','got','may','new','now','old','see','way','who','any','few','said']);
771
+
772
+ for (const dir of sources) {
773
+ if (!existsSync(dir)) continue;
774
+ let files;
775
+ try { files = readdirSync(dir); } catch { continue; }
776
+
777
+ for (const f of files) {
778
+ if (!f.endsWith('.jsonl') || f.startsWith('agent-')) continue;
779
+ const sessionId = f.replace('.jsonl', '');
780
+
781
+ // Skip if already indexed and file hasn't grown
782
+ const filePath = join(dir, f);
783
+ let fileSize = 0;
784
+ try { fileSize = statSync(filePath).size; } catch { continue; }
785
+ if (index[sessionId] && index[sessionId]._fileSize >= fileSize) continue;
786
+
787
+ // Parse session
788
+ try {
789
+ const content = readFileSync(filePath, 'utf8');
790
+ const lines = content.split('\n').filter(Boolean);
791
+
792
+ const wordCounts = {};
793
+ const fileSet = new Set();
794
+ let firstPrompt = null;
795
+ let lastPrompt = null;
796
+ let lastTimestamp = 0;
797
+ let messageCount = 0;
798
+
799
+ for (const line of lines) {
800
+ try {
801
+ const entry = JSON.parse(line);
802
+
803
+ // Track timestamps
804
+ if (entry.timestamp) {
805
+ const ts = typeof entry.timestamp === 'number' ? entry.timestamp : Date.parse(entry.timestamp) / 1000;
806
+ if (ts > lastTimestamp) lastTimestamp = ts;
807
+ }
808
+
809
+ // Extract user messages
810
+ let text = null;
811
+ if (entry.type === 'user' && entry.message?.content) {
812
+ text = typeof entry.message.content === 'string'
813
+ ? entry.message.content
814
+ : entry.message.content?.[0]?.text;
815
+ }
816
+ if (entry.display) text = text || entry.display;
817
+
818
+ if (!text) continue;
819
+ messageCount++;
820
+
821
+ if (!firstPrompt) firstPrompt = text.slice(0, 80);
822
+ lastPrompt = text.slice(0, 80);
823
+
824
+ // Extract file paths
825
+ const filePaths = text.match(/[\w./~-]+\.(?:mjs|js|ts|tsx|jsx|json|md|css|html|py|sh|sql|toml|yaml|yml)\b/g);
826
+ if (filePaths) filePaths.forEach(p => fileSet.add(p));
827
+
828
+ // Count words for topics
829
+ const words = text.toLowerCase().split(/\W+/).filter(w => w.length > 3 && !STOP_WORDS.has(w));
830
+ for (const w of words) {
831
+ wordCounts[w] = (wordCounts[w] || 0) + 1;
832
+ }
833
+ } catch { continue; }
834
+ }
835
+
836
+ // Top 10 topics by frequency
837
+ const topics = Object.entries(wordCounts)
838
+ .sort((a, b) => b[1] - a[1])
839
+ .slice(0, 10)
840
+ .map(([w]) => w);
841
+
842
+ index[sessionId] = {
843
+ id: sessionId,
844
+ topics,
845
+ files: [...fileSet].slice(0, 20),
846
+ prompts: { first: firstPrompt || '', last: lastPrompt || '' },
847
+ date: lastTimestamp ? new Date(lastTimestamp * 1000).toISOString() : null,
848
+ messageCount,
849
+ tool: 'claude',
850
+ _fileSize: fileSize,
851
+ };
852
+ } catch { continue; }
853
+ }
854
+ }
855
+
856
+ // Also index codex sessions (same pattern)
857
+ const codexDir = join(home, '.codex', 'sessions');
858
+ if (existsSync(codexDir)) {
859
+ const walk = (dir) => {
860
+ let results = [];
861
+ try {
862
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
863
+ const full = join(dir, entry.name);
864
+ if (entry.isDirectory()) results = results.concat(walk(full));
865
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) results.push(full);
866
+ }
867
+ } catch {}
868
+ return results;
869
+ };
870
+
871
+ for (const filePath of walk(codexDir)) {
872
+ try {
873
+ const content = readFileSync(filePath, 'utf8');
874
+ const lines = content.split('\n').filter(Boolean);
875
+ if (!lines.length) continue;
876
+ const meta = JSON.parse(lines[0]);
877
+ if (meta.type !== 'session_meta' || !meta.payload) continue;
878
+ const id = meta.payload.id;
879
+ if (!id || index[id]) continue;
880
+
881
+ let fileSize = 0;
882
+ try { fileSize = statSync(filePath).size; } catch { continue; }
883
+
884
+ let firstPrompt = null, lastPrompt = null, messageCount = 0;
885
+ let lastTimestamp = Date.parse(meta.payload.timestamp || meta.timestamp) / 1000 || 0;
886
+
887
+ for (const ln of lines) {
888
+ try {
889
+ const j = JSON.parse(ln);
890
+ if (j.timestamp) {
891
+ const ts = Date.parse(j.timestamp) / 1000;
892
+ if (ts > lastTimestamp) lastTimestamp = ts;
893
+ }
894
+ if (j.type === 'event_msg' && j.payload?.type === 'user_message') {
895
+ const text = (j.payload.message || '').trim();
896
+ if (text) {
897
+ messageCount++;
898
+ if (!firstPrompt) firstPrompt = text.slice(0, 80);
899
+ lastPrompt = text.slice(0, 80);
900
+ }
901
+ }
902
+ } catch { continue; }
903
+ }
904
+
905
+ index[id] = {
906
+ id, topics: [], files: [],
907
+ prompts: { first: firstPrompt || '', last: lastPrompt || '' },
908
+ date: lastTimestamp ? new Date(lastTimestamp * 1000).toISOString() : null,
909
+ messageCount, tool: 'codex', _fileSize: fileSize,
910
+ };
911
+ } catch { continue; }
912
+ }
913
+ }
914
+
915
+ // Save index
916
+ try {
917
+ mkdirSync(join(cwd, '.dualbrain'), { recursive: true });
918
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
919
+ } catch {}
920
+
921
+ return index;
922
+ }
923
+
924
+ /**
925
+ * Search the session index by keyword. Returns matching sessions sorted by relevance.
926
+ *
927
+ * @param {string} query
928
+ * @param {string} [cwd]
929
+ * @returns {Array<object>} sessions with `_score` field, sorted descending
930
+ */
931
+ export function searchSessions(query, cwd = process.cwd()) {
932
+ const indexPath = join(cwd, '.dualbrain', 'session-index.json');
933
+ let index = {};
934
+ try { index = JSON.parse(readFileSync(indexPath, 'utf8')); } catch {}
935
+
936
+ if (Object.keys(index).length === 0) {
937
+ index = buildSessionIndex(cwd);
938
+ }
939
+
940
+ const terms = query.toLowerCase().split(/\W+/).filter(Boolean);
941
+ const results = [];
942
+
943
+ for (const session of Object.values(index)) {
944
+ let score = 0;
945
+ const searchText = [
946
+ ...session.topics,
947
+ ...session.files,
948
+ session.prompts.first,
949
+ session.prompts.last,
950
+ ].join(' ').toLowerCase();
951
+
952
+ for (const term of terms) {
953
+ if (searchText.includes(term)) score++;
954
+ if (session.topics.includes(term)) score += 2;
955
+ if (session.files.some(f => f.includes(term))) score += 2;
956
+ }
957
+
958
+ if (score > 0) {
959
+ results.push({ ...session, _score: score });
960
+ }
961
+ }
962
+
963
+ return results.sort((a, b) => b._score - a._score);
964
+ }
965
+
966
+ /**
967
+ * Get detailed context for a session (for smart resume preview).
968
+ * Reads the last 20 lines of the session JSONL to surface the most recent prompt
969
+ * and files touched.
970
+ *
971
+ * @param {string} sessionId
972
+ * @param {string} [cwd]
973
+ * @returns {{ lastPrompt: string|null, filesTouched: string[], totalLines: number }|null}
974
+ */
975
+ export function getSessionContext(sessionId, cwd = process.cwd()) {
976
+ const home = process.env.HOME || '/root';
977
+ const paths = [
978
+ join(home, '.claude', 'projects', '-home-runner-workspace', sessionId + '.jsonl'),
979
+ join(cwd, '.replit-tools', '.session-archive', 'claude', 'projects', '-home-runner-workspace', sessionId + '.jsonl'),
980
+ ];
981
+
982
+ let filePath = null;
983
+ for (const p of paths) {
984
+ if (existsSync(p)) { filePath = p; break; }
985
+ }
986
+ if (!filePath) return null;
987
+
988
+ try {
989
+ const content = readFileSync(filePath, 'utf8');
990
+ const lines = content.split('\n').filter(Boolean);
991
+
992
+ // Read last 20 lines for recent context
993
+ const recentLines = lines.slice(-20);
994
+ let lastUserPrompt = null;
995
+ const filesSet = new Set();
996
+
997
+ for (const line of recentLines) {
998
+ try {
999
+ const entry = JSON.parse(line);
1000
+ if (entry.type === 'user' && entry.message?.content) {
1001
+ const text = typeof entry.message.content === 'string'
1002
+ ? entry.message.content
1003
+ : entry.message.content?.[0]?.text;
1004
+ if (text) lastUserPrompt = text.slice(0, 120);
1005
+ }
1006
+ if (entry.display) lastUserPrompt = entry.display.slice(0, 120);
1007
+
1008
+ // Look for file edits in tool use
1009
+ if (entry.type === 'tool_use' || entry.type === 'tool_result') {
1010
+ const fp = entry.tool_input?.file_path || entry.tool_input?.path;
1011
+ if (fp) filesSet.add(fp.split('/').pop());
1012
+ }
1013
+ } catch { continue; }
1014
+ }
1015
+
1016
+ return {
1017
+ lastPrompt: lastUserPrompt,
1018
+ filesTouched: [...filesSet].slice(0, 5),
1019
+ totalLines: lines.length,
1020
+ };
1021
+ } catch { return null; }
1022
+ }
1023
+
549
1024
  // ─── CLI (direct invocation) ──────────────────────────────────────────────────
550
1025
 
551
1026
  const isMain = process.argv[1]?.endsWith('session.mjs');