@litmers/cursorflow-orchestrator 0.1.13 → 0.1.14
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/CHANGELOG.md +34 -0
- package/README.md +83 -2
- package/commands/cursorflow-clean.md +20 -6
- package/commands/cursorflow-prepare.md +1 -1
- package/commands/cursorflow-resume.md +127 -6
- package/commands/cursorflow-run.md +2 -2
- package/commands/cursorflow-signal.md +11 -4
- package/dist/cli/clean.js +164 -12
- package/dist/cli/clean.js.map +1 -1
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +6 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/logs.d.ts +8 -0
- package/dist/cli/logs.js +746 -0
- package/dist/cli/logs.js.map +1 -0
- package/dist/cli/monitor.js +113 -30
- package/dist/cli/monitor.js.map +1 -1
- package/dist/cli/prepare.js +1 -1
- package/dist/cli/resume.js +367 -18
- package/dist/cli/resume.js.map +1 -1
- package/dist/cli/run.js +2 -0
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/signal.js +34 -20
- package/dist/cli/signal.js.map +1 -1
- package/dist/core/orchestrator.d.ts +11 -1
- package/dist/core/orchestrator.js +257 -35
- package/dist/core/orchestrator.js.map +1 -1
- package/dist/core/reviewer.js +20 -0
- package/dist/core/reviewer.js.map +1 -1
- package/dist/core/runner.js +113 -13
- package/dist/core/runner.js.map +1 -1
- package/dist/utils/config.js +34 -0
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/enhanced-logger.d.ts +209 -0
- package/dist/utils/enhanced-logger.js +963 -0
- package/dist/utils/enhanced-logger.js.map +1 -0
- package/dist/utils/events.d.ts +59 -0
- package/dist/utils/events.js +37 -0
- package/dist/utils/events.js.map +1 -0
- package/dist/utils/git.d.ts +5 -0
- package/dist/utils/git.js +25 -0
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/types.d.ts +122 -1
- package/dist/utils/webhook.d.ts +5 -0
- package/dist/utils/webhook.js +109 -0
- package/dist/utils/webhook.js.map +1 -0
- package/examples/README.md +1 -1
- package/package.json +1 -1
- package/scripts/simple-logging-test.sh +97 -0
- package/scripts/test-real-logging.sh +289 -0
- package/scripts/test-streaming-multi-task.sh +247 -0
- package/src/cli/clean.ts +170 -13
- package/src/cli/index.ts +4 -1
- package/src/cli/logs.ts +848 -0
- package/src/cli/monitor.ts +123 -30
- package/src/cli/prepare.ts +1 -1
- package/src/cli/resume.ts +463 -22
- package/src/cli/run.ts +2 -0
- package/src/cli/signal.ts +43 -27
- package/src/core/orchestrator.ts +303 -37
- package/src/core/reviewer.ts +22 -0
- package/src/core/runner.ts +128 -12
- package/src/utils/config.ts +36 -0
- package/src/utils/enhanced-logger.ts +1097 -0
- package/src/utils/events.ts +117 -0
- package/src/utils/git.ts +25 -0
- package/src/utils/types.ts +150 -1
- package/src/utils/webhook.ts +85 -0
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enhanced Logger - Comprehensive terminal output capture and management
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - ANSI escape sequence stripping for clean logs
|
|
6
|
+
* - Automatic timestamps on each line
|
|
7
|
+
* - Log rotation and size management
|
|
8
|
+
* - Session headers with context
|
|
9
|
+
* - Raw and clean log file options
|
|
10
|
+
* - Structured JSON logs for programmatic access
|
|
11
|
+
* - Streaming output support for real-time capture
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import { PassThrough, Transform, TransformCallback } from 'stream';
|
|
17
|
+
import { EnhancedLogConfig } from './types';
|
|
18
|
+
|
|
19
|
+
// Re-export for backwards compatibility
|
|
20
|
+
export { EnhancedLogConfig } from './types';
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_LOG_CONFIG: EnhancedLogConfig = {
|
|
23
|
+
enabled: true,
|
|
24
|
+
stripAnsi: true,
|
|
25
|
+
addTimestamps: true,
|
|
26
|
+
maxFileSize: 50 * 1024 * 1024, // 50MB
|
|
27
|
+
maxFiles: 5,
|
|
28
|
+
keepRawLogs: true,
|
|
29
|
+
writeJsonLog: true,
|
|
30
|
+
timestampFormat: 'iso',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Streaming JSON Parser - Parses cursor-agent stream-json output
|
|
35
|
+
* and combines tokens into readable messages
|
|
36
|
+
*/
|
|
37
|
+
export class StreamingMessageParser {
|
|
38
|
+
private currentMessage: string = '';
|
|
39
|
+
private currentRole: string = '';
|
|
40
|
+
private messageStartTime: number = 0;
|
|
41
|
+
private onMessage: (msg: ParsedMessage) => void;
|
|
42
|
+
|
|
43
|
+
constructor(onMessage: (msg: ParsedMessage) => void) {
|
|
44
|
+
this.onMessage = onMessage;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse a line of JSON output from cursor-agent
|
|
49
|
+
*/
|
|
50
|
+
parseLine(line: string): void {
|
|
51
|
+
const trimmed = line.trim();
|
|
52
|
+
if (!trimmed || !trimmed.startsWith('{')) return;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const json = JSON.parse(trimmed);
|
|
56
|
+
this.handleJsonMessage(json);
|
|
57
|
+
} catch {
|
|
58
|
+
// Not valid JSON, ignore
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private handleJsonMessage(json: any): void {
|
|
63
|
+
const type = json.type;
|
|
64
|
+
|
|
65
|
+
switch (type) {
|
|
66
|
+
case 'system':
|
|
67
|
+
// System init message
|
|
68
|
+
this.emitMessage({
|
|
69
|
+
type: 'system',
|
|
70
|
+
role: 'system',
|
|
71
|
+
content: `[System] Model: ${json.model || 'unknown'}, Mode: ${json.permissionMode || 'default'}`,
|
|
72
|
+
timestamp: json.timestamp_ms || Date.now(),
|
|
73
|
+
});
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
case 'user':
|
|
77
|
+
// User message - emit as complete message
|
|
78
|
+
if (json.message?.content) {
|
|
79
|
+
const textContent = json.message.content
|
|
80
|
+
.filter((c: any) => c.type === 'text')
|
|
81
|
+
.map((c: any) => c.text)
|
|
82
|
+
.join('');
|
|
83
|
+
|
|
84
|
+
this.emitMessage({
|
|
85
|
+
type: 'user',
|
|
86
|
+
role: 'user',
|
|
87
|
+
content: textContent,
|
|
88
|
+
timestamp: json.timestamp_ms || Date.now(),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
break;
|
|
92
|
+
|
|
93
|
+
case 'assistant':
|
|
94
|
+
// Streaming assistant message - accumulate tokens
|
|
95
|
+
if (json.message?.content) {
|
|
96
|
+
const textContent = json.message.content
|
|
97
|
+
.filter((c: any) => c.type === 'text')
|
|
98
|
+
.map((c: any) => c.text)
|
|
99
|
+
.join('');
|
|
100
|
+
|
|
101
|
+
// Check if this is a new message or continuation
|
|
102
|
+
if (this.currentRole !== 'assistant') {
|
|
103
|
+
// Flush previous message if any
|
|
104
|
+
this.flush();
|
|
105
|
+
this.currentRole = 'assistant';
|
|
106
|
+
this.messageStartTime = json.timestamp_ms || Date.now();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.currentMessage += textContent;
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
|
|
113
|
+
case 'tool_call':
|
|
114
|
+
// Tool call - emit as formatted message
|
|
115
|
+
if (json.subtype === 'started' && json.tool_call) {
|
|
116
|
+
const toolName = Object.keys(json.tool_call)[0] || 'unknown';
|
|
117
|
+
const toolArgs = json.tool_call[toolName]?.args || {};
|
|
118
|
+
|
|
119
|
+
this.flush(); // Flush any pending assistant message
|
|
120
|
+
|
|
121
|
+
this.emitMessage({
|
|
122
|
+
type: 'tool',
|
|
123
|
+
role: 'tool',
|
|
124
|
+
content: `[Tool: ${toolName}] ${JSON.stringify(toolArgs)}`,
|
|
125
|
+
timestamp: json.timestamp_ms || Date.now(),
|
|
126
|
+
metadata: { callId: json.call_id, toolName },
|
|
127
|
+
});
|
|
128
|
+
} else if (json.subtype === 'completed' && json.tool_call) {
|
|
129
|
+
const toolName = Object.keys(json.tool_call)[0] || 'unknown';
|
|
130
|
+
const result = json.tool_call[toolName]?.result;
|
|
131
|
+
|
|
132
|
+
if (result?.success) {
|
|
133
|
+
// Truncate large results
|
|
134
|
+
const content = result.success.content || '';
|
|
135
|
+
const truncated = content.length > 500
|
|
136
|
+
? content.substring(0, 500) + '... (truncated)'
|
|
137
|
+
: content;
|
|
138
|
+
|
|
139
|
+
this.emitMessage({
|
|
140
|
+
type: 'tool_result',
|
|
141
|
+
role: 'tool',
|
|
142
|
+
content: `[Tool Result: ${toolName}] ${truncated}`,
|
|
143
|
+
timestamp: json.timestamp_ms || Date.now(),
|
|
144
|
+
metadata: { callId: json.call_id, toolName, lines: result.success.totalLines },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
|
|
150
|
+
case 'result':
|
|
151
|
+
// Final result - flush any pending and emit result
|
|
152
|
+
this.flush();
|
|
153
|
+
|
|
154
|
+
this.emitMessage({
|
|
155
|
+
type: 'result',
|
|
156
|
+
role: 'assistant',
|
|
157
|
+
content: json.result || '',
|
|
158
|
+
timestamp: json.timestamp_ms || Date.now(),
|
|
159
|
+
metadata: {
|
|
160
|
+
duration_ms: json.duration_ms,
|
|
161
|
+
is_error: json.is_error,
|
|
162
|
+
subtype: json.subtype,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Flush accumulated message
|
|
171
|
+
*/
|
|
172
|
+
flush(): void {
|
|
173
|
+
if (this.currentMessage && this.currentRole) {
|
|
174
|
+
this.emitMessage({
|
|
175
|
+
type: this.currentRole as any,
|
|
176
|
+
role: this.currentRole,
|
|
177
|
+
content: this.currentMessage,
|
|
178
|
+
timestamp: this.messageStartTime,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
this.currentMessage = '';
|
|
182
|
+
this.currentRole = '';
|
|
183
|
+
this.messageStartTime = 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private emitMessage(msg: ParsedMessage): void {
|
|
187
|
+
if (msg.content.trim()) {
|
|
188
|
+
this.onMessage(msg);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface ParsedMessage {
|
|
194
|
+
type: 'system' | 'user' | 'assistant' | 'tool' | 'tool_result' | 'result';
|
|
195
|
+
role: string;
|
|
196
|
+
content: string;
|
|
197
|
+
timestamp: number;
|
|
198
|
+
metadata?: Record<string, any>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* ANSI escape sequence regex pattern
|
|
203
|
+
* Matches:
|
|
204
|
+
* - CSI sequences (colors, cursor movement, etc.)
|
|
205
|
+
* - OSC sequences (terminal titles, etc.)
|
|
206
|
+
* - Single-character escape codes
|
|
207
|
+
*/
|
|
208
|
+
const ANSI_REGEX = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Extended ANSI regex for more complete stripping
|
|
212
|
+
*/
|
|
213
|
+
const EXTENDED_ANSI_REGEX = /(?:\x1B[@-Z\\-_]|\x1B\[[0-?]*[ -/]*[@-~]|\x1B\][^\x07]*(?:\x07|\x1B\\)|\x1B[PX^_][^\x1B]*\x1B\\|\x1B.)/g;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Strip ANSI escape sequences from text
|
|
217
|
+
*/
|
|
218
|
+
export function stripAnsi(text: string): string {
|
|
219
|
+
return text
|
|
220
|
+
.replace(EXTENDED_ANSI_REGEX, '')
|
|
221
|
+
.replace(ANSI_REGEX, '')
|
|
222
|
+
// Also remove carriage returns that overwrite lines (progress bars, etc.)
|
|
223
|
+
.replace(/\r[^\n]/g, '\n')
|
|
224
|
+
// Clean up any remaining control characters except newlines/tabs
|
|
225
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Format timestamp based on format preference
|
|
230
|
+
*/
|
|
231
|
+
export function formatTimestamp(format: 'iso' | 'relative' | 'short', startTime?: number): string {
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
|
|
234
|
+
switch (format) {
|
|
235
|
+
case 'iso':
|
|
236
|
+
return new Date(now).toISOString();
|
|
237
|
+
case 'relative':
|
|
238
|
+
if (startTime) {
|
|
239
|
+
const elapsed = now - startTime;
|
|
240
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
241
|
+
const minutes = Math.floor(seconds / 60);
|
|
242
|
+
const hours = Math.floor(minutes / 60);
|
|
243
|
+
|
|
244
|
+
if (hours > 0) {
|
|
245
|
+
return `+${hours}h${minutes % 60}m${seconds % 60}s`;
|
|
246
|
+
} else if (minutes > 0) {
|
|
247
|
+
return `+${minutes}m${seconds % 60}s`;
|
|
248
|
+
} else {
|
|
249
|
+
return `+${seconds}s`;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return new Date(now).toISOString();
|
|
253
|
+
case 'short':
|
|
254
|
+
return new Date(now).toLocaleTimeString('en-US', { hour12: false });
|
|
255
|
+
default:
|
|
256
|
+
return new Date(now).toISOString();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* JSON log entry structure
|
|
262
|
+
*/
|
|
263
|
+
export interface JsonLogEntry {
|
|
264
|
+
timestamp: string;
|
|
265
|
+
level: 'stdout' | 'stderr' | 'info' | 'error' | 'debug' | 'session';
|
|
266
|
+
source?: string;
|
|
267
|
+
task?: string;
|
|
268
|
+
lane?: string;
|
|
269
|
+
message: string;
|
|
270
|
+
raw?: string;
|
|
271
|
+
metadata?: Record<string, any>;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Session context for logging
|
|
276
|
+
*/
|
|
277
|
+
export interface LogSession {
|
|
278
|
+
id: string;
|
|
279
|
+
laneName: string;
|
|
280
|
+
taskName?: string;
|
|
281
|
+
model?: string;
|
|
282
|
+
startTime: number;
|
|
283
|
+
metadata?: Record<string, any>;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Regex to detect if a line already has an ISO timestamp at the start
|
|
288
|
+
*/
|
|
289
|
+
const EXISTING_TIMESTAMP_REGEX = /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Check if a line already has a timestamp
|
|
293
|
+
*/
|
|
294
|
+
function hasExistingTimestamp(line: string): boolean {
|
|
295
|
+
return EXISTING_TIMESTAMP_REGEX.test(line.trim());
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Transform stream that strips ANSI and adds timestamps
|
|
300
|
+
*/
|
|
301
|
+
export class CleanLogTransform extends Transform {
|
|
302
|
+
private config: EnhancedLogConfig;
|
|
303
|
+
private session: LogSession;
|
|
304
|
+
private buffer: string = '';
|
|
305
|
+
|
|
306
|
+
constructor(config: EnhancedLogConfig, session: LogSession) {
|
|
307
|
+
super({ encoding: 'utf8' });
|
|
308
|
+
this.config = config;
|
|
309
|
+
this.session = session;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
_transform(chunk: Buffer, encoding: BufferEncoding, callback: TransformCallback): void {
|
|
313
|
+
let text = chunk.toString();
|
|
314
|
+
|
|
315
|
+
// Buffer partial lines
|
|
316
|
+
this.buffer += text;
|
|
317
|
+
const lines = this.buffer.split('\n');
|
|
318
|
+
|
|
319
|
+
// Keep the last incomplete line in buffer
|
|
320
|
+
this.buffer = lines.pop() || '';
|
|
321
|
+
|
|
322
|
+
for (const line of lines) {
|
|
323
|
+
let processed = line;
|
|
324
|
+
|
|
325
|
+
// Strip ANSI if enabled
|
|
326
|
+
if (this.config.stripAnsi) {
|
|
327
|
+
processed = stripAnsi(processed);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Add timestamp if enabled AND line doesn't already have one
|
|
331
|
+
if (this.config.addTimestamps && processed.trim() && !hasExistingTimestamp(processed)) {
|
|
332
|
+
const ts = formatTimestamp(this.config.timestampFormat, this.session.startTime);
|
|
333
|
+
processed = `[${ts}] ${processed}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
this.push(processed + '\n');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
callback();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
_flush(callback: TransformCallback): void {
|
|
343
|
+
// Process any remaining buffered content
|
|
344
|
+
if (this.buffer.trim()) {
|
|
345
|
+
let processed = this.buffer;
|
|
346
|
+
|
|
347
|
+
if (this.config.stripAnsi) {
|
|
348
|
+
processed = stripAnsi(processed);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (this.config.addTimestamps && processed.trim() && !hasExistingTimestamp(processed)) {
|
|
352
|
+
const ts = formatTimestamp(this.config.timestampFormat, this.session.startTime);
|
|
353
|
+
processed = `[${ts}] ${processed}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
this.push(processed + '\n');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
callback();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Enhanced Log Manager - Manages log files with rotation and multiple outputs
|
|
365
|
+
*/
|
|
366
|
+
export class EnhancedLogManager {
|
|
367
|
+
private config: EnhancedLogConfig;
|
|
368
|
+
private session: LogSession;
|
|
369
|
+
private logDir: string;
|
|
370
|
+
|
|
371
|
+
private cleanLogPath: string;
|
|
372
|
+
private rawLogPath: string;
|
|
373
|
+
private jsonLogPath: string;
|
|
374
|
+
private readableLogPath: string;
|
|
375
|
+
|
|
376
|
+
private cleanLogFd: number | null = null;
|
|
377
|
+
private rawLogFd: number | null = null;
|
|
378
|
+
private jsonLogFd: number | null = null;
|
|
379
|
+
private readableLogFd: number | null = null;
|
|
380
|
+
|
|
381
|
+
private cleanLogSize: number = 0;
|
|
382
|
+
private rawLogSize: number = 0;
|
|
383
|
+
|
|
384
|
+
private cleanTransform: CleanLogTransform | null = null;
|
|
385
|
+
private streamingParser: StreamingMessageParser | null = null;
|
|
386
|
+
private lineBuffer: string = '';
|
|
387
|
+
|
|
388
|
+
constructor(logDir: string, session: LogSession, config: Partial<EnhancedLogConfig> = {}) {
|
|
389
|
+
this.config = { ...DEFAULT_LOG_CONFIG, ...config };
|
|
390
|
+
this.session = session;
|
|
391
|
+
this.logDir = logDir;
|
|
392
|
+
|
|
393
|
+
// Ensure log directory exists
|
|
394
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
395
|
+
|
|
396
|
+
// Set up log file paths
|
|
397
|
+
this.cleanLogPath = path.join(logDir, 'terminal.log');
|
|
398
|
+
this.rawLogPath = path.join(logDir, 'terminal-raw.log');
|
|
399
|
+
this.jsonLogPath = path.join(logDir, 'terminal.jsonl');
|
|
400
|
+
this.readableLogPath = path.join(logDir, 'terminal-readable.log');
|
|
401
|
+
|
|
402
|
+
// Initialize log files
|
|
403
|
+
this.initLogFiles();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Initialize log files and write session headers
|
|
408
|
+
*/
|
|
409
|
+
private initLogFiles(): void {
|
|
410
|
+
// Check and rotate if necessary
|
|
411
|
+
this.rotateIfNeeded(this.cleanLogPath, 'clean');
|
|
412
|
+
if (this.config.keepRawLogs) {
|
|
413
|
+
this.rotateIfNeeded(this.rawLogPath, 'raw');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Open file descriptors
|
|
417
|
+
this.cleanLogFd = fs.openSync(this.cleanLogPath, 'a');
|
|
418
|
+
|
|
419
|
+
if (this.config.keepRawLogs) {
|
|
420
|
+
this.rawLogFd = fs.openSync(this.rawLogPath, 'a');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (this.config.writeJsonLog) {
|
|
424
|
+
this.jsonLogFd = fs.openSync(this.jsonLogPath, 'a');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Open readable log file (for parsed streaming output)
|
|
428
|
+
this.readableLogFd = fs.openSync(this.readableLogPath, 'a');
|
|
429
|
+
|
|
430
|
+
// Get initial file sizes
|
|
431
|
+
try {
|
|
432
|
+
this.cleanLogSize = fs.statSync(this.cleanLogPath).size;
|
|
433
|
+
if (this.config.keepRawLogs) {
|
|
434
|
+
this.rawLogSize = fs.statSync(this.rawLogPath).size;
|
|
435
|
+
}
|
|
436
|
+
} catch {
|
|
437
|
+
this.cleanLogSize = 0;
|
|
438
|
+
this.rawLogSize = 0;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Write session header
|
|
442
|
+
this.writeSessionHeader();
|
|
443
|
+
|
|
444
|
+
// Create transform stream
|
|
445
|
+
this.cleanTransform = new CleanLogTransform(this.config, this.session);
|
|
446
|
+
this.cleanTransform.on('data', (data: string) => {
|
|
447
|
+
this.writeToCleanLog(data);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// Create streaming parser for readable log
|
|
451
|
+
this.streamingParser = new StreamingMessageParser((msg) => {
|
|
452
|
+
this.writeReadableMessage(msg);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Write a parsed message to the readable log
|
|
458
|
+
*/
|
|
459
|
+
private writeReadableMessage(msg: ParsedMessage): void {
|
|
460
|
+
if (this.readableLogFd === null) return;
|
|
461
|
+
|
|
462
|
+
const ts = new Date(msg.timestamp).toISOString();
|
|
463
|
+
let formatted: string;
|
|
464
|
+
|
|
465
|
+
switch (msg.type) {
|
|
466
|
+
case 'system':
|
|
467
|
+
formatted = `\n${ts}\n${msg.content}\n`;
|
|
468
|
+
break;
|
|
469
|
+
|
|
470
|
+
case 'user':
|
|
471
|
+
// Format user prompt nicely
|
|
472
|
+
const promptPreview = msg.content.length > 200
|
|
473
|
+
? msg.content.substring(0, 200) + '...'
|
|
474
|
+
: msg.content;
|
|
475
|
+
formatted = `\n${ts}\n┌─ 🧑 USER ─────────────────────────────────────────────\n${this.indentText(promptPreview, '│ ')}\n└───────────────────────────────────────────────────────\n`;
|
|
476
|
+
break;
|
|
477
|
+
|
|
478
|
+
case 'assistant':
|
|
479
|
+
case 'result':
|
|
480
|
+
// Format assistant response
|
|
481
|
+
const isResult = msg.type === 'result';
|
|
482
|
+
const header = isResult ? '🤖 ASSISTANT (Final)' : '🤖 ASSISTANT';
|
|
483
|
+
const duration = msg.metadata?.duration_ms
|
|
484
|
+
? ` (${Math.round(msg.metadata.duration_ms / 1000)}s)`
|
|
485
|
+
: '';
|
|
486
|
+
formatted = `\n${ts}\n┌─ ${header}${duration} ──────────────────────────────────\n${this.indentText(msg.content, '│ ')}\n└───────────────────────────────────────────────────────\n`;
|
|
487
|
+
break;
|
|
488
|
+
|
|
489
|
+
case 'tool':
|
|
490
|
+
// Format tool call
|
|
491
|
+
formatted = `${ts} 🔧 ${msg.content}\n`;
|
|
492
|
+
break;
|
|
493
|
+
|
|
494
|
+
case 'tool_result':
|
|
495
|
+
// Format tool result (truncated)
|
|
496
|
+
const lines = msg.metadata?.lines ? ` (${msg.metadata.lines} lines)` : '';
|
|
497
|
+
formatted = `${ts} 📄 ${msg.metadata?.toolName || 'Tool'}${lines}\n`;
|
|
498
|
+
break;
|
|
499
|
+
|
|
500
|
+
default:
|
|
501
|
+
formatted = `${ts} ${msg.content}\n`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
fs.writeSync(this.readableLogFd, formatted);
|
|
506
|
+
} catch {
|
|
507
|
+
// Ignore write errors
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Indent text with a prefix
|
|
513
|
+
*/
|
|
514
|
+
private indentText(text: string, prefix: string): string {
|
|
515
|
+
return text.split('\n').map(line => prefix + line).join('\n');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Write session header to logs
|
|
520
|
+
*/
|
|
521
|
+
private writeSessionHeader(): void {
|
|
522
|
+
const header = `
|
|
523
|
+
╔══════════════════════════════════════════════════════════════════════════════╗
|
|
524
|
+
║ CursorFlow Session Log ║
|
|
525
|
+
╠══════════════════════════════════════════════════════════════════════════════╣
|
|
526
|
+
║ Session ID: ${this.session.id.padEnd(62)}║
|
|
527
|
+
║ Lane: ${this.session.laneName.padEnd(62)}║
|
|
528
|
+
║ Task: ${(this.session.taskName || '-').padEnd(62)}║
|
|
529
|
+
║ Model: ${(this.session.model || 'default').padEnd(62)}║
|
|
530
|
+
║ Started: ${new Date(this.session.startTime).toISOString().padEnd(62)}║
|
|
531
|
+
╚══════════════════════════════════════════════════════════════════════════════╝
|
|
532
|
+
|
|
533
|
+
`;
|
|
534
|
+
|
|
535
|
+
this.writeToCleanLog(header);
|
|
536
|
+
|
|
537
|
+
if (this.config.keepRawLogs && this.rawLogFd !== null) {
|
|
538
|
+
fs.writeSync(this.rawLogFd, header);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Write JSON session entry
|
|
542
|
+
this.writeJsonEntry({
|
|
543
|
+
timestamp: new Date(this.session.startTime).toISOString(),
|
|
544
|
+
level: 'session',
|
|
545
|
+
source: 'system',
|
|
546
|
+
lane: this.session.laneName,
|
|
547
|
+
task: this.session.taskName,
|
|
548
|
+
message: 'Session started',
|
|
549
|
+
metadata: {
|
|
550
|
+
sessionId: this.session.id,
|
|
551
|
+
model: this.session.model,
|
|
552
|
+
...this.session.metadata,
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Rotate log file if it exceeds max size
|
|
559
|
+
*/
|
|
560
|
+
private rotateIfNeeded(logPath: string, type: 'clean' | 'raw'): void {
|
|
561
|
+
if (!fs.existsSync(logPath)) return;
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
const stats = fs.statSync(logPath);
|
|
565
|
+
if (stats.size >= this.config.maxFileSize) {
|
|
566
|
+
this.rotateLog(logPath);
|
|
567
|
+
}
|
|
568
|
+
} catch {
|
|
569
|
+
// File doesn't exist or can't be read, ignore
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Rotate a log file
|
|
575
|
+
*/
|
|
576
|
+
private rotateLog(logPath: string): void {
|
|
577
|
+
const dir = path.dirname(logPath);
|
|
578
|
+
const ext = path.extname(logPath);
|
|
579
|
+
const base = path.basename(logPath, ext);
|
|
580
|
+
|
|
581
|
+
// Shift existing rotated files
|
|
582
|
+
for (let i = this.config.maxFiles - 1; i >= 1; i--) {
|
|
583
|
+
const oldPath = path.join(dir, `${base}.${i}${ext}`);
|
|
584
|
+
const newPath = path.join(dir, `${base}.${i + 1}${ext}`);
|
|
585
|
+
|
|
586
|
+
if (fs.existsSync(oldPath)) {
|
|
587
|
+
if (i === this.config.maxFiles - 1) {
|
|
588
|
+
fs.unlinkSync(oldPath); // Delete oldest
|
|
589
|
+
} else {
|
|
590
|
+
fs.renameSync(oldPath, newPath);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Rotate current to .1
|
|
596
|
+
const rotatedPath = path.join(dir, `${base}.1${ext}`);
|
|
597
|
+
fs.renameSync(logPath, rotatedPath);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Write to clean log with size tracking
|
|
602
|
+
*/
|
|
603
|
+
private writeToCleanLog(data: string): void {
|
|
604
|
+
if (this.cleanLogFd === null) return;
|
|
605
|
+
|
|
606
|
+
const buffer = Buffer.from(data);
|
|
607
|
+
fs.writeSync(this.cleanLogFd, buffer);
|
|
608
|
+
this.cleanLogSize += buffer.length;
|
|
609
|
+
|
|
610
|
+
// Check if rotation needed
|
|
611
|
+
if (this.cleanLogSize >= this.config.maxFileSize) {
|
|
612
|
+
fs.closeSync(this.cleanLogFd);
|
|
613
|
+
this.rotateLog(this.cleanLogPath);
|
|
614
|
+
this.cleanLogFd = fs.openSync(this.cleanLogPath, 'a');
|
|
615
|
+
this.cleanLogSize = 0;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Write to raw log with size tracking
|
|
621
|
+
*/
|
|
622
|
+
private writeToRawLog(data: string): void {
|
|
623
|
+
if (this.rawLogFd === null) return;
|
|
624
|
+
|
|
625
|
+
const buffer = Buffer.from(data);
|
|
626
|
+
fs.writeSync(this.rawLogFd, buffer);
|
|
627
|
+
this.rawLogSize += buffer.length;
|
|
628
|
+
|
|
629
|
+
// Check if rotation needed
|
|
630
|
+
if (this.rawLogSize >= this.config.maxFileSize) {
|
|
631
|
+
fs.closeSync(this.rawLogFd);
|
|
632
|
+
this.rotateLog(this.rawLogPath);
|
|
633
|
+
this.rawLogFd = fs.openSync(this.rawLogPath, 'a');
|
|
634
|
+
this.rawLogSize = 0;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Write a JSON log entry
|
|
640
|
+
*/
|
|
641
|
+
private writeJsonEntry(entry: JsonLogEntry): void {
|
|
642
|
+
if (this.jsonLogFd === null) return;
|
|
643
|
+
|
|
644
|
+
const line = JSON.stringify(entry) + '\n';
|
|
645
|
+
fs.writeSync(this.jsonLogFd, line);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Write stdout data
|
|
650
|
+
*/
|
|
651
|
+
public writeStdout(data: Buffer | string): void {
|
|
652
|
+
const text = data.toString();
|
|
653
|
+
|
|
654
|
+
// Write raw log
|
|
655
|
+
if (this.config.keepRawLogs) {
|
|
656
|
+
this.writeToRawLog(text);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Process through transform for clean log
|
|
660
|
+
if (this.cleanTransform) {
|
|
661
|
+
this.cleanTransform.write(data);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Parse streaming JSON for readable log
|
|
665
|
+
this.parseStreamingData(text);
|
|
666
|
+
|
|
667
|
+
// Write JSON entry (for significant lines only)
|
|
668
|
+
if (this.config.writeJsonLog) {
|
|
669
|
+
const cleanText = stripAnsi(text).trim();
|
|
670
|
+
if (cleanText && !this.isNoiseLog(cleanText)) {
|
|
671
|
+
this.writeJsonEntry({
|
|
672
|
+
timestamp: new Date().toISOString(),
|
|
673
|
+
level: 'stdout',
|
|
674
|
+
lane: this.session.laneName,
|
|
675
|
+
task: this.session.taskName,
|
|
676
|
+
message: cleanText.substring(0, 1000), // Truncate very long lines
|
|
677
|
+
raw: this.config.keepRawLogs ? undefined : text.substring(0, 1000),
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Parse streaming JSON data for readable log
|
|
685
|
+
*/
|
|
686
|
+
private parseStreamingData(text: string): void {
|
|
687
|
+
if (!this.streamingParser) return;
|
|
688
|
+
|
|
689
|
+
// Buffer incomplete lines
|
|
690
|
+
this.lineBuffer += text;
|
|
691
|
+
const lines = this.lineBuffer.split('\n');
|
|
692
|
+
|
|
693
|
+
// Keep the last incomplete line in buffer
|
|
694
|
+
this.lineBuffer = lines.pop() || '';
|
|
695
|
+
|
|
696
|
+
// Parse complete lines
|
|
697
|
+
for (const line of lines) {
|
|
698
|
+
const trimmed = line.trim();
|
|
699
|
+
if (trimmed.startsWith('{')) {
|
|
700
|
+
this.streamingParser.parseLine(trimmed);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Write stderr data
|
|
707
|
+
*/
|
|
708
|
+
public writeStderr(data: Buffer | string): void {
|
|
709
|
+
const text = data.toString();
|
|
710
|
+
|
|
711
|
+
// Write raw log
|
|
712
|
+
if (this.config.keepRawLogs) {
|
|
713
|
+
this.writeToRawLog(text);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Process through transform for clean log
|
|
717
|
+
if (this.cleanTransform) {
|
|
718
|
+
this.cleanTransform.write(data);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Write JSON entry
|
|
722
|
+
if (this.config.writeJsonLog) {
|
|
723
|
+
const cleanText = stripAnsi(text).trim();
|
|
724
|
+
if (cleanText) {
|
|
725
|
+
this.writeJsonEntry({
|
|
726
|
+
timestamp: new Date().toISOString(),
|
|
727
|
+
level: 'stderr',
|
|
728
|
+
lane: this.session.laneName,
|
|
729
|
+
task: this.session.taskName,
|
|
730
|
+
message: cleanText.substring(0, 1000),
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Write a custom log entry
|
|
738
|
+
*/
|
|
739
|
+
public log(level: 'info' | 'error' | 'debug', message: string, metadata?: Record<string, any>): void {
|
|
740
|
+
const ts = formatTimestamp(this.config.timestampFormat, this.session.startTime);
|
|
741
|
+
const prefix = level.toUpperCase().padEnd(5);
|
|
742
|
+
|
|
743
|
+
const line = `[${ts}] [${prefix}] ${message}\n`;
|
|
744
|
+
this.writeToCleanLog(line);
|
|
745
|
+
|
|
746
|
+
if (this.config.keepRawLogs) {
|
|
747
|
+
this.writeToRawLog(line);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (this.config.writeJsonLog) {
|
|
751
|
+
this.writeJsonEntry({
|
|
752
|
+
timestamp: new Date().toISOString(),
|
|
753
|
+
level,
|
|
754
|
+
lane: this.session.laneName,
|
|
755
|
+
task: this.session.taskName,
|
|
756
|
+
message,
|
|
757
|
+
metadata,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Add a section marker
|
|
764
|
+
*/
|
|
765
|
+
public section(title: string): void {
|
|
766
|
+
const divider = '═'.repeat(78);
|
|
767
|
+
const line = `\n${divider}\n ${title}\n${divider}\n`;
|
|
768
|
+
|
|
769
|
+
this.writeToCleanLog(line);
|
|
770
|
+
if (this.config.keepRawLogs) {
|
|
771
|
+
this.writeToRawLog(line);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Update task context
|
|
777
|
+
*/
|
|
778
|
+
public setTask(taskName: string, model?: string): void {
|
|
779
|
+
this.session.taskName = taskName;
|
|
780
|
+
if (model) {
|
|
781
|
+
this.session.model = model;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
this.section(`Task: ${taskName}${model ? ` (Model: ${model})` : ''}`);
|
|
785
|
+
|
|
786
|
+
if (this.config.writeJsonLog) {
|
|
787
|
+
this.writeJsonEntry({
|
|
788
|
+
timestamp: new Date().toISOString(),
|
|
789
|
+
level: 'info',
|
|
790
|
+
source: 'system',
|
|
791
|
+
lane: this.session.laneName,
|
|
792
|
+
task: taskName,
|
|
793
|
+
message: `Task started: ${taskName}`,
|
|
794
|
+
metadata: { model },
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Check if a log line is noise (progress bars, spinners, etc.)
|
|
801
|
+
*/
|
|
802
|
+
private isNoiseLog(text: string): boolean {
|
|
803
|
+
// Skip empty or whitespace-only
|
|
804
|
+
if (!text.trim()) return true;
|
|
805
|
+
|
|
806
|
+
// Skip common progress/spinner patterns
|
|
807
|
+
const noisePatterns = [
|
|
808
|
+
/^[\s│├└─┌┐┘┴┬┤├]+$/, // Box drawing only
|
|
809
|
+
/^[.\s]+$/, // Dots only
|
|
810
|
+
/^[=>\s-]+$/, // Progress bar characters
|
|
811
|
+
/^\d+%$/, // Percentage only
|
|
812
|
+
/^⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏/, // Spinner characters
|
|
813
|
+
];
|
|
814
|
+
|
|
815
|
+
return noisePatterns.some(p => p.test(text));
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Get paths to all log files
|
|
820
|
+
*/
|
|
821
|
+
public getLogPaths(): { clean: string; raw?: string; json?: string; readable: string } {
|
|
822
|
+
return {
|
|
823
|
+
clean: this.cleanLogPath,
|
|
824
|
+
raw: this.config.keepRawLogs ? this.rawLogPath : undefined,
|
|
825
|
+
json: this.config.writeJsonLog ? this.jsonLogPath : undefined,
|
|
826
|
+
readable: this.readableLogPath,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Create file descriptors for process stdio redirection
|
|
832
|
+
*/
|
|
833
|
+
public getFileDescriptors(): { stdout: number; stderr: number } {
|
|
834
|
+
// For process spawning, use the raw log fd if available, otherwise clean
|
|
835
|
+
const fd = this.rawLogFd !== null ? this.rawLogFd : this.cleanLogFd!;
|
|
836
|
+
return { stdout: fd, stderr: fd };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Close all log files
|
|
841
|
+
*/
|
|
842
|
+
public close(): void {
|
|
843
|
+
// Flush transform stream
|
|
844
|
+
if (this.cleanTransform) {
|
|
845
|
+
this.cleanTransform.end();
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Flush streaming parser
|
|
849
|
+
if (this.streamingParser) {
|
|
850
|
+
// Parse any remaining buffered data
|
|
851
|
+
if (this.lineBuffer.trim()) {
|
|
852
|
+
this.streamingParser.parseLine(this.lineBuffer);
|
|
853
|
+
}
|
|
854
|
+
this.streamingParser.flush();
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Write session end marker
|
|
858
|
+
const endMarker = `
|
|
859
|
+
╔══════════════════════════════════════════════════════════════════════════════╗
|
|
860
|
+
║ Session Ended: ${new Date().toISOString().padEnd(60)}║
|
|
861
|
+
║ Duration: ${this.formatDuration(Date.now() - this.session.startTime).padEnd(65)}║
|
|
862
|
+
╚══════════════════════════════════════════════════════════════════════════════╝
|
|
863
|
+
|
|
864
|
+
`;
|
|
865
|
+
|
|
866
|
+
if (this.cleanLogFd !== null) {
|
|
867
|
+
fs.writeSync(this.cleanLogFd, endMarker);
|
|
868
|
+
fs.closeSync(this.cleanLogFd);
|
|
869
|
+
this.cleanLogFd = null;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (this.rawLogFd !== null) {
|
|
873
|
+
fs.writeSync(this.rawLogFd, endMarker);
|
|
874
|
+
fs.closeSync(this.rawLogFd);
|
|
875
|
+
this.rawLogFd = null;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (this.jsonLogFd !== null) {
|
|
879
|
+
this.writeJsonEntry({
|
|
880
|
+
timestamp: new Date().toISOString(),
|
|
881
|
+
level: 'session',
|
|
882
|
+
source: 'system',
|
|
883
|
+
lane: this.session.laneName,
|
|
884
|
+
message: 'Session ended',
|
|
885
|
+
metadata: {
|
|
886
|
+
sessionId: this.session.id,
|
|
887
|
+
duration: Date.now() - this.session.startTime,
|
|
888
|
+
},
|
|
889
|
+
});
|
|
890
|
+
fs.closeSync(this.jsonLogFd);
|
|
891
|
+
this.jsonLogFd = null;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Close readable log
|
|
895
|
+
if (this.readableLogFd !== null) {
|
|
896
|
+
fs.writeSync(this.readableLogFd, endMarker);
|
|
897
|
+
fs.closeSync(this.readableLogFd);
|
|
898
|
+
this.readableLogFd = null;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Format duration for display
|
|
904
|
+
*/
|
|
905
|
+
private formatDuration(ms: number): string {
|
|
906
|
+
const seconds = Math.floor((ms / 1000) % 60);
|
|
907
|
+
const minutes = Math.floor((ms / (1000 * 60)) % 60);
|
|
908
|
+
const hours = Math.floor(ms / (1000 * 60 * 60));
|
|
909
|
+
|
|
910
|
+
if (hours > 0) {
|
|
911
|
+
return `${hours}h ${minutes}m ${seconds}s`;
|
|
912
|
+
} else if (minutes > 0) {
|
|
913
|
+
return `${minutes}m ${seconds}s`;
|
|
914
|
+
}
|
|
915
|
+
return `${seconds}s`;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Create a log manager for a lane
|
|
921
|
+
*/
|
|
922
|
+
export function createLogManager(
|
|
923
|
+
laneRunDir: string,
|
|
924
|
+
laneName: string,
|
|
925
|
+
config?: Partial<EnhancedLogConfig>
|
|
926
|
+
): EnhancedLogManager {
|
|
927
|
+
const session: LogSession = {
|
|
928
|
+
id: `${laneName}-${Date.now().toString(36)}`,
|
|
929
|
+
laneName,
|
|
930
|
+
startTime: Date.now(),
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
return new EnhancedLogManager(laneRunDir, session, config);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Read and parse JSON log file
|
|
938
|
+
*/
|
|
939
|
+
export function readJsonLog(logPath: string): JsonLogEntry[] {
|
|
940
|
+
if (!fs.existsSync(logPath)) {
|
|
941
|
+
return [];
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
try {
|
|
945
|
+
const content = fs.readFileSync(logPath, 'utf8');
|
|
946
|
+
return content
|
|
947
|
+
.split('\n')
|
|
948
|
+
.filter(line => line.trim())
|
|
949
|
+
.map(line => {
|
|
950
|
+
try {
|
|
951
|
+
return JSON.parse(line) as JsonLogEntry;
|
|
952
|
+
} catch {
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
})
|
|
956
|
+
.filter((entry): entry is JsonLogEntry => entry !== null);
|
|
957
|
+
} catch {
|
|
958
|
+
return [];
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Export logs to various formats
|
|
964
|
+
*/
|
|
965
|
+
export function exportLogs(
|
|
966
|
+
laneRunDir: string,
|
|
967
|
+
format: 'text' | 'json' | 'markdown' | 'html',
|
|
968
|
+
outputPath?: string
|
|
969
|
+
): string {
|
|
970
|
+
const cleanLogPath = path.join(laneRunDir, 'terminal.log');
|
|
971
|
+
const jsonLogPath = path.join(laneRunDir, 'terminal.jsonl');
|
|
972
|
+
|
|
973
|
+
let output = '';
|
|
974
|
+
|
|
975
|
+
switch (format) {
|
|
976
|
+
case 'text':
|
|
977
|
+
if (fs.existsSync(cleanLogPath)) {
|
|
978
|
+
output = fs.readFileSync(cleanLogPath, 'utf8');
|
|
979
|
+
}
|
|
980
|
+
break;
|
|
981
|
+
|
|
982
|
+
case 'json':
|
|
983
|
+
const entries = readJsonLog(jsonLogPath);
|
|
984
|
+
output = JSON.stringify(entries, null, 2);
|
|
985
|
+
break;
|
|
986
|
+
|
|
987
|
+
case 'markdown':
|
|
988
|
+
output = exportToMarkdown(jsonLogPath, cleanLogPath);
|
|
989
|
+
break;
|
|
990
|
+
|
|
991
|
+
case 'html':
|
|
992
|
+
output = exportToHtml(jsonLogPath, cleanLogPath);
|
|
993
|
+
break;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (outputPath) {
|
|
997
|
+
fs.writeFileSync(outputPath, output, 'utf8');
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
return output;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Export logs to Markdown format
|
|
1005
|
+
*/
|
|
1006
|
+
function exportToMarkdown(jsonLogPath: string, cleanLogPath: string): string {
|
|
1007
|
+
const entries = readJsonLog(jsonLogPath);
|
|
1008
|
+
|
|
1009
|
+
let md = '# CursorFlow Session Log\n\n';
|
|
1010
|
+
|
|
1011
|
+
// Find session info
|
|
1012
|
+
const sessionStart = entries.find(e => e.level === 'session' && e.message === 'Session started');
|
|
1013
|
+
if (sessionStart?.metadata) {
|
|
1014
|
+
md += '## Session Info\n\n';
|
|
1015
|
+
md += `- **Session ID**: ${sessionStart.metadata.sessionId}\n`;
|
|
1016
|
+
md += `- **Lane**: ${sessionStart.lane}\n`;
|
|
1017
|
+
md += `- **Model**: ${sessionStart.metadata.model || 'default'}\n`;
|
|
1018
|
+
md += `- **Started**: ${sessionStart.timestamp}\n\n`;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
md += '## Log Entries\n\n';
|
|
1022
|
+
|
|
1023
|
+
// Group by task
|
|
1024
|
+
const byTask = new Map<string, JsonLogEntry[]>();
|
|
1025
|
+
for (const entry of entries) {
|
|
1026
|
+
const task = entry.task || '(no task)';
|
|
1027
|
+
if (!byTask.has(task)) {
|
|
1028
|
+
byTask.set(task, []);
|
|
1029
|
+
}
|
|
1030
|
+
byTask.get(task)!.push(entry);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
for (const [task, taskEntries] of byTask) {
|
|
1034
|
+
md += `### Task: ${task}\n\n`;
|
|
1035
|
+
md += '```\n';
|
|
1036
|
+
for (const entry of taskEntries) {
|
|
1037
|
+
if (entry.level !== 'session') {
|
|
1038
|
+
md += `[${entry.timestamp}] [${entry.level.toUpperCase()}] ${entry.message}\n`;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
md += '```\n\n';
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
return md;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Export logs to HTML format
|
|
1049
|
+
*/
|
|
1050
|
+
function exportToHtml(jsonLogPath: string, cleanLogPath: string): string {
|
|
1051
|
+
const entries = readJsonLog(jsonLogPath);
|
|
1052
|
+
|
|
1053
|
+
let html = `<!DOCTYPE html>
|
|
1054
|
+
<html>
|
|
1055
|
+
<head>
|
|
1056
|
+
<title>CursorFlow Session Log</title>
|
|
1057
|
+
<style>
|
|
1058
|
+
body { font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; margin: 20px; background: #1e1e1e; color: #d4d4d4; }
|
|
1059
|
+
h1, h2 { color: #569cd6; }
|
|
1060
|
+
.entry { padding: 4px 8px; margin: 2px 0; border-radius: 4px; }
|
|
1061
|
+
.entry.stdout { background: #252526; }
|
|
1062
|
+
.entry.stderr { background: #3c1f1f; color: #f48771; }
|
|
1063
|
+
.entry.info { background: #1e3a5f; color: #9cdcfe; }
|
|
1064
|
+
.entry.error { background: #5f1e1e; color: #f48771; }
|
|
1065
|
+
.entry.session { background: #1e4620; color: #6a9955; }
|
|
1066
|
+
.timestamp { color: #808080; font-size: 0.9em; }
|
|
1067
|
+
.level { font-weight: bold; text-transform: uppercase; }
|
|
1068
|
+
.task { color: #dcdcaa; }
|
|
1069
|
+
pre { white-space: pre-wrap; word-wrap: break-word; }
|
|
1070
|
+
</style>
|
|
1071
|
+
</head>
|
|
1072
|
+
<body>
|
|
1073
|
+
<h1>CursorFlow Session Log</h1>
|
|
1074
|
+
`;
|
|
1075
|
+
|
|
1076
|
+
for (const entry of entries) {
|
|
1077
|
+
html += ` <div class="entry ${entry.level}">
|
|
1078
|
+
<span class="timestamp">${entry.timestamp}</span>
|
|
1079
|
+
<span class="level">[${entry.level}]</span>
|
|
1080
|
+
${entry.task ? `<span class="task">[${entry.task}]</span>` : ''}
|
|
1081
|
+
<pre>${escapeHtml(entry.message)}</pre>
|
|
1082
|
+
</div>\n`;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
html += '</body></html>';
|
|
1086
|
+
return html;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function escapeHtml(text: string): string {
|
|
1090
|
+
return text
|
|
1091
|
+
.replace(/&/g, '&')
|
|
1092
|
+
.replace(/</g, '<')
|
|
1093
|
+
.replace(/>/g, '>')
|
|
1094
|
+
.replace(/"/g, '"')
|
|
1095
|
+
.replace(/'/g, ''');
|
|
1096
|
+
}
|
|
1097
|
+
|