@aiplumber/session-recall 1.6.7 → 1.8.4

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 +822 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiplumber/session-recall",
3
- "version": "1.6.7",
3
+ "version": "1.8.4",
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,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const VERSION = '1.6.7';
3
+ const VERSION = '1.8.4';
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
 
8
8
  // Cross-platform home directory
9
- const HOME = HOME || process.env.USERPROFILE;
9
+ const HOME = process.env.HOME || process.env.USERPROFILE;
10
10
 
11
11
  // Filter patterns
12
12
  const SHELL_PREFIXES = ['ls ', 'cat ', 'npm ', 'cd ', 'git ', 'mkdir ', 'rm ', 'cp ', 'mv ', 'grep ', 'find '];
@@ -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,6 +33,12 @@ 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
+
32
42
  function readJsonl(filePath) {
33
43
  const content = fs.readFileSync(filePath, 'utf-8');
34
44
  return content.trim().split('\n').map((line) => {
@@ -1076,6 +1086,10 @@ function cmdLast(arg1, arg2, opts) {
1076
1086
  filterAfterTs = checkpoint.msgTimestamp ? new Date(checkpoint.msgTimestamp).getTime() : new Date(checkpoint.timestamp).getTime();
1077
1087
  }
1078
1088
 
1089
+ // If --before-compaction is specified, we'll find the most recent compaction and filter before it
1090
+ // This variable will be set later after we load the session messages
1091
+ let filterBeforeCompactionLine = null;
1092
+
1079
1093
  // Helper: get session start timestamp from first line of jsonl
1080
1094
  function getSessionStartTs(filePath) {
1081
1095
  try {
@@ -1146,12 +1160,18 @@ function cmdLast(arg1, arg2, opts) {
1146
1160
 
1147
1161
  // Exclude currently active sessions (activity within threshold)
1148
1162
  const now = Date.now();
1149
- const inactiveSessions = allSessions.slice(0, candidateCount).filter(s => {
1163
+ let inactiveSessions = allSessions.slice(0, candidateCount).filter(s => {
1150
1164
  if (!s.lastTs) return true; // Include sessions with no timestamp
1151
1165
  const ageMs = now - s.lastTs.getTime();
1152
1166
  return ageMs > ACTIVE_SESSION_THRESHOLD_MS;
1153
1167
  });
1154
1168
 
1169
+ // Issue 001 fix: If only 1 session exists and it's active, include it anyway
1170
+ // This handles post-compaction recall where you want your own session
1171
+ if (inactiveSessions.length === 0 && allSessions.length > 0) {
1172
+ inactiveSessions = allSessions.slice(0, n);
1173
+ }
1174
+
1155
1175
  // Take last N from inactive sessions
1156
1176
  const selected = inactiveSessions.slice(0, n);
1157
1177
 
@@ -1160,6 +1180,197 @@ function cmdLast(arg1, arg2, opts) {
1160
1180
  process.exit(1);
1161
1181
  }
1162
1182
 
1183
+ // Handle --compactions mode: show last N compaction phases instead of sessions
1184
+ if (opts.compactionPhases) {
1185
+ const session = selected[0]; // Use only the most recent session
1186
+ const compactions = scanCompactionMarkers(session.msgs);
1187
+
1188
+ if (compactions.length === 0) {
1189
+ console.log('[session-recall v' + VERSION + '] No compaction events found - showing full session as single phase');
1190
+ console.log('---');
1191
+ // Fall through to normal processing for full session
1192
+ } else {
1193
+ // Determine which phases to show
1194
+ const phasesToShow = Math.min(n, compactions.length + 1); // +1 for current phase after last compaction
1195
+ const startPhaseNum = Math.max(1, compactions.length + 2 - phasesToShow);
1196
+
1197
+ // Print header
1198
+ console.log(`[session-recall v${VERSION}] Showing last ${phasesToShow} compaction phase${phasesToShow === 1 ? '' : 's'}`);
1199
+ console.log('---');
1200
+
1201
+ // Calculate phase boundaries
1202
+ // Phase 1: session start → compaction #1
1203
+ // Phase i: compaction #(i-1) → compaction #i
1204
+ // Phase N: last compaction → now (current session end)
1205
+
1206
+ const sessionMsgs = session.msgs.slice().sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
1207
+ const sessionStartTs = sessionMsgs.length > 0 && sessionMsgs[0].timestamp ? new Date(sessionMsgs[0].timestamp).getTime() : 0;
1208
+ const sessionEndTs = sessionMsgs.length > 0 && sessionMsgs[sessionMsgs.length - 1].timestamp ? new Date(sessionMsgs[sessionMsgs.length - 1].timestamp).getTime() : Date.now();
1209
+
1210
+ // Build phase data for each phase to display
1211
+ const phases = [];
1212
+ for (let phaseNum = startPhaseNum; phaseNum <= compactions.length + 1; phaseNum++) {
1213
+ const phaseMsgs = [];
1214
+ let phaseStartTs, phaseEndTs;
1215
+
1216
+ if (phaseNum === 1) {
1217
+ // Phase 1: session start to compaction #1
1218
+ phaseStartTs = sessionStartTs;
1219
+ phaseEndTs = compactions[0].timestampMs;
1220
+ } else if (phaseNum <= compactions.length) {
1221
+ // Phase i: compaction #(i-1) to compaction #i
1222
+ phaseStartTs = compactions[phaseNum - 2].timestampMs;
1223
+ phaseEndTs = compactions[phaseNum - 1].timestampMs;
1224
+ } else {
1225
+ // Current phase: after last compaction to now
1226
+ phaseStartTs = compactions[compactions.length - 1].timestampMs;
1227
+ phaseEndTs = sessionEndTs;
1228
+ }
1229
+
1230
+ // Filter messages for this phase
1231
+ for (const msg of sessionMsgs) {
1232
+ const msgTs = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
1233
+ if (msgTs >= phaseStartTs && msgTs < phaseEndTs) {
1234
+ phaseMsgs.push(msg);
1235
+ }
1236
+ }
1237
+
1238
+ // For the current phase, include messages at endTs too
1239
+ if (phaseNum === compactions.length + 1) {
1240
+ for (const msg of sessionMsgs) {
1241
+ const msgTs = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
1242
+ if (msgTs === phaseEndTs && !phaseMsgs.includes(msg)) {
1243
+ phaseMsgs.push(msg);
1244
+ }
1245
+ }
1246
+ }
1247
+
1248
+ const startTime = new Date(phaseStartTs).toISOString().substring(11, 19);
1249
+ const endTime = new Date(phaseEndTs).toISOString().substring(11, 19);
1250
+
1251
+ phases.push({
1252
+ number: phaseNum,
1253
+ startTime,
1254
+ endTime,
1255
+ messages: phaseMsgs,
1256
+ messageCount: phaseMsgs.length
1257
+ });
1258
+ }
1259
+
1260
+ // Output each phase with separator
1261
+ const outputLines = [];
1262
+ function addOutput(line = '') {
1263
+ outputLines.push(line);
1264
+ }
1265
+
1266
+ for (const phase of phases) {
1267
+ addOutput(`=== COMPACTION PHASE #${phase.number} (${phase.startTime} - ${phase.endTime}) - ${phase.messageCount} messages ===`);
1268
+ addOutput('');
1269
+
1270
+ // Build tag map for this session
1271
+ const tagMap = {};
1272
+ const sessionTags = scanSessionTags(session.filePath);
1273
+ for (const tag of sessionTags) {
1274
+ if (tag.targetUuid) {
1275
+ tagMap[tag.targetUuid] = {
1276
+ type: tag.type,
1277
+ level: tag.level,
1278
+ reason: tag.reason
1279
+ };
1280
+ }
1281
+ }
1282
+
1283
+ // Process messages for this phase
1284
+ for (const msg of phase.messages) {
1285
+ if (NOISE_TYPES.includes(msg.type)) continue;
1286
+
1287
+ const toolId = getToolUseId(msg);
1288
+ const msgUuid = toolId || msg.uuid;
1289
+ const tag = msgUuid ? tagMap[msgUuid] : null;
1290
+ const showTag = tag && (tag.level === 'critical' || (tag.level === 'important' && opts.showImportant));
1291
+
1292
+ if (msg.type === 'user') {
1293
+ if (isToolResult(msg)) continue;
1294
+ const text = getUserText(msg);
1295
+ if (text) {
1296
+ const ts = msg.timestamp ? new Date(msg.timestamp).toISOString().substring(11, 19) : '';
1297
+ if (showTag) {
1298
+ const marker = tag.level === 'critical' ? '\u26A0' : '\u2139';
1299
+ addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] USER: ${text.substring(0, 500)}`);
1300
+ addOutput(` Reason: "${tag.reason}"`);
1301
+ } else {
1302
+ addOutput(`[${ts}] USER: ${text.substring(0, 500)}`);
1303
+ }
1304
+ }
1305
+ } else if (msg.type === 'assistant') {
1306
+ const text = getAssistantText(msg);
1307
+ const resultMeta = buildToolResultMeta(phase.messages);
1308
+ const toolSummary = collapseToolCall(msg, resultMeta);
1309
+ const ts = msg.timestamp ? new Date(msg.timestamp).toISOString().substring(11, 19) : '';
1310
+
1311
+ if (showTag) {
1312
+ const marker = tag.level === 'critical' ? '\u26A0' : '\u2139';
1313
+ if (text) {
1314
+ addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ASSISTANT: ${text.substring(0, 500)}`);
1315
+ } else if (toolSummary) {
1316
+ addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ${toolSummary}`);
1317
+ }
1318
+ addOutput(` Reason: "${tag.reason}"`);
1319
+ } else {
1320
+ if (text) {
1321
+ addOutput(`[${ts}] ASSISTANT: ${text.substring(0, 500)}`);
1322
+ } else if (toolSummary) {
1323
+ addOutput(`[${ts}] ASSISTANT: ${toolSummary}`);
1324
+ }
1325
+ }
1326
+ }
1327
+ }
1328
+
1329
+ addOutput('');
1330
+ }
1331
+
1332
+ // Calculate token metrics
1333
+ let rawTokens = 0;
1334
+ for (const msg of sessionMsgs) {
1335
+ if (msg.message?.content) {
1336
+ const content = msg.message.content;
1337
+ if (typeof content === 'string') {
1338
+ rawTokens += estimateTokens({length: content.length});
1339
+ } else if (Array.isArray(content)) {
1340
+ for (const block of content) {
1341
+ if (block.text) {
1342
+ rawTokens += estimateTokens({length: block.text.length});
1343
+ }
1344
+ }
1345
+ }
1346
+ }
1347
+ }
1348
+
1349
+ const filteredTokenChars = outputLines.reduce((sum, line) => sum + line.length, 0);
1350
+ const returnedTokens = estimateTokens({length: filteredTokenChars});
1351
+ const filteredTokens = Math.max(0, rawTokens - returnedTokens);
1352
+ const compressionPct = rawTokens > 0 ? Math.round((filteredTokens / rawTokens) * 100) : 0;
1353
+
1354
+ const returnedK = Math.round(returnedTokens / 1000);
1355
+ const filteredK = Math.round(filteredTokens / 1000);
1356
+
1357
+ // Print metrics
1358
+ console.log(`Returned: ~${returnedK}k tokens | Filtered: ~${filteredK}k tokens (${compressionPct}% compression)`);
1359
+ console.log(`---`);
1360
+
1361
+ // Print collected output
1362
+ for (const line of outputLines) {
1363
+ console.log(line);
1364
+ }
1365
+
1366
+ console.log('');
1367
+ console.log('---');
1368
+ console.log(`Session file: ${session.filePath}`);
1369
+
1370
+ return; // Exit after compaction phases output
1371
+ }
1372
+ }
1373
+
1163
1374
  // Count excluded active sessions for reporting (only from candidates we checked)
1164
1375
  const excludedActive = candidateCount - inactiveSessions.length;
1165
1376
 
@@ -1185,6 +1396,25 @@ function cmdLast(arg1, arg2, opts) {
1185
1396
  return msgTs > filterAfterTs;
1186
1397
  });
1187
1398
  }
1399
+ // Apply before-compaction filter for dry-run
1400
+ if (opts.beforeCompaction && selected.length > 0) {
1401
+ const compactions = scanCompactionMarkers(selected[0].msgs);
1402
+ if (compactions.length > 0) {
1403
+ const n = opts.beforeCompactionN || compactions.length;
1404
+ if (n < 1 || n > compactions.length) {
1405
+ console.error(`Error: Compaction #${n} not found. Available: 1-${compactions.length}`);
1406
+ process.exit(1);
1407
+ }
1408
+ const targetCompaction = compactions[n - 1]; // 1-indexed to 0-indexed
1409
+ const compactionTs = targetCompaction.timestampMs;
1410
+ allMsgs = allMsgs.filter(msg => {
1411
+ const msgTs = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
1412
+ return msgTs < compactionTs;
1413
+ });
1414
+ const compactionTime = new Date(compactionTs).toISOString().substring(11, 19);
1415
+ console.log(`(Filtering to content BEFORE compaction #${n} @ ${compactionTime})`);
1416
+ }
1417
+ }
1188
1418
  const output = [];
1189
1419
  for (const msg of allMsgs) {
1190
1420
  if (NOISE_TYPES.includes(msg.type)) continue;
@@ -1205,9 +1435,32 @@ function cmdLast(arg1, arg2, opts) {
1205
1435
  return;
1206
1436
  }
1207
1437
 
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(`---`);
1438
+ // Calculate metrics before filtering
1439
+ let rawTokens = 0;
1440
+ for (const session of selected) {
1441
+ for (const msg of session.msgs) {
1442
+ if (msg.message?.content) {
1443
+ const content = msg.message.content;
1444
+ if (typeof content === 'string') {
1445
+ rawTokens += estimateTokens({length: content.length});
1446
+ } else if (Array.isArray(content)) {
1447
+ for (const block of content) {
1448
+ if (block.text) {
1449
+ rawTokens += estimateTokens({length: block.text.length});
1450
+ }
1451
+ }
1452
+ }
1453
+ }
1454
+ }
1455
+ }
1456
+
1457
+ // Collect output lines to calculate metrics
1458
+ const outputLines = [];
1459
+
1460
+ // Helper to add to output
1461
+ function addOutput(line = '') {
1462
+ outputLines.push(line);
1463
+ }
1211
1464
 
1212
1465
  // Process each session separately to add separators
1213
1466
  for (let sessionIdx = 0; sessionIdx < selected.length; sessionIdx++) {
@@ -1225,6 +1478,35 @@ function cmdLast(arg1, arg2, opts) {
1225
1478
  });
1226
1479
  }
1227
1480
 
1481
+ // Filter to only show messages BEFORE the selected compaction boundary
1482
+ if (opts.beforeCompaction) {
1483
+ const compactions = scanCompactionMarkers(session.msgs);
1484
+ if (compactions.length > 0) {
1485
+ const n = opts.beforeCompactionN || compactions.length;
1486
+ if (n < 1 || n > compactions.length) {
1487
+ console.error(`Error: Compaction #${n} not found. Available: 1-${compactions.length}`);
1488
+ process.exit(1);
1489
+ }
1490
+ // Get the selected compaction (1-indexed to 0-indexed)
1491
+ const targetCompaction = compactions[n - 1];
1492
+ const compactionTs = targetCompaction.timestampMs;
1493
+
1494
+ // Filter to messages BEFORE the compaction
1495
+ sessionMsgs = sessionMsgs.filter(msg => {
1496
+ const msgTs = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
1497
+ return msgTs < compactionTs;
1498
+ });
1499
+
1500
+ // Print a notice about what we're showing
1501
+ const compactionTime = new Date(compactionTs).toISOString().substring(11, 19);
1502
+ addOutput(`[BEFORE COMPACTION #${n} @ ${compactionTime}]`);
1503
+ addOutput(``);
1504
+ } else {
1505
+ addOutput(`[No compaction events found - showing all content]`);
1506
+ addOutput(``);
1507
+ }
1508
+ }
1509
+
1228
1510
  // Build tool result metadata (size, duration)
1229
1511
  const resultMeta = buildToolResultMeta(sessionMsgs);
1230
1512
 
@@ -1241,6 +1523,38 @@ function cmdLast(arg1, arg2, opts) {
1241
1523
  }
1242
1524
  }
1243
1525
 
1526
+ // Scan for tangents if needed for filtering
1527
+ let tangents = [];
1528
+ if (opts.noTangents || opts.tangentsOnly) {
1529
+ tangents = scanSessionTangents(session.filePath);
1530
+ }
1531
+
1532
+ // Build tangent boundaries map for filtering
1533
+ const tangentRanges = [];
1534
+ for (const tangent of tangents) {
1535
+ if (tangent.endUuid) {
1536
+ tangentRanges.push({
1537
+ label: tangent.label,
1538
+ startUuid: tangent.startUuid,
1539
+ endUuid: tangent.endUuid,
1540
+ unclosed: false
1541
+ });
1542
+ }
1543
+ }
1544
+
1545
+ // Check if message UUID falls within any tangent range
1546
+ function isInTangent(msgUuid) {
1547
+ if (!msgUuid) return false;
1548
+ for (const range of tangentRanges) {
1549
+ if (msgUuid === range.startUuid || msgUuid === range.endUuid) return true;
1550
+ // Note: For full range checking, we'd need to track message order
1551
+ }
1552
+ return false;
1553
+ }
1554
+
1555
+ let inTangentRange = null;
1556
+ let tangentMessageCount = 0;
1557
+
1244
1558
  for (const msg of sessionMsgs) {
1245
1559
  if (NOISE_TYPES.includes(msg.type)) continue;
1246
1560
 
@@ -1255,6 +1569,34 @@ function cmdLast(arg1, arg2, opts) {
1255
1569
  (tag.level === 'important' && opts.showImportant)
1256
1570
  );
1257
1571
 
1572
+ // Check tangent boundaries
1573
+ if (opts.noTangents || opts.tangentsOnly) {
1574
+ for (const range of tangentRanges) {
1575
+ if (msgUuid === range.startUuid) {
1576
+ inTangentRange = range;
1577
+ tangentMessageCount = 0;
1578
+ }
1579
+ if (msgUuid === range.endUuid) {
1580
+ if (opts.noTangents && inTangentRange) {
1581
+ addOutput(`=== TANGENT: "${inTangentRange.label}" (skipped ${tangentMessageCount} messages) ===`);
1582
+ }
1583
+ inTangentRange = null;
1584
+ tangentMessageCount = 0;
1585
+ continue;
1586
+ }
1587
+ }
1588
+ }
1589
+
1590
+ // Apply tangent filtering
1591
+ const inTangent = inTangentRange !== null;
1592
+ if (opts.noTangents && inTangent) {
1593
+ tangentMessageCount++;
1594
+ continue;
1595
+ }
1596
+ if (opts.tangentsOnly && !inTangent) {
1597
+ continue;
1598
+ }
1599
+
1258
1600
  if (msg.type === 'user') {
1259
1601
  if (isToolResult(msg)) continue;
1260
1602
  const text = getUserText(msg);
@@ -1262,10 +1604,10 @@ function cmdLast(arg1, arg2, opts) {
1262
1604
  const ts = msg.timestamp ? new Date(msg.timestamp).toISOString().substring(11, 19) : '';
1263
1605
  if (showTag) {
1264
1606
  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}"`);
1607
+ addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] USER: ${text.substring(0, 500)}`);
1608
+ addOutput(` Reason: "${tag.reason}"`);
1267
1609
  } else {
1268
- console.log(`[${ts}] USER: ${text.substring(0, 500)}`);
1610
+ addOutput(`[${ts}] USER: ${text.substring(0, 500)}`);
1269
1611
  }
1270
1612
  }
1271
1613
  } else if (msg.type === 'assistant') {
@@ -1276,29 +1618,59 @@ function cmdLast(arg1, arg2, opts) {
1276
1618
  if (showTag) {
1277
1619
  const marker = tag.level === 'critical' ? '\u26A0' : '\u2139';
1278
1620
  if (text) {
1279
- console.log(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ASSISTANT: ${text.substring(0, 500)}`);
1621
+ addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ASSISTANT: ${text.substring(0, 500)}`);
1280
1622
  } else if (toolSummary) {
1281
- console.log(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ${toolSummary}`);
1623
+ addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ${toolSummary}`);
1282
1624
  }
1283
- console.log(` Reason: "${tag.reason}"`);
1625
+ addOutput(` Reason: "${tag.reason}"`);
1284
1626
  } else {
1285
1627
  if (text) {
1286
- console.log(`[${ts}] ASSISTANT: ${text.substring(0, 500)}`);
1628
+ addOutput(`[${ts}] ASSISTANT: ${text.substring(0, 500)}`);
1287
1629
  } else if (toolSummary) {
1288
- console.log(`[${ts}] ASSISTANT: ${toolSummary}`);
1630
+ addOutput(`[${ts}] ASSISTANT: ${toolSummary}`);
1289
1631
  }
1290
1632
  }
1291
1633
  }
1292
1634
  }
1293
1635
 
1636
+ // Warn if tangent was unclosed
1637
+ for (const tangent of tangents) {
1638
+ if (tangent.unclosed) {
1639
+ addOutput(``);
1640
+ addOutput(`[session-recall] Warning: Tangent "${tangent.label}" was never closed.`);
1641
+ addOutput(`Treating remaining messages as regular conversation.`);
1642
+ }
1643
+ }
1644
+
1294
1645
  // Print session separator if not the last session
1295
1646
  if (sessionIdx < selected.length - 1) {
1296
1647
  const nextSession = selected[sessionIdx + 1];
1297
1648
  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`);
1649
+ addOutput(`\n=== END SESSION ${sessionIdx + 1} === NEXT SESSION: ${nextSession.project} (${nextTs}) ===\n`);
1299
1650
  }
1300
1651
  }
1301
1652
 
1653
+ // Calculate filtered tokens and compression
1654
+ const filteredTokenChars = outputLines.reduce((sum, line) => sum + line.length, 0);
1655
+ const returnedTokens = estimateTokens({length: filteredTokenChars});
1656
+ const filteredTokens = Math.max(0, rawTokens - returnedTokens);
1657
+ const compressionPct = rawTokens > 0 ? Math.round((filteredTokens / rawTokens) * 100) : 0;
1658
+
1659
+ // Format as k tokens with rounding
1660
+ const returnedK = Math.round(returnedTokens / 1000);
1661
+ const filteredK = Math.round(filteredTokens / 1000);
1662
+
1663
+ // Print header with metrics
1664
+ console.log(`[session-recall v${VERSION}] This context has been stripped of most tool results to reduce noise.`);
1665
+ console.log(`Returned: ~${returnedK}k tokens | Filtered: ~${filteredK}k tokens (${compressionPct}% compression)`);
1666
+ console.log(`DO NOT read raw session files - this IS the context you need.`);
1667
+ console.log(`---`);
1668
+
1669
+ // Print collected output
1670
+ for (const line of outputLines) {
1671
+ console.log(line);
1672
+ }
1673
+
1302
1674
  // Print instructions for Claude
1303
1675
  console.log(``);
1304
1676
  console.log(`---`);
@@ -1338,6 +1710,11 @@ COMMANDS:
1338
1710
  -a, --all Scan all projects (not just CWD)
1339
1711
  -d, --dry-run Show what would be pulled without output
1340
1712
  --show-important Include important-level tags in output
1713
+ --before-compaction [N] Return content BEFORE compaction #N (default: most recent)
1714
+ --compactions Show last N compaction phases (not sessions)
1715
+
1716
+ compactions <jsonl> List compaction events (context window resets)
1717
+ Deduplicates retry markers into single events
1341
1718
 
1342
1719
  tools List tool calls in most recent session (CWD project)
1343
1720
  --show <id> Show specific tool result by ID (partial match)
@@ -1354,6 +1731,19 @@ COMMANDS:
1354
1731
 
1355
1732
  last [N] --after "name" Recall from after a checkpoint
1356
1733
 
1734
+ TANGENTS:
1735
+ --tangent-start "label" Start marking a tangent in real-time
1736
+ [--back N] Or mark retroactively N positions back
1737
+
1738
+ --tangent-end End the current tangent
1739
+ [--back N] Or end retroactively N positions back
1740
+
1741
+ tangent-start [--back N] "label" Alternative syntax for tangent-start
1742
+ tangent-end [--back N] Alternative syntax for tangent-end
1743
+
1744
+ last [N] --no-tangents Show session, skip tangent-tagged content
1745
+ last [N] --tangents-only Show only messages inside tangents
1746
+
1357
1747
  TAGGING:
1358
1748
  --back N [type] Preview message N positions back (for tagging)
1359
1749
  Types: toolcall, agentcall, discourse, checkpoint
@@ -1391,6 +1781,17 @@ EXAMPLES:
1391
1781
  session-recall tags critical # List only critical tags
1392
1782
  session-recall last 1 # Critical tags auto-surface
1393
1783
  session-recall last 1 --show-important # Include important tags
1784
+
1785
+ # Compaction detection - find context window resets
1786
+ session-recall compactions session.jsonl # List all compaction events
1787
+ session-recall last 1 --before-compaction # Recall pre-compaction content
1788
+ session-recall last 3 --compactions # Last 3 compaction phases with separators
1789
+
1790
+ # Tangents - mark side explorations
1791
+ session-recall --tangent-start "session-recall compaction feature" # Mark tangent start
1792
+ session-recall --tangent-end # Mark tangent end
1793
+ session-recall last 1 --no-tangents # Skip tangent content
1794
+ session-recall last 1 --tangents-only # Show only tangents
1394
1795
  `);
1395
1796
  }
1396
1797
 
@@ -1772,6 +2173,353 @@ function cmdTags(filterArg, opts) {
1772
2173
  console.log(`Use: session-recall last 1 to see critical tags in context`);
1773
2174
  }
1774
2175
 
2176
+ // ========== TANGENT SCANNING ==========
2177
+
2178
+ // Scan a session for tangent markers by searching tool_result content
2179
+ function scanSessionTangents(filePath) {
2180
+ const messages = readJsonl(filePath);
2181
+ const tangents = [];
2182
+ const openTangents = [];
2183
+
2184
+ for (const msg of messages) {
2185
+ const resultContent = getToolResultContent(msg);
2186
+ if (!resultContent) continue;
2187
+
2188
+ const startMatch = resultContent.match(TANGENT_START_PATTERN);
2189
+ if (startMatch) {
2190
+ const label = startMatch[1];
2191
+ const uuid = startMatch[2];
2192
+ const timestamp = startMatch[3];
2193
+
2194
+ // Skip placeholder patterns from documentation (e.g., uuid=<uuid>)
2195
+ if (uuid.includes('<') || uuid.includes('>')) continue;
2196
+
2197
+ openTangents.push({
2198
+ label,
2199
+ startUuid: uuid,
2200
+ startTimestamp: timestamp,
2201
+ msgTimestamp: msg.timestamp
2202
+ });
2203
+ continue;
2204
+ }
2205
+
2206
+ const endMatch = resultContent.match(TANGENT_END_PATTERN);
2207
+ if (endMatch) {
2208
+ const startUuid = endMatch[1];
2209
+ const endUuid = endMatch[2];
2210
+ const timestamp = endMatch[3];
2211
+
2212
+ // Skip placeholder patterns from documentation
2213
+ if (startUuid.includes('<') || startUuid.includes('>')) continue;
2214
+ if (endUuid.includes('<') || endUuid.includes('>')) continue;
2215
+
2216
+ if (openTangents.length > 0) {
2217
+ const openTangent = openTangents.pop();
2218
+ tangents.push({
2219
+ label: openTangent.label,
2220
+ startUuid: openTangent.startUuid,
2221
+ endUuid: endUuid,
2222
+ startTimestamp: openTangent.startTimestamp,
2223
+ endTimestamp: timestamp,
2224
+ tagTimestamp: msg.timestamp
2225
+ });
2226
+ }
2227
+ continue;
2228
+ }
2229
+ }
2230
+
2231
+ // Handle unclosed tangents - add them as warnings
2232
+ for (const open of openTangents) {
2233
+ tangents.push({
2234
+ label: open.label,
2235
+ startUuid: open.startUuid,
2236
+ endUuid: null,
2237
+ startTimestamp: open.startTimestamp,
2238
+ endTimestamp: null,
2239
+ tagTimestamp: open.msgTimestamp,
2240
+ unclosed: true
2241
+ });
2242
+ }
2243
+
2244
+ return tangents;
2245
+ }
2246
+
2247
+ // ========== COMPACTION DETECTION ==========
2248
+
2249
+ // Check if a message is a compaction marker
2250
+ function isCompactionMarker(msg) {
2251
+ if (msg.type !== 'progress') return false;
2252
+ if (!msg.data) return false;
2253
+ return msg.data.type === 'hook_progress' &&
2254
+ msg.data.hookEvent === 'SessionStart' &&
2255
+ msg.data.hookName === 'SessionStart:compact';
2256
+ }
2257
+
2258
+ // Scan session for compaction markers with deduplication
2259
+ // Returns array of compaction events with { timestamp, line, retries, messagesAfter }
2260
+ function scanCompactionMarkers(messages) {
2261
+ const markers = [];
2262
+
2263
+ // First pass: collect all raw markers with line numbers
2264
+ for (let i = 0; i < messages.length; i++) {
2265
+ const msg = messages[i];
2266
+ if (isCompactionMarker(msg)) {
2267
+ markers.push({
2268
+ line: i + 1, // 1-indexed line number
2269
+ timestamp: msg.timestamp,
2270
+ timestampMs: msg.timestamp ? new Date(msg.timestamp).getTime() : 0
2271
+ });
2272
+ }
2273
+ }
2274
+
2275
+ if (markers.length === 0) return [];
2276
+
2277
+ // Second pass: deduplicate markers into compaction events
2278
+ // Markers are same event if within COMPACTION_DEDUP_WINDOW_MS AND fewer than COMPACTION_DEDUP_MAX_MESSAGES between them
2279
+ const compactions = [];
2280
+ let currentCluster = [markers[0]];
2281
+
2282
+ for (let i = 1; i < markers.length; i++) {
2283
+ const prev = currentCluster[currentCluster.length - 1];
2284
+ const curr = markers[i];
2285
+
2286
+ const timeGapMs = curr.timestampMs - prev.timestampMs;
2287
+
2288
+ // Count discourse messages between prev and curr markers
2289
+ const prevLine = prev.line - 1; // Convert to 0-indexed
2290
+ const currLine = curr.line - 1;
2291
+ let discourseCount = 0;
2292
+ for (let j = prevLine + 1; j < currLine; j++) {
2293
+ if (isDiscourse(messages[j])) {
2294
+ discourseCount++;
2295
+ }
2296
+ }
2297
+
2298
+ // If within time window AND few discourse messages, same cluster
2299
+ if (timeGapMs <= COMPACTION_DEDUP_WINDOW_MS && discourseCount < COMPACTION_DEDUP_MAX_MESSAGES) {
2300
+ currentCluster.push(curr);
2301
+ } else {
2302
+ // New cluster - finalize previous one
2303
+ compactions.push(finalizeCluster(currentCluster, messages));
2304
+ currentCluster = [curr];
2305
+ }
2306
+ }
2307
+
2308
+ // Finalize last cluster
2309
+ compactions.push(finalizeCluster(currentCluster, messages));
2310
+
2311
+ return compactions;
2312
+ }
2313
+
2314
+ // Convert a cluster of markers into a single compaction event
2315
+ function finalizeCluster(cluster, messages) {
2316
+ const first = cluster[0];
2317
+ const retries = cluster.length - 1;
2318
+
2319
+ // Count messages (total raw messages, not just discourse) after this compaction
2320
+ // until end of file or next compaction marker
2321
+ let messagesAfter = 0;
2322
+ const startLine = first.line; // 1-indexed
2323
+ for (let i = startLine; i < messages.length; i++) {
2324
+ // Stop at next compaction marker
2325
+ if (isCompactionMarker(messages[i])) break;
2326
+ messagesAfter++;
2327
+ }
2328
+
2329
+ return {
2330
+ timestamp: first.timestamp,
2331
+ timestampMs: first.timestampMs,
2332
+ line: first.line,
2333
+ retries: retries,
2334
+ messagesAfter: messagesAfter
2335
+ };
2336
+ }
2337
+
2338
+ // cmdCompactions: List compaction events in a JSONL file
2339
+ function cmdCompactions(jsonlPath, opts) {
2340
+ // Auto-detect if no path provided
2341
+ if (!jsonlPath) {
2342
+ const dir = cwdToProjectFolder();
2343
+ if (!dir) {
2344
+ console.error(`No project folder found for CWD: ${process.cwd()}`);
2345
+ process.exit(1);
2346
+ }
2347
+
2348
+ const jsonlFiles = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
2349
+ if (jsonlFiles.length === 0) {
2350
+ console.error('No sessions found');
2351
+ process.exit(1);
2352
+ }
2353
+
2354
+ // Sort by modification time to get most recent
2355
+ const filesWithStats = jsonlFiles.map(f => {
2356
+ const filePath = path.join(dir, f);
2357
+ const stat = fs.statSync(filePath);
2358
+ return { filePath, mtime: stat.mtime };
2359
+ });
2360
+ filesWithStats.sort((a, b) => b.mtime - a.mtime);
2361
+ jsonlPath = filesWithStats[0].filePath;
2362
+ }
2363
+
2364
+ const messages = readJsonl(jsonlPath);
2365
+ const compactions = scanCompactionMarkers(messages);
2366
+
2367
+ if (compactions.length === 0) {
2368
+ console.log('No compaction events found.');
2369
+ return;
2370
+ }
2371
+
2372
+ console.log(`=== COMPACTION EVENTS (${compactions.length} total) ===`);
2373
+ console.log(`Source: ${path.basename(jsonlPath)}`);
2374
+ console.log(``);
2375
+
2376
+ for (let i = 0; i < compactions.length; i++) {
2377
+ const c = compactions[i];
2378
+ const ts = c.timestamp ? new Date(c.timestamp).toISOString().substring(11, 19) : '??:??:??';
2379
+ const retryInfo = c.retries > 0 ? `, +${c.retries} retries` : '';
2380
+
2381
+ console.log(`Compaction #${i + 1} @ ${ts} (line ${c.line}${retryInfo})`);
2382
+ console.log(` Messages after: ${c.messagesAfter}`);
2383
+ console.log(``);
2384
+ }
2385
+
2386
+ // Usage hint
2387
+ console.log(`Use: session-recall last 1 --before-compaction to recall pre-compaction content`);
2388
+ }
2389
+
2390
+ // ========== TANGENT COMMANDS ==========
2391
+
2392
+ function cmdTangentStart(label, backN, opts) {
2393
+ const dir = cwdToProjectFolder();
2394
+ if (!dir) {
2395
+ console.error(`No project folder found for CWD: ${process.cwd()}`);
2396
+ process.exit(1);
2397
+ }
2398
+
2399
+ // Get most recent session (INCLUDING active - this is the current session)
2400
+ const jsonlFiles = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
2401
+ if (jsonlFiles.length === 0) {
2402
+ console.error('No sessions found');
2403
+ process.exit(1);
2404
+ }
2405
+
2406
+ // Sort by modification time to get most recent
2407
+ const filesWithStats = jsonlFiles.map(f => {
2408
+ const filePath = path.join(dir, f);
2409
+ const stat = fs.statSync(filePath);
2410
+ return { filePath, mtime: stat.mtime };
2411
+ });
2412
+ filesWithStats.sort((a, b) => b.mtime - a.mtime);
2413
+
2414
+ const session = filesWithStats[0];
2415
+ const messages = readJsonl(session.filePath);
2416
+
2417
+ // Check for existing open tangent
2418
+ const tangents = scanSessionTangents(session.filePath);
2419
+ const openTangent = tangents.find(t => t.unclosed);
2420
+ if (openTangent) {
2421
+ console.error(`Error: You're already in a tangent ("${openTangent.label}").`);
2422
+ console.error(`Your biological brain is not ready for nested tangents.`);
2423
+ console.error(`Close current tangent first with: session-recall --tangent-end`);
2424
+ process.exit(1);
2425
+ }
2426
+
2427
+ // Classify all messages and filter by discourse
2428
+ const discourseMessages = [];
2429
+ for (const msg of messages) {
2430
+ const msgType = classifyMessageType(msg);
2431
+ if (msgType === 'discourse') {
2432
+ discourseMessages.push(msg);
2433
+ }
2434
+ }
2435
+
2436
+ // If --back N is specified, find the Nth discourse message back; otherwise use next message
2437
+ let targetUuid = null;
2438
+ if (backN && backN > 1) {
2439
+ const targetIndex = discourseMessages.length - backN;
2440
+ if (targetIndex < 0) {
2441
+ console.error(`Only ${discourseMessages.length} discourse messages found, cannot go back ${backN}`);
2442
+ process.exit(1);
2443
+ }
2444
+ const targetMsg = discourseMessages[targetIndex];
2445
+ targetUuid = targetMsg.uuid || '(no uuid)';
2446
+ } else {
2447
+ // Use a UUID for the marker (generate one or use most recent)
2448
+ const uuid = require('crypto').randomUUID();
2449
+ targetUuid = uuid;
2450
+ }
2451
+
2452
+ const isoTimestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
2453
+
2454
+ // Output marker format
2455
+ console.log(`@@SESSION-TANGENT-START@@ "${label}" uuid=${targetUuid} ts=${isoTimestamp}`);
2456
+ }
2457
+
2458
+ function cmdTangentEnd(backN, opts) {
2459
+ const dir = cwdToProjectFolder();
2460
+ if (!dir) {
2461
+ console.error(`No project folder found for CWD: ${process.cwd()}`);
2462
+ process.exit(1);
2463
+ }
2464
+
2465
+ // Get most recent session (INCLUDING active)
2466
+ const jsonlFiles = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
2467
+ if (jsonlFiles.length === 0) {
2468
+ console.error('No sessions found');
2469
+ process.exit(1);
2470
+ }
2471
+
2472
+ // Sort by modification time to get most recent
2473
+ const filesWithStats = jsonlFiles.map(f => {
2474
+ const filePath = path.join(dir, f);
2475
+ const stat = fs.statSync(filePath);
2476
+ return { filePath, mtime: stat.mtime };
2477
+ });
2478
+ filesWithStats.sort((a, b) => b.mtime - a.mtime);
2479
+
2480
+ const session = filesWithStats[0];
2481
+ const messages = readJsonl(session.filePath);
2482
+
2483
+ // Check for open tangent
2484
+ const tangents = scanSessionTangents(session.filePath);
2485
+ const openTangent = tangents.find(t => t.unclosed);
2486
+ if (!openTangent) {
2487
+ console.error(`Error: No open tangent to close.`);
2488
+ console.error(`Start one with: session-recall --tangent-start "label"`);
2489
+ process.exit(1);
2490
+ }
2491
+
2492
+ // Classify all messages and filter by discourse
2493
+ const discourseMessages = [];
2494
+ for (const msg of messages) {
2495
+ const msgType = classifyMessageType(msg);
2496
+ if (msgType === 'discourse') {
2497
+ discourseMessages.push(msg);
2498
+ }
2499
+ }
2500
+
2501
+ // Determine end UUID
2502
+ let endUuid;
2503
+ if (backN && backN > 1) {
2504
+ const targetIndex = discourseMessages.length - backN;
2505
+ if (targetIndex < 0) {
2506
+ console.error(`Only ${discourseMessages.length} discourse messages found, cannot go back ${backN}`);
2507
+ process.exit(1);
2508
+ }
2509
+ const targetMsg = discourseMessages[targetIndex];
2510
+ endUuid = targetMsg.uuid || '(no uuid)';
2511
+ } else {
2512
+ // Use a UUID for the end marker (generate one)
2513
+ const uuid = require('crypto').randomUUID();
2514
+ endUuid = uuid;
2515
+ }
2516
+
2517
+ const isoTimestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
2518
+
2519
+ // Output marker format
2520
+ console.log(`@@SESSION-TANGENT-END@@ start_uuid=${openTangent.startUuid} end_uuid=${endUuid} ts=${isoTimestamp}`);
2521
+ }
2522
+
1775
2523
  // ========== ARGUMENT PARSING ==========
1776
2524
 
1777
2525
  function parseArgs(args) {
@@ -1816,6 +2564,23 @@ function parseArgs(args) {
1816
2564
  opts.back = parseInt(args[++i]);
1817
2565
  } else if (arg === '--show-important') {
1818
2566
  opts.showImportant = true;
2567
+ } else if (arg === '--before-compaction') {
2568
+ opts.beforeCompaction = true;
2569
+ // Check if next arg is a number (not another flag)
2570
+ if (i + 1 < args.length && /^\d+$/.test(args[i + 1])) {
2571
+ opts.beforeCompactionN = parseInt(args[i + 1], 10);
2572
+ i++; // Skip the number in iteration
2573
+ }
2574
+ } else if (arg === '--compactions') {
2575
+ opts.compactionPhases = true;
2576
+ } else if (arg === '--tangent-start') {
2577
+ opts.tangentStart = args[++i];
2578
+ } else if (arg === '--tangent-end') {
2579
+ opts.tangentEnd = true;
2580
+ } else if (arg === '--no-tangents') {
2581
+ opts.noTangents = true;
2582
+ } else if (arg === '--tangents-only') {
2583
+ opts.tangentsOnly = true;
1819
2584
  } else if (arg === '-h' || arg === '--help') {
1820
2585
  opts.help = true;
1821
2586
  } else if (arg === '-v' || arg === '--version') {
@@ -1885,6 +2650,40 @@ if (opts.checkpoint) {
1885
2650
  process.exit(0);
1886
2651
  }
1887
2652
 
2653
+ // Handle --tangent-start flag
2654
+ if (opts.tangentStart) {
2655
+ const label = opts.tangentStart;
2656
+ const backN = opts.back || 1;
2657
+ cmdTangentStart(label, backN, opts);
2658
+ process.exit(0);
2659
+ }
2660
+
2661
+ // Handle --tangent-end flag
2662
+ if (opts.tangentEnd) {
2663
+ const backN = opts.back || 1;
2664
+ cmdTangentEnd(backN, opts);
2665
+ process.exit(0);
2666
+ }
2667
+
2668
+ // Handle 'tangent-start' command: session-recall tangent-start [--back N] "label"
2669
+ if (positional[0] === 'tangent-start') {
2670
+ const label = opts.tangentStart || positional[1];
2671
+ if (!label) {
2672
+ console.error('Usage: session-recall tangent-start [--back N] "label"');
2673
+ process.exit(1);
2674
+ }
2675
+ const backN = opts.back || 1;
2676
+ cmdTangentStart(label, backN, opts);
2677
+ process.exit(0);
2678
+ }
2679
+
2680
+ // Handle 'tangent-end' command: session-recall tangent-end [--back N]
2681
+ if (positional[0] === 'tangent-end') {
2682
+ const backN = opts.back || 1;
2683
+ cmdTangentEnd(backN, opts);
2684
+ process.exit(0);
2685
+ }
2686
+
1888
2687
  // Handle 'tag' command: session-recall tag <type> [--back N] <level> "reason"
1889
2688
  // Must come before --back handler since tag command can have --back option
1890
2689
  if (positional[0] === 'tag') {
@@ -1941,15 +2740,16 @@ if (opts.help || positional.length === 0) {
1941
2740
  const command = positional[0];
1942
2741
  const jsonlPath = positional[1];
1943
2742
 
1944
- // 'last', 'tools', and 'checkpoints' commands don't require a path
1945
- if (!jsonlPath && command !== 'help' && command !== 'last' && command !== 'tools' && command !== 'checkpoints') {
2743
+ // 'last', 'tools', 'checkpoints', and 'compactions' commands don't require a path
2744
+ if (!jsonlPath && command !== 'help' && command !== 'last' && command !== 'tools' && command !== 'checkpoints' && command !== 'compactions') {
1946
2745
  console.error('Error: JSONL path required');
1947
2746
  showHelp();
1948
2747
  process.exit(1);
1949
2748
  }
1950
2749
 
1951
- // For 'last' command, the second arg might be a number, not a path
1952
- if (command !== 'last' && jsonlPath && !fs.existsSync(jsonlPath)) {
2750
+ // For 'last' and 'compactions' commands, the second arg might not be a path
2751
+ // 'compactions' auto-detects if no path provided, so skip validation
2752
+ if (command !== 'last' && command !== 'compactions' && jsonlPath && !fs.existsSync(jsonlPath)) {
1953
2753
  console.error(`Error: File not found: ${jsonlPath}`);
1954
2754
  process.exit(1);
1955
2755
  }
@@ -1979,6 +2779,9 @@ switch (command) {
1979
2779
  case 'checkpoints':
1980
2780
  cmdCheckpoints(opts);
1981
2781
  break;
2782
+ case 'compactions':
2783
+ cmdCompactions(jsonlPath, opts);
2784
+ break;
1982
2785
  case 'help':
1983
2786
  showHelp();
1984
2787
  break;