@astrofoundry/pi-astro 0.6.0 → 0.6.1

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.
@@ -40,7 +40,9 @@ function makePi(): { tools: ToolDef[]; registerTool: (t: ToolDef) => void } {
40
40
  return { tools, registerTool: (t) => tools.push(t) };
41
41
  }
42
42
 
43
- function makeCtx(override: Partial<{ hasUI: boolean; confirm: boolean; inputValue: string | undefined }> = {}): {
43
+ type InputResponse = string | undefined;
44
+
45
+ function makeCtx(opts: { hasUI?: boolean; cwd?: string; inputs?: InputResponse[] } = {}): {
44
46
  cwd: string;
45
47
  hasUI: boolean;
46
48
  ui: {
@@ -49,25 +51,27 @@ function makeCtx(override: Partial<{ hasUI: boolean; confirm: boolean; inputValu
49
51
  input: ReturnType<typeof vi.fn>;
50
52
  };
51
53
  } {
52
- const hasUI = override.hasUI ?? true;
54
+ const responses = [...(opts.inputs ?? [])];
53
55
  return {
54
- cwd: "/tmp/cwd",
55
- hasUI,
56
+ cwd: opts.cwd ?? "/tmp/cwd",
57
+ hasUI: opts.hasUI ?? true,
56
58
  ui: {
57
59
  notify: vi.fn(),
58
- confirm: vi.fn().mockResolvedValue(override.confirm ?? true),
59
- input: vi.fn().mockResolvedValue(override.inputValue ?? "abc-key-from-paste"),
60
+ confirm: vi.fn().mockResolvedValue(true),
61
+ input: vi.fn().mockImplementation(() => Promise.resolve(responses.shift())),
60
62
  },
61
63
  };
62
64
  }
63
65
 
64
66
  describe("gemini-image extension", () => {
65
67
  let fakeHome: string;
68
+ let fakeCwd: string;
66
69
  const originalHome = process.env.HOME;
67
70
  const originalKey = process.env.GEMINI_API_KEY;
68
71
 
69
72
  beforeEach(() => {
70
73
  fakeHome = mkdtempSync(join(tmpdir(), "gem-ext-"));
74
+ fakeCwd = mkdtempSync(join(tmpdir(), "gem-cwd-"));
71
75
  process.env.HOME = fakeHome;
72
76
  process.env.GEMINI_API_KEY = "test-key";
73
77
  vi.resetModules();
@@ -77,6 +81,7 @@ describe("gemini-image extension", () => {
77
81
 
78
82
  afterEach(() => {
79
83
  rmSync(fakeHome, { recursive: true, force: true });
84
+ rmSync(fakeCwd, { recursive: true, force: true });
80
85
  process.env.HOME = originalHome;
81
86
  if (originalKey === undefined) delete process.env.GEMINI_API_KEY;
82
87
  else process.env.GEMINI_API_KEY = originalKey;
@@ -89,78 +94,214 @@ describe("gemini-image extension", () => {
89
94
  return { tool: pi.tools[0] };
90
95
  }
91
96
 
92
- it("registers a tool named gemini_image", async () => {
93
- const { tool } = await load();
94
- expect(tool.name).toBe("gemini_image");
95
- });
96
-
97
- it("uses default model gemini-2.5-flash-image and calls generateContent", async () => {
97
+ function pngImageResponse(): void {
98
98
  generateContentMock.mockResolvedValue({
99
99
  candidates: [
100
100
  { content: { parts: [{ inlineData: { mimeType: "image/png", data: "BASE64DATA" } }] } },
101
101
  ],
102
102
  });
103
+ }
104
+
105
+ it("registers tool named gemini_image", async () => {
106
+ const { tool } = await load();
107
+ expect(tool.name).toBe("gemini_image");
108
+ });
109
+
110
+ it("skip_confirm bypasses review and calls generateContent", async () => {
111
+ pngImageResponse();
103
112
  const { tool } = await load();
104
- const ctx = makeCtx();
105
- const res = await tool.execute("t", { prompt: "a cat" }, undefined, undefined, ctx);
113
+ const ctx = makeCtx({ cwd: fakeCwd });
114
+ const res = await tool.execute("t", { prompt: "a cat", skip_confirm: true }, undefined, undefined, ctx);
106
115
  expect(res.details.model).toBe("gemini-2.5-flash-image");
107
- expect(res.content[0]).toMatchObject({ type: "image", data: "BASE64DATA" });
116
+ // ui.input should NOT be called — skip_confirm bypasses the review loop
117
+ expect(ctx.ui.input).not.toHaveBeenCalled();
118
+ });
119
+
120
+ it("images + text returned in content; image AFTER text", async () => {
121
+ pngImageResponse();
122
+ const { tool } = await load();
123
+ const res = await tool.execute(
124
+ "t",
125
+ { prompt: "x", skip_confirm: true },
126
+ undefined,
127
+ undefined,
128
+ makeCtx({ cwd: fakeCwd }),
129
+ );
130
+ expect((res.content as Array<{ type: string }>)[0].type).toBe("text");
131
+ expect((res.content as Array<{ type: string }>)[1].type).toBe("image");
132
+ });
133
+
134
+ it("always saves to <cwd>/.gemini-images by default", async () => {
135
+ pngImageResponse();
136
+ const { tool } = await load();
137
+ const res = await tool.execute(
138
+ "t",
139
+ { prompt: "a-thing", skip_confirm: true },
140
+ undefined,
141
+ undefined,
142
+ makeCtx({ cwd: fakeCwd }),
143
+ );
144
+ const paths = res.details.savedPaths as string[];
145
+ expect(paths).toHaveLength(1);
146
+ expect(paths[0]).toContain(".gemini-images");
147
+ expect(readdirSync(join(fakeCwd, ".gemini-images"))).toHaveLength(1);
148
+ });
149
+
150
+ it("save_to override writes into that directory (resolved against cwd)", async () => {
151
+ pngImageResponse();
152
+ const { tool } = await load();
153
+ const res = await tool.execute(
154
+ "t",
155
+ { prompt: "x", skip_confirm: true, save_to: "out" },
156
+ undefined,
157
+ undefined,
158
+ makeCtx({ cwd: fakeCwd }),
159
+ );
160
+ const paths = res.details.savedPaths as string[];
161
+ expect(paths[0]).toContain(join(fakeCwd, "out"));
162
+ });
163
+
164
+ it("silently saves API key on first paste (no confirm prompt)", async () => {
165
+ delete process.env.GEMINI_API_KEY;
166
+ pngImageResponse();
167
+ const { tool } = await load();
168
+ const ctx = makeCtx({ cwd: fakeCwd, inputs: ["pasted-key"] });
169
+ await tool.execute("t", { prompt: "x", skip_confirm: true }, undefined, undefined, ctx);
170
+ // Only ONE input call (the key paste). confirm never used.
171
+ expect(ctx.ui.input).toHaveBeenCalledTimes(1);
172
+ expect(ctx.ui.confirm).not.toHaveBeenCalled();
173
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/saved/i), "info");
108
174
  });
109
175
 
110
- it("calls Imagen for imagen-4.0-generate-001 with numberOfImages", async () => {
176
+ it("errors when API key missing and no UI available", async () => {
177
+ delete process.env.GEMINI_API_KEY;
178
+ const { tool } = await load();
179
+ const ctx = makeCtx({ hasUI: false, cwd: fakeCwd });
180
+ await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/no interactive UI/);
181
+ });
182
+
183
+ it("errors when user provides empty key at prompt", async () => {
184
+ delete process.env.GEMINI_API_KEY;
185
+ const { tool } = await load();
186
+ const ctx = makeCtx({ cwd: fakeCwd, inputs: [" "] });
187
+ await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/No API key/);
188
+ });
189
+
190
+ it("review loop: empty input confirms and proceeds", async () => {
191
+ pngImageResponse();
192
+ const { tool } = await load();
193
+ const ctx = makeCtx({ cwd: fakeCwd, inputs: [""] });
194
+ await tool.execute("t", { prompt: "hello" }, undefined, undefined, ctx);
195
+ expect(ctx.ui.input).toHaveBeenCalledTimes(1);
196
+ });
197
+
198
+ it("review loop: 'cancel' throws cancelled", async () => {
199
+ const { tool } = await load();
200
+ const ctx = makeCtx({ cwd: fakeCwd, inputs: ["cancel"] });
201
+ await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/cancelled/i);
202
+ });
203
+
204
+ it("review loop: undefined input (escape) throws cancelled", async () => {
205
+ const { tool } = await load();
206
+ const ctx = makeCtx({ cwd: fakeCwd, inputs: [undefined] });
207
+ await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/cancelled/i);
208
+ });
209
+
210
+ it("review loop: tweak '2K' updates size then Enter confirms", async () => {
211
+ pngImageResponse();
212
+ const { tool } = await load();
213
+ const ctx = makeCtx({ cwd: fakeCwd, inputs: ["2K", ""] });
214
+ await tool.execute("t", { prompt: "x" }, undefined, undefined, ctx);
215
+ // Two input calls: one for 2K tweak, one empty confirm
216
+ expect(ctx.ui.input).toHaveBeenCalledTimes(2);
217
+ // The second input's preview message should mention 2K
218
+ const secondPreview = ctx.ui.input.mock.calls[1][1] as string;
219
+ expect(secondPreview).toContain("2K");
220
+ });
221
+
222
+ it("review loop: tweak 'use imagen ultra, 16:9, 3 images' + empty confirms and calls Imagen", async () => {
111
223
  generateImagesMock.mockResolvedValue({
112
224
  generatedImages: [
113
225
  { image: { imageBytes: "IMG1", mimeType: "image/png" } },
114
226
  { image: { imageBytes: "IMG2", mimeType: "image/png" } },
227
+ { image: { imageBytes: "IMG3", mimeType: "image/png" } },
115
228
  ],
116
229
  });
117
230
  const { tool } = await load();
118
- const ctx = makeCtx();
119
- const res = await tool.execute(
120
- "t",
121
- {
122
- prompt: "landscape",
123
- model: "imagen-4.0-generate-001",
124
- number_of_images: 2,
125
- },
126
- undefined,
127
- undefined,
128
- ctx,
129
- );
130
- expect(res.details.images).toBe(2);
131
- const args = generateImagesMock.mock.calls[0][0];
132
- expect(args.config.numberOfImages).toBe(2);
231
+ const ctx = makeCtx({ cwd: fakeCwd, inputs: ["use imagen ultra 16:9 3 images", ""] });
232
+ const res = await tool.execute("t", { prompt: "landscape" }, undefined, undefined, ctx);
233
+ expect(res.details.model).toBe("imagen-4.0-ultra-generate-001");
234
+ expect(res.details.images).toBe(3);
235
+ const cfg = generateImagesMock.mock.calls[0][0].config;
236
+ expect(cfg.numberOfImages).toBe(3);
237
+ expect(cfg.aspectRatio).toBe("16:9");
133
238
  });
134
239
 
135
- it("rejects unknown model", async () => {
240
+ it("review loop: tweak that fails validation is rejected with notify warning, user can re-tweak", async () => {
241
+ pngImageResponse();
242
+ generateImagesMock.mockResolvedValue({
243
+ generatedImages: [{ image: { imageBytes: "IMG", mimeType: "image/png" } }],
244
+ });
245
+ const { tool } = await load();
246
+ // user tries 4K with Imagen → rejected; then switches back and confirms
247
+ const ctx = makeCtx({ cwd: fakeCwd, inputs: ["imagen fast 4K", "imagen fast 2K", ""] });
248
+ await tool.execute("t", { prompt: "x" }, undefined, undefined, ctx);
249
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/Change rejected/), "warning");
250
+ });
251
+
252
+ it("review loop: unrecognized token surfaces as warning", async () => {
253
+ pngImageResponse();
254
+ const { tool } = await load();
255
+ const ctx = makeCtx({ cwd: fakeCwd, inputs: ["frobnicate plz", ""] });
256
+ await tool.execute("t", { prompt: "x" }, undefined, undefined, ctx);
257
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/Didn't understand/), "warning");
258
+ });
259
+
260
+ it("rejects unknown model at call time", async () => {
136
261
  const { tool } = await load();
137
262
  await expect(
138
- tool.execute("t", { prompt: "x", model: "bogus-model" }, undefined, undefined, makeCtx()),
263
+ tool.execute(
264
+ "t",
265
+ { prompt: "x", model: "bogus-model", skip_confirm: true },
266
+ undefined,
267
+ undefined,
268
+ makeCtx({ cwd: fakeCwd }),
269
+ ),
139
270
  ).rejects.toThrow(/Unknown model/);
140
271
  });
141
272
 
142
- it("rejects invalid aspect_ratio for Imagen (e.g. 21:9)", async () => {
273
+ it("rejects invalid aspect_ratio for Imagen at validate time", async () => {
143
274
  const { tool } = await load();
144
275
  await expect(
145
276
  tool.execute(
146
277
  "t",
147
- { prompt: "x", model: "imagen-4.0-generate-001", aspect_ratio: "21:9" },
278
+ {
279
+ prompt: "x",
280
+ model: "imagen-4.0-generate-001",
281
+ aspect_ratio: "21:9",
282
+ skip_confirm: true,
283
+ },
148
284
  undefined,
149
285
  undefined,
150
- makeCtx(),
286
+ makeCtx({ cwd: fakeCwd }),
151
287
  ),
152
288
  ).rejects.toThrow(/aspect_ratio .* Imagen/);
153
289
  });
154
290
 
155
- it("rejects 4K image_size for Imagen", async () => {
291
+ it("rejects 4K for Imagen", async () => {
156
292
  const { tool } = await load();
157
293
  await expect(
158
294
  tool.execute(
159
295
  "t",
160
- { prompt: "x", model: "imagen-4.0-generate-001", image_size: "4K" },
296
+ {
297
+ prompt: "x",
298
+ model: "imagen-4.0-generate-001",
299
+ image_size: "4K",
300
+ skip_confirm: true,
301
+ },
161
302
  undefined,
162
303
  undefined,
163
- makeCtx(),
304
+ makeCtx({ cwd: fakeCwd }),
164
305
  ),
165
306
  ).rejects.toThrow(/image_size .* Imagen/);
166
307
  });
@@ -174,10 +315,11 @@ describe("gemini-image extension", () => {
174
315
  prompt: "x",
175
316
  model: "imagen-4.0-generate-001",
176
317
  input_images: ["/tmp/x.png"],
318
+ skip_confirm: true,
177
319
  },
178
320
  undefined,
179
321
  undefined,
180
- makeCtx(),
322
+ makeCtx({ cwd: fakeCwd }),
181
323
  ),
182
324
  ).rejects.toThrow(/input_images/);
183
325
  });
@@ -187,10 +329,10 @@ describe("gemini-image extension", () => {
187
329
  await expect(
188
330
  tool.execute(
189
331
  "t",
190
- { prompt: "x", negative_prompt: "ugly" },
332
+ { prompt: "x", negative_prompt: "ugly", skip_confirm: true },
191
333
  undefined,
192
334
  undefined,
193
- makeCtx(),
335
+ makeCtx({ cwd: fakeCwd }),
194
336
  ),
195
337
  ).rejects.toThrow(/negative_prompt/);
196
338
  });
@@ -198,135 +340,37 @@ describe("gemini-image extension", () => {
198
340
  it("rejects seed on Gemini native", async () => {
199
341
  const { tool } = await load();
200
342
  await expect(
201
- tool.execute("t", { prompt: "x", seed: 42 }, undefined, undefined, makeCtx()),
343
+ tool.execute("t", { prompt: "x", seed: 42, skip_confirm: true }, undefined, undefined, makeCtx({ cwd: fakeCwd })),
202
344
  ).rejects.toThrow(/seed/);
203
345
  });
204
346
 
205
- it("rejects number_of_images > 1 on Gemini native", async () => {
206
- const { tool } = await load();
207
- await expect(
208
- tool.execute("t", { prompt: "x", number_of_images: 3 }, undefined, undefined, makeCtx()),
209
- ).rejects.toThrow(/number_of_images/);
210
- });
211
-
212
- it("prompts for API key when none configured and saves if confirmed", async () => {
213
- delete process.env.GEMINI_API_KEY;
214
- generateContentMock.mockResolvedValue({
215
- candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "X" } }] } }],
216
- });
217
- const { tool } = await load();
218
- const ctx = makeCtx({ inputValue: "pasted-key" });
219
- await tool.execute("t", { prompt: "x" }, undefined, undefined, ctx);
220
- expect(ctx.ui.input).toHaveBeenCalled();
221
- // Confirm was called for both the save prompt and the cost confirmation
222
- expect(ctx.ui.confirm.mock.calls.length).toBeGreaterThanOrEqual(1);
223
- });
224
-
225
- it("errors when API key missing and no UI available", async () => {
226
- delete process.env.GEMINI_API_KEY;
227
- const { tool } = await load();
228
- const ctx = makeCtx({ hasUI: false });
229
- await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/no interactive UI/);
230
- });
231
-
232
- it("errors when user provides empty key at prompt", async () => {
233
- delete process.env.GEMINI_API_KEY;
234
- const { tool } = await load();
235
- const ctx = makeCtx({ inputValue: " " });
236
- await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/No API key/);
237
- });
238
-
239
- it("cost confirmation: throws when user rejects", async () => {
240
- generateContentMock.mockResolvedValue({
241
- candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "X" } }] } }],
242
- });
243
- const { tool } = await load();
244
- const ctx = makeCtx({ confirm: false });
245
- await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/User cancelled/);
246
- });
247
-
248
- it("skip_confirm bypasses cost prompt", async () => {
249
- generateContentMock.mockResolvedValue({
250
- candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "X" } }] } }],
251
- });
252
- const { tool } = await load();
253
- const ctx = makeCtx();
254
- await tool.execute("t", { prompt: "x", skip_confirm: true }, undefined, undefined, ctx);
255
- // confirm should not be called for cost
256
- expect(ctx.ui.confirm).not.toHaveBeenCalled();
257
- });
258
-
259
- it("save_to writes PNG files and reports paths", async () => {
260
- generateImagesMock.mockResolvedValue({
261
- generatedImages: [
262
- { image: { imageBytes: Buffer.from("fakeimage").toString("base64"), mimeType: "image/png" } },
263
- ],
264
- });
265
- const saveDir = mkdtempSync(join(tmpdir(), "gem-save-"));
266
- const { tool } = await load();
267
- const ctx = makeCtx();
268
- const res = await tool.execute(
269
- "t",
270
- {
271
- prompt: "a-landscape",
272
- model: "imagen-4.0-fast-generate-001",
273
- save_to: saveDir,
274
- },
275
- undefined,
276
- undefined,
277
- ctx,
278
- );
279
- const paths = res.details.savedPaths as string[];
280
- expect(paths).toHaveLength(1);
281
- expect(readdirSync(saveDir)).toHaveLength(1);
282
- rmSync(saveDir, { recursive: true, force: true });
283
- });
284
-
285
347
  it("throws when model returns no images", async () => {
286
348
  generateContentMock.mockResolvedValue({ candidates: [] });
287
349
  const { tool } = await load();
288
- await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, makeCtx())).rejects.toThrow(/no images/);
350
+ await expect(
351
+ tool.execute("t", { prompt: "x", skip_confirm: true }, undefined, undefined, makeCtx({ cwd: fakeCwd })),
352
+ ).rejects.toThrow(/no images/);
289
353
  });
290
354
 
291
355
  it("input_images are read from disk and passed as inlineData", async () => {
292
356
  const sourceDir = mkdtempSync(join(tmpdir(), "gem-input-"));
293
357
  const imgPath = join(sourceDir, "input.png");
294
358
  writeFileSync(imgPath, "binary-image");
295
- generateContentMock.mockResolvedValue({
296
- candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "OUT" } }] } }],
297
- });
359
+ pngImageResponse();
298
360
  const { tool } = await load();
299
- const ctx = makeCtx();
361
+ const ctx = makeCtx({ cwd: fakeCwd });
300
362
  await tool.execute(
301
363
  "t",
302
- { prompt: "edit", input_images: [imgPath] },
364
+ { prompt: "edit", input_images: [imgPath], skip_confirm: true },
303
365
  undefined,
304
366
  undefined,
305
367
  ctx,
306
368
  );
307
369
  const callArgs = generateContentMock.mock.calls[0][0];
308
- // First content is the text, subsequent are inlineData parts
309
370
  expect(callArgs.contents.some((c: Record<string, unknown>) => "inlineData" in c)).toBe(true);
310
371
  rmSync(sourceDir, { recursive: true, force: true });
311
372
  });
312
373
 
313
- it("passes aspect_ratio + image_size into imageConfig for Gemini native", async () => {
314
- generateContentMock.mockResolvedValue({
315
- candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "X" } }] } }],
316
- });
317
- const { tool } = await load();
318
- await tool.execute(
319
- "t",
320
- { prompt: "x", aspect_ratio: "16:9", image_size: "2K" },
321
- undefined,
322
- undefined,
323
- makeCtx(),
324
- );
325
- const cfg = generateContentMock.mock.calls[0][0].config;
326
- expect(cfg.imageConfig.aspectRatio).toBe("16:9");
327
- expect(cfg.imageConfig.imageSize).toBe("2K");
328
- });
329
-
330
374
  it("Imagen config: numberOfImages + negative_prompt + seed + person_generation all pass through", async () => {
331
375
  generateImagesMock.mockResolvedValue({
332
376
  generatedImages: [{ image: { imageBytes: "X", mimeType: "image/png" } }],
@@ -341,10 +385,11 @@ describe("gemini-image extension", () => {
341
385
  negative_prompt: "blurry",
342
386
  seed: 1337,
343
387
  person_generation: "allow_adult",
388
+ skip_confirm: true,
344
389
  },
345
390
  undefined,
346
391
  undefined,
347
- makeCtx(),
392
+ makeCtx({ cwd: fakeCwd }),
348
393
  );
349
394
  const cfg = generateImagesMock.mock.calls[0][0].config;
350
395
  expect(cfg.numberOfImages).toBe(2);
@@ -352,18 +397,4 @@ describe("gemini-image extension", () => {
352
397
  expect(cfg.seed).toBe(1337);
353
398
  expect(cfg.personGeneration).toBe("allow_adult");
354
399
  });
355
-
356
- it("rejects invalid Gemini aspect_ratio", async () => {
357
- const { tool } = await load();
358
- await expect(
359
- tool.execute("t", { prompt: "x", aspect_ratio: "17:3" }, undefined, undefined, makeCtx()),
360
- ).rejects.toThrow(/aspect_ratio .* Gemini native/);
361
- });
362
-
363
- it("rejects invalid Gemini image_size", async () => {
364
- const { tool } = await load();
365
- await expect(
366
- tool.execute("t", { prompt: "x", image_size: "8K" as "4K" }, undefined, undefined, makeCtx()),
367
- ).rejects.toThrow(/image_size/);
368
- });
369
400
  });
@@ -1,4 +1,5 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
2
3
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
3
4
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
4
5
  import { GoogleGenAI } from "@google/genai";
@@ -17,8 +18,11 @@ import {
17
18
  isImagen,
18
19
  type ImageModel,
19
20
  } from "./models.ts";
21
+ import { parseTweaks } from "./parseTweaks.ts";
20
22
  import { estimateCost, formatUsd, hasPricing } from "./pricing.ts";
21
23
 
24
+ const DEFAULT_SAVE_SUBDIR = ".gemini-images";
25
+
22
26
  const params = Type.Object({
23
27
  prompt: Type.String({ description: "Text prompt describing the image to generate." }),
24
28
  model: Type.Optional(
@@ -45,8 +49,10 @@ const params = Type.Object({
45
49
  description: "Gemini native only. Absolute paths to images for editing.",
46
50
  }),
47
51
  ),
48
- save_to: Type.Optional(Type.String({ description: "Optional directory to write generated PNG(s)." })),
49
- skip_confirm: Type.Optional(Type.Boolean({ description: "Skip the cost confirmation prompt." })),
52
+ save_to: Type.Optional(
53
+ Type.String({ description: `Directory to save PNG(s). Default: <cwd>/${DEFAULT_SAVE_SUBDIR}/.` }),
54
+ ),
55
+ skip_confirm: Type.Optional(Type.Boolean({ description: "Skip the review/confirm prompt and call the API directly." })),
50
56
  });
51
57
 
52
58
  type Params = {
@@ -76,11 +82,8 @@ async function ensureApiKey(ctx: ExtensionContext): Promise<string> {
76
82
  throw new Error("No API key provided.");
77
83
  }
78
84
  const key = pasted.trim();
79
-
80
- const save = await ctx.ui.confirm("Save for future use?", "The key will be kept locally for later calls.");
81
- if (save) {
82
- persistApiKey(key);
83
- }
85
+ persistApiKey(key);
86
+ ctx.ui.notify("Gemini API key saved.", "info");
84
87
  return key;
85
88
  }
86
89
 
@@ -123,7 +126,7 @@ function validateForModel(model: ImageModel, p: Params): void {
123
126
  }
124
127
 
125
128
  interface DecodedImage {
126
- data: string; // base64
129
+ data: string;
127
130
  mimeType: string;
128
131
  }
129
132
 
@@ -209,104 +212,163 @@ async function callImagen(
209
212
  }
210
213
 
211
214
  function saveImages(images: DecodedImage[], dir: string, prompt: string): string[] {
212
- const slug = prompt
213
- .toLowerCase()
214
- .replace(/[^a-z0-9]+/g, "-")
215
- .replace(/^-+|-+$/g, "")
216
- .slice(0, 40) || "image";
215
+ mkdirSync(dir, { recursive: true });
216
+ const slug =
217
+ prompt
218
+ .toLowerCase()
219
+ .replace(/[^a-z0-9]+/g, "-")
220
+ .replace(/^-+|-+$/g, "")
221
+ .slice(0, 40) || "image";
217
222
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
218
223
  const paths: string[] = [];
219
224
  for (let i = 0; i < images.length; i++) {
220
225
  const ext = images[i].mimeType === "image/jpeg" ? "jpg" : "png";
221
226
  const base = images.length > 1 ? `${slug}-${ts}-${i + 1}.${ext}` : `${slug}-${ts}.${ext}`;
222
- const full = `${dir.replace(/\/$/, "")}/${base}`;
227
+ const full = join(dir, base);
223
228
  writeFileSync(full, Buffer.from(images[i].data, "base64"));
224
229
  paths.push(full);
225
230
  }
226
231
  return paths;
227
232
  }
228
233
 
234
+ function renderPreview(model: ImageModel, p: Params, estimate: { estimatedUsd: number; breakdown: string }): string {
235
+ const nImages = isImagen(model) ? p.number_of_images ?? 1 : 1;
236
+ const lines: string[] = [];
237
+ lines.push(`Model: ${model}`);
238
+ lines.push(`Prompt: "${p.prompt.length > 160 ? `${p.prompt.slice(0, 160)}…` : p.prompt}"`);
239
+ if (p.image_size) lines.push(`Size: ${p.image_size}`);
240
+ if (p.aspect_ratio) lines.push(`Aspect ratio: ${p.aspect_ratio}`);
241
+ lines.push(`Images: ${nImages}`);
242
+ if (p.negative_prompt) lines.push(`Negative: "${p.negative_prompt.slice(0, 80)}"`);
243
+ if (p.seed !== undefined) lines.push(`Seed: ${p.seed}`);
244
+ if (p.person_generation) lines.push(`People: ${p.person_generation}`);
245
+ lines.push(`Estimated: ~${formatUsd(estimate.estimatedUsd)} (${estimate.breakdown})`);
246
+ return lines.join(" | ");
247
+ }
248
+
249
+ async function reviewAndConfirm(
250
+ ctx: ExtensionContext,
251
+ p: Params,
252
+ ): Promise<Params> {
253
+ let current = { ...p };
254
+ while (true) {
255
+ const model = (current.model as ImageModel | undefined) ?? DEFAULT_MODEL;
256
+ const est = hasPricing(model)
257
+ ? estimateCost({
258
+ model,
259
+ prompt: current.prompt,
260
+ numberOfImages: isImagen(model) ? current.number_of_images ?? 1 : 1,
261
+ imageSize: current.image_size,
262
+ })
263
+ : { estimatedUsd: 0, breakdown: "unknown pricing" };
264
+
265
+ const preview = renderPreview(model, current, est);
266
+ const input = await ctx.ui.input(
267
+ "Review — Enter to confirm, type changes, or 'cancel'",
268
+ preview,
269
+ );
270
+ if (input === undefined) {
271
+ throw new Error("User cancelled image generation.");
272
+ }
273
+ const parsed = parseTweaks(input);
274
+ if (parsed.intent === "cancel") {
275
+ throw new Error("User cancelled image generation.");
276
+ }
277
+ if (parsed.intent === "confirm") {
278
+ return current;
279
+ }
280
+
281
+ // Apply patch. Re-validate before looping.
282
+ const next: Params = { ...current };
283
+ if (parsed.patch.prompt !== undefined) next.prompt = parsed.patch.prompt;
284
+ if (parsed.patch.model !== undefined) next.model = parsed.patch.model;
285
+ if (parsed.patch.aspect_ratio !== undefined) next.aspect_ratio = parsed.patch.aspect_ratio;
286
+ if (parsed.patch.image_size !== undefined) next.image_size = parsed.patch.image_size;
287
+ if (parsed.patch.number_of_images !== undefined) next.number_of_images = parsed.patch.number_of_images;
288
+ if (parsed.patch.negative_prompt !== undefined) next.negative_prompt = parsed.patch.negative_prompt;
289
+ if (parsed.patch.seed !== undefined) next.seed = parsed.patch.seed;
290
+ if (parsed.patch.person_generation !== undefined) next.person_generation = parsed.patch.person_generation;
291
+ if (parsed.patch.save_to !== undefined) next.save_to = parsed.patch.save_to;
292
+
293
+ try {
294
+ validateForModel((next.model as ImageModel | undefined) ?? DEFAULT_MODEL, next);
295
+ current = next;
296
+ } catch (err) {
297
+ const msg = err instanceof Error ? err.message : String(err);
298
+ ctx.ui.notify(`Change rejected: ${msg}`, "warning");
299
+ }
300
+
301
+ if (parsed.unrecognized.length > 0) {
302
+ ctx.ui.notify(
303
+ `Didn't understand: ${parsed.unrecognized.join(" ")}. Try keywords like 2K, 16:9, 'imagen ultra', 'prompt: ...'.`,
304
+ "warning",
305
+ );
306
+ }
307
+ }
308
+ }
309
+
229
310
  export default function geminiImageExtension(pi: ExtensionAPI): void {
230
311
  pi.registerTool({
231
312
  name: "gemini_image",
232
313
  label: "Gemini Image",
233
314
  description:
234
- "Generate or edit images via Google's Gemini (native) and Imagen models. Supports aspect ratios, resolutions, image editing, negative prompts, and seeds.",
315
+ "Generate or edit images via Google Gemini native and Imagen 4 models. Call this tool directly it runs its own review/tweak/confirm gate before hitting the API, so there is no need to ask the user for a prompt or model in advance.",
235
316
  promptGuidelines: [
236
- "Use gemini-2.5-flash-image (default) for general-purpose generation.",
237
- "Use gemini-3-pro-image-preview for studio-quality 4K, complex layouts, or precise text rendering.",
238
- "Use imagen-4.0-*-generate-001 models for batch text-to-image (1-4 per call) or when negative_prompt / seed is needed.",
239
- "Image editing (input_images) is only supported by Gemini native models.",
240
- "4K output is only supported by Gemini native models.",
317
+ "When the user asks to generate or edit an image, CALL THIS TOOL IMMEDIATELY. Do not ask them to confirm the prompt or model first — the tool shows a preview (prompt, model, size, estimated cost) and lets the user tweak or confirm inside its own UI.",
318
+ "By default, enhance the user's casual request into a stronger image-gen prompt (style, lighting, composition, detail qualifiers appropriate to what they asked for). The preview shows the final prompt so the user can catch unwanted enhancements and tweak before confirming.",
319
+ "If the user signals verbatim intent wraps the prompt in backticks, or says 'as-is' / 'verbatim' / 'exactly' / 'don't change' / 'use my words' — pass the prompt UNCHANGED.",
320
+ "NEVER override explicit user parameters. If the user specifies model, resolution, aspect ratio, image count, negative prompt, seed, person-generation policy, or save path, pass those exact values through: 'use pro' → model=gemini-3-pro-image-preview; 'imagen ultra' → imagen-4.0-ultra-generate-001; '4K' → image_size=4K; '16:9' → aspect_ratio=16:9; 'give me 4' → number_of_images=4; 'save to <path>' → save_to=<path>.",
321
+ "Model selection when the user didn't specify one: gemini-2.5-flash-image for general use (default); gemini-3-pro-image-preview for studio-quality 4K, complex layouts, or precise text; imagen-4.0-*-generate-001 for batch (1-4 images per call), negative_prompt, or seeded reproducibility; image editing (input_images) is only supported by Gemini native models.",
241
322
  ],
242
323
  parameters: params,
243
324
  async execute(_toolCallId, input, signal, _onUpdate, ctx) {
244
- const p = input as Params;
245
- const model = (p.model as ImageModel | undefined) ?? DEFAULT_MODEL;
325
+ const requested = input as Params;
326
+ const model = (requested.model as ImageModel | undefined) ?? DEFAULT_MODEL;
246
327
 
247
328
  if (!(ALL_MODELS as readonly string[]).includes(model)) {
248
329
  throw new Error(`Unknown model "${model}". Valid: ${ALL_MODELS.join(", ")}.`);
249
330
  }
250
-
251
- validateForModel(model, p);
331
+ validateForModel(model, requested);
252
332
 
253
333
  const apiKey = await ensureApiKey(ctx);
254
334
 
255
- const numImages = isImagen(model) ? p.number_of_images ?? 1 : 1;
256
-
257
- if (!p.skip_confirm && hasPricing(model) && ctx.hasUI) {
258
- const est = estimateCost({
259
- model,
260
- prompt: p.prompt,
261
- numberOfImages: numImages,
262
- imageSize: p.image_size,
263
- });
264
- const proceed = await ctx.ui.confirm(
265
- "Confirm image generation",
266
- `Model: ${model}\nImages: ${numImages}${p.image_size ? `\nSize: ${p.image_size}` : ""}\nEstimated: ~${formatUsd(est.estimatedUsd)} (${est.breakdown})`,
267
- );
268
- if (!proceed) {
269
- throw new Error("User cancelled image generation.");
270
- }
271
- }
335
+ const final = requested.skip_confirm || !ctx.hasUI
336
+ ? requested
337
+ : await reviewAndConfirm(ctx, requested);
272
338
 
273
- const ai = new GoogleGenAI({ apiKey });
339
+ const finalModel = (final.model as ImageModel | undefined) ?? DEFAULT_MODEL;
274
340
 
275
- const images = isImagen(model)
276
- ? await callImagen(ai, model, p, signal)
277
- : await callGeminiNative(ai, model, p, signal);
341
+ const ai = new GoogleGenAI({ apiKey });
342
+ const images = isImagen(finalModel)
343
+ ? await callImagen(ai, finalModel, final, signal)
344
+ : await callGeminiNative(ai, finalModel, final, signal);
278
345
 
279
346
  if (images.length === 0) {
280
347
  throw new Error("Model returned no images.");
281
348
  }
282
349
 
283
- const savedPaths = p.save_to ? saveImages(images, p.save_to, p.prompt) : [];
350
+ const saveDir = final.save_to ? resolve(ctx.cwd, final.save_to) : join(ctx.cwd, DEFAULT_SAVE_SUBDIR);
351
+ const savedPaths = saveImages(images, saveDir, final.prompt);
284
352
 
285
353
  const summaryLines = [
286
- `Generated ${images.length} image${images.length > 1 ? "s" : ""} with ${model}.`,
287
- `Prompt: "${p.prompt}"`,
354
+ `Generated ${images.length} image${images.length > 1 ? "s" : ""} with ${finalModel}.`,
355
+ `Prompt: "${final.prompt}"`,
356
+ ];
357
+ if (final.aspect_ratio) summaryLines.push(`Aspect ratio: ${final.aspect_ratio}`);
358
+ if (final.image_size) summaryLines.push(`Size: ${final.image_size}`);
359
+ if (final.negative_prompt) summaryLines.push(`Negative: "${final.negative_prompt}"`);
360
+ if (final.person_generation) summaryLines.push(`People: ${final.person_generation}`);
361
+ if (final.seed !== undefined) summaryLines.push(`Seed: ${final.seed}`);
362
+ summaryLines.push(`Saved to:\n ${savedPaths.join("\n ")}`);
363
+
364
+ const content: AgentToolResult<unknown>["content"] = [
365
+ { type: "text", text: summaryLines.join("\n") },
366
+ ...images.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })),
288
367
  ];
289
- if (p.aspect_ratio) summaryLines.push(`Aspect ratio: ${p.aspect_ratio}`);
290
- if (p.image_size) summaryLines.push(`Size: ${p.image_size}`);
291
- if (p.negative_prompt) summaryLines.push(`Negative: "${p.negative_prompt}"`);
292
- if (p.person_generation) summaryLines.push(`People: ${p.person_generation}`);
293
- if (p.seed !== undefined) summaryLines.push(`Seed: ${p.seed}`);
294
- if (savedPaths.length > 0) summaryLines.push(`Saved to:\n ${savedPaths.join("\n ")}`);
295
-
296
- const content: AgentToolResult<unknown>["content"] = images.map((img) => ({
297
- type: "image",
298
- data: img.data,
299
- mimeType: img.mimeType,
300
- }));
301
- content.push({ type: "text", text: summaryLines.join("\n") });
302
368
 
303
369
  return {
304
370
  content,
305
- details: {
306
- model,
307
- images: images.length,
308
- savedPaths,
309
- },
371
+ details: { model: finalModel, images: images.length, savedPaths },
310
372
  };
311
373
  },
312
374
  });
@@ -0,0 +1,119 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseTweaks } from "./parseTweaks.ts";
3
+
4
+ describe("parseTweaks intent", () => {
5
+ it("empty string = confirm", () => {
6
+ expect(parseTweaks("").intent).toBe("confirm");
7
+ expect(parseTweaks(" ").intent).toBe("confirm");
8
+ });
9
+
10
+ it("'yes' / 'y' / 'go' / 'ok' / 'proceed' / 'confirm' = confirm", () => {
11
+ for (const word of ["yes", "y", "go", "ok", "proceed", "confirm", "YES"]) {
12
+ expect(parseTweaks(word).intent).toBe("confirm");
13
+ }
14
+ });
15
+
16
+ it("'cancel' / 'abort' / 'no' / 'n' / 'quit' = cancel", () => {
17
+ for (const word of ["cancel", "abort", "no", "n", "quit", "CANCEL"]) {
18
+ expect(parseTweaks(word).intent).toBe("cancel");
19
+ }
20
+ });
21
+ });
22
+
23
+ describe("parseTweaks patches", () => {
24
+ it("recognizes image sizes 1K / 2K / 4K", () => {
25
+ expect(parseTweaks("2K").patch.image_size).toBe("2K");
26
+ expect(parseTweaks("4K").patch.image_size).toBe("4K");
27
+ expect(parseTweaks("use 1K please").patch.image_size).toBe("1K");
28
+ });
29
+
30
+ it("recognizes aspect ratios", () => {
31
+ expect(parseTweaks("16:9").patch.aspect_ratio).toBe("16:9");
32
+ expect(parseTweaks("4:3").patch.aspect_ratio).toBe("4:3");
33
+ });
34
+
35
+ it("recognizes full model ids", () => {
36
+ expect(parseTweaks("gemini-3-pro-image-preview").patch.model).toBe("gemini-3-pro-image-preview");
37
+ expect(parseTweaks("imagen-4.0-ultra-generate-001").patch.model).toBe("imagen-4.0-ultra-generate-001");
38
+ });
39
+
40
+ it("recognizes model aliases", () => {
41
+ expect(parseTweaks("use pro").patch.model).toBe("gemini-3-pro-image-preview");
42
+ expect(parseTweaks("imagen ultra").patch.model).toBe("imagen-4.0-ultra-generate-001");
43
+ expect(parseTweaks("flash").patch.model).toBe("gemini-2.5-flash-image");
44
+ expect(parseTweaks("imagen fast").patch.model).toBe("imagen-4.0-fast-generate-001");
45
+ });
46
+
47
+ it("longer aliases win over shorter (e.g. 'nano banana pro' over 'pro')", () => {
48
+ expect(parseTweaks("nano banana pro").patch.model).toBe("gemini-3-pro-image-preview");
49
+ });
50
+
51
+ it("recognizes image counts", () => {
52
+ expect(parseTweaks("3 images").patch.number_of_images).toBe(3);
53
+ expect(parseTweaks("give me 4").patch.number_of_images).toBe(4);
54
+ expect(parseTweaks("n=2").patch.number_of_images).toBe(2);
55
+ });
56
+
57
+ it("clamps number_of_images to 1-4", () => {
58
+ expect(parseTweaks("10 images").patch.number_of_images).toBe(4);
59
+ expect(parseTweaks("0 images").patch.number_of_images).toBe(1);
60
+ });
61
+
62
+ it("recognizes prompt: form", () => {
63
+ expect(parseTweaks("prompt: a purple squirrel").patch.prompt).toBe("a purple squirrel");
64
+ });
65
+
66
+ it("recognizes 'use prompt ...' form", () => {
67
+ expect(parseTweaks("use prompt a purple squirrel").patch.prompt).toBe("a purple squirrel");
68
+ });
69
+
70
+ it("strips quotes around prompt", () => {
71
+ expect(parseTweaks(`prompt: "a cat"`).patch.prompt).toBe("a cat");
72
+ expect(parseTweaks(`prompt: 'a cat'`).patch.prompt).toBe("a cat");
73
+ });
74
+
75
+ it("recognizes negative_prompt", () => {
76
+ expect(parseTweaks("negative: blurry").patch.negative_prompt).toBe("blurry");
77
+ expect(parseTweaks("negative_prompt: ugly, blurry").patch.negative_prompt).toBe("ugly, blurry");
78
+ });
79
+
80
+ it("recognizes seed", () => {
81
+ expect(parseTweaks("seed: 42").patch.seed).toBe(42);
82
+ expect(parseTweaks("seed 1337").patch.seed).toBe(1337);
83
+ });
84
+
85
+ it("recognizes save to <path>", () => {
86
+ expect(parseTweaks("save to ./out").patch.save_to).toBe("./out");
87
+ });
88
+
89
+ it("recognizes verbatim / as-is / raw", () => {
90
+ expect(parseTweaks("verbatim").patch.verbatim).toBe(true);
91
+ expect(parseTweaks("as-is").patch.verbatim).toBe(true);
92
+ expect(parseTweaks("raw").patch.verbatim).toBe(true);
93
+ });
94
+
95
+ it("recognizes person_generation tokens", () => {
96
+ expect(parseTweaks("allow_adult").patch.person_generation).toBe("allow_adult");
97
+ expect(parseTweaks("dont_allow").patch.person_generation).toBe("dont_allow");
98
+ });
99
+
100
+ it("combines multiple changes in one utterance", () => {
101
+ const p = parseTweaks("use imagen ultra 16:9 3 images");
102
+ expect(p.patch.model).toBe("imagen-4.0-ultra-generate-001");
103
+ expect(p.patch.aspect_ratio).toBe("16:9");
104
+ expect(p.patch.number_of_images).toBe(3);
105
+ });
106
+
107
+ it("unrecognized tokens are returned", () => {
108
+ const p = parseTweaks("frobnicate plz");
109
+ expect(p.unrecognized.length).toBeGreaterThan(0);
110
+ });
111
+
112
+ it("filler words 'use', 'and', 'with' don't count as unrecognized", () => {
113
+ const p = parseTweaks("use 2K and 16:9 with allow_adult");
114
+ expect(p.unrecognized).toEqual([]);
115
+ expect(p.patch.image_size).toBe("2K");
116
+ expect(p.patch.aspect_ratio).toBe("16:9");
117
+ expect(p.patch.person_generation).toBe("allow_adult");
118
+ });
119
+ });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Parse a free-text review/tweak utterance into partial parameter updates.
3
+ *
4
+ * Intentionally conservative: we recognize a known vocabulary (models,
5
+ * aspect ratios, sizes, numeric counts, person-gen tokens, and explicit
6
+ * `prompt:` / `negative:` / `seed:` / `save to <path>` phrases). Unknown
7
+ * tokens are returned in `unrecognized` so the caller can surface them.
8
+ */
9
+
10
+ import {
11
+ ALL_MODELS,
12
+ GEMINI_ASPECT_RATIOS,
13
+ IMAGEN_ASPECT_RATIOS,
14
+ GEMINI_IMAGE_SIZES,
15
+ PERSON_GENERATION_VALUES,
16
+ type ImageModel,
17
+ } from "./models.ts";
18
+
19
+ export interface TweakPatch {
20
+ prompt?: string;
21
+ model?: ImageModel;
22
+ aspect_ratio?: string;
23
+ image_size?: "1K" | "2K" | "4K";
24
+ number_of_images?: number;
25
+ negative_prompt?: string;
26
+ person_generation?: (typeof PERSON_GENERATION_VALUES)[number];
27
+ seed?: number;
28
+ save_to?: string;
29
+ verbatim?: boolean;
30
+ }
31
+
32
+ export interface ParseOutcome {
33
+ patch: TweakPatch;
34
+ unrecognized: string[];
35
+ intent: "confirm" | "cancel" | "tweak";
36
+ }
37
+
38
+ const MODEL_ALIASES: Record<string, ImageModel> = {
39
+ flash: "gemini-2.5-flash-image",
40
+ "flash image": "gemini-2.5-flash-image",
41
+ "gemini flash": "gemini-2.5-flash-image",
42
+ "2.5 flash": "gemini-2.5-flash-image",
43
+ "nano banana 2": "gemini-3.1-flash-image-preview",
44
+ "nano banana": "gemini-3.1-flash-image-preview",
45
+ "3.1 flash": "gemini-3.1-flash-image-preview",
46
+ pro: "gemini-3-pro-image-preview",
47
+ "pro model": "gemini-3-pro-image-preview",
48
+ "gemini pro": "gemini-3-pro-image-preview",
49
+ "3 pro": "gemini-3-pro-image-preview",
50
+ "nano banana pro": "gemini-3-pro-image-preview",
51
+ imagen: "imagen-4.0-generate-001",
52
+ "imagen standard": "imagen-4.0-generate-001",
53
+ "imagen 4": "imagen-4.0-generate-001",
54
+ "imagen fast": "imagen-4.0-fast-generate-001",
55
+ "imagen 4 fast": "imagen-4.0-fast-generate-001",
56
+ "imagen ultra": "imagen-4.0-ultra-generate-001",
57
+ "imagen 4 ultra": "imagen-4.0-ultra-generate-001",
58
+ };
59
+
60
+ const ALL_ASPECT_RATIOS = [
61
+ ...new Set([...GEMINI_ASPECT_RATIOS, ...IMAGEN_ASPECT_RATIOS]),
62
+ ];
63
+ const ALL_SIZES = [...GEMINI_IMAGE_SIZES];
64
+
65
+ function trimQuotes(s: string): string {
66
+ return s.replace(/^['"`](.*)['"`]$/s, "$1").trim();
67
+ }
68
+
69
+ export function parseTweaks(raw: string): ParseOutcome {
70
+ const patch: TweakPatch = {};
71
+ const unrecognized: string[] = [];
72
+ const trimmed = raw.trim();
73
+
74
+ if (trimmed === "") {
75
+ return { patch, unrecognized, intent: "confirm" };
76
+ }
77
+ if (/^(y|yes|ok|go|confirm|proceed)$/i.test(trimmed)) {
78
+ return { patch, unrecognized, intent: "confirm" };
79
+ }
80
+ if (/^(n|no|cancel|abort|quit|exit)$/i.test(trimmed)) {
81
+ return { patch, unrecognized, intent: "cancel" };
82
+ }
83
+
84
+ let rest = trimmed;
85
+
86
+ const extract = (pattern: RegExp, onMatch: (m: RegExpExecArray) => void): void => {
87
+ const match = pattern.exec(rest);
88
+ if (match) {
89
+ onMatch(match);
90
+ rest = (rest.slice(0, match.index) + rest.slice(match.index + match[0].length)).trim();
91
+ }
92
+ };
93
+
94
+ // prompt: <rest> / use prompt <rest> (greedy — takes rest of string unless another keyword follows)
95
+ extract(
96
+ /(?:^|\s)(?:use\s+prompt\s*[:=]?|prompt\s*[:=])\s*(.+?)(?=(?:\s+(?:model|size|image_size|aspect|ratio|images?|n=|count|negative|seed|save|person|verbatim|raw|as-?is|dont_allow|allow_adult|allow_all)\b)|$)/i,
97
+ (m) => {
98
+ patch.prompt = trimQuotes(m[1]);
99
+ },
100
+ );
101
+
102
+ extract(/(?:^|\s)(?:negative(?:_prompt)?)\s*[:=]\s*(.+?)(?=$|\s+(?:model|size|aspect|ratio|images?|n=|count|seed|save|person|verbatim|raw)\b)/i, (m) => {
103
+ patch.negative_prompt = trimQuotes(m[1]);
104
+ });
105
+
106
+ extract(/(?:^|\s)seed\s*[:=]?\s*(-?\d+)/i, (m) => {
107
+ patch.seed = Number(m[1]);
108
+ });
109
+
110
+ extract(/(?:^|\s)save\s+to\s+(\S+)/i, (m) => {
111
+ patch.save_to = m[1];
112
+ });
113
+
114
+ extract(/(?:^|\s)(verbatim|as-?is|raw)\b/i, () => {
115
+ patch.verbatim = true;
116
+ });
117
+
118
+ extract(/(?:^|\s)(\d+)\s*(?:images?|imgs?)\b/i, (m) => {
119
+ patch.number_of_images = Math.max(1, Math.min(4, Number(m[1])));
120
+ });
121
+ extract(/(?:^|\s)(?:n|count)\s*[:=]\s*(\d+)/i, (m) => {
122
+ patch.number_of_images = Math.max(1, Math.min(4, Number(m[1])));
123
+ });
124
+ extract(/(?:^|\s)give\s+me\s+(\d+)\b/i, (m) => {
125
+ patch.number_of_images = Math.max(1, Math.min(4, Number(m[1])));
126
+ });
127
+
128
+ for (const size of ALL_SIZES) {
129
+ const re = new RegExp(`(?:^|\\s)${size}(?=\\s|$)`, "i");
130
+ const m = re.exec(rest);
131
+ if (m) {
132
+ patch.image_size = size;
133
+ rest = (rest.slice(0, m.index) + rest.slice(m.index + m[0].length)).trim();
134
+ break;
135
+ }
136
+ }
137
+
138
+ for (const ratio of ALL_ASPECT_RATIOS) {
139
+ const re = new RegExp(`(?:^|\\s)${ratio.replace(":", "\\s*:\\s*")}(?=\\s|$)`);
140
+ const m = re.exec(rest);
141
+ if (m) {
142
+ patch.aspect_ratio = ratio;
143
+ rest = (rest.slice(0, m.index) + rest.slice(m.index + m[0].length)).trim();
144
+ break;
145
+ }
146
+ }
147
+
148
+ for (const pg of PERSON_GENERATION_VALUES) {
149
+ const re = new RegExp(`(?:^|\\s)${pg}(?=\\s|$)`, "i");
150
+ const m = re.exec(rest);
151
+ if (m) {
152
+ patch.person_generation = pg;
153
+ rest = (rest.slice(0, m.index) + rest.slice(m.index + m[0].length)).trim();
154
+ break;
155
+ }
156
+ }
157
+
158
+ // Full model id exact match
159
+ for (const model of ALL_MODELS) {
160
+ const idx = rest.toLowerCase().indexOf(model.toLowerCase());
161
+ if (idx !== -1) {
162
+ patch.model = model;
163
+ rest = (rest.slice(0, idx) + rest.slice(idx + model.length)).trim();
164
+ break;
165
+ }
166
+ }
167
+
168
+ // Model aliases (only if model not set yet) — check longest first so "nano banana pro" beats "pro"
169
+ if (!patch.model) {
170
+ const aliases = Object.keys(MODEL_ALIASES).sort((a, b) => b.length - a.length);
171
+ for (const alias of aliases) {
172
+ const re = new RegExp(`(?:^|\\s)${alias.replace(/\s+/g, "\\s+")}(?=\\s|$|[.,])`, "i");
173
+ const m = re.exec(rest);
174
+ if (m) {
175
+ patch.model = MODEL_ALIASES[alias];
176
+ rest = (rest.slice(0, m.index) + rest.slice(m.index + m[0].length)).trim();
177
+ break;
178
+ }
179
+ }
180
+ }
181
+
182
+ // Split leftover into whitespace-separated tokens; non-filler ones are "unrecognized".
183
+ const leftover = rest.split(/\s+/).filter((t) => t.length > 0 && !/^(use|and|with|,)$/i.test(t));
184
+ if (leftover.length > 0) unrecognized.push(...leftover);
185
+
186
+ return {
187
+ patch,
188
+ unrecognized,
189
+ intent: Object.keys(patch).length > 0 ? "tweak" : "tweak",
190
+ };
191
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Personal pi customizations (extensions, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"