@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.
- package/package.json +1 -1
- package/session-recall +842 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiplumber/session-recall",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1628
|
+
addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] USER: ${text.substring(0, 500)}`);
|
|
1629
|
+
addOutput(` Reason: "${tag.reason}"`);
|
|
1267
1630
|
} else {
|
|
1268
|
-
|
|
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
|
-
|
|
1642
|
+
addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ASSISTANT: ${text.substring(0, 500)}`);
|
|
1280
1643
|
} else if (toolSummary) {
|
|
1281
|
-
|
|
1644
|
+
addOutput(`[${ts}] ${marker} ${tag.level.toUpperCase()} [${tag.type}] ${toolSummary}`);
|
|
1282
1645
|
}
|
|
1283
|
-
|
|
1646
|
+
addOutput(` Reason: "${tag.reason}"`);
|
|
1284
1647
|
} else {
|
|
1285
1648
|
if (text) {
|
|
1286
|
-
|
|
1649
|
+
addOutput(`[${ts}] ASSISTANT: ${text.substring(0, 500)}`);
|
|
1287
1650
|
} else if (toolSummary) {
|
|
1288
|
-
|
|
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
|
-
|
|
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 '
|
|
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'
|
|
1952
|
-
|
|
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;
|