@aiplumber/session-recall 1.8.6 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/session-recall +205 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiplumber/session-recall",
3
- "version": "1.8.6",
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.8.6';
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 > 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
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
- s.msgs = readJsonl(s.filePath);
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,30 +2508,87 @@ function cmdCompactions(jsonlPath, opts) {
2382
2508
  jsonlPath = filesWithStats[0].filePath;
2383
2509
  }
2384
2510
 
2385
- const messages = readJsonl(jsonlPath);
2386
- const compactions = scanCompactionMarkers(messages);
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
 
2529
+ // Calculate token estimates for messages between two timestamps
2530
+ function estimatePhaseTokensByTime(startTs, endTs) {
2531
+ let tokens = 0;
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
+ }
2545
+ }
2546
+ }
2547
+ }
2548
+ }
2549
+ return { tokens, count };
2550
+ }
2551
+
2552
+ // Get file size
2553
+ let fileSizeMB = 0;
2554
+ try {
2555
+ const stats = fs.statSync(jsonlPath);
2556
+ fileSizeMB = stats.size / (1024 * 1024);
2557
+ } catch (e) {}
2558
+
2393
2559
  console.log(`=== COMPACTION EVENTS (${compactions.length} total) ===`);
2394
- console.log(`Source: ${path.basename(jsonlPath)}`);
2560
+ console.log(`Source: ${path.basename(jsonlPath)}${fileSizeMB > 0 ? ` (${fileSizeMB.toFixed(0)}MB)` : ''}`);
2395
2561
  console.log(``);
2562
+ console.log(`# TIME MSGS ~TOKENS PHASE`);
2563
+ console.log(`───────────────────────────────────────`);
2396
2564
 
2397
2565
  for (let i = 0; i < compactions.length; i++) {
2398
2566
  const c = compactions[i];
2399
2567
  const ts = c.timestamp ? new Date(c.timestamp).toISOString().substring(11, 19) : '??:??:??';
2400
- const retryInfo = c.retries > 0 ? `, +${c.retries} retries` : '';
2401
2568
 
2402
- console.log(`Compaction #${i + 1} @ ${ts} (line ${c.line}${retryInfo})`);
2403
- console.log(` Messages after: ${c.messagesAfter}`);
2404
- console.log(``);
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);
2573
+ const tokensK = (phaseTokens / 1000).toFixed(0) + 'k';
2574
+
2575
+ const num = String(i + 1).padStart(2);
2576
+ const msgs = String(phaseCount).padStart(5);
2577
+ const toks = tokensK.padStart(8);
2578
+
2579
+ console.log(`${num} ${ts} ${msgs} ${toks} Phase ${i + 1}`);
2405
2580
  }
2406
2581
 
2582
+ // Current phase (after last compaction)
2583
+ const lastCompaction = compactions[compactions.length - 1];
2584
+ const { tokens: currentTokens, count: currentCount } = estimatePhaseTokensByTime(lastCompaction.timestampMs, Date.now());
2585
+ const currentK = (currentTokens / 1000).toFixed(0) + 'k';
2586
+ console.log(`───────────────────────────────────────`);
2587
+ console.log(` (current) ${String(currentCount).padStart(5)} ${currentK.padStart(8)} After #${compactions.length}`);
2588
+ console.log(``);
2589
+
2407
2590
  // Usage hint
2408
- console.log(`Use: session-recall last 1 --before-compaction to recall pre-compaction content`);
2591
+ console.log(`Tip: session-recall last 1 --before-compaction N (load phase N)`);
2409
2592
  }
2410
2593
 
2411
2594
  // ========== TANGENT COMMANDS ==========
@@ -2616,6 +2799,7 @@ function parseArgs(args) {
2616
2799
 
2617
2800
  // ========== MAIN ==========
2618
2801
 
2802
+ (async () => {
2619
2803
  const args = process.argv.slice(2);
2620
2804
  const { opts, positional } = parseArgs(args);
2621
2805
 
@@ -2792,7 +2976,7 @@ switch (command) {
2792
2976
  cmdCost(jsonlPath);
2793
2977
  break;
2794
2978
  case 'last':
2795
- cmdLast(jsonlPath, positional[2], opts);
2979
+ await cmdLast(jsonlPath, positional[2], opts);
2796
2980
  break;
2797
2981
  case 'tools':
2798
2982
  cmdTools(opts);
@@ -2801,7 +2985,7 @@ switch (command) {
2801
2985
  cmdCheckpoints(opts);
2802
2986
  break;
2803
2987
  case 'compactions':
2804
- cmdCompactions(jsonlPath, opts);
2988
+ await cmdCompactions(jsonlPath, opts);
2805
2989
  break;
2806
2990
  case 'help':
2807
2991
  showHelp();
@@ -2811,3 +2995,4 @@ switch (command) {
2811
2995
  showHelp();
2812
2996
  process.exit(1);
2813
2997
  }
2998
+ })();