@qiaolei81/copilot-session-viewer 0.3.3 → 0.3.5

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 (60) hide show
  1. package/bin/copilot-session-viewer +2 -2
  2. package/dist/server.min.js +99 -0
  3. package/package.json +14 -3
  4. package/public/vendor/marked.umd.min.js +8 -0
  5. package/public/vendor/purify.min.js +3 -0
  6. package/public/vendor/vue-virtual-scroller.css +1 -0
  7. package/public/vendor/vue-virtual-scroller.min.js +2 -0
  8. package/public/vendor/vue.global.prod.min.js +19 -0
  9. package/views/session-vue.ejs +5 -5
  10. package/views/time-analyze.ejs +2 -2
  11. package/.nycrc +0 -29
  12. package/AGENTS.md +0 -109
  13. package/CHANGELOG.md +0 -313
  14. package/CONTRIBUTING.md +0 -104
  15. package/RELEASE.md +0 -146
  16. package/docs/API.md +0 -471
  17. package/docs/DEVELOPMENT.md +0 -556
  18. package/docs/INSTALLATION.md +0 -329
  19. package/docs/README.md +0 -102
  20. package/docs/TROUBLESHOOTING.md +0 -630
  21. package/docs/images/homepage.png +0 -0
  22. package/docs/images/session-detail.png +0 -0
  23. package/docs/images/time-analysis.png +0 -0
  24. package/docs/unified-event-format-design.md +0 -844
  25. package/docs/unified-event-format-implementation.md +0 -350
  26. package/eslint.config.mjs +0 -133
  27. package/examples/parser-usage.js +0 -114
  28. package/lib/parsers/README.md +0 -239
  29. package/lib/parsers/base-parser.js +0 -53
  30. package/lib/parsers/claude-parser.js +0 -181
  31. package/lib/parsers/copilot-parser.js +0 -143
  32. package/lib/parsers/index.js +0 -15
  33. package/lib/parsers/parser-factory.js +0 -77
  34. package/lib/parsers/pi-mono-parser.js +0 -119
  35. package/lib/parsers/vscode-parser.js +0 -591
  36. package/scripts/release.sh +0 -43
  37. package/server.js +0 -29
  38. package/src/app.js +0 -129
  39. package/src/config/index.js +0 -27
  40. package/src/controllers/insightController.js +0 -136
  41. package/src/controllers/sessionController.js +0 -449
  42. package/src/controllers/tagController.js +0 -113
  43. package/src/controllers/uploadController.js +0 -648
  44. package/src/middleware/common.js +0 -67
  45. package/src/middleware/rateLimiting.js +0 -62
  46. package/src/models/Session.js +0 -146
  47. package/src/routes/api.js +0 -11
  48. package/src/routes/insights.js +0 -12
  49. package/src/routes/pages.js +0 -12
  50. package/src/routes/uploads.js +0 -14
  51. package/src/schemas/event.schema.js +0 -73
  52. package/src/services/eventNormalizer.js +0 -291
  53. package/src/services/insightService.js +0 -535
  54. package/src/services/sessionRepository.js +0 -1092
  55. package/src/services/sessionService.js +0 -1919
  56. package/src/services/tagService.js +0 -205
  57. package/src/telemetry.js +0 -152
  58. package/src/utils/fileUtils.js +0 -305
  59. package/src/utils/helpers.js +0 -45
  60. 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;