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.
Files changed (44) hide show
  1. package/.env.example +38 -0
  2. package/ARCHITECTURE.md +99 -3
  3. package/EXPLORATION.md +148 -89
  4. package/QUICK_START.md +5 -2
  5. package/README.md +51 -7
  6. package/bin/taskex.js +11 -4
  7. package/package.json +38 -5
  8. package/src/config.js +52 -3
  9. package/src/modes/focused-reanalysis.js +2 -1
  10. package/src/modes/progress-updater.js +1 -1
  11. package/src/phases/_shared.js +43 -0
  12. package/src/phases/compile.js +101 -0
  13. package/src/phases/deep-dive.js +118 -0
  14. package/src/phases/discover.js +178 -0
  15. package/src/phases/init.js +192 -0
  16. package/src/phases/output.js +238 -0
  17. package/src/phases/process-media.js +633 -0
  18. package/src/phases/services.js +104 -0
  19. package/src/phases/summary.js +86 -0
  20. package/src/pipeline.js +431 -1463
  21. package/src/renderers/docx.js +531 -0
  22. package/src/renderers/html.js +672 -0
  23. package/src/renderers/markdown.js +15 -183
  24. package/src/renderers/pdf.js +90 -0
  25. package/src/renderers/shared.js +211 -0
  26. package/src/schemas/analysis-compiled.schema.json +381 -0
  27. package/src/schemas/analysis-segment.schema.json +380 -0
  28. package/src/services/doc-parser.js +346 -0
  29. package/src/services/gemini.js +101 -44
  30. package/src/services/video.js +123 -8
  31. package/src/utils/adaptive-budget.js +6 -4
  32. package/src/utils/checkpoint.js +2 -1
  33. package/src/utils/cli.js +131 -110
  34. package/src/utils/colors.js +83 -0
  35. package/src/utils/confidence-filter.js +138 -0
  36. package/src/utils/diff-engine.js +2 -1
  37. package/src/utils/global-config.js +6 -5
  38. package/src/utils/health-dashboard.js +11 -9
  39. package/src/utils/json-parser.js +4 -2
  40. package/src/utils/learning-loop.js +3 -2
  41. package/src/utils/progress-bar.js +286 -0
  42. package/src/utils/quality-gate.js +4 -2
  43. package/src/utils/retry.js +3 -1
  44. 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 };
@@ -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(` Could not load previous compilation: ${err.message}`);
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(` Could not parse ${CONFIG_FILE}: ${err.message}\n`);
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('\n Global config cleared (~/.taskexrc deleted)\n');
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(` Saved ${Object.keys(updates).length} value(s) to ${CONFIG_FILE}`);
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(` ${meta.label} is not configured.`);
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(` Saved to ${CONFIG_FILE}`);
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: c } = report;
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 : ${s.grades.PASS} PASS | ${s.grades.WARN} WARN | ${s.grades.FAIL} FAIL`);
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 (c) {
191
+ if (comp) {
190
192
  console.log('');
191
193
  console.log(' Compilation:');
192
- console.log(` Score : ${c.score}/100 (${c.grade})`);
193
- if (c.issues.length > 0) {
194
- console.log(` Issues: ${c.issues.length}`);
195
- c.issues.slice(0, 3).forEach(issue => console.log(` • ${issue}`));
196
- if (c.issues.length > 3) console.log(` ... +${c.issues.length - 3} more`);
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('Critical Issues:');
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
 
@@ -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('JSON was truncated — recovered partial data by closing open structures');
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('JSON had structural errors (doubled braces/commas) — repaired');
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(` Could not load history: ${err.message}`);
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(` Could not save history: ${err.message}`);
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 };