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,1140 @@
1
+ import { ContainerError } from "../agent/container-error.js";
2
+ import { AgentContainerRunner } from "../agent/container-runner.js";
3
+ import {
4
+ classifyUserError,
5
+ friendlyErrorMessage,
6
+ } from "../agent/user-error-messages.js";
7
+ import { type AppConfig, resolveProjectPath } from "../config.js";
8
+ import { createMercuryExtensionContext } from "../extensions/context.js";
9
+ import { HookDispatcher } from "../extensions/hooks.js";
10
+ import type { ExtensionRegistry } from "../extensions/loader.js";
11
+ import type { MercuryExtensionContext } from "../extensions/types.js";
12
+ import { logger } from "../logger.js";
13
+ import { Db } from "../storage/db.js";
14
+ import {
15
+ ensurePiResourceDir,
16
+ ensureSpaceWorkspace,
17
+ } from "../storage/memory.js";
18
+ import type {
19
+ ContainerResult,
20
+ IngressMessage,
21
+ MessageAttachment,
22
+ MessageRunMeta,
23
+ MessageSender,
24
+ TokenUsage,
25
+ } from "../types.js";
26
+ import { formatCategoryHelp, formatHelp } from "./commands.js";
27
+ import { hasPermission, resolveRole } from "./permissions.js";
28
+ import { RateLimiter } from "./rate-limiter.js";
29
+ import { type RouteResult, routeInput } from "./router.js";
30
+ import { SpaceQueue } from "./space-queue.js";
31
+ import { TaskScheduler } from "./task-scheduler.js";
32
+
33
+ export type InputSource = "cli" | "scheduler" | "chat-sdk";
34
+
35
+ export type ShutdownHook = () => Promise<void> | void;
36
+
37
+ function agentMetaFromUsage(
38
+ usage: TokenUsage | undefined,
39
+ ): MessageRunMeta["agent"] {
40
+ if (!usage) return undefined;
41
+ const a: NonNullable<MessageRunMeta["agent"]> = {};
42
+ if (usage.inputTokens != null) a.inputTokens = usage.inputTokens;
43
+ if (usage.outputTokens != null) a.outputTokens = usage.outputTokens;
44
+ if (usage.totalTokens != null) a.totalTokens = usage.totalTokens;
45
+ if (usage.cacheReadTokens != null) a.cacheReadTokens = usage.cacheReadTokens;
46
+ if (usage.cacheWriteTokens != null)
47
+ a.cacheWriteTokens = usage.cacheWriteTokens;
48
+ if (usage.cost != null) a.cost = usage.cost;
49
+ if (usage.model != null) a.model = usage.model;
50
+ if (usage.provider != null) a.provider = usage.provider;
51
+ if (Object.keys(a).length === 0) return undefined;
52
+ return a;
53
+ }
54
+
55
+ function userTurnRunMeta(agentUsage?: TokenUsage): MessageRunMeta {
56
+ const meta: MessageRunMeta = {};
57
+ const agent = agentMetaFromUsage(agentUsage);
58
+ if (agent) meta.agent = agent;
59
+ return meta;
60
+ }
61
+
62
+ export class MercuryCoreRuntime {
63
+ readonly db: Db;
64
+ readonly scheduler: TaskScheduler;
65
+ readonly queue: SpaceQueue;
66
+ readonly containerRunner: AgentContainerRunner;
67
+ readonly rateLimiter: RateLimiter;
68
+ hooks: HookDispatcher | null = null;
69
+ private extensionCtx: MercuryExtensionContext | null = null;
70
+ private extensionRegistry: ExtensionRegistry | null = null;
71
+ private readonly shutdownHooks: ShutdownHook[] = [];
72
+ private shuttingDown = false;
73
+ private signalHandlersInstalled = false;
74
+
75
+ constructor(readonly config: AppConfig) {
76
+ this.db = new Db(resolveProjectPath(config.dbPath));
77
+ this.queue = new SpaceQueue(config.maxConcurrency);
78
+ this.scheduler = new TaskScheduler(this.db);
79
+ this.containerRunner = new AgentContainerRunner(config);
80
+ this.rateLimiter = new RateLimiter(
81
+ config.rateLimitPerUser,
82
+ config.rateLimitWindowMs,
83
+ );
84
+
85
+ // Scaffold global (pi agent dir) and "main" (default space)
86
+ ensurePiResourceDir(resolveProjectPath(config.globalDir));
87
+ ensureSpaceWorkspace(resolveProjectPath(config.spacesDir), "main");
88
+ this.db.ensureSpace("main");
89
+
90
+ // Seed context defaults for main space from AppConfig (idempotent per key).
91
+ // YAML/env values feed the seeded defaults; existing rows are never overwritten.
92
+ // Literal fallbacks mirror the Zod defaults in src/config.ts — tests that
93
+ // build AppConfig via `as AppConfig` cast (bypassing Zod) would otherwise
94
+ // pass undefined here and fail the NOT NULL constraint on space_config.value.
95
+ if (this.db.getSpaceConfig("main", "context.mode") === null) {
96
+ this.db.setSpaceConfig(
97
+ "main",
98
+ "context.mode",
99
+ config.contextMode ?? "context",
100
+ "system",
101
+ );
102
+ }
103
+ if (this.db.getSpaceConfig("main", "context.window_size") === null) {
104
+ this.db.setSpaceConfig(
105
+ "main",
106
+ "context.window_size",
107
+ String(config.contextWindowSize ?? 10),
108
+ "system",
109
+ );
110
+ }
111
+ if (this.db.getSpaceConfig("main", "context.reply_chain_depth") === null) {
112
+ this.db.setSpaceConfig(
113
+ "main",
114
+ "context.reply_chain_depth",
115
+ String(config.contextReplyChainDepth ?? 10),
116
+ "system",
117
+ );
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Initialize the runtime — must be called before accepting work.
123
+ * Cleans up any orphaned containers from previous runs.
124
+ */
125
+ async initialize(): Promise<void> {
126
+ await this.containerRunner.cleanupOrphans();
127
+ this.rateLimiter.startCleanup();
128
+ }
129
+
130
+ /**
131
+ * Wire extension system into the runtime.
132
+ * Must be called after extensions are loaded and before accepting messages.
133
+ */
134
+ initExtensions(registry: ExtensionRegistry): void {
135
+ this.hooks = new HookDispatcher(registry, logger);
136
+ this.extensionRegistry = registry;
137
+ this.extensionCtx = createMercuryExtensionContext({
138
+ db: this.db,
139
+ config: this.config,
140
+ log: logger,
141
+ });
142
+ this.warnUncoveredSensitiveSpaces();
143
+ }
144
+
145
+ /**
146
+ * Returns a comma-joined display name of active sensitive connections,
147
+ * or null if none are active. Active means: credential env var set (for
148
+ * env-var-based connections) or any extension state stored (for OAuth connections).
149
+ */
150
+ private getActiveSensitiveConnectionName(): string | null {
151
+ if (!this.extensionRegistry) return null;
152
+ const names: string[] = [];
153
+ for (const ext of this.extensionRegistry.list()) {
154
+ if (!ext.connection?.sensitive) continue;
155
+ const conn = ext.connection;
156
+ let active = false;
157
+ if (conn.credentialEnvVar) {
158
+ active = !!process.env[conn.credentialEnvVar];
159
+ } else if (conn.statusCheck) {
160
+ // Proxy: if any state has been stored, the OAuth flow was completed.
161
+ active = this.db.hasAnyExtensionState(ext.name);
162
+ }
163
+ if (active) names.push(conn.displayName);
164
+ }
165
+ return names.length > 0 ? names.join(", ") : null;
166
+ }
167
+
168
+ /**
169
+ * Sensitive connection guard — fires before the container for assistant turns.
170
+ * Returns { action: "proceed" } (proceed, optionally replaying a stored prompt)
171
+ * or { action: "block", reason } (send reason as reply, skip container).
172
+ */
173
+ private async checkSensitiveConnectionGuard(
174
+ spaceId: string,
175
+ prompt: string,
176
+ ): Promise<
177
+ | { action: "proceed"; replayPrompt?: string }
178
+ | { action: "block"; reason: string }
179
+ > {
180
+ // No registry = no extensions loaded = no sensitive connections possible
181
+ if (!this.extensionRegistry) return { action: "proceed" };
182
+
183
+ if (!this.db.hasGroupLinkedConversation(spaceId)) {
184
+ return { action: "proceed" };
185
+ }
186
+
187
+ const sensitiveName = this.getActiveSensitiveConnectionName();
188
+ if (!sensitiveName) return { action: "proceed" };
189
+
190
+ const allowed = this.db.getSpaceConfig(
191
+ spaceId,
192
+ "security.sensitive_connections_allowed",
193
+ );
194
+ if (allowed !== "true") {
195
+ return {
196
+ action: "block",
197
+ reason: `⛔ Sensitive integrations (${sensitiveName}) are disabled for this group space. A space admin must enable them first with: mrctl config set security.sensitive_connections_allowed true`,
198
+ };
199
+ }
200
+
201
+ const pendingAt = this.db.getSpaceConfig(
202
+ spaceId,
203
+ "security.pending_sensitive_at",
204
+ );
205
+ if (pendingAt) {
206
+ const ageMs = Date.now() - new Date(pendingAt).getTime();
207
+ const expired = Number.isNaN(ageMs) || ageMs > 5 * 60 * 1000;
208
+ const text = prompt.trim().toLowerCase();
209
+
210
+ if (expired || text === "no") {
211
+ this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_prompt");
212
+ this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_at");
213
+ if (expired) {
214
+ // Treat next message as fresh — fall through to new warning below
215
+ } else {
216
+ return { action: "block", reason: "Cancelled." };
217
+ }
218
+ } else if (text === "yes") {
219
+ const storedPrompt = this.db.getSpaceConfig(
220
+ spaceId,
221
+ "security.pending_sensitive_prompt",
222
+ );
223
+ this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_prompt");
224
+ this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_at");
225
+ return { action: "proceed", replayPrompt: storedPrompt ?? undefined };
226
+ } else {
227
+ // New message arrived mid-confirmation — replace pending with new prompt
228
+ // fall through to emit new warning below
229
+ }
230
+ }
231
+
232
+ // Emit warning and store the original prompt for replay on confirmation
233
+ this.db.setSpaceConfig(
234
+ spaceId,
235
+ "security.pending_sensitive_prompt",
236
+ prompt,
237
+ "system",
238
+ );
239
+ this.db.setSpaceConfig(
240
+ spaceId,
241
+ "security.pending_sensitive_at",
242
+ new Date().toISOString(),
243
+ "system",
244
+ );
245
+ return {
246
+ action: "block",
247
+ reason: `⚠️ This response may contain data from ${sensitiveName} and will be visible to all members of this group. Reply *yes* to proceed or *no* to cancel.`,
248
+ };
249
+ }
250
+
251
+ /** Log a startup warning for group spaces with active sensitive connections but no admin enable. */
252
+ private warnUncoveredSensitiveSpaces(): void {
253
+ const sensitiveName = this.getActiveSensitiveConnectionName();
254
+ if (!sensitiveName) return;
255
+
256
+ const affected: string[] = [];
257
+ for (const space of this.db.listSpaces()) {
258
+ if (!this.db.hasGroupLinkedConversation(space.id)) continue;
259
+ const allowed = this.db.getSpaceConfig(
260
+ space.id,
261
+ "security.sensitive_connections_allowed",
262
+ );
263
+ if (allowed !== "true") affected.push(space.id);
264
+ }
265
+
266
+ if (affected.length > 0) {
267
+ logger.warn(
268
+ "Sensitive connections active but not admin-enabled for group spaces — messages will be blocked until enabled",
269
+ { spaces: affected, connections: sensitiveName },
270
+ );
271
+ }
272
+ }
273
+
274
+ startScheduler(sender?: MessageSender): void {
275
+ this.scheduler.start(async (task) => {
276
+ const result = await this.executePrompt(
277
+ task.spaceId,
278
+ task.prompt,
279
+ "scheduler",
280
+ task.createdBy,
281
+ );
282
+ if (!task.silent && sender) {
283
+ await sender.send(task.spaceId, result.reply, result.files);
284
+ }
285
+ if (!task.silent && result.reply) {
286
+ this.deliverTaskOutput(task.spaceId, result.reply);
287
+ }
288
+ });
289
+ }
290
+
291
+ private deliverTaskOutput(spaceId: string, text: string): void {
292
+ const consoleUrl = process.env.MERCURY_CONSOLE_URL;
293
+ const secret = process.env.MERCURY_CONSOLE_INTERNAL_SECRET;
294
+ if (!consoleUrl || !secret) return;
295
+ const url = `${consoleUrl}/api/internal/whatsapp/deliver`;
296
+
297
+ fetch(url, {
298
+ method: "POST",
299
+ headers: {
300
+ "Content-Type": "application/json",
301
+ Authorization: `Bearer ${secret}`,
302
+ },
303
+ body: JSON.stringify({
304
+ agentId: process.env.MERCURY_AGENT_ID ?? "unknown",
305
+ spaceId,
306
+ text,
307
+ }),
308
+ }).catch((err) => {
309
+ const cause = (err as Error & { cause?: Error }).cause;
310
+ logger.warn("Task output delivery failed", {
311
+ error: err instanceof Error ? err.message : String(err),
312
+ cause: cause?.message,
313
+ });
314
+ });
315
+ }
316
+
317
+ stopScheduler(): void {
318
+ this.scheduler.stop();
319
+ }
320
+
321
+ async handleRawInput(
322
+ message: IngressMessage,
323
+ source: Exclude<InputSource, "scheduler">,
324
+ ): Promise<RouteResult & { result?: ContainerResult }> {
325
+ const route = routeInput({
326
+ text: message.text,
327
+ spaceId: message.spaceId,
328
+ callerId: message.callerId,
329
+ isDM: message.isDM,
330
+ isReplyToBot: message.isReplyToBot,
331
+ db: this.db,
332
+ config: this.config,
333
+ attachments: message.attachments,
334
+ hadIncomingAttachments: message.hadIncomingAttachments,
335
+ authorName: message.authorName,
336
+ });
337
+
338
+ if (route.type === "command") {
339
+ const reply = await this.executeCommand(
340
+ message.spaceId,
341
+ route.command,
342
+ route.callerId,
343
+ route.verb,
344
+ route.arg,
345
+ );
346
+ return { ...route, result: { reply, files: [] } };
347
+ }
348
+
349
+ // Check mute — silently drop messages from muted users
350
+ if (
351
+ route.type === "assistant" &&
352
+ this.db.isMuted(message.spaceId, message.callerId)
353
+ ) {
354
+ return { type: "ignore" };
355
+ }
356
+
357
+ // Check rate limit for assistant requests (not commands, not ignored messages)
358
+ if (route.type === "assistant") {
359
+ // Check per-group override first
360
+ const groupLimit = this.db.getSpaceConfig(message.spaceId, "rate_limit");
361
+ const effectiveLimit = groupLimit
362
+ ? Number.parseInt(groupLimit, 10)
363
+ : this.config.rateLimitPerUser;
364
+
365
+ if (
366
+ effectiveLimit > 0 &&
367
+ !this.checkRateLimit(message.spaceId, message.callerId, effectiveLimit)
368
+ ) {
369
+ return {
370
+ type: "denied",
371
+ reason: "Rate limit exceeded. Try again shortly.",
372
+ };
373
+ }
374
+ }
375
+
376
+ if (route.type !== "assistant") {
377
+ // Store ambient messages in group chats (non-triggered, non-DM)
378
+ // Default: enabled. Set ambient.enabled=false for tag-only mode.
379
+ const ambientEnabled =
380
+ this.db.getSpaceConfig(message.spaceId, "ambient.enabled") !== "false";
381
+ if (
382
+ route.type === "ignore" &&
383
+ source === "chat-sdk" &&
384
+ !message.isDM &&
385
+ ambientEnabled
386
+ ) {
387
+ const ambientText = message.authorName
388
+ ? `${message.authorName}: ${message.text.trim()}`
389
+ : message.text.trim();
390
+
391
+ if (ambientText) {
392
+ this.db.ensureSpace(message.spaceId);
393
+ this.db.addMessage(message.spaceId, "ambient", ambientText);
394
+ }
395
+ }
396
+
397
+ return route;
398
+ }
399
+
400
+ const noPromptText = !message.text.trim();
401
+ const noSavedFiles = (message.attachments?.length ?? 0) === 0;
402
+ if (
403
+ noPromptText &&
404
+ noSavedFiles &&
405
+ (message.hadIncomingAttachments ?? false)
406
+ ) {
407
+ return {
408
+ type: "denied",
409
+ reason:
410
+ "Could not use your attachment (media disabled, over the size limit, or download failed). Check MERCURY_MEDIA_ENABLED and logs.",
411
+ };
412
+ }
413
+
414
+ const guardResult = await this.checkSensitiveConnectionGuard(
415
+ message.spaceId,
416
+ route.prompt,
417
+ );
418
+ if (guardResult.action === "block") {
419
+ return { type: "denied", reason: guardResult.reason };
420
+ }
421
+ const effectivePrompt = guardResult.replayPrompt ?? route.prompt;
422
+
423
+ try {
424
+ const result = await this.executePrompt(
425
+ message.spaceId,
426
+ effectivePrompt,
427
+ source,
428
+ message.callerId,
429
+ message.attachments,
430
+ message.authorName,
431
+ {
432
+ platform: message.platform,
433
+ conversationExternalId: message.conversationExternalId,
434
+ replyToPlatformMessageId: message.replyToPlatformMessageId,
435
+ platformMessageId: message.platformMessageId,
436
+ },
437
+ { isReplyToBot: route.isReplyToBot, isDM: route.isDM },
438
+ );
439
+ return { ...route, result };
440
+ } catch (error) {
441
+ if (error instanceof ContainerError) {
442
+ switch (error.reason) {
443
+ case "aborted":
444
+ return { type: "denied", reason: "Stopped current run." };
445
+ case "timeout":
446
+ return { type: "denied", reason: "Container timed out." };
447
+ case "oom":
448
+ return {
449
+ type: "denied",
450
+ reason: "Container was killed (possibly out of memory).",
451
+ };
452
+ case "error": {
453
+ logger.error(
454
+ "Container error",
455
+ error instanceof Error ? error : undefined,
456
+ );
457
+ const category = classifyUserError(error.message);
458
+ const reason = friendlyErrorMessage(
459
+ category,
460
+ this.config.apiKeyMode,
461
+ this.config.consoleUrl,
462
+ );
463
+ return { type: "denied", reason };
464
+ }
465
+ }
466
+ }
467
+ throw error;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Check if a request is allowed under rate limiting.
473
+ * Uses per-group override if set, otherwise uses the default limit.
474
+ */
475
+ private checkRateLimit(
476
+ spaceId: string,
477
+ userId: string,
478
+ effectiveLimit: number,
479
+ ): boolean {
480
+ return this.rateLimiter.isAllowed(spaceId, userId, effectiveLimit);
481
+ }
482
+
483
+ private async executeCommand(
484
+ spaceId: string,
485
+ command: string,
486
+ callerId: string,
487
+ verb?: string,
488
+ arg?: string,
489
+ ): Promise<string> {
490
+ switch (command) {
491
+ case "stop": {
492
+ const stopped = this.containerRunner.abort(spaceId);
493
+ const dropped = this.queue.cancelPending(spaceId);
494
+ if (stopped)
495
+ return `Stopped.${dropped > 0 ? ` Dropped ${dropped} queued request(s).` : ""}`;
496
+ if (dropped > 0) return `Dropped ${dropped} queued request(s).`;
497
+ return "No active run.";
498
+ }
499
+ case "compact": {
500
+ this.db.setSessionBoundaryToLatest(spaceId);
501
+ return "Compacted.";
502
+ }
503
+ case "clear": {
504
+ this.db.setClearBoundary(spaceId);
505
+ return "Cleared.";
506
+ }
507
+ case "help": {
508
+ if (verb) {
509
+ return (
510
+ formatCategoryHelp(verb) ?? `No help available for '/${verb}'.`
511
+ );
512
+ }
513
+ return formatHelp();
514
+ }
515
+ case "model":
516
+ return this.executeModelsCommand(spaceId, callerId, verb, arg);
517
+ default:
518
+ return `Unknown command: ${command}`;
519
+ }
520
+ }
521
+
522
+ private executeModelsCommand(
523
+ spaceId: string,
524
+ callerId: string,
525
+ verb?: string,
526
+ arg?: string,
527
+ ): string {
528
+ const chain = this.config.resolvedModelChain;
529
+
530
+ if (!verb) {
531
+ return formatCategoryHelp("model") ?? "/model — model management";
532
+ }
533
+
534
+ switch (verb) {
535
+ case "list": {
536
+ if (chain.length === 0) return "No models configured.";
537
+ const activeRaw = this.db.getSpaceConfig(spaceId, "model.active");
538
+ const activeFound = activeRaw
539
+ ? chain.some((l) => `${l.provider}:${l.model}` === activeRaw)
540
+ : false;
541
+ const lines = ["Configured models:"];
542
+ for (let i = 0; i < chain.length; i++) {
543
+ const leg = chain[i];
544
+ const isActive = activeFound
545
+ ? activeRaw === `${leg.provider}:${leg.model}`
546
+ : i === 0;
547
+ lines.push(
548
+ ` [${i + 1}] ${leg.provider} / ${leg.model}${isActive ? " ← active" : ""}`,
549
+ );
550
+ }
551
+ return lines.join("\n");
552
+ }
553
+
554
+ case "active": {
555
+ const activeRaw = this.db.getSpaceConfig(spaceId, "model.active");
556
+ const leg =
557
+ (activeRaw
558
+ ? chain.find((l) => `${l.provider}:${l.model}` === activeRaw)
559
+ : undefined) ?? chain[0];
560
+ if (!leg) return "No models configured.";
561
+ return `Active model: ${leg.provider} / ${leg.model}`;
562
+ }
563
+
564
+ case "switch": {
565
+ if (!arg)
566
+ return "Usage: /model switch <N|MODEL_ID>. Use /model list to see options.";
567
+ if (chain.length === 0) return "No models configured.";
568
+
569
+ let leg: (typeof chain)[number] | undefined;
570
+ const num = Number.parseInt(arg, 10);
571
+ if (!Number.isNaN(num) && num >= 1 && num <= chain.length) {
572
+ leg = chain[num - 1];
573
+ } else {
574
+ leg = chain.find(
575
+ (l) => l.model === arg || `${l.provider}:${l.model}` === arg,
576
+ );
577
+ }
578
+
579
+ if (!leg)
580
+ return "Model not found. Use /model list to see your options.";
581
+
582
+ this.db.setSpaceConfig(
583
+ spaceId,
584
+ "model.active",
585
+ `${leg.provider}:${leg.model}`,
586
+ callerId,
587
+ );
588
+ return `Switched to ${leg.provider} / ${leg.model}.`;
589
+ }
590
+
591
+ default:
592
+ return `/model: unknown verb '${verb}'. Use /model for help.`;
593
+ }
594
+ }
595
+
596
+ onShutdown(hook: ShutdownHook): void {
597
+ this.shutdownHooks.push(hook);
598
+ }
599
+
600
+ get isShuttingDown(): boolean {
601
+ return this.shuttingDown;
602
+ }
603
+
604
+ installSignalHandlers(): void {
605
+ if (this.signalHandlersInstalled) return;
606
+ this.signalHandlersInstalled = true;
607
+
608
+ let forceCount = 0;
609
+
610
+ const handler = (signal: string) => {
611
+ if (this.shuttingDown) {
612
+ forceCount++;
613
+ if (forceCount >= 1) {
614
+ logger.warn("Second signal received, forcing exit");
615
+ process.exit(1);
616
+ }
617
+ return;
618
+ }
619
+ logger.info("Received signal, starting graceful shutdown", { signal });
620
+ void this.shutdown().then(
621
+ () => process.exit(0),
622
+ (err) => {
623
+ logger.error(
624
+ "Shutdown failed",
625
+ err instanceof Error ? err : undefined,
626
+ );
627
+ process.exit(1);
628
+ },
629
+ );
630
+ };
631
+
632
+ process.on("SIGTERM", () => handler("SIGTERM"));
633
+ process.on("SIGINT", () => handler("SIGINT"));
634
+ }
635
+
636
+ async shutdown(timeoutMs = 10_000): Promise<void> {
637
+ if (this.shuttingDown) return;
638
+ this.shuttingDown = true;
639
+
640
+ const forceTimer = setTimeout(() => {
641
+ logger.error("Shutdown timed out, forcing exit");
642
+ process.exit(1);
643
+ }, timeoutMs);
644
+ // Don't keep the process alive just for this timer
645
+ if (forceTimer.unref) forceTimer.unref();
646
+
647
+ try {
648
+ // 1. Stop schedulers
649
+ logger.info("Shutdown: stopping task scheduler");
650
+ this.scheduler.stop();
651
+
652
+ // 2. Drain queue — cancel pending, wait for active
653
+ logger.info("Shutdown: draining group queue");
654
+ const dropped = this.queue.cancelAll();
655
+ if (dropped > 0)
656
+ logger.info("Shutdown: cancelled pending queue entries", {
657
+ count: dropped,
658
+ });
659
+
660
+ // 3. Kill running containers
661
+ logger.info("Shutdown: stopping running containers");
662
+ this.containerRunner.killAll();
663
+
664
+ // 4. Wait for active work to finish (with a shorter timeout)
665
+ const drainTimeout = Math.max(timeoutMs - 2000, 1000);
666
+ const drained = await this.queue.waitForActive(drainTimeout);
667
+ if (!drained) {
668
+ logger.warn("Shutdown: active work did not finish in time");
669
+ }
670
+
671
+ // 5. Emit extension shutdown hooks
672
+ if (this.hooks && this.extensionCtx) {
673
+ logger.info("Shutdown: notifying extensions");
674
+ await this.hooks.emit("shutdown", {}, this.extensionCtx);
675
+ }
676
+
677
+ // 6. Run registered shutdown hooks (adapters, server, etc.)
678
+ for (const hook of this.shutdownHooks) {
679
+ try {
680
+ await hook();
681
+ } catch (err) {
682
+ logger.error(
683
+ "Shutdown hook failed",
684
+ err instanceof Error ? err : undefined,
685
+ );
686
+ }
687
+ }
688
+
689
+ // 6. Stop rate limiter cleanup
690
+ this.rateLimiter.stopCleanup();
691
+
692
+ // 7. Close database
693
+ logger.info("Shutdown: closing database");
694
+ this.db.close();
695
+
696
+ logger.info("Shutdown: complete");
697
+ } finally {
698
+ clearTimeout(forceTimer);
699
+ }
700
+ }
701
+
702
+ private async executePrompt(
703
+ spaceId: string,
704
+ prompt: string,
705
+ _source: InputSource,
706
+ callerId: string,
707
+ attachments?: MessageAttachment[],
708
+ authorName?: string,
709
+ replyMeta?: {
710
+ platform?: string;
711
+ conversationExternalId?: string;
712
+ replyToPlatformMessageId?: string;
713
+ platformMessageId?: string;
714
+ },
715
+ replyFlags?: { isReplyToBot: boolean; isDM: boolean },
716
+ ): Promise<ContainerResult> {
717
+ this.db.ensureSpace(spaceId);
718
+
719
+ return this.queue.enqueue(spaceId, async () => {
720
+ // ── Daily message quota check ────────────────────────────────────────
721
+ // Calls the console API to record the message and verify the user hasn't
722
+ // exceeded their plan's daily limit. Fails open: if the API is unreachable,
723
+ // the message is allowed through (billing is best-effort, not a hard gate).
724
+ if (this.config.consoleUrl && this.config.consoleUserId) {
725
+ try {
726
+ const quotaRes = await fetch(
727
+ `${this.config.consoleUrl}/api/user/billing/message-used`,
728
+ {
729
+ method: "POST",
730
+ headers: {
731
+ "Content-Type": "application/json",
732
+ ...(this.config.consoleInternalSecret
733
+ ? {
734
+ Authorization: `Bearer ${this.config.consoleInternalSecret}`,
735
+ }
736
+ : {}),
737
+ },
738
+ body: JSON.stringify({
739
+ userId: this.config.consoleUserId,
740
+ agentId: process.env.MERCURY_AGENT_ID ?? spaceId,
741
+ isByok: false,
742
+ }),
743
+ signal: AbortSignal.timeout(5000),
744
+ },
745
+ );
746
+ if (quotaRes.ok) {
747
+ const quotaData = (await quotaRes.json()) as {
748
+ allowed: boolean;
749
+ remaining: number | null;
750
+ };
751
+ if (!quotaData.allowed) {
752
+ return {
753
+ reply:
754
+ "You've reached your daily message limit. Upgrade your plan at the Mercury Console to continue chatting.",
755
+ files: [],
756
+ };
757
+ }
758
+ }
759
+ // Non-OK response → fail open (log but don't block)
760
+ else {
761
+ logger.warn(
762
+ "Quota check returned non-OK status — allowing message",
763
+ { status: quotaRes.status },
764
+ );
765
+ }
766
+ } catch (err) {
767
+ logger.warn("Quota check failed (unreachable?) — allowing message", {
768
+ err,
769
+ });
770
+ }
771
+ }
772
+ // ────────────────────────────────────────────────────────────────────
773
+
774
+ const workspace = ensureSpaceWorkspace(
775
+ resolveProjectPath(this.config.spacesDir),
776
+ spaceId,
777
+ );
778
+
779
+ // Container-relative workspace path
780
+ const containerWorkspace = `/spaces/${spaceId}`;
781
+
782
+ // ── Reply-chain isolation ──────────────────────────────────────────
783
+ // Strip quoted bot output from unprivileged group replies-to-bot
784
+ // BEFORE hooks see the prompt (defense in depth).
785
+ let replyIsolated = false;
786
+ let finalPrompt = prompt;
787
+ if (replyFlags?.isReplyToBot && !replyFlags.isDM) {
788
+ const seededAdmins = this.config.admins
789
+ ? this.config.admins
790
+ .split(",")
791
+ .map((s) => s.trim())
792
+ .filter(Boolean)
793
+ : [];
794
+ const earlyRole = resolveRole(this.db, spaceId, callerId, seededAdmins);
795
+ if (earlyRole !== "admin" && earlyRole !== "system") {
796
+ replyIsolated = true;
797
+ finalPrompt = finalPrompt.replace(
798
+ /\n*<reply_to[^>]*>[\s\S]*?<\/reply_to>/g,
799
+ "",
800
+ );
801
+ }
802
+ }
803
+ // ────────────────────────────────────────────────────────────────────
804
+
805
+ // Emit workspace_init hook (extensions should be idempotent)
806
+ if (this.hooks && this.extensionCtx) {
807
+ await this.hooks.emit(
808
+ "workspace_init",
809
+ { spaceId, workspace, containerWorkspace },
810
+ this.extensionCtx,
811
+ );
812
+ }
813
+
814
+ // Emit before_container hook
815
+ let extraEnv: Record<string, string> | undefined;
816
+ if (this.hooks && this.extensionCtx) {
817
+ const result = await this.hooks.emitBeforeContainer(
818
+ {
819
+ spaceId,
820
+ prompt: finalPrompt,
821
+ callerId,
822
+ workspace,
823
+ containerWorkspace,
824
+ attachments,
825
+ },
826
+ this.extensionCtx,
827
+ );
828
+ if (result?.block) {
829
+ return { reply: result.block.reason, files: [] };
830
+ }
831
+ if (result) {
832
+ if (result.env) {
833
+ extraEnv = { ...extraEnv, ...result.env };
834
+ }
835
+ if (result.systemPrompt) {
836
+ extraEnv = {
837
+ ...extraEnv,
838
+ MERCURY_EXT_SYSTEM_PROMPT: result.systemPrompt,
839
+ };
840
+ }
841
+ if (result.promptAppend) {
842
+ finalPrompt = [finalPrompt, result.promptAppend]
843
+ .filter(Boolean)
844
+ .join("\n\n");
845
+ }
846
+ }
847
+ }
848
+
849
+ // Inject per-space system prompt (set via console Spaces settings or at provision time).
850
+ const spacePrompt = this.db.getSpaceConfig(spaceId, "system_prompt");
851
+ if (spacePrompt) {
852
+ const existing = extraEnv?.MERCURY_EXT_SYSTEM_PROMPT;
853
+ extraEnv = {
854
+ ...extraEnv,
855
+ MERCURY_EXT_SYSTEM_PROMPT: existing
856
+ ? `${existing}\n\n${spacePrompt}`
857
+ : spacePrompt,
858
+ };
859
+ }
860
+
861
+ // Resolve reply target once — reused for context assembly and DB linkage.
862
+ let replyMercuryMsgId: number | null = null;
863
+ if (
864
+ replyMeta?.replyToPlatformMessageId &&
865
+ replyMeta.platform &&
866
+ replyMeta.conversationExternalId
867
+ ) {
868
+ replyMercuryMsgId = this.db.lookupMercuryMessageId(
869
+ replyMeta.platform,
870
+ replyMeta.conversationExternalId,
871
+ replyMeta.replyToPlatformMessageId,
872
+ );
873
+ }
874
+ const userReplyToId = replyMercuryMsgId ?? undefined;
875
+
876
+ const replyChainDepthStr = this.db.getSpaceConfig(
877
+ spaceId,
878
+ "context.reply_chain_depth",
879
+ );
880
+ const replyChainDepth = replyChainDepthStr
881
+ ? Number.parseInt(replyChainDepthStr, 10)
882
+ : 10;
883
+
884
+ // Fetch prior turns based on context mode.
885
+ // When reply-isolated, skip all history to prevent context leakage.
886
+ let history: import("../types.js").StoredMessage[];
887
+ let anchorMessages: import("../types.js").StoredMessage[] | undefined;
888
+ if (replyIsolated) {
889
+ history = [];
890
+ extraEnv = { ...extraEnv, MERCURY_REPLY_ISOLATED: "1" };
891
+ } else {
892
+ const contextMode =
893
+ this.db.getSpaceConfig(spaceId, "context.mode") ?? "clear";
894
+
895
+ if (contextMode === "context") {
896
+ const windowSizeStr = this.db.getSpaceConfig(
897
+ spaceId,
898
+ "context.window_size",
899
+ );
900
+ const windowSize = windowSizeStr
901
+ ? Number.parseInt(windowSizeStr, 10)
902
+ : (this.config.contextWindowSize ?? 10);
903
+
904
+ if (replyMercuryMsgId !== null) {
905
+ const trimmedWindow = Math.floor(windowSize / 2);
906
+ const anchored = this.db.getAnchoredContext(
907
+ spaceId,
908
+ replyMercuryMsgId,
909
+ replyChainDepth,
910
+ trimmedWindow,
911
+ );
912
+ anchorMessages = anchored.anchor;
913
+ history = anchored.recent;
914
+ } else {
915
+ history = this.db.getRecentTurns(spaceId, windowSize);
916
+ }
917
+
918
+ // One-shot clear: reset temporary boundary immediately after reading history.
919
+ this.db.resetClearBoundary(spaceId);
920
+ } else {
921
+ // Clear mode: only include reply chain if this message is a reply
922
+ if (replyMercuryMsgId !== null) {
923
+ history = this.db.getReplyChain(
924
+ replyMercuryMsgId,
925
+ replyChainDepth,
926
+ spaceId,
927
+ );
928
+ } else {
929
+ history = [];
930
+ }
931
+ }
932
+ }
933
+
934
+ const userMessageId = this.db.addMessage(
935
+ spaceId,
936
+ "user",
937
+ finalPrompt,
938
+ attachments,
939
+ userReplyToId,
940
+ );
941
+
942
+ // Record platform message ID mapping for the inbound user message
943
+ if (
944
+ replyMeta?.platformMessageId &&
945
+ replyMeta.platform &&
946
+ replyMeta.conversationExternalId
947
+ ) {
948
+ this.db.addPlatformMessageId(
949
+ userMessageId,
950
+ replyMeta.platform,
951
+ replyMeta.conversationExternalId,
952
+ replyMeta.platformMessageId,
953
+ );
954
+ }
955
+
956
+ // Compute caller role, denied CLIs, and permitted env vars
957
+ let callerRole = "member";
958
+ if (this.extensionRegistry) {
959
+ const seededAdmins = this.config.admins
960
+ ? this.config.admins
961
+ .split(",")
962
+ .map((s) => s.trim())
963
+ .filter(Boolean)
964
+ : [];
965
+ callerRole = resolveRole(this.db, spaceId, callerId, seededAdmins);
966
+
967
+ const cliExtensions = this.extensionRegistry.getCliExtensions();
968
+ if (cliExtensions.length > 0) {
969
+ const denied = cliExtensions
970
+ .filter(
971
+ (ext) =>
972
+ ext.clis.length > 0 &&
973
+ !hasPermission(this.db, spaceId, callerRole, ext.name),
974
+ )
975
+ .flatMap((ext) => ext.clis.map((c) => c.name));
976
+ if (denied.length > 0) {
977
+ extraEnv = {
978
+ ...extraEnv,
979
+ MERCURY_DENIED_CLIS: denied.join(","),
980
+ };
981
+ }
982
+ }
983
+
984
+ // Inject extension env vars only when caller has permission
985
+ for (const ext of this.extensionRegistry.list()) {
986
+ if (ext.envVars.length === 0) continue;
987
+ if (
988
+ ext.permission &&
989
+ !hasPermission(this.db, spaceId, callerRole, ext.name)
990
+ )
991
+ continue;
992
+ for (const envDef of ext.envVars) {
993
+ const value = process.env[envDef.from];
994
+ if (value) {
995
+ const containerKey =
996
+ envDef.as ?? envDef.from.replace(/^MERCURY_/, "");
997
+ extraEnv = { ...extraEnv, [containerKey]: value };
998
+ }
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ // Inject active model as single-leg override (eliminates automatic fallback)
1004
+ const activeModelRaw = this.db.getSpaceConfig(spaceId, "model.active");
1005
+ if (activeModelRaw) {
1006
+ const colonIdx = activeModelRaw.indexOf(":");
1007
+ if (colonIdx > 0) {
1008
+ const provider = activeModelRaw.slice(0, colonIdx);
1009
+ const model = activeModelRaw.slice(colonIdx + 1);
1010
+ const legIdx = this.config.resolvedModelChain.findIndex(
1011
+ (l) => l.provider === provider && l.model === model,
1012
+ );
1013
+ if (legIdx >= 0) {
1014
+ extraEnv = {
1015
+ ...extraEnv,
1016
+ MODEL_CHAIN: JSON.stringify([
1017
+ this.config.resolvedModelChain[legIdx],
1018
+ ]),
1019
+ MODEL_CHAIN_CAPABILITIES: JSON.stringify([
1020
+ this.config.resolvedModelChainCapabilities[legIdx],
1021
+ ]),
1022
+ };
1023
+ } else {
1024
+ logger.warn(
1025
+ "model.active references unknown leg, ignoring override",
1026
+ {
1027
+ spaceId,
1028
+ activeModelRaw,
1029
+ },
1030
+ );
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ const startTime = Date.now();
1036
+
1037
+ const preferences = this.db.listSpacePreferences(spaceId).map((p) => ({
1038
+ key: p.key,
1039
+ value: p.value,
1040
+ }));
1041
+
1042
+ let containerResult: ContainerResult;
1043
+ try {
1044
+ containerResult = await this.containerRunner.replyWithRetry({
1045
+ spaceId,
1046
+ spaceWorkspace: workspace,
1047
+ messages: history,
1048
+ anchorMessages,
1049
+ prompt: finalPrompt,
1050
+ callerId,
1051
+ callerRole,
1052
+ authorName,
1053
+ attachments,
1054
+ preferences,
1055
+ extraEnv,
1056
+ claimedEnvSources: this.extensionRegistry?.getClaimedEnvSources(),
1057
+ });
1058
+ } catch (err) {
1059
+ this.db.updateMessageRunMeta(userMessageId, userTurnRunMeta(undefined));
1060
+ throw err;
1061
+ }
1062
+
1063
+ const durationMs = Date.now() - startTime;
1064
+
1065
+ // Emit after_container hook
1066
+ if (this.hooks && this.extensionCtx) {
1067
+ const hookResult = await this.hooks.emitAfterContainer(
1068
+ {
1069
+ spaceId,
1070
+ workspace,
1071
+ callerId,
1072
+ prompt: finalPrompt,
1073
+ reply: containerResult.reply,
1074
+ durationMs,
1075
+ },
1076
+ this.extensionCtx,
1077
+ );
1078
+ if (hookResult?.suppress) {
1079
+ this.db.updateMessageRunMeta(
1080
+ userMessageId,
1081
+ userTurnRunMeta(containerResult.usage),
1082
+ );
1083
+ return { reply: "", files: [] };
1084
+ }
1085
+ if (hookResult?.reply !== undefined) {
1086
+ containerResult.reply = hookResult.reply;
1087
+ }
1088
+ if (hookResult?.files?.length) {
1089
+ containerResult.files = [
1090
+ ...containerResult.files,
1091
+ ...hookResult.files,
1092
+ ];
1093
+ }
1094
+ }
1095
+
1096
+ const assistantMessageId = this.db.addMessage(
1097
+ spaceId,
1098
+ "assistant",
1099
+ containerResult.reply,
1100
+ undefined,
1101
+ userMessageId, // reply chain: assistant replies to user message
1102
+ );
1103
+
1104
+ if (containerResult.usage) {
1105
+ this.db.recordUsage(spaceId, containerResult.usage);
1106
+ } else {
1107
+ logger.debug(
1108
+ "Container run finished without token usage (old agent image, non-JSON pi output, or zero reported usage)",
1109
+ { spaceId },
1110
+ );
1111
+ }
1112
+
1113
+ this.db.updateMessageRunMeta(
1114
+ userMessageId,
1115
+ userTurnRunMeta(containerResult.usage),
1116
+ );
1117
+
1118
+ containerResult.assistantMessageId = assistantMessageId;
1119
+ return containerResult;
1120
+ });
1121
+ }
1122
+
1123
+ /**
1124
+ * Record the platform message ID for an outbound assistant message.
1125
+ * Called by the handler after sendReply() returns the platform ID.
1126
+ */
1127
+ recordOutboundPlatformId(
1128
+ assistantMessageId: number,
1129
+ platform: string,
1130
+ conversationExternalId: string,
1131
+ platformMessageId: string,
1132
+ ): void {
1133
+ this.db.addPlatformMessageId(
1134
+ assistantMessageId,
1135
+ platform,
1136
+ conversationExternalId,
1137
+ platformMessageId,
1138
+ );
1139
+ }
1140
+ }