@qiaolei81/copilot-session-viewer 0.3.1 → 0.3.3

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
@@ -9,7 +9,7 @@ const config = require('./config');
9
9
  // Middleware
10
10
  // Rate limiting disabled for local development
11
11
  // const { globalLimiter, insightGenerationLimiter, insightAccessLimiter, uploadLimiter } = require('./middleware/rateLimiting');
12
- const { requestTimeout, developmentCors, errorHandler, notFoundHandler } = require('./middleware/common');
12
+ const { requestTimeout, developmentCors, errorHandler, notFoundHandler, telemetryLocals } = require('./middleware/common');
13
13
 
14
14
  // Controllers
15
15
  const SessionController = require('./controllers/sessionController');
@@ -68,6 +68,7 @@ function createApp(options = {}) {
68
68
  app.use(express.json({ limit: '1mb' }));
69
69
  app.use(express.urlencoded({ extended: true }));
70
70
  app.use(requestTimeout);
71
+ app.use(telemetryLocals);
71
72
 
72
73
  // CORS in development
73
74
  if (config.NODE_ENV === 'development') {
@@ -1,5 +1,6 @@
1
1
  const InsightService = require('../services/insightService');
2
2
  const { isValidSessionId } = require('../utils/helpers');
3
+ const { trackEvent, trackMetric, trackException } = require('../telemetry');
3
4
 
4
5
  class InsightController {
5
6
  constructor(insightService = null, sessionService = null) {
@@ -39,10 +40,30 @@ class InsightController {
39
40
  return res.status(400).json({ error: 'Session directory not available' });
40
41
  }
41
42
 
43
+ const startTime = Date.now();
42
44
  const result = await this.insightService.generateInsight(session.id, session.directory, session.source, forceRegenerate);
45
+ const durationMs = Date.now() - startTime;
46
+
47
+ // Track InsightGenerated event
48
+ trackEvent('InsightGenerated', {
49
+ sessionId,
50
+ source: session.source || 'unknown',
51
+ durationMs: durationMs.toString()
52
+ });
53
+
54
+ // Track InsightGenerationTime metric
55
+ trackMetric('InsightGenerationTime', durationMs, { sessionId, source: session.source || 'unknown' });
56
+
43
57
  res.json(result);
44
58
  } catch (err) {
45
59
  console.error('Error generating insight:', err);
60
+
61
+ // Track insight generation failure
62
+ trackException(err, {
63
+ sessionId: req.params.id,
64
+ operation: 'generateInsight'
65
+ });
66
+
46
67
  res.status(500).json({ error: err.message || 'Error generating insight' });
47
68
  }
48
69
  }
@@ -67,6 +88,12 @@ class InsightController {
67
88
  }
68
89
 
69
90
  const result = await this.insightService.getInsightStatus(session.id, session.directory, session.source);
91
+
92
+ // Track InsightViewed event if insight is ready
93
+ if (result.status === 'ready' && result.report) {
94
+ trackEvent('InsightViewed', { sessionId });
95
+ }
96
+
70
97
  res.json(result);
71
98
  } catch (err) {
72
99
  console.error('Error getting insight status:', err);
@@ -94,6 +121,10 @@ class InsightController {
94
121
  }
95
122
 
96
123
  const result = await this.insightService.deleteInsight(session.id, session.directory, session.source);
124
+
125
+ // Track InsightDeleted event
126
+ trackEvent('InsightDeleted', { sessionId });
127
+
97
128
  res.json(result);
98
129
  } catch (err) {
99
130
  console.error('Error deleting insight:', err);
@@ -1,5 +1,6 @@
1
1
  const SessionService = require('../services/sessionService');
2
2
  const { isValidSessionId, buildMetadata } = require('../utils/helpers');
3
+ const { trackEvent, trackMetric } = require('../telemetry');
3
4
  const AdmZip = require('adm-zip');
4
5
  const path = require('path');
5
6
  const fs = require('fs');
@@ -40,6 +41,12 @@ class SessionController {
40
41
  sourceHints: JSON.stringify(sourceHints)
41
42
  };
42
43
 
44
+ // Track HomepageViewed event
45
+ trackEvent('HomepageViewed', {
46
+ sessionCount: paginationData.totalSessions.toString(),
47
+ sourceFilter: 'copilot'
48
+ });
49
+
43
50
  res.render('index', templateData);
44
51
  } catch (err) {
45
52
  console.error('Error loading sessions:', err);
@@ -62,6 +69,29 @@ class SessionController {
62
69
  }
63
70
 
64
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
+
65
95
  res.render('session-vue', { sessionId, events: [], metadata });
66
96
  } catch (err) {
67
97
  console.error('Error loading session:', err);
@@ -84,6 +114,13 @@ class SessionController {
84
114
  }
85
115
 
86
116
  const metadata = buildMetadata(session);
117
+
118
+ // Track TimeAnalysisViewed event
119
+ trackEvent('TimeAnalysisViewed', {
120
+ sessionId,
121
+ turnCount: (metadata.totalEvents || 0).toString()
122
+ });
123
+
87
124
  res.render('time-analyze', { sessionId, events: [], metadata });
88
125
  } catch (err) {
89
126
  console.error('Error loading time analysis:', err);
@@ -104,6 +141,14 @@ class SessionController {
104
141
  return res.status(400).json({ error: 'Invalid pagination parameters' });
105
142
  }
106
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
+
107
152
  res.set({ 'Cache-Control': 'public, max-age=60' });
108
153
  res.json(paginationData);
109
154
  } else if (sourceFilter && limit) {
@@ -140,6 +185,13 @@ class SessionController {
140
185
  const page = Math.floor(offset / limit) + 1;
141
186
  const paginationData = await this.sessionService.getPaginatedSessions(page, limit, sourceFilter);
142
187
 
188
+ // Track SessionListLoaded event
189
+ trackEvent('SessionListLoaded', {
190
+ page: page.toString(),
191
+ limit: limit.toString(),
192
+ totalSessions: paginationData.totalSessions.toString()
193
+ });
194
+
143
195
  res.json({
144
196
  sessions: paginationData.sessions,
145
197
  hasMore: paginationData.hasNextPage,
@@ -382,6 +434,10 @@ class SessionController {
382
434
  const zipBuffer = zip.toBuffer();
383
435
  res.setHeader('Content-Type', 'application/zip');
384
436
  res.setHeader('Content-Disposition', `attachment; filename="session-${sessionId}.zip"`);
437
+
438
+ // Track SessionExported event
439
+ trackEvent('SessionExported', { sessionId });
440
+
385
441
  res.send(zipBuffer);
386
442
  } catch (err) {
387
443
  console.error('Error exporting session:', err);
@@ -1,6 +1,7 @@
1
1
  const TagService = require('../services/tagService');
2
2
  const SessionRepository = require('../services/sessionRepository');
3
3
  const { isValidSessionId } = require('../utils/helpers');
4
+ const { trackEvent } = require('../telemetry');
4
5
 
5
6
  class TagController {
6
7
  constructor(tagService = null, sessionRepository = null) {
@@ -88,6 +89,13 @@ class TagController {
88
89
  }
89
90
 
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
+
91
99
  res.json({ tags: savedTags });
92
100
  } catch (err) {
93
101
  console.error('Error setting session tags:', err);
@@ -4,6 +4,7 @@ const os = require('os');
4
4
  const multer = require('multer');
5
5
  const { spawn } = require('child_process');
6
6
  const { isValidSessionId } = require('../utils/helpers');
7
+ const { trackEvent, trackException } = require('../telemetry');
7
8
  const processManager = require('../utils/processManager');
8
9
  const config = require('../config');
9
10
 
@@ -72,6 +73,9 @@ class UploadController {
72
73
  return res.status(500).json({ error: 'Failed to create zip file' });
73
74
  }
74
75
 
76
+ // Track SessionShared event
77
+ trackEvent('SessionShared', { sessionId });
78
+
75
79
  res.download(zipFile, `session-${sessionId}.zip`, (err) => {
76
80
  fs.promises.unlink(zipFile).catch(() => {});
77
81
  if (err) {
@@ -181,6 +185,7 @@ class UploadController {
181
185
  processManager.register(unzipProcess, { name: 'unzip-import' });
182
186
 
183
187
  unzipProcess.on('close', async (code) => {
188
+ let sessionDirName; // Declare here for access in catch block
184
189
  try {
185
190
  await fs.promises.unlink(zipPath);
186
191
 
@@ -195,7 +200,7 @@ class UploadController {
195
200
  return res.status(400).json({ error: 'Empty zip file' });
196
201
  }
197
202
 
198
- const sessionDirName = entries[0];
203
+ sessionDirName = entries[0];
199
204
 
200
205
  // Validate session directory name to prevent Zip Slip path traversal
201
206
  if (!isValidSessionId(sessionDirName)) {
@@ -222,9 +227,23 @@ class UploadController {
222
227
  await fs.promises.rename(sessionPath, targetPath);
223
228
  await fs.promises.rm(extractDir, { recursive: true, force: true });
224
229
 
230
+ // Track SessionImported event
231
+ const stats = await fs.promises.stat(zipPath).catch(() => ({ size: 0 }));
232
+ trackEvent('SessionImported', {
233
+ format: 'copilot',
234
+ fileSize: stats.size.toString()
235
+ });
236
+
225
237
  res.json({ success: true, sessionId: sessionDirName });
226
238
  } catch (err) {
227
239
  console.error('Error importing session:', err);
240
+
241
+ // Track import failure
242
+ trackException(err, {
243
+ operation: 'importSession',
244
+ sessionId: sessionDirName || 'unknown'
245
+ });
246
+
228
247
  await fs.promises.rm(extractDir, { recursive: true, force: true }).catch(() => {});
229
248
  res.status(500).json({ error: 'Error importing session' });
230
249
  }
@@ -232,12 +251,24 @@ class UploadController {
232
251
 
233
252
  unzipProcess.on('error', async (err) => {
234
253
  console.error('Error extracting zip:', err);
254
+
255
+ // Track upload/extraction failure
256
+ trackException(err, {
257
+ operation: 'importSession_unzip'
258
+ });
259
+
235
260
  await fs.promises.unlink(zipPath).catch(() => {});
236
261
  await fs.promises.rm(extractDir, { recursive: true, force: true }).catch(() => {});
237
262
  res.status(500).json({ error: 'Failed to extract zip file' });
238
263
  });
239
264
  } catch (err) {
240
265
  console.error('Error processing upload:', err);
266
+
267
+ // Track upload processing failure
268
+ trackException(err, {
269
+ operation: 'importSession_upload'
270
+ });
271
+
241
272
  if (req.file) {
242
273
  await fs.promises.unlink(req.file.path).catch(() => {});
243
274
  }
@@ -1,4 +1,5 @@
1
1
  const config = require('../config');
2
+ const { trackException, isEnabled: isTelemetryEnabled } = require('../telemetry');
2
3
 
3
4
  // Request timeout middleware
4
5
  const requestTimeout = (req, res, next) => {
@@ -6,6 +7,15 @@ const requestTimeout = (req, res, next) => {
6
7
  next();
7
8
  };
8
9
 
10
+ // Telemetry middleware - makes telemetry settings available to templates
11
+ const telemetryLocals = (req, res, next) => {
12
+ res.locals.telemetryEnabled = isTelemetryEnabled;
13
+ res.locals.telemetryConnectionString = isTelemetryEnabled
14
+ ? (process.env.APPLICATIONINSIGHTS_CONNECTION_STRING || 'InstrumentationKey=39f4fbf1-d82f-42c3-b4ef-ea92a1fd82cb;IngestionEndpoint=https://eastus-8.in.applicationinsights.azure.com/;LiveEndpoint=https://eastus.livediagnostics.monitor.azure.com/;ApplicationId=7d4bb432-f2f5-4526-a5e6-31901e5a2db2')
15
+ : null;
16
+ next();
17
+ };
18
+
9
19
  // CORS middleware for development
10
20
  const developmentCors = (req, res, next) => {
11
21
  if (config.NODE_ENV === 'development') {
@@ -24,6 +34,14 @@ const developmentCors = (req, res, next) => {
24
34
  const errorHandler = (err, req, res, _next) => {
25
35
  console.error('Unhandled error:', err.stack);
26
36
 
37
+ // Track exception in Application Insights
38
+ trackException(err, {
39
+ url: req.url,
40
+ method: req.method,
41
+ statusCode: (err.status || 500).toString(),
42
+ userAgent: (req.headers && req.headers['user-agent']) || 'unknown'
43
+ });
44
+
27
45
  const statusCode = err.status || 500;
28
46
  // Default to production-safe behavior if NODE_ENV is not set
29
47
  const isDevelopment = config.NODE_ENV === 'development';
@@ -44,5 +62,6 @@ module.exports = {
44
62
  requestTimeout,
45
63
  developmentCors,
46
64
  errorHandler,
47
- notFoundHandler
65
+ notFoundHandler,
66
+ telemetryLocals
48
67
  };
@@ -40,11 +40,21 @@ class Session {
40
40
  * @returns {Session}
41
41
  */
42
42
  static fromDirectory(dirPath, id, stats, workspace, eventCount, duration, isImported, hasInsight, copilotVersion, selectedModel, sessionStatus) {
43
+ const createdAt = workspace?.created_at
44
+ ? new Date(workspace.created_at)
45
+ : workspace?.startTime
46
+ ? new Date(workspace.startTime)
47
+ : stats.birthtime;
48
+ const updatedAt = workspace?.updated_at
49
+ ? new Date(workspace.updated_at)
50
+ : workspace?.endTime
51
+ ? new Date(workspace.endTime)
52
+ : stats.mtime;
43
53
  return new Session(id, 'directory', {
44
54
  directory: dirPath, // Add directory path
45
55
  workspace: workspace,
46
- createdAt: workspace?.created_at || stats.birthtime,
47
- updatedAt: workspace?.updated_at || stats.mtime,
56
+ createdAt,
57
+ updatedAt,
48
58
  summary: workspace?.summary || 'No summary',
49
59
  hasEvents: eventCount > 0,
50
60
  eventCount: eventCount,
@@ -512,6 +512,8 @@ class SessionRepository {
512
512
  async _findVsCodeSession(sessionId, workspaceStorageDir) {
513
513
  try {
514
514
  const hashes = await fs.readdir(workspaceStorageDir);
515
+ const candidates = [];
516
+
515
517
  for (const hash of hashes) {
516
518
  const chatSessionsDir = path.join(workspaceStorageDir, hash, 'chatSessions');
517
519
  try {
@@ -529,58 +531,24 @@ class SessionRepository {
529
531
  sessionJson = JSON.parse(raw);
530
532
  }
531
533
  const requests = sessionJson.requests || [];
532
- if (requests.length === 0) return null;
533
-
534
- const firstReq = requests[0];
535
- const createdAt = sessionJson.creationDate
536
- ? new Date(sessionJson.creationDate)
537
- : (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
538
- const lastReqTime2 = requests[requests.length - 1].timestamp ? new Date(requests[requests.length - 1].timestamp) : null;
539
- const updatedAt = sessionJson.lastMessageDate
540
- ? new Date(sessionJson.lastMessageDate)
541
- : (lastReqTime2 || stats.mtime);
542
- const userText = this._extractVsCodeUserText(firstReq.message);
543
-
544
- const copilotChatVersion = firstReq.agent?.extensionVersion || null;
545
- const realWorkspacePath = await this._resolveVsCodeWorkspacePath(path.join(workspaceStorageDir, hash));
534
+ if (requests.length === 0) continue;
546
535
 
547
- // Same estimation logic as scan path
548
- const _fileMtime2 = stats.mtime;
549
- // Count tools for estimation
550
- let toolCount2 = 0;
551
- for (const req of requests) {
552
- toolCount2 += (req.response || []).filter(r => r.kind === 'toolInvocationSerialized').length;
553
- }
554
- let effectiveEnd2;
555
- if (toolCount2 > 10 && lastReqTime2) {
556
- const estimatedDurationMs2 = Math.max(toolCount2 * 3500, 60000);
557
- effectiveEnd2 = new Date(createdAt.getTime() + estimatedDurationMs2);
558
- } else if (lastReqTime2) {
559
- effectiveEnd2 = lastReqTime2;
560
- } else {
561
- effectiveEnd2 = updatedAt;
562
- }
563
-
564
- return new Session(sessionId, 'file', {
565
- source: 'vscode',
566
- filePath: fullPath,
567
- workspaceHash: hash,
568
- createdAt,
569
- updatedAt: effectiveEnd2,
570
- summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
571
- hasEvents: true,
572
- eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
573
- duration: effectiveEnd2.getTime() - createdAt.getTime(),
574
- sessionStatus: ((Date.now() - effectiveEnd2.getTime()) < 15 * 60 * 1000 || (Date.now() - stats.mtime.getTime()) < 15 * 60 * 1000) ? 'wip' : 'completed',
575
- selectedModel: firstReq.modelId || null,
576
- copilotVersion: copilotChatVersion,
577
- workspace: { cwd: realWorkspacePath || path.join(workspaceStorageDir, hash) },
578
- });
536
+ const realWorkspacePath = await this._resolveVsCodeWorkspacePath(path.join(workspaceStorageDir, hash));
537
+ const statsWithPath = { ...stats, filePath: fullPath };
538
+ candidates.push(this._buildVsCodeSession(
539
+ sessionId, requests, sessionJson, statsWithPath, hash,
540
+ realWorkspacePath || path.join(workspaceStorageDir, hash)
541
+ ));
579
542
  }
580
543
  } catch {
581
544
  // No chatSessions dir or can't read — skip
582
545
  }
583
546
  }
547
+ // Return the candidate with the latest effectiveEndTime (most complete data)
548
+ if (candidates.length > 0) {
549
+ candidates.sort((a, b) => (b.updatedAt?.getTime?.() ?? 0) - (a.updatedAt?.getTime?.() ?? 0));
550
+ return candidates[0];
551
+ }
584
552
  } catch (err) {
585
553
  console.error(`[VSCode findById] Error searching VSCode sessions: ${err.message}`, err.stack);
586
554
  }
@@ -685,6 +653,15 @@ class SessionRepository {
685
653
  if (!workspace.summary && optimizedMetadata.firstUserMessage) {
686
654
  workspace.summary = optimizedMetadata.firstUserMessage;
687
655
  }
656
+
657
+ // Use max of filesystem mtime and last event timestamp for updatedAt
658
+ if (optimizedMetadata.lastEventTime) {
659
+ const lastEventMs = new Date(optimizedMetadata.lastEventTime).getTime();
660
+ const mtimeMs = new Date(stats.mtime).getTime();
661
+ if (lastEventMs > mtimeMs) {
662
+ stats = { ...stats, mtime: new Date(lastEventMs) };
663
+ }
664
+ }
688
665
  }
689
666
 
690
667
  const session = Session.fromDirectory(fullPath, entry, stats, workspace, eventCount, duration, isImported, hasInsight, copilotVersion, selectedModel, sessionStatus);
@@ -727,7 +704,7 @@ class SessionRepository {
727
704
  return 'completed';
728
705
  }
729
706
  if (metadata.lastEventTime !== null && metadata.lastEventTime !== undefined) {
730
- const WIP_THRESHOLD_MS = 15 * 60 * 1000;
707
+ const WIP_THRESHOLD_MS = 5 * 60 * 1000;
731
708
  if ((Date.now() - metadata.lastEventTime) < WIP_THRESHOLD_MS) {
732
709
  return 'wip';
733
710
  }
@@ -962,68 +939,11 @@ class SessionRepository {
962
939
  const requests = sessionJson.requests || [];
963
940
  if (requests.length === 0) continue;
964
941
 
965
- const firstReq = requests[0];
966
- const lastReq = requests[requests.length - 1];
967
- const createdAt = sessionJson.creationDate
968
- ? new Date(sessionJson.creationDate)
969
- : (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
970
- const lastReqTime = lastReq.timestamp ? new Date(lastReq.timestamp) : null;
971
- const updatedAt = sessionJson.lastMessageDate
972
- ? new Date(sessionJson.lastMessageDate)
973
- : (lastReqTime || stats.mtime);
974
-
975
- // Count tool invocations across all requests (must be before effectiveEndTime calc)
976
- let toolCount = 0;
977
- for (const req of requests) {
978
- toolCount += (req.response || []).filter(r => r.kind === 'toolInvocationSerialized').length;
979
- }
980
-
981
- // For VSCode agentic sessions, file mtime may be more accurate than last request timestamp
982
- // because sub-agents write incremental updates over time without new request timestamps.
983
- // BUT: VSCode may touch/sync all files at once, creating misleading mtimes.
984
- // Strategy:
985
- // - If session has many tool invocations (agentic), estimate duration from tool count
986
- // - Otherwise fall back to request timestamps
987
- const _fileMtime = stats.mtime;
988
- let effectiveEndTime;
989
- if (toolCount > 10 && lastReqTime) {
990
- // Agentic session: estimate ~3.5s per tool invocation as a rough heuristic
991
- const estimatedDurationMs = Math.max(toolCount * 3500, 60000); // at least 1 min
992
- const estimatedEnd = new Date(createdAt.getTime() + estimatedDurationMs);
993
- effectiveEndTime = estimatedEnd;
994
- } else if (lastReqTime) {
995
- effectiveEndTime = lastReqTime;
996
- } else {
997
- effectiveEndTime = updatedAt;
998
- }
999
-
1000
- const model = firstReq.modelId || null;
1001
- const agentId = firstReq.agent?.id || 'vscode-copilot';
1002
- const copilotChatVersion = firstReq.agent?.extensionVersion || null;
1003
- const userText = this._extractVsCodeUserText(firstReq.message);
1004
-
1005
- const session = new Session(
1006
- sessionId,
1007
- 'file',
1008
- {
1009
- source: 'vscode',
1010
- filePath: fullPath,
1011
- workspaceHash,
1012
- createdAt,
1013
- updatedAt: effectiveEndTime,
1014
- summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
1015
- hasEvents: true,
1016
- eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
1017
- duration: effectiveEndTime.getTime() - createdAt.getTime(),
1018
- sessionStatus: ((Date.now() - effectiveEndTime.getTime()) < 15 * 60 * 1000 || (Date.now() - stats.mtime.getTime()) < 15 * 60 * 1000) ? 'wip' : 'completed',
1019
- selectedModel: model,
1020
- agentId,
1021
- toolCount,
1022
- copilotVersion: copilotChatVersion,
1023
- workspace: { cwd: realWorkspacePath || workspaceHashDir },
1024
- }
942
+ const statsWithPath = { ...stats, filePath: fullPath };
943
+ const session = this._buildVsCodeSession(
944
+ sessionId, requests, sessionJson, statsWithPath, workspaceHash,
945
+ realWorkspacePath || workspaceHashDir
1025
946
  );
1026
-
1027
947
  sessions.push(session);
1028
948
  } catch (err) {
1029
949
  // Skip malformed files silently
@@ -1033,6 +953,76 @@ class SessionRepository {
1033
953
  }
1034
954
 
1035
955
  /** Extract plain text from a VSCode message object */
956
+ /**
957
+ * Build a VSCode Session object from parsed JSONL data.
958
+ * Single source of truth for VSCode session construction — used by both
959
+ * the main scan loop and _findVsCodeSession to avoid duplicate logic.
960
+ * @private
961
+ */
962
+ _buildVsCodeSession(sessionId, requests, sessionJson, stats, workspaceHash, workspaceCwd) {
963
+ const firstReq = requests[0];
964
+ const lastReq = requests[requests.length - 1];
965
+
966
+ const createdAt = sessionJson.creationDate
967
+ ? new Date(sessionJson.creationDate)
968
+ : (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
969
+
970
+ const lastReqTime = lastReq.timestamp ? new Date(lastReq.timestamp) : null;
971
+ const fallbackUpdatedAt = sessionJson.lastMessageDate
972
+ ? new Date(sessionJson.lastMessageDate)
973
+ : (lastReqTime || stats.mtime);
974
+
975
+ // Use last terminal command timestamp (truest end time for agentic sessions).
976
+ // terminalCommandState.timestamp = when the agent actually executed a command.
977
+ // request.timestamp = when the user sent the message (start of turn, not end).
978
+ // mtime is unreliable — VSCode syncs/touches all files when the workspace opens.
979
+ const lastTerminalTime = this._extractLastTerminalTimestamp(requests);
980
+ const effectiveEndTime = lastTerminalTime || lastReqTime || fallbackUpdatedAt;
981
+
982
+ const isWip = (Date.now() - effectiveEndTime.getTime()) < 15 * 60 * 1000;
983
+ const userText = this._extractVsCodeUserText(firstReq.message);
984
+ const toolCount = requests.reduce(
985
+ (sum, req) => sum + (req.response || []).filter(r => r.kind === 'toolInvocationSerialized').length,
986
+ 0
987
+ );
988
+
989
+ return new Session(sessionId, 'file', {
990
+ source: 'vscode',
991
+ filePath: stats.filePath,
992
+ workspaceHash,
993
+ createdAt,
994
+ updatedAt: effectiveEndTime,
995
+ summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
996
+ hasEvents: true,
997
+ eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
998
+ duration: effectiveEndTime.getTime() - createdAt.getTime(),
999
+ sessionStatus: isWip ? 'wip' : 'completed',
1000
+ selectedModel: firstReq.modelId || null,
1001
+ agentId: firstReq.agent?.id || 'vscode-copilot',
1002
+ toolCount,
1003
+ copilotVersion: firstReq.agent?.extensionVersion || null,
1004
+ workspace: { cwd: workspaceCwd },
1005
+ });
1006
+ }
1007
+
1008
+ _extractLastTerminalTimestamp(requests) {
1009
+ let maxTs = 0;
1010
+
1011
+ function walk(obj) {
1012
+ if (!obj || typeof obj !== 'object') return;
1013
+ if (Array.isArray(obj)) { obj.forEach(walk); return; }
1014
+ if (obj.terminalCommandState && typeof obj.terminalCommandState.timestamp === 'number') {
1015
+ const ts = obj.terminalCommandState.timestamp;
1016
+ if (ts > 1_000_000_000_000 && ts < 9_999_999_999_999 && ts > maxTs) maxTs = ts;
1017
+ }
1018
+ for (const val of Object.values(obj)) walk(val);
1019
+ }
1020
+
1021
+ for (const req of requests) walk(req.response);
1022
+
1023
+ return maxTs > 0 ? new Date(maxTs) : null;
1024
+ }
1025
+
1036
1026
  _extractVsCodeUserText(message) {
1037
1027
  if (!message) return '';
1038
1028
  if (typeof message.text === 'string') return message.text;