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 +21 -0
- package/README.md +352 -0
- package/discord-policy.example.json +10 -0
- package/dist/agent-models.js +81 -0
- package/dist/agent-models.test.js +40 -0
- package/dist/agent-prompt.js +68 -0
- package/dist/agent-runner.js +101 -0
- package/dist/agent-session-ops.js +157 -0
- package/dist/agent-tools.js +224 -0
- package/dist/agent-tree.js +52 -0
- package/dist/agent-tree.test.js +31 -0
- package/dist/agent-types.js +1 -0
- package/dist/agent.js +93 -0
- package/dist/context.js +58 -0
- package/dist/context.test.js +35 -0
- package/dist/discord-context.js +190 -0
- package/dist/discord-guild-tools.js +142 -0
- package/dist/discord-interactions.js +142 -0
- package/dist/discord-policy.js +90 -0
- package/dist/discord-registry.js +22 -0
- package/dist/discord-registry.test.js +17 -0
- package/dist/discord-types.js +1 -0
- package/dist/discord-ui.js +172 -0
- package/dist/discord-ui.test.js +37 -0
- package/dist/discord.js +389 -0
- package/dist/log.js +9 -0
- package/dist/main.js +362 -0
- package/dist/store.js +60 -0
- package/docs/github-release-flow.md +237 -0
- package/docs/operator-env-config.md +278 -0
- package/docs/publishing-checklist.md +95 -0
- package/docs/using-skill-in-pi.md +128 -0
- package/package.json +66 -0
- package/pi-discord-bot.env.example +12 -0
- package/pi-discord-bot.service +16 -0
- package/skills/pi-discord-bot/SKILL.md +237 -0
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,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
|
+
}
|