Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.3.0] - 2026-07-19

### Added

- `gemcatch export [--tag <t>] [--status <s>] [--format md|json] [-o <file>]` —
concatenate finished results into one document, each under a heading with its
prompt, id and date. Markdown by default (or JSON for `jq`), to stdout or a
file. This is the "gather" step that pairs with `batch`'s "scatter": where
`get` prints one result at a time, `export` collects a whole tag at once.
- `gemcatch digest --tag <t>` — feed a tag's completed results back through a
single Gemini call to synthesise one summary. Submits like `research` and
watches to completion; the summary lands under `<tag>-digest`.
- `GEMCATCH_WATCH_MAX_FAILS` (default 10) — the consecutive-poll-failure bound
at which `watch` and `batch -w` give up rather than loop forever.

### Fixed

- `research --watch` no longer marks a **successfully submitted, server-running**
task `failed` when a poll errors during the watch. A transient poll failure (a
5xx past its retries, a network blip) or an expiry mid-watch would propagate to
the submit handler and overwrite the status to `failed`, dropping the task from
the active set so the daemon abandoned it and the result was lost. The watch
loop now rides out poll errors (retrying on the next interval), and only a
failed *submit* — a task with no `interaction_id` yet — is ever marked `failed`.
- A wedged or expired interaction no longer keeps a task in flight forever. When a
poll returns **404** (the free tier drops interactions after 24h, or one was
deleted), the task is retired locally to `incomplete` with a recorded reason, so
it leaves the active set and `daemon --exit-when-idle` converges. `watch` and
`batch -w` additionally stop after `GEMCATCH_WATCH_MAX_FAILS` consecutive poll
failures (or a stalled batch), with a clear message and a non-zero exit, instead
of spinning.
- A **completed-but-empty** result is now served from the local cache. `get` and
`watch` gated the cache hit on the result being truthy, so a task that completed
with empty text (`''`) skipped the cache, re-polled, and 404'd after 24h — the
exact loss the daemon exists to prevent. The gate is now on presence
(`result != null`), not truthiness.
- `prune -d <negative>` (or a non-numeric `--days`) is rejected instead of putting
the cutoff in the future and deleting **every** finished task. `--days` must now
be a non-negative number.
- `batch` no longer silently drops a prompt line that starts with `#`. A `#` is a
comment only when followed by whitespace (`# like this`); a line such as
`#1 cause of X?` is a real prompt and survives. When comment or blank lines are
skipped, a one-line count is noted on stderr.
- `watch -i` / `daemon -i` reject a non-positive interval (`-i -5` busy-looped,
`-i 0` silently fell back to the default). `list -n 0` now returns zero rows
instead of all of them, and `-n` rejects negatives (which SQLite reads as "no
limit").
- A second `Ctrl-C` to the daemon now force-exits (130) instead of doing nothing
while a long paced pass finishes.
- One-shot commands close the SQLite store on exit, so they no longer leave
`-wal`/`-shm` sidecar files lingering next to `tasks.db`.
- Colour written to **stderr** (the status chatter from `watch`, `daemon` and
`research -w`) is now keyed to `process.stderr.isTTY`, not stdout's. Redirecting
one stream no longer strips colour from the other, nor leaks raw ANSI into a
redirected file.

### Notes

- The default `@google/genai` SDK transport is now covered by the offline suite
(previously every test forced `GEMCATCH_FORCE_REST=1`): a stubbed client drives
submit → poll → completed and one unwrapped SDK error, confirming `shape()`
reads an SDK-shaped response and `friendly()` surfaces Google's real message.
- `GEMCATCH_RPM` pacing is **per process**. Two concurrent `gemcatch` processes
each keep their own counter and can together exceed the ceiling; run a single
daemon if the limit must hold. Documented in the README.

## [0.2.0] - 2026-07-19

### Added
Expand Down Expand Up @@ -96,7 +162,8 @@ seen a task complete, the text is cached locally and survives that expiry — bu
something has to poll inside that window for it to be seen at all, which is what
`gemcatch daemon` exists to do.

[Unreleased]: https://github.com/Booyaka101/gemcatch/compare/v0.2.0...HEAD
[Unreleased]: https://github.com/Booyaka101/gemcatch/compare/v0.3.0...HEAD
[0.3.0]: https://github.com/Booyaka101/gemcatch/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/Booyaka101/gemcatch/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/Booyaka101/gemcatch/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/Booyaka101/gemcatch/releases/tag/v0.1.0
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ This week in AI: ...
| `gemcatch status <id>` | Polls the API and prints the current state. |
| `gemcatch get <id>` | Prints the full response if complete, otherwise the current status. |
| `gemcatch list` | All tasks, newest first: id, age, status, prompt. |
| `gemcatch export` | Concatenates finished results, each under its prompt, to stdout or a file (Markdown or JSON). |
| `gemcatch digest` | Feeds a tag's completed results through one Gemini call into a single summary. |
| `gemcatch watch <id>` | Polls until the task finishes, then prints the result. |
| `gemcatch sync` | Refreshes every in-flight task in one pass. |
| `gemcatch daemon` | Keeps polling in-flight tasks on a loop, so results are cached before they expire. |
Expand All @@ -102,9 +104,12 @@ Useful flags:
| `-t, --tag <tag>` | `research`, `batch`, `list` | Label tasks and filter them. |
| `-w, --watch` | `research`, `batch` | Submit and wait, in one command. |
| `--separator <str>` | `batch` | Split the file on this delimiter line for multi-line prompts. |
| `-i, --interval <s>` | `watch`, `daemon` | Poll rate. Default 10s for `watch`, 300s for `daemon`. |
| `-i, --interval <s>` | `watch`, `daemon` | Poll rate in seconds; must be > 0. Default 10s for `watch`, 300s for `daemon`. |
| `--exit-when-idle` | `daemon` | Stop once nothing is left in flight. |
| `-n, --limit <n>` | `list` | Cap the rows. |
| `--status <s>` | `list`, `export` | Only tasks in this status. |
| `-n, --limit <n>` | `list` | Cap the rows (non-negative; `0` shows none). |
| `--format <md\|json>` | `export` | Output format. Default `md`. |
| `-o, --out <file>` | `export` | Write to a file instead of stdout. |
| `--dry-run` | `batch`, `prune` | Show what would go; submit/delete nothing. |
| `--raw` | `get` | Dump the raw interaction JSON. |

Expand All @@ -117,10 +122,15 @@ Statuses come straight from the API: `in_progress`, `requires_action`, `complete
```bash
# Fire off a whole file of prompts in one command, then collect later.
# Every task shares one auto-generated tag (batch-xxxxxx), printed on submit.
$ gemcatch batch questions.txt # one prompt per line; # and blanks skipped
$ gemcatch batch questions.txt # one prompt per line; "# " and blanks skipped
$ gemcatch daemon --exit-when-idle # keep polling until they're all in
$ gemcatch list --tag batch-1a2b3c --status completed

# Collect a whole batch into one document (the "gather" for batch's "scatter").
$ gemcatch export --tag batch-1a2b3c -o results.md # Markdown, one section per prompt
$ gemcatch export --tag batch-1a2b3c --format json | jq -r '.[].result'
$ gemcatch digest --tag batch-1a2b3c # or synthesize them into one summary

# Multi-line prompts: split the file on a delimiter line instead of per-line
$ gemcatch batch briefs.md --separator ---
$ gemcatch batch - < questions.txt # or pipe the list in on stdin
Expand Down Expand Up @@ -182,10 +192,14 @@ $ gemcatch daemon --exit-when-idle -i 30
$ gemcatch list --tag batch1 --status completed
```

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.

## Rate limits and retries

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.

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.

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.

## Environment variables
Expand All @@ -199,6 +213,7 @@ Transient failures are retried with exponential backoff and full jitter, honouri
| `GEMCATCH_DAEMON_S` | `daemon` interval in seconds. Default `300`. |
| `GEMCATCH_RPM` | Requests/minute ceiling. Default `15` (the free tier). `0` disables pacing. |
| `GEMCATCH_MAX_RETRIES` | Extra attempts on a transient failure. Default `4`. `0` disables retries. |
| `GEMCATCH_WATCH_MAX_FAILS` | Consecutive poll failures before `watch`/`batch -w` give up. Default `10`. |
| `GEMCATCH_BASE_URL` | Override the API endpoint (proxy/gateway/testing). |
| `GEMCATCH_FORCE_REST` | `1` bypasses the SDK and uses raw `fetch`. |
| `NO_COLOR` | Disable colour output. |
Expand Down
5 changes: 4 additions & 1 deletion db.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ function listTasks(opts) {
let sql = 'SELECT * FROM tasks';
if (where.length) sql += ` WHERE ${where.join(' AND ')}`;
sql += ' ORDER BY created_at DESC';
if (o.limit) {
// Presence, not truthiness: `--limit 0` is a real cap (return nothing), so it
// must not be treated the same as "no limit given". The caller validates that
// it is a non-negative integer before we get here.
if (o.limit != null) {
sql += ' LIMIT @limit';
params.limit = o.limit;
}
Expand Down
Loading