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
package/src/main.ts ADDED
@@ -0,0 +1,586 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { chmodSync, existsSync, mkdirSync, rmSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ // Suppress noisy Bun WebSocket warnings from Baileys
7
+ const originalWarn = console.warn;
8
+ console.warn = (...args: unknown[]) => {
9
+ const msg = typeof args[0] === "string" ? args[0] : "";
10
+ if (msg.includes("ws.WebSocket") && msg.includes("not implemented in bun"))
11
+ return;
12
+ originalWarn(...args);
13
+ };
14
+
15
+ import type { Adapter, Message } from "chat";
16
+ import type { DiscordNativeAdapter } from "./adapters/discord-native.js";
17
+ import { setupAdapters } from "./adapters/setup.js";
18
+ import type { WhatsAppBaileysAdapter } from "./adapters/whatsapp.js";
19
+ import {
20
+ apiSocketDir,
21
+ apiSocketName,
22
+ apiSocketPath,
23
+ sweepOrphanApiSockets,
24
+ } from "./agent/api-socket.js";
25
+ import {
26
+ logExtensionCapabilityMismatches,
27
+ logUnknownModelCapabilityWarnings,
28
+ } from "./agent/model-capabilities.js";
29
+ import { DiscordBridge } from "./bridges/discord.js";
30
+ import { SlackBridge } from "./bridges/slack.js";
31
+ import { TeamsBridge } from "./bridges/teams.js";
32
+ import { TelegramBridge } from "./bridges/telegram.js";
33
+ import { WhatsAppBridge } from "./bridges/whatsapp.js";
34
+ import { createChatShim } from "./chat-shim.js";
35
+ import { loadConfig, resolveProjectPath } from "./config.js";
36
+ import { createMessageHandler } from "./core/handler.js";
37
+ import { MercuryCoreRuntime } from "./core/runtime.js";
38
+ import { runStorageCleanup } from "./core/storage-cleanup.js";
39
+ import { isOverQuota } from "./core/storage-guard.js";
40
+ import { EXTENSION_CATALOG } from "./extensions/catalog.js";
41
+ import { ConfigRegistry } from "./extensions/config-registry.js";
42
+ import { createMercuryExtensionContext } from "./extensions/context.js";
43
+ import { ExtImageBuildState } from "./extensions/image-builder.js";
44
+ import { syncBundledCatalogExtensions } from "./extensions/installer.js";
45
+ import { JobRunner } from "./extensions/jobs.js";
46
+ import { ExtensionRegistry } from "./extensions/loader.js";
47
+ import {
48
+ installBuiltinSkills,
49
+ installExtensionSkills,
50
+ } from "./extensions/skills.js";
51
+ import { configureLogger, logger } from "./logger.js";
52
+ import { createApp } from "./server.js";
53
+ import { ensureSpaceWorkspace } from "./storage/memory.js";
54
+ import type { NormalizeContext, PlatformBridge } from "./types.js";
55
+
56
+ const __dirname = dirname(fileURLToPath(import.meta.url));
57
+ const PACKAGE_ROOT = join(__dirname, "..");
58
+ const startTime = Date.now();
59
+
60
+ async function main() {
61
+ const config = loadConfig();
62
+
63
+ configureLogger({
64
+ level: config.logLevel,
65
+ format: config.logFormat,
66
+ });
67
+
68
+ logUnknownModelCapabilityWarnings(
69
+ config.resolvedModelChain,
70
+ resolveProjectPath(config.dataDir),
71
+ config.parsedModelCapabilitiesEnv,
72
+ logger,
73
+ );
74
+
75
+ if (!!config.containerNetwork !== !!config.containerApiHost) {
76
+ logger.warn(
77
+ "MERCURY_CONTAINER_NETWORK and MERCURY_CONTAINER_API_HOST should be set together; mrctl may fail to connect",
78
+ {
79
+ containerNetwork: config.containerNetwork ?? "(unset)",
80
+ containerApiHost: config.containerApiHost ?? "(unset)",
81
+ },
82
+ );
83
+ }
84
+
85
+ // ─── Normalize data dir ownership ───────────────────────────────────────
86
+ // Host base image's default user has drifted historically; if the volume
87
+ // was written by a previous uid, SQLite can't reopen state.db for writes.
88
+ // Idempotent on matching ownership. See docs/debug/major/2026-04-16-host-base-image-user-drift.md.
89
+ if (process.platform === "linux" && process.getuid?.() === 0) {
90
+ const dataDirPath = resolveProjectPath(config.dataDir);
91
+ if (existsSync(dataDirPath)) {
92
+ try {
93
+ execFileSync("chown", ["-R", "0:0", dataDirPath]);
94
+ } catch (err) {
95
+ logger.warn(
96
+ "Startup chown of data dir failed (CAP_CHOWN may be absent)",
97
+ {
98
+ dataDir: dataDirPath,
99
+ error: err instanceof Error ? err.message : String(err),
100
+ },
101
+ );
102
+ }
103
+ }
104
+ }
105
+
106
+ // ─── Early HTTP Server (warming mode) ──────────────────────────────────
107
+ // Start serving /health immediately so the readiness probe responds during
108
+ // the derived-image build (~3-4 min for heavy extensions like Playwright).
109
+ // All other paths return 503 until the full Hono app is wired in below.
110
+ type FetchHandler = (req: Request) => Response | Promise<Response>;
111
+ let fetchHandler: FetchHandler = (req) => {
112
+ if (new URL(req.url).pathname === "/health") {
113
+ return Response.json({
114
+ status: "warming",
115
+ uptime: Math.floor((Date.now() - startTime) / 1000),
116
+ });
117
+ }
118
+ return new Response("Starting…", { status: 503 });
119
+ };
120
+
121
+ const server = Bun.serve({
122
+ port: config.port,
123
+ fetch: (req) => fetchHandler(req),
124
+ });
125
+ logger.info("HTTP server listening (warming — full app initializing)", {
126
+ port: config.port,
127
+ });
128
+
129
+ // ─── Inner-container API unix socket (gVisor only) ─────────────────────
130
+ // In runsc mode the outer container leaves docker0, so inner containers can no
131
+ // longer reach the API over TCP. They reach it via a per-container unix socket
132
+ // in the per-agent data volume — visible to host-sibling inner containers
133
+ // through MERCURY_HOST_DATA_DIR. The socket name is per-container-unique
134
+ // (api-<hostname>.sock) because canonical + -next share one volume during a
135
+ // blue-green deploy. Requires the daemon to register runsc with --host-uds=open
136
+ // (see node-cloud-init.ts). Shares the same fetchHandler as the TCP listener,
137
+ // so it swaps from warming → full app in lockstep.
138
+ let unixServer: ReturnType<typeof Bun.serve> | undefined;
139
+ if (config.containerRuntime === "runsc") {
140
+ const dataDirPath = resolveProjectPath(config.dataDir);
141
+ const runDir = apiSocketDir(dataDirPath);
142
+ const socketPath = apiSocketPath(dataDirPath);
143
+ try {
144
+ mkdirSync(runDir, { recursive: true });
145
+ // Remove our own stale socket inode (left by a crash/redeploy) before bind.
146
+ rmSync(socketPath, { force: true });
147
+ // Outer runs as root (uid 0); inner connects as uid 1000. Drop the umask
148
+ // around bind so the socket inode is world-RW from birth — closes the
149
+ // window between bind and the chmod below in which uid 1000 could be
150
+ // refused. Restored immediately so no other file creation is affected.
151
+ const prevUmask = process.umask(0o000);
152
+ try {
153
+ unixServer = Bun.serve({
154
+ unix: socketPath,
155
+ fetch: (req) => fetchHandler(req),
156
+ });
157
+ } finally {
158
+ process.umask(prevUmask);
159
+ }
160
+ // Belt-and-suspenders: ensure 0666 even if Bun applied its own mode to the
161
+ // socket. See the global-dir chown debug note — wrap in try/catch so a
162
+ // chmod failure logs, not crashes.
163
+ try {
164
+ chmodSync(socketPath, 0o666);
165
+ } catch (err) {
166
+ logger.warn(
167
+ "chmod of API socket failed; inner containers may be unable to connect",
168
+ {
169
+ socket: socketPath,
170
+ error: err instanceof Error ? err.message : String(err),
171
+ },
172
+ );
173
+ }
174
+ logger.info("Inner-container API unix socket listening", {
175
+ socket: socketPath,
176
+ });
177
+ // Clean up orphan sockets from prior deploys (connect-test-and-unlink).
178
+ void sweepOrphanApiSockets(dataDirPath, apiSocketName(), logger);
179
+ } catch (err) {
180
+ logger.error(
181
+ "Failed to start inner-container API unix socket",
182
+ err instanceof Error ? err : undefined,
183
+ );
184
+ }
185
+ }
186
+
187
+ // ─── Initialize Core ────────────────────────────────────────────────────
188
+
189
+ const core = new MercuryCoreRuntime(config);
190
+ await core.initialize();
191
+
192
+ // ─── Load Extensions ────────────────────────────────────────────────────
193
+
194
+ const extensionsDir = resolveProjectPath(`${config.dataDir}/extensions`);
195
+ const globalDir = resolveProjectPath(config.globalDir);
196
+
197
+ // Sync installed catalog extensions against the bundled source. When a new
198
+ // image ships a patched extension (e.g. MERCURY_BROWSER_SESSIONS fix), agents
199
+ // with stale installed copies on their data volume are updated automatically
200
+ // on the next restart — no manual reinstall needed.
201
+ await syncBundledCatalogExtensions({
202
+ packageRoot: PACKAGE_ROOT,
203
+ extensionsDir,
204
+ globalDir,
205
+ catalog: EXTENSION_CATALOG,
206
+ logger,
207
+ });
208
+
209
+ const registry = new ExtensionRegistry();
210
+ const configRegistry = new ConfigRegistry();
211
+ const builtinExtDir = join(PACKAGE_ROOT, "resources/extensions");
212
+ await registry.loadAll(extensionsDir, core.db, logger, configRegistry, [
213
+ builtinExtDir,
214
+ ]);
215
+ logger.info("Extensions loaded", { count: registry.size });
216
+
217
+ logExtensionCapabilityMismatches(
218
+ registry.list(),
219
+ config.resolvedModelChainCapabilities,
220
+ logger,
221
+ );
222
+
223
+ // Wire extensions into runtime (hooks, context)
224
+ core.initExtensions(registry);
225
+
226
+ // Install skills (extension + built-in)
227
+ installExtensionSkills(
228
+ registry.list(),
229
+ globalDir,
230
+ logger,
231
+ config.resolvedModelChainCapabilities,
232
+ );
233
+ installBuiltinSkills(
234
+ join(PACKAGE_ROOT, "resources/skills"),
235
+ globalDir,
236
+ logger,
237
+ config.resolvedModelChainCapabilities,
238
+ );
239
+
240
+ // Ensure base image is available (auto-pull if missing)
241
+ await core.containerRunner.ensureImage();
242
+
243
+ // Start derived image build in the background — does not block warming→ready.
244
+ // Inner container spawns use the base image until the ext image is ready.
245
+ const buildState = new ExtImageBuildState(
246
+ config.agentContainerImage,
247
+ registry.list(),
248
+ logger,
249
+ process.env.MERCURY_AGENT_ID,
250
+ );
251
+ core.containerRunner.setBuildState(buildState);
252
+
253
+ // ─── Setup Adapters ─────────────────────────────────────────────────────
254
+
255
+ const adapters = setupAdapters(config);
256
+
257
+ // ─── Platform Bridges ─────────────────────────────────────────────────
258
+
259
+ const bridges: Record<string, PlatformBridge> = {};
260
+
261
+ if (adapters.whatsapp) {
262
+ bridges.whatsapp = new WhatsAppBridge(
263
+ adapters.whatsapp as WhatsAppBaileysAdapter,
264
+ );
265
+ (adapters.whatsapp as WhatsAppBaileysAdapter).onGroupRemoval = (
266
+ chatJid,
267
+ ) => {
268
+ try {
269
+ const externalId = `${chatJid}:${chatJid}`;
270
+ const conv = core.db.findConversation("whatsapp", externalId);
271
+ if (!conv?.spaceId) return;
272
+ core.db.addMessage(
273
+ conv.spaceId,
274
+ "ambient",
275
+ "[System] Agent was removed from this WhatsApp group. Conversation unlinked from its space.",
276
+ );
277
+ core.db.unlinkConversation(conv.id);
278
+ logger.info("WhatsApp: unlinked conversation on group removal", {
279
+ chatJid,
280
+ conversationId: conv.id,
281
+ spaceId: conv.spaceId,
282
+ });
283
+ } catch (err) {
284
+ logger.error(
285
+ "WhatsApp: failed to unlink conversation on group removal",
286
+ {
287
+ chatJid,
288
+ error: err instanceof Error ? err.message : String(err),
289
+ },
290
+ );
291
+ }
292
+ };
293
+ }
294
+ if (adapters.discord) {
295
+ bridges.discord = new DiscordBridge(
296
+ adapters.discord as DiscordNativeAdapter,
297
+ );
298
+ }
299
+ if (adapters.slack) {
300
+ const slackBotToken = process.env.MERCURY_SLACK_BOT_TOKEN;
301
+ if (!slackBotToken) {
302
+ throw new Error("Slack enabled but MERCURY_SLACK_BOT_TOKEN is missing");
303
+ }
304
+ bridges.slack = new SlackBridge(adapters.slack, slackBotToken);
305
+ }
306
+ if (adapters.teams) {
307
+ bridges.teams = new TeamsBridge(adapters.teams);
308
+ }
309
+ if (adapters.telegram) {
310
+ const telegramBotToken = process.env.MERCURY_TELEGRAM_BOT_TOKEN;
311
+ if (!telegramBotToken) {
312
+ throw new Error(
313
+ "Telegram enabled but MERCURY_TELEGRAM_BOT_TOKEN is missing",
314
+ );
315
+ }
316
+ bridges.telegram = new TelegramBridge(
317
+ adapters.telegram,
318
+ telegramBotToken,
319
+ config.telegramFormatEnabled,
320
+ );
321
+ }
322
+
323
+ const normalizeCtx: NormalizeContext = {
324
+ botUserName: config.botUsername,
325
+ getWorkspace: (spaceId) =>
326
+ ensureSpaceWorkspace(resolveProjectPath(config.spacesDir), spaceId),
327
+ media: {
328
+ enabled: config.mediaEnabled,
329
+ maxSizeBytes: config.mediaMaxSizeMb * 1024 * 1024,
330
+ },
331
+ isOverQuota: () => isOverQuota(config),
332
+ };
333
+
334
+ const handlers = new Map<string, ReturnType<typeof createMessageHandler>>();
335
+ for (const [name, bridge] of Object.entries(bridges)) {
336
+ handlers.set(
337
+ name,
338
+ createMessageHandler({ bridge, core, config, ctx: normalizeCtx }),
339
+ );
340
+ }
341
+
342
+ // ─── Message Dispatch ───────────────────────────────────────────────────
343
+
344
+ const onMessage = (adapter: Adapter, threadId: string, message: Message) => {
345
+ const handler = handlers.get(adapter.name);
346
+ if (handler) {
347
+ void handler(adapter, threadId, message).catch((error) =>
348
+ logger.error(
349
+ "Message handler failed",
350
+ error instanceof Error ? error : undefined,
351
+ ),
352
+ );
353
+ } else {
354
+ logger.warn("No bridge for adapter", { adapter: adapter.name });
355
+ }
356
+ };
357
+
358
+ // Chat shim satisfies Chat SDK adapter interface (initialize, webhooks)
359
+ // without the full Chat routing pipeline (subscriptions, mention routing, locks).
360
+ // Mercury handles its own routing via conversation resolution + trigger matching.
361
+ const chatShim = createChatShim(onMessage);
362
+
363
+ // ─── Message Sender (for scheduled tasks) ───────────────────────────────
364
+ // Tasks use spaceId (e.g. "tagula") which must be resolved to platform
365
+ // thread IDs (e.g. "telegram:12345") via linked conversations.
366
+
367
+ const messageSender: import("./types.js").MessageSender = {
368
+ async send(spaceOrThreadId, text, files) {
369
+ const threadIds: string[] = [];
370
+
371
+ if (spaceOrThreadId.includes(":")) {
372
+ // Already a platform thread ID (e.g. "telegram:12345")
373
+ threadIds.push(spaceOrThreadId);
374
+ } else {
375
+ // Space ID (e.g. "tagula") — resolve to linked conversations
376
+ const conversations = core.db.getSpaceConversations(spaceOrThreadId);
377
+ for (const conv of conversations) {
378
+ threadIds.push(`${conv.platform}:${conv.externalId}`);
379
+ }
380
+ if (threadIds.length === 0) {
381
+ logger.warn("Message dropped — no linked conversations for space", {
382
+ spaceId: spaceOrThreadId,
383
+ });
384
+ return;
385
+ }
386
+ logger.info("Task result: resolved space to conversations", {
387
+ spaceId: spaceOrThreadId,
388
+ threadIds,
389
+ });
390
+ }
391
+
392
+ for (const threadId of threadIds) {
393
+ const [platform] = threadId.split(":");
394
+ const bridge = bridges[platform];
395
+ if (!bridge) {
396
+ logger.warn("Message dropped — no bridge for platform", {
397
+ threadId,
398
+ platform,
399
+ });
400
+ continue;
401
+ }
402
+ logger.info("Task result: sending to platform", {
403
+ threadId,
404
+ platform,
405
+ textLength: text?.length ?? 0,
406
+ });
407
+ await bridge.sendReply(threadId, text, files);
408
+ }
409
+ },
410
+ };
411
+
412
+ // ─── Start Services ─────────────────────────────────────────────────────
413
+
414
+ core.startScheduler(messageSender);
415
+
416
+ // Start extension background jobs
417
+ const jobRunner = new JobRunner();
418
+ jobRunner.start(
419
+ registry.list(),
420
+ createMercuryExtensionContext({ db: core.db, config, log: logger }),
421
+ );
422
+ core.onShutdown(() => jobRunner.stop());
423
+
424
+ // Start built-in storage cleanup job
425
+ const cleanupLog = logger.child({ job: "_builtin:storage-cleanup" });
426
+ const runCleanup = () =>
427
+ runStorageCleanup({
428
+ config,
429
+ db: core.db,
430
+ log: cleanupLog,
431
+ isSpaceActive: (spaceId) => core.queue.isActive(spaceId),
432
+ }).catch((err) =>
433
+ cleanupLog.error(
434
+ "Storage cleanup failed",
435
+ err instanceof Error ? err : undefined,
436
+ ),
437
+ );
438
+ void runCleanup();
439
+ const cleanupTimer = setInterval(runCleanup, config.cleanupIntervalMs);
440
+ core.onShutdown(() => clearInterval(cleanupTimer));
441
+
442
+ // Initialize adapters via shim (calls adapter.initialize(chatShim))
443
+ for (const [name, adapter] of Object.entries(adapters)) {
444
+ logger.info("Initializing adapter", { adapter: name });
445
+ await adapter.initialize(chatShim);
446
+ }
447
+
448
+ // ─── Create HTTP Server ─────────────────────────────────────────────────
449
+
450
+ // Build webhook handlers — each adapter's handleWebhook is called directly
451
+ const webhooks: Record<
452
+ string,
453
+ (
454
+ request: Request,
455
+ options?: { waitUntil?: (task: Promise<unknown>) => void },
456
+ ) => Promise<Response>
457
+ > = {};
458
+
459
+ for (const [name, adapter] of Object.entries(adapters)) {
460
+ webhooks[name] = (request, options) =>
461
+ adapter.handleWebhook(request, options);
462
+ }
463
+
464
+ // Intercept Telegram my_chat_member updates to auto-unlink conversations
465
+ // when the bot is removed from or leaves a group.
466
+ if (webhooks.telegram) {
467
+ const rawTelegramWebhook = webhooks.telegram;
468
+ webhooks.telegram = async (req, opts) => {
469
+ try {
470
+ const update = (await req.clone().json()) as Record<string, unknown>;
471
+ const myChatMember = update.my_chat_member as
472
+ | { chat: { id: number }; new_chat_member: { status: string } }
473
+ | undefined;
474
+ const status = myChatMember?.new_chat_member?.status;
475
+ if (status === "kicked" || status === "left") {
476
+ const chatId = String(myChatMember?.chat.id);
477
+ const convs = core.db.findConversationsByPlatformPrefix(
478
+ "telegram",
479
+ chatId,
480
+ );
481
+ for (const conv of convs) {
482
+ if (!conv.spaceId) continue;
483
+ core.db.addMessage(
484
+ conv.spaceId,
485
+ "ambient",
486
+ "[System] Agent was removed from this Telegram group. Conversation unlinked from its space.",
487
+ );
488
+ core.db.unlinkConversation(conv.id);
489
+ logger.info("Telegram: unlinked conversation on group removal", {
490
+ chatId,
491
+ conversationId: conv.id,
492
+ spaceId: conv.spaceId,
493
+ });
494
+ }
495
+ }
496
+ } catch {
497
+ // Non-JSON body or missing fields — not a my_chat_member update; proceed normally
498
+ }
499
+ return rawTelegramWebhook(req, opts);
500
+ };
501
+ }
502
+
503
+ const app = createApp({
504
+ core,
505
+ config,
506
+ adapters,
507
+ webhooks,
508
+ startTime,
509
+ registry,
510
+ configRegistry,
511
+ projectRoot: process.cwd(),
512
+ packageRoot: PACKAGE_ROOT,
513
+ });
514
+
515
+ // Swap warming handler for the full Hono app now that initialization is complete
516
+ fetchHandler = (req) => app.fetch(req);
517
+
518
+ // ─── Shutdown Hooks ─────────────────────────────────────────────────────
519
+
520
+ core.onShutdown(async () => {
521
+ logger.info("Shutdown: closing chat adapters");
522
+ for (const [name, adapter] of Object.entries(adapters)) {
523
+ try {
524
+ if ("shutdown" in adapter && typeof adapter.shutdown === "function") {
525
+ await (adapter as { shutdown: () => Promise<void> }).shutdown();
526
+ logger.info("Shutdown: adapter disconnected", { adapter: name });
527
+ }
528
+ } catch (err) {
529
+ logger.error("Shutdown: failed to disconnect adapter", {
530
+ adapter: name,
531
+ error: err instanceof Error ? err.message : String(err),
532
+ });
533
+ }
534
+ }
535
+ });
536
+
537
+ core.onShutdown(async () => {
538
+ logger.info("Shutdown: stopping HTTP server");
539
+ server.stop(true);
540
+ if (unixServer) {
541
+ logger.info("Shutdown: stopping API unix socket");
542
+ unixServer.stop(true);
543
+ // Remove our socket inode on clean shutdown so it doesn't linger as an
544
+ // orphan; startup sweep is the backstop for unclean exits.
545
+ try {
546
+ rmSync(apiSocketPath(resolveProjectPath(config.dataDir)), {
547
+ force: true,
548
+ });
549
+ } catch {
550
+ // Best-effort cleanup — the next startup sweep will reap it.
551
+ }
552
+ }
553
+ });
554
+
555
+ core.installSignalHandlers();
556
+
557
+ // ─── Startup Logs ───────────────────────────────────────────────────────
558
+
559
+ logger.info("Server started", {
560
+ port: server.port,
561
+ image: config.agentContainerImage,
562
+ adapters: Object.keys(adapters).join(", ") || "none (chat-only mode)",
563
+ });
564
+ logger.info("Webhook path pattern: POST /webhooks/:platform");
565
+ logger.info("Internal API: /api/*");
566
+
567
+ if (adapters.discord) {
568
+ logger.info("Discord enabled (native adapter with persistent connection)");
569
+ }
570
+ if (adapters.teams) {
571
+ logger.info("Teams enabled (webhook via Azure Bot Service)");
572
+ }
573
+ if (adapters.whatsapp) {
574
+ logger.info("WhatsApp enabled", {
575
+ authDir: resolveProjectPath(config.whatsappAuthDir),
576
+ });
577
+ }
578
+ if (adapters.telegram) {
579
+ logger.info("Telegram enabled (webhook or polling)");
580
+ }
581
+ }
582
+
583
+ main().catch((error) => {
584
+ logger.error("Startup failed", error instanceof Error ? error : undefined);
585
+ process.exit(1);
586
+ });