discoclaw 0.8.0 → 0.8.2

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.
@@ -1,3 +1,4 @@
1
+ import { resolveDefaultModel as resolveImagegenDefaultModel } from '../discord/actions-imagegen.js';
1
2
  import { acquireCronLock, releaseCronLock } from './job-lock.js';
2
3
  import { resolveChannel } from '../discord/action-utils.js';
3
4
  import { DiscordTransportClient } from '../discord/transport-client.js';
@@ -211,7 +212,7 @@ export async function executeCronJob(job, ctx) {
211
212
  });
212
213
  // Inject tiered action schema documentation when discord actions are enabled.
213
214
  if (ctx.discordActionsEnabled) {
214
- const actionSelection = buildTieredDiscordActionsPromptSection(cronActionFlags, ctx.botDisplayName, { userText: job.def.prompt });
215
+ const actionSelection = buildTieredDiscordActionsPromptSection(cronActionFlags, ctx.botDisplayName, { userText: job.def.prompt, imagegenDefaultModel: ctx.imagegenCtx ? resolveImagegenDefaultModel(ctx.imagegenCtx) : undefined });
215
216
  if (actionSelection.prompt) {
216
217
  prompt += '\n\n---\n' + actionSelection.prompt;
217
218
  }
@@ -21,6 +21,7 @@ const ROLE_DESCRIPTIONS = {
21
21
  cron: 'Cron auto-tagging and model classification',
22
22
  'cron-exec': 'Default model for cron job execution (overridden by per-job settings)',
23
23
  voice: 'Voice channel AI responses',
24
+ imagegen: 'Default model for image generation',
24
25
  };
25
26
  // ---------------------------------------------------------------------------
26
27
  // Executor
@@ -229,6 +230,15 @@ export function executeConfigAction(action, configCtx) {
229
230
  return { ok: false, error: 'Voice subsystem not configured' };
230
231
  }
231
232
  break;
233
+ case 'imagegen':
234
+ if (bp.imagegenCtx) {
235
+ bp.imagegenCtx.defaultModel = model;
236
+ changes.push(`imagegen → ${model}`);
237
+ }
238
+ else {
239
+ return { ok: false, error: 'Imagegen subsystem not configured' };
240
+ }
241
+ break;
232
242
  default:
233
243
  return { ok: false, error: `Unknown role: ${String(action.role)}` };
234
244
  }
@@ -338,6 +348,21 @@ export function executeConfigAction(action, configCtx) {
338
348
  }
339
349
  }
340
350
  break;
351
+ case 'imagegen':
352
+ if (bp.imagegenCtx) {
353
+ // Restore the fallback-resolved default (env setting or provider-based fallback).
354
+ const igEnvDefault = defaults['imagegen'];
355
+ if (igEnvDefault) {
356
+ bp.imagegenCtx.defaultModel = igEnvDefault;
357
+ }
358
+ else {
359
+ // Clear override so resolveDefaultModel falls back to provider detection.
360
+ bp.imagegenCtx.defaultModel = undefined;
361
+ }
362
+ const resolvedIg = resolveDefaultModel(bp.imagegenCtx);
363
+ resetChanges.push(`imagegen → ${resolvedIg}`);
364
+ }
365
+ break;
341
366
  }
342
367
  // Clear the override marker regardless of whether we had a default.
343
368
  if (configCtx.overrideSources) {
@@ -382,7 +407,7 @@ export function executeConfigAction(action, configCtx) {
382
407
  if (bp.imagegenCtx) {
383
408
  const igModel = resolveDefaultModel(bp.imagegenCtx);
384
409
  const igProvider = resolveProvider(igModel);
385
- rows.push(['imagegen', igModel, `Image generation (${igProvider})`, '']);
410
+ rows.push(['imagegen', igModel, `Image generation (${igProvider})`, ovr('imagegen')]);
386
411
  }
387
412
  else {
388
413
  rows.push(['imagegen', 'setup-required', 'Image generation (setup required)', '']);
@@ -474,7 +499,7 @@ export function configActionsPromptSection() {
474
499
  <discord-action>{"type":"modelSet","role":"chat","model":"sonnet"}</discord-action>
475
500
  <discord-action>{"type":"modelSet","role":"fast","model":"haiku"}</discord-action>
476
501
  \`\`\`
477
- - \`role\` (required): One of \`chat\`, \`plan-run\`, \`fast\`, \`forge-drafter\`, \`forge-auditor\`, \`summary\`, \`cron\`, \`cron-exec\`, \`voice\`.
502
+ - \`role\` (required): One of \`chat\`, \`plan-run\`, \`fast\`, \`forge-drafter\`, \`forge-auditor\`, \`summary\`, \`cron\`, \`cron-exec\`, \`voice\`, \`imagegen\`.
478
503
  - \`model\` (required): Model tier (\`fast\`, \`capable\`, \`deep\`), concrete model name (\`haiku\`, \`sonnet\`, \`opus\`), runtime name (\`openrouter\`, \`gemini\` — for \`chat\` and \`voice\` roles, swaps the active runtime adapter independently), or \`default\` (for cron-exec only, to revert to the startup default for that role). For the \`voice\` role, setting a model name that belongs to a different provider's tier map (e.g. \`sonnet\` while voice is on Gemini) will auto-switch the voice runtime to match.
479
504
 
480
505
  **Roles:**
@@ -489,6 +514,7 @@ export function configActionsPromptSection() {
489
514
  | \`cron\` | Cron auto-tagging and model classification (overrides fast) |
490
515
  | \`cron-exec\` | Default model for cron job execution; per-job overrides (via \`cronUpdate\`) take priority |
491
516
  | \`voice\` | Voice channel AI responses |
517
+ | \`imagegen\` | Default model for image generation |
492
518
 
493
519
  Changes are **persisted** to \`models.json\` and survive restart. Use \`!models reset\` to clear overrides and revert to defaults.
494
520
 
@@ -243,7 +243,7 @@ describe('modelShow imagegen row', () => {
243
243
  if (!result.ok)
244
244
  return;
245
245
  expect(result.summary).toContain('imagegen');
246
- expect(result.summary).toContain('imagen-4.0-generate-001');
246
+ expect(result.summary).toContain('gemini-3.1-flash-image-preview');
247
247
  expect(result.summary).toContain('gemini');
248
248
  });
249
249
  it('respects explicit defaultModel', () => {
@@ -267,7 +267,7 @@ describe('modelShow imagegen row', () => {
267
267
  expect(result.summary).toContain('setup-required');
268
268
  expect(result.summary).toContain('Image generation (setup required)');
269
269
  });
270
- it('defaults to dall-e-3/openai when both apiKey and geminiApiKey are set', () => {
270
+ it('defaults to native Gemini when both apiKey and geminiApiKey are set', () => {
271
271
  const imagegenCtx = { apiKey: 'sk-test', geminiApiKey: 'gk-test' };
272
272
  const ctx = makeCtx({ imagegenCtx });
273
273
  const result = executeConfigAction({ type: 'modelShow' }, ctx);
@@ -275,8 +275,8 @@ describe('modelShow imagegen row', () => {
275
275
  if (!result.ok)
276
276
  return;
277
277
  expect(result.summary).toContain('imagegen');
278
- expect(result.summary).toContain('dall-e-3');
279
- expect(result.summary).toContain('openai');
278
+ expect(result.summary).toContain('gemini-3.1-flash-image-preview');
279
+ expect(result.summary).toContain('gemini');
280
280
  });
281
281
  });
282
282
  // ---------------------------------------------------------------------------
@@ -1,6 +1,7 @@
1
1
  import { AttachmentBuilder } from 'discord.js';
2
2
  import { resolveChannel, findChannelRaw, describeChannelType } from './action-utils.js';
3
3
  import { NO_MENTIONS } from './allowed-mentions.js';
4
+ import { downloadMessageImages, downloadImageUrl } from './image-download.js';
4
5
  const IMAGEGEN_TYPE_MAP = {
5
6
  generateImage: true,
6
7
  };
@@ -15,14 +16,19 @@ const GPT_IMAGE_VALID_SIZES = new Set(['1024x1024', '1024x1792', '1792x1024', 'a
15
16
  const GEMINI_VALID_SIZES = new Set(['1:1', '3:4', '4:3', '9:16', '16:9']);
16
17
  const VALID_QUALITY = new Set(['standard', 'hd']);
17
18
  const DISCORD_MAX_CONTENT = 2000;
19
+ // Progress UX
20
+ export const TYPING_INTERVAL_MS = 8_000;
21
+ export const DOT_CYCLE_INTERVAL_MS = 3_000;
22
+ export const REQUEST_TIMEOUT_MS = 120_000;
23
+ const DOT_STATES = ['On it.', 'On it..', 'On it...'];
18
24
  // ---------------------------------------------------------------------------
19
25
  // Provider resolution
20
26
  // ---------------------------------------------------------------------------
21
27
  export function resolveDefaultModel(imagegenCtx) {
22
28
  if (imagegenCtx.defaultModel)
23
29
  return imagegenCtx.defaultModel;
24
- if (imagegenCtx.geminiApiKey && !imagegenCtx.apiKey)
25
- return 'imagen-4.0-generate-001';
30
+ if (imagegenCtx.geminiApiKey)
31
+ return 'gemini-3.1-flash-image-preview';
26
32
  return 'dall-e-3';
27
33
  }
28
34
  export function resolveProvider(model, explicit) {
@@ -37,7 +43,7 @@ export function resolveProvider(model, explicit) {
37
43
  // ---------------------------------------------------------------------------
38
44
  // API callers
39
45
  // ---------------------------------------------------------------------------
40
- async function callOpenAI(prompt, model, size, quality, apiKey, baseUrl) {
46
+ async function callOpenAI(prompt, model, size, quality, apiKey, baseUrl, signal) {
41
47
  const body = {
42
48
  model,
43
49
  prompt,
@@ -57,6 +63,7 @@ async function callOpenAI(prompt, model, size, quality, apiKey, baseUrl) {
57
63
  'Content-Type': 'application/json',
58
64
  },
59
65
  body: JSON.stringify(body),
66
+ signal,
60
67
  });
61
68
  }
62
69
  catch (err) {
@@ -87,7 +94,7 @@ async function callOpenAI(prompt, model, size, quality, apiKey, baseUrl) {
87
94
  }
88
95
  return { ok: true, b64: imageItem.b64_json };
89
96
  }
90
- async function callGemini(prompt, model, size, geminiApiKey) {
97
+ async function callGemini(prompt, model, size, geminiApiKey, signal) {
91
98
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:predict`;
92
99
  const body = {
93
100
  instances: [{ prompt }],
@@ -105,6 +112,7 @@ async function callGemini(prompt, model, size, geminiApiKey) {
105
112
  'Content-Type': 'application/json',
106
113
  },
107
114
  body: JSON.stringify(body),
115
+ signal,
108
116
  });
109
117
  }
110
118
  catch (err) {
@@ -135,10 +143,15 @@ async function callGemini(prompt, model, size, geminiApiKey) {
135
143
  }
136
144
  return { ok: true, b64 };
137
145
  }
138
- async function callGeminiNative(prompt, model, geminiApiKey) {
146
+ async function callGeminiNative(prompt, model, geminiApiKey, sourceImage, signal) {
139
147
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
148
+ const parts = [];
149
+ if (sourceImage) {
150
+ parts.push({ inlineData: { mimeType: sourceImage.mediaType, data: sourceImage.base64 } });
151
+ }
152
+ parts.push({ text: prompt });
140
153
  const body = {
141
- contents: [{ parts: [{ text: prompt }] }],
154
+ contents: [{ parts }],
142
155
  generationConfig: { responseModalities: ['TEXT', 'IMAGE'] },
143
156
  };
144
157
  let response;
@@ -150,6 +163,7 @@ async function callGeminiNative(prompt, model, geminiApiKey) {
150
163
  'Content-Type': 'application/json',
151
164
  },
152
165
  body: JSON.stringify(body),
166
+ signal,
153
167
  });
154
168
  }
155
169
  catch (err) {
@@ -174,14 +188,57 @@ async function callGeminiNative(prompt, model, geminiApiKey) {
174
188
  catch {
175
189
  return { ok: false, error: 'generateImage: failed to parse API response' };
176
190
  }
177
- const parts = data.candidates?.[0]?.content?.parts ?? [];
178
- const imagePart = parts.find(p => p.inlineData?.mimeType?.startsWith('image/'));
191
+ const responseParts = data.candidates?.[0]?.content?.parts ?? [];
192
+ const imagePart = responseParts.find(p => p.inlineData?.mimeType?.startsWith('image/'));
179
193
  if (!imagePart?.inlineData?.data) {
180
194
  return { ok: false, error: 'generateImage: API returned no image data' };
181
195
  }
182
196
  return { ok: true, b64: imagePart.inlineData.data };
183
197
  }
184
198
  // ---------------------------------------------------------------------------
199
+ // Source image resolution
200
+ // ---------------------------------------------------------------------------
201
+ async function resolveSourceImage(sourceImage, ctx) {
202
+ if (sourceImage.type === 'url') {
203
+ const dlResult = await downloadImageUrl(sourceImage.url);
204
+ if (!dlResult.ok) {
205
+ return { ok: false, error: `generateImage: ${dlResult.error}` };
206
+ }
207
+ return { ok: true, base64: dlResult.image.base64, mediaType: dlResult.image.mediaType };
208
+ }
209
+ const channelId = sourceImage.channelId ?? ctx.channelId;
210
+ const messageId = sourceImage.messageId ?? ctx.messageId;
211
+ const attachmentIndex = sourceImage.attachmentIndex ?? 0;
212
+ let channel;
213
+ try {
214
+ channel = await ctx.client.channels.fetch(channelId);
215
+ }
216
+ catch {
217
+ return { ok: false, error: `generateImage: could not fetch channel "${channelId}"` };
218
+ }
219
+ if (!channel || !('messages' in channel)) {
220
+ return { ok: false, error: `generateImage: channel "${channelId}" is not a text channel` };
221
+ }
222
+ let message;
223
+ try {
224
+ message = await channel.messages.fetch(messageId);
225
+ }
226
+ catch {
227
+ return { ok: false, error: `generateImage: could not fetch message "${messageId}"` };
228
+ }
229
+ const attachments = [...message.attachments.values()];
230
+ if (attachmentIndex < 0 || attachmentIndex >= attachments.length) {
231
+ return { ok: false, error: `generateImage: no attachment at index ${attachmentIndex} (message has ${attachments.length} attachment${attachments.length === 1 ? '' : 's'})` };
232
+ }
233
+ const target = attachments[attachmentIndex];
234
+ const result = await downloadMessageImages([target], 1);
235
+ if (result.images.length === 0) {
236
+ const reason = result.errors.length > 0 ? `: ${result.errors[0]}` : '';
237
+ return { ok: false, error: `generateImage: source image attachment rejected${reason}` };
238
+ }
239
+ return { ok: true, base64: result.images[0].base64, mediaType: result.images[0].mediaType };
240
+ }
241
+ // ---------------------------------------------------------------------------
185
242
  // Executor
186
243
  // ---------------------------------------------------------------------------
187
244
  export async function executeImagegenAction(action, ctx, imagegenCtx) {
@@ -238,38 +295,85 @@ export async function executeImagegenAction(action, ctx, imagegenCtx) {
238
295
  return { ok: false, error: 'generateImage: apiKey is required for OpenAI provider' };
239
296
  }
240
297
  }
241
- // Call provider
242
- let result;
243
- if (provider === 'gemini') {
244
- if (model.startsWith('gemini-')) {
245
- result = await callGeminiNative(action.prompt.trim(), model, imagegenCtx.geminiApiKey);
298
+ // Source image model gate (sync check, before placeholder)
299
+ if (action.sourceImage && !model.startsWith('gemini-')) {
300
+ return { ok: false, error: `generateImage: sourceImage is only supported with native Gemini models (gemini-*), not "${model}"` };
301
+ }
302
+ // --- Progress UX lifecycle ---
303
+ const placeholder = await channel.send({ content: DOT_STATES[0], allowedMentions: NO_MENTIONS });
304
+ channel.sendTyping().catch(() => { });
305
+ let dotIndex = 0;
306
+ const typingInterval = setInterval(() => { channel.sendTyping().catch(() => { }); }, TYPING_INTERVAL_MS);
307
+ const dotInterval = setInterval(() => {
308
+ dotIndex = (dotIndex + 1) % DOT_STATES.length;
309
+ placeholder.edit(DOT_STATES[dotIndex]).catch(() => { });
310
+ }, DOT_CYCLE_INTERVAL_MS);
311
+ const controller = new AbortController();
312
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
313
+ try {
314
+ // Resolve source image if provided
315
+ let resolvedSourceImage;
316
+ if (action.sourceImage) {
317
+ const srcResult = await resolveSourceImage(action.sourceImage, ctx);
318
+ if (!srcResult.ok) {
319
+ return { ok: false, error: srcResult.error };
320
+ }
321
+ resolvedSourceImage = { base64: srcResult.base64, mediaType: srcResult.mediaType };
322
+ }
323
+ // Call provider
324
+ let result;
325
+ if (provider === 'gemini') {
326
+ if (model.startsWith('gemini-')) {
327
+ result = await callGeminiNative(action.prompt.trim(), model, imagegenCtx.geminiApiKey, resolvedSourceImage, controller.signal);
328
+ }
329
+ else {
330
+ result = await callGemini(action.prompt.trim(), model, size, imagegenCtx.geminiApiKey, controller.signal);
331
+ }
246
332
  }
247
333
  else {
248
- result = await callGemini(action.prompt.trim(), model, size, imagegenCtx.geminiApiKey);
334
+ const baseUrl = imagegenCtx.baseUrl ?? 'https://api.openai.com/v1';
335
+ result = await callOpenAI(action.prompt.trim(), model, size, quality, imagegenCtx.apiKey, baseUrl, controller.signal);
249
336
  }
337
+ if (controller.signal.aborted) {
338
+ return { ok: false, error: 'generateImage: request timed out' };
339
+ }
340
+ if (!result.ok) {
341
+ return { ok: false, error: result.error };
342
+ }
343
+ // Stop progress before final Discord mutations
344
+ clearInterval(typingInterval);
345
+ clearInterval(dotInterval);
346
+ clearTimeout(timeoutId);
347
+ await placeholder.delete().catch(() => { });
348
+ const buf = Buffer.from(result.b64, 'base64');
349
+ const attachment = new AttachmentBuilder(buf, { name: 'image-1.png' });
350
+ const sendOpts = { files: [attachment], allowedMentions: NO_MENTIONS };
351
+ if (action.caption) {
352
+ sendOpts.content = action.caption;
353
+ }
354
+ await channel.send(sendOpts);
355
+ return { ok: true, summary: `Generated image posted to #${channel.name}` };
250
356
  }
251
- else {
252
- const baseUrl = imagegenCtx.baseUrl ?? 'https://api.openai.com/v1';
253
- result = await callOpenAI(action.prompt.trim(), model, size, quality, imagegenCtx.apiKey, baseUrl);
254
- }
255
- if (!result.ok) {
256
- return { ok: false, error: result.error };
357
+ catch (err) {
358
+ const msg = err instanceof Error ? err.message : String(err);
359
+ return { ok: false, error: `generateImage: ${msg}` };
257
360
  }
258
- const buf = Buffer.from(result.b64, 'base64');
259
- const attachment = new AttachmentBuilder(buf, { name: 'image-1.png' });
260
- const sendOpts = { files: [attachment], allowedMentions: NO_MENTIONS };
261
- if (action.caption) {
262
- sendOpts.content = action.caption;
361
+ finally {
362
+ clearInterval(typingInterval);
363
+ clearInterval(dotInterval);
364
+ clearTimeout(timeoutId);
365
+ await placeholder.delete().catch(() => { });
263
366
  }
264
- await channel.send(sendOpts);
265
- return { ok: true, summary: `Generated image posted to #${channel.name}` };
266
367
  }
267
368
  }
268
369
  }
269
370
  // ---------------------------------------------------------------------------
270
371
  // Prompt section
271
372
  // ---------------------------------------------------------------------------
272
- export function imagegenActionsPromptSection() {
373
+ export function imagegenActionsPromptSection(resolvedDefaultModel) {
374
+ const modelFieldDoc = resolvedDefaultModel
375
+ ? `- \`model\` (optional): Default is \`${resolvedDefaultModel}\`. Omit this field to use the default; only set it when a different model is explicitly needed. Supported families/examples:`
376
+ : `- \`model\` (optional): Default depends on configuration. Supported families/examples:`;
273
377
  return `### Image Generation
274
378
 
275
379
  **generateImage** — Generate an image and post it to a channel:
@@ -278,7 +382,7 @@ export function imagegenActionsPromptSection() {
278
382
  \`\`\`
279
383
  - \`prompt\` (required): Text description of the image to generate.
280
384
  - \`channel\` (optional): Channel name (with or without #) or channel ID to post the image to. Defaults to the current channel/thread if omitted.
281
- - \`model\` (optional): Model to use. Default depends on configuration (auto-detected from available API keys). Common supported families/examples:
385
+ ${modelFieldDoc}
282
386
  - OpenAI: \`dall-e-3\`, \`gpt-image-1\`
283
387
  - Gemini (Imagen): \`imagen-4.0-generate-001\`, \`imagen-4.0-fast-generate-001\`, \`imagen-4.0-ultra-generate-001\`
284
388
  - Gemini (native): \`gemini-3.1-flash-image-preview\`, \`gemini-3-pro-image-preview\`
@@ -289,5 +393,26 @@ export function imagegenActionsPromptSection() {
289
393
  - Gemini (Imagen): aspect ratios — \`1:1\` (default), \`3:4\`, \`4:3\`, \`9:16\`, \`16:9\`
290
394
  - Gemini (native): size/aspect-ratio params do not apply — omit \`size\` for these models
291
395
  - \`quality\` (optional): \`standard\` (default) or \`hd\` — applies to OpenAI dall-e-3 only.
292
- - \`caption\` (optional): Text message to accompany the image in the channel.`;
396
+ - \`caption\` (optional): Text message to accompany the image in the channel.
397
+ - \`sourceImage\` (optional): Provide a source image for image-to-image editing. **Only supported with native Gemini models** (\`gemini-*\`). Two forms:
398
+ - **Attachment form** — reference a Discord message attachment:
399
+ - \`type\` (required): \`"attachment"\`
400
+ - \`channelId\` (optional): Channel ID of the message containing the image. Defaults to the current channel.
401
+ - \`messageId\` (optional): Message ID containing the image attachment. Defaults to the current message.
402
+ - \`attachmentIndex\` (optional): Zero-based index of the attachment to use. Defaults to \`0\` (first attachment).
403
+ - Example — edit the image from the current message:
404
+ \`\`\`
405
+ <discord-action>{"type":"generateImage","prompt":"Make this image look like a watercolor painting","model":"gemini-3.1-flash-image-preview","sourceImage":{"type":"attachment"}}</discord-action>
406
+ \`\`\`
407
+ - Example — edit an image from a specific message:
408
+ \`\`\`
409
+ <discord-action>{"type":"generateImage","prompt":"Add a sunset sky","model":"gemini-3.1-flash-image-preview","sourceImage":{"type":"attachment","channelId":"123","messageId":"456","attachmentIndex":1}}</discord-action>
410
+ \`\`\`
411
+ - **URL form** — provide a public http(s) image URL directly:
412
+ - \`type\` (required): \`"url"\`
413
+ - \`url\` (required): A public \`http(s)\` image URL (PNG, JPEG, GIF, or WebP).
414
+ - Example:
415
+ \`\`\`
416
+ <discord-action>{"type":"generateImage","prompt":"Make this photo a pencil sketch","model":"gemini-3.1-flash-image-preview","sourceImage":{"type":"url","url":"https://example.com/photo.jpg"}}</discord-action>
417
+ \`\`\``;
293
418
  }