task-summary-extractor 9.2.2 → 9.4.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.
@@ -90,7 +90,7 @@ async function prepareDocsForGemini(ai, docFileList) {
90
90
  const pollStart = Date.now();
91
91
  while (file.state === 'PROCESSING') {
92
92
  if (Date.now() - pollStart > GEMINI_POLL_TIMEOUT_MS) {
93
- console.warn(` ${c.warn(`${name} — polling timed out after ${(GEMINI_POLL_TIMEOUT_MS / 1000).toFixed(0)}s, skipping`)}`);
93
+ console.warn(` ${c.warn(`${name} — file is still processing after ${(GEMINI_POLL_TIMEOUT_MS / 1000).toFixed(0)}s, skipping (you can increase the wait time with GEMINI_POLL_TIMEOUT_MS in .env)`)}`);
94
94
  file = null;
95
95
  break;
96
96
  }
@@ -287,7 +287,7 @@ async function processWithGemini(ai, filePath, displayName, contextDocs = [], pr
287
287
  const pollStart = Date.now();
288
288
  while (uploaded.state === 'PROCESSING') {
289
289
  if (Date.now() - pollStart > GEMINI_POLL_TIMEOUT_MS) {
290
- throw new Error(`Gemini file processing timed out after ${(GEMINI_POLL_TIMEOUT_MS / 1000).toFixed(0)}s for ${displayName}. Try again or increase GEMINI_POLL_TIMEOUT_MS.`);
290
+ throw new Error(`File "${displayName}" is still processing after ${(GEMINI_POLL_TIMEOUT_MS / 1000).toFixed(0)}s. Try again or increase the wait time by setting GEMINI_POLL_TIMEOUT_MS in your .env file.`);
291
291
  }
292
292
  process.stdout.write(` Processing${'.'.repeat((waited % 3) + 1)} \r`);
293
293
  await new Promise(r => setTimeout(r, 5000));
@@ -343,7 +343,7 @@ async function processWithGemini(ai, filePath, displayName, contextDocs = [], pr
343
343
  buildProgressiveContext(previousAnalyses, userName) || ''
344
344
  );
345
345
  const docBudget = Math.max(100000, config.GEMINI_CONTEXT_WINDOW - 350000 - prevContextEstimate);
346
- console.log(` Context budget: ${(docBudget / 1000).toFixed(0)}K tokens for docs (${contextDocs.length} available)`);
346
+ console.log(` Reference docs budget: ${(docBudget / 1000).toFixed(0)}K (${contextDocs.length} doc${contextDocs.length !== 1 ? 's' : ''} available)`);
347
347
 
348
348
  const { selected: selectedDocs, excluded, stats } = selectDocsByBudget(
349
349
  contextDocs, docBudget, { segmentIndex }
@@ -459,16 +459,53 @@ async function processWithGemini(ai, filePath, displayName, contextDocs = [], pr
459
459
  throw reuploadErr;
460
460
  }
461
461
  } else {
462
- // Log request diagnostics for other errors to aid debugging
463
- const partSummary = contentParts.map((p, i) => {
464
- if (p.fileData) return ` [${i}] fileData: ${p.fileData.mimeType} ${(p.fileData.fileUri || '').substring(0, 120)}`;
465
- if (p.text) return ` [${i}] text: ${p.text.length} chars → ${p.text.substring(0, 80).replace(/\n/g, ' ')}...`;
466
- return ` [${i}] unknown part`;
467
- });
468
- console.error(` ${c.error('Request diagnostics:')}`);
469
- console.error(` Model: ${config.GEMINI_MODEL} | Parts: ${contentParts.length} | maxOutput: 65536`);
470
- partSummary.forEach(s => console.error(` ${s}`));
471
- throw apiErr;
462
+ // Handle RESOURCE_EXHAUSTED specifically shed lower-priority docs and retry
463
+ if (errMsg.includes('RESOURCE_EXHAUSTED') || errMsg.includes('429') || errMsg.includes('quota')) {
464
+ console.warn(` ${c.warn('Context window or quota exceeded shedding docs and retrying after 30s...')}`);
465
+ await new Promise(r => setTimeout(r, 30000));
466
+ // Rebuild with half the doc budget
467
+ const reducedBudget = Math.floor(docBudget * 0.5);
468
+ const { selected: reducedDocs } = selectDocsByBudget(contextDocs, reducedBudget, { segmentIndex });
469
+ const reducedParts = [contentParts[0]]; // keep video
470
+ for (const doc of reducedDocs) {
471
+ if (doc.type === 'inlineText') {
472
+ let content = doc.content;
473
+ const isVtt = doc.fileName.toLowerCase().endsWith('.vtt') || doc.fileName.toLowerCase().endsWith('.srt');
474
+ if (isVtt && segmentStartSec != null && segmentEndSec != null) {
475
+ content = sliceVttForSegment(content, segmentStartSec, segmentEndSec);
476
+ }
477
+ reducedParts.push({ text: `=== Document: ${doc.fileName} ===\n${content}` });
478
+ } else if (doc.type === 'fileData') {
479
+ reducedParts.push({ fileData: { mimeType: doc.mimeType, fileUri: doc.fileUri } });
480
+ }
481
+ }
482
+ // Re-add prompt/context parts (last 3-5 parts are prompt, focus, etc.)
483
+ const nonDocParts = contentParts.slice(1 + selectedDocs.length);
484
+ reducedParts.push(...nonDocParts);
485
+ requestPayload.contents[0].parts = reducedParts;
486
+ console.log(` Reduced to ${reducedDocs.length} docs (budget: ${(reducedBudget / 1000).toFixed(0)}K tokens)`);
487
+ try {
488
+ response = await withRetry(
489
+ () => ai.models.generateContent(requestPayload),
490
+ { label: `Gemini segment analysis — reduced docs (${displayName})`, maxRetries: 1, baseDelay: 5000 }
491
+ );
492
+ console.log(` ${c.success('Reduced-context retry succeeded')}`);
493
+ } catch (reduceErr) {
494
+ console.error(` ${c.error(`Reduced-context retry also failed: ${reduceErr.message}`)}`);
495
+ throw reduceErr;
496
+ }
497
+ } else {
498
+ // Log request diagnostics for other errors to aid debugging
499
+ const partSummary = contentParts.map((p, i) => {
500
+ if (p.fileData) return ` [${i}] fileData: ${p.fileData.mimeType} → ${(p.fileData.fileUri || '').substring(0, 120)}`;
501
+ if (p.text) return ` [${i}] text: ${p.text.length} chars → ${p.text.substring(0, 80).replace(/\n/g, ' ')}...`;
502
+ return ` [${i}] unknown part`;
503
+ });
504
+ console.error(` ${c.error('Request diagnostics:')}`);
505
+ console.error(` Model: ${config.GEMINI_MODEL} | Parts: ${contentParts.length} | maxOutput: 65536`);
506
+ partSummary.forEach(s => console.error(` ${s}`));
507
+ throw apiErr;
508
+ }
472
509
  }
473
510
  }
474
511
  const durationMs = Date.now() - t0;
@@ -628,6 +665,60 @@ ${segmentDumps}`;
628
665
 
629
666
  const contentParts = [{ text: compilationPrompt }];
630
667
 
668
+ // ------- Pre-flight context window check -------
669
+ const estimatedInputTokens = estimateTokens(compilationPrompt);
670
+ const safeLimit = Math.floor(config.GEMINI_CONTEXT_WINDOW * 0.80); // 80% of context window
671
+ if (estimatedInputTokens > safeLimit) {
672
+ console.warn(` ${c.warn(`Compilation input (~${(estimatedInputTokens / 1000).toFixed(0)}K tokens) exceeds 80% of context window (${(safeLimit / 1000).toFixed(0)}K). Trimming older segment detail...`)}`);
673
+ // Re-build segment dumps with aggressive compression: keep only first & last 2 segments
674
+ // at full detail, compress the middle ones to IDs + statuses only.
675
+ const trimmedDumps = allSegmentAnalyses.map((analysis, idx) => {
676
+ const clean = { ...analysis };
677
+ delete clean._geminiMeta;
678
+ delete clean.seg;
679
+ delete clean.conversation_transcript;
680
+ const isEdge = idx < 2 || idx >= allSegmentAnalyses.length - 2;
681
+ if (!isEdge) {
682
+ // Aggressive compression for middle segments
683
+ if (clean.tickets) {
684
+ clean.tickets = clean.tickets.map(t => ({
685
+ ticket_id: t.ticket_id, status: t.status, title: t.title,
686
+ assignee: t.assignee, source_segment: t.source_segment,
687
+ }));
688
+ }
689
+ if (clean.change_requests) {
690
+ clean.change_requests = clean.change_requests.map(cr => ({
691
+ id: cr.id, status: cr.status, title: cr.title,
692
+ assigned_to: cr.assigned_to, source_segment: cr.source_segment,
693
+ }));
694
+ }
695
+ if (clean.action_items) {
696
+ clean.action_items = clean.action_items.map(ai => ({
697
+ id: ai.id, description: ai.description, assigned_to: ai.assigned_to,
698
+ status: ai.status, source_segment: ai.source_segment,
699
+ }));
700
+ }
701
+ delete clean.file_references;
702
+ clean.summary = (clean.summary || '').substring(0, 200);
703
+ } else {
704
+ if (clean.tickets) {
705
+ clean.tickets = clean.tickets.map(t => {
706
+ const tc = { ...t };
707
+ if (tc.comments && tc.comments.length > 5) {
708
+ tc.comments = tc.comments.slice(0, 5);
709
+ tc.comments.push({ note: `...${t.comments.length - 5} more comments omitted` });
710
+ }
711
+ return tc;
712
+ });
713
+ }
714
+ }
715
+ return `=== SEGMENT ${idx + 1} OF ${allSegmentAnalyses.length} ===\n${JSON.stringify(clean, null, 2)}`;
716
+ }).join('\n\n');
717
+ contentParts[0] = { text: compilationPrompt.replace(segmentDumps, trimmedDumps) };
718
+ const newEstimate = estimateTokens(contentParts[0].text);
719
+ console.log(` Trimmed compilation input to ~${(newEstimate / 1000).toFixed(0)}K tokens`);
720
+ }
721
+
631
722
  const requestPayload = {
632
723
  model: config.GEMINI_MODEL,
633
724
  contents: [{ role: 'user', parts: contentParts }],
@@ -640,10 +731,44 @@ ${segmentDumps}`;
640
731
 
641
732
  const t0 = Date.now();
642
733
  console.log(` Compiling with ${config.GEMINI_MODEL}...`);
643
- const response = await withRetry(
644
- () => ai.models.generateContent(requestPayload),
645
- { label: 'Gemini final compilation', maxRetries: 2, baseDelay: 5000 }
646
- );
734
+ let response;
735
+ try {
736
+ response = await withRetry(
737
+ () => ai.models.generateContent(requestPayload),
738
+ { label: 'Gemini final compilation', maxRetries: 2, baseDelay: 5000 }
739
+ );
740
+ } catch (compileErr) {
741
+ const errMsg = compileErr.message || '';
742
+ if (errMsg.includes('RESOURCE_EXHAUSTED') || errMsg.includes('429') || errMsg.includes('quota')) {
743
+ console.warn(` ${c.warn('Context window or quota exceeded during compilation — waiting 30s and retrying with reduced input...')}`);
744
+ await new Promise(r => setTimeout(r, 30000));
745
+ // Halve the compilation prompt by keeping only edge segments
746
+ const miniDumps = allSegmentAnalyses.map((analysis, idx) => {
747
+ const clean = { tickets: (analysis.tickets || []).map(t => ({ ticket_id: t.ticket_id, status: t.status, title: t.title, assignee: t.assignee })),
748
+ change_requests: (analysis.change_requests || []).map(cr => ({ id: cr.id, status: cr.status, title: cr.title })),
749
+ action_items: (analysis.action_items || []).map(ai => ({ id: ai.id, description: ai.description, assigned_to: ai.assigned_to, status: ai.status })),
750
+ blockers: (analysis.blockers || []).map(b => ({ id: b.id, description: b.description, status: b.status })),
751
+ scope_changes: analysis.scope_changes || [],
752
+ your_tasks: analysis.your_tasks || {},
753
+ summary: (analysis.summary || '').substring(0, 300),
754
+ };
755
+ return `=== SEGMENT ${idx + 1} OF ${allSegmentAnalyses.length} ===\n${JSON.stringify(clean, null, 2)}`;
756
+ }).join('\n\n');
757
+ requestPayload.contents[0].parts = [{ text: compilationPrompt.replace(/SEGMENT ANALYSES:\n[\s\S]*$/, `SEGMENT ANALYSES:\n${miniDumps}`) }];
758
+ try {
759
+ response = await withRetry(
760
+ () => ai.models.generateContent(requestPayload),
761
+ { label: 'Gemini compilation (reduced)', maxRetries: 1, baseDelay: 5000 }
762
+ );
763
+ console.log(` ${c.success('Reduced compilation succeeded')}`);
764
+ } catch (reduceErr) {
765
+ console.error(` ${c.error(`Reduced compilation also failed: ${reduceErr.message}`)}`);
766
+ throw reduceErr;
767
+ }
768
+ } else {
769
+ throw compileErr;
770
+ }
771
+ }
647
772
  const durationMs = Date.now() - t0;
648
773
  const rawText = response.text;
649
774
 
package/src/utils/cli.js CHANGED
@@ -35,7 +35,7 @@ function parseArgs(argv) {
35
35
  'skip-upload', 'force-upload', 'no-storage-url',
36
36
  'skip-compression', 'skip-gemini',
37
37
  'resume', 'reanalyze', 'dry-run',
38
- 'dynamic', 'deep-dive', 'update-progress',
38
+ 'dynamic', 'deep-dive', 'deep-summary', 'update-progress',
39
39
  'no-focused-pass', 'no-learning', 'no-diff',
40
40
  'no-html',
41
41
  ]);
@@ -371,6 +371,8 @@ ${f('(default)', 'Video/audio analysis — compress, analyze, compile')}
371
371
  ${f('--dynamic', 'Document generation — no media required')}
372
372
  ${f('--update-progress', 'Track item completion via git changes')}
373
373
  ${f('--deep-dive', 'Generate explanatory docs per topic')}
374
+ ${f('--deep-summary', 'Pre-summarize context docs (saves ~60-80% input tokens)')}
375
+ ${f('--exclude-docs <list>', 'Comma-separated doc names to keep full (use with --deep-summary)')}
374
376
 
375
377
  ${h('CORE OPTIONS')}
376
378
  ${f('--name <name>', 'Your name (skip interactive prompt)')}
@@ -435,6 +437,8 @@ ${f('--version, -v', 'Show version')}
435
437
  ${c.dim('$')} taskex --format pdf "call 1" ${c.dim('# PDF report')}
436
438
  ${c.dim('$')} taskex --format docx "call 1" ${c.dim('# Word document')}
437
439
  ${c.dim('$')} taskex --resume "call 1" ${c.dim('# Resume interrupted run')}
440
+ ${c.dim('$')} taskex --deep-summary "call 1" ${c.dim('# Pre-summarize docs, save tokens')}
441
+ ${c.dim('$')} taskex --deep-summary --exclude-docs "board.md,spec.md" "call 1" ${c.dim('# Keep specific docs full')}
438
442
  ${c.dim('$')} taskex --update-progress --repo ./my-project "call 1"
439
443
  `);
440
444
  // Signal early exit — pipeline checks for help flag before calling this
@@ -444,6 +448,7 @@ ${f('--version, -v', 'Show version')}
444
448
  module.exports = {
445
449
  parseArgs, showHelp, discoverFolders, selectFolder, selectModel,
446
450
  promptUser, promptUserText, selectRunMode, selectFormats, selectConfidence,
451
+ selectDocsToExclude,
447
452
  };
448
453
 
449
454
  // ======================== INTERACTIVE PROMPTS ========================
@@ -527,6 +532,9 @@ const RUN_PRESETS = {
527
532
  },
528
533
  };
529
534
 
535
+ // Attach RUN_PRESETS to exports (defined after module.exports due to const ordering)
536
+ module.exports.RUN_PRESETS = RUN_PRESETS;
537
+
530
538
  /**
531
539
  * Interactive run-mode selector. Shows preset options and returns the chosen
532
540
  * preset key. The caller applies overrides to opts.
@@ -721,3 +729,83 @@ async function selectConfidence() {
721
729
  });
722
730
  });
723
731
  }
732
+
733
+ // ======================== DEEP SUMMARY DOC EXCLUSION PICKER ========================
734
+
735
+ /**
736
+ * Interactive picker: let user select documents to EXCLUDE from deep summary.
737
+ * Excluded docs stay at full fidelity; the summary pass focuses on their topics.
738
+ *
739
+ * @param {Array<{fileName: string, type: string, content?: string}>} contextDocs - Prepared docs
740
+ * @returns {Promise<string[]>} Array of excluded fileName strings
741
+ */
742
+ async function selectDocsToExclude(contextDocs) {
743
+ const readline = require('readline');
744
+
745
+ // Only show inlineText docs with actual content
746
+ const eligible = contextDocs
747
+ .filter(d => d.type === 'inlineText' && d.content && d.content.length > 0)
748
+ .map(d => ({
749
+ fileName: d.fileName,
750
+ chars: d.content.length,
751
+ tokensEst: Math.ceil(d.content.length * 0.3),
752
+ }));
753
+
754
+ if (eligible.length === 0) return [];
755
+
756
+ console.log('');
757
+ console.log(` ${c.bold('📋 Deep Summary — Choose What to Keep in Full')}`);
758
+ console.log(c.dim(' ' + '─'.repeat(60)));
759
+ console.log('');
760
+ console.log(` ${c.dim('To save processing time, we can create short summaries of your')}`);
761
+ console.log(` ${c.dim('reference documents. The AI will still read them — just faster.')}`);
762
+ console.log('');
763
+ console.log(` ${c.bold('If a document is especially important to you, select it below')}`);
764
+ console.log(` ${c.bold('to keep it in full.')} The rest will be smartly condensed.`);
765
+ console.log('');
766
+
767
+ eligible.forEach((d, i) => {
768
+ const num = c.cyan(`[${i + 1}]`);
769
+ const size = d.tokensEst >= 1000
770
+ ? c.dim(`~${(d.tokensEst / 1000).toFixed(0)}K words`)
771
+ : c.dim(`~${d.tokensEst} words`);
772
+ console.log(` ${num} ${c.bold(d.fileName)} ${size}`);
773
+ });
774
+ console.log('');
775
+ console.log(c.dim(' Tip: Enter = condense all · Type numbers to keep full (e.g. 1,3)'));
776
+ console.log('');
777
+
778
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
779
+ return new Promise(resolve => {
780
+ rl.question(' Keep full (e.g. 1,3 or Enter = condense all): ', answer => {
781
+ rl.close();
782
+ const trimmed = (answer || '').trim();
783
+
784
+ if (!trimmed) {
785
+ console.log(c.success('Got it — all documents will be condensed for faster processing'));
786
+ resolve([]);
787
+ return;
788
+ }
789
+
790
+ const parts = trimmed.split(/[\s,]+/).filter(Boolean);
791
+ const excluded = [];
792
+
793
+ for (const p of parts) {
794
+ const num = parseInt(p, 10);
795
+ if (num >= 1 && num <= eligible.length) {
796
+ excluded.push(eligible[num - 1].fileName);
797
+ }
798
+ }
799
+
800
+ if (excluded.length === 0) {
801
+ console.log(c.warn('No valid selections — condensing all documents'));
802
+ resolve([]);
803
+ return;
804
+ }
805
+
806
+ console.log(c.success(`Keeping ${excluded.length} doc(s) in full — the rest will be condensed:`));
807
+ excluded.forEach(f => console.log(` ${c.dim('•')} ${c.cyan(f)}`));
808
+ resolve(excluded);
809
+ });
810
+ });
811
+ }
@@ -29,6 +29,14 @@ function estimateDocTokens(doc) {
29
29
  return 500;
30
30
  }
31
31
 
32
+ /**
33
+ * Hard character limit for VTT fallback.
34
+ * When VTT parsing fails (0 cues), the full VTT is returned.
35
+ * Cap it so a huge transcript can't blow the context window.
36
+ * 500K chars ≈ 150K tokens — leaves plenty of room for docs + prompt.
37
+ */
38
+ const VTT_FALLBACK_MAX_CHARS = 500000;
39
+
32
40
  // ════════════════════════════════════════════════════════════
33
41
  // Priority Classification
34
42
  // ════════════════════════════════════════════════════════════
@@ -100,12 +108,16 @@ function selectDocsByBudget(allDocs, tokenBudget, opts = {}) {
100
108
  const excluded = [];
101
109
  let usedTokens = 0;
102
110
 
111
+ // Hard cap: even P0/P1 docs may not exceed 2× the budget.
112
+ // This prevents a handful of huge critical docs from blowing the context window.
113
+ const hardCap = tokenBudget * 2;
114
+
103
115
  for (const item of classified) {
104
116
  if (usedTokens + item.tokens <= tokenBudget) {
105
117
  selected.push(item.doc);
106
118
  usedTokens += item.tokens;
107
- } else if (item.priority <= PRIORITY.HIGH) {
108
- // P0 and P1 are always included even if over budget
119
+ } else if (item.priority <= PRIORITY.HIGH && usedTokens + item.tokens <= hardCap) {
120
+ // P0 and P1 are always included even if over budget, up to the hard cap
109
121
  selected.push(item.doc);
110
122
  usedTokens += item.tokens;
111
123
  } else {
@@ -171,14 +183,28 @@ function parseVttCues(vttContent) {
171
183
  */
172
184
  function sliceVttForSegment(vttContent, segStartSec, segEndSec, overlapSec = 30) {
173
185
  const cues = parseVttCues(vttContent);
174
- if (cues.length === 0) return vttContent; // fallback: return full VTT
186
+ if (cues.length === 0) {
187
+ // Fallback: return full VTT but cap size to avoid context window overflow
188
+ if (vttContent.length > VTT_FALLBACK_MAX_CHARS) {
189
+ return vttContent.substring(0, VTT_FALLBACK_MAX_CHARS) +
190
+ `\n\n[TRUNCATED — original VTT was ${(vttContent.length / 1024).toFixed(0)} KB; capped at ${(VTT_FALLBACK_MAX_CHARS / 1024).toFixed(0)} KB]`;
191
+ }
192
+ return vttContent;
193
+ }
175
194
 
176
195
  const rangeStart = Math.max(0, segStartSec - overlapSec);
177
196
  const rangeEnd = segEndSec + overlapSec;
178
197
 
179
198
  const filtered = cues.filter(c => c.endSec >= rangeStart && c.startSec <= rangeEnd);
180
199
 
181
- if (filtered.length === 0) return vttContent; // fallback
200
+ if (filtered.length === 0) {
201
+ // Fallback with cap
202
+ if (vttContent.length > VTT_FALLBACK_MAX_CHARS) {
203
+ return vttContent.substring(0, VTT_FALLBACK_MAX_CHARS) +
204
+ `\n\n[TRUNCATED — original VTT was ${(vttContent.length / 1024).toFixed(0)} KB; capped at ${(VTT_FALLBACK_MAX_CHARS / 1024).toFixed(0)} KB]`;
205
+ }
206
+ return vttContent;
207
+ }
182
208
 
183
209
  const header = `WEBVTT\n\n[Segment transcript: ${formatHMS(segStartSec)} — ${formatHMS(segEndSec)}]\n[Showing cues from ${formatHMS(rangeStart)} to ${formatHMS(rangeEnd)} with ${overlapSec}s overlap]\n`;
184
210
 
@@ -492,4 +518,5 @@ module.exports = {
492
518
  buildProgressiveContext,
493
519
  buildSegmentFocus,
494
520
  detectBoundaryContext,
521
+ VTT_FALLBACK_MAX_CHARS,
495
522
  };