dual-brain 0.1.1 → 0.1.3
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 +369 -315
- package/package.json +1 -1
- package/src/session.mjs +181 -9
package/bin/dual-brain.mjs
CHANGED
|
@@ -998,112 +998,94 @@ function loadTerminalState(cwd, terminalId) {
|
|
|
998
998
|
} catch { return null; }
|
|
999
999
|
}
|
|
1000
1000
|
|
|
1001
|
-
// ───
|
|
1002
|
-
|
|
1003
|
-
async function mainScreen(rl, ask) {
|
|
1004
|
-
const cwd = process.cwd();
|
|
1005
|
-
const version = readVersion();
|
|
1006
|
-
const profile = loadProfile(cwd);
|
|
1007
|
-
const auth = await detectAuth();
|
|
1001
|
+
// ─── Dashboard box helpers ────────────────────────────────────────────────────
|
|
1008
1002
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1003
|
+
/**
|
|
1004
|
+
* Build a provider status string for the dashboard status line.
|
|
1005
|
+
* Returns a string like: "🟢 Claude $100×2 $20×1 🟢 OpenAI $100"
|
|
1006
|
+
* Uses ANSI color codes for the dots (no emoji width issues).
|
|
1007
|
+
*/
|
|
1008
|
+
function buildProviderStatusLine(profile, auth) {
|
|
1009
|
+
const GREEN = '\x1b[32m●\x1b[0m';
|
|
1010
|
+
const RED = '\x1b[31m●\x1b[0m';
|
|
1011
|
+
const now = Date.now();
|
|
1013
1012
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1013
|
+
function providerSegment(provKey, displayName) {
|
|
1014
|
+
const sub = profile?.providers?.[provKey];
|
|
1015
|
+
const found = provKey === 'claude' ? auth.claude.found : auth.openai.found;
|
|
1016
|
+
if (!found) return `${RED} ${displayName}: not connected`;
|
|
1018
1017
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1018
|
+
const expired = sub?.expiresAt && Date.parse(sub.expiresAt) < now;
|
|
1019
|
+
if (expired) return `${RED} ${displayName}: expired`;
|
|
1021
1020
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
if (!found) return `⚠️ ${name}: not logged in — run: ${name === 'Claude' ? 'claude auth login' : 'codex login'}`;
|
|
1025
|
-
// Multi-sub: show aggregated counts when more than one sub exists
|
|
1021
|
+
const dot = GREEN;
|
|
1022
|
+
// Multi-sub: show aggregated plan amounts
|
|
1026
1023
|
const subs = sub?.subs;
|
|
1027
|
-
if (subs && subs.length >
|
|
1028
|
-
const
|
|
1029
|
-
return
|
|
1024
|
+
if (subs && subs.length > 0) {
|
|
1025
|
+
const agg = aggregatePlans(subs);
|
|
1026
|
+
return `${dot} ${displayName} ${agg}`;
|
|
1030
1027
|
}
|
|
1031
|
-
|
|
1032
|
-
const
|
|
1033
|
-
return
|
|
1028
|
+
// Single plan
|
|
1029
|
+
const planPrice = PLAN_PRICES[sub?.plan] || sub?.plan || 'connected';
|
|
1030
|
+
return `${dot} ${displayName} ${planPrice}`;
|
|
1034
1031
|
}
|
|
1035
1032
|
|
|
1036
|
-
const
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1033
|
+
const parts = [];
|
|
1034
|
+
parts.push(providerSegment('claude', 'Claude'));
|
|
1035
|
+
parts.push(providerSegment('openai', 'OpenAI'));
|
|
1036
|
+
return parts.join(' ');
|
|
1037
|
+
}
|
|
1040
1038
|
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1039
|
+
/**
|
|
1040
|
+
* Render a box row padded to inner width W (stripping ANSI for length calculation).
|
|
1041
|
+
* Returns a string like: "│ content padded to W │"
|
|
1042
|
+
*/
|
|
1043
|
+
function makeBoxRow(content, W) {
|
|
1044
|
+
const plain = content.replace(/\x1b\[[0-9;]*m/g, '');
|
|
1045
|
+
const padding = Math.max(0, W - plain.length);
|
|
1046
|
+
return `│ ${content}${' '.repeat(padding)} │`;
|
|
1047
|
+
}
|
|
1050
1048
|
|
|
1051
|
-
|
|
1052
|
-
for (const line of headerLines) {
|
|
1053
|
-
console.log(` ${line}`);
|
|
1054
|
-
}
|
|
1055
|
-
if (dtVersion) {
|
|
1056
|
-
console.log(` 📦 data-tools v${dtVersion} detected`);
|
|
1057
|
-
}
|
|
1049
|
+
// ─── Screen: mainScreen ───────────────────────────────────────────────────────
|
|
1058
1050
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1051
|
+
async function mainScreen(rl, ask) {
|
|
1052
|
+
const cwd = process.cwd();
|
|
1053
|
+
const version = readVersion();
|
|
1054
|
+
const profile = loadProfile(cwd);
|
|
1055
|
+
const auth = await detectAuth();
|
|
1056
|
+
|
|
1057
|
+
const claudeSub = profile?.providers?.claude;
|
|
1058
|
+
const openaiSub = profile?.providers?.openai;
|
|
1063
1059
|
|
|
1064
|
-
//
|
|
1060
|
+
// Check subscription expiry for auto-refresh
|
|
1061
|
+
const now = Date.now();
|
|
1062
|
+
const claudeExpired = claudeSub?.expiresAt && Date.parse(claudeSub.expiresAt) < now;
|
|
1063
|
+
const openaiExpired = openaiSub?.expiresAt && Date.parse(openaiSub.expiresAt) < now;
|
|
1064
|
+
|
|
1065
|
+
// Silent OAuth token auto-refresh
|
|
1065
1066
|
try {
|
|
1066
1067
|
const { autoRefreshToken } = await import('../src/profile.mjs');
|
|
1067
|
-
|
|
1068
|
-
if (refreshResult.status === 'refreshed') {
|
|
1069
|
-
console.log(` 🔄 Token auto-refreshed (${refreshResult.hoursRemaining}h remaining)`);
|
|
1070
|
-
}
|
|
1068
|
+
await autoRefreshToken(cwd);
|
|
1071
1069
|
} catch {}
|
|
1072
1070
|
|
|
1073
|
-
// Append-only session archive sync
|
|
1071
|
+
// Append-only session archive sync
|
|
1074
1072
|
try {
|
|
1075
1073
|
const { syncSessionMirror } = await import('../src/session.mjs');
|
|
1076
|
-
|
|
1077
|
-
if (mirror.copied > 0 || mirror.grew > 0) {
|
|
1078
|
-
console.log(` ✅ Archive mirror: +${mirror.copied} new, ${mirror.grew} updated`);
|
|
1079
|
-
}
|
|
1074
|
+
syncSessionMirror(cwd);
|
|
1080
1075
|
} catch {}
|
|
1081
1076
|
|
|
1082
1077
|
// Auto-refresh expired subscriptions
|
|
1083
1078
|
if (claudeExpired || openaiExpired) {
|
|
1084
1079
|
const { spawnSync } = await import('node:child_process');
|
|
1085
|
-
const expired = [];
|
|
1086
|
-
if (claudeExpired) expired.push('Claude');
|
|
1087
|
-
if (openaiExpired) expired.push('OpenAI');
|
|
1088
|
-
console.log(`\n ${expired.join(' & ')} subscription expired. Re-authenticating...`);
|
|
1089
1080
|
if (claudeExpired) {
|
|
1090
1081
|
const r = spawnSync('claude', ['auth', 'login'], { stdio: 'inherit', timeout: 30000 });
|
|
1091
|
-
if (r.status === 0) {
|
|
1092
|
-
claudeSub.expiresAt = null;
|
|
1093
|
-
saveProfile(profile, { cwd });
|
|
1094
|
-
console.log(' ✓ Claude re-authenticated');
|
|
1095
|
-
}
|
|
1082
|
+
if (r.status === 0) { claudeSub.expiresAt = null; saveProfile(profile, { cwd }); }
|
|
1096
1083
|
}
|
|
1097
1084
|
if (openaiExpired) {
|
|
1098
1085
|
const r = spawnSync('codex', ['login'], { stdio: 'inherit', timeout: 30000 });
|
|
1099
|
-
if (r.status === 0) {
|
|
1100
|
-
openaiSub.expiresAt = null;
|
|
1101
|
-
saveProfile(profile, { cwd });
|
|
1102
|
-
console.log(' ✓ OpenAI re-authenticated');
|
|
1103
|
-
}
|
|
1086
|
+
if (r.status === 0) { openaiSub.expiresAt = null; saveProfile(profile, { cwd }); }
|
|
1104
1087
|
}
|
|
1105
1088
|
}
|
|
1106
|
-
console.log('');
|
|
1107
1089
|
|
|
1108
1090
|
// Build session index in background (powers search + smart resume)
|
|
1109
1091
|
try {
|
|
@@ -1111,245 +1093,284 @@ async function mainScreen(rl, ask) {
|
|
|
1111
1093
|
buildSessionIndex(cwd);
|
|
1112
1094
|
} catch {}
|
|
1113
1095
|
|
|
1114
|
-
|
|
1096
|
+
// Gather recent sessions
|
|
1097
|
+
const recentSessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 3);
|
|
1098
|
+
|
|
1099
|
+
// Detect data-tools version
|
|
1100
|
+
const rtMain = detectReplitTools(cwd);
|
|
1101
|
+
const dtVersion = (rtMain.installed && rtMain.version) ? rtMain.version : null;
|
|
1102
|
+
|
|
1103
|
+
// ── Box layout ────────────────────────────────────────────────────────────
|
|
1104
|
+
const termW = process.stdout.columns || 60;
|
|
1105
|
+
const boxW = Math.min(termW - 2, 60); // outer width (including │ │)
|
|
1106
|
+
const W = boxW - 4; // inner content width (│ {content} │)
|
|
1107
|
+
|
|
1108
|
+
const top = `┌${'─'.repeat(boxW - 2)}┐`;
|
|
1109
|
+
const sep = `├${'─'.repeat(boxW - 2)}┤`;
|
|
1110
|
+
const bot = `└${'─'.repeat(boxW - 2)}┘`;
|
|
1111
|
+
|
|
1112
|
+
const row = (content) => makeBoxRow(content, W);
|
|
1113
|
+
|
|
1114
|
+
// ── Header: one line above the box ────────────────────────────────────────
|
|
1115
|
+
process.stdout.write(`\n🧠 dual-brain v${version}\n`);
|
|
1116
|
+
{
|
|
1117
|
+
let gitName = '';
|
|
1118
|
+
try {
|
|
1119
|
+
const { execSync } = await import('node:child_process');
|
|
1120
|
+
gitName = execSync('git config user.name', { encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
|
|
1121
|
+
} catch { /* ignore */ }
|
|
1122
|
+
if (gitName) {
|
|
1123
|
+
const hour = new Date().getHours();
|
|
1124
|
+
let greet;
|
|
1125
|
+
if (hour >= 5 && hour <= 11) greet = 'Good morning';
|
|
1126
|
+
else if (hour >= 12 && hour <= 16) greet = 'Good afternoon';
|
|
1127
|
+
else if (hour >= 17 && hour <= 21) greet = 'Good evening';
|
|
1128
|
+
else greet = 'Late night';
|
|
1129
|
+
process.stdout.write(`\x1b[2m${greet}, ${gitName}\x1b[0m\n`);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1115
1132
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1133
|
+
// ── Status section ────────────────────────────────────────────────────────
|
|
1134
|
+
const providerLine = buildProviderStatusLine(profile, auth);
|
|
1135
|
+
|
|
1136
|
+
const statusRows = [row(providerLine)];
|
|
1137
|
+
if (dtVersion) {
|
|
1138
|
+
statusRows.push(row(`\x1b[2m📦 data-tools v${dtVersion}\x1b[0m`));
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// ── Sessions section ──────────────────────────────────────────────────────
|
|
1142
|
+
const sessionRows = [];
|
|
1143
|
+
if (recentSessions.length === 0) {
|
|
1144
|
+
const noSessMsg = 'No sessions yet. Press n to start.';
|
|
1145
|
+
sessionRows.push(row(noSessMsg));
|
|
1146
|
+
} else {
|
|
1118
1147
|
recentSessions.forEach((sess, i) => {
|
|
1119
|
-
|
|
1120
|
-
const active = sess.isActive ? ' ●' : '';
|
|
1121
|
-
const cat = sess.category ? ` [${sess.category}]` : '';
|
|
1122
|
-
const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
|
|
1123
|
-
// If the name is still the "Session XXXXXXXX" fallback, try the project path instead
|
|
1148
|
+
// Normalize name: strip "Session XXXXXXXX" fallbacks
|
|
1124
1149
|
let rawName = sess.name || '';
|
|
1125
1150
|
if (/^Session [0-9a-f]{8,}$/i.test(rawName)) {
|
|
1126
|
-
rawName = sess.project
|
|
1151
|
+
rawName = sess.project
|
|
1152
|
+
? sess.project.replace(/^-/, '/').replace(/-/g, '/')
|
|
1153
|
+
: sess.id.slice(0, 8);
|
|
1127
1154
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1155
|
+
// Layout: "{num} {name...} {age}"
|
|
1156
|
+
const numStr = String(i + 1);
|
|
1157
|
+
const ageStr = sess.age || '';
|
|
1158
|
+
// Available for name: W - numStr.length - 2 spaces - 2 spaces before age - ageStr.length
|
|
1159
|
+
const nameMax = W - numStr.length - 2 - 2 - ageStr.length;
|
|
1160
|
+
const name = rawName.length > nameMax
|
|
1161
|
+
? rawName.slice(0, nameMax - 3) + '...'
|
|
1162
|
+
: rawName.padEnd(nameMax);
|
|
1163
|
+
const content = `${numStr} ${name} ${ageStr}`;
|
|
1164
|
+
sessionRows.push(row(content));
|
|
1130
1165
|
});
|
|
1131
|
-
console.log('');
|
|
1132
1166
|
}
|
|
1133
1167
|
|
|
1134
|
-
|
|
1135
|
-
const
|
|
1136
|
-
const
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1168
|
+
// ── Actions bar ───────────────────────────────────────────────────────────
|
|
1169
|
+
const actionsContent = '↵ Resume n New / Search s Settings q Quit';
|
|
1170
|
+
const actionsRow = row(actionsContent);
|
|
1171
|
+
|
|
1172
|
+
// ── Print the full box ────────────────────────────────────────────────────
|
|
1173
|
+
const lines = [
|
|
1174
|
+
top,
|
|
1175
|
+
...statusRows,
|
|
1176
|
+
sep,
|
|
1177
|
+
...sessionRows,
|
|
1178
|
+
sep,
|
|
1179
|
+
actionsRow,
|
|
1180
|
+
bot,
|
|
1181
|
+
];
|
|
1182
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
1183
|
+
process.stdout.write(`\x1b[2mPowered by data-tools · Steve Moraco\x1b[0m\n\n`);
|
|
1184
|
+
|
|
1185
|
+
// ── Key handling ──────────────────────────────────────────────────────────
|
|
1186
|
+
// Use raw keypress mode so we can show a live type-to-start buffer.
|
|
1187
|
+
// Single-key commands (n, s, q, /, 1-9, Enter) only fire when buffer is empty.
|
|
1188
|
+
let taskBuffer = '';
|
|
1189
|
+
|
|
1190
|
+
const readline = await import('node:readline');
|
|
1191
|
+
|
|
1192
|
+
// Render the type-ahead line below the box (overwrites the current cursor line)
|
|
1193
|
+
const renderBuffer = (buf) => {
|
|
1194
|
+
// Move to the prompt line (we're already at it after printing the box + footer)
|
|
1195
|
+
// Use carriage return + clear-to-end-of-line to overwrite
|
|
1196
|
+
if (buf.length === 0) {
|
|
1197
|
+
process.stdout.write('\r\x1b[K');
|
|
1198
|
+
} else {
|
|
1199
|
+
const display = buf.length > W - 4 ? buf.slice(-(W - 4)) : buf;
|
|
1200
|
+
process.stdout.write(`\r\x1b[K> ${display}\x1b[7m \x1b[0m`);
|
|
1201
|
+
}
|
|
1141
1202
|
};
|
|
1142
|
-
console.log(brandTop);
|
|
1143
|
-
console.log(` │ ${brandPad('Dual Brain Session Manager')}│`);
|
|
1144
|
-
console.log(` │ ${brandPad('Built on data-tools by Steve Moraco')}│`);
|
|
1145
|
-
console.log(brandBottom);
|
|
1146
|
-
console.log('');
|
|
1147
1203
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
if (running.claude > 0) runningParts.push(`${running.claude} claude`);
|
|
1151
|
-
if (running.codex > 0) runningParts.push(`${running.codex} codex`);
|
|
1152
|
-
if (runningParts.length > 0) {
|
|
1153
|
-
console.log(` (${runningParts.join(', ')} running)`);
|
|
1154
|
-
console.log('');
|
|
1155
|
-
}
|
|
1204
|
+
// Enable keypress events on stdin (safe to call multiple times)
|
|
1205
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
1156
1206
|
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
}
|
|
1163
|
-
console.log(' [r] Resume (full list)');
|
|
1164
|
-
console.log(' [/] Search sessions');
|
|
1165
|
-
console.log(' [e] Manage sessions');
|
|
1166
|
-
console.log(' [m] Manage subscriptions');
|
|
1167
|
-
console.log(' [s] Settings');
|
|
1168
|
-
console.log(' [?] Help & shortcuts');
|
|
1169
|
-
console.log('');
|
|
1170
|
-
console.log(' \x1b[2mreplit-tools:\x1b[0m');
|
|
1171
|
-
console.log(' [i] Import sessions');
|
|
1172
|
-
console.log(' [d] Switch to data-tools');
|
|
1173
|
-
console.log('');
|
|
1174
|
-
console.log(' [q] Exit');
|
|
1175
|
-
console.log('');
|
|
1207
|
+
const raw = await new Promise((resolve) => {
|
|
1208
|
+
// Switch to raw mode if possible (TTY only)
|
|
1209
|
+
const wasRaw = process.stdin.isRaw;
|
|
1210
|
+
const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
|
|
1211
|
+
if (canRaw) process.stdin.setRawMode(true);
|
|
1176
1212
|
|
|
1177
|
-
|
|
1213
|
+
const cleanup = () => {
|
|
1214
|
+
process.stdin.removeListener('keypress', onKey);
|
|
1215
|
+
if (canRaw) {
|
|
1216
|
+
try { process.stdin.setRawMode(wasRaw || false); } catch {}
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1178
1219
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
const helpTop = ` ┌${'─'.repeat(W)}┐`;
|
|
1182
|
-
const helpSep = ` ├${'─'.repeat(W)}┤`;
|
|
1183
|
-
const helpBottom = ` └${'─'.repeat(W)}┘`;
|
|
1184
|
-
const helpPad = (s) => s + ' '.repeat(Math.max(0, W - s.length));
|
|
1185
|
-
console.log('');
|
|
1186
|
-
console.log(helpTop);
|
|
1187
|
-
console.log(` │ ${helpPad('At ~/workspace$ prompt:')}│`);
|
|
1188
|
-
console.log(` │ ${helpPad('db = show this menu')}│`);
|
|
1189
|
-
console.log(` │ ${helpPad('j = login to claude')}│`);
|
|
1190
|
-
console.log(` │ ${helpPad('k = login to codex')}│`);
|
|
1191
|
-
console.log(helpSep);
|
|
1192
|
-
console.log(` │ ${helpPad('In Claude:')}│`);
|
|
1193
|
-
console.log(` │ ${helpPad('Ctrl+C x2 = back to menu')}│`);
|
|
1194
|
-
console.log(` │ ${helpPad('Ctrl+C x3 = exit to shell')}│`);
|
|
1195
|
-
console.log(helpBottom);
|
|
1196
|
-
console.log('');
|
|
1197
|
-
await ask(' Press Enter to continue...');
|
|
1198
|
-
return { next: 'main' };
|
|
1199
|
-
}
|
|
1220
|
+
const onKey = (str, key) => {
|
|
1221
|
+
if (!key) return;
|
|
1200
1222
|
|
|
1201
|
-
|
|
1223
|
+
const name = key.name || '';
|
|
1224
|
+
const seq = key.sequence || str || '';
|
|
1202
1225
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1226
|
+
// Ctrl-C / Ctrl-D → exit
|
|
1227
|
+
if (key.ctrl && (name === 'c' || name === 'd')) {
|
|
1228
|
+
cleanup();
|
|
1229
|
+
process.stdout.write('\n');
|
|
1230
|
+
resolve('q');
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1207
1233
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1234
|
+
// Enter key
|
|
1235
|
+
if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
|
|
1236
|
+
cleanup();
|
|
1237
|
+
if (taskBuffer.length > 0) {
|
|
1238
|
+
process.stdout.write('\n');
|
|
1239
|
+
resolve(`__task__:${taskBuffer}`);
|
|
1240
|
+
} else {
|
|
1241
|
+
resolve('');
|
|
1242
|
+
}
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1210
1245
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1246
|
+
// Escape → clear buffer
|
|
1247
|
+
if (name === 'escape') {
|
|
1248
|
+
taskBuffer = '';
|
|
1249
|
+
renderBuffer('');
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1216
1252
|
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
if (ctx.filesTouched.length > 0) console.log(` Files touched: ${ctx.filesTouched.join(', ')}`);
|
|
1253
|
+
// Backspace / delete
|
|
1254
|
+
if (name === 'backspace' || name === 'delete') {
|
|
1255
|
+
if (taskBuffer.length > 0) {
|
|
1256
|
+
taskBuffer = taskBuffer.slice(0, -1);
|
|
1257
|
+
renderBuffer(taskBuffer);
|
|
1258
|
+
}
|
|
1259
|
+
return;
|
|
1225
1260
|
}
|
|
1226
|
-
} catch {}
|
|
1227
1261
|
|
|
1262
|
+
// Ignore non-printable / control keys
|
|
1263
|
+
if (key.ctrl || key.meta || !str || str.length === 0) return;
|
|
1264
|
+
const code = str.codePointAt(0);
|
|
1265
|
+
if (code < 32 || code === 127) return;
|
|
1266
|
+
|
|
1267
|
+
// Single-key commands only fire when buffer is empty
|
|
1268
|
+
if (taskBuffer.length === 0) {
|
|
1269
|
+
const lower = str.toLowerCase();
|
|
1270
|
+
if (lower === 'n' || lower === 's' || lower === 'q' || lower === '/') {
|
|
1271
|
+
cleanup();
|
|
1272
|
+
process.stdout.write('\n');
|
|
1273
|
+
resolve(lower);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const digit = parseInt(str, 10);
|
|
1277
|
+
if (!isNaN(digit) && digit >= 1 && digit <= 9) {
|
|
1278
|
+
cleanup();
|
|
1279
|
+
process.stdout.write('\n');
|
|
1280
|
+
resolve(str);
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// Accumulate into buffer
|
|
1286
|
+
taskBuffer += str;
|
|
1287
|
+
renderBuffer(taskBuffer);
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1290
|
+
process.stdin.on('keypress', onKey);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
const choice = typeof raw === 'string' ? raw.toLowerCase() : '';
|
|
1294
|
+
|
|
1295
|
+
// Typed task → dispatch as "dual-brain go"
|
|
1296
|
+
if (raw.startsWith('__task__:')) {
|
|
1297
|
+
const prompt = raw.slice('__task__:'.length).trim();
|
|
1298
|
+
if (prompt) {
|
|
1299
|
+
return { next: 'go', prompt };
|
|
1300
|
+
}
|
|
1301
|
+
return { next: 'main' };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Enter (empty) → resume most recent session
|
|
1305
|
+
if (raw === '' || choice === '\r') {
|
|
1306
|
+
if (recentSessions.length === 0) {
|
|
1307
|
+
return { next: 'new-session' };
|
|
1308
|
+
}
|
|
1309
|
+
const sess = recentSessions[0];
|
|
1228
1310
|
const { spawnSync } = await import('node:child_process');
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
saveTerminalState(cwd, termId, targetId, tool);
|
|
1311
|
+
process.stdout.write(`\n Launching: claude --resume ${sess.id}\n\n`);
|
|
1312
|
+
spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
|
|
1313
|
+
saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
|
|
1233
1314
|
return { next: 'main' };
|
|
1234
1315
|
}
|
|
1235
1316
|
|
|
1236
|
-
|
|
1317
|
+
// Number 1-3 → resume that session
|
|
1318
|
+
const numChoice = parseInt(raw, 10);
|
|
1237
1319
|
if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= recentSessions.length) {
|
|
1238
1320
|
const sess = recentSessions[numChoice - 1];
|
|
1239
|
-
|
|
1240
|
-
// Smart resume preview
|
|
1241
1321
|
try {
|
|
1242
1322
|
const { getSessionContext } = await import('../src/session.mjs');
|
|
1243
1323
|
const ctx = getSessionContext(sess.id, cwd);
|
|
1244
1324
|
if (ctx) {
|
|
1245
|
-
|
|
1246
|
-
if (ctx.
|
|
1247
|
-
if (ctx.filesTouched.length > 0) console.log(` Files touched: ${ctx.filesTouched.join(', ')}`);
|
|
1325
|
+
if (ctx.lastPrompt) process.stdout.write(`\n Last working on: ${ctx.lastPrompt}\n`);
|
|
1326
|
+
if (ctx.filesTouched.length > 0) process.stdout.write(` Files touched: ${ctx.filesTouched.join(', ')}\n`);
|
|
1248
1327
|
}
|
|
1249
1328
|
} catch {}
|
|
1250
|
-
|
|
1251
1329
|
const { spawnSync } = await import('node:child_process');
|
|
1252
|
-
|
|
1330
|
+
process.stdout.write(`\n Launching: claude --resume ${sess.id}\n\n`);
|
|
1253
1331
|
spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
|
|
1254
1332
|
saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
|
|
1255
1333
|
return { next: 'main' };
|
|
1256
1334
|
}
|
|
1257
1335
|
|
|
1258
|
-
if (choice === '
|
|
1259
|
-
const allSessions = enrichSessions(importReplitSessions(cwd), cwd);
|
|
1260
|
-
if (allSessions.length === 0) {
|
|
1261
|
-
console.log('\n No sessions found.\n');
|
|
1262
|
-
await ask(' Press Enter to continue...');
|
|
1263
|
-
return { next: 'main' };
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
console.log('\n All Sessions:');
|
|
1267
|
-
allSessions.forEach((sess, i) => {
|
|
1268
|
-
const pin = sess.pinned ? '📌 ' : ' ';
|
|
1269
|
-
const active = sess.isActive ? ' ●' : '';
|
|
1270
|
-
const cat = sess.category ? ` [${sess.category}]` : '';
|
|
1271
|
-
const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
|
|
1272
|
-
console.log(` [${String(i + 1).padStart(2)}] ${pin}${tool} ${sess.age.padEnd(8)} ${sess.name}${active}${cat}`);
|
|
1273
|
-
});
|
|
1274
|
-
console.log('');
|
|
1275
|
-
|
|
1276
|
-
const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
|
|
1277
|
-
const num = parseInt(pick, 10);
|
|
1278
|
-
if (!isNaN(num) && num >= 1 && num <= allSessions.length) {
|
|
1279
|
-
const sess = allSessions[num - 1];
|
|
1280
|
-
const { spawnSync } = await import('node:child_process');
|
|
1281
|
-
const tool = sess.tool === 'codex' ? 'codex' : 'claude';
|
|
1282
|
-
console.log(`\n Launching: ${tool} --resume ${sess.id}\n`);
|
|
1283
|
-
spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
|
|
1284
|
-
}
|
|
1285
|
-
return { next: 'main' };
|
|
1286
|
-
}
|
|
1336
|
+
if (choice === 'n') { return { next: 'new-session' }; }
|
|
1287
1337
|
|
|
1288
1338
|
if (choice === '/') {
|
|
1289
1339
|
const query = (await ask(' Search: ')).trim();
|
|
1290
1340
|
if (!query) return { next: 'main' };
|
|
1291
1341
|
|
|
1292
1342
|
const { searchSessions, buildSessionIndex } = await import('../src/session.mjs');
|
|
1293
|
-
// Build index if needed (silent)
|
|
1294
1343
|
try { buildSessionIndex(cwd); } catch {}
|
|
1295
1344
|
|
|
1296
1345
|
const results = searchSessions(query, cwd);
|
|
1297
1346
|
if (results.length === 0) {
|
|
1298
|
-
|
|
1347
|
+
process.stdout.write(`\n No sessions matching "${query}"\n\n`);
|
|
1299
1348
|
await ask(' Press Enter to continue...');
|
|
1300
1349
|
return { next: 'main' };
|
|
1301
1350
|
}
|
|
1302
1351
|
|
|
1303
|
-
|
|
1352
|
+
process.stdout.write(`\n Found ${results.length} session${results.length === 1 ? '' : 's'}:\n`);
|
|
1304
1353
|
results.slice(0, 9).forEach((sess, i) => {
|
|
1305
|
-
const tool
|
|
1306
|
-
const date
|
|
1354
|
+
const tool = sess.tool === 'codex' ? 'cdx' : 'cld';
|
|
1355
|
+
const date = sess.date ? new Date(sess.date).toLocaleDateString() : '?';
|
|
1307
1356
|
const topics = sess.topics.slice(0, 3).join(', ');
|
|
1308
|
-
|
|
1309
|
-
if (topics)
|
|
1357
|
+
process.stdout.write(` [${i + 1}] ${tool} ${date} ${sess.prompts.first || sess.id.slice(0, 8)}\n`);
|
|
1358
|
+
if (topics) process.stdout.write(` topics: ${topics}\n`);
|
|
1310
1359
|
});
|
|
1311
|
-
|
|
1360
|
+
process.stdout.write('\n');
|
|
1312
1361
|
|
|
1313
1362
|
const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
|
|
1314
|
-
const num
|
|
1363
|
+
const num = parseInt(pick, 10);
|
|
1315
1364
|
if (!isNaN(num) && num >= 1 && num <= Math.min(results.length, 9)) {
|
|
1316
1365
|
const sess = results[num - 1];
|
|
1317
1366
|
const { spawnSync } = await import('node:child_process');
|
|
1318
1367
|
const tool = sess.tool === 'codex' ? 'codex' : 'claude';
|
|
1319
|
-
|
|
1368
|
+
process.stdout.write(`\n Launching: ${tool} --resume ${sess.id}\n\n`);
|
|
1320
1369
|
spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
|
|
1321
1370
|
}
|
|
1322
1371
|
return { next: 'main' };
|
|
1323
1372
|
}
|
|
1324
1373
|
|
|
1325
|
-
if (choice === 'e') { return { next: 'sessions' }; }
|
|
1326
|
-
|
|
1327
|
-
if (choice === 'i') {
|
|
1328
|
-
const sessions = importReplitSessions(cwd);
|
|
1329
|
-
if (sessions.length === 0) {
|
|
1330
|
-
console.log('\n No replit-tools sessions found to import.\n');
|
|
1331
|
-
} else {
|
|
1332
|
-
console.log(`\n ✅ Found ${sessions.length} sessions from replit-tools.`);
|
|
1333
|
-
console.log(' Sessions are automatically available in the list above.\n');
|
|
1334
|
-
}
|
|
1335
|
-
await ask(' Press Enter to continue...');
|
|
1336
|
-
return { next: 'main' };
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
if (choice === 'd') {
|
|
1340
|
-
const { spawnSync } = await import('node:child_process');
|
|
1341
|
-
const which = spawnSync('which', ['claude-menu'], { encoding: 'utf8' });
|
|
1342
|
-
if (which.status === 0) {
|
|
1343
|
-
spawnSync('claude-menu', { stdio: 'inherit' });
|
|
1344
|
-
} else {
|
|
1345
|
-
console.log('\n data-tools not found — install with: npm i -g replit-tools\n');
|
|
1346
|
-
await ask(' Press Enter to continue...');
|
|
1347
|
-
}
|
|
1348
|
-
return { next: 'main' };
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
if (choice === 'm') { return { next: 'subscriptions' }; }
|
|
1352
|
-
|
|
1353
1374
|
if (choice === 's') { return { next: 'settings' }; }
|
|
1354
1375
|
if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
|
|
1355
1376
|
|
|
@@ -1391,84 +1412,90 @@ async function newSessionScreen(rl, ask) {
|
|
|
1391
1412
|
|
|
1392
1413
|
async function settingsScreen(rl, ask) {
|
|
1393
1414
|
const cwd = process.cwd();
|
|
1394
|
-
const profile = loadProfile(cwd);
|
|
1395
|
-
const auth = await detectAuth();
|
|
1396
1415
|
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
const openaiPlanLabel = openaiSub?.enabled
|
|
1421
|
-
? (OPENAI_PLAN_LABELS[openaiSub.plan] ?? openaiSub.plan ?? 'n/a')
|
|
1422
|
-
: 'disabled';
|
|
1423
|
-
|
|
1424
|
-
const settingsLines = [
|
|
1425
|
-
`Mode:`,
|
|
1426
|
-
` [1] ${modeLabel('cost-saver')}`,
|
|
1427
|
-
` [2] ${modeLabel('balanced')}`,
|
|
1428
|
-
` [3] ${modeLabel('quality-first')}`,
|
|
1429
|
-
'',
|
|
1430
|
-
`Subscriptions:`,
|
|
1431
|
-
` Claude: ${auth.claude.found ? 'logged in' : 'not logged in'} — ${claudePlanLabel}${claudeSub?.label ? ` [${claudeSub.label}]` : ''}`,
|
|
1432
|
-
` OpenAI: ${auth.openai.found ? 'logged in' : 'not logged in'} — ${openaiPlanLabel}${openaiSub?.label ? ` [${openaiSub.label}]` : ''}`,
|
|
1433
|
-
'',
|
|
1434
|
-
`Enforcement: ${guardCount}/4 guards active`,
|
|
1416
|
+
// Box layout matching dashboard
|
|
1417
|
+
const termW = process.stdout.columns || 60;
|
|
1418
|
+
const boxW = Math.min(termW - 2, 60);
|
|
1419
|
+
const W = boxW - 4;
|
|
1420
|
+
|
|
1421
|
+
const top = `┌${'─'.repeat(boxW - 2)}┐`;
|
|
1422
|
+
const sep = `├${'─'.repeat(boxW - 2)}┤`;
|
|
1423
|
+
const bot = `└${'─'.repeat(boxW - 2)}┘`;
|
|
1424
|
+
const row = (content) => makeBoxRow(content, W);
|
|
1425
|
+
|
|
1426
|
+
const lines = [
|
|
1427
|
+
top,
|
|
1428
|
+
row('Settings'),
|
|
1429
|
+
sep,
|
|
1430
|
+
row('[m] Manage subscriptions'),
|
|
1431
|
+
row('[e] Manage sessions'),
|
|
1432
|
+
row('[i] Import from replit-tools'),
|
|
1433
|
+
row('[d] Switch to data-tools'),
|
|
1434
|
+
row('[?] Help & shortcuts'),
|
|
1435
|
+
row('[x] Diagnostics'),
|
|
1436
|
+
row(''),
|
|
1437
|
+
row('[Esc/b] Back to dashboard'),
|
|
1438
|
+
bot,
|
|
1435
1439
|
];
|
|
1440
|
+
process.stdout.write('\n' + lines.join('\n') + '\n\n');
|
|
1436
1441
|
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
console.log('');
|
|
1440
|
-
console.log(menu([
|
|
1441
|
-
{ key: '1', label: 'Switch to cost-saver', section: 'Mode' },
|
|
1442
|
-
{ key: '2', label: 'Switch to balanced', section: 'Mode' },
|
|
1443
|
-
{ key: '3', label: 'Switch to quality-first', section: 'Mode' },
|
|
1444
|
-
{ key: 'a', label: 'Manage subscriptions', section: 'Subscriptions' },
|
|
1445
|
-
{ key: 'i', label: 'Reinstall hooks', section: 'Enforcement' },
|
|
1446
|
-
{ key: 'b', label: 'Back', section: '' },
|
|
1447
|
-
]));
|
|
1448
|
-
console.log('');
|
|
1442
|
+
const raw = (await ask(' Choice: ')).trim();
|
|
1443
|
+
const choice = raw.toLowerCase();
|
|
1449
1444
|
|
|
1450
|
-
|
|
1445
|
+
if (choice === 'm') { return { next: 'subscriptions' }; }
|
|
1451
1446
|
|
|
1452
|
-
if (choice === '
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1447
|
+
if (choice === 'e') { return { next: 'sessions' }; }
|
|
1448
|
+
|
|
1449
|
+
if (choice === 'i') {
|
|
1450
|
+
const sessions = importReplitSessions(cwd);
|
|
1451
|
+
if (sessions.length === 0) {
|
|
1452
|
+
process.stdout.write('\n No replit-tools sessions found to import.\n\n');
|
|
1453
|
+
} else {
|
|
1454
|
+
process.stdout.write(`\n Found ${sessions.length} sessions from replit-tools.\n`);
|
|
1455
|
+
process.stdout.write(' Sessions are automatically available in the Recent list.\n\n');
|
|
1456
|
+
}
|
|
1457
|
+
await ask(' Press Enter to continue...');
|
|
1457
1458
|
return { next: 'settings' };
|
|
1458
1459
|
}
|
|
1459
1460
|
|
|
1460
|
-
if (choice === '
|
|
1461
|
-
|
|
1461
|
+
if (choice === 'd') {
|
|
1462
|
+
const { spawnSync } = await import('node:child_process');
|
|
1463
|
+
const which = spawnSync('which', ['claude-menu'], { encoding: 'utf8' });
|
|
1464
|
+
if (which.status === 0) {
|
|
1465
|
+
spawnSync('claude-menu', { stdio: 'inherit' });
|
|
1466
|
+
} else {
|
|
1467
|
+
process.stdout.write('\n data-tools not found — install with: npm i -g replit-tools\n\n');
|
|
1468
|
+
await ask(' Press Enter to continue...');
|
|
1469
|
+
}
|
|
1470
|
+
return { next: 'settings' };
|
|
1462
1471
|
}
|
|
1463
1472
|
|
|
1464
|
-
if (choice === '
|
|
1465
|
-
|
|
1473
|
+
if (choice === '?') {
|
|
1474
|
+
const W2 = 37;
|
|
1475
|
+
const helpTop = ` ┌${'─'.repeat(W2)}┐`;
|
|
1476
|
+
const helpSep = ` ├${'─'.repeat(W2)}┤`;
|
|
1477
|
+
const helpBottom = ` └${'─'.repeat(W2)}┘`;
|
|
1478
|
+
const helpPad = (s) => s + ' '.repeat(Math.max(0, W2 - s.length));
|
|
1479
|
+
process.stdout.write('\n');
|
|
1480
|
+
process.stdout.write(helpTop + '\n');
|
|
1481
|
+
process.stdout.write(` │ ${helpPad('At ~/workspace$ prompt:')}│\n`);
|
|
1482
|
+
process.stdout.write(` │ ${helpPad('db = show this menu')}│\n`);
|
|
1483
|
+
process.stdout.write(` │ ${helpPad('j = login to claude')}│\n`);
|
|
1484
|
+
process.stdout.write(` │ ${helpPad('k = login to codex')}│\n`);
|
|
1485
|
+
process.stdout.write(helpSep + '\n');
|
|
1486
|
+
process.stdout.write(` │ ${helpPad('In Claude:')}│\n`);
|
|
1487
|
+
process.stdout.write(` │ ${helpPad('Ctrl+C x2 = back to menu')}│\n`);
|
|
1488
|
+
process.stdout.write(` │ ${helpPad('Ctrl+C x3 = exit to shell')}│\n`);
|
|
1489
|
+
process.stdout.write(helpBottom + '\n\n');
|
|
1490
|
+
await ask(' Press Enter to continue...');
|
|
1466
1491
|
return { next: 'settings' };
|
|
1467
1492
|
}
|
|
1468
1493
|
|
|
1469
|
-
if (choice === '
|
|
1494
|
+
if (choice === 'x') { return { next: 'diagnostics' }; }
|
|
1470
1495
|
|
|
1471
|
-
return { next: '
|
|
1496
|
+
if (choice === 'b' || choice === 'back' || raw === '\x1b') { return { next: 'main' }; }
|
|
1497
|
+
|
|
1498
|
+
return { next: 'main' };
|
|
1472
1499
|
}
|
|
1473
1500
|
|
|
1474
1501
|
// ─── Helper: aggregatePlans ───────────────────────────────────────────────────
|
|
@@ -2559,13 +2586,40 @@ async function runScreens(startScreen = 'dashboard') {
|
|
|
2559
2586
|
let current = startScreen;
|
|
2560
2587
|
let ctx = {};
|
|
2561
2588
|
while (current && current !== 'exit') {
|
|
2589
|
+
// Handle type-to-start dispatch from mainScreen
|
|
2590
|
+
if (current === 'go' && ctx.prompt) {
|
|
2591
|
+
const prompt = ctx.prompt;
|
|
2592
|
+
const cwd = process.cwd();
|
|
2593
|
+
const profile = loadProfile(cwd);
|
|
2594
|
+
const detection = detectTask({ prompt });
|
|
2595
|
+
const decision = decideRoute({ profile, detection, cwd });
|
|
2596
|
+
process.stdout.write(`\n Routing: ${decision.provider}/${decision.model} (${decision.tier})\n`);
|
|
2597
|
+
process.stdout.write(` Reason: ${decision.explanation}\n\n`);
|
|
2598
|
+
const { spawnSync } = await import('node:child_process');
|
|
2599
|
+
const launchTool = decision.provider === 'openai' ? 'codex' : 'claude';
|
|
2600
|
+
if (launchTool === 'codex') {
|
|
2601
|
+
spawnSync('codex', [prompt], { stdio: 'inherit' });
|
|
2602
|
+
} else {
|
|
2603
|
+
spawnSync('claude', ['-p', prompt], { stdio: 'inherit' });
|
|
2604
|
+
}
|
|
2605
|
+
const freshSessions = importReplitSessions(cwd);
|
|
2606
|
+
if (freshSessions.length > 0) {
|
|
2607
|
+
saveTerminalState(cwd, getTerminalId(), freshSessions[0].id, launchTool);
|
|
2608
|
+
}
|
|
2609
|
+
current = 'main';
|
|
2610
|
+
ctx = {};
|
|
2611
|
+
continue;
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2562
2614
|
const screen = SCREENS[current];
|
|
2563
2615
|
if (!screen) break;
|
|
2564
2616
|
try {
|
|
2565
2617
|
const result = await screen(rl, ask, ctx);
|
|
2566
2618
|
current = result?.next || 'exit';
|
|
2567
|
-
// Pass through context (e.g. selected session) to next screen
|
|
2568
|
-
ctx = result?.session ? { session: result.session }
|
|
2619
|
+
// Pass through context (e.g. selected session, typed prompt) to next screen
|
|
2620
|
+
ctx = result?.session ? { session: result.session }
|
|
2621
|
+
: result?.prompt ? { prompt: result.prompt }
|
|
2622
|
+
: {};
|
|
2569
2623
|
} catch (e) {
|
|
2570
2624
|
console.error(`Error: ${e.message}`);
|
|
2571
2625
|
current = 'main';
|
package/package.json
CHANGED
package/src/session.mjs
CHANGED
|
@@ -461,23 +461,38 @@ export function importReplitSessions(cwd = process.cwd()) {
|
|
|
461
461
|
const windowMs = windowHours * 60 * 60 * 1000;
|
|
462
462
|
const cutoff = Date.now() - windowMs;
|
|
463
463
|
|
|
464
|
+
// Load existing session index for smartName lookup (best-effort, non-fatal)
|
|
465
|
+
let sessionIndex = {};
|
|
466
|
+
try {
|
|
467
|
+
const indexPath = join(cwd, '.dualbrain', 'session-index.json');
|
|
468
|
+
if (existsSync(indexPath)) {
|
|
469
|
+
sessionIndex = JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
470
|
+
}
|
|
471
|
+
} catch { /* non-fatal */ }
|
|
472
|
+
|
|
464
473
|
// Build session list
|
|
465
474
|
for (const [id, sess] of bySession) {
|
|
466
475
|
// Skip sessions outside the recency window (timestamps are in ms)
|
|
467
476
|
if (sess.lastTimestamp < cutoff) continue;
|
|
468
|
-
|
|
469
|
-
|
|
477
|
+
|
|
478
|
+
// Use smartName from index if available, otherwise fall back to first prompt
|
|
479
|
+
let name = sessionIndex[id]?.smartName || null;
|
|
480
|
+
|
|
470
481
|
if (!name) {
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
name
|
|
482
|
+
// Classic fallback: first meaningful prompt
|
|
483
|
+
name = sess.firstPrompt;
|
|
484
|
+
if (!name) {
|
|
485
|
+
const firstReal = sess.entries.find(e => e.display && e.display !== 'login');
|
|
486
|
+
name = firstReal?.display || `Session ${id.slice(0, 8)}`;
|
|
487
|
+
}
|
|
488
|
+
// Truncate long names that came from raw prompts
|
|
489
|
+
if (name.length > 60) name = name.slice(0, 57) + '...';
|
|
474
490
|
}
|
|
475
|
-
// Truncate long names
|
|
476
|
-
if (name.length > 60) name = name.slice(0, 57) + '...';
|
|
477
491
|
|
|
478
492
|
sessions.push({
|
|
479
493
|
id: sess.sessionId,
|
|
480
494
|
name,
|
|
495
|
+
smartName: sessionIndex[id]?.smartName || null,
|
|
481
496
|
project: sess.project,
|
|
482
497
|
promptCount: sess.entries.length,
|
|
483
498
|
lastActive: new Date(sess.lastTimestamp).toISOString(),
|
|
@@ -741,6 +756,159 @@ export function syncSessionMirror(cwd = process.cwd()) {
|
|
|
741
756
|
return { copied: totalCopied, grew: totalGrew };
|
|
742
757
|
}
|
|
743
758
|
|
|
759
|
+
// ─── Smart session naming ─────────────────────────────────────────────────────
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* File pattern → human label mapping (checked in order, first match wins).
|
|
763
|
+
* Each entry: { pattern: RegExp, label: string, action?: string }
|
|
764
|
+
*/
|
|
765
|
+
const FILE_PATTERN_RULES = [
|
|
766
|
+
{ pattern: /auth/i, label: 'Auth', action: 'Refactor' },
|
|
767
|
+
{ pattern: /test|spec/i, label: 'Tests', action: 'Fix' },
|
|
768
|
+
{ pattern: /dispatch/i, label: 'Dispatch', action: 'Update' },
|
|
769
|
+
{ pattern: /session/i, label: 'Session', action: 'Update' },
|
|
770
|
+
{ pattern: /profile/i, label: 'Profile', action: 'Update' },
|
|
771
|
+
{ pattern: /detect/i, label: 'Detection', action: 'Update' },
|
|
772
|
+
{ pattern: /decide/i, label: 'Routing', action: 'Update' },
|
|
773
|
+
{ pattern: /budget/i, label: 'Budget', action: 'Update' },
|
|
774
|
+
{ pattern: /hook/i, label: 'Hooks', action: 'Update' },
|
|
775
|
+
{ pattern: /install/i, label: 'Install', action: 'Update' },
|
|
776
|
+
{ pattern: /config/i, label: 'Config', action: 'Update' },
|
|
777
|
+
{ pattern: /migrate/i, label: 'Migration', action: 'Add' },
|
|
778
|
+
];
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Topic words that suggest a dominant action verb.
|
|
782
|
+
*/
|
|
783
|
+
const TOPIC_ACTION_MAP = [
|
|
784
|
+
{ words: ['fix', 'bug', 'error', 'crash', 'broken', 'fail'], action: 'Fix' },
|
|
785
|
+
{ words: ['refactor', 'cleanup', 'clean', 'reorganize'], action: 'Refactor' },
|
|
786
|
+
{ words: ['add', 'implement', 'create', 'build', 'write'], action: 'Add' },
|
|
787
|
+
{ words: ['update', 'upgrade', 'bump', 'patch'], action: 'Update' },
|
|
788
|
+
{ words: ['test', 'spec', 'coverage'], action: 'Fix' },
|
|
789
|
+
{ words: ['deploy', 'release', 'publish'], action: 'Deploy' },
|
|
790
|
+
{ words: ['audit', 'review', 'check'], action: 'Review' },
|
|
791
|
+
];
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Convert a string to Title Case.
|
|
795
|
+
* @param {string} str
|
|
796
|
+
* @returns {string}
|
|
797
|
+
*/
|
|
798
|
+
function toTitleCase(str) {
|
|
799
|
+
return str.replace(/\b\w/g, c => c.toUpperCase());
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Strip file extensions from a name candidate.
|
|
804
|
+
* @param {string} name
|
|
805
|
+
* @returns {string}
|
|
806
|
+
*/
|
|
807
|
+
function stripExtensions(name) {
|
|
808
|
+
return name.replace(/\.(mjs|js|ts|tsx|jsx|json|md|css|html|py|sh|sql|toml|yaml|yml)\b/gi, '');
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Truncate a string to maxLen characters, preserving whole words where possible.
|
|
813
|
+
* @param {string} str
|
|
814
|
+
* @param {number} maxLen
|
|
815
|
+
* @returns {string}
|
|
816
|
+
*/
|
|
817
|
+
function truncate(str, maxLen = 40) {
|
|
818
|
+
if (str.length <= maxLen) return str;
|
|
819
|
+
const cut = str.slice(0, maxLen).replace(/\s+\S*$/, '');
|
|
820
|
+
return cut || str.slice(0, maxLen);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Generate a smart human-readable session name from session index data.
|
|
825
|
+
*
|
|
826
|
+
* Priority:
|
|
827
|
+
* 1. Dominant file pattern (e.g. auth*.mjs → "Refactor Auth Module")
|
|
828
|
+
* 2. Top topics (e.g. ['auth','token','refresh'] → "Auth Token Refresh")
|
|
829
|
+
* 3. Fallback: first prompt truncated to 40 chars
|
|
830
|
+
*
|
|
831
|
+
* Rules: ≤40 chars, Title Case, no file extensions, action-prefixed when detectable.
|
|
832
|
+
*
|
|
833
|
+
* @param {{ topics?: string[], files?: string[], prompts?: { first?: string } }} sessionData
|
|
834
|
+
* @returns {string}
|
|
835
|
+
*/
|
|
836
|
+
export function generateSmartName(sessionData) {
|
|
837
|
+
const topics = sessionData.topics || [];
|
|
838
|
+
const files = sessionData.files || [];
|
|
839
|
+
const firstPrompt = sessionData.prompts?.first || '';
|
|
840
|
+
|
|
841
|
+
// ── Step 1: Detect dominant action from topics ─────────────────────────────
|
|
842
|
+
let detectedAction = null;
|
|
843
|
+
for (const { words, action } of TOPIC_ACTION_MAP) {
|
|
844
|
+
if (topics.some(t => words.includes(t))) {
|
|
845
|
+
detectedAction = action;
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ── Step 2: Try file pattern match ─────────────────────────────────────────
|
|
851
|
+
if (files.length > 0) {
|
|
852
|
+
// Flatten all filenames for pattern matching
|
|
853
|
+
const fileNames = files.map(f => f.split('/').pop()).join(' ');
|
|
854
|
+
|
|
855
|
+
for (const { pattern, label, action } of FILE_PATTERN_RULES) {
|
|
856
|
+
if (pattern.test(fileNames)) {
|
|
857
|
+
const actionWord = detectedAction || action || 'Update';
|
|
858
|
+
const candidate = `${actionWord} ${label}`;
|
|
859
|
+
return truncate(toTitleCase(candidate));
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// No named pattern — derive a label from the most common directory or base name
|
|
864
|
+
const basenames = files.map(f => {
|
|
865
|
+
const base = f.split('/').pop() || f;
|
|
866
|
+
// Strip extension and convert camelCase/kebab to words
|
|
867
|
+
return stripExtensions(base)
|
|
868
|
+
.replace(/[-_]/g, ' ')
|
|
869
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
870
|
+
.trim();
|
|
871
|
+
}).filter(Boolean);
|
|
872
|
+
|
|
873
|
+
if (basenames.length > 0) {
|
|
874
|
+
// Use the most common prefix or first significant basename
|
|
875
|
+
const label = basenames[0];
|
|
876
|
+
const actionWord = detectedAction || 'Update';
|
|
877
|
+
const candidate = `${actionWord} ${label}`;
|
|
878
|
+
return truncate(toTitleCase(stripExtensions(candidate)));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// ── Step 3: Try top topics ─────────────────────────────────────────────────
|
|
883
|
+
if (topics.length >= 2) {
|
|
884
|
+
// Take top 3 topics and compose a name
|
|
885
|
+
const topTopics = topics.slice(0, 3);
|
|
886
|
+
const actionWord = detectedAction || null;
|
|
887
|
+
|
|
888
|
+
let candidate;
|
|
889
|
+
if (actionWord) {
|
|
890
|
+
// Use action + remaining topics
|
|
891
|
+
candidate = [actionWord, ...topTopics.filter(t => t !== actionWord.toLowerCase())].slice(0, 3).join(' ');
|
|
892
|
+
} else {
|
|
893
|
+
candidate = topTopics.join(' ');
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return truncate(toTitleCase(candidate));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (topics.length === 1) {
|
|
900
|
+
const actionWord = detectedAction || 'Work on';
|
|
901
|
+
return truncate(toTitleCase(`${actionWord} ${topics[0]}`));
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ── Step 4: Fallback — first prompt truncated ──────────────────────────────
|
|
905
|
+
if (firstPrompt) {
|
|
906
|
+
return truncate(firstPrompt);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return 'Session';
|
|
910
|
+
}
|
|
911
|
+
|
|
744
912
|
// ─── Session index ────────────────────────────────────────────────────────────
|
|
745
913
|
|
|
746
914
|
/**
|
|
@@ -841,7 +1009,7 @@ export function buildSessionIndex(cwd = process.cwd()) {
|
|
|
841
1009
|
.slice(0, 10)
|
|
842
1010
|
.map(([w]) => w);
|
|
843
1011
|
|
|
844
|
-
|
|
1012
|
+
const sessionEntry = {
|
|
845
1013
|
id: sessionId,
|
|
846
1014
|
topics,
|
|
847
1015
|
files: [...fileSet].slice(0, 20),
|
|
@@ -851,6 +1019,8 @@ export function buildSessionIndex(cwd = process.cwd()) {
|
|
|
851
1019
|
tool: 'claude',
|
|
852
1020
|
_fileSize: fileSize,
|
|
853
1021
|
};
|
|
1022
|
+
sessionEntry.smartName = generateSmartName(sessionEntry);
|
|
1023
|
+
index[sessionId] = sessionEntry;
|
|
854
1024
|
} catch { continue; }
|
|
855
1025
|
}
|
|
856
1026
|
}
|
|
@@ -904,12 +1074,14 @@ export function buildSessionIndex(cwd = process.cwd()) {
|
|
|
904
1074
|
} catch { continue; }
|
|
905
1075
|
}
|
|
906
1076
|
|
|
907
|
-
|
|
1077
|
+
const codexEntry = {
|
|
908
1078
|
id, topics: [], files: [],
|
|
909
1079
|
prompts: { first: firstPrompt || '', last: lastPrompt || '' },
|
|
910
1080
|
date: lastTimestamp ? new Date(lastTimestamp * 1000).toISOString() : null,
|
|
911
1081
|
messageCount, tool: 'codex', _fileSize: fileSize,
|
|
912
1082
|
};
|
|
1083
|
+
codexEntry.smartName = generateSmartName(codexEntry);
|
|
1084
|
+
index[id] = codexEntry;
|
|
913
1085
|
} catch { continue; }
|
|
914
1086
|
}
|
|
915
1087
|
}
|