gemcatch 0.3.0 → 0.5.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.
Files changed (6) hide show
  1. package/CHANGELOG.md +110 -1
  2. package/README.md +137 -7
  3. package/db.js +60 -3
  4. package/gemini.js +125 -10
  5. package/index.js +576 -56
  6. package/package.json +7 -2
package/index.js CHANGED
@@ -89,6 +89,327 @@ function needTask(id) {
89
89
  return task;
90
90
  }
91
91
 
92
+ // 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')}`;
104
+ }
105
+
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) {
109
+ if (!raw) return null;
110
+ try {
111
+ const v = JSON.parse(raw);
112
+ return Array.isArray(v) && v.length ? v : null;
113
+ } catch (_) {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ // --- spend guard ----------------------------------------------------------
119
+
120
+ // Deep Research agents are billed PER TASK, not per token -- the docs put
121
+ // Deep Research at $1.00-$3.00 and Deep Research Max at $3.00-$7.00 -- and
122
+ // gemcatch's whole ergonomic is firing a file of prompts at once, which turns
123
+ // one careless `batch --agent` into a three-figure command. So no agent
124
+ // submission happens without the cost being shown and confirmed: interactively
125
+ // on a TTY, via --yes otherwise, and --dry-run previews without submitting.
126
+ // The bands are quoted with the docs' own hedge ("estimates based on preview
127
+ // rates and subject to change"), never as authoritative.
128
+
129
+ // A planning turn is a task and is billed as one. The docs publish ONE band per
130
+ // task and price no planning turn separately, so it is quoted at the same band
131
+ // and the line says so outright. Planning buys you a look at the plan before you
132
+ // commit to the research run; it does not buy you a discount.
133
+ const PLAN_NOTE = 'the docs price per task and do not price a planning turn separately';
134
+
135
+ function bandText(agentId, count) {
136
+ const band = gemini.AGENT_PRICE_BANDS[agentId];
137
+ if (!band) return 'no published price band for this agent';
138
+ const money = (n) => `$${(n * count).toFixed(2)}`;
139
+ return count > 1
140
+ ? `estimated ${money(band[0])}–${money(band[1])} total`
141
+ : `estimated ${money(band[0])}–${money(band[1])} for this task`;
142
+ }
143
+
144
+ function spendLine(agentId, count, planning) {
145
+ const head = count > 1 ? `${count} prompts × ${agentId}` : `Agent ${agentId}`;
146
+ return `${head}${planning ? ' (planning turn)' : ''} — ${bandText(agentId, count)}`;
147
+ }
148
+
149
+ // The parenthetical after the band. `hedge` is the docs' "preview rates" caveat,
150
+ // which the confirmation carries; --dry-run already reads as a projection.
151
+ function spendNote(planning, hedge) {
152
+ const parts = [];
153
+ if (hedge) parts.push('preview rates, subject to change');
154
+ if (planning) parts.push(PLAN_NOTE);
155
+ return parts.length ? ` (${parts.join('; ')})` : '';
156
+ }
157
+
158
+ // What the agent runs in the store have plausibly cost, from the same per-task
159
+ // bands the guard quotes before each one. A plan chain bills per turn, so this
160
+ // counts plans and refinements alongside reports -- that is the number worth
161
+ // knowing. Agents with no published band are counted separately rather than
162
+ // silently priced at zero. Null when nothing has been billed at all.
163
+ function estimatedSpend(agentRows) {
164
+ let low = 0;
165
+ let high = 0;
166
+ let tasks = 0;
167
+ let unpriced = 0;
168
+ for (const a of agentRows) {
169
+ const band = gemini.AGENT_PRICE_BANDS[a.agent];
170
+ if (!band) {
171
+ unpriced += a.n;
172
+ continue;
173
+ }
174
+ low += band[0] * a.n;
175
+ high += band[1] * a.n;
176
+ tasks += a.n;
177
+ }
178
+ if (!tasks && !unpriced) return null;
179
+ // Nothing priced means the total is unknown, NOT zero. Reporting $0.00 for
180
+ // runs that cost real money is the exact dishonesty this guard exists to
181
+ // avoid, so low/high are null and the caller says "unknown" instead.
182
+ if (!tasks) return { low: null, high: null, tasks: 0, unpriced };
183
+ return { low, high, tasks, unpriced };
184
+ }
185
+
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).`;
189
+ }
190
+
191
+ function askYesNo(question) {
192
+ const readline = require('readline');
193
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
194
+ return new Promise((resolve) => {
195
+ rl.question(question, (answer) => {
196
+ rl.close();
197
+ resolve(/^y(es)?$/i.test((answer || '').trim()));
198
+ });
199
+ });
200
+ }
201
+
202
+ // Returns only when the submission is confirmed; otherwise it exits (declined)
203
+ // or throws (no way to ask). Runs BEFORE any row is written, so a declined or
204
+ // refused submission leaves the tasks table untouched.
205
+ async function confirmSpend(agentId, count, opts, planning) {
206
+ console.error(`${spendLine(agentId, count, planning)}${spendNote(planning, true)}.`);
207
+ if (opts.yes) return;
208
+ // GEMCATCH_ASSUME_TTY lets the offline suite drive the interactive branch
209
+ // through a pipe; real non-TTY callers (cron, CI, scripts) must say --yes.
210
+ const interactive = process.stdin.isTTY || process.env.GEMCATCH_ASSUME_TTY === '1';
211
+ if (!interactive) {
212
+ throw new Error(
213
+ 'stdin is not a TTY, so this agent submission cannot be confirmed interactively.\n' +
214
+ ' Pass --yes to confirm the cost above, or --dry-run to preview without submitting.'
215
+ );
216
+ }
217
+ if (!(await askYesNo('Submit? [y/N] '))) {
218
+ console.error('Nothing submitted.');
219
+ process.exit(1);
220
+ }
221
+ }
222
+
223
+ // Shared by research and batch: resolve the agent alias and reject the
224
+ // ambiguous combination before anything is stored or sent. `--model` counts
225
+ // only when the user actually typed it -- commander fills in the default
226
+ // otherwise, and the default must not poison every agent run.
227
+ function resolveAgentOpts(opts, cmd) {
228
+ if (!opts.agent) {
229
+ // Collaborative planning is an agent_config field on an agent run. A model
230
+ // run has no plan turn at all, so --plan without --agent is a mistake worth
231
+ // naming rather than a flag that quietly does nothing.
232
+ if (opts.plan) {
233
+ throw new Error(
234
+ '--plan is a research-agent feature: collaborative planning applies to an agent, not a model, ' +
235
+ 'and a model run has no plan turn.\n Try: --agent deep-research --plan'
236
+ );
237
+ }
238
+ return null;
239
+ }
240
+ if (cmd.getOptionValueSource('model') === 'cli') {
241
+ throw new Error(
242
+ '--model and --agent are mutually exclusive: an agent run is submitted with `agent` ' +
243
+ 'instead of `model`, and the agent picks its own models. Drop one of the two.'
244
+ );
245
+ }
246
+ return gemini.resolveAgent(opts.agent);
247
+ }
248
+
249
+ // --- plan chains ----------------------------------------------------------
250
+
251
+ // `collaborative_planning: true` makes the agent return a research plan instead
252
+ // of a report. That plan is a decision point, not a deliverable: you read it,
253
+ // optionally `refine` it, and `approve` it to spend on the research run itself.
254
+ // Each turn is its own task row, linked to the one it continues by parent_id
255
+ // locally and by previous_interaction_id on the wire.
256
+
257
+ // What the approval turn sends as `input`. The plan is already in the
258
+ // conversation via previous_interaction_id, so this turn only has to say yes --
259
+ // the docs' own example sends a one-line confirmation, not the question again.
260
+ const APPROVE_INPUT = 'Plan looks good, proceed with the research.';
261
+
262
+ // The reason refresh() records when a poll 404s. Named because `approve` reads
263
+ // it back to tell an expired plan apart from any other terminal one.
264
+ const EXPIRED_ERROR = 'interaction not found (expired or deleted)';
265
+
266
+ const RETENTION_NOTE =
267
+ 'The Interactions API retains interactions for 1 day on the free tier (55 days on paid).';
268
+
269
+ // A finished plan is a decision point, so the next command is spelled out under
270
+ // it. Guidance, so it goes to stderr like the rest -- `gemcatch get <plan> >
271
+ // plan.md` still captures only the plan.
272
+ function planFooter(task) {
273
+ return `Approve with: gemcatch approve ${task.id} · Refine with: gemcatch refine ${task.id} "..."`;
274
+ }
275
+
276
+ // The result payload for `get`/`watch`. The plan-chain fields ride along only on
277
+ // a plan row, so a model run's --json shape is exactly what it always was.
278
+ function resultPayload(task, status, result, citations) {
279
+ const p = { id: task.id, status, result, citations: citations || null };
280
+ if (task.kind === 'plan') {
281
+ p.kind = 'plan';
282
+ p.approve = `gemcatch approve ${task.id}`;
283
+ p.refine = `gemcatch refine ${task.id} "<instruction>"`;
284
+ }
285
+ return p;
286
+ }
287
+
288
+ // Both continuation commands need the same thing: a plan row that completed and
289
+ // whose interaction the server can still resolve. Anything else exits here,
290
+ // before a row is written or a request is sent.
291
+ function needPlan(id, verb) {
292
+ const task = needTask(id);
293
+ if (task.kind !== 'plan') {
294
+ die(
295
+ new Error(
296
+ `Task ${task.id} is not a plan (kind: ${task.kind || 'task'}), so there is nothing to ${verb}.\n` +
297
+ ' Plans come from: gemcatch research "<prompt>" --agent deep-research --plan'
298
+ )
299
+ );
300
+ }
301
+ if (task.status === 'incomplete' && task.error === EXPIRED_ERROR) {
302
+ die(
303
+ new Error(
304
+ `Plan ${task.id}'s interaction was dropped server-side, so it can no longer be continued.\n` +
305
+ ` ${RETENTION_NOTE}\n` +
306
+ ` Submit a fresh plan: gemcatch research "<prompt>" --agent ${task.agent || '<agent>'} --plan`
307
+ )
308
+ );
309
+ }
310
+ // A plan that finished any way other than `completed` is never coming back, so
311
+ // "wait for it" would be wrong advice. Only a plan still in flight gets
312
+ // pointed at `watch`.
313
+ if (isDone(task.status) && !isSuccess(task.status)) {
314
+ die(
315
+ new Error(
316
+ `Plan ${task.id} ended ${task.status}, so there is nothing to ${verb}.\n` +
317
+ ` Submit a fresh plan: gemcatch research "<prompt>" --agent ${task.agent || '<agent>'} --plan`
318
+ )
319
+ );
320
+ }
321
+ if (!isSuccess(task.status)) {
322
+ die(
323
+ new Error(
324
+ `Plan ${task.id} has not completed yet (status: ${task.status}), so there is nothing to ${verb}.\n` +
325
+ ` Wait for it: gemcatch watch ${task.id}`
326
+ )
327
+ );
328
+ }
329
+ if (!task.interaction_id) die(new Error(`Plan ${task.id} was never submitted.`));
330
+ return task;
331
+ }
332
+
333
+ // A plan can complete locally and still expire server-side before you approve
334
+ // it, in which case the continuation 404s on an id the user never typed. Name
335
+ // the retention window instead of passing that through raw.
336
+ function expiredHint(err, plan) {
337
+ if (!err || err.httpStatus !== 404) return err;
338
+ const e = new Error(
339
+ `the plan's interaction (${plan.interaction_id}) is gone server-side, so it cannot be continued.\n` +
340
+ ` ${RETENTION_NOTE}\n` +
341
+ ` Submit a fresh plan: gemcatch research "<prompt>" --agent ${plan.agent || '<agent>'} --plan`
342
+ );
343
+ e.code = 'API_ERROR';
344
+ e.httpStatus = 404;
345
+ return e;
346
+ }
347
+
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) {
352
+ let cur = task;
353
+ const seen = new Set([task.id]);
354
+ while (cur.parent_id && !seen.has(cur.parent_id)) {
355
+ seen.add(cur.parent_id);
356
+ const parent = store.getTask(cur.parent_id);
357
+ if (!parent) break;
358
+ cur = parent;
359
+ }
360
+ return cur.prompt;
361
+ }
362
+
363
+ // `refine` (another plan) and `approve` (the report) are the same submission --
364
+ // same agent, same tag, linked to the plan by previous_interaction_id --
365
+ // differing only in the collaborative_planning flag they send, the kind they
366
+ // store and the line they print. So they share one path, and the spend guard is
367
+ // on it exactly once.
368
+ async function continuePlan(plan, opts, turn) {
369
+ let id;
370
+ try {
371
+ if (opts.dryRun) {
372
+ emit(
373
+ opts.json,
374
+ {
375
+ dry_run: true,
376
+ agent: plan.agent,
377
+ kind: turn.kind,
378
+ parent_id: plan.id,
379
+ previous_interaction_id: plan.interaction_id,
380
+ input: turn.input,
381
+ },
382
+ () => console.log(dryRunSpend(plan.agent, 1, turn.planning))
383
+ );
384
+ return;
385
+ }
386
+ await confirmSpend(plan.agent, 1, opts, turn.planning);
387
+ id = store.createTask({
388
+ prompt: turn.prompt,
389
+ agent: plan.agent,
390
+ tag: plan.tag,
391
+ kind: turn.kind,
392
+ parentId: plan.id,
393
+ collaborativePlanning: turn.planning,
394
+ previousInteractionId: plan.interaction_id,
395
+ });
396
+ const r = await gemini.submit(turn.input, {
397
+ agent: plan.agent,
398
+ collaborativePlanning: turn.planning,
399
+ previousInteractionId: plan.interaction_id,
400
+ });
401
+ store.setInteraction(id, r.interactionId, r.status);
402
+ emit(
403
+ opts.json,
404
+ { id, interaction_id: r.interactionId, status: r.status, kind: turn.kind, parent_id: plan.id },
405
+ () => console.log(turn.line(id))
406
+ );
407
+ } catch (err) {
408
+ markSubmitFailure(id, err);
409
+ die(expiredHint(err, plan));
410
+ }
411
+ }
412
+
92
413
  // --- input ----------------------------------------------------------------
93
414
 
94
415
  function readStdin() {
@@ -132,21 +453,37 @@ async function refresh(task) {
132
453
  // the end of time. Any other error (5xx, network) is transient and is
133
454
  // re-thrown for the caller to retry on its next pass.
134
455
  if (err && err.httpStatus === 404) {
135
- store.setStatus(task.id, 'incomplete', { error: 'interaction not found (expired or deleted)' });
456
+ store.setStatus(task.id, 'incomplete', { error: EXPIRED_ERROR });
136
457
  return { status: 'incomplete', text: null, usage: null, raw: null };
137
458
  }
138
459
  throw err;
139
460
  }
140
461
  const extra = {};
141
462
  if (isDone(r.status)) {
142
- if (isSuccess(r.status)) extra.result = r.text;
143
- else if (r.text) extra.error = r.text;
463
+ if (isSuccess(r.status)) {
464
+ extra.result = r.text;
465
+ // Agent runs return citations with the report; the docs tell users to
466
+ // 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;
144
469
  }
145
470
  if (r.usage) extra.usage = JSON.stringify(r.usage);
146
471
  store.setStatus(task.id, r.status, extra);
147
472
  return r;
148
473
  }
149
474
 
475
+ // Only a failed *submit* should mark a task failed. Once it has an
476
+ // interaction_id it is live on the server, and a later watch/poll error must
477
+ // never overwrite it to failed -- that would drop it from the active set and the
478
+ // daemon would abandon a task whose result is still coming. Leave it active; the
479
+ // daemon (or a later `get`) collects it. Every command that submits calls this
480
+ // on its way out, so the rule cannot drift between them.
481
+ function markSubmitFailure(id, err) {
482
+ if (!id) return;
483
+ const t = store.getTask(id);
484
+ if (!t || !t.interaction_id) store.setStatus(id, 'failed', { error: err.message });
485
+ }
486
+
150
487
  // Bounds how many polls are open at once. The *rate* limit is enforced in
151
488
  // gemini.js (GEMCATCH_RPM), which is the part that keeps a wide fan-out inside the
152
489
  // free tier's requests-per-minute allowance.
@@ -177,44 +514,64 @@ program
177
514
  .argument('[prompt]', 'what you want researched; "-" reads stdin')
178
515
  .option('-f, --file <path>', 'read the prompt from a file')
179
516
  .option('-m, --model <id>', 'model to use', gemini.DEFAULT_MODEL)
517
+ .option('-a, --agent <id>', 'submit to a research agent instead of a model (e.g. deep-research)')
518
+ .option('--plan', 'ask the agent for a research plan first, to refine and approve (needs --agent)')
180
519
  .option('-s, --system <text>', 'system instruction')
181
520
  .option('-t, --tag <tag>', 'label for filtering with `gemcatch list --tag`')
182
521
  .option('-w, --watch', 'wait for the result instead of exiting')
522
+ .option('--yes', 'confirm the agent cost without asking (required when stdin is not a TTY)')
523
+ .option('--dry-run', 'show what would be submitted (and what it would cost); submit nothing')
183
524
  .option('--json', 'machine-readable output')
184
525
  .description('submit a background task and exit immediately')
185
- .action(async (promptArg, opts) => {
526
+ .action(async (promptArg, opts, cmd) => {
186
527
  let id;
187
528
  try {
529
+ const agent = resolveAgentOpts(opts, cmd);
188
530
  const prompt = await resolvePrompt(promptArg, opts);
531
+ // undefined, not false: an ordinary run must keep sending no agent_config
532
+ // at all, exactly as it did before collaborative planning existed.
533
+ const planning = opts.plan ? true : undefined;
534
+ if (opts.dryRun) {
535
+ emit(
536
+ opts.json,
537
+ { dry_run: true, agent: agent || null, model: agent ? null : opts.model, plan: !!opts.plan, prompt },
538
+ () => {
539
+ if (agent) console.log(dryRunSpend(agent, 1, opts.plan));
540
+ else console.log(`Would submit to ${opts.model}: ${snippet(prompt)}. Nothing submitted (--dry-run).`);
541
+ }
542
+ );
543
+ return;
544
+ }
545
+ if (agent) await confirmSpend(agent, 1, opts, opts.plan);
189
546
  id = store.createTask({
190
547
  prompt,
191
- model: opts.model,
548
+ model: agent ? null : opts.model,
549
+ agent,
192
550
  systemInstruction: opts.system,
193
551
  tag: opts.tag,
552
+ kind: opts.plan ? 'plan' : 'task',
553
+ collaborativePlanning: planning,
554
+ });
555
+ const r = await gemini.submit(prompt, {
556
+ model: opts.model,
557
+ agent,
558
+ systemInstruction: opts.system,
559
+ collaborativePlanning: planning,
194
560
  });
195
- const r = await gemini.submit(prompt, { model: opts.model, systemInstruction: opts.system });
196
561
  store.setInteraction(id, r.interactionId, r.status);
197
562
  if (opts.watch) {
198
563
  // Under --watch the submit line is progress, not the answer, so it
199
564
  // goes to stderr -- `gemcatch research -w "..." > out.txt` then captures
200
565
  // only the result.
201
- if (!opts.json) console.error(edim(`Task ${id} submitted.`));
566
+ if (!opts.json) console.error(edim(`${opts.plan ? 'Plan task' : 'Task'} ${id} submitted.`));
202
567
  await watchTask(store.getTask(id), DEFAULT_POLL_MS, opts.json);
203
568
  return;
204
569
  }
205
- emit(opts.json, { id, interaction_id: r.interactionId, status: r.status }, () =>
206
- console.log(`Task ${id} submitted. Run: gemcatch get ${id} when ready.`)
570
+ emit(opts.json, { id, interaction_id: r.interactionId, status: r.status, kind: opts.plan ? 'plan' : 'task' }, () =>
571
+ console.log(`${opts.plan ? 'Plan task' : 'Task'} ${id} submitted. Run: gemcatch get ${id} when ready.`)
207
572
  );
208
573
  } catch (err) {
209
- // Only a failed *submit* should mark the task failed. Once it has an
210
- // interaction_id it is live on the server, and a later watch/poll error
211
- // must never overwrite it to failed -- that would drop it from the active
212
- // set and the daemon would abandon a task whose result is still coming.
213
- // Leave it active; the daemon (or a later `get`) collects it.
214
- if (id) {
215
- const t = store.getTask(id);
216
- if (!t || !t.interaction_id) store.setStatus(id, 'failed', { error: err.message });
217
- }
574
+ markSubmitFailure(id, err);
218
575
  die(err);
219
576
  }
220
577
  });
@@ -296,15 +653,19 @@ program
296
653
  .command('batch')
297
654
  .argument('<file>', 'prompts file — one per line, or "-" to read stdin')
298
655
  .option('-m, --model <id>', 'model to use', gemini.DEFAULT_MODEL)
656
+ .option('-a, --agent <id>', 'submit every prompt to a research agent instead of a model')
657
+ .option('--plan', 'ask the agent for a research plan per prompt, to refine and approve (needs --agent)')
299
658
  .option('-s, --system <text>', 'system instruction')
300
659
  .option('-t, --tag <tag>', 'tag the whole batch (default: batch-<hex>)')
301
660
  .option('--separator <str>', 'split the file on this delimiter line for multi-line prompts')
302
661
  .option('-w, --watch', 'submit all, then poll until the whole batch finishes')
662
+ .option('--yes', 'confirm the agent cost without asking (required when stdin is not a TTY)')
303
663
  .option('--dry-run', 'parse and list what would be submitted; submit nothing')
304
664
  .option('--json', 'machine-readable output')
305
665
  .description('submit many background tasks from a file, tagged as one batch')
306
- .action(async (file, opts) => {
666
+ .action(async (file, opts, cmd) => {
307
667
  try {
668
+ const agent = resolveAgentOpts(opts, cmd);
308
669
  const text = file === '-' ? await readStdin() : fs.readFileSync(file, 'utf8');
309
670
  const { prompts, skipped } = parsePrompts(text, opts.separator);
310
671
  if (!prompts.length) throw new Error(`no prompts found in ${file === '-' ? 'stdin' : file}`);
@@ -316,20 +677,45 @@ program
316
677
  // Auto-tag so the batch is collectable as a unit; a user tag wins.
317
678
  const tag = opts.tag || `batch-${crypto.randomUUID().slice(0, 6)}`;
318
679
 
680
+ // undefined, not false: an ordinary run must keep sending no agent_config.
681
+ const planning = opts.plan ? true : undefined;
682
+
319
683
  if (opts.dryRun) {
320
- emit(opts.json, { tag, dry_run: true, prompts }, () => {
321
- console.log(`Batch ${tag}: ${prompts.length} prompt(s) would be submitted:`);
322
- for (const p of prompts) console.log(` ${snippet(p)}`);
684
+ emit(opts.json, { tag, dry_run: true, agent: agent || null, plan: !!opts.plan, prompts }, () => {
685
+ if (agent) {
686
+ // The whole point of the guard: N × the per-task band, up front.
687
+ console.log(dryRunSpend(agent, prompts.length, opts.plan));
688
+ } else {
689
+ console.log(`Batch ${tag}: ${prompts.length} prompt(s) would be submitted:`);
690
+ for (const p of prompts) console.log(` ${snippet(p)}`);
691
+ }
323
692
  });
324
693
  return;
325
694
  }
326
695
 
696
+ // An agent batch multiplies a per-task dollar band by the whole file, so
697
+ // it is confirmed as one total before a single row is written.
698
+ if (agent) await confirmSpend(agent, prompts.length, opts, opts.plan);
699
+
327
700
  // One failed submit must not sink the batch: mark that task failed and
328
701
  // keep going. mapLimit preserves input order, so the report is stable.
329
702
  const results = await mapLimit(prompts, 4, async (prompt) => {
330
- const id = store.createTask({ prompt, model: opts.model, systemInstruction: opts.system, tag });
703
+ const id = store.createTask({
704
+ prompt,
705
+ model: agent ? null : opts.model,
706
+ agent,
707
+ systemInstruction: opts.system,
708
+ tag,
709
+ kind: opts.plan ? 'plan' : 'task',
710
+ collaborativePlanning: planning,
711
+ });
331
712
  try {
332
- const r = await gemini.submit(prompt, { model: opts.model, systemInstruction: opts.system });
713
+ const r = await gemini.submit(prompt, {
714
+ model: opts.model,
715
+ agent,
716
+ systemInstruction: opts.system,
717
+ collaborativePlanning: planning,
718
+ });
333
719
  store.setInteraction(id, r.interactionId, r.status);
334
720
  return { id, interaction_id: r.interactionId, status: r.status, prompt };
335
721
  } catch (err) {
@@ -357,9 +743,16 @@ program
357
743
  const pad = ' '.repeat(Math.max(0, 16 - status.length));
358
744
  console.log(`${r.id} ${colorStatus(status)}${pad} ${snippet(r.prompt)}`);
359
745
  }
360
- console.log(dim('\nCollect them:'));
361
- console.log(dim(' gemcatch daemon --exit-when-idle'));
362
- console.log(dim(` gemcatch list --tag ${tag} --status completed`));
746
+ if (opts.plan) {
747
+ console.log(dim('\nCollect the plans, then approve the ones worth running:'));
748
+ console.log(dim(' gemcatch daemon --exit-when-idle'));
749
+ console.log(dim(` gemcatch list --tag ${tag}`));
750
+ console.log(dim(' gemcatch get <id> # prints the plan and the approve command'));
751
+ } else {
752
+ console.log(dim('\nCollect them:'));
753
+ console.log(dim(' gemcatch daemon --exit-when-idle'));
754
+ console.log(dim(` gemcatch list --tag ${tag} --status completed`));
755
+ }
363
756
  });
364
757
  } catch (err) {
365
758
  die(err);
@@ -402,17 +795,20 @@ program
402
795
  // text stores `''`, which is exactly the case the cache must still serve
403
796
  // -- re-polling it would 404 after 24h, the very thing we cache to avoid.
404
797
  if (isSuccess(task.status) && task.result != null && !opts.raw) {
405
- emit(opts.json, { id: task.id, status: task.status, result: task.result }, () =>
406
- console.log(task.result || '(empty response)')
407
- );
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
+ });
408
803
  return;
409
804
  }
410
805
  const r = await refresh(task);
411
806
  if (opts.raw) return console.log(JSON.stringify(r.raw, null, 2));
412
807
  if (isSuccess(r.status)) {
413
- emit(opts.json, { id: task.id, status: r.status, result: r.text }, () =>
414
- console.log(r.text || '(empty response)')
415
- );
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
+ });
416
812
  } else if (isDone(r.status)) {
417
813
  emit(opts.json, { id: task.id, status: r.status, error: r.text || null }, () =>
418
814
  console.log(`Task ${task.id}: ${colorStatus(r.status)}${r.text ? `\n${r.text}` : ''}`)
@@ -428,8 +824,80 @@ program
428
824
  }
429
825
  });
430
826
 
827
+ // --- refine / approve -----------------------------------------------------
828
+
829
+ program
830
+ .command('refine')
831
+ .argument('<id>', 'plan task id')
832
+ .argument('<instruction>', 'what the plan should do differently')
833
+ .option('--yes', 'confirm the agent cost without asking (required when stdin is not a TTY)')
834
+ .option('--dry-run', 'show what it would cost; submit nothing')
835
+ .option('--json', 'machine-readable output')
836
+ .description('send an instruction back to a plan and get a revised plan')
837
+ .action(async (id, instruction, opts) => {
838
+ const text = (instruction || '').trim();
839
+ if (!text) {
840
+ return die(new Error('provide an instruction, e.g. gemcatch refine 8f3a1c04 "focus on enforcement dates"'));
841
+ }
842
+ const plan = needPlan(id, 'refine');
843
+ await continuePlan(plan, opts, {
844
+ planning: true,
845
+ kind: 'plan',
846
+ input: text,
847
+ prompt: text,
848
+ line: (newId) => `Plan task ${newId} submitted (refines ${plan.id}). Run: gemcatch get ${newId} when ready.`,
849
+ });
850
+ });
851
+
852
+ program
853
+ .command('approve')
854
+ .argument('<id>', 'plan task id')
855
+ .option('--yes', 'confirm the agent cost without asking (required when stdin is not a TTY)')
856
+ .option('--dry-run', 'show what it would cost; submit nothing')
857
+ .option('--json', 'machine-readable output')
858
+ .description('approve a plan and submit the research run it describes')
859
+ .action(async (id, opts) => {
860
+ const plan = needPlan(id, 'approve');
861
+ await continuePlan(plan, opts, {
862
+ planning: false,
863
+ kind: 'report',
864
+ input: APPROVE_INPUT,
865
+ prompt: rootPrompt(plan),
866
+ line: (newId) => `Task ${newId} submitted (approves plan ${plan.id}).`,
867
+ });
868
+ });
869
+
431
870
  // --- list -----------------------------------------------------------------
432
871
 
872
+ // A plan chain is one piece of work, so it is listed as one: the root in its
873
+ // normal newest-first position, its continuations indented under it in the order
874
+ // they were submitted. A row whose parent is filtered out of this listing (by
875
+ // --status, --tag or -n) is rendered as its own root rather than dropped.
876
+ function chainOrder(tasks) {
877
+ const present = new Set(tasks.map((t) => t.id));
878
+ const kids = new Map();
879
+ for (const t of tasks) {
880
+ if (!t.parent_id || !present.has(t.parent_id)) continue;
881
+ if (!kids.has(t.parent_id)) kids.set(t.parent_id, []);
882
+ kids.get(t.parent_id).push(t);
883
+ }
884
+ for (const list of kids.values()) list.sort((a, b) => a.created_at - b.created_at);
885
+ const out = [];
886
+ const seen = new Set();
887
+ const walk = (t, depth) => {
888
+ if (seen.has(t.id)) return;
889
+ seen.add(t.id);
890
+ out.push({ task: t, depth });
891
+ for (const c of kids.get(t.id) || []) walk(c, depth + 1);
892
+ };
893
+ for (const t of tasks) if (!t.parent_id || !present.has(t.parent_id)) walk(t, 0);
894
+ // A listing must never lose a row. Nothing the CLI writes can put a cycle in
895
+ // parent_id, but a row that is unreachable from any root would otherwise
896
+ // vanish silently, so anything left over is rendered flat.
897
+ for (const t of tasks) walk(t, 0);
898
+ return out;
899
+ }
900
+
433
901
  program
434
902
  .command('list')
435
903
  .alias('ls')
@@ -450,14 +918,27 @@ program
450
918
  console.log('No tasks yet. Submit one: gemcatch research "your question"');
451
919
  return;
452
920
  }
453
- console.log(dim('ID AGE STATUS PROMPT'));
454
- for (const t of tasks) {
455
- const snip = snippet(t.prompt);
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).
925
+ const showAgent = tasks.some((t) => t.agent);
926
+ const showKind = tasks.some((t) => t.kind && t.kind !== 'task');
927
+ const shortAgent = (a) => (a ? a.replace(/-preview-\d{2}-\d{4}$/, '') : '-');
928
+ console.log(
929
+ dim(`ID AGE STATUS ${showKind ? 'KIND ' : ''}${showAgent ? 'AGENT ' : ''}PROMPT`)
930
+ );
931
+ for (const { task: t, depth } of chainOrder(tasks)) {
456
932
  const status = t.status || PENDING;
457
933
  // Pad before colouring: ANSI codes would break the column width.
458
934
  const pad = ' '.repeat(Math.max(0, 16 - status.length));
935
+ const kindCol = showKind ? `${(t.kind || 'task').padEnd(7)} ` : '';
936
+ const agentCol = showAgent ? `${shortAgent(t.agent).padEnd(18)} ` : '';
937
+ // Indent the prompt, not the id: the fixed-width columns stay aligned and
938
+ // the chain still reads as one thing.
939
+ const branch = depth ? `${' '.repeat(depth - 1)}└─ ` : '';
459
940
  console.log(
460
- `${t.id} ${age(t.created_at).padEnd(4)} ${colorStatus(status)}${pad} ${snip}`
941
+ `${t.id} ${age(t.created_at).padEnd(4)} ${colorStatus(status)}${pad} ${kindCol}${agentCol}${branch}${snippet(t.prompt)}`
461
942
  );
462
943
  }
463
944
  });
@@ -474,6 +955,7 @@ program
474
955
  .addOption(new Option('--status <status>', 'only this status').choices(ALL_STATUSES).default('completed'))
475
956
  .addOption(new Option('--format <fmt>', 'output format').choices(['md', 'json']).default('md'))
476
957
  .option('-o, --out <file>', 'write to a file instead of stdout')
958
+ .option('--include-plans', 'also export the plan turns of a chain, not just its report')
477
959
  .description('concatenate finished results, each under its prompt, to stdout or a file')
478
960
  .action((opts) => {
479
961
  const tasks = store.listTasks({ tag: opts.tag, status: opts.status });
@@ -482,11 +964,19 @@ program
482
964
  tasks.reverse();
483
965
  // Only rows that actually carry a result are worth exporting: a status
484
966
  // filter other than `completed` can match tasks that never stored text.
485
- const rows = tasks.filter((t) => t.result != null);
967
+ // A chain's plan turns are working notes on the way to its report, so an
968
+ // export of a tag follows the chain to the report and leaves them out
969
+ // unless they were asked for.
970
+ const rows = tasks.filter((t) => t.result != null && (opts.includePlans || t.kind !== 'plan'));
486
971
  if (!rows.length) {
487
972
  // Nothing to write isn't an error, but say why so an empty -o file (or an
488
973
  // empty pipe) isn't a mystery. The note goes to stderr, never the output.
489
974
  console.error(`No ${opts.status} results to export${opts.tag ? ` for tag '${opts.tag}'` : ''}.`);
975
+ // A chain with no approved run yet has plans and nothing else, which would
976
+ // otherwise read as "there is nothing here".
977
+ if (!opts.includePlans && tasks.some((t) => t.result != null && t.kind === 'plan')) {
978
+ console.error(' Only plan turns matched. Approve one (gemcatch approve <id>), or pass --include-plans.');
979
+ }
490
980
  return;
491
981
  }
492
982
 
@@ -497,6 +987,7 @@ program
497
987
  id: t.id,
498
988
  tag: t.tag,
499
989
  status: t.status,
990
+ kind: t.kind || 'task',
500
991
  prompt: t.prompt,
501
992
  result: t.result,
502
993
  created_at: t.created_at,
@@ -510,7 +1001,8 @@ program
510
1001
  const when = new Date(t.created_at).toISOString().replace('T', ' ').slice(0, 16);
511
1002
  const head = (t.prompt || '(no prompt)').replace(/\s+/g, ' ').trim();
512
1003
  const body = t.result && t.result.trim() ? t.result : '_(empty result)_';
513
- return `## ${head}\n\n\`${t.id}\` · ${t.status} · ${when} UTC\n\n${body}`;
1004
+ const kind = t.kind && t.kind !== 'task' ? ` · ${t.kind}` : '';
1005
+ return `## ${head}\n\n\`${t.id}\` · ${t.status}${kind} · ${when} UTC\n\n${body}`;
514
1006
  })
515
1007
  .join('\n\n---\n\n');
516
1008
  }
@@ -564,11 +1056,7 @@ program
564
1056
  if (!opts.json) console.error(edim(`Digesting ${done.length} result(s) tagged ${opts.tag} -> task ${id}.`));
565
1057
  await watchTask(store.getTask(id), DEFAULT_POLL_MS, opts.json);
566
1058
  } catch (err) {
567
- // Same rule as `research`: only a failed *submit* marks the task failed.
568
- if (id) {
569
- const t = store.getTask(id);
570
- if (!t || !t.interaction_id) store.setStatus(id, 'failed', { error: err.message });
571
- }
1059
+ markSubmitFailure(id, err);
572
1060
  die(err);
573
1061
  }
574
1062
  });
@@ -726,9 +1214,10 @@ async function watchTask(task, intervalMs, json) {
726
1214
  last = r.status;
727
1215
  }
728
1216
  if (isSuccess(r.status)) {
729
- emit(json, { id: task.id, status: r.status, result: r.text }, () =>
730
- console.log(r.text || '(empty response)')
731
- );
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
+ });
732
1221
  return;
733
1222
  }
734
1223
  if (isDone(r.status)) {
@@ -755,9 +1244,11 @@ program
755
1244
  // Serve a completed result from cache -- present, not merely truthy, so an
756
1245
  // empty-text completion is served instead of re-polled (and lost at 24h).
757
1246
  if (isSuccess(task.status) && task.result != null) {
758
- emit(opts.json, { id: task.id, status: task.status, result: task.result }, () =>
759
- console.log(task.result || '(empty response)')
760
- );
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
+ });
761
1252
  return;
762
1253
  }
763
1254
  if (opts.interval != null && (!Number.isFinite(opts.interval) || opts.interval <= 0)) {
@@ -847,15 +1338,44 @@ program
847
1338
  program
848
1339
  .command('stats')
849
1340
  .option('--json', 'machine-readable output')
850
- .description('where the store lives and what is in it')
1341
+ .description('where the store lives, what is in it, and what the agent runs have plausibly cost')
851
1342
  .action((opts) => {
852
1343
  const rows = store.counts();
1344
+ const agents = store.agentCounts();
1345
+ const kinds = store.kindCounts();
853
1346
  const total = rows.reduce((n, r) => n + r.n, 0);
854
- emit(opts.json, { db: store.DB_PATH, total, by_status: rows }, () => {
855
- console.log(`Store: ${store.DB_PATH}`);
856
- console.log(`Tasks: ${total}`);
857
- for (const r of rows) console.log(` ${colorStatus(r.status).padEnd(useColor ? 26 : 17)} ${r.n}`);
858
- });
1347
+ // Priced from the runs that actually reached the server, not from every
1348
+ // attempt: a submit that failed before it left the machine cost nothing.
1349
+ const spend = estimatedSpend(store.billedAgentCounts());
1350
+ emit(
1351
+ opts.json,
1352
+ { db: store.DB_PATH, total, by_status: rows, by_agent: agents, by_kind: kinds, estimated_spend: spend },
1353
+ () => {
1354
+ console.log(`Store: ${store.DB_PATH}`);
1355
+ console.log(`Tasks: ${total}`);
1356
+ for (const r of rows) console.log(` ${colorStatus(r.status).padEnd(useColor ? 26 : 17)} ${r.n}`);
1357
+ if (agents.length) {
1358
+ console.log('Agent runs:');
1359
+ for (const a of agents) console.log(` ${a.agent.padEnd(34)} ${a.n}`);
1360
+ }
1361
+ if (kinds.length) {
1362
+ console.log(`Plan chains: ${kinds.map((k) => `${k.n} ${k.kind}`).join(', ')}`);
1363
+ }
1364
+ if (spend && !spend.tasks) {
1365
+ console.log(
1366
+ `Estimated spend: unknown for ${spend.unpriced} agent task(s) on an agent with no published price band.`
1367
+ );
1368
+ } else if (spend) {
1369
+ const rest = spend.unpriced
1370
+ ? `, plus ${spend.unpriced} on an agent with no published band`
1371
+ : '';
1372
+ console.log(
1373
+ `Estimated spend: $${spend.low.toFixed(2)}–$${spend.high.toFixed(2)} across ${spend.tasks} billed task(s)` +
1374
+ ` (preview rates, subject to change)${rest}.`
1375
+ );
1376
+ }
1377
+ }
1378
+ );
859
1379
  });
860
1380
 
861
1381
  // Close the store on the way out so a one-shot command doesn't leave the