mercury-agent 0.4.5

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 (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/container/Dockerfile +127 -0
  4. package/container/Dockerfile.base +109 -0
  5. package/container/Dockerfile.power +17 -0
  6. package/container/agent-package.json +8 -0
  7. package/container/build.sh +54 -0
  8. package/docs/TODOS.md +147 -0
  9. package/docs/auth/dashboard.md +28 -0
  10. package/docs/auth/overview.md +109 -0
  11. package/docs/auth/whatsapp.md +173 -0
  12. package/docs/configuration.md +54 -0
  13. package/docs/container-lifecycle.md +349 -0
  14. package/docs/context-architecture.md +87 -0
  15. package/docs/deployment.md +199 -0
  16. package/docs/extensions.md +375 -0
  17. package/docs/graceful-shutdown.md +62 -0
  18. package/docs/kb-distillation.md +77 -0
  19. package/docs/media/overview.md +140 -0
  20. package/docs/media/whatsapp.md +171 -0
  21. package/docs/memory.md +137 -0
  22. package/docs/permissions.md +217 -0
  23. package/docs/pipeline.md +228 -0
  24. package/docs/prd-chat-memory.md +76 -0
  25. package/docs/prd-config-load.md +82 -0
  26. package/docs/rate-limiting.md +166 -0
  27. package/docs/scheduler.md +288 -0
  28. package/docs/setup-discord.md +100 -0
  29. package/docs/setup-slack.md +119 -0
  30. package/docs/setup-whatsapp.md +94 -0
  31. package/docs/subagents.md +166 -0
  32. package/docs/web-search.md +62 -0
  33. package/examples/extensions/README.md +12 -0
  34. package/examples/extensions/charts/index.ts +13 -0
  35. package/examples/extensions/charts/skill/SKILL.md +98 -0
  36. package/examples/extensions/gws/README.md +52 -0
  37. package/examples/extensions/gws/index.ts +106 -0
  38. package/examples/extensions/gws/skill/SKILL.md +57 -0
  39. package/examples/extensions/gws/skill/references/calendar.md +101 -0
  40. package/examples/extensions/gws/skill/references/docs.md +65 -0
  41. package/examples/extensions/gws/skill/references/drive.md +79 -0
  42. package/examples/extensions/gws/skill/references/gmail.md +85 -0
  43. package/examples/extensions/gws/skill/references/sheets.md +60 -0
  44. package/examples/extensions/napkin/index.ts +821 -0
  45. package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
  46. package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
  47. package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
  48. package/examples/extensions/napkin/skill/SKILL.md +728 -0
  49. package/examples/extensions/pdf/index.ts +23 -0
  50. package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
  51. package/examples/extensions/pdf/skill/SKILL.md +314 -0
  52. package/examples/extensions/pdf/skill/forms.md +294 -0
  53. package/examples/extensions/pdf/skill/reference.md +612 -0
  54. package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
  55. package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
  56. package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
  57. package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
  58. package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
  59. package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
  60. package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
  61. package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
  62. package/examples/extensions/permission-guard/index.ts +65 -0
  63. package/examples/extensions/pinchtab/index.ts +199 -0
  64. package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
  65. package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
  66. package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
  67. package/examples/extensions/pinchtab/skill/references/api.md +297 -0
  68. package/examples/extensions/pinchtab/skill/references/env.md +45 -0
  69. package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
  70. package/examples/extensions/tradestation/host/refresh.ts +102 -0
  71. package/examples/extensions/tradestation/index.ts +153 -0
  72. package/examples/extensions/tradestation/skill/SKILL.md +67 -0
  73. package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
  74. package/examples/extensions/voice-synth/index.ts +94 -0
  75. package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
  76. package/examples/extensions/voice-transcribe/index.ts +381 -0
  77. package/examples/extensions/voice-transcribe/requirements.txt +8 -0
  78. package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
  79. package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
  80. package/examples/extensions/web-search/index.ts +22 -0
  81. package/examples/extensions/web-search/skill/SKILL.md +114 -0
  82. package/examples/extensions/web-search/skill/references/apartments.md +178 -0
  83. package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
  84. package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
  85. package/examples/extensions/web-search/skill/references/flights.md +133 -0
  86. package/examples/extensions/web-search/skill/references/hotels.md +148 -0
  87. package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
  88. package/examples/extensions/yahoo-mail/cli/package.json +13 -0
  89. package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
  90. package/examples/extensions/yahoo-mail/index.ts +57 -0
  91. package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
  92. package/package.json +106 -0
  93. package/resources/agents/explore.md +50 -0
  94. package/resources/agents/worker.md +24 -0
  95. package/resources/builtin-extensions.txt +3 -0
  96. package/resources/connection-env-vars.json +25 -0
  97. package/resources/extensions/.gitkeep +0 -0
  98. package/resources/pi-extensions/subagent/agents.ts +126 -0
  99. package/resources/pi-extensions/subagent/index.ts +964 -0
  100. package/resources/profiles/coding/AGENTS.md +43 -0
  101. package/resources/profiles/coding/mercury-profile.yaml +15 -0
  102. package/resources/profiles/general/AGENTS.md +31 -0
  103. package/resources/profiles/general/mercury-profile.yaml +15 -0
  104. package/resources/profiles/research/AGENTS.md +40 -0
  105. package/resources/profiles/research/mercury-profile.yaml +15 -0
  106. package/resources/skills/config/SKILL.md +25 -0
  107. package/resources/skills/context/SKILL.md +33 -0
  108. package/resources/skills/conversation-recap/SKILL.md +19 -0
  109. package/resources/skills/media/SKILL.md +27 -0
  110. package/resources/skills/mutes/SKILL.md +31 -0
  111. package/resources/skills/permissions/SKILL.md +19 -0
  112. package/resources/skills/preferences/SKILL.md +31 -0
  113. package/resources/skills/recall/SKILL.md +24 -0
  114. package/resources/skills/roles/SKILL.md +18 -0
  115. package/resources/skills/spaces/SKILL.md +18 -0
  116. package/resources/skills/tasks/SKILL.md +45 -0
  117. package/resources/templates/AGENTS.md +157 -0
  118. package/resources/templates/env.template +34 -0
  119. package/resources/templates/mercury.example.yaml +75 -0
  120. package/src/adapters/discord-native.ts +534 -0
  121. package/src/adapters/discord.ts +38 -0
  122. package/src/adapters/setup.ts +89 -0
  123. package/src/adapters/slack.ts +9 -0
  124. package/src/adapters/whatsapp-media.ts +337 -0
  125. package/src/adapters/whatsapp.ts +629 -0
  126. package/src/agent/api-socket.ts +127 -0
  127. package/src/agent/container-entry.ts +967 -0
  128. package/src/agent/container-error.ts +49 -0
  129. package/src/agent/container-runner.ts +1272 -0
  130. package/src/agent/model-capabilities-core.ts +23 -0
  131. package/src/agent/model-capabilities.ts +231 -0
  132. package/src/agent/pi-failure-class.ts +83 -0
  133. package/src/agent/pi-jsonl-parser.ts +306 -0
  134. package/src/agent/preferences-prompt.ts +20 -0
  135. package/src/agent/user-error-messages.ts +78 -0
  136. package/src/bridges/discord.ts +171 -0
  137. package/src/bridges/slack.ts +177 -0
  138. package/src/bridges/teams.ts +160 -0
  139. package/src/bridges/telegram.ts +571 -0
  140. package/src/bridges/whatsapp.ts +290 -0
  141. package/src/chat-shim.ts +259 -0
  142. package/src/cli/mercury.ts +2508 -0
  143. package/src/cli/mrctl-http.ts +27 -0
  144. package/src/cli/mrctl.ts +611 -0
  145. package/src/cli/whatsapp-auth.ts +260 -0
  146. package/src/config-file.ts +397 -0
  147. package/src/config-model-chain.ts +30 -0
  148. package/src/config.ts +316 -0
  149. package/src/core/api-types.ts +58 -0
  150. package/src/core/api.ts +105 -0
  151. package/src/core/commands.ts +76 -0
  152. package/src/core/conversation.ts +47 -0
  153. package/src/core/handler.ts +206 -0
  154. package/src/core/media.ts +200 -0
  155. package/src/core/mute-duration.ts +22 -0
  156. package/src/core/outbox.ts +76 -0
  157. package/src/core/permissions.ts +192 -0
  158. package/src/core/profiles.ts +245 -0
  159. package/src/core/rate-limiter.ts +127 -0
  160. package/src/core/router.ts +191 -0
  161. package/src/core/routes/chat.ts +172 -0
  162. package/src/core/routes/config-builtin.ts +107 -0
  163. package/src/core/routes/config.ts +81 -0
  164. package/src/core/routes/connections.ts +190 -0
  165. package/src/core/routes/console.ts +668 -0
  166. package/src/core/routes/control.ts +46 -0
  167. package/src/core/routes/conversations.ts +66 -0
  168. package/src/core/routes/dashboard.ts +2491 -0
  169. package/src/core/routes/extensions.ts +37 -0
  170. package/src/core/routes/index.ts +14 -0
  171. package/src/core/routes/media.ts +72 -0
  172. package/src/core/routes/messages.ts +37 -0
  173. package/src/core/routes/mutes.ts +89 -0
  174. package/src/core/routes/prefs.ts +95 -0
  175. package/src/core/routes/roles.ts +125 -0
  176. package/src/core/routes/spaces.ts +60 -0
  177. package/src/core/routes/storage.ts +126 -0
  178. package/src/core/routes/tasks.ts +189 -0
  179. package/src/core/routes/tradestation.ts +268 -0
  180. package/src/core/routes/tts.ts +51 -0
  181. package/src/core/runtime.ts +1140 -0
  182. package/src/core/space-queue.ts +103 -0
  183. package/src/core/storage-cleanup.ts +140 -0
  184. package/src/core/storage-guard.ts +24 -0
  185. package/src/core/task-scheduler.ts +132 -0
  186. package/src/core/telegram-format.ts +178 -0
  187. package/src/core/trigger.ts +142 -0
  188. package/src/dashboard/index.html +729 -0
  189. package/src/dashboard/tokens.css +53 -0
  190. package/src/extensions/api.ts +252 -0
  191. package/src/extensions/catalog.ts +117 -0
  192. package/src/extensions/config-registry.ts +83 -0
  193. package/src/extensions/context.ts +36 -0
  194. package/src/extensions/hooks.ts +156 -0
  195. package/src/extensions/image-builder.ts +617 -0
  196. package/src/extensions/installer.ts +306 -0
  197. package/src/extensions/jobs.ts +122 -0
  198. package/src/extensions/loader.ts +271 -0
  199. package/src/extensions/permission-guard.ts +52 -0
  200. package/src/extensions/reserved.ts +28 -0
  201. package/src/extensions/skills.ts +123 -0
  202. package/src/extensions/types.ts +462 -0
  203. package/src/logger.ts +174 -0
  204. package/src/main.ts +586 -0
  205. package/src/server.ts +391 -0
  206. package/src/storage/db.ts +1624 -0
  207. package/src/storage/memory.ts +45 -0
  208. package/src/storage/pi-auth.ts +95 -0
  209. package/src/text/markdown.ts +117 -0
  210. package/src/text/rtl.ts +38 -0
  211. package/src/tradestation/host-api.ts +77 -0
  212. package/src/tradestation/pending-orders.ts +69 -0
  213. package/src/tts/azure.ts +52 -0
  214. package/src/tts/google.ts +128 -0
  215. package/src/tts/index.ts +8 -0
  216. package/src/tts/language.ts +20 -0
  217. package/src/tts/synthesize.ts +133 -0
  218. package/src/types.ts +295 -0
@@ -0,0 +1,75 @@
1
+ # Copy to mercury.yaml in your project root (optional).
2
+ # Precedence: MERCURY_* env vars override this file when the env key is set.
3
+ # Secrets (API keys, tokens, MERCURY_API_SECRET, etc.) belong in .env only — not here.
4
+
5
+ # server:
6
+ # port: 8787
7
+ # bot_username: mercury
8
+
9
+ # model:
10
+ # chain:
11
+ # - provider: anthropic
12
+ # model: claude-sonnet-4-20250514
13
+ # # Legacy single primary + optional fallback (when chain is omitted):
14
+ # # provider: anthropic
15
+ # # model: claude-sonnet-4-20250514
16
+ # # fallback_provider: openai
17
+ # # fallback: gpt-4o-mini
18
+ # # max_retries_per_leg: 2
19
+ # # chain_budget_ms: 120000
20
+ # # capabilities: { tools: false }
21
+
22
+ # Top-level alias for model.chain (optional; model.chain wins if both are set):
23
+ # model_chain:
24
+ # - provider: google
25
+ # model: gemini-2.5-flash
26
+
27
+ # ingress:
28
+ # discord: false
29
+ # slack: false
30
+ # teams: false
31
+ # whatsapp: false
32
+ # telegram: false
33
+
34
+ # runtime:
35
+ # data_dir: .mercury
36
+ # # auth_path: ...
37
+ # # whatsapp_auth_dir: .mercury/whatsapp-auth
38
+ # max_concurrency: 2
39
+ # log_level: info
40
+ # log_format: text
41
+ # rate_limit_per_user: 10
42
+ # rate_limit_window_ms: 60000
43
+
44
+ # trigger:
45
+ # patterns: "@Mercury,Mercury"
46
+ # match: mention
47
+
48
+ # conditional_context:
49
+ # enabled: true
50
+ # classifier: heuristic
51
+ # # classifier_provider: groq
52
+ # # classifier_model: llama-3.3-70b-versatile
53
+
54
+ # compaction:
55
+ # keep_recent_tokens: 20000
56
+ # # After each full-session run, auto-compact if the pi session has more than this many entries (10–10000). Omit to disable.
57
+ # # auto_compact_threshold: 50
58
+
59
+ # agent:
60
+ # image: ghcr.io/michaelliv/mercury-agent:latest
61
+ # container_timeout_ms: 300000
62
+ # container_bwrap_docker_compat: false
63
+
64
+ # discord:
65
+ # gateway_duration_ms: 600000
66
+
67
+ # telegram:
68
+ # format_enabled: true
69
+
70
+ # media:
71
+ # enabled: true
72
+ # max_size_mb: 10
73
+
74
+ # permissions:
75
+ # admins: ""
@@ -0,0 +1,534 @@
1
+ /**
2
+ * Native Discord adapter using discord.js for persistent WebSocket connection.
3
+ *
4
+ * Similar to the WhatsApp/Baileys adapter pattern — maintains a persistent
5
+ * connection instead of the serverless gateway approach.
6
+ */
7
+
8
+ import {
9
+ type Adapter,
10
+ type AdapterPostableMessage,
11
+ type ChatInstance,
12
+ type EmojiValue,
13
+ type FetchOptions,
14
+ type FetchResult,
15
+ type FormattedContent,
16
+ Message,
17
+ parseMarkdown,
18
+ type RawMessage,
19
+ stringifyMarkdown,
20
+ type ThreadInfo,
21
+ type WebhookOptions,
22
+ } from "chat";
23
+ import {
24
+ Client,
25
+ type Message as DiscordMessage,
26
+ Events,
27
+ GatewayIntentBits,
28
+ type OmitPartialGroupDMChannel,
29
+ Partials,
30
+ } from "discord.js";
31
+ import { logger } from "../logger.js";
32
+
33
+ /** Discord's maximum message length */
34
+ export const DISCORD_MAX_LENGTH = 2000;
35
+
36
+ /**
37
+ * Split a message into chunks that fit within Discord's character limit.
38
+ * Tries to break at natural boundaries (paragraphs, lines, spaces) when possible.
39
+ */
40
+ export function chunkMessage(text: string, maxLength: number): string[] {
41
+ if (text.length <= maxLength) return [text];
42
+
43
+ const chunks: string[] = [];
44
+ let remaining = text;
45
+
46
+ while (remaining.length > maxLength) {
47
+ // Try to break at paragraph boundary
48
+ let breakPoint = remaining.lastIndexOf("\n\n", maxLength);
49
+
50
+ // Fall back to line break
51
+ if (breakPoint < maxLength / 2) {
52
+ breakPoint = remaining.lastIndexOf("\n", maxLength);
53
+ }
54
+
55
+ // Fall back to space
56
+ if (breakPoint < maxLength / 2) {
57
+ breakPoint = remaining.lastIndexOf(" ", maxLength);
58
+ }
59
+
60
+ // Hard break if no good boundary found
61
+ if (breakPoint <= 0) {
62
+ breakPoint = maxLength;
63
+ }
64
+
65
+ chunks.push(remaining.slice(0, breakPoint).trim());
66
+ remaining = remaining.slice(breakPoint).trim();
67
+ }
68
+
69
+ if (remaining) {
70
+ chunks.push(remaining);
71
+ }
72
+
73
+ return chunks;
74
+ }
75
+
76
+ type DiscordThreadId = {
77
+ guildId: string;
78
+ channelId: string;
79
+ threadId?: string;
80
+ };
81
+
82
+ export interface DiscordNativeAdapterOptions {
83
+ /** Bot token */
84
+ botToken: string;
85
+ /** Bot username for trigger matching */
86
+ userName?: string;
87
+ }
88
+
89
+ export class DiscordNativeAdapter
90
+ implements Adapter<DiscordThreadId, DiscordMessage>
91
+ {
92
+ readonly name = "discord";
93
+ readonly userName: string;
94
+
95
+ private chat?: ChatInstance;
96
+ private client: Client;
97
+ private readonly botToken: string;
98
+
99
+ constructor(options: DiscordNativeAdapterOptions) {
100
+ this.userName = options.userName ?? "mercury";
101
+ this.botToken = options.botToken;
102
+
103
+ this.client = new Client({
104
+ intents: [
105
+ GatewayIntentBits.Guilds,
106
+ GatewayIntentBits.GuildMessages,
107
+ GatewayIntentBits.MessageContent,
108
+ GatewayIntentBits.DirectMessages,
109
+ GatewayIntentBits.GuildMessageReactions,
110
+ ],
111
+ partials: [Partials.Channel, Partials.Message],
112
+ });
113
+ }
114
+
115
+ get botUserId(): string | undefined {
116
+ return this.client.user?.id;
117
+ }
118
+
119
+ get discordClient(): Client {
120
+ return this.client;
121
+ }
122
+
123
+ async initialize(chat: ChatInstance): Promise<void> {
124
+ this.chat = chat;
125
+ logger.info("Discord native adapter initializing");
126
+
127
+ this.client.on(Events.ClientReady, () => {
128
+ logger.info("Discord native adapter connected", {
129
+ username: this.client.user?.username,
130
+ id: this.client.user?.id,
131
+ });
132
+ });
133
+
134
+ this.client.on(Events.MessageCreate, (message) => {
135
+ void this.handleIncomingMessage(message);
136
+ });
137
+
138
+ this.client.on(Events.Error, (error) => {
139
+ logger.error("Discord client error", { error: error.message });
140
+ });
141
+
142
+ await this.client.login(this.botToken);
143
+ }
144
+
145
+ async handleWebhook(
146
+ _request: Request,
147
+ _options?: WebhookOptions,
148
+ ): Promise<Response> {
149
+ // No webhook needed — we use persistent WebSocket
150
+ return new Response("Discord native adapter uses WebSocket, no webhook.", {
151
+ status: 202,
152
+ });
153
+ }
154
+
155
+ channelIdFromThreadId(threadId: string): string {
156
+ const parts = threadId.split(":");
157
+ return `discord:${parts[1]}:${parts[2]}`;
158
+ }
159
+
160
+ encodeThreadId(platformData: DiscordThreadId): string {
161
+ const parts = ["discord", platformData.guildId, platformData.channelId];
162
+ if (platformData.threadId) {
163
+ parts.push(platformData.threadId);
164
+ }
165
+ return parts.join(":");
166
+ }
167
+
168
+ decodeThreadId(threadId: string): DiscordThreadId {
169
+ const parts = threadId.split(":");
170
+ if (parts.length < 3 || parts[0] !== "discord") {
171
+ throw new Error(`Invalid Discord thread ID: ${threadId}`);
172
+ }
173
+ return {
174
+ guildId: parts[1],
175
+ channelId: parts[2],
176
+ threadId: parts[3],
177
+ };
178
+ }
179
+
180
+ async postMessage(
181
+ threadId: string,
182
+ message: AdapterPostableMessage,
183
+ ): Promise<RawMessage<DiscordMessage>> {
184
+ const { channelId, threadId: discordThreadId } =
185
+ this.decodeThreadId(threadId);
186
+
187
+ const targetId = discordThreadId || channelId;
188
+ const channel = await this.client.channels.fetch(targetId);
189
+
190
+ if (!channel || !("send" in channel)) {
191
+ throw new Error(`Cannot send to channel: ${targetId}`);
192
+ }
193
+
194
+ const text = this.postableToText(message);
195
+ const chunks = chunkMessage(text, DISCORD_MAX_LENGTH);
196
+
197
+ let lastSent: DiscordMessage | undefined;
198
+ for (const chunk of chunks) {
199
+ lastSent = await channel.send(chunk);
200
+ }
201
+
202
+ // lastSent is guaranteed to be defined since chunks always has at least one element
203
+ const sent = lastSent as DiscordMessage;
204
+ return {
205
+ id: sent.id,
206
+ threadId,
207
+ raw: sent,
208
+ };
209
+ }
210
+
211
+ async editMessage(
212
+ threadId: string,
213
+ messageId: string,
214
+ message: AdapterPostableMessage,
215
+ ): Promise<RawMessage<DiscordMessage>> {
216
+ const { channelId, threadId: discordThreadId } =
217
+ this.decodeThreadId(threadId);
218
+
219
+ const targetId = discordThreadId || channelId;
220
+ const channel = await this.client.channels.fetch(targetId);
221
+
222
+ if (!channel || !("messages" in channel)) {
223
+ throw new Error(`Cannot edit in channel: ${targetId}`);
224
+ }
225
+
226
+ const msg = await channel.messages.fetch(messageId);
227
+ const text = this.postableToText(message);
228
+ const edited = await msg.edit(text);
229
+
230
+ return {
231
+ id: edited.id,
232
+ threadId,
233
+ raw: edited,
234
+ };
235
+ }
236
+
237
+ async deleteMessage(threadId: string, messageId: string): Promise<void> {
238
+ const { channelId, threadId: discordThreadId } =
239
+ this.decodeThreadId(threadId);
240
+
241
+ const targetId = discordThreadId || channelId;
242
+ const channel = await this.client.channels.fetch(targetId);
243
+
244
+ if (!channel || !("messages" in channel)) {
245
+ throw new Error(`Cannot delete in channel: ${targetId}`);
246
+ }
247
+
248
+ const msg = await channel.messages.fetch(messageId);
249
+ await msg.delete();
250
+ }
251
+
252
+ async addReaction(
253
+ threadId: string,
254
+ messageId: string,
255
+ emoji: EmojiValue | string,
256
+ ): Promise<void> {
257
+ const { channelId, threadId: discordThreadId } =
258
+ this.decodeThreadId(threadId);
259
+
260
+ const targetId = discordThreadId || channelId;
261
+ const channel = await this.client.channels.fetch(targetId);
262
+
263
+ if (!channel || !("messages" in channel)) {
264
+ throw new Error(`Cannot react in channel: ${targetId}`);
265
+ }
266
+
267
+ const msg = await channel.messages.fetch(messageId);
268
+ const emojiStr = typeof emoji === "string" ? emoji : emoji.toString();
269
+ await msg.react(emojiStr);
270
+ }
271
+
272
+ async removeReaction(
273
+ threadId: string,
274
+ messageId: string,
275
+ emoji: EmojiValue | string,
276
+ ): Promise<void> {
277
+ const { channelId, threadId: discordThreadId } =
278
+ this.decodeThreadId(threadId);
279
+
280
+ const targetId = discordThreadId || channelId;
281
+ const channel = await this.client.channels.fetch(targetId);
282
+
283
+ if (!channel || !("messages" in channel)) {
284
+ throw new Error(`Cannot remove reaction in channel: ${targetId}`);
285
+ }
286
+
287
+ const msg = await channel.messages.fetch(messageId);
288
+ const emojiStr = typeof emoji === "string" ? emoji : emoji.toString();
289
+ const reaction = msg.reactions.cache.find(
290
+ (r) => r.emoji.name === emojiStr || r.emoji.toString() === emojiStr,
291
+ );
292
+ if (reaction) {
293
+ await reaction.users.remove(this.client.user?.id);
294
+ }
295
+ }
296
+
297
+ async fetchMessages(
298
+ threadId: string,
299
+ options?: FetchOptions,
300
+ ): Promise<FetchResult<DiscordMessage>> {
301
+ const { channelId, threadId: discordThreadId } =
302
+ this.decodeThreadId(threadId);
303
+
304
+ const targetId = discordThreadId || channelId;
305
+ const channel = await this.client.channels.fetch(targetId);
306
+
307
+ if (!channel || !("messages" in channel)) {
308
+ return { messages: [] };
309
+ }
310
+
311
+ const fetchOptions: { limit?: number; before?: string } = {};
312
+ if (options?.limit) fetchOptions.limit = options.limit;
313
+ if (options?.cursor) fetchOptions.before = options.cursor;
314
+
315
+ const messages = await channel.messages.fetch(fetchOptions);
316
+ const parsed = messages.map((msg) => this.parseMessage(msg));
317
+
318
+ return { messages: Array.from(parsed.values()) };
319
+ }
320
+
321
+ async fetchThread(threadId: string): Promise<ThreadInfo> {
322
+ const {
323
+ guildId,
324
+ channelId,
325
+ threadId: discordThreadId,
326
+ } = this.decodeThreadId(threadId);
327
+
328
+ const isDM = guildId === "@me";
329
+
330
+ return {
331
+ id: threadId,
332
+ channelId: `discord:${guildId}:${channelId}`,
333
+ isDM,
334
+ metadata: { guildId, channelId, threadId: discordThreadId },
335
+ };
336
+ }
337
+
338
+ parseMessage(raw: DiscordMessage): Message<DiscordMessage> {
339
+ const guildId = raw.guildId || "@me";
340
+ const channelId = raw.channelId;
341
+ const threadId = raw.thread?.id;
342
+
343
+ const fullThreadId = this.encodeThreadId({ guildId, channelId, threadId });
344
+
345
+ // Check if bot is mentioned
346
+ const isMention =
347
+ raw.mentions.users.has(this.client.user?.id || "") ||
348
+ raw.mentions.everyone ||
349
+ raw.content.includes(`<@${this.client.user?.id}>`);
350
+
351
+ return new Message({
352
+ id: raw.id,
353
+ threadId: fullThreadId,
354
+ text: raw.content,
355
+ formatted: parseMarkdown(raw.content),
356
+ raw,
357
+ isMention,
358
+ author: {
359
+ userId: raw.author.id,
360
+ userName: raw.author.username,
361
+ fullName: raw.author.displayName || raw.author.username,
362
+ isBot: raw.author.bot,
363
+ isMe: raw.author.id === this.client.user?.id,
364
+ },
365
+ metadata: {
366
+ dateSent: raw.createdAt,
367
+ edited: raw.editedAt !== null,
368
+ },
369
+ attachments: raw.attachments.map((a) => ({
370
+ type: this.getAttachmentType(a.contentType),
371
+ url: a.url,
372
+ name: a.name,
373
+ size: a.size,
374
+ mimeType: a.contentType || undefined,
375
+ })),
376
+ });
377
+ }
378
+
379
+ renderFormatted(content: FormattedContent): string {
380
+ return stringifyMarkdown(content);
381
+ }
382
+
383
+ async startTyping(threadId: string): Promise<void> {
384
+ const { channelId, threadId: discordThreadId } =
385
+ this.decodeThreadId(threadId);
386
+
387
+ const targetId = discordThreadId || channelId;
388
+ const channel = await this.client.channels.fetch(targetId);
389
+
390
+ if (channel && "sendTyping" in channel) {
391
+ await channel.sendTyping();
392
+ }
393
+ }
394
+
395
+ async shutdown(): Promise<void> {
396
+ await this.client.destroy();
397
+ logger.info("Discord native adapter disconnected");
398
+ }
399
+
400
+ /**
401
+ * Handle incoming Discord message.
402
+ */
403
+ private async handleIncomingMessage(
404
+ msg: OmitPartialGroupDMChannel<DiscordMessage>,
405
+ ): Promise<void> {
406
+ // Ignore bot messages
407
+ if (msg.author.bot) return;
408
+ if (msg.author.id === this.client.user?.id) return;
409
+
410
+ // Ignore empty messages
411
+ if (!msg.content && msg.attachments.size === 0) return;
412
+
413
+ const guildId = msg.guildId || "@me";
414
+ const channelId = msg.channelId;
415
+ const threadId = msg.thread?.id;
416
+
417
+ const fullThreadId = this.encodeThreadId({ guildId, channelId, threadId });
418
+
419
+ // Convert mentions for trigger matching
420
+ let text = msg.content;
421
+ if (this.client.user?.id) {
422
+ text = text.replace(
423
+ new RegExp(`<@!?${this.client.user.id}>`, "g"),
424
+ `@${this.userName}`,
425
+ );
426
+ }
427
+
428
+ const isDM = guildId === "@me";
429
+ const isMention =
430
+ msg.mentions.users.has(this.client.user?.id || "") ||
431
+ msg.content.includes(`<@${this.client.user?.id}>`);
432
+
433
+ // Check if this is a reply to one of our messages
434
+ let isReplyToBot = false;
435
+ if (msg.reference?.messageId && this.client.user?.id) {
436
+ try {
437
+ const channel = msg.channel;
438
+ if ("messages" in channel) {
439
+ const repliedTo = await channel.messages.fetch(
440
+ msg.reference.messageId,
441
+ );
442
+ isReplyToBot = repliedTo.author.id === this.client.user.id;
443
+ }
444
+ } catch {
445
+ // Referenced message may be deleted or inaccessible
446
+ }
447
+ }
448
+
449
+ logger.debug("Discord native inbound", {
450
+ guildId,
451
+ channelId,
452
+ threadId,
453
+ isDM,
454
+ isMention,
455
+ isReplyToBot,
456
+ preview: text.slice(0, 80),
457
+ });
458
+
459
+ const incoming = new Message<DiscordMessage>({
460
+ id: msg.id,
461
+ threadId: fullThreadId,
462
+ text,
463
+ formatted: parseMarkdown(text),
464
+ raw: msg as DiscordMessage,
465
+ isMention: isMention || isDM,
466
+ author: {
467
+ userId: msg.author.id,
468
+ userName: msg.author.username,
469
+ fullName: msg.author.displayName || msg.author.username,
470
+ isBot: msg.author.bot,
471
+ isMe: false,
472
+ },
473
+ metadata: {
474
+ dateSent: msg.createdAt,
475
+ edited: msg.editedAt !== null,
476
+ // Store reply flag and platform IDs in metadata for downstream consumers
477
+ ...({
478
+ isReplyToBot,
479
+ replyToMessageId: msg.reference?.messageId ?? undefined,
480
+ platformMessageId: msg.id,
481
+ } as Record<string, unknown>),
482
+ },
483
+ attachments: msg.attachments.map((a) => ({
484
+ type: this.getAttachmentType(a.contentType),
485
+ url: a.url,
486
+ name: a.name,
487
+ size: a.size,
488
+ mimeType: a.contentType || undefined,
489
+ })),
490
+ });
491
+
492
+ this.chat?.processMessage(this, fullThreadId, incoming);
493
+ }
494
+
495
+ private postableToText(message: AdapterPostableMessage): string {
496
+ if (typeof message === "string") return message;
497
+ if (typeof message === "object" && message !== null) {
498
+ if ("markdown" in message && typeof message.markdown === "string")
499
+ return message.markdown;
500
+ if ("ast" in message && message.ast)
501
+ return stringifyMarkdown(message.ast);
502
+ if ("raw" in message && typeof message.raw === "string")
503
+ return message.raw;
504
+ }
505
+ return "";
506
+ }
507
+
508
+ private getAttachmentType(
509
+ mimeType: string | null,
510
+ ): "image" | "video" | "audio" | "file" {
511
+ if (!mimeType) return "file";
512
+ if (mimeType.startsWith("image/")) return "image";
513
+ if (mimeType.startsWith("video/")) return "video";
514
+ if (mimeType.startsWith("audio/")) return "audio";
515
+ return "file";
516
+ }
517
+ }
518
+
519
+ export function createDiscordNativeAdapter(
520
+ options?: Partial<DiscordNativeAdapterOptions>,
521
+ ): DiscordNativeAdapter {
522
+ const botToken = options?.botToken || process.env.MERCURY_DISCORD_BOT_TOKEN;
523
+
524
+ if (!botToken) {
525
+ throw new Error(
526
+ "Discord native adapter requires MERCURY_DISCORD_BOT_TOKEN environment variable",
527
+ );
528
+ }
529
+
530
+ return new DiscordNativeAdapter({
531
+ botToken,
532
+ userName: options?.userName,
533
+ });
534
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Discord adapter factory for the webhook-based (serverless) adapter.
3
+ *
4
+ * Note: Mercury primarily uses the native adapter (discord-native.ts) with
5
+ * persistent WebSocket. This factory is kept for potential fallback use.
6
+ */
7
+
8
+ import {
9
+ createDiscordAdapter as createBaseDiscordAdapter,
10
+ type DiscordAdapter,
11
+ type DiscordThreadId,
12
+ } from "@chat-adapter/discord";
13
+ import { logger } from "../logger.js";
14
+
15
+ export type { DiscordAdapter, DiscordThreadId };
16
+
17
+ export function createDiscordAdapter(options?: {
18
+ userName?: string;
19
+ }): DiscordAdapter {
20
+ const botToken = process.env.MERCURY_DISCORD_BOT_TOKEN;
21
+ const publicKey = process.env.MERCURY_DISCORD_PUBLIC_KEY;
22
+ const applicationId = process.env.MERCURY_DISCORD_APPLICATION_ID;
23
+
24
+ if (!botToken || !publicKey || !applicationId) {
25
+ throw new Error(
26
+ "Discord adapter requires MERCURY_DISCORD_BOT_TOKEN, MERCURY_DISCORD_PUBLIC_KEY, and MERCURY_DISCORD_APPLICATION_ID",
27
+ );
28
+ }
29
+
30
+ logger.info("Creating Discord adapter", { applicationId });
31
+
32
+ return createBaseDiscordAdapter({
33
+ botToken,
34
+ publicKey,
35
+ applicationId,
36
+ userName: options?.userName,
37
+ });
38
+ }