@qiaolei81/copilot-session-viewer 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +3 -3
  2. package/bin/copilot-session-viewer +2 -2
  3. package/dist/server.min.js +99 -0
  4. package/package.json +5 -17
  5. package/public/js/homepage.min.js +9 -9
  6. package/public/js/session-detail.min.js +36 -7
  7. package/public/vendor/marked.umd.min.js +8 -0
  8. package/public/vendor/purify.min.js +3 -0
  9. package/public/vendor/vue-virtual-scroller.css +1 -0
  10. package/public/vendor/vue-virtual-scroller.min.js +2 -0
  11. package/public/vendor/vue.global.prod.min.js +19 -0
  12. package/views/session-vue.ejs +31 -6
  13. package/views/time-analyze.ejs +2 -2
  14. package/lib/parsers/README.md +0 -239
  15. package/lib/parsers/base-parser.js +0 -53
  16. package/lib/parsers/claude-parser.js +0 -181
  17. package/lib/parsers/copilot-parser.js +0 -143
  18. package/lib/parsers/index.js +0 -15
  19. package/lib/parsers/parser-factory.js +0 -77
  20. package/lib/parsers/pi-mono-parser.js +0 -119
  21. package/lib/parsers/vscode-parser.js +0 -591
  22. package/server.js +0 -29
  23. package/src/app.js +0 -129
  24. package/src/config/index.js +0 -27
  25. package/src/controllers/insightController.js +0 -136
  26. package/src/controllers/sessionController.js +0 -449
  27. package/src/controllers/tagController.js +0 -113
  28. package/src/controllers/uploadController.js +0 -648
  29. package/src/middleware/common.js +0 -67
  30. package/src/middleware/rateLimiting.js +0 -62
  31. package/src/models/Session.js +0 -146
  32. package/src/routes/api.js +0 -11
  33. package/src/routes/insights.js +0 -12
  34. package/src/routes/pages.js +0 -12
  35. package/src/routes/uploads.js +0 -14
  36. package/src/schemas/event.schema.js +0 -73
  37. package/src/services/eventNormalizer.js +0 -291
  38. package/src/services/insightService.js +0 -535
  39. package/src/services/sessionRepository.js +0 -1092
  40. package/src/services/sessionService.js +0 -1919
  41. package/src/services/tagService.js +0 -205
  42. package/src/telemetry.js +0 -152
  43. package/src/utils/fileUtils.js +0 -305
  44. package/src/utils/helpers.js +0 -45
  45. package/src/utils/processManager.js +0 -85
@@ -1,1919 +0,0 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const readline = require('readline');
4
- const { isValidSessionId, buildMetadata } = require('../utils/helpers');
5
- const SessionRepository = require('./sessionRepository');
6
- const EventNormalizer = require('./eventNormalizer');
7
-
8
- class SessionService {
9
- constructor(sessionDir) {
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();
22
- }
23
-
24
- async getAllSessions(sourceFilter = null) {
25
- const sessions = await this.sessionRepository.findAll(sourceFilter);
26
- return sessions.map(s => s.toJSON());
27
- }
28
-
29
- async getPaginatedSessions(page = 1, limit = 20, sourceFilter = null) {
30
- const allSessions = await this.sessionRepository.findAll(sourceFilter);
31
- const sessions = allSessions.map(s => s.toJSON());
32
-
33
- const startIndex = (page - 1) * limit;
34
- const endIndex = startIndex + limit;
35
- const paginatedSessions = sessions.slice(startIndex, endIndex);
36
-
37
- return {
38
- sessions: paginatedSessions,
39
- totalSessions: sessions.length,
40
- currentPage: page,
41
- totalPages: Math.ceil(sessions.length / limit),
42
- hasNextPage: endIndex < sessions.length,
43
- hasPrevPage: page > 1
44
- };
45
- }
46
-
47
- async getSessionById(sessionId) {
48
- if (!isValidSessionId(sessionId)) {
49
- return null;
50
- }
51
-
52
- const sessions = await this.getAllSessions();
53
- return sessions.find(s => s.id === sessionId);
54
- }
55
-
56
- async getSessionEvents(sessionId, options = null) {
57
- if (!isValidSessionId(sessionId)) {
58
- return options ? { events: [], total: 0 } : [];
59
- }
60
-
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
- }
66
-
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
- }
86
- } else {
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 [];
153
- }
154
- } else if (session.source === 'vscode') {
155
- // VSCode format: Read JSONL file and parse with VsCodeParser
156
- const { VsCodeParser } = require('../../lib/parsers');
157
- const vscodeParser = new VsCodeParser();
158
- try {
159
- const raw = await fs.promises.readFile(session.filePath, 'utf-8');
160
- const lines = raw.trim().split('\n').filter(line => line.trim());
161
- const parsedLines = lines.map(line => {
162
- try {
163
- return JSON.parse(line);
164
- } catch {
165
- return null;
166
- }
167
- }).filter(l => l !== null);
168
-
169
- if (parsedLines.length === 0) return [];
170
-
171
- // Use parseJsonl for new JSONL format, or parseVsCode for old JSON format
172
- let parsed;
173
- if (vscodeParser.canParse(parsedLines)) {
174
- // New JSONL format
175
- parsed = vscodeParser.parseJsonl(parsedLines);
176
- } else {
177
- // Old JSON format (single object)
178
- const sessionJson = JSON.parse(raw);
179
- parsed = vscodeParser.parseVsCode(sessionJson);
180
- }
181
-
182
- let events = this._expandVsCodeEvents(parsed.allEvents);
183
-
184
- // Bug fix #2: Use file modification time as session end time
185
- // VSCode sessions only record request.timestamp, not completion time
186
- // For agentic sessions with sub-agents, actual work takes much longer
187
- if (events.length > 0) {
188
- try {
189
- const stats = await fs.promises.stat(session.filePath);
190
- const fileMtime = new Date(stats.mtime).toISOString();
191
-
192
- // Check if the last event timestamp is much earlier than file mtime
193
- const lastEvent = events[events.length - 1];
194
- if (lastEvent && lastEvent.timestamp) {
195
- const lastEventTime = new Date(lastEvent.timestamp).getTime();
196
- const fileTime = new Date(fileMtime).getTime();
197
- const diffSeconds = (fileTime - lastEventTime) / 1000;
198
-
199
- // If difference is more than 10 seconds, use file mtime as end time
200
- if (diffSeconds > 10) {
201
- // Update the last event's timestamp to file modification time
202
- lastEvent.timestamp = fileMtime;
203
- console.log(`[VSCode] Updated session ${session.id} end time to file mtime (${diffSeconds.toFixed(0)}s difference)`);
204
- }
205
- }
206
- } catch (err) {
207
- console.error('[VSCode] Error getting file mtime:', err);
208
- }
209
- }
210
-
211
- // Bug fix #1: Generate tool.execution_start/complete events from data.tools array
212
- // This expansion must happen here before returning, since VSCode bypasses the normal pipeline
213
- events = this._expandVsCodeToTimelineFormat(events);
214
-
215
- return events;
216
- } catch (err) {
217
- console.error('Error reading VSCode session:', err);
218
- return [];
219
- }
220
- }
221
-
222
-
223
- // Initialize events array
224
- let events = [];
225
-
226
- // Load main events file if it exists
227
- if (eventsFile) {
228
- try {
229
- await fs.promises.access(eventsFile);
230
-
231
- // Stream-based reading: supports files of any size
232
- const fileStream = fs.createReadStream(eventsFile, { encoding: 'utf-8' });
233
- const rl = readline.createInterface({
234
- input: fileStream,
235
- crlfDelay: Infinity // Treat \r\n as single line break
236
- });
237
-
238
- let lineIndex = 0;
239
- const parsedEvents = [];
240
-
241
- for await (const line of rl) {
242
- const trimmedLine = line.trim();
243
- if (trimmedLine) {
244
- try {
245
- const event = JSON.parse(trimmedLine);
246
- event._fileIndex = lineIndex;
247
- parsedEvents.push(event);
248
- } catch (err) {
249
- console.error(`Error parsing line ${lineIndex + 1}:`, err.message);
250
- }
251
- }
252
- lineIndex++;
253
- }
254
-
255
- events = parsedEvents;
256
-
257
- // Sort by timestamp with stable tiebreaker on original file order
258
- events.sort((a, b) => {
259
- const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
260
- const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
261
- if (timeA !== timeB) return timeA - timeB;
262
- return a._fileIndex - b._fileIndex;
263
- });
264
-
265
- // Normalize events to unified format (convert Claude format to standard)
266
- const normalizedEvents = events.map(event => this._normalizeEvent(event, session.source));
267
- events = normalizedEvents;
268
-
269
- // Match tool calls across events (source-specific)
270
- if (session.source === 'copilot') {
271
- this._matchCopilotToolCalls(events);
272
- } else if (session.source === 'claude') {
273
- this._matchClaudeToolResults(events);
274
- }
275
- } catch (err) {
276
- console.error('Error reading main events file:', err);
277
- // Continue to load subagents even if main file fails
278
- }
279
- }
280
-
281
- // Load and merge sub-agent events (for both Copilot and Claude)
282
- // For Claude sessions without main events.jsonl, this will load subagents from correct path
283
- await this._mergeSubAgentEvents(events, eventsFile, sessionId, session.source);
284
-
285
- // Re-run tool matching after merging subagents (subagent events need matching too)
286
- if (session.source === 'copilot') {
287
- this._matchCopilotToolCalls(events);
288
- events = this._expandCopilotToTimelineFormat(events);
289
- } else if (session.source === 'claude') {
290
- this._matchClaudeToolResults(events);
291
- events = this._expandClaudeToTimelineFormat(events);
292
- } else if (session.source === 'pi-mono') {
293
- // Pi-Mono: Keep original event structure, no transformation
294
- // Events are already normalized with type="message" + role in data
295
- // But we need to merge toolResult events into their parent assistant messages
296
- this._mergePiMonoToolResults(events);
297
- }
298
- // Note: VSCode expansion is handled in the VSCode block above (before early return)
299
-
300
- // Clean up events for timeline rendering
301
- events = events.filter(e => {
302
- // Keep events with valid timestamps
303
- const ts = e.timestamp || e.snapshot?.timestamp;
304
- if (!ts) {
305
- console.warn('[SessionService] Filtered event without timestamp:', e.type, e.id || e._fileIndex);
306
- return false;
307
- }
308
- return true;
309
- });
310
-
311
- // Fix fileIndex for subagent events (999999 is too large and breaks sorting)
312
- events.forEach(e => {
313
- if (e._fileIndex === 999999 && e.timestamp) {
314
- // Use timestamp for sorting instead
315
- delete e._fileIndex;
316
- }
317
- });
318
-
319
- // Apply unified tool schema normalization (adds startTime, endTime, status, etc.)
320
- events = this.eventNormalizer.normalizeEvents(events, session.source);
321
-
322
- // Apply pagination if requested
323
- if (options && typeof options.limit === 'number' && typeof options.offset === 'number') {
324
- const total = events.length;
325
- const paginatedEvents = events.slice(options.offset, options.offset + options.limit);
326
- return {
327
- events: paginatedEvents,
328
- total
329
- };
330
- }
331
-
332
- // Backward compatibility: return array when no pagination options
333
- return events;
334
- }
335
-
336
- /**
337
- * Load and merge sub-agent events into main event stream
338
- * @private
339
- * @param {Array} events - Main events array
340
- * @param {string|null} mainEventsFile - Path to main events file (null if doesn't exist)
341
- * @param {string} sessionId - Session ID
342
- * @param {string} source - Session source ('copilot' or 'claude')
343
- */
344
- async _mergeSubAgentEvents(events, mainEventsFile, sessionId, source) {
345
- let subagentsDir;
346
-
347
- if (source === 'claude') {
348
- // For Claude sessions, look in .claude/projects/*/sessionId/subagents
349
- const claudeSource = this.sessionRepository.sources.find(s => s.type === 'claude');
350
- if (!claudeSource) return;
351
-
352
- try {
353
- const projects = await fs.promises.readdir(claudeSource.dir);
354
- for (const project of projects) {
355
- const candidateDir = path.join(claudeSource.dir, project, sessionId, 'subagents');
356
- try {
357
- const stats = await fs.promises.stat(candidateDir);
358
- if (stats.isDirectory()) {
359
- subagentsDir = candidateDir;
360
- break;
361
- }
362
- } catch {
363
- // Not in this project, continue
364
- }
365
- }
366
- } catch (err) {
367
- console.error('Error searching Claude subagents:', err);
368
- return;
369
- }
370
- } else if (source === 'copilot' && mainEventsFile) {
371
- // For Copilot sessions, detect from main events file path
372
- const eventsDir = path.dirname(mainEventsFile);
373
- const eventsBasename = path.basename(mainEventsFile);
374
-
375
- if (eventsBasename === 'events.jsonl') {
376
- // .copilot/session-state/<sessionId>/events.jsonl → check <sessionId>/subagents
377
- subagentsDir = path.join(eventsDir, 'subagents');
378
- } else {
379
- // <sessionId>.jsonl alongside session directory → check <parent>/<sessionId>/subagents
380
- subagentsDir = path.join(eventsDir, sessionId, 'subagents');
381
- }
382
- }
383
-
384
- if (!subagentsDir) {
385
- return;
386
- }
387
-
388
- try {
389
- const stats = await fs.promises.stat(subagentsDir);
390
- if (!stats.isDirectory()) {
391
- return;
392
- }
393
- } catch (err) {
394
- // No subagents directory
395
- return;
396
- }
397
-
398
- try {
399
- const files = await fs.promises.readdir(subagentsDir);
400
- const subagentFiles = files.filter(f => f.startsWith('agent-') && f.endsWith('.jsonl'));
401
-
402
- if (subagentFiles.length === 0) return;
403
-
404
- // Process each sub-agent
405
- for (const file of subagentFiles) {
406
- const subagentId = file.replace('.jsonl', '');
407
- const subagentPath = path.join(subagentsDir, file);
408
-
409
- try {
410
- // Stream-based reading for subagent files
411
- const fileStream = fs.createReadStream(subagentPath, { encoding: 'utf-8' });
412
- const rl = readline.createInterface({
413
- input: fileStream,
414
- crlfDelay: Infinity
415
- });
416
-
417
- const lines = [];
418
- for await (const line of rl) {
419
- const trimmedLine = line.trim();
420
- if (trimmedLine) {
421
- lines.push(trimmedLine);
422
- }
423
- }
424
-
425
- if (lines.length === 0) continue;
426
-
427
- // Parse first event to get metadata (slug, agentId, first message)
428
- let agentName = subagentId.replace('agent-', '');
429
- let agentDisplayName = agentName.toUpperCase();
430
- let agentDescription = `Sub-agent ${subagentId}`;
431
-
432
- try {
433
- const firstEvent = JSON.parse(lines[0]);
434
- // Use agentId (unique per sub-agent) instead of slug (same for all)
435
- if (firstEvent.agentId) {
436
- agentName = firstEvent.agentId;
437
- agentDisplayName = `agent-${firstEvent.agentId}`;
438
- }
439
- if (firstEvent.message?.content) {
440
- // Use first message as description (truncate if too long)
441
- const content = typeof firstEvent.message.content === 'string'
442
- ? firstEvent.message.content
443
- : JSON.stringify(firstEvent.message.content);
444
- agentDescription = content.length > 100 ? content.slice(0, 100) + '...' : content;
445
- }
446
- } catch (err) {
447
- // Fall back to file-based name
448
- }
449
-
450
- const subagentEvents = lines.map((line, index) => {
451
- try {
452
- const event = JSON.parse(line);
453
- event._fileIndex = 1000000 + index; // Offset to avoid collision
454
-
455
- // Mark as sub-agent event
456
- event._subagent = {
457
- id: subagentId,
458
- name: agentName
459
- };
460
-
461
- return event;
462
- } catch (err) {
463
- console.error(`Error parsing sub-agent ${subagentId} line ${index + 1}:`, err.message);
464
- return null;
465
- }
466
- }).filter(e => e !== null);
467
-
468
- if (subagentEvents.length === 0) continue;
469
-
470
- // Normalize sub-agent events (use same source as parent session)
471
- const normalizedSubEvents = subagentEvents.map(event => this._normalizeEvent(event, source));
472
-
473
- // Get first and last event timestamps
474
- const firstEvent = normalizedSubEvents[0];
475
- const lastEvent = normalizedSubEvents[normalizedSubEvents.length - 1];
476
-
477
- const startTime = firstEvent.timestamp || new Date().toISOString();
478
- const endTime = lastEvent.timestamp || new Date().toISOString();
479
-
480
- // Generate subagent.started event
481
- const startEvent = {
482
- type: 'subagent.started',
483
- id: `${subagentId}-start`,
484
- timestamp: startTime,
485
- _fileIndex: firstEvent._fileIndex - 1,
486
- _subagent: { id: subagentId, name: agentName },
487
- data: {
488
- toolCallId: subagentId,
489
- agentName: agentName,
490
- agentDisplayName: agentDisplayName,
491
- agentDescription: agentDescription
492
- }
493
- };
494
-
495
- // Generate subagent.completed event
496
- const endEvent = {
497
- type: 'subagent.completed',
498
- id: `${subagentId}-end`,
499
- timestamp: endTime,
500
- _fileIndex: lastEvent._fileIndex + 1,
501
- _subagent: { id: subagentId, name: agentName },
502
- data: {
503
- toolCallId: subagentId,
504
- result: `Sub-agent ${agentDisplayName} completed`
505
- }
506
- };
507
-
508
- // Add to main events array
509
- events.push(startEvent, ...normalizedSubEvents, endEvent);
510
-
511
- } catch (err) {
512
- console.error(`Error reading sub-agent ${subagentId}:`, err);
513
- }
514
- }
515
-
516
- // Re-sort all events by timestamp
517
- events.sort((a, b) => {
518
- const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
519
- const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
520
- if (timeA !== timeB) return timeA - timeB;
521
- return a._fileIndex - b._fileIndex;
522
- });
523
-
524
- } catch (err) {
525
- console.error('Error processing sub-agents:', err);
526
- }
527
- }
528
-
529
- /**
530
- * Match tool_result events with tool_use for Claude format
531
- * @private
532
- */
533
- _matchClaudeToolResults(events) {
534
- // Build map of tool_result by tool_use_id
535
- const toolResultMap = new Map();
536
-
537
- events.forEach(event => {
538
- if (event.data?.tools) {
539
- event.data.tools.forEach(tool => {
540
- if (tool.type === 'tool_result') {
541
- // Bug fix #1: Validate tool_use_id exists
542
- if (tool.tool_use_id) {
543
- toolResultMap.set(tool.tool_use_id, tool);
544
- } else {
545
- console.warn('[sessionService] tool_result missing tool_use_id:', tool);
546
- }
547
- }
548
- });
549
- }
550
- });
551
-
552
- // Mark user events that are entirely tool_result responses (will be filtered out in normalizer)
553
- events.forEach(event => {
554
- if (event.type === 'user' && Array.isArray(event.message?.content)) {
555
- const allToolResults = event.message.content.length > 0 &&
556
- event.message.content.every(block => block?.type === 'tool_result');
557
- if (allToolResults) {
558
- event._isToolResultWrapper = true;
559
- }
560
- }
561
- });
562
-
563
- // Match tool_use with tool_result
564
- events.forEach(event => {
565
- if (event.data?.tools) {
566
- event.data.tools = event.data.tools.map(tool => {
567
- if (tool.type === 'tool_use') {
568
- const result = toolResultMap.get(tool.id);
569
- if (result) {
570
- return {
571
- ...tool,
572
- result: result.content,
573
- _matched: true
574
- };
575
- }
576
- // Bug fix #4: Add _matched: false for unmatched Claude tools (consistency with Copilot)
577
- return {
578
- ...tool,
579
- _matched: false
580
- };
581
- }
582
- return tool;
583
- });
584
-
585
- // Bug fix: Only remove tool_result from assistant messages
586
- // Mark user messages that consist entirely of tool_results as wrappers (will be filtered out)
587
- if (event.type === 'assistant' || event.type === 'assistant.message') {
588
- event.data.tools = event.data.tools.filter(tool => tool.type !== 'tool_result');
589
- } else if (event.type === 'user' || event.type === 'user.message') {
590
- const allToolResults = event.data.tools.length > 0 &&
591
- event.data.tools.every(tool => tool.type === 'tool_result');
592
- if (allToolResults) {
593
- event._isToolResultWrapper = true;
594
- }
595
- }
596
- }
597
- });
598
- }
599
-
600
- /**
601
- * Match Pi-Mono tool results with tool calls by order (parentId chain)
602
- * Pi-Mono format: toolResult messages form a parentId chain starting from assistant message
603
- * After matching, removes tool.result events from the stream (they're attached to tools)
604
- * @private
605
- */
606
- /**
607
- * Merge Pi-Mono toolResult messages into their parent assistant messages
608
- * Pi-Mono has message events with role: user/assistant/toolResult
609
- * After normalization: user.message, assistant.message, and message (toolResult only)
610
- * toolResult events are chained via parentId (first points to assistant, rest chain to previous)
611
- * @private
612
- */
613
- _mergePiMonoToolResults(events) {
614
- const toolResultIdsToRemove = new Set();
615
-
616
- // Find all assistant messages with tool calls
617
- events.forEach(assistantEvent => {
618
- if (assistantEvent.type === 'assistant.message' && // After normalization
619
- assistantEvent.data.tools &&
620
- assistantEvent.data.tools.length > 0) {
621
-
622
- const tools = assistantEvent.data.tools;
623
-
624
- // Collect toolResult events by following parentId chain
625
- const resultEvents = [];
626
- let currentId = assistantEvent.id;
627
-
628
- // Follow the chain: find events whose parentId points to current
629
- let foundMore = true;
630
- while (foundMore && resultEvents.length < tools.length) {
631
- foundMore = false;
632
- for (const event of events) {
633
- if (event.type === 'message' &&
634
- event.data.role === 'toolResult' &&
635
- event.parentId === currentId &&
636
- !resultEvents.includes(event)) {
637
- resultEvents.push(event);
638
- currentId = event.id;
639
- foundMore = true;
640
- break;
641
- }
642
- }
643
- }
644
-
645
- // Match results to tools by order
646
- resultEvents.forEach((resultEvent, index) => {
647
- if (index < tools.length) {
648
- const tool = tools[index];
649
- tool.result = resultEvent.data.result;
650
- tool.resultId = resultEvent.id;
651
- tool.status = 'completed';
652
- toolResultIdsToRemove.add(resultEvent.id);
653
- }
654
- });
655
- }
656
- });
657
-
658
- // Remove toolResult events from the stream (they're now merged into assistant messages)
659
- const originalLength = events.length;
660
- for (let i = events.length - 1; i >= 0; i--) {
661
- if (events[i].type === 'message' &&
662
- events[i].data.role === 'toolResult' &&
663
- toolResultIdsToRemove.has(events[i].id)) {
664
- events.splice(i, 1);
665
- }
666
- }
667
-
668
- if (toolResultIdsToRemove.size > 0) {
669
- console.log(`[PI-MONO] Merged ${toolResultIdsToRemove.size} toolResult events into assistant messages (${originalLength} → ${events.length} events)`);
670
- }
671
- }
672
-
673
- /**
674
- * OLD METHOD - kept for reference, not used anymore
675
- * Match Pi-Mono tool results (old format with tool.result type)
676
- * @private
677
- */
678
- _matchPiMonoToolResults_OLD(events) {
679
- const matchedResultIds = new Set(); // Track matched tool.result event IDs to remove
680
-
681
- // Find all assistant messages with tool calls
682
- events.forEach(assistantEvent => {
683
- if (assistantEvent.type === 'assistant.message' && assistantEvent.data.tools && assistantEvent.data.tools.length > 0) {
684
- const tools = assistantEvent.data.tools;
685
-
686
- // Collect toolResult events by following parentId chain
687
- const resultEvents = [];
688
- let currentId = assistantEvent.id;
689
-
690
- // Follow the chain: find events whose parentId points to current
691
- let foundMore = true;
692
- while (foundMore && resultEvents.length < tools.length) {
693
- foundMore = false;
694
- for (const event of events) {
695
- if (event.type === 'tool.result' && event.parentId === currentId && !resultEvents.includes(event)) {
696
- resultEvents.push(event);
697
- currentId = event.id;
698
- foundMore = true;
699
- break;
700
- }
701
- }
702
- }
703
-
704
- // Match results to tools by order
705
- resultEvents.forEach((resultEvent, index) => {
706
- if (index < tools.length) {
707
- const tool = tools[index];
708
- tool.status = 'completed';
709
- tool._matched = true;
710
- tool.result = resultEvent.data.result;
711
- tool.resultId = resultEvent.id;
712
- matchedResultIds.add(resultEvent.id); // Mark for removal
713
- }
714
- });
715
- }
716
- });
717
-
718
- // Remove matched tool.result events from the stream (like Claude does)
719
- // These are now attached to assistant messages, don't need separate display
720
- const originalLength = events.length;
721
- for (let i = events.length - 1; i >= 0; i--) {
722
- if (events[i].type === 'tool.result' && matchedResultIds.has(events[i].id)) {
723
- events.splice(i, 1);
724
- }
725
- }
726
-
727
- if (matchedResultIds.size > 0) {
728
- console.log(`[PI-MONO] Removed ${matchedResultIds.size} matched tool.result events (${originalLength} → ${events.length} events)`);
729
- }
730
- }
731
-
732
- /**
733
- * Match Copilot tool.execution_start/complete events and attach to assistant.message
734
- * @private
735
- */
736
- _matchCopilotToolCalls(events) {
737
- // Step 1: Build tool execution map (start + complete paired by toolCallId)
738
- const toolExecutions = new Map();
739
-
740
- events.forEach(event => {
741
- if (event.type === 'tool.execution_start') {
742
- const toolId = event.data?.toolCallId;
743
- if (toolId) {
744
- toolExecutions.set(toolId, {
745
- name: event.data.toolName,
746
- input: event.data.arguments || {},
747
- start: event
748
- });
749
- }
750
- } else if (event.type === 'tool.execution_complete') {
751
- const toolId = event.data?.toolCallId;
752
- if (toolId) {
753
- if (toolExecutions.has(toolId)) {
754
- const exec = toolExecutions.get(toolId);
755
- exec.complete = event;
756
- exec.result = event.data?.result;
757
- exec.status = event.data?.error ? 'error' : 'completed';
758
- exec.error = event.data?.error;
759
- } else {
760
- // Bug fix #3: Handle orphaned execution_complete events (no matching start)
761
- console.warn(`[sessionService] Orphaned tool.execution_complete for toolCallId=${toolId}`);
762
- toolExecutions.set(toolId, {
763
- name: event.data.toolName || 'unknown',
764
- input: {},
765
- start: null, // No start event
766
- complete: event,
767
- result: event.data?.result,
768
- status: event.data?.error ? 'error' : 'completed',
769
- error: event.data?.error
770
- });
771
- }
772
- }
773
- }
774
- });
775
-
776
- // Step 2: Match toolRequests in assistant.message with tool executions
777
- events.forEach(event => {
778
- if (event.type === 'assistant.message' && event.data?.toolRequests) {
779
- const tools = [];
780
-
781
- event.data.toolRequests.forEach(req => {
782
- const toolId = req.toolCallId;
783
- if (toolExecutions.has(toolId)) {
784
- const exec = toolExecutions.get(toolId);
785
- tools.push({
786
- type: 'tool_use',
787
- id: toolId,
788
- name: req.name || exec.name,
789
- input: req.arguments || exec.input,
790
- result: exec.result,
791
- status: exec.status || 'running',
792
- error: exec.error,
793
- _matched: !!exec.complete
794
- });
795
- } else {
796
- // Tool request but no execution found (shouldn't happen normally)
797
- tools.push({
798
- type: 'tool_use',
799
- id: toolId,
800
- name: req.name,
801
- input: req.arguments || {},
802
- status: 'running',
803
- _matched: false
804
- });
805
- }
806
- });
807
-
808
- if (tools.length > 0) {
809
- event.data.tools = tools;
810
- }
811
- }
812
- });
813
- }
814
-
815
- /**
816
- * Generate badge display information for an event
817
- * @private
818
- */
819
- _generateBadgeInfo(normalized) {
820
- const type = normalized.type;
821
- const data = normalized.data || {};
822
-
823
- // Special case: toolResult still uses type='message'
824
- if (type === 'message' && data.role === 'toolResult') {
825
- normalized.data.badgeLabel = 'TOOL RESULT';
826
- normalized.data.badgeClass = 'badge-tool';
827
- return;
828
- }
829
-
830
- // Special cases for specific event types
831
- if (type === 'session.model_change' || type === 'model.change') {
832
- normalized.data.badgeLabel = 'MODEL CHANGE';
833
- normalized.data.badgeClass = 'badge-session';
834
- return;
835
- }
836
- if (type === 'session.truncation') {
837
- normalized.data.badgeLabel = 'TRUNCATION';
838
- normalized.data.badgeClass = 'badge-truncation';
839
- return;
840
- }
841
- if (type === 'session.compaction_start' || type === 'session.compaction_complete' || type === 'compaction') {
842
- normalized.data.badgeLabel = 'COMPACTION';
843
- normalized.data.badgeClass = 'badge-compaction';
844
- return;
845
- }
846
- if (type === 'thinking.change') {
847
- normalized.data.badgeLabel = 'THINKING';
848
- normalized.data.badgeClass = 'badge-session';
849
- return;
850
- }
851
- if (type === 'system.notification') {
852
- normalized.data.badgeLabel = 'SYSTEM';
853
- normalized.data.badgeClass = 'badge-system';
854
- return;
855
- }
856
-
857
- // Extract category from type (e.g., 'user.message' → 'user')
858
- const parts = (type || '').split('.');
859
- const category = parts[0] || 'unknown';
860
-
861
- const badgeMap = {
862
- user: { label: 'USER', class: 'badge-user' },
863
- assistant: { label: 'ASSISTANT', class: 'badge-assistant' },
864
- reasoning: { label: 'REASONING', class: 'badge-reasoning' },
865
- turn: { label: 'TURN', class: 'badge-turn' },
866
- tool: { label: 'TOOL', class: 'badge-tool' },
867
- subagent: { label: 'SUBAGENT', class: 'badge-subagent' },
868
- skill: { label: 'SKILL', class: 'badge-skill' },
869
- session: { label: 'SESSION', class: 'badge-session' },
870
- error: { label: 'ERROR', class: 'badge-error' },
871
- abort: { label: 'ABORT', class: 'badge-error' }
872
- };
873
-
874
- const badge = badgeMap[category] || { label: category.toUpperCase(), class: 'badge-info' };
875
- normalized.data.badgeLabel = badge.label;
876
- normalized.data.badgeClass = badge.class;
877
- }
878
-
879
- /**
880
- * Normalize event to unified format for frontend
881
- * @private
882
- */
883
- _normalizeEvent(event, source) {
884
- const normalized = { ...event };
885
- normalized.data = normalized.data || {};
886
-
887
- if (source === 'copilot') {
888
- // Copilot format normalization
889
-
890
- // system-sourced user.message → system.notification (separate type for display)
891
- if (event.type === 'user.message' && event.data?.source === 'system') {
892
- normalized.type = 'system.notification';
893
- // Extract text from <system_notification>...</system_notification> tag if present
894
- const raw = event.data.content || event.data.message || '';
895
- const match = raw.match(/<system_notification>([\s\S]*?)<\/system_notification>/);
896
- normalized.data.message = match ? match[1].trim() : raw.trim();
897
- this._generateBadgeInfo(normalized);
898
- return normalized;
899
- }
900
- if (event.type === 'request') {
901
- normalized.type = 'user';
902
- // Extract message from payload.messages (Anthropic API format)
903
- if (event.payload?.messages && Array.isArray(event.payload.messages)) {
904
- const userMessage = event.payload.messages.find(m => m.role === 'user');
905
- if (userMessage) {
906
- normalized.message = {
907
- role: 'user',
908
- content: userMessage.content || ''
909
- };
910
- }
911
- }
912
- } else if (event.type === 'response') {
913
- normalized.type = 'assistant';
914
- // Extract message from payload.content (Anthropic API format)
915
- if (event.payload?.content && Array.isArray(event.payload.content)) {
916
- const textBlocks = event.payload.content.filter(block => block.type === 'text');
917
- if (textBlocks.length > 0) {
918
- normalized.message = {
919
- role: 'assistant',
920
- content: textBlocks.map(block => block.text).join('\n')
921
- };
922
- }
923
- }
924
- }
925
-
926
- // New format: assistant.message data normalization
927
- if (event.type === 'assistant.message') {
928
- // Convert data.content → data.message for consistency
929
- if (event.data?.content && event.data.content.trim()) {
930
- normalized.data.message = event.data.content;
931
- }
932
- // If only toolcalls, leave message empty (don't create placeholder)
933
- }
934
-
935
- // Generate badge display info
936
- this._generateBadgeInfo(normalized);
937
- return normalized;
938
- }
939
-
940
- if (source === 'pi-mono') {
941
- // Pi-Mono format normalization - TRANSFORM TO UNIFIED TYPE
942
- if (event.type === 'message') {
943
- const { message } = event;
944
-
945
- // Transform type to unified format (like Copilot/Claude)
946
- if (message.role === 'user') {
947
- normalized.type = 'user.message';
948
- } else if (message.role === 'assistant') {
949
- normalized.type = 'assistant.message';
950
- } else if (message.role === 'toolResult') {
951
- // toolResult keeps 'message' type - will be merged by _mergePiMonoToolResults
952
- normalized.type = 'message';
953
- }
954
- normalized.data.role = message.role;
955
-
956
- // Extract text content
957
- if (Array.isArray(message.content)) {
958
- const textBlocks = message.content.filter(block => block.type === 'text');
959
- if (textBlocks.length > 0) {
960
- normalized.data.message = textBlocks.map(block => block.text).join('\n');
961
- }
962
-
963
- // Extract tool calls (for assistant messages)
964
- if (message.role === 'assistant') {
965
- const toolCalls = message.content.filter(block => block.type === 'toolCall');
966
- if (toolCalls.length > 0) {
967
- normalized.data.tools = toolCalls.map(tool => ({
968
- type: 'tool_use',
969
- id: tool.id,
970
- name: tool.name,
971
- input: tool.arguments
972
- }));
973
- }
974
- }
975
-
976
- // Extract tool result (for toolResult messages)
977
- if (message.role === 'toolResult') {
978
- normalized.data.result = textBlocks.map(block => block.text).join('\n');
979
- }
980
- }
981
-
982
- // Preserve usage metadata if available
983
- if (message.usage) {
984
- normalized.usage = message.usage;
985
- }
986
- } else if (event.type === 'model_change') {
987
- // Normalize to model.change format
988
- normalized.type = 'model.change';
989
- normalized.data = {
990
- provider: event.provider,
991
- model: event.modelId
992
- };
993
- // Generate readable message
994
- if (event.provider && event.modelId) {
995
- normalized.data.message = `Model changed to ${event.provider}/${event.modelId}`;
996
- } else if (event.modelId) {
997
- normalized.data.message = `Model changed to ${event.modelId}`;
998
- }
999
- } else if (event.type === 'thinking_level_change') {
1000
- // Normalize to thinking.change format
1001
- normalized.type = 'thinking.change';
1002
- normalized.data = {
1003
- level: event.thinkingLevel
1004
- };
1005
- // Generate readable message
1006
- if (event.thinkingLevel) {
1007
- normalized.data.message = `Thinking level: ${event.thinkingLevel}`;
1008
- }
1009
- } else if (event.type === 'session') {
1010
- // Session metadata
1011
- normalized.data = {
1012
- cwd: event.cwd,
1013
- version: event.version
1014
- };
1015
- // Generate readable message
1016
- const parts = [];
1017
- if (event.cwd) {
1018
- parts.push(`Working directory: ${event.cwd}`);
1019
- }
1020
- if (event.version) {
1021
- parts.push(`Session version: ${event.version}`);
1022
- }
1023
- if (parts.length > 0) {
1024
- normalized.data.message = parts.join('\n');
1025
- }
1026
- }
1027
-
1028
- // Generate badge display info
1029
- this._generateBadgeInfo(normalized);
1030
- return normalized;
1031
- }
1032
-
1033
- // Claude format normalization
1034
- // Handle different event types
1035
- switch (event.type) {
1036
- case 'user':
1037
- case 'assistant':
1038
- // Convert Claude user/assistant messages to standard format
1039
- if (event.message) {
1040
- // Extract text content from message.content
1041
- if (event.message.content) {
1042
- const textContent = this._extractClaudeTextContent(event.message.content);
1043
- if (textContent) {
1044
- normalized.data.message = textContent;
1045
- }
1046
- }
1047
-
1048
- // Extract tool calls from message.content
1049
- if (Array.isArray(event.message.content)) {
1050
- // Gap #6 fix: Add null safety check for block objects
1051
- const toolBlocks = event.message.content.filter(block =>
1052
- block && typeof block === 'object' &&
1053
- (block.type === 'tool_use' || block.type === 'tool_result')
1054
- );
1055
-
1056
- if (toolBlocks.length > 0) {
1057
- normalized.data.tools = toolBlocks.map(block => {
1058
- if (block.type === 'tool_use') {
1059
- return {
1060
- type: 'tool_use',
1061
- id: block.id,
1062
- name: block.name,
1063
- input: block.input
1064
- };
1065
- } else {
1066
- return {
1067
- type: 'tool_result',
1068
- tool_use_id: block.tool_use_id,
1069
- content: block.content
1070
- };
1071
- }
1072
- });
1073
- }
1074
- }
1075
-
1076
- // Preserve original message for reference
1077
- normalized._originalMessage = event.message;
1078
- }
1079
- break;
1080
-
1081
- case 'file-history-snapshot':
1082
- // Extract file list from snapshot
1083
- if (event.snapshot?.trackedFileBackups) {
1084
- const files = Object.entries(event.snapshot.trackedFileBackups);
1085
- if (files.length > 0) {
1086
- const fileList = files.map(([filename, backup]) =>
1087
- `${filename} (v${backup.version})`
1088
- ).join('\n');
1089
- normalized.data.message = `Tracked files:\n${fileList}`;
1090
- } else {
1091
- normalized.data.message = 'No files tracked';
1092
- }
1093
- }
1094
- break;
1095
-
1096
- case 'progress':
1097
- // Extract progress information
1098
- if (event.data) {
1099
- const parts = [];
1100
- if (event.data.hookName) parts.push(`Hook: ${event.data.hookName}`);
1101
- if (event.data.hookEvent) parts.push(`Event: ${event.data.hookEvent}`);
1102
- if (event.data.command) parts.push(`Command: ${event.data.command}`);
1103
- if (parts.length > 0) {
1104
- normalized.data.message = parts.join('\n');
1105
- }
1106
-
1107
- // Extract nested tool_use from progress events (subagent messages)
1108
- // Progress events from subagents contain message.message.content with tool_use blocks
1109
- if (event.data.message?.message?.content && Array.isArray(event.data.message.message.content)) {
1110
- const toolBlocks = event.data.message.message.content.filter(block =>
1111
- block && typeof block === 'object' && (block.type === 'tool_use' || block.type === 'tool_result')
1112
- );
1113
-
1114
- if (toolBlocks.length > 0) {
1115
- normalized.data.tools = toolBlocks.map(block => {
1116
- if (block.type === 'tool_use') {
1117
- return {
1118
- type: 'tool_use',
1119
- id: block.id,
1120
- name: block.name,
1121
- input: block.input
1122
- };
1123
- } else {
1124
- return {
1125
- type: 'tool_result',
1126
- tool_use_id: block.tool_use_id,
1127
- content: block.content
1128
- };
1129
- }
1130
- });
1131
- }
1132
- }
1133
- }
1134
- break;
1135
-
1136
- // Add more event types as needed
1137
- default:
1138
- // For unknown types, try to extract any reasonable text
1139
- if (event.data?.message && !normalized.data.message) {
1140
- normalized.data.message = event.data.message;
1141
- }
1142
- }
1143
-
1144
- // Generate badge display info
1145
- this._generateBadgeInfo(normalized);
1146
- return normalized;
1147
- }
1148
-
1149
- /**
1150
- * Extract text content from Claude message.content
1151
- * @private
1152
- */
1153
- _extractClaudeTextContent(content) {
1154
- if (typeof content === 'string') {
1155
- return content;
1156
- }
1157
-
1158
- if (Array.isArray(content)) {
1159
- const textParts = [];
1160
-
1161
- for (const block of content) {
1162
- if (block.type === 'text') {
1163
- // Direct text block
1164
- textParts.push(block.text);
1165
- } else if (block.type === 'tool_result') {
1166
- // Extract text from tool_result content
1167
- if (typeof block.content === 'string') {
1168
- // Format 2: direct string
1169
- textParts.push(block.content);
1170
- } else if (Array.isArray(block.content)) {
1171
- // Format 1: nested array with text blocks
1172
- const nestedText = block.content
1173
- .filter(item => item.type === 'text')
1174
- .map(item => item.text)
1175
- .join('\n');
1176
- if (nestedText) {
1177
- textParts.push(nestedText);
1178
- }
1179
- }
1180
- }
1181
- }
1182
-
1183
- return textParts.join('\n');
1184
- }
1185
-
1186
- return '';
1187
- }
1188
-
1189
- async getSessionWithEvents(sessionId) {
1190
- const session = await this.getSessionById(sessionId);
1191
- if (!session) {
1192
- return null;
1193
- }
1194
-
1195
- const events = await this.getSessionEvents(sessionId);
1196
- const metadata = buildMetadata(session);
1197
-
1198
- // Extract model from events
1199
- const sessionStartEvent = events.find(e => e.type === 'session.start');
1200
- if (sessionStartEvent?.data?.selectedModel) {
1201
- metadata.model = sessionStartEvent.data.selectedModel;
1202
- }
1203
-
1204
- const modelChangeEvent = events.find(e => e.type === 'session.model_change');
1205
- if (modelChangeEvent?.data) {
1206
- metadata.model = modelChangeEvent.data.newModel || modelChangeEvent.data.model;
1207
- }
1208
-
1209
- // Derive "updated" from last event timestamp (more accurate than filesystem mtime)
1210
- if (events.length) {
1211
- const lastEvent = events[events.length - 1];
1212
- if (lastEvent?.timestamp) {
1213
- metadata.updated = lastEvent.timestamp;
1214
- }
1215
- }
1216
-
1217
- // Derive "created" from first event timestamp if available
1218
- if (events.length) {
1219
- const firstEvent = events[0];
1220
- if (firstEvent?.timestamp) {
1221
- metadata.created = firstEvent.timestamp;
1222
- }
1223
- }
1224
-
1225
- return { session, events, metadata };
1226
- }
1227
-
1228
- /**
1229
- * Get unified timeline structure (source-agnostic)
1230
- * Converts raw events into standardized turns/tools/subagents structure
1231
- * @param {string} sessionId
1232
- * @returns {Promise<Object>} Unified timeline data
1233
- */
1234
- async getTimeline(sessionId) {
1235
- const session = await this.getSessionById(sessionId);
1236
- if (!session) {
1237
- return null;
1238
- }
1239
-
1240
- const events = await this.getSessionEvents(sessionId);
1241
-
1242
- // Dispatch to source-specific builder
1243
- if (session.source === 'copilot') {
1244
- return this._buildCopilotTimeline(events, session);
1245
- } else if (session.source === 'claude') {
1246
- return this._buildClaudeTimeline(events, session);
1247
- } else if (session.source === 'pi-mono') {
1248
- return this._buildPiMonoTimeline(events, session);
1249
- }
1250
-
1251
- return { turns: [], summary: {} };
1252
- }
1253
-
1254
- /**
1255
- * Build Pi-Mono timeline from normalized events
1256
- * Pi-Mono uses user.message/assistant.message (unified with Copilot/Claude)
1257
- * @private
1258
- */
1259
- _buildPiMonoTimeline(events, _session) {
1260
- const turns = [];
1261
- let turnId = 0;
1262
-
1263
- // Find consecutive user -> assistant pairs
1264
- for (let i = 0; i < events.length; i++) {
1265
- const event = events[i];
1266
-
1267
- // After normalization, Pi-Mono uses unified type 'user.message'
1268
- if (event.type === 'user.message') {
1269
- turnId++;
1270
- const turn = {
1271
- id: `turn-${turnId}`,
1272
- type: 'user-request',
1273
- message: event.data.message || '',
1274
- startTime: event.timestamp,
1275
- endTime: event.timestamp,
1276
- assistantTurns: [], // Group assistant messages
1277
- subagents: []
1278
- };
1279
-
1280
- // Find all assistant responses until next user message
1281
- let j = i + 1;
1282
- let assistantId = 0;
1283
- while (j < events.length && events[j].type !== 'user.message') {
1284
- const nextEvent = events[j];
1285
-
1286
- if (nextEvent.type === 'assistant.message') {
1287
- assistantId++;
1288
- turn.endTime = nextEvent.timestamp;
1289
-
1290
- // Create assistant turn with its tools
1291
- const assistantTurn = {
1292
- id: `assistant-${assistantId}`,
1293
- startTime: nextEvent.timestamp,
1294
- endTime: nextEvent.timestamp,
1295
- tools: []
1296
- };
1297
-
1298
- // Extract tools from this assistant message
1299
- if (nextEvent.data.tools && Array.isArray(nextEvent.data.tools)) {
1300
- for (const tool of nextEvent.data.tools) {
1301
- assistantTurn.tools.push({
1302
- name: tool.name,
1303
- startTime: nextEvent.timestamp,
1304
- endTime: nextEvent.timestamp, // Pi-Mono doesn't have separate end time
1305
- status: tool.status || 'completed',
1306
- input: tool.input,
1307
- result: tool.result
1308
- });
1309
- }
1310
- }
1311
-
1312
- turn.assistantTurns.push(assistantTurn);
1313
- }
1314
-
1315
- j++;
1316
- }
1317
-
1318
- turns.push(turn);
1319
- }
1320
- }
1321
-
1322
- // Calculate summary statistics
1323
- const totalTools = turns.reduce((sum, t) =>
1324
- sum + t.assistantTurns.reduce((aSum, at) => aSum + at.tools.length, 0), 0
1325
- );
1326
-
1327
- const summary = {
1328
- totalTurns: turns.length,
1329
- totalAssistantTurns: turns.reduce((sum, t) => sum + t.assistantTurns.length, 0),
1330
- totalTools,
1331
- totalSubagents: 0,
1332
- startTime: events[0]?.timestamp,
1333
- endTime: events[events.length - 1]?.timestamp
1334
- };
1335
-
1336
- return { turns, summary };
1337
- }
1338
-
1339
- /**
1340
- * Build Copilot timeline from normalized events
1341
- * Copilot has explicit turn_start/complete and tool.execution_start/complete
1342
- * @private
1343
- */
1344
- _buildCopilotTimeline(events, _session) {
1345
- const turns = [];
1346
- let currentTurn = null;
1347
- let turnId = 0;
1348
-
1349
- for (const event of events) {
1350
- if (event.type === 'assistant.turn_start') {
1351
- turnId++;
1352
- currentTurn = {
1353
- id: `turn-${turnId}`,
1354
- type: 'assistant-turn',
1355
- message: event.data.message || '',
1356
- startTime: event.timestamp,
1357
- endTime: null,
1358
- tools: [],
1359
- subagents: []
1360
- };
1361
- } else if (event.type === 'assistant.turn_complete' && currentTurn) {
1362
- currentTurn.endTime = event.timestamp;
1363
- turns.push(currentTurn);
1364
- currentTurn = null;
1365
- } else if (event.type === 'tool.execution_start' && currentTurn) {
1366
- const tool = {
1367
- name: event.data.tool || event.data.name,
1368
- startTime: event.timestamp,
1369
- endTime: null,
1370
- status: 'running',
1371
- input: event.data.arguments || event.data.input
1372
- };
1373
- currentTurn.tools.push(tool);
1374
- } else if (event.type === 'tool.execution_complete' && currentTurn) {
1375
- // Find matching tool by name and update
1376
- const tool = currentTurn.tools.find(t =>
1377
- t.name === (event.data.tool || event.data.name) && !t.endTime
1378
- );
1379
- if (tool) {
1380
- tool.endTime = event.timestamp;
1381
- tool.status = event.data.error || event.data.isError ? 'error' : 'completed';
1382
- tool.result = event.data.result || event.data.output;
1383
- }
1384
- }
1385
- }
1386
-
1387
- // Close any open turn
1388
- if (currentTurn) {
1389
- currentTurn.endTime = events[events.length - 1]?.timestamp;
1390
- turns.push(currentTurn);
1391
- }
1392
-
1393
- const summary = {
1394
- totalTurns: turns.length,
1395
- totalTools: turns.reduce((sum, t) => sum + t.tools.length, 0),
1396
- totalSubagents: 0,
1397
- startTime: events[0]?.timestamp,
1398
- endTime: events[events.length - 1]?.timestamp
1399
- };
1400
-
1401
- return { turns, summary };
1402
- }
1403
-
1404
- /**
1405
- * Build Claude timeline from normalized events
1406
- * Claude has tool_use/tool_result embedded in messages
1407
- * @private
1408
- */
1409
- _buildClaudeTimeline(events, session) {
1410
- // Similar to Pi-Mono but may have different patterns
1411
- // For now, delegate to Pi-Mono logic (can refine later)
1412
- return this._buildPiMonoTimeline(events, session);
1413
- }
1414
-
1415
- /**
1416
- * Expand Pi-Mono format (assistant.message with embedded tools) to Copilot-compatible event stream
1417
- * This allows time-analyze.ejs to work with Pi-Mono sessions without modification
1418
- * @private
1419
- * @param {Array} events - Normalized Pi-Mono events
1420
- * @returns {Array} Expanded events in Copilot format
1421
- */
1422
- _expandPiMonoToCopilotFormat(events) {
1423
- const expanded = [];
1424
- let turnCounter = 0;
1425
- let toolCallCounter = 0;
1426
-
1427
- for (let i = 0; i < events.length; i++) {
1428
- const event = events[i];
1429
-
1430
- // Keep non-message events as-is
1431
- if (event.type !== 'user.message' && event.type !== 'assistant.message') {
1432
- expanded.push(event);
1433
- continue;
1434
- }
1435
-
1436
- // Track user messages for turn grouping
1437
- if (event.type === 'user.message') {
1438
- turnCounter++;
1439
- expanded.push({
1440
- ...event,
1441
- _turnNumber: turnCounter
1442
- });
1443
- continue;
1444
- }
1445
-
1446
- // Expand assistant.message to turn_start + tool events + turn_complete
1447
- if (event.type === 'assistant.message') {
1448
- const tools = event.data.tools || [];
1449
- const turnStartTime = event.timestamp;
1450
- const turnId = `pi-turn-${i}`;
1451
-
1452
- // Insert assistant.turn_start
1453
- expanded.push({
1454
- type: 'assistant.turn_start',
1455
- id: `${turnId}-start`,
1456
- timestamp: turnStartTime,
1457
- parentId: event.parentId,
1458
- data: {
1459
- message: event.data.message || '',
1460
- model: event.data.model,
1461
- tools: tools.length > 0 ? tools : undefined
1462
- },
1463
- _synthetic: true,
1464
- _turnNumber: turnCounter,
1465
- _fileIndex: event._fileIndex
1466
- });
1467
-
1468
- // Keep the original assistant.message event
1469
- expanded.push({
1470
- ...event,
1471
- _fileIndex: event._fileIndex + 0.05
1472
- });
1473
-
1474
- // Insert tool.execution_start and tool.execution_complete for each tool
1475
- tools.forEach((tool, idx) => {
1476
- const toolCallId = `pi-tool-${toolCallCounter++}`;
1477
- const toolStartTime = turnStartTime; // Pi-Mono doesn't have separate timestamps
1478
- const toolEndTime = turnStartTime; // Same timestamp
1479
-
1480
- expanded.push({
1481
- type: 'tool.execution_start',
1482
- id: `${toolCallId}-start`,
1483
- timestamp: toolStartTime,
1484
- parentId: turnId,
1485
- data: {
1486
- toolCallId,
1487
- toolName: tool.name,
1488
- tool: tool.name, // Alias for compatibility
1489
- arguments: tool.input || {}
1490
- },
1491
- _synthetic: true,
1492
- _fileIndex: event._fileIndex + 0.1 + (idx * 0.01)
1493
- });
1494
-
1495
- expanded.push({
1496
- type: 'tool.execution_complete',
1497
- id: `${toolCallId}-complete`,
1498
- timestamp: toolEndTime,
1499
- parentId: toolCallId,
1500
- data: {
1501
- toolCallId,
1502
- toolName: tool.name,
1503
- tool: tool.name, // Alias
1504
- result: tool.result,
1505
- error: tool.status === 'error' ? 'Tool execution failed' : null,
1506
- isError: tool.status === 'error'
1507
- },
1508
- _synthetic: true,
1509
- _fileIndex: event._fileIndex + 0.15 + (idx * 0.01)
1510
- });
1511
- });
1512
-
1513
- // Insert assistant.turn_complete
1514
- expanded.push({
1515
- type: 'assistant.turn_complete',
1516
- id: `${turnId}-complete`,
1517
- timestamp: event.timestamp,
1518
- parentId: turnId,
1519
- data: {
1520
- message: event.data.message || ''
1521
- },
1522
- _synthetic: true,
1523
- _turnNumber: turnCounter,
1524
- _fileIndex: event._fileIndex + 0.9
1525
- });
1526
- }
1527
- }
1528
-
1529
- return expanded;
1530
- }
1531
-
1532
- /**
1533
- * Expand Copilot format (user/assistant) to timeline format with turn_start/complete
1534
- * @private
1535
- /**
1536
- * Convert VSCode tool.invocation events into assistant.message events with data.tools,
1537
- * so they render using the same frontend tool-list component.
1538
- * Groups consecutive tool.invocation events under a single assistant.message when possible.
1539
- */
1540
- _expandVsCodeEvents(events) {
1541
- const result = [];
1542
- let pendingTools = [];
1543
- let pendingParentId = null;
1544
- let pendingTs = null;
1545
- let pendingIdx = 0;
1546
- let pendingSubAgentId = null;
1547
- let pendingSubAgentName = null;
1548
-
1549
- const flushTools = () => {
1550
- if (pendingTools.length === 0) return;
1551
- result.push({
1552
- type: 'assistant.message',
1553
- id: `vscode-tools-${pendingIdx}`,
1554
- timestamp: pendingTs,
1555
- parentId: pendingParentId,
1556
- data: {
1557
- message: '',
1558
- content: '',
1559
- tools: pendingTools,
1560
- subAgentId: pendingSubAgentId,
1561
- subAgentName: pendingSubAgentName,
1562
- },
1563
- _synthetic: true,
1564
- });
1565
- pendingTools = [];
1566
- pendingSubAgentId = null;
1567
- pendingSubAgentName = null;
1568
- };
1569
-
1570
- for (let i = 0; i < events.length; i++) {
1571
- const ev = events[i];
1572
- if (ev.type === 'tool.invocation') {
1573
- const evSubAgentId = ev.data?.subAgentId || null;
1574
- // Flush if switching to a different subagent's tool group
1575
- if (pendingTools.length > 0 && evSubAgentId !== pendingSubAgentId) {
1576
- flushTools();
1577
- }
1578
- if (pendingTools.length === 0) {
1579
- pendingParentId = ev.parentId;
1580
- pendingTs = ev.timestamp;
1581
- pendingIdx = i;
1582
- pendingSubAgentId = evSubAgentId;
1583
- pendingSubAgentName = ev.data?.subAgentName || null;
1584
- }
1585
- if (ev.data?.tool) pendingTools.push(ev.data.tool);
1586
- } else {
1587
- flushTools();
1588
- result.push(ev);
1589
- }
1590
- }
1591
- flushTools();
1592
- return result;
1593
- }
1594
-
1595
- /**
1596
- * @param {Array} events - Normalized Copilot events
1597
- * @returns {Array} Expanded events with turn_start/complete
1598
- */
1599
- _expandCopilotToTimelineFormat(events) {
1600
- const expanded = [];
1601
- let turnCounter = 0;
1602
-
1603
- for (let i = 0; i < events.length; i++) {
1604
- const event = events[i];
1605
-
1606
- // Convert user → user.message
1607
- if (event.type === 'user') {
1608
- turnCounter++;
1609
- expanded.push({
1610
- ...event,
1611
- type: 'user.message',
1612
- _turnNumber: turnCounter,
1613
- data: {
1614
- ...event.data,
1615
- message: event.message?.content || event.data?.message || ''
1616
- }
1617
- });
1618
- continue;
1619
- }
1620
-
1621
- // Convert assistant → turn_start + (optional tools) + turn_complete
1622
- if (event.type === 'assistant') {
1623
- const assistantId = event.uuid || `copilot-assistant-${i}`;
1624
- const timestamp = event.timestamp;
1625
-
1626
- // Extract message content
1627
- let messageText = '';
1628
- if (event.message?.content) {
1629
- if (Array.isArray(event.message.content)) {
1630
- // Claude-style content array
1631
- messageText = event.message.content
1632
- .filter(block => block && block.type === 'text')
1633
- .map(block => block.text)
1634
- .join('\n');
1635
- } else if (typeof event.message.content === 'string') {
1636
- // Simple string content
1637
- messageText = event.message.content;
1638
- }
1639
- } else if (event.data?.message) {
1640
- messageText = event.data.message;
1641
- }
1642
-
1643
- // Insert assistant.turn_start
1644
- expanded.push({
1645
- type: 'assistant.turn_start',
1646
- id: `${assistantId}-start`,
1647
- timestamp,
1648
- parentId: event.parentId,
1649
- uuid: event.uuid,
1650
- data: {
1651
- message: messageText,
1652
- turnId: assistantId
1653
- },
1654
- _synthetic: true,
1655
- _turnNumber: turnCounter,
1656
- _fileIndex: event._fileIndex
1657
- });
1658
-
1659
- // Insert assistant.message (for Timeline rendering)
1660
- expanded.push({
1661
- type: 'assistant.message',
1662
- id: assistantId,
1663
- timestamp,
1664
- parentId: event.parentId,
1665
- uuid: event.uuid,
1666
- data: {
1667
- message: messageText,
1668
- tools: event.data?.tools || []
1669
- },
1670
- _synthetic: true,
1671
- _turnNumber: turnCounter,
1672
- _fileIndex: event._fileIndex + 0.05
1673
- });
1674
-
1675
- // Insert tool events if they exist (already matched by _matchCopilotToolCalls)
1676
- // Tools are attached to assistant event as data.tools array
1677
- if (event.data?.tools && event.data.tools.length > 0) {
1678
- event.data.tools.forEach((tool, idx) => {
1679
- const _toolCallId = tool.toolId || `tool-${i}-${idx}`;
1680
-
1681
- // tool.execution_start
1682
- if (tool.start) {
1683
- expanded.push({
1684
- ...tool.start,
1685
- _fileIndex: event._fileIndex + 0.1 + (idx * 0.02)
1686
- });
1687
- }
1688
-
1689
- // tool.execution_complete
1690
- if (tool.complete) {
1691
- expanded.push({
1692
- ...tool.complete,
1693
- _fileIndex: event._fileIndex + 0.15 + (idx * 0.02)
1694
- });
1695
- }
1696
- });
1697
- }
1698
-
1699
- // Insert assistant.turn_complete
1700
- expanded.push({
1701
- type: 'assistant.turn_complete',
1702
- id: `${assistantId}-complete`,
1703
- timestamp,
1704
- parentId: assistantId,
1705
- uuid: event.uuid,
1706
- data: {
1707
- message: messageText
1708
- },
1709
- _synthetic: true,
1710
- _turnNumber: turnCounter,
1711
- _fileIndex: event._fileIndex + 0.9
1712
- });
1713
-
1714
- continue;
1715
- }
1716
-
1717
- // Keep other events as-is
1718
- expanded.push(event);
1719
- }
1720
-
1721
- return expanded;
1722
- }
1723
-
1724
- /**
1725
- * Expand Claude format (user/assistant) to timeline format with turn_start/complete
1726
- * @private
1727
- * @param {Array} events - Normalized Claude events
1728
- * @returns {Array} Expanded events with turn_start/complete
1729
- */
1730
- _expandClaudeToTimelineFormat(events) {
1731
- const expanded = [];
1732
- let turnCounter = 0;
1733
-
1734
- for (let i = 0; i < events.length; i++) {
1735
- const event = events[i];
1736
-
1737
- // Convert user → user.message
1738
- if (event.type === 'user') {
1739
- turnCounter++;
1740
- expanded.push({
1741
- ...event,
1742
- type: 'user.message',
1743
- _turnNumber: turnCounter,
1744
- data: {
1745
- ...event.data,
1746
- message: event.data?.message || ''
1747
- }
1748
- });
1749
- continue;
1750
- }
1751
-
1752
- // Convert assistant → turn_start + (optional tools) + turn_complete
1753
- if (event.type === 'assistant') {
1754
- const assistantId = event.id || `claude-assistant-${i}`;
1755
- const timestamp = event.timestamp;
1756
-
1757
- // Extract message content (already normalized in _normalizeEvent)
1758
- const messageText = event.data?.message || '';
1759
-
1760
- // Insert assistant.turn_start
1761
- expanded.push({
1762
- type: 'assistant.turn_start',
1763
- id: `${assistantId}-start`,
1764
- timestamp,
1765
- parentId: event.parentId,
1766
- data: {
1767
- message: messageText,
1768
- turnId: assistantId
1769
- },
1770
- _synthetic: true,
1771
- _turnNumber: turnCounter,
1772
- _fileIndex: event._fileIndex
1773
- });
1774
-
1775
- // Insert assistant.message (for Timeline rendering)
1776
- expanded.push({
1777
- type: 'assistant.message',
1778
- id: assistantId,
1779
- timestamp,
1780
- parentId: event.parentId,
1781
- data: {
1782
- message: messageText,
1783
- tools: event.data?.tools || []
1784
- },
1785
- _synthetic: true,
1786
- _turnNumber: turnCounter,
1787
- _fileIndex: event._fileIndex + 0.05
1788
- });
1789
-
1790
- // Insert tool events if they exist (already matched by _matchClaudeToolResults)
1791
- if (event.data?.tools && event.data.tools.length > 0) {
1792
- event.data.tools.forEach((tool, idx) => {
1793
- if (tool.type === 'tool_use') {
1794
- // Tool call
1795
- expanded.push({
1796
- type: 'tool.execution_start',
1797
- id: `${tool.id}-start`,
1798
- timestamp,
1799
- data: {
1800
- toolCallId: tool.id,
1801
- toolName: tool.name,
1802
- arguments: tool.input || {}
1803
- },
1804
- _synthetic: true,
1805
- _fileIndex: event._fileIndex + 0.1 + (idx * 0.02)
1806
- });
1807
-
1808
- // Tool result (if matched)
1809
- if (tool.result) {
1810
- expanded.push({
1811
- type: 'tool.execution_complete',
1812
- id: `${tool.id}-complete`,
1813
- timestamp,
1814
- data: {
1815
- toolCallId: tool.id,
1816
- toolName: tool.name,
1817
- result: tool.result,
1818
- isError: false
1819
- },
1820
- _synthetic: true,
1821
- _fileIndex: event._fileIndex + 0.15 + (idx * 0.02)
1822
- });
1823
- }
1824
- }
1825
- });
1826
- }
1827
-
1828
- // Insert assistant.turn_complete
1829
- expanded.push({
1830
- type: 'assistant.turn_complete',
1831
- id: `${assistantId}-complete`,
1832
- timestamp,
1833
- parentId: assistantId,
1834
- data: {
1835
- message: messageText
1836
- },
1837
- _synthetic: true,
1838
- _turnNumber: turnCounter,
1839
- _fileIndex: event._fileIndex + 0.9
1840
- });
1841
-
1842
- continue;
1843
- }
1844
-
1845
- // Keep other events as-is
1846
- expanded.push(event);
1847
- }
1848
-
1849
- return expanded;
1850
- }
1851
-
1852
- /**
1853
- * Expand VSCode format to timeline format with tool.execution_start/complete events
1854
- * VSCode events already have assistant.message events with data.tools arrays (from _expandVsCodeEvents)
1855
- * This method generates tool.execution_start/complete events for the time-analyze page
1856
- * @private
1857
- * @param {Array} events - VSCode events with assistant.message events containing data.tools
1858
- * @returns {Array} Expanded events with tool execution events
1859
- */
1860
- _expandVsCodeToTimelineFormat(events) {
1861
- const expanded = [];
1862
-
1863
- for (let i = 0; i < events.length; i++) {
1864
- const event = events[i];
1865
-
1866
- // Keep the original event
1867
- expanded.push(event);
1868
-
1869
- // Generate tool.execution_start and tool.execution_complete for assistant.message events with tools
1870
- if (event.type === 'assistant.message' && event.data?.tools && event.data.tools.length > 0) {
1871
- event.data.tools.forEach((tool, idx) => {
1872
- // Skip if tool doesn't have required fields
1873
- if (!tool.id || !tool.name) return;
1874
-
1875
- const toolStartTime = tool.startTime || event.timestamp;
1876
- const toolEndTime = tool.endTime || event.timestamp;
1877
-
1878
- // Generate tool.execution_start event
1879
- expanded.push({
1880
- type: 'tool.execution_start',
1881
- id: `${tool.id}-start`,
1882
- timestamp: toolStartTime,
1883
- parentId: event.id,
1884
- data: {
1885
- toolCallId: tool.id,
1886
- toolName: tool.name,
1887
- tool: tool.name, // Alias for compatibility
1888
- arguments: tool.input || {}
1889
- },
1890
- _synthetic: true,
1891
- _fileIndex: event._fileIndex ? event._fileIndex + 0.1 + (idx * 0.02) : undefined
1892
- });
1893
-
1894
- // Generate tool.execution_complete event
1895
- expanded.push({
1896
- type: 'tool.execution_complete',
1897
- id: `${tool.id}-complete`,
1898
- timestamp: toolEndTime,
1899
- parentId: tool.id,
1900
- data: {
1901
- toolCallId: tool.id,
1902
- toolName: tool.name,
1903
- tool: tool.name, // Alias
1904
- result: tool.result || null,
1905
- error: tool.error || (tool.status === 'error' ? 'Tool execution failed' : null),
1906
- isError: tool.status === 'error'
1907
- },
1908
- _synthetic: true,
1909
- _fileIndex: event._fileIndex ? event._fileIndex + 0.15 + (idx * 0.02) : undefined
1910
- });
1911
- });
1912
- }
1913
- }
1914
-
1915
- return expanded;
1916
- }
1917
- }
1918
-
1919
- module.exports = SessionService;