skimpyclaw 0.3.14 → 0.4.0

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 (222) hide show
  1. package/README.md +47 -37
  2. package/dist/__tests__/adapter-types.test.d.ts +4 -0
  3. package/dist/__tests__/adapter-types.test.js +63 -0
  4. package/dist/__tests__/anthropic-adapter.test.d.ts +4 -0
  5. package/dist/__tests__/anthropic-adapter.test.js +264 -0
  6. package/dist/__tests__/api.test.js +0 -1
  7. package/dist/__tests__/cli.integration.test.js +2 -4
  8. package/dist/__tests__/cli.test.js +0 -1
  9. package/dist/__tests__/code-agents-notifications.test.js +137 -0
  10. package/dist/__tests__/code-agents-parser.test.js +19 -1
  11. package/dist/__tests__/code-agents-preflight.test.js +3 -28
  12. package/dist/__tests__/code-agents-utils.test.js +34 -9
  13. package/dist/__tests__/code-agents-worktrees.test.js +116 -0
  14. package/dist/__tests__/codex-adapter.test.js +184 -0
  15. package/dist/__tests__/codex-auth.test.js +66 -0
  16. package/dist/__tests__/codex-provider-gating.test.js +35 -0
  17. package/dist/__tests__/codex-unified-loop.test.js +111 -0
  18. package/dist/__tests__/config-security.test.js +127 -0
  19. package/dist/__tests__/config.test.js +23 -0
  20. package/dist/__tests__/context-manager.test.js +243 -164
  21. package/dist/__tests__/cron-run.test.js +250 -0
  22. package/dist/__tests__/cron.test.js +12 -38
  23. package/dist/__tests__/digests.test.js +67 -0
  24. package/dist/__tests__/discord-attachments.test.js +211 -0
  25. package/dist/__tests__/discord-docs.test.d.ts +1 -0
  26. package/dist/__tests__/discord-docs.test.js +27 -0
  27. package/dist/__tests__/discord-thread-agents.test.d.ts +1 -0
  28. package/dist/__tests__/discord-thread-agents.test.js +115 -0
  29. package/dist/__tests__/discord-thread-context.test.d.ts +1 -0
  30. package/dist/__tests__/discord-thread-context.test.js +42 -0
  31. package/dist/__tests__/doctor.formatters.test.js +4 -4
  32. package/dist/__tests__/doctor.index.test.js +1 -1
  33. package/dist/__tests__/doctor.runner.test.js +3 -15
  34. package/dist/__tests__/env-sanitizer.test.d.ts +1 -0
  35. package/dist/__tests__/env-sanitizer.test.js +45 -0
  36. package/dist/__tests__/exec-approval.test.js +61 -0
  37. package/dist/__tests__/fetch-tool.test.d.ts +1 -0
  38. package/dist/__tests__/fetch-tool.test.js +85 -0
  39. package/dist/__tests__/gateway-status-auth.test.d.ts +1 -0
  40. package/dist/__tests__/gateway-status-auth.test.js +72 -0
  41. package/dist/__tests__/heartbeat.test.js +3 -3
  42. package/dist/__tests__/interactive-sessions.test.d.ts +1 -0
  43. package/dist/__tests__/interactive-sessions.test.js +96 -0
  44. package/dist/__tests__/langfuse.test.js +6 -18
  45. package/dist/__tests__/model-selection.test.js +3 -4
  46. package/dist/__tests__/providers-init.test.js +2 -8
  47. package/dist/__tests__/providers-routing.test.js +1 -1
  48. package/dist/__tests__/providers-utils.test.js +13 -3
  49. package/dist/__tests__/sessions.test.js +14 -10
  50. package/dist/__tests__/setup.test.js +12 -29
  51. package/dist/__tests__/skills.test.js +10 -7
  52. package/dist/__tests__/stream-formatter.test.d.ts +1 -0
  53. package/dist/__tests__/stream-formatter.test.js +114 -0
  54. package/dist/__tests__/token-efficiency.test.js +131 -15
  55. package/dist/__tests__/tool-loop.test.d.ts +4 -0
  56. package/dist/__tests__/tool-loop.test.js +505 -0
  57. package/dist/__tests__/tools.test.js +101 -276
  58. package/dist/__tests__/utils.test.d.ts +1 -0
  59. package/dist/__tests__/utils.test.js +14 -0
  60. package/dist/__tests__/voice.test.js +21 -0
  61. package/dist/agent.js +35 -4
  62. package/dist/api.js +113 -37
  63. package/dist/channels/discord/attachments.d.ts +50 -0
  64. package/dist/channels/discord/attachments.js +137 -0
  65. package/dist/channels/discord/delegation.d.ts +5 -0
  66. package/dist/channels/discord/delegation.js +136 -0
  67. package/dist/channels/discord/handlers.js +694 -7
  68. package/dist/channels/discord/index.d.ts +16 -1
  69. package/dist/channels/discord/index.js +64 -1
  70. package/dist/channels/discord/thread-agents.d.ts +54 -0
  71. package/dist/channels/discord/thread-agents.js +323 -0
  72. package/dist/channels/discord/threads.d.ts +58 -0
  73. package/dist/channels/discord/threads.js +192 -0
  74. package/dist/channels/discord/types.js +4 -2
  75. package/dist/channels/discord/utils.d.ts +16 -0
  76. package/dist/channels/discord/utils.js +86 -6
  77. package/dist/channels/telegram/index.d.ts +1 -1
  78. package/dist/channels/telegram/types.js +1 -1
  79. package/dist/channels/telegram/utils.js +9 -3
  80. package/dist/channels.d.ts +1 -1
  81. package/dist/cli.js +20 -400
  82. package/dist/code-agents/executor.d.ts +1 -1
  83. package/dist/code-agents/executor.js +101 -45
  84. package/dist/code-agents/index.d.ts +2 -7
  85. package/dist/code-agents/index.js +111 -80
  86. package/dist/code-agents/interactive-resume.d.ts +6 -0
  87. package/dist/code-agents/interactive-resume.js +98 -0
  88. package/dist/code-agents/interactive-sessions.d.ts +20 -0
  89. package/dist/code-agents/interactive-sessions.js +132 -0
  90. package/dist/code-agents/parser.js +5 -1
  91. package/dist/code-agents/registry.d.ts +7 -1
  92. package/dist/code-agents/registry.js +11 -23
  93. package/dist/code-agents/stream-formatter.d.ts +8 -0
  94. package/dist/code-agents/stream-formatter.js +92 -0
  95. package/dist/code-agents/types.d.ts +16 -24
  96. package/dist/code-agents/utils.d.ts +35 -11
  97. package/dist/code-agents/utils.js +349 -95
  98. package/dist/code-agents/worktrees.d.ts +37 -0
  99. package/dist/code-agents/worktrees.js +116 -0
  100. package/dist/config.d.ts +2 -4
  101. package/dist/config.js +123 -23
  102. package/dist/cron.d.ts +1 -6
  103. package/dist/cron.js +175 -82
  104. package/dist/dashboard/assets/index-B345aOO-.js +65 -0
  105. package/dist/dashboard/assets/index-ZWK4dalJ.css +1 -0
  106. package/dist/dashboard/index.html +2 -2
  107. package/dist/digests.d.ts +1 -0
  108. package/dist/digests.js +132 -42
  109. package/dist/doctor/checks.d.ts +0 -3
  110. package/dist/doctor/checks.js +1 -108
  111. package/dist/doctor/runner.js +1 -4
  112. package/dist/env-sanitizer.d.ts +2 -0
  113. package/dist/env-sanitizer.js +61 -0
  114. package/dist/exec-approval.d.ts +11 -1
  115. package/dist/exec-approval.js +17 -4
  116. package/dist/gateway.d.ts +3 -1
  117. package/dist/gateway.js +17 -7
  118. package/dist/heartbeat.js +1 -6
  119. package/dist/langfuse.js +3 -29
  120. package/dist/model-selection.js +3 -1
  121. package/dist/providers/adapter.d.ts +118 -0
  122. package/dist/providers/adapter.js +6 -0
  123. package/dist/providers/adapters/anthropic-adapter.d.ts +22 -0
  124. package/dist/providers/adapters/anthropic-adapter.js +204 -0
  125. package/dist/providers/adapters/codex-adapter.d.ts +26 -0
  126. package/dist/providers/adapters/codex-adapter.js +203 -0
  127. package/dist/providers/anthropic.d.ts +1 -0
  128. package/dist/providers/anthropic.js +10 -272
  129. package/dist/providers/codex.d.ts +21 -0
  130. package/dist/providers/codex.js +149 -330
  131. package/dist/providers/content.d.ts +1 -1
  132. package/dist/providers/content.js +2 -2
  133. package/dist/providers/context-manager.d.ts +18 -6
  134. package/dist/providers/context-manager.js +199 -223
  135. package/dist/providers/index.d.ts +9 -1
  136. package/dist/providers/index.js +73 -64
  137. package/dist/providers/loop-utils.d.ts +20 -0
  138. package/dist/providers/loop-utils.js +30 -0
  139. package/dist/providers/tool-loop.d.ts +12 -0
  140. package/dist/providers/tool-loop.js +251 -0
  141. package/dist/providers/utils.d.ts +19 -3
  142. package/dist/providers/utils.js +100 -29
  143. package/dist/secure-store.d.ts +8 -0
  144. package/dist/secure-store.js +80 -0
  145. package/dist/service.js +3 -28
  146. package/dist/sessions.d.ts +3 -0
  147. package/dist/sessions.js +147 -18
  148. package/dist/setup-templates.js +13 -25
  149. package/dist/setup.d.ts +10 -6
  150. package/dist/setup.js +84 -292
  151. package/dist/skills.js +3 -11
  152. package/dist/tools/agent-delegation.d.ts +19 -0
  153. package/dist/tools/agent-delegation.js +49 -0
  154. package/dist/tools/bash-tool.js +89 -34
  155. package/dist/tools/definitions.d.ts +199 -302
  156. package/dist/tools/definitions.js +70 -123
  157. package/dist/tools/execute-context.d.ts +13 -4
  158. package/dist/tools/fetch-tool.js +109 -13
  159. package/dist/tools/file-tools.js +7 -1
  160. package/dist/tools.d.ts +7 -7
  161. package/dist/tools.js +133 -151
  162. package/dist/types.d.ts +37 -30
  163. package/dist/utils.js +4 -6
  164. package/dist/voice.d.ts +1 -1
  165. package/dist/voice.js +17 -4
  166. package/package.json +33 -23
  167. package/templates/TOOLS.md +0 -27
  168. package/dist/__tests__/audit.test.js +0 -122
  169. package/dist/__tests__/code-agents-orchestrator.test.js +0 -216
  170. package/dist/__tests__/code-agents-sandbox.test.js +0 -163
  171. package/dist/__tests__/orchestrator.test.js +0 -425
  172. package/dist/__tests__/sandbox-bridge.test.js +0 -116
  173. package/dist/__tests__/sandbox-manager.test.js +0 -144
  174. package/dist/__tests__/sandbox-mount-security.test.js +0 -139
  175. package/dist/__tests__/sandbox-runtime.test.js +0 -176
  176. package/dist/__tests__/subagent.test.js +0 -240
  177. package/dist/__tests__/telegram.test.js +0 -42
  178. package/dist/code-agents/orchestrator.d.ts +0 -29
  179. package/dist/code-agents/orchestrator.js +0 -694
  180. package/dist/code-agents/worktree.d.ts +0 -40
  181. package/dist/code-agents/worktree.js +0 -215
  182. package/dist/dashboard/assets/index-BoTHPby4.js +0 -65
  183. package/dist/dashboard/assets/index-D4mufvBg.css +0 -1
  184. package/dist/dashboard.d.ts +0 -8
  185. package/dist/dashboard.js +0 -4071
  186. package/dist/discord.d.ts +0 -8
  187. package/dist/discord.js +0 -792
  188. package/dist/mcp-context-a8c.d.ts +0 -13
  189. package/dist/mcp-context-a8c.js +0 -34
  190. package/dist/orchestrator.d.ts +0 -15
  191. package/dist/orchestrator.js +0 -676
  192. package/dist/providers/openai.d.ts +0 -10
  193. package/dist/providers/openai.js +0 -355
  194. package/dist/sandbox/bridge.d.ts +0 -5
  195. package/dist/sandbox/bridge.js +0 -63
  196. package/dist/sandbox/index.d.ts +0 -5
  197. package/dist/sandbox/index.js +0 -4
  198. package/dist/sandbox/manager.d.ts +0 -7
  199. package/dist/sandbox/manager.js +0 -100
  200. package/dist/sandbox/mount-security.d.ts +0 -12
  201. package/dist/sandbox/mount-security.js +0 -122
  202. package/dist/sandbox/runtime.d.ts +0 -39
  203. package/dist/sandbox/runtime.js +0 -192
  204. package/dist/sandbox-utils.d.ts +0 -6
  205. package/dist/sandbox-utils.js +0 -36
  206. package/dist/subagent.d.ts +0 -19
  207. package/dist/subagent.js +0 -407
  208. package/dist/telegram.d.ts +0 -2
  209. package/dist/telegram.js +0 -11
  210. package/dist/tools/browser-tool.d.ts +0 -3
  211. package/dist/tools/browser-tool.js +0 -266
  212. package/sandbox/Dockerfile +0 -40
  213. /package/dist/__tests__/{audit.test.d.ts → code-agents-notifications.test.d.ts} +0 -0
  214. /package/dist/__tests__/{code-agents-orchestrator.test.d.ts → code-agents-worktrees.test.d.ts} +0 -0
  215. /package/dist/__tests__/{code-agents-sandbox.test.d.ts → codex-adapter.test.d.ts} +0 -0
  216. /package/dist/__tests__/{orchestrator.test.d.ts → codex-auth.test.d.ts} +0 -0
  217. /package/dist/__tests__/{sandbox-bridge.test.d.ts → codex-provider-gating.test.d.ts} +0 -0
  218. /package/dist/__tests__/{sandbox-manager.test.d.ts → codex-unified-loop.test.d.ts} +0 -0
  219. /package/dist/__tests__/{sandbox-mount-security.test.d.ts → config-security.test.d.ts} +0 -0
  220. /package/dist/__tests__/{sandbox-runtime.test.d.ts → cron-run.test.d.ts} +0 -0
  221. /package/dist/__tests__/{subagent.test.d.ts → digests.test.d.ts} +0 -0
  222. /package/dist/__tests__/{telegram.test.d.ts → discord-attachments.test.d.ts} +0 -0
package/dist/discord.js DELETED
@@ -1,792 +0,0 @@
1
- import { Client, GatewayIntentBits, Partials, ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder, } from 'discord.js';
2
- import { join } from 'path';
3
- import { homedir } from 'os';
4
- import { tmpdir } from 'os';
5
- import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
6
- import { getCurrentModel, setCurrentModel, getLastMessage } from './gateway.js';
7
- import { getCronJobs, runCronJob } from './cron.js';
8
- import { runAgentTurn } from './agent.js';
9
- import { runHeartbeatCheck } from './heartbeat.js';
10
- import { isAllowed, isRateLimited } from './security.js';
11
- import { getActiveCodeAgents, getRecentCodeAgents } from './tools.js';
12
- import { listApprovals, approveRequest, denyRequest, getApproval, onApprovalEvent, } from './exec-approval.js';
13
- import { transcribeAudio, synthesizeSpeech } from './voice.js';
14
- import * as sessions from './sessions.js';
15
- import { formatAliases, formatModelSelectionError, getModelSelectionUsage, resolveModelSelection } from './model-selection.js';
16
- function getDiscordRunContext(message) {
17
- return {
18
- userId: message.author.id,
19
- sessionId: message.channel.id,
20
- channel: 'discord',
21
- trigger: 'discord',
22
- metadata: {
23
- username: message.author.username,
24
- },
25
- };
26
- }
27
- const BOT_COMMANDS = [
28
- { command: 'help', description: 'Show available commands' },
29
- { command: 'model', description: 'Switch model (fast/smart/opus)' },
30
- { command: 'status', description: 'Show bot status' },
31
- { command: 'clear', description: 'Clear conversation history' },
32
- { command: 'compact', description: 'Compress conversation history' },
33
- { command: 'silence', description: 'Pause proactive messages' },
34
- { command: 'cron', description: 'List or run scheduled jobs' },
35
- { command: 'tasks', description: 'List active coding agents and cron jobs' },
36
- { command: 'cancel', description: 'Cancel a coding agent (use dashboard) or cron job' },
37
- { command: 'approvals', description: 'List pending exec approvals' },
38
- { command: 'approve', description: 'Approve an exec request by ID' },
39
- { command: 'deny', description: 'Deny an exec request by ID' },
40
- { command: 'heartbeat', description: 'Trigger heartbeat check' },
41
- ];
42
- const KNOWN_COMMANDS = new Set(BOT_COMMANDS.map(c => c.command));
43
- const MAX_HISTORY_PAIRS = 5;
44
- const chatHistory = new Map();
45
- // Track which keys have been loaded from disk this session
46
- const loadedFromDisk = new Set();
47
- const DEFAULT_DISCORD_TOOLS = {
48
- enabled: true,
49
- allowedPaths: [join(homedir(), '.skimpyclaw')],
50
- maxIterations: 30,
51
- bashTimeout: 15000,
52
- };
53
- let client = null;
54
- let config;
55
- let silenceUntil = null;
56
- async function getHistory(key) {
57
- // Lazy-load from disk on first access this session
58
- if (!loadedFromDisk.has(key)) {
59
- loadedFromDisk.add(key);
60
- const diskHistory = await sessions.loadHistory('discord', key).catch(() => []);
61
- if (diskHistory.length > 0 && !chatHistory.has(key)) {
62
- chatHistory.set(key, diskHistory);
63
- }
64
- }
65
- return chatHistory.get(key) || [];
66
- }
67
- async function addToHistory(key, userMsg, assistantMsg) {
68
- const history = await getHistory(key);
69
- history.push({ role: 'user', content: userMsg });
70
- history.push({ role: 'assistant', content: assistantMsg });
71
- while (history.length > MAX_HISTORY_PAIRS * 2) {
72
- history.shift();
73
- history.shift();
74
- }
75
- chatHistory.set(key, history);
76
- // Persist to disk (fire-and-forget)
77
- sessions.saveExchange('discord', key, userMsg, assistantMsg).catch(() => { });
78
- }
79
- async function clearHistory(key) {
80
- chatHistory.delete(key);
81
- loadedFromDisk.delete(key);
82
- await sessions.clearHistory('discord', key).catch(() => { });
83
- }
84
- function getDiscordToolConfig(cfg) {
85
- const discord = cfg.channels.discord;
86
- if (discord?.tools) {
87
- return {
88
- ...DEFAULT_DISCORD_TOOLS,
89
- ...discord.tools,
90
- allowedPaths: discord.tools.allowedPaths ?? discord.defaultAllowedPaths ?? DEFAULT_DISCORD_TOOLS.allowedPaths,
91
- };
92
- }
93
- if (discord?.defaultAllowedPaths?.length) {
94
- return {
95
- ...DEFAULT_DISCORD_TOOLS,
96
- allowedPaths: discord.defaultAllowedPaths,
97
- };
98
- }
99
- return DEFAULT_DISCORD_TOOLS;
100
- }
101
- function conversationKey(message) {
102
- if (message.channel.isDMBased()) {
103
- return `dm:${message.author.id}`;
104
- }
105
- return `channel:${message.channelId}`;
106
- }
107
- function buildHelpText(cfg) {
108
- const agentConfig = cfg.agents.list[cfg.agents.default];
109
- const emoji = agentConfig?.identity?.emoji || '🦞';
110
- const name = agentConfig?.identity?.name || 'SkimpyClaw';
111
- const commandList = BOT_COMMANDS.map(c => `/${c.command} - ${c.description}`).join('\n');
112
- return `${emoji} ${name} online.\n\nSend a message to chat, or use a command:\n\n${commandList}`;
113
- }
114
- function splitToChunks(text, maxLength) {
115
- if (text.length <= maxLength)
116
- return [text];
117
- const chunks = [];
118
- let current = '';
119
- for (const paragraph of text.split('\n\n')) {
120
- if (current.length + paragraph.length + 2 > maxLength) {
121
- if (current)
122
- chunks.push(current.trim());
123
- // If a single paragraph exceeds maxLength, split it on newlines or hard-cut
124
- if (paragraph.length > maxLength) {
125
- const lines = paragraph.split('\n');
126
- let lineBuf = '';
127
- for (const line of lines) {
128
- if (lineBuf.length + line.length + 1 > maxLength) {
129
- if (lineBuf)
130
- chunks.push(lineBuf.trim());
131
- // If a single line still exceeds, hard-cut it
132
- if (line.length > maxLength) {
133
- for (let i = 0; i < line.length; i += maxLength) {
134
- chunks.push(line.slice(i, i + maxLength));
135
- }
136
- lineBuf = '';
137
- }
138
- else {
139
- lineBuf = line;
140
- }
141
- }
142
- else {
143
- lineBuf += (lineBuf ? '\n' : '') + line;
144
- }
145
- }
146
- current = lineBuf;
147
- }
148
- else {
149
- current = paragraph;
150
- }
151
- }
152
- else {
153
- current += (current ? '\n\n' : '') + paragraph;
154
- }
155
- }
156
- if (current)
157
- chunks.push(current.trim());
158
- return chunks.filter(c => c.length > 0);
159
- }
160
- async function sendLongText(message, text) {
161
- const chunks = splitToChunks(text, 1900);
162
- for (const chunk of chunks) {
163
- await message.reply(chunk);
164
- }
165
- }
166
- function startTypingIndicator(message) {
167
- const maxDurationMs = 90_000;
168
- let stopped = false;
169
- const stop = () => {
170
- if (stopped)
171
- return;
172
- stopped = true;
173
- clearInterval(interval);
174
- clearTimeout(watchdog);
175
- };
176
- const channel = message.channel;
177
- if (typeof channel.sendTyping === 'function') {
178
- void channel.sendTyping().catch(() => { });
179
- }
180
- const interval = setInterval(() => {
181
- if (stopped)
182
- return;
183
- if (typeof channel.sendTyping === 'function') {
184
- void channel.sendTyping().catch(() => { });
185
- }
186
- }, 4000);
187
- const watchdog = setTimeout(() => {
188
- console.warn('[discord] Typing indicator watchdog reached; auto-stopping.');
189
- stop();
190
- }, maxDurationMs);
191
- return stop;
192
- }
193
- async function handleCommand(message, command, args) {
194
- const rawArgs = args.join(' ').trim();
195
- if (command === 'start' || command === 'help') {
196
- await sendLongText(message, buildHelpText(config));
197
- return;
198
- }
199
- if (command === 'model') {
200
- if (!rawArgs) {
201
- const current = getCurrentModel();
202
- const aliases = formatAliases(config);
203
- await message.reply(`Current: ${current}\nAliases: ${aliases}\n\nUsage: /model <alias|provider/model|model-id>\n${getModelSelectionUsage()}`);
204
- return;
205
- }
206
- const selection = resolveModelSelection(rawArgs, config);
207
- if (!selection.ok || !selection.resolved) {
208
- const errorMessage = selection.error || 'Invalid model selection';
209
- await message.reply(formatModelSelectionError(errorMessage, config));
210
- return;
211
- }
212
- setCurrentModel(selection.resolved);
213
- if (selection.aliasUsed) {
214
- await message.reply(`Model switched to: ${selection.aliasUsed} (${selection.resolved})`);
215
- }
216
- else {
217
- await message.reply(`Model switched to: ${selection.resolved}`);
218
- }
219
- return;
220
- }
221
- if (command === 'status') {
222
- const model = getCurrentModel();
223
- const last = getLastMessage();
224
- const jobs = getCronJobs();
225
- const jobList = jobs.map(j => `- ${j.name}: ${j.nextRun?.toLocaleString() || 'unknown'}`).join('\n');
226
- // Coding agents status (multi-agent)
227
- const caActive = getActiveCodeAgents();
228
- const caRecent = getRecentCodeAgents(3);
229
- const caAll = [...caActive, ...caRecent];
230
- let caLine = 'Coding Agents: idle';
231
- if (caAll.length > 0) {
232
- const runningCount = caActive.length;
233
- const completedCount = caRecent.filter(t => t.status === 'completed').length;
234
- const failedCount = caRecent.filter(t => t.status === 'failed' || t.status === 'timeout').length;
235
- const parts = [];
236
- if (runningCount)
237
- parts.push(`${runningCount} running`);
238
- if (completedCount)
239
- parts.push(`${completedCount} completed`);
240
- if (failedCount)
241
- parts.push(`${failedCount} failed`);
242
- caLine = `Coding Agents: ${parts.join(', ') || 'idle'}`;
243
- const caPreview = caAll.slice(0, 5).map(t => {
244
- const elapsed = t.durationSeconds != null
245
- ? (t.durationSeconds < 60 ? `${t.durationSeconds}s` : `${Math.floor(t.durationSeconds / 60)}m ${t.durationSeconds % 60}s`)
246
- : (Math.round((Date.now() - new Date(t.startedAt).getTime()) / 1000) + 's');
247
- const taskPreview = t.task.length > 50 ? t.task.slice(0, 50) + '...' : t.task;
248
- return ` ${t.id}: ${t.status.toUpperCase()} (${t.agent}, ${elapsed}) — ${taskPreview}`;
249
- }).join('\n');
250
- if (caPreview)
251
- caLine += '\n' + caPreview;
252
- }
253
- await message.reply(`Agent: ${config.agents.default}\n` +
254
- `Model: ${model}\n` +
255
- `Last message: ${last?.toLocaleString() || 'never'}\n` +
256
- `Silence until: ${silenceUntil?.toLocaleString() || 'not silenced'}\n\n` +
257
- `${caLine}\n\n` +
258
- `Scheduled jobs:\n${jobList || '(none)'}`);
259
- return;
260
- }
261
- if (command === 'cron') {
262
- const subcommand = args[0];
263
- if (!subcommand || subcommand === 'list') {
264
- const jobs = getCronJobs();
265
- if (jobs.length === 0) {
266
- await message.reply('No scheduled jobs.');
267
- return;
268
- }
269
- const list = jobs.map(j => `${j.id}: ${j.name} (next: ${j.nextRun?.toLocaleString() || '?'})`).join('\n');
270
- await message.reply(`Scheduled jobs:\n${list}`);
271
- return;
272
- }
273
- if (subcommand === 'run') {
274
- const jobId = args[1];
275
- if (!jobId) {
276
- await message.reply('Usage: /cron run <job-id>');
277
- return;
278
- }
279
- try {
280
- await runCronJob(jobId, config);
281
- await message.reply(`Triggered: ${jobId}`);
282
- }
283
- catch (error) {
284
- const msg = error instanceof Error ? error.message : 'Unknown error';
285
- await message.reply(`Error: ${msg}`);
286
- }
287
- return;
288
- }
289
- await message.reply('Usage: /cron list | /cron run <id>');
290
- return;
291
- }
292
- if (command === 'heartbeat') {
293
- const stopTyping = startTypingIndicator(message);
294
- try {
295
- const response = await runHeartbeatCheck(config);
296
- await sendLongText(message, `Heartbeat:\n\n${response}`);
297
- }
298
- catch (error) {
299
- const msg = error instanceof Error ? error.message : 'Unknown error';
300
- await message.reply(`Heartbeat error: ${msg}`);
301
- }
302
- finally {
303
- stopTyping();
304
- }
305
- return;
306
- }
307
- if (command === 'approvals') {
308
- const pending = listApprovals();
309
- if (pending.length === 0) {
310
- await message.reply('No pending exec approvals.');
311
- return;
312
- }
313
- for (const approval of pending.slice(0, 10)) {
314
- const cmdPreview = approval.command.length > 80
315
- ? approval.command.slice(0, 80) + '...'
316
- : approval.command;
317
- const expiresIn = Math.max(0, Math.round((approval.expiresAt.getTime() - Date.now()) / 1000));
318
- const expiresStr = expiresIn < 60 ? `${expiresIn}s` : `${Math.floor(expiresIn / 60)}m`;
319
- const row = new ActionRowBuilder().addComponents(new ButtonBuilder()
320
- .setCustomId(`approve:${approval.id}`)
321
- .setLabel('Approve')
322
- .setStyle(ButtonStyle.Success), new ButtonBuilder()
323
- .setCustomId(`deny:${approval.id}`)
324
- .setLabel('Deny')
325
- .setStyle(ButtonStyle.Danger));
326
- await message.reply({
327
- content: `⛔ Approval #${approval.id}\n` +
328
- `Tier ${approval.tier}: ${approval.reason}\n` +
329
- `Command: ${cmdPreview}\n` +
330
- `${approval.cwd ? `CWD: ${approval.cwd}\n` : ''}` +
331
- `Expires in: ${expiresStr}`,
332
- components: [row],
333
- });
334
- }
335
- return;
336
- }
337
- if (command === 'approve') {
338
- const id = rawArgs;
339
- if (!id) {
340
- await message.reply('Usage: /approve <id>');
341
- return;
342
- }
343
- const by = message.author.username || message.author.id;
344
- const success = approveRequest(id, by);
345
- if (success) {
346
- await message.reply(`✅ Approved #${id}`);
347
- }
348
- else {
349
- const existing = getApproval(id);
350
- if (existing) {
351
- await message.reply(`Cannot approve #${id} — status is already "${existing.status}".`);
352
- }
353
- else {
354
- await message.reply(`No pending approval found with ID "${id}".`);
355
- }
356
- }
357
- return;
358
- }
359
- if (command === 'deny') {
360
- const id = rawArgs;
361
- if (!id) {
362
- await message.reply('Usage: /deny <id>');
363
- return;
364
- }
365
- const by = message.author.username || message.author.id;
366
- const success = denyRequest(id, by);
367
- if (success) {
368
- await message.reply(`❌ Denied #${id}`);
369
- }
370
- else {
371
- const existing = getApproval(id);
372
- if (existing) {
373
- await message.reply(`Cannot deny #${id} — status is already "${existing.status}".`);
374
- }
375
- else {
376
- await message.reply(`No pending approval found with ID "${id}".`);
377
- }
378
- }
379
- return;
380
- }
381
- if (command === 'tasks') {
382
- await message.reply('Use `/agents` to list active coding agents, or `/cron` to manage scheduled tasks.');
383
- return;
384
- }
385
- if (command === 'cancel') {
386
- await message.reply('Use the dashboard to cancel coding agents, or `/cron` to manage scheduled tasks.');
387
- return;
388
- }
389
- if (command === 'clear') {
390
- await clearHistory(conversationKey(message));
391
- await message.reply('Conversation cleared. Starting fresh.');
392
- return;
393
- }
394
- if (command === 'compact') {
395
- const key = conversationKey(message);
396
- const history = await getHistory(key);
397
- if (history.length === 0) {
398
- await message.reply('No conversation history to compact.');
399
- return;
400
- }
401
- const stopTyping = startTypingIndicator(message);
402
- try {
403
- const historyText = history.map(m => `${m.role}: ${m.content}`).join('\n');
404
- 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));
405
- await clearHistory(key);
406
- chatHistory.set(key, [
407
- { role: 'user', content: 'Summary of our previous conversation:' },
408
- { role: 'assistant', content: summary },
409
- ]);
410
- loadedFromDisk.add(key); // Mark as loaded so we don't re-load on next access
411
- await sessions.replaceWithSummary('discord', key, summary);
412
- await message.reply(`Compacted ${history.length} messages into a summary.`);
413
- }
414
- catch (error) {
415
- const msg = error instanceof Error ? error.message : 'Unknown error';
416
- await message.reply(`Error: ${msg}`);
417
- }
418
- finally {
419
- stopTyping();
420
- }
421
- return;
422
- }
423
- if (command === 'silence') {
424
- const minutes = parseInt(rawArgs, 10) || 30;
425
- silenceUntil = new Date(Date.now() + minutes * 60 * 1000);
426
- await message.reply(`Proactive messages silenced until ${silenceUntil.toLocaleTimeString()}`);
427
- return;
428
- }
429
- await message.reply(`Unknown command: /${command}\n\nType /help to see available commands.`);
430
- }
431
- async function handleIncomingMessage(message) {
432
- if (message.author.bot)
433
- return;
434
- if (!config.channels.discord)
435
- return;
436
- console.log(`[discord] Received message from ${message.author.id} in ${message.channelId}: ${JSON.stringify(message.content).slice(0, 120)}`);
437
- const senderId = message.author.id;
438
- const senderUsername = message.author.username;
439
- if (!isAllowed(config.channels.discord.allowFrom, senderId, senderUsername)) {
440
- console.log(`[discord] Blocked message from ${senderId} (@${senderUsername})`);
441
- return;
442
- }
443
- if (isRateLimited(senderId)) {
444
- await message.reply('Too many messages. Please wait a moment.');
445
- return;
446
- }
447
- // Check for image attachments
448
- const imageAttachments = message.attachments.filter(a => a.contentType?.startsWith('image/'));
449
- if (imageAttachments.size > 0) {
450
- const attachment = imageAttachments.first();
451
- const stopTyping = startTypingIndicator(message);
452
- try {
453
- // Download the image
454
- const imageResponse = await fetch(attachment.url);
455
- const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
456
- const base64Image = imageBuffer.toString('base64');
457
- // Determine media type
458
- const mediaType = attachment.contentType || 'image/jpeg';
459
- // Get message text or use default
460
- const caption = message.content.trim() || "What's in this image?";
461
- // Build multi-part content array
462
- const content = [
463
- {
464
- type: 'image',
465
- source: {
466
- type: 'base64',
467
- media_type: mediaType,
468
- data: base64Image,
469
- },
470
- },
471
- {
472
- type: 'text',
473
- text: caption,
474
- },
475
- ];
476
- const key = conversationKey(message);
477
- const history = await getHistory(key);
478
- const response = await runAgentTurn(config.agents.default, content, config, getCurrentModel(), getDiscordToolConfig(config), history, getDiscordRunContext(message));
479
- await addToHistory(key, `[Image: ${caption}]`, response);
480
- await sendLongText(message, response);
481
- }
482
- catch (error) {
483
- const msg = error instanceof Error ? error.message : 'Unknown error';
484
- await message.reply(`Error processing image: ${msg}`);
485
- }
486
- finally {
487
- stopTyping();
488
- }
489
- return;
490
- }
491
- // Check for voice message attachments
492
- const voiceAttachments = message.attachments.filter(a => a.contentType?.startsWith('audio/') || a.contentType?.startsWith('voice/'));
493
- if (voiceAttachments.size > 0) {
494
- const attachment = voiceAttachments.first();
495
- const stopTyping = startTypingIndicator(message);
496
- try {
497
- // Check if voice config is available
498
- if (!config.voice) {
499
- await message.reply('Voice transcription not configured. Add a "voice" section to config.json.');
500
- return;
501
- }
502
- // Download the voice file
503
- const voiceResponse = await fetch(attachment.url);
504
- const buffer = Buffer.from(await voiceResponse.arrayBuffer());
505
- // Save to temp file
506
- const ext = attachment.name?.split('.').pop() || 'ogg';
507
- const tempDir = join(tmpdir(), 'skimpyclaw-voice');
508
- if (!existsSync(tempDir))
509
- mkdirSync(tempDir, { recursive: true });
510
- const tempPath = join(tempDir, `discord-voice-${Date.now()}.${ext}`);
511
- writeFileSync(tempPath, buffer);
512
- try {
513
- // Transcribe
514
- const result = await transcribeAudio(tempPath, config.voice);
515
- const transcription = result.text.trim();
516
- console.log(`[discord] Transcription result: ${transcription}`);
517
- if (!transcription) {
518
- await message.reply('Could not transcribe audio — no speech detected.');
519
- return;
520
- }
521
- const key = conversationKey(message);
522
- const history = await getHistory(key);
523
- const agentResponse = await runAgentTurn(config.agents.default, transcription, config, getCurrentModel(), getDiscordToolConfig(config), history, getDiscordRunContext(message));
524
- await addToHistory(key, transcription, agentResponse);
525
- // TTS voice reply if sendVoice enabled
526
- console.log('[discord] TTS check - sendVoice:', config.voice?.channels?.['discord']?.sendVoice);
527
- if (config.voice?.channels?.['discord']?.sendVoice) {
528
- console.log('[discord] Attempting TTS synthesis...');
529
- try {
530
- const speech = await synthesizeSpeech(agentResponse, config.voice);
531
- console.log('[discord] TTS synthesis success:', speech.format, speech.provider, 'buffer size:', speech.buffer.length);
532
- const attachment = new AttachmentBuilder(speech.buffer, {
533
- name: `voice-reply.${speech.format}`,
534
- description: 'Voice reply'
535
- });
536
- await message.reply({ files: [attachment] });
537
- console.log('[discord] Voice reply sent');
538
- }
539
- catch (err) {
540
- console.error('[discord] TTS synthesis failed:', err);
541
- // Non-fatal — text reply still sends below
542
- }
543
- }
544
- // Format response with transcription in a blockquote
545
- const combined = `> 🎤 ${transcription}\n\n${agentResponse}`;
546
- await sendLongText(message, combined);
547
- }
548
- finally {
549
- // Clean up temp file
550
- try {
551
- unlinkSync(tempPath);
552
- }
553
- catch { /* best effort */ }
554
- }
555
- }
556
- catch (error) {
557
- const msg = error instanceof Error ? error.message : 'Unknown error';
558
- await message.reply(`Voice transcription error: ${msg}`);
559
- }
560
- finally {
561
- stopTyping();
562
- }
563
- return;
564
- }
565
- const text = message.content.trim();
566
- if (!text)
567
- return;
568
- const isPrefixedCommand = text.startsWith('/') || text.startsWith('!');
569
- const isDm = message.channel.isDMBased();
570
- if (isPrefixedCommand || isDm) {
571
- const commandText = isPrefixedCommand ? text.slice(1).trim() : text;
572
- const [commandPart, ...args] = commandText.split(/\s+/);
573
- const command = (commandPart || '').toLowerCase();
574
- if (!KNOWN_COMMANDS.has(command)) {
575
- if (isPrefixedCommand) {
576
- await message.reply(`Unknown command: /${command}\n\nType /help to see available commands.`);
577
- return;
578
- }
579
- }
580
- else {
581
- await handleCommand(message, command, args);
582
- return;
583
- }
584
- }
585
- const key = conversationKey(message);
586
- const stopTyping = startTypingIndicator(message);
587
- try {
588
- const history = await getHistory(key);
589
- const response = await runAgentTurn(config.agents.default, text, config, getCurrentModel(), getDiscordToolConfig(config), history, getDiscordRunContext(message));
590
- await addToHistory(key, text, response);
591
- await sendLongText(message, response);
592
- }
593
- catch (error) {
594
- const msg = error instanceof Error ? error.message : 'Unknown error';
595
- await message.reply(`Error: ${msg}`);
596
- }
597
- finally {
598
- stopTyping();
599
- }
600
- }
601
- /** Send an approval card message with approve/deny buttons to a Discord channel. */
602
- async function sendApprovalCard(channelId, approval) {
603
- if (!client)
604
- return;
605
- const channel = await client.channels.fetch(channelId).catch(() => null);
606
- if (!channel || !('send' in channel) || typeof channel.send !== 'function')
607
- return;
608
- const cmdPreview = approval.command.length > 80
609
- ? approval.command.slice(0, 80) + '...'
610
- : approval.command;
611
- const expiresIn = Math.max(0, Math.round((approval.expiresAt.getTime() - Date.now()) / 1000));
612
- const expiresStr = expiresIn < 60 ? `${expiresIn}s` : `${Math.floor(expiresIn / 60)}m`;
613
- const row = new ActionRowBuilder().addComponents(new ButtonBuilder()
614
- .setCustomId(`approve:${approval.id}`)
615
- .setLabel('Approve')
616
- .setStyle(ButtonStyle.Success), new ButtonBuilder()
617
- .setCustomId(`deny:${approval.id}`)
618
- .setLabel('Deny')
619
- .setStyle(ButtonStyle.Danger));
620
- await channel.send({
621
- content: `⛔ Exec approval needed: #${approval.id}\n` +
622
- `Tier ${approval.tier}: ${approval.reason}\n` +
623
- `Command: ${cmdPreview}\n` +
624
- `${approval.cwd ? `CWD: ${approval.cwd}\n` : ''}` +
625
- `Expires in: ${expiresStr}`,
626
- components: [row],
627
- });
628
- }
629
- /** Handle Discord button interactions for approval approve/deny. */
630
- async function handleInteraction(interaction) {
631
- if (!interaction.isButton())
632
- return;
633
- const customId = interaction.customId;
634
- const [action, id] = customId.split(':');
635
- if (!id || (action !== 'approve' && action !== 'deny')) {
636
- await interaction.reply({ content: 'Unknown action', ephemeral: true });
637
- return;
638
- }
639
- const by = interaction.user.username || interaction.user.id;
640
- let success;
641
- let statusText;
642
- if (action === 'approve') {
643
- success = approveRequest(id, by);
644
- statusText = success ? `✅ Approved by @${by}` : 'Failed — not pending';
645
- }
646
- else {
647
- success = denyRequest(id, by);
648
- statusText = success ? `❌ Denied by @${by}` : 'Failed — not pending';
649
- }
650
- // Ephemeral acknowledgement to the clicker
651
- await interaction.reply({ content: statusText, ephemeral: true });
652
- // Update the original message to reflect the resolved status
653
- try {
654
- const approval = getApproval(id);
655
- if (approval) {
656
- const cmdPreview = approval.command.length > 80
657
- ? approval.command.slice(0, 80) + '...'
658
- : approval.command;
659
- await interaction.message.edit({
660
- content: `${statusText}\n\n` +
661
- `Approval #${id}\n` +
662
- `Tier ${approval.tier}: ${approval.reason}\n` +
663
- `Command: ${cmdPreview}`,
664
- components: [], // Remove buttons after resolution
665
- });
666
- }
667
- }
668
- catch {
669
- // Message may already be edited or deleted — ignore
670
- }
671
- }
672
- export async function initDiscord(cfg) {
673
- const discord = cfg.channels.discord;
674
- if (!discord?.enabled || !discord.token) {
675
- console.log('[discord] Disabled or no token configured');
676
- return false;
677
- }
678
- config = cfg;
679
- client = new Client({
680
- intents: [
681
- GatewayIntentBits.Guilds,
682
- GatewayIntentBits.GuildMessages,
683
- GatewayIntentBits.DirectMessages,
684
- GatewayIntentBits.MessageContent,
685
- ],
686
- partials: [Partials.Channel],
687
- });
688
- client.on('messageCreate', (message) => {
689
- void handleIncomingMessage(message);
690
- });
691
- // Handle button interactions (approval approve/deny)
692
- client.on('interactionCreate', (interaction) => {
693
- void handleInteraction(interaction);
694
- });
695
- client.once('clientReady', () => {
696
- console.log(`[discord] Bot started as ${client?.user?.tag ?? 'unknown'}`);
697
- });
698
- client.on('error', (error) => {
699
- console.error('[discord] Client error:', error);
700
- });
701
- // Subscribe to approval-created events — proactively post to Discord for discord-origin approvals
702
- onApprovalEvent('created', (event) => {
703
- if (!client)
704
- return;
705
- const { approval } = event;
706
- const meta = approval.channelMeta;
707
- // Only post discord-origin approvals (or fallback when no channel set)
708
- if (meta?.channel && meta.channel !== 'discord')
709
- return;
710
- let targetChannelId;
711
- if (meta?.chatId) {
712
- targetChannelId = String(meta.chatId);
713
- }
714
- if (!targetChannelId) {
715
- targetChannelId = getDiscordDefaultTarget(cfg) ?? undefined;
716
- }
717
- if (!targetChannelId)
718
- return;
719
- void sendApprovalCard(targetChannelId, approval).catch((err) => {
720
- console.error('[discord] Failed to send approval notification:', err);
721
- });
722
- });
723
- return true;
724
- }
725
- export async function startDiscord() {
726
- if (!client || !config.channels.discord?.token)
727
- return;
728
- console.log('[discord] Starting bot...');
729
- await client.login(config.channels.discord.token);
730
- }
731
- export async function stopDiscord() {
732
- if (!client)
733
- return;
734
- client.destroy();
735
- console.log('[discord] Bot stopped');
736
- }
737
- export function isDiscordSilenced() {
738
- if (!silenceUntil)
739
- return false;
740
- return new Date() < silenceUntil;
741
- }
742
- export function getDiscordDefaultTarget(cfg) {
743
- const discord = cfg.channels.discord;
744
- if (!discord)
745
- return null;
746
- if (discord.defaultChannelId?.trim())
747
- return discord.defaultChannelId.trim();
748
- for (const entry of discord.allowFrom) {
749
- const value = String(entry).trim();
750
- if (value)
751
- return value;
752
- }
753
- return null;
754
- }
755
- async function sendChunked(target, text) {
756
- const chunks = splitToChunks(text, 1900);
757
- for (const chunk of chunks) {
758
- await target.send(chunk);
759
- }
760
- }
761
- export async function sendDiscordProactiveMessage(target, message) {
762
- if (!client || isDiscordSilenced())
763
- return;
764
- const targetId = String(target);
765
- const channel = await client.channels.fetch(targetId).catch(() => null);
766
- if (channel && 'send' in channel && typeof channel.send === 'function') {
767
- await sendChunked(channel, message);
768
- return;
769
- }
770
- const user = await client.users.fetch(targetId).catch(() => null);
771
- if (user) {
772
- await sendChunked(user, message);
773
- }
774
- }
775
- export async function sendDiscordProactiveVoice(target, buffer, format) {
776
- if (!client || isDiscordSilenced())
777
- return;
778
- const targetId = String(target);
779
- const attachment = new AttachmentBuilder(buffer, {
780
- name: `voice.${format}`,
781
- description: 'Voice message',
782
- });
783
- const channel = await client.channels.fetch(targetId).catch(() => null);
784
- if (channel && 'send' in channel && typeof channel.send === 'function') {
785
- await channel.send({ files: [attachment] });
786
- return;
787
- }
788
- const user = await client.users.fetch(targetId).catch(() => null);
789
- if (user) {
790
- await user.send({ files: [attachment] });
791
- }
792
- }