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
11 changes: 9 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@ jobs:
node-version: ${{ matrix.node }}
cache: npm

- run: npm ci
# --ignore-scripts: better-sqlite3 13.x dropped its `install` script and
# ships N-API prebuilds in the tarball (prebuilds/<platform>-<arch>.node),
# so nothing here needs building. Without this, npm sees the package's
# binding.gyp, falls back to `node-gyp rebuild`, and the build fails on
# windows-latest images carrying Visual Studio 18 -- node-gyp 11.5.0 (the
# one bundled with Node 22) reports it as "unknown version undefined" and
# gives up. Nothing in the dependency tree needs an install script to run.
- run: npm ci --ignore-scripts

# The suite runs the real CLI against a mock Interactions API on
# localhost. No GEMINI_API_KEY is needed, so this is safe on forks.
Expand All @@ -40,7 +47,7 @@ jobs:
node-version: '22'
cache: npm

- run: npm ci
- run: npm ci --ignore-scripts

# Catches a broken bin entry or a missing file in `files` before a
# release does.
Expand Down
45 changes: 44 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.4.0] - 2026-08-08

### Added

- **Research agents.** `-a, --agent <id>` on `research` and `batch` submits to a
Gemini Deep Research agent instead of a model — `interactions.create` is sent
`agent` *instead of* `model` (they are mutually exclusive, and passing both is
a clean error). Aliases resolve through one table: `deep-research` →
`deep-research-preview-04-2026`, `deep-research-max` →
`deep-research-max-preview-04-2026`; any other value passes through unchanged,
so a future agent id works without a gemcatch release. Agents *require*
background execution, which gemcatch has always set — and on the free tier the
finished report is dropped after 1 day, which is exactly the race the daemon
exists to win. The agent is recorded per task, shown in `list` (the AGENT
column appears when a listing contains agent runs) and tallied in `stats`.
- **Spend guard.** Deep Research is documented at $1.00–$3.00 per task and Deep
Research Max at $3.00–$7.00 (estimates based on preview rates, per the docs,
and subject to change). Every agent submission prints its band first —
`batch` prints N × the band as a total — and asks for an interactive `y/N`
confirmation. When stdin is not a TTY, `--yes` is required and anything else
is refused before a row is written; declining writes nothing and exits
non-zero. `--dry-run` (now on `research` too) prints the full projected spend
and submits nothing.
- **Citations.** Agent runs return citations alongside the report; the docs say
to review them to verify the sources, so they are persisted (new `citations`
column, JSON) rather than discarded, printed under the result as a `Sources:`
list, and carried in `--json` output.
- Result extraction now takes the **final answer-bearing step** — where the
docs place an agent's completed report (`steps[-1].content[0].text`) and
where a model run's `model_output` already sits — with a fall-back to the old
collect-everything behaviour if that step carries no text, so an unexpected
shape can never silently blank a result. No special-casing on the agent id.
- Additive schema migration: `agent` and `citations` columns. A pre-0.4.0
`tasks.db` upgrades in place, keeps every row, and reports `agent` as NULL
for them.

### Changed

- The default model is now **`gemini-3.5-flash-lite`** (GA on 2026-07-21),
replacing the older `gemini-3.1-flash-lite`. Override with `GEMCATCH_MODEL`
or `--model` as before.

## [0.3.0] - 2026-07-19

### Added
Expand Down Expand Up @@ -162,7 +204,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.3.0...HEAD
[Unreleased]: https://github.com/Booyaka101/gemcatch/compare/v0.4.0...HEAD
[0.4.0]: https://github.com/Booyaka101/gemcatch/compare/v0.3.0...v0.4.0
[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
Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ These are all load-bearing and were each learned the hard way:
- **Every call goes through `call()`**, which paces it (`gate()`) and retries it. Add a new API operation and it must too, or it silently escapes both.
- **Concurrency is not a rate.** `mapLimit` in `index.js` bounds how many polls are open at once; `GEMCATCH_RPM` in `gemini.js` is what actually keeps a wide fan-out inside the free tier's requests-per-minute allowance. They are different limits and both matter.
- **Only transient failures retry.** 408/429/5xx and network errors, never 4xx: a bad key or bad model id fails the same way forever, so retrying it just spends the user's quota to reach the identical error. `shouldRetry()` is the one place that decides, and it's pinned by a test.
- **`agent` replaces `model` on create — they are mutually exclusive.** An agent run is submitted with `agent` and no `model`; the CLI rejects the combination before anything is written. The full preview agent ids live in ONE table (`AGENT_ALIASES` in `gemini.js`) — never hardcode them at a call site, they will be superseded.
- **An agent's report is in the FINAL step** (`steps[-1].content[0].text` per the docs); the earlier steps are its plan and interim drafts. `textFromSteps` takes the last answer-bearing step for every run — no special-casing on the agent id — and falls back to collecting everything if that step has no text.
- **Agent submissions cost dollars per task, so the spend guard is load-bearing.** Any new path that reaches `gemini.submit` with an agent must go through `confirmSpend()` first, *before* any row is written — a declined confirmation must leave the store untouched. `GEMCATCH_ASSUME_TTY=1` is the test hook that lets the suite drive the interactive y/N branch through a pipe.

## Tests

Expand Down
24 changes: 24 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# PROGRESS — gemcatch 0.4.0 (Deep Research agents)

**State: COMPLETE.** Branch `feat/deep-research-agents` (off `fix/better-sqlite3-13`, which carries the better-sqlite3 13 bump not yet on `main`), commit `c5869cc`. Version bumped 0.3.0 → 0.4.0, CHANGELOG entry written, README has the new "Research agents" section.

## Verified working (all offline, `npm test`, 64 tests green — 49 pre-existing + 15 new)

- `research`/`batch -a/--agent`: `agent` sent INSTEAD of `model` on create (both SDK-stub and REST paths asserted); aliases resolve through the one table in `gemini.js` (`deep-research` → `deep-research-preview-04-2026`, `deep-research-max` → `…-max-preview-04-2026`); unknown ids pass through and the API's 4xx surfaces unretried; `--model` + `--agent` is a clean error with zero rows written.
- Spend guard: band printed before every agent submission ($1.00–$3.00 / $3.00–$7.00, hedged "preview rates, subject to change"); batch prints N × band; interactive y/N (test hook `GEMCATCH_ASSUME_TTY=1`); non-TTY without `--yes` refused; decline → zero rows, exit 1; `--dry-run` on research and batch prints projected spend, submits nothing.
- Extraction: final answer-bearing step (docs: `steps[-1].content[0].text`) with collect-all fallback; interim agent steps never leak; citations persisted (new column), printed as `Sources:`, in `--json`.
- Migration: v1 and v0.3.0 `tasks.db` upgrade in place, all rows kept, `agent` NULL for old rows.
- `incomplete` (max_total_tokens budget pause) and agent-404-after-retention both retire cleanly; daemon converges.
- Default model now `gemini-3.5-flash-lite`.
- Packaging: `npm pack` → tarball installed in a clean scratch dir, `.bin/gemcatch --version` → 0.4.0; full worked-example session driven for real against a standalone mock (confirm-y submit → batch dry-run → daemon → get with sources → list AGENT column → stats agent tally).

## Phase 0 (all re-verified live 2026-08-08)

deep-research doc (agent ids, `agent=` vs `model=`, background mandatory, `steps[-1]`, citations, price bands + hedge); interactions-overview (55 days paid / 1 day free, three agents listed); blog 2026-07-28 (free-tier managed agents; `max_total_tokens` → `status: "incomplete"`); changelog (3.5-flash-lite GA 2026-07-21).

## Next steps (owner, from the phone)

1. Merge `fix/better-sqlite3-13` → `main` (PR #? — the dependabot-adjacent branch), then PR `feat/deep-research-agents` → `main`.
2. `npm publish` (prepublishOnly runs the suite) + `git tag v0.4.0` + GitHub release — per RELEASING.md.
3. Optional live smoke on a real key: `gemcatch research "test" -w` (free model, $0) and `gemcatch research "…" --agent deep-research --dry-run` ($0). A real agent run costs $1–$3 — owner's call.
4. Distribution: the 0.4.0 story writes itself — "the docs require background execution for Deep Research agents; gemcatch already was that client" (dev.to per the usual channel playbook).
50 changes: 46 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The EU AI Act's high-risk obligations phase in from August 2026, whereas...

## Setup

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.
Needs Node.js 22+ and a Gemini API key. **Getting a key needs no billing account and no card.** `gemini-3.5-flash-lite` (the default model, GA since July 2026) runs free within the [free tier's](https://ai.google.dev/gemini-api/docs/pricing) daily quota; past that, paid rates apply.

1. Get a key at **<https://aistudio.google.com/apikey>**
2. Put it in your environment:
Expand Down Expand Up @@ -99,6 +99,8 @@ Useful flags:
| --- | --- | --- |
| `--json` | most commands | Machine-readable output. |
| `-m, --model <id>` | `research`, `batch` | Override the model. |
| `-a, --agent <id>` | `research`, `batch` | Submit to a [research agent](#research-agents) instead of a model. Mutually exclusive with `--model`. |
| `--yes` | `research`, `batch` | Confirm the agent cost without asking. Required for `--agent` when stdin is not a TTY. |
| `-s, --system <text>` | `research`, `batch` | Set a system instruction. |
| `-f, --file <path>` | `research` | Read the prompt from a file. |
| `-t, --tag <tag>` | `research`, `batch`, `list` | Label tasks and filter them. |
Expand All @@ -110,7 +112,7 @@ Useful flags:
| `-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. |
| `--dry-run` | `research`, `batch`, `prune` | Show what would go — including the projected agent spend; submit/delete nothing. |
| `--raw` | `get` | Dump the raw interaction JSON. |

IDs are the first 8 characters of a UUID. Any unique prefix works, so `gemcatch get 8f3a` is fine.
Expand Down Expand Up @@ -153,14 +155,54 @@ $ id=$(gemcatch research "..." --json | jq -r .id)
$ gemcatch watch "$id" --json | jq -r .result
```

## Research agents

The [Gemini Deep Research agents](https://ai.google.dev/gemini-api/docs/deep-research) are reachable only through the Interactions API, and the docs are explicit: *"You must use background execution (set `background=true`) to run the agent asynchronously and poll for results or stream updates."* That is precisely the half of the job `gemcatch` already does — it always sets `background: true`, owns the polling, and its daemon collects results before the free tier drops interactions after **1 day** (paid tier: 55 days). A Deep Research run takes minutes and you were never going to sit there holding the connection; submit it, and let the daemon catch it.

```console
$ gemcatch research "map the EU AI Act high-risk obligations against the UK approach" --agent deep-research
Agent deep-research-preview-04-2026 — estimated $1.00–$3.00 for this task (preview rates, subject to change).
Submit? [y/N] y
Task 8f3a1c04 submitted. Run: gemcatch get 8f3a1c04 when ready.
```

`--agent` takes an alias or a raw agent id:

| You type | Sent to the API |
| --- | --- |
| `deep-research` | `deep-research-preview-04-2026` |
| `deep-research-max` | `deep-research-max-preview-04-2026` |
| anything else | passed through unchanged (future agent ids work without a gemcatch release; a bad id fails fast with the API's own 4xx) |

An agent is sent **instead of** a model — the agent picks its own models — so `--model` and `--agent` together is an error, and nothing is submitted.

**These agents cost real money, per task.** The docs put Deep Research at **$1.00–$3.00 per task** and Deep Research Max at **$3.00–$7.00 per task** — with their own hedge attached: *"These figures are estimates based on preview rates and are subject to change."* Because `gemcatch batch` fires a whole file at once, a 20-line file against `deep-research-max` is a **$60–$140 command**, so every agent submission shows its band and asks first. In a script (stdin not a TTY) you must pass `--yes`; `--dry-run` prints the full projected spend and submits nothing:

```console
$ gemcatch batch questions.txt --agent deep-research-max --dry-run
20 prompts × deep-research-max-preview-04-2026 — estimated $60.00–$140.00 total. Nothing submitted (--dry-run).
```

The report lands like any other result — final answer only, none of the agent's interim plan — and its **citations** come with it. The docs tell you to review them to verify the sources, so `gemcatch get` prints them under the report as a `Sources:` list, `--json` carries them as an array, and they live in the store alongside the result.

An agent run can also come back `incomplete` — that is what a `max_total_tokens` budget cap produces when the run "safely pauses" — which `gemcatch` treats as terminal, exactly like the API does: the daemon retires it and moves on.

The agent recipe, end to end:

```bash
$ gemcatch batch questions.txt --agent deep-research --yes # bands shown, N × total quoted
$ gemcatch daemon --exit-when-idle # catch reports before the 1-day expiry
$ gemcatch export --tag batch-1a2b3c -o reports.md # every report, with its sources
```

## How it works

Tasks live in SQLite at `~/.gemcatch/tasks.db` (override with `GEMCATCH_HOME`):

```sql
CREATE TABLE tasks (id TEXT PRIMARY KEY, prompt TEXT, interaction_id TEXT,
status TEXT DEFAULT 'pending', result TEXT, created_at INTEGER);
-- plus model, system_instruction, tag, error, usage, updated_at
-- plus model, system_instruction, tag, error, usage, updated_at, agent, citations
```

`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.
Expand Down Expand Up @@ -208,7 +250,7 @@ Transient failures are retried with exponential backoff and full jitter, honouri
| --- | --- |
| `GEMINI_API_KEY` | Your API key. `GOOGLE_API_KEY` also works. |
| `GEMCATCH_HOME` | Where `tasks.db` lives. Default `~/.gemcatch`. |
| `GEMCATCH_MODEL` | Default model. Default `gemini-3.1-flash-lite`. |
| `GEMCATCH_MODEL` | Default model. Default `gemini-3.5-flash-lite`. |
| `GEMCATCH_POLL_MS` | `watch` poll interval in ms. Default `10000`. |
| `GEMCATCH_DAEMON_S` | `daemon` interval in seconds. Default `300`. |
| `GEMCATCH_RPM` | Requests/minute ceiling. Default `15` (the free tier). `0` disables pacing. |
Expand Down
21 changes: 18 additions & 3 deletions db.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ const MIGRATIONS = [
['error', 'TEXT'],
['usage', 'TEXT'],
['updated_at', 'INTEGER'],
// 0.4.0: agent runs. `agent` is the resolved agent id the task was submitted
// with (NULL for model runs, including every pre-0.4.0 row); `citations` is
// the JSON array of sources an agent run returned alongside its report.
['agent', 'TEXT'],
['citations', 'TEXT'],
];

let _db = null;
Expand Down Expand Up @@ -59,8 +64,8 @@ function createTask(fields) {
const now = Date.now();
db()
.prepare(
'INSERT INTO tasks (id, prompt, status, created_at, updated_at, model, system_instruction, tag) ' +
'VALUES (@id, @prompt, @status, @now, @now, @model, @system_instruction, @tag)'
'INSERT INTO tasks (id, prompt, status, created_at, updated_at, model, system_instruction, tag, agent) ' +
'VALUES (@id, @prompt, @status, @now, @now, @model, @system_instruction, @tag, @agent)'
)
.run({
id,
Expand All @@ -70,6 +75,7 @@ function createTask(fields) {
model: t.model || null,
system_instruction: t.systemInstruction || null,
tag: t.tag || null,
agent: t.agent || null,
});
return id;
}
Expand Down Expand Up @@ -101,7 +107,7 @@ function setStatus(id, status, extra) {
const e = extra || {};
const sets = ['status = @status', 'updated_at = @now'];
const params = { id, status, now: Date.now() };
for (const key of ['result', 'error', 'usage']) {
for (const key of ['result', 'error', 'usage', 'citations']) {
if (e[key] !== undefined) {
sets.push(`${key} = @${key}`);
params[key] = e[key];
Expand Down Expand Up @@ -167,6 +173,14 @@ function counts() {
return db().prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status').all();
}

// Per-agent totals for `stats`. Model runs (agent IS NULL) are not a row here;
// they are already accounted for in counts().
function agentCounts() {
return db()
.prepare('SELECT agent, COUNT(*) AS n FROM tasks WHERE agent IS NOT NULL GROUP BY agent')
.all();
}

function close() {
if (_db) _db.close();
_db = null;
Expand All @@ -185,5 +199,6 @@ module.exports = {
removeMany,
prunableTasks,
counts,
agentCounts,
close,
};
Loading
Loading