gemcatch 0.4.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 +130 -1
- package/README.md +179 -9
- package/db.js +71 -4
- package/gemini.js +179 -48
- package/index.js +706 -114
- package/package.json +3 -2
- package/sources.js +580 -0
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
|
|
@@ -23,6 +26,12 @@ const AGENT_ALIASES = Object.freeze({
|
|
|
23
26
|
'deep-research-max': 'deep-research-max-preview-04-2026',
|
|
24
27
|
});
|
|
25
28
|
|
|
29
|
+
// `agent_config.type` for the config block a collaborative-planning turn sends.
|
|
30
|
+
// Both documented Deep Research agents use the same value, and an unknown
|
|
31
|
+
// pass-through id is assumed to be one too -- collaborative planning is a Deep
|
|
32
|
+
// Research feature, so there is nothing else it could be.
|
|
33
|
+
const AGENT_CONFIG_TYPE = 'deep-research';
|
|
34
|
+
|
|
26
35
|
// Documented per-task price bands, in dollars, keyed by the RESOLVED id.
|
|
27
36
|
// The docs' own hedge applies -- "These figures are estimates based on
|
|
28
37
|
// preview rates and are subject to change" -- so the spend guard quotes
|
|
@@ -42,6 +51,9 @@ function resolveAgent(id) {
|
|
|
42
51
|
// Overridable for tests and for routing via a proxy/gateway.
|
|
43
52
|
const REST_BASE =
|
|
44
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\/?$/, '');
|
|
45
57
|
|
|
46
58
|
function envNum(name, dflt) {
|
|
47
59
|
const raw = process.env[name];
|
|
@@ -189,21 +201,25 @@ async function call(fn) {
|
|
|
189
201
|
|
|
190
202
|
// --- response shaping -----------------------------------------------------
|
|
191
203
|
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
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;
|
|
195
210
|
if (Array.isArray(node)) {
|
|
196
|
-
for (const n of node)
|
|
197
|
-
return
|
|
198
|
-
}
|
|
199
|
-
if (typeof node.text === 'string' && node.text.trim()) acc.push(node.text);
|
|
200
|
-
for (const [k, v] of Object.entries(node)) {
|
|
201
|
-
// Citations are sources *about* the answer, not answer text: an agent step
|
|
202
|
-
// carries them alongside its content, and a citation's own title/snippet
|
|
203
|
-
// must not be concatenated into the result. They are collected separately.
|
|
204
|
-
if (k === 'citations') continue;
|
|
205
|
-
if (v && typeof v === 'object') collectText(v, acc);
|
|
211
|
+
for (const n of node) walk(n, visit, skip);
|
|
212
|
+
return;
|
|
206
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
|
+
});
|
|
207
223
|
return acc;
|
|
208
224
|
}
|
|
209
225
|
|
|
@@ -224,9 +240,12 @@ function collectText(node, acc) {
|
|
|
224
240
|
// silently blank result.
|
|
225
241
|
const NON_ANSWER_STEP = new Set(['user_input', 'thought']);
|
|
226
242
|
|
|
243
|
+
function answerSteps(steps) {
|
|
244
|
+
return Array.isArray(steps) ? steps.filter((s) => !(s && NON_ANSWER_STEP.has(s.type))) : [];
|
|
245
|
+
}
|
|
246
|
+
|
|
227
247
|
function textFromSteps(steps) {
|
|
228
|
-
|
|
229
|
-
const candidates = steps.filter((s) => !(s && NON_ANSWER_STEP.has(s.type)));
|
|
248
|
+
const candidates = answerSteps(steps);
|
|
230
249
|
if (!candidates.length) return '';
|
|
231
250
|
const last = collectText(candidates[candidates.length - 1], []).join('\n').trim();
|
|
232
251
|
if (last) return last;
|
|
@@ -240,18 +259,10 @@ function textFromSteps(steps) {
|
|
|
240
259
|
// walk is shape-agnostic (any `citations` array anywhere in the interaction),
|
|
241
260
|
// because the docs do not pin down where they attach; duplicates are dropped.
|
|
242
261
|
function collectCitations(node, acc) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
for (const
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
for (const [k, v] of Object.entries(node)) {
|
|
249
|
-
if (k === 'citations' && Array.isArray(v)) {
|
|
250
|
-
for (const c of v) if (c && typeof c === 'object') acc.push(c);
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
if (v && typeof v === 'object') collectCitations(v, acc);
|
|
254
|
-
}
|
|
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));
|
|
255
266
|
return acc;
|
|
256
267
|
}
|
|
257
268
|
|
|
@@ -277,11 +288,26 @@ function textOf(interaction) {
|
|
|
277
288
|
return textFromSteps(interaction && interaction.steps);
|
|
278
289
|
}
|
|
279
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
|
+
|
|
280
305
|
function shape(r) {
|
|
281
306
|
return {
|
|
282
307
|
interactionId: r.id,
|
|
283
308
|
status: r.status,
|
|
284
309
|
text: textOf(r),
|
|
310
|
+
images: imagesOf(r),
|
|
285
311
|
citations: citationsOf(r),
|
|
286
312
|
usage: r.usage || null,
|
|
287
313
|
raw: r,
|
|
@@ -290,30 +316,40 @@ function shape(r) {
|
|
|
290
316
|
|
|
291
317
|
// --- transports -----------------------------------------------------------
|
|
292
318
|
|
|
293
|
-
let
|
|
319
|
+
let _client;
|
|
294
320
|
|
|
295
|
-
function
|
|
321
|
+
function sdkClient() {
|
|
296
322
|
// GEMCATCH_FORCE_REST exercises the raw-fetch fallback without uninstalling the
|
|
297
323
|
// SDK. Checked every call so it always wins over the memo below.
|
|
298
324
|
if (process.env.GEMCATCH_FORCE_REST === '1') return null;
|
|
299
|
-
if (
|
|
325
|
+
if (_client !== undefined) return _client;
|
|
300
326
|
let GoogleGenAI;
|
|
301
327
|
try {
|
|
302
328
|
({ GoogleGenAI } = require('@google/genai'));
|
|
303
329
|
} catch (_) {
|
|
304
|
-
|
|
305
|
-
return
|
|
330
|
+
_client = null;
|
|
331
|
+
return _client;
|
|
306
332
|
}
|
|
307
333
|
// apiKey() throws before the memo is written, so a missing key keeps
|
|
308
334
|
// reporting itself instead of being cached as "no SDK".
|
|
309
|
-
|
|
310
|
-
|
|
335
|
+
_client = new GoogleGenAI({ apiKey: apiKey() });
|
|
336
|
+
return _client;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function sdkInteractions() {
|
|
340
|
+
const c = sdkClient();
|
|
341
|
+
const i = c && c.interactions;
|
|
311
342
|
// Only use the SDK if background is genuinely first-class here.
|
|
312
|
-
|
|
313
|
-
return _api;
|
|
343
|
+
return i && typeof i.create === 'function' && typeof i.get === 'function' ? i : null;
|
|
314
344
|
}
|
|
315
345
|
|
|
316
|
-
|
|
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) {
|
|
317
353
|
let res;
|
|
318
354
|
try {
|
|
319
355
|
res = await fetch(url, init);
|
|
@@ -339,7 +375,11 @@ async function restJson(url, init) {
|
|
|
339
375
|
}
|
|
340
376
|
}
|
|
341
377
|
if (!res.ok) throw apiError(res.status, body, res.headers);
|
|
342
|
-
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;
|
|
343
383
|
}
|
|
344
384
|
|
|
345
385
|
// NOTE: the API key goes in x-goog-api-key. `Authorization: Bearer <key>` is
|
|
@@ -354,21 +394,110 @@ async function submit(prompt, opts) {
|
|
|
354
394
|
const o = opts || {};
|
|
355
395
|
// `agent` and `model` are mutually exclusive on create: an agent run is sent
|
|
356
396
|
// with `agent` INSTEAD of `model` (the agent picks its own models). `input`
|
|
357
|
-
// stays a plain string and `background` stays true
|
|
358
|
-
// *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;
|
|
359
401
|
const body = o.agent
|
|
360
|
-
? { agent: o.agent, input
|
|
361
|
-
: { model: o.model || DEFAULT_MODEL, input
|
|
402
|
+
? { agent: o.agent, input, background: true }
|
|
403
|
+
: { model: o.model || DEFAULT_MODEL, input, background: true };
|
|
362
404
|
if (o.systemInstruction) body.system_instruction = o.systemInstruction;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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;
|
|
408
|
+
// collaborative_planning is an `agent_config` field, NOT a top-level one, and
|
|
409
|
+
// the docs send the whole block (type + thinking_summaries) with it. Sent only
|
|
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;
|
|
421
|
+
}
|
|
422
|
+
// Continues an earlier interaction server-side: the plan is already in that
|
|
423
|
+
// conversation, so this turn sends only what changed.
|
|
424
|
+
if (o.previousInteractionId) body.previous_interaction_id = o.previousInteractionId;
|
|
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
|
+
}
|
|
369
439
|
return shape(r);
|
|
370
440
|
}
|
|
371
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
|
+
|
|
372
501
|
async function poll(interactionId) {
|
|
373
502
|
const r = await call(() => {
|
|
374
503
|
const api = sdkInteractions();
|
|
@@ -414,8 +543,10 @@ module.exports = {
|
|
|
414
543
|
MAX_RETRIES,
|
|
415
544
|
AGENT_ALIASES,
|
|
416
545
|
AGENT_PRICE_BANDS,
|
|
546
|
+
AGENT_CONFIG_TYPE,
|
|
417
547
|
resolveAgent,
|
|
418
548
|
submit,
|
|
549
|
+
upload,
|
|
419
550
|
poll,
|
|
420
551
|
cancel,
|
|
421
552
|
remove,
|