@qiaolei81/copilot-session-viewer 0.3.4 → 0.3.6

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 (45) hide show
  1. package/README.md +3 -3
  2. package/bin/copilot-session-viewer +2 -2
  3. package/dist/server.min.js +99 -0
  4. package/package.json +5 -17
  5. package/public/js/homepage.min.js +9 -9
  6. package/public/js/session-detail.min.js +36 -7
  7. package/public/vendor/marked.umd.min.js +8 -0
  8. package/public/vendor/purify.min.js +3 -0
  9. package/public/vendor/vue-virtual-scroller.css +1 -0
  10. package/public/vendor/vue-virtual-scroller.min.js +2 -0
  11. package/public/vendor/vue.global.prod.min.js +19 -0
  12. package/views/session-vue.ejs +31 -6
  13. package/views/time-analyze.ejs +2 -2
  14. package/lib/parsers/README.md +0 -239
  15. package/lib/parsers/base-parser.js +0 -53
  16. package/lib/parsers/claude-parser.js +0 -181
  17. package/lib/parsers/copilot-parser.js +0 -143
  18. package/lib/parsers/index.js +0 -15
  19. package/lib/parsers/parser-factory.js +0 -77
  20. package/lib/parsers/pi-mono-parser.js +0 -119
  21. package/lib/parsers/vscode-parser.js +0 -591
  22. package/server.js +0 -29
  23. package/src/app.js +0 -129
  24. package/src/config/index.js +0 -27
  25. package/src/controllers/insightController.js +0 -136
  26. package/src/controllers/sessionController.js +0 -449
  27. package/src/controllers/tagController.js +0 -113
  28. package/src/controllers/uploadController.js +0 -648
  29. package/src/middleware/common.js +0 -67
  30. package/src/middleware/rateLimiting.js +0 -62
  31. package/src/models/Session.js +0 -146
  32. package/src/routes/api.js +0 -11
  33. package/src/routes/insights.js +0 -12
  34. package/src/routes/pages.js +0 -12
  35. package/src/routes/uploads.js +0 -14
  36. package/src/schemas/event.schema.js +0 -73
  37. package/src/services/eventNormalizer.js +0 -291
  38. package/src/services/insightService.js +0 -535
  39. package/src/services/sessionRepository.js +0 -1092
  40. package/src/services/sessionService.js +0 -1919
  41. package/src/services/tagService.js +0 -205
  42. package/src/telemetry.js +0 -152
  43. package/src/utils/fileUtils.js +0 -305
  44. package/src/utils/helpers.js +0 -45
  45. package/src/utils/processManager.js +0 -85
@@ -1,449 +0,0 @@
1
- const SessionService = require('../services/sessionService');
2
- const { isValidSessionId, buildMetadata } = require('../utils/helpers');
3
- const { trackEvent, trackMetric } = require('../telemetry');
4
- const AdmZip = require('adm-zip');
5
- const path = require('path');
6
- const fs = require('fs');
7
-
8
- class SessionController {
9
- constructor(sessionService = null) {
10
- this.sessionService = sessionService || new SessionService();
11
- }
12
-
13
- // Homepage with initial load (first batch)
14
- async getHomepage(req, res) {
15
- try {
16
- // Only load default pill (copilot) first 20 sessions
17
- const paginationData = await this.sessionService.getPaginatedSessions(1, 20, 'copilot');
18
-
19
- // Build source path hints from repository config
20
- const sourceHints = {};
21
- if (this.sessionService.sessionRepository && this.sessionService.sessionRepository.sources) {
22
- for (const src of this.sessionService.sessionRepository.sources) {
23
- // Replace home dir with ~ for display
24
- const home = require('os').homedir();
25
- let displayPath = src.dir;
26
- if (displayPath.startsWith(home)) {
27
- displayPath = '~' + displayPath.slice(home.length);
28
- }
29
- // Normalize path separators for display
30
- displayPath = displayPath.replace(/\\/g, '/');
31
- if (!displayPath.endsWith('/')) displayPath += '/';
32
- sourceHints[src.type] = displayPath;
33
- }
34
- }
35
-
36
- // Pass data for infinite scroll
37
- const templateData = {
38
- sessions: paginationData.sessions,
39
- hasMore: paginationData.hasNextPage,
40
- totalSessions: paginationData.totalSessions,
41
- sourceHints: JSON.stringify(sourceHints)
42
- };
43
-
44
- // Track HomepageViewed event
45
- trackEvent('HomepageViewed', {
46
- sessionCount: paginationData.totalSessions.toString(),
47
- sourceFilter: 'copilot'
48
- });
49
-
50
- res.render('index', templateData);
51
- } catch (err) {
52
- console.error('Error loading sessions:', err);
53
- res.status(500).send('Error loading sessions');
54
- }
55
- }
56
-
57
- // Session detail page
58
- async getSessionDetail(req, res) {
59
- try {
60
- const sessionId = req.params.id;
61
-
62
- if (!isValidSessionId(sessionId)) {
63
- return res.status(400).json({ error: 'Invalid session ID' });
64
- }
65
-
66
- const session = await this.sessionService.sessionRepository.findById(sessionId);
67
- if (!session) {
68
- return res.status(404).json({ error: 'Session not found' });
69
- }
70
-
71
- const metadata = buildMetadata(session);
72
-
73
- // Track SessionViewed event
74
- trackEvent('SessionViewed', {
75
- sessionId,
76
- source: session.source || 'unknown',
77
- eventCount: (session.eventCount || metadata.totalEvents || 0).toString(),
78
- duration: (session.duration || metadata.duration || 0).toString(),
79
- model: session.model || metadata.model || 'unknown',
80
- sessionStatus: session.status || metadata.status || 'unknown'
81
- });
82
-
83
- // Track SessionEventCount metric
84
- const eventCount = session.eventCount || metadata.totalEvents || 0;
85
- if (eventCount > 0) {
86
- trackMetric('SessionEventCount', eventCount, { sessionId, source: session.source || 'unknown' });
87
- }
88
-
89
- // Track SessionDuration metric
90
- const duration = session.duration || metadata.duration || 0;
91
- if (duration > 0) {
92
- trackMetric('SessionDuration', duration, { sessionId, source: session.source || 'unknown' });
93
- }
94
-
95
- res.render('session-vue', { sessionId, events: [], metadata });
96
- } catch (err) {
97
- console.error('Error loading session:', err);
98
- res.status(500).json({ error: 'Error loading session' });
99
- }
100
- }
101
-
102
- // Time analysis page
103
- async getTimeAnalysis(req, res) {
104
- try {
105
- const sessionId = req.params.id;
106
-
107
- if (!isValidSessionId(sessionId)) {
108
- return res.status(400).json({ error: 'Invalid session ID' });
109
- }
110
-
111
- const session = await this.sessionService.sessionRepository.findById(sessionId);
112
- if (!session) {
113
- return res.status(404).json({ error: 'Session not found' });
114
- }
115
-
116
- const metadata = buildMetadata(session);
117
-
118
- // Track TimeAnalysisViewed event
119
- trackEvent('TimeAnalysisViewed', {
120
- sessionId,
121
- turnCount: (metadata.totalEvents || 0).toString()
122
- });
123
-
124
- res.render('time-analyze', { sessionId, events: [], metadata });
125
- } catch (err) {
126
- console.error('Error loading time analysis:', err);
127
- res.status(500).json({ error: 'Error loading analysis' });
128
- }
129
- }
130
-
131
- // API: Get sessions with optional pagination
132
- async getSessions(req, res) {
133
- try {
134
- const page = req.query.page ? parseInt(req.query.page) : null;
135
- const limit = req.query.limit ? parseInt(req.query.limit) : null;
136
- const sourceFilter = req.query.source || null;
137
-
138
- if (page && limit) {
139
- // Return paginated response
140
- if (page < 1 || limit < 1 || limit > 100) {
141
- return res.status(400).json({ error: 'Invalid pagination parameters' });
142
- }
143
- const paginationData = await this.sessionService.getPaginatedSessions(page, limit, sourceFilter);
144
-
145
- // Track SessionListLoaded event for API pagination
146
- trackEvent('SessionListLoaded', {
147
- page: page.toString(),
148
- limit: limit.toString(),
149
- totalSessions: paginationData.totalSessions.toString()
150
- });
151
-
152
- res.set({ 'Cache-Control': 'public, max-age=60' });
153
- res.json(paginationData);
154
- } else if (sourceFilter && limit) {
155
- // Source-filtered first page (for pill switching)
156
- const sessions = await this.sessionService.getAllSessions(sourceFilter);
157
- const sliced = sessions.slice(0, limit);
158
- res.set({ 'Cache-Control': 'public, max-age=60' });
159
- res.json({ sessions: sliced, hasMore: sessions.length > limit, totalSessions: sessions.length });
160
- } else {
161
- // Return all sessions for backward compatibility
162
- const sessions = await this.sessionService.getAllSessions(sourceFilter);
163
- res.set({ 'Cache-Control': 'public, max-age=300' });
164
- res.json(sessions);
165
- }
166
- } catch (err) {
167
- console.error('Error loading sessions:', err);
168
- res.status(500).json({ error: 'Error loading sessions' });
169
- }
170
- }
171
-
172
- // API: Load more sessions for infinite scroll
173
- async loadMoreSessions(req, res) {
174
- try {
175
- const offset = parseInt(req.query.offset) || 0;
176
- const limit = parseInt(req.query.limit) || 20;
177
- const sourceFilter = req.query.source || null;
178
-
179
- // Validate parameters
180
- if (offset < 0 || limit < 1 || limit > 50) {
181
- return res.status(400).json({ error: 'Invalid parameters' });
182
- }
183
-
184
- // Calculate page number from offset
185
- const page = Math.floor(offset / limit) + 1;
186
- const paginationData = await this.sessionService.getPaginatedSessions(page, limit, sourceFilter);
187
-
188
- // Track SessionListLoaded event
189
- trackEvent('SessionListLoaded', {
190
- page: page.toString(),
191
- limit: limit.toString(),
192
- totalSessions: paginationData.totalSessions.toString()
193
- });
194
-
195
- res.json({
196
- sessions: paginationData.sessions,
197
- hasMore: paginationData.hasNextPage,
198
- totalSessions: paginationData.totalSessions
199
- });
200
- } catch (err) {
201
- console.error('Error loading more sessions:', err);
202
- res.status(500).json({ error: 'Error loading more sessions' });
203
- }
204
- }
205
-
206
- // API: Get session events
207
- async getSessionEvents(req, res) {
208
- try {
209
- const sessionId = req.params.id;
210
-
211
- // Validate session ID format
212
- if (!isValidSessionId(sessionId)) {
213
- return res.status(400).json({ error: 'Invalid session ID' });
214
- }
215
-
216
- // Check if pagination is requested
217
- const isPaginationRequested = req.query.limit !== undefined || req.query.offset !== undefined;
218
-
219
- // Parse pagination parameters (only if requested)
220
- let limit, offset, result;
221
-
222
- if (isPaginationRequested) {
223
- limit = parseInt(req.query.limit) || 100; // Default 100 events per page
224
- offset = parseInt(req.query.offset) || 0;
225
-
226
- // Validate pagination parameters
227
- if (limit < 1 || limit > 1000) {
228
- return res.status(400).json({ error: 'Limit must be between 1 and 1000' });
229
- }
230
- if (offset < 0) {
231
- return res.status(400).json({ error: 'Offset must be non-negative' });
232
- }
233
- }
234
-
235
- // Get session (needed for findById, no caching)
236
- const session = await this.sessionService.sessionRepository.findById(sessionId);
237
- if (!session) {
238
- return res.status(404).json({ error: 'Session not found' });
239
- }
240
-
241
- // Load events (with or without pagination)
242
- if (isPaginationRequested) {
243
- result = await this.sessionService.getSessionEvents(sessionId, { limit, offset });
244
- } else {
245
- // Load all events (backward compatibility)
246
- const events = await this.sessionService.getSessionEvents(sessionId);
247
- result = events; // Direct array
248
- }
249
-
250
- // No caching for events - session files are live/active
251
- res.set({
252
- 'Cache-Control': 'no-store',
253
- 'Vary': 'Accept-Encoding'
254
- });
255
-
256
- // Return response (paginated or plain array)
257
- if (isPaginationRequested) {
258
- res.json({
259
- events: result.events,
260
- pagination: {
261
- total: result.total,
262
- limit,
263
- offset,
264
- hasMore: offset + limit < result.total
265
- }
266
- });
267
- } else {
268
- res.json(result); // Plain array for backward compatibility
269
- }
270
- } catch (err) {
271
- console.error('Error loading events:', err);
272
- res.status(500).json({ error: 'Error loading events' });
273
- }
274
- }
275
-
276
- // API: Get timeline data (source-agnostic)
277
- async getTimeline(req, res) {
278
- try {
279
- const sessionId = req.params.id;
280
-
281
- // Validate session ID format
282
- if (!isValidSessionId(sessionId)) {
283
- return res.status(400).json({ error: 'Invalid session ID' });
284
- }
285
-
286
- const session = await this.sessionService.getSessionById(sessionId);
287
- if (!session) {
288
- return res.status(404).json({ error: 'Session not found' });
289
- }
290
-
291
- // Generate unified timeline structure
292
- const timeline = await this.sessionService.getTimeline(sessionId);
293
-
294
- // Set caching headers
295
- const crypto = require('crypto');
296
- const etagBase = `${sessionId}-timeline-${session.updatedAt || session.createdAt}`;
297
- const etag = crypto.createHash('md5').update(etagBase).digest('hex');
298
-
299
- res.set({
300
- 'ETag': etag,
301
- 'Cache-Control': 'private, max-age=300',
302
- 'Vary': 'Accept-Encoding'
303
- });
304
-
305
- res.json(timeline);
306
- } catch (err) {
307
- console.error('Error loading timeline:', err);
308
- res.status(500).json({ error: 'Error loading timeline' });
309
- }
310
- }
311
-
312
- // Export session as zip
313
- async exportSession(req, res) {
314
- const sessionId = req.params.id;
315
-
316
- if (!isValidSessionId(sessionId)) {
317
- return res.status(400).json({ error: 'Invalid session ID' });
318
- }
319
-
320
- try {
321
- // Get session to verify it exists and get its source
322
- const session = await this.sessionService.sessionRepository.findById(sessionId);
323
- if (!session) {
324
- return res.status(404).json({ error: 'Session not found' });
325
- }
326
-
327
- // Find session file/directory path based on source
328
- let sessionPath;
329
- let isDirectory = false;
330
-
331
- if (session.directory) {
332
- // Try session directory first (copilot dirs, vscode dirs)
333
- try {
334
- const stats = await fs.promises.stat(session.directory);
335
- if (stats.isDirectory()) {
336
- sessionPath = session.directory;
337
- isDirectory = true;
338
- }
339
- } catch {
340
- // Fall through to filePath
341
- }
342
- }
343
-
344
- if (!sessionPath && session.filePath) {
345
- // File-based sessions (claude .jsonl, pi-mono .jsonl, copilot .jsonl)
346
- try {
347
- await fs.promises.access(session.filePath);
348
- sessionPath = session.filePath;
349
- } catch {
350
- // Not accessible
351
- }
352
- }
353
-
354
- // Legacy source-specific lookup as fallback
355
- if (!sessionPath) {
356
- if (session.source === 'copilot') {
357
- const copilotSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'copilot');
358
- if (copilotSource) {
359
- const basePath = path.join(copilotSource.dir, sessionId);
360
- try {
361
- const stats = await fs.promises.stat(basePath);
362
- if (stats.isDirectory()) {
363
- sessionPath = basePath;
364
- isDirectory = true;
365
- } else {
366
- sessionPath = `${basePath}.jsonl`;
367
- }
368
- } catch {
369
- sessionPath = `${basePath}.jsonl`;
370
- }
371
- }
372
- } else if (session.source === 'claude') {
373
- const claudeSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'claude');
374
- if (claudeSource) {
375
- const projectDirs = await fs.promises.readdir(path.join(claudeSource.dir, 'projects'));
376
- for (const projectDir of projectDirs) {
377
- const candidatePath = path.join(claudeSource.dir, 'projects', projectDir, `${sessionId}.jsonl`);
378
- try {
379
- await fs.promises.access(candidatePath);
380
- sessionPath = candidatePath;
381
- break;
382
- } catch {
383
- // Try next project
384
- }
385
- }
386
- }
387
- } else if (session.source === 'pi-mono') {
388
- const piMonoSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'pi-mono');
389
- if (piMonoSource) {
390
- const files = await fs.promises.readdir(piMonoSource.dir);
391
- const matchingFile = files.find(f => f.includes(sessionId) && f.endsWith('.jsonl'));
392
- if (matchingFile) {
393
- sessionPath = path.join(piMonoSource.dir, matchingFile);
394
- }
395
- }
396
- }
397
- }
398
-
399
- if (!sessionPath) {
400
- return res.status(404).json({ error: 'Session file not found' });
401
- }
402
-
403
- // Verify path exists
404
- try {
405
- await fs.promises.access(sessionPath);
406
- } catch {
407
- return res.status(404).json({ error: 'Session file not accessible' });
408
- }
409
-
410
- // Create zip
411
- const zip = new AdmZip();
412
-
413
- if (isDirectory) {
414
- // Add entire directory (includes tags.json if present)
415
- zip.addLocalFolder(sessionPath, sessionId);
416
- } else {
417
- // Add session file
418
- const fileName = path.basename(sessionPath);
419
- zip.addLocalFile(sessionPath, '', fileName);
420
-
421
- // Also include tags file if it exists
422
- const TagService = require('../services/tagService');
423
- const tagService = new TagService();
424
- const tagsFilePath = tagService.getSessionTagsFilePath(session);
425
- try {
426
- await fs.promises.access(tagsFilePath);
427
- zip.addLocalFile(tagsFilePath, '', path.basename(tagsFilePath));
428
- } catch {
429
- // No tags file, skip
430
- }
431
- }
432
-
433
- // Send zip file
434
- const zipBuffer = zip.toBuffer();
435
- res.setHeader('Content-Type', 'application/zip');
436
- res.setHeader('Content-Disposition', `attachment; filename="session-${sessionId}.zip"`);
437
-
438
- // Track SessionExported event
439
- trackEvent('SessionExported', { sessionId });
440
-
441
- res.send(zipBuffer);
442
- } catch (err) {
443
- console.error('Error exporting session:', err);
444
- res.status(500).json({ error: 'Error exporting session' });
445
- }
446
- }
447
- }
448
-
449
- module.exports = SessionController;
@@ -1,113 +0,0 @@
1
- const TagService = require('../services/tagService');
2
- const SessionRepository = require('../services/sessionRepository');
3
- const { isValidSessionId } = require('../utils/helpers');
4
- const { trackEvent } = require('../telemetry');
5
-
6
- class TagController {
7
- constructor(tagService = null, sessionRepository = null) {
8
- this.tagService = tagService || new TagService();
9
- this.sessionRepository = sessionRepository || new SessionRepository();
10
- }
11
-
12
- /**
13
- * GET /api/tags
14
- * Get all unique tags across all sessions (for autocomplete)
15
- */
16
- async getAllTags(req, res) {
17
- try {
18
- const tags = await this.tagService.getAllKnownTags();
19
- res.json({ tags });
20
- } catch (err) {
21
- console.error('Error getting all tags:', err);
22
- res.status(500).json({ error: 'Error loading tags' });
23
- }
24
- }
25
-
26
- /**
27
- * GET /api/sessions/:id/tags
28
- * Get tags for a specific session
29
- */
30
- async getSessionTags(req, res) {
31
- try {
32
- const sessionId = req.params.id;
33
-
34
- if (!isValidSessionId(sessionId)) {
35
- return res.status(400).json({ error: 'Invalid session ID' });
36
- }
37
-
38
- // Find session by ID
39
- const session = await this.sessionRepository.findById(sessionId);
40
- if (!session) {
41
- return res.status(404).json({ error: 'Session not found' });
42
- }
43
-
44
- const tags = await this.tagService.getSessionTags(session);
45
- res.json({ tags });
46
- } catch (err) {
47
- console.error('Error getting session tags:', err);
48
- res.status(500).json({ error: 'Error loading session tags' });
49
- }
50
- }
51
-
52
- /**
53
- * PUT /api/sessions/:id/tags
54
- * Set tags for a specific session
55
- * Body: { tags: ["tag1", "tag2"] }
56
- */
57
- async setSessionTags(req, res) {
58
- try {
59
- const sessionId = req.params.id;
60
- const { tags } = req.body;
61
-
62
- if (!isValidSessionId(sessionId)) {
63
- return res.status(400).json({ error: 'Invalid session ID' });
64
- }
65
-
66
- if (!Array.isArray(tags)) {
67
- return res.status(400).json({ error: 'Tags must be an array' });
68
- }
69
-
70
- // Validate tag count
71
- if (tags.length > 10) {
72
- return res.status(400).json({ error: 'Maximum 10 tags per session' });
73
- }
74
-
75
- // Validate tag length
76
- for (const tag of tags) {
77
- if (typeof tag !== 'string' || tag.trim().length === 0) {
78
- return res.status(400).json({ error: 'Tags must be non-empty strings' });
79
- }
80
- if (tag.length > 30) {
81
- return res.status(400).json({ error: 'Tag length must not exceed 30 characters' });
82
- }
83
- }
84
-
85
- // Find session by ID
86
- const session = await this.sessionRepository.findById(sessionId);
87
- if (!session) {
88
- return res.status(404).json({ error: 'Session not found' });
89
- }
90
-
91
- const savedTags = await this.tagService.setSessionTags(session, tags);
92
-
93
- // Track TagUpdated event
94
- trackEvent('TagUpdated', {
95
- sessionId,
96
- tagCount: savedTags.length.toString()
97
- });
98
-
99
- res.json({ tags: savedTags });
100
- } catch (err) {
101
- console.error('Error setting session tags:', err);
102
- if (err.message === 'Maximum 10 tags per session') {
103
- return res.status(400).json({ error: err.message });
104
- }
105
- if (err.message === 'Session must have a directory field') {
106
- return res.status(400).json({ error: 'Session does not support tagging' });
107
- }
108
- res.status(500).json({ error: 'Error saving session tags' });
109
- }
110
- }
111
- }
112
-
113
- module.exports = TagController;