@aiplumber/session-recall 1.8.7 → 1.9.2
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/package.json +1 -1
- package/session-recall +200 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiplumber/session-recall",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.2",
|
|
4
4
|
"description": "Pull context from previous Claude Code sessions. Sessions end, context resets - this tool lets you continue where you left off.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"session-recall": "./session-recall"
|
package/session-recall
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const VERSION = '1.
|
|
3
|
+
const VERSION = '1.9.2';
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const readline = require('readline');
|
|
7
8
|
|
|
8
9
|
// Cross-platform home directory
|
|
9
10
|
const HOME = process.env.HOME || process.env.USERPROFILE;
|
|
@@ -49,12 +50,7 @@ function readJsonl(filePath) {
|
|
|
49
50
|
const stats = fs.statSync(filePath);
|
|
50
51
|
const sizeMB = stats.size / (1024 * 1024);
|
|
51
52
|
|
|
52
|
-
if (sizeMB >
|
|
53
|
-
console.error(`[session-recall] ERROR: File is ${sizeMB.toFixed(0)}MB - too large, will crash.`);
|
|
54
|
-
console.error(`Tip: Use 'session-recall last 1 --compactions' for just current phase`);
|
|
55
|
-
console.error(`Or: SESSION_RECALL_FORCE=1 NODE_OPTIONS="--max-old-space-size=8192" session-recall ...`);
|
|
56
|
-
process.exit(1);
|
|
57
|
-
} else if (sizeMB > LARGE_FILE_WARNING_MB) {
|
|
53
|
+
if (sizeMB > LARGE_FILE_WARNING_MB) {
|
|
58
54
|
console.error(`[session-recall] WARNING: ${sizeMB.toFixed(0)}MB file - may be slow`);
|
|
59
55
|
}
|
|
60
56
|
} catch (e) {
|
|
@@ -71,6 +67,129 @@ function readJsonl(filePath) {
|
|
|
71
67
|
}).filter(Boolean);
|
|
72
68
|
}
|
|
73
69
|
|
|
70
|
+
// Streaming JSONL reader - processes line by line, filters noise, returns promise
|
|
71
|
+
async function streamReadJsonl(filePath, opts = {}) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const messages = [];
|
|
74
|
+
const rl = readline.createInterface({
|
|
75
|
+
input: fs.createReadStream(filePath, { encoding: 'utf-8' }),
|
|
76
|
+
crlfDelay: Infinity
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
rl.on('line', (line) => {
|
|
80
|
+
if (!line.trim()) return;
|
|
81
|
+
try {
|
|
82
|
+
const msg = JSON.parse(line);
|
|
83
|
+
|
|
84
|
+
// Skip noise types immediately - don't store
|
|
85
|
+
if (NOISE_TYPES.includes(msg.type)) return;
|
|
86
|
+
|
|
87
|
+
// If only scanning for compactions, check and skip non-markers
|
|
88
|
+
if (opts.compactionsOnly) {
|
|
89
|
+
if (isCompactionMarker(msg)) {
|
|
90
|
+
messages.push({
|
|
91
|
+
timestamp: msg.timestamp,
|
|
92
|
+
line: messages.length,
|
|
93
|
+
data: msg.data
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Keep message for discourse extraction
|
|
100
|
+
messages.push(msg);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// Skip unparseable lines
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
rl.on('close', () => resolve(messages));
|
|
107
|
+
rl.on('error', reject);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Lightweight scan for compaction markers only
|
|
112
|
+
async function streamScanCompactions(filePath) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const markers = [];
|
|
115
|
+
let lineNum = 0;
|
|
116
|
+
|
|
117
|
+
const rl = readline.createInterface({
|
|
118
|
+
input: fs.createReadStream(filePath, { encoding: 'utf-8' }),
|
|
119
|
+
crlfDelay: Infinity
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
rl.on('line', (line) => {
|
|
123
|
+
lineNum++;
|
|
124
|
+
// Quick string check before parsing - check both old and new formats
|
|
125
|
+
if (!line.includes('SessionStart:compact') && !line.includes('compact_boundary')) return;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const msg = JSON.parse(line);
|
|
129
|
+
if (isCompactionMarker(msg)) {
|
|
130
|
+
markers.push({
|
|
131
|
+
line: lineNum,
|
|
132
|
+
timestamp: msg.timestamp,
|
|
133
|
+
timestampMs: msg.timestamp ? new Date(msg.timestamp).getTime() : 0
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
} catch (e) {}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
rl.on('close', () => {
|
|
140
|
+
// Deduplicate markers
|
|
141
|
+
resolve(deduplicateCompactionMarkers(markers));
|
|
142
|
+
});
|
|
143
|
+
rl.on('error', reject);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Extract deduplication logic from scanCompactionMarkers into reusable function
|
|
148
|
+
function deduplicateCompactionMarkers(rawMarkers) {
|
|
149
|
+
if (rawMarkers.length === 0) return [];
|
|
150
|
+
|
|
151
|
+
// Deduplicate markers into compaction events
|
|
152
|
+
// Markers are same event if within COMPACTION_DEDUP_WINDOW_MS AND fewer than COMPACTION_DEDUP_MAX_MESSAGES between them
|
|
153
|
+
const compactions = [];
|
|
154
|
+
let currentCluster = [rawMarkers[0]];
|
|
155
|
+
|
|
156
|
+
for (let i = 1; i < rawMarkers.length; i++) {
|
|
157
|
+
const prev = currentCluster[currentCluster.length - 1];
|
|
158
|
+
const curr = rawMarkers[i];
|
|
159
|
+
|
|
160
|
+
const timeGapMs = curr.timestampMs - prev.timestampMs;
|
|
161
|
+
|
|
162
|
+
// For deduplication without full messages, just use time window
|
|
163
|
+
// (discourse counting needs full message bodies, which we don't have in lightweight scan)
|
|
164
|
+
if (timeGapMs <= COMPACTION_DEDUP_WINDOW_MS) {
|
|
165
|
+
currentCluster.push(curr);
|
|
166
|
+
} else {
|
|
167
|
+
// New cluster - finalize previous one
|
|
168
|
+
compactions.push(finalizeMarkerCluster(currentCluster));
|
|
169
|
+
currentCluster = [curr];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Finalize last cluster
|
|
174
|
+
compactions.push(finalizeMarkerCluster(currentCluster));
|
|
175
|
+
|
|
176
|
+
return compactions;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Convert a cluster of markers into a single compaction event (lightweight version)
|
|
180
|
+
function finalizeMarkerCluster(cluster) {
|
|
181
|
+
const first = cluster[0];
|
|
182
|
+
const retries = cluster.length - 1;
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
timestamp: first.timestamp,
|
|
186
|
+
timestampMs: first.timestampMs,
|
|
187
|
+
line: first.line,
|
|
188
|
+
retries: retries,
|
|
189
|
+
messagesAfter: 0 // Not computed in lightweight scan
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
74
193
|
function isToolResult(msg) {
|
|
75
194
|
if (!msg.message?.content) return false;
|
|
76
195
|
const content = msg.message.content;
|
|
@@ -1028,7 +1147,7 @@ function cwdToProjectFolder() {
|
|
|
1028
1147
|
return null;
|
|
1029
1148
|
}
|
|
1030
1149
|
|
|
1031
|
-
function cmdLast(arg1, arg2, opts) {
|
|
1150
|
+
async function cmdLast(arg1, arg2, opts) {
|
|
1032
1151
|
// Usage: last [N] [folder]
|
|
1033
1152
|
// arg1 could be: undefined, a number string, or a folder path
|
|
1034
1153
|
// arg2 could be: undefined, or a folder path (if arg1 was a number)
|
|
@@ -1174,7 +1293,21 @@ function cmdLast(arg1, arg2, opts) {
|
|
|
1174
1293
|
const candidateCount = Math.min(allSessions.length, n + 5);
|
|
1175
1294
|
for (let i = 0; i < candidateCount; i++) {
|
|
1176
1295
|
const s = allSessions[i];
|
|
1177
|
-
|
|
1296
|
+
const stats = fs.statSync(s.filePath);
|
|
1297
|
+
const sizeMB = stats.size / (1024 * 1024);
|
|
1298
|
+
|
|
1299
|
+
// If we need compaction markers (for --before-compaction or --compactions),
|
|
1300
|
+
// scan for them FIRST before loading messages (since streaming filters them out)
|
|
1301
|
+
if ((opts.beforeCompaction || opts.compactionPhases) && sizeMB > 100) {
|
|
1302
|
+
s.compactionMarkers = await streamScanCompactions(s.filePath);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// Use streaming for large files, sync for small files
|
|
1306
|
+
if (sizeMB > 100) {
|
|
1307
|
+
s.msgs = await streamReadJsonl(s.filePath);
|
|
1308
|
+
} else {
|
|
1309
|
+
s.msgs = readJsonl(s.filePath);
|
|
1310
|
+
}
|
|
1178
1311
|
const timestamps = s.msgs.filter(m => m.timestamp).map(m => new Date(m.timestamp));
|
|
1179
1312
|
s.lastTs = timestamps.length > 0 ? new Date(Math.max(...timestamps)) : s.startTs;
|
|
1180
1313
|
}
|
|
@@ -1204,7 +1337,8 @@ function cmdLast(arg1, arg2, opts) {
|
|
|
1204
1337
|
// Handle --compactions mode: show last N compaction phases instead of sessions
|
|
1205
1338
|
if (opts.compactionPhases) {
|
|
1206
1339
|
const session = selected[0]; // Use only the most recent session
|
|
1207
|
-
|
|
1340
|
+
// Use pre-scanned markers if available (for large files), otherwise scan now
|
|
1341
|
+
const compactions = session.compactionMarkers || scanCompactionMarkers(session.msgs);
|
|
1208
1342
|
|
|
1209
1343
|
if (compactions.length === 0) {
|
|
1210
1344
|
console.log('[session-recall v' + VERSION + '] No compaction events found - showing full session as single phase');
|
|
@@ -1419,7 +1553,8 @@ function cmdLast(arg1, arg2, opts) {
|
|
|
1419
1553
|
}
|
|
1420
1554
|
// Apply before-compaction filter for dry-run
|
|
1421
1555
|
if (opts.beforeCompaction && selected.length > 0) {
|
|
1422
|
-
|
|
1556
|
+
// Use pre-scanned markers if available (for large files), otherwise scan now
|
|
1557
|
+
const compactions = selected[0].compactionMarkers || scanCompactionMarkers(selected[0].msgs);
|
|
1423
1558
|
if (compactions.length > 0) {
|
|
1424
1559
|
const n = opts.beforeCompactionN || compactions.length;
|
|
1425
1560
|
if (n < 1 || n > compactions.length) {
|
|
@@ -1501,7 +1636,8 @@ function cmdLast(arg1, arg2, opts) {
|
|
|
1501
1636
|
|
|
1502
1637
|
// Filter to only show messages BEFORE the selected compaction boundary
|
|
1503
1638
|
if (opts.beforeCompaction) {
|
|
1504
|
-
|
|
1639
|
+
// Use pre-scanned markers if available (for large files), otherwise scan now
|
|
1640
|
+
const compactions = session.compactionMarkers || scanCompactionMarkers(session.msgs);
|
|
1505
1641
|
if (compactions.length > 0) {
|
|
1506
1642
|
const n = opts.beforeCompactionN || compactions.length;
|
|
1507
1643
|
if (n < 1 || n > compactions.length) {
|
|
@@ -2268,12 +2404,19 @@ function scanSessionTangents(filePath) {
|
|
|
2268
2404
|
// ========== COMPACTION DETECTION ==========
|
|
2269
2405
|
|
|
2270
2406
|
// Check if a message is a compaction marker
|
|
2407
|
+
// Supports both old format (progress + hook) and new format (system + subtype)
|
|
2271
2408
|
function isCompactionMarker(msg) {
|
|
2272
|
-
|
|
2273
|
-
if (
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2409
|
+
// New format (Claude Code 2.1+): system message with compact_boundary subtype
|
|
2410
|
+
if (msg.type === 'system' && msg.subtype === 'compact_boundary') {
|
|
2411
|
+
return true;
|
|
2412
|
+
}
|
|
2413
|
+
// Old format: progress message with hook
|
|
2414
|
+
if (msg.type === 'progress' && msg.data) {
|
|
2415
|
+
return msg.data.type === 'hook_progress' &&
|
|
2416
|
+
msg.data.hookEvent === 'SessionStart' &&
|
|
2417
|
+
msg.data.hookName === 'SessionStart:compact';
|
|
2418
|
+
}
|
|
2419
|
+
return false;
|
|
2277
2420
|
}
|
|
2278
2421
|
|
|
2279
2422
|
// Scan session for compaction markers with deduplication
|
|
@@ -2357,7 +2500,7 @@ function finalizeCluster(cluster, messages) {
|
|
|
2357
2500
|
}
|
|
2358
2501
|
|
|
2359
2502
|
// cmdCompactions: List compaction events in a JSONL file
|
|
2360
|
-
function cmdCompactions(jsonlPath, opts) {
|
|
2503
|
+
async function cmdCompactions(jsonlPath, opts) {
|
|
2361
2504
|
// Auto-detect if no path provided
|
|
2362
2505
|
if (!jsonlPath) {
|
|
2363
2506
|
const dir = cwdToProjectFolder();
|
|
@@ -2382,31 +2525,45 @@ function cmdCompactions(jsonlPath, opts) {
|
|
|
2382
2525
|
jsonlPath = filesWithStats[0].filePath;
|
|
2383
2526
|
}
|
|
2384
2527
|
|
|
2385
|
-
const
|
|
2386
|
-
|
|
2528
|
+
const compactions = await streamScanCompactions(jsonlPath);
|
|
2529
|
+
// Read full messages only if we found compactions and need phase token estimates
|
|
2530
|
+
let messages = [];
|
|
2531
|
+
if (compactions.length > 0) {
|
|
2532
|
+
const stats = fs.statSync(jsonlPath);
|
|
2533
|
+
const sizeMB = stats.size / (1024 * 1024);
|
|
2534
|
+
if (sizeMB > 100) {
|
|
2535
|
+
messages = await streamReadJsonl(jsonlPath);
|
|
2536
|
+
} else {
|
|
2537
|
+
messages = readJsonl(jsonlPath);
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2387
2540
|
|
|
2388
2541
|
if (compactions.length === 0) {
|
|
2389
2542
|
console.log('No compaction events found.');
|
|
2390
2543
|
return;
|
|
2391
2544
|
}
|
|
2392
2545
|
|
|
2393
|
-
// Calculate token estimates for
|
|
2394
|
-
function
|
|
2546
|
+
// Calculate token estimates for messages between two timestamps
|
|
2547
|
+
function estimatePhaseTokensByTime(startTs, endTs) {
|
|
2395
2548
|
let tokens = 0;
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2549
|
+
let count = 0;
|
|
2550
|
+
for (const msg of messages) {
|
|
2551
|
+
const msgTs = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
|
|
2552
|
+
if (msgTs >= startTs && msgTs < endTs) {
|
|
2553
|
+
count++;
|
|
2554
|
+
if (msg.message?.content) {
|
|
2555
|
+
const content = msg.message.content;
|
|
2556
|
+
if (typeof content === 'string') {
|
|
2557
|
+
tokens += estimateTokens({ length: content.length });
|
|
2558
|
+
} else if (Array.isArray(content)) {
|
|
2559
|
+
for (const block of content) {
|
|
2560
|
+
if (block.text) tokens += estimateTokens({ length: block.text.length });
|
|
2561
|
+
}
|
|
2405
2562
|
}
|
|
2406
2563
|
}
|
|
2407
2564
|
}
|
|
2408
2565
|
}
|
|
2409
|
-
return tokens;
|
|
2566
|
+
return { tokens, count };
|
|
2410
2567
|
}
|
|
2411
2568
|
|
|
2412
2569
|
// Get file size
|
|
@@ -2426,14 +2583,14 @@ function cmdCompactions(jsonlPath, opts) {
|
|
|
2426
2583
|
const c = compactions[i];
|
|
2427
2584
|
const ts = c.timestamp ? new Date(c.timestamp).toISOString().substring(11, 19) : '??:??:??';
|
|
2428
2585
|
|
|
2429
|
-
// Calculate phase boundaries
|
|
2430
|
-
const
|
|
2431
|
-
const
|
|
2432
|
-
const phaseTokens =
|
|
2586
|
+
// Calculate phase boundaries using timestamps
|
|
2587
|
+
const startTs = i === 0 ? 0 : compactions[i - 1].timestampMs;
|
|
2588
|
+
const endTs = c.timestampMs;
|
|
2589
|
+
const { tokens: phaseTokens, count: phaseCount } = estimatePhaseTokensByTime(startTs, endTs);
|
|
2433
2590
|
const tokensK = (phaseTokens / 1000).toFixed(0) + 'k';
|
|
2434
2591
|
|
|
2435
2592
|
const num = String(i + 1).padStart(2);
|
|
2436
|
-
const msgs = String(
|
|
2593
|
+
const msgs = String(phaseCount).padStart(5);
|
|
2437
2594
|
const toks = tokensK.padStart(8);
|
|
2438
2595
|
|
|
2439
2596
|
console.log(`${num} ${ts} ${msgs} ${toks} Phase ${i + 1}`);
|
|
@@ -2441,11 +2598,10 @@ function cmdCompactions(jsonlPath, opts) {
|
|
|
2441
2598
|
|
|
2442
2599
|
// Current phase (after last compaction)
|
|
2443
2600
|
const lastCompaction = compactions[compactions.length - 1];
|
|
2444
|
-
const
|
|
2445
|
-
const currentTokens = estimatePhaseTokens(lastCompaction.line, messages.length);
|
|
2601
|
+
const { tokens: currentTokens, count: currentCount } = estimatePhaseTokensByTime(lastCompaction.timestampMs, Date.now());
|
|
2446
2602
|
const currentK = (currentTokens / 1000).toFixed(0) + 'k';
|
|
2447
2603
|
console.log(`───────────────────────────────────────`);
|
|
2448
|
-
console.log(` (current) ${String(
|
|
2604
|
+
console.log(` (current) ${String(currentCount).padStart(5)} ${currentK.padStart(8)} After #${compactions.length}`);
|
|
2449
2605
|
console.log(``);
|
|
2450
2606
|
|
|
2451
2607
|
// Usage hint
|
|
@@ -2660,6 +2816,7 @@ function parseArgs(args) {
|
|
|
2660
2816
|
|
|
2661
2817
|
// ========== MAIN ==========
|
|
2662
2818
|
|
|
2819
|
+
(async () => {
|
|
2663
2820
|
const args = process.argv.slice(2);
|
|
2664
2821
|
const { opts, positional } = parseArgs(args);
|
|
2665
2822
|
|
|
@@ -2836,7 +2993,7 @@ switch (command) {
|
|
|
2836
2993
|
cmdCost(jsonlPath);
|
|
2837
2994
|
break;
|
|
2838
2995
|
case 'last':
|
|
2839
|
-
cmdLast(jsonlPath, positional[2], opts);
|
|
2996
|
+
await cmdLast(jsonlPath, positional[2], opts);
|
|
2840
2997
|
break;
|
|
2841
2998
|
case 'tools':
|
|
2842
2999
|
cmdTools(opts);
|
|
@@ -2845,7 +3002,7 @@ switch (command) {
|
|
|
2845
3002
|
cmdCheckpoints(opts);
|
|
2846
3003
|
break;
|
|
2847
3004
|
case 'compactions':
|
|
2848
|
-
cmdCompactions(jsonlPath, opts);
|
|
3005
|
+
await cmdCompactions(jsonlPath, opts);
|
|
2849
3006
|
break;
|
|
2850
3007
|
case 'help':
|
|
2851
3008
|
showHelp();
|
|
@@ -2855,3 +3012,4 @@ switch (command) {
|
|
|
2855
3012
|
showHelp();
|
|
2856
3013
|
process.exit(1);
|
|
2857
3014
|
}
|
|
3015
|
+
})();
|