@friendlyrobot/discord-pi-agent 0.20.1 → 0.21.1

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/README.md CHANGED
@@ -43,8 +43,9 @@ Every Discord prompt is wrapped with lightweight Discord context before `promptT
43
43
  }
44
44
  </discord_message_context>
45
45
 
46
- User message:
46
+ <user_message>
47
47
  ...
48
+ </user_message>
48
49
  ```
49
50
 
50
51
  DM prompts omit thread-only fields. `sent_at_local` uses `promptTimeZone` and `promptLocale`.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { DiscordGateway, DiscordGatewayConfig } from "./types";
2
- export { buildDiscordMessageContextPrompt, formatDiscordPromptTime, type DiscordMessageContextPromptOptions, type DiscordPromptScope, type DiscordPromptTimeFormatOptions, } from "./prompt-context";
2
+ export { formatDiscordPromptTime } from "./prompt-context";
3
3
  export { loadDiscordGatewayConfigFromEnv, resolveConfig } from "./config";
4
- export type { AgentStatus, DiscordGateway, DiscordGatewayConfig, PromptTransform, ResolvedDiscordGatewayConfig, GatewayAccessConfig, } from "./types";
4
+ export type { AgentStatus, DiscordGateway, DiscordGatewayConfig, PromptTransform, PromptTransformContext, ResolvedDiscordGatewayConfig, GatewayAccessConfig, } from "./types";
5
5
  /**
6
6
  * Start the unified Discord gateway. Supports DM and forum thread sessions
7
7
  * out of the box. Set discordAllowedForumChannelIds to enable forum support.
package/dist/index.js CHANGED
@@ -506,7 +506,12 @@ class AgentService {
506
506
  }
507
507
  async prompt(text) {
508
508
  const session = this.requireSession();
509
- const transformedPrompt = await this.config.promptTransform(text);
509
+ const transformedPrompt = await this.config.promptTransform({
510
+ rawContent: text,
511
+ discordMetadata: "",
512
+ now: () => "",
513
+ userMessage: () => text
514
+ });
510
515
  return runAgentTurn(session, transformedPrompt);
511
516
  }
512
517
  async compact() {
@@ -603,7 +608,7 @@ function resolveConfig(config) {
603
608
  thinkingLevel: parseThinkingLevel(config.thinkingLevel) || "medium",
604
609
  promptTimeZone: config.promptTimeZone?.trim() || "UTC",
605
610
  promptLocale: config.promptLocale?.trim() || "en-AU",
606
- promptTransform: config.promptTransform || identityPromptTransform,
611
+ promptTransform: config.promptTransform || defaultPromptTransform,
607
612
  startupMessage: config.startupMessage === undefined ? "Bot is online and ready." : config.startupMessage,
608
613
  shutdownOnSignals: config.shutdownOnSignals ?? true,
609
614
  visionModelId: config.visionModelId?.trim() || null,
@@ -669,8 +674,9 @@ function parseThinkingLevel(value) {
669
674
  }
670
675
  return;
671
676
  }
672
- function identityPromptTransform(input) {
673
- return input;
677
+ function defaultPromptTransform(ctx) {
678
+ return [ctx.now(), ctx.discordMetadata, "", ctx.userMessage()].filter(Boolean).join(`
679
+ `);
674
680
  }
675
681
  function parseStringArrayFromEnv(key) {
676
682
  const value = process.env[key];
@@ -683,6 +689,245 @@ function parseStringArrayFromEnv(key) {
683
689
  // src/discord-gateway-client.ts
684
690
  import { Client, Events, GatewayIntentBits, Partials } from "discord.js";
685
691
 
692
+ // src/session-registry.ts
693
+ import path3 from "node:path";
694
+
695
+ // src/prompt-queue.ts
696
+ class PromptQueue {
697
+ queue = [];
698
+ running = false;
699
+ enqueue(task) {
700
+ return new Promise((resolve, reject) => {
701
+ this.queue.push(async () => {
702
+ try {
703
+ resolve(await task());
704
+ } catch (error) {
705
+ reject(error);
706
+ }
707
+ });
708
+ this.runNextTask();
709
+ });
710
+ }
711
+ getSnapshot() {
712
+ return {
713
+ pending: this.queue.length,
714
+ busy: this.running
715
+ };
716
+ }
717
+ runNextTask() {
718
+ if (this.running) {
719
+ return;
720
+ }
721
+ const next = this.queue.shift();
722
+ if (!next) {
723
+ return;
724
+ }
725
+ this.running = true;
726
+ next().finally(() => {
727
+ this.running = false;
728
+ this.runNextTask();
729
+ });
730
+ }
731
+ }
732
+
733
+ // src/message-chunker.ts
734
+ import { marked } from "marked";
735
+ var DISCORD_MESSAGE_LIMIT = 2000;
736
+ var SAFE_MESSAGE_LIMIT = 1900;
737
+ function chunkMessage(text, maxChunkSize = SAFE_MESSAGE_LIMIT) {
738
+ if (text.length <= maxChunkSize) {
739
+ return [text];
740
+ }
741
+ const tokens = marked.lexer(text);
742
+ const chunks = [];
743
+ let currentTokens = [];
744
+ let currentSize = 0;
745
+ const flushChunk = () => {
746
+ if (currentTokens.length > 0) {
747
+ chunks.push(currentTokens.map((t) => t.raw).join("").trim());
748
+ currentTokens = [];
749
+ currentSize = 0;
750
+ }
751
+ };
752
+ for (const token of tokens) {
753
+ const size = token.raw.length;
754
+ if (currentSize + size > maxChunkSize && currentTokens.length > 0) {
755
+ flushChunk();
756
+ }
757
+ currentTokens.push(token);
758
+ currentSize += size;
759
+ }
760
+ flushChunk();
761
+ return chunks.map((chunk) => chunk.slice(0, DISCORD_MESSAGE_LIMIT));
762
+ }
763
+
764
+ // src/discord-replies.ts
765
+ var logger6 = createModuleLogger("discord-replies");
766
+ var DISCORD_MESSAGE_LIMIT2 = 2000;
767
+ var FENCE_OVERHEAD = 8;
768
+ var MAX_CODE_FENCE_CONTENT = DISCORD_MESSAGE_LIMIT2 - FENCE_OVERHEAD;
769
+ function chunkByLines(text, maxSize) {
770
+ const lines = text.split(`
771
+ `);
772
+ const chunks = [];
773
+ let current = "";
774
+ for (const line of lines) {
775
+ const candidate = current ? current + `
776
+ ` + line : line;
777
+ if (candidate.length > maxSize) {
778
+ if (current) {
779
+ chunks.push(current);
780
+ current = line;
781
+ } else {
782
+ chunks.push(line.slice(0, maxSize));
783
+ current = line.slice(maxSize);
784
+ }
785
+ } else {
786
+ current = candidate;
787
+ }
788
+ }
789
+ if (current) {
790
+ chunks.push(current);
791
+ }
792
+ return chunks;
793
+ }
794
+ var DEFAULT_WORKING_EMOJI = "⚙️";
795
+ async function addWorkingReaction(message, emoji = DEFAULT_WORKING_EMOJI) {
796
+ try {
797
+ await message.react(emoji);
798
+ } catch (error) {
799
+ logger6.debug({ messageId: message.id, error }, "failed to add working reaction");
800
+ }
801
+ }
802
+ async function removeWorkingReaction(message, emoji = DEFAULT_WORKING_EMOJI) {
803
+ try {
804
+ const reaction = message.reactions.cache.get(emoji);
805
+ if (reaction) {
806
+ await reaction.users.remove(message.client.user);
807
+ }
808
+ } catch (error) {
809
+ logger6.debug({ messageId: message.id, error }, "failed to remove working reaction");
810
+ }
811
+ }
812
+ async function sendReply(message, text) {
813
+ const channel = message.channel;
814
+ if (!channel.isSendable()) {
815
+ logger6.debug({
816
+ messageId: message.id
817
+ }, "reply skipped, channel not sendable");
818
+ return;
819
+ }
820
+ const chunks = chunkMessage(text);
821
+ const [firstChunk, ...remainingChunks] = chunks;
822
+ if (!firstChunk) {
823
+ return;
824
+ }
825
+ try {
826
+ await message.reply(firstChunk);
827
+ for (const chunk of remainingChunks) {
828
+ await channel.send(chunk);
829
+ }
830
+ } catch (error) {
831
+ logger6.error({
832
+ messageId: message.id,
833
+ error
834
+ }, "send reply failed");
835
+ }
836
+ }
837
+ async function sendCommandReply(message, text) {
838
+ const channel = message.channel;
839
+ if (!channel.isSendable()) {
840
+ logger6.debug({
841
+ messageId: message.id
842
+ }, "command reply skipped, channel not sendable");
843
+ return;
844
+ }
845
+ const chunks = chunkByLines(text, MAX_CODE_FENCE_CONTENT).map((c) => `\`\`\`
846
+ ${c}
847
+ \`\`\``);
848
+ const [firstChunk, ...remainingChunks] = chunks;
849
+ if (!firstChunk) {
850
+ return;
851
+ }
852
+ try {
853
+ await message.reply(firstChunk);
854
+ for (const chunk of remainingChunks) {
855
+ await channel.send(chunk);
856
+ }
857
+ } catch (error) {
858
+ logger6.error({
859
+ messageId: message.id,
860
+ error
861
+ }, "send command reply failed");
862
+ }
863
+ }
864
+
865
+ // src/session-registry.ts
866
+ function sessionDirForScope(agentDir, scope) {
867
+ if (scope === "dm") {
868
+ return path3.join(agentDir, "sessions");
869
+ }
870
+ if (scope.startsWith("thread:")) {
871
+ const threadId = scope.slice(7);
872
+ return path3.join(agentDir, "sessions", `thread-${threadId}`);
873
+ }
874
+ throw new Error(`Unknown session scope: ${scope}`);
875
+ }
876
+ var logger7 = createModuleLogger("session-registry");
877
+
878
+ class SessionRegistry {
879
+ scopes = new Map;
880
+ agentService;
881
+ constructor(agentService) {
882
+ this.agentService = agentService;
883
+ }
884
+ async getOrCreate(scope) {
885
+ const existing = this.scopes.get(scope);
886
+ if (existing) {
887
+ return { entry: existing, created: false };
888
+ }
889
+ const sessionDir = sessionDirForScope(this.agentService.getAgentDir(), scope);
890
+ const session = await this.agentService.createSession(sessionDir);
891
+ const promptQueue = new PromptQueue;
892
+ const entry = {
893
+ session,
894
+ promptQueue,
895
+ createdAt: new Date,
896
+ workingEmoji: DEFAULT_WORKING_EMOJI
897
+ };
898
+ this.scopes.set(scope, entry);
899
+ logger7.debug({
900
+ scope,
901
+ sessionDir,
902
+ sessionId: session.sessionId
903
+ }, "scope registered");
904
+ return { entry, created: true };
905
+ }
906
+ async remove(scope) {
907
+ const entry = this.scopes.get(scope);
908
+ if (!entry) {
909
+ return;
910
+ }
911
+ logger7.debug({ scope }, "removing scope");
912
+ await entry.session.abort();
913
+ entry.session.dispose();
914
+ this.scopes.delete(scope);
915
+ }
916
+ get(scope) {
917
+ return this.scopes.get(scope);
918
+ }
919
+ getScopes() {
920
+ return Array.from(this.scopes.keys());
921
+ }
922
+ async shutdownAll() {
923
+ logger7.info({ count: this.scopes.size }, "shutting down all scopes");
924
+ const scopes = Array.from(this.scopes.keys());
925
+ for (const scope of scopes) {
926
+ await this.remove(scope);
927
+ }
928
+ }
929
+ }
930
+
686
931
  // src/session-commands.ts
687
932
  function getSessionStatusText(session, promptQueue, extras) {
688
933
  const model = session.model ? `${session.model.provider}/${session.model.id}` : "(no model selected)";
@@ -886,22 +1131,23 @@ async function handleResetSessionCommand(trimmedInput, context) {
886
1131
  if (trimmedInput !== "!reset-session") {
887
1132
  return null;
888
1133
  }
889
- if (context.session) {
890
- return {
891
- handled: true,
892
- response: await context.promptQueue.enqueue(async () => {
893
- const previousSession = context.session;
894
- await previousSession.abort();
895
- previousSession.dispose();
896
- return `Session reset. Old session kept at ${previousSession.sessionFile ?? "(unknown path)"}. Use !archive to archive the thread and start fresh.`;
897
- })
898
- };
1134
+ const effectiveSession = requireEffectiveSession(context);
1135
+ if ("handled" in effectiveSession) {
1136
+ return effectiveSession;
899
1137
  }
1138
+ let newSession;
1139
+ const response = await context.promptQueue.enqueue(async () => {
1140
+ const previousSessionFile = effectiveSession.session.sessionFile;
1141
+ await effectiveSession.session.abort();
1142
+ effectiveSession.session.dispose();
1143
+ const sessionDir = sessionDirForScope(context.agentService.getAgentDir(), context.scope);
1144
+ newSession = await context.agentService.createSession(sessionDir);
1145
+ return `Started a fresh session. Old session kept at ${previousSessionFile ?? "(unknown path)"}.`;
1146
+ });
900
1147
  return {
901
1148
  handled: true,
902
- response: await context.promptQueue.enqueue(async () => {
903
- return context.agentService.resetSession();
904
- })
1149
+ response,
1150
+ newSession
905
1151
  };
906
1152
  }
907
1153
  async function handleReactionCommand(trimmedInput, _context) {
@@ -959,7 +1205,7 @@ async function executeSessionCommand(input, context) {
959
1205
  }
960
1206
 
961
1207
  // src/discord-attachments.ts
962
- var logger6 = createModuleLogger("discord-attachments");
1208
+ var logger8 = createModuleLogger("discord-attachments");
963
1209
  var TEXT_ATTACHMENT_EXTENSIONS = [
964
1210
  ".txt",
965
1211
  ".md",
@@ -1021,11 +1267,11 @@ async function readTextAttachments(message) {
1021
1267
  const results = [];
1022
1268
  for (const [, attachment] of attachments) {
1023
1269
  if (!isSupportedTextAttachment(attachment)) {
1024
- logger6.debug({ messageId: message.id, filename: attachment.name }, "skipping non-text attachment");
1270
+ logger8.debug({ messageId: message.id, filename: attachment.name }, "skipping non-text attachment");
1025
1271
  continue;
1026
1272
  }
1027
1273
  if (attachment.size > MAX_TEXT_ATTACHMENT_SIZE_BYTES) {
1028
- logger6.warn({
1274
+ logger8.warn({
1029
1275
  messageId: message.id,
1030
1276
  filename: attachment.name,
1031
1277
  size: attachment.size
@@ -1033,14 +1279,14 @@ async function readTextAttachments(message) {
1033
1279
  continue;
1034
1280
  }
1035
1281
  try {
1036
- logger6.info({
1282
+ logger8.info({
1037
1283
  messageId: message.id,
1038
1284
  filename: attachment.name,
1039
1285
  size: attachment.size
1040
1286
  }, "fetching attachment");
1041
1287
  const response = await fetch(attachment.url);
1042
1288
  if (!response.ok) {
1043
- logger6.warn({
1289
+ logger8.warn({
1044
1290
  messageId: message.id,
1045
1291
  filename: attachment.name,
1046
1292
  status: response.status
@@ -1050,7 +1296,7 @@ async function readTextAttachments(message) {
1050
1296
  const content = await response.text();
1051
1297
  results.push({ filename: attachment.name, content });
1052
1298
  } catch (error) {
1053
- logger6.error({ messageId: message.id, filename: attachment.name, error }, "error fetching attachment");
1299
+ logger8.error({ messageId: message.id, filename: attachment.name, error }, "error fetching attachment");
1054
1300
  }
1055
1301
  }
1056
1302
  return results;
@@ -1066,7 +1312,7 @@ async function readMediaAttachments(message) {
1066
1312
  continue;
1067
1313
  }
1068
1314
  if (attachment.size > MAX_MEDIA_ATTACHMENT_SIZE_BYTES) {
1069
- logger6.warn({
1315
+ logger8.warn({
1070
1316
  messageId: message.id,
1071
1317
  filename: attachment.name,
1072
1318
  size: attachment.size
@@ -1074,14 +1320,14 @@ async function readMediaAttachments(message) {
1074
1320
  continue;
1075
1321
  }
1076
1322
  try {
1077
- logger6.info({
1323
+ logger8.info({
1078
1324
  messageId: message.id,
1079
1325
  filename: attachment.name,
1080
1326
  size: attachment.size
1081
1327
  }, "fetching media attachment");
1082
1328
  const response = await fetch(attachment.url);
1083
1329
  if (!response.ok) {
1084
- logger6.warn({
1330
+ logger8.warn({
1085
1331
  messageId: message.id,
1086
1332
  filename: attachment.name,
1087
1333
  status: response.status
@@ -1095,7 +1341,7 @@ async function readMediaAttachments(message) {
1095
1341
  mimeType: attachment.contentType ?? "application/octet-stream"
1096
1342
  });
1097
1343
  } catch (error) {
1098
- logger6.error({ messageId: message.id, filename: attachment.name, error }, "error fetching media attachment");
1344
+ logger8.error({ messageId: message.id, filename: attachment.name, error }, "error fetching media attachment");
1099
1345
  }
1100
1346
  }
1101
1347
  return results;
@@ -1133,140 +1379,8 @@ function isAuthorizedMessage(message, scope, accessConfig) {
1133
1379
  return false;
1134
1380
  }
1135
1381
 
1136
- // src/message-chunker.ts
1137
- import { marked } from "marked";
1138
- var DISCORD_MESSAGE_LIMIT = 2000;
1139
- var SAFE_MESSAGE_LIMIT = 1900;
1140
- function chunkMessage(text, maxChunkSize = SAFE_MESSAGE_LIMIT) {
1141
- if (text.length <= maxChunkSize) {
1142
- return [text];
1143
- }
1144
- const tokens = marked.lexer(text);
1145
- const chunks = [];
1146
- let currentTokens = [];
1147
- let currentSize = 0;
1148
- const flushChunk = () => {
1149
- if (currentTokens.length > 0) {
1150
- chunks.push(currentTokens.map((t) => t.raw).join("").trim());
1151
- currentTokens = [];
1152
- currentSize = 0;
1153
- }
1154
- };
1155
- for (const token of tokens) {
1156
- const size = token.raw.length;
1157
- if (currentSize + size > maxChunkSize && currentTokens.length > 0) {
1158
- flushChunk();
1159
- }
1160
- currentTokens.push(token);
1161
- currentSize += size;
1162
- }
1163
- flushChunk();
1164
- return chunks.map((chunk) => chunk.slice(0, DISCORD_MESSAGE_LIMIT));
1165
- }
1166
-
1167
- // src/discord-replies.ts
1168
- var logger7 = createModuleLogger("discord-replies");
1169
- var DISCORD_MESSAGE_LIMIT2 = 2000;
1170
- var FENCE_OVERHEAD = 8;
1171
- var MAX_CODE_FENCE_CONTENT = DISCORD_MESSAGE_LIMIT2 - FENCE_OVERHEAD;
1172
- function chunkByLines(text, maxSize) {
1173
- const lines = text.split(`
1174
- `);
1175
- const chunks = [];
1176
- let current = "";
1177
- for (const line of lines) {
1178
- const candidate = current ? current + `
1179
- ` + line : line;
1180
- if (candidate.length > maxSize) {
1181
- if (current) {
1182
- chunks.push(current);
1183
- current = line;
1184
- } else {
1185
- chunks.push(line.slice(0, maxSize));
1186
- current = line.slice(maxSize);
1187
- }
1188
- } else {
1189
- current = candidate;
1190
- }
1191
- }
1192
- if (current) {
1193
- chunks.push(current);
1194
- }
1195
- return chunks;
1196
- }
1197
- var DEFAULT_WORKING_EMOJI = "⚙️";
1198
- async function addWorkingReaction(message, emoji = DEFAULT_WORKING_EMOJI) {
1199
- try {
1200
- await message.react(emoji);
1201
- } catch (error) {
1202
- logger7.debug({ messageId: message.id, error }, "failed to add working reaction");
1203
- }
1204
- }
1205
- async function removeWorkingReaction(message, emoji = DEFAULT_WORKING_EMOJI) {
1206
- try {
1207
- const reaction = message.reactions.cache.get(emoji);
1208
- if (reaction) {
1209
- await reaction.users.remove(message.client.user);
1210
- }
1211
- } catch (error) {
1212
- logger7.debug({ messageId: message.id, error }, "failed to remove working reaction");
1213
- }
1214
- }
1215
- async function sendReply(message, text) {
1216
- const channel = message.channel;
1217
- if (!channel.isSendable()) {
1218
- logger7.debug({
1219
- messageId: message.id
1220
- }, "reply skipped, channel not sendable");
1221
- return;
1222
- }
1223
- const chunks = chunkMessage(text);
1224
- const [firstChunk, ...remainingChunks] = chunks;
1225
- if (!firstChunk) {
1226
- return;
1227
- }
1228
- try {
1229
- await message.reply(firstChunk);
1230
- for (const chunk of remainingChunks) {
1231
- await channel.send(chunk);
1232
- }
1233
- } catch (error) {
1234
- logger7.error({
1235
- messageId: message.id,
1236
- error
1237
- }, "send reply failed");
1238
- }
1239
- }
1240
- async function sendCommandReply(message, text) {
1241
- const channel = message.channel;
1242
- if (!channel.isSendable()) {
1243
- logger7.debug({
1244
- messageId: message.id
1245
- }, "command reply skipped, channel not sendable");
1246
- return;
1247
- }
1248
- const chunks = chunkByLines(text, MAX_CODE_FENCE_CONTENT).map((c) => `\`\`\`
1249
- ${c}
1250
- \`\`\``);
1251
- const [firstChunk, ...remainingChunks] = chunks;
1252
- if (!firstChunk) {
1253
- return;
1254
- }
1255
- try {
1256
- await message.reply(firstChunk);
1257
- for (const chunk of remainingChunks) {
1258
- await channel.send(chunk);
1259
- }
1260
- } catch (error) {
1261
- logger7.error({
1262
- messageId: message.id,
1263
- error
1264
- }, "send command reply failed");
1265
- }
1266
- }
1267
-
1268
1382
  // src/media-description.ts
1269
- var logger8 = createModuleLogger("media-description");
1383
+ var logger9 = createModuleLogger("media-description");
1270
1384
  async function describeMediaAttachment(agentService, imageData, mimeType, userText, visionModel) {
1271
1385
  const session = await agentService.createTemporarySession();
1272
1386
  await session.setModel(visionModel);
@@ -1287,7 +1401,7 @@ async function describeMediaAttachment(agentService, imageData, mimeType, userTe
1287
1401
  await session.prompt(promptText, { images: [imageContent] });
1288
1402
  text = extractLastAssistantText(session);
1289
1403
  } catch (error) {
1290
- logger8.error({ error, mimeType }, "vision model prompt failed");
1404
+ logger9.error({ error, mimeType }, "vision model prompt failed");
1291
1405
  text = "(Vision model failed to process the file.)";
1292
1406
  } finally {
1293
1407
  session.dispose();
@@ -1295,7 +1409,7 @@ async function describeMediaAttachment(agentService, imageData, mimeType, userTe
1295
1409
  if (!text) {
1296
1410
  return "(Vision model returned no description.)";
1297
1411
  }
1298
- logger8.debug({ textLength: text.length, mimeType }, "media described");
1412
+ logger9.debug({ textLength: text.length, mimeType }, "media described");
1299
1413
  return text;
1300
1414
  }
1301
1415
  function extractLastAssistantText(session) {
@@ -1331,7 +1445,7 @@ function isAssistantMessage(msg) {
1331
1445
  }
1332
1446
 
1333
1447
  // src/discord-media-resolution.ts
1334
- var logger9 = createModuleLogger("discord-media-resolution");
1448
+ var logger10 = createModuleLogger("discord-media-resolution");
1335
1449
  function parseProviderModelId(value) {
1336
1450
  const trimmed = value.trim();
1337
1451
  if (!trimmed) {
@@ -1365,7 +1479,7 @@ async function resolveMediaAttachmentsForPrompt(mediaAttachments, content, curre
1365
1479
  const modelSupportsVision = currentModel?.input.includes("image") ?? false;
1366
1480
  if (modelSupportsVision) {
1367
1481
  const names = mediaAttachments.map((media) => media.filename).join(", ");
1368
- logger9.info({
1482
+ logger10.info({
1369
1483
  count: mediaAttachments.length,
1370
1484
  filenames: names,
1371
1485
  model: currentModel ? `${currentModel.provider}/${currentModel.id}` : "none"
@@ -1381,7 +1495,7 @@ async function resolveMediaAttachmentsForPrompt(mediaAttachments, content, curre
1381
1495
  }
1382
1496
  if (!config.visionModelId) {
1383
1497
  const names = mediaAttachments.map((media) => media.filename).join(", ");
1384
- logger9.info({ filenames: names }, "media attachments received but vision model not configured");
1498
+ logger10.info({ filenames: names }, "media attachments received but vision model not configured");
1385
1499
  const note = `
1386
1500
 
1387
1501
  [User sent media attachment(s): ${names}]
@@ -1397,7 +1511,7 @@ async function resolveMediaAttachmentsForPrompt(mediaAttachments, content, curre
1397
1511
  }
1398
1512
  const visionModel = agentService.models.findModel(parsedVisionModelId.provider, parsedVisionModelId.modelId);
1399
1513
  if (!visionModel) {
1400
- logger9.warn({ visionModelId: config.visionModelId }, "vision model not found in registry");
1514
+ logger10.warn({ visionModelId: config.visionModelId }, "vision model not found in registry");
1401
1515
  const names = mediaAttachments.map((media) => media.filename).join(", ");
1402
1516
  const note = `
1403
1517
 
@@ -1408,7 +1522,7 @@ async function resolveMediaAttachmentsForPrompt(mediaAttachments, content, curre
1408
1522
  images: []
1409
1523
  };
1410
1524
  }
1411
- logger9.info({
1525
+ logger10.info({
1412
1526
  count: mediaAttachments.length,
1413
1527
  visionModel: `${visionModel.provider}/${visionModel.id}`
1414
1528
  }, "describing media with vision model");
@@ -1435,7 +1549,7 @@ ${content}` : descriptionPrefix,
1435
1549
  }
1436
1550
 
1437
1551
  // src/discord-typing.ts
1438
- var logger10 = createModuleLogger("discord-typing");
1552
+ var logger11 = createModuleLogger("discord-typing");
1439
1553
  var TYPING_INTERVAL_MS = 9000;
1440
1554
  var typingIntervals = new Map;
1441
1555
  async function sendTypingSafe(channel, channelKey) {
@@ -1458,18 +1572,18 @@ async function sendTypingSafe(channel, channelKey) {
1458
1572
  retryMs = parsed.retry_after * 1000 + 500;
1459
1573
  }
1460
1574
  } catch {}
1461
- logger10.warn({ channelKey, retryMs, response: body }, `[TYPING] 429, retrying after ${retryMs}ms delay`);
1575
+ logger11.warn({ channelKey, retryMs, response: body }, `[TYPING] 429, retrying after ${retryMs}ms delay`);
1462
1576
  await new Promise((resolve) => setTimeout(resolve, retryMs));
1463
1577
  await fetch(url, {
1464
1578
  method: "POST",
1465
1579
  headers: { Authorization: `Bot ${token}` }
1466
1580
  });
1467
- logger10.warn({ channelKey }, "[TYPING] retry done");
1581
+ logger11.warn({ channelKey }, "[TYPING] retry done");
1468
1582
  return;
1469
1583
  }
1470
- logger10.warn({ channelKey, status: response.status }, "[TYPING] unexpected status");
1584
+ logger11.warn({ channelKey, status: response.status }, "[TYPING] unexpected status");
1471
1585
  } catch (error) {
1472
- logger10.error({ channelKey, error }, "[TYPING] FAILED");
1586
+ logger11.error({ channelKey, error }, "[TYPING] FAILED");
1473
1587
  }
1474
1588
  }
1475
1589
  function startTypingForChannel(channel, channelKey) {
@@ -1478,7 +1592,7 @@ function startTypingForChannel(channel, channelKey) {
1478
1592
  existing.refs += 1;
1479
1593
  return;
1480
1594
  }
1481
- logger10.debug("[TYPING] started new interval");
1595
+ logger11.debug("[TYPING] started new interval");
1482
1596
  sendTypingSafe(channel, channelKey);
1483
1597
  const interval = setInterval(() => {
1484
1598
  sendTypingSafe(channel, channelKey);
@@ -1499,31 +1613,6 @@ function stopTypingForChannel(channelKey) {
1499
1613
  }
1500
1614
 
1501
1615
  // src/prompt-context.ts
1502
- function buildDiscordMessageContextPrompt(userMessage, options) {
1503
- const contextEntries = [
1504
- ["scope", options.scope],
1505
- ["sent_at", options.sentAt],
1506
- ["sent_at_local", options.sentAtLocal],
1507
- ["message_id", options.messageId],
1508
- ["author_name", normalizeContextValue(options.authorName)],
1509
- ["author_id", options.authorId],
1510
- ["thread_title", normalizeContextValue(options.threadTitle)],
1511
- ["thread_id", options.threadId],
1512
- ["forum_channel_id", options.forumChannelId ?? undefined]
1513
- ].filter((entry) => {
1514
- return typeof entry[1] === "string" && entry[1].trim().length > 0;
1515
- });
1516
- const contextJson = JSON.stringify(Object.fromEntries(contextEntries), null, 2);
1517
- return [
1518
- "<discord_message_context>",
1519
- contextJson,
1520
- "</discord_message_context>",
1521
- "",
1522
- "User message:",
1523
- userMessage.trim()
1524
- ].join(`
1525
- `);
1526
- }
1527
1616
  function formatDiscordPromptTime(date, options = {}) {
1528
1617
  const timeZone = options.timeZone || "UTC";
1529
1618
  const locale = options.locale || "en-AU";
@@ -1539,31 +1628,38 @@ function formatDiscordPromptTime(date, options = {}) {
1539
1628
  timeZoneName: "short"
1540
1629
  }).format(date);
1541
1630
  }
1542
- function normalizeContextValue(value) {
1543
- if (value === undefined) {
1544
- return;
1545
- }
1546
- return value.replace(/\s+/g, " ").trim();
1631
+ function wrapXmlTag(tag, content) {
1632
+ return `<${tag}>${content}</${tag}>`;
1547
1633
  }
1548
1634
 
1549
1635
  // src/discord-message-handler.ts
1550
- var logger11 = createModuleLogger("discord-message-handler");
1551
- function buildDiscordPromptContent(message, scope, content, config) {
1636
+ var logger12 = createModuleLogger("discord-message-handler");
1637
+ function formatDiscordMessageMetadata(message, scope) {
1552
1638
  const isThread = scope.startsWith("thread:") && message.channel.isThread();
1553
- return buildDiscordMessageContextPrompt(content, {
1554
- scope: scope === "dm" ? "dm" : "thread",
1555
- sentAt: message.createdAt.toISOString(),
1556
- sentAtLocal: formatDiscordPromptTime(message.createdAt, {
1557
- timeZone: config.promptTimeZone,
1558
- locale: config.promptLocale
1559
- }),
1560
- messageId: message.id,
1561
- authorId: message.author.id,
1562
- authorName: getAuthorDisplayName(message),
1563
- threadId: isThread ? message.channel.id : undefined,
1564
- threadTitle: isThread ? message.channel.name : undefined,
1565
- forumChannelId: isThread ? message.channel.parentId : undefined
1639
+ const contextEntries = [
1640
+ ["scope", scope === "dm" ? "dm" : "thread"],
1641
+ ["sent_at", message.createdAt.toISOString()],
1642
+ ["sent_at_local", formatDiscordPromptTime(message.createdAt)],
1643
+ ["message_id", message.id],
1644
+ [
1645
+ "author_name",
1646
+ getAuthorDisplayName(message).replace(/\s+/g, " ").trim() || undefined
1647
+ ],
1648
+ ["author_id", message.author.id],
1649
+ [
1650
+ "thread_title",
1651
+ isThread ? (message.channel.name ?? "").replace(/\s+/g, " ").trim() : undefined
1652
+ ],
1653
+ ["thread_id", isThread ? message.channel.id : undefined],
1654
+ [
1655
+ "forum_channel_id",
1656
+ isThread ? message.channel.parentId ?? undefined : undefined
1657
+ ]
1658
+ ].filter((entry) => {
1659
+ return typeof entry[1] === "string" && entry[1].length > 0;
1566
1660
  });
1661
+ const contextJson = JSON.stringify(Object.fromEntries(contextEntries), null, 2);
1662
+ return `<discord_message_context>${contextJson}</discord_message_context>`;
1567
1663
  }
1568
1664
  async function handleDiscordMessage(message, config, agentService, sessionRegistry, accessConfig) {
1569
1665
  if (message.author.bot) {
@@ -1574,14 +1670,14 @@ async function handleDiscordMessage(message, config, agentService, sessionRegist
1574
1670
  }
1575
1671
  const scope = resolveMessageScope(message);
1576
1672
  if (scope === null) {
1577
- logger11.debug({
1673
+ logger12.debug({
1578
1674
  messageId: message.id,
1579
1675
  channelType: message.channel.type
1580
1676
  }, "unsupported channel type, ignoring");
1581
1677
  return;
1582
1678
  }
1583
1679
  if (!isAuthorizedMessage(message, scope, accessConfig)) {
1584
- logger11.debug({
1680
+ logger12.debug({
1585
1681
  messageId: message.id,
1586
1682
  authorId: message.author.id,
1587
1683
  scope
@@ -1601,10 +1697,10 @@ ${attachment.content}`;
1601
1697
  }
1602
1698
  const mediaAttachments = await readMediaAttachments(message);
1603
1699
  if (!content && mediaAttachments.length === 0) {
1604
- logger11.debug({ messageId: message.id }, "ignored empty message (no text or images)");
1700
+ logger12.debug({ messageId: message.id }, "ignored empty message (no text or images)");
1605
1701
  return;
1606
1702
  }
1607
- logger11.info({
1703
+ logger12.info({
1608
1704
  scope,
1609
1705
  content
1610
1706
  }, "message received");
@@ -1615,7 +1711,7 @@ ${attachment.content}`;
1615
1711
  const { entry, created } = await sessionRegistry.getOrCreate(scope);
1616
1712
  const { session, promptQueue } = entry;
1617
1713
  if (created && scope.startsWith("thread:") && message.channel.isThread()) {
1618
- logger11.info({
1714
+ logger12.info({
1619
1715
  scope,
1620
1716
  threadName: message.channel.name
1621
1717
  }, "new thread session");
@@ -1624,16 +1720,21 @@ ${attachment.content}`;
1624
1720
  agentService,
1625
1721
  promptQueue,
1626
1722
  session,
1723
+ scope,
1627
1724
  workingEmoji: entry.workingEmoji
1628
1725
  });
1629
1726
  if (commandResult.handled) {
1630
1727
  stopTypingForChannel(channelKey);
1631
1728
  if (commandResult.workingEmoji) {
1632
1729
  entry.workingEmoji = commandResult.workingEmoji;
1633
- logger11.info({ scope, emoji: commandResult.workingEmoji }, "working emoji updated");
1730
+ logger12.info({ scope, emoji: commandResult.workingEmoji }, "working emoji updated");
1731
+ }
1732
+ if (commandResult.newSession) {
1733
+ entry.session = commandResult.newSession;
1734
+ logger12.info({ scope, sessionId: commandResult.newSession.sessionId }, "session replaced");
1634
1735
  }
1635
1736
  if (commandResult.archive && scope.startsWith("thread:")) {
1636
- logger11.info({ scope }, "archiving thread");
1737
+ logger12.info({ scope }, "archiving thread");
1637
1738
  const archiveChannel = message.channel;
1638
1739
  if (archiveChannel.isSendable()) {
1639
1740
  await archiveChannel.send(`\`\`\`
@@ -1645,12 +1746,12 @@ ${commandResult.response ?? "Archiving..."}
1645
1746
  await archiveChannel.setArchived(true);
1646
1747
  }
1647
1748
  } catch (error) {
1648
- logger11.error({ error }, "failed to archive thread");
1749
+ logger12.error({ error }, "failed to archive thread");
1649
1750
  }
1650
1751
  await sessionRegistry.remove(scope);
1651
1752
  return;
1652
1753
  }
1653
- logger11.info({
1754
+ logger12.info({
1654
1755
  messageId: message.id,
1655
1756
  command: content,
1656
1757
  hasResponse: Boolean(commandResult.response)
@@ -1662,7 +1763,7 @@ ${commandResult.response ?? "Archiving..."}
1662
1763
  }
1663
1764
  if (!message.channel.isSendable()) {
1664
1765
  stopTypingForChannel(channelKey);
1665
- logger11.debug({ messageId: message.id }, "channel not sendable");
1766
+ logger12.debug({ messageId: message.id }, "channel not sendable");
1666
1767
  return;
1667
1768
  }
1668
1769
  await addWorkingReaction(message, entry.workingEmoji);
@@ -1682,8 +1783,16 @@ ${commandResult.response ?? "Archiving..."}
1682
1783
  promptImages = resolvedPromptMedia.images;
1683
1784
  }
1684
1785
  }
1685
- const wrappedContent = buildDiscordPromptContent(message, scope, promptContent, config);
1686
- const transformedPrompt = await config.promptTransform(wrappedContent);
1786
+ const discordMetadata = formatDiscordMessageMetadata(message, scope);
1787
+ const transformedPrompt = await config.promptTransform({
1788
+ rawContent: promptContent,
1789
+ discordMetadata,
1790
+ now: () => wrapXmlTag("datetime", formatDiscordPromptTime(new Date, {
1791
+ timeZone: config.promptTimeZone,
1792
+ locale: config.promptLocale
1793
+ })),
1794
+ userMessage: () => wrapXmlTag("user_message", promptContent)
1795
+ });
1687
1796
  return runAgentTurn(session, transformedPrompt, {
1688
1797
  images: promptImages
1689
1798
  });
@@ -1696,7 +1805,7 @@ ${commandResult.response ?? "Archiving..."}
1696
1805
  }
1697
1806
 
1698
1807
  // src/discord-gateway-client.ts
1699
- var logger12 = createModuleLogger("discord-gateway");
1808
+ var logger13 = createModuleLogger("discord-gateway");
1700
1809
  async function startGatewayClient(config, agentService, sessionRegistry, accessConfig) {
1701
1810
  const client = new Client({
1702
1811
  intents: [
@@ -1708,7 +1817,7 @@ async function startGatewayClient(config, agentService, sessionRegistry, accessC
1708
1817
  partials: [Partials.Channel]
1709
1818
  });
1710
1819
  client.once(Events.ClientReady, async (readyClient) => {
1711
- logger12.info({ userTag: readyClient.user.tag }, "logged in");
1820
+ logger13.info({ userTag: readyClient.user.tag }, "logged in");
1712
1821
  if (!accessConfig.startupMessage) {
1713
1822
  return;
1714
1823
  }
@@ -1716,137 +1825,30 @@ async function startGatewayClient(config, agentService, sessionRegistry, accessC
1716
1825
  const user = await readyClient.users.fetch(accessConfig.discordAllowedUserId);
1717
1826
  const dmChannel = await user.createDM();
1718
1827
  await dmChannel.send(accessConfig.startupMessage);
1719
- logger12.info({
1828
+ logger13.info({
1720
1829
  userId: accessConfig.discordAllowedUserId
1721
1830
  }, "sent startup dm");
1722
1831
  } catch (error) {
1723
- logger12.error({ error }, "failed to send startup dm");
1832
+ logger13.error({ error }, "failed to send startup dm");
1724
1833
  }
1725
1834
  });
1726
1835
  client.on(Events.MessageCreate, async (message) => {
1727
1836
  try {
1728
1837
  await handleDiscordMessage(message, config, agentService, sessionRegistry, accessConfig);
1729
1838
  } catch (error) {
1730
- logger12.error({ error, direction: "IN" }, "message handling failed");
1839
+ logger13.error({ error, direction: "IN" }, "message handling failed");
1731
1840
  await sendReply(message, "The bot hit an error while handling that message.");
1732
1841
  }
1733
1842
  });
1734
1843
  client.on(Events.ThreadDelete, async (thread) => {
1735
1844
  const scope = `thread:${thread.id}`;
1736
- logger12.info({ threadId: thread.id, scope }, "thread deleted");
1845
+ logger13.info({ threadId: thread.id, scope }, "thread deleted");
1737
1846
  await sessionRegistry.remove(scope);
1738
1847
  });
1739
1848
  await client.login(config.discordBotToken);
1740
1849
  return client;
1741
1850
  }
1742
1851
 
1743
- // src/session-registry.ts
1744
- import path3 from "node:path";
1745
-
1746
- // src/prompt-queue.ts
1747
- class PromptQueue {
1748
- queue = [];
1749
- running = false;
1750
- enqueue(task) {
1751
- return new Promise((resolve, reject) => {
1752
- this.queue.push(async () => {
1753
- try {
1754
- resolve(await task());
1755
- } catch (error) {
1756
- reject(error);
1757
- }
1758
- });
1759
- this.runNextTask();
1760
- });
1761
- }
1762
- getSnapshot() {
1763
- return {
1764
- pending: this.queue.length,
1765
- busy: this.running
1766
- };
1767
- }
1768
- runNextTask() {
1769
- if (this.running) {
1770
- return;
1771
- }
1772
- const next = this.queue.shift();
1773
- if (!next) {
1774
- return;
1775
- }
1776
- this.running = true;
1777
- next().finally(() => {
1778
- this.running = false;
1779
- this.runNextTask();
1780
- });
1781
- }
1782
- }
1783
-
1784
- // src/session-registry.ts
1785
- function sessionDirForScope(agentDir, scope) {
1786
- if (scope === "dm") {
1787
- return path3.join(agentDir, "sessions");
1788
- }
1789
- if (scope.startsWith("thread:")) {
1790
- const threadId = scope.slice(7);
1791
- return path3.join(agentDir, "sessions", `thread-${threadId}`);
1792
- }
1793
- throw new Error(`Unknown session scope: ${scope}`);
1794
- }
1795
- var logger13 = createModuleLogger("session-registry");
1796
-
1797
- class SessionRegistry {
1798
- scopes = new Map;
1799
- agentService;
1800
- constructor(agentService) {
1801
- this.agentService = agentService;
1802
- }
1803
- async getOrCreate(scope) {
1804
- const existing = this.scopes.get(scope);
1805
- if (existing) {
1806
- return { entry: existing, created: false };
1807
- }
1808
- const sessionDir = sessionDirForScope(this.agentService.getAgentDir(), scope);
1809
- const session = await this.agentService.createSession(sessionDir);
1810
- const promptQueue = new PromptQueue;
1811
- const entry = {
1812
- session,
1813
- promptQueue,
1814
- createdAt: new Date,
1815
- workingEmoji: DEFAULT_WORKING_EMOJI
1816
- };
1817
- this.scopes.set(scope, entry);
1818
- logger13.debug({
1819
- scope,
1820
- sessionDir,
1821
- sessionId: session.sessionId
1822
- }, "scope registered");
1823
- return { entry, created: true };
1824
- }
1825
- async remove(scope) {
1826
- const entry = this.scopes.get(scope);
1827
- if (!entry) {
1828
- return;
1829
- }
1830
- logger13.debug({ scope }, "removing scope");
1831
- await entry.session.abort();
1832
- entry.session.dispose();
1833
- this.scopes.delete(scope);
1834
- }
1835
- get(scope) {
1836
- return this.scopes.get(scope);
1837
- }
1838
- getScopes() {
1839
- return Array.from(this.scopes.keys());
1840
- }
1841
- async shutdownAll() {
1842
- logger13.info({ count: this.scopes.size }, "shutting down all scopes");
1843
- const scopes = Array.from(this.scopes.keys());
1844
- for (const scope of scopes) {
1845
- await this.remove(scope);
1846
- }
1847
- }
1848
- }
1849
-
1850
1852
  // src/index.ts
1851
1853
  var logger14 = createModuleLogger("index");
1852
1854
  async function startDiscordGateway(config) {
@@ -1910,6 +1912,5 @@ export {
1910
1912
  startDiscordGateway,
1911
1913
  resolveConfig,
1912
1914
  loadDiscordGatewayConfigFromEnv,
1913
- formatDiscordPromptTime,
1914
- buildDiscordMessageContextPrompt
1915
+ formatDiscordPromptTime
1915
1916
  };
@@ -2,17 +2,6 @@ export type DiscordPromptTimeFormatOptions = {
2
2
  timeZone?: string;
3
3
  locale?: string;
4
4
  };
5
- export type DiscordPromptScope = "dm" | "thread";
6
- export type DiscordMessageContextPromptOptions = {
7
- scope: DiscordPromptScope;
8
- messageId: string;
9
- authorId: string;
10
- sentAt?: string;
11
- sentAtLocal?: string;
12
- authorName?: string;
13
- threadId?: string;
14
- threadTitle?: string;
15
- forumChannelId?: string | null;
16
- };
17
- export declare function buildDiscordMessageContextPrompt(userMessage: string, options: DiscordMessageContextPromptOptions): string;
18
5
  export declare function formatDiscordPromptTime(date: Date, options?: DiscordPromptTimeFormatOptions): string;
6
+ /** Wrap content in an XML-style tag: `<tag>content</tag>`. */
7
+ export declare function wrapXmlTag(tag: string, content: string): string;
@@ -1,6 +1,7 @@
1
1
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
2
  import type { AgentService } from "./agent-service";
3
3
  import type { PromptQueue } from "./prompt-queue";
4
+ import type { SessionScope } from "./session-registry";
4
5
  export type CommandResult = {
5
6
  handled: boolean;
6
7
  response?: string;
@@ -8,11 +9,15 @@ export type CommandResult = {
8
9
  archive?: boolean;
9
10
  /** When set, update the session's working reaction emoji. */
10
11
  workingEmoji?: string;
12
+ /** When set, the command created a new session that should replace the current one. */
13
+ newSession?: AgentSession;
11
14
  };
12
15
  export type CommandContext = {
13
16
  agentService: AgentService;
14
17
  promptQueue: PromptQueue;
15
18
  session?: AgentSession;
19
+ /** Session scope ("dm" or "thread:<id>") for session lifecycle commands. */
20
+ scope: SessionScope;
16
21
  /** Current working reaction emoji for this session. */
17
22
  workingEmoji: string;
18
23
  };
package/dist/types.d.ts CHANGED
@@ -1,5 +1,18 @@
1
1
  import type { Client } from "discord.js";
2
- export type PromptTransform = (input: string) => string | Promise<string>;
2
+ /** Context object passed to promptTransform so consumers can optionally
3
+ * wrap the raw content with Discord metadata. */
4
+ export type PromptTransformContext = {
5
+ /** The raw user content without any XML wrapping. */
6
+ rawContent: string;
7
+ /** XML string with Discord message metadata (context JSON block). */
8
+ discordMetadata: string;
9
+ /** Returns current datetime formatted using the gateway config's
10
+ * promptTimeZone and promptLocale, wrapped in a <datetime> tag. */
11
+ now: () => string;
12
+ /** Returns rawContent wrapped in a <user_message> tag. */
13
+ userMessage: () => string;
14
+ };
15
+ export type PromptTransform = (ctx: PromptTransformContext) => string | Promise<string>;
3
16
  export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
4
17
  export type DiscordGatewayConfig = {
5
18
  discordBotToken: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@friendlyrobot/discord-pi-agent",
3
- "version": "0.20.1",
3
+ "version": "0.21.1",
4
4
  "description": "Reusable Discord gateway for persistent pi agent sessions",
5
5
  "license": "MIT",
6
6
  "type": "module",