@animalabs/agent-framework 0.1.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 (212) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/src/agent.d.ts +130 -0
  4. package/dist/src/agent.d.ts.map +1 -0
  5. package/dist/src/agent.js +406 -0
  6. package/dist/src/agent.js.map +1 -0
  7. package/dist/src/api/mcp-server.d.ts +37 -0
  8. package/dist/src/api/mcp-server.d.ts.map +1 -0
  9. package/dist/src/api/mcp-server.js +598 -0
  10. package/dist/src/api/mcp-server.js.map +1 -0
  11. package/dist/src/api/server.d.ts +83 -0
  12. package/dist/src/api/server.d.ts.map +1 -0
  13. package/dist/src/api/server.js +785 -0
  14. package/dist/src/api/server.js.map +1 -0
  15. package/dist/src/api/types.d.ts +395 -0
  16. package/dist/src/api/types.d.ts.map +1 -0
  17. package/dist/src/api/types.js +5 -0
  18. package/dist/src/api/types.js.map +1 -0
  19. package/dist/src/framework.d.ts +309 -0
  20. package/dist/src/framework.d.ts.map +1 -0
  21. package/dist/src/framework.js +2309 -0
  22. package/dist/src/framework.js.map +1 -0
  23. package/dist/src/gate/event-gate.d.ts +75 -0
  24. package/dist/src/gate/event-gate.d.ts.map +1 -0
  25. package/dist/src/gate/event-gate.js +503 -0
  26. package/dist/src/gate/event-gate.js.map +1 -0
  27. package/dist/src/gate/index.d.ts +3 -0
  28. package/dist/src/gate/index.d.ts.map +1 -0
  29. package/dist/src/gate/index.js +2 -0
  30. package/dist/src/gate/index.js.map +1 -0
  31. package/dist/src/gate/types.d.ts +82 -0
  32. package/dist/src/gate/types.d.ts.map +1 -0
  33. package/dist/src/gate/types.js +9 -0
  34. package/dist/src/gate/types.js.map +1 -0
  35. package/dist/src/index.d.ts +17 -0
  36. package/dist/src/index.d.ts.map +1 -0
  37. package/dist/src/index.js +18 -0
  38. package/dist/src/index.js.map +1 -0
  39. package/dist/src/mcpl/channel-registry.d.ts +141 -0
  40. package/dist/src/mcpl/channel-registry.d.ts.map +1 -0
  41. package/dist/src/mcpl/channel-registry.js +647 -0
  42. package/dist/src/mcpl/channel-registry.js.map +1 -0
  43. package/dist/src/mcpl/checkpoint-manager.d.ts +79 -0
  44. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -0
  45. package/dist/src/mcpl/checkpoint-manager.js +430 -0
  46. package/dist/src/mcpl/checkpoint-manager.js.map +1 -0
  47. package/dist/src/mcpl/errors.d.ts +52 -0
  48. package/dist/src/mcpl/errors.d.ts.map +1 -0
  49. package/dist/src/mcpl/errors.js +93 -0
  50. package/dist/src/mcpl/errors.js.map +1 -0
  51. package/dist/src/mcpl/feature-set-manager.d.ts +91 -0
  52. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -0
  53. package/dist/src/mcpl/feature-set-manager.js +250 -0
  54. package/dist/src/mcpl/feature-set-manager.js.map +1 -0
  55. package/dist/src/mcpl/hook-orchestrator.d.ts +51 -0
  56. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -0
  57. package/dist/src/mcpl/hook-orchestrator.js +217 -0
  58. package/dist/src/mcpl/hook-orchestrator.js.map +1 -0
  59. package/dist/src/mcpl/index.d.ts +14 -0
  60. package/dist/src/mcpl/index.d.ts.map +1 -0
  61. package/dist/src/mcpl/index.js +21 -0
  62. package/dist/src/mcpl/index.js.map +1 -0
  63. package/dist/src/mcpl/inference-router.d.ts +67 -0
  64. package/dist/src/mcpl/inference-router.d.ts.map +1 -0
  65. package/dist/src/mcpl/inference-router.js +249 -0
  66. package/dist/src/mcpl/inference-router.js.map +1 -0
  67. package/dist/src/mcpl/push-handler.d.ts +60 -0
  68. package/dist/src/mcpl/push-handler.d.ts.map +1 -0
  69. package/dist/src/mcpl/push-handler.js +162 -0
  70. package/dist/src/mcpl/push-handler.js.map +1 -0
  71. package/dist/src/mcpl/scope-manager.d.ts +76 -0
  72. package/dist/src/mcpl/scope-manager.d.ts.map +1 -0
  73. package/dist/src/mcpl/scope-manager.js +213 -0
  74. package/dist/src/mcpl/scope-manager.js.map +1 -0
  75. package/dist/src/mcpl/server-connection.d.ts +174 -0
  76. package/dist/src/mcpl/server-connection.d.ts.map +1 -0
  77. package/dist/src/mcpl/server-connection.js +559 -0
  78. package/dist/src/mcpl/server-connection.js.map +1 -0
  79. package/dist/src/mcpl/server-registry.d.ts +63 -0
  80. package/dist/src/mcpl/server-registry.d.ts.map +1 -0
  81. package/dist/src/mcpl/server-registry.js +119 -0
  82. package/dist/src/mcpl/server-registry.js.map +1 -0
  83. package/dist/src/mcpl/types.d.ts +755 -0
  84. package/dist/src/mcpl/types.d.ts.map +1 -0
  85. package/dist/src/mcpl/types.js +48 -0
  86. package/dist/src/mcpl/types.js.map +1 -0
  87. package/dist/src/module-registry.d.ts +93 -0
  88. package/dist/src/module-registry.d.ts.map +1 -0
  89. package/dist/src/module-registry.js +386 -0
  90. package/dist/src/module-registry.js.map +1 -0
  91. package/dist/src/modules/api/index.d.ts +22 -0
  92. package/dist/src/modules/api/index.d.ts.map +1 -0
  93. package/dist/src/modules/api/index.js +63 -0
  94. package/dist/src/modules/api/index.js.map +1 -0
  95. package/dist/src/modules/discord/discord-js-client.d.ts +58 -0
  96. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -0
  97. package/dist/src/modules/discord/discord-js-client.js +273 -0
  98. package/dist/src/modules/discord/discord-js-client.js.map +1 -0
  99. package/dist/src/modules/discord/index.d.ts +82 -0
  100. package/dist/src/modules/discord/index.d.ts.map +1 -0
  101. package/dist/src/modules/discord/index.js +959 -0
  102. package/dist/src/modules/discord/index.js.map +1 -0
  103. package/dist/src/modules/discord/types.d.ts +327 -0
  104. package/dist/src/modules/discord/types.d.ts.map +1 -0
  105. package/dist/src/modules/discord/types.js +5 -0
  106. package/dist/src/modules/discord/types.js.map +1 -0
  107. package/dist/src/modules/index.d.ts +11 -0
  108. package/dist/src/modules/index.d.ts.map +1 -0
  109. package/dist/src/modules/index.js +7 -0
  110. package/dist/src/modules/index.js.map +1 -0
  111. package/dist/src/modules/workspace/index.d.ts +59 -0
  112. package/dist/src/modules/workspace/index.d.ts.map +1 -0
  113. package/dist/src/modules/workspace/index.js +831 -0
  114. package/dist/src/modules/workspace/index.js.map +1 -0
  115. package/dist/src/modules/workspace/sync.d.ts +48 -0
  116. package/dist/src/modules/workspace/sync.d.ts.map +1 -0
  117. package/dist/src/modules/workspace/sync.js +253 -0
  118. package/dist/src/modules/workspace/sync.js.map +1 -0
  119. package/dist/src/modules/workspace/types.d.ts +166 -0
  120. package/dist/src/modules/workspace/types.d.ts.map +1 -0
  121. package/dist/src/modules/workspace/types.js +5 -0
  122. package/dist/src/modules/workspace/types.js.map +1 -0
  123. package/dist/src/modules/workspace/watcher.d.ts +43 -0
  124. package/dist/src/modules/workspace/watcher.d.ts.map +1 -0
  125. package/dist/src/modules/workspace/watcher.js +120 -0
  126. package/dist/src/modules/workspace/watcher.js.map +1 -0
  127. package/dist/src/queue.d.ts +50 -0
  128. package/dist/src/queue.d.ts.map +1 -0
  129. package/dist/src/queue.js +95 -0
  130. package/dist/src/queue.js.map +1 -0
  131. package/dist/src/safe-slice.d.ts +8 -0
  132. package/dist/src/safe-slice.d.ts.map +1 -0
  133. package/dist/src/safe-slice.js +18 -0
  134. package/dist/src/safe-slice.js.map +1 -0
  135. package/dist/src/types/agent.d.ts +118 -0
  136. package/dist/src/types/agent.d.ts.map +1 -0
  137. package/dist/src/types/agent.js +2 -0
  138. package/dist/src/types/agent.js.map +1 -0
  139. package/dist/src/types/events.d.ts +220 -0
  140. package/dist/src/types/events.d.ts.map +1 -0
  141. package/dist/src/types/events.js +2 -0
  142. package/dist/src/types/events.js.map +1 -0
  143. package/dist/src/types/framework.d.ts +184 -0
  144. package/dist/src/types/framework.d.ts.map +1 -0
  145. package/dist/src/types/framework.js +2 -0
  146. package/dist/src/types/framework.js.map +1 -0
  147. package/dist/src/types/index.d.ts +7 -0
  148. package/dist/src/types/index.d.ts.map +1 -0
  149. package/dist/src/types/index.js +7 -0
  150. package/dist/src/types/index.js.map +1 -0
  151. package/dist/src/types/inference-log.d.ts +64 -0
  152. package/dist/src/types/inference-log.d.ts.map +1 -0
  153. package/dist/src/types/inference-log.js +5 -0
  154. package/dist/src/types/inference-log.js.map +1 -0
  155. package/dist/src/types/module.d.ts +302 -0
  156. package/dist/src/types/module.d.ts.map +1 -0
  157. package/dist/src/types/module.js +2 -0
  158. package/dist/src/types/module.js.map +1 -0
  159. package/dist/src/types/trace.d.ts +173 -0
  160. package/dist/src/types/trace.d.ts.map +1 -0
  161. package/dist/src/types/trace.js +2 -0
  162. package/dist/src/types/trace.js.map +1 -0
  163. package/dist/test/event-gate.test.d.ts +2 -0
  164. package/dist/test/event-gate.test.d.ts.map +1 -0
  165. package/dist/test/event-gate.test.js +535 -0
  166. package/dist/test/event-gate.test.js.map +1 -0
  167. package/dist/test/framework.test.d.ts +5 -0
  168. package/dist/test/framework.test.d.ts.map +1 -0
  169. package/dist/test/framework.test.js +738 -0
  170. package/dist/test/framework.test.js.map +1 -0
  171. package/dist/tsconfig.tsbuildinfo +1 -0
  172. package/package.json +58 -0
  173. package/src/agent.ts +512 -0
  174. package/src/api/mcp-server.ts +687 -0
  175. package/src/api/server.ts +1023 -0
  176. package/src/api/types.ts +542 -0
  177. package/src/framework.ts +2784 -0
  178. package/src/gate/event-gate.ts +623 -0
  179. package/src/gate/index.ts +12 -0
  180. package/src/gate/types.ts +112 -0
  181. package/src/index.ts +63 -0
  182. package/src/mcpl/channel-registry.ts +824 -0
  183. package/src/mcpl/checkpoint-manager.ts +545 -0
  184. package/src/mcpl/errors.ts +114 -0
  185. package/src/mcpl/feature-set-manager.ts +333 -0
  186. package/src/mcpl/hook-orchestrator.ts +281 -0
  187. package/src/mcpl/index.ts +134 -0
  188. package/src/mcpl/inference-router.ts +335 -0
  189. package/src/mcpl/push-handler.ts +232 -0
  190. package/src/mcpl/scope-manager.ts +262 -0
  191. package/src/mcpl/server-connection.ts +694 -0
  192. package/src/mcpl/server-registry.ts +144 -0
  193. package/src/mcpl/types.ts +991 -0
  194. package/src/module-registry.ts +547 -0
  195. package/src/modules/api/index.ts +94 -0
  196. package/src/modules/discord/discord-js-client.ts +353 -0
  197. package/src/modules/discord/index.ts +1146 -0
  198. package/src/modules/discord/types.ts +465 -0
  199. package/src/modules/index.ts +43 -0
  200. package/src/modules/workspace/index.ts +969 -0
  201. package/src/modules/workspace/sync.ts +314 -0
  202. package/src/modules/workspace/types.ts +202 -0
  203. package/src/modules/workspace/watcher.ts +142 -0
  204. package/src/queue.ts +106 -0
  205. package/src/safe-slice.ts +18 -0
  206. package/src/types/agent.ts +119 -0
  207. package/src/types/events.ts +252 -0
  208. package/src/types/framework.ts +211 -0
  209. package/src/types/index.ts +6 -0
  210. package/src/types/inference-log.ts +64 -0
  211. package/src/types/module.ts +367 -0
  212. package/src/types/trace.ts +196 -0
@@ -0,0 +1,1146 @@
1
+ /**
2
+ * Discord Module - Discord integration for agent framework
3
+ *
4
+ * Provides:
5
+ * - Listening to Discord messages and routing to agents
6
+ * - Sending agent speech to Discord channels
7
+ * - Tools for explicit Discord operations (send, DM, react, etc.)
8
+ */
9
+
10
+ import type { ContentBlock } from '@animalabs/membrane';
11
+ import type {
12
+ Module,
13
+ ModuleContext,
14
+ ProcessState,
15
+ ProcessEvent,
16
+ EventResponse,
17
+ ToolDefinition,
18
+ ToolCall,
19
+ ToolResult,
20
+ SpeechContext,
21
+ } from '../../types/index.js';
22
+ import type {
23
+ DiscordModuleConfig,
24
+ DiscordModuleState,
25
+ ConversationContext,
26
+ DiscordClientInterface,
27
+ DiscordMessageData,
28
+ DiscordMessageEvent,
29
+ DiscordEditEvent,
30
+ DiscordDeleteEvent,
31
+ SendInput,
32
+ SendDMInput,
33
+ ReplyInput,
34
+ ReactInput,
35
+ EditMessageInput,
36
+ DeleteMessageInput,
37
+ CreateThreadInput,
38
+ SetReplyContextInput,
39
+ ListGuildsInput,
40
+ ListChannelsInput,
41
+ FetchHistoryInput,
42
+ } from './types.js';
43
+
44
+ // Re-export event types for consumers who want to handle them
45
+ export type { DiscordMessageEvent, DiscordEditEvent, DiscordDeleteEvent } from './types.js';
46
+
47
+ export * from './types.js';
48
+ export { DiscordJsClient, type DiscordJsClientConfig } from './discord-js-client.js';
49
+
50
+ const DEFAULT_CONFIG: Partial<DiscordModuleConfig> = {
51
+ handleDMs: true,
52
+ ignoreBots: true,
53
+ rateLimitPerMinute: 30,
54
+ triggerOn: 'mention_or_reply',
55
+ autoTyping: true,
56
+ historyScrollback: 50,
57
+ };
58
+
59
+ // Image MIME types that Claude can process
60
+ const SUPPORTED_IMAGE_TYPES = [
61
+ 'image/jpeg',
62
+ 'image/png',
63
+ 'image/gif',
64
+ 'image/webp',
65
+ ];
66
+
67
+ // Text file extensions for inline reading
68
+ const TEXT_FILE_EXTENSIONS = [
69
+ '.txt', '.md', '.json', '.yaml', '.yml', '.toml',
70
+ '.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.go', '.rs',
71
+ '.html', '.css', '.xml', '.csv', '.log', '.sh', '.bash',
72
+ ];
73
+
74
+ const CONVERSATION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
75
+
76
+ /**
77
+ * Discord module for the agent framework.
78
+ */
79
+ export class DiscordModule implements Module {
80
+ readonly name = 'discord';
81
+
82
+ private config: DiscordModuleConfig;
83
+ private ctx: ModuleContext | null = null;
84
+ private client: DiscordClientInterface;
85
+ private state: DiscordModuleState = {
86
+ connectedGuilds: [],
87
+ conversations: {},
88
+ rateLimits: {},
89
+ lastReadMessageId: {},
90
+ };
91
+
92
+ // Track current reply context per agent
93
+ private replyContexts: Map<string, ConversationContext> = new Map();
94
+
95
+ // Last message sent by a tool (for thought-editing)
96
+ private lastSentMessage: { channelId: string; messageId: string } | null = null;
97
+
98
+ constructor(client: DiscordClientInterface, config: DiscordModuleConfig) {
99
+ this.client = client;
100
+ this.config = { ...DEFAULT_CONFIG, ...config };
101
+ }
102
+
103
+ async start(ctx: ModuleContext): Promise<void> {
104
+ this.ctx = ctx;
105
+
106
+ // Load persisted state
107
+ const savedState = ctx.getState<DiscordModuleState>();
108
+ if (savedState) {
109
+ this.state = savedState;
110
+ // Clean up old conversations
111
+ this.cleanupOldConversations();
112
+ }
113
+
114
+ // Register as speech handler
115
+ ctx.registerSpeechHandler('*');
116
+
117
+ // Set up Discord event handlers
118
+ this.client.onMessage((message) => this.handleDiscordMessage(message));
119
+ this.client.onMessageEdit((id, content) => this.handleDiscordMessageEdit(id, content));
120
+ this.client.onMessageDelete((id) => this.handleDiscordMessageDelete(id));
121
+
122
+ // Set up ready handler for history sync on (re)connect
123
+ if (this.client.onReady) {
124
+ this.client.onReady(() => this.handleReady());
125
+ }
126
+
127
+ // Connect to Discord
128
+ if (!ctx.isRestart || !this.client.isConnected()) {
129
+ await this.client.connect();
130
+ } else {
131
+ // Already connected (restart) - sync history now
132
+ await this.syncAllChannelHistory();
133
+ }
134
+
135
+ // Get bot user ID after connect
136
+ const botUserId = this.client.getBotUserId();
137
+ if (botUserId) {
138
+ this.state.botUserId = botUserId;
139
+ ctx.setState(this.state);
140
+ }
141
+ }
142
+
143
+ async stop(): Promise<void> {
144
+ // Save state
145
+ if (this.ctx) {
146
+ this.ctx.setState(this.state);
147
+ }
148
+
149
+ await this.client.disconnect();
150
+ this.ctx = null;
151
+ }
152
+
153
+ getTools(): ToolDefinition[] {
154
+ return [
155
+ {
156
+ name: 'send',
157
+ description: 'Send a message to a specific Discord channel',
158
+ inputSchema: {
159
+ type: 'object',
160
+ properties: {
161
+ channelId: { type: 'string', description: 'Channel ID to send to' },
162
+ content: { type: 'string', description: 'Message content' },
163
+ replyTo: { type: 'string', description: 'Message ID to reply to (optional)' },
164
+ createThread: {
165
+ type: 'object',
166
+ description: 'Create a thread with this message',
167
+ properties: {
168
+ name: { type: 'string', description: 'Thread name' },
169
+ },
170
+ },
171
+ },
172
+ required: ['channelId', 'content'],
173
+ },
174
+ },
175
+ {
176
+ name: 'dm',
177
+ description: 'Send a direct message to a user',
178
+ inputSchema: {
179
+ type: 'object',
180
+ properties: {
181
+ userId: { type: 'string', description: 'User ID to DM' },
182
+ content: { type: 'string', description: 'Message content' },
183
+ },
184
+ required: ['userId', 'content'],
185
+ },
186
+ },
187
+ {
188
+ name: 'reply',
189
+ description:
190
+ 'Reply in the current conversation context. Use this for normal responses.',
191
+ inputSchema: {
192
+ type: 'object',
193
+ properties: {
194
+ content: { type: 'string', description: 'Message content' },
195
+ mention: { type: 'boolean', description: 'Whether to mention the user' },
196
+ },
197
+ required: ['content'],
198
+ },
199
+ },
200
+ {
201
+ name: 'react',
202
+ description: 'Add a reaction to a message',
203
+ inputSchema: {
204
+ type: 'object',
205
+ properties: {
206
+ channelId: { type: 'string', description: 'Channel ID' },
207
+ messageId: { type: 'string', description: 'Message ID to react to' },
208
+ emoji: { type: 'string', description: 'Emoji (unicode or custom emoji ID)' },
209
+ },
210
+ required: ['channelId', 'messageId', 'emoji'],
211
+ },
212
+ },
213
+ {
214
+ name: 'edit',
215
+ description: 'Edit a message sent by this bot',
216
+ inputSchema: {
217
+ type: 'object',
218
+ properties: {
219
+ channelId: { type: 'string', description: 'Channel ID' },
220
+ messageId: { type: 'string', description: 'Message ID to edit' },
221
+ content: { type: 'string', description: 'New content' },
222
+ },
223
+ required: ['channelId', 'messageId', 'content'],
224
+ },
225
+ },
226
+ {
227
+ name: 'delete',
228
+ description: 'Delete a message sent by this bot',
229
+ inputSchema: {
230
+ type: 'object',
231
+ properties: {
232
+ channelId: { type: 'string', description: 'Channel ID' },
233
+ messageId: { type: 'string', description: 'Message ID to delete' },
234
+ },
235
+ required: ['channelId', 'messageId'],
236
+ },
237
+ },
238
+ {
239
+ name: 'create_thread',
240
+ description: 'Create a thread from a message',
241
+ inputSchema: {
242
+ type: 'object',
243
+ properties: {
244
+ channelId: { type: 'string', description: 'Channel ID' },
245
+ messageId: { type: 'string', description: 'Message ID to create thread from' },
246
+ name: { type: 'string', description: 'Thread name' },
247
+ autoArchiveDuration: {
248
+ type: 'number',
249
+ description: 'Auto-archive duration in minutes (60, 1440, 4320, 10080)',
250
+ },
251
+ },
252
+ required: ['channelId', 'messageId', 'name'],
253
+ },
254
+ },
255
+ {
256
+ name: 'set_reply_context',
257
+ description: 'Change where default speech goes for this conversation',
258
+ inputSchema: {
259
+ type: 'object',
260
+ properties: {
261
+ channelId: { type: 'string', description: 'Channel ID for replies' },
262
+ threadId: { type: 'string', description: 'Thread ID (optional)' },
263
+ replyToMessageId: { type: 'string', description: 'Message ID to reply to (optional)' },
264
+ },
265
+ required: ['channelId'],
266
+ },
267
+ },
268
+ {
269
+ name: 'activate',
270
+ description: 'Register Discord as the speech handler (take over from other modules)',
271
+ inputSchema: {
272
+ type: 'object',
273
+ properties: {
274
+ additive: {
275
+ type: 'boolean',
276
+ description: 'If true, add to existing handlers instead of replacing',
277
+ },
278
+ },
279
+ },
280
+ },
281
+ {
282
+ name: 'deactivate',
283
+ description: 'Unregister Discord as a speech handler',
284
+ inputSchema: {
285
+ type: 'object',
286
+ properties: {},
287
+ },
288
+ },
289
+ // ========== Discovery & Utility Tools ==========
290
+ {
291
+ name: 'list_guilds',
292
+ description: 'List all Discord servers (guilds) the bot is in',
293
+ inputSchema: {
294
+ type: 'object',
295
+ properties: {},
296
+ },
297
+ },
298
+ {
299
+ name: 'list_channels',
300
+ description: 'List all channels in a Discord server',
301
+ inputSchema: {
302
+ type: 'object',
303
+ properties: {
304
+ guildId: { type: 'string', description: 'Guild/Server ID' },
305
+ },
306
+ required: ['guildId'],
307
+ },
308
+ },
309
+ {
310
+ name: 'fetch_history',
311
+ description: 'Fetch recent message history from a channel',
312
+ inputSchema: {
313
+ type: 'object',
314
+ properties: {
315
+ channelId: { type: 'string', description: 'Channel ID' },
316
+ limit: { type: 'number', description: 'Number of messages to fetch (default: 50, max: 100)' },
317
+ before: { type: 'string', description: 'Fetch messages before this message ID' },
318
+ },
319
+ required: ['channelId'],
320
+ },
321
+ },
322
+ ];
323
+ }
324
+
325
+ async handleToolCall(call: ToolCall): Promise<ToolResult> {
326
+ try {
327
+ switch (call.name) {
328
+ case 'send':
329
+ return await this.handleSend(call.input as SendInput);
330
+ case 'dm':
331
+ return await this.handleDM(call.input as SendDMInput);
332
+ case 'reply':
333
+ return await this.handleReply(call.input as ReplyInput);
334
+ case 'react':
335
+ return await this.handleReact(call.input as ReactInput);
336
+ case 'edit':
337
+ return await this.handleEdit(call.input as EditMessageInput);
338
+ case 'delete':
339
+ return await this.handleDelete(call.input as DeleteMessageInput);
340
+ case 'create_thread':
341
+ return await this.handleCreateThread(call.input as CreateThreadInput);
342
+ case 'set_reply_context':
343
+ return await this.handleSetReplyContext(call.input as SetReplyContextInput);
344
+ case 'activate':
345
+ return await this.handleActivate(call.input as { additive?: boolean });
346
+ case 'deactivate':
347
+ return await this.handleDeactivate();
348
+ case 'list_guilds':
349
+ return await this.handleListGuilds();
350
+ case 'list_channels':
351
+ return await this.handleListChannels(call.input as ListChannelsInput);
352
+ case 'fetch_history':
353
+ return await this.handleFetchHistory(call.input as FetchHistoryInput);
354
+ default:
355
+ return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
356
+ }
357
+ } catch (error) {
358
+ return {
359
+ success: false,
360
+ error: error instanceof Error ? error.message : String(error),
361
+ isError: true,
362
+ };
363
+ }
364
+ }
365
+
366
+ async onProcess(event: ProcessEvent, state: ProcessState): Promise<EventResponse> {
367
+ // Handle new Discord message
368
+ if (event.type === 'discord:message') {
369
+ const msg = event as DiscordMessageEvent;
370
+ const currentState = state.getState<DiscordModuleState>() ?? {
371
+ connectedGuilds: [],
372
+ conversations: {},
373
+ rateLimits: {},
374
+ lastReadMessageId: {},
375
+ };
376
+
377
+ // Build conversation state update
378
+ const convKey = msg.guildId ? `${msg.guildId}:${msg.channelId}` : `dm:${msg.authorId}`;
379
+ const conversation: ConversationContext = {
380
+ channelId: msg.channelId,
381
+ userId: msg.authorId,
382
+ guildId: msg.guildId ?? null,
383
+ replyToMessageId: msg.discordMessageId,
384
+ lastActivity: Date.now(),
385
+ };
386
+
387
+ // Update in-memory reply context (ephemeral, not persisted)
388
+ this.replyContexts.set('default', conversation);
389
+
390
+ // Build new state with updated conversation
391
+ const newState: DiscordModuleState = {
392
+ ...currentState,
393
+ conversations: {
394
+ ...currentState.conversations,
395
+ [convKey]: conversation,
396
+ },
397
+ lastReadMessageId: {
398
+ ...currentState.lastReadMessageId,
399
+ [msg.channelId]: msg.discordMessageId,
400
+ },
401
+ };
402
+
403
+ // Update local state reference for tool handlers
404
+ this.state = newState;
405
+
406
+ // Auto-send typing indicator if configured
407
+ if (this.config.autoTyping) {
408
+ // Fire and forget - don't await
409
+ this.client.sendTyping(msg.channelId).catch((err) => {
410
+ console.warn('Discord: Failed to send typing indicator:', err);
411
+ });
412
+ }
413
+
414
+ // Build content blocks from message and attachments
415
+ const contentBlocks = msg.contentBlocks ?? [{ type: 'text' as const, text: msg.content }];
416
+
417
+ // Return declarative response - framework applies all writes atomically
418
+ return {
419
+ addMessages: [
420
+ {
421
+ participant: msg.authorName,
422
+ content: contentBlocks,
423
+ metadata: {
424
+ external: { source: 'discord', id: msg.discordMessageId },
425
+ channelId: msg.channelId,
426
+ timestamp: msg.timestamp,
427
+ },
428
+ },
429
+ ],
430
+ stateUpdate: newState,
431
+ requestInference: msg.shouldTriggerInference ?? true,
432
+ };
433
+ }
434
+
435
+ // Handle Discord message edit
436
+ if (event.type === 'discord:edit') {
437
+ const edit = event as DiscordEditEvent;
438
+ const internalId = state.findMessageByExternalId('discord', edit.discordMessageId);
439
+ if (!internalId) {
440
+ return {};
441
+ }
442
+ return {
443
+ editMessages: [
444
+ {
445
+ messageId: internalId,
446
+ content: [{ type: 'text', text: edit.newContent }],
447
+ },
448
+ ],
449
+ };
450
+ }
451
+
452
+ // Handle Discord message delete
453
+ if (event.type === 'discord:delete') {
454
+ const del = event as DiscordDeleteEvent;
455
+ const internalId = state.findMessageByExternalId('discord', del.discordMessageId);
456
+ if (!internalId) {
457
+ return {};
458
+ }
459
+ return {
460
+ removeMessages: [internalId],
461
+ };
462
+ }
463
+
464
+ return {};
465
+ }
466
+
467
+ /**
468
+ * Handle agent speech - send to Discord.
469
+ */
470
+ async onAgentSpeech(
471
+ agentName: string,
472
+ content: ContentBlock[],
473
+ context: SpeechContext
474
+ ): Promise<void> {
475
+ // If thoughts are present and we have a recently sent message, edit it
476
+ // to prepend the thoughts as spoiler text.
477
+ if (context.thoughts && context.thoughts.length > 0 && this.lastSentMessage) {
478
+ const thoughtText = this.extractText(context.thoughts);
479
+ if (thoughtText) {
480
+ try {
481
+ const { channelId, messageId } = this.lastSentMessage;
482
+ // Fetch the current message content via the client isn't available,
483
+ // but we know the tool just sent it — reconstruct by editing with
484
+ // spoiler-wrapped thoughts prepended.
485
+ // Discord API edit replaces content, so we need the original text.
486
+ // We don't have it here, so use a get-then-edit pattern via the API.
487
+ // For now, the simplest approach: the discord.js client can fetch & edit.
488
+ await this.editWithThoughts(channelId, messageId, thoughtText);
489
+ } catch (err) {
490
+ console.warn('Discord: Failed to edit thoughts into message:', err);
491
+ }
492
+ }
493
+ this.lastSentMessage = null;
494
+ }
495
+
496
+ // Get reply context for this agent
497
+ const replyCtx = this.replyContexts.get(agentName) ?? this.replyContexts.get('default');
498
+ if (!replyCtx) {
499
+ // No conversation context - nowhere to send
500
+ console.warn(`Discord: No reply context for agent ${agentName}`);
501
+ return;
502
+ }
503
+
504
+ // Extract text content
505
+ const text = this.extractText(content);
506
+ if (!text) {
507
+ return;
508
+ }
509
+
510
+ // Check rate limit
511
+ if (!this.checkRateLimit(replyCtx.channelId)) {
512
+ console.warn(`Discord: Rate limited for channel ${replyCtx.channelId}`);
513
+ return;
514
+ }
515
+
516
+ // Send to Discord
517
+ const targetChannel = replyCtx.threadId ?? replyCtx.channelId;
518
+ const result = await this.client.sendMessage(targetChannel, text, {
519
+ replyTo: replyCtx.replyToMessageId,
520
+ });
521
+
522
+ // Track for thought-editing (in case of chained speech)
523
+ this.lastSentMessage = { channelId: targetChannel, messageId: result.messageId };
524
+
525
+ // Clear reply-to after sending (don't keep replying to same message)
526
+ replyCtx.replyToMessageId = undefined;
527
+ replyCtx.lastActivity = Date.now();
528
+ }
529
+
530
+ /**
531
+ * Edit a sent message to prepend thought text as spoilers.
532
+ */
533
+ private async editWithThoughts(
534
+ channelId: string,
535
+ messageId: string,
536
+ thoughtText: string
537
+ ): Promise<void> {
538
+ // Fetch current message content
539
+ const currentContent = await this.client.fetchMessage(channelId, messageId);
540
+ // Prepend spoiler-wrapped thoughts
541
+ const spoiler = `||${thoughtText}||\n`;
542
+ await this.client.editMessage(channelId, messageId, spoiler + currentContent);
543
+ }
544
+
545
+ // ==========================================================================
546
+ // Discord Event Handlers
547
+ // ==========================================================================
548
+
549
+ /**
550
+ * Handle Discord ready event - sync history for all tracked channels.
551
+ */
552
+ private async handleReady(): Promise<void> {
553
+ console.log('[Discord] Ready - syncing history for tracked channels');
554
+
555
+ // Get bot user ID
556
+ const botUserId = this.client.getBotUserId();
557
+ if (botUserId) {
558
+ this.state.botUserId = botUserId;
559
+ }
560
+
561
+ await this.syncAllChannelHistory();
562
+ }
563
+
564
+ /**
565
+ * Sync history for all channels we have conversations in.
566
+ */
567
+ private async syncAllChannelHistory(): Promise<void> {
568
+ if (!this.ctx) return;
569
+
570
+ // Get unique channel IDs from conversations
571
+ const channelIds = new Set<string>();
572
+ for (const conv of Object.values(this.state.conversations)) {
573
+ channelIds.add(conv.channelId);
574
+ }
575
+
576
+ if (channelIds.size === 0) {
577
+ console.log('[Discord] No channels to sync');
578
+ return;
579
+ }
580
+
581
+ console.log(`[Discord] Syncing history for ${channelIds.size} channel(s)`);
582
+
583
+ const syncResults: Array<{
584
+ channelId: string;
585
+ newMessages: number;
586
+ editedMessages: number;
587
+ deletedMessages: number;
588
+ }> = [];
589
+
590
+ for (const channelId of channelIds) {
591
+ try {
592
+ const result = await this.syncChannelHistory(channelId);
593
+ if (result.newMessages > 0 || result.editedMessages > 0 || result.deletedMessages > 0) {
594
+ syncResults.push({ channelId, ...result });
595
+ }
596
+ } catch (error) {
597
+ console.error(`[Discord] Failed to sync channel ${channelId}:`, error);
598
+ }
599
+ }
600
+
601
+ // Emit sync complete event if there were any changes
602
+ if (syncResults.length > 0 && this.ctx) {
603
+ const totalNew = syncResults.reduce((sum, r) => sum + r.newMessages, 0);
604
+ const totalEdited = syncResults.reduce((sum, r) => sum + r.editedMessages, 0);
605
+ const totalDeleted = syncResults.reduce((sum, r) => sum + r.deletedMessages, 0);
606
+
607
+ console.log(`[Discord] History sync complete: ${totalNew} new, ${totalEdited} edited, ${totalDeleted} deleted`);
608
+
609
+ // Push a notification event (doesn't trigger inference, just informs)
610
+ this.ctx.pushEvent({
611
+ type: 'module-event',
612
+ source: 'discord',
613
+ eventType: 'history-sync-complete',
614
+ payload: {
615
+ channels: syncResults.length,
616
+ newMessages: totalNew,
617
+ editedMessages: totalEdited,
618
+ deletedMessages: totalDeleted,
619
+ },
620
+ });
621
+ }
622
+
623
+ this.ctx?.setState(this.state);
624
+ }
625
+
626
+ /**
627
+ * Sync history for a specific channel - compare with Context Manager and update.
628
+ * Uses queryMessages() to enumerate stored messages for proper edit/delete detection.
629
+ */
630
+ private async syncChannelHistory(channelId: string): Promise<{
631
+ newMessages: number;
632
+ editedMessages: number;
633
+ deletedMessages: number;
634
+ }> {
635
+ if (!this.ctx) {
636
+ return { newMessages: 0, editedMessages: 0, deletedMessages: 0 };
637
+ }
638
+
639
+ const scrollback = this.config.historyScrollback ?? 50;
640
+
641
+ // Fetch recent history from Discord
642
+ const discordMessages = await this.client.fetchHistory(channelId, { limit: scrollback });
643
+
644
+ // Build a map of Discord message ID -> content for comparison
645
+ const discordMessageMap = new Map<string, { content: string; authorName: string; timestamp: Date }>();
646
+ for (const msg of discordMessages) {
647
+ discordMessageMap.set(msg.id, {
648
+ content: msg.content,
649
+ authorName: msg.authorName,
650
+ timestamp: msg.timestamp,
651
+ });
652
+ }
653
+
654
+ // Query Context Manager for all Discord messages from this channel
655
+ const { messages: storedMessages } = this.ctx.queryMessages({
656
+ source: 'discord',
657
+ metadata: { channelId },
658
+ });
659
+
660
+ // Build a map of stored message external IDs -> internal data
661
+ const storedMessageMap = new Map<string, { internalId: string; content: string }>();
662
+ for (const msg of storedMessages) {
663
+ const external = msg.metadata?.external as { id?: string } | undefined;
664
+ if (external?.id) {
665
+ // Extract text content for comparison
666
+ const textContent = msg.content
667
+ .filter((block): block is { type: 'text'; text: string } => block.type === 'text')
668
+ .map((block) => block.text)
669
+ .join('\n');
670
+ storedMessageMap.set(external.id, {
671
+ internalId: msg.id,
672
+ content: textContent,
673
+ });
674
+ }
675
+ }
676
+
677
+ let newMessages = 0;
678
+ let editedMessages = 0;
679
+ let deletedMessages = 0;
680
+
681
+ // Process Discord messages - check for new/edited
682
+ for (const discordMsg of discordMessages) {
683
+ const stored = storedMessageMap.get(discordMsg.id);
684
+
685
+ if (!stored) {
686
+ // New message we haven't seen
687
+ // For bot's own messages (from previous sessions), use the agent name
688
+ // This ensures continuity of conversation history
689
+ const isBotMessage = discordMsg.authorId === this.state.botUserId;
690
+ const agentName = this.ctx.getAgents()[0]?.name;
691
+ const participantName = isBotMessage
692
+ ? (agentName ?? discordMsg.authorName)
693
+ : discordMsg.authorName;
694
+
695
+ this.ctx.addMessage(
696
+ participantName,
697
+ [{ type: 'text', text: discordMsg.content }],
698
+ {
699
+ external: { source: 'discord', id: discordMsg.id },
700
+ channelId,
701
+ timestamp: discordMsg.timestamp.getTime(),
702
+ isOwnMessage: isBotMessage, // Mark as our own for reference
703
+ }
704
+ );
705
+ newMessages++;
706
+ } else {
707
+ // We have this message - check if content changed (offline edit detection)
708
+ if (stored.content !== discordMsg.content) {
709
+ this.ctx.editMessage(stored.internalId, [
710
+ { type: 'text', text: discordMsg.content },
711
+ ]);
712
+ editedMessages++;
713
+ }
714
+ }
715
+ }
716
+
717
+ // Check for deletes - messages we have that Discord doesn't
718
+ // Only consider messages within the scrollback window (recent ones)
719
+ for (const [externalId, stored] of storedMessageMap) {
720
+ if (!discordMessageMap.has(externalId)) {
721
+ // Message exists in our store but not in Discord - it was deleted
722
+ // Note: This only catches deletes within the scrollback window
723
+ this.ctx.removeMessage(stored.internalId);
724
+ deletedMessages++;
725
+ }
726
+ }
727
+
728
+ // Update last read
729
+ if (discordMessages.length > 0) {
730
+ // Messages are typically newest first
731
+ const newestId = discordMessages[0].id;
732
+ this.state.lastReadMessageId[channelId] = newestId;
733
+ }
734
+
735
+ return { newMessages, editedMessages, deletedMessages };
736
+ }
737
+
738
+ private async handleDiscordMessage(message: DiscordMessageData): Promise<void> {
739
+ if (!this.ctx) return;
740
+
741
+ // ALWAYS ignore our own messages to prevent feedback loops
742
+ if (this.state.botUserId && message.authorId === this.state.botUserId) {
743
+ return;
744
+ }
745
+
746
+ // Optionally ignore other bots
747
+ if (this.config.ignoreBots && message.isBot) {
748
+ return;
749
+ }
750
+
751
+ // Check if we should handle this channel
752
+ if (this.config.channelIds && !this.config.channelIds.includes(message.channelId)) {
753
+ return;
754
+ }
755
+
756
+ // Check if we should handle this guild
757
+ if (
758
+ message.guildId &&
759
+ this.config.guildIds &&
760
+ !this.config.guildIds.includes(message.guildId)
761
+ ) {
762
+ return;
763
+ }
764
+
765
+ // Check if we should handle DMs
766
+ const isDM = !message.guildId;
767
+ if (isDM && !this.config.handleDMs) {
768
+ return;
769
+ }
770
+
771
+ // Determine if this message should trigger inference
772
+ const shouldTrigger = this.shouldTriggerInference(message, isDM);
773
+
774
+ // Convert message content including attachments
775
+ const contentBlocks = await this.convertMessageToContent(message);
776
+
777
+ // Only queue the event - all state writes happen in onProcess()
778
+ this.ctx.pushEvent({
779
+ type: 'discord:message',
780
+ discordMessageId: message.id,
781
+ channelId: message.channelId,
782
+ guildId: message.guildId,
783
+ authorId: message.authorId,
784
+ authorName: message.authorName,
785
+ content: message.content,
786
+ contentBlocks,
787
+ timestamp: message.timestamp.getTime(),
788
+ attachments: message.attachments,
789
+ shouldTriggerInference: shouldTrigger,
790
+ } as DiscordMessageEvent);
791
+ }
792
+
793
+ /**
794
+ * Convert Discord message to ContentBlock array.
795
+ * Includes text content and any supported attachments (images, text files).
796
+ */
797
+ private async convertMessageToContent(message: DiscordMessageData): Promise<ContentBlock[]> {
798
+ const blocks: ContentBlock[] = [];
799
+
800
+ // Add text content if present
801
+ if (message.content.trim()) {
802
+ blocks.push({ type: 'text', text: message.content });
803
+ }
804
+
805
+ // Process attachments
806
+ for (const attachment of message.attachments) {
807
+ const contentType = attachment.contentType?.toLowerCase() ?? '';
808
+ const filename = attachment.filename.toLowerCase();
809
+
810
+ // Check for supported image types
811
+ if (SUPPORTED_IMAGE_TYPES.some(type => contentType.startsWith(type))) {
812
+ blocks.push({
813
+ type: 'image',
814
+ source: {
815
+ type: 'url',
816
+ url: attachment.url,
817
+ },
818
+ } as ContentBlock);
819
+ continue;
820
+ }
821
+
822
+ // Check for text files - fetch and include content
823
+ const isTextFile = TEXT_FILE_EXTENSIONS.some(ext => filename.endsWith(ext));
824
+ if (isTextFile && attachment.size && attachment.size < 100_000) { // < 100KB
825
+ try {
826
+ const response = await fetch(attachment.url);
827
+ if (response.ok) {
828
+ const textContent = await response.text();
829
+ blocks.push({
830
+ type: 'text',
831
+ text: `\n--- File: ${attachment.filename} ---\n${textContent}\n--- End of ${attachment.filename} ---\n`,
832
+ });
833
+ }
834
+ } catch (error) {
835
+ // If fetch fails, just note the attachment
836
+ blocks.push({
837
+ type: 'text',
838
+ text: `[Attachment: ${attachment.filename} (failed to fetch)]`,
839
+ });
840
+ }
841
+ continue;
842
+ }
843
+
844
+ // For unsupported attachments, add a note
845
+ blocks.push({
846
+ type: 'text',
847
+ text: `[Attachment: ${attachment.filename} (${contentType || 'unknown type'})]`,
848
+ });
849
+ }
850
+
851
+ // Ensure we have at least empty text if nothing else
852
+ if (blocks.length === 0) {
853
+ blocks.push({ type: 'text', text: '[Empty message]' });
854
+ }
855
+
856
+ return blocks;
857
+ }
858
+
859
+ /**
860
+ * Determine if a message should trigger agent inference based on config.
861
+ */
862
+ private shouldTriggerInference(message: DiscordMessageData, isDM: boolean): boolean {
863
+ const triggerMode = this.config.triggerOn ?? 'mention_or_reply';
864
+ const botUserId = this.state.botUserId;
865
+
866
+ // Check if bot is mentioned
867
+ const isMentioned = botUserId
868
+ ? message.mentionedUserIds.includes(botUserId)
869
+ : false;
870
+
871
+ // Check if this is a reply to bot's message
872
+ const isReplyToBot = botUserId
873
+ ? message.replyToAuthorId === botUserId
874
+ : false;
875
+
876
+ switch (triggerMode) {
877
+ case 'all':
878
+ // Always trigger (use with caution!)
879
+ return true;
880
+
881
+ case 'mention':
882
+ // Only trigger on direct @mention
883
+ return isMentioned;
884
+
885
+ case 'mention_or_reply':
886
+ // Trigger on @mention OR when replying to bot
887
+ return isMentioned || isReplyToBot;
888
+
889
+ case 'dm_or_mention':
890
+ // DMs always trigger, channels require mention
891
+ return isDM || isMentioned;
892
+
893
+ default:
894
+ // Fallback: mention_or_reply behavior
895
+ return isMentioned || isReplyToBot;
896
+ }
897
+ }
898
+
899
+ private handleDiscordMessageEdit(messageId: string, newContent: string): void {
900
+ if (!this.ctx) return;
901
+
902
+ // Only queue the event - state writes happen in onProcess()
903
+ this.ctx.pushEvent({
904
+ type: 'discord:edit',
905
+ discordMessageId: messageId,
906
+ newContent,
907
+ });
908
+ }
909
+
910
+ private handleDiscordMessageDelete(messageId: string): void {
911
+ if (!this.ctx) return;
912
+
913
+ // Only queue the event - state writes happen in onProcess()
914
+ this.ctx.pushEvent({
915
+ type: 'discord:delete',
916
+ discordMessageId: messageId,
917
+ });
918
+ }
919
+
920
+ // ==========================================================================
921
+ // Tool Handlers
922
+ // ==========================================================================
923
+
924
+ private async handleSend(input: SendInput): Promise<ToolResult> {
925
+ if (!this.checkRateLimit(input.channelId)) {
926
+ return { success: false, error: 'Rate limited', isError: true };
927
+ }
928
+
929
+ const result = await this.client.sendMessage(input.channelId, input.content, {
930
+ replyTo: input.replyTo,
931
+ createThread: input.createThread,
932
+ });
933
+
934
+ // Track for thought-editing
935
+ this.lastSentMessage = { channelId: input.channelId, messageId: result.messageId };
936
+
937
+ return { success: true, data: { messageId: result.messageId } };
938
+ }
939
+
940
+ private async handleDM(input: SendDMInput): Promise<ToolResult> {
941
+ const result = await this.client.sendDM(input.userId, input.content);
942
+ return { success: true, data: { messageId: result.messageId } };
943
+ }
944
+
945
+ private async handleReply(input: ReplyInput): Promise<ToolResult> {
946
+ const replyCtx = this.replyContexts.get('default');
947
+ if (!replyCtx) {
948
+ return { success: false, error: 'No conversation context', isError: true };
949
+ }
950
+
951
+ if (!this.checkRateLimit(replyCtx.channelId)) {
952
+ return { success: false, error: 'Rate limited', isError: true };
953
+ }
954
+
955
+ let content = input.content;
956
+ if (input.mention && replyCtx.userId) {
957
+ content = `<@${replyCtx.userId}> ${content}`;
958
+ }
959
+
960
+ const targetChannel = replyCtx.threadId ?? replyCtx.channelId;
961
+ const result = await this.client.sendMessage(targetChannel, content, {
962
+ replyTo: replyCtx.replyToMessageId,
963
+ });
964
+
965
+ // Track for thought-editing
966
+ this.lastSentMessage = { channelId: targetChannel, messageId: result.messageId };
967
+
968
+ // Clear reply-to after sending
969
+ replyCtx.replyToMessageId = undefined;
970
+ replyCtx.lastActivity = Date.now();
971
+
972
+ return { success: true, data: { messageId: result.messageId } };
973
+ }
974
+
975
+ private async handleReact(input: ReactInput): Promise<ToolResult> {
976
+ await this.client.addReaction(input.channelId, input.messageId, input.emoji);
977
+ return { success: true };
978
+ }
979
+
980
+ private async handleEdit(input: EditMessageInput): Promise<ToolResult> {
981
+ await this.client.editMessage(input.channelId, input.messageId, input.content);
982
+ return { success: true };
983
+ }
984
+
985
+ private async handleDelete(input: DeleteMessageInput): Promise<ToolResult> {
986
+ await this.client.deleteMessage(input.channelId, input.messageId);
987
+ return { success: true };
988
+ }
989
+
990
+ private async handleCreateThread(input: CreateThreadInput): Promise<ToolResult> {
991
+ const result = await this.client.createThread(
992
+ input.channelId,
993
+ input.messageId,
994
+ input.name,
995
+ input.autoArchiveDuration
996
+ );
997
+
998
+ // Update reply context to use the new thread
999
+ const replyCtx = this.replyContexts.get('default');
1000
+ if (replyCtx) {
1001
+ replyCtx.threadId = result.threadId;
1002
+ }
1003
+
1004
+ return { success: true, data: { threadId: result.threadId } };
1005
+ }
1006
+
1007
+ private async handleSetReplyContext(input: SetReplyContextInput): Promise<ToolResult> {
1008
+ const replyCtx = this.replyContexts.get('default');
1009
+ if (replyCtx) {
1010
+ replyCtx.channelId = input.channelId;
1011
+ replyCtx.threadId = input.threadId;
1012
+ replyCtx.replyToMessageId = input.replyToMessageId;
1013
+ } else {
1014
+ this.replyContexts.set('default', {
1015
+ channelId: input.channelId,
1016
+ threadId: input.threadId,
1017
+ replyToMessageId: input.replyToMessageId,
1018
+ userId: '',
1019
+ guildId: null,
1020
+ lastActivity: Date.now(),
1021
+ });
1022
+ }
1023
+
1024
+ return { success: true };
1025
+ }
1026
+
1027
+ private async handleActivate(input: { additive?: boolean }): Promise<ToolResult> {
1028
+ if (!this.ctx) {
1029
+ return { success: false, error: 'Module not started', isError: true };
1030
+ }
1031
+
1032
+ this.ctx.registerSpeechHandler('*', { additive: input.additive });
1033
+ return { success: true, data: { message: 'Discord is now handling speech' } };
1034
+ }
1035
+
1036
+ private async handleDeactivate(): Promise<ToolResult> {
1037
+ if (!this.ctx) {
1038
+ return { success: false, error: 'Module not started', isError: true };
1039
+ }
1040
+
1041
+ this.ctx.unregisterSpeechHandler();
1042
+ return { success: true, data: { message: 'Discord is no longer handling speech' } };
1043
+ }
1044
+
1045
+ private async handleListGuilds(): Promise<ToolResult> {
1046
+ const guilds = await this.client.listGuilds();
1047
+ return {
1048
+ success: true,
1049
+ data: {
1050
+ guilds: guilds.map((g) => ({
1051
+ id: g.id,
1052
+ name: g.name,
1053
+ memberCount: g.memberCount,
1054
+ })),
1055
+ },
1056
+ };
1057
+ }
1058
+
1059
+ private async handleListChannels(input: ListChannelsInput): Promise<ToolResult> {
1060
+ const channels = await this.client.listChannels(input.guildId);
1061
+ return {
1062
+ success: true,
1063
+ data: {
1064
+ guildId: input.guildId,
1065
+ channels: channels.map((c) => ({
1066
+ id: c.id,
1067
+ name: c.name,
1068
+ type: c.type,
1069
+ parentId: c.parentId,
1070
+ })),
1071
+ },
1072
+ };
1073
+ }
1074
+
1075
+ private async handleFetchHistory(input: FetchHistoryInput): Promise<ToolResult> {
1076
+ const limit = Math.min(input.limit ?? 50, 100); // Cap at 100
1077
+ const messages = await this.client.fetchHistory(input.channelId, {
1078
+ limit,
1079
+ before: input.before,
1080
+ });
1081
+
1082
+ // Update last read if we got messages
1083
+ if (messages.length > 0) {
1084
+ const latestId = messages[0].id; // Assuming sorted newest first
1085
+ this.state.lastReadMessageId[input.channelId] = latestId;
1086
+ this.ctx?.setState(this.state);
1087
+ }
1088
+
1089
+ return {
1090
+ success: true,
1091
+ data: {
1092
+ channelId: input.channelId,
1093
+ messageCount: messages.length,
1094
+ messages: messages.map((m) => ({
1095
+ id: m.id,
1096
+ author: m.authorName,
1097
+ authorId: m.authorId,
1098
+ isBot: m.isBot,
1099
+ content: m.content,
1100
+ timestamp: m.timestamp.toISOString(),
1101
+ replyTo: m.replyTo,
1102
+ })),
1103
+ },
1104
+ };
1105
+ }
1106
+
1107
+ // ==========================================================================
1108
+ // Helpers
1109
+ // ==========================================================================
1110
+
1111
+ private extractText(content: ContentBlock[]): string {
1112
+ return content
1113
+ .filter((block): block is { type: 'text'; text: string } => block.type === 'text')
1114
+ .map((block) => block.text)
1115
+ .join('\n');
1116
+ }
1117
+
1118
+ private checkRateLimit(channelId: string): boolean {
1119
+ const limit = this.config.rateLimitPerMinute ?? 30;
1120
+ const now = Date.now();
1121
+ const windowMs = 60 * 1000;
1122
+
1123
+ let info = this.state.rateLimits[channelId];
1124
+ if (!info || now - info.windowStart > windowMs) {
1125
+ // New window
1126
+ info = { count: 0, windowStart: now };
1127
+ this.state.rateLimits[channelId] = info;
1128
+ }
1129
+
1130
+ if (info.count >= limit) {
1131
+ return false;
1132
+ }
1133
+
1134
+ info.count++;
1135
+ return true;
1136
+ }
1137
+
1138
+ private cleanupOldConversations(): void {
1139
+ const now = Date.now();
1140
+ for (const [key, conv] of Object.entries(this.state.conversations)) {
1141
+ if (now - conv.lastActivity > CONVERSATION_TIMEOUT_MS) {
1142
+ delete this.state.conversations[key];
1143
+ }
1144
+ }
1145
+ }
1146
+ }