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/CHANGELOG.md ADDED
@@ -0,0 +1,68 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-07-17
11
+
12
+ First release. Wraps the background execution capability added to the Gemini
13
+ Interactions API on [2026-07-07](https://blog.google/innovation-and-ai/technology/developers-tools/expanding-managed-agents-gemini-api/).
14
+
15
+ ### Added
16
+
17
+ - `gemcatch research "<prompt>"` — submits with `background: true` and exits immediately.
18
+ Reads the prompt from an argument, from `--file`, or from stdin via `-`.
19
+ Supports `--model`, `--system`, `--tag`, `--watch` and `--json`.
20
+ - `gemcatch status <id>` — polls the API and prints the current state.
21
+ - `gemcatch get <id>` — prints the result if complete, otherwise the current status.
22
+ Completed results are served from SQLite without a network call. `--raw` dumps
23
+ the raw interaction JSON.
24
+ - `gemcatch list` — all tasks, newest first. Filters: `--status`, `--tag`, `-n`.
25
+ - `gemcatch watch <id>` — polls until the task finishes, then prints the result.
26
+ `--interval` tunes the poll rate. Status chatter goes to stderr so the result
27
+ can be redirected cleanly.
28
+ - `gemcatch sync` — refreshes every in-flight task in one pass, four polls at a time.
29
+ - `gemcatch daemon` — polls in-flight tasks on a loop (default every 300s) so results
30
+ are cached locally before the free tier drops them at 24h. `--exit-when-idle`
31
+ stops once nothing is left in flight; `--json` emits newline-delimited events.
32
+ Results are committed to SQLite as each poll lands, so an abrupt kill loses
33
+ nothing already collected.
34
+ - Request pacing: every API call is held to `GEMCATCH_RPM` requests/minute (default
35
+ 15, the free-tier allowance; `0` disables). Capping concurrency alone does not
36
+ cap a rate, which a wide `sync` would otherwise discover the hard way.
37
+ - Retries: transient failures (429, 408, 5xx, network errors) get
38
+ `GEMCATCH_MAX_RETRIES` further attempts (default 4) with exponential backoff and
39
+ full jitter, honouring `Retry-After`. A 4xx is surfaced immediately — it will
40
+ fail identically forever, and retrying it only burns the rate limit.
41
+ - `gemcatch cancel <id>` — asks the API to stop an in-flight task.
42
+ - `gemcatch rm <ids...>` — forgets tasks locally; `--remote` also deletes them server-side.
43
+ - `gemcatch prune` — drops finished tasks older than `--days` (default 30). Never
44
+ touches in-flight work. `--dry-run` shows what would go.
45
+ - `gemcatch stats` — where the store lives and what's in it.
46
+ - Task IDs are 8-character UUID prefixes, and any unique prefix resolves.
47
+ - Token usage is recorded per task when the API reports it.
48
+ - Colour output on a TTY, honouring `NO_COLOR`.
49
+ - Schema migrations: a v1 `tasks.db` upgrades in place without losing rows.
50
+ - Offline test suite covering every command against a mock Interactions API.
51
+ No key or network required.
52
+
53
+ ### Notes
54
+
55
+ Two details in the API differ from what a first reading of the announcement
56
+ suggests, and both are handled here:
57
+
58
+ - The API key must be sent as `x-goog-api-key`. `Authorization: Bearer <key>`
59
+ is rejected with `401 ACCESS_TOKEN_TYPE_UNSUPPORTED`.
60
+ - The prompt field is `input` (which accepts a plain string), not `contents`.
61
+
62
+ Free-tier interactions are retained server-side for 24 hours. Once `gemcatch` has
63
+ seen a task complete, the text is cached locally and survives that expiry — but
64
+ something has to poll inside that window for it to be seen at all, which is what
65
+ `gemcatch daemon` exists to do.
66
+
67
+ [Unreleased]: https://github.com/Booyaka101/gemcatch/compare/v0.1.0...HEAD
68
+ [0.1.0]: https://github.com/Booyaka101/gemcatch/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gemcatch contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # gemcatch
2
+
3
+ [![CI](https://github.com/Booyaka101/gemcatch/actions/workflows/ci.yml/badge.svg)](https://github.com/Booyaka101/gemcatch/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/gemcatch.svg)](https://www.npmjs.com/package/gemcatch)
5
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ Fire-and-forget research tasks for the Gemini API. Submit a long-running prompt, get a task ID back in under a second, close your laptop, collect the answer later.
8
+
9
+ On [July 7, 2026](https://blog.google/innovation-and-ai/technology/developers-tools/expanding-managed-agents-gemini-api/) Google added background execution to the Gemini Interactions API:
10
+
11
+ > Holding an HTTP connection open for long-running tasks is fragile. Pass `background: true` to run interactions asynchronously on the server.
12
+
13
+ That solves the server half. The client half is still on you: you get back an interaction ID and now you own it — polling it, remembering which prompt it belonged to, not losing it when your shell dies, noticing that the free tier throws it away after 24 hours. `gemcatch` is that half. It passes `background: true`, stores the interaction ID in local SQLite next to the prompt that created it, gives you a handful of commands to get results back, and runs a [daemon](#dont-lose-results-gemcatch-daemon) that collects them before the free tier drops them.
14
+
15
+ ```console
16
+ $ gemcatch research "compare the 2026 EU AI Act timelines against the UK approach"
17
+ Task 8f3a1c04 submitted. Run: gemcatch get 8f3a1c04 when ready.
18
+
19
+ $ # ...close your laptop, come back later...
20
+
21
+ $ gemcatch get 8f3a1c04
22
+ The EU AI Act's high-risk obligations phase in from August 2026, whereas...
23
+ ```
24
+
25
+ ## Setup
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.
28
+
29
+ 1. Get a key at **<https://aistudio.google.com/apikey>**
30
+ 2. Put it in your environment:
31
+
32
+ ```powershell
33
+ # PowerShell
34
+ $env:GEMINI_API_KEY = "your-key-here"
35
+ ```
36
+
37
+ ```bash
38
+ # bash / zsh
39
+ export GEMINI_API_KEY=your-key-here
40
+ ```
41
+
42
+ To make it permanent, add that line to your shell profile (`$PROFILE` on PowerShell, `~/.bashrc` or `~/.zshrc` on Unix). `GOOGLE_API_KEY` works too.
43
+
44
+ ## Run
45
+
46
+ ```bash
47
+ npx gemcatch research "your question"
48
+ ```
49
+
50
+ Or install it once and use the short name:
51
+
52
+ ```bash
53
+ npm install -g gemcatch
54
+ gemcatch research "your question"
55
+ ```
56
+
57
+ From a clone: `npm install && node index.js research "your question"`.
58
+
59
+ ## Three examples
60
+
61
+ ```bash
62
+ # 1. Submit — returns immediately with a task ID
63
+ $ gemcatch research "summarize this week in AI"
64
+ Task 8f3a1c04 submitted. Run: gemcatch get 8f3a1c04 when ready.
65
+
66
+ # 2. Check on it — or `gemcatch list` to see everything
67
+ $ gemcatch status 8f3a1c04
68
+ Task 8f3a1c04: in_progress
69
+
70
+ # 3. Collect the answer (blocks and polls until done)
71
+ $ gemcatch watch 8f3a1c04
72
+ [10:52:31] 8f3a1c04: in_progress
73
+ [10:54:02] 8f3a1c04: completed
74
+ This week in AI: ...
75
+ ```
76
+
77
+ ## Commands
78
+
79
+ | Command | What it does |
80
+ | --- | --- |
81
+ | `gemcatch research "<prompt>"` | Submits with `background: true`, stores the interaction ID, exits immediately. |
82
+ | `gemcatch status <id>` | Polls the API and prints the current state. |
83
+ | `gemcatch get <id>` | Prints the full response if complete, otherwise the current status. |
84
+ | `gemcatch list` | All tasks, newest first: id, age, status, prompt. |
85
+ | `gemcatch watch <id>` | Polls until the task finishes, then prints the result. |
86
+ | `gemcatch sync` | Refreshes every in-flight task in one pass. |
87
+ | `gemcatch daemon` | Keeps polling in-flight tasks on a loop, so results are cached before they expire. |
88
+ | `gemcatch cancel <id>` | Asks the API to stop an in-flight task. |
89
+ | `gemcatch rm <ids...>` | Forgets tasks locally. `--remote` deletes them server-side too. |
90
+ | `gemcatch prune` | Drops finished tasks older than `--days` (default 30). |
91
+ | `gemcatch stats` | Where the store lives and what's in it. |
92
+
93
+ Useful flags:
94
+
95
+ | Flag | On | Does |
96
+ | --- | --- | --- |
97
+ | `--json` | most commands | Machine-readable output. |
98
+ | `-m, --model <id>` | `research` | Override the model. |
99
+ | `-s, --system <text>` | `research` | Set a system instruction. |
100
+ | `-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. |
103
+ | `-i, --interval <s>` | `watch`, `daemon` | Poll rate. Default 10s for `watch`, 300s for `daemon`. |
104
+ | `--exit-when-idle` | `daemon` | Stop once nothing is left in flight. |
105
+ | `-n, --limit <n>` | `list` | Cap the rows. |
106
+ | `--dry-run` | `prune` | Show what would go; delete nothing. |
107
+ | `--raw` | `get` | Dump the raw interaction JSON. |
108
+
109
+ IDs are the first 8 characters of a UUID. Any unique prefix works, so `gemcatch get 8f3a` is fine.
110
+
111
+ Statuses come straight from the API: `in_progress`, `requires_action`, `completed`, `failed`, `cancelled`, `incomplete`, `budget_exceeded`. Plus `pending`, which is local: the row exists but the submit call hasn't returned yet.
112
+
113
+ ## Recipes
114
+
115
+ ```bash
116
+ # Fire off a batch, then collect later
117
+ $ for q in "topic A" "topic B" "topic C"; do gemcatch research "$q" -t batch1; done
118
+ $ gemcatch sync # one pass now...
119
+ $ gemcatch daemon --exit-when-idle # ...or keep polling until they're all in
120
+ $ gemcatch list --tag batch1 --status completed
121
+
122
+ # Long prompt from a file, result to a file.
123
+ # Progress goes to stderr, so the redirect captures only the answer.
124
+ $ gemcatch research -f brief.md -w > answer.md
125
+
126
+ # Pipe a prompt in
127
+ $ cat notes.txt | gemcatch research - -s "extract every open question"
128
+
129
+ # Script against it
130
+ $ id=$(gemcatch research "..." --json | jq -r .id)
131
+ $ gemcatch watch "$id" --json | jq -r .result
132
+ ```
133
+
134
+ ## How it works
135
+
136
+ Tasks live in SQLite at `~/.gemcatch/tasks.db` (override with `GEMCATCH_HOME`):
137
+
138
+ ```sql
139
+ CREATE TABLE tasks (id TEXT PRIMARY KEY, prompt TEXT, interaction_id TEXT,
140
+ status TEXT DEFAULT 'pending', result TEXT, created_at INTEGER);
141
+ -- plus model, system_instruction, tag, error, usage, updated_at
142
+ ```
143
+
144
+ `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.
145
+
146
+ An older `tasks.db` upgrades in place; migrations are additive and never drop a row.
147
+
148
+ **Free-tier results expire after 24 hours.** The [docs](https://ai.google.dev/gemini-api/docs/interactions-overview) note the system retains interactions for 1 day on the free tier (55 days paid). Once `gemcatch` has seen a task complete, the text is cached locally and survives that expiry — but a task nobody polls inside that window is gone server-side. That is what `gemcatch daemon` is for.
149
+
150
+ ## Don't lose results: `gemcatch daemon`
151
+
152
+ Caching a result permanently is easy; *noticing* it is the hard part. If nothing polls a finished task within 24 hours, the answer is dropped server-side and no amount of local bookkeeping brings it back. `gemcatch daemon` is the something that looks:
153
+
154
+ ```console
155
+ $ gemcatch daemon
156
+ gemcatch daemon: polling every 300s. Store: ~/.gemcatch/tasks.db. Ctrl-C to stop.
157
+ [10:54:02] 8f3a1c04: completed
158
+ [11:31:20] c7b91e55: completed
159
+ ```
160
+
161
+ It refreshes everything in flight on an interval, writes each result to SQLite the moment it lands, and stays quiet otherwise — only transitions and errors get a line. It's an ordinary foreground process: no forking, no pidfile. Leave it in a terminal, or hand it to whatever supervises long-running jobs on your machine (a systemd user unit, a launchd agent, Task Scheduler at log-on, `nohup`, `pm2`).
162
+
163
+ **Nothing is lost if it dies.** Every poll is committed to SQLite synchronously, so killing the daemon — Ctrl-C, `kill -9`, a reboot — stops the polling and nothing else. Results it already collected are on disk, and restarting picks up where it left off.
164
+
165
+ `--exit-when-idle` stops once nothing is in flight, which makes it the "collect this batch, then quit" one-liner:
166
+
167
+ ```bash
168
+ $ for q in "topic A" "topic B" "topic C"; do gemcatch research "$q" -t batch1; done
169
+ $ gemcatch daemon --exit-when-idle -i 30
170
+ $ gemcatch list --tag batch1 --status completed
171
+ ```
172
+
173
+ ## Rate limits and retries
174
+
175
+ 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.
176
+
177
+ 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.
178
+
179
+ ## Environment variables
180
+
181
+ | Variable | Purpose |
182
+ | --- | --- |
183
+ | `GEMINI_API_KEY` | Your API key. `GOOGLE_API_KEY` also works. |
184
+ | `GEMCATCH_HOME` | Where `tasks.db` lives. Default `~/.gemcatch`. |
185
+ | `GEMCATCH_MODEL` | Default model. Default `gemini-3.1-flash-lite`. |
186
+ | `GEMCATCH_POLL_MS` | `watch` poll interval in ms. Default `10000`. |
187
+ | `GEMCATCH_DAEMON_S` | `daemon` interval in seconds. Default `300`. |
188
+ | `GEMCATCH_RPM` | Requests/minute ceiling. Default `15` (the free tier). `0` disables pacing. |
189
+ | `GEMCATCH_MAX_RETRIES` | Extra attempts on a transient failure. Default `4`. `0` disables retries. |
190
+ | `GEMCATCH_BASE_URL` | Override the API endpoint (proxy/gateway/testing). |
191
+ | `GEMCATCH_FORCE_REST` | `1` bypasses the SDK and uses raw `fetch`. |
192
+ | `NO_COLOR` | Disable colour output. |
193
+
194
+ ## Development
195
+
196
+ ```bash
197
+ git clone https://github.com/Booyaka101/gemcatch
198
+ cd gemcatch
199
+ npm install
200
+ npm test
201
+ ```
202
+
203
+ **The tests need no API key and no network.** They run the real CLI as a subprocess against a mock Interactions API on localhost, covering every command, the failure paths, and schema migration. See [CONTRIBUTING.md](CONTRIBUTING.md) for the layout and for the API gotchas worth knowing before touching `gemini.js`.
204
+
205
+ ## Contributing
206
+
207
+ Issues and PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md). Security reports go through [private disclosure](SECURITY.md), not public issues.
208
+
209
+ ## License
210
+
211
+ [MIT](LICENSE)
package/db.js ADDED
@@ -0,0 +1,186 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const Database = require('better-sqlite3');
8
+ const { PENDING, TERMINAL } = require('./status');
9
+
10
+ // GEMCATCH_HOME lets tests and power users relocate the store.
11
+ const HOME = process.env.GEMCATCH_HOME || path.join(os.homedir(), '.gemcatch');
12
+ const DB_PATH = path.join(HOME, 'tasks.db');
13
+
14
+ // The v1 shape. Never change this -- migrations below carry it forward, so an
15
+ // old tasks.db keeps opening cleanly.
16
+ const BASE_SCHEMA =
17
+ 'CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, prompt TEXT, ' +
18
+ "interaction_id TEXT, status TEXT DEFAULT 'pending', result TEXT, created_at INTEGER)";
19
+
20
+ // Columns added after v1. Additive only: SQLite can ALTER TABLE ADD COLUMN but
21
+ // not drop or retype, so anything here must be nullable.
22
+ const MIGRATIONS = [
23
+ ['model', 'TEXT'],
24
+ ['system_instruction', 'TEXT'],
25
+ ['tag', 'TEXT'],
26
+ ['error', 'TEXT'],
27
+ ['usage', 'TEXT'],
28
+ ['updated_at', 'INTEGER'],
29
+ ];
30
+
31
+ let _db = null;
32
+
33
+ function migrate(d) {
34
+ const have = new Set(d.prepare('PRAGMA table_info(tasks)').all().map((c) => c.name));
35
+ for (const [name, type] of MIGRATIONS) {
36
+ if (!have.has(name)) d.exec(`ALTER TABLE tasks ADD COLUMN ${name} ${type}`);
37
+ }
38
+ d.exec('CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks (created_at DESC)');
39
+ d.exec('CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status)');
40
+ }
41
+
42
+ function db() {
43
+ if (_db) return _db;
44
+ fs.mkdirSync(HOME, { recursive: true });
45
+ _db = new Database(DB_PATH);
46
+ _db.pragma('journal_mode = WAL');
47
+ _db.exec(BASE_SCHEMA);
48
+ migrate(_db);
49
+ return _db;
50
+ }
51
+
52
+ function newId() {
53
+ return crypto.randomUUID().slice(0, 8);
54
+ }
55
+
56
+ function createTask(fields) {
57
+ const t = fields || {};
58
+ const id = newId();
59
+ const now = Date.now();
60
+ db()
61
+ .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)'
64
+ )
65
+ .run({
66
+ id,
67
+ prompt: t.prompt,
68
+ status: PENDING,
69
+ now,
70
+ model: t.model || null,
71
+ system_instruction: t.systemInstruction || null,
72
+ tag: t.tag || null,
73
+ });
74
+ return id;
75
+ }
76
+
77
+ // Exact id first, then unique-prefix match so short ids stay forgiving.
78
+ // Returns null for no match; throws on an ambiguous prefix rather than
79
+ // silently picking one.
80
+ function getTask(id) {
81
+ const exact = db().prepare('SELECT * FROM tasks WHERE id = ?').get(id);
82
+ if (exact) return exact;
83
+ const hits = db().prepare('SELECT * FROM tasks WHERE id LIKE ? ORDER BY created_at DESC').all(id + '%');
84
+ if (hits.length > 1) {
85
+ const e = new Error(`'${id}' matches ${hits.length} tasks: ${hits.map((h) => h.id).join(', ')}`);
86
+ e.code = 'AMBIGUOUS_ID';
87
+ throw e;
88
+ }
89
+ return hits[0] || null;
90
+ }
91
+
92
+ function setInteraction(id, interactionId, status) {
93
+ db()
94
+ .prepare('UPDATE tasks SET interaction_id = ?, status = ?, updated_at = ? WHERE id = ?')
95
+ .run(interactionId, status, Date.now(), id);
96
+ }
97
+
98
+ // Only overwrites result/error/usage when the caller supplies them, so a poll
99
+ // that returns no text can't blank out a result we already stored.
100
+ function setStatus(id, status, extra) {
101
+ const e = extra || {};
102
+ const sets = ['status = @status', 'updated_at = @now'];
103
+ const params = { id, status, now: Date.now() };
104
+ for (const key of ['result', 'error', 'usage']) {
105
+ if (e[key] !== undefined) {
106
+ sets.push(`${key} = @${key}`);
107
+ params[key] = e[key];
108
+ }
109
+ }
110
+ db().prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = @id`).run(params);
111
+ }
112
+
113
+ function listTasks(opts) {
114
+ const o = opts || {};
115
+ const where = [];
116
+ const params = {};
117
+ if (o.status) {
118
+ where.push('status = @status');
119
+ params.status = o.status;
120
+ }
121
+ if (o.tag) {
122
+ where.push('tag = @tag');
123
+ params.tag = o.tag;
124
+ }
125
+ let sql = 'SELECT * FROM tasks';
126
+ if (where.length) sql += ` WHERE ${where.join(' AND ')}`;
127
+ sql += ' ORDER BY created_at DESC';
128
+ if (o.limit) {
129
+ sql += ' LIMIT @limit';
130
+ params.limit = o.limit;
131
+ }
132
+ return db().prepare(sql).all(params);
133
+ }
134
+
135
+ // In-flight tasks worth re-polling: submitted (we have an id) but not final.
136
+ function activeTasks() {
137
+ const marks = TERMINAL.map(() => '?').join(', ');
138
+ return db()
139
+ .prepare(
140
+ `SELECT * FROM tasks WHERE interaction_id IS NOT NULL AND status NOT IN (${marks}) ORDER BY created_at ASC`
141
+ )
142
+ .all(...TERMINAL);
143
+ }
144
+
145
+ function removeTask(id) {
146
+ return db().prepare('DELETE FROM tasks WHERE id = ?').run(id).changes > 0;
147
+ }
148
+
149
+ // Finished tasks older than `beforeMs`. Never touches in-flight work.
150
+ function prunableTasks(beforeMs) {
151
+ const marks = TERMINAL.map(() => '?').join(', ');
152
+ return db()
153
+ .prepare(`SELECT * FROM tasks WHERE status IN (${marks}) AND created_at < ? ORDER BY created_at ASC`)
154
+ .all(...TERMINAL, beforeMs);
155
+ }
156
+
157
+ function removeMany(ids) {
158
+ if (!ids.length) return 0;
159
+ const del = db().prepare('DELETE FROM tasks WHERE id = ?');
160
+ return db().transaction((list) => list.reduce((n, id) => n + del.run(id).changes, 0))(ids);
161
+ }
162
+
163
+ function counts() {
164
+ return db().prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status').all();
165
+ }
166
+
167
+ function close() {
168
+ if (_db) _db.close();
169
+ _db = null;
170
+ }
171
+
172
+ module.exports = {
173
+ DB_PATH,
174
+ HOME,
175
+ createTask,
176
+ getTask,
177
+ setInteraction,
178
+ setStatus,
179
+ listTasks,
180
+ activeTasks,
181
+ removeTask,
182
+ removeMany,
183
+ prunableTasks,
184
+ counts,
185
+ close,
186
+ };