@hybridaione/hybridclaw 0.1.21 → 0.1.24

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 (113) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +50 -8
  3. package/config.example.json +3 -0
  4. package/container/package-lock.json +2 -2
  5. package/container/package.json +1 -1
  6. package/container/src/browser-tools.ts +53 -3
  7. package/container/src/hybridai-client.ts +270 -8
  8. package/container/src/index.ts +66 -3
  9. package/container/src/token-usage.ts +89 -0
  10. package/container/src/tools.ts +9 -2
  11. package/container/src/types.ts +19 -0
  12. package/container/src/web-fetch.ts +98 -7
  13. package/dist/agent.d.ts +1 -1
  14. package/dist/agent.d.ts.map +1 -1
  15. package/dist/agent.js +2 -2
  16. package/dist/agent.js.map +1 -1
  17. package/dist/chunk.d.ts +6 -0
  18. package/dist/chunk.d.ts.map +1 -0
  19. package/dist/chunk.js +129 -0
  20. package/dist/chunk.js.map +1 -0
  21. package/dist/container-runner.d.ts +1 -1
  22. package/dist/container-runner.d.ts.map +1 -1
  23. package/dist/container-runner.js +25 -1
  24. package/dist/container-runner.js.map +1 -1
  25. package/dist/conversation.d.ts +4 -0
  26. package/dist/conversation.d.ts.map +1 -1
  27. package/dist/conversation.js +13 -3
  28. package/dist/conversation.js.map +1 -1
  29. package/dist/discord-stream.d.ts +32 -0
  30. package/dist/discord-stream.d.ts.map +1 -0
  31. package/dist/discord-stream.js +196 -0
  32. package/dist/discord-stream.js.map +1 -0
  33. package/dist/discord.d.ts +9 -2
  34. package/dist/discord.d.ts.map +1 -1
  35. package/dist/discord.js +452 -23
  36. package/dist/discord.js.map +1 -1
  37. package/dist/gateway-client.d.ts.map +1 -1
  38. package/dist/gateway-client.js +5 -0
  39. package/dist/gateway-client.js.map +1 -1
  40. package/dist/gateway-service.d.ts +1 -0
  41. package/dist/gateway-service.d.ts.map +1 -1
  42. package/dist/gateway-service.js +60 -2
  43. package/dist/gateway-service.js.map +1 -1
  44. package/dist/gateway-types.d.ts +7 -1
  45. package/dist/gateway-types.d.ts.map +1 -1
  46. package/dist/gateway-types.js.map +1 -1
  47. package/dist/gateway.js +55 -4
  48. package/dist/gateway.js.map +1 -1
  49. package/dist/health.d.ts.map +1 -1
  50. package/dist/health.js +7 -0
  51. package/dist/health.js.map +1 -1
  52. package/dist/heartbeat.d.ts.map +1 -1
  53. package/dist/heartbeat.js +20 -0
  54. package/dist/heartbeat.js.map +1 -1
  55. package/dist/observability-ingest.d.ts.map +1 -1
  56. package/dist/observability-ingest.js +26 -0
  57. package/dist/observability-ingest.js.map +1 -1
  58. package/dist/prompt-hooks.d.ts +2 -0
  59. package/dist/prompt-hooks.d.ts.map +1 -1
  60. package/dist/prompt-hooks.js +29 -0
  61. package/dist/prompt-hooks.js.map +1 -1
  62. package/dist/runtime-config.d.ts +3 -0
  63. package/dist/runtime-config.d.ts.map +1 -1
  64. package/dist/runtime-config.js +17 -1
  65. package/dist/runtime-config.js.map +1 -1
  66. package/dist/scheduled-task-runner.d.ts.map +1 -1
  67. package/dist/scheduled-task-runner.js +20 -0
  68. package/dist/scheduled-task-runner.js.map +1 -1
  69. package/dist/session-maintenance.d.ts.map +1 -1
  70. package/dist/session-maintenance.js +1 -0
  71. package/dist/session-maintenance.js.map +1 -1
  72. package/dist/skills-guard.d.ts +36 -0
  73. package/dist/skills-guard.d.ts.map +1 -0
  74. package/dist/skills-guard.js +607 -0
  75. package/dist/skills-guard.js.map +1 -0
  76. package/dist/skills.d.ts +13 -2
  77. package/dist/skills.d.ts.map +1 -1
  78. package/dist/skills.js +494 -59
  79. package/dist/skills.js.map +1 -1
  80. package/dist/token-efficiency.d.ts +41 -0
  81. package/dist/token-efficiency.d.ts.map +1 -0
  82. package/dist/token-efficiency.js +164 -0
  83. package/dist/token-efficiency.js.map +1 -0
  84. package/dist/types.d.ts +11 -0
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/workspace.d.ts.map +1 -1
  87. package/dist/workspace.js +2 -1
  88. package/dist/workspace.js.map +1 -1
  89. package/docs/index.html +33 -7
  90. package/package.json +1 -1
  91. package/src/agent.ts +15 -1
  92. package/src/chunk.ts +153 -0
  93. package/src/container-runner.ts +24 -0
  94. package/src/conversation.ts +28 -4
  95. package/src/discord-stream.ts +240 -0
  96. package/src/discord.ts +517 -23
  97. package/src/gateway-client.ts +7 -0
  98. package/src/gateway-service.ts +72 -1
  99. package/src/gateway-types.ts +12 -1
  100. package/src/gateway.ts +65 -4
  101. package/src/health.ts +8 -0
  102. package/src/heartbeat.ts +20 -0
  103. package/src/observability-ingest.ts +24 -0
  104. package/src/prompt-hooks.ts +29 -0
  105. package/src/runtime-config.ts +18 -1
  106. package/src/scheduled-task-runner.ts +20 -0
  107. package/src/session-maintenance.ts +1 -0
  108. package/src/skills-guard.ts +736 -0
  109. package/src/skills.ts +570 -61
  110. package/src/token-efficiency.ts +228 -0
  111. package/src/types.ts +12 -0
  112. package/src/workspace.ts +2 -2
  113. package/.hybridclaw/container-image-state.json +0 -5
package/src/discord.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ ActivityType,
2
3
  AttachmentBuilder,
3
4
  Client,
4
5
  GatewayIntentBits,
@@ -7,10 +8,19 @@ import {
7
8
  } from 'discord.js';
8
9
 
9
10
  import { DISCORD_PREFIX, DISCORD_TOKEN } from './config.js';
11
+ import { chunkMessage } from './chunk.js';
12
+ import { DiscordStreamManager } from './discord-stream.js';
10
13
  import { logger } from './logger.js';
11
14
 
12
15
  export type ReplyFn = (content: string, files?: AttachmentBuilder[]) => Promise<void>;
13
16
 
17
+ export interface MessageRunContext {
18
+ sourceMessage: DiscordMessage;
19
+ batchedMessages: DiscordMessage[];
20
+ abortSignal: AbortSignal;
21
+ stream: DiscordStreamManager;
22
+ }
23
+
14
24
  export type MessageHandler = (
15
25
  sessionId: string,
16
26
  guildId: string | null,
@@ -19,6 +29,7 @@ export type MessageHandler = (
19
29
  username: string,
20
30
  content: string,
21
31
  reply: ReplyFn,
32
+ context: MessageRunContext,
22
33
  ) => Promise<void>;
23
34
 
24
35
  export type CommandHandler = (
@@ -32,26 +43,42 @@ export type CommandHandler = (
32
43
  let client: Client;
33
44
  let messageHandler: MessageHandler;
34
45
  let commandHandler: CommandHandler;
46
+ let activeConversationRuns = 0;
47
+ const MESSAGE_DEBOUNCE_MS = 2_500;
48
+ const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
49
+ const MAX_ATTACHMENT_CONTEXT_CHARS = 16_000;
50
+ const MAX_SINGLE_ATTACHMENT_CHARS = 8_000;
51
+ const DISCORD_RETRY_MAX_ATTEMPTS = 3;
52
+ const DISCORD_RETRY_BASE_DELAY_MS = 500;
53
+
54
+ interface DiscordErrorLike {
55
+ status?: number;
56
+ httpStatus?: number;
57
+ retryAfter?: number;
58
+ data?: {
59
+ retry_after?: number;
60
+ };
61
+ }
35
62
 
36
63
  /**
37
64
  * Format an agent response as plain text.
38
65
  * Appends a subtle tools line if any tools were used.
39
66
  */
40
67
  export function buildResponseText(text: string, toolsUsed?: string[]): string {
41
- let body = text.slice(0, 2000);
68
+ let body = text;
42
69
  if (toolsUsed && toolsUsed.length > 0) {
43
70
  const toolsLine = `\n*Tools: ${toolsUsed.join(', ')}*`;
44
- body = text.slice(0, 2000 - toolsLine.length) + toolsLine;
71
+ body = `${text}${toolsLine}`;
45
72
  }
46
73
  return body;
47
74
  }
48
75
 
49
76
  export function formatInfo(title: string, body: string): string {
50
- return `**${title}**\n${body}`.slice(0, 2000);
77
+ return `**${title}**\n${body}`;
51
78
  }
52
79
 
53
80
  export function formatError(title: string, detail: string): string {
54
- return `**${title}:** ${detail}`.slice(0, 2000);
81
+ return `**${title}:** ${detail}`;
55
82
  }
56
83
 
57
84
  function getSessionId(msg: DiscordMessage): string {
@@ -86,24 +113,431 @@ function parseCommand(content: string): { isCommand: boolean; command: string; a
86
113
  return { isCommand: false, command: '', args: [] };
87
114
  }
88
115
 
116
+ function isRetryableDiscordError(error: unknown): boolean {
117
+ const maybe = error as DiscordErrorLike;
118
+ const status = maybe.status ?? maybe.httpStatus;
119
+ return status === 429 || (typeof status === 'number' && status >= 500 && status <= 599);
120
+ }
121
+
122
+ function retryDelayMs(error: unknown, fallbackMs: number): number {
123
+ const maybe = error as DiscordErrorLike;
124
+ const retryAfterSeconds = maybe.retryAfter ?? maybe.data?.retry_after;
125
+ if (typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
126
+ return Math.max(50, Math.ceil(retryAfterSeconds * 1_000));
127
+ }
128
+ return fallbackMs + Math.floor(Math.random() * 250);
129
+ }
130
+
131
+ async function withDiscordRetry<T>(label: string, fn: () => Promise<T>): Promise<T> {
132
+ let attempt = 0;
133
+ let delayMs = DISCORD_RETRY_BASE_DELAY_MS;
134
+ while (true) {
135
+ attempt += 1;
136
+ try {
137
+ return await fn();
138
+ } catch (error) {
139
+ if (attempt >= DISCORD_RETRY_MAX_ATTEMPTS || !isRetryableDiscordError(error)) {
140
+ throw error;
141
+ }
142
+ const waitMs = retryDelayMs(error, delayMs);
143
+ logger.warn({ label, attempt, waitMs, error }, 'Discord API call failed; retrying');
144
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
145
+ delayMs = Math.min(delayMs * 2, 4_000);
146
+ }
147
+ }
148
+ }
149
+
150
+ function cleanIncomingContent(content: string): string {
151
+ let text = content;
152
+ if (client.user) {
153
+ text = text.replace(new RegExp(`<@!?${client.user.id}>`, 'g'), '').trim();
154
+ }
155
+ if (text.startsWith(DISCORD_PREFIX)) {
156
+ text = text.slice(DISCORD_PREFIX.length).trim();
157
+ }
158
+ return text;
159
+ }
160
+
161
+ function summarizeContextMessage(msg: DiscordMessage): string {
162
+ const author = msg.author?.username || 'user';
163
+ const content = (msg.content || '').trim();
164
+ const snippet = content.length > 500 ? `${content.slice(0, 497)}...` : content;
165
+ return `${author}: ${snippet || '(no text)'}`;
166
+ }
167
+
168
+ function looksLikeTextAttachment(name: string, contentType: string): boolean {
169
+ if (contentType.startsWith('text/')) return true;
170
+ if (contentType.includes('json') || contentType.includes('xml') || contentType.includes('yaml')) return true;
171
+ return /\.(txt|md|markdown|json|ya?ml|js|jsx|ts|tsx|py|rb|go|rs|java|c|cpp|h|hpp|cs|php|html?|css|scss|sql|log|csv)$/i.test(name);
172
+ }
173
+
174
+ async function fetchAttachmentText(url: string, maxChars: number): Promise<string | null> {
175
+ try {
176
+ const response = await fetch(url);
177
+ if (!response.ok) return null;
178
+ const text = await response.text();
179
+ if (!text) return null;
180
+ if (text.length <= maxChars) return text;
181
+ return `${text.slice(0, Math.max(1_000, maxChars - 32))}\n...[truncated]`;
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ async function buildReplyContext(msg: DiscordMessage): Promise<string> {
188
+ const blocks: string[] = [];
189
+
190
+ if ('isThread' in msg.channel && typeof msg.channel.isThread === 'function' && msg.channel.isThread()) {
191
+ try {
192
+ const starter = await msg.channel.fetchStarterMessage();
193
+ if (starter) {
194
+ blocks.push(`[Thread starter]\n${summarizeContextMessage(starter)}`);
195
+ }
196
+ } catch (error) {
197
+ logger.debug({ error, channelId: msg.channelId }, 'Failed to fetch thread starter message');
198
+ }
199
+ }
200
+
201
+ const replyLines: string[] = [];
202
+ let replyId = msg.reference?.messageId || null;
203
+ let depth = 0;
204
+ while (replyId && depth < 5) {
205
+ try {
206
+ const referenced = await msg.channel.messages.fetch(replyId);
207
+ replyLines.push(summarizeContextMessage(referenced));
208
+ replyId = referenced.reference?.messageId || null;
209
+ depth += 1;
210
+ } catch {
211
+ break;
212
+ }
213
+ }
214
+ if (replyLines.length > 0) {
215
+ blocks.push(`[Reply context]\n${replyLines.reverse().join('\n')}`);
216
+ }
217
+
218
+ if (blocks.length === 0) return '';
219
+ return `${blocks.join('\n\n')}\n\n`;
220
+ }
221
+
222
+ async function buildAttachmentContext(messages: DiscordMessage[]): Promise<string> {
223
+ const lines: string[] = [];
224
+ let remainingChars = MAX_ATTACHMENT_CONTEXT_CHARS;
225
+
226
+ for (const msg of messages) {
227
+ if (!msg.attachments || msg.attachments.size === 0) continue;
228
+ for (const attachment of msg.attachments.values()) {
229
+ const name = attachment.name || 'unnamed';
230
+ const size = attachment.size || 0;
231
+ const contentType = (attachment.contentType || '').toLowerCase();
232
+ if (size > MAX_ATTACHMENT_BYTES) {
233
+ lines.push(`- ${name}: skipped (size ${size} bytes exceeds 10MB limit)`);
234
+ continue;
235
+ }
236
+
237
+ if (contentType.startsWith('image/')) {
238
+ lines.push(`- ${name}: image attachment (${size} bytes, ${contentType || 'unknown type'})`);
239
+ continue;
240
+ }
241
+
242
+ if (looksLikeTextAttachment(name, contentType)) {
243
+ const maxChars = Math.min(MAX_SINGLE_ATTACHMENT_CHARS, Math.max(500, remainingChars));
244
+ const text = await fetchAttachmentText(attachment.url, maxChars);
245
+ if (!text) {
246
+ lines.push(`- ${name}: text attachment (failed to read content)`);
247
+ continue;
248
+ }
249
+
250
+ const block = `- ${name} (text attachment):\n\`\`\`\n${text}\n\`\`\``;
251
+ remainingChars -= block.length;
252
+ lines.push(block);
253
+ if (remainingChars <= 0) {
254
+ lines.push('- Additional attachment content omitted (context budget reached).');
255
+ return `[Attachments]\n${lines.join('\n')}\n\n`;
256
+ }
257
+ continue;
258
+ }
259
+
260
+ lines.push(`- ${name}: attachment (${size} bytes, ${contentType || 'unknown type'})`);
261
+ }
262
+ }
263
+
264
+ if (lines.length === 0) return '';
265
+ return `[Attachments]\n${lines.join('\n')}\n\n`;
266
+ }
267
+
268
+ async function addProcessingReaction(msg: DiscordMessage): Promise<() => Promise<void>> {
269
+ if (!client.user) return async () => {};
270
+ const botUserId = client.user.id;
271
+ try {
272
+ await withDiscordRetry('react', () => msg.react('👀'));
273
+ } catch (error) {
274
+ logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to add processing reaction');
275
+ return async () => {};
276
+ }
277
+
278
+ return async () => {
279
+ try {
280
+ const reaction = msg.reactions.resolve('👀');
281
+ if (!reaction) return;
282
+ await withDiscordRetry('reaction-remove', () => reaction.users.remove(botUserId));
283
+ } catch (error) {
284
+ logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to remove processing reaction');
285
+ }
286
+ };
287
+ }
288
+
289
+ function startTypingLoop(msg: DiscordMessage): { stop: () => void } {
290
+ let stopped = false;
291
+ const sendTyping = async (): Promise<void> => {
292
+ if (stopped) return;
293
+ if (!('sendTyping' in msg.channel)) return;
294
+ try {
295
+ await msg.channel.sendTyping();
296
+ } catch (error) {
297
+ logger.debug({ error, channelId: msg.channelId }, 'Failed to send typing indicator');
298
+ }
299
+ };
300
+
301
+ void sendTyping();
302
+ const timer = setInterval(() => {
303
+ void sendTyping();
304
+ }, 8_000);
305
+
306
+ return {
307
+ stop: () => {
308
+ if (stopped) return;
309
+ stopped = true;
310
+ clearInterval(timer);
311
+ },
312
+ };
313
+ }
314
+
315
+ async function sendChunkedReply(
316
+ msg: DiscordMessage,
317
+ text: string,
318
+ files?: AttachmentBuilder[],
319
+ ): Promise<void> {
320
+ const chunks = chunkMessage(text, { maxChars: 1_900, maxLines: 20 });
321
+ const safeChunks = chunks.length > 0 ? chunks : ['(no content)'];
322
+
323
+ for (let i = 0; i < safeChunks.length; i += 1) {
324
+ const payload: { content: string; files?: AttachmentBuilder[] } = {
325
+ content: safeChunks[i],
326
+ ...(i === safeChunks.length - 1 && files && files.length > 0 ? { files } : {}),
327
+ };
328
+ if (i === 0) {
329
+ await withDiscordRetry('reply', () => msg.reply(payload));
330
+ } else {
331
+ await withDiscordRetry('send', () => (msg.channel as unknown as {
332
+ send: (next: { content: string; files?: AttachmentBuilder[] }) => Promise<void>;
333
+ }).send(payload));
334
+ }
335
+ }
336
+ }
337
+
338
+ function updatePresence(): void {
339
+ if (!client.user) return;
340
+ if (activeConversationRuns > 0) {
341
+ client.user.setPresence({
342
+ activities: [{ name: 'Thinking...', type: ActivityType.Playing }],
343
+ status: 'online',
344
+ });
345
+ return;
346
+ }
347
+ client.user.setPresence({
348
+ activities: [{ name: `in ${client.guilds.cache.size} servers`, type: ActivityType.Listening }],
349
+ status: 'online',
350
+ });
351
+ }
352
+
89
353
  export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler): Client {
90
354
  messageHandler = onMessage;
91
355
  commandHandler = onCommand;
92
356
 
357
+ interface QueuedConversationMessage {
358
+ msg: DiscordMessage;
359
+ content: string;
360
+ clearReaction: () => Promise<void>;
361
+ }
362
+ interface PendingConversationBatch {
363
+ items: QueuedConversationMessage[];
364
+ timer: ReturnType<typeof setTimeout>;
365
+ }
366
+ interface InFlightConversation {
367
+ abortController: AbortController;
368
+ stream: DiscordStreamManager;
369
+ messageIds: Set<string>;
370
+ aborted: boolean;
371
+ }
372
+ const pendingBatches = new Map<string, PendingConversationBatch>();
373
+ const inFlightByMessageId = new Map<string, InFlightConversation>();
374
+ const negativeFeedbackByChannel = new Map<string, string>();
375
+
93
376
  client = new Client({
94
377
  intents: [
95
378
  GatewayIntentBits.Guilds,
96
379
  GatewayIntentBits.GuildMessages,
380
+ GatewayIntentBits.GuildMessageReactions,
97
381
  GatewayIntentBits.MessageContent,
98
382
  GatewayIntentBits.DirectMessages,
99
383
  ],
100
- partials: [Partials.Channel],
384
+ partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
101
385
  });
102
386
 
103
387
  client.on('clientReady', () => {
104
388
  logger.info({ user: client.user?.tag }, 'Discord bot connected');
389
+ updatePresence();
105
390
  });
106
391
 
392
+ const dispatchConversationBatch = async (batchKey: string): Promise<void> => {
393
+ const pending = pendingBatches.get(batchKey);
394
+ if (!pending) return;
395
+ pendingBatches.delete(batchKey);
396
+ const items = pending.items;
397
+ if (items.length === 0) return;
398
+
399
+ const sourceItem = items[items.length - 1];
400
+ const msg = sourceItem.msg;
401
+ const sessionId = getSessionId(msg);
402
+ const guildId = msg.guild?.id || null;
403
+ const channelId = msg.channelId;
404
+ const userId = msg.author.id;
405
+ const username = msg.author.username;
406
+
407
+ const batchedContent = items.length > 1
408
+ ? items.map((item, index) => `Message ${index + 1}:\n${item.content}`).join('\n\n')
409
+ : sourceItem.content;
410
+ const replyContext = await buildReplyContext(msg);
411
+ const feedbackNote = negativeFeedbackByChannel.get(channelId) || '';
412
+ if (feedbackNote) {
413
+ negativeFeedbackByChannel.delete(channelId);
414
+ }
415
+ const attachmentContext = await buildAttachmentContext(items.map((item) => item.msg));
416
+ const combinedContent = `${feedbackNote ? `[Reaction feedback]\n${feedbackNote}\n\n` : ''}${replyContext}${attachmentContext}${batchedContent}`;
417
+
418
+ const abortController = new AbortController();
419
+ const typingLoop = startTypingLoop(msg);
420
+ const stream = new DiscordStreamManager(msg, {
421
+ onFirstMessage: () => typingLoop.stop(),
422
+ });
423
+ const inFlight: InFlightConversation = {
424
+ abortController,
425
+ stream,
426
+ messageIds: new Set(items.map((item) => item.msg.id)),
427
+ aborted: false,
428
+ };
429
+ for (const messageId of inFlight.messageIds) {
430
+ inFlightByMessageId.set(messageId, inFlight);
431
+ }
432
+
433
+ try {
434
+ activeConversationRuns += 1;
435
+ updatePresence();
436
+ await messageHandler(
437
+ sessionId,
438
+ guildId,
439
+ channelId,
440
+ userId,
441
+ username,
442
+ combinedContent,
443
+ async (text, files) => {
444
+ typingLoop.stop();
445
+ await sendChunkedReply(msg, text, files);
446
+ },
447
+ {
448
+ sourceMessage: msg,
449
+ batchedMessages: items.map((item) => item.msg),
450
+ abortSignal: abortController.signal,
451
+ stream,
452
+ },
453
+ );
454
+ } catch (error) {
455
+ logger.error({ error, channelId, sessionId }, 'Conversation batch handling failed');
456
+ const detail = error instanceof Error ? error.message : String(error);
457
+ if (stream.hasSentMessages()) {
458
+ await stream.fail(formatError('Gateway Error', detail));
459
+ } else {
460
+ await sendChunkedReply(msg, formatError('Gateway Error', detail));
461
+ }
462
+ } finally {
463
+ activeConversationRuns = Math.max(0, activeConversationRuns - 1);
464
+ updatePresence();
465
+ for (const messageId of inFlight.messageIds) {
466
+ if (inFlightByMessageId.get(messageId) === inFlight) {
467
+ inFlightByMessageId.delete(messageId);
468
+ }
469
+ }
470
+ typingLoop.stop();
471
+ await Promise.all(items.map(async (item) => {
472
+ await item.clearReaction();
473
+ }));
474
+ }
475
+ };
476
+
477
+ const queueConversationMessage = async (msg: DiscordMessage, content: string): Promise<void> => {
478
+ const key = `${msg.channelId}:${msg.author.id}`;
479
+ const clearReaction = await addProcessingReaction(msg);
480
+ const queued: QueuedConversationMessage = { msg, content, clearReaction };
481
+ const existing = pendingBatches.get(key);
482
+
483
+ if (!existing) {
484
+ const timer = setTimeout(() => {
485
+ void dispatchConversationBatch(key);
486
+ }, MESSAGE_DEBOUNCE_MS);
487
+ pendingBatches.set(key, {
488
+ items: [queued],
489
+ timer,
490
+ });
491
+ return;
492
+ }
493
+
494
+ clearTimeout(existing.timer);
495
+ existing.items.push(queued);
496
+ existing.timer = setTimeout(() => {
497
+ void dispatchConversationBatch(key);
498
+ }, MESSAGE_DEBOUNCE_MS);
499
+ };
500
+
501
+ const dropPendingMessage = async (messageId: string): Promise<void> => {
502
+ for (const [key, pending] of pendingBatches) {
503
+ const index = pending.items.findIndex((item) => item.msg.id === messageId);
504
+ if (index === -1) continue;
505
+ const [removed] = pending.items.splice(index, 1);
506
+ await removed.clearReaction();
507
+ if (pending.items.length === 0) {
508
+ clearTimeout(pending.timer);
509
+ pendingBatches.delete(key);
510
+ }
511
+ return;
512
+ }
513
+ };
514
+
515
+ const updatePendingMessage = async (
516
+ messageId: string,
517
+ nextMsg: DiscordMessage,
518
+ nextContent: string,
519
+ ): Promise<boolean> => {
520
+ for (const [key, pending] of pendingBatches) {
521
+ const index = pending.items.findIndex((item) => item.msg.id === messageId);
522
+ if (index === -1) continue;
523
+
524
+ if (!nextContent) {
525
+ const [removed] = pending.items.splice(index, 1);
526
+ await removed.clearReaction();
527
+ } else {
528
+ pending.items[index].msg = nextMsg;
529
+ pending.items[index].content = nextContent;
530
+ }
531
+
532
+ if (pending.items.length === 0) {
533
+ clearTimeout(pending.timer);
534
+ pendingBatches.delete(key);
535
+ }
536
+ return true;
537
+ }
538
+ return false;
539
+ };
540
+
107
541
  client.on('messageCreate', async (msg: DiscordMessage) => {
108
542
  if (msg.author.bot) return;
109
543
  if (!isTrigger(msg)) return;
@@ -113,30 +547,82 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
113
547
  const channelId = msg.channelId;
114
548
 
115
549
  const reply: ReplyFn = async (text, files) => {
116
- await msg.reply({ content: text, files: files ?? [] });
550
+ await sendChunkedReply(msg, text, files);
117
551
  };
118
552
 
119
- // Clean content (remove mention/prefix)
120
- let content = msg.content;
121
- if (client.user) {
122
- content = content.replace(new RegExp(`<@!?${client.user.id}>`, 'g'), '').trim();
123
- }
124
- if (content.startsWith(DISCORD_PREFIX)) {
125
- content = content.slice(DISCORD_PREFIX.length).trim();
126
- }
127
-
553
+ const content = cleanIncomingContent(msg.content);
128
554
  const parsed = parseCommand(msg.content);
129
555
 
130
556
  if (parsed.isCommand) {
131
557
  await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], reply);
132
- } else {
133
- if (!content) {
134
- await reply('How can I help? Send me a message or try `!claw help`.');
135
- return;
558
+ return;
559
+ }
560
+
561
+ if (!content) {
562
+ await reply('How can I help? Send me a message or try `!claw help`.');
563
+ return;
564
+ }
565
+
566
+ await queueConversationMessage(msg, content);
567
+ });
568
+
569
+ client.on('messageUpdate', async (_oldMsg, nextMsg) => {
570
+ const fetched = nextMsg.partial
571
+ ? await nextMsg.fetch().catch(() => null)
572
+ : nextMsg;
573
+ if (!fetched) return;
574
+ if (fetched.author?.bot) return;
575
+
576
+ const updatedContent = cleanIncomingContent(fetched.content || '');
577
+ await updatePendingMessage(fetched.id, fetched, updatedContent);
578
+
579
+ const inFlight = inFlightByMessageId.get(fetched.id);
580
+ if (!inFlight || inFlight.aborted) return;
581
+ inFlight.aborted = true;
582
+ inFlight.abortController.abort();
583
+ for (const messageId of inFlight.messageIds) {
584
+ if (inFlightByMessageId.get(messageId) === inFlight) {
585
+ inFlightByMessageId.delete(messageId);
586
+ }
587
+ }
588
+ await inFlight.stream.discard();
589
+ if (updatedContent) {
590
+ await queueConversationMessage(fetched, updatedContent);
591
+ }
592
+ });
593
+
594
+ client.on('messageDelete', async (msg) => {
595
+ await dropPendingMessage(msg.id);
596
+ const inFlight = inFlightByMessageId.get(msg.id);
597
+ if (!inFlight || inFlight.aborted) return;
598
+ inFlight.aborted = true;
599
+ inFlight.abortController.abort();
600
+ for (const messageId of inFlight.messageIds) {
601
+ if (inFlightByMessageId.get(messageId) === inFlight) {
602
+ inFlightByMessageId.delete(messageId);
136
603
  }
137
- if ('sendTyping' in msg.channel) await msg.channel.sendTyping();
138
- await messageHandler(sessionId, guildId, channelId, msg.author.id, msg.author.username, content, reply);
139
604
  }
605
+ await inFlight.stream.discard();
606
+ });
607
+
608
+ client.on('messageReactionAdd', async (reaction, user) => {
609
+ if (user.bot) return;
610
+ const fullReaction = reaction.partial
611
+ ? await reaction.fetch().catch(() => null)
612
+ : reaction;
613
+ if (!fullReaction) return;
614
+ if (fullReaction.emoji.name !== '👎') return;
615
+
616
+ const message = fullReaction.message.partial
617
+ ? await fullReaction.message.fetch().catch(() => null)
618
+ : fullReaction.message;
619
+ if (!message) return;
620
+ if (!client.user || message.author?.id !== client.user.id) return;
621
+
622
+ negativeFeedbackByChannel.set(
623
+ message.channelId,
624
+ `${user.username} reacted with 👎 to assistant message ${message.id}.`,
625
+ );
140
626
  });
141
627
 
142
628
  if (!DISCORD_TOKEN) {
@@ -152,8 +638,16 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
152
638
  export async function sendToChannel(channelId: string, text: string, files?: AttachmentBuilder[]): Promise<void> {
153
639
  const channel = await client.channels.fetch(channelId);
154
640
  if (channel && 'send' in channel) {
155
- await (channel as unknown as {
641
+ const chunks = chunkMessage(text, { maxChars: 1_900, maxLines: 20 });
642
+ const safeChunks = chunks.length > 0 ? chunks : ['(no content)'];
643
+ const send = (channel as unknown as {
156
644
  send: (payload: { content: string; files?: AttachmentBuilder[] }) => Promise<void>;
157
- }).send({ content: text, ...(files && files.length > 0 ? { files } : {}) });
645
+ }).send;
646
+ for (let i = 0; i < safeChunks.length; i += 1) {
647
+ await withDiscordRetry('send-channel', () => send({
648
+ content: safeChunks[i],
649
+ ...(i === safeChunks.length - 1 && files && files.length > 0 ? { files } : {}),
650
+ }));
651
+ }
158
652
  }
159
653
  }
@@ -7,6 +7,7 @@ import {
7
7
  type GatewayCommandResult,
8
8
  type GatewayChatStreamEvent,
9
9
  type GatewayChatStreamResultEvent,
10
+ type GatewayChatTextDeltaEvent,
10
11
  type GatewayChatToolProgressEvent,
11
12
  type GatewayStatus,
12
13
  } from './gateway-types.js';
@@ -187,6 +188,12 @@ function createResponseParser(onEvent: (event: GatewayChatStreamEvent) => void):
187
188
  return null;
188
189
  }
189
190
 
191
+ if (parsed.type === 'text' && typeof parsed.delta === 'string') {
192
+ const textEvent = parsed as GatewayChatTextDeltaEvent;
193
+ onEvent(textEvent);
194
+ return null;
195
+ }
196
+
190
197
  return null;
191
198
  };
192
199
  }