@aiplumber/session-recall 1.6.8 → 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.
- package/package.json +1 -1
- package/session-recall +821 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiplumber/session-recall",
|
|
3
|
-
"version": "1.
|
|
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,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const VERSION = '1.
|
|
3
|
+
const VERSION = '1.8.4';
|
|
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,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
|
-
|
|
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
|
-
//
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1607
|
+
addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] USER: ${text.substring(0, 500)}`);
|
|
1608
|
+
addOutput(` Reason: "${tag.reason}"`);
|
|
1267
1609
|
} else {
|
|
1268
|
-
|
|
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
|
-
|
|
1621
|
+
addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ASSISTANT: ${text.substring(0, 500)}`);
|
|
1280
1622
|
} else if (toolSummary) {
|
|
1281
|
-
|
|
1623
|
+
addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ${toolSummary}`);
|
|
1282
1624
|
}
|
|
1283
|
-
|
|
1625
|
+
addOutput(` Reason: "${tag.reason}"`);
|
|
1284
1626
|
} else {
|
|
1285
1627
|
if (text) {
|
|
1286
|
-
|
|
1628
|
+
addOutput(`[${ts}] ASSISTANT: ${text.substring(0, 500)}`);
|
|
1287
1629
|
} else if (toolSummary) {
|
|
1288
|
-
|
|
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
|
-
|
|
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 '
|
|
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'
|
|
1952
|
-
|
|
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;
|