@aiplumber/session-recall 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +29 -0
  2. package/session-recall +1351 -0
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@aiplumber/session-recall",
3
+ "version": "1.5.0",
4
+ "description": "Pull context from previous Claude Code sessions. Sessions end, context resets - this tool lets you continue where you left off.",
5
+ "bin": {
6
+ "session-recall": "./session-recall"
7
+ },
8
+ "files": [
9
+ "session-recall",
10
+ "README.md"
11
+ ],
12
+ "keywords": [
13
+ "claude",
14
+ "claude-code",
15
+ "session",
16
+ "context",
17
+ "recall",
18
+ "cli"
19
+ ],
20
+ "author": "Hung Nguyen",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/pnwtech/session-recall.git"
25
+ },
26
+ "engines": {
27
+ "node": ">=16.0.0"
28
+ }
29
+ }
package/session-recall ADDED
@@ -0,0 +1,1351 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // Filter patterns
7
+ const SHELL_PREFIXES = ['ls ', 'cat ', 'npm ', 'cd ', 'git ', 'mkdir ', 'rm ', 'cp ', 'mv ', 'grep ', 'find '];
8
+ const NOISE_TYPES = ['progress', 'file-history-snapshot', 'system', 'queue-operation'];
9
+ const MAX_USER_MSG_LENGTH = 200;
10
+ const ACTIVE_SESSION_THRESHOLD_MS = 60000; // Sessions with activity in last 60s are "active"
11
+
12
+ // Token estimation (~4 chars per token for English, conservative)
13
+ function estimateTokens(text) {
14
+ if (!text) return 0;
15
+ return Math.ceil(text.length / 4);
16
+ }
17
+
18
+ // Marker detection patterns
19
+ const MARKERS = {
20
+ aha_moment: [/oh!/i, /YES/, /now I see/i, /did we just discover/i, /that's it/i],
21
+ pushback: [/^no$/i, /^wait$/i, /for real\?/i, /nooo+/i, /^but /i, /hold on/i],
22
+ strong_opinion: [/\bmust\b/i, /\bnever\b/i, /\balways\b/i, /\bneed to\b/i],
23
+ clarification: [/you mean/i, /so basically/i, /in other words/i],
24
+ decision: /^[A-D](,\s*[A-D])*$/i
25
+ };
26
+
27
+ function readJsonl(filePath) {
28
+ const content = fs.readFileSync(filePath, 'utf-8');
29
+ return content.trim().split('\n').map((line) => {
30
+ try {
31
+ return JSON.parse(line);
32
+ } catch (e) {
33
+ return null;
34
+ }
35
+ }).filter(Boolean);
36
+ }
37
+
38
+ function isToolResult(msg) {
39
+ if (!msg.message?.content) return false;
40
+ const content = msg.message.content;
41
+ if (Array.isArray(content)) {
42
+ return content.some(c => c.type === 'tool_result');
43
+ }
44
+ return false;
45
+ }
46
+
47
+ function isShellCommand(text) {
48
+ if (typeof text !== 'string') return false;
49
+ return SHELL_PREFIXES.some(prefix => text.startsWith(prefix));
50
+ }
51
+
52
+ function getAssistantText(msg) {
53
+ if (!msg.message?.content) return null;
54
+ const content = msg.message.content;
55
+ if (!Array.isArray(content)) return null;
56
+
57
+ const textBlocks = content.filter(c => c.type === 'text');
58
+ if (textBlocks.length === 0) return null;
59
+ return textBlocks.map(t => t.text).join('\n');
60
+ }
61
+
62
+ function getUserText(msg) {
63
+ if (!msg.message?.content) return null;
64
+ const content = msg.message.content;
65
+ if (typeof content === 'string') return content;
66
+ return null;
67
+ }
68
+
69
+ function detectMarker(text) {
70
+ if (!text) return null;
71
+
72
+ for (const [marker, patterns] of Object.entries(MARKERS)) {
73
+ if (Array.isArray(patterns)) {
74
+ if (patterns.some(p => p.test(text))) return marker;
75
+ } else if (patterns.test(text)) {
76
+ return marker;
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function isDiscourse(msg) {
83
+ if (NOISE_TYPES.includes(msg.type)) return false;
84
+
85
+ if (msg.type === 'user') {
86
+ if (isToolResult(msg)) return false;
87
+ const text = getUserText(msg);
88
+ if (!text) return false;
89
+ if (text.length > MAX_USER_MSG_LENGTH) return false;
90
+ if (isShellCommand(text)) return false;
91
+ return true;
92
+ }
93
+
94
+ if (msg.type === 'assistant') {
95
+ const text = getAssistantText(msg);
96
+ return text !== null && text.length > 0;
97
+ }
98
+
99
+ return false;
100
+ }
101
+
102
+ function buildToolResultMeta(messages) {
103
+ const meta = {};
104
+ const toolUseInfo = {};
105
+
106
+ // First pass: collect tool_use timestamps, model, agent info, and Task details
107
+ for (const msg of messages) {
108
+ if (msg.type === 'assistant' && msg.message?.content && Array.isArray(msg.message.content)) {
109
+ const model = msg.message?.model || null;
110
+ const agentId = msg.isSidechain ? msg.agentId : null;
111
+ for (const block of msg.message.content) {
112
+ if (block.type === 'tool_use' && block.id) {
113
+ const info = {
114
+ timestamp: msg.timestamp ? new Date(msg.timestamp).getTime() : null,
115
+ model: model,
116
+ agentId: agentId
117
+ };
118
+ // Capture Task tool details
119
+ if (block.name === 'Task' && block.input) {
120
+ info.taskDescription = block.input.description || null;
121
+ info.taskPrompt = block.input.prompt || null;
122
+ }
123
+ toolUseInfo[block.id] = info;
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ // Second pass: collect tool_result sizes and calculate duration
130
+ for (const msg of messages) {
131
+ if (msg.type === 'user' && msg.message?.content && Array.isArray(msg.message.content)) {
132
+ for (const block of msg.message.content) {
133
+ if (block.type === 'tool_result' && block.tool_use_id) {
134
+ const content = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
135
+ const resultTs = msg.timestamp ? new Date(msg.timestamp).getTime() : null;
136
+ const info = toolUseInfo[block.tool_use_id] || {};
137
+ const durationMs = (resultTs && info.timestamp) ? resultTs - info.timestamp : null;
138
+
139
+ const metaEntry = {
140
+ size: content?.length || 0,
141
+ durationMs: durationMs,
142
+ model: info.model,
143
+ agentId: info.agentId,
144
+ taskDescription: info.taskDescription,
145
+ taskPrompt: info.taskPrompt
146
+ };
147
+
148
+ // Parse Task tool result for agentId and output_file
149
+ if (info.taskDescription && content) {
150
+ const agentMatch = content.match(/agentId:\s*(\w+)/);
151
+ const outputMatch = content.match(/output_file:\s*([^\n]+)/);
152
+ if (agentMatch) metaEntry.taskAgentId = agentMatch[1];
153
+ if (outputMatch) metaEntry.taskOutputFile = outputMatch[1].trim();
154
+ }
155
+
156
+ meta[block.tool_use_id] = metaEntry;
157
+ }
158
+ }
159
+ }
160
+ }
161
+ return meta;
162
+ }
163
+
164
+ function formatSize(bytes) {
165
+ if (bytes < 1024) return `${bytes}B`;
166
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
167
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
168
+ }
169
+
170
+ function formatDuration(ms) {
171
+ if (ms === null || ms === undefined) return '';
172
+ if (ms < 1000) return `${ms}ms`;
173
+ return `${(ms / 1000).toFixed(1)}s`;
174
+ }
175
+
176
+ function shortModelName(model) {
177
+ if (!model) return '';
178
+ if (model.includes('opus')) return 'opus';
179
+ if (model.includes('sonnet')) return 'sonnet';
180
+ if (model.includes('haiku')) return 'haiku';
181
+ if (model.includes('gemini')) return 'gemini';
182
+ return model.substring(0, 10);
183
+ }
184
+
185
+ function collapseToolCall(msg, resultMeta = {}) {
186
+ if (msg.type !== 'assistant') return null;
187
+ if (!msg.message?.content || !Array.isArray(msg.message.content)) return null;
188
+
189
+ const toolUses = msg.message.content.filter(c => c.type === 'tool_use');
190
+ if (toolUses.length === 0) return null;
191
+
192
+ return toolUses.map(t => {
193
+ const name = t.name;
194
+ const input = t.input || {};
195
+ const shortId = t.id ? t.id.substring(5, 17) : '';
196
+ const meta = resultMeta[t.id] || {};
197
+ const size = meta.size !== undefined ? formatSize(meta.size) : '';
198
+ const duration = meta.durationMs ? formatDuration(meta.durationMs) : '';
199
+ const model = meta.model ? shortModelName(meta.model) : '';
200
+ const agent = meta.agentId ? `@${meta.agentId}` : '';
201
+ const suffix = size || duration || model || agent ? ` (${[model, size, duration, agent].filter(Boolean).join(', ')})` : '';
202
+
203
+ if (name === 'Task') {
204
+ const desc = meta.taskDescription || input.description || 'task';
205
+ const taskAgent = meta.taskAgentId ? `@${meta.taskAgentId}` : '';
206
+ const taskOutput = meta.taskOutputFile || '';
207
+ const taskInfo = [taskAgent, taskOutput].filter(Boolean).join(' → ');
208
+ const taskSuffix = [model, size, duration, taskInfo].filter(Boolean).join(', ');
209
+ return `[${shortId} Task: ${desc}] (${taskSuffix})`;
210
+ } else if (name === 'Read') {
211
+ return `[${shortId} Read: ${input.file_path}]${suffix}`;
212
+ } else if (name === 'Bash') {
213
+ const cmd = input.command?.substring(0, 60) || 'command';
214
+ return `[${shortId} Ran: ${cmd}${input.command?.length > 60 ? '...' : ''}]${suffix}`;
215
+ } else if (name === 'Write') {
216
+ return `[${shortId} Write: ${input.file_path}]${suffix}`;
217
+ } else if (name === 'Edit') {
218
+ return `[${shortId} Edit: ${input.file_path}]${suffix}`;
219
+ } else if (name === 'Glob') {
220
+ return `[${shortId} Glob: ${input.pattern}]${suffix}`;
221
+ } else if (name === 'Grep') {
222
+ return `[${shortId} Grep: ${input.pattern}]${suffix}`;
223
+ } else {
224
+ return `[${shortId} ${name}]${suffix}`;
225
+ }
226
+ }).join(' ');
227
+ }
228
+
229
+ // ========== COMMANDS ==========
230
+
231
+ function cmdParse(jsonlPath, opts) {
232
+ const messages = readJsonl(jsonlPath);
233
+ const discourse = messages.filter(isDiscourse);
234
+
235
+ // Calculate totals for dry-run
236
+ let totalChars = 0;
237
+ const contents = discourse.map(msg => {
238
+ const text = msg.type === 'user' ? getUserText(msg) : getAssistantText(msg);
239
+ totalChars += (text?.length || 0);
240
+ return { msg, text };
241
+ });
242
+
243
+ if (opts.dryRun) {
244
+ const userCount = discourse.filter(m => m.type === 'user').length;
245
+ const assistantCount = discourse.filter(m => m.type === 'assistant').length;
246
+ console.log(`--- DRY RUN: parse ---`);
247
+ console.log(`Messages: ${discourse.length} (${userCount} user, ${assistantCount} assistant)`);
248
+ console.log(`Characters: ${totalChars.toLocaleString()}`);
249
+ console.log(`Estimated tokens: ~${estimateTokens({length: totalChars}).toLocaleString()}`);
250
+ console.log(`Filtered out: ${messages.length - discourse.length} noise messages`);
251
+ return;
252
+ }
253
+
254
+ if (opts.format === 'text') {
255
+ contents.forEach(({ msg, text }) => {
256
+ const role = msg.type === 'user' ? 'USER' : 'ASSISTANT';
257
+ const ts = msg.timestamp ? new Date(msg.timestamp).toISOString().substring(11, 19) : '';
258
+ console.log(`[${ts}] ${role}: ${text?.substring(0, 150)}${text?.length > 150 ? '...' : ''}`);
259
+ });
260
+ } else {
261
+ contents.forEach(({ msg, text }) => {
262
+ const output = {
263
+ type: msg.type,
264
+ timestamp: msg.timestamp,
265
+ content: text
266
+ };
267
+ console.log(JSON.stringify(output));
268
+ });
269
+ }
270
+
271
+ if (opts.stats) {
272
+ const userCount = discourse.filter(m => m.type === 'user').length;
273
+ const assistantCount = discourse.filter(m => m.type === 'assistant').length;
274
+ console.error(`\n--- Stats ---`);
275
+ console.error(`Total discourse: ${discourse.length} (${userCount} user, ${assistantCount} assistant)`);
276
+ console.error(`Filtered out: ${messages.length - discourse.length} noise messages`);
277
+ console.error(`Estimated tokens: ~${estimateTokens({length: totalChars}).toLocaleString()}`);
278
+ }
279
+ }
280
+
281
+ function cmdHot(jsonlPath, opts) {
282
+ const messages = readJsonl(jsonlPath);
283
+ const discourse = messages.filter(isDiscourse);
284
+ const lastN = parseInt(opts.last) || 30;
285
+
286
+ const hot = discourse.slice(-lastN);
287
+
288
+ const exchanges = hot.map(msg => {
289
+ const text = msg.type === 'user' ? getUserText(msg) : getAssistantText(msg);
290
+ return {
291
+ ts: msg.timestamp,
292
+ role: msg.type === 'user' ? 'user' : 'assistant',
293
+ content: text,
294
+ marker: detectMarker(text)
295
+ };
296
+ });
297
+
298
+ const totalChars = exchanges.reduce((sum, e) => sum + (e.content?.length || 0), 0);
299
+
300
+ if (opts.dryRun) {
301
+ const userCount = exchanges.filter(e => e.role === 'user').length;
302
+ const assistantCount = exchanges.filter(e => e.role === 'assistant').length;
303
+ const markedCount = exchanges.filter(e => e.marker).length;
304
+ console.log(`--- DRY RUN: hot --last ${lastN} ---`);
305
+ console.log(`Exchanges: ${exchanges.length} (${userCount} user, ${assistantCount} assistant)`);
306
+ console.log(`Marked moments: ${markedCount}`);
307
+ console.log(`Characters: ${totalChars.toLocaleString()}`);
308
+ console.log(`Estimated tokens: ~${estimateTokens({length: totalChars}).toLocaleString()}`);
309
+ console.log(`Available discourse: ${discourse.length} total`);
310
+ return;
311
+ }
312
+
313
+ const insights = exchanges.filter(e => e.marker === 'aha_moment' || e.marker === 'strong_opinion');
314
+ const lastInsight = insights.length > 0 ? insights[insights.length - 1].content : null;
315
+
316
+ const output = {
317
+ source: path.resolve(jsonlPath),
318
+ extracted: new Date().toISOString(),
319
+ message_count: exchanges.length,
320
+ thread: 'extracted from ' + path.basename(jsonlPath),
321
+ last_insight: lastInsight?.substring(0, 200) || 'none detected',
322
+ momentum: exchanges.length > 20 ? 'high' : exchanges.length > 10 ? 'medium' : 'low',
323
+ exchanges
324
+ };
325
+
326
+ console.log(JSON.stringify(output, null, 2));
327
+ }
328
+
329
+ function getToolResultText(msg) {
330
+ if (!msg.message?.content || !Array.isArray(msg.message.content)) return null;
331
+ const result = msg.message.content.find(c => c.type === 'tool_result');
332
+ if (!result) return null;
333
+ return typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
334
+ }
335
+
336
+ function cmdRinse(inputPath, opts) {
337
+ // Check if input is a directory or file
338
+ const stat = fs.statSync(inputPath);
339
+ const isDir = stat.isDirectory();
340
+
341
+ let allMessages = [];
342
+ if (isDir) {
343
+ const jsonlFiles = fs.readdirSync(inputPath).filter(f => f.endsWith('.jsonl'));
344
+ // Sort by modification time to get chronological order
345
+ const filesWithStats = jsonlFiles.map(f => {
346
+ const filePath = path.join(inputPath, f);
347
+ const msgs = readJsonl(filePath);
348
+ const timestamps = msgs.filter(m => m.timestamp).map(m => new Date(m.timestamp));
349
+ const firstTs = timestamps.length > 0 ? Math.min(...timestamps) : 0;
350
+ return { filePath, msgs, firstTs };
351
+ });
352
+ filesWithStats.sort((a, b) => a.firstTs - b.firstTs);
353
+ for (const f of filesWithStats) {
354
+ allMessages.push(...f.msgs);
355
+ }
356
+ } else {
357
+ allMessages = readJsonl(inputPath);
358
+ }
359
+
360
+ const messages = allMessages;
361
+ const collapseTools = opts.collapseTools;
362
+ const mild = opts.mild;
363
+ const truncateLen = mild ? 200 : 500;
364
+
365
+ const output = [];
366
+
367
+ for (const msg of messages) {
368
+ if (NOISE_TYPES.includes(msg.type)) continue;
369
+
370
+ if (msg.type === 'user') {
371
+ // Tool results handling
372
+ if (isToolResult(msg)) {
373
+ if (mild) {
374
+ // Mild: keep tool results but truncated
375
+ const resultText = getToolResultText(msg);
376
+ if (resultText) {
377
+ output.push({
378
+ role: 'tool_result',
379
+ ts: msg.timestamp,
380
+ content: resultText.substring(0, truncateLen) + (resultText.length > truncateLen ? '...' : '')
381
+ });
382
+ }
383
+ }
384
+ // Otherwise (collapse mode): skip entirely
385
+ continue;
386
+ }
387
+
388
+ const text = getUserText(msg);
389
+ if (text) {
390
+ output.push({
391
+ role: 'user',
392
+ ts: msg.timestamp,
393
+ content: text.substring(0, 500)
394
+ });
395
+ }
396
+ } else if (msg.type === 'assistant') {
397
+ const text = getAssistantText(msg);
398
+ const toolSummary = collapseTools ? collapseToolCall(msg) : null;
399
+
400
+ if (text) {
401
+ output.push({
402
+ role: 'assistant',
403
+ ts: msg.timestamp,
404
+ content: text.substring(0, truncateLen)
405
+ });
406
+ } else if (toolSummary && (collapseTools || mild)) {
407
+ output.push({
408
+ role: 'assistant',
409
+ ts: msg.timestamp,
410
+ content: toolSummary
411
+ });
412
+ }
413
+ }
414
+ }
415
+
416
+ const totalChars = output.reduce((sum, m) => sum + (m.content?.length || 0), 0);
417
+
418
+ if (opts.dryRun) {
419
+ const userCount = output.filter(m => m.role === 'user').length;
420
+ const assistantCount = output.filter(m => m.role === 'assistant').length;
421
+ const toolResultCount = output.filter(m => m.role === 'tool_result').length;
422
+ const mode = mild ? '--logistics' : (collapseTools ? '--gotime' : '');
423
+ console.log(`--- DRY RUN: rinse${mode ? ' ' + mode : ''} ---`);
424
+
425
+ if (isDir) {
426
+ const jsonlFiles = fs.readdirSync(inputPath).filter(f => f.endsWith('.jsonl'));
427
+ const filesWithStats = jsonlFiles.map(f => {
428
+ const filePath = path.join(inputPath, f);
429
+ const msgs = readJsonl(filePath);
430
+ const timestamps = msgs.filter(m => m.timestamp).map(m => new Date(m.timestamp));
431
+ const firstTs = timestamps.length > 0 ? new Date(Math.min(...timestamps)) : null;
432
+ return { file: f, filePath, msgs, firstTs, raw: msgs.length };
433
+ });
434
+ filesWithStats.sort((a, b) => (a.firstTs || 0) - (b.firstTs || 0));
435
+
436
+ console.log(`Source: ${inputPath}`);
437
+ console.log(``);
438
+ console.log(`SESSIONS TO MERGE:`);
439
+ console.log(`# DATE TIME RAW FILE`);
440
+ console.log(`────────────────────────────────────────────────────────────────────`);
441
+ filesWithStats.forEach((s, i) => {
442
+ const date = s.firstTs ? s.firstTs.toISOString().substring(0, 10) : 'unknown';
443
+ const time = s.firstTs ? s.firstTs.toISOString().substring(11, 16) : '??:??';
444
+ const fileName = s.file.replace('.jsonl', '');
445
+ console.log(`${String(i + 1).padStart(2)} ${date} ${time} ${String(s.raw).padStart(4)} ${fileName}`);
446
+ });
447
+ console.log(`────────────────────────────────────────────────────────────────────`);
448
+ } else {
449
+ console.log(`Source: ${path.basename(inputPath)}`);
450
+ }
451
+
452
+ const tokens = estimateTokens({length: totalChars});
453
+ console.log(``);
454
+ console.log(`OUTPUT:`);
455
+ console.log(`Messages: ${output.length} (${userCount} user, ${assistantCount} assistant${toolResultCount ? ', ' + toolResultCount + ' tool_result' : ''})`);
456
+ console.log(`Characters: ${totalChars.toLocaleString()}`);
457
+ console.log(`Estimated tokens: ~${tokens.toLocaleString()}`);
458
+ console.log(`Compression: ${messages.length} → ${output.length} (${Math.round((1 - output.length/messages.length) * 100)}% reduction)`);
459
+ return;
460
+ }
461
+
462
+ // Check token limit before outputting (only for directories)
463
+ const TOKEN_LIMIT = 50000;
464
+ const tokens = estimateTokens({length: totalChars});
465
+ if (isDir && tokens > TOKEN_LIMIT && !opts.yolo) {
466
+ console.error(`\n⚠️ HOLD UP: ${tokens.toLocaleString()} tokens exceeds safe limit (${TOKEN_LIMIT.toLocaleString()})`);
467
+ console.error(` This will eat most of your context window.`);
468
+ console.error(`\n To proceed anyway: --yolo`);
469
+ console.error(` Or pick specific sessions instead of entire folder.`);
470
+ process.exit(1);
471
+ }
472
+
473
+ if (opts.format === 'text') {
474
+ output.forEach(m => {
475
+ const ts = m.ts ? new Date(m.ts).toISOString().substring(11, 19) : '';
476
+ console.log(`[${ts}] ${m.role.toUpperCase()}: ${m.content}`);
477
+ });
478
+ } else {
479
+ output.forEach(m => console.log(JSON.stringify(m)));
480
+ }
481
+
482
+ console.error(`\n--- Rinsed ${messages.length} → ${output.length} messages (${estimateTokens({length: totalChars}).toLocaleString()} tokens) ---`);
483
+ }
484
+
485
+ function cmdState(jsonlPath) {
486
+ const messages = readJsonl(jsonlPath);
487
+ const discourse = messages.filter(isDiscourse);
488
+
489
+ const typeCounts = {};
490
+ messages.forEach(m => {
491
+ typeCounts[m.type] = (typeCounts[m.type] || 0) + 1;
492
+ });
493
+
494
+ const markerCounts = { aha_moment: 0, pushback: 0, strong_opinion: 0, clarification: 0, decision: 0 };
495
+ discourse.forEach(msg => {
496
+ const text = msg.type === 'user' ? getUserText(msg) : getAssistantText(msg);
497
+ const marker = detectMarker(text);
498
+ if (marker) markerCounts[marker]++;
499
+ });
500
+
501
+ const timestamps = messages.filter(m => m.timestamp).map(m => new Date(m.timestamp));
502
+ const firstTs = timestamps.length > 0 ? new Date(Math.min(...timestamps)) : null;
503
+ const lastTs = timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null;
504
+
505
+ console.log(`Source: ${path.resolve(jsonlPath)}`);
506
+ console.log(`Total messages: ${messages.length}`);
507
+ console.log(`\nBy type:`);
508
+ Object.entries(typeCounts).sort((a, b) => b[1] - a[1]).forEach(([type, count]) => {
509
+ console.log(` ${type}: ${count}`);
510
+ });
511
+
512
+ console.log(`\nFiltered discourse: ${discourse.length} messages`);
513
+ console.log(` user: ${discourse.filter(m => m.type === 'user').length}`);
514
+ console.log(` assistant: ${discourse.filter(m => m.type === 'assistant').length}`);
515
+
516
+ console.log(`\nDetected markers:`);
517
+ Object.entries(markerCounts).forEach(([marker, count]) => {
518
+ if (count > 0) console.log(` ${marker}: ${count}`);
519
+ });
520
+
521
+ if (firstTs && lastTs) {
522
+ console.log(`\nTime range:`);
523
+ console.log(` First: ${firstTs.toISOString()}`);
524
+ console.log(` Last: ${lastTs.toISOString()}`);
525
+ const durationMs = lastTs - firstTs;
526
+ const hours = Math.floor(durationMs / 3600000);
527
+ const mins = Math.floor((durationMs % 3600000) / 60000);
528
+ console.log(` Duration: ${hours}h ${mins}m`);
529
+ }
530
+ }
531
+
532
+ function extractToolCalls(messages) {
533
+ const toolCalls = [];
534
+ const toolResults = {};
535
+
536
+ for (const msg of messages) {
537
+ // Extract tool_use from assistant messages
538
+ if (msg.type === 'assistant' && msg.message?.content) {
539
+ const content = msg.message.content;
540
+ if (Array.isArray(content)) {
541
+ for (const block of content) {
542
+ if (block.type === 'tool_use') {
543
+ toolCalls.push({
544
+ id: block.id,
545
+ name: block.name,
546
+ input: block.input,
547
+ timestamp: msg.timestamp
548
+ });
549
+ }
550
+ }
551
+ }
552
+ }
553
+
554
+ // Extract tool_result from user messages
555
+ if (msg.type === 'user' && msg.message?.content) {
556
+ const content = msg.message.content;
557
+ if (Array.isArray(content)) {
558
+ for (const block of content) {
559
+ if (block.type === 'tool_result' && block.tool_use_id) {
560
+ toolResults[block.tool_use_id] = block.content;
561
+ }
562
+ }
563
+ }
564
+ }
565
+ }
566
+
567
+ // Attach results to calls
568
+ for (const call of toolCalls) {
569
+ call.result = toolResults[call.id] || null;
570
+ }
571
+
572
+ return toolCalls;
573
+ }
574
+
575
+ function cmdTools(opts) {
576
+ const dir = cwdToProjectFolder();
577
+ if (!dir) {
578
+ console.error(`No project folder found for CWD: ${process.cwd()}`);
579
+ process.exit(1);
580
+ }
581
+
582
+ // Get most recent session
583
+ const jsonlFiles = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
584
+ if (jsonlFiles.length === 0) {
585
+ console.error('No sessions found');
586
+ process.exit(1);
587
+ }
588
+
589
+ // Sort by modification time to get most recent
590
+ const filesWithStats = jsonlFiles.map(f => {
591
+ const filePath = path.join(dir, f);
592
+ const msgs = readJsonl(filePath);
593
+ const timestamps = msgs.filter(m => m.timestamp).map(m => new Date(m.timestamp));
594
+ const lastTs = timestamps.length > 0 ? new Date(Math.max(...timestamps)) : new Date(0);
595
+ return { filePath, msgs, lastTs };
596
+ });
597
+ filesWithStats.sort((a, b) => b.lastTs - a.lastTs);
598
+
599
+ // Exclude currently active sessions
600
+ const now = Date.now();
601
+ const inactiveSessions = filesWithStats.filter(s => {
602
+ const ageMs = now - s.lastTs.getTime();
603
+ return ageMs > ACTIVE_SESSION_THRESHOLD_MS;
604
+ });
605
+
606
+ if (inactiveSessions.length === 0) {
607
+ console.error('No inactive sessions found (only the current session exists)');
608
+ process.exit(1);
609
+ }
610
+
611
+ const session = inactiveSessions[0];
612
+ const toolCalls = extractToolCalls(session.msgs);
613
+
614
+ // If --show flag, find and display specific tool result
615
+ if (opts.show) {
616
+ const searchId = opts.show;
617
+ const match = toolCalls.find(t => t.id.includes(searchId));
618
+ if (!match) {
619
+ console.error(`No tool call found matching: ${searchId}`);
620
+ console.error(`\nAvailable IDs (showing last 10):`);
621
+ toolCalls.slice(-10).forEach(t => {
622
+ console.error(` ${t.id.substring(5, 17)} - ${t.name}`);
623
+ });
624
+ process.exit(1);
625
+ }
626
+
627
+ console.log(`=== TOOL RESULT: ${match.id} ===`);
628
+ console.log(`Tool: ${match.name}`);
629
+ console.log(`Time: ${match.timestamp ? new Date(match.timestamp).toISOString().substring(11, 19) : 'unknown'}`);
630
+ if (match.input) {
631
+ if (match.name === 'Bash') {
632
+ console.log(`Command: ${match.input.command?.substring(0, 100)}${match.input.command?.length > 100 ? '...' : ''}`);
633
+ } else if (match.name === 'Read') {
634
+ console.log(`File: ${match.input.file_path}`);
635
+ } else if (match.name === 'Edit' || match.name === 'Write') {
636
+ console.log(`File: ${match.input.file_path}`);
637
+ }
638
+ }
639
+ console.log(`\n--- RESULT ---`);
640
+ if (match.result) {
641
+ console.log(typeof match.result === 'string' ? match.result : JSON.stringify(match.result, null, 2));
642
+ } else {
643
+ console.log('(no result captured)');
644
+ }
645
+ return;
646
+ }
647
+
648
+ // List all tool calls
649
+ console.log(`=== TOOL CALLS (${toolCalls.length} total) ===`);
650
+ console.log(`Session: ${path.basename(session.filePath)}`);
651
+ console.log(``);
652
+ console.log(`# ID TIME TOOL SUMMARY`);
653
+ console.log(`───────────────────────────────────────────────────────────────────────────`);
654
+
655
+ toolCalls.forEach((t, i) => {
656
+ const shortId = t.id.substring(5, 17);
657
+ const ts = t.timestamp ? new Date(t.timestamp).toISOString().substring(11, 19) : '??:??:??';
658
+ let summary = '';
659
+
660
+ if (t.name === 'Bash') {
661
+ summary = t.input?.command?.substring(0, 40) || '';
662
+ } else if (t.name === 'Read') {
663
+ summary = t.input?.file_path?.split('/').pop() || '';
664
+ } else if (t.name === 'Edit' || t.name === 'Write') {
665
+ summary = t.input?.file_path?.split('/').pop() || '';
666
+ } else if (t.name === 'Grep') {
667
+ summary = t.input?.pattern || '';
668
+ } else if (t.name === 'Glob') {
669
+ summary = t.input?.pattern || '';
670
+ }
671
+
672
+ const num = String(i + 1).padStart(3);
673
+ const name = t.name.padEnd(8);
674
+ console.log(`${num} ${shortId} ${ts} ${name} ${summary}`);
675
+ });
676
+
677
+ console.log(``);
678
+ const exampleId = toolCalls.length > 0 ? toolCalls[0].id.substring(5, 17) : '<id>';
679
+ console.log(`Use: session-recall tools --show ${exampleId} to see specific result`);
680
+ }
681
+
682
+ function cmdCheckpoints(opts) {
683
+ const dir = cwdToProjectFolder();
684
+ if (!dir) {
685
+ console.error(`No project folder found for CWD: ${process.cwd()}`);
686
+ process.exit(1);
687
+ }
688
+
689
+ const jsonlFiles = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
690
+ if (jsonlFiles.length === 0) {
691
+ console.error('No sessions found');
692
+ process.exit(1);
693
+ }
694
+
695
+ // Collect all checkpoints from all sessions
696
+ const checkpoints = [];
697
+
698
+ for (const file of jsonlFiles) {
699
+ const filePath = path.join(dir, file);
700
+ const msgs = readJsonl(filePath);
701
+
702
+ for (const msg of msgs) {
703
+ // Look for Bash tool calls with session-recall --checkpoint
704
+ if (msg.type === 'assistant' && msg.message?.content && Array.isArray(msg.message.content)) {
705
+ for (const block of msg.message.content) {
706
+ if (block.type === 'tool_use' && block.name === 'Bash') {
707
+ const cmd = block.input?.command || '';
708
+ const match = cmd.match(/session-recall\s+--checkpoint\s+["']?([^"'\n]+)["']?/);
709
+ if (match) {
710
+ checkpoints.push({
711
+ name: match[1].trim(),
712
+ timestamp: msg.timestamp,
713
+ toolId: block.id,
714
+ session: file
715
+ });
716
+ }
717
+ }
718
+ }
719
+ }
720
+ }
721
+ }
722
+
723
+ if (checkpoints.length === 0) {
724
+ console.log('No checkpoints found.');
725
+ console.log('Create one with: session-recall --checkpoint "description"');
726
+ return;
727
+ }
728
+
729
+ // Sort by timestamp
730
+ checkpoints.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
731
+
732
+ console.log(`=== CHECKPOINTS (${checkpoints.length} total) ===`);
733
+ console.log(``);
734
+ console.log(`# TIME ID NAME`);
735
+ console.log(`────────────────────────────────────────────────────────────────────`);
736
+
737
+ checkpoints.forEach((cp, i) => {
738
+ const ts = cp.timestamp ? new Date(cp.timestamp).toISOString().substring(11, 19) : '??:??:??';
739
+ const shortId = cp.toolId ? cp.toolId.substring(5, 17) : '????????????';
740
+ console.log(`${String(i + 1).padStart(2)} ${ts} ${shortId} ${cp.name}`);
741
+ });
742
+
743
+ console.log(``);
744
+ console.log(`Use: session-recall last 1 --after "checkpoint name" to recall from checkpoint`);
745
+ }
746
+
747
+ function calcSessionCosts(messages) {
748
+ // Calculate logistics (mild) output
749
+ const logisticsOutput = [];
750
+ for (const msg of messages) {
751
+ if (NOISE_TYPES.includes(msg.type)) continue;
752
+ if (msg.type === 'user') {
753
+ if (isToolResult(msg)) {
754
+ const resultText = getToolResultText(msg);
755
+ if (resultText) {
756
+ logisticsOutput.push({ content: resultText.substring(0, 200) });
757
+ }
758
+ continue;
759
+ }
760
+ const text = getUserText(msg);
761
+ if (text) logisticsOutput.push({ content: text.substring(0, 500) });
762
+ } else if (msg.type === 'assistant') {
763
+ const text = getAssistantText(msg);
764
+ const toolSummary = collapseToolCall(msg);
765
+ if (text) logisticsOutput.push({ content: text.substring(0, 200) });
766
+ else if (toolSummary) logisticsOutput.push({ content: toolSummary });
767
+ }
768
+ }
769
+ const logisticsChars = logisticsOutput.reduce((sum, m) => sum + (m.content?.length || 0), 0);
770
+
771
+ // Calculate gotime (collapse) output
772
+ const gotimeOutput = [];
773
+ for (const msg of messages) {
774
+ if (NOISE_TYPES.includes(msg.type)) continue;
775
+ if (msg.type === 'user') {
776
+ if (isToolResult(msg)) continue;
777
+ const text = getUserText(msg);
778
+ if (text) gotimeOutput.push({ content: text.substring(0, 500) });
779
+ } else if (msg.type === 'assistant') {
780
+ const text = getAssistantText(msg);
781
+ const toolSummary = collapseToolCall(msg);
782
+ if (text) gotimeOutput.push({ content: text.substring(0, 500) });
783
+ else if (toolSummary) gotimeOutput.push({ content: toolSummary });
784
+ }
785
+ }
786
+ const gotimeChars = gotimeOutput.reduce((sum, m) => sum + (m.content?.length || 0), 0);
787
+
788
+ // Calculate hot context
789
+ const discourse = messages.filter(isDiscourse);
790
+ const hot30 = discourse.slice(-30);
791
+ const hot50 = discourse.slice(-50);
792
+ const hot30Chars = hot30.reduce((sum, m) => {
793
+ const text = m.type === 'user' ? getUserText(m) : getAssistantText(m);
794
+ return sum + (text?.length || 0);
795
+ }, 0);
796
+ const hot50Chars = hot50.reduce((sum, m) => {
797
+ const text = m.type === 'user' ? getUserText(m) : getAssistantText(m);
798
+ return sum + (text?.length || 0);
799
+ }, 0);
800
+
801
+ return {
802
+ raw: messages.length,
803
+ hot30: { count: hot30.length, tokens: estimateTokens({ length: hot30Chars }) },
804
+ hot50: { count: hot50.length, tokens: estimateTokens({ length: hot50Chars }) },
805
+ gotime: { count: gotimeOutput.length, tokens: estimateTokens({ length: gotimeChars }) },
806
+ logistics: { count: logisticsOutput.length, tokens: estimateTokens({ length: logisticsChars }) }
807
+ };
808
+ }
809
+
810
+ function cmdCost(inputPath) {
811
+ // Check if input is a directory or file
812
+ const stat = fs.statSync(inputPath);
813
+ const isDir = stat.isDirectory();
814
+ const dir = isDir ? inputPath : path.dirname(inputPath);
815
+ const jsonlFiles = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
816
+
817
+ if (jsonlFiles.length === 0) {
818
+ console.error('No .jsonl files found in ' + dir);
819
+ process.exit(1);
820
+ }
821
+
822
+ // If directory, use first file as "current" for single-session stats
823
+ const jsonlPath = isDir ? path.join(dir, jsonlFiles[0]) : inputPath;
824
+ const messages = readJsonl(jsonlPath);
825
+ const costs = calcSessionCosts(messages);
826
+
827
+ // Calculate project totals if multiple sessions or if scanning directory
828
+ let projectCosts = null;
829
+ if (jsonlFiles.length > 1 || isDir) {
830
+ const allMessages = [];
831
+ for (const file of jsonlFiles) {
832
+ const filePath = path.join(dir, file);
833
+ const msgs = readJsonl(filePath);
834
+ allMessages.push(...msgs);
835
+ }
836
+ projectCosts = calcSessionCosts(allMessages);
837
+ projectCosts.sessionCount = jsonlFiles.length;
838
+ }
839
+
840
+ console.log(`=== IMPORT COST COMPARISON ===`);
841
+ console.log(`Source: ${isDir ? dir : path.basename(jsonlPath)}`);
842
+
843
+ if (!isDir) {
844
+ console.log(`Raw messages: ${costs.raw}`);
845
+ console.log(``);
846
+ console.log(`THIS SESSION:`);
847
+ console.log(`OPTION MESSAGES TOKENS USE CASE`);
848
+ console.log(`─────────────────────────────────────────────────────────`);
849
+ console.log(`hot --last 30 ${String(costs.hot30.count).padStart(4)} ~${String(costs.hot30.tokens.toLocaleString()).padStart(6)} Quick continue`);
850
+ console.log(`hot --last 50 ${String(costs.hot50.count).padStart(4)} ~${String(costs.hot50.tokens.toLocaleString()).padStart(6)} More context`);
851
+ console.log(`rinse --gotime ${String(costs.gotime.count).padStart(4)} ~${String(costs.gotime.tokens.toLocaleString()).padStart(6)} Full session, action mode`);
852
+ console.log(`rinse --logistics ${String(costs.logistics.count).padStart(4)} ~${String(costs.logistics.tokens.toLocaleString()).padStart(6)} Full session, see what was done`);
853
+ }
854
+
855
+ if (projectCosts) {
856
+ // Get individual session stats sorted by date
857
+ const sessionStats = [];
858
+ for (const file of jsonlFiles) {
859
+ const filePath = path.join(dir, file);
860
+ const msgs = readJsonl(filePath);
861
+ const timestamps = msgs.filter(m => m.timestamp).map(m => new Date(m.timestamp));
862
+ const firstTs = timestamps.length > 0 ? new Date(Math.min(...timestamps)) : null;
863
+ const lastTs = timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null;
864
+ const sessCosts = calcSessionCosts(msgs);
865
+ sessionStats.push({
866
+ file,
867
+ filePath,
868
+ firstTs,
869
+ lastTs,
870
+ raw: msgs.length,
871
+ gotime: sessCosts.gotime.tokens,
872
+ logistics: sessCosts.logistics.tokens
873
+ });
874
+ }
875
+ // Sort by first timestamp
876
+ sessionStats.sort((a, b) => (a.firstTs || 0) - (b.firstTs || 0));
877
+
878
+ console.log(``);
879
+ console.log(`ALL SESSIONS IN PROJECT (${projectCosts.sessionCount}):`);
880
+ console.log(`# DATE TIME RAW GOTIME LOGISTICS FILE`);
881
+ console.log(`──────────────────────────────────────────────────────────────────────────────`);
882
+
883
+ // Split into significant (>500 tokens) and tiny sessions
884
+ const TINY_THRESHOLD = 500;
885
+ const significant = sessionStats.filter(s => s.gotime >= TINY_THRESHOLD);
886
+ const tiny = sessionStats.filter(s => s.gotime < TINY_THRESHOLD);
887
+
888
+ significant.forEach((s, i) => {
889
+ const date = s.firstTs ? s.firstTs.toISOString().substring(0, 10) : 'unknown';
890
+ const time = s.firstTs ? s.firstTs.toISOString().substring(11, 16) : '??:??';
891
+ const isCurrent = s.filePath === path.resolve(jsonlPath) ? ' ←' : '';
892
+ const fileName = s.file.replace('.jsonl', '');
893
+ console.log(`${String(i + 1).padStart(2)} ${date} ${time} ${String(s.raw).padStart(4)} ~${String(s.gotime.toLocaleString()).padStart(6)} ~${String(s.logistics.toLocaleString()).padStart(6)} ${fileName}${isCurrent}`);
894
+ });
895
+
896
+ if (tiny.length > 0) {
897
+ const tinyRaw = tiny.reduce((sum, s) => sum + s.raw, 0);
898
+ const tinyGotime = tiny.reduce((sum, s) => sum + s.gotime, 0);
899
+ const tinyLogistics = tiny.reduce((sum, s) => sum + s.logistics, 0);
900
+ console.log(` ... ${tiny.length} small sessions ${String(tinyRaw).padStart(4)} ~${String(tinyGotime.toLocaleString()).padStart(6)} ~${String(tinyLogistics.toLocaleString()).padStart(6)}`);
901
+ }
902
+
903
+ console.log(`──────────────────────────────────────────────────────────────────────────────`);
904
+ console.log(` TOTAL ${String(projectCosts.raw).padStart(4)} ~${String(projectCosts.gotime.tokens.toLocaleString()).padStart(6)} ~${String(projectCosts.logistics.tokens.toLocaleString()).padStart(6)}`);
905
+ }
906
+
907
+ console.log(``);
908
+ console.log(`Pick your path, then run the command.`);
909
+ }
910
+
911
+ function cwdToProjectFolder() {
912
+ // Map CWD to Claude project folder
913
+ // /home/hung/.skills/scripture-keeper -> -home-hung--skills-scripture-keeper
914
+ // Both / and . are replaced with -
915
+ const cwd = process.cwd();
916
+ const projectName = cwd.replace(/[\/\.]/g, '-');
917
+ const projectsDir = path.join(process.env.HOME, '.claude', 'projects');
918
+ const projectPath = path.join(projectsDir, projectName);
919
+
920
+ if (fs.existsSync(projectPath)) {
921
+ return projectPath;
922
+ }
923
+ return null;
924
+ }
925
+
926
+ function cmdLast(arg1, arg2, opts) {
927
+ // Usage: last [N] [folder]
928
+ // arg1 could be: undefined, a number string, or a folder path
929
+ // arg2 could be: undefined, or a folder path (if arg1 was a number)
930
+ const projectsDir = path.join(process.env.HOME, '.claude', 'projects');
931
+
932
+ let n = 1;
933
+ let dir = null;
934
+ let filterAfterTs = null;
935
+
936
+ // Parse arguments
937
+ if (arg1) {
938
+ if (!isNaN(parseInt(arg1))) {
939
+ // arg1 is a number
940
+ n = parseInt(arg1);
941
+ // Check if arg2 is a folder
942
+ if (arg2 && fs.existsSync(arg2)) {
943
+ const stat = fs.statSync(arg2);
944
+ dir = stat.isDirectory() ? arg2 : path.dirname(arg2);
945
+ }
946
+ } else if (fs.existsSync(arg1)) {
947
+ // arg1 is a folder
948
+ const stat = fs.statSync(arg1);
949
+ dir = stat.isDirectory() ? arg1 : path.dirname(arg1);
950
+ }
951
+ }
952
+
953
+ // Default behavior: use CWD project unless --all specified
954
+ if (!dir && !opts.all) {
955
+ dir = cwdToProjectFolder();
956
+ if (!dir) {
957
+ console.error(`No project folder found for CWD: ${process.cwd()}`);
958
+ console.error(`Use --all to scan all projects.`);
959
+ process.exit(1);
960
+ }
961
+ }
962
+
963
+ // If --after is specified, find the checkpoint in the current project
964
+ if (opts.after) {
965
+ if (!dir) {
966
+ console.error(`--after requires CWD project context (cannot use --all)`);
967
+ process.exit(1);
968
+ }
969
+
970
+ const jsonlFiles = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
971
+ let checkpoint = null;
972
+
973
+ for (const file of jsonlFiles) {
974
+ const filePath = path.join(dir, file);
975
+ const msgs = readJsonl(filePath);
976
+
977
+ for (const msg of msgs) {
978
+ // Look for Bash tool calls with session-recall --checkpoint
979
+ if (msg.type === 'assistant' && msg.message?.content && Array.isArray(msg.message.content)) {
980
+ for (const block of msg.message.content) {
981
+ if (block.type === 'tool_use' && block.name === 'Bash') {
982
+ const cmd = block.input?.command || '';
983
+ const match = cmd.match(/session-recall\s+--checkpoint\s+["']?([^"'\n]+)["']?/);
984
+ if (match && match[1].trim() === opts.after) {
985
+ checkpoint = {
986
+ name: match[1].trim(),
987
+ timestamp: msg.timestamp,
988
+ session: file
989
+ };
990
+ break;
991
+ }
992
+ }
993
+ }
994
+ }
995
+ if (checkpoint) break;
996
+ }
997
+ if (checkpoint) break;
998
+ }
999
+
1000
+ if (!checkpoint) {
1001
+ console.error(`Checkpoint not found: "${opts.after}"`);
1002
+ console.error(`Available checkpoints:`);
1003
+
1004
+ // List all available checkpoints
1005
+ const checkpoints = [];
1006
+ for (const file of jsonlFiles) {
1007
+ const filePath = path.join(dir, file);
1008
+ const msgs = readJsonl(filePath);
1009
+
1010
+ for (const msg of msgs) {
1011
+ if (msg.type === 'assistant' && msg.message?.content && Array.isArray(msg.message.content)) {
1012
+ for (const block of msg.message.content) {
1013
+ if (block.type === 'tool_use' && block.name === 'Bash') {
1014
+ const cmd = block.input?.command || '';
1015
+ const match = cmd.match(/session-recall\s+--checkpoint\s+["']?([^"'\n]+)["']?/);
1016
+ if (match) {
1017
+ checkpoints.push({
1018
+ name: match[1].trim(),
1019
+ timestamp: msg.timestamp
1020
+ });
1021
+ }
1022
+ }
1023
+ }
1024
+ }
1025
+ }
1026
+ }
1027
+
1028
+ if (checkpoints.length === 0) {
1029
+ console.error(' (none)');
1030
+ } else {
1031
+ checkpoints.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
1032
+ checkpoints.forEach(cp => {
1033
+ const ts = cp.timestamp ? new Date(cp.timestamp).toISOString().substring(11, 19) : '??:??:??';
1034
+ console.error(` "${cp.name}" (${ts})`);
1035
+ });
1036
+ }
1037
+ process.exit(1);
1038
+ }
1039
+
1040
+ filterAfterTs = checkpoint.timestamp ? new Date(checkpoint.timestamp).getTime() : null;
1041
+ }
1042
+
1043
+ // Collect all sessions
1044
+ let allSessions = [];
1045
+
1046
+ if (dir) {
1047
+ // Single project folder
1048
+ const jsonlFiles = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
1049
+ for (const file of jsonlFiles) {
1050
+ const filePath = path.join(dir, file);
1051
+ const msgs = readJsonl(filePath);
1052
+ const timestamps = msgs.filter(m => m.timestamp).map(m => new Date(m.timestamp));
1053
+ const lastTs = timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null;
1054
+ allSessions.push({ filePath, msgs, lastTs, project: path.basename(dir) });
1055
+ }
1056
+ } else {
1057
+ // Scan all projects
1058
+ if (!fs.existsSync(projectsDir)) {
1059
+ console.error('No projects found at ' + projectsDir);
1060
+ process.exit(1);
1061
+ }
1062
+ const projects = fs.readdirSync(projectsDir).filter(p => {
1063
+ const pPath = path.join(projectsDir, p);
1064
+ return fs.statSync(pPath).isDirectory();
1065
+ });
1066
+ for (const proj of projects) {
1067
+ const projPath = path.join(projectsDir, proj);
1068
+ const jsonlFiles = fs.readdirSync(projPath).filter(f => f.endsWith('.jsonl'));
1069
+ for (const file of jsonlFiles) {
1070
+ const filePath = path.join(projPath, file);
1071
+ try {
1072
+ const msgs = readJsonl(filePath);
1073
+ const timestamps = msgs.filter(m => m.timestamp).map(m => new Date(m.timestamp));
1074
+ const lastTs = timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null;
1075
+ allSessions.push({ filePath, msgs, lastTs, project: proj });
1076
+ } catch (e) {
1077
+ // Skip unreadable files
1078
+ }
1079
+ }
1080
+ }
1081
+ }
1082
+
1083
+ // Sort by last timestamp descending
1084
+ allSessions.sort((a, b) => (b.lastTs || 0) - (a.lastTs || 0));
1085
+
1086
+ // Exclude currently active sessions (activity within threshold)
1087
+ const now = Date.now();
1088
+ const inactiveSessions = allSessions.filter(s => {
1089
+ if (!s.lastTs) return true; // Include sessions with no timestamp
1090
+ const ageMs = now - s.lastTs.getTime();
1091
+ return ageMs > ACTIVE_SESSION_THRESHOLD_MS;
1092
+ });
1093
+
1094
+ // Take last N from inactive sessions
1095
+ const selected = inactiveSessions.slice(0, n);
1096
+
1097
+ if (selected.length === 0) {
1098
+ console.error('No sessions found');
1099
+ process.exit(1);
1100
+ }
1101
+
1102
+ // Count excluded active sessions for reporting
1103
+ const excludedActive = allSessions.length - inactiveSessions.length;
1104
+
1105
+ if (opts.dryRun) {
1106
+ console.log(`--- DRY RUN: last ${n} ---`);
1107
+ if (excludedActive > 0) {
1108
+ console.log(`(Excluded ${excludedActive} active session${excludedActive > 1 ? 's' : ''} with activity in last ${ACTIVE_SESSION_THRESHOLD_MS / 1000}s)`);
1109
+ }
1110
+ console.log(`\nMOST RECENT SESSIONS:`);
1111
+ console.log(`# LAST ACTIVE RAW PROJECT`);
1112
+ console.log(`────────────────────────────────────────────────────────────────────`);
1113
+ selected.forEach((s, i) => {
1114
+ const ts = s.lastTs ? s.lastTs.toISOString().replace('T', ' ').substring(0, 16) : 'unknown';
1115
+ console.log(`${String(i + 1).padStart(2)} ${ts} ${String(s.msgs.length).padStart(4)} ${s.project}`);
1116
+ });
1117
+
1118
+ // Calculate combined output
1119
+ let allMsgs = selected.flatMap(s => s.msgs);
1120
+ // Apply checkpoint filter if specified
1121
+ if (filterAfterTs) {
1122
+ allMsgs = allMsgs.filter(msg => {
1123
+ const msgTs = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
1124
+ return msgTs > filterAfterTs;
1125
+ });
1126
+ }
1127
+ const output = [];
1128
+ for (const msg of allMsgs) {
1129
+ if (NOISE_TYPES.includes(msg.type)) continue;
1130
+ if (msg.type === 'user') {
1131
+ if (isToolResult(msg)) continue;
1132
+ const text = getUserText(msg);
1133
+ if (text) output.push({ content: text.substring(0, 500) });
1134
+ } else if (msg.type === 'assistant') {
1135
+ const text = getAssistantText(msg);
1136
+ const toolSummary = collapseToolCall(msg);
1137
+ if (text) output.push({ content: text.substring(0, 500) });
1138
+ else if (toolSummary) output.push({ content: toolSummary });
1139
+ }
1140
+ }
1141
+ const totalChars = output.reduce((sum, m) => sum + (m.content?.length || 0), 0);
1142
+ console.log(`────────────────────────────────────────────────────────────────────`);
1143
+ console.log(`\nOUTPUT: ${output.length} messages, ~${estimateTokens({length: totalChars}).toLocaleString()} tokens`);
1144
+ return;
1145
+ }
1146
+
1147
+ // Output combined rinse (gotime mode)
1148
+ let allMsgs = selected.flatMap(s => s.msgs);
1149
+ // Sort by timestamp
1150
+ allMsgs.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
1151
+
1152
+ // Filter by checkpoint timestamp if --after was specified
1153
+ if (filterAfterTs) {
1154
+ allMsgs = allMsgs.filter(msg => {
1155
+ const msgTs = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
1156
+ return msgTs > filterAfterTs;
1157
+ });
1158
+ }
1159
+
1160
+ // Build tool result metadata (size, duration)
1161
+ const resultMeta = buildToolResultMeta(allMsgs);
1162
+
1163
+ for (const msg of allMsgs) {
1164
+ if (NOISE_TYPES.includes(msg.type)) continue;
1165
+ if (msg.type === 'user') {
1166
+ if (isToolResult(msg)) continue;
1167
+ const text = getUserText(msg);
1168
+ if (text) {
1169
+ const ts = msg.timestamp ? new Date(msg.timestamp).toISOString().substring(11, 19) : '';
1170
+ console.log(`[${ts}] USER: ${text.substring(0, 500)}`);
1171
+ }
1172
+ } else if (msg.type === 'assistant') {
1173
+ const text = getAssistantText(msg);
1174
+ const toolSummary = collapseToolCall(msg, resultMeta);
1175
+ const ts = msg.timestamp ? new Date(msg.timestamp).toISOString().substring(11, 19) : '';
1176
+ if (text) {
1177
+ console.log(`[${ts}] ASSISTANT: ${text.substring(0, 500)}`);
1178
+ } else if (toolSummary) {
1179
+ console.log(`[${ts}] ASSISTANT: ${toolSummary}`);
1180
+ }
1181
+ }
1182
+ }
1183
+
1184
+ // Print instructions for Claude
1185
+ console.log(``);
1186
+ console.log(`---`);
1187
+ console.log(`To get a specific tool result: session-recall tools --show <id>`);
1188
+ console.log(`Session file: ${selected[0]?.filePath || 'unknown'}`);
1189
+ }
1190
+
1191
+ function showHelp() {
1192
+ console.log(`
1193
+ session-recall - Extract discourse from Claude conversation JSONL files
1194
+
1195
+ USAGE:
1196
+ session-recall <command> <jsonl-path> [options]
1197
+
1198
+ COMMANDS:
1199
+ parse <jsonl> Extract clean discourse, filter noise
1200
+ -f, --format <type> Output: jsonl (default) or text
1201
+ -s, --stats Show stats after output
1202
+ -d, --dry-run Show token estimate without output
1203
+
1204
+ hot <jsonl> Extract last N meaningful exchanges
1205
+ -l, --last <n> Number of exchanges (default: 30)
1206
+ -d, --dry-run Show token estimate without output
1207
+
1208
+ rinse <jsonl|folder> Compress for import, filter noise
1209
+ --logistics Keep tool results (truncated) - see what was done
1210
+ --gotime Drop tool results - just discourse & decisions
1211
+ --yolo Override 50k token safety limit for folders
1212
+ -f, --format <type> Output: jsonl (default) or text
1213
+ -d, --dry-run Show token estimate without output
1214
+
1215
+ state <jsonl> Show what exists, counts, markers
1216
+
1217
+ cost <jsonl|folder> Compare token costs for logistics vs gotime
1218
+
1219
+ last [N] [folder] Get last N sessions (default: 1, CWD project only)
1220
+ -a, --all Scan all projects (not just CWD)
1221
+ -d, --dry-run Show what would be pulled without output
1222
+
1223
+ tools List tool calls in most recent session (CWD project)
1224
+ --show <id> Show specific tool result by ID (partial match)
1225
+
1226
+ checkpoints List all checkpoints in current project
1227
+
1228
+ --checkpoint "name" Create a checkpoint marker (use as standalone flag)
1229
+
1230
+ last [N] --after "name" Recall from after a checkpoint
1231
+
1232
+ EXAMPLES:
1233
+ session-recall last 1 -d # Check what's available
1234
+ session-recall last 1 -f text # Pull last session
1235
+ session-recall tools # List tool calls
1236
+ session-recall tools --show 014opBVN # Get specific tool result
1237
+
1238
+ # Checkpoints - mark progress, recall from any point
1239
+ session-recall --checkpoint "finished research" # Create checkpoint
1240
+ session-recall checkpoints # List all checkpoints
1241
+ session-recall last 1 --after "finished research" # Recall from checkpoint
1242
+ `);
1243
+ }
1244
+
1245
+ // ========== ARGUMENT PARSING ==========
1246
+
1247
+ function parseArgs(args) {
1248
+ const opts = {};
1249
+ const positional = [];
1250
+
1251
+ for (let i = 0; i < args.length; i++) {
1252
+ const arg = args[i];
1253
+ if (arg === '-f' || arg === '--format') {
1254
+ opts.format = args[++i];
1255
+ } else if (arg === '-s' || arg === '--stats') {
1256
+ opts.stats = true;
1257
+ } else if (arg === '-l' || arg === '--last') {
1258
+ opts.last = args[++i];
1259
+ } else if (arg === '-c' || arg === '--collapse-tools' || arg === '--gotime') {
1260
+ opts.collapseTools = true;
1261
+ } else if (arg === '-m' || arg === '--mild' || arg === '--logistics') {
1262
+ opts.mild = true;
1263
+ } else if (arg === '--yolo') {
1264
+ opts.yolo = true;
1265
+ } else if (arg === '-d' || arg === '--dry-run') {
1266
+ opts.dryRun = true;
1267
+ } else if (arg === '-a' || arg === '--all') {
1268
+ opts.all = true;
1269
+ } else if (arg === '--show') {
1270
+ opts.show = args[++i];
1271
+ } else if (arg === '--checkpoint') {
1272
+ opts.checkpoint = args[++i];
1273
+ } else if (arg === '--after') {
1274
+ opts.after = args[++i];
1275
+ } else if (arg === '-h' || arg === '--help') {
1276
+ opts.help = true;
1277
+ } else if (!arg.startsWith('-')) {
1278
+ positional.push(arg);
1279
+ }
1280
+ }
1281
+
1282
+ return { opts, positional };
1283
+ }
1284
+
1285
+ // ========== MAIN ==========
1286
+
1287
+ const args = process.argv.slice(2);
1288
+ const { opts, positional } = parseArgs(args);
1289
+
1290
+ // Handle --checkpoint flag first (no-op marker that gets logged)
1291
+ if (opts.checkpoint) {
1292
+ console.log(`✓ Checkpoint: ${opts.checkpoint}`);
1293
+ console.log(` Time: ${new Date().toISOString()}`);
1294
+ console.log(` Use 'session-recall checkpoints' to list all checkpoints`);
1295
+ process.exit(0);
1296
+ }
1297
+
1298
+ if (opts.help || positional.length === 0) {
1299
+ showHelp();
1300
+ process.exit(0);
1301
+ }
1302
+
1303
+ const command = positional[0];
1304
+ const jsonlPath = positional[1];
1305
+
1306
+ // 'last', 'tools', and 'checkpoints' commands don't require a path
1307
+ if (!jsonlPath && command !== 'help' && command !== 'last' && command !== 'tools' && command !== 'checkpoints') {
1308
+ console.error('Error: JSONL path required');
1309
+ showHelp();
1310
+ process.exit(1);
1311
+ }
1312
+
1313
+ // For 'last' command, the second arg might be a number, not a path
1314
+ if (command !== 'last' && jsonlPath && !fs.existsSync(jsonlPath)) {
1315
+ console.error(`Error: File not found: ${jsonlPath}`);
1316
+ process.exit(1);
1317
+ }
1318
+
1319
+ switch (command) {
1320
+ case 'parse':
1321
+ cmdParse(jsonlPath, opts);
1322
+ break;
1323
+ case 'hot':
1324
+ cmdHot(jsonlPath, opts);
1325
+ break;
1326
+ case 'rinse':
1327
+ cmdRinse(jsonlPath, opts);
1328
+ break;
1329
+ case 'state':
1330
+ cmdState(jsonlPath);
1331
+ break;
1332
+ case 'cost':
1333
+ cmdCost(jsonlPath);
1334
+ break;
1335
+ case 'last':
1336
+ cmdLast(jsonlPath, positional[2], opts);
1337
+ break;
1338
+ case 'tools':
1339
+ cmdTools(opts);
1340
+ break;
1341
+ case 'checkpoints':
1342
+ cmdCheckpoints(opts);
1343
+ break;
1344
+ case 'help':
1345
+ showHelp();
1346
+ break;
1347
+ default:
1348
+ console.error(`Unknown command: ${command}`);
1349
+ showHelp();
1350
+ process.exit(1);
1351
+ }