gemcatch 0.2.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 +111 -1
- package/README.md +64 -7
- package/db.js +22 -4
- package/gemini.js +103 -10
- package/index.js +441 -56
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,114 @@ 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
|
+
|
|
52
|
+
## [0.3.0] - 2026-07-19
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- `gemcatch export [--tag <t>] [--status <s>] [--format md|json] [-o <file>]` —
|
|
57
|
+
concatenate finished results into one document, each under a heading with its
|
|
58
|
+
prompt, id and date. Markdown by default (or JSON for `jq`), to stdout or a
|
|
59
|
+
file. This is the "gather" step that pairs with `batch`'s "scatter": where
|
|
60
|
+
`get` prints one result at a time, `export` collects a whole tag at once.
|
|
61
|
+
- `gemcatch digest --tag <t>` — feed a tag's completed results back through a
|
|
62
|
+
single Gemini call to synthesise one summary. Submits like `research` and
|
|
63
|
+
watches to completion; the summary lands under `<tag>-digest`.
|
|
64
|
+
- `GEMCATCH_WATCH_MAX_FAILS` (default 10) — the consecutive-poll-failure bound
|
|
65
|
+
at which `watch` and `batch -w` give up rather than loop forever.
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- `research --watch` no longer marks a **successfully submitted, server-running**
|
|
70
|
+
task `failed` when a poll errors during the watch. A transient poll failure (a
|
|
71
|
+
5xx past its retries, a network blip) or an expiry mid-watch would propagate to
|
|
72
|
+
the submit handler and overwrite the status to `failed`, dropping the task from
|
|
73
|
+
the active set so the daemon abandoned it and the result was lost. The watch
|
|
74
|
+
loop now rides out poll errors (retrying on the next interval), and only a
|
|
75
|
+
failed *submit* — a task with no `interaction_id` yet — is ever marked `failed`.
|
|
76
|
+
- A wedged or expired interaction no longer keeps a task in flight forever. When a
|
|
77
|
+
poll returns **404** (the free tier drops interactions after 24h, or one was
|
|
78
|
+
deleted), the task is retired locally to `incomplete` with a recorded reason, so
|
|
79
|
+
it leaves the active set and `daemon --exit-when-idle` converges. `watch` and
|
|
80
|
+
`batch -w` additionally stop after `GEMCATCH_WATCH_MAX_FAILS` consecutive poll
|
|
81
|
+
failures (or a stalled batch), with a clear message and a non-zero exit, instead
|
|
82
|
+
of spinning.
|
|
83
|
+
- A **completed-but-empty** result is now served from the local cache. `get` and
|
|
84
|
+
`watch` gated the cache hit on the result being truthy, so a task that completed
|
|
85
|
+
with empty text (`''`) skipped the cache, re-polled, and 404'd after 24h — the
|
|
86
|
+
exact loss the daemon exists to prevent. The gate is now on presence
|
|
87
|
+
(`result != null`), not truthiness.
|
|
88
|
+
- `prune -d <negative>` (or a non-numeric `--days`) is rejected instead of putting
|
|
89
|
+
the cutoff in the future and deleting **every** finished task. `--days` must now
|
|
90
|
+
be a non-negative number.
|
|
91
|
+
- `batch` no longer silently drops a prompt line that starts with `#`. A `#` is a
|
|
92
|
+
comment only when followed by whitespace (`# like this`); a line such as
|
|
93
|
+
`#1 cause of X?` is a real prompt and survives. When comment or blank lines are
|
|
94
|
+
skipped, a one-line count is noted on stderr.
|
|
95
|
+
- `watch -i` / `daemon -i` reject a non-positive interval (`-i -5` busy-looped,
|
|
96
|
+
`-i 0` silently fell back to the default). `list -n 0` now returns zero rows
|
|
97
|
+
instead of all of them, and `-n` rejects negatives (which SQLite reads as "no
|
|
98
|
+
limit").
|
|
99
|
+
- A second `Ctrl-C` to the daemon now force-exits (130) instead of doing nothing
|
|
100
|
+
while a long paced pass finishes.
|
|
101
|
+
- One-shot commands close the SQLite store on exit, so they no longer leave
|
|
102
|
+
`-wal`/`-shm` sidecar files lingering next to `tasks.db`.
|
|
103
|
+
- Colour written to **stderr** (the status chatter from `watch`, `daemon` and
|
|
104
|
+
`research -w`) is now keyed to `process.stderr.isTTY`, not stdout's. Redirecting
|
|
105
|
+
one stream no longer strips colour from the other, nor leaks raw ANSI into a
|
|
106
|
+
redirected file.
|
|
107
|
+
|
|
108
|
+
### Notes
|
|
109
|
+
|
|
110
|
+
- The default `@google/genai` SDK transport is now covered by the offline suite
|
|
111
|
+
(previously every test forced `GEMCATCH_FORCE_REST=1`): a stubbed client drives
|
|
112
|
+
submit → poll → completed and one unwrapped SDK error, confirming `shape()`
|
|
113
|
+
reads an SDK-shaped response and `friendly()` surfaces Google's real message.
|
|
114
|
+
- `GEMCATCH_RPM` pacing is **per process**. Two concurrent `gemcatch` processes
|
|
115
|
+
each keep their own counter and can together exceed the ceiling; run a single
|
|
116
|
+
daemon if the limit must hold. Documented in the README.
|
|
117
|
+
|
|
10
118
|
## [0.2.0] - 2026-07-19
|
|
11
119
|
|
|
12
120
|
### Added
|
|
@@ -96,7 +204,9 @@ seen a task complete, the text is cached locally and survives that expiry — bu
|
|
|
96
204
|
something has to poll inside that window for it to be seen at all, which is what
|
|
97
205
|
`gemcatch daemon` exists to do.
|
|
98
206
|
|
|
99
|
-
[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
|
|
209
|
+
[0.3.0]: https://github.com/Booyaka101/gemcatch/compare/v0.2.0...v0.3.0
|
|
100
210
|
[0.2.0]: https://github.com/Booyaka101/gemcatch/compare/v0.1.1...v0.2.0
|
|
101
211
|
[0.1.1]: https://github.com/Booyaka101/gemcatch/compare/v0.1.0...v0.1.1
|
|
102
212
|
[0.1.0]: https://github.com/Booyaka101/gemcatch/releases/tag/v0.1.0
|
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:
|
|
@@ -83,6 +83,8 @@ This week in AI: ...
|
|
|
83
83
|
| `gemcatch status <id>` | Polls the API and prints the current state. |
|
|
84
84
|
| `gemcatch get <id>` | Prints the full response if complete, otherwise the current status. |
|
|
85
85
|
| `gemcatch list` | All tasks, newest first: id, age, status, prompt. |
|
|
86
|
+
| `gemcatch export` | Concatenates finished results, each under its prompt, to stdout or a file (Markdown or JSON). |
|
|
87
|
+
| `gemcatch digest` | Feeds a tag's completed results through one Gemini call into a single summary. |
|
|
86
88
|
| `gemcatch watch <id>` | Polls until the task finishes, then prints the result. |
|
|
87
89
|
| `gemcatch sync` | Refreshes every in-flight task in one pass. |
|
|
88
90
|
| `gemcatch daemon` | Keeps polling in-flight tasks on a loop, so results are cached before they expire. |
|
|
@@ -97,15 +99,20 @@ Useful flags:
|
|
|
97
99
|
| --- | --- | --- |
|
|
98
100
|
| `--json` | most commands | Machine-readable output. |
|
|
99
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. |
|
|
100
104
|
| `-s, --system <text>` | `research`, `batch` | Set a system instruction. |
|
|
101
105
|
| `-f, --file <path>` | `research` | Read the prompt from a file. |
|
|
102
106
|
| `-t, --tag <tag>` | `research`, `batch`, `list` | Label tasks and filter them. |
|
|
103
107
|
| `-w, --watch` | `research`, `batch` | Submit and wait, in one command. |
|
|
104
108
|
| `--separator <str>` | `batch` | Split the file on this delimiter line for multi-line prompts. |
|
|
105
|
-
| `-i, --interval <s>` | `watch`, `daemon` | Poll rate. Default 10s for `watch`, 300s for `daemon`. |
|
|
109
|
+
| `-i, --interval <s>` | `watch`, `daemon` | Poll rate in seconds; must be > 0. Default 10s for `watch`, 300s for `daemon`. |
|
|
106
110
|
| `--exit-when-idle` | `daemon` | Stop once nothing is left in flight. |
|
|
107
|
-
|
|
|
108
|
-
|
|
|
111
|
+
| `--status <s>` | `list`, `export` | Only tasks in this status. |
|
|
112
|
+
| `-n, --limit <n>` | `list` | Cap the rows (non-negative; `0` shows none). |
|
|
113
|
+
| `--format <md\|json>` | `export` | Output format. Default `md`. |
|
|
114
|
+
| `-o, --out <file>` | `export` | Write to a file instead of stdout. |
|
|
115
|
+
| `--dry-run` | `research`, `batch`, `prune` | Show what would go — including the projected agent spend; submit/delete nothing. |
|
|
109
116
|
| `--raw` | `get` | Dump the raw interaction JSON. |
|
|
110
117
|
|
|
111
118
|
IDs are the first 8 characters of a UUID. Any unique prefix works, so `gemcatch get 8f3a` is fine.
|
|
@@ -117,10 +124,15 @@ Statuses come straight from the API: `in_progress`, `requires_action`, `complete
|
|
|
117
124
|
```bash
|
|
118
125
|
# Fire off a whole file of prompts in one command, then collect later.
|
|
119
126
|
# Every task shares one auto-generated tag (batch-xxxxxx), printed on submit.
|
|
120
|
-
$ gemcatch batch questions.txt # one prompt per line; # and blanks skipped
|
|
127
|
+
$ gemcatch batch questions.txt # one prompt per line; "# " and blanks skipped
|
|
121
128
|
$ gemcatch daemon --exit-when-idle # keep polling until they're all in
|
|
122
129
|
$ gemcatch list --tag batch-1a2b3c --status completed
|
|
123
130
|
|
|
131
|
+
# Collect a whole batch into one document (the "gather" for batch's "scatter").
|
|
132
|
+
$ gemcatch export --tag batch-1a2b3c -o results.md # Markdown, one section per prompt
|
|
133
|
+
$ gemcatch export --tag batch-1a2b3c --format json | jq -r '.[].result'
|
|
134
|
+
$ gemcatch digest --tag batch-1a2b3c # or synthesize them into one summary
|
|
135
|
+
|
|
124
136
|
# Multi-line prompts: split the file on a delimiter line instead of per-line
|
|
125
137
|
$ gemcatch batch briefs.md --separator ---
|
|
126
138
|
$ gemcatch batch - < questions.txt # or pipe the list in on stdin
|
|
@@ -143,6 +155,46 @@ $ id=$(gemcatch research "..." --json | jq -r .id)
|
|
|
143
155
|
$ gemcatch watch "$id" --json | jq -r .result
|
|
144
156
|
```
|
|
145
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
|
+
|
|
146
198
|
## How it works
|
|
147
199
|
|
|
148
200
|
Tasks live in SQLite at `~/.gemcatch/tasks.db` (override with `GEMCATCH_HOME`):
|
|
@@ -150,7 +202,7 @@ Tasks live in SQLite at `~/.gemcatch/tasks.db` (override with `GEMCATCH_HOME`):
|
|
|
150
202
|
```sql
|
|
151
203
|
CREATE TABLE tasks (id TEXT PRIMARY KEY, prompt TEXT, interaction_id TEXT,
|
|
152
204
|
status TEXT DEFAULT 'pending', result TEXT, created_at INTEGER);
|
|
153
|
-
-- plus model, system_instruction, tag, error, usage, updated_at
|
|
205
|
+
-- plus model, system_instruction, tag, error, usage, updated_at, agent, citations
|
|
154
206
|
```
|
|
155
207
|
|
|
156
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.
|
|
@@ -182,10 +234,14 @@ $ gemcatch daemon --exit-when-idle -i 30
|
|
|
182
234
|
$ gemcatch list --tag batch1 --status completed
|
|
183
235
|
```
|
|
184
236
|
|
|
237
|
+
If a task's interaction has vanished server-side — the free tier dropped it after 24h, or it was deleted — polling it returns a 404. Rather than chase a task that can never resolve, `gemcatch` retires it locally to `incomplete`, so it leaves the in-flight set and `--exit-when-idle` still converges. `watch` and `batch -w` also give up after a bounded run of consecutive poll failures (`GEMCATCH_WATCH_MAX_FAILS`, default 10) instead of looping forever.
|
|
238
|
+
|
|
185
239
|
## Rate limits and retries
|
|
186
240
|
|
|
187
241
|
The free tier allows roughly 15 requests a minute, which a wide `gemcatch sync` or a busy daemon would otherwise blow straight through. Every outbound call is paced to `GEMCATCH_RPM` (default 15) — set it higher on a paid key, or `0` to disable pacing entirely.
|
|
188
242
|
|
|
243
|
+
Pacing is **per process**: each `gemcatch` invocation keeps its own counter, so two running at once (a `daemon` in one terminal and a one-off `sync` in another) can together exceed the ceiling. If you need the limit to hold, run a single daemon and let it do the polling.
|
|
244
|
+
|
|
189
245
|
Transient failures are retried with exponential backoff and full jitter, honouring `Retry-After` when the server sends it. A rate limit, a timeout or a 5xx gets `GEMCATCH_MAX_RETRIES` more attempts (default 4); a 4xx does not, because a bad key or a bad model id fails identically forever and retrying it only burns your quota.
|
|
190
246
|
|
|
191
247
|
## Environment variables
|
|
@@ -194,11 +250,12 @@ Transient failures are retried with exponential backoff and full jitter, honouri
|
|
|
194
250
|
| --- | --- |
|
|
195
251
|
| `GEMINI_API_KEY` | Your API key. `GOOGLE_API_KEY` also works. |
|
|
196
252
|
| `GEMCATCH_HOME` | Where `tasks.db` lives. Default `~/.gemcatch`. |
|
|
197
|
-
| `GEMCATCH_MODEL` | Default model. Default `gemini-3.
|
|
253
|
+
| `GEMCATCH_MODEL` | Default model. Default `gemini-3.5-flash-lite`. |
|
|
198
254
|
| `GEMCATCH_POLL_MS` | `watch` poll interval in ms. Default `10000`. |
|
|
199
255
|
| `GEMCATCH_DAEMON_S` | `daemon` interval in seconds. Default `300`. |
|
|
200
256
|
| `GEMCATCH_RPM` | Requests/minute ceiling. Default `15` (the free tier). `0` disables pacing. |
|
|
201
257
|
| `GEMCATCH_MAX_RETRIES` | Extra attempts on a transient failure. Default `4`. `0` disables retries. |
|
|
258
|
+
| `GEMCATCH_WATCH_MAX_FAILS` | Consecutive poll failures before `watch`/`batch -w` give up. Default `10`. |
|
|
202
259
|
| `GEMCATCH_BASE_URL` | Override the API endpoint (proxy/gateway/testing). |
|
|
203
260
|
| `GEMCATCH_FORCE_REST` | `1` bypasses the SDK and uses raw `fetch`. |
|
|
204
261
|
| `NO_COLOR` | Disable colour output. |
|
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];
|
|
@@ -125,7 +131,10 @@ function listTasks(opts) {
|
|
|
125
131
|
let sql = 'SELECT * FROM tasks';
|
|
126
132
|
if (where.length) sql += ` WHERE ${where.join(' AND ')}`;
|
|
127
133
|
sql += ' ORDER BY created_at DESC';
|
|
128
|
-
|
|
134
|
+
// Presence, not truthiness: `--limit 0` is a real cap (return nothing), so it
|
|
135
|
+
// must not be treated the same as "no limit given". The caller validates that
|
|
136
|
+
// it is a non-negative integer before we get here.
|
|
137
|
+
if (o.limit != null) {
|
|
129
138
|
sql += ' LIMIT @limit';
|
|
130
139
|
params.limit = o.limit;
|
|
131
140
|
}
|
|
@@ -164,6 +173,14 @@ function counts() {
|
|
|
164
173
|
return db().prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status').all();
|
|
165
174
|
}
|
|
166
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
|
+
|
|
167
184
|
function close() {
|
|
168
185
|
if (_db) _db.close();
|
|
169
186
|
_db = null;
|
|
@@ -182,5 +199,6 @@ module.exports = {
|
|
|
182
199
|
removeMany,
|
|
183
200
|
prunableTasks,
|
|
184
201
|
counts,
|
|
202
|
+
agentCounts,
|
|
185
203
|
close,
|
|
186
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
|
@@ -13,20 +13,41 @@ const DEFAULT_POLL_MS = Number(process.env.GEMCATCH_POLL_MS) || 10000;
|
|
|
13
13
|
// comfortably faster than that. Five minutes is far inside the margin and
|
|
14
14
|
// costs a handful of requests an hour.
|
|
15
15
|
const DEFAULT_DAEMON_S = Number(process.env.GEMCATCH_DAEMON_S) || 300;
|
|
16
|
+
// A watch loop must not spin forever on a task the server can no longer resolve
|
|
17
|
+
// -- a wedged in_progress, or transient poll errors that never clear. `watch`
|
|
18
|
+
// and `batch -w` give up after this many *consecutive* poll failures (a clean
|
|
19
|
+
// poll resets the run), surfacing a clear message and a non-zero exit instead
|
|
20
|
+
// of hanging. The daemon, meant to run for days, is bounded differently: a 404
|
|
21
|
+
// retires the task locally (see refresh) so it simply leaves the active set.
|
|
22
|
+
const WATCH_MAX_FAILS = Number(process.env.GEMCATCH_WATCH_MAX_FAILS) || 10;
|
|
16
23
|
const ALL_STATUSES = [PENDING].concat(ACTIVE, TERMINAL);
|
|
17
24
|
|
|
18
25
|
// --- output ---------------------------------------------------------------
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
// Colour is decided per stream. Progress and status chatter go to stderr
|
|
28
|
+
// (watch/daemon/research -w); results and tables go to stdout. Each stream keys
|
|
29
|
+
// its ANSI on its *own* TTY-ness, so redirecting one (`gemcatch watch x > out.txt`)
|
|
30
|
+
// neither strips colour from the other nor leaks raw escape codes into the
|
|
31
|
+
// redirected file. NO_COLOR disables both.
|
|
32
|
+
const NO_COLOR = !!process.env.NO_COLOR;
|
|
33
|
+
const useColor = process.stdout.isTTY && !NO_COLOR; // stdout-bound colour
|
|
34
|
+
const useColorErr = process.stderr.isTTY && !NO_COLOR; // stderr-bound colour
|
|
35
|
+
|
|
36
|
+
const wrap = (on) => (code, s) => (on ? `[${code}m${s}[0m` : s);
|
|
37
|
+
const paint = wrap(useColor); // paints for stdout
|
|
38
|
+
const epaint = wrap(useColorErr); // paints for stderr
|
|
22
39
|
const dim = (s) => paint('2', s);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (s
|
|
28
|
-
return
|
|
40
|
+
const edim = (s) => epaint('2', s);
|
|
41
|
+
|
|
42
|
+
// Status colour keyed to a given painter, so one rule set serves both streams.
|
|
43
|
+
function tint(pnt, s) {
|
|
44
|
+
if (isSuccess(s)) return pnt('32', s); // green
|
|
45
|
+
if (s === 'in_progress' || s === PENDING) return pnt('36', s); // cyan
|
|
46
|
+
if (s === 'requires_action') return pnt('33', s); // yellow
|
|
47
|
+
return pnt('31', s); // red: failed/cancelled/incomplete/budget_exceeded
|
|
29
48
|
}
|
|
49
|
+
const colorStatus = (s) => tint(paint, s); // for stdout
|
|
50
|
+
const ecolorStatus = (s) => tint(epaint, s); // for stderr
|
|
30
51
|
|
|
31
52
|
const hhmmss = () => new Date().toISOString().slice(11, 19);
|
|
32
53
|
|
|
@@ -68,6 +89,104 @@ function needTask(id) {
|
|
|
68
89
|
return task;
|
|
69
90
|
}
|
|
70
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
|
+
|
|
71
190
|
// --- input ----------------------------------------------------------------
|
|
72
191
|
|
|
73
192
|
function readStdin() {
|
|
@@ -100,11 +219,30 @@ async function resolvePrompt(arg, opts) {
|
|
|
100
219
|
// Poll one task and persist whatever came back.
|
|
101
220
|
async function refresh(task) {
|
|
102
221
|
if (!task.interaction_id) return { status: task.status, text: null, usage: null };
|
|
103
|
-
|
|
222
|
+
let r;
|
|
223
|
+
try {
|
|
224
|
+
r = await gemini.poll(task.interaction_id);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
// A 404 is genuine and permanent: the interaction is gone -- dropped after
|
|
227
|
+
// the free tier's 24h retention, or deleted -- and it will 404 identically
|
|
228
|
+
// forever (a 4xx never retries). Retire the task locally so it leaves the
|
|
229
|
+
// active set, instead of the daemon or a watch loop polling a ghost until
|
|
230
|
+
// the end of time. Any other error (5xx, network) is transient and is
|
|
231
|
+
// re-thrown for the caller to retry on its next pass.
|
|
232
|
+
if (err && err.httpStatus === 404) {
|
|
233
|
+
store.setStatus(task.id, 'incomplete', { error: 'interaction not found (expired or deleted)' });
|
|
234
|
+
return { status: 'incomplete', text: null, usage: null, raw: null };
|
|
235
|
+
}
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
104
238
|
const extra = {};
|
|
105
239
|
if (isDone(r.status)) {
|
|
106
|
-
if (isSuccess(r.status))
|
|
107
|
-
|
|
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;
|
|
108
246
|
}
|
|
109
247
|
if (r.usage) extra.usage = JSON.stringify(r.usage);
|
|
110
248
|
store.setStatus(task.id, r.status, extra);
|
|
@@ -141,28 +279,41 @@ program
|
|
|
141
279
|
.argument('[prompt]', 'what you want researched; "-" reads stdin')
|
|
142
280
|
.option('-f, --file <path>', 'read the prompt from a file')
|
|
143
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)')
|
|
144
283
|
.option('-s, --system <text>', 'system instruction')
|
|
145
284
|
.option('-t, --tag <tag>', 'label for filtering with `gemcatch list --tag`')
|
|
146
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')
|
|
147
288
|
.option('--json', 'machine-readable output')
|
|
148
289
|
.description('submit a background task and exit immediately')
|
|
149
|
-
.action(async (promptArg, opts) => {
|
|
290
|
+
.action(async (promptArg, opts, cmd) => {
|
|
150
291
|
let id;
|
|
151
292
|
try {
|
|
293
|
+
const agent = resolveAgentOpts(opts, cmd);
|
|
152
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);
|
|
153
303
|
id = store.createTask({
|
|
154
304
|
prompt,
|
|
155
|
-
model: opts.model,
|
|
305
|
+
model: agent ? null : opts.model,
|
|
306
|
+
agent,
|
|
156
307
|
systemInstruction: opts.system,
|
|
157
308
|
tag: opts.tag,
|
|
158
309
|
});
|
|
159
|
-
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 });
|
|
160
311
|
store.setInteraction(id, r.interactionId, r.status);
|
|
161
312
|
if (opts.watch) {
|
|
162
313
|
// Under --watch the submit line is progress, not the answer, so it
|
|
163
314
|
// goes to stderr -- `gemcatch research -w "..." > out.txt` then captures
|
|
164
315
|
// only the result.
|
|
165
|
-
if (!opts.json) console.error(
|
|
316
|
+
if (!opts.json) console.error(edim(`Task ${id} submitted.`));
|
|
166
317
|
await watchTask(store.getTask(id), DEFAULT_POLL_MS, opts.json);
|
|
167
318
|
return;
|
|
168
319
|
}
|
|
@@ -170,16 +321,29 @@ program
|
|
|
170
321
|
console.log(`Task ${id} submitted. Run: gemcatch get ${id} when ready.`)
|
|
171
322
|
);
|
|
172
323
|
} catch (err) {
|
|
173
|
-
|
|
324
|
+
// Only a failed *submit* should mark the task failed. Once it has an
|
|
325
|
+
// interaction_id it is live on the server, and a later watch/poll error
|
|
326
|
+
// must never overwrite it to failed -- that would drop it from the active
|
|
327
|
+
// set and the daemon would abandon a task whose result is still coming.
|
|
328
|
+
// Leave it active; the daemon (or a later `get`) collects it.
|
|
329
|
+
if (id) {
|
|
330
|
+
const t = store.getTask(id);
|
|
331
|
+
if (!t || !t.interaction_id) store.setStatus(id, 'failed', { error: err.message });
|
|
332
|
+
}
|
|
174
333
|
die(err);
|
|
175
334
|
}
|
|
176
335
|
});
|
|
177
336
|
|
|
178
337
|
// --- batch ----------------------------------------------------------------
|
|
179
338
|
|
|
180
|
-
// Turn a prompts file into a list of prompts
|
|
181
|
-
//
|
|
339
|
+
// Turn a prompts file into a list of prompts, plus a count of the lines it
|
|
340
|
+
// dropped so the caller can note them. Default: one per line, skipping blank
|
|
341
|
+
// lines and `#` comments. With --separator, split the whole file on that
|
|
182
342
|
// delimiter line instead, so a single prompt can span multiple lines.
|
|
343
|
+
//
|
|
344
|
+
// A `#` is a comment only when followed by whitespace (`# like this`). A line
|
|
345
|
+
// such as `#1 cause of X?` is a real prompt, not a comment, and must survive --
|
|
346
|
+
// treating every leading `#` as a comment silently swallowed those.
|
|
183
347
|
function parsePrompts(text, separator) {
|
|
184
348
|
if (separator) {
|
|
185
349
|
const blocks = [];
|
|
@@ -193,12 +357,20 @@ function parsePrompts(text, separator) {
|
|
|
193
357
|
}
|
|
194
358
|
}
|
|
195
359
|
blocks.push(cur.join('\n').trim());
|
|
196
|
-
|
|
360
|
+
const prompts = blocks.filter(Boolean);
|
|
361
|
+
return { prompts, skipped: blocks.length - prompts.length };
|
|
362
|
+
}
|
|
363
|
+
// The file almost always ends in a newline; that trailing empty line is not a
|
|
364
|
+
// blank the user wrote, so it does not count towards the skipped tally.
|
|
365
|
+
const lines = text.split(/\r?\n/).map((l) => l.trim());
|
|
366
|
+
while (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
367
|
+
const prompts = [];
|
|
368
|
+
let skipped = 0;
|
|
369
|
+
for (const l of lines) {
|
|
370
|
+
if (!l || /^#\s/.test(l)) skipped += 1;
|
|
371
|
+
else prompts.push(l);
|
|
197
372
|
}
|
|
198
|
-
return
|
|
199
|
-
.split(/\r?\n/)
|
|
200
|
-
.map((l) => l.trim())
|
|
201
|
-
.filter((l) => l && !l.startsWith('#'));
|
|
373
|
+
return { prompts, skipped };
|
|
202
374
|
}
|
|
203
375
|
|
|
204
376
|
// Poll just this batch until nothing tagged with it is still in flight, then
|
|
@@ -207,11 +379,25 @@ function parsePrompts(text, separator) {
|
|
|
207
379
|
async function watchBatch(tag, intervalMs, json) {
|
|
208
380
|
const inFlight = () => store.listTasks({ tag }).filter((t) => t.interaction_id && !isDone(t.status));
|
|
209
381
|
let pending = inFlight();
|
|
382
|
+
let stalls = 0; // consecutive passes that resolved nothing
|
|
210
383
|
while (pending.length) {
|
|
211
384
|
// A poll that throws keeps the task's old status; the next pass retries it.
|
|
385
|
+
// A 404 retires the task inside refresh, so it drops out of `inFlight`.
|
|
212
386
|
await mapLimit(pending, 4, (t) => refresh(t).catch(() => {}));
|
|
213
|
-
|
|
214
|
-
|
|
387
|
+
const next = inFlight();
|
|
388
|
+
// Forward progress = the in-flight set shrank. A pass that resolves nothing
|
|
389
|
+
// -- every poll erroring, or a wedged in_progress that never moves -- is a
|
|
390
|
+
// stall; enough of those in a row means give up rather than loop forever.
|
|
391
|
+
stalls = next.length < pending.length ? 0 : stalls + 1;
|
|
392
|
+
pending = next;
|
|
393
|
+
if (!pending.length) break;
|
|
394
|
+
if (stalls >= WATCH_MAX_FAILS) {
|
|
395
|
+
const msg = `Batch ${tag}: gave up after ${stalls} passes with no progress; ${pending.length} task(s) unresolved.`;
|
|
396
|
+
emit(json, { tag, error: msg, unresolved: pending.length }, () => console.error(edim(msg)));
|
|
397
|
+
process.exitCode = 1;
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
215
401
|
}
|
|
216
402
|
const tasks = store.listTasks({ tag });
|
|
217
403
|
const completed = tasks.filter((t) => isSuccess(t.status)).length;
|
|
@@ -225,35 +411,52 @@ program
|
|
|
225
411
|
.command('batch')
|
|
226
412
|
.argument('<file>', 'prompts file — one per line, or "-" to read stdin')
|
|
227
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')
|
|
228
415
|
.option('-s, --system <text>', 'system instruction')
|
|
229
416
|
.option('-t, --tag <tag>', 'tag the whole batch (default: batch-<hex>)')
|
|
230
417
|
.option('--separator <str>', 'split the file on this delimiter line for multi-line prompts')
|
|
231
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)')
|
|
232
420
|
.option('--dry-run', 'parse and list what would be submitted; submit nothing')
|
|
233
421
|
.option('--json', 'machine-readable output')
|
|
234
422
|
.description('submit many background tasks from a file, tagged as one batch')
|
|
235
|
-
.action(async (file, opts) => {
|
|
423
|
+
.action(async (file, opts, cmd) => {
|
|
236
424
|
try {
|
|
425
|
+
const agent = resolveAgentOpts(opts, cmd);
|
|
237
426
|
const text = file === '-' ? await readStdin() : fs.readFileSync(file, 'utf8');
|
|
238
|
-
const prompts = parsePrompts(text, opts.separator);
|
|
427
|
+
const { prompts, skipped } = parsePrompts(text, opts.separator);
|
|
239
428
|
if (!prompts.length) throw new Error(`no prompts found in ${file === '-' ? 'stdin' : file}`);
|
|
429
|
+
// A one-line heads-up so a swallowed prompt (or a stray comment) is never a
|
|
430
|
+
// silent mystery. Goes to stderr so it can't corrupt --json on stdout.
|
|
431
|
+
if (skipped) {
|
|
432
|
+
console.error(edim(`(skipped ${skipped} blank/comment line${skipped === 1 ? '' : 's'})`));
|
|
433
|
+
}
|
|
240
434
|
// Auto-tag so the batch is collectable as a unit; a user tag wins.
|
|
241
435
|
const tag = opts.tag || `batch-${crypto.randomUUID().slice(0, 6)}`;
|
|
242
436
|
|
|
243
437
|
if (opts.dryRun) {
|
|
244
|
-
emit(opts.json, { tag, dry_run: true, prompts }, () => {
|
|
245
|
-
|
|
246
|
-
|
|
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
|
+
}
|
|
247
446
|
});
|
|
248
447
|
return;
|
|
249
448
|
}
|
|
250
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
|
+
|
|
251
454
|
// One failed submit must not sink the batch: mark that task failed and
|
|
252
455
|
// keep going. mapLimit preserves input order, so the report is stable.
|
|
253
456
|
const results = await mapLimit(prompts, 4, async (prompt) => {
|
|
254
|
-
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 });
|
|
255
458
|
try {
|
|
256
|
-
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 });
|
|
257
460
|
store.setInteraction(id, r.interactionId, r.status);
|
|
258
461
|
return { id, interaction_id: r.interactionId, status: r.status, prompt };
|
|
259
462
|
} catch (err) {
|
|
@@ -321,18 +524,22 @@ program
|
|
|
321
524
|
const task = needTask(id);
|
|
322
525
|
try {
|
|
323
526
|
// Completed tasks are served from SQLite -- no network, and it still
|
|
324
|
-
// works after the free tier drops the interaction at 24h.
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
527
|
+
// works after the free tier drops the interaction at 24h. Gate on the
|
|
528
|
+
// result being *present*, not truthy: a task that completes with empty
|
|
529
|
+
// text stores `''`, which is exactly the case the cache must still serve
|
|
530
|
+
// -- re-polling it would 404 after 24h, the very thing we cache to avoid.
|
|
531
|
+
if (isSuccess(task.status) && task.result != null && !opts.raw) {
|
|
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))
|
|
328
535
|
);
|
|
329
536
|
return;
|
|
330
537
|
}
|
|
331
538
|
const r = await refresh(task);
|
|
332
539
|
if (opts.raw) return console.log(JSON.stringify(r.raw, null, 2));
|
|
333
540
|
if (isSuccess(r.status)) {
|
|
334
|
-
emit(opts.json, { id: task.id, status: r.status, result: r.text }, () =>
|
|
335
|
-
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))
|
|
336
543
|
);
|
|
337
544
|
} else if (isDone(r.status)) {
|
|
338
545
|
emit(opts.json, { id: task.id, status: r.status, error: r.text || null }, () =>
|
|
@@ -360,21 +567,144 @@ program
|
|
|
360
567
|
.option('--json', 'machine-readable output')
|
|
361
568
|
.description('all tasks, newest first')
|
|
362
569
|
.action((opts) => {
|
|
570
|
+
// `-n 0` is a valid cap (show nothing); a negative would become SQLite's
|
|
571
|
+
// "no limit" (LIMIT -1 = all rows), so reject anything but a non-negative int.
|
|
572
|
+
if (opts.limit != null && (!Number.isInteger(opts.limit) || opts.limit < 0)) {
|
|
573
|
+
return die(new Error(`--limit must be a non-negative integer (got ${opts.limit})`));
|
|
574
|
+
}
|
|
363
575
|
const tasks = store.listTasks({ status: opts.status, tag: opts.tag, limit: opts.limit });
|
|
364
576
|
if (opts.json) return console.log(JSON.stringify(tasks, null, 2));
|
|
365
577
|
if (!tasks.length) {
|
|
366
578
|
console.log('No tasks yet. Submit one: gemcatch research "your question"');
|
|
367
579
|
return;
|
|
368
580
|
}
|
|
369
|
-
|
|
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`));
|
|
370
588
|
for (const t of tasks) {
|
|
371
589
|
const snip = snippet(t.prompt);
|
|
372
590
|
const status = t.status || PENDING;
|
|
373
591
|
// Pad before colouring: ANSI codes would break the column width.
|
|
374
592
|
const pad = ' '.repeat(Math.max(0, 16 - status.length));
|
|
593
|
+
const agentCol = showAgent ? `${shortAgent(t.agent).padEnd(18)} ` : '';
|
|
375
594
|
console.log(
|
|
376
|
-
`${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}`
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// --- export ---------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
// Collect many finished results into one document -- the "gather" that pairs
|
|
603
|
+
// with `batch`'s "scatter". Where `get` prints one result at a time, `export`
|
|
604
|
+
// concatenates a whole tag (or status) under prompt headings, to stdout or a
|
|
605
|
+
// file, as Markdown (default) or JSON.
|
|
606
|
+
program
|
|
607
|
+
.command('export')
|
|
608
|
+
.option('-t, --tag <tag>', 'only this tag')
|
|
609
|
+
.addOption(new Option('--status <status>', 'only this status').choices(ALL_STATUSES).default('completed'))
|
|
610
|
+
.addOption(new Option('--format <fmt>', 'output format').choices(['md', 'json']).default('md'))
|
|
611
|
+
.option('-o, --out <file>', 'write to a file instead of stdout')
|
|
612
|
+
.description('concatenate finished results, each under its prompt, to stdout or a file')
|
|
613
|
+
.action((opts) => {
|
|
614
|
+
const tasks = store.listTasks({ tag: opts.tag, status: opts.status });
|
|
615
|
+
// Newest-first suits a listing, but an export reads top-to-bottom like a
|
|
616
|
+
// document, so oldest-first is the natural order here.
|
|
617
|
+
tasks.reverse();
|
|
618
|
+
// Only rows that actually carry a result are worth exporting: a status
|
|
619
|
+
// filter other than `completed` can match tasks that never stored text.
|
|
620
|
+
const rows = tasks.filter((t) => t.result != null);
|
|
621
|
+
if (!rows.length) {
|
|
622
|
+
// Nothing to write isn't an error, but say why so an empty -o file (or an
|
|
623
|
+
// empty pipe) isn't a mystery. The note goes to stderr, never the output.
|
|
624
|
+
console.error(`No ${opts.status} results to export${opts.tag ? ` for tag '${opts.tag}'` : ''}.`);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
let output;
|
|
629
|
+
if (opts.format === 'json') {
|
|
630
|
+
output = JSON.stringify(
|
|
631
|
+
rows.map((t) => ({
|
|
632
|
+
id: t.id,
|
|
633
|
+
tag: t.tag,
|
|
634
|
+
status: t.status,
|
|
635
|
+
prompt: t.prompt,
|
|
636
|
+
result: t.result,
|
|
637
|
+
created_at: t.created_at,
|
|
638
|
+
})),
|
|
639
|
+
null,
|
|
640
|
+
2
|
|
377
641
|
);
|
|
642
|
+
} else {
|
|
643
|
+
output = rows
|
|
644
|
+
.map((t) => {
|
|
645
|
+
const when = new Date(t.created_at).toISOString().replace('T', ' ').slice(0, 16);
|
|
646
|
+
const head = (t.prompt || '(no prompt)').replace(/\s+/g, ' ').trim();
|
|
647
|
+
const body = t.result && t.result.trim() ? t.result : '_(empty result)_';
|
|
648
|
+
return `## ${head}\n\n\`${t.id}\` · ${t.status} · ${when} UTC\n\n${body}`;
|
|
649
|
+
})
|
|
650
|
+
.join('\n\n---\n\n');
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (opts.out) {
|
|
654
|
+
fs.writeFileSync(opts.out, output.endsWith('\n') ? output : `${output}\n`);
|
|
655
|
+
console.error(`Wrote ${rows.length} result(s) to ${opts.out}.`);
|
|
656
|
+
} else {
|
|
657
|
+
console.log(output);
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// --- digest ---------------------------------------------------------------
|
|
662
|
+
|
|
663
|
+
// One step past `export`: instead of concatenating a tag's results, feed them
|
|
664
|
+
// back through a single Gemini call and synthesise one summary. It is `research`
|
|
665
|
+
// with a prompt built from what you have already collected, so it submits, then
|
|
666
|
+
// watches to completion just like `research -w`.
|
|
667
|
+
program
|
|
668
|
+
.command('digest')
|
|
669
|
+
.requiredOption('-t, --tag <tag>', 'synthesize the completed results under this tag')
|
|
670
|
+
.option('-m, --model <id>', 'model to use', gemini.DEFAULT_MODEL)
|
|
671
|
+
.option('-s, --system <text>', 'system instruction for the synthesis')
|
|
672
|
+
.option('--json', 'machine-readable output')
|
|
673
|
+
.description("feed a tag's completed results through one Gemini call into a single summary")
|
|
674
|
+
.action(async (opts) => {
|
|
675
|
+
let id;
|
|
676
|
+
try {
|
|
677
|
+
const done = store
|
|
678
|
+
.listTasks({ tag: opts.tag, status: 'completed' })
|
|
679
|
+
.filter((t) => t.result != null && t.result.trim());
|
|
680
|
+
if (!done.length) {
|
|
681
|
+
throw new Error(
|
|
682
|
+
`no completed results tagged '${opts.tag}' to digest.` +
|
|
683
|
+
' Collect them first: gemcatch daemon --exit-when-idle'
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
done.reverse(); // oldest first, so the sources read in submission order
|
|
687
|
+
const sources = done
|
|
688
|
+
.map((t, i) => `## Source ${i + 1}: ${(t.prompt || '').replace(/\s+/g, ' ').trim()}\n\n${t.result}`)
|
|
689
|
+
.join('\n\n');
|
|
690
|
+
const prompt =
|
|
691
|
+
`Synthesize the following ${done.length} research result(s) into one coherent summary.` +
|
|
692
|
+
' Note where they agree and disagree, and do not simply repeat each verbatim.\n\n' +
|
|
693
|
+
sources;
|
|
694
|
+
// The digest is itself a task, tagged so it is findable but kept out of
|
|
695
|
+
// the source tag so a later digest never digests its own output.
|
|
696
|
+
id = store.createTask({ prompt, model: opts.model, systemInstruction: opts.system, tag: `${opts.tag}-digest` });
|
|
697
|
+
const r = await gemini.submit(prompt, { model: opts.model, systemInstruction: opts.system });
|
|
698
|
+
store.setInteraction(id, r.interactionId, r.status);
|
|
699
|
+
if (!opts.json) console.error(edim(`Digesting ${done.length} result(s) tagged ${opts.tag} -> task ${id}.`));
|
|
700
|
+
await watchTask(store.getTask(id), DEFAULT_POLL_MS, opts.json);
|
|
701
|
+
} catch (err) {
|
|
702
|
+
// Same rule as `research`: only a failed *submit* marks the task failed.
|
|
703
|
+
if (id) {
|
|
704
|
+
const t = store.getTask(id);
|
|
705
|
+
if (!t || !t.interaction_id) store.setStatus(id, 'failed', { error: err.message });
|
|
706
|
+
}
|
|
707
|
+
die(err);
|
|
378
708
|
}
|
|
379
709
|
});
|
|
380
710
|
|
|
@@ -422,12 +752,18 @@ program
|
|
|
422
752
|
.option('--json', 'newline-delimited JSON events on stdout')
|
|
423
753
|
.description('poll in-flight tasks on a loop so results are cached before they expire')
|
|
424
754
|
.action(async (opts) => {
|
|
425
|
-
|
|
755
|
+
if (!Number.isFinite(opts.interval) || opts.interval <= 0) {
|
|
756
|
+
return die(new Error(`--interval must be a positive number of seconds (got ${opts.interval})`));
|
|
757
|
+
}
|
|
758
|
+
const intervalMs = Math.max(1000, opts.interval * 1000);
|
|
426
759
|
let stopping = false;
|
|
427
760
|
let wake = null;
|
|
428
761
|
// Finish the pass in progress, then exit cleanly -- never leave a polled
|
|
429
|
-
// result unwritten because someone hit Ctrl-C.
|
|
762
|
+
// result unwritten because someone hit Ctrl-C. A *second* signal, though,
|
|
763
|
+
// means "I don't want to wait for this pass" -- force-exit immediately with
|
|
764
|
+
// the conventional 130 (128 + SIGINT) so a long paced pass can't trap you.
|
|
430
765
|
const stop = () => {
|
|
766
|
+
if (stopping) process.exit(130);
|
|
431
767
|
stopping = true;
|
|
432
768
|
if (wake) wake();
|
|
433
769
|
};
|
|
@@ -440,7 +776,7 @@ program
|
|
|
440
776
|
|
|
441
777
|
if (!opts.json) {
|
|
442
778
|
console.error(
|
|
443
|
-
|
|
779
|
+
edim(`gemcatch daemon: polling every ${intervalMs / 1000}s. Store: ${store.DB_PATH}. Ctrl-C to stop.`)
|
|
444
780
|
);
|
|
445
781
|
}
|
|
446
782
|
event({ event: 'start', interval_s: intervalMs / 1000, db: store.DB_PATH });
|
|
@@ -455,7 +791,7 @@ program
|
|
|
455
791
|
// the next pass may well succeed, and a daemon that dies silently is
|
|
456
792
|
// worse than one that complains.
|
|
457
793
|
if (opts.json) event({ event: 'error', error: err.message });
|
|
458
|
-
else console.error(`${
|
|
794
|
+
else console.error(`${edim(`[${hhmmss()}]`)} Error: ${err.message}`);
|
|
459
795
|
}
|
|
460
796
|
|
|
461
797
|
// Quiet by default: only transitions and failures are worth a line.
|
|
@@ -465,7 +801,7 @@ program
|
|
|
465
801
|
event({ event: r.error ? 'error' : 'update', id: r.id, status: r.status, error: r.error || null });
|
|
466
802
|
} else {
|
|
467
803
|
console.error(
|
|
468
|
-
|
|
804
|
+
edim(`[${hhmmss()}] ${r.id}: `) + ecolorStatus(r.status) + (r.error ? ` ${edim(r.error)}` : '')
|
|
469
805
|
);
|
|
470
806
|
}
|
|
471
807
|
}
|
|
@@ -488,7 +824,7 @@ program
|
|
|
488
824
|
}
|
|
489
825
|
|
|
490
826
|
event({ event: 'stop' });
|
|
491
|
-
if (!opts.json) console.error(
|
|
827
|
+
if (!opts.json) console.error(edim('gemcatch daemon: stopped.'));
|
|
492
828
|
store.close();
|
|
493
829
|
});
|
|
494
830
|
|
|
@@ -496,23 +832,43 @@ program
|
|
|
496
832
|
|
|
497
833
|
async function watchTask(task, intervalMs, json) {
|
|
498
834
|
let last = null;
|
|
835
|
+
let fails = 0;
|
|
499
836
|
for (;;) {
|
|
500
|
-
|
|
837
|
+
let r;
|
|
838
|
+
try {
|
|
839
|
+
r = await refresh(task);
|
|
840
|
+
fails = 0; // a clean poll resets the failure run
|
|
841
|
+
} catch (err) {
|
|
842
|
+
// A poll error must not sink a live task: keep its old status and try
|
|
843
|
+
// again next interval, exactly like watchBatch. Give up only once the
|
|
844
|
+
// failures pile up, so a task the server can't answer for can't hang the
|
|
845
|
+
// watch forever. (A 404 doesn't reach here -- refresh retires it and
|
|
846
|
+
// returns a terminal status, handled below.)
|
|
847
|
+
fails += 1;
|
|
848
|
+
if (fails >= WATCH_MAX_FAILS) {
|
|
849
|
+
const msg = `Gave up watching ${task.id} after ${fails} consecutive poll failures: ${err.message}`;
|
|
850
|
+
emit(json, { id: task.id, status: task.status, error: msg }, () => console.error(edim(msg)));
|
|
851
|
+
process.exitCode = 1;
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
await new Promise((r2) => setTimeout(r2, intervalMs));
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
501
857
|
// Status chatter goes to stderr so `gemcatch watch x > out.txt` captures only
|
|
502
858
|
// the result.
|
|
503
859
|
if (r.status !== last && !json) {
|
|
504
|
-
console.error(
|
|
860
|
+
console.error(edim(`[${new Date().toISOString().slice(11, 19)}] ${task.id}: `) + ecolorStatus(r.status));
|
|
505
861
|
last = r.status;
|
|
506
862
|
}
|
|
507
863
|
if (isSuccess(r.status)) {
|
|
508
|
-
emit(json, { id: task.id, status: r.status, result: r.text }, () =>
|
|
509
|
-
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))
|
|
510
866
|
);
|
|
511
867
|
return;
|
|
512
868
|
}
|
|
513
869
|
if (isDone(r.status)) {
|
|
514
870
|
emit(json, { id: task.id, status: r.status, error: r.text || null }, () => {
|
|
515
|
-
console.error(`Task ${task.id} ended: ${
|
|
871
|
+
console.error(`Task ${task.id} ended: ${ecolorStatus(r.status)}`);
|
|
516
872
|
if (r.text) console.log(r.text);
|
|
517
873
|
});
|
|
518
874
|
process.exitCode = 1;
|
|
@@ -531,12 +887,18 @@ program
|
|
|
531
887
|
.action(async (id, opts) => {
|
|
532
888
|
const task = needTask(id);
|
|
533
889
|
try {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
890
|
+
// Serve a completed result from cache -- present, not merely truthy, so an
|
|
891
|
+
// empty-text completion is served instead of re-polled (and lost at 24h).
|
|
892
|
+
if (isSuccess(task.status) && task.result != null) {
|
|
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))
|
|
537
896
|
);
|
|
538
897
|
return;
|
|
539
898
|
}
|
|
899
|
+
if (opts.interval != null && (!Number.isFinite(opts.interval) || opts.interval <= 0)) {
|
|
900
|
+
return die(new Error(`--interval must be a positive number of seconds (got ${opts.interval})`));
|
|
901
|
+
}
|
|
540
902
|
await watchTask(task, opts.interval ? opts.interval * 1000 : DEFAULT_POLL_MS, opts.json);
|
|
541
903
|
} catch (err) {
|
|
542
904
|
die(err);
|
|
@@ -579,7 +941,7 @@ program
|
|
|
579
941
|
} catch (err) {
|
|
580
942
|
// Free-tier interactions vanish after 24h, so a missing remote is
|
|
581
943
|
// normal -- never block the local delete on it.
|
|
582
|
-
console.error(
|
|
944
|
+
console.error(edim(` (remote delete failed for ${task.id}: ${err.message})`));
|
|
583
945
|
}
|
|
584
946
|
}
|
|
585
947
|
if (store.removeTask(task.id)) removed += 1;
|
|
@@ -595,6 +957,12 @@ program
|
|
|
595
957
|
.option('--dry-run', 'list what would go, delete nothing')
|
|
596
958
|
.description('drop old finished tasks (in-flight work is never touched)')
|
|
597
959
|
.action((opts) => {
|
|
960
|
+
// A negative (or non-numeric) --days puts the cutoff in the *future*, which
|
|
961
|
+
// would match every finished task and quietly wipe the lot. Refuse it: the
|
|
962
|
+
// cutoff must be at or before now.
|
|
963
|
+
if (!Number.isFinite(opts.days) || opts.days < 0) {
|
|
964
|
+
return die(new Error(`--days must be a non-negative number (got ${opts.days})`));
|
|
965
|
+
}
|
|
598
966
|
const cutoff = Date.now() - opts.days * 86400000;
|
|
599
967
|
const doomed = store.prunableTasks(cutoff);
|
|
600
968
|
if (!doomed.length) {
|
|
@@ -618,12 +986,29 @@ program
|
|
|
618
986
|
.description('where the store lives and what is in it')
|
|
619
987
|
.action((opts) => {
|
|
620
988
|
const rows = store.counts();
|
|
989
|
+
const agents = store.agentCounts();
|
|
621
990
|
const total = rows.reduce((n, r) => n + r.n, 0);
|
|
622
|
-
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 }, () => {
|
|
623
992
|
console.log(`Store: ${store.DB_PATH}`);
|
|
624
993
|
console.log(`Tasks: ${total}`);
|
|
625
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
|
+
}
|
|
626
999
|
});
|
|
627
1000
|
});
|
|
628
1001
|
|
|
1002
|
+
// Close the store on the way out so a one-shot command doesn't leave the
|
|
1003
|
+
// SQLite -wal/-shm sidecars lingering. The store opens lazily, so if a command
|
|
1004
|
+
// never touched it this is a no-op; the daemon closes explicitly too, and a
|
|
1005
|
+
// second close is harmless.
|
|
1006
|
+
process.on('exit', () => {
|
|
1007
|
+
try {
|
|
1008
|
+
store.close();
|
|
1009
|
+
} catch (_) {
|
|
1010
|
+
/* best effort on the way out */
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
|
|
629
1014
|
program.parseAsync(process.argv).catch(die);
|
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
|
}
|