@herdctl/discord 0.2.2 → 1.0.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 (83) hide show
  1. package/dist/__tests__/auto-mode-handler.test.js +1 -28
  2. package/dist/__tests__/auto-mode-handler.test.js.map +1 -1
  3. package/dist/__tests__/discord-connector.test.js +1 -0
  4. package/dist/__tests__/discord-connector.test.js.map +1 -1
  5. package/dist/__tests__/error-handler.test.js +1 -1
  6. package/dist/__tests__/error-handler.test.js.map +1 -1
  7. package/dist/__tests__/manager.test.d.ts +8 -0
  8. package/dist/__tests__/manager.test.d.ts.map +1 -0
  9. package/dist/__tests__/manager.test.js +3541 -0
  10. package/dist/__tests__/manager.test.js.map +1 -0
  11. package/dist/auto-mode-handler.d.ts +8 -39
  12. package/dist/auto-mode-handler.d.ts.map +1 -1
  13. package/dist/auto-mode-handler.js +12 -78
  14. package/dist/auto-mode-handler.js.map +1 -1
  15. package/dist/commands/__tests__/command-manager.test.js +1 -0
  16. package/dist/commands/__tests__/command-manager.test.js.map +1 -1
  17. package/dist/commands/__tests__/help.test.js +1 -0
  18. package/dist/commands/__tests__/help.test.js.map +1 -1
  19. package/dist/commands/__tests__/reset.test.js +1 -0
  20. package/dist/commands/__tests__/reset.test.js.map +1 -1
  21. package/dist/commands/__tests__/status.test.js +1 -0
  22. package/dist/commands/__tests__/status.test.js.map +1 -1
  23. package/dist/commands/command-manager.d.ts.map +1 -1
  24. package/dist/commands/command-manager.js.map +1 -1
  25. package/dist/commands/status.d.ts.map +1 -1
  26. package/dist/commands/status.js +1 -54
  27. package/dist/commands/status.js.map +1 -1
  28. package/dist/commands/types.d.ts +3 -3
  29. package/dist/commands/types.d.ts.map +1 -1
  30. package/dist/discord-connector.d.ts +2 -2
  31. package/dist/discord-connector.d.ts.map +1 -1
  32. package/dist/discord-connector.js.map +1 -1
  33. package/dist/error-handler.js +1 -1
  34. package/dist/error-handler.js.map +1 -1
  35. package/dist/index.d.ts +13 -13
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +15 -15
  38. package/dist/index.js.map +1 -1
  39. package/dist/manager.d.ts +195 -0
  40. package/dist/manager.d.ts.map +1 -0
  41. package/dist/manager.js +851 -0
  42. package/dist/manager.js.map +1 -0
  43. package/dist/types.d.ts +2 -2
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/utils/__tests__/formatting.test.js +1 -247
  46. package/dist/utils/__tests__/formatting.test.js.map +1 -1
  47. package/dist/utils/formatting.d.ts +11 -99
  48. package/dist/utils/formatting.d.ts.map +1 -1
  49. package/dist/utils/formatting.js +15 -163
  50. package/dist/utils/formatting.js.map +1 -1
  51. package/dist/utils/index.d.ts +1 -1
  52. package/dist/utils/index.d.ts.map +1 -1
  53. package/dist/utils/index.js +2 -2
  54. package/dist/utils/index.js.map +1 -1
  55. package/package.json +3 -4
  56. package/dist/session-manager/__tests__/errors.test.d.ts +0 -2
  57. package/dist/session-manager/__tests__/errors.test.d.ts.map +0 -1
  58. package/dist/session-manager/__tests__/errors.test.js +0 -124
  59. package/dist/session-manager/__tests__/errors.test.js.map +0 -1
  60. package/dist/session-manager/__tests__/session-manager.test.d.ts +0 -2
  61. package/dist/session-manager/__tests__/session-manager.test.d.ts.map +0 -1
  62. package/dist/session-manager/__tests__/session-manager.test.js +0 -573
  63. package/dist/session-manager/__tests__/session-manager.test.js.map +0 -1
  64. package/dist/session-manager/__tests__/types.test.d.ts +0 -2
  65. package/dist/session-manager/__tests__/types.test.d.ts.map +0 -1
  66. package/dist/session-manager/__tests__/types.test.js +0 -169
  67. package/dist/session-manager/__tests__/types.test.js.map +0 -1
  68. package/dist/session-manager/errors.d.ts +0 -58
  69. package/dist/session-manager/errors.d.ts.map +0 -1
  70. package/dist/session-manager/errors.js +0 -70
  71. package/dist/session-manager/errors.js.map +0 -1
  72. package/dist/session-manager/index.d.ts +0 -11
  73. package/dist/session-manager/index.d.ts.map +0 -1
  74. package/dist/session-manager/index.js +0 -12
  75. package/dist/session-manager/index.js.map +0 -1
  76. package/dist/session-manager/session-manager.d.ts +0 -119
  77. package/dist/session-manager/session-manager.d.ts.map +0 -1
  78. package/dist/session-manager/session-manager.js +0 -383
  79. package/dist/session-manager/session-manager.js.map +0 -1
  80. package/dist/session-manager/types.d.ts +0 -186
  81. package/dist/session-manager/types.d.ts.map +0 -1
  82. package/dist/session-manager/types.js +0 -57
  83. package/dist/session-manager/types.js.map +0 -1
@@ -0,0 +1,851 @@
1
+ /**
2
+ * Discord Manager Module
3
+ *
4
+ * Manages Discord connectors for agents that have `chat.discord` configured.
5
+ * This module is responsible for:
6
+ * - Creating one DiscordConnector instance per Discord-enabled agent
7
+ * - Managing connector lifecycle (start/stop)
8
+ * - Providing access to connectors for status queries
9
+ *
10
+ * @module manager
11
+ */
12
+ import { StreamingResponder, extractMessageContent, splitMessage, ChatSessionManager, } from "@herdctl/chat";
13
+ import { DiscordConnector } from "./discord-connector.js";
14
+ /**
15
+ * DiscordManager handles Discord connections for agents
16
+ *
17
+ * This class encapsulates the creation and lifecycle management of
18
+ * DiscordConnector instances for agents that have Discord chat configured.
19
+ *
20
+ * Implements IChatManager so FleetManager can interact with it through
21
+ * the generic chat manager interface.
22
+ */
23
+ export class DiscordManager {
24
+ ctx;
25
+ connectors = new Map();
26
+ initialized = false;
27
+ constructor(ctx) {
28
+ this.ctx = ctx;
29
+ }
30
+ /**
31
+ * Initialize Discord connectors for all configured agents
32
+ *
33
+ * This method:
34
+ * 1. Iterates through agents to find those with Discord configured
35
+ * 2. Creates a DiscordConnector for each Discord-enabled agent
36
+ *
37
+ * Should be called during FleetManager initialization.
38
+ */
39
+ async initialize() {
40
+ if (this.initialized) {
41
+ return;
42
+ }
43
+ const logger = this.ctx.getLogger();
44
+ const config = this.ctx.getConfig();
45
+ if (!config) {
46
+ logger.debug("No config available, skipping Discord initialization");
47
+ return;
48
+ }
49
+ const stateDir = this.ctx.getStateDir();
50
+ // Find agents with Discord configured
51
+ const discordAgents = config.agents.filter((agent) => agent.chat?.discord !== undefined);
52
+ if (discordAgents.length === 0) {
53
+ logger.debug("No agents with Discord configured");
54
+ this.initialized = true;
55
+ return;
56
+ }
57
+ logger.debug(`Initializing Discord connectors for ${discordAgents.length} agent(s)`);
58
+ for (const agent of discordAgents) {
59
+ try {
60
+ const discordConfig = agent.chat.discord;
61
+ if (!discordConfig)
62
+ continue;
63
+ // Get bot token from environment variable
64
+ const botToken = process.env[discordConfig.bot_token_env];
65
+ if (!botToken) {
66
+ logger.warn(`Discord bot token not found in environment variable '${discordConfig.bot_token_env}' for agent '${agent.name}'`);
67
+ continue;
68
+ }
69
+ // Create logger adapter for this agent
70
+ const createAgentLogger = (prefix) => ({
71
+ debug: (msg, data) => logger.debug(`${prefix} ${msg}${data ? ` ${JSON.stringify(data)}` : ""}`),
72
+ info: (msg, data) => logger.info(`${prefix} ${msg}${data ? ` ${JSON.stringify(data)}` : ""}`),
73
+ warn: (msg, data) => logger.warn(`${prefix} ${msg}${data ? ` ${JSON.stringify(data)}` : ""}`),
74
+ error: (msg, data) => logger.error(`${prefix} ${msg}${data ? ` ${JSON.stringify(data)}` : ""}`),
75
+ });
76
+ // Create session manager for this agent
77
+ const sessionManager = new ChatSessionManager({
78
+ platform: "discord",
79
+ agentName: agent.name,
80
+ stateDir,
81
+ sessionExpiryHours: discordConfig.session_expiry_hours,
82
+ logger: createAgentLogger(`[discord:${agent.name}:session]`),
83
+ });
84
+ // Create the connector
85
+ // Pass FleetManager (via ctx.getEmitter() which returns FleetManager instance)
86
+ const connector = new DiscordConnector({
87
+ agentConfig: agent,
88
+ discordConfig,
89
+ botToken,
90
+ // The context's getEmitter() returns the FleetManager instance
91
+ fleetManager: this.ctx.getEmitter(),
92
+ sessionManager,
93
+ stateDir,
94
+ logger: createAgentLogger(`[discord:${agent.name}]`),
95
+ });
96
+ this.connectors.set(agent.name, connector);
97
+ logger.debug(`Created Discord connector for agent '${agent.name}'`);
98
+ }
99
+ catch (error) {
100
+ const errorMessage = error instanceof Error ? error.message : String(error);
101
+ logger.error(`Failed to create Discord connector for agent '${agent.name}': ${errorMessage}`);
102
+ // Continue with other agents - don't fail the whole initialization
103
+ }
104
+ }
105
+ this.initialized = true;
106
+ logger.debug(`Discord manager initialized with ${this.connectors.size} connector(s)`);
107
+ }
108
+ /**
109
+ * Connect all Discord connectors
110
+ *
111
+ * Connects each connector to the Discord gateway and subscribes to events.
112
+ * Errors are logged but don't stop other connectors from connecting.
113
+ */
114
+ async start() {
115
+ const logger = this.ctx.getLogger();
116
+ if (this.connectors.size === 0) {
117
+ logger.debug("No Discord connectors to start");
118
+ return;
119
+ }
120
+ logger.debug(`Starting ${this.connectors.size} Discord connector(s)...`);
121
+ const connectPromises = [];
122
+ for (const [agentName, connector] of this.connectors) {
123
+ // Subscribe to connector events before connecting
124
+ connector.on("message", (event) => {
125
+ this.handleMessage(agentName, event).catch((error) => {
126
+ this.handleError(agentName, error);
127
+ });
128
+ });
129
+ connector.on("error", (event) => {
130
+ this.handleError(agentName, event.error);
131
+ });
132
+ connectPromises.push(connector.connect().catch((error) => {
133
+ const errorMessage = error instanceof Error ? error.message : String(error);
134
+ logger.error(`Failed to connect Discord for agent '${agentName}': ${errorMessage}`);
135
+ // Don't re-throw - we want to continue connecting other agents
136
+ }));
137
+ }
138
+ await Promise.all(connectPromises);
139
+ const connectedCount = Array.from(this.connectors.values()).filter((c) => c.isConnected()).length;
140
+ logger.info(`Discord connectors started: ${connectedCount}/${this.connectors.size} connected`);
141
+ }
142
+ /**
143
+ * Disconnect all Discord connectors gracefully
144
+ *
145
+ * Sessions are automatically persisted to disk on every update,
146
+ * so they survive bot restarts. This method logs session state
147
+ * before disconnecting for monitoring purposes.
148
+ *
149
+ * Errors are logged but don't prevent other connectors from disconnecting.
150
+ */
151
+ async stop() {
152
+ const logger = this.ctx.getLogger();
153
+ if (this.connectors.size === 0) {
154
+ logger.debug("No Discord connectors to stop");
155
+ return;
156
+ }
157
+ logger.debug(`Stopping ${this.connectors.size} Discord connector(s)...`);
158
+ // Log session state before shutdown (sessions are already persisted to disk)
159
+ for (const [agentName, connector] of this.connectors) {
160
+ try {
161
+ const activeSessionCount = await connector.sessionManager.getActiveSessionCount();
162
+ if (activeSessionCount > 0) {
163
+ logger.debug(`Preserving ${activeSessionCount} active session(s) for agent '${agentName}'`);
164
+ }
165
+ }
166
+ catch (error) {
167
+ const errorMessage = error instanceof Error ? error.message : String(error);
168
+ logger.warn(`Failed to get session count for agent '${agentName}': ${errorMessage}`);
169
+ // Continue with shutdown - this is just informational logging
170
+ }
171
+ }
172
+ const disconnectPromises = [];
173
+ for (const [agentName, connector] of this.connectors) {
174
+ disconnectPromises.push(connector.disconnect().catch((error) => {
175
+ const errorMessage = error instanceof Error ? error.message : String(error);
176
+ logger.error(`Error disconnecting Discord for agent '${agentName}': ${errorMessage}`);
177
+ // Don't re-throw - graceful shutdown should continue
178
+ }));
179
+ }
180
+ await Promise.all(disconnectPromises);
181
+ logger.debug("All Discord connectors stopped");
182
+ }
183
+ /**
184
+ * Get a connector for a specific agent
185
+ *
186
+ * @param agentName - Name of the agent
187
+ * @returns The DiscordConnector instance, or undefined if not found
188
+ */
189
+ getConnector(agentName) {
190
+ return this.connectors.get(agentName);
191
+ }
192
+ /**
193
+ * Get all connector names
194
+ *
195
+ * @returns Array of agent names that have Discord connectors
196
+ */
197
+ getConnectorNames() {
198
+ return Array.from(this.connectors.keys());
199
+ }
200
+ /**
201
+ * Get the number of active connectors
202
+ *
203
+ * @returns Number of connectors that are currently connected
204
+ */
205
+ getConnectedCount() {
206
+ return Array.from(this.connectors.values()).filter((c) => c.isConnected()).length;
207
+ }
208
+ /**
209
+ * Check if the manager has been initialized
210
+ */
211
+ isInitialized() {
212
+ return this.initialized;
213
+ }
214
+ /**
215
+ * Check if a specific agent has a Discord connector
216
+ *
217
+ * @param agentName - Name of the agent
218
+ * @returns true if the agent has a Discord connector
219
+ */
220
+ hasConnector(agentName) {
221
+ return this.connectors.has(agentName);
222
+ }
223
+ /**
224
+ * Check if a specific agent has a connector (alias for hasConnector)
225
+ *
226
+ * @param agentName - Name of the agent
227
+ * @returns true if the agent has a connector
228
+ */
229
+ hasAgent(agentName) {
230
+ return this.connectors.has(agentName);
231
+ }
232
+ /**
233
+ * Get the state of a connector for a specific agent
234
+ *
235
+ * @param agentName - Name of the agent
236
+ * @returns The connector state, or undefined if not found
237
+ */
238
+ getState(agentName) {
239
+ const connector = this.connectors.get(agentName);
240
+ if (!connector)
241
+ return undefined;
242
+ const state = connector.getState();
243
+ return {
244
+ status: state.status,
245
+ connectedAt: state.connectedAt,
246
+ disconnectedAt: state.disconnectedAt,
247
+ reconnectAttempts: state.reconnectAttempts,
248
+ lastError: state.lastError,
249
+ botUser: state.botUser ? { id: state.botUser.id, username: state.botUser.username } : null,
250
+ messageStats: state.messageStats,
251
+ };
252
+ }
253
+ // ===========================================================================
254
+ // Message Handling Pipeline
255
+ // ===========================================================================
256
+ /**
257
+ * Handle an incoming Discord message
258
+ *
259
+ * This method:
260
+ * 1. Gets or creates a session for the channel
261
+ * 2. Builds job context from the message
262
+ * 3. Executes the job via trigger
263
+ * 4. Sends the response back to Discord
264
+ *
265
+ * @param agentName - Name of the agent handling the message
266
+ * @param event - The Discord message event
267
+ */
268
+ async handleMessage(agentName, event) {
269
+ const logger = this.ctx.getLogger();
270
+ const emitter = this.ctx.getEmitter();
271
+ logger.info(`Discord message for agent '${agentName}': ${event.prompt.substring(0, 50)}...`);
272
+ // Get the agent configuration
273
+ const config = this.ctx.getConfig();
274
+ const agent = config?.agents.find((a) => a.name === agentName);
275
+ if (!agent) {
276
+ logger.error(`Agent '${agentName}' not found in configuration`);
277
+ try {
278
+ await event.reply("Sorry, I'm not properly configured. Please contact an administrator.");
279
+ }
280
+ catch (replyError) {
281
+ logger.error(`Failed to send error reply: ${replyError.message}`);
282
+ }
283
+ return;
284
+ }
285
+ // Get output configuration (with defaults)
286
+ const outputConfig = agent.chat?.discord?.output ?? {
287
+ tool_results: true,
288
+ tool_result_max_length: 900,
289
+ system_status: true,
290
+ result_summary: false,
291
+ errors: true,
292
+ };
293
+ // Get existing session for this channel (for conversation continuity)
294
+ const connector = this.connectors.get(agentName);
295
+ let existingSessionId;
296
+ if (connector) {
297
+ try {
298
+ const existingSession = await connector.sessionManager.getSession(event.metadata.channelId);
299
+ if (existingSession) {
300
+ existingSessionId = existingSession.sessionId;
301
+ logger.debug(`Resuming session for channel ${event.metadata.channelId}: ${existingSessionId}`);
302
+ }
303
+ else {
304
+ logger.debug(`No existing session for channel ${event.metadata.channelId}, starting new conversation`);
305
+ }
306
+ }
307
+ catch (error) {
308
+ const errorMessage = error instanceof Error ? error.message : String(error);
309
+ logger.warn(`Failed to get session: ${errorMessage}`);
310
+ // Continue processing - session failure shouldn't block message handling
311
+ }
312
+ }
313
+ // Create streaming responder for incremental message delivery
314
+ const streamer = new StreamingResponder({
315
+ reply: (content) => event.reply(content),
316
+ logger: logger,
317
+ agentName,
318
+ maxMessageLength: 2000, // Discord's limit
319
+ maxBufferSize: 1500,
320
+ platformName: "Discord",
321
+ });
322
+ // Start typing indicator while processing
323
+ const stopTyping = event.startTyping();
324
+ // Track if we've stopped typing to avoid multiple calls
325
+ let typingStopped = false;
326
+ try {
327
+ // Track pending tool_use blocks so we can pair them with results
328
+ const pendingToolUses = new Map();
329
+ let embedsSent = 0;
330
+ // Execute job via FleetManager.trigger() through the context
331
+ // Pass resume option for conversation continuity
332
+ // The onMessage callback streams output incrementally to Discord
333
+ const result = await this.ctx.trigger(agentName, undefined, {
334
+ prompt: event.prompt,
335
+ resume: existingSessionId,
336
+ onMessage: async (message) => {
337
+ // Extract text content from assistant messages and stream to Discord
338
+ if (message.type === "assistant") {
339
+ // Cast to the SDKMessage shape expected by extractMessageContent
340
+ // The chat package's SDKMessage type expects a specific structure
341
+ const sdkMessage = message;
342
+ const content = extractMessageContent(sdkMessage);
343
+ if (content) {
344
+ // Each assistant message is a complete turn - send immediately
345
+ await streamer.addMessageAndSend(content);
346
+ }
347
+ // Track tool_use blocks for pairing with results later
348
+ const toolUseBlocks = this.extractToolUseBlocks(sdkMessage);
349
+ for (const block of toolUseBlocks) {
350
+ if (block.id) {
351
+ pendingToolUses.set(block.id, {
352
+ name: block.name,
353
+ input: block.input,
354
+ startTime: Date.now(),
355
+ });
356
+ }
357
+ }
358
+ }
359
+ // Build and send embeds for tool results
360
+ if (message.type === "user" && outputConfig.tool_results) {
361
+ // Cast to the shape expected by extractToolResults
362
+ const userMessage = message;
363
+ const toolResults = this.extractToolResults(userMessage);
364
+ for (const toolResult of toolResults) {
365
+ // Look up the matching tool_use for name, input, and timing
366
+ const toolUse = toolResult.toolUseId
367
+ ? pendingToolUses.get(toolResult.toolUseId)
368
+ : undefined;
369
+ if (toolResult.toolUseId) {
370
+ pendingToolUses.delete(toolResult.toolUseId);
371
+ }
372
+ const embed = this.buildToolEmbed(toolUse ?? null, toolResult, outputConfig.tool_result_max_length);
373
+ // Flush any buffered text before sending embed to preserve ordering
374
+ await streamer.flush();
375
+ await event.reply({ embeds: [embed] });
376
+ embedsSent++;
377
+ }
378
+ }
379
+ // Show system status messages (e.g., "compacting context...")
380
+ if (message.type === "system" && outputConfig.system_status) {
381
+ const sysMessage = message;
382
+ if (sysMessage.subtype === "status" && sysMessage.status) {
383
+ const statusText = sysMessage.status === "compacting"
384
+ ? "Compacting context..."
385
+ : `Status: ${sysMessage.status}`;
386
+ await streamer.flush();
387
+ await event.reply({
388
+ embeds: [{
389
+ title: "\u2699\uFE0F System",
390
+ description: statusText,
391
+ color: DiscordManager.EMBED_COLOR_SYSTEM,
392
+ }],
393
+ });
394
+ embedsSent++;
395
+ }
396
+ }
397
+ // Show result summary embed (cost, tokens, turns)
398
+ if (message.type === "result" && outputConfig.result_summary) {
399
+ const resultMessage = message;
400
+ const fields = [];
401
+ if (resultMessage.duration_ms !== undefined) {
402
+ fields.push({
403
+ name: "Duration",
404
+ value: DiscordManager.formatDuration(resultMessage.duration_ms),
405
+ inline: true,
406
+ });
407
+ }
408
+ if (resultMessage.num_turns !== undefined) {
409
+ fields.push({
410
+ name: "Turns",
411
+ value: String(resultMessage.num_turns),
412
+ inline: true,
413
+ });
414
+ }
415
+ if (resultMessage.total_cost_usd !== undefined) {
416
+ fields.push({
417
+ name: "Cost",
418
+ value: `$${resultMessage.total_cost_usd.toFixed(4)}`,
419
+ inline: true,
420
+ });
421
+ }
422
+ if (resultMessage.usage) {
423
+ const inputTokens = resultMessage.usage.input_tokens ?? 0;
424
+ const outputTokens = resultMessage.usage.output_tokens ?? 0;
425
+ fields.push({
426
+ name: "Tokens",
427
+ value: `${inputTokens.toLocaleString()} in / ${outputTokens.toLocaleString()} out`,
428
+ inline: true,
429
+ });
430
+ }
431
+ const isError = resultMessage.is_error === true;
432
+ await streamer.flush();
433
+ await event.reply({
434
+ embeds: [{
435
+ title: isError ? "\u274C Task Failed" : "\u2705 Task Complete",
436
+ color: isError ? DiscordManager.EMBED_COLOR_ERROR : DiscordManager.EMBED_COLOR_SUCCESS,
437
+ fields,
438
+ }],
439
+ });
440
+ embedsSent++;
441
+ }
442
+ // Show SDK error messages
443
+ if (message.type === "error" && outputConfig.errors) {
444
+ const errorText = typeof message.content === "string"
445
+ ? message.content
446
+ : "An unknown error occurred";
447
+ await streamer.flush();
448
+ await event.reply({
449
+ embeds: [{
450
+ title: "\u274C Error",
451
+ description: errorText.length > 4000 ? errorText.substring(0, 4000) + "..." : errorText,
452
+ color: DiscordManager.EMBED_COLOR_ERROR,
453
+ }],
454
+ });
455
+ embedsSent++;
456
+ }
457
+ },
458
+ });
459
+ // Stop typing indicator immediately after SDK execution completes
460
+ // This prevents the interval from firing during flush/session storage
461
+ if (!typingStopped) {
462
+ stopTyping();
463
+ typingStopped = true;
464
+ }
465
+ // Flush any remaining buffered content
466
+ await streamer.flush();
467
+ logger.debug(`Discord job completed: ${result.jobId} for agent '${agentName}'${result.sessionId ? ` (session: ${result.sessionId})` : ""}`);
468
+ // If no messages were sent (text or embeds), send an appropriate fallback
469
+ if (!streamer.hasSentMessages() && embedsSent === 0) {
470
+ if (result.success) {
471
+ await event.reply("I've completed the task, but I don't have a specific response to share.");
472
+ }
473
+ else {
474
+ // Job failed without streaming any messages - send error details
475
+ const errorMessage = result.errorDetails?.message ?? result.error?.message ?? "An unknown error occurred";
476
+ await event.reply(`\u274C **Error:** ${errorMessage}\n\nThe task could not be completed. Please check the logs for more details.`);
477
+ }
478
+ // Stop typing after sending fallback message (if not already stopped)
479
+ if (!typingStopped) {
480
+ stopTyping();
481
+ typingStopped = true;
482
+ }
483
+ }
484
+ // Store the SDK session ID for future conversation continuity
485
+ // Only store if the job succeeded - failed jobs may return invalid session IDs
486
+ if (connector && result.sessionId && result.success) {
487
+ try {
488
+ await connector.sessionManager.setSession(event.metadata.channelId, result.sessionId);
489
+ logger.debug(`Stored session ${result.sessionId} for channel ${event.metadata.channelId}`);
490
+ }
491
+ catch (sessionError) {
492
+ const errorMessage = sessionError instanceof Error ? sessionError.message : String(sessionError);
493
+ logger.warn(`Failed to store session: ${errorMessage}`);
494
+ // Don't fail the message handling for session storage failure
495
+ }
496
+ }
497
+ else if (connector && result.sessionId && !result.success) {
498
+ logger.debug(`Not storing session ${result.sessionId} for channel ${event.metadata.channelId} - job failed`);
499
+ }
500
+ // Emit event for tracking
501
+ emitter.emit("discord:message:handled", {
502
+ agentName,
503
+ channelId: event.metadata.channelId,
504
+ messageId: event.metadata.messageId,
505
+ jobId: result.jobId,
506
+ timestamp: new Date().toISOString(),
507
+ });
508
+ }
509
+ catch (error) {
510
+ const err = error instanceof Error ? error : new Error(String(error));
511
+ logger.error(`Discord message handling failed for agent '${agentName}': ${err.message}`);
512
+ // Send user-friendly error message using the formatted error method
513
+ try {
514
+ await event.reply(this.formatErrorMessage(err));
515
+ }
516
+ catch (replyError) {
517
+ logger.error(`Failed to send error reply: ${replyError.message}`);
518
+ }
519
+ // Emit error event for tracking
520
+ emitter.emit("discord:message:error", {
521
+ agentName,
522
+ channelId: event.metadata.channelId,
523
+ messageId: event.metadata.messageId,
524
+ error: err.message,
525
+ timestamp: new Date().toISOString(),
526
+ });
527
+ }
528
+ finally {
529
+ // Safety net: stop typing indicator if not already stopped
530
+ // (Should already be stopped after sending messages, but this ensures cleanup on errors)
531
+ if (!typingStopped) {
532
+ stopTyping();
533
+ }
534
+ }
535
+ }
536
+ // =============================================================================
537
+ // Tool Embed Support
538
+ // =============================================================================
539
+ /** Maximum characters for tool output in Discord embed fields */
540
+ static TOOL_OUTPUT_MAX_CHARS = 900;
541
+ /** Embed colors */
542
+ static EMBED_COLOR_DEFAULT = 0x5865f2; // Discord blurple
543
+ static EMBED_COLOR_ERROR = 0xef4444; // Red
544
+ static EMBED_COLOR_SYSTEM = 0x95a5a6; // Gray
545
+ static EMBED_COLOR_SUCCESS = 0x57f287; // Green
546
+ /** Tool title emojis */
547
+ static TOOL_EMOJIS = {
548
+ Bash: "\u{1F4BB}", // laptop
549
+ bash: "\u{1F4BB}",
550
+ Read: "\u{1F4C4}", // page
551
+ Write: "\u{270F}\u{FE0F}", // pencil
552
+ Edit: "\u{270F}\u{FE0F}",
553
+ Glob: "\u{1F50D}", // magnifying glass
554
+ Grep: "\u{1F50D}",
555
+ WebFetch: "\u{1F310}", // globe
556
+ WebSearch: "\u{1F310}",
557
+ };
558
+ /**
559
+ * Extract tool_use blocks from an assistant message's content blocks
560
+ *
561
+ * Returns id, name, and input for each tool_use block so we can
562
+ * track pending calls and pair them with results.
563
+ */
564
+ extractToolUseBlocks(message) {
565
+ const apiMessage = message.message;
566
+ const content = apiMessage?.content;
567
+ if (!Array.isArray(content))
568
+ return [];
569
+ const blocks = [];
570
+ for (const block of content) {
571
+ if (block &&
572
+ typeof block === "object" &&
573
+ "type" in block &&
574
+ block.type === "tool_use" &&
575
+ "name" in block &&
576
+ typeof block.name === "string") {
577
+ blocks.push({
578
+ id: "id" in block && typeof block.id === "string" ? block.id : undefined,
579
+ name: block.name,
580
+ input: "input" in block ? block.input : undefined,
581
+ });
582
+ }
583
+ }
584
+ return blocks;
585
+ }
586
+ /**
587
+ * Get a human-readable summary of tool input
588
+ */
589
+ getToolInputSummary(name, input) {
590
+ const inputObj = input;
591
+ if (name === "Bash" || name === "bash") {
592
+ const command = inputObj?.command;
593
+ if (typeof command === "string" && command.length > 0) {
594
+ return command.length > 200 ? command.substring(0, 200) + "..." : command;
595
+ }
596
+ }
597
+ if (name === "Read" || name === "Write" || name === "Edit") {
598
+ const path = inputObj?.file_path ?? inputObj?.path;
599
+ if (typeof path === "string")
600
+ return path;
601
+ }
602
+ if (name === "Glob" || name === "Grep") {
603
+ const pattern = inputObj?.pattern;
604
+ if (typeof pattern === "string")
605
+ return pattern;
606
+ }
607
+ if (name === "WebFetch" || name === "WebSearch") {
608
+ const url = inputObj?.url;
609
+ const query = inputObj?.query;
610
+ if (typeof url === "string")
611
+ return url;
612
+ if (typeof query === "string")
613
+ return query;
614
+ }
615
+ return undefined;
616
+ }
617
+ /**
618
+ * Extract tool results from a user message
619
+ *
620
+ * Returns output, error status, and the tool_use_id for matching
621
+ * to the pending tool_use that produced this result.
622
+ */
623
+ extractToolResults(message) {
624
+ const results = [];
625
+ // Check for top-level tool_use_result (direct SDK format)
626
+ if (message.tool_use_result !== undefined) {
627
+ const extracted = this.extractToolResultContent(message.tool_use_result);
628
+ if (extracted) {
629
+ results.push(extracted);
630
+ }
631
+ return results;
632
+ }
633
+ // Check for content blocks in nested message
634
+ const apiMessage = message.message;
635
+ const content = apiMessage?.content;
636
+ if (!Array.isArray(content))
637
+ return results;
638
+ for (const block of content) {
639
+ if (!block || typeof block !== "object" || !("type" in block))
640
+ continue;
641
+ if (block.type === "tool_result") {
642
+ const toolResultBlock = block;
643
+ const isError = toolResultBlock.is_error === true;
644
+ const toolUseId = typeof toolResultBlock.tool_use_id === "string"
645
+ ? toolResultBlock.tool_use_id
646
+ : undefined;
647
+ // Content can be a string or an array of content blocks
648
+ const blockContent = toolResultBlock.content;
649
+ if (typeof blockContent === "string" && blockContent.length > 0) {
650
+ results.push({ output: blockContent, isError, toolUseId });
651
+ }
652
+ else if (Array.isArray(blockContent)) {
653
+ const textParts = [];
654
+ for (const part of blockContent) {
655
+ if (part &&
656
+ typeof part === "object" &&
657
+ "type" in part &&
658
+ part.type === "text" &&
659
+ "text" in part &&
660
+ typeof part.text === "string") {
661
+ textParts.push(part.text);
662
+ }
663
+ }
664
+ if (textParts.length > 0) {
665
+ results.push({ output: textParts.join("\n"), isError, toolUseId });
666
+ }
667
+ }
668
+ }
669
+ }
670
+ return results;
671
+ }
672
+ /**
673
+ * Extract content from a top-level tool_use_result value
674
+ */
675
+ extractToolResultContent(result) {
676
+ if (typeof result === "string" && result.length > 0) {
677
+ return { output: result, isError: false };
678
+ }
679
+ if (result && typeof result === "object") {
680
+ const obj = result;
681
+ // Check for content field
682
+ if (typeof obj.content === "string" && obj.content.length > 0) {
683
+ return {
684
+ output: obj.content,
685
+ isError: obj.is_error === true,
686
+ toolUseId: typeof obj.tool_use_id === "string" ? obj.tool_use_id : undefined,
687
+ };
688
+ }
689
+ // Check for content blocks array
690
+ if (Array.isArray(obj.content)) {
691
+ const textParts = [];
692
+ for (const block of obj.content) {
693
+ if (block &&
694
+ typeof block === "object" &&
695
+ "type" in block &&
696
+ block.type === "text" &&
697
+ "text" in block &&
698
+ typeof block.text === "string") {
699
+ textParts.push(block.text);
700
+ }
701
+ }
702
+ if (textParts.length > 0) {
703
+ return {
704
+ output: textParts.join("\n"),
705
+ isError: obj.is_error === true,
706
+ toolUseId: typeof obj.tool_use_id === "string" ? obj.tool_use_id : undefined,
707
+ };
708
+ }
709
+ }
710
+ }
711
+ return undefined;
712
+ }
713
+ /**
714
+ * Format duration in milliseconds to a human-readable string
715
+ */
716
+ static formatDuration(ms) {
717
+ if (ms < 1000)
718
+ return `${ms}ms`;
719
+ const seconds = Math.floor(ms / 1000);
720
+ if (seconds < 60)
721
+ return `${seconds}s`;
722
+ const minutes = Math.floor(seconds / 60);
723
+ const remainingSeconds = seconds % 60;
724
+ return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
725
+ }
726
+ /**
727
+ * Build a Discord embed for a tool call result
728
+ *
729
+ * Combines the tool_use info (name, input) with the tool_result
730
+ * (output, error status) into a compact Discord embed.
731
+ *
732
+ * @param toolUse - The tool_use block info (name, input, startTime)
733
+ * @param toolResult - The tool result (output, isError)
734
+ * @param maxOutputChars - Maximum characters for output (defaults to TOOL_OUTPUT_MAX_CHARS)
735
+ */
736
+ buildToolEmbed(toolUse, toolResult, maxOutputChars) {
737
+ const toolName = toolUse?.name ?? "Tool";
738
+ const emoji = DiscordManager.TOOL_EMOJIS[toolName] ?? "\u{1F527}"; // wrench fallback
739
+ const isError = toolResult.isError;
740
+ // Build description from input summary
741
+ const inputSummary = toolUse ? this.getToolInputSummary(toolUse.name, toolUse.input) : undefined;
742
+ let description;
743
+ if (inputSummary) {
744
+ if (toolName === "Bash" || toolName === "bash") {
745
+ description = `\`> ${inputSummary}\``;
746
+ }
747
+ else {
748
+ description = `\`${inputSummary}\``;
749
+ }
750
+ }
751
+ // Build inline fields
752
+ const fields = [];
753
+ if (toolUse) {
754
+ const durationMs = Date.now() - toolUse.startTime;
755
+ fields.push({
756
+ name: "Duration",
757
+ value: DiscordManager.formatDuration(durationMs),
758
+ inline: true,
759
+ });
760
+ }
761
+ const outputLength = toolResult.output.length;
762
+ fields.push({
763
+ name: "Output",
764
+ value: outputLength >= 1000
765
+ ? `${(outputLength / 1000).toFixed(1)}k chars`
766
+ : `${outputLength} chars`,
767
+ inline: true,
768
+ });
769
+ // Add truncated output as a field if non-empty
770
+ const trimmedOutput = toolResult.output.trim();
771
+ if (trimmedOutput.length > 0) {
772
+ const maxChars = maxOutputChars ?? DiscordManager.TOOL_OUTPUT_MAX_CHARS;
773
+ let outputText = trimmedOutput;
774
+ if (outputText.length > maxChars) {
775
+ outputText = outputText.substring(0, maxChars) + `\n... (${outputLength.toLocaleString()} chars total)`;
776
+ }
777
+ fields.push({
778
+ name: isError ? "Error" : "Result",
779
+ value: `\`\`\`\n${outputText}\n\`\`\``,
780
+ inline: false,
781
+ });
782
+ }
783
+ return {
784
+ title: `${emoji} ${toolName}`,
785
+ description,
786
+ color: isError ? DiscordManager.EMBED_COLOR_ERROR : DiscordManager.EMBED_COLOR_DEFAULT,
787
+ fields,
788
+ };
789
+ }
790
+ /**
791
+ * Handle errors from Discord connectors
792
+ *
793
+ * Logs errors without crashing the connector
794
+ *
795
+ * @param agentName - Name of the agent that encountered the error
796
+ * @param error - The error that occurred
797
+ */
798
+ handleError(agentName, error) {
799
+ const logger = this.ctx.getLogger();
800
+ const emitter = this.ctx.getEmitter();
801
+ const errorMessage = error instanceof Error ? error.message : String(error);
802
+ logger.error(`Discord connector error for agent '${agentName}': ${errorMessage}`);
803
+ // Emit error event for monitoring
804
+ emitter.emit("discord:error", {
805
+ agentName,
806
+ error: errorMessage,
807
+ timestamp: new Date().toISOString(),
808
+ });
809
+ }
810
+ // ===========================================================================
811
+ // Response Formatting and Splitting
812
+ // ===========================================================================
813
+ /** Discord's maximum message length */
814
+ static MAX_MESSAGE_LENGTH = 2000;
815
+ /**
816
+ * Format an error message for Discord display
817
+ *
818
+ * Creates a user-friendly error message with guidance on how to proceed.
819
+ *
820
+ * @param error - The error that occurred
821
+ * @returns Formatted error message string
822
+ */
823
+ formatErrorMessage(error) {
824
+ return `\u274C **Error**: ${error.message}\n\nPlease try again or use \`/reset\` to start a new session.`;
825
+ }
826
+ /**
827
+ * Split a response into chunks that fit Discord's 2000 character limit
828
+ *
829
+ * Uses the shared splitMessage utility from @herdctl/chat.
830
+ *
831
+ * @param text - The text to split
832
+ * @returns Array of text chunks, each under 2000 characters
833
+ */
834
+ splitResponse(text) {
835
+ const result = splitMessage(text, { maxLength: DiscordManager.MAX_MESSAGE_LENGTH });
836
+ return result.chunks;
837
+ }
838
+ /**
839
+ * Send a response to Discord, splitting if necessary
840
+ *
841
+ * @param reply - The reply function from the message event
842
+ * @param content - The content to send
843
+ */
844
+ async sendResponse(reply, content) {
845
+ const chunks = this.splitResponse(content);
846
+ for (const chunk of chunks) {
847
+ await reply(chunk);
848
+ }
849
+ }
850
+ }
851
+ //# sourceMappingURL=manager.js.map