@qiaolei81/copilot-session-viewer 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.js CHANGED
@@ -7,7 +7,8 @@ const helmet = require('helmet');
7
7
  const config = require('./config');
8
8
 
9
9
  // Middleware
10
- const { globalLimiter, insightGenerationLimiter, insightAccessLimiter, uploadLimiter } = require('./middleware/rateLimiting');
10
+ // Rate limiting disabled for local development
11
+ // const { globalLimiter, insightGenerationLimiter, insightAccessLimiter, uploadLimiter } = require('./middleware/rateLimiting');
11
12
  const { requestTimeout, developmentCors, errorHandler, notFoundHandler } = require('./middleware/common');
12
13
 
13
14
  // Controllers
@@ -20,23 +21,45 @@ function createApp(options = {}) {
20
21
 
21
22
  // Create controller instances (with optional dependency injection)
22
23
  const sessionController = new SessionController(options.sessionService);
23
- const insightController = new InsightController(options.insightService);
24
+ const insightController = new InsightController(options.insightService, options.sessionService);
24
25
  const uploadController = new UploadController();
25
26
 
26
- // Security and parsing middleware
27
+ // Minimal security headers for local development tool
28
+ // Custom CSP without upgrade-insecure-requests
29
+ app.use((req, res, next) => {
30
+ res.setHeader(
31
+ 'Content-Security-Policy',
32
+ "default-src 'self'; " +
33
+ "style-src 'self' 'unsafe-inline' https: http:; " +
34
+ "font-src 'self' https: http:; " +
35
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' https: http:; " +
36
+ "img-src 'self' data: https: http:; " +
37
+ "connect-src 'self' https: http:"
38
+ );
39
+ next();
40
+ });
41
+
42
+ // Other helmet protections (without CSP and HSTS)
27
43
  app.use(helmet({
28
- contentSecurityPolicy: {
29
- directives: {
30
- defaultSrc: ['\'self\''],
31
- styleSrc: ['\'self\'', '\'unsafe-inline\'', 'https://fonts.googleapis.com', 'https://cdn.jsdelivr.net'],
32
- fontSrc: ['\'self\'', 'https://fonts.gstatic.com'],
33
- scriptSrc: ['\'self\'', '\'unsafe-inline\'', '\'unsafe-eval\'', 'https://cdn.jsdelivr.net'],
34
- imgSrc: ['\'self\'', 'data:', 'https:']
44
+ contentSecurityPolicy: false,
45
+ hsts: false,
46
+ referrerPolicy: false,
47
+ crossOriginEmbedderPolicy: false,
48
+ crossOriginOpenerPolicy: false,
49
+ crossOriginResourcePolicy: false
50
+ }));
51
+
52
+ app.use(compression({
53
+ level: 9, // Maximum compression for local use (CPU is not a bottleneck)
54
+ threshold: 1024, // Compress responses > 1KB
55
+ filter: (req, res) => {
56
+ // Always compress JSON responses
57
+ if (res.getHeader('Content-Type')?.includes('application/json')) {
58
+ return true;
35
59
  }
60
+ return compression.filter(req, res);
36
61
  }
37
62
  }));
38
-
39
- app.use(compression());
40
63
  app.use(express.json({ limit: '1mb' }));
41
64
  app.use(express.urlencoded({ extended: true }));
42
65
  app.use(requestTimeout);
@@ -46,8 +69,8 @@ function createApp(options = {}) {
46
69
  app.use(developmentCors);
47
70
  }
48
71
 
49
- // Rate limiting
50
- app.use(globalLimiter);
72
+ // Rate limiting - DISABLED for local development
73
+ // app.use(globalLimiter);
51
74
 
52
75
  // Static files
53
76
  app.use('/public', express.static(path.join(__dirname, '../public')));
@@ -62,11 +85,13 @@ function createApp(options = {}) {
62
85
  app.get('/', sessionController.getHomepage.bind(sessionController));
63
86
  app.get('/session/:id', sessionController.getSessionDetail.bind(sessionController));
64
87
  app.get('/session/:id/time-analyze', sessionController.getTimeAnalysis.bind(sessionController));
88
+ app.get('/session/:id/export', sessionController.exportSession.bind(sessionController));
65
89
 
66
90
  // API routes (more specific routes first)
67
91
  app.get('/api/sessions/load-more', sessionController.loadMoreSessions.bind(sessionController));
68
92
  app.get('/api/sessions', sessionController.getSessions.bind(sessionController));
69
93
  app.get('/api/sessions/:id/events', sessionController.getSessionEvents.bind(sessionController));
94
+ app.get('/api/sessions/:id/timeline', sessionController.getTimeline.bind(sessionController));
70
95
 
71
96
  // Upload routes
72
97
  app.get('/session/:id/share', uploadController.shareSession.bind(uploadController));
@@ -75,13 +100,13 @@ function createApp(options = {}) {
75
100
  uploadController.importSession.bind(uploadController)
76
101
  );
77
102
 
78
- // Insight routes with appropriate rate limiting
79
- app.post('/session/:id/insight', insightGenerationLimiter, insightController.generateInsight.bind(insightController));
80
- app.get('/session/:id/insight', insightController.getInsightStatus.bind(insightController)); // Remove rate limiting for GET
81
- app.delete('/session/:id/insight', insightAccessLimiter, insightController.deleteInsight.bind(insightController));
103
+ // Insight routes (rate limiting disabled)
104
+ app.post('/session/:id/insight', insightController.generateInsight.bind(insightController));
105
+ app.get('/session/:id/insight', insightController.getInsightStatus.bind(insightController));
106
+ app.delete('/session/:id/insight', insightController.deleteInsight.bind(insightController));
82
107
 
83
- // Upload rate limiting
84
- app.use('/session/import', uploadLimiter);
108
+ // Upload rate limiting - DISABLED
109
+ // app.use('/session/import', uploadLimiter);
85
110
 
86
111
  // Error handling
87
112
  app.use(notFoundHandler);
@@ -1,15 +1,21 @@
1
1
  const InsightService = require('../services/insightService');
2
2
  const { isValidSessionId } = require('../utils/helpers');
3
- const path = require('path');
4
- const os = require('os');
5
3
 
6
4
  class InsightController {
7
- constructor(insightService = null) {
5
+ constructor(insightService = null, sessionService = null) {
8
6
  if (insightService) {
9
7
  this.insightService = insightService;
10
8
  } else {
11
- const SESSION_DIR = process.env.SESSION_DIR || path.join(os.homedir(), '.copilot', 'session-state');
12
- this.insightService = new InsightService(SESSION_DIR);
9
+ // Use default multi-source configuration
10
+ this.insightService = new InsightService();
11
+ }
12
+
13
+ // SessionService for getting session metadata (source)
14
+ if (sessionService) {
15
+ this.sessionService = sessionService;
16
+ } else {
17
+ const SessionService = require('../services/sessionService');
18
+ this.sessionService = new SessionService();
13
19
  }
14
20
  }
15
21
 
@@ -23,7 +29,17 @@ class InsightController {
23
29
  return res.status(400).json({ error: 'Invalid session ID' });
24
30
  }
25
31
 
26
- const result = await this.insightService.generateInsight(sessionId, forceRegenerate);
32
+ // Get session to determine source and directory
33
+ const session = await this.sessionService.getSessionById(sessionId);
34
+ if (!session) {
35
+ return res.status(404).json({ error: 'Session not found' });
36
+ }
37
+
38
+ if (!session.directory) {
39
+ return res.status(400).json({ error: 'Session directory not available' });
40
+ }
41
+
42
+ const result = await this.insightService.generateInsight(session.id, session.directory, session.source, forceRegenerate);
27
43
  res.json(result);
28
44
  } catch (err) {
29
45
  console.error('Error generating insight:', err);
@@ -40,7 +56,17 @@ class InsightController {
40
56
  return res.status(400).json({ error: 'Invalid session ID' });
41
57
  }
42
58
 
43
- const result = await this.insightService.getInsightStatus(sessionId);
59
+ // Get session to determine directory
60
+ const session = await this.sessionService.getSessionById(sessionId);
61
+ if (!session) {
62
+ return res.status(404).json({ error: 'Session not found' });
63
+ }
64
+
65
+ if (!session.directory) {
66
+ return res.status(400).json({ error: 'Session directory not available' });
67
+ }
68
+
69
+ const result = await this.insightService.getInsightStatus(session.id, session.directory, session.source);
44
70
  res.json(result);
45
71
  } catch (err) {
46
72
  console.error('Error getting insight status:', err);
@@ -57,7 +83,17 @@ class InsightController {
57
83
  return res.status(400).json({ error: 'Invalid session ID' });
58
84
  }
59
85
 
60
- const result = await this.insightService.deleteInsight(sessionId);
86
+ // Get session to determine directory
87
+ const session = await this.sessionService.getSessionById(sessionId);
88
+ if (!session) {
89
+ return res.status(404).json({ error: 'Session not found' });
90
+ }
91
+
92
+ if (!session.directory) {
93
+ return res.status(400).json({ error: 'Session directory not available' });
94
+ }
95
+
96
+ const result = await this.insightService.deleteInsight(session.id, session.directory, session.source);
61
97
  res.json(result);
62
98
  } catch (err) {
63
99
  console.error('Error deleting insight:', err);
@@ -1,5 +1,8 @@
1
1
  const SessionService = require('../services/sessionService');
2
2
  const { isValidSessionId } = require('../utils/helpers');
3
+ const AdmZip = require('adm-zip');
4
+ const path = require('path');
5
+ const fs = require('fs');
3
6
 
4
7
  class SessionController {
5
8
  constructor(sessionService = null) {
@@ -9,7 +12,7 @@ class SessionController {
9
12
  // Homepage with initial load (first batch)
10
13
  async getHomepage(req, res) {
11
14
  try {
12
- const initialLimit = 20; // Load first 20 sessions
15
+ const initialLimit = 100; // Load first 100 sessions to ensure Pi-Mono sessions are included
13
16
  const paginationData = await this.sessionService.getPaginatedSessions(1, initialLimit);
14
17
 
15
18
  // Pass data for infinite scroll
@@ -67,6 +70,7 @@ class SessionController {
67
70
  }
68
71
 
69
72
  const { events, metadata } = sessionData;
73
+ // Use original time-analyze view (supports all sources via normalized events)
70
74
  res.render('time-analyze', { sessionId, events, metadata });
71
75
  } catch (err) {
72
76
  console.error('Error loading time analysis:', err);
@@ -150,13 +154,223 @@ class SessionController {
150
154
  return res.status(400).json({ error: 'Invalid session ID' });
151
155
  }
152
156
 
153
- const events = await this.sessionService.getSessionEvents(sessionId);
154
- res.json(events);
157
+ // Check if pagination is requested
158
+ const isPaginationRequested = req.query.limit !== undefined || req.query.offset !== undefined;
159
+
160
+ // Parse pagination parameters (only if requested)
161
+ let limit, offset, result;
162
+
163
+ if (isPaginationRequested) {
164
+ limit = parseInt(req.query.limit) || 100; // Default 100 events per page
165
+ offset = parseInt(req.query.offset) || 0;
166
+
167
+ // Validate pagination parameters
168
+ if (limit < 1 || limit > 1000) {
169
+ return res.status(400).json({ error: 'Limit must be between 1 and 1000' });
170
+ }
171
+ if (offset < 0) {
172
+ return res.status(400).json({ error: 'Offset must be non-negative' });
173
+ }
174
+ }
175
+
176
+ // Get session metadata for ETag generation
177
+ const session = await this.sessionService.getSessionById(sessionId);
178
+ if (!session) {
179
+ return res.status(404).json({ error: 'Session not found' });
180
+ }
181
+
182
+ // Generate ETag from session ID + timestamp + pagination params (if used)
183
+ const crypto = require('crypto');
184
+ const etagBase = isPaginationRequested
185
+ ? `${sessionId}-${session.updated || session.created}-${limit}-${offset}`
186
+ : `${sessionId}-${session.updated || session.created}`;
187
+ const etag = crypto.createHash('md5').update(etagBase).digest('hex');
188
+
189
+ // Check If-None-Match header (client cache)
190
+ const clientEtag = req.headers['if-none-match'];
191
+ if (clientEtag === etag) {
192
+ return res.status(304).end(); // Not Modified - use cached version
193
+ }
194
+
195
+ // Load events (with or without pagination)
196
+ if (isPaginationRequested) {
197
+ result = await this.sessionService.getSessionEvents(sessionId, { limit, offset });
198
+ } else {
199
+ // Load all events (backward compatibility)
200
+ const events = await this.sessionService.getSessionEvents(sessionId);
201
+ result = events; // Direct array
202
+ }
203
+
204
+ // Set caching headers
205
+ res.set({
206
+ 'ETag': etag,
207
+ 'Cache-Control': 'private, max-age=0, no-cache', // Disable cache during development
208
+ 'Vary': 'Accept-Encoding'
209
+ });
210
+
211
+ // Return response (paginated or plain array)
212
+ if (isPaginationRequested) {
213
+ res.json({
214
+ events: result.events,
215
+ pagination: {
216
+ total: result.total,
217
+ limit,
218
+ offset,
219
+ hasMore: offset + limit < result.total
220
+ }
221
+ });
222
+ } else {
223
+ res.json(result); // Plain array for backward compatibility
224
+ }
155
225
  } catch (err) {
156
226
  console.error('Error loading events:', err);
157
227
  res.status(500).json({ error: 'Error loading events' });
158
228
  }
159
229
  }
230
+
231
+ // API: Get timeline data (source-agnostic)
232
+ async getTimeline(req, res) {
233
+ try {
234
+ const sessionId = req.params.id;
235
+
236
+ // Validate session ID format
237
+ if (!isValidSessionId(sessionId)) {
238
+ return res.status(400).json({ error: 'Invalid session ID' });
239
+ }
240
+
241
+ const session = await this.sessionService.getSessionById(sessionId);
242
+ if (!session) {
243
+ return res.status(404).json({ error: 'Session not found' });
244
+ }
245
+
246
+ // Generate unified timeline structure
247
+ const timeline = await this.sessionService.getTimeline(sessionId);
248
+
249
+ // Set caching headers
250
+ const crypto = require('crypto');
251
+ const etagBase = `${sessionId}-timeline-${session.updated || session.created}`;
252
+ const etag = crypto.createHash('md5').update(etagBase).digest('hex');
253
+
254
+ res.set({
255
+ 'ETag': etag,
256
+ 'Cache-Control': 'private, max-age=300',
257
+ 'Vary': 'Accept-Encoding'
258
+ });
259
+
260
+ res.json(timeline);
261
+ } catch (err) {
262
+ console.error('Error loading timeline:', err);
263
+ res.status(500).json({ error: 'Error loading timeline' });
264
+ }
265
+ }
266
+
267
+ // Export session as zip
268
+ async exportSession(req, res) {
269
+ const sessionId = req.params.id;
270
+
271
+ if (!isValidSessionId(sessionId)) {
272
+ return res.status(400).json({ error: 'Invalid session ID' });
273
+ }
274
+
275
+ try {
276
+ // Get session to verify it exists and get its source
277
+ const session = await this.sessionService.sessionRepository.findById(sessionId);
278
+ if (!session) {
279
+ return res.status(404).json({ error: 'Session not found' });
280
+ }
281
+
282
+ // Find session file/directory path based on source
283
+ let sessionPath;
284
+ let isDirectory = false;
285
+
286
+ if (session.source === 'copilot') {
287
+ const copilotSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'copilot');
288
+ if (!copilotSource) {
289
+ return res.status(404).json({ error: 'Copilot source not found' });
290
+ }
291
+
292
+ const basePath = path.join(copilotSource.dir, sessionId);
293
+ try {
294
+ const stats = await fs.promises.stat(basePath);
295
+ if (stats.isDirectory()) {
296
+ sessionPath = basePath;
297
+ isDirectory = true;
298
+ } else {
299
+ sessionPath = `${basePath}.jsonl`;
300
+ }
301
+ } catch {
302
+ sessionPath = `${basePath}.jsonl`;
303
+ }
304
+ } else if (session.source === 'claude') {
305
+ const claudeSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'claude');
306
+ if (!claudeSource) {
307
+ return res.status(404).json({ error: 'Claude source not found' });
308
+ }
309
+
310
+ // Claude sessions are in projects/*/sessionId.jsonl
311
+ const projectDirs = await fs.promises.readdir(path.join(claudeSource.dir, 'projects'));
312
+ for (const projectDir of projectDirs) {
313
+ const candidatePath = path.join(claudeSource.dir, 'projects', projectDir, `${sessionId}.jsonl`);
314
+ try {
315
+ await fs.promises.access(candidatePath);
316
+ sessionPath = candidatePath;
317
+ break;
318
+ } catch {
319
+ // Try next project
320
+ }
321
+ }
322
+
323
+ if (!sessionPath) {
324
+ return res.status(404).json({ error: 'Session file not found' });
325
+ }
326
+ } else if (session.source === 'pi-mono') {
327
+ const piMonoSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'pi-mono');
328
+ if (!piMonoSource) {
329
+ return res.status(404).json({ error: 'Pi-Mono source not found' });
330
+ }
331
+
332
+ // Pi-Mono sessions are timestamp-based JSONL files
333
+ const files = await fs.promises.readdir(piMonoSource.dir);
334
+ const matchingFile = files.find(f => f.includes(sessionId) && f.endsWith('.jsonl'));
335
+ if (!matchingFile) {
336
+ return res.status(404).json({ error: 'Session file not found' });
337
+ }
338
+ sessionPath = path.join(piMonoSource.dir, matchingFile);
339
+ }
340
+
341
+ if (!sessionPath) {
342
+ return res.status(404).json({ error: 'Session file not found' });
343
+ }
344
+
345
+ // Verify path exists
346
+ try {
347
+ await fs.promises.access(sessionPath);
348
+ } catch {
349
+ return res.status(404).json({ error: 'Session file not accessible' });
350
+ }
351
+
352
+ // Create zip
353
+ const zip = new AdmZip();
354
+
355
+ if (isDirectory) {
356
+ // Add entire directory
357
+ zip.addLocalFolder(sessionPath, sessionId);
358
+ } else {
359
+ // Add single file
360
+ const fileName = path.basename(sessionPath);
361
+ zip.addLocalFile(sessionPath, '', fileName);
362
+ }
363
+
364
+ // Send zip file
365
+ const zipBuffer = zip.toBuffer();
366
+ res.setHeader('Content-Type', 'application/zip');
367
+ res.setHeader('Content-Disposition', `attachment; filename="session-${sessionId}.zip"`);
368
+ res.send(zipBuffer);
369
+ } catch (err) {
370
+ console.error('Error exporting session:', err);
371
+ res.status(500).json({ error: 'Error exporting session' });
372
+ }
373
+ }
160
374
  }
161
375
 
162
376
  module.exports = SessionController;