task-summary-extractor 8.1.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/src/config.js ADDED
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Central configuration — all constants, API keys, and settings.
3
+ *
4
+ * Resolution priority (highest wins):
5
+ * 1. CLI flags (--gemini-key etc. — injected in bin/taskex.js before require)
6
+ * 2. process.env (set by user shell or CI)
7
+ * 3. CWD .env file (project-specific)
8
+ * 4. ~/.taskexrc (global persistent config)
9
+ * 5. Package root .env (development fallback)
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const path = require('path');
15
+ const fs_ = require('fs');
16
+
17
+ // ── Step 1: Load .env (CWD first, then package root — both with override: false) ──
18
+ // dotenv's default: only sets vars that aren't already set.
19
+ // Load CWD .env first (higher priority), then package root .env (fills gaps).
20
+ const cwd = process.cwd();
21
+ const cwdEnv = path.join(cwd, '.env');
22
+ const pkgEnv = path.resolve(__dirname, '..', '.env');
23
+ if (fs_.existsSync(cwdEnv)) require('dotenv').config({ path: cwdEnv });
24
+ if (fs_.existsSync(pkgEnv)) require('dotenv').config({ path: pkgEnv });
25
+
26
+ // ── Step 2: Inject global config (~/.taskexrc) for keys still missing ─────
27
+ const { injectGlobalConfig } = require('./utils/global-config');
28
+ injectGlobalConfig();
29
+
30
+ // ======================== HELPERS ========================
31
+
32
+ /** Read an env var, returning defaultVal if missing. */
33
+ function env(key, defaultVal = undefined) {
34
+ const val = process.env[key];
35
+ if (val !== undefined && val !== '') return val;
36
+ return defaultVal;
37
+ }
38
+
39
+ /** Read an env var as a number, with default. */
40
+ function envInt(key, defaultVal) {
41
+ const raw = process.env[key];
42
+ if (raw === undefined || raw === '') return defaultVal;
43
+ const n = parseInt(raw, 10);
44
+ return isNaN(n) ? defaultVal : n;
45
+ }
46
+
47
+ /** Read an env var as a float, with default. */
48
+ function envFloat(key, defaultVal) {
49
+ const raw = process.env[key];
50
+ if (raw === undefined || raw === '') return defaultVal;
51
+ const n = parseFloat(raw);
52
+ return isNaN(n) ? defaultVal : n;
53
+ }
54
+
55
+ // ======================== FIREBASE ========================
56
+
57
+ const FIREBASE_CONFIG = {
58
+ apiKey: env('FIREBASE_API_KEY'),
59
+ authDomain: env('FIREBASE_AUTH_DOMAIN'),
60
+ projectId: env('FIREBASE_PROJECT_ID'),
61
+ storageBucket: env('FIREBASE_STORAGE_BUCKET'),
62
+ messagingSenderId: env('FIREBASE_MESSAGING_SENDER_ID'),
63
+ appId: env('FIREBASE_APP_ID'),
64
+ measurementId: env('FIREBASE_MEASUREMENT_ID'),
65
+ };
66
+
67
+ // ======================== GEMINI AI ========================
68
+
69
+ const GEMINI_API_KEY = env('GEMINI_API_KEY');
70
+
71
+ /**
72
+ * Complete Gemini model registry — specs, context windows, pricing, and descriptions.
73
+ *
74
+ * Pricing source: Google AI for Developers — https://ai.google.dev/gemini-api/docs/pricing
75
+ * Last verified: February 2026
76
+ *
77
+ * Rates are per 1 million tokens. Output pricing INCLUDES thinking tokens
78
+ * (unified rate). Some models have tiered pricing based on context length
79
+ * (short = under threshold, long = over threshold).
80
+ *
81
+ * NOTE: gemini-2.0-flash, gemini-2.0-flash-lite, and all gemini-1.5-* models
82
+ * are deprecated/removed. Use 2.5+ models only.
83
+ */
84
+ const GEMINI_MODELS = {
85
+ 'gemini-3.1-pro-preview': {
86
+ name: 'Gemini 3.1 Pro Preview',
87
+ description: 'Latest & most capable — best reasoning, agentic workflows, vibe-coding',
88
+ contextWindow: 1_048_576,
89
+ maxOutput: 65536,
90
+ thinking: true,
91
+ tier: 'premium',
92
+ pricing: {
93
+ inputPerM: 2.00,
94
+ inputLongPerM: 4.00,
95
+ outputPerM: 12.00, // includes thinking tokens
96
+ outputLongPerM: 18.00,
97
+ thinkingPerM: 12.00, // same rate as output (unified pricing)
98
+ longContextThreshold: 200_000,
99
+ },
100
+ costEstimate: '~$0.30/segment',
101
+ },
102
+ 'gemini-3-flash-preview': {
103
+ name: 'Gemini 3 Flash Preview',
104
+ description: 'Frontier intelligence at flash speed — rivals larger models at fraction of cost',
105
+ contextWindow: 1_048_576,
106
+ maxOutput: 65536,
107
+ thinking: true,
108
+ tier: 'balanced',
109
+ pricing: {
110
+ inputPerM: 0.50,
111
+ inputLongPerM: 0.50, // flat rate (no long context tier)
112
+ outputPerM: 3.00, // includes thinking tokens
113
+ outputLongPerM: 3.00,
114
+ thinkingPerM: 3.00,
115
+ longContextThreshold: 200_000,
116
+ },
117
+ costEstimate: '~$0.07/segment',
118
+ },
119
+ 'gemini-2.5-pro': {
120
+ name: 'Gemini 2.5 Pro',
121
+ description: 'Stable premium — deep reasoning, coding, math, STEM, long context',
122
+ contextWindow: 1_048_576,
123
+ maxOutput: 65536,
124
+ thinking: true,
125
+ tier: 'premium',
126
+ pricing: {
127
+ inputPerM: 1.25,
128
+ inputLongPerM: 2.50,
129
+ outputPerM: 10.00, // includes thinking tokens
130
+ outputLongPerM: 15.00,
131
+ thinkingPerM: 10.00,
132
+ longContextThreshold: 200_000,
133
+ },
134
+ costEstimate: '~$0.20/segment',
135
+ },
136
+ 'gemini-2.5-flash': {
137
+ name: 'Gemini 2.5 Flash',
138
+ description: 'Best price-performance — thinking, 1M context, high throughput',
139
+ contextWindow: 1_048_576,
140
+ maxOutput: 65536,
141
+ thinking: true,
142
+ tier: 'balanced',
143
+ pricing: {
144
+ inputPerM: 0.30,
145
+ inputLongPerM: 0.30, // flat rate
146
+ outputPerM: 2.50, // includes thinking tokens
147
+ outputLongPerM: 2.50,
148
+ thinkingPerM: 2.50,
149
+ longContextThreshold: 200_000,
150
+ },
151
+ costEstimate: '~$0.05/segment',
152
+ },
153
+ 'gemini-2.5-flash-lite': {
154
+ name: 'Gemini 2.5 Flash-Lite',
155
+ description: 'Cheapest available — fastest, most cost-efficient for high-volume tasks',
156
+ contextWindow: 1_048_576,
157
+ maxOutput: 65536,
158
+ thinking: true,
159
+ tier: 'economy',
160
+ pricing: {
161
+ inputPerM: 0.10,
162
+ inputLongPerM: 0.10, // flat rate
163
+ outputPerM: 0.40, // includes thinking tokens
164
+ outputLongPerM: 0.40,
165
+ thinkingPerM: 0.40,
166
+ longContextThreshold: 200_000,
167
+ },
168
+ costEstimate: '~$0.01/segment',
169
+ },
170
+ };
171
+
172
+ // Active model — defaults from env or 'gemini-2.5-flash'
173
+ let GEMINI_MODEL = env('GEMINI_MODEL', 'gemini-2.5-flash');
174
+ let GEMINI_CONTEXT_WINDOW = (GEMINI_MODELS[GEMINI_MODEL] || {}).contextWindow || 1_048_576;
175
+
176
+ /**
177
+ * Set the active model at runtime. Updates GEMINI_MODEL and GEMINI_CONTEXT_WINDOW
178
+ * on module.exports so all modules that reference config.GEMINI_MODEL see the change.
179
+ *
180
+ * @param {string} modelId - Model ID (key from GEMINI_MODELS)
181
+ * @returns {{ id: string, specs: object }} The selected model
182
+ */
183
+ function setActiveModel(modelId) {
184
+ const specs = GEMINI_MODELS[modelId];
185
+ if (!specs) {
186
+ const valid = Object.keys(GEMINI_MODELS).join(', ');
187
+ throw new Error(`Unknown model "${modelId}". Valid models: ${valid}`);
188
+ }
189
+ GEMINI_MODEL = modelId;
190
+ GEMINI_CONTEXT_WINDOW = specs.contextWindow;
191
+ // Update module.exports so modules accessing config.GEMINI_MODEL see the new value
192
+ module.exports.GEMINI_MODEL = modelId;
193
+ module.exports.GEMINI_CONTEXT_WINDOW = specs.contextWindow;
194
+ return { id: modelId, specs };
195
+ }
196
+
197
+ /**
198
+ * Get the pricing config for the currently active model.
199
+ * @returns {object} Pricing object compatible with CostTracker
200
+ */
201
+ function getActiveModelPricing() {
202
+ const specs = GEMINI_MODELS[module.exports.GEMINI_MODEL];
203
+ return specs ? specs.pricing : GEMINI_MODELS['gemini-2.5-flash'].pricing;
204
+ }
205
+
206
+ // ======================== VIDEO PROCESSING ========================
207
+
208
+ const SPEED = envFloat('VIDEO_SPEED', 1.5);
209
+ const SEG_TIME = envInt('VIDEO_SEGMENT_TIME', 280); // seconds — produces segments < 5 min
210
+ const PRESET = env('VIDEO_PRESET', 'slow');
211
+ const VIDEO_EXTS = ['.mp4', '.mkv', '.avi', '.mov', '.webm'];
212
+ const DOC_EXTS = ['.vtt', '.txt', '.pdf', '.docx', '.doc', '.srt', '.csv', '.md'];
213
+
214
+ // ======================== PIPELINE SETTINGS ========================
215
+
216
+ const LOG_LEVEL = env('LOG_LEVEL', 'info');
217
+ const MAX_PARALLEL_UPLOADS = envInt('MAX_PARALLEL_UPLOADS', 3);
218
+ const MAX_RETRIES = envInt('MAX_RETRIES', 3);
219
+ const RETRY_BASE_DELAY_MS = envInt('RETRY_BASE_DELAY_MS', 2000);
220
+
221
+ // Gemini thinking budget (tokens allocated for model reasoning)
222
+ const THINKING_BUDGET = envInt('THINKING_BUDGET', 24576); // per-segment analysis
223
+ const COMPILATION_THINKING_BUDGET = envInt('COMPILATION_THINKING_BUDGET', 10240); // final compilation
224
+ const DEEP_DIVE_THINKING_BUDGET = envInt('DEEP_DIVE_THINKING_BUDGET', 16384); // deep-dive document generation
225
+
226
+ // Gemini file API polling timeout (ms) — prevents indefinite hanging
227
+ const GEMINI_POLL_TIMEOUT_MS = envInt('GEMINI_POLL_TIMEOUT_MS', 300000); // 5 min
228
+
229
+ // ======================== GEMINI FILE HANDLING ========================
230
+
231
+ // Extensions that Gemini supports via File API for generateContent
232
+ const GEMINI_FILE_API_EXTS = ['.pdf'];
233
+ // Text-readable extensions — inlined as text parts (Gemini rejects text/vtt, text/csv etc. as fileData)
234
+ const INLINE_TEXT_EXTS = ['.vtt', '.srt', '.txt', '.md', '.csv'];
235
+ // Unsupported by Gemini — uploaded to Firebase only
236
+ const GEMINI_UNSUPPORTED = ['.docx', '.doc'];
237
+
238
+ // ======================== MIME TYPES ========================
239
+
240
+ const MIME_MAP = {
241
+ '.vtt': 'text/vtt',
242
+ '.srt': 'application/x-subrip',
243
+ '.txt': 'text/plain',
244
+ '.md': 'text/markdown',
245
+ '.csv': 'text/csv',
246
+ '.pdf': 'application/pdf',
247
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
248
+ '.doc': 'application/msword',
249
+ '.mp4': 'video/mp4',
250
+ '.mkv': 'video/x-matroska',
251
+ '.avi': 'video/x-msvideo',
252
+ '.mov': 'video/quicktime',
253
+ '.webm': 'video/webm',
254
+ '.json': 'application/json',
255
+ };
256
+
257
+ // ======================== VALIDATION ========================
258
+
259
+ /**
260
+ * Validate that all required configuration is present.
261
+ * Returns { valid: boolean, errors: string[] }.
262
+ */
263
+ function validateConfig({ skipFirebase = false, skipGemini = false } = {}) {
264
+ const errors = [];
265
+
266
+ if (!skipGemini && !GEMINI_API_KEY) {
267
+ errors.push('GEMINI_API_KEY is missing. Set it in .env or as an environment variable.');
268
+ }
269
+
270
+ if (!skipFirebase) {
271
+ const fbRequired = ['apiKey', 'authDomain', 'projectId', 'storageBucket'];
272
+ for (const key of fbRequired) {
273
+ if (!FIREBASE_CONFIG[key]) {
274
+ const envKey = `FIREBASE_${key.replace(/([A-Z])/g, '_$1').toUpperCase()}`;
275
+ errors.push(`Firebase ${key} is missing. Set ${envKey} in .env or as an environment variable.`);
276
+ }
277
+ }
278
+ }
279
+
280
+ if (SPEED <= 0 || SPEED > 10) {
281
+ errors.push(`VIDEO_SPEED=${SPEED} is out of range. Must be between 0.1 and 10.`);
282
+ }
283
+
284
+ if (SEG_TIME < 30 || SEG_TIME > 3600) {
285
+ errors.push(`VIDEO_SEGMENT_TIME=${SEG_TIME} is out of range. Must be between 30 and 3600.`);
286
+ }
287
+
288
+ const validPresets = ['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'];
289
+ if (!validPresets.includes(PRESET)) {
290
+ errors.push(`VIDEO_PRESET="${PRESET}" is invalid. Must be one of: ${validPresets.join(', ')}`);
291
+ }
292
+
293
+ const validLogLevels = ['debug', 'info', 'warn', 'error'];
294
+ if (!validLogLevels.includes(LOG_LEVEL)) {
295
+ errors.push(`LOG_LEVEL="${LOG_LEVEL}" is invalid. Must be one of: ${validLogLevels.join(', ')}`);
296
+ }
297
+
298
+ return { valid: errors.length === 0, errors };
299
+ }
300
+
301
+ module.exports = {
302
+ FIREBASE_CONFIG,
303
+ GEMINI_API_KEY,
304
+ GEMINI_MODEL,
305
+ GEMINI_CONTEXT_WINDOW,
306
+ GEMINI_MODELS,
307
+ setActiveModel,
308
+ getActiveModelPricing,
309
+ SPEED,
310
+ SEG_TIME,
311
+ PRESET,
312
+ VIDEO_EXTS,
313
+ DOC_EXTS,
314
+ GEMINI_FILE_API_EXTS,
315
+ INLINE_TEXT_EXTS,
316
+ GEMINI_UNSUPPORTED,
317
+ MIME_MAP,
318
+ LOG_LEVEL,
319
+ MAX_PARALLEL_UPLOADS,
320
+ MAX_RETRIES,
321
+ RETRY_BASE_DELAY_MS,
322
+ THINKING_BUDGET,
323
+ COMPILATION_THINKING_BUDGET,
324
+ DEEP_DIVE_THINKING_BUDGET,
325
+ GEMINI_POLL_TIMEOUT_MS,
326
+ validateConfig,
327
+ };
package/src/logger.js ADDED
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Logger — dual-file logger with buffered writes, configurable log levels,
3
+ * structured JSON logging, phase timing spans, and reversible console patching.
4
+ *
5
+ * v6 improvements:
6
+ * - Structured JSON log file: machine-parseable logs alongside human-readable
7
+ * - Phase spans: automatic timing of pipeline phases with structured events
8
+ * - Operation context: attach context (phase, segment, etc.) to log entries
9
+ * - Log aggregation: summary stats computed from structured entries
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
18
+
19
+ class Logger {
20
+ /**
21
+ * @param {string} logsDir - Directory for log files
22
+ * @param {string} callName - Name used in the log filename
23
+ * @param {object} [opts]
24
+ * @param {string} [opts.level='info'] - Minimum log level: debug|info|warn|error
25
+ * @param {number} [opts.flushIntervalMs=500] - How often to flush buffers
26
+ */
27
+ constructor(logsDir, callName, opts = {}) {
28
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
29
+ fs.mkdirSync(logsDir, { recursive: true });
30
+
31
+ this.detailedPath = path.join(logsDir, `${callName}_${ts}_detailed.log`);
32
+ this.minimalPath = path.join(logsDir, `${callName}_${ts}_minimal.log`);
33
+ this.structuredPath = path.join(logsDir, `${callName}_${ts}_structured.jsonl`);
34
+ this.startTime = Date.now();
35
+ this.level = LOG_LEVELS[opts.level] ?? LOG_LEVELS.info;
36
+ this.closed = false;
37
+ this.callName = callName;
38
+
39
+ // Buffered write system — accumulate lines, flush periodically
40
+ this._detailedBuffer = [];
41
+ this._minimalBuffer = [];
42
+ this._structuredBuffer = [];
43
+ this._flushInterval = setInterval(() => this._flush(), opts.flushIntervalMs || 500);
44
+ // Prevent the interval from keeping the process alive
45
+ if (this._flushInterval.unref) this._flushInterval.unref();
46
+
47
+ // Original console methods (for unpatch)
48
+ this._origLog = null;
49
+ this._origWarn = null;
50
+ this._origError = null;
51
+
52
+ // Phase tracking for spans
53
+ this._activePhase = null;
54
+ this._phaseStart = null;
55
+ this._phases = []; // Completed phase records
56
+
57
+ // Operation context stack
58
+ this._context = {};
59
+
60
+ // Write headers
61
+ const header = `=== ${callName} | ${new Date().toISOString()} ===\n`;
62
+ this._detailedBuffer.push(header);
63
+ this._minimalBuffer.push(header);
64
+ this._writeStructured({
65
+ event: 'session_start',
66
+ callName,
67
+ timestamp: new Date().toISOString(),
68
+ level: 'info',
69
+ });
70
+ this._flush(); // Flush headers immediately
71
+ }
72
+
73
+ _ts() {
74
+ const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
75
+ return `[${new Date().toISOString().slice(11, 19)} +${elapsed}s]`;
76
+ }
77
+
78
+ _elapsedMs() {
79
+ return Date.now() - this.startTime;
80
+ }
81
+
82
+ _shouldLog(level) {
83
+ return LOG_LEVELS[level] >= this.level;
84
+ }
85
+
86
+ _writeDetailed(line) {
87
+ if (this.closed) return;
88
+ this._detailedBuffer.push(line + '\n');
89
+ }
90
+
91
+ _writeMinimal(line) {
92
+ if (this.closed) return;
93
+ this._minimalBuffer.push(line + '\n');
94
+ }
95
+
96
+ _writeBoth(line) {
97
+ this._writeDetailed(line);
98
+ this._writeMinimal(line);
99
+ }
100
+
101
+ /**
102
+ * Write a structured JSON entry to the JSONL log.
103
+ * @param {object} entry - Structured log entry (will have timestamp/elapsed added)
104
+ */
105
+ _writeStructured(entry) {
106
+ if (this.closed) return;
107
+ const enriched = {
108
+ ...entry,
109
+ elapsedMs: this._elapsedMs(),
110
+ ...(this._activePhase ? { phase: this._activePhase } : {}),
111
+ ...(Object.keys(this._context).length > 0 ? { context: { ...this._context } } : {}),
112
+ };
113
+ try {
114
+ this._structuredBuffer.push(JSON.stringify(enriched) + '\n');
115
+ } catch { /* ignore serialization errors */ }
116
+ }
117
+
118
+ _flush(sync = false) {
119
+ const writeFn = sync
120
+ ? (p, d) => fs.appendFileSync(p, d)
121
+ : (p, d) => fs.appendFile(p, d, () => {});
122
+
123
+ if (this._detailedBuffer.length > 0) {
124
+ const data = this._detailedBuffer.join('');
125
+ this._detailedBuffer.length = 0;
126
+ try { writeFn(this.detailedPath, data); }
127
+ catch { /* ignore write errors */ }
128
+ }
129
+ if (this._minimalBuffer.length > 0) {
130
+ const data = this._minimalBuffer.join('');
131
+ this._minimalBuffer.length = 0;
132
+ try { writeFn(this.minimalPath, data); }
133
+ catch { /* ignore write errors */ }
134
+ }
135
+ if (this._structuredBuffer.length > 0) {
136
+ const data = this._structuredBuffer.join('');
137
+ this._structuredBuffer.length = 0;
138
+ try { writeFn(this.structuredPath, data); }
139
+ catch { /* ignore write errors */ }
140
+ }
141
+ }
142
+
143
+ // ======================== CONTEXT ========================
144
+
145
+ /**
146
+ * Set operation context that will be attached to all subsequent log entries.
147
+ * @param {object} ctx - Context fields (e.g., { segment: 'seg_00', phase: 'analyze' })
148
+ */
149
+ setContext(ctx) {
150
+ this._context = { ...this._context, ...ctx };
151
+ }
152
+
153
+ /**
154
+ * Clear specific context keys.
155
+ * @param {...string} keys - Keys to remove
156
+ */
157
+ clearContext(...keys) {
158
+ for (const k of keys) delete this._context[k];
159
+ }
160
+
161
+ // ======================== PHASE SPANS ========================
162
+
163
+ /**
164
+ * Start a named phase span for timing.
165
+ * @param {string} phaseName - Name of the phase
166
+ */
167
+ phaseStart(phaseName) {
168
+ // End previous phase if active
169
+ if (this._activePhase) {
170
+ this.phaseEnd();
171
+ }
172
+
173
+ this._activePhase = phaseName;
174
+ this._phaseStart = Date.now();
175
+
176
+ this._writeStructured({
177
+ event: 'phase_start',
178
+ phase: phaseName,
179
+ timestamp: new Date().toISOString(),
180
+ level: 'info',
181
+ });
182
+ }
183
+
184
+ /**
185
+ * End the current phase span and record timing.
186
+ * @param {object} [meta] - Optional metadata to attach to the phase record
187
+ * @returns {number} Duration in milliseconds
188
+ */
189
+ phaseEnd(meta = {}) {
190
+ if (!this._activePhase) return 0;
191
+
192
+ const durationMs = Date.now() - this._phaseStart;
193
+ const record = {
194
+ phase: this._activePhase,
195
+ durationMs,
196
+ ...meta,
197
+ };
198
+ this._phases.push(record);
199
+
200
+ this._writeStructured({
201
+ event: 'phase_end',
202
+ phase: this._activePhase,
203
+ durationMs,
204
+ timestamp: new Date().toISOString(),
205
+ level: 'info',
206
+ meta,
207
+ });
208
+
209
+ const name = this._activePhase;
210
+ this._activePhase = null;
211
+ this._phaseStart = null;
212
+
213
+ return durationMs;
214
+ }
215
+
216
+ /**
217
+ * Get all completed phase records.
218
+ * @returns {Array<{phase: string, durationMs: number}>}
219
+ */
220
+ getPhases() {
221
+ return [...this._phases];
222
+ }
223
+
224
+ // ======================== LOG METHODS ========================
225
+
226
+ /** Detailed log only — verbose/debug info */
227
+ debug(msg, data = null) {
228
+ if (!this._shouldLog('debug')) return;
229
+ this._writeDetailed(`${this._ts()} DBG ${msg}`);
230
+ this._writeStructured({ event: 'log', level: 'debug', message: msg, ...(data ? { data } : {}) });
231
+ }
232
+
233
+ /** Both logs — standard info */
234
+ info(msg, data = null) {
235
+ if (!this._shouldLog('info')) return;
236
+ this._writeBoth(`${this._ts()} INFO ${msg}`);
237
+ this._writeStructured({ event: 'log', level: 'info', message: msg, ...(data ? { data } : {}) });
238
+ }
239
+
240
+ /** Both logs — warnings */
241
+ warn(msg, data = null) {
242
+ if (!this._shouldLog('warn')) return;
243
+ this._writeBoth(`${this._ts()} WARN ${msg}`);
244
+ this._writeStructured({ event: 'log', level: 'warn', message: msg, ...(data ? { data } : {}) });
245
+ }
246
+
247
+ /** Both logs — errors */
248
+ error(msg, data = null) {
249
+ // Errors always logged regardless of level
250
+ this._writeBoth(`${this._ts()} ERR ${msg}`);
251
+ this._writeStructured({ event: 'log', level: 'error', message: msg, ...(data ? { data } : {}) });
252
+ }
253
+
254
+ /** Minimal + detailed — key milestone events */
255
+ step(msg, data = null) {
256
+ this._writeBoth(`${this._ts()} STEP ${msg}`);
257
+ this._writeStructured({ event: 'step', level: 'info', message: msg, ...(data ? { data } : {}) });
258
+ }
259
+
260
+ /**
261
+ * Log a structured metric event (e.g., token usage, quality scores).
262
+ * Only goes to structured log — not human-readable logs.
263
+ * @param {string} metric - Metric name
264
+ * @param {object} value - Metric data
265
+ */
266
+ metric(metric, value) {
267
+ this._writeStructured({
268
+ event: 'metric',
269
+ metric,
270
+ value,
271
+ timestamp: new Date().toISOString(),
272
+ level: 'info',
273
+ });
274
+ }
275
+
276
+ /** Patch console so ALL output also goes to detailed log */
277
+ patchConsole() {
278
+ this._origLog = console.log.bind(console);
279
+ this._origWarn = console.warn.bind(console);
280
+ this._origError = console.error.bind(console);
281
+ const self = this;
282
+
283
+ console.log = function (...args) {
284
+ self._origLog(...args);
285
+ try { self.info(args.map(String).join(' ')); } catch { /* ignore */ }
286
+ };
287
+ console.warn = function (...args) {
288
+ self._origWarn(...args);
289
+ try { self.warn(args.map(String).join(' ')); } catch { /* ignore */ }
290
+ };
291
+ console.error = function (...args) {
292
+ self._origError(...args);
293
+ try { self.error(args.map(String).join(' ')); } catch { /* ignore */ }
294
+ };
295
+ }
296
+
297
+ /** Restore original console methods */
298
+ unpatchConsole() {
299
+ if (this._origLog) console.log = this._origLog;
300
+ if (this._origWarn) console.warn = this._origWarn;
301
+ if (this._origError) console.error = this._origError;
302
+ }
303
+
304
+ /** Write a final summary block to minimal log */
305
+ summary(lines) {
306
+ const block = '\n--- SUMMARY ---\n' + lines.join('\n') + '\n';
307
+ this._writeBoth(block);
308
+
309
+ this._writeStructured({
310
+ event: 'session_summary',
311
+ phases: this._phases,
312
+ totalElapsedMs: this._elapsedMs(),
313
+ timestamp: new Date().toISOString(),
314
+ level: 'info',
315
+ });
316
+ }
317
+
318
+ /** Flush buffers and close the logger. Safe to call multiple times. */
319
+ close() {
320
+ if (this.closed) return;
321
+ this.closed = true;
322
+ clearInterval(this._flushInterval);
323
+ this.unpatchConsole();
324
+
325
+ // End active phase if any
326
+ if (this._activePhase) {
327
+ this.phaseEnd();
328
+ }
329
+
330
+ // Write footer
331
+ const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
332
+ const footer = `\n=== CLOSED | elapsed: ${elapsed}s | ${new Date().toISOString()} ===\n`;
333
+ this._detailedBuffer.push(footer);
334
+ this._minimalBuffer.push(footer);
335
+ this._writeStructured({
336
+ event: 'session_end',
337
+ totalElapsedMs: this._elapsedMs(),
338
+ phases: this._phases,
339
+ timestamp: new Date().toISOString(),
340
+ level: 'info',
341
+ });
342
+ this._flush(true); // sync flush on close to ensure data is written before process exits
343
+ }
344
+
345
+ /** Get human-readable elapsed time */
346
+ elapsed() {
347
+ const sec = (Date.now() - this.startTime) / 1000;
348
+ if (sec < 60) return `${sec.toFixed(1)}s`;
349
+ const min = Math.floor(sec / 60);
350
+ const remainder = (sec % 60).toFixed(0);
351
+ return `${min}m ${remainder}s`;
352
+ }
353
+ }
354
+
355
+ module.exports = Logger;