@ebowwa/claudecodehistory 1.5.1 → 1.6.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,739 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import { createReadStream } from 'fs';
5
+ import { createInterface } from 'readline';
6
+ import { exec } from 'child_process';
7
+ import { promisify } from 'util';
8
+ import { parseDir, CLAUDE_CODE_FIELDS } from '@ebowwa/jsonl-hft';
9
+ const execAsync = promisify(exec);
10
+ const USE_FAST_PARSER = process.env.CLAUDE_HISTORY_FAST_PARSER !== 'false';
11
+ export class ClaudeCodeHistoryService {
12
+ claudeDir;
13
+ constructor(claudeDir) {
14
+ this.claudeDir = claudeDir || path.join(os.homedir(), '.claude');
15
+ }
16
+ /**
17
+ * Normalize date string to ISO format for proper comparison with timezone support
18
+ */
19
+ normalizeDate(dateString, isEndDate = false, timezone) {
20
+ if (dateString.includes('T')) {
21
+ return dateString;
22
+ }
23
+ const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
24
+ try {
25
+ if (tz === 'UTC') {
26
+ const timeStr = isEndDate ? '23:59:59.999' : '00:00:00.000';
27
+ return `${dateString}T${timeStr}Z`;
28
+ }
29
+ // Correct approach: Create date in target timezone and convert to UTC
30
+ const [year, month, day] = dateString.split('-').map(Number);
31
+ const hour = isEndDate ? 23 : 0;
32
+ const minute = isEndDate ? 59 : 0;
33
+ const second = isEndDate ? 59 : 0;
34
+ const millisecond = isEndDate ? 999 : 0;
35
+ // Create a reference date to calculate offset
36
+ const referenceDate = new Date(year, month - 1, day, 12, 0, 0); // Use noon for stable offset
37
+ // Calculate timezone offset for this specific date (handles DST)
38
+ const offsetMs = referenceDate.getTimezoneOffset() * 60000;
39
+ // Create the target time in the specified timezone
40
+ const localTime = new Date(year, month - 1, day, hour, minute, second, millisecond);
41
+ // Get what this local time would be in the target timezone
42
+ const targetTzTime = new Date(localTime.toLocaleString('en-CA', { timeZone: tz }));
43
+ const utcTime = new Date(localTime.toLocaleString('en-CA', { timeZone: 'UTC' }));
44
+ // Calculate the difference between target timezone and UTC
45
+ const tzOffsetMs = targetTzTime.getTime() - utcTime.getTime();
46
+ // Adjust local time to get UTC equivalent
47
+ const utcResult = new Date(localTime.getTime() + offsetMs - tzOffsetMs);
48
+ const result = utcResult.toISOString();
49
+ console.log(`normalizeDate: ${dateString} (${isEndDate ? 'end' : 'start'}) in ${tz} -> ${result}`);
50
+ return result;
51
+ }
52
+ catch (error) {
53
+ console.warn(`Failed to process timezone ${tz}, falling back to simple conversion:`, error);
54
+ const fallback = `${dateString}T${isEndDate ? '23:59:59.999' : '00:00:00.000'}Z`;
55
+ console.log(`normalizeDate fallback: ${dateString} -> ${fallback}`);
56
+ return fallback;
57
+ }
58
+ }
59
+ async getConversationHistory(options = {}) {
60
+ const { sessionId, startDate, endDate, limit = 20, offset = 0, timezone, messageTypes } = options;
61
+ // Normalize date strings for proper comparison
62
+ const normalizedStartDate = startDate ? this.normalizeDate(startDate, false, timezone) : undefined;
63
+ const normalizedEndDate = endDate ? this.normalizeDate(endDate, true, timezone) : undefined;
64
+ // Determine which message types to include (default to user only to reduce data volume)
65
+ const allowedTypes = messageTypes && messageTypes.length > 0 ? messageTypes : ['user'];
66
+ // Load history from Claude Code's .jsonl files with pre-filtering
67
+ let allEntries = await this.loadClaudeHistoryEntries({
68
+ startDate: normalizedStartDate,
69
+ endDate: normalizedEndDate
70
+ });
71
+ // Filter by session ID if specified
72
+ if (sessionId) {
73
+ allEntries = allEntries.filter(entry => entry.sessionId === sessionId);
74
+ }
75
+ // Filter by message types (defaults to user only)
76
+ allEntries = allEntries.filter(entry => allowedTypes.includes(entry.type));
77
+ // Filter by date range if specified (additional in-memory filtering for precision)
78
+ if (normalizedStartDate) {
79
+ allEntries = allEntries.filter(entry => entry.timestamp >= normalizedStartDate);
80
+ }
81
+ if (normalizedEndDate) {
82
+ allEntries = allEntries.filter(entry => entry.timestamp <= normalizedEndDate);
83
+ }
84
+ // Sort by timestamp (newest first)
85
+ allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
86
+ // Calculate pagination
87
+ const totalCount = allEntries.length;
88
+ const paginatedEntries = allEntries.slice(offset, offset + limit);
89
+ const hasMore = offset + limit < totalCount;
90
+ return {
91
+ entries: paginatedEntries,
92
+ pagination: {
93
+ total_count: totalCount,
94
+ limit,
95
+ offset,
96
+ has_more: hasMore
97
+ }
98
+ };
99
+ }
100
+ async searchConversations(searchQuery, options = {}) {
101
+ const { limit = 30, projectPath, startDate, endDate, timezone } = options;
102
+ // Normalize date strings for proper comparison
103
+ const normalizedStartDate = startDate ? this.normalizeDate(startDate, false, timezone) : undefined;
104
+ const normalizedEndDate = endDate ? this.normalizeDate(endDate, true, timezone) : undefined;
105
+ const allEntries = await this.loadClaudeHistoryEntries({
106
+ startDate: normalizedStartDate,
107
+ endDate: normalizedEndDate
108
+ });
109
+ const queryLower = searchQuery.toLowerCase();
110
+ let matchedEntries = allEntries.filter(entry => entry.content.toLowerCase().includes(queryLower));
111
+ // Filter by project path if specified
112
+ if (projectPath) {
113
+ matchedEntries = matchedEntries.filter(entry => entry.projectPath === projectPath);
114
+ }
115
+ // Filter by date range if specified (additional in-memory filtering for precision)
116
+ if (normalizedStartDate) {
117
+ matchedEntries = matchedEntries.filter(entry => entry.timestamp >= normalizedStartDate);
118
+ }
119
+ if (normalizedEndDate) {
120
+ matchedEntries = matchedEntries.filter(entry => entry.timestamp <= normalizedEndDate);
121
+ }
122
+ matchedEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
123
+ return matchedEntries.slice(0, limit);
124
+ }
125
+ async listProjects() {
126
+ const projects = new Map();
127
+ try {
128
+ const projectsDir = path.join(this.claudeDir, 'projects');
129
+ const projectDirs = await fs.readdir(projectsDir);
130
+ for (const projectDir of projectDirs) {
131
+ const projectPath = path.join(projectsDir, projectDir);
132
+ const stats = await fs.stat(projectPath);
133
+ if (stats.isDirectory()) {
134
+ const files = await fs.readdir(projectPath);
135
+ const decodedPath = this.decodeProjectPath(projectDir);
136
+ if (!projects.has(decodedPath)) {
137
+ projects.set(decodedPath, {
138
+ sessionIds: new Set(),
139
+ messageCount: 0,
140
+ lastActivityTime: '1970-01-01T00:00:00.000Z'
141
+ });
142
+ }
143
+ const projectInfo = projects.get(decodedPath);
144
+ if (!projectInfo)
145
+ continue;
146
+ for (const file of files) {
147
+ if (file.endsWith('.jsonl')) {
148
+ const sessionId = file.replace('.jsonl', '');
149
+ projectInfo.sessionIds.add(sessionId);
150
+ const filePath = path.join(projectPath, file);
151
+ const fileStats = await fs.stat(filePath);
152
+ if (fileStats.mtime.toISOString() > projectInfo.lastActivityTime) {
153
+ projectInfo.lastActivityTime = fileStats.mtime.toISOString();
154
+ }
155
+ // Count messages in this session
156
+ const entries = await this.parseJsonlFile(filePath, projectDir);
157
+ projectInfo.messageCount += entries.length;
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+ catch (error) {
164
+ console.error('Error listing projects:', error);
165
+ }
166
+ return Array.from(projects.entries()).map(([projectPath, info]) => ({
167
+ projectPath,
168
+ sessionCount: info.sessionIds.size,
169
+ messageCount: info.messageCount,
170
+ lastActivityTime: info.lastActivityTime
171
+ }));
172
+ }
173
+ async listSessions(options = {}) {
174
+ const { projectPath, startDate, endDate, timezone } = options;
175
+ // Normalize date strings for proper comparison
176
+ const normalizedStartDate = startDate ? this.normalizeDate(startDate, false, timezone) : undefined;
177
+ const normalizedEndDate = endDate ? this.normalizeDate(endDate, true, timezone) : undefined;
178
+ const sessions = [];
179
+ try {
180
+ const projectsDir = path.join(this.claudeDir, 'projects');
181
+ const projectDirs = await fs.readdir(projectsDir);
182
+ for (const projectDir of projectDirs) {
183
+ const decodedPath = this.decodeProjectPath(projectDir);
184
+ // Filter by project path if specified
185
+ if (projectPath && decodedPath !== projectPath) {
186
+ continue;
187
+ }
188
+ const projectDirPath = path.join(projectsDir, projectDir);
189
+ const stats = await fs.stat(projectDirPath);
190
+ if (stats.isDirectory()) {
191
+ // Use fast parser if available
192
+ if (USE_FAST_PARSER) {
193
+ const fastSessions = await this.listSessionsFast(projectDirPath, projectDir, normalizedStartDate, normalizedEndDate);
194
+ sessions.push(...fastSessions);
195
+ }
196
+ else {
197
+ // Fallback to slow parser
198
+ const files = await fs.readdir(projectDirPath);
199
+ for (const file of files) {
200
+ if (file.endsWith('.jsonl')) {
201
+ const sessionId = file.replace('.jsonl', '');
202
+ const filePath = path.join(projectDirPath, file);
203
+ const entries = await this.parseJsonlFile(filePath, projectDir);
204
+ if (entries.length === 0)
205
+ continue;
206
+ const sessionStart = entries[entries.length - 1].timestamp;
207
+ const sessionEnd = entries[0].timestamp;
208
+ // Filter by date range if specified
209
+ if (normalizedStartDate && sessionEnd < normalizedStartDate)
210
+ continue;
211
+ if (normalizedEndDate && sessionStart > normalizedEndDate)
212
+ continue;
213
+ const userMessageCount = entries.filter(e => e.type === 'user').length;
214
+ const assistantMessageCount = entries.filter(e => e.type === 'assistant').length;
215
+ // Calculate duration
216
+ const startTime = new Date(sessionStart).getTime();
217
+ const endTime = new Date(sessionEnd).getTime();
218
+ const durationMs = endTime - startTime;
219
+ // Find first user message for preview
220
+ const firstUserEntry = entries.slice().reverse().find(e => e.type === 'user');
221
+ const firstUserMessage = firstUserEntry
222
+ ? (firstUserEntry.content.length > 100
223
+ ? firstUserEntry.content.slice(0, 100) + '...'
224
+ : firstUserEntry.content)
225
+ : undefined;
226
+ // Check for errors
227
+ const hasErrors = entries.some(e => e.metadata?.isError === true);
228
+ // Extract project name from path
229
+ const projectName = decodedPath.split('/').pop() || decodedPath;
230
+ sessions.push({
231
+ sessionId,
232
+ projectPath: decodedPath,
233
+ projectName,
234
+ startTime: sessionStart,
235
+ endTime: sessionEnd,
236
+ messageCount: entries.length,
237
+ userMessageCount,
238
+ assistantMessageCount,
239
+ firstUserMessage,
240
+ durationMs,
241
+ durationFormatted: this.formatDuration(durationMs),
242
+ hasErrors
243
+ });
244
+ }
245
+ }
246
+ }
247
+ }
248
+ }
249
+ }
250
+ catch (error) {
251
+ console.error('Error listing sessions:', error);
252
+ }
253
+ // Sort by start time (newest first)
254
+ sessions.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
255
+ return sessions;
256
+ }
257
+ /**
258
+ * Fast path: List sessions using the Rust parser
259
+ * Groups entries by session_id from fast parser results
260
+ */
261
+ async listSessionsFast(projectDirPath, projectDir, startDate, endDate) {
262
+ const sessions = [];
263
+ const decodedPath = this.decodeProjectPath(projectDir);
264
+ // Use fast parser on the project directory
265
+ const parsedEntries = parseDir(projectDirPath, CLAUDE_CODE_FIELDS);
266
+ // Group entries by session_id
267
+ const sessionMap = new Map();
268
+ for (const entry of parsedEntries) {
269
+ // Skip entries with empty session_id
270
+ if (!entry.session_id)
271
+ continue;
272
+ // Apply date filtering
273
+ if (startDate && entry.timestamp < startDate)
274
+ continue;
275
+ if (endDate && entry.timestamp > endDate)
276
+ continue;
277
+ if (!sessionMap.has(entry.session_id)) {
278
+ sessionMap.set(entry.session_id, []);
279
+ }
280
+ sessionMap.get(entry.session_id).push(entry);
281
+ }
282
+ // Convert to SessionInfo
283
+ for (const [sessionId, entries] of sessionMap) {
284
+ if (entries.length === 0)
285
+ continue;
286
+ // Sort entries by timestamp
287
+ entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
288
+ const sessionStart = entries[entries.length - 1].timestamp;
289
+ const sessionEnd = entries[0].timestamp;
290
+ // Filter by date range
291
+ if (startDate && sessionEnd < startDate)
292
+ continue;
293
+ if (endDate && sessionStart > endDate)
294
+ continue;
295
+ // Count message types
296
+ const userEntries = entries.filter(e => e.role?.toLowerCase() === 'user');
297
+ const assistantEntries = entries.filter(e => e.role?.toLowerCase() === 'assistant');
298
+ const userMessageCount = userEntries.length;
299
+ const assistantMessageCount = assistantEntries.length;
300
+ // Calculate duration
301
+ const startTime = new Date(sessionStart).getTime();
302
+ const endTime = new Date(sessionEnd).getTime();
303
+ const durationMs = endTime - startTime;
304
+ // Find first user message for preview
305
+ const firstUserEntry = userEntries[userEntries.length - 1]; // Last in sorted = earliest
306
+ const firstUserMessage = firstUserEntry?.content
307
+ ? (firstUserEntry.content.length > 100
308
+ ? firstUserEntry.content.slice(0, 100) + '...'
309
+ : firstUserEntry.content)
310
+ : undefined;
311
+ // Extract project name from path
312
+ const projectName = decodedPath.split('/').pop() || decodedPath;
313
+ sessions.push({
314
+ sessionId,
315
+ projectPath: decodedPath,
316
+ projectName,
317
+ startTime: sessionStart,
318
+ endTime: sessionEnd,
319
+ messageCount: entries.length,
320
+ userMessageCount,
321
+ assistantMessageCount,
322
+ firstUserMessage,
323
+ durationMs,
324
+ durationFormatted: this.formatDuration(durationMs),
325
+ hasErrors: false // Would need to scan for errors in fast mode
326
+ });
327
+ }
328
+ return sessions;
329
+ }
330
+ /**
331
+ * Get recent activity across all projects
332
+ * Returns what was asked, what was done, and when for the most recent sessions
333
+ */
334
+ async getRecentActivity(options = {}) {
335
+ const { limit = 10, includeSummaries = true } = options;
336
+ // Get all sessions (sorted by start time, newest first)
337
+ const allSessions = await this.listSessions();
338
+ // Take the most recent N sessions
339
+ const recentSessions = allSessions.slice(0, limit);
340
+ // Build activity items with summaries
341
+ const activities = [];
342
+ for (const session of recentSessions) {
343
+ const activity = {
344
+ sessionId: session.sessionId,
345
+ projectPath: session.projectPath,
346
+ projectName: session.projectName || session.projectPath.split('/').pop() || session.projectPath,
347
+ timestamp: session.startTime,
348
+ timeAgo: this.getTimeAgo(new Date(session.startTime)),
349
+ asked: session.firstUserMessage || 'No user message found',
350
+ };
351
+ // Generate summary from assistant messages if requested
352
+ if (includeSummaries) {
353
+ activity.done = await this.generateSessionSummary(session.sessionId);
354
+ }
355
+ activities.push(activity);
356
+ }
357
+ return activities;
358
+ }
359
+ /**
360
+ * Generate a brief summary of what was done in a session from assistant messages
361
+ */
362
+ async generateSessionSummary(sessionId) {
363
+ try {
364
+ const result = await this.getConversationHistory({
365
+ sessionId,
366
+ limit: 50, // Get first 50 messages for summary
367
+ messageTypes: ['assistant'],
368
+ });
369
+ if (result.entries.length === 0) {
370
+ return undefined;
371
+ }
372
+ // Extract the first meaningful assistant response
373
+ const firstAssistant = result.entries[result.entries.length - 1];
374
+ if (!firstAssistant || !firstAssistant.content) {
375
+ return undefined;
376
+ }
377
+ // Truncate summary to ~150 characters
378
+ const content = firstAssistant.content;
379
+ return content.length > 150 ? content.slice(0, 150) + '...' : content;
380
+ }
381
+ catch (error) {
382
+ console.error(`Error generating summary for session ${sessionId}:`, error);
383
+ return undefined;
384
+ }
385
+ }
386
+ async loadClaudeHistoryEntries(options = {}) {
387
+ const entries = [];
388
+ const { startDate, endDate } = options;
389
+ try {
390
+ const projectsDir = path.join(this.claudeDir, 'projects');
391
+ const projectDirs = await fs.readdir(projectsDir);
392
+ for (const projectDir of projectDirs) {
393
+ const projectPath = path.join(projectsDir, projectDir);
394
+ const stats = await fs.stat(projectPath);
395
+ if (stats.isDirectory()) {
396
+ const files = await fs.readdir(projectPath);
397
+ for (const file of files) {
398
+ if (file.endsWith('.jsonl')) {
399
+ const filePath = path.join(projectPath, file);
400
+ // Pre-filter files based on modification time
401
+ if (await this.shouldSkipFile(filePath, startDate, endDate)) {
402
+ continue;
403
+ }
404
+ const sessionEntries = await this.parseJsonlFile(filePath, projectDir, startDate, endDate);
405
+ entries.push(...sessionEntries);
406
+ }
407
+ }
408
+ }
409
+ }
410
+ }
411
+ catch (error) {
412
+ console.error('Error loading Claude history:', error);
413
+ }
414
+ return entries;
415
+ }
416
+ async parseJsonlFile(filePath, projectDir, startDate, endDate) {
417
+ const entries = [];
418
+ try {
419
+ const fileStream = createReadStream(filePath);
420
+ const rl = createInterface({
421
+ input: fileStream,
422
+ crlfDelay: Infinity
423
+ });
424
+ for await (const line of rl) {
425
+ if (line.trim()) {
426
+ try {
427
+ const claudeMessage = JSON.parse(line);
428
+ // Apply date filtering at message level for efficiency
429
+ if (startDate && claudeMessage.timestamp < startDate) {
430
+ continue;
431
+ }
432
+ if (endDate && claudeMessage.timestamp > endDate) {
433
+ continue;
434
+ }
435
+ const entry = this.convertClaudeMessageToEntry(claudeMessage, projectDir);
436
+ if (entry) {
437
+ entries.push(entry);
438
+ }
439
+ }
440
+ catch (parseError) {
441
+ console.error('Error parsing line:', parseError);
442
+ }
443
+ }
444
+ }
445
+ }
446
+ catch (error) {
447
+ console.error('Error reading file:', filePath, error);
448
+ }
449
+ return entries;
450
+ }
451
+ convertClaudeMessageToEntry(claudeMessage, projectDir) {
452
+ try {
453
+ let content = '';
454
+ if (claudeMessage.message?.content) {
455
+ if (typeof claudeMessage.message.content === 'string') {
456
+ content = claudeMessage.message.content;
457
+ }
458
+ else if (Array.isArray(claudeMessage.message.content)) {
459
+ // Handle array content (e.g., from assistant messages)
460
+ content = claudeMessage.message.content
461
+ .map(item => {
462
+ if (typeof item === 'string')
463
+ return item;
464
+ if (item?.type === 'text' && item?.text)
465
+ return item.text;
466
+ return JSON.stringify(item);
467
+ })
468
+ .join(' ');
469
+ }
470
+ }
471
+ // Decode project path from directory name
472
+ const projectPath = this.decodeProjectPath(projectDir);
473
+ // Add enhanced time information
474
+ const timestamp = claudeMessage.timestamp;
475
+ const messageDate = new Date(timestamp);
476
+ return {
477
+ sessionId: claudeMessage.sessionId,
478
+ timestamp,
479
+ type: claudeMessage.type,
480
+ content,
481
+ projectPath,
482
+ uuid: claudeMessage.uuid,
483
+ formattedTime: messageDate.toLocaleString('en-US', {
484
+ timeZone: 'Asia/Tokyo',
485
+ year: 'numeric',
486
+ month: '2-digit',
487
+ day: '2-digit',
488
+ hour: '2-digit',
489
+ minute: '2-digit',
490
+ second: '2-digit',
491
+ hour12: false
492
+ }),
493
+ timeAgo: this.getTimeAgo(messageDate),
494
+ localDate: messageDate.toLocaleDateString('sv-SE', { timeZone: 'Asia/Tokyo' }),
495
+ metadata: {
496
+ usage: claudeMessage.message?.usage,
497
+ model: claudeMessage.message?.model,
498
+ requestId: claudeMessage.requestId
499
+ }
500
+ };
501
+ }
502
+ catch (error) {
503
+ console.error('Error converting Claude message:', error);
504
+ return null;
505
+ }
506
+ }
507
+ getTimeAgo(date) {
508
+ const now = new Date();
509
+ const diffMs = now.getTime() - date.getTime();
510
+ const diffMins = Math.floor(diffMs / (1000 * 60));
511
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
512
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
513
+ if (diffMins < 1)
514
+ return 'just now';
515
+ if (diffMins < 60)
516
+ return `${diffMins}m ago`;
517
+ if (diffHours < 24)
518
+ return `${diffHours}h ago`;
519
+ if (diffDays < 7)
520
+ return `${diffDays}d ago`;
521
+ if (diffDays < 30)
522
+ return `${Math.floor(diffDays / 7)}w ago`;
523
+ if (diffDays < 365)
524
+ return `${Math.floor(diffDays / 30)}mo ago`;
525
+ return `${Math.floor(diffDays / 365)}y ago`;
526
+ }
527
+ /**
528
+ * Format duration in milliseconds to human-readable string
529
+ */
530
+ formatDuration(ms) {
531
+ if (ms < 1000)
532
+ return `${ms}ms`;
533
+ const seconds = Math.floor(ms / 1000);
534
+ if (seconds < 60)
535
+ return `${seconds}s`;
536
+ const minutes = Math.floor(seconds / 60);
537
+ const remainingSeconds = seconds % 60;
538
+ if (minutes < 60) {
539
+ return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
540
+ }
541
+ const hours = Math.floor(minutes / 60);
542
+ const remainingMinutes = minutes % 60;
543
+ if (hours < 24) {
544
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
545
+ }
546
+ const days = Math.floor(hours / 24);
547
+ const remainingHours = hours % 24;
548
+ return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
549
+ }
550
+ decodeProjectPath(projectDir) {
551
+ return projectDir.replace(/-/g, '/').replace(/^\//, '');
552
+ }
553
+ /**
554
+ * Determines whether to skip reading a file based on its modification time
555
+ */
556
+ async shouldSkipFile(filePath, startDate, endDate) {
557
+ if (!startDate && !endDate) {
558
+ return false; // Don't skip if no date filters are specified
559
+ }
560
+ try {
561
+ const fileStats = await fs.stat(filePath);
562
+ const fileModTime = fileStats.mtime.toISOString();
563
+ const fileCreateTime = fileStats.birthtime.toISOString();
564
+ // Get the earliest and latest possible times for file content
565
+ const oldestPossibleTime = fileCreateTime < fileModTime ? fileCreateTime : fileModTime;
566
+ const newestPossibleTime = fileModTime;
567
+ // If endDate is specified: skip if file's oldest time is after endDate
568
+ if (endDate && oldestPossibleTime > endDate) {
569
+ return true; // Skip
570
+ }
571
+ // If startDate is specified: skip if file's newest time is before startDate
572
+ if (startDate && newestPossibleTime < startDate) {
573
+ return true; // Skip
574
+ }
575
+ return false; // File might contain data in range, so read it
576
+ }
577
+ catch (error) {
578
+ console.warn(`Failed to get file stats for ${filePath}:`, error);
579
+ return false; // Safe fallback: read the file if stat fails
580
+ }
581
+ }
582
+ /**
583
+ * Gets the current active Claude Code session by reading the last line from history.jsonl
584
+ */
585
+ async getCurrentSession() {
586
+ try {
587
+ const historyPath = path.join(this.claudeDir, 'history.jsonl');
588
+ const lastLine = await this.readLastLineFromFile(historyPath);
589
+ if (!lastLine) {
590
+ return null;
591
+ }
592
+ const entry = JSON.parse(lastLine);
593
+ // Validate and parse timestamp
594
+ let timestamp;
595
+ if (typeof entry.timestamp === 'number') {
596
+ timestamp = new Date(entry.timestamp).toISOString();
597
+ }
598
+ else if (typeof entry.timestamp === 'string') {
599
+ const date = new Date(entry.timestamp);
600
+ if (isNaN(date.getTime())) {
601
+ return null; // Invalid date
602
+ }
603
+ timestamp = date.toISOString();
604
+ }
605
+ else {
606
+ return null;
607
+ }
608
+ return {
609
+ sessionId: entry.sessionId,
610
+ timestamp,
611
+ projectPath: entry.project,
612
+ display: entry.display
613
+ };
614
+ }
615
+ catch (error) {
616
+ console.error('Error getting current session:', error);
617
+ return null;
618
+ }
619
+ }
620
+ /**
621
+ * Maps a process ID to a Claude Code session by examining the process tree
622
+ */
623
+ async getSessionByPid(pid) {
624
+ try {
625
+ // Get process info including parent PID and command
626
+ const { stdout } = await execAsync(`ps -p ${pid} -o pid,ppid,command`);
627
+ const lines = stdout.trim().split('\n');
628
+ if (lines.length < 2) {
629
+ return null;
630
+ }
631
+ // Parse the process line (skip header)
632
+ const parts = lines[1].trim().split(/\s+/);
633
+ const processPid = parseInt(parts[0], 10);
634
+ const parentPid = parseInt(parts[1], 10);
635
+ const command = parts.slice(2).join(' ');
636
+ // Check if this is a Claude Code process
637
+ if (!command.toLowerCase().includes('claude')) {
638
+ return null;
639
+ }
640
+ // Extract session ID from command arguments or trace process tree
641
+ let sessionId = await this.extractSessionIdFromProcess(processPid);
642
+ // Check if process is still alive
643
+ const isAlive = await this.isProcessAlive(processPid);
644
+ return {
645
+ sessionId,
646
+ pid: processPid,
647
+ command,
648
+ alive: isAlive
649
+ };
650
+ }
651
+ catch (error) {
652
+ console.error(`Error getting session by PID ${pid}:`, error);
653
+ return null;
654
+ }
655
+ }
656
+ /**
657
+ * Lists all session UUIDs from the session-env directory
658
+ */
659
+ async listAllSessionUuids() {
660
+ try {
661
+ const sessionEnvDir = path.join(this.claudeDir, 'session-env');
662
+ const entries = await fs.readdir(sessionEnvDir);
663
+ // Filter only UUID-like directory names
664
+ const uuids = entries.filter(entry => {
665
+ // UUID v4 pattern: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
666
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
667
+ return uuidPattern.test(entry);
668
+ });
669
+ return uuids;
670
+ }
671
+ catch (error) {
672
+ console.error('Error listing session UUIDs:', error);
673
+ return [];
674
+ }
675
+ }
676
+ /**
677
+ * Reads only the last line from a file efficiently
678
+ */
679
+ async readLastLineFromFile(filePath) {
680
+ try {
681
+ const fileStream = createReadStream(filePath, { encoding: 'utf-8' });
682
+ const rl = createInterface({
683
+ input: fileStream,
684
+ crlfDelay: Infinity
685
+ });
686
+ let lastLine = null;
687
+ for await (const line of rl) {
688
+ if (line.trim()) {
689
+ lastLine = line;
690
+ }
691
+ }
692
+ return lastLine;
693
+ }
694
+ catch (error) {
695
+ console.error('Error reading last line from file:', error);
696
+ return null;
697
+ }
698
+ }
699
+ /**
700
+ * Extracts session ID from a Claude Code process by examining command and environment
701
+ */
702
+ async extractSessionIdFromProcess(pid) {
703
+ try {
704
+ // First, check if the command contains a session ID argument (-r flag)
705
+ const { stdout: psOutput } = await execAsync(`ps -p ${pid} -o command`);
706
+ const command = psOutput.trim();
707
+ // Look for -r flag with session ID
708
+ const sessionMatch = command.match(/-r\s+([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/i);
709
+ if (sessionMatch) {
710
+ return sessionMatch[1];
711
+ }
712
+ // If not found in command, check the current session from history
713
+ const currentSession = await this.getCurrentSession();
714
+ if (currentSession) {
715
+ return currentSession.sessionId;
716
+ }
717
+ // Fallback: return empty string
718
+ return '';
719
+ }
720
+ catch (error) {
721
+ console.error('Error extracting session ID from process:', error);
722
+ return '';
723
+ }
724
+ }
725
+ /**
726
+ * Checks if a process is still alive
727
+ */
728
+ async isProcessAlive(pid) {
729
+ try {
730
+ // Send signal 0 to check if process exists
731
+ process.kill(pid, 0);
732
+ return true;
733
+ }
734
+ catch (error) {
735
+ return false;
736
+ }
737
+ }
738
+ }
739
+ //# sourceMappingURL=history-service.js.map