@qiaolei81/copilot-session-viewer 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-be-responsive-on-mobile-viewport-1771605454041.json +435 -0
  2. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-display-sessions-if-available-1771605462872.json +435 -0
  3. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-handle-JavaScript-errors-gracefully-1771605463381.json +435 -0
  4. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-handle-session-import-dialog-1771605466264.json +435 -0
  5. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-have-working-infinite-scroll-elements-1771605454038.json +435 -0
  6. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-load-homepage-with-basic-elements-1771605454001.json +435 -0
  7. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-load-time-analysis-page-1771605464990.json +1236 -0
  8. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-navigate-to-session-detail-page-1771605472595.json +1177 -0
  9. package/.nyc_output/coverage-e2e-merged.json +1 -0
  10. package/.nyc_output/coverage-homepage-spec-js-Homepage-should-display-session-list-1771605453565.json +435 -0
  11. package/.nyc_output/coverage-homepage-spec-js-Homepage-should-load-homepage-successfully-1771605453552.json +435 -0
  12. package/.nyc_output/coverage-homepage-spec-js-Homepage-should-navigate-to-session-detail-on-click-1771605469317.json +1134 -0
  13. package/.nyc_output/coverage-homepage-spec-js-Homepage-should-show-session-metadata-1771605460581.json +435 -0
  14. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-display-Load-More-Sessions-button-when-there-are-more-sessions-1771605468486.json +435 -0
  15. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-handle-API-errors-gracefully-during-infinite-scroll-1771605482161.json +471 -0
  16. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-hide-Load-More-button-when-no-more-sessions-available-1771605478370.json +471 -0
  17. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-load-additional-sessions-when-Load-More-button-is-clicked-1771605475059.json +471 -0
  18. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-preserve-session-list-state-during-navigation-1771605494575.json +1633 -0
  19. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-show-loading-state-when-Load-More-button-is-clicked-1771605475401.json +471 -0
  20. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-trigger-infinite-scroll-when-scrolling-near-bottom-1771605476949.json +471 -0
  21. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-clear-search-filter-1771605508542.json +1255 -0
  22. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-display-event-list-1771605505572.json +1156 -0
  23. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-display-session-metadata-1771605504552.json +701 -0
  24. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-expand-and-collapse-tool-details-1771605515809.json +1182 -0
  25. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-filter-events-by-search-1771605513421.json +1245 -0
  26. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-load-session-detail-page-1771605494974.json +701 -0
  27. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-toggle-content-visibility-1771605550729.json +1177 -0
  28. package/.nyc_output/coverage-unit.json +21 -0
  29. package/.nycrc +29 -0
  30. package/CHANGELOG.md +36 -0
  31. package/README.md +154 -15
  32. package/examples/parser-usage.js +114 -0
  33. package/lib/parsers/README.md +239 -0
  34. package/lib/parsers/base-parser.js +53 -0
  35. package/lib/parsers/claude-parser.js +181 -0
  36. package/lib/parsers/copilot-parser.js +143 -0
  37. package/lib/parsers/index.js +13 -0
  38. package/lib/parsers/parser-factory.js +77 -0
  39. package/lib/parsers/pi-mono-parser.js +119 -0
  40. package/package.json +12 -4
  41. package/server.js +17 -2
  42. package/src/app.js +45 -20
  43. package/src/controllers/insightController.js +44 -8
  44. package/src/controllers/sessionController.js +217 -3
  45. package/src/controllers/uploadController.js +447 -7
  46. package/src/middleware/rateLimiting.js +7 -1
  47. package/src/models/Session.js +26 -0
  48. package/src/schemas/event.schema.js +73 -0
  49. package/src/services/eventNormalizer.js +291 -0
  50. package/src/services/insightService.js +140 -48
  51. package/src/services/sessionRepository.js +584 -49
  52. package/src/services/sessionService.js +1588 -36
  53. package/src/utils/helpers.js +6 -1
  54. package/views/index.ejs +111 -4
  55. package/views/session-vue.ejs +272 -65
  56. package/views/time-analyze.ejs +127 -55
@@ -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.1.9",
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
- "ejs": "^3.1.9",
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
- "supertest": "^7.2.2"
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(`📂 Monitoring: ${process.env.SESSION_DIR || path.join(require('os').homedir(), '.copilot', 'session-state')}`);
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