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/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, so they are printed under the result rather than
94
- // left in the database. A run without citations prints exactly as before.
95
- function withSources(text, citations) {
96
- const body = text || '(empty response)';
97
- if (!Array.isArray(citations) || !citations.length) return body;
98
- const lines = citations.map((c, i) => {
99
- const title = (c && (c.title || c.text)) || '';
100
- const url = (c && (c.url || c.uri)) || '';
101
- return ` [${i + 1}] ${[title, url].filter(Boolean).join(' — ') || JSON.stringify(c)}`;
102
- });
103
- return `${body}\n\nSources:\n${lines.join('\n')}`;
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 column holds JSON (or NULL). Parsed defensively: a corrupt row
107
- // degrades to "no sources", never a crash in the middle of printing a result.
108
- function parseCitations(raw) {
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
- // One sentence for a --dry-run: the band, the honesty note, and that nothing went.
187
- function dryRunSpend(agentId, count, planning) {
188
- return `${spendLine(agentId, count, planning)}${spendNote(planning, false)}. Nothing submitted (--dry-run).`;
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 that started the chain rather
349
- // than the "plan looks good" line actually sent -- that is what keeps `list` and
350
- // `export` reading as research instead of as protocol chatter.
351
- function rootPrompt(task) {
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.prompt;
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
- await confirmSpend(plan.agent, 1, opts, turn.planning);
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) extra.citations = JSON.stringify(r.citations);
468
- } else if (r.text) extra.error = r.text;
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
- { dry_run: true, agent: agent || null, model: agent ? null : opts.model, plan: !!opts.plan, prompt },
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
- emit(opts.json, { tag, dry_run: true, agent: agent || null, plan: !!opts.plan, prompts }, () => {
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(JSON.stringify(r.raw, null, 2));
980
+ if (opts.raw) return console.log(maskRaw(r.raw, task));
807
981
  if (isSuccess(r.status)) {
808
- emit(opts.json, resultPayload(task, r.status, r.text, r.citations), () => {
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: rootPrompt(plan),
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) return console.log(JSON.stringify(tasks, null, 2));
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 KIND columns only appear when something in the listing uses
922
- // them, so a pure-model store keeps the compact four-column layout it always
923
- // had. Agent ids are shown compact -- the "-preview-MM-YYYY" suffix is
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
- id: t.id,
988
- tag: t.tag,
989
- status: t.status,
990
- kind: t.kind || 'task',
991
- prompt: t.prompt,
992
- result: t.result,
993
- created_at: t.created_at,
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
- return `## ${head}\n\n\`${t.id}\` · ${t.status}${kind} · ${when} UTC\n\n${body}`;
1217
+ const link = (p) => (path.isAbsolute(p) ? pathToFileURL(p).href : p);
1218
+ const images = exportImages(t).map((p, i) => `\n\n![${t.id} image ${i + 1}](${link(p)})`);
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 sources = done
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
- sources;
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
- id = store.createTask({ prompt, model: opts.model, systemInstruction: opts.system, tag: `${opts.tag}-digest` });
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)) removed += 1;
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