@qiaolei81/copilot-session-viewer 0.2.1 → 0.2.4
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/CHANGELOG.md +5 -0
- package/lib/parsers/index.js +2 -0
- package/lib/parsers/vscode-parser.js +413 -0
- package/package.json +1 -1
- package/server.js +0 -17
- package/src/app.js +4 -4
- package/src/controllers/sessionController.js +29 -40
- package/src/models/Session.js +3 -1
- package/src/services/sessionRepository.js +275 -3
- package/src/services/sessionService.js +73 -4
- package/views/index.ejs +70 -45
- package/views/session-vue.ejs +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.2] - 2026-02-27
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **ETag Cache Bug** - `session.updated`/`session.created` field name typo (should be `updatedAt`/`createdAt`) caused ETag to always be `md5("sessionId-undefined")`, resulting in permanent 304 Not Modified responses — frontend never saw new events for WIP sessions
|
|
12
|
+
|
|
8
13
|
## [0.2.1] - 2026-02-25
|
|
9
14
|
|
|
10
15
|
### Changed
|
package/lib/parsers/index.js
CHANGED
|
@@ -2,6 +2,7 @@ const BaseSessionParser = require('./base-parser');
|
|
|
2
2
|
const CopilotSessionParser = require('./copilot-parser');
|
|
3
3
|
const ClaudeSessionParser = require('./claude-parser');
|
|
4
4
|
const PiMonoParser = require('./pi-mono-parser');
|
|
5
|
+
const VsCodeParser = require('./vscode-parser');
|
|
5
6
|
const ParserFactory = require('./parser-factory');
|
|
6
7
|
|
|
7
8
|
module.exports = {
|
|
@@ -9,5 +10,6 @@ module.exports = {
|
|
|
9
10
|
CopilotSessionParser,
|
|
10
11
|
ClaudeSessionParser,
|
|
11
12
|
PiMonoParser,
|
|
13
|
+
VsCodeParser,
|
|
12
14
|
ParserFactory
|
|
13
15
|
};
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
const BaseSessionParser = require('./base-parser');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* VSCode Copilot Chat Session Parser
|
|
5
|
+
*
|
|
6
|
+
* Parses chat sessions stored by VS Code's GitHub Copilot Chat extension.
|
|
7
|
+
* Location: ~/Library/Application Support/Code/User/workspaceStorage/<hash>/chatSessions/<uuid>.json
|
|
8
|
+
*
|
|
9
|
+
* Format: A single JSON object with a `requests` array. Each request has
|
|
10
|
+
* a `message` (user input) and a `response` array of typed content items.
|
|
11
|
+
*/
|
|
12
|
+
class VsCodeParser extends BaseSessionParser {
|
|
13
|
+
/**
|
|
14
|
+
* VSCode sessions are passed as a plain JS object (already parsed JSON),
|
|
15
|
+
* not as an array of events. We detect them by the presence of `requests`.
|
|
16
|
+
* The ParserFactory passes raw events arrays for JSONL sources, so we
|
|
17
|
+
* keep canParse() returning false — VSCode sessions are loaded differently.
|
|
18
|
+
*/
|
|
19
|
+
canParse(_events) {
|
|
20
|
+
// VSCode sessions are JSON objects, not event arrays.
|
|
21
|
+
// SessionRepository calls parseVsCode() directly; canParse is unused here.
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a VSCode chat session JSON object into the normalised format.
|
|
27
|
+
* @param {Object} sessionJson - Parsed JSON from chatSessions/<uuid>.json
|
|
28
|
+
* @returns {Object} Normalised session data
|
|
29
|
+
*/
|
|
30
|
+
parseVsCode(sessionJson) {
|
|
31
|
+
const metadata = this._getMetadata(sessionJson);
|
|
32
|
+
const events = this._toEvents(sessionJson);
|
|
33
|
+
return {
|
|
34
|
+
metadata,
|
|
35
|
+
turns: this._extractTurns(events),
|
|
36
|
+
toolCalls: this._extractToolCalls(events),
|
|
37
|
+
allEvents: events,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---- required abstract methods (not used for VsCode path) ----
|
|
42
|
+
parse(_events) { return null; }
|
|
43
|
+
getMetadata(_events) { return null; }
|
|
44
|
+
extractTurns(_events) { return []; }
|
|
45
|
+
extractToolCalls(_events) { return []; }
|
|
46
|
+
|
|
47
|
+
// ---- private helpers ----
|
|
48
|
+
|
|
49
|
+
_getMetadata(sessionJson) {
|
|
50
|
+
const requests = sessionJson.requests || [];
|
|
51
|
+
const firstReq = requests[0] || {};
|
|
52
|
+
const lastReq = requests[requests.length - 1] || {};
|
|
53
|
+
|
|
54
|
+
// Derive workspace label from agent name or fallback
|
|
55
|
+
const agentName = firstReq.agent?.name || firstReq.agent?.id || 'vscode-copilot';
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
sessionId: sessionJson.sessionId,
|
|
59
|
+
startTime: sessionJson.creationDate
|
|
60
|
+
? new Date(sessionJson.creationDate).toISOString()
|
|
61
|
+
: (firstReq.timestamp ? new Date(firstReq.timestamp).toISOString() : null),
|
|
62
|
+
endTime: sessionJson.lastMessageDate
|
|
63
|
+
? new Date(sessionJson.lastMessageDate).toISOString()
|
|
64
|
+
: (lastReq.timestamp ? new Date(lastReq.timestamp).toISOString() : null),
|
|
65
|
+
model: firstReq.modelId || null,
|
|
66
|
+
producer: 'vscode-copilot-chat',
|
|
67
|
+
version: firstReq.agent?.extensionVersion || null,
|
|
68
|
+
agentName,
|
|
69
|
+
requestCount: requests.length,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Convert VSCode session JSON into a flat event array that matches the
|
|
75
|
+
* normalised event schema used by the rest of the viewer.
|
|
76
|
+
*/
|
|
77
|
+
_toEvents(sessionJson) {
|
|
78
|
+
const events = [];
|
|
79
|
+
const requests = sessionJson.requests || [];
|
|
80
|
+
|
|
81
|
+
// session.start synthetic event
|
|
82
|
+
events.push({
|
|
83
|
+
type: 'session.start',
|
|
84
|
+
id: `${sessionJson.sessionId}-start`,
|
|
85
|
+
timestamp: sessionJson.creationDate
|
|
86
|
+
? new Date(sessionJson.creationDate).toISOString()
|
|
87
|
+
: null,
|
|
88
|
+
data: {
|
|
89
|
+
sessionId: sessionJson.sessionId,
|
|
90
|
+
producer: 'vscode-copilot-chat',
|
|
91
|
+
selectedModel: requests[0]?.modelId || null,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
for (const req of requests) {
|
|
96
|
+
const ts = req.timestamp ? new Date(req.timestamp).toISOString() : null;
|
|
97
|
+
// Use completedAt for assistant events — more accurate than request start time
|
|
98
|
+
const completedAt = req.modelState?.completedAt
|
|
99
|
+
? new Date(req.modelState.completedAt).toISOString()
|
|
100
|
+
: ts;
|
|
101
|
+
const reqStartMs = ts ? new Date(ts).getTime() : null;
|
|
102
|
+
const reqEndMs = completedAt ? new Date(completedAt).getTime() : reqStartMs;
|
|
103
|
+
const reqDurationMs = (reqStartMs && reqEndMs) ? (reqEndMs - reqStartMs) : 0;
|
|
104
|
+
|
|
105
|
+
// Build subAgentInvocationId → agent name map from this request's response items
|
|
106
|
+
const responseItems = req.response || [];
|
|
107
|
+
const subAgentNames = this._buildSubAgentNameMap(responseItems);
|
|
108
|
+
|
|
109
|
+
// Count items per subagent (in first-appearance order) for proportional time estimation
|
|
110
|
+
const subAgentItemCounts = new Map(); // sid → count
|
|
111
|
+
const subAgentOrder = []; // ordered unique sids
|
|
112
|
+
for (const item of responseItems) {
|
|
113
|
+
if (!item || typeof item !== 'object') continue;
|
|
114
|
+
const sid = item.subAgentInvocationId;
|
|
115
|
+
if (!sid) continue;
|
|
116
|
+
if (!subAgentItemCounts.has(sid)) { subAgentItemCounts.set(sid, 0); subAgentOrder.push(sid); }
|
|
117
|
+
subAgentItemCounts.set(sid, subAgentItemCounts.get(sid) + 1);
|
|
118
|
+
}
|
|
119
|
+
const totalSubAgentItems = [...subAgentItemCounts.values()].reduce((a, b) => a + b, 0);
|
|
120
|
+
|
|
121
|
+
// Compute estimated start/end timestamps per subagent (proportional to item count)
|
|
122
|
+
const subAgentTimestamps = new Map(); // sid → { startTime, endTime }
|
|
123
|
+
if (reqStartMs && totalSubAgentItems > 0) {
|
|
124
|
+
let cursor = reqStartMs;
|
|
125
|
+
for (const sid of subAgentOrder) {
|
|
126
|
+
const fraction = subAgentItemCounts.get(sid) / totalSubAgentItems;
|
|
127
|
+
const duration = Math.round(reqDurationMs * fraction);
|
|
128
|
+
subAgentTimestamps.set(sid, {
|
|
129
|
+
startTime: new Date(cursor).toISOString(),
|
|
130
|
+
endTime: new Date(cursor + duration).toISOString(),
|
|
131
|
+
});
|
|
132
|
+
cursor += duration;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const seenSubAgents = new Set();
|
|
137
|
+
|
|
138
|
+
// user.message
|
|
139
|
+
const userText = this._extractUserText(req.message);
|
|
140
|
+
events.push({
|
|
141
|
+
type: 'user.message',
|
|
142
|
+
id: req.requestId,
|
|
143
|
+
timestamp: ts,
|
|
144
|
+
data: {
|
|
145
|
+
message: userText,
|
|
146
|
+
content: userText,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// assistant.turn_start
|
|
151
|
+
events.push({
|
|
152
|
+
type: 'assistant.turn_start',
|
|
153
|
+
id: `${req.requestId}-turn`,
|
|
154
|
+
timestamp: ts,
|
|
155
|
+
parentId: req.requestId,
|
|
156
|
+
data: {},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
let assistantText = '';
|
|
160
|
+
let itemIndex = 0;
|
|
161
|
+
let currentSubAgentId = null;
|
|
162
|
+
|
|
163
|
+
const flushText = () => {
|
|
164
|
+
const trimmed = assistantText.trim().replace(/^`{3,}$/gm, '').trim();
|
|
165
|
+
assistantText = '';
|
|
166
|
+
if (!trimmed) return;
|
|
167
|
+
const sid = currentSubAgentId;
|
|
168
|
+
const _agentName = sid ? (subAgentNames[sid] || sid.slice(0, 8)) : null;
|
|
169
|
+
events.push({
|
|
170
|
+
type: 'assistant.message',
|
|
171
|
+
id: `${req.requestId}-text-${itemIndex}`,
|
|
172
|
+
timestamp: completedAt,
|
|
173
|
+
parentId: req.requestId,
|
|
174
|
+
data: {
|
|
175
|
+
message: trimmed,
|
|
176
|
+
content: trimmed,
|
|
177
|
+
tools: [],
|
|
178
|
+
subAgentId: null,
|
|
179
|
+
subAgentName: null,
|
|
180
|
+
parentToolCallId: null,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const emitSubAgentStart = (sid, _itemIdx) => {
|
|
186
|
+
if (!sid || seenSubAgents.has(sid)) return;
|
|
187
|
+
seenSubAgents.add(sid);
|
|
188
|
+
const agentName = subAgentNames[sid] || `subagent-${sid.slice(0, 8)}`;
|
|
189
|
+
const times = subAgentTimestamps.get(sid);
|
|
190
|
+
const startTs = times?.startTime || completedAt;
|
|
191
|
+
events.push({
|
|
192
|
+
type: 'subagent.started',
|
|
193
|
+
id: `${req.requestId}-subagent-${sid}`,
|
|
194
|
+
timestamp: startTs,
|
|
195
|
+
parentId: req.requestId,
|
|
196
|
+
data: {
|
|
197
|
+
subAgentId: sid,
|
|
198
|
+
subAgentName: agentName,
|
|
199
|
+
agentName: agentName,
|
|
200
|
+
agentDisplayName: agentName,
|
|
201
|
+
toolCallId: sid,
|
|
202
|
+
badgeLabel: agentName,
|
|
203
|
+
badgeClass: 'badge-subagent',
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
for (const item of responseItems) {
|
|
209
|
+
itemIndex++;
|
|
210
|
+
switch (item.kind) {
|
|
211
|
+
case 'thinking': {
|
|
212
|
+
const text = item.content?.value || item.content || '';
|
|
213
|
+
if (text) assistantText += text + '\n';
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case 'markdownContent': {
|
|
218
|
+
const text = item.content?.value || item.content || '';
|
|
219
|
+
if (text) assistantText += text + '\n';
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
case undefined:
|
|
224
|
+
case null: {
|
|
225
|
+
// Plain markdown text item (no kind field, has 'value')
|
|
226
|
+
const text = item.value || '';
|
|
227
|
+
if (text) assistantText += text + '\n';
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
case 'toolInvocationSerialized': {
|
|
232
|
+
flushText();
|
|
233
|
+
// Emit subagent.start on first appearance of this subagent
|
|
234
|
+
const sid = item.subAgentInvocationId;
|
|
235
|
+
emitSubAgentStart(sid, itemIndex);
|
|
236
|
+
if (sid) currentSubAgentId = sid;
|
|
237
|
+
const tool = this._normalizeTool(item);
|
|
238
|
+
if (tool) {
|
|
239
|
+
const agentName = sid ? (subAgentNames[sid] || sid.slice(0, 8)) : null;
|
|
240
|
+
events.push({
|
|
241
|
+
type: 'tool.invocation',
|
|
242
|
+
id: tool.id || `${req.requestId}-tool-${itemIndex}`,
|
|
243
|
+
timestamp: completedAt,
|
|
244
|
+
parentId: req.requestId,
|
|
245
|
+
data: {
|
|
246
|
+
tool,
|
|
247
|
+
subAgentId: sid || null,
|
|
248
|
+
subAgentName: agentName,
|
|
249
|
+
parentToolCallId: sid || null,
|
|
250
|
+
badgeLabel: tool.name,
|
|
251
|
+
badgeClass: tool.status === 'error' ? 'badge-error' : 'badge-tool',
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case 'textEditGroup': {
|
|
259
|
+
flushText();
|
|
260
|
+
const edits = item.edits || item.uri ? [item] : [];
|
|
261
|
+
events.push({
|
|
262
|
+
type: 'tool.invocation',
|
|
263
|
+
id: `${req.requestId}-edit-${itemIndex}`,
|
|
264
|
+
timestamp: completedAt,
|
|
265
|
+
parentId: req.requestId,
|
|
266
|
+
data: {
|
|
267
|
+
tool: {
|
|
268
|
+
type: 'tool_use',
|
|
269
|
+
id: `${req.requestId}-edit-${itemIndex}`,
|
|
270
|
+
name: 'textEdit',
|
|
271
|
+
startTime: ts,
|
|
272
|
+
endTime: ts,
|
|
273
|
+
status: 'completed',
|
|
274
|
+
input: { uri: item.uri, edits },
|
|
275
|
+
result: 'file edit',
|
|
276
|
+
error: null,
|
|
277
|
+
},
|
|
278
|
+
badgeLabel: 'textEdit',
|
|
279
|
+
badgeClass: 'badge-tool',
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
case 'prepareToolInvocation':
|
|
286
|
+
case 'inlineReference':
|
|
287
|
+
case 'undoStop':
|
|
288
|
+
case 'codeblockUri':
|
|
289
|
+
case 'mcpServersStarting':
|
|
290
|
+
// Skip non-visible items
|
|
291
|
+
break;
|
|
292
|
+
|
|
293
|
+
default:
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
flushText(); // flush any trailing text
|
|
299
|
+
|
|
300
|
+
// Emit subagent.completed events (proportional estimated endTime) after all items
|
|
301
|
+
for (const sid of seenSubAgents) {
|
|
302
|
+
const agentName = subAgentNames[sid] || `subagent-${sid.slice(0, 8)}`;
|
|
303
|
+
const times = subAgentTimestamps.get(sid);
|
|
304
|
+
const endTs = times?.endTime || completedAt;
|
|
305
|
+
events.push({
|
|
306
|
+
type: 'subagent.completed',
|
|
307
|
+
id: `${req.requestId}-subagent-${sid}-end`,
|
|
308
|
+
timestamp: endTs,
|
|
309
|
+
parentId: req.requestId,
|
|
310
|
+
data: {
|
|
311
|
+
toolCallId: sid,
|
|
312
|
+
agentDisplayName: agentName,
|
|
313
|
+
agentName: agentName,
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return events;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Extract agent name from the first toolInvocationSerialized item for a given subAgentInvocationId.
|
|
324
|
+
* Looks for .agent.md filename in invocationMessage or resultDetails URIs.
|
|
325
|
+
*/
|
|
326
|
+
_buildSubAgentNameMap(items) {
|
|
327
|
+
const nameMap = {};
|
|
328
|
+
for (const item of items) {
|
|
329
|
+
if (!item || typeof item !== 'object') continue;
|
|
330
|
+
const sid = item.subAgentInvocationId;
|
|
331
|
+
if (!sid || nameMap[sid]) continue;
|
|
332
|
+
if (item.kind !== 'toolInvocationSerialized') continue;
|
|
333
|
+
|
|
334
|
+
// Try invocationMessage text for agent file path
|
|
335
|
+
const msgObj = item.invocationMessage;
|
|
336
|
+
const msg = (msgObj && typeof msgObj === 'object') ? (msgObj.value || '') : '';
|
|
337
|
+
let m = msg.match(/agents\/([^/\]]+?)\.agent\.md/);
|
|
338
|
+
if (m) { nameMap[sid] = m[1]; continue; }
|
|
339
|
+
|
|
340
|
+
// Try resultDetails
|
|
341
|
+
for (const rd of (item.resultDetails || [])) {
|
|
342
|
+
if (typeof rd !== 'object') continue;
|
|
343
|
+
const fp = rd.fsPath || rd.path || '';
|
|
344
|
+
m = fp.match(/agents\/([^/]+?)\.agent\.md/);
|
|
345
|
+
if (m) { nameMap[sid] = m[1]; break; }
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return nameMap;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
_extractUserText(message) {
|
|
352
|
+
if (!message) return '';
|
|
353
|
+
if (typeof message.text === 'string') return message.text;
|
|
354
|
+
// parts[] may contain text fragments
|
|
355
|
+
if (Array.isArray(message.parts)) {
|
|
356
|
+
return message.parts
|
|
357
|
+
.filter(p => p.kind === 'text')
|
|
358
|
+
.map(p => p.text || '')
|
|
359
|
+
.join('');
|
|
360
|
+
}
|
|
361
|
+
return '';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
_normalizeTool(item) {
|
|
365
|
+
if (!item.toolCallId) return null;
|
|
366
|
+
|
|
367
|
+
// toolSpecificData may hold input/output depending on tool type
|
|
368
|
+
const tsd = item.toolSpecificData || {};
|
|
369
|
+
const input = tsd.input || tsd.parameters || tsd.request || {};
|
|
370
|
+
const result = tsd.output || tsd.result || item.resultDetails || null;
|
|
371
|
+
const isError = item.isConfirmed === false;
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
type: 'tool_use',
|
|
375
|
+
id: item.toolCallId,
|
|
376
|
+
name: item.toolId || 'unknown',
|
|
377
|
+
startTime: null,
|
|
378
|
+
endTime: null,
|
|
379
|
+
status: isError ? 'error' : (item.isComplete ? 'completed' : 'pending'),
|
|
380
|
+
input,
|
|
381
|
+
result: typeof result === 'string' ? result : JSON.stringify(result),
|
|
382
|
+
error: isError ? (item.resultDetails || 'Tool invocation not confirmed') : null,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
_extractTurns(events) {
|
|
387
|
+
const turns = [];
|
|
388
|
+
let current = null;
|
|
389
|
+
|
|
390
|
+
for (const event of events) {
|
|
391
|
+
if (event.type === 'user.message') {
|
|
392
|
+
if (current) turns.push(current);
|
|
393
|
+
current = { userMessage: event, assistantMessages: [], toolCalls: [] };
|
|
394
|
+
} else if (current) {
|
|
395
|
+
if (event.type === 'assistant.message') {
|
|
396
|
+
current.assistantMessages.push(event);
|
|
397
|
+
} else if (event.type === 'tool.invocation') {
|
|
398
|
+
current.toolCalls.push(event.data?.tool);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (current) turns.push(current);
|
|
403
|
+
return turns;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
_extractToolCalls(events) {
|
|
407
|
+
return events
|
|
408
|
+
.filter(e => e.type === 'tool.invocation' && e.data?.tool)
|
|
409
|
+
.map(e => e.data.tool);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
module.exports = VsCodeParser;
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -12,25 +12,8 @@ module.exports = app;
|
|
|
12
12
|
if (require.main === module) {
|
|
13
13
|
const server = app.listen(config.PORT, () => {
|
|
14
14
|
console.log(`🚀 Copilot Session Viewer running at http://localhost:${config.PORT}`);
|
|
15
|
-
console.log('📂 Session directories (env vars):');
|
|
16
|
-
console.log(` COPILOT_SESSION_DIR=${process.env.COPILOT_SESSION_DIR || 'not set'}`);
|
|
17
|
-
console.log(` CLAUDE_SESSION_DIR=${process.env.CLAUDE_SESSION_DIR || 'not set'}`);
|
|
18
|
-
console.log(` PI_MONO_SESSION_DIR=${process.env.PI_MONO_SESSION_DIR || 'not set'}`);
|
|
19
|
-
console.log(` SESSION_DIR=${process.env.SESSION_DIR || 'not set'} (legacy)`);
|
|
20
15
|
console.log(`🔧 Environment: ${config.NODE_ENV}`);
|
|
21
16
|
console.log(`⚡ Active processes: ${processManager.getActiveCount()}`);
|
|
22
|
-
|
|
23
|
-
// Log sessions found
|
|
24
|
-
const SessionRepository = require('./src/services/sessionRepository');
|
|
25
|
-
const repo = new SessionRepository();
|
|
26
|
-
repo.findAll().then(sessions => {
|
|
27
|
-
console.log(`📊 Sessions found: ${sessions.length}`);
|
|
28
|
-
if (sessions.length > 0) {
|
|
29
|
-
console.log(` First 5: ${sessions.slice(0, 5).map(s => s.id + ' (' + s.source + ')').join(', ')}`);
|
|
30
|
-
}
|
|
31
|
-
}).catch(err => {
|
|
32
|
-
console.error('❌ Error loading sessions:', err.message);
|
|
33
|
-
});
|
|
34
17
|
});
|
|
35
18
|
|
|
36
19
|
// Graceful shutdown
|
package/src/app.js
CHANGED
|
@@ -50,12 +50,12 @@ function createApp(options = {}) {
|
|
|
50
50
|
}));
|
|
51
51
|
|
|
52
52
|
app.use(compression({
|
|
53
|
-
level:
|
|
53
|
+
level: 1, // Fast compression (speed > ratio for local use)
|
|
54
54
|
threshold: 1024, // Compress responses > 1KB
|
|
55
55
|
filter: (req, res) => {
|
|
56
|
-
//
|
|
57
|
-
if (res.getHeader('Content-Type')?.includes('application/json')) {
|
|
58
|
-
return
|
|
56
|
+
// Skip compression for large JSON API responses (handled separately)
|
|
57
|
+
if (req.path.includes('/events') && res.getHeader('Content-Type')?.includes('application/json')) {
|
|
58
|
+
return false;
|
|
59
59
|
}
|
|
60
60
|
return compression.filter(req, res);
|
|
61
61
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const SessionService = require('../services/sessionService');
|
|
2
|
-
const { isValidSessionId } = require('../utils/helpers');
|
|
2
|
+
const { isValidSessionId, buildMetadata } = require('../utils/helpers');
|
|
3
3
|
const AdmZip = require('adm-zip');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
@@ -12,8 +12,8 @@ class SessionController {
|
|
|
12
12
|
// Homepage with initial load (first batch)
|
|
13
13
|
async getHomepage(req, res) {
|
|
14
14
|
try {
|
|
15
|
-
|
|
16
|
-
const paginationData = await this.sessionService.getPaginatedSessions(1,
|
|
15
|
+
// Only load default pill (copilot) first 20 sessions
|
|
16
|
+
const paginationData = await this.sessionService.getPaginatedSessions(1, 20, 'copilot');
|
|
17
17
|
|
|
18
18
|
// Pass data for infinite scroll
|
|
19
19
|
const templateData = {
|
|
@@ -34,19 +34,17 @@ class SessionController {
|
|
|
34
34
|
try {
|
|
35
35
|
const sessionId = req.params.id;
|
|
36
36
|
|
|
37
|
-
// Validate session ID format
|
|
38
37
|
if (!isValidSessionId(sessionId)) {
|
|
39
38
|
return res.status(400).json({ error: 'Invalid session ID' });
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
if (!sessionData) {
|
|
41
|
+
const session = await this.sessionService.sessionRepository.findById(sessionId);
|
|
42
|
+
if (!session) {
|
|
45
43
|
return res.status(404).json({ error: 'Session not found' });
|
|
46
44
|
}
|
|
47
45
|
|
|
48
|
-
const
|
|
49
|
-
res.render('session-vue', { sessionId, events, metadata });
|
|
46
|
+
const metadata = buildMetadata(session);
|
|
47
|
+
res.render('session-vue', { sessionId, events: [], metadata });
|
|
50
48
|
} catch (err) {
|
|
51
49
|
console.error('Error loading session:', err);
|
|
52
50
|
res.status(500).json({ error: 'Error loading session' });
|
|
@@ -58,20 +56,17 @@ class SessionController {
|
|
|
58
56
|
try {
|
|
59
57
|
const sessionId = req.params.id;
|
|
60
58
|
|
|
61
|
-
// Validate session ID format
|
|
62
59
|
if (!isValidSessionId(sessionId)) {
|
|
63
60
|
return res.status(400).json({ error: 'Invalid session ID' });
|
|
64
61
|
}
|
|
65
62
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
if (!sessionData) {
|
|
63
|
+
const session = await this.sessionService.sessionRepository.findById(sessionId);
|
|
64
|
+
if (!session) {
|
|
69
65
|
return res.status(404).json({ error: 'Session not found' });
|
|
70
66
|
}
|
|
71
67
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
res.render('time-analyze', { sessionId, events, metadata });
|
|
68
|
+
const metadata = buildMetadata(session);
|
|
69
|
+
res.render('time-analyze', { sessionId, events: [], metadata });
|
|
75
70
|
} catch (err) {
|
|
76
71
|
console.error('Error loading time analysis:', err);
|
|
77
72
|
res.status(500).json({ error: 'Error loading analysis' });
|
|
@@ -83,33 +78,26 @@ class SessionController {
|
|
|
83
78
|
try {
|
|
84
79
|
const page = req.query.page ? parseInt(req.query.page) : null;
|
|
85
80
|
const limit = req.query.limit ? parseInt(req.query.limit) : null;
|
|
81
|
+
const sourceFilter = req.query.source || null;
|
|
86
82
|
|
|
87
83
|
if (page && limit) {
|
|
88
84
|
// Return paginated response
|
|
89
85
|
if (page < 1 || limit < 1 || limit > 100) {
|
|
90
86
|
return res.status(400).json({ error: 'Invalid pagination parameters' });
|
|
91
87
|
}
|
|
92
|
-
const paginationData = await this.sessionService.getPaginatedSessions(page, limit);
|
|
93
|
-
|
|
94
|
-
// Set cache headers for paginated data (shorter cache)
|
|
95
|
-
res.set({
|
|
96
|
-
'Cache-Control': 'public, max-age=60', // 1 minute cache
|
|
97
|
-
'ETag': `"sessions-page-${page}-${limit}-${Date.now()}"`,
|
|
98
|
-
'Vary': 'Accept-Encoding'
|
|
99
|
-
});
|
|
100
|
-
|
|
88
|
+
const paginationData = await this.sessionService.getPaginatedSessions(page, limit, sourceFilter);
|
|
89
|
+
res.set({ 'Cache-Control': 'public, max-age=60' });
|
|
101
90
|
res.json(paginationData);
|
|
91
|
+
} else if (sourceFilter && limit) {
|
|
92
|
+
// Source-filtered first page (for pill switching)
|
|
93
|
+
const sessions = await this.sessionService.getAllSessions(sourceFilter);
|
|
94
|
+
const sliced = sessions.slice(0, limit);
|
|
95
|
+
res.set({ 'Cache-Control': 'public, max-age=60' });
|
|
96
|
+
res.json({ sessions: sliced, hasMore: sessions.length > limit, totalSessions: sessions.length });
|
|
102
97
|
} else {
|
|
103
98
|
// Return all sessions for backward compatibility
|
|
104
|
-
const sessions = await this.sessionService.getAllSessions();
|
|
105
|
-
|
|
106
|
-
// Set cache headers for full session list
|
|
107
|
-
res.set({
|
|
108
|
-
'Cache-Control': 'public, max-age=300', // 5 minute cache
|
|
109
|
-
'ETag': `"sessions-all-${Date.now()}"`,
|
|
110
|
-
'Vary': 'Accept-Encoding'
|
|
111
|
-
});
|
|
112
|
-
|
|
99
|
+
const sessions = await this.sessionService.getAllSessions(sourceFilter);
|
|
100
|
+
res.set({ 'Cache-Control': 'public, max-age=300' });
|
|
113
101
|
res.json(sessions);
|
|
114
102
|
}
|
|
115
103
|
} catch (err) {
|
|
@@ -118,11 +106,12 @@ class SessionController {
|
|
|
118
106
|
}
|
|
119
107
|
}
|
|
120
108
|
|
|
121
|
-
|
|
109
|
+
// API: Load more sessions for infinite scroll
|
|
122
110
|
async loadMoreSessions(req, res) {
|
|
123
111
|
try {
|
|
124
112
|
const offset = parseInt(req.query.offset) || 0;
|
|
125
113
|
const limit = parseInt(req.query.limit) || 20;
|
|
114
|
+
const sourceFilter = req.query.source || null;
|
|
126
115
|
|
|
127
116
|
// Validate parameters
|
|
128
117
|
if (offset < 0 || limit < 1 || limit > 50) {
|
|
@@ -131,7 +120,7 @@ class SessionController {
|
|
|
131
120
|
|
|
132
121
|
// Calculate page number from offset
|
|
133
122
|
const page = Math.floor(offset / limit) + 1;
|
|
134
|
-
const paginationData = await this.sessionService.getPaginatedSessions(page, limit);
|
|
123
|
+
const paginationData = await this.sessionService.getPaginatedSessions(page, limit, sourceFilter);
|
|
135
124
|
|
|
136
125
|
res.json({
|
|
137
126
|
sessions: paginationData.sessions,
|
|
@@ -174,7 +163,7 @@ class SessionController {
|
|
|
174
163
|
}
|
|
175
164
|
|
|
176
165
|
// Get session metadata for ETag generation
|
|
177
|
-
const session = await this.sessionService.
|
|
166
|
+
const session = await this.sessionService.sessionRepository.findById(sessionId);
|
|
178
167
|
if (!session) {
|
|
179
168
|
return res.status(404).json({ error: 'Session not found' });
|
|
180
169
|
}
|
|
@@ -182,8 +171,8 @@ class SessionController {
|
|
|
182
171
|
// Generate ETag from session ID + timestamp + pagination params (if used)
|
|
183
172
|
const crypto = require('crypto');
|
|
184
173
|
const etagBase = isPaginationRequested
|
|
185
|
-
? `${sessionId}-${session.
|
|
186
|
-
: `${sessionId}-${session.
|
|
174
|
+
? `${sessionId}-${session.updatedAt || session.createdAt}-${limit}-${offset}`
|
|
175
|
+
: `${sessionId}-${session.updatedAt || session.createdAt}`;
|
|
187
176
|
const etag = crypto.createHash('md5').update(etagBase).digest('hex');
|
|
188
177
|
|
|
189
178
|
// Check If-None-Match header (client cache)
|
|
@@ -248,7 +237,7 @@ class SessionController {
|
|
|
248
237
|
|
|
249
238
|
// Set caching headers
|
|
250
239
|
const crypto = require('crypto');
|
|
251
|
-
const etagBase = `${sessionId}-timeline-${session.
|
|
240
|
+
const etagBase = `${sessionId}-timeline-${session.updatedAt || session.createdAt}`;
|
|
252
241
|
const etag = crypto.createHash('md5').update(etagBase).digest('hex');
|
|
253
242
|
|
|
254
243
|
res.set({
|
package/src/models/Session.js
CHANGED
|
@@ -9,6 +9,7 @@ class Session {
|
|
|
9
9
|
this.type = type; // 'directory' or 'file'
|
|
10
10
|
this.source = options.source || 'copilot'; // 'copilot' or 'claude'
|
|
11
11
|
this.directory = options.directory || null; // Full path to session directory
|
|
12
|
+
this.filePath = options.filePath || null; // Full path to session file (for file-based sessions)
|
|
12
13
|
this.workspace = options.workspace || {};
|
|
13
14
|
this.createdAt = options.createdAt;
|
|
14
15
|
this.updatedAt = options.updatedAt;
|
|
@@ -124,7 +125,8 @@ class Session {
|
|
|
124
125
|
const metadata = {
|
|
125
126
|
'copilot': { name: 'Copilot', badgeClass: 'source-copilot' },
|
|
126
127
|
'claude': { name: 'Claude', badgeClass: 'source-claude' },
|
|
127
|
-
'pi-mono': { name: 'Pi', badgeClass: 'source-pi-mono' }
|
|
128
|
+
'pi-mono': { name: 'Pi', badgeClass: 'source-pi-mono' },
|
|
129
|
+
'vscode': { name: 'VSCode', badgeClass: 'source-vscode' }
|
|
128
130
|
};
|
|
129
131
|
return metadata[source] || { name: source, badgeClass: 'source-unknown' };
|
|
130
132
|
}
|
|
@@ -38,6 +38,11 @@ class SessionRepository {
|
|
|
38
38
|
type: 'pi-mono',
|
|
39
39
|
dir: process.env.PI_MONO_SESSION_DIR ||
|
|
40
40
|
path.join(os.homedir(), '.pi', 'agent', 'sessions')
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: 'vscode',
|
|
44
|
+
dir: process.env.VSCODE_WORKSPACE_STORAGE_DIR ||
|
|
45
|
+
path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage')
|
|
41
46
|
}
|
|
42
47
|
];
|
|
43
48
|
}
|
|
@@ -46,13 +51,18 @@ class SessionRepository {
|
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
/**
|
|
49
|
-
* Get all sessions from all sources
|
|
54
|
+
* Get all sessions from all sources (or a specific source)
|
|
55
|
+
* @param {string|null} sourceType - Optional source type filter ('copilot', 'claude', 'pi-mono', 'vscode')
|
|
50
56
|
* @returns {Promise<Session[]>} Array of sessions sorted by updatedAt (newest first)
|
|
51
57
|
*/
|
|
52
|
-
async findAll() {
|
|
58
|
+
async findAll(sourceType = null) {
|
|
53
59
|
const allSessions = [];
|
|
54
60
|
|
|
55
|
-
|
|
61
|
+
const sources = sourceType
|
|
62
|
+
? this.sources.filter(s => s.type === sourceType)
|
|
63
|
+
: this.sources;
|
|
64
|
+
|
|
65
|
+
for (const source of sources) {
|
|
56
66
|
try {
|
|
57
67
|
const sessions = await this._scanSource(source);
|
|
58
68
|
allSessions.push(...sessions);
|
|
@@ -100,6 +110,12 @@ class SessionRepository {
|
|
|
100
110
|
if (stats.isDirectory()) {
|
|
101
111
|
return this._scanPiMonoDir(fullPath, entry);
|
|
102
112
|
}
|
|
113
|
+
} else if (source.type === 'vscode') {
|
|
114
|
+
// VSCode: workspaceStorage/<hash>/chatSessions/<uuid>.json
|
|
115
|
+
// Each top-level entry is a workspace hash directory
|
|
116
|
+
if (stats.isDirectory()) {
|
|
117
|
+
return this._scanVsCodeWorkspaceDir(fullPath);
|
|
118
|
+
}
|
|
103
119
|
}
|
|
104
120
|
return null;
|
|
105
121
|
});
|
|
@@ -259,6 +275,8 @@ class SessionRepository {
|
|
|
259
275
|
session = await this._findClaudeSession(sessionId, source.dir);
|
|
260
276
|
} else if (source.type === 'pi-mono') {
|
|
261
277
|
session = await this._findPiMonoSession(sessionId, source.dir);
|
|
278
|
+
} else if (source.type === 'vscode') {
|
|
279
|
+
session = await this._findVsCodeSession(sessionId, source.dir);
|
|
262
280
|
}
|
|
263
281
|
|
|
264
282
|
if (session) return session;
|
|
@@ -423,6 +441,66 @@ class SessionRepository {
|
|
|
423
441
|
return null;
|
|
424
442
|
}
|
|
425
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Find VSCode session by ID in workspaceStorage
|
|
446
|
+
* @private
|
|
447
|
+
*/
|
|
448
|
+
async _findVsCodeSession(sessionId, workspaceStorageDir) {
|
|
449
|
+
try {
|
|
450
|
+
const hashes = await fs.readdir(workspaceStorageDir);
|
|
451
|
+
for (const hash of hashes) {
|
|
452
|
+
const chatSessionsDir = path.join(workspaceStorageDir, hash, 'chatSessions');
|
|
453
|
+
try {
|
|
454
|
+
const files = await fs.readdir(chatSessionsDir);
|
|
455
|
+
const matchingFile = files.find(f => f === `${sessionId}.json` || f === `${sessionId}.jsonl` || f.replace(/\.jsonl?$/, '') === sessionId);
|
|
456
|
+
if (matchingFile) {
|
|
457
|
+
const fullPath = path.join(chatSessionsDir, matchingFile);
|
|
458
|
+
const stats = await fs.stat(fullPath);
|
|
459
|
+
const raw = await fs.readFile(fullPath, 'utf-8');
|
|
460
|
+
let sessionJson;
|
|
461
|
+
if (matchingFile.endsWith('.jsonl')) {
|
|
462
|
+
sessionJson = this._parseVsCodeJsonl(raw);
|
|
463
|
+
if (!sessionJson) continue;
|
|
464
|
+
} else {
|
|
465
|
+
sessionJson = JSON.parse(raw);
|
|
466
|
+
}
|
|
467
|
+
const requests = sessionJson.requests || [];
|
|
468
|
+
if (requests.length === 0) return null;
|
|
469
|
+
|
|
470
|
+
const firstReq = requests[0];
|
|
471
|
+
const createdAt = sessionJson.creationDate
|
|
472
|
+
? new Date(sessionJson.creationDate)
|
|
473
|
+
: (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
|
|
474
|
+
const updatedAt = sessionJson.lastMessageDate
|
|
475
|
+
? new Date(sessionJson.lastMessageDate)
|
|
476
|
+
: stats.mtime;
|
|
477
|
+
const userText = this._extractVsCodeUserText(firstReq.message);
|
|
478
|
+
|
|
479
|
+
return new Session(sessionId, 'file', {
|
|
480
|
+
source: 'vscode',
|
|
481
|
+
filePath: fullPath,
|
|
482
|
+
createdAt,
|
|
483
|
+
updatedAt,
|
|
484
|
+
summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
|
|
485
|
+
hasEvents: true,
|
|
486
|
+
eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
|
|
487
|
+
duration: updatedAt - createdAt,
|
|
488
|
+
sessionStatus: 'completed',
|
|
489
|
+
model: firstReq.modelId || null,
|
|
490
|
+
workspace: { cwd: path.join(workspaceStorageDir, hash) },
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
} catch {
|
|
494
|
+
// No chatSessions dir or can't read — skip
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
} catch (err) {
|
|
498
|
+
console.error(`[VSCode findById] Error searching VSCode sessions: ${err.message}`, err.stack);
|
|
499
|
+
}
|
|
500
|
+
console.log(`[VSCode findById] Session ${sessionId} not found in vscode sessions`);
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
|
|
426
504
|
/**
|
|
427
505
|
* Create Claude session from subagents-only directory (no main events.jsonl)
|
|
428
506
|
* @private
|
|
@@ -657,6 +735,200 @@ class SessionRepository {
|
|
|
657
735
|
}
|
|
658
736
|
}
|
|
659
737
|
|
|
738
|
+
/**
|
|
739
|
+
* Scan a VSCode workspaceStorage/<hash> directory for chatSessions/*.json files
|
|
740
|
+
* @private
|
|
741
|
+
*/
|
|
742
|
+
/** Resolve the real project/workspace path from a VSCode workspaceStorage hash directory */
|
|
743
|
+
/** Parse a VSCode .jsonl file: read kind=0 for base state, merge kind=2 patches into response arrays */
|
|
744
|
+
_parseVsCodeJsonl(raw) {
|
|
745
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
746
|
+
if (lines.length === 0) return null;
|
|
747
|
+
|
|
748
|
+
const first = JSON.parse(lines[0]);
|
|
749
|
+
const sessionJson = first.v || first; // kind=0 wraps in .v
|
|
750
|
+
|
|
751
|
+
// Apply kind=1 (field set) and kind=2 (array splice) patches
|
|
752
|
+
for (let idx = 1; idx < lines.length; idx++) {
|
|
753
|
+
try {
|
|
754
|
+
const patch = JSON.parse(lines[idx]);
|
|
755
|
+
const k = patch.k || [];
|
|
756
|
+
const v = patch.v;
|
|
757
|
+
|
|
758
|
+
if (patch.kind === 2 && Array.isArray(v)) {
|
|
759
|
+
// Navigate to parent object, then splice into the target array
|
|
760
|
+
let obj = sessionJson;
|
|
761
|
+
for (let ki = 0; ki < k.length - 1; ki++) {
|
|
762
|
+
const key = k[ki];
|
|
763
|
+
if (typeof key === 'number') {
|
|
764
|
+
obj = obj[key];
|
|
765
|
+
} else {
|
|
766
|
+
if (!obj[key]) obj[key] = {};
|
|
767
|
+
obj = obj[key];
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const lastKey = k[k.length - 1];
|
|
771
|
+
if (lastKey !== undefined) {
|
|
772
|
+
if (!obj[lastKey]) obj[lastKey] = [];
|
|
773
|
+
const target = obj[lastKey];
|
|
774
|
+
const i = patch.i;
|
|
775
|
+
if (i === null || i === undefined) {
|
|
776
|
+
target.push(...v);
|
|
777
|
+
} else {
|
|
778
|
+
target.splice(i, 0, ...v);
|
|
779
|
+
}
|
|
780
|
+
} else {
|
|
781
|
+
// k is empty — splice into sessionJson itself (rare)
|
|
782
|
+
const i = patch.i;
|
|
783
|
+
if (i === null || i === undefined) sessionJson.push?.(...v);
|
|
784
|
+
}
|
|
785
|
+
} else if (patch.kind === 1 && k.length > 0) {
|
|
786
|
+
// Navigate to parent, set the final key
|
|
787
|
+
let obj = sessionJson;
|
|
788
|
+
for (let ki = 0; ki < k.length - 1; ki++) {
|
|
789
|
+
const key = k[ki];
|
|
790
|
+
if (typeof key === 'number') {
|
|
791
|
+
obj = obj[key];
|
|
792
|
+
} else {
|
|
793
|
+
if (!obj[key]) obj[key] = {};
|
|
794
|
+
obj = obj[key];
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const lastKey = k[k.length - 1];
|
|
798
|
+
obj[lastKey] = v;
|
|
799
|
+
}
|
|
800
|
+
} catch {
|
|
801
|
+
// Skip malformed lines
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return sessionJson;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async _resolveVsCodeWorkspacePath(workspaceHashDir) {
|
|
809
|
+
try {
|
|
810
|
+
const workspaceJsonPath = path.join(workspaceHashDir, 'workspace.json');
|
|
811
|
+
const raw = await fs.readFile(workspaceJsonPath, 'utf-8');
|
|
812
|
+
const meta = JSON.parse(raw);
|
|
813
|
+
|
|
814
|
+
if (meta.folder) {
|
|
815
|
+
// Single-folder workspace: file:///path/to/project
|
|
816
|
+
return decodeURIComponent(meta.folder.replace('file://', ''));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (meta.workspace) {
|
|
820
|
+
// Multi-folder workspace: points to another .json with folders array
|
|
821
|
+
const wsFilePath = decodeURIComponent(meta.workspace.replace('file://', ''));
|
|
822
|
+
try {
|
|
823
|
+
const wsRaw = await fs.readFile(wsFilePath, 'utf-8');
|
|
824
|
+
const ws = JSON.parse(wsRaw);
|
|
825
|
+
if (Array.isArray(ws.folders) && ws.folders.length > 0) {
|
|
826
|
+
// Return first folder path
|
|
827
|
+
const wsDir = path.dirname(wsFilePath);
|
|
828
|
+
const resolved = path.resolve(wsDir, ws.folders[0].path);
|
|
829
|
+
return resolved;
|
|
830
|
+
}
|
|
831
|
+
} catch {
|
|
832
|
+
// Ignore nested read errors
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
} catch {
|
|
836
|
+
// No workspace.json or unreadable
|
|
837
|
+
}
|
|
838
|
+
return null;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async _scanVsCodeWorkspaceDir(workspaceHashDir) {
|
|
842
|
+
const chatSessionsDir = path.join(workspaceHashDir, 'chatSessions');
|
|
843
|
+
try {
|
|
844
|
+
await fs.access(chatSessionsDir);
|
|
845
|
+
} catch {
|
|
846
|
+
return []; // No chatSessions subfolder — skip silently
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Resolve the real project path from workspace.json
|
|
850
|
+
const realWorkspacePath = await this._resolveVsCodeWorkspacePath(workspaceHashDir);
|
|
851
|
+
|
|
852
|
+
const entries = await fs.readdir(chatSessionsDir);
|
|
853
|
+
const jsonFiles = entries.filter(e => (e.endsWith('.json') || e.endsWith('.jsonl')) && !shouldSkipEntry(e));
|
|
854
|
+
if (jsonFiles.length === 0) return [];
|
|
855
|
+
|
|
856
|
+
const sessions = [];
|
|
857
|
+
for (const file of jsonFiles) {
|
|
858
|
+
const fullPath = path.join(chatSessionsDir, file);
|
|
859
|
+
try {
|
|
860
|
+
const stats = await fs.stat(fullPath);
|
|
861
|
+
const raw = await fs.readFile(fullPath, 'utf-8');
|
|
862
|
+
// Support both .json (flat) and .jsonl (incremental patch: kind=0 + kind=2 patches)
|
|
863
|
+
let sessionJson;
|
|
864
|
+
if (file.endsWith('.jsonl')) {
|
|
865
|
+
sessionJson = this._parseVsCodeJsonl(raw);
|
|
866
|
+
if (!sessionJson) continue;
|
|
867
|
+
} else {
|
|
868
|
+
sessionJson = JSON.parse(raw);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const sessionId = sessionJson.sessionId || path.basename(file).replace(/\.jsonl?$/, '');
|
|
872
|
+
const requests = sessionJson.requests || [];
|
|
873
|
+
if (requests.length === 0) continue;
|
|
874
|
+
|
|
875
|
+
const firstReq = requests[0];
|
|
876
|
+
const lastReq = requests[requests.length - 1];
|
|
877
|
+
const createdAt = sessionJson.creationDate
|
|
878
|
+
? new Date(sessionJson.creationDate)
|
|
879
|
+
: (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
|
|
880
|
+
const updatedAt = sessionJson.lastMessageDate
|
|
881
|
+
? new Date(sessionJson.lastMessageDate)
|
|
882
|
+
: (lastReq.timestamp ? new Date(lastReq.timestamp) : stats.mtime);
|
|
883
|
+
|
|
884
|
+
// Count tool invocations across all requests
|
|
885
|
+
let toolCount = 0;
|
|
886
|
+
for (const req of requests) {
|
|
887
|
+
toolCount += (req.response || []).filter(r => r.kind === 'toolInvocationSerialized').length;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const model = firstReq.modelId || null;
|
|
891
|
+
const agentId = firstReq.agent?.id || 'vscode-copilot';
|
|
892
|
+
const userText = this._extractVsCodeUserText(firstReq.message);
|
|
893
|
+
|
|
894
|
+
const session = new Session(
|
|
895
|
+
sessionId,
|
|
896
|
+
'file',
|
|
897
|
+
{
|
|
898
|
+
source: 'vscode',
|
|
899
|
+
filePath: fullPath,
|
|
900
|
+
createdAt,
|
|
901
|
+
updatedAt,
|
|
902
|
+
summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
|
|
903
|
+
hasEvents: true,
|
|
904
|
+
eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
|
|
905
|
+
duration: updatedAt - createdAt,
|
|
906
|
+
sessionStatus: 'completed',
|
|
907
|
+
model,
|
|
908
|
+
agentId,
|
|
909
|
+
toolCount,
|
|
910
|
+
workspace: { cwd: realWorkspacePath || workspaceHashDir },
|
|
911
|
+
}
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
sessions.push(session);
|
|
915
|
+
} catch (err) {
|
|
916
|
+
// Skip malformed files silently
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return sessions;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** Extract plain text from a VSCode message object */
|
|
923
|
+
_extractVsCodeUserText(message) {
|
|
924
|
+
if (!message) return '';
|
|
925
|
+
if (typeof message.text === 'string') return message.text;
|
|
926
|
+
if (Array.isArray(message.parts)) {
|
|
927
|
+
return message.parts.filter(p => p.kind === 'text').map(p => p.text || '').join('');
|
|
928
|
+
}
|
|
929
|
+
return '';
|
|
930
|
+
}
|
|
931
|
+
|
|
660
932
|
/**
|
|
661
933
|
* Read first line of a file
|
|
662
934
|
* @private
|
|
@@ -21,13 +21,13 @@ class SessionService {
|
|
|
21
21
|
this.eventNormalizer = new EventNormalizer();
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
async getAllSessions() {
|
|
25
|
-
const sessions = await this.sessionRepository.findAll();
|
|
24
|
+
async getAllSessions(sourceFilter = null) {
|
|
25
|
+
const sessions = await this.sessionRepository.findAll(sourceFilter);
|
|
26
26
|
return sessions.map(s => s.toJSON());
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
async getPaginatedSessions(page = 1, limit = 20) {
|
|
30
|
-
const allSessions = await this.sessionRepository.findAll();
|
|
29
|
+
async getPaginatedSessions(page = 1, limit = 20, sourceFilter = null) {
|
|
30
|
+
const allSessions = await this.sessionRepository.findAll(sourceFilter);
|
|
31
31
|
const sessions = allSessions.map(s => s.toJSON());
|
|
32
32
|
|
|
33
33
|
const startIndex = (page - 1) * limit;
|
|
@@ -151,6 +151,27 @@ class SessionService {
|
|
|
151
151
|
console.error('Error searching Pi-Mono sessions:', err);
|
|
152
152
|
return [];
|
|
153
153
|
}
|
|
154
|
+
} else if (session.source === 'vscode') {
|
|
155
|
+
// VSCode format: read JSON file directly, convert to event array via 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
|
+
let sessionJson;
|
|
161
|
+
if (session.filePath.endsWith('.jsonl')) {
|
|
162
|
+
sessionJson = this.sessionRepository._parseVsCodeJsonl(raw);
|
|
163
|
+
} else {
|
|
164
|
+
sessionJson = JSON.parse(raw);
|
|
165
|
+
}
|
|
166
|
+
if (!sessionJson) return [];
|
|
167
|
+
const parsed = vscodeParser.parseVsCode(sessionJson);
|
|
168
|
+
// Convert tool.invocation events → assistant.message with data.tools
|
|
169
|
+
// so frontend can render them using the same tool-list component
|
|
170
|
+
return this._expandVsCodeEvents(parsed.allEvents);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error('Error reading VSCode session:', err);
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
154
175
|
}
|
|
155
176
|
|
|
156
177
|
|
|
@@ -1434,6 +1455,54 @@ class SessionService {
|
|
|
1434
1455
|
/**
|
|
1435
1456
|
* Expand Copilot format (user/assistant) to timeline format with turn_start/complete
|
|
1436
1457
|
* @private
|
|
1458
|
+
/**
|
|
1459
|
+
* Convert VSCode tool.invocation events into assistant.message events with data.tools,
|
|
1460
|
+
* so they render using the same frontend tool-list component.
|
|
1461
|
+
* Groups consecutive tool.invocation events under a single assistant.message when possible.
|
|
1462
|
+
*/
|
|
1463
|
+
_expandVsCodeEvents(events) {
|
|
1464
|
+
const result = [];
|
|
1465
|
+
let pendingTools = [];
|
|
1466
|
+
let pendingParentId = null;
|
|
1467
|
+
let pendingTs = null;
|
|
1468
|
+
let pendingIdx = 0;
|
|
1469
|
+
|
|
1470
|
+
const flushTools = () => {
|
|
1471
|
+
if (pendingTools.length === 0) return;
|
|
1472
|
+
result.push({
|
|
1473
|
+
type: 'assistant.message',
|
|
1474
|
+
id: `vscode-tools-${pendingIdx}`,
|
|
1475
|
+
timestamp: pendingTs,
|
|
1476
|
+
parentId: pendingParentId,
|
|
1477
|
+
data: {
|
|
1478
|
+
message: '',
|
|
1479
|
+
content: '',
|
|
1480
|
+
tools: pendingTools,
|
|
1481
|
+
},
|
|
1482
|
+
_synthetic: true,
|
|
1483
|
+
});
|
|
1484
|
+
pendingTools = [];
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1487
|
+
for (let i = 0; i < events.length; i++) {
|
|
1488
|
+
const ev = events[i];
|
|
1489
|
+
if (ev.type === 'tool.invocation') {
|
|
1490
|
+
if (pendingTools.length === 0) {
|
|
1491
|
+
pendingParentId = ev.parentId;
|
|
1492
|
+
pendingTs = ev.timestamp;
|
|
1493
|
+
pendingIdx = i;
|
|
1494
|
+
}
|
|
1495
|
+
if (ev.data?.tool) pendingTools.push(ev.data.tool);
|
|
1496
|
+
} else {
|
|
1497
|
+
flushTools();
|
|
1498
|
+
result.push(ev);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
flushTools();
|
|
1502
|
+
return result;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1437
1506
|
* @param {Array} events - Normalized Copilot events
|
|
1438
1507
|
* @returns {Array} Expanded events with turn_start/complete
|
|
1439
1508
|
*/
|
package/views/index.ejs
CHANGED
|
@@ -308,6 +308,15 @@
|
|
|
308
308
|
font-weight: 600;
|
|
309
309
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
|
310
310
|
}
|
|
311
|
+
.status-badge.source-vscode {
|
|
312
|
+
padding: 2px 8px;
|
|
313
|
+
background: rgba(0, 122, 204, 0.15);
|
|
314
|
+
color: #4fc3f7;
|
|
315
|
+
border-radius: 12px;
|
|
316
|
+
font-size: 11px;
|
|
317
|
+
font-weight: 600;
|
|
318
|
+
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
|
319
|
+
}
|
|
311
320
|
|
|
312
321
|
/* Filter pills */
|
|
313
322
|
.filter-pills {
|
|
@@ -474,6 +483,7 @@
|
|
|
474
483
|
<button class="filter-pill active" data-source="copilot">Copilot</button>
|
|
475
484
|
<button class="filter-pill" data-source="claude">Claude</button>
|
|
476
485
|
<button class="filter-pill" data-source="pi-mono">Pi</button>
|
|
486
|
+
<!-- <button class="filter-pill" data-source="vscode">VSCode</button> -->
|
|
477
487
|
</div>
|
|
478
488
|
<input
|
|
479
489
|
type="file"
|
|
@@ -485,10 +495,7 @@
|
|
|
485
495
|
<div id="importStatus" class="import-status"></div>
|
|
486
496
|
<div id="sessions-container"></div>
|
|
487
497
|
|
|
488
|
-
|
|
489
|
-
<div id="load-more-section" style="text-align: center; margin-top: 20px; display: none;">
|
|
490
|
-
<button id="load-more-btn" class="load-more-btn">Load More Sessions</button>
|
|
491
|
-
</div>
|
|
498
|
+
<!-- Loading indicator for infinite scroll -->
|
|
492
499
|
<div id="loading-indicator" style="text-align: center; margin-top: 20px; display: none;">
|
|
493
500
|
<div class="loading-spinner">Loading more sessions...</div>
|
|
494
501
|
</div>
|
|
@@ -507,57 +514,56 @@
|
|
|
507
514
|
const totalSessionsFromServer = metaData.totalSessions;
|
|
508
515
|
const hasMoreFromServer = metaData.hasMore;
|
|
509
516
|
|
|
510
|
-
// Infinite scroll state
|
|
517
|
+
// Infinite scroll state — per-source
|
|
511
518
|
let allSessions = [...initialSessions];
|
|
512
|
-
|
|
513
|
-
|
|
519
|
+
// Per-source pagination state
|
|
520
|
+
const sourceState = {};
|
|
521
|
+
// Initialize from initial server load (copilot is default active pill)
|
|
522
|
+
sourceState['copilot'] = { offset: initialSessions.length, hasMore: hasMoreFromServer };
|
|
523
|
+
|
|
514
524
|
let isLoading = false;
|
|
515
525
|
|
|
516
526
|
// Filter state
|
|
517
527
|
let currentSourceFilter = 'copilot';
|
|
518
|
-
|
|
528
|
+
|
|
529
|
+
function currentState() {
|
|
530
|
+
if (!sourceState[currentSourceFilter]) {
|
|
531
|
+
sourceState[currentSourceFilter] = { offset: 0, hasMore: true };
|
|
532
|
+
}
|
|
533
|
+
return sourceState[currentSourceFilter];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Load more sessions for current source
|
|
519
537
|
async function loadMoreSessions() {
|
|
520
|
-
|
|
538
|
+
const state = currentState();
|
|
539
|
+
if (isLoading || !state.hasMore) return;
|
|
521
540
|
|
|
522
541
|
isLoading = true;
|
|
523
|
-
const loadMoreBtn = document.getElementById('load-more-btn');
|
|
524
542
|
const loadingIndicator = document.getElementById('loading-indicator');
|
|
525
|
-
const loadMoreSection = document.getElementById('load-more-section');
|
|
526
|
-
|
|
527
|
-
// Show loading, hide button
|
|
528
|
-
loadMoreSection.style.display = 'none';
|
|
529
543
|
loadingIndicator.style.display = 'block';
|
|
530
544
|
|
|
531
545
|
try {
|
|
532
|
-
const response = await fetch(`/api/sessions/load-more?offset=${
|
|
533
|
-
if (!response.ok)
|
|
534
|
-
throw new Error('Failed to load more sessions');
|
|
535
|
-
}
|
|
546
|
+
const response = await fetch(`/api/sessions/load-more?offset=${currentState().offset}&limit=20&source=${encodeURIComponent(currentSourceFilter)}`);
|
|
547
|
+
if (!response.ok) throw new Error('Failed to load more sessions');
|
|
536
548
|
|
|
537
549
|
const data = await response.json();
|
|
538
|
-
allSessions.
|
|
539
|
-
|
|
540
|
-
|
|
550
|
+
const existingIds = new Set(allSessions.map(s => s.id));
|
|
551
|
+
for (const s of data.sessions) {
|
|
552
|
+
if (!existingIds.has(s.id)) allSessions.push(s);
|
|
553
|
+
}
|
|
554
|
+
currentState().offset += data.sessions.length;
|
|
555
|
+
currentState().hasMore = data.hasMore;
|
|
541
556
|
|
|
542
|
-
// Re-render all sessions
|
|
543
557
|
renderAllSessions();
|
|
544
|
-
updateLoadMoreButton();
|
|
545
|
-
|
|
546
558
|
} catch (err) {
|
|
547
559
|
console.error('Error loading more sessions:', err);
|
|
548
|
-
// Show button again on error
|
|
549
|
-
loadMoreSection.style.display = hasMore ? 'block' : 'none';
|
|
550
560
|
} finally {
|
|
551
561
|
isLoading = false;
|
|
552
562
|
loadingIndicator.style.display = 'none';
|
|
553
563
|
}
|
|
554
564
|
}
|
|
555
565
|
|
|
556
|
-
//
|
|
557
|
-
function updateLoadMoreButton() {
|
|
558
|
-
const loadMoreSection = document.getElementById('load-more-section');
|
|
559
|
-
loadMoreSection.style.display = hasMore && !isLoading ? 'block' : 'none';
|
|
560
|
-
}
|
|
566
|
+
// (load more button removed — pure infinite scroll)
|
|
561
567
|
|
|
562
568
|
// Get filtered sessions based on current filter
|
|
563
569
|
function getFilteredSessions() {
|
|
@@ -601,7 +607,7 @@
|
|
|
601
607
|
const docHeight = document.documentElement.scrollHeight;
|
|
602
608
|
|
|
603
609
|
// Load more when user is within 500px of bottom
|
|
604
|
-
if (scrollTop + windowHeight >= docHeight - 500 && hasMore && !isLoading) {
|
|
610
|
+
if (scrollTop + windowHeight >= docHeight - 500 && currentState().hasMore && !isLoading) {
|
|
605
611
|
loadMoreSessions();
|
|
606
612
|
}
|
|
607
613
|
}
|
|
@@ -847,16 +853,41 @@
|
|
|
847
853
|
function setupFilterPills() {
|
|
848
854
|
const filterPills = document.querySelectorAll('.filter-pill');
|
|
849
855
|
filterPills.forEach(pill => {
|
|
850
|
-
pill.addEventListener('click', () => {
|
|
851
|
-
// Remove active class from all pills
|
|
856
|
+
pill.addEventListener('click', async () => {
|
|
852
857
|
filterPills.forEach(p => p.classList.remove('active'));
|
|
853
|
-
// Add active class to clicked pill
|
|
854
858
|
pill.classList.add('active');
|
|
855
|
-
|
|
856
|
-
// Update filter state
|
|
857
859
|
currentSourceFilter = pill.getAttribute('data-source');
|
|
858
860
|
|
|
859
|
-
//
|
|
861
|
+
// Init per-source state if first visit
|
|
862
|
+
if (!sourceState[currentSourceFilter]) {
|
|
863
|
+
sourceState[currentSourceFilter] = { offset: 0, hasMore: true };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Always fetch first batch when switching to a new source (backend-filtered)
|
|
867
|
+
if (sourceState[currentSourceFilter].offset === 0 && !isLoading) {
|
|
868
|
+
isLoading = true;
|
|
869
|
+
// Show loading state immediately (clear old results)
|
|
870
|
+
const container = document.getElementById('sessions-container');
|
|
871
|
+
container.innerHTML = '<div style="text-align: center; color: #6e7681; padding: 40px; font-size: 14px;">⏳ Loading...</div>';
|
|
872
|
+
document.getElementById('loading-indicator').style.display = 'none';
|
|
873
|
+
try {
|
|
874
|
+
const resp = await fetch(`/api/sessions/load-more?offset=0&limit=20&source=${encodeURIComponent(currentSourceFilter)}`);
|
|
875
|
+
if (resp.ok) {
|
|
876
|
+
const data = await resp.json();
|
|
877
|
+
const existingIds = new Set(allSessions.map(s => s.id));
|
|
878
|
+
for (const s of (data.sessions || [])) {
|
|
879
|
+
if (!existingIds.has(s.id)) allSessions.push(s);
|
|
880
|
+
}
|
|
881
|
+
sourceState[currentSourceFilter].offset = (data.sessions || []).length;
|
|
882
|
+
sourceState[currentSourceFilter].hasMore = data.hasMore;
|
|
883
|
+
}
|
|
884
|
+
} catch (e) {
|
|
885
|
+
console.error('Failed to load sessions for source:', currentSourceFilter, e);
|
|
886
|
+
} finally {
|
|
887
|
+
isLoading = false;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
860
891
|
renderAllSessions();
|
|
861
892
|
});
|
|
862
893
|
});
|
|
@@ -864,16 +895,11 @@
|
|
|
864
895
|
|
|
865
896
|
// Render grouped sessions
|
|
866
897
|
document.addEventListener('DOMContentLoaded', function() {
|
|
867
|
-
// Initial render
|
|
868
898
|
renderAllSessions();
|
|
869
|
-
updateLoadMoreButton();
|
|
870
899
|
|
|
871
|
-
//
|
|
900
|
+
// Infinite scroll
|
|
872
901
|
window.addEventListener('scroll', throttledScroll);
|
|
873
902
|
|
|
874
|
-
// Add click listener for load more button
|
|
875
|
-
document.getElementById('load-more-btn').addEventListener('click', loadMoreSessions);
|
|
876
|
-
|
|
877
903
|
// Setup filter pills
|
|
878
904
|
setupFilterPills();
|
|
879
905
|
});
|
|
@@ -887,7 +913,6 @@
|
|
|
887
913
|
margin-top: 32px;
|
|
888
914
|
margin-bottom: 16px;
|
|
889
915
|
padding-bottom: 8px;
|
|
890
|
-
border-bottom: 2px solid #21262d;
|
|
891
916
|
}
|
|
892
917
|
.date-group-header:first-child {
|
|
893
918
|
margin-top: 0;
|
package/views/session-vue.ejs
CHANGED
|
@@ -242,6 +242,11 @@
|
|
|
242
242
|
color: #a78bdb;
|
|
243
243
|
border: 1px solid rgba(138, 102, 204, 0.4);
|
|
244
244
|
}
|
|
245
|
+
.source-vscode {
|
|
246
|
+
background: rgba(0, 122, 204, 0.2);
|
|
247
|
+
color: #4fc3f7;
|
|
248
|
+
border: 1px solid rgba(0, 122, 204, 0.4);
|
|
249
|
+
}
|
|
245
250
|
|
|
246
251
|
/* Turn buttons */
|
|
247
252
|
.turn-buttons {
|
|
@@ -1318,6 +1323,15 @@
|
|
|
1318
1323
|
}
|
|
1319
1324
|
}
|
|
1320
1325
|
|
|
1326
|
+
// 6. Attribute tool.invocation events (VS Code format) via parentToolCallId
|
|
1327
|
+
for (const ev of sorted) {
|
|
1328
|
+
if (ev.type !== 'tool.invocation') continue;
|
|
1329
|
+
const ptcid = ev.data?.parentToolCallId;
|
|
1330
|
+
if (ptcid && subagentInfo.has(ptcid)) {
|
|
1331
|
+
ownerMap.set(ev.stableId, ptcid);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1321
1335
|
return { ownerMap, subagentInfo };
|
|
1322
1336
|
});
|
|
1323
1337
|
|