gemcatch 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gemcatch",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Fire-and-forget CLI for Gemini's Interactions API background execution. Submit long-running research prompts, close your laptop, collect results later.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "index.js",
11
11
  "db.js",
12
12
  "gemini.js",
13
+ "sources.js",
13
14
  "status.js",
14
15
  "README.md",
15
16
  "LICENSE",
@@ -51,7 +52,7 @@
51
52
  "commander": "^15.0.0"
52
53
  },
53
54
  "allowScripts": {
54
- "@google/genai@2.16.0": true,
55
+ "@google/genai@2.20.0": true,
55
56
  "protobufjs@7.6.5": true
56
57
  }
57
58
  }
package/sources.js ADDED
@@ -0,0 +1,580 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const net = require('net');
5
+ const path = require('path');
6
+
7
+ // What a Deep Research agent reads besides the prompt: the tools it may call
8
+ // (the default web tools, remote MCP servers, File Search stores) and the files
9
+ // attached to the first turn.
10
+
11
+ // With no `tools` field the agent gets these three, and an explicit list is the
12
+ // whole set, so they are listed back in once any source flag is given. Code
13
+ // Execution stays under --no-web: it draws the charts and reads the CSVs.
14
+ const WEB_TOOLS = Object.freeze([{ type: 'google_search' }, { type: 'url_context' }]);
15
+ const CODE_TOOL = Object.freeze({ type: 'code_execution' });
16
+
17
+ // Input types the Interactions API documents for these content blocks
18
+ // (DocumentContent and ImageContent). Anything else is refused up front rather
19
+ // than discovered as a 400 after the spend was confirmed.
20
+ const MIME_BY_EXT = Object.freeze({
21
+ '.pdf': ['document', 'application/pdf'],
22
+ '.csv': ['document', 'text/csv'],
23
+ '.png': ['image', 'image/png'],
24
+ '.jpg': ['image', 'image/jpeg'],
25
+ '.jpeg': ['image', 'image/jpeg'],
26
+ '.webp': ['image', 'image/webp'],
27
+ '.heic': ['image', 'image/heic'],
28
+ '.heif': ['image', 'image/heif'],
29
+ '.gif': ['image', 'image/gif'],
30
+ '.bmp': ['image', 'image/bmp'],
31
+ });
32
+ const TYPE_BY_MIME = Object.freeze(
33
+ Object.fromEntries(Object.values(MIME_BY_EXT).map(([type, mime]) => [mime, type]))
34
+ );
35
+ const SUPPORTED = Object.keys(MIME_BY_EXT).join(' ');
36
+ const EXPORT_AS = Object.freeze({
37
+ '.txt': 'PDF',
38
+ '.md': 'PDF',
39
+ '.rtf': 'PDF',
40
+ '.doc': 'PDF',
41
+ '.docx': 'PDF',
42
+ '.odt': 'PDF',
43
+ '.ppt': 'PDF',
44
+ '.pptx': 'PDF',
45
+ '.html': 'PDF',
46
+ '.htm': 'PDF',
47
+ '.xls': 'CSV',
48
+ '.xlsx': 'CSV',
49
+ '.ods': 'CSV',
50
+ '.tsv': 'CSV',
51
+ });
52
+
53
+ // file-input-methods: inline data is capped per request, not per file, at
54
+ // 100 MB, or 50 MB once a PDF is in it. Decimal megabytes, the smaller reading.
55
+ const INLINE_LIMIT = 100 * 1000 * 1000;
56
+ const PDF_INLINE_LIMIT = 50 * 1000 * 1000;
57
+ // Files API: 2 GB per file, kept for 48 hours.
58
+ const UPLOAD_LIMIT = 2 * 1024 * 1024 * 1024;
59
+ // Room left in the inline budget for the prompt and the JSON around the blocks.
60
+ const INLINE_HEADROOM = 1000 * 1000;
61
+ const UPLOAD_TTL_MS = 48 * 3600 * 1000;
62
+
63
+ const MASK = '***';
64
+
65
+ function fail(message) {
66
+ const e = new Error(message);
67
+ e.code = 'BAD_SOURCE';
68
+ return e;
69
+ }
70
+
71
+ // --- MCP ------------------------------------------------------------------
72
+
73
+ // --mcp-name/--mcp-header/--mcp-allow modify the --mcp before them, so the
74
+ // four parsers share one list and see the flags in argv order. A modifier with
75
+ // no --mcp before it is remembered and reported by resolve(), never thrown from
76
+ // a parser: commander would echo the argument, which may be a secret header.
77
+ function mcpOptionParsers() {
78
+ const servers = [];
79
+ const state = { servers, orphan: null };
80
+ const onLast = (flag, apply) => (value) => {
81
+ const last = servers[servers.length - 1];
82
+ if (last) apply(last, value);
83
+ else state.orphan = state.orphan || flag;
84
+ return state;
85
+ };
86
+ return {
87
+ mcp: (url) => {
88
+ servers.push({ url, name: null, headers: [], allow: [] });
89
+ return state;
90
+ },
91
+ name: onLast('--mcp-name', (s, v) => (s.name = v)),
92
+ header: onLast('--mcp-header', (s, v) => s.headers.push(v)),
93
+ allow: onLast('--mcp-allow', (s, v) => s.allow.push(v)),
94
+ };
95
+ }
96
+
97
+ function parseHeader(raw) {
98
+ const i = raw.indexOf(':');
99
+ const name = i > 0 ? raw.slice(0, i).trim() : '';
100
+ const value = i > 0 ? raw.slice(i + 1).trim() : '';
101
+ const valid = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name);
102
+ if (!valid || !value) {
103
+ // Only a valid header name is ever echoed: anything else may be a token.
104
+ const got = valid ? `got '${name}: ...'` : 'got no header name before a colon';
105
+ throw fail(`--mcp-header needs 'Name: value' (${got})`);
106
+ }
107
+ return [name, value];
108
+ }
109
+
110
+ function parseHeaders(list) {
111
+ const headers = {};
112
+ const seen = new Set();
113
+ for (const [name, value] of list.map(parseHeader)) {
114
+ if (seen.has(name.toLowerCase())) throw fail(`--mcp-header ${name} is given twice for one --mcp`);
115
+ seen.add(name.toLowerCase());
116
+ headers[name] = value;
117
+ }
118
+ return headers;
119
+ }
120
+
121
+ // ${NAME} in a header value is read from the environment when a turn is sent,
122
+ // so the store only ever holds the reference.
123
+ const ENV_REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
124
+
125
+ function expandEnv(value, onMissing) {
126
+ return String(value).replace(ENV_REF, (ref, name) => process.env[name] || onMissing(name, ref));
127
+ }
128
+
129
+ // The tools as they go on the wire. Throws in one line naming a variable that
130
+ // is not set, so callers run it before the spend guard.
131
+ function withEnv(tools) {
132
+ if (!Array.isArray(tools)) return tools;
133
+ return tools.map((t) => {
134
+ if (!t || t.type !== 'mcp_server' || !t.headers) return t;
135
+ const headers = {};
136
+ for (const [k, v] of Object.entries(t.headers)) {
137
+ headers[k] = expandEnv(v, (name) => {
138
+ throw fail(`--mcp-header ${k} for ${t.name} reads \${${name}}, which is not set`);
139
+ });
140
+ }
141
+ return { ...t, headers };
142
+ });
143
+ }
144
+
145
+ function urlOrNull(raw) {
146
+ try {
147
+ return new URL(raw);
148
+ } catch (_) {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ function decoded(s) {
154
+ try {
155
+ return decodeURIComponent(s);
156
+ } catch (_) {
157
+ return s;
158
+ }
159
+ }
160
+
161
+ // A URL with its user, password, query and fragment masked, for anything
162
+ // printed or stored in a listing.
163
+ function shownUrl(raw) {
164
+ const url = urlOrNull(raw);
165
+ if (!url) return String(raw).replace(/\/\/[^/]*@/, `//${MASK}@`).replace(/([?#]).*$/, `$1${MASK}`);
166
+ if (!url.username && !url.password && !url.search && !url.hash) return raw;
167
+ const mark = 'GEMCATCHMASKED';
168
+ if (url.username) url.username = mark;
169
+ if (url.password) url.password = mark;
170
+ if (url.search) url.search = mark;
171
+ if (url.hash) url.hash = mark;
172
+ return url.toString().split(mark).join(MASK);
173
+ }
174
+
175
+ // The parts of a URL that may carry a credential, raw and decoded.
176
+ function urlSecrets(raw) {
177
+ const url = urlOrNull(raw);
178
+ if (!url) return [];
179
+ // A fragment can carry key=value pairs too (#access_token=...).
180
+ const values = (s) => s.split('&').map((p) => p.slice(p.indexOf('=') + 1));
181
+ return [url.username, url.password, ...values(url.search.slice(1)), ...values(url.hash.slice(1))]
182
+ .flatMap((p) => [p, decoded(p), decoded(p.replace(/\+/g, ' '))])
183
+ .filter(Boolean);
184
+ }
185
+
186
+ function parseUrl(s) {
187
+ const url = urlOrNull(s.url);
188
+ if (!url) throw fail(`--mcp ${shownUrl(s.url)}: not a URL`);
189
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') throw fail(`--mcp ${shownUrl(s.url)}: must be an http(s) URL`);
190
+ return url;
191
+ }
192
+
193
+ function mcpTool(s, name) {
194
+ const tool = { type: 'mcp_server', name, url: s.url };
195
+ if (s.headers.length) tool.headers = parseHeaders(s.headers);
196
+ const allowed = s.allow.flatMap((v) => v.split(',')).map((t) => t.trim()).filter(Boolean);
197
+ if (s.allow.length && !allowed.length) throw fail(`--mcp-allow for ${name} lists no tools`);
198
+ // The API takes allowed_tools as a list of {mode?, tools} objects, not bare names.
199
+ if (allowed.length) tool.allowed_tools = [{ tools: allowed }];
200
+ return tool;
201
+ }
202
+
203
+ // Explicit --mcp-name values are taken first, so a name derived from the host
204
+ // steps around them (host-2, host-3) instead of colliding.
205
+ function mcpNames(servers) {
206
+ const taken = new Set();
207
+ for (const s of servers) {
208
+ if (!s.name) continue;
209
+ if (taken.has(s.name)) throw fail(`--mcp-name ${s.name} is used twice`);
210
+ taken.add(s.name);
211
+ }
212
+ return servers.map((s) => {
213
+ const host = parseUrl(s).hostname;
214
+ if (s.name) return s.name;
215
+ let name = host;
216
+ for (let n = 2; taken.has(name); n += 1) name = `${host}-${n}`;
217
+ taken.add(name);
218
+ return name;
219
+ });
220
+ }
221
+
222
+ function privateV4(ip) {
223
+ const [a, b] = ip.split('.').map(Number);
224
+ return (
225
+ a === 0 ||
226
+ a === 10 ||
227
+ a === 127 ||
228
+ (a === 100 && b >= 64 && b <= 127) ||
229
+ (a === 169 && b === 254) ||
230
+ (a === 172 && b >= 16 && b <= 31) ||
231
+ (a === 192 && b === 168)
232
+ );
233
+ }
234
+
235
+ // Google's servers call the MCP URL, not this machine, so a local address can
236
+ // never be reached. Worth a line, not a refusal: a tunnel may map it.
237
+ function unreachableFromGoogle(raw) {
238
+ const host = new URL(raw).hostname.replace(/^\[|\]$/g, '').toLowerCase();
239
+ if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true;
240
+ const kind = net.isIP(host);
241
+ if (kind === 4) return privateV4(host);
242
+ if (kind !== 6) return false;
243
+ // The URL parser writes ::ffff:127.0.0.1 as ::ffff:7f00:1.
244
+ const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
245
+ if (mapped) {
246
+ const [hi, lo] = mapped.slice(1).map((h) => parseInt(h, 16));
247
+ return privateV4([hi >> 8, hi & 255, lo >> 8, lo & 255].join('.'));
248
+ }
249
+ return host === '::' || host === '::1' || /^f[cd][0-9a-f]{2}:/.test(host) || /^fe[89ab][0-9a-f]:/.test(host);
250
+ }
251
+
252
+ // --- tools ----------------------------------------------------------------
253
+
254
+ function fileSearchName(store) {
255
+ const s = store.trim();
256
+ if (!s) throw fail('--file-search needs a store name');
257
+ return s.includes('/') ? s : `fileSearchStores/${s}`;
258
+ }
259
+
260
+ // The `tools` array for the request, or undefined when no source flag was given
261
+ // so a plain agent run keeps sending no tools field at all.
262
+ function buildTools(servers, stores, web) {
263
+ if (!servers.length && !stores.length && web !== false) return undefined;
264
+ const names = mcpNames(servers);
265
+ const tools = web === false ? [CODE_TOOL] : WEB_TOOLS.concat(CODE_TOOL);
266
+ const out = tools.map((t) => ({ ...t })).concat(servers.map((s, i) => mcpTool(s, names[i])));
267
+ if (stores.length) out.push({ type: 'file_search', file_search_store_names: stores.map(fileSearchName) });
268
+ return out;
269
+ }
270
+
271
+ // A copy of a tools array that is safe to print or store in a listing.
272
+ function redactTools(tools) {
273
+ if (!Array.isArray(tools)) return tools;
274
+ return tools.map((t) => {
275
+ if (!t || t.type !== 'mcp_server') return t;
276
+ const shown = { ...t, url: shownUrl(t.url) };
277
+ if (t.headers && typeof t.headers === 'object') {
278
+ shown.headers = Object.fromEntries(Object.keys(t.headers).map((k) => [k, MASK]));
279
+ }
280
+ return shown;
281
+ });
282
+ }
283
+
284
+ function redactToolsJson(raw) {
285
+ if (!raw) return raw;
286
+ try {
287
+ return JSON.stringify(redactTools(JSON.parse(raw)));
288
+ } catch (_) {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ // A header value as sent, the credential in a 'Bearer <token>' shape, and the
294
+ // value of each ${VAR} it reads.
295
+ function headerSecrets(value) {
296
+ const v = expandEnv(value, (_, ref) => ref);
297
+ const space = v.indexOf(' ');
298
+ const vars = [...String(value).matchAll(ENV_REF)].map((m) => process.env[m[1]]).filter(Boolean);
299
+ return [v, ...(space > 0 ? [v.slice(space + 1).trim()] : []), ...vars];
300
+ }
301
+
302
+ // Header values and URL credentials, longest first so a value is scrubbed whole
303
+ // before any shorter piece of it. Anything shorter than 8 characters is left
304
+ // alone: scrubbing a value like "1" or "json" would garble the text around it.
305
+ function secrets(tools, urls) {
306
+ const found = (Array.isArray(tools) ? tools : [])
307
+ .filter((t) => t && t.type === 'mcp_server')
308
+ .flatMap((t) => [...Object.values(t.headers || {}).flatMap(headerSecrets), ...urlSecrets(t.url)])
309
+ .concat((urls || []).flatMap(urlSecrets));
310
+ return [...new Set(found.map(String))].filter((v) => v.length >= 8).sort((a, b) => b.length - a.length);
311
+ }
312
+
313
+ const URL_IN_TEXT = /(?:https?|ftp):\/\/[^\s"'<>\\]*[^\s"'<>\\.,;:!?)\]}*]/gi;
314
+
315
+ // Scrubs secrets out of text that is about to be printed or stored, such as an
316
+ // API error that quotes the request back: plain, JSON-escaped and URL-encoded.
317
+ // `urls` are attachment URLs, which may carry a signed query. A stored one is
318
+ // already masked, so any URL in the text naming the same resource is masked too.
319
+ function redactText(text, tools, urls) {
320
+ let s = String(text);
321
+ for (const v of secrets(tools, urls)) {
322
+ for (const form of new Set([v, JSON.stringify(v).slice(1, -1), encodeURIComponent(v)])) s = s.split(form).join(MASK);
323
+ }
324
+ const known = (urls || []).map(urlOrNull).filter(Boolean);
325
+ const same = (u) => u && known.some((k) => k.host === u.host && k.pathname === u.pathname);
326
+ return known.length ? s.replace(URL_IN_TEXT, (u) => (same(urlOrNull(u)) ? shownUrl(u) : u)) : s;
327
+ }
328
+
329
+ // --- attachments ----------------------------------------------------------
330
+
331
+ function typeFromExt(name) {
332
+ return MIME_BY_EXT[path.extname(name).toLowerCase()] || null;
333
+ }
334
+
335
+ // The docs' own document example (arxiv.org/pdf/1706.03762) has no extension,
336
+ // so an https URL without one is asked for its Content-Type. `fetchImpl` is
337
+ // injectable for the offline suite.
338
+ async function sniffUrl(spec, fetchImpl) {
339
+ const ask = (init) => (fetchImpl || fetch)(spec, { redirect: 'follow', signal: AbortSignal.timeout(15000), ...init });
340
+ const refuse = (why) => fail(`--attach ${shownUrl(spec)}: can't tell its file type (${why}); supported: ${SUPPORTED}`);
341
+ let res;
342
+ try {
343
+ res = await ask({ method: 'HEAD' });
344
+ // Some servers refuse HEAD; the first byte of a GET carries the same header.
345
+ if (res.status === 403 || res.status === 405) {
346
+ res = await ask({ method: 'GET', headers: { Range: 'bytes=0-0' } });
347
+ if (res.body && typeof res.body.cancel === 'function') res.body.cancel().catch(() => {});
348
+ }
349
+ } catch (err) {
350
+ throw refuse(`could not reach it: ${redactText(err.message, [], [spec])}`);
351
+ }
352
+ const mime = String((res.headers && res.headers.get('content-type')) || '').split(';')[0].trim().toLowerCase();
353
+ const type = TYPE_BY_MIME[mime];
354
+ if (!res.ok) throw refuse(`HTTP ${res.status}`);
355
+ if (!type) throw refuse(`the server says ${mime || 'nothing'}`);
356
+ return [type, mime];
357
+ }
358
+
359
+ function unsupported(spec) {
360
+ const ext = path.extname(spec).toLowerCase();
361
+ const hint = EXPORT_AS[ext] ? `; export it as ${EXPORT_AS[ext]} first` : '';
362
+ return fail(`--attach ${spec}: unsupported file type '${ext || '(none)'}'${hint}; supported: ${SUPPORTED}`);
363
+ }
364
+
365
+ // Resolves every --attach into an entry, in order, without reading any file
366
+ // content: {source, type, mime_type, via: 'url'|'inline'|'upload', bytes?, path?}.
367
+ // Local files go inline, in order, while the running base64 total stays under
368
+ // the request limit (the PDF limit once a PDF is in it). The first file that
369
+ // would cross it, and every local file after it, is uploaded instead. A file
370
+ // or URL given twice is attached once.
371
+ async function planAttachments(specs, fetchImpl) {
372
+ const files = [];
373
+ const seen = new Set();
374
+ let inlineTotal = 0;
375
+ let pdfInline = false;
376
+ let spilled = false;
377
+ for (const spec of specs) {
378
+ const isUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(spec);
379
+ const resolved = isUrl ? spec : path.resolve(spec);
380
+ const key = !isUrl && process.platform === 'win32' ? resolved.toLowerCase() : resolved;
381
+ if (seen.has(key)) continue;
382
+ seen.add(key);
383
+ if (/^https:\/\//i.test(spec)) {
384
+ const url = urlOrNull(spec);
385
+ if (!url) throw fail(`--attach ${shownUrl(spec)}: not a URL`);
386
+ const [type, mime] = typeFromExt(url.pathname) || (await sniffUrl(spec, fetchImpl));
387
+ files.push({ source: spec, type, mime_type: mime, via: 'url' });
388
+ continue;
389
+ }
390
+ if (isUrl) throw fail(`--attach ${shownUrl(spec)}: only local files and https URLs are supported`);
391
+ let st;
392
+ try {
393
+ st = fs.statSync(spec);
394
+ } catch (_) {
395
+ throw fail(`--attach ${spec}: no such file`);
396
+ }
397
+ if (!st.isFile()) throw fail(`--attach ${spec}: not a file`);
398
+ if (!st.size) throw fail(`--attach ${spec}: the file is empty`);
399
+ const known = typeFromExt(spec);
400
+ if (!known) throw unsupported(spec);
401
+ const [type, mime] = known;
402
+ if (st.size > UPLOAD_LIMIT) throw fail(`--attach ${spec}: ${size(st.size)} is over the Files API's 2 GB per-file limit`);
403
+ const encoded = 4 * Math.ceil(st.size / 3);
404
+ const isPdf = mime === 'application/pdf';
405
+ const limit = pdfInline || isPdf ? PDF_INLINE_LIMIT : INLINE_LIMIT;
406
+ const inline = !spilled && inlineTotal + encoded <= limit - INLINE_HEADROOM;
407
+ if (inline) {
408
+ inlineTotal += encoded;
409
+ pdfInline = pdfInline || isPdf;
410
+ } else {
411
+ spilled = true;
412
+ }
413
+ files.push({ source: spec, path: resolved, type, mime_type: mime, via: inline ? 'inline' : 'upload', bytes: st.size });
414
+ }
415
+ return files;
416
+ }
417
+
418
+ // Turns the planned attachments into request content blocks, reading inline
419
+ // files and uploading the rest through `upload(path, mime) -> {uri, expiresAt}`.
420
+ // Returns the blocks for the request and the record kept in the store (no file
421
+ // bytes, and URLs masked).
422
+ async function materialize(files, upload, onUpload) {
423
+ const items = [];
424
+ const record = [];
425
+ for (const a of files) {
426
+ const rec = { source: a.via === 'url' ? shownUrl(a.source) : a.source, type: a.type, mime_type: a.mime_type, via: a.via };
427
+ if (a.bytes != null) rec.bytes = a.bytes;
428
+ // The inline budget was worked out from the size at planning time.
429
+ const changed = () => fail(`--attach ${a.source}: the file changed after it was checked; run again`);
430
+ if (a.via === 'url') {
431
+ items.push({ type: a.type, uri: a.source, mime_type: a.mime_type });
432
+ } else if (a.via === 'inline') {
433
+ let buf;
434
+ try {
435
+ buf = fs.readFileSync(a.path);
436
+ } catch (err) {
437
+ throw fail(`--attach ${a.source}: could not read it (${err.message})`);
438
+ }
439
+ if (buf.length !== a.bytes) throw changed();
440
+ items.push({ type: a.type, data: buf.toString('base64'), mime_type: a.mime_type });
441
+ } else {
442
+ const now = fs.statSync(a.path, { throwIfNoEntry: false });
443
+ if (!now || now.size !== a.bytes) throw changed();
444
+ if (onUpload) onUpload(a);
445
+ const f = await upload(a.path, a.mime_type);
446
+ rec.uri = f.uri;
447
+ rec.expires_at = f.expiresAt || Date.now() + UPLOAD_TTL_MS;
448
+ items.push({ type: a.type, uri: f.uri, mime_type: a.mime_type });
449
+ }
450
+ record.push(rec);
451
+ }
452
+ return { items, record };
453
+ }
454
+
455
+ // A batch sends the same attachments with every prompt, and inline bytes would
456
+ // go over the wire once per prompt, so there every local file is uploaded once.
457
+ function uploadAll(files) {
458
+ return files.map((a) => (a.via === 'inline' ? { ...a, via: 'upload' } : a));
459
+ }
460
+
461
+ // --- resolve --------------------------------------------------------------
462
+
463
+ // All four MCP parsers return the shared state, so it lands under whichever of
464
+ // them appeared on the command line.
465
+ function mcpState(opts) {
466
+ return opts.mcp || opts.mcpName || opts.mcpHeader || opts.mcpAllow || null;
467
+ }
468
+
469
+ // Every source flag, in the order the one-line "needs --agent" error names them.
470
+ function givenFlags(opts) {
471
+ const st = mcpState(opts);
472
+ const flags = [];
473
+ if (st && st.servers.length) flags.push('--mcp');
474
+ if (st && st.orphan) flags.push(st.orphan);
475
+ if (opts.fileSearch && opts.fileSearch.length) flags.push('--file-search');
476
+ if (opts.web === false) flags.push('--no-web');
477
+ if (opts.attach && opts.attach.length) flags.push('--attach');
478
+ if (opts.visualize) flags.push('--visualize');
479
+ return flags;
480
+ }
481
+
482
+ function mcpWarnings(tools) {
483
+ const warnings = [];
484
+ for (const t of tools || []) {
485
+ if (t.type !== 'mcp_server') continue;
486
+ const shown = `--mcp ${shownUrl(t.url)}`;
487
+ if (unreachableFromGoogle(t.url)) warnings.push(`${shown}: Google's servers make this call, so a local address will not be reachable`);
488
+ if (/^http:/i.test(t.url) && (t.headers || urlSecrets(t.url).length)) {
489
+ warnings.push(`${shown}: plain http, so its credentials cross the network unencrypted`);
490
+ }
491
+ }
492
+ return warnings;
493
+ }
494
+
495
+ // Validates the source flags against each other and returns what the request
496
+ // needs: {tools, files, visualization, warnings}. Called before the spend guard,
497
+ // so a bad flag costs nothing and writes nothing.
498
+ async function resolve(opts, agent, fetchImpl) {
499
+ const flags = givenFlags(opts);
500
+ if (!flags.length) return { tools: undefined, files: [], visualization: undefined, warnings: [] };
501
+ if (!agent) {
502
+ throw fail(`${flags[0]} needs --agent: tools, attachments and visualization are Deep Research agent features (try --agent deep-research)`);
503
+ }
504
+ const st = mcpState(opts) || { servers: [], orphan: null };
505
+ if (st.orphan) throw fail(`${st.orphan} applies to the --mcp before it, and there is none`);
506
+ const stores = opts.fileSearch || [];
507
+ const specs = opts.attach || [];
508
+ if (opts.web === false && !st.servers.length && !stores.length && !specs.length) {
509
+ throw fail('--no-web leaves the agent nothing to read: add --mcp, --file-search or --attach');
510
+ }
511
+ const tools = buildTools(st.servers, stores, opts.web);
512
+ withEnv(tools);
513
+ const files = await planAttachments(specs, fetchImpl);
514
+ return { tools, files, visualization: opts.visualize ? 'auto' : undefined, warnings: mcpWarnings(tools) };
515
+ }
516
+
517
+ // --- describing -----------------------------------------------------------
518
+
519
+ function size(bytes) {
520
+ return bytes < 1e6 ? `${Math.max(1, Math.round(bytes / 1e3))} KB` : `${(bytes / 1e6).toFixed(1)} MB`;
521
+ }
522
+
523
+ function describeTool(t) {
524
+ if (t.type === 'mcp_server') {
525
+ const hs = t.headers ? ` (${Object.keys(t.headers).map((k) => `${k}: ${MASK}`).join(', ')})` : '';
526
+ const allow = t.allowed_tools ? ` [${t.allowed_tools.flatMap((a) => a.tools || []).join(', ')}]` : '';
527
+ return `MCP ${t.name} ${shownUrl(t.url)}${hs}${allow}`;
528
+ }
529
+ if (t.type === 'file_search') return `File Search ${t.file_search_store_names.join(', ')}`;
530
+ return t.type;
531
+ }
532
+
533
+ function describeAttachment(a) {
534
+ return a.via === 'url' ? `${shownUrl(a.source)} (url)` : `${a.source} (${a.via}, ${size(a.bytes)})`;
535
+ }
536
+
537
+ // `src` below is what resolve() returns, or the same shape rebuilt from a
538
+ // stored row for a later turn (tools and visualization, no files).
539
+
540
+ // Lines for the spend confirmation and --dry-run. Header values never appear.
541
+ function describe(src) {
542
+ const lines = [];
543
+ if (src.tools) lines.push(`Tools: ${src.tools.map(describeTool).join('; ')}`);
544
+ if (src.files.length) lines.push(`Attachments: ${src.files.map(describeAttachment).join('; ')}`);
545
+ if (src.visualization) lines.push('Visualization: auto (charts are saved as image files)');
546
+ return lines;
547
+ }
548
+
549
+ // The same, as extra keys for a --dry-run --json payload. Empty for a run with
550
+ // no sources.
551
+ function preview(src) {
552
+ const fields = {};
553
+ if (src.tools) fields.tools = redactTools(src.tools);
554
+ if (src.files.length) {
555
+ fields.attachments = src.files.map(({ path: _local, ...a }) => (a.via === 'url' ? { ...a, source: shownUrl(a.source) } : a));
556
+ }
557
+ if (src.visualization) fields.visualization = src.visualization;
558
+ return fields;
559
+ }
560
+
561
+ module.exports = {
562
+ INLINE_LIMIT,
563
+ PDF_INLINE_LIMIT,
564
+ INLINE_HEADROOM,
565
+ mcpOptionParsers,
566
+ buildTools,
567
+ withEnv,
568
+ planAttachments,
569
+ materialize,
570
+ uploadAll,
571
+ resolve,
572
+ redactTools,
573
+ redactToolsJson,
574
+ redactText,
575
+ shownUrl,
576
+ unreachableFromGoogle,
577
+ describe,
578
+ preview,
579
+ size,
580
+ };