mercury-agent 0.4.5

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 (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/container/Dockerfile +127 -0
  4. package/container/Dockerfile.base +109 -0
  5. package/container/Dockerfile.power +17 -0
  6. package/container/agent-package.json +8 -0
  7. package/container/build.sh +54 -0
  8. package/docs/TODOS.md +147 -0
  9. package/docs/auth/dashboard.md +28 -0
  10. package/docs/auth/overview.md +109 -0
  11. package/docs/auth/whatsapp.md +173 -0
  12. package/docs/configuration.md +54 -0
  13. package/docs/container-lifecycle.md +349 -0
  14. package/docs/context-architecture.md +87 -0
  15. package/docs/deployment.md +199 -0
  16. package/docs/extensions.md +375 -0
  17. package/docs/graceful-shutdown.md +62 -0
  18. package/docs/kb-distillation.md +77 -0
  19. package/docs/media/overview.md +140 -0
  20. package/docs/media/whatsapp.md +171 -0
  21. package/docs/memory.md +137 -0
  22. package/docs/permissions.md +217 -0
  23. package/docs/pipeline.md +228 -0
  24. package/docs/prd-chat-memory.md +76 -0
  25. package/docs/prd-config-load.md +82 -0
  26. package/docs/rate-limiting.md +166 -0
  27. package/docs/scheduler.md +288 -0
  28. package/docs/setup-discord.md +100 -0
  29. package/docs/setup-slack.md +119 -0
  30. package/docs/setup-whatsapp.md +94 -0
  31. package/docs/subagents.md +166 -0
  32. package/docs/web-search.md +62 -0
  33. package/examples/extensions/README.md +12 -0
  34. package/examples/extensions/charts/index.ts +13 -0
  35. package/examples/extensions/charts/skill/SKILL.md +98 -0
  36. package/examples/extensions/gws/README.md +52 -0
  37. package/examples/extensions/gws/index.ts +106 -0
  38. package/examples/extensions/gws/skill/SKILL.md +57 -0
  39. package/examples/extensions/gws/skill/references/calendar.md +101 -0
  40. package/examples/extensions/gws/skill/references/docs.md +65 -0
  41. package/examples/extensions/gws/skill/references/drive.md +79 -0
  42. package/examples/extensions/gws/skill/references/gmail.md +85 -0
  43. package/examples/extensions/gws/skill/references/sheets.md +60 -0
  44. package/examples/extensions/napkin/index.ts +821 -0
  45. package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
  46. package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
  47. package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
  48. package/examples/extensions/napkin/skill/SKILL.md +728 -0
  49. package/examples/extensions/pdf/index.ts +23 -0
  50. package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
  51. package/examples/extensions/pdf/skill/SKILL.md +314 -0
  52. package/examples/extensions/pdf/skill/forms.md +294 -0
  53. package/examples/extensions/pdf/skill/reference.md +612 -0
  54. package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
  55. package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
  56. package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
  57. package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
  58. package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
  59. package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
  60. package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
  61. package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
  62. package/examples/extensions/permission-guard/index.ts +65 -0
  63. package/examples/extensions/pinchtab/index.ts +199 -0
  64. package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
  65. package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
  66. package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
  67. package/examples/extensions/pinchtab/skill/references/api.md +297 -0
  68. package/examples/extensions/pinchtab/skill/references/env.md +45 -0
  69. package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
  70. package/examples/extensions/tradestation/host/refresh.ts +102 -0
  71. package/examples/extensions/tradestation/index.ts +153 -0
  72. package/examples/extensions/tradestation/skill/SKILL.md +67 -0
  73. package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
  74. package/examples/extensions/voice-synth/index.ts +94 -0
  75. package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
  76. package/examples/extensions/voice-transcribe/index.ts +381 -0
  77. package/examples/extensions/voice-transcribe/requirements.txt +8 -0
  78. package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
  79. package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
  80. package/examples/extensions/web-search/index.ts +22 -0
  81. package/examples/extensions/web-search/skill/SKILL.md +114 -0
  82. package/examples/extensions/web-search/skill/references/apartments.md +178 -0
  83. package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
  84. package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
  85. package/examples/extensions/web-search/skill/references/flights.md +133 -0
  86. package/examples/extensions/web-search/skill/references/hotels.md +148 -0
  87. package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
  88. package/examples/extensions/yahoo-mail/cli/package.json +13 -0
  89. package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
  90. package/examples/extensions/yahoo-mail/index.ts +57 -0
  91. package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
  92. package/package.json +106 -0
  93. package/resources/agents/explore.md +50 -0
  94. package/resources/agents/worker.md +24 -0
  95. package/resources/builtin-extensions.txt +3 -0
  96. package/resources/connection-env-vars.json +25 -0
  97. package/resources/extensions/.gitkeep +0 -0
  98. package/resources/pi-extensions/subagent/agents.ts +126 -0
  99. package/resources/pi-extensions/subagent/index.ts +964 -0
  100. package/resources/profiles/coding/AGENTS.md +43 -0
  101. package/resources/profiles/coding/mercury-profile.yaml +15 -0
  102. package/resources/profiles/general/AGENTS.md +31 -0
  103. package/resources/profiles/general/mercury-profile.yaml +15 -0
  104. package/resources/profiles/research/AGENTS.md +40 -0
  105. package/resources/profiles/research/mercury-profile.yaml +15 -0
  106. package/resources/skills/config/SKILL.md +25 -0
  107. package/resources/skills/context/SKILL.md +33 -0
  108. package/resources/skills/conversation-recap/SKILL.md +19 -0
  109. package/resources/skills/media/SKILL.md +27 -0
  110. package/resources/skills/mutes/SKILL.md +31 -0
  111. package/resources/skills/permissions/SKILL.md +19 -0
  112. package/resources/skills/preferences/SKILL.md +31 -0
  113. package/resources/skills/recall/SKILL.md +24 -0
  114. package/resources/skills/roles/SKILL.md +18 -0
  115. package/resources/skills/spaces/SKILL.md +18 -0
  116. package/resources/skills/tasks/SKILL.md +45 -0
  117. package/resources/templates/AGENTS.md +157 -0
  118. package/resources/templates/env.template +34 -0
  119. package/resources/templates/mercury.example.yaml +75 -0
  120. package/src/adapters/discord-native.ts +534 -0
  121. package/src/adapters/discord.ts +38 -0
  122. package/src/adapters/setup.ts +89 -0
  123. package/src/adapters/slack.ts +9 -0
  124. package/src/adapters/whatsapp-media.ts +337 -0
  125. package/src/adapters/whatsapp.ts +629 -0
  126. package/src/agent/api-socket.ts +127 -0
  127. package/src/agent/container-entry.ts +967 -0
  128. package/src/agent/container-error.ts +49 -0
  129. package/src/agent/container-runner.ts +1272 -0
  130. package/src/agent/model-capabilities-core.ts +23 -0
  131. package/src/agent/model-capabilities.ts +231 -0
  132. package/src/agent/pi-failure-class.ts +83 -0
  133. package/src/agent/pi-jsonl-parser.ts +306 -0
  134. package/src/agent/preferences-prompt.ts +20 -0
  135. package/src/agent/user-error-messages.ts +78 -0
  136. package/src/bridges/discord.ts +171 -0
  137. package/src/bridges/slack.ts +177 -0
  138. package/src/bridges/teams.ts +160 -0
  139. package/src/bridges/telegram.ts +571 -0
  140. package/src/bridges/whatsapp.ts +290 -0
  141. package/src/chat-shim.ts +259 -0
  142. package/src/cli/mercury.ts +2508 -0
  143. package/src/cli/mrctl-http.ts +27 -0
  144. package/src/cli/mrctl.ts +611 -0
  145. package/src/cli/whatsapp-auth.ts +260 -0
  146. package/src/config-file.ts +397 -0
  147. package/src/config-model-chain.ts +30 -0
  148. package/src/config.ts +316 -0
  149. package/src/core/api-types.ts +58 -0
  150. package/src/core/api.ts +105 -0
  151. package/src/core/commands.ts +76 -0
  152. package/src/core/conversation.ts +47 -0
  153. package/src/core/handler.ts +206 -0
  154. package/src/core/media.ts +200 -0
  155. package/src/core/mute-duration.ts +22 -0
  156. package/src/core/outbox.ts +76 -0
  157. package/src/core/permissions.ts +192 -0
  158. package/src/core/profiles.ts +245 -0
  159. package/src/core/rate-limiter.ts +127 -0
  160. package/src/core/router.ts +191 -0
  161. package/src/core/routes/chat.ts +172 -0
  162. package/src/core/routes/config-builtin.ts +107 -0
  163. package/src/core/routes/config.ts +81 -0
  164. package/src/core/routes/connections.ts +190 -0
  165. package/src/core/routes/console.ts +668 -0
  166. package/src/core/routes/control.ts +46 -0
  167. package/src/core/routes/conversations.ts +66 -0
  168. package/src/core/routes/dashboard.ts +2491 -0
  169. package/src/core/routes/extensions.ts +37 -0
  170. package/src/core/routes/index.ts +14 -0
  171. package/src/core/routes/media.ts +72 -0
  172. package/src/core/routes/messages.ts +37 -0
  173. package/src/core/routes/mutes.ts +89 -0
  174. package/src/core/routes/prefs.ts +95 -0
  175. package/src/core/routes/roles.ts +125 -0
  176. package/src/core/routes/spaces.ts +60 -0
  177. package/src/core/routes/storage.ts +126 -0
  178. package/src/core/routes/tasks.ts +189 -0
  179. package/src/core/routes/tradestation.ts +268 -0
  180. package/src/core/routes/tts.ts +51 -0
  181. package/src/core/runtime.ts +1140 -0
  182. package/src/core/space-queue.ts +103 -0
  183. package/src/core/storage-cleanup.ts +140 -0
  184. package/src/core/storage-guard.ts +24 -0
  185. package/src/core/task-scheduler.ts +132 -0
  186. package/src/core/telegram-format.ts +178 -0
  187. package/src/core/trigger.ts +142 -0
  188. package/src/dashboard/index.html +729 -0
  189. package/src/dashboard/tokens.css +53 -0
  190. package/src/extensions/api.ts +252 -0
  191. package/src/extensions/catalog.ts +117 -0
  192. package/src/extensions/config-registry.ts +83 -0
  193. package/src/extensions/context.ts +36 -0
  194. package/src/extensions/hooks.ts +156 -0
  195. package/src/extensions/image-builder.ts +617 -0
  196. package/src/extensions/installer.ts +306 -0
  197. package/src/extensions/jobs.ts +122 -0
  198. package/src/extensions/loader.ts +271 -0
  199. package/src/extensions/permission-guard.ts +52 -0
  200. package/src/extensions/reserved.ts +28 -0
  201. package/src/extensions/skills.ts +123 -0
  202. package/src/extensions/types.ts +462 -0
  203. package/src/logger.ts +174 -0
  204. package/src/main.ts +586 -0
  205. package/src/server.ts +391 -0
  206. package/src/storage/db.ts +1624 -0
  207. package/src/storage/memory.ts +45 -0
  208. package/src/storage/pi-auth.ts +95 -0
  209. package/src/text/markdown.ts +117 -0
  210. package/src/text/rtl.ts +38 -0
  211. package/src/tradestation/host-api.ts +77 -0
  212. package/src/tradestation/pending-orders.ts +69 -0
  213. package/src/tts/azure.ts +52 -0
  214. package/src/tts/google.ts +128 -0
  215. package/src/tts/index.ts +8 -0
  216. package/src/tts/language.ts +20 -0
  217. package/src/tts/synthesize.ts +133 -0
  218. package/src/types.ts +295 -0
package/src/config.ts ADDED
@@ -0,0 +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
+ }
@@ -0,0 +1,58 @@
1
+ import type { Context } from "hono";
2
+ import type { AgentContainerRunner } from "../agent/container-runner.js";
3
+ import type { AppConfig } from "../config.js";
4
+ import type { ConfigRegistry } from "../extensions/config-registry.js";
5
+ import type { ExtensionRegistry } from "../extensions/loader.js";
6
+ import type { Db } from "../storage/db.js";
7
+ import type { TradeStationFetch } from "../tradestation/host-api.js";
8
+ import { hasPermission } from "./permissions.js";
9
+ import type { SpaceQueue } from "./space-queue.js";
10
+ import type { TaskScheduler } from "./task-scheduler.js";
11
+
12
+ // ─── Context Types ────────────────────────────────────────────────────────
13
+
14
+ export interface ApiContext {
15
+ db: Db;
16
+ config: AppConfig;
17
+ containerRunner: AgentContainerRunner;
18
+ queue: SpaceQueue;
19
+ scheduler: TaskScheduler;
20
+ registry: ExtensionRegistry;
21
+ configRegistry: ConfigRegistry;
22
+ /** Optional mock for TradeStation HTTP in tests */
23
+ tradeStationFetch?: TradeStationFetch;
24
+ }
25
+
26
+ export interface AuthContext {
27
+ callerId: string;
28
+ spaceId: string;
29
+ role: string;
30
+ }
31
+
32
+ export type Env = {
33
+ Variables: {
34
+ auth: AuthContext;
35
+ apiCtx: ApiContext;
36
+ };
37
+ };
38
+
39
+ // ─── Helper Functions ─────────────────────────────────────────────────────
40
+
41
+ export const getAuth = (c: Context<Env>): AuthContext => c.get("auth");
42
+ export const getApiCtx = (c: Context<Env>): ApiContext => c.get("apiCtx");
43
+
44
+ export const checkPerm = (
45
+ c: Context<Env>,
46
+ permission: string,
47
+ ): Response | null => {
48
+ const { spaceId, role } = c.get("auth");
49
+ const { db } = c.get("apiCtx");
50
+
51
+ if (!hasPermission(db, spaceId, role, permission)) {
52
+ return c.json(
53
+ { error: `Forbidden: requires '${permission}' permission` },
54
+ 403,
55
+ );
56
+ }
57
+ return null;
58
+ };
@@ -0,0 +1,105 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { Hono } from "hono";
3
+ import type { ApiContext, AuthContext, Env } from "./api-types.js";
4
+ import { resolveRole } from "./permissions.js";
5
+ import {
6
+ config,
7
+ connections,
8
+ control,
9
+ conversations,
10
+ extensions,
11
+ media,
12
+ messages,
13
+ mutes,
14
+ permissions,
15
+ prefs,
16
+ roles,
17
+ spaces,
18
+ tasks,
19
+ tradestation,
20
+ tts,
21
+ } from "./routes/index.js";
22
+
23
+ function safeCompare(a: string, b: string): boolean {
24
+ if (a.length !== b.length) return false;
25
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
26
+ }
27
+
28
+ // ─── App Factory ──────────────────────────────────────────────────────────
29
+
30
+ export function createApiApp(apiCtx: ApiContext): Hono<Env> {
31
+ const app = new Hono<Env>();
32
+
33
+ // ─── Auth Middleware ────────────────────────────────────────────────────
34
+
35
+ app.use("*", async (c, next) => {
36
+ // Validate API secret when configured
37
+ const secret = apiCtx.config.apiSecret;
38
+ if (secret) {
39
+ const authHeader = c.req.header("authorization");
40
+ const token = authHeader?.startsWith("Bearer ")
41
+ ? authHeader.slice(7)
42
+ : undefined;
43
+
44
+ if (!token || !safeCompare(token, secret)) {
45
+ return c.json({ error: "Unauthorized" }, 401);
46
+ }
47
+ }
48
+
49
+ // Parse auth headers
50
+ const callerId = c.req.header("x-mercury-caller");
51
+ const spaceId = c.req.header("x-mercury-space");
52
+
53
+ if (!callerId || !spaceId) {
54
+ return c.json(
55
+ { error: "Missing X-Mercury-Caller or X-Mercury-Space headers" },
56
+ 400,
57
+ );
58
+ }
59
+
60
+ // Resolve role
61
+ const seededAdmins = apiCtx.config.admins
62
+ ? apiCtx.config.admins
63
+ .split(",")
64
+ .map((s) => s.trim())
65
+ .filter(Boolean)
66
+ : [];
67
+
68
+ apiCtx.db.ensureSpace(spaceId);
69
+ const role = resolveRole(apiCtx.db, spaceId, callerId, seededAdmins);
70
+
71
+ // Store in request context
72
+ c.set("auth", { callerId, spaceId, role } as AuthContext);
73
+ c.set("apiCtx", apiCtx);
74
+ await next();
75
+ });
76
+
77
+ // ─── Mount Routes ───────────────────────────────────────────────────────
78
+
79
+ app.route("/", control);
80
+ app.route("/tasks", tasks);
81
+ app.route("/config", config);
82
+ app.route("/prefs", prefs);
83
+ app.route("/roles", roles);
84
+ app.route("/permissions", permissions);
85
+ app.route("/spaces", spaces);
86
+ app.route("/conversations", conversations);
87
+ app.route("/media", media);
88
+ app.route("/messages", messages);
89
+ app.route("/mutes", mutes);
90
+ app.route("/ext", extensions);
91
+ app.route("/connections", connections);
92
+ app.route("/tradestation", tradestation);
93
+ app.route("/tts", tts);
94
+
95
+ // ─── Fallback ───────────────────────────────────────────────────────────
96
+
97
+ app.all("*", (c) => {
98
+ return c.json({ error: "Not found" }, 404);
99
+ });
100
+
101
+ return app;
102
+ }
103
+
104
+ // Re-export types for convenience
105
+ export type { ApiContext, AuthContext, Env } from "./api-types.js";
@@ -0,0 +1,76 @@
1
+ export interface CommandVerb {
2
+ verb: string;
3
+ args?: string; // e.g. "<N|MODEL_ID>"
4
+ description: string;
5
+ }
6
+
7
+ export interface CommandEntry {
8
+ name: string; // e.g. "model", "help"
9
+ description: string; // one-liner shown in /help
10
+ verbs?: CommandVerb[]; // defined ⟹ category; absent ⟹ leaf command
11
+ }
12
+
13
+ // Slash-command registry — source of truth for /help and router recognition.
14
+ // Add new categories here; router + executeCommand both derive from this list.
15
+ export const SLASH_COMMANDS: CommandEntry[] = [
16
+ {
17
+ name: "help",
18
+ description: "list all available commands",
19
+ },
20
+ {
21
+ name: "model",
22
+ description: "list, switch, and inspect your AI models",
23
+ verbs: [
24
+ { verb: "list", description: "list your configured models" },
25
+ { verb: "active", description: "show the active model" },
26
+ {
27
+ verb: "switch",
28
+ args: "<N|MODEL_ID>",
29
+ description: "switch by number or model ID",
30
+ },
31
+ ],
32
+ },
33
+ ];
34
+
35
+ // Legacy bare commands — still work without a slash; shown in /help for discoverability.
36
+ export const BARE_COMMANDS: Array<{ name: string; description: string }> = [
37
+ {
38
+ name: "stop",
39
+ description: "abort the current agent run and queued requests",
40
+ },
41
+ { name: "compact", description: "reset the session context window" },
42
+ { name: "clear", description: "clear context for this message" },
43
+ ];
44
+
45
+ // Generates the /help response listing all commands.
46
+ export function formatHelp(): string {
47
+ const lines: string[] = ["Available commands:", ""];
48
+
49
+ for (const cmd of SLASH_COMMANDS) {
50
+ lines.push(` /${cmd.name.padEnd(12)} ${cmd.description}`);
51
+ }
52
+
53
+ lines.push("", "Bare commands (no slash needed):");
54
+ for (const cmd of BARE_COMMANDS) {
55
+ lines.push(` ${cmd.name.padEnd(12)} ${cmd.description}`);
56
+ }
57
+
58
+ lines.push("", "Type /<category> to see detailed help for that category.");
59
+ return lines.join("\n");
60
+ }
61
+
62
+ // Generates the /<category> no-verb response.
63
+ // Returns null if name is not in the registry or the command has no verbs.
64
+ export function formatCategoryHelp(name: string): string | null {
65
+ const entry = SLASH_COMMANDS.find((c) => c.name === name);
66
+ if (!entry?.verbs || entry.verbs.length === 0) return null;
67
+
68
+ const lines: string[] = [`/${name} — ${entry.description}`, ""];
69
+ for (const v of entry.verbs) {
70
+ const usage = v.args
71
+ ? `/${name} ${v.verb} ${v.args}`
72
+ : `/${name} ${v.verb}`;
73
+ lines.push(` ${usage.padEnd(28)} ${v.description}`);
74
+ }
75
+ return lines.join("\n");
76
+ }
@@ -0,0 +1,47 @@
1
+ import type { Db } from "../storage/db.js";
2
+ import type { Conversation } from "../types.js";
3
+
4
+ export interface ConversationResolution {
5
+ conversation: Conversation;
6
+ spaceId: string;
7
+ }
8
+
9
+ export function resolveConversation(
10
+ db: Db,
11
+ platform: string,
12
+ externalId: string,
13
+ kind: string,
14
+ observedTitle?: string,
15
+ ): ConversationResolution | null {
16
+ const conversation = db.ensureConversation(
17
+ platform,
18
+ externalId,
19
+ kind,
20
+ observedTitle,
21
+ );
22
+
23
+ if (!conversation.spaceId) return null;
24
+
25
+ return { conversation, spaceId: conversation.spaceId };
26
+ }
27
+
28
+ export function inferConversationKind(
29
+ platform: string,
30
+ externalId: string,
31
+ isDM: boolean,
32
+ ): string {
33
+ if (isDM) return "dm";
34
+
35
+ switch (platform) {
36
+ case "whatsapp":
37
+ return "group";
38
+ case "discord":
39
+ return externalId.includes(":") ? "thread" : "channel";
40
+ case "slack":
41
+ return "channel";
42
+ case "teams":
43
+ return "channel";
44
+ default:
45
+ return "group";
46
+ }
47
+ }