@epiphytic/claudecodeui 1.2.3 → 1.3.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,354 @@
1
+ /**
2
+ * History Cache Module
3
+ *
4
+ * Parses and caches the ~/.claude/history.jsonl file which contains
5
+ * user prompts across all sessions. This data can be used to:
6
+ * - Supplement session titles with the last user prompt
7
+ * - Provide user prompt history for a session
8
+ *
9
+ * Uses lazy loading with LRU eviction:
10
+ * - Only caches sessions that have been accessed recently
11
+ * - Uses streaming to find entries for a specific session
12
+ * - Evicts least recently used sessions when cache is full
13
+ */
14
+
15
+ import fs from "fs";
16
+ import fsPromises from "fs/promises";
17
+ import path from "path";
18
+ import os from "os";
19
+ import readline from "readline";
20
+ import { createLogger } from "./logger.js";
21
+
22
+ const log = createLogger("history-cache");
23
+
24
+ // LRU cache configuration
25
+ const MAX_CACHED_SESSIONS = 20;
26
+
27
+ // Cache TTL - 60 seconds (reduces reload frequency during active use)
28
+ const HISTORY_CACHE_TTL = 60000;
29
+
30
+ // LRU cache for session data
31
+ // Map maintains insertion order, so we use it for LRU behavior
32
+ const sessionCache = new Map(); // sessionId -> { entries: [], timestamp: number }
33
+
34
+ // File modification time tracking (to invalidate cache when file changes)
35
+ let lastFileMtime = null;
36
+
37
+ /**
38
+ * Get the history.jsonl file path
39
+ */
40
+ function getHistoryFilePath() {
41
+ return path.join(os.homedir(), ".claude", "history.jsonl");
42
+ }
43
+
44
+ /**
45
+ * Get the file modification time
46
+ */
47
+ async function getFileMtime(filePath) {
48
+ try {
49
+ const stats = await fsPromises.stat(filePath);
50
+ return stats.mtimeMs;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Parse a single history entry
58
+ * @param {string} line - JSON line from history.jsonl
59
+ * @returns {object|null} Parsed entry or null if invalid
60
+ */
61
+ function parseHistoryEntry(line) {
62
+ try {
63
+ const entry = JSON.parse(line);
64
+
65
+ // Validate required fields
66
+ if (!entry.display || !entry.timestamp || !entry.sessionId) {
67
+ return null;
68
+ }
69
+
70
+ return {
71
+ prompt: entry.display,
72
+ timestamp: entry.timestamp,
73
+ sessionId: entry.sessionId,
74
+ project: entry.project || null,
75
+ pastedContents: entry.pastedContents || {},
76
+ };
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Invalidate cache if file has changed
84
+ */
85
+ async function checkFileChanged() {
86
+ const filePath = getHistoryFilePath();
87
+ const currentMtime = await getFileMtime(filePath);
88
+
89
+ if (currentMtime !== lastFileMtime) {
90
+ // File changed, clear all cached data
91
+ sessionCache.clear();
92
+ lastFileMtime = currentMtime;
93
+ log.debug({ mtime: currentMtime }, "History file changed, cache cleared");
94
+ return true;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Move a session to the end of the LRU cache (most recently used)
101
+ * @param {string} sessionId
102
+ * @param {object} data
103
+ */
104
+ function touchSession(sessionId, data) {
105
+ // Delete and re-add to move to end (most recently used)
106
+ sessionCache.delete(sessionId);
107
+ sessionCache.set(sessionId, data);
108
+
109
+ // Evict oldest entries if over limit
110
+ while (sessionCache.size > MAX_CACHED_SESSIONS) {
111
+ const oldestKey = sessionCache.keys().next().value;
112
+ sessionCache.delete(oldestKey);
113
+ log.debug({ sessionId: oldestKey }, "Evicted session from LRU cache");
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Stream through history file and collect entries for a specific session
119
+ * @param {string} sessionId - The session ID to find
120
+ * @returns {Promise<Array>} Array of entries for the session
121
+ */
122
+ async function streamEntriesForSession(sessionId) {
123
+ const filePath = getHistoryFilePath();
124
+
125
+ if (!fs.existsSync(filePath)) {
126
+ return [];
127
+ }
128
+
129
+ const entries = [];
130
+
131
+ try {
132
+ const fileStream = fs.createReadStream(filePath);
133
+ const rl = readline.createInterface({
134
+ input: fileStream,
135
+ crlfDelay: Infinity,
136
+ });
137
+
138
+ for await (const line of rl) {
139
+ if (line.trim()) {
140
+ const entry = parseHistoryEntry(line);
141
+ if (entry && entry.sessionId === sessionId) {
142
+ entries.push(entry);
143
+ }
144
+ }
145
+ }
146
+
147
+ // Sort by timestamp
148
+ entries.sort((a, b) => a.timestamp - b.timestamp);
149
+
150
+ log.debug(
151
+ { sessionId, entryCount: entries.length },
152
+ "Streamed session entries",
153
+ );
154
+
155
+ return entries;
156
+ } catch (error) {
157
+ log.error({ error: error.message, sessionId }, "Failed to stream session");
158
+ return [];
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Get all prompts for a specific session (with lazy loading and LRU caching)
164
+ * @param {string} sessionId - The session ID
165
+ * @returns {Promise<Array>} Array of prompt entries sorted by timestamp
166
+ */
167
+ async function getSessionPrompts(sessionId) {
168
+ // Check if file has changed (invalidates cache)
169
+ await checkFileChanged();
170
+
171
+ // Check if session is in cache and still valid
172
+ const cached = sessionCache.get(sessionId);
173
+ if (cached && Date.now() - cached.timestamp < HISTORY_CACHE_TTL) {
174
+ // Move to end of LRU
175
+ touchSession(sessionId, cached);
176
+ log.debug({ sessionId }, "Using cached session prompts");
177
+ return cached.entries;
178
+ }
179
+
180
+ // Not in cache or expired, stream from file
181
+ const entries = await streamEntriesForSession(sessionId);
182
+
183
+ // Cache the result
184
+ touchSession(sessionId, {
185
+ entries,
186
+ timestamp: Date.now(),
187
+ });
188
+
189
+ return entries;
190
+ }
191
+
192
+ /**
193
+ * Get the last prompt for a specific session
194
+ * @param {string} sessionId - The session ID
195
+ * @returns {Promise<object|null>} The last prompt entry or null
196
+ */
197
+ async function getLastSessionPrompt(sessionId) {
198
+ const prompts = await getSessionPrompts(sessionId);
199
+ if (prompts.length === 0) return null;
200
+ return prompts[prompts.length - 1];
201
+ }
202
+
203
+ /**
204
+ * Get all prompts for a specific project path
205
+ * Streams through file without caching (less common operation)
206
+ * @param {string} projectPath - The project path
207
+ * @returns {Promise<Array>} Array of prompt entries sorted by timestamp
208
+ */
209
+ async function getProjectPrompts(projectPath) {
210
+ const filePath = getHistoryFilePath();
211
+
212
+ if (!fs.existsSync(filePath)) {
213
+ return [];
214
+ }
215
+
216
+ const entries = [];
217
+
218
+ try {
219
+ const fileStream = fs.createReadStream(filePath);
220
+ const rl = readline.createInterface({
221
+ input: fileStream,
222
+ crlfDelay: Infinity,
223
+ });
224
+
225
+ for await (const line of rl) {
226
+ if (line.trim()) {
227
+ const entry = parseHistoryEntry(line);
228
+ if (entry && entry.project === projectPath) {
229
+ entries.push(entry);
230
+ }
231
+ }
232
+ }
233
+
234
+ // Sort by timestamp
235
+ entries.sort((a, b) => a.timestamp - b.timestamp);
236
+
237
+ return entries;
238
+ } catch (error) {
239
+ log.error(
240
+ { error: error.message, projectPath },
241
+ "Failed to get project prompts",
242
+ );
243
+ return [];
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Get session title from history (last user prompt, truncated)
249
+ * @param {string} sessionId - The session ID
250
+ * @param {number} maxLength - Maximum title length (default 100)
251
+ * @returns {Promise<string|null>} The title or null if no prompts found
252
+ */
253
+ async function getSessionTitleFromHistory(sessionId, maxLength = 100) {
254
+ const lastPrompt = await getLastSessionPrompt(sessionId);
255
+ if (!lastPrompt) return null;
256
+
257
+ let title = lastPrompt.prompt.trim();
258
+
259
+ // Remove common prefixes that aren't useful as titles
260
+ if (title.startsWith("/")) {
261
+ // Skip slash commands like /compact, /model, etc.
262
+ // Unless it's followed by actual content
263
+ const spaceIndex = title.indexOf(" ");
264
+ if (spaceIndex > 0 && spaceIndex < 20) {
265
+ const afterCommand = title.slice(spaceIndex + 1).trim();
266
+ if (afterCommand.length > 10) {
267
+ title = afterCommand;
268
+ } else {
269
+ return null; // Pure slash command, not useful as title
270
+ }
271
+ } else {
272
+ return null; // Pure slash command
273
+ }
274
+ }
275
+
276
+ // Truncate if needed
277
+ if (title.length > maxLength) {
278
+ title = title.slice(0, maxLength - 3) + "...";
279
+ }
280
+
281
+ // Clean up newlines and extra whitespace
282
+ title = title.replace(/\s+/g, " ").trim();
283
+
284
+ return title || null;
285
+ }
286
+
287
+ /**
288
+ * Get all session IDs that have history entries
289
+ * Streams through file (not frequently used)
290
+ * @returns {Promise<Array<string>>} Array of session IDs
291
+ */
292
+ async function getAllSessionIds() {
293
+ const filePath = getHistoryFilePath();
294
+
295
+ if (!fs.existsSync(filePath)) {
296
+ return [];
297
+ }
298
+
299
+ const sessionIds = new Set();
300
+
301
+ try {
302
+ const fileStream = fs.createReadStream(filePath);
303
+ const rl = readline.createInterface({
304
+ input: fileStream,
305
+ crlfDelay: Infinity,
306
+ });
307
+
308
+ for await (const line of rl) {
309
+ if (line.trim()) {
310
+ const entry = parseHistoryEntry(line);
311
+ if (entry) {
312
+ sessionIds.add(entry.sessionId);
313
+ }
314
+ }
315
+ }
316
+
317
+ return Array.from(sessionIds);
318
+ } catch (error) {
319
+ log.error({ error: error.message }, "Failed to get all session IDs");
320
+ return [];
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Invalidate the history cache
326
+ */
327
+ function invalidateCache() {
328
+ sessionCache.clear();
329
+ lastFileMtime = null;
330
+ log.debug("History cache invalidated");
331
+ }
332
+
333
+ /**
334
+ * Get cache statistics
335
+ */
336
+ function getCacheStats() {
337
+ return {
338
+ cachedSessions: sessionCache.size,
339
+ maxCachedSessions: MAX_CACHED_SESSIONS,
340
+ lastFileMtime,
341
+ };
342
+ }
343
+
344
+ export {
345
+ getSessionPrompts,
346
+ getLastSessionPrompt,
347
+ getProjectPrompts,
348
+ getSessionTitleFromHistory,
349
+ getAllSessionIds,
350
+ invalidateCache,
351
+ getCacheStats,
352
+ HISTORY_CACHE_TTL,
353
+ MAX_CACHED_SESSIONS,
354
+ };