discoclaw 1.1.4 → 1.1.6

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.
@@ -2633,7 +2633,6 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2633
2633
  let stopReactionRemoved = false;
2634
2634
  // Declared before try so they remain accessible after the finally block closes.
2635
2635
  let historySection = '';
2636
- let historyAttachments = [];
2637
2636
  let summarySection = '';
2638
2637
  let existingSummaryText = null;
2639
2638
  let existingSummaryUpdatedAt;
@@ -2705,7 +2704,6 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2705
2704
  try {
2706
2705
  const historyResult = await fetchMessageHistory(msg.channel, msg.id, { budgetChars: params.messageHistoryBudget, fetchLimit: params.messageHistoryFetchLimit, maxAgeMs: params.messageHistoryMaxAgeMs, botDisplayName: params.botDisplayName });
2707
2706
  historySection = historyResult.text;
2708
- historyAttachments = historyResult.historyAttachments;
2709
2707
  }
2710
2708
  catch (err) {
2711
2709
  params.log?.warn({ err }, 'discord:history fetch failed');
@@ -2937,7 +2935,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2937
2935
  hasChannelContext: Boolean(channelCtx.contextPath),
2938
2936
  permissionTier: tools.permissionTier,
2939
2937
  }, 'invoke:start');
2940
- // Collect images across sources. Priority: direct > reply-ref > history.
2938
+ // Collect images across sources. Priority: direct > reply-ref.
2941
2939
  // Each source fills the remaining MAX_IMAGES_PER_INVOCATION budget.
2942
2940
  let inputImages;
2943
2941
  // 1. Direct message attachments (highest priority — full budget).
@@ -3021,41 +3019,12 @@ export function createMessageCreateHandler(params, queue, statusRef) {
3021
3019
  catch (err) {
3022
3020
  params.log?.warn({ err }, 'discord:youtube transcript fetch failed');
3023
3021
  }
3024
- // 3. History images from thread/channel context (remaining budget).
3025
- // Skip for Codex runtime: `codex exec resume` does not support --image,
3026
- // so history images would force a session reset on every turn that has
3027
- // any image in recent channel history. Direct-message images (from the
3028
- // user's current message) still work they trigger a fresh session.
3029
- const skipHistoryImages = params.runtime.id === 'codex';
3030
- if (!skipHistoryImages && historyAttachments.length > 0) {
3031
- const currentCount = inputImages?.length ?? 0;
3032
- const historyImageBudget = MAX_IMAGES_PER_INVOCATION - currentCount;
3033
- if (historyImageBudget > 0) {
3034
- try {
3035
- // Deduplicate: exclude attachment URLs already processed from the direct message.
3036
- const directUrls = new Set();
3037
- if (msg.attachments) {
3038
- for (const att of msg.attachments.values()) {
3039
- directUrls.add(att.url);
3040
- }
3041
- }
3042
- const deduped = historyAttachments.filter(a => !directUrls.has(a.url));
3043
- if (deduped.length > 0) {
3044
- const dlResult = await downloadMessageImages(deduped, historyImageBudget);
3045
- if (dlResult.images.length > 0) {
3046
- inputImages = [...(inputImages ?? []), ...dlResult.images];
3047
- params.log?.info({ imageCount: dlResult.images.length }, 'discord:history images downloaded');
3048
- }
3049
- if (dlResult.errors.length > 0) {
3050
- params.log?.warn({ errors: dlResult.errors }, 'discord:history image download errors');
3051
- }
3052
- }
3053
- }
3054
- catch (err) {
3055
- params.log?.warn({ err }, 'discord:history image download failed');
3056
- }
3057
- }
3058
- }
3022
+ // History images are intentionally NOT injected here. The text
3023
+ // history already notes `[attachment/embed]` when media was present,
3024
+ // and the reply-reference mechanism (source #2 above) handles cases
3025
+ // where the user explicitly wants the model to see an older image.
3026
+ // Injecting history images as raw content blocks caused the model to
3027
+ // analyze or act on stale images unprompted.
3059
3028
  let currentPrompt = prompt;
3060
3029
  let followUpDepth = 0;
3061
3030
  let pendingFollowUp = null;
@@ -3,8 +3,8 @@
3
3
  * rule is injected alongside the live action inventory so the model trusts
4
4
  * the per-turn inventory over generic product knowledge.
5
5
  *
6
- * Also tests image input precedence across direct, reply-reference, and
7
- * history sources.
6
+ * Also tests image input precedence across direct and reply-reference
7
+ * sources (history images are intentionally excluded).
8
8
  */
9
9
  import fs from 'node:fs/promises';
10
10
  import os from 'node:os';
@@ -413,115 +413,18 @@ describe('image input precedence — direct > reply-ref > history', () => {
413
413
  await handler(msg);
414
414
  expect(runtime.images).toEqual([directImg, refImg]);
415
415
  });
416
- it('reply-ref images appear before history images', async () => {
417
- const refImg = fakeImage('ref-1');
418
- const histImg = fakeImage('hist-1');
419
- // No direct attachments → downloadMessageImages not called for direct.
420
- mockReplyRef.mockResolvedValue({ section: '[User]: hi', images: [refImg] });
421
- // History returns attachments.
416
+ it('history images are never injected into the prompt', async () => {
417
+ // History returns attachments, but they should NOT be downloaded.
422
418
  const histAtt = fakeAttachment('hist.png');
423
419
  mockFetchHistory.mockResolvedValue({ text: 'history text', historyAttachments: [histAtt] });
424
- // downloadMessageImages called once for history attachments.
425
- mockDownloadImages.mockResolvedValueOnce({ images: [histImg], errors: [] });
426
- const runtime = makeImageCaptureRuntime();
427
- const reply = makeReply();
428
- const msg = makeGuildMessage(reply);
429
- const params = makeParams(runtime, { messageHistoryBudget: 500 });
430
- const queue = { run: vi.fn(async (_key, fn) => fn()) };
431
- const handler = await makeHandler(params, queue);
432
- await handler(msg);
433
- expect(runtime.images).toEqual([refImg, histImg]);
434
- });
435
- it('history images are downloaded newest-first (preserving fetchMessageHistory order)', async () => {
436
- const histImgA = fakeImage('hist-a');
437
- const histImgB = fakeImage('hist-b');
438
- // History returns attachments in newest-first order.
439
- const attNew = fakeAttachment('new.png');
440
- const attOld = fakeAttachment('old.png');
441
- mockFetchHistory.mockResolvedValue({
442
- text: 'history',
443
- historyAttachments: [attNew, attOld],
444
- });
445
- // downloadMessageImages receives them in the same order and returns both.
446
- mockDownloadImages.mockResolvedValueOnce({ images: [histImgA, histImgB], errors: [] });
447
- const runtime = makeImageCaptureRuntime();
448
- const reply = makeReply();
449
- const msg = makeGuildMessage(reply);
450
- const params = makeParams(runtime, { messageHistoryBudget: 500 });
451
- const queue = { run: vi.fn(async (_key, fn) => fn()) };
452
- const handler = await makeHandler(params, queue);
453
- await handler(msg);
454
- // Verify downloadMessageImages received attachments in newest-first order.
455
- expect(mockDownloadImages).toHaveBeenCalledWith([attNew, attOld], expect.any(Number));
456
- expect(runtime.images).toEqual([histImgA, histImgB]);
457
- });
458
- it('duplicate URLs between direct and history are only counted once', async () => {
459
- const directImg = fakeImage('direct-shared');
460
- const histImg = fakeImage('hist-unique');
461
- const sharedUrl = 'https://cdn.discordapp.com/shared.png';
462
- const directAtt = fakeAttachment('shared.png', sharedUrl);
463
- const histAttShared = fakeAttachment('shared.png', sharedUrl);
464
- const histAttUnique = fakeAttachment('unique.png');
465
- // Direct download returns one image.
466
- mockDownloadImages
467
- .mockResolvedValueOnce({ images: [directImg], errors: [] })
468
- // History download receives only the unique attachment (deduped).
469
- .mockResolvedValueOnce({ images: [histImg], errors: [] });
470
- mockFetchHistory.mockResolvedValue({
471
- text: 'history',
472
- historyAttachments: [histAttShared, histAttUnique],
473
- });
474
- const runtime = makeImageCaptureRuntime();
475
- const reply = makeReply();
476
- const attachments = new Map([['1', directAtt]]);
477
- const msg = makeGuildMessage(reply, { attachments });
478
- const params = makeParams(runtime, { messageHistoryBudget: 500 });
479
- const queue = { run: vi.fn(async (_key, fn) => fn()) };
480
- const handler = await makeHandler(params, queue);
481
- await handler(msg);
482
- // The second downloadMessageImages call should only receive the unique attachment.
483
- expect(mockDownloadImages).toHaveBeenCalledTimes(2);
484
- const historyCall = mockDownloadImages.mock.calls[1];
485
- expect(historyCall[0]).toEqual([histAttUnique]);
486
- expect(runtime.images).toEqual([directImg, histImg]);
487
- });
488
- it('history download is skipped when higher-priority sources exhaust the cap', async () => {
489
- // Fill budget with direct images (MAX_IMAGES_PER_INVOCATION).
490
- const directImages = Array.from({ length: MAX_IMAGES_PER_INVOCATION }, (_, i) => fakeImage(`direct-${i}`));
491
- mockDownloadImages.mockResolvedValueOnce({ images: directImages, errors: [] });
492
- // History has attachments, but budget should be exhausted.
493
- const histAtt = fakeAttachment('hist.png');
494
- mockFetchHistory.mockResolvedValue({
495
- text: 'history',
496
- historyAttachments: [histAtt],
497
- });
498
- const runtime = makeImageCaptureRuntime();
499
- const reply = makeReply();
500
- const directAtts = Array.from({ length: MAX_IMAGES_PER_INVOCATION }, (_, i) => fakeAttachment(`img-${i}.png`));
501
- const attachments = new Map(directAtts.map((a, i) => [String(i), a]));
502
- const msg = makeGuildMessage(reply, { attachments });
503
- const params = makeParams(runtime, { messageHistoryBudget: 500 });
504
- const queue = { run: vi.fn(async (_key, fn) => fn()) };
505
- const handler = await makeHandler(params, queue);
506
- await handler(msg);
507
- // downloadMessageImages should be called only once (for direct), not for history.
508
- expect(mockDownloadImages).toHaveBeenCalledTimes(1);
509
- expect(runtime.images).toEqual(directImages);
510
- });
511
- it('history images are skipped for codex runtime to avoid session reset', async () => {
512
- const histAtt = fakeAttachment('hist.png');
513
- mockFetchHistory.mockResolvedValue({ text: 'history', historyAttachments: [histAtt] });
514
- // Do NOT set mockResolvedValueOnce — download should never be called.
515
420
  const runtime = makeImageCaptureRuntime();
516
- // Override runtime id to 'codex'.
517
- runtime.id = 'codex';
518
421
  const reply = makeReply();
519
422
  const msg = makeGuildMessage(reply);
520
423
  const params = makeParams(runtime, { messageHistoryBudget: 500 });
521
424
  const queue = { run: vi.fn(async (_key, fn) => fn()) };
522
425
  const handler = await makeHandler(params, queue);
523
426
  await handler(msg);
524
- // History images should NOT be downloaded for codex runtime.
427
+ // downloadMessageImages should NOT be called for history attachments.
525
428
  expect(mockDownloadImages).not.toHaveBeenCalled();
526
429
  expect(runtime.images).toBeUndefined();
527
430
  });
@@ -18,13 +18,13 @@ export function formatRelativeTime(deltaMs) {
18
18
  const weeks = Math.floor(days / 7);
19
19
  return `${weeks}w ago`;
20
20
  }
21
- const EMPTY_RESULT = { text: '', historyAttachments: [] };
21
+ const EMPTY_RESULT = { text: '' };
22
22
  /**
23
23
  * Fetch recent messages from a Discord channel and format them as conversation
24
24
  * history suitable for prepending to a prompt.
25
25
  *
26
- * Returns text in chronological order and attachments newest-first so the
27
- * caller can trim to an image budget starting from the most recent.
26
+ * Returns text in chronological order. Messages with attachments or embeds
27
+ * but no text content are represented as `[attachment]` / `[embed]`.
28
28
  */
29
29
  export async function fetchMessageHistory(channel, beforeMessageId, opts) {
30
30
  if (opts.budgetChars <= 0)
@@ -65,7 +65,23 @@ export async function fetchMessageHistory(channel, beforeMessageId, opts) {
65
65
  for (let i = sorted.length - 1; i >= 0 && remaining > 0; i--) {
66
66
  const m = sorted[i];
67
67
  const author = m.author.bot ? (opts.botDisplayName ?? 'Discoclaw') : (m.author.displayName || m.author.username);
68
- const content = String(m.content ?? '');
68
+ let content = String(m.content ?? '').trim();
69
+ if (!content) {
70
+ const atts = m.attachments;
71
+ const hasAttachments = atts && typeof atts.size === 'number'
72
+ && atts.size > 0;
73
+ const embeds = m.embeds;
74
+ const hasEmbeds = Array.isArray(embeds) && embeds.length > 0;
75
+ if (hasAttachments) {
76
+ content = '[attachment]';
77
+ }
78
+ else if (hasEmbeds) {
79
+ content = '[embed]';
80
+ }
81
+ else {
82
+ continue;
83
+ }
84
+ }
69
85
  const ts = typeof m.createdTimestamp === 'number' ? m.createdTimestamp : 0;
70
86
  const age = ts > 0 ? formatRelativeTime(now - ts) : '';
71
87
  const tag = age ? `${author}, ${age}` : author;
@@ -88,17 +104,6 @@ export async function fetchMessageHistory(channel, beforeMessageId, opts) {
88
104
  remaining -= full.length + 1; // +1 for newline separator
89
105
  }
90
106
  }
91
- // Extract attachments newest-first for downstream image budget trimming.
92
- const historyAttachments = [];
93
- for (let i = sorted.length - 1; i >= 0; i--) {
94
- const m = sorted[i];
95
- const atts = m.attachments;
96
- if (atts && typeof atts.values === 'function') {
97
- for (const a of atts.values()) {
98
- historyAttachments.push(a);
99
- }
100
- }
101
- }
102
107
  const text = selected.length > 0 ? selected.join('\n') : '';
103
- return { text, historyAttachments };
108
+ return { text };
104
109
  }
@@ -1,21 +1,20 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { fetchMessageHistory, formatRelativeTime } from './message-history.js';
3
3
  /** Helper: create a fake Discord message. */
4
- function fakeMsg(id, content, username, bot = false, attachments, createdTimestamp) {
4
+ function fakeMsg(id, content, username, bot = false, opts) {
5
5
  return {
6
6
  id,
7
7
  content,
8
8
  author: { username, displayName: username, bot },
9
- ...(createdTimestamp !== undefined ? { createdTimestamp } : {}),
10
- ...(attachments
11
- ? { attachments: new Map(attachments.map((a, i) => [String(i), a])) }
9
+ ...(opts?.createdTimestamp !== undefined ? { createdTimestamp: opts.createdTimestamp } : {}),
10
+ ...(opts?.attachmentCount
11
+ ? { attachments: new Map(Array.from({ length: opts.attachmentCount }, (_, i) => [String(i), { url: `https://cdn.discordapp.com/${i}.png`, name: `${i}.png` }])) }
12
+ : {}),
13
+ ...(opts?.embedCount
14
+ ? { embeds: Array.from({ length: opts.embedCount }, () => ({ type: 'rich' })) }
12
15
  : {}),
13
16
  };
14
17
  }
15
- /** Shorthand for an image attachment. */
16
- function imgAtt(name, url = `https://cdn.discordapp.com/${name}`) {
17
- return { url, name, contentType: 'image/png', size: 1024 };
18
- }
19
18
  /** Helper: create a fake channel whose messages.fetch returns the given messages (newest-first). */
20
19
  function fakeChannel(messages) {
21
20
  return {
@@ -87,13 +86,11 @@ describe('fetchMessageHistory', () => {
87
86
  };
88
87
  const result = await fetchMessageHistory(ch, '1', { budgetChars: 3000 });
89
88
  expect(result.text).toBe('');
90
- expect(result.historyAttachments).toEqual([]);
91
89
  });
92
90
  it('returns empty result when no prior messages exist', async () => {
93
91
  const ch = fakeChannel([]);
94
92
  const result = await fetchMessageHistory(ch, '1', { budgetChars: 3000 });
95
93
  expect(result.text).toBe('');
96
- expect(result.historyAttachments).toEqual([]);
97
94
  });
98
95
  it('returns empty result when budget is 0', async () => {
99
96
  const ch = fakeChannel([
@@ -101,7 +98,6 @@ describe('fetchMessageHistory', () => {
101
98
  ]);
102
99
  const result = await fetchMessageHistory(ch, '2', { budgetChars: 0 });
103
100
  expect(result.text).toBe('');
104
- expect(result.historyAttachments).toEqual([]);
105
101
  });
106
102
  it('can fetch the latest messages and exclude specific message ids', async () => {
107
103
  const fetch = vi.fn(async () => new Map([
@@ -119,85 +115,60 @@ describe('fetchMessageHistory', () => {
119
115
  expect(fetch).toHaveBeenCalledWith({ limit: 11 });
120
116
  expect(result.text).toBe('[User]: earlier context\n[User]: latest follow-up');
121
117
  });
122
- // --- Image / attachment extraction tests ---
123
- it('extracts image attachments from channel history messages', async () => {
124
- const att1 = imgAtt('screenshot.png');
125
- const att2 = imgAtt('diagram.png');
118
+ // --- Attachment / embed text marker tests ---
119
+ it('renders empty-content messages with attachments as [attachment]', async () => {
126
120
  const ch = fakeChannel([
127
- fakeMsg('3', 'here is a screenshot', 'User', false, [att1]),
128
- fakeMsg('2', 'no images here', 'User'),
129
- fakeMsg('1', 'and a diagram', 'User', false, [att2]),
121
+ fakeMsg('2', 'look at this', 'User'),
122
+ fakeMsg('1', '', 'User', false, { attachmentCount: 1 }),
130
123
  ]);
131
- const result = await fetchMessageHistory(ch, '4', { budgetChars: 5000 });
132
- expect(result.historyAttachments).toHaveLength(2);
133
- // Newest-first ordering: att1 (msg 3) before att2 (msg 1)
134
- expect(result.historyAttachments[0]).toBe(att1);
135
- expect(result.historyAttachments[1]).toBe(att2);
124
+ const result = await fetchMessageHistory(ch, '3', { budgetChars: 5000 });
125
+ const lines = result.text.split('\n');
126
+ expect(lines).toHaveLength(2);
127
+ expect(lines[0]).toBe('[User]: [attachment]');
128
+ expect(lines[1]).toBe('[User]: look at this');
136
129
  });
137
- it('returns attachments newest-first across multiple messages', async () => {
138
- const a1 = imgAtt('old.png');
139
- const a2 = imgAtt('mid.png');
140
- const a3 = imgAtt('new.png');
130
+ it('renders empty-content messages with embeds as [embed]', async () => {
141
131
  const ch = fakeChannel([
142
- fakeMsg('3', 'newest', 'User', false, [a3]),
143
- fakeMsg('2', 'middle', 'User', false, [a2]),
144
- fakeMsg('1', 'oldest', 'User', false, [a1]),
132
+ fakeMsg('1', '', 'User', false, { embedCount: 1 }),
145
133
  ]);
146
- const result = await fetchMessageHistory(ch, '4', { budgetChars: 5000 });
147
- expect(result.historyAttachments).toEqual([a3, a2, a1]);
134
+ const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000 });
135
+ expect(result.text).toBe('[User]: [embed]');
148
136
  });
149
- it('returns multiple attachments from a single message in order', async () => {
150
- const a1 = imgAtt('first.png');
151
- const a2 = imgAtt('second.png');
137
+ it('prefers [attachment] over [embed] when both present', async () => {
152
138
  const ch = fakeChannel([
153
- fakeMsg('1', 'two images', 'User', false, [a1, a2]),
139
+ fakeMsg('1', '', 'User', false, { attachmentCount: 1, embedCount: 1 }),
154
140
  ]);
155
141
  const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000 });
156
- expect(result.historyAttachments).toEqual([a1, a2]);
142
+ expect(result.text).toBe('[User]: [attachment]');
157
143
  });
158
- it('returns empty attachments when messages have no images', async () => {
144
+ it('skips empty-content messages with no attachments or embeds', async () => {
159
145
  const ch = fakeChannel([
160
- fakeMsg('2', 'just text', 'User'),
161
- fakeMsg('1', 'more text', 'User'),
146
+ fakeMsg('2', 'visible', 'User'),
147
+ fakeMsg('1', '', 'User'),
162
148
  ]);
163
149
  const result = await fetchMessageHistory(ch, '3', { budgetChars: 5000 });
164
- expect(result.historyAttachments).toEqual([]);
150
+ expect(result.text).toBe('[User]: visible');
165
151
  });
166
- it('does not include attachments from excluded messages', async () => {
167
- const att = imgAtt('excluded.png');
152
+ it('keeps text content as-is when message also has attachments', async () => {
168
153
  const ch = fakeChannel([
169
- fakeMsg('2', 'keep this', 'User'),
170
- fakeMsg('1', 'exclude this', 'User', false, [att]),
154
+ fakeMsg('1', 'here is my screenshot', 'User', false, { attachmentCount: 2 }),
171
155
  ]);
172
- const result = await fetchMessageHistory(ch, '3', {
173
- budgetChars: 5000,
174
- excludeMessageIds: ['1'],
175
- });
176
- expect(result.text).toBe('[User]: keep this');
177
- expect(result.historyAttachments).toEqual([]);
156
+ const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000 });
157
+ expect(result.text).toBe('[User]: here is my screenshot');
178
158
  });
179
- it('text is chronological while attachments are newest-first', async () => {
180
- const oldAtt = imgAtt('old.png');
181
- const newAtt = imgAtt('new.png');
159
+ it('does not include historyAttachments in result', async () => {
182
160
  const ch = fakeChannel([
183
- fakeMsg('2', 'new message', 'User', false, [newAtt]),
184
- fakeMsg('1', 'old message', 'User', false, [oldAtt]),
161
+ fakeMsg('1', '', 'User', false, { attachmentCount: 1 }),
185
162
  ]);
186
- const result = await fetchMessageHistory(ch, '3', { budgetChars: 5000 });
187
- // Text: chronological (old first)
188
- const lines = result.text.split('\n');
189
- expect(lines[0]).toContain('old message');
190
- expect(lines[1]).toContain('new message');
191
- // Attachments: newest-first
192
- expect(result.historyAttachments[0]).toBe(newAtt);
193
- expect(result.historyAttachments[1]).toBe(oldAtt);
163
+ const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000 });
164
+ expect(result).not.toHaveProperty('historyAttachments');
194
165
  });
195
166
  // --- Temporal signal tests ---
196
167
  it('includes relative timestamps when createdTimestamp is present', async () => {
197
168
  const now = 1700000000000;
198
169
  const ch = fakeChannel([
199
- fakeMsg('2', 'recent msg', 'Alice', false, undefined, now - 5 * 60_000), // 5m ago
200
- fakeMsg('1', 'old msg', 'Bob', false, undefined, now - 3 * 86_400_000), // 3d ago
170
+ fakeMsg('2', 'recent msg', 'Alice', false, { createdTimestamp: now - 5 * 60_000 }), // 5m ago
171
+ fakeMsg('1', 'old msg', 'Bob', false, { createdTimestamp: now - 3 * 86_400_000 }), // 3d ago
201
172
  ]);
202
173
  const result = await fetchMessageHistory(ch, '3', { budgetChars: 5000, now });
203
174
  const lines = result.text.split('\n');
@@ -207,7 +178,7 @@ describe('fetchMessageHistory', () => {
207
178
  it('shows "just now" for messages under 60 seconds old', async () => {
208
179
  const now = 1700000000000;
209
180
  const ch = fakeChannel([
210
- fakeMsg('1', 'fresh', 'User', false, undefined, now - 10_000), // 10s ago
181
+ fakeMsg('1', 'fresh', 'User', false, { createdTimestamp: now - 10_000 }), // 10s ago
211
182
  ]);
212
183
  const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000, now });
213
184
  expect(result.text).toBe('[User, just now]: fresh');
@@ -222,7 +193,7 @@ describe('fetchMessageHistory', () => {
222
193
  it('includes age label on bot messages too', async () => {
223
194
  const now = 1700000000000;
224
195
  const ch = fakeChannel([
225
- fakeMsg('1', 'bot reply', 'Discoclaw', true, undefined, now - 2 * 3600_000), // 2h ago
196
+ fakeMsg('1', 'bot reply', 'Discoclaw', true, { createdTimestamp: now - 2 * 3600_000 }), // 2h ago
226
197
  ]);
227
198
  const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000, now });
228
199
  expect(result.text).toBe('[Discoclaw, 2h ago]: bot reply');
@@ -230,7 +201,7 @@ describe('fetchMessageHistory', () => {
230
201
  it('shows hours correctly at boundary', async () => {
231
202
  const now = 1700000000000;
232
203
  const ch = fakeChannel([
233
- fakeMsg('1', 'msg', 'User', false, undefined, now - 23 * 3600_000), // 23h ago
204
+ fakeMsg('1', 'msg', 'User', false, { createdTimestamp: now - 23 * 3600_000 }), // 23h ago
234
205
  ]);
235
206
  const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000, now });
236
207
  expect(result.text).toBe('[User, 23h ago]: msg');
@@ -238,9 +209,9 @@ describe('fetchMessageHistory', () => {
238
209
  it('filters out messages older than maxAgeMs', async () => {
239
210
  const now = 1700000000000;
240
211
  const ch = fakeChannel([
241
- fakeMsg('3', 'recent', 'User', false, undefined, now - 60_000), // 1m ago
242
- fakeMsg('2', 'stale', 'User', false, undefined, now - 25 * 3600_000), // 25h ago
243
- fakeMsg('1', 'ancient', 'User', false, undefined, now - 72 * 3600_000), // 3d ago
212
+ fakeMsg('3', 'recent', 'User', false, { createdTimestamp: now - 60_000 }), // 1m ago
213
+ fakeMsg('2', 'stale', 'User', false, { createdTimestamp: now - 25 * 3600_000 }), // 25h ago
214
+ fakeMsg('1', 'ancient', 'User', false, { createdTimestamp: now - 72 * 3600_000 }), // 3d ago
244
215
  ]);
245
216
  // maxAgeMs = 24h — only the 1m-ago message should survive
246
217
  const result = await fetchMessageHistory(ch, '4', { budgetChars: 5000, now, maxAgeMs: 24 * 3600_000 });
@@ -251,16 +222,15 @@ describe('fetchMessageHistory', () => {
251
222
  it('returns empty when all messages exceed maxAgeMs', async () => {
252
223
  const now = 1700000000000;
253
224
  const ch = fakeChannel([
254
- fakeMsg('1', 'old', 'User', false, undefined, now - 48 * 3600_000),
225
+ fakeMsg('1', 'old', 'User', false, { createdTimestamp: now - 48 * 3600_000 }),
255
226
  ]);
256
227
  const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000, now, maxAgeMs: 24 * 3600_000 });
257
228
  expect(result.text).toBe('');
258
- expect(result.historyAttachments).toEqual([]);
259
229
  });
260
230
  it('does not filter by age when maxAgeMs is 0', async () => {
261
231
  const now = 1700000000000;
262
232
  const ch = fakeChannel([
263
- fakeMsg('1', 'ancient', 'User', false, undefined, now - 100 * 86_400_000),
233
+ fakeMsg('1', 'ancient', 'User', false, { createdTimestamp: now - 100 * 86_400_000 }),
264
234
  ]);
265
235
  const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000, now, maxAgeMs: 0 });
266
236
  expect(result.text).toContain('ancient');
@@ -268,7 +238,7 @@ describe('fetchMessageHistory', () => {
268
238
  it('shows weeks for messages older than 30 days', async () => {
269
239
  const now = 1700000000000;
270
240
  const ch = fakeChannel([
271
- fakeMsg('1', 'ancient', 'User', false, undefined, now - 45 * 86_400_000), // 45d ago
241
+ fakeMsg('1', 'ancient', 'User', false, { createdTimestamp: now - 45 * 86_400_000 }), // 45d ago
272
242
  ]);
273
243
  const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000, now });
274
244
  expect(result.text).toBe('[User, 6w ago]: ancient');
@@ -1,4 +1,3 @@
1
- import { downloadMessageImages } from './image-download.js';
2
1
  import { countPinnedMessages, normalizePinnedMessages } from './pinned-message-utils.js';
3
2
  const DEFAULT_BUDGET_CHARS = 3000;
4
3
  const DEFAULT_RECENT_LIMIT = 10;
@@ -10,14 +9,6 @@ function hasMedia(m) {
10
9
  const embLen = m.embeds && ('length' in m.embeds ? m.embeds.length : 0);
11
10
  return (attSize ?? 0) > 0 || (embLen ?? 0) > 0;
12
11
  }
13
- function extractAttachmentLikes(attachments) {
14
- if (!attachments)
15
- return [];
16
- if (typeof attachments.values === 'function') {
17
- return [...attachments.values()];
18
- }
19
- return [];
20
- }
21
12
  function formatMessageLine(m, botName, suffix) {
22
13
  const content = String(m.content ?? '').trim();
23
14
  const author = m.author.bot
@@ -140,9 +131,7 @@ export async function resolveThreadContext(channel, currentMessageId, opts = {})
140
131
  }
141
132
  }
142
133
  // 4. Recent thread messages (before the current command message)
143
- const historyImageBudget = opts.historyImageBudget ?? 0;
144
- let fetchedRecent = [];
145
- if (recentLimit > 0 && (remaining > 50 || historyImageBudget > 0)) {
134
+ if (recentLimit > 0 && remaining > 50) {
146
135
  try {
147
136
  const messages = await channel.messages.fetch({
148
137
  before: currentMessageId,
@@ -152,63 +141,40 @@ export async function resolveThreadContext(channel, currentMessageId, opts = {})
152
141
  // Sort by snowflake ID (ascending = chronological).
153
142
  const sorted = Array.from(messages.values())
154
143
  .sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
155
- fetchedRecent = sorted;
156
- if (remaining > 50) {
157
- const lines = [];
158
- for (const m of sorted) {
159
- // Deduplicate: skip the starter message if it appears in recent messages
160
- if (m.id && seenMessageIds.has(m.id))
161
- continue;
162
- const line = formatMessageLine(m, botName);
163
- if (!line)
164
- continue;
165
- if (line.length <= remaining) {
166
- lines.push(line);
167
- remaining -= line.length + 1;
168
- if (m.id)
169
- seenMessageIds.add(m.id);
170
- }
171
- else if (remaining > 50 && m.author.bot) {
172
- lines.push(line.slice(0, remaining - 3) + '...');
173
- remaining = 0;
174
- break;
175
- }
176
- else {
177
- break;
178
- }
144
+ const lines = [];
145
+ for (const m of sorted) {
146
+ // Deduplicate: skip the starter message if it appears in recent messages
147
+ if (m.id && seenMessageIds.has(m.id))
148
+ continue;
149
+ const line = formatMessageLine(m, botName);
150
+ if (!line)
151
+ continue;
152
+ if (line.length <= remaining) {
153
+ lines.push(line);
154
+ remaining -= line.length + 1;
155
+ if (m.id)
156
+ seenMessageIds.add(m.id);
157
+ }
158
+ else if (remaining > 50 && m.author.bot) {
159
+ lines.push(line.slice(0, remaining - 3) + '...');
160
+ remaining = 0;
161
+ break;
179
162
  }
180
- if (lines.length > 0) {
181
- sections.push('Recent thread messages:');
182
- sections.push(...lines);
163
+ else {
164
+ break;
183
165
  }
184
166
  }
167
+ if (lines.length > 0) {
168
+ sections.push('Recent thread messages:');
169
+ sections.push(...lines);
170
+ }
185
171
  }
186
172
  }
187
173
  catch (err) {
188
174
  opts.log?.warn({ err }, 'thread-context: failed to fetch recent messages');
189
175
  }
190
176
  }
191
- // 5. Download images from thread history (newest-first)
192
- const images = [];
193
- if (historyImageBudget > 0 && fetchedRecent.length > 0) {
194
- const candidates = [];
195
- for (let i = fetchedRecent.length - 1; i >= 0; i--) {
196
- candidates.push(...extractAttachmentLikes(fetchedRecent[i].attachments));
197
- }
198
- if (candidates.length > 0) {
199
- try {
200
- const dlResult = await downloadMessageImages(candidates, historyImageBudget);
201
- images.push(...dlResult.images);
202
- if (dlResult.errors.length > 0) {
203
- opts.log?.warn({ errors: dlResult.errors }, 'thread-context: image download errors');
204
- }
205
- }
206
- catch (err) {
207
- opts.log?.warn({ err }, 'thread-context: image download failed');
208
- }
209
- }
210
- }
211
- if (sections.length === 0 && images.length === 0)
177
+ if (sections.length === 0)
212
178
  return null;
213
- return { section: sections.join('\n'), images };
179
+ return { section: sections.join('\n') };
214
180
  }
@@ -1,9 +1,4 @@
1
- import { describe, expect, it, vi, beforeEach } from 'vitest';
2
- import { downloadMessageImages } from './image-download.js';
3
- vi.mock('./image-download.js', () => ({
4
- downloadMessageImages: vi.fn(),
5
- }));
6
- const mockDownload = vi.mocked(downloadMessageImages);
1
+ import { describe, expect, it, vi } from 'vitest';
7
2
  import { resolveThreadContext } from './thread-context.js';
8
3
  // ---------------------------------------------------------------------------
9
4
  // Helpers
@@ -49,9 +44,6 @@ function fakeNonThread() {
49
44
  // Tests
50
45
  // ---------------------------------------------------------------------------
51
46
  describe('resolveThreadContext', () => {
52
- beforeEach(() => {
53
- mockDownload.mockReset();
54
- });
55
47
  it('returns null for non-thread channels', async () => {
56
48
  const result = await resolveThreadContext(fakeNonThread(), '100');
57
49
  expect(result).toBeNull();
@@ -410,27 +402,17 @@ describe('resolveThreadContext', () => {
410
402
  expect(aliceIdx).toBeLessThan(bobIdx);
411
403
  expect(bobIdx).toBeLessThan(charlieIdx);
412
404
  });
413
- // ---------------------------------------------------------------------------
414
- // Thread-history image tests
415
- // ---------------------------------------------------------------------------
416
- it('returns empty images array when historyImageBudget is not set', async () => {
405
+ it('does not include images field in result', async () => {
417
406
  const ch = fakeThread({
418
407
  name: 'no-img-thread',
419
408
  messages: [fakeMsg('2', 'hello', 'Alice')],
420
409
  });
421
410
  const result = await resolveThreadContext(ch, '100');
422
411
  expect(result).not.toBeNull();
423
- expect(result.images).toEqual([]);
424
- expect(mockDownload).not.toHaveBeenCalled();
412
+ expect(result).not.toHaveProperty('images');
425
413
  });
426
- it('supports AttachmentLike map in ThreadMessage attachments for hasMedia', async () => {
427
- const att = {
428
- url: 'https://cdn.discordapp.com/img.png',
429
- name: 'img.png',
430
- contentType: 'image/png',
431
- size: 1024,
432
- };
433
- const attachments = new Map([['1', att]]);
414
+ it('supports Map-based attachments for hasMedia detection', async () => {
415
+ const attachments = new Map([['1', { size: 1024 }]]);
434
416
  const ch = fakeThread({
435
417
  name: 'att-test',
436
418
  messages: [fakeMsg('2', '', 'Alice', false, { attachments })],
@@ -439,146 +421,4 @@ describe('resolveThreadContext', () => {
439
421
  expect(result).not.toBeNull();
440
422
  expect(result.section).toContain('[Alice]: [attachment/embed]');
441
423
  });
442
- it('extracts and downloads images from thread history when historyImageBudget > 0', async () => {
443
- const att = {
444
- url: 'https://cdn.discordapp.com/img.png',
445
- name: 'img.png',
446
- contentType: 'image/png',
447
- size: 1024,
448
- };
449
- const attachments = new Map([['1', att]]);
450
- const fakeImage = { base64: 'abc', mediaType: 'image/png' };
451
- mockDownload.mockResolvedValueOnce({ images: [fakeImage], errors: [] });
452
- const ch = fakeThread({
453
- name: 'img-thread',
454
- messages: [fakeMsg('2', 'check this', 'Alice', false, { attachments })],
455
- });
456
- const result = await resolveThreadContext(ch, '100', { historyImageBudget: 5 });
457
- expect(result).not.toBeNull();
458
- expect(result.images).toEqual([fakeImage]);
459
- expect(mockDownload).toHaveBeenCalledOnce();
460
- expect(mockDownload).toHaveBeenCalledWith([att], 5);
461
- });
462
- it('passes all attachment types to downloadMessageImages for filtering', async () => {
463
- const textAtt = {
464
- url: 'https://cdn.discordapp.com/file.txt',
465
- name: 'file.txt',
466
- contentType: 'text/plain',
467
- size: 100,
468
- };
469
- const imgAtt = {
470
- url: 'https://cdn.discordapp.com/img.png',
471
- name: 'img.png',
472
- contentType: 'image/png',
473
- size: 1024,
474
- };
475
- const attachments = new Map([['1', textAtt], ['2', imgAtt]]);
476
- const fakeImage = { base64: 'abc', mediaType: 'image/png' };
477
- mockDownload.mockResolvedValueOnce({ images: [fakeImage], errors: [] });
478
- const ch = fakeThread({
479
- name: 'mixed-att-thread',
480
- messages: [fakeMsg('2', 'mixed', 'Alice', false, { attachments })],
481
- });
482
- const result = await resolveThreadContext(ch, '100', { historyImageBudget: 5 });
483
- expect(result).not.toBeNull();
484
- expect(result.images).toEqual([fakeImage]);
485
- // Both attachments passed — downloadMessageImages handles filtering
486
- expect(mockDownload).toHaveBeenCalledWith([textAtt, imgAtt], 5);
487
- });
488
- it('selects thread-history images newest-first', async () => {
489
- const oldAtt = {
490
- url: 'https://cdn.discordapp.com/old.png',
491
- name: 'old.png',
492
- contentType: 'image/png',
493
- size: 1024,
494
- };
495
- const newAtt = {
496
- url: 'https://cdn.discordapp.com/new.png',
497
- name: 'new.png',
498
- contentType: 'image/png',
499
- size: 1024,
500
- };
501
- mockDownload.mockResolvedValueOnce({
502
- images: [{ base64: 'new', mediaType: 'image/png' }],
503
- errors: [],
504
- });
505
- const ch = fakeThread({
506
- name: 'order-thread',
507
- messages: [
508
- fakeMsg('2', 'old msg', 'Alice', false, {
509
- attachments: new Map([['1', oldAtt]]),
510
- }),
511
- fakeMsg('3', 'new msg', 'Bob', false, {
512
- attachments: new Map([['2', newAtt]]),
513
- }),
514
- ],
515
- });
516
- const result = await resolveThreadContext(ch, '100', { historyImageBudget: 1 });
517
- expect(result).not.toBeNull();
518
- // downloadMessageImages receives new attachment first (newest-first)
519
- expect(mockDownload).toHaveBeenCalledWith([newAtt, oldAtt], 1);
520
- });
521
- it('handles partial image download failures gracefully', async () => {
522
- const att1 = {
523
- url: 'https://cdn.discordapp.com/good.png',
524
- name: 'good.png',
525
- contentType: 'image/png',
526
- size: 1024,
527
- };
528
- const att2 = {
529
- url: 'https://cdn.discordapp.com/bad.png',
530
- name: 'bad.png',
531
- contentType: 'image/png',
532
- size: 1024,
533
- };
534
- const attachments = new Map([['1', att1], ['2', att2]]);
535
- const fakeImage = { base64: 'abc', mediaType: 'image/png' };
536
- mockDownload.mockResolvedValueOnce({
537
- images: [fakeImage],
538
- errors: ['bad.png: download failed'],
539
- });
540
- const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
541
- const ch = fakeThread({
542
- name: 'partial-fail-thread',
543
- messages: [fakeMsg('2', 'images here', 'Alice', false, { attachments })],
544
- });
545
- const result = await resolveThreadContext(ch, '100', { historyImageBudget: 5, log });
546
- expect(result).not.toBeNull();
547
- expect(result.images).toEqual([fakeImage]);
548
- expect(result.section).toContain('[Alice]: images here');
549
- expect(log.warn).toHaveBeenCalledWith({ errors: ['bad.png: download failed'] }, 'thread-context: image download errors');
550
- });
551
- it('handles complete image download failure gracefully', async () => {
552
- const att = {
553
- url: 'https://cdn.discordapp.com/img.png',
554
- name: 'img.png',
555
- contentType: 'image/png',
556
- size: 1024,
557
- };
558
- const attachments = new Map([['1', att]]);
559
- mockDownload.mockRejectedValueOnce(new Error('network error'));
560
- const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
561
- const ch = fakeThread({
562
- name: 'crash-thread',
563
- messages: [fakeMsg('2', 'oops', 'Alice', false, { attachments })],
564
- });
565
- const result = await resolveThreadContext(ch, '100', { historyImageBudget: 5, log });
566
- expect(result).not.toBeNull();
567
- expect(result.images).toEqual([]);
568
- expect(result.section).toContain('[Alice]: oops');
569
- expect(log.warn).toHaveBeenCalled();
570
- });
571
- it('skips image download when messages have no attachments', async () => {
572
- const ch = fakeThread({
573
- name: 'text-only-thread',
574
- messages: [
575
- fakeMsg('2', 'just text', 'Alice'),
576
- fakeMsg('3', 'more text', 'Bob'),
577
- ],
578
- });
579
- const result = await resolveThreadContext(ch, '100', { historyImageBudget: 5 });
580
- expect(result).not.toBeNull();
581
- expect(result.images).toEqual([]);
582
- expect(mockDownload).not.toHaveBeenCalled();
583
- });
584
424
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discoclaw",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "description": "Personal AI orchestrator that turns Discord into a persistent workspace",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,6 +19,10 @@ Before claiming insufficient info: check workspace files → durable memory →
19
19
 
20
20
  Use tools immediately — Read/Bash/Grep — don't narrate plans. CWD is the workspace dir; code lives in `~/code/discoclaw`. Don't defer what you can do now. Task status updates are coordination, not investigation.
21
21
 
22
+ ## Check Before Creating
23
+
24
+ Before implementing requested functionality, search the codebase to confirm it doesn't already exist. Re-read the actual file content rather than relying on what you recall from earlier in the session — your memory of what you read vs. what you generated can drift.
25
+
22
26
  ## Runtime Registry
23
27
 
24
28
  | Key | Type | Backend |