@friendlyrobot/discord-pi-agent 0.5.8 → 0.6.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/README.md CHANGED
@@ -23,6 +23,31 @@ Reusable Discord gateway bridge for persistent pi agent sessions — DM and foru
23
23
 
24
24
  Any other text is sent to the active session (DM or thread).
25
25
 
26
+ ## Prompt metadata
27
+
28
+ Every Discord prompt is wrapped with lightweight Discord context before `promptTransform` runs:
29
+
30
+ ```text
31
+ <discord_message_context>
32
+ {
33
+ "scope": "thread",
34
+ "sent_at": "2026-05-07T04:31:00.000Z",
35
+ "sent_at_local": "Thu, 7 May 26, 14:31 AEST",
36
+ "message_id": "...",
37
+ "author_name": "Alice",
38
+ "author_id": "...",
39
+ "thread_title": "Bug report",
40
+ "thread_id": "...",
41
+ "forum_channel_id": "..."
42
+ }
43
+ </discord_message_context>
44
+
45
+ User message:
46
+ ...
47
+ ```
48
+
49
+ DM prompts omit thread-only fields. `sent_at_local` uses `promptTimeZone` and `promptLocale`.
50
+
26
51
  ## Install
27
52
 
28
53
  ```bash
@@ -33,19 +58,14 @@ bun add @friendlyrobot/discord-pi-agent
33
58
 
34
59
  ```ts
35
60
  import {
36
- buildTimeContextPrompt,
37
61
  loadDiscordGatewayConfigFromEnv,
38
62
  startDiscordGateway,
39
63
  } from "@friendlyrobot/discord-pi-agent";
40
64
 
41
65
  const config = loadDiscordGatewayConfigFromEnv({
42
66
  cwd: process.cwd(),
43
- promptTransform: (input) => {
44
- return buildTimeContextPrompt(input, {
45
- timeZone: "Australia/Sydney",
46
- locale: "en-AU",
47
- });
48
- },
67
+ promptTimeZone: "Australia/Sydney",
68
+ promptLocale: "en-AU",
49
69
  // Enable forum channel support (omit for DM-only)
50
70
  discordAllowedForumChannelIds: ["1498563501780897832"],
51
71
  });
@@ -70,6 +90,8 @@ The initial post body becomes the first prompt. Sessions survive restarts.
70
90
  - `modelProvider` default: `openrouter`
71
91
  - `modelId` default: `anthropic/claude-3.5-haiku`
72
92
  - `thinkingLevel` default: `medium` (values: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`)
93
+ - `promptTimeZone` default: `UTC` — used for `sent_at_local` in Discord prompt metadata
94
+ - `promptLocale` default: `en-AU` — used for `sent_at_local` in Discord prompt metadata
73
95
  - `promptTransform` default: identity
74
96
  - `startupMessage` default: `Bot is online and ready.`
75
97
  - `shutdownOnSignals` default: `true`
@@ -90,6 +112,8 @@ The initial post body becomes the first prompt. Sessions survive restarts.
90
112
  - `PI_AGENT_DIR`
91
113
  - `PI_MODEL_PROVIDER`
92
114
  - `PI_MODEL_ID`
115
+ - `PI_PROMPT_TIME_ZONE`
116
+ - `PI_PROMPT_LOCALE`
93
117
  - `DISCORD_STARTUP_MESSAGE`
94
118
  - `DISCORD_FORUM_CHANNEL_IDS` — comma-separated forum channel IDs
95
119
  - `DISCORD_ALLOWED_USER_IDS` — comma-separated allowed user IDs
@@ -24,6 +24,9 @@ export declare class AgentService {
24
24
  private requireSession;
25
25
  private applyConfiguredThinkingLevel;
26
26
  private applyConfiguredThinkingLevelForSession;
27
+ listModels(): Promise<string>;
28
+ switchModel(provider: string, modelId: string): Promise<string>;
29
+ getCurrentModelDisplay(): string;
27
30
  getThinkingLevel(): {
28
31
  current: ThinkingLevel;
29
32
  available: ThinkingLevel[];
@@ -8,8 +8,4 @@ export type GatewayAuthConfig = {
8
8
  discordAllowedUserIds: string[];
9
9
  startupMessage: string | false;
10
10
  };
11
- /**
12
- * Combine a forum thread title with the post body for the initial session prompt.
13
- */
14
- export declare function buildThreadOpeningPrompt(threadName: string, content: string): string;
15
11
  export declare function startGatewayClient(config: ResolvedDiscordPiBridgeConfig, agentService: AgentService, sessionRegistry: SessionRegistry, authConfig: GatewayAuthConfig): Promise<Client>;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DiscordGateway, DiscordGatewayConfig, DiscordPiBridge, DiscordPiBridgeConfig } from "./types";
2
- export { buildTimeContextPrompt, type TimeContextPromptOptions, } from "./prompt-context";
2
+ export { buildDiscordMessageContextPrompt, formatDiscordPromptTime, type DiscordMessageContextPromptOptions, type DiscordPromptScope, type DiscordPromptTimeFormatOptions, } from "./prompt-context";
3
3
  export { loadDiscordPiBridgeConfigFromEnv, loadDiscordGatewayConfigFromEnv, resolveConfig, } from "./config";
4
4
  export type { AgentStatus, DiscordGateway, DiscordGatewayConfig, DiscordPiBridge, DiscordPiBridgeConfig, PromptTransform, ResolvedDiscordPiBridgeConfig, } from "./types";
5
5
  /**
package/dist/index.js CHANGED
@@ -361,6 +361,51 @@ class AgentService {
361
361
  }
362
362
  }
363
363
  }
364
+ async listModels() {
365
+ const availableModels = await this.modelRegistry.getAvailable();
366
+ const session = this.session;
367
+ const currentDisplay = session?.model ? `${session.model.provider}/${session.model.id}` : null;
368
+ const lines = availableModels.map((model) => {
369
+ const display = `${model.provider}/${model.id}`;
370
+ const marker = currentDisplay === display ? " (current)" : "";
371
+ return ` ${display}${marker}`;
372
+ });
373
+ return [
374
+ `Available models (${availableModels.length}):`,
375
+ ...lines,
376
+ `
377
+ Usage: !model <provider/modelId> to switch.`
378
+ ].join(`
379
+ `);
380
+ }
381
+ async switchModel(provider, modelId) {
382
+ const session = this.requireSession();
383
+ const model = this.modelRegistry.find(provider, modelId);
384
+ if (!model) {
385
+ const availableModels = await this.modelRegistry.getAvailable();
386
+ const matches = availableModels.filter((m) => {
387
+ return m.provider === provider;
388
+ }).map((m) => `${m.provider}/${m.id}`);
389
+ const hint = matches.length > 0 ? `
390
+ Models from "${provider}": ${matches.join(", ")}` : `
391
+ Use !model to see all available models.`;
392
+ return `Model not found: ${provider}/${modelId}.${hint}`;
393
+ }
394
+ if (isSameModel(session.model, model)) {
395
+ return `Already using ${provider}/${modelId}.`;
396
+ }
397
+ await session.setModel(model);
398
+ await this.applyConfiguredThinkingLevelForSession(session);
399
+ const thinkingInfo = session.supportsThinking() ? ` (thinking: ${session.thinkingLevel})` : "";
400
+ return `Switched to ${provider}/${modelId}${thinkingInfo}.`;
401
+ }
402
+ getCurrentModelDisplay() {
403
+ const session = this.session;
404
+ if (!session?.model) {
405
+ return "(no model selected)";
406
+ }
407
+ return `${session.model.provider}/${session.model.id}`;
408
+ }
364
409
  getThinkingLevel() {
365
410
  const session = this.requireSession();
366
411
  if (!session.supportsThinking()) {
@@ -407,6 +452,8 @@ function resolveConfig(config) {
407
452
  modelProvider: config.modelProvider?.trim() || "openrouter",
408
453
  modelId: config.modelId?.trim() || "anthropic/claude-3.5-haiku",
409
454
  thinkingLevel: parseThinkingLevel(config.thinkingLevel) || "medium",
455
+ promptTimeZone: config.promptTimeZone?.trim() || "UTC",
456
+ promptLocale: config.promptLocale?.trim() || "en-AU",
410
457
  promptTransform: config.promptTransform || identityPromptTransform,
411
458
  startupMessage: config.startupMessage === undefined ? "Bot is online and ready." : config.startupMessage,
412
459
  shutdownOnSignals: config.shutdownOnSignals ?? true
@@ -422,6 +469,8 @@ function loadDiscordPiBridgeConfigFromEnv(overrides = {}) {
422
469
  modelProvider: overrides.modelProvider || process.env.PI_MODEL_PROVIDER,
423
470
  modelId: overrides.modelId || process.env.PI_MODEL_ID,
424
471
  thinkingLevel: parseThinkingLevel(overrides.thinkingLevel || process.env.PI_THINKING_LEVEL),
472
+ promptTimeZone: overrides.promptTimeZone || process.env.PI_PROMPT_TIME_ZONE,
473
+ promptLocale: overrides.promptLocale || process.env.PI_PROMPT_LOCALE,
425
474
  promptTransform: overrides.promptTransform,
426
475
  startupMessage: overrides.startupMessage ?? readStartupMessageFromEnv(),
427
476
  shutdownOnSignals: overrides.shutdownOnSignals
@@ -558,6 +607,7 @@ async function handleCommand(input, ctx) {
558
607
  "!help - show this message",
559
608
  "!status - show current session status",
560
609
  "!thinking - show or set thinking/reasoning level",
610
+ "!model - list available models or switch to one",
561
611
  "!compact - compact the persistent session",
562
612
  "!reset-session - start a fresh persistent session",
563
613
  "!reload - reload resources (AGENTS.md, extensions, skills, etc.)",
@@ -640,6 +690,42 @@ async function handleCommand(input, ctx) {
640
690
  response: `Thinking level set to "${requestedLevel}".`
641
691
  };
642
692
  }
693
+ if (trimmed === "!model" || trimmed.startsWith("!model ")) {
694
+ const effectiveSession = session ?? agentService.getSession();
695
+ if (!effectiveSession) {
696
+ return {
697
+ handled: true,
698
+ response: "No active session."
699
+ };
700
+ }
701
+ const parts = trimmed.split(" ");
702
+ if (parts.length === 1) {
703
+ const current = agentService.getCurrentModelDisplay();
704
+ const modelList = await agentService.listModels();
705
+ return {
706
+ handled: true,
707
+ response: `Current model: ${current}
708
+
709
+ ${modelList}`
710
+ };
711
+ }
712
+ const arg = parts.slice(1).join(" ");
713
+ const slashIndex = arg.indexOf("/");
714
+ if (slashIndex === -1) {
715
+ return {
716
+ handled: true,
717
+ response: `Usage: !model <provider/modelId>
718
+ Example: !model openrouter/anthropic/claude-sonnet-4
719
+ Use !model without args to see available models.`
720
+ };
721
+ }
722
+ const provider = arg.substring(0, slashIndex);
723
+ const modelId = arg.substring(slashIndex + 1);
724
+ return {
725
+ handled: true,
726
+ response: await agentService.switchModel(provider, modelId)
727
+ };
728
+ }
643
729
  if (trimmed === "!compact") {
644
730
  const effectiveSession = session ?? agentService.getSession();
645
731
  if (!effectiveSession) {
@@ -720,11 +806,74 @@ function chunkMessage(text, maxChunkSize = SAFE_MESSAGE_LIMIT) {
720
806
  return chunks.map((chunk) => chunk.slice(0, DISCORD_MESSAGE_LIMIT));
721
807
  }
722
808
 
723
- // src/discord-gateway-client.ts
724
- function buildThreadOpeningPrompt(threadName, content) {
725
- return `<thread_title>${threadName}</thread_title>
809
+ // src/prompt-context.ts
810
+ function buildDiscordMessageContextPrompt(userMessage, options) {
811
+ const contextEntries = [
812
+ ["scope", options.scope],
813
+ ["sent_at", options.sentAt],
814
+ ["sent_at_local", options.sentAtLocal],
815
+ ["message_id", options.messageId],
816
+ ["author_name", normalizeContextValue(options.authorName)],
817
+ ["author_id", options.authorId],
818
+ ["thread_title", normalizeContextValue(options.threadTitle)],
819
+ ["thread_id", options.threadId],
820
+ ["forum_channel_id", options.forumChannelId ?? undefined]
821
+ ].filter((entry) => {
822
+ return typeof entry[1] === "string" && entry[1].trim().length > 0;
823
+ });
824
+ const contextJson = JSON.stringify(Object.fromEntries(contextEntries), null, 2);
825
+ return [
826
+ "<discord_message_context>",
827
+ contextJson,
828
+ "</discord_message_context>",
829
+ "",
830
+ "User message:",
831
+ userMessage.trim()
832
+ ].join(`
833
+ `);
834
+ }
835
+ function formatDiscordPromptTime(date, options = {}) {
836
+ const timeZone = options.timeZone || "UTC";
837
+ const locale = options.locale || "en-AU";
838
+ return new Intl.DateTimeFormat(locale, {
839
+ timeZone,
840
+ weekday: "short",
841
+ day: "numeric",
842
+ month: "short",
843
+ year: "2-digit",
844
+ hour: "2-digit",
845
+ minute: "2-digit",
846
+ hour12: false,
847
+ timeZoneName: "short"
848
+ }).format(date);
849
+ }
850
+ function normalizeContextValue(value) {
851
+ if (value === undefined) {
852
+ return;
853
+ }
854
+ return value.replace(/\s+/g, " ").trim();
855
+ }
726
856
 
727
- ${content}`;
857
+ // src/discord-gateway-client.ts
858
+ function getAuthorDisplayName(message) {
859
+ return message.member?.displayName || message.author.globalName || message.author.username;
860
+ }
861
+ function buildDiscordPromptContent(message, scope, content, config) {
862
+ const isThread = scope.startsWith("thread:") && message.channel.isThread();
863
+ return buildDiscordMessageContextPrompt(content, {
864
+ scope: scope === "dm" ? "dm" : "thread",
865
+ sentAt: message.createdAt.toISOString(),
866
+ sentAtLocal: formatDiscordPromptTime(message.createdAt, {
867
+ timeZone: config.promptTimeZone,
868
+ locale: config.promptLocale
869
+ }),
870
+ messageId: message.id,
871
+ authorId: message.author.id,
872
+ authorName: getAuthorDisplayName(message),
873
+ threadId: isThread ? message.channel.id : undefined,
874
+ threadTitle: isThread ? message.channel.name : undefined,
875
+ forumChannelId: isThread ? message.channel.parentId : undefined
876
+ });
728
877
  }
729
878
  function resolveScope(message) {
730
879
  if (message.channel.type === ChannelType.DM) {
@@ -887,16 +1036,11 @@ async function onMessage(message, config, agentService, sessionRegistry, authCon
887
1036
  }
888
1037
  const { entry, created } = await sessionRegistry.getOrCreate(scope);
889
1038
  const { session, promptQueue } = entry;
890
- let effectiveContent = content;
891
- if (created && scope.startsWith("thread:")) {
892
- const thread = message.channel;
893
- if (thread.isThread() && thread.name) {
894
- effectiveContent = buildThreadOpeningPrompt(thread.name, content);
895
- console.log("[gateway] new thread session — prepending title", {
896
- scope,
897
- threadName: thread.name
898
- });
899
- }
1039
+ if (created && scope.startsWith("thread:") && message.channel.isThread()) {
1040
+ console.log("[gateway] new thread session", {
1041
+ scope,
1042
+ threadName: message.channel.name
1043
+ });
900
1044
  }
901
1045
  let typingInterval = null;
902
1046
  if (message.channel.isSendable()) {
@@ -951,7 +1095,8 @@ async function onMessage(message, config, agentService, sessionRegistry, authCon
951
1095
  }
952
1096
  const response = await promptQueue.enqueue(async () => {
953
1097
  console.log(`[queue] processing message ${message.id} in scope ${scope}`);
954
- const transformedPrompt = await config.promptTransform(effectiveContent);
1098
+ const promptContent = buildDiscordPromptContent(message, scope, content, config);
1099
+ const transformedPrompt = await config.promptTransform(promptContent);
955
1100
  return collectReply(session, transformedPrompt, {
956
1101
  logPrefix: `[agent:${session.sessionId}]`
957
1102
  });
@@ -1071,27 +1216,6 @@ class SessionRegistry {
1071
1216
  }
1072
1217
  }
1073
1218
 
1074
- // src/prompt-context.ts
1075
- function buildTimeContextPrompt(userMessage, options = {}) {
1076
- const timeZone = options.timeZone || "UTC";
1077
- const locale = options.locale || "en-AU";
1078
- const now = options.now || new Date;
1079
- const localTime = new Intl.DateTimeFormat(locale, {
1080
- timeZone,
1081
- weekday: "short",
1082
- day: "numeric",
1083
- month: "short",
1084
- year: "2-digit",
1085
- hour: "2-digit",
1086
- minute: "2-digit",
1087
- hour12: false,
1088
- timeZoneName: "short"
1089
- }).format(now);
1090
- const trimmedMessage = userMessage.trim();
1091
- return [`<time>${localTime}</time>`, "", trimmedMessage].join(`
1092
- `);
1093
- }
1094
-
1095
1219
  // src/index.ts
1096
1220
  async function startDiscordGateway(config) {
1097
1221
  const resolvedConfig = resolveGatewayConfig(config);
@@ -1159,5 +1283,6 @@ export {
1159
1283
  resolveConfig,
1160
1284
  loadDiscordPiBridgeConfigFromEnv,
1161
1285
  loadDiscordGatewayConfigFromEnv,
1162
- buildTimeContextPrompt
1286
+ formatDiscordPromptTime,
1287
+ buildDiscordMessageContextPrompt
1163
1288
  };
@@ -1,6 +1,18 @@
1
- export type TimeContextPromptOptions = {
1
+ export type DiscordPromptTimeFormatOptions = {
2
2
  timeZone?: string;
3
3
  locale?: string;
4
- now?: Date;
5
4
  };
6
- export declare function buildTimeContextPrompt(userMessage: string, options?: TimeContextPromptOptions): string;
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
+ export declare function formatDiscordPromptTime(date: Date, options?: DiscordPromptTimeFormatOptions): string;
package/dist/types.d.ts CHANGED
@@ -9,6 +9,8 @@ export type DiscordPiBridgeConfig = {
9
9
  modelProvider?: string;
10
10
  modelId?: string;
11
11
  thinkingLevel?: ThinkingLevel;
12
+ promptTimeZone?: string;
13
+ promptLocale?: string;
12
14
  promptTransform?: PromptTransform;
13
15
  startupMessage?: string | false;
14
16
  shutdownOnSignals?: boolean;
@@ -21,6 +23,8 @@ export type ResolvedDiscordPiBridgeConfig = {
21
23
  modelProvider: string;
22
24
  modelId: string;
23
25
  thinkingLevel: ThinkingLevel;
26
+ promptTimeZone: string;
27
+ promptLocale: string;
24
28
  promptTransform: PromptTransform;
25
29
  startupMessage: string | false;
26
30
  shutdownOnSignals: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@friendlyrobot/discord-pi-agent",
3
- "version": "0.5.8",
3
+ "version": "0.6.0",
4
4
  "description": "Reusable Discord gateway bridge for persistent pi agent sessions",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1 +0,0 @@
1
- export {};