mercury-agent 0.4.28 → 0.5.0

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