@aiplumber/session-recall 1.8.7 → 1.9.0
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 +175 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiplumber/session-recall",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
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.0';
|
|
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
|
|
125
|
+
if (!line.includes('SessionStart:compact')) 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,14 @@ 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
|
+
// Use streaming for large files, sync for small files
|
|
1297
|
+
const stats = fs.statSync(s.filePath);
|
|
1298
|
+
const sizeMB = stats.size / (1024 * 1024);
|
|
1299
|
+
if (sizeMB > 100) {
|
|
1300
|
+
s.msgs = await streamReadJsonl(s.filePath);
|
|
1301
|
+
} else {
|
|
1302
|
+
s.msgs = readJsonl(s.filePath);
|
|
1303
|
+
}
|
|
1178
1304
|
const timestamps = s.msgs.filter(m => m.timestamp).map(m => new Date(m.timestamp));
|
|
1179
1305
|
s.lastTs = timestamps.length > 0 ? new Date(Math.max(...timestamps)) : s.startTs;
|
|
1180
1306
|
}
|
|
@@ -2357,7 +2483,7 @@ function finalizeCluster(cluster, messages) {
|
|
|
2357
2483
|
}
|
|
2358
2484
|
|
|
2359
2485
|
// cmdCompactions: List compaction events in a JSONL file
|
|
2360
|
-
function cmdCompactions(jsonlPath, opts) {
|
|
2486
|
+
async function cmdCompactions(jsonlPath, opts) {
|
|
2361
2487
|
// Auto-detect if no path provided
|
|
2362
2488
|
if (!jsonlPath) {
|
|
2363
2489
|
const dir = cwdToProjectFolder();
|
|
@@ -2382,31 +2508,45 @@ function cmdCompactions(jsonlPath, opts) {
|
|
|
2382
2508
|
jsonlPath = filesWithStats[0].filePath;
|
|
2383
2509
|
}
|
|
2384
2510
|
|
|
2385
|
-
const
|
|
2386
|
-
|
|
2511
|
+
const compactions = await streamScanCompactions(jsonlPath);
|
|
2512
|
+
// Read full messages only if we found compactions and need phase token estimates
|
|
2513
|
+
let messages = [];
|
|
2514
|
+
if (compactions.length > 0) {
|
|
2515
|
+
const stats = fs.statSync(jsonlPath);
|
|
2516
|
+
const sizeMB = stats.size / (1024 * 1024);
|
|
2517
|
+
if (sizeMB > 100) {
|
|
2518
|
+
messages = await streamReadJsonl(jsonlPath);
|
|
2519
|
+
} else {
|
|
2520
|
+
messages = readJsonl(jsonlPath);
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2387
2523
|
|
|
2388
2524
|
if (compactions.length === 0) {
|
|
2389
2525
|
console.log('No compaction events found.');
|
|
2390
2526
|
return;
|
|
2391
2527
|
}
|
|
2392
2528
|
|
|
2393
|
-
// Calculate token estimates for
|
|
2394
|
-
function
|
|
2529
|
+
// Calculate token estimates for messages between two timestamps
|
|
2530
|
+
function estimatePhaseTokensByTime(startTs, endTs) {
|
|
2395
2531
|
let tokens = 0;
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2532
|
+
let count = 0;
|
|
2533
|
+
for (const msg of messages) {
|
|
2534
|
+
const msgTs = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
|
|
2535
|
+
if (msgTs >= startTs && msgTs < endTs) {
|
|
2536
|
+
count++;
|
|
2537
|
+
if (msg.message?.content) {
|
|
2538
|
+
const content = msg.message.content;
|
|
2539
|
+
if (typeof content === 'string') {
|
|
2540
|
+
tokens += estimateTokens({ length: content.length });
|
|
2541
|
+
} else if (Array.isArray(content)) {
|
|
2542
|
+
for (const block of content) {
|
|
2543
|
+
if (block.text) tokens += estimateTokens({ length: block.text.length });
|
|
2544
|
+
}
|
|
2405
2545
|
}
|
|
2406
2546
|
}
|
|
2407
2547
|
}
|
|
2408
2548
|
}
|
|
2409
|
-
return tokens;
|
|
2549
|
+
return { tokens, count };
|
|
2410
2550
|
}
|
|
2411
2551
|
|
|
2412
2552
|
// Get file size
|
|
@@ -2426,14 +2566,14 @@ function cmdCompactions(jsonlPath, opts) {
|
|
|
2426
2566
|
const c = compactions[i];
|
|
2427
2567
|
const ts = c.timestamp ? new Date(c.timestamp).toISOString().substring(11, 19) : '??:??:??';
|
|
2428
2568
|
|
|
2429
|
-
// Calculate phase boundaries
|
|
2430
|
-
const
|
|
2431
|
-
const
|
|
2432
|
-
const phaseTokens =
|
|
2569
|
+
// Calculate phase boundaries using timestamps
|
|
2570
|
+
const startTs = i === 0 ? 0 : compactions[i - 1].timestampMs;
|
|
2571
|
+
const endTs = c.timestampMs;
|
|
2572
|
+
const { tokens: phaseTokens, count: phaseCount } = estimatePhaseTokensByTime(startTs, endTs);
|
|
2433
2573
|
const tokensK = (phaseTokens / 1000).toFixed(0) + 'k';
|
|
2434
2574
|
|
|
2435
2575
|
const num = String(i + 1).padStart(2);
|
|
2436
|
-
const msgs = String(
|
|
2576
|
+
const msgs = String(phaseCount).padStart(5);
|
|
2437
2577
|
const toks = tokensK.padStart(8);
|
|
2438
2578
|
|
|
2439
2579
|
console.log(`${num} ${ts} ${msgs} ${toks} Phase ${i + 1}`);
|
|
@@ -2441,11 +2581,10 @@ function cmdCompactions(jsonlPath, opts) {
|
|
|
2441
2581
|
|
|
2442
2582
|
// Current phase (after last compaction)
|
|
2443
2583
|
const lastCompaction = compactions[compactions.length - 1];
|
|
2444
|
-
const
|
|
2445
|
-
const currentTokens = estimatePhaseTokens(lastCompaction.line, messages.length);
|
|
2584
|
+
const { tokens: currentTokens, count: currentCount } = estimatePhaseTokensByTime(lastCompaction.timestampMs, Date.now());
|
|
2446
2585
|
const currentK = (currentTokens / 1000).toFixed(0) + 'k';
|
|
2447
2586
|
console.log(`───────────────────────────────────────`);
|
|
2448
|
-
console.log(` (current) ${String(
|
|
2587
|
+
console.log(` (current) ${String(currentCount).padStart(5)} ${currentK.padStart(8)} After #${compactions.length}`);
|
|
2449
2588
|
console.log(``);
|
|
2450
2589
|
|
|
2451
2590
|
// Usage hint
|
|
@@ -2660,6 +2799,7 @@ function parseArgs(args) {
|
|
|
2660
2799
|
|
|
2661
2800
|
// ========== MAIN ==========
|
|
2662
2801
|
|
|
2802
|
+
(async () => {
|
|
2663
2803
|
const args = process.argv.slice(2);
|
|
2664
2804
|
const { opts, positional } = parseArgs(args);
|
|
2665
2805
|
|
|
@@ -2836,7 +2976,7 @@ switch (command) {
|
|
|
2836
2976
|
cmdCost(jsonlPath);
|
|
2837
2977
|
break;
|
|
2838
2978
|
case 'last':
|
|
2839
|
-
cmdLast(jsonlPath, positional[2], opts);
|
|
2979
|
+
await cmdLast(jsonlPath, positional[2], opts);
|
|
2840
2980
|
break;
|
|
2841
2981
|
case 'tools':
|
|
2842
2982
|
cmdTools(opts);
|
|
@@ -2845,7 +2985,7 @@ switch (command) {
|
|
|
2845
2985
|
cmdCheckpoints(opts);
|
|
2846
2986
|
break;
|
|
2847
2987
|
case 'compactions':
|
|
2848
|
-
cmdCompactions(jsonlPath, opts);
|
|
2988
|
+
await cmdCompactions(jsonlPath, opts);
|
|
2849
2989
|
break;
|
|
2850
2990
|
case 'help':
|
|
2851
2991
|
showHelp();
|
|
@@ -2855,3 +2995,4 @@ switch (command) {
|
|
|
2855
2995
|
showHelp();
|
|
2856
2996
|
process.exit(1);
|
|
2857
2997
|
}
|
|
2998
|
+
})();
|