@friendlyrobot/discord-pi-agent 0.1.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 ADDED
@@ -0,0 +1,105 @@
1
+ # @friendlyrobot/discord-pi-agent
2
+
3
+ Reusable Discord DM bridge for persistent pi agent sessions.
4
+
5
+ ## What it does
6
+ - runs one long-lived pi agent session
7
+ - resumes the latest session on restart
8
+ - loads project context from the target repo via pi resource loading
9
+ - accepts DM messages from one allowed Discord user
10
+ - serializes prompts through a FIFO queue
11
+ - exposes built-in session commands
12
+
13
+ ## Built-in commands
14
+ - `/help`
15
+ - `/status`
16
+ - `/compact`
17
+ - `/reset-session`
18
+
19
+ Any other DM text is sent to the persistent agent session.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ bun add @friendlyrobot/discord-pi-agent
25
+ ```
26
+
27
+ ## Minimal usage
28
+
29
+ ```ts
30
+ import { startDiscordPiBridge } from "@friendlyrobot/discord-pi-agent";
31
+
32
+ const bridge = await startDiscordPiBridge({
33
+ discordBotToken: process.env.DISCORD_BOT_TOKEN!,
34
+ discordAllowedUserId: process.env.DISCORD_ALLOWED_USER_ID!,
35
+ cwd: process.cwd(),
36
+ modelProvider: "moonshot-cn",
37
+ modelId: "kimi-k2.5",
38
+ });
39
+ ```
40
+
41
+ ## Usage with dotenv and time context
42
+
43
+ ```ts
44
+ import {
45
+ buildTimeContextPrompt,
46
+ loadDiscordPiBridgeConfigFromEnv,
47
+ startDiscordPiBridge,
48
+ } from "@friendlyrobot/discord-pi-agent";
49
+
50
+ const config = loadDiscordPiBridgeConfigFromEnv({
51
+ promptTransform: (input) => {
52
+ return buildTimeContextPrompt(input, {
53
+ timeZone: "Australia/Sydney",
54
+ locale: "en-AU",
55
+ locationLabel: "Sydney",
56
+ });
57
+ },
58
+ });
59
+
60
+ await startDiscordPiBridge(config);
61
+ ```
62
+
63
+ ## Config
64
+
65
+ ### Required
66
+ - `discordBotToken`
67
+ - `discordAllowedUserId`
68
+ - `cwd`
69
+
70
+ ### Optional
71
+ - `agentDir` default: `<cwd>/.pi-agent`
72
+ - `modelProvider` default: `moonshot-cn`
73
+ - `modelId` default: `kimi-k2.5`
74
+ - `promptTransform` default: identity
75
+ - `startupMessage` default: `Bot is online and ready.`
76
+ - `shutdownOnSignals` default: `true`
77
+
78
+ ## Env helper
79
+
80
+ `loadDiscordPiBridgeConfigFromEnv()` supports:
81
+
82
+ - `DISCORD_BOT_TOKEN`
83
+ - `DISCORD_ALLOWED_USER_ID`
84
+ - `PI_AGENT_CWD`
85
+ - `PI_AGENT_DIR`
86
+ - `PI_MODEL_PROVIDER`
87
+ - `PI_MODEL_ID`
88
+ - `DISCORD_STARTUP_MESSAGE`
89
+
90
+ If `PI_AGENT_CWD` is missing it falls back to `process.cwd()`.
91
+
92
+ Set `DISCORD_STARTUP_MESSAGE=false` to disable the startup DM.
93
+
94
+ ## Build
95
+
96
+ ```bash
97
+ bun run build
98
+ bun run typecheck
99
+ ```
100
+
101
+ ## Notes
102
+ - DM-only by design
103
+ - single allowed user by design
104
+ - the package does not register Discord slash commands
105
+ - pi resources are loaded from the configured `cwd` and `agentDir`
@@ -0,0 +1,20 @@
1
+ import type { AgentStatus, ResolvedDiscordPiBridgeConfig } from "./types";
2
+ export declare class AgentService {
3
+ private readonly config;
4
+ private readonly authStorage;
5
+ private readonly modelRegistry;
6
+ private readonly settingsManager;
7
+ private readonly resourceLoader;
8
+ private session;
9
+ constructor(config: ResolvedDiscordPiBridgeConfig);
10
+ initialize(): Promise<void>;
11
+ prompt(text: string): Promise<string>;
12
+ compact(): Promise<string>;
13
+ resetSession(): Promise<string>;
14
+ getStatus(): AgentStatus;
15
+ shutdown(): Promise<void>;
16
+ private createOrResumeSession;
17
+ private ensureConfiguredModel;
18
+ private requireSession;
19
+ private getSessionDir;
20
+ }
@@ -0,0 +1,8 @@
1
+ import type { AgentService } from "./agent-service";
2
+ import type { PromptQueue } from "./prompt-queue";
3
+ type CommandResult = {
4
+ handled: boolean;
5
+ response?: string;
6
+ };
7
+ export declare function handleCommand(input: string, agentService: AgentService, promptQueue: PromptQueue): Promise<CommandResult>;
8
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { DiscordPiBridgeConfig, ResolvedDiscordPiBridgeConfig } from "./types";
2
+ export declare function resolveConfig(config: DiscordPiBridgeConfig): ResolvedDiscordPiBridgeConfig;
3
+ export declare function loadDiscordPiBridgeConfigFromEnv(overrides?: Partial<DiscordPiBridgeConfig>): ResolvedDiscordPiBridgeConfig;
@@ -0,0 +1,5 @@
1
+ import { Client } from "discord.js";
2
+ import type { AgentService } from "./agent-service";
3
+ import type { PromptQueue } from "./prompt-queue";
4
+ import type { ResolvedDiscordPiBridgeConfig } from "./types";
5
+ export declare function startDiscordClient(config: ResolvedDiscordPiBridgeConfig, agentService: AgentService, promptQueue: PromptQueue): Promise<Client>;
@@ -0,0 +1,5 @@
1
+ import type { DiscordPiBridge, DiscordPiBridgeConfig } from "./types";
2
+ export { buildTimeContextPrompt, type TimeContextPromptOptions } from "./prompt-context";
3
+ export { loadDiscordPiBridgeConfigFromEnv, resolveConfig } from "./config";
4
+ export type { AgentStatus, DiscordPiBridge, DiscordPiBridgeConfig, PromptTransform, ResolvedDiscordPiBridgeConfig, } from "./types";
5
+ export declare function startDiscordPiBridge(config: DiscordPiBridgeConfig): Promise<DiscordPiBridge>;
package/dist/index.js ADDED
@@ -0,0 +1,621 @@
1
+ // src/agent-service.ts
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import {
5
+ AuthStorage,
6
+ createAgentSession,
7
+ DefaultResourceLoader,
8
+ ModelRegistry,
9
+ SessionManager,
10
+ SettingsManager
11
+ } from "@mariozechner/pi-coding-agent";
12
+
13
+ // src/reply-buffer.ts
14
+ async function collectReply(session, prompt, options = {}) {
15
+ const logPrefix = options.logPrefix ?? "[agent]";
16
+ let streamedText = "";
17
+ let eventCount = 0;
18
+ let toolCount = 0;
19
+ let sawAgentEnd = false;
20
+ console.log(`${logPrefix} prompt start`, { prompt });
21
+ const unsubscribe = session.subscribe((event) => {
22
+ eventCount += 1;
23
+ if (event.type === "message_update") {
24
+ if (event.assistantMessageEvent.type === "text_delta") {
25
+ streamedText += event.assistantMessageEvent.delta;
26
+ }
27
+ if (event.assistantMessageEvent.type === "thinking_delta") {}
28
+ }
29
+ if (event.type === "tool_execution_start") {
30
+ toolCount += 1;
31
+ console.log(`${logPrefix} tool start`, {
32
+ toolName: event.toolName,
33
+ input: truncateForLog(JSON.stringify(event.args))
34
+ });
35
+ }
36
+ if (event.type === "tool_execution_end") {
37
+ console.log(`${logPrefix} tool end`, {
38
+ toolName: event.toolName,
39
+ isError: event.isError,
40
+ output: truncateForLog(extractToolOutput(event.result))
41
+ });
42
+ }
43
+ if (event.type === "agent_end") {
44
+ sawAgentEnd = true;
45
+ console.log(`${logPrefix} agent end`, { messageCount: event.messages.length });
46
+ }
47
+ });
48
+ try {
49
+ await session.prompt(prompt);
50
+ } finally {
51
+ unsubscribe();
52
+ }
53
+ const errorMessage = session.agent.state.errorMessage?.trim();
54
+ const fallbackText = getLatestAssistantText(session.messages);
55
+ const finalText = streamedText.trim() || fallbackText.trim();
56
+ console.log(`${logPrefix} prompt done`, {
57
+ eventCount,
58
+ toolCount,
59
+ sawAgentEnd,
60
+ streamedTextLength: streamedText.trim().length,
61
+ fallbackTextLength: fallbackText.trim().length,
62
+ errorMessage
63
+ });
64
+ if (errorMessage) {
65
+ return errorMessage;
66
+ }
67
+ if (finalText) {
68
+ return finalText;
69
+ }
70
+ return "No response generated.";
71
+ }
72
+ function truncateForLog(value, maxLength = 400) {
73
+ if (value.length <= maxLength) {
74
+ return value;
75
+ }
76
+ return `${value.slice(0, maxLength)}...`;
77
+ }
78
+ function extractToolOutput(output) {
79
+ if (typeof output === "string") {
80
+ return output;
81
+ }
82
+ try {
83
+ return JSON.stringify(output);
84
+ } catch {
85
+ return String(output);
86
+ }
87
+ }
88
+ function getLatestAssistantText(messages) {
89
+ const latestAssistantMessage = [...messages].reverse().find((message) => {
90
+ return message.role === "assistant";
91
+ });
92
+ if (!latestAssistantMessage || !Array.isArray(latestAssistantMessage.content)) {
93
+ return "";
94
+ }
95
+ return latestAssistantMessage.content.filter((item) => {
96
+ return item.type === "text";
97
+ }).map((item) => {
98
+ return item.text;
99
+ }).join(`
100
+ `).trim();
101
+ }
102
+
103
+ // src/agent-service.ts
104
+ class AgentService {
105
+ config;
106
+ authStorage;
107
+ modelRegistry;
108
+ settingsManager;
109
+ resourceLoader;
110
+ session = null;
111
+ constructor(config) {
112
+ this.config = config;
113
+ this.authStorage = AuthStorage.create(path.join(config.agentDir, "auth.json"));
114
+ this.modelRegistry = ModelRegistry.create(this.authStorage, path.join(config.agentDir, "models.json"));
115
+ this.settingsManager = SettingsManager.create(config.cwd, config.agentDir);
116
+ this.resourceLoader = new DefaultResourceLoader({
117
+ cwd: config.cwd,
118
+ agentDir: config.agentDir,
119
+ settingsManager: this.settingsManager
120
+ });
121
+ }
122
+ async initialize() {
123
+ await fs.mkdir(this.config.agentDir, { recursive: true });
124
+ await fs.mkdir(this.getSessionDir(), { recursive: true });
125
+ console.log("[agent] config", {
126
+ cwd: this.config.cwd,
127
+ agentDir: this.config.agentDir,
128
+ sessionDir: this.getSessionDir(),
129
+ modelProvider: this.config.modelProvider,
130
+ modelId: this.config.modelId
131
+ });
132
+ await this.resourceLoader.reload();
133
+ console.log("[agent] resources loaded", {
134
+ extensions: this.resourceLoader.getExtensions().extensions.map((extension) => extension.path),
135
+ agentsFiles: this.resourceLoader.getAgentsFiles().agentsFiles.map((file) => file.path)
136
+ });
137
+ await this.createOrResumeSession();
138
+ await this.ensureConfiguredModel();
139
+ }
140
+ async prompt(text) {
141
+ const session = this.requireSession();
142
+ const transformedPrompt = await this.config.promptTransform(text);
143
+ return collectReply(session, transformedPrompt, { logPrefix: `[agent:${session.sessionId}]` });
144
+ }
145
+ async compact() {
146
+ const session = this.requireSession();
147
+ await session.compact();
148
+ return `Compaction finished for session ${session.sessionId}.`;
149
+ }
150
+ async resetSession() {
151
+ const previousSession = this.requireSession();
152
+ await previousSession.abort();
153
+ previousSession.dispose();
154
+ this.session = null;
155
+ const { session } = await createAgentSession({
156
+ cwd: this.config.cwd,
157
+ agentDir: this.config.agentDir,
158
+ authStorage: this.authStorage,
159
+ modelRegistry: this.modelRegistry,
160
+ resourceLoader: this.resourceLoader,
161
+ settingsManager: this.settingsManager,
162
+ sessionManager: SessionManager.create(this.config.cwd, this.getSessionDir())
163
+ });
164
+ this.session = session;
165
+ await this.ensureConfiguredModel();
166
+ return `Started a fresh session. Old session kept at ${previousSession.sessionFile ?? "(unknown path)"}.`;
167
+ }
168
+ getStatus() {
169
+ const session = this.requireSession();
170
+ const model = session.model ? `${session.model.provider}/${session.model.id}` : "(no model selected)";
171
+ return {
172
+ sessionId: session.sessionId,
173
+ sessionFile: session.sessionFile,
174
+ model,
175
+ streaming: session.isStreaming
176
+ };
177
+ }
178
+ async shutdown() {
179
+ const session = this.session;
180
+ if (session) {
181
+ await session.abort();
182
+ session.dispose();
183
+ }
184
+ await this.settingsManager.flush();
185
+ }
186
+ async createOrResumeSession() {
187
+ const { session } = await createAgentSession({
188
+ cwd: this.config.cwd,
189
+ agentDir: this.config.agentDir,
190
+ authStorage: this.authStorage,
191
+ modelRegistry: this.modelRegistry,
192
+ resourceLoader: this.resourceLoader,
193
+ settingsManager: this.settingsManager,
194
+ sessionManager: SessionManager.continueRecent(this.config.cwd, this.getSessionDir())
195
+ });
196
+ this.session = session;
197
+ console.log("[agent] session ready", {
198
+ sessionId: session.sessionId,
199
+ sessionFile: session.sessionFile,
200
+ restoredModel: session.model ? `${session.model.provider}/${session.model.id}` : null
201
+ });
202
+ }
203
+ async ensureConfiguredModel() {
204
+ const session = this.requireSession();
205
+ const desiredModel = this.modelRegistry.find(this.config.modelProvider, this.config.modelId);
206
+ const availableModels = await this.modelRegistry.getAvailable();
207
+ console.log("[agent] available models", {
208
+ count: availableModels.length,
209
+ matches: availableModels.filter((model) => {
210
+ return model.provider === this.config.modelProvider;
211
+ }).map((model) => `${model.provider}/${model.id}`)
212
+ });
213
+ if (!desiredModel) {
214
+ throw new Error(`Configured model not found: ${this.config.modelProvider}/${this.config.modelId}. Check your pi agent config and installed extensions.`);
215
+ }
216
+ if (isSameModel(session.model, desiredModel)) {
217
+ console.log("[agent] model already selected", {
218
+ model: `${desiredModel.provider}/${desiredModel.id}`
219
+ });
220
+ return;
221
+ }
222
+ console.log("[agent] switching model", {
223
+ from: session.model ? `${session.model.provider}/${session.model.id}` : null,
224
+ to: `${desiredModel.provider}/${desiredModel.id}`
225
+ });
226
+ await session.setModel(desiredModel);
227
+ }
228
+ requireSession() {
229
+ if (!this.session) {
230
+ throw new Error("Agent session has not been initialized.");
231
+ }
232
+ return this.session;
233
+ }
234
+ getSessionDir() {
235
+ return path.join(this.config.agentDir, "sessions");
236
+ }
237
+ }
238
+ function isSameModel(currentModel, desiredModel) {
239
+ if (!currentModel) {
240
+ return false;
241
+ }
242
+ return currentModel.provider === desiredModel.provider && currentModel.id === desiredModel.id;
243
+ }
244
+
245
+ // src/config.ts
246
+ import path2 from "node:path";
247
+ import dotenv from "dotenv";
248
+ function resolveConfig(config) {
249
+ return {
250
+ discordBotToken: readRequiredValue("discordBotToken", config.discordBotToken),
251
+ discordAllowedUserId: readRequiredValue("discordAllowedUserId", config.discordAllowedUserId),
252
+ cwd: readRequiredValue("cwd", config.cwd),
253
+ agentDir: config.agentDir?.trim() || path2.join(config.cwd, ".pi-agent"),
254
+ modelProvider: config.modelProvider?.trim() || "moonshot-cn",
255
+ modelId: config.modelId?.trim() || "kimi-k2.5",
256
+ promptTransform: config.promptTransform || identityPromptTransform,
257
+ startupMessage: config.startupMessage === undefined ? "Bot is online and ready." : config.startupMessage,
258
+ shutdownOnSignals: config.shutdownOnSignals ?? true
259
+ };
260
+ }
261
+ function loadDiscordPiBridgeConfigFromEnv(overrides = {}) {
262
+ dotenv.config();
263
+ return resolveConfig({
264
+ discordBotToken: overrides.discordBotToken || process.env.DISCORD_BOT_TOKEN || "",
265
+ discordAllowedUserId: overrides.discordAllowedUserId || process.env.DISCORD_ALLOWED_USER_ID || "",
266
+ cwd: overrides.cwd || process.env.PI_AGENT_CWD || process.cwd(),
267
+ agentDir: overrides.agentDir || process.env.PI_AGENT_DIR,
268
+ modelProvider: overrides.modelProvider || process.env.PI_MODEL_PROVIDER,
269
+ modelId: overrides.modelId || process.env.PI_MODEL_ID,
270
+ promptTransform: overrides.promptTransform,
271
+ startupMessage: overrides.startupMessage ?? readStartupMessageFromEnv(),
272
+ shutdownOnSignals: overrides.shutdownOnSignals
273
+ });
274
+ }
275
+ function readRequiredValue(name, value) {
276
+ const trimmedValue = value.trim();
277
+ if (!trimmedValue) {
278
+ throw new Error(`Missing required config value: ${name}`);
279
+ }
280
+ return trimmedValue;
281
+ }
282
+ function readStartupMessageFromEnv() {
283
+ const value = process.env.DISCORD_STARTUP_MESSAGE;
284
+ if (value === undefined) {
285
+ return;
286
+ }
287
+ const trimmedValue = value.trim();
288
+ if (!trimmedValue || trimmedValue.toLowerCase() === "false") {
289
+ return false;
290
+ }
291
+ return trimmedValue;
292
+ }
293
+ function identityPromptTransform(input) {
294
+ return input;
295
+ }
296
+
297
+ // src/discord-client.ts
298
+ import {
299
+ ChannelType,
300
+ Client,
301
+ Events,
302
+ GatewayIntentBits,
303
+ Partials
304
+ } from "discord.js";
305
+
306
+ // src/commands.ts
307
+ async function handleCommand(input, agentService, promptQueue) {
308
+ const trimmed = input.trim();
309
+ if (!trimmed.startsWith("/")) {
310
+ return { handled: false };
311
+ }
312
+ if (trimmed === "/help") {
313
+ return {
314
+ handled: true,
315
+ response: [
316
+ "Commands:",
317
+ "/help - show this message",
318
+ "/status - show current session status",
319
+ "/compact - compact the persistent session",
320
+ "/reset-session - start a fresh persistent session",
321
+ "Any other DM text goes to the persistent agent session."
322
+ ].join(`
323
+ `)
324
+ };
325
+ }
326
+ if (trimmed === "/status") {
327
+ const agentStatus = agentService.getStatus();
328
+ const queueStatus = promptQueue.getSnapshot();
329
+ return {
330
+ handled: true,
331
+ response: [
332
+ `model: ${agentStatus.model}`,
333
+ `session-id: ${agentStatus.sessionId}`,
334
+ `session-file: ${agentStatus.sessionFile ?? "(none)"}`,
335
+ `streaming: ${agentStatus.streaming}`,
336
+ `queue-pending: ${queueStatus.pending}`,
337
+ `queue-busy: ${queueStatus.busy}`
338
+ ].join(`
339
+ `)
340
+ };
341
+ }
342
+ if (trimmed === "/compact") {
343
+ return {
344
+ handled: true,
345
+ response: await promptQueue.enqueue(async () => {
346
+ return agentService.compact();
347
+ })
348
+ };
349
+ }
350
+ if (trimmed === "/reset-session") {
351
+ return {
352
+ handled: true,
353
+ response: await promptQueue.enqueue(async () => {
354
+ return agentService.resetSession();
355
+ })
356
+ };
357
+ }
358
+ return {
359
+ handled: true,
360
+ response: `Unknown command: ${trimmed}. Try /help.`
361
+ };
362
+ }
363
+
364
+ // src/message-chunker.ts
365
+ var DISCORD_MESSAGE_LIMIT = 2000;
366
+ var SAFE_MESSAGE_LIMIT = 1900;
367
+ function chunkMessage(text) {
368
+ if (text.length <= SAFE_MESSAGE_LIMIT) {
369
+ return [text];
370
+ }
371
+ const chunks = [];
372
+ let remaining = text;
373
+ while (remaining.length > SAFE_MESSAGE_LIMIT) {
374
+ const candidate = remaining.slice(0, SAFE_MESSAGE_LIMIT);
375
+ const splitIndex = Math.max(candidate.lastIndexOf(`
376
+
377
+ `), candidate.lastIndexOf(`
378
+ `), candidate.lastIndexOf(" "));
379
+ const boundary = splitIndex > 0 ? splitIndex : SAFE_MESSAGE_LIMIT;
380
+ chunks.push(remaining.slice(0, boundary).trim());
381
+ remaining = remaining.slice(boundary).trim();
382
+ }
383
+ if (remaining.length > 0) {
384
+ chunks.push(remaining);
385
+ }
386
+ return chunks.filter((chunk) => chunk.length > 0).map((chunk) => chunk.slice(0, DISCORD_MESSAGE_LIMIT));
387
+ }
388
+
389
+ // src/discord-client.ts
390
+ async function startDiscordClient(config, agentService, promptQueue) {
391
+ const client = new Client({
392
+ intents: [GatewayIntentBits.DirectMessages, GatewayIntentBits.MessageContent],
393
+ partials: [Partials.Channel]
394
+ });
395
+ client.once(Events.ClientReady, async (readyClient) => {
396
+ console.log(`[discord] logged in as ${readyClient.user.tag}`);
397
+ if (!config.startupMessage) {
398
+ return;
399
+ }
400
+ try {
401
+ const user = await readyClient.users.fetch(config.discordAllowedUserId);
402
+ const dmChannel = await user.createDM();
403
+ await dmChannel.send(config.startupMessage);
404
+ console.log("[discord] sent startup dm", {
405
+ userId: config.discordAllowedUserId
406
+ });
407
+ } catch (error) {
408
+ console.error("[discord] failed to send startup dm", error);
409
+ }
410
+ });
411
+ client.on(Events.MessageCreate, async (message) => {
412
+ console.log("[discord] message received", {
413
+ messageId: message.id,
414
+ authorId: message.author.id,
415
+ channelType: message.channel.type,
416
+ content: message.content
417
+ });
418
+ try {
419
+ await onMessage(message, config, agentService, promptQueue);
420
+ } catch (error) {
421
+ console.error("[discord] message handling failed", error);
422
+ await sendReply(message, "The bot hit an error while handling that message.");
423
+ }
424
+ });
425
+ await client.login(config.discordBotToken);
426
+ return client;
427
+ }
428
+ async function onMessage(message, config, agentService, promptQueue) {
429
+ if (message.author.bot) {
430
+ console.log("[discord] ignored bot message", { messageId: message.id });
431
+ return;
432
+ }
433
+ if (message.author.id !== config.discordAllowedUserId) {
434
+ console.log("[discord] ignored unauthorized user", {
435
+ messageId: message.id,
436
+ authorId: message.author.id
437
+ });
438
+ return;
439
+ }
440
+ if (message.channel.type !== ChannelType.DM) {
441
+ console.log("[discord] ignored non-dm message", {
442
+ messageId: message.id,
443
+ channelType: message.channel.type
444
+ });
445
+ return;
446
+ }
447
+ const content = message.content.trim();
448
+ if (!content) {
449
+ console.log("[discord] ignored empty message", { messageId: message.id });
450
+ return;
451
+ }
452
+ const commandResult = await handleCommand(content, agentService, promptQueue);
453
+ if (commandResult.handled) {
454
+ console.log("[discord] command handled", {
455
+ messageId: message.id,
456
+ command: content,
457
+ hasResponse: Boolean(commandResult.response)
458
+ });
459
+ if (commandResult.response) {
460
+ await sendReply(message, commandResult.response);
461
+ }
462
+ return;
463
+ }
464
+ if (!message.channel.isSendable()) {
465
+ console.log("[discord] channel is not sendable", { messageId: message.id });
466
+ return;
467
+ }
468
+ await message.channel.sendTyping();
469
+ const queuePosition = promptQueue.getSnapshot().pending;
470
+ console.log("[queue] enqueue request", {
471
+ messageId: message.id,
472
+ queuePosition
473
+ });
474
+ if (queuePosition > 0) {
475
+ await sendReply(message, `Queued. ${queuePosition} request(s) ahead of this one.`);
476
+ }
477
+ const response = await promptQueue.enqueue(async () => {
478
+ console.log(`[queue] processing message ${message.id}`);
479
+ return agentService.prompt(content);
480
+ });
481
+ console.log("[discord] response ready", {
482
+ messageId: message.id,
483
+ responseLength: response.length,
484
+ preview: response.slice(0, 200)
485
+ });
486
+ await sendReply(message, response);
487
+ }
488
+ async function sendReply(message, text) {
489
+ if (!message.channel.isSendable()) {
490
+ console.log("[discord] reply skipped, channel not sendable", { messageId: message.id });
491
+ return;
492
+ }
493
+ const chunks = chunkMessage(text);
494
+ console.log("[discord] sending reply", {
495
+ messageId: message.id,
496
+ chunkCount: chunks.length,
497
+ textLength: text.length
498
+ });
499
+ const [firstChunk, ...remainingChunks] = chunks;
500
+ if (!firstChunk) {
501
+ return;
502
+ }
503
+ await message.reply(firstChunk);
504
+ for (const chunk of remainingChunks) {
505
+ await message.channel.send(chunk);
506
+ }
507
+ }
508
+
509
+ // src/prompt-queue.ts
510
+ class PromptQueue {
511
+ queue = [];
512
+ running = false;
513
+ enqueue(task) {
514
+ return new Promise((resolve, reject) => {
515
+ this.queue.push(async () => {
516
+ try {
517
+ resolve(await task());
518
+ } catch (error) {
519
+ reject(error);
520
+ }
521
+ });
522
+ this.kick();
523
+ });
524
+ }
525
+ getSnapshot() {
526
+ return {
527
+ pending: this.queue.length,
528
+ busy: this.running
529
+ };
530
+ }
531
+ kick() {
532
+ if (this.running) {
533
+ return;
534
+ }
535
+ const next = this.queue.shift();
536
+ if (!next) {
537
+ return;
538
+ }
539
+ this.running = true;
540
+ next().finally(() => {
541
+ this.running = false;
542
+ this.kick();
543
+ });
544
+ }
545
+ }
546
+
547
+ // src/prompt-context.ts
548
+ function buildTimeContextPrompt(userMessage, options = {}) {
549
+ const timeZone = options.timeZone || "UTC";
550
+ const locale = options.locale || "en-AU";
551
+ const locationLabel = options.locationLabel || timeZone;
552
+ const now = options.now || new Date;
553
+ const localTime = new Intl.DateTimeFormat(locale, {
554
+ timeZone,
555
+ day: "numeric",
556
+ month: "short",
557
+ year: "2-digit",
558
+ hour: "2-digit",
559
+ minute: "2-digit",
560
+ hour12: false
561
+ }).format(now);
562
+ const trimmedMessage = userMessage.trim();
563
+ return [`Local time: ${localTime}, ${locationLabel}`, "", "User message:", trimmedMessage].join(`
564
+ `);
565
+ }
566
+
567
+ // src/index.ts
568
+ async function startDiscordPiBridge(config) {
569
+ const resolvedConfig = resolveConfig(config);
570
+ const agentService = new AgentService(resolvedConfig);
571
+ const promptQueue = new PromptQueue;
572
+ console.log("[boot] initializing persistent agent session");
573
+ await agentService.initialize();
574
+ console.log("[boot] agent ready", agentService.getStatus());
575
+ const client = await startDiscordClient(resolvedConfig, agentService, promptQueue);
576
+ const stop = createStopHandler(client, agentService, resolvedConfig);
577
+ if (resolvedConfig.shutdownOnSignals) {
578
+ registerSignalHandlers(stop);
579
+ }
580
+ return {
581
+ client,
582
+ stop,
583
+ getStatus: () => {
584
+ return agentService.getStatus();
585
+ }
586
+ };
587
+ }
588
+ function createStopHandler(client, agentService, config) {
589
+ let stopped = false;
590
+ return async () => {
591
+ if (stopped) {
592
+ return;
593
+ }
594
+ stopped = true;
595
+ console.log("[shutdown] stopping discord pi bridge", {
596
+ cwd: config.cwd,
597
+ agentDir: config.agentDir
598
+ });
599
+ client.destroy();
600
+ await agentService.shutdown();
601
+ };
602
+ }
603
+ function registerSignalHandlers(stop) {
604
+ const handleSignal = (signal) => {
605
+ stop().finally(() => {
606
+ console.log(`[shutdown] received ${signal}`);
607
+ });
608
+ };
609
+ process.on("SIGINT", () => {
610
+ handleSignal("SIGINT");
611
+ });
612
+ process.on("SIGTERM", () => {
613
+ handleSignal("SIGTERM");
614
+ });
615
+ }
616
+ export {
617
+ startDiscordPiBridge,
618
+ resolveConfig,
619
+ loadDiscordPiBridgeConfigFromEnv,
620
+ buildTimeContextPrompt
621
+ };
@@ -0,0 +1 @@
1
+ export declare function chunkMessage(text: string): string[];
@@ -0,0 +1,7 @@
1
+ export type TimeContextPromptOptions = {
2
+ timeZone?: string;
3
+ locale?: string;
4
+ locationLabel?: string;
5
+ now?: Date;
6
+ };
7
+ export declare function buildTimeContextPrompt(userMessage: string, options?: TimeContextPromptOptions): string;
@@ -0,0 +1,13 @@
1
+ type QueueTask<T> = () => Promise<T>;
2
+ type QueueSnapshot = {
3
+ pending: number;
4
+ busy: boolean;
5
+ };
6
+ export declare class PromptQueue {
7
+ private readonly queue;
8
+ private running;
9
+ enqueue<T>(task: QueueTask<T>): Promise<T>;
10
+ getSnapshot(): QueueSnapshot;
11
+ private kick;
12
+ }
13
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
2
+ type CollectReplyOptions = {
3
+ logPrefix?: string;
4
+ };
5
+ export declare function collectReply(session: AgentSession, prompt: string, options?: CollectReplyOptions): Promise<string>;
6
+ export {};
@@ -0,0 +1,35 @@
1
+ import type { Client } from "discord.js";
2
+ export type PromptTransform = (input: string) => string | Promise<string>;
3
+ export type DiscordPiBridgeConfig = {
4
+ discordBotToken: string;
5
+ discordAllowedUserId: string;
6
+ cwd: string;
7
+ agentDir?: string;
8
+ modelProvider?: string;
9
+ modelId?: string;
10
+ promptTransform?: PromptTransform;
11
+ startupMessage?: string | false;
12
+ shutdownOnSignals?: boolean;
13
+ };
14
+ export type ResolvedDiscordPiBridgeConfig = {
15
+ discordBotToken: string;
16
+ discordAllowedUserId: string;
17
+ cwd: string;
18
+ agentDir: string;
19
+ modelProvider: string;
20
+ modelId: string;
21
+ promptTransform: PromptTransform;
22
+ startupMessage: string | false;
23
+ shutdownOnSignals: boolean;
24
+ };
25
+ export type AgentStatus = {
26
+ sessionId: string;
27
+ sessionFile: string | undefined;
28
+ model: string;
29
+ streaming: boolean;
30
+ };
31
+ export type DiscordPiBridge = {
32
+ client: Client;
33
+ stop: () => Promise<void>;
34
+ getStatus: () => AgentStatus;
35
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@friendlyrobot/discord-pi-agent",
3
+ "version": "0.1.0",
4
+ "description": "Reusable Discord gateway bridge for persistent pi agent sessions",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "build": "rm -rf dist && bun build ./src/index.ts --outdir ./dist --target node --format esm --packages external && tsc -p tsconfig.json --emitDeclarationOnly --declaration --declarationMap false",
24
+ "typecheck": "tsc --noEmit -p tsconfig.json"
25
+ },
26
+ "dependencies": {
27
+ "@mariozechner/pi-ai": "latest",
28
+ "@mariozechner/pi-coding-agent": "latest",
29
+ "discord.js": "^14.19.3",
30
+ "dotenv": "^16.6.1"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^24.6.0",
34
+ "typescript": "^5.9.3"
35
+ }
36
+ }