@qiaolei81/copilot-session-viewer 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-be-responsive-on-mobile-viewport-1771605454041.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-display-sessions-if-available-1771605462872.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-handle-JavaScript-errors-gracefully-1771605463381.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-handle-session-import-dialog-1771605466264.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-have-working-infinite-scroll-elements-1771605454038.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-load-homepage-with-basic-elements-1771605454001.json +435 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-load-time-analysis-page-1771605464990.json +1236 -0
- package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-navigate-to-session-detail-page-1771605472595.json +1177 -0
- package/.nyc_output/coverage-e2e-merged.json +1 -0
- package/.nyc_output/coverage-homepage-spec-js-Homepage-should-display-session-list-1771605453565.json +435 -0
- package/.nyc_output/coverage-homepage-spec-js-Homepage-should-load-homepage-successfully-1771605453552.json +435 -0
- package/.nyc_output/coverage-homepage-spec-js-Homepage-should-navigate-to-session-detail-on-click-1771605469317.json +1134 -0
- package/.nyc_output/coverage-homepage-spec-js-Homepage-should-show-session-metadata-1771605460581.json +435 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-display-Load-More-Sessions-button-when-there-are-more-sessions-1771605468486.json +435 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-handle-API-errors-gracefully-during-infinite-scroll-1771605482161.json +471 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-hide-Load-More-button-when-no-more-sessions-available-1771605478370.json +471 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-load-additional-sessions-when-Load-More-button-is-clicked-1771605475059.json +471 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-preserve-session-list-state-during-navigation-1771605494575.json +1633 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-show-loading-state-when-Load-More-button-is-clicked-1771605475401.json +471 -0
- package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-trigger-infinite-scroll-when-scrolling-near-bottom-1771605476949.json +471 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-clear-search-filter-1771605508542.json +1255 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-display-event-list-1771605505572.json +1156 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-display-session-metadata-1771605504552.json +701 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-expand-and-collapse-tool-details-1771605515809.json +1182 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-filter-events-by-search-1771605513421.json +1245 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-load-session-detail-page-1771605494974.json +701 -0
- package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-toggle-content-visibility-1771605550729.json +1177 -0
- package/.nyc_output/coverage-unit.json +21 -0
- package/.nycrc +29 -0
- package/CHANGELOG.md +36 -0
- package/README.md +154 -15
- package/examples/parser-usage.js +114 -0
- package/lib/parsers/README.md +239 -0
- package/lib/parsers/base-parser.js +53 -0
- package/lib/parsers/claude-parser.js +181 -0
- package/lib/parsers/copilot-parser.js +143 -0
- package/lib/parsers/index.js +13 -0
- package/lib/parsers/parser-factory.js +77 -0
- package/lib/parsers/pi-mono-parser.js +119 -0
- package/package.json +12 -4
- package/server.js +17 -2
- package/src/app.js +45 -20
- package/src/controllers/insightController.js +44 -8
- package/src/controllers/sessionController.js +217 -3
- package/src/controllers/uploadController.js +447 -7
- package/src/middleware/rateLimiting.js +7 -1
- package/src/models/Session.js +26 -0
- package/src/schemas/event.schema.js +73 -0
- package/src/services/eventNormalizer.js +291 -0
- package/src/services/insightService.js +140 -48
- package/src/services/sessionRepository.js +584 -49
- package/src/services/sessionService.js +1594 -27
- package/src/utils/helpers.js +6 -1
- package/views/index.ejs +111 -4
- package/views/session-vue.ejs +425 -71
- package/views/time-analyze.ejs +140 -57
|
@@ -1,74 +1,294 @@
|
|
|
1
1
|
const fs = require('fs').promises;
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
3
4
|
const Session = require('../models/Session');
|
|
4
5
|
const { fileExists, countLines, parseYAML, getSessionMetadataOptimized, shouldSkipEntry } = require('../utils/fileUtils');
|
|
6
|
+
const { ParserFactory } = require('../../lib/parsers');
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Session Repository - Data access layer for sessions
|
|
10
|
+
* Supports both Copilot CLI and Claude Code sessions
|
|
8
11
|
*/
|
|
9
12
|
class SessionRepository {
|
|
10
|
-
constructor(
|
|
11
|
-
|
|
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
|
|
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
|
+
}
|
|
44
|
+
|
|
45
|
+
this.parserFactory = new ParserFactory();
|
|
12
46
|
}
|
|
13
47
|
|
|
14
48
|
/**
|
|
15
|
-
* Get all sessions
|
|
49
|
+
* Get all sessions from all sources
|
|
16
50
|
* @returns {Promise<Session[]>} Array of sessions sorted by updatedAt (newest first)
|
|
17
51
|
*/
|
|
18
52
|
async findAll() {
|
|
53
|
+
const allSessions = [];
|
|
54
|
+
|
|
55
|
+
for (const source of this.sources) {
|
|
56
|
+
try {
|
|
57
|
+
const sessions = await this._scanSource(source);
|
|
58
|
+
allSessions.push(...sessions);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error(`Error reading ${source.type} sessions from ${source.dir}:`, err.message);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return this._sortByUpdatedAt(allSessions);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Scan a single source directory
|
|
69
|
+
* @private
|
|
70
|
+
*/
|
|
71
|
+
async _scanSource(source) {
|
|
19
72
|
try {
|
|
20
|
-
|
|
73
|
+
await fs.access(source.dir);
|
|
74
|
+
} catch {
|
|
75
|
+
console.warn(`Source directory not found: ${source.dir}`);
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
21
78
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
79
|
+
const entries = await fs.readdir(source.dir);
|
|
80
|
+
const tasks = entries
|
|
81
|
+
.filter(entry => !shouldSkipEntry(entry))
|
|
82
|
+
.map(async (entry) => {
|
|
83
|
+
const fullPath = path.join(source.dir, entry);
|
|
84
|
+
const stats = await fs.stat(fullPath);
|
|
27
85
|
|
|
86
|
+
if (source.type === 'copilot') {
|
|
87
|
+
// Copilot: directory-based or .jsonl files
|
|
28
88
|
if (stats.isDirectory()) {
|
|
29
|
-
return this._createDirectorySession(entry, fullPath, stats);
|
|
89
|
+
return this._createDirectorySession(entry, fullPath, stats, 'copilot');
|
|
30
90
|
} else if (entry.endsWith('.jsonl')) {
|
|
31
|
-
return this._createFileSession(entry, fullPath, stats);
|
|
91
|
+
return this._createFileSession(entry, fullPath, stats, 'copilot');
|
|
32
92
|
}
|
|
33
|
-
|
|
34
|
-
|
|
93
|
+
} else if (source.type === 'claude') {
|
|
94
|
+
// Claude: all directories contain .jsonl files named by sessionId
|
|
95
|
+
if (stats.isDirectory()) {
|
|
96
|
+
return this._scanClaudeProjectDir(fullPath, entry);
|
|
97
|
+
}
|
|
98
|
+
} else if (source.type === 'pi-mono') {
|
|
99
|
+
// Pi-Mono: project directories containing timestamped .jsonl files
|
|
100
|
+
if (stats.isDirectory()) {
|
|
101
|
+
return this._scanPiMonoDir(fullPath, entry);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
});
|
|
35
106
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
107
|
+
const results = await Promise.allSettled(tasks);
|
|
108
|
+
return results
|
|
109
|
+
.filter(r => r.status === 'fulfilled' && r.value !== null && r.value !== undefined)
|
|
110
|
+
.map(r => r.value)
|
|
111
|
+
.flat(); // flat() because scanClaudeProjectDir returns array
|
|
112
|
+
}
|
|
40
113
|
|
|
41
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Scan Claude project directory (contains multiple session .jsonl files AND directories with subagents)
|
|
116
|
+
* @private
|
|
117
|
+
*/
|
|
118
|
+
async _scanClaudeProjectDir(projectDir, projectName) {
|
|
119
|
+
try {
|
|
120
|
+
const entries = await fs.readdir(projectDir);
|
|
121
|
+
const sessions = [];
|
|
122
|
+
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (shouldSkipEntry(entry)) continue;
|
|
125
|
+
|
|
126
|
+
const fullPath = path.join(projectDir, entry);
|
|
127
|
+
const stats = await fs.stat(fullPath);
|
|
128
|
+
|
|
129
|
+
// Handle .jsonl files (main session files)
|
|
130
|
+
if (stats.isFile() && entry.endsWith('.jsonl')) {
|
|
131
|
+
const session = await this._createClaudeSession(entry, fullPath, stats, projectName);
|
|
132
|
+
if (session) {
|
|
133
|
+
sessions.push(session);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Handle directories (potential subagents-only sessions)
|
|
138
|
+
if (stats.isDirectory()) {
|
|
139
|
+
// Check if this directory has a subagents subdirectory
|
|
140
|
+
const subagentsDir = path.join(fullPath, 'subagents');
|
|
141
|
+
try {
|
|
142
|
+
const subStats = await fs.stat(subagentsDir);
|
|
143
|
+
if (subStats.isDirectory()) {
|
|
144
|
+
// This is a valid subagents-only session
|
|
145
|
+
const session = await this._createClaudeSubagentsSession(entry, fullPath, stats, projectName);
|
|
146
|
+
if (session) {
|
|
147
|
+
sessions.push(session);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// No subagents directory, not a session directory
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return sessions;
|
|
42
157
|
} catch (err) {
|
|
43
|
-
console.error(
|
|
158
|
+
console.error(`Error scanning Claude project dir ${projectDir}:`, err.message);
|
|
44
159
|
return [];
|
|
45
160
|
}
|
|
46
161
|
}
|
|
47
162
|
|
|
48
163
|
/**
|
|
49
|
-
*
|
|
164
|
+
* Create Claude Code session from .jsonl file
|
|
165
|
+
* @private
|
|
166
|
+
*/
|
|
167
|
+
async _createClaudeSession(entry, fullPath, stats, projectName) {
|
|
168
|
+
const sessionId = entry.replace('.jsonl', '');
|
|
169
|
+
const eventCount = await countLines(fullPath);
|
|
170
|
+
|
|
171
|
+
console.log(`[DEBUG] _createClaudeSession: ${entry}, events: ${eventCount}`);
|
|
172
|
+
|
|
173
|
+
// Read events to extract metadata and VALIDATE format
|
|
174
|
+
try {
|
|
175
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
176
|
+
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
177
|
+
const events = lines.map(line => {
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(line);
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}).filter(e => e !== null);
|
|
184
|
+
|
|
185
|
+
console.log(`[DEBUG] Parsed ${events.length} events from ${entry}`);
|
|
186
|
+
|
|
187
|
+
// VALIDATION: Check if this has Claude CORE events (assistant, user)
|
|
188
|
+
// Ignore metadata events like file-history-snapshot, progress (可以共存)
|
|
189
|
+
const hasClaudeCoreEvents = events.some(e => e.type === 'assistant' || e.type === 'user');
|
|
190
|
+
const hasCopilotCoreEvents = events.some(e => e.type === 'assistant.message' || e.type === 'user.message');
|
|
191
|
+
|
|
192
|
+
console.log(`[DEBUG] ${entry}: hasClaudeCoreEvents=${hasClaudeCoreEvents}, hasCopilotCoreEvents=${hasCopilotCoreEvents}`);
|
|
193
|
+
|
|
194
|
+
if (!hasClaudeCoreEvents && hasCopilotCoreEvents) {
|
|
195
|
+
console.warn(`File ${fullPath} contains only Copilot core events, skipping as Claude session`);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// If no Claude core events, also skip (empty or invalid file)
|
|
200
|
+
if (!hasClaudeCoreEvents) {
|
|
201
|
+
console.warn(`File ${fullPath} has no Claude core events (assistant/user), skipping`);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.log(`[DEBUG] ${entry} passed validation, creating session...`);
|
|
206
|
+
|
|
207
|
+
// Use parser to extract metadata
|
|
208
|
+
const parserType = this.parserFactory.getParserType(events);
|
|
209
|
+
if (parserType !== 'claude') {
|
|
210
|
+
// Not a valid Claude session
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const parsed = this.parserFactory.parse(events);
|
|
215
|
+
const metadata = parsed.metadata || {};
|
|
216
|
+
|
|
217
|
+
// Extract project name from directory name (convert back from dashes to slashes)
|
|
218
|
+
const projectPath = projectName.replace(/^-/, '/').replace(/-/g, '/');
|
|
219
|
+
|
|
220
|
+
return new Session(sessionId, 'file', {
|
|
221
|
+
source: 'claude',
|
|
222
|
+
directory: path.dirname(fullPath), // Directory containing the session file
|
|
223
|
+
workspace: {
|
|
224
|
+
summary: metadata.model ? `Claude Code session (${metadata.model})` : 'Claude Code session',
|
|
225
|
+
cwd: metadata.cwd || projectPath
|
|
226
|
+
},
|
|
227
|
+
createdAt: metadata.startTime || stats.birthtime,
|
|
228
|
+
updatedAt: stats.mtime,
|
|
229
|
+
summary: parsed.turns[0]?.userMessage?.content?.substring(0, 100) || 'No summary',
|
|
230
|
+
hasEvents: eventCount > 0,
|
|
231
|
+
eventCount: eventCount,
|
|
232
|
+
duration: null, // Claude format doesn't have explicit duration
|
|
233
|
+
isImported: false,
|
|
234
|
+
hasInsight: false,
|
|
235
|
+
copilotVersion: metadata.version,
|
|
236
|
+
selectedModel: metadata.model,
|
|
237
|
+
sessionStatus: 'completed'
|
|
238
|
+
});
|
|
239
|
+
} catch (err) {
|
|
240
|
+
console.error(`Error creating Claude session ${sessionId}:`, err.message);
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Find session by ID (searches all sources)
|
|
50
247
|
* @param {string} sessionId - Session ID
|
|
51
248
|
* @returns {Promise<Session|null>}
|
|
52
249
|
*/
|
|
53
250
|
async findById(sessionId) {
|
|
54
251
|
if (shouldSkipEntry(sessionId)) return null;
|
|
55
252
|
|
|
253
|
+
for (const source of this.sources) {
|
|
254
|
+
let session = null;
|
|
255
|
+
|
|
256
|
+
if (source.type === 'copilot') {
|
|
257
|
+
session = await this._findCopilotSession(sessionId, source.dir);
|
|
258
|
+
} else if (source.type === 'claude') {
|
|
259
|
+
session = await this._findClaudeSession(sessionId, source.dir);
|
|
260
|
+
} else if (source.type === 'pi-mono') {
|
|
261
|
+
session = await this._findPiMonoSession(sessionId, source.dir);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (session) return session;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Find Copilot session by ID
|
|
272
|
+
* @private
|
|
273
|
+
*/
|
|
274
|
+
async _findCopilotSession(sessionId, sessionDir) {
|
|
275
|
+
// Try directory first
|
|
56
276
|
try {
|
|
57
|
-
|
|
58
|
-
const dirPath = path.join(this.sessionDir, sessionId);
|
|
277
|
+
const dirPath = path.join(sessionDir, sessionId);
|
|
59
278
|
const dirStats = await fs.stat(dirPath);
|
|
60
279
|
if (dirStats.isDirectory()) {
|
|
61
|
-
return await this._createDirectorySession(sessionId, dirPath, dirStats);
|
|
280
|
+
return await this._createDirectorySession(sessionId, dirPath, dirStats, 'copilot');
|
|
62
281
|
}
|
|
63
282
|
} catch {
|
|
64
|
-
// Not a directory
|
|
283
|
+
// Not a directory
|
|
65
284
|
}
|
|
66
285
|
|
|
286
|
+
// Try .jsonl file
|
|
67
287
|
try {
|
|
68
|
-
const filePath = path.join(
|
|
288
|
+
const filePath = path.join(sessionDir, `${sessionId}.jsonl`);
|
|
69
289
|
const fileStats = await fs.stat(filePath);
|
|
70
290
|
if (fileStats.isFile()) {
|
|
71
|
-
return await this._createFileSession(`${sessionId}.jsonl`, filePath, fileStats);
|
|
291
|
+
return await this._createFileSession(`${sessionId}.jsonl`, filePath, fileStats, 'copilot');
|
|
72
292
|
}
|
|
73
293
|
} catch {
|
|
74
294
|
// File not found
|
|
@@ -78,21 +298,207 @@ class SessionRepository {
|
|
|
78
298
|
}
|
|
79
299
|
|
|
80
300
|
/**
|
|
81
|
-
*
|
|
301
|
+
* Find Claude session by ID (searches all project directories)
|
|
82
302
|
* @private
|
|
83
303
|
*/
|
|
84
|
-
async
|
|
304
|
+
async _findClaudeSession(sessionId, projectsDir) {
|
|
305
|
+
try {
|
|
306
|
+
const projects = await fs.readdir(projectsDir);
|
|
307
|
+
|
|
308
|
+
for (const project of projects) {
|
|
309
|
+
const projectPath = path.join(projectsDir, project);
|
|
310
|
+
|
|
311
|
+
// Try main session file first
|
|
312
|
+
const sessionFile = path.join(projectPath, `${sessionId}.jsonl`);
|
|
313
|
+
try {
|
|
314
|
+
const stats = await fs.stat(sessionFile);
|
|
315
|
+
if (stats.isFile()) {
|
|
316
|
+
console.log(`[DEBUG] Found file: ${sessionFile}`);
|
|
317
|
+
const session = await this._createClaudeSession(`${sessionId}.jsonl`, sessionFile, stats, project);
|
|
318
|
+
// If file contains Copilot events (validation failed), continue to check directory
|
|
319
|
+
if (session) {
|
|
320
|
+
console.log('[DEBUG] File validated as Claude session, returning');
|
|
321
|
+
return session;
|
|
322
|
+
}
|
|
323
|
+
console.log('[DEBUG] File validation failed, checking directory...');
|
|
324
|
+
// Otherwise fall through to check directory
|
|
325
|
+
}
|
|
326
|
+
} catch (err) {
|
|
327
|
+
// Main file not found, try directory
|
|
328
|
+
console.log(`[DEBUG] File not found: ${sessionFile}, error: ${err.message}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Try session directory (subagents-only sessions, or when file validation failed)
|
|
332
|
+
const sessionDir = path.join(projectPath, sessionId);
|
|
333
|
+
console.log(`[DEBUG] Checking directory: ${sessionDir}`);
|
|
334
|
+
try {
|
|
335
|
+
const dirStats = await fs.stat(sessionDir);
|
|
336
|
+
if (dirStats.isDirectory()) {
|
|
337
|
+
console.log('[DEBUG] Directory exists, checking for subagents...');
|
|
338
|
+
// Check if it has subagents subdirectory
|
|
339
|
+
const subagentsDir = path.join(sessionDir, 'subagents');
|
|
340
|
+
try {
|
|
341
|
+
const subStats = await fs.stat(subagentsDir);
|
|
342
|
+
if (subStats.isDirectory()) {
|
|
343
|
+
console.log('[DEBUG] Found subagents directory, creating session...');
|
|
344
|
+
// Valid Claude subagents-only session
|
|
345
|
+
const result = await this._createClaudeSubagentsSession(sessionId, sessionDir, dirStats, project);
|
|
346
|
+
console.log('[DEBUG] Created subagents session:', result ? 'SUCCESS' : 'FAILED');
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
} catch (err) {
|
|
350
|
+
// No subagents directory
|
|
351
|
+
console.log(`[DEBUG] No subagents directory: ${err.message}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} catch (err) {
|
|
355
|
+
// Directory not found, continue
|
|
356
|
+
console.log(`[DEBUG] Directory not found: ${err.message}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} catch (err) {
|
|
360
|
+
// Projects dir not found
|
|
361
|
+
console.error(`[DEBUG] Projects dir error: ${err.message}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
console.log(`[DEBUG] Session ${sessionId} not found in any project`);
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Find Pi-Mono session by ID (searches all project directories)
|
|
370
|
+
* @private
|
|
371
|
+
*/
|
|
372
|
+
async _findPiMonoSession(sessionId, sessionsDir) {
|
|
373
|
+
try {
|
|
374
|
+
const projects = await fs.readdir(sessionsDir);
|
|
375
|
+
|
|
376
|
+
for (const projectDir of projects) {
|
|
377
|
+
const projectPath = path.join(sessionsDir, projectDir);
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const files = await fs.readdir(projectPath);
|
|
381
|
+
// Look for file matching pattern: *_<sessionId>.jsonl
|
|
382
|
+
const matchingFile = files.find(f => f.includes(`_${sessionId}.jsonl`));
|
|
383
|
+
|
|
384
|
+
if (matchingFile) {
|
|
385
|
+
const filePath = path.join(projectPath, matchingFile);
|
|
386
|
+
const stats = await fs.stat(filePath);
|
|
387
|
+
|
|
388
|
+
// Read first line for metadata
|
|
389
|
+
const firstLine = await this._readFirstLine(filePath);
|
|
390
|
+
if (firstLine) {
|
|
391
|
+
const sessionEvent = JSON.parse(firstLine);
|
|
392
|
+
if (sessionEvent.type === 'session') {
|
|
393
|
+
const projectName = projectDir.replace(/^--/, '').replace(/--$/, '');
|
|
394
|
+
const eventCount = await countLines(filePath);
|
|
395
|
+
|
|
396
|
+
return new Session(
|
|
397
|
+
sessionId,
|
|
398
|
+
'directory',
|
|
399
|
+
{
|
|
400
|
+
source: 'pi-mono',
|
|
401
|
+
directory: projectPath, // Project directory containing the session file
|
|
402
|
+
workspace: { cwd: sessionEvent.cwd || projectName },
|
|
403
|
+
createdAt: new Date(sessionEvent.timestamp),
|
|
404
|
+
updatedAt: new Date(stats.mtime),
|
|
405
|
+
summary: `Pi-Mono: ${path.basename(sessionEvent.cwd || projectName)}`,
|
|
406
|
+
hasEvents: eventCount > 0,
|
|
407
|
+
eventCount: eventCount,
|
|
408
|
+
duration: null,
|
|
409
|
+
sessionStatus: 'completed'
|
|
410
|
+
}
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
// Not a directory or can't read
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} catch (err) {
|
|
420
|
+
console.error(`Error searching Pi-Mono sessions: ${err.message}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Create Claude session from subagents-only directory (no main events.jsonl)
|
|
428
|
+
* @private
|
|
429
|
+
*/
|
|
430
|
+
async _createClaudeSubagentsSession(sessionId, sessionDir, stats, projectName) {
|
|
431
|
+
try {
|
|
432
|
+
const subagentsDir = path.join(sessionDir, 'subagents');
|
|
433
|
+
const files = await fs.readdir(subagentsDir);
|
|
434
|
+
const subagentFiles = files.filter(f => f.startsWith('agent-') && f.endsWith('.jsonl'));
|
|
435
|
+
|
|
436
|
+
if (subagentFiles.length === 0) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Count events from all subagent files
|
|
441
|
+
let totalEvents = 0;
|
|
442
|
+
for (const file of subagentFiles) {
|
|
443
|
+
const filePath = path.join(subagentsDir, file);
|
|
444
|
+
totalEvents += await countLines(filePath);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Read first subagent file for metadata
|
|
448
|
+
const firstFile = path.join(subagentsDir, subagentFiles[0]);
|
|
449
|
+
const content = await fs.readFile(firstFile, 'utf-8');
|
|
450
|
+
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
451
|
+
const firstEvent = lines.length > 0 ? JSON.parse(lines[0]) : null;
|
|
452
|
+
|
|
453
|
+
const metadata = {
|
|
454
|
+
cwd: firstEvent?.cwd || projectName,
|
|
455
|
+
version: firstEvent?.version,
|
|
456
|
+
model: firstEvent?.message?.model,
|
|
457
|
+
startTime: firstEvent?.timestamp
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const projectPath = projectName.replace(/^-/, '/').replace(/-/g, '/');
|
|
461
|
+
|
|
462
|
+
return new Session(sessionId, 'directory', {
|
|
463
|
+
source: 'claude',
|
|
464
|
+
directory: sessionDir, // Session directory path
|
|
465
|
+
workspace: {
|
|
466
|
+
summary: `Claude session (${subagentFiles.length} sub-agents)`,
|
|
467
|
+
cwd: metadata.cwd || projectPath
|
|
468
|
+
},
|
|
469
|
+
createdAt: metadata.startTime || stats.birthtime,
|
|
470
|
+
updatedAt: stats.mtime,
|
|
471
|
+
summary: firstEvent?.message?.content?.substring(0, 100) || 'Sub-agent tasks',
|
|
472
|
+
hasEvents: totalEvents > 0,
|
|
473
|
+
eventCount: totalEvents,
|
|
474
|
+
duration: null,
|
|
475
|
+
isImported: false,
|
|
476
|
+
hasInsight: false,
|
|
477
|
+
copilotVersion: metadata.version,
|
|
478
|
+
selectedModel: metadata.model,
|
|
479
|
+
sessionStatus: 'completed'
|
|
480
|
+
});
|
|
481
|
+
} catch (err) {
|
|
482
|
+
console.error(`Error creating Claude subagents session ${sessionId}:`, err.message);
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Create session from directory (Copilot format)
|
|
489
|
+
* @private
|
|
490
|
+
*/
|
|
491
|
+
async _createDirectorySession(entry, fullPath, stats, source = 'copilot') {
|
|
85
492
|
const workspaceFile = path.join(fullPath, 'workspace.yaml');
|
|
86
493
|
const eventsFile = path.join(fullPath, 'events.jsonl');
|
|
87
494
|
const importedMarkerFile = path.join(fullPath, '.imported');
|
|
88
495
|
const insightReportFile = path.join(fullPath, 'agent-review.md');
|
|
89
496
|
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const workspace = await parseYAML(workspaceFile);
|
|
497
|
+
// Parse workspace.yaml if exists, otherwise use defaults
|
|
498
|
+
const workspace = await fileExists(workspaceFile)
|
|
499
|
+
? await parseYAML(workspaceFile)
|
|
500
|
+
: { summary: entry, repo: 'unknown' };
|
|
501
|
+
|
|
96
502
|
const eventCount = await fileExists(eventsFile) ? await countLines(eventsFile) : 0;
|
|
97
503
|
const isImported = await fileExists(importedMarkerFile);
|
|
98
504
|
const hasInsight = await fileExists(insightReportFile);
|
|
@@ -109,36 +515,30 @@ class SessionRepository {
|
|
|
109
515
|
copilotVersion = optimizedMetadata.copilotVersion;
|
|
110
516
|
selectedModel = optimizedMetadata.selectedModel;
|
|
111
517
|
|
|
112
|
-
// Compute session status:
|
|
113
|
-
// - has session.end → completed
|
|
114
|
-
// - no session.end + last event < 5 min ago → wip (actively running)
|
|
115
|
-
// - no session.end + last event ≥ 5 min ago → unfinished (crashed/aborted)
|
|
116
518
|
sessionStatus = this._computeSessionStatus(optimizedMetadata);
|
|
117
519
|
|
|
118
|
-
// Fallback: if no summary in workspace, use first user message from optimized read
|
|
119
520
|
if (!workspace.summary && optimizedMetadata.firstUserMessage) {
|
|
120
521
|
workspace.summary = optimizedMetadata.firstUserMessage;
|
|
121
522
|
}
|
|
122
523
|
}
|
|
123
524
|
|
|
124
|
-
|
|
525
|
+
const session = Session.fromDirectory(fullPath, entry, stats, workspace, eventCount, duration, isImported, hasInsight, copilotVersion, selectedModel, sessionStatus);
|
|
526
|
+
session.source = source;
|
|
527
|
+
return session;
|
|
125
528
|
}
|
|
126
529
|
|
|
127
530
|
/**
|
|
128
|
-
* Create session from .jsonl file
|
|
531
|
+
* Create session from .jsonl file (Copilot format)
|
|
129
532
|
* @private
|
|
130
533
|
*/
|
|
131
|
-
async _createFileSession(entry, fullPath, stats) {
|
|
534
|
+
async _createFileSession(entry, fullPath, stats, source = 'copilot') {
|
|
132
535
|
const sessionId = entry.replace('.jsonl', '');
|
|
133
536
|
const eventCount = await countLines(fullPath);
|
|
134
537
|
|
|
135
|
-
// Use optimized single-pass metadata extraction
|
|
136
538
|
const optimizedMetadata = await getSessionMetadataOptimized(fullPath);
|
|
137
|
-
|
|
138
|
-
// Compute session status
|
|
139
539
|
const sessionStatus = this._computeSessionStatus(optimizedMetadata);
|
|
140
540
|
|
|
141
|
-
|
|
541
|
+
const session = Session.fromFile(
|
|
142
542
|
fullPath,
|
|
143
543
|
sessionId,
|
|
144
544
|
stats,
|
|
@@ -149,20 +549,20 @@ class SessionRepository {
|
|
|
149
549
|
optimizedMetadata.selectedModel,
|
|
150
550
|
sessionStatus
|
|
151
551
|
);
|
|
552
|
+
session.source = source;
|
|
553
|
+
return session;
|
|
152
554
|
}
|
|
153
555
|
|
|
154
556
|
/**
|
|
155
557
|
* Compute session status from metadata
|
|
156
558
|
* @private
|
|
157
|
-
* @param {Object} metadata - Optimized metadata from getSessionMetadataOptimized
|
|
158
|
-
* @returns {string} 'completed' | 'wip'
|
|
159
559
|
*/
|
|
160
560
|
_computeSessionStatus(metadata) {
|
|
161
561
|
if (metadata.hasSessionEnd) {
|
|
162
562
|
return 'completed';
|
|
163
563
|
}
|
|
164
564
|
if (metadata.lastEventTime !== null && metadata.lastEventTime !== undefined) {
|
|
165
|
-
const WIP_THRESHOLD_MS = 5 * 60 * 1000;
|
|
565
|
+
const WIP_THRESHOLD_MS = 5 * 60 * 1000;
|
|
166
566
|
if ((Date.now() - metadata.lastEventTime) < WIP_THRESHOLD_MS) {
|
|
167
567
|
return 'wip';
|
|
168
568
|
}
|
|
@@ -170,6 +570,141 @@ class SessionRepository {
|
|
|
170
570
|
return 'completed';
|
|
171
571
|
}
|
|
172
572
|
|
|
573
|
+
/**
|
|
574
|
+
* Scan Pi-Mono project directory (--project-path--)
|
|
575
|
+
* Contains timestamped .jsonl files: YYYY-MM-DDTHH-mm-ss-SSSZ_<uuid>.jsonl
|
|
576
|
+
* @private
|
|
577
|
+
*/
|
|
578
|
+
async _scanPiMonoDir(projectDir, dirName) {
|
|
579
|
+
try {
|
|
580
|
+
console.log(`[PI-MONO] Scanning directory: ${projectDir}`);
|
|
581
|
+
const entries = await fs.readdir(projectDir);
|
|
582
|
+
const jsonlFiles = entries.filter(e => e.endsWith('.jsonl'));
|
|
583
|
+
|
|
584
|
+
console.log(`[PI-MONO] Found ${jsonlFiles.length} .jsonl files in ${dirName}`);
|
|
585
|
+
|
|
586
|
+
if (jsonlFiles.length === 0) {
|
|
587
|
+
return [];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const sessions = [];
|
|
591
|
+
|
|
592
|
+
// Sort files by name (timestamp) to get latest
|
|
593
|
+
jsonlFiles.sort().reverse();
|
|
594
|
+
|
|
595
|
+
for (const file of jsonlFiles) {
|
|
596
|
+
const fullPath = path.join(projectDir, file);
|
|
597
|
+
const stats = await fs.stat(fullPath);
|
|
598
|
+
|
|
599
|
+
// Extract session ID from filename: YYYY-MM-DD...Z_<uuid>.jsonl
|
|
600
|
+
const match = file.match(/_([a-f0-9-]+)\.jsonl$/);
|
|
601
|
+
if (!match) {
|
|
602
|
+
console.log(`[PI-MONO] Skipping ${file}: no UUID match`);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const sessionId = match[1];
|
|
607
|
+
|
|
608
|
+
// Read first line to get session metadata
|
|
609
|
+
const firstLine = await this._readFirstLine(fullPath);
|
|
610
|
+
if (!firstLine) {
|
|
611
|
+
console.log(`[PI-MONO] Skipping ${file}: no first line`);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
try {
|
|
616
|
+
const sessionEvent = JSON.parse(firstLine);
|
|
617
|
+
if (sessionEvent.type !== 'session') {
|
|
618
|
+
console.log(`[PI-MONO] Skipping ${file}: first event type is ${sessionEvent.type}, not 'session'`);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Count events in the file
|
|
623
|
+
const eventCount = await countLines(fullPath);
|
|
624
|
+
|
|
625
|
+
// Extract project name from directory (remove -- prefix/suffix)
|
|
626
|
+
const projectPath = dirName.replace(/^--/, '').replace(/--$/, '');
|
|
627
|
+
|
|
628
|
+
const session = new Session(
|
|
629
|
+
sessionId,
|
|
630
|
+
'directory',
|
|
631
|
+
{
|
|
632
|
+
source: 'pi-mono',
|
|
633
|
+
directory: projectDir, // Add directory path for Agent Review
|
|
634
|
+
workspace: { cwd: sessionEvent.cwd || projectPath },
|
|
635
|
+
createdAt: new Date(sessionEvent.timestamp),
|
|
636
|
+
updatedAt: new Date(stats.mtime),
|
|
637
|
+
summary: `Pi-Mono: ${path.basename(sessionEvent.cwd || projectPath)}`,
|
|
638
|
+
hasEvents: eventCount > 0,
|
|
639
|
+
eventCount: eventCount,
|
|
640
|
+
duration: null,
|
|
641
|
+
sessionStatus: 'completed'
|
|
642
|
+
}
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
console.log(`[PI-MONO] Created session: ${sessionId} from ${file}`);
|
|
646
|
+
sessions.push(session);
|
|
647
|
+
} catch (err) {
|
|
648
|
+
console.error(`[PI-MONO] Error parsing session ${file}:`, err.message);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
console.log(`[PI-MONO] Total sessions found in ${dirName}: ${sessions.length}`);
|
|
653
|
+
return sessions;
|
|
654
|
+
} catch (err) {
|
|
655
|
+
console.error(`[PI-MONO] Error scanning dir ${projectDir}:`, err.message);
|
|
656
|
+
return [];
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Read first line of a file
|
|
662
|
+
* @private
|
|
663
|
+
*/
|
|
664
|
+
async _readFirstLine(filePath) {
|
|
665
|
+
const fs = require('fs');
|
|
666
|
+
const readline = require('readline');
|
|
667
|
+
|
|
668
|
+
return new Promise((resolve, reject) => {
|
|
669
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
670
|
+
const rl = readline.createInterface({
|
|
671
|
+
input: stream,
|
|
672
|
+
crlfDelay: Infinity
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
let resolved = false;
|
|
676
|
+
|
|
677
|
+
rl.on('line', (line) => {
|
|
678
|
+
if (!resolved) {
|
|
679
|
+
resolved = true;
|
|
680
|
+
rl.close();
|
|
681
|
+
resolve(line.trim());
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
rl.on('close', () => {
|
|
686
|
+
if (!resolved) {
|
|
687
|
+
resolve(null);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
rl.on('error', (err) => {
|
|
692
|
+
if (!resolved) {
|
|
693
|
+
resolved = true;
|
|
694
|
+
reject(err);
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
stream.on('error', (err) => {
|
|
699
|
+
if (!resolved) {
|
|
700
|
+
resolved = true;
|
|
701
|
+
rl.close();
|
|
702
|
+
reject(err);
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
173
708
|
/**
|
|
174
709
|
* Sort sessions by updated time (newest first)
|
|
175
710
|
* @private
|