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