@steipete/summarize 0.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.
Files changed (174) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.md +185 -0
  4. package/dist/cli.cjs +74333 -0
  5. package/dist/cli.cjs.map +7 -0
  6. package/dist/esm/cli-main.js +80 -0
  7. package/dist/esm/cli-main.js.map +1 -0
  8. package/dist/esm/cli.js +18 -0
  9. package/dist/esm/cli.js.map +1 -0
  10. package/dist/esm/config.js +33 -0
  11. package/dist/esm/config.js.map +1 -0
  12. package/dist/esm/content/asset.js +167 -0
  13. package/dist/esm/content/asset.js.map +1 -0
  14. package/dist/esm/content/index.js +4 -0
  15. package/dist/esm/content/index.js.map +1 -0
  16. package/dist/esm/content/link-preview/client.js +20 -0
  17. package/dist/esm/content/link-preview/client.js.map +1 -0
  18. package/dist/esm/content/link-preview/content/article.js +150 -0
  19. package/dist/esm/content/link-preview/content/article.js.map +1 -0
  20. package/dist/esm/content/link-preview/content/cleaner.js +55 -0
  21. package/dist/esm/content/link-preview/content/cleaner.js.map +1 -0
  22. package/dist/esm/content/link-preview/content/fetcher.js +120 -0
  23. package/dist/esm/content/link-preview/content/fetcher.js.map +1 -0
  24. package/dist/esm/content/link-preview/content/index.js +275 -0
  25. package/dist/esm/content/link-preview/content/index.js.map +1 -0
  26. package/dist/esm/content/link-preview/content/parsers.js +77 -0
  27. package/dist/esm/content/link-preview/content/parsers.js.map +1 -0
  28. package/dist/esm/content/link-preview/content/types.js +4 -0
  29. package/dist/esm/content/link-preview/content/types.js.map +1 -0
  30. package/dist/esm/content/link-preview/content/utils.js +127 -0
  31. package/dist/esm/content/link-preview/content/utils.js.map +1 -0
  32. package/dist/esm/content/link-preview/content/youtube.js +82 -0
  33. package/dist/esm/content/link-preview/content/youtube.js.map +1 -0
  34. package/dist/esm/content/link-preview/deps.js +2 -0
  35. package/dist/esm/content/link-preview/deps.js.map +1 -0
  36. package/dist/esm/content/link-preview/fetch-with-timeout.js +35 -0
  37. package/dist/esm/content/link-preview/fetch-with-timeout.js.map +1 -0
  38. package/dist/esm/content/link-preview/transcript/cache.js +73 -0
  39. package/dist/esm/content/link-preview/transcript/cache.js.map +1 -0
  40. package/dist/esm/content/link-preview/transcript/index.js +95 -0
  41. package/dist/esm/content/link-preview/transcript/index.js.map +1 -0
  42. package/dist/esm/content/link-preview/transcript/normalize.js +43 -0
  43. package/dist/esm/content/link-preview/transcript/normalize.js.map +1 -0
  44. package/dist/esm/content/link-preview/transcript/providers/generic.js +11 -0
  45. package/dist/esm/content/link-preview/transcript/providers/generic.js.map +1 -0
  46. package/dist/esm/content/link-preview/transcript/providers/podcast.js +12 -0
  47. package/dist/esm/content/link-preview/transcript/providers/podcast.js.map +1 -0
  48. package/dist/esm/content/link-preview/transcript/providers/twitter.js +12 -0
  49. package/dist/esm/content/link-preview/transcript/providers/twitter.js.map +1 -0
  50. package/dist/esm/content/link-preview/transcript/providers/youtube/api.js +257 -0
  51. package/dist/esm/content/link-preview/transcript/providers/youtube/api.js.map +1 -0
  52. package/dist/esm/content/link-preview/transcript/providers/youtube/apify.js +55 -0
  53. package/dist/esm/content/link-preview/transcript/providers/youtube/apify.js.map +1 -0
  54. package/dist/esm/content/link-preview/transcript/providers/youtube/captions.js +409 -0
  55. package/dist/esm/content/link-preview/transcript/providers/youtube/captions.js.map +1 -0
  56. package/dist/esm/content/link-preview/transcript/providers/youtube/ytdlp.js +114 -0
  57. package/dist/esm/content/link-preview/transcript/providers/youtube/ytdlp.js.map +1 -0
  58. package/dist/esm/content/link-preview/transcript/providers/youtube.js +74 -0
  59. package/dist/esm/content/link-preview/transcript/providers/youtube.js.map +1 -0
  60. package/dist/esm/content/link-preview/transcript/types.js +2 -0
  61. package/dist/esm/content/link-preview/transcript/types.js.map +1 -0
  62. package/dist/esm/content/link-preview/transcript/utils.js +193 -0
  63. package/dist/esm/content/link-preview/transcript/utils.js.map +1 -0
  64. package/dist/esm/content/link-preview/types.js +2 -0
  65. package/dist/esm/content/link-preview/types.js.map +1 -0
  66. package/dist/esm/costs.js +57 -0
  67. package/dist/esm/costs.js.map +1 -0
  68. package/dist/esm/firecrawl.js +54 -0
  69. package/dist/esm/firecrawl.js.map +1 -0
  70. package/dist/esm/flags.js +97 -0
  71. package/dist/esm/flags.js.map +1 -0
  72. package/dist/esm/index.js +4 -0
  73. package/dist/esm/index.js.map +1 -0
  74. package/dist/esm/llm/generate-text.js +296 -0
  75. package/dist/esm/llm/generate-text.js.map +1 -0
  76. package/dist/esm/llm/google-models.js +112 -0
  77. package/dist/esm/llm/google-models.js.map +1 -0
  78. package/dist/esm/llm/html-to-markdown.js +44 -0
  79. package/dist/esm/llm/html-to-markdown.js.map +1 -0
  80. package/dist/esm/llm/model-id.js +45 -0
  81. package/dist/esm/llm/model-id.js.map +1 -0
  82. package/dist/esm/pricing/litellm.js +25 -0
  83. package/dist/esm/pricing/litellm.js.map +1 -0
  84. package/dist/esm/prompts/file.js +14 -0
  85. package/dist/esm/prompts/file.js.map +1 -0
  86. package/dist/esm/prompts/index.js +3 -0
  87. package/dist/esm/prompts/index.js.map +1 -0
  88. package/dist/esm/prompts/link-summary.js +105 -0
  89. package/dist/esm/prompts/link-summary.js.map +1 -0
  90. package/dist/esm/run.js +1674 -0
  91. package/dist/esm/run.js.map +1 -0
  92. package/dist/esm/shared/contracts.js +2 -0
  93. package/dist/esm/shared/contracts.js.map +1 -0
  94. package/dist/esm/summarizeHome.js +20 -0
  95. package/dist/esm/summarizeHome.js.map +1 -0
  96. package/dist/esm/tty/live-markdown.js +52 -0
  97. package/dist/esm/tty/live-markdown.js.map +1 -0
  98. package/dist/esm/tty/osc-progress.js +8 -0
  99. package/dist/esm/tty/osc-progress.js.map +1 -0
  100. package/dist/esm/tty/spinner.js +33 -0
  101. package/dist/esm/tty/spinner.js.map +1 -0
  102. package/dist/esm/version.js +44 -0
  103. package/dist/esm/version.js.map +1 -0
  104. package/dist/types/cli-main.d.ts +11 -0
  105. package/dist/types/cli.d.ts +1 -0
  106. package/dist/types/config.d.ts +15 -0
  107. package/dist/types/content/asset.d.ts +44 -0
  108. package/dist/types/content/index.d.ts +4 -0
  109. package/dist/types/content/link-preview/client.d.ts +14 -0
  110. package/dist/types/content/link-preview/content/article.d.ts +4 -0
  111. package/dist/types/content/link-preview/content/cleaner.d.ts +12 -0
  112. package/dist/types/content/link-preview/content/fetcher.d.ts +16 -0
  113. package/dist/types/content/link-preview/content/index.d.ts +4 -0
  114. package/dist/types/content/link-preview/content/parsers.d.ts +7 -0
  115. package/dist/types/content/link-preview/content/types.d.ts +44 -0
  116. package/dist/types/content/link-preview/content/utils.d.ts +16 -0
  117. package/dist/types/content/link-preview/content/youtube.d.ts +1 -0
  118. package/dist/types/content/link-preview/deps.d.ts +70 -0
  119. package/dist/types/content/link-preview/fetch-with-timeout.d.ts +4 -0
  120. package/dist/types/content/link-preview/transcript/cache.d.ts +29 -0
  121. package/dist/types/content/link-preview/transcript/index.d.ts +9 -0
  122. package/dist/types/content/link-preview/transcript/normalize.d.ts +3 -0
  123. package/dist/types/content/link-preview/transcript/providers/generic.d.ts +3 -0
  124. package/dist/types/content/link-preview/transcript/providers/podcast.d.ts +3 -0
  125. package/dist/types/content/link-preview/transcript/providers/twitter.d.ts +3 -0
  126. package/dist/types/content/link-preview/transcript/providers/youtube/api.d.ts +26 -0
  127. package/dist/types/content/link-preview/transcript/providers/youtube/apify.d.ts +1 -0
  128. package/dist/types/content/link-preview/transcript/providers/youtube/captions.d.ts +7 -0
  129. package/dist/types/content/link-preview/transcript/providers/youtube/ytdlp.d.ts +3 -0
  130. package/dist/types/content/link-preview/transcript/providers/youtube.d.ts +3 -0
  131. package/dist/types/content/link-preview/transcript/types.d.ts +23 -0
  132. package/dist/types/content/link-preview/transcript/utils.d.ts +7 -0
  133. package/dist/types/content/link-preview/types.d.ts +36 -0
  134. package/dist/types/costs.d.ts +31 -0
  135. package/dist/types/firecrawl.d.ts +5 -0
  136. package/dist/types/flags.d.ts +23 -0
  137. package/dist/types/index.d.ts +4 -0
  138. package/dist/types/llm/generate-text.d.ts +43 -0
  139. package/dist/types/llm/google-models.d.ts +10 -0
  140. package/dist/types/llm/html-to-markdown.d.ts +15 -0
  141. package/dist/types/llm/model-id.d.ts +14 -0
  142. package/dist/types/pricing/litellm.d.ts +13 -0
  143. package/dist/types/prompts/file.d.ts +6 -0
  144. package/dist/types/prompts/index.d.ts +3 -0
  145. package/dist/types/prompts/link-summary.d.ts +27 -0
  146. package/dist/types/run.d.ts +8 -0
  147. package/dist/types/shared/contracts.d.ts +2 -0
  148. package/dist/types/summarizeHome.d.ts +6 -0
  149. package/dist/types/tty/live-markdown.d.ts +10 -0
  150. package/dist/types/tty/osc-progress.d.ts +3 -0
  151. package/dist/types/tty/spinner.d.ts +10 -0
  152. package/dist/types/version.d.ts +2 -0
  153. package/docs/README.md +11 -0
  154. package/docs/config.md +28 -0
  155. package/docs/extract-only.md +13 -0
  156. package/docs/firecrawl.md +17 -0
  157. package/docs/llm.md +33 -0
  158. package/docs/openai.md +18 -0
  159. package/docs/site/.nojekyll +1 -0
  160. package/docs/site/404.html +37 -0
  161. package/docs/site/assets/site.css +577 -0
  162. package/docs/site/assets/site.js +69 -0
  163. package/docs/site/docs/config.html +73 -0
  164. package/docs/site/docs/extract-only.html +79 -0
  165. package/docs/site/docs/firecrawl.html +72 -0
  166. package/docs/site/docs/index.html +89 -0
  167. package/docs/site/docs/llm.html +70 -0
  168. package/docs/site/docs/openai.html +66 -0
  169. package/docs/site/docs/website.html +70 -0
  170. package/docs/site/docs/youtube.html +62 -0
  171. package/docs/site/index.html +125 -0
  172. package/docs/website.md +27 -0
  173. package/docs/youtube.md +32 -0
  174. package/package.json +76 -0
@@ -0,0 +1,1674 @@
1
+ import fs from 'node:fs/promises';
2
+ import { Command, CommanderError, Option } from 'commander';
3
+ import { createLiveRenderer, render as renderMarkdownAnsi } from 'markdansi';
4
+ import { loadSummarizeConfig } from './config.js';
5
+ import { buildAssetPromptMessages, classifyUrl, loadLocalAsset, loadRemoteAsset, resolveInputTarget, } from './content/asset.js';
6
+ import { createLinkPreviewClient } from './content/index.js';
7
+ import { buildRunMetricsReport } from './costs.js';
8
+ import { createFirecrawlScraper } from './firecrawl.js';
9
+ import { parseDurationMs, parseFirecrawlMode, parseLengthArg, parseMarkdownMode, parseMaxOutputTokensArg, parseMetricsMode, parseRenderMode, parseStreamMode, parseYoutubeMode, } from './flags.js';
10
+ import { generateTextWithModelId, streamTextWithModelId } from './llm/generate-text.js';
11
+ import { resolveGoogleModelForUsage } from './llm/google-models.js';
12
+ import { createHtmlToMarkdownConverter } from './llm/html-to-markdown.js';
13
+ import { normalizeGatewayStyleModelId, parseGatewayStyleModelId } from './llm/model-id.js';
14
+ import { loadLiteLlmCatalog, resolveLiteLlmMaxOutputTokensForModelId, resolveLiteLlmPricingForModelId, } from './pricing/litellm.js';
15
+ import { buildFileSummaryPrompt, buildLinkSummaryPrompt, SUMMARY_LENGTH_TO_TOKENS, } from './prompts/index.js';
16
+ import { startOscProgress } from './tty/osc-progress.js';
17
+ import { startSpinner } from './tty/spinner.js';
18
+ import { resolvePackageVersion } from './version.js';
19
+ const MAP_REDUCE_TRIGGER_CHARACTERS = 120_000;
20
+ const MAP_REDUCE_CHUNK_CHARACTERS = 60_000;
21
+ function buildProgram() {
22
+ return new Command()
23
+ .name('summarize')
24
+ .description('Summarize web pages and YouTube links (uses direct provider API keys).')
25
+ .argument('[input]', 'URL or local file path to summarize')
26
+ .option('--youtube <mode>', 'YouTube transcript source: auto (web then apify), web (youtubei/captionTracks), apify', 'auto')
27
+ .option('--firecrawl <mode>', 'Firecrawl usage: off, auto (fallback), always (try Firecrawl first). Note: in --extract-only website mode, defaults to always when FIRECRAWL_API_KEY is set.', 'auto')
28
+ .option('--markdown <mode>', 'Website Markdown output: off, auto (prefer Firecrawl, then LLM when configured), llm (force LLM). Only affects --extract-only for non-YouTube URLs.', 'auto')
29
+ .option('--length <length>', 'Summary length: short|medium|long|xl|xxl or a character limit like 20000, 20k', 'medium')
30
+ .option('--max-output-tokens <count>', 'Hard cap for LLM output tokens (e.g. 2000, 2k). Overrides provider defaults.', undefined)
31
+ .option('--timeout <duration>', 'Timeout for content fetching and LLM request: 30 (seconds), 30s, 2m, 5000ms', '2m')
32
+ .option('--model <model>', 'LLM model id (gateway-style): xai/..., openai/..., google/... (default: google/gemini-3-flash-preview)', undefined)
33
+ .option('--extract-only', 'Print extracted content and exit (no LLM summary)', false)
34
+ .option('--json', 'Output structured JSON (includes prompt + metrics)', false)
35
+ .option('--stream <mode>', 'Stream LLM output: auto (TTY only), on, off. Note: streaming is disabled in --json mode.', 'auto')
36
+ .option('--render <mode>', 'Render Markdown output: auto (TTY only), md-live, md, plain. Note: auto selects md-live when streaming to a TTY.', 'auto')
37
+ .option('--verbose', 'Print detailed progress info to stderr', false)
38
+ .addOption(new Option('--metrics <mode>', 'Metrics output: off, on, detailed')
39
+ .choices(['off', 'on', 'detailed'])
40
+ .default('on'))
41
+ .option('-V, --version', 'Print version and exit', false)
42
+ .allowExcessArguments(false);
43
+ }
44
+ function isRichTty(stream) {
45
+ return Boolean(stream.isTTY);
46
+ }
47
+ function supportsColor(stream, env) {
48
+ if (env.NO_COLOR)
49
+ return false;
50
+ if (env.FORCE_COLOR && env.FORCE_COLOR !== '0')
51
+ return true;
52
+ if (!isRichTty(stream))
53
+ return false;
54
+ const term = env.TERM?.toLowerCase();
55
+ if (!term || term === 'dumb')
56
+ return false;
57
+ return true;
58
+ }
59
+ function terminalWidth(stream, env) {
60
+ const cols = stream.columns;
61
+ if (typeof cols === 'number' && Number.isFinite(cols) && cols > 0) {
62
+ return Math.floor(cols);
63
+ }
64
+ const fromEnv = env.COLUMNS ? Number(env.COLUMNS) : NaN;
65
+ if (Number.isFinite(fromEnv) && fromEnv > 0) {
66
+ return Math.floor(fromEnv);
67
+ }
68
+ return 80;
69
+ }
70
+ function markdownRenderWidth(stream, env) {
71
+ // Avoid “phantom blank lines” from terminal auto-wrap when the rendered line hits the exact width.
72
+ // Wrap 1 column earlier so explicit newlines don't combine with terminal soft-wrap.
73
+ const w = terminalWidth(stream, env);
74
+ return Math.max(20, w - 1);
75
+ }
76
+ function ansi(code, input, enabled) {
77
+ if (!enabled)
78
+ return input;
79
+ return `\u001b[${code}m${input}\u001b[0m`;
80
+ }
81
+ function isUnsupportedAttachmentError(error) {
82
+ if (!error || typeof error !== 'object')
83
+ return false;
84
+ const err = error;
85
+ const name = typeof err.name === 'string' ? err.name : '';
86
+ const message = typeof err.message === 'string' ? err.message : '';
87
+ if (name.toLowerCase().includes('unsupportedfunctionality'))
88
+ return true;
89
+ if (message.toLowerCase().includes('functionality not supported'))
90
+ return true;
91
+ return false;
92
+ }
93
+ function isTextLikeMediaType(mediaType) {
94
+ const mt = mediaType.toLowerCase();
95
+ if (mt.startsWith('text/'))
96
+ return true;
97
+ // Common “text but not text/*” types we want to inline instead of attaching as a file part.
98
+ return (mt === 'application/json' ||
99
+ mt === 'application/xml' ||
100
+ mt === 'application/x-yaml' ||
101
+ mt === 'application/yaml' ||
102
+ mt === 'application/toml' ||
103
+ mt === 'application/rtf' ||
104
+ mt === 'application/javascript');
105
+ }
106
+ function isArchiveMediaType(mediaType) {
107
+ const mt = mediaType.toLowerCase();
108
+ return (mt === 'application/zip' ||
109
+ mt === 'application/x-zip-compressed' ||
110
+ mt === 'application/x-7z-compressed' ||
111
+ mt === 'application/x-rar-compressed' ||
112
+ mt === 'application/x-tar' ||
113
+ mt === 'application/gzip');
114
+ }
115
+ function attachmentByteLength(attachment) {
116
+ if (attachment.part.type === 'image') {
117
+ const image = attachment.part.image;
118
+ if (image instanceof Uint8Array)
119
+ return image.byteLength;
120
+ if (typeof image === 'string')
121
+ return image.length;
122
+ return null;
123
+ }
124
+ const data = attachment.part.data;
125
+ if (data instanceof Uint8Array)
126
+ return data.byteLength;
127
+ if (typeof data === 'string')
128
+ return data.length;
129
+ return null;
130
+ }
131
+ function assertAssetMediaTypeSupported({ attachment, sizeLabel, }) {
132
+ if (!isArchiveMediaType(attachment.mediaType))
133
+ return;
134
+ const name = attachment.filename ?? 'file';
135
+ const bytes = attachmentByteLength(attachment);
136
+ const size = sizeLabel ?? (typeof bytes === 'number' ? formatBytes(bytes) : null);
137
+ const details = size ? `${attachment.mediaType}, ${size}` : attachment.mediaType;
138
+ throw new Error(`Unsupported file type: ${name} (${details})\n` +
139
+ `Archive formats (zip/tar/7z/rar) can’t be sent to the model.\n` +
140
+ `Unzip and summarize a specific file instead (e.g. README.md).`);
141
+ }
142
+ function buildAssetPromptPayload({ promptText, attachment, }) {
143
+ if (attachment.part.type === 'file' && isTextLikeMediaType(attachment.mediaType)) {
144
+ const data = attachment.part.data;
145
+ const content = typeof data === 'string'
146
+ ? data
147
+ : data instanceof Uint8Array
148
+ ? new TextDecoder().decode(data)
149
+ : '';
150
+ const header = `File: ${attachment.filename ?? 'unknown'} (${attachment.mediaType})`;
151
+ return `${promptText}\n\n---\n${header}\n\n${content}`.trim();
152
+ }
153
+ return buildAssetPromptMessages({ promptText, attachment });
154
+ }
155
+ function assertProviderSupportsAttachment({ provider, modelId, attachment, }) {
156
+ // xAI via AI SDK currently supports image parts, but not generic file parts (e.g. PDFs).
157
+ if (provider === 'xai' &&
158
+ attachment.part.type === 'file' &&
159
+ !isTextLikeMediaType(attachment.mediaType)) {
160
+ throw new Error(`Model ${modelId} does not support attaching files of type ${attachment.mediaType}. Try a different --model (e.g. google/gemini-3-flash-preview).`);
161
+ }
162
+ }
163
+ async function resolveModelIdForLlmCall({ parsedModel, apiKeys, fetchImpl, timeoutMs, }) {
164
+ if (parsedModel.provider !== 'google') {
165
+ return { modelId: parsedModel.canonical, note: null, forceStreamOff: false };
166
+ }
167
+ const key = apiKeys.googleApiKey;
168
+ if (!key) {
169
+ return { modelId: parsedModel.canonical, note: null, forceStreamOff: false };
170
+ }
171
+ const resolved = await resolveGoogleModelForUsage({
172
+ requestedModelId: parsedModel.model,
173
+ apiKey: key,
174
+ fetchImpl,
175
+ timeoutMs,
176
+ });
177
+ return {
178
+ modelId: `google/${resolved.resolvedModelId}`,
179
+ note: resolved.note,
180
+ forceStreamOff: false,
181
+ };
182
+ }
183
+ function isGoogleStreamingUnsupportedError(error) {
184
+ if (!error || typeof error !== 'object')
185
+ return false;
186
+ const maybe = error;
187
+ const message = typeof maybe.message === 'string' ? maybe.message : '';
188
+ const url = typeof maybe.url === 'string' ? maybe.url : '';
189
+ const responseBody = typeof maybe.responseBody === 'string' ? maybe.responseBody : '';
190
+ const errorText = `${message}\n${responseBody}`;
191
+ const isStreamEndpoint = url.includes(':streamGenerateContent') || errorText.includes('streamGenerateContent');
192
+ if (!isStreamEndpoint)
193
+ return false;
194
+ return (/does not support/i.test(errorText) ||
195
+ /not supported/i.test(errorText) ||
196
+ /Call ListModels/i.test(errorText) ||
197
+ /supported methods/i.test(errorText));
198
+ }
199
+ function attachRichHelp(program, env, stdout) {
200
+ const color = supportsColor(stdout, env);
201
+ const heading = (text) => ansi('1;36', text, color);
202
+ const cmd = (text) => ansi('1', text, color);
203
+ const dim = (text) => ansi('2', text, color);
204
+ program.addHelpText('after', () => `
205
+ ${heading('Examples')}
206
+ ${cmd('summarize "https://example.com"')}
207
+ ${cmd('summarize "https://example.com" --extract-only')} ${dim('# website markdown (prefers Firecrawl when configured)')}
208
+ ${cmd('summarize "https://example.com" --extract-only --markdown llm')} ${dim('# website markdown via LLM')}
209
+ ${cmd('summarize "https://www.youtube.com/watch?v=I845O57ZSy4&t=11s" --extract-only --youtube web')}
210
+ ${cmd('summarize "https://example.com" --length 20k --max-output-tokens 2k --timeout 2m --model openai/gpt-5.2')}
211
+ ${cmd('OPENAI_BASE_URL=https://openrouter.ai/api/v1 OPENROUTER_API_KEY=... summarize "https://example.com" --model openai/xiaomi/mimo-v2-flash:free')}
212
+ ${cmd('summarize "https://example.com" --json --verbose')}
213
+
214
+ ${heading('Env Vars')}
215
+ XAI_API_KEY optional (required for xai/... models)
216
+ OPENAI_API_KEY optional (required for openai/... models)
217
+ OPENAI_BASE_URL optional (OpenAI-compatible API endpoint; e.g. OpenRouter)
218
+ OPENROUTER_API_KEY optional (used when OPENAI_BASE_URL points to OpenRouter)
219
+ GEMINI_API_KEY optional (required for google/... models)
220
+ ANTHROPIC_API_KEY optional (required for anthropic/... models)
221
+ SUMMARIZE_MODEL optional (overrides default model selection)
222
+ FIRECRAWL_API_KEY optional website extraction fallback (Markdown)
223
+ APIFY_API_TOKEN optional YouTube transcript fallback
224
+ `);
225
+ }
226
+ async function summarizeWithModelId({ modelId, prompt, maxOutputTokens, timeoutMs, fetchImpl, apiKeys, }) {
227
+ const result = await generateTextWithModelId({
228
+ modelId,
229
+ apiKeys,
230
+ prompt,
231
+ temperature: 0,
232
+ maxOutputTokens,
233
+ timeoutMs,
234
+ fetchImpl,
235
+ });
236
+ return {
237
+ text: result.text,
238
+ provider: result.provider,
239
+ canonicalModelId: result.canonicalModelId,
240
+ usage: result.usage,
241
+ };
242
+ }
243
+ function splitTextIntoChunks(input, maxCharacters) {
244
+ if (maxCharacters <= 0) {
245
+ return [input];
246
+ }
247
+ const text = input.trim();
248
+ if (text.length <= maxCharacters) {
249
+ return [text];
250
+ }
251
+ const chunks = [];
252
+ let offset = 0;
253
+ while (offset < text.length) {
254
+ const end = Math.min(offset + maxCharacters, text.length);
255
+ const slice = text.slice(offset, end);
256
+ if (end === text.length) {
257
+ chunks.push(slice.trim());
258
+ break;
259
+ }
260
+ const candidateBreaks = [
261
+ slice.lastIndexOf('\n\n'),
262
+ slice.lastIndexOf('\n'),
263
+ slice.lastIndexOf('. '),
264
+ ];
265
+ const lastBreak = Math.max(...candidateBreaks);
266
+ const splitAt = lastBreak > Math.floor(maxCharacters * 0.5) ? lastBreak + 1 : slice.length;
267
+ const chunk = slice.slice(0, splitAt).trim();
268
+ if (chunk.length > 0) {
269
+ chunks.push(chunk);
270
+ }
271
+ offset += splitAt;
272
+ }
273
+ return chunks.filter((chunk) => chunk.length > 0);
274
+ }
275
+ const VERBOSE_PREFIX = '[summarize]';
276
+ function writeVerbose(stderr, verbose, message, color) {
277
+ if (!verbose) {
278
+ return;
279
+ }
280
+ const prefix = ansi('36', VERBOSE_PREFIX, color);
281
+ stderr.write(`${prefix} ${message}\n`);
282
+ }
283
+ function formatOptionalString(value) {
284
+ if (typeof value === 'string' && value.trim().length > 0) {
285
+ return value.trim();
286
+ }
287
+ return 'none';
288
+ }
289
+ function formatOptionalNumber(value) {
290
+ if (typeof value === 'number' && Number.isFinite(value)) {
291
+ return String(value);
292
+ }
293
+ return 'none';
294
+ }
295
+ function formatElapsedMs(ms) {
296
+ if (!Number.isFinite(ms) || ms < 0)
297
+ return 'unknown';
298
+ if (ms < 60_000)
299
+ return `${(ms / 1000).toFixed(1)}s`;
300
+ const minutes = Math.floor(ms / 60_000);
301
+ const seconds = Math.floor((ms % 60_000) / 1000);
302
+ return `${minutes}m${seconds.toString().padStart(2, '0')}s`;
303
+ }
304
+ function formatBytes(bytes) {
305
+ if (!Number.isFinite(bytes) || bytes < 0)
306
+ return 'unknown';
307
+ if (bytes < 1024)
308
+ return `${bytes} B`;
309
+ const units = ['KB', 'MB', 'GB', 'TB'];
310
+ let value = bytes / 1024;
311
+ let unitIndex = 0;
312
+ while (value >= 1024 && unitIndex < units.length - 1) {
313
+ value /= 1024;
314
+ unitIndex += 1;
315
+ }
316
+ return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
317
+ }
318
+ function sumNumbersOrNull(values) {
319
+ let sum = 0;
320
+ let any = false;
321
+ for (const value of values) {
322
+ if (typeof value === 'number' && Number.isFinite(value)) {
323
+ sum += value;
324
+ any = true;
325
+ }
326
+ }
327
+ return any ? sum : null;
328
+ }
329
+ function formatUSD(value) {
330
+ if (!Number.isFinite(value))
331
+ return 'n/a';
332
+ return `$${value.toFixed(4)}`;
333
+ }
334
+ function writeFinishLine({ stderr, elapsedMs, model, strategy, chunkCount, report, costUsd, color, }) {
335
+ const promptTokens = sumNumbersOrNull(report.llm.map((row) => row.promptTokens));
336
+ const completionTokens = sumNumbersOrNull(report.llm.map((row) => row.completionTokens));
337
+ const totalTokens = sumNumbersOrNull(report.llm.map((row) => row.totalTokens));
338
+ const tokPart = promptTokens !== null || completionTokens !== null || totalTokens !== null
339
+ ? `tok(i/o/t)=${promptTokens?.toLocaleString() ?? 'unknown'}/${completionTokens?.toLocaleString() ?? 'unknown'}/${totalTokens?.toLocaleString() ?? 'unknown'}`
340
+ : 'tok(i/o/t)=unknown';
341
+ const parts = [
342
+ model,
343
+ costUsd != null ? `cost=${formatUSD(costUsd)}` : 'cost=N/A',
344
+ tokPart,
345
+ ];
346
+ if (report.services.firecrawl.requests > 0) {
347
+ parts.push(`firecrawl=${report.services.firecrawl.requests}`);
348
+ }
349
+ if (report.services.apify.requests > 0) {
350
+ parts.push(`apify=${report.services.apify.requests}`);
351
+ }
352
+ if (strategy === 'map-reduce') {
353
+ parts.push('strategy=map-reduce');
354
+ if (typeof chunkCount === 'number' && Number.isFinite(chunkCount) && chunkCount > 0) {
355
+ parts.push(`chunks=${chunkCount}`);
356
+ }
357
+ }
358
+ const line = `Finished in ${formatElapsedMs(elapsedMs)} (${parts.join(' | ')})`;
359
+ stderr.write('\n');
360
+ stderr.write(`${ansi('1;32', line, color)}\n`);
361
+ }
362
+ function buildChunkNotesPrompt({ content }) {
363
+ return `Return 10 bullet points summarizing the content below (Markdown).
364
+
365
+ CONTENT:
366
+ """
367
+ ${content}
368
+ """
369
+ `;
370
+ }
371
+ export async function runCli(argv, { env, fetch, stdout, stderr }) {
372
+ ;
373
+ globalThis.AI_SDK_LOG_WARNINGS = false;
374
+ const normalizedArgv = argv.filter((arg) => arg !== '--');
375
+ const version = resolvePackageVersion();
376
+ const program = buildProgram();
377
+ program.configureOutput({
378
+ writeOut(str) {
379
+ stdout.write(str);
380
+ },
381
+ writeErr(str) {
382
+ stderr.write(str);
383
+ },
384
+ });
385
+ program.exitOverride();
386
+ attachRichHelp(program, env, stdout);
387
+ try {
388
+ program.parse(normalizedArgv, { from: 'user' });
389
+ }
390
+ catch (error) {
391
+ if (error instanceof CommanderError && error.code === 'commander.helpDisplayed') {
392
+ return;
393
+ }
394
+ throw error;
395
+ }
396
+ if (program.opts().version) {
397
+ stdout.write(`${version}\n`);
398
+ return;
399
+ }
400
+ const rawInput = program.args[0];
401
+ if (!rawInput) {
402
+ throw new Error('Usage: summarize <url-or-file> [--youtube auto|web|apify] [--length 20k] [--max-output-tokens 2k] [--timeout 2m] [--json]');
403
+ }
404
+ const inputTarget = resolveInputTarget(rawInput);
405
+ const url = inputTarget.kind === 'url' ? inputTarget.url : null;
406
+ const runStartedAtMs = Date.now();
407
+ const youtubeMode = parseYoutubeMode(program.opts().youtube);
408
+ const lengthArg = parseLengthArg(program.opts().length);
409
+ const maxOutputTokensArg = parseMaxOutputTokensArg(program.opts().maxOutputTokens);
410
+ const timeoutMs = parseDurationMs(program.opts().timeout);
411
+ const extractOnly = Boolean(program.opts().extractOnly);
412
+ const json = Boolean(program.opts().json);
413
+ const streamMode = parseStreamMode(program.opts().stream);
414
+ const renderMode = parseRenderMode(program.opts().render);
415
+ const verbose = Boolean(program.opts().verbose);
416
+ const metricsMode = parseMetricsMode(program.opts().metrics);
417
+ const metricsEnabled = metricsMode !== 'off';
418
+ const metricsDetailed = metricsMode === 'detailed';
419
+ const markdownMode = parseMarkdownMode(program.opts().markdown);
420
+ const shouldComputeReport = metricsEnabled;
421
+ const isYoutubeUrl = typeof url === 'string' ? /youtube\.com|youtu\.be/i.test(url) : false;
422
+ const firecrawlExplicitlySet = normalizedArgv.some((arg) => arg === '--firecrawl' || arg.startsWith('--firecrawl='));
423
+ const requestedFirecrawlMode = parseFirecrawlMode(program.opts().firecrawl);
424
+ const modelArg = typeof program.opts().model === 'string' ? program.opts().model : null;
425
+ const { config, path: configPath } = loadSummarizeConfig({ env });
426
+ const xaiKeyRaw = typeof env.XAI_API_KEY === 'string' ? env.XAI_API_KEY : null;
427
+ const openaiBaseUrl = typeof env.OPENAI_BASE_URL === 'string' ? env.OPENAI_BASE_URL : null;
428
+ const openRouterKeyRaw = typeof env.OPENROUTER_API_KEY === 'string' ? env.OPENROUTER_API_KEY : null;
429
+ const openaiKeyRaw = typeof env.OPENAI_API_KEY === 'string' ? env.OPENAI_API_KEY : null;
430
+ const apiKey = typeof openaiBaseUrl === 'string' && /openrouter\.ai/i.test(openaiBaseUrl)
431
+ ? (openRouterKeyRaw ?? openaiKeyRaw)
432
+ : openaiKeyRaw;
433
+ const apifyToken = typeof env.APIFY_API_TOKEN === 'string' ? env.APIFY_API_TOKEN : null;
434
+ const firecrawlKey = typeof env.FIRECRAWL_API_KEY === 'string' ? env.FIRECRAWL_API_KEY : null;
435
+ const anthropicKeyRaw = typeof env.ANTHROPIC_API_KEY === 'string' ? env.ANTHROPIC_API_KEY : null;
436
+ const googleKeyRaw = typeof env.GEMINI_API_KEY === 'string'
437
+ ? env.GEMINI_API_KEY
438
+ : typeof env.GOOGLE_GENERATIVE_AI_API_KEY === 'string'
439
+ ? env.GOOGLE_GENERATIVE_AI_API_KEY
440
+ : typeof env.GOOGLE_API_KEY === 'string'
441
+ ? env.GOOGLE_API_KEY
442
+ : null;
443
+ const firecrawlApiKey = firecrawlKey && firecrawlKey.trim().length > 0 ? firecrawlKey : null;
444
+ const firecrawlConfigured = firecrawlApiKey !== null;
445
+ const xaiApiKey = xaiKeyRaw?.trim() ?? null;
446
+ const googleApiKey = googleKeyRaw?.trim() ?? null;
447
+ const anthropicApiKey = anthropicKeyRaw?.trim() ?? null;
448
+ const googleConfigured = typeof googleApiKey === 'string' && googleApiKey.length > 0;
449
+ const xaiConfigured = typeof xaiApiKey === 'string' && xaiApiKey.length > 0;
450
+ const anthropicConfigured = typeof anthropicApiKey === 'string' && anthropicApiKey.length > 0;
451
+ const llmCalls = [];
452
+ let firecrawlRequests = 0;
453
+ let apifyRequests = 0;
454
+ let liteLlmCatalogPromise = null;
455
+ const getLiteLlmCatalog = async () => {
456
+ if (!liteLlmCatalogPromise) {
457
+ liteLlmCatalogPromise = loadLiteLlmCatalog({
458
+ env,
459
+ fetchImpl: globalThis.fetch.bind(globalThis),
460
+ });
461
+ }
462
+ const result = await liteLlmCatalogPromise;
463
+ return result.catalog;
464
+ };
465
+ const capMaxOutputTokensForModel = async ({ modelId, requested, }) => {
466
+ const catalog = await getLiteLlmCatalog();
467
+ if (!catalog)
468
+ return requested;
469
+ const limit = resolveLiteLlmMaxOutputTokensForModelId(catalog, modelId);
470
+ if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) {
471
+ return Math.min(requested, limit);
472
+ }
473
+ return requested;
474
+ };
475
+ const resolveMaxOutputTokensForCall = async (modelId) => {
476
+ if (typeof maxOutputTokensArg !== 'number')
477
+ return null;
478
+ return capMaxOutputTokensForModel({ modelId, requested: maxOutputTokensArg });
479
+ };
480
+ const estimateCostUsd = async () => {
481
+ const catalog = await getLiteLlmCatalog();
482
+ if (!catalog)
483
+ return null;
484
+ let total = 0;
485
+ let any = false;
486
+ for (const call of llmCalls) {
487
+ const promptTokens = call.usage?.promptTokens ?? null;
488
+ const completionTokens = call.usage?.completionTokens ?? null;
489
+ if (typeof promptTokens !== 'number' ||
490
+ !Number.isFinite(promptTokens) ||
491
+ typeof completionTokens !== 'number' ||
492
+ !Number.isFinite(completionTokens)) {
493
+ continue;
494
+ }
495
+ const pricing = resolveLiteLlmPricingForModelId(catalog, call.model);
496
+ if (!pricing)
497
+ continue;
498
+ total +=
499
+ promptTokens * pricing.inputUsdPerToken + completionTokens * pricing.outputUsdPerToken;
500
+ any = true;
501
+ }
502
+ return any ? total : null;
503
+ };
504
+ const buildReport = async () => {
505
+ return buildRunMetricsReport({ llmCalls, firecrawlRequests, apifyRequests });
506
+ };
507
+ const trackedFetch = async (input, init) => {
508
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
509
+ let hostname = null;
510
+ try {
511
+ hostname = new URL(url).hostname.toLowerCase();
512
+ }
513
+ catch {
514
+ hostname = null;
515
+ }
516
+ if (hostname === 'api.firecrawl.dev') {
517
+ firecrawlRequests += 1;
518
+ }
519
+ else if (hostname === 'api.apify.com') {
520
+ apifyRequests += 1;
521
+ }
522
+ return fetch(input, init);
523
+ };
524
+ const resolvedDefaultModel = (() => {
525
+ if (typeof env.SUMMARIZE_MODEL === 'string' && env.SUMMARIZE_MODEL.trim().length > 0) {
526
+ return env.SUMMARIZE_MODEL.trim();
527
+ }
528
+ if (typeof config?.model === 'string' && config.model.trim().length > 0) {
529
+ return config.model.trim();
530
+ }
531
+ return 'google/gemini-3-flash-preview';
532
+ })();
533
+ const model = normalizeGatewayStyleModelId((modelArg?.trim() ?? '') || resolvedDefaultModel);
534
+ const parsedModelForLlm = parseGatewayStyleModelId(model);
535
+ const verboseColor = supportsColor(stderr, env);
536
+ const effectiveStreamMode = (() => {
537
+ if (streamMode !== 'auto')
538
+ return streamMode;
539
+ return isRichTty(stdout) ? 'on' : 'off';
540
+ })();
541
+ const streamingEnabled = effectiveStreamMode === 'on' && !json && !extractOnly;
542
+ const effectiveRenderMode = (() => {
543
+ if (renderMode !== 'auto')
544
+ return renderMode;
545
+ if (!isRichTty(stdout))
546
+ return 'plain';
547
+ return streamingEnabled ? 'md-live' : 'md';
548
+ })();
549
+ const writeMetricsReport = (report) => {
550
+ const promptTokens = sumNumbersOrNull(report.llm.map((row) => row.promptTokens));
551
+ const completionTokens = sumNumbersOrNull(report.llm.map((row) => row.completionTokens));
552
+ const totalTokens = sumNumbersOrNull(report.llm.map((row) => row.totalTokens));
553
+ for (const row of report.llm) {
554
+ stderr.write(`metrics llm provider=${row.provider} model=${row.model} calls=${row.calls} promptTokens=${row.promptTokens ?? 'unknown'} completionTokens=${row.completionTokens ?? 'unknown'} totalTokens=${row.totalTokens ?? 'unknown'}\n`);
555
+ }
556
+ stderr.write(`metrics firecrawl requests=${report.services.firecrawl.requests}\n`);
557
+ stderr.write(`metrics apify requests=${report.services.apify.requests}\n`);
558
+ stderr.write(`metrics total tok(i/o/t)=${promptTokens ?? 'unknown'}/${completionTokens ?? 'unknown'}/${totalTokens ?? 'unknown'}\n`);
559
+ };
560
+ if (extractOnly && inputTarget.kind !== 'url') {
561
+ throw new Error('--extract-only is only supported for website/YouTube URLs');
562
+ }
563
+ const progressEnabled = isRichTty(stderr) && !verbose && !json;
564
+ let clearProgressBeforeStdout = null;
565
+ const clearProgressForStdout = () => {
566
+ const fn = clearProgressBeforeStdout;
567
+ clearProgressBeforeStdout = null;
568
+ fn?.();
569
+ };
570
+ const summarizeAsset = async ({ sourceKind, sourceLabel, attachment, }) => {
571
+ const parsedModel = parseGatewayStyleModelId(model);
572
+ const apiKeysForLlm = {
573
+ xaiApiKey,
574
+ openaiApiKey: apiKey,
575
+ googleApiKey: googleConfigured ? googleApiKey : null,
576
+ anthropicApiKey: anthropicConfigured ? anthropicApiKey : null,
577
+ };
578
+ const requiredKeyEnv = parsedModel.provider === 'xai'
579
+ ? 'XAI_API_KEY'
580
+ : parsedModel.provider === 'google'
581
+ ? 'GEMINI_API_KEY (or GOOGLE_GENERATIVE_AI_API_KEY / GOOGLE_API_KEY)'
582
+ : parsedModel.provider === 'anthropic'
583
+ ? 'ANTHROPIC_API_KEY'
584
+ : 'OPENAI_API_KEY';
585
+ const hasRequiredKey = parsedModel.provider === 'xai'
586
+ ? Boolean(xaiApiKey)
587
+ : parsedModel.provider === 'google'
588
+ ? googleConfigured
589
+ : parsedModel.provider === 'anthropic'
590
+ ? anthropicConfigured
591
+ : Boolean(apiKey);
592
+ if (!hasRequiredKey) {
593
+ throw new Error(`Missing ${requiredKeyEnv} for model ${parsedModel.canonical}. Set the env var or choose a different --model.`);
594
+ }
595
+ assertProviderSupportsAttachment({
596
+ provider: parsedModel.provider,
597
+ modelId: parsedModel.canonical,
598
+ attachment: { part: attachment.part, mediaType: attachment.mediaType },
599
+ });
600
+ const modelResolution = await resolveModelIdForLlmCall({
601
+ parsedModel,
602
+ apiKeys: { googleApiKey: apiKeysForLlm.googleApiKey },
603
+ fetchImpl: trackedFetch,
604
+ timeoutMs,
605
+ });
606
+ if (modelResolution.note && verbose) {
607
+ writeVerbose(stderr, verbose, modelResolution.note, verboseColor);
608
+ }
609
+ const effectiveModelId = modelResolution.modelId;
610
+ const parsedModelEffective = parseGatewayStyleModelId(effectiveModelId);
611
+ const streamingEnabledForCall = streamingEnabled && !modelResolution.forceStreamOff;
612
+ const summaryLengthTarget = lengthArg.kind === 'preset' ? lengthArg.preset : { maxCharacters: lengthArg.maxCharacters };
613
+ const promptText = buildFileSummaryPrompt({
614
+ filename: attachment.filename,
615
+ mediaType: attachment.mediaType,
616
+ summaryLength: summaryLengthTarget,
617
+ });
618
+ const maxOutputTokensForCall = await resolveMaxOutputTokensForCall(parsedModelEffective.canonical);
619
+ const promptPayload = buildAssetPromptPayload({ promptText, attachment });
620
+ const shouldBufferSummaryForRender = streamingEnabledForCall && effectiveRenderMode === 'md' && isRichTty(stdout);
621
+ const shouldLiveRenderSummary = streamingEnabledForCall && effectiveRenderMode === 'md-live' && isRichTty(stdout);
622
+ const shouldStreamSummaryToStdout = streamingEnabledForCall && !shouldBufferSummaryForRender && !shouldLiveRenderSummary;
623
+ let summaryAlreadyPrinted = false;
624
+ let summary = '';
625
+ let getLastStreamError = null;
626
+ if (streamingEnabledForCall) {
627
+ let streamResult = null;
628
+ try {
629
+ streamResult = await streamTextWithModelId({
630
+ modelId: parsedModelEffective.canonical,
631
+ apiKeys: apiKeysForLlm,
632
+ prompt: promptPayload,
633
+ temperature: 0,
634
+ maxOutputTokens: maxOutputTokensForCall ?? undefined,
635
+ timeoutMs,
636
+ fetchImpl: trackedFetch,
637
+ });
638
+ }
639
+ catch (error) {
640
+ if (parsedModelEffective.provider === 'google' &&
641
+ isGoogleStreamingUnsupportedError(error)) {
642
+ writeVerbose(stderr, verbose, `Google model ${parsedModelEffective.canonical} rejected streamGenerateContent; falling back to non-streaming.`, verboseColor);
643
+ const result = await summarizeWithModelId({
644
+ modelId: parsedModelEffective.canonical,
645
+ prompt: promptPayload,
646
+ maxOutputTokens: maxOutputTokensForCall ?? undefined,
647
+ timeoutMs,
648
+ fetchImpl: trackedFetch,
649
+ apiKeys: apiKeysForLlm,
650
+ });
651
+ llmCalls.push({
652
+ provider: result.provider,
653
+ model: result.canonicalModelId,
654
+ usage: result.usage,
655
+ purpose: 'summary',
656
+ });
657
+ summary = result.text;
658
+ streamResult = null;
659
+ }
660
+ else if (isUnsupportedAttachmentError(error)) {
661
+ throw new Error(`Model ${parsedModel.canonical} does not support attaching files of type ${attachment.mediaType}. Try a different --model (e.g. google/gemini-3-flash-preview).`, { cause: error });
662
+ }
663
+ else {
664
+ throw error;
665
+ }
666
+ }
667
+ if (streamResult) {
668
+ getLastStreamError = streamResult.lastError;
669
+ let streamed = '';
670
+ const liveRenderer = shouldLiveRenderSummary
671
+ ? createLiveRenderer({
672
+ write: (chunk) => {
673
+ clearProgressForStdout();
674
+ stdout.write(chunk);
675
+ },
676
+ width: markdownRenderWidth(stdout, env),
677
+ renderFrame: (markdown) => renderMarkdownAnsi(markdown, {
678
+ width: markdownRenderWidth(stdout, env),
679
+ wrap: true,
680
+ color: supportsColor(stdout, env),
681
+ }),
682
+ })
683
+ : null;
684
+ let lastFrameAtMs = 0;
685
+ try {
686
+ try {
687
+ let cleared = false;
688
+ for await (const delta of streamResult.textStream) {
689
+ if (!cleared) {
690
+ clearProgressForStdout();
691
+ cleared = true;
692
+ }
693
+ streamed += delta;
694
+ if (shouldStreamSummaryToStdout) {
695
+ stdout.write(delta);
696
+ continue;
697
+ }
698
+ if (liveRenderer) {
699
+ const now = Date.now();
700
+ const due = now - lastFrameAtMs >= 120;
701
+ const hasNewline = delta.includes('\n');
702
+ if (hasNewline || due) {
703
+ liveRenderer.render(streamed);
704
+ lastFrameAtMs = now;
705
+ }
706
+ }
707
+ }
708
+ }
709
+ catch (error) {
710
+ if (isUnsupportedAttachmentError(error)) {
711
+ throw new Error(`Model ${parsedModel.canonical} does not support attaching files of type ${attachment.mediaType}. Try a different --model (e.g. google/gemini-3-flash-preview).`, { cause: error });
712
+ }
713
+ throw error;
714
+ }
715
+ const trimmed = streamed.trim();
716
+ streamed = trimmed;
717
+ if (liveRenderer) {
718
+ liveRenderer.render(trimmed);
719
+ summaryAlreadyPrinted = true;
720
+ }
721
+ }
722
+ finally {
723
+ liveRenderer?.finish();
724
+ }
725
+ const usage = await streamResult.usage;
726
+ llmCalls.push({
727
+ provider: streamResult.provider,
728
+ model: streamResult.canonicalModelId,
729
+ usage,
730
+ purpose: 'summary',
731
+ });
732
+ summary = streamed;
733
+ if (shouldStreamSummaryToStdout) {
734
+ if (!streamed.endsWith('\n')) {
735
+ stdout.write('\n');
736
+ }
737
+ summaryAlreadyPrinted = true;
738
+ }
739
+ }
740
+ }
741
+ else {
742
+ let result;
743
+ try {
744
+ result = await summarizeWithModelId({
745
+ modelId: parsedModelEffective.canonical,
746
+ prompt: promptPayload,
747
+ maxOutputTokens: maxOutputTokensForCall ?? undefined,
748
+ timeoutMs,
749
+ fetchImpl: trackedFetch,
750
+ apiKeys: apiKeysForLlm,
751
+ });
752
+ }
753
+ catch (error) {
754
+ if (isUnsupportedAttachmentError(error)) {
755
+ throw new Error(`Model ${parsedModel.canonical} does not support attaching files of type ${attachment.mediaType}. Try a different --model (e.g. google/gemini-3-flash-preview).`, { cause: error });
756
+ }
757
+ throw error;
758
+ }
759
+ llmCalls.push({
760
+ provider: result.provider,
761
+ model: result.canonicalModelId,
762
+ usage: result.usage,
763
+ purpose: 'summary',
764
+ });
765
+ summary = result.text;
766
+ }
767
+ summary = summary.trim();
768
+ if (summary.length === 0) {
769
+ const last = getLastStreamError?.();
770
+ if (last instanceof Error) {
771
+ throw new Error(last.message, { cause: last });
772
+ }
773
+ throw new Error('LLM returned an empty summary');
774
+ }
775
+ const extracted = {
776
+ kind: 'asset',
777
+ source: sourceLabel,
778
+ mediaType: attachment.mediaType,
779
+ filename: attachment.filename,
780
+ };
781
+ if (json) {
782
+ clearProgressForStdout();
783
+ const finishReport = shouldComputeReport ? await buildReport() : null;
784
+ const input = sourceKind === 'file'
785
+ ? {
786
+ kind: 'file',
787
+ filePath: sourceLabel,
788
+ timeoutMs,
789
+ length: lengthArg.kind === 'preset'
790
+ ? { kind: 'preset', preset: lengthArg.preset }
791
+ : { kind: 'chars', maxCharacters: lengthArg.maxCharacters },
792
+ maxOutputTokens: maxOutputTokensArg,
793
+ model,
794
+ }
795
+ : {
796
+ kind: 'asset-url',
797
+ url: sourceLabel,
798
+ timeoutMs,
799
+ length: lengthArg.kind === 'preset'
800
+ ? { kind: 'preset', preset: lengthArg.preset }
801
+ : { kind: 'chars', maxCharacters: lengthArg.maxCharacters },
802
+ maxOutputTokens: maxOutputTokensArg,
803
+ model,
804
+ };
805
+ const payload = {
806
+ input,
807
+ env: {
808
+ hasXaiKey: Boolean(xaiApiKey),
809
+ hasOpenAIKey: Boolean(apiKey),
810
+ hasApifyToken: Boolean(apifyToken),
811
+ hasFirecrawlKey: firecrawlConfigured,
812
+ hasGoogleKey: googleConfigured,
813
+ hasAnthropicKey: anthropicConfigured,
814
+ },
815
+ extracted,
816
+ prompt: promptText,
817
+ llm: {
818
+ provider: parsedModelEffective.provider,
819
+ model: parsedModelEffective.canonical,
820
+ maxCompletionTokens: maxOutputTokensForCall,
821
+ strategy: 'single',
822
+ chunkCount: 1,
823
+ },
824
+ metrics: metricsEnabled ? finishReport : null,
825
+ summary,
826
+ };
827
+ if (metricsDetailed && finishReport) {
828
+ writeMetricsReport(finishReport);
829
+ }
830
+ stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
831
+ if (metricsEnabled && finishReport) {
832
+ const costUsd = await estimateCostUsd();
833
+ writeFinishLine({
834
+ stderr,
835
+ elapsedMs: Date.now() - runStartedAtMs,
836
+ model: parsedModelEffective.canonical,
837
+ strategy: 'single',
838
+ chunkCount: 1,
839
+ report: finishReport,
840
+ costUsd,
841
+ color: verboseColor,
842
+ });
843
+ }
844
+ return;
845
+ }
846
+ if (!summaryAlreadyPrinted) {
847
+ clearProgressForStdout();
848
+ const rendered = (effectiveRenderMode === 'md' || effectiveRenderMode === 'md-live') && isRichTty(stdout)
849
+ ? renderMarkdownAnsi(summary, {
850
+ width: markdownRenderWidth(stdout, env),
851
+ wrap: true,
852
+ color: supportsColor(stdout, env),
853
+ })
854
+ : summary;
855
+ stdout.write(rendered);
856
+ if (!rendered.endsWith('\n')) {
857
+ stdout.write('\n');
858
+ }
859
+ }
860
+ const report = shouldComputeReport ? await buildReport() : null;
861
+ if (metricsDetailed && report)
862
+ writeMetricsReport(report);
863
+ if (metricsEnabled && report) {
864
+ const costUsd = await estimateCostUsd();
865
+ writeFinishLine({
866
+ stderr,
867
+ elapsedMs: Date.now() - runStartedAtMs,
868
+ model: parsedModelEffective.canonical,
869
+ strategy: 'single',
870
+ chunkCount: 1,
871
+ report,
872
+ costUsd,
873
+ color: verboseColor,
874
+ });
875
+ }
876
+ };
877
+ if (inputTarget.kind === 'file') {
878
+ let sizeLabel = null;
879
+ try {
880
+ const stat = await fs.stat(inputTarget.filePath);
881
+ if (stat.isFile()) {
882
+ sizeLabel = formatBytes(stat.size);
883
+ }
884
+ }
885
+ catch {
886
+ // Ignore size preflight; loadLocalAsset will throw a user-friendly error if needed.
887
+ }
888
+ const stopOscProgress = startOscProgress({
889
+ label: 'Loading file',
890
+ indeterminate: true,
891
+ env,
892
+ isTty: progressEnabled,
893
+ write: (data) => stderr.write(data),
894
+ });
895
+ const spinner = startSpinner({
896
+ text: sizeLabel ? `Loading file (${sizeLabel})…` : 'Loading file…',
897
+ enabled: progressEnabled,
898
+ stream: stderr,
899
+ });
900
+ let stopped = false;
901
+ const stopProgress = () => {
902
+ if (stopped)
903
+ return;
904
+ stopped = true;
905
+ spinner.stopAndClear();
906
+ stopOscProgress();
907
+ };
908
+ clearProgressBeforeStdout = stopProgress;
909
+ try {
910
+ const loaded = await loadLocalAsset({ filePath: inputTarget.filePath });
911
+ assertAssetMediaTypeSupported({ attachment: loaded.attachment, sizeLabel });
912
+ if (progressEnabled) {
913
+ const mt = loaded.attachment.mediaType;
914
+ const name = loaded.attachment.filename;
915
+ const details = sizeLabel ? `${mt}, ${sizeLabel}` : mt;
916
+ spinner.setText(name ? `Summarizing ${name} (${details})…` : `Summarizing ${details}…`);
917
+ }
918
+ await summarizeAsset({
919
+ sourceKind: 'file',
920
+ sourceLabel: loaded.sourceLabel,
921
+ attachment: loaded.attachment,
922
+ });
923
+ return;
924
+ }
925
+ finally {
926
+ if (clearProgressBeforeStdout === stopProgress) {
927
+ clearProgressBeforeStdout = null;
928
+ }
929
+ stopProgress();
930
+ }
931
+ }
932
+ if (url && !isYoutubeUrl) {
933
+ const kind = await classifyUrl({ url, fetchImpl: trackedFetch, timeoutMs });
934
+ if (kind.kind === 'asset') {
935
+ const stopOscProgress = startOscProgress({
936
+ label: 'Downloading file',
937
+ indeterminate: true,
938
+ env,
939
+ isTty: progressEnabled,
940
+ write: (data) => stderr.write(data),
941
+ });
942
+ const spinner = startSpinner({
943
+ text: 'Downloading file…',
944
+ enabled: progressEnabled,
945
+ stream: stderr,
946
+ });
947
+ let stopped = false;
948
+ const stopProgress = () => {
949
+ if (stopped)
950
+ return;
951
+ stopped = true;
952
+ spinner.stopAndClear();
953
+ stopOscProgress();
954
+ };
955
+ clearProgressBeforeStdout = stopProgress;
956
+ try {
957
+ const loaded = await (async () => {
958
+ try {
959
+ return await loadRemoteAsset({ url, fetchImpl: trackedFetch, timeoutMs });
960
+ }
961
+ catch (error) {
962
+ if (error instanceof Error && /HTML/i.test(error.message)) {
963
+ return null;
964
+ }
965
+ throw error;
966
+ }
967
+ })();
968
+ if (!loaded)
969
+ return;
970
+ assertAssetMediaTypeSupported({ attachment: loaded.attachment, sizeLabel: null });
971
+ if (progressEnabled)
972
+ spinner.setText('Summarizing…');
973
+ await summarizeAsset({
974
+ sourceKind: 'asset-url',
975
+ sourceLabel: loaded.sourceLabel,
976
+ attachment: loaded.attachment,
977
+ });
978
+ return;
979
+ }
980
+ finally {
981
+ if (clearProgressBeforeStdout === stopProgress) {
982
+ clearProgressBeforeStdout = null;
983
+ }
984
+ stopProgress();
985
+ }
986
+ }
987
+ }
988
+ if (!url) {
989
+ throw new Error('Only HTTP and HTTPS URLs can be summarized');
990
+ }
991
+ const firecrawlMode = (() => {
992
+ if (extractOnly && !isYoutubeUrl && !firecrawlExplicitlySet && firecrawlConfigured) {
993
+ return 'always';
994
+ }
995
+ return requestedFirecrawlMode;
996
+ })();
997
+ if (firecrawlMode === 'always' && !firecrawlConfigured) {
998
+ throw new Error('--firecrawl always requires FIRECRAWL_API_KEY');
999
+ }
1000
+ const effectiveMarkdownMode = markdownMode;
1001
+ const markdownRequested = extractOnly && !isYoutubeUrl && effectiveMarkdownMode !== 'off';
1002
+ const hasKeyForModel = parsedModelForLlm.provider === 'xai'
1003
+ ? xaiConfigured
1004
+ : parsedModelForLlm.provider === 'google'
1005
+ ? googleConfigured
1006
+ : parsedModelForLlm.provider === 'anthropic'
1007
+ ? anthropicConfigured
1008
+ : Boolean(apiKey);
1009
+ const markdownProvider = hasKeyForModel ? parsedModelForLlm.provider : 'none';
1010
+ if (markdownRequested && effectiveMarkdownMode === 'llm' && !hasKeyForModel) {
1011
+ const required = parsedModelForLlm.provider === 'xai'
1012
+ ? 'XAI_API_KEY'
1013
+ : parsedModelForLlm.provider === 'google'
1014
+ ? 'GEMINI_API_KEY (or GOOGLE_GENERATIVE_AI_API_KEY / GOOGLE_API_KEY)'
1015
+ : parsedModelForLlm.provider === 'anthropic'
1016
+ ? 'ANTHROPIC_API_KEY'
1017
+ : 'OPENAI_API_KEY';
1018
+ throw new Error(`--markdown llm requires ${required} for model ${parsedModelForLlm.canonical}`);
1019
+ }
1020
+ writeVerbose(stderr, verbose, `config url=${url} timeoutMs=${timeoutMs} youtube=${youtubeMode} firecrawl=${firecrawlMode} length=${lengthArg.kind === 'preset' ? lengthArg.preset : `${lengthArg.maxCharacters} chars`} maxOutputTokens=${formatOptionalNumber(maxOutputTokensArg)} json=${json} extractOnly=${extractOnly} markdown=${effectiveMarkdownMode} model=${model} stream=${effectiveStreamMode} render=${effectiveRenderMode}`, verboseColor);
1021
+ writeVerbose(stderr, verbose, `configFile path=${formatOptionalString(configPath)} model=${formatOptionalString(config?.model ?? null)}`, verboseColor);
1022
+ writeVerbose(stderr, verbose, `env xaiKey=${xaiConfigured} openaiKey=${Boolean(apiKey)} googleKey=${googleConfigured} anthropicKey=${anthropicConfigured} apifyToken=${Boolean(apifyToken)} firecrawlKey=${firecrawlConfigured}`, verboseColor);
1023
+ writeVerbose(stderr, verbose, `markdown requested=${markdownRequested} provider=${markdownProvider}`, verboseColor);
1024
+ const scrapeWithFirecrawl = firecrawlConfigured && firecrawlMode !== 'off'
1025
+ ? createFirecrawlScraper({ apiKey: firecrawlApiKey, fetchImpl: trackedFetch })
1026
+ : null;
1027
+ const convertHtmlToMarkdown = markdownRequested && (effectiveMarkdownMode === 'llm' || markdownProvider !== 'none')
1028
+ ? createHtmlToMarkdownConverter({
1029
+ modelId: model,
1030
+ xaiApiKey: xaiConfigured ? xaiApiKey : null,
1031
+ googleApiKey: googleConfigured ? googleApiKey : null,
1032
+ openaiApiKey: apiKey,
1033
+ anthropicApiKey: anthropicConfigured ? anthropicApiKey : null,
1034
+ fetchImpl: trackedFetch,
1035
+ onUsage: ({ model: usedModel, provider, usage }) => {
1036
+ llmCalls.push({ provider, model: usedModel, usage, purpose: 'markdown' });
1037
+ },
1038
+ })
1039
+ : null;
1040
+ writeVerbose(stderr, verbose, 'extract start', verboseColor);
1041
+ const stopOscProgress = startOscProgress({
1042
+ label: 'Fetching website',
1043
+ indeterminate: true,
1044
+ env,
1045
+ isTty: progressEnabled,
1046
+ write: (data) => stderr.write(data),
1047
+ });
1048
+ const spinner = startSpinner({
1049
+ text: 'Fetching website (connecting)…',
1050
+ enabled: progressEnabled,
1051
+ stream: stderr,
1052
+ });
1053
+ const websiteProgress = (() => {
1054
+ if (!progressEnabled)
1055
+ return null;
1056
+ const state = {
1057
+ phase: 'idle',
1058
+ htmlDownloadedBytes: 0,
1059
+ htmlTotalBytes: null,
1060
+ lastSpinnerUpdateAtMs: 0,
1061
+ };
1062
+ const updateSpinner = (text) => {
1063
+ const now = Date.now();
1064
+ if (now - state.lastSpinnerUpdateAtMs < 100)
1065
+ return;
1066
+ state.lastSpinnerUpdateAtMs = now;
1067
+ spinner.setText(text);
1068
+ };
1069
+ return {
1070
+ getHtmlDownloadedBytes: () => state.htmlDownloadedBytes,
1071
+ onProgress: (event) => {
1072
+ if (event.kind === 'fetch-html-start') {
1073
+ state.phase = 'fetching';
1074
+ state.htmlDownloadedBytes = 0;
1075
+ state.htmlTotalBytes = null;
1076
+ updateSpinner('Fetching website (connecting)…');
1077
+ return;
1078
+ }
1079
+ if (event.kind === 'fetch-html-progress' || event.kind === 'fetch-html-done') {
1080
+ state.phase = 'fetching';
1081
+ state.htmlDownloadedBytes = event.downloadedBytes;
1082
+ state.htmlTotalBytes = event.totalBytes;
1083
+ const downloaded = formatBytes(event.downloadedBytes);
1084
+ const total = typeof event.totalBytes === 'number' ? `/${formatBytes(event.totalBytes)}` : '';
1085
+ updateSpinner(`Fetching website (${downloaded}${total})…`);
1086
+ return;
1087
+ }
1088
+ if (event.kind === 'firecrawl-start') {
1089
+ state.phase = 'firecrawl';
1090
+ updateSpinner('Firecrawl: scraping…');
1091
+ return;
1092
+ }
1093
+ if (event.kind === 'firecrawl-done') {
1094
+ state.phase = 'firecrawl';
1095
+ if (event.ok && typeof event.markdownBytes === 'number') {
1096
+ updateSpinner(`Firecrawl: got ${formatBytes(event.markdownBytes)}…`);
1097
+ return;
1098
+ }
1099
+ updateSpinner('Firecrawl: no content; fallback…');
1100
+ }
1101
+ },
1102
+ };
1103
+ })();
1104
+ const client = createLinkPreviewClient({
1105
+ apifyApiToken: apifyToken,
1106
+ scrapeWithFirecrawl,
1107
+ convertHtmlToMarkdown,
1108
+ fetch: trackedFetch,
1109
+ onProgress: websiteProgress?.onProgress ?? null,
1110
+ });
1111
+ let stopped = false;
1112
+ const stopProgress = () => {
1113
+ if (stopped)
1114
+ return;
1115
+ stopped = true;
1116
+ spinner.stopAndClear();
1117
+ stopOscProgress();
1118
+ };
1119
+ clearProgressBeforeStdout = stopProgress;
1120
+ try {
1121
+ const extracted = await client.fetchLinkContent(url, {
1122
+ timeoutMs,
1123
+ youtubeTranscript: youtubeMode,
1124
+ firecrawl: firecrawlMode,
1125
+ format: markdownRequested ? 'markdown' : 'text',
1126
+ });
1127
+ const extractedContentBytes = Buffer.byteLength(extracted.content, 'utf8');
1128
+ const extractedContentSize = formatBytes(extractedContentBytes);
1129
+ const viaFirecrawl = extracted.diagnostics.firecrawl.used ? ', Firecrawl' : '';
1130
+ if (progressEnabled) {
1131
+ spinner.setText(extractOnly
1132
+ ? `Extracted (${extractedContentSize})`
1133
+ : `Summarizing (sent ${extractedContentSize}${viaFirecrawl})…`);
1134
+ }
1135
+ writeVerbose(stderr, verbose, `extract done strategy=${extracted.diagnostics.strategy} siteName=${formatOptionalString(extracted.siteName)} title=${formatOptionalString(extracted.title)} transcriptSource=${formatOptionalString(extracted.transcriptSource)}`, verboseColor);
1136
+ writeVerbose(stderr, verbose, `extract stats characters=${extracted.totalCharacters} words=${extracted.wordCount} transcriptCharacters=${formatOptionalNumber(extracted.transcriptCharacters)} transcriptLines=${formatOptionalNumber(extracted.transcriptLines)}`, verboseColor);
1137
+ writeVerbose(stderr, verbose, `extract firecrawl attempted=${extracted.diagnostics.firecrawl.attempted} used=${extracted.diagnostics.firecrawl.used} notes=${formatOptionalString(extracted.diagnostics.firecrawl.notes ?? null)}`, verboseColor);
1138
+ writeVerbose(stderr, verbose, `extract markdown requested=${extracted.diagnostics.markdown.requested} used=${extracted.diagnostics.markdown.used} provider=${formatOptionalString(extracted.diagnostics.markdown.provider ?? null)} notes=${formatOptionalString(extracted.diagnostics.markdown.notes ?? null)}`, verboseColor);
1139
+ writeVerbose(stderr, verbose, `extract transcript textProvided=${extracted.diagnostics.transcript.textProvided} provider=${formatOptionalString(extracted.diagnostics.transcript.provider ?? null)} attemptedProviders=${extracted.diagnostics.transcript.attemptedProviders.length > 0
1140
+ ? extracted.diagnostics.transcript.attemptedProviders.join(',')
1141
+ : 'none'} notes=${formatOptionalString(extracted.diagnostics.transcript.notes ?? null)}`, verboseColor);
1142
+ const isYouTube = extracted.siteName === 'YouTube';
1143
+ const prompt = buildLinkSummaryPrompt({
1144
+ url: extracted.url,
1145
+ title: extracted.title,
1146
+ siteName: extracted.siteName,
1147
+ description: extracted.description,
1148
+ content: extracted.content,
1149
+ truncated: false,
1150
+ hasTranscript: isYouTube ||
1151
+ (extracted.transcriptSource !== null && extracted.transcriptSource !== 'unavailable'),
1152
+ summaryLength: lengthArg.kind === 'preset' ? lengthArg.preset : { maxCharacters: lengthArg.maxCharacters },
1153
+ shares: [],
1154
+ });
1155
+ if (extractOnly) {
1156
+ clearProgressForStdout();
1157
+ if (json) {
1158
+ const finishReport = shouldComputeReport ? await buildReport() : null;
1159
+ const payload = {
1160
+ input: {
1161
+ kind: 'url',
1162
+ url,
1163
+ timeoutMs,
1164
+ youtube: youtubeMode,
1165
+ firecrawl: firecrawlMode,
1166
+ markdown: effectiveMarkdownMode,
1167
+ length: lengthArg.kind === 'preset'
1168
+ ? { kind: 'preset', preset: lengthArg.preset }
1169
+ : { kind: 'chars', maxCharacters: lengthArg.maxCharacters },
1170
+ maxOutputTokens: maxOutputTokensArg,
1171
+ model,
1172
+ },
1173
+ env: {
1174
+ hasXaiKey: Boolean(xaiApiKey),
1175
+ hasOpenAIKey: Boolean(apiKey),
1176
+ hasApifyToken: Boolean(apifyToken),
1177
+ hasFirecrawlKey: firecrawlConfigured,
1178
+ hasGoogleKey: googleConfigured,
1179
+ hasAnthropicKey: anthropicConfigured,
1180
+ },
1181
+ extracted,
1182
+ prompt,
1183
+ llm: null,
1184
+ metrics: metricsEnabled ? finishReport : null,
1185
+ summary: null,
1186
+ };
1187
+ if (metricsDetailed && finishReport) {
1188
+ writeMetricsReport(finishReport);
1189
+ }
1190
+ stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
1191
+ if (metricsEnabled && finishReport) {
1192
+ const costUsd = await estimateCostUsd();
1193
+ writeFinishLine({
1194
+ stderr,
1195
+ elapsedMs: Date.now() - runStartedAtMs,
1196
+ model,
1197
+ strategy: 'none',
1198
+ chunkCount: null,
1199
+ report: finishReport,
1200
+ costUsd,
1201
+ color: verboseColor,
1202
+ });
1203
+ }
1204
+ return;
1205
+ }
1206
+ stdout.write(`${extracted.content}\n`);
1207
+ const report = shouldComputeReport ? await buildReport() : null;
1208
+ if (metricsDetailed && report)
1209
+ writeMetricsReport(report);
1210
+ if (metricsEnabled && report) {
1211
+ const costUsd = await estimateCostUsd();
1212
+ writeFinishLine({
1213
+ stderr,
1214
+ elapsedMs: Date.now() - runStartedAtMs,
1215
+ model,
1216
+ strategy: 'none',
1217
+ chunkCount: null,
1218
+ report,
1219
+ costUsd,
1220
+ color: verboseColor,
1221
+ });
1222
+ }
1223
+ return;
1224
+ }
1225
+ const parsedModel = parseGatewayStyleModelId(model);
1226
+ const apiKeysForLlm = {
1227
+ xaiApiKey,
1228
+ openaiApiKey: apiKey,
1229
+ googleApiKey: googleConfigured ? googleApiKey : null,
1230
+ anthropicApiKey: anthropicConfigured ? anthropicApiKey : null,
1231
+ };
1232
+ const requiredKeyEnv = parsedModel.provider === 'xai'
1233
+ ? 'XAI_API_KEY'
1234
+ : parsedModel.provider === 'google'
1235
+ ? 'GEMINI_API_KEY (or GOOGLE_GENERATIVE_AI_API_KEY / GOOGLE_API_KEY)'
1236
+ : parsedModel.provider === 'anthropic'
1237
+ ? 'ANTHROPIC_API_KEY'
1238
+ : 'OPENAI_API_KEY';
1239
+ const hasRequiredKey = parsedModel.provider === 'xai'
1240
+ ? Boolean(xaiApiKey)
1241
+ : parsedModel.provider === 'google'
1242
+ ? googleConfigured
1243
+ : parsedModel.provider === 'anthropic'
1244
+ ? anthropicConfigured
1245
+ : Boolean(apiKey);
1246
+ if (!hasRequiredKey) {
1247
+ throw new Error(`Missing ${requiredKeyEnv} for model ${parsedModel.canonical}. Set the env var or choose a different --model.`);
1248
+ }
1249
+ const modelResolution = await resolveModelIdForLlmCall({
1250
+ parsedModel,
1251
+ apiKeys: { googleApiKey: apiKeysForLlm.googleApiKey },
1252
+ fetchImpl: trackedFetch,
1253
+ timeoutMs,
1254
+ });
1255
+ if (modelResolution.note && verbose) {
1256
+ writeVerbose(stderr, verbose, modelResolution.note, verboseColor);
1257
+ }
1258
+ const parsedModelEffective = parseGatewayStyleModelId(modelResolution.modelId);
1259
+ const streamingEnabledForCall = streamingEnabled && !modelResolution.forceStreamOff;
1260
+ writeVerbose(stderr, verbose, `mode summarize provider=${parsedModelEffective.provider} model=${parsedModelEffective.canonical}`, verboseColor);
1261
+ const maxOutputTokensForCall = await resolveMaxOutputTokensForCall(parsedModelEffective.canonical);
1262
+ const isLargeContent = extracted.content.length >= MAP_REDUCE_TRIGGER_CHARACTERS;
1263
+ let strategy = 'single';
1264
+ let chunkCount = 1;
1265
+ const shouldBufferSummaryForRender = streamingEnabledForCall && effectiveRenderMode === 'md' && isRichTty(stdout);
1266
+ const shouldLiveRenderSummary = streamingEnabledForCall && effectiveRenderMode === 'md-live' && isRichTty(stdout);
1267
+ const shouldStreamSummaryToStdout = streamingEnabledForCall && !shouldBufferSummaryForRender && !shouldLiveRenderSummary;
1268
+ let summaryAlreadyPrinted = false;
1269
+ let summary = '';
1270
+ let getLastStreamError = null;
1271
+ if (!isLargeContent) {
1272
+ writeVerbose(stderr, verbose, 'summarize strategy=single', verboseColor);
1273
+ if (streamingEnabledForCall) {
1274
+ writeVerbose(stderr, verbose, `summarize stream=on buffered=${shouldBufferSummaryForRender}`, verboseColor);
1275
+ let streamResult = null;
1276
+ try {
1277
+ streamResult = await streamTextWithModelId({
1278
+ modelId: parsedModelEffective.canonical,
1279
+ apiKeys: apiKeysForLlm,
1280
+ prompt,
1281
+ temperature: 0,
1282
+ maxOutputTokens: maxOutputTokensForCall ?? undefined,
1283
+ timeoutMs,
1284
+ fetchImpl: trackedFetch,
1285
+ });
1286
+ }
1287
+ catch (error) {
1288
+ if (parsedModelEffective.provider === 'google' &&
1289
+ isGoogleStreamingUnsupportedError(error)) {
1290
+ writeVerbose(stderr, verbose, `Google model ${parsedModelEffective.canonical} rejected streamGenerateContent; falling back to non-streaming.`, verboseColor);
1291
+ const result = await summarizeWithModelId({
1292
+ modelId: parsedModelEffective.canonical,
1293
+ prompt,
1294
+ maxOutputTokens: maxOutputTokensForCall ?? undefined,
1295
+ timeoutMs,
1296
+ fetchImpl: trackedFetch,
1297
+ apiKeys: apiKeysForLlm,
1298
+ });
1299
+ llmCalls.push({
1300
+ provider: result.provider,
1301
+ model: result.canonicalModelId,
1302
+ usage: result.usage,
1303
+ purpose: 'summary',
1304
+ });
1305
+ summary = result.text;
1306
+ streamResult = null;
1307
+ }
1308
+ else {
1309
+ throw error;
1310
+ }
1311
+ }
1312
+ if (streamResult) {
1313
+ getLastStreamError = streamResult.lastError;
1314
+ let streamed = '';
1315
+ const liveRenderer = shouldLiveRenderSummary
1316
+ ? createLiveRenderer({
1317
+ write: (chunk) => {
1318
+ clearProgressForStdout();
1319
+ stdout.write(chunk);
1320
+ },
1321
+ width: markdownRenderWidth(stdout, env),
1322
+ renderFrame: (markdown) => renderMarkdownAnsi(markdown, {
1323
+ width: markdownRenderWidth(stdout, env),
1324
+ wrap: true,
1325
+ color: supportsColor(stdout, env),
1326
+ }),
1327
+ })
1328
+ : null;
1329
+ let lastFrameAtMs = 0;
1330
+ try {
1331
+ let cleared = false;
1332
+ for await (const delta of streamResult.textStream) {
1333
+ streamed += delta;
1334
+ if (shouldStreamSummaryToStdout) {
1335
+ if (!cleared) {
1336
+ clearProgressForStdout();
1337
+ cleared = true;
1338
+ }
1339
+ stdout.write(delta);
1340
+ continue;
1341
+ }
1342
+ if (liveRenderer) {
1343
+ const now = Date.now();
1344
+ const due = now - lastFrameAtMs >= 120;
1345
+ const hasNewline = delta.includes('\n');
1346
+ if (hasNewline || due) {
1347
+ liveRenderer.render(streamed);
1348
+ lastFrameAtMs = now;
1349
+ }
1350
+ }
1351
+ }
1352
+ const trimmed = streamed.trim();
1353
+ streamed = trimmed;
1354
+ if (liveRenderer) {
1355
+ liveRenderer.render(trimmed);
1356
+ summaryAlreadyPrinted = true;
1357
+ }
1358
+ }
1359
+ finally {
1360
+ liveRenderer?.finish();
1361
+ }
1362
+ const usage = await streamResult.usage;
1363
+ llmCalls.push({
1364
+ provider: streamResult.provider,
1365
+ model: streamResult.canonicalModelId,
1366
+ usage,
1367
+ purpose: 'summary',
1368
+ });
1369
+ summary = streamed;
1370
+ if (shouldStreamSummaryToStdout) {
1371
+ if (!streamed.endsWith('\n')) {
1372
+ stdout.write('\n');
1373
+ }
1374
+ summaryAlreadyPrinted = true;
1375
+ }
1376
+ }
1377
+ }
1378
+ else {
1379
+ const result = await summarizeWithModelId({
1380
+ modelId: parsedModelEffective.canonical,
1381
+ prompt,
1382
+ maxOutputTokens: maxOutputTokensForCall ?? undefined,
1383
+ timeoutMs,
1384
+ fetchImpl: trackedFetch,
1385
+ apiKeys: apiKeysForLlm,
1386
+ });
1387
+ llmCalls.push({
1388
+ provider: result.provider,
1389
+ model: result.canonicalModelId,
1390
+ usage: result.usage,
1391
+ purpose: 'summary',
1392
+ });
1393
+ summary = result.text;
1394
+ }
1395
+ }
1396
+ else {
1397
+ strategy = 'map-reduce';
1398
+ const chunks = splitTextIntoChunks(extracted.content, MAP_REDUCE_CHUNK_CHARACTERS);
1399
+ chunkCount = chunks.length;
1400
+ stderr.write(`Large input (${extracted.content.length} chars); summarizing in ${chunks.length} chunks.\n`);
1401
+ writeVerbose(stderr, verbose, `summarize strategy=map-reduce chunks=${chunks.length}`, verboseColor);
1402
+ const chunkNotes = [];
1403
+ for (let i = 0; i < chunks.length; i += 1) {
1404
+ writeVerbose(stderr, verbose, `summarize chunk ${i + 1}/${chunks.length} notes start`, verboseColor);
1405
+ const chunkPrompt = buildChunkNotesPrompt({
1406
+ content: chunks[i] ?? '',
1407
+ });
1408
+ const chunkNoteTokensRequested = typeof maxOutputTokensArg === 'number'
1409
+ ? Math.min(SUMMARY_LENGTH_TO_TOKENS.medium, maxOutputTokensArg)
1410
+ : SUMMARY_LENGTH_TO_TOKENS.medium;
1411
+ const chunkNoteTokens = await capMaxOutputTokensForModel({
1412
+ modelId: parsedModelEffective.canonical,
1413
+ requested: chunkNoteTokensRequested,
1414
+ });
1415
+ const notesResult = await summarizeWithModelId({
1416
+ modelId: parsedModelEffective.canonical,
1417
+ prompt: chunkPrompt,
1418
+ maxOutputTokens: chunkNoteTokens,
1419
+ timeoutMs,
1420
+ fetchImpl: trackedFetch,
1421
+ apiKeys: apiKeysForLlm,
1422
+ });
1423
+ const notes = notesResult.text;
1424
+ llmCalls.push({
1425
+ provider: notesResult.provider,
1426
+ model: notesResult.canonicalModelId,
1427
+ usage: notesResult.usage,
1428
+ purpose: 'chunk-notes',
1429
+ });
1430
+ chunkNotes.push(notes.trim());
1431
+ }
1432
+ writeVerbose(stderr, verbose, 'summarize merge chunk notes', verboseColor);
1433
+ const mergedContent = `Chunk notes (generated from the full input):\n\n${chunkNotes
1434
+ .filter((value) => value.length > 0)
1435
+ .join('\n\n')}`;
1436
+ const mergedPrompt = buildLinkSummaryPrompt({
1437
+ url: extracted.url,
1438
+ title: extracted.title,
1439
+ siteName: extracted.siteName,
1440
+ description: extracted.description,
1441
+ content: mergedContent,
1442
+ truncated: false,
1443
+ hasTranscript: isYouTube ||
1444
+ (extracted.transcriptSource !== null && extracted.transcriptSource !== 'unavailable'),
1445
+ summaryLength: lengthArg.kind === 'preset'
1446
+ ? lengthArg.preset
1447
+ : { maxCharacters: lengthArg.maxCharacters },
1448
+ shares: [],
1449
+ });
1450
+ if (streamingEnabledForCall) {
1451
+ writeVerbose(stderr, verbose, `summarize stream=on buffered=${shouldBufferSummaryForRender}`, verboseColor);
1452
+ let streamResult = null;
1453
+ try {
1454
+ streamResult = await streamTextWithModelId({
1455
+ modelId: parsedModelEffective.canonical,
1456
+ apiKeys: apiKeysForLlm,
1457
+ prompt: mergedPrompt,
1458
+ temperature: 0,
1459
+ maxOutputTokens: maxOutputTokensForCall ?? undefined,
1460
+ timeoutMs,
1461
+ fetchImpl: trackedFetch,
1462
+ });
1463
+ }
1464
+ catch (error) {
1465
+ if (parsedModelEffective.provider === 'google' &&
1466
+ isGoogleStreamingUnsupportedError(error)) {
1467
+ writeVerbose(stderr, verbose, `Google model ${parsedModelEffective.canonical} rejected streamGenerateContent; falling back to non-streaming.`, verboseColor);
1468
+ const mergedResult = await summarizeWithModelId({
1469
+ modelId: parsedModelEffective.canonical,
1470
+ prompt: mergedPrompt,
1471
+ maxOutputTokens: maxOutputTokensForCall ?? undefined,
1472
+ timeoutMs,
1473
+ fetchImpl: trackedFetch,
1474
+ apiKeys: apiKeysForLlm,
1475
+ });
1476
+ llmCalls.push({
1477
+ provider: mergedResult.provider,
1478
+ model: mergedResult.canonicalModelId,
1479
+ usage: mergedResult.usage,
1480
+ purpose: 'summary',
1481
+ });
1482
+ summary = mergedResult.text;
1483
+ streamResult = null;
1484
+ }
1485
+ else {
1486
+ throw error;
1487
+ }
1488
+ }
1489
+ if (streamResult) {
1490
+ getLastStreamError = streamResult.lastError;
1491
+ let streamed = '';
1492
+ const liveRenderer = shouldLiveRenderSummary
1493
+ ? createLiveRenderer({
1494
+ write: (chunk) => {
1495
+ clearProgressForStdout();
1496
+ stdout.write(chunk);
1497
+ },
1498
+ width: markdownRenderWidth(stdout, env),
1499
+ renderFrame: (markdown) => renderMarkdownAnsi(markdown, {
1500
+ width: markdownRenderWidth(stdout, env),
1501
+ wrap: true,
1502
+ color: supportsColor(stdout, env),
1503
+ }),
1504
+ })
1505
+ : null;
1506
+ let lastFrameAtMs = 0;
1507
+ try {
1508
+ let cleared = false;
1509
+ for await (const delta of streamResult.textStream) {
1510
+ if (!cleared) {
1511
+ clearProgressForStdout();
1512
+ cleared = true;
1513
+ }
1514
+ streamed += delta;
1515
+ if (shouldStreamSummaryToStdout) {
1516
+ stdout.write(delta);
1517
+ continue;
1518
+ }
1519
+ if (liveRenderer) {
1520
+ const now = Date.now();
1521
+ const due = now - lastFrameAtMs >= 120;
1522
+ const hasNewline = delta.includes('\n');
1523
+ if (hasNewline || due) {
1524
+ liveRenderer.render(streamed);
1525
+ lastFrameAtMs = now;
1526
+ }
1527
+ }
1528
+ }
1529
+ const trimmed = streamed.trim();
1530
+ streamed = trimmed;
1531
+ if (liveRenderer) {
1532
+ liveRenderer.render(trimmed);
1533
+ summaryAlreadyPrinted = true;
1534
+ }
1535
+ }
1536
+ finally {
1537
+ liveRenderer?.finish();
1538
+ }
1539
+ const usage = await streamResult.usage;
1540
+ llmCalls.push({
1541
+ provider: streamResult.provider,
1542
+ model: streamResult.canonicalModelId,
1543
+ usage,
1544
+ purpose: 'summary',
1545
+ });
1546
+ summary = streamed;
1547
+ if (shouldStreamSummaryToStdout) {
1548
+ if (!streamed.endsWith('\n')) {
1549
+ stdout.write('\n');
1550
+ }
1551
+ summaryAlreadyPrinted = true;
1552
+ }
1553
+ }
1554
+ }
1555
+ else {
1556
+ const mergedResult = await summarizeWithModelId({
1557
+ modelId: parsedModelEffective.canonical,
1558
+ prompt: mergedPrompt,
1559
+ maxOutputTokens: maxOutputTokensForCall ?? undefined,
1560
+ timeoutMs,
1561
+ fetchImpl: trackedFetch,
1562
+ apiKeys: apiKeysForLlm,
1563
+ });
1564
+ llmCalls.push({
1565
+ provider: mergedResult.provider,
1566
+ model: mergedResult.canonicalModelId,
1567
+ usage: mergedResult.usage,
1568
+ purpose: 'summary',
1569
+ });
1570
+ summary = mergedResult.text;
1571
+ }
1572
+ }
1573
+ summary = summary.trim();
1574
+ if (summary.length === 0) {
1575
+ const last = getLastStreamError?.();
1576
+ if (last instanceof Error) {
1577
+ throw new Error(last.message, { cause: last });
1578
+ }
1579
+ throw new Error('LLM returned an empty summary');
1580
+ }
1581
+ if (json) {
1582
+ const finishReport = shouldComputeReport ? await buildReport() : null;
1583
+ const payload = {
1584
+ input: {
1585
+ kind: 'url',
1586
+ url,
1587
+ timeoutMs,
1588
+ youtube: youtubeMode,
1589
+ firecrawl: firecrawlMode,
1590
+ markdown: effectiveMarkdownMode,
1591
+ length: lengthArg.kind === 'preset'
1592
+ ? { kind: 'preset', preset: lengthArg.preset }
1593
+ : { kind: 'chars', maxCharacters: lengthArg.maxCharacters },
1594
+ maxOutputTokens: maxOutputTokensArg,
1595
+ model,
1596
+ },
1597
+ env: {
1598
+ hasXaiKey: Boolean(xaiApiKey),
1599
+ hasOpenAIKey: Boolean(apiKey),
1600
+ hasApifyToken: Boolean(apifyToken),
1601
+ hasFirecrawlKey: firecrawlConfigured,
1602
+ hasGoogleKey: googleConfigured,
1603
+ hasAnthropicKey: anthropicConfigured,
1604
+ },
1605
+ extracted,
1606
+ prompt,
1607
+ llm: {
1608
+ provider: parsedModelEffective.provider,
1609
+ model: parsedModelEffective.canonical,
1610
+ maxCompletionTokens: maxOutputTokensForCall,
1611
+ strategy,
1612
+ chunkCount,
1613
+ },
1614
+ metrics: metricsEnabled ? finishReport : null,
1615
+ summary,
1616
+ };
1617
+ if (metricsDetailed && finishReport) {
1618
+ writeMetricsReport(finishReport);
1619
+ }
1620
+ stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
1621
+ if (metricsEnabled && finishReport) {
1622
+ const costUsd = await estimateCostUsd();
1623
+ writeFinishLine({
1624
+ stderr,
1625
+ elapsedMs: Date.now() - runStartedAtMs,
1626
+ model: parsedModelEffective.canonical,
1627
+ strategy,
1628
+ chunkCount,
1629
+ report: finishReport,
1630
+ costUsd,
1631
+ color: verboseColor,
1632
+ });
1633
+ }
1634
+ return;
1635
+ }
1636
+ if (!summaryAlreadyPrinted) {
1637
+ clearProgressForStdout();
1638
+ const rendered = (effectiveRenderMode === 'md' || effectiveRenderMode === 'md-live') && isRichTty(stdout)
1639
+ ? renderMarkdownAnsi(summary, {
1640
+ width: markdownRenderWidth(stdout, env),
1641
+ wrap: true,
1642
+ color: supportsColor(stdout, env),
1643
+ })
1644
+ : summary;
1645
+ stdout.write(rendered);
1646
+ if (!rendered.endsWith('\n')) {
1647
+ stdout.write('\n');
1648
+ }
1649
+ }
1650
+ const report = shouldComputeReport ? await buildReport() : null;
1651
+ if (metricsDetailed && report)
1652
+ writeMetricsReport(report);
1653
+ if (metricsEnabled && report) {
1654
+ const costUsd = await estimateCostUsd();
1655
+ writeFinishLine({
1656
+ stderr,
1657
+ elapsedMs: Date.now() - runStartedAtMs,
1658
+ model: parsedModelEffective.canonical,
1659
+ strategy,
1660
+ chunkCount,
1661
+ report,
1662
+ costUsd,
1663
+ color: verboseColor,
1664
+ });
1665
+ }
1666
+ }
1667
+ finally {
1668
+ if (clearProgressBeforeStdout === stopProgress) {
1669
+ clearProgressBeforeStdout = null;
1670
+ }
1671
+ stopProgress();
1672
+ }
1673
+ }
1674
+ //# sourceMappingURL=run.js.map