discoclaw 0.8.0 → 0.8.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.
@@ -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
 
@@ -269,7 +269,10 @@ export async function executeImagegenAction(action, ctx, imagegenCtx) {
269
269
  // ---------------------------------------------------------------------------
270
270
  // Prompt section
271
271
  // ---------------------------------------------------------------------------
272
- export function imagegenActionsPromptSection() {
272
+ export function imagegenActionsPromptSection(resolvedDefaultModel) {
273
+ const modelFieldDoc = resolvedDefaultModel
274
+ ? `- \`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:`
275
+ : `- \`model\` (optional): Default depends on configuration. Supported families/examples:`;
273
276
  return `### Image Generation
274
277
 
275
278
  **generateImage** — Generate an image and post it to a channel:
@@ -278,7 +281,7 @@ export function imagegenActionsPromptSection() {
278
281
  \`\`\`
279
282
  - \`prompt\` (required): Text description of the image to generate.
280
283
  - \`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:
284
+ ${modelFieldDoc}
282
285
  - OpenAI: \`dall-e-3\`, \`gpt-image-1\`
283
286
  - Gemini (Imagen): \`imagen-4.0-generate-001\`, \`imagen-4.0-fast-generate-001\`, \`imagen-4.0-ultra-generate-001\`
284
287
  - Gemini (native): \`gemini-3.1-flash-image-preview\`, \`gemini-3-pro-image-preview\`
@@ -84,6 +84,16 @@ describe('imagegenActionsPromptSection', () => {
84
84
  expect(section).toContain('channel');
85
85
  expect(section).toContain('dall-e-3');
86
86
  });
87
+ it('includes resolved default model when provided', () => {
88
+ const section = imagegenActionsPromptSection('gpt-image-1');
89
+ expect(section).toContain('Default is `gpt-image-1`');
90
+ expect(section).toContain('Omit this field to use the default');
91
+ });
92
+ it('omits default model note when not provided', () => {
93
+ const section = imagegenActionsPromptSection();
94
+ expect(section).not.toContain('Default is `');
95
+ expect(section).toContain('Default depends on configuration');
96
+ });
87
97
  });
88
98
  describe('resolveProvider', () => {
89
99
  it('detects gemini from imagen- prefix', () => {
@@ -4,6 +4,22 @@ import * as path from 'node:path';
4
4
  import { resolveChannel, fmtTime, findChannelRaw, describeChannelType } from './action-utils.js';
5
5
  import { NO_MENTIONS } from './allowed-mentions.js';
6
6
  import { isPathUnderRoots } from '../runtime/tools/path-security.js';
7
+ /** Serialize Discord attachments into compact image metadata lines. */
8
+ function formatAttachments(attachments) {
9
+ if (!attachments)
10
+ return '';
11
+ const parts = [];
12
+ for (const a of attachments) {
13
+ const tag = [];
14
+ if (a.contentType)
15
+ tag.push(a.contentType);
16
+ if (a.width && a.height)
17
+ tag.push(`${a.width}x${a.height}`);
18
+ const detail = tag.length ? ` (${tag.join(', ')})` : '';
19
+ parts.push(`[Attachment: ${a.name || 'unknown'}${detail}]`);
20
+ }
21
+ return parts.join(' ');
22
+ }
7
23
  /** Serialize Discord embeds into a compact text representation. */
8
24
  function formatEmbeds(embeds, truncate) {
9
25
  if (!embeds?.length)
@@ -240,8 +256,17 @@ export async function executeMessagingAction(action, ctx, requesterMember) {
240
256
  const time = fmtTime(m.createdAt);
241
257
  const content = m.content || '';
242
258
  const embed = formatEmbeds(m.embeds, 200);
243
- const combined = content || embed || '(no text)';
244
- const text = (content && embed ? `${content} [Embed: ${embed}]` : combined).slice(0, 300);
259
+ const attach = formatAttachments(m.attachments?.values());
260
+ const parts = [];
261
+ if (content && embed)
262
+ parts.push(`${content} [Embed: ${embed}]`);
263
+ else if (content)
264
+ parts.push(content);
265
+ else if (embed)
266
+ parts.push(embed);
267
+ if (attach)
268
+ parts.push(attach);
269
+ const text = (parts.length ? parts.join(' ') : '(no text)').slice(0, 300);
245
270
  return `[${author}] ${text} (${time}, id:${m.id})`;
246
271
  });
247
272
  return { ok: true, summary: `Messages in #${channel.name}:\n${lines.join('\n')}` };
@@ -263,8 +288,17 @@ export async function executeMessagingAction(action, ctx, requesterMember) {
263
288
  const time = fmtTime(message.createdAt);
264
289
  const contentText = message.content || '';
265
290
  const embedText = formatEmbeds(message.embeds, action.full ? undefined : 2000);
266
- const body = contentText || embedText || '(no text)';
267
- const combined = contentText && embedText ? `${contentText}\n[Embeds]\n${embedText}` : body;
291
+ const attachText = formatAttachments(message.attachments?.values());
292
+ const parts = [];
293
+ if (contentText && embedText)
294
+ parts.push(`${contentText}\n[Embeds]\n${embedText}`);
295
+ else if (contentText)
296
+ parts.push(contentText);
297
+ else if (embedText)
298
+ parts.push(embedText);
299
+ if (attachText)
300
+ parts.push(attachText);
301
+ const combined = parts.length ? parts.join('\n') : '(no text)';
268
302
  const text = action.full ? combined : combined.slice(0, 2000);
269
303
  return { ok: true, summary: `[${author}]: ${text} (${time}, #${messageChannel.name}, id:${message.id})` };
270
304
  }
@@ -560,6 +594,7 @@ export function messagingActionsPromptSection() {
560
594
  - \`channel\` (required): Channel name or ID.
561
595
  - \`limit\` (optional): 1–20, default 10.
562
596
  - \`before\` (optional): Message ID to fetch messages before.
597
+ - Summaries include image attachment metadata (filename, content type, dimensions) when present.
563
598
 
564
599
  **fetchMessage** — Fetch a single message by ID:
565
600
  \`\`\`
@@ -567,6 +602,7 @@ export function messagingActionsPromptSection() {
567
602
  \`\`\`
568
603
  - Use \`fetchMessage\` to retrieve the full content of any Discord message by channel and message ID. This works for pinned prompts, status messages, and any other message you have the IDs for.
569
604
  - \`full\` (optional): When true, returns the complete message content without truncation. Default: false (content truncated to 2000 chars).
605
+ - Includes image attachment metadata (filename, content type, dimensions) when present.
570
606
 
571
607
  **editMessage** — Edit a bot message:
572
608
  \`\`\`
@@ -51,12 +51,27 @@ function makeEmbed(overrides = {}) {
51
51
  footer: overrides.footer ?? null,
52
52
  };
53
53
  }
54
+ function makeAttachment(overrides = {}) {
55
+ return {
56
+ name: overrides.name ?? 'image.png',
57
+ contentType: overrides.contentType ?? 'image/png',
58
+ width: overrides.width ?? null,
59
+ height: overrides.height ?? null,
60
+ ...overrides,
61
+ };
62
+ }
63
+ function makeAttachmentsCollection(attachments) {
64
+ return {
65
+ values() { return attachments[Symbol.iterator](); },
66
+ };
67
+ }
54
68
  function makeMockMessage(id, overrides = {}) {
55
69
  const { author: authorName, ...rest } = overrides;
56
70
  return {
57
71
  id,
58
72
  content: rest.content ?? 'Hello',
59
73
  embeds: rest.embeds ?? [],
74
+ attachments: rest.attachments ?? undefined,
60
75
  author: { username: authorName ?? 'testuser' },
61
76
  createdAt: new Date('2025-01-15T12:00:00Z'),
62
77
  createdTimestamp: new Date('2025-01-15T12:00:00Z').getTime(),
@@ -445,6 +460,91 @@ describe('readMessages', () => {
445
460
  await executeMessagingAction({ type: 'readMessages', channel: '#general', limit: 50 }, ctx);
446
461
  expect(ch.messages.fetch).toHaveBeenCalledWith({ limit: 20 });
447
462
  });
463
+ it('shows attachment metadata for image-only message', async () => {
464
+ const msg = makeMockMessage('m1', {
465
+ content: '',
466
+ author: 'alice',
467
+ attachments: makeAttachmentsCollection([
468
+ makeAttachment({ name: 'photo.png', contentType: 'image/png', width: 1920, height: 1080 }),
469
+ ]),
470
+ });
471
+ const fetchedMessages = new Map([['m1', msg]]);
472
+ const ch = makeMockChannel({ name: 'general', fetchedMessages });
473
+ const ctx = makeCtx([ch]);
474
+ const result = await executeMessagingAction({ type: 'readMessages', channel: '#general', limit: 5 }, ctx);
475
+ expect(result.ok).toBe(true);
476
+ const summary = result.summary;
477
+ expect(summary).toContain('[Attachment: photo.png (image/png, 1920x1080)]');
478
+ expect(summary).not.toContain('(no text)');
479
+ });
480
+ it('shows content plus attachment metadata for mixed message', async () => {
481
+ const msg = makeMockMessage('m1', {
482
+ content: 'Check this out',
483
+ author: 'alice',
484
+ attachments: makeAttachmentsCollection([
485
+ makeAttachment({ name: 'screenshot.jpg', contentType: 'image/jpeg', width: 800, height: 600 }),
486
+ ]),
487
+ });
488
+ const fetchedMessages = new Map([['m1', msg]]);
489
+ const ch = makeMockChannel({ name: 'general', fetchedMessages });
490
+ const ctx = makeCtx([ch]);
491
+ const result = await executeMessagingAction({ type: 'readMessages', channel: '#general', limit: 5 }, ctx);
492
+ expect(result.ok).toBe(true);
493
+ const summary = result.summary;
494
+ expect(summary).toContain('Check this out');
495
+ expect(summary).toContain('[Attachment: screenshot.jpg (image/jpeg, 800x600)]');
496
+ });
497
+ it('shows embed plus attachment metadata for embed+image message', async () => {
498
+ const msg = makeMockMessage('m1', {
499
+ content: '',
500
+ author: 'alice',
501
+ embeds: [makeEmbed({ title: 'Link Preview' })],
502
+ attachments: makeAttachmentsCollection([
503
+ makeAttachment({ name: 'thumb.png', contentType: 'image/png' }),
504
+ ]),
505
+ });
506
+ const fetchedMessages = new Map([['m1', msg]]);
507
+ const ch = makeMockChannel({ name: 'general', fetchedMessages });
508
+ const ctx = makeCtx([ch]);
509
+ const result = await executeMessagingAction({ type: 'readMessages', channel: '#general', limit: 5 }, ctx);
510
+ expect(result.ok).toBe(true);
511
+ const summary = result.summary;
512
+ expect(summary).toContain('Title: Link Preview');
513
+ expect(summary).toContain('[Attachment: thumb.png (image/png)]');
514
+ });
515
+ it('degrades cleanly when attachment fields are missing', async () => {
516
+ const msg = makeMockMessage('m1', {
517
+ content: '',
518
+ author: 'alice',
519
+ attachments: makeAttachmentsCollection([
520
+ makeAttachment({ name: null, contentType: null, width: null, height: null }),
521
+ ]),
522
+ });
523
+ const fetchedMessages = new Map([['m1', msg]]);
524
+ const ch = makeMockChannel({ name: 'general', fetchedMessages });
525
+ const ctx = makeCtx([ch]);
526
+ const result = await executeMessagingAction({ type: 'readMessages', channel: '#general', limit: 5 }, ctx);
527
+ expect(result.ok).toBe(true);
528
+ const summary = result.summary;
529
+ expect(summary).toContain('[Attachment: unknown]');
530
+ expect(summary).not.toContain('null');
531
+ });
532
+ it('shows attachment with dimensions but no content type', async () => {
533
+ const msg = makeMockMessage('m1', {
534
+ content: '',
535
+ author: 'alice',
536
+ attachments: makeAttachmentsCollection([
537
+ makeAttachment({ name: 'photo.webp', contentType: null, width: 640, height: 480 }),
538
+ ]),
539
+ });
540
+ const fetchedMessages = new Map([['m1', msg]]);
541
+ const ch = makeMockChannel({ name: 'general', fetchedMessages });
542
+ const ctx = makeCtx([ch]);
543
+ const result = await executeMessagingAction({ type: 'readMessages', channel: '#general', limit: 5 }, ctx);
544
+ expect(result.ok).toBe(true);
545
+ const summary = result.summary;
546
+ expect(summary).toContain('[Attachment: photo.webp (640x480)]');
547
+ });
448
548
  });
449
549
  describe('fetchMessage', () => {
450
550
  it('fetches and formats a single message', async () => {
@@ -630,6 +730,75 @@ describe('fetchMessage', () => {
630
730
  const summary = result.summary;
631
731
  expect(summary).not.toContain('x'.repeat(3000));
632
732
  });
733
+ it('shows attachment metadata for image-only message', async () => {
734
+ const msg = makeMockMessage('msg1', {
735
+ content: '',
736
+ author: 'alice',
737
+ attachments: makeAttachmentsCollection([
738
+ makeAttachment({ name: 'photo.png', contentType: 'image/png', width: 1920, height: 1080 }),
739
+ ]),
740
+ });
741
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
742
+ ch.messages.fetch = vi.fn(async () => msg);
743
+ const ctx = makeCtx([ch]);
744
+ const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1' }, ctx);
745
+ expect(result.ok).toBe(true);
746
+ const summary = result.summary;
747
+ expect(summary).toContain('[Attachment: photo.png (image/png, 1920x1080)]');
748
+ expect(summary).not.toContain('(no text)');
749
+ });
750
+ it('shows content plus attachment metadata for mixed message', async () => {
751
+ const msg = makeMockMessage('msg1', {
752
+ content: 'Look at this',
753
+ author: 'alice',
754
+ attachments: makeAttachmentsCollection([
755
+ makeAttachment({ name: 'screenshot.jpg', contentType: 'image/jpeg', width: 800, height: 600 }),
756
+ ]),
757
+ });
758
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
759
+ ch.messages.fetch = vi.fn(async () => msg);
760
+ const ctx = makeCtx([ch]);
761
+ const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1' }, ctx);
762
+ expect(result.ok).toBe(true);
763
+ const summary = result.summary;
764
+ expect(summary).toContain('Look at this');
765
+ expect(summary).toContain('[Attachment: screenshot.jpg (image/jpeg, 800x600)]');
766
+ });
767
+ it('shows embed plus attachment metadata for embed+image message', async () => {
768
+ const msg = makeMockMessage('msg1', {
769
+ content: '',
770
+ author: 'alice',
771
+ embeds: [makeEmbed({ title: 'Link Preview' })],
772
+ attachments: makeAttachmentsCollection([
773
+ makeAttachment({ name: 'thumb.png', contentType: 'image/png' }),
774
+ ]),
775
+ });
776
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
777
+ ch.messages.fetch = vi.fn(async () => msg);
778
+ const ctx = makeCtx([ch]);
779
+ const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1' }, ctx);
780
+ expect(result.ok).toBe(true);
781
+ const summary = result.summary;
782
+ expect(summary).toContain('Title: Link Preview');
783
+ expect(summary).toContain('[Attachment: thumb.png (image/png)]');
784
+ });
785
+ it('degrades cleanly when attachment fields are missing', async () => {
786
+ const msg = makeMockMessage('msg1', {
787
+ content: '',
788
+ author: 'alice',
789
+ attachments: makeAttachmentsCollection([
790
+ makeAttachment({ name: null, contentType: null, width: null, height: null }),
791
+ ]),
792
+ });
793
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
794
+ ch.messages.fetch = vi.fn(async () => msg);
795
+ const ctx = makeCtx([ch]);
796
+ const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1' }, ctx);
797
+ expect(result.ok).toBe(true);
798
+ const summary = result.summary;
799
+ expect(summary).toContain('[Attachment: unknown]');
800
+ expect(summary).not.toContain('null');
801
+ });
633
802
  });
634
803
  describe('editMessage', () => {
635
804
  it('edits a message', async () => {
@@ -599,7 +599,7 @@ function isActionSchemaCategoryEnabled(flags, category) {
599
599
  case 'spawn': return Boolean(flags.spawn);
600
600
  }
601
601
  }
602
- function renderActionSchemaCategorySection(category) {
602
+ function renderActionSchemaCategorySection(category, renderCtx) {
603
603
  switch (category) {
604
604
  case 'messaging':
605
605
  return `${messagingActionsPromptSection()}\n\n${reactionPromptSection()}`;
@@ -628,7 +628,7 @@ function renderActionSchemaCategorySection(category) {
628
628
  case 'loop':
629
629
  return loopActionsPromptSection();
630
630
  case 'imagegen':
631
- return imagegenActionsPromptSection();
631
+ return imagegenActionsPromptSection(renderCtx?.imagegenDefaultModel);
632
632
  case 'voice':
633
633
  return voiceActionsPromptSection();
634
634
  case 'spawn':
@@ -762,10 +762,13 @@ export function buildTieredDiscordActionsPromptSection(flags, botDisplayName, op
762
762
  const intro = discordActionsIntroSection();
763
763
  sections.push(intro);
764
764
  sectionLogs.push({ section: 'intro', content: intro });
765
+ const renderCtx = opts?.imagegenDefaultModel
766
+ ? { imagegenDefaultModel: opts.imagegenDefaultModel }
767
+ : undefined;
765
768
  for (const category of includedCategories) {
766
769
  if (category === 'defer')
767
770
  continue;
768
- const section = renderActionSchemaCategorySection(category);
771
+ const section = renderActionSchemaCategorySection(category, renderCtx);
769
772
  if (!section)
770
773
  continue;
771
774
  sections.push(section);
@@ -5,6 +5,7 @@ import { fmtTime, resolveChannel } from './action-utils.js';
5
5
  import { NO_MENTIONS } from './allowed-mentions.js';
6
6
  import { DeferScheduler as DeferSchedulerImpl } from './defer-scheduler.js';
7
7
  import { resolveDiscordChannelContext } from './channel-context.js';
8
+ import { resolveDefaultModel } from './actions-imagegen.js';
8
9
  import { resolveGroundedToolCapabilities } from '../runtime/tool-capabilities.js';
9
10
  import { appendUnavailableActionTypesNotice, appendParseFailureNotice } from './output-common.js';
10
11
  import { buildPromptSectionEstimates, buildContextFiles, buildOpenTasksSection, buildScheduledSelfInvocationPrompt, inlineContextFilesWithMeta, loadWorkspacePaFiles, resolveEffectiveTools, } from './prompt-common.js';
@@ -141,6 +142,7 @@ export function configureDeferredScheduler(opts) {
141
142
  channelContextPath: channelCtx.contextPath,
142
143
  isThread: threadParentId !== null,
143
144
  userText: action.prompt,
145
+ imagegenDefaultModel: opts.state.imagegenCtx ? resolveDefaultModel(opts.state.imagegenCtx) : undefined,
144
146
  });
145
147
  actionsReferenceSection = actionSelection.prompt;
146
148
  actionSchemaSelection = {
@@ -11,6 +11,7 @@ import { shouldTriggerFollowUp } from './action-categories.js';
11
11
  import { countPinnedMessages, normalizePinnedMessages } from './pinned-message-utils.js';
12
12
  import { buildForgeCompletionWatchdogDetail, buildForgeCrashWatchdogDetail, buildForgePostProcessingWatchdogDetail, } from './actions-forge.js';
13
13
  import { executePlanAction } from './actions-plan.js';
14
+ import { resolveDefaultModel } from './actions-imagegen.js';
14
15
  import { autoImplementForgePlan } from './forge-auto-implement.js';
15
16
  import { resolveGroundedToolCapabilities } from '../runtime/tool-capabilities.js';
16
17
  import { fetchMessageHistory } from './message-history.js';
@@ -236,8 +237,8 @@ async function gatherConversationContext(opts) {
236
237
  if (contextParts.length === 0 && params.messageHistoryBudget > 0) {
237
238
  try {
238
239
  const history = await fetchMessageHistory(msg.channel, msg.id, { budgetChars: params.messageHistoryBudget, botDisplayName: params.botDisplayName });
239
- if (history) {
240
- contextParts.push(`Context (recent channel messages):\n${history}`);
240
+ if (history.text) {
241
+ contextParts.push(`Context (recent channel messages):\n${history.text}`);
241
242
  }
242
243
  }
243
244
  catch (err) {
@@ -2350,6 +2351,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2350
2351
  let stopReactionRemoved = false;
2351
2352
  // Declared before try so they remain accessible after the finally block closes.
2352
2353
  let historySection = '';
2354
+ let historyAttachments = [];
2353
2355
  let summarySection = '';
2354
2356
  let existingSummaryText = null;
2355
2357
  let existingSummaryUpdatedAt;
@@ -2405,7 +2407,9 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2405
2407
  const contextFiles = buildContextFiles([...paFiles, ...memoryFiles], params.discordChannelContext, channelCtx.contextPath);
2406
2408
  if (params.messageHistoryBudget > 0) {
2407
2409
  try {
2408
- historySection = await fetchMessageHistory(msg.channel, msg.id, { budgetChars: params.messageHistoryBudget, botDisplayName: params.botDisplayName });
2410
+ const historyResult = await fetchMessageHistory(msg.channel, msg.id, { budgetChars: params.messageHistoryBudget, botDisplayName: params.botDisplayName });
2411
+ historySection = historyResult.text;
2412
+ historyAttachments = historyResult.historyAttachments;
2409
2413
  }
2410
2414
  catch (err) {
2411
2415
  params.log?.warn({ err }, 'discord:history fetch failed');
@@ -2530,6 +2534,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2530
2534
  channelContextPath: channelCtx.contextPath,
2531
2535
  isThread,
2532
2536
  userText: batch.map((m) => String(m.content ?? '')).join(' '),
2537
+ imagegenDefaultModel: params.imagegenCtx ? resolveDefaultModel(params.imagegenCtx) : undefined,
2533
2538
  });
2534
2539
  actionsReferenceSection = actionSelection.prompt;
2535
2540
  actionSchemaSelection = {
@@ -2606,20 +2611,15 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2606
2611
  hasChannelContext: Boolean(channelCtx.contextPath),
2607
2612
  permissionTier: tools.permissionTier,
2608
2613
  }, 'invoke:start');
2609
- // Collect images from reply reference (downloaded first, takes priority).
2614
+ // Collect images across sources. Priority: direct > reply-ref > history.
2615
+ // Each source fills the remaining MAX_IMAGES_PER_INVOCATION budget.
2610
2616
  let inputImages;
2611
- const replyRefImages = replyRef?.images ?? [];
2612
- const replyRefImageCount = replyRefImages.length;
2613
- if (replyRefImageCount > 0) {
2614
- inputImages = [...replyRefImages];
2615
- params.log?.info({ imageCount: replyRefImageCount }, 'discord:reply-ref images downloaded');
2616
- }
2617
- // Download image attachments from the user message (remaining budget).
2617
+ // 1. Direct message attachments (highest priority — full budget).
2618
2618
  if (msg.attachments && msg.attachments.size > 0) {
2619
2619
  try {
2620
- const dlResult = await downloadMessageImages([...msg.attachments.values()], MAX_IMAGES_PER_INVOCATION - replyRefImageCount);
2620
+ const dlResult = await downloadMessageImages([...msg.attachments.values()], MAX_IMAGES_PER_INVOCATION);
2621
2621
  if (dlResult.images.length > 0) {
2622
- inputImages = [...(inputImages ?? []), ...dlResult.images];
2622
+ inputImages = [...dlResult.images];
2623
2623
  params.log?.info({ imageCount: dlResult.images.length }, 'discord:images downloaded');
2624
2624
  }
2625
2625
  if (dlResult.errors.length > 0) {
@@ -2651,6 +2651,17 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2651
2651
  params.log?.warn({ err }, 'discord:text attachment download failed');
2652
2652
  }
2653
2653
  }
2654
+ // 2. Reply-reference images (remaining budget).
2655
+ const replyRefImages = replyRef?.images ?? [];
2656
+ if (replyRefImages.length > 0) {
2657
+ const directCount = inputImages?.length ?? 0;
2658
+ const refBudget = MAX_IMAGES_PER_INVOCATION - directCount;
2659
+ if (refBudget > 0) {
2660
+ const toAdd = replyRefImages.slice(0, refBudget);
2661
+ inputImages = [...(inputImages ?? []), ...toAdd];
2662
+ params.log?.info({ imageCount: toAdd.length }, 'discord:reply-ref images');
2663
+ }
2664
+ }
2654
2665
  // Fetch YouTube transcripts for URLs found in the message.
2655
2666
  try {
2656
2667
  const ytResult = await fetchYouTubeTranscripts(msg.content ?? '');
@@ -2667,6 +2678,36 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2667
2678
  catch (err) {
2668
2679
  params.log?.warn({ err }, 'discord:youtube transcript fetch failed');
2669
2680
  }
2681
+ // 3. History images from thread/channel context (remaining budget).
2682
+ if (historyAttachments.length > 0) {
2683
+ const currentCount = inputImages?.length ?? 0;
2684
+ const historyImageBudget = MAX_IMAGES_PER_INVOCATION - currentCount;
2685
+ if (historyImageBudget > 0) {
2686
+ try {
2687
+ // Deduplicate: exclude attachment URLs already processed from the direct message.
2688
+ const directUrls = new Set();
2689
+ if (msg.attachments) {
2690
+ for (const att of msg.attachments.values()) {
2691
+ directUrls.add(att.url);
2692
+ }
2693
+ }
2694
+ const deduped = historyAttachments.filter(a => !directUrls.has(a.url));
2695
+ if (deduped.length > 0) {
2696
+ const dlResult = await downloadMessageImages(deduped, historyImageBudget);
2697
+ if (dlResult.images.length > 0) {
2698
+ inputImages = [...(inputImages ?? []), ...dlResult.images];
2699
+ params.log?.info({ imageCount: dlResult.images.length }, 'discord:history images downloaded');
2700
+ }
2701
+ if (dlResult.errors.length > 0) {
2702
+ params.log?.warn({ errors: dlResult.errors }, 'discord:history image download errors');
2703
+ }
2704
+ }
2705
+ }
2706
+ catch (err) {
2707
+ params.log?.warn({ err }, 'discord:history image download failed');
2708
+ }
2709
+ }
2710
+ }
2670
2711
  let currentPrompt = prompt;
2671
2712
  let followUpDepth = 0;
2672
2713
  let pendingFollowUp = null;