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/CHANGELOG.md CHANGED
@@ -7,6 +7,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-09-24
11
+
12
+ ### Added
13
+
14
+ - **Research over your own data.** A Deep Research agent could only read the
15
+ public web through gemcatch, while the API has always let it read much more.
16
+ Five new flags on `research` and `batch` open that up, listed under their own
17
+ heading in `--help`. All of them need `--agent`; without it gemcatch exits
18
+ with one line naming the flag and writes nothing.
19
+
20
+ - `--attach <path|url>` (repeatable) gives the agent a PDF, CSV or image.
21
+ Local files go inline, in order, while the request stays under the API's
22
+ inline limit (100 MB, or 50 MB once a PDF is in it). The file that would
23
+ cross it, and every one after, is uploaded through the Files API and sent by
24
+ URI. Uploads expire after 48 hours, and a `--plan` run prints when. An https
25
+ URL is sent by reference, its query string masked everywhere but the wire,
26
+ and one without a supported extension is typed from a HEAD request (a
27
+ one-byte GET if HEAD is refused). Unknown types, missing and empty files are refused in one
28
+ line before the spend confirmation, with a hint to export Word files as PDF
29
+ and spreadsheets as CSV. A file that changes size between that check and
30
+ the send is refused too. A `batch` of more than one prompt uploads every
31
+ local file once instead of sending it inline with each prompt.
32
+ - `--mcp <url>` (repeatable) adds a remote MCP server, named after its host
33
+ unless `--mcp-name` says otherwise. `--mcp-header 'Name: value'` and
34
+ `--mcp-allow tool,tool` apply to the `--mcp` before them. Header values
35
+ and URL credentials are masked in the confirmation, `--dry-run`,
36
+ `list --json`, `get --raw` and API errors. A header value can read
37
+ `${VAR}` from the environment at send time, so the token never reaches
38
+ `tasks.db`; a value written out in full is kept there, since a later turn
39
+ has to resend it. On POSIX systems the data directory and the images
40
+ folder are now 0700, and `tasks.db` and each image 0600. A local or
41
+ private address, or credentials over plain http, get a warning.
42
+ - `--file-search <store>` (repeatable) lets the agent search File Search
43
+ stores, all in one `file_search` tool.
44
+ - `--no-web` drops Google Search and URL Context so the agent reads only what
45
+ you gave it. Code Execution stays, because it's how the agent works through a
46
+ CSV and draws a chart. It's refused when nothing else was given to read.
47
+ - `--visualize` sets `agent_config.visualization: "auto"`, alongside
48
+ `collaborative_planning` when `--plan` is also given. Charts in the final
49
+ report are written to the data directory's `images/` folder as
50
+ `<task-id>-<n>.<ext>`, recorded in the store, and listed under the report by
51
+ `get`, `watch` and `digest` and as `images` in `--json`. `export -o` copies
52
+ them next to the export and links them; `rm` and `prune` delete them.
53
+
54
+ Once a tool flag is given, the request lists Google Search, URL Context and
55
+ Code Execution alongside the new sources, because an explicit `tools` list
56
+ replaces the agent's defaults. `--attach` on its own sends no `tools` field.
57
+ `refine` and `approve` resend the plan's tools and visualization setting;
58
+ attachments go with the first turn only. `--dry-run` and the spend
59
+ confirmation list the sources above the cost, and `list` gains a SOURCES
60
+ column when a listed task used any of this.
61
+ - An unknown `--flag=value` option is reported without its value, which could
62
+ be a token.
63
+ - Additive schema migration: `tools_json`, `attachments_json`, `visualization`
64
+ and `images_json`, all nullable. A 0.5.0 `tasks.db` upgrades in place and
65
+ behaves exactly as it did.
66
+
67
+ ### Notes
68
+
69
+ - A run with none of the new flags sends the same request body, byte for byte,
70
+ as 0.5.0 did, and a result without charts prints the same text.
71
+
10
72
  ## [0.5.0] - 2026-09-04
11
73
 
12
74
  ### Added
@@ -269,7 +331,8 @@ seen a task complete, the text is cached locally and survives that expiry — bu
269
331
  something has to poll inside that window for it to be seen at all, which is what
270
332
  `gemcatch daemon` exists to do.
271
333
 
272
- [Unreleased]: https://github.com/Booyaka101/gemcatch/compare/v0.5.0...HEAD
334
+ [Unreleased]: https://github.com/Booyaka101/gemcatch/compare/v0.6.0...HEAD
335
+ [0.6.0]: https://github.com/Booyaka101/gemcatch/compare/v0.5.0...v0.6.0
273
336
  [0.5.0]: https://github.com/Booyaka101/gemcatch/compare/v0.4.0...v0.5.0
274
337
  [0.4.0]: https://github.com/Booyaka101/gemcatch/compare/v0.3.0...v0.4.0
275
338
  [0.3.0]: https://github.com/Booyaka101/gemcatch/compare/v0.2.0...v0.3.0
package/README.md CHANGED
@@ -103,6 +103,11 @@ Useful flags:
103
103
  | `-m, --model <id>` | `research`, `batch` | Override the model. |
104
104
  | `-a, --agent <id>` | `research`, `batch` | Submit to a [research agent](#research-agents) instead of a model. Mutually exclusive with `--model`. |
105
105
  | `--plan` | `research`, `batch` | Ask the agent for a [research plan](#see-the-plan-before-you-pay-for-the-run) first, to refine and approve. Needs `--agent`. |
106
+ | `--attach <path\|url>` | `research`, `batch` | Give the agent a PDF, CSV or image ([your own data](#research-over-your-own-data)). Repeatable. Needs `--agent`. |
107
+ | `--mcp <url>` | `research`, `batch` | Let the agent call a remote MCP server. Repeatable; `--mcp-header`, `--mcp-allow` and `--mcp-name` apply to the one before them. Needs `--agent`. |
108
+ | `--file-search <store>` | `research`, `batch` | Let the agent search a File Search store. Repeatable. Needs `--agent`. |
109
+ | `--no-web` | `research`, `batch` | Drop Google Search and URL Context, so the agent reads only what you gave it. Needs `--agent`. |
110
+ | `--visualize` | `research`, `batch` | Let the agent draw charts; they're saved as image files. Needs `--agent`. |
106
111
  | `--yes` | `research`, `batch`, `refine`, `approve` | Confirm the agent cost without asking. Required for `--agent` when stdin is not a TTY. |
107
112
  | `-s, --system <text>` | `research`, `batch` | Set a system instruction. |
108
113
  | `-f, --file <path>` | `research` | Read the prompt from a file. |
@@ -282,6 +287,81 @@ $ gemcatch export --tag batch-1a2b3c -o reports.md # every repo
282
287
 
283
288
  Drop `--plan` and the first two lines become the 0.4.0 one-shot flow, which still works exactly as it did.
284
289
 
290
+ ## Research over your own data
291
+
292
+ Out of the box a Deep Research agent reads the public web. These flags hand it your files, your MCP servers and your File Search stores as well, and `--visualize` lets it draw charts. They are agent features, so every one of them needs `--agent`. Without it gemcatch stops with one line naming the flag and sends nothing.
293
+
294
+ ### Files: `--attach`
295
+
296
+ ```console
297
+ $ gemcatch research "Which region is growing fastest, and does the board pack agree?" --agent deep-research \
298
+ --attach sales-2026.csv --attach q3-board-pack.pdf --attach whiteboard.png \
299
+ --attach customer-contracts-2026.pdf --yes
300
+ Attachments: sales-2026.csv (inline, 1 KB); q3-board-pack.pdf (inline, 1 KB); whiteboard.png (inline, 1 KB); customer-contracts-2026.pdf (upload, 40.0 MB)
301
+ Agent deep-research-preview-04-2026 — estimated $1.00–$3.00 for this task (preview rates, subject to change).
302
+ Uploading customer-contracts-2026.pdf (40.0 MB) to the Files API...
303
+ Task 7cf00c5b submitted. Run: gemcatch get 7cf00c5b when ready.
304
+ ```
305
+
306
+ PDF and CSV go in as documents; PNG, JPEG, WebP, HEIC, HEIF, GIF and BMP as images. The type comes from the extension, and anything else is refused before you're asked to pay, with a nudge to export a Word file as PDF or a spreadsheet as CSV. `--attach` also takes an https URL, which is passed to the agent by reference. A URL with no supported extension (arxiv's `https://arxiv.org/pdf/1706.03762`, say) gets one HEAD request to read its Content-Type, or a one-byte GET if the server refuses HEAD. The query string of a signed URL is sent as given and shown as `***` everywhere else, the store included.
307
+
308
+ Local files are sent inline, in the order given, as long as the request stays under the API's inline limit: 100 MB of base64, or 50 MB once a PDF is in it. The first file that doesn't fit, and every file after it, goes up through the Files API instead and is referenced by URI. Google keeps uploads for 48 hours. On a `--plan` run gemcatch prints when they expire, and a `refine` or `approve` after that gets a warning. A file given twice is sent once.
309
+
310
+ In a `batch` with more than one prompt, every local file is uploaded, once, and each prompt points at the upload. Inline bytes would otherwise go over the wire again with every prompt.
311
+
312
+ Files go with the first turn only. A `refine` or `approve` continues the same conversation, and the files are already in it.
313
+
314
+ ### MCP servers: `--mcp`
315
+
316
+ ```console
317
+ $ gemcatch research "Which accounts are at risk of churn this quarter?" --agent deep-research --plan --yes \
318
+ --mcp https://mcp.example-crm.com/mcp \
319
+ --mcp-header 'Authorization: Bearer ${CRM_TOKEN}' \
320
+ --mcp-allow search_accounts,get_account
321
+ Tools: google_search; url_context; code_execution; MCP mcp.example-crm.com https://mcp.example-crm.com/mcp (Authorization: ***) [search_accounts, get_account]
322
+ Agent deep-research-preview-04-2026 (planning turn) — estimated $1.00–$3.00 for this task (preview rates, subject to change; the docs price per task and do not price a planning turn separately).
323
+ Plan task 5451f8b9 submitted. Run: gemcatch get 5451f8b9 when ready.
324
+ ```
325
+
326
+ `--mcp` is repeatable, and `--mcp-header`, `--mcp-allow` and `--mcp-name` apply to the `--mcp` just before them. The server is named after its host unless you pass `--mcp-name`, and two servers on one host become `host` and `host-2`. Giving two servers the same `--mcp-name`, or one server the same header twice, is refused. Google's servers make the call, not your machine, so the URL has to be reachable from the internet; a localhost or private address gets a warning, and so does a plain `http://` URL that carries credentials.
327
+
328
+ Header values, and any user, password, query string or fragment in an MCP URL, never appear in anything gemcatch prints: the confirmation, `--dry-run`, `list --json`, `get --raw` and API errors all show `***`.
329
+
330
+ Note the single quotes in the example. gemcatch reads `${CRM_TOKEN}` from the environment itself, each time a turn is sent, so `tasks.db` only ever holds the reference. A `refine` or `approve` needs the variable set too, and stops in one line naming it if it isn't. A value written out in full, or expanded by your shell, is stored as given, because a later turn has to send it again. On Linux and macOS gemcatch keeps `~/.gemcatch` readable by you alone.
331
+
332
+ ### File Search stores: `--file-search`
333
+
334
+ ```console
335
+ $ gemcatch research "What does our playbook say about discounting?" --agent deep-research \
336
+ --file-search sales-playbooks --no-web --dry-run
337
+ Tools: code_execution; File Search fileSearchStores/sales-playbooks
338
+ Agent deep-research-preview-04-2026 — estimated $1.00–$3.00 for this task. Nothing submitted (--dry-run).
339
+ ```
340
+
341
+ Repeat it for more stores; they all go in one `file_search` tool. A bare name gets `fileSearchStores/` put in front of it. gemcatch doesn't create or fill stores, [the File Search docs](https://ai.google.dev/gemini-api/docs/file-search) cover that.
342
+
343
+ ### Charts: `--visualize`
344
+
345
+ ```console
346
+ $ gemcatch research "Which region is growing fastest, and does the board pack agree?" --agent deep-research \
347
+ --attach sales-2026.csv --visualize --yes -w
348
+ ...
349
+ (report text)
350
+
351
+ Images:
352
+ /home/you/.gemcatch/images/7cf00c5b-1.png
353
+ ```
354
+
355
+ This sets `agent_config.visualization` to `auto`, next to `collaborative_planning` when you also pass `--plan`. The charts in the final report are written to the data directory's `images/` folder as `<task-id>-<n>.<ext>`, and listed under the report by `get`, `watch` and `digest`, and as `images` in `--json`. `export -o report.md` copies them next to the export and links them in the Markdown. `rm` and `prune` delete them with the task.
356
+
357
+ ### What the agent can reach: `--no-web`
358
+
359
+ With no source flags gemcatch sends no `tools` field and the agent gets its defaults: Google Search, URL Context and Code Execution. The API reads an explicit list as the whole set, so once you add `--mcp` or `--file-search`, gemcatch lists those three back in alongside your sources. `--attach` alone sends no list at all, since a file isn't a tool.
360
+
361
+ `--no-web` drops Google Search and URL Context, so the agent works only from what you gave it. Code Execution stays: it can't reach the web, and it's what the agent uses to crunch a CSV and draw a chart. On its own `--no-web` is refused, because it would leave the agent nothing to read.
362
+
363
+ `refine` and `approve` send the plan's tools and visualization setting again, so every turn of a chain reaches the same sources. Once a task in the listing used any of this, `list` grows a SOURCES column (`mcp`, `store`, `no-web`, `2 files`, `charts`).
364
+
285
365
  ## How it works
286
366
 
287
367
  Tasks live in SQLite at `~/.gemcatch/tasks.db` (override with `GEMCATCH_HOME`):
@@ -290,7 +370,8 @@ Tasks live in SQLite at `~/.gemcatch/tasks.db` (override with `GEMCATCH_HOME`):
290
370
  CREATE TABLE tasks (id TEXT PRIMARY KEY, prompt TEXT, interaction_id TEXT,
291
371
  status TEXT DEFAULT 'pending', result TEXT, created_at INTEGER);
292
372
  -- plus model, system_instruction, tag, error, usage, updated_at, agent, citations,
293
- -- collaborative_planning, previous_interaction_id, kind, parent_id
373
+ -- collaborative_planning, previous_interaction_id, kind, parent_id,
374
+ -- tools_json, attachments_json, visualization, images_json
294
375
  ```
295
376
 
296
377
  `research` calls `interactions.create({model, input, background: true})` via [`@google/genai`](https://www.npmjs.com/package/@google/genai) and keeps the returned `id`. The polling commands call `interactions.get(id)` and write the status back. Once a task completes, the text is cached in the `result` column — `gemcatch get` then answers from disk without touching the network.
@@ -340,6 +421,7 @@ Transient failures are retried with exponential backoff and full jitter, honouri
340
421
  | `GEMCATCH_HOME` | Where `tasks.db` lives. Default `~/.gemcatch`. |
341
422
  | `GEMCATCH_MODEL` | Default model. Default `gemini-3.5-flash-lite`. |
342
423
  | `GEMCATCH_POLL_MS` | `watch` poll interval in ms. Default `10000`. |
424
+ | `GEMCATCH_UPLOAD_POLL_MS` | How often an upload still being processed by the Files API is checked, in ms. Default `2000`. |
343
425
  | `GEMCATCH_DAEMON_S` | `daemon` interval in seconds. Default `300`. |
344
426
  | `GEMCATCH_RPM` | Requests/minute ceiling. Default `15` (the free tier). `0` disables pacing. |
345
427
  | `GEMCATCH_MAX_RETRIES` | Extra attempts on a transient failure. Default `4`. `0` disables retries. |
package/db.js CHANGED
@@ -42,6 +42,15 @@ const MIGRATIONS = [
42
42
  ['previous_interaction_id', 'TEXT'],
43
43
  ['kind', "TEXT DEFAULT 'task'"],
44
44
  ['parent_id', 'TEXT'],
45
+ // 0.6.0: sources. `tools_json` is the tools array the row was submitted with
46
+ // (header values included, so it is masked wherever it is printed);
47
+ // `attachments_json` records what was attached on this turn, never the bytes;
48
+ // `visualization` is the agent_config value; `images_json` lists the image
49
+ // files saved from the result. All NULL for a run that used none of them.
50
+ ['tools_json', 'TEXT'],
51
+ ['attachments_json', 'TEXT'],
52
+ ['visualization', 'TEXT'],
53
+ ['images_json', 'TEXT'],
45
54
  ];
46
55
 
47
56
  let _db = null;
@@ -57,8 +66,18 @@ function migrate(d) {
57
66
 
58
67
  function db() {
59
68
  if (_db) return _db;
60
- fs.mkdirSync(HOME, { recursive: true });
69
+ const created = fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
61
70
  _db = new Database(DB_PATH);
71
+ // Rows can hold MCP header values. mkdir's mode only applies to a new
72
+ // directory, so an existing ~/.gemcatch is tightened too; a GEMCATCH_HOME the
73
+ // user already had is left alone. SQLite gives the -wal and -shm files the
74
+ // database file's mode. Best effort: some mounts refuse chmod.
75
+ if (process.platform !== 'win32') {
76
+ try {
77
+ if (!created && !process.env.GEMCATCH_HOME) fs.chmodSync(HOME, 0o700);
78
+ fs.chmodSync(DB_PATH, 0o600);
79
+ } catch (_) {}
80
+ }
62
81
  _db.pragma('journal_mode = WAL');
63
82
  _db.exec(BASE_SCHEMA);
64
83
  migrate(_db);
@@ -76,9 +95,11 @@ function createTask(fields) {
76
95
  db()
77
96
  .prepare(
78
97
  'INSERT INTO tasks (id, prompt, status, created_at, updated_at, model, system_instruction, tag, agent, ' +
79
- 'kind, parent_id, collaborative_planning, previous_interaction_id) ' +
98
+ 'kind, parent_id, collaborative_planning, previous_interaction_id, ' +
99
+ 'tools_json, attachments_json, visualization, images_json) ' +
80
100
  'VALUES (@id, @prompt, @status, @now, @now, @model, @system_instruction, @tag, @agent, ' +
81
- '@kind, @parent_id, @collaborative_planning, @previous_interaction_id)'
101
+ '@kind, @parent_id, @collaborative_planning, @previous_interaction_id, ' +
102
+ '@tools_json, @attachments_json, @visualization, @images_json)'
82
103
  )
83
104
  .run({
84
105
  id,
@@ -95,6 +116,10 @@ function createTask(fields) {
95
116
  // must be stored as 0, while a run that sends no agent_config stores NULL.
96
117
  collaborative_planning: t.collaborativePlanning === undefined ? null : Number(!!t.collaborativePlanning),
97
118
  previous_interaction_id: t.previousInteractionId || null,
119
+ tools_json: t.toolsJson || null,
120
+ attachments_json: t.attachmentsJson || null,
121
+ visualization: t.visualization || null,
122
+ images_json: t.imagesJson || null,
98
123
  });
99
124
  return id;
100
125
  }
@@ -126,7 +151,7 @@ function setStatus(id, status, extra) {
126
151
  const e = extra || {};
127
152
  const sets = ['status = @status', 'updated_at = @now'];
128
153
  const params = { id, status, now: Date.now() };
129
- for (const key of ['result', 'error', 'usage', 'citations']) {
154
+ for (const key of ['result', 'error', 'usage', 'citations', 'images_json']) {
130
155
  if (e[key] !== undefined) {
131
156
  sets.push(`${key} = @${key}`);
132
157
  params[key] = e[key];
package/gemini.js CHANGED
@@ -1,6 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
4
+ const path = require('path');
3
5
  const { isDone, isSuccess } = require('./status');
6
+ const { redactText } = require('./sources');
4
7
 
5
8
  // Free of charge on the Gemini free tier; override per-call with --model.
6
9
  // gemini-3.5-flash-lite went GA on 2026-07-21 (it replaced 3.1 as the
@@ -48,6 +51,9 @@ function resolveAgent(id) {
48
51
  // Overridable for tests and for routing via a proxy/gateway.
49
52
  const REST_BASE =
50
53
  process.env.GEMCATCH_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta/interactions';
54
+ // The Files API shares the Interactions API's version root, with uploads under
55
+ // /upload/<version>/files. Derived from REST_BASE so a gateway moves both.
56
+ const API_ROOT = REST_BASE.replace(/\/interactions\/?$/, '');
51
57
 
52
58
  function envNum(name, dflt) {
53
59
  const raw = process.env[name];
@@ -195,21 +201,25 @@ async function call(fn) {
195
201
 
196
202
  // --- response shaping -----------------------------------------------------
197
203
 
198
- // output_text is added by the SDK, so REST responses need text pulled from steps.
199
- function collectText(node, acc) {
200
- if (!node || typeof node !== 'object') return acc;
204
+ // Depth-first over every object in a response. `citations` subtrees are
205
+ // sources *about* the answer, not answer content: an agent step carries them
206
+ // alongside its content, and a citation's own title/snippet must not be read as
207
+ // answer text, so the walk never enters them. They are collected separately.
208
+ function walk(node, visit, skip = (k) => k === 'citations') {
209
+ if (!node || typeof node !== 'object') return;
201
210
  if (Array.isArray(node)) {
202
- for (const n of node) collectText(n, acc);
203
- return acc;
204
- }
205
- if (typeof node.text === 'string' && node.text.trim()) acc.push(node.text);
206
- for (const [k, v] of Object.entries(node)) {
207
- // Citations are sources *about* the answer, not answer text: an agent step
208
- // carries them alongside its content, and a citation's own title/snippet
209
- // must not be concatenated into the result. They are collected separately.
210
- if (k === 'citations') continue;
211
- if (v && typeof v === 'object') collectText(v, acc);
211
+ for (const n of node) walk(n, visit, skip);
212
+ return;
212
213
  }
214
+ visit(node);
215
+ for (const [k, v] of Object.entries(node)) if (!skip(k, v)) walk(v, visit, skip);
216
+ }
217
+
218
+ // output_text is added by the SDK, so REST responses need text pulled from steps.
219
+ function collectText(node, acc) {
220
+ walk(node, (n) => {
221
+ if (typeof n.text === 'string' && n.text.trim()) acc.push(n.text);
222
+ });
213
223
  return acc;
214
224
  }
215
225
 
@@ -230,9 +240,12 @@ function collectText(node, acc) {
230
240
  // silently blank result.
231
241
  const NON_ANSWER_STEP = new Set(['user_input', 'thought']);
232
242
 
243
+ function answerSteps(steps) {
244
+ return Array.isArray(steps) ? steps.filter((s) => !(s && NON_ANSWER_STEP.has(s.type))) : [];
245
+ }
246
+
233
247
  function textFromSteps(steps) {
234
- if (!Array.isArray(steps)) return '';
235
- const candidates = steps.filter((s) => !(s && NON_ANSWER_STEP.has(s.type)));
248
+ const candidates = answerSteps(steps);
236
249
  if (!candidates.length) return '';
237
250
  const last = collectText(candidates[candidates.length - 1], []).join('\n').trim();
238
251
  if (last) return last;
@@ -246,18 +259,10 @@ function textFromSteps(steps) {
246
259
  // walk is shape-agnostic (any `citations` array anywhere in the interaction),
247
260
  // because the docs do not pin down where they attach; duplicates are dropped.
248
261
  function collectCitations(node, acc) {
249
- if (!node || typeof node !== 'object') return acc;
250
- if (Array.isArray(node)) {
251
- for (const n of node) collectCitations(n, acc);
252
- return acc;
253
- }
254
- for (const [k, v] of Object.entries(node)) {
255
- if (k === 'citations' && Array.isArray(v)) {
256
- for (const c of v) if (c && typeof c === 'object') acc.push(c);
257
- continue;
258
- }
259
- if (v && typeof v === 'object') collectCitations(v, acc);
260
- }
262
+ walk(node, (n) => {
263
+ if (!Array.isArray(n.citations)) return;
264
+ for (const c of n.citations) if (c && typeof c === 'object') acc.push(c);
265
+ }, (k, v) => k === 'citations' && Array.isArray(v));
261
266
  return acc;
262
267
  }
263
268
 
@@ -283,11 +288,26 @@ function textOf(interaction) {
283
288
  return textFromSteps(interaction && interaction.steps);
284
289
  }
285
290
 
291
+ // With agent_config.visualization the agent's charts come back as image content
292
+ // ({type:'image', data:<base64>, mime_type}) in the same final step as the
293
+ // report. Interim drafts can carry images too, so only that step is read.
294
+ function imagesOf(interaction) {
295
+ const candidates = answerSteps(interaction && interaction.steps);
296
+ const images = [];
297
+ walk(candidates[candidates.length - 1], (n) => {
298
+ if (n.type === 'image' && typeof n.data === 'string' && n.data) {
299
+ images.push({ data: n.data, mime_type: n.mime_type || 'image/png' });
300
+ }
301
+ });
302
+ return images;
303
+ }
304
+
286
305
  function shape(r) {
287
306
  return {
288
307
  interactionId: r.id,
289
308
  status: r.status,
290
309
  text: textOf(r),
310
+ images: imagesOf(r),
291
311
  citations: citationsOf(r),
292
312
  usage: r.usage || null,
293
313
  raw: r,
@@ -296,30 +316,40 @@ function shape(r) {
296
316
 
297
317
  // --- transports -----------------------------------------------------------
298
318
 
299
- let _api;
319
+ let _client;
300
320
 
301
- function sdkInteractions() {
321
+ function sdkClient() {
302
322
  // GEMCATCH_FORCE_REST exercises the raw-fetch fallback without uninstalling the
303
323
  // SDK. Checked every call so it always wins over the memo below.
304
324
  if (process.env.GEMCATCH_FORCE_REST === '1') return null;
305
- if (_api !== undefined) return _api;
325
+ if (_client !== undefined) return _client;
306
326
  let GoogleGenAI;
307
327
  try {
308
328
  ({ GoogleGenAI } = require('@google/genai'));
309
329
  } catch (_) {
310
- _api = null;
311
- return _api;
330
+ _client = null;
331
+ return _client;
312
332
  }
313
333
  // apiKey() throws before the memo is written, so a missing key keeps
314
334
  // reporting itself instead of being cached as "no SDK".
315
- const client = new GoogleGenAI({ apiKey: apiKey() });
316
- const i = client.interactions;
335
+ _client = new GoogleGenAI({ apiKey: apiKey() });
336
+ return _client;
337
+ }
338
+
339
+ function sdkInteractions() {
340
+ const c = sdkClient();
341
+ const i = c && c.interactions;
317
342
  // Only use the SDK if background is genuinely first-class here.
318
- _api = i && typeof i.create === 'function' && typeof i.get === 'function' ? i : null;
319
- return _api;
343
+ return i && typeof i.create === 'function' && typeof i.get === 'function' ? i : null;
320
344
  }
321
345
 
322
- async function restJson(url, init) {
346
+ function sdkFiles() {
347
+ const c = sdkClient();
348
+ const f = c && c.files;
349
+ return f && typeof f.upload === 'function' && typeof f.get === 'function' ? f : null;
350
+ }
351
+
352
+ async function restRequest(url, init) {
323
353
  let res;
324
354
  try {
325
355
  res = await fetch(url, init);
@@ -345,7 +375,11 @@ async function restJson(url, init) {
345
375
  }
346
376
  }
347
377
  if (!res.ok) throw apiError(res.status, body, res.headers);
348
- return Array.isArray(body) ? body[0] : body;
378
+ return { body: Array.isArray(body) ? body[0] : body, headers: res.headers };
379
+ }
380
+
381
+ async function restJson(url, init) {
382
+ return (await restRequest(url, init)).body;
349
383
  }
350
384
 
351
385
  // NOTE: the API key goes in x-goog-api-key. `Authorization: Bearer <key>` is
@@ -360,36 +394,110 @@ async function submit(prompt, opts) {
360
394
  const o = opts || {};
361
395
  // `agent` and `model` are mutually exclusive on create: an agent run is sent
362
396
  // with `agent` INSTEAD of `model` (the agent picks its own models). `input`
363
- // stays a plain string and `background` stays true either way -- agents
364
- // *require* background execution, which gemcatch has always set.
397
+ // stays a plain string unless files are attached, and `background` stays true
398
+ // either way -- agents *require* background execution, which gemcatch has
399
+ // always set.
400
+ const input = o.attachments && o.attachments.length ? [{ type: 'text', text: prompt }, ...o.attachments] : prompt;
365
401
  const body = o.agent
366
- ? { agent: o.agent, input: prompt, background: true }
367
- : { model: o.model || DEFAULT_MODEL, input: prompt, background: true };
402
+ ? { agent: o.agent, input, background: true }
403
+ : { model: o.model || DEFAULT_MODEL, input, background: true };
368
404
  if (o.systemInstruction) body.system_instruction = o.systemInstruction;
405
+ // An explicit tools list replaces the agent's defaults, so it is sent only
406
+ // when the user named a source; a plain run leaves the field out entirely.
407
+ if (o.tools) body.tools = o.tools;
369
408
  // collaborative_planning is an `agent_config` field, NOT a top-level one, and
370
409
  // the docs send the whole block (type + thinking_summaries) with it. Sent only
371
- // when a plan turn is involved -- agent_config is optional otherwise, so an
372
- // ordinary run keeps making exactly the request it always made. Presence, not
373
- // truthiness: `false` is the approval turn's real value and must reach the API.
374
- if (o.collaborativePlanning !== undefined) {
375
- body.agent_config = {
376
- type: AGENT_CONFIG_TYPE,
377
- thinking_summaries: 'auto',
378
- collaborative_planning: !!o.collaborativePlanning,
379
- };
410
+ // when a plan turn or visualization is involved -- agent_config is optional
411
+ // otherwise, so an ordinary run keeps making exactly the request it always
412
+ // made. Presence, not truthiness: `false` is the approval turn's real value
413
+ // and must reach the API.
414
+ if (o.collaborativePlanning !== undefined || o.visualization) {
415
+ body.agent_config = { type: AGENT_CONFIG_TYPE };
416
+ if (o.collaborativePlanning !== undefined) {
417
+ body.agent_config.thinking_summaries = 'auto';
418
+ body.agent_config.collaborative_planning = !!o.collaborativePlanning;
419
+ }
420
+ if (o.visualization) body.agent_config.visualization = o.visualization;
380
421
  }
381
422
  // Continues an earlier interaction server-side: the plan is already in that
382
423
  // conversation, so this turn sends only what changed.
383
424
  if (o.previousInteractionId) body.previous_interaction_id = o.previousInteractionId;
384
- const r = await call(() => {
385
- const api = sdkInteractions();
386
- return api
387
- ? api.create(body)
388
- : restJson(REST_BASE, { method: 'POST', headers: restHeaders(), body: JSON.stringify(body) });
389
- });
425
+ let r;
426
+ try {
427
+ r = await call(() => {
428
+ const api = sdkInteractions();
429
+ return api
430
+ ? api.create(body)
431
+ : restJson(REST_BASE, { method: 'POST', headers: restHeaders(), body: JSON.stringify(body) });
432
+ });
433
+ } catch (err) {
434
+ // A 400 can quote the request back; MCP header values are credentials.
435
+ const urls = (o.attachments || []).map((a) => a.uri).filter(Boolean);
436
+ err.message = redactText(err.message, o.tools, urls);
437
+ throw err;
438
+ }
390
439
  return shape(r);
391
440
  }
392
441
 
442
+ // Google's resumable upload, in its single-request form: start a session, then
443
+ // send every byte and finalize in one go. Returns the File resource.
444
+ async function restUpload(filePath, mime) {
445
+ const blob = await fs.openAsBlob(filePath, { type: mime });
446
+ const root = new URL(API_ROOT);
447
+ const start = await restRequest(`${root.origin}/upload${root.pathname.replace(/\/$/, '')}/files`, {
448
+ method: 'POST',
449
+ headers: {
450
+ ...restHeaders(),
451
+ 'X-Goog-Upload-Protocol': 'resumable',
452
+ 'X-Goog-Upload-Command': 'start',
453
+ 'X-Goog-Upload-Header-Content-Length': String(blob.size),
454
+ 'X-Goog-Upload-Header-Content-Type': mime,
455
+ },
456
+ body: JSON.stringify({ file: { display_name: path.basename(filePath) } }),
457
+ });
458
+ const session = start.headers.get('x-goog-upload-url');
459
+ if (!session) {
460
+ const e = new Error('the Files API did not return an upload URL');
461
+ e.code = 'NETWORK';
462
+ throw e;
463
+ }
464
+ const done = await restJson(session, {
465
+ method: 'POST',
466
+ headers: { 'X-Goog-Upload-Offset': '0', 'X-Goog-Upload-Command': 'upload, finalize' },
467
+ body: blob,
468
+ });
469
+ return (done && done.file) || done;
470
+ }
471
+
472
+ const UPLOAD_POLL_MS = envNum('GEMCATCH_UPLOAD_POLL_MS', 2000);
473
+ const UPLOAD_WAIT_MS = 10 * 60 * 1000;
474
+
475
+ // Uploads a local file through the Files API and waits until it can be used.
476
+ // Resolves to {uri, expiresAt} (ms since epoch; Google keeps files 48 hours).
477
+ async function upload(filePath, mime) {
478
+ let file = await call(() => {
479
+ const files = sdkFiles();
480
+ return files
481
+ ? files.upload({ file: filePath, config: { mimeType: mime, displayName: path.basename(filePath) } })
482
+ : restUpload(filePath, mime);
483
+ });
484
+ const failed = (why) => Object.assign(new Error(`upload of ${path.basename(filePath)} failed: ${why}`), { code: 'UPLOAD_FAILED' });
485
+ const deadline = Date.now() + UPLOAD_WAIT_MS;
486
+ while (file && file.state === 'PROCESSING') {
487
+ if (Date.now() > deadline) throw failed(`still processing after ${UPLOAD_WAIT_MS / 60000} minutes`);
488
+ await sleep(UPLOAD_POLL_MS);
489
+ const name = file.name;
490
+ file = await call(() => {
491
+ const files = sdkFiles();
492
+ return files ? files.get({ name }) : restJson(`${API_ROOT}/${name}`, { method: 'GET', headers: restHeaders() });
493
+ });
494
+ }
495
+ if (!file || !file.uri || file.state === 'FAILED') {
496
+ throw failed((file && file.error && file.error.message) || 'no usable file came back');
497
+ }
498
+ return { uri: file.uri, expiresAt: Date.parse(file.expirationTime) || null };
499
+ }
500
+
393
501
  async function poll(interactionId) {
394
502
  const r = await call(() => {
395
503
  const api = sdkInteractions();
@@ -438,6 +546,7 @@ module.exports = {
438
546
  AGENT_CONFIG_TYPE,
439
547
  resolveAgent,
440
548
  submit,
549
+ upload,
441
550
  poll,
442
551
  cancel,
443
552
  remove,