@qiaolei81/copilot-session-viewer 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +3 -3
  2. package/bin/copilot-session-viewer +2 -2
  3. package/dist/server.min.js +99 -0
  4. package/package.json +5 -17
  5. package/public/js/homepage.min.js +9 -9
  6. package/public/js/session-detail.min.js +36 -7
  7. package/public/vendor/marked.umd.min.js +8 -0
  8. package/public/vendor/purify.min.js +3 -0
  9. package/public/vendor/vue-virtual-scroller.css +1 -0
  10. package/public/vendor/vue-virtual-scroller.min.js +2 -0
  11. package/public/vendor/vue.global.prod.min.js +19 -0
  12. package/views/session-vue.ejs +31 -6
  13. package/views/time-analyze.ejs +2 -2
  14. package/lib/parsers/README.md +0 -239
  15. package/lib/parsers/base-parser.js +0 -53
  16. package/lib/parsers/claude-parser.js +0 -181
  17. package/lib/parsers/copilot-parser.js +0 -143
  18. package/lib/parsers/index.js +0 -15
  19. package/lib/parsers/parser-factory.js +0 -77
  20. package/lib/parsers/pi-mono-parser.js +0 -119
  21. package/lib/parsers/vscode-parser.js +0 -591
  22. package/server.js +0 -29
  23. package/src/app.js +0 -129
  24. package/src/config/index.js +0 -27
  25. package/src/controllers/insightController.js +0 -136
  26. package/src/controllers/sessionController.js +0 -449
  27. package/src/controllers/tagController.js +0 -113
  28. package/src/controllers/uploadController.js +0 -648
  29. package/src/middleware/common.js +0 -67
  30. package/src/middleware/rateLimiting.js +0 -62
  31. package/src/models/Session.js +0 -146
  32. package/src/routes/api.js +0 -11
  33. package/src/routes/insights.js +0 -12
  34. package/src/routes/pages.js +0 -12
  35. package/src/routes/uploads.js +0 -14
  36. package/src/schemas/event.schema.js +0 -73
  37. package/src/services/eventNormalizer.js +0 -291
  38. package/src/services/insightService.js +0 -535
  39. package/src/services/sessionRepository.js +0 -1092
  40. package/src/services/sessionService.js +0 -1919
  41. package/src/services/tagService.js +0 -205
  42. package/src/telemetry.js +0 -152
  43. package/src/utils/fileUtils.js +0 -305
  44. package/src/utils/helpers.js +0 -45
  45. package/src/utils/processManager.js +0 -85
@@ -1,1092 +0,0 @@
1
- const fs = require('fs').promises;
2
- const path = require('path');
3
- const os = require('os');
4
- const Session = require('../models/Session');
5
- const { fileExists, countLines, parseYAML, getSessionMetadataOptimized, shouldSkipEntry } = require('../utils/fileUtils');
6
- const { ParserFactory } = require('../../lib/parsers');
7
-
8
- /**
9
- * Session Repository - Data access layer for sessions
10
- * Supports both Copilot CLI and Claude Code sessions
11
- */
12
- class SessionRepository {
13
- constructor(sessionDirs) {
14
- // Support both old (single dir) and new (multi-source) initialization
15
- if (typeof sessionDirs === 'string') {
16
- this.sources = [{
17
- type: 'copilot',
18
- dir: sessionDirs
19
- }];
20
- } else if (Array.isArray(sessionDirs)) {
21
- this.sources = sessionDirs;
22
- } else {
23
- // Default: Copilot + Claude + Pi-Mono + VSCode
24
- // Support environment variables for each source (useful for testing/CI)
25
- this.sources = [
26
- {
27
- type: 'copilot',
28
- dir: process.env.COPILOT_SESSION_DIR ||
29
- process.env.SESSION_DIR || // Legacy fallback
30
- path.join(os.homedir(), '.copilot', 'session-state')
31
- },
32
- {
33
- type: 'claude',
34
- dir: process.env.CLAUDE_SESSION_DIR ||
35
- path.join(os.homedir(), '.claude', 'projects')
36
- },
37
- {
38
- type: 'pi-mono',
39
- dir: process.env.PI_MONO_SESSION_DIR ||
40
- path.join(os.homedir(), '.pi', 'agent', 'sessions')
41
- },
42
- {
43
- type: 'vscode',
44
- dir: process.env.VSCODE_WORKSPACE_STORAGE_DIR ||
45
- path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage')
46
- }
47
- ];
48
- }
49
-
50
- this.parserFactory = new ParserFactory();
51
-
52
- // Cache: keyed by sourceType (null = all sources)
53
- this._cache = new Map();
54
- this._cacheTTL = 60 * 1000; // 60 seconds
55
- this._pendingScans = new Map(); // dedup concurrent requests
56
- }
57
-
58
- /**
59
- * Invalidate cache (call after tag/insight changes if needed)
60
- */
61
- invalidateCache(sourceType = null) {
62
- if (sourceType) {
63
- this._cache.delete(sourceType);
64
- this._cache.delete(null); // also invalidate "all" cache
65
- } else {
66
- this._cache.clear();
67
- }
68
- }
69
-
70
- /**
71
- * Get all sessions from all sources (or a specific source)
72
- * @param {string|null} sourceType - Optional source type filter ('copilot', 'claude', 'pi-mono', 'vscode')
73
- * @returns {Promise<Session[]>} Array of sessions sorted by updatedAt (newest first)
74
- */
75
- async findAll(sourceType = null) {
76
- const cacheKey = sourceType || '__all__';
77
-
78
- // Check cache
79
- const cached = this._cache.get(cacheKey);
80
- if (cached && (Date.now() - cached.timestamp < this._cacheTTL)) {
81
- return cached.data;
82
- }
83
-
84
- // Dedup concurrent scans for same key
85
- if (this._pendingScans.has(cacheKey)) {
86
- return this._pendingScans.get(cacheKey);
87
- }
88
-
89
- const scanPromise = this._doFindAll(sourceType).then(result => {
90
- this._cache.set(cacheKey, { data: result, timestamp: Date.now() });
91
- this._pendingScans.delete(cacheKey);
92
- return result;
93
- }).catch(err => {
94
- this._pendingScans.delete(cacheKey);
95
- throw err;
96
- });
97
-
98
- this._pendingScans.set(cacheKey, scanPromise);
99
- return scanPromise;
100
- }
101
-
102
- /**
103
- * @private
104
- */
105
- async _doFindAll(sourceType = null) {
106
- const allSessions = [];
107
-
108
- const sources = sourceType
109
- ? this.sources.filter(s => s.type === sourceType)
110
- : this.sources;
111
-
112
- for (const source of sources) {
113
- try {
114
- const sessions = await this._scanSource(source);
115
- allSessions.push(...sessions);
116
- } catch (err) {
117
- console.error(`Error reading ${source.type} sessions from ${source.dir}:`, err.message);
118
- }
119
- }
120
-
121
- return this._sortByUpdatedAt(this._deduplicateSessions(allSessions));
122
- }
123
-
124
- /**
125
- * Deduplicate sessions with the same ID (e.g. VSCode sessions in multiple workspaces).
126
- * Keeps the most recently updated session for each ID.
127
- * @private
128
- */
129
- _deduplicateSessions(sessions) {
130
- const seen = new Map();
131
- for (const session of sessions) {
132
- const existing = seen.get(session.id);
133
- if (!existing || (session.updatedAt && existing.updatedAt && new Date(session.updatedAt) > new Date(existing.updatedAt))) {
134
- seen.set(session.id, session);
135
- }
136
- }
137
- return Array.from(seen.values());
138
- }
139
-
140
- /**
141
- * Scan a single source directory
142
- * @private
143
- */
144
- async _scanSource(source) {
145
- try {
146
- await fs.access(source.dir);
147
- } catch {
148
- console.warn(`Source directory not found: ${source.dir}`);
149
- return [];
150
- }
151
-
152
- const entries = await fs.readdir(source.dir);
153
- const tasks = entries
154
- .filter(entry => !shouldSkipEntry(entry))
155
- .map(async (entry) => {
156
- const fullPath = path.join(source.dir, entry);
157
- const stats = await fs.stat(fullPath);
158
-
159
- if (source.type === 'copilot') {
160
- // Copilot: directory-based or .jsonl files
161
- if (stats.isDirectory()) {
162
- return this._createDirectorySession(entry, fullPath, stats, 'copilot');
163
- } else if (entry.endsWith('.jsonl')) {
164
- return this._createFileSession(entry, fullPath, stats, 'copilot');
165
- }
166
- } else if (source.type === 'claude') {
167
- // Claude: all directories contain .jsonl files named by sessionId
168
- if (stats.isDirectory()) {
169
- return this._scanClaudeProjectDir(fullPath, entry);
170
- }
171
- } else if (source.type === 'pi-mono') {
172
- // Pi-Mono: project directories containing timestamped .jsonl files
173
- if (stats.isDirectory()) {
174
- return this._scanPiMonoDir(fullPath, entry);
175
- }
176
- } else if (source.type === 'vscode') {
177
- // VSCode: workspace hash directories containing chatSessions/*.jsonl
178
- if (stats.isDirectory()) {
179
- return this._scanVsCodeWorkspaceDir(fullPath);
180
- }
181
- }
182
- return null;
183
- });
184
-
185
- const results = await Promise.allSettled(tasks);
186
- return results
187
- .filter(r => r.status === 'fulfilled' && r.value !== null && r.value !== undefined)
188
- .map(r => r.value)
189
- .flat(); // flat() because scanClaudeProjectDir returns array
190
- }
191
-
192
- /**
193
- * Scan Claude project directory (contains multiple session .jsonl files AND directories with subagents)
194
- * @private
195
- */
196
- async _scanClaudeProjectDir(projectDir, projectName) {
197
- try {
198
- const entries = await fs.readdir(projectDir);
199
- const sessions = [];
200
-
201
- for (const entry of entries) {
202
- if (shouldSkipEntry(entry)) continue;
203
-
204
- const fullPath = path.join(projectDir, entry);
205
- const stats = await fs.stat(fullPath);
206
-
207
- // Handle .jsonl files (main session files)
208
- if (stats.isFile() && entry.endsWith('.jsonl')) {
209
- const session = await this._createClaudeSession(entry, fullPath, stats, projectName);
210
- if (session) {
211
- sessions.push(session);
212
- }
213
- }
214
-
215
- // Handle directories (potential subagents-only sessions)
216
- if (stats.isDirectory()) {
217
- // Check if this directory has a subagents subdirectory
218
- const subagentsDir = path.join(fullPath, 'subagents');
219
- try {
220
- const subStats = await fs.stat(subagentsDir);
221
- if (subStats.isDirectory()) {
222
- // This is a valid subagents-only session
223
- const session = await this._createClaudeSubagentsSession(entry, fullPath, stats, projectName);
224
- if (session) {
225
- sessions.push(session);
226
- }
227
- }
228
- } catch {
229
- // No subagents directory, not a session directory
230
- }
231
- }
232
- }
233
-
234
- return sessions;
235
- } catch (err) {
236
- console.error(`Error scanning Claude project dir ${projectDir}:`, err.message);
237
- return [];
238
- }
239
- }
240
-
241
- /**
242
- * Create Claude Code session from .jsonl file
243
- * @private
244
- */
245
- async _createClaudeSession(entry, fullPath, stats, projectName) {
246
- const sessionId = entry.replace('.jsonl', '');
247
- const eventCount = await countLines(fullPath);
248
-
249
- console.log(`[DEBUG] _createClaudeSession: ${entry}, events: ${eventCount}`);
250
-
251
- // Read events to extract metadata and VALIDATE format
252
- try {
253
- const content = await fs.readFile(fullPath, 'utf-8');
254
- const lines = content.trim().split('\n').filter(line => line.trim());
255
- const events = lines.map(line => {
256
- try {
257
- return JSON.parse(line);
258
- } catch {
259
- return null;
260
- }
261
- }).filter(e => e !== null);
262
-
263
- console.log(`[DEBUG] Parsed ${events.length} events from ${entry}`);
264
-
265
- // VALIDATION: Check if this has Claude CORE events (assistant, user)
266
- // Ignore metadata events like file-history-snapshot, progress (可以共存)
267
- const hasClaudeCoreEvents = events.some(e => e.type === 'assistant' || e.type === 'user');
268
- const hasCopilotCoreEvents = events.some(e => e.type === 'assistant.message' || e.type === 'user.message');
269
-
270
- console.log(`[DEBUG] ${entry}: hasClaudeCoreEvents=${hasClaudeCoreEvents}, hasCopilotCoreEvents=${hasCopilotCoreEvents}`);
271
-
272
- if (!hasClaudeCoreEvents && hasCopilotCoreEvents) {
273
- console.warn(`File ${fullPath} contains only Copilot core events, skipping as Claude session`);
274
- return null;
275
- }
276
-
277
- // If no Claude core events, also skip (empty or invalid file)
278
- if (!hasClaudeCoreEvents) {
279
- console.warn(`File ${fullPath} has no Claude core events (assistant/user), skipping`);
280
- return null;
281
- }
282
-
283
- console.log(`[DEBUG] ${entry} passed validation, creating session...`);
284
-
285
- // Use parser to extract metadata
286
- const parserType = this.parserFactory.getParserType(events);
287
- if (parserType !== 'claude') {
288
- // Not a valid Claude session
289
- return null;
290
- }
291
-
292
- const parsed = this.parserFactory.parse(events);
293
- const metadata = parsed.metadata || {};
294
-
295
- // Extract project name from directory name (convert back from dashes to slashes)
296
- const projectPath = projectName.replace(/^-/, '/').replace(/-/g, '/');
297
-
298
- return new Session(sessionId, 'file', {
299
- source: 'claude',
300
- filePath: fullPath,
301
- directory: path.dirname(fullPath), // Directory containing the session file
302
- workspace: {
303
- summary: metadata.model ? `Claude Code session (${metadata.model})` : 'Claude Code session',
304
- cwd: metadata.cwd || projectPath
305
- },
306
- createdAt: metadata.startTime || stats.birthtime,
307
- updatedAt: stats.mtime,
308
- summary: parsed.turns[0]?.userMessage?.content?.substring(0, 100) || 'No summary',
309
- hasEvents: eventCount > 0,
310
- eventCount: eventCount,
311
- duration: null, // Claude format doesn't have explicit duration
312
- isImported: false,
313
- hasInsight: false,
314
- copilotVersion: metadata.version,
315
- selectedModel: metadata.model,
316
- sessionStatus: 'completed'
317
- });
318
- } catch (err) {
319
- console.error(`Error creating Claude session ${sessionId}:`, err.message);
320
- return null;
321
- }
322
- }
323
-
324
- /**
325
- * Find session by ID (searches all sources)
326
- * @param {string} sessionId - Session ID
327
- * @returns {Promise<Session|null>}
328
- */
329
- async findById(sessionId) {
330
- if (shouldSkipEntry(sessionId)) return null;
331
-
332
- for (const source of this.sources) {
333
- let session = null;
334
-
335
- if (source.type === 'copilot') {
336
- session = await this._findCopilotSession(sessionId, source.dir);
337
- } else if (source.type === 'claude') {
338
- session = await this._findClaudeSession(sessionId, source.dir);
339
- } else if (source.type === 'pi-mono') {
340
- session = await this._findPiMonoSession(sessionId, source.dir);
341
- } else if (source.type === 'vscode') {
342
- session = await this._findVsCodeSession(sessionId, source.dir);
343
- }
344
-
345
- if (session) return session;
346
- }
347
-
348
- return null;
349
- }
350
-
351
- /**
352
- * Find Copilot session by ID
353
- * @private
354
- */
355
- async _findCopilotSession(sessionId, sessionDir) {
356
- // Try directory first
357
- try {
358
- const dirPath = path.join(sessionDir, sessionId);
359
- const dirStats = await fs.stat(dirPath);
360
- if (dirStats.isDirectory()) {
361
- return await this._createDirectorySession(sessionId, dirPath, dirStats, 'copilot');
362
- }
363
- } catch {
364
- // Not a directory
365
- }
366
-
367
- // Try .jsonl file
368
- try {
369
- const filePath = path.join(sessionDir, `${sessionId}.jsonl`);
370
- const fileStats = await fs.stat(filePath);
371
- if (fileStats.isFile()) {
372
- return await this._createFileSession(`${sessionId}.jsonl`, filePath, fileStats, 'copilot');
373
- }
374
- } catch {
375
- // File not found
376
- }
377
-
378
- return null;
379
- }
380
-
381
- /**
382
- * Find Claude session by ID (searches all project directories)
383
- * @private
384
- */
385
- async _findClaudeSession(sessionId, projectsDir) {
386
- try {
387
- const projects = await fs.readdir(projectsDir);
388
-
389
- for (const project of projects) {
390
- const projectPath = path.join(projectsDir, project);
391
-
392
- // Try main session file first
393
- const sessionFile = path.join(projectPath, `${sessionId}.jsonl`);
394
- try {
395
- const stats = await fs.stat(sessionFile);
396
- if (stats.isFile()) {
397
- console.log(`[DEBUG] Found file: ${sessionFile}`);
398
- const session = await this._createClaudeSession(`${sessionId}.jsonl`, sessionFile, stats, project);
399
- // If file contains Copilot events (validation failed), continue to check directory
400
- if (session) {
401
- console.log('[DEBUG] File validated as Claude session, returning');
402
- return session;
403
- }
404
- console.log('[DEBUG] File validation failed, checking directory...');
405
- // Otherwise fall through to check directory
406
- }
407
- } catch (err) {
408
- // Main file not found, try directory
409
- console.log(`[DEBUG] File not found: ${sessionFile}, error: ${err.message}`);
410
- }
411
-
412
- // Try session directory (subagents-only sessions, or when file validation failed)
413
- const sessionDir = path.join(projectPath, sessionId);
414
- console.log(`[DEBUG] Checking directory: ${sessionDir}`);
415
- try {
416
- const dirStats = await fs.stat(sessionDir);
417
- if (dirStats.isDirectory()) {
418
- console.log('[DEBUG] Directory exists, checking for subagents...');
419
- // Check if it has subagents subdirectory
420
- const subagentsDir = path.join(sessionDir, 'subagents');
421
- try {
422
- const subStats = await fs.stat(subagentsDir);
423
- if (subStats.isDirectory()) {
424
- console.log('[DEBUG] Found subagents directory, creating session...');
425
- // Valid Claude subagents-only session
426
- const result = await this._createClaudeSubagentsSession(sessionId, sessionDir, dirStats, project);
427
- console.log('[DEBUG] Created subagents session:', result ? 'SUCCESS' : 'FAILED');
428
- return result;
429
- }
430
- } catch (err) {
431
- // No subagents directory
432
- console.log(`[DEBUG] No subagents directory: ${err.message}`);
433
- }
434
- }
435
- } catch (err) {
436
- // Directory not found, continue
437
- console.log(`[DEBUG] Directory not found: ${err.message}`);
438
- }
439
- }
440
- } catch (err) {
441
- // Projects dir not found
442
- console.error(`[DEBUG] Projects dir error: ${err.message}`);
443
- }
444
-
445
- console.log(`[DEBUG] Session ${sessionId} not found in any project`);
446
- return null;
447
- }
448
-
449
- /**
450
- * Find Pi-Mono session by ID (searches all project directories)
451
- * @private
452
- */
453
- async _findPiMonoSession(sessionId, sessionsDir) {
454
- try {
455
- const projects = await fs.readdir(sessionsDir);
456
-
457
- for (const projectDir of projects) {
458
- const projectPath = path.join(sessionsDir, projectDir);
459
-
460
- try {
461
- const files = await fs.readdir(projectPath);
462
- // Look for file matching pattern: *_<sessionId>.jsonl
463
- const matchingFile = files.find(f => f.includes(`_${sessionId}.jsonl`));
464
-
465
- if (matchingFile) {
466
- const filePath = path.join(projectPath, matchingFile);
467
- const stats = await fs.stat(filePath);
468
-
469
- // Read first line for metadata
470
- const firstLine = await this._readFirstLine(filePath);
471
- if (firstLine) {
472
- const sessionEvent = JSON.parse(firstLine);
473
- if (sessionEvent.type === 'session') {
474
- const projectName = projectDir.replace(/^--/, '').replace(/--$/, '');
475
- const eventCount = await countLines(filePath);
476
-
477
- return new Session(
478
- sessionId,
479
- 'directory',
480
- {
481
- source: 'pi-mono',
482
- filePath: filePath,
483
- directory: projectPath, // Project directory containing the session file
484
- workspace: { cwd: sessionEvent.cwd || projectName },
485
- createdAt: new Date(sessionEvent.timestamp),
486
- updatedAt: new Date(stats.mtime),
487
- summary: `Pi-Mono: ${path.basename(sessionEvent.cwd || projectName)}`,
488
- hasEvents: eventCount > 0,
489
- eventCount: eventCount,
490
- duration: null,
491
- sessionStatus: 'completed'
492
- }
493
- );
494
- }
495
- }
496
- }
497
- } catch {
498
- // Not a directory or can't read
499
- }
500
- }
501
- } catch (err) {
502
- console.error(`Error searching Pi-Mono sessions: ${err.message}`);
503
- }
504
-
505
- return null;
506
- }
507
-
508
- /**
509
- * Find VSCode session by ID in workspaceStorage
510
- * @private
511
- */
512
- async _findVsCodeSession(sessionId, workspaceStorageDir) {
513
- try {
514
- const hashes = await fs.readdir(workspaceStorageDir);
515
- const candidates = [];
516
-
517
- for (const hash of hashes) {
518
- const chatSessionsDir = path.join(workspaceStorageDir, hash, 'chatSessions');
519
- try {
520
- const files = await fs.readdir(chatSessionsDir);
521
- const matchingFile = files.find(f => f === `${sessionId}.json` || f === `${sessionId}.jsonl` || f.replace(/\.jsonl?$/, '') === sessionId);
522
- if (matchingFile) {
523
- const fullPath = path.join(chatSessionsDir, matchingFile);
524
- const stats = await fs.stat(fullPath);
525
- const raw = await fs.readFile(fullPath, 'utf-8');
526
- let sessionJson;
527
- if (matchingFile.endsWith('.jsonl')) {
528
- sessionJson = this._parseVsCodeJsonl(raw);
529
- if (!sessionJson) continue;
530
- } else {
531
- sessionJson = JSON.parse(raw);
532
- }
533
- const requests = sessionJson.requests || [];
534
- if (requests.length === 0) continue;
535
-
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
- ));
542
- }
543
- } catch {
544
- // No chatSessions dir or can't read — skip
545
- }
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
- }
552
- } catch (err) {
553
- console.error(`[VSCode findById] Error searching VSCode sessions: ${err.message}`, err.stack);
554
- }
555
- console.log(`[VSCode findById] Session ${sessionId} not found in vscode sessions`);
556
- return null;
557
- }
558
-
559
- /**
560
- * Create Claude session from subagents-only directory (no main events.jsonl)
561
- * @private
562
- */
563
- async _createClaudeSubagentsSession(sessionId, sessionDir, stats, projectName) {
564
- try {
565
- const subagentsDir = path.join(sessionDir, 'subagents');
566
- const files = await fs.readdir(subagentsDir);
567
- const subagentFiles = files.filter(f => f.startsWith('agent-') && f.endsWith('.jsonl'));
568
-
569
- if (subagentFiles.length === 0) {
570
- return null;
571
- }
572
-
573
- // Count events from all subagent files
574
- let totalEvents = 0;
575
- for (const file of subagentFiles) {
576
- const filePath = path.join(subagentsDir, file);
577
- totalEvents += await countLines(filePath);
578
- }
579
-
580
- // Read first subagent file for metadata
581
- const firstFile = path.join(subagentsDir, subagentFiles[0]);
582
- const content = await fs.readFile(firstFile, 'utf-8');
583
- const lines = content.trim().split('\n').filter(line => line.trim());
584
- const firstEvent = lines.length > 0 ? JSON.parse(lines[0]) : null;
585
-
586
- const metadata = {
587
- cwd: firstEvent?.cwd || projectName,
588
- version: firstEvent?.version,
589
- model: firstEvent?.message?.model,
590
- startTime: firstEvent?.timestamp
591
- };
592
-
593
- const projectPath = projectName.replace(/^-/, '/').replace(/-/g, '/');
594
-
595
- return new Session(sessionId, 'directory', {
596
- source: 'claude',
597
- directory: sessionDir, // Session directory path
598
- workspace: {
599
- summary: `Claude session (${subagentFiles.length} sub-agents)`,
600
- cwd: metadata.cwd || projectPath
601
- },
602
- createdAt: metadata.startTime || stats.birthtime,
603
- updatedAt: stats.mtime,
604
- summary: firstEvent?.message?.content?.substring(0, 100) || 'Sub-agent tasks',
605
- hasEvents: totalEvents > 0,
606
- eventCount: totalEvents,
607
- duration: null,
608
- isImported: false,
609
- hasInsight: false,
610
- copilotVersion: metadata.version,
611
- selectedModel: metadata.model,
612
- sessionStatus: 'completed'
613
- });
614
- } catch (err) {
615
- console.error(`Error creating Claude subagents session ${sessionId}:`, err.message);
616
- return null;
617
- }
618
- }
619
-
620
- /**
621
- * Create session from directory (Copilot format)
622
- * @private
623
- */
624
- async _createDirectorySession(entry, fullPath, stats, source = 'copilot') {
625
- const workspaceFile = path.join(fullPath, 'workspace.yaml');
626
- const eventsFile = path.join(fullPath, 'events.jsonl');
627
- const importedMarkerFile = path.join(fullPath, '.imported');
628
- const insightReportFile = path.join(fullPath, `${entry}.agent-review.md`);
629
-
630
- // Parse workspace.yaml if exists, otherwise use defaults
631
- const workspace = await fileExists(workspaceFile)
632
- ? await parseYAML(workspaceFile)
633
- : { summary: entry, repo: 'unknown' };
634
-
635
- const eventCount = await fileExists(eventsFile) ? await countLines(eventsFile) : 0;
636
- const isImported = await fileExists(importedMarkerFile);
637
- const hasInsight = await fileExists(insightReportFile);
638
-
639
- let duration = null;
640
- let copilotVersion = null;
641
- let selectedModel = null;
642
- let sessionStatus = 'completed';
643
-
644
- // Use optimized metadata extraction if events file exists
645
- if (await fileExists(eventsFile)) {
646
- const optimizedMetadata = await getSessionMetadataOptimized(eventsFile);
647
- duration = optimizedMetadata.duration;
648
- copilotVersion = optimizedMetadata.copilotVersion;
649
- selectedModel = optimizedMetadata.selectedModel;
650
-
651
- sessionStatus = this._computeSessionStatus(optimizedMetadata);
652
-
653
- if (!workspace.summary && optimizedMetadata.firstUserMessage) {
654
- workspace.summary = optimizedMetadata.firstUserMessage;
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
- }
665
- }
666
-
667
- const session = Session.fromDirectory(fullPath, entry, stats, workspace, eventCount, duration, isImported, hasInsight, copilotVersion, selectedModel, sessionStatus);
668
- session.source = source;
669
- return session;
670
- }
671
-
672
- /**
673
- * Create session from .jsonl file (Copilot format)
674
- * @private
675
- */
676
- async _createFileSession(entry, fullPath, stats, source = 'copilot') {
677
- const sessionId = entry.replace('.jsonl', '');
678
- const eventCount = await countLines(fullPath);
679
-
680
- const optimizedMetadata = await getSessionMetadataOptimized(fullPath);
681
- const sessionStatus = this._computeSessionStatus(optimizedMetadata);
682
-
683
- const session = Session.fromFile(
684
- fullPath,
685
- sessionId,
686
- stats,
687
- eventCount,
688
- optimizedMetadata.firstUserMessage,
689
- optimizedMetadata.duration,
690
- optimizedMetadata.copilotVersion,
691
- optimizedMetadata.selectedModel,
692
- sessionStatus
693
- );
694
- session.source = source;
695
- return session;
696
- }
697
-
698
- /**
699
- * Compute session status from metadata
700
- * @private
701
- */
702
- _computeSessionStatus(metadata) {
703
- if (metadata.hasSessionEnd) {
704
- return 'completed';
705
- }
706
- if (metadata.lastEventTime !== null && metadata.lastEventTime !== undefined) {
707
- const WIP_THRESHOLD_MS = 5 * 60 * 1000;
708
- if ((Date.now() - metadata.lastEventTime) < WIP_THRESHOLD_MS) {
709
- return 'wip';
710
- }
711
- }
712
- return 'completed';
713
- }
714
-
715
- /**
716
- * Scan Pi-Mono project directory (--project-path--)
717
- * Contains timestamped .jsonl files: YYYY-MM-DDTHH-mm-ss-SSSZ_<uuid>.jsonl
718
- * @private
719
- */
720
- async _scanPiMonoDir(projectDir, dirName) {
721
- try {
722
- console.log(`[PI-MONO] Scanning directory: ${projectDir}`);
723
- const entries = await fs.readdir(projectDir);
724
- const jsonlFiles = entries.filter(e => e.endsWith('.jsonl'));
725
-
726
- console.log(`[PI-MONO] Found ${jsonlFiles.length} .jsonl files in ${dirName}`);
727
-
728
- if (jsonlFiles.length === 0) {
729
- return [];
730
- }
731
-
732
- const sessions = [];
733
-
734
- // Sort files by name (timestamp) to get latest
735
- jsonlFiles.sort().reverse();
736
-
737
- for (const file of jsonlFiles) {
738
- const fullPath = path.join(projectDir, file);
739
- const stats = await fs.stat(fullPath);
740
-
741
- // Extract session ID from filename: YYYY-MM-DD...Z_<uuid>.jsonl
742
- const match = file.match(/_([a-f0-9-]+)\.jsonl$/);
743
- if (!match) {
744
- console.log(`[PI-MONO] Skipping ${file}: no UUID match`);
745
- continue;
746
- }
747
-
748
- const sessionId = match[1];
749
-
750
- // Read first line to get session metadata
751
- const firstLine = await this._readFirstLine(fullPath);
752
- if (!firstLine) {
753
- console.log(`[PI-MONO] Skipping ${file}: no first line`);
754
- continue;
755
- }
756
-
757
- try {
758
- const sessionEvent = JSON.parse(firstLine);
759
- if (sessionEvent.type !== 'session') {
760
- console.log(`[PI-MONO] Skipping ${file}: first event type is ${sessionEvent.type}, not 'session'`);
761
- continue;
762
- }
763
-
764
- // Count events in the file
765
- const eventCount = await countLines(fullPath);
766
-
767
- // Extract project name from directory (remove -- prefix/suffix)
768
- const projectPath = dirName.replace(/^--/, '').replace(/--$/, '');
769
-
770
- const session = new Session(
771
- sessionId,
772
- 'directory',
773
- {
774
- source: 'pi-mono',
775
- directory: projectDir, // Add directory path for Agent Review
776
- workspace: { cwd: sessionEvent.cwd || projectPath },
777
- createdAt: new Date(sessionEvent.timestamp),
778
- updatedAt: new Date(stats.mtime),
779
- summary: `Pi-Mono: ${path.basename(sessionEvent.cwd || projectPath)}`,
780
- hasEvents: eventCount > 0,
781
- eventCount: eventCount,
782
- duration: null,
783
- sessionStatus: 'completed'
784
- }
785
- );
786
-
787
- console.log(`[PI-MONO] Created session: ${sessionId} from ${file}`);
788
- sessions.push(session);
789
- } catch (err) {
790
- console.error(`[PI-MONO] Error parsing session ${file}:`, err.message);
791
- }
792
- }
793
-
794
- console.log(`[PI-MONO] Total sessions found in ${dirName}: ${sessions.length}`);
795
- return sessions;
796
- } catch (err) {
797
- console.error(`[PI-MONO] Error scanning dir ${projectDir}:`, err.message);
798
- return [];
799
- }
800
- }
801
-
802
- /**
803
- * Scan a VSCode workspaceStorage/<hash> directory for chatSessions/*.json files
804
- * @private
805
- */
806
- /** Resolve the real project/workspace path from a VSCode workspaceStorage hash directory */
807
- /** Parse a VSCode .jsonl file: read kind=0 for base state, merge kind=2 patches into response arrays */
808
- _parseVsCodeJsonl(raw) {
809
- const lines = raw.split('\n').filter(l => l.trim());
810
- if (lines.length === 0) return null;
811
-
812
- const first = JSON.parse(lines[0]);
813
- const sessionJson = first.v || first; // kind=0 wraps in .v
814
-
815
- // Apply kind=1 (field set) and kind=2 (array splice) patches
816
- for (let idx = 1; idx < lines.length; idx++) {
817
- try {
818
- const patch = JSON.parse(lines[idx]);
819
- const k = patch.k || [];
820
- const v = patch.v;
821
-
822
- if (patch.kind === 2 && Array.isArray(v)) {
823
- // Navigate to parent object, then splice into the target array
824
- let obj = sessionJson;
825
- for (let ki = 0; ki < k.length - 1; ki++) {
826
- const key = k[ki];
827
- if (typeof key === 'number') {
828
- obj = obj[key];
829
- } else {
830
- if (!obj[key]) obj[key] = {};
831
- obj = obj[key];
832
- }
833
- }
834
- const lastKey = k[k.length - 1];
835
- if (lastKey !== undefined) {
836
- if (!obj[lastKey]) obj[lastKey] = [];
837
- const target = obj[lastKey];
838
- const i = patch.i;
839
- if (i === null || i === undefined) {
840
- target.push(...v);
841
- } else {
842
- target.splice(i, 0, ...v);
843
- }
844
- } else {
845
- // k is empty — splice into sessionJson itself (rare)
846
- const i = patch.i;
847
- if (i === null || i === undefined) sessionJson.push?.(...v);
848
- }
849
- } else if (patch.kind === 1 && k.length > 0) {
850
- // Navigate to parent, set the final key
851
- let obj = sessionJson;
852
- for (let ki = 0; ki < k.length - 1; ki++) {
853
- const key = k[ki];
854
- if (typeof key === 'number') {
855
- obj = obj[key];
856
- } else {
857
- if (!obj[key]) obj[key] = {};
858
- obj = obj[key];
859
- }
860
- }
861
- const lastKey = k[k.length - 1];
862
- obj[lastKey] = v;
863
- }
864
- } catch {
865
- // Skip malformed lines
866
- }
867
- }
868
-
869
- return sessionJson;
870
- }
871
-
872
- async _resolveVsCodeWorkspacePath(workspaceHashDir) {
873
- try {
874
- const workspaceJsonPath = path.join(workspaceHashDir, 'workspace.json');
875
- const raw = await fs.readFile(workspaceJsonPath, 'utf-8');
876
- const meta = JSON.parse(raw);
877
-
878
- if (meta.folder) {
879
- // Single-folder workspace: file:///path/to/project
880
- return decodeURIComponent(meta.folder.replace('file://', ''));
881
- }
882
-
883
- if (meta.workspace) {
884
- // Multi-folder workspace: points to another .json with folders array
885
- const wsFilePath = decodeURIComponent(meta.workspace.replace('file://', ''));
886
- try {
887
- const wsRaw = await fs.readFile(wsFilePath, 'utf-8');
888
- const ws = JSON.parse(wsRaw);
889
- if (Array.isArray(ws.folders) && ws.folders.length > 0) {
890
- // Return first folder path
891
- const wsDir = path.dirname(wsFilePath);
892
- const resolved = path.resolve(wsDir, ws.folders[0].path);
893
- return resolved;
894
- }
895
- } catch {
896
- // Ignore nested read errors
897
- }
898
- }
899
- } catch {
900
- // No workspace.json or unreadable
901
- }
902
- return null;
903
- }
904
-
905
- async _scanVsCodeWorkspaceDir(workspaceHashDir) {
906
- const chatSessionsDir = path.join(workspaceHashDir, 'chatSessions');
907
- try {
908
- await fs.access(chatSessionsDir);
909
- } catch {
910
- return []; // No chatSessions subfolder — skip silently
911
- }
912
-
913
- // Extract workspace hash from directory name
914
- const workspaceHash = path.basename(workspaceHashDir);
915
-
916
- // Resolve the real project path from workspace.json
917
- const realWorkspacePath = await this._resolveVsCodeWorkspacePath(workspaceHashDir);
918
-
919
- const entries = await fs.readdir(chatSessionsDir);
920
- const jsonFiles = entries.filter(e => (e.endsWith('.json') || e.endsWith('.jsonl')) && !shouldSkipEntry(e));
921
- if (jsonFiles.length === 0) return [];
922
-
923
- const sessions = [];
924
- for (const file of jsonFiles) {
925
- const fullPath = path.join(chatSessionsDir, file);
926
- try {
927
- const stats = await fs.stat(fullPath);
928
- const raw = await fs.readFile(fullPath, 'utf-8');
929
- // Support both .json (flat) and .jsonl (incremental patch: kind=0 + kind=2 patches)
930
- let sessionJson;
931
- if (file.endsWith('.jsonl')) {
932
- sessionJson = this._parseVsCodeJsonl(raw);
933
- if (!sessionJson) continue;
934
- } else {
935
- sessionJson = JSON.parse(raw);
936
- }
937
-
938
- const sessionId = sessionJson.sessionId || path.basename(file).replace(/\.jsonl?$/, '');
939
- const requests = sessionJson.requests || [];
940
- if (requests.length === 0) continue;
941
-
942
- const statsWithPath = { ...stats, filePath: fullPath };
943
- const session = this._buildVsCodeSession(
944
- sessionId, requests, sessionJson, statsWithPath, workspaceHash,
945
- realWorkspacePath || workspaceHashDir
946
- );
947
- sessions.push(session);
948
- } catch (err) {
949
- // Skip malformed files silently
950
- }
951
- }
952
- return sessions;
953
- }
954
-
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
-
1026
- _extractVsCodeUserText(message) {
1027
- if (!message) return '';
1028
- if (typeof message.text === 'string') return message.text;
1029
- if (Array.isArray(message.parts)) {
1030
- return message.parts.filter(p => p.kind === 'text').map(p => p.text || '').join('');
1031
- }
1032
- return '';
1033
- }
1034
-
1035
- /**
1036
- * Read first line of a file
1037
- * @private
1038
- */
1039
- async _readFirstLine(filePath) {
1040
- const fs = require('fs');
1041
- const readline = require('readline');
1042
-
1043
- return new Promise((resolve, reject) => {
1044
- const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
1045
- const rl = readline.createInterface({
1046
- input: stream,
1047
- crlfDelay: Infinity
1048
- });
1049
-
1050
- let resolved = false;
1051
-
1052
- rl.on('line', (line) => {
1053
- if (!resolved) {
1054
- resolved = true;
1055
- rl.close();
1056
- resolve(line.trim());
1057
- }
1058
- });
1059
-
1060
- rl.on('close', () => {
1061
- if (!resolved) {
1062
- resolve(null);
1063
- }
1064
- });
1065
-
1066
- rl.on('error', (err) => {
1067
- if (!resolved) {
1068
- resolved = true;
1069
- reject(err);
1070
- }
1071
- });
1072
-
1073
- stream.on('error', (err) => {
1074
- if (!resolved) {
1075
- resolved = true;
1076
- rl.close();
1077
- reject(err);
1078
- }
1079
- });
1080
- });
1081
- }
1082
-
1083
- /**
1084
- * Sort sessions by updated time (newest first)
1085
- * @private
1086
- */
1087
- _sortByUpdatedAt(sessions) {
1088
- return sessions.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
1089
- }
1090
- }
1091
-
1092
- module.exports = SessionRepository;