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
package/src/config.ts CHANGED
@@ -1,339 +1,339 @@
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("@Mercury,Mercury"),
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/avishai-tsabari/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
- rateLimitDailyMember: z.coerce.number().int().min(0).max(10000).default(0),
173
- rateLimitDailyAdmin: z.coerce.number().int().min(0).max(10000).default(0),
174
-
175
- // ─── Server ─────────────────────────────────────────────────────────
176
- port: z.coerce.number().int().min(1).max(65535).default(8787),
177
- botUsername: z.string().default("mercury"),
178
-
179
- // ─── Discord ────────────────────────────────────────────────────────
180
- enableDiscord: booleanFromEnv.default(false),
181
- discordGatewayDurationMs: z.coerce
182
- .number()
183
- .int()
184
- .min(60_000)
185
- .max(60 * 60 * 1000)
186
- .default(10 * 60 * 1000),
187
- discordGatewaySecret: z.string().optional(),
188
-
189
- // ─── Slack ──────────────────────────────────────────────────────────
190
- enableSlack: booleanFromEnv.default(false),
191
-
192
- // ─── Teams ───────────────────────────────────────────────────────────
193
- enableTeams: booleanFromEnv.default(false),
194
-
195
- // ─── WhatsApp ───────────────────────────────────────────────────────
196
- enableWhatsApp: booleanFromEnv.default(false),
197
-
198
- // ─── Telegram ───────────────────────────────────────────────────────
199
- enableTelegram: booleanFromEnv.default(false),
200
- /** When true, convert Markdown to Telegram HTML for formatted replies. */
201
- telegramFormatEnabled: booleanFromEnv.default(true),
202
-
203
- // ─── Media Handling ─────────────────────────────────────────────────
204
- mediaEnabled: booleanFromEnv.default(true),
205
- mediaMaxSizeMb: z.coerce.number().min(1).max(100).default(10),
206
-
207
- // ─── Permissions ────────────────────────────────────────────────────
208
- admins: z.string().default(""),
209
-
210
- // ─── Applicative Profile ────────────────────────────────────────────
211
- /**
212
- * Name of the active applicative profile (informational at runtime; the
213
- * authoritative activation is persisted to `.mercury/active-profile.json`
214
- * by `applyProfile`). Env-only.
215
- */
216
- profile: z.string().optional(),
217
-
218
- // ─── Security ─────────────────────────────────────────────────────
219
- /** Shared secret for API authentication. Required for /api/* routes. */
220
- apiSecret: z.string().optional(),
221
- /**
222
- * Host-only HMAC key for signing per-turn caller tokens. Never injected into
223
- * containers. When unset, an ephemeral random key is generated per host
224
- * process (fine for single-process deployments). Only set this when token
225
- * minting and verification run in separate processes.
226
- */
227
- callerTokenKey: z.string().optional(),
228
- /** Optional API key for the /chat endpoint. When unset, /chat is open (for local use). */
229
- chatApiKey: z.string().optional(),
230
- /**
231
- * URL of the Mercury Cloud Console managing this agent (e.g. "https://console.mercury.app").
232
- * When set, the dashboard keys page redirects users to the Console instead of allowing
233
- * direct key edits — the Console is the single source of truth for API keys.
234
- * Env-only; not settable from mercury.yaml.
235
- */
236
- consoleUrl: z.string().url().optional(),
237
- /** User ID in the Mercury Cloud Console — used for per-message quota checks. Env-only. */
238
- consoleUserId: z.string().optional(),
239
- /** Shared secret for calling console internal API endpoints. Env-only. */
240
- consoleInternalSecret: z.string().optional(),
241
-
242
- // ─── Scheduling ─────────────────────────────────────────────────────
243
- /** IANA timezone used when a scheduled task is created without an explicit --timezone flag (e.g. "Asia/Jerusalem"). */
244
- defaultTimezone: z.string().optional(),
245
-
246
- // ─── TradeStation (host order API) ────────────────────────────────
247
- /**
248
- * When false (default), POST /api/tradestation/orders rejects non-SIM accounts.
249
- * Set true only when you intentionally allow live brokerage orders from the assistant flow.
250
- */
251
- tsAllowLiveOrders: booleanFromEnv.default(false),
252
-
253
- // ─── Cloud TTS (host-only; /api/tts, optional voice-synth extension) ───
254
- /** `google` | `azure` | `auto` — auto picks Google if credentials file set, else Azure if key+region set. */
255
- ttsProvider: z.enum(["google", "azure", "auto"]).default("auto"),
256
- /** Azure Speech resource key (secret; env-only). */
257
- azureSpeechKey: z.string().optional(),
258
- /** Azure region, e.g. `eastus`. */
259
- azureSpeechRegion: z.string().optional(),
260
- /**
261
- * Path to GCP service account JSON for Text-to-Speech.
262
- * Also accepts standard `GOOGLE_APPLICATION_CREDENTIALS` via mergeRawMercuryConfig.
263
- */
264
- googleApplicationCredentials: z.string().optional(),
265
- /** Max input characters per /api/tts request (clamped 500–10000). */
266
- ttsMaxChars: z.coerce.number().int().min(500).max(10_000).default(5000),
267
-
268
- // ─── DM Auto-Space ─────────────────────────────────────────────────
269
- dmAutoSpaceEnabled: booleanFromEnv.default(false),
270
- dmAutoSpaceAdminIds: z.string().default(""),
271
- dmAutoSpaceDefaultSystemPrompt: z.string().default(""),
272
- dmAutoSpaceDefaultMemberPermissions: z.string().default("prompt,prefs.get"),
273
- });
274
-
275
- export type AppConfig = z.infer<typeof schema> & {
276
- /** Derived paths from dataDir */
277
- dbPath: string;
278
- globalDir: string;
279
- spacesDir: string;
280
- whatsappAuthDir: string;
281
- /** Ordered model legs (primary first), max 20. */
282
- resolvedModelChain: ModelLeg[];
283
- /** Parsed MERCURY_MODEL_CAPABILITIES override, if valid. */
284
- parsedModelCapabilitiesEnv: ModelCapabilities | null;
285
- /** Capabilities per chain leg (same order as resolvedModelChain). */
286
- resolvedModelChainCapabilities: ModelCapabilities[];
287
- /** Effective budget after clamping to container timeout. */
288
- effectiveModelChainBudgetMs: number;
289
- };
290
-
291
- export function loadConfig(): AppConfig {
292
- const raw = mergeRawMercuryConfig(process.env);
293
- const base = schema.parse(raw);
294
-
295
- const dataDir = base.dataDir;
296
-
297
- const resolvedModelChain = resolveModelChain({
298
- modelChain: base.modelChain,
299
- modelProvider: base.modelProvider,
300
- model: base.model,
301
- modelFallbackProvider: base.modelFallbackProvider,
302
- modelFallback: base.modelFallback,
303
- });
304
-
305
- const dataDirAbsolute = resolveProjectPath(base.dataDir);
306
- const parsedModelCapabilitiesEnv = parseModelCapabilitiesEnv(
307
- base.modelCapabilitiesEnv,
308
- );
309
- const { chainCaps: resolvedModelChainCapabilities } =
310
- resolveModelChainCapabilities(
311
- resolvedModelChain,
312
- dataDirAbsolute,
313
- parsedModelCapabilitiesEnv,
314
- );
315
-
316
- const slackMs = 10_000;
317
- const effectiveModelChainBudgetMs = Math.min(
318
- base.modelChainBudgetMs,
319
- Math.max(5000, base.containerTimeoutMs - slackMs),
320
- );
321
-
322
- return {
323
- ...base,
324
- dbPath: path.join(dataDir, "state.db"),
325
- globalDir: path.join(dataDir, "global"),
326
- spacesDir: path.join(dataDir, "spaces"),
327
- whatsappAuthDir:
328
- base.whatsappAuthDir ?? path.join(dataDir, "whatsapp-auth"),
329
- resolvedModelChain,
330
- parsedModelCapabilitiesEnv,
331
- resolvedModelChainCapabilities,
332
- effectiveModelChainBudgetMs,
333
- };
334
- }
335
-
336
- export function resolveProjectPath(p: string): string {
337
- if (path.isAbsolute(p)) return p;
338
- return path.join(process.cwd(), p);
339
- }
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-8"),
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("@Mercury,Mercury"),
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/avishai-tsabari/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
+ rateLimitDailyMember: z.coerce.number().int().min(0).max(10000).default(0),
173
+ rateLimitDailyAdmin: z.coerce.number().int().min(0).max(10000).default(0),
174
+
175
+ // ─── Server ─────────────────────────────────────────────────────────
176
+ port: z.coerce.number().int().min(1).max(65535).default(8787),
177
+ botUsername: z.string().default("mercury"),
178
+
179
+ // ─── Discord ────────────────────────────────────────────────────────
180
+ enableDiscord: booleanFromEnv.default(false),
181
+ discordGatewayDurationMs: z.coerce
182
+ .number()
183
+ .int()
184
+ .min(60_000)
185
+ .max(60 * 60 * 1000)
186
+ .default(10 * 60 * 1000),
187
+ discordGatewaySecret: z.string().optional(),
188
+
189
+ // ─── Slack ──────────────────────────────────────────────────────────
190
+ enableSlack: booleanFromEnv.default(false),
191
+
192
+ // ─── Teams ───────────────────────────────────────────────────────────
193
+ enableTeams: booleanFromEnv.default(false),
194
+
195
+ // ─── WhatsApp ───────────────────────────────────────────────────────
196
+ enableWhatsApp: booleanFromEnv.default(false),
197
+
198
+ // ─── Telegram ───────────────────────────────────────────────────────
199
+ enableTelegram: booleanFromEnv.default(false),
200
+ /** When true, convert Markdown to Telegram HTML for formatted replies. */
201
+ telegramFormatEnabled: booleanFromEnv.default(true),
202
+
203
+ // ─── Media Handling ─────────────────────────────────────────────────
204
+ mediaEnabled: booleanFromEnv.default(true),
205
+ mediaMaxSizeMb: z.coerce.number().min(1).max(100).default(10),
206
+
207
+ // ─── Permissions ────────────────────────────────────────────────────
208
+ admins: z.string().default(""),
209
+
210
+ // ─── Applicative Profile ────────────────────────────────────────────
211
+ /**
212
+ * Name of the active applicative profile (informational at runtime; the
213
+ * authoritative activation is persisted to `.mercury/active-profile.json`
214
+ * by `applyProfile`). Env-only.
215
+ */
216
+ profile: z.string().optional(),
217
+
218
+ // ─── Security ─────────────────────────────────────────────────────
219
+ /** Shared secret for API authentication. Required for /api/* routes. */
220
+ apiSecret: z.string().optional(),
221
+ /**
222
+ * Host-only HMAC key for signing per-turn caller tokens. Never injected into
223
+ * containers. When unset, an ephemeral random key is generated per host
224
+ * process (fine for single-process deployments). Only set this when token
225
+ * minting and verification run in separate processes.
226
+ */
227
+ callerTokenKey: z.string().optional(),
228
+ /** Optional API key for the /chat endpoint. When unset, /chat is open (for local use). */
229
+ chatApiKey: z.string().optional(),
230
+ /**
231
+ * URL of the Mercury Cloud Console managing this agent (e.g. "https://console.mercury.app").
232
+ * When set, the dashboard keys page redirects users to the Console instead of allowing
233
+ * direct key edits — the Console is the single source of truth for API keys.
234
+ * Env-only; not settable from mercury.yaml.
235
+ */
236
+ consoleUrl: z.string().url().optional(),
237
+ /** User ID in the Mercury Cloud Console — used for per-message quota checks. Env-only. */
238
+ consoleUserId: z.string().optional(),
239
+ /** Shared secret for calling console internal API endpoints. Env-only. */
240
+ consoleInternalSecret: z.string().optional(),
241
+
242
+ // ─── Scheduling ─────────────────────────────────────────────────────
243
+ /** IANA timezone used when a scheduled task is created without an explicit --timezone flag (e.g. "Asia/Jerusalem"). */
244
+ defaultTimezone: z.string().optional(),
245
+
246
+ // ─── TradeStation (host order API) ────────────────────────────────
247
+ /**
248
+ * When false (default), POST /api/tradestation/orders rejects non-SIM accounts.
249
+ * Set true only when you intentionally allow live brokerage orders from the assistant flow.
250
+ */
251
+ tsAllowLiveOrders: booleanFromEnv.default(false),
252
+
253
+ // ─── Cloud TTS (host-only; /api/tts, optional voice-synth extension) ───
254
+ /** `google` | `azure` | `auto` — auto picks Google if credentials file set, else Azure if key+region set. */
255
+ ttsProvider: z.enum(["google", "azure", "auto"]).default("auto"),
256
+ /** Azure Speech resource key (secret; env-only). */
257
+ azureSpeechKey: z.string().optional(),
258
+ /** Azure region, e.g. `eastus`. */
259
+ azureSpeechRegion: z.string().optional(),
260
+ /**
261
+ * Path to GCP service account JSON for Text-to-Speech.
262
+ * Also accepts standard `GOOGLE_APPLICATION_CREDENTIALS` via mergeRawMercuryConfig.
263
+ */
264
+ googleApplicationCredentials: z.string().optional(),
265
+ /** Max input characters per /api/tts request (clamped 500–10000). */
266
+ ttsMaxChars: z.coerce.number().int().min(500).max(10_000).default(5000),
267
+
268
+ // ─── DM Auto-Space ─────────────────────────────────────────────────
269
+ dmAutoSpaceEnabled: booleanFromEnv.default(false),
270
+ dmAutoSpaceAdminIds: z.string().default(""),
271
+ dmAutoSpaceDefaultSystemPrompt: z.string().default(""),
272
+ dmAutoSpaceDefaultMemberPermissions: z.string().default("prompt,prefs.get"),
273
+ });
274
+
275
+ export type AppConfig = z.infer<typeof schema> & {
276
+ /** Derived paths from dataDir */
277
+ dbPath: string;
278
+ globalDir: string;
279
+ spacesDir: string;
280
+ whatsappAuthDir: string;
281
+ /** Ordered model legs (primary first), max 20. */
282
+ resolvedModelChain: ModelLeg[];
283
+ /** Parsed MERCURY_MODEL_CAPABILITIES override, if valid. */
284
+ parsedModelCapabilitiesEnv: ModelCapabilities | null;
285
+ /** Capabilities per chain leg (same order as resolvedModelChain). */
286
+ resolvedModelChainCapabilities: ModelCapabilities[];
287
+ /** Effective budget after clamping to container timeout. */
288
+ effectiveModelChainBudgetMs: number;
289
+ };
290
+
291
+ export function loadConfig(): AppConfig {
292
+ const raw = mergeRawMercuryConfig(process.env);
293
+ const base = schema.parse(raw);
294
+
295
+ const dataDir = base.dataDir;
296
+
297
+ const resolvedModelChain = resolveModelChain({
298
+ modelChain: base.modelChain,
299
+ modelProvider: base.modelProvider,
300
+ model: base.model,
301
+ modelFallbackProvider: base.modelFallbackProvider,
302
+ modelFallback: base.modelFallback,
303
+ });
304
+
305
+ const dataDirAbsolute = resolveProjectPath(base.dataDir);
306
+ const parsedModelCapabilitiesEnv = parseModelCapabilitiesEnv(
307
+ base.modelCapabilitiesEnv,
308
+ );
309
+ const { chainCaps: resolvedModelChainCapabilities } =
310
+ resolveModelChainCapabilities(
311
+ resolvedModelChain,
312
+ dataDirAbsolute,
313
+ parsedModelCapabilitiesEnv,
314
+ );
315
+
316
+ const slackMs = 10_000;
317
+ const effectiveModelChainBudgetMs = Math.min(
318
+ base.modelChainBudgetMs,
319
+ Math.max(5000, base.containerTimeoutMs - slackMs),
320
+ );
321
+
322
+ return {
323
+ ...base,
324
+ dbPath: path.join(dataDir, "state.db"),
325
+ globalDir: path.join(dataDir, "global"),
326
+ spacesDir: path.join(dataDir, "spaces"),
327
+ whatsappAuthDir:
328
+ base.whatsappAuthDir ?? path.join(dataDir, "whatsapp-auth"),
329
+ resolvedModelChain,
330
+ parsedModelCapabilitiesEnv,
331
+ resolvedModelChainCapabilities,
332
+ effectiveModelChainBudgetMs,
333
+ };
334
+ }
335
+
336
+ export function resolveProjectPath(p: string): string {
337
+ if (path.isAbsolute(p)) return p;
338
+ return path.join(process.cwd(), p);
339
+ }