claude-token-saver 2.0.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.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Terminal table formatter — zero dependencies.
3
+ */
4
+ import { ISSUE_MESSAGES } from '../advice.js';
5
+
6
+ function pad(str, len, align = 'left') {
7
+ const s = String(str);
8
+ if (align === 'right') return s.padStart(len);
9
+ return s.padEnd(len);
10
+ }
11
+
12
+ function pct(n) {
13
+ return (n * 100).toFixed(1) + '%';
14
+ }
15
+
16
+ function millions(n) {
17
+ return (n / 1_000_000).toFixed(2) + 'M';
18
+ }
19
+
20
+ function thousands(n) {
21
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
22
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
23
+ return String(n);
24
+ }
25
+
26
+ function hr(len) {
27
+ return '─'.repeat(len);
28
+ }
29
+
30
+ function tableRow(cols, widths, aligns) {
31
+ return (
32
+ '│ ' +
33
+ cols.map((c, i) => pad(c, widths[i], aligns[i])).join(' │ ') +
34
+ ' │'
35
+ );
36
+ }
37
+
38
+ function tableSep(widths) {
39
+ return '├─' + widths.map((w) => hr(w)).join('─┼─') + '─┤';
40
+ }
41
+
42
+ function tableTop(widths) {
43
+ return '┌─' + widths.map((w) => hr(w)).join('─┬─') + '─┐';
44
+ }
45
+
46
+ function tableBot(widths) {
47
+ return '└─' + widths.map((w) => hr(w)).join('─┴─') + '─┘';
48
+ }
49
+
50
+ /**
51
+ * Format the full report for terminal output.
52
+ */
53
+ function shortSessionId(id) {
54
+ if (!id) return '(unknown)';
55
+ return id.length > 8 ? id.slice(0, 8) : id;
56
+ }
57
+
58
+ function formatContextSize(n) {
59
+ if (!n) return '0';
60
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
61
+ if (n >= 1_000) return (n / 1_000).toFixed(0) + 'k';
62
+ return String(n);
63
+ }
64
+
65
+ function renderSpikeSection(spikes, contextWindow) {
66
+ const lines = [];
67
+ lines.push(' ⚠ 토큰 급증 감지');
68
+ lines.push(` ${'─'.repeat(50)}`);
69
+ if (contextWindow && contextWindow.size === '1M') {
70
+ lines.push(
71
+ ` 컨텍스트 모드 추정: 1M (최근 단일 요청 최대 ${formatContextSize(contextWindow.maxContext)} 토큰)`,
72
+ );
73
+ lines.push('');
74
+ }
75
+ for (const spike of spikes) {
76
+ const m = spike.metrics;
77
+ const ratioLabel = spike.ratio ? `${spike.ratio.toFixed(1)}× p95` : 'single-request > 250k';
78
+ lines.push(
79
+ ` • ${shortSessionId(m.sessionId)} [${m.projectDir || 'unknown'}] ` +
80
+ `총 입력 ${formatContextSize(m.totalInput)} (${ratioLabel}, 요청 ${m.requestCount}회)`,
81
+ );
82
+ if (m.maxContextPerRequest > 0) {
83
+ lines.push(
84
+ ` 단일 요청 최대 컨텍스트: ${formatContextSize(m.maxContextPerRequest)} 토큰`,
85
+ );
86
+ }
87
+ for (const issue of spike.issues) {
88
+ const info = ISSUE_MESSAGES[issue.code];
89
+ if (!info) continue;
90
+ lines.push(` · ${info.title}`);
91
+ }
92
+ lines.push('');
93
+ }
94
+
95
+ // De-dupe action blocks — if multiple spikes share the same issue, show
96
+ // the remediation once per run, not once per spike.
97
+ const seen = new Set();
98
+ const uniqueIssues = [];
99
+ for (const spike of spikes) {
100
+ for (const issue of spike.issues) {
101
+ if (seen.has(issue.code)) continue;
102
+ seen.add(issue.code);
103
+ uniqueIssues.push(issue);
104
+ }
105
+ }
106
+ if (uniqueIssues.length > 0) {
107
+ lines.push(' 권장 액션');
108
+ lines.push(` ${'─'.repeat(50)}`);
109
+ for (const issue of uniqueIssues) {
110
+ const info = ISSUE_MESSAGES[issue.code];
111
+ if (!info) continue;
112
+ lines.push(` ▸ ${info.title}`);
113
+ lines.push(` ${info.explain}`);
114
+ for (const action of info.actions()) {
115
+ lines.push(` - ${action.label}`);
116
+ for (const cmd of action.commands) {
117
+ lines.push(` ${cmd}`);
118
+ }
119
+ }
120
+ lines.push('');
121
+ }
122
+ }
123
+ return lines;
124
+ }
125
+
126
+ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow }) {
127
+ const lines = [];
128
+
129
+ // Header
130
+ lines.push('');
131
+ lines.push(` Claude 토큰 아껴쓰기 — Last ${options.days} days`);
132
+ lines.push(` (claude-token-saver v${options.version || ''})`.trimEnd());
133
+ lines.push(` ${'═'.repeat(50)}`);
134
+ lines.push('');
135
+
136
+ // Spike section goes FIRST — it's what the user acts on.
137
+ if (spikeReport && spikeReport.spikes.length > 0) {
138
+ lines.push(...renderSpikeSection(spikeReport.spikes, contextWindow));
139
+ }
140
+
141
+ // Context window chip for the normal case too
142
+ if (contextWindow && contextWindow.size !== 'unknown') {
143
+ const note =
144
+ contextWindow.size === '1M'
145
+ ? '⚠ 1M 컨텍스트 사용 중 (Opus 4.7+ Max 기본값). 필요 없으면 CLAUDE_CODE_DISABLE_1M_CONTEXT=1'
146
+ : '✓ 200k 컨텍스트 (표준)';
147
+ lines.push(` Context window: ${contextWindow.size} ${note}`);
148
+ lines.push(` (최근 단일 요청 최대 ${formatContextSize(contextWindow.maxContext)} 토큰)`);
149
+ lines.push('');
150
+ }
151
+
152
+ // Overall summary
153
+ lines.push(' Summary');
154
+ lines.push(` Sessions: ${sum.sessions} | API calls: ${sum.apiCalls.toLocaleString()} | Model: ${cost.tier}`);
155
+ lines.push(` Cache hit rate: ${pct(sum.hitRate)} | Total input: ${millions(sum.totalInput)} tokens`);
156
+ lines.push('');
157
+
158
+ // TTL breakdown
159
+ lines.push(' TTL Breakdown');
160
+ const ttlW = [18, 16, 16];
161
+ const ttlA = ['left', 'right', 'right'];
162
+ lines.push(' ' + tableTop(ttlW));
163
+ lines.push(' ' + tableRow(['', '5m Ephemeral', '1h Extended'], ttlW, ttlA));
164
+ lines.push(' ' + tableSep(ttlW));
165
+ lines.push(
166
+ ' ' +
167
+ tableRow(
168
+ [
169
+ 'Cache writes',
170
+ `${thousands(ttl.ephemeral5m)} (${pct(ttl.pct5m)})`,
171
+ `${thousands(ttl.ephemeral1h)} (${pct(ttl.pct1h)})`,
172
+ ],
173
+ ttlW,
174
+ ttlA,
175
+ ),
176
+ );
177
+ lines.push(' ' + tableBot(ttlW));
178
+ lines.push('');
179
+
180
+ // Cost impact
181
+ lines.push(' Cost Impact (estimated)');
182
+ const costW = [24, 12];
183
+ const costA = ['left', 'right'];
184
+ lines.push(' ' + tableTop(costW));
185
+ lines.push(' ' + tableRow(['Actual cost', `$${cost.actual}`], costW, costA));
186
+ lines.push(' ' + tableRow(['Without cache', `$${cost.noCacheCost}`], costW, costA));
187
+ lines.push(' ' + tableSep(costW));
188
+ lines.push(' ' + tableRow(['Savings', `$${cost.savings} (${pct(cost.savingsRate)})`], costW, costA));
189
+ lines.push(' ' + tableRow(['Extra cost if 5m-only', `+$${cost.extraCostIf5m}`], costW, costA));
190
+ lines.push(' ' + tableBot(costW));
191
+ lines.push('');
192
+
193
+ // Daily trend
194
+ lines.push(' Daily Trend');
195
+ const tw = [10, 8, 7, 10, 10, 5];
196
+ const ta = ['left', 'right', 'right', 'right', 'right', 'right'];
197
+ lines.push(' ' + tableTop(tw));
198
+ lines.push(' ' + tableRow(['Date', 'HitRate', 'Calls', 'Read', 'Write', '5m%'], tw, ta));
199
+ lines.push(' ' + tableSep(tw));
200
+
201
+ const recentTrend = trend.slice(-14); // last 14 days
202
+ for (const d of recentTrend) {
203
+ const ccTotal = d.ephemeral5m + d.ephemeral1h;
204
+ const pct5m = ccTotal > 0 ? pct(d.ephemeral5m / ccTotal) : '-';
205
+ lines.push(
206
+ ' ' +
207
+ tableRow(
208
+ [d.date, pct(d.hitRate), String(d.apiCalls), millions(d.cacheRead), millions(d.cacheCreation), pct5m],
209
+ tw,
210
+ ta,
211
+ ),
212
+ );
213
+ }
214
+ lines.push(' ' + tableBot(tw));
215
+
216
+ if (trend.length > 14) {
217
+ lines.push(` ... ${trend.length - 14} earlier days omitted (use --format json for full data)`);
218
+ }
219
+ lines.push('');
220
+
221
+ // Anomalies
222
+ if (anomalies.length > 0) {
223
+ lines.push(' ⚠ Anomalies Detected');
224
+ for (const a of anomalies) {
225
+ lines.push(
226
+ ` ${a.date}: hit rate ${pct(a.hitRate)} (7-day avg: ${pct(a.avgHitRate)}, drop: -${pct(a.drop)}) [${a.apiCalls} calls]`,
227
+ );
228
+ }
229
+ } else {
230
+ lines.push(' ✓ No anomalies detected');
231
+ }
232
+
233
+ lines.push('');
234
+ return lines.join('\n');
235
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Install/uninstall the SessionEnd hook in ~/.claude/settings.json
3
+ */
4
+
5
+ import { readFile, writeFile, copyFile } from 'node:fs/promises';
6
+ import { join, dirname } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
11
+ const HOOK_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'hook.cjs');
12
+ const HOOK_DEST = join(homedir(), '.claude', 'cache-monitor-hook.cjs');
13
+ const HOOK_MARKER = 'cache-monitor-hook';
14
+
15
+ export async function installHook({ threshold = 0.7 } = {}) {
16
+ // Copy hook script to ~/.claude/ for stable path
17
+ await copyFile(HOOK_SCRIPT, HOOK_DEST);
18
+
19
+ let settings;
20
+ try {
21
+ const raw = await readFile(SETTINGS_PATH, 'utf8');
22
+ settings = JSON.parse(raw);
23
+ } catch {
24
+ settings = {};
25
+ }
26
+
27
+ if (!settings.hooks) settings.hooks = {};
28
+ if (!Array.isArray(settings.hooks.PostToolUse)) settings.hooks.PostToolUse = [];
29
+
30
+ // Remove existing cache-monitor hook if present
31
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
32
+ (h) => {
33
+ // Check nested hooks structure
34
+ const nested = h.hooks || [];
35
+ return !nested.some((nh) => nh.command?.includes(HOOK_MARKER));
36
+ },
37
+ );
38
+
39
+ // Add new hook (correct 3-level nested structure)
40
+ // Normalize path separators for Windows compatibility in shell commands
41
+ const hookPath = HOOK_DEST.replace(/\\/g, '/');
42
+ settings.hooks.PostToolUse.push({
43
+ matcher: 'Bash|Edit|Write',
44
+ hooks: [
45
+ {
46
+ type: 'command',
47
+ command: `node "${hookPath}" --threshold ${threshold}`,
48
+ timeout: 10,
49
+ },
50
+ ],
51
+ });
52
+
53
+ await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8');
54
+
55
+ console.log(`✓ Hook installed at ${HOOK_DEST}`);
56
+ console.log(` Settings updated: ${SETTINGS_PATH}`);
57
+ console.log(` Threshold: ${(threshold * 100).toFixed(0)}%`);
58
+ console.log(` Stats file: ~/.claude/cache-stats.jsonl`);
59
+ }
60
+
61
+ export async function uninstallHook() {
62
+ let settings;
63
+ try {
64
+ const raw = await readFile(SETTINGS_PATH, 'utf8');
65
+ settings = JSON.parse(raw);
66
+ } catch {
67
+ console.log('No settings.json found, nothing to uninstall.');
68
+ return;
69
+ }
70
+
71
+ if (settings.hooks?.PostToolUse) {
72
+ const before = settings.hooks.PostToolUse.length;
73
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
74
+ (h) => {
75
+ const nested = h.hooks || [];
76
+ return !nested.some((nh) => nh.command?.includes(HOOK_MARKER));
77
+ },
78
+ );
79
+ const removed = before - settings.hooks.PostToolUse.length;
80
+
81
+ if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
82
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
83
+
84
+ await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8');
85
+ console.log(`✓ Removed ${removed} hook(s) from settings.json`);
86
+ } else {
87
+ console.log('No cache-monitor hook found in settings.');
88
+ }
89
+ }
package/src/hook.cjs ADDED
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Claude Code PostToolUse hook — standalone, zero dependencies, CommonJS.
4
+ * Appends per-session cache stats to ~/.claude/cache-stats.jsonl.
5
+ * Warns if hit rate drops below threshold.
6
+ *
7
+ * Written in CommonJS so it works standalone in ~/.claude/ without package.json.
8
+ *
9
+ * Receives hook context on stdin:
10
+ * { session_id, transcript_path, cwd, tool_name, tool_input, tool_response }
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const os = require('node:os');
18
+
19
+ const STATS_FILE = path.join(os.homedir(), '.claude', 'cache-stats.jsonl');
20
+ const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
21
+
22
+ // Parse threshold from args
23
+ let threshold = 0.7;
24
+ const thIdx = process.argv.indexOf('--threshold');
25
+ if (thIdx !== -1 && process.argv[thIdx + 1]) {
26
+ threshold = parseFloat(process.argv[thIdx + 1]);
27
+ }
28
+
29
+ // Read stdin (hook context)
30
+ let stdin = '';
31
+ try {
32
+ stdin = fs.readFileSync(0, 'utf8');
33
+ } catch {
34
+ // no stdin
35
+ }
36
+
37
+ let context;
38
+ try {
39
+ context = JSON.parse(stdin);
40
+ } catch {
41
+ process.exit(0);
42
+ }
43
+
44
+ const sessionId = context.session_id;
45
+ const cwd = context.cwd || '';
46
+ if (!sessionId) process.exit(0);
47
+
48
+ // Resolve session file: prefer transcript_path, fallback to directory scan
49
+ function resolveSessionFile() {
50
+ // Method 1: transcript_path (Claude Code v2.1.85+)
51
+ if (context.transcript_path) {
52
+ try {
53
+ fs.statSync(context.transcript_path);
54
+ return context.transcript_path;
55
+ } catch {
56
+ // path provided but file not found, try fallback
57
+ }
58
+ }
59
+
60
+ // Method 2: scan projects directory (older versions)
61
+ try {
62
+ const dirs = fs.readdirSync(PROJECTS_DIR);
63
+ for (const d of dirs) {
64
+ const fp = path.join(PROJECTS_DIR, d, `${sessionId}.jsonl`);
65
+ try {
66
+ fs.statSync(fp);
67
+ return fp;
68
+ } catch {
69
+ // not here
70
+ }
71
+ }
72
+ } catch {
73
+ // no projects dir
74
+ }
75
+
76
+ return null;
77
+ }
78
+
79
+ const sessionFile = resolveSessionFile();
80
+ if (!sessionFile) process.exit(0);
81
+
82
+ // Parse session file
83
+ let content;
84
+ try {
85
+ content = fs.readFileSync(sessionFile, 'utf8');
86
+ } catch {
87
+ process.exit(0);
88
+ }
89
+
90
+ const lines = content.trim().split('\n');
91
+ const requests = new Map();
92
+
93
+ for (const line of lines) {
94
+ let entry;
95
+ try {
96
+ entry = JSON.parse(line);
97
+ } catch {
98
+ continue;
99
+ }
100
+
101
+ const msg = entry.message;
102
+ if (!msg || !msg.usage || !msg.id) continue;
103
+
104
+ const u = msg.usage;
105
+ const cc = u.cache_creation || {};
106
+ const reqId = entry.requestId || msg.id;
107
+
108
+ requests.set(reqId, {
109
+ input: u.input_tokens || 0,
110
+ cacheCreation: u.cache_creation_input_tokens || 0,
111
+ cacheRead: u.cache_read_input_tokens || 0,
112
+ ephemeral5m: cc.ephemeral_5m_input_tokens || 0,
113
+ ephemeral1h: cc.ephemeral_1h_input_tokens || 0,
114
+ output: u.output_tokens || 0,
115
+ model: msg.model || 'unknown',
116
+ });
117
+ }
118
+
119
+ if (requests.size === 0) process.exit(0);
120
+
121
+ const reqs = Array.from(requests.values());
122
+ const totals = reqs.reduce(
123
+ function (a, r) {
124
+ a.input += r.input;
125
+ a.cacheCreation += r.cacheCreation;
126
+ a.cacheRead += r.cacheRead;
127
+ a.ephemeral5m += r.ephemeral5m;
128
+ a.ephemeral1h += r.ephemeral1h;
129
+ a.output += r.output;
130
+ return a;
131
+ },
132
+ { input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 },
133
+ );
134
+
135
+ const totalInput = totals.cacheRead + totals.cacheCreation + totals.input;
136
+ const hitRate = totalInput > 0 ? totals.cacheRead / totalInput : 0;
137
+
138
+ const record = {
139
+ timestamp: new Date().toISOString(),
140
+ sessionId: sessionId,
141
+ cwd: cwd,
142
+ apiCalls: requests.size,
143
+ hitRate: Math.round(hitRate * 10000) / 10000,
144
+ tokens: {
145
+ input: totals.input,
146
+ cacheCreation: totals.cacheCreation,
147
+ cacheRead: totals.cacheRead,
148
+ ephemeral5m: totals.ephemeral5m,
149
+ ephemeral1h: totals.ephemeral1h,
150
+ output: totals.output,
151
+ },
152
+ model: reqs[0] ? reqs[0].model : 'unknown',
153
+ };
154
+
155
+ // Append to stats file
156
+ try {
157
+ fs.appendFileSync(STATS_FILE, JSON.stringify(record) + '\n', 'utf8');
158
+ } catch {
159
+ // can't write, ignore
160
+ }
161
+
162
+ // Alert if hit rate below threshold
163
+ if (hitRate < threshold && requests.size >= 5) {
164
+ var pct = (hitRate * 100).toFixed(1);
165
+ var ccTotal = totals.ephemeral5m + totals.ephemeral1h;
166
+ var pct5m = ccTotal > 0 ? ((totals.ephemeral5m / ccTotal) * 100).toFixed(0) : '0';
167
+ process.stdout.write(
168
+ '\u26a0 Cache hit rate: ' + pct + '% (threshold: ' + (threshold * 100).toFixed(0) + '%) | 5m TTL: ' + pct5m + '% | ' + requests.size + ' API calls\n',
169
+ );
170
+ }
package/src/parser.js ADDED
@@ -0,0 +1,197 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { readdir, stat } from 'node:fs/promises';
3
+ import { createInterface } from 'node:readline';
4
+ import { join, isAbsolute } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+
7
+ const CLAUDE_DIR = join(homedir(), '.claude', 'projects');
8
+
9
+ /**
10
+ * Parse a single session JSONL file.
11
+ * Deduplicates by requestId (last-write-wins for streaming chunks).
12
+ */
13
+ export async function parseSessionFile(filePath) {
14
+ const requests = new Map();
15
+ let sessionId = null;
16
+ let firstTimestamp = null;
17
+ let lastTimestamp = null;
18
+
19
+ const rl = createInterface({
20
+ input: createReadStream(filePath, { encoding: 'utf8' }),
21
+ crlfDelay: Infinity,
22
+ });
23
+
24
+ for await (const line of rl) {
25
+ let entry;
26
+ try {
27
+ entry = JSON.parse(line);
28
+ } catch {
29
+ continue;
30
+ }
31
+
32
+ const ts = entry.timestamp;
33
+ if (ts) {
34
+ if (!firstTimestamp || ts < firstTimestamp) firstTimestamp = ts;
35
+ if (!lastTimestamp || ts > lastTimestamp) lastTimestamp = ts;
36
+ }
37
+
38
+ if (!sessionId && entry.sessionId) {
39
+ sessionId = entry.sessionId;
40
+ }
41
+
42
+ const msg = entry.message;
43
+ if (!msg?.usage || !msg.id) continue;
44
+
45
+ const usage = msg.usage;
46
+ const cc = usage.cache_creation || {};
47
+ const reqId = entry.requestId || msg.id;
48
+
49
+ requests.set(reqId, {
50
+ requestId: reqId,
51
+ model: msg.model || 'unknown',
52
+ inputTokens: usage.input_tokens || 0,
53
+ cacheCreationTokens: usage.cache_creation_input_tokens || 0,
54
+ cacheReadTokens: usage.cache_read_input_tokens || 0,
55
+ ephemeral5mTokens: cc.ephemeral_5m_input_tokens || 0,
56
+ ephemeral1hTokens: cc.ephemeral_1h_input_tokens || 0,
57
+ outputTokens: usage.output_tokens || 0,
58
+ });
59
+ }
60
+
61
+ const reqs = [...requests.values()];
62
+ let maxContextPerRequest = 0;
63
+ const totals = reqs.reduce(
64
+ (acc, r) => {
65
+ acc.input += r.inputTokens;
66
+ acc.cacheCreation += r.cacheCreationTokens;
67
+ acc.cacheRead += r.cacheReadTokens;
68
+ acc.ephemeral5m += r.ephemeral5mTokens;
69
+ acc.ephemeral1h += r.ephemeral1hTokens;
70
+ acc.output += r.outputTokens;
71
+ const ctx = r.inputTokens + r.cacheCreationTokens + r.cacheReadTokens;
72
+ if (ctx > maxContextPerRequest) maxContextPerRequest = ctx;
73
+ return acc;
74
+ },
75
+ { input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 },
76
+ );
77
+
78
+ return {
79
+ sessionId,
80
+ filePath,
81
+ startTime: firstTimestamp ? new Date(firstTimestamp) : null,
82
+ endTime: lastTimestamp ? new Date(lastTimestamp) : null,
83
+ requestCount: reqs.length,
84
+ requests: reqs,
85
+ totals,
86
+ maxContextPerRequest,
87
+ model: reqs[0]?.model || 'unknown',
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Find the most recent user-message timestamp in a session JSONL.
93
+ * Used for statusline mode so the agent's own tool calls don't reset the TTL
94
+ * countdown — only the user's actual prompts (type === "user") do.
95
+ *
96
+ * @param {string} filePath absolute path to the session JSONL
97
+ * @returns {Promise<Date|null>}
98
+ */
99
+ export async function getLastUserMessageTime(filePath) {
100
+ let lastUserTs = null;
101
+ try {
102
+ const rl = createInterface({
103
+ input: createReadStream(filePath, { encoding: 'utf8' }),
104
+ crlfDelay: Infinity,
105
+ });
106
+ for await (const line of rl) {
107
+ if (!line) continue;
108
+ try {
109
+ const entry = JSON.parse(line);
110
+ if (entry.type === 'user' && entry.timestamp) {
111
+ lastUserTs = entry.timestamp;
112
+ }
113
+ } catch {
114
+ // ignore malformed lines
115
+ }
116
+ }
117
+ } catch {
118
+ return null;
119
+ }
120
+ return lastUserTs ? new Date(lastUserTs) : null;
121
+ }
122
+
123
+ /**
124
+ * Discover all session JSONL files under ~/.claude/projects/
125
+ */
126
+ export async function discoverSessionFiles(options = {}) {
127
+ const { projectFilter, days = 30, excludeSessionPath } = options;
128
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
129
+ const files = [];
130
+ // Resolve the excluded session to an absolute path so equality checks are exact.
131
+ // Use path.isAbsolute() so Windows paths like C:\... are recognized too.
132
+ const excludeAbs = excludeSessionPath
133
+ ? (isAbsolute(excludeSessionPath) ? excludeSessionPath : join(process.cwd(), excludeSessionPath))
134
+ : null;
135
+
136
+ let projectDirs;
137
+ try {
138
+ projectDirs = await readdir(CLAUDE_DIR);
139
+ } catch {
140
+ return files;
141
+ }
142
+
143
+ for (const projDir of projectDirs) {
144
+ if (projectFilter && !projDir.includes(projectFilter)) continue;
145
+
146
+ const projPath = join(CLAUDE_DIR, projDir);
147
+ let entries;
148
+ try {
149
+ entries = await readdir(projPath);
150
+ } catch {
151
+ continue;
152
+ }
153
+
154
+ for (const entry of entries) {
155
+ if (!entry.endsWith('.jsonl')) continue;
156
+ const fp = join(projPath, entry);
157
+ if (excludeAbs && fp === excludeAbs) continue;
158
+ try {
159
+ const s = await stat(fp);
160
+ if (s.mtimeMs >= cutoff) {
161
+ files.push({ path: fp, projectDir: projDir, mtime: s.mtimeMs });
162
+ }
163
+ } catch {
164
+ continue;
165
+ }
166
+ }
167
+ }
168
+
169
+ return files.sort((a, b) => a.mtime - b.mtime);
170
+ }
171
+
172
+ /**
173
+ * Parse all sessions with concurrency control
174
+ */
175
+ export async function parseAllSessions(options = {}) {
176
+ const files = await discoverSessionFiles(options);
177
+ const concurrency = 10;
178
+ const results = [];
179
+
180
+ for (let i = 0; i < files.length; i += concurrency) {
181
+ const batch = files.slice(i, i + concurrency);
182
+ const parsed = await Promise.all(
183
+ batch.map(async (f) => {
184
+ try {
185
+ const session = await parseSessionFile(f.path);
186
+ session.projectDir = f.projectDir;
187
+ return session;
188
+ } catch {
189
+ return null;
190
+ }
191
+ }),
192
+ );
193
+ results.push(...parsed.filter(Boolean));
194
+ }
195
+
196
+ return results.filter((s) => s.requestCount > 0);
197
+ }