@friendlyrobot/discord-pi-agent 0.21.4 → 0.22.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.
@@ -1,7 +1,14 @@
1
1
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
2
  import type { ImageContent } from "@earendil-works/pi-ai";
3
+ type ToolLifecycleEvent = {
4
+ toolName: string;
5
+ toolCallId: string;
6
+ isError?: boolean;
7
+ };
3
8
  type CollectReplyOptions = {
4
9
  images?: ImageContent[];
10
+ onToolStart?: (event: ToolLifecycleEvent) => void | Promise<void>;
11
+ onToolEnd?: (event: ToolLifecycleEvent) => void | Promise<void>;
5
12
  };
6
13
  export declare function runAgentTurn(session: AgentSession, prompt: string, options?: CollectReplyOptions): Promise<string>;
7
14
  export {};
@@ -33,6 +33,10 @@ export async function runAgentTurn(session, prompt, options = {}) {
33
33
  toolCount += 1;
34
34
  const input = event.toolName === "bash" ? event.args.command : event.args;
35
35
  toolInputsByCallId.set(event.toolCallId, input);
36
+ void options.onToolStart?.({
37
+ toolName: event.toolName,
38
+ toolCallId: event.toolCallId,
39
+ });
36
40
  if (event.toolName === "bash") {
37
41
  debugPrint(input, "CMD");
38
42
  // logger.debug(
@@ -52,6 +56,11 @@ export async function runAgentTurn(session, prompt, options = {}) {
52
56
  if (event.type === "tool_execution_end") {
53
57
  const input = toolInputsByCallId.get(event.toolCallId);
54
58
  toolInputsByCallId.delete(event.toolCallId);
59
+ void options.onToolEnd?.({
60
+ toolName: event.toolName,
61
+ toolCallId: event.toolCallId,
62
+ isError: event.isError,
63
+ });
55
64
  if (event.toolName === "bash") {
56
65
  debugPrint(extractToolOutput(event.result), event.isError ? "BASH TOOL ERROR OUTPUT" : "BASH TOOL OUTPUT");
57
66
  // logger.debug(
@@ -1,13 +1,20 @@
1
1
  import { readMediaAttachments, readTextAttachments, } from "./discord-attachments";
2
2
  import { isAuthorizedMessage, resolveMessageScope } from "./discord-auth";
3
3
  import { resolveMediaAttachmentsForPrompt } from "./discord-media-resolution";
4
- import { addWorkingReaction, removeWorkingReaction, sendCommandReply, sendReply, } from "./discord-replies";
4
+ import { addReaction, addWorkingReaction, removeReaction, removeWorkingReaction, sendCommandReply, sendReply, } from "./discord-replies";
5
5
  import { executeSessionCommand } from "./session-commands";
6
6
  import { startTypingForChannel, stopTypingForChannel } from "./discord-typing";
7
7
  import { createModuleLogger } from "./logger";
8
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
+ const TOOL_REACTION_EMOJIS = {
12
+ bash: "🖥️",
13
+ edit: "✏️",
14
+ read: "👀",
15
+ write: "📝",
16
+ };
17
+ const DEFAULT_TOOL_REACTION_EMOJI = "🔧";
11
18
  export async function handleDiscordMessage(message, config, agentService, sessionRegistry, accessConfig) {
12
19
  const preparedMessage = await prepareDiscordMessage(message, accessConfig);
13
20
  if (!preparedMessage) {
@@ -134,6 +141,9 @@ async function archiveThreadSession(message, sessionRegistry, scope, commandResu
134
141
  }
135
142
  await sessionRegistry.remove(scope);
136
143
  }
144
+ function resolveToolReactionEmoji(toolName) {
145
+ return TOOL_REACTION_EMOJIS[toolName] ?? DEFAULT_TOOL_REACTION_EMOJI;
146
+ }
137
147
  async function processAgentPrompt(message, config, agentService, entry, preparedMessage, channelKey) {
138
148
  if (!message.channel.isSendable()) {
139
149
  stopTypingForChannel(channelKey);
@@ -142,17 +152,46 @@ async function processAgentPrompt(message, config, agentService, entry, prepared
142
152
  }
143
153
  await addWorkingReaction(message, entry.workingEmoji);
144
154
  await notifyIfPromptQueued(message, entry.promptQueue.getSnapshot().pending);
155
+ const toolEmojiByCallId = new Map();
156
+ const activeToolCountByEmoji = new Map();
145
157
  try {
146
158
  const response = await entry.promptQueue.enqueue(async () => {
147
159
  const promptInput = await buildPromptInput(message, config, agentService, entry, preparedMessage);
148
160
  return runAgentTurn(entry.session, promptInput.prompt, {
149
161
  images: promptInput.images,
162
+ onToolStart: async ({ toolName, toolCallId }) => {
163
+ const emoji = resolveToolReactionEmoji(toolName);
164
+ toolEmojiByCallId.set(toolCallId, emoji);
165
+ const activeCount = activeToolCountByEmoji.get(emoji) ?? 0;
166
+ activeToolCountByEmoji.set(emoji, activeCount + 1);
167
+ if (activeCount === 0) {
168
+ await addReaction(message, emoji);
169
+ }
170
+ },
171
+ onToolEnd: async ({ toolCallId }) => {
172
+ const emoji = toolEmojiByCallId.get(toolCallId);
173
+ if (!emoji) {
174
+ return;
175
+ }
176
+ toolEmojiByCallId.delete(toolCallId);
177
+ const activeCount = activeToolCountByEmoji.get(emoji) ?? 0;
178
+ if (activeCount <= 1) {
179
+ activeToolCountByEmoji.delete(emoji);
180
+ await removeReaction(message, emoji);
181
+ return;
182
+ }
183
+ activeToolCountByEmoji.set(emoji, activeCount - 1);
184
+ },
150
185
  });
151
186
  });
152
187
  await sendReply(message, response);
153
188
  }
154
189
  finally {
155
190
  stopTypingForChannel(channelKey);
191
+ const activeEmojis = Array.from(activeToolCountByEmoji.keys());
192
+ await Promise.all(activeEmojis.map(async (emoji) => {
193
+ await removeReaction(message, emoji);
194
+ }));
156
195
  await removeWorkingReaction(message, entry.workingEmoji);
157
196
  }
158
197
  }
@@ -1,5 +1,7 @@
1
1
  import type { Message } from "discord.js";
2
2
  export declare const DEFAULT_WORKING_EMOJI = "\u2699\uFE0F";
3
+ export declare function addReaction(message: Message, emoji: string): Promise<void>;
4
+ export declare function removeReaction(message: Message, emoji: string): Promise<void>;
3
5
  export declare function addWorkingReaction(message: Message, emoji?: string): Promise<void>;
4
6
  export declare function removeWorkingReaction(message: Message, emoji?: string): Promise<void>;
5
7
  export declare function sendReply(message: Message, text: string): Promise<void>;
@@ -35,25 +35,59 @@ function chunkByLines(text, maxSize) {
35
35
  return chunks;
36
36
  }
37
37
  export const DEFAULT_WORKING_EMOJI = "⚙️";
38
- export async function addWorkingReaction(message, emoji = DEFAULT_WORKING_EMOJI) {
38
+ function normalizeEmoji(value) {
39
+ return value.normalize("NFKC").replace(/\uFE0F/g, "");
40
+ }
41
+ function findReactionByEmoji(message, emoji) {
42
+ const directMatch = message.reactions.cache.get(emoji);
43
+ if (directMatch) {
44
+ return directMatch;
45
+ }
46
+ const normalizedEmoji = normalizeEmoji(emoji);
47
+ const normalizedMatch = Array.from(message.reactions.cache.entries()).find(([key, reaction]) => {
48
+ if (normalizeEmoji(String(key)) === normalizedEmoji) {
49
+ return true;
50
+ }
51
+ if (typeof reaction.emoji?.name === "string") {
52
+ return normalizeEmoji(reaction.emoji.name) === normalizedEmoji;
53
+ }
54
+ return false;
55
+ })?.[1];
56
+ if (!normalizedMatch) {
57
+ logger.debug({
58
+ messageId: message.id,
59
+ emoji,
60
+ normalizedEmoji,
61
+ reactionKeys: Array.from(message.reactions.cache.keys()),
62
+ }, "reaction not found in cache");
63
+ }
64
+ return normalizedMatch;
65
+ }
66
+ export async function addReaction(message, emoji) {
39
67
  try {
40
68
  await message.react(emoji);
41
69
  }
42
70
  catch (error) {
43
- logger.debug({ messageId: message.id, error }, "failed to add working reaction");
71
+ logger.debug({ messageId: message.id, emoji, error }, "failed to add reaction");
44
72
  }
45
73
  }
46
- export async function removeWorkingReaction(message, emoji = DEFAULT_WORKING_EMOJI) {
74
+ export async function removeReaction(message, emoji) {
47
75
  try {
48
- const reaction = message.reactions.cache.get(emoji);
76
+ const reaction = findReactionByEmoji(message, emoji);
49
77
  if (reaction) {
50
78
  await reaction.users.remove(message.client.user);
51
79
  }
52
80
  }
53
81
  catch (error) {
54
- logger.debug({ messageId: message.id, error }, "failed to remove working reaction");
82
+ logger.debug({ messageId: message.id, emoji, error }, "failed to remove reaction");
55
83
  }
56
84
  }
85
+ export async function addWorkingReaction(message, emoji = DEFAULT_WORKING_EMOJI) {
86
+ await addReaction(message, emoji);
87
+ }
88
+ export async function removeWorkingReaction(message, emoji = DEFAULT_WORKING_EMOJI) {
89
+ await removeReaction(message, emoji);
90
+ }
57
91
  export async function sendReply(message, text) {
58
92
  const channel = message.channel;
59
93
  if (!channel.isSendable()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@friendlyrobot/discord-pi-agent",
3
- "version": "0.21.4",
3
+ "version": "0.22.1",
4
4
  "description": "Reusable Discord gateway for persistent pi agent sessions",
5
5
  "license": "MIT",
6
6
  "type": "module",