@friendlyrobot/discord-pi-agent 0.14.1 → 0.16.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.
package/dist/index.js CHANGED
@@ -110,7 +110,7 @@ async function formatWithPrettier(text) {
110
110
 
111
111
  // src/reply-buffer.ts
112
112
  var logger3 = createModuleLogger("reply-buffer");
113
- async function collectReply(session, prompt, options = {}) {
113
+ async function runPromptAndCollectReply(session, prompt, options = {}) {
114
114
  let streamedText = "";
115
115
  let eventCount = 0;
116
116
  let toolCount = 0;
@@ -278,7 +278,7 @@ class AgentService {
278
278
  async prompt(text) {
279
279
  const session = this.requireSession();
280
280
  const transformedPrompt = await this.config.promptTransform(text);
281
- return collectReply(session, transformedPrompt, {
281
+ return runPromptAndCollectReply(session, transformedPrompt, {
282
282
  logPrefix: `[agent:${session.sessionId}]`
283
283
  });
284
284
  }
@@ -531,9 +531,10 @@ function isSameModel(currentModel, desiredModel) {
531
531
  import path2 from "node:path";
532
532
  import dotenv from "dotenv";
533
533
  function resolveConfig(config) {
534
+ const discordAllowedUserId = readRequiredValue("discordAllowedUserId", config.discordAllowedUserId);
534
535
  return {
535
536
  discordBotToken: readRequiredValue("discordBotToken", config.discordBotToken),
536
- discordAllowedUserId: readRequiredValue("discordAllowedUserId", config.discordAllowedUserId),
537
+ discordAllowedUserId,
537
538
  cwd: readRequiredValue("cwd", config.cwd),
538
539
  agentDir: config.agentDir?.trim() || path2.join(config.cwd, ".pi-agent"),
539
540
  modelProvider: config.modelProvider?.trim() || "openrouter",
@@ -544,10 +545,13 @@ function resolveConfig(config) {
544
545
  promptTransform: config.promptTransform || identityPromptTransform,
545
546
  startupMessage: config.startupMessage === undefined ? "Bot is online and ready." : config.startupMessage,
546
547
  shutdownOnSignals: config.shutdownOnSignals ?? true,
547
- visionModelId: config.visionModelId?.trim() || null
548
+ visionModelId: config.visionModelId?.trim() || null,
549
+ discordAllowedForumChannelIds: config.discordAllowedForumChannelIds ?? [],
550
+ discordAllowedUserIds: config.discordAllowedUserIds ?? [discordAllowedUserId],
551
+ sessionIdleTimeoutMs: config.sessionIdleTimeoutMs ?? null
548
552
  };
549
553
  }
550
- function loadDiscordPiBridgeConfigFromEnv(overrides = {}) {
554
+ function loadDiscordGatewayConfigFromEnv(overrides = {}) {
551
555
  dotenv.config();
552
556
  return resolveConfig({
553
557
  discordBotToken: overrides.discordBotToken || process.env.DISCORD_BOT_TOKEN || "",
@@ -562,19 +566,11 @@ function loadDiscordPiBridgeConfigFromEnv(overrides = {}) {
562
566
  promptTransform: overrides.promptTransform,
563
567
  startupMessage: overrides.startupMessage ?? readStartupMessageFromEnv(),
564
568
  shutdownOnSignals: overrides.shutdownOnSignals,
565
- visionModelId: overrides.visionModelId ?? process.env.PI_VISION_MODEL_ID
566
- });
567
- }
568
- function loadDiscordGatewayConfigFromEnv(overrides = {}) {
569
- const base = loadDiscordPiBridgeConfigFromEnv(overrides);
570
- return {
571
- ...base,
569
+ visionModelId: overrides.visionModelId ?? process.env.PI_VISION_MODEL_ID,
572
570
  discordAllowedForumChannelIds: overrides.discordAllowedForumChannelIds ?? parseStringArrayFromEnv("DISCORD_FORUM_CHANNEL_IDS") ?? [],
573
- discordAllowedUserIds: overrides.discordAllowedUserIds ?? parseStringArrayFromEnv("DISCORD_ALLOWED_USER_IDS") ?? [
574
- base.discordAllowedUserId
575
- ],
576
- sessionIdleTimeoutMs: overrides.sessionIdleTimeoutMs ?? parseOptionalIntFromEnv("DISCORD_SESSION_IDLE_TIMEOUT_MS") ?? null
577
- };
571
+ discordAllowedUserIds: overrides.discordAllowedUserIds ?? parseStringArrayFromEnv("DISCORD_ALLOWED_USER_IDS"),
572
+ sessionIdleTimeoutMs: overrides.sessionIdleTimeoutMs ?? parseOptionalIntFromEnv("DISCORD_SESSION_IDLE_TIMEOUT_MS") ?? undefined
573
+ });
578
574
  }
579
575
  function readRequiredValue(name, value) {
580
576
  const trimmedValue = value.trim();
@@ -594,17 +590,6 @@ function readStartupMessageFromEnv() {
594
590
  }
595
591
  return trimmedValue;
596
592
  }
597
- function resolveGatewayConfig(config) {
598
- const base = resolveConfig(config);
599
- return {
600
- ...base,
601
- discordAllowedForumChannelIds: config.discordAllowedForumChannelIds ?? [],
602
- discordAllowedUserIds: config.discordAllowedUserIds ?? [
603
- base.discordAllowedUserId
604
- ],
605
- sessionIdleTimeoutMs: config.sessionIdleTimeoutMs ?? null
606
- };
607
- }
608
593
  function parseThinkingLevel(value) {
609
594
  if (!value) {
610
595
  return;
@@ -644,7 +629,6 @@ function parseOptionalIntFromEnv(key) {
644
629
 
645
630
  // src/discord-gateway-client.ts
646
631
  import {
647
- ChannelType,
648
632
  Client,
649
633
  Events,
650
634
  GatewayIntentBits,
@@ -669,7 +653,7 @@ function getSessionStatusText(session, promptQueue, extras) {
669
653
  `queue-busy: ${queueStatus.busy}`
670
654
  ];
671
655
  if (extras?.tools && extras.tools.length > 0) {
672
- const toolNames = extras.tools.map((t) => t.name);
656
+ const toolNames = extras.tools.map((tool) => tool.name);
673
657
  lines.push("", `Tools (${extras.tools.length}): ${toolNames.join(", ")}`);
674
658
  }
675
659
  if (extras?.skillsSummary) {
@@ -691,260 +675,410 @@ function getThinkingInfo(session) {
691
675
  supported: true
692
676
  };
693
677
  }
694
- async function handleCommand(input, ctx) {
695
- const { agentService, promptQueue, session } = ctx;
696
- const trimmed = input.trim();
697
- if (!trimmed.startsWith("!")) {
698
- return { handled: false };
699
- }
700
- if (trimmed === "!help") {
701
- const extraCommands = session ? `
702
- !archive - archive this thread and end the session` : "";
678
+ function getEffectiveSession(context) {
679
+ return context.session ?? context.agentService.getSession();
680
+ }
681
+ function requireEffectiveSession(context) {
682
+ const session = getEffectiveSession(context);
683
+ if (!session) {
703
684
  return {
704
685
  handled: true,
705
- response: [
706
- "Commands:",
707
- "!help - show this message",
708
- "!status - show current session status",
709
- "!thinking - show or set thinking/reasoning level",
710
- "!model - list available models or switch to one",
711
- "!compact - compact the persistent session",
712
- "!reset-session - start a fresh persistent session",
713
- "!reload - reload resources (AGENTS.md, extensions, skills, etc.)",
714
- extraCommands,
715
- "Any other text goes to the agent session."
716
- ].filter(Boolean).join(`
717
- `)
686
+ response: "No active session."
718
687
  };
719
688
  }
720
- if (trimmed === "!archive") {
721
- if (!session) {
722
- return {
723
- handled: true,
724
- response: "!archive is only available in forum threads."
725
- };
726
- }
689
+ return { session };
690
+ }
691
+ async function handleHelpCommand(trimmedInput, context) {
692
+ if (trimmedInput !== "!help") {
693
+ return null;
694
+ }
695
+ const extraCommands = context.session ? `
696
+ !archive - archive this thread and end the session` : "";
697
+ return {
698
+ handled: true,
699
+ response: [
700
+ "Commands:",
701
+ "!help - show this message",
702
+ "!status - show current session status",
703
+ "!thinking - show or set thinking/reasoning level",
704
+ "!model - list available models or switch to one",
705
+ "!compact - compact the persistent session",
706
+ "!reset-session - start a fresh persistent session",
707
+ "!reload - reload resources (AGENTS.md, extensions, skills, etc.)",
708
+ extraCommands,
709
+ "Any other text goes to the agent session."
710
+ ].filter(Boolean).join(`
711
+ `)
712
+ };
713
+ }
714
+ async function handleArchiveCommand(trimmedInput, context) {
715
+ if (trimmedInput !== "!archive") {
716
+ return null;
717
+ }
718
+ if (!context.session) {
727
719
  return {
728
720
  handled: true,
729
- archive: true,
730
- response: "Archiving thread and shutting down session."
721
+ response: "!archive is only available in forum threads."
731
722
  };
732
723
  }
733
- if (trimmed === "!status") {
734
- const effectiveSession = session ?? agentService.getSession();
735
- if (!effectiveSession) {
724
+ return {
725
+ handled: true,
726
+ archive: true,
727
+ response: "Archiving thread and shutting down session."
728
+ };
729
+ }
730
+ async function handleStatusCommand(trimmedInput, context) {
731
+ if (trimmedInput !== "!status") {
732
+ return null;
733
+ }
734
+ const effectiveSession = requireEffectiveSession(context);
735
+ if ("handled" in effectiveSession) {
736
+ return effectiveSession;
737
+ }
738
+ const tools = effectiveSession.session.getAllTools();
739
+ const extensionsSummary = context.agentService.getExtensionsSummary();
740
+ const skillsSummary = context.agentService.getSkillsSummary();
741
+ return {
742
+ handled: true,
743
+ response: getSessionStatusText(effectiveSession.session, context.promptQueue, {
744
+ tools,
745
+ extensionsSummary,
746
+ skillsSummary
747
+ })
748
+ };
749
+ }
750
+ async function handleThinkingCommand(trimmedInput, context) {
751
+ if (trimmedInput !== "!thinking" && !trimmedInput.startsWith("!thinking ")) {
752
+ return null;
753
+ }
754
+ const effectiveSession = requireEffectiveSession(context);
755
+ if ("handled" in effectiveSession) {
756
+ return effectiveSession;
757
+ }
758
+ const parts = trimmedInput.split(" ");
759
+ if (parts.length === 1) {
760
+ const info = getThinkingInfo(effectiveSession.session);
761
+ if (!info.supported) {
736
762
  return {
737
763
  handled: true,
738
- response: "No active session."
764
+ response: "Current model does not support reasoning/thinking."
739
765
  };
740
766
  }
741
- const tools = effectiveSession.getAllTools();
742
- const extensionsSummary = agentService.getExtensionsSummary();
743
- const skillsSummary = agentService.getSkillsSummary();
744
767
  return {
745
768
  handled: true,
746
- response: getSessionStatusText(effectiveSession, promptQueue, {
747
- tools,
748
- extensionsSummary,
749
- skillsSummary
750
- })
769
+ response: [
770
+ `Current: ${info.current}`,
771
+ `Available: ${info.available.join(", ")}`,
772
+ `Usage: !thinking <level>`
773
+ ].join(`
774
+ `)
751
775
  };
752
776
  }
753
- if (trimmed === "!thinking" || trimmed.startsWith("!thinking ")) {
754
- const effectiveSession = session ?? agentService.getSession();
755
- if (!effectiveSession) {
756
- return {
757
- handled: true,
758
- response: "No active session."
759
- };
760
- }
761
- const parts = trimmed.split(" ");
762
- if (parts.length === 1) {
763
- const info = getThinkingInfo(effectiveSession);
764
- if (!info.supported) {
765
- return {
766
- handled: true,
767
- response: "Current model does not support reasoning/thinking."
768
- };
769
- }
770
- return {
771
- handled: true,
772
- response: [
773
- `Current: ${info.current}`,
774
- `Available: ${info.available.join(", ")}`,
775
- `Usage: !thinking <level>`
776
- ].join(`
777
- `)
778
- };
779
- }
780
- const requestedLevel = parts[1];
781
- if (!effectiveSession.supportsThinking()) {
782
- return {
783
- handled: true,
784
- response: "Current model does not support reasoning/thinking."
785
- };
786
- }
787
- const available = effectiveSession.getAvailableThinkingLevels();
788
- if (!available.includes(requestedLevel)) {
789
- return {
790
- handled: true,
791
- response: `Invalid thinking level "${requestedLevel}" for current model. Available: ${available.join(", ")}`
792
- };
793
- }
794
- effectiveSession.setThinkingLevel(requestedLevel);
777
+ const requestedLevel = parts[1];
778
+ if (!effectiveSession.session.supportsThinking()) {
795
779
  return {
796
780
  handled: true,
797
- response: `Thinking level set to "${requestedLevel}".`
781
+ response: "Current model does not support reasoning/thinking."
798
782
  };
799
783
  }
800
- if (trimmed === "!model" || trimmed.startsWith("!model ")) {
801
- const effectiveSession = session ?? agentService.getSession();
802
- if (!effectiveSession) {
803
- return {
804
- handled: true,
805
- response: "No active session."
806
- };
807
- }
808
- const parts = trimmed.split(" ");
809
- if (parts.length === 1) {
810
- const current = agentService.getCurrentModelDisplay(effectiveSession);
811
- const modelList = await agentService.listModels(effectiveSession);
812
- return {
813
- handled: true,
814
- response: `Current model: ${current}
815
-
816
- ${modelList}`
817
- };
818
- }
819
- const arg = parts.slice(1).join(" ");
820
- const slashIndex = arg.indexOf("/");
821
- if (slashIndex === -1) {
822
- return {
823
- handled: true,
824
- response: `Usage: !model <provider/modelId>
825
- Example: !model openrouter/anthropic/claude-sonnet-4
826
- Use !model without args to see available models.`
827
- };
828
- }
829
- const provider = arg.substring(0, slashIndex).trim();
830
- const modelId = arg.substring(slashIndex + 1).trim();
784
+ const available = effectiveSession.session.getAvailableThinkingLevels();
785
+ if (!available.includes(requestedLevel)) {
831
786
  return {
832
787
  handled: true,
833
- response: await agentService.switchModel(provider, modelId, effectiveSession)
788
+ response: `Invalid thinking level "${requestedLevel}" for current model. Available: ${available.join(", ")}`
834
789
  };
835
790
  }
836
- if (trimmed === "!compact") {
837
- const effectiveSession = session ?? agentService.getSession();
838
- if (!effectiveSession) {
839
- return {
840
- handled: true,
841
- response: "No active session."
842
- };
843
- }
791
+ effectiveSession.session.setThinkingLevel(requestedLevel);
792
+ return {
793
+ handled: true,
794
+ response: `Thinking level set to "${requestedLevel}".`
795
+ };
796
+ }
797
+ async function handleModelCommand(trimmedInput, context) {
798
+ if (trimmedInput !== "!model" && !trimmedInput.startsWith("!model ")) {
799
+ return null;
800
+ }
801
+ const effectiveSession = requireEffectiveSession(context);
802
+ if ("handled" in effectiveSession) {
803
+ return effectiveSession;
804
+ }
805
+ const parts = trimmedInput.split(" ");
806
+ if (parts.length === 1) {
807
+ const current = context.agentService.getCurrentModelDisplay(effectiveSession.session);
808
+ const modelList = await context.agentService.listModels(effectiveSession.session);
844
809
  return {
845
810
  handled: true,
846
- response: await promptQueue.enqueue(async () => {
847
- await effectiveSession.compact();
848
- return `Compaction finished for session ${effectiveSession.sessionId}.`;
849
- })
811
+ response: `Current model: ${current}
812
+
813
+ ${modelList}`
850
814
  };
851
815
  }
852
- if (trimmed === "!reload") {
816
+ const argument = parts.slice(1).join(" ");
817
+ const slashIndex = argument.indexOf("/");
818
+ if (slashIndex === -1) {
853
819
  return {
854
820
  handled: true,
855
- response: await promptQueue.enqueue(async () => {
856
- return agentService.reloadResources();
857
- })
821
+ response: `Usage: !model <provider/modelId>
822
+ ` + `Example: !model openrouter/anthropic/claude-sonnet-4
823
+ ` + "Use !model without args to see available models."
858
824
  };
859
825
  }
860
- if (trimmed === "!reset-session") {
861
- if (session) {
862
- return {
863
- handled: true,
864
- response: await promptQueue.enqueue(async () => {
865
- const previousSession = session;
866
- await previousSession.abort();
867
- previousSession.dispose();
868
- return `Session reset. Old session kept at ${previousSession.sessionFile ?? "(unknown path)"}. Use !archive to archive the thread and start fresh.`;
869
- })
870
- };
871
- }
826
+ const provider = argument.substring(0, slashIndex).trim();
827
+ const modelId = argument.substring(slashIndex + 1).trim();
828
+ return {
829
+ handled: true,
830
+ response: await context.agentService.switchModel(provider, modelId, effectiveSession.session)
831
+ };
832
+ }
833
+ async function handleCompactCommand(trimmedInput, context) {
834
+ if (trimmedInput !== "!compact") {
835
+ return null;
836
+ }
837
+ const effectiveSession = requireEffectiveSession(context);
838
+ if ("handled" in effectiveSession) {
839
+ return effectiveSession;
840
+ }
841
+ return {
842
+ handled: true,
843
+ response: await context.promptQueue.enqueue(async () => {
844
+ await effectiveSession.session.compact();
845
+ return `Compaction finished for session ${effectiveSession.session.sessionId}.`;
846
+ })
847
+ };
848
+ }
849
+ async function handleReloadCommand(trimmedInput, context) {
850
+ if (trimmedInput !== "!reload") {
851
+ return null;
852
+ }
853
+ return {
854
+ handled: true,
855
+ response: await context.promptQueue.enqueue(async () => {
856
+ return context.agentService.reloadResources();
857
+ })
858
+ };
859
+ }
860
+ async function handleResetSessionCommand(trimmedInput, context) {
861
+ if (trimmedInput !== "!reset-session") {
862
+ return null;
863
+ }
864
+ if (context.session) {
872
865
  return {
873
866
  handled: true,
874
- response: await promptQueue.enqueue(async () => {
875
- return agentService.resetSession();
867
+ response: await context.promptQueue.enqueue(async () => {
868
+ const previousSession = context.session;
869
+ await previousSession.abort();
870
+ previousSession.dispose();
871
+ return `Session reset. Old session kept at ${previousSession.sessionFile ?? "(unknown path)"}. Use !archive to archive the thread and start fresh.`;
876
872
  })
877
873
  };
878
874
  }
879
875
  return {
880
876
  handled: true,
881
- response: `Unknown command: ${trimmed}. Try !help.`
877
+ response: await context.promptQueue.enqueue(async () => {
878
+ return context.agentService.resetSession();
879
+ })
882
880
  };
883
881
  }
884
-
885
- // src/image-description.ts
886
- var logger5 = createModuleLogger("image-description");
887
- async function describeImage(agentService, imageData, mimeType, userText, visionModel) {
888
- const session = await agentService.createTemporarySession();
889
- await session.setModel(visionModel);
890
- const mediaType = getMediaType(mimeType);
891
- const imageContent = {
892
- type: "image",
893
- data: imageData,
894
- mimeType
895
- };
896
- let promptText;
897
- if (mediaType === "document") {
898
- promptText = userText.trim().length > 0 ? `The user sent a document with the following message: "${userText}". Please extract and summarize the text content of this document. Be thorough — include all important details, sections, and data from the document.` : "Please extract and summarize the text content of this document. Be thorough — include all important details, sections, data, and key points.";
899
- } else {
900
- promptText = userText.trim().length > 0 ? `The user sent this image with the following message: "${userText}". Please describe the image in detail and address any questions from the user's message.` : "Please describe this image in detail. What do you see?";
882
+ var commandHandlers = [
883
+ handleHelpCommand,
884
+ handleArchiveCommand,
885
+ handleStatusCommand,
886
+ handleThinkingCommand,
887
+ handleModelCommand,
888
+ handleCompactCommand,
889
+ handleReloadCommand,
890
+ handleResetSessionCommand
891
+ ];
892
+ async function executeCommand(input, context) {
893
+ const trimmedInput = input.trim();
894
+ if (!trimmedInput.startsWith("!")) {
895
+ return { handled: false };
901
896
  }
902
- let text = "";
903
- try {
904
- await session.prompt(promptText, { images: [imageContent] });
905
- text = extractLastAssistantText(session);
906
- } catch (error) {
907
- logger5.error({ error, mimeType }, "vision model prompt failed");
908
- text = "(Vision model failed to process the file.)";
909
- } finally {
910
- session.dispose();
897
+ for (const handler of commandHandlers) {
898
+ const result = await handler(trimmedInput, context);
899
+ if (result) {
900
+ return result;
901
+ }
911
902
  }
912
- if (!text) {
913
- return "(Vision model returned no description.)";
903
+ return {
904
+ handled: true,
905
+ response: `Unknown command: ${trimmedInput}. Try !help.`
906
+ };
907
+ }
908
+
909
+ // src/discord-attachments.ts
910
+ var logger5 = createModuleLogger("discord-attachments");
911
+ var TEXT_ATTACHMENT_EXTENSIONS = [
912
+ ".txt",
913
+ ".md",
914
+ ".json",
915
+ ".csv",
916
+ ".log",
917
+ ".yml",
918
+ ".yaml",
919
+ ".xml",
920
+ ".toml",
921
+ ".ini",
922
+ ".cfg"
923
+ ];
924
+ var MAX_TEXT_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
925
+ var MEDIA_ATTACHMENT_EXTENSIONS = [
926
+ ".png",
927
+ ".jpg",
928
+ ".jpeg",
929
+ ".gif",
930
+ ".webp",
931
+ ".pdf",
932
+ ".docx",
933
+ ".doc",
934
+ ".pptx",
935
+ ".ppt",
936
+ ".xlsx",
937
+ ".xls"
938
+ ];
939
+ var MAX_MEDIA_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
940
+ var OFFICE_MIME_TYPES = new Set([
941
+ "application/pdf",
942
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
943
+ "application/msword",
944
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
945
+ "application/vnd.ms-powerpoint",
946
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
947
+ "application/vnd.ms-excel"
948
+ ]);
949
+ function isSupportedTextAttachment(attachment) {
950
+ const ext = attachment.name?.slice(attachment.name.lastIndexOf(".")).toLowerCase();
951
+ return Boolean(ext && TEXT_ATTACHMENT_EXTENSIONS.includes(ext));
952
+ }
953
+ function isSupportedMediaAttachment(attachment) {
954
+ const ext = attachment.name?.slice(attachment.name.lastIndexOf(".")).toLowerCase();
955
+ if (!ext || !MEDIA_ATTACHMENT_EXTENSIONS.includes(ext)) {
956
+ return false;
914
957
  }
915
- logger5.debug({ textLength: text.length, mimeType }, "media described");
916
- return text;
958
+ const contentType = attachment.contentType;
959
+ if (!contentType) {
960
+ return false;
961
+ }
962
+ return contentType.startsWith("image/") || OFFICE_MIME_TYPES.has(contentType);
917
963
  }
918
- function extractLastAssistantText(session) {
919
- const messages = session.messages;
920
- for (let i = messages.length - 1;i >= 0; i--) {
921
- const msg = messages[i];
922
- if (!msg || !isAssistantMessage(msg)) {
964
+ async function readTextAttachments(message) {
965
+ const attachments = message.attachments;
966
+ if (attachments.size === 0) {
967
+ return [];
968
+ }
969
+ const results = [];
970
+ for (const [, attachment] of attachments) {
971
+ if (!isSupportedTextAttachment(attachment)) {
972
+ logger5.debug({ messageId: message.id, filename: attachment.name }, "skipping non-text attachment");
923
973
  continue;
924
974
  }
925
- const content = msg.content;
926
- if (!Array.isArray(content)) {
975
+ if (attachment.size > MAX_TEXT_ATTACHMENT_SIZE_BYTES) {
976
+ logger5.warn({
977
+ messageId: message.id,
978
+ filename: attachment.name,
979
+ size: attachment.size
980
+ }, "attachment too large, skipping");
927
981
  continue;
928
982
  }
929
- const textBlocks = [];
930
- for (const item of content) {
931
- if (typeof item === "object" && item !== null && "type" in item && item.type === "text") {
932
- textBlocks.push(item.text);
983
+ try {
984
+ logger5.info({
985
+ messageId: message.id,
986
+ filename: attachment.name,
987
+ size: attachment.size
988
+ }, "fetching attachment");
989
+ const response = await fetch(attachment.url);
990
+ if (!response.ok) {
991
+ logger5.warn({
992
+ messageId: message.id,
993
+ filename: attachment.name,
994
+ status: response.status
995
+ }, "failed to fetch attachment");
996
+ continue;
933
997
  }
998
+ const content = await response.text();
999
+ results.push({ filename: attachment.name, content });
1000
+ } catch (error) {
1001
+ logger5.error({ messageId: message.id, filename: attachment.name, error }, "error fetching attachment");
934
1002
  }
935
- return textBlocks.join(`
936
- `).trim();
937
1003
  }
938
- return "";
1004
+ return results;
939
1005
  }
940
- function getMediaType(mimeType) {
941
- if (mimeType.startsWith("image/")) {
942
- return "image";
1006
+ async function readMediaAttachments(message) {
1007
+ const attachments = message.attachments;
1008
+ if (attachments.size === 0) {
1009
+ return [];
943
1010
  }
944
- return "document";
945
- }
946
- function isAssistantMessage(msg) {
947
- return typeof msg === "object" && msg !== null && "role" in msg && msg.role === "assistant";
1011
+ const results = [];
1012
+ for (const [, attachment] of attachments) {
1013
+ if (!isSupportedMediaAttachment(attachment)) {
1014
+ continue;
1015
+ }
1016
+ if (attachment.size > MAX_MEDIA_ATTACHMENT_SIZE_BYTES) {
1017
+ logger5.warn({
1018
+ messageId: message.id,
1019
+ filename: attachment.name,
1020
+ size: attachment.size
1021
+ }, "media attachment too large, skipping");
1022
+ continue;
1023
+ }
1024
+ try {
1025
+ logger5.info({
1026
+ messageId: message.id,
1027
+ filename: attachment.name,
1028
+ size: attachment.size
1029
+ }, "fetching media attachment");
1030
+ const response = await fetch(attachment.url);
1031
+ if (!response.ok) {
1032
+ logger5.warn({
1033
+ messageId: message.id,
1034
+ filename: attachment.name,
1035
+ status: response.status
1036
+ }, "failed to fetch media attachment");
1037
+ continue;
1038
+ }
1039
+ const buffer = await response.arrayBuffer();
1040
+ results.push({
1041
+ filename: attachment.name,
1042
+ data: Buffer.from(buffer).toString("base64"),
1043
+ mimeType: attachment.contentType ?? "application/octet-stream"
1044
+ });
1045
+ } catch (error) {
1046
+ logger5.error({ messageId: message.id, filename: attachment.name, error }, "error fetching media attachment");
1047
+ }
1048
+ }
1049
+ return results;
1050
+ }
1051
+
1052
+ // src/discord-auth.ts
1053
+ import { ChannelType } from "discord.js";
1054
+ function getAuthorDisplayName(message) {
1055
+ return message.member?.displayName || message.author.globalName || message.author.username;
1056
+ }
1057
+ function resolveMessageScope(message) {
1058
+ if (message.channel.type === ChannelType.DM) {
1059
+ return "dm";
1060
+ }
1061
+ if (message.channel.isThread()) {
1062
+ return `thread:${message.channel.id}`;
1063
+ }
1064
+ return null;
1065
+ }
1066
+ function isAuthorizedMessage(message, scope, authConfig) {
1067
+ if (scope === "dm") {
1068
+ return message.author.id === authConfig.discordAllowedUserId;
1069
+ }
1070
+ if (scope.startsWith("thread:")) {
1071
+ const channel = message.channel;
1072
+ if (!channel.isThread()) {
1073
+ return false;
1074
+ }
1075
+ const parentId = channel.parentId;
1076
+ if (!parentId || !authConfig.discordAllowedForumChannelIds.includes(parentId)) {
1077
+ return false;
1078
+ }
1079
+ return authConfig.discordAllowedUserIds.includes(message.author.id);
1080
+ }
1081
+ return false;
948
1082
  }
949
1083
 
950
1084
  // src/message-chunker.ts
@@ -978,102 +1112,8 @@ function chunkMessage(text, maxChunkSize = SAFE_MESSAGE_LIMIT) {
978
1112
  return chunks.map((chunk) => chunk.slice(0, DISCORD_MESSAGE_LIMIT));
979
1113
  }
980
1114
 
981
- // src/prompt-context.ts
982
- function buildDiscordMessageContextPrompt(userMessage, options) {
983
- const contextEntries = [
984
- ["scope", options.scope],
985
- ["sent_at", options.sentAt],
986
- ["sent_at_local", options.sentAtLocal],
987
- ["message_id", options.messageId],
988
- ["author_name", normalizeContextValue(options.authorName)],
989
- ["author_id", options.authorId],
990
- ["thread_title", normalizeContextValue(options.threadTitle)],
991
- ["thread_id", options.threadId],
992
- ["forum_channel_id", options.forumChannelId ?? undefined]
993
- ].filter((entry) => {
994
- return typeof entry[1] === "string" && entry[1].trim().length > 0;
995
- });
996
- const contextJson = JSON.stringify(Object.fromEntries(contextEntries), null, 2);
997
- return [
998
- "<discord_message_context>",
999
- contextJson,
1000
- "</discord_message_context>",
1001
- "",
1002
- "User message:",
1003
- userMessage.trim()
1004
- ].join(`
1005
- `);
1006
- }
1007
- function formatDiscordPromptTime(date, options = {}) {
1008
- const timeZone = options.timeZone || "UTC";
1009
- const locale = options.locale || "en-AU";
1010
- return new Intl.DateTimeFormat(locale, {
1011
- timeZone,
1012
- weekday: "short",
1013
- day: "numeric",
1014
- month: "short",
1015
- year: "2-digit",
1016
- hour: "2-digit",
1017
- minute: "2-digit",
1018
- hour12: false,
1019
- timeZoneName: "short"
1020
- }).format(date);
1021
- }
1022
- function normalizeContextValue(value) {
1023
- if (value === undefined) {
1024
- return;
1025
- }
1026
- return value.replace(/\s+/g, " ").trim();
1027
- }
1028
-
1029
- // src/discord-gateway-client.ts
1030
- var logger6 = createModuleLogger("discord-gateway");
1031
- function getAuthorDisplayName(message) {
1032
- return message.member?.displayName || message.author.globalName || message.author.username;
1033
- }
1034
- function buildDiscordPromptContent(message, scope, content, config) {
1035
- const isThread = scope.startsWith("thread:") && message.channel.isThread();
1036
- return buildDiscordMessageContextPrompt(content, {
1037
- scope: scope === "dm" ? "dm" : "thread",
1038
- sentAt: message.createdAt.toISOString(),
1039
- sentAtLocal: formatDiscordPromptTime(message.createdAt, {
1040
- timeZone: config.promptTimeZone,
1041
- locale: config.promptLocale
1042
- }),
1043
- messageId: message.id,
1044
- authorId: message.author.id,
1045
- authorName: getAuthorDisplayName(message),
1046
- threadId: isThread ? message.channel.id : undefined,
1047
- threadTitle: isThread ? message.channel.name : undefined,
1048
- forumChannelId: isThread ? message.channel.parentId : undefined
1049
- });
1050
- }
1051
- function resolveScope(message) {
1052
- if (message.channel.type === ChannelType.DM) {
1053
- return "dm";
1054
- }
1055
- if (message.channel.isThread()) {
1056
- return `thread:${message.channel.id}`;
1057
- }
1058
- return null;
1059
- }
1060
- function isAuthorized(message, scope, authConfig) {
1061
- if (scope === "dm") {
1062
- return message.author.id === authConfig.discordAllowedUserId;
1063
- }
1064
- if (scope.startsWith("thread:")) {
1065
- const channel = message.channel;
1066
- if (!channel.isThread()) {
1067
- return false;
1068
- }
1069
- const parentId = channel.parentId;
1070
- if (!parentId || !authConfig.discordAllowedForumChannelIds.includes(parentId)) {
1071
- return false;
1072
- }
1073
- return authConfig.discordAllowedUserIds.includes(message.author.id);
1074
- }
1075
- return false;
1076
- }
1115
+ // src/discord-replies.ts
1116
+ var logger6 = createModuleLogger("discord-replies");
1077
1117
  var WORKING_EMOJI = "⚙️";
1078
1118
  async function addWorkingReaction(message) {
1079
1119
  try {
@@ -1092,72 +1132,6 @@ async function removeWorkingReaction(message) {
1092
1132
  logger6.debug({ messageId: message.id, error }, "failed to remove working reaction");
1093
1133
  }
1094
1134
  }
1095
- var TYPING_INTERVAL_MS = 9000;
1096
- var typingIntervals = new Map;
1097
- async function sendTypingSafe(channel, channelKey) {
1098
- try {
1099
- const token = channel.client.token;
1100
- const url = `https://discord.com/api/v10/channels/${channel.id}/typing`;
1101
- const res = await fetch(url, {
1102
- method: "POST",
1103
- headers: { Authorization: `Bot ${token}` }
1104
- });
1105
- if (res.ok) {
1106
- logger6.debug(`[TYPING] STATUS UPDATED OK`);
1107
- return;
1108
- }
1109
- if (res.status === 429) {
1110
- const body = await res.text();
1111
- let retryMs = 3000;
1112
- try {
1113
- const parsed = JSON.parse(body);
1114
- if (typeof parsed.retry_after === "number") {
1115
- retryMs = parsed.retry_after * 1000 + 500;
1116
- }
1117
- } catch {}
1118
- logger6.warn({ channelKey, retryMs, response: body }, `[TYPING] 429, retrying after ${retryMs}ms delay`);
1119
- await new Promise((resolve) => setTimeout(resolve, retryMs));
1120
- await fetch(url, {
1121
- method: "POST",
1122
- headers: { Authorization: `Bot ${token}` }
1123
- });
1124
- logger6.info({ channelKey }, "[TYPING] retry done");
1125
- return;
1126
- }
1127
- logger6.warn({ channelKey, status: res.status }, "[TYPING] unexpected status");
1128
- } catch (error) {
1129
- logger6.warn({ channelKey, error }, "[TYPING] FAILED");
1130
- }
1131
- }
1132
- function startTypingForChannel(channel, channelKey) {
1133
- const existing = typingIntervals.get(channelKey);
1134
- if (existing) {
1135
- existing.refs += 1;
1136
- logger6.debug({ channelKey, refs: existing.refs }, "[TYPING] ref++ (reusing existing interval)");
1137
- return;
1138
- }
1139
- logger6.debug("[TYPING] started new interval");
1140
- sendTypingSafe(channel, channelKey);
1141
- const interval = setInterval(() => {
1142
- sendTypingSafe(channel, channelKey);
1143
- }, TYPING_INTERVAL_MS);
1144
- typingIntervals.set(channelKey, { interval, refs: 1 });
1145
- }
1146
- function stopTypingForChannel(channelKey) {
1147
- const entry = typingIntervals.get(channelKey);
1148
- if (!entry) {
1149
- logger6.debug({ channelKey }, "[TYPING] stop called but no entry found");
1150
- return;
1151
- }
1152
- entry.refs -= 1;
1153
- if (entry.refs <= 0) {
1154
- clearInterval(entry.interval);
1155
- typingIntervals.delete(channelKey);
1156
- logger6.debug("[TYPING] interval cleared (refs hit 0)");
1157
- } else {
1158
- logger6.debug("[TYPING] ref-- (interval still active)");
1159
- }
1160
- }
1161
1135
  async function sendReply(message, text) {
1162
1136
  const channel = message.channel;
1163
1137
  if (!channel.isSendable()) {
@@ -1183,146 +1157,76 @@ async function sendReply(message, text) {
1183
1157
  }, "send reply failed");
1184
1158
  }
1185
1159
  }
1186
- var TEXT_ATTACHMENT_EXTENSIONS = [
1187
- ".txt",
1188
- ".md",
1189
- ".json",
1190
- ".csv",
1191
- ".log",
1192
- ".yml",
1193
- ".yaml",
1194
- ".xml",
1195
- ".toml",
1196
- ".ini",
1197
- ".cfg"
1198
- ];
1199
- var MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
1200
- async function readTextAttachments(message) {
1201
- const attachments = message.attachments;
1202
- if (attachments.size === 0) {
1203
- return [];
1204
- }
1205
- const results = [];
1206
- for (const [, attachment] of attachments) {
1207
- const ext = attachment.name?.slice(attachment.name.lastIndexOf(".")).toLowerCase();
1208
- if (!ext || !TEXT_ATTACHMENT_EXTENSIONS.includes(ext)) {
1209
- logger6.debug({ messageId: message.id, filename: attachment.name, ext }, "skipping non-text attachment");
1210
- continue;
1211
- }
1212
- if (attachment.size > MAX_ATTACHMENT_SIZE_BYTES) {
1213
- logger6.warn({
1214
- messageId: message.id,
1215
- filename: attachment.name,
1216
- size: attachment.size
1217
- }, "attachment too large, skipping");
1218
- continue;
1219
- }
1220
- try {
1221
- logger6.info({
1222
- messageId: message.id,
1223
- filename: attachment.name,
1224
- size: attachment.size
1225
- }, "fetching attachment");
1226
- const response = await fetch(attachment.url);
1227
- if (!response.ok) {
1228
- logger6.warn({
1229
- messageId: message.id,
1230
- filename: attachment.name,
1231
- status: response.status
1232
- }, "failed to fetch attachment");
1233
- continue;
1234
- }
1235
- const content = await response.text();
1236
- results.push({ filename: attachment.name, content });
1237
- } catch (error) {
1238
- logger6.error({ messageId: message.id, filename: attachment.name, error }, "error fetching attachment");
1239
- }
1240
- }
1241
- return results;
1242
- }
1243
- var MEDIA_ATTACHMENT_EXTENSIONS = [
1244
- ".png",
1245
- ".jpg",
1246
- ".jpeg",
1247
- ".gif",
1248
- ".webp",
1249
- ".pdf",
1250
- ".docx",
1251
- ".doc",
1252
- ".pptx",
1253
- ".ppt",
1254
- ".xlsx",
1255
- ".xls"
1256
- ];
1257
- var MAX_MEDIA_ATTACHMENT_SIZE = 25 * 1024 * 1024;
1258
- var OFFICE_MIME_TYPES = new Set([
1259
- "application/pdf",
1260
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1261
- "application/msword",
1262
- "application/vnd.openxmlformats-officedocument.presentationml.presentation",
1263
- "application/vnd.ms-powerpoint",
1264
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1265
- "application/vnd.ms-excel"
1266
- ]);
1267
- function isMediaAttachment(attachment) {
1268
- const ext = attachment.name?.slice(attachment.name.lastIndexOf(".")).toLowerCase();
1269
- if (!ext || !MEDIA_ATTACHMENT_EXTENSIONS.includes(ext)) {
1270
- return false;
1271
- }
1272
- const ct = attachment.contentType;
1273
- if (!ct) {
1274
- return false;
1160
+
1161
+ // src/image-description.ts
1162
+ var logger7 = createModuleLogger("image-description");
1163
+ async function describeMediaAttachment(agentService, imageData, mimeType, userText, visionModel) {
1164
+ const session = await agentService.createTemporarySession();
1165
+ await session.setModel(visionModel);
1166
+ const mediaType = getMediaType(mimeType);
1167
+ const imageContent = {
1168
+ type: "image",
1169
+ data: imageData,
1170
+ mimeType
1171
+ };
1172
+ let promptText;
1173
+ if (mediaType === "document") {
1174
+ promptText = userText.trim().length > 0 ? `The user sent a document with the following message: "${userText}". Please extract and summarize the text content of this document. Be thorough — include all important details, sections, and data from the document.` : "Please extract and summarize the text content of this document. Be thorough — include all important details, sections, data, and key points.";
1175
+ } else {
1176
+ promptText = userText.trim().length > 0 ? `The user sent this image with the following message: "${userText}". Please describe the image in detail and address any questions from the user's message.` : "Please describe this image in detail. What do you see?";
1275
1177
  }
1276
- return ct.startsWith("image/") || OFFICE_MIME_TYPES.has(ct);
1277
- }
1278
- async function readMediaAttachments(message) {
1279
- const attachments = message.attachments;
1280
- if (attachments.size === 0) {
1281
- return [];
1178
+ let text = "";
1179
+ try {
1180
+ await session.prompt(promptText, { images: [imageContent] });
1181
+ text = extractLastAssistantText(session);
1182
+ } catch (error) {
1183
+ logger7.error({ error, mimeType }, "vision model prompt failed");
1184
+ text = "(Vision model failed to process the file.)";
1185
+ } finally {
1186
+ session.dispose();
1282
1187
  }
1283
- const results = [];
1284
- for (const [, attachment] of attachments) {
1285
- if (!isMediaAttachment(attachment)) {
1188
+ if (!text) {
1189
+ return "(Vision model returned no description.)";
1190
+ }
1191
+ logger7.debug({ textLength: text.length, mimeType }, "media described");
1192
+ return text;
1193
+ }
1194
+ function extractLastAssistantText(session) {
1195
+ const messages = session.messages;
1196
+ for (let i = messages.length - 1;i >= 0; i--) {
1197
+ const msg = messages[i];
1198
+ if (!msg || !isAssistantMessage(msg)) {
1286
1199
  continue;
1287
1200
  }
1288
- if (attachment.size > MAX_MEDIA_ATTACHMENT_SIZE) {
1289
- logger6.warn({
1290
- messageId: message.id,
1291
- filename: attachment.name,
1292
- size: attachment.size
1293
- }, "media attachment too large, skipping");
1201
+ const content = msg.content;
1202
+ if (!Array.isArray(content)) {
1294
1203
  continue;
1295
1204
  }
1296
- try {
1297
- logger6.info({
1298
- messageId: message.id,
1299
- filename: attachment.name,
1300
- size: attachment.size
1301
- }, "fetching media attachment");
1302
- const response = await fetch(attachment.url);
1303
- if (!response.ok) {
1304
- logger6.warn({
1305
- messageId: message.id,
1306
- filename: attachment.name,
1307
- status: response.status
1308
- }, "failed to fetch media attachment");
1309
- continue;
1205
+ const textBlocks = [];
1206
+ for (const item of content) {
1207
+ if (typeof item === "object" && item !== null && "type" in item && item.type === "text") {
1208
+ textBlocks.push(item.text);
1310
1209
  }
1311
- const buffer = await response.arrayBuffer();
1312
- const base64 = Buffer.from(buffer).toString("base64");
1313
- results.push({
1314
- filename: attachment.name,
1315
- data: base64,
1316
- mimeType: attachment.contentType ?? "application/octet-stream"
1317
- });
1318
- } catch (error) {
1319
- logger6.error({ messageId: message.id, filename: attachment.name, error }, "error fetching media attachment");
1320
1210
  }
1211
+ return textBlocks.join(`
1212
+ `).trim();
1321
1213
  }
1322
- return results;
1214
+ return "";
1215
+ }
1216
+ function getMediaType(mimeType) {
1217
+ if (mimeType.startsWith("image/")) {
1218
+ return "image";
1219
+ }
1220
+ return "document";
1221
+ }
1222
+ function isAssistantMessage(msg) {
1223
+ return typeof msg === "object" && msg !== null && "role" in msg && msg.role === "assistant";
1323
1224
  }
1324
- function parseVisionModelId(visionModelId) {
1325
- const trimmed = visionModelId.trim();
1225
+
1226
+ // src/discord-media-resolution.ts
1227
+ var logger8 = createModuleLogger("discord-media-resolution");
1228
+ function parseProviderModelId(value) {
1229
+ const trimmed = value.trim();
1326
1230
  if (!trimmed) {
1327
1231
  return null;
1328
1232
  }
@@ -1335,7 +1239,7 @@ function parseVisionModelId(visionModelId) {
1335
1239
  modelId: trimmed.substring(slashIndex + 1)
1336
1240
  };
1337
1241
  }
1338
- function getMediaLabel(filename, mimeType) {
1242
+ function getMediaAttachmentLabel(filename, mimeType) {
1339
1243
  if (mimeType === "application/pdf") {
1340
1244
  return `[PDF: ${filename}]`;
1341
1245
  }
@@ -1350,131 +1254,234 @@ function getMediaLabel(filename, mimeType) {
1350
1254
  }
1351
1255
  return `[Image: ${filename}]`;
1352
1256
  }
1353
- async function resolveMediaAttachments(media, content, currentModel, config, agentService) {
1257
+ async function resolveMediaAttachmentsForPrompt(mediaAttachments, content, currentModel, config, agentService) {
1354
1258
  const modelSupportsVision = currentModel?.input.includes("image") ?? false;
1355
1259
  if (modelSupportsVision) {
1356
- const names = media.map((m) => m.filename).join(", ");
1357
- logger6.info({
1358
- count: media.length,
1260
+ const names = mediaAttachments.map((media) => media.filename).join(", ");
1261
+ logger8.info({
1262
+ count: mediaAttachments.length,
1359
1263
  filenames: names,
1360
1264
  model: currentModel ? `${currentModel.provider}/${currentModel.id}` : "none"
1361
1265
  }, "passing media natively to vision-capable model");
1362
- const images = media.map((m) => ({
1363
- type: "image",
1364
- data: m.data,
1365
- mimeType: m.mimeType
1366
- }));
1367
- return { content, images };
1266
+ return {
1267
+ content,
1268
+ images: mediaAttachments.map((media) => ({
1269
+ type: "image",
1270
+ data: media.data,
1271
+ mimeType: media.mimeType
1272
+ }))
1273
+ };
1368
1274
  }
1369
1275
  if (!config.visionModelId) {
1370
- const names = media.map((m) => m.filename).join(", ");
1371
- logger6.info({ filenames: names }, "media attachments received but vision model not configured");
1276
+ const names = mediaAttachments.map((media) => media.filename).join(", ");
1277
+ logger8.info({ filenames: names }, "media attachments received but vision model not configured");
1372
1278
  const note = `
1373
1279
 
1374
1280
  [User sent media attachment(s): ${names}]
1375
1281
  ` + "(Media vision not configured. Set visionModelId to enable image/PDF/document understanding.)";
1376
- return { content: content ? content + note : note, images: [] };
1282
+ return {
1283
+ content: content ? content + note : note,
1284
+ images: []
1285
+ };
1377
1286
  }
1378
- const parsed = parseVisionModelId(config.visionModelId);
1379
- if (!parsed) {
1287
+ const parsedVisionModelId = parseProviderModelId(config.visionModelId);
1288
+ if (!parsedVisionModelId) {
1380
1289
  return { content, images: [] };
1381
1290
  }
1382
- const visionModel = agentService.findModel(parsed.provider, parsed.modelId);
1291
+ const visionModel = agentService.findModel(parsedVisionModelId.provider, parsedVisionModelId.modelId);
1383
1292
  if (!visionModel) {
1384
- logger6.warn({ visionModelId: config.visionModelId }, "vision model not found in registry");
1385
- const names = media.map((m) => m.filename).join(", ");
1293
+ logger8.warn({ visionModelId: config.visionModelId }, "vision model not found in registry");
1294
+ const names = mediaAttachments.map((media) => media.filename).join(", ");
1386
1295
  const note = `
1387
1296
 
1388
1297
  [User sent media attachment(s): ${names}]
1389
- (Vision model not found: ${config.visionModelId})`;
1390
- return { content: content ? content + note : note, images: [] };
1298
+ ` + `(Vision model not found: ${config.visionModelId})`;
1299
+ return {
1300
+ content: content ? content + note : note,
1301
+ images: []
1302
+ };
1391
1303
  }
1392
- logger6.info({
1393
- count: media.length,
1304
+ logger8.info({
1305
+ count: mediaAttachments.length,
1394
1306
  visionModel: `${visionModel.provider}/${visionModel.id}`
1395
1307
  }, "describing media with vision model");
1396
1308
  const descriptions = [];
1397
- for (const m of media) {
1398
- const description = await describeImage(agentService, m.data, m.mimeType, content, visionModel);
1399
- const label = getMediaLabel(m.filename, m.mimeType);
1309
+ for (const media of mediaAttachments) {
1310
+ const description = await describeMediaAttachment(agentService, media.data, media.mimeType, content, visionModel);
1311
+ const label = getMediaAttachmentLabel(media.filename, media.mimeType);
1400
1312
  descriptions.push(`${label}
1401
1313
  ${description}`);
1402
1314
  }
1403
- if (descriptions.length > 0) {
1404
- const prefix = descriptions.join(`
1315
+ if (descriptions.length === 0) {
1316
+ return { content, images: [] };
1317
+ }
1318
+ const descriptionPrefix = descriptions.join(`
1405
1319
 
1406
1320
  `);
1407
- return {
1408
- content: content ? `${prefix}
1321
+ return {
1322
+ content: content ? `${descriptionPrefix}
1409
1323
 
1410
1324
  ---
1411
- ${content}` : prefix,
1412
- images: []
1413
- };
1414
- }
1415
- return { content, images: [] };
1325
+ ${content}` : descriptionPrefix,
1326
+ images: []
1327
+ };
1416
1328
  }
1417
- async function startGatewayClient(config, agentService, sessionRegistry, authConfig) {
1418
- const client = new Client({
1419
- intents: [
1420
- GatewayIntentBits.DirectMessages,
1421
- GatewayIntentBits.Guilds,
1422
- GatewayIntentBits.GuildMessages,
1423
- GatewayIntentBits.MessageContent
1424
- ],
1425
- partials: [Partials.Channel]
1426
- });
1427
- client.once(Events.ClientReady, async (readyClient) => {
1428
- logger6.info({ userTag: readyClient.user.tag }, "logged in");
1429
- if (!authConfig.startupMessage) {
1329
+
1330
+ // src/discord-typing.ts
1331
+ var logger9 = createModuleLogger("discord-typing");
1332
+ var TYPING_INTERVAL_MS = 9000;
1333
+ var typingIntervals = new Map;
1334
+ async function sendTypingSafe(channel, channelKey) {
1335
+ try {
1336
+ const token = channel.client.token;
1337
+ const url = `https://discord.com/api/v10/channels/${channel.id}/typing`;
1338
+ const response = await fetch(url, {
1339
+ method: "POST",
1340
+ headers: { Authorization: `Bot ${token}` }
1341
+ });
1342
+ if (response.ok) {
1343
+ logger9.debug("[TYPING] STATUS UPDATED OK");
1430
1344
  return;
1431
1345
  }
1432
- try {
1433
- const user = await readyClient.users.fetch(authConfig.discordAllowedUserId);
1434
- const dmChannel = await user.createDM();
1435
- await dmChannel.send(authConfig.startupMessage);
1436
- logger6.info({
1437
- userId: authConfig.discordAllowedUserId
1438
- }, "sent startup dm");
1439
- } catch (error) {
1440
- logger6.error({ error }, "failed to send startup dm");
1441
- }
1442
- });
1443
- client.on(Events.MessageCreate, async (message) => {
1444
- try {
1445
- await onMessage(message, config, agentService, sessionRegistry, authConfig);
1446
- } catch (error) {
1447
- logger6.error({ error, direction: "IN" }, "message handling failed");
1448
- await sendReply(message, "The bot hit an error while handling that message.");
1346
+ if (response.status === 429) {
1347
+ const body = await response.text();
1348
+ let retryMs = 3000;
1349
+ try {
1350
+ const parsed = JSON.parse(body);
1351
+ if (typeof parsed.retry_after === "number") {
1352
+ retryMs = parsed.retry_after * 1000 + 500;
1353
+ }
1354
+ } catch {}
1355
+ logger9.warn({ channelKey, retryMs, response: body }, `[TYPING] 429, retrying after ${retryMs}ms delay`);
1356
+ await new Promise((resolve) => setTimeout(resolve, retryMs));
1357
+ await fetch(url, {
1358
+ method: "POST",
1359
+ headers: { Authorization: `Bot ${token}` }
1360
+ });
1361
+ logger9.info({ channelKey }, "[TYPING] retry done");
1362
+ return;
1449
1363
  }
1364
+ logger9.warn({ channelKey, status: response.status }, "[TYPING] unexpected status");
1365
+ } catch (error) {
1366
+ logger9.warn({ channelKey, error }, "[TYPING] FAILED");
1367
+ }
1368
+ }
1369
+ function startTypingForChannel(channel, channelKey) {
1370
+ const existing = typingIntervals.get(channelKey);
1371
+ if (existing) {
1372
+ existing.refs += 1;
1373
+ logger9.debug({ channelKey, refs: existing.refs }, "[TYPING] ref++ (reusing existing interval)");
1374
+ return;
1375
+ }
1376
+ logger9.debug("[TYPING] started new interval");
1377
+ sendTypingSafe(channel, channelKey);
1378
+ const interval = setInterval(() => {
1379
+ sendTypingSafe(channel, channelKey);
1380
+ }, TYPING_INTERVAL_MS);
1381
+ typingIntervals.set(channelKey, { interval, refs: 1 });
1382
+ }
1383
+ function stopTypingForChannel(channelKey) {
1384
+ const entry = typingIntervals.get(channelKey);
1385
+ if (!entry) {
1386
+ logger9.debug({ channelKey }, "[TYPING] stop called but no entry found");
1387
+ return;
1388
+ }
1389
+ entry.refs -= 1;
1390
+ if (entry.refs <= 0) {
1391
+ clearInterval(entry.interval);
1392
+ typingIntervals.delete(channelKey);
1393
+ logger9.debug("[TYPING] interval cleared (refs hit 0)");
1394
+ return;
1395
+ }
1396
+ logger9.debug("[TYPING] ref-- (interval still active)");
1397
+ }
1398
+
1399
+ // src/prompt-context.ts
1400
+ function buildDiscordMessageContextPrompt(userMessage, options) {
1401
+ const contextEntries = [
1402
+ ["scope", options.scope],
1403
+ ["sent_at", options.sentAt],
1404
+ ["sent_at_local", options.sentAtLocal],
1405
+ ["message_id", options.messageId],
1406
+ ["author_name", normalizeContextValue(options.authorName)],
1407
+ ["author_id", options.authorId],
1408
+ ["thread_title", normalizeContextValue(options.threadTitle)],
1409
+ ["thread_id", options.threadId],
1410
+ ["forum_channel_id", options.forumChannelId ?? undefined]
1411
+ ].filter((entry) => {
1412
+ return typeof entry[1] === "string" && entry[1].trim().length > 0;
1450
1413
  });
1451
- client.on(Events.ThreadDelete, async (thread) => {
1452
- const scope = `thread:${thread.id}`;
1453
- logger6.info({ threadId: thread.id, scope }, "thread deleted");
1454
- await sessionRegistry.remove(scope);
1414
+ const contextJson = JSON.stringify(Object.fromEntries(contextEntries), null, 2);
1415
+ return [
1416
+ "<discord_message_context>",
1417
+ contextJson,
1418
+ "</discord_message_context>",
1419
+ "",
1420
+ "User message:",
1421
+ userMessage.trim()
1422
+ ].join(`
1423
+ `);
1424
+ }
1425
+ function formatDiscordPromptTime(date, options = {}) {
1426
+ const timeZone = options.timeZone || "UTC";
1427
+ const locale = options.locale || "en-AU";
1428
+ return new Intl.DateTimeFormat(locale, {
1429
+ timeZone,
1430
+ weekday: "short",
1431
+ day: "numeric",
1432
+ month: "short",
1433
+ year: "2-digit",
1434
+ hour: "2-digit",
1435
+ minute: "2-digit",
1436
+ hour12: false,
1437
+ timeZoneName: "short"
1438
+ }).format(date);
1439
+ }
1440
+ function normalizeContextValue(value) {
1441
+ if (value === undefined) {
1442
+ return;
1443
+ }
1444
+ return value.replace(/\s+/g, " ").trim();
1445
+ }
1446
+
1447
+ // src/discord-message-handler.ts
1448
+ var logger10 = createModuleLogger("discord-message-handler");
1449
+ function buildDiscordPromptContent(message, scope, content, config) {
1450
+ const isThread = scope.startsWith("thread:") && message.channel.isThread();
1451
+ return buildDiscordMessageContextPrompt(content, {
1452
+ scope: scope === "dm" ? "dm" : "thread",
1453
+ sentAt: message.createdAt.toISOString(),
1454
+ sentAtLocal: formatDiscordPromptTime(message.createdAt, {
1455
+ timeZone: config.promptTimeZone,
1456
+ locale: config.promptLocale
1457
+ }),
1458
+ messageId: message.id,
1459
+ authorId: message.author.id,
1460
+ authorName: getAuthorDisplayName(message),
1461
+ threadId: isThread ? message.channel.id : undefined,
1462
+ threadTitle: isThread ? message.channel.name : undefined,
1463
+ forumChannelId: isThread ? message.channel.parentId : undefined
1455
1464
  });
1456
- await client.login(config.discordBotToken);
1457
- return client;
1458
1465
  }
1459
- async function onMessage(message, config, agentService, sessionRegistry, authConfig) {
1466
+ async function handleDiscordMessage(message, config, agentService, sessionRegistry, authConfig) {
1460
1467
  if (message.author.bot) {
1461
- logger6.debug("ignored bot message");
1468
+ logger10.debug("ignored bot message");
1462
1469
  return;
1463
1470
  }
1464
1471
  if (message.system) {
1465
- logger6.debug({ messageId: message.id }, "ignored system message");
1472
+ logger10.debug({ messageId: message.id }, "ignored system message");
1466
1473
  return;
1467
1474
  }
1468
- const scope = resolveScope(message);
1475
+ const scope = resolveMessageScope(message);
1469
1476
  if (scope === null) {
1470
- logger6.debug({
1477
+ logger10.debug({
1471
1478
  messageId: message.id,
1472
1479
  channelType: message.channel.type
1473
1480
  }, "unsupported channel type, ignoring");
1474
1481
  return;
1475
1482
  }
1476
- if (!isAuthorized(message, scope, authConfig)) {
1477
- logger6.debug({
1483
+ if (!isAuthorizedMessage(message, scope, authConfig)) {
1484
+ logger10.debug({
1478
1485
  messageId: message.id,
1479
1486
  authorId: message.author.id,
1480
1487
  scope
@@ -1482,20 +1489,22 @@ async function onMessage(message, config, agentService, sessionRegistry, authCon
1482
1489
  return;
1483
1490
  }
1484
1491
  let content = message.content.trim();
1485
- const attachmentContents = await readTextAttachments(message);
1486
- if (attachmentContents.length > 0) {
1487
- const suffix = attachmentContents.map((a) => `
1492
+ const textAttachments = await readTextAttachments(message);
1493
+ if (textAttachments.length > 0) {
1494
+ const attachmentSuffix = textAttachments.map((attachment) => {
1495
+ return `
1488
1496
 
1489
- --- Attachment: ${a.filename} ---
1490
- ${a.content}`).join("");
1491
- content = content ? content + suffix : attachmentContents[0].content;
1497
+ --- Attachment: ${attachment.filename} ---
1498
+ ${attachment.content}`;
1499
+ }).join("");
1500
+ content = content ? content + attachmentSuffix : textAttachments[0].content;
1492
1501
  }
1493
1502
  const mediaAttachments = await readMediaAttachments(message);
1494
1503
  if (!content && mediaAttachments.length === 0) {
1495
- logger6.debug({ messageId: message.id }, "ignored empty message (no text or images)");
1504
+ logger10.debug({ messageId: message.id }, "ignored empty message (no text or images)");
1496
1505
  return;
1497
1506
  }
1498
- logger6.info({
1507
+ logger10.info({
1499
1508
  direction: "IN",
1500
1509
  scope,
1501
1510
  messageId: message.id,
@@ -1504,19 +1513,18 @@ ${a.content}`).join("");
1504
1513
  content
1505
1514
  }, "message received");
1506
1515
  const channelKey = message.channel.id;
1507
- const canSend = message.channel.isSendable();
1508
- if (canSend) {
1516
+ if (message.channel.isSendable()) {
1509
1517
  startTypingForChannel(message.channel, channelKey);
1510
1518
  }
1511
1519
  const { entry, created } = await sessionRegistry.getOrCreate(scope);
1512
1520
  const { session, promptQueue } = entry;
1513
1521
  if (created && scope.startsWith("thread:") && message.channel.isThread()) {
1514
- logger6.info({
1522
+ logger10.info({
1515
1523
  scope,
1516
1524
  threadName: message.channel.name
1517
1525
  }, "new thread session");
1518
1526
  }
1519
- const commandResult = await handleCommand(content, {
1527
+ const commandResult = await executeCommand(content, {
1520
1528
  agentService,
1521
1529
  promptQueue,
1522
1530
  session
@@ -1524,7 +1532,7 @@ ${a.content}`).join("");
1524
1532
  if (commandResult.handled) {
1525
1533
  stopTypingForChannel(channelKey);
1526
1534
  if (commandResult.archive && scope.startsWith("thread:")) {
1527
- logger6.info({ scope }, "archiving thread");
1535
+ logger10.info({ scope }, "archiving thread");
1528
1536
  const archiveChannel = message.channel;
1529
1537
  if (archiveChannel.isSendable()) {
1530
1538
  await archiveChannel.send(commandResult.response ?? "Archiving...");
@@ -1534,12 +1542,12 @@ ${a.content}`).join("");
1534
1542
  await archiveChannel.setArchived(true);
1535
1543
  }
1536
1544
  } catch (error) {
1537
- logger6.error({ error }, "failed to archive thread");
1545
+ logger10.error({ error }, "failed to archive thread");
1538
1546
  }
1539
1547
  await sessionRegistry.remove(scope);
1540
1548
  return;
1541
1549
  }
1542
- logger6.info({
1550
+ logger10.info({
1543
1551
  messageId: message.id,
1544
1552
  command: content,
1545
1553
  hasResponse: Boolean(commandResult.response)
@@ -1551,7 +1559,7 @@ ${a.content}`).join("");
1551
1559
  }
1552
1560
  if (!message.channel.isSendable()) {
1553
1561
  stopTypingForChannel(channelKey);
1554
- logger6.debug({ messageId: message.id }, "channel not sendable");
1562
+ logger10.debug({ messageId: message.id }, "channel not sendable");
1555
1563
  return;
1556
1564
  }
1557
1565
  await addWorkingReaction(message);
@@ -1565,15 +1573,15 @@ ${a.content}`).join("");
1565
1573
  let promptContent = content;
1566
1574
  let promptImages;
1567
1575
  if (mediaAttachments.length > 0) {
1568
- const resolved = await resolveMediaAttachments(mediaAttachments, promptContent, session.model, config, agentService);
1569
- promptContent = resolved.content;
1570
- if (resolved.images.length > 0) {
1571
- promptImages = resolved.images;
1576
+ const resolvedPromptMedia = await resolveMediaAttachmentsForPrompt(mediaAttachments, promptContent, session.model, config, agentService);
1577
+ promptContent = resolvedPromptMedia.content;
1578
+ if (resolvedPromptMedia.images.length > 0) {
1579
+ promptImages = resolvedPromptMedia.images;
1572
1580
  }
1573
1581
  }
1574
1582
  const wrappedContent = buildDiscordPromptContent(message, scope, promptContent, config);
1575
1583
  const transformedPrompt = await config.promptTransform(wrappedContent);
1576
- return collectReply(session, transformedPrompt, {
1584
+ return runPromptAndCollectReply(session, transformedPrompt, {
1577
1585
  logPrefix: `[agent:${session.sessionId}]`,
1578
1586
  images: promptImages
1579
1587
  });
@@ -1585,6 +1593,51 @@ ${a.content}`).join("");
1585
1593
  await sendReply(message, response);
1586
1594
  }
1587
1595
 
1596
+ // src/discord-gateway-client.ts
1597
+ var logger11 = createModuleLogger("discord-gateway");
1598
+ async function startGatewayClient(config, agentService, sessionRegistry, authConfig) {
1599
+ const client = new Client({
1600
+ intents: [
1601
+ GatewayIntentBits.DirectMessages,
1602
+ GatewayIntentBits.Guilds,
1603
+ GatewayIntentBits.GuildMessages,
1604
+ GatewayIntentBits.MessageContent
1605
+ ],
1606
+ partials: [Partials.Channel]
1607
+ });
1608
+ client.once(Events.ClientReady, async (readyClient) => {
1609
+ logger11.info({ userTag: readyClient.user.tag }, "logged in");
1610
+ if (!authConfig.startupMessage) {
1611
+ return;
1612
+ }
1613
+ try {
1614
+ const user = await readyClient.users.fetch(authConfig.discordAllowedUserId);
1615
+ const dmChannel = await user.createDM();
1616
+ await dmChannel.send(authConfig.startupMessage);
1617
+ logger11.info({
1618
+ userId: authConfig.discordAllowedUserId
1619
+ }, "sent startup dm");
1620
+ } catch (error) {
1621
+ logger11.error({ error }, "failed to send startup dm");
1622
+ }
1623
+ });
1624
+ client.on(Events.MessageCreate, async (message) => {
1625
+ try {
1626
+ await handleDiscordMessage(message, config, agentService, sessionRegistry, authConfig);
1627
+ } catch (error) {
1628
+ logger11.error({ error, direction: "IN" }, "message handling failed");
1629
+ await sendReply(message, "The bot hit an error while handling that message.");
1630
+ }
1631
+ });
1632
+ client.on(Events.ThreadDelete, async (thread) => {
1633
+ const scope = `thread:${thread.id}`;
1634
+ logger11.info({ threadId: thread.id, scope }, "thread deleted");
1635
+ await sessionRegistry.remove(scope);
1636
+ });
1637
+ await client.login(config.discordBotToken);
1638
+ return client;
1639
+ }
1640
+
1588
1641
  // src/session-registry.ts
1589
1642
  import path3 from "node:path";
1590
1643
 
@@ -1601,7 +1654,7 @@ class PromptQueue {
1601
1654
  reject(error);
1602
1655
  }
1603
1656
  });
1604
- this.kick();
1657
+ this.runNextTask();
1605
1658
  });
1606
1659
  }
1607
1660
  getSnapshot() {
@@ -1610,7 +1663,7 @@ class PromptQueue {
1610
1663
  busy: this.running
1611
1664
  };
1612
1665
  }
1613
- kick() {
1666
+ runNextTask() {
1614
1667
  if (this.running) {
1615
1668
  return;
1616
1669
  }
@@ -1621,7 +1674,7 @@ class PromptQueue {
1621
1674
  this.running = true;
1622
1675
  next().finally(() => {
1623
1676
  this.running = false;
1624
- this.kick();
1677
+ this.runNextTask();
1625
1678
  });
1626
1679
  }
1627
1680
  }
@@ -1637,7 +1690,7 @@ function sessionDirForScope(agentDir, scope) {
1637
1690
  }
1638
1691
  throw new Error(`Unknown session scope: ${scope}`);
1639
1692
  }
1640
- var logger7 = createModuleLogger("session-registry");
1693
+ var logger12 = createModuleLogger("session-registry");
1641
1694
 
1642
1695
  class SessionRegistry {
1643
1696
  scopes = new Map;
@@ -1659,7 +1712,7 @@ class SessionRegistry {
1659
1712
  createdAt: new Date
1660
1713
  };
1661
1714
  this.scopes.set(scope, entry);
1662
- logger7.debug({
1715
+ logger12.debug({
1663
1716
  scope,
1664
1717
  sessionDir,
1665
1718
  sessionId: session.sessionId
@@ -1671,7 +1724,7 @@ class SessionRegistry {
1671
1724
  if (!entry) {
1672
1725
  return;
1673
1726
  }
1674
- logger7.debug({ scope }, "removing scope");
1727
+ logger12.debug({ scope }, "removing scope");
1675
1728
  await entry.session.abort();
1676
1729
  entry.session.dispose();
1677
1730
  this.scopes.delete(scope);
@@ -1683,7 +1736,7 @@ class SessionRegistry {
1683
1736
  return Array.from(this.scopes.keys());
1684
1737
  }
1685
1738
  async shutdownAll() {
1686
- logger7.info({ count: this.scopes.size }, "shutting down all scopes");
1739
+ logger12.info({ count: this.scopes.size }, "shutting down all scopes");
1687
1740
  const scopes = Array.from(this.scopes.keys());
1688
1741
  for (const scope of scopes) {
1689
1742
  await this.remove(scope);
@@ -1692,13 +1745,13 @@ class SessionRegistry {
1692
1745
  }
1693
1746
 
1694
1747
  // src/index.ts
1695
- var logger8 = createModuleLogger("index");
1748
+ var logger13 = createModuleLogger("index");
1696
1749
  async function startDiscordGateway(config) {
1697
- const resolvedConfig = resolveGatewayConfig(config);
1750
+ const resolvedConfig = resolveConfig(config);
1698
1751
  const agentService = new AgentService(resolvedConfig);
1699
- logger8.info("initializing agent service");
1752
+ logger13.info("initializing agent service");
1700
1753
  await agentService.initialize();
1701
- logger8.info(agentService.getStatus(), "agent ready");
1754
+ logger13.info(agentService.getStatus(), "agent ready");
1702
1755
  const authConfig = {
1703
1756
  discordAllowedUserId: resolvedConfig.discordAllowedUserId,
1704
1757
  discordAllowedForumChannelIds: resolvedConfig.discordAllowedForumChannelIds,
@@ -1719,9 +1772,6 @@ async function startDiscordGateway(config) {
1719
1772
  }
1720
1773
  };
1721
1774
  }
1722
- async function startDiscordPiBridge(config) {
1723
- return startDiscordGateway(config);
1724
- }
1725
1775
  function createGatewayStopHandler(client, agentService, sessionRegistry, config) {
1726
1776
  let stopped = false;
1727
1777
  return async () => {
@@ -1729,7 +1779,7 @@ function createGatewayStopHandler(client, agentService, sessionRegistry, config)
1729
1779
  return;
1730
1780
  }
1731
1781
  stopped = true;
1732
- logger8.info({
1782
+ logger13.info({
1733
1783
  cwd: config.cwd,
1734
1784
  agentDir: config.agentDir
1735
1785
  }, "stopping discord gateway");
@@ -1740,9 +1790,9 @@ function createGatewayStopHandler(client, agentService, sessionRegistry, config)
1740
1790
  }
1741
1791
  function registerSignalHandlers(stop) {
1742
1792
  const handleSignal = (signal) => {
1743
- logger8.info({ signal }, "received signal");
1793
+ logger13.info({ signal }, "received signal");
1744
1794
  stop().finally(() => {
1745
- logger8.info("done");
1795
+ logger13.info("done");
1746
1796
  process.exit(0);
1747
1797
  });
1748
1798
  };
@@ -1754,10 +1804,8 @@ function registerSignalHandlers(stop) {
1754
1804
  });
1755
1805
  }
1756
1806
  export {
1757
- startDiscordPiBridge,
1758
1807
  startDiscordGateway,
1759
1808
  resolveConfig,
1760
- loadDiscordPiBridgeConfigFromEnv,
1761
1809
  loadDiscordGatewayConfigFromEnv,
1762
1810
  formatDiscordPromptTime,
1763
1811
  buildDiscordMessageContextPrompt