@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.
- package/README.md +3 -3
- package/bin/copilot-session-viewer +2 -2
- package/dist/server.min.js +99 -0
- package/package.json +5 -17
- package/public/js/homepage.min.js +9 -9
- package/public/js/session-detail.min.js +36 -7
- package/public/vendor/marked.umd.min.js +8 -0
- package/public/vendor/purify.min.js +3 -0
- package/public/vendor/vue-virtual-scroller.css +1 -0
- package/public/vendor/vue-virtual-scroller.min.js +2 -0
- package/public/vendor/vue.global.prod.min.js +19 -0
- package/views/session-vue.ejs +31 -6
- package/views/time-analyze.ejs +2 -2
- package/lib/parsers/README.md +0 -239
- package/lib/parsers/base-parser.js +0 -53
- package/lib/parsers/claude-parser.js +0 -181
- package/lib/parsers/copilot-parser.js +0 -143
- package/lib/parsers/index.js +0 -15
- package/lib/parsers/parser-factory.js +0 -77
- package/lib/parsers/pi-mono-parser.js +0 -119
- package/lib/parsers/vscode-parser.js +0 -591
- package/server.js +0 -29
- package/src/app.js +0 -129
- package/src/config/index.js +0 -27
- package/src/controllers/insightController.js +0 -136
- package/src/controllers/sessionController.js +0 -449
- package/src/controllers/tagController.js +0 -113
- package/src/controllers/uploadController.js +0 -648
- package/src/middleware/common.js +0 -67
- package/src/middleware/rateLimiting.js +0 -62
- package/src/models/Session.js +0 -146
- package/src/routes/api.js +0 -11
- package/src/routes/insights.js +0 -12
- package/src/routes/pages.js +0 -12
- package/src/routes/uploads.js +0 -14
- package/src/schemas/event.schema.js +0 -73
- package/src/services/eventNormalizer.js +0 -291
- package/src/services/insightService.js +0 -535
- package/src/services/sessionRepository.js +0 -1092
- package/src/services/sessionService.js +0 -1919
- package/src/services/tagService.js +0 -205
- package/src/telemetry.js +0 -152
- package/src/utils/fileUtils.js +0 -305
- package/src/utils/helpers.js +0 -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;
|