@hybridaione/hybridclaw 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +47 -15
  3. package/container/package-lock.json +2 -2
  4. package/container/package.json +1 -1
  5. package/container/src/browser-tools.ts +1 -1
  6. package/container/src/index.ts +243 -14
  7. package/container/src/token-usage.ts +18 -2
  8. package/container/src/tools.ts +339 -1
  9. package/container/src/types.ts +28 -2
  10. package/dist/agent.d.ts +2 -2
  11. package/dist/agent.d.ts.map +1 -1
  12. package/dist/agent.js +2 -2
  13. package/dist/agent.js.map +1 -1
  14. package/dist/channels/discord/attachments.d.ts +9 -0
  15. package/dist/channels/discord/attachments.d.ts.map +1 -0
  16. package/dist/channels/discord/attachments.js +245 -0
  17. package/dist/channels/discord/attachments.js.map +1 -0
  18. package/dist/channels/discord/delivery.d.ts +31 -0
  19. package/dist/channels/discord/delivery.d.ts.map +1 -0
  20. package/dist/channels/discord/delivery.js +60 -0
  21. package/dist/channels/discord/delivery.js.map +1 -0
  22. package/dist/channels/discord/inbound.d.ts +20 -0
  23. package/dist/channels/discord/inbound.d.ts.map +1 -0
  24. package/dist/channels/discord/inbound.js +44 -0
  25. package/dist/channels/discord/inbound.js.map +1 -0
  26. package/dist/channels/discord/mentions.d.ts +14 -0
  27. package/dist/channels/discord/mentions.d.ts.map +1 -0
  28. package/dist/channels/discord/mentions.js +118 -0
  29. package/dist/channels/discord/mentions.js.map +1 -0
  30. package/dist/channels/discord/runtime.d.ts +22 -0
  31. package/dist/channels/discord/runtime.d.ts.map +1 -0
  32. package/dist/channels/discord/runtime.js +972 -0
  33. package/dist/channels/discord/runtime.js.map +1 -0
  34. package/dist/channels/discord/stream.d.ts +32 -0
  35. package/dist/channels/discord/stream.d.ts.map +1 -0
  36. package/dist/channels/discord/stream.js +196 -0
  37. package/dist/channels/discord/stream.js.map +1 -0
  38. package/dist/channels/discord/tool-actions.d.ts +31 -0
  39. package/dist/channels/discord/tool-actions.d.ts.map +1 -0
  40. package/dist/channels/discord/tool-actions.js +268 -0
  41. package/dist/channels/discord/tool-actions.js.map +1 -0
  42. package/dist/container-runner.d.ts +2 -2
  43. package/dist/container-runner.d.ts.map +1 -1
  44. package/dist/container-runner.js +12 -2
  45. package/dist/container-runner.js.map +1 -1
  46. package/dist/discord.basic.test.d.ts +2 -0
  47. package/dist/discord.basic.test.d.ts.map +1 -0
  48. package/dist/discord.basic.test.js +38 -0
  49. package/dist/discord.basic.test.js.map +1 -0
  50. package/dist/discord.d.ts +5 -44
  51. package/dist/discord.d.ts.map +1 -1
  52. package/dist/discord.js +3 -1468
  53. package/dist/discord.js.map +1 -1
  54. package/dist/gateway-service.d.ts +7 -1
  55. package/dist/gateway-service.d.ts.map +1 -1
  56. package/dist/gateway-service.js +111 -2
  57. package/dist/gateway-service.js.map +1 -1
  58. package/dist/gateway-service.media-routing.test.d.ts +2 -0
  59. package/dist/gateway-service.media-routing.test.d.ts.map +1 -0
  60. package/dist/gateway-service.media-routing.test.js +29 -0
  61. package/dist/gateway-service.media-routing.test.js.map +1 -0
  62. package/dist/gateway-types.d.ts +8 -0
  63. package/dist/gateway-types.d.ts.map +1 -1
  64. package/dist/gateway-types.js.map +1 -1
  65. package/dist/gateway.js +5 -2
  66. package/dist/gateway.js.map +1 -1
  67. package/dist/health.d.ts.map +1 -1
  68. package/dist/health.js +1 -1
  69. package/dist/health.js.map +1 -1
  70. package/dist/heartbeat.d.ts.map +1 -1
  71. package/dist/heartbeat.js +2 -0
  72. package/dist/heartbeat.js.map +1 -1
  73. package/dist/token-efficiency.basic.test.d.ts +2 -0
  74. package/dist/token-efficiency.basic.test.d.ts.map +1 -0
  75. package/dist/token-efficiency.basic.test.js +29 -0
  76. package/dist/token-efficiency.basic.test.js.map +1 -0
  77. package/dist/token-efficiency.d.ts.map +1 -1
  78. package/dist/token-efficiency.js +18 -1
  79. package/dist/token-efficiency.js.map +1 -1
  80. package/dist/types.d.ts +23 -1
  81. package/dist/types.d.ts.map +1 -1
  82. package/package.json +10 -2
  83. package/src/agent.ts +11 -1
  84. package/src/channels/discord/attachments.ts +282 -0
  85. package/src/channels/discord/delivery.ts +99 -0
  86. package/src/channels/discord/inbound.ts +72 -0
  87. package/src/channels/discord/mentions.ts +130 -0
  88. package/src/{discord.ts → channels/discord/runtime.ts} +77 -615
  89. package/src/{discord-stream.ts → channels/discord/stream.ts} +2 -2
  90. package/src/channels/discord/tool-actions.ts +332 -0
  91. package/src/container-runner.ts +24 -1
  92. package/src/gateway-service.ts +125 -1
  93. package/src/gateway-types.ts +8 -0
  94. package/src/gateway.ts +5 -5
  95. package/src/health.ts +2 -1
  96. package/src/heartbeat.ts +2 -0
  97. package/src/token-efficiency.ts +17 -1
  98. package/src/types.ts +27 -1
  99. package/tests/discord.basic.test.ts +43 -0
  100. package/tests/gateway-service.media-routing.test.ts +33 -0
  101. package/tests/token-efficiency.basic.test.ts +32 -0
  102. package/vitest.e2e.config.ts +15 -0
  103. package/vitest.integration.config.ts +15 -0
  104. package/vitest.live.config.ts +16 -0
  105. package/vitest.unit.config.ts +15 -0
@@ -0,0 +1,972 @@
1
+ import { ActivityType, Client, GatewayIntentBits, Partials, } from 'discord.js';
2
+ import { DISCORD_COMMAND_USER_ID, DISCORD_COMMANDS_ONLY, DISCORD_GUILD_MEMBERS_INTENT, DISCORD_PRESENCE_INTENT, DISCORD_PREFIX, DISCORD_RESPOND_TO_ALL_MESSAGES, DISCORD_TOKEN, } from '../../config.js';
3
+ import { buildAttachmentContext } from './attachments.js';
4
+ import { buildSessionIdFromContext as buildSessionIdFromContextInbound, cleanIncomingContent as cleanIncomingContentInbound, hasPrefixInvocation as hasPrefixInvocationInbound, isTrigger as isTriggerInbound, parseCommand as parseCommandInbound, } from './inbound.js';
5
+ import { addMentionAlias, extractMentionAliasHints, normalizeMentionAlias, } from './mentions.js';
6
+ import { formatError, prepareChunkedPayloads, sendChunkedDirectReply as sendChunkedDirectReplyFromDelivery, sendChunkedInteractionReply as sendChunkedInteractionReplyFromDelivery, sendChunkedReply as sendChunkedReplyFromDelivery, } from './delivery.js';
7
+ import { createDiscordToolActionRunner, } from './tool-actions.js';
8
+ import { DiscordStreamManager } from './stream.js';
9
+ import { logger } from '../../logger.js';
10
+ let client;
11
+ let messageHandler;
12
+ let commandHandler;
13
+ let activeConversationRuns = 0;
14
+ let botMentionRegex = null;
15
+ const MESSAGE_DEBOUNCE_MS = 2_500;
16
+ const DISCORD_RETRY_MAX_ATTEMPTS = 3;
17
+ const DISCORD_RETRY_BASE_DELAY_MS = 500;
18
+ const GUILD_INBOUND_HISTORY_LIMIT = 20;
19
+ const GUILD_INBOUND_HISTORY_MAX_CHARS = 6_000;
20
+ const PARTICIPANT_CONTEXT_MAX_USERS = 30;
21
+ const PARTICIPANT_MEMORY_MAX_CHANNELS = 200;
22
+ const PARTICIPANT_MEMORY_MAX_USERS_PER_CHANNEL = 200;
23
+ const PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER = 8;
24
+ const MAX_PRESENCE_CACHE_USERS = 5_000;
25
+ const discordPresenceCache = new Map();
26
+ function setDiscordPresence(userId, data) {
27
+ discordPresenceCache.set(userId, data);
28
+ if (discordPresenceCache.size > MAX_PRESENCE_CACHE_USERS) {
29
+ const oldestUserId = discordPresenceCache.keys().next().value;
30
+ if (oldestUserId) {
31
+ discordPresenceCache.delete(oldestUserId);
32
+ }
33
+ }
34
+ }
35
+ function getDiscordPresence(userId) {
36
+ return discordPresenceCache.get(userId);
37
+ }
38
+ function buildMentionLookup(messages, pendingHistory, rememberedParticipants) {
39
+ const lookup = { byAlias: new Map() };
40
+ const botUserId = client.user?.id || '';
41
+ const addUser = (userId, aliases) => {
42
+ if (!userId || userId === botUserId)
43
+ return;
44
+ for (const alias of aliases) {
45
+ addMentionAlias(lookup, alias, userId);
46
+ }
47
+ };
48
+ for (const msg of messages) {
49
+ const authorAliases = [msg.author?.username];
50
+ if (msg.member?.displayName)
51
+ authorAliases.push(msg.member.displayName);
52
+ addUser(msg.author.id, authorAliases);
53
+ for (const mentioned of msg.mentions.users.values()) {
54
+ const aliases = [mentioned.username];
55
+ const mentionedMember = msg.mentions.members?.get(mentioned.id);
56
+ if (mentionedMember?.displayName)
57
+ aliases.push(mentionedMember.displayName);
58
+ addUser(mentioned.id, aliases);
59
+ }
60
+ for (const hint of extractMentionAliasHints(msg.content || '')) {
61
+ addMentionAlias(lookup, hint.alias, hint.userId);
62
+ }
63
+ }
64
+ for (const entry of pendingHistory) {
65
+ addUser(entry.userId, [entry.username, entry.displayName]);
66
+ for (const hint of extractMentionAliasHints(entry.content)) {
67
+ addMentionAlias(lookup, hint.alias, hint.userId);
68
+ }
69
+ }
70
+ if (rememberedParticipants) {
71
+ for (const [userId, aliases] of rememberedParticipants) {
72
+ addUser(userId, Array.from(aliases));
73
+ }
74
+ }
75
+ return lookup;
76
+ }
77
+ function summarizePendingHistoryEntry(entry) {
78
+ const author = entry.displayName || entry.username || 'user';
79
+ const authorLabel = entry.isBot ? `${author} [bot]` : author;
80
+ const content = entry.content.trim();
81
+ const snippet = content.length > 300 ? `${content.slice(0, 297)}...` : content;
82
+ return `${authorLabel}: ${snippet}`;
83
+ }
84
+ function buildPendingHistoryContext(entries) {
85
+ if (entries.length === 0)
86
+ return '';
87
+ const selected = [];
88
+ let totalChars = 0;
89
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
90
+ const line = summarizePendingHistoryEntry(entries[i]);
91
+ if (!line)
92
+ continue;
93
+ if (totalChars + line.length > GUILD_INBOUND_HISTORY_MAX_CHARS && selected.length > 0)
94
+ break;
95
+ selected.push(line);
96
+ totalChars += line.length + 1;
97
+ }
98
+ if (selected.length === 0)
99
+ return '';
100
+ selected.reverse();
101
+ return [
102
+ '[InboundHistory]',
103
+ 'Recent channel messages (most recent last):',
104
+ ...selected,
105
+ '',
106
+ '',
107
+ ].join('\n');
108
+ }
109
+ async function buildInboundHistorySnapshot(msg, excludeMessageIds) {
110
+ if (!msg.guild || !('messages' in msg.channel))
111
+ return { entries: [], context: '' };
112
+ try {
113
+ const recentMessages = await msg.channel.messages.fetch({ limit: GUILD_INBOUND_HISTORY_LIMIT });
114
+ const entries = [];
115
+ let hiddenTextCount = 0;
116
+ let hiddenBotTextCount = 0;
117
+ const summarizeHistoryMessageContent = (recent) => {
118
+ const plainText = cleanIncomingContent(recent.content || '').trim();
119
+ if (plainText)
120
+ return plainText;
121
+ const embedChunks = recent.embeds
122
+ .map((embed) => [embed.title?.trim(), embed.description?.trim()].filter(Boolean).join(' — '))
123
+ .map((part) => part.trim())
124
+ .filter(Boolean)
125
+ .slice(0, 3);
126
+ if (embedChunks.length > 0) {
127
+ return `[embed] ${embedChunks.join(' | ')}`;
128
+ }
129
+ const attachmentNames = Array.from(recent.attachments.values())
130
+ .map((attachment) => attachment.name?.trim())
131
+ .filter((name) => Boolean(name))
132
+ .slice(0, 5);
133
+ if (attachmentNames.length > 0) {
134
+ return `[attachments] ${attachmentNames.join(', ')}`;
135
+ }
136
+ const systemContent = recent.system ? (recent.cleanContent || '').trim() : '';
137
+ if (systemContent)
138
+ return `[system] ${systemContent}`;
139
+ hiddenTextCount += 1;
140
+ if (recent.author?.bot)
141
+ hiddenBotTextCount += 1;
142
+ return '[no visible text]';
143
+ };
144
+ for (const recent of recentMessages.values()) {
145
+ if (excludeMessageIds.has(recent.id))
146
+ continue;
147
+ if (!recent.author?.id)
148
+ continue;
149
+ if (recent.author.id === client.user?.id)
150
+ continue;
151
+ const content = summarizeHistoryMessageContent(recent);
152
+ if (!content)
153
+ continue;
154
+ entries.push({
155
+ messageId: recent.id,
156
+ userId: recent.author.id,
157
+ username: recent.author.username || 'user',
158
+ displayName: recent.member?.displayName || null,
159
+ isBot: Boolean(recent.author.bot),
160
+ timestampMs: Number.isFinite(recent.createdTimestamp) ? recent.createdTimestamp : 0,
161
+ content,
162
+ });
163
+ }
164
+ entries.sort((a, b) => a.timestampMs - b.timestampMs || a.messageId.localeCompare(b.messageId));
165
+ let context = buildPendingHistoryContext(entries);
166
+ if (hiddenTextCount > 0) {
167
+ const visibilityNote = [
168
+ '[Discord visibility note]',
169
+ `${hiddenTextCount} recent message(s) had no visible text via API${hiddenBotTextCount > 0 ? ` (${hiddenBotTextCount} from bot users)` : ''}.`,
170
+ 'If asked for exact wording of those messages, say text was not visible in this snapshot.',
171
+ '',
172
+ '',
173
+ ].join('\n');
174
+ context = `${visibilityNote}${context}`;
175
+ }
176
+ return {
177
+ entries,
178
+ context,
179
+ };
180
+ }
181
+ catch (error) {
182
+ logger.debug({ error, guildId: msg.guild.id, channelId: msg.channelId }, 'Failed to build inbound channel history snapshot');
183
+ return { entries: [], context: '' };
184
+ }
185
+ }
186
+ function addParticipantAlias(info, alias) {
187
+ const normalized = normalizeMentionAlias(alias);
188
+ if (!normalized)
189
+ return;
190
+ info.aliases.add(normalized);
191
+ }
192
+ function formatDiscordHandleFromAlias(alias) {
193
+ const normalized = normalizeMentionAlias(alias);
194
+ if (!normalized)
195
+ return null;
196
+ return `@${normalized}`;
197
+ }
198
+ function buildParticipantContext(messages, pendingHistory, rememberedParticipants) {
199
+ const participants = new Map();
200
+ const botUserId = client.user?.id || '';
201
+ const botParticipantIds = new Set();
202
+ const upsert = (userId) => {
203
+ let info = participants.get(userId);
204
+ if (!info) {
205
+ info = { id: userId, aliases: new Set() };
206
+ participants.set(userId, info);
207
+ }
208
+ return info;
209
+ };
210
+ for (const msg of messages) {
211
+ if (!msg.author?.id || msg.author.id === botUserId)
212
+ continue;
213
+ const info = upsert(msg.author.id);
214
+ if (msg.author.bot) {
215
+ botParticipantIds.add(msg.author.id);
216
+ }
217
+ addParticipantAlias(info, msg.author.username);
218
+ addParticipantAlias(info, msg.member?.displayName);
219
+ for (const mentioned of msg.mentions.users.values()) {
220
+ if (!mentioned.id || mentioned.id === botUserId)
221
+ continue;
222
+ const mentionedInfo = upsert(mentioned.id);
223
+ if (mentioned.bot) {
224
+ botParticipantIds.add(mentioned.id);
225
+ }
226
+ addParticipantAlias(mentionedInfo, mentioned.username);
227
+ const mentionedMember = msg.mentions.members?.get(mentioned.id);
228
+ addParticipantAlias(mentionedInfo, mentionedMember?.displayName);
229
+ }
230
+ for (const hint of extractMentionAliasHints(msg.content || '')) {
231
+ if (hint.userId === botUserId)
232
+ continue;
233
+ const hintedInfo = upsert(hint.userId);
234
+ addParticipantAlias(hintedInfo, hint.alias);
235
+ }
236
+ }
237
+ for (const entry of pendingHistory) {
238
+ if (!entry.userId || entry.userId === botUserId)
239
+ continue;
240
+ const info = upsert(entry.userId);
241
+ if (entry.isBot) {
242
+ botParticipantIds.add(entry.userId);
243
+ }
244
+ addParticipantAlias(info, entry.username);
245
+ addParticipantAlias(info, entry.displayName);
246
+ for (const hint of extractMentionAliasHints(entry.content)) {
247
+ if (hint.userId === botUserId)
248
+ continue;
249
+ const hintedInfo = upsert(hint.userId);
250
+ addParticipantAlias(hintedInfo, hint.alias);
251
+ }
252
+ }
253
+ if (rememberedParticipants) {
254
+ for (const [userId, aliases] of rememberedParticipants) {
255
+ if (!userId || userId === botUserId)
256
+ continue;
257
+ const info = upsert(userId);
258
+ for (const alias of aliases) {
259
+ addParticipantAlias(info, alias);
260
+ }
261
+ }
262
+ }
263
+ if (participants.size === 0)
264
+ return '';
265
+ const lines = Array.from(participants.values())
266
+ .filter((entry) => entry.aliases.size > 0)
267
+ .sort((a, b) => a.id.localeCompare(b.id))
268
+ .slice(0, PARTICIPANT_CONTEXT_MAX_USERS)
269
+ .map((entry) => {
270
+ const aliases = Array.from(entry.aliases).slice(0, 3);
271
+ const preferredHandle = formatDiscordHandleFromAlias(aliases[0]) || `id:${entry.id}`;
272
+ const botSuffix = botParticipantIds.has(entry.id) ? ' [bot]' : '';
273
+ return `- ${preferredHandle}${botSuffix} id:${entry.id} aliases: ${aliases.join(', ')}`;
274
+ });
275
+ if (lines.length === 0)
276
+ return '';
277
+ return [
278
+ '[Known participants]',
279
+ 'Use @handles from this list in normal replies.',
280
+ 'Use raw <@id> mention syntax only when the user explicitly asks for mention IDs/tokens.',
281
+ 'This list is derived from recent and remembered context; it may be incomplete.',
282
+ ...lines,
283
+ '',
284
+ ].join('\n');
285
+ }
286
+ function requireDiscordClientReady() {
287
+ if (!client) {
288
+ throw new Error('Discord client is not initialized.');
289
+ }
290
+ if (!client.isReady()) {
291
+ throw new Error('Discord client is not ready yet.');
292
+ }
293
+ return client;
294
+ }
295
+ const runDiscordToolActionInternal = createDiscordToolActionRunner({
296
+ requireDiscordClientReady,
297
+ getDiscordPresence,
298
+ });
299
+ export async function runDiscordToolAction(request) {
300
+ return await runDiscordToolActionInternal(request);
301
+ }
302
+ function getSessionId(msg) {
303
+ return buildSessionIdFromContext(msg.guild?.id ?? null, msg.channelId, msg.author.id);
304
+ }
305
+ function hasPrefixInvocation(content) {
306
+ return hasPrefixInvocationInbound(content, botMentionRegex, DISCORD_PREFIX);
307
+ }
308
+ function isAuthorizedCommandUserId(userId) {
309
+ const configuredUserId = DISCORD_COMMAND_USER_ID.trim();
310
+ if (!configuredUserId)
311
+ return true;
312
+ return userId === configuredUserId;
313
+ }
314
+ function buildSessionIdFromContext(guildId, channelId, userId) {
315
+ return buildSessionIdFromContextInbound(guildId, channelId, userId);
316
+ }
317
+ function isTrigger(msg) {
318
+ return isTriggerInbound({
319
+ content: msg.content,
320
+ isDm: !msg.guild,
321
+ commandsOnly: DISCORD_COMMANDS_ONLY,
322
+ respondToAllMessages: DISCORD_RESPOND_TO_ALL_MESSAGES,
323
+ prefix: DISCORD_PREFIX,
324
+ botMentionRegex,
325
+ hasBotMention: Boolean(client.user && msg.mentions.has(client.user)),
326
+ });
327
+ }
328
+ function parseCommand(content) {
329
+ return parseCommandInbound(content, botMentionRegex, DISCORD_PREFIX);
330
+ }
331
+ function isRetryableDiscordError(error) {
332
+ const maybe = error;
333
+ const status = maybe.status ?? maybe.httpStatus;
334
+ return status === 429 || (typeof status === 'number' && status >= 500 && status <= 599);
335
+ }
336
+ function retryDelayMs(error, fallbackMs) {
337
+ const maybe = error;
338
+ const retryAfterSeconds = maybe.retryAfter ?? maybe.data?.retry_after;
339
+ if (typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
340
+ return Math.max(50, Math.ceil(retryAfterSeconds * 1_000));
341
+ }
342
+ return fallbackMs + Math.floor(Math.random() * 250);
343
+ }
344
+ async function withDiscordRetry(label, fn) {
345
+ let attempt = 0;
346
+ let delayMs = DISCORD_RETRY_BASE_DELAY_MS;
347
+ while (true) {
348
+ attempt += 1;
349
+ try {
350
+ return await fn();
351
+ }
352
+ catch (error) {
353
+ if (attempt >= DISCORD_RETRY_MAX_ATTEMPTS || !isRetryableDiscordError(error)) {
354
+ throw error;
355
+ }
356
+ const waitMs = retryDelayMs(error, delayMs);
357
+ logger.warn({ label, attempt, waitMs, error }, 'Discord API call failed; retrying');
358
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
359
+ delayMs = Math.min(delayMs * 2, 4_000);
360
+ }
361
+ }
362
+ }
363
+ function cleanIncomingContent(content) {
364
+ return cleanIncomingContentInbound(content, botMentionRegex, DISCORD_PREFIX);
365
+ }
366
+ function summarizeContextMessage(msg) {
367
+ const author = msg.author?.username || 'user';
368
+ const content = (msg.content || '').trim();
369
+ const snippet = content.length > 500 ? `${content.slice(0, 497)}...` : content;
370
+ return `${author}: ${snippet || '(no text)'}`;
371
+ }
372
+ function buildChannelInfoContext(msg) {
373
+ if (!msg.guild)
374
+ return '';
375
+ const lines = [
376
+ '[Channel info]',
377
+ `- guild_id: ${msg.guild.id}`,
378
+ `- channel_id: ${msg.channelId}`,
379
+ ];
380
+ const namedChannel = msg.channel;
381
+ const channelName = typeof namedChannel.name === 'string' ? namedChannel.name.trim() : '';
382
+ if (channelName) {
383
+ lines.push(`- channel_name: #${channelName}`);
384
+ }
385
+ const channelTopic = typeof namedChannel.topic === 'string' ? namedChannel.topic.trim() : '';
386
+ if (channelTopic) {
387
+ lines.push(`- channel_topic: ${channelTopic}`);
388
+ }
389
+ const parentName = typeof namedChannel.parent?.name === 'string' ? namedChannel.parent.name.trim() : '';
390
+ if (parentName) {
391
+ lines.push(`- parent_channel: ${parentName}`);
392
+ }
393
+ lines.push('');
394
+ return `${lines.join('\n')}\n`;
395
+ }
396
+ async function buildReplyContext(msg) {
397
+ const blocks = [];
398
+ if ('isThread' in msg.channel && typeof msg.channel.isThread === 'function' && msg.channel.isThread()) {
399
+ try {
400
+ const starter = await msg.channel.fetchStarterMessage();
401
+ if (starter) {
402
+ blocks.push(`[Thread starter]\n${summarizeContextMessage(starter)}`);
403
+ }
404
+ }
405
+ catch (error) {
406
+ logger.debug({ error, channelId: msg.channelId }, 'Failed to fetch thread starter message');
407
+ }
408
+ }
409
+ const replyLines = [];
410
+ let replyId = msg.reference?.messageId || null;
411
+ let depth = 0;
412
+ while (replyId && depth < 5) {
413
+ try {
414
+ const referenced = await msg.channel.messages.fetch(replyId);
415
+ replyLines.push(summarizeContextMessage(referenced));
416
+ replyId = referenced.reference?.messageId || null;
417
+ depth += 1;
418
+ }
419
+ catch {
420
+ break;
421
+ }
422
+ }
423
+ if (replyLines.length > 0) {
424
+ blocks.push(`[Reply context]\n${replyLines.reverse().join('\n')}`);
425
+ }
426
+ if (blocks.length === 0)
427
+ return '';
428
+ return `${blocks.join('\n\n')}\n\n`;
429
+ }
430
+ async function addProcessingReaction(msg) {
431
+ if (!client.user)
432
+ return async () => { };
433
+ const botUserId = client.user.id;
434
+ try {
435
+ await withDiscordRetry('react', () => msg.react('👀'));
436
+ }
437
+ catch (error) {
438
+ logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to add processing reaction');
439
+ return async () => { };
440
+ }
441
+ return async () => {
442
+ try {
443
+ const reaction = msg.reactions.resolve('👀');
444
+ if (!reaction)
445
+ return;
446
+ await withDiscordRetry('reaction-remove', () => reaction.users.remove(botUserId));
447
+ }
448
+ catch (error) {
449
+ logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to remove processing reaction');
450
+ }
451
+ };
452
+ }
453
+ function startTypingLoop(msg) {
454
+ let stopped = false;
455
+ const sendTyping = async () => {
456
+ if (stopped)
457
+ return;
458
+ if (!('sendTyping' in msg.channel))
459
+ return;
460
+ try {
461
+ await msg.channel.sendTyping();
462
+ }
463
+ catch (error) {
464
+ logger.debug({ error, channelId: msg.channelId }, 'Failed to send typing indicator');
465
+ }
466
+ };
467
+ void sendTyping();
468
+ const timer = setInterval(() => {
469
+ void sendTyping();
470
+ }, 8_000);
471
+ return {
472
+ stop: () => {
473
+ if (stopped)
474
+ return;
475
+ stopped = true;
476
+ clearInterval(timer);
477
+ },
478
+ };
479
+ }
480
+ async function sendChunkedReply(msg, text, files, mentionLookup) {
481
+ await sendChunkedReplyFromDelivery({
482
+ msg,
483
+ text,
484
+ withRetry: withDiscordRetry,
485
+ ...(files ? { files } : {}),
486
+ ...(mentionLookup ? { mentionLookup } : {}),
487
+ });
488
+ }
489
+ async function sendChunkedDirectReply(msg, text, files, mentionLookup) {
490
+ await sendChunkedDirectReplyFromDelivery({
491
+ msg,
492
+ text,
493
+ withRetry: withDiscordRetry,
494
+ ...(files ? { files } : {}),
495
+ ...(mentionLookup ? { mentionLookup } : {}),
496
+ });
497
+ }
498
+ async function sendChunkedInteractionReply(interaction, text, files) {
499
+ await sendChunkedInteractionReplyFromDelivery({
500
+ interaction,
501
+ text,
502
+ withRetry: withDiscordRetry,
503
+ ...(files ? { files } : {}),
504
+ });
505
+ }
506
+ async function ensureSlashStatusCommand() {
507
+ const definition = {
508
+ name: 'status',
509
+ description: 'Show HybridClaw runtime status (only visible to you)',
510
+ };
511
+ if (!client.application)
512
+ return;
513
+ await Promise.allSettled([...client.guilds.cache.values()].map(async (guild) => {
514
+ try {
515
+ const existing = await guild.commands.fetch();
516
+ const current = existing.find((command) => command.name === definition.name);
517
+ if (!current) {
518
+ await guild.commands.create(definition);
519
+ logger.info({ guildId: guild.id }, 'Registered slash command /status');
520
+ return;
521
+ }
522
+ if (current.description !== definition.description) {
523
+ await guild.commands.edit(current.id, definition);
524
+ logger.info({ guildId: guild.id }, 'Updated slash command /status');
525
+ }
526
+ }
527
+ catch (error) {
528
+ logger.warn({ error, guildId: guild.id }, 'Failed to register slash command /status');
529
+ }
530
+ }));
531
+ }
532
+ function updatePresence() {
533
+ if (!client.user)
534
+ return;
535
+ if (activeConversationRuns > 0) {
536
+ client.user.setPresence({
537
+ activities: [{ name: 'Thinking...', type: ActivityType.Playing }],
538
+ status: 'online',
539
+ });
540
+ return;
541
+ }
542
+ client.user.setPresence({
543
+ activities: [{ name: `in ${client.guilds.cache.size} servers`, type: ActivityType.Listening }],
544
+ status: 'online',
545
+ });
546
+ }
547
+ export function initDiscord(onMessage, onCommand) {
548
+ messageHandler = onMessage;
549
+ commandHandler = onCommand;
550
+ const pendingBatches = new Map();
551
+ const inFlightByMessageId = new Map();
552
+ const negativeFeedbackByChannel = new Map();
553
+ const participantMemoryByChannel = new Map();
554
+ const touchParticipantMemoryChannel = (channelId) => {
555
+ const existing = participantMemoryByChannel.get(channelId);
556
+ if (existing) {
557
+ participantMemoryByChannel.delete(channelId);
558
+ participantMemoryByChannel.set(channelId, existing);
559
+ return existing;
560
+ }
561
+ const created = new Map();
562
+ participantMemoryByChannel.set(channelId, created);
563
+ while (participantMemoryByChannel.size > PARTICIPANT_MEMORY_MAX_CHANNELS) {
564
+ const oldestKey = participantMemoryByChannel.keys().next().value;
565
+ if (!oldestKey)
566
+ break;
567
+ participantMemoryByChannel.delete(oldestKey);
568
+ }
569
+ return created;
570
+ };
571
+ const rememberParticipantAliasForChannel = (channelId, userId, rawAlias) => {
572
+ if (!userId || userId === client.user?.id)
573
+ return;
574
+ const alias = normalizeMentionAlias(rawAlias);
575
+ if (!alias)
576
+ return;
577
+ const channelMemory = touchParticipantMemoryChannel(channelId);
578
+ let aliases = channelMemory.get(userId);
579
+ if (!aliases) {
580
+ aliases = new Set();
581
+ channelMemory.set(userId, aliases);
582
+ while (channelMemory.size > PARTICIPANT_MEMORY_MAX_USERS_PER_CHANNEL) {
583
+ const oldestUserId = channelMemory.keys().next().value;
584
+ if (!oldestUserId)
585
+ break;
586
+ channelMemory.delete(oldestUserId);
587
+ }
588
+ }
589
+ aliases.add(alias);
590
+ if (aliases.size > PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER) {
591
+ const kept = new Set(Array.from(aliases).slice(-PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER));
592
+ channelMemory.set(userId, kept);
593
+ }
594
+ // Refresh user recency.
595
+ const refreshed = channelMemory.get(userId);
596
+ if (refreshed) {
597
+ channelMemory.delete(userId);
598
+ channelMemory.set(userId, refreshed);
599
+ }
600
+ };
601
+ const rememberParticipantForChannel = (channelId, userId, aliases) => {
602
+ if (!userId || userId === client.user?.id)
603
+ return;
604
+ for (const alias of aliases) {
605
+ rememberParticipantAliasForChannel(channelId, userId, alias);
606
+ }
607
+ };
608
+ const observeMessageParticipants = (msg, content) => {
609
+ if (!msg.guild)
610
+ return;
611
+ rememberParticipantForChannel(msg.channelId, msg.author.id, [
612
+ msg.author.username,
613
+ msg.member?.displayName,
614
+ ]);
615
+ for (const mentioned of msg.mentions.users.values()) {
616
+ const mentionedMember = msg.mentions.members?.get(mentioned.id);
617
+ rememberParticipantForChannel(msg.channelId, mentioned.id, [
618
+ mentioned.username,
619
+ mentionedMember?.displayName,
620
+ ]);
621
+ }
622
+ for (const hint of extractMentionAliasHints(content)) {
623
+ rememberParticipantAliasForChannel(msg.channelId, hint.userId, hint.alias);
624
+ }
625
+ };
626
+ const intents = [
627
+ GatewayIntentBits.Guilds,
628
+ GatewayIntentBits.GuildMessages,
629
+ GatewayIntentBits.GuildMessageReactions,
630
+ GatewayIntentBits.MessageContent,
631
+ GatewayIntentBits.DirectMessages,
632
+ ];
633
+ if (DISCORD_GUILD_MEMBERS_INTENT)
634
+ intents.push(GatewayIntentBits.GuildMembers);
635
+ if (DISCORD_PRESENCE_INTENT)
636
+ intents.push(GatewayIntentBits.GuildPresences);
637
+ client = new Client({
638
+ intents,
639
+ partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
640
+ });
641
+ client.on('presenceUpdate', (_oldPresence, nextPresence) => {
642
+ const userId = nextPresence.userId || nextPresence.user?.id;
643
+ if (!userId)
644
+ return;
645
+ setDiscordPresence(userId, {
646
+ status: nextPresence.status,
647
+ activities: nextPresence.activities.map((activity) => ({
648
+ type: activity.type,
649
+ name: activity.name,
650
+ state: activity.state || null,
651
+ details: activity.details || null,
652
+ })),
653
+ });
654
+ });
655
+ client.on('clientReady', () => {
656
+ logger.info({ user: client.user?.tag }, 'Discord bot connected');
657
+ if (client.user) {
658
+ botMentionRegex = new RegExp(`<@!?${client.user.id}>`, 'g');
659
+ }
660
+ updatePresence();
661
+ void ensureSlashStatusCommand();
662
+ });
663
+ client.on('interactionCreate', async (interaction) => {
664
+ if (!interaction.isChatInputCommand())
665
+ return;
666
+ if (interaction.commandName !== 'status')
667
+ return;
668
+ if (!isAuthorizedCommandUserId(interaction.user.id)) {
669
+ await sendChunkedInteractionReply(interaction, 'You are not authorized to run commands for this bot.');
670
+ return;
671
+ }
672
+ const guildId = interaction.guildId ?? null;
673
+ const channelId = interaction.channelId;
674
+ const sessionId = buildSessionIdFromContext(guildId, channelId, interaction.user.id);
675
+ try {
676
+ await commandHandler(sessionId, guildId, channelId, ['status'], async (text, files) => sendChunkedInteractionReply(interaction, text, files));
677
+ }
678
+ catch (error) {
679
+ const detail = error instanceof Error ? error.message : String(error);
680
+ logger.error({ error, guildId, channelId, userId: interaction.user.id }, 'Discord slash /status command failed');
681
+ await sendChunkedInteractionReply(interaction, formatError('Gateway Error', detail));
682
+ }
683
+ });
684
+ const dispatchConversationBatch = async (batchKey) => {
685
+ const pending = pendingBatches.get(batchKey);
686
+ if (!pending)
687
+ return;
688
+ pendingBatches.delete(batchKey);
689
+ const items = pending.items;
690
+ if (items.length === 0)
691
+ return;
692
+ const sourceItem = items[items.length - 1];
693
+ const msg = sourceItem.msg;
694
+ const sessionId = getSessionId(msg);
695
+ const guildId = msg.guild?.id || null;
696
+ const channelId = msg.channelId;
697
+ const userId = msg.author.id;
698
+ const username = msg.author.username;
699
+ const batchedContent = items.length > 1
700
+ ? items.map((item, index) => `Message ${index + 1}:\n${item.content}`).join('\n\n')
701
+ : sourceItem.content;
702
+ const channelInfoContext = buildChannelInfoContext(msg);
703
+ const replyContext = await buildReplyContext(msg);
704
+ const feedbackNote = negativeFeedbackByChannel.get(channelId) || '';
705
+ if (feedbackNote) {
706
+ negativeFeedbackByChannel.delete(channelId);
707
+ }
708
+ const currentBatchMessageIds = new Set(items.map((item) => item.msg.id));
709
+ const inboundHistory = await buildInboundHistorySnapshot(msg, currentBatchMessageIds);
710
+ const attachmentContext = await buildAttachmentContext(items.map((item) => item.msg));
711
+ const rememberedParticipants = participantMemoryByChannel.get(msg.channelId);
712
+ const participantContext = buildParticipantContext(items.map((item) => item.msg), inboundHistory.entries, rememberedParticipants);
713
+ const mentionLookup = buildMentionLookup(items.map((item) => item.msg), inboundHistory.entries, rememberedParticipants);
714
+ const combinedContent = `${feedbackNote ? `[Reaction feedback]\n${feedbackNote}\n\n` : ''}${channelInfoContext}${replyContext}${inboundHistory.context}${attachmentContext.context}${participantContext}${batchedContent}`;
715
+ const abortController = new AbortController();
716
+ const typingLoop = startTypingLoop(msg);
717
+ const stream = new DiscordStreamManager(msg, {
718
+ onFirstMessage: () => typingLoop.stop(),
719
+ });
720
+ const inFlight = {
721
+ abortController,
722
+ stream,
723
+ messageIds: new Set(items.map((item) => item.msg.id)),
724
+ aborted: false,
725
+ };
726
+ for (const messageId of inFlight.messageIds) {
727
+ inFlightByMessageId.set(messageId, inFlight);
728
+ }
729
+ try {
730
+ activeConversationRuns += 1;
731
+ updatePresence();
732
+ await messageHandler(sessionId, guildId, channelId, userId, username, combinedContent, attachmentContext.media, async (text, files) => {
733
+ typingLoop.stop();
734
+ await sendChunkedReply(msg, text, files, mentionLookup);
735
+ }, {
736
+ sourceMessage: msg,
737
+ batchedMessages: items.map((item) => item.msg),
738
+ abortSignal: abortController.signal,
739
+ stream,
740
+ mentionLookup,
741
+ });
742
+ }
743
+ catch (error) {
744
+ logger.error({ error, channelId, sessionId }, 'Conversation batch handling failed');
745
+ const detail = error instanceof Error ? error.message : String(error);
746
+ if (stream.hasSentMessages()) {
747
+ await stream.fail(formatError('Gateway Error', detail));
748
+ }
749
+ else {
750
+ await sendChunkedReply(msg, formatError('Gateway Error', detail), undefined, mentionLookup);
751
+ }
752
+ }
753
+ finally {
754
+ activeConversationRuns = Math.max(0, activeConversationRuns - 1);
755
+ updatePresence();
756
+ for (const messageId of inFlight.messageIds) {
757
+ if (inFlightByMessageId.get(messageId) === inFlight) {
758
+ inFlightByMessageId.delete(messageId);
759
+ }
760
+ }
761
+ typingLoop.stop();
762
+ await Promise.all(items.map(async (item) => {
763
+ await item.clearReaction();
764
+ }));
765
+ }
766
+ };
767
+ const queueConversationMessage = async (msg, content) => {
768
+ const key = `${msg.channelId}:${msg.author.id}`;
769
+ const clearReaction = await addProcessingReaction(msg);
770
+ const queued = { msg, content, clearReaction };
771
+ const existing = pendingBatches.get(key);
772
+ if (!existing) {
773
+ const timer = setTimeout(() => {
774
+ void dispatchConversationBatch(key);
775
+ }, MESSAGE_DEBOUNCE_MS);
776
+ pendingBatches.set(key, {
777
+ items: [queued],
778
+ timer,
779
+ });
780
+ return;
781
+ }
782
+ clearTimeout(existing.timer);
783
+ existing.items.push(queued);
784
+ existing.timer = setTimeout(() => {
785
+ void dispatchConversationBatch(key);
786
+ }, MESSAGE_DEBOUNCE_MS);
787
+ };
788
+ const dropPendingMessage = async (messageId) => {
789
+ for (const [key, pending] of pendingBatches) {
790
+ const index = pending.items.findIndex((item) => item.msg.id === messageId);
791
+ if (index === -1)
792
+ continue;
793
+ const [removed] = pending.items.splice(index, 1);
794
+ await removed.clearReaction();
795
+ if (pending.items.length === 0) {
796
+ clearTimeout(pending.timer);
797
+ pendingBatches.delete(key);
798
+ }
799
+ return;
800
+ }
801
+ };
802
+ const updatePendingMessage = async (messageId, nextMsg, nextContent) => {
803
+ for (const [key, pending] of pendingBatches) {
804
+ const index = pending.items.findIndex((item) => item.msg.id === messageId);
805
+ if (index === -1)
806
+ continue;
807
+ if (!nextContent) {
808
+ const [removed] = pending.items.splice(index, 1);
809
+ await removed.clearReaction();
810
+ }
811
+ else {
812
+ pending.items[index].msg = nextMsg;
813
+ pending.items[index].content = nextContent;
814
+ }
815
+ if (pending.items.length === 0) {
816
+ clearTimeout(pending.timer);
817
+ pendingBatches.delete(key);
818
+ }
819
+ return true;
820
+ }
821
+ return false;
822
+ };
823
+ client.on('messageCreate', async (msg) => {
824
+ if (msg.author.bot)
825
+ return;
826
+ const sessionId = getSessionId(msg);
827
+ const guildId = msg.guild?.id || null;
828
+ const channelId = msg.channelId;
829
+ const content = cleanIncomingContent(msg.content);
830
+ observeMessageParticipants(msg, content);
831
+ const immediateMentionLookup = buildMentionLookup([msg], [], msg.guild ? participantMemoryByChannel.get(msg.channelId) : undefined);
832
+ const reply = async (text, files) => {
833
+ await sendChunkedReply(msg, text, files, immediateMentionLookup);
834
+ };
835
+ const commandReply = async (text, files) => {
836
+ try {
837
+ await sendChunkedDirectReply(msg, text, files, immediateMentionLookup);
838
+ }
839
+ catch (error) {
840
+ logger.warn({ error, userId: msg.author.id, channelId: msg.channelId }, 'Failed to send command reply via DM; command response dropped');
841
+ }
842
+ };
843
+ const parsed = parseCommand(msg.content);
844
+ const prefixedToken = hasPrefixInvocation(msg.content)
845
+ ? cleanIncomingContent(msg.content).split(/\s+/)[0]?.toLowerCase() || ''
846
+ : '';
847
+ const ignorePrefixCommand = prefixedToken === 'status';
848
+ if (DISCORD_COMMANDS_ONLY) {
849
+ if (!hasPrefixInvocation(msg.content))
850
+ return;
851
+ if (!isAuthorizedCommandUserId(msg.author.id)) {
852
+ logger.debug({ userId: msg.author.id, channelId: msg.channelId }, 'Ignoring unauthorized Discord command in commands-only mode');
853
+ return;
854
+ }
855
+ if (ignorePrefixCommand) {
856
+ return;
857
+ }
858
+ if (!parsed.isCommand) {
859
+ if (!content) {
860
+ await commandReply(`How can I help? Try \`${DISCORD_PREFIX} help\`.`);
861
+ }
862
+ else {
863
+ await commandReply(`Unknown command. Try \`${DISCORD_PREFIX} help\`.`);
864
+ }
865
+ return;
866
+ }
867
+ await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
868
+ return;
869
+ }
870
+ if (!isTrigger(msg))
871
+ return;
872
+ if (ignorePrefixCommand) {
873
+ return;
874
+ }
875
+ if (parsed.isCommand && hasPrefixInvocation(msg.content)) {
876
+ if (!isAuthorizedCommandUserId(msg.author.id)) {
877
+ logger.debug({ userId: msg.author.id, channelId: msg.channelId }, 'Ignoring unauthorized Discord command; processing as normal chat message');
878
+ }
879
+ else {
880
+ await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
881
+ return;
882
+ }
883
+ }
884
+ if (!content) {
885
+ await reply('How can I help? Send me a message or try `!claw help`.');
886
+ return;
887
+ }
888
+ await queueConversationMessage(msg, content);
889
+ });
890
+ client.on('messageUpdate', async (_oldMsg, nextMsg) => {
891
+ if (DISCORD_COMMANDS_ONLY)
892
+ return;
893
+ const fetched = nextMsg.partial
894
+ ? await nextMsg.fetch().catch(() => null)
895
+ : nextMsg;
896
+ if (!fetched)
897
+ return;
898
+ if (fetched.author?.bot)
899
+ return;
900
+ const updatedContent = cleanIncomingContent(fetched.content || '');
901
+ observeMessageParticipants(fetched, updatedContent);
902
+ if (!isTrigger(fetched))
903
+ return;
904
+ await updatePendingMessage(fetched.id, fetched, updatedContent);
905
+ const inFlight = inFlightByMessageId.get(fetched.id);
906
+ if (!inFlight || inFlight.aborted)
907
+ return;
908
+ inFlight.aborted = true;
909
+ inFlight.abortController.abort();
910
+ for (const messageId of inFlight.messageIds) {
911
+ if (inFlightByMessageId.get(messageId) === inFlight) {
912
+ inFlightByMessageId.delete(messageId);
913
+ }
914
+ }
915
+ await inFlight.stream.discard();
916
+ if (updatedContent) {
917
+ await queueConversationMessage(fetched, updatedContent);
918
+ }
919
+ });
920
+ client.on('messageDelete', async (msg) => {
921
+ await dropPendingMessage(msg.id);
922
+ const inFlight = inFlightByMessageId.get(msg.id);
923
+ if (!inFlight || inFlight.aborted)
924
+ return;
925
+ inFlight.aborted = true;
926
+ inFlight.abortController.abort();
927
+ for (const messageId of inFlight.messageIds) {
928
+ if (inFlightByMessageId.get(messageId) === inFlight) {
929
+ inFlightByMessageId.delete(messageId);
930
+ }
931
+ }
932
+ await inFlight.stream.discard();
933
+ });
934
+ client.on('messageReactionAdd', async (reaction, user) => {
935
+ if (user.bot)
936
+ return;
937
+ const fullReaction = reaction.partial
938
+ ? await reaction.fetch().catch(() => null)
939
+ : reaction;
940
+ if (!fullReaction)
941
+ return;
942
+ if (fullReaction.emoji.name !== '👎')
943
+ return;
944
+ const message = fullReaction.message.partial
945
+ ? await fullReaction.message.fetch().catch(() => null)
946
+ : fullReaction.message;
947
+ if (!message)
948
+ return;
949
+ if (!client.user || message.author?.id !== client.user.id)
950
+ return;
951
+ negativeFeedbackByChannel.set(message.channelId, `${user.username} reacted with 👎 to assistant message ${message.id}.`);
952
+ });
953
+ if (!DISCORD_TOKEN) {
954
+ throw new Error('DISCORD_TOKEN is required to start the Discord bot');
955
+ }
956
+ client.login(DISCORD_TOKEN);
957
+ return client;
958
+ }
959
+ /**
960
+ * Send a message to a channel by ID (used by scheduler).
961
+ */
962
+ export async function sendToChannel(channelId, text, files) {
963
+ const channel = await client.channels.fetch(channelId);
964
+ if (channel && 'send' in channel) {
965
+ const payloads = prepareChunkedPayloads(text, files);
966
+ const send = channel.send;
967
+ for (const payload of payloads) {
968
+ await withDiscordRetry('send-channel', () => send(payload));
969
+ }
970
+ }
971
+ }
972
+ //# sourceMappingURL=runtime.js.map