mercury-agent 0.4.7 → 0.4.8

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.
package/src/config.ts CHANGED
@@ -1,316 +1,316 @@
1
- import path from "node:path";
2
- import { z } from "zod";
3
- import {
4
- type ModelCapabilities,
5
- parseModelCapabilitiesEnv,
6
- resolveModelChainCapabilities,
7
- } from "./agent/model-capabilities.js";
8
- import { mergeRawMercuryConfig } from "./config-file.js";
9
- import { parseModelLegsArray } from "./config-model-chain.js";
10
-
11
- /** One model leg in the ordered fallback chain (primary first). */
12
- export type ModelLeg = { provider: string; model: string };
13
-
14
- function parseModelChainJson(raw: string): ModelLeg[] {
15
- let parsed: unknown;
16
- try {
17
- parsed = JSON.parse(raw);
18
- } catch {
19
- throw new Error("MERCURY_MODEL_CHAIN must be valid JSON array");
20
- }
21
- return parseModelLegsArray(parsed, "MERCURY_MODEL_CHAIN");
22
- }
23
-
24
- function resolveModelChain(base: {
25
- modelChain: string | undefined;
26
- modelProvider: string;
27
- model: string;
28
- modelFallbackProvider: string | undefined;
29
- modelFallback: string | undefined;
30
- }): ModelLeg[] {
31
- const trimmed = base.modelChain?.trim();
32
- if (trimmed) {
33
- return parseModelChainJson(trimmed);
34
- }
35
- const legs: ModelLeg[] = [
36
- { provider: base.modelProvider, model: base.model },
37
- ];
38
- const fp = base.modelFallbackProvider?.trim();
39
- const fm = base.modelFallback?.trim();
40
- if (fp && fm) {
41
- legs.push({ provider: fp, model: fm });
42
- }
43
- return legs;
44
- }
45
-
46
- /** Parse boolean from env var strings — case-insensitive "true"/"1" → true, everything else → false */
47
- const booleanFromEnv = z.union([z.boolean(), z.string()]).transform((val) => {
48
- if (typeof val === "boolean") return val;
49
- const lower = val.toLowerCase();
50
- return lower === "true" || lower === "1";
51
- });
52
-
53
- const schema = z.object({
54
- // ─── API Key Mode ───────────────────────────────────────────────────
55
- apiKeyMode: z.enum(["platform", "byok"]).default("platform"),
56
-
57
- // ─── Logging ────────────────────────────────────────────────────────
58
- logLevel: z
59
- .enum(["debug", "info", "warn", "error", "silent"])
60
- .default("info"),
61
- logFormat: z.enum(["text", "json"]).default("text"),
62
-
63
- // ─── AI Model ───────────────────────────────────────────────────────
64
- modelProvider: z.string().default("anthropic"),
65
- model: z.string().default("claude-opus-4-6"),
66
- modelFallbackProvider: z.string().optional(),
67
- modelFallback: z.string().optional(),
68
- /** JSON array of `{ provider, model }`. When set, overrides legacy primary+fallback pair. */
69
- modelChain: z.string().optional(),
70
- /** Extra attempts after the first failure on the same leg (retryable errors only). Default 2 => 3 tries max per leg. */
71
- modelMaxRetriesPerLeg: z.coerce.number().int().min(0).max(5).default(2),
72
- /** Wall-clock budget for the whole chain (ms). Clamped below container timeout. Default 120s. */
73
- modelChainBudgetMs: z.coerce
74
- .number()
75
- .int()
76
- .min(5000)
77
- .max(55 * 60 * 1000)
78
- .default(120_000),
79
- /**
80
- * Optional JSON object overriding model capabilities for all chain legs, e.g.
81
- * `{"tools":false,"vision":true}`. Highest priority over YAML and built-in map.
82
- */
83
- modelCapabilitiesEnv: z.string().optional(),
84
-
85
- // ─── Trigger Behavior ───────────────────────────────────────────────
86
- triggerPatterns: z.string().default("@Pi,Pi"),
87
- triggerMatch: z.string().default("mention"),
88
-
89
- // ─── Context Behavior ───────────────────────────────────────────────
90
- /** Default context mode seeded into the `main` space on first boot. */
91
- contextMode: z.enum(["clear", "context"]).default("context"),
92
- /** Default sliding-window turn count for `context` mode (1–50). Seeded into `main` on first boot. */
93
- contextWindowSize: z.coerce.number().int().min(1).max(50).default(10),
94
- /** Default reply-chain depth for `clear` mode (1–50). Seeded into `main` on first boot. */
95
- contextReplyChainDepth: z.coerce.number().int().min(1).max(50).default(10),
96
-
97
- // ─── Storage ────────────────────────────────────────────────────────
98
- dataDir: z.string().default(".mercury"),
99
- /** Max disk usage in MB for the agent's data directory. Unset = no enforcement (local/self-hosted). */
100
- maxDiskMb: z.coerce.number().int().min(0).optional(),
101
-
102
- // ─── Storage Lifecycle ─────────────────────────────────────────────
103
- /** Days before inbox files are auto-deleted. */
104
- inboxTtlDays: z.coerce.number().min(1).max(365).default(7),
105
- /** Days before outbox files are auto-deleted. */
106
- outboxTtlDays: z.coerce.number().min(1).max(365).default(3),
107
- /** Interval in ms between storage cleanup runs. Default 1 hour. */
108
- cleanupIntervalMs: z.coerce
109
- .number()
110
- .int()
111
- .min(60_000)
112
- .max(86_400_000)
113
- .default(3_600_000),
114
- authPath: z.string().optional(),
115
- /** WhatsApp Baileys auth directory; default `<dataDir>/whatsapp-auth`. */
116
- whatsappAuthDir: z.string().optional(),
117
-
118
- // ─── Container / Agent ──────────────────────────────────────────────
119
- agentContainerImage: z
120
- .string()
121
- .default("ghcr.io/michaelliv/mercury-agent:latest"),
122
- containerTimeoutMs: z.coerce
123
- .number()
124
- .int()
125
- .min(10_000)
126
- .max(60 * 60 * 1000)
127
- .default(5 * 60 * 1000), // 5 minutes
128
- /**
129
- * OCI runtime for inner (pi) containers.
130
- * - "runc" (default): standard Docker runtime; uses bubblewrap inside the container for sandboxing.
131
- * - "runsc": gVisor runtime — intercepts syscalls at a user-space kernel boundary.
132
- * Stronger isolation than bwrap; restores full Docker hardening (no SYS_ADMIN relaxation needed).
133
- * Requires gVisor installed on the compute node (auto-installed by cloud-init on provisioned nodes).
134
- */
135
- containerRuntime: z.enum(["runc", "runsc"]).default("runc"),
136
- /**
137
- * @deprecated Use MERCURY_CONTAINER_RUNTIME=runsc instead.
138
- * When true, `docker run` uses looser outer sandbox so bubblewrap can nest (e.g. Docker Desktop).
139
- * Ignored when containerRuntime is "runsc". See docs/container-lifecycle.md.
140
- */
141
- containerBwrapDockerCompat: booleanFromEnv.default(false),
142
- /**
143
- * Docker network to attach inner (pi) containers to. When set, inner containers join this
144
- * network and can reach the Mercury host container by its container name rather than via
145
- * host.docker.internal. Required on Linux where host.docker.internal is not available.
146
- * Set via MERCURY_CONTAINER_NETWORK (e.g. "mercury-net").
147
- */
148
- containerNetwork: z.string().optional(),
149
- /**
150
- * Hostname (and optional port) that inner containers use to reach the Mercury host API.
151
- * When set, overrides the default "host.docker.internal" in the API_URL passed to mrctl.
152
- * Set via MERCURY_CONTAINER_API_HOST (e.g. "mercury-agent-<uuid>").
153
- */
154
- containerApiHost: z.string().optional(),
155
- maxConcurrency: z.coerce.number().int().min(1).max(32).default(2),
156
- /**
157
- * When true, Mercury uses `--system-prompt` instead of `--append-system-prompt` when invoking pi,
158
- * making Mercury the sole author of the system prompt. The prompt includes accurate tool snippets
159
- * and Mercury identity without any pi-specific references.
160
- * Default: false (append mode, preserves existing behaviour).
161
- */
162
- overridePiSystemPrompt: booleanFromEnv.default(false),
163
-
164
- // ─── Rate Limiting ──────────────────────────────────────────────────
165
- rateLimitPerUser: z.coerce.number().int().min(1).max(1000).default(10),
166
- rateLimitWindowMs: z.coerce
167
- .number()
168
- .int()
169
- .min(1000)
170
- .max(60 * 60 * 1000)
171
- .default(60 * 1000), // 1 minute
172
-
173
- // ─── Server ─────────────────────────────────────────────────────────
174
- port: z.coerce.number().int().min(1).max(65535).default(8787),
175
- botUsername: z.string().default("mercury"),
176
-
177
- // ─── Discord ────────────────────────────────────────────────────────
178
- enableDiscord: booleanFromEnv.default(false),
179
- discordGatewayDurationMs: z.coerce
180
- .number()
181
- .int()
182
- .min(60_000)
183
- .max(60 * 60 * 1000)
184
- .default(10 * 60 * 1000),
185
- discordGatewaySecret: z.string().optional(),
186
-
187
- // ─── Slack ──────────────────────────────────────────────────────────
188
- enableSlack: booleanFromEnv.default(false),
189
-
190
- // ─── Teams ───────────────────────────────────────────────────────────
191
- enableTeams: booleanFromEnv.default(false),
192
-
193
- // ─── WhatsApp ───────────────────────────────────────────────────────
194
- enableWhatsApp: booleanFromEnv.default(false),
195
-
196
- // ─── Telegram ───────────────────────────────────────────────────────
197
- enableTelegram: booleanFromEnv.default(false),
198
- /** When true, convert Markdown to Telegram HTML for formatted replies. */
199
- telegramFormatEnabled: booleanFromEnv.default(true),
200
-
201
- // ─── Media Handling ─────────────────────────────────────────────────
202
- mediaEnabled: booleanFromEnv.default(true),
203
- mediaMaxSizeMb: z.coerce.number().min(1).max(100).default(10),
204
-
205
- // ─── Permissions ────────────────────────────────────────────────────
206
- admins: z.string().default(""),
207
-
208
- // ─── Security ─────────────────────────────────────────────────────
209
- /** Shared secret for API authentication. Required for /api/* routes. */
210
- apiSecret: z.string().optional(),
211
- /** Optional API key for the /chat endpoint. When unset, /chat is open (for local use). */
212
- chatApiKey: z.string().optional(),
213
- /**
214
- * URL of the Mercury Cloud Console managing this agent (e.g. "https://console.mercury.app").
215
- * When set, the dashboard keys page redirects users to the Console instead of allowing
216
- * direct key edits — the Console is the single source of truth for API keys.
217
- * Env-only; not settable from mercury.yaml.
218
- */
219
- consoleUrl: z.string().url().optional(),
220
- /** User ID in the Mercury Cloud Console — used for per-message quota checks. Env-only. */
221
- consoleUserId: z.string().optional(),
222
- /** Shared secret for calling console internal API endpoints. Env-only. */
223
- consoleInternalSecret: z.string().optional(),
224
-
225
- // ─── Scheduling ─────────────────────────────────────────────────────
226
- /** IANA timezone used when a scheduled task is created without an explicit --timezone flag (e.g. "Asia/Jerusalem"). */
227
- defaultTimezone: z.string().optional(),
228
-
229
- // ─── TradeStation (host order API) ────────────────────────────────
230
- /**
231
- * When false (default), POST /api/tradestation/orders rejects non-SIM accounts.
232
- * Set true only when you intentionally allow live brokerage orders from the assistant flow.
233
- */
234
- tsAllowLiveOrders: booleanFromEnv.default(false),
235
-
236
- // ─── Cloud TTS (host-only; /api/tts, optional voice-synth extension) ───
237
- /** `google` | `azure` | `auto` — auto picks Google if credentials file set, else Azure if key+region set. */
238
- ttsProvider: z.enum(["google", "azure", "auto"]).default("auto"),
239
- /** Azure Speech resource key (secret; env-only). */
240
- azureSpeechKey: z.string().optional(),
241
- /** Azure region, e.g. `eastus`. */
242
- azureSpeechRegion: z.string().optional(),
243
- /**
244
- * Path to GCP service account JSON for Text-to-Speech.
245
- * Also accepts standard `GOOGLE_APPLICATION_CREDENTIALS` via mergeRawMercuryConfig.
246
- */
247
- googleApplicationCredentials: z.string().optional(),
248
- /** Max input characters per /api/tts request (clamped 500–10000). */
249
- ttsMaxChars: z.coerce.number().int().min(500).max(10_000).default(5000),
250
- });
251
-
252
- export type AppConfig = z.infer<typeof schema> & {
253
- /** Derived paths from dataDir */
254
- dbPath: string;
255
- globalDir: string;
256
- spacesDir: string;
257
- whatsappAuthDir: string;
258
- /** Ordered model legs (primary first), max 20. */
259
- resolvedModelChain: ModelLeg[];
260
- /** Parsed MERCURY_MODEL_CAPABILITIES override, if valid. */
261
- parsedModelCapabilitiesEnv: ModelCapabilities | null;
262
- /** Capabilities per chain leg (same order as resolvedModelChain). */
263
- resolvedModelChainCapabilities: ModelCapabilities[];
264
- /** Effective budget after clamping to container timeout. */
265
- effectiveModelChainBudgetMs: number;
266
- };
267
-
268
- export function loadConfig(): AppConfig {
269
- const raw = mergeRawMercuryConfig(process.env);
270
- const base = schema.parse(raw);
271
-
272
- const dataDir = base.dataDir;
273
-
274
- const resolvedModelChain = resolveModelChain({
275
- modelChain: base.modelChain,
276
- modelProvider: base.modelProvider,
277
- model: base.model,
278
- modelFallbackProvider: base.modelFallbackProvider,
279
- modelFallback: base.modelFallback,
280
- });
281
-
282
- const dataDirAbsolute = resolveProjectPath(base.dataDir);
283
- const parsedModelCapabilitiesEnv = parseModelCapabilitiesEnv(
284
- base.modelCapabilitiesEnv,
285
- );
286
- const { chainCaps: resolvedModelChainCapabilities } =
287
- resolveModelChainCapabilities(
288
- resolvedModelChain,
289
- dataDirAbsolute,
290
- parsedModelCapabilitiesEnv,
291
- );
292
-
293
- const slackMs = 10_000;
294
- const effectiveModelChainBudgetMs = Math.min(
295
- base.modelChainBudgetMs,
296
- Math.max(5000, base.containerTimeoutMs - slackMs),
297
- );
298
-
299
- return {
300
- ...base,
301
- dbPath: path.join(dataDir, "state.db"),
302
- globalDir: path.join(dataDir, "global"),
303
- spacesDir: path.join(dataDir, "spaces"),
304
- whatsappAuthDir:
305
- base.whatsappAuthDir ?? path.join(dataDir, "whatsapp-auth"),
306
- resolvedModelChain,
307
- parsedModelCapabilitiesEnv,
308
- resolvedModelChainCapabilities,
309
- effectiveModelChainBudgetMs,
310
- };
311
- }
312
-
313
- export function resolveProjectPath(p: string): string {
314
- if (path.isAbsolute(p)) return p;
315
- return path.join(process.cwd(), p);
316
- }
1
+ import path from "node:path";
2
+ import { z } from "zod";
3
+ import {
4
+ type ModelCapabilities,
5
+ parseModelCapabilitiesEnv,
6
+ resolveModelChainCapabilities,
7
+ } from "./agent/model-capabilities.js";
8
+ import { mergeRawMercuryConfig } from "./config-file.js";
9
+ import { parseModelLegsArray } from "./config-model-chain.js";
10
+
11
+ /** One model leg in the ordered fallback chain (primary first). */
12
+ export type ModelLeg = { provider: string; model: string };
13
+
14
+ function parseModelChainJson(raw: string): ModelLeg[] {
15
+ let parsed: unknown;
16
+ try {
17
+ parsed = JSON.parse(raw);
18
+ } catch {
19
+ throw new Error("MERCURY_MODEL_CHAIN must be valid JSON array");
20
+ }
21
+ return parseModelLegsArray(parsed, "MERCURY_MODEL_CHAIN");
22
+ }
23
+
24
+ function resolveModelChain(base: {
25
+ modelChain: string | undefined;
26
+ modelProvider: string;
27
+ model: string;
28
+ modelFallbackProvider: string | undefined;
29
+ modelFallback: string | undefined;
30
+ }): ModelLeg[] {
31
+ const trimmed = base.modelChain?.trim();
32
+ if (trimmed) {
33
+ return parseModelChainJson(trimmed);
34
+ }
35
+ const legs: ModelLeg[] = [
36
+ { provider: base.modelProvider, model: base.model },
37
+ ];
38
+ const fp = base.modelFallbackProvider?.trim();
39
+ const fm = base.modelFallback?.trim();
40
+ if (fp && fm) {
41
+ legs.push({ provider: fp, model: fm });
42
+ }
43
+ return legs;
44
+ }
45
+
46
+ /** Parse boolean from env var strings — case-insensitive "true"/"1" → true, everything else → false */
47
+ const booleanFromEnv = z.union([z.boolean(), z.string()]).transform((val) => {
48
+ if (typeof val === "boolean") return val;
49
+ const lower = val.toLowerCase();
50
+ return lower === "true" || lower === "1";
51
+ });
52
+
53
+ const schema = z.object({
54
+ // ─── API Key Mode ───────────────────────────────────────────────────
55
+ apiKeyMode: z.enum(["platform", "byok"]).default("platform"),
56
+
57
+ // ─── Logging ────────────────────────────────────────────────────────
58
+ logLevel: z
59
+ .enum(["debug", "info", "warn", "error", "silent"])
60
+ .default("info"),
61
+ logFormat: z.enum(["text", "json"]).default("text"),
62
+
63
+ // ─── AI Model ───────────────────────────────────────────────────────
64
+ modelProvider: z.string().default("anthropic"),
65
+ model: z.string().default("claude-opus-4-6"),
66
+ modelFallbackProvider: z.string().optional(),
67
+ modelFallback: z.string().optional(),
68
+ /** JSON array of `{ provider, model }`. When set, overrides legacy primary+fallback pair. */
69
+ modelChain: z.string().optional(),
70
+ /** Extra attempts after the first failure on the same leg (retryable errors only). Default 2 => 3 tries max per leg. */
71
+ modelMaxRetriesPerLeg: z.coerce.number().int().min(0).max(5).default(2),
72
+ /** Wall-clock budget for the whole chain (ms). Clamped below container timeout. Default 120s. */
73
+ modelChainBudgetMs: z.coerce
74
+ .number()
75
+ .int()
76
+ .min(5000)
77
+ .max(55 * 60 * 1000)
78
+ .default(120_000),
79
+ /**
80
+ * Optional JSON object overriding model capabilities for all chain legs, e.g.
81
+ * `{"tools":false,"vision":true}`. Highest priority over YAML and built-in map.
82
+ */
83
+ modelCapabilitiesEnv: z.string().optional(),
84
+
85
+ // ─── Trigger Behavior ───────────────────────────────────────────────
86
+ triggerPatterns: z.string().default("@Pi,Pi"),
87
+ triggerMatch: z.string().default("mention"),
88
+
89
+ // ─── Context Behavior ───────────────────────────────────────────────
90
+ /** Default context mode seeded into the `main` space on first boot. */
91
+ contextMode: z.enum(["clear", "context"]).default("context"),
92
+ /** Default sliding-window turn count for `context` mode (1–50). Seeded into `main` on first boot. */
93
+ contextWindowSize: z.coerce.number().int().min(1).max(50).default(10),
94
+ /** Default reply-chain depth for `clear` mode (1–50). Seeded into `main` on first boot. */
95
+ contextReplyChainDepth: z.coerce.number().int().min(1).max(50).default(10),
96
+
97
+ // ─── Storage ────────────────────────────────────────────────────────
98
+ dataDir: z.string().default(".mercury"),
99
+ /** Max disk usage in MB for the agent's data directory. Unset = no enforcement (local/self-hosted). */
100
+ maxDiskMb: z.coerce.number().int().min(0).optional(),
101
+
102
+ // ─── Storage Lifecycle ─────────────────────────────────────────────
103
+ /** Days before inbox files are auto-deleted. */
104
+ inboxTtlDays: z.coerce.number().min(1).max(365).default(7),
105
+ /** Days before outbox files are auto-deleted. */
106
+ outboxTtlDays: z.coerce.number().min(1).max(365).default(3),
107
+ /** Interval in ms between storage cleanup runs. Default 1 hour. */
108
+ cleanupIntervalMs: z.coerce
109
+ .number()
110
+ .int()
111
+ .min(60_000)
112
+ .max(86_400_000)
113
+ .default(3_600_000),
114
+ authPath: z.string().optional(),
115
+ /** WhatsApp Baileys auth directory; default `<dataDir>/whatsapp-auth`. */
116
+ whatsappAuthDir: z.string().optional(),
117
+
118
+ // ─── Container / Agent ──────────────────────────────────────────────
119
+ agentContainerImage: z
120
+ .string()
121
+ .default("ghcr.io/michaelliv/mercury-agent:latest"),
122
+ containerTimeoutMs: z.coerce
123
+ .number()
124
+ .int()
125
+ .min(10_000)
126
+ .max(60 * 60 * 1000)
127
+ .default(5 * 60 * 1000), // 5 minutes
128
+ /**
129
+ * OCI runtime for inner (pi) containers.
130
+ * - "runc" (default): standard Docker runtime; uses bubblewrap inside the container for sandboxing.
131
+ * - "runsc": gVisor runtime — intercepts syscalls at a user-space kernel boundary.
132
+ * Stronger isolation than bwrap; restores full Docker hardening (no SYS_ADMIN relaxation needed).
133
+ * Requires gVisor installed on the compute node (auto-installed by cloud-init on provisioned nodes).
134
+ */
135
+ containerRuntime: z.enum(["runc", "runsc"]).default("runc"),
136
+ /**
137
+ * @deprecated Use MERCURY_CONTAINER_RUNTIME=runsc instead.
138
+ * When true, `docker run` uses looser outer sandbox so bubblewrap can nest (e.g. Docker Desktop).
139
+ * Ignored when containerRuntime is "runsc". See docs/container-lifecycle.md.
140
+ */
141
+ containerBwrapDockerCompat: booleanFromEnv.default(false),
142
+ /**
143
+ * Docker network to attach inner (pi) containers to. When set, inner containers join this
144
+ * network and can reach the Mercury host container by its container name rather than via
145
+ * host.docker.internal. Required on Linux where host.docker.internal is not available.
146
+ * Set via MERCURY_CONTAINER_NETWORK (e.g. "mercury-net").
147
+ */
148
+ containerNetwork: z.string().optional(),
149
+ /**
150
+ * Hostname (and optional port) that inner containers use to reach the Mercury host API.
151
+ * When set, overrides the default "host.docker.internal" in the API_URL passed to mrctl.
152
+ * Set via MERCURY_CONTAINER_API_HOST (e.g. "mercury-agent-<uuid>").
153
+ */
154
+ containerApiHost: z.string().optional(),
155
+ maxConcurrency: z.coerce.number().int().min(1).max(32).default(2),
156
+ /**
157
+ * When true, Mercury uses `--system-prompt` instead of `--append-system-prompt` when invoking pi,
158
+ * making Mercury the sole author of the system prompt. The prompt includes accurate tool snippets
159
+ * and Mercury identity without any pi-specific references.
160
+ * Default: false (append mode, preserves existing behaviour).
161
+ */
162
+ overridePiSystemPrompt: booleanFromEnv.default(false),
163
+
164
+ // ─── Rate Limiting ──────────────────────────────────────────────────
165
+ rateLimitPerUser: z.coerce.number().int().min(1).max(1000).default(10),
166
+ rateLimitWindowMs: z.coerce
167
+ .number()
168
+ .int()
169
+ .min(1000)
170
+ .max(60 * 60 * 1000)
171
+ .default(60 * 1000), // 1 minute
172
+
173
+ // ─── Server ─────────────────────────────────────────────────────────
174
+ port: z.coerce.number().int().min(1).max(65535).default(8787),
175
+ botUsername: z.string().default("mercury"),
176
+
177
+ // ─── Discord ────────────────────────────────────────────────────────
178
+ enableDiscord: booleanFromEnv.default(false),
179
+ discordGatewayDurationMs: z.coerce
180
+ .number()
181
+ .int()
182
+ .min(60_000)
183
+ .max(60 * 60 * 1000)
184
+ .default(10 * 60 * 1000),
185
+ discordGatewaySecret: z.string().optional(),
186
+
187
+ // ─── Slack ──────────────────────────────────────────────────────────
188
+ enableSlack: booleanFromEnv.default(false),
189
+
190
+ // ─── Teams ───────────────────────────────────────────────────────────
191
+ enableTeams: booleanFromEnv.default(false),
192
+
193
+ // ─── WhatsApp ───────────────────────────────────────────────────────
194
+ enableWhatsApp: booleanFromEnv.default(false),
195
+
196
+ // ─── Telegram ───────────────────────────────────────────────────────
197
+ enableTelegram: booleanFromEnv.default(false),
198
+ /** When true, convert Markdown to Telegram HTML for formatted replies. */
199
+ telegramFormatEnabled: booleanFromEnv.default(true),
200
+
201
+ // ─── Media Handling ─────────────────────────────────────────────────
202
+ mediaEnabled: booleanFromEnv.default(true),
203
+ mediaMaxSizeMb: z.coerce.number().min(1).max(100).default(10),
204
+
205
+ // ─── Permissions ────────────────────────────────────────────────────
206
+ admins: z.string().default(""),
207
+
208
+ // ─── Security ─────────────────────────────────────────────────────
209
+ /** Shared secret for API authentication. Required for /api/* routes. */
210
+ apiSecret: z.string().optional(),
211
+ /** Optional API key for the /chat endpoint. When unset, /chat is open (for local use). */
212
+ chatApiKey: z.string().optional(),
213
+ /**
214
+ * URL of the Mercury Cloud Console managing this agent (e.g. "https://console.mercury.app").
215
+ * When set, the dashboard keys page redirects users to the Console instead of allowing
216
+ * direct key edits — the Console is the single source of truth for API keys.
217
+ * Env-only; not settable from mercury.yaml.
218
+ */
219
+ consoleUrl: z.string().url().optional(),
220
+ /** User ID in the Mercury Cloud Console — used for per-message quota checks. Env-only. */
221
+ consoleUserId: z.string().optional(),
222
+ /** Shared secret for calling console internal API endpoints. Env-only. */
223
+ consoleInternalSecret: z.string().optional(),
224
+
225
+ // ─── Scheduling ─────────────────────────────────────────────────────
226
+ /** IANA timezone used when a scheduled task is created without an explicit --timezone flag (e.g. "Asia/Jerusalem"). */
227
+ defaultTimezone: z.string().optional(),
228
+
229
+ // ─── TradeStation (host order API) ────────────────────────────────
230
+ /**
231
+ * When false (default), POST /api/tradestation/orders rejects non-SIM accounts.
232
+ * Set true only when you intentionally allow live brokerage orders from the assistant flow.
233
+ */
234
+ tsAllowLiveOrders: booleanFromEnv.default(false),
235
+
236
+ // ─── Cloud TTS (host-only; /api/tts, optional voice-synth extension) ───
237
+ /** `google` | `azure` | `auto` — auto picks Google if credentials file set, else Azure if key+region set. */
238
+ ttsProvider: z.enum(["google", "azure", "auto"]).default("auto"),
239
+ /** Azure Speech resource key (secret; env-only). */
240
+ azureSpeechKey: z.string().optional(),
241
+ /** Azure region, e.g. `eastus`. */
242
+ azureSpeechRegion: z.string().optional(),
243
+ /**
244
+ * Path to GCP service account JSON for Text-to-Speech.
245
+ * Also accepts standard `GOOGLE_APPLICATION_CREDENTIALS` via mergeRawMercuryConfig.
246
+ */
247
+ googleApplicationCredentials: z.string().optional(),
248
+ /** Max input characters per /api/tts request (clamped 500–10000). */
249
+ ttsMaxChars: z.coerce.number().int().min(500).max(10_000).default(5000),
250
+ });
251
+
252
+ export type AppConfig = z.infer<typeof schema> & {
253
+ /** Derived paths from dataDir */
254
+ dbPath: string;
255
+ globalDir: string;
256
+ spacesDir: string;
257
+ whatsappAuthDir: string;
258
+ /** Ordered model legs (primary first), max 20. */
259
+ resolvedModelChain: ModelLeg[];
260
+ /** Parsed MERCURY_MODEL_CAPABILITIES override, if valid. */
261
+ parsedModelCapabilitiesEnv: ModelCapabilities | null;
262
+ /** Capabilities per chain leg (same order as resolvedModelChain). */
263
+ resolvedModelChainCapabilities: ModelCapabilities[];
264
+ /** Effective budget after clamping to container timeout. */
265
+ effectiveModelChainBudgetMs: number;
266
+ };
267
+
268
+ export function loadConfig(): AppConfig {
269
+ const raw = mergeRawMercuryConfig(process.env);
270
+ const base = schema.parse(raw);
271
+
272
+ const dataDir = base.dataDir;
273
+
274
+ const resolvedModelChain = resolveModelChain({
275
+ modelChain: base.modelChain,
276
+ modelProvider: base.modelProvider,
277
+ model: base.model,
278
+ modelFallbackProvider: base.modelFallbackProvider,
279
+ modelFallback: base.modelFallback,
280
+ });
281
+
282
+ const dataDirAbsolute = resolveProjectPath(base.dataDir);
283
+ const parsedModelCapabilitiesEnv = parseModelCapabilitiesEnv(
284
+ base.modelCapabilitiesEnv,
285
+ );
286
+ const { chainCaps: resolvedModelChainCapabilities } =
287
+ resolveModelChainCapabilities(
288
+ resolvedModelChain,
289
+ dataDirAbsolute,
290
+ parsedModelCapabilitiesEnv,
291
+ );
292
+
293
+ const slackMs = 10_000;
294
+ const effectiveModelChainBudgetMs = Math.min(
295
+ base.modelChainBudgetMs,
296
+ Math.max(5000, base.containerTimeoutMs - slackMs),
297
+ );
298
+
299
+ return {
300
+ ...base,
301
+ dbPath: path.join(dataDir, "state.db"),
302
+ globalDir: path.join(dataDir, "global"),
303
+ spacesDir: path.join(dataDir, "spaces"),
304
+ whatsappAuthDir:
305
+ base.whatsappAuthDir ?? path.join(dataDir, "whatsapp-auth"),
306
+ resolvedModelChain,
307
+ parsedModelCapabilitiesEnv,
308
+ resolvedModelChainCapabilities,
309
+ effectiveModelChainBudgetMs,
310
+ };
311
+ }
312
+
313
+ export function resolveProjectPath(p: string): string {
314
+ if (path.isAbsolute(p)) return p;
315
+ return path.join(process.cwd(), p);
316
+ }