@ian2018cs/agenthub 0.1.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.
Files changed (136) hide show
  1. package/LICENSE +675 -0
  2. package/README.md +330 -0
  3. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/dist/assets/index-B4ru3EJb.css +32 -0
  63. package/dist/assets/index-DDFuyrpY.js +154 -0
  64. package/dist/assets/vendor-codemirror-C_VWDoZS.js +39 -0
  65. package/dist/assets/vendor-icons-CJV4dnDL.js +326 -0
  66. package/dist/assets/vendor-katex-DK8hFnhL.js +261 -0
  67. package/dist/assets/vendor-markdown-VwNYkg_0.js +35 -0
  68. package/dist/assets/vendor-react-BeVl62c0.js +59 -0
  69. package/dist/assets/vendor-syntax-CdGaPJRS.js +16 -0
  70. package/dist/assets/vendor-utils-00TdZexr.js +1 -0
  71. package/dist/assets/vendor-xterm-CvdiG4-n.js +66 -0
  72. package/dist/clear-cache.html +85 -0
  73. package/dist/convert-icons.md +53 -0
  74. package/dist/favicon.png +0 -0
  75. package/dist/favicon.svg +9 -0
  76. package/dist/generate-icons.js +49 -0
  77. package/dist/icons/claude-ai-icon.svg +1 -0
  78. package/dist/icons/codex-white.svg +3 -0
  79. package/dist/icons/codex.svg +3 -0
  80. package/dist/icons/cursor-white.svg +12 -0
  81. package/dist/icons/cursor.svg +1 -0
  82. package/dist/icons/generate-icons.md +19 -0
  83. package/dist/icons/icon-128x128.png +0 -0
  84. package/dist/icons/icon-128x128.svg +12 -0
  85. package/dist/icons/icon-144x144.png +0 -0
  86. package/dist/icons/icon-144x144.svg +12 -0
  87. package/dist/icons/icon-152x152.png +0 -0
  88. package/dist/icons/icon-152x152.svg +12 -0
  89. package/dist/icons/icon-192x192.png +0 -0
  90. package/dist/icons/icon-192x192.svg +12 -0
  91. package/dist/icons/icon-384x384.png +0 -0
  92. package/dist/icons/icon-384x384.svg +12 -0
  93. package/dist/icons/icon-512x512.png +0 -0
  94. package/dist/icons/icon-512x512.svg +12 -0
  95. package/dist/icons/icon-72x72.png +0 -0
  96. package/dist/icons/icon-72x72.svg +12 -0
  97. package/dist/icons/icon-96x96.png +0 -0
  98. package/dist/icons/icon-96x96.svg +12 -0
  99. package/dist/icons/icon-template.svg +12 -0
  100. package/dist/index.html +57 -0
  101. package/dist/logo-128.png +0 -0
  102. package/dist/logo-256.png +0 -0
  103. package/dist/logo-32.png +0 -0
  104. package/dist/logo-512.png +0 -0
  105. package/dist/logo-64.png +0 -0
  106. package/dist/logo.svg +17 -0
  107. package/dist/manifest.json +61 -0
  108. package/dist/screenshots/cli-selection.png +0 -0
  109. package/dist/screenshots/desktop-main.png +0 -0
  110. package/dist/screenshots/mobile-chat.png +0 -0
  111. package/dist/screenshots/tools-modal.png +0 -0
  112. package/dist/sw.js +49 -0
  113. package/package.json +113 -0
  114. package/server/claude-sdk.js +791 -0
  115. package/server/cli.js +330 -0
  116. package/server/database/auth.db +0 -0
  117. package/server/database/db.js +523 -0
  118. package/server/database/init.sql +23 -0
  119. package/server/index.js +1678 -0
  120. package/server/load-env.js +27 -0
  121. package/server/middleware/auth.js +118 -0
  122. package/server/projects.js +899 -0
  123. package/server/routes/admin.js +89 -0
  124. package/server/routes/auth.js +144 -0
  125. package/server/routes/commands.js +570 -0
  126. package/server/routes/mcp-utils.js +37 -0
  127. package/server/routes/mcp.js +593 -0
  128. package/server/routes/projects.js +216 -0
  129. package/server/routes/skills.js +891 -0
  130. package/server/routes/usage.js +206 -0
  131. package/server/services/pricing.js +196 -0
  132. package/server/services/usage-scanner.js +283 -0
  133. package/server/services/user-directories.js +123 -0
  134. package/server/utils/commandParser.js +303 -0
  135. package/server/utils/mcp-detector.js +73 -0
  136. package/shared/modelConstants.js +23 -0
@@ -0,0 +1,899 @@
1
+ /**
2
+ * PROJECT DISCOVERY AND MANAGEMENT SYSTEM
3
+ * ========================================
4
+ *
5
+ * This module manages project discovery for Claude CLI sessions.
6
+ *
7
+ * ## Architecture Overview
8
+ *
9
+ * **Claude Projects** (stored in ~/.claude/projects/)
10
+ * - Each project is a directory named with the project path encoded (/ replaced with -)
11
+ * - Contains .jsonl files with conversation history including 'cwd' field
12
+ * - Project metadata stored in ~/.claude/project-config.json
13
+ *
14
+ * ## Project Discovery Strategy
15
+ *
16
+ * 1. **Claude Projects Discovery**:
17
+ * - Scan ~/.claude/projects/ directory for Claude project folders
18
+ * - Extract actual project path from .jsonl files (cwd field)
19
+ * - Fall back to decoded directory name if no sessions exist
20
+ *
21
+ * 2. **Manual Project Addition**:
22
+ * - Users can manually add project paths via UI
23
+ * - Stored in ~/.claude/project-config.json with 'manuallyAdded' flag
24
+ *
25
+ * ## Error Handling
26
+ *
27
+ * - Missing ~/.claude directory is handled gracefully with automatic creation
28
+ * - ENOENT errors are caught and handled without crashing
29
+ * - Empty arrays returned when no projects/sessions exist
30
+ *
31
+ * ## Caching Strategy
32
+ *
33
+ * - Project directory extraction is cached to minimize file I/O
34
+ * - Cache is cleared when project configuration changes
35
+ * - Session data is fetched on-demand, not cached
36
+ */
37
+
38
+ import { promises as fs } from 'fs';
39
+ import fsSync from 'fs';
40
+ import path from 'path';
41
+ import readline from 'readline';
42
+ import { getUserPaths } from './services/user-directories.js';
43
+
44
+ // Cache for extracted project directories
45
+ const projectDirectoryCache = new Map();
46
+
47
+ // Clear cache when needed (called when project files change)
48
+ function clearProjectDirectoryCache() {
49
+ projectDirectoryCache.clear();
50
+ }
51
+
52
+ // Load project configuration file
53
+ async function loadProjectConfig(userUuid) {
54
+ if (!userUuid) {
55
+ throw new Error('userUuid is required for loadProjectConfig');
56
+ }
57
+ const configPath = path.join(getUserPaths(userUuid).claudeDir, 'project-config.json');
58
+ try {
59
+ const configData = await fs.readFile(configPath, 'utf8');
60
+ return JSON.parse(configData);
61
+ } catch (error) {
62
+ // Return empty config if file doesn't exist
63
+ return {};
64
+ }
65
+ }
66
+
67
+ // Save project configuration file
68
+ async function saveProjectConfig(config, userUuid) {
69
+ if (!userUuid) {
70
+ throw new Error('userUuid is required for saveProjectConfig');
71
+ }
72
+ const claudeDir = getUserPaths(userUuid).claudeDir;
73
+ const configPath = path.join(claudeDir, 'project-config.json');
74
+
75
+ // Ensure the .claude directory exists
76
+ try {
77
+ await fs.mkdir(claudeDir, { recursive: true });
78
+ } catch (error) {
79
+ if (error.code !== 'EEXIST') {
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
85
+ }
86
+
87
+ // Generate better display name from path
88
+ async function generateDisplayName(projectName, actualProjectDir = null) {
89
+ // Use actual project directory if provided, otherwise decode from project name
90
+ let projectPath = actualProjectDir || projectName.replace(/-/g, '/');
91
+
92
+ // Try to read package.json from the project path
93
+ try {
94
+ const packageJsonPath = path.join(projectPath, 'package.json');
95
+ const packageData = await fs.readFile(packageJsonPath, 'utf8');
96
+ const packageJson = JSON.parse(packageData);
97
+
98
+ // Return the name from package.json if it exists
99
+ if (packageJson.name) {
100
+ return packageJson.name;
101
+ }
102
+ } catch (error) {
103
+ // Fall back to path-based naming if package.json doesn't exist or can't be read
104
+ }
105
+
106
+ // If it starts with /, it's an absolute path
107
+ if (projectPath.startsWith('/')) {
108
+ const parts = projectPath.split('/').filter(Boolean);
109
+ // Return only the last folder name
110
+ return parts[parts.length - 1] || projectPath;
111
+ }
112
+
113
+ return projectPath;
114
+ }
115
+
116
+ // Extract the actual project directory from JSONL sessions (with caching)
117
+ async function extractProjectDirectory(projectName, userUuid) {
118
+ if (!userUuid) {
119
+ throw new Error('userUuid is required for extractProjectDirectory');
120
+ }
121
+ // Check cache first
122
+ const cacheKey = `${userUuid}:${projectName}`;
123
+ if (projectDirectoryCache.has(cacheKey)) {
124
+ return projectDirectoryCache.get(cacheKey);
125
+ }
126
+
127
+ // Check project config for originalPath (manually added projects via UI or platform)
128
+ // This handles projects with dashes in their directory names correctly
129
+ const config = await loadProjectConfig(userUuid);
130
+ if (config[projectName]?.originalPath) {
131
+ const originalPath = config[projectName].originalPath;
132
+ projectDirectoryCache.set(cacheKey, originalPath);
133
+ return originalPath;
134
+ }
135
+
136
+ const projectDir = path.join(getUserPaths(userUuid).claudeDir, 'projects', projectName);
137
+ const cwdCounts = new Map();
138
+ let latestTimestamp = 0;
139
+ let latestCwd = null;
140
+ let extractedPath;
141
+
142
+ try {
143
+ // Check if the project directory exists
144
+ await fs.access(projectDir);
145
+
146
+ const files = await fs.readdir(projectDir);
147
+ const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
148
+
149
+ if (jsonlFiles.length === 0) {
150
+ // Fall back to decoded project name if no sessions
151
+ extractedPath = projectName.replace(/-/g, '/');
152
+ } else {
153
+ // Process all JSONL files to collect cwd values
154
+ for (const file of jsonlFiles) {
155
+ const jsonlFile = path.join(projectDir, file);
156
+ const fileStream = fsSync.createReadStream(jsonlFile);
157
+ const rl = readline.createInterface({
158
+ input: fileStream,
159
+ crlfDelay: Infinity
160
+ });
161
+
162
+ for await (const line of rl) {
163
+ if (line.trim()) {
164
+ try {
165
+ const entry = JSON.parse(line);
166
+
167
+ if (entry.cwd) {
168
+ // Count occurrences of each cwd
169
+ cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
170
+
171
+ // Track the most recent cwd
172
+ const timestamp = new Date(entry.timestamp || 0).getTime();
173
+ if (timestamp > latestTimestamp) {
174
+ latestTimestamp = timestamp;
175
+ latestCwd = entry.cwd;
176
+ }
177
+ }
178
+ } catch (parseError) {
179
+ // Skip malformed lines
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ // Determine the best cwd to use
186
+ if (cwdCounts.size === 0) {
187
+ // No cwd found, fall back to decoded project name
188
+ extractedPath = projectName.replace(/-/g, '/');
189
+ } else if (cwdCounts.size === 1) {
190
+ // Only one cwd, use it
191
+ extractedPath = Array.from(cwdCounts.keys())[0];
192
+ } else {
193
+ // Multiple cwd values - prefer the most recent one if it has reasonable usage
194
+ const mostRecentCount = cwdCounts.get(latestCwd) || 0;
195
+ const maxCount = Math.max(...cwdCounts.values());
196
+
197
+ // Use most recent if it has at least 25% of the max count
198
+ if (mostRecentCount >= maxCount * 0.25) {
199
+ extractedPath = latestCwd;
200
+ } else {
201
+ // Otherwise use the most frequently used cwd
202
+ for (const [cwd, count] of cwdCounts.entries()) {
203
+ if (count === maxCount) {
204
+ extractedPath = cwd;
205
+ break;
206
+ }
207
+ }
208
+ }
209
+
210
+ // Fallback (shouldn't reach here)
211
+ if (!extractedPath) {
212
+ extractedPath = latestCwd || projectName.replace(/-/g, '/');
213
+ }
214
+ }
215
+ }
216
+
217
+ // Cache the result
218
+ projectDirectoryCache.set(cacheKey, extractedPath);
219
+
220
+ return extractedPath;
221
+
222
+ } catch (error) {
223
+ // If the directory doesn't exist, just use the decoded project name
224
+ if (error.code === 'ENOENT') {
225
+ extractedPath = projectName.replace(/-/g, '/');
226
+ } else {
227
+ console.error(`Error extracting project directory for ${projectName}:`, error);
228
+ // Fall back to decoded project name for other errors
229
+ extractedPath = projectName.replace(/-/g, '/');
230
+ }
231
+
232
+ // Cache the fallback result too
233
+ projectDirectoryCache.set(cacheKey, extractedPath);
234
+
235
+ return extractedPath;
236
+ }
237
+ }
238
+
239
+ async function getProjects(userUuid) {
240
+ if (!userUuid) {
241
+ throw new Error('userUuid is required for getProjects');
242
+ }
243
+ const claudeDir = path.join(getUserPaths(userUuid).claudeDir, 'projects');
244
+
245
+ const config = await loadProjectConfig(userUuid);
246
+ const projects = [];
247
+ const existingProjects = new Set();
248
+
249
+ try {
250
+ // Check if the .claude/projects directory exists
251
+ await fs.access(claudeDir);
252
+
253
+ // First, get existing Claude projects from the file system
254
+ const entries = await fs.readdir(claudeDir, { withFileTypes: true });
255
+
256
+ for (const entry of entries) {
257
+ if (entry.isDirectory()) {
258
+ existingProjects.add(entry.name);
259
+ const projectPath = path.join(claudeDir, entry.name);
260
+
261
+ // Extract actual project directory from JSONL sessions
262
+ const actualProjectDir = await extractProjectDirectory(entry.name, userUuid);
263
+
264
+ // Get display name from config or generate one
265
+ const customName = config[entry.name]?.displayName;
266
+ const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir);
267
+ const fullPath = actualProjectDir;
268
+
269
+ const project = {
270
+ name: entry.name,
271
+ path: actualProjectDir,
272
+ displayName: customName || autoDisplayName,
273
+ fullPath: fullPath,
274
+ isCustomName: !!customName,
275
+ sessions: []
276
+ };
277
+
278
+ // Try to get sessions for this project (just first 5 for performance)
279
+ try {
280
+ const sessionResult = await getSessions(entry.name, 5, 0, userUuid);
281
+ project.sessions = sessionResult.sessions || [];
282
+ project.sessionMeta = {
283
+ hasMore: sessionResult.hasMore,
284
+ total: sessionResult.total
285
+ };
286
+ } catch (e) {
287
+ console.warn(`Could not load sessions for project ${entry.name}:`, e.message);
288
+ }
289
+
290
+ projects.push(project);
291
+ }
292
+ }
293
+ } catch (error) {
294
+ // If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects
295
+ if (error.code !== 'ENOENT') {
296
+ console.error('Error reading projects directory:', error);
297
+ }
298
+ }
299
+
300
+ // Add manually configured projects that don't exist as folders yet
301
+ // We need to check by actual path (not just encoded project name) to avoid duplicates
302
+ // because Claude CLI may use a different encoding than addProjectManually
303
+ const existingPaths = new Set(projects.map(p => p.fullPath));
304
+
305
+ for (const [projectName, projectConfig] of Object.entries(config)) {
306
+ if (projectConfig.manuallyAdded) {
307
+ // Use the original path if available, otherwise extract from potential sessions
308
+ let actualProjectDir = projectConfig.originalPath;
309
+
310
+ if (!actualProjectDir) {
311
+ try {
312
+ actualProjectDir = await extractProjectDirectory(projectName, userUuid);
313
+ } catch (error) {
314
+ // Fall back to decoded project name
315
+ actualProjectDir = projectName.replace(/-/g, '/');
316
+ }
317
+ }
318
+
319
+ // Check if this project already exists by comparing actual paths
320
+ // This prevents duplicates when Claude CLI creates a folder with a different encoded name
321
+ if (existingPaths.has(actualProjectDir)) {
322
+ continue;
323
+ }
324
+
325
+ // Also check if the encoded name already exists
326
+ if (existingProjects.has(projectName)) {
327
+ continue;
328
+ }
329
+
330
+ const project = {
331
+ name: projectName,
332
+ path: actualProjectDir,
333
+ displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
334
+ fullPath: actualProjectDir,
335
+ isCustomName: !!projectConfig.displayName,
336
+ isManuallyAdded: true,
337
+ sessions: []
338
+ };
339
+
340
+ projects.push(project);
341
+ }
342
+ }
343
+
344
+ return projects;
345
+ }
346
+
347
+ async function getSessions(projectName, limit = 5, offset = 0, userUuid) {
348
+ if (!userUuid) {
349
+ throw new Error('userUuid is required for getSessions');
350
+ }
351
+ const projectDir = path.join(getUserPaths(userUuid).claudeDir, 'projects', projectName);
352
+
353
+ try {
354
+ const files = await fs.readdir(projectDir);
355
+ // agent-*.jsonl files contain session start data at this point. This needs to be revisited
356
+ // periodically to make sure only accurate data is there and no new functionality is added there
357
+ const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
358
+
359
+ if (jsonlFiles.length === 0) {
360
+ return { sessions: [], hasMore: false, total: 0 };
361
+ }
362
+
363
+ // Sort files by modification time (newest first)
364
+ const filesWithStats = await Promise.all(
365
+ jsonlFiles.map(async (file) => {
366
+ const filePath = path.join(projectDir, file);
367
+ const stats = await fs.stat(filePath);
368
+ return { file, mtime: stats.mtime };
369
+ })
370
+ );
371
+ filesWithStats.sort((a, b) => b.mtime - a.mtime);
372
+
373
+ const allSessions = new Map();
374
+ const allEntries = [];
375
+ const uuidToSessionMap = new Map();
376
+
377
+ // Collect all sessions and entries from all files
378
+ for (const { file } of filesWithStats) {
379
+ const jsonlFile = path.join(projectDir, file);
380
+ const result = await parseJsonlSessions(jsonlFile);
381
+
382
+ result.sessions.forEach(session => {
383
+ if (!allSessions.has(session.id)) {
384
+ allSessions.set(session.id, session);
385
+ }
386
+ });
387
+
388
+ allEntries.push(...result.entries);
389
+
390
+ // Early exit optimization for large projects
391
+ if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) {
392
+ break;
393
+ }
394
+ }
395
+
396
+ // Build UUID-to-session mapping for timeline detection
397
+ allEntries.forEach(entry => {
398
+ if (entry.uuid && entry.sessionId) {
399
+ uuidToSessionMap.set(entry.uuid, entry.sessionId);
400
+ }
401
+ });
402
+
403
+ // Group sessions by first user message ID
404
+ const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] }
405
+ const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId
406
+
407
+ // Find the first user message for each session
408
+ allEntries.forEach(entry => {
409
+ if (entry.sessionId && entry.type === 'user' && entry.parentUuid === null && entry.uuid) {
410
+ // This is a first user message in a session (parentUuid is null)
411
+ const firstUserMsgId = entry.uuid;
412
+
413
+ if (!sessionToFirstUserMsgId.has(entry.sessionId)) {
414
+ sessionToFirstUserMsgId.set(entry.sessionId, firstUserMsgId);
415
+
416
+ const session = allSessions.get(entry.sessionId);
417
+ if (session) {
418
+ if (!sessionGroups.has(firstUserMsgId)) {
419
+ sessionGroups.set(firstUserMsgId, {
420
+ latestSession: session,
421
+ allSessions: [session]
422
+ });
423
+ } else {
424
+ const group = sessionGroups.get(firstUserMsgId);
425
+ group.allSessions.push(session);
426
+
427
+ // Update latest session if this one is more recent
428
+ if (new Date(session.lastActivity) > new Date(group.latestSession.lastActivity)) {
429
+ group.latestSession = session;
430
+ }
431
+ }
432
+ }
433
+ }
434
+ }
435
+ });
436
+
437
+ // Collect all sessions that don't belong to any group (standalone sessions)
438
+ const groupedSessionIds = new Set();
439
+ sessionGroups.forEach(group => {
440
+ group.allSessions.forEach(session => groupedSessionIds.add(session.id));
441
+ });
442
+
443
+ const standaloneSessionsArray = Array.from(allSessions.values())
444
+ .filter(session => !groupedSessionIds.has(session.id));
445
+
446
+ // Combine grouped sessions (only show latest from each group) + standalone sessions
447
+ const latestFromGroups = Array.from(sessionGroups.values()).map(group => {
448
+ const session = { ...group.latestSession };
449
+ // Add metadata about grouping
450
+ if (group.allSessions.length > 1) {
451
+ session.isGrouped = true;
452
+ session.groupSize = group.allSessions.length;
453
+ session.groupSessions = group.allSessions.map(s => s.id);
454
+ }
455
+ return session;
456
+ });
457
+ const visibleSessions = [...latestFromGroups, ...standaloneSessionsArray]
458
+ .filter(session => !session.summary.startsWith('{ "'))
459
+ .sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
460
+
461
+ const total = visibleSessions.length;
462
+ const paginatedSessions = visibleSessions.slice(offset, offset + limit);
463
+ const hasMore = offset + limit < total;
464
+
465
+ return {
466
+ sessions: paginatedSessions,
467
+ hasMore,
468
+ total,
469
+ offset,
470
+ limit
471
+ };
472
+ } catch (error) {
473
+ console.error(`Error reading sessions for project ${projectName}:`, error);
474
+ return { sessions: [], hasMore: false, total: 0 };
475
+ }
476
+ }
477
+
478
+ async function parseJsonlSessions(filePath) {
479
+ const sessions = new Map();
480
+ const entries = [];
481
+ const pendingSummaries = new Map(); // leafUuid -> summary for entries without sessionId
482
+
483
+ try {
484
+ const fileStream = fsSync.createReadStream(filePath);
485
+ const rl = readline.createInterface({
486
+ input: fileStream,
487
+ crlfDelay: Infinity
488
+ });
489
+
490
+ for await (const line of rl) {
491
+ if (line.trim()) {
492
+ try {
493
+ const entry = JSON.parse(line);
494
+ entries.push(entry);
495
+
496
+ // Handle summary entries that don't have sessionId yet
497
+ if (entry.type === 'summary' && entry.summary && !entry.sessionId && entry.leafUuid) {
498
+ pendingSummaries.set(entry.leafUuid, entry.summary);
499
+ }
500
+
501
+ if (entry.sessionId) {
502
+ // Skip meta messages (e.g., skill/command loading messages)
503
+ if (entry.isMeta) {
504
+ continue;
505
+ }
506
+
507
+ // Check if this is an actual message (user or assistant)
508
+ const isMessage = entry.type === 'user' || entry.type === 'assistant';
509
+
510
+ if (!sessions.has(entry.sessionId)) {
511
+ sessions.set(entry.sessionId, {
512
+ id: entry.sessionId,
513
+ summary: 'New Session',
514
+ messageCount: 0,
515
+ lastActivity: new Date(),
516
+ cwd: entry.cwd || '',
517
+ lastUserMessage: null,
518
+ lastAssistantMessage: null
519
+ });
520
+ }
521
+
522
+ const session = sessions.get(entry.sessionId);
523
+
524
+ // Apply pending summary if this entry has a parentUuid that matches a pending summary
525
+ if (session.summary === 'New Session' && entry.parentUuid && pendingSummaries.has(entry.parentUuid)) {
526
+ session.summary = pendingSummaries.get(entry.parentUuid);
527
+ }
528
+
529
+ // Update summary from summary entries with sessionId
530
+ if (entry.type === 'summary' && entry.summary) {
531
+ session.summary = entry.summary;
532
+ }
533
+
534
+ // Track last user and assistant messages (skip system messages)
535
+ if (entry.message?.role === 'user' && entry.message?.content) {
536
+ const content = entry.message.content;
537
+
538
+ // Extract text from array format if needed
539
+ let textContent = content;
540
+ if (Array.isArray(content) && content.length > 0 && content[0].type === 'text') {
541
+ textContent = content[0].text;
542
+ }
543
+
544
+ const isSystemMessage = typeof textContent === 'string' && (
545
+ textContent.startsWith('<command-name>') ||
546
+ textContent.startsWith('<command-message>') ||
547
+ textContent.startsWith('<command-args>') ||
548
+ textContent.startsWith('<local-command-stdout>') ||
549
+ textContent.startsWith('<system-reminder>') ||
550
+ textContent.startsWith('Caveat:') ||
551
+ textContent.startsWith('This session is being continued from a previous') ||
552
+ textContent.startsWith('Invalid API key') ||
553
+ textContent.includes('{"subtasks":') || // Filter Task Master prompts
554
+ textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') || // Filter Task Master system prompts
555
+ textContent === 'Warmup' // Explicitly filter out "Warmup"
556
+ );
557
+
558
+ if (typeof textContent === 'string' && textContent.length > 0 && !isSystemMessage) {
559
+ session.lastUserMessage = textContent;
560
+ }
561
+ } else if (entry.message?.role === 'assistant' && entry.message?.content) {
562
+ // Skip API error messages using the isApiErrorMessage flag
563
+ if (entry.isApiErrorMessage === true) {
564
+ // Skip this message entirely
565
+ } else {
566
+ // Track last assistant text message
567
+ let assistantText = null;
568
+
569
+ if (Array.isArray(entry.message.content)) {
570
+ for (const part of entry.message.content) {
571
+ if (part.type === 'text' && part.text) {
572
+ assistantText = part.text;
573
+ }
574
+ }
575
+ } else if (typeof entry.message.content === 'string') {
576
+ assistantText = entry.message.content;
577
+ }
578
+
579
+ // Additional filter for assistant messages with system content
580
+ const isSystemAssistantMessage = typeof assistantText === 'string' && (
581
+ assistantText.startsWith('Invalid API key') ||
582
+ assistantText.includes('{"subtasks":') ||
583
+ assistantText.includes('CRITICAL: You MUST respond with ONLY a JSON')
584
+ );
585
+
586
+ if (assistantText && !isSystemAssistantMessage) {
587
+ session.lastAssistantMessage = assistantText;
588
+ }
589
+ }
590
+ }
591
+
592
+ // Only count actual messages (user or assistant), not queue-operation, summary, etc.
593
+ if (isMessage) {
594
+ session.messageCount++;
595
+ }
596
+
597
+ if (entry.timestamp) {
598
+ session.lastActivity = new Date(entry.timestamp);
599
+ }
600
+ }
601
+ } catch (parseError) {
602
+ // Skip malformed lines silently
603
+ }
604
+ }
605
+ }
606
+
607
+ // After processing all entries, set final summary based on last message if no summary exists
608
+ for (const session of sessions.values()) {
609
+ if (session.summary === 'New Session') {
610
+ // Prefer last user message, fall back to last assistant message
611
+ const lastMessage = session.lastUserMessage || session.lastAssistantMessage;
612
+ if (lastMessage) {
613
+ session.summary = lastMessage.length > 50 ? lastMessage.substring(0, 50) + '...' : lastMessage;
614
+ }
615
+ }
616
+ }
617
+
618
+ // Filter out sessions that contain JSON responses (Task Master errors)
619
+ const allSessions = Array.from(sessions.values());
620
+ const filteredSessions = allSessions.filter(session => {
621
+ const shouldFilter = session.summary.startsWith('{ "');
622
+ if (shouldFilter) {
623
+ }
624
+ // Log a sample of summaries to debug
625
+ if (Math.random() < 0.01) { // Log 1% of sessions
626
+ }
627
+ return !shouldFilter;
628
+ });
629
+
630
+
631
+ return {
632
+ sessions: filteredSessions,
633
+ entries: entries
634
+ };
635
+
636
+ } catch (error) {
637
+ console.error('Error reading JSONL file:', error);
638
+ return { sessions: [], entries: [] };
639
+ }
640
+ }
641
+
642
+ // Get messages for a specific session with pagination support
643
+ async function getSessionMessages(projectName, sessionId, limit = null, offset = 0, userUuid) {
644
+ if (!userUuid) {
645
+ throw new Error('userUuid is required for getSessionMessages');
646
+ }
647
+ const projectDir = path.join(getUserPaths(userUuid).claudeDir, 'projects', projectName);
648
+
649
+ try {
650
+ const files = await fs.readdir(projectDir);
651
+ // agent-*.jsonl files contain session start data at this point. This needs to be revisited
652
+ // periodically to make sure only accurate data is there and no new functionality is added there
653
+ const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
654
+
655
+ if (jsonlFiles.length === 0) {
656
+ return { messages: [], total: 0, hasMore: false };
657
+ }
658
+
659
+ const messages = [];
660
+
661
+ // Process all JSONL files to find messages for this session
662
+ for (const file of jsonlFiles) {
663
+ const jsonlFile = path.join(projectDir, file);
664
+ const fileStream = fsSync.createReadStream(jsonlFile);
665
+ const rl = readline.createInterface({
666
+ input: fileStream,
667
+ crlfDelay: Infinity
668
+ });
669
+
670
+ for await (const line of rl) {
671
+ if (line.trim()) {
672
+ try {
673
+ const entry = JSON.parse(line);
674
+ if (entry.sessionId === sessionId) {
675
+ messages.push(entry);
676
+ }
677
+ } catch (parseError) {
678
+ console.warn('Error parsing line:', parseError.message);
679
+ }
680
+ }
681
+ }
682
+ }
683
+
684
+ // Sort messages by timestamp
685
+ const sortedMessages = messages.sort((a, b) =>
686
+ new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
687
+ );
688
+
689
+ const total = sortedMessages.length;
690
+
691
+ // If no limit is specified, return all messages (backward compatibility)
692
+ if (limit === null) {
693
+ return sortedMessages;
694
+ }
695
+
696
+ // Apply pagination - for recent messages, we need to slice from the end
697
+ // offset 0 should give us the most recent messages
698
+ const startIndex = Math.max(0, total - offset - limit);
699
+ const endIndex = total - offset;
700
+ const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
701
+ const hasMore = startIndex > 0;
702
+
703
+ return {
704
+ messages: paginatedMessages,
705
+ total,
706
+ hasMore,
707
+ offset,
708
+ limit
709
+ };
710
+ } catch (error) {
711
+ console.error(`Error reading messages for session ${sessionId}:`, error);
712
+ return limit === null ? [] : { messages: [], total: 0, hasMore: false };
713
+ }
714
+ }
715
+
716
+ // Rename a project's display name
717
+ async function renameProject(projectName, newDisplayName, userUuid) {
718
+ if (!userUuid) {
719
+ throw new Error('userUuid is required for renameProject');
720
+ }
721
+ const config = await loadProjectConfig(userUuid);
722
+
723
+ if (!newDisplayName || newDisplayName.trim() === '') {
724
+ // Remove custom name if empty, will fall back to auto-generated
725
+ delete config[projectName];
726
+ } else {
727
+ // Set custom display name
728
+ config[projectName] = {
729
+ displayName: newDisplayName.trim()
730
+ };
731
+ }
732
+
733
+ await saveProjectConfig(config, userUuid);
734
+ return true;
735
+ }
736
+
737
+ // Delete a session from a project
738
+ async function deleteSession(projectName, sessionId, userUuid) {
739
+ if (!userUuid) {
740
+ throw new Error('userUuid is required for deleteSession');
741
+ }
742
+ const projectDir = path.join(getUserPaths(userUuid).claudeDir, 'projects', projectName);
743
+
744
+ try {
745
+ const files = await fs.readdir(projectDir);
746
+ const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
747
+
748
+ if (jsonlFiles.length === 0) {
749
+ throw new Error('No session files found for this project');
750
+ }
751
+
752
+ // Check all JSONL files to find which one contains the session
753
+ for (const file of jsonlFiles) {
754
+ const jsonlFile = path.join(projectDir, file);
755
+ const content = await fs.readFile(jsonlFile, 'utf8');
756
+ const lines = content.split('\n').filter(line => line.trim());
757
+
758
+ // Check if this file contains the session
759
+ const hasSession = lines.some(line => {
760
+ try {
761
+ const data = JSON.parse(line);
762
+ return data.sessionId === sessionId;
763
+ } catch {
764
+ return false;
765
+ }
766
+ });
767
+
768
+ if (hasSession) {
769
+ // Filter out all entries for this session
770
+ const filteredLines = lines.filter(line => {
771
+ try {
772
+ const data = JSON.parse(line);
773
+ return data.sessionId !== sessionId;
774
+ } catch {
775
+ return true; // Keep malformed lines
776
+ }
777
+ });
778
+
779
+ // Write back the filtered content
780
+ await fs.writeFile(jsonlFile, filteredLines.join('\n') + (filteredLines.length > 0 ? '\n' : ''));
781
+ return true;
782
+ }
783
+ }
784
+
785
+ throw new Error(`Session ${sessionId} not found in any files`);
786
+ } catch (error) {
787
+ console.error(`Error deleting session ${sessionId} from project ${projectName}:`, error);
788
+ throw error;
789
+ }
790
+ }
791
+
792
+ // Check if a project is empty (has no sessions)
793
+ async function isProjectEmpty(projectName, userUuid) {
794
+ if (!userUuid) {
795
+ throw new Error('userUuid is required for isProjectEmpty');
796
+ }
797
+ try {
798
+ const sessionsResult = await getSessions(projectName, 1, 0, userUuid);
799
+ return sessionsResult.total === 0;
800
+ } catch (error) {
801
+ console.error(`Error checking if project ${projectName} is empty:`, error);
802
+ return false;
803
+ }
804
+ }
805
+
806
+ // Delete an empty project
807
+ async function deleteProject(projectName, userUuid) {
808
+ if (!userUuid) {
809
+ throw new Error('userUuid is required for deleteProject');
810
+ }
811
+ const projectDir = path.join(getUserPaths(userUuid).claudeDir, 'projects', projectName);
812
+
813
+ try {
814
+ // First check if the project is empty
815
+ const isEmpty = await isProjectEmpty(projectName, userUuid);
816
+ if (!isEmpty) {
817
+ throw new Error('Cannot delete project with existing sessions');
818
+ }
819
+
820
+ // Remove the project directory
821
+ await fs.rm(projectDir, { recursive: true, force: true });
822
+
823
+ // Remove from project config
824
+ const config = await loadProjectConfig(userUuid);
825
+ delete config[projectName];
826
+ await saveProjectConfig(config, userUuid);
827
+
828
+ return true;
829
+ } catch (error) {
830
+ console.error(`Error deleting project ${projectName}:`, error);
831
+ throw error;
832
+ }
833
+ }
834
+
835
+ // Add a project manually to the config (without creating folders)
836
+ async function addProjectManually(projectPath, displayName = null, userUuid) {
837
+ if (!userUuid) {
838
+ throw new Error('userUuid is required for addProjectManually');
839
+ }
840
+ const absolutePath = path.resolve(projectPath);
841
+
842
+ try {
843
+ // Check if the path exists
844
+ await fs.access(absolutePath);
845
+ } catch (error) {
846
+ throw new Error(`Path does not exist: ${absolutePath}`);
847
+ }
848
+
849
+ // Generate project name (encode path for use as directory name)
850
+ const projectName = absolutePath.replace(/\//g, '-');
851
+
852
+ // Check if project already exists in config
853
+ const config = await loadProjectConfig(userUuid);
854
+
855
+ if (config[projectName]) {
856
+ throw new Error(`Project already configured for path: ${absolutePath}`);
857
+ }
858
+
859
+ // Allow adding projects even if the directory exists - this enables tracking
860
+ // existing Claude Code or Cursor projects in the UI
861
+
862
+ // Add to config as manually added project
863
+ config[projectName] = {
864
+ manuallyAdded: true,
865
+ originalPath: absolutePath
866
+ };
867
+
868
+ if (displayName) {
869
+ config[projectName].displayName = displayName;
870
+ }
871
+
872
+ await saveProjectConfig(config, userUuid);
873
+
874
+
875
+ return {
876
+ name: projectName,
877
+ path: absolutePath,
878
+ fullPath: absolutePath,
879
+ displayName: displayName || await generateDisplayName(projectName, absolutePath),
880
+ isManuallyAdded: true,
881
+ sessions: []
882
+ };
883
+ }
884
+
885
+ export {
886
+ getProjects,
887
+ getSessions,
888
+ getSessionMessages,
889
+ parseJsonlSessions,
890
+ renameProject,
891
+ deleteSession,
892
+ isProjectEmpty,
893
+ deleteProject,
894
+ addProjectManually,
895
+ loadProjectConfig,
896
+ saveProjectConfig,
897
+ extractProjectDirectory,
898
+ clearProjectDirectoryCache
899
+ };