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 +64 -1
- package/README.md +83 -1
- package/db.js +29 -4
- package/gemini.js +166 -57
- package/index.js +302 -89
- package/package.json +3 -2
- package/sources.js +580 -0
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.
|
|
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
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
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)
|
|
203
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
for (const
|
|
252
|
-
|
|
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
|
|
319
|
+
let _client;
|
|
300
320
|
|
|
301
|
-
function
|
|
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 (
|
|
325
|
+
if (_client !== undefined) return _client;
|
|
306
326
|
let GoogleGenAI;
|
|
307
327
|
try {
|
|
308
328
|
({ GoogleGenAI } = require('@google/genai'));
|
|
309
329
|
} catch (_) {
|
|
310
|
-
|
|
311
|
-
return
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
return _api;
|
|
343
|
+
return i && typeof i.create === 'function' && typeof i.get === 'function' ? i : null;
|
|
320
344
|
}
|
|
321
345
|
|
|
322
|
-
|
|
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
|
|
364
|
-
// *require* background execution, which gemcatch has
|
|
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
|
|
367
|
-
: { model: o.model || DEFAULT_MODEL, input
|
|
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
|
|
372
|
-
// ordinary run keeps making exactly the request it always
|
|
373
|
-
// truthiness: `false` is the approval turn's real value
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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,
|