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/index.js
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { pathToFileURL } = require('url');
|
|
5
7
|
const crypto = require('crypto');
|
|
6
8
|
const { Command, Option } = require('commander');
|
|
7
9
|
const store = require('./db');
|
|
8
10
|
const gemini = require('./gemini');
|
|
11
|
+
const sources = require('./sources');
|
|
9
12
|
const { TERMINAL, ACTIVE, PENDING, isDone, isSuccess } = require('./status');
|
|
10
13
|
|
|
11
14
|
const DEFAULT_POLL_MS = Number(process.env.GEMCATCH_POLL_MS) || 10000;
|
|
@@ -90,22 +93,26 @@ function needTask(id) {
|
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
// Citations ride along with an agent's report -- the docs tell users to review
|
|
93
|
-
// them to verify the sources
|
|
94
|
-
//
|
|
95
|
-
function withSources(text, citations) {
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
96
|
+
// them to verify the sources -- so they are printed under the result, and so are
|
|
97
|
+
// the paths of any charts it drew.
|
|
98
|
+
function withSources(text, citations, images) {
|
|
99
|
+
const parts = [text || '(empty response)'];
|
|
100
|
+
if (Array.isArray(citations) && citations.length) {
|
|
101
|
+
const lines = citations.map((c, i) => {
|
|
102
|
+
const title = (c && (c.title || c.text)) || '';
|
|
103
|
+
const url = (c && (c.url || c.uri)) || '';
|
|
104
|
+
return ` [${i + 1}] ${[title, url].filter(Boolean).join(' — ') || JSON.stringify(c)}`;
|
|
105
|
+
});
|
|
106
|
+
parts.push(`Sources:\n${lines.join('\n')}`);
|
|
107
|
+
}
|
|
108
|
+
if (images && images.length) parts.push(`Images:\n${images.map((p) => ` ${p}`).join('\n')}`);
|
|
109
|
+
return parts.join('\n\n');
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
// The citations
|
|
107
|
-
// degrades to "
|
|
108
|
-
|
|
112
|
+
// The citations and images_json columns hold JSON arrays (or NULL). Parsed
|
|
113
|
+
// defensively: a corrupt row degrades to "none", never a crash in the middle of
|
|
114
|
+
// printing a result.
|
|
115
|
+
function parseList(raw) {
|
|
109
116
|
if (!raw) return null;
|
|
110
117
|
try {
|
|
111
118
|
const v = JSON.parse(raw);
|
|
@@ -115,6 +122,40 @@ function parseCitations(raw) {
|
|
|
115
122
|
}
|
|
116
123
|
}
|
|
117
124
|
|
|
125
|
+
// --- images ---------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
const IMAGE_DIR = path.join(store.HOME, 'images');
|
|
128
|
+
function imageExt(mime) {
|
|
129
|
+
if (mime === 'image/jpeg') return '.jpg';
|
|
130
|
+
const sub = /^image\/([a-z0-9]+)/i.exec(mime || '');
|
|
131
|
+
return sub ? `.${sub[1].toLowerCase()}` : '.png';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A --visualize run's charts arrive as base64 in the result, which the store
|
|
135
|
+
// keeps as text only, so they are written out as <task-id>-<n>.<ext>. They can
|
|
136
|
+
// chart private data, so they get the store's permissions.
|
|
137
|
+
function saveImages(task, images) {
|
|
138
|
+
fs.mkdirSync(IMAGE_DIR, { recursive: true, mode: 0o700 });
|
|
139
|
+
return images.map((img, i) => {
|
|
140
|
+
const file = path.join(IMAGE_DIR, `${task.id}-${i + 1}${imageExt(img.mime_type)}`);
|
|
141
|
+
fs.writeFileSync(file, Buffer.from(img.data, 'base64'), { mode: 0o600 });
|
|
142
|
+
return file;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Only files named for this task go: a digest lists its sources' images too,
|
|
147
|
+
// and forgetting the digest must not take them with it.
|
|
148
|
+
function removeImages(task) {
|
|
149
|
+
for (const p of parseList(task.images_json) || []) {
|
|
150
|
+
if (!path.basename(p).startsWith(`${task.id}-`)) continue;
|
|
151
|
+
try {
|
|
152
|
+
fs.rmSync(p, { force: true });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.error(`Note: could not delete ${p} (${err.message}).`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
118
159
|
// --- spend guard ----------------------------------------------------------
|
|
119
160
|
|
|
120
161
|
// Deep Research agents are billed PER TASK, not per token -- the docs put
|
|
@@ -183,9 +224,11 @@ function estimatedSpend(agentRows) {
|
|
|
183
224
|
return { low, high, tasks, unpriced };
|
|
184
225
|
}
|
|
185
226
|
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
227
|
+
// A --dry-run's lines: the sources it would have sent, then the band, the
|
|
228
|
+
// honesty note and that nothing went, in the confirmation's order.
|
|
229
|
+
function dryRunSpend(agentId, count, planning, src) {
|
|
230
|
+
const line = `${spendLine(agentId, count, planning)}${spendNote(planning, false)}. Nothing submitted (--dry-run).`;
|
|
231
|
+
return [...sources.describe(src), line].join('\n');
|
|
189
232
|
}
|
|
190
233
|
|
|
191
234
|
function askYesNo(question) {
|
|
@@ -202,7 +245,8 @@ function askYesNo(question) {
|
|
|
202
245
|
// Returns only when the submission is confirmed; otherwise it exits (declined)
|
|
203
246
|
// or throws (no way to ask). Runs BEFORE any row is written, so a declined or
|
|
204
247
|
// refused submission leaves the tasks table untouched.
|
|
205
|
-
async function confirmSpend(agentId, count, opts, planning) {
|
|
248
|
+
async function confirmSpend(agentId, count, opts, planning, src) {
|
|
249
|
+
for (const line of sources.describe(src)) console.error(line);
|
|
206
250
|
console.error(`${spendLine(agentId, count, planning)}${spendNote(planning, true)}.`);
|
|
207
251
|
if (opts.yes) return;
|
|
208
252
|
// GEMCATCH_ASSUME_TTY lets the offline suite drive the interactive branch
|
|
@@ -246,6 +290,80 @@ function resolveAgentOpts(opts, cmd) {
|
|
|
246
290
|
return gemini.resolveAgent(opts.agent);
|
|
247
291
|
}
|
|
248
292
|
|
|
293
|
+
// --- sources --------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
// --mcp, --file-search, --no-web, --attach and --visualize, on research and
|
|
296
|
+
// batch alike. Each command gets its own MCP parsers: the modifier flags attach
|
|
297
|
+
// to the --mcp before them, which only works if they share one list.
|
|
298
|
+
function addSourceOptions(cmd) {
|
|
299
|
+
const mcp = sources.mcpOptionParsers();
|
|
300
|
+
const collect = (v, list) => (list || []).concat(v);
|
|
301
|
+
return cmd
|
|
302
|
+
.optionsGroup('Your own data (needs --agent):')
|
|
303
|
+
.option('--mcp <url>', 'let the agent call this remote MCP server (repeatable)', mcp.mcp)
|
|
304
|
+
.option('--mcp-name <name>', 'name for the --mcp before it (default: its host)', mcp.name)
|
|
305
|
+
.option('--mcp-header <header>', "'Name: value' header for the --mcp before it; ${VAR} reads the environment", mcp.header)
|
|
306
|
+
.option('--mcp-allow <tools>', 'comma-separated tools the --mcp before it may call', mcp.allow)
|
|
307
|
+
.option('--file-search <store>', 'let the agent search this File Search store (repeatable)', collect)
|
|
308
|
+
.option('--no-web', 'drop Google Search and URL Context; needs --mcp, --file-search or --attach')
|
|
309
|
+
.option('--attach <path|url>', 'attach a PDF, CSV or image to the first turn (repeatable)', collect)
|
|
310
|
+
.option('--visualize', 'let the agent draw charts; they are saved as image files');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Reads the inline attachments and uploads the rest. Runs after the spend is
|
|
314
|
+
// confirmed and before any row is written, so a failed upload costs nothing.
|
|
315
|
+
// A plan is continued later, so it is told when its uploads expire.
|
|
316
|
+
async function attachFiles(files, planning) {
|
|
317
|
+
const attached = await sources.materialize(files, gemini.upload, (a) => {
|
|
318
|
+
console.error(edim(`Uploading ${a.source} (${sources.size(a.bytes)}) to the Files API...`));
|
|
319
|
+
});
|
|
320
|
+
const expiry = Math.min(...attached.record.map((a) => a.expires_at || Infinity));
|
|
321
|
+
if (planning && expiry !== Infinity) {
|
|
322
|
+
const when = new Date(expiry).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
|
323
|
+
console.error(`Note: the uploaded files expire at ${when}; a refine or approve after that will probably fail.`);
|
|
324
|
+
}
|
|
325
|
+
return attached;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// What a submitted turn records about its sources. Tools are stored with their
|
|
329
|
+
// header values because a later turn of the chain has to send them again.
|
|
330
|
+
function sourceColumns(src, record) {
|
|
331
|
+
return {
|
|
332
|
+
toolsJson: src.tools ? JSON.stringify(src.tools) : null,
|
|
333
|
+
attachmentsJson: record && record.length ? JSON.stringify(record) : null,
|
|
334
|
+
visualization: src.visualization || null,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// The source fields of a gemini.submit call, with ${VAR} header values filled in.
|
|
339
|
+
function sourceArgs(src, items) {
|
|
340
|
+
return { tools: sources.withEnv(src.tools), attachments: items, visualization: src.visualization };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// The sources a later turn inherits from the row it continues. Attachments went
|
|
344
|
+
// with the first turn and are already in the conversation, so none are resent.
|
|
345
|
+
function storedSources(task) {
|
|
346
|
+
let tools;
|
|
347
|
+
try {
|
|
348
|
+
tools = task.tools_json ? JSON.parse(task.tools_json) : undefined;
|
|
349
|
+
} catch (_) {
|
|
350
|
+
throw new Error(`task ${task.id} has an unreadable tools_json column, so its tools can't be sent again`);
|
|
351
|
+
}
|
|
352
|
+
sources.withEnv(tools);
|
|
353
|
+
return { tools, files: [], visualization: task.visualization || undefined };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Server text about a task can quote its MCP headers or attachment URLs back.
|
|
357
|
+
function redactFor(task, text) {
|
|
358
|
+
const urls = (parseList(chainRoot(task).attachments_json) || []).filter((a) => a.via === 'url').map((a) => a.source);
|
|
359
|
+
return sources.redactText(text, parseList(task.tools_json), urls);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function maskRaw(raw, task) {
|
|
363
|
+
const shown = raw && raw.tools ? { ...raw, tools: sources.redactTools(raw.tools) } : raw;
|
|
364
|
+
return redactFor(task, JSON.stringify(shown, null, 2));
|
|
365
|
+
}
|
|
366
|
+
|
|
249
367
|
// --- plan chains ----------------------------------------------------------
|
|
250
368
|
|
|
251
369
|
// `collaborative_planning: true` makes the agent return a research plan instead
|
|
@@ -275,8 +393,9 @@ function planFooter(task) {
|
|
|
275
393
|
|
|
276
394
|
// The result payload for `get`/`watch`. The plan-chain fields ride along only on
|
|
277
395
|
// a plan row, so a model run's --json shape is exactly what it always was.
|
|
278
|
-
function resultPayload(task, status, result, citations) {
|
|
396
|
+
function resultPayload(task, status, result, citations, images) {
|
|
279
397
|
const p = { id: task.id, status, result, citations: citations || null };
|
|
398
|
+
if (images && images.length) p.images = images;
|
|
280
399
|
if (task.kind === 'plan') {
|
|
281
400
|
p.kind = 'plan';
|
|
282
401
|
p.approve = `gemcatch approve ${task.id}`;
|
|
@@ -285,6 +404,17 @@ function resultPayload(task, status, result, citations) {
|
|
|
285
404
|
return p;
|
|
286
405
|
}
|
|
287
406
|
|
|
407
|
+
function cachedResult(task) {
|
|
408
|
+
return { status: task.status, text: task.result, citations: parseList(task.citations), imagePaths: parseList(task.images_json) };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function printResult(task, r, json) {
|
|
412
|
+
emit(json, resultPayload(task, r.status, r.text, r.citations, r.imagePaths), () => {
|
|
413
|
+
console.log(withSources(r.text, r.citations, r.imagePaths));
|
|
414
|
+
if (task.kind === 'plan') console.error(planFooter(task));
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
288
418
|
// Both continuation commands need the same thing: a plan row that completed and
|
|
289
419
|
// whose interaction the server can still resolve. Anything else exits here,
|
|
290
420
|
// before a row is written or a request is sent.
|
|
@@ -345,10 +475,11 @@ function expiredHint(err, plan) {
|
|
|
345
475
|
return e;
|
|
346
476
|
}
|
|
347
477
|
|
|
348
|
-
// A report row is displayed under the question
|
|
349
|
-
// than the "plan looks good" line actually sent --
|
|
350
|
-
// `export` reading as research instead of as
|
|
351
|
-
|
|
478
|
+
// The first turn of a plan chain. A report row is displayed under the question
|
|
479
|
+
// that started the chain rather than the "plan looks good" line actually sent --
|
|
480
|
+
// that is what keeps `list` and `export` reading as research instead of as
|
|
481
|
+
// protocol chatter.
|
|
482
|
+
function chainRoot(task) {
|
|
352
483
|
let cur = task;
|
|
353
484
|
const seen = new Set([task.id]);
|
|
354
485
|
while (cur.parent_id && !seen.has(cur.parent_id)) {
|
|
@@ -357,7 +488,7 @@ function rootPrompt(task) {
|
|
|
357
488
|
if (!parent) break;
|
|
358
489
|
cur = parent;
|
|
359
490
|
}
|
|
360
|
-
return cur
|
|
491
|
+
return cur;
|
|
361
492
|
}
|
|
362
493
|
|
|
363
494
|
// `refine` (another plan) and `approve` (the report) are the same submission --
|
|
@@ -368,6 +499,7 @@ function rootPrompt(task) {
|
|
|
368
499
|
async function continuePlan(plan, opts, turn) {
|
|
369
500
|
let id;
|
|
370
501
|
try {
|
|
502
|
+
const src = storedSources(plan);
|
|
371
503
|
if (opts.dryRun) {
|
|
372
504
|
emit(
|
|
373
505
|
opts.json,
|
|
@@ -378,12 +510,23 @@ async function continuePlan(plan, opts, turn) {
|
|
|
378
510
|
parent_id: plan.id,
|
|
379
511
|
previous_interaction_id: plan.interaction_id,
|
|
380
512
|
input: turn.input,
|
|
513
|
+
...sources.preview(src),
|
|
381
514
|
},
|
|
382
|
-
() => console.log(dryRunSpend(plan.agent, 1, turn.planning))
|
|
515
|
+
() => console.log(dryRunSpend(plan.agent, 1, turn.planning, src))
|
|
383
516
|
);
|
|
384
517
|
return;
|
|
385
518
|
}
|
|
386
|
-
|
|
519
|
+
// Warned, not refused: whether the server still needs the files once the
|
|
520
|
+
// conversation has read them is not documented.
|
|
521
|
+
const expired = (parseList(chainRoot(plan).attachments_json) || []).filter(
|
|
522
|
+
(a) => a.expires_at && a.expires_at < Date.now()
|
|
523
|
+
);
|
|
524
|
+
if (expired.length) {
|
|
525
|
+
console.error(
|
|
526
|
+
`Note: ${expired.map((a) => a.source).join(', ')} expired from the Files API, so this turn will probably fail.`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
await confirmSpend(plan.agent, 1, opts, turn.planning, src);
|
|
387
530
|
id = store.createTask({
|
|
388
531
|
prompt: turn.prompt,
|
|
389
532
|
agent: plan.agent,
|
|
@@ -392,11 +535,13 @@ async function continuePlan(plan, opts, turn) {
|
|
|
392
535
|
parentId: plan.id,
|
|
393
536
|
collaborativePlanning: turn.planning,
|
|
394
537
|
previousInteractionId: plan.interaction_id,
|
|
538
|
+
...sourceColumns(src),
|
|
395
539
|
});
|
|
396
540
|
const r = await gemini.submit(turn.input, {
|
|
397
541
|
agent: plan.agent,
|
|
398
542
|
collaborativePlanning: turn.planning,
|
|
399
543
|
previousInteractionId: plan.interaction_id,
|
|
544
|
+
...sourceArgs(src),
|
|
400
545
|
});
|
|
401
546
|
store.setInteraction(id, r.interactionId, r.status);
|
|
402
547
|
emit(
|
|
@@ -460,15 +605,27 @@ async function refresh(task) {
|
|
|
460
605
|
}
|
|
461
606
|
const extra = {};
|
|
462
607
|
if (isDone(r.status)) {
|
|
608
|
+
if (r.text) r.text = redactFor(task, r.text);
|
|
463
609
|
if (isSuccess(r.status)) {
|
|
464
610
|
extra.result = r.text;
|
|
465
611
|
// Agent runs return citations with the report; the docs tell users to
|
|
466
612
|
// review them to verify the sources, so they are persisted, not dropped.
|
|
467
|
-
if (r.citations && r.citations.length)
|
|
468
|
-
|
|
613
|
+
if (r.citations && r.citations.length) {
|
|
614
|
+
r.citations = JSON.parse(redactFor(task, JSON.stringify(r.citations)));
|
|
615
|
+
extra.citations = JSON.stringify(r.citations);
|
|
616
|
+
}
|
|
617
|
+
if (r.images.length) {
|
|
618
|
+
r.imagePaths = saveImages(task, r.images);
|
|
619
|
+
extra.images_json = JSON.stringify(r.imagePaths);
|
|
620
|
+
}
|
|
621
|
+
} else if (r.text) {
|
|
622
|
+
extra.error = r.text;
|
|
623
|
+
}
|
|
469
624
|
}
|
|
470
625
|
if (r.usage) extra.usage = JSON.stringify(r.usage);
|
|
471
626
|
store.setStatus(task.id, r.status, extra);
|
|
627
|
+
// A digest has no images of its own but lists its sources' (see digest).
|
|
628
|
+
if (!r.imagePaths) r.imagePaths = parseList(task.images_json);
|
|
472
629
|
return r;
|
|
473
630
|
}
|
|
474
631
|
|
|
@@ -505,11 +662,13 @@ const program = new Command();
|
|
|
505
662
|
program
|
|
506
663
|
.name('gemcatch')
|
|
507
664
|
.description("Fire-and-forget research tasks on Gemini's Interactions API (background execution).")
|
|
508
|
-
.version(require('./package.json').version)
|
|
665
|
+
.version(require('./package.json').version)
|
|
666
|
+
// An unknown `--flag=value` is echoed whole, and the value may be a token.
|
|
667
|
+
.configureOutput({ outputError: (str, write) => write(str.replace(/(unknown option '[^'=]*)=[^']*'/g, "$1=...'")) });
|
|
509
668
|
|
|
510
669
|
// --- research -------------------------------------------------------------
|
|
511
670
|
|
|
512
|
-
program
|
|
671
|
+
const research = program
|
|
513
672
|
.command('research')
|
|
514
673
|
.argument('[prompt]', 'what you want researched; "-" reads stdin')
|
|
515
674
|
.option('-f, --file <path>', 'read the prompt from a file')
|
|
@@ -521,12 +680,15 @@ program
|
|
|
521
680
|
.option('-w, --watch', 'wait for the result instead of exiting')
|
|
522
681
|
.option('--yes', 'confirm the agent cost without asking (required when stdin is not a TTY)')
|
|
523
682
|
.option('--dry-run', 'show what would be submitted (and what it would cost); submit nothing')
|
|
524
|
-
.option('--json', 'machine-readable output')
|
|
683
|
+
.option('--json', 'machine-readable output');
|
|
684
|
+
addSourceOptions(research)
|
|
525
685
|
.description('submit a background task and exit immediately')
|
|
526
686
|
.action(async (promptArg, opts, cmd) => {
|
|
527
687
|
let id;
|
|
528
688
|
try {
|
|
529
689
|
const agent = resolveAgentOpts(opts, cmd);
|
|
690
|
+
const src = await sources.resolve(opts, agent);
|
|
691
|
+
for (const w of src.warnings) console.error(`Note: ${w}`);
|
|
530
692
|
const prompt = await resolvePrompt(promptArg, opts);
|
|
531
693
|
// undefined, not false: an ordinary run must keep sending no agent_config
|
|
532
694
|
// at all, exactly as it did before collaborative planning existed.
|
|
@@ -534,15 +696,23 @@ program
|
|
|
534
696
|
if (opts.dryRun) {
|
|
535
697
|
emit(
|
|
536
698
|
opts.json,
|
|
537
|
-
{
|
|
699
|
+
{
|
|
700
|
+
dry_run: true,
|
|
701
|
+
agent: agent || null,
|
|
702
|
+
model: agent ? null : opts.model,
|
|
703
|
+
plan: !!opts.plan,
|
|
704
|
+
prompt,
|
|
705
|
+
...sources.preview(src),
|
|
706
|
+
},
|
|
538
707
|
() => {
|
|
539
|
-
if (agent) console.log(dryRunSpend(agent, 1, opts.plan));
|
|
708
|
+
if (agent) console.log(dryRunSpend(agent, 1, opts.plan, src));
|
|
540
709
|
else console.log(`Would submit to ${opts.model}: ${snippet(prompt)}. Nothing submitted (--dry-run).`);
|
|
541
710
|
}
|
|
542
711
|
);
|
|
543
712
|
return;
|
|
544
713
|
}
|
|
545
|
-
if (agent) await confirmSpend(agent, 1, opts, opts.plan);
|
|
714
|
+
if (agent) await confirmSpend(agent, 1, opts, opts.plan, src);
|
|
715
|
+
const attached = await attachFiles(src.files, opts.plan);
|
|
546
716
|
id = store.createTask({
|
|
547
717
|
prompt,
|
|
548
718
|
model: agent ? null : opts.model,
|
|
@@ -551,12 +721,14 @@ program
|
|
|
551
721
|
tag: opts.tag,
|
|
552
722
|
kind: opts.plan ? 'plan' : 'task',
|
|
553
723
|
collaborativePlanning: planning,
|
|
724
|
+
...sourceColumns(src, attached.record),
|
|
554
725
|
});
|
|
555
726
|
const r = await gemini.submit(prompt, {
|
|
556
727
|
model: opts.model,
|
|
557
728
|
agent,
|
|
558
729
|
systemInstruction: opts.system,
|
|
559
730
|
collaborativePlanning: planning,
|
|
731
|
+
...sourceArgs(src, attached.items),
|
|
560
732
|
});
|
|
561
733
|
store.setInteraction(id, r.interactionId, r.status);
|
|
562
734
|
if (opts.watch) {
|
|
@@ -649,7 +821,7 @@ async function watchBatch(tag, intervalMs, json) {
|
|
|
649
821
|
);
|
|
650
822
|
}
|
|
651
823
|
|
|
652
|
-
program
|
|
824
|
+
const batch = program
|
|
653
825
|
.command('batch')
|
|
654
826
|
.argument('<file>', 'prompts file — one per line, or "-" to read stdin')
|
|
655
827
|
.option('-m, --model <id>', 'model to use', gemini.DEFAULT_MODEL)
|
|
@@ -661,11 +833,14 @@ program
|
|
|
661
833
|
.option('-w, --watch', 'submit all, then poll until the whole batch finishes')
|
|
662
834
|
.option('--yes', 'confirm the agent cost without asking (required when stdin is not a TTY)')
|
|
663
835
|
.option('--dry-run', 'parse and list what would be submitted; submit nothing')
|
|
664
|
-
.option('--json', 'machine-readable output')
|
|
836
|
+
.option('--json', 'machine-readable output');
|
|
837
|
+
addSourceOptions(batch)
|
|
665
838
|
.description('submit many background tasks from a file, tagged as one batch')
|
|
666
839
|
.action(async (file, opts, cmd) => {
|
|
667
840
|
try {
|
|
668
841
|
const agent = resolveAgentOpts(opts, cmd);
|
|
842
|
+
const src = await sources.resolve(opts, agent);
|
|
843
|
+
for (const w of src.warnings) console.error(`Note: ${w}`);
|
|
669
844
|
const text = file === '-' ? await readStdin() : fs.readFileSync(file, 'utf8');
|
|
670
845
|
const { prompts, skipped } = parsePrompts(text, opts.separator);
|
|
671
846
|
if (!prompts.length) throw new Error(`no prompts found in ${file === '-' ? 'stdin' : file}`);
|
|
@@ -674,6 +849,7 @@ program
|
|
|
674
849
|
if (skipped) {
|
|
675
850
|
console.error(edim(`(skipped ${skipped} blank/comment line${skipped === 1 ? '' : 's'})`));
|
|
676
851
|
}
|
|
852
|
+
if (prompts.length > 1) src.files = sources.uploadAll(src.files);
|
|
677
853
|
// Auto-tag so the batch is collectable as a unit; a user tag wins.
|
|
678
854
|
const tag = opts.tag || `batch-${crypto.randomUUID().slice(0, 6)}`;
|
|
679
855
|
|
|
@@ -681,10 +857,11 @@ program
|
|
|
681
857
|
const planning = opts.plan ? true : undefined;
|
|
682
858
|
|
|
683
859
|
if (opts.dryRun) {
|
|
684
|
-
|
|
860
|
+
const payload = { tag, dry_run: true, agent: agent || null, plan: !!opts.plan, prompts, ...sources.preview(src) };
|
|
861
|
+
emit(opts.json, payload, () => {
|
|
685
862
|
if (agent) {
|
|
686
863
|
// The whole point of the guard: N × the per-task band, up front.
|
|
687
|
-
console.log(dryRunSpend(agent, prompts.length, opts.plan));
|
|
864
|
+
console.log(dryRunSpend(agent, prompts.length, opts.plan, src));
|
|
688
865
|
} else {
|
|
689
866
|
console.log(`Batch ${tag}: ${prompts.length} prompt(s) would be submitted:`);
|
|
690
867
|
for (const p of prompts) console.log(` ${snippet(p)}`);
|
|
@@ -695,7 +872,9 @@ program
|
|
|
695
872
|
|
|
696
873
|
// An agent batch multiplies a per-task dollar band by the whole file, so
|
|
697
874
|
// it is confirmed as one total before a single row is written.
|
|
698
|
-
if (agent) await confirmSpend(agent, prompts.length, opts, opts.plan);
|
|
875
|
+
if (agent) await confirmSpend(agent, prompts.length, opts, opts.plan, src);
|
|
876
|
+
// Uploaded once (see uploadAll) and attached to every prompt in the file.
|
|
877
|
+
const attached = await attachFiles(src.files, opts.plan);
|
|
699
878
|
|
|
700
879
|
// One failed submit must not sink the batch: mark that task failed and
|
|
701
880
|
// keep going. mapLimit preserves input order, so the report is stable.
|
|
@@ -708,6 +887,7 @@ program
|
|
|
708
887
|
tag,
|
|
709
888
|
kind: opts.plan ? 'plan' : 'task',
|
|
710
889
|
collaborativePlanning: planning,
|
|
890
|
+
...sourceColumns(src, attached.record),
|
|
711
891
|
});
|
|
712
892
|
try {
|
|
713
893
|
const r = await gemini.submit(prompt, {
|
|
@@ -715,6 +895,7 @@ program
|
|
|
715
895
|
agent,
|
|
716
896
|
systemInstruction: opts.system,
|
|
717
897
|
collaborativePlanning: planning,
|
|
898
|
+
...sourceArgs(src, attached.items),
|
|
718
899
|
});
|
|
719
900
|
store.setInteraction(id, r.interactionId, r.status);
|
|
720
901
|
return { id, interaction_id: r.interactionId, status: r.status, prompt };
|
|
@@ -794,21 +975,11 @@ program
|
|
|
794
975
|
// result being *present*, not truthy: a task that completes with empty
|
|
795
976
|
// text stores `''`, which is exactly the case the cache must still serve
|
|
796
977
|
// -- re-polling it would 404 after 24h, the very thing we cache to avoid.
|
|
797
|
-
if (isSuccess(task.status) && task.result != null && !opts.raw)
|
|
798
|
-
const cits = parseCitations(task.citations);
|
|
799
|
-
emit(opts.json, resultPayload(task, task.status, task.result, cits), () => {
|
|
800
|
-
console.log(withSources(task.result, cits));
|
|
801
|
-
if (task.kind === 'plan') console.error(planFooter(task));
|
|
802
|
-
});
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
978
|
+
if (isSuccess(task.status) && task.result != null && !opts.raw) return printResult(task, cachedResult(task), opts.json);
|
|
805
979
|
const r = await refresh(task);
|
|
806
|
-
if (opts.raw) return console.log(
|
|
980
|
+
if (opts.raw) return console.log(maskRaw(r.raw, task));
|
|
807
981
|
if (isSuccess(r.status)) {
|
|
808
|
-
|
|
809
|
-
console.log(withSources(r.text, r.citations));
|
|
810
|
-
if (task.kind === 'plan') console.error(planFooter(task));
|
|
811
|
-
});
|
|
982
|
+
printResult(task, r, opts.json);
|
|
812
983
|
} else if (isDone(r.status)) {
|
|
813
984
|
emit(opts.json, { id: task.id, status: r.status, error: r.text || null }, () =>
|
|
814
985
|
console.log(`Task ${task.id}: ${colorStatus(r.status)}${r.text ? `\n${r.text}` : ''}`)
|
|
@@ -862,7 +1033,7 @@ program
|
|
|
862
1033
|
planning: false,
|
|
863
1034
|
kind: 'report',
|
|
864
1035
|
input: APPROVE_INPUT,
|
|
865
|
-
prompt:
|
|
1036
|
+
prompt: chainRoot(plan).prompt,
|
|
866
1037
|
line: (newId) => `Task ${newId} submitted (approves plan ${plan.id}).`,
|
|
867
1038
|
});
|
|
868
1039
|
});
|
|
@@ -898,6 +1069,20 @@ function chainOrder(tasks) {
|
|
|
898
1069
|
return out;
|
|
899
1070
|
}
|
|
900
1071
|
|
|
1072
|
+
// A word per kind of source a row used, for the list's SOURCES column.
|
|
1073
|
+
function sourcesUsed(t) {
|
|
1074
|
+
const tools = parseList(t.tools_json) || [];
|
|
1075
|
+
const files = parseList(t.attachments_json) || [];
|
|
1076
|
+
const mcp = tools.filter((x) => x && x.type === 'mcp_server').length;
|
|
1077
|
+
const words = [];
|
|
1078
|
+
if (mcp) words.push(mcp > 1 ? `${mcp} mcp` : 'mcp');
|
|
1079
|
+
if (tools.some((x) => x && x.type === 'file_search')) words.push('store');
|
|
1080
|
+
if (tools.length && !tools.some((x) => x && x.type === 'google_search')) words.push('no-web');
|
|
1081
|
+
if (files.length) words.push(files.length > 1 ? `${files.length} files` : 'file');
|
|
1082
|
+
if (t.visualization) words.push('charts');
|
|
1083
|
+
return words.join(',');
|
|
1084
|
+
}
|
|
1085
|
+
|
|
901
1086
|
program
|
|
902
1087
|
.command('list')
|
|
903
1088
|
.alias('ls')
|
|
@@ -913,20 +1098,26 @@ program
|
|
|
913
1098
|
return die(new Error(`--limit must be a non-negative integer (got ${opts.limit})`));
|
|
914
1099
|
}
|
|
915
1100
|
const tasks = store.listTasks({ status: opts.status, tag: opts.tag, limit: opts.limit });
|
|
916
|
-
if (opts.json)
|
|
1101
|
+
if (opts.json) {
|
|
1102
|
+
const masked = tasks.map((t) => (t.tools_json ? { ...t, tools_json: sources.redactToolsJson(t.tools_json) } : t));
|
|
1103
|
+
return console.log(JSON.stringify(masked, null, 2));
|
|
1104
|
+
}
|
|
917
1105
|
if (!tasks.length) {
|
|
918
1106
|
console.log('No tasks yet. Submit one: gemcatch research "your question"');
|
|
919
1107
|
return;
|
|
920
1108
|
}
|
|
921
|
-
// The AGENT and
|
|
922
|
-
// them, so a pure-model store keeps the compact four-column
|
|
923
|
-
// had. Agent ids are shown compact -- the "-preview-MM-YYYY"
|
|
924
|
-
// version noise in a table (the full id is in --json and in stats).
|
|
1109
|
+
// The AGENT, KIND and SOURCES columns only appear when something in the
|
|
1110
|
+
// listing uses them, so a pure-model store keeps the compact four-column
|
|
1111
|
+
// layout it always had. Agent ids are shown compact -- the "-preview-MM-YYYY"
|
|
1112
|
+
// suffix is version noise in a table (the full id is in --json and in stats).
|
|
925
1113
|
const showAgent = tasks.some((t) => t.agent);
|
|
926
1114
|
const showKind = tasks.some((t) => t.kind && t.kind !== 'task');
|
|
927
1115
|
const shortAgent = (a) => (a ? a.replace(/-preview-\d{2}-\d{4}$/, '') : '-');
|
|
1116
|
+
const used = new Map(tasks.map((t) => [t.id, sourcesUsed(t)]));
|
|
1117
|
+
const srcWidth = Math.max(...[...used.values()].map((u) => u.length));
|
|
1118
|
+
const srcHead = srcWidth ? `${'SOURCES'.padEnd(Math.max(7, srcWidth))} ` : '';
|
|
928
1119
|
console.log(
|
|
929
|
-
dim(`ID AGE STATUS ${showKind ? 'KIND ' : ''}${showAgent ? 'AGENT ' : ''}PROMPT`)
|
|
1120
|
+
dim(`ID AGE STATUS ${showKind ? 'KIND ' : ''}${showAgent ? 'AGENT ' : ''}${srcHead}PROMPT`)
|
|
930
1121
|
);
|
|
931
1122
|
for (const { task: t, depth } of chainOrder(tasks)) {
|
|
932
1123
|
const status = t.status || PENDING;
|
|
@@ -934,11 +1125,12 @@ program
|
|
|
934
1125
|
const pad = ' '.repeat(Math.max(0, 16 - status.length));
|
|
935
1126
|
const kindCol = showKind ? `${(t.kind || 'task').padEnd(7)} ` : '';
|
|
936
1127
|
const agentCol = showAgent ? `${shortAgent(t.agent).padEnd(18)} ` : '';
|
|
1128
|
+
const srcCol = srcWidth ? `${(used.get(t.id) || '-').padEnd(Math.max(7, srcWidth))} ` : '';
|
|
937
1129
|
// Indent the prompt, not the id: the fixed-width columns stay aligned and
|
|
938
1130
|
// the chain still reads as one thing.
|
|
939
1131
|
const branch = depth ? `${' '.repeat(depth - 1)}└─ ` : '';
|
|
940
1132
|
console.log(
|
|
941
|
-
`${t.id} ${age(t.created_at).padEnd(4)} ${colorStatus(status)}${pad} ${kindCol}${agentCol}${branch}${snippet(t.prompt)}`
|
|
1133
|
+
`${t.id} ${age(t.created_at).padEnd(4)} ${colorStatus(status)}${pad} ${kindCol}${agentCol}${srcCol}${branch}${snippet(t.prompt)}`
|
|
942
1134
|
);
|
|
943
1135
|
}
|
|
944
1136
|
});
|
|
@@ -980,18 +1172,38 @@ program
|
|
|
980
1172
|
return;
|
|
981
1173
|
}
|
|
982
1174
|
|
|
1175
|
+
// Images go next to the export file so its links still resolve when the
|
|
1176
|
+
// file is moved with them; on stdout they point into the data dir.
|
|
1177
|
+
const exportImages = (t) =>
|
|
1178
|
+
(parseList(t.images_json) || []).map((p) => {
|
|
1179
|
+
if (!opts.out) return p;
|
|
1180
|
+
const name = path.basename(p);
|
|
1181
|
+
try {
|
|
1182
|
+
fs.copyFileSync(p, path.join(path.dirname(path.resolve(opts.out)), name));
|
|
1183
|
+
return name;
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
console.error(`Note: could not copy ${p} next to the export (${err.message}).`);
|
|
1186
|
+
return p;
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
|
|
983
1190
|
let output;
|
|
984
1191
|
if (opts.format === 'json') {
|
|
985
1192
|
output = JSON.stringify(
|
|
986
|
-
rows.map((t) =>
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1193
|
+
rows.map((t) => {
|
|
1194
|
+
const row = {
|
|
1195
|
+
id: t.id,
|
|
1196
|
+
tag: t.tag,
|
|
1197
|
+
status: t.status,
|
|
1198
|
+
kind: t.kind || 'task',
|
|
1199
|
+
prompt: t.prompt,
|
|
1200
|
+
result: t.result,
|
|
1201
|
+
created_at: t.created_at,
|
|
1202
|
+
};
|
|
1203
|
+
const images = exportImages(t);
|
|
1204
|
+
if (images.length) row.images = images;
|
|
1205
|
+
return row;
|
|
1206
|
+
}),
|
|
995
1207
|
null,
|
|
996
1208
|
2
|
|
997
1209
|
);
|
|
@@ -1002,7 +1214,9 @@ program
|
|
|
1002
1214
|
const head = (t.prompt || '(no prompt)').replace(/\s+/g, ' ').trim();
|
|
1003
1215
|
const body = t.result && t.result.trim() ? t.result : '_(empty result)_';
|
|
1004
1216
|
const kind = t.kind && t.kind !== 'task' ? ` · ${t.kind}` : '';
|
|
1005
|
-
|
|
1217
|
+
const link = (p) => (path.isAbsolute(p) ? pathToFileURL(p).href : p);
|
|
1218
|
+
const images = exportImages(t).map((p, i) => `\n\n})`);
|
|
1219
|
+
return `## ${head}\n\n\`${t.id}\` · ${t.status}${kind} · ${when} UTC\n\n${body}${images.join('')}`;
|
|
1006
1220
|
})
|
|
1007
1221
|
.join('\n\n---\n\n');
|
|
1008
1222
|
}
|
|
@@ -1041,16 +1255,24 @@ program
|
|
|
1041
1255
|
);
|
|
1042
1256
|
}
|
|
1043
1257
|
done.reverse(); // oldest first, so the sources read in submission order
|
|
1044
|
-
const
|
|
1258
|
+
const results = done
|
|
1045
1259
|
.map((t, i) => `## Source ${i + 1}: ${(t.prompt || '').replace(/\s+/g, ' ').trim()}\n\n${t.result}`)
|
|
1046
1260
|
.join('\n\n');
|
|
1047
1261
|
const prompt =
|
|
1048
1262
|
`Synthesize the following ${done.length} research result(s) into one coherent summary.` +
|
|
1049
1263
|
' Note where they agree and disagree, and do not simply repeat each verbatim.\n\n' +
|
|
1050
|
-
|
|
1264
|
+
results;
|
|
1051
1265
|
// The digest is itself a task, tagged so it is findable but kept out of
|
|
1052
1266
|
// the source tag so a later digest never digests its own output.
|
|
1053
|
-
|
|
1267
|
+
// A model can't carry the sources' charts forward, so the digest lists them.
|
|
1268
|
+
const images = done.flatMap((t) => parseList(t.images_json) || []);
|
|
1269
|
+
id = store.createTask({
|
|
1270
|
+
prompt,
|
|
1271
|
+
model: opts.model,
|
|
1272
|
+
systemInstruction: opts.system,
|
|
1273
|
+
tag: `${opts.tag}-digest`,
|
|
1274
|
+
imagesJson: images.length ? JSON.stringify(images) : null,
|
|
1275
|
+
});
|
|
1054
1276
|
const r = await gemini.submit(prompt, { model: opts.model, systemInstruction: opts.system });
|
|
1055
1277
|
store.setInteraction(id, r.interactionId, r.status);
|
|
1056
1278
|
if (!opts.json) console.error(edim(`Digesting ${done.length} result(s) tagged ${opts.tag} -> task ${id}.`));
|
|
@@ -1213,13 +1435,7 @@ async function watchTask(task, intervalMs, json) {
|
|
|
1213
1435
|
console.error(edim(`[${new Date().toISOString().slice(11, 19)}] ${task.id}: `) + ecolorStatus(r.status));
|
|
1214
1436
|
last = r.status;
|
|
1215
1437
|
}
|
|
1216
|
-
if (isSuccess(r.status))
|
|
1217
|
-
emit(json, resultPayload(task, r.status, r.text, r.citations), () => {
|
|
1218
|
-
console.log(withSources(r.text, r.citations));
|
|
1219
|
-
if (task.kind === 'plan') console.error(planFooter(task));
|
|
1220
|
-
});
|
|
1221
|
-
return;
|
|
1222
|
-
}
|
|
1438
|
+
if (isSuccess(r.status)) return printResult(task, r, json);
|
|
1223
1439
|
if (isDone(r.status)) {
|
|
1224
1440
|
emit(json, { id: task.id, status: r.status, error: r.text || null }, () => {
|
|
1225
1441
|
console.error(`Task ${task.id} ended: ${ecolorStatus(r.status)}`);
|
|
@@ -1243,14 +1459,7 @@ program
|
|
|
1243
1459
|
try {
|
|
1244
1460
|
// Serve a completed result from cache -- present, not merely truthy, so an
|
|
1245
1461
|
// empty-text completion is served instead of re-polled (and lost at 24h).
|
|
1246
|
-
if (isSuccess(task.status) && task.result != null)
|
|
1247
|
-
const cits = parseCitations(task.citations);
|
|
1248
|
-
emit(opts.json, resultPayload(task, task.status, task.result, cits), () => {
|
|
1249
|
-
console.log(withSources(task.result, cits));
|
|
1250
|
-
if (task.kind === 'plan') console.error(planFooter(task));
|
|
1251
|
-
});
|
|
1252
|
-
return;
|
|
1253
|
-
}
|
|
1462
|
+
if (isSuccess(task.status) && task.result != null) return printResult(task, cachedResult(task), opts.json);
|
|
1254
1463
|
if (opts.interval != null && (!Number.isFinite(opts.interval) || opts.interval <= 0)) {
|
|
1255
1464
|
return die(new Error(`--interval must be a positive number of seconds (got ${opts.interval})`));
|
|
1256
1465
|
}
|
|
@@ -1299,7 +1508,10 @@ program
|
|
|
1299
1508
|
console.error(edim(` (remote delete failed for ${task.id}: ${err.message})`));
|
|
1300
1509
|
}
|
|
1301
1510
|
}
|
|
1302
|
-
if (store.removeTask(task.id))
|
|
1511
|
+
if (store.removeTask(task.id)) {
|
|
1512
|
+
removeImages(task);
|
|
1513
|
+
removed += 1;
|
|
1514
|
+
}
|
|
1303
1515
|
}
|
|
1304
1516
|
console.log(`Removed ${removed} task${removed === 1 ? '' : 's'}.`);
|
|
1305
1517
|
});
|
|
@@ -1330,6 +1542,7 @@ program
|
|
|
1330
1542
|
return;
|
|
1331
1543
|
}
|
|
1332
1544
|
const n = store.removeMany(doomed.map((t) => t.id));
|
|
1545
|
+
for (const t of doomed) removeImages(t);
|
|
1333
1546
|
console.log(`Pruned ${n} task${n === 1 ? '' : 's'}.`);
|
|
1334
1547
|
});
|
|
1335
1548
|
|