task-summary-extractor 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ /**
2
+ * CLI argument parser — simple, zero-dependency flag parser.
3
+ *
4
+ * Supports:
5
+ * --flag Boolean flag
6
+ * --key=value Key-value pairs
7
+ * --key value Key-value (next arg)
8
+ * positional args Collected separately
9
+ *
10
+ * Also includes interactive folder selection for when no folder arg is provided.
11
+ *
12
+ * Usage:
13
+ * const { flags, positional } = parseArgs(process.argv.slice(2));
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ /**
22
+ * Parse command-line arguments into flags and positional args.
23
+ *
24
+ * @param {string[]} argv - Arguments (typically process.argv.slice(2))
25
+ * @returns {{ flags: object, positional: string[] }}
26
+ */
27
+ function parseArgs(argv) {
28
+ const flags = {};
29
+ const positional = [];
30
+
31
+ // Boolean flags that should never consume the next argument as a value
32
+ const BOOLEAN_FLAGS = new Set([
33
+ 'help', 'h', 'version', 'v',
34
+ 'skip-upload', 'force-upload', 'no-storage-url',
35
+ 'skip-compression', 'skip-gemini',
36
+ 'resume', 'reanalyze', 'dry-run',
37
+ 'dynamic', 'deep-dive', 'update-progress',
38
+ 'no-focused-pass', 'no-learning', 'no-diff',
39
+ ]);
40
+
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const arg = argv[i];
43
+
44
+ if (arg.startsWith('--')) {
45
+ const eqIdx = arg.indexOf('=');
46
+ if (eqIdx !== -1) {
47
+ // --key=value
48
+ const key = arg.slice(2, eqIdx);
49
+ flags[key] = arg.slice(eqIdx + 1);
50
+ } else {
51
+ const key = arg.slice(2);
52
+ // Boolean flags never consume the next argument
53
+ if (BOOLEAN_FLAGS.has(key)) {
54
+ flags[key] = true;
55
+ } else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
56
+ // Value flag: consume next argument
57
+ flags[key] = argv[i + 1];
58
+ i++;
59
+ } else {
60
+ flags[key] = true;
61
+ }
62
+ }
63
+ } else if (arg.startsWith('-') && arg.length === 2) {
64
+ // Short flag: -v, -q, etc.
65
+ const key = arg.slice(1);
66
+ flags[key] = true;
67
+ } else {
68
+ positional.push(arg);
69
+ }
70
+ }
71
+
72
+ return { flags, positional };
73
+ }
74
+
75
+ // ======================== INTERACTIVE FOLDER SELECTOR ========================
76
+
77
+ /** Directories to exclude when scanning for call/project folders */
78
+ const SKIP_FOLDER_NAMES = new Set([
79
+ 'node_modules', '.git', 'src', 'logs', 'gemini_runs', 'compressed', 'runs',
80
+ ]);
81
+
82
+ /**
83
+ * Discover folders in the project root that look like call/project folders.
84
+ * A valid folder is any directory that is NOT a known infrastructure folder
85
+ * and contains at least one file (video, doc, or subdirectory with docs).
86
+ *
87
+ * @param {string} projectRoot - Root directory of the tool
88
+ * @returns {Array<{name: string, absPath: string, hasVideo: boolean, docCount: number, description: string}>}
89
+ */
90
+ function discoverFolders(projectRoot) {
91
+ const VIDEO_EXTS = new Set(['.mp4', '.mkv', '.avi', '.mov', '.webm']);
92
+ const DOC_EXTS = new Set(['.vtt', '.txt', '.pdf', '.docx', '.doc', '.srt', '.csv', '.md']);
93
+ const folders = [];
94
+
95
+ let entries;
96
+ try {
97
+ entries = fs.readdirSync(projectRoot, { withFileTypes: true });
98
+ } catch {
99
+ return folders;
100
+ }
101
+
102
+ for (const entry of entries) {
103
+ if (!entry.isDirectory()) continue;
104
+ if (entry.name.startsWith('.') || SKIP_FOLDER_NAMES.has(entry.name)) continue;
105
+
106
+ const absPath = path.join(projectRoot, entry.name);
107
+ let hasVideo = false;
108
+ let docCount = 0;
109
+ let hasRuns = false;
110
+
111
+ // Quick scan top level + one depth
112
+ const scan = (dir, depth = 0) => {
113
+ let items;
114
+ try { items = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
115
+ for (const item of items) {
116
+ if (item.isFile()) {
117
+ const ext = path.extname(item.name).toLowerCase();
118
+ if (VIDEO_EXTS.has(ext)) hasVideo = true;
119
+ if (DOC_EXTS.has(ext)) docCount++;
120
+ } else if (item.isDirectory() && depth === 0) {
121
+ if (item.name === 'runs') hasRuns = true;
122
+ if (!SKIP_FOLDER_NAMES.has(item.name) && item.name !== 'runs') {
123
+ scan(path.join(dir, item.name), depth + 1);
124
+ }
125
+ }
126
+ }
127
+ };
128
+ scan(absPath);
129
+
130
+ // Only include folders with at least some content
131
+ if (hasVideo || docCount > 0) {
132
+ const parts = [];
133
+ if (hasVideo) parts.push('video');
134
+ if (docCount > 0) parts.push(`${docCount} doc(s)`);
135
+ if (hasRuns) parts.push('has runs');
136
+ folders.push({
137
+ name: entry.name,
138
+ absPath,
139
+ hasVideo,
140
+ docCount,
141
+ hasRuns,
142
+ description: parts.join(', '),
143
+ });
144
+ }
145
+ }
146
+
147
+ return folders;
148
+ }
149
+
150
+ /**
151
+ * Interactive folder selection — shows discovered folders and lets user pick.
152
+ * Returns the selected folder name as a string, or null if cancelled.
153
+ *
154
+ * @param {string} projectRoot - Root directory
155
+ * @returns {Promise<string|null>} - Folder name or null
156
+ */
157
+ async function selectFolder(projectRoot) {
158
+ const readline = require('readline');
159
+ const folders = discoverFolders(projectRoot);
160
+
161
+ if (folders.length === 0) {
162
+ console.log('\n No call/project folders found in the current directory.');
163
+ console.log(' Create a folder with your recording or documents, then run again.\n');
164
+ return null;
165
+ }
166
+
167
+ console.log('');
168
+ console.log(' Available folders:');
169
+ console.log(' ─────────────────');
170
+ folders.forEach((f, i) => {
171
+ const icon = f.hasVideo ? '🎥' : '📄';
172
+ const mode = f.hasVideo ? '' : ' (docs only → use --dynamic)';
173
+ console.log(` [${i + 1}] ${icon} ${f.name} — ${f.description}${mode}`);
174
+ });
175
+ console.log('');
176
+
177
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
178
+ return new Promise(resolve => {
179
+ rl.question(' Select folder (number, or type a path): ', answer => {
180
+ rl.close();
181
+ const trimmed = (answer || '').trim();
182
+ if (!trimmed) { resolve(null); return; }
183
+
184
+ // Number selection
185
+ const num = parseInt(trimmed, 10);
186
+ if (!isNaN(num) && num >= 1 && num <= folders.length) {
187
+ resolve(folders[num - 1].name);
188
+ return;
189
+ }
190
+
191
+ // Direct path input
192
+ resolve(trimmed);
193
+ });
194
+ });
195
+ }
196
+
197
+ // ======================== INTERACTIVE MODEL SELECTOR ========================
198
+
199
+ /**
200
+ * Format a token count as a human-readable context window size.
201
+ * @param {number} tokens
202
+ * @returns {string}
203
+ */
204
+ function fmtContext(tokens) {
205
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M`;
206
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(0)}K`;
207
+ return String(tokens);
208
+ }
209
+
210
+ /**
211
+ * Interactive model selector — shows all available Gemini models with
212
+ * context window sizes, pricing, and descriptions. Returns the model ID.
213
+ *
214
+ * @param {object} GEMINI_MODELS - Model registry from config.js
215
+ * @param {string} currentModel - Currently active model ID (shown as default)
216
+ * @returns {Promise<string>} Selected model ID
217
+ */
218
+ async function selectModel(GEMINI_MODELS, currentModel) {
219
+ const readline = require('readline');
220
+ const modelIds = Object.keys(GEMINI_MODELS);
221
+
222
+ // Group by tier for organized display
223
+ const tiers = {
224
+ premium: { label: 'Premium (highest quality)', icon: '🏆', models: [] },
225
+ balanced: { label: 'Balanced (recommended)', icon: '⚡', models: [] },
226
+ economy: { label: 'Economy (lowest cost)', icon: '💰', models: [] },
227
+ };
228
+
229
+ let idx = 0;
230
+ const indexMap = {}; // index → modelId
231
+ for (const id of modelIds) {
232
+ const m = GEMINI_MODELS[id];
233
+ const tier = tiers[m.tier] || tiers.fast;
234
+ idx++;
235
+ indexMap[idx] = id;
236
+ tier.models.push({ idx, id, ...m });
237
+ }
238
+
239
+ console.log('');
240
+ console.log(' ┌──────────────────────────────────────────────────────────────────────────────┐');
241
+ console.log(' │ 🤖 Gemini Model Selection │');
242
+ console.log(' └──────────────────────────────────────────────────────────────────────────────┘');
243
+
244
+ for (const [, tier] of Object.entries(tiers)) {
245
+ if (tier.models.length === 0) continue;
246
+ console.log('');
247
+ console.log(` ${tier.icon} ${tier.label}`);
248
+ console.log(' ' + '─'.repeat(76));
249
+
250
+ for (const m of tier.models) {
251
+ const isDefault = m.id === currentModel;
252
+ const marker = isDefault ? ' ← default' : '';
253
+ const thinkTag = m.thinking ? ' [thinking]' : '';
254
+
255
+ // Line 1: number, name, description
256
+ console.log(` [${m.idx}] ${m.name}${thinkTag}${marker}`);
257
+ console.log(` ${m.description}`);
258
+
259
+ // Line 2: specs
260
+ const ctxStr = fmtContext(m.contextWindow);
261
+ const outStr = fmtContext(m.maxOutput);
262
+ const inPrice = `$${m.pricing.inputPerM.toFixed(m.pricing.inputPerM < 0.1 ? 4 : 2)}/1M in`;
263
+ const outPrice = `$${m.pricing.outputPerM.toFixed(m.pricing.outputPerM < 1 ? 2 : 2)}/1M out`;
264
+ const thinkPrice = m.thinking ? ` · $${m.pricing.thinkingPerM.toFixed(2)}/1M think` : '';
265
+ console.log(` Context: ${ctxStr} tokens · Max output: ${outStr} · ${m.costEstimate}`);
266
+ console.log(` Pricing: ${inPrice} · ${outPrice}${thinkPrice}`);
267
+ }
268
+ }
269
+
270
+ console.log('');
271
+
272
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
273
+ return new Promise(resolve => {
274
+ rl.question(` Select model [1-${idx}] (Enter = keep default): `, answer => {
275
+ rl.close();
276
+ const trimmed = (answer || '').trim();
277
+
278
+ // Enter = keep default
279
+ if (!trimmed) {
280
+ console.log(` → Using ${GEMINI_MODELS[currentModel].name}`);
281
+ resolve(currentModel);
282
+ return;
283
+ }
284
+
285
+ // Number selection
286
+ const num = parseInt(trimmed, 10);
287
+ if (!isNaN(num) && indexMap[num]) {
288
+ const chosen = indexMap[num];
289
+ console.log(` → Selected ${GEMINI_MODELS[chosen].name}`);
290
+ resolve(chosen);
291
+ return;
292
+ }
293
+
294
+ // Direct model ID input
295
+ if (GEMINI_MODELS[trimmed]) {
296
+ console.log(` → Selected ${GEMINI_MODELS[trimmed].name}`);
297
+ resolve(trimmed);
298
+ return;
299
+ }
300
+
301
+ // Fuzzy match: partial name
302
+ const lower = trimmed.toLowerCase();
303
+ const match = modelIds.find(id =>
304
+ id.toLowerCase().includes(lower) ||
305
+ GEMINI_MODELS[id].name.toLowerCase().includes(lower)
306
+ );
307
+ if (match) {
308
+ console.log(` → Matched ${GEMINI_MODELS[match].name}`);
309
+ resolve(match);
310
+ return;
311
+ }
312
+
313
+ console.log(` ⚠ Unknown selection "${trimmed}" — using default (${currentModel})`);
314
+ resolve(currentModel);
315
+ });
316
+ });
317
+ }
318
+
319
+ /**
320
+ * Display help text and signal an early exit by throwing.
321
+ * Callers should catch this and exit cleanly (no process.exit in library code).
322
+ */
323
+ function showHelp() {
324
+ console.log(`
325
+ Usage: taskex [options] [folder]
326
+ taskex config [--show | --clear]
327
+ node process_and_upload.js [options] [folder]
328
+
329
+ AI-powered meeting analysis & document generation pipeline.
330
+ If no folder is specified, shows an interactive folder selector.
331
+ If you cd into a folder, just run: taskex
332
+
333
+ Subcommands:
334
+ config Interactive global config setup (~/.taskexrc)
335
+ config --show Show saved config (masked secrets)
336
+ config --clear Remove global config file
337
+
338
+ Arguments:
339
+ [folder] Path to the call/project folder (optional — interactive if omitted)
340
+
341
+ Modes:
342
+ (default) Video analysis — compress, analyze, extract, compile
343
+ --dynamic Document-only mode — no video required, generates docs from context + request
344
+ --update-progress Track item completion via git since last analysis
345
+ --deep-dive (after video analysis) Generate explanatory docs per topic discussed
346
+
347
+ Core Options:
348
+ --name <name> Your name (skips interactive prompt)
349
+ --model <id> Gemini model to use (skips interactive selector)
350
+ Models: gemini-3.1-pro-preview, gemini-3-flash-preview,
351
+ gemini-2.5-pro, gemini-2.5-flash (default), gemini-2.5-flash-lite
352
+ --skip-upload Skip all Firebase Storage uploads
353
+ --force-upload Re-upload files even if they already exist in Storage
354
+ --no-storage-url Disable Storage URL optimization (force Gemini File API)
355
+ --skip-compression Skip video compression (use existing segments)
356
+ --skip-gemini Skip Gemini AI analysis
357
+ --resume Resume from last checkpoint (skip completed steps)
358
+ --reanalyze Force re-analysis of all segments
359
+ --dry-run Show what would be done without executing
360
+
361
+ Dynamic Mode:
362
+ --dynamic Enable document-only mode (no video required)
363
+ --request <text> What to generate — e.g. "Plan migration from X to Y"
364
+ (prompted interactively if omitted)
365
+
366
+ Progress Tracking:
367
+ --repo <path> Path to the project git repo (for change detection)
368
+
369
+ Configuration:
370
+ --gemini-key <key> Gemini API key (overrides .env / ~/.taskexrc)
371
+ --firebase-key <key> Firebase API key (overrides .env / ~/.taskexrc)
372
+ --firebase-project <id> Firebase project ID (overrides .env / ~/.taskexrc)
373
+ --firebase-bucket <bucket> Firebase storage bucket (overrides .env / ~/.taskexrc)
374
+ --firebase-domain <domain> Firebase auth domain (overrides .env / ~/.taskexrc)
375
+
376
+ Config resolution (highest wins):
377
+ CLI flags → env vars → CWD .env → ~/.taskexrc → package .env
378
+
379
+ Tuning:
380
+ --parallel <n> Max parallel uploads (default: 3)
381
+ --parallel-analysis <n> Concurrent segment analysis batches (default: 2)
382
+ --thinking-budget <n> Thinking token budget per segment (default: 24576)
383
+ --compilation-thinking-budget <n> Thinking tokens for final compilation (default: 10240)
384
+ --log-level <level> Log level: debug, info, warn, error (default: info)
385
+ --output <dir> Custom output directory for results
386
+ --no-focused-pass Disable focused re-analysis for weak segments
387
+ --no-learning Disable learning loop (historical budget adjustments)
388
+ --no-diff Disable diff comparison against previous runs
389
+
390
+ Info:
391
+ --help, -h Show this help message
392
+ --version, -v Show version
393
+
394
+ Examples:
395
+ taskex Interactive (cd into folder first)
396
+ taskex "call 1" Analyze a call (with video)
397
+ taskex --name "Jane" --skip-upload "call 1" Skip Firebase, set name
398
+ taskex --gemini-key "AIza..." --skip-upload "call 1" Pass API key inline (no .env)
399
+ taskex --model gemini-2.5-pro "call 1" Use Gemini 2.5 Pro model
400
+ taskex --resume "call 1" Resume interrupted run
401
+ taskex --deep-dive "call 1" Video analysis + deep dive docs
402
+ taskex --dynamic "my-project" Doc-only mode (prompted for request)
403
+ taskex --dynamic --request "Plan API migration" "specs" Dynamic with request
404
+ taskex --update-progress --repo "C:\\my-project" "call 1" Progress tracking via git
405
+
406
+ First-time setup:
407
+ taskex config Save API keys globally (~/.taskexrc)
408
+ taskex config --show View saved config
409
+ taskex config --clear Remove saved config
410
+ `);
411
+ // Signal early exit — pipeline checks for help flag before calling this
412
+ throw Object.assign(new Error('HELP_SHOWN'), { code: 'HELP_SHOWN' });
413
+ }
414
+
415
+ module.exports = { parseArgs, showHelp, discoverFolders, selectFolder, selectModel };