@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.
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 +1588 -36
  53. package/src/utils/helpers.js +6 -1
  54. package/views/index.ejs +111 -4
  55. package/views/session-vue.ejs +272 -65
  56. package/views/time-analyze.ejs +127 -55
@@ -1,13 +1,24 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const os = require('os');
3
+ const readline = require('readline');
4
4
  const { isValidSessionId, buildMetadata } = require('../utils/helpers');
5
5
  const SessionRepository = require('./sessionRepository');
6
+ const EventNormalizer = require('./eventNormalizer');
6
7
 
7
8
  class SessionService {
8
9
  constructor(sessionDir) {
9
- this.SESSION_DIR = sessionDir || process.env.SESSION_DIR || path.join(os.homedir(), '.copilot', 'session-state');
10
- this.sessionRepository = new SessionRepository(this.SESSION_DIR);
10
+ // If sessionDir is provided, use it (for backward compatibility)
11
+ // Otherwise, use SessionRepository's default multi-source configuration
12
+ if (sessionDir) {
13
+ this.SESSION_DIR = sessionDir;
14
+ this.sessionRepository = new SessionRepository(sessionDir);
15
+ } else {
16
+ // Use default configuration (Copilot + Claude + Pi-Mono)
17
+ this.sessionRepository = new SessionRepository();
18
+ }
19
+
20
+ // Initialize EventNormalizer for unified tool format
21
+ this.eventNormalizer = new EventNormalizer();
11
22
  }
12
23
 
13
24
  async getAllSessions() {
@@ -42,61 +53,1039 @@ class SessionService {
42
53
  return sessions.find(s => s.id === sessionId);
43
54
  }
44
55
 
45
- async getSessionEvents(sessionId) {
56
+ async getSessionEvents(sessionId, options = null) {
46
57
  if (!isValidSessionId(sessionId)) {
47
- return [];
58
+ return options ? { events: [], total: 0 } : [];
48
59
  }
49
60
 
50
- const sessionPath = path.join(this.SESSION_DIR, sessionId);
51
- let eventsFile;
61
+ // First, find the session to get its source and type
62
+ const session = await this.sessionRepository.findById(sessionId);
63
+ if (!session) {
64
+ return options ? { events: [], total: 0 } : [];
65
+ }
52
66
 
53
- try {
54
- const stats = await fs.promises.stat(sessionPath);
55
- if (stats.isDirectory()) {
56
- eventsFile = path.join(sessionPath, 'events.jsonl');
67
+
68
+ // Determine the file path based on source
69
+ let eventsFile;
70
+
71
+ if (session.source === 'copilot') {
72
+ // Copilot format: directory/events.jsonl or sessionId.jsonl
73
+ if (this.SESSION_DIR) {
74
+ // Single-source mode (backward compatibility)
75
+ const sessionPath = path.join(this.SESSION_DIR, sessionId);
76
+ try {
77
+ const stats = await fs.promises.stat(sessionPath);
78
+ if (stats.isDirectory()) {
79
+ eventsFile = path.join(sessionPath, 'events.jsonl');
80
+ } else {
81
+ eventsFile = path.join(this.SESSION_DIR, `${sessionId}.jsonl`);
82
+ }
83
+ } catch (_err) {
84
+ eventsFile = path.join(this.SESSION_DIR, `${sessionId}.jsonl`);
85
+ }
57
86
  } else {
58
- eventsFile = path.join(this.SESSION_DIR, `${sessionId}.jsonl`);
87
+ // Multi-source mode
88
+ const copilotSource = this.sessionRepository.sources.find(s => s.type === 'copilot');
89
+ if (!copilotSource) return [];
90
+
91
+ const sessionPath = path.join(copilotSource.dir, sessionId);
92
+ try {
93
+ const stats = await fs.promises.stat(sessionPath);
94
+ if (stats.isDirectory()) {
95
+ eventsFile = path.join(sessionPath, 'events.jsonl');
96
+ } else {
97
+ eventsFile = path.join(copilotSource.dir, `${sessionId}.jsonl`);
98
+ }
99
+ } catch (_err) {
100
+ eventsFile = path.join(copilotSource.dir, `${sessionId}.jsonl`);
101
+ }
102
+ }
103
+ } else if (session.source === 'claude') {
104
+ // Claude format: projects/*/sessionId.jsonl
105
+ const claudeSource = this.sessionRepository.sources.find(s => s.type === 'claude');
106
+ if (!claudeSource) return [];
107
+
108
+ // If session type is 'directory', it's a subagents-only session (no main file)
109
+ // Skip main file search and load only subagents
110
+ if (session.type !== 'directory') {
111
+ // Search all project directories for this session
112
+ try {
113
+ const projects = await fs.promises.readdir(claudeSource.dir);
114
+ for (const project of projects) {
115
+ const candidateFile = path.join(claudeSource.dir, project, `${sessionId}.jsonl`);
116
+ try {
117
+ await fs.promises.access(candidateFile);
118
+ eventsFile = candidateFile;
119
+ break;
120
+ } catch {
121
+ // Not in this project, continue
122
+ }
123
+ }
124
+ } catch (err) {
125
+ console.error('Error searching Claude projects:', err);
126
+ return [];
127
+ }
128
+ }
129
+ } else if (session.source === 'pi-mono') {
130
+ // Pi-Mono format: sessions/--project-path--/timestamp_uuid.jsonl
131
+ const piMonoSource = this.sessionRepository.sources.find(s => s.type === 'pi-mono');
132
+ if (!piMonoSource) return [];
133
+
134
+ // Search all project directories for this session ID
135
+ try {
136
+ const projects = await fs.promises.readdir(piMonoSource.dir);
137
+ for (const project of projects) {
138
+ const projectPath = path.join(piMonoSource.dir, project);
139
+ try {
140
+ const files = await fs.promises.readdir(projectPath);
141
+ const matchingFile = files.find(f => f.includes(`_${sessionId}.jsonl`));
142
+ if (matchingFile) {
143
+ eventsFile = path.join(projectPath, matchingFile);
144
+ break;
145
+ }
146
+ } catch {
147
+ // Not a directory or can't read
148
+ }
149
+ }
150
+ } catch (err) {
151
+ console.error('Error searching Pi-Mono sessions:', err);
152
+ return [];
59
153
  }
60
- } catch (_err) {
61
- eventsFile = path.join(this.SESSION_DIR, `${sessionId}.jsonl`);
62
154
  }
63
155
 
64
- try {
65
- await fs.promises.access(eventsFile);
66
- } catch (_err) {
67
- return [];
156
+
157
+ // Initialize events array
158
+ let events = [];
159
+
160
+ // Load main events file if it exists
161
+ if (eventsFile) {
162
+ try {
163
+ await fs.promises.access(eventsFile);
164
+
165
+ // Stream-based reading: supports files of any size
166
+ const fileStream = fs.createReadStream(eventsFile, { encoding: 'utf-8' });
167
+ const rl = readline.createInterface({
168
+ input: fileStream,
169
+ crlfDelay: Infinity // Treat \r\n as single line break
170
+ });
171
+
172
+ let lineIndex = 0;
173
+ const parsedEvents = [];
174
+
175
+ for await (const line of rl) {
176
+ const trimmedLine = line.trim();
177
+ if (trimmedLine) {
178
+ try {
179
+ const event = JSON.parse(trimmedLine);
180
+ event._fileIndex = lineIndex;
181
+ parsedEvents.push(event);
182
+ } catch (err) {
183
+ console.error(`Error parsing line ${lineIndex + 1}:`, err.message);
184
+ }
185
+ }
186
+ lineIndex++;
187
+ }
188
+
189
+ events = parsedEvents;
190
+
191
+ // Sort by timestamp with stable tiebreaker on original file order
192
+ events.sort((a, b) => {
193
+ const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
194
+ const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
195
+ if (timeA !== timeB) return timeA - timeB;
196
+ return a._fileIndex - b._fileIndex;
197
+ });
198
+
199
+ // Normalize events to unified format (convert Claude format to standard)
200
+ const normalizedEvents = events.map(event => this._normalizeEvent(event, session.source));
201
+ events = normalizedEvents;
202
+
203
+ // Match tool calls across events (source-specific)
204
+ if (session.source === 'copilot') {
205
+ this._matchCopilotToolCalls(events);
206
+ } else if (session.source === 'claude') {
207
+ this._matchClaudeToolResults(events);
208
+ }
209
+ } catch (err) {
210
+ console.error('Error reading main events file:', err);
211
+ // Continue to load subagents even if main file fails
212
+ }
213
+ }
214
+
215
+ // Load and merge sub-agent events (for both Copilot and Claude)
216
+ // For Claude sessions without main events.jsonl, this will load subagents from correct path
217
+ await this._mergeSubAgentEvents(events, eventsFile, sessionId, session.source);
218
+
219
+ // Re-run tool matching after merging subagents (subagent events need matching too)
220
+ if (session.source === 'copilot') {
221
+ this._matchCopilotToolCalls(events);
222
+ events = this._expandCopilotToTimelineFormat(events);
223
+ } else if (session.source === 'claude') {
224
+ this._matchClaudeToolResults(events);
225
+ events = this._expandClaudeToTimelineFormat(events);
226
+ } else if (session.source === 'pi-mono') {
227
+ // Pi-Mono: Keep original event structure, no transformation
228
+ // Events are already normalized with type="message" + role in data
229
+ // But we need to merge toolResult events into their parent assistant messages
230
+ this._mergePiMonoToolResults(events);
68
231
  }
232
+
233
+ // Clean up events for timeline rendering
234
+ events = events.filter(e => {
235
+ // Keep events with valid timestamps
236
+ const ts = e.timestamp || e.snapshot?.timestamp;
237
+ if (!ts) {
238
+ console.warn('[SessionService] Filtered event without timestamp:', e.type, e.id || e._fileIndex);
239
+ return false;
240
+ }
241
+ return true;
242
+ });
243
+
244
+ // Fix fileIndex for subagent events (999999 is too large and breaks sorting)
245
+ events.forEach(e => {
246
+ if (e._fileIndex === 999999 && e.timestamp) {
247
+ // Use timestamp for sorting instead
248
+ delete e._fileIndex;
249
+ }
250
+ });
251
+
252
+ // Apply unified tool schema normalization (adds startTime, endTime, status, etc.)
253
+ events = this.eventNormalizer.normalizeEvents(events, session.source);
254
+
255
+ // Apply pagination if requested
256
+ if (options && typeof options.limit === 'number' && typeof options.offset === 'number') {
257
+ const total = events.length;
258
+ const paginatedEvents = events.slice(options.offset, options.offset + options.limit);
259
+ return {
260
+ events: paginatedEvents,
261
+ total
262
+ };
263
+ }
264
+
265
+ // Backward compatibility: return array when no pagination options
266
+ return events;
267
+ }
69
268
 
269
+ /**
270
+ * Load and merge sub-agent events into main event stream
271
+ * @private
272
+ * @param {Array} events - Main events array
273
+ * @param {string|null} mainEventsFile - Path to main events file (null if doesn't exist)
274
+ * @param {string} sessionId - Session ID
275
+ * @param {string} source - Session source ('copilot' or 'claude')
276
+ */
277
+ async _mergeSubAgentEvents(events, mainEventsFile, sessionId, source) {
278
+ let subagentsDir;
279
+
280
+ if (source === 'claude') {
281
+ // For Claude sessions, look in .claude/projects/*/sessionId/subagents
282
+ const claudeSource = this.sessionRepository.sources.find(s => s.type === 'claude');
283
+ if (!claudeSource) return;
284
+
285
+ try {
286
+ const projects = await fs.promises.readdir(claudeSource.dir);
287
+ for (const project of projects) {
288
+ const candidateDir = path.join(claudeSource.dir, project, sessionId, 'subagents');
289
+ try {
290
+ const stats = await fs.promises.stat(candidateDir);
291
+ if (stats.isDirectory()) {
292
+ subagentsDir = candidateDir;
293
+ break;
294
+ }
295
+ } catch {
296
+ // Not in this project, continue
297
+ }
298
+ }
299
+ } catch (err) {
300
+ console.error('Error searching Claude subagents:', err);
301
+ return;
302
+ }
303
+ } else if (source === 'copilot' && mainEventsFile) {
304
+ // For Copilot sessions, detect from main events file path
305
+ const eventsDir = path.dirname(mainEventsFile);
306
+ const eventsBasename = path.basename(mainEventsFile);
307
+
308
+ if (eventsBasename === 'events.jsonl') {
309
+ // .copilot/session-state/<sessionId>/events.jsonl → check <sessionId>/subagents
310
+ subagentsDir = path.join(eventsDir, 'subagents');
311
+ } else {
312
+ // <sessionId>.jsonl alongside session directory → check <parent>/<sessionId>/subagents
313
+ subagentsDir = path.join(eventsDir, sessionId, 'subagents');
314
+ }
315
+ }
316
+
317
+ if (!subagentsDir) {
318
+ return;
319
+ }
320
+
70
321
  try {
71
- const content = await fs.promises.readFile(eventsFile, 'utf-8');
72
- const lines = content.trim().split('\n').filter(line => line.trim());
73
- const events = lines.map((line, index) => {
322
+ const stats = await fs.promises.stat(subagentsDir);
323
+ if (!stats.isDirectory()) {
324
+ return;
325
+ }
326
+ } catch (err) {
327
+ // No subagents directory
328
+ return;
329
+ }
330
+
331
+ try {
332
+ const files = await fs.promises.readdir(subagentsDir);
333
+ const subagentFiles = files.filter(f => f.startsWith('agent-') && f.endsWith('.jsonl'));
334
+
335
+ if (subagentFiles.length === 0) return;
336
+
337
+ // Process each sub-agent
338
+ for (const file of subagentFiles) {
339
+ const subagentId = file.replace('.jsonl', '');
340
+ const subagentPath = path.join(subagentsDir, file);
341
+
74
342
  try {
75
- const event = JSON.parse(line);
76
- // Preserve original file order as _fileIndex for stable sorting
77
- event._fileIndex = index;
78
- return event;
343
+ // Stream-based reading for subagent files
344
+ const fileStream = fs.createReadStream(subagentPath, { encoding: 'utf-8' });
345
+ const rl = readline.createInterface({
346
+ input: fileStream,
347
+ crlfDelay: Infinity
348
+ });
349
+
350
+ const lines = [];
351
+ for await (const line of rl) {
352
+ const trimmedLine = line.trim();
353
+ if (trimmedLine) {
354
+ lines.push(trimmedLine);
355
+ }
356
+ }
357
+
358
+ if (lines.length === 0) continue;
359
+
360
+ // Parse first event to get metadata (slug, agentId, first message)
361
+ let agentName = subagentId.replace('agent-', '');
362
+ let agentDisplayName = agentName.toUpperCase();
363
+ let agentDescription = `Sub-agent ${subagentId}`;
364
+
365
+ try {
366
+ const firstEvent = JSON.parse(lines[0]);
367
+ // Use agentId (unique per sub-agent) instead of slug (same for all)
368
+ if (firstEvent.agentId) {
369
+ agentName = firstEvent.agentId;
370
+ agentDisplayName = `agent-${firstEvent.agentId}`;
371
+ }
372
+ if (firstEvent.message?.content) {
373
+ // Use first message as description (truncate if too long)
374
+ const content = typeof firstEvent.message.content === 'string'
375
+ ? firstEvent.message.content
376
+ : JSON.stringify(firstEvent.message.content);
377
+ agentDescription = content.length > 100 ? content.slice(0, 100) + '...' : content;
378
+ }
379
+ } catch (err) {
380
+ // Fall back to file-based name
381
+ }
382
+
383
+ const subagentEvents = lines.map((line, index) => {
384
+ try {
385
+ const event = JSON.parse(line);
386
+ event._fileIndex = 1000000 + index; // Offset to avoid collision
387
+
388
+ // Mark as sub-agent event
389
+ event._subagent = {
390
+ id: subagentId,
391
+ name: agentName
392
+ };
393
+
394
+ return event;
395
+ } catch (err) {
396
+ console.error(`Error parsing sub-agent ${subagentId} line ${index + 1}:`, err.message);
397
+ return null;
398
+ }
399
+ }).filter(e => e !== null);
400
+
401
+ if (subagentEvents.length === 0) continue;
402
+
403
+ // Normalize sub-agent events (use same source as parent session)
404
+ const normalizedSubEvents = subagentEvents.map(event => this._normalizeEvent(event, source));
405
+
406
+ // Get first and last event timestamps
407
+ const firstEvent = normalizedSubEvents[0];
408
+ const lastEvent = normalizedSubEvents[normalizedSubEvents.length - 1];
409
+
410
+ const startTime = firstEvent.timestamp || new Date().toISOString();
411
+ const endTime = lastEvent.timestamp || new Date().toISOString();
412
+
413
+ // Generate subagent.started event
414
+ const startEvent = {
415
+ type: 'subagent.started',
416
+ id: `${subagentId}-start`,
417
+ timestamp: startTime,
418
+ _fileIndex: firstEvent._fileIndex - 1,
419
+ _subagent: { id: subagentId, name: agentName },
420
+ data: {
421
+ toolCallId: subagentId,
422
+ agentName: agentName,
423
+ agentDisplayName: agentDisplayName,
424
+ agentDescription: agentDescription
425
+ }
426
+ };
427
+
428
+ // Generate subagent.completed event
429
+ const endEvent = {
430
+ type: 'subagent.completed',
431
+ id: `${subagentId}-end`,
432
+ timestamp: endTime,
433
+ _fileIndex: lastEvent._fileIndex + 1,
434
+ _subagent: { id: subagentId, name: agentName },
435
+ data: {
436
+ toolCallId: subagentId,
437
+ result: `Sub-agent ${agentDisplayName} completed`
438
+ }
439
+ };
440
+
441
+ // Add to main events array
442
+ events.push(startEvent, ...normalizedSubEvents, endEvent);
443
+
79
444
  } catch (err) {
80
- console.error(`Error parsing line ${index + 1}:`, err.message);
81
- return null;
445
+ console.error(`Error reading sub-agent ${subagentId}:`, err);
82
446
  }
83
- }).filter(event => event !== null);
84
-
85
- // Sort by timestamp with stable tiebreaker on original file order.
86
- // This ensures events with identical timestamps (e.g. an assistant.message
87
- // followed by its tool.execution_start events) keep their logical order.
447
+ }
448
+
449
+ // Re-sort all events by timestamp
88
450
  events.sort((a, b) => {
89
451
  const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
90
452
  const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
91
453
  if (timeA !== timeB) return timeA - timeB;
92
454
  return a._fileIndex - b._fileIndex;
93
455
  });
94
-
95
- return events;
456
+
96
457
  } catch (err) {
97
- console.error('Error reading events:', err);
98
- return [];
458
+ console.error('Error processing sub-agents:', err);
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Match tool_result events with tool_use for Claude format
464
+ * @private
465
+ */
466
+ _matchClaudeToolResults(events) {
467
+ // Build map of tool_result by tool_use_id
468
+ const toolResultMap = new Map();
469
+
470
+ events.forEach(event => {
471
+ if (event.data?.tools) {
472
+ event.data.tools.forEach(tool => {
473
+ if (tool.type === 'tool_result') {
474
+ // Bug fix #1: Validate tool_use_id exists
475
+ if (tool.tool_use_id) {
476
+ toolResultMap.set(tool.tool_use_id, tool);
477
+ } else {
478
+ console.warn('[sessionService] tool_result missing tool_use_id:', tool);
479
+ }
480
+ }
481
+ });
482
+ }
483
+ });
484
+
485
+ // Match tool_use with tool_result
486
+ events.forEach(event => {
487
+ if (event.data?.tools) {
488
+ event.data.tools = event.data.tools.map(tool => {
489
+ if (tool.type === 'tool_use') {
490
+ const result = toolResultMap.get(tool.id);
491
+ if (result) {
492
+ return {
493
+ ...tool,
494
+ result: result.content,
495
+ _matched: true
496
+ };
497
+ }
498
+ // Bug fix #4: Add _matched: false for unmatched Claude tools (consistency with Copilot)
499
+ return {
500
+ ...tool,
501
+ _matched: false
502
+ };
503
+ }
504
+ return tool;
505
+ });
506
+
507
+ // Bug fix: Only remove tool_result from assistant messages
508
+ // User messages naturally contain tool_result (responses to tool_use), keep them
509
+ if (event.type === 'assistant' || event.type === 'assistant.message') {
510
+ event.data.tools = event.data.tools.filter(tool => tool.type !== 'tool_result');
511
+ }
512
+ }
513
+ });
514
+ }
515
+
516
+ /**
517
+ * Match Pi-Mono tool results with tool calls by order (parentId chain)
518
+ * Pi-Mono format: toolResult messages form a parentId chain starting from assistant message
519
+ * After matching, removes tool.result events from the stream (they're attached to tools)
520
+ * @private
521
+ */
522
+ /**
523
+ * Merge Pi-Mono toolResult messages into their parent assistant messages
524
+ * Pi-Mono has message events with role: user/assistant/toolResult
525
+ * After normalization: user.message, assistant.message, and message (toolResult only)
526
+ * toolResult events are chained via parentId (first points to assistant, rest chain to previous)
527
+ * @private
528
+ */
529
+ _mergePiMonoToolResults(events) {
530
+ const toolResultIdsToRemove = new Set();
531
+
532
+ // Find all assistant messages with tool calls
533
+ events.forEach(assistantEvent => {
534
+ if (assistantEvent.type === 'assistant.message' && // After normalization
535
+ assistantEvent.data.tools &&
536
+ assistantEvent.data.tools.length > 0) {
537
+
538
+ const tools = assistantEvent.data.tools;
539
+
540
+ // Collect toolResult events by following parentId chain
541
+ const resultEvents = [];
542
+ let currentId = assistantEvent.id;
543
+
544
+ // Follow the chain: find events whose parentId points to current
545
+ let foundMore = true;
546
+ while (foundMore && resultEvents.length < tools.length) {
547
+ foundMore = false;
548
+ for (const event of events) {
549
+ if (event.type === 'message' &&
550
+ event.data.role === 'toolResult' &&
551
+ event.parentId === currentId &&
552
+ !resultEvents.includes(event)) {
553
+ resultEvents.push(event);
554
+ currentId = event.id;
555
+ foundMore = true;
556
+ break;
557
+ }
558
+ }
559
+ }
560
+
561
+ // Match results to tools by order
562
+ resultEvents.forEach((resultEvent, index) => {
563
+ if (index < tools.length) {
564
+ const tool = tools[index];
565
+ tool.result = resultEvent.data.result;
566
+ tool.resultId = resultEvent.id;
567
+ tool.status = 'completed';
568
+ toolResultIdsToRemove.add(resultEvent.id);
569
+ }
570
+ });
571
+ }
572
+ });
573
+
574
+ // Remove toolResult events from the stream (they're now merged into assistant messages)
575
+ const originalLength = events.length;
576
+ for (let i = events.length - 1; i >= 0; i--) {
577
+ if (events[i].type === 'message' &&
578
+ events[i].data.role === 'toolResult' &&
579
+ toolResultIdsToRemove.has(events[i].id)) {
580
+ events.splice(i, 1);
581
+ }
582
+ }
583
+
584
+ if (toolResultIdsToRemove.size > 0) {
585
+ console.log(`[PI-MONO] Merged ${toolResultIdsToRemove.size} toolResult events into assistant messages (${originalLength} → ${events.length} events)`);
586
+ }
587
+ }
588
+
589
+ /**
590
+ * OLD METHOD - kept for reference, not used anymore
591
+ * Match Pi-Mono tool results (old format with tool.result type)
592
+ * @private
593
+ */
594
+ _matchPiMonoToolResults_OLD(events) {
595
+ const matchedResultIds = new Set(); // Track matched tool.result event IDs to remove
596
+
597
+ // Find all assistant messages with tool calls
598
+ events.forEach(assistantEvent => {
599
+ if (assistantEvent.type === 'assistant.message' && assistantEvent.data.tools && assistantEvent.data.tools.length > 0) {
600
+ const tools = assistantEvent.data.tools;
601
+
602
+ // Collect toolResult events by following parentId chain
603
+ const resultEvents = [];
604
+ let currentId = assistantEvent.id;
605
+
606
+ // Follow the chain: find events whose parentId points to current
607
+ let foundMore = true;
608
+ while (foundMore && resultEvents.length < tools.length) {
609
+ foundMore = false;
610
+ for (const event of events) {
611
+ if (event.type === 'tool.result' && event.parentId === currentId && !resultEvents.includes(event)) {
612
+ resultEvents.push(event);
613
+ currentId = event.id;
614
+ foundMore = true;
615
+ break;
616
+ }
617
+ }
618
+ }
619
+
620
+ // Match results to tools by order
621
+ resultEvents.forEach((resultEvent, index) => {
622
+ if (index < tools.length) {
623
+ const tool = tools[index];
624
+ tool.status = 'completed';
625
+ tool._matched = true;
626
+ tool.result = resultEvent.data.result;
627
+ tool.resultId = resultEvent.id;
628
+ matchedResultIds.add(resultEvent.id); // Mark for removal
629
+ }
630
+ });
631
+ }
632
+ });
633
+
634
+ // Remove matched tool.result events from the stream (like Claude does)
635
+ // These are now attached to assistant messages, don't need separate display
636
+ const originalLength = events.length;
637
+ for (let i = events.length - 1; i >= 0; i--) {
638
+ if (events[i].type === 'tool.result' && matchedResultIds.has(events[i].id)) {
639
+ events.splice(i, 1);
640
+ }
641
+ }
642
+
643
+ if (matchedResultIds.size > 0) {
644
+ console.log(`[PI-MONO] Removed ${matchedResultIds.size} matched tool.result events (${originalLength} → ${events.length} events)`);
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Match Copilot tool.execution_start/complete events and attach to assistant.message
650
+ * @private
651
+ */
652
+ _matchCopilotToolCalls(events) {
653
+ // Step 1: Build tool execution map (start + complete paired by toolCallId)
654
+ const toolExecutions = new Map();
655
+
656
+ events.forEach(event => {
657
+ if (event.type === 'tool.execution_start') {
658
+ const toolId = event.data?.toolCallId;
659
+ if (toolId) {
660
+ toolExecutions.set(toolId, {
661
+ name: event.data.toolName,
662
+ input: event.data.arguments || {},
663
+ start: event
664
+ });
665
+ }
666
+ } else if (event.type === 'tool.execution_complete') {
667
+ const toolId = event.data?.toolCallId;
668
+ if (toolId) {
669
+ if (toolExecutions.has(toolId)) {
670
+ const exec = toolExecutions.get(toolId);
671
+ exec.complete = event;
672
+ exec.result = event.data?.result;
673
+ exec.status = event.data?.error ? 'error' : 'completed';
674
+ exec.error = event.data?.error;
675
+ } else {
676
+ // Bug fix #3: Handle orphaned execution_complete events (no matching start)
677
+ console.warn(`[sessionService] Orphaned tool.execution_complete for toolCallId=${toolId}`);
678
+ toolExecutions.set(toolId, {
679
+ name: event.data.toolName || 'unknown',
680
+ input: {},
681
+ start: null, // No start event
682
+ complete: event,
683
+ result: event.data?.result,
684
+ status: event.data?.error ? 'error' : 'completed',
685
+ error: event.data?.error
686
+ });
687
+ }
688
+ }
689
+ }
690
+ });
691
+
692
+ // Step 2: Match toolRequests in assistant.message with tool executions
693
+ events.forEach(event => {
694
+ if (event.type === 'assistant.message' && event.data?.toolRequests) {
695
+ const tools = [];
696
+
697
+ event.data.toolRequests.forEach(req => {
698
+ const toolId = req.toolCallId;
699
+ if (toolExecutions.has(toolId)) {
700
+ const exec = toolExecutions.get(toolId);
701
+ tools.push({
702
+ type: 'tool_use',
703
+ id: toolId,
704
+ name: req.name || exec.name,
705
+ input: req.arguments || exec.input,
706
+ result: exec.result,
707
+ status: exec.status || 'running',
708
+ error: exec.error,
709
+ _matched: !!exec.complete
710
+ });
711
+ } else {
712
+ // Tool request but no execution found (shouldn't happen normally)
713
+ tools.push({
714
+ type: 'tool_use',
715
+ id: toolId,
716
+ name: req.name,
717
+ input: req.arguments || {},
718
+ status: 'running',
719
+ _matched: false
720
+ });
721
+ }
722
+ });
723
+
724
+ if (tools.length > 0) {
725
+ event.data.tools = tools;
726
+ }
727
+ }
728
+ });
729
+ }
730
+
731
+ /**
732
+ * Generate badge display information for an event
733
+ * @private
734
+ */
735
+ _generateBadgeInfo(normalized) {
736
+ const type = normalized.type;
737
+ const data = normalized.data || {};
738
+
739
+ // Special case: toolResult still uses type='message'
740
+ if (type === 'message' && data.role === 'toolResult') {
741
+ normalized.data.badgeLabel = 'TOOL RESULT';
742
+ normalized.data.badgeClass = 'badge-tool';
743
+ return;
744
+ }
745
+
746
+ // Special cases for specific event types
747
+ if (type === 'session.model_change' || type === 'model.change') {
748
+ normalized.data.badgeLabel = 'MODEL CHANGE';
749
+ normalized.data.badgeClass = 'badge-session';
750
+ return;
751
+ }
752
+ if (type === 'session.truncation') {
753
+ normalized.data.badgeLabel = 'TRUNCATION';
754
+ normalized.data.badgeClass = 'badge-truncation';
755
+ return;
756
+ }
757
+ if (type === 'session.compaction_start' || type === 'session.compaction_complete' || type === 'compaction') {
758
+ normalized.data.badgeLabel = 'COMPACTION';
759
+ normalized.data.badgeClass = 'badge-compaction';
760
+ return;
761
+ }
762
+ if (type === 'thinking.change') {
763
+ normalized.data.badgeLabel = 'THINKING';
764
+ normalized.data.badgeClass = 'badge-session';
765
+ return;
766
+ }
767
+
768
+ // Extract category from type (e.g., 'user.message' → 'user')
769
+ const parts = (type || '').split('.');
770
+ const category = parts[0] || 'unknown';
771
+
772
+ const badgeMap = {
773
+ user: { label: 'USER', class: 'badge-user' },
774
+ assistant: { label: 'ASSISTANT', class: 'badge-assistant' },
775
+ reasoning: { label: 'REASONING', class: 'badge-reasoning' },
776
+ turn: { label: 'TURN', class: 'badge-turn' },
777
+ tool: { label: 'TOOL', class: 'badge-tool' },
778
+ subagent: { label: 'SUBAGENT', class: 'badge-subagent' },
779
+ skill: { label: 'SKILL', class: 'badge-skill' },
780
+ session: { label: 'SESSION', class: 'badge-session' },
781
+ error: { label: 'ERROR', class: 'badge-error' },
782
+ abort: { label: 'ABORT', class: 'badge-error' }
783
+ };
784
+
785
+ const badge = badgeMap[category] || { label: category.toUpperCase(), class: 'badge-info' };
786
+ normalized.data.badgeLabel = badge.label;
787
+ normalized.data.badgeClass = badge.class;
788
+ }
789
+
790
+ /**
791
+ * Normalize event to unified format for frontend
792
+ * @private
793
+ */
794
+ _normalizeEvent(event, source) {
795
+ const normalized = { ...event };
796
+ normalized.data = normalized.data || {};
797
+
798
+ if (source === 'copilot') {
799
+ // Copilot format normalization
800
+
801
+ // Old format: request/response → user/assistant
802
+ if (event.type === 'request') {
803
+ normalized.type = 'user';
804
+ // Extract message from payload.messages (Anthropic API format)
805
+ if (event.payload?.messages && Array.isArray(event.payload.messages)) {
806
+ const userMessage = event.payload.messages.find(m => m.role === 'user');
807
+ if (userMessage) {
808
+ normalized.message = {
809
+ role: 'user',
810
+ content: userMessage.content || ''
811
+ };
812
+ }
813
+ }
814
+ } else if (event.type === 'response') {
815
+ normalized.type = 'assistant';
816
+ // Extract message from payload.content (Anthropic API format)
817
+ if (event.payload?.content && Array.isArray(event.payload.content)) {
818
+ const textBlocks = event.payload.content.filter(block => block.type === 'text');
819
+ if (textBlocks.length > 0) {
820
+ normalized.message = {
821
+ role: 'assistant',
822
+ content: textBlocks.map(block => block.text).join('\n')
823
+ };
824
+ }
825
+ }
826
+ }
827
+
828
+ // New format: assistant.message data normalization
829
+ if (event.type === 'assistant.message') {
830
+ // Convert data.content → data.message for consistency
831
+ if (event.data?.content && event.data.content.trim()) {
832
+ normalized.data.message = event.data.content;
833
+ }
834
+ // If only toolcalls, leave message empty (don't create placeholder)
835
+ }
836
+
837
+ // Generate badge display info
838
+ this._generateBadgeInfo(normalized);
839
+ return normalized;
840
+ }
841
+
842
+ if (source === 'pi-mono') {
843
+ // Pi-Mono format normalization - TRANSFORM TO UNIFIED TYPE
844
+ if (event.type === 'message') {
845
+ const { message } = event;
846
+
847
+ // Transform type to unified format (like Copilot/Claude)
848
+ if (message.role === 'user') {
849
+ normalized.type = 'user.message';
850
+ } else if (message.role === 'assistant') {
851
+ normalized.type = 'assistant.message';
852
+ } else if (message.role === 'toolResult') {
853
+ // toolResult keeps 'message' type - will be merged by _mergePiMonoToolResults
854
+ normalized.type = 'message';
855
+ }
856
+ normalized.data.role = message.role;
857
+
858
+ // Extract text content
859
+ if (Array.isArray(message.content)) {
860
+ const textBlocks = message.content.filter(block => block.type === 'text');
861
+ if (textBlocks.length > 0) {
862
+ normalized.data.message = textBlocks.map(block => block.text).join('\n');
863
+ }
864
+
865
+ // Extract tool calls (for assistant messages)
866
+ if (message.role === 'assistant') {
867
+ const toolCalls = message.content.filter(block => block.type === 'toolCall');
868
+ if (toolCalls.length > 0) {
869
+ normalized.data.tools = toolCalls.map(tool => ({
870
+ type: 'tool_use',
871
+ id: tool.id,
872
+ name: tool.name,
873
+ input: tool.arguments
874
+ }));
875
+ }
876
+ }
877
+
878
+ // Extract tool result (for toolResult messages)
879
+ if (message.role === 'toolResult') {
880
+ normalized.data.result = textBlocks.map(block => block.text).join('\n');
881
+ }
882
+ }
883
+
884
+ // Preserve usage metadata if available
885
+ if (message.usage) {
886
+ normalized.usage = message.usage;
887
+ }
888
+ } else if (event.type === 'model_change') {
889
+ // Normalize to model.change format
890
+ normalized.type = 'model.change';
891
+ normalized.data = {
892
+ provider: event.provider,
893
+ model: event.modelId
894
+ };
895
+ // Generate readable message
896
+ if (event.provider && event.modelId) {
897
+ normalized.data.message = `Model changed to ${event.provider}/${event.modelId}`;
898
+ } else if (event.modelId) {
899
+ normalized.data.message = `Model changed to ${event.modelId}`;
900
+ }
901
+ } else if (event.type === 'thinking_level_change') {
902
+ // Normalize to thinking.change format
903
+ normalized.type = 'thinking.change';
904
+ normalized.data = {
905
+ level: event.thinkingLevel
906
+ };
907
+ // Generate readable message
908
+ if (event.thinkingLevel) {
909
+ normalized.data.message = `Thinking level: ${event.thinkingLevel}`;
910
+ }
911
+ } else if (event.type === 'session') {
912
+ // Session metadata
913
+ normalized.data = {
914
+ cwd: event.cwd,
915
+ version: event.version
916
+ };
917
+ // Generate readable message
918
+ const parts = [];
919
+ if (event.cwd) {
920
+ parts.push(`Working directory: ${event.cwd}`);
921
+ }
922
+ if (event.version) {
923
+ parts.push(`Session version: ${event.version}`);
924
+ }
925
+ if (parts.length > 0) {
926
+ normalized.data.message = parts.join('\n');
927
+ }
928
+ }
929
+
930
+ // Generate badge display info
931
+ this._generateBadgeInfo(normalized);
932
+ return normalized;
933
+ }
934
+
935
+ // Claude format normalization
936
+ // Handle different event types
937
+ switch (event.type) {
938
+ case 'user':
939
+ case 'assistant':
940
+ // Convert Claude user/assistant messages to standard format
941
+ if (event.message) {
942
+ // Extract text content from message.content
943
+ if (event.message.content) {
944
+ const textContent = this._extractClaudeTextContent(event.message.content);
945
+ if (textContent) {
946
+ normalized.data.message = textContent;
947
+ }
948
+ }
949
+
950
+ // Extract tool calls from message.content
951
+ if (Array.isArray(event.message.content)) {
952
+ // Gap #6 fix: Add null safety check for block objects
953
+ const toolBlocks = event.message.content.filter(block =>
954
+ block && typeof block === 'object' &&
955
+ (block.type === 'tool_use' || block.type === 'tool_result')
956
+ );
957
+
958
+ if (toolBlocks.length > 0) {
959
+ normalized.data.tools = toolBlocks.map(block => {
960
+ if (block.type === 'tool_use') {
961
+ return {
962
+ type: 'tool_use',
963
+ id: block.id,
964
+ name: block.name,
965
+ input: block.input
966
+ };
967
+ } else {
968
+ return {
969
+ type: 'tool_result',
970
+ tool_use_id: block.tool_use_id,
971
+ content: block.content
972
+ };
973
+ }
974
+ });
975
+ }
976
+ }
977
+
978
+ // Preserve original message for reference
979
+ normalized._originalMessage = event.message;
980
+ }
981
+ break;
982
+
983
+ case 'file-history-snapshot':
984
+ // Extract file list from snapshot
985
+ if (event.snapshot?.trackedFileBackups) {
986
+ const files = Object.entries(event.snapshot.trackedFileBackups);
987
+ if (files.length > 0) {
988
+ const fileList = files.map(([filename, backup]) =>
989
+ `${filename} (v${backup.version})`
990
+ ).join('\n');
991
+ normalized.data.message = `Tracked files:\n${fileList}`;
992
+ } else {
993
+ normalized.data.message = 'No files tracked';
994
+ }
995
+ }
996
+ break;
997
+
998
+ case 'progress':
999
+ // Extract progress information
1000
+ if (event.data) {
1001
+ const parts = [];
1002
+ if (event.data.hookName) parts.push(`Hook: ${event.data.hookName}`);
1003
+ if (event.data.hookEvent) parts.push(`Event: ${event.data.hookEvent}`);
1004
+ if (event.data.command) parts.push(`Command: ${event.data.command}`);
1005
+ if (parts.length > 0) {
1006
+ normalized.data.message = parts.join('\n');
1007
+ }
1008
+
1009
+ // Extract nested tool_use from progress events (subagent messages)
1010
+ // Progress events from subagents contain message.message.content with tool_use blocks
1011
+ if (event.data.message?.message?.content && Array.isArray(event.data.message.message.content)) {
1012
+ const toolBlocks = event.data.message.message.content.filter(block =>
1013
+ block && typeof block === 'object' && (block.type === 'tool_use' || block.type === 'tool_result')
1014
+ );
1015
+
1016
+ if (toolBlocks.length > 0) {
1017
+ normalized.data.tools = toolBlocks.map(block => {
1018
+ if (block.type === 'tool_use') {
1019
+ return {
1020
+ type: 'tool_use',
1021
+ id: block.id,
1022
+ name: block.name,
1023
+ input: block.input
1024
+ };
1025
+ } else {
1026
+ return {
1027
+ type: 'tool_result',
1028
+ tool_use_id: block.tool_use_id,
1029
+ content: block.content
1030
+ };
1031
+ }
1032
+ });
1033
+ }
1034
+ }
1035
+ }
1036
+ break;
1037
+
1038
+ // Add more event types as needed
1039
+ default:
1040
+ // For unknown types, try to extract any reasonable text
1041
+ if (event.data?.message && !normalized.data.message) {
1042
+ normalized.data.message = event.data.message;
1043
+ }
99
1044
  }
1045
+
1046
+ // Generate badge display info
1047
+ this._generateBadgeInfo(normalized);
1048
+ return normalized;
1049
+ }
1050
+
1051
+ /**
1052
+ * Extract text content from Claude message.content
1053
+ * @private
1054
+ */
1055
+ _extractClaudeTextContent(content) {
1056
+ if (typeof content === 'string') {
1057
+ return content;
1058
+ }
1059
+
1060
+ if (Array.isArray(content)) {
1061
+ const textParts = [];
1062
+
1063
+ for (const block of content) {
1064
+ if (block.type === 'text') {
1065
+ // Direct text block
1066
+ textParts.push(block.text);
1067
+ } else if (block.type === 'tool_result') {
1068
+ // Extract text from tool_result content
1069
+ if (typeof block.content === 'string') {
1070
+ // Format 2: direct string
1071
+ textParts.push(block.content);
1072
+ } else if (Array.isArray(block.content)) {
1073
+ // Format 1: nested array with text blocks
1074
+ const nestedText = block.content
1075
+ .filter(item => item.type === 'text')
1076
+ .map(item => item.text)
1077
+ .join('\n');
1078
+ if (nestedText) {
1079
+ textParts.push(nestedText);
1080
+ }
1081
+ }
1082
+ }
1083
+ }
1084
+
1085
+ return textParts.join('\n');
1086
+ }
1087
+
1088
+ return '';
100
1089
  }
101
1090
 
102
1091
  async getSessionWithEvents(sessionId) {
@@ -137,6 +1126,569 @@ class SessionService {
137
1126
 
138
1127
  return { session, events, metadata };
139
1128
  }
1129
+
1130
+ /**
1131
+ * Get unified timeline structure (source-agnostic)
1132
+ * Converts raw events into standardized turns/tools/subagents structure
1133
+ * @param {string} sessionId
1134
+ * @returns {Promise<Object>} Unified timeline data
1135
+ */
1136
+ async getTimeline(sessionId) {
1137
+ const session = await this.getSessionById(sessionId);
1138
+ if (!session) {
1139
+ return null;
1140
+ }
1141
+
1142
+ const events = await this.getSessionEvents(sessionId);
1143
+
1144
+ // Dispatch to source-specific builder
1145
+ if (session.source === 'copilot') {
1146
+ return this._buildCopilotTimeline(events, session);
1147
+ } else if (session.source === 'claude') {
1148
+ return this._buildClaudeTimeline(events, session);
1149
+ } else if (session.source === 'pi-mono') {
1150
+ return this._buildPiMonoTimeline(events, session);
1151
+ }
1152
+
1153
+ return { turns: [], summary: {} };
1154
+ }
1155
+
1156
+ /**
1157
+ * Build Pi-Mono timeline from normalized events
1158
+ * Pi-Mono uses user.message/assistant.message (unified with Copilot/Claude)
1159
+ * @private
1160
+ */
1161
+ _buildPiMonoTimeline(events, _session) {
1162
+ const turns = [];
1163
+ let turnId = 0;
1164
+
1165
+ // Find consecutive user -> assistant pairs
1166
+ for (let i = 0; i < events.length; i++) {
1167
+ const event = events[i];
1168
+
1169
+ // After normalization, Pi-Mono uses unified type 'user.message'
1170
+ if (event.type === 'user.message') {
1171
+ turnId++;
1172
+ const turn = {
1173
+ id: `turn-${turnId}`,
1174
+ type: 'user-request',
1175
+ message: event.data.message || '',
1176
+ startTime: event.timestamp,
1177
+ endTime: event.timestamp,
1178
+ assistantTurns: [], // Group assistant messages
1179
+ subagents: []
1180
+ };
1181
+
1182
+ // Find all assistant responses until next user message
1183
+ let j = i + 1;
1184
+ let assistantId = 0;
1185
+ while (j < events.length && events[j].type !== 'user.message') {
1186
+ const nextEvent = events[j];
1187
+
1188
+ if (nextEvent.type === 'assistant.message') {
1189
+ assistantId++;
1190
+ turn.endTime = nextEvent.timestamp;
1191
+
1192
+ // Create assistant turn with its tools
1193
+ const assistantTurn = {
1194
+ id: `assistant-${assistantId}`,
1195
+ startTime: nextEvent.timestamp,
1196
+ endTime: nextEvent.timestamp,
1197
+ tools: []
1198
+ };
1199
+
1200
+ // Extract tools from this assistant message
1201
+ if (nextEvent.data.tools && Array.isArray(nextEvent.data.tools)) {
1202
+ for (const tool of nextEvent.data.tools) {
1203
+ assistantTurn.tools.push({
1204
+ name: tool.name,
1205
+ startTime: nextEvent.timestamp,
1206
+ endTime: nextEvent.timestamp, // Pi-Mono doesn't have separate end time
1207
+ status: tool.status || 'completed',
1208
+ input: tool.input,
1209
+ result: tool.result
1210
+ });
1211
+ }
1212
+ }
1213
+
1214
+ turn.assistantTurns.push(assistantTurn);
1215
+ }
1216
+
1217
+ j++;
1218
+ }
1219
+
1220
+ turns.push(turn);
1221
+ }
1222
+ }
1223
+
1224
+ // Calculate summary statistics
1225
+ const totalTools = turns.reduce((sum, t) =>
1226
+ sum + t.assistantTurns.reduce((aSum, at) => aSum + at.tools.length, 0), 0
1227
+ );
1228
+
1229
+ const summary = {
1230
+ totalTurns: turns.length,
1231
+ totalAssistantTurns: turns.reduce((sum, t) => sum + t.assistantTurns.length, 0),
1232
+ totalTools,
1233
+ totalSubagents: 0,
1234
+ startTime: events[0]?.timestamp,
1235
+ endTime: events[events.length - 1]?.timestamp
1236
+ };
1237
+
1238
+ return { turns, summary };
1239
+ }
1240
+
1241
+ /**
1242
+ * Build Copilot timeline from normalized events
1243
+ * Copilot has explicit turn_start/complete and tool.execution_start/complete
1244
+ * @private
1245
+ */
1246
+ _buildCopilotTimeline(events, _session) {
1247
+ const turns = [];
1248
+ let currentTurn = null;
1249
+ let turnId = 0;
1250
+
1251
+ for (const event of events) {
1252
+ if (event.type === 'assistant.turn_start') {
1253
+ turnId++;
1254
+ currentTurn = {
1255
+ id: `turn-${turnId}`,
1256
+ type: 'assistant-turn',
1257
+ message: event.data.message || '',
1258
+ startTime: event.timestamp,
1259
+ endTime: null,
1260
+ tools: [],
1261
+ subagents: []
1262
+ };
1263
+ } else if (event.type === 'assistant.turn_complete' && currentTurn) {
1264
+ currentTurn.endTime = event.timestamp;
1265
+ turns.push(currentTurn);
1266
+ currentTurn = null;
1267
+ } else if (event.type === 'tool.execution_start' && currentTurn) {
1268
+ const tool = {
1269
+ name: event.data.tool || event.data.name,
1270
+ startTime: event.timestamp,
1271
+ endTime: null,
1272
+ status: 'running',
1273
+ input: event.data.arguments || event.data.input
1274
+ };
1275
+ currentTurn.tools.push(tool);
1276
+ } else if (event.type === 'tool.execution_complete' && currentTurn) {
1277
+ // Find matching tool by name and update
1278
+ const tool = currentTurn.tools.find(t =>
1279
+ t.name === (event.data.tool || event.data.name) && !t.endTime
1280
+ );
1281
+ if (tool) {
1282
+ tool.endTime = event.timestamp;
1283
+ tool.status = event.data.error || event.data.isError ? 'error' : 'completed';
1284
+ tool.result = event.data.result || event.data.output;
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ // Close any open turn
1290
+ if (currentTurn) {
1291
+ currentTurn.endTime = events[events.length - 1]?.timestamp;
1292
+ turns.push(currentTurn);
1293
+ }
1294
+
1295
+ const summary = {
1296
+ totalTurns: turns.length,
1297
+ totalTools: turns.reduce((sum, t) => sum + t.tools.length, 0),
1298
+ totalSubagents: 0,
1299
+ startTime: events[0]?.timestamp,
1300
+ endTime: events[events.length - 1]?.timestamp
1301
+ };
1302
+
1303
+ return { turns, summary };
1304
+ }
1305
+
1306
+ /**
1307
+ * Build Claude timeline from normalized events
1308
+ * Claude has tool_use/tool_result embedded in messages
1309
+ * @private
1310
+ */
1311
+ _buildClaudeTimeline(events, session) {
1312
+ // Similar to Pi-Mono but may have different patterns
1313
+ // For now, delegate to Pi-Mono logic (can refine later)
1314
+ return this._buildPiMonoTimeline(events, session);
1315
+ }
1316
+
1317
+ /**
1318
+ * Expand Pi-Mono format (assistant.message with embedded tools) to Copilot-compatible event stream
1319
+ * This allows time-analyze.ejs to work with Pi-Mono sessions without modification
1320
+ * @private
1321
+ * @param {Array} events - Normalized Pi-Mono events
1322
+ * @returns {Array} Expanded events in Copilot format
1323
+ */
1324
+ _expandPiMonoToCopilotFormat(events) {
1325
+ const expanded = [];
1326
+ let turnCounter = 0;
1327
+ let toolCallCounter = 0;
1328
+
1329
+ for (let i = 0; i < events.length; i++) {
1330
+ const event = events[i];
1331
+
1332
+ // Keep non-message events as-is
1333
+ if (event.type !== 'user.message' && event.type !== 'assistant.message') {
1334
+ expanded.push(event);
1335
+ continue;
1336
+ }
1337
+
1338
+ // Track user messages for turn grouping
1339
+ if (event.type === 'user.message') {
1340
+ turnCounter++;
1341
+ expanded.push({
1342
+ ...event,
1343
+ _turnNumber: turnCounter
1344
+ });
1345
+ continue;
1346
+ }
1347
+
1348
+ // Expand assistant.message to turn_start + tool events + turn_complete
1349
+ if (event.type === 'assistant.message') {
1350
+ const tools = event.data.tools || [];
1351
+ const turnStartTime = event.timestamp;
1352
+ const turnId = `pi-turn-${i}`;
1353
+
1354
+ // Insert assistant.turn_start
1355
+ expanded.push({
1356
+ type: 'assistant.turn_start',
1357
+ id: `${turnId}-start`,
1358
+ timestamp: turnStartTime,
1359
+ parentId: event.parentId,
1360
+ data: {
1361
+ message: event.data.message || '',
1362
+ model: event.data.model,
1363
+ tools: tools.length > 0 ? tools : undefined
1364
+ },
1365
+ _synthetic: true,
1366
+ _turnNumber: turnCounter,
1367
+ _fileIndex: event._fileIndex
1368
+ });
1369
+
1370
+ // Keep the original assistant.message event
1371
+ expanded.push({
1372
+ ...event,
1373
+ _fileIndex: event._fileIndex + 0.05
1374
+ });
1375
+
1376
+ // Insert tool.execution_start and tool.execution_complete for each tool
1377
+ tools.forEach((tool, idx) => {
1378
+ const toolCallId = `pi-tool-${toolCallCounter++}`;
1379
+ const toolStartTime = turnStartTime; // Pi-Mono doesn't have separate timestamps
1380
+ const toolEndTime = turnStartTime; // Same timestamp
1381
+
1382
+ expanded.push({
1383
+ type: 'tool.execution_start',
1384
+ id: `${toolCallId}-start`,
1385
+ timestamp: toolStartTime,
1386
+ parentId: turnId,
1387
+ data: {
1388
+ toolCallId,
1389
+ toolName: tool.name,
1390
+ tool: tool.name, // Alias for compatibility
1391
+ arguments: tool.input || {}
1392
+ },
1393
+ _synthetic: true,
1394
+ _fileIndex: event._fileIndex + 0.1 + (idx * 0.01)
1395
+ });
1396
+
1397
+ expanded.push({
1398
+ type: 'tool.execution_complete',
1399
+ id: `${toolCallId}-complete`,
1400
+ timestamp: toolEndTime,
1401
+ parentId: toolCallId,
1402
+ data: {
1403
+ toolCallId,
1404
+ toolName: tool.name,
1405
+ tool: tool.name, // Alias
1406
+ result: tool.result,
1407
+ error: tool.status === 'error' ? 'Tool execution failed' : null,
1408
+ isError: tool.status === 'error'
1409
+ },
1410
+ _synthetic: true,
1411
+ _fileIndex: event._fileIndex + 0.15 + (idx * 0.01)
1412
+ });
1413
+ });
1414
+
1415
+ // Insert assistant.turn_complete
1416
+ expanded.push({
1417
+ type: 'assistant.turn_complete',
1418
+ id: `${turnId}-complete`,
1419
+ timestamp: event.timestamp,
1420
+ parentId: turnId,
1421
+ data: {
1422
+ message: event.data.message || ''
1423
+ },
1424
+ _synthetic: true,
1425
+ _turnNumber: turnCounter,
1426
+ _fileIndex: event._fileIndex + 0.9
1427
+ });
1428
+ }
1429
+ }
1430
+
1431
+ return expanded;
1432
+ }
1433
+
1434
+ /**
1435
+ * Expand Copilot format (user/assistant) to timeline format with turn_start/complete
1436
+ * @private
1437
+ * @param {Array} events - Normalized Copilot events
1438
+ * @returns {Array} Expanded events with turn_start/complete
1439
+ */
1440
+ _expandCopilotToTimelineFormat(events) {
1441
+ const expanded = [];
1442
+ let turnCounter = 0;
1443
+
1444
+ for (let i = 0; i < events.length; i++) {
1445
+ const event = events[i];
1446
+
1447
+ // Convert user → user.message
1448
+ if (event.type === 'user') {
1449
+ turnCounter++;
1450
+ expanded.push({
1451
+ ...event,
1452
+ type: 'user.message',
1453
+ _turnNumber: turnCounter,
1454
+ data: {
1455
+ ...event.data,
1456
+ message: event.message?.content || event.data?.message || ''
1457
+ }
1458
+ });
1459
+ continue;
1460
+ }
1461
+
1462
+ // Convert assistant → turn_start + (optional tools) + turn_complete
1463
+ if (event.type === 'assistant') {
1464
+ const assistantId = event.uuid || `copilot-assistant-${i}`;
1465
+ const timestamp = event.timestamp;
1466
+
1467
+ // Extract message content
1468
+ let messageText = '';
1469
+ if (event.message?.content) {
1470
+ if (Array.isArray(event.message.content)) {
1471
+ // Claude-style content array
1472
+ messageText = event.message.content
1473
+ .filter(block => block && block.type === 'text')
1474
+ .map(block => block.text)
1475
+ .join('\n');
1476
+ } else if (typeof event.message.content === 'string') {
1477
+ // Simple string content
1478
+ messageText = event.message.content;
1479
+ }
1480
+ } else if (event.data?.message) {
1481
+ messageText = event.data.message;
1482
+ }
1483
+
1484
+ // Insert assistant.turn_start
1485
+ expanded.push({
1486
+ type: 'assistant.turn_start',
1487
+ id: `${assistantId}-start`,
1488
+ timestamp,
1489
+ parentId: event.parentId,
1490
+ uuid: event.uuid,
1491
+ data: {
1492
+ message: messageText,
1493
+ turnId: assistantId
1494
+ },
1495
+ _synthetic: true,
1496
+ _turnNumber: turnCounter,
1497
+ _fileIndex: event._fileIndex
1498
+ });
1499
+
1500
+ // Insert assistant.message (for Timeline rendering)
1501
+ expanded.push({
1502
+ type: 'assistant.message',
1503
+ id: assistantId,
1504
+ timestamp,
1505
+ parentId: event.parentId,
1506
+ uuid: event.uuid,
1507
+ data: {
1508
+ message: messageText,
1509
+ tools: event.data?.tools || []
1510
+ },
1511
+ _synthetic: true,
1512
+ _turnNumber: turnCounter,
1513
+ _fileIndex: event._fileIndex + 0.05
1514
+ });
1515
+
1516
+ // Insert tool events if they exist (already matched by _matchCopilotToolCalls)
1517
+ // Tools are attached to assistant event as data.tools array
1518
+ if (event.data?.tools && event.data.tools.length > 0) {
1519
+ event.data.tools.forEach((tool, idx) => {
1520
+ const _toolCallId = tool.toolId || `tool-${i}-${idx}`;
1521
+
1522
+ // tool.execution_start
1523
+ if (tool.start) {
1524
+ expanded.push({
1525
+ ...tool.start,
1526
+ _fileIndex: event._fileIndex + 0.1 + (idx * 0.02)
1527
+ });
1528
+ }
1529
+
1530
+ // tool.execution_complete
1531
+ if (tool.complete) {
1532
+ expanded.push({
1533
+ ...tool.complete,
1534
+ _fileIndex: event._fileIndex + 0.15 + (idx * 0.02)
1535
+ });
1536
+ }
1537
+ });
1538
+ }
1539
+
1540
+ // Insert assistant.turn_complete
1541
+ expanded.push({
1542
+ type: 'assistant.turn_complete',
1543
+ id: `${assistantId}-complete`,
1544
+ timestamp,
1545
+ parentId: assistantId,
1546
+ uuid: event.uuid,
1547
+ data: {
1548
+ message: messageText
1549
+ },
1550
+ _synthetic: true,
1551
+ _turnNumber: turnCounter,
1552
+ _fileIndex: event._fileIndex + 0.9
1553
+ });
1554
+
1555
+ continue;
1556
+ }
1557
+
1558
+ // Keep other events as-is
1559
+ expanded.push(event);
1560
+ }
1561
+
1562
+ return expanded;
1563
+ }
1564
+
1565
+ /**
1566
+ * Expand Claude format (user/assistant) to timeline format with turn_start/complete
1567
+ * @private
1568
+ * @param {Array} events - Normalized Claude events
1569
+ * @returns {Array} Expanded events with turn_start/complete
1570
+ */
1571
+ _expandClaudeToTimelineFormat(events) {
1572
+ const expanded = [];
1573
+ let turnCounter = 0;
1574
+
1575
+ for (let i = 0; i < events.length; i++) {
1576
+ const event = events[i];
1577
+
1578
+ // Convert user → user.message
1579
+ if (event.type === 'user') {
1580
+ turnCounter++;
1581
+ expanded.push({
1582
+ ...event,
1583
+ type: 'user.message',
1584
+ _turnNumber: turnCounter,
1585
+ data: {
1586
+ ...event.data,
1587
+ message: event.data?.message || ''
1588
+ }
1589
+ });
1590
+ continue;
1591
+ }
1592
+
1593
+ // Convert assistant → turn_start + (optional tools) + turn_complete
1594
+ if (event.type === 'assistant') {
1595
+ const assistantId = event.id || `claude-assistant-${i}`;
1596
+ const timestamp = event.timestamp;
1597
+
1598
+ // Extract message content (already normalized in _normalizeEvent)
1599
+ const messageText = event.data?.message || '';
1600
+
1601
+ // Insert assistant.turn_start
1602
+ expanded.push({
1603
+ type: 'assistant.turn_start',
1604
+ id: `${assistantId}-start`,
1605
+ timestamp,
1606
+ parentId: event.parentId,
1607
+ data: {
1608
+ message: messageText,
1609
+ turnId: assistantId
1610
+ },
1611
+ _synthetic: true,
1612
+ _turnNumber: turnCounter,
1613
+ _fileIndex: event._fileIndex
1614
+ });
1615
+
1616
+ // Insert assistant.message (for Timeline rendering)
1617
+ expanded.push({
1618
+ type: 'assistant.message',
1619
+ id: assistantId,
1620
+ timestamp,
1621
+ parentId: event.parentId,
1622
+ data: {
1623
+ message: messageText,
1624
+ tools: event.data?.tools || []
1625
+ },
1626
+ _synthetic: true,
1627
+ _turnNumber: turnCounter,
1628
+ _fileIndex: event._fileIndex + 0.05
1629
+ });
1630
+
1631
+ // Insert tool events if they exist (already matched by _matchClaudeToolResults)
1632
+ if (event.data?.tools && event.data.tools.length > 0) {
1633
+ event.data.tools.forEach((tool, idx) => {
1634
+ if (tool.type === 'tool_use') {
1635
+ // Tool call
1636
+ expanded.push({
1637
+ type: 'tool.execution_start',
1638
+ id: `${tool.id}-start`,
1639
+ timestamp,
1640
+ data: {
1641
+ toolCallId: tool.id,
1642
+ toolName: tool.name,
1643
+ arguments: tool.input || {}
1644
+ },
1645
+ _synthetic: true,
1646
+ _fileIndex: event._fileIndex + 0.1 + (idx * 0.02)
1647
+ });
1648
+
1649
+ // Tool result (if matched)
1650
+ if (tool.result) {
1651
+ expanded.push({
1652
+ type: 'tool.execution_complete',
1653
+ id: `${tool.id}-complete`,
1654
+ timestamp,
1655
+ data: {
1656
+ toolCallId: tool.id,
1657
+ toolName: tool.name,
1658
+ result: tool.result,
1659
+ isError: false
1660
+ },
1661
+ _synthetic: true,
1662
+ _fileIndex: event._fileIndex + 0.15 + (idx * 0.02)
1663
+ });
1664
+ }
1665
+ }
1666
+ });
1667
+ }
1668
+
1669
+ // Insert assistant.turn_complete
1670
+ expanded.push({
1671
+ type: 'assistant.turn_complete',
1672
+ id: `${assistantId}-complete`,
1673
+ timestamp,
1674
+ parentId: assistantId,
1675
+ data: {
1676
+ message: messageText
1677
+ },
1678
+ _synthetic: true,
1679
+ _turnNumber: turnCounter,
1680
+ _fileIndex: event._fileIndex + 0.9
1681
+ });
1682
+
1683
+ continue;
1684
+ }
1685
+
1686
+ // Keep other events as-is
1687
+ expanded.push(event);
1688
+ }
1689
+
1690
+ return expanded;
1691
+ }
140
1692
  }
141
1693
 
142
1694
  module.exports = SessionService;