@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/AGENTS.md +109 -0
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +104 -0
- package/RELEASE.md +146 -0
- package/docs/API.md +471 -0
- package/docs/DEVELOPMENT.md +556 -0
- package/docs/INSTALLATION.md +329 -0
- package/docs/README.md +102 -0
- package/docs/TROUBLESHOOTING.md +630 -0
- package/docs/images/homepage.png +0 -0
- package/docs/images/session-detail.png +0 -0
- package/docs/images/time-analysis.png +0 -0
- package/docs/unified-event-format-design.md +844 -0
- package/docs/unified-event-format-implementation.md +350 -0
- package/eslint.config.mjs +133 -0
- package/package.json +10 -4
- package/public/js/homepage.min.js +35 -0
- package/public/js/session-detail.min.js +461 -0
- package/public/js/telemetry-browser.min.js +1 -0
- package/public/js/time-analyze.min.js +518 -0
- package/scripts/release.sh +43 -0
- package/server.js +3 -0
- package/src/app.js +2 -1
- package/src/controllers/insightController.js +31 -0
- package/src/controllers/sessionController.js +56 -0
- package/src/controllers/tagController.js +8 -0
- package/src/controllers/uploadController.js +32 -1
- package/src/middleware/common.js +20 -1
- package/src/models/Session.js +12 -2
- package/src/services/sessionRepository.js +98 -108
- package/src/telemetry.js +152 -0
- package/src/utils/fileUtils.js +2 -1
- package/views/index.ejs +9 -494
- package/views/session-vue.ejs +166 -1869
- package/views/telemetry-snippet.ejs +26 -0
- package/views/time-analyze.ejs +2 -2217
- package/.env.example +0 -14
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
|
-
|
|
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
|
}
|
package/src/middleware/common.js
CHANGED
|
@@ -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
|
};
|
package/src/models/Session.js
CHANGED
|
@@ -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
|
|
47
|
-
updatedAt
|
|
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)
|
|
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
|
-
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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 =
|
|
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
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
|
|
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;
|