@qiaolei81/copilot-session-viewer 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bin/copilot-session-viewer +2 -2
- package/dist/server.min.js +99 -0
- package/package.json +5 -17
- package/public/js/homepage.min.js +9 -9
- package/public/js/session-detail.min.js +36 -7
- package/public/vendor/marked.umd.min.js +8 -0
- package/public/vendor/purify.min.js +3 -0
- package/public/vendor/vue-virtual-scroller.css +1 -0
- package/public/vendor/vue-virtual-scroller.min.js +2 -0
- package/public/vendor/vue.global.prod.min.js +19 -0
- package/views/session-vue.ejs +31 -6
- package/views/time-analyze.ejs +2 -2
- package/lib/parsers/README.md +0 -239
- package/lib/parsers/base-parser.js +0 -53
- package/lib/parsers/claude-parser.js +0 -181
- package/lib/parsers/copilot-parser.js +0 -143
- package/lib/parsers/index.js +0 -15
- package/lib/parsers/parser-factory.js +0 -77
- package/lib/parsers/pi-mono-parser.js +0 -119
- package/lib/parsers/vscode-parser.js +0 -591
- package/server.js +0 -29
- package/src/app.js +0 -129
- package/src/config/index.js +0 -27
- package/src/controllers/insightController.js +0 -136
- package/src/controllers/sessionController.js +0 -449
- package/src/controllers/tagController.js +0 -113
- package/src/controllers/uploadController.js +0 -648
- package/src/middleware/common.js +0 -67
- package/src/middleware/rateLimiting.js +0 -62
- package/src/models/Session.js +0 -146
- package/src/routes/api.js +0 -11
- package/src/routes/insights.js +0 -12
- package/src/routes/pages.js +0 -12
- package/src/routes/uploads.js +0 -14
- package/src/schemas/event.schema.js +0 -73
- package/src/services/eventNormalizer.js +0 -291
- package/src/services/insightService.js +0 -535
- package/src/services/sessionRepository.js +0 -1092
- package/src/services/sessionService.js +0 -1919
- package/src/services/tagService.js +0 -205
- package/src/telemetry.js +0 -152
- package/src/utils/fileUtils.js +0 -305
- package/src/utils/helpers.js +0 -45
- package/src/utils/processManager.js +0 -85
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
const fs = require('fs').promises;
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Service for managing session tags
|
|
7
|
-
* - Per-session tags: stored in {session.directory}/tags.json as ["tag1", "tag2"]
|
|
8
|
-
* - Global known tags: stored in ~/.session-viewer/known-tags.json as ["tag1", "tag2", ...]
|
|
9
|
-
*/
|
|
10
|
-
class TagService {
|
|
11
|
-
constructor() {
|
|
12
|
-
this.knownTagsDir = path.join(os.homedir(), '.session-viewer');
|
|
13
|
-
this.knownTagsFilePath = path.join(this.knownTagsDir, 'known-tags.json');
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Ensure known-tags directory and file exist
|
|
18
|
-
*/
|
|
19
|
-
async ensureKnownTagsFile() {
|
|
20
|
-
try {
|
|
21
|
-
await fs.access(this.knownTagsFilePath);
|
|
22
|
-
} catch (err) {
|
|
23
|
-
// File doesn't exist, create directory and empty array
|
|
24
|
-
await fs.mkdir(this.knownTagsDir, { recursive: true });
|
|
25
|
-
await fs.writeFile(this.knownTagsFilePath, JSON.stringify([]), 'utf8');
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Read known tags from global file
|
|
31
|
-
* @returns {Promise<string[]>} Array of known tags
|
|
32
|
-
*/
|
|
33
|
-
async readKnownTagsFile() {
|
|
34
|
-
await this.ensureKnownTagsFile();
|
|
35
|
-
try {
|
|
36
|
-
const content = await fs.readFile(this.knownTagsFilePath, 'utf8');
|
|
37
|
-
return JSON.parse(content);
|
|
38
|
-
} catch (err) {
|
|
39
|
-
console.error('Error reading known tags file:', err);
|
|
40
|
-
return [];
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Write known tags to global file
|
|
46
|
-
* @param {string[]} tags - Array of known tags
|
|
47
|
-
*/
|
|
48
|
-
async writeKnownTagsFile(tags) {
|
|
49
|
-
await this.ensureKnownTagsFile();
|
|
50
|
-
await fs.writeFile(this.knownTagsFilePath, JSON.stringify(tags, null, 2), 'utf8');
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Get tags file path for a session
|
|
55
|
-
* @param {Session} session - Session object with directory field
|
|
56
|
-
* @returns {string} Path to tags.json
|
|
57
|
-
*/
|
|
58
|
-
getSessionTagsFilePath(session) {
|
|
59
|
-
// File-based sessions (e.g. Claude .jsonl): per-file tags to avoid sharing
|
|
60
|
-
if (session.filePath) {
|
|
61
|
-
const dir = path.dirname(session.filePath);
|
|
62
|
-
const base = path.basename(session.filePath, path.extname(session.filePath));
|
|
63
|
-
return path.join(dir, `${base}.tags.json`);
|
|
64
|
-
}
|
|
65
|
-
// Directory-based sessions (e.g. Copilot CLI): tags.json inside session dir
|
|
66
|
-
if (session.directory) {
|
|
67
|
-
return path.join(session.directory, 'tags.json');
|
|
68
|
-
}
|
|
69
|
-
// Fallback: store in central location by session id
|
|
70
|
-
return path.join(this.knownTagsDir, 'session-tags', `${session.id}.tags.json`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Normalize tag name (lowercase, trim, max 30 chars)
|
|
75
|
-
* @param {string} tag - Tag name
|
|
76
|
-
* @returns {string} Normalized tag
|
|
77
|
-
*/
|
|
78
|
-
normalizeTag(tag) {
|
|
79
|
-
return tag.trim().toLowerCase().substring(0, 30);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Get all known tags for autocomplete
|
|
84
|
-
* @returns {Promise<string[]>} Array of unique tags
|
|
85
|
-
*/
|
|
86
|
-
async getAllKnownTags() {
|
|
87
|
-
const tags = await this.readKnownTagsFile();
|
|
88
|
-
return tags.sort();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Get tags for a specific session
|
|
93
|
-
* @param {Session} session - Session object with directory field
|
|
94
|
-
* @returns {Promise<string[]>} Array of tags
|
|
95
|
-
*/
|
|
96
|
-
async getSessionTags(session) {
|
|
97
|
-
const tagsFilePath = this.getSessionTagsFilePath(session);
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
await fs.access(tagsFilePath);
|
|
101
|
-
const content = await fs.readFile(tagsFilePath, 'utf8');
|
|
102
|
-
return JSON.parse(content);
|
|
103
|
-
} catch (err) {
|
|
104
|
-
// File doesn't exist or can't be read, return empty array
|
|
105
|
-
return [];
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Set tags for a specific session
|
|
111
|
-
* @param {Session} session - Session object with directory field
|
|
112
|
-
* @param {string[]} tags - Array of tag names
|
|
113
|
-
* @returns {Promise<string[]>} Normalized and saved tags
|
|
114
|
-
*/
|
|
115
|
-
async setSessionTags(session, tags) {
|
|
116
|
-
if (!Array.isArray(tags)) {
|
|
117
|
-
throw new Error('Tags must be an array');
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Normalize tags (lowercase, trim, max 30 chars)
|
|
121
|
-
const normalizedTags = tags
|
|
122
|
-
.map(tag => this.normalizeTag(tag))
|
|
123
|
-
.filter(tag => tag.length > 0)
|
|
124
|
-
.filter((tag, index, self) => self.indexOf(tag) === index); // Remove duplicates
|
|
125
|
-
|
|
126
|
-
// Enforce max 10 tags per session
|
|
127
|
-
if (normalizedTags.length > 10) {
|
|
128
|
-
throw new Error('Maximum 10 tags per session');
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const tagsFilePath = this.getSessionTagsFilePath(session);
|
|
132
|
-
|
|
133
|
-
if (normalizedTags.length === 0) {
|
|
134
|
-
// Remove tags file if no tags
|
|
135
|
-
try {
|
|
136
|
-
await fs.unlink(tagsFilePath);
|
|
137
|
-
} catch (err) {
|
|
138
|
-
// File doesn't exist, ignore
|
|
139
|
-
}
|
|
140
|
-
} else {
|
|
141
|
-
// Ensure directory exists for tags file
|
|
142
|
-
await fs.mkdir(path.dirname(tagsFilePath), { recursive: true });
|
|
143
|
-
// Write tags to session directory
|
|
144
|
-
await fs.writeFile(tagsFilePath, JSON.stringify(normalizedTags, null, 2), 'utf8');
|
|
145
|
-
|
|
146
|
-
// Update known tags (append and deduplicate)
|
|
147
|
-
await this.updateKnownTags(normalizedTags);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return normalizedTags;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Update known tags by appending new tags and deduplicating
|
|
155
|
-
* @param {string[]} newTags - New tags to add to known tags
|
|
156
|
-
*/
|
|
157
|
-
async updateKnownTags(newTags) {
|
|
158
|
-
const knownTags = await this.readKnownTagsFile();
|
|
159
|
-
const allTags = [...knownTags, ...newTags];
|
|
160
|
-
const uniqueTags = [...new Set(allTags)];
|
|
161
|
-
await this.writeKnownTagsFile(uniqueTags);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Add tags to a session (merge with existing)
|
|
166
|
-
* @param {Session} session - Session object with directory field
|
|
167
|
-
* @param {string[]} newTags - Tags to add
|
|
168
|
-
* @returns {Promise<string[]>} Updated tags array
|
|
169
|
-
*/
|
|
170
|
-
async addSessionTags(session, newTags) {
|
|
171
|
-
const existingTags = await this.getSessionTags(session);
|
|
172
|
-
const mergedTags = [...existingTags, ...newTags];
|
|
173
|
-
return await this.setSessionTags(session, mergedTags);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Remove tags from a session
|
|
178
|
-
* @param {Session} session - Session object with directory field
|
|
179
|
-
* @param {string[]} tagsToRemove - Tags to remove
|
|
180
|
-
* @returns {Promise<string[]>} Updated tags array
|
|
181
|
-
*/
|
|
182
|
-
async removeSessionTags(session, tagsToRemove) {
|
|
183
|
-
const existingTags = await this.getSessionTags(session);
|
|
184
|
-
const normalizedToRemove = tagsToRemove.map(tag => this.normalizeTag(tag));
|
|
185
|
-
const updatedTags = existingTags.filter(tag => !normalizedToRemove.includes(tag));
|
|
186
|
-
return await this.setSessionTags(session, updatedTags);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Get tags for multiple sessions (batch)
|
|
191
|
-
* @param {Session[]} sessions - Array of session objects
|
|
192
|
-
* @returns {Promise<Object>} Map of sessionId -> tags array
|
|
193
|
-
*/
|
|
194
|
-
async getMultipleSessionTags(sessions) {
|
|
195
|
-
const result = {};
|
|
196
|
-
|
|
197
|
-
for (const session of sessions) {
|
|
198
|
-
result[session.id] = await this.getSessionTags(session);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
return result;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
module.exports = TagService;
|
package/src/telemetry.js
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Application Insights Telemetry Module
|
|
3
|
-
*
|
|
4
|
-
* This module initializes and configures Application Insights for telemetry tracking.
|
|
5
|
-
* Must be required BEFORE any other modules (especially Express) in server.js.
|
|
6
|
-
*
|
|
7
|
-
* Features:
|
|
8
|
-
* - Auto-collection of requests, dependencies, exceptions, and performance counters
|
|
9
|
-
* - Custom event and metric tracking
|
|
10
|
-
* - Automatic disabling in test environments
|
|
11
|
-
* - Support for manual disabling via DISABLE_TELEMETRY env var
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const appInsights = require('applicationinsights');
|
|
15
|
-
|
|
16
|
-
// Determine if telemetry should be disabled
|
|
17
|
-
const isTestEnvironment = process.env.NODE_ENV === 'test';
|
|
18
|
-
const isDisabled = process.env.DISABLE_TELEMETRY === 'true' || isTestEnvironment;
|
|
19
|
-
|
|
20
|
-
// Default connection string (can be overridden via env var)
|
|
21
|
-
const DEFAULT_CONNECTION_STRING = 'InstrumentationKey=39f4fbf1-d82f-42c3-b4ef-ea92a1fd82cb;IngestionEndpoint=https://eastus-8.in.applicationinsights.azure.com/;LiveEndpoint=https://eastus.livediagnostics.monitor.azure.com/;ApplicationId=7d4bb432-f2f5-4526-a5e6-31901e5a2db2';
|
|
22
|
-
|
|
23
|
-
let client = null;
|
|
24
|
-
|
|
25
|
-
if (!isDisabled) {
|
|
26
|
-
try {
|
|
27
|
-
// Get connection string from environment or use default
|
|
28
|
-
const connectionString = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING || DEFAULT_CONNECTION_STRING;
|
|
29
|
-
|
|
30
|
-
// Setup and start Application Insights
|
|
31
|
-
appInsights.setup(connectionString)
|
|
32
|
-
.setAutoDependencyCorrelation(true)
|
|
33
|
-
.setAutoCollectRequests(true)
|
|
34
|
-
.setAutoCollectPerformance(true, true)
|
|
35
|
-
.setAutoCollectExceptions(true)
|
|
36
|
-
.setAutoCollectDependencies(true)
|
|
37
|
-
.setAutoCollectConsole(false) // Disable console tracking to avoid noise
|
|
38
|
-
.setUseDiskRetryCaching(true)
|
|
39
|
-
.setSendLiveMetrics(false) // Disable live metrics for local dev tool
|
|
40
|
-
.setDistributedTracingMode(appInsights.DistributedTracingModes.AI_AND_W3C)
|
|
41
|
-
.start();
|
|
42
|
-
|
|
43
|
-
client = appInsights.defaultClient;
|
|
44
|
-
|
|
45
|
-
// Set context properties
|
|
46
|
-
client.context.tags[client.context.keys.cloudRole] = 'copilot-session-viewer';
|
|
47
|
-
client.context.tags[client.context.keys.cloudRoleInstance] = require('os').hostname();
|
|
48
|
-
|
|
49
|
-
console.log('✅ Application Insights telemetry initialized');
|
|
50
|
-
} catch (error) {
|
|
51
|
-
console.error('❌ Failed to initialize Application Insights:', error.message);
|
|
52
|
-
// Continue without telemetry rather than crashing
|
|
53
|
-
client = createNoOpClient();
|
|
54
|
-
}
|
|
55
|
-
} else {
|
|
56
|
-
// Return no-op client for test environment or when disabled
|
|
57
|
-
client = createNoOpClient();
|
|
58
|
-
|
|
59
|
-
if (isTestEnvironment) {
|
|
60
|
-
console.log('📊 Telemetry disabled (test environment)');
|
|
61
|
-
} else {
|
|
62
|
-
console.log('📊 Telemetry disabled (DISABLE_TELEMETRY=true)');
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Creates a no-op client that safely ignores all telemetry calls
|
|
68
|
-
* Used when telemetry is disabled or in test environments
|
|
69
|
-
*/
|
|
70
|
-
function createNoOpClient() {
|
|
71
|
-
return {
|
|
72
|
-
trackEvent: () => {},
|
|
73
|
-
trackMetric: () => {},
|
|
74
|
-
trackException: () => {},
|
|
75
|
-
trackTrace: () => {},
|
|
76
|
-
trackDependency: () => {},
|
|
77
|
-
trackRequest: () => {},
|
|
78
|
-
flush: (callback) => {
|
|
79
|
-
if (callback) callback();
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Track a custom event
|
|
86
|
-
* @param {string} name - Event name
|
|
87
|
-
* @param {Object} properties - Event properties
|
|
88
|
-
*/
|
|
89
|
-
function trackEvent(name, properties = {}) {
|
|
90
|
-
if (client && client.trackEvent) {
|
|
91
|
-
client.trackEvent({
|
|
92
|
-
name,
|
|
93
|
-
properties
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Track a custom metric
|
|
100
|
-
* @param {string} name - Metric name
|
|
101
|
-
* @param {number} value - Metric value
|
|
102
|
-
* @param {Object} properties - Additional properties
|
|
103
|
-
*/
|
|
104
|
-
function trackMetric(name, value, properties = {}) {
|
|
105
|
-
if (client && client.trackMetric) {
|
|
106
|
-
client.trackMetric({
|
|
107
|
-
name,
|
|
108
|
-
value,
|
|
109
|
-
properties
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Track an exception
|
|
116
|
-
* @param {Error} error - Error object
|
|
117
|
-
* @param {Object} properties - Additional properties
|
|
118
|
-
*/
|
|
119
|
-
function trackException(error, properties = {}) {
|
|
120
|
-
if (client && client.trackException) {
|
|
121
|
-
client.trackException({
|
|
122
|
-
exception: error,
|
|
123
|
-
properties
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Flush telemetry data (useful for short-lived processes)
|
|
130
|
-
* @returns {Promise<void>}
|
|
131
|
-
*/
|
|
132
|
-
function flush() {
|
|
133
|
-
return new Promise((resolve) => {
|
|
134
|
-
if (client && client.flush) {
|
|
135
|
-
client.flush({
|
|
136
|
-
callback: () => resolve()
|
|
137
|
-
});
|
|
138
|
-
} else {
|
|
139
|
-
resolve();
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Export the client and helper functions
|
|
145
|
-
module.exports = {
|
|
146
|
-
client,
|
|
147
|
-
trackEvent,
|
|
148
|
-
trackMetric,
|
|
149
|
-
trackException,
|
|
150
|
-
flush,
|
|
151
|
-
isEnabled: !isDisabled
|
|
152
|
-
};
|
package/src/utils/fileUtils.js
DELETED
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
const fs = require('fs').promises;
|
|
2
|
-
const fsSync = require('fs');
|
|
3
|
-
const readline = require('readline');
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* File utility functions
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Check if a file exists
|
|
11
|
-
* @param {string} filePath - File path
|
|
12
|
-
* @returns {Promise<boolean>}
|
|
13
|
-
*/
|
|
14
|
-
async function fileExists(filePath) {
|
|
15
|
-
try {
|
|
16
|
-
await fs.access(filePath);
|
|
17
|
-
return true;
|
|
18
|
-
} catch {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Count lines in a file (non-empty lines only)
|
|
25
|
-
* @param {string} filePath - File path
|
|
26
|
-
* @returns {Promise<number>}
|
|
27
|
-
*/
|
|
28
|
-
async function countLines(filePath) {
|
|
29
|
-
try {
|
|
30
|
-
const stream = fsSync.createReadStream(filePath, { encoding: 'utf-8' });
|
|
31
|
-
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
32
|
-
let count = 0;
|
|
33
|
-
for await (const line of rl) {
|
|
34
|
-
if (line.trim()) count++;
|
|
35
|
-
}
|
|
36
|
-
return count;
|
|
37
|
-
} catch (err) {
|
|
38
|
-
console.error(`Error counting lines in ${filePath}:`, err.message);
|
|
39
|
-
return 0;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Read and parse YAML file (simple key: value format)
|
|
45
|
-
* @param {string} filePath - YAML file path
|
|
46
|
-
* @returns {Promise<object>}
|
|
47
|
-
*/
|
|
48
|
-
async function parseYAML(filePath) {
|
|
49
|
-
try {
|
|
50
|
-
const content = await fs.readFile(filePath, 'utf-8');
|
|
51
|
-
const lines = content.split('\n');
|
|
52
|
-
const result = {};
|
|
53
|
-
|
|
54
|
-
for (const line of lines) {
|
|
55
|
-
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
56
|
-
if (match) {
|
|
57
|
-
result[match[1]] = match[2].trim();
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return result;
|
|
62
|
-
} catch (err) {
|
|
63
|
-
console.error(`Error parsing YAML ${filePath}:`, err.message);
|
|
64
|
-
return {};
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Efficiently read session metadata in a single pass
|
|
70
|
-
* Combines getFirstUserMessage, getSessionDuration, getSessionMetadata
|
|
71
|
-
* @param {string} filePath - Path to .jsonl file
|
|
72
|
-
* @param {number} maxMessageLength - Max characters for first message (default 200)
|
|
73
|
-
* @returns {Promise<Object>} Combined metadata object
|
|
74
|
-
*/
|
|
75
|
-
async function getSessionMetadataOptimized(filePath, maxMessageLength = 200) {
|
|
76
|
-
try {
|
|
77
|
-
const stream = fsSync.createReadStream(filePath, { encoding: 'utf-8' });
|
|
78
|
-
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
79
|
-
|
|
80
|
-
let firstUserMessage = '';
|
|
81
|
-
let firstTimestamp = null;
|
|
82
|
-
let lastTimestamp = null;
|
|
83
|
-
let copilotVersion = null;
|
|
84
|
-
let selectedModel = null;
|
|
85
|
-
let hasSessionEnd = false;
|
|
86
|
-
|
|
87
|
-
for await (const line of rl) {
|
|
88
|
-
if (!line.trim()) continue;
|
|
89
|
-
|
|
90
|
-
try {
|
|
91
|
-
const event = JSON.parse(line);
|
|
92
|
-
|
|
93
|
-
// Extract timestamp for duration calculation
|
|
94
|
-
if (event.timestamp) {
|
|
95
|
-
const ts = new Date(event.timestamp).getTime();
|
|
96
|
-
if (!isNaN(ts)) {
|
|
97
|
-
if (!firstTimestamp) firstTimestamp = ts;
|
|
98
|
-
lastTimestamp = ts;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Get first user message
|
|
103
|
-
if (!firstUserMessage && event.type === 'user.message') {
|
|
104
|
-
const msg = event.data?.message || event.data?.content || event.data?.text || '';
|
|
105
|
-
if (msg) {
|
|
106
|
-
firstUserMessage = msg.length > maxMessageLength ? msg.substring(0, maxMessageLength) + '...' : msg;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Check for session.end event
|
|
111
|
-
if (event.type === 'session.end') {
|
|
112
|
-
hasSessionEnd = true;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Get copilot version from session start
|
|
116
|
-
if (event.type === 'session.start' && event.data?.copilotVersion && !copilotVersion) {
|
|
117
|
-
copilotVersion = event.data.copilotVersion;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Get selected model
|
|
121
|
-
if ((event.type === 'session.start' || event.type === 'session.model_change') && !selectedModel) {
|
|
122
|
-
if (event.data?.selectedModel) {
|
|
123
|
-
selectedModel = event.data.selectedModel;
|
|
124
|
-
} else if (event.data?.newModel) {
|
|
125
|
-
selectedModel = event.data.newModel;
|
|
126
|
-
} else if (event.data?.model) {
|
|
127
|
-
selectedModel = event.data.model;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
} catch {
|
|
131
|
-
// Skip malformed JSON lines
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
rl.close();
|
|
136
|
-
stream.destroy();
|
|
137
|
-
|
|
138
|
-
// Calculate duration
|
|
139
|
-
const duration = firstTimestamp && lastTimestamp && lastTimestamp > firstTimestamp
|
|
140
|
-
? lastTimestamp - firstTimestamp
|
|
141
|
-
: null;
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
firstUserMessage: firstUserMessage || '',
|
|
145
|
-
duration,
|
|
146
|
-
copilotVersion: copilotVersion || null,
|
|
147
|
-
selectedModel: selectedModel || null,
|
|
148
|
-
hasSessionEnd,
|
|
149
|
-
lastEventTime: lastTimestamp,
|
|
150
|
-
firstEventTime: firstTimestamp
|
|
151
|
-
};
|
|
152
|
-
} catch (err) {
|
|
153
|
-
console.error(`Error reading session metadata from ${filePath}:`, err.message);
|
|
154
|
-
return {
|
|
155
|
-
firstUserMessage: '',
|
|
156
|
-
duration: null,
|
|
157
|
-
copilotVersion: null,
|
|
158
|
-
selectedModel: null,
|
|
159
|
-
hasSessionEnd: false,
|
|
160
|
-
lastEventTime: null
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Get the first user message from a .jsonl events file
|
|
167
|
-
* Reads line by line and stops at the first user.message event
|
|
168
|
-
* @param {string} filePath - Path to .jsonl file
|
|
169
|
-
* @param {number} maxLength - Max characters to return (default 200)
|
|
170
|
-
* @returns {Promise<string>} First user message or empty string
|
|
171
|
-
*/
|
|
172
|
-
async function getFirstUserMessage(filePath, maxLength = 200) {
|
|
173
|
-
try {
|
|
174
|
-
const stream = fsSync.createReadStream(filePath, { encoding: 'utf-8' });
|
|
175
|
-
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
176
|
-
|
|
177
|
-
for await (const line of rl) {
|
|
178
|
-
if (!line.trim()) continue;
|
|
179
|
-
try {
|
|
180
|
-
const event = JSON.parse(line);
|
|
181
|
-
if (event.type === 'user.message') {
|
|
182
|
-
const msg = event.data?.message || event.data?.content || event.data?.text || '';
|
|
183
|
-
if (msg) {
|
|
184
|
-
rl.close();
|
|
185
|
-
stream.destroy();
|
|
186
|
-
return msg.length > maxLength ? msg.substring(0, maxLength) + '...' : msg;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
} catch {
|
|
190
|
-
// Skip malformed JSON lines
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return '';
|
|
194
|
-
} catch (_err) {
|
|
195
|
-
return '';
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Get session duration by reading first and last event timestamps
|
|
201
|
-
* @param {string} filePath - Path to .jsonl events file
|
|
202
|
-
* @returns {Promise<number|null>} Duration in milliseconds, or null if unable to calculate
|
|
203
|
-
*/
|
|
204
|
-
async function getSessionDuration(filePath) {
|
|
205
|
-
try {
|
|
206
|
-
const stream = fsSync.createReadStream(filePath, { encoding: 'utf-8' });
|
|
207
|
-
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
208
|
-
|
|
209
|
-
let firstTimestamp = null;
|
|
210
|
-
let lastTimestamp = null;
|
|
211
|
-
|
|
212
|
-
for await (const line of rl) {
|
|
213
|
-
if (!line.trim()) continue;
|
|
214
|
-
try {
|
|
215
|
-
const event = JSON.parse(line);
|
|
216
|
-
if (event.timestamp) {
|
|
217
|
-
const ts = new Date(event.timestamp).getTime();
|
|
218
|
-
if (!firstTimestamp) {
|
|
219
|
-
firstTimestamp = ts;
|
|
220
|
-
}
|
|
221
|
-
lastTimestamp = ts;
|
|
222
|
-
}
|
|
223
|
-
} catch {
|
|
224
|
-
// Skip malformed JSON lines
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (firstTimestamp && lastTimestamp && lastTimestamp >= firstTimestamp) {
|
|
229
|
-
return lastTimestamp - firstTimestamp;
|
|
230
|
-
}
|
|
231
|
-
return null;
|
|
232
|
-
} catch (err) {
|
|
233
|
-
console.error(`Error calculating session duration for ${filePath}:`, err.message);
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Get session metadata from session.start event
|
|
240
|
-
* @param {string} filePath - Path to .jsonl events file
|
|
241
|
-
* @returns {Promise<{copilotVersion: string|null, selectedModel: string|null}>}
|
|
242
|
-
*/
|
|
243
|
-
async function getSessionMetadata(filePath) {
|
|
244
|
-
try {
|
|
245
|
-
const stream = fsSync.createReadStream(filePath, { encoding: 'utf-8' });
|
|
246
|
-
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
247
|
-
|
|
248
|
-
let copilotVersion = null;
|
|
249
|
-
let selectedModel = null;
|
|
250
|
-
|
|
251
|
-
for await (const line of rl) {
|
|
252
|
-
if (!line.trim()) continue;
|
|
253
|
-
try {
|
|
254
|
-
const event = JSON.parse(line);
|
|
255
|
-
|
|
256
|
-
// Extract copilotVersion and selectedModel from session.start
|
|
257
|
-
if (event.type === 'session.start' && event.data) {
|
|
258
|
-
copilotVersion = event.data.copilotVersion || null;
|
|
259
|
-
selectedModel = event.data.selectedModel || null;
|
|
260
|
-
|
|
261
|
-
// If we have selectedModel, we're done
|
|
262
|
-
if (selectedModel) {
|
|
263
|
-
rl.close();
|
|
264
|
-
stream.destroy();
|
|
265
|
-
return { copilotVersion, selectedModel };
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// If no selectedModel in session.start, check for model_change
|
|
270
|
-
if (!selectedModel && event.type === 'session.model_change' && event.data) {
|
|
271
|
-
selectedModel = event.data.newModel || null;
|
|
272
|
-
rl.close();
|
|
273
|
-
stream.destroy();
|
|
274
|
-
return { copilotVersion, selectedModel };
|
|
275
|
-
}
|
|
276
|
-
} catch {
|
|
277
|
-
// Skip malformed JSON lines
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
return { copilotVersion, selectedModel };
|
|
281
|
-
} catch (err) {
|
|
282
|
-
console.error(`Error reading session metadata from ${filePath}:`, err.message);
|
|
283
|
-
return { copilotVersion: null, selectedModel: null };
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Check if entry should be skipped
|
|
289
|
-
* @param {string} entry - Directory/file name
|
|
290
|
-
* @returns {boolean}
|
|
291
|
-
*/
|
|
292
|
-
function shouldSkipEntry(entry) {
|
|
293
|
-
return entry === '.DS_Store' || entry.startsWith('.');
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
module.exports = {
|
|
297
|
-
fileExists,
|
|
298
|
-
countLines,
|
|
299
|
-
parseYAML,
|
|
300
|
-
getFirstUserMessage,
|
|
301
|
-
getSessionDuration,
|
|
302
|
-
getSessionMetadata,
|
|
303
|
-
getSessionMetadataOptimized, // New optimized function
|
|
304
|
-
shouldSkipEntry
|
|
305
|
-
};
|
package/src/utils/helpers.js
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Helper utilities for server routes
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Build metadata object from session
|
|
7
|
-
* @param {Session} session - Session model instance
|
|
8
|
-
* @returns {Object} Metadata object
|
|
9
|
-
*/
|
|
10
|
-
function buildMetadata(session) {
|
|
11
|
-
const json = session.toJSON ? session.toJSON() : {};
|
|
12
|
-
return {
|
|
13
|
-
type: session.type,
|
|
14
|
-
source: session.source, // 'copilot' or 'claude'
|
|
15
|
-
sourceName: json.sourceName || session.source,
|
|
16
|
-
sourceBadgeClass: json.sourceBadgeClass || 'source-unknown',
|
|
17
|
-
summary: session.summary,
|
|
18
|
-
model: session.selectedModel || session.model,
|
|
19
|
-
repo: session.workspace?.repository,
|
|
20
|
-
branch: session.workspace?.branch,
|
|
21
|
-
cwd: session.workspace?.cwd,
|
|
22
|
-
created: session.createdAt,
|
|
23
|
-
updated: session.updatedAt,
|
|
24
|
-
copilotVersion: session.copilotVersion,
|
|
25
|
-
sessionStatus: session.sessionStatus
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Validate session ID to prevent path traversal
|
|
31
|
-
* @param {string} sessionId - Session identifier
|
|
32
|
-
* @returns {boolean} True if valid
|
|
33
|
-
*/
|
|
34
|
-
function isValidSessionId(sessionId) {
|
|
35
|
-
// Allow alphanumeric, underscore, and hyphen (common in UUIDs and session IDs)
|
|
36
|
-
// Length limit prevents abuse
|
|
37
|
-
return typeof sessionId === 'string' &&
|
|
38
|
-
/^[a-zA-Z0-9_-]+$/.test(sessionId) &&
|
|
39
|
-
sessionId.length < 256;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
module.exports = {
|
|
43
|
-
buildMetadata,
|
|
44
|
-
isValidSessionId
|
|
45
|
-
};
|