gemcatch 0.3.0 → 0.4.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 +44 -1
- package/README.md +46 -4
- package/db.js +18 -3
- package/gemini.js +103 -10
- package/index.js +163 -22
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-08-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Research agents.** `-a, --agent <id>` on `research` and `batch` submits to a
|
|
15
|
+
Gemini Deep Research agent instead of a model — `interactions.create` is sent
|
|
16
|
+
`agent` *instead of* `model` (they are mutually exclusive, and passing both is
|
|
17
|
+
a clean error). Aliases resolve through one table: `deep-research` →
|
|
18
|
+
`deep-research-preview-04-2026`, `deep-research-max` →
|
|
19
|
+
`deep-research-max-preview-04-2026`; any other value passes through unchanged,
|
|
20
|
+
so a future agent id works without a gemcatch release. Agents *require*
|
|
21
|
+
background execution, which gemcatch has always set — and on the free tier the
|
|
22
|
+
finished report is dropped after 1 day, which is exactly the race the daemon
|
|
23
|
+
exists to win. The agent is recorded per task, shown in `list` (the AGENT
|
|
24
|
+
column appears when a listing contains agent runs) and tallied in `stats`.
|
|
25
|
+
- **Spend guard.** Deep Research is documented at $1.00–$3.00 per task and Deep
|
|
26
|
+
Research Max at $3.00–$7.00 (estimates based on preview rates, per the docs,
|
|
27
|
+
and subject to change). Every agent submission prints its band first —
|
|
28
|
+
`batch` prints N × the band as a total — and asks for an interactive `y/N`
|
|
29
|
+
confirmation. When stdin is not a TTY, `--yes` is required and anything else
|
|
30
|
+
is refused before a row is written; declining writes nothing and exits
|
|
31
|
+
non-zero. `--dry-run` (now on `research` too) prints the full projected spend
|
|
32
|
+
and submits nothing.
|
|
33
|
+
- **Citations.** Agent runs return citations alongside the report; the docs say
|
|
34
|
+
to review them to verify the sources, so they are persisted (new `citations`
|
|
35
|
+
column, JSON) rather than discarded, printed under the result as a `Sources:`
|
|
36
|
+
list, and carried in `--json` output.
|
|
37
|
+
- Result extraction now takes the **final answer-bearing step** — where the
|
|
38
|
+
docs place an agent's completed report (`steps[-1].content[0].text`) and
|
|
39
|
+
where a model run's `model_output` already sits — with a fall-back to the old
|
|
40
|
+
collect-everything behaviour if that step carries no text, so an unexpected
|
|
41
|
+
shape can never silently blank a result. No special-casing on the agent id.
|
|
42
|
+
- Additive schema migration: `agent` and `citations` columns. A pre-0.4.0
|
|
43
|
+
`tasks.db` upgrades in place, keeps every row, and reports `agent` as NULL
|
|
44
|
+
for them.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- The default model is now **`gemini-3.5-flash-lite`** (GA on 2026-07-21),
|
|
49
|
+
replacing the older `gemini-3.1-flash-lite`. Override with `GEMCATCH_MODEL`
|
|
50
|
+
or `--model` as before.
|
|
51
|
+
|
|
10
52
|
## [0.3.0] - 2026-07-19
|
|
11
53
|
|
|
12
54
|
### Added
|
|
@@ -162,7 +204,8 @@ seen a task complete, the text is cached locally and survives that expiry — bu
|
|
|
162
204
|
something has to poll inside that window for it to be seen at all, which is what
|
|
163
205
|
`gemcatch daemon` exists to do.
|
|
164
206
|
|
|
165
|
-
[Unreleased]: https://github.com/Booyaka101/gemcatch/compare/v0.
|
|
207
|
+
[Unreleased]: https://github.com/Booyaka101/gemcatch/compare/v0.4.0...HEAD
|
|
208
|
+
[0.4.0]: https://github.com/Booyaka101/gemcatch/compare/v0.3.0...v0.4.0
|
|
166
209
|
[0.3.0]: https://github.com/Booyaka101/gemcatch/compare/v0.2.0...v0.3.0
|
|
167
210
|
[0.2.0]: https://github.com/Booyaka101/gemcatch/compare/v0.1.1...v0.2.0
|
|
168
211
|
[0.1.1]: https://github.com/Booyaka101/gemcatch/compare/v0.1.0...v0.1.1
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ The EU AI Act's high-risk obligations phase in from August 2026, whereas...
|
|
|
24
24
|
|
|
25
25
|
## Setup
|
|
26
26
|
|
|
27
|
-
Needs Node.js 22+ and a Gemini API key. **Getting a key needs no billing account and no card.** `gemini-3.
|
|
27
|
+
Needs Node.js 22+ and a Gemini API key. **Getting a key needs no billing account and no card.** `gemini-3.5-flash-lite` (the default model, GA since July 2026) runs free within the [free tier's](https://ai.google.dev/gemini-api/docs/pricing) daily quota; past that, paid rates apply.
|
|
28
28
|
|
|
29
29
|
1. Get a key at **<https://aistudio.google.com/apikey>**
|
|
30
30
|
2. Put it in your environment:
|
|
@@ -99,6 +99,8 @@ Useful flags:
|
|
|
99
99
|
| --- | --- | --- |
|
|
100
100
|
| `--json` | most commands | Machine-readable output. |
|
|
101
101
|
| `-m, --model <id>` | `research`, `batch` | Override the model. |
|
|
102
|
+
| `-a, --agent <id>` | `research`, `batch` | Submit to a [research agent](#research-agents) instead of a model. Mutually exclusive with `--model`. |
|
|
103
|
+
| `--yes` | `research`, `batch` | Confirm the agent cost without asking. Required for `--agent` when stdin is not a TTY. |
|
|
102
104
|
| `-s, --system <text>` | `research`, `batch` | Set a system instruction. |
|
|
103
105
|
| `-f, --file <path>` | `research` | Read the prompt from a file. |
|
|
104
106
|
| `-t, --tag <tag>` | `research`, `batch`, `list` | Label tasks and filter them. |
|
|
@@ -110,7 +112,7 @@ Useful flags:
|
|
|
110
112
|
| `-n, --limit <n>` | `list` | Cap the rows (non-negative; `0` shows none). |
|
|
111
113
|
| `--format <md\|json>` | `export` | Output format. Default `md`. |
|
|
112
114
|
| `-o, --out <file>` | `export` | Write to a file instead of stdout. |
|
|
113
|
-
| `--dry-run` | `batch`, `prune` | Show what would go; submit/delete nothing. |
|
|
115
|
+
| `--dry-run` | `research`, `batch`, `prune` | Show what would go — including the projected agent spend; submit/delete nothing. |
|
|
114
116
|
| `--raw` | `get` | Dump the raw interaction JSON. |
|
|
115
117
|
|
|
116
118
|
IDs are the first 8 characters of a UUID. Any unique prefix works, so `gemcatch get 8f3a` is fine.
|
|
@@ -153,6 +155,46 @@ $ id=$(gemcatch research "..." --json | jq -r .id)
|
|
|
153
155
|
$ gemcatch watch "$id" --json | jq -r .result
|
|
154
156
|
```
|
|
155
157
|
|
|
158
|
+
## Research agents
|
|
159
|
+
|
|
160
|
+
The [Gemini Deep Research agents](https://ai.google.dev/gemini-api/docs/deep-research) are reachable only through the Interactions API, and the docs are explicit: *"You must use background execution (set `background=true`) to run the agent asynchronously and poll for results or stream updates."* That is precisely the half of the job `gemcatch` already does — it always sets `background: true`, owns the polling, and its daemon collects results before the free tier drops interactions after **1 day** (paid tier: 55 days). A Deep Research run takes minutes and you were never going to sit there holding the connection; submit it, and let the daemon catch it.
|
|
161
|
+
|
|
162
|
+
```console
|
|
163
|
+
$ gemcatch research "map the EU AI Act high-risk obligations against the UK approach" --agent deep-research
|
|
164
|
+
Agent deep-research-preview-04-2026 — estimated $1.00–$3.00 for this task (preview rates, subject to change).
|
|
165
|
+
Submit? [y/N] y
|
|
166
|
+
Task 8f3a1c04 submitted. Run: gemcatch get 8f3a1c04 when ready.
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`--agent` takes an alias or a raw agent id:
|
|
170
|
+
|
|
171
|
+
| You type | Sent to the API |
|
|
172
|
+
| --- | --- |
|
|
173
|
+
| `deep-research` | `deep-research-preview-04-2026` |
|
|
174
|
+
| `deep-research-max` | `deep-research-max-preview-04-2026` |
|
|
175
|
+
| anything else | passed through unchanged (future agent ids work without a gemcatch release; a bad id fails fast with the API's own 4xx) |
|
|
176
|
+
|
|
177
|
+
An agent is sent **instead of** a model — the agent picks its own models — so `--model` and `--agent` together is an error, and nothing is submitted.
|
|
178
|
+
|
|
179
|
+
**These agents cost real money, per task.** The docs put Deep Research at **$1.00–$3.00 per task** and Deep Research Max at **$3.00–$7.00 per task** — with their own hedge attached: *"These figures are estimates based on preview rates and are subject to change."* Because `gemcatch batch` fires a whole file at once, a 20-line file against `deep-research-max` is a **$60–$140 command**, so every agent submission shows its band and asks first. In a script (stdin not a TTY) you must pass `--yes`; `--dry-run` prints the full projected spend and submits nothing:
|
|
180
|
+
|
|
181
|
+
```console
|
|
182
|
+
$ gemcatch batch questions.txt --agent deep-research-max --dry-run
|
|
183
|
+
20 prompts × deep-research-max-preview-04-2026 — estimated $60.00–$140.00 total. Nothing submitted (--dry-run).
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The report lands like any other result — final answer only, none of the agent's interim plan — and its **citations** come with it. The docs tell you to review them to verify the sources, so `gemcatch get` prints them under the report as a `Sources:` list, `--json` carries them as an array, and they live in the store alongside the result.
|
|
187
|
+
|
|
188
|
+
An agent run can also come back `incomplete` — that is what a `max_total_tokens` budget cap produces when the run "safely pauses" — which `gemcatch` treats as terminal, exactly like the API does: the daemon retires it and moves on.
|
|
189
|
+
|
|
190
|
+
The agent recipe, end to end:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
$ gemcatch batch questions.txt --agent deep-research --yes # bands shown, N × total quoted
|
|
194
|
+
$ gemcatch daemon --exit-when-idle # catch reports before the 1-day expiry
|
|
195
|
+
$ gemcatch export --tag batch-1a2b3c -o reports.md # every report, with its sources
|
|
196
|
+
```
|
|
197
|
+
|
|
156
198
|
## How it works
|
|
157
199
|
|
|
158
200
|
Tasks live in SQLite at `~/.gemcatch/tasks.db` (override with `GEMCATCH_HOME`):
|
|
@@ -160,7 +202,7 @@ Tasks live in SQLite at `~/.gemcatch/tasks.db` (override with `GEMCATCH_HOME`):
|
|
|
160
202
|
```sql
|
|
161
203
|
CREATE TABLE tasks (id TEXT PRIMARY KEY, prompt TEXT, interaction_id TEXT,
|
|
162
204
|
status TEXT DEFAULT 'pending', result TEXT, created_at INTEGER);
|
|
163
|
-
-- plus model, system_instruction, tag, error, usage, updated_at
|
|
205
|
+
-- plus model, system_instruction, tag, error, usage, updated_at, agent, citations
|
|
164
206
|
```
|
|
165
207
|
|
|
166
208
|
`research` calls `interactions.create({model, input, background: true})` via [`@google/genai`](https://www.npmjs.com/package/@google/genai) and keeps the returned `id`. The polling commands call `interactions.get(id)` and write the status back. Once a task completes, the text is cached in the `result` column — `gemcatch get` then answers from disk without touching the network.
|
|
@@ -208,7 +250,7 @@ Transient failures are retried with exponential backoff and full jitter, honouri
|
|
|
208
250
|
| --- | --- |
|
|
209
251
|
| `GEMINI_API_KEY` | Your API key. `GOOGLE_API_KEY` also works. |
|
|
210
252
|
| `GEMCATCH_HOME` | Where `tasks.db` lives. Default `~/.gemcatch`. |
|
|
211
|
-
| `GEMCATCH_MODEL` | Default model. Default `gemini-3.
|
|
253
|
+
| `GEMCATCH_MODEL` | Default model. Default `gemini-3.5-flash-lite`. |
|
|
212
254
|
| `GEMCATCH_POLL_MS` | `watch` poll interval in ms. Default `10000`. |
|
|
213
255
|
| `GEMCATCH_DAEMON_S` | `daemon` interval in seconds. Default `300`. |
|
|
214
256
|
| `GEMCATCH_RPM` | Requests/minute ceiling. Default `15` (the free tier). `0` disables pacing. |
|
package/db.js
CHANGED
|
@@ -26,6 +26,11 @@ const MIGRATIONS = [
|
|
|
26
26
|
['error', 'TEXT'],
|
|
27
27
|
['usage', 'TEXT'],
|
|
28
28
|
['updated_at', 'INTEGER'],
|
|
29
|
+
// 0.4.0: agent runs. `agent` is the resolved agent id the task was submitted
|
|
30
|
+
// with (NULL for model runs, including every pre-0.4.0 row); `citations` is
|
|
31
|
+
// the JSON array of sources an agent run returned alongside its report.
|
|
32
|
+
['agent', 'TEXT'],
|
|
33
|
+
['citations', 'TEXT'],
|
|
29
34
|
];
|
|
30
35
|
|
|
31
36
|
let _db = null;
|
|
@@ -59,8 +64,8 @@ function createTask(fields) {
|
|
|
59
64
|
const now = Date.now();
|
|
60
65
|
db()
|
|
61
66
|
.prepare(
|
|
62
|
-
'INSERT INTO tasks (id, prompt, status, created_at, updated_at, model, system_instruction, tag) ' +
|
|
63
|
-
'VALUES (@id, @prompt, @status, @now, @now, @model, @system_instruction, @tag)'
|
|
67
|
+
'INSERT INTO tasks (id, prompt, status, created_at, updated_at, model, system_instruction, tag, agent) ' +
|
|
68
|
+
'VALUES (@id, @prompt, @status, @now, @now, @model, @system_instruction, @tag, @agent)'
|
|
64
69
|
)
|
|
65
70
|
.run({
|
|
66
71
|
id,
|
|
@@ -70,6 +75,7 @@ function createTask(fields) {
|
|
|
70
75
|
model: t.model || null,
|
|
71
76
|
system_instruction: t.systemInstruction || null,
|
|
72
77
|
tag: t.tag || null,
|
|
78
|
+
agent: t.agent || null,
|
|
73
79
|
});
|
|
74
80
|
return id;
|
|
75
81
|
}
|
|
@@ -101,7 +107,7 @@ function setStatus(id, status, extra) {
|
|
|
101
107
|
const e = extra || {};
|
|
102
108
|
const sets = ['status = @status', 'updated_at = @now'];
|
|
103
109
|
const params = { id, status, now: Date.now() };
|
|
104
|
-
for (const key of ['result', 'error', 'usage']) {
|
|
110
|
+
for (const key of ['result', 'error', 'usage', 'citations']) {
|
|
105
111
|
if (e[key] !== undefined) {
|
|
106
112
|
sets.push(`${key} = @${key}`);
|
|
107
113
|
params[key] = e[key];
|
|
@@ -167,6 +173,14 @@ function counts() {
|
|
|
167
173
|
return db().prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status').all();
|
|
168
174
|
}
|
|
169
175
|
|
|
176
|
+
// Per-agent totals for `stats`. Model runs (agent IS NULL) are not a row here;
|
|
177
|
+
// they are already accounted for in counts().
|
|
178
|
+
function agentCounts() {
|
|
179
|
+
return db()
|
|
180
|
+
.prepare('SELECT agent, COUNT(*) AS n FROM tasks WHERE agent IS NOT NULL GROUP BY agent')
|
|
181
|
+
.all();
|
|
182
|
+
}
|
|
183
|
+
|
|
170
184
|
function close() {
|
|
171
185
|
if (_db) _db.close();
|
|
172
186
|
_db = null;
|
|
@@ -185,5 +199,6 @@ module.exports = {
|
|
|
185
199
|
removeMany,
|
|
186
200
|
prunableTasks,
|
|
187
201
|
counts,
|
|
202
|
+
agentCounts,
|
|
188
203
|
close,
|
|
189
204
|
};
|
package/gemini.js
CHANGED
|
@@ -3,7 +3,41 @@
|
|
|
3
3
|
const { isDone, isSuccess } = require('./status');
|
|
4
4
|
|
|
5
5
|
// Free of charge on the Gemini free tier; override per-call with --model.
|
|
6
|
-
|
|
6
|
+
// gemini-3.5-flash-lite went GA on 2026-07-21 (it replaced 3.1 as the
|
|
7
|
+
// low-latency free-tier workhorse in the same release that deprecated the
|
|
8
|
+
// sampling parameters).
|
|
9
|
+
const DEFAULT_MODEL = process.env.GEMCATCH_MODEL || 'gemini-3.5-flash-lite';
|
|
10
|
+
|
|
11
|
+
// --- agents ---------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
// The Deep Research agents are reachable ONLY through the Interactions API,
|
|
14
|
+
// and only with background execution -- which gemcatch always sets. An agent
|
|
15
|
+
// is sent as `agent` on create, INSTEAD of `model`: the two are mutually
|
|
16
|
+
// exclusive, and the CLI rejects the combination before anything is written.
|
|
17
|
+
//
|
|
18
|
+
// This table is the ONE place the full preview ids live. They are preview ids
|
|
19
|
+
// and will be superseded; call sites must resolve through here (or pass an
|
|
20
|
+
// unknown id straight through, so a future agent works without a release).
|
|
21
|
+
const AGENT_ALIASES = Object.freeze({
|
|
22
|
+
'deep-research': 'deep-research-preview-04-2026',
|
|
23
|
+
'deep-research-max': 'deep-research-max-preview-04-2026',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Documented per-task price bands, in dollars, keyed by the RESOLVED id.
|
|
27
|
+
// The docs' own hedge applies -- "These figures are estimates based on
|
|
28
|
+
// preview rates and are subject to change" -- so the spend guard quotes
|
|
29
|
+
// them as estimates, never as authoritative.
|
|
30
|
+
const AGENT_PRICE_BANDS = Object.freeze({
|
|
31
|
+
'deep-research-preview-04-2026': Object.freeze([1, 3]),
|
|
32
|
+
'deep-research-max-preview-04-2026': Object.freeze([3, 7]),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// A known alias resolves to its full preview id; anything else passes through
|
|
36
|
+
// unchanged so a new or newer agent id works without a gemcatch release (a
|
|
37
|
+
// genuinely bad id fails fast: the API 4xxes, and a 4xx never retries).
|
|
38
|
+
function resolveAgent(id) {
|
|
39
|
+
return AGENT_ALIASES[id] || id;
|
|
40
|
+
}
|
|
7
41
|
|
|
8
42
|
// Overridable for tests and for routing via a proxy/gateway.
|
|
9
43
|
const REST_BASE =
|
|
@@ -163,7 +197,11 @@ function collectText(node, acc) {
|
|
|
163
197
|
return acc;
|
|
164
198
|
}
|
|
165
199
|
if (typeof node.text === 'string' && node.text.trim()) acc.push(node.text);
|
|
166
|
-
for (const v of Object.
|
|
200
|
+
for (const [k, v] of Object.entries(node)) {
|
|
201
|
+
// Citations are sources *about* the answer, not answer text: an agent step
|
|
202
|
+
// carries them alongside its content, and a citation's own title/snippet
|
|
203
|
+
// must not be concatenated into the result. They are collected separately.
|
|
204
|
+
if (k === 'citations') continue;
|
|
167
205
|
if (v && typeof v === 'object') collectText(v, acc);
|
|
168
206
|
}
|
|
169
207
|
return acc;
|
|
@@ -173,21 +211,65 @@ function collectText(node, acc) {
|
|
|
173
211
|
// internal reasoning with the actual answer, each tagged by `type`:
|
|
174
212
|
// [ {type:'user_input', ...}, {type:'thought', ...}, {type:'model_output', ...} ]
|
|
175
213
|
// Collecting text indiscriminately prepends the prompt (and any reasoning) to
|
|
176
|
-
// the result, so those step types are skipped.
|
|
177
|
-
//
|
|
178
|
-
//
|
|
214
|
+
// the result, so those step types are skipped.
|
|
215
|
+
//
|
|
216
|
+
// Both kinds of run put the deliverable in the FINAL answer-bearing step. A
|
|
217
|
+
// model run ends [user_input, thought, model_output]; an agent run's steps
|
|
218
|
+
// additionally interleave its plan, searches and interim drafts, and the docs
|
|
219
|
+
// place the finished report at `interaction.steps[-1].content[0].text`. So one
|
|
220
|
+
// rule serves both, with no special-casing on the agent id: take the last step
|
|
221
|
+
// that is not user_input/thought. If that step somehow carries no text -- an
|
|
222
|
+
// unexpected shape, a renamed type -- fall back to collecting across every
|
|
223
|
+
// answer-bearing step, so the failure mode is "too much text", never a
|
|
224
|
+
// silently blank result.
|
|
179
225
|
const NON_ANSWER_STEP = new Set(['user_input', 'thought']);
|
|
180
226
|
|
|
181
227
|
function textFromSteps(steps) {
|
|
182
228
|
if (!Array.isArray(steps)) return '';
|
|
229
|
+
const candidates = steps.filter((s) => !(s && NON_ANSWER_STEP.has(s.type)));
|
|
230
|
+
if (!candidates.length) return '';
|
|
231
|
+
const last = collectText(candidates[candidates.length - 1], []).join('\n').trim();
|
|
232
|
+
if (last) return last;
|
|
183
233
|
const acc = [];
|
|
184
|
-
for (const step of
|
|
185
|
-
if (step && NON_ANSWER_STEP.has(step.type)) continue;
|
|
186
|
-
collectText(step, acc);
|
|
187
|
-
}
|
|
234
|
+
for (const step of candidates) collectText(step, acc);
|
|
188
235
|
return acc.join('\n').trim();
|
|
189
236
|
}
|
|
190
237
|
|
|
238
|
+
// Agent runs carry citations -- the docs explicitly tell users to review them
|
|
239
|
+
// to verify the sources -- so they are gathered rather than discarded. The
|
|
240
|
+
// walk is shape-agnostic (any `citations` array anywhere in the interaction),
|
|
241
|
+
// because the docs do not pin down where they attach; duplicates are dropped.
|
|
242
|
+
function collectCitations(node, acc) {
|
|
243
|
+
if (!node || typeof node !== 'object') return acc;
|
|
244
|
+
if (Array.isArray(node)) {
|
|
245
|
+
for (const n of node) collectCitations(n, acc);
|
|
246
|
+
return acc;
|
|
247
|
+
}
|
|
248
|
+
for (const [k, v] of Object.entries(node)) {
|
|
249
|
+
if (k === 'citations' && Array.isArray(v)) {
|
|
250
|
+
for (const c of v) if (c && typeof c === 'object') acc.push(c);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (v && typeof v === 'object') collectCitations(v, acc);
|
|
254
|
+
}
|
|
255
|
+
return acc;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function citationsOf(interaction) {
|
|
259
|
+
const all = collectCitations(interaction, []);
|
|
260
|
+
if (!all.length) return null;
|
|
261
|
+
const seen = new Set();
|
|
262
|
+
const out = [];
|
|
263
|
+
for (const c of all) {
|
|
264
|
+
const key = JSON.stringify(c);
|
|
265
|
+
if (!seen.has(key)) {
|
|
266
|
+
seen.add(key);
|
|
267
|
+
out.push(c);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
191
273
|
function textOf(interaction) {
|
|
192
274
|
if (interaction && typeof interaction.output_text === 'string' && interaction.output_text) {
|
|
193
275
|
return interaction.output_text;
|
|
@@ -200,6 +282,7 @@ function shape(r) {
|
|
|
200
282
|
interactionId: r.id,
|
|
201
283
|
status: r.status,
|
|
202
284
|
text: textOf(r),
|
|
285
|
+
citations: citationsOf(r),
|
|
203
286
|
usage: r.usage || null,
|
|
204
287
|
raw: r,
|
|
205
288
|
};
|
|
@@ -269,7 +352,13 @@ function restHeaders() {
|
|
|
269
352
|
|
|
270
353
|
async function submit(prompt, opts) {
|
|
271
354
|
const o = opts || {};
|
|
272
|
-
|
|
355
|
+
// `agent` and `model` are mutually exclusive on create: an agent run is sent
|
|
356
|
+
// with `agent` INSTEAD of `model` (the agent picks its own models). `input`
|
|
357
|
+
// stays a plain string and `background` stays true either way -- agents
|
|
358
|
+
// *require* background execution, which gemcatch has always set.
|
|
359
|
+
const body = o.agent
|
|
360
|
+
? { agent: o.agent, input: prompt, background: true }
|
|
361
|
+
: { model: o.model || DEFAULT_MODEL, input: prompt, background: true };
|
|
273
362
|
if (o.systemInstruction) body.system_instruction = o.systemInstruction;
|
|
274
363
|
const r = await call(() => {
|
|
275
364
|
const api = sdkInteractions();
|
|
@@ -323,6 +412,9 @@ module.exports = {
|
|
|
323
412
|
REST_BASE,
|
|
324
413
|
RPM,
|
|
325
414
|
MAX_RETRIES,
|
|
415
|
+
AGENT_ALIASES,
|
|
416
|
+
AGENT_PRICE_BANDS,
|
|
417
|
+
resolveAgent,
|
|
326
418
|
submit,
|
|
327
419
|
poll,
|
|
328
420
|
cancel,
|
|
@@ -330,6 +422,7 @@ module.exports = {
|
|
|
330
422
|
apiKey,
|
|
331
423
|
textOf,
|
|
332
424
|
collectText,
|
|
425
|
+
citationsOf,
|
|
333
426
|
// Exported for the suite: the retry policy is behaviour worth pinning.
|
|
334
427
|
shouldRetry,
|
|
335
428
|
// Re-exported so callers need only one require.
|
package/index.js
CHANGED
|
@@ -89,6 +89,104 @@ 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
|
+
function bandText(agentId, count) {
|
|
130
|
+
const band = gemini.AGENT_PRICE_BANDS[agentId];
|
|
131
|
+
if (!band) return 'no published price band for this agent';
|
|
132
|
+
const money = (n) => `$${(n * count).toFixed(2)}`;
|
|
133
|
+
return count > 1
|
|
134
|
+
? `estimated ${money(band[0])}–${money(band[1])} total`
|
|
135
|
+
: `estimated ${money(band[0])}–${money(band[1])} for this task`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function spendLine(agentId, count) {
|
|
139
|
+
const head = count > 1 ? `${count} prompts × ${agentId}` : `Agent ${agentId}`;
|
|
140
|
+
return `${head} — ${bandText(agentId, count)}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function askYesNo(question) {
|
|
144
|
+
const readline = require('readline');
|
|
145
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
rl.question(question, (answer) => {
|
|
148
|
+
rl.close();
|
|
149
|
+
resolve(/^y(es)?$/i.test((answer || '').trim()));
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Returns only when the submission is confirmed; otherwise it exits (declined)
|
|
155
|
+
// or throws (no way to ask). Runs BEFORE any row is written, so a declined or
|
|
156
|
+
// refused submission leaves the tasks table untouched.
|
|
157
|
+
async function confirmSpend(agentId, count, opts) {
|
|
158
|
+
console.error(`${spendLine(agentId, count)} (preview rates, subject to change).`);
|
|
159
|
+
if (opts.yes) return;
|
|
160
|
+
// GEMCATCH_ASSUME_TTY lets the offline suite drive the interactive branch
|
|
161
|
+
// through a pipe; real non-TTY callers (cron, CI, scripts) must say --yes.
|
|
162
|
+
const interactive = process.stdin.isTTY || process.env.GEMCATCH_ASSUME_TTY === '1';
|
|
163
|
+
if (!interactive) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
'stdin is not a TTY, so this agent submission cannot be confirmed interactively.\n' +
|
|
166
|
+
' Pass --yes to confirm the cost above, or --dry-run to preview without submitting.'
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (!(await askYesNo('Submit? [y/N] '))) {
|
|
170
|
+
console.error('Nothing submitted.');
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Shared by research and batch: resolve the agent alias and reject the
|
|
176
|
+
// ambiguous combination before anything is stored or sent. `--model` counts
|
|
177
|
+
// only when the user actually typed it -- commander fills in the default
|
|
178
|
+
// otherwise, and the default must not poison every agent run.
|
|
179
|
+
function resolveAgentOpts(opts, cmd) {
|
|
180
|
+
if (!opts.agent) return null;
|
|
181
|
+
if (cmd.getOptionValueSource('model') === 'cli') {
|
|
182
|
+
throw new Error(
|
|
183
|
+
'--model and --agent are mutually exclusive: an agent run is submitted with `agent` ' +
|
|
184
|
+
'instead of `model`, and the agent picks its own models. Drop one of the two.'
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return gemini.resolveAgent(opts.agent);
|
|
188
|
+
}
|
|
189
|
+
|
|
92
190
|
// --- input ----------------------------------------------------------------
|
|
93
191
|
|
|
94
192
|
function readStdin() {
|
|
@@ -139,8 +237,12 @@ async function refresh(task) {
|
|
|
139
237
|
}
|
|
140
238
|
const extra = {};
|
|
141
239
|
if (isDone(r.status)) {
|
|
142
|
-
if (isSuccess(r.status))
|
|
143
|
-
|
|
240
|
+
if (isSuccess(r.status)) {
|
|
241
|
+
extra.result = r.text;
|
|
242
|
+
// Agent runs return citations with the report; the docs tell users to
|
|
243
|
+
// review them to verify the sources, so they are persisted, not dropped.
|
|
244
|
+
if (r.citations && r.citations.length) extra.citations = JSON.stringify(r.citations);
|
|
245
|
+
} else if (r.text) extra.error = r.text;
|
|
144
246
|
}
|
|
145
247
|
if (r.usage) extra.usage = JSON.stringify(r.usage);
|
|
146
248
|
store.setStatus(task.id, r.status, extra);
|
|
@@ -177,22 +279,35 @@ program
|
|
|
177
279
|
.argument('[prompt]', 'what you want researched; "-" reads stdin')
|
|
178
280
|
.option('-f, --file <path>', 'read the prompt from a file')
|
|
179
281
|
.option('-m, --model <id>', 'model to use', gemini.DEFAULT_MODEL)
|
|
282
|
+
.option('-a, --agent <id>', 'submit to a research agent instead of a model (e.g. deep-research)')
|
|
180
283
|
.option('-s, --system <text>', 'system instruction')
|
|
181
284
|
.option('-t, --tag <tag>', 'label for filtering with `gemcatch list --tag`')
|
|
182
285
|
.option('-w, --watch', 'wait for the result instead of exiting')
|
|
286
|
+
.option('--yes', 'confirm the agent cost without asking (required when stdin is not a TTY)')
|
|
287
|
+
.option('--dry-run', 'show what would be submitted (and what it would cost); submit nothing')
|
|
183
288
|
.option('--json', 'machine-readable output')
|
|
184
289
|
.description('submit a background task and exit immediately')
|
|
185
|
-
.action(async (promptArg, opts) => {
|
|
290
|
+
.action(async (promptArg, opts, cmd) => {
|
|
186
291
|
let id;
|
|
187
292
|
try {
|
|
293
|
+
const agent = resolveAgentOpts(opts, cmd);
|
|
188
294
|
const prompt = await resolvePrompt(promptArg, opts);
|
|
295
|
+
if (opts.dryRun) {
|
|
296
|
+
emit(opts.json, { dry_run: true, agent: agent || null, model: agent ? null : opts.model, prompt }, () => {
|
|
297
|
+
if (agent) console.log(`${spendLine(agent, 1)}. Nothing submitted (--dry-run).`);
|
|
298
|
+
else console.log(`Would submit to ${opts.model}: ${snippet(prompt)}. Nothing submitted (--dry-run).`);
|
|
299
|
+
});
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (agent) await confirmSpend(agent, 1, opts);
|
|
189
303
|
id = store.createTask({
|
|
190
304
|
prompt,
|
|
191
|
-
model: opts.model,
|
|
305
|
+
model: agent ? null : opts.model,
|
|
306
|
+
agent,
|
|
192
307
|
systemInstruction: opts.system,
|
|
193
308
|
tag: opts.tag,
|
|
194
309
|
});
|
|
195
|
-
const r = await gemini.submit(prompt, { model: opts.model, systemInstruction: opts.system });
|
|
310
|
+
const r = await gemini.submit(prompt, { model: opts.model, agent, systemInstruction: opts.system });
|
|
196
311
|
store.setInteraction(id, r.interactionId, r.status);
|
|
197
312
|
if (opts.watch) {
|
|
198
313
|
// Under --watch the submit line is progress, not the answer, so it
|
|
@@ -296,15 +411,18 @@ program
|
|
|
296
411
|
.command('batch')
|
|
297
412
|
.argument('<file>', 'prompts file — one per line, or "-" to read stdin')
|
|
298
413
|
.option('-m, --model <id>', 'model to use', gemini.DEFAULT_MODEL)
|
|
414
|
+
.option('-a, --agent <id>', 'submit every prompt to a research agent instead of a model')
|
|
299
415
|
.option('-s, --system <text>', 'system instruction')
|
|
300
416
|
.option('-t, --tag <tag>', 'tag the whole batch (default: batch-<hex>)')
|
|
301
417
|
.option('--separator <str>', 'split the file on this delimiter line for multi-line prompts')
|
|
302
418
|
.option('-w, --watch', 'submit all, then poll until the whole batch finishes')
|
|
419
|
+
.option('--yes', 'confirm the agent cost without asking (required when stdin is not a TTY)')
|
|
303
420
|
.option('--dry-run', 'parse and list what would be submitted; submit nothing')
|
|
304
421
|
.option('--json', 'machine-readable output')
|
|
305
422
|
.description('submit many background tasks from a file, tagged as one batch')
|
|
306
|
-
.action(async (file, opts) => {
|
|
423
|
+
.action(async (file, opts, cmd) => {
|
|
307
424
|
try {
|
|
425
|
+
const agent = resolveAgentOpts(opts, cmd);
|
|
308
426
|
const text = file === '-' ? await readStdin() : fs.readFileSync(file, 'utf8');
|
|
309
427
|
const { prompts, skipped } = parsePrompts(text, opts.separator);
|
|
310
428
|
if (!prompts.length) throw new Error(`no prompts found in ${file === '-' ? 'stdin' : file}`);
|
|
@@ -317,19 +435,28 @@ program
|
|
|
317
435
|
const tag = opts.tag || `batch-${crypto.randomUUID().slice(0, 6)}`;
|
|
318
436
|
|
|
319
437
|
if (opts.dryRun) {
|
|
320
|
-
emit(opts.json, { tag, dry_run: true, prompts }, () => {
|
|
321
|
-
|
|
322
|
-
|
|
438
|
+
emit(opts.json, { tag, dry_run: true, agent: agent || null, prompts }, () => {
|
|
439
|
+
if (agent) {
|
|
440
|
+
// The whole point of the guard: N × the per-task band, up front.
|
|
441
|
+
console.log(`${spendLine(agent, prompts.length)}. Nothing submitted (--dry-run).`);
|
|
442
|
+
} else {
|
|
443
|
+
console.log(`Batch ${tag}: ${prompts.length} prompt(s) would be submitted:`);
|
|
444
|
+
for (const p of prompts) console.log(` ${snippet(p)}`);
|
|
445
|
+
}
|
|
323
446
|
});
|
|
324
447
|
return;
|
|
325
448
|
}
|
|
326
449
|
|
|
450
|
+
// An agent batch multiplies a per-task dollar band by the whole file, so
|
|
451
|
+
// it is confirmed as one total before a single row is written.
|
|
452
|
+
if (agent) await confirmSpend(agent, prompts.length, opts);
|
|
453
|
+
|
|
327
454
|
// One failed submit must not sink the batch: mark that task failed and
|
|
328
455
|
// keep going. mapLimit preserves input order, so the report is stable.
|
|
329
456
|
const results = await mapLimit(prompts, 4, async (prompt) => {
|
|
330
|
-
const id = store.createTask({ prompt, model: opts.model, systemInstruction: opts.system, tag });
|
|
457
|
+
const id = store.createTask({ prompt, model: agent ? null : opts.model, agent, systemInstruction: opts.system, tag });
|
|
331
458
|
try {
|
|
332
|
-
const r = await gemini.submit(prompt, { model: opts.model, systemInstruction: opts.system });
|
|
459
|
+
const r = await gemini.submit(prompt, { model: opts.model, agent, systemInstruction: opts.system });
|
|
333
460
|
store.setInteraction(id, r.interactionId, r.status);
|
|
334
461
|
return { id, interaction_id: r.interactionId, status: r.status, prompt };
|
|
335
462
|
} catch (err) {
|
|
@@ -402,16 +529,17 @@ program
|
|
|
402
529
|
// text stores `''`, which is exactly the case the cache must still serve
|
|
403
530
|
// -- re-polling it would 404 after 24h, the very thing we cache to avoid.
|
|
404
531
|
if (isSuccess(task.status) && task.result != null && !opts.raw) {
|
|
405
|
-
|
|
406
|
-
|
|
532
|
+
const cits = parseCitations(task.citations);
|
|
533
|
+
emit(opts.json, { id: task.id, status: task.status, result: task.result, citations: cits }, () =>
|
|
534
|
+
console.log(withSources(task.result, cits))
|
|
407
535
|
);
|
|
408
536
|
return;
|
|
409
537
|
}
|
|
410
538
|
const r = await refresh(task);
|
|
411
539
|
if (opts.raw) return console.log(JSON.stringify(r.raw, null, 2));
|
|
412
540
|
if (isSuccess(r.status)) {
|
|
413
|
-
emit(opts.json, { id: task.id, status: r.status, result: r.text }, () =>
|
|
414
|
-
console.log(r.text
|
|
541
|
+
emit(opts.json, { id: task.id, status: r.status, result: r.text, citations: r.citations || null }, () =>
|
|
542
|
+
console.log(withSources(r.text, r.citations))
|
|
415
543
|
);
|
|
416
544
|
} else if (isDone(r.status)) {
|
|
417
545
|
emit(opts.json, { id: task.id, status: r.status, error: r.text || null }, () =>
|
|
@@ -450,14 +578,21 @@ program
|
|
|
450
578
|
console.log('No tasks yet. Submit one: gemcatch research "your question"');
|
|
451
579
|
return;
|
|
452
580
|
}
|
|
453
|
-
|
|
581
|
+
// The AGENT column only appears when something in the listing used one, so
|
|
582
|
+
// a pure-model store keeps the compact four-column layout it always had.
|
|
583
|
+
// Agent ids are shown compact -- the "-preview-MM-YYYY" suffix is version
|
|
584
|
+
// noise in a table (the full id is in --json and in stats).
|
|
585
|
+
const showAgent = tasks.some((t) => t.agent);
|
|
586
|
+
const shortAgent = (a) => (a ? a.replace(/-preview-\d{2}-\d{4}$/, '') : '-');
|
|
587
|
+
console.log(dim(`ID AGE STATUS ${showAgent ? 'AGENT ' : ''}PROMPT`));
|
|
454
588
|
for (const t of tasks) {
|
|
455
589
|
const snip = snippet(t.prompt);
|
|
456
590
|
const status = t.status || PENDING;
|
|
457
591
|
// Pad before colouring: ANSI codes would break the column width.
|
|
458
592
|
const pad = ' '.repeat(Math.max(0, 16 - status.length));
|
|
593
|
+
const agentCol = showAgent ? `${shortAgent(t.agent).padEnd(18)} ` : '';
|
|
459
594
|
console.log(
|
|
460
|
-
`${t.id} ${age(t.created_at).padEnd(4)} ${colorStatus(status)}${pad} ${snip}`
|
|
595
|
+
`${t.id} ${age(t.created_at).padEnd(4)} ${colorStatus(status)}${pad} ${agentCol}${snip}`
|
|
461
596
|
);
|
|
462
597
|
}
|
|
463
598
|
});
|
|
@@ -726,8 +861,8 @@ async function watchTask(task, intervalMs, json) {
|
|
|
726
861
|
last = r.status;
|
|
727
862
|
}
|
|
728
863
|
if (isSuccess(r.status)) {
|
|
729
|
-
emit(json, { id: task.id, status: r.status, result: r.text }, () =>
|
|
730
|
-
console.log(r.text
|
|
864
|
+
emit(json, { id: task.id, status: r.status, result: r.text, citations: r.citations || null }, () =>
|
|
865
|
+
console.log(withSources(r.text, r.citations))
|
|
731
866
|
);
|
|
732
867
|
return;
|
|
733
868
|
}
|
|
@@ -755,8 +890,9 @@ program
|
|
|
755
890
|
// Serve a completed result from cache -- present, not merely truthy, so an
|
|
756
891
|
// empty-text completion is served instead of re-polled (and lost at 24h).
|
|
757
892
|
if (isSuccess(task.status) && task.result != null) {
|
|
758
|
-
|
|
759
|
-
|
|
893
|
+
const cits = parseCitations(task.citations);
|
|
894
|
+
emit(opts.json, { id: task.id, status: task.status, result: task.result, citations: cits }, () =>
|
|
895
|
+
console.log(withSources(task.result, cits))
|
|
760
896
|
);
|
|
761
897
|
return;
|
|
762
898
|
}
|
|
@@ -850,11 +986,16 @@ program
|
|
|
850
986
|
.description('where the store lives and what is in it')
|
|
851
987
|
.action((opts) => {
|
|
852
988
|
const rows = store.counts();
|
|
989
|
+
const agents = store.agentCounts();
|
|
853
990
|
const total = rows.reduce((n, r) => n + r.n, 0);
|
|
854
|
-
emit(opts.json, { db: store.DB_PATH, total, by_status: rows }, () => {
|
|
991
|
+
emit(opts.json, { db: store.DB_PATH, total, by_status: rows, by_agent: agents }, () => {
|
|
855
992
|
console.log(`Store: ${store.DB_PATH}`);
|
|
856
993
|
console.log(`Tasks: ${total}`);
|
|
857
994
|
for (const r of rows) console.log(` ${colorStatus(r.status).padEnd(useColor ? 26 : 17)} ${r.n}`);
|
|
995
|
+
if (agents.length) {
|
|
996
|
+
console.log('Agent runs:');
|
|
997
|
+
for (const a of agents) console.log(` ${a.agent.padEnd(34)} ${a.n}`);
|
|
998
|
+
}
|
|
858
999
|
});
|
|
859
1000
|
});
|
|
860
1001
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gemcatch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Fire-and-forget CLI for Gemini's Interactions API background execution. Submit long-running research prompts, close your laptop, collect results later.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"background",
|
|
30
30
|
"async",
|
|
31
31
|
"agents",
|
|
32
|
+
"deep-research",
|
|
32
33
|
"cli",
|
|
33
34
|
"research",
|
|
34
35
|
"daemon",
|
|
@@ -46,7 +47,11 @@
|
|
|
46
47
|
"homepage": "https://github.com/Booyaka101/gemcatch#readme",
|
|
47
48
|
"dependencies": {
|
|
48
49
|
"@google/genai": "^2.12.0",
|
|
49
|
-
"better-sqlite3": "^
|
|
50
|
+
"better-sqlite3": "^13.0.3",
|
|
50
51
|
"commander": "^15.0.0"
|
|
52
|
+
},
|
|
53
|
+
"allowScripts": {
|
|
54
|
+
"@google/genai@2.15.0": true,
|
|
55
|
+
"protobufjs@7.6.5": true
|
|
51
56
|
}
|
|
52
57
|
}
|