dual-brain 7.1.13 → 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.
- package/bin/dual-brain.mjs +191 -1
- package/package.json +1 -1
- package/src/index.mjs +1 -1
- package/src/session.mjs +281 -0
package/bin/dual-brain.mjs
CHANGED
|
@@ -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 = `
|
package/package.json
CHANGED
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');
|