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.
- package/dist/__tests__/channels.test.js +1 -1
- package/dist/__tests__/context-manager.test.js +219 -76
- package/dist/__tests__/providers-utils.test.js +2 -0
- package/dist/__tests__/sandbox-manager.test.js +25 -0
- package/dist/__tests__/sandbox-mount-security.test.js +8 -0
- package/dist/__tests__/setup.test.js +1 -1
- package/dist/__tests__/skills.test.js +53 -26
- package/dist/__tests__/token-efficiency.test.js +37 -15
- package/dist/__tests__/tools.test.js +11 -9
- package/dist/agent.js +2 -2
- package/dist/api.js +5 -0
- package/dist/channels/discord/handlers.d.ts +7 -0
- package/dist/channels/discord/handlers.js +479 -0
- package/dist/channels/discord/index.d.ts +8 -0
- package/dist/channels/discord/index.js +149 -0
- package/dist/channels/discord/types.d.ts +6 -0
- package/dist/channels/discord/types.js +17 -0
- package/dist/channels/discord/utils.d.ts +14 -0
- package/dist/channels/discord/utils.js +161 -0
- package/dist/channels/telegram/utils.d.ts +1 -1
- package/dist/channels/telegram/utils.js +7 -9
- package/dist/channels.js +1 -1
- package/dist/cli.js +8 -43
- package/dist/code-agents/parser.js +5 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +13 -0
- package/dist/cron.js +6 -3
- package/dist/heartbeat.js +11 -15
- package/dist/providers/anthropic.js +7 -1
- package/dist/providers/codex.js +8 -2
- package/dist/providers/context-manager.d.ts +37 -6
- package/dist/providers/context-manager.js +303 -47
- package/dist/providers/openai.js +8 -2
- package/dist/providers/utils.d.ts +6 -2
- package/dist/providers/utils.js +36 -4
- package/dist/sandbox/manager.js +11 -0
- package/dist/sandbox/mount-security.js +5 -1
- package/dist/sandbox/runtime.d.ts +1 -0
- package/dist/sandbox/runtime.js +5 -0
- package/dist/sandbox-utils.d.ts +6 -0
- package/dist/sandbox-utils.js +36 -0
- package/dist/security.js +4 -3
- package/dist/service.js +25 -0
- package/dist/setup-templates.d.ts +14 -0
- package/dist/setup-templates.js +214 -0
- package/dist/setup.d.ts +1 -9
- package/dist/setup.js +3 -244
- package/dist/skills-types.d.ts +6 -0
- package/dist/skills.d.ts +5 -1
- package/dist/skills.js +25 -2
- package/dist/tools/bash-tool.js +11 -1
- package/dist/tools/definitions.d.ts +57 -0
- package/dist/tools/definitions.js +19 -1
- package/dist/tools/fetch-tool.d.ts +8 -0
- package/dist/tools/fetch-tool.js +80 -0
- package/dist/tools.d.ts +4 -2
- package/dist/tools.js +110 -62
- package/dist/types.d.ts +5 -0
- 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('
|
|
10
|
-
const result = 'x'.repeat(
|
|
11
|
-
expect(truncateToolResult(result)).toBe(result);
|
|
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('
|
|
14
|
-
const result = 'x'.repeat(
|
|
15
|
-
const
|
|
16
|
-
expect(
|
|
17
|
-
expect(
|
|
18
|
-
expect(
|
|
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('
|
|
21
|
-
const result = '
|
|
22
|
-
const
|
|
23
|
-
expect(
|
|
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
|
|
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(
|
|
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:
|
|
204
|
-
expect(result).toContain('
|
|
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:
|
|
208
|
-
expect(result).toContain('
|
|
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>;
|