@chrischall/gemini-mcp 1.0.4 → 1.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/dist/tools/set.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
- import { slugify, baseName, gatherImageInputs } from '../images.js';
5
- import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
4
+ import { slugify, baseName } from '../images.js';
5
+ import { resolveImageInputs } from '../inputs.js';
6
+ import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
6
7
  import { fingerprintRequest } from '../jobs.js';
7
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
8
9
  export function registerSetTools(server, client) {
@@ -16,7 +17,9 @@ export function registerSetTools(server, client) {
16
17
  reference_mode: z.enum(['master', 'chain']).optional().describe('master: every image references the master (default). chain: each references the previous.'),
17
18
  basename: z.string().optional().describe('Base filename prefix for output images (default: slugified master_prompt)'),
18
19
  master_images: z.array(z.string().min(1)).optional().describe('Reference image paths passed to the master generation call'),
19
- master_images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs for master generation'),
20
+ master_images_url: imagesUrlSchema('Reference images passed to the master AND to every scene call (fetched once)'),
21
+ master_images_file_uris: imagesFileUrisSchema('Reference images passed to the master AND to every scene call'),
22
+ master_images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs for master generation. Last resort: prefer master_images_url or master_images_file_uris, which keep image bytes out of the conversation'),
20
23
  confirm: schemaConfirm,
21
24
  ...sharedImageSchema,
22
25
  },
@@ -37,13 +40,30 @@ export function registerSetTools(server, client) {
37
40
  reference_mode: args.reference_mode, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
38
41
  thinking_level: args.thinking_level, google_search: args.google_search,
39
42
  master_images: args.master_images, master_images_base64: args.master_images_base64, from_clipboard: args.from_clipboard,
43
+ master_images_url: args.master_images_url, master_images_file_uris: args.master_images_file_uris,
40
44
  });
41
45
  return client.session.jobs.dispatch({ toolName: 'gemini_image_set', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
42
46
  const seed = pickSeed(args.seed);
43
47
  const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
44
48
  const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
45
- // 1. master (optionally seeded from reference images)
46
- const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
49
+ // 1. master (optionally seeded from reference images).
50
+ //
51
+ // Two resolutions, because the two groups have different lifetimes:
52
+ //
53
+ // - `carried` — URL and Files API references. Fetched/uploaded ONCE for
54
+ // the whole set (a URL repeated across master + N scenes is one
55
+ // download, and a `files/…` reference costs nothing to repeat), and
56
+ // passed to EVERY scene call, where they do the most good: they are
57
+ // what keeps the subject consistent scene to scene.
58
+ // - `masterOnly` — path / base64 / clipboard references. Master-only,
59
+ // exactly as before these parameters existed, so pre-existing calls
60
+ // still build byte-identical requests.
61
+ const carried = await resolveImageInputs({ images_url: args.master_images_url, images_file_uris: args.master_images_file_uris }, client);
62
+ const masterOnly = await resolveImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard }, client);
63
+ const masterRefInputs = [...masterOnly.inputs, ...carried.inputs];
64
+ const report = carried.report || masterOnly.report
65
+ ? { ...masterOnly.report, ...carried.report }
66
+ : undefined;
47
67
  const named = [];
48
68
  const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
49
69
  const result = await client.generate({
@@ -62,13 +82,13 @@ export function registerSetTools(server, client) {
62
82
  if (mode === 'chain') {
63
83
  let ref = master;
64
84
  for (let i = 0; i < scenePrompts.length; i++) {
65
- const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
85
+ const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
66
86
  named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` });
67
87
  ref = img;
68
88
  }
69
89
  }
70
90
  else {
71
- const scenes = await Promise.all(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
91
+ const scenes = await Promise.all(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
72
92
  scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` }));
73
93
  }
74
94
  return result;
@@ -79,6 +99,8 @@ export function registerSetTools(server, client) {
79
99
  meta.text = masterText;
80
100
  if (masterResult.grounding)
81
101
  meta.grounding = masterResult.grounding;
102
+ if (report)
103
+ meta.image_inputs = report;
82
104
  // A set is master + N scenes in one tools/call — the most timeout-prone
83
105
  // tool. Effective image count drives the risk hint.
84
106
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client.mediaSink?.persistsFiles });
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveVideoPath, videoMimeType } from '../images.js';
4
+ import { IMAGE_URL_MAX_BYTES } from '../fetch-image.js';
4
5
  import { createDiskSink } from '../storage/media.js';
5
6
  /**
6
7
  * Fallback sink for call sites that pass no explicit one (every pre-existing
@@ -52,6 +53,31 @@ export const asyncSchema = z
52
53
  .boolean()
53
54
  .optional()
54
55
  .describe('Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result (jobs are per-process and expire ~10 min after completion).');
56
+ /**
57
+ * Reference images the SERVER fetches, so the bytes never pass through the
58
+ * model's context window. Defined once and reused under both the `images_url`
59
+ * and `master_images_url` names.
60
+ */
61
+ export function imagesUrlSchema(label = 'Reference images') {
62
+ return z
63
+ .array(z.string().url())
64
+ .optional()
65
+ .describe(`${label} as public https URLs — the SERVER downloads them, so no image bytes travel through the conversation. ` +
66
+ 'Preferred over images_base64, which costs ~14k tokens per photo and breaks if a file read was truncated. ' +
67
+ `Max ${Math.round(IMAGE_URL_MAX_BYTES / (1024 * 1024))}MB each; must be a directly-linked image (Content-Type image/*).`);
68
+ }
69
+ /**
70
+ * Reference images already in the Gemini Files API. The cheapest form there is:
71
+ * a short string, reusable across calls until the file expires.
72
+ */
73
+ export function imagesFileUrisSchema(label = 'Reference images') {
74
+ return z
75
+ .array(z.string().min(1))
76
+ .optional()
77
+ .describe(`${label} by Gemini Files API reference ("files/<id>", or the full uri) from gemini_upload_file or POST /upload. ` +
78
+ 'Upload once, then reference it across as many calls as you like — no bytes are re-sent and none enter the conversation. ' +
79
+ 'Files are retained ~48h, after which the reference stops resolving.');
80
+ }
55
81
  /**
56
82
  * The model/aspect/size/output fields every generation tool shares. Defined once
57
83
  * here so descriptions can't drift between `generate.ts` and `set.ts`. Spread
@@ -271,10 +297,15 @@ export async function emit(named, opts, meta, onWritten) {
271
297
  * - `video_path` — the Files API upload streams the file off disk.
272
298
  *
273
299
  * Called at the top of every generation handler, BEFORE any billable upstream
274
- * call, so a caller gets a clear "use images_base64 / video_url instead"
275
- * instead of an obscure module-resolution or ENOENT crash. A no-op on the disk
276
- * sink, so the stdio behaviour (including its own "Image not found" errors) is
277
- * untouched.
300
+ * call, so a caller gets a clear alternative instead of an obscure
301
+ * module-resolution or ENOENT crash. A no-op on the disk sink, so the stdio
302
+ * behaviour (including its own "Image not found" errors) is untouched.
303
+ *
304
+ * The remediation deliberately does NOT lead with `images_base64` any more.
305
+ * Inlining a photo as a tool argument costs ~14k tokens of the model's context
306
+ * and silently corrupts whenever the read that produced it was truncated — so
307
+ * it is named last, after the three routes that move bytes without the model
308
+ * ever seeing them.
278
309
  */
279
310
  export function assertLocalInputsAvailable(sink, args) {
280
311
  if ((sink ?? DEFAULT_SINK).persistsFiles)
@@ -293,8 +324,14 @@ export function assertLocalInputsAvailable(sink, args) {
293
324
  // Remediation goes in the MESSAGE, not just `hint`: MCP error serialization
294
325
  // surfaces the message and drops the hint, so hint-only advice is invisible
295
326
  // exactly when it is needed.
296
- const fix = 'Send image bytes as `images_base64` (raw base64 or a data: URI) instead of `images`/`master_images`/`from_clipboard`, ' +
297
- 'and reference video by `video_url` (a public YouTube URL, or a files/… uri uploaded elsewhere) instead of `video_path`.';
327
+ const fix = 'Reference images WITHOUT putting bytes in the conversation, in order of preference: ' +
328
+ '(1) `images_url` — public https URLs the server fetches itself; ' +
329
+ '(2) `images_file_uris` — upload once with `gemini_upload_file` (it takes a `url` or `data_base64`) and reuse the returned files/… uri; ' +
330
+ '(3) `POST /upload` on this connector with the raw bytes as the body ' +
331
+ "(`curl -X POST <connector-url>/upload -H 'Authorization: Bearer <token>' -H 'Content-Type: image/jpeg' --data-binary @photo.jpg`), " +
332
+ 'then pass the returned file_uri. ' +
333
+ 'As a last resort `images_base64` still works, but it costs ~14k tokens per photo and breaks on a truncated read. ' +
334
+ 'Reference video by `video_url` (a public YouTube URL, or a files/… uri uploaded elsewhere) instead of `video_path`.';
298
335
  throw new McpToolError(`${unavailable.join(', ')} ${unavailable.length > 1 ? 'are' : 'is'} unavailable on the hosted connector: ` +
299
336
  `it runs on a Cloudflare Worker, which has no filesystem and no local clipboard. ${fix}`, { hint: fix });
300
337
  }
@@ -0,0 +1,79 @@
1
+ /** Media the Files API is useful for. Anything else is a caller mistake. */
2
+ const ACCEPTED_PREFIXES = ['image/', 'video/', 'audio/'];
3
+ function json(body, status) {
4
+ return new Response(JSON.stringify(body, null, 2), {
5
+ status,
6
+ headers: { 'content-type': 'application/json; charset=utf-8' },
7
+ });
8
+ }
9
+ /** Every error names the fix, because the caller here is a shell, not a model. */
10
+ function fail(status, error, hint) {
11
+ return json({ error, hint }, status);
12
+ }
13
+ /**
14
+ * Build the `/upload` route handler. `buildClient` is injected rather than
15
+ * imported so the Worker can construct a client from *this request's* OAuth
16
+ * props (never a shared singleton — see the cross-tenant note in session.ts),
17
+ * and so tests can drive the whole handler against a stub client.
18
+ */
19
+ export function createUploadHandler(buildClient) {
20
+ return async function handleUpload(request, ctx) {
21
+ if (request.method === 'OPTIONS') {
22
+ return new Response(null, { status: 204, headers: { allow: 'POST, OPTIONS' } });
23
+ }
24
+ if (request.method !== 'POST') {
25
+ return fail(405, `Method ${request.method} not allowed on /upload.`, 'POST the raw file bytes as the request body.');
26
+ }
27
+ // The OAuth provider populates props from the validated bearer token. Its
28
+ // absence means the route was reached some other way — refuse rather than
29
+ // fall back to an ambient key, which on a multi-tenant Worker would bill an
30
+ // unauthenticated upload to whoever configured the environment.
31
+ const apiKey = ctx.props?.apiKey;
32
+ if (typeof apiKey !== 'string' || !apiKey) {
33
+ return fail(401, 'Unauthenticated.', 'Send the same Authorization: Bearer <access-token> you use for the /mcp endpoint.');
34
+ }
35
+ const contentType = (request.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
36
+ if (!ACCEPTED_PREFIXES.some((p) => contentType.startsWith(p))) {
37
+ return fail(415, `Unsupported Content-Type "${contentType || '(none)'}".`, 'Set Content-Type to the file\'s media type, e.g. -H "Content-Type: image/jpeg".');
38
+ }
39
+ const declared = request.headers.get('content-length');
40
+ const contentLength = Number(declared);
41
+ if (!declared || !Number.isFinite(contentLength) || contentLength <= 0) {
42
+ return fail(411, 'Content-Length is required (and must be > 0).', 'The Files API resumable upload declares the size up front, so the body cannot be sent chunked. ' +
43
+ 'curl sets this automatically with --data-binary @file.');
44
+ }
45
+ const body = request.body;
46
+ if (!body) {
47
+ return fail(400, 'Request had no body.', 'Send the file bytes as the request body, e.g. --data-binary @photo.jpg.');
48
+ }
49
+ try {
50
+ const file = await buildClient(apiKey).uploadStream(body, contentType, displayNameFor(request, contentType), contentLength);
51
+ return json({
52
+ file_uri: file.name,
53
+ uri: file.uri,
54
+ name: file.name,
55
+ mime_type: file.mimeType,
56
+ ...(file.sizeBytes ? { size_bytes: Number(file.sizeBytes) } : {}),
57
+ expires: file.expirationTime ?? '~48h from upload',
58
+ hint: `Pass "${file.name}" as images_file_uris (or video_url) on any image tool. Gemini retains it ~48h.`,
59
+ }, 201);
60
+ }
61
+ catch (err) {
62
+ // Upstream failures are the interesting case here (bad key, quota, a file
63
+ // over the 2 GB cap), so the message is passed through rather than
64
+ // flattened into "upload failed".
65
+ const message = err instanceof Error ? err.message : String(err);
66
+ return fail(502, `Upload to the Gemini Files API failed: ${message}`, 'Check the API key on this connection is still valid and funded.');
67
+ }
68
+ };
69
+ }
70
+ /**
71
+ * A display name for the upload: an explicit `?name=` wins, else the
72
+ * `X-Filename` header (what a wrapper script would send), else the media type.
73
+ * Cosmetic — the Files API keys on the returned resource name, not this.
74
+ */
75
+ function displayNameFor(request, contentType) {
76
+ const fromQuery = new URL(request.url).searchParams.get('name');
77
+ const raw = fromQuery ?? request.headers.get('x-filename') ?? `upload.${contentType.split('/')[1] || 'bin'}`;
78
+ return raw.replace(/[^\w.\-]+/g, '-').slice(0, 120) || 'upload';
79
+ }
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Single source of the server version. release-please bumps the literal below. */
2
- export const VERSION = '1.0.4'; // x-release-please-version
2
+ export const VERSION = '1.1.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "mcpName": "io.github.chrischall/gemini-mcp",
5
5
  "description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/gemini-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.0.4",
9
+ "version": "1.1.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "1.0.4",
14
+ "version": "1.1.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -91,14 +91,53 @@ Note: Image generation requires a billing-enabled Google Cloud project.
91
91
  ### Image Generation
92
92
  | Tool | Description |
93
93
  |------|-------------|
94
- | `gemini_image_generate(prompt, count?, images?, images_base64?, video_url?, video_path?, google_search?, seed?, filename?, model?, aspect_ratio?, image_size?, thinking_level?, output_dir?, inline?)` | Generate image(s) from a text prompt (optionally image-conditioned via `images`/`images_base64`, or video-conditioned via `video_url`/`video_path`) |
95
- | `gemini_image_edit(prompt, images?, images_base64?, google_search?, seed?, filename?, model?, aspect_ratio?, image_size?, thinking_level?, output_dir?, inline?)` | Edit or compose input image(s) — by **path** (`images`) or **value** (`images_base64`: data URI or raw base64) — with a text instruction. Requires ≥1 input |
96
- | `gemini_image_set(master_prompt, scenes? \| count?, reference_mode?, master_images?, master_images_base64?, google_search?, seed?, basename?, model?, thinking_level?, ...)` | Master image (optionally seeded from a reference photo) plus N consistent images referencing it |
94
+ | `gemini_image_generate(prompt, count?, images_url?, images_file_uris?, images?, images_base64?, video_url?, video_path?, google_search?, seed?, filename?, model?, aspect_ratio?, image_size?, thinking_level?, output_dir?, inline?)` | Generate image(s) from a text prompt (optionally image-conditioned — see **Reference images** below — or video-conditioned via `video_url`/`video_path`) |
95
+ | `gemini_image_edit(prompt, images_url?, images_file_uris?, images?, images_base64?, google_search?, seed?, filename?, model?, aspect_ratio?, image_size?, thinking_level?, output_dir?, inline?)` | Edit or compose input image(s) with a text instruction. Requires ≥1 input from any of the four reference forms |
96
+ | `gemini_image_set(master_prompt, scenes? \| count?, reference_mode?, master_images_url?, master_images_file_uris?, master_images?, master_images_base64?, google_search?, seed?, basename?, model?, thinking_level?, ...)` | Master image (optionally seeded from a reference photo) plus N consistent images referencing it. `master_images_url` / `master_images_file_uris` are resolved **once** and passed to the master *and* every scene |
97
+
98
+ ### Reference images — four ways in, one that costs context
99
+
100
+ | Parameter | Where the bytes travel | Context cost |
101
+ |---|---|---|
102
+ | `images_url` (`master_images_url`) | the **server** downloads the https URL | none |
103
+ | `images_file_uris` (`master_images_file_uris`) | a `files/<id>` reference from `gemini_upload_file` | none |
104
+ | `images` | read off local disk (stdio builds only) | none |
105
+ | `images_base64` | **through the tool-call JSON** | **~14k tokens per JPEG** |
106
+
107
+ **Reach for `images_base64` last.** It costs ~14k tokens per modest photo, and a truncated file
108
+ read produces base64 that still *looks* valid — so the corruption surfaces as a bad generation,
109
+ not an error.
110
+
111
+ - `images_url` accepts public `https://` URLs only (private/loopback/link-local hosts refused,
112
+ every redirect revalidated), must be `Content-Type: image/*`, capped at 15MB. Errors name the
113
+ failing URL. Over 6MB is auto-uploaded to the Files API instead of inlined.
114
+ - `images_file_uris` accepts `files/<id>` or the full uri. Retained **~48h**; reusable across
115
+ any number of calls until then.
116
+ - On stdio, an `images` path referenced **more than once in a session** is auto-uploaded to the
117
+ Files API (keyed on path + mtime + size) so the bytes stop being re-sent.
118
+
119
+ ### Files API
120
+ | Tool | Description |
121
+ |------|-------------|
122
+ | `gemini_upload_file(url? \| data_base64? \| path?, mime_type?, display_name?, confirm?)` | Upload once, get a reusable `files/<id>`. Exactly one source. `url` is fetched by the server (image/video/audio, ≤100MB); `path` is stdio-only and confirm-gated; `data_base64` is the last resort |
123
+ | `gemini_list_files(page_size?)` | List current uploads with MIME types and expiry |
124
+ | `gemini_delete_file(file_uri, confirm)` | Delete an upload before its ~48h expiry |
125
+
126
+ On the **hosted connector** there is also `POST /upload`, behind the same OAuth token as
127
+ `/mcp` — the zero-base64 path for an agent with a shell:
128
+
129
+ ```bash
130
+ curl -X POST https://<connector>/upload \
131
+ -H "Authorization: Bearer $ACCESS_TOKEN" \
132
+ -H "Content-Type: image/jpeg" \
133
+ --data-binary @photo.jpg
134
+ # → {"file_uri":"files/abc123", ...} then: images_file_uris: ["files/abc123"]
135
+ ```
97
136
 
98
137
  ### Multi-turn (Interactions API)
99
138
  | Tool | Description |
100
139
  |------|-------------|
101
- | `gemini_interact(input, previous_interaction_id?, continue_last?, images?, images_base64?, video_url?, video_path?, google_search?, search_types?, model?, aspect_ratio?, image_size?, thinking_level?, filename?, output_dir?, inline?)` | **Preferred tool for iterative refinement.** Generate/edit via Gemini's **Interactions API**. Returns an `interaction_id`; pass it back as `previous_interaction_id` (or set `continue_last: true` to reuse the session's most recent one) to **iteratively refine the same image** conversationally — do NOT start a new interaction or re-upload the image per tweak. Output is **JPEG**. |
140
+ | `gemini_interact(input, previous_interaction_id?, continue_last?, images_url?, images_file_uris?, images?, images_base64?, video_url?, video_path?, google_search?, search_types?, model?, aspect_ratio?, image_size?, thinking_level?, filename?, output_dir?, inline?)` | **Preferred tool for iterative refinement.** Generate/edit via Gemini's **Interactions API**. Returns an `interaction_id`; pass it back as `previous_interaction_id` (or set `continue_last: true` to reuse the session's most recent one) to **iteratively refine the same image** conversationally — do NOT start a new interaction or re-upload the image per tweak. Output is **JPEG**. |
102
141
 
103
142
  ### Video & Music (preview — funded account)
104
143
  | Tool | Description |
@@ -131,6 +170,23 @@ gemini_image_edit(prompt: "make the background blue", images: ["/path/to/image.p
131
170
  → returns path to edited PNG
132
171
  ```
133
172
 
173
+ **Edit an image you only have a URL for (nothing downloads into the conversation):**
174
+ ```
175
+ gemini_image_edit(prompt: "make the background blue", images_url: ["https://example.com/photo.jpg"])
176
+ → the server fetches the URL itself; returns path to edited PNG
177
+ ```
178
+
179
+ **Reuse one photo across many generations (hosted connector, agent with a shell):**
180
+ ```
181
+ $ curl -X POST https://<connector>/upload -H "Authorization: Bearer $TOKEN" \
182
+ -H "Content-Type: image/jpeg" --data-binary @photo.jpg
183
+ → {"file_uri": "files/abc123"}
184
+
185
+ gemini_image_edit(prompt: "make it winter", images_file_uris: ["files/abc123"])
186
+ gemini_image_edit(prompt: "make it sunrise", images_file_uris: ["files/abc123"])
187
+ → two edits, one upload, zero image bytes in context. Valid ~48h.
188
+ ```
189
+
134
190
  **Generate a consistent set (master + scenes):**
135
191
  ```
136
192
  gemini_image_set(