@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,1092 +0,0 @@
|
|
|
1
|
-
const fs = require('fs').promises;
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
|
-
const Session = require('../models/Session');
|
|
5
|
-
const { fileExists, countLines, parseYAML, getSessionMetadataOptimized, shouldSkipEntry } = require('../utils/fileUtils');
|
|
6
|
-
const { ParserFactory } = require('../../lib/parsers');
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Session Repository - Data access layer for sessions
|
|
10
|
-
* Supports both Copilot CLI and Claude Code sessions
|
|
11
|
-
*/
|
|
12
|
-
class SessionRepository {
|
|
13
|
-
constructor(sessionDirs) {
|
|
14
|
-
// Support both old (single dir) and new (multi-source) initialization
|
|
15
|
-
if (typeof sessionDirs === 'string') {
|
|
16
|
-
this.sources = [{
|
|
17
|
-
type: 'copilot',
|
|
18
|
-
dir: sessionDirs
|
|
19
|
-
}];
|
|
20
|
-
} else if (Array.isArray(sessionDirs)) {
|
|
21
|
-
this.sources = sessionDirs;
|
|
22
|
-
} else {
|
|
23
|
-
// Default: Copilot + Claude + Pi-Mono + VSCode
|
|
24
|
-
// Support environment variables for each source (useful for testing/CI)
|
|
25
|
-
this.sources = [
|
|
26
|
-
{
|
|
27
|
-
type: 'copilot',
|
|
28
|
-
dir: process.env.COPILOT_SESSION_DIR ||
|
|
29
|
-
process.env.SESSION_DIR || // Legacy fallback
|
|
30
|
-
path.join(os.homedir(), '.copilot', 'session-state')
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
type: 'claude',
|
|
34
|
-
dir: process.env.CLAUDE_SESSION_DIR ||
|
|
35
|
-
path.join(os.homedir(), '.claude', 'projects')
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
type: 'pi-mono',
|
|
39
|
-
dir: process.env.PI_MONO_SESSION_DIR ||
|
|
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')
|
|
46
|
-
}
|
|
47
|
-
];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
this.parserFactory = new ParserFactory();
|
|
51
|
-
|
|
52
|
-
// Cache: keyed by sourceType (null = all sources)
|
|
53
|
-
this._cache = new Map();
|
|
54
|
-
this._cacheTTL = 60 * 1000; // 60 seconds
|
|
55
|
-
this._pendingScans = new Map(); // dedup concurrent requests
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Invalidate cache (call after tag/insight changes if needed)
|
|
60
|
-
*/
|
|
61
|
-
invalidateCache(sourceType = null) {
|
|
62
|
-
if (sourceType) {
|
|
63
|
-
this._cache.delete(sourceType);
|
|
64
|
-
this._cache.delete(null); // also invalidate "all" cache
|
|
65
|
-
} else {
|
|
66
|
-
this._cache.clear();
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Get all sessions from all sources (or a specific source)
|
|
72
|
-
* @param {string|null} sourceType - Optional source type filter ('copilot', 'claude', 'pi-mono', 'vscode')
|
|
73
|
-
* @returns {Promise<Session[]>} Array of sessions sorted by updatedAt (newest first)
|
|
74
|
-
*/
|
|
75
|
-
async findAll(sourceType = null) {
|
|
76
|
-
const cacheKey = sourceType || '__all__';
|
|
77
|
-
|
|
78
|
-
// Check cache
|
|
79
|
-
const cached = this._cache.get(cacheKey);
|
|
80
|
-
if (cached && (Date.now() - cached.timestamp < this._cacheTTL)) {
|
|
81
|
-
return cached.data;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Dedup concurrent scans for same key
|
|
85
|
-
if (this._pendingScans.has(cacheKey)) {
|
|
86
|
-
return this._pendingScans.get(cacheKey);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const scanPromise = this._doFindAll(sourceType).then(result => {
|
|
90
|
-
this._cache.set(cacheKey, { data: result, timestamp: Date.now() });
|
|
91
|
-
this._pendingScans.delete(cacheKey);
|
|
92
|
-
return result;
|
|
93
|
-
}).catch(err => {
|
|
94
|
-
this._pendingScans.delete(cacheKey);
|
|
95
|
-
throw err;
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
this._pendingScans.set(cacheKey, scanPromise);
|
|
99
|
-
return scanPromise;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* @private
|
|
104
|
-
*/
|
|
105
|
-
async _doFindAll(sourceType = null) {
|
|
106
|
-
const allSessions = [];
|
|
107
|
-
|
|
108
|
-
const sources = sourceType
|
|
109
|
-
? this.sources.filter(s => s.type === sourceType)
|
|
110
|
-
: this.sources;
|
|
111
|
-
|
|
112
|
-
for (const source of sources) {
|
|
113
|
-
try {
|
|
114
|
-
const sessions = await this._scanSource(source);
|
|
115
|
-
allSessions.push(...sessions);
|
|
116
|
-
} catch (err) {
|
|
117
|
-
console.error(`Error reading ${source.type} sessions from ${source.dir}:`, err.message);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
return this._sortByUpdatedAt(this._deduplicateSessions(allSessions));
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Deduplicate sessions with the same ID (e.g. VSCode sessions in multiple workspaces).
|
|
126
|
-
* Keeps the most recently updated session for each ID.
|
|
127
|
-
* @private
|
|
128
|
-
*/
|
|
129
|
-
_deduplicateSessions(sessions) {
|
|
130
|
-
const seen = new Map();
|
|
131
|
-
for (const session of sessions) {
|
|
132
|
-
const existing = seen.get(session.id);
|
|
133
|
-
if (!existing || (session.updatedAt && existing.updatedAt && new Date(session.updatedAt) > new Date(existing.updatedAt))) {
|
|
134
|
-
seen.set(session.id, session);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
return Array.from(seen.values());
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Scan a single source directory
|
|
142
|
-
* @private
|
|
143
|
-
*/
|
|
144
|
-
async _scanSource(source) {
|
|
145
|
-
try {
|
|
146
|
-
await fs.access(source.dir);
|
|
147
|
-
} catch {
|
|
148
|
-
console.warn(`Source directory not found: ${source.dir}`);
|
|
149
|
-
return [];
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const entries = await fs.readdir(source.dir);
|
|
153
|
-
const tasks = entries
|
|
154
|
-
.filter(entry => !shouldSkipEntry(entry))
|
|
155
|
-
.map(async (entry) => {
|
|
156
|
-
const fullPath = path.join(source.dir, entry);
|
|
157
|
-
const stats = await fs.stat(fullPath);
|
|
158
|
-
|
|
159
|
-
if (source.type === 'copilot') {
|
|
160
|
-
// Copilot: directory-based or .jsonl files
|
|
161
|
-
if (stats.isDirectory()) {
|
|
162
|
-
return this._createDirectorySession(entry, fullPath, stats, 'copilot');
|
|
163
|
-
} else if (entry.endsWith('.jsonl')) {
|
|
164
|
-
return this._createFileSession(entry, fullPath, stats, 'copilot');
|
|
165
|
-
}
|
|
166
|
-
} else if (source.type === 'claude') {
|
|
167
|
-
// Claude: all directories contain .jsonl files named by sessionId
|
|
168
|
-
if (stats.isDirectory()) {
|
|
169
|
-
return this._scanClaudeProjectDir(fullPath, entry);
|
|
170
|
-
}
|
|
171
|
-
} else if (source.type === 'pi-mono') {
|
|
172
|
-
// Pi-Mono: project directories containing timestamped .jsonl files
|
|
173
|
-
if (stats.isDirectory()) {
|
|
174
|
-
return this._scanPiMonoDir(fullPath, entry);
|
|
175
|
-
}
|
|
176
|
-
} else if (source.type === 'vscode') {
|
|
177
|
-
// VSCode: workspace hash directories containing chatSessions/*.jsonl
|
|
178
|
-
if (stats.isDirectory()) {
|
|
179
|
-
return this._scanVsCodeWorkspaceDir(fullPath);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
return null;
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
const results = await Promise.allSettled(tasks);
|
|
186
|
-
return results
|
|
187
|
-
.filter(r => r.status === 'fulfilled' && r.value !== null && r.value !== undefined)
|
|
188
|
-
.map(r => r.value)
|
|
189
|
-
.flat(); // flat() because scanClaudeProjectDir returns array
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Scan Claude project directory (contains multiple session .jsonl files AND directories with subagents)
|
|
194
|
-
* @private
|
|
195
|
-
*/
|
|
196
|
-
async _scanClaudeProjectDir(projectDir, projectName) {
|
|
197
|
-
try {
|
|
198
|
-
const entries = await fs.readdir(projectDir);
|
|
199
|
-
const sessions = [];
|
|
200
|
-
|
|
201
|
-
for (const entry of entries) {
|
|
202
|
-
if (shouldSkipEntry(entry)) continue;
|
|
203
|
-
|
|
204
|
-
const fullPath = path.join(projectDir, entry);
|
|
205
|
-
const stats = await fs.stat(fullPath);
|
|
206
|
-
|
|
207
|
-
// Handle .jsonl files (main session files)
|
|
208
|
-
if (stats.isFile() && entry.endsWith('.jsonl')) {
|
|
209
|
-
const session = await this._createClaudeSession(entry, fullPath, stats, projectName);
|
|
210
|
-
if (session) {
|
|
211
|
-
sessions.push(session);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Handle directories (potential subagents-only sessions)
|
|
216
|
-
if (stats.isDirectory()) {
|
|
217
|
-
// Check if this directory has a subagents subdirectory
|
|
218
|
-
const subagentsDir = path.join(fullPath, 'subagents');
|
|
219
|
-
try {
|
|
220
|
-
const subStats = await fs.stat(subagentsDir);
|
|
221
|
-
if (subStats.isDirectory()) {
|
|
222
|
-
// This is a valid subagents-only session
|
|
223
|
-
const session = await this._createClaudeSubagentsSession(entry, fullPath, stats, projectName);
|
|
224
|
-
if (session) {
|
|
225
|
-
sessions.push(session);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
} catch {
|
|
229
|
-
// No subagents directory, not a session directory
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
return sessions;
|
|
235
|
-
} catch (err) {
|
|
236
|
-
console.error(`Error scanning Claude project dir ${projectDir}:`, err.message);
|
|
237
|
-
return [];
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Create Claude Code session from .jsonl file
|
|
243
|
-
* @private
|
|
244
|
-
*/
|
|
245
|
-
async _createClaudeSession(entry, fullPath, stats, projectName) {
|
|
246
|
-
const sessionId = entry.replace('.jsonl', '');
|
|
247
|
-
const eventCount = await countLines(fullPath);
|
|
248
|
-
|
|
249
|
-
console.log(`[DEBUG] _createClaudeSession: ${entry}, events: ${eventCount}`);
|
|
250
|
-
|
|
251
|
-
// Read events to extract metadata and VALIDATE format
|
|
252
|
-
try {
|
|
253
|
-
const content = await fs.readFile(fullPath, 'utf-8');
|
|
254
|
-
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
255
|
-
const events = lines.map(line => {
|
|
256
|
-
try {
|
|
257
|
-
return JSON.parse(line);
|
|
258
|
-
} catch {
|
|
259
|
-
return null;
|
|
260
|
-
}
|
|
261
|
-
}).filter(e => e !== null);
|
|
262
|
-
|
|
263
|
-
console.log(`[DEBUG] Parsed ${events.length} events from ${entry}`);
|
|
264
|
-
|
|
265
|
-
// VALIDATION: Check if this has Claude CORE events (assistant, user)
|
|
266
|
-
// Ignore metadata events like file-history-snapshot, progress (可以共存)
|
|
267
|
-
const hasClaudeCoreEvents = events.some(e => e.type === 'assistant' || e.type === 'user');
|
|
268
|
-
const hasCopilotCoreEvents = events.some(e => e.type === 'assistant.message' || e.type === 'user.message');
|
|
269
|
-
|
|
270
|
-
console.log(`[DEBUG] ${entry}: hasClaudeCoreEvents=${hasClaudeCoreEvents}, hasCopilotCoreEvents=${hasCopilotCoreEvents}`);
|
|
271
|
-
|
|
272
|
-
if (!hasClaudeCoreEvents && hasCopilotCoreEvents) {
|
|
273
|
-
console.warn(`File ${fullPath} contains only Copilot core events, skipping as Claude session`);
|
|
274
|
-
return null;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// If no Claude core events, also skip (empty or invalid file)
|
|
278
|
-
if (!hasClaudeCoreEvents) {
|
|
279
|
-
console.warn(`File ${fullPath} has no Claude core events (assistant/user), skipping`);
|
|
280
|
-
return null;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
console.log(`[DEBUG] ${entry} passed validation, creating session...`);
|
|
284
|
-
|
|
285
|
-
// Use parser to extract metadata
|
|
286
|
-
const parserType = this.parserFactory.getParserType(events);
|
|
287
|
-
if (parserType !== 'claude') {
|
|
288
|
-
// Not a valid Claude session
|
|
289
|
-
return null;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const parsed = this.parserFactory.parse(events);
|
|
293
|
-
const metadata = parsed.metadata || {};
|
|
294
|
-
|
|
295
|
-
// Extract project name from directory name (convert back from dashes to slashes)
|
|
296
|
-
const projectPath = projectName.replace(/^-/, '/').replace(/-/g, '/');
|
|
297
|
-
|
|
298
|
-
return new Session(sessionId, 'file', {
|
|
299
|
-
source: 'claude',
|
|
300
|
-
filePath: fullPath,
|
|
301
|
-
directory: path.dirname(fullPath), // Directory containing the session file
|
|
302
|
-
workspace: {
|
|
303
|
-
summary: metadata.model ? `Claude Code session (${metadata.model})` : 'Claude Code session',
|
|
304
|
-
cwd: metadata.cwd || projectPath
|
|
305
|
-
},
|
|
306
|
-
createdAt: metadata.startTime || stats.birthtime,
|
|
307
|
-
updatedAt: stats.mtime,
|
|
308
|
-
summary: parsed.turns[0]?.userMessage?.content?.substring(0, 100) || 'No summary',
|
|
309
|
-
hasEvents: eventCount > 0,
|
|
310
|
-
eventCount: eventCount,
|
|
311
|
-
duration: null, // Claude format doesn't have explicit duration
|
|
312
|
-
isImported: false,
|
|
313
|
-
hasInsight: false,
|
|
314
|
-
copilotVersion: metadata.version,
|
|
315
|
-
selectedModel: metadata.model,
|
|
316
|
-
sessionStatus: 'completed'
|
|
317
|
-
});
|
|
318
|
-
} catch (err) {
|
|
319
|
-
console.error(`Error creating Claude session ${sessionId}:`, err.message);
|
|
320
|
-
return null;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Find session by ID (searches all sources)
|
|
326
|
-
* @param {string} sessionId - Session ID
|
|
327
|
-
* @returns {Promise<Session|null>}
|
|
328
|
-
*/
|
|
329
|
-
async findById(sessionId) {
|
|
330
|
-
if (shouldSkipEntry(sessionId)) return null;
|
|
331
|
-
|
|
332
|
-
for (const source of this.sources) {
|
|
333
|
-
let session = null;
|
|
334
|
-
|
|
335
|
-
if (source.type === 'copilot') {
|
|
336
|
-
session = await this._findCopilotSession(sessionId, source.dir);
|
|
337
|
-
} else if (source.type === 'claude') {
|
|
338
|
-
session = await this._findClaudeSession(sessionId, source.dir);
|
|
339
|
-
} else if (source.type === 'pi-mono') {
|
|
340
|
-
session = await this._findPiMonoSession(sessionId, source.dir);
|
|
341
|
-
} else if (source.type === 'vscode') {
|
|
342
|
-
session = await this._findVsCodeSession(sessionId, source.dir);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
if (session) return session;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
return null;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* Find Copilot session by ID
|
|
353
|
-
* @private
|
|
354
|
-
*/
|
|
355
|
-
async _findCopilotSession(sessionId, sessionDir) {
|
|
356
|
-
// Try directory first
|
|
357
|
-
try {
|
|
358
|
-
const dirPath = path.join(sessionDir, sessionId);
|
|
359
|
-
const dirStats = await fs.stat(dirPath);
|
|
360
|
-
if (dirStats.isDirectory()) {
|
|
361
|
-
return await this._createDirectorySession(sessionId, dirPath, dirStats, 'copilot');
|
|
362
|
-
}
|
|
363
|
-
} catch {
|
|
364
|
-
// Not a directory
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// Try .jsonl file
|
|
368
|
-
try {
|
|
369
|
-
const filePath = path.join(sessionDir, `${sessionId}.jsonl`);
|
|
370
|
-
const fileStats = await fs.stat(filePath);
|
|
371
|
-
if (fileStats.isFile()) {
|
|
372
|
-
return await this._createFileSession(`${sessionId}.jsonl`, filePath, fileStats, 'copilot');
|
|
373
|
-
}
|
|
374
|
-
} catch {
|
|
375
|
-
// File not found
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
return null;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* Find Claude session by ID (searches all project directories)
|
|
383
|
-
* @private
|
|
384
|
-
*/
|
|
385
|
-
async _findClaudeSession(sessionId, projectsDir) {
|
|
386
|
-
try {
|
|
387
|
-
const projects = await fs.readdir(projectsDir);
|
|
388
|
-
|
|
389
|
-
for (const project of projects) {
|
|
390
|
-
const projectPath = path.join(projectsDir, project);
|
|
391
|
-
|
|
392
|
-
// Try main session file first
|
|
393
|
-
const sessionFile = path.join(projectPath, `${sessionId}.jsonl`);
|
|
394
|
-
try {
|
|
395
|
-
const stats = await fs.stat(sessionFile);
|
|
396
|
-
if (stats.isFile()) {
|
|
397
|
-
console.log(`[DEBUG] Found file: ${sessionFile}`);
|
|
398
|
-
const session = await this._createClaudeSession(`${sessionId}.jsonl`, sessionFile, stats, project);
|
|
399
|
-
// If file contains Copilot events (validation failed), continue to check directory
|
|
400
|
-
if (session) {
|
|
401
|
-
console.log('[DEBUG] File validated as Claude session, returning');
|
|
402
|
-
return session;
|
|
403
|
-
}
|
|
404
|
-
console.log('[DEBUG] File validation failed, checking directory...');
|
|
405
|
-
// Otherwise fall through to check directory
|
|
406
|
-
}
|
|
407
|
-
} catch (err) {
|
|
408
|
-
// Main file not found, try directory
|
|
409
|
-
console.log(`[DEBUG] File not found: ${sessionFile}, error: ${err.message}`);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Try session directory (subagents-only sessions, or when file validation failed)
|
|
413
|
-
const sessionDir = path.join(projectPath, sessionId);
|
|
414
|
-
console.log(`[DEBUG] Checking directory: ${sessionDir}`);
|
|
415
|
-
try {
|
|
416
|
-
const dirStats = await fs.stat(sessionDir);
|
|
417
|
-
if (dirStats.isDirectory()) {
|
|
418
|
-
console.log('[DEBUG] Directory exists, checking for subagents...');
|
|
419
|
-
// Check if it has subagents subdirectory
|
|
420
|
-
const subagentsDir = path.join(sessionDir, 'subagents');
|
|
421
|
-
try {
|
|
422
|
-
const subStats = await fs.stat(subagentsDir);
|
|
423
|
-
if (subStats.isDirectory()) {
|
|
424
|
-
console.log('[DEBUG] Found subagents directory, creating session...');
|
|
425
|
-
// Valid Claude subagents-only session
|
|
426
|
-
const result = await this._createClaudeSubagentsSession(sessionId, sessionDir, dirStats, project);
|
|
427
|
-
console.log('[DEBUG] Created subagents session:', result ? 'SUCCESS' : 'FAILED');
|
|
428
|
-
return result;
|
|
429
|
-
}
|
|
430
|
-
} catch (err) {
|
|
431
|
-
// No subagents directory
|
|
432
|
-
console.log(`[DEBUG] No subagents directory: ${err.message}`);
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
} catch (err) {
|
|
436
|
-
// Directory not found, continue
|
|
437
|
-
console.log(`[DEBUG] Directory not found: ${err.message}`);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
} catch (err) {
|
|
441
|
-
// Projects dir not found
|
|
442
|
-
console.error(`[DEBUG] Projects dir error: ${err.message}`);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
console.log(`[DEBUG] Session ${sessionId} not found in any project`);
|
|
446
|
-
return null;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Find Pi-Mono session by ID (searches all project directories)
|
|
451
|
-
* @private
|
|
452
|
-
*/
|
|
453
|
-
async _findPiMonoSession(sessionId, sessionsDir) {
|
|
454
|
-
try {
|
|
455
|
-
const projects = await fs.readdir(sessionsDir);
|
|
456
|
-
|
|
457
|
-
for (const projectDir of projects) {
|
|
458
|
-
const projectPath = path.join(sessionsDir, projectDir);
|
|
459
|
-
|
|
460
|
-
try {
|
|
461
|
-
const files = await fs.readdir(projectPath);
|
|
462
|
-
// Look for file matching pattern: *_<sessionId>.jsonl
|
|
463
|
-
const matchingFile = files.find(f => f.includes(`_${sessionId}.jsonl`));
|
|
464
|
-
|
|
465
|
-
if (matchingFile) {
|
|
466
|
-
const filePath = path.join(projectPath, matchingFile);
|
|
467
|
-
const stats = await fs.stat(filePath);
|
|
468
|
-
|
|
469
|
-
// Read first line for metadata
|
|
470
|
-
const firstLine = await this._readFirstLine(filePath);
|
|
471
|
-
if (firstLine) {
|
|
472
|
-
const sessionEvent = JSON.parse(firstLine);
|
|
473
|
-
if (sessionEvent.type === 'session') {
|
|
474
|
-
const projectName = projectDir.replace(/^--/, '').replace(/--$/, '');
|
|
475
|
-
const eventCount = await countLines(filePath);
|
|
476
|
-
|
|
477
|
-
return new Session(
|
|
478
|
-
sessionId,
|
|
479
|
-
'directory',
|
|
480
|
-
{
|
|
481
|
-
source: 'pi-mono',
|
|
482
|
-
filePath: filePath,
|
|
483
|
-
directory: projectPath, // Project directory containing the session file
|
|
484
|
-
workspace: { cwd: sessionEvent.cwd || projectName },
|
|
485
|
-
createdAt: new Date(sessionEvent.timestamp),
|
|
486
|
-
updatedAt: new Date(stats.mtime),
|
|
487
|
-
summary: `Pi-Mono: ${path.basename(sessionEvent.cwd || projectName)}`,
|
|
488
|
-
hasEvents: eventCount > 0,
|
|
489
|
-
eventCount: eventCount,
|
|
490
|
-
duration: null,
|
|
491
|
-
sessionStatus: 'completed'
|
|
492
|
-
}
|
|
493
|
-
);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
} catch {
|
|
498
|
-
// Not a directory or can't read
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
} catch (err) {
|
|
502
|
-
console.error(`Error searching Pi-Mono sessions: ${err.message}`);
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
return null;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/**
|
|
509
|
-
* Find VSCode session by ID in workspaceStorage
|
|
510
|
-
* @private
|
|
511
|
-
*/
|
|
512
|
-
async _findVsCodeSession(sessionId, workspaceStorageDir) {
|
|
513
|
-
try {
|
|
514
|
-
const hashes = await fs.readdir(workspaceStorageDir);
|
|
515
|
-
const candidates = [];
|
|
516
|
-
|
|
517
|
-
for (const hash of hashes) {
|
|
518
|
-
const chatSessionsDir = path.join(workspaceStorageDir, hash, 'chatSessions');
|
|
519
|
-
try {
|
|
520
|
-
const files = await fs.readdir(chatSessionsDir);
|
|
521
|
-
const matchingFile = files.find(f => f === `${sessionId}.json` || f === `${sessionId}.jsonl` || f.replace(/\.jsonl?$/, '') === sessionId);
|
|
522
|
-
if (matchingFile) {
|
|
523
|
-
const fullPath = path.join(chatSessionsDir, matchingFile);
|
|
524
|
-
const stats = await fs.stat(fullPath);
|
|
525
|
-
const raw = await fs.readFile(fullPath, 'utf-8');
|
|
526
|
-
let sessionJson;
|
|
527
|
-
if (matchingFile.endsWith('.jsonl')) {
|
|
528
|
-
sessionJson = this._parseVsCodeJsonl(raw);
|
|
529
|
-
if (!sessionJson) continue;
|
|
530
|
-
} else {
|
|
531
|
-
sessionJson = JSON.parse(raw);
|
|
532
|
-
}
|
|
533
|
-
const requests = sessionJson.requests || [];
|
|
534
|
-
if (requests.length === 0) continue;
|
|
535
|
-
|
|
536
|
-
const realWorkspacePath = await this._resolveVsCodeWorkspacePath(path.join(workspaceStorageDir, hash));
|
|
537
|
-
const statsWithPath = { ...stats, filePath: fullPath };
|
|
538
|
-
candidates.push(this._buildVsCodeSession(
|
|
539
|
-
sessionId, requests, sessionJson, statsWithPath, hash,
|
|
540
|
-
realWorkspacePath || path.join(workspaceStorageDir, hash)
|
|
541
|
-
));
|
|
542
|
-
}
|
|
543
|
-
} catch {
|
|
544
|
-
// No chatSessions dir or can't read — skip
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
// Return the candidate with the latest effectiveEndTime (most complete data)
|
|
548
|
-
if (candidates.length > 0) {
|
|
549
|
-
candidates.sort((a, b) => (b.updatedAt?.getTime?.() ?? 0) - (a.updatedAt?.getTime?.() ?? 0));
|
|
550
|
-
return candidates[0];
|
|
551
|
-
}
|
|
552
|
-
} catch (err) {
|
|
553
|
-
console.error(`[VSCode findById] Error searching VSCode sessions: ${err.message}`, err.stack);
|
|
554
|
-
}
|
|
555
|
-
console.log(`[VSCode findById] Session ${sessionId} not found in vscode sessions`);
|
|
556
|
-
return null;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* Create Claude session from subagents-only directory (no main events.jsonl)
|
|
561
|
-
* @private
|
|
562
|
-
*/
|
|
563
|
-
async _createClaudeSubagentsSession(sessionId, sessionDir, stats, projectName) {
|
|
564
|
-
try {
|
|
565
|
-
const subagentsDir = path.join(sessionDir, 'subagents');
|
|
566
|
-
const files = await fs.readdir(subagentsDir);
|
|
567
|
-
const subagentFiles = files.filter(f => f.startsWith('agent-') && f.endsWith('.jsonl'));
|
|
568
|
-
|
|
569
|
-
if (subagentFiles.length === 0) {
|
|
570
|
-
return null;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// Count events from all subagent files
|
|
574
|
-
let totalEvents = 0;
|
|
575
|
-
for (const file of subagentFiles) {
|
|
576
|
-
const filePath = path.join(subagentsDir, file);
|
|
577
|
-
totalEvents += await countLines(filePath);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// Read first subagent file for metadata
|
|
581
|
-
const firstFile = path.join(subagentsDir, subagentFiles[0]);
|
|
582
|
-
const content = await fs.readFile(firstFile, 'utf-8');
|
|
583
|
-
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
584
|
-
const firstEvent = lines.length > 0 ? JSON.parse(lines[0]) : null;
|
|
585
|
-
|
|
586
|
-
const metadata = {
|
|
587
|
-
cwd: firstEvent?.cwd || projectName,
|
|
588
|
-
version: firstEvent?.version,
|
|
589
|
-
model: firstEvent?.message?.model,
|
|
590
|
-
startTime: firstEvent?.timestamp
|
|
591
|
-
};
|
|
592
|
-
|
|
593
|
-
const projectPath = projectName.replace(/^-/, '/').replace(/-/g, '/');
|
|
594
|
-
|
|
595
|
-
return new Session(sessionId, 'directory', {
|
|
596
|
-
source: 'claude',
|
|
597
|
-
directory: sessionDir, // Session directory path
|
|
598
|
-
workspace: {
|
|
599
|
-
summary: `Claude session (${subagentFiles.length} sub-agents)`,
|
|
600
|
-
cwd: metadata.cwd || projectPath
|
|
601
|
-
},
|
|
602
|
-
createdAt: metadata.startTime || stats.birthtime,
|
|
603
|
-
updatedAt: stats.mtime,
|
|
604
|
-
summary: firstEvent?.message?.content?.substring(0, 100) || 'Sub-agent tasks',
|
|
605
|
-
hasEvents: totalEvents > 0,
|
|
606
|
-
eventCount: totalEvents,
|
|
607
|
-
duration: null,
|
|
608
|
-
isImported: false,
|
|
609
|
-
hasInsight: false,
|
|
610
|
-
copilotVersion: metadata.version,
|
|
611
|
-
selectedModel: metadata.model,
|
|
612
|
-
sessionStatus: 'completed'
|
|
613
|
-
});
|
|
614
|
-
} catch (err) {
|
|
615
|
-
console.error(`Error creating Claude subagents session ${sessionId}:`, err.message);
|
|
616
|
-
return null;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
/**
|
|
621
|
-
* Create session from directory (Copilot format)
|
|
622
|
-
* @private
|
|
623
|
-
*/
|
|
624
|
-
async _createDirectorySession(entry, fullPath, stats, source = 'copilot') {
|
|
625
|
-
const workspaceFile = path.join(fullPath, 'workspace.yaml');
|
|
626
|
-
const eventsFile = path.join(fullPath, 'events.jsonl');
|
|
627
|
-
const importedMarkerFile = path.join(fullPath, '.imported');
|
|
628
|
-
const insightReportFile = path.join(fullPath, `${entry}.agent-review.md`);
|
|
629
|
-
|
|
630
|
-
// Parse workspace.yaml if exists, otherwise use defaults
|
|
631
|
-
const workspace = await fileExists(workspaceFile)
|
|
632
|
-
? await parseYAML(workspaceFile)
|
|
633
|
-
: { summary: entry, repo: 'unknown' };
|
|
634
|
-
|
|
635
|
-
const eventCount = await fileExists(eventsFile) ? await countLines(eventsFile) : 0;
|
|
636
|
-
const isImported = await fileExists(importedMarkerFile);
|
|
637
|
-
const hasInsight = await fileExists(insightReportFile);
|
|
638
|
-
|
|
639
|
-
let duration = null;
|
|
640
|
-
let copilotVersion = null;
|
|
641
|
-
let selectedModel = null;
|
|
642
|
-
let sessionStatus = 'completed';
|
|
643
|
-
|
|
644
|
-
// Use optimized metadata extraction if events file exists
|
|
645
|
-
if (await fileExists(eventsFile)) {
|
|
646
|
-
const optimizedMetadata = await getSessionMetadataOptimized(eventsFile);
|
|
647
|
-
duration = optimizedMetadata.duration;
|
|
648
|
-
copilotVersion = optimizedMetadata.copilotVersion;
|
|
649
|
-
selectedModel = optimizedMetadata.selectedModel;
|
|
650
|
-
|
|
651
|
-
sessionStatus = this._computeSessionStatus(optimizedMetadata);
|
|
652
|
-
|
|
653
|
-
if (!workspace.summary && optimizedMetadata.firstUserMessage) {
|
|
654
|
-
workspace.summary = optimizedMetadata.firstUserMessage;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// Use max of filesystem mtime and last event timestamp for updatedAt
|
|
658
|
-
if (optimizedMetadata.lastEventTime) {
|
|
659
|
-
const lastEventMs = new Date(optimizedMetadata.lastEventTime).getTime();
|
|
660
|
-
const mtimeMs = new Date(stats.mtime).getTime();
|
|
661
|
-
if (lastEventMs > mtimeMs) {
|
|
662
|
-
stats = { ...stats, mtime: new Date(lastEventMs) };
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
const session = Session.fromDirectory(fullPath, entry, stats, workspace, eventCount, duration, isImported, hasInsight, copilotVersion, selectedModel, sessionStatus);
|
|
668
|
-
session.source = source;
|
|
669
|
-
return session;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/**
|
|
673
|
-
* Create session from .jsonl file (Copilot format)
|
|
674
|
-
* @private
|
|
675
|
-
*/
|
|
676
|
-
async _createFileSession(entry, fullPath, stats, source = 'copilot') {
|
|
677
|
-
const sessionId = entry.replace('.jsonl', '');
|
|
678
|
-
const eventCount = await countLines(fullPath);
|
|
679
|
-
|
|
680
|
-
const optimizedMetadata = await getSessionMetadataOptimized(fullPath);
|
|
681
|
-
const sessionStatus = this._computeSessionStatus(optimizedMetadata);
|
|
682
|
-
|
|
683
|
-
const session = Session.fromFile(
|
|
684
|
-
fullPath,
|
|
685
|
-
sessionId,
|
|
686
|
-
stats,
|
|
687
|
-
eventCount,
|
|
688
|
-
optimizedMetadata.firstUserMessage,
|
|
689
|
-
optimizedMetadata.duration,
|
|
690
|
-
optimizedMetadata.copilotVersion,
|
|
691
|
-
optimizedMetadata.selectedModel,
|
|
692
|
-
sessionStatus
|
|
693
|
-
);
|
|
694
|
-
session.source = source;
|
|
695
|
-
return session;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
/**
|
|
699
|
-
* Compute session status from metadata
|
|
700
|
-
* @private
|
|
701
|
-
*/
|
|
702
|
-
_computeSessionStatus(metadata) {
|
|
703
|
-
if (metadata.hasSessionEnd) {
|
|
704
|
-
return 'completed';
|
|
705
|
-
}
|
|
706
|
-
if (metadata.lastEventTime !== null && metadata.lastEventTime !== undefined) {
|
|
707
|
-
const WIP_THRESHOLD_MS = 5 * 60 * 1000;
|
|
708
|
-
if ((Date.now() - metadata.lastEventTime) < WIP_THRESHOLD_MS) {
|
|
709
|
-
return 'wip';
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
return 'completed';
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
/**
|
|
716
|
-
* Scan Pi-Mono project directory (--project-path--)
|
|
717
|
-
* Contains timestamped .jsonl files: YYYY-MM-DDTHH-mm-ss-SSSZ_<uuid>.jsonl
|
|
718
|
-
* @private
|
|
719
|
-
*/
|
|
720
|
-
async _scanPiMonoDir(projectDir, dirName) {
|
|
721
|
-
try {
|
|
722
|
-
console.log(`[PI-MONO] Scanning directory: ${projectDir}`);
|
|
723
|
-
const entries = await fs.readdir(projectDir);
|
|
724
|
-
const jsonlFiles = entries.filter(e => e.endsWith('.jsonl'));
|
|
725
|
-
|
|
726
|
-
console.log(`[PI-MONO] Found ${jsonlFiles.length} .jsonl files in ${dirName}`);
|
|
727
|
-
|
|
728
|
-
if (jsonlFiles.length === 0) {
|
|
729
|
-
return [];
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
const sessions = [];
|
|
733
|
-
|
|
734
|
-
// Sort files by name (timestamp) to get latest
|
|
735
|
-
jsonlFiles.sort().reverse();
|
|
736
|
-
|
|
737
|
-
for (const file of jsonlFiles) {
|
|
738
|
-
const fullPath = path.join(projectDir, file);
|
|
739
|
-
const stats = await fs.stat(fullPath);
|
|
740
|
-
|
|
741
|
-
// Extract session ID from filename: YYYY-MM-DD...Z_<uuid>.jsonl
|
|
742
|
-
const match = file.match(/_([a-f0-9-]+)\.jsonl$/);
|
|
743
|
-
if (!match) {
|
|
744
|
-
console.log(`[PI-MONO] Skipping ${file}: no UUID match`);
|
|
745
|
-
continue;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
const sessionId = match[1];
|
|
749
|
-
|
|
750
|
-
// Read first line to get session metadata
|
|
751
|
-
const firstLine = await this._readFirstLine(fullPath);
|
|
752
|
-
if (!firstLine) {
|
|
753
|
-
console.log(`[PI-MONO] Skipping ${file}: no first line`);
|
|
754
|
-
continue;
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
try {
|
|
758
|
-
const sessionEvent = JSON.parse(firstLine);
|
|
759
|
-
if (sessionEvent.type !== 'session') {
|
|
760
|
-
console.log(`[PI-MONO] Skipping ${file}: first event type is ${sessionEvent.type}, not 'session'`);
|
|
761
|
-
continue;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// Count events in the file
|
|
765
|
-
const eventCount = await countLines(fullPath);
|
|
766
|
-
|
|
767
|
-
// Extract project name from directory (remove -- prefix/suffix)
|
|
768
|
-
const projectPath = dirName.replace(/^--/, '').replace(/--$/, '');
|
|
769
|
-
|
|
770
|
-
const session = new Session(
|
|
771
|
-
sessionId,
|
|
772
|
-
'directory',
|
|
773
|
-
{
|
|
774
|
-
source: 'pi-mono',
|
|
775
|
-
directory: projectDir, // Add directory path for Agent Review
|
|
776
|
-
workspace: { cwd: sessionEvent.cwd || projectPath },
|
|
777
|
-
createdAt: new Date(sessionEvent.timestamp),
|
|
778
|
-
updatedAt: new Date(stats.mtime),
|
|
779
|
-
summary: `Pi-Mono: ${path.basename(sessionEvent.cwd || projectPath)}`,
|
|
780
|
-
hasEvents: eventCount > 0,
|
|
781
|
-
eventCount: eventCount,
|
|
782
|
-
duration: null,
|
|
783
|
-
sessionStatus: 'completed'
|
|
784
|
-
}
|
|
785
|
-
);
|
|
786
|
-
|
|
787
|
-
console.log(`[PI-MONO] Created session: ${sessionId} from ${file}`);
|
|
788
|
-
sessions.push(session);
|
|
789
|
-
} catch (err) {
|
|
790
|
-
console.error(`[PI-MONO] Error parsing session ${file}:`, err.message);
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
console.log(`[PI-MONO] Total sessions found in ${dirName}: ${sessions.length}`);
|
|
795
|
-
return sessions;
|
|
796
|
-
} catch (err) {
|
|
797
|
-
console.error(`[PI-MONO] Error scanning dir ${projectDir}:`, err.message);
|
|
798
|
-
return [];
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
/**
|
|
803
|
-
* Scan a VSCode workspaceStorage/<hash> directory for chatSessions/*.json files
|
|
804
|
-
* @private
|
|
805
|
-
*/
|
|
806
|
-
/** Resolve the real project/workspace path from a VSCode workspaceStorage hash directory */
|
|
807
|
-
/** Parse a VSCode .jsonl file: read kind=0 for base state, merge kind=2 patches into response arrays */
|
|
808
|
-
_parseVsCodeJsonl(raw) {
|
|
809
|
-
const lines = raw.split('\n').filter(l => l.trim());
|
|
810
|
-
if (lines.length === 0) return null;
|
|
811
|
-
|
|
812
|
-
const first = JSON.parse(lines[0]);
|
|
813
|
-
const sessionJson = first.v || first; // kind=0 wraps in .v
|
|
814
|
-
|
|
815
|
-
// Apply kind=1 (field set) and kind=2 (array splice) patches
|
|
816
|
-
for (let idx = 1; idx < lines.length; idx++) {
|
|
817
|
-
try {
|
|
818
|
-
const patch = JSON.parse(lines[idx]);
|
|
819
|
-
const k = patch.k || [];
|
|
820
|
-
const v = patch.v;
|
|
821
|
-
|
|
822
|
-
if (patch.kind === 2 && Array.isArray(v)) {
|
|
823
|
-
// Navigate to parent object, then splice into the target array
|
|
824
|
-
let obj = sessionJson;
|
|
825
|
-
for (let ki = 0; ki < k.length - 1; ki++) {
|
|
826
|
-
const key = k[ki];
|
|
827
|
-
if (typeof key === 'number') {
|
|
828
|
-
obj = obj[key];
|
|
829
|
-
} else {
|
|
830
|
-
if (!obj[key]) obj[key] = {};
|
|
831
|
-
obj = obj[key];
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
const lastKey = k[k.length - 1];
|
|
835
|
-
if (lastKey !== undefined) {
|
|
836
|
-
if (!obj[lastKey]) obj[lastKey] = [];
|
|
837
|
-
const target = obj[lastKey];
|
|
838
|
-
const i = patch.i;
|
|
839
|
-
if (i === null || i === undefined) {
|
|
840
|
-
target.push(...v);
|
|
841
|
-
} else {
|
|
842
|
-
target.splice(i, 0, ...v);
|
|
843
|
-
}
|
|
844
|
-
} else {
|
|
845
|
-
// k is empty — splice into sessionJson itself (rare)
|
|
846
|
-
const i = patch.i;
|
|
847
|
-
if (i === null || i === undefined) sessionJson.push?.(...v);
|
|
848
|
-
}
|
|
849
|
-
} else if (patch.kind === 1 && k.length > 0) {
|
|
850
|
-
// Navigate to parent, set the final key
|
|
851
|
-
let obj = sessionJson;
|
|
852
|
-
for (let ki = 0; ki < k.length - 1; ki++) {
|
|
853
|
-
const key = k[ki];
|
|
854
|
-
if (typeof key === 'number') {
|
|
855
|
-
obj = obj[key];
|
|
856
|
-
} else {
|
|
857
|
-
if (!obj[key]) obj[key] = {};
|
|
858
|
-
obj = obj[key];
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
const lastKey = k[k.length - 1];
|
|
862
|
-
obj[lastKey] = v;
|
|
863
|
-
}
|
|
864
|
-
} catch {
|
|
865
|
-
// Skip malformed lines
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
return sessionJson;
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
async _resolveVsCodeWorkspacePath(workspaceHashDir) {
|
|
873
|
-
try {
|
|
874
|
-
const workspaceJsonPath = path.join(workspaceHashDir, 'workspace.json');
|
|
875
|
-
const raw = await fs.readFile(workspaceJsonPath, 'utf-8');
|
|
876
|
-
const meta = JSON.parse(raw);
|
|
877
|
-
|
|
878
|
-
if (meta.folder) {
|
|
879
|
-
// Single-folder workspace: file:///path/to/project
|
|
880
|
-
return decodeURIComponent(meta.folder.replace('file://', ''));
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
if (meta.workspace) {
|
|
884
|
-
// Multi-folder workspace: points to another .json with folders array
|
|
885
|
-
const wsFilePath = decodeURIComponent(meta.workspace.replace('file://', ''));
|
|
886
|
-
try {
|
|
887
|
-
const wsRaw = await fs.readFile(wsFilePath, 'utf-8');
|
|
888
|
-
const ws = JSON.parse(wsRaw);
|
|
889
|
-
if (Array.isArray(ws.folders) && ws.folders.length > 0) {
|
|
890
|
-
// Return first folder path
|
|
891
|
-
const wsDir = path.dirname(wsFilePath);
|
|
892
|
-
const resolved = path.resolve(wsDir, ws.folders[0].path);
|
|
893
|
-
return resolved;
|
|
894
|
-
}
|
|
895
|
-
} catch {
|
|
896
|
-
// Ignore nested read errors
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
} catch {
|
|
900
|
-
// No workspace.json or unreadable
|
|
901
|
-
}
|
|
902
|
-
return null;
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
async _scanVsCodeWorkspaceDir(workspaceHashDir) {
|
|
906
|
-
const chatSessionsDir = path.join(workspaceHashDir, 'chatSessions');
|
|
907
|
-
try {
|
|
908
|
-
await fs.access(chatSessionsDir);
|
|
909
|
-
} catch {
|
|
910
|
-
return []; // No chatSessions subfolder — skip silently
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
// Extract workspace hash from directory name
|
|
914
|
-
const workspaceHash = path.basename(workspaceHashDir);
|
|
915
|
-
|
|
916
|
-
// Resolve the real project path from workspace.json
|
|
917
|
-
const realWorkspacePath = await this._resolveVsCodeWorkspacePath(workspaceHashDir);
|
|
918
|
-
|
|
919
|
-
const entries = await fs.readdir(chatSessionsDir);
|
|
920
|
-
const jsonFiles = entries.filter(e => (e.endsWith('.json') || e.endsWith('.jsonl')) && !shouldSkipEntry(e));
|
|
921
|
-
if (jsonFiles.length === 0) return [];
|
|
922
|
-
|
|
923
|
-
const sessions = [];
|
|
924
|
-
for (const file of jsonFiles) {
|
|
925
|
-
const fullPath = path.join(chatSessionsDir, file);
|
|
926
|
-
try {
|
|
927
|
-
const stats = await fs.stat(fullPath);
|
|
928
|
-
const raw = await fs.readFile(fullPath, 'utf-8');
|
|
929
|
-
// Support both .json (flat) and .jsonl (incremental patch: kind=0 + kind=2 patches)
|
|
930
|
-
let sessionJson;
|
|
931
|
-
if (file.endsWith('.jsonl')) {
|
|
932
|
-
sessionJson = this._parseVsCodeJsonl(raw);
|
|
933
|
-
if (!sessionJson) continue;
|
|
934
|
-
} else {
|
|
935
|
-
sessionJson = JSON.parse(raw);
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
const sessionId = sessionJson.sessionId || path.basename(file).replace(/\.jsonl?$/, '');
|
|
939
|
-
const requests = sessionJson.requests || [];
|
|
940
|
-
if (requests.length === 0) continue;
|
|
941
|
-
|
|
942
|
-
const statsWithPath = { ...stats, filePath: fullPath };
|
|
943
|
-
const session = this._buildVsCodeSession(
|
|
944
|
-
sessionId, requests, sessionJson, statsWithPath, workspaceHash,
|
|
945
|
-
realWorkspacePath || workspaceHashDir
|
|
946
|
-
);
|
|
947
|
-
sessions.push(session);
|
|
948
|
-
} catch (err) {
|
|
949
|
-
// Skip malformed files silently
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
return sessions;
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
/** Extract plain text from a VSCode message object */
|
|
956
|
-
/**
|
|
957
|
-
* Build a VSCode Session object from parsed JSONL data.
|
|
958
|
-
* Single source of truth for VSCode session construction — used by both
|
|
959
|
-
* the main scan loop and _findVsCodeSession to avoid duplicate logic.
|
|
960
|
-
* @private
|
|
961
|
-
*/
|
|
962
|
-
_buildVsCodeSession(sessionId, requests, sessionJson, stats, workspaceHash, workspaceCwd) {
|
|
963
|
-
const firstReq = requests[0];
|
|
964
|
-
const lastReq = requests[requests.length - 1];
|
|
965
|
-
|
|
966
|
-
const createdAt = sessionJson.creationDate
|
|
967
|
-
? new Date(sessionJson.creationDate)
|
|
968
|
-
: (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
|
|
969
|
-
|
|
970
|
-
const lastReqTime = lastReq.timestamp ? new Date(lastReq.timestamp) : null;
|
|
971
|
-
const fallbackUpdatedAt = sessionJson.lastMessageDate
|
|
972
|
-
? new Date(sessionJson.lastMessageDate)
|
|
973
|
-
: (lastReqTime || stats.mtime);
|
|
974
|
-
|
|
975
|
-
// Use last terminal command timestamp (truest end time for agentic sessions).
|
|
976
|
-
// terminalCommandState.timestamp = when the agent actually executed a command.
|
|
977
|
-
// request.timestamp = when the user sent the message (start of turn, not end).
|
|
978
|
-
// mtime is unreliable — VSCode syncs/touches all files when the workspace opens.
|
|
979
|
-
const lastTerminalTime = this._extractLastTerminalTimestamp(requests);
|
|
980
|
-
const effectiveEndTime = lastTerminalTime || lastReqTime || fallbackUpdatedAt;
|
|
981
|
-
|
|
982
|
-
const isWip = (Date.now() - effectiveEndTime.getTime()) < 15 * 60 * 1000;
|
|
983
|
-
const userText = this._extractVsCodeUserText(firstReq.message);
|
|
984
|
-
const toolCount = requests.reduce(
|
|
985
|
-
(sum, req) => sum + (req.response || []).filter(r => r.kind === 'toolInvocationSerialized').length,
|
|
986
|
-
0
|
|
987
|
-
);
|
|
988
|
-
|
|
989
|
-
return new Session(sessionId, 'file', {
|
|
990
|
-
source: 'vscode',
|
|
991
|
-
filePath: stats.filePath,
|
|
992
|
-
workspaceHash,
|
|
993
|
-
createdAt,
|
|
994
|
-
updatedAt: effectiveEndTime,
|
|
995
|
-
summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
|
|
996
|
-
hasEvents: true,
|
|
997
|
-
eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
|
|
998
|
-
duration: effectiveEndTime.getTime() - createdAt.getTime(),
|
|
999
|
-
sessionStatus: isWip ? 'wip' : 'completed',
|
|
1000
|
-
selectedModel: firstReq.modelId || null,
|
|
1001
|
-
agentId: firstReq.agent?.id || 'vscode-copilot',
|
|
1002
|
-
toolCount,
|
|
1003
|
-
copilotVersion: firstReq.agent?.extensionVersion || null,
|
|
1004
|
-
workspace: { cwd: workspaceCwd },
|
|
1005
|
-
});
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
_extractLastTerminalTimestamp(requests) {
|
|
1009
|
-
let maxTs = 0;
|
|
1010
|
-
|
|
1011
|
-
function walk(obj) {
|
|
1012
|
-
if (!obj || typeof obj !== 'object') return;
|
|
1013
|
-
if (Array.isArray(obj)) { obj.forEach(walk); return; }
|
|
1014
|
-
if (obj.terminalCommandState && typeof obj.terminalCommandState.timestamp === 'number') {
|
|
1015
|
-
const ts = obj.terminalCommandState.timestamp;
|
|
1016
|
-
if (ts > 1_000_000_000_000 && ts < 9_999_999_999_999 && ts > maxTs) maxTs = ts;
|
|
1017
|
-
}
|
|
1018
|
-
for (const val of Object.values(obj)) walk(val);
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
for (const req of requests) walk(req.response);
|
|
1022
|
-
|
|
1023
|
-
return maxTs > 0 ? new Date(maxTs) : null;
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
_extractVsCodeUserText(message) {
|
|
1027
|
-
if (!message) return '';
|
|
1028
|
-
if (typeof message.text === 'string') return message.text;
|
|
1029
|
-
if (Array.isArray(message.parts)) {
|
|
1030
|
-
return message.parts.filter(p => p.kind === 'text').map(p => p.text || '').join('');
|
|
1031
|
-
}
|
|
1032
|
-
return '';
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
/**
|
|
1036
|
-
* Read first line of a file
|
|
1037
|
-
* @private
|
|
1038
|
-
*/
|
|
1039
|
-
async _readFirstLine(filePath) {
|
|
1040
|
-
const fs = require('fs');
|
|
1041
|
-
const readline = require('readline');
|
|
1042
|
-
|
|
1043
|
-
return new Promise((resolve, reject) => {
|
|
1044
|
-
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
1045
|
-
const rl = readline.createInterface({
|
|
1046
|
-
input: stream,
|
|
1047
|
-
crlfDelay: Infinity
|
|
1048
|
-
});
|
|
1049
|
-
|
|
1050
|
-
let resolved = false;
|
|
1051
|
-
|
|
1052
|
-
rl.on('line', (line) => {
|
|
1053
|
-
if (!resolved) {
|
|
1054
|
-
resolved = true;
|
|
1055
|
-
rl.close();
|
|
1056
|
-
resolve(line.trim());
|
|
1057
|
-
}
|
|
1058
|
-
});
|
|
1059
|
-
|
|
1060
|
-
rl.on('close', () => {
|
|
1061
|
-
if (!resolved) {
|
|
1062
|
-
resolve(null);
|
|
1063
|
-
}
|
|
1064
|
-
});
|
|
1065
|
-
|
|
1066
|
-
rl.on('error', (err) => {
|
|
1067
|
-
if (!resolved) {
|
|
1068
|
-
resolved = true;
|
|
1069
|
-
reject(err);
|
|
1070
|
-
}
|
|
1071
|
-
});
|
|
1072
|
-
|
|
1073
|
-
stream.on('error', (err) => {
|
|
1074
|
-
if (!resolved) {
|
|
1075
|
-
resolved = true;
|
|
1076
|
-
rl.close();
|
|
1077
|
-
reject(err);
|
|
1078
|
-
}
|
|
1079
|
-
});
|
|
1080
|
-
});
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
/**
|
|
1084
|
-
* Sort sessions by updated time (newest first)
|
|
1085
|
-
* @private
|
|
1086
|
-
*/
|
|
1087
|
-
_sortByUpdatedAt(sessions) {
|
|
1088
|
-
return sessions.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
module.exports = SessionRepository;
|