mercury-agent 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +452 -451
  2. package/container/Dockerfile +127 -127
  3. package/container/Dockerfile.base +109 -109
  4. package/container/Dockerfile.power +17 -17
  5. package/container/agent-package.json +8 -8
  6. package/container/build.sh +54 -54
  7. package/docs/ROADMAP.md +3 -4
  8. package/docs/archive/summarization/2026-07-02-recent-dev-summary.md +71 -0
  9. package/docs/auth/dashboard.md +28 -28
  10. package/docs/auth/overview.md +109 -109
  11. package/docs/auth/whatsapp.md +173 -173
  12. package/docs/authoring-profiles.md +174 -174
  13. package/docs/configuration.md +54 -54
  14. package/docs/container-lifecycle.md +349 -349
  15. package/docs/{bugs/bwrap-privileged-linux-docker.md → debug/major/2026-07-02-bwrap-privileged-linux-docker.md} +21 -1
  16. package/docs/{bugs/e2big-prompt-delivery-fix.md → debug/major/2026-07-02-e2big-prompt-delivery-fix.md} +27 -1
  17. package/docs/{bugs/registry-pull-no-local-fallback.md → debug/major/2026-07-02-registry-pull-no-local-fallback.md} +27 -1
  18. package/docs/debug/summarization/2026-07-02-common-bug-patterns.md +72 -0
  19. package/docs/deployment.md +199 -199
  20. package/docs/extensions.md +375 -375
  21. package/docs/graceful-shutdown.md +62 -62
  22. package/docs/kb-distillation.md +77 -77
  23. package/docs/media/overview.md +140 -140
  24. package/docs/media/whatsapp.md +171 -171
  25. package/docs/memory.md +137 -137
  26. package/docs/permissions.md +217 -217
  27. package/docs/pipeline.md +228 -228
  28. package/docs/prd-chat-memory.md +76 -76
  29. package/docs/prd-config-load.md +82 -82
  30. package/docs/rate-limiting.md +229 -229
  31. package/docs/runbooks/publish-checklist.md +9 -1
  32. package/docs/scheduler.md +288 -288
  33. package/docs/setup-discord.md +100 -100
  34. package/docs/setup-slack.md +119 -119
  35. package/docs/setup-whatsapp.md +94 -94
  36. package/docs/subagents.md +166 -166
  37. package/docs/web-search.md +62 -62
  38. package/examples/extensions/README.md +12 -12
  39. package/examples/extensions/charts/index.ts +13 -13
  40. package/examples/extensions/charts/skill/SKILL.md +98 -98
  41. package/examples/extensions/gws/README.md +52 -52
  42. package/examples/extensions/gws/skill/SKILL.md +57 -57
  43. package/examples/extensions/gws/skill/references/calendar.md +101 -101
  44. package/examples/extensions/gws/skill/references/docs.md +65 -65
  45. package/examples/extensions/gws/skill/references/drive.md +79 -79
  46. package/examples/extensions/gws/skill/references/gmail.md +85 -85
  47. package/examples/extensions/gws/skill/references/sheets.md +60 -60
  48. package/examples/extensions/napkin/skill/SKILL.md +728 -728
  49. package/examples/extensions/pdf/skill/LICENSE.txt +30 -30
  50. package/examples/extensions/pdf/skill/SKILL.md +314 -314
  51. package/examples/extensions/pdf/skill/forms.md +294 -294
  52. package/examples/extensions/pdf/skill/reference.md +611 -611
  53. package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -65
  54. package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -11
  55. package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -33
  56. package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -37
  57. package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -122
  58. package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -115
  59. package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -98
  60. package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -107
  61. package/examples/extensions/permission-guard/index.ts +65 -65
  62. package/examples/extensions/pinchtab/skill/SKILL.md +224 -224
  63. package/examples/extensions/pinchtab/skill/TRUST.md +69 -69
  64. package/examples/extensions/pinchtab/skill/references/api.md +297 -297
  65. package/examples/extensions/pinchtab/skill/references/env.md +45 -45
  66. package/examples/extensions/pinchtab/skill/references/profiles.md +107 -107
  67. package/examples/extensions/tradestation/host/refresh.ts +102 -102
  68. package/examples/extensions/tradestation/index.ts +153 -153
  69. package/examples/extensions/tradestation/skill/SKILL.md +67 -67
  70. package/examples/extensions/voice-synth/index.ts +94 -94
  71. package/examples/extensions/voice-synth/skill/SKILL.md +38 -38
  72. package/examples/extensions/voice-transcribe/requirements.txt +8 -8
  73. package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -179
  74. package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -53
  75. package/examples/extensions/yahoo-mail/cli/package.json +13 -13
  76. package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -78
  77. package/package.json +106 -106
  78. package/resources/agents/explore.md +50 -50
  79. package/resources/agents/worker.md +24 -24
  80. package/resources/connection-env-vars.json +25 -25
  81. package/resources/pi-extensions/subagent/agents.ts +126 -126
  82. package/resources/pi-extensions/subagent/index.ts +964 -964
  83. package/resources/profiles/coding/AGENTS.md +43 -43
  84. package/resources/profiles/coding/mercury-profile.yaml +15 -15
  85. package/resources/profiles/general/AGENTS.md +31 -31
  86. package/resources/profiles/general/mercury-profile.yaml +15 -15
  87. package/resources/profiles/research/AGENTS.md +40 -40
  88. package/resources/profiles/research/mercury-profile.yaml +15 -15
  89. package/resources/skills/config/SKILL.md +25 -25
  90. package/resources/skills/context/SKILL.md +33 -33
  91. package/resources/skills/conversation-recap/SKILL.md +19 -19
  92. package/resources/skills/mutes/SKILL.md +31 -31
  93. package/resources/skills/permissions/SKILL.md +19 -19
  94. package/resources/skills/preferences/SKILL.md +31 -31
  95. package/resources/skills/recall/SKILL.md +24 -24
  96. package/resources/skills/roles/SKILL.md +18 -18
  97. package/resources/skills/spaces/SKILL.md +18 -18
  98. package/resources/skills/tasks/SKILL.md +45 -45
  99. package/resources/templates/AGENTS.md +157 -157
  100. package/resources/templates/env.template +38 -38
  101. package/resources/templates/mercury.example.yaml +99 -99
  102. package/src/agent/container-entry.ts +1 -1
  103. package/src/agent/container-runner.ts +1345 -1346
  104. package/src/cli/mercury.ts +39 -7
  105. package/src/cli/mrctl.ts +636 -636
  106. package/src/config-file.ts +540 -540
  107. package/src/config.ts +339 -339
  108. package/src/core/api.ts +125 -125
  109. package/src/core/caller-token.ts +101 -101
  110. package/src/core/permissions.ts +228 -228
  111. package/src/core/profiles.ts +271 -271
  112. package/src/core/routes/capability.ts +70 -70
  113. package/src/core/routes/index.ts +15 -15
  114. package/src/core/runtime.ts +1530 -1530
  115. package/src/dashboard/index.html +729 -729
  116. package/src/extensions/api.ts +273 -273
  117. package/src/extensions/loader.ts +286 -286
  118. package/src/extensions/types.ts +517 -517
  119. package/src/main.ts +605 -605
  120. package/docs/pending-updates/applicative-profiles.md +0 -15
  121. package/docs/pending-updates/caller-bound-capability-token.md +0 -15
  122. package/docs/pending-updates/dm-auto-space.md +0 -15
  123. /package/docs/archive/{2026-07-01-applicative-profiles.md → applicative-profiles/2026-07-01-applicative-profiles.md} +0 -0
  124. /package/docs/archive/{2026-07-01-caller-bound-capability-token.md → applicative-profiles/2026-07-01-caller-bound-capability-token.md} +0 -0
  125. /package/docs/archive/{2026-07-01-dm-auto-space.md → applicative-profiles/2026-07-01-dm-auto-space.md} +0 -0
@@ -1,1530 +1,1530 @@
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 { getActiveProfileSystemPrompt } from "./profiles.js";
29
- import { RateLimiter } from "./rate-limiter.js";
30
- import { type RouteResult, routeInput } from "./router.js";
31
- import { SpaceQueue } from "./space-queue.js";
32
- import { TaskScheduler } from "./task-scheduler.js";
33
-
34
- export type InputSource = "cli" | "scheduler" | "chat-sdk";
35
-
36
- export type ShutdownHook = () => Promise<void> | void;
37
-
38
- function agentMetaFromUsage(
39
- usage: TokenUsage | undefined,
40
- ): MessageRunMeta["agent"] {
41
- if (!usage) return undefined;
42
- const a: NonNullable<MessageRunMeta["agent"]> = {};
43
- if (usage.inputTokens != null) a.inputTokens = usage.inputTokens;
44
- if (usage.outputTokens != null) a.outputTokens = usage.outputTokens;
45
- if (usage.totalTokens != null) a.totalTokens = usage.totalTokens;
46
- if (usage.cacheReadTokens != null) a.cacheReadTokens = usage.cacheReadTokens;
47
- if (usage.cacheWriteTokens != null)
48
- a.cacheWriteTokens = usage.cacheWriteTokens;
49
- if (usage.cost != null) a.cost = usage.cost;
50
- if (usage.model != null) a.model = usage.model;
51
- if (usage.provider != null) a.provider = usage.provider;
52
- if (Object.keys(a).length === 0) return undefined;
53
- return a;
54
- }
55
-
56
- function userTurnRunMeta(agentUsage?: TokenUsage): MessageRunMeta {
57
- const meta: MessageRunMeta = {};
58
- const agent = agentMetaFromUsage(agentUsage);
59
- if (agent) meta.agent = agent;
60
- return meta;
61
- }
62
-
63
- export class MercuryCoreRuntime {
64
- readonly db: Db;
65
- readonly scheduler: TaskScheduler;
66
- readonly queue: SpaceQueue;
67
- readonly containerRunner: AgentContainerRunner;
68
- readonly rateLimiter: RateLimiter;
69
- hooks: HookDispatcher | null = null;
70
- private extensionCtx: MercuryExtensionContext | null = null;
71
- private extensionRegistry: ExtensionRegistry | null = null;
72
- private readonly shutdownHooks: ShutdownHook[] = [];
73
- private readonly pauseTimers = new Map<string, NodeJS.Timeout>();
74
- private messageSender: MessageSender | undefined;
75
- private shuttingDown = false;
76
- private signalHandlersInstalled = false;
77
-
78
- constructor(readonly config: AppConfig) {
79
- this.db = new Db(resolveProjectPath(config.dbPath));
80
- this.queue = new SpaceQueue(config.maxConcurrency);
81
- this.scheduler = new TaskScheduler(this.db);
82
- this.containerRunner = new AgentContainerRunner(config);
83
- this.rateLimiter = new RateLimiter(
84
- config.rateLimitPerUser,
85
- config.rateLimitWindowMs,
86
- );
87
-
88
- // Scaffold global (pi agent dir) and "main" (default space)
89
- ensurePiResourceDir(resolveProjectPath(config.globalDir));
90
- ensureSpaceWorkspace(resolveProjectPath(config.spacesDir), "main");
91
- this.db.ensureSpace("main");
92
-
93
- // Seed context defaults for main space from AppConfig (idempotent per key).
94
- // YAML/env values feed the seeded defaults; existing rows are never overwritten.
95
- // Literal fallbacks mirror the Zod defaults in src/config.ts — tests that
96
- // build AppConfig via `as AppConfig` cast (bypassing Zod) would otherwise
97
- // pass undefined here and fail the NOT NULL constraint on space_config.value.
98
- if (this.db.getSpaceConfig("main", "context.mode") === null) {
99
- this.db.setSpaceConfig(
100
- "main",
101
- "context.mode",
102
- config.contextMode ?? "context",
103
- "system",
104
- );
105
- }
106
- if (this.db.getSpaceConfig("main", "context.window_size") === null) {
107
- this.db.setSpaceConfig(
108
- "main",
109
- "context.window_size",
110
- String(config.contextWindowSize ?? 10),
111
- "system",
112
- );
113
- }
114
- if (this.db.getSpaceConfig("main", "context.reply_chain_depth") === null) {
115
- this.db.setSpaceConfig(
116
- "main",
117
- "context.reply_chain_depth",
118
- String(config.contextReplyChainDepth ?? 10),
119
- "system",
120
- );
121
- }
122
- }
123
-
124
- /**
125
- * Initialize the runtime — must be called before accepting work.
126
- * Cleans up any orphaned containers from previous runs.
127
- */
128
- async initialize(): Promise<void> {
129
- await this.containerRunner.cleanupOrphans();
130
- this.rateLimiter.startCleanup();
131
- }
132
-
133
- /**
134
- * Wire extension system into the runtime.
135
- * Must be called after extensions are loaded and before accepting messages.
136
- */
137
- initExtensions(registry: ExtensionRegistry): void {
138
- this.hooks = new HookDispatcher(registry, logger);
139
- this.extensionRegistry = registry;
140
- this.extensionCtx = createMercuryExtensionContext({
141
- db: this.db,
142
- config: this.config,
143
- log: logger,
144
- });
145
- this.warnUncoveredSensitiveSpaces();
146
- }
147
-
148
- /**
149
- * Returns a comma-joined display name of active sensitive connections,
150
- * or null if none are active. Active means: credential env var set (for
151
- * env-var-based connections) or any extension state stored (for OAuth connections).
152
- */
153
- private getActiveSensitiveConnectionName(): string | null {
154
- if (!this.extensionRegistry) return null;
155
- const names: string[] = [];
156
- for (const ext of this.extensionRegistry.list()) {
157
- if (!ext.connection?.sensitive) continue;
158
- const conn = ext.connection;
159
- let active = false;
160
- if (conn.credentialEnvVar) {
161
- active = !!process.env[conn.credentialEnvVar];
162
- } else if (conn.statusCheck) {
163
- // Proxy: if any state has been stored, the OAuth flow was completed.
164
- active = this.db.hasAnyExtensionState(ext.name);
165
- }
166
- if (active) names.push(conn.displayName);
167
- }
168
- return names.length > 0 ? names.join(", ") : null;
169
- }
170
-
171
- /**
172
- * Sensitive connection guard — fires before the container for assistant turns.
173
- * Returns { action: "proceed" } (proceed, optionally replaying a stored prompt)
174
- * or { action: "block", reason } (send reason as reply, skip container).
175
- */
176
- private async checkSensitiveConnectionGuard(
177
- spaceId: string,
178
- prompt: string,
179
- ): Promise<
180
- | { action: "proceed"; replayPrompt?: string }
181
- | { action: "block"; reason: string }
182
- > {
183
- // No registry = no extensions loaded = no sensitive connections possible
184
- if (!this.extensionRegistry) return { action: "proceed" };
185
-
186
- if (!this.db.hasGroupLinkedConversation(spaceId)) {
187
- return { action: "proceed" };
188
- }
189
-
190
- const sensitiveName = this.getActiveSensitiveConnectionName();
191
- if (!sensitiveName) return { action: "proceed" };
192
-
193
- const allowed = this.db.getSpaceConfig(
194
- spaceId,
195
- "security.sensitive_connections_allowed",
196
- );
197
- if (allowed !== "true") {
198
- return {
199
- action: "block",
200
- 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`,
201
- };
202
- }
203
-
204
- const pendingAt = this.db.getSpaceConfig(
205
- spaceId,
206
- "security.pending_sensitive_at",
207
- );
208
- if (pendingAt) {
209
- const ageMs = Date.now() - new Date(pendingAt).getTime();
210
- const expired = Number.isNaN(ageMs) || ageMs > 5 * 60 * 1000;
211
- const text = prompt.trim().toLowerCase();
212
-
213
- if (expired || text === "no") {
214
- this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_prompt");
215
- this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_at");
216
- if (expired) {
217
- // Treat next message as fresh — fall through to new warning below
218
- } else {
219
- return { action: "block", reason: "Cancelled." };
220
- }
221
- } else if (text === "yes") {
222
- const storedPrompt = this.db.getSpaceConfig(
223
- spaceId,
224
- "security.pending_sensitive_prompt",
225
- );
226
- this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_prompt");
227
- this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_at");
228
- return { action: "proceed", replayPrompt: storedPrompt ?? undefined };
229
- } else {
230
- // New message arrived mid-confirmation — replace pending with new prompt
231
- // fall through to emit new warning below
232
- }
233
- }
234
-
235
- // Emit warning and store the original prompt for replay on confirmation
236
- this.db.setSpaceConfig(
237
- spaceId,
238
- "security.pending_sensitive_prompt",
239
- prompt,
240
- "system",
241
- );
242
- this.db.setSpaceConfig(
243
- spaceId,
244
- "security.pending_sensitive_at",
245
- new Date().toISOString(),
246
- "system",
247
- );
248
- return {
249
- action: "block",
250
- 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.`,
251
- };
252
- }
253
-
254
- /** Log a startup warning for group spaces with active sensitive connections but no admin enable. */
255
- private warnUncoveredSensitiveSpaces(): void {
256
- const sensitiveName = this.getActiveSensitiveConnectionName();
257
- if (!sensitiveName) return;
258
-
259
- const affected: string[] = [];
260
- for (const space of this.db.listSpaces()) {
261
- if (!this.db.hasGroupLinkedConversation(space.id)) continue;
262
- const allowed = this.db.getSpaceConfig(
263
- space.id,
264
- "security.sensitive_connections_allowed",
265
- );
266
- if (allowed !== "true") affected.push(space.id);
267
- }
268
-
269
- if (affected.length > 0) {
270
- logger.warn(
271
- "Sensitive connections active but not admin-enabled for group spaces — messages will be blocked until enabled",
272
- { spaces: affected, connections: sensitiveName },
273
- );
274
- }
275
- }
276
-
277
- startScheduler(sender?: MessageSender): void {
278
- this.messageSender = sender;
279
- this.restorePauseTimers();
280
- this.scheduler.start(async (task) => {
281
- const result = await this.executePrompt(
282
- task.spaceId,
283
- task.prompt,
284
- "scheduler",
285
- task.createdBy,
286
- );
287
- if (!task.silent && sender) {
288
- await sender.send(task.spaceId, result.reply, result.files);
289
- }
290
- if (!task.silent && result.reply) {
291
- this.deliverTaskOutput(task.spaceId, result.reply);
292
- }
293
- });
294
- }
295
-
296
- private deliverTaskOutput(spaceId: string, text: string): void {
297
- const consoleUrl = process.env.MERCURY_CONSOLE_URL;
298
- const secret = process.env.MERCURY_CONSOLE_INTERNAL_SECRET;
299
- if (!consoleUrl || !secret) return;
300
- const url = `${consoleUrl}/api/internal/whatsapp/deliver`;
301
-
302
- fetch(url, {
303
- method: "POST",
304
- headers: {
305
- "Content-Type": "application/json",
306
- Authorization: `Bearer ${secret}`,
307
- },
308
- body: JSON.stringify({
309
- agentId: process.env.MERCURY_AGENT_ID ?? "unknown",
310
- spaceId,
311
- text,
312
- }),
313
- }).catch((err) => {
314
- const cause = (err as Error & { cause?: Error }).cause;
315
- logger.warn("Task output delivery failed", {
316
- error: err instanceof Error ? err.message : String(err),
317
- cause: cause?.message,
318
- });
319
- });
320
- }
321
-
322
- stopScheduler(): void {
323
- this.scheduler.stop();
324
- for (const t of this.pauseTimers.values()) clearTimeout(t);
325
- this.pauseTimers.clear();
326
- }
327
-
328
- async handleRawInput(
329
- message: IngressMessage,
330
- source: Exclude<InputSource, "scheduler">,
331
- ): Promise<RouteResult & { result?: ContainerResult }> {
332
- const route = routeInput({
333
- text: message.text,
334
- spaceId: message.spaceId,
335
- callerId: message.callerId,
336
- isDM: message.isDM,
337
- isReplyToBot: message.isReplyToBot,
338
- db: this.db,
339
- config: this.config,
340
- attachments: message.attachments,
341
- hadIncomingAttachments: message.hadIncomingAttachments,
342
- authorName: message.authorName,
343
- });
344
-
345
- const pendingDeleteResult = this.handlePendingSpaceDelete(
346
- message.spaceId,
347
- message.callerId,
348
- message.text.trim().toLowerCase(),
349
- );
350
- if (pendingDeleteResult) {
351
- return {
352
- type: "command",
353
- command: "spaces",
354
- callerId: message.callerId,
355
- role:
356
- route.type === "command" || route.type === "assistant"
357
- ? route.role
358
- : "admin",
359
- result: { reply: pendingDeleteResult, files: [] },
360
- };
361
- }
362
-
363
- // Pause guard — drop everything except /pause and /resume when paused
364
- if (this.db.getSpaceConfig(message.spaceId, "paused") === "true") {
365
- const exempt =
366
- route.type === "command" &&
367
- (route.command === "pause" || route.command === "resume");
368
- if (!exempt) {
369
- return { type: "ignore" };
370
- }
371
- }
372
-
373
- if (route.type === "command") {
374
- const reply = await this.executeCommand(
375
- message.spaceId,
376
- route.command,
377
- route.callerId,
378
- route.verb,
379
- route.arg,
380
- {
381
- platform: message.platform,
382
- externalId: message.conversationExternalId,
383
- },
384
- );
385
- return { ...route, result: { reply, files: [] } };
386
- }
387
-
388
- // Check mute — silently drop messages from muted users
389
- if (
390
- route.type === "assistant" &&
391
- this.db.isMuted(message.spaceId, message.callerId)
392
- ) {
393
- return { type: "ignore" };
394
- }
395
-
396
- // Check rate limit for assistant requests (not commands, not ignored messages)
397
- if (route.type === "assistant") {
398
- // Daily role-based rate check (before burst limiter)
399
- if (route.role !== "system") {
400
- const roleKey = `rate_limit.${route.role}`;
401
- const roleLimitRaw = this.db.getSpaceConfig(message.spaceId, roleKey);
402
- const globalDailyLimit =
403
- route.role === "member"
404
- ? this.config.rateLimitDailyMember
405
- : route.role === "admin"
406
- ? this.config.rateLimitDailyAdmin
407
- : 0;
408
- const effectiveDailyRaw =
409
- roleLimitRaw ??
410
- (globalDailyLimit > 0 ? String(globalDailyLimit) : null);
411
- if (effectiveDailyRaw !== null) {
412
- const roleLimit = Number.parseInt(effectiveDailyRaw, 10);
413
- if (!Number.isNaN(roleLimit) && roleLimit > 0) {
414
- const daily = this.db.checkAndIncrementDailyUsage(
415
- message.spaceId,
416
- message.callerId,
417
- roleLimit,
418
- );
419
- if (!daily.allowed) {
420
- const msUntilReset =
421
- new Date().setUTCHours(24, 0, 0, 0) - Date.now();
422
- const hoursLeft = Math.ceil(msUntilReset / 3_600_000);
423
- return {
424
- type: "denied",
425
- reason: `You've used ${daily.count}/${roleLimit} messages today. Resets in ${hoursLeft}h.`,
426
- };
427
- }
428
- }
429
- }
430
- }
431
-
432
- // Burst rate limit (sliding window)
433
- const groupLimit = this.db.getSpaceConfig(message.spaceId, "rate_limit");
434
- const effectiveLimit = groupLimit
435
- ? Number.parseInt(groupLimit, 10)
436
- : this.config.rateLimitPerUser;
437
-
438
- if (
439
- effectiveLimit > 0 &&
440
- !this.checkRateLimit(message.spaceId, message.callerId, effectiveLimit)
441
- ) {
442
- return {
443
- type: "denied",
444
- reason: "Rate limit exceeded. Try again shortly.",
445
- };
446
- }
447
- }
448
-
449
- if (route.type !== "assistant") {
450
- // Store ambient messages in group chats (non-triggered, non-DM)
451
- // Default: enabled. Set ambient.enabled=false for tag-only mode.
452
- const ambientEnabled =
453
- this.db.getSpaceConfig(message.spaceId, "ambient.enabled") !== "false";
454
- if (
455
- route.type === "ignore" &&
456
- source === "chat-sdk" &&
457
- !message.isDM &&
458
- ambientEnabled
459
- ) {
460
- const ambientText = message.authorName
461
- ? `${message.authorName}: ${message.text.trim()}`
462
- : message.text.trim();
463
-
464
- if (ambientText) {
465
- this.db.ensureSpace(message.spaceId);
466
- this.db.addMessage(message.spaceId, "ambient", ambientText);
467
- }
468
- }
469
-
470
- return route;
471
- }
472
-
473
- const noPromptText = !message.text.trim();
474
- const noSavedFiles = (message.attachments?.length ?? 0) === 0;
475
- if (
476
- noPromptText &&
477
- noSavedFiles &&
478
- (message.hadIncomingAttachments ?? false)
479
- ) {
480
- return {
481
- type: "denied",
482
- reason:
483
- "Could not use your attachment (media disabled, over the size limit, or download failed). Check MERCURY_MEDIA_ENABLED and logs.",
484
- };
485
- }
486
-
487
- const guardResult = await this.checkSensitiveConnectionGuard(
488
- message.spaceId,
489
- route.prompt,
490
- );
491
- if (guardResult.action === "block") {
492
- return { type: "denied", reason: guardResult.reason };
493
- }
494
- const effectivePrompt = guardResult.replayPrompt ?? route.prompt;
495
-
496
- try {
497
- const result = await this.executePrompt(
498
- message.spaceId,
499
- effectivePrompt,
500
- source,
501
- message.callerId,
502
- message.attachments,
503
- message.authorName,
504
- {
505
- platform: message.platform,
506
- conversationExternalId: message.conversationExternalId,
507
- replyToPlatformMessageId: message.replyToPlatformMessageId,
508
- platformMessageId: message.platformMessageId,
509
- },
510
- { isReplyToBot: route.isReplyToBot, isDM: route.isDM },
511
- );
512
- return { ...route, result };
513
- } catch (error) {
514
- if (error instanceof ContainerError) {
515
- switch (error.reason) {
516
- case "aborted":
517
- return { type: "denied", reason: "Stopped current run." };
518
- case "timeout":
519
- return { type: "denied", reason: "Container timed out." };
520
- case "oom":
521
- return {
522
- type: "denied",
523
- reason: "Container was killed (possibly out of memory).",
524
- };
525
- case "error": {
526
- logger.error(
527
- "Container error",
528
- error instanceof Error ? error : undefined,
529
- );
530
- const category = classifyUserError(error.message);
531
- const reason = friendlyErrorMessage(
532
- category,
533
- this.config.apiKeyMode,
534
- this.config.consoleUrl,
535
- );
536
- return { type: "denied", reason };
537
- }
538
- }
539
- }
540
- throw error;
541
- }
542
- }
543
-
544
- /**
545
- * Check if a request is allowed under rate limiting.
546
- * Uses per-group override if set, otherwise uses the default limit.
547
- */
548
- private checkRateLimit(
549
- spaceId: string,
550
- userId: string,
551
- effectiveLimit: number,
552
- ): boolean {
553
- return this.rateLimiter.isAllowed(spaceId, userId, effectiveLimit);
554
- }
555
-
556
- private async executeCommand(
557
- spaceId: string,
558
- command: string,
559
- callerId: string,
560
- verb?: string,
561
- arg?: string,
562
- conversationContext?: { platform: string; externalId: string },
563
- ): Promise<string> {
564
- switch (command) {
565
- case "stop": {
566
- const stopped = this.containerRunner.abort(spaceId);
567
- const dropped = this.queue.cancelPending(spaceId);
568
- if (stopped)
569
- return `Stopped.${dropped > 0 ? ` Dropped ${dropped} queued request(s).` : ""}`;
570
- if (dropped > 0) return `Dropped ${dropped} queued request(s).`;
571
- return "No active run.";
572
- }
573
- case "compact": {
574
- this.db.setSessionBoundaryToLatest(spaceId);
575
- return "Compacted.";
576
- }
577
- case "clear": {
578
- this.db.setClearBoundary(spaceId);
579
- return "Cleared.";
580
- }
581
- case "help": {
582
- if (verb) {
583
- return (
584
- formatCategoryHelp(verb) ?? `No help available for '/${verb}'.`
585
- );
586
- }
587
- return formatHelp();
588
- }
589
- case "model":
590
- return this.executeModelsCommand(spaceId, callerId, verb, arg);
591
- case "spaces":
592
- return this.executeSpacesCommand(
593
- spaceId,
594
- callerId,
595
- conversationContext,
596
- verb,
597
- arg,
598
- );
599
- case "pause":
600
- return this.executePauseCommand(spaceId, callerId, verb);
601
- case "resume":
602
- return this.executeResumeCommand(spaceId, callerId);
603
- default:
604
- return `Unknown command: ${command}`;
605
- }
606
- }
607
-
608
- private executeModelsCommand(
609
- spaceId: string,
610
- callerId: string,
611
- verb?: string,
612
- arg?: string,
613
- ): string {
614
- const chain = this.config.resolvedModelChain;
615
-
616
- if (!verb) {
617
- return formatCategoryHelp("model") ?? "/model — model management";
618
- }
619
-
620
- switch (verb) {
621
- case "list": {
622
- if (chain.length === 0) return "No models configured.";
623
- const activeRaw = this.db.getSpaceConfig(spaceId, "model.active");
624
- const activeFound = activeRaw
625
- ? chain.some((l) => `${l.provider}:${l.model}` === activeRaw)
626
- : false;
627
- const lines = ["Configured models:"];
628
- for (let i = 0; i < chain.length; i++) {
629
- const leg = chain[i];
630
- const isActive = activeFound
631
- ? activeRaw === `${leg.provider}:${leg.model}`
632
- : i === 0;
633
- lines.push(
634
- ` [${i + 1}] ${leg.provider} / ${leg.model}${isActive ? " ← active" : ""}`,
635
- );
636
- }
637
- return lines.join("\n");
638
- }
639
-
640
- case "active": {
641
- const activeRaw = this.db.getSpaceConfig(spaceId, "model.active");
642
- const leg =
643
- (activeRaw
644
- ? chain.find((l) => `${l.provider}:${l.model}` === activeRaw)
645
- : undefined) ?? chain[0];
646
- if (!leg) return "No models configured.";
647
- return `Active model: ${leg.provider} / ${leg.model}`;
648
- }
649
-
650
- case "switch": {
651
- if (!arg)
652
- return "Usage: /model switch <N|MODEL_ID>. Use /model list to see options.";
653
- if (chain.length === 0) return "No models configured.";
654
-
655
- let leg: (typeof chain)[number] | undefined;
656
- const num = Number.parseInt(arg, 10);
657
- if (!Number.isNaN(num) && num >= 1 && num <= chain.length) {
658
- leg = chain[num - 1];
659
- } else {
660
- leg = chain.find(
661
- (l) => l.model === arg || `${l.provider}:${l.model}` === arg,
662
- );
663
- }
664
-
665
- if (!leg)
666
- return "Model not found. Use /model list to see your options.";
667
-
668
- this.db.setSpaceConfig(
669
- spaceId,
670
- "model.active",
671
- `${leg.provider}:${leg.model}`,
672
- callerId,
673
- );
674
- return `Switched to ${leg.provider} / ${leg.model}.`;
675
- }
676
-
677
- default:
678
- return `/model: unknown verb '${verb}'. Use /model for help.`;
679
- }
680
- }
681
-
682
- private executeSpacesCommand(
683
- spaceId: string,
684
- _callerId: string,
685
- conversationContext?: { platform: string; externalId: string },
686
- verb?: string,
687
- arg?: string,
688
- ): string {
689
- this.clearPendingDelete(spaceId);
690
-
691
- if (!verb) {
692
- return formatCategoryHelp("spaces") ?? "/spaces — space management";
693
- }
694
-
695
- switch (verb) {
696
- case "list": {
697
- const spaces = this.db.listSpaces();
698
- if (spaces.length === 0) return "No spaces.";
699
- const lines = ["Spaces:"];
700
- for (const s of spaces) {
701
- const convos = this.db.getSpaceConversations(s.id);
702
- lines.push(
703
- ` ${s.id.padEnd(16)} ${s.name} (${convos.length} conversation${convos.length === 1 ? "" : "s"})`,
704
- );
705
- }
706
- return lines.join("\n");
707
- }
708
-
709
- case "create": {
710
- if (!arg) return "Usage: /spaces create <id> <name>";
711
- const firstSpace = arg.indexOf(" ");
712
- if (firstSpace === -1) return "Usage: /spaces create <id> <name>";
713
- const newId = arg.slice(0, firstSpace);
714
- const newName = arg.slice(firstSpace + 1).trim();
715
- if (!newName) return "Usage: /spaces create <id> <name>";
716
- try {
717
- this.db.createSpace(newId, newName);
718
- } catch (err) {
719
- return err instanceof Error ? err.message : String(err);
720
- }
721
- return `Created space '${newId}' (${newName}).`;
722
- }
723
-
724
- case "switch": {
725
- if (!arg) return "Usage: /spaces switch <id>";
726
- const targetSpace = this.db.getSpace(arg);
727
- if (!targetSpace) return `Space '${arg}' not found.`;
728
- if (!conversationContext) {
729
- return "Cannot determine current conversation.";
730
- }
731
- const convo = this.db.findConversation(
732
- conversationContext.platform,
733
- conversationContext.externalId,
734
- );
735
- if (!convo) return "Current conversation not found.";
736
- if (convo.spaceId === arg) return `Already in space '${arg}'.`;
737
- this.db.linkConversation(convo.id, arg);
738
- return `Switched to space '${targetSpace.name}' (${arg}).`;
739
- }
740
-
741
- case "delete": {
742
- if (!arg) return "Usage: /spaces delete <id>";
743
- if (arg === "main") return "Cannot delete the default 'main' space.";
744
- const target = this.db.getSpace(arg);
745
- if (!target) return `Space '${arg}' not found.`;
746
- this.db.setSpaceConfig(
747
- spaceId,
748
- "spaces.pending_delete_id",
749
- arg,
750
- "system",
751
- );
752
- this.db.setSpaceConfig(
753
- spaceId,
754
- "spaces.pending_delete_at",
755
- new Date().toISOString(),
756
- "system",
757
- );
758
- const isSelf = spaceId === arg;
759
- const warning = isSelf
760
- ? "\n⚠️ This will unlink this conversation. You'll need to link it to another space to continue chatting."
761
- : "";
762
- return `Delete space '${arg}' (${target.name})? This will destroy all messages, tasks, roles, config, and preferences for that space. Reply *yes* to confirm or *no* to cancel.${warning}`;
763
- }
764
-
765
- case "unlink": {
766
- if (!conversationContext) {
767
- return "Cannot determine current conversation.";
768
- }
769
- const convo = this.db.findConversation(
770
- conversationContext.platform,
771
- conversationContext.externalId,
772
- );
773
- if (!convo) return "Current conversation not found.";
774
- this.db.unlinkConversation(convo.id);
775
- return "Unlinked. Messages in this conversation will be ignored until you link it to a space with /spaces switch.";
776
- }
777
-
778
- default:
779
- return `/spaces: unknown verb '${verb}'. Use /spaces for help.`;
780
- }
781
- }
782
-
783
- private handlePendingSpaceDelete(
784
- spaceId: string,
785
- callerId: string,
786
- text: string,
787
- ): string | null {
788
- const pendingId = this.db.getSpaceConfig(
789
- spaceId,
790
- "spaces.pending_delete_id",
791
- );
792
- if (!pendingId) return null;
793
-
794
- const seededAdmins = this.config.admins
795
- ? this.config.admins
796
- .split(",")
797
- .map((s) => s.trim())
798
- .filter(Boolean)
799
- : [];
800
- if (!seededAdmins.includes(callerId)) return null;
801
-
802
- const pendingAt = this.db.getSpaceConfig(
803
- spaceId,
804
- "spaces.pending_delete_at",
805
- );
806
- const ageMs = pendingAt
807
- ? Date.now() - new Date(pendingAt).getTime()
808
- : Number.POSITIVE_INFINITY;
809
- const expired = Number.isNaN(ageMs) || ageMs > 60_000;
810
-
811
- this.db.deleteSpaceConfig(spaceId, "spaces.pending_delete_id");
812
- this.db.deleteSpaceConfig(spaceId, "spaces.pending_delete_at");
813
-
814
- if (expired) {
815
- if (text === "yes") return "Delete cancelled (timed out).";
816
- return null;
817
- }
818
-
819
- if (text === "no") {
820
- return "Delete cancelled.";
821
- }
822
-
823
- if (text === "yes") {
824
- const target = this.db.getSpace(pendingId);
825
- if (!target) return `Space '${pendingId}' not found.`;
826
- const result = this.db.deleteSpace(pendingId);
827
- if (!result.deleted) return `Failed to delete space '${pendingId}'.`;
828
- return `Deleted space '${pendingId}' (${target.name}). Removed: ${result.removed.messages} messages, ${result.removed.tasks} tasks, ${result.removed.conversationsUnlinked} conversations unlinked.`;
829
- }
830
-
831
- return null;
832
- }
833
-
834
- private clearPendingDelete(spaceId: string): void {
835
- this.db.deleteSpaceConfig(spaceId, "spaces.pending_delete_id");
836
- this.db.deleteSpaceConfig(spaceId, "spaces.pending_delete_at");
837
- }
838
-
839
- private executePauseCommand(
840
- spaceId: string,
841
- callerId: string,
842
- verb?: string,
843
- ): string {
844
- const alreadyPaused = this.db.getSpaceConfig(spaceId, "paused") === "true";
845
- const hadTimer = this.db.getSpaceConfig(spaceId, "paused.resume_at");
846
-
847
- if (verb) {
848
- const parsed = this.parsePauseDuration(verb);
849
- if (!parsed.ok) return parsed.error;
850
-
851
- this.db.setSpaceConfig(spaceId, "paused", "true", callerId);
852
- const resumeAt = Date.now() + parsed.ms;
853
- this.db.setSpaceConfig(
854
- spaceId,
855
- "paused.resume_at",
856
- String(resumeAt),
857
- callerId,
858
- );
859
- this.schedulePauseTimer(spaceId, parsed.ms);
860
-
861
- const resumeTime = new Date(resumeAt).toLocaleString("en-US", {
862
- hour: "2-digit",
863
- minute: "2-digit",
864
- hour12: true,
865
- });
866
- return `Bot paused for ${verb}. It will auto-resume at ${resumeTime}.`;
867
- }
868
-
869
- if (alreadyPaused && !hadTimer) {
870
- return "Already paused.";
871
- }
872
-
873
- if (alreadyPaused && hadTimer) {
874
- this.clearPauseTimer(spaceId);
875
- this.db.deleteSpaceConfig(spaceId, "paused.resume_at");
876
- return "Pause is now indefinite (timer cleared). Use /resume to reactivate.";
877
- }
878
-
879
- this.db.setSpaceConfig(spaceId, "paused", "true", callerId);
880
- return "Bot paused in this space. Use /resume to reactivate.";
881
- }
882
-
883
- private executeResumeCommand(spaceId: string, _callerId: string): string {
884
- if (this.db.getSpaceConfig(spaceId, "paused") !== "true") {
885
- return "Bot is not paused.";
886
- }
887
- this.clearPauseTimer(spaceId);
888
- this.db.deleteSpaceConfig(spaceId, "paused");
889
- this.db.deleteSpaceConfig(spaceId, "paused.resume_at");
890
- return "Bot resumed.";
891
- }
892
-
893
- private parsePauseDuration(
894
- input: string,
895
- ): { ok: true; ms: number } | { ok: false; error: string } {
896
- const match = input.match(/^(\d+)(m|h)$/i);
897
- if (!match) {
898
- return {
899
- ok: false,
900
- error: "Invalid duration. Use e.g. /pause 30m or /pause 2h.",
901
- };
902
- }
903
- const value = Number.parseInt(match[1], 10);
904
- const unit = match[2].toLowerCase();
905
- const ms = unit === "h" ? value * 60 * 60 * 1000 : value * 60 * 1000;
906
-
907
- if (ms < 60_000) {
908
- return { ok: false, error: "Duration must be at least 1 minute." };
909
- }
910
- if (ms > 24 * 60 * 60 * 1000) {
911
- return {
912
- ok: false,
913
- error: "Duration must be at most 24 hours.",
914
- };
915
- }
916
- return { ok: true, ms };
917
- }
918
-
919
- private schedulePauseTimer(spaceId: string, delayMs: number): void {
920
- this.clearPauseTimer(spaceId);
921
- const timer = setTimeout(() => {
922
- this.pauseTimers.delete(spaceId);
923
- if (this.db.getSpaceConfig(spaceId, "paused") !== "true") return;
924
- this.db.deleteSpaceConfig(spaceId, "paused");
925
- this.db.deleteSpaceConfig(spaceId, "paused.resume_at");
926
- const text = "Bot resumed — pause timer expired.";
927
- this.messageSender
928
- ?.send(spaceId, text, [])
929
- .catch((e) =>
930
- logger.warn("Auto-resume notification failed", { spaceId, error: e }),
931
- );
932
- this.deliverTaskOutput(spaceId, text);
933
- }, delayMs);
934
- if (timer.unref) timer.unref();
935
- this.pauseTimers.set(spaceId, timer);
936
- }
937
-
938
- private clearPauseTimer(spaceId: string): void {
939
- const existing = this.pauseTimers.get(spaceId);
940
- if (existing) {
941
- clearTimeout(existing);
942
- this.pauseTimers.delete(spaceId);
943
- }
944
- }
945
-
946
- private restorePauseTimers(): void {
947
- for (const space of this.db.listSpaces()) {
948
- const resumeAtStr = this.db.getSpaceConfig(space.id, "paused.resume_at");
949
- if (!resumeAtStr) continue;
950
- const resumeAt = Number.parseInt(resumeAtStr, 10);
951
- if (Number.isNaN(resumeAt)) continue;
952
-
953
- const remaining = resumeAt - Date.now();
954
- if (remaining <= 0) {
955
- this.db.deleteSpaceConfig(space.id, "paused");
956
- this.db.deleteSpaceConfig(space.id, "paused.resume_at");
957
- const text = "Bot resumed — pause timer expired.";
958
- this.messageSender?.send(space.id, text, []).catch((e) =>
959
- logger.warn("Auto-resume notification failed", {
960
- spaceId: space.id,
961
- error: e,
962
- }),
963
- );
964
- this.deliverTaskOutput(space.id, text);
965
- } else {
966
- this.schedulePauseTimer(space.id, remaining);
967
- }
968
- }
969
- }
970
-
971
- onShutdown(hook: ShutdownHook): void {
972
- this.shutdownHooks.push(hook);
973
- }
974
-
975
- get isShuttingDown(): boolean {
976
- return this.shuttingDown;
977
- }
978
-
979
- installSignalHandlers(): void {
980
- if (this.signalHandlersInstalled) return;
981
- this.signalHandlersInstalled = true;
982
-
983
- let forceCount = 0;
984
-
985
- const handler = (signal: string) => {
986
- if (this.shuttingDown) {
987
- forceCount++;
988
- if (forceCount >= 1) {
989
- logger.warn("Second signal received, forcing exit");
990
- process.exit(1);
991
- }
992
- return;
993
- }
994
- logger.info("Received signal, starting graceful shutdown", { signal });
995
- void this.shutdown().then(
996
- () => process.exit(0),
997
- (err) => {
998
- logger.error(
999
- "Shutdown failed",
1000
- err instanceof Error ? err : undefined,
1001
- );
1002
- process.exit(1);
1003
- },
1004
- );
1005
- };
1006
-
1007
- process.on("SIGTERM", () => handler("SIGTERM"));
1008
- process.on("SIGINT", () => handler("SIGINT"));
1009
- }
1010
-
1011
- async shutdown(timeoutMs = 10_000): Promise<void> {
1012
- if (this.shuttingDown) return;
1013
- this.shuttingDown = true;
1014
-
1015
- const forceTimer = setTimeout(() => {
1016
- logger.error("Shutdown timed out, forcing exit");
1017
- process.exit(1);
1018
- }, timeoutMs);
1019
- // Don't keep the process alive just for this timer
1020
- if (forceTimer.unref) forceTimer.unref();
1021
-
1022
- try {
1023
- // 1. Stop schedulers + pause timers
1024
- logger.info("Shutdown: stopping task scheduler");
1025
- this.scheduler.stop();
1026
- for (const timer of this.pauseTimers.values()) clearTimeout(timer);
1027
- this.pauseTimers.clear();
1028
-
1029
- // 2. Drain queue — cancel pending, wait for active
1030
- logger.info("Shutdown: draining group queue");
1031
- const dropped = this.queue.cancelAll();
1032
- if (dropped > 0)
1033
- logger.info("Shutdown: cancelled pending queue entries", {
1034
- count: dropped,
1035
- });
1036
-
1037
- // 3. Kill running containers
1038
- logger.info("Shutdown: stopping running containers");
1039
- this.containerRunner.killAll();
1040
-
1041
- // 4. Wait for active work to finish (with a shorter timeout)
1042
- const drainTimeout = Math.max(timeoutMs - 2000, 1000);
1043
- const drained = await this.queue.waitForActive(drainTimeout);
1044
- if (!drained) {
1045
- logger.warn("Shutdown: active work did not finish in time");
1046
- }
1047
-
1048
- // 5. Emit extension shutdown hooks
1049
- if (this.hooks && this.extensionCtx) {
1050
- logger.info("Shutdown: notifying extensions");
1051
- await this.hooks.emit("shutdown", {}, this.extensionCtx);
1052
- }
1053
-
1054
- // 6. Run registered shutdown hooks (adapters, server, etc.)
1055
- for (const hook of this.shutdownHooks) {
1056
- try {
1057
- await hook();
1058
- } catch (err) {
1059
- logger.error(
1060
- "Shutdown hook failed",
1061
- err instanceof Error ? err : undefined,
1062
- );
1063
- }
1064
- }
1065
-
1066
- // 6. Stop rate limiter cleanup
1067
- this.rateLimiter.stopCleanup();
1068
-
1069
- // 7. Close database
1070
- logger.info("Shutdown: closing database");
1071
- this.db.close();
1072
-
1073
- logger.info("Shutdown: complete");
1074
- } finally {
1075
- clearTimeout(forceTimer);
1076
- }
1077
- }
1078
-
1079
- private async executePrompt(
1080
- spaceId: string,
1081
- prompt: string,
1082
- _source: InputSource,
1083
- callerId: string,
1084
- attachments?: MessageAttachment[],
1085
- authorName?: string,
1086
- replyMeta?: {
1087
- platform?: string;
1088
- conversationExternalId?: string;
1089
- replyToPlatformMessageId?: string;
1090
- platformMessageId?: string;
1091
- },
1092
- replyFlags?: { isReplyToBot: boolean; isDM: boolean },
1093
- ): Promise<ContainerResult> {
1094
- this.db.ensureSpace(spaceId);
1095
-
1096
- return this.queue.enqueue(spaceId, async () => {
1097
- // ── Daily message quota check ────────────────────────────────────────
1098
- // Calls the console API to record the message and verify the user hasn't
1099
- // exceeded their plan's daily limit. Fails open: if the API is unreachable,
1100
- // the message is allowed through (billing is best-effort, not a hard gate).
1101
- if (this.config.consoleUrl && this.config.consoleUserId) {
1102
- try {
1103
- const quotaRes = await fetch(
1104
- `${this.config.consoleUrl}/api/user/billing/message-used`,
1105
- {
1106
- method: "POST",
1107
- headers: {
1108
- "Content-Type": "application/json",
1109
- ...(this.config.consoleInternalSecret
1110
- ? {
1111
- Authorization: `Bearer ${this.config.consoleInternalSecret}`,
1112
- }
1113
- : {}),
1114
- },
1115
- body: JSON.stringify({
1116
- userId: this.config.consoleUserId,
1117
- agentId: process.env.MERCURY_AGENT_ID ?? spaceId,
1118
- isByok: false,
1119
- }),
1120
- signal: AbortSignal.timeout(5000),
1121
- },
1122
- );
1123
- if (quotaRes.ok) {
1124
- const quotaData = (await quotaRes.json()) as {
1125
- allowed: boolean;
1126
- remaining: number | null;
1127
- };
1128
- if (!quotaData.allowed) {
1129
- return {
1130
- reply:
1131
- "You've reached your daily message limit. Upgrade your plan at the Mercury Console to continue chatting.",
1132
- files: [],
1133
- };
1134
- }
1135
- }
1136
- // Non-OK response → fail open (log but don't block)
1137
- else {
1138
- logger.warn(
1139
- "Quota check returned non-OK status — allowing message",
1140
- { status: quotaRes.status },
1141
- );
1142
- }
1143
- } catch (err) {
1144
- logger.warn("Quota check failed (unreachable?) — allowing message", {
1145
- err,
1146
- });
1147
- }
1148
- }
1149
- // ────────────────────────────────────────────────────────────────────
1150
-
1151
- const workspace = ensureSpaceWorkspace(
1152
- resolveProjectPath(this.config.spacesDir),
1153
- spaceId,
1154
- );
1155
-
1156
- // Container-relative workspace path
1157
- const containerWorkspace = `/spaces/${spaceId}`;
1158
-
1159
- // ── Reply-chain isolation ──────────────────────────────────────────
1160
- // Strip quoted bot output from unprivileged group replies-to-bot
1161
- // BEFORE hooks see the prompt (defense in depth).
1162
- let replyIsolated = false;
1163
- let finalPrompt = prompt;
1164
- if (replyFlags?.isReplyToBot && !replyFlags.isDM) {
1165
- const seededAdmins = this.config.admins
1166
- ? this.config.admins
1167
- .split(",")
1168
- .map((s) => s.trim())
1169
- .filter(Boolean)
1170
- : [];
1171
- const earlyRole = resolveRole(this.db, spaceId, callerId, seededAdmins);
1172
- if (earlyRole !== "admin" && earlyRole !== "system") {
1173
- replyIsolated = true;
1174
- finalPrompt = finalPrompt.replace(
1175
- /\n*<reply_to[^>]*>[\s\S]*?<\/reply_to>/g,
1176
- "",
1177
- );
1178
- }
1179
- }
1180
- // ────────────────────────────────────────────────────────────────────
1181
-
1182
- // Emit workspace_init hook (extensions should be idempotent)
1183
- if (this.hooks && this.extensionCtx) {
1184
- await this.hooks.emit(
1185
- "workspace_init",
1186
- { spaceId, workspace, containerWorkspace },
1187
- this.extensionCtx,
1188
- );
1189
- }
1190
-
1191
- // Emit before_container hook
1192
- let extraEnv: Record<string, string> | undefined;
1193
- if (this.hooks && this.extensionCtx) {
1194
- const result = await this.hooks.emitBeforeContainer(
1195
- {
1196
- spaceId,
1197
- prompt: finalPrompt,
1198
- callerId,
1199
- workspace,
1200
- containerWorkspace,
1201
- attachments,
1202
- },
1203
- this.extensionCtx,
1204
- );
1205
- if (result?.block) {
1206
- return { reply: result.block.reason, files: [] };
1207
- }
1208
- if (result) {
1209
- if (result.env) {
1210
- extraEnv = { ...extraEnv, ...result.env };
1211
- }
1212
- if (result.systemPrompt) {
1213
- extraEnv = {
1214
- ...extraEnv,
1215
- MERCURY_EXT_SYSTEM_PROMPT: result.systemPrompt,
1216
- };
1217
- }
1218
- if (result.promptAppend) {
1219
- finalPrompt = [finalPrompt, result.promptAppend]
1220
- .filter(Boolean)
1221
- .join("\n\n");
1222
- }
1223
- }
1224
- }
1225
-
1226
- // Inject the active applicative profile persona (project-wide), ahead of
1227
- // any per-space system prompt so a space-specific prompt refines it.
1228
- const profilePrompt = getActiveProfileSystemPrompt();
1229
- if (profilePrompt) {
1230
- const existing = extraEnv?.MERCURY_EXT_SYSTEM_PROMPT;
1231
- extraEnv = {
1232
- ...extraEnv,
1233
- MERCURY_EXT_SYSTEM_PROMPT: existing
1234
- ? `${existing}\n\n${profilePrompt}`
1235
- : profilePrompt,
1236
- };
1237
- }
1238
-
1239
- // Inject per-space system prompt (set via console Spaces settings or at provision time).
1240
- const spacePrompt = this.db.getSpaceConfig(spaceId, "system_prompt");
1241
- if (spacePrompt) {
1242
- const existing = extraEnv?.MERCURY_EXT_SYSTEM_PROMPT;
1243
- extraEnv = {
1244
- ...extraEnv,
1245
- MERCURY_EXT_SYSTEM_PROMPT: existing
1246
- ? `${existing}\n\n${spacePrompt}`
1247
- : spacePrompt,
1248
- };
1249
- }
1250
-
1251
- // Resolve reply target once — reused for context assembly and DB linkage.
1252
- let replyMercuryMsgId: number | null = null;
1253
- if (
1254
- replyMeta?.replyToPlatformMessageId &&
1255
- replyMeta.platform &&
1256
- replyMeta.conversationExternalId
1257
- ) {
1258
- replyMercuryMsgId = this.db.lookupMercuryMessageId(
1259
- replyMeta.platform,
1260
- replyMeta.conversationExternalId,
1261
- replyMeta.replyToPlatformMessageId,
1262
- );
1263
- }
1264
- const userReplyToId = replyMercuryMsgId ?? undefined;
1265
-
1266
- const replyChainDepthStr = this.db.getSpaceConfig(
1267
- spaceId,
1268
- "context.reply_chain_depth",
1269
- );
1270
- const replyChainDepth = replyChainDepthStr
1271
- ? Number.parseInt(replyChainDepthStr, 10)
1272
- : 10;
1273
-
1274
- // Fetch prior turns based on context mode.
1275
- // When reply-isolated, skip all history to prevent context leakage.
1276
- let history: import("../types.js").StoredMessage[];
1277
- let anchorMessages: import("../types.js").StoredMessage[] | undefined;
1278
- if (replyIsolated) {
1279
- history = [];
1280
- extraEnv = { ...extraEnv, MERCURY_REPLY_ISOLATED: "1" };
1281
- } else {
1282
- const contextMode =
1283
- this.db.getSpaceConfig(spaceId, "context.mode") ?? "clear";
1284
-
1285
- if (contextMode === "context") {
1286
- const windowSizeStr = this.db.getSpaceConfig(
1287
- spaceId,
1288
- "context.window_size",
1289
- );
1290
- const windowSize = windowSizeStr
1291
- ? Number.parseInt(windowSizeStr, 10)
1292
- : (this.config.contextWindowSize ?? 10);
1293
-
1294
- if (replyMercuryMsgId !== null) {
1295
- const trimmedWindow = Math.floor(windowSize / 2);
1296
- const anchored = this.db.getAnchoredContext(
1297
- spaceId,
1298
- replyMercuryMsgId,
1299
- replyChainDepth,
1300
- trimmedWindow,
1301
- );
1302
- anchorMessages = anchored.anchor;
1303
- history = anchored.recent;
1304
- } else {
1305
- history = this.db.getRecentTurns(spaceId, windowSize);
1306
- }
1307
-
1308
- // One-shot clear: reset temporary boundary immediately after reading history.
1309
- this.db.resetClearBoundary(spaceId);
1310
- } else {
1311
- // Clear mode: only include reply chain if this message is a reply
1312
- if (replyMercuryMsgId !== null) {
1313
- history = this.db.getReplyChain(
1314
- replyMercuryMsgId,
1315
- replyChainDepth,
1316
- spaceId,
1317
- );
1318
- } else {
1319
- history = [];
1320
- }
1321
- }
1322
- }
1323
-
1324
- const userMessageId = this.db.addMessage(
1325
- spaceId,
1326
- "user",
1327
- finalPrompt,
1328
- attachments,
1329
- userReplyToId,
1330
- );
1331
-
1332
- // Record platform message ID mapping for the inbound user message
1333
- if (
1334
- replyMeta?.platformMessageId &&
1335
- replyMeta.platform &&
1336
- replyMeta.conversationExternalId
1337
- ) {
1338
- this.db.addPlatformMessageId(
1339
- userMessageId,
1340
- replyMeta.platform,
1341
- replyMeta.conversationExternalId,
1342
- replyMeta.platformMessageId,
1343
- );
1344
- }
1345
-
1346
- // Compute caller role, denied CLIs, and permitted env vars
1347
- let callerRole = "member";
1348
- if (this.extensionRegistry) {
1349
- const seededAdmins = this.config.admins
1350
- ? this.config.admins
1351
- .split(",")
1352
- .map((s) => s.trim())
1353
- .filter(Boolean)
1354
- : [];
1355
- callerRole = resolveRole(this.db, spaceId, callerId, seededAdmins);
1356
-
1357
- const cliExtensions = this.extensionRegistry.getCliExtensions();
1358
- if (cliExtensions.length > 0) {
1359
- const denied = cliExtensions
1360
- .filter(
1361
- (ext) =>
1362
- ext.clis.length > 0 &&
1363
- !hasPermission(this.db, spaceId, callerRole, ext.name),
1364
- )
1365
- .flatMap((ext) => ext.clis.map((c) => c.name));
1366
- if (denied.length > 0) {
1367
- extraEnv = {
1368
- ...extraEnv,
1369
- MERCURY_DENIED_CLIS: denied.join(","),
1370
- };
1371
- }
1372
- }
1373
-
1374
- // Inject extension env vars only when caller has permission
1375
- for (const ext of this.extensionRegistry.list()) {
1376
- if (ext.envVars.length === 0) continue;
1377
- if (
1378
- ext.permission &&
1379
- !hasPermission(this.db, spaceId, callerRole, ext.name)
1380
- )
1381
- continue;
1382
- for (const envDef of ext.envVars) {
1383
- const value = process.env[envDef.from];
1384
- if (value) {
1385
- const containerKey =
1386
- envDef.as ?? envDef.from.replace(/^MERCURY_/, "");
1387
- extraEnv = { ...extraEnv, [containerKey]: value };
1388
- }
1389
- }
1390
- }
1391
- }
1392
-
1393
- // Inject active model as single-leg override (eliminates automatic fallback)
1394
- const activeModelRaw = this.db.getSpaceConfig(spaceId, "model.active");
1395
- if (activeModelRaw) {
1396
- const colonIdx = activeModelRaw.indexOf(":");
1397
- if (colonIdx > 0) {
1398
- const provider = activeModelRaw.slice(0, colonIdx);
1399
- const model = activeModelRaw.slice(colonIdx + 1);
1400
- const legIdx = this.config.resolvedModelChain.findIndex(
1401
- (l) => l.provider === provider && l.model === model,
1402
- );
1403
- if (legIdx >= 0) {
1404
- extraEnv = {
1405
- ...extraEnv,
1406
- MODEL_CHAIN: JSON.stringify([
1407
- this.config.resolvedModelChain[legIdx],
1408
- ]),
1409
- MODEL_CHAIN_CAPABILITIES: JSON.stringify([
1410
- this.config.resolvedModelChainCapabilities[legIdx],
1411
- ]),
1412
- };
1413
- } else {
1414
- logger.warn(
1415
- "model.active references unknown leg, ignoring override",
1416
- {
1417
- spaceId,
1418
- activeModelRaw,
1419
- },
1420
- );
1421
- }
1422
- }
1423
- }
1424
-
1425
- const startTime = Date.now();
1426
-
1427
- const preferences = this.db.listSpacePreferences(spaceId).map((p) => ({
1428
- key: p.key,
1429
- value: p.value,
1430
- }));
1431
-
1432
- let containerResult: ContainerResult;
1433
- try {
1434
- containerResult = await this.containerRunner.replyWithRetry({
1435
- spaceId,
1436
- spaceWorkspace: workspace,
1437
- messages: history,
1438
- anchorMessages,
1439
- prompt: finalPrompt,
1440
- callerId,
1441
- callerRole,
1442
- authorName,
1443
- attachments,
1444
- preferences,
1445
- extraEnv,
1446
- claimedEnvSources: this.extensionRegistry?.getClaimedEnvSources(),
1447
- });
1448
- } catch (err) {
1449
- this.db.updateMessageRunMeta(userMessageId, userTurnRunMeta(undefined));
1450
- throw err;
1451
- }
1452
-
1453
- const durationMs = Date.now() - startTime;
1454
-
1455
- // Emit after_container hook
1456
- if (this.hooks && this.extensionCtx) {
1457
- const hookResult = await this.hooks.emitAfterContainer(
1458
- {
1459
- spaceId,
1460
- workspace,
1461
- callerId,
1462
- prompt: finalPrompt,
1463
- reply: containerResult.reply,
1464
- durationMs,
1465
- },
1466
- this.extensionCtx,
1467
- );
1468
- if (hookResult?.suppress) {
1469
- this.db.updateMessageRunMeta(
1470
- userMessageId,
1471
- userTurnRunMeta(containerResult.usage),
1472
- );
1473
- return { reply: "", files: [] };
1474
- }
1475
- if (hookResult?.reply !== undefined) {
1476
- containerResult.reply = hookResult.reply;
1477
- }
1478
- if (hookResult?.files?.length) {
1479
- containerResult.files = [
1480
- ...containerResult.files,
1481
- ...hookResult.files,
1482
- ];
1483
- }
1484
- }
1485
-
1486
- const assistantMessageId = this.db.addMessage(
1487
- spaceId,
1488
- "assistant",
1489
- containerResult.reply,
1490
- undefined,
1491
- userMessageId, // reply chain: assistant replies to user message
1492
- );
1493
-
1494
- if (containerResult.usage) {
1495
- this.db.recordUsage(spaceId, containerResult.usage);
1496
- } else {
1497
- logger.debug(
1498
- "Container run finished without token usage (old agent image, non-JSON pi output, or zero reported usage)",
1499
- { spaceId },
1500
- );
1501
- }
1502
-
1503
- this.db.updateMessageRunMeta(
1504
- userMessageId,
1505
- userTurnRunMeta(containerResult.usage),
1506
- );
1507
-
1508
- containerResult.assistantMessageId = assistantMessageId;
1509
- return containerResult;
1510
- });
1511
- }
1512
-
1513
- /**
1514
- * Record the platform message ID for an outbound assistant message.
1515
- * Called by the handler after sendReply() returns the platform ID.
1516
- */
1517
- recordOutboundPlatformId(
1518
- assistantMessageId: number,
1519
- platform: string,
1520
- conversationExternalId: string,
1521
- platformMessageId: string,
1522
- ): void {
1523
- this.db.addPlatformMessageId(
1524
- assistantMessageId,
1525
- platform,
1526
- conversationExternalId,
1527
- platformMessageId,
1528
- );
1529
- }
1530
- }
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 { getActiveProfileSystemPrompt } from "./profiles.js";
29
+ import { RateLimiter } from "./rate-limiter.js";
30
+ import { type RouteResult, routeInput } from "./router.js";
31
+ import { SpaceQueue } from "./space-queue.js";
32
+ import { TaskScheduler } from "./task-scheduler.js";
33
+
34
+ export type InputSource = "cli" | "scheduler" | "chat-sdk";
35
+
36
+ export type ShutdownHook = () => Promise<void> | void;
37
+
38
+ function agentMetaFromUsage(
39
+ usage: TokenUsage | undefined,
40
+ ): MessageRunMeta["agent"] {
41
+ if (!usage) return undefined;
42
+ const a: NonNullable<MessageRunMeta["agent"]> = {};
43
+ if (usage.inputTokens != null) a.inputTokens = usage.inputTokens;
44
+ if (usage.outputTokens != null) a.outputTokens = usage.outputTokens;
45
+ if (usage.totalTokens != null) a.totalTokens = usage.totalTokens;
46
+ if (usage.cacheReadTokens != null) a.cacheReadTokens = usage.cacheReadTokens;
47
+ if (usage.cacheWriteTokens != null)
48
+ a.cacheWriteTokens = usage.cacheWriteTokens;
49
+ if (usage.cost != null) a.cost = usage.cost;
50
+ if (usage.model != null) a.model = usage.model;
51
+ if (usage.provider != null) a.provider = usage.provider;
52
+ if (Object.keys(a).length === 0) return undefined;
53
+ return a;
54
+ }
55
+
56
+ function userTurnRunMeta(agentUsage?: TokenUsage): MessageRunMeta {
57
+ const meta: MessageRunMeta = {};
58
+ const agent = agentMetaFromUsage(agentUsage);
59
+ if (agent) meta.agent = agent;
60
+ return meta;
61
+ }
62
+
63
+ export class MercuryCoreRuntime {
64
+ readonly db: Db;
65
+ readonly scheduler: TaskScheduler;
66
+ readonly queue: SpaceQueue;
67
+ readonly containerRunner: AgentContainerRunner;
68
+ readonly rateLimiter: RateLimiter;
69
+ hooks: HookDispatcher | null = null;
70
+ private extensionCtx: MercuryExtensionContext | null = null;
71
+ private extensionRegistry: ExtensionRegistry | null = null;
72
+ private readonly shutdownHooks: ShutdownHook[] = [];
73
+ private readonly pauseTimers = new Map<string, NodeJS.Timeout>();
74
+ private messageSender: MessageSender | undefined;
75
+ private shuttingDown = false;
76
+ private signalHandlersInstalled = false;
77
+
78
+ constructor(readonly config: AppConfig) {
79
+ this.db = new Db(resolveProjectPath(config.dbPath));
80
+ this.queue = new SpaceQueue(config.maxConcurrency);
81
+ this.scheduler = new TaskScheduler(this.db);
82
+ this.containerRunner = new AgentContainerRunner(config);
83
+ this.rateLimiter = new RateLimiter(
84
+ config.rateLimitPerUser,
85
+ config.rateLimitWindowMs,
86
+ );
87
+
88
+ // Scaffold global (pi agent dir) and "main" (default space)
89
+ ensurePiResourceDir(resolveProjectPath(config.globalDir));
90
+ ensureSpaceWorkspace(resolveProjectPath(config.spacesDir), "main");
91
+ this.db.ensureSpace("main");
92
+
93
+ // Seed context defaults for main space from AppConfig (idempotent per key).
94
+ // YAML/env values feed the seeded defaults; existing rows are never overwritten.
95
+ // Literal fallbacks mirror the Zod defaults in src/config.ts — tests that
96
+ // build AppConfig via `as AppConfig` cast (bypassing Zod) would otherwise
97
+ // pass undefined here and fail the NOT NULL constraint on space_config.value.
98
+ if (this.db.getSpaceConfig("main", "context.mode") === null) {
99
+ this.db.setSpaceConfig(
100
+ "main",
101
+ "context.mode",
102
+ config.contextMode ?? "context",
103
+ "system",
104
+ );
105
+ }
106
+ if (this.db.getSpaceConfig("main", "context.window_size") === null) {
107
+ this.db.setSpaceConfig(
108
+ "main",
109
+ "context.window_size",
110
+ String(config.contextWindowSize ?? 10),
111
+ "system",
112
+ );
113
+ }
114
+ if (this.db.getSpaceConfig("main", "context.reply_chain_depth") === null) {
115
+ this.db.setSpaceConfig(
116
+ "main",
117
+ "context.reply_chain_depth",
118
+ String(config.contextReplyChainDepth ?? 10),
119
+ "system",
120
+ );
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Initialize the runtime — must be called before accepting work.
126
+ * Cleans up any orphaned containers from previous runs.
127
+ */
128
+ async initialize(): Promise<void> {
129
+ await this.containerRunner.cleanupOrphans();
130
+ this.rateLimiter.startCleanup();
131
+ }
132
+
133
+ /**
134
+ * Wire extension system into the runtime.
135
+ * Must be called after extensions are loaded and before accepting messages.
136
+ */
137
+ initExtensions(registry: ExtensionRegistry): void {
138
+ this.hooks = new HookDispatcher(registry, logger);
139
+ this.extensionRegistry = registry;
140
+ this.extensionCtx = createMercuryExtensionContext({
141
+ db: this.db,
142
+ config: this.config,
143
+ log: logger,
144
+ });
145
+ this.warnUncoveredSensitiveSpaces();
146
+ }
147
+
148
+ /**
149
+ * Returns a comma-joined display name of active sensitive connections,
150
+ * or null if none are active. Active means: credential env var set (for
151
+ * env-var-based connections) or any extension state stored (for OAuth connections).
152
+ */
153
+ private getActiveSensitiveConnectionName(): string | null {
154
+ if (!this.extensionRegistry) return null;
155
+ const names: string[] = [];
156
+ for (const ext of this.extensionRegistry.list()) {
157
+ if (!ext.connection?.sensitive) continue;
158
+ const conn = ext.connection;
159
+ let active = false;
160
+ if (conn.credentialEnvVar) {
161
+ active = !!process.env[conn.credentialEnvVar];
162
+ } else if (conn.statusCheck) {
163
+ // Proxy: if any state has been stored, the OAuth flow was completed.
164
+ active = this.db.hasAnyExtensionState(ext.name);
165
+ }
166
+ if (active) names.push(conn.displayName);
167
+ }
168
+ return names.length > 0 ? names.join(", ") : null;
169
+ }
170
+
171
+ /**
172
+ * Sensitive connection guard — fires before the container for assistant turns.
173
+ * Returns { action: "proceed" } (proceed, optionally replaying a stored prompt)
174
+ * or { action: "block", reason } (send reason as reply, skip container).
175
+ */
176
+ private async checkSensitiveConnectionGuard(
177
+ spaceId: string,
178
+ prompt: string,
179
+ ): Promise<
180
+ | { action: "proceed"; replayPrompt?: string }
181
+ | { action: "block"; reason: string }
182
+ > {
183
+ // No registry = no extensions loaded = no sensitive connections possible
184
+ if (!this.extensionRegistry) return { action: "proceed" };
185
+
186
+ if (!this.db.hasGroupLinkedConversation(spaceId)) {
187
+ return { action: "proceed" };
188
+ }
189
+
190
+ const sensitiveName = this.getActiveSensitiveConnectionName();
191
+ if (!sensitiveName) return { action: "proceed" };
192
+
193
+ const allowed = this.db.getSpaceConfig(
194
+ spaceId,
195
+ "security.sensitive_connections_allowed",
196
+ );
197
+ if (allowed !== "true") {
198
+ return {
199
+ action: "block",
200
+ 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`,
201
+ };
202
+ }
203
+
204
+ const pendingAt = this.db.getSpaceConfig(
205
+ spaceId,
206
+ "security.pending_sensitive_at",
207
+ );
208
+ if (pendingAt) {
209
+ const ageMs = Date.now() - new Date(pendingAt).getTime();
210
+ const expired = Number.isNaN(ageMs) || ageMs > 5 * 60 * 1000;
211
+ const text = prompt.trim().toLowerCase();
212
+
213
+ if (expired || text === "no") {
214
+ this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_prompt");
215
+ this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_at");
216
+ if (expired) {
217
+ // Treat next message as fresh — fall through to new warning below
218
+ } else {
219
+ return { action: "block", reason: "Cancelled." };
220
+ }
221
+ } else if (text === "yes") {
222
+ const storedPrompt = this.db.getSpaceConfig(
223
+ spaceId,
224
+ "security.pending_sensitive_prompt",
225
+ );
226
+ this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_prompt");
227
+ this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_at");
228
+ return { action: "proceed", replayPrompt: storedPrompt ?? undefined };
229
+ } else {
230
+ // New message arrived mid-confirmation — replace pending with new prompt
231
+ // fall through to emit new warning below
232
+ }
233
+ }
234
+
235
+ // Emit warning and store the original prompt for replay on confirmation
236
+ this.db.setSpaceConfig(
237
+ spaceId,
238
+ "security.pending_sensitive_prompt",
239
+ prompt,
240
+ "system",
241
+ );
242
+ this.db.setSpaceConfig(
243
+ spaceId,
244
+ "security.pending_sensitive_at",
245
+ new Date().toISOString(),
246
+ "system",
247
+ );
248
+ return {
249
+ action: "block",
250
+ 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.`,
251
+ };
252
+ }
253
+
254
+ /** Log a startup warning for group spaces with active sensitive connections but no admin enable. */
255
+ private warnUncoveredSensitiveSpaces(): void {
256
+ const sensitiveName = this.getActiveSensitiveConnectionName();
257
+ if (!sensitiveName) return;
258
+
259
+ const affected: string[] = [];
260
+ for (const space of this.db.listSpaces()) {
261
+ if (!this.db.hasGroupLinkedConversation(space.id)) continue;
262
+ const allowed = this.db.getSpaceConfig(
263
+ space.id,
264
+ "security.sensitive_connections_allowed",
265
+ );
266
+ if (allowed !== "true") affected.push(space.id);
267
+ }
268
+
269
+ if (affected.length > 0) {
270
+ logger.warn(
271
+ "Sensitive connections active but not admin-enabled for group spaces — messages will be blocked until enabled",
272
+ { spaces: affected, connections: sensitiveName },
273
+ );
274
+ }
275
+ }
276
+
277
+ startScheduler(sender?: MessageSender): void {
278
+ this.messageSender = sender;
279
+ this.restorePauseTimers();
280
+ this.scheduler.start(async (task) => {
281
+ const result = await this.executePrompt(
282
+ task.spaceId,
283
+ task.prompt,
284
+ "scheduler",
285
+ task.createdBy,
286
+ );
287
+ if (!task.silent && sender) {
288
+ await sender.send(task.spaceId, result.reply, result.files);
289
+ }
290
+ if (!task.silent && result.reply) {
291
+ this.deliverTaskOutput(task.spaceId, result.reply);
292
+ }
293
+ });
294
+ }
295
+
296
+ private deliverTaskOutput(spaceId: string, text: string): void {
297
+ const consoleUrl = process.env.MERCURY_CONSOLE_URL;
298
+ const secret = process.env.MERCURY_CONSOLE_INTERNAL_SECRET;
299
+ if (!consoleUrl || !secret) return;
300
+ const url = `${consoleUrl}/api/internal/whatsapp/deliver`;
301
+
302
+ fetch(url, {
303
+ method: "POST",
304
+ headers: {
305
+ "Content-Type": "application/json",
306
+ Authorization: `Bearer ${secret}`,
307
+ },
308
+ body: JSON.stringify({
309
+ agentId: process.env.MERCURY_AGENT_ID ?? "unknown",
310
+ spaceId,
311
+ text,
312
+ }),
313
+ }).catch((err) => {
314
+ const cause = (err as Error & { cause?: Error }).cause;
315
+ logger.warn("Task output delivery failed", {
316
+ error: err instanceof Error ? err.message : String(err),
317
+ cause: cause?.message,
318
+ });
319
+ });
320
+ }
321
+
322
+ stopScheduler(): void {
323
+ this.scheduler.stop();
324
+ for (const t of this.pauseTimers.values()) clearTimeout(t);
325
+ this.pauseTimers.clear();
326
+ }
327
+
328
+ async handleRawInput(
329
+ message: IngressMessage,
330
+ source: Exclude<InputSource, "scheduler">,
331
+ ): Promise<RouteResult & { result?: ContainerResult }> {
332
+ const route = routeInput({
333
+ text: message.text,
334
+ spaceId: message.spaceId,
335
+ callerId: message.callerId,
336
+ isDM: message.isDM,
337
+ isReplyToBot: message.isReplyToBot,
338
+ db: this.db,
339
+ config: this.config,
340
+ attachments: message.attachments,
341
+ hadIncomingAttachments: message.hadIncomingAttachments,
342
+ authorName: message.authorName,
343
+ });
344
+
345
+ const pendingDeleteResult = this.handlePendingSpaceDelete(
346
+ message.spaceId,
347
+ message.callerId,
348
+ message.text.trim().toLowerCase(),
349
+ );
350
+ if (pendingDeleteResult) {
351
+ return {
352
+ type: "command",
353
+ command: "spaces",
354
+ callerId: message.callerId,
355
+ role:
356
+ route.type === "command" || route.type === "assistant"
357
+ ? route.role
358
+ : "admin",
359
+ result: { reply: pendingDeleteResult, files: [] },
360
+ };
361
+ }
362
+
363
+ // Pause guard — drop everything except /pause and /resume when paused
364
+ if (this.db.getSpaceConfig(message.spaceId, "paused") === "true") {
365
+ const exempt =
366
+ route.type === "command" &&
367
+ (route.command === "pause" || route.command === "resume");
368
+ if (!exempt) {
369
+ return { type: "ignore" };
370
+ }
371
+ }
372
+
373
+ if (route.type === "command") {
374
+ const reply = await this.executeCommand(
375
+ message.spaceId,
376
+ route.command,
377
+ route.callerId,
378
+ route.verb,
379
+ route.arg,
380
+ {
381
+ platform: message.platform,
382
+ externalId: message.conversationExternalId,
383
+ },
384
+ );
385
+ return { ...route, result: { reply, files: [] } };
386
+ }
387
+
388
+ // Check mute — silently drop messages from muted users
389
+ if (
390
+ route.type === "assistant" &&
391
+ this.db.isMuted(message.spaceId, message.callerId)
392
+ ) {
393
+ return { type: "ignore" };
394
+ }
395
+
396
+ // Check rate limit for assistant requests (not commands, not ignored messages)
397
+ if (route.type === "assistant") {
398
+ // Daily role-based rate check (before burst limiter)
399
+ if (route.role !== "system") {
400
+ const roleKey = `rate_limit.${route.role}`;
401
+ const roleLimitRaw = this.db.getSpaceConfig(message.spaceId, roleKey);
402
+ const globalDailyLimit =
403
+ route.role === "member"
404
+ ? this.config.rateLimitDailyMember
405
+ : route.role === "admin"
406
+ ? this.config.rateLimitDailyAdmin
407
+ : 0;
408
+ const effectiveDailyRaw =
409
+ roleLimitRaw ??
410
+ (globalDailyLimit > 0 ? String(globalDailyLimit) : null);
411
+ if (effectiveDailyRaw !== null) {
412
+ const roleLimit = Number.parseInt(effectiveDailyRaw, 10);
413
+ if (!Number.isNaN(roleLimit) && roleLimit > 0) {
414
+ const daily = this.db.checkAndIncrementDailyUsage(
415
+ message.spaceId,
416
+ message.callerId,
417
+ roleLimit,
418
+ );
419
+ if (!daily.allowed) {
420
+ const msUntilReset =
421
+ new Date().setUTCHours(24, 0, 0, 0) - Date.now();
422
+ const hoursLeft = Math.ceil(msUntilReset / 3_600_000);
423
+ return {
424
+ type: "denied",
425
+ reason: `You've used ${daily.count}/${roleLimit} messages today. Resets in ${hoursLeft}h.`,
426
+ };
427
+ }
428
+ }
429
+ }
430
+ }
431
+
432
+ // Burst rate limit (sliding window)
433
+ const groupLimit = this.db.getSpaceConfig(message.spaceId, "rate_limit");
434
+ const effectiveLimit = groupLimit
435
+ ? Number.parseInt(groupLimit, 10)
436
+ : this.config.rateLimitPerUser;
437
+
438
+ if (
439
+ effectiveLimit > 0 &&
440
+ !this.checkRateLimit(message.spaceId, message.callerId, effectiveLimit)
441
+ ) {
442
+ return {
443
+ type: "denied",
444
+ reason: "Rate limit exceeded. Try again shortly.",
445
+ };
446
+ }
447
+ }
448
+
449
+ if (route.type !== "assistant") {
450
+ // Store ambient messages in group chats (non-triggered, non-DM)
451
+ // Default: enabled. Set ambient.enabled=false for tag-only mode.
452
+ const ambientEnabled =
453
+ this.db.getSpaceConfig(message.spaceId, "ambient.enabled") !== "false";
454
+ if (
455
+ route.type === "ignore" &&
456
+ source === "chat-sdk" &&
457
+ !message.isDM &&
458
+ ambientEnabled
459
+ ) {
460
+ const ambientText = message.authorName
461
+ ? `${message.authorName}: ${message.text.trim()}`
462
+ : message.text.trim();
463
+
464
+ if (ambientText) {
465
+ this.db.ensureSpace(message.spaceId);
466
+ this.db.addMessage(message.spaceId, "ambient", ambientText);
467
+ }
468
+ }
469
+
470
+ return route;
471
+ }
472
+
473
+ const noPromptText = !message.text.trim();
474
+ const noSavedFiles = (message.attachments?.length ?? 0) === 0;
475
+ if (
476
+ noPromptText &&
477
+ noSavedFiles &&
478
+ (message.hadIncomingAttachments ?? false)
479
+ ) {
480
+ return {
481
+ type: "denied",
482
+ reason:
483
+ "Could not use your attachment (media disabled, over the size limit, or download failed). Check MERCURY_MEDIA_ENABLED and logs.",
484
+ };
485
+ }
486
+
487
+ const guardResult = await this.checkSensitiveConnectionGuard(
488
+ message.spaceId,
489
+ route.prompt,
490
+ );
491
+ if (guardResult.action === "block") {
492
+ return { type: "denied", reason: guardResult.reason };
493
+ }
494
+ const effectivePrompt = guardResult.replayPrompt ?? route.prompt;
495
+
496
+ try {
497
+ const result = await this.executePrompt(
498
+ message.spaceId,
499
+ effectivePrompt,
500
+ source,
501
+ message.callerId,
502
+ message.attachments,
503
+ message.authorName,
504
+ {
505
+ platform: message.platform,
506
+ conversationExternalId: message.conversationExternalId,
507
+ replyToPlatformMessageId: message.replyToPlatformMessageId,
508
+ platformMessageId: message.platformMessageId,
509
+ },
510
+ { isReplyToBot: route.isReplyToBot, isDM: route.isDM },
511
+ );
512
+ return { ...route, result };
513
+ } catch (error) {
514
+ if (error instanceof ContainerError) {
515
+ switch (error.reason) {
516
+ case "aborted":
517
+ return { type: "denied", reason: "Stopped current run." };
518
+ case "timeout":
519
+ return { type: "denied", reason: "Container timed out." };
520
+ case "oom":
521
+ return {
522
+ type: "denied",
523
+ reason: "Container was killed (possibly out of memory).",
524
+ };
525
+ case "error": {
526
+ logger.error(
527
+ "Container error",
528
+ error instanceof Error ? error : undefined,
529
+ );
530
+ const category = classifyUserError(error.message);
531
+ const reason = friendlyErrorMessage(
532
+ category,
533
+ this.config.apiKeyMode,
534
+ this.config.consoleUrl,
535
+ );
536
+ return { type: "denied", reason };
537
+ }
538
+ }
539
+ }
540
+ throw error;
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Check if a request is allowed under rate limiting.
546
+ * Uses per-group override if set, otherwise uses the default limit.
547
+ */
548
+ private checkRateLimit(
549
+ spaceId: string,
550
+ userId: string,
551
+ effectiveLimit: number,
552
+ ): boolean {
553
+ return this.rateLimiter.isAllowed(spaceId, userId, effectiveLimit);
554
+ }
555
+
556
+ private async executeCommand(
557
+ spaceId: string,
558
+ command: string,
559
+ callerId: string,
560
+ verb?: string,
561
+ arg?: string,
562
+ conversationContext?: { platform: string; externalId: string },
563
+ ): Promise<string> {
564
+ switch (command) {
565
+ case "stop": {
566
+ const stopped = this.containerRunner.abort(spaceId);
567
+ const dropped = this.queue.cancelPending(spaceId);
568
+ if (stopped)
569
+ return `Stopped.${dropped > 0 ? ` Dropped ${dropped} queued request(s).` : ""}`;
570
+ if (dropped > 0) return `Dropped ${dropped} queued request(s).`;
571
+ return "No active run.";
572
+ }
573
+ case "compact": {
574
+ this.db.setSessionBoundaryToLatest(spaceId);
575
+ return "Compacted.";
576
+ }
577
+ case "clear": {
578
+ this.db.setClearBoundary(spaceId);
579
+ return "Cleared.";
580
+ }
581
+ case "help": {
582
+ if (verb) {
583
+ return (
584
+ formatCategoryHelp(verb) ?? `No help available for '/${verb}'.`
585
+ );
586
+ }
587
+ return formatHelp();
588
+ }
589
+ case "model":
590
+ return this.executeModelsCommand(spaceId, callerId, verb, arg);
591
+ case "spaces":
592
+ return this.executeSpacesCommand(
593
+ spaceId,
594
+ callerId,
595
+ conversationContext,
596
+ verb,
597
+ arg,
598
+ );
599
+ case "pause":
600
+ return this.executePauseCommand(spaceId, callerId, verb);
601
+ case "resume":
602
+ return this.executeResumeCommand(spaceId, callerId);
603
+ default:
604
+ return `Unknown command: ${command}`;
605
+ }
606
+ }
607
+
608
+ private executeModelsCommand(
609
+ spaceId: string,
610
+ callerId: string,
611
+ verb?: string,
612
+ arg?: string,
613
+ ): string {
614
+ const chain = this.config.resolvedModelChain;
615
+
616
+ if (!verb) {
617
+ return formatCategoryHelp("model") ?? "/model — model management";
618
+ }
619
+
620
+ switch (verb) {
621
+ case "list": {
622
+ if (chain.length === 0) return "No models configured.";
623
+ const activeRaw = this.db.getSpaceConfig(spaceId, "model.active");
624
+ const activeFound = activeRaw
625
+ ? chain.some((l) => `${l.provider}:${l.model}` === activeRaw)
626
+ : false;
627
+ const lines = ["Configured models:"];
628
+ for (let i = 0; i < chain.length; i++) {
629
+ const leg = chain[i];
630
+ const isActive = activeFound
631
+ ? activeRaw === `${leg.provider}:${leg.model}`
632
+ : i === 0;
633
+ lines.push(
634
+ ` [${i + 1}] ${leg.provider} / ${leg.model}${isActive ? " ← active" : ""}`,
635
+ );
636
+ }
637
+ return lines.join("\n");
638
+ }
639
+
640
+ case "active": {
641
+ const activeRaw = this.db.getSpaceConfig(spaceId, "model.active");
642
+ const leg =
643
+ (activeRaw
644
+ ? chain.find((l) => `${l.provider}:${l.model}` === activeRaw)
645
+ : undefined) ?? chain[0];
646
+ if (!leg) return "No models configured.";
647
+ return `Active model: ${leg.provider} / ${leg.model}`;
648
+ }
649
+
650
+ case "switch": {
651
+ if (!arg)
652
+ return "Usage: /model switch <N|MODEL_ID>. Use /model list to see options.";
653
+ if (chain.length === 0) return "No models configured.";
654
+
655
+ let leg: (typeof chain)[number] | undefined;
656
+ const num = Number.parseInt(arg, 10);
657
+ if (!Number.isNaN(num) && num >= 1 && num <= chain.length) {
658
+ leg = chain[num - 1];
659
+ } else {
660
+ leg = chain.find(
661
+ (l) => l.model === arg || `${l.provider}:${l.model}` === arg,
662
+ );
663
+ }
664
+
665
+ if (!leg)
666
+ return "Model not found. Use /model list to see your options.";
667
+
668
+ this.db.setSpaceConfig(
669
+ spaceId,
670
+ "model.active",
671
+ `${leg.provider}:${leg.model}`,
672
+ callerId,
673
+ );
674
+ return `Switched to ${leg.provider} / ${leg.model}.`;
675
+ }
676
+
677
+ default:
678
+ return `/model: unknown verb '${verb}'. Use /model for help.`;
679
+ }
680
+ }
681
+
682
+ private executeSpacesCommand(
683
+ spaceId: string,
684
+ _callerId: string,
685
+ conversationContext?: { platform: string; externalId: string },
686
+ verb?: string,
687
+ arg?: string,
688
+ ): string {
689
+ this.clearPendingDelete(spaceId);
690
+
691
+ if (!verb) {
692
+ return formatCategoryHelp("spaces") ?? "/spaces — space management";
693
+ }
694
+
695
+ switch (verb) {
696
+ case "list": {
697
+ const spaces = this.db.listSpaces();
698
+ if (spaces.length === 0) return "No spaces.";
699
+ const lines = ["Spaces:"];
700
+ for (const s of spaces) {
701
+ const convos = this.db.getSpaceConversations(s.id);
702
+ lines.push(
703
+ ` ${s.id.padEnd(16)} ${s.name} (${convos.length} conversation${convos.length === 1 ? "" : "s"})`,
704
+ );
705
+ }
706
+ return lines.join("\n");
707
+ }
708
+
709
+ case "create": {
710
+ if (!arg) return "Usage: /spaces create <id> <name>";
711
+ const firstSpace = arg.indexOf(" ");
712
+ if (firstSpace === -1) return "Usage: /spaces create <id> <name>";
713
+ const newId = arg.slice(0, firstSpace);
714
+ const newName = arg.slice(firstSpace + 1).trim();
715
+ if (!newName) return "Usage: /spaces create <id> <name>";
716
+ try {
717
+ this.db.createSpace(newId, newName);
718
+ } catch (err) {
719
+ return err instanceof Error ? err.message : String(err);
720
+ }
721
+ return `Created space '${newId}' (${newName}).`;
722
+ }
723
+
724
+ case "switch": {
725
+ if (!arg) return "Usage: /spaces switch <id>";
726
+ const targetSpace = this.db.getSpace(arg);
727
+ if (!targetSpace) return `Space '${arg}' not found.`;
728
+ if (!conversationContext) {
729
+ return "Cannot determine current conversation.";
730
+ }
731
+ const convo = this.db.findConversation(
732
+ conversationContext.platform,
733
+ conversationContext.externalId,
734
+ );
735
+ if (!convo) return "Current conversation not found.";
736
+ if (convo.spaceId === arg) return `Already in space '${arg}'.`;
737
+ this.db.linkConversation(convo.id, arg);
738
+ return `Switched to space '${targetSpace.name}' (${arg}).`;
739
+ }
740
+
741
+ case "delete": {
742
+ if (!arg) return "Usage: /spaces delete <id>";
743
+ if (arg === "main") return "Cannot delete the default 'main' space.";
744
+ const target = this.db.getSpace(arg);
745
+ if (!target) return `Space '${arg}' not found.`;
746
+ this.db.setSpaceConfig(
747
+ spaceId,
748
+ "spaces.pending_delete_id",
749
+ arg,
750
+ "system",
751
+ );
752
+ this.db.setSpaceConfig(
753
+ spaceId,
754
+ "spaces.pending_delete_at",
755
+ new Date().toISOString(),
756
+ "system",
757
+ );
758
+ const isSelf = spaceId === arg;
759
+ const warning = isSelf
760
+ ? "\n⚠️ This will unlink this conversation. You'll need to link it to another space to continue chatting."
761
+ : "";
762
+ return `Delete space '${arg}' (${target.name})? This will destroy all messages, tasks, roles, config, and preferences for that space. Reply *yes* to confirm or *no* to cancel.${warning}`;
763
+ }
764
+
765
+ case "unlink": {
766
+ if (!conversationContext) {
767
+ return "Cannot determine current conversation.";
768
+ }
769
+ const convo = this.db.findConversation(
770
+ conversationContext.platform,
771
+ conversationContext.externalId,
772
+ );
773
+ if (!convo) return "Current conversation not found.";
774
+ this.db.unlinkConversation(convo.id);
775
+ return "Unlinked. Messages in this conversation will be ignored until you link it to a space with /spaces switch.";
776
+ }
777
+
778
+ default:
779
+ return `/spaces: unknown verb '${verb}'. Use /spaces for help.`;
780
+ }
781
+ }
782
+
783
+ private handlePendingSpaceDelete(
784
+ spaceId: string,
785
+ callerId: string,
786
+ text: string,
787
+ ): string | null {
788
+ const pendingId = this.db.getSpaceConfig(
789
+ spaceId,
790
+ "spaces.pending_delete_id",
791
+ );
792
+ if (!pendingId) return null;
793
+
794
+ const seededAdmins = this.config.admins
795
+ ? this.config.admins
796
+ .split(",")
797
+ .map((s) => s.trim())
798
+ .filter(Boolean)
799
+ : [];
800
+ if (!seededAdmins.includes(callerId)) return null;
801
+
802
+ const pendingAt = this.db.getSpaceConfig(
803
+ spaceId,
804
+ "spaces.pending_delete_at",
805
+ );
806
+ const ageMs = pendingAt
807
+ ? Date.now() - new Date(pendingAt).getTime()
808
+ : Number.POSITIVE_INFINITY;
809
+ const expired = Number.isNaN(ageMs) || ageMs > 60_000;
810
+
811
+ this.db.deleteSpaceConfig(spaceId, "spaces.pending_delete_id");
812
+ this.db.deleteSpaceConfig(spaceId, "spaces.pending_delete_at");
813
+
814
+ if (expired) {
815
+ if (text === "yes") return "Delete cancelled (timed out).";
816
+ return null;
817
+ }
818
+
819
+ if (text === "no") {
820
+ return "Delete cancelled.";
821
+ }
822
+
823
+ if (text === "yes") {
824
+ const target = this.db.getSpace(pendingId);
825
+ if (!target) return `Space '${pendingId}' not found.`;
826
+ const result = this.db.deleteSpace(pendingId);
827
+ if (!result.deleted) return `Failed to delete space '${pendingId}'.`;
828
+ return `Deleted space '${pendingId}' (${target.name}). Removed: ${result.removed.messages} messages, ${result.removed.tasks} tasks, ${result.removed.conversationsUnlinked} conversations unlinked.`;
829
+ }
830
+
831
+ return null;
832
+ }
833
+
834
+ private clearPendingDelete(spaceId: string): void {
835
+ this.db.deleteSpaceConfig(spaceId, "spaces.pending_delete_id");
836
+ this.db.deleteSpaceConfig(spaceId, "spaces.pending_delete_at");
837
+ }
838
+
839
+ private executePauseCommand(
840
+ spaceId: string,
841
+ callerId: string,
842
+ verb?: string,
843
+ ): string {
844
+ const alreadyPaused = this.db.getSpaceConfig(spaceId, "paused") === "true";
845
+ const hadTimer = this.db.getSpaceConfig(spaceId, "paused.resume_at");
846
+
847
+ if (verb) {
848
+ const parsed = this.parsePauseDuration(verb);
849
+ if (!parsed.ok) return parsed.error;
850
+
851
+ this.db.setSpaceConfig(spaceId, "paused", "true", callerId);
852
+ const resumeAt = Date.now() + parsed.ms;
853
+ this.db.setSpaceConfig(
854
+ spaceId,
855
+ "paused.resume_at",
856
+ String(resumeAt),
857
+ callerId,
858
+ );
859
+ this.schedulePauseTimer(spaceId, parsed.ms);
860
+
861
+ const resumeTime = new Date(resumeAt).toLocaleString("en-US", {
862
+ hour: "2-digit",
863
+ minute: "2-digit",
864
+ hour12: true,
865
+ });
866
+ return `Bot paused for ${verb}. It will auto-resume at ${resumeTime}.`;
867
+ }
868
+
869
+ if (alreadyPaused && !hadTimer) {
870
+ return "Already paused.";
871
+ }
872
+
873
+ if (alreadyPaused && hadTimer) {
874
+ this.clearPauseTimer(spaceId);
875
+ this.db.deleteSpaceConfig(spaceId, "paused.resume_at");
876
+ return "Pause is now indefinite (timer cleared). Use /resume to reactivate.";
877
+ }
878
+
879
+ this.db.setSpaceConfig(spaceId, "paused", "true", callerId);
880
+ return "Bot paused in this space. Use /resume to reactivate.";
881
+ }
882
+
883
+ private executeResumeCommand(spaceId: string, _callerId: string): string {
884
+ if (this.db.getSpaceConfig(spaceId, "paused") !== "true") {
885
+ return "Bot is not paused.";
886
+ }
887
+ this.clearPauseTimer(spaceId);
888
+ this.db.deleteSpaceConfig(spaceId, "paused");
889
+ this.db.deleteSpaceConfig(spaceId, "paused.resume_at");
890
+ return "Bot resumed.";
891
+ }
892
+
893
+ private parsePauseDuration(
894
+ input: string,
895
+ ): { ok: true; ms: number } | { ok: false; error: string } {
896
+ const match = input.match(/^(\d+)(m|h)$/i);
897
+ if (!match) {
898
+ return {
899
+ ok: false,
900
+ error: "Invalid duration. Use e.g. /pause 30m or /pause 2h.",
901
+ };
902
+ }
903
+ const value = Number.parseInt(match[1], 10);
904
+ const unit = match[2].toLowerCase();
905
+ const ms = unit === "h" ? value * 60 * 60 * 1000 : value * 60 * 1000;
906
+
907
+ if (ms < 60_000) {
908
+ return { ok: false, error: "Duration must be at least 1 minute." };
909
+ }
910
+ if (ms > 24 * 60 * 60 * 1000) {
911
+ return {
912
+ ok: false,
913
+ error: "Duration must be at most 24 hours.",
914
+ };
915
+ }
916
+ return { ok: true, ms };
917
+ }
918
+
919
+ private schedulePauseTimer(spaceId: string, delayMs: number): void {
920
+ this.clearPauseTimer(spaceId);
921
+ const timer = setTimeout(() => {
922
+ this.pauseTimers.delete(spaceId);
923
+ if (this.db.getSpaceConfig(spaceId, "paused") !== "true") return;
924
+ this.db.deleteSpaceConfig(spaceId, "paused");
925
+ this.db.deleteSpaceConfig(spaceId, "paused.resume_at");
926
+ const text = "Bot resumed — pause timer expired.";
927
+ this.messageSender
928
+ ?.send(spaceId, text, [])
929
+ .catch((e) =>
930
+ logger.warn("Auto-resume notification failed", { spaceId, error: e }),
931
+ );
932
+ this.deliverTaskOutput(spaceId, text);
933
+ }, delayMs);
934
+ if (timer.unref) timer.unref();
935
+ this.pauseTimers.set(spaceId, timer);
936
+ }
937
+
938
+ private clearPauseTimer(spaceId: string): void {
939
+ const existing = this.pauseTimers.get(spaceId);
940
+ if (existing) {
941
+ clearTimeout(existing);
942
+ this.pauseTimers.delete(spaceId);
943
+ }
944
+ }
945
+
946
+ private restorePauseTimers(): void {
947
+ for (const space of this.db.listSpaces()) {
948
+ const resumeAtStr = this.db.getSpaceConfig(space.id, "paused.resume_at");
949
+ if (!resumeAtStr) continue;
950
+ const resumeAt = Number.parseInt(resumeAtStr, 10);
951
+ if (Number.isNaN(resumeAt)) continue;
952
+
953
+ const remaining = resumeAt - Date.now();
954
+ if (remaining <= 0) {
955
+ this.db.deleteSpaceConfig(space.id, "paused");
956
+ this.db.deleteSpaceConfig(space.id, "paused.resume_at");
957
+ const text = "Bot resumed — pause timer expired.";
958
+ this.messageSender?.send(space.id, text, []).catch((e) =>
959
+ logger.warn("Auto-resume notification failed", {
960
+ spaceId: space.id,
961
+ error: e,
962
+ }),
963
+ );
964
+ this.deliverTaskOutput(space.id, text);
965
+ } else {
966
+ this.schedulePauseTimer(space.id, remaining);
967
+ }
968
+ }
969
+ }
970
+
971
+ onShutdown(hook: ShutdownHook): void {
972
+ this.shutdownHooks.push(hook);
973
+ }
974
+
975
+ get isShuttingDown(): boolean {
976
+ return this.shuttingDown;
977
+ }
978
+
979
+ installSignalHandlers(): void {
980
+ if (this.signalHandlersInstalled) return;
981
+ this.signalHandlersInstalled = true;
982
+
983
+ let forceCount = 0;
984
+
985
+ const handler = (signal: string) => {
986
+ if (this.shuttingDown) {
987
+ forceCount++;
988
+ if (forceCount >= 1) {
989
+ logger.warn("Second signal received, forcing exit");
990
+ process.exit(1);
991
+ }
992
+ return;
993
+ }
994
+ logger.info("Received signal, starting graceful shutdown", { signal });
995
+ void this.shutdown().then(
996
+ () => process.exit(0),
997
+ (err) => {
998
+ logger.error(
999
+ "Shutdown failed",
1000
+ err instanceof Error ? err : undefined,
1001
+ );
1002
+ process.exit(1);
1003
+ },
1004
+ );
1005
+ };
1006
+
1007
+ process.on("SIGTERM", () => handler("SIGTERM"));
1008
+ process.on("SIGINT", () => handler("SIGINT"));
1009
+ }
1010
+
1011
+ async shutdown(timeoutMs = 10_000): Promise<void> {
1012
+ if (this.shuttingDown) return;
1013
+ this.shuttingDown = true;
1014
+
1015
+ const forceTimer = setTimeout(() => {
1016
+ logger.error("Shutdown timed out, forcing exit");
1017
+ process.exit(1);
1018
+ }, timeoutMs);
1019
+ // Don't keep the process alive just for this timer
1020
+ if (forceTimer.unref) forceTimer.unref();
1021
+
1022
+ try {
1023
+ // 1. Stop schedulers + pause timers
1024
+ logger.info("Shutdown: stopping task scheduler");
1025
+ this.scheduler.stop();
1026
+ for (const timer of this.pauseTimers.values()) clearTimeout(timer);
1027
+ this.pauseTimers.clear();
1028
+
1029
+ // 2. Drain queue — cancel pending, wait for active
1030
+ logger.info("Shutdown: draining group queue");
1031
+ const dropped = this.queue.cancelAll();
1032
+ if (dropped > 0)
1033
+ logger.info("Shutdown: cancelled pending queue entries", {
1034
+ count: dropped,
1035
+ });
1036
+
1037
+ // 3. Kill running containers
1038
+ logger.info("Shutdown: stopping running containers");
1039
+ this.containerRunner.killAll();
1040
+
1041
+ // 4. Wait for active work to finish (with a shorter timeout)
1042
+ const drainTimeout = Math.max(timeoutMs - 2000, 1000);
1043
+ const drained = await this.queue.waitForActive(drainTimeout);
1044
+ if (!drained) {
1045
+ logger.warn("Shutdown: active work did not finish in time");
1046
+ }
1047
+
1048
+ // 5. Emit extension shutdown hooks
1049
+ if (this.hooks && this.extensionCtx) {
1050
+ logger.info("Shutdown: notifying extensions");
1051
+ await this.hooks.emit("shutdown", {}, this.extensionCtx);
1052
+ }
1053
+
1054
+ // 6. Run registered shutdown hooks (adapters, server, etc.)
1055
+ for (const hook of this.shutdownHooks) {
1056
+ try {
1057
+ await hook();
1058
+ } catch (err) {
1059
+ logger.error(
1060
+ "Shutdown hook failed",
1061
+ err instanceof Error ? err : undefined,
1062
+ );
1063
+ }
1064
+ }
1065
+
1066
+ // 6. Stop rate limiter cleanup
1067
+ this.rateLimiter.stopCleanup();
1068
+
1069
+ // 7. Close database
1070
+ logger.info("Shutdown: closing database");
1071
+ this.db.close();
1072
+
1073
+ logger.info("Shutdown: complete");
1074
+ } finally {
1075
+ clearTimeout(forceTimer);
1076
+ }
1077
+ }
1078
+
1079
+ private async executePrompt(
1080
+ spaceId: string,
1081
+ prompt: string,
1082
+ _source: InputSource,
1083
+ callerId: string,
1084
+ attachments?: MessageAttachment[],
1085
+ authorName?: string,
1086
+ replyMeta?: {
1087
+ platform?: string;
1088
+ conversationExternalId?: string;
1089
+ replyToPlatformMessageId?: string;
1090
+ platformMessageId?: string;
1091
+ },
1092
+ replyFlags?: { isReplyToBot: boolean; isDM: boolean },
1093
+ ): Promise<ContainerResult> {
1094
+ this.db.ensureSpace(spaceId);
1095
+
1096
+ return this.queue.enqueue(spaceId, async () => {
1097
+ // ── Daily message quota check ────────────────────────────────────────
1098
+ // Calls the console API to record the message and verify the user hasn't
1099
+ // exceeded their plan's daily limit. Fails open: if the API is unreachable,
1100
+ // the message is allowed through (billing is best-effort, not a hard gate).
1101
+ if (this.config.consoleUrl && this.config.consoleUserId) {
1102
+ try {
1103
+ const quotaRes = await fetch(
1104
+ `${this.config.consoleUrl}/api/user/billing/message-used`,
1105
+ {
1106
+ method: "POST",
1107
+ headers: {
1108
+ "Content-Type": "application/json",
1109
+ ...(this.config.consoleInternalSecret
1110
+ ? {
1111
+ Authorization: `Bearer ${this.config.consoleInternalSecret}`,
1112
+ }
1113
+ : {}),
1114
+ },
1115
+ body: JSON.stringify({
1116
+ userId: this.config.consoleUserId,
1117
+ agentId: process.env.MERCURY_AGENT_ID ?? spaceId,
1118
+ isByok: false,
1119
+ }),
1120
+ signal: AbortSignal.timeout(5000),
1121
+ },
1122
+ );
1123
+ if (quotaRes.ok) {
1124
+ const quotaData = (await quotaRes.json()) as {
1125
+ allowed: boolean;
1126
+ remaining: number | null;
1127
+ };
1128
+ if (!quotaData.allowed) {
1129
+ return {
1130
+ reply:
1131
+ "You've reached your daily message limit. Upgrade your plan at the Mercury Console to continue chatting.",
1132
+ files: [],
1133
+ };
1134
+ }
1135
+ }
1136
+ // Non-OK response → fail open (log but don't block)
1137
+ else {
1138
+ logger.warn(
1139
+ "Quota check returned non-OK status — allowing message",
1140
+ { status: quotaRes.status },
1141
+ );
1142
+ }
1143
+ } catch (err) {
1144
+ logger.warn("Quota check failed (unreachable?) — allowing message", {
1145
+ err,
1146
+ });
1147
+ }
1148
+ }
1149
+ // ────────────────────────────────────────────────────────────────────
1150
+
1151
+ const workspace = ensureSpaceWorkspace(
1152
+ resolveProjectPath(this.config.spacesDir),
1153
+ spaceId,
1154
+ );
1155
+
1156
+ // Container-relative workspace path
1157
+ const containerWorkspace = `/spaces/${spaceId}`;
1158
+
1159
+ // ── Reply-chain isolation ──────────────────────────────────────────
1160
+ // Strip quoted bot output from unprivileged group replies-to-bot
1161
+ // BEFORE hooks see the prompt (defense in depth).
1162
+ let replyIsolated = false;
1163
+ let finalPrompt = prompt;
1164
+ if (replyFlags?.isReplyToBot && !replyFlags.isDM) {
1165
+ const seededAdmins = this.config.admins
1166
+ ? this.config.admins
1167
+ .split(",")
1168
+ .map((s) => s.trim())
1169
+ .filter(Boolean)
1170
+ : [];
1171
+ const earlyRole = resolveRole(this.db, spaceId, callerId, seededAdmins);
1172
+ if (earlyRole !== "admin" && earlyRole !== "system") {
1173
+ replyIsolated = true;
1174
+ finalPrompt = finalPrompt.replace(
1175
+ /\n*<reply_to[^>]*>[\s\S]*?<\/reply_to>/g,
1176
+ "",
1177
+ );
1178
+ }
1179
+ }
1180
+ // ────────────────────────────────────────────────────────────────────
1181
+
1182
+ // Emit workspace_init hook (extensions should be idempotent)
1183
+ if (this.hooks && this.extensionCtx) {
1184
+ await this.hooks.emit(
1185
+ "workspace_init",
1186
+ { spaceId, workspace, containerWorkspace },
1187
+ this.extensionCtx,
1188
+ );
1189
+ }
1190
+
1191
+ // Emit before_container hook
1192
+ let extraEnv: Record<string, string> | undefined;
1193
+ if (this.hooks && this.extensionCtx) {
1194
+ const result = await this.hooks.emitBeforeContainer(
1195
+ {
1196
+ spaceId,
1197
+ prompt: finalPrompt,
1198
+ callerId,
1199
+ workspace,
1200
+ containerWorkspace,
1201
+ attachments,
1202
+ },
1203
+ this.extensionCtx,
1204
+ );
1205
+ if (result?.block) {
1206
+ return { reply: result.block.reason, files: [] };
1207
+ }
1208
+ if (result) {
1209
+ if (result.env) {
1210
+ extraEnv = { ...extraEnv, ...result.env };
1211
+ }
1212
+ if (result.systemPrompt) {
1213
+ extraEnv = {
1214
+ ...extraEnv,
1215
+ MERCURY_EXT_SYSTEM_PROMPT: result.systemPrompt,
1216
+ };
1217
+ }
1218
+ if (result.promptAppend) {
1219
+ finalPrompt = [finalPrompt, result.promptAppend]
1220
+ .filter(Boolean)
1221
+ .join("\n\n");
1222
+ }
1223
+ }
1224
+ }
1225
+
1226
+ // Inject the active applicative profile persona (project-wide), ahead of
1227
+ // any per-space system prompt so a space-specific prompt refines it.
1228
+ const profilePrompt = getActiveProfileSystemPrompt();
1229
+ if (profilePrompt) {
1230
+ const existing = extraEnv?.MERCURY_EXT_SYSTEM_PROMPT;
1231
+ extraEnv = {
1232
+ ...extraEnv,
1233
+ MERCURY_EXT_SYSTEM_PROMPT: existing
1234
+ ? `${existing}\n\n${profilePrompt}`
1235
+ : profilePrompt,
1236
+ };
1237
+ }
1238
+
1239
+ // Inject per-space system prompt (set via console Spaces settings or at provision time).
1240
+ const spacePrompt = this.db.getSpaceConfig(spaceId, "system_prompt");
1241
+ if (spacePrompt) {
1242
+ const existing = extraEnv?.MERCURY_EXT_SYSTEM_PROMPT;
1243
+ extraEnv = {
1244
+ ...extraEnv,
1245
+ MERCURY_EXT_SYSTEM_PROMPT: existing
1246
+ ? `${existing}\n\n${spacePrompt}`
1247
+ : spacePrompt,
1248
+ };
1249
+ }
1250
+
1251
+ // Resolve reply target once — reused for context assembly and DB linkage.
1252
+ let replyMercuryMsgId: number | null = null;
1253
+ if (
1254
+ replyMeta?.replyToPlatformMessageId &&
1255
+ replyMeta.platform &&
1256
+ replyMeta.conversationExternalId
1257
+ ) {
1258
+ replyMercuryMsgId = this.db.lookupMercuryMessageId(
1259
+ replyMeta.platform,
1260
+ replyMeta.conversationExternalId,
1261
+ replyMeta.replyToPlatformMessageId,
1262
+ );
1263
+ }
1264
+ const userReplyToId = replyMercuryMsgId ?? undefined;
1265
+
1266
+ const replyChainDepthStr = this.db.getSpaceConfig(
1267
+ spaceId,
1268
+ "context.reply_chain_depth",
1269
+ );
1270
+ const replyChainDepth = replyChainDepthStr
1271
+ ? Number.parseInt(replyChainDepthStr, 10)
1272
+ : 10;
1273
+
1274
+ // Fetch prior turns based on context mode.
1275
+ // When reply-isolated, skip all history to prevent context leakage.
1276
+ let history: import("../types.js").StoredMessage[];
1277
+ let anchorMessages: import("../types.js").StoredMessage[] | undefined;
1278
+ if (replyIsolated) {
1279
+ history = [];
1280
+ extraEnv = { ...extraEnv, MERCURY_REPLY_ISOLATED: "1" };
1281
+ } else {
1282
+ const contextMode =
1283
+ this.db.getSpaceConfig(spaceId, "context.mode") ?? "clear";
1284
+
1285
+ if (contextMode === "context") {
1286
+ const windowSizeStr = this.db.getSpaceConfig(
1287
+ spaceId,
1288
+ "context.window_size",
1289
+ );
1290
+ const windowSize = windowSizeStr
1291
+ ? Number.parseInt(windowSizeStr, 10)
1292
+ : (this.config.contextWindowSize ?? 10);
1293
+
1294
+ if (replyMercuryMsgId !== null) {
1295
+ const trimmedWindow = Math.floor(windowSize / 2);
1296
+ const anchored = this.db.getAnchoredContext(
1297
+ spaceId,
1298
+ replyMercuryMsgId,
1299
+ replyChainDepth,
1300
+ trimmedWindow,
1301
+ );
1302
+ anchorMessages = anchored.anchor;
1303
+ history = anchored.recent;
1304
+ } else {
1305
+ history = this.db.getRecentTurns(spaceId, windowSize);
1306
+ }
1307
+
1308
+ // One-shot clear: reset temporary boundary immediately after reading history.
1309
+ this.db.resetClearBoundary(spaceId);
1310
+ } else {
1311
+ // Clear mode: only include reply chain if this message is a reply
1312
+ if (replyMercuryMsgId !== null) {
1313
+ history = this.db.getReplyChain(
1314
+ replyMercuryMsgId,
1315
+ replyChainDepth,
1316
+ spaceId,
1317
+ );
1318
+ } else {
1319
+ history = [];
1320
+ }
1321
+ }
1322
+ }
1323
+
1324
+ const userMessageId = this.db.addMessage(
1325
+ spaceId,
1326
+ "user",
1327
+ finalPrompt,
1328
+ attachments,
1329
+ userReplyToId,
1330
+ );
1331
+
1332
+ // Record platform message ID mapping for the inbound user message
1333
+ if (
1334
+ replyMeta?.platformMessageId &&
1335
+ replyMeta.platform &&
1336
+ replyMeta.conversationExternalId
1337
+ ) {
1338
+ this.db.addPlatformMessageId(
1339
+ userMessageId,
1340
+ replyMeta.platform,
1341
+ replyMeta.conversationExternalId,
1342
+ replyMeta.platformMessageId,
1343
+ );
1344
+ }
1345
+
1346
+ // Compute caller role, denied CLIs, and permitted env vars
1347
+ let callerRole = "member";
1348
+ if (this.extensionRegistry) {
1349
+ const seededAdmins = this.config.admins
1350
+ ? this.config.admins
1351
+ .split(",")
1352
+ .map((s) => s.trim())
1353
+ .filter(Boolean)
1354
+ : [];
1355
+ callerRole = resolveRole(this.db, spaceId, callerId, seededAdmins);
1356
+
1357
+ const cliExtensions = this.extensionRegistry.getCliExtensions();
1358
+ if (cliExtensions.length > 0) {
1359
+ const denied = cliExtensions
1360
+ .filter(
1361
+ (ext) =>
1362
+ ext.clis.length > 0 &&
1363
+ !hasPermission(this.db, spaceId, callerRole, ext.name),
1364
+ )
1365
+ .flatMap((ext) => ext.clis.map((c) => c.name));
1366
+ if (denied.length > 0) {
1367
+ extraEnv = {
1368
+ ...extraEnv,
1369
+ MERCURY_DENIED_CLIS: denied.join(","),
1370
+ };
1371
+ }
1372
+ }
1373
+
1374
+ // Inject extension env vars only when caller has permission
1375
+ for (const ext of this.extensionRegistry.list()) {
1376
+ if (ext.envVars.length === 0) continue;
1377
+ if (
1378
+ ext.permission &&
1379
+ !hasPermission(this.db, spaceId, callerRole, ext.name)
1380
+ )
1381
+ continue;
1382
+ for (const envDef of ext.envVars) {
1383
+ const value = process.env[envDef.from];
1384
+ if (value) {
1385
+ const containerKey =
1386
+ envDef.as ?? envDef.from.replace(/^MERCURY_/, "");
1387
+ extraEnv = { ...extraEnv, [containerKey]: value };
1388
+ }
1389
+ }
1390
+ }
1391
+ }
1392
+
1393
+ // Inject active model as single-leg override (eliminates automatic fallback)
1394
+ const activeModelRaw = this.db.getSpaceConfig(spaceId, "model.active");
1395
+ if (activeModelRaw) {
1396
+ const colonIdx = activeModelRaw.indexOf(":");
1397
+ if (colonIdx > 0) {
1398
+ const provider = activeModelRaw.slice(0, colonIdx);
1399
+ const model = activeModelRaw.slice(colonIdx + 1);
1400
+ const legIdx = this.config.resolvedModelChain.findIndex(
1401
+ (l) => l.provider === provider && l.model === model,
1402
+ );
1403
+ if (legIdx >= 0) {
1404
+ extraEnv = {
1405
+ ...extraEnv,
1406
+ MODEL_CHAIN: JSON.stringify([
1407
+ this.config.resolvedModelChain[legIdx],
1408
+ ]),
1409
+ MODEL_CHAIN_CAPABILITIES: JSON.stringify([
1410
+ this.config.resolvedModelChainCapabilities[legIdx],
1411
+ ]),
1412
+ };
1413
+ } else {
1414
+ logger.warn(
1415
+ "model.active references unknown leg, ignoring override",
1416
+ {
1417
+ spaceId,
1418
+ activeModelRaw,
1419
+ },
1420
+ );
1421
+ }
1422
+ }
1423
+ }
1424
+
1425
+ const startTime = Date.now();
1426
+
1427
+ const preferences = this.db.listSpacePreferences(spaceId).map((p) => ({
1428
+ key: p.key,
1429
+ value: p.value,
1430
+ }));
1431
+
1432
+ let containerResult: ContainerResult;
1433
+ try {
1434
+ containerResult = await this.containerRunner.replyWithRetry({
1435
+ spaceId,
1436
+ spaceWorkspace: workspace,
1437
+ messages: history,
1438
+ anchorMessages,
1439
+ prompt: finalPrompt,
1440
+ callerId,
1441
+ callerRole,
1442
+ authorName,
1443
+ attachments,
1444
+ preferences,
1445
+ extraEnv,
1446
+ claimedEnvSources: this.extensionRegistry?.getClaimedEnvSources(),
1447
+ });
1448
+ } catch (err) {
1449
+ this.db.updateMessageRunMeta(userMessageId, userTurnRunMeta(undefined));
1450
+ throw err;
1451
+ }
1452
+
1453
+ const durationMs = Date.now() - startTime;
1454
+
1455
+ // Emit after_container hook
1456
+ if (this.hooks && this.extensionCtx) {
1457
+ const hookResult = await this.hooks.emitAfterContainer(
1458
+ {
1459
+ spaceId,
1460
+ workspace,
1461
+ callerId,
1462
+ prompt: finalPrompt,
1463
+ reply: containerResult.reply,
1464
+ durationMs,
1465
+ },
1466
+ this.extensionCtx,
1467
+ );
1468
+ if (hookResult?.suppress) {
1469
+ this.db.updateMessageRunMeta(
1470
+ userMessageId,
1471
+ userTurnRunMeta(containerResult.usage),
1472
+ );
1473
+ return { reply: "", files: [] };
1474
+ }
1475
+ if (hookResult?.reply !== undefined) {
1476
+ containerResult.reply = hookResult.reply;
1477
+ }
1478
+ if (hookResult?.files?.length) {
1479
+ containerResult.files = [
1480
+ ...containerResult.files,
1481
+ ...hookResult.files,
1482
+ ];
1483
+ }
1484
+ }
1485
+
1486
+ const assistantMessageId = this.db.addMessage(
1487
+ spaceId,
1488
+ "assistant",
1489
+ containerResult.reply,
1490
+ undefined,
1491
+ userMessageId, // reply chain: assistant replies to user message
1492
+ );
1493
+
1494
+ if (containerResult.usage) {
1495
+ this.db.recordUsage(spaceId, containerResult.usage);
1496
+ } else {
1497
+ logger.debug(
1498
+ "Container run finished without token usage (old agent image, non-JSON pi output, or zero reported usage)",
1499
+ { spaceId },
1500
+ );
1501
+ }
1502
+
1503
+ this.db.updateMessageRunMeta(
1504
+ userMessageId,
1505
+ userTurnRunMeta(containerResult.usage),
1506
+ );
1507
+
1508
+ containerResult.assistantMessageId = assistantMessageId;
1509
+ return containerResult;
1510
+ });
1511
+ }
1512
+
1513
+ /**
1514
+ * Record the platform message ID for an outbound assistant message.
1515
+ * Called by the handler after sendReply() returns the platform ID.
1516
+ */
1517
+ recordOutboundPlatformId(
1518
+ assistantMessageId: number,
1519
+ platform: string,
1520
+ conversationExternalId: string,
1521
+ platformMessageId: string,
1522
+ ): void {
1523
+ this.db.addPlatformMessageId(
1524
+ assistantMessageId,
1525
+ platform,
1526
+ conversationExternalId,
1527
+ platformMessageId,
1528
+ );
1529
+ }
1530
+ }