skimpyclaw 0.3.10 → 0.3.15

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 (53) hide show
  1. package/dist/__tests__/channels.test.js +1 -1
  2. package/dist/__tests__/context-manager.test.js +219 -76
  3. package/dist/__tests__/providers-utils.test.js +2 -0
  4. package/dist/__tests__/sandbox-manager.test.js +25 -0
  5. package/dist/__tests__/sandbox-mount-security.test.js +8 -0
  6. package/dist/__tests__/setup.test.js +1 -1
  7. package/dist/__tests__/tools.test.js +12 -9
  8. package/dist/agent.js +1 -1
  9. package/dist/api.js +5 -0
  10. package/dist/channels/discord/handlers.d.ts +7 -0
  11. package/dist/channels/discord/handlers.js +479 -0
  12. package/dist/channels/discord/index.d.ts +8 -0
  13. package/dist/channels/discord/index.js +149 -0
  14. package/dist/channels/discord/types.d.ts +6 -0
  15. package/dist/channels/discord/types.js +17 -0
  16. package/dist/channels/discord/utils.d.ts +14 -0
  17. package/dist/channels/discord/utils.js +161 -0
  18. package/dist/channels/telegram/utils.d.ts +1 -1
  19. package/dist/channels/telegram/utils.js +7 -9
  20. package/dist/channels.js +1 -1
  21. package/dist/cli.js +8 -43
  22. package/dist/code-agents/parser.js +5 -0
  23. package/dist/code-agents/utils.js +1 -0
  24. package/dist/config.d.ts +7 -0
  25. package/dist/config.js +13 -0
  26. package/dist/cron.js +6 -3
  27. package/dist/heartbeat.js +11 -15
  28. package/dist/providers/anthropic.js +7 -1
  29. package/dist/providers/codex.js +8 -2
  30. package/dist/providers/context-manager.d.ts +37 -6
  31. package/dist/providers/context-manager.js +303 -47
  32. package/dist/providers/openai.js +8 -2
  33. package/dist/providers/utils.js +1 -1
  34. package/dist/sandbox/manager.js +11 -0
  35. package/dist/sandbox/mount-security.js +5 -1
  36. package/dist/sandbox/runtime.d.ts +1 -0
  37. package/dist/sandbox/runtime.js +5 -0
  38. package/dist/sandbox-utils.d.ts +6 -0
  39. package/dist/sandbox-utils.js +36 -0
  40. package/dist/security.js +4 -3
  41. package/dist/setup-templates.d.ts +14 -0
  42. package/dist/setup-templates.js +214 -0
  43. package/dist/setup.d.ts +1 -9
  44. package/dist/setup.js +3 -244
  45. package/dist/tools/bash-tool.js +11 -1
  46. package/dist/tools/definitions.d.ts +57 -0
  47. package/dist/tools/definitions.js +19 -1
  48. package/dist/tools/fetch-tool.d.ts +8 -0
  49. package/dist/tools/fetch-tool.js +80 -0
  50. package/dist/tools.d.ts +4 -2
  51. package/dist/tools.js +110 -62
  52. package/dist/types.d.ts +5 -0
  53. package/package.json +3 -4
@@ -0,0 +1,479 @@
1
+ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder, } from 'discord.js';
2
+ import { join } from 'path';
3
+ import { tmpdir } from 'os';
4
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
5
+ import { getCurrentModel, setCurrentModel } from '../../gateway.js';
6
+ import { getCronJobs, runCronJob } from '../../cron.js';
7
+ import { runAgentTurn } from '../../agent.js';
8
+ import { runHeartbeatCheck } from '../../heartbeat.js';
9
+ import { isAllowed, isRateLimited } from '../../security.js';
10
+ import { getActiveCodeAgents, getRecentCodeAgents } from '../../tools.js';
11
+ import { listApprovals, approveRequest, denyRequest, getApproval, } from '../../exec-approval.js';
12
+ import { transcribeAudio, synthesizeSpeech } from '../../voice.js';
13
+ import * as sessions from '../../sessions.js';
14
+ import { formatAliases, formatModelSelectionError, getModelSelectionUsage, resolveModelSelection } from '../../model-selection.js';
15
+ import { KNOWN_COMMANDS } from './types.js';
16
+ import { getHistory, addToHistory, clearHistory, replaceHistory, getDiscordToolConfig, getDiscordRunContext, conversationKey, buildHelpText, sendLongText, startTypingIndicator, } from './utils.js';
17
+ // ── Command handler ─────────────────────────────────────────────────
18
+ export async function handleCommand(message, command, args, config, silenceUntil, setSilenceUntil) {
19
+ const rawArgs = args.join(' ').trim();
20
+ if (command === 'start' || command === 'help') {
21
+ await sendLongText(message, buildHelpText(config));
22
+ return;
23
+ }
24
+ if (command === 'model') {
25
+ if (!rawArgs) {
26
+ const current = getCurrentModel();
27
+ const aliases = formatAliases(config);
28
+ await message.reply(`Current: ${current}\nAliases: ${aliases}\n\nUsage: /model <alias|provider/model|model-id>\n${getModelSelectionUsage()}`);
29
+ return;
30
+ }
31
+ const selection = resolveModelSelection(rawArgs, config);
32
+ if (!selection.ok || !selection.resolved) {
33
+ const errorMessage = selection.error || 'Invalid model selection';
34
+ await message.reply(formatModelSelectionError(errorMessage, config));
35
+ return;
36
+ }
37
+ setCurrentModel(selection.resolved);
38
+ if (selection.aliasUsed) {
39
+ await message.reply(`Model switched to: ${selection.aliasUsed} (${selection.resolved})`);
40
+ }
41
+ else {
42
+ await message.reply(`Model switched to: ${selection.resolved}`);
43
+ }
44
+ return;
45
+ }
46
+ if (command === 'status') {
47
+ const model = getCurrentModel();
48
+ const { getLastMessage } = await import('../../gateway.js');
49
+ const last = getLastMessage();
50
+ const jobs = getCronJobs();
51
+ const jobList = jobs.map(j => `- ${j.name}: ${j.nextRun?.toLocaleString() || 'unknown'}`).join('\n');
52
+ const caActive = getActiveCodeAgents();
53
+ const caRecent = getRecentCodeAgents(3);
54
+ const caAll = [...caActive, ...caRecent];
55
+ let caLine = 'Coding Agents: idle';
56
+ if (caAll.length > 0) {
57
+ const runningCount = caActive.length;
58
+ const completedCount = caRecent.filter(t => t.status === 'completed').length;
59
+ const failedCount = caRecent.filter(t => t.status === 'failed' || t.status === 'timeout').length;
60
+ const parts = [];
61
+ if (runningCount)
62
+ parts.push(`${runningCount} running`);
63
+ if (completedCount)
64
+ parts.push(`${completedCount} completed`);
65
+ if (failedCount)
66
+ parts.push(`${failedCount} failed`);
67
+ caLine = `Coding Agents: ${parts.join(', ') || 'idle'}`;
68
+ const caPreview = caAll.slice(0, 5).map(t => {
69
+ const elapsed = t.durationSeconds != null
70
+ ? (t.durationSeconds < 60 ? `${t.durationSeconds}s` : `${Math.floor(t.durationSeconds / 60)}m ${t.durationSeconds % 60}s`)
71
+ : (Math.round((Date.now() - new Date(t.startedAt).getTime()) / 1000) + 's');
72
+ const taskPreview = t.task.length > 50 ? t.task.slice(0, 50) + '...' : t.task;
73
+ return ` ${t.id}: ${t.status.toUpperCase()} (${t.agent}, ${elapsed}) — ${taskPreview}`;
74
+ }).join('\n');
75
+ if (caPreview)
76
+ caLine += '\n' + caPreview;
77
+ }
78
+ await message.reply(`Agent: ${config.agents.default}\n` +
79
+ `Model: ${model}\n` +
80
+ `Last message: ${last?.toLocaleString() || 'never'}\n` +
81
+ `Silence until: ${silenceUntil?.toLocaleString() || 'not silenced'}\n\n` +
82
+ `${caLine}\n\n` +
83
+ `Scheduled jobs:\n${jobList || '(none)'}`);
84
+ return;
85
+ }
86
+ if (command === 'cron') {
87
+ const subcommand = args[0];
88
+ if (!subcommand || subcommand === 'list') {
89
+ const jobs = getCronJobs();
90
+ if (jobs.length === 0) {
91
+ await message.reply('No scheduled jobs.');
92
+ return;
93
+ }
94
+ const list = jobs.map(j => `${j.id}: ${j.name} (next: ${j.nextRun?.toLocaleString() || '?'})`).join('\n');
95
+ await message.reply(`Scheduled jobs:\n${list}`);
96
+ return;
97
+ }
98
+ if (subcommand === 'run') {
99
+ const jobId = args[1];
100
+ if (!jobId) {
101
+ await message.reply('Usage: /cron run <job-id>');
102
+ return;
103
+ }
104
+ try {
105
+ await runCronJob(jobId, config);
106
+ await message.reply(`Triggered: ${jobId}`);
107
+ }
108
+ catch (error) {
109
+ const msg = error instanceof Error ? error.message : 'Unknown error';
110
+ await message.reply(`Error: ${msg}`);
111
+ }
112
+ return;
113
+ }
114
+ await message.reply('Usage: /cron list | /cron run <id>');
115
+ return;
116
+ }
117
+ if (command === 'heartbeat') {
118
+ const stopTyping = startTypingIndicator(message);
119
+ try {
120
+ const response = await runHeartbeatCheck(config);
121
+ await sendLongText(message, `Heartbeat:\n\n${response}`);
122
+ }
123
+ catch (error) {
124
+ const msg = error instanceof Error ? error.message : 'Unknown error';
125
+ await message.reply(`Heartbeat error: ${msg}`);
126
+ }
127
+ finally {
128
+ stopTyping();
129
+ }
130
+ return;
131
+ }
132
+ if (command === 'approvals') {
133
+ const pending = listApprovals();
134
+ if (pending.length === 0) {
135
+ await message.reply('No pending exec approvals.');
136
+ return;
137
+ }
138
+ for (const approval of pending.slice(0, 10)) {
139
+ const cmdPreview = approval.command.length > 80
140
+ ? approval.command.slice(0, 80) + '...'
141
+ : approval.command;
142
+ const expiresIn = Math.max(0, Math.round((approval.expiresAt.getTime() - Date.now()) / 1000));
143
+ const expiresStr = expiresIn < 60 ? `${expiresIn}s` : `${Math.floor(expiresIn / 60)}m`;
144
+ const row = new ActionRowBuilder().addComponents(new ButtonBuilder()
145
+ .setCustomId(`approve:${approval.id}`)
146
+ .setLabel('Approve')
147
+ .setStyle(ButtonStyle.Success), new ButtonBuilder()
148
+ .setCustomId(`deny:${approval.id}`)
149
+ .setLabel('Deny')
150
+ .setStyle(ButtonStyle.Danger));
151
+ await message.reply({
152
+ content: `⛔ Approval #${approval.id}\n` +
153
+ `Tier ${approval.tier}: ${approval.reason}\n` +
154
+ `Command: ${cmdPreview}\n` +
155
+ `${approval.cwd ? `CWD: ${approval.cwd}\n` : ''}` +
156
+ `Expires in: ${expiresStr}`,
157
+ components: [row],
158
+ });
159
+ }
160
+ return;
161
+ }
162
+ if (command === 'approve') {
163
+ const id = rawArgs;
164
+ if (!id) {
165
+ await message.reply('Usage: /approve <id>');
166
+ return;
167
+ }
168
+ const by = message.author.username || message.author.id;
169
+ const success = approveRequest(id, by);
170
+ if (success) {
171
+ await message.reply(`✅ Approved #${id}`);
172
+ }
173
+ else {
174
+ const existing = getApproval(id);
175
+ if (existing) {
176
+ await message.reply(`Cannot approve #${id} — status is already "${existing.status}".`);
177
+ }
178
+ else {
179
+ await message.reply(`No pending approval found with ID "${id}".`);
180
+ }
181
+ }
182
+ return;
183
+ }
184
+ if (command === 'deny') {
185
+ const id = rawArgs;
186
+ if (!id) {
187
+ await message.reply('Usage: /deny <id>');
188
+ return;
189
+ }
190
+ const by = message.author.username || message.author.id;
191
+ const success = denyRequest(id, by);
192
+ if (success) {
193
+ await message.reply(`❌ Denied #${id}`);
194
+ }
195
+ else {
196
+ const existing = getApproval(id);
197
+ if (existing) {
198
+ await message.reply(`Cannot deny #${id} — status is already "${existing.status}".`);
199
+ }
200
+ else {
201
+ await message.reply(`No pending approval found with ID "${id}".`);
202
+ }
203
+ }
204
+ return;
205
+ }
206
+ if (command === 'tasks') {
207
+ await message.reply('Use `/agents` to list active coding agents, or `/cron` to manage scheduled tasks.');
208
+ return;
209
+ }
210
+ if (command === 'cancel') {
211
+ await message.reply('Use the dashboard to cancel coding agents, or `/cron` to manage scheduled tasks.');
212
+ return;
213
+ }
214
+ if (command === 'clear') {
215
+ await clearHistory(conversationKey(message));
216
+ await message.reply('Conversation cleared. Starting fresh.');
217
+ return;
218
+ }
219
+ if (command === 'compact') {
220
+ const key = conversationKey(message);
221
+ const history = await getHistory(key);
222
+ if (history.length === 0) {
223
+ await message.reply('No conversation history to compact.');
224
+ return;
225
+ }
226
+ const stopTyping = startTypingIndicator(message);
227
+ try {
228
+ const historyText = history.map(m => `${m.role}: ${m.content}`).join('\n');
229
+ const summary = await runAgentTurn(config.agents.default, `Summarize this conversation in 2-3 sentences so you can remember the context:\n\n${historyText}`, config, getCurrentModel(), undefined, undefined, getDiscordRunContext(message));
230
+ await clearHistory(key);
231
+ replaceHistory(key, summary);
232
+ await sessions.replaceWithSummary('discord', key, summary);
233
+ await message.reply(`Compacted ${history.length} messages into a summary.`);
234
+ }
235
+ catch (error) {
236
+ const msg = error instanceof Error ? error.message : 'Unknown error';
237
+ await message.reply(`Error: ${msg}`);
238
+ }
239
+ finally {
240
+ stopTyping();
241
+ }
242
+ return;
243
+ }
244
+ if (command === 'silence') {
245
+ const minutes = parseInt(rawArgs, 10) || 30;
246
+ const until = new Date(Date.now() + minutes * 60 * 1000);
247
+ setSilenceUntil(until);
248
+ await message.reply(`Proactive messages silenced until ${until.toLocaleTimeString()}`);
249
+ return;
250
+ }
251
+ await message.reply(`Unknown command: /${command}\n\nType /help to see available commands.`);
252
+ }
253
+ // ── Incoming message handler ────────────────────────────────────────
254
+ export async function handleIncomingMessage(message, config) {
255
+ if (message.author.bot)
256
+ return;
257
+ if (!config.channels.discord)
258
+ return;
259
+ console.log(`[discord] Received message from ${message.author.id} in ${message.channelId}: ${JSON.stringify(message.content).slice(0, 120)}`);
260
+ const senderId = message.author.id;
261
+ const senderUsername = message.author.username;
262
+ if (!isAllowed(config.channels.discord.allowFrom, senderId, senderUsername)) {
263
+ console.log(`[discord] Blocked message from ${senderId} (@${senderUsername})`);
264
+ return;
265
+ }
266
+ if (isRateLimited(senderId)) {
267
+ await message.reply('Too many messages. Please wait a moment.');
268
+ return;
269
+ }
270
+ // Check for image attachments
271
+ const imageAttachments = message.attachments.filter(a => a.contentType?.startsWith('image/'));
272
+ if (imageAttachments.size > 0) {
273
+ const attachment = imageAttachments.first();
274
+ const stopTyping = startTypingIndicator(message);
275
+ try {
276
+ const imageResponse = await fetch(attachment.url);
277
+ const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
278
+ const base64Image = imageBuffer.toString('base64');
279
+ const mediaType = attachment.contentType || 'image/jpeg';
280
+ const caption = message.content.trim() || "What's in this image?";
281
+ const content = [
282
+ {
283
+ type: 'image',
284
+ source: {
285
+ type: 'base64',
286
+ media_type: mediaType,
287
+ data: base64Image,
288
+ },
289
+ },
290
+ {
291
+ type: 'text',
292
+ text: caption,
293
+ },
294
+ ];
295
+ const key = conversationKey(message);
296
+ const history = await getHistory(key);
297
+ const response = await runAgentTurn(config.agents.default, content, config, getCurrentModel(), getDiscordToolConfig(config), history, getDiscordRunContext(message));
298
+ await addToHistory(key, `[Image: ${caption}]`, response);
299
+ await sendLongText(message, response);
300
+ }
301
+ catch (error) {
302
+ const msg = error instanceof Error ? error.message : 'Unknown error';
303
+ await message.reply(`Error processing image: ${msg}`);
304
+ }
305
+ finally {
306
+ stopTyping();
307
+ }
308
+ return;
309
+ }
310
+ // Check for voice message attachments
311
+ const voiceAttachments = message.attachments.filter(a => a.contentType?.startsWith('audio/') || a.contentType?.startsWith('voice/'));
312
+ if (voiceAttachments.size > 0) {
313
+ const attachment = voiceAttachments.first();
314
+ const stopTyping = startTypingIndicator(message);
315
+ try {
316
+ if (!config.voice) {
317
+ await message.reply('Voice transcription not configured. Add a "voice" section to config.json.');
318
+ return;
319
+ }
320
+ const voiceResponse = await fetch(attachment.url);
321
+ const buffer = Buffer.from(await voiceResponse.arrayBuffer());
322
+ const ext = attachment.name?.split('.').pop() || 'ogg';
323
+ const tempDir = join(tmpdir(), 'skimpyclaw-voice');
324
+ if (!existsSync(tempDir))
325
+ mkdirSync(tempDir, { recursive: true });
326
+ const tempPath = join(tempDir, `discord-voice-${Date.now()}.${ext}`);
327
+ writeFileSync(tempPath, buffer);
328
+ try {
329
+ const result = await transcribeAudio(tempPath, config.voice);
330
+ const transcription = result.text.trim();
331
+ console.log(`[discord] Transcription result: ${transcription}`);
332
+ if (!transcription) {
333
+ await message.reply('Could not transcribe audio — no speech detected.');
334
+ return;
335
+ }
336
+ const key = conversationKey(message);
337
+ const history = await getHistory(key);
338
+ const agentResponse = await runAgentTurn(config.agents.default, transcription, config, getCurrentModel(), getDiscordToolConfig(config), history, getDiscordRunContext(message));
339
+ await addToHistory(key, transcription, agentResponse);
340
+ console.log('[discord] TTS check - sendVoice:', config.voice?.channels?.['discord']?.sendVoice);
341
+ if (config.voice?.channels?.['discord']?.sendVoice) {
342
+ console.log('[discord] Attempting TTS synthesis...');
343
+ try {
344
+ const speech = await synthesizeSpeech(agentResponse, config.voice);
345
+ console.log('[discord] TTS synthesis success:', speech.format, speech.provider, 'buffer size:', speech.buffer.length);
346
+ const voiceAttachment = new AttachmentBuilder(speech.buffer, {
347
+ name: `voice-reply.${speech.format}`,
348
+ description: 'Voice reply'
349
+ });
350
+ await message.reply({ files: [voiceAttachment] });
351
+ console.log('[discord] Voice reply sent');
352
+ }
353
+ catch (err) {
354
+ console.error('[discord] TTS synthesis failed:', err);
355
+ }
356
+ }
357
+ const combined = `> 🎤 ${transcription}\n\n${agentResponse}`;
358
+ await sendLongText(message, combined);
359
+ }
360
+ finally {
361
+ try {
362
+ unlinkSync(tempPath);
363
+ }
364
+ catch { /* best effort */ }
365
+ }
366
+ }
367
+ catch (error) {
368
+ const msg = error instanceof Error ? error.message : 'Unknown error';
369
+ await message.reply(`Voice transcription error: ${msg}`);
370
+ }
371
+ finally {
372
+ stopTyping();
373
+ }
374
+ return;
375
+ }
376
+ const text = message.content.trim();
377
+ if (!text)
378
+ return;
379
+ const isPrefixedCommand = text.startsWith('/') || text.startsWith('!');
380
+ const isDm = message.channel.isDMBased();
381
+ if (isPrefixedCommand || isDm) {
382
+ const commandText = isPrefixedCommand ? text.slice(1).trim() : text;
383
+ const [commandPart, ...cmdArgs] = commandText.split(/\s+/);
384
+ const command = (commandPart || '').toLowerCase();
385
+ if (!KNOWN_COMMANDS.has(command)) {
386
+ if (isPrefixedCommand) {
387
+ await message.reply(`Unknown command: /${command}\n\nType /help to see available commands.`);
388
+ return;
389
+ }
390
+ }
391
+ else {
392
+ // handleCommand is called from index.ts which passes silenceUntil/setter
393
+ // For DM free-text that happens to match a command, delegate up
394
+ return;
395
+ }
396
+ }
397
+ const key = conversationKey(message);
398
+ const stopTyping = startTypingIndicator(message);
399
+ try {
400
+ const history = await getHistory(key);
401
+ const response = await runAgentTurn(config.agents.default, text, config, getCurrentModel(), getDiscordToolConfig(config), history, getDiscordRunContext(message));
402
+ await addToHistory(key, text, response);
403
+ await sendLongText(message, response);
404
+ }
405
+ catch (error) {
406
+ const msg = error instanceof Error ? error.message : 'Unknown error';
407
+ await message.reply(`Error: ${msg}`);
408
+ }
409
+ finally {
410
+ stopTyping();
411
+ }
412
+ }
413
+ // ── Approval card ───────────────────────────────────────────────────
414
+ export async function sendApprovalCard(client, channelId, approval) {
415
+ const channel = await client.channels.fetch(channelId).catch(() => null);
416
+ if (!channel || !('send' in channel) || typeof channel.send !== 'function')
417
+ return;
418
+ const cmdPreview = approval.command.length > 80
419
+ ? approval.command.slice(0, 80) + '...'
420
+ : approval.command;
421
+ const expiresIn = Math.max(0, Math.round((approval.expiresAt.getTime() - Date.now()) / 1000));
422
+ const expiresStr = expiresIn < 60 ? `${expiresIn}s` : `${Math.floor(expiresIn / 60)}m`;
423
+ const row = new ActionRowBuilder().addComponents(new ButtonBuilder()
424
+ .setCustomId(`approve:${approval.id}`)
425
+ .setLabel('Approve')
426
+ .setStyle(ButtonStyle.Success), new ButtonBuilder()
427
+ .setCustomId(`deny:${approval.id}`)
428
+ .setLabel('Deny')
429
+ .setStyle(ButtonStyle.Danger));
430
+ await channel.send({
431
+ content: `⛔ Exec approval needed: #${approval.id}\n` +
432
+ `Tier ${approval.tier}: ${approval.reason}\n` +
433
+ `Command: ${cmdPreview}\n` +
434
+ `${approval.cwd ? `CWD: ${approval.cwd}\n` : ''}` +
435
+ `Expires in: ${expiresStr}`,
436
+ components: [row],
437
+ });
438
+ }
439
+ // ── Button interaction handler ──────────────────────────────────────
440
+ export async function handleInteraction(interaction) {
441
+ if (!interaction.isButton())
442
+ return;
443
+ const customId = interaction.customId;
444
+ const [action, id] = customId.split(':');
445
+ if (!id || (action !== 'approve' && action !== 'deny')) {
446
+ await interaction.reply({ content: 'Unknown action', ephemeral: true });
447
+ return;
448
+ }
449
+ const by = interaction.user.username || interaction.user.id;
450
+ let success;
451
+ let statusText;
452
+ if (action === 'approve') {
453
+ success = approveRequest(id, by);
454
+ statusText = success ? `✅ Approved by @${by}` : 'Failed — not pending';
455
+ }
456
+ else {
457
+ success = denyRequest(id, by);
458
+ statusText = success ? `❌ Denied by @${by}` : 'Failed — not pending';
459
+ }
460
+ await interaction.reply({ content: statusText, ephemeral: true });
461
+ try {
462
+ const approval = getApproval(id);
463
+ if (approval) {
464
+ const cmdPreview = approval.command.length > 80
465
+ ? approval.command.slice(0, 80) + '...'
466
+ : approval.command;
467
+ await interaction.message.edit({
468
+ content: `${statusText}\n\n` +
469
+ `Approval #${id}\n` +
470
+ `Tier ${approval.tier}: ${approval.reason}\n` +
471
+ `Command: ${cmdPreview}`,
472
+ components: [],
473
+ });
474
+ }
475
+ }
476
+ catch {
477
+ // Message may already be edited or deleted
478
+ }
479
+ }
@@ -0,0 +1,8 @@
1
+ import type { Config } from '../../types.js';
2
+ export declare function initDiscord(cfg: Config): Promise<boolean>;
3
+ export declare function startDiscord(): Promise<void>;
4
+ export declare function stopDiscord(): Promise<void>;
5
+ export declare function isDiscordSilenced(): boolean;
6
+ export declare function getDiscordDefaultTarget(cfg: Config): string | null;
7
+ export declare function sendDiscordProactiveMessage(target: string | number, message: string): Promise<void>;
8
+ export declare function sendDiscordProactiveVoice(target: string | number, buffer: Buffer, format: string): Promise<void>;
@@ -0,0 +1,149 @@
1
+ import { Client, GatewayIntentBits, Partials, AttachmentBuilder, } from 'discord.js';
2
+ import { onApprovalEvent } from '../../exec-approval.js';
3
+ import { KNOWN_COMMANDS } from './types.js';
4
+ import { handleCommand, handleIncomingMessage, handleInteraction, sendApprovalCard } from './handlers.js';
5
+ import { splitToChunks } from './utils.js';
6
+ let client = null;
7
+ let config;
8
+ let silenceUntil = null;
9
+ export async function initDiscord(cfg) {
10
+ const discord = cfg.channels.discord;
11
+ if (!discord?.enabled || !discord.token) {
12
+ console.log('[discord] Disabled or no token configured');
13
+ return false;
14
+ }
15
+ config = cfg;
16
+ client = new Client({
17
+ intents: [
18
+ GatewayIntentBits.Guilds,
19
+ GatewayIntentBits.GuildMessages,
20
+ GatewayIntentBits.DirectMessages,
21
+ GatewayIntentBits.MessageContent,
22
+ ],
23
+ partials: [Partials.Channel],
24
+ });
25
+ client.on('messageCreate', (message) => {
26
+ if (message.author.bot)
27
+ return;
28
+ void (async () => {
29
+ const text = message.content.trim();
30
+ const isPrefixedCommand = text.startsWith('/') || text.startsWith('!');
31
+ const isDm = message.channel.isDMBased();
32
+ // Route commands through handleCommand with silenceUntil access
33
+ if (isPrefixedCommand || isDm) {
34
+ const commandText = isPrefixedCommand ? text.slice(1).trim() : text;
35
+ const [commandPart, ...args] = commandText.split(/\s+/);
36
+ const command = (commandPart || '').toLowerCase();
37
+ if (KNOWN_COMMANDS.has(command)) {
38
+ await handleCommand(message, command, args, config, silenceUntil, (d) => { silenceUntil = d; });
39
+ return;
40
+ }
41
+ if (isPrefixedCommand) {
42
+ await message.reply(`Unknown command: /${command}\n\nType /help to see available commands.`);
43
+ return;
44
+ }
45
+ }
46
+ // Non-command messages
47
+ await handleIncomingMessage(message, config);
48
+ })();
49
+ });
50
+ client.on('interactionCreate', (interaction) => {
51
+ void handleInteraction(interaction);
52
+ });
53
+ client.once('clientReady', () => {
54
+ console.log(`[discord] Bot started as ${client?.user?.tag ?? 'unknown'}`);
55
+ });
56
+ client.on('error', (error) => {
57
+ console.error('[discord] Client error:', error);
58
+ });
59
+ // Subscribe to approval-created events
60
+ onApprovalEvent('created', (event) => {
61
+ if (!client)
62
+ return;
63
+ const { approval } = event;
64
+ const meta = approval.channelMeta;
65
+ if (meta?.channel && meta.channel !== 'discord')
66
+ return;
67
+ let targetChannelId;
68
+ if (meta?.chatId) {
69
+ targetChannelId = String(meta.chatId);
70
+ }
71
+ if (!targetChannelId) {
72
+ targetChannelId = getDiscordDefaultTarget(cfg) ?? undefined;
73
+ }
74
+ if (!targetChannelId)
75
+ return;
76
+ void sendApprovalCard(client, targetChannelId, approval).catch((err) => {
77
+ console.error('[discord] Failed to send approval notification:', err);
78
+ });
79
+ });
80
+ return true;
81
+ }
82
+ export async function startDiscord() {
83
+ if (!client || !config.channels.discord?.token)
84
+ return;
85
+ console.log('[discord] Starting bot...');
86
+ await client.login(config.channels.discord.token);
87
+ }
88
+ export async function stopDiscord() {
89
+ if (!client)
90
+ return;
91
+ client.destroy();
92
+ console.log('[discord] Bot stopped');
93
+ }
94
+ export function isDiscordSilenced() {
95
+ if (!silenceUntil)
96
+ return false;
97
+ return new Date() < silenceUntil;
98
+ }
99
+ export function getDiscordDefaultTarget(cfg) {
100
+ const discord = cfg.channels.discord;
101
+ if (!discord)
102
+ return null;
103
+ if (discord.defaultChannelId?.trim())
104
+ return discord.defaultChannelId.trim();
105
+ for (const entry of discord.allowFrom) {
106
+ const value = String(entry).trim();
107
+ if (value)
108
+ return value;
109
+ }
110
+ return null;
111
+ }
112
+ async function sendChunked(target, text) {
113
+ const chunks = splitToChunks(text, 1900);
114
+ for (const chunk of chunks) {
115
+ await target.send(chunk);
116
+ }
117
+ }
118
+ export async function sendDiscordProactiveMessage(target, message) {
119
+ if (!client || isDiscordSilenced())
120
+ return;
121
+ const targetId = String(target);
122
+ const channel = await client.channels.fetch(targetId).catch(() => null);
123
+ if (channel && 'send' in channel && typeof channel.send === 'function') {
124
+ await sendChunked(channel, message);
125
+ return;
126
+ }
127
+ const user = await client.users.fetch(targetId).catch(() => null);
128
+ if (user) {
129
+ await sendChunked(user, message);
130
+ }
131
+ }
132
+ export async function sendDiscordProactiveVoice(target, buffer, format) {
133
+ if (!client || isDiscordSilenced())
134
+ return;
135
+ const targetId = String(target);
136
+ const attachment = new AttachmentBuilder(buffer, {
137
+ name: `voice.${format}`,
138
+ description: 'Voice message',
139
+ });
140
+ const channel = await client.channels.fetch(targetId).catch(() => null);
141
+ if (channel && 'send' in channel && typeof channel.send === 'function') {
142
+ await channel.send({ files: [attachment] });
143
+ return;
144
+ }
145
+ const user = await client.users.fetch(targetId).catch(() => null);
146
+ if (user) {
147
+ await user.send({ files: [attachment] });
148
+ }
149
+ }
@@ -0,0 +1,6 @@
1
+ export declare const BOT_COMMANDS: {
2
+ command: string;
3
+ description: string;
4
+ }[];
5
+ export declare const KNOWN_COMMANDS: Set<string>;
6
+ export declare const MAX_HISTORY_PAIRS = 5;
@@ -0,0 +1,17 @@
1
+ export const BOT_COMMANDS = [
2
+ { command: 'help', description: 'Show available commands' },
3
+ { command: 'model', description: 'Switch model (fast/smart/opus)' },
4
+ { command: 'status', description: 'Show bot status' },
5
+ { command: 'clear', description: 'Clear conversation history' },
6
+ { command: 'compact', description: 'Compress conversation history' },
7
+ { command: 'silence', description: 'Pause proactive messages' },
8
+ { command: 'cron', description: 'List or run scheduled jobs' },
9
+ { command: 'tasks', description: 'List active coding agents and cron jobs' },
10
+ { command: 'cancel', description: 'Cancel a coding agent (use dashboard) or cron job' },
11
+ { command: 'approvals', description: 'List pending exec approvals' },
12
+ { command: 'approve', description: 'Approve an exec request by ID' },
13
+ { command: 'deny', description: 'Deny an exec request by ID' },
14
+ { command: 'heartbeat', description: 'Trigger heartbeat check' },
15
+ ];
16
+ export const KNOWN_COMMANDS = new Set(BOT_COMMANDS.map(c => c.command));
17
+ export const MAX_HISTORY_PAIRS = 5;
@@ -0,0 +1,14 @@
1
+ import type { Message } from 'discord.js';
2
+ import type { AgentRunContext, ChatMessage, Config, ToolConfig } from '../../types.js';
3
+ export declare function getHistory(key: string): Promise<ChatMessage[]>;
4
+ export declare function addToHistory(key: string, userMsg: string, assistantMsg: string): Promise<void>;
5
+ export declare function clearHistory(key: string): Promise<void>;
6
+ /** Replace history with a compact summary (used by /compact). */
7
+ export declare function replaceHistory(key: string, summary: string): void;
8
+ export declare function getDiscordToolConfig(cfg: Config): ToolConfig;
9
+ export declare function conversationKey(message: Message): string;
10
+ export declare function getDiscordRunContext(message: Message): AgentRunContext;
11
+ export declare function buildHelpText(cfg: Config): string;
12
+ export declare function splitToChunks(text: string, maxLength: number): string[];
13
+ export declare function sendLongText(message: Message, text: string): Promise<void>;
14
+ export declare function startTypingIndicator(message: Message): () => void;