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 +10 -0
- package/bin/dual-brain.mjs +191 -1
- package/mcp-server/index.mjs +58 -0
- package/package.json +1 -1
- package/src/index.mjs +1 -1
- package/src/session.mjs +281 -0
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.
|
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/mcp-server/index.mjs
CHANGED
|
@@ -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
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');
|