gemcatch 0.1.0 → 0.2.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 CHANGED
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-07-19
11
+
12
+ ### Added
13
+
14
+ - `gemcatch batch <file>` — submit many background tasks from a file in one
15
+ command. One prompt per non-empty line by default (`#` comments and blanks are
16
+ skipped); `--separator <str>` splits the file on a delimiter line so prompts
17
+ can span multiple lines; `-` reads the list from stdin. The whole batch is
18
+ tagged as a unit — `-t/--tag` sets it, otherwise an auto tag `batch-<hex>` is
19
+ generated and printed — so it is collectable with `gemcatch list --tag`.
20
+ Submissions are bounded but concurrent (four at a time, paced by `GEMCATCH_RPM`),
21
+ and a single failed submit is marked `failed` and reported without sinking the
22
+ rest of the batch. Supports `-m/--model`, `-s/--system`, `--dry-run` (parse and
23
+ list, submit nothing), `--json`, and `-w/--watch` (poll the batch to completion,
24
+ then print a combined tally).
25
+
26
+ ## [0.1.1] - 2026-07-19
27
+
28
+ ### Fixed
29
+
30
+ - REST fallback (`GEMCATCH_FORCE_REST=1`, or when the SDK lacks Interactions)
31
+ no longer prepends the echoed prompt and the model's reasoning to the result.
32
+ A real Interactions response tags each step by type; only `model_output` is
33
+ the answer. The default SDK path was unaffected. Verified live against the API.
34
+
35
+ ### Changed
36
+
37
+ - Require Node.js 22+. The advertised "20+" was never accurate — `commander@15`
38
+ requires Node `>=22.12`, and Node 20 reached end-of-life. CI now covers 22 and 24.
39
+ - Clarified the free-tier wording in the README: `gemini-3.1-flash-lite` is free
40
+ within the free tier's daily quota, then billed at standard rates.
41
+
10
42
  ## [0.1.0] - 2026-07-17
11
43
 
12
44
  First release. Wraps the background execution capability added to the Gemini
@@ -64,5 +96,7 @@ seen a task complete, the text is cached locally and survives that expiry — bu
64
96
  something has to poll inside that window for it to be seen at all, which is what
65
97
  `gemcatch daemon` exists to do.
66
98
 
67
- [Unreleased]: https://github.com/Booyaka101/gemcatch/compare/v0.1.0...HEAD
99
+ [Unreleased]: https://github.com/Booyaka101/gemcatch/compare/v0.2.0...HEAD
100
+ [0.2.0]: https://github.com/Booyaka101/gemcatch/compare/v0.1.1...v0.2.0
101
+ [0.1.1]: https://github.com/Booyaka101/gemcatch/compare/v0.1.0...v0.1.1
68
102
  [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 20+ and a Gemini API key. **Getting a key needs no billing account and no card.** `gemini-3.1-flash-lite` runs free within the [free tier's](https://ai.google.dev/gemini-api/docs/pricing) daily quota; past that, paid rates apply.
27
+ Needs Node.js 22+ and a Gemini API key. **Getting a key needs no billing account and no card.** `gemini-3.1-flash-lite` 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:
@@ -79,6 +79,7 @@ This week in AI: ...
79
79
  | Command | What it does |
80
80
  | --- | --- |
81
81
  | `gemcatch research "<prompt>"` | Submits with `background: true`, stores the interaction ID, exits immediately. |
82
+ | `gemcatch batch <file>` | Submits many prompts from a file at once, tagged as one collectable batch. |
82
83
  | `gemcatch status <id>` | Polls the API and prints the current state. |
83
84
  | `gemcatch get <id>` | Prints the full response if complete, otherwise the current status. |
84
85
  | `gemcatch list` | All tasks, newest first: id, age, status, prompt. |
@@ -95,15 +96,16 @@ Useful flags:
95
96
  | Flag | On | Does |
96
97
  | --- | --- | --- |
97
98
  | `--json` | most commands | Machine-readable output. |
98
- | `-m, --model <id>` | `research` | Override the model. |
99
- | `-s, --system <text>` | `research` | Set a system instruction. |
99
+ | `-m, --model <id>` | `research`, `batch` | Override the model. |
100
+ | `-s, --system <text>` | `research`, `batch` | Set a system instruction. |
100
101
  | `-f, --file <path>` | `research` | Read the prompt from a file. |
101
- | `-t, --tag <tag>` | `research`, `list` | Label tasks and filter them. |
102
- | `-w, --watch` | `research` | Submit and wait, in one command. |
102
+ | `-t, --tag <tag>` | `research`, `batch`, `list` | Label tasks and filter them. |
103
+ | `-w, --watch` | `research`, `batch` | Submit and wait, in one command. |
104
+ | `--separator <str>` | `batch` | Split the file on this delimiter line for multi-line prompts. |
103
105
  | `-i, --interval <s>` | `watch`, `daemon` | Poll rate. Default 10s for `watch`, 300s for `daemon`. |
104
106
  | `--exit-when-idle` | `daemon` | Stop once nothing is left in flight. |
105
107
  | `-n, --limit <n>` | `list` | Cap the rows. |
106
- | `--dry-run` | `prune` | Show what would go; delete nothing. |
108
+ | `--dry-run` | `batch`, `prune` | Show what would go; submit/delete nothing. |
107
109
  | `--raw` | `get` | Dump the raw interaction JSON. |
108
110
 
109
111
  IDs are the first 8 characters of a UUID. Any unique prefix works, so `gemcatch get 8f3a` is fine.
@@ -113,7 +115,17 @@ Statuses come straight from the API: `in_progress`, `requires_action`, `complete
113
115
  ## Recipes
114
116
 
115
117
  ```bash
116
- # Fire off a batch, then collect later
118
+ # Fire off a whole file of prompts in one command, then collect later.
119
+ # Every task shares one auto-generated tag (batch-xxxxxx), printed on submit.
120
+ $ gemcatch batch questions.txt # one prompt per line; # and blanks skipped
121
+ $ gemcatch daemon --exit-when-idle # keep polling until they're all in
122
+ $ gemcatch list --tag batch-1a2b3c --status completed
123
+
124
+ # Multi-line prompts: split the file on a delimiter line instead of per-line
125
+ $ gemcatch batch briefs.md --separator ---
126
+ $ gemcatch batch - < questions.txt # or pipe the list in on stdin
127
+
128
+ # The same thing by hand, if you prefer a loop
117
129
  $ for q in "topic A" "topic B" "topic C"; do gemcatch research "$q" -t batch1; done
118
130
  $ gemcatch sync # one pass now...
119
131
  $ gemcatch daemon --exit-when-idle # ...or keep polling until they're all in
package/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  const fs = require('fs');
5
+ const crypto = require('crypto');
5
6
  const { Command, Option } = require('commander');
6
7
  const store = require('./db');
7
8
  const gemini = require('./gemini');
@@ -42,6 +43,12 @@ function emit(json, value, human) {
42
43
  else human();
43
44
  }
44
45
 
46
+ // One-line preview of a prompt for the list/batch columns.
47
+ function snippet(prompt, n = 60) {
48
+ const s = (prompt || '').replace(/\s+/g, ' ');
49
+ return s.length > n ? `${s.slice(0, n - 3)}...` : s;
50
+ }
51
+
45
52
  function die(err) {
46
53
  console.error(`Error: ${err.message}`);
47
54
  process.exit(1);
@@ -168,6 +175,121 @@ program
168
175
  }
169
176
  });
170
177
 
178
+ // --- batch ----------------------------------------------------------------
179
+
180
+ // Turn a prompts file into a list of prompts. Default: one per line, skipping
181
+ // blank lines and `#` comments. With --separator, split the whole file on that
182
+ // delimiter line instead, so a single prompt can span multiple lines.
183
+ function parsePrompts(text, separator) {
184
+ if (separator) {
185
+ const blocks = [];
186
+ let cur = [];
187
+ for (const line of text.split(/\r?\n/)) {
188
+ if (line.trim() === separator) {
189
+ blocks.push(cur.join('\n').trim());
190
+ cur = [];
191
+ } else {
192
+ cur.push(line);
193
+ }
194
+ }
195
+ blocks.push(cur.join('\n').trim());
196
+ return blocks.filter(Boolean);
197
+ }
198
+ return text
199
+ .split(/\r?\n/)
200
+ .map((l) => l.trim())
201
+ .filter((l) => l && !l.startsWith('#'));
202
+ }
203
+
204
+ // Poll just this batch until nothing tagged with it is still in flight, then
205
+ // tally the outcome. Modelled on syncPass (a bounded refresh pass) and
206
+ // watchTask (poll-until-terminal), but scoped to one tag.
207
+ async function watchBatch(tag, intervalMs, json) {
208
+ const inFlight = () => store.listTasks({ tag }).filter((t) => t.interaction_id && !isDone(t.status));
209
+ let pending = inFlight();
210
+ while (pending.length) {
211
+ // A poll that throws keeps the task's old status; the next pass retries it.
212
+ await mapLimit(pending, 4, (t) => refresh(t).catch(() => {}));
213
+ pending = inFlight();
214
+ if (pending.length) await new Promise((r) => setTimeout(r, intervalMs));
215
+ }
216
+ const tasks = store.listTasks({ tag });
217
+ const completed = tasks.filter((t) => isSuccess(t.status)).length;
218
+ const failed = tasks.filter((t) => isDone(t.status) && !isSuccess(t.status)).length;
219
+ emit(json, { tag, completed, failed, total: tasks.length }, () =>
220
+ console.log(dim(`Batch ${tag}: ${completed}/${tasks.length} completed, ${failed} failed.`))
221
+ );
222
+ }
223
+
224
+ program
225
+ .command('batch')
226
+ .argument('<file>', 'prompts file — one per line, or "-" to read stdin')
227
+ .option('-m, --model <id>', 'model to use', gemini.DEFAULT_MODEL)
228
+ .option('-s, --system <text>', 'system instruction')
229
+ .option('-t, --tag <tag>', 'tag the whole batch (default: batch-<hex>)')
230
+ .option('--separator <str>', 'split the file on this delimiter line for multi-line prompts')
231
+ .option('-w, --watch', 'submit all, then poll until the whole batch finishes')
232
+ .option('--dry-run', 'parse and list what would be submitted; submit nothing')
233
+ .option('--json', 'machine-readable output')
234
+ .description('submit many background tasks from a file, tagged as one batch')
235
+ .action(async (file, opts) => {
236
+ try {
237
+ const text = file === '-' ? await readStdin() : fs.readFileSync(file, 'utf8');
238
+ const prompts = parsePrompts(text, opts.separator);
239
+ if (!prompts.length) throw new Error(`no prompts found in ${file === '-' ? 'stdin' : file}`);
240
+ // Auto-tag so the batch is collectable as a unit; a user tag wins.
241
+ const tag = opts.tag || `batch-${crypto.randomUUID().slice(0, 6)}`;
242
+
243
+ if (opts.dryRun) {
244
+ emit(opts.json, { tag, dry_run: true, prompts }, () => {
245
+ console.log(`Batch ${tag}: ${prompts.length} prompt(s) would be submitted:`);
246
+ for (const p of prompts) console.log(` ${snippet(p)}`);
247
+ });
248
+ return;
249
+ }
250
+
251
+ // One failed submit must not sink the batch: mark that task failed and
252
+ // keep going. mapLimit preserves input order, so the report is stable.
253
+ const results = await mapLimit(prompts, 4, async (prompt) => {
254
+ const id = store.createTask({ prompt, model: opts.model, systemInstruction: opts.system, tag });
255
+ try {
256
+ const r = await gemini.submit(prompt, { model: opts.model, systemInstruction: opts.system });
257
+ store.setInteraction(id, r.interactionId, r.status);
258
+ return { id, interaction_id: r.interactionId, status: r.status, prompt };
259
+ } catch (err) {
260
+ store.setStatus(id, 'failed', { error: err.message });
261
+ return { id, interaction_id: null, status: 'failed', prompt, error: err.message };
262
+ }
263
+ });
264
+ const submitted = results.filter((r) => !r.error);
265
+ const failed = results.filter((r) => r.error);
266
+
267
+ if (opts.watch) {
268
+ // The submit lines are progress, not the answer, so they go to stderr.
269
+ if (!opts.json) {
270
+ console.error(`Batch ${tag}: submitted ${submitted.length} task(s)` + (failed.length ? `, ${failed.length} failed` : '') + '. Watching...');
271
+ }
272
+ await watchBatch(tag, DEFAULT_POLL_MS, opts.json);
273
+ return;
274
+ }
275
+
276
+ emit(opts.json, { tag, submitted, failed }, () => {
277
+ console.log(`Batch ${tag}: submitted ${submitted.length} task(s)` + (failed.length ? `, ${failed.length} failed` : '') + '.');
278
+ for (const r of results) {
279
+ const status = r.status || PENDING;
280
+ // Pad before colouring: ANSI codes would break the column width.
281
+ const pad = ' '.repeat(Math.max(0, 16 - status.length));
282
+ console.log(`${r.id} ${colorStatus(status)}${pad} ${snippet(r.prompt)}`);
283
+ }
284
+ console.log(dim('\nCollect them:'));
285
+ console.log(dim(' gemcatch daemon --exit-when-idle'));
286
+ console.log(dim(` gemcatch list --tag ${tag} --status completed`));
287
+ });
288
+ } catch (err) {
289
+ die(err);
290
+ }
291
+ });
292
+
171
293
  // --- status ---------------------------------------------------------------
172
294
 
173
295
  program
@@ -246,8 +368,7 @@ program
246
368
  }
247
369
  console.log(dim('ID AGE STATUS PROMPT'));
248
370
  for (const t of tasks) {
249
- const prompt = (t.prompt || '').replace(/\s+/g, ' ');
250
- const snip = prompt.length > 60 ? `${prompt.slice(0, 57)}...` : prompt;
371
+ const snip = snippet(t.prompt);
251
372
  const status = t.status || PENDING;
252
373
  // Pad before colouring: ANSI codes would break the column width.
253
374
  const pad = ' '.repeat(Math.max(0, 16 - status.length));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gemcatch",
3
- "version": "0.1.0",
3
+ "version": "0.2.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": {
@@ -16,7 +16,7 @@
16
16
  "CHANGELOG.md"
17
17
  ],
18
18
  "engines": {
19
- "node": ">=20"
19
+ "node": ">=22"
20
20
  },
21
21
  "scripts": {
22
22
  "test": "node test-offline.js",