pi-discord-bot 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,352 @@
1
+ # pi-discord-bot
2
+
3
+ A small Discord harness built around Pi primitives.
4
+
5
+ It keeps Pi as the agent core and adds a Discord transport layer with:
6
+ - one runner per conversation
7
+ - append-only session/log files
8
+ - Discord-native embeds, buttons, and select menus
9
+ - approval-gated Discord admin actions
10
+ - systemd-friendly local operation
11
+
12
+ ## Architecture
13
+
14
+ This project does **not** reimplement Pi’s core agent loop.
15
+ It uses:
16
+ - `@mariozechner/pi-agent-core` `Agent`
17
+ - `@mariozechner/pi-coding-agent` `AgentSession`
18
+ - `SessionManager`
19
+ - `SettingsManager`
20
+ - `AuthStorage`
21
+ - `ModelRegistry`
22
+
23
+ Main files:
24
+ - `src/main.ts` — startup and command routing
25
+ - `src/discord.ts` — Discord transport and interaction handling
26
+ - `src/discord-ui.ts` — embed/card builders and Discord UI helpers
27
+ - `src/agent.ts` — Pi runner/session wiring
28
+ - `src/agent-models.ts` — model resolution helpers
29
+ - `src/agent-tree.ts` — session tree formatting/browser helpers
30
+ - `src/context.ts` — sync `log.jsonl` into Pi session state
31
+ - `src/store.ts` — log + attachment persistence
32
+
33
+ ## Features
34
+
35
+ - DM support
36
+ - guild support with mention gating for normal chat
37
+ - text commands like `/tree` in chat
38
+ - Discord slash commands
39
+ - model picker cards
40
+ - scoped model selector cards
41
+ - settings card
42
+ - session tree browser card
43
+ - approval cards for destructive / mutating actions
44
+ - detail threads in guilds for verbose output
45
+ - reactions for progress:
46
+ - `🤔` thinking/working
47
+ - `🧑‍💻` tool activity
48
+ - long-message chunking
49
+ - image attachment support
50
+ - file attachment download + local-path handoff to Pi tools
51
+
52
+ ## Commands
53
+
54
+ Supported command surface:
55
+ - `/help`
56
+ - `/new`
57
+ - `/name <name>`
58
+ - `/session`
59
+ - `/tree`
60
+ - `/tree <entryId>`
61
+ - `/model`
62
+ - `/model <provider/model-or-search>`
63
+ - `/scoped-models`
64
+ - `/scoped-models <pattern[,pattern...]>`
65
+ - `/scoped-models clear`
66
+ - `/settings`
67
+ - `/compact [instructions]`
68
+ - `/reload`
69
+ - `/login [provider]`
70
+ - `/logout [provider]`
71
+ - `/stop`
72
+
73
+ Unsupported in Discord:
74
+ - `/resume`
75
+ - `/fork`
76
+ - `/copy`
77
+ - `/export`
78
+ - `/share`
79
+ - `/hotkeys`
80
+ - `/changelog`
81
+ - `/quit`
82
+ - `/exit`
83
+
84
+ ## Install
85
+
86
+ ### Use as a Pi package skill
87
+
88
+ From npm:
89
+
90
+ ```bash
91
+ pi install npm:pi-discord-bot
92
+ ```
93
+
94
+ From a local source checkout:
95
+
96
+ ```bash
97
+ pi install /absolute/path/to/pi-discord-bot
98
+ ```
99
+
100
+ Then start Pi and use:
101
+
102
+ ```text
103
+ /skill:pi-discord-bot
104
+ ```
105
+
106
+ See also:
107
+ - `docs/using-skill-in-pi.md`
108
+
109
+ ### Develop or run from source
110
+
111
+ ```bash
112
+ npm install
113
+ ```
114
+
115
+ ## Run locally
116
+
117
+ By default, the runtime workspace is **outside the repo**:
118
+
119
+ ```text
120
+ $XDG_STATE_HOME/pi-discord-bot/agent
121
+ ```
122
+
123
+ or, if `XDG_STATE_HOME` is unset:
124
+
125
+ ```text
126
+ ~/.local/state/pi-discord-bot/agent
127
+ ```
128
+
129
+ Run with the default external workspace:
130
+
131
+ ```bash
132
+ export DISCORD_TOKEN=...
133
+ npx tsx src/main.ts
134
+ ```
135
+
136
+ Or override it explicitly:
137
+
138
+ ```bash
139
+ PI_DISCORD_BOT_WORKDIR=/absolute/path/to/pi-discord-bot-agent npx tsx src/main.ts
140
+ ```
141
+
142
+ Or:
143
+
144
+ ```bash
145
+ npm run build
146
+ npm start
147
+ ```
148
+
149
+ ## Auth and model selection
150
+
151
+ Do **not** hardcode model choices in the bot.
152
+ This project uses Pi shared auth/settings/default model flow.
153
+
154
+ The bot reads model availability from Pi’s model registry and lets you choose with Discord UI.
155
+
156
+ ## Discord setup
157
+
158
+ Create a Discord application and bot, then enable:
159
+ - **Message Content Intent**
160
+
161
+ Recommended scopes:
162
+ - `bot`
163
+ - `applications.commands`
164
+
165
+ Recommended bot permissions:
166
+ - View Channels
167
+ - Send Messages
168
+ - Send Messages in Threads
169
+ - Create Public Threads
170
+ - Create Private Threads
171
+ - Read Message History
172
+ - Attach Files
173
+ - Use Slash Commands
174
+ - Manage Channels
175
+ - needed if you want channel/category/thread admin tools to work
176
+
177
+ ## Policy file
178
+
179
+ Create the runtime policy file in the external workspace:
180
+
181
+ ```bash
182
+ mkdir -p ~/.local/state/pi-discord-bot/agent
183
+ cp discord-policy.example.json ~/.local/state/pi-discord-bot/agent/discord-policy.json
184
+ ```
185
+
186
+ Or, if using a custom workspace path:
187
+
188
+ ```bash
189
+ mkdir -p "$PI_DISCORD_BOT_WORKDIR"
190
+ cp discord-policy.example.json "$PI_DISCORD_BOT_WORKDIR/discord-policy.json"
191
+ ```
192
+
193
+ Example:
194
+
195
+ ```json
196
+ {
197
+ "allowDMs": true,
198
+ "guildIds": ["123456789012345678"],
199
+ "channelIds": ["234567890123456789"],
200
+ "mentionMode": "mention-only",
201
+ "slashCommands": {
202
+ "enabled": true
203
+ }
204
+ }
205
+ ```
206
+
207
+ Notes:
208
+ - omit `guildIds` to allow all guilds
209
+ - omit `channelIds` to allow all channels
210
+ - `mentionMode: "mention-only"` means normal non-command guild chat must mention the bot
211
+ - text commands beginning with `/` are accepted without mentioning the bot
212
+ - slash commands can be registered globally or to a guild via policy/env
213
+
214
+ ## Attachments
215
+
216
+ Normal Discord message attachments are handled like this:
217
+ - images (`png`, `jpg`, `jpeg`, `gif`, `webp`) are passed to Pi as image input
218
+ - other files are downloaded into the conversation `attachments/` directory and their local paths are added to the prompt so Pi tools can inspect them
219
+
220
+ Slash-command attachments are not currently wired.
221
+
222
+ ## Runtime layout
223
+
224
+ Default runtime root:
225
+
226
+ ```text
227
+ ~/.local/state/pi-discord-bot/agent/
228
+ ```
229
+
230
+ Layout:
231
+
232
+ ```text
233
+ agent/
234
+ discord-policy.json
235
+ MEMORY.md
236
+ skills/
237
+ guild:123:channel:456/
238
+ MEMORY.md
239
+ log.jsonl
240
+ context.jsonl
241
+ attachments/
242
+ scratch/
243
+ skills/
244
+ dm:999/
245
+ ...
246
+ ```
247
+
248
+ ## Operator env/config guide
249
+
250
+ For operator-focused configuration, especially if you already use Pi CLI / TUI on the same machine, see:
251
+
252
+ - `docs/operator-env-config.md`
253
+ - `docs/using-skill-in-pi.md`
254
+ - `docs/publishing-checklist.md`
255
+ - `docs/github-release-flow.md`
256
+
257
+ Those guides explain:
258
+ - how to install the package into Pi from npm or source
259
+ - how to use `/skill:pi-discord-bot`
260
+ - `~/.config/pi-discord-bot.env`
261
+ - Pi shared auth/settings expectations
262
+ - workspace `discord-policy.json`
263
+ - systemd usage
264
+ - troubleshooting for operators
265
+
266
+ ## systemd
267
+
268
+ A user service file is included:
269
+ - `pi-discord-bot.service`
270
+
271
+ Typical setup:
272
+
273
+ ```bash
274
+ mkdir -p ~/.config/systemd/user ~/.config
275
+ cp pi-discord-bot.service ~/.config/systemd/user/
276
+ cp pi-discord-bot.env.example ~/.config/pi-discord-bot.env
277
+ $EDITOR ~/.config/pi-discord-bot.env
278
+ systemctl --user daemon-reload
279
+ systemctl --user enable --now pi-discord-bot.service
280
+ ```
281
+
282
+ Useful commands:
283
+
284
+ ```bash
285
+ systemctl --user status pi-discord-bot.service
286
+ journalctl --user -u pi-discord-bot.service -f
287
+ systemctl --user restart pi-discord-bot.service
288
+ ```
289
+
290
+ ## Troubleshooting
291
+
292
+ ### Slash command updated in code but not in Discord
293
+ If you register commands globally, Discord may take time to refresh the slash-command UI.
294
+ The bot may already support the command even if the Discord slash menu has not updated yet.
295
+
296
+ What you can do:
297
+ - wait for Discord to refresh global commands
298
+ - reopen/refresh the Discord client
299
+ - use a text command like `/tree` directly in chat while waiting
300
+
301
+ ### Text command vs slash command
302
+ There are two ways to invoke commands:
303
+ - **slash command UI**: `/tree`, `/model`, etc. from Discord command picker
304
+ - **plain text command message**: a normal message beginning with `/`
305
+
306
+ In guilds:
307
+ - normal non-command chat still follows mention gating
308
+ - plain text commands beginning with `/` are accepted without mentioning the bot
309
+
310
+ ### Attachments
311
+ Current attachment behavior:
312
+ - normal message image attachments are passed as image input
313
+ - normal message non-image files are downloaded and exposed as local files for Pi tools
314
+ - slash-command attachments are **not** currently supported
315
+
316
+ ### Admin tools do not work
317
+ Make sure the bot has the required Discord permissions for the action.
318
+ For server structure mutations, you usually need:
319
+ - View Channels
320
+ - Send Messages
321
+ - Read Message History
322
+ - Use Slash Commands
323
+ - Manage Channels
324
+ - Create Public Threads
325
+ - Create Private Threads
326
+
327
+ ## Debugging notes
328
+ The bot logs useful runtime events through the service log, including:
329
+ - startup and slash command registration
330
+ - skipped messages due to policy
331
+ - backfill activity
332
+ - queue/update warnings
333
+
334
+ Use:
335
+
336
+ ```bash
337
+ journalctl --user -u pi-discord-bot.service -f
338
+ ```
339
+
340
+ ## Security / hygiene
341
+
342
+ Runtime conversation data is stored under the external workspace directory (by default `~/.local/state/pi-discord-bot/agent/`) and may contain:
343
+ - user messages
344
+ - assistant outputs
345
+ - attachment metadata
346
+ - local file paths
347
+ - tool results
348
+
349
+ Treat the workspace directory as private runtime state.
350
+ Do not commit or share it.
351
+
352
+ The repo includes a `.gitignore` that excludes runtime state and build artifacts.
@@ -0,0 +1,10 @@
1
+ {
2
+ "allowDMs": true,
3
+ "guildIds": ["123456789012345678"],
4
+ "channelIds": ["234567890123456789"],
5
+ "mentionMode": "mention-only",
6
+ "slashCommands": {
7
+ "enabled": true,
8
+ "guildId": "123456789012345678"
9
+ }
10
+ }
@@ -0,0 +1,81 @@
1
+ export function resolveInitialModel(modelRegistry, settingsManager) {
2
+ const available = modelRegistry.getAvailable();
3
+ const all = modelRegistry.getAll();
4
+ const savedProvider = settingsManager.getDefaultProvider();
5
+ const savedModelId = settingsManager.getDefaultModel();
6
+ const saved = savedProvider && savedModelId ? modelRegistry.find(savedProvider, savedModelId) : undefined;
7
+ return saved ?? available[0] ?? all[0] ?? (() => {
8
+ throw new Error("No models available in Pi model registry");
9
+ })();
10
+ }
11
+ export function findModelByReference(modelRegistry, reference) {
12
+ const query = reference.trim().toLowerCase();
13
+ if (!query)
14
+ return { error: "Missing model reference." };
15
+ const models = modelRegistry.getAvailable();
16
+ if (models.length === 0)
17
+ return { error: "No available models. Authenticate with Pi first." };
18
+ const exact = models.find((model) => `${model.provider}/${model.id}`.toLowerCase() === query || model.id.toLowerCase() === query);
19
+ if (exact)
20
+ return { model: exact };
21
+ const partial = models.filter((model) => `${model.provider}/${model.id}`.toLowerCase().includes(query) || model.id.toLowerCase().includes(query));
22
+ if (partial.length === 1)
23
+ return { model: partial[0] };
24
+ if (partial.length === 0)
25
+ return { error: `No available model matches \`${reference}\`.` };
26
+ return {
27
+ error: `Model reference is ambiguous. Matches: ${partial.slice(0, 8).map((model) => `\`${model.provider}/${model.id}\``).join(", ")}`,
28
+ };
29
+ }
30
+ export function formatModel(model) {
31
+ return model ? `${model.provider}/${model.id}` : "(no model selected)";
32
+ }
33
+ export function modelSortKey(model) {
34
+ const providerPriority = model.provider === "openai-codex"
35
+ ? 0
36
+ : model.provider === "anthropic"
37
+ ? 1
38
+ : model.provider === "openai"
39
+ ? 2
40
+ : 3;
41
+ return [providerPriority, `${model.provider}/${model.id}`.toLowerCase()];
42
+ }
43
+ function wildcardToRegExp(pattern) {
44
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
45
+ return new RegExp(`^${escaped}$`, "i");
46
+ }
47
+ export function resolveScopedModels(modelRegistry, patterns) {
48
+ const available = modelRegistry.getAvailable();
49
+ const resolved = [];
50
+ const seen = new Set();
51
+ for (const raw of patterns) {
52
+ const pattern = raw.trim();
53
+ if (!pattern)
54
+ continue;
55
+ const exact = available.filter((model) => `${model.provider}/${model.id}`.toLowerCase() === pattern.toLowerCase() || model.id.toLowerCase() === pattern.toLowerCase());
56
+ const regex = wildcardToRegExp(pattern.includes("/") ? pattern : `*${pattern}*`);
57
+ const matches = exact.length > 0
58
+ ? exact
59
+ : available.filter((model) => regex.test(`${model.provider}/${model.id}`) || regex.test(model.id));
60
+ for (const model of matches) {
61
+ const key = `${model.provider}/${model.id}`;
62
+ if (seen.has(key))
63
+ continue;
64
+ seen.add(key);
65
+ resolved.push({ model });
66
+ }
67
+ }
68
+ return resolved;
69
+ }
70
+ export function imageMimeType(path) {
71
+ const ext = path.split(".").pop()?.toLowerCase();
72
+ if (ext === "png")
73
+ return "image/png";
74
+ if (ext === "jpg" || ext === "jpeg")
75
+ return "image/jpeg";
76
+ if (ext === "gif")
77
+ return "image/gif";
78
+ if (ext === "webp")
79
+ return "image/webp";
80
+ return undefined;
81
+ }
@@ -0,0 +1,40 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { findModelByReference, formatModel, imageMimeType, modelSortKey, resolveScopedModels } from "./agent-models.js";
4
+ const models = [
5
+ { provider: "anthropic", id: "claude-sonnet" },
6
+ { provider: "openai-codex", id: "gpt-5.4" },
7
+ { provider: "openai", id: "gpt-4.1" },
8
+ ];
9
+ const registry = {
10
+ getAvailable: () => models,
11
+ };
12
+ test("findModelByReference resolves exact provider/model", () => {
13
+ const result = findModelByReference(registry, "openai-codex/gpt-5.4");
14
+ assert.equal(result.model?.provider, "openai-codex");
15
+ assert.equal(result.model?.id, "gpt-5.4");
16
+ });
17
+ test("findModelByReference reports ambiguity", () => {
18
+ const result = findModelByReference(registry, "gpt");
19
+ assert.match(result.error ?? "", /ambiguous/i);
20
+ });
21
+ test("resolveScopedModels supports wildcard matching", () => {
22
+ const result = resolveScopedModels(registry, ["openai*"]);
23
+ assert.deepEqual(result.map((item) => `${item.model.provider}/${item.model.id}`), [
24
+ "openai-codex/gpt-5.4",
25
+ "openai/gpt-4.1",
26
+ ]);
27
+ });
28
+ test("modelSortKey prioritizes openai-codex first", () => {
29
+ const sorted = [...models].sort((a, b) => {
30
+ const [ap, ak] = modelSortKey(a);
31
+ const [bp, bk] = modelSortKey(b);
32
+ return ap - bp || ak.localeCompare(bk);
33
+ });
34
+ assert.equal(sorted[0].provider, "openai-codex");
35
+ });
36
+ test("formatModel and imageMimeType behave as expected", () => {
37
+ assert.equal(formatModel({ provider: "openai", id: "gpt-4.1" }), "openai/gpt-4.1");
38
+ assert.equal(imageMimeType("image.png"), "image/png");
39
+ assert.equal(imageMimeType("doc.txt"), undefined);
40
+ });
@@ -0,0 +1,68 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { loadSkillsFromDir } from "@mariozechner/pi-coding-agent";
4
+ export function getMemory(conversationDir) {
5
+ const parts = [];
6
+ for (const path of [join(conversationDir, "..", "MEMORY.md"), join(conversationDir, "MEMORY.md")]) {
7
+ if (!existsSync(path))
8
+ continue;
9
+ const text = readFileSync(path, "utf-8").trim();
10
+ if (text)
11
+ parts.push(text);
12
+ }
13
+ return parts.join("\n\n") || "(no memory yet)";
14
+ }
15
+ export function loadDiscordSkills(conversationDir) {
16
+ const map = new Map();
17
+ for (const dir of [join(conversationDir, "..", "skills"), join(conversationDir, "skills")]) {
18
+ for (const skill of loadSkillsFromDir({ dir, source: dir.includes("/skills") ? "workspace" : "channel" }).skills) {
19
+ map.set(skill.name, skill);
20
+ }
21
+ }
22
+ return [...map.values()];
23
+ }
24
+ export function buildAppendSystemPrompt(workspaceDir, conversationKey, memory) {
25
+ return `## Surface
26
+ - You are replying through a Discord harness.
27
+ - Keep the main reply readable.
28
+ - Put verbose tool details in thread replies when the harness chooses to.
29
+
30
+ ## Workspace
31
+ ${workspaceDir}/
32
+ ├── MEMORY.md
33
+ ├── skills/
34
+ └── ${conversationKey}/
35
+ ├── MEMORY.md
36
+ ├── log.jsonl
37
+ ├── context.jsonl
38
+ ├── attachments/
39
+ ├── scratch/
40
+ └── skills/
41
+
42
+ ## Memory
43
+ ${memory}
44
+
45
+ ## Conversation history
46
+ - log.jsonl is the long-term, searchable source of truth.
47
+ - context.jsonl is your active agent context.
48
+ - Older history may be outside context; inspect log.jsonl if needed.
49
+
50
+ ## Extra tools
51
+ - attach: upload a generated local file back to Discord.
52
+ - discord_list_channels: list guild channels visible to the bot.
53
+ - discord_resolve_channel: resolve a guild channel/category by name or ID.
54
+ - discord_resolve_member: resolve a guild member by username, display name, or ID.
55
+ - discord_resolve_role: resolve a guild role by name or ID.
56
+ - discord_create_channel: create a guild text channel after approval.
57
+ - discord_create_private_channel: create a private guild text channel after approval, optionally allowing extra members or roles.
58
+ - discord_create_category: create a category after approval.
59
+ - discord_rename_channel: rename a guild channel after approval.
60
+ - discord_move_channel: move a guild channel into or out of a category after approval.
61
+ - discord_delete_channel: delete a guild channel after approval.
62
+ - discord_create_thread: create a thread in the current guild channel after approval.
63
+ - discord_rename_thread: rename a thread after approval.
64
+ - discord_archive_thread: archive or unarchive a thread after approval.
65
+ - Use files in the conversation scratch directory for working files.
66
+ - Use \`attach\` to upload a generated file back to Discord.
67
+ - For Discord admin actions, confirm intent, prefer safe defaults, and use approval before creating or renaming channels or threads.`;
68
+ }
@@ -0,0 +1,101 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { imageMimeType } from "./agent-models.js";
4
+ import * as log from "./log.js";
5
+ import { syncLogToSessionManager } from "./context.js";
6
+ function truncate(text, max = 1800) {
7
+ return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
8
+ }
9
+ function codeBlock(text, language = "") {
10
+ const suffix = language ? language : "";
11
+ return `\`\`\`${suffix}\n${text}\n\`\`\``;
12
+ }
13
+ function formatToolResult(params) {
14
+ const status = params.isError ? "✗" : "✓";
15
+ const title = params.label && params.label !== params.toolName ? `${status} ${params.toolName} — ${params.label}` : `${status} ${params.toolName}`;
16
+ const resultObj = params.result;
17
+ const textSummary = Array.isArray(resultObj?.content) ? resultObj.content.filter((item) => item?.type === "text" && typeof item.text === "string").map((item) => item.text).join("\n") : "";
18
+ if (!params.isError) {
19
+ return textSummary.trim() ? `**${title}** · ${(params.durationMs / 1000).toFixed(1)}s\n\n${truncate(textSummary, 1200)}` : `**${title}** · ${(params.durationMs / 1000).toFixed(1)}s`;
20
+ }
21
+ return [`**${title}** · ${(params.durationMs / 1000).toFixed(1)}s`, "", "**Args**", codeBlock(params.argsText, "json"), "", "**Result**", codeBlock(params.resultText, "json")].join("\n");
22
+ }
23
+ export function wireSessionUpdates(session, runState) {
24
+ const enqueue = (fn) => {
25
+ runState.queue = runState.queue.then(fn).catch((err) => {
26
+ log.warn("discord update failed", err instanceof Error ? err.message : String(err));
27
+ });
28
+ };
29
+ session.subscribe((event) => {
30
+ if (!runState.ctx)
31
+ return;
32
+ const ctx = runState.ctx;
33
+ if (event.type === "tool_execution_start") {
34
+ const args = event.args;
35
+ runState.pendingTools.set(event.toolCallId, { toolName: event.toolName, label: args.label ?? event.toolName, args: event.args, startedAt: Date.now() });
36
+ enqueue(() => ctx.setToolActive(true));
37
+ }
38
+ if (event.type === "tool_execution_end") {
39
+ const pending = runState.pendingTools.get(event.toolCallId);
40
+ runState.pendingTools.delete(event.toolCallId);
41
+ const durationMs = pending ? Date.now() - pending.startedAt : 0;
42
+ const argsText = pending?.args ? truncate(JSON.stringify(pending.args, null, 2), 1200) : "{}";
43
+ const resultText = truncate(JSON.stringify(event.result, null, 2), 1800);
44
+ enqueue(() => ctx.setToolActive(false));
45
+ if (event.isError) {
46
+ const reply = formatToolResult({ toolName: event.toolName, label: pending?.label, durationMs, argsText, resultText, isError: event.isError, result: event.result });
47
+ enqueue(() => ctx.respondInThread(reply));
48
+ enqueue(() => ctx.respond(`Error: ${truncate(resultText, 200)}`, false));
49
+ }
50
+ }
51
+ if (event.type === "message_end" && event.message.role === "assistant") {
52
+ const assistant = event.message;
53
+ if (assistant.stopReason)
54
+ runState.stopReason = assistant.stopReason;
55
+ if (assistant.errorMessage)
56
+ runState.errorMessage = assistant.errorMessage;
57
+ const text = assistant.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
58
+ if (text.trim()) {
59
+ enqueue(async () => {
60
+ await ctx.setWorking(false);
61
+ await ctx.replaceMessage(text);
62
+ });
63
+ }
64
+ }
65
+ });
66
+ }
67
+ export async function runAgentTurn(params) {
68
+ const { ctx, conversationDir, scratchDir, sessionManager, agent, session, runState } = params;
69
+ await mkdir(scratchDir, { recursive: true });
70
+ await mkdir(conversationDir, { recursive: true });
71
+ syncLogToSessionManager(sessionManager, conversationDir, ctx.message.messageId);
72
+ agent.state.messages = sessionManager.buildSessionContext().messages;
73
+ session.setActiveToolsByName(session.getActiveToolNames());
74
+ runState.ctx = ctx;
75
+ runState.stopReason = "stop";
76
+ runState.errorMessage = undefined;
77
+ runState.pendingTools.clear();
78
+ const imageAttachments = [];
79
+ const otherAttachments = [];
80
+ for (const attachment of ctx.message.attachments) {
81
+ const mimeType = imageMimeType(attachment.local);
82
+ if (mimeType && existsSync(attachment.local)) {
83
+ imageAttachments.push({ type: "image", mimeType, data: readFileSync(attachment.local).toString("base64") });
84
+ }
85
+ else {
86
+ otherAttachments.push(attachment.local);
87
+ }
88
+ }
89
+ let prompt = `[${ctx.message.userName}]: ${ctx.message.text}`;
90
+ if (otherAttachments.length > 0)
91
+ prompt += `\n\n<discord_attachments>\n${otherAttachments.join("\n")}\n</discord_attachments>`;
92
+ await ctx.setTyping(true);
93
+ await ctx.setWorking(true);
94
+ await session.prompt(prompt, imageAttachments.length > 0 ? { images: imageAttachments } : undefined);
95
+ await runState.queue;
96
+ await ctx.setTyping(false);
97
+ await ctx.setWorking(false);
98
+ const result = { stopReason: runState.stopReason, errorMessage: runState.errorMessage };
99
+ runState.ctx = null;
100
+ return result;
101
+ }