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
@@ -0,0 +1,668 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { timingSafeEqual } from "node:crypto";
3
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { rm } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { CronExpressionParser } from "cron-parser";
7
+ import { Hono } from "hono";
8
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
9
+ import type { AppConfig } from "../../config.js";
10
+ import {
11
+ EXTENSION_CATALOG,
12
+ getCatalogEntryByName,
13
+ } from "../../extensions/catalog.js";
14
+ import {
15
+ installExtensionFromDirectory,
16
+ removeInstalledExtension,
17
+ resolveExamplesExtensionDir,
18
+ } from "../../extensions/installer.js";
19
+ import type { ExtensionRegistry } from "../../extensions/loader.js";
20
+ import { logger } from "../../logger.js";
21
+ import type { Db } from "../../storage/db.js";
22
+ import { removeSpaceWorkspace } from "../../storage/memory.js";
23
+ import {
24
+ isBuiltinConfigKey,
25
+ validateBuiltinConfigValue,
26
+ } from "./config-builtin.js";
27
+ import { resolveConnectionList } from "./connections.js";
28
+ import { ensureSpacesDirExists, getStorageInfo } from "./storage.js";
29
+
30
+ /* ── Adapter configuration helpers ──────────────────────────────── */
31
+
32
+ /** Which env vars each adapter requires (beyond the enable flag). */
33
+ const ADAPTER_CREDENTIALS: Record<string, string[]> = {
34
+ whatsapp: [],
35
+ telegram: ["MERCURY_TELEGRAM_BOT_TOKEN"],
36
+ discord: ["MERCURY_DISCORD_BOT_TOKEN"],
37
+ slack: ["MERCURY_SLACK_BOT_TOKEN", "MERCURY_SLACK_SIGNING_SECRET"],
38
+ teams: ["MERCURY_TEAMS_APP_ID", "MERCURY_TEAMS_APP_PASSWORD"],
39
+ };
40
+
41
+ const ADAPTER_ENABLE_VARS: Record<string, string> = {
42
+ whatsapp: "MERCURY_ENABLE_WHATSAPP",
43
+ telegram: "MERCURY_ENABLE_TELEGRAM",
44
+ discord: "MERCURY_ENABLE_DISCORD",
45
+ slack: "MERCURY_ENABLE_SLACK",
46
+ teams: "MERCURY_ENABLE_TEAMS",
47
+ };
48
+
49
+ /**
50
+ * Parse a `.env` file into ordered entries preserving comments and blanks.
51
+ * Returns an array of `{ key, value, raw }` where key is null for non-KV lines.
52
+ */
53
+ export function parseDotEnv(
54
+ content: string,
55
+ ): { key: string | null; value: string; raw: string }[] {
56
+ return content.split(/\r?\n/).map((raw) => {
57
+ const m = raw.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)/);
58
+ if (!m) return { key: null, value: "", raw };
59
+ return { key: m[1], value: m[2], raw };
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Update keys in a `.env` file. Keys mapped to `null` are removed.
65
+ * Writes atomically via tmp+rename.
66
+ */
67
+ export function updateDotEnv(
68
+ envPath: string,
69
+ updates: Record<string, string | null>,
70
+ ): void {
71
+ const content = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
72
+ const entries = parseDotEnv(content);
73
+ const remaining = { ...updates };
74
+
75
+ // Update or remove existing lines
76
+ const lines: string[] = [];
77
+ for (const entry of entries) {
78
+ if (entry.key && entry.key in remaining) {
79
+ const val = remaining[entry.key];
80
+ if (val !== null) {
81
+ lines.push(`${entry.key}=${val}`);
82
+ }
83
+ // val === null → skip line (remove)
84
+ delete remaining[entry.key];
85
+ } else {
86
+ lines.push(entry.raw);
87
+ }
88
+ }
89
+
90
+ // Append new keys
91
+ for (const [k, v] of Object.entries(remaining)) {
92
+ if (v !== null) {
93
+ lines.push(`${k}=${v}`);
94
+ }
95
+ }
96
+
97
+ const out = lines.join("\n");
98
+ const tmp = `${envPath}.tmp`;
99
+ writeFileSync(tmp, out, { mode: 0o600 });
100
+ renameSync(tmp, envPath);
101
+ }
102
+
103
+ /**
104
+ * Update the `ingress` section of a mercury.yaml file.
105
+ * Creates the file with just the ingress section if it doesn't exist.
106
+ */
107
+ function updateMercuryYaml(
108
+ yamlPath: string,
109
+ ingressUpdate: Record<string, boolean>,
110
+ ): void {
111
+ let doc: Record<string, unknown> = {};
112
+ if (existsSync(yamlPath)) {
113
+ const raw = readFileSync(yamlPath, "utf-8");
114
+ doc = (parseYaml(raw) as Record<string, unknown>) ?? {};
115
+ }
116
+ const ingress = (doc.ingress as Record<string, boolean>) ?? {};
117
+ Object.assign(ingress, ingressUpdate);
118
+ doc.ingress = ingress;
119
+ const out = stringifyYaml(doc);
120
+ const tmp = `${yamlPath}.tmp`;
121
+ writeFileSync(tmp, out, "utf-8");
122
+ renameSync(tmp, yamlPath);
123
+ }
124
+
125
+ function safeCompare(a: string, b: string): boolean {
126
+ if (a.length !== b.length) return false;
127
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
128
+ }
129
+
130
+ /**
131
+ * JSON control-plane API (Bearer MERCURY_API_SECRET only).
132
+ * Complements dashboard HTML forms for remote provisioning tools.
133
+ */
134
+ export function createConsoleApp(opts: {
135
+ projectRoot: string;
136
+ packageRoot: string;
137
+ apiSecret: string | undefined;
138
+ db?: Db;
139
+ spacesDir: string;
140
+ dbPath: string;
141
+ whatsappAuthDir: string;
142
+ registry?: ExtensionRegistry;
143
+ /** Used by /connections to build extension contexts for statusCheck. */
144
+ config?: AppConfig;
145
+ }): Hono {
146
+ const app = new Hono();
147
+
148
+ ensureSpacesDirExists(opts.spacesDir);
149
+
150
+ app.use("*", async (c, next) => {
151
+ if (!opts.apiSecret) {
152
+ return c.json(
153
+ { error: "MERCURY_API_SECRET must be set for /api/console" },
154
+ 503,
155
+ );
156
+ }
157
+ const auth = c.req.header("authorization");
158
+ const token = auth?.startsWith("Bearer ") ? auth.slice(7) : "";
159
+ if (!safeCompare(token, opts.apiSecret)) {
160
+ return c.json({ error: "Unauthorized" }, 401);
161
+ }
162
+ await next();
163
+ });
164
+
165
+ app.get("/extensions", (c) => {
166
+ const list = (opts.registry?.list() ?? []).map((ext) => ({
167
+ name: ext.name,
168
+ hasCli: ext.clis.length > 0,
169
+ hasSkill: !!ext.skillDir,
170
+ }));
171
+ return c.json({ extensions: list });
172
+ });
173
+
174
+ app.get("/extensions/catalog", (c) => {
175
+ return c.json({
176
+ extensions: EXTENSION_CATALOG.map((e) => ({
177
+ name: e.name,
178
+ sourceDir: e.sourceDir,
179
+ })),
180
+ });
181
+ });
182
+
183
+ app.post("/extensions/install", async (c) => {
184
+ const body = (await c.req.json().catch(() => ({}))) as {
185
+ source?: string;
186
+ catalogName?: string;
187
+ };
188
+ const source = typeof body.source === "string" ? body.source.trim() : "";
189
+ const catalogName =
190
+ typeof body.catalogName === "string" ? body.catalogName.trim() : "";
191
+
192
+ if (catalogName) {
193
+ const entry = getCatalogEntryByName(catalogName);
194
+ if (!entry) {
195
+ return c.json({ error: "Unknown catalog extension" }, 400);
196
+ }
197
+ const src = resolveExamplesExtensionDir(
198
+ opts.packageRoot,
199
+ entry.sourceDir,
200
+ );
201
+ if (!existsSync(src)) {
202
+ return c.json(
203
+ { error: "Bundled extension source not found on this install" },
204
+ 500,
205
+ );
206
+ }
207
+ const result = await installExtensionFromDirectory({
208
+ cwd: opts.projectRoot,
209
+ sourceDir: src,
210
+ destName: entry.name,
211
+ });
212
+ if (!result.ok) {
213
+ return c.json({ error: result.error }, 500);
214
+ }
215
+ return c.json({ ok: true, name: entry.name });
216
+ }
217
+
218
+ if (source) {
219
+ const r = spawnSync("mercury", ["add", source], {
220
+ cwd: opts.projectRoot,
221
+ encoding: "utf8",
222
+ env: process.env,
223
+ });
224
+ if (r.status !== 0) {
225
+ return c.json(
226
+ {
227
+ error: (r.stderr || r.stdout || "mercury add failed").trim(),
228
+ },
229
+ 500,
230
+ );
231
+ }
232
+ return c.json({ ok: true, log: (r.stdout || "").trim() });
233
+ }
234
+
235
+ return c.json(
236
+ { error: "Provide JSON body { catalogName } or { source }" },
237
+ 400,
238
+ );
239
+ });
240
+
241
+ app.post("/restart", (c) => {
242
+ setTimeout(() => process.kill(process.pid, "SIGTERM"), 500);
243
+ return c.json({ ok: true, restarting: true });
244
+ });
245
+
246
+ app.delete("/extensions/:name", (c) => {
247
+ const name = c.req.param("name");
248
+ const result = removeInstalledExtension({ cwd: opts.projectRoot, name });
249
+ if (!result.ok) {
250
+ return c.json({ error: result.error }, 400);
251
+ }
252
+ return c.json({ ok: true });
253
+ });
254
+
255
+ /**
256
+ * List all connection-enabled extensions with resolved runtime status.
257
+ * Mirrors `/api/connections` but Bearer-authed so the console can call it
258
+ * without the caller/space headers that the internal API middleware enforces.
259
+ */
260
+ app.get("/connections", async (c) => {
261
+ if (!opts.registry) {
262
+ return c.json({ error: "Extension registry not initialized" }, 503);
263
+ }
264
+ if (!opts.db) {
265
+ return c.json({ error: "Database not initialized" }, 503);
266
+ }
267
+ if (!opts.config) {
268
+ return c.json({ error: "Config not initialized" }, 503);
269
+ }
270
+ const connections = await resolveConnectionList({
271
+ registry: opts.registry,
272
+ db: opts.db,
273
+ config: opts.config,
274
+ });
275
+ return c.json({ connections });
276
+ });
277
+
278
+ /* ── Adapter management ──────────────────────────────────────── */
279
+
280
+ /** Return current adapter enable/disable state and credential presence. */
281
+ app.get("/adapters", (c) => {
282
+ const adapters: Record<
283
+ string,
284
+ { enabled: boolean; credentials: Record<string, boolean> }
285
+ > = {};
286
+ for (const [name, creds] of Object.entries(ADAPTER_CREDENTIALS)) {
287
+ const enableVar = ADAPTER_ENABLE_VARS[name];
288
+ const enabled =
289
+ process.env[enableVar]?.toLowerCase() === "true" ||
290
+ process.env[enableVar] === "1";
291
+ const credentials: Record<string, boolean> = {};
292
+ for (const envKey of creds) {
293
+ credentials[envKey] = !!process.env[envKey];
294
+ }
295
+ adapters[name] = { enabled, credentials };
296
+ }
297
+ return c.json({ adapters });
298
+ });
299
+
300
+ /**
301
+ * Configure adapters: update .env + mercury.yaml, then restart.
302
+ *
303
+ * Body: `{ adapters: { [name]: { enabled: boolean, env?: Record<string,string> } } }`
304
+ *
305
+ * Credentials are only written when provided (non-empty string).
306
+ * An adapter being disabled removes its enable flag but keeps stored credentials
307
+ * so re-enabling doesn't require re-entering them.
308
+ */
309
+ app.post("/adapters/configure", async (c) => {
310
+ const body = (await c.req.json().catch(() => ({}))) as {
311
+ adapters?: Record<
312
+ string,
313
+ { enabled?: boolean; env?: Record<string, string> }
314
+ >;
315
+ };
316
+
317
+ if (!body.adapters || typeof body.adapters !== "object") {
318
+ return c.json({ error: "Body must include { adapters: { ... } }" }, 400);
319
+ }
320
+
321
+ const envUpdates: Record<string, string | null> = {};
322
+ const ingressUpdates: Record<string, boolean> = {};
323
+
324
+ for (const [name, cfg] of Object.entries(body.adapters)) {
325
+ // __model_providers is a synthetic key used by the console to push
326
+ // model provider API keys and the model chain. It is not a real adapter —
327
+ // skip enable/credentials validation and just write the env vars.
328
+ if (name === "__model_providers") {
329
+ if (cfg.env) {
330
+ for (const [k, v] of Object.entries(cfg.env)) {
331
+ const trimmed = typeof v === "string" ? v.trim() : "";
332
+ if (trimmed) {
333
+ envUpdates[k] = trimmed;
334
+ }
335
+ }
336
+ }
337
+ continue;
338
+ }
339
+
340
+ if (!(name in ADAPTER_CREDENTIALS)) {
341
+ return c.json({ error: `Unknown adapter: ${name}` }, 400);
342
+ }
343
+
344
+ const enabled = cfg.enabled === true;
345
+ const enableVar = ADAPTER_ENABLE_VARS[name];
346
+ envUpdates[enableVar] = enabled ? "true" : "false";
347
+ ingressUpdates[name] = enabled;
348
+
349
+ // Validate that required credentials are present (either in payload or already in env)
350
+ if (enabled) {
351
+ for (const reqVar of ADAPTER_CREDENTIALS[name]) {
352
+ const inPayload = cfg.env?.[reqVar]?.trim();
353
+ const inEnv = !!process.env[reqVar];
354
+ if (!inPayload && !inEnv) {
355
+ return c.json(
356
+ {
357
+ error: `Adapter "${name}" requires ${reqVar} but it is not set`,
358
+ },
359
+ 400,
360
+ );
361
+ }
362
+ }
363
+ }
364
+
365
+ // Write any credential env vars that were provided
366
+ if (cfg.env) {
367
+ for (const [k, v] of Object.entries(cfg.env)) {
368
+ const trimmed = typeof v === "string" ? v.trim() : "";
369
+ if (trimmed) {
370
+ envUpdates[k] = trimmed;
371
+ }
372
+ // Empty string → keep existing (don't overwrite)
373
+ }
374
+ }
375
+ }
376
+
377
+ try {
378
+ const envPath = path.join(opts.projectRoot, ".env");
379
+ updateDotEnv(envPath, envUpdates);
380
+
381
+ const yamlPath = path.join(opts.projectRoot, "mercury.yaml");
382
+ updateMercuryYaml(yamlPath, ingressUpdates);
383
+ } catch (err) {
384
+ const msg = err instanceof Error ? err.message : String(err);
385
+ return c.json({ error: `Failed to write config: ${msg}` }, 500);
386
+ }
387
+
388
+ // Schedule a graceful restart so the response reaches the caller
389
+ setTimeout(() => {
390
+ process.kill(process.pid, "SIGTERM");
391
+ }, 500);
392
+
393
+ return c.json({ ok: true, restarting: true });
394
+ });
395
+
396
+ /**
397
+ * Purge Baileys WhatsApp auth files so the next re-enable generates a fresh QR.
398
+ * Idempotent: absent directory is treated as success.
399
+ * Path comes from config — never from the request body.
400
+ */
401
+ app.post("/adapters/whatsapp/purge", async (c) => {
402
+ const authDir = opts.whatsappAuthDir;
403
+ const alreadyAbsent = !existsSync(authDir);
404
+ if (!alreadyAbsent) {
405
+ try {
406
+ await rm(authDir, { recursive: true, force: true });
407
+ } catch (err) {
408
+ const detail = err instanceof Error ? err.message : String(err);
409
+ return c.json({ error: "purge_failed", detail }, 500);
410
+ }
411
+ }
412
+ logger.info("whatsapp auth purged", {
413
+ whatsappAuthDir: authDir,
414
+ alreadyAbsent,
415
+ });
416
+ const result: { wiped: boolean; alreadyAbsent?: boolean } = { wiped: true };
417
+ if (alreadyAbsent) result.alreadyAbsent = true;
418
+ return c.json(result);
419
+ });
420
+
421
+ /* ── Usage data ──────────────────────────────────────────────── */
422
+
423
+ app.get("/usage", (c) => {
424
+ if (!opts.db) {
425
+ return c.json({ error: "Database not available" }, 503);
426
+ }
427
+ const totals = opts.db.getUsageTotals();
428
+ const summary = opts.db.getUsageSummary();
429
+ const perSpace = summary.map((row) => ({
430
+ spaceId: row.spaceId,
431
+ totalInputTokens: row.totalInputTokens,
432
+ totalOutputTokens: row.totalOutputTokens,
433
+ totalTokens: row.totalTokens,
434
+ totalCost: row.totalCost,
435
+ runCount: row.runCount,
436
+ lastUsedAt: row.lastUsedAt ?? null,
437
+ }));
438
+ return c.json({ totals, perSpace });
439
+ });
440
+
441
+ app.get("/storage", async (c) => {
442
+ const info = await getStorageInfo({
443
+ spacesDir: opts.spacesDir,
444
+ dbPath: opts.dbPath,
445
+ });
446
+ return c.json(info);
447
+ });
448
+
449
+ /* ── Space config management ────────────────────────────────── */
450
+
451
+ app.get("/spaces", (c) => {
452
+ if (!opts.db) {
453
+ return c.json({ error: "Database not available" }, 503);
454
+ }
455
+ const spaces = opts.db.listSpaces();
456
+ return c.json({ spaces });
457
+ });
458
+
459
+ app.post("/spaces", async (c) => {
460
+ if (!opts.db) {
461
+ return c.json({ error: "Database not available" }, 503);
462
+ }
463
+ let body: { id?: unknown; name?: unknown };
464
+ try {
465
+ body = await c.req.json();
466
+ } catch {
467
+ return c.json({ error: "Invalid JSON body" }, 400);
468
+ }
469
+ const id = typeof body.id === "string" ? body.id.trim() : "";
470
+ const name = typeof body.name === "string" ? body.name.trim() : "";
471
+ if (!id) {
472
+ return c.json({ error: "id is required" }, 400);
473
+ }
474
+ if (!name) {
475
+ return c.json({ error: "name is required" }, 400);
476
+ }
477
+ try {
478
+ const space = opts.db.createSpace(id, name);
479
+ return c.json(space, 201);
480
+ } catch (err) {
481
+ const msg = err instanceof Error ? err.message : String(err);
482
+ if (msg.startsWith("Space already exists")) {
483
+ return c.json({ error: `Space already exists: ${id}` }, 409);
484
+ }
485
+ if (msg.startsWith("Invalid space id")) {
486
+ return c.json({ error: msg }, 400);
487
+ }
488
+ return c.json({ error: msg }, 500);
489
+ }
490
+ });
491
+
492
+ app.patch("/spaces/:spaceId", async (c) => {
493
+ if (!opts.db) {
494
+ return c.json({ error: "Database not available" }, 503);
495
+ }
496
+ let body: { name?: unknown };
497
+ try {
498
+ body = await c.req.json();
499
+ } catch {
500
+ return c.json({ error: "Invalid JSON body" }, 400);
501
+ }
502
+ const name = typeof body.name === "string" ? body.name.trim() : "";
503
+ if (!name) {
504
+ return c.json({ error: "name is required" }, 400);
505
+ }
506
+ const spaceId = c.req.param("spaceId");
507
+ const updated = opts.db.updateSpaceName(spaceId, name);
508
+ if (!updated) {
509
+ return c.json({ error: "Space not found" }, 404);
510
+ }
511
+ return c.json({ ok: true });
512
+ });
513
+
514
+ app.delete("/spaces/:spaceId", (c) => {
515
+ if (!opts.db) {
516
+ return c.json({ error: "Database not available" }, 503);
517
+ }
518
+ const spaceId = c.req.param("spaceId");
519
+ const result = opts.db.deleteSpace(spaceId);
520
+ if (!result.deleted) {
521
+ return c.json({ error: "Space not found" }, 404);
522
+ }
523
+ removeSpaceWorkspace(opts.spacesDir, spaceId);
524
+ return c.json({ ok: true, removed: result.removed });
525
+ });
526
+
527
+ app.get("/spaces/:spaceId/config", (c) => {
528
+ if (!opts.db) {
529
+ return c.json({ error: "Database not available" }, 503);
530
+ }
531
+ const spaceId = c.req.param("spaceId");
532
+ const config: Record<string, string> = {};
533
+ for (const entry of opts.db.listSpaceConfig(spaceId)) {
534
+ config[entry.key] = entry.value;
535
+ }
536
+ return c.json({ spaceId, config });
537
+ });
538
+
539
+ app.put("/spaces/:spaceId/config", async (c) => {
540
+ if (!opts.db) {
541
+ return c.json({ error: "Database not available" }, 503);
542
+ }
543
+ const spaceId = c.req.param("spaceId");
544
+ const body = (await c.req.json().catch(() => ({}))) as {
545
+ key?: string;
546
+ value?: string;
547
+ };
548
+ const key = typeof body.key === "string" ? body.key.trim() : "";
549
+ const value = typeof body.value === "string" ? body.value : "";
550
+
551
+ if (!key) {
552
+ return c.json({ error: "key is required" }, 400);
553
+ }
554
+
555
+ // Validate against built-in keys
556
+ if (isBuiltinConfigKey(key)) {
557
+ const err = validateBuiltinConfigValue(key, value);
558
+ if (err) {
559
+ return c.json({ error: err }, 400);
560
+ }
561
+ }
562
+
563
+ opts.db.setSpaceConfig(spaceId, key, value, "console");
564
+ return c.json({ ok: true });
565
+ });
566
+
567
+ app.post("/spaces/:spaceId/tasks", async (c) => {
568
+ if (!opts.db) {
569
+ return c.json({ error: "Database not available" }, 503);
570
+ }
571
+ const spaceId = c.req.param("spaceId");
572
+
573
+ const space = opts.db.getSpace(spaceId);
574
+ if (!space) {
575
+ return c.json({ error: "Space not found" }, 404);
576
+ }
577
+
578
+ const body = (await c.req.json().catch(() => ({}))) as {
579
+ cron?: unknown;
580
+ prompt?: unknown;
581
+ timezone?: unknown;
582
+ name?: unknown;
583
+ };
584
+
585
+ const cron = typeof body.cron === "string" ? body.cron.trim() : "";
586
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
587
+
588
+ if (!cron) {
589
+ return c.json({ error: "cron is required" }, 400);
590
+ }
591
+ if (!prompt) {
592
+ return c.json({ error: "prompt is required" }, 400);
593
+ }
594
+
595
+ let timezone: string | undefined;
596
+ if (typeof body.timezone === "string" && body.timezone) {
597
+ try {
598
+ if (!Intl.supportedValuesOf("timeZone").includes(body.timezone)) {
599
+ return c.json({ error: "Invalid timezone identifier" }, 400);
600
+ }
601
+ timezone = body.timezone;
602
+ } catch {
603
+ return c.json({ error: "Invalid timezone identifier" }, 400);
604
+ }
605
+ }
606
+
607
+ const name =
608
+ typeof body.name === "string" && body.name.trim()
609
+ ? body.name.trim().slice(0, 100)
610
+ : undefined;
611
+
612
+ let nextRunAt: number;
613
+ try {
614
+ const interval = CronExpressionParser.parse(cron, {
615
+ currentDate: new Date(),
616
+ tz: timezone ?? "UTC",
617
+ });
618
+ nextRunAt = interval.next().getTime();
619
+ } catch {
620
+ return c.json({ error: "Invalid cron expression" }, 400);
621
+ }
622
+
623
+ const id = opts.db.createTask(
624
+ spaceId,
625
+ { cron },
626
+ prompt,
627
+ nextRunAt,
628
+ "system",
629
+ false,
630
+ timezone,
631
+ name,
632
+ );
633
+
634
+ return c.json(
635
+ {
636
+ id,
637
+ cron,
638
+ prompt,
639
+ timezone: timezone ?? null,
640
+ name: name ?? null,
641
+ nextRunAt,
642
+ },
643
+ 201,
644
+ );
645
+ });
646
+
647
+ app.get("/spaces/:spaceId/messages", (c) => {
648
+ if (!opts.db) {
649
+ return c.json({ error: "Database not available" }, 503);
650
+ }
651
+ const spaceId = c.req.param("spaceId");
652
+ const limitParam = c.req.query("limit");
653
+ const limit = Math.min(Math.max(1, Number(limitParam) || 50), 200);
654
+ const messages = opts.db.getRecentMessages(spaceId, limit);
655
+ messages.reverse();
656
+ return c.json({
657
+ messages: messages.map((m) => ({
658
+ id: m.id,
659
+ role: m.role,
660
+ content: m.content,
661
+ createdAt: m.createdAt,
662
+ runMeta: m.runMeta ? JSON.stringify(m.runMeta) : null,
663
+ })),
664
+ });
665
+ });
666
+
667
+ return app;
668
+ }
@@ -0,0 +1,46 @@
1
+ import { Hono } from "hono";
2
+ import { checkPerm, type Env, getApiCtx, getAuth } from "../api-types.js";
3
+ import { getRolePermissions } from "../permissions.js";
4
+
5
+ export const control = new Hono<Env>();
6
+
7
+ control.get("/whoami", (c) => {
8
+ const { callerId, spaceId, role } = getAuth(c);
9
+ const { db } = getApiCtx(c);
10
+ const permissions = [...getRolePermissions(db, spaceId, role)];
11
+ return c.json({ callerId, spaceId, role, permissions });
12
+ });
13
+
14
+ control.post("/stop", (c) => {
15
+ const { spaceId } = getAuth(c);
16
+ const denied = checkPerm(c, "stop");
17
+ if (denied) return denied;
18
+
19
+ const { containerRunner, queue } = getApiCtx(c);
20
+ const stopped = containerRunner.abort(spaceId);
21
+ const dropped = queue.cancelPending(spaceId);
22
+
23
+ return c.json({ stopped, dropped });
24
+ });
25
+
26
+ control.post("/compact", (c) => {
27
+ const { spaceId } = getAuth(c);
28
+ const denied = checkPerm(c, "compact");
29
+ if (denied) return denied;
30
+
31
+ const { db } = getApiCtx(c);
32
+ const boundary = db.setSessionBoundaryToLatest(spaceId);
33
+
34
+ return c.json({ spaceId, boundary });
35
+ });
36
+
37
+ control.post("/clear", (c) => {
38
+ const { spaceId } = getAuth(c);
39
+ const denied = checkPerm(c, "clear");
40
+ if (denied) return denied;
41
+
42
+ const { db } = getApiCtx(c);
43
+ const boundary = db.setClearBoundary(spaceId);
44
+
45
+ return c.json({ spaceId, boundary });
46
+ });