@qiaolei81/copilot-session-viewer 0.1.9 → 0.2.0
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/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-be-responsive-on-mobile-viewport-1771605454041.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-display-sessions-if-available-1771605462872.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-handle-JavaScript-errors-gracefully-1771605463381.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-handle-session-import-dialog-1771605466264.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-have-working-infinite-scroll-elements-1771605454038.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-load-homepage-with-basic-elements-1771605454001.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-load-time-analysis-page-1771605464990.json +1236 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-navigate-to-session-detail-page-1771605472595.json +1177 -0
- package/.nyc_output/coverage-e2e-merged.json +1 -0
- package/.nyc_output/coverage-homepage-spec-js-Homepage-should-display-session-list-1771605453565.json +435 -0
- package/.nyc_output/coverage-homepage-spec-js-Homepage-should-load-homepage-successfully-1771605453552.json +435 -0
- package/.nyc_output/coverage-homepage-spec-js-Homepage-should-navigate-to-session-detail-on-click-1771605469317.json +1134 -0
- package/.nyc_output/coverage-homepage-spec-js-Homepage-should-show-session-metadata-1771605460581.json +435 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-display-Load-More-Sessions-button-when-there-are-more-sessions-1771605468486.json +435 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-handle-API-errors-gracefully-during-infinite-scroll-1771605482161.json +471 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-hide-Load-More-button-when-no-more-sessions-available-1771605478370.json +471 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-load-additional-sessions-when-Load-More-button-is-clicked-1771605475059.json +471 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-preserve-session-list-state-during-navigation-1771605494575.json +1633 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-show-loading-state-when-Load-More-button-is-clicked-1771605475401.json +471 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-trigger-infinite-scroll-when-scrolling-near-bottom-1771605476949.json +471 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-clear-search-filter-1771605508542.json +1255 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-display-event-list-1771605505572.json +1156 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-display-session-metadata-1771605504552.json +701 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-expand-and-collapse-tool-details-1771605515809.json +1182 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-filter-events-by-search-1771605513421.json +1245 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-load-session-detail-page-1771605494974.json +701 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-toggle-content-visibility-1771605550729.json +1177 -0
- package/.nyc_output/coverage-unit.json +21 -0
- package/.nycrc +29 -0
- package/CHANGELOG.md +36 -0
- package/README.md +154 -15
- package/examples/parser-usage.js +114 -0
- package/lib/parsers/README.md +239 -0
- package/lib/parsers/base-parser.js +53 -0
- package/lib/parsers/claude-parser.js +181 -0
- package/lib/parsers/copilot-parser.js +143 -0
- package/lib/parsers/index.js +13 -0
- package/lib/parsers/parser-factory.js +77 -0
- package/lib/parsers/pi-mono-parser.js +119 -0
- package/package.json +12 -4
- package/server.js +17 -2
- package/src/app.js +45 -20
- package/src/controllers/insightController.js +44 -8
- package/src/controllers/sessionController.js +217 -3
- package/src/controllers/uploadController.js +447 -7
- package/src/middleware/rateLimiting.js +7 -1
- package/src/models/Session.js +26 -0
- package/src/schemas/event.schema.js +73 -0
- package/src/services/eventNormalizer.js +291 -0
- package/src/services/insightService.js +140 -48
- package/src/services/sessionRepository.js +584 -49
- package/src/services/sessionService.js +1588 -36
- package/src/utils/helpers.js +6 -1
- package/views/index.ejs +111 -4
- package/views/session-vue.ejs +272 -65
- package/views/time-analyze.ejs +127 -55
|
@@ -11,14 +11,17 @@ class UploadController {
|
|
|
11
11
|
constructor() {
|
|
12
12
|
this.SESSION_DIR = process.env.SESSION_DIR || path.join(os.homedir(), '.copilot', 'session-state');
|
|
13
13
|
this.uploadDir = path.join(os.tmpdir(), 'copilot-session-uploads');
|
|
14
|
-
this.initializeUploadDir();
|
|
15
|
-
this.upload = this.createMulterInstance();
|
|
16
|
-
}
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
// Multi-format session directories
|
|
16
|
+
this.SESSION_DIRS = {
|
|
17
|
+
copilot: this.SESSION_DIR,
|
|
18
|
+
claude: path.join(os.homedir(), '.claude', 'projects'),
|
|
19
|
+
'pi-mono': path.join(os.homedir(), '.pi', 'agent', 'sessions')
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Don't create uploadDir here - multer's DiskStorage will handle it
|
|
23
|
+
// This avoids EEXIST errors when multiple tests run in parallel
|
|
24
|
+
this.upload = this.createMulterInstance();
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
createMulterInstance() {
|
|
@@ -99,6 +102,80 @@ class UploadController {
|
|
|
99
102
|
|
|
100
103
|
await fs.promises.mkdir(extractDir, { recursive: true });
|
|
101
104
|
|
|
105
|
+
// ZIP bomb protection: Check compressed file size first
|
|
106
|
+
const MAX_COMPRESSED_SIZE = 50 * 1024 * 1024; // 50MB (already enforced by multer)
|
|
107
|
+
const MAX_UNCOMPRESSED_SIZE = 200 * 1024 * 1024; // 200MB
|
|
108
|
+
const MAX_FILE_COUNT = 1000; // Maximum number of files
|
|
109
|
+
const MAX_DEPTH = 5; // Maximum directory nesting depth
|
|
110
|
+
|
|
111
|
+
const stats = await fs.promises.stat(zipPath);
|
|
112
|
+
if (stats.size > MAX_COMPRESSED_SIZE) {
|
|
113
|
+
await fs.promises.unlink(zipPath);
|
|
114
|
+
return res.status(400).json({ error: 'Compressed file too large (max 50MB)' });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// First pass: List zip contents without extracting to check for bombs
|
|
118
|
+
const listProcess = spawn('unzip', ['-l', zipPath]);
|
|
119
|
+
let listOutput = '';
|
|
120
|
+
|
|
121
|
+
listProcess.stdout.on('data', (data) => {
|
|
122
|
+
listOutput += data.toString();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await new Promise((resolve, reject) => {
|
|
126
|
+
listProcess.on('close', (code) => {
|
|
127
|
+
if (code !== 0) {
|
|
128
|
+
reject(new Error('Failed to list zip contents'));
|
|
129
|
+
} else {
|
|
130
|
+
resolve();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
listProcess.on('error', reject);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Parse unzip output to check total size and file count
|
|
137
|
+
const lines = listOutput.split('\n');
|
|
138
|
+
let totalUncompressedSize = 0;
|
|
139
|
+
let fileCount = 0;
|
|
140
|
+
let maxDepth = 0;
|
|
141
|
+
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
const match = line.trim().match(/^\s*(\d+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+(.+)$/);
|
|
144
|
+
if (match) {
|
|
145
|
+
const size = parseInt(match[1]);
|
|
146
|
+
const filename = match[2];
|
|
147
|
+
totalUncompressedSize += size;
|
|
148
|
+
fileCount++;
|
|
149
|
+
|
|
150
|
+
// Check directory depth
|
|
151
|
+
const depth = (filename.match(/\//g) || []).length;
|
|
152
|
+
maxDepth = Math.max(maxDepth, depth);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Validate against ZIP bomb thresholds
|
|
157
|
+
if (totalUncompressedSize > MAX_UNCOMPRESSED_SIZE) {
|
|
158
|
+
await fs.promises.unlink(zipPath);
|
|
159
|
+
return res.status(400).json({
|
|
160
|
+
error: `Uncompressed size too large (${Math.round(totalUncompressedSize / 1024 / 1024)}MB > ${MAX_UNCOMPRESSED_SIZE / 1024 / 1024}MB)`
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (fileCount > MAX_FILE_COUNT) {
|
|
165
|
+
await fs.promises.unlink(zipPath);
|
|
166
|
+
return res.status(400).json({
|
|
167
|
+
error: `Too many files in archive (${fileCount} > ${MAX_FILE_COUNT})`
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (maxDepth > MAX_DEPTH) {
|
|
172
|
+
await fs.promises.unlink(zipPath);
|
|
173
|
+
return res.status(400).json({
|
|
174
|
+
error: `Directory nesting too deep (${maxDepth} > ${MAX_DEPTH})`
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// If all checks pass, proceed with extraction
|
|
102
179
|
const unzipProcess = spawn('unzip', ['-q', zipPath, '-d', extractDir]);
|
|
103
180
|
|
|
104
181
|
processManager.register(unzipProcess, { name: 'unzip-import' });
|
|
@@ -172,6 +249,369 @@ class UploadController {
|
|
|
172
249
|
getUploadMiddleware() {
|
|
173
250
|
return this.upload.single('zipFile');
|
|
174
251
|
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Detect the format of a session from extracted directory
|
|
255
|
+
* @param {string} extractDir - Directory containing extracted session files
|
|
256
|
+
* @returns {Promise<Object|null>} Format information or null if unknown
|
|
257
|
+
*/
|
|
258
|
+
async _detectFormat(extractDir) {
|
|
259
|
+
try {
|
|
260
|
+
const entries = await fs.promises.readdir(extractDir);
|
|
261
|
+
|
|
262
|
+
if (entries.length === 0) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check for Pi-Mono format: timestamped filename pattern YYYY-MM-DDTHH-MM-SS-SSSZ_sessionId.jsonl
|
|
267
|
+
const piMonoPattern = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_([a-zA-Z0-9_-]+)\.jsonl$/;
|
|
268
|
+
for (const entry of entries) {
|
|
269
|
+
const match = entry.match(piMonoPattern);
|
|
270
|
+
if (match) {
|
|
271
|
+
return {
|
|
272
|
+
format: 'pi-mono',
|
|
273
|
+
sessionId: match[1],
|
|
274
|
+
fileName: entry,
|
|
275
|
+
extractDir
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Check for Copilot format: directory with events.jsonl
|
|
281
|
+
for (const entry of entries) {
|
|
282
|
+
const entryPath = path.join(extractDir, entry);
|
|
283
|
+
const stat = await fs.promises.stat(entryPath);
|
|
284
|
+
if (stat.isDirectory()) {
|
|
285
|
+
const eventsFile = path.join(entryPath, 'events.jsonl');
|
|
286
|
+
if (fs.existsSync(eventsFile)) {
|
|
287
|
+
return {
|
|
288
|
+
format: 'copilot',
|
|
289
|
+
sessionId: entry,
|
|
290
|
+
directoryName: entry,
|
|
291
|
+
extractDir
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Check for Claude format: uuid.jsonl file
|
|
298
|
+
const claudePattern = /^([a-zA-Z0-9_-]+)\.jsonl$/;
|
|
299
|
+
for (const entry of entries) {
|
|
300
|
+
const entryPath = path.join(extractDir, entry);
|
|
301
|
+
const stat = await fs.promises.stat(entryPath);
|
|
302
|
+
|
|
303
|
+
if (stat.isFile()) {
|
|
304
|
+
const match = entry.match(claudePattern);
|
|
305
|
+
if (match) {
|
|
306
|
+
const sessionId = match[1];
|
|
307
|
+
// Check if there's an optional directory with the same name
|
|
308
|
+
const sessionDir = path.join(extractDir, sessionId);
|
|
309
|
+
const hasDirectory = fs.existsSync(sessionDir);
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
format: 'claude',
|
|
313
|
+
sessionId,
|
|
314
|
+
fileName: entry,
|
|
315
|
+
hasDirectory,
|
|
316
|
+
directoryName: hasDirectory ? sessionId : undefined,
|
|
317
|
+
extractDir
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return null;
|
|
324
|
+
} catch (err) {
|
|
325
|
+
console.error('Error detecting format:', err);
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Import Copilot format session
|
|
332
|
+
* @param {Object} formatInfo - Format detection result
|
|
333
|
+
* @param {string} extractDir - Extraction directory
|
|
334
|
+
* @returns {Promise<Object>} Import result
|
|
335
|
+
*/
|
|
336
|
+
async _importCopilotSession(formatInfo, extractDir) {
|
|
337
|
+
try {
|
|
338
|
+
const { sessionId, directoryName } = formatInfo;
|
|
339
|
+
|
|
340
|
+
// Validate session ID
|
|
341
|
+
if (!isValidSessionId(sessionId)) {
|
|
342
|
+
return {
|
|
343
|
+
success: false,
|
|
344
|
+
error: 'Invalid session ID',
|
|
345
|
+
statusCode: 400
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const sessionPath = path.join(extractDir, directoryName);
|
|
350
|
+
const targetPath = path.join(this.SESSION_DIRS.copilot, sessionId);
|
|
351
|
+
|
|
352
|
+
// Check for events.jsonl
|
|
353
|
+
const eventsFile = path.join(sessionPath, 'events.jsonl');
|
|
354
|
+
if (!fs.existsSync(eventsFile)) {
|
|
355
|
+
return {
|
|
356
|
+
success: false,
|
|
357
|
+
error: 'Invalid session structure (no events.jsonl)',
|
|
358
|
+
statusCode: 400
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Check if session already exists
|
|
363
|
+
if (fs.existsSync(targetPath)) {
|
|
364
|
+
return {
|
|
365
|
+
success: false,
|
|
366
|
+
error: 'Session already exists',
|
|
367
|
+
statusCode: 409
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Move session directory
|
|
372
|
+
await fs.promises.rename(sessionPath, targetPath);
|
|
373
|
+
|
|
374
|
+
// Mark as imported
|
|
375
|
+
await fs.promises.writeFile(path.join(targetPath, '.imported'), '');
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
success: true,
|
|
379
|
+
sessionId,
|
|
380
|
+
format: 'copilot'
|
|
381
|
+
};
|
|
382
|
+
} catch (err) {
|
|
383
|
+
console.error('Error importing Copilot session:', err);
|
|
384
|
+
return {
|
|
385
|
+
success: false,
|
|
386
|
+
error: `Error importing Copilot session: ${err.message}`,
|
|
387
|
+
statusCode: 500
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Import Claude format session
|
|
394
|
+
* @param {Object} formatInfo - Format detection result
|
|
395
|
+
* @param {string} extractDir - Extraction directory
|
|
396
|
+
* @param {Object} req - Express request object
|
|
397
|
+
* @returns {Promise<Object>} Import result
|
|
398
|
+
*/
|
|
399
|
+
async _importClaudeSession(formatInfo, extractDir, req) {
|
|
400
|
+
try {
|
|
401
|
+
const { sessionId, fileName, hasDirectory, directoryName } = formatInfo;
|
|
402
|
+
|
|
403
|
+
// Validate session ID
|
|
404
|
+
if (!isValidSessionId(sessionId)) {
|
|
405
|
+
return {
|
|
406
|
+
success: false,
|
|
407
|
+
error: 'Invalid session ID',
|
|
408
|
+
statusCode: 400
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Get project from query or use default
|
|
413
|
+
const project = req.query.project || 'imported-sessions';
|
|
414
|
+
|
|
415
|
+
// Create project directory
|
|
416
|
+
const projectPath = path.join(this.SESSION_DIRS.claude, project);
|
|
417
|
+
await fs.promises.mkdir(projectPath, { recursive: true });
|
|
418
|
+
|
|
419
|
+
// Move the .jsonl file
|
|
420
|
+
const sourceFile = path.join(extractDir, fileName);
|
|
421
|
+
const targetFile = path.join(projectPath, fileName);
|
|
422
|
+
await fs.promises.rename(sourceFile, targetFile);
|
|
423
|
+
|
|
424
|
+
// If there's a directory, move it too
|
|
425
|
+
if (hasDirectory && directoryName) {
|
|
426
|
+
const sourceDir = path.join(extractDir, directoryName);
|
|
427
|
+
const targetDir = path.join(projectPath, directoryName);
|
|
428
|
+
await fs.promises.rename(sourceDir, targetDir);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
success: true,
|
|
433
|
+
sessionId,
|
|
434
|
+
format: 'claude',
|
|
435
|
+
project
|
|
436
|
+
};
|
|
437
|
+
} catch (err) {
|
|
438
|
+
console.error('Error importing Claude session:', err);
|
|
439
|
+
return {
|
|
440
|
+
success: false,
|
|
441
|
+
error: `Error importing Claude session: ${err.message}`,
|
|
442
|
+
statusCode: 500
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Import Pi-Mono format session
|
|
449
|
+
* @param {Object} formatInfo - Format detection result
|
|
450
|
+
* @param {string} extractDir - Extraction directory
|
|
451
|
+
* @param {Object} req - Express request object
|
|
452
|
+
* @returns {Promise<Object>} Import result
|
|
453
|
+
*/
|
|
454
|
+
async _importPiMonoSession(formatInfo, extractDir, req) {
|
|
455
|
+
try {
|
|
456
|
+
const { sessionId, fileName } = formatInfo;
|
|
457
|
+
|
|
458
|
+
// Validate session ID
|
|
459
|
+
if (!isValidSessionId(sessionId)) {
|
|
460
|
+
return {
|
|
461
|
+
success: false,
|
|
462
|
+
error: 'Invalid session ID',
|
|
463
|
+
statusCode: 400
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Get project from query or use default
|
|
468
|
+
const project = req.query.project || 'imported-sessions';
|
|
469
|
+
|
|
470
|
+
// Create project directory
|
|
471
|
+
const projectPath = path.join(this.SESSION_DIRS['pi-mono'], project);
|
|
472
|
+
await fs.promises.mkdir(projectPath, { recursive: true });
|
|
473
|
+
|
|
474
|
+
// Move the .jsonl file
|
|
475
|
+
const sourceFile = path.join(extractDir, fileName);
|
|
476
|
+
const targetFile = path.join(projectPath, fileName);
|
|
477
|
+
await fs.promises.rename(sourceFile, targetFile);
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
success: true,
|
|
481
|
+
sessionId,
|
|
482
|
+
format: 'pi-mono',
|
|
483
|
+
project
|
|
484
|
+
};
|
|
485
|
+
} catch (err) {
|
|
486
|
+
console.error('Error importing Pi-Mono session:', err);
|
|
487
|
+
return {
|
|
488
|
+
success: false,
|
|
489
|
+
error: `Error importing Pi-Mono session: ${err.message}`,
|
|
490
|
+
statusCode: 500
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Import session by detected format
|
|
497
|
+
* @param {Object} formatInfo - Format detection result
|
|
498
|
+
* @param {string} extractDir - Extraction directory
|
|
499
|
+
* @param {Object} req - Express request object
|
|
500
|
+
* @returns {Promise<Object>} Import result
|
|
501
|
+
*/
|
|
502
|
+
async _importByFormat(formatInfo, extractDir, req) {
|
|
503
|
+
// Validate session ID
|
|
504
|
+
if (!isValidSessionId(formatInfo.sessionId)) {
|
|
505
|
+
return {
|
|
506
|
+
success: false,
|
|
507
|
+
error: 'Invalid session ID',
|
|
508
|
+
statusCode: 400
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
switch (formatInfo.format) {
|
|
513
|
+
case 'copilot':
|
|
514
|
+
return await this._importCopilotSession(formatInfo, extractDir);
|
|
515
|
+
case 'claude':
|
|
516
|
+
return await this._importClaudeSession(formatInfo, extractDir, req);
|
|
517
|
+
case 'pi-mono':
|
|
518
|
+
return await this._importPiMonoSession(formatInfo, extractDir, req);
|
|
519
|
+
default:
|
|
520
|
+
return {
|
|
521
|
+
success: false,
|
|
522
|
+
error: `Unsupported format: ${formatInfo.format}`,
|
|
523
|
+
statusCode: 400
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Find session location across all session directories
|
|
530
|
+
* @param {string} sessionId - Session identifier
|
|
531
|
+
* @param {string} preferredSource - Preferred source to search first
|
|
532
|
+
* @returns {Promise<Object|null>} Session location info or null
|
|
533
|
+
*/
|
|
534
|
+
async _findSessionLocation(sessionId, preferredSource = null) {
|
|
535
|
+
try {
|
|
536
|
+
// Define search order based on preference
|
|
537
|
+
const sources = preferredSource
|
|
538
|
+
? [preferredSource, ...Object.keys(this.SESSION_DIRS).filter(s => s !== preferredSource)]
|
|
539
|
+
: Object.keys(this.SESSION_DIRS);
|
|
540
|
+
|
|
541
|
+
for (const source of sources) {
|
|
542
|
+
const baseDir = this.SESSION_DIRS[source];
|
|
543
|
+
|
|
544
|
+
if (source === 'copilot') {
|
|
545
|
+
// For Copilot, sessions are directly in SESSION_DIR
|
|
546
|
+
const sessionPath = path.join(baseDir, sessionId);
|
|
547
|
+
if (fs.existsSync(sessionPath)) {
|
|
548
|
+
const eventsFile = path.join(sessionPath, 'events.jsonl');
|
|
549
|
+
if (fs.existsSync(eventsFile)) {
|
|
550
|
+
return {
|
|
551
|
+
source: 'copilot',
|
|
552
|
+
sessionId,
|
|
553
|
+
sessionPath,
|
|
554
|
+
baseDir
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
} else if (source === 'claude') {
|
|
559
|
+
// For Claude, search in all project directories
|
|
560
|
+
if (fs.existsSync(baseDir)) {
|
|
561
|
+
const projects = await fs.promises.readdir(baseDir);
|
|
562
|
+
for (const project of projects) {
|
|
563
|
+
const projectPath = path.join(baseDir, project);
|
|
564
|
+
const stat = await fs.promises.stat(projectPath);
|
|
565
|
+
if (stat.isDirectory()) {
|
|
566
|
+
const sessionFile = path.join(projectPath, `${sessionId}.jsonl`);
|
|
567
|
+
if (fs.existsSync(sessionFile)) {
|
|
568
|
+
return {
|
|
569
|
+
source: 'claude',
|
|
570
|
+
sessionId,
|
|
571
|
+
sessionFile,
|
|
572
|
+
projectPath,
|
|
573
|
+
project,
|
|
574
|
+
baseDir
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
} else if (source === 'pi-mono') {
|
|
581
|
+
// For Pi-Mono, search in all project directories for timestamped files
|
|
582
|
+
if (fs.existsSync(baseDir)) {
|
|
583
|
+
const projects = await fs.promises.readdir(baseDir);
|
|
584
|
+
for (const project of projects) {
|
|
585
|
+
const projectPath = path.join(baseDir, project);
|
|
586
|
+
const stat = await fs.promises.stat(projectPath);
|
|
587
|
+
if (stat.isDirectory()) {
|
|
588
|
+
const files = await fs.promises.readdir(projectPath);
|
|
589
|
+
const piMonoPattern = new RegExp(`^\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z_${sessionId}\\.jsonl$`);
|
|
590
|
+
for (const file of files) {
|
|
591
|
+
if (piMonoPattern.test(file)) {
|
|
592
|
+
return {
|
|
593
|
+
source: 'pi-mono',
|
|
594
|
+
sessionId,
|
|
595
|
+
fileName: file,
|
|
596
|
+
sessionFile: path.join(projectPath, file),
|
|
597
|
+
projectPath,
|
|
598
|
+
project,
|
|
599
|
+
baseDir
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return null;
|
|
610
|
+
} catch (err) {
|
|
611
|
+
console.error('Error finding session location:', err);
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
175
615
|
}
|
|
176
616
|
|
|
177
617
|
module.exports = UploadController;
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
const rateLimit = require('express-rate-limit');
|
|
2
2
|
|
|
3
|
+
// Disable rate limiting in E2E tests (when NODE_ENV is test or when running via Playwright)
|
|
4
|
+
const isTestEnvironment = process.env.NODE_ENV === 'test' || process.env.PLAYWRIGHT === '1';
|
|
5
|
+
|
|
3
6
|
// Global rate limiting for all routes
|
|
4
7
|
const globalLimiter = rateLimit({
|
|
5
8
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
6
|
-
max: 100, //
|
|
9
|
+
max: isTestEnvironment ? 10000 : 100, // Much higher limit for tests
|
|
7
10
|
message: { error: 'Too many requests. Please try again later.' },
|
|
8
11
|
standardHeaders: true,
|
|
9
12
|
legacyHeaders: false,
|
|
10
13
|
skip: (req) => {
|
|
14
|
+
// Skip rate limiting entirely in test environment
|
|
15
|
+
if (isTestEnvironment) return true;
|
|
16
|
+
|
|
11
17
|
// Skip static files
|
|
12
18
|
if (req.path.startsWith('/public')) return true;
|
|
13
19
|
|
package/src/models/Session.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Session domain model
|
|
3
5
|
*/
|
|
@@ -5,6 +7,8 @@ class Session {
|
|
|
5
7
|
constructor(id, type, options = {}) {
|
|
6
8
|
this.id = id;
|
|
7
9
|
this.type = type; // 'directory' or 'file'
|
|
10
|
+
this.source = options.source || 'copilot'; // 'copilot' or 'claude'
|
|
11
|
+
this.directory = options.directory || null; // Full path to session directory
|
|
8
12
|
this.workspace = options.workspace || {};
|
|
9
13
|
this.createdAt = options.createdAt;
|
|
10
14
|
this.updatedAt = options.updatedAt;
|
|
@@ -36,6 +40,7 @@ class Session {
|
|
|
36
40
|
*/
|
|
37
41
|
static fromDirectory(dirPath, id, stats, workspace, eventCount, duration, isImported, hasInsight, copilotVersion, selectedModel, sessionStatus) {
|
|
38
42
|
return new Session(id, 'directory', {
|
|
43
|
+
directory: dirPath, // Add directory path
|
|
39
44
|
workspace: workspace,
|
|
40
45
|
createdAt: workspace?.created_at || stats.birthtime,
|
|
41
46
|
updatedAt: workspace?.updated_at || stats.mtime,
|
|
@@ -66,6 +71,7 @@ class Session {
|
|
|
66
71
|
*/
|
|
67
72
|
static fromFile(filePath, id, stats, eventCount, summary, duration, copilotVersion, selectedModel, sessionStatus) {
|
|
68
73
|
return new Session(id, 'file', {
|
|
74
|
+
directory: path.dirname(filePath), // Directory containing the file
|
|
69
75
|
createdAt: stats.birthtime,
|
|
70
76
|
updatedAt: stats.mtime,
|
|
71
77
|
summary: summary || 'Legacy session',
|
|
@@ -85,9 +91,16 @@ class Session {
|
|
|
85
91
|
* @returns {object}
|
|
86
92
|
*/
|
|
87
93
|
toJSON() {
|
|
94
|
+
// Generate display-ready source metadata (Violation #3 & #5 fix)
|
|
95
|
+
const sourceMetadata = this._getSourceDisplayMetadata(this.source);
|
|
96
|
+
|
|
88
97
|
return {
|
|
89
98
|
id: this.id,
|
|
90
99
|
type: this.type,
|
|
100
|
+
source: this.source,
|
|
101
|
+
sourceName: sourceMetadata.name,
|
|
102
|
+
sourceBadgeClass: sourceMetadata.badgeClass,
|
|
103
|
+
directory: this.directory, // Include directory path
|
|
91
104
|
workspace: this.workspace,
|
|
92
105
|
createdAt: this.createdAt,
|
|
93
106
|
updatedAt: this.updatedAt,
|
|
@@ -102,6 +115,19 @@ class Session {
|
|
|
102
115
|
sessionStatus: this.sessionStatus
|
|
103
116
|
};
|
|
104
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get display metadata for source
|
|
121
|
+
* @private
|
|
122
|
+
*/
|
|
123
|
+
_getSourceDisplayMetadata(source) {
|
|
124
|
+
const metadata = {
|
|
125
|
+
'copilot': { name: 'Copilot', badgeClass: 'source-copilot' },
|
|
126
|
+
'claude': { name: 'Claude', badgeClass: 'source-claude' },
|
|
127
|
+
'pi-mono': { name: 'Pi', badgeClass: 'source-pi-mono' }
|
|
128
|
+
};
|
|
129
|
+
return metadata[source] || { name: source, badgeClass: 'source-unknown' };
|
|
130
|
+
}
|
|
105
131
|
}
|
|
106
132
|
|
|
107
133
|
module.exports = Session;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const { z } = require('zod');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unified Event Schema for Copilot Session Viewer
|
|
5
|
+
*
|
|
6
|
+
* This schema defines the standardized event format that the API returns to the frontend.
|
|
7
|
+
* Both Copilot and Claude events are normalized to match this schema.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Tool call schema (unified format for both Copilot and Claude)
|
|
11
|
+
const ToolSchema = z.object({
|
|
12
|
+
type: z.literal('tool_use'),
|
|
13
|
+
id: z.string(),
|
|
14
|
+
name: z.string(),
|
|
15
|
+
input: z.record(z.any()),
|
|
16
|
+
result: z.any().optional(), // Tool execution result (when matched)
|
|
17
|
+
status: z.enum(['success', 'error', 'running']).optional(),
|
|
18
|
+
error: z.string().optional(),
|
|
19
|
+
_matched: z.boolean().optional() // Internal flag: whether result was matched
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Subagent metadata (for events belonging to a subagent)
|
|
23
|
+
const SubagentMetadataSchema = z.object({
|
|
24
|
+
id: z.string(),
|
|
25
|
+
name: z.string()
|
|
26
|
+
}).optional();
|
|
27
|
+
|
|
28
|
+
// Event data schema (standardized data field)
|
|
29
|
+
const EventDataSchema = z.object({
|
|
30
|
+
// Message content (text)
|
|
31
|
+
message: z.string().optional(),
|
|
32
|
+
text: z.string().optional(), // Alternative field name (legacy)
|
|
33
|
+
|
|
34
|
+
// Tool calls (unified format)
|
|
35
|
+
tools: z.array(ToolSchema).optional(),
|
|
36
|
+
|
|
37
|
+
// Original fields preserved for reference
|
|
38
|
+
// (Copilot-specific fields)
|
|
39
|
+
messageId: z.string().optional(),
|
|
40
|
+
content: z.string().optional(), // Original Copilot content field
|
|
41
|
+
toolRequests: z.array(z.any()).optional(), // Original Copilot toolRequests
|
|
42
|
+
|
|
43
|
+
// (Claude-specific fields)
|
|
44
|
+
// ... other fields as needed
|
|
45
|
+
}).passthrough(); // Allow additional fields
|
|
46
|
+
|
|
47
|
+
// Base event schema
|
|
48
|
+
const EventSchema = z.object({
|
|
49
|
+
// Core fields
|
|
50
|
+
type: z.string(),
|
|
51
|
+
id: z.string().optional(),
|
|
52
|
+
timestamp: z.string(),
|
|
53
|
+
parentId: z.string().nullable().optional(),
|
|
54
|
+
|
|
55
|
+
// Standardized data
|
|
56
|
+
data: EventDataSchema.optional(),
|
|
57
|
+
|
|
58
|
+
// Metadata
|
|
59
|
+
_subagent: SubagentMetadataSchema,
|
|
60
|
+
_fileIndex: z.number().optional(),
|
|
61
|
+
|
|
62
|
+
// Virtual fields (computed by frontend)
|
|
63
|
+
stableId: z.string().optional(),
|
|
64
|
+
virtualIndex: z.number().optional()
|
|
65
|
+
}).passthrough(); // Allow additional fields for flexibility
|
|
66
|
+
|
|
67
|
+
// Export schemas
|
|
68
|
+
module.exports = {
|
|
69
|
+
EventSchema,
|
|
70
|
+
EventDataSchema,
|
|
71
|
+
ToolSchema,
|
|
72
|
+
SubagentMetadataSchema
|
|
73
|
+
};
|