@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.
@@ -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
+ };