@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.
- package/dist/history-service.d.ts.map +1 -1
- package/dist/history-service.js +2 -2
- package/dist/history-service.js.map +1 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -1
- package/package.json +32 -9
- package/rust/Cargo.lock +1955 -0
- package/rust/Cargo.toml +90 -0
- package/rust/build.rs +4 -0
- package/rust/claudecodehistory.darwin-arm64.node +0 -0
- package/rust/index.d.ts +0 -0
- package/rust/package.json +69 -0
- package/rust/src/lib.rs +800 -0
- package/rust/src/parser.rs +261 -0
- package/rust/src/search/index.rs +206 -0
- package/rust/src/search/mod.rs +291 -0
- package/rust/src/search/query.rs +458 -0
- package/rust/src/search/schema.rs +115 -0
- package/rust/src/types.rs +248 -0
- package/rust/src/utils.rs +210 -0
- package/typescript/dist/history-service.d.ts +187 -0
- package/typescript/dist/history-service.d.ts.map +1 -0
- package/typescript/dist/history-service.js +739 -0
- package/typescript/dist/history-service.js.map +1 -0
- package/typescript/dist/index.d.ts +3 -0
- package/typescript/dist/index.d.ts.map +1 -0
- package/typescript/dist/index.js +2 -0
- package/typescript/dist/index.js.map +1 -0
|
@@ -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
|