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.
- package/CHANGELOG.md +110 -1
- package/README.md +137 -7
- package/db.js +60 -3
- package/gemini.js +125 -10
- package/index.js +576 -56
- 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:
|
|
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))
|
|
143
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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({
|
|
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, {
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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,
|
|
414
|
-
console.log(r.text
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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} ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
730
|
-
console.log(r.text
|
|
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
|
-
|
|
759
|
-
|
|
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
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|