@herdctl/discord 1.1.1 → 1.2.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 (134) hide show
  1. package/README.md +14 -1
  2. package/dist/__tests__/attachments.test.d.ts +8 -0
  3. package/dist/__tests__/attachments.test.d.ts.map +1 -0
  4. package/dist/__tests__/attachments.test.js +439 -0
  5. package/dist/__tests__/attachments.test.js.map +1 -0
  6. package/dist/__tests__/discord-connector.test.js +4 -1
  7. package/dist/__tests__/discord-connector.test.js.map +1 -1
  8. package/dist/__tests__/embeds.test.d.ts +2 -0
  9. package/dist/__tests__/embeds.test.d.ts.map +1 -0
  10. package/dist/__tests__/embeds.test.js +47 -0
  11. package/dist/__tests__/embeds.test.js.map +1 -0
  12. package/dist/__tests__/logger.test.js +4 -1
  13. package/dist/__tests__/logger.test.js.map +1 -1
  14. package/dist/__tests__/manager.test.js +1193 -28
  15. package/dist/__tests__/manager.test.js.map +1 -1
  16. package/dist/__tests__/message-normalizer.test.d.ts +2 -0
  17. package/dist/__tests__/message-normalizer.test.d.ts.map +1 -0
  18. package/dist/__tests__/message-normalizer.test.js +83 -0
  19. package/dist/__tests__/message-normalizer.test.js.map +1 -0
  20. package/dist/__tests__/runtime-parity.test.d.ts +2 -0
  21. package/dist/__tests__/runtime-parity.test.d.ts.map +1 -0
  22. package/dist/__tests__/runtime-parity.test.js +157 -0
  23. package/dist/__tests__/runtime-parity.test.js.map +1 -0
  24. package/dist/auto-mode-handler.d.ts.map +1 -1
  25. package/dist/auto-mode-handler.js +9 -0
  26. package/dist/auto-mode-handler.js.map +1 -1
  27. package/dist/commands/__tests__/command-manager.test.js +63 -3
  28. package/dist/commands/__tests__/command-manager.test.js.map +1 -1
  29. package/dist/commands/__tests__/extended-commands.test.d.ts +2 -0
  30. package/dist/commands/__tests__/extended-commands.test.d.ts.map +1 -0
  31. package/dist/commands/__tests__/extended-commands.test.js +159 -0
  32. package/dist/commands/__tests__/extended-commands.test.js.map +1 -0
  33. package/dist/commands/__tests__/help.test.js +5 -6
  34. package/dist/commands/__tests__/help.test.js.map +1 -1
  35. package/dist/commands/__tests__/reset.test.js +14 -6
  36. package/dist/commands/__tests__/reset.test.js.map +1 -1
  37. package/dist/commands/__tests__/status.test.js +27 -25
  38. package/dist/commands/__tests__/status.test.js.map +1 -1
  39. package/dist/commands/cancel.d.ts +3 -0
  40. package/dist/commands/cancel.d.ts.map +1 -0
  41. package/dist/commands/cancel.js +7 -0
  42. package/dist/commands/cancel.js.map +1 -0
  43. package/dist/commands/command-manager.d.ts +4 -1
  44. package/dist/commands/command-manager.d.ts.map +1 -1
  45. package/dist/commands/command-manager.js +65 -3
  46. package/dist/commands/command-manager.js.map +1 -1
  47. package/dist/commands/config.d.ts +3 -0
  48. package/dist/commands/config.d.ts.map +1 -0
  49. package/dist/commands/config.js +33 -0
  50. package/dist/commands/config.js.map +1 -0
  51. package/dist/commands/help.d.ts +1 -1
  52. package/dist/commands/help.d.ts.map +1 -1
  53. package/dist/commands/help.js +26 -12
  54. package/dist/commands/help.js.map +1 -1
  55. package/dist/commands/index.d.ts +12 -1
  56. package/dist/commands/index.d.ts.map +1 -1
  57. package/dist/commands/index.js +12 -1
  58. package/dist/commands/index.js.map +1 -1
  59. package/dist/commands/new.d.ts +3 -0
  60. package/dist/commands/new.d.ts.map +1 -0
  61. package/dist/commands/new.js +22 -0
  62. package/dist/commands/new.js.map +1 -0
  63. package/dist/commands/ping.d.ts +3 -0
  64. package/dist/commands/ping.d.ts.map +1 -0
  65. package/dist/commands/ping.js +22 -0
  66. package/dist/commands/ping.js.map +1 -0
  67. package/dist/commands/reset.d.ts +1 -1
  68. package/dist/commands/reset.d.ts.map +1 -1
  69. package/dist/commands/reset.js +13 -13
  70. package/dist/commands/reset.js.map +1 -1
  71. package/dist/commands/retry.d.ts +3 -0
  72. package/dist/commands/retry.d.ts.map +1 -0
  73. package/dist/commands/retry.js +25 -0
  74. package/dist/commands/retry.js.map +1 -0
  75. package/dist/commands/session.d.ts +3 -0
  76. package/dist/commands/session.d.ts.map +1 -0
  77. package/dist/commands/session.js +47 -0
  78. package/dist/commands/session.js.map +1 -0
  79. package/dist/commands/skill.d.ts +3 -0
  80. package/dist/commands/skill.d.ts.map +1 -0
  81. package/dist/commands/skill.js +44 -0
  82. package/dist/commands/skill.js.map +1 -0
  83. package/dist/commands/skills.d.ts +3 -0
  84. package/dist/commands/skills.d.ts.map +1 -0
  85. package/dist/commands/skills.js +30 -0
  86. package/dist/commands/skills.js.map +1 -0
  87. package/dist/commands/status.d.ts +1 -1
  88. package/dist/commands/status.d.ts.map +1 -1
  89. package/dist/commands/status.js +25 -18
  90. package/dist/commands/status.js.map +1 -1
  91. package/dist/commands/stop.d.ts +3 -0
  92. package/dist/commands/stop.d.ts.map +1 -0
  93. package/dist/commands/stop.js +25 -0
  94. package/dist/commands/stop.js.map +1 -0
  95. package/dist/commands/tools.d.ts +3 -0
  96. package/dist/commands/tools.d.ts.map +1 -0
  97. package/dist/commands/tools.js +30 -0
  98. package/dist/commands/tools.js.map +1 -0
  99. package/dist/commands/types.d.ts +71 -1
  100. package/dist/commands/types.d.ts.map +1 -1
  101. package/dist/commands/usage.d.ts +3 -0
  102. package/dist/commands/usage.d.ts.map +1 -0
  103. package/dist/commands/usage.js +58 -0
  104. package/dist/commands/usage.js.map +1 -0
  105. package/dist/discord-connector.d.ts +10 -1
  106. package/dist/discord-connector.d.ts.map +1 -1
  107. package/dist/discord-connector.js +153 -8
  108. package/dist/discord-connector.js.map +1 -1
  109. package/dist/embeds.d.ts +47 -0
  110. package/dist/embeds.d.ts.map +1 -0
  111. package/dist/embeds.js +121 -0
  112. package/dist/embeds.js.map +1 -0
  113. package/dist/index.d.ts +6 -2
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +3 -1
  116. package/dist/index.js.map +1 -1
  117. package/dist/manager.d.ts +53 -24
  118. package/dist/manager.d.ts.map +1 -1
  119. package/dist/manager.js +1031 -217
  120. package/dist/manager.js.map +1 -1
  121. package/dist/mention-handler.d.ts.map +1 -1
  122. package/dist/mention-handler.js +27 -0
  123. package/dist/mention-handler.js.map +1 -1
  124. package/dist/message-normalizer.d.ts +40 -0
  125. package/dist/message-normalizer.d.ts.map +1 -0
  126. package/dist/message-normalizer.js +99 -0
  127. package/dist/message-normalizer.js.map +1 -0
  128. package/dist/types.d.ts +80 -3
  129. package/dist/types.d.ts.map +1 -1
  130. package/dist/voice-transcriber.d.ts +31 -0
  131. package/dist/voice-transcriber.d.ts.map +1 -0
  132. package/dist/voice-transcriber.js +44 -0
  133. package/dist/voice-transcriber.js.map +1 -0
  134. package/package.json +3 -3
package/dist/manager.js CHANGED
@@ -9,9 +9,16 @@
9
9
  *
10
10
  * @module manager
11
11
  */
12
- import { ChatSessionManager, extractMessageContent, StreamingResponder, splitMessage, } from "@herdctl/chat";
13
- import { extractToolResults, extractToolUseBlocks, getToolInputSummary, TOOL_EMOJIS, } from "@herdctl/core";
12
+ import { randomUUID } from "node:crypto";
13
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
14
+ import { basename, dirname, join, resolve } from "node:path";
15
+ import { ChatSessionManager, StreamingResponder, splitMessage, } from "@herdctl/chat";
16
+ import { createFileSenderDef, getToolInputSummary, TOOL_EMOJIS, } from "@herdctl/core";
14
17
  import { DiscordConnector } from "./discord-connector.js";
18
+ import { buildErrorEmbed, buildResultSummaryEmbed, buildRunCardEmbed, buildStatusEmbed, buildToolResultEmbed, } from "./embeds.js";
19
+ import { formatContextForPrompt } from "./mention-handler.js";
20
+ import { normalizeDiscordMessage } from "./message-normalizer.js";
21
+ import { transcribeAudio } from "./voice-transcriber.js";
15
22
  /**
16
23
  * DiscordManager handles Discord connections for agents
17
24
  *
@@ -24,6 +31,10 @@ import { DiscordConnector } from "./discord-connector.js";
24
31
  export class DiscordManager {
25
32
  ctx;
26
33
  connectors = new Map();
34
+ activeJobsByChannel = new Map();
35
+ lastPromptByChannel = new Map();
36
+ lastUsageByChannel = new Map();
37
+ cumulativeUsageByAgent = new Map();
27
38
  initialized = false;
28
39
  constructor(ctx) {
29
40
  this.ctx = ctx;
@@ -93,6 +104,22 @@ export class DiscordManager {
93
104
  sessionManager,
94
105
  stateDir,
95
106
  logger: createAgentLogger(`[discord:${agent.qualifiedName}]`),
107
+ commandActions: {
108
+ stopRun: (channelId) => this.stopChannelRun(agent.qualifiedName, channelId),
109
+ retryRun: (channelId) => this.retryChannelRun(agent.qualifiedName, channelId),
110
+ runSkill: (channelId, skillName, input) => this.runChannelSkill(agent.qualifiedName, channelId, skillName, input),
111
+ listSkills: async () => this.discoverAgentSkills(agent),
112
+ getUsage: async (channelId) => this.getChannelUsage(agent.qualifiedName, channelId),
113
+ getCumulativeUsage: async () => this.getAgentCumulativeUsage(agent.qualifiedName),
114
+ getAgentConfig: async () => this.getAgentConfigSummary(agent),
115
+ getSessionInfo: async (channelId) => this.getChannelRunInfo(agent.qualifiedName, channelId),
116
+ },
117
+ commandRegistration: discordConfig.command_registration
118
+ ? {
119
+ scope: discordConfig.command_registration.scope,
120
+ guildId: discordConfig.command_registration.guild_id,
121
+ }
122
+ : { scope: "global" },
96
123
  });
97
124
  this.connectors.set(agent.qualifiedName, connector);
98
125
  logger.debug(`Created Discord connector for agent '${agent.qualifiedName}'`);
@@ -270,6 +297,7 @@ export class DiscordManager {
270
297
  const logger = this.ctx.getLogger();
271
298
  const emitter = this.ctx.getEmitter();
272
299
  logger.info(`Discord message for agent '${qualifiedName}': ${event.prompt.substring(0, 50)}...`);
300
+ this.lastPromptByChannel.set(this.getChannelKey(qualifiedName, event.metadata.channelId), event.prompt);
273
301
  // Get the agent configuration (lookup by qualifiedName)
274
302
  const config = this.ctx.getConfig();
275
303
  const agent = config?.agents.find((a) => a.qualifiedName === qualifiedName);
@@ -291,7 +319,13 @@ export class DiscordManager {
291
319
  result_summary: false,
292
320
  errors: true,
293
321
  typing_indicator: true,
322
+ acknowledge_emoji: "👀",
323
+ assistant_messages: "answers",
324
+ progress_indicator: true,
294
325
  };
326
+ // Resolve output modes
327
+ const assistantMessages = outputConfig.assistant_messages ?? "answers";
328
+ const showProgressIndicator = outputConfig.progress_indicator !== false;
295
329
  // Get existing session for this channel (for conversation continuity)
296
330
  const connector = this.connectors.get(qualifiedName);
297
331
  let existingSessionId;
@@ -312,156 +346,446 @@ export class DiscordManager {
312
346
  // Continue processing - session failure shouldn't block message handling
313
347
  }
314
348
  }
315
- // Create streaming responder for incremental message delivery
349
+ // Buffer for files uploaded by the agent via MCP tool.
350
+ // Files are queued here and attached to the next answer message,
351
+ // so they appear below the text (not as standalone messages above it).
352
+ const pendingFiles = [];
353
+ // Create file sender definition for this message context
354
+ let injectedMcpServers;
355
+ const workingDir = this.resolveWorkingDirectory(agent);
356
+ if (connector && workingDir) {
357
+ const fileSenderContext = {
358
+ workingDirectory: workingDir,
359
+ uploadFile: async (params) => {
360
+ // Queue the file — it will be attached to the next answer message
361
+ pendingFiles.push({
362
+ buffer: params.fileBuffer,
363
+ filename: params.filename,
364
+ });
365
+ const fileId = `buffered-${randomUUID()}`;
366
+ logger.debug(`Buffered file '${params.filename}' for attachment to next answer message`);
367
+ return { fileId };
368
+ },
369
+ };
370
+ const fileSenderDef = createFileSenderDef(fileSenderContext);
371
+ injectedMcpServers = { [fileSenderDef.name]: fileSenderDef };
372
+ }
373
+ // Create streaming responder for incremental message delivery.
374
+ // The reply closure drains pending files and attaches them to the message.
316
375
  const streamer = new StreamingResponder({
317
- reply: (content) => event.reply(content),
376
+ reply: async (content) => {
377
+ if (pendingFiles.length > 0) {
378
+ const files = pendingFiles.splice(0);
379
+ await event.reply({
380
+ content,
381
+ files: files.map((f) => ({ attachment: f.buffer, name: f.filename })),
382
+ });
383
+ }
384
+ else {
385
+ await event.reply(content);
386
+ }
387
+ },
318
388
  logger: logger,
319
389
  agentName: qualifiedName,
320
390
  maxMessageLength: 2000, // Discord's limit
321
391
  maxBufferSize: 1500,
322
392
  platformName: "Discord",
323
393
  });
324
- // Start typing indicator while processing (configurable via output.typing_indicator)
325
- const stopTyping = outputConfig.typing_indicator ? event.startTyping() : () => { };
394
+ // Start typing indicator while processing (if not disabled via output.typing_indicator)
395
+ const stopTyping = outputConfig.typing_indicator !== false ? event.startTyping() : () => { };
326
396
  // Track if we've stopped typing to avoid multiple calls
327
397
  let typingStopped = false;
398
+ // Add acknowledgement reaction if configured (non-fatal — don't abort message handling)
399
+ const ackEmoji = outputConfig.acknowledge_emoji;
400
+ if (ackEmoji) {
401
+ try {
402
+ await event.addReaction(ackEmoji);
403
+ }
404
+ catch (reactionError) {
405
+ logger.warn(`Failed to add ack reaction: ${reactionError.message}`);
406
+ }
407
+ }
408
+ // Attachment state — declared here so the finally block can clean up
409
+ let attachmentDownloadedPaths = [];
410
+ const attachmentConfig = agent.chat?.discord?.attachments;
411
+ // Progress embed state — declared here so the finally block can clean up
412
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
413
+ const progressState = { handle: null };
414
+ const traceLines = [];
415
+ const pushTraceLine = (line) => {
416
+ traceLines.push(line);
417
+ if (traceLines.length > 24) {
418
+ traceLines.splice(0, traceLines.length - 24);
419
+ }
420
+ };
328
421
  try {
422
+ // Handle voice messages: transcribe audio before triggering the agent
423
+ let prompt = event.prompt;
424
+ if (!existingSessionId && event.context.messages.length > 0) {
425
+ const priorContext = formatContextForPrompt(event.context);
426
+ if (priorContext) {
427
+ prompt = [
428
+ "Recent conversation context from this Discord channel:",
429
+ priorContext,
430
+ "",
431
+ `Current user message: ${prompt}`,
432
+ ].join("\n");
433
+ }
434
+ }
435
+ const voiceConfig = agent.chat?.discord?.voice;
436
+ if (event.metadata.isVoiceMessage) {
437
+ if (!voiceConfig?.enabled) {
438
+ await event.reply("Voice messages are not enabled for this agent. Please send a text message instead.");
439
+ return;
440
+ }
441
+ const apiKey = process.env[voiceConfig.api_key_env ?? "OPENAI_API_KEY"];
442
+ if (!apiKey) {
443
+ logger.error(`Voice transcription API key not found in env var '${voiceConfig.api_key_env}'`);
444
+ await event.reply("Voice transcription is misconfigured. Please contact an administrator.");
445
+ return;
446
+ }
447
+ if (!event.metadata.voiceAttachmentUrl) {
448
+ await event.reply("Could not find audio attachment in voice message.");
449
+ return;
450
+ }
451
+ try {
452
+ logger.debug("Downloading voice message audio...");
453
+ const audioResponse = await fetch(event.metadata.voiceAttachmentUrl, {
454
+ signal: AbortSignal.timeout(30_000),
455
+ });
456
+ if (!audioResponse.ok) {
457
+ throw new Error(`Failed to download audio: ${audioResponse.status}`);
458
+ }
459
+ const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());
460
+ const filename = event.metadata.voiceAttachmentName ?? "voice-message.ogg";
461
+ logger.debug("Transcribing voice message...");
462
+ const transcription = await transcribeAudio(audioBuffer, filename, {
463
+ apiKey,
464
+ model: voiceConfig.model,
465
+ language: voiceConfig.language,
466
+ });
467
+ prompt = `[Voice message transcription]: ${transcription.text}`;
468
+ logger.info(`Voice message transcribed: "${prompt.substring(0, 80)}..."`);
469
+ // Echo the transcription to the channel so everyone can read the voice message
470
+ await event.reply({
471
+ embeds: [
472
+ {
473
+ description: transcription.text,
474
+ color: 0x95a5a6, // subtle grey
475
+ footer: { text: "Voice transcription" },
476
+ },
477
+ ],
478
+ });
479
+ }
480
+ catch (transcribeError) {
481
+ const errMsg = transcribeError instanceof Error ? transcribeError.message : String(transcribeError);
482
+ logger.error(`Voice transcription failed: ${errMsg}`);
483
+ await event.reply(`Failed to transcribe voice message: ${errMsg}`);
484
+ return;
485
+ }
486
+ }
487
+ // Handle file attachments: download, process, and prepend to prompt
488
+ if (event.metadata.attachments &&
489
+ event.metadata.attachments.length > 0 &&
490
+ attachmentConfig?.enabled) {
491
+ const result = await DiscordManager.processAttachments(event.metadata.attachments, attachmentConfig, workingDir, logger);
492
+ attachmentDownloadedPaths = result.downloadedPaths;
493
+ if (result.skippedFiles.length > 0) {
494
+ for (const skipped of result.skippedFiles) {
495
+ logger.debug(`Skipped attachment ${skipped.name}: ${skipped.reason}`);
496
+ }
497
+ }
498
+ if (result.promptSections.length > 0) {
499
+ // When agent runs in Docker, translate host paths to container paths
500
+ // (working_directory is mounted at /workspace inside the container)
501
+ let sections = result.promptSections;
502
+ if (agent.docker?.enabled && workingDir) {
503
+ sections = sections.map((s) => s.replaceAll(workingDir, "/workspace"));
504
+ }
505
+ const attachmentBlock = [
506
+ "The user sent the following file attachment(s) with their message:",
507
+ "",
508
+ ...sections,
509
+ "",
510
+ "---",
511
+ "",
512
+ `User message: ${prompt}`,
513
+ ].join("\n");
514
+ prompt = attachmentBlock;
515
+ }
516
+ }
329
517
  // Track pending tool_use blocks so we can pair them with results
330
518
  const pendingToolUses = new Map();
331
519
  let embedsSent = 0;
520
+ // Deduplicate assistant messages by finalized snapshot. Claude Code can emit
521
+ // intermediate snapshots (stop_reason: null) before the final assistant message.
522
+ // We skip intermediates and deliver the first finalized snapshot per message.id.
523
+ const deliveredAssistantIds = new Set();
524
+ // Capture the result text from the SDK's "result" message as a fallback
525
+ // When all assistant messages are tool-only (no text), this is the last resort
526
+ let resultText;
527
+ let sentAnswer = false;
528
+ let streamedDeltaSinceFinal = false;
529
+ // Progress indicator: track tool names for in-place-updating embed
530
+ const toolNamesRun = [];
531
+ let lastProgressUpdate = 0;
532
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
533
+ let liveAnswerHandle = null;
534
+ let liveAnswerText = "";
535
+ let latestStatusText = "Preparing run…";
536
+ const refreshRunCard = async (status) => {
537
+ if (!showProgressIndicator) {
538
+ return;
539
+ }
540
+ const now = Date.now();
541
+ if (status === "running" && now - lastProgressUpdate < 1500) {
542
+ return;
543
+ }
544
+ lastProgressUpdate = now;
545
+ const header = toolNamesRun.length > 0 ? `Running · ${toolNamesRun.join(" → ")}` : "Running";
546
+ const message = status === "running" ? `${header}\n${latestStatusText}` : latestStatusText;
547
+ const embedPayload = {
548
+ embeds: [
549
+ buildRunCardEmbed({
550
+ agentName: qualifiedName,
551
+ status,
552
+ message: message.length > 4000 ? `…${message.slice(-3997)}` : message,
553
+ traceLines,
554
+ }),
555
+ ],
556
+ };
557
+ try {
558
+ if (!progressState.handle) {
559
+ progressState.handle = await event.replyWithRef(embedPayload);
560
+ }
561
+ else {
562
+ await progressState.handle.edit(embedPayload);
563
+ }
564
+ }
565
+ catch (progressError) {
566
+ logger.warn(`Failed to update run card: ${progressError.message}`);
567
+ }
568
+ };
569
+ const showToolResults = outputConfig.tool_results;
570
+ const enableDeltaStreaming = assistantMessages === "all";
332
571
  // Execute job via FleetManager.trigger() through the context
333
572
  // Pass resume option for conversation continuity
334
573
  // The onMessage callback streams output incrementally to Discord
335
574
  const result = await this.ctx.trigger(qualifiedName, undefined, {
336
575
  triggerType: "discord",
337
- prompt: event.prompt,
576
+ prompt,
338
577
  resume: existingSessionId,
578
+ injectedMcpServers,
579
+ onJobCreated: async (jobId) => {
580
+ this.activeJobsByChannel.set(this.getChannelKey(qualifiedName, event.metadata.channelId), jobId);
581
+ },
339
582
  onMessage: async (message) => {
340
- // Extract text content from assistant messages and stream to Discord
341
- if (message.type === "assistant") {
342
- // Cast to the SDKMessage shape expected by extractMessageContent
343
- // The chat package's SDKMessage type expects a specific structure
344
- const sdkMessage = message;
345
- const content = extractMessageContent(sdkMessage);
346
- if (content) {
347
- // Each assistant message is a complete turn - send immediately
348
- await streamer.addMessageAndSend(content);
583
+ for (const normalized of normalizeDiscordMessage(message)) {
584
+ if (normalized.kind === "assistant_delta") {
585
+ if (!enableDeltaStreaming) {
586
+ continue;
587
+ }
588
+ streamedDeltaSinceFinal = true;
589
+ liveAnswerText += normalized.delta;
590
+ if (!liveAnswerText.trim()) {
591
+ continue;
592
+ }
593
+ const payload = { content: liveAnswerText };
594
+ try {
595
+ if (!liveAnswerHandle) {
596
+ liveAnswerHandle = await event.replyWithRef(payload);
597
+ }
598
+ else {
599
+ await liveAnswerHandle.edit(payload);
600
+ }
601
+ sentAnswer = true;
602
+ }
603
+ catch (deltaError) {
604
+ logger.warn(`Failed delta streaming update: ${deltaError.message}`);
605
+ }
606
+ continue;
349
607
  }
350
- // Track tool_use blocks for pairing with results later
351
- const toolUseBlocks = extractToolUseBlocks(sdkMessage);
352
- for (const block of toolUseBlocks) {
353
- if (block.id) {
354
- pendingToolUses.set(block.id, {
355
- name: block.name,
356
- input: block.input,
357
- startTime: Date.now(),
358
- });
608
+ if (normalized.kind === "assistant_final") {
609
+ for (const block of normalized.toolUses) {
610
+ if (block.id) {
611
+ pendingToolUses.set(block.id, {
612
+ name: block.name,
613
+ input: block.input,
614
+ startTime: Date.now(),
615
+ });
616
+ }
617
+ if (block.name && showProgressIndicator) {
618
+ const emoji = TOOL_EMOJIS[block.name] ?? "\u{1F527}";
619
+ const displayName = `${emoji} ${block.name}`;
620
+ toolNamesRun.push(displayName);
621
+ if (toolNamesRun.length > 50) {
622
+ toolNamesRun.splice(0, toolNamesRun.length - 50);
623
+ }
624
+ const inputSummary = getToolInputSummary(block.name, block.input);
625
+ pushTraceLine(`${emoji} ${block.name}${inputSummary ? ` · ${inputSummary.slice(0, 60)}` : ""}`);
626
+ latestStatusText = `Executing ${block.name}`;
627
+ await refreshRunCard("running");
628
+ }
629
+ }
630
+ if (normalized.messageId && normalized.stopReason === null) {
631
+ continue;
632
+ }
633
+ if (normalized.messageId) {
634
+ if (deliveredAssistantIds.has(normalized.messageId)) {
635
+ continue;
636
+ }
637
+ deliveredAssistantIds.add(normalized.messageId);
638
+ }
639
+ const content = normalized.content;
640
+ if (!content) {
641
+ streamedDeltaSinceFinal = false;
642
+ continue;
359
643
  }
644
+ if (assistantMessages === "answers") {
645
+ if (normalized.toolUses.length === 0) {
646
+ await streamer.addMessageAndSend(content);
647
+ sentAnswer = true;
648
+ }
649
+ }
650
+ else if (streamedDeltaSinceFinal && enableDeltaStreaming) {
651
+ // Sync final content into the live delta message to avoid duplicates.
652
+ liveAnswerText = content;
653
+ try {
654
+ if (!liveAnswerHandle) {
655
+ liveAnswerHandle = await event.replyWithRef({ content });
656
+ }
657
+ else {
658
+ await liveAnswerHandle.edit({ content });
659
+ }
660
+ sentAnswer = true;
661
+ }
662
+ catch (syncError) {
663
+ logger.warn(`Failed to sync final streamed answer: ${syncError.message}`);
664
+ await streamer.addMessageAndSend(content);
665
+ sentAnswer = true;
666
+ }
667
+ }
668
+ else {
669
+ await streamer.addMessageAndSend(content);
670
+ sentAnswer = true;
671
+ }
672
+ streamedDeltaSinceFinal = false;
673
+ continue;
360
674
  }
361
- }
362
- // Build and send embeds for tool results
363
- if (message.type === "user" && outputConfig.tool_results) {
364
- // Cast to the shape expected by extractToolResults
365
- const userMessage = message;
366
- const toolResults = extractToolResults(userMessage);
367
- for (const toolResult of toolResults) {
368
- // Look up the matching tool_use for name, input, and timing
369
- const toolUse = toolResult.toolUseId
370
- ? pendingToolUses.get(toolResult.toolUseId)
371
- : undefined;
372
- if (toolResult.toolUseId) {
373
- pendingToolUses.delete(toolResult.toolUseId);
675
+ if (normalized.kind === "tool_results" && showToolResults) {
676
+ for (const toolResult of normalized.results) {
677
+ const toolUse = toolResult.toolUseId
678
+ ? pendingToolUses.get(toolResult.toolUseId)
679
+ : undefined;
680
+ if (toolResult.toolUseId) {
681
+ pendingToolUses.delete(toolResult.toolUseId);
682
+ }
683
+ const toolName = toolUse?.name ?? "Tool";
684
+ const output = toolResult.output.trim();
685
+ const preview = output.length > 0 ? output.replace(/\s+/g, " ").slice(0, 90) : "";
686
+ pushTraceLine(`${toolResult.isError ? "✖" : "✓"} ${toolName}${preview ? ` · ${preview}` : ""}`);
687
+ latestStatusText = `${toolResult.isError ? "Error from" : "Completed"} ${toolName}`;
688
+ await refreshRunCard("running");
689
+ // Oversized output is attached as a file instead of flooding chat.
690
+ const maxOutputChars = outputConfig.tool_result_max_length ?? 900;
691
+ if (output.length > maxOutputChars) {
692
+ await streamer.flush();
693
+ const filename = `${toolName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "tool"}-output.txt`;
694
+ const previewEmbed = buildToolResultEmbed({
695
+ toolUse: toolUse ?? null,
696
+ toolResult: {
697
+ output: output.slice(0, Math.min(300, maxOutputChars)),
698
+ isError: toolResult.isError,
699
+ },
700
+ agentName: qualifiedName,
701
+ maxOutputChars: Math.min(300, maxOutputChars),
702
+ });
703
+ await event.reply({
704
+ embeds: [previewEmbed],
705
+ files: [{ attachment: Buffer.from(output, "utf8"), name: filename }],
706
+ });
707
+ embedsSent++;
708
+ }
374
709
  }
375
- const embed = this.buildToolEmbed(toolUse ?? null, toolResult, outputConfig.tool_result_max_length);
376
- // Flush any buffered text before sending embed to preserve ordering
377
- await streamer.flush();
378
- await event.reply({ embeds: [embed] });
379
- embedsSent++;
710
+ continue;
380
711
  }
381
- }
382
- // Show system status messages (e.g., "compacting context...")
383
- if (message.type === "system" && outputConfig.system_status) {
384
- const sysMessage = message;
385
- if (sysMessage.subtype === "status" && sysMessage.status) {
386
- const statusText = sysMessage.status === "compacting"
387
- ? "Compacting context..."
388
- : `Status: ${sysMessage.status}`;
389
- await streamer.flush();
390
- await event.reply({
391
- embeds: [
392
- {
393
- title: "\u2699\uFE0F System",
394
- description: statusText,
395
- color: DiscordManager.EMBED_COLOR_SYSTEM,
396
- },
397
- ],
398
- });
399
- embedsSent++;
712
+ if (normalized.kind === "system_status" && outputConfig.system_status) {
713
+ latestStatusText =
714
+ normalized.status === "compacting" ? "Compacting context…" : normalized.status;
715
+ pushTraceLine(`ℹ ${latestStatusText}`);
716
+ await refreshRunCard("running");
717
+ continue;
400
718
  }
401
- }
402
- // Show result summary embed (cost, tokens, turns)
403
- if (message.type === "result" && outputConfig.result_summary) {
404
- const resultMessage = message;
405
- const fields = [];
406
- if (resultMessage.duration_ms !== undefined) {
407
- fields.push({
408
- name: "Duration",
409
- value: DiscordManager.formatDuration(resultMessage.duration_ms),
410
- inline: true,
411
- });
719
+ if (normalized.kind === "tool_progress" && outputConfig.system_status) {
720
+ latestStatusText = normalized.content;
721
+ pushTraceLine(`ℹ ${normalized.content}`);
722
+ await refreshRunCard("running");
723
+ continue;
412
724
  }
413
- if (resultMessage.num_turns !== undefined) {
414
- fields.push({
415
- name: "Turns",
416
- value: String(resultMessage.num_turns),
417
- inline: true,
418
- });
725
+ if (normalized.kind === "auth_status" && outputConfig.system_status) {
726
+ latestStatusText = normalized.content;
727
+ pushTraceLine(`${normalized.isError ? "✖" : ""} ${normalized.content}`);
728
+ if (normalized.isError) {
729
+ await streamer.flush();
730
+ await event.reply({
731
+ embeds: [buildStatusEmbed(normalized.content, "error", qualifiedName)],
732
+ });
733
+ embedsSent++;
734
+ }
735
+ else {
736
+ await refreshRunCard("running");
737
+ }
738
+ continue;
419
739
  }
420
- if (resultMessage.total_cost_usd !== undefined) {
421
- fields.push({
422
- name: "Cost",
423
- value: `$${resultMessage.total_cost_usd.toFixed(4)}`,
424
- inline: true,
740
+ if (normalized.kind === "result") {
741
+ if (normalized.resultText) {
742
+ resultText = normalized.resultText;
743
+ }
744
+ const now = new Date().toISOString();
745
+ this.lastUsageByChannel.set(this.getChannelKey(qualifiedName, event.metadata.channelId), {
746
+ timestamp: now,
747
+ numTurns: normalized.numTurns,
748
+ durationMs: normalized.durationMs,
749
+ totalCostUsd: normalized.totalCostUsd,
750
+ inputTokens: normalized.usage?.input_tokens,
751
+ outputTokens: normalized.usage?.output_tokens,
752
+ isError: normalized.isError,
425
753
  });
754
+ this.accumulateUsage(qualifiedName, {
755
+ durationMs: normalized.durationMs,
756
+ totalCostUsd: normalized.totalCostUsd,
757
+ inputTokens: normalized.usage?.input_tokens,
758
+ outputTokens: normalized.usage?.output_tokens,
759
+ isError: normalized.isError,
760
+ timestamp: now,
761
+ });
762
+ latestStatusText = normalized.isError ? "Task failed" : "Task complete";
763
+ await refreshRunCard(normalized.isError ? "error" : "success");
764
+ if (outputConfig.result_summary) {
765
+ await streamer.flush();
766
+ await event.reply({
767
+ embeds: [
768
+ buildResultSummaryEmbed({
769
+ agentName: qualifiedName,
770
+ isError: normalized.isError,
771
+ durationMs: normalized.durationMs,
772
+ numTurns: normalized.numTurns,
773
+ totalCostUsd: normalized.totalCostUsd,
774
+ usage: normalized.usage,
775
+ }),
776
+ ],
777
+ });
778
+ embedsSent++;
779
+ }
780
+ continue;
426
781
  }
427
- if (resultMessage.usage) {
428
- const inputTokens = resultMessage.usage.input_tokens ?? 0;
429
- const outputTokens = resultMessage.usage.output_tokens ?? 0;
430
- fields.push({
431
- name: "Tokens",
432
- value: `${inputTokens.toLocaleString()} in / ${outputTokens.toLocaleString()} out`,
433
- inline: true,
782
+ if (normalized.kind === "error" && outputConfig.errors) {
783
+ await streamer.flush();
784
+ await event.reply({
785
+ embeds: [buildErrorEmbed(normalized.message, qualifiedName)],
434
786
  });
787
+ embedsSent++;
435
788
  }
436
- const isError = resultMessage.is_error === true;
437
- await streamer.flush();
438
- await event.reply({
439
- embeds: [
440
- {
441
- title: isError ? "\u274C Task Failed" : "\u2705 Task Complete",
442
- color: isError
443
- ? DiscordManager.EMBED_COLOR_ERROR
444
- : DiscordManager.EMBED_COLOR_SUCCESS,
445
- fields,
446
- },
447
- ],
448
- });
449
- embedsSent++;
450
- }
451
- // Show SDK error messages
452
- if (message.type === "error" && outputConfig.errors) {
453
- const errorText = typeof message.content === "string" ? message.content : "An unknown error occurred";
454
- await streamer.flush();
455
- await event.reply({
456
- embeds: [
457
- {
458
- title: "\u274C Error",
459
- description: errorText.length > 4000 ? `${errorText.substring(0, 4000)}...` : errorText,
460
- color: DiscordManager.EMBED_COLOR_ERROR,
461
- },
462
- ],
463
- });
464
- embedsSent++;
465
789
  }
466
790
  },
467
791
  });
@@ -471,18 +795,60 @@ export class DiscordManager {
471
795
  stopTyping();
472
796
  typingStopped = true;
473
797
  }
798
+ // Fall back to SDK result text if no answer turns produced text
799
+ if (!sentAnswer && !streamer.hasSentMessages() && resultText) {
800
+ logger.debug("No answer turns produced text — using SDK result text as fallback");
801
+ await streamer.addMessageAndSend(resultText);
802
+ sentAnswer = true;
803
+ }
474
804
  // Flush any remaining buffered content
475
805
  await streamer.flush();
806
+ // Send any remaining buffered files that weren't attached to an answer.
807
+ // This handles the case where the agent uploaded files but produced no text answer.
808
+ if (pendingFiles.length > 0) {
809
+ const files = pendingFiles.splice(0);
810
+ logger.debug(`Sending ${files.length} remaining buffered file(s) as standalone message`);
811
+ await event.reply({
812
+ files: files.map((f) => ({ attachment: f.buffer, name: f.filename })),
813
+ });
814
+ }
476
815
  logger.debug(`Discord job completed: ${result.jobId} for agent '${qualifiedName}'${result.sessionId ? ` (session: ${result.sessionId})` : ""}`);
477
- // If no messages were sent (text or embeds), send an appropriate fallback
478
- if (!streamer.hasSentMessages() && embedsSent === 0) {
816
+ if (progressState.handle) {
817
+ try {
818
+ await progressState.handle.edit({
819
+ embeds: [
820
+ buildRunCardEmbed({
821
+ agentName: qualifiedName,
822
+ status: result.success ? "success" : "error",
823
+ message: result.success ? "Task complete" : "Task failed",
824
+ traceLines,
825
+ }),
826
+ ],
827
+ });
828
+ }
829
+ catch (progressError) {
830
+ logger.warn(`Failed to finalize run card: ${progressError.message}`);
831
+ }
832
+ }
833
+ // If no text messages were sent, send an appropriate fallback.
834
+ // When embedsSent > 0 but no text was delivered, the user saw tool/result embeds
835
+ // but may have missed the final answer. Show a brief completion indicator.
836
+ if (!sentAnswer && !streamer.hasSentMessages() && embedsSent === 0) {
479
837
  if (result.success) {
480
- await event.reply("I've completed the task, but I don't have a specific response to share.");
838
+ await event.reply({
839
+ embeds: [
840
+ buildStatusEmbed("Task completed — no additional output to share.", "info", qualifiedName),
841
+ ],
842
+ });
481
843
  }
482
844
  else {
483
845
  // Job failed without streaming any messages - send error details
484
846
  const errorMessage = result.errorDetails?.message ?? result.error?.message ?? "An unknown error occurred";
485
- await event.reply(`\u274C **Error:** ${errorMessage}\n\nThe task could not be completed. Please check the logs for more details.`);
847
+ await event.reply({
848
+ embeds: [
849
+ buildErrorEmbed(`${errorMessage}\n\nThe task could not be completed.`, qualifiedName),
850
+ ],
851
+ });
486
852
  }
487
853
  // Stop typing after sending fallback message (if not already stopped)
488
854
  if (!typingStopped) {
@@ -518,9 +884,26 @@ export class DiscordManager {
518
884
  catch (error) {
519
885
  const err = error instanceof Error ? error : new Error(String(error));
520
886
  logger.error(`Discord message handling failed for agent '${qualifiedName}': ${err.message}`);
887
+ if (progressState.handle) {
888
+ try {
889
+ await progressState.handle.edit({
890
+ embeds: [
891
+ buildRunCardEmbed({
892
+ agentName: qualifiedName,
893
+ status: "error",
894
+ message: `Task failed · ${err.message}`,
895
+ traceLines,
896
+ }),
897
+ ],
898
+ });
899
+ }
900
+ catch (progressError) {
901
+ logger.warn(`Failed to finalize failed run card: ${progressError.message}`);
902
+ }
903
+ }
521
904
  // Send user-friendly error message using the formatted error method
522
905
  try {
523
- await event.reply(this.formatErrorMessage(err));
906
+ await event.reply(this.formatErrorMessage(err, qualifiedName));
524
907
  }
525
908
  catch (replyError) {
526
909
  logger.error(`Failed to send error reply: ${replyError.message}`);
@@ -535,102 +918,33 @@ export class DiscordManager {
535
918
  });
536
919
  }
537
920
  finally {
921
+ this.activeJobsByChannel.delete(this.getChannelKey(qualifiedName, event.metadata.channelId));
538
922
  // Safety net: stop typing indicator if not already stopped
539
923
  // (Should already be stopped after sending messages, but this ensures cleanup on errors)
540
924
  if (!typingStopped) {
541
925
  stopTyping();
542
926
  }
927
+ // Remove acknowledgement reaction now that processing is complete
928
+ if (ackEmoji) {
929
+ try {
930
+ await event.removeReaction(ackEmoji);
931
+ }
932
+ catch (reactionError) {
933
+ logger.warn(`Failed to remove ack reaction: ${reactionError.message}`);
934
+ }
935
+ }
936
+ // Clean up downloaded attachment files if configured
937
+ if (attachmentDownloadedPaths.length > 0 &&
938
+ attachmentConfig?.cleanup_after_processing !== false) {
939
+ try {
940
+ await DiscordManager.cleanupAttachments(attachmentDownloadedPaths, logger);
941
+ }
942
+ catch (cleanupError) {
943
+ logger.warn(`Failed to cleanup attachments: ${cleanupError.message}`);
944
+ }
945
+ }
543
946
  }
544
947
  }
545
- // =============================================================================
546
- // Tool Embed Support
547
- // =============================================================================
548
- /** Maximum characters for tool output in Discord embed fields */
549
- static TOOL_OUTPUT_MAX_CHARS = 900;
550
- /** Embed colors */
551
- static EMBED_COLOR_DEFAULT = 0x5865f2; // Discord blurple
552
- static EMBED_COLOR_ERROR = 0xef4444; // Red
553
- static EMBED_COLOR_SYSTEM = 0x95a5a6; // Gray
554
- static EMBED_COLOR_SUCCESS = 0x57f287; // Green
555
- /**
556
- * Format duration in milliseconds to a human-readable string
557
- */
558
- static formatDuration(ms) {
559
- if (ms < 1000)
560
- return `${ms}ms`;
561
- const seconds = Math.floor(ms / 1000);
562
- if (seconds < 60)
563
- return `${seconds}s`;
564
- const minutes = Math.floor(seconds / 60);
565
- const remainingSeconds = seconds % 60;
566
- return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
567
- }
568
- /**
569
- * Build a Discord embed for a tool call result
570
- *
571
- * Combines the tool_use info (name, input) with the tool_result
572
- * (output, error status) into a compact Discord embed.
573
- *
574
- * @param toolUse - The tool_use block info (name, input, startTime)
575
- * @param toolResult - The tool result (output, isError)
576
- * @param maxOutputChars - Maximum characters for output (defaults to TOOL_OUTPUT_MAX_CHARS)
577
- */
578
- buildToolEmbed(toolUse, toolResult, maxOutputChars) {
579
- const toolName = toolUse?.name ?? "Tool";
580
- const emoji = TOOL_EMOJIS[toolName] ?? "\u{1F527}"; // wrench fallback
581
- const isError = toolResult.isError;
582
- // Build description from input summary
583
- const inputSummary = toolUse ? getToolInputSummary(toolUse.name, toolUse.input) : undefined;
584
- let description;
585
- if (inputSummary) {
586
- if (toolName === "Bash" || toolName === "bash") {
587
- description = `\`> ${inputSummary}\``;
588
- }
589
- else {
590
- description = `\`${inputSummary}\``;
591
- }
592
- }
593
- // Build inline fields
594
- const fields = [];
595
- if (toolUse) {
596
- const durationMs = Date.now() - toolUse.startTime;
597
- fields.push({
598
- name: "Duration",
599
- value: DiscordManager.formatDuration(durationMs),
600
- inline: true,
601
- });
602
- }
603
- const outputLength = toolResult.output.length;
604
- fields.push({
605
- name: "Output",
606
- value: outputLength >= 1000
607
- ? `${(outputLength / 1000).toFixed(1)}k chars`
608
- : `${outputLength} chars`,
609
- inline: true,
610
- });
611
- // Add truncated output as a field if non-empty
612
- const trimmedOutput = toolResult.output.trim();
613
- if (trimmedOutput.length > 0) {
614
- const maxChars = maxOutputChars ?? DiscordManager.TOOL_OUTPUT_MAX_CHARS;
615
- let outputText = trimmedOutput;
616
- if (outputText.length > maxChars) {
617
- outputText =
618
- outputText.substring(0, maxChars) +
619
- `\n... (${outputLength.toLocaleString()} chars total)`;
620
- }
621
- fields.push({
622
- name: isError ? "Error" : "Result",
623
- value: `\`\`\`\n${outputText}\n\`\`\``,
624
- inline: false,
625
- });
626
- }
627
- return {
628
- title: `${emoji} ${toolName}`,
629
- description,
630
- color: isError ? DiscordManager.EMBED_COLOR_ERROR : DiscordManager.EMBED_COLOR_DEFAULT,
631
- fields,
632
- };
633
- }
634
948
  /**
635
949
  * Handle errors from Discord connectors
636
950
  *
@@ -651,6 +965,345 @@ export class DiscordManager {
651
965
  timestamp: new Date().toISOString(),
652
966
  });
653
967
  }
968
+ getChannelKey(qualifiedName, channelId) {
969
+ return `${qualifiedName}:${channelId}`;
970
+ }
971
+ async stopChannelRun(qualifiedName, channelId) {
972
+ const key = this.getChannelKey(qualifiedName, channelId);
973
+ const jobId = this.activeJobsByChannel.get(key);
974
+ if (!jobId) {
975
+ return {
976
+ success: false,
977
+ message: "No active run found for this channel.",
978
+ };
979
+ }
980
+ try {
981
+ const fleetManager = this.ctx.getEmitter();
982
+ await fleetManager.cancelJob(jobId);
983
+ this.activeJobsByChannel.delete(key);
984
+ return {
985
+ success: true,
986
+ message: `Stop requested for job \`${jobId}\`.`,
987
+ jobId,
988
+ };
989
+ }
990
+ catch (error) {
991
+ return {
992
+ success: false,
993
+ message: `Failed to stop active run: ${error.message}`,
994
+ jobId,
995
+ };
996
+ }
997
+ }
998
+ async retryChannelRun(qualifiedName, channelId) {
999
+ const key = this.getChannelKey(qualifiedName, channelId);
1000
+ const lastPrompt = this.lastPromptByChannel.get(key);
1001
+ if (!lastPrompt) {
1002
+ return {
1003
+ success: false,
1004
+ message: "No previous prompt found to retry in this channel.",
1005
+ };
1006
+ }
1007
+ return this.runChannelPrompt(qualifiedName, channelId, lastPrompt);
1008
+ }
1009
+ // ===========================================================================
1010
+ // Synthetic Event Helpers
1011
+ // ===========================================================================
1012
+ toTextChannel(channel) {
1013
+ return channel;
1014
+ }
1015
+ /**
1016
+ * Build a DiscordMessageEvent from scratch for programmatic triggers
1017
+ * (e.g. /retry). This keeps the boilerplate in one place so any new
1018
+ * fields on DiscordMessageEvent only need updating here.
1019
+ */
1020
+ createSyntheticMessageEvent(params) {
1021
+ const { qualifiedName, prompt, channelId, channel, textChannel, logger } = params;
1022
+ return {
1023
+ agentName: qualifiedName,
1024
+ prompt,
1025
+ context: {
1026
+ messages: [],
1027
+ prompt,
1028
+ wasMentioned: false,
1029
+ },
1030
+ metadata: {
1031
+ guildId: typeof channel.isDMBased === "function" && channel.isDMBased()
1032
+ ? null
1033
+ : typeof channel.guildId === "string"
1034
+ ? channel.guildId
1035
+ : null,
1036
+ channelId,
1037
+ messageId: `synthetic-${randomUUID()}`,
1038
+ userId: "system",
1039
+ username: "system",
1040
+ wasMentioned: false,
1041
+ mode: "auto",
1042
+ },
1043
+ reply: async (content) => {
1044
+ await textChannel.send(content);
1045
+ },
1046
+ replyWithRef: async (content) => {
1047
+ const sent = await textChannel.send(content);
1048
+ return {
1049
+ edit: async (c) => {
1050
+ await sent.edit(c);
1051
+ },
1052
+ delete: async () => {
1053
+ await sent.delete();
1054
+ },
1055
+ };
1056
+ },
1057
+ startTyping: () => {
1058
+ let typingInterval = null;
1059
+ if (textChannel.sendTyping) {
1060
+ void textChannel.sendTyping().catch((err) => {
1061
+ logger.debug(`Synthetic typing indicator failed: ${err.message}`);
1062
+ });
1063
+ typingInterval = setInterval(() => {
1064
+ void textChannel.sendTyping?.().catch((err) => {
1065
+ logger.debug(`Synthetic typing refresh failed: ${err.message}`);
1066
+ });
1067
+ }, 8000);
1068
+ }
1069
+ return () => {
1070
+ if (typingInterval) {
1071
+ clearInterval(typingInterval);
1072
+ typingInterval = null;
1073
+ }
1074
+ };
1075
+ },
1076
+ addReaction: async () => { },
1077
+ removeReaction: async () => { },
1078
+ };
1079
+ }
1080
+ async getChannelRunInfo(qualifiedName, channelId) {
1081
+ const key = this.getChannelKey(qualifiedName, channelId);
1082
+ return {
1083
+ activeJobId: this.activeJobsByChannel.get(key),
1084
+ lastPrompt: this.lastPromptByChannel.get(key),
1085
+ };
1086
+ }
1087
+ async getChannelUsage(qualifiedName, channelId) {
1088
+ return this.lastUsageByChannel.get(this.getChannelKey(qualifiedName, channelId)) ?? null;
1089
+ }
1090
+ accumulateUsage(qualifiedName, run) {
1091
+ const existing = this.cumulativeUsageByAgent.get(qualifiedName);
1092
+ if (existing) {
1093
+ existing.totalRuns++;
1094
+ if (run.isError)
1095
+ existing.totalFailures++;
1096
+ else
1097
+ existing.totalSuccesses++;
1098
+ existing.totalCostUsd += run.totalCostUsd ?? 0;
1099
+ existing.totalInputTokens += run.inputTokens ?? 0;
1100
+ existing.totalOutputTokens += run.outputTokens ?? 0;
1101
+ existing.totalDurationMs += run.durationMs ?? 0;
1102
+ existing.lastRunAt = run.timestamp;
1103
+ }
1104
+ else {
1105
+ this.cumulativeUsageByAgent.set(qualifiedName, {
1106
+ totalRuns: 1,
1107
+ totalSuccesses: run.isError ? 0 : 1,
1108
+ totalFailures: run.isError ? 1 : 0,
1109
+ totalCostUsd: run.totalCostUsd ?? 0,
1110
+ totalInputTokens: run.inputTokens ?? 0,
1111
+ totalOutputTokens: run.outputTokens ?? 0,
1112
+ totalDurationMs: run.durationMs ?? 0,
1113
+ firstRunAt: run.timestamp,
1114
+ lastRunAt: run.timestamp,
1115
+ });
1116
+ }
1117
+ }
1118
+ async getAgentCumulativeUsage(qualifiedName) {
1119
+ return (this.cumulativeUsageByAgent.get(qualifiedName) ?? {
1120
+ totalRuns: 0,
1121
+ totalSuccesses: 0,
1122
+ totalFailures: 0,
1123
+ totalCostUsd: 0,
1124
+ totalInputTokens: 0,
1125
+ totalOutputTokens: 0,
1126
+ totalDurationMs: 0,
1127
+ firstRunAt: "",
1128
+ lastRunAt: "",
1129
+ });
1130
+ }
1131
+ async runChannelSkill(qualifiedName, channelId, skillName, input) {
1132
+ const agent = this.getAgentByQualifiedName(qualifiedName);
1133
+ if (!agent) {
1134
+ return {
1135
+ success: false,
1136
+ message: `Agent '${qualifiedName}' is not available.`,
1137
+ };
1138
+ }
1139
+ const availableSkills = await this.discoverAgentSkills(agent);
1140
+ if (availableSkills.length === 0) {
1141
+ return {
1142
+ success: false,
1143
+ message: "No skills are configured/discovered for this agent. Configure `chat.discord.skills` or ensure skills exist in the agent working directory.",
1144
+ };
1145
+ }
1146
+ const matchedSkill = availableSkills.find((s) => s.name === skillName);
1147
+ if (!matchedSkill) {
1148
+ return {
1149
+ success: false,
1150
+ message: `Unknown skill \`${skillName}\`. Use \`/skills\` to see available skills.`,
1151
+ };
1152
+ }
1153
+ const instruction = [
1154
+ `Use the "${matchedSkill.name}" skill to handle this request.`,
1155
+ "",
1156
+ input?.trim() ? input.trim() : "No additional input was provided.",
1157
+ ].join("\n");
1158
+ const result = await this.runChannelPrompt(qualifiedName, channelId, instruction);
1159
+ if (result.success) {
1160
+ return {
1161
+ ...result,
1162
+ message: `Skill \`${matchedSkill.name}\` started. Output will be posted in this channel.`,
1163
+ };
1164
+ }
1165
+ return result;
1166
+ }
1167
+ async runChannelPrompt(qualifiedName, channelId, prompt) {
1168
+ const logger = this.ctx.getLogger();
1169
+ const key = this.getChannelKey(qualifiedName, channelId);
1170
+ const activeJobId = this.activeJobsByChannel.get(key);
1171
+ if (activeJobId) {
1172
+ return {
1173
+ success: false,
1174
+ message: `A run is already active in this channel (\`${activeJobId}\`).`,
1175
+ jobId: activeJobId,
1176
+ };
1177
+ }
1178
+ const connector = this.connectors.get(qualifiedName);
1179
+ if (!connector?.client?.isReady()) {
1180
+ return {
1181
+ success: false,
1182
+ message: "Run cannot start because the Discord connector is not connected.",
1183
+ };
1184
+ }
1185
+ const channel = await connector.client.channels.fetch(channelId);
1186
+ if (!channel ||
1187
+ !("isTextBased" in channel) ||
1188
+ typeof channel.isTextBased !== "function" ||
1189
+ !channel.isTextBased() ||
1190
+ !("send" in channel) ||
1191
+ typeof channel.send !== "function") {
1192
+ return {
1193
+ success: false,
1194
+ message: "Run failed because this channel is not text-capable.",
1195
+ };
1196
+ }
1197
+ const textChannel = this.toTextChannel(channel);
1198
+ const syntheticEvent = this.createSyntheticMessageEvent({
1199
+ qualifiedName,
1200
+ prompt,
1201
+ channelId,
1202
+ channel,
1203
+ textChannel,
1204
+ logger,
1205
+ });
1206
+ this.lastPromptByChannel.set(key, prompt);
1207
+ void this.handleMessage(qualifiedName, syntheticEvent)
1208
+ .catch(async (error) => {
1209
+ const err = error instanceof Error ? error : new Error(String(error));
1210
+ logger.error(`Background slash run failed for '${qualifiedName}': ${err.message}`);
1211
+ try {
1212
+ await textChannel.send(this.formatErrorMessage(err, qualifiedName));
1213
+ }
1214
+ catch (replyError) {
1215
+ logger.error(`Failed to send slash failure message: ${replyError.message}`);
1216
+ }
1217
+ })
1218
+ .finally(() => {
1219
+ this.activeJobsByChannel.delete(key);
1220
+ });
1221
+ return {
1222
+ success: true,
1223
+ message: "Run started. Output will be posted in this channel.",
1224
+ };
1225
+ }
1226
+ async discoverAgentSkills(agent) {
1227
+ const discovered = new Map();
1228
+ // Step 1: if skills are explicitly configured, use them as-is.
1229
+ // - undefined → not configured, fall through to auto-discovery
1230
+ // - [] → explicitly disabled, return empty
1231
+ // - [...] → use exactly those skills
1232
+ const configuredSkills = this.getConfiguredDiscordSkills(agent);
1233
+ if (configuredSkills !== undefined) {
1234
+ return configuredSkills
1235
+ .filter((s) => s.name.trim().length > 0)
1236
+ .sort((a, b) => a.name.localeCompare(b.name));
1237
+ }
1238
+ // Step 2: auto-discover from agent working directory paths.
1239
+ const workingDir = typeof agent.working_directory === "string"
1240
+ ? agent.working_directory
1241
+ : agent.working_directory?.root;
1242
+ if (!workingDir) {
1243
+ return Array.from(discovered.values()).sort((a, b) => a.name.localeCompare(b.name));
1244
+ }
1245
+ const candidateDirs = [
1246
+ join(workingDir, ".claude", "skills"),
1247
+ join(workingDir, ".codex", "skills"),
1248
+ join(workingDir, "skills"),
1249
+ ];
1250
+ for (const dir of candidateDirs) {
1251
+ try {
1252
+ const entries = await readdir(resolve(dir), { withFileTypes: true });
1253
+ for (const entry of entries) {
1254
+ if (!entry.isDirectory())
1255
+ continue;
1256
+ const skillName = entry.name;
1257
+ const skillPath = join(dir, skillName, "SKILL.md");
1258
+ try {
1259
+ const raw = await readFile(skillPath, "utf8");
1260
+ const lines = raw.split("\n").map((line) => line.trim());
1261
+ const description = lines.find((line) => line.length > 0 && !line.startsWith("#"))?.slice(0, 120) ??
1262
+ undefined;
1263
+ discovered.set(skillName, { name: skillName, description });
1264
+ }
1265
+ catch {
1266
+ // Ignore directories without readable SKILL.md
1267
+ }
1268
+ }
1269
+ }
1270
+ catch {
1271
+ // Directory does not exist or is unreadable; skip.
1272
+ }
1273
+ }
1274
+ return Array.from(discovered.values()).sort((a, b) => a.name.localeCompare(b.name));
1275
+ }
1276
+ getAgentByQualifiedName(qualifiedName) {
1277
+ const config = this.ctx.getConfig();
1278
+ return config?.agents.find((agent) => agent.qualifiedName === qualifiedName);
1279
+ }
1280
+ getConfiguredDiscordSkills(agent) {
1281
+ const discord = agent.chat?.discord;
1282
+ if (!discord || !("skills" in discord)) {
1283
+ return undefined; // not configured → auto-discover
1284
+ }
1285
+ const skills = discord.skills;
1286
+ if (!Array.isArray(skills)) {
1287
+ return undefined;
1288
+ }
1289
+ // [] → explicitly empty (no skills); [...] → those exact skills
1290
+ return skills
1291
+ .filter((skill) => typeof skill?.name === "string")
1292
+ .map((skill) => ({ name: skill.name, description: skill.description }));
1293
+ }
1294
+ async getAgentConfigSummary(agent) {
1295
+ return {
1296
+ runtime: agent.runtime,
1297
+ model: agent.model,
1298
+ permissionMode: agent.permission_mode,
1299
+ workingDirectory: typeof agent.working_directory === "string"
1300
+ ? agent.working_directory
1301
+ : agent.working_directory?.root,
1302
+ allowedTools: agent.allowed_tools,
1303
+ deniedTools: agent.denied_tools,
1304
+ mcpServers: agent.mcp_servers ? Object.keys(agent.mcp_servers) : [],
1305
+ };
1306
+ }
654
1307
  // ===========================================================================
655
1308
  // Response Formatting and Splitting
656
1309
  // ===========================================================================
@@ -660,12 +1313,21 @@ export class DiscordManager {
660
1313
  * Format an error message for Discord display
661
1314
  *
662
1315
  * Creates a user-friendly error message with guidance on how to proceed.
1316
+ * Returns an embed when agentName is provided, plain text otherwise.
663
1317
  *
664
1318
  * @param error - The error that occurred
665
- * @returns Formatted error message string
1319
+ * @param agentName - Optional agent name for embed footer
1320
+ * @returns Formatted error message string or embed payload
666
1321
  */
667
- formatErrorMessage(error) {
668
- return `\u274C **Error**: ${error.message}\n\nPlease try again or use \`/reset\` to start a new session.`;
1322
+ formatErrorMessage(error, agentName) {
1323
+ if (agentName) {
1324
+ return {
1325
+ embeds: [
1326
+ buildErrorEmbed(`${error.message}\n\nTry again or use \`/reset\` to start a new session.`, agentName),
1327
+ ],
1328
+ };
1329
+ }
1330
+ return `**Error:** ${error.message}\n\nTry again or use \`/reset\` to start a new session.`;
669
1331
  }
670
1332
  /**
671
1333
  * Split a response into chunks that fit Discord's 2000 character limit
@@ -691,5 +1353,157 @@ export class DiscordManager {
691
1353
  await reply(chunk);
692
1354
  }
693
1355
  }
1356
+ // ===========================================================================
1357
+ // Utility Methods
1358
+ // ===========================================================================
1359
+ /**
1360
+ * Resolve the agent's working directory to an absolute path string
1361
+ */
1362
+ resolveWorkingDirectory(agent) {
1363
+ if (!agent.working_directory) {
1364
+ return undefined;
1365
+ }
1366
+ if (typeof agent.working_directory === "string") {
1367
+ return agent.working_directory;
1368
+ }
1369
+ return agent.working_directory.root;
1370
+ }
1371
+ // ===========================================================================
1372
+ // Attachment Processing
1373
+ // ===========================================================================
1374
+ /** Maximum characters to inline for text/code file content */
1375
+ static TEXT_INLINE_MAX_CHARS = 50_000;
1376
+ /**
1377
+ * Check if a content type matches a MIME pattern (supports wildcards like "image/*")
1378
+ */
1379
+ static matchesMimePattern(contentType, pattern) {
1380
+ const ct = contentType.toLowerCase().split(";")[0].trim();
1381
+ const pat = pattern.toLowerCase().trim();
1382
+ if (pat === ct)
1383
+ return true;
1384
+ if (pat.endsWith("/*")) {
1385
+ const prefix = pat.slice(0, -1); // "image/*" → "image/"
1386
+ return ct.startsWith(prefix);
1387
+ }
1388
+ return false;
1389
+ }
1390
+ /**
1391
+ * Process file attachments: download, categorize, and prepare prompt sections.
1392
+ *
1393
+ * - Text/code files are inlined directly into the prompt
1394
+ * - Images and PDFs are saved to disk so the agent can use its Read tool
1395
+ *
1396
+ * Returns prompt sections to prepend, paths of downloaded files for cleanup,
1397
+ * and a list of skipped files with reasons.
1398
+ */
1399
+ static async processAttachments(attachments, config, workingDir, logger) {
1400
+ const promptSections = [];
1401
+ const downloadedPaths = [];
1402
+ const skippedFiles = [];
1403
+ const maxBytes = config.max_file_size_mb * 1024 * 1024;
1404
+ // Limit to max_files_per_message
1405
+ const toProcess = attachments.slice(0, config.max_files_per_message);
1406
+ if (attachments.length > config.max_files_per_message) {
1407
+ const skipped = attachments.slice(config.max_files_per_message);
1408
+ for (const a of skipped) {
1409
+ skippedFiles.push({ name: a.name, reason: "exceeded max_files_per_message" });
1410
+ }
1411
+ }
1412
+ // Create one collision-resistant directory per message processing run.
1413
+ const messageDownloadDir = randomUUID();
1414
+ for (const attachment of toProcess) {
1415
+ // Check allowed types
1416
+ const allowed = config.allowed_types.some((pattern) => DiscordManager.matchesMimePattern(attachment.contentType, pattern));
1417
+ if (!allowed) {
1418
+ skippedFiles.push({
1419
+ name: attachment.name,
1420
+ reason: `type ${attachment.contentType} not in allowed_types`,
1421
+ });
1422
+ continue;
1423
+ }
1424
+ // Check file size
1425
+ if (attachment.size > maxBytes) {
1426
+ skippedFiles.push({
1427
+ name: attachment.name,
1428
+ reason: `size ${attachment.size} exceeds ${config.max_file_size_mb}MB limit`,
1429
+ });
1430
+ continue;
1431
+ }
1432
+ try {
1433
+ if (attachment.category === "text") {
1434
+ // Text/code: download and inline
1435
+ const response = await fetch(attachment.url, { signal: AbortSignal.timeout(30_000) });
1436
+ if (!response.ok) {
1437
+ throw new Error(`HTTP ${response.status}`);
1438
+ }
1439
+ let text = await response.text();
1440
+ if (text.length > DiscordManager.TEXT_INLINE_MAX_CHARS) {
1441
+ text = `${text.substring(0, DiscordManager.TEXT_INLINE_MAX_CHARS)}\n... [truncated at ${DiscordManager.TEXT_INLINE_MAX_CHARS} chars]`;
1442
+ }
1443
+ promptSections.push(`--- File: ${attachment.name} (${attachment.contentType}) ---\n${text}\n--- End of ${attachment.name} ---`);
1444
+ }
1445
+ else {
1446
+ // Image/PDF: download to disk
1447
+ if (!workingDir) {
1448
+ skippedFiles.push({
1449
+ name: attachment.name,
1450
+ reason: "no working_directory configured for binary attachments",
1451
+ });
1452
+ continue;
1453
+ }
1454
+ const response = await fetch(attachment.url, { signal: AbortSignal.timeout(30_000) });
1455
+ if (!response.ok) {
1456
+ throw new Error(`HTTP ${response.status}`);
1457
+ }
1458
+ const buffer = Buffer.from(await response.arrayBuffer());
1459
+ const downloadDir = join(workingDir, config.download_dir, messageDownloadDir);
1460
+ await mkdir(downloadDir, { recursive: true });
1461
+ const filePath = join(downloadDir, `${attachment.id}-${basename(attachment.name)}`);
1462
+ await writeFile(filePath, buffer);
1463
+ downloadedPaths.push(filePath);
1464
+ const typeLabel = attachment.category === "image" ? "Image" : "PDF";
1465
+ promptSections.push(`[${typeLabel} attached: ${filePath}] (Use the Read tool to view this file)`);
1466
+ }
1467
+ }
1468
+ catch (err) {
1469
+ const errMsg = err instanceof Error ? err.message : String(err);
1470
+ logger.warn(`Failed to process attachment ${attachment.name}: ${errMsg}`);
1471
+ skippedFiles.push({
1472
+ name: attachment.name,
1473
+ reason: `download/processing failed: ${errMsg}`,
1474
+ });
1475
+ }
1476
+ }
1477
+ return { promptSections, downloadedPaths, skippedFiles };
1478
+ }
1479
+ /**
1480
+ * Clean up downloaded attachment files after processing
1481
+ */
1482
+ static async cleanupAttachments(paths, logger) {
1483
+ const parentDirs = new Set();
1484
+ for (const filePath of paths) {
1485
+ try {
1486
+ await rm(filePath);
1487
+ // Track parent directory for cleanup
1488
+ const parent = dirname(filePath);
1489
+ if (parent !== filePath && parent !== ".") {
1490
+ parentDirs.add(parent);
1491
+ }
1492
+ }
1493
+ catch (err) {
1494
+ const errMsg = err instanceof Error ? err.message : String(err);
1495
+ logger.debug(`Failed to clean up attachment file ${filePath}: ${errMsg}`);
1496
+ }
1497
+ }
1498
+ // Try to remove empty timestamp directories
1499
+ for (const dir of parentDirs) {
1500
+ try {
1501
+ await rm(dir, { recursive: true });
1502
+ }
1503
+ catch {
1504
+ // Directory may not be empty or already removed — ignore
1505
+ }
1506
+ }
1507
+ }
694
1508
  }
695
1509
  //# sourceMappingURL=manager.js.map