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/ARCHITECTURE.md +605 -0
- package/EXPLORATION.md +451 -0
- package/QUICK_START.md +272 -0
- package/README.md +544 -0
- package/bin/taskex.js +64 -0
- package/package.json +63 -0
- package/process_and_upload.js +107 -0
- package/prompt.json +265 -0
- package/setup.js +505 -0
- package/src/config.js +327 -0
- package/src/logger.js +355 -0
- package/src/pipeline.js +2006 -0
- package/src/renderers/markdown.js +968 -0
- package/src/services/firebase.js +106 -0
- package/src/services/gemini.js +779 -0
- package/src/services/git.js +329 -0
- package/src/services/video.js +305 -0
- package/src/utils/adaptive-budget.js +266 -0
- package/src/utils/change-detector.js +466 -0
- package/src/utils/cli.js +415 -0
- package/src/utils/context-manager.js +499 -0
- package/src/utils/cost-tracker.js +156 -0
- package/src/utils/deep-dive.js +549 -0
- package/src/utils/diff-engine.js +315 -0
- package/src/utils/dynamic-mode.js +567 -0
- package/src/utils/focused-reanalysis.js +317 -0
- package/src/utils/format.js +32 -0
- package/src/utils/fs.js +39 -0
- package/src/utils/global-config.js +315 -0
- package/src/utils/health-dashboard.js +216 -0
- package/src/utils/inject-cli-flags.js +58 -0
- package/src/utils/json-parser.js +245 -0
- package/src/utils/learning-loop.js +301 -0
- package/src/utils/progress-updater.js +451 -0
- package/src/utils/progress.js +166 -0
- package/src/utils/prompt.js +32 -0
- package/src/utils/quality-gate.js +429 -0
- package/src/utils/retry.js +129 -0
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;
|