@qiaolei81/copilot-session-viewer 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-be-responsive-on-mobile-viewport-1771605454041.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-display-sessions-if-available-1771605462872.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-handle-JavaScript-errors-gracefully-1771605463381.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-handle-session-import-dialog-1771605466264.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-have-working-infinite-scroll-elements-1771605454038.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-load-homepage-with-basic-elements-1771605454001.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-load-time-analysis-page-1771605464990.json +1236 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-navigate-to-session-detail-page-1771605472595.json +1177 -0
- package/.nyc_output/coverage-e2e-merged.json +1 -0
- package/.nyc_output/coverage-homepage-spec-js-Homepage-should-display-session-list-1771605453565.json +435 -0
- package/.nyc_output/coverage-homepage-spec-js-Homepage-should-load-homepage-successfully-1771605453552.json +435 -0
- package/.nyc_output/coverage-homepage-spec-js-Homepage-should-navigate-to-session-detail-on-click-1771605469317.json +1134 -0
- package/.nyc_output/coverage-homepage-spec-js-Homepage-should-show-session-metadata-1771605460581.json +435 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-display-Load-More-Sessions-button-when-there-are-more-sessions-1771605468486.json +435 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-handle-API-errors-gracefully-during-infinite-scroll-1771605482161.json +471 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-hide-Load-More-button-when-no-more-sessions-available-1771605478370.json +471 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-load-additional-sessions-when-Load-More-button-is-clicked-1771605475059.json +471 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-preserve-session-list-state-during-navigation-1771605494575.json +1633 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-show-loading-state-when-Load-More-button-is-clicked-1771605475401.json +471 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-trigger-infinite-scroll-when-scrolling-near-bottom-1771605476949.json +471 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-clear-search-filter-1771605508542.json +1255 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-display-event-list-1771605505572.json +1156 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-display-session-metadata-1771605504552.json +701 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-expand-and-collapse-tool-details-1771605515809.json +1182 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-filter-events-by-search-1771605513421.json +1245 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-load-session-detail-page-1771605494974.json +701 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-toggle-content-visibility-1771605550729.json +1177 -0
- package/.nyc_output/coverage-unit.json +21 -0
- package/.nycrc +29 -0
- package/CHANGELOG.md +36 -0
- package/README.md +154 -15
- package/examples/parser-usage.js +114 -0
- package/lib/parsers/README.md +239 -0
- package/lib/parsers/base-parser.js +53 -0
- package/lib/parsers/claude-parser.js +181 -0
- package/lib/parsers/copilot-parser.js +143 -0
- package/lib/parsers/index.js +13 -0
- package/lib/parsers/parser-factory.js +77 -0
- package/lib/parsers/pi-mono-parser.js +119 -0
- package/package.json +12 -4
- package/server.js +17 -2
- package/src/app.js +45 -20
- package/src/controllers/insightController.js +44 -8
- package/src/controllers/sessionController.js +217 -3
- package/src/controllers/uploadController.js +447 -7
- package/src/middleware/rateLimiting.js +7 -1
- package/src/models/Session.js +26 -0
- package/src/schemas/event.schema.js +73 -0
- package/src/services/eventNormalizer.js +291 -0
- package/src/services/insightService.js +140 -48
- package/src/services/sessionRepository.js +584 -49
- package/src/services/sessionService.js +1588 -36
- package/src/utils/helpers.js +6 -1
- package/views/index.ejs +111 -4
- package/views/session-vue.ejs +272 -65
- package/views/time-analyze.ejs +127 -55
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base Session Parser Interface
|
|
3
|
+
*
|
|
4
|
+
* All session parsers must implement these methods
|
|
5
|
+
*/
|
|
6
|
+
class BaseSessionParser {
|
|
7
|
+
/**
|
|
8
|
+
* Detect if this parser can handle the given events
|
|
9
|
+
* @param {Array<Object>} _events - Raw events from jsonl
|
|
10
|
+
* @returns {boolean}
|
|
11
|
+
*/
|
|
12
|
+
canParse(_events) {
|
|
13
|
+
throw new Error('canParse() must be implemented');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse raw events into normalized format
|
|
18
|
+
* @param {Array<Object>} _events - Raw events from jsonl
|
|
19
|
+
* @returns {Object} Parsed session data
|
|
20
|
+
*/
|
|
21
|
+
parse(_events) {
|
|
22
|
+
throw new Error('parse() must be implemented');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get session metadata (sessionId, startTime, model, etc.)
|
|
27
|
+
* @param {Array<Object>} _events - Raw events
|
|
28
|
+
* @returns {Object} Session metadata
|
|
29
|
+
*/
|
|
30
|
+
getMetadata(_events) {
|
|
31
|
+
throw new Error('getMetadata() must be implemented');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract turns (user message + assistant response pairs)
|
|
36
|
+
* @param {Array<Object>} _events - Raw events
|
|
37
|
+
* @returns {Array<Object>} Array of turns
|
|
38
|
+
*/
|
|
39
|
+
extractTurns(_events) {
|
|
40
|
+
throw new Error('extractTurns() must be implemented');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract tool calls/executions
|
|
45
|
+
* @param {Array<Object>} _events - Raw events
|
|
46
|
+
* @returns {Array<Object>} Array of tool calls
|
|
47
|
+
*/
|
|
48
|
+
extractToolCalls(_events) {
|
|
49
|
+
throw new Error('extractToolCalls() must be implemented');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = BaseSessionParser;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
const BaseSessionParser = require('./base-parser');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Session Parser
|
|
5
|
+
*
|
|
6
|
+
* Parses events from Anthropic Claude Code CLI
|
|
7
|
+
* Format: {type: "user"|"assistant", uuid: "...", parentUuid: "...", message: {...}}
|
|
8
|
+
*/
|
|
9
|
+
class ClaudeSessionParser extends BaseSessionParser {
|
|
10
|
+
canParse(events) {
|
|
11
|
+
if (!events || events.length === 0) return false;
|
|
12
|
+
|
|
13
|
+
// Check for Claude Code specific structure
|
|
14
|
+
return events.some(e =>
|
|
15
|
+
e.type && ['user', 'assistant'].includes(e.type) &&
|
|
16
|
+
e.uuid && Object.prototype.hasOwnProperty.call(e, 'parentUuid') && e.sessionId
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
parse(events) {
|
|
21
|
+
return {
|
|
22
|
+
metadata: this.getMetadata(events),
|
|
23
|
+
turns: this.extractTurns(events),
|
|
24
|
+
toolCalls: this.extractToolCalls(events),
|
|
25
|
+
allEvents: events
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getMetadata(events) {
|
|
30
|
+
const firstUserMessage = events.find(e => e.type === 'user');
|
|
31
|
+
if (!firstUserMessage) return null;
|
|
32
|
+
|
|
33
|
+
// Extract model from assistant messages
|
|
34
|
+
const firstAssistant = events.find(e => e.type === 'assistant' && e.message?.model);
|
|
35
|
+
const model = firstAssistant?.message?.model || 'unknown';
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
sessionId: firstUserMessage.sessionId,
|
|
39
|
+
startTime: firstUserMessage.timestamp,
|
|
40
|
+
model: model,
|
|
41
|
+
version: firstUserMessage.version,
|
|
42
|
+
producer: 'claude-code',
|
|
43
|
+
cwd: firstUserMessage.cwd,
|
|
44
|
+
gitRoot: null, // Not available in Claude format
|
|
45
|
+
branch: firstUserMessage.gitBranch,
|
|
46
|
+
repository: null // Not available in Claude format
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
extractTurns(events) {
|
|
51
|
+
const turns = [];
|
|
52
|
+
const messageEvents = events.filter(e =>
|
|
53
|
+
['user', 'assistant'].includes(e.type) &&
|
|
54
|
+
e.type !== 'file-history-snapshot' &&
|
|
55
|
+
e.type !== 'queue-operation'
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Build parent-child tree
|
|
59
|
+
const eventMap = new Map();
|
|
60
|
+
for (const event of messageEvents) {
|
|
61
|
+
eventMap.set(event.uuid, event);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Find root user messages (no parentUuid or parent is not a message)
|
|
65
|
+
const rootMessages = messageEvents.filter(e =>
|
|
66
|
+
e.type === 'user' && (!e.parentUuid || !eventMap.has(e.parentUuid))
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
for (const userMsg of rootMessages) {
|
|
70
|
+
const turn = {
|
|
71
|
+
turnId: userMsg.uuid,
|
|
72
|
+
userMessage: {
|
|
73
|
+
id: userMsg.uuid,
|
|
74
|
+
content: this._extractMessageContent(userMsg.message),
|
|
75
|
+
timestamp: userMsg.timestamp
|
|
76
|
+
},
|
|
77
|
+
assistantMessages: [],
|
|
78
|
+
toolCalls: []
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Find all children of this user message
|
|
82
|
+
this._collectAssistantResponses(userMsg.uuid, eventMap, turn);
|
|
83
|
+
|
|
84
|
+
turns.push(turn);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return turns;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
_collectAssistantResponses(parentUuid, eventMap, turn) {
|
|
91
|
+
for (const [_uuid, event] of eventMap.entries()) {
|
|
92
|
+
if (event.parentUuid === parentUuid) {
|
|
93
|
+
if (event.type === 'assistant') {
|
|
94
|
+
const assistantMsg = {
|
|
95
|
+
id: event.uuid,
|
|
96
|
+
messageId: event.message?.id,
|
|
97
|
+
content: this._extractMessageContent(event.message),
|
|
98
|
+
model: event.message?.model,
|
|
99
|
+
timestamp: event.timestamp
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Extract tool calls from content
|
|
103
|
+
const toolUseBlocks = this._extractToolUse(event.message);
|
|
104
|
+
if (toolUseBlocks.length > 0) {
|
|
105
|
+
assistantMsg.toolRequests = toolUseBlocks;
|
|
106
|
+
turn.toolCalls.push(...toolUseBlocks.map(t => ({
|
|
107
|
+
toolCallId: t.id,
|
|
108
|
+
name: t.name,
|
|
109
|
+
arguments: t.input,
|
|
110
|
+
parentUuid: event.uuid
|
|
111
|
+
})));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
turn.assistantMessages.push(assistantMsg);
|
|
115
|
+
|
|
116
|
+
// Recursively collect children (follow-up messages)
|
|
117
|
+
this._collectAssistantResponses(event.uuid, eventMap, turn);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_extractMessageContent(message) {
|
|
124
|
+
if (!message || !message.content) return '';
|
|
125
|
+
|
|
126
|
+
if (typeof message.content === 'string') {
|
|
127
|
+
return message.content;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (Array.isArray(message.content)) {
|
|
131
|
+
return message.content
|
|
132
|
+
.filter(block => block.type === 'text')
|
|
133
|
+
.map(block => block.text)
|
|
134
|
+
.join('\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return '';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_extractToolUse(message) {
|
|
141
|
+
if (!message || !message.content || !Array.isArray(message.content)) {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return message.content
|
|
146
|
+
.filter(block => block.type === 'tool_use')
|
|
147
|
+
.map(block => ({
|
|
148
|
+
id: block.id,
|
|
149
|
+
name: block.name,
|
|
150
|
+
input: block.input
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
extractToolCalls(events) {
|
|
155
|
+
const toolCalls = [];
|
|
156
|
+
|
|
157
|
+
for (const event of events) {
|
|
158
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
159
|
+
const toolUseBlocks = this._extractToolUse(event.message);
|
|
160
|
+
|
|
161
|
+
for (const tool of toolUseBlocks) {
|
|
162
|
+
toolCalls.push({
|
|
163
|
+
toolCallId: tool.id,
|
|
164
|
+
name: tool.name,
|
|
165
|
+
arguments: tool.input,
|
|
166
|
+
parentUuid: event.uuid,
|
|
167
|
+
timestamp: event.timestamp,
|
|
168
|
+
// Claude format doesn't have separate execution events
|
|
169
|
+
// Tool result would be in a following user message with tool_result
|
|
170
|
+
result: null,
|
|
171
|
+
exitCode: null
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return toolCalls;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = ClaudeSessionParser;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const BaseSessionParser = require('./base-parser');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Copilot CLI Session Parser
|
|
5
|
+
*
|
|
6
|
+
* Parses events from GitHub Copilot CLI (copilot-agent)
|
|
7
|
+
* Format: {type: "session.start", data: {...}, id: "...", parentId: "..."}
|
|
8
|
+
*/
|
|
9
|
+
class CopilotSessionParser extends BaseSessionParser {
|
|
10
|
+
canParse(events) {
|
|
11
|
+
if (!events || events.length === 0) return false;
|
|
12
|
+
|
|
13
|
+
// Check for Copilot CLI specific event types
|
|
14
|
+
const copilotEventTypes = [
|
|
15
|
+
'session.start',
|
|
16
|
+
'user.message',
|
|
17
|
+
'assistant.turn_start',
|
|
18
|
+
'assistant.message',
|
|
19
|
+
'tool.execution_start'
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
return events.some(e =>
|
|
23
|
+
e.type && copilotEventTypes.some(t => e.type.startsWith(t))
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
parse(events) {
|
|
28
|
+
return {
|
|
29
|
+
metadata: this.getMetadata(events),
|
|
30
|
+
turns: this.extractTurns(events),
|
|
31
|
+
toolCalls: this.extractToolCalls(events),
|
|
32
|
+
allEvents: events
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getMetadata(events) {
|
|
37
|
+
const sessionStart = events.find(e => e.type === 'session.start');
|
|
38
|
+
if (!sessionStart) return null;
|
|
39
|
+
|
|
40
|
+
const data = sessionStart.data || {};
|
|
41
|
+
return {
|
|
42
|
+
sessionId: data.sessionId,
|
|
43
|
+
startTime: data.startTime,
|
|
44
|
+
model: data.selectedModel,
|
|
45
|
+
version: data.copilotVersion,
|
|
46
|
+
producer: data.producer,
|
|
47
|
+
cwd: data.context?.cwd,
|
|
48
|
+
gitRoot: data.context?.gitRoot,
|
|
49
|
+
branch: data.context?.branch,
|
|
50
|
+
repository: data.context?.repository
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
extractTurns(events) {
|
|
55
|
+
const turns = [];
|
|
56
|
+
let currentTurn = null;
|
|
57
|
+
|
|
58
|
+
for (const event of events) {
|
|
59
|
+
if (event.type === 'user.message') {
|
|
60
|
+
// Start new turn
|
|
61
|
+
if (currentTurn) {
|
|
62
|
+
turns.push(currentTurn);
|
|
63
|
+
}
|
|
64
|
+
currentTurn = {
|
|
65
|
+
turnId: event.id,
|
|
66
|
+
userMessage: {
|
|
67
|
+
id: event.id,
|
|
68
|
+
content: event.data?.content || '',
|
|
69
|
+
transformedContent: event.data?.transformedContent,
|
|
70
|
+
timestamp: event.timestamp
|
|
71
|
+
},
|
|
72
|
+
assistantMessages: [],
|
|
73
|
+
toolCalls: []
|
|
74
|
+
};
|
|
75
|
+
} else if (event.type === 'assistant.message' && currentTurn) {
|
|
76
|
+
currentTurn.assistantMessages.push({
|
|
77
|
+
id: event.id,
|
|
78
|
+
messageId: event.data?.messageId,
|
|
79
|
+
content: event.data?.content || '',
|
|
80
|
+
toolRequests: event.data?.toolRequests || [],
|
|
81
|
+
reasoningText: event.data?.reasoningText,
|
|
82
|
+
timestamp: event.timestamp
|
|
83
|
+
});
|
|
84
|
+
} else if (event.type.startsWith('tool.execution_') && currentTurn) {
|
|
85
|
+
const toolCallId = event.data?.toolCallId;
|
|
86
|
+
let toolCall = currentTurn.toolCalls.find(tc => tc.toolCallId === toolCallId);
|
|
87
|
+
|
|
88
|
+
if (!toolCall) {
|
|
89
|
+
toolCall = { toolCallId, events: [] };
|
|
90
|
+
currentTurn.toolCalls.push(toolCall);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
toolCall.events.push(event);
|
|
94
|
+
|
|
95
|
+
if (event.type === 'tool.execution_start') {
|
|
96
|
+
toolCall.name = event.data?.toolName;
|
|
97
|
+
toolCall.arguments = event.data?.arguments;
|
|
98
|
+
} else if (event.type === 'tool.execution_complete') {
|
|
99
|
+
toolCall.result = event.data?.result;
|
|
100
|
+
toolCall.exitCode = event.data?.exitCode;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Push last turn
|
|
106
|
+
if (currentTurn) {
|
|
107
|
+
turns.push(currentTurn);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return turns;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
extractToolCalls(events) {
|
|
114
|
+
const toolCalls = [];
|
|
115
|
+
const toolCallMap = new Map();
|
|
116
|
+
|
|
117
|
+
for (const event of events) {
|
|
118
|
+
if (event.type === 'tool.execution_start') {
|
|
119
|
+
const toolCallId = event.data?.toolCallId;
|
|
120
|
+
toolCallMap.set(toolCallId, {
|
|
121
|
+
toolCallId,
|
|
122
|
+
name: event.data?.toolName,
|
|
123
|
+
arguments: event.data?.arguments,
|
|
124
|
+
startEvent: event,
|
|
125
|
+
completeEvent: null
|
|
126
|
+
});
|
|
127
|
+
} else if (event.type === 'tool.execution_complete') {
|
|
128
|
+
const toolCallId = event.data?.toolCallId;
|
|
129
|
+
const toolCall = toolCallMap.get(toolCallId);
|
|
130
|
+
if (toolCall) {
|
|
131
|
+
toolCall.completeEvent = event;
|
|
132
|
+
toolCall.result = event.data?.result;
|
|
133
|
+
toolCall.exitCode = event.data?.exitCode;
|
|
134
|
+
toolCalls.push(toolCall);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return toolCalls;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = CopilotSessionParser;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const BaseSessionParser = require('./base-parser');
|
|
2
|
+
const CopilotSessionParser = require('./copilot-parser');
|
|
3
|
+
const ClaudeSessionParser = require('./claude-parser');
|
|
4
|
+
const PiMonoParser = require('./pi-mono-parser');
|
|
5
|
+
const ParserFactory = require('./parser-factory');
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
BaseSessionParser,
|
|
9
|
+
CopilotSessionParser,
|
|
10
|
+
ClaudeSessionParser,
|
|
11
|
+
PiMonoParser,
|
|
12
|
+
ParserFactory
|
|
13
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const CopilotSessionParser = require('./copilot-parser');
|
|
2
|
+
const ClaudeSessionParser = require('./claude-parser');
|
|
3
|
+
const PiMonoParser = require('./pi-mono-parser');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parser Factory
|
|
7
|
+
*
|
|
8
|
+
* Automatically detects the session format and returns the appropriate parser
|
|
9
|
+
*/
|
|
10
|
+
class ParserFactory {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.parsers = [
|
|
13
|
+
new CopilotSessionParser(),
|
|
14
|
+
new ClaudeSessionParser(),
|
|
15
|
+
new PiMonoParser()
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// Name-to-parser mapping
|
|
19
|
+
this.parserMap = {
|
|
20
|
+
'copilot': new CopilotSessionParser(),
|
|
21
|
+
'claude': new ClaudeSessionParser(),
|
|
22
|
+
'pi-mono': new PiMonoParser()
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get parser by name or auto-detect from events
|
|
28
|
+
* @param {string|Array<Object>} nameOrEvents - Parser name or events array
|
|
29
|
+
* @returns {BaseSessionParser|null}
|
|
30
|
+
*/
|
|
31
|
+
getParser(nameOrEvents) {
|
|
32
|
+
// If it's a string, get parser by name
|
|
33
|
+
if (typeof nameOrEvents === 'string') {
|
|
34
|
+
return this.parserMap[nameOrEvents] || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Otherwise, auto-detect from events
|
|
38
|
+
const events = nameOrEvents;
|
|
39
|
+
for (const parser of this.parsers) {
|
|
40
|
+
if (parser.canParse(events)) {
|
|
41
|
+
return parser;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse events using the appropriate parser
|
|
49
|
+
* @param {Array<Object>} events - Raw events from jsonl
|
|
50
|
+
* @returns {Object|null} Parsed session data or null if no parser found
|
|
51
|
+
*/
|
|
52
|
+
parse(events) {
|
|
53
|
+
const parser = this.getParser(events);
|
|
54
|
+
if (!parser) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return parser.parse(events);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get parser type name
|
|
62
|
+
* @param {Array<Object>} events - Raw events from jsonl
|
|
63
|
+
* @returns {string|null} Parser name ('copilot', 'claude', 'pi-mono') or null
|
|
64
|
+
*/
|
|
65
|
+
getParserType(events) {
|
|
66
|
+
const parser = this.getParser(events);
|
|
67
|
+
if (!parser) return null;
|
|
68
|
+
|
|
69
|
+
if (parser instanceof CopilotSessionParser) return 'copilot';
|
|
70
|
+
if (parser instanceof ClaudeSessionParser) return 'claude';
|
|
71
|
+
if (parser instanceof PiMonoParser) return 'pi-mono';
|
|
72
|
+
|
|
73
|
+
return 'unknown';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = ParserFactory;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi-Mono session parser
|
|
3
|
+
* Parses ~/.pi/agent/sessions/ format
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const BaseParser = require('./base-parser');
|
|
7
|
+
|
|
8
|
+
class PiMonoParser extends BaseParser {
|
|
9
|
+
constructor() {
|
|
10
|
+
super('pi-mono');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse Pi-Mono session directory
|
|
15
|
+
* @param {string} sessionDir - e.g., ~/.pi/agent/sessions/--project-path--/
|
|
16
|
+
* @returns {Object|null} - Session metadata or null if invalid
|
|
17
|
+
*/
|
|
18
|
+
async parseSessionDir(sessionDir) {
|
|
19
|
+
const fs = require('fs').promises;
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
// List .jsonl files in directory
|
|
24
|
+
const entries = await fs.readdir(sessionDir);
|
|
25
|
+
const jsonlFiles = entries.filter(f => f.endsWith('.jsonl'));
|
|
26
|
+
|
|
27
|
+
if (jsonlFiles.length === 0) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Use the latest file for metadata
|
|
32
|
+
jsonlFiles.sort().reverse();
|
|
33
|
+
const latestFile = path.join(sessionDir, jsonlFiles[0]);
|
|
34
|
+
const firstLine = await this._readFirstLine(latestFile);
|
|
35
|
+
|
|
36
|
+
if (!firstLine) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sessionEvent = JSON.parse(firstLine);
|
|
41
|
+
|
|
42
|
+
if (sessionEvent.type !== 'session') {
|
|
43
|
+
console.warn(`Pi-Mono file ${latestFile} doesn't start with session event`);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Extract project name from directory name
|
|
48
|
+
const dirName = path.basename(sessionDir);
|
|
49
|
+
const projectPath = dirName.replace(/^--/, '').replace(/--$/, '');
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
id: sessionEvent.id,
|
|
53
|
+
type: 'pi-mono',
|
|
54
|
+
source: 'pi-mono',
|
|
55
|
+
cwd: sessionEvent.cwd || projectPath,
|
|
56
|
+
createdAt: new Date(sessionEvent.timestamp),
|
|
57
|
+
updatedAt: new Date(sessionEvent.timestamp), // Will be updated when scanning all files
|
|
58
|
+
summary: `Pi-Mono: ${projectPath}`,
|
|
59
|
+
fileCount: jsonlFiles.length
|
|
60
|
+
};
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error(`Error parsing Pi-Mono session dir ${sessionDir}:`, err.message);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read first line of a file
|
|
69
|
+
*/
|
|
70
|
+
async _readFirstLine(filePath) {
|
|
71
|
+
const fs = require('fs');
|
|
72
|
+
const readline = require('readline');
|
|
73
|
+
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
76
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
77
|
+
|
|
78
|
+
rl.on('line', (line) => {
|
|
79
|
+
rl.close();
|
|
80
|
+
stream.destroy();
|
|
81
|
+
resolve(line.trim());
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
rl.on('close', () => resolve(null));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse Pi-Mono events from .jsonl file
|
|
90
|
+
* @param {string} filePath
|
|
91
|
+
* @returns {Array} - Array of parsed events
|
|
92
|
+
*/
|
|
93
|
+
async parseEvents(filePath) {
|
|
94
|
+
const fs = require('fs');
|
|
95
|
+
const readline = require('readline');
|
|
96
|
+
|
|
97
|
+
const events = [];
|
|
98
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
99
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
100
|
+
|
|
101
|
+
let lineIndex = 0;
|
|
102
|
+
for await (const line of rl) {
|
|
103
|
+
lineIndex++;
|
|
104
|
+
const trimmed = line.trim();
|
|
105
|
+
if (!trimmed) continue;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const event = JSON.parse(trimmed);
|
|
109
|
+
events.push(event);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error(`Error parsing Pi-Mono line ${lineIndex}:`, err.message);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return events;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = PiMonoParser;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qiaolei81/copilot-session-viewer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Web UI for viewing GitHub Copilot CLI session logs",
|
|
5
5
|
"author": "Lei Qiao <qiaolei81@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"test:e2e:headed": "playwright test --headed",
|
|
41
41
|
"test:e2e:debug": "playwright test --debug",
|
|
42
42
|
"test:all": "npm test && npm run test:e2e",
|
|
43
|
+
"test:coverage:all": "npm run test:coverage && npm run test:e2e",
|
|
43
44
|
"lint": "eslint .",
|
|
44
45
|
"lint:fix": "eslint . --fix",
|
|
45
46
|
"lint:check": "eslint . --max-warnings 0",
|
|
@@ -50,25 +51,32 @@
|
|
|
50
51
|
},
|
|
51
52
|
"dependencies": {
|
|
52
53
|
"compression": "^1.8.1",
|
|
53
|
-
"
|
|
54
|
+
"dompurify": "^3.3.1",
|
|
55
|
+
"ejs": "^4.0.1",
|
|
54
56
|
"express": "^4.18.2",
|
|
55
57
|
"express-rate-limit": "^8.2.1",
|
|
56
58
|
"helmet": "^8.1.0",
|
|
57
|
-
"multer": "^2.0.2"
|
|
59
|
+
"multer": "^2.0.2",
|
|
60
|
+
"zod": "^4.3.6"
|
|
58
61
|
},
|
|
59
62
|
"devDependencies": {
|
|
60
63
|
"@eslint/css": "^0.14.1",
|
|
61
64
|
"@eslint/js": "^10.0.1",
|
|
62
65
|
"@eslint/json": "^1.0.1",
|
|
63
66
|
"@eslint/markdown": "^7.5.1",
|
|
67
|
+
"@istanbuljs/nyc-config-typescript": "^1.0.2",
|
|
64
68
|
"@playwright/test": "^1.58.2",
|
|
65
69
|
"@types/jest": "^30.0.0",
|
|
66
70
|
"adm-zip": "^0.5.16",
|
|
67
71
|
"eslint": "^10.0.0",
|
|
68
72
|
"eslint-plugin-vue": "^10.8.0",
|
|
69
73
|
"globals": "^17.3.0",
|
|
74
|
+
"istanbul-lib-coverage": "^3.2.2",
|
|
70
75
|
"jest": "^30.2.0",
|
|
76
|
+
"jsdom": "^28.1.0",
|
|
71
77
|
"nodemon": "^3.0.1",
|
|
72
|
-
"
|
|
78
|
+
"nyc": "^17.1.0",
|
|
79
|
+
"supertest": "^7.2.2",
|
|
80
|
+
"v8-to-istanbul": "^9.3.0"
|
|
73
81
|
}
|
|
74
82
|
}
|
package/server.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
const path = require('path');
|
|
2
1
|
const createApp = require('./src/app');
|
|
3
2
|
const config = require('./src/config');
|
|
4
3
|
const processManager = require('./src/utils/processManager');
|
|
@@ -13,9 +12,25 @@ module.exports = app;
|
|
|
13
12
|
if (require.main === module) {
|
|
14
13
|
const server = app.listen(config.PORT, () => {
|
|
15
14
|
console.log(`🚀 Copilot Session Viewer running at http://localhost:${config.PORT}`);
|
|
16
|
-
console.log(
|
|
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)`);
|
|
17
20
|
console.log(`🔧 Environment: ${config.NODE_ENV}`);
|
|
18
21
|
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
|
+
});
|
|
19
34
|
});
|
|
20
35
|
|
|
21
36
|
// Graceful shutdown
|