task-summary-extractor 8.3.0 → 9.0.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/.env.example +38 -0
- package/ARCHITECTURE.md +99 -3
- package/EXPLORATION.md +148 -89
- package/QUICK_START.md +5 -2
- package/README.md +51 -7
- package/bin/taskex.js +11 -4
- package/package.json +38 -5
- package/src/config.js +52 -3
- package/src/modes/focused-reanalysis.js +2 -1
- package/src/modes/progress-updater.js +1 -1
- package/src/phases/_shared.js +43 -0
- package/src/phases/compile.js +101 -0
- package/src/phases/deep-dive.js +118 -0
- package/src/phases/discover.js +178 -0
- package/src/phases/init.js +192 -0
- package/src/phases/output.js +238 -0
- package/src/phases/process-media.js +633 -0
- package/src/phases/services.js +104 -0
- package/src/phases/summary.js +86 -0
- package/src/pipeline.js +431 -1463
- package/src/renderers/docx.js +531 -0
- package/src/renderers/html.js +672 -0
- package/src/renderers/markdown.js +15 -183
- package/src/renderers/pdf.js +90 -0
- package/src/renderers/shared.js +211 -0
- package/src/schemas/analysis-compiled.schema.json +381 -0
- package/src/schemas/analysis-segment.schema.json +380 -0
- package/src/services/doc-parser.js +346 -0
- package/src/services/gemini.js +101 -44
- package/src/services/video.js +123 -8
- package/src/utils/adaptive-budget.js +6 -4
- package/src/utils/checkpoint.js +2 -1
- package/src/utils/cli.js +131 -110
- package/src/utils/colors.js +83 -0
- package/src/utils/confidence-filter.js +138 -0
- package/src/utils/diff-engine.js +2 -1
- package/src/utils/global-config.js +6 -5
- package/src/utils/health-dashboard.js +11 -9
- package/src/utils/json-parser.js +4 -2
- package/src/utils/learning-loop.js +3 -2
- package/src/utils/progress-bar.js +286 -0
- package/src/utils/quality-gate.js +4 -2
- package/src/utils/retry.js +3 -1
- package/src/utils/schema-validator.js +314 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// ANSI color utility – zero dependencies
|
|
5
|
+
// Auto-detects color support via NO_COLOR / FORCE_COLOR / isTTY.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const env = process.env;
|
|
9
|
+
const enabled =
|
|
10
|
+
env.FORCE_COLOR !== undefined
|
|
11
|
+
? env.FORCE_COLOR !== '0'
|
|
12
|
+
: !env.NO_COLOR && (process.stdout.isTTY === true);
|
|
13
|
+
|
|
14
|
+
/** Whether ANSI colors are currently active. */
|
|
15
|
+
const isColorEnabled = () => enabled;
|
|
16
|
+
|
|
17
|
+
// -- helpers ----------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const esc = (open, close) =>
|
|
20
|
+
enabled ? (s) => `\x1b[${open}m${s}\x1b[${close}m` : (s) => String(s);
|
|
21
|
+
|
|
22
|
+
const noop = (s) => String(s);
|
|
23
|
+
|
|
24
|
+
// -- ANSI escape code regex (for stripping) ---------------------------------
|
|
25
|
+
|
|
26
|
+
// eslint-disable-next-line no-control-regex
|
|
27
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
28
|
+
|
|
29
|
+
/** Remove all ANSI escape codes from a string. */
|
|
30
|
+
const strip = (s) => String(s).replace(ANSI_RE, '');
|
|
31
|
+
|
|
32
|
+
// -- style / color functions ------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const bold = esc(1, 22);
|
|
35
|
+
const dim = esc(2, 22);
|
|
36
|
+
const italic = esc(3, 23);
|
|
37
|
+
const underline = esc(4, 24);
|
|
38
|
+
|
|
39
|
+
const red = esc(31, 39);
|
|
40
|
+
const green = esc(32, 39);
|
|
41
|
+
const yellow = esc(33, 39);
|
|
42
|
+
const blue = esc(34, 39);
|
|
43
|
+
const magenta = esc(35, 39);
|
|
44
|
+
const cyan = esc(36, 39);
|
|
45
|
+
const white = esc(97, 39);
|
|
46
|
+
const gray = esc(90, 39);
|
|
47
|
+
|
|
48
|
+
const bgRed = esc(41, 49);
|
|
49
|
+
const bgGreen = esc(42, 49);
|
|
50
|
+
const bgYellow = esc(43, 49);
|
|
51
|
+
const bgBlue = esc(44, 49);
|
|
52
|
+
|
|
53
|
+
// -- semantic aliases -------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
const compose = (...fns) =>
|
|
56
|
+
enabled ? (s) => fns.reduce((v, fn) => fn(v), s) : noop;
|
|
57
|
+
|
|
58
|
+
const success = enabled ? (s) => green(`✓ ${s}`) : (s) => `✓ ${s}`;
|
|
59
|
+
const error = enabled ? (s) => red(`✗ ${s}`) : (s) => `✗ ${s}`;
|
|
60
|
+
const warn = enabled ? (s) => yellow(`⚠ ${s}`) : (s) => `⚠ ${s}`;
|
|
61
|
+
const info = enabled ? (s) => blue(`ℹ ${s}`) : (s) => `ℹ ${s}`;
|
|
62
|
+
|
|
63
|
+
const heading = compose(bold, cyan);
|
|
64
|
+
const muted = dim;
|
|
65
|
+
const highlight = compose(bold, yellow);
|
|
66
|
+
const link = compose(underline, blue);
|
|
67
|
+
|
|
68
|
+
// -- public API -------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/** Colour helper object – every function is a safe no-op when colours are off. */
|
|
71
|
+
const c = {
|
|
72
|
+
// styles
|
|
73
|
+
bold, dim, italic, underline,
|
|
74
|
+
// foreground
|
|
75
|
+
red, green, yellow, blue, cyan, magenta, gray, white,
|
|
76
|
+
// background
|
|
77
|
+
bgRed, bgGreen, bgYellow, bgBlue,
|
|
78
|
+
// semantic
|
|
79
|
+
success, error, warn, info,
|
|
80
|
+
heading, muted, highlight, link,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
module.exports = { c, isColorEnabled, strip };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confidence Filter — filters extracted items below a confidence threshold.
|
|
3
|
+
*
|
|
4
|
+
* Confidence hierarchy: HIGH (3) > MEDIUM (2) > LOW (1).
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const { filterByConfidence } = require('./utils/confidence-filter');
|
|
8
|
+
* const filtered = filterByConfidence(compiledAnalysis, 'MEDIUM');
|
|
9
|
+
* // → keeps only HIGH + MEDIUM items; LOW items removed
|
|
10
|
+
*
|
|
11
|
+
* @module confidence-filter
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
// ======================== CONSTANTS ========================
|
|
17
|
+
|
|
18
|
+
const LEVELS = { HIGH: 3, MEDIUM: 2, LOW: 1 };
|
|
19
|
+
const VALID_LEVELS = new Set(['HIGH', 'MEDIUM', 'LOW']);
|
|
20
|
+
|
|
21
|
+
// ======================== HELPERS ========================
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Count items across all filterable arrays.
|
|
25
|
+
* @param {object} data - compiled analysis object
|
|
26
|
+
* @returns {{ tickets: number, action_items: number, change_requests: number, blockers: number, scope_changes: number, your_tasks: number, total: number }}
|
|
27
|
+
*/
|
|
28
|
+
function countItems(data) {
|
|
29
|
+
const tickets = (data.tickets || []).length;
|
|
30
|
+
const action_items = (data.action_items || []).length;
|
|
31
|
+
const change_requests = (data.change_requests || []).length;
|
|
32
|
+
const blockers = (data.blockers || []).length;
|
|
33
|
+
const scope_changes = (data.scope_changes || []).length;
|
|
34
|
+
|
|
35
|
+
let your_tasks = 0;
|
|
36
|
+
if (data.your_tasks) {
|
|
37
|
+
your_tasks += (data.your_tasks.tasks_todo || []).length;
|
|
38
|
+
your_tasks += (data.your_tasks.tasks_waiting_on_others || []).length;
|
|
39
|
+
your_tasks += (data.your_tasks.decisions_needed || []).length;
|
|
40
|
+
your_tasks += (data.your_tasks.completed_in_call || []).length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
tickets,
|
|
45
|
+
action_items,
|
|
46
|
+
change_requests,
|
|
47
|
+
blockers,
|
|
48
|
+
scope_changes,
|
|
49
|
+
your_tasks,
|
|
50
|
+
total: tickets + action_items + change_requests + blockers + scope_changes + your_tasks,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ======================== MAIN FILTER ========================
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Filter a compiled analysis, removing items below the confidence threshold.
|
|
58
|
+
*
|
|
59
|
+
* Items without a `confidence` field are treated as LOW.
|
|
60
|
+
* Non-array fields (summary, file_references, etc.) are passed through untouched.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} compiled - Compiled analysis object
|
|
63
|
+
* @param {string} [minLevel='LOW'] - Minimum confidence: 'HIGH', 'MEDIUM', or 'LOW'
|
|
64
|
+
* @returns {object} Filtered copy with _filterMeta attached
|
|
65
|
+
*/
|
|
66
|
+
function filterByConfidence(compiled, minLevel = 'LOW') {
|
|
67
|
+
if (!compiled || typeof compiled !== 'object') return compiled;
|
|
68
|
+
|
|
69
|
+
const normalised = (minLevel || 'LOW').toUpperCase();
|
|
70
|
+
const threshold = LEVELS[normalised] || 1;
|
|
71
|
+
|
|
72
|
+
// If threshold is LOW (1), everything passes — return with meta only
|
|
73
|
+
const originalCounts = countItems(compiled);
|
|
74
|
+
|
|
75
|
+
if (threshold <= 1) {
|
|
76
|
+
return {
|
|
77
|
+
...compiled,
|
|
78
|
+
_filterMeta: {
|
|
79
|
+
minConfidence: normalised,
|
|
80
|
+
originalCounts,
|
|
81
|
+
filteredCounts: { ...originalCounts },
|
|
82
|
+
removed: 0,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const filterArr = (items) =>
|
|
88
|
+
(items || []).filter(item => (LEVELS[item.confidence] || 1) >= threshold);
|
|
89
|
+
|
|
90
|
+
const filtered = {
|
|
91
|
+
...compiled,
|
|
92
|
+
tickets: filterArr(compiled.tickets),
|
|
93
|
+
action_items: filterArr(compiled.action_items),
|
|
94
|
+
change_requests: filterArr(compiled.change_requests),
|
|
95
|
+
blockers: filterArr(compiled.blockers),
|
|
96
|
+
scope_changes: filterArr(compiled.scope_changes),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Filter your_tasks sub-arrays if present
|
|
100
|
+
if (compiled.your_tasks && typeof compiled.your_tasks === 'object') {
|
|
101
|
+
filtered.your_tasks = {
|
|
102
|
+
...compiled.your_tasks,
|
|
103
|
+
tasks_todo: filterArr(compiled.your_tasks.tasks_todo),
|
|
104
|
+
tasks_waiting_on_others: filterArr(compiled.your_tasks.tasks_waiting_on_others),
|
|
105
|
+
decisions_needed: filterArr(compiled.your_tasks.decisions_needed),
|
|
106
|
+
completed_in_call: filterArr(compiled.your_tasks.completed_in_call),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const filteredCounts = countItems(filtered);
|
|
111
|
+
|
|
112
|
+
filtered._filterMeta = {
|
|
113
|
+
minConfidence: normalised,
|
|
114
|
+
originalCounts,
|
|
115
|
+
filteredCounts,
|
|
116
|
+
removed: originalCounts.total - filteredCounts.total,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return filtered;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Validate a min-confidence level string.
|
|
124
|
+
* @param {string} level
|
|
125
|
+
* @returns {{ valid: boolean, normalised: string|null, error: string|null }}
|
|
126
|
+
*/
|
|
127
|
+
function validateConfidenceLevel(level) {
|
|
128
|
+
if (!level || typeof level !== 'string') {
|
|
129
|
+
return { valid: false, normalised: null, error: 'Confidence level must be a string: high, medium, or low' };
|
|
130
|
+
}
|
|
131
|
+
const normalised = level.toUpperCase();
|
|
132
|
+
if (!VALID_LEVELS.has(normalised)) {
|
|
133
|
+
return { valid: false, normalised: null, error: `Invalid confidence level "${level}". Must be: high, medium, or low` };
|
|
134
|
+
}
|
|
135
|
+
return { valid: true, normalised, error: null };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { filterByConfidence, validateConfidenceLevel, countItems, LEVELS };
|
package/src/utils/diff-engine.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
|
+
const { c } = require('./colors');
|
|
13
14
|
|
|
14
15
|
// ======================== PREVIOUS RUN LOADING ========================
|
|
15
16
|
|
|
@@ -61,7 +62,7 @@ function loadPreviousCompilation(targetDir, currentRunTs = null) {
|
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
} catch (err) {
|
|
64
|
-
console.warn(`
|
|
65
|
+
console.warn(` ${c.warn(`Could not load previous compilation: ${err.message}`)}`);
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
return null;
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
const fs = require('fs');
|
|
22
22
|
const path = require('path');
|
|
23
23
|
const os = require('os');
|
|
24
|
+
const { c } = require('./colors');
|
|
24
25
|
|
|
25
26
|
// ── Config file path ──────────────────────────────────────────────────────
|
|
26
27
|
const CONFIG_FILE = path.join(os.homedir(), '.taskexrc');
|
|
@@ -53,7 +54,7 @@ function loadGlobalConfig() {
|
|
|
53
54
|
} catch (err) {
|
|
54
55
|
// Warn if file exists but is corrupt (not just missing)
|
|
55
56
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
56
|
-
process.stderr.write(`
|
|
57
|
+
process.stderr.write(` ${c.warn(`Could not parse ${CONFIG_FILE}: ${err.message}`)}\n`);
|
|
57
58
|
process.stderr.write(` Run \`taskex config\` to reconfigure, or delete the file.\n`);
|
|
58
59
|
}
|
|
59
60
|
return {};
|
|
@@ -155,7 +156,7 @@ async function interactiveSetup({ showOnly = false, clear = false, onlyMissing =
|
|
|
155
156
|
if (clear) {
|
|
156
157
|
const removed = clearGlobalConfig();
|
|
157
158
|
if (removed) {
|
|
158
|
-
console.log(
|
|
159
|
+
console.log(`\n ${c.success('Global config cleared (~/.taskexrc deleted)')}\n`);
|
|
159
160
|
} else {
|
|
160
161
|
console.log('\n No global config to clear.\n');
|
|
161
162
|
}
|
|
@@ -233,7 +234,7 @@ async function interactiveSetup({ showOnly = false, clear = false, onlyMissing =
|
|
|
233
234
|
|
|
234
235
|
if (Object.keys(updates).length > 0) {
|
|
235
236
|
saveGlobalConfig(updates);
|
|
236
|
-
console.log(`
|
|
237
|
+
console.log(` ${c.success(`Saved ${Object.keys(updates).length} value(s) to ${CONFIG_FILE}`)}`);
|
|
237
238
|
console.log('');
|
|
238
239
|
|
|
239
240
|
// Also inject into current process so the pipeline can proceed
|
|
@@ -260,7 +261,7 @@ async function promptForKey(key) {
|
|
|
260
261
|
const readline = require('readline');
|
|
261
262
|
|
|
262
263
|
console.log('');
|
|
263
|
-
console.log(`
|
|
264
|
+
console.log(` ${c.warn(`${meta.label} is not configured.`)}`);
|
|
264
265
|
if (meta.hint) console.log(` ${meta.hint}`);
|
|
265
266
|
console.log('');
|
|
266
267
|
|
|
@@ -292,7 +293,7 @@ async function promptForKey(key) {
|
|
|
292
293
|
|
|
293
294
|
if (save) {
|
|
294
295
|
saveGlobalConfig({ [key]: value });
|
|
295
|
-
console.log(`
|
|
296
|
+
console.log(` ${c.success(`Saved to ${CONFIG_FILE}`)}`);
|
|
296
297
|
}
|
|
297
298
|
|
|
298
299
|
// Inject into current process
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
|
+
const { c } = require('./colors');
|
|
17
|
+
|
|
16
18
|
// ======================== HEALTH REPORT ========================
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -133,7 +135,7 @@ function buildHealthReport(params) {
|
|
|
133
135
|
* @param {object} report - From buildHealthReport
|
|
134
136
|
*/
|
|
135
137
|
function printHealthDashboard(report) {
|
|
136
|
-
const { summary: s, extraction: e, retry: r, efficiency: eff, compilation:
|
|
138
|
+
const { summary: s, extraction: e, retry: r, efficiency: eff, compilation: comp } = report;
|
|
137
139
|
|
|
138
140
|
console.log('');
|
|
139
141
|
console.log('╔══════════════════════════════════════════════╗');
|
|
@@ -145,7 +147,7 @@ function printHealthDashboard(report) {
|
|
|
145
147
|
console.log(' Quality Scores:');
|
|
146
148
|
console.log(` Average : ${s.avgQualityScore}/100`);
|
|
147
149
|
console.log(` Range : ${s.minQualityScore}–${s.maxQualityScore}`);
|
|
148
|
-
console.log(` Grades :
|
|
150
|
+
console.log(` Grades : ${c.success(`${s.grades.PASS} PASS`)} | ${c.warn(`${s.grades.WARN} WARN`)} | ${c.error(`${s.grades.FAIL} FAIL`)}`);
|
|
149
151
|
console.log(` Parse : ${s.parseSuccessRate}% success rate`);
|
|
150
152
|
console.log('');
|
|
151
153
|
|
|
@@ -186,14 +188,14 @@ function printHealthDashboard(report) {
|
|
|
186
188
|
console.log(` Cost : $${eff.totalCost.toFixed(4)}`);
|
|
187
189
|
|
|
188
190
|
// Compilation quality
|
|
189
|
-
if (
|
|
191
|
+
if (comp) {
|
|
190
192
|
console.log('');
|
|
191
193
|
console.log(' Compilation:');
|
|
192
|
-
console.log(` Score : ${
|
|
193
|
-
if (
|
|
194
|
-
console.log(` Issues: ${
|
|
195
|
-
|
|
196
|
-
if (
|
|
194
|
+
console.log(` Score : ${comp.score}/100 (${comp.grade})`);
|
|
195
|
+
if (comp.issues.length > 0) {
|
|
196
|
+
console.log(` Issues: ${comp.issues.length}`);
|
|
197
|
+
comp.issues.slice(0, 3).forEach(issue => console.log(` • ${issue}`));
|
|
198
|
+
if (comp.issues.length > 3) console.log(` ... +${comp.issues.length - 3} more`);
|
|
197
199
|
}
|
|
198
200
|
}
|
|
199
201
|
|
|
@@ -203,7 +205,7 @@ function printHealthDashboard(report) {
|
|
|
203
205
|
);
|
|
204
206
|
if (criticalIssues.length > 0) {
|
|
205
207
|
console.log('');
|
|
206
|
-
console.log('
|
|
208
|
+
console.log(` ${c.warn('Critical Issues:')}`);
|
|
207
209
|
criticalIssues.slice(0, 5).forEach(i => console.log(` ${i.segment}: ${i.issue}`));
|
|
208
210
|
}
|
|
209
211
|
|
package/src/utils/json-parser.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
'use strict';
|
|
8
8
|
|
|
9
|
+
const { c } = require('./colors');
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
12
|
* Gemini sometimes produces invalid JSON escape sequences (e.g. \d, \s, \w from regex patterns).
|
|
11
13
|
* Fix them by double-escaping backslashes that aren't valid JSON escapes.
|
|
@@ -228,14 +230,14 @@ function extractJson(rawText) {
|
|
|
228
230
|
// This is common for large compilation outputs (safest repair, stack-based)
|
|
229
231
|
parsed = repairTruncatedJson(rawText);
|
|
230
232
|
if (parsed !== undefined) {
|
|
231
|
-
console.warn('
|
|
233
|
+
console.warn(` ${c.warn('JSON was truncated — recovered partial data by closing open structures')}`);
|
|
232
234
|
return parsed;
|
|
233
235
|
}
|
|
234
236
|
|
|
235
237
|
// Strategy 5: Fix doubled closers and mid-output structural errors (aggressive, last resort)
|
|
236
238
|
parsed = repairDoubledClosers(rawText);
|
|
237
239
|
if (parsed !== undefined) {
|
|
238
|
-
console.warn('
|
|
240
|
+
console.warn(` ${c.warn('JSON had structural errors (doubled braces/commas) — repaired')}`);
|
|
239
241
|
return parsed;
|
|
240
242
|
}
|
|
241
243
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
const fs = require('fs');
|
|
15
15
|
const path = require('path');
|
|
16
|
+
const { c } = require('./colors');
|
|
16
17
|
|
|
17
18
|
const HISTORY_FILE = 'history.json';
|
|
18
19
|
const MAX_HISTORY_ENTRIES = 50; // Keep last 50 runs
|
|
@@ -33,7 +34,7 @@ function loadHistory(projectRoot) {
|
|
|
33
34
|
return Array.isArray(data) ? data : [];
|
|
34
35
|
}
|
|
35
36
|
} catch (err) {
|
|
36
|
-
console.warn(`
|
|
37
|
+
console.warn(` ${c.warn(`Could not load history: ${err.message}`)}`);
|
|
37
38
|
}
|
|
38
39
|
return [];
|
|
39
40
|
}
|
|
@@ -54,7 +55,7 @@ function saveHistory(projectRoot, entry) {
|
|
|
54
55
|
const trimmed = history.slice(-MAX_HISTORY_ENTRIES);
|
|
55
56
|
fs.writeFileSync(historyPath, JSON.stringify(trimmed, null, 2), 'utf8');
|
|
56
57
|
} catch (err) {
|
|
57
|
-
console.warn(`
|
|
58
|
+
console.warn(` ${c.warn(`Could not save history: ${err.message}`)}`);
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress Bar — visual progress display for pipeline phases.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Real-time bar with phase name, percentage, and ETA
|
|
6
|
+
* - Segment-level sub-progress during media processing
|
|
7
|
+
* - Live cost display via CostTracker integration
|
|
8
|
+
* - Non-TTY fallback: one line per event (CI-friendly)
|
|
9
|
+
* - Writes to stderr to avoid polluting piped stdout
|
|
10
|
+
*
|
|
11
|
+
* @module progress-bar
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { fmtDuration } = require('./format');
|
|
17
|
+
|
|
18
|
+
// ======================== PHASE DEFINITIONS ========================
|
|
19
|
+
|
|
20
|
+
const PHASES = [
|
|
21
|
+
{ key: 'init', label: 'Init', index: 1 },
|
|
22
|
+
{ key: 'discover', label: 'Discover', index: 2 },
|
|
23
|
+
{ key: 'services', label: 'Services', index: 3 },
|
|
24
|
+
{ key: 'compress', label: 'Compress', index: 4 },
|
|
25
|
+
{ key: 'upload', label: 'Upload', index: 5 },
|
|
26
|
+
{ key: 'analyze', label: 'Analyze', index: 6 },
|
|
27
|
+
{ key: 'compile', label: 'Compile', index: 7 },
|
|
28
|
+
{ key: 'output', label: 'Output', index: 8 },
|
|
29
|
+
{ key: 'summary', label: 'Summary', index: 9 },
|
|
30
|
+
{ key: 'deep-dive', label: 'Deep Dive', index: 10 },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const PHASE_MAP = Object.fromEntries(PHASES.map(p => [p.key, p]));
|
|
34
|
+
const TOTAL_PHASES = PHASES.length;
|
|
35
|
+
|
|
36
|
+
// ======================== BAR CHARACTERS ========================
|
|
37
|
+
|
|
38
|
+
const BAR_FILLED = '\u2501'; // ━
|
|
39
|
+
const BAR_EMPTY = '\u2500'; // ─
|
|
40
|
+
const BAR_LEFT = '';
|
|
41
|
+
const BAR_RIGHT = '';
|
|
42
|
+
|
|
43
|
+
// ======================== PROGRESS BAR CLASS ========================
|
|
44
|
+
|
|
45
|
+
class ProgressBar {
|
|
46
|
+
/**
|
|
47
|
+
* @param {object} [opts]
|
|
48
|
+
* @param {number} [opts.width=40] - Bar width in characters
|
|
49
|
+
* @param {NodeJS.WriteStream} [opts.stream] - Output stream (default: stderr)
|
|
50
|
+
* @param {boolean} [opts.enabled] - Force enable/disable (default: auto-detect TTY)
|
|
51
|
+
* @param {object} [opts.costTracker] - CostTracker instance for live cost display
|
|
52
|
+
* @param {string} [opts.callName] - Name of the current call/project
|
|
53
|
+
*/
|
|
54
|
+
constructor(opts = {}) {
|
|
55
|
+
this.width = opts.width || 40;
|
|
56
|
+
this.stream = opts.stream || process.stderr;
|
|
57
|
+
this.enabled = opts.enabled !== undefined ? opts.enabled : (this.stream.isTTY === true);
|
|
58
|
+
this.costTracker = opts.costTracker || null;
|
|
59
|
+
this.callName = opts.callName || '';
|
|
60
|
+
|
|
61
|
+
// Phase tracking
|
|
62
|
+
this.phaseKey = 'init';
|
|
63
|
+
this.phaseLabel = 'Init';
|
|
64
|
+
this.phaseIndex = 1;
|
|
65
|
+
|
|
66
|
+
// Item tracking within a phase
|
|
67
|
+
this.total = 0;
|
|
68
|
+
this.current = 0;
|
|
69
|
+
this.itemLabel = '';
|
|
70
|
+
|
|
71
|
+
// Time tracking
|
|
72
|
+
this.startTime = Date.now();
|
|
73
|
+
this.phaseStartTime = Date.now();
|
|
74
|
+
|
|
75
|
+
// Sub-status for long operations
|
|
76
|
+
this.subStatus = '';
|
|
77
|
+
|
|
78
|
+
// Track if we need to restore cursor
|
|
79
|
+
this._lastLineLength = 0;
|
|
80
|
+
this._rendered = false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ======================== PUBLIC API ========================
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Set the current pipeline phase.
|
|
87
|
+
* @param {string} key - Phase key (e.g. 'compress', 'analyze')
|
|
88
|
+
* @param {number} [total] - Total items in this phase
|
|
89
|
+
*/
|
|
90
|
+
setPhase(key, total) {
|
|
91
|
+
const phase = PHASE_MAP[key];
|
|
92
|
+
if (phase) {
|
|
93
|
+
this.phaseKey = key;
|
|
94
|
+
this.phaseLabel = phase.label;
|
|
95
|
+
this.phaseIndex = phase.index;
|
|
96
|
+
} else {
|
|
97
|
+
this.phaseKey = key;
|
|
98
|
+
this.phaseLabel = key;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.total = total || 0;
|
|
102
|
+
this.current = 0;
|
|
103
|
+
this.itemLabel = '';
|
|
104
|
+
this.subStatus = '';
|
|
105
|
+
this.phaseStartTime = Date.now();
|
|
106
|
+
|
|
107
|
+
if (this.enabled) {
|
|
108
|
+
this._clearLine();
|
|
109
|
+
this.render();
|
|
110
|
+
} else {
|
|
111
|
+
this._logEvent(`[Phase ${this.phaseIndex}/${TOTAL_PHASES}] ${this.phaseLabel}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Set total items for the current phase.
|
|
117
|
+
* @param {number} n
|
|
118
|
+
*/
|
|
119
|
+
setTotal(n) {
|
|
120
|
+
this.total = n;
|
|
121
|
+
if (this.enabled) this.render();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Increment progress by 1 and update the item label.
|
|
126
|
+
* @param {string} [label] - Current item description (e.g. "segment_01.mp4")
|
|
127
|
+
*/
|
|
128
|
+
tick(label) {
|
|
129
|
+
this.current = Math.min(this.current + 1, Math.max(this.total, this.current + 1));
|
|
130
|
+
if (label) this.itemLabel = label;
|
|
131
|
+
this.subStatus = '';
|
|
132
|
+
|
|
133
|
+
if (this.enabled) {
|
|
134
|
+
this.render();
|
|
135
|
+
} else if (label) {
|
|
136
|
+
this._logEvent(` ${label} (${this.current}/${this.total})`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Update the sub-status text without incrementing.
|
|
142
|
+
* @param {string} text - Status text (e.g. "Uploading to Storage...")
|
|
143
|
+
*/
|
|
144
|
+
status(text) {
|
|
145
|
+
this.subStatus = text;
|
|
146
|
+
if (this.enabled) this.render();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Finish the progress bar — print a final newline and clear.
|
|
151
|
+
*/
|
|
152
|
+
finish() {
|
|
153
|
+
if (this.enabled && this._rendered) {
|
|
154
|
+
this._clearLine();
|
|
155
|
+
const elapsed = fmtDuration((Date.now() - this.startTime) / 1000);
|
|
156
|
+
const cost = this._getCostString();
|
|
157
|
+
const line = ` Done in ${elapsed}${cost ? ` | ${cost}` : ''}`;
|
|
158
|
+
this.stream.write(line + '\n');
|
|
159
|
+
}
|
|
160
|
+
this._rendered = false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Cleanup — restore terminal state.
|
|
165
|
+
*/
|
|
166
|
+
cleanup() {
|
|
167
|
+
if (this._rendered) {
|
|
168
|
+
this._clearLine();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ======================== RENDERING ========================
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Render the progress bar to the stream.
|
|
176
|
+
*/
|
|
177
|
+
render() {
|
|
178
|
+
if (!this.enabled) return;
|
|
179
|
+
|
|
180
|
+
const pct = this.total > 0 ? Math.min(100, Math.round((this.current / this.total) * 100)) : 0;
|
|
181
|
+
const filledWidth = this.total > 0
|
|
182
|
+
? Math.min(this.width, Math.round((this.current / this.total) * this.width))
|
|
183
|
+
: 0;
|
|
184
|
+
const emptyWidth = Math.max(0, this.width - filledWidth);
|
|
185
|
+
|
|
186
|
+
// Build bar
|
|
187
|
+
const bar = BAR_LEFT +
|
|
188
|
+
BAR_FILLED.repeat(filledWidth) +
|
|
189
|
+
BAR_EMPTY.repeat(emptyWidth) +
|
|
190
|
+
BAR_RIGHT;
|
|
191
|
+
|
|
192
|
+
// Phase info
|
|
193
|
+
const phaseStr = `Phase ${this.phaseIndex}/${TOTAL_PHASES}: ${this.phaseLabel}`;
|
|
194
|
+
|
|
195
|
+
// ETA
|
|
196
|
+
const eta = this._eta();
|
|
197
|
+
const etaStr = eta ? ` | ETA: ${eta}` : '';
|
|
198
|
+
|
|
199
|
+
// Cost
|
|
200
|
+
const costStr = this._getCostString();
|
|
201
|
+
const costPart = costStr ? ` | ${costStr}` : '';
|
|
202
|
+
|
|
203
|
+
// Item label or sub-status
|
|
204
|
+
const detail = this.subStatus || this.itemLabel;
|
|
205
|
+
const detailStr = detail ? ` ${detail}` : '';
|
|
206
|
+
|
|
207
|
+
// Progress counts
|
|
208
|
+
const countStr = this.total > 0 ? ` ${this.current}/${this.total}` : '';
|
|
209
|
+
|
|
210
|
+
// Build line
|
|
211
|
+
const line = ` ${bar} ${pct}% | ${phaseStr}${countStr}${detailStr}${etaStr}${costPart}`;
|
|
212
|
+
|
|
213
|
+
// Write with \r to overwrite
|
|
214
|
+
this._clearLine();
|
|
215
|
+
this.stream.write('\r' + line);
|
|
216
|
+
this._lastLineLength = line.length;
|
|
217
|
+
this._rendered = true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ======================== INTERNAL ========================
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Calculate ETA based on elapsed time and progress.
|
|
224
|
+
* @returns {string|null}
|
|
225
|
+
*/
|
|
226
|
+
_eta() {
|
|
227
|
+
if (this.total <= 0 || this.current <= 0) return null;
|
|
228
|
+
|
|
229
|
+
const elapsed = Date.now() - this.phaseStartTime;
|
|
230
|
+
if (elapsed < 2000) return null; // Don't show ETA for first 2s (unreliable)
|
|
231
|
+
|
|
232
|
+
const msPerItem = elapsed / this.current;
|
|
233
|
+
const remaining = (this.total - this.current) * msPerItem;
|
|
234
|
+
|
|
235
|
+
if (remaining < 1000) return null;
|
|
236
|
+
return fmtDuration(remaining / 1000);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get formatted cost string from CostTracker.
|
|
241
|
+
* @returns {string}
|
|
242
|
+
*/
|
|
243
|
+
_getCostString() {
|
|
244
|
+
if (!this.costTracker) return '';
|
|
245
|
+
try {
|
|
246
|
+
const summary = this.costTracker.getSummary();
|
|
247
|
+
if (summary.totalCost > 0) {
|
|
248
|
+
return `$${summary.totalCost.toFixed(4)}`;
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
// CostTracker not ready
|
|
252
|
+
}
|
|
253
|
+
return '';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Clear the current line.
|
|
258
|
+
*/
|
|
259
|
+
_clearLine() {
|
|
260
|
+
if (this._lastLineLength > 0) {
|
|
261
|
+
this.stream.write('\r' + ' '.repeat(this._lastLineLength + 2) + '\r');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Non-TTY fallback: print a simple log line.
|
|
267
|
+
* @param {string} msg
|
|
268
|
+
*/
|
|
269
|
+
_logEvent(msg) {
|
|
270
|
+
this.stream.write(msg + '\n');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ======================== FACTORY ========================
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Create a ProgressBar instance with sensible defaults.
|
|
278
|
+
*
|
|
279
|
+
* @param {object} [opts] - Same as ProgressBar constructor
|
|
280
|
+
* @returns {ProgressBar}
|
|
281
|
+
*/
|
|
282
|
+
function createProgressBar(opts = {}) {
|
|
283
|
+
return new ProgressBar(opts);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = { ProgressBar, createProgressBar, PHASES, PHASE_MAP };
|