@epiphytic/claudecodeui 1.2.2 → 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-BGneYLVE.css +0 -32
- package/dist/assets/index-DM1BeYBg.js +0 -1245
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Indexer Module
|
|
3
|
+
*
|
|
4
|
+
* Incrementally indexes session files into SQLite.
|
|
5
|
+
* Only processes new bytes when files are appended.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import fsPromises from "fs/promises";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import os from "os";
|
|
12
|
+
import readline from "readline";
|
|
13
|
+
import { createLogger } from "./logger.js";
|
|
14
|
+
import {
|
|
15
|
+
getFileState,
|
|
16
|
+
updateFileState,
|
|
17
|
+
upsertProject,
|
|
18
|
+
upsertSession,
|
|
19
|
+
insertMessageIndexBatch,
|
|
20
|
+
insertUuidMappingBatch,
|
|
21
|
+
deleteSessionMessageIndexes,
|
|
22
|
+
getStats,
|
|
23
|
+
getProjectCwdFromSessions,
|
|
24
|
+
} from "./database.js";
|
|
25
|
+
|
|
26
|
+
const log = createLogger("db-indexer");
|
|
27
|
+
|
|
28
|
+
// Claude projects path
|
|
29
|
+
const CLAUDE_PROJECTS_PATH = path.join(os.homedir(), ".claude", "projects");
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Process a single session file incrementally
|
|
33
|
+
* Only reads new bytes since last processing
|
|
34
|
+
*/
|
|
35
|
+
async function processSessionFile(filePath, projectName) {
|
|
36
|
+
try {
|
|
37
|
+
const stats = await fsPromises.stat(filePath);
|
|
38
|
+
const fileState = getFileState(filePath);
|
|
39
|
+
|
|
40
|
+
// Check if file has changed
|
|
41
|
+
if (fileState && fileState.last_mtime === stats.mtimeMs) {
|
|
42
|
+
return { skipped: true, reason: "unchanged" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check if we can do incremental update (file grew)
|
|
46
|
+
const isIncremental =
|
|
47
|
+
fileState &&
|
|
48
|
+
fileState.file_size &&
|
|
49
|
+
stats.size > fileState.file_size &&
|
|
50
|
+
fileState.last_mtime < stats.mtimeMs;
|
|
51
|
+
|
|
52
|
+
const startOffset = isIncremental ? fileState.last_byte_offset : 0;
|
|
53
|
+
|
|
54
|
+
// Extract session ID from filename
|
|
55
|
+
const sessionId = path.basename(filePath, ".jsonl");
|
|
56
|
+
|
|
57
|
+
// If starting fresh, clear existing indexes for this session
|
|
58
|
+
if (!isIncremental) {
|
|
59
|
+
deleteSessionMessageIndexes(sessionId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const entries = [];
|
|
63
|
+
const uuidMappings = [];
|
|
64
|
+
const messageIndexes = [];
|
|
65
|
+
|
|
66
|
+
let byteOffset = startOffset;
|
|
67
|
+
let messageNumber = isIncremental ? fileState.message_count || 0 : 0;
|
|
68
|
+
let lastActivity = null;
|
|
69
|
+
let summary = "New Session";
|
|
70
|
+
let firstUserMessage = null;
|
|
71
|
+
let cwd = null;
|
|
72
|
+
|
|
73
|
+
// Stream the file from the start offset
|
|
74
|
+
const stream = fs.createReadStream(filePath, {
|
|
75
|
+
start: startOffset,
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const rl = readline.createInterface({
|
|
80
|
+
input: stream,
|
|
81
|
+
crlfDelay: Infinity,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
for await (const line of rl) {
|
|
85
|
+
const lineBytes = Buffer.byteLength(line, "utf8") + 1; // +1 for newline
|
|
86
|
+
|
|
87
|
+
if (line.trim()) {
|
|
88
|
+
try {
|
|
89
|
+
const entry = JSON.parse(line);
|
|
90
|
+
|
|
91
|
+
// Handle summary entries (they don't have sessionId)
|
|
92
|
+
if (entry.type === "summary" && entry.summary) {
|
|
93
|
+
summary = entry.summary;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Capture first user message as fallback title
|
|
97
|
+
if (
|
|
98
|
+
!firstUserMessage &&
|
|
99
|
+
entry.type === "user" &&
|
|
100
|
+
entry.message?.content
|
|
101
|
+
) {
|
|
102
|
+
const content = entry.message.content;
|
|
103
|
+
// Handle both string and array content formats
|
|
104
|
+
if (typeof content === "string") {
|
|
105
|
+
firstUserMessage = content;
|
|
106
|
+
} else if (Array.isArray(content) && content[0]?.text) {
|
|
107
|
+
firstUserMessage = content[0].text;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (entry.sessionId === sessionId) {
|
|
112
|
+
messageNumber++;
|
|
113
|
+
|
|
114
|
+
// Build message index
|
|
115
|
+
messageIndexes.push({
|
|
116
|
+
sessionId,
|
|
117
|
+
messageNumber,
|
|
118
|
+
uuid: entry.uuid || null,
|
|
119
|
+
type: entry.type || null,
|
|
120
|
+
timestamp: entry.timestamp,
|
|
121
|
+
byteOffset: byteOffset,
|
|
122
|
+
filePath,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Build UUID mapping
|
|
126
|
+
if (entry.uuid) {
|
|
127
|
+
uuidMappings.push({
|
|
128
|
+
uuid: entry.uuid,
|
|
129
|
+
sessionId,
|
|
130
|
+
parentUuid: entry.parentUuid || null,
|
|
131
|
+
type: entry.type || null,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Track session metadata
|
|
136
|
+
if (entry.timestamp) {
|
|
137
|
+
const ts = new Date(entry.timestamp);
|
|
138
|
+
if (!lastActivity || ts > lastActivity) {
|
|
139
|
+
lastActivity = ts;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Extract cwd
|
|
144
|
+
if (entry.cwd && !cwd) {
|
|
145
|
+
cwd = entry.cwd;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch (parseError) {
|
|
149
|
+
// Skip malformed lines
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
byteOffset += lineBytes;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Batch insert indexes
|
|
157
|
+
if (messageIndexes.length > 0) {
|
|
158
|
+
insertMessageIndexBatch(messageIndexes);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (uuidMappings.length > 0) {
|
|
162
|
+
insertUuidMappingBatch(uuidMappings);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Use first user message as fallback if no summary
|
|
166
|
+
let finalSummary = summary;
|
|
167
|
+
if (summary === "New Session" && firstUserMessage) {
|
|
168
|
+
// Truncate long messages and clean up for display
|
|
169
|
+
finalSummary = firstUserMessage
|
|
170
|
+
.replace(/\n/g, " ")
|
|
171
|
+
.replace(/\s+/g, " ")
|
|
172
|
+
.trim();
|
|
173
|
+
if (finalSummary.length > 100) {
|
|
174
|
+
finalSummary = finalSummary.substring(0, 97) + "...";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Update session metadata
|
|
179
|
+
upsertSession({
|
|
180
|
+
id: sessionId,
|
|
181
|
+
projectName,
|
|
182
|
+
summary: finalSummary,
|
|
183
|
+
messageCount: messageNumber,
|
|
184
|
+
lastActivity: lastActivity ? lastActivity.toISOString() : null,
|
|
185
|
+
cwd,
|
|
186
|
+
provider: "claude",
|
|
187
|
+
filePath,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Update file state
|
|
191
|
+
updateFileState(filePath, byteOffset, stats.mtimeMs, stats.size);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
skipped: false,
|
|
195
|
+
sessionId,
|
|
196
|
+
messagesIndexed: messageIndexes.length,
|
|
197
|
+
isIncremental,
|
|
198
|
+
totalMessages: messageNumber,
|
|
199
|
+
cwd,
|
|
200
|
+
};
|
|
201
|
+
} catch (error) {
|
|
202
|
+
log.error({ error: error.message, filePath }, "Error processing file");
|
|
203
|
+
return { skipped: true, reason: "error", error: error.message };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get display name for a project by trying package.json first, then path-based
|
|
209
|
+
* @param {string} actualPath - The actual filesystem path to the project
|
|
210
|
+
*/
|
|
211
|
+
async function getProjectDisplayName(actualPath) {
|
|
212
|
+
if (!actualPath) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Try to read package.json
|
|
217
|
+
try {
|
|
218
|
+
const packageJsonPath = path.join(actualPath, "package.json");
|
|
219
|
+
const packageData = await fsPromises.readFile(packageJsonPath, "utf8");
|
|
220
|
+
const packageJson = JSON.parse(packageData);
|
|
221
|
+
if (packageJson.name) {
|
|
222
|
+
return packageJson.name;
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// Fall back to path-based naming
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Return last 2 parts of the actual path like "org/repo"
|
|
229
|
+
const parts = actualPath.split("/").filter(Boolean);
|
|
230
|
+
if (parts.length >= 2) {
|
|
231
|
+
return parts.slice(-2).join("/");
|
|
232
|
+
}
|
|
233
|
+
return parts.pop() || actualPath;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Index all files in a project directory
|
|
238
|
+
*/
|
|
239
|
+
async function indexProject(projectDir) {
|
|
240
|
+
const projectName = path.basename(projectDir);
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const files = await fsPromises.readdir(projectDir);
|
|
244
|
+
const jsonlFiles = files.filter(
|
|
245
|
+
(f) => f.endsWith(".jsonl") && !f.startsWith("agent-"),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
let lastActivity = null;
|
|
249
|
+
let sessionCount = 0;
|
|
250
|
+
let projectCwd = null;
|
|
251
|
+
const results = [];
|
|
252
|
+
|
|
253
|
+
for (const file of jsonlFiles) {
|
|
254
|
+
const filePath = path.join(projectDir, file);
|
|
255
|
+
const result = await processSessionFile(filePath, projectName);
|
|
256
|
+
results.push(result);
|
|
257
|
+
|
|
258
|
+
if (!result.skipped) {
|
|
259
|
+
sessionCount++;
|
|
260
|
+
// Track project's last activity
|
|
261
|
+
if (result.lastActivity) {
|
|
262
|
+
const ts = new Date(result.lastActivity);
|
|
263
|
+
if (!lastActivity || ts > lastActivity) {
|
|
264
|
+
lastActivity = ts;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Capture the first valid cwd for display name generation
|
|
268
|
+
if (!projectCwd && result.cwd) {
|
|
269
|
+
projectCwd = result.cwd;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// If no cwd was found from processing (e.g., all files skipped), try database
|
|
275
|
+
if (!projectCwd) {
|
|
276
|
+
projectCwd = getProjectCwdFromSessions(projectName);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Generate display name from actual path (from session cwd)
|
|
280
|
+
const displayName = await getProjectDisplayName(projectCwd);
|
|
281
|
+
upsertProject({
|
|
282
|
+
name: projectName,
|
|
283
|
+
displayName: displayName || decodeProjectName(projectName),
|
|
284
|
+
fullPath: projectCwd || projectDir,
|
|
285
|
+
sessionCount,
|
|
286
|
+
lastActivity: lastActivity ? lastActivity.toISOString() : null,
|
|
287
|
+
hasClaudeSessions: sessionCount > 0,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return { projectName, filesProcessed: results.length, results };
|
|
291
|
+
} catch (error) {
|
|
292
|
+
log.error({ error: error.message, projectDir }, "Error indexing project");
|
|
293
|
+
return { projectName, error: error.message };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Index all projects in the Claude projects directory
|
|
299
|
+
*/
|
|
300
|
+
async function indexAllProjects() {
|
|
301
|
+
const startTime = Date.now();
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
if (!fs.existsSync(CLAUDE_PROJECTS_PATH)) {
|
|
305
|
+
log.warn({ path: CLAUDE_PROJECTS_PATH }, "Projects path does not exist");
|
|
306
|
+
return { success: false, error: "Projects path not found" };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const entries = await fsPromises.readdir(CLAUDE_PROJECTS_PATH, {
|
|
310
|
+
withFileTypes: true,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const projectDirs = entries
|
|
314
|
+
.filter((e) => e.isDirectory())
|
|
315
|
+
.map((e) => path.join(CLAUDE_PROJECTS_PATH, e.name));
|
|
316
|
+
|
|
317
|
+
const results = [];
|
|
318
|
+
for (const dir of projectDirs) {
|
|
319
|
+
const result = await indexProject(dir);
|
|
320
|
+
results.push(result);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const duration = Date.now() - startTime;
|
|
324
|
+
const stats = getStats();
|
|
325
|
+
|
|
326
|
+
log.info(
|
|
327
|
+
{
|
|
328
|
+
projectsIndexed: results.length,
|
|
329
|
+
durationMs: duration,
|
|
330
|
+
stats,
|
|
331
|
+
},
|
|
332
|
+
"Full indexing complete",
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
success: true,
|
|
337
|
+
projectsIndexed: results.length,
|
|
338
|
+
durationMs: duration,
|
|
339
|
+
stats,
|
|
340
|
+
};
|
|
341
|
+
} catch (error) {
|
|
342
|
+
log.error({ error: error.message }, "Error during full indexing");
|
|
343
|
+
return { success: false, error: error.message };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Index a single file (called on file change)
|
|
349
|
+
*/
|
|
350
|
+
async function indexFile(filePath) {
|
|
351
|
+
// Extract project name from path
|
|
352
|
+
const projectDir = path.dirname(filePath);
|
|
353
|
+
const projectName = path.basename(projectDir);
|
|
354
|
+
|
|
355
|
+
// Check this is in the projects directory
|
|
356
|
+
if (!filePath.startsWith(CLAUDE_PROJECTS_PATH)) {
|
|
357
|
+
return { skipped: true, reason: "not in projects directory" };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Only process .jsonl files
|
|
361
|
+
if (
|
|
362
|
+
!filePath.endsWith(".jsonl") ||
|
|
363
|
+
path.basename(filePath).startsWith("agent-")
|
|
364
|
+
) {
|
|
365
|
+
return { skipped: true, reason: "not a session file" };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return processSessionFile(filePath, projectName);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Decode project name from URL-encoded format
|
|
373
|
+
* Returns a meaningful display name (last 2 path parts like "org/repo")
|
|
374
|
+
*/
|
|
375
|
+
function decodeProjectName(encodedName) {
|
|
376
|
+
try {
|
|
377
|
+
// Replace - with / for path reconstruction
|
|
378
|
+
const decoded = decodeURIComponent(encodedName.replace(/-/g, "/"));
|
|
379
|
+
const parts = decoded.split("/").filter(Boolean);
|
|
380
|
+
|
|
381
|
+
// Return last 2 parts for more context (e.g., "epiphytic/claudecodeui")
|
|
382
|
+
// Skip common parent dirs like "repos", "projects", "src"
|
|
383
|
+
const skipParts = ["repos", "projects", "src", "code", "Users", "home"];
|
|
384
|
+
const meaningfulParts = parts.filter((p) => !skipParts.includes(p));
|
|
385
|
+
|
|
386
|
+
if (meaningfulParts.length >= 2) {
|
|
387
|
+
return meaningfulParts.slice(-2).join("/");
|
|
388
|
+
}
|
|
389
|
+
return parts.pop() || encodedName;
|
|
390
|
+
} catch {
|
|
391
|
+
return encodedName;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export {
|
|
396
|
+
processSessionFile,
|
|
397
|
+
indexProject,
|
|
398
|
+
indexAllProjects,
|
|
399
|
+
indexFile,
|
|
400
|
+
CLAUDE_PROJECTS_PATH,
|
|
401
|
+
};
|