@aiplumber/session-recall 1.6.8 → 1.8.5

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 +842 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiplumber/session-recall",
3
- "version": "1.6.8",
3
+ "version": "1.8.5",
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,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const VERSION = '1.6.8';
3
+ const VERSION = '1.8.5';
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
@@ -14,6 +14,10 @@ const NOISE_TYPES = ['progress', 'file-history-snapshot', 'system', 'queue-opera
14
14
  const MAX_USER_MSG_LENGTH = 200;
15
15
  const ACTIVE_SESSION_THRESHOLD_MS = 60000; // Sessions with activity in last 60s are "active"
16
16
 
17
+ // Compaction detection constants
18
+ const COMPACTION_DEDUP_WINDOW_MS = 120000; // 120 seconds - markers within this window are same event
19
+ const COMPACTION_DEDUP_MAX_MESSAGES = 5; // Max discourse messages between markers to be same event
20
+
17
21
  // Token estimation (~4 chars per token for English, conservative)
18
22
  function estimateTokens(text) {
19
23
  if (!text) return 0;
@@ -29,7 +33,34 @@ const MARKERS = {
29
33
  decision: /^[A-D](,\s*[A-D])*$/i
30
34
  };
31
35
 
36
+ // Tangent marker patterns
37
+ const TANGENT_START_PATTERN = /@@SESSION-TANGENT-START@@\s+"([^"]*)"\s+uuid=(\S+)\s+ts=(\S+)/;
38
+ // Groups: [1]=label, [2]=uuid, [3]=timestamp
39
+ const TANGENT_END_PATTERN = /@@SESSION-TANGENT-END@@\s+start_uuid=(\S+)\s+end_uuid=(\S+)\s+ts=(\S+)/;
40
+ // Groups: [1]=start_uuid, [2]=end_uuid, [3]=timestamp
41
+
42
+ // Size thresholds for large file warnings
43
+ const LARGE_FILE_WARNING_MB = 100;
44
+ const LARGE_FILE_DANGER_MB = 300;
45
+
32
46
  function readJsonl(filePath) {
47
+ // Check file size before loading
48
+ try {
49
+ const stats = fs.statSync(filePath);
50
+ const sizeMB = stats.size / (1024 * 1024);
51
+
52
+ if (sizeMB > LARGE_FILE_DANGER_MB) {
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: NODE_OPTIONS="--max-old-space-size=8192" session-recall ...`);
56
+ process.exit(1);
57
+ } else if (sizeMB > LARGE_FILE_WARNING_MB) {
58
+ console.error(`[session-recall] WARNING: ${sizeMB.toFixed(0)}MB file - may be slow`);
59
+ }
60
+ } catch (e) {
61
+ // Ignore stat errors, let readFileSync handle them
62
+ }
63
+
33
64
  const content = fs.readFileSync(filePath, 'utf-8');
34
65
  return content.trim().split('\n').map((line) => {
35
66
  try {
@@ -1076,6 +1107,10 @@ function cmdLast(arg1, arg2, opts) {
1076
1107
  filterAfterTs = checkpoint.msgTimestamp ? new Date(checkpoint.msgTimestamp).getTime() : new Date(checkpoint.timestamp).getTime();
1077
1108
  }
1078
1109
 
1110
+ // If --before-compaction is specified, we'll find the most recent compaction and filter before it
1111
+ // This variable will be set later after we load the session messages
1112
+ let filterBeforeCompactionLine = null;
1113
+
1079
1114
  // Helper: get session start timestamp from first line of jsonl
1080
1115
  function getSessionStartTs(filePath) {
1081
1116
  try {
@@ -1146,12 +1181,18 @@ function cmdLast(arg1, arg2, opts) {
1146
1181
 
1147
1182
  // Exclude currently active sessions (activity within threshold)
1148
1183
  const now = Date.now();
1149
- const inactiveSessions = allSessions.slice(0, candidateCount).filter(s => {
1184
+ let inactiveSessions = allSessions.slice(0, candidateCount).filter(s => {
1150
1185
  if (!s.lastTs) return true; // Include sessions with no timestamp
1151
1186
  const ageMs = now - s.lastTs.getTime();
1152
1187
  return ageMs > ACTIVE_SESSION_THRESHOLD_MS;
1153
1188
  });
1154
1189
 
1190
+ // Issue 001 fix: If only 1 session exists and it's active, include it anyway
1191
+ // This handles post-compaction recall where you want your own session
1192
+ if (inactiveSessions.length === 0 && allSessions.length > 0) {
1193
+ inactiveSessions = allSessions.slice(0, n);
1194
+ }
1195
+
1155
1196
  // Take last N from inactive sessions
1156
1197
  const selected = inactiveSessions.slice(0, n);
1157
1198
 
@@ -1160,6 +1201,197 @@ function cmdLast(arg1, arg2, opts) {
1160
1201
  process.exit(1);
1161
1202
  }
1162
1203
 
1204
+ // Handle --compactions mode: show last N compaction phases instead of sessions
1205
+ if (opts.compactionPhases) {
1206
+ const session = selected[0]; // Use only the most recent session
1207
+ const compactions = scanCompactionMarkers(session.msgs);
1208
+
1209
+ if (compactions.length === 0) {
1210
+ console.log('[session-recall v' + VERSION + '] No compaction events found - showing full session as single phase');
1211
+ console.log('---');
1212
+ // Fall through to normal processing for full session
1213
+ } else {
1214
+ // Determine which phases to show
1215
+ const phasesToShow = Math.min(n, compactions.length + 1); // +1 for current phase after last compaction
1216
+ const startPhaseNum = Math.max(1, compactions.length + 2 - phasesToShow);
1217
+
1218
+ // Print header
1219
+ console.log(`[session-recall v${VERSION}] Showing last ${phasesToShow} compaction phase${phasesToShow === 1 ? '' : 's'}`);
1220
+ console.log('---');
1221
+
1222
+ // Calculate phase boundaries
1223
+ // Phase 1: session start → compaction #1
1224
+ // Phase i: compaction #(i-1) → compaction #i
1225
+ // Phase N: last compaction → now (current session end)
1226
+
1227
+ const sessionMsgs = session.msgs.slice().sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
1228
+ const sessionStartTs = sessionMsgs.length > 0 && sessionMsgs[0].timestamp ? new Date(sessionMsgs[0].timestamp).getTime() : 0;
1229
+ const sessionEndTs = sessionMsgs.length > 0 && sessionMsgs[sessionMsgs.length - 1].timestamp ? new Date(sessionMsgs[sessionMsgs.length - 1].timestamp).getTime() : Date.now();
1230
+
1231
+ // Build phase data for each phase to display
1232
+ const phases = [];
1233
+ for (let phaseNum = startPhaseNum; phaseNum <= compactions.length + 1; phaseNum++) {
1234
+ const phaseMsgs = [];
1235
+ let phaseStartTs, phaseEndTs;
1236
+
1237
+ if (phaseNum === 1) {
1238
+ // Phase 1: session start to compaction #1
1239
+ phaseStartTs = sessionStartTs;
1240
+ phaseEndTs = compactions[0].timestampMs;
1241
+ } else if (phaseNum <= compactions.length) {
1242
+ // Phase i: compaction #(i-1) to compaction #i
1243
+ phaseStartTs = compactions[phaseNum - 2].timestampMs;
1244
+ phaseEndTs = compactions[phaseNum - 1].timestampMs;
1245
+ } else {
1246
+ // Current phase: after last compaction to now
1247
+ phaseStartTs = compactions[compactions.length - 1].timestampMs;
1248
+ phaseEndTs = sessionEndTs;
1249
+ }
1250
+
1251
+ // Filter messages for this phase
1252
+ for (const msg of sessionMsgs) {
1253
+ const msgTs = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
1254
+ if (msgTs >= phaseStartTs && msgTs < phaseEndTs) {
1255
+ phaseMsgs.push(msg);
1256
+ }
1257
+ }
1258
+
1259
+ // For the current phase, include messages at endTs too
1260
+ if (phaseNum === compactions.length + 1) {
1261
+ for (const msg of sessionMsgs) {
1262
+ const msgTs = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
1263
+ if (msgTs === phaseEndTs && !phaseMsgs.includes(msg)) {
1264
+ phaseMsgs.push(msg);
1265
+ }
1266
+ }
1267
+ }
1268
+
1269
+ const startTime = new Date(phaseStartTs).toISOString().substring(11, 19);
1270
+ const endTime = new Date(phaseEndTs).toISOString().substring(11, 19);
1271
+
1272
+ phases.push({
1273
+ number: phaseNum,
1274
+ startTime,
1275
+ endTime,
1276
+ messages: phaseMsgs,
1277
+ messageCount: phaseMsgs.length
1278
+ });
1279
+ }
1280
+
1281
+ // Output each phase with separator
1282
+ const outputLines = [];
1283
+ function addOutput(line = '') {
1284
+ outputLines.push(line);
1285
+ }
1286
+
1287
+ for (const phase of phases) {
1288
+ addOutput(`=== COMPACTION PHASE #${phase.number} (${phase.startTime} - ${phase.endTime}) - ${phase.messageCount} messages ===`);
1289
+ addOutput('');
1290
+
1291
+ // Build tag map for this session
1292
+ const tagMap = {};
1293
+ const sessionTags = scanSessionTags(session.filePath);
1294
+ for (const tag of sessionTags) {
1295
+ if (tag.targetUuid) {
1296
+ tagMap[tag.targetUuid] = {
1297
+ type: tag.type,
1298
+ level: tag.level,
1299
+ reason: tag.reason
1300
+ };
1301
+ }
1302
+ }
1303
+
1304
+ // Process messages for this phase
1305
+ for (const msg of phase.messages) {
1306
+ if (NOISE_TYPES.includes(msg.type)) continue;
1307
+
1308
+ const toolId = getToolUseId(msg);
1309
+ const msgUuid = toolId || msg.uuid;
1310
+ const tag = msgUuid ? tagMap[msgUuid] : null;
1311
+ const showTag = tag && (tag.level === 'critical' || (tag.level === 'important' && opts.showImportant));
1312
+
1313
+ if (msg.type === 'user') {
1314
+ if (isToolResult(msg)) continue;
1315
+ const text = getUserText(msg);
1316
+ if (text) {
1317
+ const ts = msg.timestamp ? new Date(msg.timestamp).toISOString().substring(11, 19) : '';
1318
+ if (showTag) {
1319
+ const marker = tag.level === 'critical' ? '\u26A0' : '\u2139';
1320
+ addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] USER: ${text.substring(0, 500)}`);
1321
+ addOutput(` Reason: "${tag.reason}"`);
1322
+ } else {
1323
+ addOutput(`[${ts}] USER: ${text.substring(0, 500)}`);
1324
+ }
1325
+ }
1326
+ } else if (msg.type === 'assistant') {
1327
+ const text = getAssistantText(msg);
1328
+ const resultMeta = buildToolResultMeta(phase.messages);
1329
+ const toolSummary = collapseToolCall(msg, resultMeta);
1330
+ const ts = msg.timestamp ? new Date(msg.timestamp).toISOString().substring(11, 19) : '';
1331
+
1332
+ if (showTag) {
1333
+ const marker = tag.level === 'critical' ? '\u26A0' : '\u2139';
1334
+ if (text) {
1335
+ addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ASSISTANT: ${text.substring(0, 500)}`);
1336
+ } else if (toolSummary) {
1337
+ addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ${toolSummary}`);
1338
+ }
1339
+ addOutput(` Reason: "${tag.reason}"`);
1340
+ } else {
1341
+ if (text) {
1342
+ addOutput(`[${ts}] ASSISTANT: ${text.substring(0, 500)}`);
1343
+ } else if (toolSummary) {
1344
+ addOutput(`[${ts}] ASSISTANT: ${toolSummary}`);
1345
+ }
1346
+ }
1347
+ }
1348
+ }
1349
+
1350
+ addOutput('');
1351
+ }
1352
+
1353
+ // Calculate token metrics
1354
+ let rawTokens = 0;
1355
+ for (const msg of sessionMsgs) {
1356
+ if (msg.message?.content) {
1357
+ const content = msg.message.content;
1358
+ if (typeof content === 'string') {
1359
+ rawTokens += estimateTokens({length: content.length});
1360
+ } else if (Array.isArray(content)) {
1361
+ for (const block of content) {
1362
+ if (block.text) {
1363
+ rawTokens += estimateTokens({length: block.text.length});
1364
+ }
1365
+ }
1366
+ }
1367
+ }
1368
+ }
1369
+
1370
+ const filteredTokenChars = outputLines.reduce((sum, line) => sum + line.length, 0);
1371
+ const returnedTokens = estimateTokens({length: filteredTokenChars});
1372
+ const filteredTokens = Math.max(0, rawTokens - returnedTokens);
1373
+ const compressionPct = rawTokens > 0 ? Math.round((filteredTokens / rawTokens) * 100) : 0;
1374
+
1375
+ const returnedK = Math.round(returnedTokens / 1000);
1376
+ const filteredK = Math.round(filteredTokens / 1000);
1377
+
1378
+ // Print metrics
1379
+ console.log(`Returned: ~${returnedK}k tokens | Filtered: ~${filteredK}k tokens (${compressionPct}% compression)`);
1380
+ console.log(`---`);
1381
+
1382
+ // Print collected output
1383
+ for (const line of outputLines) {
1384
+ console.log(line);
1385
+ }
1386
+
1387
+ console.log('');
1388
+ console.log('---');
1389
+ console.log(`Session file: ${session.filePath}`);
1390
+
1391
+ return; // Exit after compaction phases output
1392
+ }
1393
+ }
1394
+
1163
1395
  // Count excluded active sessions for reporting (only from candidates we checked)
1164
1396
  const excludedActive = candidateCount - inactiveSessions.length;
1165
1397
 
@@ -1185,6 +1417,25 @@ function cmdLast(arg1, arg2, opts) {
1185
1417
  return msgTs > filterAfterTs;
1186
1418
  });
1187
1419
  }
1420
+ // Apply before-compaction filter for dry-run
1421
+ if (opts.beforeCompaction && selected.length > 0) {
1422
+ const compactions = scanCompactionMarkers(selected[0].msgs);
1423
+ if (compactions.length > 0) {
1424
+ const n = opts.beforeCompactionN || compactions.length;
1425
+ if (n < 1 || n > compactions.length) {
1426
+ console.error(`Error: Compaction #${n} not found. Available: 1-${compactions.length}`);
1427
+ process.exit(1);
1428
+ }
1429
+ const targetCompaction = compactions[n - 1]; // 1-indexed to 0-indexed
1430
+ const compactionTs = targetCompaction.timestampMs;
1431
+ allMsgs = allMsgs.filter(msg => {
1432
+ const msgTs = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
1433
+ return msgTs < compactionTs;
1434
+ });
1435
+ const compactionTime = new Date(compactionTs).toISOString().substring(11, 19);
1436
+ console.log(`(Filtering to content BEFORE compaction #${n} @ ${compactionTime})`);
1437
+ }
1438
+ }
1188
1439
  const output = [];
1189
1440
  for (const msg of allMsgs) {
1190
1441
  if (NOISE_TYPES.includes(msg.type)) continue;
@@ -1205,9 +1456,32 @@ function cmdLast(arg1, arg2, opts) {
1205
1456
  return;
1206
1457
  }
1207
1458
 
1208
- // Print header
1209
- console.log(`[session-recall v${VERSION}] This context has been stripped of most tool results to reduce noise.`);
1210
- console.log(`---`);
1459
+ // Calculate metrics before filtering
1460
+ let rawTokens = 0;
1461
+ for (const session of selected) {
1462
+ for (const msg of session.msgs) {
1463
+ if (msg.message?.content) {
1464
+ const content = msg.message.content;
1465
+ if (typeof content === 'string') {
1466
+ rawTokens += estimateTokens({length: content.length});
1467
+ } else if (Array.isArray(content)) {
1468
+ for (const block of content) {
1469
+ if (block.text) {
1470
+ rawTokens += estimateTokens({length: block.text.length});
1471
+ }
1472
+ }
1473
+ }
1474
+ }
1475
+ }
1476
+ }
1477
+
1478
+ // Collect output lines to calculate metrics
1479
+ const outputLines = [];
1480
+
1481
+ // Helper to add to output
1482
+ function addOutput(line = '') {
1483
+ outputLines.push(line);
1484
+ }
1211
1485
 
1212
1486
  // Process each session separately to add separators
1213
1487
  for (let sessionIdx = 0; sessionIdx < selected.length; sessionIdx++) {
@@ -1225,6 +1499,35 @@ function cmdLast(arg1, arg2, opts) {
1225
1499
  });
1226
1500
  }
1227
1501
 
1502
+ // Filter to only show messages BEFORE the selected compaction boundary
1503
+ if (opts.beforeCompaction) {
1504
+ const compactions = scanCompactionMarkers(session.msgs);
1505
+ if (compactions.length > 0) {
1506
+ const n = opts.beforeCompactionN || compactions.length;
1507
+ if (n < 1 || n > compactions.length) {
1508
+ console.error(`Error: Compaction #${n} not found. Available: 1-${compactions.length}`);
1509
+ process.exit(1);
1510
+ }
1511
+ // Get the selected compaction (1-indexed to 0-indexed)
1512
+ const targetCompaction = compactions[n - 1];
1513
+ const compactionTs = targetCompaction.timestampMs;
1514
+
1515
+ // Filter to messages BEFORE the compaction
1516
+ sessionMsgs = sessionMsgs.filter(msg => {
1517
+ const msgTs = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
1518
+ return msgTs < compactionTs;
1519
+ });
1520
+
1521
+ // Print a notice about what we're showing
1522
+ const compactionTime = new Date(compactionTs).toISOString().substring(11, 19);
1523
+ addOutput(`[BEFORE COMPACTION #${n} @ ${compactionTime}]`);
1524
+ addOutput(``);
1525
+ } else {
1526
+ addOutput(`[No compaction events found - showing all content]`);
1527
+ addOutput(``);
1528
+ }
1529
+ }
1530
+
1228
1531
  // Build tool result metadata (size, duration)
1229
1532
  const resultMeta = buildToolResultMeta(sessionMsgs);
1230
1533
 
@@ -1241,6 +1544,38 @@ function cmdLast(arg1, arg2, opts) {
1241
1544
  }
1242
1545
  }
1243
1546
 
1547
+ // Scan for tangents if needed for filtering
1548
+ let tangents = [];
1549
+ if (opts.noTangents || opts.tangentsOnly) {
1550
+ tangents = scanSessionTangents(session.filePath);
1551
+ }
1552
+
1553
+ // Build tangent boundaries map for filtering
1554
+ const tangentRanges = [];
1555
+ for (const tangent of tangents) {
1556
+ if (tangent.endUuid) {
1557
+ tangentRanges.push({
1558
+ label: tangent.label,
1559
+ startUuid: tangent.startUuid,
1560
+ endUuid: tangent.endUuid,
1561
+ unclosed: false
1562
+ });
1563
+ }
1564
+ }
1565
+
1566
+ // Check if message UUID falls within any tangent range
1567
+ function isInTangent(msgUuid) {
1568
+ if (!msgUuid) return false;
1569
+ for (const range of tangentRanges) {
1570
+ if (msgUuid === range.startUuid || msgUuid === range.endUuid) return true;
1571
+ // Note: For full range checking, we'd need to track message order
1572
+ }
1573
+ return false;
1574
+ }
1575
+
1576
+ let inTangentRange = null;
1577
+ let tangentMessageCount = 0;
1578
+
1244
1579
  for (const msg of sessionMsgs) {
1245
1580
  if (NOISE_TYPES.includes(msg.type)) continue;
1246
1581
 
@@ -1255,6 +1590,34 @@ function cmdLast(arg1, arg2, opts) {
1255
1590
  (tag.level === 'important' && opts.showImportant)
1256
1591
  );
1257
1592
 
1593
+ // Check tangent boundaries
1594
+ if (opts.noTangents || opts.tangentsOnly) {
1595
+ for (const range of tangentRanges) {
1596
+ if (msgUuid === range.startUuid) {
1597
+ inTangentRange = range;
1598
+ tangentMessageCount = 0;
1599
+ }
1600
+ if (msgUuid === range.endUuid) {
1601
+ if (opts.noTangents && inTangentRange) {
1602
+ addOutput(`=== TANGENT: "${inTangentRange.label}" (skipped ${tangentMessageCount} messages) ===`);
1603
+ }
1604
+ inTangentRange = null;
1605
+ tangentMessageCount = 0;
1606
+ continue;
1607
+ }
1608
+ }
1609
+ }
1610
+
1611
+ // Apply tangent filtering
1612
+ const inTangent = inTangentRange !== null;
1613
+ if (opts.noTangents && inTangent) {
1614
+ tangentMessageCount++;
1615
+ continue;
1616
+ }
1617
+ if (opts.tangentsOnly && !inTangent) {
1618
+ continue;
1619
+ }
1620
+
1258
1621
  if (msg.type === 'user') {
1259
1622
  if (isToolResult(msg)) continue;
1260
1623
  const text = getUserText(msg);
@@ -1262,10 +1625,10 @@ function cmdLast(arg1, arg2, opts) {
1262
1625
  const ts = msg.timestamp ? new Date(msg.timestamp).toISOString().substring(11, 19) : '';
1263
1626
  if (showTag) {
1264
1627
  const marker = tag.level === 'critical' ? '\u26A0' : '\u2139';
1265
- console.log(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] USER: ${text.substring(0, 500)}`);
1266
- console.log(` Reason: "${tag.reason}"`);
1628
+ addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] USER: ${text.substring(0, 500)}`);
1629
+ addOutput(` Reason: "${tag.reason}"`);
1267
1630
  } else {
1268
- console.log(`[${ts}] USER: ${text.substring(0, 500)}`);
1631
+ addOutput(`[${ts}] USER: ${text.substring(0, 500)}`);
1269
1632
  }
1270
1633
  }
1271
1634
  } else if (msg.type === 'assistant') {
@@ -1276,29 +1639,59 @@ function cmdLast(arg1, arg2, opts) {
1276
1639
  if (showTag) {
1277
1640
  const marker = tag.level === 'critical' ? '\u26A0' : '\u2139';
1278
1641
  if (text) {
1279
- console.log(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ASSISTANT: ${text.substring(0, 500)}`);
1642
+ addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ASSISTANT: ${text.substring(0, 500)}`);
1280
1643
  } else if (toolSummary) {
1281
- console.log(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ${toolSummary}`);
1644
+ addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ${toolSummary}`);
1282
1645
  }
1283
- console.log(` Reason: "${tag.reason}"`);
1646
+ addOutput(` Reason: "${tag.reason}"`);
1284
1647
  } else {
1285
1648
  if (text) {
1286
- console.log(`[${ts}] ASSISTANT: ${text.substring(0, 500)}`);
1649
+ addOutput(`[${ts}] ASSISTANT: ${text.substring(0, 500)}`);
1287
1650
  } else if (toolSummary) {
1288
- console.log(`[${ts}] ASSISTANT: ${toolSummary}`);
1651
+ addOutput(`[${ts}] ASSISTANT: ${toolSummary}`);
1289
1652
  }
1290
1653
  }
1291
1654
  }
1292
1655
  }
1293
1656
 
1657
+ // Warn if tangent was unclosed
1658
+ for (const tangent of tangents) {
1659
+ if (tangent.unclosed) {
1660
+ addOutput(``);
1661
+ addOutput(`[session-recall] Warning: Tangent "${tangent.label}" was never closed.`);
1662
+ addOutput(`Treating remaining messages as regular conversation.`);
1663
+ }
1664
+ }
1665
+
1294
1666
  // Print session separator if not the last session
1295
1667
  if (sessionIdx < selected.length - 1) {
1296
1668
  const nextSession = selected[sessionIdx + 1];
1297
1669
  const nextTs = nextSession.lastTs ? nextSession.lastTs.toISOString().replace('T', ' ').substring(0, 16) : 'unknown';
1298
- console.log(`\n=== END SESSION ${sessionIdx + 1} === NEXT SESSION: ${nextSession.project} (${nextTs}) ===\n`);
1670
+ addOutput(`\n=== END SESSION ${sessionIdx + 1} === NEXT SESSION: ${nextSession.project} (${nextTs}) ===\n`);
1299
1671
  }
1300
1672
  }
1301
1673
 
1674
+ // Calculate filtered tokens and compression
1675
+ const filteredTokenChars = outputLines.reduce((sum, line) => sum + line.length, 0);
1676
+ const returnedTokens = estimateTokens({length: filteredTokenChars});
1677
+ const filteredTokens = Math.max(0, rawTokens - returnedTokens);
1678
+ const compressionPct = rawTokens > 0 ? Math.round((filteredTokens / rawTokens) * 100) : 0;
1679
+
1680
+ // Format as k tokens with rounding
1681
+ const returnedK = Math.round(returnedTokens / 1000);
1682
+ const filteredK = Math.round(filteredTokens / 1000);
1683
+
1684
+ // Print header with metrics
1685
+ console.log(`[session-recall v${VERSION}] This context has been stripped of most tool results to reduce noise.`);
1686
+ console.log(`Returned: ~${returnedK}k tokens | Filtered: ~${filteredK}k tokens (${compressionPct}% compression)`);
1687
+ console.log(`DO NOT read raw session files - this IS the context you need.`);
1688
+ console.log(`---`);
1689
+
1690
+ // Print collected output
1691
+ for (const line of outputLines) {
1692
+ console.log(line);
1693
+ }
1694
+
1302
1695
  // Print instructions for Claude
1303
1696
  console.log(``);
1304
1697
  console.log(`---`);
@@ -1338,6 +1731,11 @@ COMMANDS:
1338
1731
  -a, --all Scan all projects (not just CWD)
1339
1732
  -d, --dry-run Show what would be pulled without output
1340
1733
  --show-important Include important-level tags in output
1734
+ --before-compaction [N] Return content BEFORE compaction #N (default: most recent)
1735
+ --compactions Show last N compaction phases (not sessions)
1736
+
1737
+ compactions <jsonl> List compaction events (context window resets)
1738
+ Deduplicates retry markers into single events
1341
1739
 
1342
1740
  tools List tool calls in most recent session (CWD project)
1343
1741
  --show <id> Show specific tool result by ID (partial match)
@@ -1354,6 +1752,19 @@ COMMANDS:
1354
1752
 
1355
1753
  last [N] --after "name" Recall from after a checkpoint
1356
1754
 
1755
+ TANGENTS:
1756
+ --tangent-start "label" Start marking a tangent in real-time
1757
+ [--back N] Or mark retroactively N positions back
1758
+
1759
+ --tangent-end End the current tangent
1760
+ [--back N] Or end retroactively N positions back
1761
+
1762
+ tangent-start [--back N] "label" Alternative syntax for tangent-start
1763
+ tangent-end [--back N] Alternative syntax for tangent-end
1764
+
1765
+ last [N] --no-tangents Show session, skip tangent-tagged content
1766
+ last [N] --tangents-only Show only messages inside tangents
1767
+
1357
1768
  TAGGING:
1358
1769
  --back N [type] Preview message N positions back (for tagging)
1359
1770
  Types: toolcall, agentcall, discourse, checkpoint
@@ -1391,6 +1802,17 @@ EXAMPLES:
1391
1802
  session-recall tags critical # List only critical tags
1392
1803
  session-recall last 1 # Critical tags auto-surface
1393
1804
  session-recall last 1 --show-important # Include important tags
1805
+
1806
+ # Compaction detection - find context window resets
1807
+ session-recall compactions session.jsonl # List all compaction events
1808
+ session-recall last 1 --before-compaction # Recall pre-compaction content
1809
+ session-recall last 3 --compactions # Last 3 compaction phases with separators
1810
+
1811
+ # Tangents - mark side explorations
1812
+ session-recall --tangent-start "session-recall compaction feature" # Mark tangent start
1813
+ session-recall --tangent-end # Mark tangent end
1814
+ session-recall last 1 --no-tangents # Skip tangent content
1815
+ session-recall last 1 --tangents-only # Show only tangents
1394
1816
  `);
1395
1817
  }
1396
1818
 
@@ -1772,6 +2194,353 @@ function cmdTags(filterArg, opts) {
1772
2194
  console.log(`Use: session-recall last 1 to see critical tags in context`);
1773
2195
  }
1774
2196
 
2197
+ // ========== TANGENT SCANNING ==========
2198
+
2199
+ // Scan a session for tangent markers by searching tool_result content
2200
+ function scanSessionTangents(filePath) {
2201
+ const messages = readJsonl(filePath);
2202
+ const tangents = [];
2203
+ const openTangents = [];
2204
+
2205
+ for (const msg of messages) {
2206
+ const resultContent = getToolResultContent(msg);
2207
+ if (!resultContent) continue;
2208
+
2209
+ const startMatch = resultContent.match(TANGENT_START_PATTERN);
2210
+ if (startMatch) {
2211
+ const label = startMatch[1];
2212
+ const uuid = startMatch[2];
2213
+ const timestamp = startMatch[3];
2214
+
2215
+ // Skip placeholder patterns from documentation (e.g., uuid=<uuid>)
2216
+ if (uuid.includes('<') || uuid.includes('>')) continue;
2217
+
2218
+ openTangents.push({
2219
+ label,
2220
+ startUuid: uuid,
2221
+ startTimestamp: timestamp,
2222
+ msgTimestamp: msg.timestamp
2223
+ });
2224
+ continue;
2225
+ }
2226
+
2227
+ const endMatch = resultContent.match(TANGENT_END_PATTERN);
2228
+ if (endMatch) {
2229
+ const startUuid = endMatch[1];
2230
+ const endUuid = endMatch[2];
2231
+ const timestamp = endMatch[3];
2232
+
2233
+ // Skip placeholder patterns from documentation
2234
+ if (startUuid.includes('<') || startUuid.includes('>')) continue;
2235
+ if (endUuid.includes('<') || endUuid.includes('>')) continue;
2236
+
2237
+ if (openTangents.length > 0) {
2238
+ const openTangent = openTangents.pop();
2239
+ tangents.push({
2240
+ label: openTangent.label,
2241
+ startUuid: openTangent.startUuid,
2242
+ endUuid: endUuid,
2243
+ startTimestamp: openTangent.startTimestamp,
2244
+ endTimestamp: timestamp,
2245
+ tagTimestamp: msg.timestamp
2246
+ });
2247
+ }
2248
+ continue;
2249
+ }
2250
+ }
2251
+
2252
+ // Handle unclosed tangents - add them as warnings
2253
+ for (const open of openTangents) {
2254
+ tangents.push({
2255
+ label: open.label,
2256
+ startUuid: open.startUuid,
2257
+ endUuid: null,
2258
+ startTimestamp: open.startTimestamp,
2259
+ endTimestamp: null,
2260
+ tagTimestamp: open.msgTimestamp,
2261
+ unclosed: true
2262
+ });
2263
+ }
2264
+
2265
+ return tangents;
2266
+ }
2267
+
2268
+ // ========== COMPACTION DETECTION ==========
2269
+
2270
+ // Check if a message is a compaction marker
2271
+ 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';
2277
+ }
2278
+
2279
+ // Scan session for compaction markers with deduplication
2280
+ // Returns array of compaction events with { timestamp, line, retries, messagesAfter }
2281
+ function scanCompactionMarkers(messages) {
2282
+ const markers = [];
2283
+
2284
+ // First pass: collect all raw markers with line numbers
2285
+ for (let i = 0; i < messages.length; i++) {
2286
+ const msg = messages[i];
2287
+ if (isCompactionMarker(msg)) {
2288
+ markers.push({
2289
+ line: i + 1, // 1-indexed line number
2290
+ timestamp: msg.timestamp,
2291
+ timestampMs: msg.timestamp ? new Date(msg.timestamp).getTime() : 0
2292
+ });
2293
+ }
2294
+ }
2295
+
2296
+ if (markers.length === 0) return [];
2297
+
2298
+ // Second pass: deduplicate markers into compaction events
2299
+ // Markers are same event if within COMPACTION_DEDUP_WINDOW_MS AND fewer than COMPACTION_DEDUP_MAX_MESSAGES between them
2300
+ const compactions = [];
2301
+ let currentCluster = [markers[0]];
2302
+
2303
+ for (let i = 1; i < markers.length; i++) {
2304
+ const prev = currentCluster[currentCluster.length - 1];
2305
+ const curr = markers[i];
2306
+
2307
+ const timeGapMs = curr.timestampMs - prev.timestampMs;
2308
+
2309
+ // Count discourse messages between prev and curr markers
2310
+ const prevLine = prev.line - 1; // Convert to 0-indexed
2311
+ const currLine = curr.line - 1;
2312
+ let discourseCount = 0;
2313
+ for (let j = prevLine + 1; j < currLine; j++) {
2314
+ if (isDiscourse(messages[j])) {
2315
+ discourseCount++;
2316
+ }
2317
+ }
2318
+
2319
+ // If within time window AND few discourse messages, same cluster
2320
+ if (timeGapMs <= COMPACTION_DEDUP_WINDOW_MS && discourseCount < COMPACTION_DEDUP_MAX_MESSAGES) {
2321
+ currentCluster.push(curr);
2322
+ } else {
2323
+ // New cluster - finalize previous one
2324
+ compactions.push(finalizeCluster(currentCluster, messages));
2325
+ currentCluster = [curr];
2326
+ }
2327
+ }
2328
+
2329
+ // Finalize last cluster
2330
+ compactions.push(finalizeCluster(currentCluster, messages));
2331
+
2332
+ return compactions;
2333
+ }
2334
+
2335
+ // Convert a cluster of markers into a single compaction event
2336
+ function finalizeCluster(cluster, messages) {
2337
+ const first = cluster[0];
2338
+ const retries = cluster.length - 1;
2339
+
2340
+ // Count messages (total raw messages, not just discourse) after this compaction
2341
+ // until end of file or next compaction marker
2342
+ let messagesAfter = 0;
2343
+ const startLine = first.line; // 1-indexed
2344
+ for (let i = startLine; i < messages.length; i++) {
2345
+ // Stop at next compaction marker
2346
+ if (isCompactionMarker(messages[i])) break;
2347
+ messagesAfter++;
2348
+ }
2349
+
2350
+ return {
2351
+ timestamp: first.timestamp,
2352
+ timestampMs: first.timestampMs,
2353
+ line: first.line,
2354
+ retries: retries,
2355
+ messagesAfter: messagesAfter
2356
+ };
2357
+ }
2358
+
2359
+ // cmdCompactions: List compaction events in a JSONL file
2360
+ function cmdCompactions(jsonlPath, opts) {
2361
+ // Auto-detect if no path provided
2362
+ if (!jsonlPath) {
2363
+ const dir = cwdToProjectFolder();
2364
+ if (!dir) {
2365
+ console.error(`No project folder found for CWD: ${process.cwd()}`);
2366
+ process.exit(1);
2367
+ }
2368
+
2369
+ const jsonlFiles = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
2370
+ if (jsonlFiles.length === 0) {
2371
+ console.error('No sessions found');
2372
+ process.exit(1);
2373
+ }
2374
+
2375
+ // Sort by modification time to get most recent
2376
+ const filesWithStats = jsonlFiles.map(f => {
2377
+ const filePath = path.join(dir, f);
2378
+ const stat = fs.statSync(filePath);
2379
+ return { filePath, mtime: stat.mtime };
2380
+ });
2381
+ filesWithStats.sort((a, b) => b.mtime - a.mtime);
2382
+ jsonlPath = filesWithStats[0].filePath;
2383
+ }
2384
+
2385
+ const messages = readJsonl(jsonlPath);
2386
+ const compactions = scanCompactionMarkers(messages);
2387
+
2388
+ if (compactions.length === 0) {
2389
+ console.log('No compaction events found.');
2390
+ return;
2391
+ }
2392
+
2393
+ console.log(`=== COMPACTION EVENTS (${compactions.length} total) ===`);
2394
+ console.log(`Source: ${path.basename(jsonlPath)}`);
2395
+ console.log(``);
2396
+
2397
+ for (let i = 0; i < compactions.length; i++) {
2398
+ const c = compactions[i];
2399
+ const ts = c.timestamp ? new Date(c.timestamp).toISOString().substring(11, 19) : '??:??:??';
2400
+ const retryInfo = c.retries > 0 ? `, +${c.retries} retries` : '';
2401
+
2402
+ console.log(`Compaction #${i + 1} @ ${ts} (line ${c.line}${retryInfo})`);
2403
+ console.log(` Messages after: ${c.messagesAfter}`);
2404
+ console.log(``);
2405
+ }
2406
+
2407
+ // Usage hint
2408
+ console.log(`Use: session-recall last 1 --before-compaction to recall pre-compaction content`);
2409
+ }
2410
+
2411
+ // ========== TANGENT COMMANDS ==========
2412
+
2413
+ function cmdTangentStart(label, backN, opts) {
2414
+ const dir = cwdToProjectFolder();
2415
+ if (!dir) {
2416
+ console.error(`No project folder found for CWD: ${process.cwd()}`);
2417
+ process.exit(1);
2418
+ }
2419
+
2420
+ // Get most recent session (INCLUDING active - this is the current session)
2421
+ const jsonlFiles = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
2422
+ if (jsonlFiles.length === 0) {
2423
+ console.error('No sessions found');
2424
+ process.exit(1);
2425
+ }
2426
+
2427
+ // Sort by modification time to get most recent
2428
+ const filesWithStats = jsonlFiles.map(f => {
2429
+ const filePath = path.join(dir, f);
2430
+ const stat = fs.statSync(filePath);
2431
+ return { filePath, mtime: stat.mtime };
2432
+ });
2433
+ filesWithStats.sort((a, b) => b.mtime - a.mtime);
2434
+
2435
+ const session = filesWithStats[0];
2436
+ const messages = readJsonl(session.filePath);
2437
+
2438
+ // Check for existing open tangent
2439
+ const tangents = scanSessionTangents(session.filePath);
2440
+ const openTangent = tangents.find(t => t.unclosed);
2441
+ if (openTangent) {
2442
+ console.error(`Error: You're already in a tangent ("${openTangent.label}").`);
2443
+ console.error(`Your biological brain is not ready for nested tangents.`);
2444
+ console.error(`Close current tangent first with: session-recall --tangent-end`);
2445
+ process.exit(1);
2446
+ }
2447
+
2448
+ // Classify all messages and filter by discourse
2449
+ const discourseMessages = [];
2450
+ for (const msg of messages) {
2451
+ const msgType = classifyMessageType(msg);
2452
+ if (msgType === 'discourse') {
2453
+ discourseMessages.push(msg);
2454
+ }
2455
+ }
2456
+
2457
+ // If --back N is specified, find the Nth discourse message back; otherwise use next message
2458
+ let targetUuid = null;
2459
+ if (backN && backN > 1) {
2460
+ const targetIndex = discourseMessages.length - backN;
2461
+ if (targetIndex < 0) {
2462
+ console.error(`Only ${discourseMessages.length} discourse messages found, cannot go back ${backN}`);
2463
+ process.exit(1);
2464
+ }
2465
+ const targetMsg = discourseMessages[targetIndex];
2466
+ targetUuid = targetMsg.uuid || '(no uuid)';
2467
+ } else {
2468
+ // Use a UUID for the marker (generate one or use most recent)
2469
+ const uuid = require('crypto').randomUUID();
2470
+ targetUuid = uuid;
2471
+ }
2472
+
2473
+ const isoTimestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
2474
+
2475
+ // Output marker format
2476
+ console.log(`@@SESSION-TANGENT-START@@ "${label}" uuid=${targetUuid} ts=${isoTimestamp}`);
2477
+ }
2478
+
2479
+ function cmdTangentEnd(backN, opts) {
2480
+ const dir = cwdToProjectFolder();
2481
+ if (!dir) {
2482
+ console.error(`No project folder found for CWD: ${process.cwd()}`);
2483
+ process.exit(1);
2484
+ }
2485
+
2486
+ // Get most recent session (INCLUDING active)
2487
+ const jsonlFiles = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
2488
+ if (jsonlFiles.length === 0) {
2489
+ console.error('No sessions found');
2490
+ process.exit(1);
2491
+ }
2492
+
2493
+ // Sort by modification time to get most recent
2494
+ const filesWithStats = jsonlFiles.map(f => {
2495
+ const filePath = path.join(dir, f);
2496
+ const stat = fs.statSync(filePath);
2497
+ return { filePath, mtime: stat.mtime };
2498
+ });
2499
+ filesWithStats.sort((a, b) => b.mtime - a.mtime);
2500
+
2501
+ const session = filesWithStats[0];
2502
+ const messages = readJsonl(session.filePath);
2503
+
2504
+ // Check for open tangent
2505
+ const tangents = scanSessionTangents(session.filePath);
2506
+ const openTangent = tangents.find(t => t.unclosed);
2507
+ if (!openTangent) {
2508
+ console.error(`Error: No open tangent to close.`);
2509
+ console.error(`Start one with: session-recall --tangent-start "label"`);
2510
+ process.exit(1);
2511
+ }
2512
+
2513
+ // Classify all messages and filter by discourse
2514
+ const discourseMessages = [];
2515
+ for (const msg of messages) {
2516
+ const msgType = classifyMessageType(msg);
2517
+ if (msgType === 'discourse') {
2518
+ discourseMessages.push(msg);
2519
+ }
2520
+ }
2521
+
2522
+ // Determine end UUID
2523
+ let endUuid;
2524
+ if (backN && backN > 1) {
2525
+ const targetIndex = discourseMessages.length - backN;
2526
+ if (targetIndex < 0) {
2527
+ console.error(`Only ${discourseMessages.length} discourse messages found, cannot go back ${backN}`);
2528
+ process.exit(1);
2529
+ }
2530
+ const targetMsg = discourseMessages[targetIndex];
2531
+ endUuid = targetMsg.uuid || '(no uuid)';
2532
+ } else {
2533
+ // Use a UUID for the end marker (generate one)
2534
+ const uuid = require('crypto').randomUUID();
2535
+ endUuid = uuid;
2536
+ }
2537
+
2538
+ const isoTimestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
2539
+
2540
+ // Output marker format
2541
+ console.log(`@@SESSION-TANGENT-END@@ start_uuid=${openTangent.startUuid} end_uuid=${endUuid} ts=${isoTimestamp}`);
2542
+ }
2543
+
1775
2544
  // ========== ARGUMENT PARSING ==========
1776
2545
 
1777
2546
  function parseArgs(args) {
@@ -1816,6 +2585,23 @@ function parseArgs(args) {
1816
2585
  opts.back = parseInt(args[++i]);
1817
2586
  } else if (arg === '--show-important') {
1818
2587
  opts.showImportant = true;
2588
+ } else if (arg === '--before-compaction') {
2589
+ opts.beforeCompaction = true;
2590
+ // Check if next arg is a number (not another flag)
2591
+ if (i + 1 < args.length && /^\d+$/.test(args[i + 1])) {
2592
+ opts.beforeCompactionN = parseInt(args[i + 1], 10);
2593
+ i++; // Skip the number in iteration
2594
+ }
2595
+ } else if (arg === '--compactions') {
2596
+ opts.compactionPhases = true;
2597
+ } else if (arg === '--tangent-start') {
2598
+ opts.tangentStart = args[++i];
2599
+ } else if (arg === '--tangent-end') {
2600
+ opts.tangentEnd = true;
2601
+ } else if (arg === '--no-tangents') {
2602
+ opts.noTangents = true;
2603
+ } else if (arg === '--tangents-only') {
2604
+ opts.tangentsOnly = true;
1819
2605
  } else if (arg === '-h' || arg === '--help') {
1820
2606
  opts.help = true;
1821
2607
  } else if (arg === '-v' || arg === '--version') {
@@ -1885,6 +2671,40 @@ if (opts.checkpoint) {
1885
2671
  process.exit(0);
1886
2672
  }
1887
2673
 
2674
+ // Handle --tangent-start flag
2675
+ if (opts.tangentStart) {
2676
+ const label = opts.tangentStart;
2677
+ const backN = opts.back || 1;
2678
+ cmdTangentStart(label, backN, opts);
2679
+ process.exit(0);
2680
+ }
2681
+
2682
+ // Handle --tangent-end flag
2683
+ if (opts.tangentEnd) {
2684
+ const backN = opts.back || 1;
2685
+ cmdTangentEnd(backN, opts);
2686
+ process.exit(0);
2687
+ }
2688
+
2689
+ // Handle 'tangent-start' command: session-recall tangent-start [--back N] "label"
2690
+ if (positional[0] === 'tangent-start') {
2691
+ const label = opts.tangentStart || positional[1];
2692
+ if (!label) {
2693
+ console.error('Usage: session-recall tangent-start [--back N] "label"');
2694
+ process.exit(1);
2695
+ }
2696
+ const backN = opts.back || 1;
2697
+ cmdTangentStart(label, backN, opts);
2698
+ process.exit(0);
2699
+ }
2700
+
2701
+ // Handle 'tangent-end' command: session-recall tangent-end [--back N]
2702
+ if (positional[0] === 'tangent-end') {
2703
+ const backN = opts.back || 1;
2704
+ cmdTangentEnd(backN, opts);
2705
+ process.exit(0);
2706
+ }
2707
+
1888
2708
  // Handle 'tag' command: session-recall tag <type> [--back N] <level> "reason"
1889
2709
  // Must come before --back handler since tag command can have --back option
1890
2710
  if (positional[0] === 'tag') {
@@ -1941,15 +2761,16 @@ if (opts.help || positional.length === 0) {
1941
2761
  const command = positional[0];
1942
2762
  const jsonlPath = positional[1];
1943
2763
 
1944
- // 'last', 'tools', and 'checkpoints' commands don't require a path
1945
- if (!jsonlPath && command !== 'help' && command !== 'last' && command !== 'tools' && command !== 'checkpoints') {
2764
+ // 'last', 'tools', 'checkpoints', and 'compactions' commands don't require a path
2765
+ if (!jsonlPath && command !== 'help' && command !== 'last' && command !== 'tools' && command !== 'checkpoints' && command !== 'compactions') {
1946
2766
  console.error('Error: JSONL path required');
1947
2767
  showHelp();
1948
2768
  process.exit(1);
1949
2769
  }
1950
2770
 
1951
- // For 'last' command, the second arg might be a number, not a path
1952
- if (command !== 'last' && jsonlPath && !fs.existsSync(jsonlPath)) {
2771
+ // For 'last' and 'compactions' commands, the second arg might not be a path
2772
+ // 'compactions' auto-detects if no path provided, so skip validation
2773
+ if (command !== 'last' && command !== 'compactions' && jsonlPath && !fs.existsSync(jsonlPath)) {
1953
2774
  console.error(`Error: File not found: ${jsonlPath}`);
1954
2775
  process.exit(1);
1955
2776
  }
@@ -1979,6 +2800,9 @@ switch (command) {
1979
2800
  case 'checkpoints':
1980
2801
  cmdCheckpoints(opts);
1981
2802
  break;
2803
+ case 'compactions':
2804
+ cmdCompactions(jsonlPath, opts);
2805
+ break;
1982
2806
  case 'help':
1983
2807
  showHelp();
1984
2808
  break;