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 +68 -0
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/db.js +186 -0
- package/gemini.js +338 -0
- package/index.js +508 -0
- package/package.json +52 -0
- package/status.js +33 -0
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
|
+
[](https://github.com/Booyaka101/gemcatch/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/gemcatch)
|
|
5
|
+
[](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
|
+
};
|