@friendlyrobot/discord-pi-agent 0.21.3 → 0.21.4
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.
|
@@ -138,10 +138,10 @@ function getLatestAssistantText(messages) {
|
|
|
138
138
|
}
|
|
139
139
|
return latestAssistantMessage.content
|
|
140
140
|
.filter((item) => {
|
|
141
|
-
return item.type === "text";
|
|
141
|
+
return item.type === "text" && typeof item.text === "string";
|
|
142
142
|
})
|
|
143
143
|
.map((item) => {
|
|
144
|
-
return item.text
|
|
144
|
+
return item.text;
|
|
145
145
|
})
|
|
146
146
|
.join("\n")
|
|
147
147
|
.trim();
|
package/dist/discord-auth.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type Message } from "discord.js";
|
|
2
|
+
import type { SessionScope } from "./session-registry";
|
|
2
3
|
import type { GatewayAccessConfig } from "./types";
|
|
3
4
|
export declare function getAuthorDisplayName(message: Message): string;
|
|
4
5
|
/**
|
|
5
6
|
* Determine the session scope from an incoming message.
|
|
6
7
|
* Returns null for unsupported channel types (silently ignored).
|
|
7
8
|
*/
|
|
8
|
-
export declare function resolveMessageScope(message: Message):
|
|
9
|
-
export declare function isAuthorizedMessage(message: Message, scope:
|
|
9
|
+
export declare function resolveMessageScope(message: Message): SessionScope | null;
|
|
10
|
+
export declare function isAuthorizedMessage(message: Message, scope: SessionScope, accessConfig: GatewayAccessConfig): boolean;
|
|
@@ -1,59 +1,50 @@
|
|
|
1
|
-
import { executeSessionCommand } from "./session-commands";
|
|
2
1
|
import { readMediaAttachments, readTextAttachments, } from "./discord-attachments";
|
|
3
|
-
import {
|
|
4
|
-
import { addWorkingReaction, removeWorkingReaction, sendCommandReply, sendReply, } from "./discord-replies";
|
|
2
|
+
import { isAuthorizedMessage, resolveMessageScope } from "./discord-auth";
|
|
5
3
|
import { resolveMediaAttachmentsForPrompt } from "./discord-media-resolution";
|
|
4
|
+
import { addWorkingReaction, removeWorkingReaction, sendCommandReply, sendReply, } from "./discord-replies";
|
|
5
|
+
import { executeSessionCommand } from "./session-commands";
|
|
6
6
|
import { startTypingForChannel, stopTypingForChannel } from "./discord-typing";
|
|
7
7
|
import { createModuleLogger } from "./logger";
|
|
8
|
-
import { formatDiscordPromptTime, wrapXmlTag } from "./prompt-context";
|
|
8
|
+
import { buildDiscordMessageMetadata, formatDiscordPromptTime, wrapXmlTag, } from "./prompt-context";
|
|
9
9
|
import { runAgentTurn } from "./agent-turn-runner";
|
|
10
10
|
const logger = createModuleLogger("discord-message-handler");
|
|
11
|
-
/** Build a Discord metadata XML string for a message. */
|
|
12
|
-
function formatDiscordMessageMetadata(message, scope) {
|
|
13
|
-
const isThread = scope.startsWith("thread:") && message.channel.isThread();
|
|
14
|
-
const contextEntries = [
|
|
15
|
-
["scope", scope === "dm" ? "dm" : "thread"],
|
|
16
|
-
["sent_at", message.createdAt.toISOString()],
|
|
17
|
-
["sent_at_local", formatDiscordPromptTime(message.createdAt)],
|
|
18
|
-
["message_id", message.id],
|
|
19
|
-
[
|
|
20
|
-
"author_name",
|
|
21
|
-
getAuthorDisplayName(message).replace(/\s+/g, " ").trim() || undefined,
|
|
22
|
-
],
|
|
23
|
-
["author_id", message.author.id],
|
|
24
|
-
[
|
|
25
|
-
"thread_title",
|
|
26
|
-
isThread
|
|
27
|
-
? (message.channel.name ?? "").replace(/\s+/g, " ").trim()
|
|
28
|
-
: undefined,
|
|
29
|
-
],
|
|
30
|
-
["thread_id", isThread ? message.channel.id : undefined],
|
|
31
|
-
[
|
|
32
|
-
"forum_channel_id",
|
|
33
|
-
isThread ? (message.channel.parentId ?? undefined) : undefined,
|
|
34
|
-
],
|
|
35
|
-
].filter((entry) => {
|
|
36
|
-
return typeof entry[1] === "string" && entry[1].length > 0;
|
|
37
|
-
});
|
|
38
|
-
const contextJson = JSON.stringify(Object.fromEntries(contextEntries), null, 2);
|
|
39
|
-
return `<discord_message_context>${contextJson}</discord_message_context>`;
|
|
40
|
-
}
|
|
41
11
|
export async function handleDiscordMessage(message, config, agentService, sessionRegistry, accessConfig) {
|
|
42
|
-
|
|
43
|
-
|
|
12
|
+
const preparedMessage = await prepareDiscordMessage(message, accessConfig);
|
|
13
|
+
if (!preparedMessage) {
|
|
44
14
|
return;
|
|
45
15
|
}
|
|
46
|
-
|
|
47
|
-
|
|
16
|
+
logger.info({
|
|
17
|
+
scope: preparedMessage.scope,
|
|
18
|
+
content: preparedMessage.content,
|
|
19
|
+
}, "message received");
|
|
20
|
+
const channelKey = message.channel.id;
|
|
21
|
+
startTypingIfPossible(message, channelKey);
|
|
22
|
+
const { entry, created } = await sessionRegistry.getOrCreate(preparedMessage.scope);
|
|
23
|
+
logNewThreadSession(message, preparedMessage.scope, created);
|
|
24
|
+
const commandResult = await executeSessionCommand(preparedMessage.content, {
|
|
25
|
+
agentService,
|
|
26
|
+
promptQueue: entry.promptQueue,
|
|
27
|
+
session: entry.session,
|
|
28
|
+
scope: preparedMessage.scope,
|
|
29
|
+
workingEmoji: entry.workingEmoji,
|
|
30
|
+
});
|
|
31
|
+
if (commandResult.handled) {
|
|
32
|
+
await handleCommandResult(message, sessionRegistry, preparedMessage.scope, entry, commandResult, preparedMessage.content, channelKey);
|
|
48
33
|
return;
|
|
49
34
|
}
|
|
35
|
+
await processAgentPrompt(message, config, agentService, entry, preparedMessage, channelKey);
|
|
36
|
+
}
|
|
37
|
+
async function prepareDiscordMessage(message, accessConfig) {
|
|
38
|
+
if (message.author.bot || message.system) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
50
41
|
const scope = resolveMessageScope(message);
|
|
51
42
|
if (scope === null) {
|
|
52
43
|
logger.debug({
|
|
53
44
|
messageId: message.id,
|
|
54
45
|
channelType: message.channel.type,
|
|
55
46
|
}, "unsupported channel type, ignoring");
|
|
56
|
-
return;
|
|
47
|
+
return null;
|
|
57
48
|
}
|
|
58
49
|
if (!isAuthorizedMessage(message, scope, accessConfig)) {
|
|
59
50
|
logger.debug({
|
|
@@ -61,129 +52,144 @@ export async function handleDiscordMessage(message, config, agentService, sessio
|
|
|
61
52
|
authorId: message.author.id,
|
|
62
53
|
scope,
|
|
63
54
|
}, "unauthorized");
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
let content = message.content.trim();
|
|
67
|
-
const textAttachments = await readTextAttachments(message);
|
|
68
|
-
if (textAttachments.length > 0) {
|
|
69
|
-
const attachmentSuffix = textAttachments
|
|
70
|
-
.map((attachment) => {
|
|
71
|
-
return `\n\n--- Attachment: ${attachment.filename} ---\n${attachment.content}`;
|
|
72
|
-
})
|
|
73
|
-
.join("");
|
|
74
|
-
content = content
|
|
75
|
-
? content + attachmentSuffix
|
|
76
|
-
: textAttachments[0].content;
|
|
55
|
+
return null;
|
|
77
56
|
}
|
|
57
|
+
const content = await buildMessageContent(message);
|
|
78
58
|
const mediaAttachments = await readMediaAttachments(message);
|
|
79
59
|
if (!content && mediaAttachments.length === 0) {
|
|
80
60
|
logger.debug({ messageId: message.id }, "ignored empty message (no text or images)");
|
|
81
|
-
return;
|
|
61
|
+
return null;
|
|
82
62
|
}
|
|
83
|
-
|
|
84
|
-
// direction: "IN",
|
|
63
|
+
return {
|
|
85
64
|
scope,
|
|
86
|
-
// messageId: message.id,
|
|
87
|
-
// authorId: message.author.id,
|
|
88
|
-
// channelType: message.channel.type,
|
|
89
65
|
content,
|
|
90
|
-
|
|
91
|
-
|
|
66
|
+
mediaAttachments,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async function buildMessageContent(message) {
|
|
70
|
+
const baseContent = message.content.trim();
|
|
71
|
+
const textAttachments = await readTextAttachments(message);
|
|
72
|
+
if (textAttachments.length === 0) {
|
|
73
|
+
return baseContent;
|
|
74
|
+
}
|
|
75
|
+
const attachmentText = textAttachments
|
|
76
|
+
.map((attachment) => {
|
|
77
|
+
return `\n\n--- Attachment: ${attachment.filename} ---\n${attachment.content}`;
|
|
78
|
+
})
|
|
79
|
+
.join("");
|
|
80
|
+
if (!baseContent) {
|
|
81
|
+
return textAttachments[0]?.content ?? "";
|
|
82
|
+
}
|
|
83
|
+
return baseContent + attachmentText;
|
|
84
|
+
}
|
|
85
|
+
function startTypingIfPossible(message, channelKey) {
|
|
92
86
|
if (message.channel.isSendable()) {
|
|
93
87
|
startTypingForChannel(message.channel, channelKey);
|
|
94
88
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (created
|
|
98
|
-
|
|
99
|
-
scope,
|
|
100
|
-
threadName: message.channel.name,
|
|
101
|
-
}, "new thread session");
|
|
89
|
+
}
|
|
90
|
+
function logNewThreadSession(message, scope, created) {
|
|
91
|
+
if (!created || !scope.startsWith("thread:") || !message.channel.isThread()) {
|
|
92
|
+
return;
|
|
102
93
|
}
|
|
103
|
-
|
|
104
|
-
agentService,
|
|
105
|
-
promptQueue,
|
|
106
|
-
session,
|
|
94
|
+
logger.info({
|
|
107
95
|
scope,
|
|
108
|
-
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (archiveChannel.isSendable()) {
|
|
124
|
-
await archiveChannel.send(`\`\`\`\n${commandResult.response ?? "Archiving..."}\n\`\`\``);
|
|
125
|
-
}
|
|
126
|
-
try {
|
|
127
|
-
if (archiveChannel.isThread()) {
|
|
128
|
-
await archiveChannel.setArchived(true);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
catch (error) {
|
|
132
|
-
logger.error({ error }, "failed to archive thread");
|
|
133
|
-
}
|
|
134
|
-
await sessionRegistry.remove(scope);
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
logger.info({
|
|
138
|
-
messageId: message.id,
|
|
139
|
-
command: content,
|
|
140
|
-
hasResponse: Boolean(commandResult.response),
|
|
141
|
-
}, `command handled: ${content}`);
|
|
142
|
-
if (commandResult.response) {
|
|
143
|
-
await sendCommandReply(message, commandResult.response);
|
|
144
|
-
}
|
|
96
|
+
threadName: message.channel.name,
|
|
97
|
+
}, "new thread session");
|
|
98
|
+
}
|
|
99
|
+
async function handleCommandResult(message, sessionRegistry, scope, entry, commandResult, content, channelKey) {
|
|
100
|
+
stopTypingForChannel(channelKey);
|
|
101
|
+
if (commandResult.workingEmoji) {
|
|
102
|
+
entry.workingEmoji = commandResult.workingEmoji;
|
|
103
|
+
logger.info({ scope, emoji: commandResult.workingEmoji }, "working emoji updated");
|
|
104
|
+
}
|
|
105
|
+
if (commandResult.newSession) {
|
|
106
|
+
entry.session = commandResult.newSession;
|
|
107
|
+
logger.info({ scope, sessionId: commandResult.newSession.sessionId }, "session replaced");
|
|
108
|
+
}
|
|
109
|
+
if (commandResult.archive && scope.startsWith("thread:")) {
|
|
110
|
+
await archiveThreadSession(message, sessionRegistry, scope, commandResult);
|
|
145
111
|
return;
|
|
146
112
|
}
|
|
113
|
+
logger.info({
|
|
114
|
+
messageId: message.id,
|
|
115
|
+
command: content,
|
|
116
|
+
hasResponse: Boolean(commandResult.response),
|
|
117
|
+
}, `command handled: ${content}`);
|
|
118
|
+
if (commandResult.response) {
|
|
119
|
+
await sendCommandReply(message, commandResult.response);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function archiveThreadSession(message, sessionRegistry, scope, commandResult) {
|
|
123
|
+
logger.info({ scope }, "archiving thread");
|
|
124
|
+
if (message.channel.isSendable()) {
|
|
125
|
+
await message.channel.send(`\`\`\`\n${commandResult.response ?? "Archiving..."}\n\`\`\``);
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
if (message.channel.isThread()) {
|
|
129
|
+
await message.channel.setArchived(true);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
logger.error({ error }, "failed to archive thread");
|
|
134
|
+
}
|
|
135
|
+
await sessionRegistry.remove(scope);
|
|
136
|
+
}
|
|
137
|
+
async function processAgentPrompt(message, config, agentService, entry, preparedMessage, channelKey) {
|
|
147
138
|
if (!message.channel.isSendable()) {
|
|
148
139
|
stopTypingForChannel(channelKey);
|
|
149
140
|
logger.debug({ messageId: message.id }, "channel not sendable");
|
|
150
141
|
return;
|
|
151
142
|
}
|
|
152
143
|
await addWorkingReaction(message, entry.workingEmoji);
|
|
153
|
-
|
|
154
|
-
if (queuePosition > 0) {
|
|
155
|
-
await sendReply(message, `Queued. ${queuePosition} request(s) ahead of this one.`);
|
|
156
|
-
}
|
|
157
|
-
let response;
|
|
144
|
+
await notifyIfPromptQueued(message, entry.promptQueue.getSnapshot().pending);
|
|
158
145
|
try {
|
|
159
|
-
response = await promptQueue.enqueue(async () => {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const resolvedPromptMedia = await resolveMediaAttachmentsForPrompt(mediaAttachments, promptContent, session.model, config, agentService);
|
|
164
|
-
promptContent = resolvedPromptMedia.content;
|
|
165
|
-
if (resolvedPromptMedia.images.length > 0) {
|
|
166
|
-
promptImages = resolvedPromptMedia.images;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
const discordMetadata = formatDiscordMessageMetadata(message, scope);
|
|
170
|
-
const transformedPrompt = await config.promptTransform({
|
|
171
|
-
rawContent: promptContent,
|
|
172
|
-
discordMetadata,
|
|
173
|
-
now: () => wrapXmlTag("datetime", formatDiscordPromptTime(new Date(), {
|
|
174
|
-
timeZone: config.promptTimeZone,
|
|
175
|
-
locale: config.promptLocale,
|
|
176
|
-
})),
|
|
177
|
-
userMessage: () => wrapXmlTag("user_message", promptContent),
|
|
178
|
-
});
|
|
179
|
-
return runAgentTurn(session, transformedPrompt, {
|
|
180
|
-
images: promptImages,
|
|
146
|
+
const response = await entry.promptQueue.enqueue(async () => {
|
|
147
|
+
const promptInput = await buildPromptInput(message, config, agentService, entry, preparedMessage);
|
|
148
|
+
return runAgentTurn(entry.session, promptInput.prompt, {
|
|
149
|
+
images: promptInput.images,
|
|
181
150
|
});
|
|
182
151
|
});
|
|
152
|
+
await sendReply(message, response);
|
|
183
153
|
}
|
|
184
154
|
finally {
|
|
185
155
|
stopTypingForChannel(channelKey);
|
|
186
156
|
await removeWorkingReaction(message, entry.workingEmoji);
|
|
187
157
|
}
|
|
188
|
-
|
|
158
|
+
}
|
|
159
|
+
async function notifyIfPromptQueued(message, pendingCount) {
|
|
160
|
+
if (pendingCount > 0) {
|
|
161
|
+
await sendReply(message, `Queued. ${pendingCount} request(s) ahead of this one.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function buildPromptInput(message, config, agentService, entry, preparedMessage) {
|
|
165
|
+
const resolvedPromptMedia = await resolvePromptMedia(preparedMessage, entry, config, agentService);
|
|
166
|
+
const discordMetadata = buildDiscordMessageMetadata(message, preparedMessage.scope);
|
|
167
|
+
const prompt = await config.promptTransform({
|
|
168
|
+
rawContent: resolvedPromptMedia.content,
|
|
169
|
+
discordMetadata,
|
|
170
|
+
now: () => {
|
|
171
|
+
return wrapXmlTag("datetime", formatDiscordPromptTime(new Date(), {
|
|
172
|
+
timeZone: config.promptTimeZone,
|
|
173
|
+
locale: config.promptLocale,
|
|
174
|
+
}));
|
|
175
|
+
},
|
|
176
|
+
userMessage: () => {
|
|
177
|
+
return wrapXmlTag("user_message", resolvedPromptMedia.content);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
prompt,
|
|
182
|
+
images: resolvedPromptMedia.images.length > 0
|
|
183
|
+
? resolvedPromptMedia.images
|
|
184
|
+
: undefined,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
async function resolvePromptMedia(preparedMessage, entry, config, agentService) {
|
|
188
|
+
if (preparedMessage.mediaAttachments.length === 0) {
|
|
189
|
+
return {
|
|
190
|
+
content: preparedMessage.content,
|
|
191
|
+
images: [],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return resolveMediaAttachmentsForPrompt(preparedMessage.mediaAttachments, preparedMessage.content, entry.session.model, config, agentService);
|
|
189
195
|
}
|
package/dist/prompt-context.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import type { Message } from "discord.js";
|
|
2
|
+
import type { SessionScope } from "./session-registry";
|
|
1
3
|
export type DiscordPromptTimeFormatOptions = {
|
|
2
4
|
timeZone?: string;
|
|
3
5
|
locale?: string;
|
|
4
6
|
};
|
|
5
7
|
export declare function formatDiscordPromptTime(date: Date, options?: DiscordPromptTimeFormatOptions): string;
|
|
8
|
+
export declare function buildDiscordMessageMetadata(message: Message, scope: SessionScope): string;
|
|
6
9
|
/** Wrap content in an XML-style tag: `<tag>content</tag>`. */
|
|
7
10
|
export declare function wrapXmlTag(tag: string, content: string): string;
|
package/dist/prompt-context.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getAuthorDisplayName } from "./discord-auth";
|
|
1
2
|
export function formatDiscordPromptTime(date, options = {}) {
|
|
2
3
|
const timeZone = options.timeZone || "UTC";
|
|
3
4
|
const locale = options.locale || "en-AU";
|
|
@@ -13,6 +14,34 @@ export function formatDiscordPromptTime(date, options = {}) {
|
|
|
13
14
|
timeZoneName: "short",
|
|
14
15
|
}).format(date);
|
|
15
16
|
}
|
|
17
|
+
export function buildDiscordMessageMetadata(message, scope) {
|
|
18
|
+
const isThread = scope.startsWith("thread:") && message.channel.isThread();
|
|
19
|
+
const contextEntries = [
|
|
20
|
+
["scope", scope === "dm" ? "dm" : "thread"],
|
|
21
|
+
["sent_at", message.createdAt.toISOString()],
|
|
22
|
+
["sent_at_local", formatDiscordPromptTime(message.createdAt)],
|
|
23
|
+
["message_id", message.id],
|
|
24
|
+
[
|
|
25
|
+
"author_name",
|
|
26
|
+
getAuthorDisplayName(message).replace(/\s+/g, " ").trim() || undefined,
|
|
27
|
+
],
|
|
28
|
+
["author_id", message.author.id],
|
|
29
|
+
[
|
|
30
|
+
"thread_title",
|
|
31
|
+
isThread
|
|
32
|
+
? (message.channel.name ?? "").replace(/\s+/g, " ").trim()
|
|
33
|
+
: undefined,
|
|
34
|
+
],
|
|
35
|
+
["thread_id", isThread ? message.channel.id : undefined],
|
|
36
|
+
[
|
|
37
|
+
"forum_channel_id",
|
|
38
|
+
isThread ? (message.channel.parentId ?? undefined) : undefined,
|
|
39
|
+
],
|
|
40
|
+
].filter((entry) => {
|
|
41
|
+
return typeof entry[1] === "string" && entry[1].length > 0;
|
|
42
|
+
});
|
|
43
|
+
return wrapXmlTag("discord_message_context", JSON.stringify(Object.fromEntries(contextEntries), null, 2));
|
|
44
|
+
}
|
|
16
45
|
/** Wrap content in an XML-style tag: `<tag>content</tag>`. */
|
|
17
46
|
export function wrapXmlTag(tag, content) {
|
|
18
47
|
return `<${tag}>${content}</${tag}>`;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AgentService } from "./agent-service";
|
|
3
3
|
import { PromptQueue } from "./prompt-queue";
|
|
4
|
-
export type SessionScope = string
|
|
4
|
+
export type SessionScope = "dm" | `thread:${string}`;
|
|
5
5
|
export type ScopedSessionEntry = {
|
|
6
6
|
session: AgentSession;
|
|
7
7
|
promptQueue: PromptQueue;
|