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