@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.
- package/CHANGELOG.md +59 -0
- package/README.md +50 -8
- package/config.example.json +3 -0
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/container/src/browser-tools.ts +53 -3
- package/container/src/hybridai-client.ts +270 -8
- package/container/src/index.ts +66 -3
- package/container/src/token-usage.ts +89 -0
- package/container/src/tools.ts +9 -2
- package/container/src/types.ts +19 -0
- package/container/src/web-fetch.ts +98 -7
- package/dist/agent.d.ts +1 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +2 -2
- package/dist/agent.js.map +1 -1
- package/dist/chunk.d.ts +6 -0
- package/dist/chunk.d.ts.map +1 -0
- package/dist/chunk.js +129 -0
- package/dist/chunk.js.map +1 -0
- package/dist/container-runner.d.ts +1 -1
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +25 -1
- package/dist/container-runner.js.map +1 -1
- package/dist/conversation.d.ts +4 -0
- package/dist/conversation.d.ts.map +1 -1
- package/dist/conversation.js +13 -3
- package/dist/conversation.js.map +1 -1
- package/dist/discord-stream.d.ts +32 -0
- package/dist/discord-stream.d.ts.map +1 -0
- package/dist/discord-stream.js +196 -0
- package/dist/discord-stream.js.map +1 -0
- package/dist/discord.d.ts +9 -2
- package/dist/discord.d.ts.map +1 -1
- package/dist/discord.js +452 -23
- package/dist/discord.js.map +1 -1
- package/dist/gateway-client.d.ts.map +1 -1
- package/dist/gateway-client.js +5 -0
- package/dist/gateway-client.js.map +1 -1
- package/dist/gateway-service.d.ts +1 -0
- package/dist/gateway-service.d.ts.map +1 -1
- package/dist/gateway-service.js +60 -2
- package/dist/gateway-service.js.map +1 -1
- package/dist/gateway-types.d.ts +7 -1
- package/dist/gateway-types.d.ts.map +1 -1
- package/dist/gateway-types.js.map +1 -1
- package/dist/gateway.js +55 -4
- package/dist/gateway.js.map +1 -1
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +7 -0
- package/dist/health.js.map +1 -1
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +20 -0
- package/dist/heartbeat.js.map +1 -1
- package/dist/observability-ingest.d.ts.map +1 -1
- package/dist/observability-ingest.js +26 -0
- package/dist/observability-ingest.js.map +1 -1
- package/dist/prompt-hooks.d.ts +2 -0
- package/dist/prompt-hooks.d.ts.map +1 -1
- package/dist/prompt-hooks.js +29 -0
- package/dist/prompt-hooks.js.map +1 -1
- package/dist/runtime-config.d.ts +3 -0
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +17 -1
- package/dist/runtime-config.js.map +1 -1
- package/dist/scheduled-task-runner.d.ts.map +1 -1
- package/dist/scheduled-task-runner.js +20 -0
- package/dist/scheduled-task-runner.js.map +1 -1
- package/dist/session-maintenance.d.ts.map +1 -1
- package/dist/session-maintenance.js +1 -0
- package/dist/session-maintenance.js.map +1 -1
- package/dist/skills-guard.d.ts +36 -0
- package/dist/skills-guard.d.ts.map +1 -0
- package/dist/skills-guard.js +607 -0
- package/dist/skills-guard.js.map +1 -0
- package/dist/skills.d.ts +13 -2
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +494 -59
- package/dist/skills.js.map +1 -1
- package/dist/token-efficiency.d.ts +41 -0
- package/dist/token-efficiency.d.ts.map +1 -0
- package/dist/token-efficiency.js +164 -0
- package/dist/token-efficiency.js.map +1 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +2 -1
- package/dist/workspace.js.map +1 -1
- package/docs/index.html +33 -7
- package/package.json +1 -1
- package/src/agent.ts +15 -1
- package/src/chunk.ts +153 -0
- package/src/container-runner.ts +24 -0
- package/src/conversation.ts +28 -4
- package/src/discord-stream.ts +240 -0
- package/src/discord.ts +517 -23
- package/src/gateway-client.ts +7 -0
- package/src/gateway-service.ts +72 -1
- package/src/gateway-types.ts +12 -1
- package/src/gateway.ts +65 -4
- package/src/health.ts +8 -0
- package/src/heartbeat.ts +20 -0
- package/src/observability-ingest.ts +24 -0
- package/src/prompt-hooks.ts +29 -0
- package/src/runtime-config.ts +18 -1
- package/src/scheduled-task-runner.ts +20 -0
- package/src/session-maintenance.ts +1 -0
- package/src/skills-guard.ts +736 -0
- package/src/skills.ts +570 -61
- package/src/token-efficiency.ts +228 -0
- package/src/types.ts +12 -0
- package/src/workspace.ts +2 -2
- 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
|
|
68
|
+
let body = text;
|
|
42
69
|
if (toolsUsed && toolsUsed.length > 0) {
|
|
43
70
|
const toolsLine = `\n*Tools: ${toolsUsed.join(', ')}*`;
|
|
44
|
-
body = text
|
|
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}
|
|
77
|
+
return `**${title}**\n${body}`;
|
|
51
78
|
}
|
|
52
79
|
|
|
53
80
|
export function formatError(title: string, detail: string): string {
|
|
54
|
-
return `**${title}:** ${detail}
|
|
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
|
|
550
|
+
await sendChunkedReply(msg, text, files);
|
|
117
551
|
};
|
|
118
552
|
|
|
119
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/gateway-client.ts
CHANGED
|
@@ -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
|
}
|