@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
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventNormalizer - Unified Event Format Transformer
|
|
3
|
+
*
|
|
4
|
+
* Converts tool events from different AI session formats (Copilot, Claude, Pi-Mono)
|
|
5
|
+
* into a single, consistent schema for frontend consumption.
|
|
6
|
+
*
|
|
7
|
+
* Key transformations:
|
|
8
|
+
* - Normalizes tool call structure to UnifiedToolCall schema
|
|
9
|
+
* - Computes consistent status fields ('pending' | 'running' | 'completed' | 'error')
|
|
10
|
+
* - Adds timing metadata (startTime, endTime, duration)
|
|
11
|
+
* - Handles edge cases (orphaned events, missing fields)
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* const normalizer = new EventNormalizer();
|
|
15
|
+
* const normalizedEvents = normalizer.normalizeEvents(rawEvents, 'copilot');
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
class EventNormalizer {
|
|
19
|
+
/**
|
|
20
|
+
* Normalize all events to unified format
|
|
21
|
+
* @param {Array} events - Raw events from parsers (after matching)
|
|
22
|
+
* @param {string} _source - 'copilot' | 'claude' | 'pi-mono'
|
|
23
|
+
* @returns {Array} - Normalized events
|
|
24
|
+
*/
|
|
25
|
+
normalizeEvents(events, _source) {
|
|
26
|
+
if (!Array.isArray(events)) {
|
|
27
|
+
console.warn('[EventNormalizer] normalizeEvents: events is not an array', typeof events);
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return events
|
|
32
|
+
.filter(event => {
|
|
33
|
+
// Filter out Claude tool_result wrappers (marked by sessionService._matchClaudeToolResults)
|
|
34
|
+
if (event._isToolResultWrapper) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
})
|
|
39
|
+
.map(event => this.normalizeEvent(event, _source));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalize a single event
|
|
44
|
+
* @param {Object} event - Raw event
|
|
45
|
+
* @param {string} source - Source format
|
|
46
|
+
* @returns {Object} - Normalized event
|
|
47
|
+
*/
|
|
48
|
+
normalizeEvent(event, source) {
|
|
49
|
+
if (!event || typeof event !== 'object') {
|
|
50
|
+
console.warn('[EventNormalizer] normalizeEvent: invalid event', event);
|
|
51
|
+
return event;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Handle assistant messages with tools
|
|
55
|
+
if (this._isAssistantMessage(event)) {
|
|
56
|
+
return this._normalizeAssistantMessage(event, source);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Handle timeline events (tool.execution_start/complete, subagent events)
|
|
60
|
+
if (this._isTimelineEvent(event)) {
|
|
61
|
+
return this._normalizeTimelineEvent(event, source);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Pass through other events unchanged
|
|
65
|
+
return event;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if event is an assistant message with tools (needs normalization)
|
|
70
|
+
* @private
|
|
71
|
+
*/
|
|
72
|
+
_isAssistantMessage(event) {
|
|
73
|
+
// Check if event has tools array (works for all sources)
|
|
74
|
+
if (event.data?.tools && Array.isArray(event.data.tools) && event.data.tools.length > 0) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
// Fallback: check specific types (Copilot/Claude legacy)
|
|
78
|
+
return event.type === 'assistant.message' || event.type === 'assistant' || event.type === 'user.message' || event.type === 'user';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if event is a timeline event (tool/subagent events)
|
|
83
|
+
* @private
|
|
84
|
+
*/
|
|
85
|
+
_isTimelineEvent(event) {
|
|
86
|
+
return event.type?.startsWith('tool.') || event.type?.startsWith('subagent.');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Normalize assistant message with embedded tools
|
|
91
|
+
* @private
|
|
92
|
+
*/
|
|
93
|
+
_normalizeAssistantMessage(event, source) {
|
|
94
|
+
const normalized = { ...event };
|
|
95
|
+
|
|
96
|
+
// Normalize tools array if present
|
|
97
|
+
if (event.data?.tools && Array.isArray(event.data.tools)) {
|
|
98
|
+
normalized.data = {
|
|
99
|
+
...event.data,
|
|
100
|
+
tools: event.data.tools
|
|
101
|
+
.filter(tool => tool.type !== 'tool_result') // Filter out orphan tool_result
|
|
102
|
+
.map(tool => this._normalizeToolCall(tool, source, event.timestamp))
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return normalized;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Normalize a tool call to unified schema
|
|
111
|
+
*
|
|
112
|
+
* UnifiedToolCall schema:
|
|
113
|
+
* {
|
|
114
|
+
* id: string,
|
|
115
|
+
* name: string,
|
|
116
|
+
* startTime: string (ISO 8601),
|
|
117
|
+
* endTime: string | null,
|
|
118
|
+
* status: 'pending' | 'running' | 'completed' | 'error',
|
|
119
|
+
* input: Record<string, any>,
|
|
120
|
+
* result: string | null,
|
|
121
|
+
* error: string | null,
|
|
122
|
+
* metadata: {
|
|
123
|
+
* source: string,
|
|
124
|
+
* duration?: number,
|
|
125
|
+
* ...
|
|
126
|
+
* }
|
|
127
|
+
* }
|
|
128
|
+
*
|
|
129
|
+
* @private
|
|
130
|
+
*/
|
|
131
|
+
_normalizeToolCall(tool, source, messageTimestamp) {
|
|
132
|
+
// Handle Copilot/Claude format with _matched flag
|
|
133
|
+
if (tool.type === 'tool_use') {
|
|
134
|
+
const status = this._computeStatus(tool);
|
|
135
|
+
const startTime = messageTimestamp;
|
|
136
|
+
const endTime = tool._matched ? messageTimestamp : null;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
type: 'tool_use', // Preserve type for frontend compatibility
|
|
140
|
+
id: tool.id,
|
|
141
|
+
name: tool.name,
|
|
142
|
+
startTime,
|
|
143
|
+
endTime,
|
|
144
|
+
status,
|
|
145
|
+
input: tool.input || {},
|
|
146
|
+
result: tool.result || null,
|
|
147
|
+
error: tool.error || null,
|
|
148
|
+
metadata: {
|
|
149
|
+
source,
|
|
150
|
+
matched: tool._matched,
|
|
151
|
+
duration: this._computeDuration(startTime, endTime)
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Handle Pi-Mono format (already has status)
|
|
157
|
+
if (tool.name && tool.status) {
|
|
158
|
+
const startTime = messageTimestamp;
|
|
159
|
+
// Normalize 'success' to 'completed' for backward compatibility
|
|
160
|
+
const normalizedStatus = tool.status === 'success' ? 'completed' : tool.status;
|
|
161
|
+
const endTime = normalizedStatus === 'completed' || normalizedStatus === 'error'
|
|
162
|
+
? messageTimestamp
|
|
163
|
+
: null;
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
id: tool.id || this._generateToolId(),
|
|
167
|
+
name: tool.name,
|
|
168
|
+
startTime,
|
|
169
|
+
endTime,
|
|
170
|
+
status: normalizedStatus,
|
|
171
|
+
input: tool.input || {},
|
|
172
|
+
result: tool.isError ? null : (tool.result || null),
|
|
173
|
+
error: tool.isError ? tool.result : null,
|
|
174
|
+
metadata: {
|
|
175
|
+
source,
|
|
176
|
+
duration: this._computeDuration(startTime, endTime)
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Fallback: minimal normalization for unknown formats
|
|
182
|
+
console.warn('[EventNormalizer] Unknown tool format, applying fallback normalization', tool);
|
|
183
|
+
return {
|
|
184
|
+
id: tool.id || this._generateToolId(),
|
|
185
|
+
name: tool.name || 'unknown',
|
|
186
|
+
startTime: messageTimestamp,
|
|
187
|
+
endTime: null,
|
|
188
|
+
status: 'running',
|
|
189
|
+
input: tool.input || {},
|
|
190
|
+
result: null,
|
|
191
|
+
error: null,
|
|
192
|
+
metadata: {
|
|
193
|
+
source,
|
|
194
|
+
fallback: true
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Compute tool status from tool object
|
|
201
|
+
* @private
|
|
202
|
+
*/
|
|
203
|
+
_computeStatus(tool) {
|
|
204
|
+
// Explicit error indication
|
|
205
|
+
if (tool.error) {
|
|
206
|
+
return 'error';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Has result = completed (regardless of _matched flag)
|
|
210
|
+
if (tool.result !== undefined && tool.result !== null && tool.result !== '') {
|
|
211
|
+
return 'completed';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Explicitly unmatched with no result
|
|
215
|
+
if (tool._matched === false) {
|
|
216
|
+
return 'running';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Matched = completed
|
|
220
|
+
if (tool._matched) {
|
|
221
|
+
return 'completed';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// No match info: assume running
|
|
225
|
+
return 'running';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Compute duration in milliseconds from start/end timestamps
|
|
230
|
+
* @private
|
|
231
|
+
*/
|
|
232
|
+
_computeDuration(startTime, endTime) {
|
|
233
|
+
if (!startTime || !endTime) {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const start = new Date(startTime);
|
|
239
|
+
const end = new Date(endTime);
|
|
240
|
+
|
|
241
|
+
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const duration = end.getTime() - start.getTime();
|
|
246
|
+
return duration >= 0 ? duration : undefined;
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Generate a unique tool ID
|
|
254
|
+
* @private
|
|
255
|
+
*/
|
|
256
|
+
_generateToolId() {
|
|
257
|
+
return `tool-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Normalize timeline events (tool.execution_start/complete, subagent events)
|
|
262
|
+
* Ensures consistent schema for these events
|
|
263
|
+
* @private
|
|
264
|
+
*/
|
|
265
|
+
_normalizeTimelineEvent(event) {
|
|
266
|
+
// For tool.execution_start/complete, ensure consistent schema
|
|
267
|
+
if (event.type === 'tool.execution_start' || event.type === 'tool.execution_complete') {
|
|
268
|
+
return {
|
|
269
|
+
...event,
|
|
270
|
+
data: {
|
|
271
|
+
...event.data,
|
|
272
|
+
// Normalize field names for consistency
|
|
273
|
+
toolCallId: event.data?.toolCallId || event.data?.id,
|
|
274
|
+
toolName: event.data?.toolName || event.data?.tool || event.data?.name,
|
|
275
|
+
// Preserve original fields
|
|
276
|
+
...event.data
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Subagent events: pass through (already have consistent schema)
|
|
282
|
+
if (event.type?.startsWith('subagent.')) {
|
|
283
|
+
return event;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Unknown timeline event: pass through
|
|
287
|
+
return event;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = EventNormalizer;
|
|
@@ -12,21 +12,74 @@ const config = require('../config');
|
|
|
12
12
|
const processManager = require('../utils/processManager');
|
|
13
13
|
|
|
14
14
|
class InsightService {
|
|
15
|
-
constructor(
|
|
16
|
-
|
|
15
|
+
constructor() {
|
|
16
|
+
// No longer needs session directories - paths are passed directly
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get CLI tool configuration based on session source
|
|
21
|
+
* @private
|
|
22
|
+
*/
|
|
23
|
+
_getToolConfig(source, sessionPath) {
|
|
24
|
+
const configs = {
|
|
25
|
+
copilot: {
|
|
26
|
+
name: 'Copilot',
|
|
27
|
+
cli: 'copilot',
|
|
28
|
+
args: (tmpDir, prompt) => ['--config-dir', tmpDir, '--yolo', '-p', prompt],
|
|
29
|
+
cwd: sessionPath
|
|
30
|
+
},
|
|
31
|
+
claude: {
|
|
32
|
+
name: 'Claude Code',
|
|
33
|
+
cli: 'claude',
|
|
34
|
+
args: (_tmpDir, prompt) => ['-p', prompt, '--dangerously-skip-permissions'],
|
|
35
|
+
cwd: sessionPath
|
|
36
|
+
},
|
|
37
|
+
'pi-mono': {
|
|
38
|
+
name: 'Pi',
|
|
39
|
+
cli: 'pi',
|
|
40
|
+
args: (_tmpDir, prompt) => ['-p', prompt],
|
|
41
|
+
cwd: sessionPath
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return configs[source] || configs.copilot; // fallback to copilot
|
|
17
46
|
}
|
|
18
47
|
|
|
19
48
|
/**
|
|
20
49
|
* Generate or retrieve insight report
|
|
21
|
-
* @param {string} sessionId - Session
|
|
50
|
+
* @param {string} sessionId - Session ID
|
|
51
|
+
* @param {string} sessionPath - Full path to session directory
|
|
52
|
+
* @param {string} source - Session source: 'copilot', 'claude', or 'pi-mono'
|
|
22
53
|
* @param {boolean} forceRegenerate - Force new generation
|
|
23
54
|
* @returns {Promise<Object>} Insight status and report
|
|
24
55
|
*/
|
|
25
|
-
async generateInsight(sessionId, forceRegenerate = false) {
|
|
26
|
-
const sessionPath = path.join(this.sessionDir, sessionId);
|
|
56
|
+
async generateInsight(sessionId, sessionPath, source = 'copilot', forceRegenerate = false) {
|
|
27
57
|
const insightFile = path.join(sessionPath, 'agent-review.md');
|
|
28
58
|
const lockFile = path.join(sessionPath, 'agent-review.md.lock');
|
|
29
|
-
|
|
59
|
+
|
|
60
|
+
// Determine events file location based on directory structure
|
|
61
|
+
// Try standard events.jsonl first, then <sessionId>.jsonl (for file-type sessions),
|
|
62
|
+
// finally *_<sessionId>.jsonl (for Pi-Mono timestamped sessions)
|
|
63
|
+
let eventsFile = path.join(sessionPath, 'events.jsonl');
|
|
64
|
+
try {
|
|
65
|
+
await fs.access(eventsFile);
|
|
66
|
+
} catch {
|
|
67
|
+
// Try <sessionId>.jsonl (common for Claude file-type sessions)
|
|
68
|
+
try {
|
|
69
|
+
eventsFile = path.join(sessionPath, `${sessionId}.jsonl`);
|
|
70
|
+
await fs.access(eventsFile);
|
|
71
|
+
} catch {
|
|
72
|
+
// Try *_<sessionId>.jsonl (Pi-Mono format: YYYY-MM-DDTHH-mm-ss-SSSZ_<uuid>.jsonl)
|
|
73
|
+
const entries = await fs.readdir(sessionPath);
|
|
74
|
+
const piFile = entries.find(f => f.endsWith(`_${sessionId}.jsonl`));
|
|
75
|
+
if (piFile) {
|
|
76
|
+
eventsFile = path.join(sessionPath, piFile);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const toolConfig = this._getToolConfig(source, sessionPath);
|
|
82
|
+
const toolName = toolConfig.name;
|
|
30
83
|
|
|
31
84
|
// Check if complete insight exists
|
|
32
85
|
if (!forceRegenerate) {
|
|
@@ -63,7 +116,7 @@ class InsightService {
|
|
|
63
116
|
// Still valid, return generating status
|
|
64
117
|
return {
|
|
65
118
|
status: 'generating',
|
|
66
|
-
report:
|
|
119
|
+
report: `# Generating ${toolName} Insight...\n\nAnother request is currently generating this insight. Please wait.`,
|
|
67
120
|
startedAt: lockStats.birthtime,
|
|
68
121
|
lastUpdate: lockStats.mtime,
|
|
69
122
|
ageMs: Date.now() - lockStats.birthtime.getTime()
|
|
@@ -106,77 +159,92 @@ class InsightService {
|
|
|
106
159
|
}
|
|
107
160
|
|
|
108
161
|
// Start generation
|
|
109
|
-
await this.
|
|
162
|
+
await this._spawnAnalysisProcess(sessionPath, eventsFile, insightFile, lockFile, toolConfig);
|
|
110
163
|
|
|
111
164
|
return {
|
|
112
165
|
status: 'generating',
|
|
113
|
-
report:
|
|
166
|
+
report: `# Generating ${toolName} Insight...\n\nAnalysis in progress. Please wait.`,
|
|
114
167
|
startedAt: new Date()
|
|
115
168
|
};
|
|
116
169
|
}
|
|
117
170
|
|
|
118
171
|
/**
|
|
119
|
-
* Spawn
|
|
172
|
+
* Spawn analysis process safely (no shell)
|
|
120
173
|
* @private
|
|
121
174
|
*/
|
|
122
|
-
async
|
|
175
|
+
async _spawnAnalysisProcess(sessionPath, eventsFile, insightFile, lockFile, toolConfig) {
|
|
176
|
+
const sessionId = path.basename(sessionPath); // Extract session ID from path
|
|
123
177
|
const tmpDir = path.join(os.tmpdir(), `agent-review-${sessionId}-${Date.now()}`);
|
|
124
|
-
await fs.mkdir(tmpDir, { recursive: true
|
|
178
|
+
await fs.mkdir(tmpDir, { recursive: true});
|
|
125
179
|
|
|
126
|
-
const prompt = this._buildPrompt(insightFile);
|
|
180
|
+
const prompt = this._buildPrompt(insightFile, eventsFile);
|
|
127
181
|
const outputFile = path.join(sessionPath, 'agent-review.md.tmp');
|
|
128
182
|
|
|
129
|
-
// Spawn
|
|
130
|
-
const
|
|
131
|
-
const args =
|
|
183
|
+
// Spawn analysis tool directly (no shell)
|
|
184
|
+
const cliPath = toolConfig.cli;
|
|
185
|
+
const args = toolConfig.args(tmpDir, prompt);
|
|
186
|
+
|
|
187
|
+
console.log(`🤖 Starting ${toolConfig.name} analysis: ${cliPath} ${args.slice(0, 2).join(' ')}...`);
|
|
188
|
+
console.log(`📋 Args count: ${args.length}, prompt length: ${prompt.length} chars`);
|
|
132
189
|
|
|
133
|
-
// Use system PATH -
|
|
134
|
-
const
|
|
190
|
+
// Use system PATH - CLI should be in the user's PATH
|
|
191
|
+
const analysisProcess = spawn(cliPath, args, {
|
|
135
192
|
env: { ...process.env },
|
|
136
193
|
cwd: sessionPath,
|
|
137
194
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
138
195
|
});
|
|
139
196
|
|
|
140
197
|
// Register for cleanup
|
|
141
|
-
processManager.register(
|
|
142
|
-
|
|
143
|
-
// Pipe events file to stdin
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
198
|
+
processManager.register(analysisProcess, { name: `insight-${sessionId}` });
|
|
199
|
+
|
|
200
|
+
// Pipe events file to stdin (for tools that read from stdin like copilot)
|
|
201
|
+
// Claude Code and Pi read files directly, so they don't need stdin
|
|
202
|
+
if (toolConfig.cli === 'copilot') {
|
|
203
|
+
const eventsStream = fsSync.createReadStream(eventsFile);
|
|
204
|
+
// Handle EPIPE: if process exits before stdin is fully written, suppress the error
|
|
205
|
+
analysisProcess.stdin.on('error', (err) => {
|
|
206
|
+
if (err.code === 'EPIPE') {
|
|
207
|
+
eventsStream.destroy();
|
|
208
|
+
} else {
|
|
209
|
+
console.error('❌ stdin error:', err);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
eventsStream.pipe(analysisProcess.stdin);
|
|
213
|
+
} else {
|
|
214
|
+
// Close stdin for tools that don't need it
|
|
215
|
+
analysisProcess.stdin.end();
|
|
216
|
+
}
|
|
154
217
|
|
|
155
218
|
// Capture output
|
|
156
219
|
const outputStream = fsSync.createWriteStream(outputFile);
|
|
157
|
-
|
|
220
|
+
analysisProcess.stdout.pipe(outputStream);
|
|
158
221
|
|
|
159
222
|
// Capture stderr with size limit
|
|
160
223
|
const stderrChunks = [];
|
|
161
224
|
let stderrSize = 0;
|
|
162
225
|
const MAX_STDERR = 64 * 1024; // 64KB cap
|
|
163
226
|
|
|
164
|
-
|
|
227
|
+
analysisProcess.stderr.on('data', (data) => {
|
|
165
228
|
if (stderrSize < MAX_STDERR) {
|
|
166
229
|
stderrChunks.push(data);
|
|
167
230
|
stderrSize += data.length;
|
|
168
231
|
}
|
|
169
232
|
});
|
|
170
233
|
|
|
171
|
-
|
|
234
|
+
analysisProcess.on('close', async (code) => {
|
|
172
235
|
try {
|
|
173
236
|
outputStream.end();
|
|
174
237
|
|
|
238
|
+
const stderr = Buffer.concat(stderrChunks).toString('utf-8').slice(0, MAX_STDERR);
|
|
239
|
+
console.log(`📋 ${toolConfig.name} process exited with code ${code}`);
|
|
240
|
+
if (stderr) {
|
|
241
|
+
console.log(`📋 ${toolConfig.name} stderr:`, stderr.substring(0, 500));
|
|
242
|
+
}
|
|
243
|
+
|
|
175
244
|
if (code !== 0) {
|
|
176
|
-
|
|
177
|
-
console.error('❌ Copilot CLI failed:', stderr);
|
|
245
|
+
console.error(`❌ ${toolConfig.name} CLI failed (code ${code}):`, stderr);
|
|
178
246
|
await fs.writeFile(insightFile,
|
|
179
|
-
`# ❌ Generation Failed\n\n\`\`\`\n${stderr}\n\`\`\`\n`,
|
|
247
|
+
`# ❌ Generation Failed\n\nExit code: ${code}\n\n\`\`\`\n${stderr || '(no error output)'}\n\`\`\`\n`,
|
|
180
248
|
'utf-8'
|
|
181
249
|
);
|
|
182
250
|
} else {
|
|
@@ -212,8 +280,8 @@ class InsightService {
|
|
|
212
280
|
}
|
|
213
281
|
});
|
|
214
282
|
|
|
215
|
-
|
|
216
|
-
console.error(
|
|
283
|
+
analysisProcess.on('error', async (err) => {
|
|
284
|
+
console.error(`❌ Failed to spawn ${toolConfig.name}:`, err);
|
|
217
285
|
await fs.unlink(lockFile).catch(() => {});
|
|
218
286
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
219
287
|
});
|
|
@@ -223,19 +291,26 @@ class InsightService {
|
|
|
223
291
|
* Build insight generation prompt
|
|
224
292
|
* @private
|
|
225
293
|
*/
|
|
226
|
-
_buildPrompt(outputPath) {
|
|
294
|
+
_buildPrompt(outputPath, eventsFile) {
|
|
227
295
|
const sessionDir = path.dirname(outputPath);
|
|
296
|
+
const eventsFilename = path.basename(eventsFile);
|
|
228
297
|
const workDir = `${sessionDir}/.output`;
|
|
229
|
-
|
|
230
|
-
|
|
298
|
+
|
|
299
|
+
// For Pi-Mono: emphasize analyzing ONLY the specified file
|
|
300
|
+
const fileInstruction = eventsFilename.includes('_')
|
|
301
|
+
? `\n**IMPORTANT**: This directory may contain multiple .jsonl files. You MUST analyze ONLY the file named \`${eventsFilename}\`. Do NOT read or analyze any other .jsonl files in this directory.\n`
|
|
302
|
+
: '';
|
|
303
|
+
|
|
304
|
+
return `You are an expert AI agent evaluator. The current working directory is an AI coding agent session folder. It contains the raw session data from an agent run.
|
|
305
|
+
${fileInstruction}
|
|
231
306
|
**Step 1 — Discover session files.** Run \`ls -la\` to see what's available, then note which files exist:
|
|
232
|
-
- \`
|
|
307
|
+
- \`${eventsFilename}\` — the main session event log (JSONL, one JSON event per line). Primary data source. May be large. **This is the ONLY events file you should analyze.**
|
|
233
308
|
- \`plan.md\` — the agent's plan (if it exists).
|
|
234
309
|
- \`workspace.yaml\` — workspace configuration (if it exists).
|
|
235
310
|
- Any other relevant files.
|
|
236
311
|
|
|
237
312
|
**Step 2 — Spawn 3 sub-agents for parallel analysis.** First create the working directory: \`mkdir -p ${workDir}\`. Then use the Task tool to launch ALL of the following sub-agents simultaneously (in a single message with multiple Task tool calls). Each sub-agent should:
|
|
238
|
-
- Read \`
|
|
313
|
+
- Read \`${eventsFilename}\` from \`${sessionDir}\` (use Bash: \`cat\`, \`jq\`, or \`python3\` to parse) **— ONLY this file, ignore others**
|
|
239
314
|
- Read other session files as needed
|
|
240
315
|
- Write its findings to an intermediate file in \`${workDir}/\`
|
|
241
316
|
- Return a summary of its findings
|
|
@@ -362,8 +437,22 @@ IMPORTANT CONSTRAINTS:
|
|
|
362
437
|
/**
|
|
363
438
|
* Get insight status
|
|
364
439
|
*/
|
|
365
|
-
|
|
366
|
-
|
|
440
|
+
/**
|
|
441
|
+
* Get insight status
|
|
442
|
+
* @param {string} sessionId - Session ID
|
|
443
|
+
* @param {string} sessionPath - Full path to session directory
|
|
444
|
+
* @param {string} source - Session source
|
|
445
|
+
* @returns {Promise<Object>} Status object
|
|
446
|
+
*/
|
|
447
|
+
async getInsightStatus(sessionId, sessionPath, _source = 'copilot') {
|
|
448
|
+
return await this._getStatusForSource(sessionPath);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Get status for a specific session directory
|
|
453
|
+
* @private
|
|
454
|
+
*/
|
|
455
|
+
async _getStatusForSource(sessionPath) {
|
|
367
456
|
const insightFile = path.join(sessionPath, 'agent-review.md');
|
|
368
457
|
const lockFile = path.join(sessionPath, 'agent-review.md.lock');
|
|
369
458
|
const tmpFile = path.join(sessionPath, 'agent-review.md.tmp');
|
|
@@ -416,9 +505,12 @@ IMPORTANT CONSTRAINTS:
|
|
|
416
505
|
|
|
417
506
|
/**
|
|
418
507
|
* Delete insight report
|
|
508
|
+
* @param {string} sessionId - Session ID
|
|
509
|
+
* @param {string} sessionPath - Full path to session directory
|
|
510
|
+
* @param {string} source - Session source
|
|
511
|
+
* @returns {Promise<Object>} Result object
|
|
419
512
|
*/
|
|
420
|
-
async deleteInsight(sessionId) {
|
|
421
|
-
const sessionPath = path.join(this.sessionDir, sessionId);
|
|
513
|
+
async deleteInsight(sessionId, sessionPath, _source = 'copilot') {
|
|
422
514
|
const insightFile = path.join(sessionPath, 'agent-review.md');
|
|
423
515
|
|
|
424
516
|
try {
|