@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/session-recall +200 -42
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiplumber/session-recall",
3
- "version": "1.8.7",
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.8.7';
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 > LARGE_FILE_DANGER_MB && !process.env.SESSION_RECALL_FORCE) {
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
- s.msgs = readJsonl(s.filePath);
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
- const compactions = scanCompactionMarkers(session.msgs);
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
- const compactions = scanCompactionMarkers(selected[0].msgs);
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
- const compactions = scanCompactionMarkers(session.msgs);
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
- if (msg.type !== 'progress') return false;
2273
- if (!msg.data) return false;
2274
- return msg.data.type === 'hook_progress' &&
2275
- msg.data.hookEvent === 'SessionStart' &&
2276
- msg.data.hookName === 'SessionStart:compact';
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 messages = readJsonl(jsonlPath);
2386
- const compactions = scanCompactionMarkers(messages);
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 each phase
2394
- function estimatePhaseTokens(startLine, endLine) {
2546
+ // Calculate token estimates for messages between two timestamps
2547
+ function estimatePhaseTokensByTime(startTs, endTs) {
2395
2548
  let tokens = 0;
2396
- for (let j = startLine; j < endLine && j < messages.length; j++) {
2397
- const msg = messages[j];
2398
- if (msg.message?.content) {
2399
- const content = msg.message.content;
2400
- if (typeof content === 'string') {
2401
- tokens += estimateTokens({ length: content.length });
2402
- } else if (Array.isArray(content)) {
2403
- for (const block of content) {
2404
- if (block.text) tokens += estimateTokens({ length: block.text.length });
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 startLine = i === 0 ? 0 : compactions[i - 1].line;
2431
- const endLine = c.line;
2432
- const phaseTokens = estimatePhaseTokens(startLine, endLine);
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(endLine - startLine).padStart(5);
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 currentMsgs = messages.length - lastCompaction.line;
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(currentMsgs).padStart(5)} ${currentK.padStart(8)} After #${compactions.length}`);
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
+ })();