@geminixiang/mikan 0.2.0 → 0.2.2

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +54 -181
  3. package/dist/adapters/discord/bot.d.ts.map +1 -1
  4. package/dist/adapters/discord/bot.js +5 -4
  5. package/dist/adapters/discord/bot.js.map +1 -1
  6. package/dist/adapters/shared.d.ts +3 -2
  7. package/dist/adapters/shared.d.ts.map +1 -1
  8. package/dist/adapters/shared.js +11 -11
  9. package/dist/adapters/shared.js.map +1 -1
  10. package/dist/adapters/slack/bot.d.ts +1 -0
  11. package/dist/adapters/slack/bot.d.ts.map +1 -1
  12. package/dist/adapters/slack/bot.js +43 -1
  13. package/dist/adapters/slack/bot.js.map +1 -1
  14. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  15. package/dist/adapters/telegram/bot.js +2 -3
  16. package/dist/adapters/telegram/bot.js.map +1 -1
  17. package/dist/admin/portal.d.ts +27 -0
  18. package/dist/admin/portal.d.ts.map +1 -0
  19. package/dist/admin/portal.js +2029 -0
  20. package/dist/admin/portal.js.map +1 -0
  21. package/dist/admin/store.d.ts +22 -0
  22. package/dist/admin/store.d.ts.map +1 -0
  23. package/dist/admin/store.js +39 -0
  24. package/dist/admin/store.js.map +1 -0
  25. package/dist/agent.d.ts +5 -0
  26. package/dist/agent.d.ts.map +1 -1
  27. package/dist/agent.js +10 -9
  28. package/dist/agent.js.map +1 -1
  29. package/dist/commands/admin.d.ts +8 -0
  30. package/dist/commands/admin.d.ts.map +1 -0
  31. package/dist/commands/admin.js +59 -0
  32. package/dist/commands/admin.js.map +1 -0
  33. package/dist/commands/auto-reply.d.ts.map +1 -1
  34. package/dist/commands/auto-reply.js +8 -7
  35. package/dist/commands/auto-reply.js.map +1 -1
  36. package/dist/commands/index.d.ts.map +1 -1
  37. package/dist/commands/index.js +2 -0
  38. package/dist/commands/index.js.map +1 -1
  39. package/dist/commands/login.d.ts.map +1 -1
  40. package/dist/commands/login.js +38 -14
  41. package/dist/commands/login.js.map +1 -1
  42. package/dist/commands/model.d.ts.map +1 -1
  43. package/dist/commands/model.js +2 -2
  44. package/dist/commands/model.js.map +1 -1
  45. package/dist/commands/new.d.ts.map +1 -1
  46. package/dist/commands/new.js +13 -3
  47. package/dist/commands/new.js.map +1 -1
  48. package/dist/commands/sandbox.d.ts.map +1 -1
  49. package/dist/commands/sandbox.js +2 -2
  50. package/dist/commands/sandbox.js.map +1 -1
  51. package/dist/commands/session-view.d.ts.map +1 -1
  52. package/dist/commands/session-view.js +3 -6
  53. package/dist/commands/session-view.js.map +1 -1
  54. package/dist/commands/types.d.ts +11 -0
  55. package/dist/commands/types.d.ts.map +1 -1
  56. package/dist/commands/types.js.map +1 -1
  57. package/dist/commands/utils.d.ts +1 -0
  58. package/dist/commands/utils.d.ts.map +1 -1
  59. package/dist/commands/utils.js +5 -0
  60. package/dist/commands/utils.js.map +1 -1
  61. package/dist/config.d.ts.map +1 -1
  62. package/dist/config.js +5 -13
  63. package/dist/config.js.map +1 -1
  64. package/dist/events.d.ts +0 -1
  65. package/dist/events.d.ts.map +1 -1
  66. package/dist/events.js +2 -5
  67. package/dist/events.js.map +1 -1
  68. package/dist/file-guards.d.ts.map +1 -1
  69. package/dist/file-guards.js +10 -7
  70. package/dist/file-guards.js.map +1 -1
  71. package/dist/login/portal.d.ts +11 -1
  72. package/dist/login/portal.d.ts.map +1 -1
  73. package/dist/login/portal.js +57 -175
  74. package/dist/login/portal.js.map +1 -1
  75. package/dist/main.d.ts.map +1 -1
  76. package/dist/main.js +5 -1
  77. package/dist/main.js.map +1 -1
  78. package/dist/portal-shell.d.ts +30 -0
  79. package/dist/portal-shell.d.ts.map +1 -0
  80. package/dist/portal-shell.js +371 -0
  81. package/dist/portal-shell.js.map +1 -0
  82. package/dist/session-view/portal.d.ts.map +1 -1
  83. package/dist/session-view/portal.js +88 -242
  84. package/dist/session-view/portal.js.map +1 -1
  85. package/dist/store.d.ts +1 -0
  86. package/dist/store.d.ts.map +1 -1
  87. package/dist/store.js +30 -12
  88. package/dist/store.js.map +1 -1
  89. package/dist/vault.d.ts.map +1 -1
  90. package/dist/vault.js +2 -8
  91. package/dist/vault.js.map +1 -1
  92. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,39 @@ any release.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.2.2] - 2026-05-30
13
+
14
+ ### Added
15
+
16
+ - Add `/admin` portal with token-protected access via `/admin` and `/pi-admin` commands.
17
+ - Add admin APIs and UI for conversation settings, global settings, workspace preview, skills, events, session links, and login/vault links.
18
+ - Add model selection in the admin portal using `ModelRegistry.getAvailable()` instead of free-text fields.
19
+ - Share portal shell chrome across admin, session-view, and login/vault pages.
20
+ - Document admin, login, and session token boundaries in `docs/portal-auth-model.md`.
21
+
22
+ ### Changed
23
+
24
+ - Normalize slash-command feedback into muted compact summaries.
25
+ - Align desktop layout widths across all portal surfaces.
26
+
27
+ ## [0.2.1] - 2026-05-27
28
+
29
+ ### Added
30
+
31
+ - Add expanded command, configuration, deployment, development, session, and skill documentation.
32
+ - Add README hero and architecture diagrams.
33
+ - Add project agent development rules and Karpathy coding guidelines.
34
+
35
+ ### Changed
36
+
37
+ - Simplify the README overview and clarify sandbox runtime architecture documentation.
38
+ - Simplify file guards and retry helpers.
39
+
40
+ ### Fixed
41
+
42
+ - Retry attachment downloads.
43
+ - Skip retry sleep on the final attempt and clarify retry naming.
44
+
12
45
  ## [0.2.0] - 2026-05-23
13
46
 
14
47
  ### Changed
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="docs/assets/mikan.png" alt="mikan — multi-platform AI coding agent" width="100%">
3
+ </p>
4
+
1
5
  # mikan
2
6
 
3
7
  [![npm version](https://img.shields.io/npm/v/@geminixiang/mikan.svg)](https://www.npmjs.com/package/@geminixiang/mikan)
@@ -5,6 +9,18 @@
5
9
 
6
10
  A multi-platform AI coding agent for Slack, Telegram, and Discord.
7
11
 
12
+ ## Architecture
13
+
14
+ mikan keeps the chat record, agent session, and execution runtime separate:
15
+
16
+ ![mikan architecture](docs/assets/architecture.png)
17
+
18
+ - **Chat / conversation data** is the platform-facing record: `log.jsonl`, attachments, and conversation files.
19
+ - **Session orchestration** turns platform events into agent runs, handles top-level/thread/fork scopes, and persists pi-coding-agent structured context under `sessions/*.jsonl`.
20
+ - **pi-coding-agent harness** runs the model loop and calls mikan tools.
21
+ - **Sandbox runtime** is where tool commands execute: host, Docker container/image, Firecracker, or Cloudflare bridge.
22
+ - **Vault** provides runtime credentials as env vars and mounted secret files.
23
+
8
24
  ## Features
9
25
 
10
26
  - **Multi-platform** — Slack, Telegram, Discord adapters
@@ -17,14 +33,6 @@ A multi-platform AI coding agent for Slack, Telegram, and Discord.
17
33
  - **Events** — schedule one-shot or recurring tasks via JSON files
18
34
  - **Multi-provider** — any provider/model supported by `pi-ai`
19
35
 
20
- ## Platform Session Model
21
-
22
- | Platform | `sessionKey` Rule | Notes |
23
- | -------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
24
- | Slack | top-level / DM: `conversationId`; thread: `conversationId:threadTs` | thread inherits parent context at fork time only; branch changes do not merge back |
25
- | Discord | DM: `channelId`; shared top-level: `channelId:messageId`; reply/thread: rooted id | replies in shared channels continue the root message session; DM replies do not fork |
26
- | Telegram | private: `chatId`; shared top-level: `chatId:messageId`; reply chain: root reply | no native thread model; shared sessions are inferred from reply chains |
27
-
28
36
  ## Requirements
29
37
 
30
38
  - Node.js >= 22.19.0
@@ -49,7 +57,7 @@ All platforms share the same CLI:
49
57
  mikan [--state-dir=~/.mikan] [--sandbox=<mode>] <working-directory>
50
58
  ```
51
59
 
52
- Set the platform tokens you need (you can run multiple platforms at once):
60
+ Set the platform tokens you need; you can run multiple platforms at once:
53
61
 
54
62
  ```bash
55
63
  export SLACK_APP_TOKEN=xapp-...
@@ -58,19 +66,15 @@ export TELEGRAM_BOT_TOKEN=123456:ABC-...
58
66
  export DISCORD_BOT_TOKEN=MTI...
59
67
  ```
60
68
 
61
- ### Slack
62
-
63
- Create a Socket Mode app with the scopes and event subscriptions listed in [docs/slack-bot-minimal-guide.md](docs/slack-bot-minimal-guide.md). The bot responds when `@mentioned` in channels and to all DMs.
64
-
65
- ### Telegram
69
+ ## Platforms
66
70
 
67
- Create a bot via [@BotFather](https://t.me/BotFather) and copy the token. The bot responds to all private messages, and to `@mention` or reply chains in groups. Use `/login`, `/session`, `/new`, and `/stop` for controls.
71
+ - **Slack** — create a Socket Mode app using [docs/slack-bot-minimal-guide.md](docs/slack-bot-minimal-guide.md). The bot responds when `@mentioned` in channels and to all DMs.
72
+ - **Telegram** — create a bot via [@BotFather](https://t.me/BotFather). The bot responds to private messages, `@mention`, and reply chains in groups.
73
+ - **Discord** — create an application in the [Discord Developer Portal](https://discord.com/developers/applications), enable **Message Content Intent**, and invite it with message/file permissions.
68
74
 
69
- ### Discord
75
+ Slack threads, Discord replies/threads, and Telegram reply chains are mapped to independent session scopes. See [docs/sessions.md](docs/sessions.md).
70
76
 
71
- Create an application in the [Discord Developer Portal](https://discord.com/developers/applications), enable **Message Content Intent**, and invite the bot with `Send Messages`, `Read Message History`, `Attach Files`. The bot responds to `@mentions` in servers and to all DMs.
72
-
73
- ## Sandbox Modes
77
+ ## Sandbox
74
78
 
75
79
  | Mode | Description |
76
80
  | ---------------------------- | ---------------------------------------------------------------------- |
@@ -80,53 +84,24 @@ Create an application in the [Discord Developer Portal](https://discord.com/deve
80
84
  | `firecracker:<vm-id>:<path>` | Firecracker microVM (alpha; not recommended) |
81
85
  | `cloudflare:<sandbox-id>` | Cloudflare Worker bridge (experimental; no auto workspace sync) |
82
86
 
83
- Vault routing: `image`, `firecracker`, and `cloudflare` resolve a vault per platform userId. See [docs/sandbox.md](docs/sandbox.md) for the full matrix.
84
-
85
- ### Managed per-user containers (`image:*`)
86
-
87
- ```bash
88
- docker pull ghcr.io/geminixiang/mikan-sandbox:latest
89
- mikan --sandbox=image:ghcr.io/geminixiang/mikan-sandbox:latest /path/to/workspace
90
- ```
91
-
92
- Or build locally:
93
-
94
- ```bash
95
- docker build -f docker/mikan-sandbox.Dockerfile -t mikan-sandbox:tools .
96
- ```
97
-
98
- mikan creates one container per vault, attaches each to its own bridge network, mounts the workspace at `/workspace`, injects vault env, mounts declared credential files, and stops idle containers.
99
-
100
- ### Firecracker / Cloudflare
101
-
102
- See [docs/firecracker-setup.md](docs/firecracker-setup.md) and [examples/cloudflare-sandbox-bridge/README.md](examples/cloudflare-sandbox-bridge/README.md).
103
-
104
- ## `/login` and Web Session Viewer
105
-
106
- ```bash
107
- export LINK_URL="https://mikan.example.com" # public base URL
108
- export LINK_PORT=8181 # optional, defaults to 8181
109
- ```
110
-
111
- For local testing you can set just `LINK_PORT`; mikan will use `http://localhost:<port>`.
112
-
113
- Every environment variable also supports a `MIKAN_` prefix for deployment-specific namespacing. For example, `MIKAN_SLACK_APP_TOKEN` and `MIKAN_LINK_URL` are accepted fallbacks. Unprefixed variables take precedence.
87
+ For routing, mounts, vault behavior, managed container details, and Firecracker/Cloudflare notes, see [docs/sandbox.md](docs/sandbox.md).
114
88
 
115
- - `/login` / `/pi-login` (DM only) returns a 15-minute link to store API keys or run built-in OAuth flows ([GitHub](docs/oauth/github.md), [Google Workspace](docs/oauth/google-workspace.md), [Google Cloud SDK / gcloud](docs/oauth/google-cloud-sdk.md)).
116
- - `session` / `/session` (DM only) returns a read-only link showing the current session timeline.
117
- - `new` / `/new` (DM only) resets the current session and starts fresh.
118
- - `model` / `/model` / `/pi-model provider/model[:thinking]` switches the LLM for the current conversation, e.g. `/pi-model anthropic/claude-sonnet-4-6:off`.
119
- - `auto-reply` / `/pi-auto-reply on|off|status` controls group/channel auto-reply for the current conversation. Rules live in the conversation's `auto-reply` marker file.
120
- - `stop` / `/stop` stops the current run. On Slack, use text commands so thread-local stop routing remains accurate.
121
- - On Slack you can also register native commands like `/pi-login`, `/pi-session`, `/pi-model`, `/pi-auto-reply`, and `/pi-new`.
89
+ ## Chat commands
122
90
 
123
- Credentials are stored under `<state-dir>/vaults` (default `~/.mikan/vaults`). Vault env is only injected in `container`, `image`, `firecracker`, and `cloudflare` modes.
91
+ | Command | Purpose |
92
+ | ---------------------------------------------------------- | ------------------------------------------------ |
93
+ | `/login` / `/pi-login` | Store API keys or run built-in OAuth flows |
94
+ | `session` / `/session` | Open a read-only web view of the current session |
95
+ | `new` / `/new` | Reset the current session |
96
+ | `model` / `/model` / `/pi-model provider/model[:thinking]` | Switch the LLM for the current conversation |
97
+ | `auto-reply` / `/pi-auto-reply on\|off\|status` | Control group/channel auto-reply |
98
+ | `stop` / `/stop` | Stop the current run |
124
99
 
125
- Shared login profiles live under `<state-dir>/vaults/shared/<name>`. `/pi-login copy <name>` merge-copies that shared profile into the current conversation vault: shared env keys overwrite matching conversation env keys, conversation-only env keys are kept, and files from the shared profile overwrite files at the same relative path. To seed every new managed sandbox vault from a shared profile, fill in `sandbox.defaultSharedVault` in `<state-dir>/settings.json` (onboard creates it as an empty string), for example `{ "sandbox": { "defaultSharedVault": "claw" } }`. Empty string disables the default. The default profile is copied only when the target vault does not exist yet.
100
+ See [docs/commands.md](docs/commands.md) for command details and web session viewer setup.
126
101
 
127
102
  ## Configuration
128
103
 
129
- mikan reads global settings from `<state-dir>/settings.json` (default `~/.mikan/settings.json`, override via `--state-dir` or `STATE_DIR`). This file is required and is created explicitly with `mikan --onboard`. Per-conversation settings live at `<workingDir>/<conversationId>/settings.json` and override global settings for that conversation.
104
+ mikan reads global settings from `<state-dir>/settings.json`; per-conversation overrides live at `<working-directory>/<conversationId>/settings.json`.
130
105
 
131
106
  ```json
132
107
  {
@@ -134,66 +109,25 @@ mikan reads global settings from `<state-dir>/settings.json` (default `~/.mikan/
134
109
  "provider": "anthropic",
135
110
  "model": "claude-sonnet-4-6",
136
111
  "thinkingLevel": "off"
137
- },
138
- "sentry": {
139
- "dsn": "https://examplePublicKey@o0.ingest.sentry.io/0"
140
- },
141
- "sandbox": {
142
- "cpus": "0.5",
143
- "memory": "512m",
144
- "boost": {
145
- "cpus": "2",
146
- "memory": "4g"
147
- }
148
112
  }
149
113
  }
150
114
  ```
151
115
 
152
- | Field | Default | Description |
153
- | ---------------------- | ------------------- | ----------------------------------------------------- |
154
- | `llm.provider` | `anthropic` | AI provider |
155
- | `llm.model` | `claude-sonnet-4-6` | Model name |
156
- | `llm.thinkingLevel` | `off` | `off` / `low` / `medium` / `high` |
157
- | `sentry.dsn` | unset | Sentry DSN; sensitive prompt/tool content is redacted |
158
- | `sandbox.cpus` | unset | CPU limit for managed containers |
159
- | `sandbox.memory` | unset | Memory limit for managed containers |
160
- | `sandbox.boost.cpus` | unset | Temporary CPU limit used by `/pi-sandbox boost` |
161
- | `sandbox.boost.memory` | unset | Temporary memory limit used by `/pi-sandbox boost` |
116
+ See [docs/configuration.md](docs/configuration.md) for all fields.
162
117
 
163
- `/pi-sandbox` shows the current managed-container CPU/memory limits. `/pi-sandbox boost` temporarily applies `sandbox.boost` to the current conversation; the boost ends when that sandbox container is stopped.
118
+ ## Data layout
164
119
 
165
- Conversation-local settings written by `/pi-model` use the same shape and usually only include the override:
166
-
167
- ```json
168
- {
169
- "llm": {
170
- "provider": "anthropic",
171
- "model": "claude-sonnet-4-6",
172
- "thinkingLevel": "off"
173
- }
174
- }
175
- ```
176
-
177
- mikan writes logs to stdout/stderr. Use your process manager or host platform (for example PM2, systemd, Docker, or a cloud logging agent) to route logs to your preferred backend.
178
-
179
- ## Layout
180
-
181
- ```
120
+ ```text
182
121
  <state-dir>/
183
122
  ├── settings.json
184
123
  └── vaults/
185
- └── <vault-id>/
186
- ├── env
187
- └── ... # credential files
188
124
 
189
125
  <working-directory>/
190
- ├── MEMORY.md # global memory
191
- ├── SYSTEM.md # installed packages / env log
192
- ├── skills/ # global skills
193
- ├── events/ # scheduled events
126
+ ├── MEMORY.md
127
+ ├── SYSTEM.md
128
+ ├── skills/
129
+ ├── events/
194
130
  └── <conversation-id>/
195
- ├── MEMORY.md
196
- ├── auto-reply[.disabled] # optional channel auto-reply rules
197
131
  ├── log.jsonl
198
132
  ├── attachments/
199
133
  ├── scratch/
@@ -201,37 +135,13 @@ mikan writes logs to stdout/stderr. Use your process manager or host platform (f
201
135
  └── sessions/
202
136
  ```
203
137
 
204
- ## Events
205
-
206
- Drop JSON files into `<working-directory>/events/`:
207
-
208
- ```json
209
- // Immediate
210
- {"type": "immediate", "platform": "slack", "conversationId": "C0123456789", "conversationKind": "shared", "text": "Deploy finished"}
211
-
212
- // One-shot
213
- {"type": "one-shot", "platform": "telegram", "conversationId": "574247312", "conversationKind": "direct", "text": "Standup", "at": "2025-12-15T09:00:00+08:00"}
214
-
215
- // Periodic (cron)
216
- {"type": "periodic", "platform": "discord", "conversationId": "1498975469343739948", "conversationKind": "shared", "text": "Check inbox", "schedule": "0 9 * * 1-5", "timezone": "Asia/Taipei"}
217
- ```
218
-
219
- ## Skills
138
+ ## More docs
220
139
 
221
- ```
222
- skills/my-tool/
223
- ├── SKILL.md # name + description frontmatter, usage docs
224
- └── run.sh
225
- ```
226
-
227
- ```yaml
228
- ---
229
- name: my-tool
230
- description: Does something useful
231
- ---
232
-
233
- Usage: {baseDir}/run.sh <args>
234
- ```
140
+ - [Events](docs/events.md)
141
+ - [Skills](docs/skills.md)
142
+ - [Deployment](docs/deployment.md)
143
+ - [Development](docs/development.md)
144
+ - [Sandbox](docs/sandbox.md)
235
145
 
236
146
  ## Slack: Download channel history
237
147
 
@@ -239,54 +149,17 @@ Usage: {baseDir}/run.sh <args>
239
149
  mikan --download C0123456789
240
150
  ```
241
151
 
242
- ## Production deployment (PM2)
243
-
244
- For long-running deployments, use [PM2](https://pm2.keymetrics.io/) as a process supervisor. It daemonizes mikan, restarts on crash, and survives reboots.
245
-
246
- ```bash
247
- # 1. Install mikan and pm2
248
- npm i -g @geminixiang/mikan pm2
249
-
250
- # 2. Start the sandbox container (long-lived; mikan execs into it)
251
- docker pull ghcr.io/geminixiang/mikan-sandbox:latest
252
-
253
- # 3. Grab the ecosystem file, edit args + env tokens, then start
254
- curl -O https://raw.githubusercontent.com/geminixiang/mikan/main/deploy/pm2/ecosystem.config.cjs
255
- pm2 start ecosystem.config.cjs
256
- pm2 save
257
- pm2 startup # run the printed command to enable boot autostart
258
- ```
259
-
260
- Upgrade flow:
261
-
262
- ```bash
263
- npm i -g @geminixiang/mikan && pm2 reload mikan
264
- ```
265
-
266
- `pm2 reload` sends SIGTERM and waits up to `kill_timeout` (60s in the shipped config) before SIGKILL. mikan's internal graceful shutdown drains in-flight LLM turns within that window, so reloads do not interrupt active conversations.
267
-
268
- See [`deploy/pm2/ecosystem.config.cjs`](deploy/pm2/ecosystem.config.cjs) for all tunables.
269
-
270
152
  ## Development
271
153
 
272
154
  ```bash
273
- npm run dev # build in watch mode
274
- npm test # unit tests (vitest)
275
- npm run lint # oxlint
276
- npm run fmt:check # oxfmt (use `npm run fmt` to auto-fix)
277
- npm run build # type check + emit dist/
278
- ```
279
-
280
- ### End-to-end tests
281
-
282
- E2E suites under `e2e/` exercise real platform APIs and are kept off the default `npm test` run.
283
-
284
- ```bash
285
- npm run test:e2e # all platforms
286
- npm run test:e2e:slack # Slack only
155
+ npm run dev
156
+ npm test
157
+ npm run lint
158
+ npm run fmt:check
159
+ npm run build
287
160
  ```
288
161
 
289
- Slack E2E requires `SLACK_QA_USER_TOKEN`, `SLACK_QA_CHANNEL_ID`, and `SLACK_QA_BOT_USER_ID` against a dedicated test workspace. See [`docs/slack-qa-test-plan.md`](docs/slack-qa-test-plan.md) for setup.
162
+ See [docs/development.md](docs/development.md) for E2E tests.
290
163
 
291
164
  ## Contributing
292
165
 
@@ -1 +1 @@
1
- {"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../../../src/adapters/discord/bot.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n ApplicationCommandOptionType,\n Client,\n Events,\n GatewayIntentBits,\n Partials,\n type ChatInputCommandInteraction,\n type Collection,\n type Message,\n type Attachment,\n type TextChannel,\n type DMChannel,\n type NewsChannel,\n type ThreadChannel,\n} from \"discord.js\";\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"fs\";\nimport { basename, join } from \"path\";\n\nimport type {\n Bot,\n BotAdapters,\n BotEvent,\n BotHandler,\n ChatMessage,\n ChatResponseContext,\n ChatToolResult,\n PlatformInfo,\n} from \"../../adapter.js\";\nimport * as log from \"../../log.js\";\nimport { resolveChatSessionKey } from \"../../sessions/policy.js\";\nimport { evaluateAutoReplyPolicy } from \"../../trigger.js\";\nimport { formatNothingRunning } from \"../../ui-copy.js\";\nimport {\n appendBotResponseLog,\n appendChannelLog,\n ChannelQueue,\n resolveOnlyScopedStopTarget,\n resolveStopTarget,\n withRetry,\n} from \"../shared.js\";\nimport { createDiscordAdapters } from \"./context.js\";\n\n// discord.js: DiscordAPIError exposes `.status` (HTTP status) and a `.code`.\n// RateLimitError fires when the internal queue gives up. Both should retry.\nfunction discordIsRateLimited(err: Error): boolean {\n if ((err as { status?: number }).status === 429) return true;\n if ((err as { httpStatus?: number }).httpStatus === 429) return true;\n if (err.name === \"RateLimitError\") return true;\n return false;\n}\n\nconst discordRetry = <T>(fn: () => Promise<T>): Promise<T> =>\n withRetry(fn, { isRateLimited: discordIsRateLimited });\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface DiscordEvent extends BotEvent {\n type: \"mention\" | \"dm\";\n userName?: string;\n}\n\n// ============================================================================\n// DiscordBot\n// ============================================================================\n\nexport class DiscordBot implements Bot {\n private client: Client;\n private handler: BotHandler;\n private token: string;\n private workingDir: string;\n private botUserId: string | null = null;\n private queues = new Map<string, ChannelQueue>();\n private startupTime: number = 0;\n private channels = new Map<string, { id: string; name: string }>();\n private users = new Map<string, { id: string; userName: string; displayName: string }>();\n\n constructor(handler: BotHandler, config: { token: string; workingDir: string }) {\n this.handler = handler;\n this.token = config.token;\n this.workingDir = config.workingDir;\n this.client = new Client({\n intents: [\n GatewayIntentBits.Guilds,\n GatewayIntentBits.GuildMessages,\n GatewayIntentBits.MessageContent,\n GatewayIntentBits.DirectMessages,\n ],\n partials: [Partials.Channel, Partials.Message],\n });\n }\n\n // ==========================================================================\n // Public API (implements Bot)\n // ==========================================================================\n\n async start(): Promise<void> {\n await new Promise<void>((resolve, reject) => {\n this.client.once(Events.ClientReady, async (readyClient) => {\n this.botUserId = readyClient.user.id;\n this.startupTime = Date.now();\n log.logConnected(\"Discord\");\n log.logInfo(`Discord bot started as ${readyClient.user.tag}`);\n this.loadCachedGuildData();\n this.setupEventHandlers();\n try {\n await readyClient.application.commands.set([\n {\n name: \"login\",\n description: \"Store credentials in your private vault\",\n },\n {\n name: \"session\",\n description: \"Open the current session in the web viewer\",\n },\n {\n name: \"new\",\n description: \"Reset conversation history and start fresh\",\n },\n {\n name: \"stop\",\n description: \"Stop the current conversation\",\n },\n {\n name: \"model\",\n description: \"Switch this conversation's LLM model\",\n options: [\n {\n name: \"model\",\n description: \"provider/model[:thinking], e.g. anthropic/claude-sonnet-4-6:off\",\n type: ApplicationCommandOptionType.String,\n required: false,\n },\n ],\n },\n {\n name: \"sandbox\",\n description: \"Show or temporarily boost this conversation's sandbox limits\",\n options: [\n {\n name: \"action\",\n description: \"Use 'boost' to temporarily apply the configured boost limits\",\n type: ApplicationCommandOptionType.String,\n required: false,\n },\n ],\n },\n ]);\n } catch (err) {\n log.logWarning(\n \"Failed to register Discord slash commands\",\n err instanceof Error ? err.message : String(err),\n );\n }\n resolve();\n });\n this.client.once(Events.Error, reject);\n this.client.login(this.token).catch(reject);\n });\n }\n\n async postMessage(channel: string, text: string): Promise<string> {\n return discordRetry(async () => {\n const ch = await this.fetchTextChannel(channel);\n const msg = await ch.send(text);\n return msg.id;\n });\n }\n\n async updateMessage(channel: string, ts: string, text: string): Promise<void> {\n await this.updateMessageRaw(channel, ts, text);\n }\n\n enqueueEvent(event: BotEvent): boolean {\n const conversationId = event.conversationId;\n const queue = this.getQueue(conversationId);\n if (queue.size() >= 5) {\n log.logWarning(\n `Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`,\n );\n return false;\n }\n log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);\n queue.enqueue(() => {\n const adapters = createDiscordAdapters(event as DiscordEvent, this);\n return this.handler.handleEvent(event, this, adapters);\n });\n return true;\n }\n\n getPlatformInfo(): PlatformInfo {\n return {\n name: \"discord\",\n formattingGuide:\n \"## Discord Formatting (Markdown)\\nBold: **text**, Italic: *text*, Code: `code`, Block: ```language\\ncode```\\nLinks: [text](url)\",\n channels: this.getAllChannels(),\n users: this.getAllUsers(),\n diagnostics: {\n showUsageSummary: false,\n },\n };\n }\n\n // ==========================================================================\n // Internal helpers (used by context.ts)\n // ==========================================================================\n\n async updateMessageRaw(channelId: string, messageId: string, text: string): Promise<void> {\n return discordRetry(async () => {\n const ch = await this.fetchTextChannel(channelId);\n const msg = await ch.messages.fetch(messageId);\n await msg.edit(text);\n });\n }\n\n async postReply(channelId: string, replyToId: string, text: string): Promise<string> {\n return discordRetry(async () => {\n const ch = await this.fetchTextChannel(channelId);\n const replyTarget = await ch.messages.fetch(replyToId);\n const sent = await replyTarget.reply(text);\n return sent.id;\n });\n }\n\n async postInThread(channelId: string, threadOrMessageId: string, text: string): Promise<string> {\n // Try as a thread channel first, then fall back to posting in the channel\n try {\n const thread = await this.client.channels.fetch(threadOrMessageId);\n if (thread && (thread.isThread() || thread.isTextBased())) {\n return discordRetry(async () => {\n const msg = await (thread as ThreadChannel).send(text);\n return msg.id;\n });\n }\n } catch {\n // Not a thread channel, treat as message ID for reply\n }\n return this.postReply(channelId, threadOrMessageId, text);\n }\n\n async deleteMessageRaw(channelId: string, messageId: string): Promise<void> {\n try {\n const ch = await this.fetchTextChannel(channelId);\n const msg = await ch.messages.fetch(messageId);\n await msg.delete();\n } catch {\n // Ignore if already deleted\n }\n }\n\n async sendTyping(channelId: string): Promise<void> {\n try {\n const ch = await this.fetchTextChannel(channelId);\n await ch.sendTyping();\n } catch {\n // Non-fatal\n }\n }\n\n async uploadFile(channelId: string, filePath: string, title?: string): Promise<void> {\n return discordRetry(async () => {\n const ch = await this.fetchTextChannel(channelId);\n const fileName = title ?? basename(filePath);\n const fileContent = readFileSync(filePath);\n await ch.send({ files: [{ attachment: fileContent, name: fileName }] });\n });\n }\n\n async sendDirectMessage(userId: string, text: string): Promise<string> {\n return discordRetry(async () => {\n const user = await this.client.users.fetch(userId);\n const msg = await user.send(text);\n return msg.id;\n });\n }\n\n async postPrivate(_conversationId: string, userId: string, text: string): Promise<void> {\n await this.sendDirectMessage(userId, text);\n }\n\n getAllChannels(): { id: string; name: string }[] {\n return Array.from(this.channels.values());\n }\n\n getAllUsers(): { id: string; userName: string; displayName: string }[] {\n return Array.from(this.users.values());\n }\n\n logToFile(channelId: string, entry: object): void {\n appendChannelLog(this.workingDir, channelId, entry);\n }\n\n logBotResponse(channelId: string, text: string, ts: string): void {\n appendBotResponseLog(this.workingDir, channelId, text, ts);\n }\n\n /**\n * Process attachments from a Discord message.\n * Downloads files before returning so the agent can read them immediately.\n */\n async processAttachments(\n channelId: string,\n attachments: Collection<string, Attachment>,\n _messageId: string,\n ): Promise<{ name: string; localPath: string }[]> {\n const downloads: Array<Promise<{ name: string; localPath: string } | null>> = [];\n\n // Discord attachments Collection - iterate over values\n for (const attachment of attachments.values()) {\n if (!attachment.name) {\n log.logWarning(\"Discord attachment missing name, skipping\", attachment.url);\n continue;\n }\n\n const ts = Date.now();\n const sanitizedName = attachment.name.replace(/[^a-zA-Z0-9._-]/g, \"_\");\n const filename = `${ts}_${sanitizedName}`;\n const localPath = `${channelId}/attachments/${filename}`;\n const fullDir = join(this.workingDir, channelId, \"attachments\");\n const result = {\n name: attachment.name,\n localPath,\n };\n\n downloads.push(\n this.downloadAttachment(fullDir, filename, attachment.url)\n .then(() => result)\n .catch((err) => {\n log.logWarning(`Failed to download Discord attachment`, `${filename}: ${err}`);\n return null;\n }),\n );\n }\n\n const results = await Promise.all(downloads);\n return results.filter(\n (attachment): attachment is { name: string; localPath: string } => attachment !== null,\n );\n }\n\n /**\n * Download an attachment from URL to local file\n */\n private async downloadAttachment(dir: string, filename: string, url: string): Promise<void> {\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n\n try {\n const response = await fetch(url);\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const buffer = await response.arrayBuffer();\n writeFileSync(join(dir, filename), Buffer.from(buffer));\n } catch (err) {\n throw new Error(`Download failed: ${err instanceof Error ? err.message : String(err)}`, {\n cause: err,\n });\n }\n }\n\n // ==========================================================================\n // Private - Event Handlers\n // ==========================================================================\n\n private getQueue(channelId: string): ChannelQueue {\n let queue = this.queues.get(channelId);\n if (!queue) {\n queue = new ChannelQueue(\"Discord\");\n this.queues.set(channelId, queue);\n }\n return queue;\n }\n\n private resolveStopTarget(channelId: string, sessionKey: string): string | null {\n const directTarget = resolveStopTarget({\n handler: this.handler,\n conversationId: channelId,\n sessionKey,\n });\n if (directTarget) return directTarget;\n if (sessionKey !== channelId) return null;\n return resolveOnlyScopedStopTarget(this.handler, channelId);\n }\n\n private loadCachedGuildData(): void {\n for (const guild of this.client.guilds.cache.values()) {\n for (const channel of guild.channels.cache.values()) {\n if (channel.isTextBased() && \"name\" in channel) {\n this.channels.set(channel.id, { id: channel.id, name: channel.name ?? channel.id });\n }\n }\n for (const member of guild.members.cache.values()) {\n this.users.set(member.id, {\n id: member.id,\n userName: member.user.username,\n displayName: member.displayName,\n });\n }\n }\n }\n\n private stripBotMention(text: string): string {\n if (!this.botUserId) return text;\n return text.replace(new RegExp(`<@!?${this.botUserId}>`, \"g\"), \"\").trim();\n }\n\n private resolveConversationContext(input: {\n channelId: string;\n inGuild: boolean;\n isThread: boolean;\n parentChannelId?: string | null;\n referencedMsgId?: string;\n }): { conversationId: string; threadTs?: string } {\n if (!input.inGuild) {\n return {\n conversationId: input.channelId,\n threadTs: input.referencedMsgId,\n };\n }\n\n if (input.isThread) {\n return {\n conversationId: input.parentChannelId ?? input.channelId,\n threadTs: input.channelId,\n };\n }\n\n return {\n conversationId: input.channelId,\n threadTs: input.referencedMsgId,\n };\n }\n\n private createSlashCommandAdapters(\n interaction: ChatInputCommandInteraction,\n commandText: string,\n sessionKey: string,\n conversationId: string,\n ): BotAdapters {\n const isDM = !interaction.inGuild();\n const userId = interaction.user.id;\n const userName = interaction.user.username;\n const platform = this.getPlatformInfo();\n const shouldUseEphemeral = !isDM;\n\n const message: ChatMessage = {\n id: interaction.id,\n sessionKey,\n conversationKind: isDM ? \"direct\" : \"shared\",\n userId,\n userName,\n text: commandText,\n attachments: [],\n };\n\n const respondPrivately = async (text: string, replace = false): Promise<void> => {\n if (interaction.replied || interaction.deferred) {\n if (replace) {\n await interaction.editReply({ content: text });\n } else {\n await interaction.followUp({ content: text, ephemeral: shouldUseEphemeral });\n }\n return;\n }\n\n await interaction.reply({ content: text, ephemeral: shouldUseEphemeral });\n };\n\n const responseCtx: ChatResponseContext = {\n respond: async (text: string) => {\n await respondPrivately(text);\n },\n replaceResponse: async (text: string) => {\n await respondPrivately(text, true);\n },\n respondDiagnostic: async (text: string) => {\n await respondPrivately(text);\n },\n respondToolResult: async (result: ChatToolResult) => {\n const duration = (result.durationMs / 1000).toFixed(1);\n const formatted = `${result.isError ? \"Error\" : \"Done\"} ${result.toolName} (${duration}s)\\n${result.result}`;\n await respondPrivately(formatted);\n },\n setTyping: async () => {},\n setWorking: async () => {},\n uploadFile: async (filePath: string, title?: string) => {\n await this.uploadFile(conversationId, filePath, title);\n },\n deleteResponse: async () => {},\n };\n\n return { message, responseCtx, platform };\n }\n\n private setupEventHandlers(): void {\n this.client.on(Events.InteractionCreate, async (interaction) => {\n if (!interaction.isChatInputCommand()) return;\n if (\n interaction.commandName !== \"login\" &&\n interaction.commandName !== \"session\" &&\n interaction.commandName !== \"new\" &&\n interaction.commandName !== \"stop\" &&\n interaction.commandName !== \"model\" &&\n interaction.commandName !== \"sandbox\"\n ) {\n return;\n }\n\n const isDM = !interaction.inGuild();\n const { conversationId, threadTs } = this.resolveConversationContext({\n channelId: interaction.channelId,\n inGuild: interaction.inGuild(),\n isThread: interaction.channel?.isThread() ?? false,\n parentChannelId:\n interaction.channel && \"parentId\" in interaction.channel\n ? interaction.channel.parentId\n : null,\n });\n const sessionKey = resolveChatSessionKey({\n conversationId,\n conversationKind: isDM ? \"direct\" : \"shared\",\n messageId: interaction.id,\n persistentTopLevel: true,\n threadTs,\n });\n const modelOption =\n interaction.commandName === \"model\"\n ? interaction.options.getString(\"model\")?.trim()\n : undefined;\n const sandboxAction =\n interaction.commandName === \"sandbox\"\n ? interaction.options.getString(\"action\")?.trim()\n : undefined;\n const commandArg = modelOption ?? sandboxAction;\n const commandText = commandArg\n ? `/${interaction.commandName} ${commandArg}`\n : `/${interaction.commandName}`;\n\n this.logToFile(conversationId, {\n date: new Date(interaction.createdTimestamp).toISOString(),\n ts: interaction.id,\n ...(threadTs ? { threadTs } : {}),\n user: interaction.user.id,\n userName: interaction.user.username,\n text: commandText,\n attachments: [],\n isBot: false,\n });\n\n const adapters = this.createSlashCommandAdapters(\n interaction,\n commandText,\n sessionKey,\n conversationId,\n );\n try {\n if (interaction.commandName === \"new\") {\n await this.handler.handleNewCommand(sessionKey, conversationId, this);\n return;\n }\n\n if (interaction.commandName === \"stop\") {\n const stopTarget = this.resolveStopTarget(conversationId, sessionKey);\n if (stopTarget) {\n await this.handler.handleStop(stopTarget, conversationId, this);\n } else {\n await adapters.responseCtx.respond(formatNothingRunning(\"discord\"));\n }\n return;\n }\n\n const event: BotEvent = {\n type: \"dm\",\n conversationId,\n conversationKind: isDM ? \"direct\" : \"shared\",\n ts: interaction.id,\n thread_ts: threadTs,\n sessionKey,\n user: interaction.user.id,\n text: commandText,\n attachments: [],\n };\n\n await this.handler.handleEvent(event, this, adapters);\n } catch (err) {\n log.logWarning(\n \"Discord slash command error\",\n err instanceof Error ? err.message : String(err),\n );\n if (!interaction.replied && !interaction.deferred) {\n await interaction.reply({\n content: `${interaction.commandName} command failed. Please try again later.`,\n ephemeral: !isDM,\n });\n }\n }\n });\n\n this.client.on(Events.MessageCreate, async (msg: Message) => {\n // Skip messages from before startup\n if (msg.createdTimestamp < this.startupTime) return;\n // Skip bot messages\n if (msg.author.bot) return;\n const isDM = msg.channel.type === 1; // ChannelType.DM = 1\n const isInThread = msg.channel.isThread();\n const referencedMsgId = msg.reference?.messageId;\n const isThreadReply = isInThread || !!referencedMsgId;\n const isMentioned = msg.mentions.users.has(this.botUserId ?? \"\");\n const isAutoReplyCandidate = !isDM && !isMentioned && !isThreadReply;\n\n const { conversationId, threadTs } = this.resolveConversationContext({\n channelId: msg.channelId,\n inGuild: !isDM,\n isThread: isInThread,\n parentChannelId: \"parentId\" in msg.channel ? msg.channel.parentId : null,\n referencedMsgId,\n });\n const userId = msg.author.id;\n const userName = msg.author.username;\n const msgId = msg.id;\n\n // Track user\n this.users.set(userId, {\n id: userId,\n userName,\n displayName: msg.member?.displayName ?? userName,\n });\n\n // Track channel\n if (!this.channels.has(conversationId) && \"name\" in msg.channel) {\n const ch = msg.channel as TextChannel | NewsChannel;\n this.channels.set(conversationId, { id: conversationId, name: ch.name });\n }\n\n const conversationKind = isDM ? \"direct\" : \"shared\";\n const sessionKey = resolveChatSessionKey({\n conversationId,\n conversationKind,\n messageId: msgId,\n persistentTopLevel: true,\n threadTs,\n });\n\n const cleanedText = this.stripBotMention(msg.content);\n\n const eventBase: DiscordEvent = {\n type: isDM ? \"dm\" : \"mention\",\n conversationId,\n conversationKind,\n ts: msgId,\n thread_ts: threadTs,\n sessionKey,\n user: userId,\n userName,\n text: cleanedText,\n };\n\n // Handle stop before trigger gate — \"stop\" should never be auto-reply judged.\n if (cleanedText.toLowerCase() === \"stop\" || cleanedText.toLowerCase() === \"/stop\") {\n const stopTarget = this.resolveStopTarget(conversationId, sessionKey);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, conversationId, this);\n } else if (!isAutoReplyCandidate) {\n await this.postMessage(conversationId, formatNothingRunning(\"discord\"));\n }\n return;\n }\n\n const triggerResult = isAutoReplyCandidate\n ? await evaluateAutoReplyPolicy({ event: eventBase, workingDir: this.workingDir })\n : ({ trigger: true, reason: \"addressed\" } as const);\n\n const logEntryBase = {\n date: msg.createdAt.toISOString(),\n ts: msgId,\n ...(!isDM && threadTs ? { threadTs } : {}),\n user: userId,\n userName,\n text: cleanedText,\n isBot: false,\n };\n\n if (!triggerResult.trigger) {\n this.logToFile(conversationId, { ...logEntryBase, attachments: [] });\n return;\n }\n\n const processedAttachments = await this.processAttachments(\n conversationId,\n msg.attachments,\n msgId,\n );\n const event: DiscordEvent = { ...eventBase, attachments: processedAttachments };\n\n this.logToFile(conversationId, { ...logEntryBase, attachments: processedAttachments });\n\n this.getQueue(sessionKey).enqueue(() => {\n const adapters = createDiscordAdapters(event, this);\n return this.handler.handleEvent(event, this, adapters);\n });\n });\n }\n\n private async fetchTextChannel(\n channelId: string,\n ): Promise<TextChannel | DMChannel | NewsChannel | ThreadChannel> {\n const ch = await this.client.channels.fetch(channelId);\n if (!ch || !ch.isTextBased()) {\n throw new Error(`Channel ${channelId} is not a text channel`);\n }\n return ch as TextChannel | DMChannel | NewsChannel | ThreadChannel;\n }\n}\n"]}
1
+ {"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../../../src/adapters/discord/bot.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n ApplicationCommandOptionType,\n Client,\n Events,\n GatewayIntentBits,\n Partials,\n type ChatInputCommandInteraction,\n type Collection,\n type Message,\n type Attachment,\n type TextChannel,\n type DMChannel,\n type NewsChannel,\n type ThreadChannel,\n} from \"discord.js\";\nimport { mkdirSync, readFileSync, writeFileSync } from \"fs\";\nimport { basename, join } from \"path\";\n\nimport type {\n Bot,\n BotAdapters,\n BotEvent,\n BotHandler,\n ChatMessage,\n ChatResponseContext,\n ChatToolResult,\n PlatformInfo,\n} from \"../../adapter.js\";\nimport * as log from \"../../log.js\";\nimport { resolveChatSessionKey } from \"../../sessions/policy.js\";\nimport { evaluateAutoReplyPolicy } from \"../../trigger.js\";\nimport { formatNothingRunning } from \"../../ui-copy.js\";\nimport {\n appendBotResponseLog,\n appendChannelLog,\n ChannelQueue,\n resolveOnlyScopedStopTarget,\n resolveStopTarget,\n withRetry,\n} from \"../shared.js\";\nimport { createDiscordAdapters } from \"./context.js\";\n\n// discord.js: DiscordAPIError exposes `.status` (HTTP status) and a `.code`.\n// RateLimitError fires when the internal queue gives up. Both should retry.\nfunction discordIsRateLimited(err: Error): boolean {\n if ((err as { status?: number }).status === 429) return true;\n if ((err as { httpStatus?: number }).httpStatus === 429) return true;\n if (err.name === \"RateLimitError\") return true;\n return false;\n}\n\nconst discordRetry = <T>(fn: () => Promise<T>): Promise<T> =>\n withRetry(fn, { isRateLimited: discordIsRateLimited });\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface DiscordEvent extends BotEvent {\n type: \"mention\" | \"dm\";\n userName?: string;\n}\n\n// ============================================================================\n// DiscordBot\n// ============================================================================\n\nexport class DiscordBot implements Bot {\n private client: Client;\n private handler: BotHandler;\n private token: string;\n private workingDir: string;\n private botUserId: string | null = null;\n private queues = new Map<string, ChannelQueue>();\n private startupTime: number = 0;\n private channels = new Map<string, { id: string; name: string }>();\n private users = new Map<string, { id: string; userName: string; displayName: string }>();\n\n constructor(handler: BotHandler, config: { token: string; workingDir: string }) {\n this.handler = handler;\n this.token = config.token;\n this.workingDir = config.workingDir;\n this.client = new Client({\n intents: [\n GatewayIntentBits.Guilds,\n GatewayIntentBits.GuildMessages,\n GatewayIntentBits.MessageContent,\n GatewayIntentBits.DirectMessages,\n ],\n partials: [Partials.Channel, Partials.Message],\n });\n }\n\n // ==========================================================================\n // Public API (implements Bot)\n // ==========================================================================\n\n async start(): Promise<void> {\n await new Promise<void>((resolve, reject) => {\n this.client.once(Events.ClientReady, async (readyClient) => {\n this.botUserId = readyClient.user.id;\n this.startupTime = Date.now();\n log.logConnected(\"Discord\");\n log.logInfo(`Discord bot started as ${readyClient.user.tag}`);\n this.loadCachedGuildData();\n this.setupEventHandlers();\n try {\n await readyClient.application.commands.set([\n {\n name: \"login\",\n description: \"Store credentials in your private vault\",\n },\n {\n name: \"session\",\n description: \"Open the current session in the web viewer\",\n },\n {\n name: \"new\",\n description: \"Reset conversation history and start fresh\",\n },\n {\n name: \"stop\",\n description: \"Stop the current conversation\",\n },\n {\n name: \"model\",\n description: \"Switch this conversation's LLM model\",\n options: [\n {\n name: \"model\",\n description: \"provider/model[:thinking], e.g. anthropic/claude-sonnet-4-6:off\",\n type: ApplicationCommandOptionType.String,\n required: false,\n },\n ],\n },\n {\n name: \"sandbox\",\n description: \"Show or temporarily boost this conversation's sandbox limits\",\n options: [\n {\n name: \"action\",\n description: \"Use 'boost' to temporarily apply the configured boost limits\",\n type: ApplicationCommandOptionType.String,\n required: false,\n },\n ],\n },\n ]);\n } catch (err) {\n log.logWarning(\n \"Failed to register Discord slash commands\",\n err instanceof Error ? err.message : String(err),\n );\n }\n resolve();\n });\n this.client.once(Events.Error, reject);\n this.client.login(this.token).catch(reject);\n });\n }\n\n async postMessage(channel: string, text: string): Promise<string> {\n return discordRetry(async () => {\n const ch = await this.fetchTextChannel(channel);\n const msg = await ch.send(text);\n return msg.id;\n });\n }\n\n async updateMessage(channel: string, ts: string, text: string): Promise<void> {\n await this.updateMessageRaw(channel, ts, text);\n }\n\n enqueueEvent(event: BotEvent): boolean {\n const conversationId = event.conversationId;\n const queue = this.getQueue(conversationId);\n if (queue.size() >= 5) {\n log.logWarning(\n `Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`,\n );\n return false;\n }\n log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);\n queue.enqueue(() => {\n const adapters = createDiscordAdapters(event as DiscordEvent, this);\n return this.handler.handleEvent(event, this, adapters);\n });\n return true;\n }\n\n getPlatformInfo(): PlatformInfo {\n return {\n name: \"discord\",\n formattingGuide:\n \"## Discord Formatting (Markdown)\\nBold: **text**, Italic: *text*, Code: `code`, Block: ```language\\ncode```\\nLinks: [text](url)\",\n channels: this.getAllChannels(),\n users: this.getAllUsers(),\n diagnostics: {\n showUsageSummary: false,\n },\n };\n }\n\n // ==========================================================================\n // Internal helpers (used by context.ts)\n // ==========================================================================\n\n async updateMessageRaw(channelId: string, messageId: string, text: string): Promise<void> {\n return discordRetry(async () => {\n const ch = await this.fetchTextChannel(channelId);\n const msg = await ch.messages.fetch(messageId);\n await msg.edit(text);\n });\n }\n\n async postReply(channelId: string, replyToId: string, text: string): Promise<string> {\n return discordRetry(async () => {\n const ch = await this.fetchTextChannel(channelId);\n const replyTarget = await ch.messages.fetch(replyToId);\n const sent = await replyTarget.reply(text);\n return sent.id;\n });\n }\n\n async postInThread(channelId: string, threadOrMessageId: string, text: string): Promise<string> {\n // Try as a thread channel first, then fall back to posting in the channel\n try {\n const thread = await this.client.channels.fetch(threadOrMessageId);\n if (thread && (thread.isThread() || thread.isTextBased())) {\n return discordRetry(async () => {\n const msg = await (thread as ThreadChannel).send(text);\n return msg.id;\n });\n }\n } catch {\n // Not a thread channel, treat as message ID for reply\n }\n return this.postReply(channelId, threadOrMessageId, text);\n }\n\n async deleteMessageRaw(channelId: string, messageId: string): Promise<void> {\n try {\n const ch = await this.fetchTextChannel(channelId);\n const msg = await ch.messages.fetch(messageId);\n await msg.delete();\n } catch {\n // Ignore if already deleted\n }\n }\n\n async sendTyping(channelId: string): Promise<void> {\n try {\n const ch = await this.fetchTextChannel(channelId);\n await ch.sendTyping();\n } catch {\n // Non-fatal\n }\n }\n\n async uploadFile(channelId: string, filePath: string, title?: string): Promise<void> {\n return discordRetry(async () => {\n const ch = await this.fetchTextChannel(channelId);\n const fileName = title ?? basename(filePath);\n const fileContent = readFileSync(filePath);\n await ch.send({ files: [{ attachment: fileContent, name: fileName }] });\n });\n }\n\n async sendDirectMessage(userId: string, text: string): Promise<string> {\n return discordRetry(async () => {\n const user = await this.client.users.fetch(userId);\n const msg = await user.send(text);\n return msg.id;\n });\n }\n\n async postPrivate(_conversationId: string, userId: string, text: string): Promise<void> {\n await this.sendDirectMessage(userId, text);\n }\n\n getAllChannels(): { id: string; name: string }[] {\n return Array.from(this.channels.values());\n }\n\n getAllUsers(): { id: string; userName: string; displayName: string }[] {\n return Array.from(this.users.values());\n }\n\n logToFile(channelId: string, entry: object): void {\n appendChannelLog(this.workingDir, channelId, entry);\n }\n\n logBotResponse(channelId: string, text: string, ts: string): void {\n appendBotResponseLog(this.workingDir, channelId, text, ts);\n }\n\n /**\n * Process attachments from a Discord message.\n * Downloads files before returning so the agent can read them immediately.\n */\n async processAttachments(\n channelId: string,\n attachments: Collection<string, Attachment>,\n _messageId: string,\n ): Promise<{ name: string; localPath: string }[]> {\n const downloads: Array<Promise<{ name: string; localPath: string } | null>> = [];\n\n // Discord attachments Collection - iterate over values\n for (const attachment of attachments.values()) {\n if (!attachment.name) {\n log.logWarning(\"Discord attachment missing name, skipping\", attachment.url);\n continue;\n }\n\n const ts = Date.now();\n const sanitizedName = attachment.name.replace(/[^a-zA-Z0-9._-]/g, \"_\");\n const filename = `${ts}_${sanitizedName}`;\n const localPath = `${channelId}/attachments/${filename}`;\n const fullDir = join(this.workingDir, channelId, \"attachments\");\n const result = {\n name: attachment.name,\n localPath,\n };\n\n downloads.push(\n this.downloadAttachment(fullDir, filename, attachment.url)\n .then(() => result)\n .catch((err) => {\n log.logWarning(`Failed to download Discord attachment`, `${filename}: ${err}`);\n return null;\n }),\n );\n }\n\n const results = await Promise.all(downloads);\n return results.filter(\n (attachment): attachment is { name: string; localPath: string } => attachment !== null,\n );\n }\n\n /**\n * Download an attachment from URL to local file\n */\n private async downloadAttachment(dir: string, filename: string, url: string): Promise<void> {\n mkdirSync(dir, { recursive: true });\n\n try {\n const response = await fetch(url);\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const buffer = await response.arrayBuffer();\n writeFileSync(join(dir, filename), Buffer.from(buffer));\n } catch (err) {\n throw new Error(`Download failed: ${err instanceof Error ? err.message : String(err)}`, {\n cause: err,\n });\n }\n }\n\n // ==========================================================================\n // Private - Event Handlers\n // ==========================================================================\n\n private getQueue(channelId: string): ChannelQueue {\n let queue = this.queues.get(channelId);\n if (!queue) {\n queue = new ChannelQueue(\"Discord\");\n this.queues.set(channelId, queue);\n }\n return queue;\n }\n\n private resolveStopTarget(channelId: string, sessionKey: string): string | null {\n const directTarget = resolveStopTarget({\n handler: this.handler,\n conversationId: channelId,\n sessionKey,\n });\n if (directTarget) return directTarget;\n if (sessionKey !== channelId) return null;\n return resolveOnlyScopedStopTarget(this.handler, channelId);\n }\n\n private loadCachedGuildData(): void {\n for (const guild of this.client.guilds.cache.values()) {\n for (const channel of guild.channels.cache.values()) {\n if (channel.isTextBased() && \"name\" in channel) {\n this.channels.set(channel.id, { id: channel.id, name: channel.name ?? channel.id });\n }\n }\n for (const member of guild.members.cache.values()) {\n this.users.set(member.id, {\n id: member.id,\n userName: member.user.username,\n displayName: member.displayName,\n });\n }\n }\n }\n\n private stripBotMention(text: string): string {\n if (!this.botUserId) return text;\n return text.replace(new RegExp(`<@!?${this.botUserId}>`, \"g\"), \"\").trim();\n }\n\n private resolveConversationContext(input: {\n channelId: string;\n inGuild: boolean;\n isThread: boolean;\n parentChannelId?: string | null;\n referencedMsgId?: string;\n }): { conversationId: string; threadTs?: string } {\n if (!input.inGuild) {\n return {\n conversationId: input.channelId,\n threadTs: input.referencedMsgId,\n };\n }\n\n if (input.isThread) {\n return {\n conversationId: input.parentChannelId ?? input.channelId,\n threadTs: input.channelId,\n };\n }\n\n return {\n conversationId: input.channelId,\n threadTs: input.referencedMsgId,\n };\n }\n\n private createSlashCommandAdapters(\n interaction: ChatInputCommandInteraction,\n commandText: string,\n sessionKey: string,\n conversationId: string,\n ): BotAdapters {\n const isDM = !interaction.inGuild();\n const userId = interaction.user.id;\n const userName = interaction.user.username;\n const platform = this.getPlatformInfo();\n const shouldUseEphemeral = !isDM;\n\n const message: ChatMessage = {\n id: interaction.id,\n sessionKey,\n conversationKind: isDM ? \"direct\" : \"shared\",\n userId,\n userName,\n text: commandText,\n attachments: [],\n };\n\n const respondPrivately = async (text: string, replace = false): Promise<void> => {\n if (interaction.replied || interaction.deferred) {\n if (replace) {\n await interaction.editReply({ content: text });\n } else {\n await interaction.followUp({ content: text, ephemeral: shouldUseEphemeral });\n }\n return;\n }\n\n await interaction.reply({ content: text, ephemeral: shouldUseEphemeral });\n };\n\n const responseCtx: ChatResponseContext = {\n respond: async (text: string) => {\n await respondPrivately(text);\n },\n replaceResponse: async (text: string) => {\n await respondPrivately(text, true);\n },\n respondDiagnostic: async (text: string) => {\n await respondPrivately(text);\n },\n respondToolResult: async (result: ChatToolResult) => {\n const duration = (result.durationMs / 1000).toFixed(1);\n const formatted = `${result.isError ? \"Error\" : \"Done\"} ${result.toolName} (${duration}s)\\n${result.result}`;\n await respondPrivately(formatted);\n },\n setTyping: async () => {},\n setWorking: async () => {},\n uploadFile: async (filePath: string, title?: string) => {\n await this.uploadFile(conversationId, filePath, title);\n },\n deleteResponse: async () => {},\n };\n\n return { message, responseCtx, platform };\n }\n\n private setupEventHandlers(): void {\n this.client.on(Events.InteractionCreate, async (interaction) => {\n if (!interaction.isChatInputCommand()) return;\n if (\n interaction.commandName !== \"login\" &&\n interaction.commandName !== \"session\" &&\n interaction.commandName !== \"new\" &&\n interaction.commandName !== \"stop\" &&\n interaction.commandName !== \"model\" &&\n interaction.commandName !== \"sandbox\"\n ) {\n return;\n }\n\n const isDM = !interaction.inGuild();\n const { conversationId, threadTs } = this.resolveConversationContext({\n channelId: interaction.channelId,\n inGuild: interaction.inGuild(),\n isThread: interaction.channel?.isThread() ?? false,\n parentChannelId:\n interaction.channel && \"parentId\" in interaction.channel\n ? interaction.channel.parentId\n : null,\n });\n const sessionKey = resolveChatSessionKey({\n conversationId,\n conversationKind: isDM ? \"direct\" : \"shared\",\n messageId: interaction.id,\n persistentTopLevel: true,\n threadTs,\n });\n const modelOption =\n interaction.commandName === \"model\"\n ? interaction.options.getString(\"model\")?.trim()\n : undefined;\n const sandboxAction =\n interaction.commandName === \"sandbox\"\n ? interaction.options.getString(\"action\")?.trim()\n : undefined;\n const commandArg = modelOption ?? sandboxAction;\n const commandText = commandArg\n ? `/${interaction.commandName} ${commandArg}`\n : `/${interaction.commandName}`;\n\n this.logToFile(conversationId, {\n date: new Date(interaction.createdTimestamp).toISOString(),\n ts: interaction.id,\n ...(threadTs ? { threadTs } : {}),\n user: interaction.user.id,\n userName: interaction.user.username,\n text: commandText,\n attachments: [],\n isBot: false,\n });\n\n const adapters = this.createSlashCommandAdapters(\n interaction,\n commandText,\n sessionKey,\n conversationId,\n );\n try {\n if (interaction.commandName === \"new\") {\n await this.handler.handleNewCommand(sessionKey, conversationId, this);\n await adapters.responseCtx.respond(\"Started a new conversation.\");\n return;\n }\n\n if (interaction.commandName === \"stop\") {\n const stopTarget = this.resolveStopTarget(conversationId, sessionKey);\n if (stopTarget) {\n await this.handler.handleStop(stopTarget, conversationId, this);\n await adapters.responseCtx.respond(\"Stopped the current conversation.\");\n } else {\n await adapters.responseCtx.respond(formatNothingRunning(\"discord\"));\n }\n return;\n }\n\n const event: BotEvent = {\n type: \"dm\",\n conversationId,\n conversationKind: isDM ? \"direct\" : \"shared\",\n ts: interaction.id,\n thread_ts: threadTs,\n sessionKey,\n user: interaction.user.id,\n text: commandText,\n attachments: [],\n };\n\n await this.handler.handleEvent(event, this, adapters);\n } catch (err) {\n log.logWarning(\n \"Discord slash command error\",\n err instanceof Error ? err.message : String(err),\n );\n if (!interaction.replied && !interaction.deferred) {\n await interaction.reply({\n content: `${interaction.commandName} command failed. Please try again later.`,\n ephemeral: !isDM,\n });\n }\n }\n });\n\n this.client.on(Events.MessageCreate, async (msg: Message) => {\n // Skip messages from before startup\n if (msg.createdTimestamp < this.startupTime) return;\n // Skip bot messages\n if (msg.author.bot) return;\n const isDM = msg.channel.type === 1; // ChannelType.DM = 1\n const isInThread = msg.channel.isThread();\n const referencedMsgId = msg.reference?.messageId;\n const isThreadReply = isInThread || !!referencedMsgId;\n const isMentioned = msg.mentions.users.has(this.botUserId ?? \"\");\n const isAutoReplyCandidate = !isDM && !isMentioned && !isThreadReply;\n\n const { conversationId, threadTs } = this.resolveConversationContext({\n channelId: msg.channelId,\n inGuild: !isDM,\n isThread: isInThread,\n parentChannelId: \"parentId\" in msg.channel ? msg.channel.parentId : null,\n referencedMsgId,\n });\n const userId = msg.author.id;\n const userName = msg.author.username;\n const msgId = msg.id;\n\n // Track user\n this.users.set(userId, {\n id: userId,\n userName,\n displayName: msg.member?.displayName ?? userName,\n });\n\n // Track channel\n if (!this.channels.has(conversationId) && \"name\" in msg.channel) {\n const ch = msg.channel as TextChannel | NewsChannel;\n this.channels.set(conversationId, { id: conversationId, name: ch.name });\n }\n\n const conversationKind = isDM ? \"direct\" : \"shared\";\n const sessionKey = resolveChatSessionKey({\n conversationId,\n conversationKind,\n messageId: msgId,\n persistentTopLevel: true,\n threadTs,\n });\n\n const cleanedText = this.stripBotMention(msg.content);\n\n const eventBase: DiscordEvent = {\n type: isDM ? \"dm\" : \"mention\",\n conversationId,\n conversationKind,\n ts: msgId,\n thread_ts: threadTs,\n sessionKey,\n user: userId,\n userName,\n text: cleanedText,\n };\n\n // Handle stop before trigger gate — \"stop\" should never be auto-reply judged.\n if (cleanedText.toLowerCase() === \"stop\" || cleanedText.toLowerCase() === \"/stop\") {\n const stopTarget = this.resolveStopTarget(conversationId, sessionKey);\n if (stopTarget) {\n await this.handler.handleStop(stopTarget, conversationId, this);\n } else if (!isAutoReplyCandidate) {\n await this.postMessage(conversationId, formatNothingRunning(\"discord\"));\n }\n return;\n }\n\n const triggerResult = isAutoReplyCandidate\n ? await evaluateAutoReplyPolicy({ event: eventBase, workingDir: this.workingDir })\n : ({ trigger: true, reason: \"addressed\" } as const);\n\n const logEntryBase = {\n date: msg.createdAt.toISOString(),\n ts: msgId,\n ...(!isDM && threadTs ? { threadTs } : {}),\n user: userId,\n userName,\n text: cleanedText,\n isBot: false,\n };\n\n if (!triggerResult.trigger) {\n this.logToFile(conversationId, { ...logEntryBase, attachments: [] });\n return;\n }\n\n const processedAttachments = await this.processAttachments(\n conversationId,\n msg.attachments,\n msgId,\n );\n const event: DiscordEvent = { ...eventBase, attachments: processedAttachments };\n\n this.logToFile(conversationId, { ...logEntryBase, attachments: processedAttachments });\n\n this.getQueue(sessionKey).enqueue(() => {\n const adapters = createDiscordAdapters(event, this);\n return this.handler.handleEvent(event, this, adapters);\n });\n });\n }\n\n private async fetchTextChannel(\n channelId: string,\n ): Promise<TextChannel | DMChannel | NewsChannel | ThreadChannel> {\n const ch = await this.client.channels.fetch(channelId);\n if (!ch || !ch.isTextBased()) {\n throw new Error(`Channel ${channelId} is not a text channel`);\n }\n return ch as TextChannel | DMChannel | NewsChannel | ThreadChannel;\n }\n}\n"]}
@@ -1,5 +1,5 @@
1
1
  import { ApplicationCommandOptionType, Client, Events, GatewayIntentBits, Partials, } from "discord.js";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
3
3
  import { basename, join } from "path";
4
4
  import * as log from "../../log.js";
5
5
  import { resolveChatSessionKey } from "../../sessions/policy.js";
@@ -260,8 +260,7 @@ export class DiscordBot {
260
260
  * Download an attachment from URL to local file
261
261
  */
262
262
  async downloadAttachment(dir, filename, url) {
263
- if (!existsSync(dir))
264
- mkdirSync(dir, { recursive: true });
263
+ mkdirSync(dir, { recursive: true });
265
264
  try {
266
265
  const response = await fetch(url);
267
266
  if (!response.ok) {
@@ -441,12 +440,14 @@ export class DiscordBot {
441
440
  try {
442
441
  if (interaction.commandName === "new") {
443
442
  await this.handler.handleNewCommand(sessionKey, conversationId, this);
443
+ await adapters.responseCtx.respond("Started a new conversation.");
444
444
  return;
445
445
  }
446
446
  if (interaction.commandName === "stop") {
447
447
  const stopTarget = this.resolveStopTarget(conversationId, sessionKey);
448
448
  if (stopTarget) {
449
449
  await this.handler.handleStop(stopTarget, conversationId, this);
450
+ await adapters.responseCtx.respond("Stopped the current conversation.");
450
451
  }
451
452
  else {
452
453
  await adapters.responseCtx.respond(formatNothingRunning("discord"));
@@ -534,7 +535,7 @@ export class DiscordBot {
534
535
  if (cleanedText.toLowerCase() === "stop" || cleanedText.toLowerCase() === "/stop") {
535
536
  const stopTarget = this.resolveStopTarget(conversationId, sessionKey);
536
537
  if (stopTarget) {
537
- this.handler.handleStop(stopTarget, conversationId, this);
538
+ await this.handler.handleStop(stopTarget, conversationId, this);
538
539
  }
539
540
  else if (!isAutoReplyCandidate) {
540
541
  await this.postMessage(conversationId, formatNothingRunning("discord"));