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.
- package/dist/cron/executor.js +2 -1
- package/dist/discord/actions-config.js +28 -2
- package/dist/discord/actions-imagegen.js +5 -2
- package/dist/discord/actions-imagegen.test.js +10 -0
- package/dist/discord/actions-messaging.js +40 -4
- package/dist/discord/actions-messaging.test.js +169 -0
- package/dist/discord/actions.js +6 -3
- package/dist/discord/deferred-runner.js +2 -0
- package/dist/discord/message-coordinator.js +54 -13
- package/dist/discord/message-coordinator.test.js +206 -1
- package/dist/discord/message-history.js +21 -9
- package/dist/discord/message-history.test.js +98 -15
- package/dist/discord/models-command.js +3 -3
- package/dist/discord/models-command.test.js +3 -4
- package/dist/discord/reaction-handler.js +4 -1
- package/dist/discord/thread-context.js +61 -27
- package/dist/discord/thread-context.test.js +180 -1
- package/dist/index.js +10 -0
- package/dist/runtime/model-tiers.js +1 -1
- package/dist/runtime/model-tiers.test.js +4 -4
- package/package.json +1 -1
package/dist/cron/executor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
244
|
-
const
|
|
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
|
|
267
|
-
const
|
|
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 () => {
|
package/dist/discord/actions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2620
|
+
const dlResult = await downloadMessageImages([...msg.attachments.values()], MAX_IMAGES_PER_INVOCATION);
|
|
2621
2621
|
if (dlResult.images.length > 0) {
|
|
2622
|
-
inputImages = [...
|
|
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;
|