dual-brain 7.1.13 → 7.1.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/CLAUDE.md CHANGED
@@ -135,3 +135,13 @@ For most tasks, prefer `dual-brain go "..."` — it runs the same detect→decid
135
135
  | `node .claude/hooks/health-check.mjs` | System health |
136
136
  | `node .claude/hooks/test-orchestrator.mjs` | Self-tests (40 tests) |
137
137
  | `node .claude/hooks/vibe-memory.mjs` | Persistent preferences across sessions |
138
+ | `dual-brain search "..."` | Search across all previous sessions |
139
+
140
+ ## Cross-Session Context
141
+
142
+ When the user references past work ("we did this before", "yesterday we worked on", "remember when we", "didn't we already fix"), use the session search to find relevant context:
143
+
144
+ 1. Run `dual-brain search "keyword"` to search the session index
145
+ 2. Or use the MCP tool `dual_brain_search` if available
146
+
147
+ This surfaces previous conversations so HEAD can provide continuity across sessions without the user having to re-explain.
@@ -95,6 +95,40 @@ function cleanStaleMarkers(cwd) {
95
95
  } catch {}
96
96
  }
97
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
+
98
132
  function daysUntil(isoDate) {
99
133
  if (!isoDate) return null;
100
134
  const ms = Date.parse(isoDate) - Date.now();
@@ -134,6 +168,7 @@ Commands:
134
168
  cool <provider> Manually clear hot state for a provider
135
169
  remember "preference" Save a project-scoped preference
136
170
  forget "preference" Remove a preference by fuzzy match
171
+ search "keyword" Search across all sessions
137
172
  shell-hook Output bash snippet to add dual-brain to your shell
138
173
  Usage: dual-brain shell-hook >> ~/.bashrc
139
174
 
@@ -148,6 +183,30 @@ Options:
148
183
  `.trim());
149
184
  }
150
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
+
151
210
  // ─── Subscription status table ────────────────────────────────────────────────
152
211
 
153
212
  /**
@@ -677,9 +736,26 @@ async function welcomeScreen(rl, ask) {
677
736
  detectedLines.push(` ${existingSessions.length} session${existingSessions.length !== 1 ? 's' : ''} found from data-tools`);
678
737
  }
679
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
+
680
756
  // Show detection results in a box
681
757
  const detectedFormatted = detectedLines.map(line => {
682
- const ok = !line.includes('not logged');
758
+ const ok = !line.includes('not logged') && !line.includes('not found');
683
759
  return `${ok ? '✅' : '⚠️ '} ${line.trim()}`;
684
760
  });
685
761
  console.log('');
@@ -699,6 +775,11 @@ async function welcomeScreen(rl, ask) {
699
775
  if (existingSessions.length > 0) {
700
776
  console.log(` [i] Import ${existingSessions.length} session${existingSessions.length !== 1 ? 's' : ''} from data-tools`);
701
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
+ }
702
783
  console.log('');
703
784
 
704
785
  const choice = (await ask(' Choice: ')).trim().toLowerCase();
@@ -948,6 +1029,17 @@ async function mainScreen(rl, ask) {
948
1029
  console.log(` ${line}`);
949
1030
  }
950
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
+
951
1043
  // Silent OAuth token auto-refresh (like data-tools)
952
1044
  try {
953
1045
  const { autoRefreshToken } = await import('../src/profile.mjs');
@@ -992,6 +1084,12 @@ async function mainScreen(rl, ask) {
992
1084
  }
993
1085
  console.log('');
994
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
+
995
1093
  const recentSessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 7);
996
1094
 
997
1095
  if (recentSessions.length > 0) {
@@ -1035,6 +1133,7 @@ async function mainScreen(rl, ask) {
1035
1133
  console.log(' [1-9] Resume numbered above');
1036
1134
  }
1037
1135
  console.log(' [r] Resume (full list)');
1136
+ console.log(' [/] Search sessions');
1038
1137
  console.log(' [e] Manage sessions');
1039
1138
  console.log(' [i] Import from replit-tools');
1040
1139
  console.log(' [m] Manage subscriptions');
@@ -1061,6 +1160,17 @@ async function mainScreen(rl, ask) {
1061
1160
  return { next: 'main' };
1062
1161
  }
1063
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
+
1064
1174
  const { spawnSync } = await import('node:child_process');
1065
1175
  const tool = termState?.tool || 'claude';
1066
1176
  console.log(`\n Resuming: ${tool} --resume ${targetId}\n`);
@@ -1072,6 +1182,18 @@ async function mainScreen(rl, ask) {
1072
1182
  const numChoice = parseInt(choice, 10);
1073
1183
  if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= recentSessions.length) {
1074
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
+
1075
1197
  const { spawnSync } = await import('node:child_process');
1076
1198
  console.log(`\n Launching: claude --resume ${sess.id}\n`);
1077
1199
  spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
@@ -1109,6 +1231,43 @@ async function mainScreen(rl, ask) {
1109
1231
  return { next: 'main' };
1110
1232
  }
1111
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
+ }
1268
+ return { next: 'main' };
1269
+ }
1270
+
1112
1271
  if (choice === 'e') { return { next: 'sessions' }; }
1113
1272
 
1114
1273
  if (choice === 'i') {
@@ -2147,6 +2306,37 @@ async function main() {
2147
2306
  if (cmd === 'remember') { cmdRemember(args[1]); return; }
2148
2307
  if (cmd === 'forget') { cmdForget(args[1]); return; }
2149
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
+
2150
2340
  if (cmd === 'shell-hook') {
2151
2341
  // Output a bash snippet users can add to their .bashrc or source directly.
2152
2342
  const hook = `
@@ -99,6 +99,34 @@ const TOOLS = [
99
99
  required: ['preference'],
100
100
  },
101
101
  },
102
+ {
103
+ name: 'dual_brain_search',
104
+ description: 'Search across all previous sessions for context. Use this when the user references past work ("we did this before", "yesterday we worked on", "remember when we", "didn\'t we already"). Returns matching sessions with prompts, topics, and files.',
105
+ inputSchema: {
106
+ type: 'object',
107
+ properties: {
108
+ query: {
109
+ type: 'string',
110
+ description: 'Search keywords to find in previous sessions. Can be topic, file name, or description of past work.',
111
+ },
112
+ },
113
+ required: ['query'],
114
+ },
115
+ },
116
+ {
117
+ name: 'dual_brain_session_context',
118
+ description: 'Get detailed context from a specific previous session. Use after dual_brain_search to retrieve details about what happened in a found session.',
119
+ inputSchema: {
120
+ type: 'object',
121
+ properties: {
122
+ sessionId: {
123
+ type: 'string',
124
+ description: 'The session UUID to get context for (from search results).',
125
+ },
126
+ },
127
+ required: ['sessionId'],
128
+ },
129
+ },
102
130
  ];
103
131
 
104
132
  // ─── Tool handlers ────────────────────────────────────────────────────────────
@@ -217,6 +245,34 @@ async function handleRemember({ preference }) {
217
245
  };
218
246
  }
219
247
 
248
+ async function handleSearch({ query }) {
249
+ const { searchSessions, buildSessionIndex } = await import(`${SRC}/session.mjs`);
250
+ const cwd = process.cwd();
251
+ try { buildSessionIndex(cwd); } catch {}
252
+ const results = searchSessions(query, cwd);
253
+ return {
254
+ count: results.length,
255
+ sessions: results.slice(0, 5).map(s => ({
256
+ id: s.id,
257
+ tool: s.tool,
258
+ date: s.date,
259
+ firstPrompt: s.prompts.first,
260
+ lastPrompt: s.prompts.last,
261
+ topics: s.topics.slice(0, 5),
262
+ files: s.files.slice(0, 10),
263
+ messageCount: s.messageCount,
264
+ })),
265
+ };
266
+ }
267
+
268
+ async function handleSessionContext({ sessionId }) {
269
+ const { getSessionContext } = await import(`${SRC}/session.mjs`);
270
+ const cwd = process.cwd();
271
+ const ctx = getSessionContext(sessionId, cwd);
272
+ if (!ctx) return { error: 'Session not found' };
273
+ return ctx;
274
+ }
275
+
220
276
  // ─── JSON-RPC dispatcher ──────────────────────────────────────────────────────
221
277
 
222
278
  async function dispatchTool(name, args) {
@@ -225,6 +281,8 @@ async function dispatchTool(name, args) {
225
281
  case 'dual_brain_decide': return handleDecide(args);
226
282
  case 'dual_brain_status': return handleStatus();
227
283
  case 'dual_brain_remember': return handleRemember(args);
284
+ case 'dual_brain_search': return handleSearch(args);
285
+ case 'dual_brain_session_context': return handleSessionContext(args);
228
286
  default:
229
287
  throw Object.assign(new Error(`Unknown tool: ${name}`), { code: -32601 });
230
288
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "7.1.13",
3
+ "version": "7.1.15",
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
@@ -13,7 +13,7 @@ export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrai
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, ensurePersistence, syncSessionMirror } 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/session.mjs CHANGED
@@ -740,6 +740,287 @@ export function syncSessionMirror(cwd = process.cwd()) {
740
740
  return { copied: totalCopied, grew: totalGrew };
741
741
  }
742
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
+
743
1024
  // ─── CLI (direct invocation) ──────────────────────────────────────────────────
744
1025
 
745
1026
  const isMain = process.argv[1]?.endsWith('session.mjs');