task-summary-extractor 9.0.0 → 9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-summary-extractor",
3
- "version": "9.0.0",
3
+ "version": "9.1.0",
4
4
  "description": "AI-powered meeting analysis & document generation CLI — video + document processing, deep dive docs, dynamic mode, interactive CLI with model selection, confidence scoring, learning loop, git progress tracking",
5
5
  "main": "process_and_upload.js",
6
6
  "bin": {
package/prompt.json CHANGED
@@ -225,7 +225,7 @@
225
225
  "what": "string (precise description of the change)",
226
226
  "how": "string (implementation/execution approach discussed or specified)",
227
227
  "why": "string (business, technical, legal, or strategic reason)",
228
- "type": "bug_fix|feature|refactor|optimization|cleanup|upgrade|integration|configuration|process_change|policy_update|contract_revision|design_change",
228
+ "type": "bug_fix|feature|refactor|optimization|cleanup|upgrade|integration|configuration|process_change|policy_update|contract_revision|design_change|content_update|documentation",
229
229
  "priority": "low|medium|high|critical",
230
230
  "status": "actionable|pending_decision|blocked|completed",
231
231
  "dependencies": ["string (IDs of other change requests this depends on)"],
@@ -316,7 +316,7 @@
316
316
  "blockers": [
317
317
  {
318
318
  "id": "string (BLK-1, BLK-2, or matching ID from tracking docs like DB-1, LEGAL-1, INFRA-1)",
319
- "type": "database_prerequisite|pending_decision|dba_work|external_dependency|testing|deployment|legal_review|budget_approval|vendor_dependency|regulatory|access_permission|resource_constraint",
319
+ "type": "database_prerequisite|pending_decision|dba_work|external_dependency|testing|deployment|legal_review|budget_approval|vendor_dependency|regulatory|access_permission|resource_constraint|backend_work",
320
320
  "description": "string",
321
321
  "owner": "string (who needs to resolve this — person, team, department, external party, etc.)",
322
322
  "blocks": ["string (work-item IDs or action items blocked by this)"],
package/src/logger.js CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
+ const { strip: stripAnsi } = require('./utils/colors');
16
17
 
17
18
  const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
18
19
 
@@ -85,12 +86,12 @@ class Logger {
85
86
 
86
87
  _writeDetailed(line) {
87
88
  if (this.closed) return;
88
- this._detailedBuffer.push(line + '\n');
89
+ this._detailedBuffer.push(stripAnsi(line) + '\n');
89
90
  }
90
91
 
91
92
  _writeMinimal(line) {
92
93
  if (this.closed) return;
93
- this._minimalBuffer.push(line + '\n');
94
+ this._minimalBuffer.push(stripAnsi(line) + '\n');
94
95
  }
95
96
 
96
97
  _writeBoth(line) {
@@ -104,6 +105,8 @@ class Logger {
104
105
  */
105
106
  _writeStructured(entry) {
106
107
  if (this.closed) return;
108
+ // Strip ANSI from message fields so structured logs are clean
109
+ if (entry.message) entry.message = stripAnsi(entry.message);
107
110
  const enriched = {
108
111
  ...entry,
109
112
  elapsedMs: this._elapsedMs(),
@@ -117,8 +120,8 @@ class Logger {
117
120
 
118
121
  _flush(sync = false) {
119
122
  const writeFn = sync
120
- ? (p, d) => fs.appendFileSync(p, d)
121
- : (p, d) => fs.appendFile(p, d, () => {});
123
+ ? (p, d) => fs.appendFileSync(p, d, 'utf8')
124
+ : (p, d) => fs.appendFile(p, d, 'utf8', () => {});
122
125
 
123
126
  if (this._detailedBuffer.length > 0) {
124
127
  const data = this._detailedBuffer.join('');
@@ -23,6 +23,13 @@ const { loadHistory, analyzeHistory, printLearningInsights } = require('../utils
23
23
  // --- Shared state ---
24
24
  const { PKG_ROOT, PROJECT_ROOT, setLog, isShuttingDown, setShuttingDown } = require('./_shared');
25
25
 
26
+ /** Parse an integer flag, falling back to `defaultVal` only when the input is absent or NaN. */
27
+ function safeInt(raw, defaultVal) {
28
+ if (raw === undefined || raw === null || raw === true) return defaultVal;
29
+ const n = parseInt(raw, 10);
30
+ return Number.isNaN(n) ? defaultVal : n;
31
+ }
32
+
26
33
  // ======================== PHASE: INIT ========================
27
34
 
28
35
  /**
@@ -49,12 +56,12 @@ async function phaseInit() {
49
56
  reanalyze: !!flags.reanalyze,
50
57
  dryRun: !!flags['dry-run'],
51
58
  userName: flags.name || null,
52
- parallel: parseInt(flags.parallel, 10) || MAX_PARALLEL_UPLOADS,
59
+ parallel: safeInt(flags.parallel, MAX_PARALLEL_UPLOADS),
53
60
  logLevel: flags['log-level'] || LOG_LEVEL,
54
61
  outputDir: flags.output || null,
55
- thinkingBudget: parseInt(flags['thinking-budget'], 10) || THINKING_BUDGET,
56
- compilationThinkingBudget: parseInt(flags['compilation-thinking-budget'], 10) || COMPILATION_THINKING_BUDGET,
57
- parallelAnalysis: parseInt(flags['parallel-analysis'], 10) || 2, // concurrent segment analysis
62
+ thinkingBudget: safeInt(flags['thinking-budget'], THINKING_BUDGET),
63
+ compilationThinkingBudget: safeInt(flags['compilation-thinking-budget'], COMPILATION_THINKING_BUDGET),
64
+ parallelAnalysis: safeInt(flags['parallel-analysis'], 2), // concurrent segment analysis
58
65
  disableFocusedPass: !!flags['no-focused-pass'],
59
66
  disableLearning: !!flags['no-learning'],
60
67
  disableDiff: !!flags['no-diff'],
@@ -79,11 +86,19 @@ async function phaseInit() {
79
86
  opts.minConfidence = check.normalised.toLowerCase();
80
87
  }
81
88
 
82
- // --- Validate --format flag ---
89
+ // --- Validate --format flag (supports comma-separated: md,html,pdf) ---
83
90
  const VALID_FORMATS = new Set(['md', 'html', 'json', 'pdf', 'docx', 'all']);
84
- if (!VALID_FORMATS.has(opts.format)) {
85
- throw new Error(`Invalid --format "${opts.format}". Must be: md, html, json, pdf, docx, or all`);
91
+ const requestedFormats = opts.format.split(',').map(f => f.trim()).filter(Boolean);
92
+ const invalidFormats = requestedFormats.filter(f => !VALID_FORMATS.has(f));
93
+ if (invalidFormats.length > 0) {
94
+ throw new Error(`Invalid --format "${invalidFormats.join(', ')}". Valid: md, html, json, pdf, docx, all`);
86
95
  }
96
+ // Normalise: "all" or set of specific formats
97
+ opts.formats = requestedFormats.includes('all')
98
+ ? new Set(['md', 'html', 'json', 'pdf', 'docx'])
99
+ : new Set(requestedFormats);
100
+ // Keep opts.format as the original string for backwards compatibility
101
+ opts.format = requestedFormats.includes('all') ? 'all' : requestedFormats.join(',');
87
102
 
88
103
  // --- Resolve folder: positional arg or interactive selection ---
89
104
  let folderArg = positional[0];
@@ -25,6 +25,8 @@ const { getLog, phaseTimer, PROJECT_ROOT } = require('./_shared');
25
25
 
26
26
  /** Check whether a given output type should be rendered. */
27
27
  function shouldRender(opts, type) {
28
+ if (opts.formats) return opts.formats.has(type);
29
+ // Fallback for legacy callers
28
30
  if (opts.format === 'all') return true;
29
31
  return opts.format === type;
30
32
  }
@@ -59,14 +61,11 @@ async function phaseOutput(ctx, results, compiledAnalysis, compilationRun, compi
59
61
  // Attach cost summary to results
60
62
  results.costSummary = costTracker.getSummary();
61
63
 
62
- // Write results JSON (always written unless --format excludes it)
64
+ // Write results JSON (always written; logged only when JSON format is requested)
63
65
  const jsonPath = path.join(runDir, 'results.json');
64
- if (shouldRender(opts, 'json') || opts.format === 'all') {
65
- fs.writeFileSync(jsonPath, JSON.stringify(results, null, 2), 'utf8');
66
+ fs.writeFileSync(jsonPath, JSON.stringify(results, null, 2), 'utf8');
67
+ if (shouldRender(opts, 'json')) {
66
68
  log.step(`Results JSON saved → ${jsonPath}`);
67
- } else {
68
- // Still write JSON internally (needed for uploads/diffs) but don't advertise
69
- fs.writeFileSync(jsonPath, JSON.stringify(results, null, 2), 'utf8');
70
69
  }
71
70
 
72
71
  // Generate Markdown
@@ -440,7 +440,7 @@ async function phaseProcessVideo(ctx, videoPath, videoIndex) {
440
440
 
441
441
  // === AUTO-RETRY on FAIL ===
442
442
  if (qualityReport.shouldRetry && !isShuttingDown()) {
443
- console.log(` ↻ Quality below threshold (${qualityReport.score}/${THRESHOLDS.PASS}) — retrying with enhanced hints...`);
443
+ console.log(` ↻ Quality below threshold (${qualityReport.score}/${THRESHOLDS.FAIL_BELOW}) — retrying with enhanced hints...`);
444
444
  log.step(`Quality gate FAIL for ${segName} (score: ${qualityReport.score}) — retrying`);
445
445
  retried = true;
446
446
 
@@ -64,7 +64,7 @@ async function phaseServices(ctx) {
64
64
  .map(({ absPath, relPath }) => ({
65
65
  type: 'inlineText',
66
66
  fileName: relPath,
67
- content: fs.readFileSync(absPath, 'utf8'),
67
+ content: fs.readFileSync(absPath, 'utf8').replace(/^\uFEFF/, ''),
68
68
  }));
69
69
  }
70
70
 
package/src/pipeline.js CHANGED
@@ -370,7 +370,7 @@ async function runDocOnly(ctx) {
370
370
  const jsonPath = path.join(runDir, 'results.json');
371
371
  fs.writeFileSync(jsonPath, JSON.stringify(results, null, 2), 'utf8');
372
372
 
373
- const shouldRender = (type) => opts.format === 'all' || opts.format === type;
373
+ const shouldRender = (type) => opts.formats ? opts.formats.has(type) : (opts.format === 'all' || opts.format === type);
374
374
 
375
375
  if (compiledAnalysis) {
376
376
  const mdMeta = {
@@ -641,7 +641,7 @@ async function runDynamic(initCtx) {
641
641
  const ext = path.extname(absPath).toLowerCase();
642
642
  try {
643
643
  if (INLINE_EXTS.includes(ext)) {
644
- let content = fs.readFileSync(absPath, 'utf8');
644
+ let content = fs.readFileSync(absPath, 'utf8').replace(/^\uFEFF/, '');
645
645
  if (content.length > 8000) {
646
646
  content = content.slice(0, 8000) + '\n... (truncated)';
647
647
  }
@@ -455,10 +455,10 @@ function renderResultsHtml({ compiled, meta }) {
455
455
  const comments = t.comments || [];
456
456
  if (comments.length > 0) {
457
457
  ln('<h4>🗣️ Key Quotes</h4><ul>');
458
- for (const c of comments) {
459
- const speaker = c.speaker ? resolve(c.speaker, clusterMap) : 'Unknown';
460
- const ts = c.timestamp ? `<code>${e(c.timestamp)}</code> ` : '';
461
- ln(`<li>${ts}<strong>${e(speaker)}</strong>: "${e(c.text)}"</li>`);
458
+ for (const cmt of comments) {
459
+ const speaker = cmt.speaker ? resolve(cmt.speaker, clusterMap) : 'Unknown';
460
+ const ts = cmt.timestamp ? `<code>${e(cmt.timestamp)}</code> ` : '';
461
+ ln(`<li>${ts}<strong>${e(speaker)}</strong>: "${e(cmt.text)}"</li>`);
462
462
  }
463
463
  ln('</ul>');
464
464
  }
@@ -54,7 +54,10 @@ function clusterNames(rawNames) {
54
54
 
55
55
  let merged = false;
56
56
  for (const [existNk, c] of normToCluster) {
57
- if (existNk.includes(nk) || nk.includes(existNk)) {
57
+ // Only merge via substring if the shorter name is at least 5 chars
58
+ // to prevent false merges (e.g. "Ed" matching "Jeddah Dev")
59
+ const shorter = nk.length < existNk.length ? nk : existNk;
60
+ if (shorter.length >= 5 && (existNk.includes(nk) || nk.includes(existNk))) {
58
61
  c.variants.add(raw);
59
62
  normToCluster.set(nk, c);
60
63
  if (stripped.length >= c.canonical.length && stripped[0] === stripped[0].toUpperCase()) {
@@ -92,7 +95,8 @@ function resolve(name, clusterMap) {
92
95
  if (normalizeKey(v) === nk) return canonical;
93
96
  }
94
97
  const cnk = normalizeKey(canonical);
95
- if (cnk.includes(nk) || nk.includes(cnk)) return canonical;
98
+ const shorter = nk.length < cnk.length ? nk : cnk;
99
+ if (shorter.length >= 5 && (cnk.includes(nk) || nk.includes(cnk))) return canonical;
96
100
  }
97
101
  return stripParens(name).trim() || name;
98
102
  }
@@ -234,7 +234,7 @@ function stripHtml(html) {
234
234
  */
235
235
  async function parseBuiltinText(filePath) {
236
236
  try {
237
- const content = await fs.promises.readFile(filePath, 'utf8');
237
+ const content = (await fs.promises.readFile(filePath, 'utf8')).replace(/^\uFEFF/, '');
238
238
  return { text: content.trim(), warnings: [] };
239
239
  } catch (err) {
240
240
  return { text: '', warnings: [`Failed to read file: ${err.message}`] };
@@ -248,7 +248,7 @@ async function parseBuiltinText(filePath) {
248
248
  */
249
249
  async function parseHtmlFile(filePath) {
250
250
  try {
251
- const html = await fs.promises.readFile(filePath, 'utf8');
251
+ const html = (await fs.promises.readFile(filePath, 'utf8')).replace(/^\uFEFF/, '');
252
252
  const text = stripHtml(html);
253
253
  return { text, warnings: [] };
254
254
  } catch (err) {
@@ -61,7 +61,7 @@ async function prepareDocsForGemini(ai, docFileList) {
61
61
  try {
62
62
  if (INLINE_TEXT_EXTS.includes(ext)) {
63
63
  console.log(` Reading ${name} (inline text)...`);
64
- const content = await fs.promises.readFile(docPath, 'utf8');
64
+ const content = (await fs.promises.readFile(docPath, 'utf8')).replace(/^\uFEFF/, '');
65
65
  prepared.push({ type: 'inlineText', fileName: name, content });
66
66
  console.log(` ${c.success(`${name} ready (${(content.length / 1024).toFixed(1)} KB)`)}`);
67
67
  } else if (DOC_PARSER_EXTS.includes(ext)) {
@@ -442,6 +442,22 @@ async function processWithGemini(ai, filePath, displayName, contextDocs = [], pr
442
442
  console.error(` ${c.error(`File API fallback also failed: ${fallbackErr.message}`)}`);
443
443
  throw fallbackErr;
444
444
  }
445
+ } else if (!usedExternalUrl && errMsg.includes('INVALID_ARGUMENT')) {
446
+ // File API upload was used but still got INVALID_ARGUMENT — re-upload fresh and retry once
447
+ console.log(` ${c.warn('INVALID_ARGUMENT with File API — re-uploading and retrying...')}`);
448
+ try {
449
+ file = await uploadViaFileApi();
450
+ contentParts[0] = { fileData: { mimeType: file.mimeType, fileUri: file.uri } };
451
+ requestPayload.contents[0].parts = contentParts;
452
+ response = await withRetry(
453
+ () => ai.models.generateContent(requestPayload),
454
+ { label: `Gemini segment analysis — re-upload retry (${displayName})`, maxRetries: 1, baseDelay: 5000 }
455
+ );
456
+ console.log(` ${c.success('Re-upload retry succeeded')}`);
457
+ } catch (reuploadErr) {
458
+ console.error(` ${c.error(`Re-upload retry also failed: ${reuploadErr.message}`)}`);
459
+ throw reuploadErr;
460
+ }
445
461
  } else {
446
462
  // Log request diagnostics for other errors to aid debugging
447
463
  const partSummary = contentParts.map((p, i) => {
package/src/utils/cli.js CHANGED
@@ -246,7 +246,7 @@ async function selectModel(GEMINI_MODELS, currentModel) {
246
246
  const indexMap = {}; // index → modelId
247
247
  for (const id of modelIds) {
248
248
  const m = GEMINI_MODELS[id];
249
- const tier = tiers[m.tier] || tiers.fast;
249
+ const tier = tiers[m.tier] || tiers.economy;
250
250
  idx++;
251
251
  indexMap[idx] = id;
252
252
  tier.models.push({ idx, id, ...m });
@@ -370,7 +370,7 @@ ${f('--deep-dive', 'Generate explanatory docs per topic')}
370
370
  ${h('CORE OPTIONS')}
371
371
  ${f('--name <name>', 'Your name (skip interactive prompt)')}
372
372
  ${f('--model <id>', 'Gemini model (skip interactive selector)')}
373
- ${f('--format <type>', 'Output formats: md, html, json, pdf, docx, all (default: all)')}
373
+ ${f('--format <type>', 'Output: md, html, json, pdf, docx, all — comma-separated (default: all)')}
374
374
  ${f('--min-confidence <level>', 'Filter: high, medium, low (default: all)')}
375
375
  ${f('--output <dir>', 'Custom output directory for results')}
376
376
  ${f('--skip-upload', 'Skip Firebase Storage uploads')}
@@ -424,6 +424,7 @@ ${f('--version, -v', 'Show version')}
424
424
  ${c.dim('$')} taskex --dynamic --request "Plan API migration" "specs"
425
425
  ${c.dim('$')} taskex --min-confidence medium "call 1" ${c.dim('# Filter low-confidence')}
426
426
  ${c.dim('$')} taskex --format md "call 1" ${c.dim('# Markdown only')}
427
+ ${c.dim('$')} taskex --format md,html,pdf "call 1" ${c.dim('# Multiple formats')}
427
428
  ${c.dim('$')} taskex --format pdf "call 1" ${c.dim('# PDF report')}
428
429
  ${c.dim('$')} taskex --format docx "call 1" ${c.dim('# Word document')}
429
430
  ${c.dim('$')} taskex --resume "call 1" ${c.dim('# Resume interrupted run')}
@@ -103,7 +103,8 @@ function filterByConfidence(compiled, minLevel = 'LOW') {
103
103
  tasks_todo: filterArr(compiled.your_tasks.tasks_todo),
104
104
  tasks_waiting_on_others: filterArr(compiled.your_tasks.tasks_waiting_on_others),
105
105
  decisions_needed: filterArr(compiled.your_tasks.decisions_needed),
106
- completed_in_call: filterArr(compiled.your_tasks.completed_in_call),
106
+ // completed_in_call items are plain strings (no confidence field) — preserve unconditionally
107
+ completed_in_call: compiled.your_tasks.completed_in_call || [],
107
108
  };
108
109
  }
109
110
 
@@ -19,10 +19,10 @@ const { c } = require('./colors');
19
19
  // ======================== QUALITY THRESHOLDS ========================
20
20
 
21
21
  const THRESHOLDS = {
22
- /** Minimum score to PASS without retry (0-100) */
23
- PASS: 45,
24
- /** Score range for WARNING will pass but flag issues (45-65 is typical) */
25
- WARN: 65,
22
+ /** Minimum score to avoid FAIL. Below this → FAIL + retry (0-100) */
23
+ FAIL_BELOW: 45,
24
+ /** Minimum score for a clean PASS. Between FAIL_BELOW and PASS_ABOVE → WARN (45-65 is typical) */
25
+ PASS_ABOVE: 65,
26
26
  /** Maximum retries per segment */
27
27
  MAX_RETRIES: 1,
28
28
  };
@@ -301,9 +301,9 @@ function assessQuality(analysis, context = {}) {
301
301
  ];
302
302
 
303
303
  let grade;
304
- if (compositeScore >= THRESHOLDS.WARN) {
304
+ if (compositeScore >= THRESHOLDS.PASS_ABOVE) {
305
305
  grade = 'PASS';
306
- } else if (compositeScore >= THRESHOLDS.PASS) {
306
+ } else if (compositeScore >= THRESHOLDS.FAIL_BELOW) {
307
307
  grade = 'WARN';
308
308
  } else {
309
309
  grade = 'FAIL';