@qiaolei81/copilot-session-viewer 0.1.8 → 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.
Files changed (56) hide show
  1. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-be-responsive-on-mobile-viewport-1771605454041.json +435 -0
  2. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-display-sessions-if-available-1771605462872.json +435 -0
  3. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-handle-JavaScript-errors-gracefully-1771605463381.json +435 -0
  4. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-handle-session-import-dialog-1771605466264.json +435 -0
  5. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-have-working-infinite-scroll-elements-1771605454038.json +435 -0
  6. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-load-homepage-with-basic-elements-1771605454001.json +435 -0
  7. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-load-time-analysis-page-1771605464990.json +1236 -0
  8. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-navigate-to-session-detail-page-1771605472595.json +1177 -0
  9. package/.nyc_output/coverage-e2e-merged.json +1 -0
  10. package/.nyc_output/coverage-homepage-spec-js-Homepage-should-display-session-list-1771605453565.json +435 -0
  11. package/.nyc_output/coverage-homepage-spec-js-Homepage-should-load-homepage-successfully-1771605453552.json +435 -0
  12. package/.nyc_output/coverage-homepage-spec-js-Homepage-should-navigate-to-session-detail-on-click-1771605469317.json +1134 -0
  13. package/.nyc_output/coverage-homepage-spec-js-Homepage-should-show-session-metadata-1771605460581.json +435 -0
  14. 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
  15. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-handle-API-errors-gracefully-during-infinite-scroll-1771605482161.json +471 -0
  16. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-hide-Load-More-button-when-no-more-sessions-available-1771605478370.json +471 -0
  17. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-load-additional-sessions-when-Load-More-button-is-clicked-1771605475059.json +471 -0
  18. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-preserve-session-list-state-during-navigation-1771605494575.json +1633 -0
  19. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-show-loading-state-when-Load-More-button-is-clicked-1771605475401.json +471 -0
  20. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-trigger-infinite-scroll-when-scrolling-near-bottom-1771605476949.json +471 -0
  21. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-clear-search-filter-1771605508542.json +1255 -0
  22. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-display-event-list-1771605505572.json +1156 -0
  23. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-display-session-metadata-1771605504552.json +701 -0
  24. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-expand-and-collapse-tool-details-1771605515809.json +1182 -0
  25. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-filter-events-by-search-1771605513421.json +1245 -0
  26. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-load-session-detail-page-1771605494974.json +701 -0
  27. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-toggle-content-visibility-1771605550729.json +1177 -0
  28. package/.nyc_output/coverage-unit.json +21 -0
  29. package/.nycrc +29 -0
  30. package/CHANGELOG.md +36 -0
  31. package/README.md +154 -15
  32. package/examples/parser-usage.js +114 -0
  33. package/lib/parsers/README.md +239 -0
  34. package/lib/parsers/base-parser.js +53 -0
  35. package/lib/parsers/claude-parser.js +181 -0
  36. package/lib/parsers/copilot-parser.js +143 -0
  37. package/lib/parsers/index.js +13 -0
  38. package/lib/parsers/parser-factory.js +77 -0
  39. package/lib/parsers/pi-mono-parser.js +119 -0
  40. package/package.json +12 -4
  41. package/server.js +17 -2
  42. package/src/app.js +45 -20
  43. package/src/controllers/insightController.js +44 -8
  44. package/src/controllers/sessionController.js +217 -3
  45. package/src/controllers/uploadController.js +447 -7
  46. package/src/middleware/rateLimiting.js +7 -1
  47. package/src/models/Session.js +26 -0
  48. package/src/schemas/event.schema.js +73 -0
  49. package/src/services/eventNormalizer.js +291 -0
  50. package/src/services/insightService.js +140 -48
  51. package/src/services/sessionRepository.js +584 -49
  52. package/src/services/sessionService.js +1594 -27
  53. package/src/utils/helpers.js +6 -1
  54. package/views/index.ejs +111 -4
  55. package/views/session-vue.ejs +425 -71
  56. package/views/time-analyze.ejs +140 -57
@@ -1,74 +1,294 @@
1
1
  const fs = require('fs').promises;
2
2
  const path = require('path');
3
+ const os = require('os');
3
4
  const Session = require('../models/Session');
4
5
  const { fileExists, countLines, parseYAML, getSessionMetadataOptimized, shouldSkipEntry } = require('../utils/fileUtils');
6
+ const { ParserFactory } = require('../../lib/parsers');
5
7
 
6
8
  /**
7
9
  * Session Repository - Data access layer for sessions
10
+ * Supports both Copilot CLI and Claude Code sessions
8
11
  */
9
12
  class SessionRepository {
10
- constructor(sessionDir) {
11
- this.sessionDir = sessionDir;
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
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
+ }
44
+
45
+ this.parserFactory = new ParserFactory();
12
46
  }
13
47
 
14
48
  /**
15
- * Get all sessions
49
+ * Get all sessions from all sources
16
50
  * @returns {Promise<Session[]>} Array of sessions sorted by updatedAt (newest first)
17
51
  */
18
52
  async findAll() {
53
+ const allSessions = [];
54
+
55
+ for (const source of this.sources) {
56
+ try {
57
+ const sessions = await this._scanSource(source);
58
+ allSessions.push(...sessions);
59
+ } catch (err) {
60
+ console.error(`Error reading ${source.type} sessions from ${source.dir}:`, err.message);
61
+ }
62
+ }
63
+
64
+ return this._sortByUpdatedAt(allSessions);
65
+ }
66
+
67
+ /**
68
+ * Scan a single source directory
69
+ * @private
70
+ */
71
+ async _scanSource(source) {
19
72
  try {
20
- const entries = await fs.readdir(this.sessionDir);
73
+ await fs.access(source.dir);
74
+ } catch {
75
+ console.warn(`Source directory not found: ${source.dir}`);
76
+ return [];
77
+ }
21
78
 
22
- const tasks = entries
23
- .filter(entry => !shouldSkipEntry(entry))
24
- .map(async (entry) => {
25
- const fullPath = path.join(this.sessionDir, entry);
26
- const stats = await fs.stat(fullPath);
79
+ const entries = await fs.readdir(source.dir);
80
+ const tasks = entries
81
+ .filter(entry => !shouldSkipEntry(entry))
82
+ .map(async (entry) => {
83
+ const fullPath = path.join(source.dir, entry);
84
+ const stats = await fs.stat(fullPath);
27
85
 
86
+ if (source.type === 'copilot') {
87
+ // Copilot: directory-based or .jsonl files
28
88
  if (stats.isDirectory()) {
29
- return this._createDirectorySession(entry, fullPath, stats);
89
+ return this._createDirectorySession(entry, fullPath, stats, 'copilot');
30
90
  } else if (entry.endsWith('.jsonl')) {
31
- return this._createFileSession(entry, fullPath, stats);
91
+ return this._createFileSession(entry, fullPath, stats, 'copilot');
32
92
  }
33
- return null;
34
- });
93
+ } else if (source.type === 'claude') {
94
+ // Claude: all directories contain .jsonl files named by sessionId
95
+ if (stats.isDirectory()) {
96
+ return this._scanClaudeProjectDir(fullPath, entry);
97
+ }
98
+ } else if (source.type === 'pi-mono') {
99
+ // Pi-Mono: project directories containing timestamped .jsonl files
100
+ if (stats.isDirectory()) {
101
+ return this._scanPiMonoDir(fullPath, entry);
102
+ }
103
+ }
104
+ return null;
105
+ });
35
106
 
36
- const results = await Promise.allSettled(tasks);
37
- const sessions = results
38
- .filter(r => r.status === 'fulfilled' && r.value !== null && r.value !== undefined)
39
- .map(r => r.value);
107
+ const results = await Promise.allSettled(tasks);
108
+ return results
109
+ .filter(r => r.status === 'fulfilled' && r.value !== null && r.value !== undefined)
110
+ .map(r => r.value)
111
+ .flat(); // flat() because scanClaudeProjectDir returns array
112
+ }
40
113
 
41
- return this._sortByUpdatedAt(sessions);
114
+ /**
115
+ * Scan Claude project directory (contains multiple session .jsonl files AND directories with subagents)
116
+ * @private
117
+ */
118
+ async _scanClaudeProjectDir(projectDir, projectName) {
119
+ try {
120
+ const entries = await fs.readdir(projectDir);
121
+ const sessions = [];
122
+
123
+ for (const entry of entries) {
124
+ if (shouldSkipEntry(entry)) continue;
125
+
126
+ const fullPath = path.join(projectDir, entry);
127
+ const stats = await fs.stat(fullPath);
128
+
129
+ // Handle .jsonl files (main session files)
130
+ if (stats.isFile() && entry.endsWith('.jsonl')) {
131
+ const session = await this._createClaudeSession(entry, fullPath, stats, projectName);
132
+ if (session) {
133
+ sessions.push(session);
134
+ }
135
+ }
136
+
137
+ // Handle directories (potential subagents-only sessions)
138
+ if (stats.isDirectory()) {
139
+ // Check if this directory has a subagents subdirectory
140
+ const subagentsDir = path.join(fullPath, 'subagents');
141
+ try {
142
+ const subStats = await fs.stat(subagentsDir);
143
+ if (subStats.isDirectory()) {
144
+ // This is a valid subagents-only session
145
+ const session = await this._createClaudeSubagentsSession(entry, fullPath, stats, projectName);
146
+ if (session) {
147
+ sessions.push(session);
148
+ }
149
+ }
150
+ } catch {
151
+ // No subagents directory, not a session directory
152
+ }
153
+ }
154
+ }
155
+
156
+ return sessions;
42
157
  } catch (err) {
43
- console.error('Error reading sessions:', err);
158
+ console.error(`Error scanning Claude project dir ${projectDir}:`, err.message);
44
159
  return [];
45
160
  }
46
161
  }
47
162
 
48
163
  /**
49
- * Find session by ID
164
+ * Create Claude Code session from .jsonl file
165
+ * @private
166
+ */
167
+ async _createClaudeSession(entry, fullPath, stats, projectName) {
168
+ const sessionId = entry.replace('.jsonl', '');
169
+ const eventCount = await countLines(fullPath);
170
+
171
+ console.log(`[DEBUG] _createClaudeSession: ${entry}, events: ${eventCount}`);
172
+
173
+ // Read events to extract metadata and VALIDATE format
174
+ try {
175
+ const content = await fs.readFile(fullPath, 'utf-8');
176
+ const lines = content.trim().split('\n').filter(line => line.trim());
177
+ const events = lines.map(line => {
178
+ try {
179
+ return JSON.parse(line);
180
+ } catch {
181
+ return null;
182
+ }
183
+ }).filter(e => e !== null);
184
+
185
+ console.log(`[DEBUG] Parsed ${events.length} events from ${entry}`);
186
+
187
+ // VALIDATION: Check if this has Claude CORE events (assistant, user)
188
+ // Ignore metadata events like file-history-snapshot, progress (可以共存)
189
+ const hasClaudeCoreEvents = events.some(e => e.type === 'assistant' || e.type === 'user');
190
+ const hasCopilotCoreEvents = events.some(e => e.type === 'assistant.message' || e.type === 'user.message');
191
+
192
+ console.log(`[DEBUG] ${entry}: hasClaudeCoreEvents=${hasClaudeCoreEvents}, hasCopilotCoreEvents=${hasCopilotCoreEvents}`);
193
+
194
+ if (!hasClaudeCoreEvents && hasCopilotCoreEvents) {
195
+ console.warn(`File ${fullPath} contains only Copilot core events, skipping as Claude session`);
196
+ return null;
197
+ }
198
+
199
+ // If no Claude core events, also skip (empty or invalid file)
200
+ if (!hasClaudeCoreEvents) {
201
+ console.warn(`File ${fullPath} has no Claude core events (assistant/user), skipping`);
202
+ return null;
203
+ }
204
+
205
+ console.log(`[DEBUG] ${entry} passed validation, creating session...`);
206
+
207
+ // Use parser to extract metadata
208
+ const parserType = this.parserFactory.getParserType(events);
209
+ if (parserType !== 'claude') {
210
+ // Not a valid Claude session
211
+ return null;
212
+ }
213
+
214
+ const parsed = this.parserFactory.parse(events);
215
+ const metadata = parsed.metadata || {};
216
+
217
+ // Extract project name from directory name (convert back from dashes to slashes)
218
+ const projectPath = projectName.replace(/^-/, '/').replace(/-/g, '/');
219
+
220
+ return new Session(sessionId, 'file', {
221
+ source: 'claude',
222
+ directory: path.dirname(fullPath), // Directory containing the session file
223
+ workspace: {
224
+ summary: metadata.model ? `Claude Code session (${metadata.model})` : 'Claude Code session',
225
+ cwd: metadata.cwd || projectPath
226
+ },
227
+ createdAt: metadata.startTime || stats.birthtime,
228
+ updatedAt: stats.mtime,
229
+ summary: parsed.turns[0]?.userMessage?.content?.substring(0, 100) || 'No summary',
230
+ hasEvents: eventCount > 0,
231
+ eventCount: eventCount,
232
+ duration: null, // Claude format doesn't have explicit duration
233
+ isImported: false,
234
+ hasInsight: false,
235
+ copilotVersion: metadata.version,
236
+ selectedModel: metadata.model,
237
+ sessionStatus: 'completed'
238
+ });
239
+ } catch (err) {
240
+ console.error(`Error creating Claude session ${sessionId}:`, err.message);
241
+ return null;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Find session by ID (searches all sources)
50
247
  * @param {string} sessionId - Session ID
51
248
  * @returns {Promise<Session|null>}
52
249
  */
53
250
  async findById(sessionId) {
54
251
  if (shouldSkipEntry(sessionId)) return null;
55
252
 
253
+ for (const source of this.sources) {
254
+ let session = null;
255
+
256
+ if (source.type === 'copilot') {
257
+ session = await this._findCopilotSession(sessionId, source.dir);
258
+ } else if (source.type === 'claude') {
259
+ session = await this._findClaudeSession(sessionId, source.dir);
260
+ } else if (source.type === 'pi-mono') {
261
+ session = await this._findPiMonoSession(sessionId, source.dir);
262
+ }
263
+
264
+ if (session) return session;
265
+ }
266
+
267
+ return null;
268
+ }
269
+
270
+ /**
271
+ * Find Copilot session by ID
272
+ * @private
273
+ */
274
+ async _findCopilotSession(sessionId, sessionDir) {
275
+ // Try directory first
56
276
  try {
57
- // Try directory first
58
- const dirPath = path.join(this.sessionDir, sessionId);
277
+ const dirPath = path.join(sessionDir, sessionId);
59
278
  const dirStats = await fs.stat(dirPath);
60
279
  if (dirStats.isDirectory()) {
61
- return await this._createDirectorySession(sessionId, dirPath, dirStats);
280
+ return await this._createDirectorySession(sessionId, dirPath, dirStats, 'copilot');
62
281
  }
63
282
  } catch {
64
- // Not a directory, try .jsonl file
283
+ // Not a directory
65
284
  }
66
285
 
286
+ // Try .jsonl file
67
287
  try {
68
- const filePath = path.join(this.sessionDir, `${sessionId}.jsonl`);
288
+ const filePath = path.join(sessionDir, `${sessionId}.jsonl`);
69
289
  const fileStats = await fs.stat(filePath);
70
290
  if (fileStats.isFile()) {
71
- return await this._createFileSession(`${sessionId}.jsonl`, filePath, fileStats);
291
+ return await this._createFileSession(`${sessionId}.jsonl`, filePath, fileStats, 'copilot');
72
292
  }
73
293
  } catch {
74
294
  // File not found
@@ -78,21 +298,207 @@ class SessionRepository {
78
298
  }
79
299
 
80
300
  /**
81
- * Create session from directory
301
+ * Find Claude session by ID (searches all project directories)
82
302
  * @private
83
303
  */
84
- async _createDirectorySession(entry, fullPath, stats) {
304
+ async _findClaudeSession(sessionId, projectsDir) {
305
+ try {
306
+ const projects = await fs.readdir(projectsDir);
307
+
308
+ for (const project of projects) {
309
+ const projectPath = path.join(projectsDir, project);
310
+
311
+ // Try main session file first
312
+ const sessionFile = path.join(projectPath, `${sessionId}.jsonl`);
313
+ try {
314
+ const stats = await fs.stat(sessionFile);
315
+ if (stats.isFile()) {
316
+ console.log(`[DEBUG] Found file: ${sessionFile}`);
317
+ const session = await this._createClaudeSession(`${sessionId}.jsonl`, sessionFile, stats, project);
318
+ // If file contains Copilot events (validation failed), continue to check directory
319
+ if (session) {
320
+ console.log('[DEBUG] File validated as Claude session, returning');
321
+ return session;
322
+ }
323
+ console.log('[DEBUG] File validation failed, checking directory...');
324
+ // Otherwise fall through to check directory
325
+ }
326
+ } catch (err) {
327
+ // Main file not found, try directory
328
+ console.log(`[DEBUG] File not found: ${sessionFile}, error: ${err.message}`);
329
+ }
330
+
331
+ // Try session directory (subagents-only sessions, or when file validation failed)
332
+ const sessionDir = path.join(projectPath, sessionId);
333
+ console.log(`[DEBUG] Checking directory: ${sessionDir}`);
334
+ try {
335
+ const dirStats = await fs.stat(sessionDir);
336
+ if (dirStats.isDirectory()) {
337
+ console.log('[DEBUG] Directory exists, checking for subagents...');
338
+ // Check if it has subagents subdirectory
339
+ const subagentsDir = path.join(sessionDir, 'subagents');
340
+ try {
341
+ const subStats = await fs.stat(subagentsDir);
342
+ if (subStats.isDirectory()) {
343
+ console.log('[DEBUG] Found subagents directory, creating session...');
344
+ // Valid Claude subagents-only session
345
+ const result = await this._createClaudeSubagentsSession(sessionId, sessionDir, dirStats, project);
346
+ console.log('[DEBUG] Created subagents session:', result ? 'SUCCESS' : 'FAILED');
347
+ return result;
348
+ }
349
+ } catch (err) {
350
+ // No subagents directory
351
+ console.log(`[DEBUG] No subagents directory: ${err.message}`);
352
+ }
353
+ }
354
+ } catch (err) {
355
+ // Directory not found, continue
356
+ console.log(`[DEBUG] Directory not found: ${err.message}`);
357
+ }
358
+ }
359
+ } catch (err) {
360
+ // Projects dir not found
361
+ console.error(`[DEBUG] Projects dir error: ${err.message}`);
362
+ }
363
+
364
+ console.log(`[DEBUG] Session ${sessionId} not found in any project`);
365
+ return null;
366
+ }
367
+
368
+ /**
369
+ * Find Pi-Mono session by ID (searches all project directories)
370
+ * @private
371
+ */
372
+ async _findPiMonoSession(sessionId, sessionsDir) {
373
+ try {
374
+ const projects = await fs.readdir(sessionsDir);
375
+
376
+ for (const projectDir of projects) {
377
+ const projectPath = path.join(sessionsDir, projectDir);
378
+
379
+ try {
380
+ const files = await fs.readdir(projectPath);
381
+ // Look for file matching pattern: *_<sessionId>.jsonl
382
+ const matchingFile = files.find(f => f.includes(`_${sessionId}.jsonl`));
383
+
384
+ if (matchingFile) {
385
+ const filePath = path.join(projectPath, matchingFile);
386
+ const stats = await fs.stat(filePath);
387
+
388
+ // Read first line for metadata
389
+ const firstLine = await this._readFirstLine(filePath);
390
+ if (firstLine) {
391
+ const sessionEvent = JSON.parse(firstLine);
392
+ if (sessionEvent.type === 'session') {
393
+ const projectName = projectDir.replace(/^--/, '').replace(/--$/, '');
394
+ const eventCount = await countLines(filePath);
395
+
396
+ return new Session(
397
+ sessionId,
398
+ 'directory',
399
+ {
400
+ source: 'pi-mono',
401
+ directory: projectPath, // Project directory containing the session file
402
+ workspace: { cwd: sessionEvent.cwd || projectName },
403
+ createdAt: new Date(sessionEvent.timestamp),
404
+ updatedAt: new Date(stats.mtime),
405
+ summary: `Pi-Mono: ${path.basename(sessionEvent.cwd || projectName)}`,
406
+ hasEvents: eventCount > 0,
407
+ eventCount: eventCount,
408
+ duration: null,
409
+ sessionStatus: 'completed'
410
+ }
411
+ );
412
+ }
413
+ }
414
+ }
415
+ } catch {
416
+ // Not a directory or can't read
417
+ }
418
+ }
419
+ } catch (err) {
420
+ console.error(`Error searching Pi-Mono sessions: ${err.message}`);
421
+ }
422
+
423
+ return null;
424
+ }
425
+
426
+ /**
427
+ * Create Claude session from subagents-only directory (no main events.jsonl)
428
+ * @private
429
+ */
430
+ async _createClaudeSubagentsSession(sessionId, sessionDir, stats, projectName) {
431
+ try {
432
+ const subagentsDir = path.join(sessionDir, 'subagents');
433
+ const files = await fs.readdir(subagentsDir);
434
+ const subagentFiles = files.filter(f => f.startsWith('agent-') && f.endsWith('.jsonl'));
435
+
436
+ if (subagentFiles.length === 0) {
437
+ return null;
438
+ }
439
+
440
+ // Count events from all subagent files
441
+ let totalEvents = 0;
442
+ for (const file of subagentFiles) {
443
+ const filePath = path.join(subagentsDir, file);
444
+ totalEvents += await countLines(filePath);
445
+ }
446
+
447
+ // Read first subagent file for metadata
448
+ const firstFile = path.join(subagentsDir, subagentFiles[0]);
449
+ const content = await fs.readFile(firstFile, 'utf-8');
450
+ const lines = content.trim().split('\n').filter(line => line.trim());
451
+ const firstEvent = lines.length > 0 ? JSON.parse(lines[0]) : null;
452
+
453
+ const metadata = {
454
+ cwd: firstEvent?.cwd || projectName,
455
+ version: firstEvent?.version,
456
+ model: firstEvent?.message?.model,
457
+ startTime: firstEvent?.timestamp
458
+ };
459
+
460
+ const projectPath = projectName.replace(/^-/, '/').replace(/-/g, '/');
461
+
462
+ return new Session(sessionId, 'directory', {
463
+ source: 'claude',
464
+ directory: sessionDir, // Session directory path
465
+ workspace: {
466
+ summary: `Claude session (${subagentFiles.length} sub-agents)`,
467
+ cwd: metadata.cwd || projectPath
468
+ },
469
+ createdAt: metadata.startTime || stats.birthtime,
470
+ updatedAt: stats.mtime,
471
+ summary: firstEvent?.message?.content?.substring(0, 100) || 'Sub-agent tasks',
472
+ hasEvents: totalEvents > 0,
473
+ eventCount: totalEvents,
474
+ duration: null,
475
+ isImported: false,
476
+ hasInsight: false,
477
+ copilotVersion: metadata.version,
478
+ selectedModel: metadata.model,
479
+ sessionStatus: 'completed'
480
+ });
481
+ } catch (err) {
482
+ console.error(`Error creating Claude subagents session ${sessionId}:`, err.message);
483
+ return null;
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Create session from directory (Copilot format)
489
+ * @private
490
+ */
491
+ async _createDirectorySession(entry, fullPath, stats, source = 'copilot') {
85
492
  const workspaceFile = path.join(fullPath, 'workspace.yaml');
86
493
  const eventsFile = path.join(fullPath, 'events.jsonl');
87
494
  const importedMarkerFile = path.join(fullPath, '.imported');
88
495
  const insightReportFile = path.join(fullPath, 'agent-review.md');
89
496
 
90
- // Check if workspace.yaml exists
91
- if (!await fileExists(workspaceFile)) {
92
- return null; // Skip directories without workspace.yaml
93
- }
94
-
95
- const workspace = await parseYAML(workspaceFile);
497
+ // Parse workspace.yaml if exists, otherwise use defaults
498
+ const workspace = await fileExists(workspaceFile)
499
+ ? await parseYAML(workspaceFile)
500
+ : { summary: entry, repo: 'unknown' };
501
+
96
502
  const eventCount = await fileExists(eventsFile) ? await countLines(eventsFile) : 0;
97
503
  const isImported = await fileExists(importedMarkerFile);
98
504
  const hasInsight = await fileExists(insightReportFile);
@@ -109,36 +515,30 @@ class SessionRepository {
109
515
  copilotVersion = optimizedMetadata.copilotVersion;
110
516
  selectedModel = optimizedMetadata.selectedModel;
111
517
 
112
- // Compute session status:
113
- // - has session.end → completed
114
- // - no session.end + last event < 5 min ago → wip (actively running)
115
- // - no session.end + last event ≥ 5 min ago → unfinished (crashed/aborted)
116
518
  sessionStatus = this._computeSessionStatus(optimizedMetadata);
117
519
 
118
- // Fallback: if no summary in workspace, use first user message from optimized read
119
520
  if (!workspace.summary && optimizedMetadata.firstUserMessage) {
120
521
  workspace.summary = optimizedMetadata.firstUserMessage;
121
522
  }
122
523
  }
123
524
 
124
- return Session.fromDirectory(fullPath, entry, stats, workspace, eventCount, duration, isImported, hasInsight, copilotVersion, selectedModel, sessionStatus);
525
+ const session = Session.fromDirectory(fullPath, entry, stats, workspace, eventCount, duration, isImported, hasInsight, copilotVersion, selectedModel, sessionStatus);
526
+ session.source = source;
527
+ return session;
125
528
  }
126
529
 
127
530
  /**
128
- * Create session from .jsonl file
531
+ * Create session from .jsonl file (Copilot format)
129
532
  * @private
130
533
  */
131
- async _createFileSession(entry, fullPath, stats) {
534
+ async _createFileSession(entry, fullPath, stats, source = 'copilot') {
132
535
  const sessionId = entry.replace('.jsonl', '');
133
536
  const eventCount = await countLines(fullPath);
134
537
 
135
- // Use optimized single-pass metadata extraction
136
538
  const optimizedMetadata = await getSessionMetadataOptimized(fullPath);
137
-
138
- // Compute session status
139
539
  const sessionStatus = this._computeSessionStatus(optimizedMetadata);
140
540
 
141
- return Session.fromFile(
541
+ const session = Session.fromFile(
142
542
  fullPath,
143
543
  sessionId,
144
544
  stats,
@@ -149,20 +549,20 @@ class SessionRepository {
149
549
  optimizedMetadata.selectedModel,
150
550
  sessionStatus
151
551
  );
552
+ session.source = source;
553
+ return session;
152
554
  }
153
555
 
154
556
  /**
155
557
  * Compute session status from metadata
156
558
  * @private
157
- * @param {Object} metadata - Optimized metadata from getSessionMetadataOptimized
158
- * @returns {string} 'completed' | 'wip'
159
559
  */
160
560
  _computeSessionStatus(metadata) {
161
561
  if (metadata.hasSessionEnd) {
162
562
  return 'completed';
163
563
  }
164
564
  if (metadata.lastEventTime !== null && metadata.lastEventTime !== undefined) {
165
- const WIP_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
565
+ const WIP_THRESHOLD_MS = 5 * 60 * 1000;
166
566
  if ((Date.now() - metadata.lastEventTime) < WIP_THRESHOLD_MS) {
167
567
  return 'wip';
168
568
  }
@@ -170,6 +570,141 @@ class SessionRepository {
170
570
  return 'completed';
171
571
  }
172
572
 
573
+ /**
574
+ * Scan Pi-Mono project directory (--project-path--)
575
+ * Contains timestamped .jsonl files: YYYY-MM-DDTHH-mm-ss-SSSZ_<uuid>.jsonl
576
+ * @private
577
+ */
578
+ async _scanPiMonoDir(projectDir, dirName) {
579
+ try {
580
+ console.log(`[PI-MONO] Scanning directory: ${projectDir}`);
581
+ const entries = await fs.readdir(projectDir);
582
+ const jsonlFiles = entries.filter(e => e.endsWith('.jsonl'));
583
+
584
+ console.log(`[PI-MONO] Found ${jsonlFiles.length} .jsonl files in ${dirName}`);
585
+
586
+ if (jsonlFiles.length === 0) {
587
+ return [];
588
+ }
589
+
590
+ const sessions = [];
591
+
592
+ // Sort files by name (timestamp) to get latest
593
+ jsonlFiles.sort().reverse();
594
+
595
+ for (const file of jsonlFiles) {
596
+ const fullPath = path.join(projectDir, file);
597
+ const stats = await fs.stat(fullPath);
598
+
599
+ // Extract session ID from filename: YYYY-MM-DD...Z_<uuid>.jsonl
600
+ const match = file.match(/_([a-f0-9-]+)\.jsonl$/);
601
+ if (!match) {
602
+ console.log(`[PI-MONO] Skipping ${file}: no UUID match`);
603
+ continue;
604
+ }
605
+
606
+ const sessionId = match[1];
607
+
608
+ // Read first line to get session metadata
609
+ const firstLine = await this._readFirstLine(fullPath);
610
+ if (!firstLine) {
611
+ console.log(`[PI-MONO] Skipping ${file}: no first line`);
612
+ continue;
613
+ }
614
+
615
+ try {
616
+ const sessionEvent = JSON.parse(firstLine);
617
+ if (sessionEvent.type !== 'session') {
618
+ console.log(`[PI-MONO] Skipping ${file}: first event type is ${sessionEvent.type}, not 'session'`);
619
+ continue;
620
+ }
621
+
622
+ // Count events in the file
623
+ const eventCount = await countLines(fullPath);
624
+
625
+ // Extract project name from directory (remove -- prefix/suffix)
626
+ const projectPath = dirName.replace(/^--/, '').replace(/--$/, '');
627
+
628
+ const session = new Session(
629
+ sessionId,
630
+ 'directory',
631
+ {
632
+ source: 'pi-mono',
633
+ directory: projectDir, // Add directory path for Agent Review
634
+ workspace: { cwd: sessionEvent.cwd || projectPath },
635
+ createdAt: new Date(sessionEvent.timestamp),
636
+ updatedAt: new Date(stats.mtime),
637
+ summary: `Pi-Mono: ${path.basename(sessionEvent.cwd || projectPath)}`,
638
+ hasEvents: eventCount > 0,
639
+ eventCount: eventCount,
640
+ duration: null,
641
+ sessionStatus: 'completed'
642
+ }
643
+ );
644
+
645
+ console.log(`[PI-MONO] Created session: ${sessionId} from ${file}`);
646
+ sessions.push(session);
647
+ } catch (err) {
648
+ console.error(`[PI-MONO] Error parsing session ${file}:`, err.message);
649
+ }
650
+ }
651
+
652
+ console.log(`[PI-MONO] Total sessions found in ${dirName}: ${sessions.length}`);
653
+ return sessions;
654
+ } catch (err) {
655
+ console.error(`[PI-MONO] Error scanning dir ${projectDir}:`, err.message);
656
+ return [];
657
+ }
658
+ }
659
+
660
+ /**
661
+ * Read first line of a file
662
+ * @private
663
+ */
664
+ async _readFirstLine(filePath) {
665
+ const fs = require('fs');
666
+ const readline = require('readline');
667
+
668
+ return new Promise((resolve, reject) => {
669
+ const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
670
+ const rl = readline.createInterface({
671
+ input: stream,
672
+ crlfDelay: Infinity
673
+ });
674
+
675
+ let resolved = false;
676
+
677
+ rl.on('line', (line) => {
678
+ if (!resolved) {
679
+ resolved = true;
680
+ rl.close();
681
+ resolve(line.trim());
682
+ }
683
+ });
684
+
685
+ rl.on('close', () => {
686
+ if (!resolved) {
687
+ resolve(null);
688
+ }
689
+ });
690
+
691
+ rl.on('error', (err) => {
692
+ if (!resolved) {
693
+ resolved = true;
694
+ reject(err);
695
+ }
696
+ });
697
+
698
+ stream.on('error', (err) => {
699
+ if (!resolved) {
700
+ resolved = true;
701
+ rl.close();
702
+ reject(err);
703
+ }
704
+ });
705
+ });
706
+ }
707
+
173
708
  /**
174
709
  * Sort sessions by updated time (newest first)
175
710
  * @private