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,540 +1,540 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import path from "node:path";
3
- import { parse as parseYaml } from "yaml";
4
- import { z } from "zod";
5
- import {
6
- MAX_MODEL_CHAIN_LEGS,
7
- parseModelLegsArray,
8
- } from "./config-model-chain.js";
9
-
10
- /** Env-only: never loaded from mercury.yaml (secrets). */
11
- const SECRET_SCHEMA_KEYS = new Set([
12
- "apiSecret",
13
- "callerTokenKey",
14
- "chatApiKey",
15
- "consoleUrl",
16
- "consoleInternalSecret",
17
- "discordGatewaySecret",
18
- "azureSpeechKey",
19
- ]);
20
-
21
- const modelLegYamlSchema = z.object({
22
- provider: z.string().min(1),
23
- model: z.string().min(1),
24
- });
25
-
26
- const mercuryFileSchema = z
27
- .object({
28
- server: z
29
- .object({
30
- port: z.number().int().min(1).max(65535).optional(),
31
- bot_username: z.string().optional(),
32
- })
33
- .strip()
34
- .optional(),
35
-
36
- model: z
37
- .object({
38
- chain: z.array(modelLegYamlSchema).max(MAX_MODEL_CHAIN_LEGS).optional(),
39
- provider: z.string().optional(),
40
- model: z.string().optional(),
41
- fallback_provider: z.string().optional(),
42
- fallback: z.string().optional(),
43
- max_retries_per_leg: z.number().int().min(0).max(5).optional(),
44
- chain_budget_ms: z
45
- .number()
46
- .int()
47
- .min(5000)
48
- .max(55 * 60 * 1000)
49
- .optional(),
50
- capabilities: z.record(z.string(), z.unknown()).optional(),
51
- })
52
- .strip()
53
- .optional(),
54
-
55
- /** Top-level alias for `model.chain` */
56
- model_chain: z
57
- .array(modelLegYamlSchema)
58
- .max(MAX_MODEL_CHAIN_LEGS)
59
- .optional(),
60
-
61
- ingress: z
62
- .object({
63
- discord: z.boolean().optional(),
64
- slack: z.boolean().optional(),
65
- teams: z.boolean().optional(),
66
- whatsapp: z.boolean().optional(),
67
- telegram: z.boolean().optional(),
68
- })
69
- .strip()
70
- .optional(),
71
-
72
- runtime: z
73
- .object({
74
- data_dir: z.string().optional(),
75
- auth_path: z.string().optional(),
76
- whatsapp_auth_dir: z.string().optional(),
77
- max_concurrency: z.number().int().min(1).max(32).optional(),
78
- log_level: z
79
- .enum(["debug", "info", "warn", "error", "silent"])
80
- .optional(),
81
- log_format: z.enum(["text", "json"]).optional(),
82
- rate_limit_per_user: z.number().int().min(1).max(1000).optional(),
83
- rate_limit_window_ms: z
84
- .number()
85
- .int()
86
- .min(1000)
87
- .max(60 * 60 * 1000)
88
- .optional(),
89
- rate_limit_daily_member: z.number().int().min(0).max(10000).optional(),
90
- rate_limit_daily_admin: z.number().int().min(0).max(10000).optional(),
91
- })
92
- .strip()
93
- .optional(),
94
-
95
- scheduling: z
96
- .object({
97
- default_timezone: z.string().optional(),
98
- })
99
- .strip()
100
- .optional(),
101
-
102
- trigger: z
103
- .object({
104
- patterns: z.string().optional(),
105
- match: z.string().optional(),
106
- })
107
- .strip()
108
- .optional(),
109
-
110
- context: z
111
- .object({
112
- mode: z.enum(["clear", "context"]).optional(),
113
- window_size: z.number().int().min(1).max(50).optional(),
114
- reply_chain_depth: z.number().int().min(1).max(50).optional(),
115
- })
116
- .strip()
117
- .optional(),
118
-
119
- agent: z
120
- .object({
121
- image: z.string().optional(),
122
- container_timeout_ms: z
123
- .number()
124
- .int()
125
- .min(10_000)
126
- .max(60 * 60 * 1000)
127
- .optional(),
128
- container_bwrap_docker_compat: z.boolean().optional(),
129
- override_pi_system_prompt: z.boolean().optional(),
130
- })
131
- .strip()
132
- .optional(),
133
-
134
- discord: z
135
- .object({
136
- gateway_duration_ms: z
137
- .number()
138
- .int()
139
- .min(60_000)
140
- .max(60 * 60 * 1000)
141
- .optional(),
142
- })
143
- .strip()
144
- .optional(),
145
-
146
- telegram: z
147
- .object({
148
- format_enabled: z.boolean().optional(),
149
- })
150
- .strip()
151
- .optional(),
152
-
153
- media: z
154
- .object({
155
- enabled: z.boolean().optional(),
156
- max_size_mb: z.number().min(1).max(100).optional(),
157
- })
158
- .strip()
159
- .optional(),
160
-
161
- permissions: z
162
- .object({
163
- admins: z.string().optional(),
164
- })
165
- .strip()
166
- .optional(),
167
-
168
- dm_auto_space: z
169
- .object({
170
- enabled: z.boolean().optional(),
171
- admin_ids: z.array(z.string()).optional(),
172
- default_system_prompt: z.string().optional(),
173
- default_member_permissions: z.string().optional(),
174
- })
175
- .strip()
176
- .optional(),
177
- })
178
- .strip();
179
-
180
- type MercuryFile = z.infer<typeof mercuryFileSchema>;
181
-
182
- export type RawMercuryConfigInput = Record<string, unknown>;
183
-
184
- const KNOWN_TOP_KEYS = new Set([
185
- "server",
186
- "model",
187
- "model_chain",
188
- "ingress",
189
- "runtime",
190
- "scheduling",
191
- "trigger",
192
- "context",
193
- "agent",
194
- "discord",
195
- "telegram",
196
- "media",
197
- "permissions",
198
- "dm_auto_space",
199
- ]);
200
-
201
- const KNOWN_SECTION_KEYS: Record<string, Set<string>> = {
202
- server: new Set(["port", "bot_username"]),
203
- model: new Set([
204
- "chain",
205
- "provider",
206
- "model",
207
- "fallback_provider",
208
- "fallback",
209
- "max_retries_per_leg",
210
- "chain_budget_ms",
211
- "capabilities",
212
- ]),
213
- ingress: new Set(["discord", "slack", "teams", "whatsapp", "telegram"]),
214
- runtime: new Set([
215
- "data_dir",
216
- "auth_path",
217
- "whatsapp_auth_dir",
218
- "max_concurrency",
219
- "log_level",
220
- "log_format",
221
- "rate_limit_per_user",
222
- "rate_limit_window_ms",
223
- "rate_limit_daily_member",
224
- "rate_limit_daily_admin",
225
- ]),
226
- scheduling: new Set(["default_timezone"]),
227
- trigger: new Set(["patterns", "match"]),
228
- context: new Set(["mode", "window_size", "reply_chain_depth"]),
229
- agent: new Set([
230
- "image",
231
- "container_timeout_ms",
232
- "container_bwrap_docker_compat",
233
- "override_pi_system_prompt",
234
- ]),
235
- discord: new Set(["gateway_duration_ms"]),
236
- telegram: new Set(["format_enabled"]),
237
- media: new Set(["enabled", "max_size_mb"]),
238
- permissions: new Set(["admins"]),
239
- dm_auto_space: new Set([
240
- "enabled",
241
- "admin_ids",
242
- "default_system_prompt",
243
- "default_member_permissions",
244
- ]),
245
- };
246
-
247
- function warnUnknownKeys(
248
- rawYaml: Record<string, unknown>,
249
- configPath: string,
250
- log: (msg: string) => void,
251
- ): void {
252
- for (const key of Object.keys(rawYaml)) {
253
- if (!KNOWN_TOP_KEYS.has(key)) {
254
- log(
255
- `[WARN] Unknown key "${key}" in ${configPath} — ignored. Check spelling or update mercury-agent.`,
256
- );
257
- continue;
258
- }
259
- const section = rawYaml[key];
260
- const knownKeys = KNOWN_SECTION_KEYS[key];
261
- if (
262
- knownKeys &&
263
- section != null &&
264
- typeof section === "object" &&
265
- !Array.isArray(section)
266
- ) {
267
- for (const subKey of Object.keys(section)) {
268
- if (!knownKeys.has(subKey)) {
269
- log(
270
- `[WARN] Unknown key "${key}.${subKey}" in ${configPath} — ignored. Check spelling or update mercury-agent.`,
271
- );
272
- }
273
- }
274
- }
275
- }
276
- }
277
-
278
- function resolveConfigPath(cwd: string): string | null {
279
- const explicit = process.env.MERCURY_CONFIG_FILE;
280
- if (explicit !== undefined) {
281
- const t = explicit.trim();
282
- if (t === "" || t.toLowerCase() === "none") return null;
283
- return path.isAbsolute(t) ? t : path.join(cwd, t);
284
- }
285
- const yml = path.join(cwd, "mercury.yaml");
286
- if (existsSync(yml)) return yml;
287
- const yml2 = path.join(cwd, "mercury.yml");
288
- if (existsSync(yml2)) return yml2;
289
- return null;
290
- }
291
-
292
- function flattenMercuryFile(f: MercuryFile): RawMercuryConfigInput {
293
- const o: RawMercuryConfigInput = {};
294
-
295
- if (f.server?.port != null) o.port = f.server.port;
296
- if (f.server?.bot_username != null) o.botUsername = f.server.bot_username;
297
-
298
- const chainFromModel = f.model?.chain;
299
- const chainTop = f.model_chain;
300
- const chainRaw = chainFromModel ?? chainTop;
301
- if (chainRaw != null && chainRaw.length > 0) {
302
- const legs = parseModelLegsArray(chainRaw, "mercury.yaml model chain");
303
- o.modelChain = JSON.stringify(legs);
304
- }
305
- if (f.model?.provider != null) o.modelProvider = f.model.provider;
306
- if (f.model?.model != null) o.model = f.model.model;
307
- if (f.model?.fallback_provider != null) {
308
- o.modelFallbackProvider = f.model.fallback_provider;
309
- }
310
- if (f.model?.fallback != null) o.modelFallback = f.model.fallback;
311
- if (f.model?.max_retries_per_leg != null) {
312
- o.modelMaxRetriesPerLeg = f.model.max_retries_per_leg;
313
- }
314
- if (f.model?.chain_budget_ms != null) {
315
- o.modelChainBudgetMs = f.model.chain_budget_ms;
316
- }
317
- if (f.model?.capabilities != null) {
318
- o.modelCapabilitiesEnv = JSON.stringify(f.model.capabilities);
319
- }
320
-
321
- if (f.ingress?.discord != null) o.enableDiscord = f.ingress.discord;
322
- if (f.ingress?.slack != null) o.enableSlack = f.ingress.slack;
323
- if (f.ingress?.teams != null) o.enableTeams = f.ingress.teams;
324
- if (f.ingress?.whatsapp != null) o.enableWhatsApp = f.ingress.whatsapp;
325
- if (f.ingress?.telegram != null) o.enableTelegram = f.ingress.telegram;
326
-
327
- if (f.runtime?.data_dir != null) o.dataDir = f.runtime.data_dir;
328
- if (f.runtime?.auth_path != null) o.authPath = f.runtime.auth_path;
329
- if (f.runtime?.whatsapp_auth_dir != null) {
330
- o.whatsappAuthDir = f.runtime.whatsapp_auth_dir;
331
- }
332
- if (f.runtime?.max_concurrency != null) {
333
- o.maxConcurrency = f.runtime.max_concurrency;
334
- }
335
- if (f.runtime?.log_level != null) o.logLevel = f.runtime.log_level;
336
- if (f.runtime?.log_format != null) o.logFormat = f.runtime.log_format;
337
- if (f.runtime?.rate_limit_per_user != null) {
338
- o.rateLimitPerUser = f.runtime.rate_limit_per_user;
339
- }
340
- if (f.runtime?.rate_limit_window_ms != null) {
341
- o.rateLimitWindowMs = f.runtime.rate_limit_window_ms;
342
- }
343
- if (f.runtime?.rate_limit_daily_member != null) {
344
- o.rateLimitDailyMember = f.runtime.rate_limit_daily_member;
345
- }
346
- if (f.runtime?.rate_limit_daily_admin != null) {
347
- o.rateLimitDailyAdmin = f.runtime.rate_limit_daily_admin;
348
- }
349
-
350
- if (f.scheduling?.default_timezone != null) {
351
- o.defaultTimezone = f.scheduling.default_timezone;
352
- }
353
-
354
- if (f.trigger?.patterns != null) o.triggerPatterns = f.trigger.patterns;
355
- if (f.trigger?.match != null) o.triggerMatch = f.trigger.match;
356
-
357
- if (f.context?.mode != null) o.contextMode = f.context.mode;
358
- if (f.context?.window_size != null) {
359
- o.contextWindowSize = f.context.window_size;
360
- }
361
- if (f.context?.reply_chain_depth != null) {
362
- o.contextReplyChainDepth = f.context.reply_chain_depth;
363
- }
364
-
365
- if (f.agent?.image != null) o.agentContainerImage = f.agent.image;
366
- if (f.agent?.container_timeout_ms != null) {
367
- o.containerTimeoutMs = f.agent.container_timeout_ms;
368
- }
369
- if (f.agent?.container_bwrap_docker_compat != null) {
370
- o.containerBwrapDockerCompat = f.agent.container_bwrap_docker_compat;
371
- }
372
- if (f.agent?.override_pi_system_prompt != null) {
373
- o.overridePiSystemPrompt = f.agent.override_pi_system_prompt;
374
- }
375
-
376
- if (f.discord?.gateway_duration_ms != null) {
377
- o.discordGatewayDurationMs = f.discord.gateway_duration_ms;
378
- }
379
-
380
- if (f.telegram?.format_enabled != null) {
381
- o.telegramFormatEnabled = f.telegram.format_enabled;
382
- }
383
-
384
- if (f.media?.enabled != null) o.mediaEnabled = f.media.enabled;
385
- if (f.media?.max_size_mb != null) o.mediaMaxSizeMb = f.media.max_size_mb;
386
-
387
- if (f.permissions?.admins != null) o.admins = f.permissions.admins;
388
-
389
- if (f.dm_auto_space?.enabled != null) {
390
- o.dmAutoSpaceEnabled = f.dm_auto_space.enabled;
391
- }
392
- if (f.dm_auto_space?.admin_ids != null) {
393
- o.dmAutoSpaceAdminIds = f.dm_auto_space.admin_ids.join(",");
394
- }
395
- if (f.dm_auto_space?.default_system_prompt != null) {
396
- o.dmAutoSpaceDefaultSystemPrompt = f.dm_auto_space.default_system_prompt;
397
- }
398
- if (f.dm_auto_space?.default_member_permissions != null) {
399
- o.dmAutoSpaceDefaultMemberPermissions =
400
- f.dm_auto_space.default_member_permissions;
401
- }
402
-
403
- return o;
404
- }
405
-
406
- /** camelCase schema key → MERCURY_* env name */
407
- const CAMEL_TO_ENV: Record<string, string> = {
408
- logLevel: "MERCURY_LOG_LEVEL",
409
- logFormat: "MERCURY_LOG_FORMAT",
410
- modelProvider: "MERCURY_MODEL_PROVIDER",
411
- model: "MERCURY_MODEL",
412
- modelFallbackProvider: "MERCURY_MODEL_FALLBACK_PROVIDER",
413
- modelFallback: "MERCURY_MODEL_FALLBACK",
414
- modelChain: "MERCURY_MODEL_CHAIN",
415
- modelMaxRetriesPerLeg: "MERCURY_MODEL_MAX_RETRIES_PER_LEG",
416
- modelChainBudgetMs: "MERCURY_MODEL_CHAIN_BUDGET_MS",
417
- modelCapabilitiesEnv: "MERCURY_MODEL_CAPABILITIES",
418
- triggerPatterns: "MERCURY_TRIGGER_PATTERNS",
419
- triggerMatch: "MERCURY_TRIGGER_MATCH",
420
- contextMode: "MERCURY_CONTEXT_MODE",
421
- contextWindowSize: "MERCURY_CONTEXT_WINDOW_SIZE",
422
- contextReplyChainDepth: "MERCURY_CONTEXT_REPLY_CHAIN_DEPTH",
423
- dataDir: "MERCURY_DATA_DIR",
424
- maxDiskMb: "MERCURY_MAX_DISK_MB",
425
- inboxTtlDays: "MERCURY_INBOX_TTL_DAYS",
426
- outboxTtlDays: "MERCURY_OUTBOX_TTL_DAYS",
427
- cleanupIntervalMs: "MERCURY_CLEANUP_INTERVAL_MS",
428
- authPath: "MERCURY_AUTH_PATH",
429
- whatsappAuthDir: "MERCURY_WHATSAPP_AUTH_DIR",
430
- agentContainerImage: "MERCURY_AGENT_IMAGE",
431
- containerTimeoutMs: "MERCURY_CONTAINER_TIMEOUT_MS",
432
- containerRuntime: "MERCURY_CONTAINER_RUNTIME",
433
- containerNetwork: "MERCURY_CONTAINER_NETWORK",
434
- containerApiHost: "MERCURY_CONTAINER_API_HOST",
435
- containerBwrapDockerCompat: "MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT",
436
- overridePiSystemPrompt: "MERCURY_OVERRIDE_PI_SYSTEM_PROMPT",
437
- maxConcurrency: "MERCURY_MAX_CONCURRENCY",
438
- rateLimitPerUser: "MERCURY_RATE_LIMIT_PER_USER",
439
- rateLimitWindowMs: "MERCURY_RATE_LIMIT_WINDOW_MS",
440
- port: "MERCURY_PORT",
441
- botUsername: "MERCURY_BOT_USERNAME",
442
- enableDiscord: "MERCURY_ENABLE_DISCORD",
443
- discordGatewayDurationMs: "MERCURY_DISCORD_GATEWAY_DURATION_MS",
444
- discordGatewaySecret: "MERCURY_DISCORD_GATEWAY_SECRET",
445
- enableSlack: "MERCURY_ENABLE_SLACK",
446
- enableTeams: "MERCURY_ENABLE_TEAMS",
447
- enableWhatsApp: "MERCURY_ENABLE_WHATSAPP",
448
- enableTelegram: "MERCURY_ENABLE_TELEGRAM",
449
- telegramFormatEnabled: "MERCURY_TELEGRAM_FORMAT_ENABLED",
450
- mediaEnabled: "MERCURY_MEDIA_ENABLED",
451
- mediaMaxSizeMb: "MERCURY_MEDIA_MAX_SIZE_MB",
452
- admins: "MERCURY_ADMINS",
453
- profile: "MERCURY_PROFILE",
454
- apiSecret: "MERCURY_API_SECRET",
455
- callerTokenKey: "MERCURY_CALLER_TOKEN_KEY",
456
- chatApiKey: "MERCURY_CHAT_API_KEY",
457
- consoleUrl: "MERCURY_CONSOLE_URL",
458
- consoleUserId: "MERCURY_CONSOLE_USER_ID",
459
- consoleInternalSecret: "MERCURY_CONSOLE_INTERNAL_SECRET",
460
- tsAllowLiveOrders: "MERCURY_TS_ALLOW_LIVE_ORDERS",
461
- ttsProvider: "MERCURY_TTS_PROVIDER",
462
- azureSpeechKey: "MERCURY_AZURE_SPEECH_KEY",
463
- azureSpeechRegion: "MERCURY_AZURE_SPEECH_REGION",
464
- googleApplicationCredentials: "MERCURY_GOOGLE_APPLICATION_CREDENTIALS",
465
- ttsMaxChars: "MERCURY_TTS_MAX_CHARS",
466
- defaultTimezone: "MERCURY_DEFAULT_TIMEZONE",
467
- rateLimitDailyMember: "MERCURY_RATE_LIMIT_DAILY_MEMBER",
468
- rateLimitDailyAdmin: "MERCURY_RATE_LIMIT_DAILY_ADMIN",
469
- dmAutoSpaceEnabled: "MERCURY_DM_AUTO_SPACE_ENABLED",
470
- dmAutoSpaceAdminIds: "MERCURY_DM_AUTO_SPACE_ADMIN_IDS",
471
- dmAutoSpaceDefaultSystemPrompt: "MERCURY_DM_AUTO_SPACE_DEFAULT_SYSTEM_PROMPT",
472
- dmAutoSpaceDefaultMemberPermissions:
473
- "MERCURY_DM_AUTO_SPACE_DEFAULT_MEMBER_PERMISSIONS",
474
- };
475
-
476
- function envValueForSchema(
477
- env: NodeJS.ProcessEnv,
478
- envKey: string,
479
- ): string | undefined {
480
- if (!Object.hasOwn(env, envKey)) return undefined;
481
- return env[envKey];
482
- }
483
-
484
- /**
485
- * Merge optional mercury.yaml with process.env. Env wins whenever the MERCURY_*
486
- * key is present (even if empty). File values are ignored for secret keys.
487
- */
488
- export function mergeRawMercuryConfig(
489
- env: NodeJS.ProcessEnv = process.env,
490
- cwd: string = process.cwd(),
491
- log: (msg: string) => void = console.warn,
492
- ): RawMercuryConfigInput {
493
- const configPath = resolveConfigPath(cwd);
494
- let fromFile: RawMercuryConfigInput = {};
495
-
496
- if (configPath) {
497
- let rawYaml: unknown;
498
- try {
499
- rawYaml = parseYaml(readFileSync(configPath, "utf-8"));
500
- } catch (e) {
501
- const msg = e instanceof Error ? e.message : String(e);
502
- throw new Error(`Failed to read mercury config ${configPath}: ${msg}`);
503
- }
504
- if (rawYaml == null) rawYaml = {};
505
-
506
- if (typeof rawYaml === "object" && !Array.isArray(rawYaml)) {
507
- warnUnknownKeys(rawYaml as Record<string, unknown>, configPath, log);
508
- }
509
-
510
- const parsed = mercuryFileSchema.safeParse(rawYaml);
511
- if (!parsed.success) {
512
- const issues = parsed.error.issues
513
- .map((i) => `${i.path.join(".")}: ${i.message}`)
514
- .join("; ");
515
- throw new Error(`Invalid mercury.yaml at ${configPath}: ${issues}`);
516
- }
517
- fromFile = flattenMercuryFile(parsed.data);
518
- for (const secretKey of SECRET_SCHEMA_KEYS) {
519
- delete fromFile[secretKey];
520
- }
521
- }
522
-
523
- const merged: RawMercuryConfigInput = { ...fromFile };
524
-
525
- for (const [camel, envKey] of Object.entries(CAMEL_TO_ENV)) {
526
- if (envValueForSchema(env, envKey) !== undefined) {
527
- merged[camel] = env[envKey];
528
- }
529
- }
530
-
531
- // Standard GCP env (when MERCURY_GOOGLE_APPLICATION_CREDENTIALS not set)
532
- if (
533
- merged.googleApplicationCredentials == null &&
534
- envValueForSchema(env, "GOOGLE_APPLICATION_CREDENTIALS") !== undefined
535
- ) {
536
- merged.googleApplicationCredentials = env.GOOGLE_APPLICATION_CREDENTIALS;
537
- }
538
-
539
- return merged;
540
- }
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { z } from "zod";
5
+ import {
6
+ MAX_MODEL_CHAIN_LEGS,
7
+ parseModelLegsArray,
8
+ } from "./config-model-chain.js";
9
+
10
+ /** Env-only: never loaded from mercury.yaml (secrets). */
11
+ const SECRET_SCHEMA_KEYS = new Set([
12
+ "apiSecret",
13
+ "callerTokenKey",
14
+ "chatApiKey",
15
+ "consoleUrl",
16
+ "consoleInternalSecret",
17
+ "discordGatewaySecret",
18
+ "azureSpeechKey",
19
+ ]);
20
+
21
+ const modelLegYamlSchema = z.object({
22
+ provider: z.string().min(1),
23
+ model: z.string().min(1),
24
+ });
25
+
26
+ const mercuryFileSchema = z
27
+ .object({
28
+ server: z
29
+ .object({
30
+ port: z.number().int().min(1).max(65535).optional(),
31
+ bot_username: z.string().optional(),
32
+ })
33
+ .strip()
34
+ .optional(),
35
+
36
+ model: z
37
+ .object({
38
+ chain: z.array(modelLegYamlSchema).max(MAX_MODEL_CHAIN_LEGS).optional(),
39
+ provider: z.string().optional(),
40
+ model: z.string().optional(),
41
+ fallback_provider: z.string().optional(),
42
+ fallback: z.string().optional(),
43
+ max_retries_per_leg: z.number().int().min(0).max(5).optional(),
44
+ chain_budget_ms: z
45
+ .number()
46
+ .int()
47
+ .min(5000)
48
+ .max(55 * 60 * 1000)
49
+ .optional(),
50
+ capabilities: z.record(z.string(), z.unknown()).optional(),
51
+ })
52
+ .strip()
53
+ .optional(),
54
+
55
+ /** Top-level alias for `model.chain` */
56
+ model_chain: z
57
+ .array(modelLegYamlSchema)
58
+ .max(MAX_MODEL_CHAIN_LEGS)
59
+ .optional(),
60
+
61
+ ingress: z
62
+ .object({
63
+ discord: z.boolean().optional(),
64
+ slack: z.boolean().optional(),
65
+ teams: z.boolean().optional(),
66
+ whatsapp: z.boolean().optional(),
67
+ telegram: z.boolean().optional(),
68
+ })
69
+ .strip()
70
+ .optional(),
71
+
72
+ runtime: z
73
+ .object({
74
+ data_dir: z.string().optional(),
75
+ auth_path: z.string().optional(),
76
+ whatsapp_auth_dir: z.string().optional(),
77
+ max_concurrency: z.number().int().min(1).max(32).optional(),
78
+ log_level: z
79
+ .enum(["debug", "info", "warn", "error", "silent"])
80
+ .optional(),
81
+ log_format: z.enum(["text", "json"]).optional(),
82
+ rate_limit_per_user: z.number().int().min(1).max(1000).optional(),
83
+ rate_limit_window_ms: z
84
+ .number()
85
+ .int()
86
+ .min(1000)
87
+ .max(60 * 60 * 1000)
88
+ .optional(),
89
+ rate_limit_daily_member: z.number().int().min(0).max(10000).optional(),
90
+ rate_limit_daily_admin: z.number().int().min(0).max(10000).optional(),
91
+ })
92
+ .strip()
93
+ .optional(),
94
+
95
+ scheduling: z
96
+ .object({
97
+ default_timezone: z.string().optional(),
98
+ })
99
+ .strip()
100
+ .optional(),
101
+
102
+ trigger: z
103
+ .object({
104
+ patterns: z.string().optional(),
105
+ match: z.string().optional(),
106
+ })
107
+ .strip()
108
+ .optional(),
109
+
110
+ context: z
111
+ .object({
112
+ mode: z.enum(["clear", "context"]).optional(),
113
+ window_size: z.number().int().min(1).max(50).optional(),
114
+ reply_chain_depth: z.number().int().min(1).max(50).optional(),
115
+ })
116
+ .strip()
117
+ .optional(),
118
+
119
+ agent: z
120
+ .object({
121
+ image: z.string().optional(),
122
+ container_timeout_ms: z
123
+ .number()
124
+ .int()
125
+ .min(10_000)
126
+ .max(60 * 60 * 1000)
127
+ .optional(),
128
+ container_bwrap_docker_compat: z.boolean().optional(),
129
+ override_pi_system_prompt: z.boolean().optional(),
130
+ })
131
+ .strip()
132
+ .optional(),
133
+
134
+ discord: z
135
+ .object({
136
+ gateway_duration_ms: z
137
+ .number()
138
+ .int()
139
+ .min(60_000)
140
+ .max(60 * 60 * 1000)
141
+ .optional(),
142
+ })
143
+ .strip()
144
+ .optional(),
145
+
146
+ telegram: z
147
+ .object({
148
+ format_enabled: z.boolean().optional(),
149
+ })
150
+ .strip()
151
+ .optional(),
152
+
153
+ media: z
154
+ .object({
155
+ enabled: z.boolean().optional(),
156
+ max_size_mb: z.number().min(1).max(100).optional(),
157
+ })
158
+ .strip()
159
+ .optional(),
160
+
161
+ permissions: z
162
+ .object({
163
+ admins: z.string().optional(),
164
+ })
165
+ .strip()
166
+ .optional(),
167
+
168
+ dm_auto_space: z
169
+ .object({
170
+ enabled: z.boolean().optional(),
171
+ admin_ids: z.array(z.string()).optional(),
172
+ default_system_prompt: z.string().optional(),
173
+ default_member_permissions: z.string().optional(),
174
+ })
175
+ .strip()
176
+ .optional(),
177
+ })
178
+ .strip();
179
+
180
+ type MercuryFile = z.infer<typeof mercuryFileSchema>;
181
+
182
+ export type RawMercuryConfigInput = Record<string, unknown>;
183
+
184
+ const KNOWN_TOP_KEYS = new Set([
185
+ "server",
186
+ "model",
187
+ "model_chain",
188
+ "ingress",
189
+ "runtime",
190
+ "scheduling",
191
+ "trigger",
192
+ "context",
193
+ "agent",
194
+ "discord",
195
+ "telegram",
196
+ "media",
197
+ "permissions",
198
+ "dm_auto_space",
199
+ ]);
200
+
201
+ const KNOWN_SECTION_KEYS: Record<string, Set<string>> = {
202
+ server: new Set(["port", "bot_username"]),
203
+ model: new Set([
204
+ "chain",
205
+ "provider",
206
+ "model",
207
+ "fallback_provider",
208
+ "fallback",
209
+ "max_retries_per_leg",
210
+ "chain_budget_ms",
211
+ "capabilities",
212
+ ]),
213
+ ingress: new Set(["discord", "slack", "teams", "whatsapp", "telegram"]),
214
+ runtime: new Set([
215
+ "data_dir",
216
+ "auth_path",
217
+ "whatsapp_auth_dir",
218
+ "max_concurrency",
219
+ "log_level",
220
+ "log_format",
221
+ "rate_limit_per_user",
222
+ "rate_limit_window_ms",
223
+ "rate_limit_daily_member",
224
+ "rate_limit_daily_admin",
225
+ ]),
226
+ scheduling: new Set(["default_timezone"]),
227
+ trigger: new Set(["patterns", "match"]),
228
+ context: new Set(["mode", "window_size", "reply_chain_depth"]),
229
+ agent: new Set([
230
+ "image",
231
+ "container_timeout_ms",
232
+ "container_bwrap_docker_compat",
233
+ "override_pi_system_prompt",
234
+ ]),
235
+ discord: new Set(["gateway_duration_ms"]),
236
+ telegram: new Set(["format_enabled"]),
237
+ media: new Set(["enabled", "max_size_mb"]),
238
+ permissions: new Set(["admins"]),
239
+ dm_auto_space: new Set([
240
+ "enabled",
241
+ "admin_ids",
242
+ "default_system_prompt",
243
+ "default_member_permissions",
244
+ ]),
245
+ };
246
+
247
+ function warnUnknownKeys(
248
+ rawYaml: Record<string, unknown>,
249
+ configPath: string,
250
+ log: (msg: string) => void,
251
+ ): void {
252
+ for (const key of Object.keys(rawYaml)) {
253
+ if (!KNOWN_TOP_KEYS.has(key)) {
254
+ log(
255
+ `[WARN] Unknown key "${key}" in ${configPath} — ignored. Check spelling or update mercury-agent.`,
256
+ );
257
+ continue;
258
+ }
259
+ const section = rawYaml[key];
260
+ const knownKeys = KNOWN_SECTION_KEYS[key];
261
+ if (
262
+ knownKeys &&
263
+ section != null &&
264
+ typeof section === "object" &&
265
+ !Array.isArray(section)
266
+ ) {
267
+ for (const subKey of Object.keys(section)) {
268
+ if (!knownKeys.has(subKey)) {
269
+ log(
270
+ `[WARN] Unknown key "${key}.${subKey}" in ${configPath} — ignored. Check spelling or update mercury-agent.`,
271
+ );
272
+ }
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ function resolveConfigPath(cwd: string): string | null {
279
+ const explicit = process.env.MERCURY_CONFIG_FILE;
280
+ if (explicit !== undefined) {
281
+ const t = explicit.trim();
282
+ if (t === "" || t.toLowerCase() === "none") return null;
283
+ return path.isAbsolute(t) ? t : path.join(cwd, t);
284
+ }
285
+ const yml = path.join(cwd, "mercury.yaml");
286
+ if (existsSync(yml)) return yml;
287
+ const yml2 = path.join(cwd, "mercury.yml");
288
+ if (existsSync(yml2)) return yml2;
289
+ return null;
290
+ }
291
+
292
+ function flattenMercuryFile(f: MercuryFile): RawMercuryConfigInput {
293
+ const o: RawMercuryConfigInput = {};
294
+
295
+ if (f.server?.port != null) o.port = f.server.port;
296
+ if (f.server?.bot_username != null) o.botUsername = f.server.bot_username;
297
+
298
+ const chainFromModel = f.model?.chain;
299
+ const chainTop = f.model_chain;
300
+ const chainRaw = chainFromModel ?? chainTop;
301
+ if (chainRaw != null && chainRaw.length > 0) {
302
+ const legs = parseModelLegsArray(chainRaw, "mercury.yaml model chain");
303
+ o.modelChain = JSON.stringify(legs);
304
+ }
305
+ if (f.model?.provider != null) o.modelProvider = f.model.provider;
306
+ if (f.model?.model != null) o.model = f.model.model;
307
+ if (f.model?.fallback_provider != null) {
308
+ o.modelFallbackProvider = f.model.fallback_provider;
309
+ }
310
+ if (f.model?.fallback != null) o.modelFallback = f.model.fallback;
311
+ if (f.model?.max_retries_per_leg != null) {
312
+ o.modelMaxRetriesPerLeg = f.model.max_retries_per_leg;
313
+ }
314
+ if (f.model?.chain_budget_ms != null) {
315
+ o.modelChainBudgetMs = f.model.chain_budget_ms;
316
+ }
317
+ if (f.model?.capabilities != null) {
318
+ o.modelCapabilitiesEnv = JSON.stringify(f.model.capabilities);
319
+ }
320
+
321
+ if (f.ingress?.discord != null) o.enableDiscord = f.ingress.discord;
322
+ if (f.ingress?.slack != null) o.enableSlack = f.ingress.slack;
323
+ if (f.ingress?.teams != null) o.enableTeams = f.ingress.teams;
324
+ if (f.ingress?.whatsapp != null) o.enableWhatsApp = f.ingress.whatsapp;
325
+ if (f.ingress?.telegram != null) o.enableTelegram = f.ingress.telegram;
326
+
327
+ if (f.runtime?.data_dir != null) o.dataDir = f.runtime.data_dir;
328
+ if (f.runtime?.auth_path != null) o.authPath = f.runtime.auth_path;
329
+ if (f.runtime?.whatsapp_auth_dir != null) {
330
+ o.whatsappAuthDir = f.runtime.whatsapp_auth_dir;
331
+ }
332
+ if (f.runtime?.max_concurrency != null) {
333
+ o.maxConcurrency = f.runtime.max_concurrency;
334
+ }
335
+ if (f.runtime?.log_level != null) o.logLevel = f.runtime.log_level;
336
+ if (f.runtime?.log_format != null) o.logFormat = f.runtime.log_format;
337
+ if (f.runtime?.rate_limit_per_user != null) {
338
+ o.rateLimitPerUser = f.runtime.rate_limit_per_user;
339
+ }
340
+ if (f.runtime?.rate_limit_window_ms != null) {
341
+ o.rateLimitWindowMs = f.runtime.rate_limit_window_ms;
342
+ }
343
+ if (f.runtime?.rate_limit_daily_member != null) {
344
+ o.rateLimitDailyMember = f.runtime.rate_limit_daily_member;
345
+ }
346
+ if (f.runtime?.rate_limit_daily_admin != null) {
347
+ o.rateLimitDailyAdmin = f.runtime.rate_limit_daily_admin;
348
+ }
349
+
350
+ if (f.scheduling?.default_timezone != null) {
351
+ o.defaultTimezone = f.scheduling.default_timezone;
352
+ }
353
+
354
+ if (f.trigger?.patterns != null) o.triggerPatterns = f.trigger.patterns;
355
+ if (f.trigger?.match != null) o.triggerMatch = f.trigger.match;
356
+
357
+ if (f.context?.mode != null) o.contextMode = f.context.mode;
358
+ if (f.context?.window_size != null) {
359
+ o.contextWindowSize = f.context.window_size;
360
+ }
361
+ if (f.context?.reply_chain_depth != null) {
362
+ o.contextReplyChainDepth = f.context.reply_chain_depth;
363
+ }
364
+
365
+ if (f.agent?.image != null) o.agentContainerImage = f.agent.image;
366
+ if (f.agent?.container_timeout_ms != null) {
367
+ o.containerTimeoutMs = f.agent.container_timeout_ms;
368
+ }
369
+ if (f.agent?.container_bwrap_docker_compat != null) {
370
+ o.containerBwrapDockerCompat = f.agent.container_bwrap_docker_compat;
371
+ }
372
+ if (f.agent?.override_pi_system_prompt != null) {
373
+ o.overridePiSystemPrompt = f.agent.override_pi_system_prompt;
374
+ }
375
+
376
+ if (f.discord?.gateway_duration_ms != null) {
377
+ o.discordGatewayDurationMs = f.discord.gateway_duration_ms;
378
+ }
379
+
380
+ if (f.telegram?.format_enabled != null) {
381
+ o.telegramFormatEnabled = f.telegram.format_enabled;
382
+ }
383
+
384
+ if (f.media?.enabled != null) o.mediaEnabled = f.media.enabled;
385
+ if (f.media?.max_size_mb != null) o.mediaMaxSizeMb = f.media.max_size_mb;
386
+
387
+ if (f.permissions?.admins != null) o.admins = f.permissions.admins;
388
+
389
+ if (f.dm_auto_space?.enabled != null) {
390
+ o.dmAutoSpaceEnabled = f.dm_auto_space.enabled;
391
+ }
392
+ if (f.dm_auto_space?.admin_ids != null) {
393
+ o.dmAutoSpaceAdminIds = f.dm_auto_space.admin_ids.join(",");
394
+ }
395
+ if (f.dm_auto_space?.default_system_prompt != null) {
396
+ o.dmAutoSpaceDefaultSystemPrompt = f.dm_auto_space.default_system_prompt;
397
+ }
398
+ if (f.dm_auto_space?.default_member_permissions != null) {
399
+ o.dmAutoSpaceDefaultMemberPermissions =
400
+ f.dm_auto_space.default_member_permissions;
401
+ }
402
+
403
+ return o;
404
+ }
405
+
406
+ /** camelCase schema key → MERCURY_* env name */
407
+ const CAMEL_TO_ENV: Record<string, string> = {
408
+ logLevel: "MERCURY_LOG_LEVEL",
409
+ logFormat: "MERCURY_LOG_FORMAT",
410
+ modelProvider: "MERCURY_MODEL_PROVIDER",
411
+ model: "MERCURY_MODEL",
412
+ modelFallbackProvider: "MERCURY_MODEL_FALLBACK_PROVIDER",
413
+ modelFallback: "MERCURY_MODEL_FALLBACK",
414
+ modelChain: "MERCURY_MODEL_CHAIN",
415
+ modelMaxRetriesPerLeg: "MERCURY_MODEL_MAX_RETRIES_PER_LEG",
416
+ modelChainBudgetMs: "MERCURY_MODEL_CHAIN_BUDGET_MS",
417
+ modelCapabilitiesEnv: "MERCURY_MODEL_CAPABILITIES",
418
+ triggerPatterns: "MERCURY_TRIGGER_PATTERNS",
419
+ triggerMatch: "MERCURY_TRIGGER_MATCH",
420
+ contextMode: "MERCURY_CONTEXT_MODE",
421
+ contextWindowSize: "MERCURY_CONTEXT_WINDOW_SIZE",
422
+ contextReplyChainDepth: "MERCURY_CONTEXT_REPLY_CHAIN_DEPTH",
423
+ dataDir: "MERCURY_DATA_DIR",
424
+ maxDiskMb: "MERCURY_MAX_DISK_MB",
425
+ inboxTtlDays: "MERCURY_INBOX_TTL_DAYS",
426
+ outboxTtlDays: "MERCURY_OUTBOX_TTL_DAYS",
427
+ cleanupIntervalMs: "MERCURY_CLEANUP_INTERVAL_MS",
428
+ authPath: "MERCURY_AUTH_PATH",
429
+ whatsappAuthDir: "MERCURY_WHATSAPP_AUTH_DIR",
430
+ agentContainerImage: "MERCURY_AGENT_IMAGE",
431
+ containerTimeoutMs: "MERCURY_CONTAINER_TIMEOUT_MS",
432
+ containerRuntime: "MERCURY_CONTAINER_RUNTIME",
433
+ containerNetwork: "MERCURY_CONTAINER_NETWORK",
434
+ containerApiHost: "MERCURY_CONTAINER_API_HOST",
435
+ containerBwrapDockerCompat: "MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT",
436
+ overridePiSystemPrompt: "MERCURY_OVERRIDE_PI_SYSTEM_PROMPT",
437
+ maxConcurrency: "MERCURY_MAX_CONCURRENCY",
438
+ rateLimitPerUser: "MERCURY_RATE_LIMIT_PER_USER",
439
+ rateLimitWindowMs: "MERCURY_RATE_LIMIT_WINDOW_MS",
440
+ port: "MERCURY_PORT",
441
+ botUsername: "MERCURY_BOT_USERNAME",
442
+ enableDiscord: "MERCURY_ENABLE_DISCORD",
443
+ discordGatewayDurationMs: "MERCURY_DISCORD_GATEWAY_DURATION_MS",
444
+ discordGatewaySecret: "MERCURY_DISCORD_GATEWAY_SECRET",
445
+ enableSlack: "MERCURY_ENABLE_SLACK",
446
+ enableTeams: "MERCURY_ENABLE_TEAMS",
447
+ enableWhatsApp: "MERCURY_ENABLE_WHATSAPP",
448
+ enableTelegram: "MERCURY_ENABLE_TELEGRAM",
449
+ telegramFormatEnabled: "MERCURY_TELEGRAM_FORMAT_ENABLED",
450
+ mediaEnabled: "MERCURY_MEDIA_ENABLED",
451
+ mediaMaxSizeMb: "MERCURY_MEDIA_MAX_SIZE_MB",
452
+ admins: "MERCURY_ADMINS",
453
+ profile: "MERCURY_PROFILE",
454
+ apiSecret: "MERCURY_API_SECRET",
455
+ callerTokenKey: "MERCURY_CALLER_TOKEN_KEY",
456
+ chatApiKey: "MERCURY_CHAT_API_KEY",
457
+ consoleUrl: "MERCURY_CONSOLE_URL",
458
+ consoleUserId: "MERCURY_CONSOLE_USER_ID",
459
+ consoleInternalSecret: "MERCURY_CONSOLE_INTERNAL_SECRET",
460
+ tsAllowLiveOrders: "MERCURY_TS_ALLOW_LIVE_ORDERS",
461
+ ttsProvider: "MERCURY_TTS_PROVIDER",
462
+ azureSpeechKey: "MERCURY_AZURE_SPEECH_KEY",
463
+ azureSpeechRegion: "MERCURY_AZURE_SPEECH_REGION",
464
+ googleApplicationCredentials: "MERCURY_GOOGLE_APPLICATION_CREDENTIALS",
465
+ ttsMaxChars: "MERCURY_TTS_MAX_CHARS",
466
+ defaultTimezone: "MERCURY_DEFAULT_TIMEZONE",
467
+ rateLimitDailyMember: "MERCURY_RATE_LIMIT_DAILY_MEMBER",
468
+ rateLimitDailyAdmin: "MERCURY_RATE_LIMIT_DAILY_ADMIN",
469
+ dmAutoSpaceEnabled: "MERCURY_DM_AUTO_SPACE_ENABLED",
470
+ dmAutoSpaceAdminIds: "MERCURY_DM_AUTO_SPACE_ADMIN_IDS",
471
+ dmAutoSpaceDefaultSystemPrompt: "MERCURY_DM_AUTO_SPACE_DEFAULT_SYSTEM_PROMPT",
472
+ dmAutoSpaceDefaultMemberPermissions:
473
+ "MERCURY_DM_AUTO_SPACE_DEFAULT_MEMBER_PERMISSIONS",
474
+ };
475
+
476
+ function envValueForSchema(
477
+ env: NodeJS.ProcessEnv,
478
+ envKey: string,
479
+ ): string | undefined {
480
+ if (!Object.hasOwn(env, envKey)) return undefined;
481
+ return env[envKey];
482
+ }
483
+
484
+ /**
485
+ * Merge optional mercury.yaml with process.env. Env wins whenever the MERCURY_*
486
+ * key is present (even if empty). File values are ignored for secret keys.
487
+ */
488
+ export function mergeRawMercuryConfig(
489
+ env: NodeJS.ProcessEnv = process.env,
490
+ cwd: string = process.cwd(),
491
+ log: (msg: string) => void = console.warn,
492
+ ): RawMercuryConfigInput {
493
+ const configPath = resolveConfigPath(cwd);
494
+ let fromFile: RawMercuryConfigInput = {};
495
+
496
+ if (configPath) {
497
+ let rawYaml: unknown;
498
+ try {
499
+ rawYaml = parseYaml(readFileSync(configPath, "utf-8"));
500
+ } catch (e) {
501
+ const msg = e instanceof Error ? e.message : String(e);
502
+ throw new Error(`Failed to read mercury config ${configPath}: ${msg}`);
503
+ }
504
+ if (rawYaml == null) rawYaml = {};
505
+
506
+ if (typeof rawYaml === "object" && !Array.isArray(rawYaml)) {
507
+ warnUnknownKeys(rawYaml as Record<string, unknown>, configPath, log);
508
+ }
509
+
510
+ const parsed = mercuryFileSchema.safeParse(rawYaml);
511
+ if (!parsed.success) {
512
+ const issues = parsed.error.issues
513
+ .map((i) => `${i.path.join(".")}: ${i.message}`)
514
+ .join("; ");
515
+ throw new Error(`Invalid mercury.yaml at ${configPath}: ${issues}`);
516
+ }
517
+ fromFile = flattenMercuryFile(parsed.data);
518
+ for (const secretKey of SECRET_SCHEMA_KEYS) {
519
+ delete fromFile[secretKey];
520
+ }
521
+ }
522
+
523
+ const merged: RawMercuryConfigInput = { ...fromFile };
524
+
525
+ for (const [camel, envKey] of Object.entries(CAMEL_TO_ENV)) {
526
+ if (envValueForSchema(env, envKey) !== undefined) {
527
+ merged[camel] = env[envKey];
528
+ }
529
+ }
530
+
531
+ // Standard GCP env (when MERCURY_GOOGLE_APPLICATION_CREDENTIALS not set)
532
+ if (
533
+ merged.googleApplicationCredentials == null &&
534
+ envValueForSchema(env, "GOOGLE_APPLICATION_CREDENTIALS") !== undefined
535
+ ) {
536
+ merged.googleApplicationCredentials = env.GOOGLE_APPLICATION_CREDENTIALS;
537
+ }
538
+
539
+ return merged;
540
+ }