@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.
- package/dist/assets/index-CceDF8mT.css +32 -0
- package/dist/assets/index-Dlv06cpK.js +1245 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +44 -4
- package/package.json +4 -2
- package/public/sw.js +44 -4
- package/server/database/db.js +26 -16
- package/server/database/init.sql +2 -1
- package/server/database.js +861 -0
- package/server/db-indexer.js +401 -0
- package/server/external-session-detector.js +48 -392
- package/server/history-cache.js +354 -0
- package/server/index.js +457 -48
- package/server/logger.js +59 -0
- package/server/maintenance-scheduler.js +172 -0
- package/server/messages-cache.js +485 -0
- package/server/openai-codex.js +110 -102
- package/server/orchestrator/client.js +52 -0
- package/server/process-cache.js +513 -0
- package/server/projects.js +64 -47
- package/server/routes/auth.js +18 -12
- package/server/routes/sessions.js +59 -33
- package/server/session-lock.js +2 -10
- package/server/sessions-cache.js +16 -0
- package/dist/assets/index-BV_fwoPa.css +0 -32
- package/dist/assets/index-CWwPqmRx.js +0 -1245
|
@@ -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
|
+
};
|