gemcatch 0.1.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/index.js ADDED
@@ -0,0 +1,508 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const { Command, Option } = require('commander');
6
+ const store = require('./db');
7
+ const gemini = require('./gemini');
8
+ const { TERMINAL, ACTIVE, PENDING, isDone, isSuccess } = require('./status');
9
+
10
+ const DEFAULT_POLL_MS = Number(process.env.GEMCATCH_POLL_MS) || 10000;
11
+ // Free-tier results are dropped after 24h, so the daemon only has to be
12
+ // comfortably faster than that. Five minutes is far inside the margin and
13
+ // costs a handful of requests an hour.
14
+ const DEFAULT_DAEMON_S = Number(process.env.GEMCATCH_DAEMON_S) || 300;
15
+ const ALL_STATUSES = [PENDING].concat(ACTIVE, TERMINAL);
16
+
17
+ // --- output ---------------------------------------------------------------
18
+
19
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
20
+ const paint = (code, s) => (useColor ? `[${code}m${s}` : s);
21
+ const dim = (s) => paint('2', s);
22
+
23
+ function colorStatus(s) {
24
+ if (isSuccess(s)) return paint('32', s); // green
25
+ if (s === 'in_progress' || s === PENDING) return paint('36', s); // cyan
26
+ if (s === 'requires_action') return paint('33', s); // yellow
27
+ return paint('31', s); // red: failed/cancelled/incomplete/budget_exceeded
28
+ }
29
+
30
+ const hhmmss = () => new Date().toISOString().slice(11, 19);
31
+
32
+ function age(ms) {
33
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
34
+ if (s < 60) return `${s}s`;
35
+ if (s < 3600) return `${Math.round(s / 60)}m`;
36
+ if (s < 86400) return `${Math.round(s / 3600)}h`;
37
+ return `${Math.round(s / 86400)}d`;
38
+ }
39
+
40
+ function emit(json, value, human) {
41
+ if (json) console.log(JSON.stringify(value, null, 2));
42
+ else human();
43
+ }
44
+
45
+ function die(err) {
46
+ console.error(`Error: ${err.message}`);
47
+ process.exit(1);
48
+ }
49
+
50
+ function needTask(id) {
51
+ let task;
52
+ try {
53
+ task = store.getTask(id);
54
+ } catch (err) {
55
+ die(err); // ambiguous prefix
56
+ }
57
+ if (!task) {
58
+ console.error(`Error: no task matching '${id}'. Try: gemcatch list`);
59
+ process.exit(1);
60
+ }
61
+ return task;
62
+ }
63
+
64
+ // --- input ----------------------------------------------------------------
65
+
66
+ function readStdin() {
67
+ return new Promise((resolve, reject) => {
68
+ let data = '';
69
+ process.stdin.setEncoding('utf8');
70
+ process.stdin.on('data', (c) => (data += c));
71
+ process.stdin.on('end', () => resolve(data.trim()));
72
+ process.stdin.on('error', reject);
73
+ });
74
+ }
75
+
76
+ async function resolvePrompt(arg, opts) {
77
+ if (opts.file) {
78
+ const text = fs.readFileSync(opts.file, 'utf8').trim();
79
+ if (!text) throw new Error(`${opts.file} is empty`);
80
+ return text;
81
+ }
82
+ if (arg === '-') {
83
+ const text = await readStdin();
84
+ if (!text) throw new Error('no prompt on stdin');
85
+ return text;
86
+ }
87
+ if (arg && arg.trim()) return arg.trim();
88
+ throw new Error('provide a prompt, --file <path>, or "-" to read stdin');
89
+ }
90
+
91
+ // --- core -----------------------------------------------------------------
92
+
93
+ // Poll one task and persist whatever came back.
94
+ async function refresh(task) {
95
+ if (!task.interaction_id) return { status: task.status, text: null, usage: null };
96
+ const r = await gemini.poll(task.interaction_id);
97
+ const extra = {};
98
+ if (isDone(r.status)) {
99
+ if (isSuccess(r.status)) extra.result = r.text;
100
+ else if (r.text) extra.error = r.text;
101
+ }
102
+ if (r.usage) extra.usage = JSON.stringify(r.usage);
103
+ store.setStatus(task.id, r.status, extra);
104
+ return r;
105
+ }
106
+
107
+ // Bounds how many polls are open at once. The *rate* limit is enforced in
108
+ // gemini.js (GEMCATCH_RPM), which is the part that keeps a wide fan-out inside the
109
+ // free tier's requests-per-minute allowance.
110
+ async function mapLimit(items, limit, fn) {
111
+ const out = [];
112
+ let i = 0;
113
+ await Promise.all(
114
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
115
+ while (i < items.length) {
116
+ const idx = i++;
117
+ out[idx] = await fn(items[idx]);
118
+ }
119
+ })
120
+ );
121
+ return out;
122
+ }
123
+
124
+ const program = new Command();
125
+ program
126
+ .name('gemcatch')
127
+ .description("Fire-and-forget research tasks on Gemini's Interactions API (background execution).")
128
+ .version(require('./package.json').version);
129
+
130
+ // --- research -------------------------------------------------------------
131
+
132
+ program
133
+ .command('research')
134
+ .argument('[prompt]', 'what you want researched; "-" reads stdin')
135
+ .option('-f, --file <path>', 'read the prompt from a file')
136
+ .option('-m, --model <id>', 'model to use', gemini.DEFAULT_MODEL)
137
+ .option('-s, --system <text>', 'system instruction')
138
+ .option('-t, --tag <tag>', 'label for filtering with `gemcatch list --tag`')
139
+ .option('-w, --watch', 'wait for the result instead of exiting')
140
+ .option('--json', 'machine-readable output')
141
+ .description('submit a background task and exit immediately')
142
+ .action(async (promptArg, opts) => {
143
+ let id;
144
+ try {
145
+ const prompt = await resolvePrompt(promptArg, opts);
146
+ id = store.createTask({
147
+ prompt,
148
+ model: opts.model,
149
+ systemInstruction: opts.system,
150
+ tag: opts.tag,
151
+ });
152
+ const r = await gemini.submit(prompt, { model: opts.model, systemInstruction: opts.system });
153
+ store.setInteraction(id, r.interactionId, r.status);
154
+ if (opts.watch) {
155
+ // Under --watch the submit line is progress, not the answer, so it
156
+ // goes to stderr -- `gemcatch research -w "..." > out.txt` then captures
157
+ // only the result.
158
+ if (!opts.json) console.error(dim(`Task ${id} submitted.`));
159
+ await watchTask(store.getTask(id), DEFAULT_POLL_MS, opts.json);
160
+ return;
161
+ }
162
+ emit(opts.json, { id, interaction_id: r.interactionId, status: r.status }, () =>
163
+ console.log(`Task ${id} submitted. Run: gemcatch get ${id} when ready.`)
164
+ );
165
+ } catch (err) {
166
+ if (id) store.setStatus(id, 'failed', { error: err.message });
167
+ die(err);
168
+ }
169
+ });
170
+
171
+ // --- status ---------------------------------------------------------------
172
+
173
+ program
174
+ .command('status')
175
+ .argument('<id>', 'task id')
176
+ .option('--json', 'machine-readable output')
177
+ .description('poll the API and print the current state')
178
+ .action(async (id, opts) => {
179
+ const task = needTask(id);
180
+ try {
181
+ const { status } = await refresh(task);
182
+ emit(opts.json, { id: task.id, status }, () =>
183
+ console.log(`Task ${task.id}: ${colorStatus(status)}`)
184
+ );
185
+ } catch (err) {
186
+ die(err);
187
+ }
188
+ });
189
+
190
+ // --- get ------------------------------------------------------------------
191
+
192
+ program
193
+ .command('get')
194
+ .argument('<id>', 'task id')
195
+ .option('--json', 'machine-readable output')
196
+ .option('--raw', 'print the raw interaction JSON from the API')
197
+ .description('print the full response if complete, else the current status')
198
+ .action(async (id, opts) => {
199
+ const task = needTask(id);
200
+ try {
201
+ // Completed tasks are served from SQLite -- no network, and it still
202
+ // works after the free tier drops the interaction at 24h.
203
+ if (isSuccess(task.status) && task.result && !opts.raw) {
204
+ emit(opts.json, { id: task.id, status: task.status, result: task.result }, () =>
205
+ console.log(task.result)
206
+ );
207
+ return;
208
+ }
209
+ const r = await refresh(task);
210
+ if (opts.raw) return console.log(JSON.stringify(r.raw, null, 2));
211
+ if (isSuccess(r.status)) {
212
+ emit(opts.json, { id: task.id, status: r.status, result: r.text }, () =>
213
+ console.log(r.text || '(empty response)')
214
+ );
215
+ } else if (isDone(r.status)) {
216
+ emit(opts.json, { id: task.id, status: r.status, error: r.text || null }, () =>
217
+ console.log(`Task ${task.id}: ${colorStatus(r.status)}${r.text ? `\n${r.text}` : ''}`)
218
+ );
219
+ process.exitCode = 1;
220
+ } else {
221
+ emit(opts.json, { id: task.id, status: r.status, result: null }, () =>
222
+ console.log(`Task ${task.id}: ${colorStatus(r.status)} — not ready yet. Try: gemcatch watch ${task.id}`)
223
+ );
224
+ }
225
+ } catch (err) {
226
+ die(err);
227
+ }
228
+ });
229
+
230
+ // --- list -----------------------------------------------------------------
231
+
232
+ program
233
+ .command('list')
234
+ .alias('ls')
235
+ .addOption(new Option('--status <status>', 'only this status').choices(ALL_STATUSES))
236
+ .option('-t, --tag <tag>', 'only this tag')
237
+ .option('-n, --limit <n>', 'cap the number of rows', (v) => parseInt(v, 10))
238
+ .option('--json', 'machine-readable output')
239
+ .description('all tasks, newest first')
240
+ .action((opts) => {
241
+ const tasks = store.listTasks({ status: opts.status, tag: opts.tag, limit: opts.limit });
242
+ if (opts.json) return console.log(JSON.stringify(tasks, null, 2));
243
+ if (!tasks.length) {
244
+ console.log('No tasks yet. Submit one: gemcatch research "your question"');
245
+ return;
246
+ }
247
+ console.log(dim('ID AGE STATUS PROMPT'));
248
+ for (const t of tasks) {
249
+ const prompt = (t.prompt || '').replace(/\s+/g, ' ');
250
+ const snip = prompt.length > 60 ? `${prompt.slice(0, 57)}...` : prompt;
251
+ const status = t.status || PENDING;
252
+ // Pad before colouring: ANSI codes would break the column width.
253
+ const pad = ' '.repeat(Math.max(0, 16 - status.length));
254
+ console.log(
255
+ `${t.id} ${age(t.created_at).padEnd(4)} ${colorStatus(status)}${pad} ${snip}`
256
+ );
257
+ }
258
+ });
259
+
260
+ // --- sync -----------------------------------------------------------------
261
+
262
+ // One refresh pass over everything in flight. Never throws: a task that fails
263
+ // to poll reports its error and keeps its old status, and the rest carry on.
264
+ // Shared by `sync` (one pass) and `daemon` (a pass every interval).
265
+ async function syncPass() {
266
+ return mapLimit(store.activeTasks(), 4, async (t) => {
267
+ try {
268
+ const r = await refresh(t);
269
+ return { id: t.id, status: r.status, changed: r.status !== t.status };
270
+ } catch (err) {
271
+ return { id: t.id, status: t.status, error: err.message, changed: false };
272
+ }
273
+ });
274
+ }
275
+
276
+ program
277
+ .command('sync')
278
+ .option('--json', 'machine-readable output')
279
+ .description('refresh every in-flight task in one pass')
280
+ .action(async (opts) => {
281
+ const results = await syncPass();
282
+ if (!results.length) {
283
+ emit(opts.json, { refreshed: [] }, () => console.log('Nothing in flight.'));
284
+ return;
285
+ }
286
+ emit(opts.json, { refreshed: results }, () => {
287
+ for (const r of results) {
288
+ console.log(`${r.id} ${colorStatus(r.status)}${r.error ? ` ${dim(r.error)}` : ''}`);
289
+ }
290
+ const done = results.filter((r) => isDone(r.status)).length;
291
+ console.log(dim(`\n${results.length} refreshed, ${done} finished.`));
292
+ });
293
+ });
294
+
295
+ // --- daemon ---------------------------------------------------------------
296
+
297
+ program
298
+ .command('daemon')
299
+ .option('-i, --interval <seconds>', 'seconds between passes', (v) => parseFloat(v), DEFAULT_DAEMON_S)
300
+ .option('--exit-when-idle', 'stop once nothing is left in flight')
301
+ .option('--json', 'newline-delimited JSON events on stdout')
302
+ .description('poll in-flight tasks on a loop so results are cached before they expire')
303
+ .action(async (opts) => {
304
+ const intervalMs = Math.max(1000, (opts.interval || DEFAULT_DAEMON_S) * 1000);
305
+ let stopping = false;
306
+ let wake = null;
307
+ // Finish the pass in progress, then exit cleanly -- never leave a polled
308
+ // result unwritten because someone hit Ctrl-C.
309
+ const stop = () => {
310
+ stopping = true;
311
+ if (wake) wake();
312
+ };
313
+ process.on('SIGINT', stop);
314
+ process.on('SIGTERM', stop);
315
+
316
+ const event = (o) => {
317
+ if (opts.json) console.log(JSON.stringify(o));
318
+ };
319
+
320
+ if (!opts.json) {
321
+ console.error(
322
+ dim(`gemcatch daemon: polling every ${intervalMs / 1000}s. Store: ${store.DB_PATH}. Ctrl-C to stop.`)
323
+ );
324
+ }
325
+ event({ event: 'start', interval_s: intervalMs / 1000, db: store.DB_PATH });
326
+
327
+ for (;;) {
328
+ let pass = [];
329
+ try {
330
+ pass = await syncPass();
331
+ } catch (err) {
332
+ // syncPass swallows per-task failures, so reaching here means the store
333
+ // itself is unhappy (locked, full disk). Report it and keep looping --
334
+ // the next pass may well succeed, and a daemon that dies silently is
335
+ // worse than one that complains.
336
+ if (opts.json) event({ event: 'error', error: err.message });
337
+ else console.error(`${dim(`[${hhmmss()}]`)} Error: ${err.message}`);
338
+ }
339
+
340
+ // Quiet by default: only transitions and failures are worth a line.
341
+ for (const r of pass) {
342
+ if (!r.changed && !r.error) continue;
343
+ if (opts.json) {
344
+ event({ event: r.error ? 'error' : 'update', id: r.id, status: r.status, error: r.error || null });
345
+ } else {
346
+ console.error(
347
+ dim(`[${hhmmss()}] ${r.id}: `) + colorStatus(r.status) + (r.error ? ` ${dim(r.error)}` : '')
348
+ );
349
+ }
350
+ }
351
+
352
+ if (stopping) break;
353
+ if (opts.exitWhenIdle && !pass.some((r) => !isDone(r.status))) break;
354
+
355
+ await new Promise((resolve) => {
356
+ const t = setTimeout(() => {
357
+ wake = null;
358
+ resolve();
359
+ }, intervalMs);
360
+ wake = () => {
361
+ clearTimeout(t);
362
+ wake = null;
363
+ resolve();
364
+ };
365
+ });
366
+ if (stopping) break;
367
+ }
368
+
369
+ event({ event: 'stop' });
370
+ if (!opts.json) console.error(dim('gemcatch daemon: stopped.'));
371
+ store.close();
372
+ });
373
+
374
+ // --- watch ----------------------------------------------------------------
375
+
376
+ async function watchTask(task, intervalMs, json) {
377
+ let last = null;
378
+ for (;;) {
379
+ const r = await refresh(task);
380
+ // Status chatter goes to stderr so `gemcatch watch x > out.txt` captures only
381
+ // the result.
382
+ if (r.status !== last && !json) {
383
+ console.error(dim(`[${new Date().toISOString().slice(11, 19)}] ${task.id}: `) + colorStatus(r.status));
384
+ last = r.status;
385
+ }
386
+ if (isSuccess(r.status)) {
387
+ emit(json, { id: task.id, status: r.status, result: r.text }, () =>
388
+ console.log(r.text || '(empty response)')
389
+ );
390
+ return;
391
+ }
392
+ if (isDone(r.status)) {
393
+ emit(json, { id: task.id, status: r.status, error: r.text || null }, () => {
394
+ console.error(`Task ${task.id} ended: ${colorStatus(r.status)}`);
395
+ if (r.text) console.log(r.text);
396
+ });
397
+ process.exitCode = 1;
398
+ return;
399
+ }
400
+ await new Promise((r2) => setTimeout(r2, intervalMs));
401
+ }
402
+ }
403
+
404
+ program
405
+ .command('watch')
406
+ .argument('<id>', 'task id')
407
+ .option('-i, --interval <seconds>', 'poll interval', (v) => parseFloat(v))
408
+ .option('--json', 'machine-readable output')
409
+ .description('poll until the task finishes, then print the result')
410
+ .action(async (id, opts) => {
411
+ const task = needTask(id);
412
+ try {
413
+ if (isSuccess(task.status) && task.result) {
414
+ emit(opts.json, { id: task.id, status: task.status, result: task.result }, () =>
415
+ console.log(task.result)
416
+ );
417
+ return;
418
+ }
419
+ await watchTask(task, opts.interval ? opts.interval * 1000 : DEFAULT_POLL_MS, opts.json);
420
+ } catch (err) {
421
+ die(err);
422
+ }
423
+ });
424
+
425
+ // --- cancel ---------------------------------------------------------------
426
+
427
+ program
428
+ .command('cancel')
429
+ .argument('<id>', 'task id')
430
+ .description('ask the API to stop an in-flight task')
431
+ .action(async (id) => {
432
+ const task = needTask(id);
433
+ if (!task.interaction_id) return die(new Error(`Task ${task.id} was never submitted.`));
434
+ if (isDone(task.status)) return die(new Error(`Task ${task.id} already ${task.status}.`));
435
+ try {
436
+ const r = await gemini.cancel(task.interaction_id);
437
+ store.setStatus(task.id, r.status);
438
+ console.log(`Task ${task.id}: ${colorStatus(r.status)}`);
439
+ } catch (err) {
440
+ die(err);
441
+ }
442
+ });
443
+
444
+ // --- rm -------------------------------------------------------------------
445
+
446
+ program
447
+ .command('rm')
448
+ .argument('<ids...>', 'task ids')
449
+ .option('--remote', 'also delete the interaction server-side')
450
+ .description('forget tasks locally')
451
+ .action(async (ids, opts) => {
452
+ let removed = 0;
453
+ for (const raw of ids) {
454
+ const task = needTask(raw);
455
+ if (opts.remote && task.interaction_id) {
456
+ try {
457
+ await gemini.remove(task.interaction_id);
458
+ } catch (err) {
459
+ // Free-tier interactions vanish after 24h, so a missing remote is
460
+ // normal -- never block the local delete on it.
461
+ console.error(dim(` (remote delete failed for ${task.id}: ${err.message})`));
462
+ }
463
+ }
464
+ if (store.removeTask(task.id)) removed += 1;
465
+ }
466
+ console.log(`Removed ${removed} task${removed === 1 ? '' : 's'}.`);
467
+ });
468
+
469
+ // --- prune ----------------------------------------------------------------
470
+
471
+ program
472
+ .command('prune')
473
+ .option('-d, --days <n>', 'only finished tasks older than n days', (v) => parseFloat(v), 30)
474
+ .option('--dry-run', 'list what would go, delete nothing')
475
+ .description('drop old finished tasks (in-flight work is never touched)')
476
+ .action((opts) => {
477
+ const cutoff = Date.now() - opts.days * 86400000;
478
+ const doomed = store.prunableTasks(cutoff);
479
+ if (!doomed.length) {
480
+ console.log(`Nothing finished is older than ${opts.days} days.`);
481
+ return;
482
+ }
483
+ if (opts.dryRun) {
484
+ for (const t of doomed) console.log(`${t.id} ${age(t.created_at)} ${t.status}`);
485
+ console.log(dim(`\n${doomed.length} task(s) would be removed.`));
486
+ return;
487
+ }
488
+ const n = store.removeMany(doomed.map((t) => t.id));
489
+ console.log(`Pruned ${n} task${n === 1 ? '' : 's'}.`);
490
+ });
491
+
492
+ // --- stats ----------------------------------------------------------------
493
+
494
+ program
495
+ .command('stats')
496
+ .option('--json', 'machine-readable output')
497
+ .description('where the store lives and what is in it')
498
+ .action((opts) => {
499
+ const rows = store.counts();
500
+ const total = rows.reduce((n, r) => n + r.n, 0);
501
+ emit(opts.json, { db: store.DB_PATH, total, by_status: rows }, () => {
502
+ console.log(`Store: ${store.DB_PATH}`);
503
+ console.log(`Tasks: ${total}`);
504
+ for (const r of rows) console.log(` ${colorStatus(r.status).padEnd(useColor ? 26 : 17)} ${r.n}`);
505
+ });
506
+ });
507
+
508
+ program.parseAsync(process.argv).catch(die);
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "gemcatch",
3
+ "version": "0.1.0",
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
+ "main": "index.js",
6
+ "bin": {
7
+ "gemcatch": "index.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "db.js",
12
+ "gemini.js",
13
+ "status.js",
14
+ "README.md",
15
+ "LICENSE",
16
+ "CHANGELOG.md"
17
+ ],
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "scripts": {
22
+ "test": "node test-offline.js",
23
+ "prepublishOnly": "npm test"
24
+ },
25
+ "keywords": [
26
+ "gemini",
27
+ "gemini-api",
28
+ "interactions-api",
29
+ "background",
30
+ "async",
31
+ "agents",
32
+ "cli",
33
+ "research",
34
+ "daemon",
35
+ "google-ai"
36
+ ],
37
+ "license": "MIT",
38
+ "author": "Booyaka101",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/Booyaka101/gemcatch.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/Booyaka101/gemcatch/issues"
45
+ },
46
+ "homepage": "https://github.com/Booyaka101/gemcatch#readme",
47
+ "dependencies": {
48
+ "@google/genai": "^2.12.0",
49
+ "better-sqlite3": "^12.11.1",
50
+ "commander": "^15.0.0"
51
+ }
52
+ }
package/status.js ADDED
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Interaction lifecycle states.
5
+ *
6
+ * These mirror the InteractionStatus union in @google/genai
7
+ * (dist/genai.d.ts): "in_progress" | "requires_action" | "completed" |
8
+ * "failed" | "cancelled" | "incomplete" | "budget_exceeded".
9
+ *
10
+ * "pending" is ours alone: a row exists locally but the submit call has not
11
+ * come back yet, so there is no interaction_id to poll.
12
+ */
13
+
14
+ const PENDING = 'pending';
15
+
16
+ // Reached a final state. Polling one of these again tells you nothing new.
17
+ const TERMINAL = Object.freeze(['completed', 'failed', 'cancelled', 'incomplete', 'budget_exceeded']);
18
+
19
+ // Still moving. `gemcatch sync` refreshes exactly these.
20
+ const ACTIVE = Object.freeze(['in_progress', 'requires_action']);
21
+
22
+ const TERMINAL_SET = new Set(TERMINAL);
23
+
24
+ function isDone(status) {
25
+ return TERMINAL_SET.has(status);
26
+ }
27
+
28
+ // Did the task finish with something worth reading?
29
+ function isSuccess(status) {
30
+ return status === 'completed';
31
+ }
32
+
33
+ module.exports = { PENDING, TERMINAL, ACTIVE, isDone, isSuccess };