@abnersajr/claude-timeline 1.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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +227 -0
  3. package/dist/capture.js +140 -0
  4. package/dist/classifier.d.ts +37 -0
  5. package/dist/classifier.d.ts.map +1 -0
  6. package/dist/classifier.test.d.ts +2 -0
  7. package/dist/classifier.test.d.ts.map +1 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +1328 -0
  10. package/dist/context-tracker.d.ts +44 -0
  11. package/dist/context-tracker.d.ts.map +1 -0
  12. package/dist/context-tracker.test.d.ts +2 -0
  13. package/dist/context-tracker.test.d.ts.map +1 -0
  14. package/dist/conversation-groups.d.ts +11 -0
  15. package/dist/conversation-groups.d.ts.map +1 -0
  16. package/dist/conversation-groups.test.d.ts +2 -0
  17. package/dist/conversation-groups.test.d.ts.map +1 -0
  18. package/dist/cost-stream-capture.d.ts +47 -0
  19. package/dist/cost-stream-capture.d.ts.map +1 -0
  20. package/dist/cost-stream-db.d.ts +87 -0
  21. package/dist/cost-stream-db.d.ts.map +1 -0
  22. package/dist/cost-stream-merger.d.ts +38 -0
  23. package/dist/cost-stream-merger.d.ts.map +1 -0
  24. package/dist/db-reader-BrPRGqww.mjs +1028 -0
  25. package/dist/db-reader-BrPRGqww.mjs.map +1 -0
  26. package/dist/db-reader-CPXmkt55.mjs +2 -0
  27. package/dist/db-reader.d.ts +58 -0
  28. package/dist/db-reader.d.ts.map +1 -0
  29. package/dist/db.js +100 -0
  30. package/dist/dedup.d.ts +16 -0
  31. package/dist/dedup.d.ts.map +1 -0
  32. package/dist/dedup.test.d.ts +2 -0
  33. package/dist/dedup.test.d.ts.map +1 -0
  34. package/dist/index.d.ts +20 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/jsonl-parser.d.ts +14 -0
  37. package/dist/jsonl-parser.d.ts.map +1 -0
  38. package/dist/jsonl-parser.test.d.ts +2 -0
  39. package/dist/jsonl-parser.test.d.ts.map +1 -0
  40. package/dist/merger.d.ts +31 -0
  41. package/dist/merger.d.ts.map +1 -0
  42. package/dist/model-parser.d.ts +25 -0
  43. package/dist/model-parser.d.ts.map +1 -0
  44. package/dist/model-parser.test.d.ts +2 -0
  45. package/dist/model-parser.test.d.ts.map +1 -0
  46. package/dist/noise-filter.d.ts +6 -0
  47. package/dist/noise-filter.d.ts.map +1 -0
  48. package/dist/pricing-B-rwfwDB.mjs +2 -0
  49. package/dist/pricing-DTmya3JY.mjs +273 -0
  50. package/dist/pricing-DTmya3JY.mjs.map +1 -0
  51. package/dist/pricing.d.ts +26 -0
  52. package/dist/pricing.d.ts.map +1 -0
  53. package/dist/server.cjs +31237 -0
  54. package/dist/session-state.d.ts +19 -0
  55. package/dist/session-state.d.ts.map +1 -0
  56. package/dist/session-state.test.d.ts +2 -0
  57. package/dist/session-state.test.d.ts.map +1 -0
  58. package/dist/subagent-locator.d.ts +30 -0
  59. package/dist/subagent-locator.d.ts.map +1 -0
  60. package/dist/subagent-locator.test.d.ts +2 -0
  61. package/dist/subagent-locator.test.d.ts.map +1 -0
  62. package/dist/subagent-resolver.d.ts +35 -0
  63. package/dist/subagent-resolver.d.ts.map +1 -0
  64. package/dist/subagent-resolver.test.d.ts +2 -0
  65. package/dist/subagent-resolver.test.d.ts.map +1 -0
  66. package/dist/tool-extraction.d.ts +34 -0
  67. package/dist/tool-extraction.d.ts.map +1 -0
  68. package/dist/tool-extraction.test.d.ts +2 -0
  69. package/dist/tool-extraction.test.d.ts.map +1 -0
  70. package/dist/tool-matcher.d.ts +35 -0
  71. package/dist/tool-matcher.d.ts.map +1 -0
  72. package/dist/types.d.ts +272 -0
  73. package/dist/types.d.ts.map +1 -0
  74. package/dist/utils.d.ts +24 -0
  75. package/dist/utils.d.ts.map +1 -0
  76. package/dist/web/assets/index-Dr0FGYfS.js +158 -0
  77. package/dist/web/assets/index-nXTIEelb.css +1 -0
  78. package/dist/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  79. package/dist/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  80. package/dist/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  81. package/dist/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  82. package/dist/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  83. package/dist/web/favicon-light.svg +14 -0
  84. package/dist/web/favicon.svg +14 -0
  85. package/dist/web/index.html +14 -0
  86. package/dist/web/logo.svg +20 -0
  87. package/package.json +73 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1328 @@
1
+ #!/usr/bin/env node
2
+ import { _ as deduplicateByRequestId, a as getTurns, c as resolveSubagents, d as formatToolResult, f as linkToolResults, g as resolveSessionJsonlPath, h as getProjectsDir, i as getSession, l as extractToolCalls, m as getDbPath, o as listJsonlSessions, p as listSubagentFiles, s as listSessions, u as extractToolResults, v as classifyMessage } from "./db-reader-BrPRGqww.mjs";
3
+ import { s as normalizeModelName, t as calculateSessionCost } from "./pricing-DTmya3JY.mjs";
4
+ import { execSync } from "node:child_process";
5
+ import * as fs from "node:fs";
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
7
+ import * as path from "node:path";
8
+ import { homedir } from "node:os";
9
+ import { createInterface } from "node:readline";
10
+ import { Command } from "commander";
11
+ import minimist from "minimist";
12
+ //#region src/conversation-groups.ts
13
+ /**
14
+ * Build conversation groups from a flat list of turns.
15
+ *
16
+ * Grouping strategy: scan turns in order. Each turn containing a user message
17
+ * starts a new group. All subsequent turns (AI-only) belong to that group
18
+ * until the next user turn. Orphaned AI-only sequences at the start are
19
+ * collected into their own group.
20
+ */
21
+ function buildConversationGroups(turns) {
22
+ if (turns.length === 0) return [];
23
+ const groups = [];
24
+ let currentGroup = null;
25
+ for (const turn of turns) if (turn.messages.some((m) => m.type === "user")) {
26
+ if (currentGroup) groups.push(finalizeGroup(currentGroup));
27
+ const userMessage = turn.messages.find((m) => m.type === "user");
28
+ currentGroup = {
29
+ id: `group-${groups.length + 1}`,
30
+ userMessage,
31
+ aiResponses: [],
32
+ toolExecutions: [],
33
+ processIds: [],
34
+ startTime: turn.timestamp,
35
+ endTime: turn.timestamp,
36
+ durationMs: 0,
37
+ tokenUsage: emptyTokenUsage(),
38
+ totalCost: 0
39
+ };
40
+ collectToolCalls(currentGroup, turn);
41
+ currentGroup.tokenUsage.inputTokens += turn.tokenUsage.inputTokens;
42
+ currentGroup.tokenUsage.outputTokens += turn.tokenUsage.outputTokens;
43
+ currentGroup.tokenUsage.cacheReadTokens += turn.tokenUsage.cacheReadTokens;
44
+ currentGroup.tokenUsage.cacheCreation5mTokens += turn.tokenUsage.cacheCreation5mTokens;
45
+ currentGroup.tokenUsage.cacheCreation1hTokens += turn.tokenUsage.cacheCreation1hTokens;
46
+ } else if (currentGroup) appendTurnToGroup(currentGroup, turn);
47
+ else {
48
+ currentGroup = {
49
+ id: `group-${groups.length + 1}`,
50
+ userMessage: void 0,
51
+ aiResponses: [],
52
+ toolExecutions: [],
53
+ processIds: [],
54
+ startTime: turn.timestamp,
55
+ endTime: turn.timestamp,
56
+ durationMs: 0,
57
+ tokenUsage: emptyTokenUsage(),
58
+ totalCost: 0
59
+ };
60
+ appendTurnToGroup(currentGroup, turn);
61
+ }
62
+ if (currentGroup) groups.push(finalizeGroup(currentGroup));
63
+ return groups;
64
+ }
65
+ /** Append an AI turn's data into the current group. */
66
+ function appendTurnToGroup(group, turn) {
67
+ for (const msg of turn.messages) group.aiResponses.push(msg);
68
+ collectToolCalls(group, turn);
69
+ group.tokenUsage.inputTokens += turn.tokenUsage.inputTokens;
70
+ group.tokenUsage.outputTokens += turn.tokenUsage.outputTokens;
71
+ group.tokenUsage.cacheReadTokens += turn.tokenUsage.cacheReadTokens;
72
+ group.tokenUsage.cacheCreation5mTokens += turn.tokenUsage.cacheCreation5mTokens;
73
+ group.tokenUsage.cacheCreation1hTokens += turn.tokenUsage.cacheCreation1hTokens;
74
+ group.endTime = turn.timestamp;
75
+ }
76
+ /** Collect tool executions and process IDs from a turn's tool calls. */
77
+ function collectToolCalls(group, turn) {
78
+ for (const tc of turn.toolCalls) {
79
+ group.toolExecutions.push(tc);
80
+ if (tc.isTask) group.processIds.push(tc.toolUseId);
81
+ }
82
+ }
83
+ /** Finalize a group: aggregate token usage, compute duration, set cost to 0. */
84
+ function finalizeGroup(group) {
85
+ const start = new Date(group.startTime).getTime();
86
+ const end = new Date(group.endTime).getTime();
87
+ group.durationMs = Number.isFinite(start) && Number.isFinite(end) ? end - start : 0;
88
+ group.totalCost = 0;
89
+ return group;
90
+ }
91
+ /** Create a zeroed TokenUsage object. */
92
+ function emptyTokenUsage() {
93
+ return {
94
+ inputTokens: 0,
95
+ outputTokens: 0,
96
+ cacheReadTokens: 0,
97
+ cacheCreation5mTokens: 0,
98
+ cacheCreation1hTokens: 0
99
+ };
100
+ }
101
+ //#endregion
102
+ //#region src/context-tracker.ts
103
+ /** Check if content blocks contain any tool_use blocks */
104
+ function hasToolUseBlocks(content) {
105
+ if (!Array.isArray(content)) return false;
106
+ return content.some((block) => block.type === "tool_use");
107
+ }
108
+ /** Check if content blocks contain any thinking blocks */
109
+ function hasThinkingBlocks(content) {
110
+ if (!Array.isArray(content)) return false;
111
+ return content.some((block) => block.type === "thinking");
112
+ }
113
+ /**
114
+ * Classify a record's context contribution based on type and content blocks.
115
+ *
116
+ * Priority order:
117
+ * 1. Compact records → "compact"
118
+ * 2. User meta messages with tool_result blocks → "tool-output"
119
+ * 3. User text/image messages → "user-message"
120
+ * 4. Assistant messages with tool_use → "tool-output"
121
+ * 5. Assistant messages with thinking → "thinking-text"
122
+ * 6. System messages (command output) → "system"
123
+ * 7. Everything else → "other"
124
+ */
125
+ function categorizeContext(record) {
126
+ if (record.isCompactSummary) return "compact";
127
+ if (record.type === "user" && record.isMeta) return "tool-output";
128
+ if (record.type === "user") return "user-message";
129
+ if (record.type === "assistant") {
130
+ const content = record.message?.content;
131
+ if (hasToolUseBlocks(content)) return "tool-output";
132
+ if (hasThinkingBlocks(content)) return "thinking-text";
133
+ }
134
+ if (record.type === "system") return "system";
135
+ return "other";
136
+ }
137
+ /**
138
+ * Extract input tokens from a record's usage data.
139
+ * Returns 0 if no usage data is present.
140
+ */
141
+ function getInputTokens(record) {
142
+ return record.message?.usage?.input_tokens ?? 0;
143
+ }
144
+ /**
145
+ * Scan records for isCompactSummary events and return Phase[].
146
+ * Each phase represents a contiguous segment of records between compact events.
147
+ * Phase 1 starts at index 0. The compact record itself is included at the end
148
+ * of the phase it terminates. A new phase starts after each compact record.
149
+ */
150
+ function detectCompactions(records) {
151
+ const phases = [];
152
+ let currentPhaseNumber = 1;
153
+ let currentPhaseStart = 0;
154
+ for (let i = 0; i < records.length; i++) if (records[i].isCompactSummary) {
155
+ phases.push({
156
+ phaseNumber: currentPhaseNumber,
157
+ startRecordIndex: currentPhaseStart,
158
+ endRecordIndex: i
159
+ });
160
+ currentPhaseNumber++;
161
+ currentPhaseStart = i + 1;
162
+ }
163
+ phases.push({
164
+ phaseNumber: currentPhaseNumber,
165
+ startRecordIndex: currentPhaseStart,
166
+ endRecordIndex: records.length - 1
167
+ });
168
+ return phases;
169
+ }
170
+ /**
171
+ * Determine which phase a record index belongs to.
172
+ */
173
+ function getPhaseForIndex(recordIndex, phases) {
174
+ for (const phase of phases) if (recordIndex >= phase.startRecordIndex && recordIndex <= phase.endRecordIndex) return phase.phaseNumber;
175
+ return phases.length > 0 ? phases[phases.length - 1].phaseNumber : 1;
176
+ }
177
+ /**
178
+ * Compute context statistics by iterating records and categorizing each one.
179
+ * Tracks compaction phases and accumulates tokens by category.
180
+ *
181
+ * For now, attributes full input_tokens to the primary category of each record.
182
+ * Precise per-category breakdown would require content size analysis.
183
+ */
184
+ function computeContextStats(records) {
185
+ const phases = detectCompactions(records);
186
+ const tokensByCategory = {
187
+ "user-message": 0,
188
+ "tool-output": 0,
189
+ "thinking-text": 0,
190
+ system: 0,
191
+ compact: 0,
192
+ other: 0
193
+ };
194
+ const injections = [];
195
+ let totalInputTokens = 0;
196
+ for (let i = 0; i < records.length; i++) {
197
+ const record = records[i];
198
+ const category = categorizeContext(record);
199
+ const inputTokens = getInputTokens(record);
200
+ const phaseNumber = getPhaseForIndex(i, phases);
201
+ if (inputTokens > 0) {
202
+ tokensByCategory[category] += inputTokens;
203
+ totalInputTokens += inputTokens;
204
+ }
205
+ injections.push({
206
+ recordIndex: i,
207
+ category,
208
+ inputTokens,
209
+ timestamp: record.timestamp,
210
+ phaseNumber
211
+ });
212
+ }
213
+ return {
214
+ injections,
215
+ tokensByCategory,
216
+ totalInputTokens,
217
+ phaseCount: phases.length,
218
+ phases
219
+ };
220
+ }
221
+ //#endregion
222
+ //#region src/jsonl-parser.ts
223
+ /**
224
+ * Parse a JSONL session file into raw messages and tool calls.
225
+ * Returns null if file doesn't exist or path is null.
226
+ */
227
+ function parseSessionJsonl(jsonlPath, _sessionId) {
228
+ if (!jsonlPath || !existsSync(jsonlPath)) return null;
229
+ const lines = readFileSync(jsonlPath, "utf-8").split("\n").filter((line) => line.trim().length > 0);
230
+ const hookRewrites = /* @__PURE__ */ new Map();
231
+ for (const line of lines) {
232
+ let entry;
233
+ try {
234
+ entry = JSON.parse(line);
235
+ } catch {
236
+ continue;
237
+ }
238
+ const att = entry.attachment;
239
+ if (!att || att.type !== "hook_success") continue;
240
+ if (att.hookName !== "PreToolUse:Bash") continue;
241
+ const toolUseID = String(att.toolUseID ?? "");
242
+ const stdout = String(att.stdout ?? "");
243
+ if (!toolUseID || !stdout) continue;
244
+ try {
245
+ const command = (JSON.parse(stdout).hookSpecificOutput?.updatedInput)?.command;
246
+ if (typeof command === "string") hookRewrites.set(toolUseID, command);
247
+ } catch {}
248
+ }
249
+ const rawMessages = [];
250
+ const toolCalls = [];
251
+ let malformedCount = 0;
252
+ const assistantToolCallIndices = /* @__PURE__ */ new Map();
253
+ for (const line of lines) {
254
+ let entry;
255
+ try {
256
+ entry = JSON.parse(line);
257
+ } catch {
258
+ malformedCount++;
259
+ continue;
260
+ }
261
+ const record = entry;
262
+ if (classifyMessage(record) === "hardNoise") continue;
263
+ rawMessages.push(record);
264
+ if (record.message?.usage?.cache_creation) {
265
+ const cc = record.message.usage.cache_creation;
266
+ record.message.usage.cacheCreation5mTokens = cc.ephemeral_5m_input_tokens ?? 0;
267
+ record.message.usage.cacheCreation1hTokens = cc.ephemeral_1h_input_tokens ?? 0;
268
+ }
269
+ if (record.type === "assistant" && record.message?.content) {
270
+ const newCalls = extractToolCalls(record.message.content, record.timestamp);
271
+ const startIdx = toolCalls.length;
272
+ toolCalls.push(...newCalls);
273
+ if (newCalls.length > 0 && record.uuid) {
274
+ const indices = Array.from({ length: newCalls.length }, (_, i) => startIdx + i);
275
+ assistantToolCallIndices.set(record.uuid, indices);
276
+ }
277
+ }
278
+ if (record.type === "user" && record.isMeta && record.message?.content) {
279
+ const results = extractToolResults(record.message.content);
280
+ if (results.length > 0) {
281
+ const updatedCalls = linkToolResults(toolCalls, results);
282
+ for (let i = 0; i < updatedCalls.length; i++) if (updatedCalls[i].result !== toolCalls[i].result) toolCalls[i] = updatedCalls[i];
283
+ }
284
+ }
285
+ if (record.toolUseResult && record.parentUuid) {
286
+ const indices = assistantToolCallIndices.get(record.parentUuid);
287
+ if (indices) {
288
+ const result = record.toolUseResult;
289
+ const resultStr = formatToolResult(result);
290
+ const isError = Boolean(result.interrupted) || Boolean(result.stderr);
291
+ for (const idx of indices) {
292
+ toolCalls[idx].result = resultStr;
293
+ toolCalls[idx].isError = isError;
294
+ }
295
+ }
296
+ }
297
+ }
298
+ if (hookRewrites.size > 0) for (const tc of toolCalls) {
299
+ const rewritten = hookRewrites.get(tc.toolUseId);
300
+ if (rewritten) tc.hookRewrite = { command: rewritten };
301
+ }
302
+ const deduped = deduplicateByRequestId(rawMessages);
303
+ const toolUseIdToMergedTs = /* @__PURE__ */ new Map();
304
+ for (const rec of deduped) {
305
+ const content = rec.message?.content;
306
+ if (!Array.isArray(content)) continue;
307
+ for (const block of content) if (block.type === "tool_use") {
308
+ const id = String(block.id ?? block.toolUseId ?? "");
309
+ if (id && rec.timestamp) toolUseIdToMergedTs.set(id, rec.timestamp);
310
+ }
311
+ }
312
+ for (const tc of toolCalls) {
313
+ const mergedTs = toolUseIdToMergedTs.get(tc.toolUseId);
314
+ if (mergedTs) tc.timestamp = mergedTs;
315
+ }
316
+ return {
317
+ rawMessages: deduped,
318
+ categories: deduped.map((r) => classifyMessage(r)),
319
+ toolCalls,
320
+ malformedCount
321
+ };
322
+ }
323
+ //#endregion
324
+ //#region src/session-state.ts
325
+ /**
326
+ * Classify a single record into an activity type.
327
+ * Used to determine ending events vs continuing AI activities.
328
+ */
329
+ function classifyActivity(record) {
330
+ const { type, message, isMeta } = record;
331
+ if (type === "user") {
332
+ const content = message?.content;
333
+ if (typeof content === "string" && content === "[Request interrupted by user]") return "interruption";
334
+ }
335
+ if (type === "assistant" && message?.content) {
336
+ const content = message.content;
337
+ if (Array.isArray(content)) {
338
+ if (content.some((b) => b.type === "tool_use")) return "tool_use";
339
+ if (content.some((b) => b.type === "thinking")) return "thinking";
340
+ if (content.some((b) => b.type === "text")) return "text_output";
341
+ }
342
+ }
343
+ if (type === "user" && isMeta) return "tool_result";
344
+ return "other";
345
+ }
346
+ /** Ending events: text_output or interruption */
347
+ function isEndingEvent(activity) {
348
+ return activity === "text_output" || activity === "interruption";
349
+ }
350
+ /** AI activities that indicate the session is still in progress */
351
+ function isAiActivity(activity) {
352
+ return activity === "thinking" || activity === "tool_use" || activity === "tool_result";
353
+ }
354
+ /**
355
+ * Detect whether a session is ongoing (AI still working) vs completed.
356
+ *
357
+ * Algorithm:
358
+ * 1. Classify each record into an activity type
359
+ * 2. Find the last ending event (text_output or interruption)
360
+ * 3. Check if any AI activities exist after that ending event
361
+ * 4. If AI activities exist after the last ending event → ongoing
362
+ * 5. If no AI activities after last ending event → completed
363
+ * 6. If no ending events exist → not ongoing (empty or all AI activities)
364
+ *
365
+ * Special case: interruption is always treated as an ending event,
366
+ * and no AI activities are expected to follow it in practice.
367
+ */
368
+ function detectSessionState(records) {
369
+ if (records.length === 0) return { isOngoing: false };
370
+ const activities = records.map(classifyActivity);
371
+ let lastEndingIndex = -1;
372
+ for (let i = activities.length - 1; i >= 0; i--) if (isEndingEvent(activities[i])) {
373
+ lastEndingIndex = i;
374
+ break;
375
+ }
376
+ if (lastEndingIndex === -1) return { isOngoing: false };
377
+ for (let i = lastEndingIndex + 1; i < activities.length; i++) if (isAiActivity(activities[i])) return { isOngoing: true };
378
+ return { isOngoing: false };
379
+ }
380
+ //#endregion
381
+ //#region src/merger.ts
382
+ /**
383
+ * Compute active duration by summing gaps between consecutive turns
384
+ * that are below a threshold (5 minutes). Large gaps represent idle/closed
385
+ * sessions and are excluded.
386
+ */
387
+ /** Check if a turn has actual content (not an empty noise record) */
388
+ function hasTurnContent(turn) {
389
+ const u = turn.tokenUsage;
390
+ if (u.inputTokens + u.outputTokens + u.cacheReadTokens + u.cacheCreation5mTokens + u.cacheCreation1hTokens > 0) return true;
391
+ return turn.messages.some((m) => m.content.length > 0);
392
+ }
393
+ function computeActiveDurationMs(turns, thresholdMs = 300 * 1e3) {
394
+ const meaningful = turns.filter(hasTurnContent);
395
+ if (meaningful.length < 2) return 0;
396
+ let activeMs = 0;
397
+ for (let i = 1; i < meaningful.length; i++) {
398
+ const gap = new Date(meaningful[i].timestamp).getTime() - new Date(meaningful[i - 1].timestamp).getTime();
399
+ if (gap > 0 && gap < thresholdMs) activeMs += gap;
400
+ }
401
+ return activeMs;
402
+ }
403
+ /**
404
+ * Match SQLite turns to JSONL messages and tool calls by timestamp.
405
+ * Primary: timestamp within 5 seconds.
406
+ * Fallback: index-based matching.
407
+ * Each message/tool call is matched to at most one turn (closest timestamp wins).
408
+ */
409
+ function matchTurnsToMessages(turns, messages, toolCalls) {
410
+ if (messages.length === 0 && (!toolCalls || toolCalls.length === 0)) return turns;
411
+ const USER_TEXT_WINDOW = 1e4;
412
+ const OTHER_WINDOW = 5e3;
413
+ const userIdxSet = /* @__PURE__ */ new Set();
414
+ const otherIdxSet = /* @__PURE__ */ new Set();
415
+ for (let i = 0; i < messages.length; i++) {
416
+ const m = messages[i];
417
+ if (!m.timestamp) {
418
+ otherIdxSet.add(i);
419
+ continue;
420
+ }
421
+ const category = classifyMessage(m);
422
+ if (category === "user") userIdxSet.add(i);
423
+ else if (category !== "hardNoise") otherIdxSet.add(i);
424
+ }
425
+ const matchedMsgIndices = /* @__PURE__ */ new Set();
426
+ const toolCallsByTurnIdx = /* @__PURE__ */ new Map();
427
+ if (toolCalls && toolCalls.length > 0) {
428
+ const TC_WINDOW = 5e3;
429
+ for (const tc of toolCalls) {
430
+ if (!tc.timestamp) continue;
431
+ const tcTime = new Date(tc.timestamp).getTime();
432
+ let bestIdx = -1;
433
+ let bestDiff = Number.MAX_VALUE;
434
+ for (let ti = 0; ti < turns.length; ti++) {
435
+ const turnTime = new Date(turns[ti].timestamp).getTime();
436
+ const diff = Math.abs(turnTime - tcTime);
437
+ if (diff < bestDiff && diff < TC_WINDOW) {
438
+ bestDiff = diff;
439
+ bestIdx = ti;
440
+ }
441
+ }
442
+ if (bestIdx >= 0) {
443
+ let arr = toolCallsByTurnIdx.get(bestIdx);
444
+ if (!arr) {
445
+ arr = [];
446
+ toolCallsByTurnIdx.set(bestIdx, arr);
447
+ }
448
+ arr.push(tc);
449
+ }
450
+ }
451
+ }
452
+ const matched = turns.map((turn, turnIdx) => {
453
+ const turnTime = new Date(turn.timestamp).getTime();
454
+ const matchedMessages = [];
455
+ let bestUserIndex = -1;
456
+ let bestUserDiff = Number.MAX_VALUE;
457
+ for (const i of userIdxSet) {
458
+ if (matchedMsgIndices.has(i)) continue;
459
+ const msg = messages[i];
460
+ if (!msg.timestamp) continue;
461
+ const msgTime = new Date(msg.timestamp).getTime();
462
+ const diff = Math.abs(turnTime - msgTime);
463
+ if (diff < USER_TEXT_WINDOW && diff < bestUserDiff) {
464
+ bestUserDiff = diff;
465
+ bestUserIndex = i;
466
+ }
467
+ }
468
+ if (bestUserIndex >= 0) {
469
+ matchedMessages.push(messages[bestUserIndex]);
470
+ matchedMsgIndices.add(bestUserIndex);
471
+ }
472
+ let bestOtherIndex = -1;
473
+ let bestOtherDiff = Number.MAX_VALUE;
474
+ for (const i of otherIdxSet) {
475
+ if (matchedMsgIndices.has(i)) continue;
476
+ const msg = messages[i];
477
+ if (!msg.timestamp) continue;
478
+ const msgTime = new Date(msg.timestamp).getTime();
479
+ const diff = Math.abs(turnTime - msgTime);
480
+ if (diff < OTHER_WINDOW && diff < bestOtherDiff) {
481
+ bestOtherDiff = diff;
482
+ bestOtherIndex = i;
483
+ }
484
+ }
485
+ if (bestOtherIndex >= 0) {
486
+ matchedMessages.push(messages[bestOtherIndex]);
487
+ matchedMsgIndices.add(bestOtherIndex);
488
+ }
489
+ if (matchedMessages.length === 0) {
490
+ for (let i = 0; i < messages.length; i++) if (!matchedMsgIndices.has(i) && otherIdxSet.has(i)) {
491
+ matchedMessages.push(messages[i]);
492
+ matchedMsgIndices.add(i);
493
+ break;
494
+ }
495
+ if (matchedMessages.length === 0) {
496
+ for (let i = 0; i < messages.length; i++) if (!matchedMsgIndices.has(i)) {
497
+ matchedMessages.push(messages[i]);
498
+ matchedMsgIndices.add(i);
499
+ break;
500
+ }
501
+ }
502
+ }
503
+ const normalizedMessages = matchedMessages.map((m) => {
504
+ return {
505
+ type: classifyMessage(m) === "user" ? "user" : "assistant",
506
+ timestamp: m.timestamp,
507
+ content: normalizeContent(m.message?.content ?? [])
508
+ };
509
+ });
510
+ let mergedTokenUsage = turn.tokenUsage;
511
+ for (const msg of matchedMessages) {
512
+ const usage = msg.message?.usage;
513
+ if (usage?.cacheCreation5mTokens !== void 0 || usage?.cacheCreation1hTokens !== void 0) {
514
+ mergedTokenUsage = {
515
+ ...turn.tokenUsage,
516
+ cacheCreation5mTokens: usage.cacheCreation5mTokens ?? turn.tokenUsage.cacheCreation5mTokens,
517
+ cacheCreation1hTokens: usage.cacheCreation1hTokens ?? turn.tokenUsage.cacheCreation1hTokens
518
+ };
519
+ break;
520
+ }
521
+ }
522
+ let turnModel;
523
+ for (const msg of matchedMessages) if (msg.type === "assistant" && msg.message?.model) {
524
+ turnModel = msg.message.model;
525
+ break;
526
+ }
527
+ return {
528
+ ...turn,
529
+ model: turnModel,
530
+ messages: normalizedMessages,
531
+ toolCalls: toolCallsByTurnIdx.get(turnIdx) ?? [],
532
+ tokenUsage: mergedTokenUsage
533
+ };
534
+ });
535
+ const unmatchedUserTexts = [];
536
+ for (const i of userIdxSet) if (!matchedMsgIndices.has(i)) unmatchedUserTexts.push(messages[i]);
537
+ if (unmatchedUserTexts.length === 0) return matched;
538
+ const syntheticTurns = unmatchedUserTexts.map((r) => ({
539
+ timestamp: r.timestamp,
540
+ tokenUsage: {
541
+ inputTokens: 0,
542
+ outputTokens: 0,
543
+ cacheReadTokens: 0,
544
+ cacheCreation5mTokens: 0,
545
+ cacheCreation1hTokens: 0
546
+ },
547
+ messages: [{
548
+ type: "user",
549
+ timestamp: r.timestamp,
550
+ content: normalizeContent(r.message?.content ?? [])
551
+ }],
552
+ toolCalls: [],
553
+ cacheWriteType: "none",
554
+ cacheReadType: "unknown",
555
+ cacheCreationTokensThisTurn: 0
556
+ }));
557
+ return [...matched, ...syntheticTurns].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
558
+ }
559
+ /**
560
+ * Normalize content blocks to MessageContent[]
561
+ */
562
+ function normalizeContent(content) {
563
+ if (typeof content === "string") return [{
564
+ type: "text",
565
+ text: content
566
+ }];
567
+ return content.filter((block) => {
568
+ if (block.type === "thinking") return false;
569
+ return true;
570
+ }).map((block) => {
571
+ const type = block.type;
572
+ if (type === "text") return {
573
+ type: "text",
574
+ text: String(block.text ?? "")
575
+ };
576
+ if (type === "tool_use") return {
577
+ type: "tool_use",
578
+ name: String(block.name ?? ""),
579
+ input: block.input ?? {},
580
+ toolUseId: String(block.id ?? block.toolUseId ?? "")
581
+ };
582
+ if (type === "tool_result") return {
583
+ type: "tool_result",
584
+ toolUseId: String(block.tool_use_id ?? block.toolUseId ?? ""),
585
+ content: block.content ?? "",
586
+ isError: block.is_error ?? block.isError
587
+ };
588
+ return {
589
+ type: "text",
590
+ text: JSON.stringify(block)
591
+ };
592
+ });
593
+ }
594
+ /**
595
+ * Infer cache read type based on timing between turns.
596
+ * Default: 5m (most common TTL).
597
+ */
598
+ function inferCacheReadType(turnIndex, turns, currentTurnTime) {
599
+ try {
600
+ if (turnIndex === 0) return "5m";
601
+ const currentTime = new Date(currentTurnTime).getTime();
602
+ if (Number.isNaN(currentTime)) return "unknown";
603
+ const prevTurn = turns[turnIndex - 1];
604
+ const prevTime = new Date(prevTurn.timestamp).getTime();
605
+ if (Number.isNaN(prevTime)) return "unknown";
606
+ const timeDiff = currentTime - prevTime;
607
+ if (prevTurn.cacheWriteType === "1h" && timeDiff < 3600 * 1e3) return "1h";
608
+ if (prevTurn.cacheWriteType === "5m" && timeDiff < 300 * 1e3) return "5m";
609
+ return "5m";
610
+ } catch {
611
+ return "unknown";
612
+ }
613
+ }
614
+ /**
615
+ * Extract commandExecuted from the first user message.
616
+ * Looks for <command-name>/...</command-name> tags in content.
617
+ */
618
+ function extractCommandExecuted(messages) {
619
+ const firstUser = messages.find((m) => m.type === "user");
620
+ if (!firstUser) return void 0;
621
+ const content = firstUser.message?.content;
622
+ if (typeof content !== "string") return void 0;
623
+ return content.match(/<command-name>([\s\S]*?)<\/command-name>/)?.[1]?.trim() || void 0;
624
+ }
625
+ /**
626
+ * Extract full timeline for a session by merging SQLite and JSONL data.
627
+ */
628
+ async function extractFullTimeline(sessionId, dbPath, projectsDir) {
629
+ const session = getSession(dbPath, sessionId);
630
+ const turns = getTurns(dbPath, sessionId);
631
+ const jsonlResult = parseSessionJsonl(resolveSessionJsonlPath(session, projectsDir), sessionId);
632
+ const matchedTurns = matchTurnsToMessages(turns, jsonlResult?.rawMessages ?? [], jsonlResult?.toolCalls);
633
+ const enrichedTurns = matchedTurns.map((turn, i) => ({
634
+ ...turn,
635
+ cacheReadType: inferCacheReadType(i, matchedTurns, turn.timestamp)
636
+ }));
637
+ const pricing = calculateSessionCost(session, enrichedTurns);
638
+ const contextStats = computeContextStats(jsonlResult?.rawMessages ?? []);
639
+ const commandExecuted = extractCommandExecuted(jsonlResult?.rawMessages ?? []);
640
+ const { isOngoing } = detectSessionState(jsonlResult?.rawMessages ?? []);
641
+ const subagents = resolveSubagents(listSubagentFiles(projectsDir, session.projectName, sessionId), jsonlResult?.toolCalls ?? []);
642
+ for (const sub of subagents) if (sub.parentTaskId) {
643
+ for (let i = 0; i < enrichedTurns.length; i++) if (enrichedTurns[i].toolCalls.some((tc) => tc.toolUseId === sub.parentTaskId)) {
644
+ sub.parentTurnIndex = i;
645
+ break;
646
+ }
647
+ }
648
+ const conversationGroups = buildConversationGroups(enrichedTurns);
649
+ const activeDurationMs = computeActiveDurationMs(enrichedTurns);
650
+ const agentTotalCost = subagents.reduce((sum, s) => sum + (s.totalCost ?? 0), 0);
651
+ if (agentTotalCost > 0) {
652
+ pricing.estimatedTotalCost += agentTotalCost;
653
+ pricing.totalCost = pricing.costSource === "api" ? pricing.apiTotalCost ?? pricing.estimatedTotalCost : pricing.estimatedTotalCost;
654
+ for (const sub of subagents) {
655
+ const model = normalizeModelName(sub.model ?? "unknown");
656
+ if (!pricing.modelBreakdown[model]) pricing.modelBreakdown[model] = {
657
+ inputTokens: 0,
658
+ outputTokens: 0,
659
+ cacheReadTokens: 0,
660
+ cacheCreationTokens: 0,
661
+ cost: 0,
662
+ turnCount: 0
663
+ };
664
+ const entry = pricing.modelBreakdown[model];
665
+ if (sub.totalTokens) {
666
+ entry.inputTokens += sub.totalTokens.inputTokens;
667
+ entry.outputTokens += sub.totalTokens.outputTokens;
668
+ entry.cacheReadTokens += sub.totalTokens.cacheReadTokens;
669
+ entry.cacheCreationTokens += sub.totalTokens.cacheCreation5mTokens + sub.totalTokens.cacheCreation1hTokens;
670
+ }
671
+ entry.cost += sub.totalCost ?? 0;
672
+ entry.turnCount += sub.turnCount;
673
+ }
674
+ }
675
+ return {
676
+ session: {
677
+ ...session,
678
+ commandExecuted,
679
+ isOngoing,
680
+ activeDurationMs
681
+ },
682
+ turns: enrichedTurns,
683
+ pricing,
684
+ contextStats,
685
+ ...subagents.length > 0 ? { subagents } : {},
686
+ ...conversationGroups.length > 0 ? { conversationGroups } : {}
687
+ };
688
+ }
689
+ /**
690
+ * Build synthetic Turn[] from JSONL records when no SQLite turns exist.
691
+ * Groups consecutive assistant messages into turns, each preceded by the
692
+ * nearest user message.
693
+ */
694
+ function buildTurnsFromJsonl(rawMessages, toolCalls) {
695
+ const turns = [];
696
+ const turnEntries = [];
697
+ for (let i = 0; i < rawMessages.length; i++) {
698
+ const m = rawMessages[i];
699
+ const category = classifyMessage(m);
700
+ if (category !== "assistant" && category !== "user") continue;
701
+ if (!m.timestamp) continue;
702
+ turnEntries.push({
703
+ index: i,
704
+ timestamp: new Date(m.timestamp).getTime()
705
+ });
706
+ }
707
+ const toolCallsByTurn = /* @__PURE__ */ new Map();
708
+ const MAX_TC_WINDOW = 5e3;
709
+ for (const tc of toolCalls) {
710
+ if (!tc.timestamp) continue;
711
+ const tcTime = new Date(tc.timestamp).getTime();
712
+ let bestIdx = -1;
713
+ let bestDiff = Number.MAX_VALUE;
714
+ for (const entry of turnEntries) {
715
+ const diff = Math.abs(entry.timestamp - tcTime);
716
+ if (diff < bestDiff && diff < MAX_TC_WINDOW) {
717
+ bestDiff = diff;
718
+ bestIdx = entry.index;
719
+ }
720
+ }
721
+ if (bestIdx >= 0) {
722
+ let arr = toolCallsByTurn.get(bestIdx);
723
+ if (!arr) {
724
+ arr = [];
725
+ toolCallsByTurn.set(bestIdx, arr);
726
+ }
727
+ arr.push(tc);
728
+ }
729
+ }
730
+ for (let i = 0; i < rawMessages.length; i++) {
731
+ const m = rawMessages[i];
732
+ const category = classifyMessage(m);
733
+ if (category !== "assistant" && category !== "user") continue;
734
+ const matchedToolCalls = toolCallsByTurn.get(i) ?? [];
735
+ const usage = m.message?.usage;
736
+ if ((usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0) + (usage?.cache_read_input_tokens ?? 0) + (usage?.cache_creation?.ephemeral_5m_input_tokens ?? 0) + (usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0) === 0 && matchedToolCalls.length === 0) {
737
+ const content = m.message?.content;
738
+ if (!(typeof content === "string" && content.length > 0 && !content.includes("No response requested") || Array.isArray(content) && content.some((b) => b.type === "tool_use" || b.type === "tool_result"))) continue;
739
+ }
740
+ const cc5m = usage?.cacheCreation5mTokens ?? usage?.cache_creation?.ephemeral_5m_input_tokens ?? 0;
741
+ const cc1h = usage?.cacheCreation1hTokens ?? usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0;
742
+ turns.push({
743
+ timestamp: m.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
744
+ tokenUsage: {
745
+ inputTokens: usage?.input_tokens ?? 0,
746
+ outputTokens: usage?.output_tokens ?? 0,
747
+ cacheReadTokens: usage?.cache_read_input_tokens ?? 0,
748
+ cacheCreation5mTokens: cc5m,
749
+ cacheCreation1hTokens: cc1h
750
+ },
751
+ model: m.message?.model,
752
+ messages: [{
753
+ type: category === "user" ? "user" : "assistant",
754
+ timestamp: m.timestamp,
755
+ content: normalizeContent(m.message?.content ?? [])
756
+ }],
757
+ toolCalls: matchedToolCalls,
758
+ cacheWriteType: cc5m > 0 ? "5m" : cc1h > 0 ? "1h" : "none",
759
+ cacheReadType: "unknown",
760
+ cacheCreationTokensThisTurn: cc5m + cc1h
761
+ });
762
+ }
763
+ return turns.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
764
+ }
765
+ /**
766
+ * Extract full timeline from JSONL only (no SQLite).
767
+ * Used when a session exists on disk but hasn't been indexed into usage.db.
768
+ */
769
+ async function extractJsonlTimeline(sessionId, projectsDir, jsonlPath) {
770
+ const jsonlResult = parseSessionJsonl(jsonlPath, sessionId);
771
+ const rawMessages = jsonlResult?.rawMessages ?? [];
772
+ const { isOngoing } = detectSessionState(rawMessages);
773
+ let model = "claude-sonnet-4-6";
774
+ for (const m of rawMessages) if (m.type === "assistant" && m.message?.model) {
775
+ model = m.message.model;
776
+ break;
777
+ }
778
+ const turnCount = rawMessages.filter((m) => m.type === "user" && !m.isMeta).length;
779
+ const totalTokens = {
780
+ inputTokens: 0,
781
+ outputTokens: 0,
782
+ cacheReadTokens: 0,
783
+ cacheCreation5mTokens: 0,
784
+ cacheCreation1hTokens: 0
785
+ };
786
+ for (const m of rawMessages) {
787
+ const u = m.message?.usage;
788
+ if (!u) continue;
789
+ totalTokens.inputTokens += u.input_tokens ?? 0;
790
+ totalTokens.outputTokens += u.output_tokens ?? 0;
791
+ totalTokens.cacheReadTokens += u.cache_read_input_tokens ?? 0;
792
+ const cc = u.cache_creation;
793
+ totalTokens.cacheCreation5mTokens += u.cacheCreation5mTokens ?? cc?.ephemeral_5m_input_tokens ?? 0;
794
+ totalTokens.cacheCreation1hTokens += u.cacheCreation1hTokens ?? cc?.ephemeral_1h_input_tokens ?? 0;
795
+ }
796
+ const encodedProject = jsonlPath.replace(projectsDir, "").split("/").filter(Boolean)[0] ?? "unknown";
797
+ const projectName = encodedProject.startsWith("-") ? encodedProject.slice(1) : encodedProject;
798
+ const turns = buildTurnsFromJsonl(rawMessages, jsonlResult?.toolCalls ?? []);
799
+ const enrichedTurns = turns.map((turn, i) => ({
800
+ ...turn,
801
+ cacheReadType: inferCacheReadType(i, turns, turn.timestamp)
802
+ }));
803
+ const meaningfulTurns = enrichedTurns.filter(hasTurnContent);
804
+ const firstMeaningful = meaningfulTurns[0];
805
+ const lastMeaningful = meaningfulTurns[meaningfulTurns.length - 1];
806
+ const sessionStartTime = firstMeaningful?.timestamp ?? rawMessages[0]?.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
807
+ const sessionEndTime = lastMeaningful?.timestamp ?? rawMessages[rawMessages.length - 1]?.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
808
+ const session = {
809
+ sessionId,
810
+ projectName,
811
+ model,
812
+ workingDirectory: "",
813
+ turnCount,
814
+ totalTokens,
815
+ startTime: sessionStartTime,
816
+ endTime: sessionEndTime,
817
+ isOngoing
818
+ };
819
+ const pricing = calculateSessionCost(session, enrichedTurns);
820
+ const contextStats = computeContextStats(rawMessages);
821
+ const commandExecuted = extractCommandExecuted(rawMessages);
822
+ const subagents = resolveSubagents(listSubagentFiles(projectsDir, projectName, sessionId), jsonlResult?.toolCalls ?? []);
823
+ for (const sub of subagents) if (sub.parentTaskId) {
824
+ for (let i = 0; i < enrichedTurns.length; i++) if (enrichedTurns[i].toolCalls.some((tc) => tc.toolUseId === sub.parentTaskId)) {
825
+ sub.parentTurnIndex = i;
826
+ break;
827
+ }
828
+ }
829
+ const conversationGroups = buildConversationGroups(enrichedTurns);
830
+ const activeDurationMs = computeActiveDurationMs(enrichedTurns);
831
+ const agentTotalCost = subagents.reduce((sum, s) => sum + (s.totalCost ?? 0), 0);
832
+ if (agentTotalCost > 0) {
833
+ pricing.estimatedTotalCost += agentTotalCost;
834
+ pricing.totalCost = pricing.costSource === "api" ? pricing.apiTotalCost ?? pricing.estimatedTotalCost : pricing.estimatedTotalCost;
835
+ for (const sub of subagents) {
836
+ const model = normalizeModelName(sub.model ?? "unknown");
837
+ if (!pricing.modelBreakdown[model]) pricing.modelBreakdown[model] = {
838
+ inputTokens: 0,
839
+ outputTokens: 0,
840
+ cacheReadTokens: 0,
841
+ cacheCreationTokens: 0,
842
+ cost: 0,
843
+ turnCount: 0
844
+ };
845
+ const entry = pricing.modelBreakdown[model];
846
+ if (sub.totalTokens) {
847
+ entry.inputTokens += sub.totalTokens.inputTokens;
848
+ entry.outputTokens += sub.totalTokens.outputTokens;
849
+ entry.cacheReadTokens += sub.totalTokens.cacheReadTokens;
850
+ entry.cacheCreationTokens += sub.totalTokens.cacheCreation5mTokens + sub.totalTokens.cacheCreation1hTokens;
851
+ }
852
+ entry.cost += sub.totalCost ?? 0;
853
+ entry.turnCount += sub.turnCount;
854
+ }
855
+ }
856
+ return {
857
+ session: {
858
+ ...session,
859
+ commandExecuted,
860
+ isOngoing,
861
+ activeDurationMs
862
+ },
863
+ turns: enrichedTurns,
864
+ pricing,
865
+ contextStats,
866
+ ...subagents.length > 0 ? { subagents } : {},
867
+ ...conversationGroups.length > 0 ? { conversationGroups } : {}
868
+ };
869
+ }
870
+ //#endregion
871
+ //#region src/index.ts
872
+ /**
873
+ * Parse CLI arguments.
874
+ * Required: --session-id
875
+ * Optional: --db-path, --projects-dir, --output
876
+ */
877
+ function parseArgs(argv) {
878
+ const args = minimist(argv.slice(2));
879
+ const listSessions = Boolean(args["list-sessions"]);
880
+ if (!listSessions && !args["session-id"]) throw new Error("Error: --session-id is required (or use --list-sessions).\nUsage: tsx src/index.ts --session-id <id> [options]\n tsx src/index.ts --list-sessions\nOptions:\n --db-path <path> SQLite DB path (default: ~/.claude/usage.db)\n --projects-dir <path> Projects directory (default: ~/.claude/projects)\n --output <path> Write JSON to file instead of stdout\n --list-sessions List recent sessions and exit");
881
+ return {
882
+ sessionId: args["session-id"] || null,
883
+ dbPath: args["db-path"] || getDbPath(),
884
+ projectsDir: args["projects-dir"] || getProjectsDir(),
885
+ outputPath: args.output || null,
886
+ listSessions
887
+ };
888
+ }
889
+ /**
890
+ * Output JSON to stdout or file.
891
+ */
892
+ function outputJSON(data, outputPath) {
893
+ const json = JSON.stringify(data, null, 2);
894
+ if (outputPath) try {
895
+ fs.writeFileSync(outputPath, json, "utf-8");
896
+ console.log(`Output written to: ${outputPath}`);
897
+ } catch (err) {
898
+ console.error(`Failed to write output file: ${outputPath}`);
899
+ console.error(String(err));
900
+ console.log(json);
901
+ }
902
+ else console.log(json);
903
+ }
904
+ /**
905
+ * Main entry point.
906
+ */
907
+ async function main() {
908
+ const config = parseArgs(process.argv);
909
+ if (config.listSessions) {
910
+ const { listSessions, listJsonlSessions } = await import("./db-reader-CPXmkt55.mjs");
911
+ const dbSessions = listSessions(config.dbPath);
912
+ const jsonlSessions = listJsonlSessions(config.projectsDir, config.dbPath);
913
+ const seen = new Set(dbSessions.map((s) => s.sessionId));
914
+ const merged = [...dbSessions];
915
+ for (const s of jsonlSessions) if (!seen.has(s.sessionId)) {
916
+ merged.push(s);
917
+ seen.add(s.sessionId);
918
+ }
919
+ merged.sort((a, b) => b.lastTimestamp.localeCompare(a.lastTimestamp));
920
+ outputJSON(merged, config.outputPath);
921
+ return;
922
+ }
923
+ const sessionId = config.sessionId;
924
+ let data;
925
+ try {
926
+ data = await extractFullTimeline(sessionId, config.dbPath, config.projectsDir);
927
+ } catch (err) {
928
+ if (err.code === 2) {
929
+ let foundPath = null;
930
+ for (const dir of fs.readdirSync(config.projectsDir)) {
931
+ const candidate = path.join(config.projectsDir, dir, `${sessionId}.jsonl`);
932
+ if (fs.existsSync(candidate)) {
933
+ foundPath = candidate;
934
+ break;
935
+ }
936
+ }
937
+ if (foundPath) data = await extractJsonlTimeline(sessionId, config.projectsDir, foundPath);
938
+ else throw err;
939
+ } else throw err;
940
+ }
941
+ outputJSON(data, config.outputPath);
942
+ }
943
+ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) main().catch((err) => {
944
+ console.error(err.message || err);
945
+ process.exit(1);
946
+ });
947
+ //#endregion
948
+ //#region src/cli.ts
949
+ /**
950
+ * claude-timeline CLI — built with Commander.js
951
+ *
952
+ * npx claude-timeline → opens browser with timeline
953
+ * npx claude-timeline serve [--port] → start server only
954
+ * npx claude-timeline extract --session-id <id> → extract to JSON
955
+ * npx claude-timeline list → list available sessions
956
+ * npx claude-timeline setup → install cost-capture statusline
957
+ */
958
+ let chalk;
959
+ try {
960
+ chalk = (await import("chalk")).default;
961
+ } catch {
962
+ chalk = new Proxy({}, { get: (_t, prop) => {
963
+ if (typeof prop === "symbol") return () => "";
964
+ return (...args) => String(args[0]);
965
+ } });
966
+ }
967
+ const HOME = homedir();
968
+ const TIMELINE_DIR = path.join(HOME, ".claude-timeline");
969
+ const CONFIG_PATH = path.join(TIMELINE_DIR, "config.json");
970
+ const DISMISSED_PATH = path.join(TIMELINE_DIR, ".setup-dismissed");
971
+ path.join(HOME, ".claude", "settings.json");
972
+ /** Resolve paths to the bundled dist files next to this CLI module. */
973
+ function getDistDir() {
974
+ return path.dirname(new URL(import.meta.url).pathname);
975
+ }
976
+ function isCostCaptureInstalled() {
977
+ try {
978
+ if (!fs.existsSync(CONFIG_PATH)) return false;
979
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
980
+ return !!(config && config.originalStatusLine);
981
+ } catch {
982
+ return false;
983
+ }
984
+ }
985
+ /** Default Claude data paths */
986
+ function getDefaultPaths() {
987
+ const home = homedir();
988
+ return {
989
+ dbPath: path.join(home, ".claude", "usage.db"),
990
+ projectsDir: path.join(home, ".claude", "projects")
991
+ };
992
+ }
993
+ /** Check if a path exists and return file size / dir count */
994
+ function inspectPath(p) {
995
+ try {
996
+ const stat = statSync(p);
997
+ if (stat.isDirectory()) {
998
+ const count = readdirSync(p).length;
999
+ return {
1000
+ exists: true,
1001
+ detail: `${count} project${count !== 1 ? "s" : ""}`
1002
+ };
1003
+ }
1004
+ return {
1005
+ exists: true,
1006
+ detail: `${(stat.size / 1024).toFixed(0)} KB`
1007
+ };
1008
+ } catch {
1009
+ return { exists: false };
1010
+ }
1011
+ }
1012
+ /** Print startup status before server launches */
1013
+ function printStartupStatus(port) {
1014
+ const paths = getDefaultPaths();
1015
+ const db = inspectPath(paths.dbPath);
1016
+ const projects = inspectPath(paths.projectsDir);
1017
+ let sessionCount = 0;
1018
+ if (db.exists || projects.exists) try {
1019
+ const dbSessions = db.exists ? listSessions(paths.dbPath) : [];
1020
+ const jsonlSessions = projects.exists ? listJsonlSessions(paths.projectsDir, paths.dbPath) : [];
1021
+ const seen = new Set(dbSessions.map((s) => s.sessionId));
1022
+ for (const s of jsonlSessions) if (!seen.has(s.sessionId)) seen.add(s.sessionId);
1023
+ sessionCount = seen.size;
1024
+ } catch {}
1025
+ console.log("");
1026
+ console.log(" ⚡ claude-timeline");
1027
+ console.log("");
1028
+ console.log(` → http://localhost:${port}`);
1029
+ console.log("");
1030
+ const dbIcon = db.exists ? "✓" : "✗";
1031
+ const projIcon = projects.exists ? "✓" : "✗";
1032
+ const sessionIcon = sessionCount > 0 ? "✓" : "–";
1033
+ const capIcon = isCostCaptureInstalled() ? "✓" : "✗";
1034
+ console.log(" ┌─ Status ─────────────────────────────────────┐");
1035
+ console.log(` │ ${dbIcon} Database ${paths.dbPath}`);
1036
+ if (db.exists && db.detail) console.log(` │ ${db.detail}`);
1037
+ console.log(` │ ${projIcon} Projects ${paths.projectsDir}`);
1038
+ if (projects.exists && projects.detail) console.log(` │ ${projects.detail}`);
1039
+ console.log(` │ ${sessionIcon} Sessions ${sessionCount} found`);
1040
+ console.log(` │ ${capIcon} Cost capture ${isCostCaptureInstalled() ? "installed" : "not installed"}`);
1041
+ console.log(" └──────────────────────────────────────────────┘");
1042
+ console.log("");
1043
+ if (!isCostCaptureInstalled() && !fs.existsSync(DISMISSED_PATH)) {
1044
+ console.log(" 💡 Tip: Run `claude-timeline setup` for real-time cost tracking");
1045
+ console.log("");
1046
+ try {
1047
+ fs.mkdirSync(TIMELINE_DIR, { recursive: true });
1048
+ writeFileSync(DISMISSED_PATH, (/* @__PURE__ */ new Date()).toISOString(), "utf-8");
1049
+ } catch {}
1050
+ }
1051
+ }
1052
+ /** Open URL in the default browser. macOS → `open`, Linux → `xdg-open`, Windows → `start`. */
1053
+ function openBrowser(url) {
1054
+ try {
1055
+ const platform = process.platform;
1056
+ if (platform === "darwin") execSync(`open "${url}"`, { stdio: "ignore" });
1057
+ else if (platform === "linux") execSync(`xdg-open "${url}" 2>/dev/null || true`, { stdio: "ignore" });
1058
+ else if (platform === "win32") execSync(`start "" "${url}"`, { stdio: "ignore" });
1059
+ } catch {}
1060
+ }
1061
+ /**
1062
+ * Run the cost-capture setup:
1063
+ * 1. Check Claude Code installation
1064
+ * 2. Read existing settings, save original statusLine
1065
+ * 3. Create ~/.claude-timeline/ dir
1066
+ * 4. Install better-sqlite3 runtime dependency
1067
+ * 5. Copy capture.js + db.js from dist/
1068
+ * 6. Update settings.json to wrap original statusLine
1069
+ */
1070
+ async function runSetup() {
1071
+ const distDir = getDistDir();
1072
+ const captureSrc = path.join(distDir, "capture.js");
1073
+ const dbSrc = path.join(distDir, "db.js");
1074
+ const captureDst = path.join(TIMELINE_DIR, "capture.js");
1075
+ const dbDst = path.join(TIMELINE_DIR, "db.js");
1076
+ const claudeDir = path.join(HOME, ".claude");
1077
+ const settingsPath = path.join(claudeDir, "settings.json");
1078
+ const step = (label) => process.stdout.write(` ${chalk.gray("│")} ${label} `);
1079
+ const ok = () => console.log(chalk.green("✓"));
1080
+ const fail = (reason) => console.log(chalk.red(`✗ ${reason}`));
1081
+ const info = (msg) => console.log(chalk.gray(` ${msg}`));
1082
+ console.log("");
1083
+ console.log(chalk.bold(" claude-timeline ") + chalk.gray("setup"));
1084
+ console.log("");
1085
+ step("Checking Claude Code installation...");
1086
+ if (!existsSync(claudeDir)) {
1087
+ fail("~/.claude not found. Is Claude Code installed?");
1088
+ return;
1089
+ }
1090
+ ok();
1091
+ step("Reading Claude Code settings...");
1092
+ let settings = {};
1093
+ if (existsSync(settingsPath)) try {
1094
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
1095
+ } catch {
1096
+ info("Could not parse settings.json — will create new one");
1097
+ }
1098
+ const existingStatusLine = settings.statusLine;
1099
+ if (existingStatusLine) info(`Found existing statusLine: ${existingStatusLine.command ?? JSON.stringify(existingStatusLine)}`);
1100
+ else info("No existing statusLine found");
1101
+ ok();
1102
+ step("Setting up cost capture directory...");
1103
+ try {
1104
+ mkdirSync(TIMELINE_DIR, { recursive: true });
1105
+ ok();
1106
+ } catch (e) {
1107
+ fail(e.message);
1108
+ return;
1109
+ }
1110
+ step("Configuring statusLine wrapper...");
1111
+ const configPath = CONFIG_PATH;
1112
+ const existingConfig = existsSync(configPath) ? (() => {
1113
+ try {
1114
+ return JSON.parse(readFileSync(configPath, "utf-8"));
1115
+ } catch {
1116
+ return {};
1117
+ }
1118
+ })() : {};
1119
+ if (existingStatusLine && !existingConfig.originalStatusLine) {
1120
+ existingConfig.originalStatusLine = existingStatusLine;
1121
+ writeFileSync(configPath, JSON.stringify(existingConfig, null, 2) + "\n", "utf-8");
1122
+ info("Original statusLine saved — will be wrapped transparently");
1123
+ } else if (existingConfig.originalStatusLine) info("Original statusLine already saved");
1124
+ else info("No existing statusLine to save");
1125
+ ok();
1126
+ step("Installing runtime dependencies...");
1127
+ try {
1128
+ const { execSync } = await import("node:child_process");
1129
+ execSync(`cd "${TIMELINE_DIR}" && cat > package.json << 'PKGJSON'\n{"name":"claude-timeline-runtime","private":true,"type":"module","dependencies":{"better-sqlite3":"^11.0.0"}}\nPKGJSON\nnpm install --silent 2>/dev/null || true`, { stdio: "pipe" });
1130
+ ok();
1131
+ } catch {
1132
+ info("Could not install runtime deps — will try on first use");
1133
+ ok();
1134
+ }
1135
+ step("Installing capture script...");
1136
+ try {
1137
+ if (!existsSync(captureSrc)) {
1138
+ fail(`not found at ${captureSrc} — run build first`);
1139
+ return;
1140
+ }
1141
+ copyFileSync(captureSrc, captureDst);
1142
+ ok();
1143
+ } catch (e) {
1144
+ fail(e.message);
1145
+ return;
1146
+ }
1147
+ step("Installing database script...");
1148
+ try {
1149
+ if (!existsSync(dbSrc)) {
1150
+ fail(`not found at ${dbSrc} — run build first`);
1151
+ return;
1152
+ }
1153
+ copyFileSync(dbSrc, dbDst);
1154
+ ok();
1155
+ } catch (e) {
1156
+ fail(e.message);
1157
+ return;
1158
+ }
1159
+ step("Updating Claude Code settings...");
1160
+ try {
1161
+ settings.statusLine = {
1162
+ type: "command",
1163
+ command: `node "${captureDst}"`
1164
+ };
1165
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
1166
+ ok();
1167
+ } catch (e) {
1168
+ fail(e.message);
1169
+ return;
1170
+ }
1171
+ console.log("");
1172
+ const lines = [
1173
+ chalk.green(" Setup complete!"),
1174
+ "",
1175
+ chalk.gray(" Cost data will be captured automatically from"),
1176
+ chalk.gray(" all Claude Code sessions."),
1177
+ "",
1178
+ existingStatusLine ? chalk.gray(" Your original statusline is preserved and") : chalk.gray(" Restart Claude Code to activate."),
1179
+ existingStatusLine ? chalk.gray(" It will continue to work as before.") : "",
1180
+ "",
1181
+ chalk.gray(" Data stored: ") + chalk.cyan("~/.claude-timeline/cost-stream.db")
1182
+ ];
1183
+ const maxLen = Math.max(...lines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, "").length));
1184
+ console.log(" ┌" + "─".repeat(maxLen + 2) + "┐");
1185
+ for (const line of lines) {
1186
+ const stripped = line.replace(/\x1b\[[0-9;]*m/g, "");
1187
+ console.log(" │ " + line + " ".repeat(Math.max(0, maxLen - stripped.length)) + " │");
1188
+ }
1189
+ console.log(" └" + "─".repeat(maxLen + 2) + "┘");
1190
+ console.log("");
1191
+ }
1192
+ /** Prompt the user to install cost-capture if not already set up. */
1193
+ function promptSetup() {
1194
+ return new Promise((resolve) => {
1195
+ if (!process.stdin.isTTY) return resolve(false);
1196
+ if (fs.existsSync(DISMISSED_PATH)) return resolve(false);
1197
+ const rl = createInterface({
1198
+ input: process.stdin,
1199
+ output: process.stdout
1200
+ });
1201
+ rl.question(chalk.yellow("\n Cost capture not installed. Run `claude-timeline setup` to get real-time cost data? [Y/n] "), (answer) => {
1202
+ rl.close();
1203
+ const normalized = answer.trim().toLowerCase();
1204
+ if (normalized === "" || normalized === "y" || normalized === "yes") resolve(true);
1205
+ else {
1206
+ try {
1207
+ fs.mkdirSync(TIMELINE_DIR, { recursive: true });
1208
+ writeFileSync(DISMISSED_PATH, (/* @__PURE__ */ new Date()).toISOString(), "utf-8");
1209
+ } catch {}
1210
+ resolve(false);
1211
+ }
1212
+ });
1213
+ });
1214
+ }
1215
+ async function startServer(port, open) {
1216
+ process.env.PORT = String(port);
1217
+ const serverPath = path.join(path.dirname(new URL(import.meta.url).pathname), "server.cjs");
1218
+ if (!fs.existsSync(serverPath)) {
1219
+ console.error("Error: server.cjs not found. Run `pnpm build` first.");
1220
+ process.exit(1);
1221
+ }
1222
+ if (!isCostCaptureInstalled()) {
1223
+ if (await promptSetup()) await runSetup();
1224
+ }
1225
+ printStartupStatus(port);
1226
+ if (open) setTimeout(() => openBrowser(`http://localhost:${port}`), 1500);
1227
+ await import(serverPath);
1228
+ }
1229
+ const program = new Command().name("claude-timeline").description("Claude Code session visualizer — see your sessions, costs, and timeline").version("1.0.0");
1230
+ program.option("--no-open", "Don't open browser automatically").option("-p, --port <port>", "Server port", "5199").action(async (opts) => {
1231
+ await startServer(Number(opts.port), opts.open !== false);
1232
+ });
1233
+ program.command("serve").description("Start the API server + web UI").option("-p, --port <port>", "Server port", "5199").option("--no-open", "Don't open browser automatically").action(async (opts) => {
1234
+ await startServer(Number(opts.port), opts.open !== false);
1235
+ });
1236
+ program.command("extract").description("Extract a session to JSON").requiredOption("-s, --session-id <id>", "Session ID to extract").option("--db-path <path>", "SQLite DB path").option("--projects-dir <dir>", "Projects directory").option("-o, --output <path>", "Write JSON to file instead of stdout").action(async (opts) => {
1237
+ const fakeArgv = ["node", "cli"];
1238
+ if (opts.sessionId) fakeArgv.push("--session-id", opts.sessionId);
1239
+ if (opts.dbPath) fakeArgv.push("--db-path", opts.dbPath);
1240
+ if (opts.projectsDir) fakeArgv.push("--projects-dir", opts.projectsDir);
1241
+ if (opts.output) fakeArgv.push("--output", opts.output);
1242
+ const originalArgv = process.argv;
1243
+ process.argv = fakeArgv;
1244
+ try {
1245
+ const config = parseArgs(process.argv);
1246
+ const sessionId = config.sessionId;
1247
+ let data;
1248
+ try {
1249
+ data = await extractFullTimeline(sessionId, config.dbPath, config.projectsDir);
1250
+ } catch (err) {
1251
+ if (err.code === 2) {
1252
+ let foundPath = null;
1253
+ for (const dir of fs.readdirSync(config.projectsDir)) {
1254
+ const candidate = path.join(config.projectsDir, dir, `${sessionId}.jsonl`);
1255
+ if (fs.existsSync(candidate)) {
1256
+ foundPath = candidate;
1257
+ break;
1258
+ }
1259
+ }
1260
+ if (foundPath) data = await extractJsonlTimeline(sessionId, config.projectsDir, foundPath);
1261
+ else throw err;
1262
+ } else throw err;
1263
+ }
1264
+ outputJSON(data, config.outputPath);
1265
+ } finally {
1266
+ process.argv = originalArgv;
1267
+ }
1268
+ });
1269
+ program.command("list").description("List available sessions").option("--db-path <path>", "SQLite DB path").option("--projects-dir <dir>", "Projects directory").option("-o, --output <path>", "Write JSON to file instead of stdout").action((opts) => {
1270
+ const fakeArgv = [
1271
+ "node",
1272
+ "cli",
1273
+ "--list-sessions"
1274
+ ];
1275
+ if (opts.dbPath) fakeArgv.push("--db-path", opts.dbPath);
1276
+ if (opts.projectsDir) fakeArgv.push("--projects-dir", opts.projectsDir);
1277
+ if (opts.output) fakeArgv.push("--output", opts.output);
1278
+ const originalArgv = process.argv;
1279
+ process.argv = fakeArgv;
1280
+ try {
1281
+ const config = parseArgs(process.argv);
1282
+ const dbSessions = listSessions(config.dbPath);
1283
+ const jsonlSessions = listJsonlSessions(config.projectsDir, config.dbPath);
1284
+ const seen = new Set(dbSessions.map((s) => s.sessionId));
1285
+ const merged = [...dbSessions];
1286
+ for (const s of jsonlSessions) if (!seen.has(s.sessionId)) {
1287
+ merged.push(s);
1288
+ seen.add(s.sessionId);
1289
+ }
1290
+ merged.sort((a, b) => b.lastTimestamp.localeCompare(a.lastTimestamp));
1291
+ outputJSON(merged, config.outputPath);
1292
+ } finally {
1293
+ process.argv = originalArgv;
1294
+ }
1295
+ });
1296
+ program.command("setup").description("Install cost-capture statusline wrapper").action(async () => {
1297
+ await runSetup();
1298
+ });
1299
+ program.command("update-pricing").description("Fetch latest model pricing from OpenRouter and save to ~/.claude-timeline/pricing.json").action(async () => {
1300
+ const { refreshPricing } = await import("./pricing-B-rwfwDB.mjs");
1301
+ console.log("");
1302
+ console.log(" Fetching pricing from OpenRouter...");
1303
+ console.log("");
1304
+ try {
1305
+ const table = await refreshPricing();
1306
+ const modelCount = Object.keys(table).length;
1307
+ console.log(` ✓ Saved ${modelCount} model${modelCount !== 1 ? "s" : ""} to ${path.join(homedir(), ".claude-timeline", "pricing.json")}`);
1308
+ console.log("");
1309
+ console.log(" ┌─ Models ────────────────────────────────────────────────────────┐");
1310
+ for (const rate of Object.values(table)) {
1311
+ const name = rate.model.padEnd(20);
1312
+ const inp = `${rate.inputPerMTok.toFixed(2)}`.padStart(6);
1313
+ const out = `${rate.outputPerMTok.toFixed(2)}`.padStart(6);
1314
+ console.log(` │ ${name} input ${inp} output ${out} │`);
1315
+ }
1316
+ console.log(" └────────────────────────────────────────────────────────────────┘");
1317
+ console.log("");
1318
+ } catch (err) {
1319
+ console.error(` ✗ Failed: ${err.message}`);
1320
+ console.error("");
1321
+ process.exit(1);
1322
+ }
1323
+ });
1324
+ program.parse();
1325
+ //#endregion
1326
+ export {};
1327
+
1328
+ //# sourceMappingURL=cli.mjs.map