skimpyclaw 0.3.9 → 0.3.14

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 (59) 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__/skills.test.js +53 -26
  8. package/dist/__tests__/token-efficiency.test.js +37 -15
  9. package/dist/__tests__/tools.test.js +11 -9
  10. package/dist/agent.js +2 -2
  11. package/dist/api.js +5 -0
  12. package/dist/channels/discord/handlers.d.ts +7 -0
  13. package/dist/channels/discord/handlers.js +479 -0
  14. package/dist/channels/discord/index.d.ts +8 -0
  15. package/dist/channels/discord/index.js +149 -0
  16. package/dist/channels/discord/types.d.ts +6 -0
  17. package/dist/channels/discord/types.js +17 -0
  18. package/dist/channels/discord/utils.d.ts +14 -0
  19. package/dist/channels/discord/utils.js +161 -0
  20. package/dist/channels/telegram/utils.d.ts +1 -1
  21. package/dist/channels/telegram/utils.js +7 -9
  22. package/dist/channels.js +1 -1
  23. package/dist/cli.js +8 -43
  24. package/dist/code-agents/parser.js +5 -0
  25. package/dist/config.d.ts +7 -0
  26. package/dist/config.js +13 -0
  27. package/dist/cron.js +6 -3
  28. package/dist/heartbeat.js +11 -15
  29. package/dist/providers/anthropic.js +7 -1
  30. package/dist/providers/codex.js +8 -2
  31. package/dist/providers/context-manager.d.ts +37 -6
  32. package/dist/providers/context-manager.js +303 -47
  33. package/dist/providers/openai.js +8 -2
  34. package/dist/providers/utils.d.ts +6 -2
  35. package/dist/providers/utils.js +36 -4
  36. package/dist/sandbox/manager.js +11 -0
  37. package/dist/sandbox/mount-security.js +5 -1
  38. package/dist/sandbox/runtime.d.ts +1 -0
  39. package/dist/sandbox/runtime.js +5 -0
  40. package/dist/sandbox-utils.d.ts +6 -0
  41. package/dist/sandbox-utils.js +36 -0
  42. package/dist/security.js +4 -3
  43. package/dist/service.js +25 -0
  44. package/dist/setup-templates.d.ts +14 -0
  45. package/dist/setup-templates.js +214 -0
  46. package/dist/setup.d.ts +1 -9
  47. package/dist/setup.js +3 -244
  48. package/dist/skills-types.d.ts +6 -0
  49. package/dist/skills.d.ts +5 -1
  50. package/dist/skills.js +25 -2
  51. package/dist/tools/bash-tool.js +11 -1
  52. package/dist/tools/definitions.d.ts +57 -0
  53. package/dist/tools/definitions.js +19 -1
  54. package/dist/tools/fetch-tool.d.ts +8 -0
  55. package/dist/tools/fetch-tool.js +80 -0
  56. package/dist/tools.d.ts +4 -2
  57. package/dist/tools.js +110 -62
  58. package/dist/types.d.ts +5 -0
  59. package/package.json +23 -29
@@ -1,27 +1,49 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, afterAll } from 'vitest';
2
2
  import { truncateToolResult } from '../providers/utils.js';
3
+ import { existsSync, readdirSync, unlinkSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { homedir } from 'os';
6
+ const scratchDir = join(homedir(), '.skimpyclaw', 'scratch');
7
+ // Clean up scratch files created during tests
8
+ afterAll(() => {
9
+ try {
10
+ if (existsSync(scratchDir)) {
11
+ for (const f of readdirSync(scratchDir)) {
12
+ try {
13
+ unlinkSync(join(scratchDir, f));
14
+ }
15
+ catch { /* ignore */ }
16
+ }
17
+ }
18
+ }
19
+ catch { /* ignore */ }
20
+ });
3
21
  describe('token efficiency', () => {
4
22
  describe('truncateToolResult', () => {
5
23
  it('returns short results unchanged', () => {
6
24
  const result = 'short result';
7
25
  expect(truncateToolResult(result)).toBe(result);
8
26
  });
9
- it('truncates at exact boundary', () => {
10
- const result = 'x'.repeat(10_240);
11
- expect(truncateToolResult(result)).toBe(result); // exactly at limit
27
+ it('returns results under mask threshold unchanged', () => {
28
+ const result = 'x'.repeat(7_999);
29
+ expect(truncateToolResult(result)).toBe(result);
12
30
  });
13
- it('truncates over limit with notice', () => {
14
- const result = 'x'.repeat(20_000);
15
- const truncated = truncateToolResult(result);
16
- expect(truncated.length).toBeLessThan(result.length);
17
- expect(truncated).toContain('[Truncated: 20000 chars total]');
18
- expect(truncated.startsWith('x'.repeat(10_240))).toBe(true);
31
+ it('masks large results to scratch file with summary', () => {
32
+ const result = 'START' + 'x'.repeat(10_000) + 'END';
33
+ const masked = truncateToolResult(result);
34
+ expect(masked.length).toBeLessThan(result.length);
35
+ expect(masked).toContain('[Full output');
36
+ expect(masked).toContain('saved to');
37
+ expect(masked).toContain('.skimpyclaw/scratch/');
38
+ expect(masked).toContain('use Read tool to access');
39
+ // Summary includes head and tail
40
+ expect(masked).toContain('START');
41
+ expect(masked).toContain('END');
19
42
  });
20
- it('respects custom maxBytes', () => {
21
- const result = 'abcdefghij'; // 10 chars
22
- const truncated = truncateToolResult(result, 5);
23
- expect(truncated).toContain('[Truncated: 10 chars total]');
24
- expect(truncated.startsWith('abcde')).toBe(true);
43
+ it('includes char count in masked output', () => {
44
+ const result = 'y'.repeat(20_000);
45
+ const masked = truncateToolResult(result);
46
+ expect(masked).toContain('20000 chars');
25
47
  });
26
48
  });
27
49
  describe('retry prompt compression', () => {
@@ -60,11 +60,11 @@ describe('getToolDefinitions', () => {
60
60
  expect(tools.map(t => t.name)).not.toContain('Browser');
61
61
  });
62
62
  describe('tool profiles', () => {
63
- it('minimal returns exactly 4 built-in tools', async () => {
63
+ it('minimal returns built-in tools plus Fetch', async () => {
64
64
  const config = { ...toolConfig, toolProfile: 'minimal' };
65
65
  const tools = await getToolDefinitions(config, { includeAgentTools: true, includeMcp: true });
66
- expect(tools).toHaveLength(4);
67
- expect(tools.map(t => t.name)).toEqual(['Read', 'Write', 'Glob', 'Bash']);
66
+ expect(tools).toHaveLength(5);
67
+ expect(tools.map(t => t.name)).toEqual(['Read', 'Write', 'Glob', 'Bash', 'Fetch']);
68
68
  });
69
69
  it('minimal excludes Browser even when browser.enabled is true', async () => {
70
70
  const config = { ...toolConfig, toolProfile: 'minimal', browser: { enabled: true } };
@@ -199,13 +199,15 @@ describe('bash', () => {
199
199
  const result = await executeTool('Bash', { command: 'echo hello' }, toolConfig);
200
200
  expect(result.trim()).toBe('hello');
201
201
  });
202
- it('blocks dangerous commands', async () => {
203
- const result = await executeTool('Bash', { command: 'rm -rf /' }, toolConfig);
204
- expect(result).toContain('Error: Command blocked');
202
+ it('blocks dangerous commands via exec approval', async () => {
203
+ const result = await executeTool('Bash', { command: `rm -rf ${TEST_DIR}` }, toolConfig);
204
+ expect(result).toContain('');
205
+ expect(result).toContain('tier 3');
205
206
  });
206
- it('blocks sudo', async () => {
207
- const result = await executeTool('Bash', { command: 'sudo cat /etc/passwd' }, toolConfig);
208
- expect(result).toContain('Error: Command blocked');
207
+ it('blocks sudo via exec approval', async () => {
208
+ const result = await executeTool('Bash', { command: `sudo ls ${TEST_DIR}` }, toolConfig);
209
+ expect(result).toContain('');
210
+ expect(result).toContain('tier 2');
209
211
  });
210
212
  it('respects cwd when in allowed paths', async () => {
211
213
  const result = await executeTool('Bash', { command: 'ls', cwd: TEST_DIR }, toolConfig);
package/dist/agent.js CHANGED
@@ -52,7 +52,7 @@ export function buildSystemPrompt(agentId, skillsContext) {
52
52
  cronJobId: skillsContext?.cronJobId,
53
53
  tags: skillsContext?.tags,
54
54
  });
55
- skillsSection = formatSkillsPrompt(contextSkills, skillsContext?.skillConfig?.maxPromptTokens);
55
+ skillsSection = formatSkillsPrompt(contextSkills, skillsContext?.skillConfig?.maxPromptTokens, skillsContext?.skillConfig?.dynamicLoading);
56
56
  }
57
57
  const base = [soul, identity, tools, skillsSection].filter(Boolean).join('\n\n---\n\n');
58
58
  const userContext = [user, memory].filter(Boolean).join('\n\n');
@@ -159,7 +159,7 @@ export async function runAgentTurn(agentId, userMessage, config, modelOverride,
159
159
  const runTurn = async () => {
160
160
  if (toolConfig?.enabled) {
161
161
  // Provider-specific routing is centralized in providers/chatWithTools.
162
- console.log(`[agent] Running with tools (provider: ${provider}, model: ${modelId}, paths: ${toolConfig.allowedPaths.join(', ')})`);
162
+ console.log(`[agent] Running with tools (provider: ${provider}, model: ${modelId}, paths: ${(toolConfig.allowedPaths ?? []).join(', ')})`);
163
163
  const result = await chatWithTools(messages, chatOptions, config, toolConfig, toolCtx);
164
164
  response = result.response;
165
165
  toolCalls = result.toolCalls;
package/dist/api.js CHANGED
@@ -619,6 +619,11 @@ export function registerDashboardAPI(fastify, config) {
619
619
  return reply.code(500).send({ error: `Reload failed: ${msg}` });
620
620
  }
621
621
  });
622
+ fastify.post('/api/dashboard/mcp/reconnect', async () => {
623
+ const { reconnectMcp } = await import('./tools.js');
624
+ await reconnectMcp();
625
+ return { reconnected: true, timestamp: new Date().toISOString() };
626
+ });
622
627
  // --- Audit Log ---
623
628
  // Reads from ~/.skimpyclaw/logs/audit/YYYY-MM-DD.jsonl files
624
629
  fastify.get('/api/dashboard/audit', async (request) => {
@@ -0,0 +1,7 @@
1
+ import { type Client, type Message, type Interaction } from 'discord.js';
2
+ import type { Config } from '../../types.js';
3
+ import { type PendingApproval } from '../../exec-approval.js';
4
+ export declare function handleCommand(message: Message, command: string, args: string[], config: Config, silenceUntil: Date | null, setSilenceUntil: (d: Date | null) => void): Promise<void>;
5
+ export declare function handleIncomingMessage(message: Message, config: Config): Promise<void>;
6
+ export declare function sendApprovalCard(client: Client, channelId: string, approval: PendingApproval): Promise<void>;
7
+ export declare function handleInteraction(interaction: Interaction): Promise<void>;
@@ -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>;