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,2491 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { Hono } from "hono";
4
+ import { html, raw } from "hono/html";
5
+ import { streamSSE } from "hono/streaming";
6
+ import type { AppConfig } from "../../config.js";
7
+ import {
8
+ EXTENSION_CATALOG,
9
+ getCatalogEntryByName,
10
+ } from "../../extensions/catalog.js";
11
+ import type { ConfigRegistry } from "../../extensions/config-registry.js";
12
+ import {
13
+ installExtensionFromDirectory,
14
+ removeInstalledExtension,
15
+ resolveExamplesExtensionDir,
16
+ } from "../../extensions/installer.js";
17
+ import type { ExtensionRegistry } from "../../extensions/loader.js";
18
+ import type { MercuryExtensionContext } from "../../extensions/types.js";
19
+ import type { MessageRunMeta } from "../../types.js";
20
+ import { parseMuteDuration } from "../mute-duration.js";
21
+ import type { MercuryCoreRuntime } from "../runtime.js";
22
+ import { loadTriggerConfig } from "../trigger.js";
23
+ import {
24
+ BUILTIN_CONFIG_DESCRIPTIONS,
25
+ BUILTIN_CONFIG_KEYS,
26
+ isBuiltinConfigKey,
27
+ validateDashboardBuiltinConfig,
28
+ } from "./config-builtin.js";
29
+ import { updateDotEnv } from "./console.js";
30
+ import { validatePrefKey, validatePrefValue } from "./prefs.js";
31
+
32
+ const VOICE_TRANSCRIBE_EXT = "voice-transcribe";
33
+ const VT_KEY = {
34
+ provider: `${VOICE_TRANSCRIBE_EXT}.provider`,
35
+ local_engine: `${VOICE_TRANSCRIBE_EXT}.local_engine`,
36
+ model: `${VOICE_TRANSCRIBE_EXT}.model`,
37
+ } as const;
38
+
39
+ const VOICE_SYNTH_EXT = "voice-synth";
40
+ const VS_KEY = {
41
+ mode: `${VOICE_SYNTH_EXT}.mode`,
42
+ auto: `${VOICE_SYNTH_EXT}.auto`,
43
+ } as const;
44
+
45
+ type VoiceTranscribePreset = {
46
+ id: string;
47
+ label: string;
48
+ provider: string;
49
+ local_engine: string;
50
+ model: string;
51
+ };
52
+
53
+ /** Curated STT setups (must stay consistent with voice-transcribe extension skill). */
54
+ const VOICE_TRANSCRIBE_PRESETS: VoiceTranscribePreset[] = [
55
+ {
56
+ id: "he_tiny_tf",
57
+ label: "Hebrew tiny (Transformers, local)",
58
+ provider: "local",
59
+ local_engine: "transformers",
60
+ model: "mike249/whisper-tiny-he-2",
61
+ },
62
+ {
63
+ id: "he_ivrit_fw",
64
+ label: "Hebrew Faster-Whisper v2 d4 (local)",
65
+ provider: "local",
66
+ local_engine: "faster_whisper",
67
+ model: "ivrit-ai/faster-whisper-v2-d4",
68
+ },
69
+ {
70
+ id: "api_whisper_large",
71
+ label: "Whisper Large v3 (Hugging Face Inference API)",
72
+ provider: "api",
73
+ local_engine: "transformers",
74
+ model: "openai/whisper-large-v3",
75
+ },
76
+ ];
77
+
78
+ const VOICE_CUSTOM_MODEL_MAX_LEN = 200;
79
+
80
+ interface DashboardContext {
81
+ core: MercuryCoreRuntime;
82
+ adapters: Record<string, boolean>;
83
+ startTime: number;
84
+ registry?: ExtensionRegistry;
85
+ extensionCtx?: MercuryExtensionContext;
86
+ /** When set, enables voice-transcribe dashboard panel if extension keys are registered. */
87
+ configRegistry?: ConfigRegistry;
88
+ projectRoot: string;
89
+ packageRoot: string;
90
+ }
91
+
92
+ type HealthStatus = "healthy" | "degraded" | "critical";
93
+
94
+ function formatTokenCount(n: number): string {
95
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
96
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
97
+ return String(n);
98
+ }
99
+
100
+ export function createDashboardRoutes(ctx: DashboardContext) {
101
+ const {
102
+ core,
103
+ adapters,
104
+ startTime,
105
+ registry,
106
+ extensionCtx,
107
+ configRegistry,
108
+ projectRoot,
109
+ packageRoot,
110
+ } = ctx;
111
+ const app = new Hono();
112
+
113
+ const MODEL_PROVIDERS: {
114
+ id: string;
115
+ label: string;
116
+ envVar: string;
117
+ placeholder: string;
118
+ }[] = [
119
+ {
120
+ id: "anthropic",
121
+ label: "Anthropic",
122
+ envVar: "MERCURY_ANTHROPIC_API_KEY",
123
+ placeholder: "sk-ant-...",
124
+ },
125
+ {
126
+ id: "openai",
127
+ label: "OpenAI",
128
+ envVar: "MERCURY_OPENAI_API_KEY",
129
+ placeholder: "sk-...",
130
+ },
131
+ {
132
+ id: "google",
133
+ label: "Google Gemini",
134
+ envVar: "MERCURY_GEMINI_API_KEY",
135
+ placeholder: "AIza...",
136
+ },
137
+ {
138
+ id: "groq",
139
+ label: "Groq",
140
+ envVar: "MERCURY_GROQ_API_KEY",
141
+ placeholder: "gsk_...",
142
+ },
143
+ {
144
+ id: "mistral",
145
+ label: "Mistral",
146
+ envVar: "MERCURY_MISTRAL_API_KEY",
147
+ placeholder: "...",
148
+ },
149
+ {
150
+ id: "openrouter",
151
+ label: "OpenRouter",
152
+ envVar: "MERCURY_OPENROUTER_API_KEY",
153
+ placeholder: "sk-or-...",
154
+ },
155
+ ];
156
+
157
+ // ─── Helpers ────────────────────────────────────────────────────────────
158
+
159
+ function formatUptime(seconds: number): string {
160
+ const d = Math.floor(seconds / 86400);
161
+ const h = Math.floor((seconds % 86400) / 3600);
162
+ const m = Math.floor((seconds % 3600) / 60);
163
+ const s = seconds % 60;
164
+
165
+ if (d > 0) return `${d}d ${h}h`;
166
+ if (h > 0) return `${h}h ${m}m`;
167
+ if (m > 0) return `${m}m ${s}s`;
168
+ return `${s}s`;
169
+ }
170
+
171
+ function formatRelativeTime(timestamp: number): string {
172
+ const now = Date.now();
173
+ const diff = now - timestamp;
174
+ const seconds = Math.floor(diff / 1000);
175
+ const minutes = Math.floor(seconds / 60);
176
+ const hours = Math.floor(minutes / 60);
177
+ const days = Math.floor(hours / 24);
178
+
179
+ if (days > 0) return `${days}d ago`;
180
+ if (hours > 0) return `${hours}h ago`;
181
+ if (minutes > 0) return `${minutes}m ago`;
182
+ if (seconds > 10) return `${seconds}s ago`;
183
+ return "just now";
184
+ }
185
+
186
+ function formatFutureTime(timestamp: number): string {
187
+ const now = Date.now();
188
+ const diff = timestamp - now;
189
+ if (diff < 0) return "now";
190
+
191
+ const seconds = Math.floor(diff / 1000);
192
+ const minutes = Math.floor(seconds / 60);
193
+ const hours = Math.floor(minutes / 60);
194
+ const days = Math.floor(hours / 24);
195
+
196
+ if (days > 0) return `in ${days}d`;
197
+ if (hours > 0) return `in ${hours}h ${minutes % 60}m`;
198
+ if (minutes > 0) return `in ${minutes}m`;
199
+ return `in ${seconds}s`;
200
+ }
201
+
202
+ function escapeHtml(str: string): string {
203
+ if (!str) return "";
204
+ return str
205
+ .replace(/&/g, "&amp;")
206
+ .replace(/</g, "&lt;")
207
+ .replace(/>/g, "&gt;")
208
+ .replace(/"/g, "&quot;");
209
+ }
210
+
211
+ function formatUserRunMetaHtml(meta: MessageRunMeta | undefined): string {
212
+ if (!meta) return "";
213
+ const parts: string[] = [];
214
+ const a = meta.agent;
215
+ if (a) {
216
+ const up =
217
+ a.inputTokens != null ? `↑${formatTokenCount(a.inputTokens)}` : "";
218
+ const down =
219
+ a.outputTokens != null ? `↓${formatTokenCount(a.outputTokens)}` : "";
220
+ const tok = [up, down].filter(Boolean).join(" ");
221
+ if (tok) {
222
+ parts.push(`<span class="mono muted">agent ${escapeHtml(tok)}</span>`);
223
+ }
224
+ }
225
+ if (parts.length === 0) return "";
226
+ return `<div class="message-run-meta" style="font-size:11px;margin-top:6px;display:flex;flex-wrap:wrap;gap:8px;align-items:center;line-height:1.4">${parts.join("")}</div>`;
227
+ }
228
+
229
+ function truncate(str: string, len = 40): string {
230
+ if (!str) return "—";
231
+ return str.length > len ? `${str.slice(0, len)}...` : str;
232
+ }
233
+
234
+ const PALETTE_SIZE = 8;
235
+
236
+ function spaceSwatchClass(
237
+ spaceId: string,
238
+ knownSpaceIds: Set<string>,
239
+ ): string {
240
+ if (!spaceId || !knownSpaceIds.has(spaceId)) return "space-badge-unknown";
241
+ let hash = 0;
242
+ for (let i = 0; i < spaceId.length; i++) {
243
+ hash = (hash * 31 + spaceId.charCodeAt(i)) >>> 0;
244
+ }
245
+ return `space-badge-${hash % PALETTE_SIZE}`;
246
+ }
247
+
248
+ function renderTriggersAmbientPanel(
249
+ spaceId: string,
250
+ spacePageReload: string,
251
+ ): string {
252
+ const cfg = core.config;
253
+ const defaultPatterns = cfg.triggerPatterns
254
+ .split(",")
255
+ .map((s) => s.trim())
256
+ .filter(Boolean);
257
+ const tc = loadTriggerConfig(core.db, spaceId, {
258
+ patterns: defaultPatterns,
259
+ match: cfg.triggerMatch,
260
+ });
261
+ const ambientOn =
262
+ core.db.getSpaceConfig(spaceId, "ambient.enabled") !== "false";
263
+
264
+ const hasOverride = (key: string) =>
265
+ core.db.getSpaceConfig(spaceId, key) !== null;
266
+
267
+ const resetBtn = (key: string) =>
268
+ hasOverride(key)
269
+ ? `<button type="button" class="btn btn-sm" title="Use project default"
270
+ hx-delete="/dashboard/api/space-config?spaceId=${encodeURIComponent(spaceId)}&key=${encodeURIComponent(key)}"
271
+ hx-swap="none"
272
+ hx-on::after-request="${spacePageReload}">Reset</button>`
273
+ : "";
274
+
275
+ const sid = escapeHtml(spaceId);
276
+
277
+ const row = (label: string, key: string, bodyHtml: string) => {
278
+ const desc = BUILTIN_CONFIG_DESCRIPTIONS[key];
279
+ const titleAttr = desc ? ` title="${escapeHtml(desc)}"` : "";
280
+ return `<div class="role-row" style="flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:10px">
281
+ <span style="min-width:160px;font-weight:500"${titleAttr}>${escapeHtml(label)}</span>
282
+ ${bodyHtml}
283
+ ${resetBtn(key)}
284
+ </div>`;
285
+ };
286
+
287
+ const matchForm = `
288
+ <form style="display:flex;gap:8px;align-items:center;flex:1;flex-wrap:wrap" class="trigger-cfg-form"
289
+ hx-post="/dashboard/api/space-config" hx-swap="none" hx-on::after-request="${spacePageReload}">
290
+ <input type="hidden" name="spaceId" value="${sid}" />
291
+ <input type="hidden" name="key" value="trigger.match" />
292
+ <select name="value" class="select" required>
293
+ <option value="mention"${tc.match === "mention" ? " selected" : ""}>mention</option>
294
+ <option value="prefix"${tc.match === "prefix" ? " selected" : ""}>prefix</option>
295
+ <option value="always"${tc.match === "always" ? " selected" : ""}>always</option>
296
+ </select>
297
+ <button type="submit" class="btn btn-sm">Save</button>
298
+ </form>`;
299
+
300
+ const patternsVal = escapeHtml(tc.patterns.join(", "));
301
+ const patternsForm = `
302
+ <form style="display:flex;gap:8px;align-items:center;flex:1;flex-wrap:wrap" class="trigger-cfg-form"
303
+ hx-post="/dashboard/api/space-config" hx-swap="none" hx-on::after-request="${spacePageReload}">
304
+ <input type="hidden" name="spaceId" value="${sid}" />
305
+ <input type="hidden" name="key" value="trigger.patterns" />
306
+ <input type="text" name="value" class="select" value="${patternsVal}" placeholder="@Name,Name" style="min-width:200px;flex:1" />
307
+ <button type="submit" class="btn btn-sm">Save</button>
308
+ </form>`;
309
+
310
+ const boolSelect = (k: string, on: boolean) => `
311
+ <form style="display:flex;gap:8px;align-items:center;flex:1;flex-wrap:wrap" class="trigger-cfg-form"
312
+ hx-post="/dashboard/api/space-config" hx-swap="none" hx-on::after-request="${spacePageReload}">
313
+ <input type="hidden" name="spaceId" value="${sid}" />
314
+ <input type="hidden" name="key" value="${escapeHtml(k)}" />
315
+ <select name="value" class="select" required>
316
+ <option value="true"${on ? " selected" : ""}>true</option>
317
+ <option value="false"${!on ? " selected" : ""}>false</option>
318
+ </select>
319
+ <button type="submit" class="btn btn-sm">Save</button>
320
+ </form>`;
321
+
322
+ return `
323
+ <p class="muted" style="margin-bottom:10px;line-height:1.5">
324
+ <strong>Project default</strong> (from env / mercury.yaml):
325
+ <span class="mono">match=${escapeHtml(cfg.triggerMatch)}</span>,
326
+ patterns <span class="mono">${escapeHtml(cfg.triggerPatterns)}</span>.
327
+ Ambient context is on unless this space sets <span class="mono">ambient.enabled</span> to <span class="mono">false</span>.
328
+ </p>
329
+ <p class="muted" style="margin-bottom:16px;line-height:1.5;font-size:0.92em">
330
+ <strong>Effective</strong> for this space:
331
+ <span class="mono">match=${escapeHtml(tc.match)}</span>,
332
+ patterns <span class="mono">${escapeHtml(tc.patterns.join(", "))}</span>,
333
+ <span class="mono">case_sensitive=${tc.caseSensitive}</span>,
334
+ <span class="mono">media_in_groups=${tc.mediaInGroups}</span>,
335
+ <span class="mono">ambient=${ambientOn}</span>
336
+ </p>
337
+ ${row("trigger.match", "trigger.match", matchForm)}
338
+ ${row("trigger.patterns", "trigger.patterns", patternsForm)}
339
+ ${row("trigger.case_sensitive", "trigger.case_sensitive", boolSelect("trigger.case_sensitive", tc.caseSensitive))}
340
+ ${row("trigger.media_in_groups", "trigger.media_in_groups", boolSelect("trigger.media_in_groups", tc.mediaInGroups))}
341
+ ${row("ambient.enabled", "ambient.enabled", boolSelect("ambient.enabled", ambientOn))}
342
+ `;
343
+ }
344
+
345
+ function renderContextPanel(
346
+ spaceId: string,
347
+ spacePageReload: string,
348
+ ): string {
349
+ const contextMode =
350
+ core.db.getSpaceConfig(spaceId, "context.mode") ?? "clear";
351
+ const windowSizeStr =
352
+ core.db.getSpaceConfig(spaceId, "context.window_size") ?? "10";
353
+ const chainDepthStr =
354
+ core.db.getSpaceConfig(spaceId, "context.reply_chain_depth") ?? "10";
355
+
356
+ const hasOverride = (key: string) =>
357
+ core.db.getSpaceConfig(spaceId, key) !== null;
358
+
359
+ const resetBtn = (key: string) =>
360
+ hasOverride(key)
361
+ ? `<button type="button" class="btn btn-sm" title="Use default"
362
+ hx-delete="/dashboard/api/space-config?spaceId=${encodeURIComponent(spaceId)}&key=${encodeURIComponent(key)}"
363
+ hx-swap="none"
364
+ hx-on::after-request="${spacePageReload}">Reset</button>`
365
+ : "";
366
+
367
+ const sid = escapeHtml(spaceId);
368
+
369
+ const row = (label: string, key: string, bodyHtml: string) => {
370
+ const desc = BUILTIN_CONFIG_DESCRIPTIONS[key];
371
+ const titleAttr = desc ? ` title="${escapeHtml(desc)}"` : "";
372
+ return `<div class="role-row" style="flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:10px">
373
+ <span style="min-width:160px;font-weight:500"${titleAttr}>${escapeHtml(label)}</span>
374
+ ${bodyHtml}
375
+ ${resetBtn(key)}
376
+ </div>`;
377
+ };
378
+
379
+ const modeForm = `
380
+ <form style="display:flex;gap:8px;align-items:center;flex:1;flex-wrap:wrap" class="trigger-cfg-form"
381
+ hx-post="/dashboard/api/space-config" hx-swap="none" hx-on::after-request="${spacePageReload}">
382
+ <input type="hidden" name="spaceId" value="${sid}" />
383
+ <input type="hidden" name="key" value="context.mode" />
384
+ <select name="value" class="select" required>
385
+ <option value="clear"${contextMode === "clear" ? " selected" : ""}>clear</option>
386
+ <option value="context"${contextMode === "context" ? " selected" : ""}>context</option>
387
+ </select>
388
+ <button type="submit" class="btn btn-sm">Save</button>
389
+ </form>`;
390
+
391
+ const windowSizeForm = `
392
+ <form style="display:flex;gap:8px;align-items:center;flex:1;flex-wrap:wrap" class="trigger-cfg-form"
393
+ hx-post="/dashboard/api/space-config" hx-swap="none" hx-on::after-request="${spacePageReload}">
394
+ <input type="hidden" name="spaceId" value="${sid}" />
395
+ <input type="hidden" name="key" value="context.window_size" />
396
+ <input type="number" name="value" class="select" value="${escapeHtml(windowSizeStr)}" min="1" max="50" required style="width:80px" />
397
+ <button type="submit" class="btn btn-sm">Save</button>
398
+ </form>`;
399
+
400
+ const depthForm = `
401
+ <form style="display:flex;gap:8px;align-items:center;flex:1;flex-wrap:wrap" class="trigger-cfg-form"
402
+ hx-post="/dashboard/api/space-config" hx-swap="none" hx-on::after-request="${spacePageReload}">
403
+ <input type="hidden" name="spaceId" value="${sid}" />
404
+ <input type="hidden" name="key" value="context.reply_chain_depth" />
405
+ <input type="number" name="value" class="select" value="${escapeHtml(chainDepthStr)}" min="1" max="50" required style="width:80px" />
406
+ <button type="submit" class="btn btn-sm">Save</button>
407
+ </form>`;
408
+
409
+ return `
410
+ <p class="muted" style="margin-bottom:10px;line-height:1.5">
411
+ <strong>clear</strong> = each message starts fresh; reply to bot for chain context.
412
+ <strong>context</strong> = sliding window of recent turns.
413
+ </p>
414
+ <p class="muted" style="margin-bottom:16px;line-height:1.5;font-size:0.92em">
415
+ <strong>Effective</strong> for this space:
416
+ <span class="mono">mode=${escapeHtml(contextMode)}</span>,
417
+ <span class="mono">window_size=${escapeHtml(windowSizeStr)}</span>,
418
+ <span class="mono">reply_chain_depth=${escapeHtml(chainDepthStr)}</span>
419
+ </p>
420
+ ${row("context.mode", "context.mode", modeForm)}
421
+ ${row("context.window_size", "context.window_size", windowSizeForm)}
422
+ ${row("context.reply_chain_depth", "context.reply_chain_depth", depthForm)}
423
+ `;
424
+ }
425
+
426
+ function renderModelBlock(cfg: AppConfig): string {
427
+ const legs = cfg.resolvedModelChain;
428
+ if (legs.length === 0) {
429
+ return '<p class="muted">No model chain configured.</p>';
430
+ }
431
+ return legs
432
+ .map((leg, i) => {
433
+ const label = i === 0 ? "Primary" : `Fallback ${i}`;
434
+ return `<div style="margin-bottom:6px"><span class="muted">${label}:</span> <span class="mono">${escapeHtml(leg.provider)}</span> / <span class="mono">${escapeHtml(leg.model)}</span></div>`;
435
+ })
436
+ .join("");
437
+ }
438
+
439
+ function renderFeaturesToast(
440
+ kind: "success" | "error",
441
+ message: string,
442
+ ): string {
443
+ const border =
444
+ kind === "success"
445
+ ? "border-color: var(--color-success)"
446
+ : "border-color: var(--color-error)";
447
+ return `<div class="features-toast" style="padding:10px 12px;border-radius:6px;margin-bottom:12px;border:1px solid var(--border);${border}">${escapeHtml(message)}</div>`;
448
+ }
449
+
450
+ function getSystemHealth(): {
451
+ status: HealthStatus;
452
+ message: string;
453
+ lastError: string | null;
454
+ } {
455
+ const adapterEntries = Object.entries(adapters);
456
+ const disconnected = adapterEntries.filter(([, connected]) => !connected);
457
+ const queueBacklog = core.queue.pendingCount > 10;
458
+
459
+ // TODO: Track actual errors in the system
460
+ const lastError = null;
461
+
462
+ if (
463
+ disconnected.length === adapterEntries.length &&
464
+ adapterEntries.length > 0
465
+ ) {
466
+ return {
467
+ status: "critical",
468
+ message: "All adapters disconnected",
469
+ lastError,
470
+ };
471
+ }
472
+
473
+ if (queueBacklog) {
474
+ return {
475
+ status: "critical",
476
+ message: `Queue backing up (${core.queue.pendingCount} pending)`,
477
+ lastError,
478
+ };
479
+ }
480
+
481
+ if (disconnected.length > 0) {
482
+ return {
483
+ status: "degraded",
484
+ message: `${disconnected.map(([n]) => n).join(", ")} disconnected`,
485
+ lastError,
486
+ };
487
+ }
488
+
489
+ return {
490
+ status: "healthy",
491
+ message: "All systems operational",
492
+ lastError,
493
+ };
494
+ }
495
+
496
+ function voiceTranscribePanelHtml(spaceId: string): string {
497
+ const reg = configRegistry;
498
+ if (!reg?.isValidKey(VT_KEY.model)) return "";
499
+
500
+ const defP = reg.get(VT_KEY.provider)?.default ?? "local";
501
+ const defL = reg.get(VT_KEY.local_engine)?.default ?? "transformers";
502
+ const defM = reg.get(VT_KEY.model)?.default ?? "";
503
+
504
+ const effProvider =
505
+ core.db.getSpaceConfig(spaceId, VT_KEY.provider)?.trim() ?? defP;
506
+ const effLocalEngine =
507
+ core.db.getSpaceConfig(spaceId, VT_KEY.local_engine)?.trim() ?? defL;
508
+ const effModel =
509
+ core.db.getSpaceConfig(spaceId, VT_KEY.model)?.trim() ?? defM;
510
+
511
+ const matched = VOICE_TRANSCRIBE_PRESETS.find(
512
+ (p) =>
513
+ p.provider === effProvider &&
514
+ p.local_engine === effLocalEngine &&
515
+ p.model === effModel,
516
+ );
517
+ const selectedPreset = matched?.id ?? "custom";
518
+
519
+ const presetOptions = [
520
+ ...VOICE_TRANSCRIBE_PRESETS.map(
521
+ (p) =>
522
+ `<option value="${escapeHtml(p.id)}"${p.id === selectedPreset ? " selected" : ""}>${escapeHtml(p.label)}</option>`,
523
+ ),
524
+ `<option value="custom"${selectedPreset === "custom" ? " selected" : ""}>Custom…</option>`,
525
+ ].join("");
526
+
527
+ const reload = `if(event.detail.successful)htmx.ajax('GET','/dashboard/page/spaces/${encodeURIComponent(spaceId)}',{target:'#main',swap:'innerHTML',pushUrl:true})`;
528
+
529
+ const customModelValue =
530
+ selectedPreset === "custom" ? escapeHtml(effModel) : "";
531
+ const selTf =
532
+ selectedPreset === "custom" && effLocalEngine === "transformers"
533
+ ? " selected"
534
+ : "";
535
+ const selFw =
536
+ selectedPreset === "custom" && effLocalEngine === "faster_whisper"
537
+ ? " selected"
538
+ : "";
539
+ const selLoc =
540
+ selectedPreset === "custom" && effProvider === "local" ? " selected" : "";
541
+ const selApi =
542
+ selectedPreset === "custom" && effProvider === "api" ? " selected" : "";
543
+
544
+ return `
545
+ <div class="panel">
546
+ <div class="panel-header">Voice transcription</div>
547
+ <div class="panel-body">
548
+ <p class="muted" style="margin-bottom:10px">Per-space STT for <span class="mono">voice-transcribe</span>. API preset requires <span class="mono">MERCURY_HF_TOKEN</span> on the Mercury host.</p>
549
+ <div class="role-row" style="flex-wrap:wrap;gap:8px;margin-bottom:8px">
550
+ <span class="muted" style="min-width:72px">Effective</span>
551
+ <span style="flex:1;min-width:200px"><span class="mono">${escapeHtml(effModel)}</span> · <span class="mono">${escapeHtml(effLocalEngine)}</span> · <span class="mono">${escapeHtml(effProvider)}</span></span>
552
+ </div>
553
+ <form class="role-row" style="flex-wrap:wrap;gap:8px;align-items:flex-end;margin-top:12px;padding-top:12px;border-top:1px solid var(--border)"
554
+ hx-post="/dashboard/api/voice-transcribe"
555
+ hx-swap="none"
556
+ hx-on::after-request="${reload}">
557
+ <input type="hidden" name="spaceId" value="${escapeHtml(spaceId)}" />
558
+ <label style="display:flex;flex-direction:column;gap:4px">
559
+ <span class="muted" style="font-size:12px">Preset</span>
560
+ <select name="preset" class="select">${presetOptions}</select>
561
+ </label>
562
+ <label style="display:flex;flex-direction:column;gap:4px;flex:1;min-width:180px">
563
+ <span class="muted" style="font-size:12px">Custom model (HF id)</span>
564
+ <input type="text" name="custom_model" class="select" value="${customModelValue}" placeholder="org/model" />
565
+ </label>
566
+ <label style="display:flex;flex-direction:column;gap:4px">
567
+ <span class="muted" style="font-size:12px">Custom engine</span>
568
+ <select name="custom_local_engine" class="select">
569
+ <option value="transformers"${selTf}>transformers</option>
570
+ <option value="faster_whisper"${selFw}>faster_whisper</option>
571
+ </select>
572
+ </label>
573
+ <label style="display:flex;flex-direction:column;gap:4px">
574
+ <span class="muted" style="font-size:12px">Custom provider</span>
575
+ <select name="custom_provider" class="select">
576
+ <option value="local"${selLoc}>local</option>
577
+ <option value="api"${selApi}>api</option>
578
+ </select>
579
+ </label>
580
+ <button type="submit" name="intent" value="apply" class="btn btn-sm">Save</button>
581
+ <button type="submit" name="intent" value="reset" class="btn btn-sm btn-danger" hx-confirm="Clear voice-transcribe overrides and use extension defaults?">Reset</button>
582
+ </form>
583
+ </div>
584
+ </div>`;
585
+ }
586
+
587
+ /** Matches voice-synth extension readVoiceSynthMode precedence. */
588
+ function effectiveVoiceSynthMode(spaceId: string): "on_demand" | "auto" {
589
+ const modeRaw = core.db.getSpaceConfig(spaceId, VS_KEY.mode)?.trim() ?? "";
590
+ if (modeRaw === "auto" || modeRaw === "on_demand") return modeRaw;
591
+ const legacy = core.db.getSpaceConfig(spaceId, VS_KEY.auto)?.trim() ?? "";
592
+ return legacy === "true" ? "auto" : "on_demand";
593
+ }
594
+
595
+ function voiceSynthPanelHtml(spaceId: string): string {
596
+ const reg = configRegistry;
597
+ if (!reg?.isValidKey(VS_KEY.mode)) return "";
598
+
599
+ const eff = effectiveVoiceSynthMode(spaceId);
600
+ const reload = `if(event.detail.successful)htmx.ajax('GET','/dashboard/page/spaces/${encodeURIComponent(spaceId)}',{target:'#main',swap:'innerHTML',pushUrl:true})`;
601
+ const selDemand = eff === "on_demand" ? " selected" : "";
602
+ const selAuto = eff === "auto" ? " selected" : "";
603
+
604
+ return `
605
+ <div class="panel">
606
+ <div class="panel-header">Voice synthesis</div>
607
+ <div class="panel-body">
608
+ <p class="muted" style="margin-bottom:10px">Per-space TTS for <span class="mono">voice-synth</span>. Host needs Azure or Google credentials (<span class="mono">MERCURY_TTS_*</span>). Callers need <span class="mono">tts.synthesize</span> for auto attachments.</p>
609
+ <div class="role-row" style="flex-wrap:wrap;gap:8px;margin-bottom:8px">
610
+ <span class="muted" style="min-width:72px">Effective</span>
611
+ <span style="flex:1;min-width:200px"><span class="mono">${escapeHtml(eff)}</span></span>
612
+ </div>
613
+ <form class="role-row" style="flex-wrap:wrap;gap:8px;align-items:flex-end;margin-top:12px;padding-top:12px;border-top:1px solid var(--border)"
614
+ hx-post="/dashboard/api/voice-synth"
615
+ hx-swap="none"
616
+ hx-on::after-request="${reload}">
617
+ <input type="hidden" name="spaceId" value="${escapeHtml(spaceId)}" />
618
+ <label style="display:flex;flex-direction:column;gap:4px">
619
+ <span class="muted" style="font-size:12px">Mode</span>
620
+ <select name="mode" class="select" required>
621
+ <option value="on_demand"${selDemand}>on_demand — TTS only via mrctl</option>
622
+ <option value="auto"${selAuto}>auto — MP3 on every reply</option>
623
+ </select>
624
+ </label>
625
+ <button type="submit" name="intent" value="apply" class="btn btn-sm">Save</button>
626
+ <button type="submit" name="intent" value="reset" class="btn btn-sm btn-danger" hx-confirm="Clear voice-synth overrides and use extension defaults?">Reset</button>
627
+ </form>
628
+ </div>
629
+ </div>`;
630
+ }
631
+
632
+ function renderExtensionWidgets(): string {
633
+ if (!registry || !extensionCtx) return "";
634
+
635
+ const allWidgets: Array<{ extName: string; label: string; html: string }> =
636
+ [];
637
+ for (const ext of registry.list()) {
638
+ for (const widget of ext.widgets) {
639
+ try {
640
+ const widgetHtml = widget.render(extensionCtx);
641
+ allWidgets.push({
642
+ extName: ext.name,
643
+ label: widget.label,
644
+ html: widgetHtml,
645
+ });
646
+ } catch {
647
+ allWidgets.push({
648
+ extName: ext.name,
649
+ label: widget.label,
650
+ html: '<p class="muted">Error rendering widget</p>',
651
+ });
652
+ }
653
+ }
654
+ }
655
+
656
+ if (allWidgets.length === 0) return "";
657
+
658
+ const widgetPanels = allWidgets
659
+ .map(
660
+ (w) => `
661
+ <div class="panel">
662
+ <div class="panel-header">${escapeHtml(w.label)} <span class="muted">${escapeHtml(w.extName)}</span></div>
663
+ <div class="panel-body">${w.html}</div>
664
+ </div>
665
+ `,
666
+ )
667
+ .join("");
668
+
669
+ return `<div class="grid-2">${widgetPanels}</div>`;
670
+ }
671
+
672
+ // ─── Page Routes (htmx content swapping) ────────────────────────────────
673
+
674
+ // Middleware: redirect direct browser access to main dashboard
675
+ app.use("/page/*", async (c, next) => {
676
+ const isHtmx = c.req.header("HX-Request") === "true";
677
+ if (!isHtmx) {
678
+ // Direct browser access - redirect to dashboard with the page in hash
679
+ const path = c.req.path.replace("/dashboard/page/", "");
680
+ return c.redirect(`/dashboard#${path}`);
681
+ }
682
+ return next();
683
+ });
684
+
685
+ app.get("/page/overview", (c) => {
686
+ const activeSpaces = core.containerRunner.getActiveSpaces();
687
+ const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000);
688
+
689
+ // Active runs
690
+ const activeRunsHtml =
691
+ activeSpaces.length > 0
692
+ ? activeSpaces
693
+ .map((spaceId) => {
694
+ const space = core.db.getSpace(spaceId);
695
+ const linked = core.db.getSpaceConversations(spaceId);
696
+ const platform = linked[0]?.platform ?? "space";
697
+ const label = space?.name ?? spaceId;
698
+ return `
699
+ <div class="active-run">
700
+ <span class="badge">${platform}</span>
701
+ <span class="mono">${escapeHtml(label)}</span>
702
+ <span class="status active">running</span>
703
+ <button class="btn btn-sm btn-danger"
704
+ hx-post="/dashboard/api/stop"
705
+ hx-headers='{"X-Mercury-Space": "${escapeHtml(spaceId)}", "X-Mercury-Caller": "dashboard"}'
706
+ hx-swap="none">Stop</button>
707
+ </div>
708
+ `;
709
+ })
710
+ .join("")
711
+ : '<div class="empty-small">No active runs</div>';
712
+
713
+ // Adapters
714
+ const adapterEntries = Object.entries(adapters);
715
+ const adaptersHtml = adapterEntries
716
+ .map(([name, connected]) => {
717
+ const status = connected ? "connected" : "disconnected";
718
+ const icon = connected ? "🟢" : "🔴";
719
+ return `
720
+ <div class="adapter-row">
721
+ <span>${icon} ${name}</span>
722
+ <span class="muted">${status}</span>
723
+ </div>
724
+ `;
725
+ })
726
+ .join("");
727
+
728
+ // Recent activity
729
+ const spaces = core.db.listSpaces();
730
+ const activity: Array<{
731
+ spaceId: string;
732
+ spaceName: string;
733
+ platform: string;
734
+ role: string;
735
+ preview: string;
736
+ time: number;
737
+ }> = [];
738
+
739
+ for (const space of spaces.slice(0, 5)) {
740
+ const msgs = core.db.getRecentMessages(space.id, 3);
741
+ const linked = core.db.getSpaceConversations(space.id);
742
+ const platform = linked[0]?.platform ?? "space";
743
+ for (const m of msgs) {
744
+ activity.push({
745
+ spaceId: space.id,
746
+ spaceName: space.name,
747
+ platform,
748
+ role: m.role,
749
+ preview: m.content.slice(0, 60),
750
+ time: m.createdAt,
751
+ });
752
+ }
753
+ }
754
+ activity.sort((a, b) => b.time - a.time);
755
+
756
+ const activityHtml =
757
+ activity.length > 0
758
+ ? activity
759
+ .slice(0, 8)
760
+ .map(
761
+ (a) => `
762
+ <div class="activity-row"
763
+ hx-get="/dashboard/page/spaces/${encodeURIComponent(a.spaceId)}"
764
+ hx-target="#main"
765
+ hx-push-url="true">
766
+ <span class="time">${formatRelativeTime(a.time)}</span>
767
+ <span class="badge">${a.platform}</span>
768
+ <span class="mono">${escapeHtml(truncate(a.spaceName, 18))}</span>
769
+ <span class="role ${a.role}">${a.role}</span>
770
+ <span class="preview">${escapeHtml(a.preview)}</span>
771
+ </div>
772
+ `,
773
+ )
774
+ .join("")
775
+ : '<div class="empty-small">No recent activity</div>';
776
+
777
+ // Upcoming tasks
778
+ const tasks = core.db.listTasks().filter((t) => t.active);
779
+ const upcomingHtml =
780
+ tasks.length > 0
781
+ ? tasks
782
+ .slice(0, 3)
783
+ .map(
784
+ (t) => `
785
+ <div class="task-row">
786
+ <span class="mono">#${t.id}</span>
787
+ <span class="truncate">${escapeHtml(truncate(t.prompt, 25))}</span>
788
+ <span class="muted">${formatFutureTime(t.nextRunAt)}</span>
789
+ ${t.silent === 1 ? '<span class="badge muted">silent</span>' : '<span class="badge">chat</span>'}
790
+ <button class="btn btn-sm"
791
+ hx-post="/dashboard/api/tasks/${t.id}/run"
792
+ hx-swap="none"
793
+ title="Run now">▶</button>
794
+ </div>
795
+ `,
796
+ )
797
+ .join("")
798
+ : '<div class="empty-small">No scheduled tasks</div>';
799
+
800
+ return c.html(html`
801
+ <div class="grid-2">
802
+ <div class="panel">
803
+ <div class="panel-header">Adapters</div>
804
+ <div class="panel-body">${raw(adaptersHtml)}</div>
805
+ </div>
806
+ <div class="panel">
807
+ <div class="panel-header">
808
+ Active Work
809
+ <span class="badge">${activeSpaces.length}</span>
810
+ </div>
811
+ <div class="panel-body">${raw(activeRunsHtml)}</div>
812
+ </div>
813
+ </div>
814
+
815
+ <div class="panel">
816
+ <div class="panel-header">
817
+ Recent Activity
818
+ <a href="#" hx-get="/dashboard/page/logs" hx-target="#main" hx-push-url="true" class="link">View logs →</a>
819
+ </div>
820
+ <div class="panel-body">${raw(activityHtml)}</div>
821
+ </div>
822
+
823
+ <div class="grid-2">
824
+ <div class="panel">
825
+ <div class="panel-header">
826
+ Upcoming Tasks
827
+ <a href="#" hx-get="/dashboard/page/tasks" hx-target="#main" hx-push-url="true" class="link">View all →</a>
828
+ </div>
829
+ <div class="panel-body">${raw(upcomingHtml)}</div>
830
+ </div>
831
+ <div class="panel">
832
+ <div class="panel-header">Stats &amp; model</div>
833
+ <div class="panel-body">
834
+ <div class="stats">
835
+ <div class="stat">
836
+ <div class="stat-value">${spaces.length}</div>
837
+ <div class="stat-label">Spaces</div>
838
+ </div>
839
+ <div class="stat">
840
+ <div class="stat-value">${core.queue.pendingCount}</div>
841
+ <div class="stat-label">Queued</div>
842
+ </div>
843
+ <div class="stat">
844
+ <div class="stat-value">${formatUptime(uptimeSeconds)}</div>
845
+ <div class="stat-label">Uptime</div>
846
+ </div>
847
+ </div>
848
+ ${
849
+ extensionCtx?.config
850
+ ? raw(
851
+ `<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border)">${renderModelBlock(extensionCtx.config)}</div>`,
852
+ )
853
+ : ""
854
+ }
855
+ </div>
856
+ </div>
857
+ </div>
858
+
859
+ ${raw(renderExtensionWidgets())}
860
+ `);
861
+ });
862
+
863
+ app.get("/page/spaces", (c) => {
864
+ const spaces = core.db
865
+ .listSpaces()
866
+ .map((s) => {
867
+ const conversations = core.db.getSpaceConversations(s.id);
868
+ const msgCount = core.db.getRecentMessages(s.id, 1000).length;
869
+ return {
870
+ id: s.id,
871
+ name: s.name,
872
+ tags: s.tags,
873
+ conversationCount: conversations.length,
874
+ platforms: [...new Set(conversations.map((conv) => conv.platform))],
875
+ lastActivity: s.updatedAt,
876
+ messageCount: msgCount,
877
+ };
878
+ })
879
+ .sort((a, b) => b.lastActivity - a.lastActivity);
880
+
881
+ const rowsHtml =
882
+ spaces.length > 0
883
+ ? spaces
884
+ .map(
885
+ (s) => `
886
+ <tr class="clickable"
887
+ hx-get="/dashboard/page/spaces/${encodeURIComponent(s.id)}"
888
+ hx-target="#main"
889
+ hx-push-url="true">
890
+ <td class="mono">${escapeHtml(s.name)}</td>
891
+ <td>${s.platforms.map((p) => `<span class="badge">${escapeHtml(p)}</span>`).join(" ") || '<span class="muted">—</span>'}</td>
892
+ <td class="muted">${s.conversationCount}</td>
893
+ <td class="muted">${s.messageCount}</td>
894
+ <td class="muted">${formatRelativeTime(s.lastActivity)}</td>
895
+ </tr>
896
+ `,
897
+ )
898
+ .join("")
899
+ : '<tr><td colspan="5" class="empty">No spaces yet</td></tr>';
900
+
901
+ return c.html(html`
902
+ <div class="page-header">
903
+ <h2>Spaces</h2>
904
+ <div class="search-box">
905
+ <input type="text" placeholder="Search spaces..." id="space-search"
906
+ onkeyup="_filterTable(this, 'spaces-table')" />
907
+ </div>
908
+ </div>
909
+
910
+ <div class="panel">
911
+ <div class="table-scroll">
912
+ <table class="table" id="spaces-table">
913
+ <thead>
914
+ <tr>
915
+ <th>Name</th>
916
+ <th>Platforms</th>
917
+ <th>Conversations</th>
918
+ <th>Messages</th>
919
+ <th>Last Active</th>
920
+ </tr>
921
+ </thead>
922
+ <tbody>${raw(rowsHtml)}</tbody>
923
+ </table>
924
+ </div>
925
+ </div>
926
+ `);
927
+ });
928
+
929
+ app.get("/page/spaces/:id", (c) => {
930
+ const spaceId = decodeURIComponent(c.req.param("id"));
931
+ const group = core.db.listSpaces().find((g) => g.id === spaceId);
932
+
933
+ if (!group) {
934
+ return c.html(html`
935
+ <div class="page-header">
936
+ <a href="#" hx-get="/dashboard/page/spaces" hx-target="#main" hx-push-url="true" class="back">← Back</a>
937
+ <h2>Space not found</h2>
938
+ </div>
939
+ <div class="panel">
940
+ <div class="panel-body empty">Space "${escapeHtml(spaceId)}" not found</div>
941
+ </div>
942
+ `);
943
+ }
944
+
945
+ const linkedConversations = core.db.getSpaceConversations(spaceId);
946
+ const messages = core.db.getRecentMessages(spaceId, 50);
947
+ const roles = core.db.listRoles(spaceId);
948
+ const mutes = core.db.listMutes(spaceId);
949
+ const tasks = core.db.listTasks().filter((t) => t.spaceId === spaceId);
950
+ const configEntries = core.db.listSpaceConfig(spaceId);
951
+ const configEntriesFiltered = configEntries.filter(
952
+ (e) => !BUILTIN_CONFIG_KEYS.has(e.key),
953
+ );
954
+ const prefEntries = core.db.listSpacePreferences(spaceId);
955
+
956
+ const messagesHtml =
957
+ messages.length > 0
958
+ ? messages
959
+ .map(
960
+ (m) => `
961
+ <div class="message ${m.role}">
962
+ <div class="message-meta">
963
+ <span class="role ${m.role}">${m.role}</span>
964
+ <span class="time">${formatRelativeTime(m.createdAt)}</span>
965
+ </div>
966
+ ${m.role === "user" ? formatUserRunMetaHtml(m.runMeta) : ""}
967
+ <div class="message-content">${escapeHtml(m.content)}</div>
968
+ </div>
969
+ `,
970
+ )
971
+ .join("")
972
+ : '<div class="empty-small">No messages yet</div>';
973
+
974
+ const linkedConversationsHtml =
975
+ linkedConversations.length > 0
976
+ ? linkedConversations
977
+ .map(
978
+ (conv) => `
979
+ <div class="role-row">
980
+ <span><span class="badge">${escapeHtml(conv.platform)}</span> ${escapeHtml(conv.observedTitle || conv.externalId)}</span>
981
+ <span class="badge">${escapeHtml(conv.kind)}</span>
982
+ <button class="btn btn-sm btn-danger"
983
+ hx-post="/dashboard/api/conversations/${conv.id}/unlink"
984
+ hx-swap="none"
985
+ hx-confirm="Unlink this conversation from ${escapeHtml(group.name)}?">Unlink</button>
986
+ </div>
987
+ `,
988
+ )
989
+ .join("")
990
+ : '<div class="empty-small">No linked conversations</div>';
991
+
992
+ const spacePageReload = `if(event.detail.successful)htmx.ajax('GET','/dashboard/page/spaces/${encodeURIComponent(spaceId)}',{target:'#main',swap:'innerHTML',pushUrl:true})`;
993
+
994
+ const rolesHtml =
995
+ roles.length > 0
996
+ ? roles
997
+ .map(
998
+ (r) => `
999
+ <div class="role-row">
1000
+ <span style="display:flex;flex-direction:column;gap:1px;min-width:0">${r.displayName ? `<span>${escapeHtml(r.displayName)}</span><span class="mono muted" style="font-size:0.75rem">${escapeHtml(r.platformUserId)}</span>` : `<span class="mono">${escapeHtml(r.platformUserId)}</span>`}</span>
1001
+ <span class="badge ${r.role === "admin" ? "green" : ""}">${r.role}</span>
1002
+ ${
1003
+ r.role === "member"
1004
+ ? `<button class="btn btn-sm" title="Promote to admin" style="margin-left:auto;min-width:78px;font-size:0.75rem;color:var(--color-success)"
1005
+ hx-post="/dashboard/api/roles"
1006
+ hx-vals='${JSON.stringify({ spaceId, platformUserId: r.platformUserId, role: "admin" })}'
1007
+ hx-swap="none"
1008
+ hx-on::after-request="${spacePageReload}">↑ admin</button>`
1009
+ : `<button class="btn btn-sm" title="Demote to member" style="margin-left:auto;min-width:78px;font-size:0.75rem;color:var(--color-warning)"
1010
+ hx-post="/dashboard/api/roles"
1011
+ hx-vals='${JSON.stringify({ spaceId, platformUserId: r.platformUserId, role: "member" })}'
1012
+ hx-swap="none"
1013
+ hx-on::after-request="${spacePageReload}">↓ member</button>`
1014
+ }
1015
+ </div>
1016
+ `,
1017
+ )
1018
+ .join("")
1019
+ : '<div class="empty-small">No roles assigned</div>';
1020
+
1021
+ const voiceTranscribeHtml = voiceTranscribePanelHtml(spaceId);
1022
+ const voiceSynthHtml = voiceSynthPanelHtml(spaceId);
1023
+
1024
+ const mutesListHtml =
1025
+ mutes.length > 0
1026
+ ? mutes
1027
+ .map(
1028
+ (m) => `
1029
+ <div class="role-row">
1030
+ <span class="mono">${escapeHtml(m.platformUserId)}</span>
1031
+ <span class="badge">${escapeHtml(formatFutureTime(m.expiresAt))}</span>
1032
+ <span class="muted" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(m.reason ?? "")}">${escapeHtml(m.reason ? truncate(m.reason, 48) : "—")}</span>
1033
+ <span class="mono muted">${escapeHtml(truncate(m.mutedBy, 20))}</span>
1034
+ <button type="button" class="btn btn-sm btn-danger"
1035
+ hx-delete="/dashboard/api/mutes?spaceId=${encodeURIComponent(spaceId)}&platformUserId=${encodeURIComponent(m.platformUserId)}"
1036
+ hx-swap="none"
1037
+ hx-confirm="Unmute ${escapeHtml(m.platformUserId)}?"
1038
+ hx-on::after-request="${spacePageReload}">Unmute</button>
1039
+ </div>
1040
+ `,
1041
+ )
1042
+ .join("")
1043
+ : '<div class="empty-small">No muted users</div>';
1044
+
1045
+ const muteAddFormHtml = `
1046
+ <form class="role-row" style="flex-wrap:wrap;gap:8px;margin-top:12px;padding-top:12px;border-top:1px solid var(--border)"
1047
+ hx-post="/dashboard/api/mutes"
1048
+ hx-swap="none"
1049
+ hx-on::after-request="${spacePageReload}">
1050
+ <input type="hidden" name="spaceId" value="${escapeHtml(spaceId)}" />
1051
+ <input type="text" name="platformUserId" class="select" placeholder="Platform user id" required style="min-width:140px;flex:1;width:auto" />
1052
+ <input type="text" name="duration" class="select" placeholder="Duration (e.g. 1h)" required style="width:120px" />
1053
+ <input type="text" name="reason" class="select" placeholder="Reason (optional)" style="min-width:120px;flex:1;width:auto" />
1054
+ <button type="submit" class="btn btn-sm">Mute</button>
1055
+ </form>
1056
+ `;
1057
+
1058
+ const tasksHtml =
1059
+ tasks.length > 0
1060
+ ? tasks
1061
+ .map(
1062
+ (t) => `
1063
+ <div class="task-row">
1064
+ <span class="mono">#${t.id}</span>
1065
+ <span>${escapeHtml(truncate(t.prompt, 30))}</span>
1066
+ <span class="badge ${t.silent === 1 ? "muted" : ""}">${t.silent === 1 ? "silent" : "chat"}</span>
1067
+ <span class="badge ${t.active ? "green" : ""}">${t.active ? "active" : "paused"}</span>
1068
+ </div>
1069
+ `,
1070
+ )
1071
+ .join("")
1072
+ : '<div class="empty-small">No tasks for this space</div>';
1073
+
1074
+ const triggersAmbientHtml = renderTriggersAmbientPanel(
1075
+ spaceId,
1076
+ spacePageReload,
1077
+ );
1078
+
1079
+ const contextHtml = renderContextPanel(spaceId, spacePageReload);
1080
+
1081
+ const configHtml =
1082
+ configEntriesFiltered.length > 0
1083
+ ? configEntriesFiltered
1084
+ .map(
1085
+ (entry) => `
1086
+ <div class="task-row">
1087
+ <span class="mono">${escapeHtml(entry.key)}</span>
1088
+ <span>${escapeHtml(entry.value)}</span>
1089
+ </div>
1090
+ `,
1091
+ )
1092
+ .join("")
1093
+ : '<div class="empty-small">No extension config overrides</div>';
1094
+
1095
+ const preferencesHtml =
1096
+ prefEntries.length > 0
1097
+ ? prefEntries
1098
+ .map(
1099
+ (entry) => `
1100
+ <div class="task-row">
1101
+ <span class="mono">${escapeHtml(entry.key)}</span>
1102
+ <span style="flex:1;min-width:0;word-break:break-word">${escapeHtml(entry.value)}</span>
1103
+ <button type="button" class="btn btn-sm btn-danger"
1104
+ hx-delete="/dashboard/api/prefs?spaceId=${encodeURIComponent(spaceId)}&key=${encodeURIComponent(entry.key)}"
1105
+ hx-swap="none"
1106
+ hx-confirm="Delete preference ${escapeHtml(entry.key)}?"
1107
+ hx-on::after-request="${spacePageReload}">Delete</button>
1108
+ </div>
1109
+ `,
1110
+ )
1111
+ .join("")
1112
+ : '<div class="empty-small">No preferences yet</div>';
1113
+
1114
+ const prefsAddFormHtml = `
1115
+ <form class="role-row" style="flex-wrap:wrap;gap:8px;margin-top:12px;padding-top:12px;border-top:1px solid var(--border)"
1116
+ hx-post="/dashboard/api/prefs"
1117
+ hx-swap="none"
1118
+ hx-on::after-request="${spacePageReload}">
1119
+ <input type="hidden" name="spaceId" value="${escapeHtml(spaceId)}" />
1120
+ <input type="text" name="key" class="select" placeholder="key (e.g. stock-sources)" required style="min-width:120px;width:160px" />
1121
+ <input type="text" name="value" class="select" placeholder="Value" required style="min-width:140px;flex:1;width:auto" />
1122
+ <button type="submit" class="btn btn-sm">Add</button>
1123
+ </form>
1124
+ `;
1125
+
1126
+ return c.html(html`
1127
+ <div class="page-header">
1128
+ <a href="#" hx-get="/dashboard/page/spaces" hx-target="#main" hx-push-url="true" class="back">← Back</a>
1129
+ <h2>${escapeHtml(group.name)}</h2>
1130
+ </div>
1131
+
1132
+ <div class="grid-2">
1133
+ <div class="panel">
1134
+ <div class="panel-header">Linked Conversations</div>
1135
+ <div class="panel-body">${raw(linkedConversationsHtml)}</div>
1136
+ </div>
1137
+ <div class="panel">
1138
+ <div class="panel-header">Roles</div>
1139
+ <div class="panel-body scroll-cap">${raw(rolesHtml)}</div>
1140
+ </div>
1141
+ </div>
1142
+
1143
+ <div class="panel">
1144
+ <div class="panel-header">Muted users</div>
1145
+ <div class="panel-body">${raw(mutesListHtml)}${raw(muteAddFormHtml)}</div>
1146
+ </div>
1147
+
1148
+ <div class="panel">
1149
+ <div class="panel-header">Triggers & ambient</div>
1150
+ <div class="panel-body">${raw(triggersAmbientHtml)}</div>
1151
+ </div>
1152
+
1153
+ <div class="panel">
1154
+ <div class="panel-header">Context</div>
1155
+ <div class="panel-body">${raw(contextHtml)}</div>
1156
+ </div>
1157
+
1158
+ <div class="grid-2">
1159
+ <div class="panel">
1160
+ <div class="panel-header">Tasks</div>
1161
+ <div class="panel-body scroll-cap">${raw(tasksHtml)}</div>
1162
+ </div>
1163
+ <div class="panel">
1164
+ <div class="panel-header">Config</div>
1165
+ <div class="panel-body">${raw(configHtml)}</div>
1166
+ </div>
1167
+ </div>
1168
+
1169
+ ${raw(voiceTranscribeHtml)}
1170
+ ${raw(voiceSynthHtml)}
1171
+
1172
+ <div class="panel">
1173
+ <div class="panel-header">Preferences</div>
1174
+ <div class="panel-body">${raw(preferencesHtml)}${raw(prefsAddFormHtml)}</div>
1175
+ </div>
1176
+
1177
+ <div class="panel">
1178
+ <div class="panel-header">Recent Messages</div>
1179
+ <div class="panel-body messages-list">${raw(messagesHtml)}</div>
1180
+ </div>
1181
+ `);
1182
+ });
1183
+
1184
+ app.get("/page/conversations", (c) => {
1185
+ const spaces = core.db.listSpaces();
1186
+ const conversations = core.db.listConversations();
1187
+
1188
+ const rowsHtml =
1189
+ conversations.length > 0
1190
+ ? conversations
1191
+ .map((conv) => {
1192
+ const title = conv.observedTitle || conv.externalId;
1193
+ const linked = conv.spaceId
1194
+ ? `<span class="badge green">${escapeHtml(conv.spaceId)}</span>`
1195
+ : `
1196
+ <form hx-post="/dashboard/api/conversations/${conv.id}/link" hx-swap="none" style="display:flex; gap:8px; align-items:center;">
1197
+ <select name="spaceId" class="select">
1198
+ ${spaces
1199
+ .map(
1200
+ (space) =>
1201
+ `<option value="${escapeHtml(space.id)}">${escapeHtml(space.name)}</option>`,
1202
+ )
1203
+ .join("")}
1204
+ </select>
1205
+ <button class="btn btn-sm">Link</button>
1206
+ </form>
1207
+ `;
1208
+ const action = conv.spaceId
1209
+ ? `<button class="btn btn-sm btn-danger" hx-post="/dashboard/api/conversations/${conv.id}/unlink" hx-swap="none">Unlink</button>`
1210
+ : "";
1211
+
1212
+ return `
1213
+ <tr>
1214
+ <td><span class="badge">${escapeHtml(conv.platform)}</span></td>
1215
+ <td>${escapeHtml(title)}</td>
1216
+ <td><span class="badge">${escapeHtml(conv.kind)}</span></td>
1217
+ <td>${linked}</td>
1218
+ <td class="muted">${formatRelativeTime(conv.lastSeenAt)}</td>
1219
+ <td>${action}</td>
1220
+ </tr>
1221
+ `;
1222
+ })
1223
+ .join("")
1224
+ : '<tr><td colspan="6" class="empty">No conversations yet</td></tr>';
1225
+
1226
+ return c.html(html`
1227
+ <div class="page-header">
1228
+ <h2>Conversations</h2>
1229
+ </div>
1230
+ <div class="panel">
1231
+ <div class="table-scroll">
1232
+ <table class="table">
1233
+ <thead>
1234
+ <tr>
1235
+ <th>Platform</th>
1236
+ <th>Title</th>
1237
+ <th>Kind</th>
1238
+ <th>Linked Space</th>
1239
+ <th>Last Seen</th>
1240
+ <th>Actions</th>
1241
+ </tr>
1242
+ </thead>
1243
+ <tbody>${raw(rowsHtml)}</tbody>
1244
+ </table>
1245
+ </div>
1246
+ </div>
1247
+ `);
1248
+ });
1249
+
1250
+ app.get("/page/tasks", (c) => {
1251
+ const tasks = core.db.listTasks();
1252
+ const spaces = core.db.listSpaces();
1253
+ const spacesById = new Map(spaces.map((s) => [s.id, s]));
1254
+ const knownSpaceIds = new Set(spacesById.keys());
1255
+
1256
+ const taskSpaceIds = new Set(tasks.map((t) => t.spaceId).filter(Boolean));
1257
+
1258
+ const rowsHtml =
1259
+ tasks.length > 0
1260
+ ? tasks
1261
+ .map((t) => {
1262
+ const label =
1263
+ spacesById.get(t.spaceId)?.name ?? (t.spaceId || "global");
1264
+ const swatchClass = spaceSwatchClass(t.spaceId, knownSpaceIds);
1265
+ return `
1266
+ <tr data-space-id="${escapeHtml(t.spaceId || "")}">
1267
+ <td class="mono">#${t.id}</td>
1268
+ <td><span class="space-badge ${swatchClass}">${escapeHtml(label)}</span></td>
1269
+ <td class="mono">${escapeHtml(t.cron || "one-shot")}</td>
1270
+ <td class="mono muted">${escapeHtml(t.timezone || "UTC")}</td>
1271
+ <td class="truncate" title="${escapeHtml(t.prompt)}">${escapeHtml(truncate(t.prompt, 40))}</td>
1272
+ <td class="muted">${formatFutureTime(t.nextRunAt)}</td>
1273
+ <td><span class="badge ${t.silent === 1 ? "muted" : ""}">${t.silent === 1 ? "silent" : "chat"}</span></td>
1274
+ <td><span class="badge ${t.active ? "green" : ""}">${t.active ? "active" : "paused"}</span></td>
1275
+ <td class="actions">
1276
+ <button class="btn btn-sm" hx-post="/dashboard/api/tasks/${t.id}/run" hx-swap="none" title="Run now">▶</button>
1277
+ ${
1278
+ t.active
1279
+ ? `<button class="btn btn-sm" hx-post="/dashboard/api/tasks/${t.id}/pause" hx-swap="none" title="Pause">⏸</button>`
1280
+ : `<button class="btn btn-sm" hx-post="/dashboard/api/tasks/${t.id}/resume" hx-swap="none" title="Resume">▶️</button>`
1281
+ }
1282
+ <button class="btn btn-sm btn-danger" hx-delete="/dashboard/api/tasks/${t.id}" hx-swap="none" hx-confirm="Delete task #${t.id}?" title="Delete">✕</button>
1283
+ </td>
1284
+ </tr>
1285
+ `;
1286
+ })
1287
+ .join("")
1288
+ : '<tr><td colspan="9" class="empty">No scheduled tasks</td></tr>';
1289
+
1290
+ let filterSelectHtml = "";
1291
+ if (taskSpaceIds.size >= 2) {
1292
+ const options: string[] = [`<option value="">All spaces</option>`];
1293
+ for (const [id, space] of spacesById) {
1294
+ if (taskSpaceIds.has(id)) {
1295
+ options.push(
1296
+ `<option value="${escapeHtml(id)}">${escapeHtml(space.name)}</option>`,
1297
+ );
1298
+ }
1299
+ }
1300
+ for (const spaceId of taskSpaceIds) {
1301
+ if (!spacesById.has(spaceId)) {
1302
+ options.push(
1303
+ `<option value="${escapeHtml(spaceId)}">${escapeHtml(spaceId || "global")} (unknown)</option>`,
1304
+ );
1305
+ }
1306
+ }
1307
+ filterSelectHtml = `<div style="padding: 12px 0 0">
1308
+ <select id="task-space-filter" class="select" onchange="_filterTasksBySpace(this)">
1309
+ ${options.join("")}
1310
+ </select>
1311
+ </div>`;
1312
+ }
1313
+
1314
+ return c.html(html`
1315
+ <div class="page-header">
1316
+ <h2>Scheduled Tasks</h2>
1317
+ </div>
1318
+
1319
+ <div class="panel">
1320
+ ${raw(filterSelectHtml)}
1321
+ <div class="table-scroll">
1322
+ <table class="table" id="tasks-table">
1323
+ <thead>
1324
+ <tr>
1325
+ <th>ID</th>
1326
+ <th>Space</th>
1327
+ <th>Schedule</th>
1328
+ <th>TZ</th>
1329
+ <th>Prompt</th>
1330
+ <th>Next Run</th>
1331
+ <th>Silent</th>
1332
+ <th>Status</th>
1333
+ <th>Actions</th>
1334
+ </tr>
1335
+ </thead>
1336
+ <tbody>${raw(rowsHtml)}</tbody>
1337
+ </table>
1338
+ </div>
1339
+ </div>
1340
+ `);
1341
+ });
1342
+
1343
+ app.get("/page/permissions", (c) => {
1344
+ const groups = core.db.listSpaces();
1345
+ const allRoles: Array<{
1346
+ spaceId: string;
1347
+ platform: string;
1348
+ userId: string;
1349
+ displayName: string | null;
1350
+ role: string;
1351
+ }> = [];
1352
+
1353
+ for (const g of groups) {
1354
+ const platform = g.id.split(":")[0];
1355
+ const groupRoles = core.db.listRoles(g.id);
1356
+ for (const r of groupRoles) {
1357
+ allRoles.push({
1358
+ spaceId: g.id,
1359
+ platform,
1360
+ userId: r.platformUserId,
1361
+ displayName: r.displayName,
1362
+ role: r.role,
1363
+ });
1364
+ }
1365
+ }
1366
+
1367
+ const permPageReload = `if(event.detail.successful)htmx.ajax('GET','/dashboard/page/permissions',{target:'#main',swap:'innerHTML',pushUrl:true})`;
1368
+
1369
+ const rowsHtml =
1370
+ allRoles.length > 0
1371
+ ? allRoles
1372
+ .map(
1373
+ (r) => `
1374
+ <tr>
1375
+ <td><span class="badge">${r.platform}</span></td>
1376
+ <td class="mono truncate" title="${escapeHtml(r.spaceId)}">${escapeHtml(truncate(r.spaceId, 25))}</td>
1377
+ <td>${r.displayName ? `<span>${escapeHtml(r.displayName)}</span><br><span class="mono muted" style="font-size:0.75rem">${escapeHtml(r.userId)}</span>` : `<span class="mono">${escapeHtml(r.userId)}</span>`}</td>
1378
+ <td><span class="badge ${r.role === "admin" ? "green" : ""}">${r.role}</span></td>
1379
+ <td>
1380
+ ${
1381
+ r.role === "member"
1382
+ ? `<button class="btn btn-sm" title="Promote to admin" style="min-width:78px;font-size:0.75rem;color:var(--color-success)"
1383
+ hx-post="/dashboard/api/roles"
1384
+ hx-vals='${JSON.stringify({ spaceId: r.spaceId, platformUserId: r.userId, role: "admin" })}'
1385
+ hx-swap="none"
1386
+ hx-on::after-request="${permPageReload}">↑ admin</button>`
1387
+ : `<button class="btn btn-sm" title="Demote to member" style="min-width:78px;font-size:0.75rem;color:var(--color-warning)"
1388
+ hx-post="/dashboard/api/roles"
1389
+ hx-vals='${JSON.stringify({ spaceId: r.spaceId, platformUserId: r.userId, role: "member" })}'
1390
+ hx-swap="none"
1391
+ hx-on::after-request="${permPageReload}">↓ member</button>`
1392
+ }
1393
+ </td>
1394
+ </tr>
1395
+ `,
1396
+ )
1397
+ .join("")
1398
+ : '<tr><td colspan="5" class="empty">No roles assigned</td></tr>';
1399
+
1400
+ return c.html(html`
1401
+ <div class="page-header">
1402
+ <h2>Permissions</h2>
1403
+ </div>
1404
+
1405
+ <div class="panel">
1406
+ <div class="table-scroll">
1407
+ <table class="table">
1408
+ <thead>
1409
+ <tr>
1410
+ <th>Platform</th>
1411
+ <th>Space</th>
1412
+ <th>User</th>
1413
+ <th>Role</th>
1414
+ <th>Actions</th>
1415
+ </tr>
1416
+ </thead>
1417
+ <tbody>${raw(rowsHtml)}</tbody>
1418
+ </table>
1419
+ </div>
1420
+ </div>
1421
+ `);
1422
+ });
1423
+
1424
+ app.get("/page/logs", (c) => {
1425
+ // Aggregate recent messages as "logs" for now
1426
+ // In a real system, you'd have a proper log store
1427
+ const groups = core.db.listSpaces();
1428
+ const logs: Array<{
1429
+ time: number;
1430
+ level: string;
1431
+ source: string;
1432
+ message: string;
1433
+ spaceId?: string;
1434
+ }> = [];
1435
+
1436
+ // Add message events as logs
1437
+ for (const g of groups) {
1438
+ const msgs = core.db.getRecentMessages(g.id, 10);
1439
+ const platform = g.id.split(":")[0];
1440
+ for (const m of msgs) {
1441
+ logs.push({
1442
+ time: m.createdAt,
1443
+ level: "INFO",
1444
+ source: platform,
1445
+ message: `${m.role}: ${m.content.slice(0, 80)}`,
1446
+ spaceId: g.id,
1447
+ });
1448
+ }
1449
+ }
1450
+
1451
+ logs.sort((a, b) => b.time - a.time);
1452
+
1453
+ const logsHtml =
1454
+ logs.length > 0
1455
+ ? logs
1456
+ .slice(0, 50)
1457
+ .map(
1458
+ (l) => `
1459
+ <div class="log-row ${l.level.toLowerCase()}">
1460
+ <span class="time">${new Date(l.time).toLocaleTimeString()}</span>
1461
+ <span class="level ${l.level.toLowerCase()}">${l.level}</span>
1462
+ <span class="source">${l.source}</span>
1463
+ <span class="message">${escapeHtml(l.message)}</span>
1464
+ </div>
1465
+ `,
1466
+ )
1467
+ .join("")
1468
+ : '<div class="empty">No logs available</div>';
1469
+
1470
+ return c.html(html`
1471
+ <div class="page-header">
1472
+ <h2>Logs</h2>
1473
+ <div class="filters">
1474
+ <select class="select" onchange="filterLogs(this)">
1475
+ <option value="all">All levels</option>
1476
+ <option value="error">Errors only</option>
1477
+ <option value="info">Info</option>
1478
+ </select>
1479
+ </div>
1480
+ </div>
1481
+
1482
+ <div class="panel">
1483
+ <div class="panel-body logs-list">${raw(logsHtml)}</div>
1484
+ </div>
1485
+ `);
1486
+ });
1487
+
1488
+ // ─── Features (extensions catalog) ───────────────────────────────────────
1489
+
1490
+ app.get("/page/features", (c) => {
1491
+ const cfg = extensionCtx?.config;
1492
+ if (!cfg || !registry) {
1493
+ return c.html(html`
1494
+ <div class="page-header">
1495
+ <h2>Features</h2>
1496
+ </div>
1497
+ <p class="muted">Extension registry is not available.</p>
1498
+ `);
1499
+ }
1500
+
1501
+ const installed = registry.list();
1502
+ const installedNames = new Set(installed.map((e) => e.name));
1503
+
1504
+ const installedRows =
1505
+ installed.length > 0
1506
+ ? installed
1507
+ .map((ext) => {
1508
+ const cat = getCatalogEntryByName(ext.name);
1509
+ const desc =
1510
+ cat?.description ?? "Installed extension (outside catalog).";
1511
+ const feats = [
1512
+ ext.clis.length > 0 ? "cli" : null,
1513
+ ext.skillDir ? "skill" : null,
1514
+ ext.widgets.length > 0 ? "widget" : null,
1515
+ ]
1516
+ .filter(Boolean)
1517
+ .join(", ");
1518
+ const label = cat?.label ?? ext.name;
1519
+ return `
1520
+ <tr>
1521
+ <td class="mono">${escapeHtml(ext.name)}</td>
1522
+ <td>${escapeHtml(label)}</td>
1523
+ <td class="muted" style="max-width:320px">${escapeHtml(desc)}</td>
1524
+ <td class="muted">${escapeHtml(feats || "—")}</td>
1525
+ <td>
1526
+ <button type="button" class="btn btn-sm btn-danger"
1527
+ hx-delete="/dashboard/api/extensions/${encodeURIComponent(ext.name)}"
1528
+ hx-target="#features-toast"
1529
+ hx-swap="innerHTML"
1530
+ hx-confirm="Remove extension &quot;${escapeHtml(ext.name)}&quot;? Restart Mercury afterward.">Remove</button>
1531
+ </td>
1532
+ </tr>`;
1533
+ })
1534
+ .join("")
1535
+ : '<tr><td colspan="5" class="empty">No extensions installed. Add one from the catalog below.</td></tr>';
1536
+
1537
+ const available = EXTENSION_CATALOG.filter(
1538
+ (e) => !installedNames.has(e.name),
1539
+ );
1540
+ const availableRows =
1541
+ available.length > 0
1542
+ ? available
1543
+ .map((entry) => {
1544
+ const srcPath = resolveExamplesExtensionDir(
1545
+ packageRoot,
1546
+ entry.sourceDir,
1547
+ );
1548
+ const missing = !existsSync(srcPath);
1549
+ const envHint = entry.requiredEnvVars?.length
1550
+ ? `<div class="muted" style="font-size:12px;margin-top:4px">Env: ${escapeHtml(entry.requiredEnvVars.join(", "))}</div>`
1551
+ : "";
1552
+ const installBtn = missing
1553
+ ? `<button type="button" class="btn btn-sm" disabled title="examples/extensions missing in this install">Unavailable</button>`
1554
+ : `<form style="display:inline" hx-post="/dashboard/api/extensions/install" hx-target="#features-toast" hx-swap="innerHTML">
1555
+ <input type="hidden" name="name" value="${escapeHtml(entry.name)}" />
1556
+ <button type="submit" class="btn btn-sm">Install</button>
1557
+ </form>`;
1558
+ return `
1559
+ <tr>
1560
+ <td class="mono">${escapeHtml(entry.name)}</td>
1561
+ <td>${escapeHtml(entry.label)}</td>
1562
+ <td class="muted" style="max-width:320px">
1563
+ ${escapeHtml(entry.description)}
1564
+ ${envHint}
1565
+ </td>
1566
+ <td class="muted">${escapeHtml(entry.category)}</td>
1567
+ <td>${installBtn}</td>
1568
+ </tr>`;
1569
+ })
1570
+ .join("")
1571
+ : '<tr><td colspan="5" class="empty">All catalog extensions are installed.</td></tr>';
1572
+
1573
+ const nAvail = available.filter((e) =>
1574
+ existsSync(resolveExamplesExtensionDir(packageRoot, e.sourceDir)),
1575
+ ).length;
1576
+
1577
+ return c.html(html`
1578
+ <div class="page-header">
1579
+ <h2>Features</h2>
1580
+ </div>
1581
+ <p class="muted" style="margin: -8px 0 16px; font-size: 13px; line-height: 1.5;">
1582
+ Install optional capabilities from the bundled catalog. Third-party sites (email, banking, etc.) are typically used via the web browsing &amp; automation extension, not separate integrations.
1583
+ <strong> Restart Mercury</strong> after install or remove. Extensions with CLIs may require an agent image rebuild (<span class="mono">mercury build</span> when developing the base image).
1584
+ </p>
1585
+
1586
+ <div id="features-toast"></div>
1587
+
1588
+ <div class="grid-2">
1589
+ <div class="panel">
1590
+ <div class="panel-header">Model chain</div>
1591
+ <div class="panel-body">${raw(renderModelBlock(cfg))}</div>
1592
+ </div>
1593
+ <div class="panel">
1594
+ <div class="panel-header">Extension counts</div>
1595
+ <div class="panel-body stats" style="grid-template-columns: 1fr 1fr">
1596
+ <div class="stat">
1597
+ <div class="stat-value">${installed.length}</div>
1598
+ <div class="stat-label">Installed</div>
1599
+ </div>
1600
+ <div class="stat">
1601
+ <div class="stat-value">${nAvail}</div>
1602
+ <div class="stat-label">Available to add</div>
1603
+ </div>
1604
+ </div>
1605
+ </div>
1606
+ </div>
1607
+
1608
+ <div class="panel">
1609
+ <div class="panel-header">Installed features</div>
1610
+ <div class="panel-body">
1611
+ <div class="table-scroll">
1612
+ <table class="table">
1613
+ <thead>
1614
+ <tr>
1615
+ <th>Name</th>
1616
+ <th>Label</th>
1617
+ <th>Description</th>
1618
+ <th>Capabilities</th>
1619
+ <th></th>
1620
+ </tr>
1621
+ </thead>
1622
+ <tbody>
1623
+ ${raw(installedRows)}
1624
+ </tbody>
1625
+ </table>
1626
+ </div>
1627
+ </div>
1628
+ </div>
1629
+
1630
+ <div class="panel">
1631
+ <div class="panel-header">Available features</div>
1632
+ <div class="panel-body">
1633
+ <div class="table-scroll">
1634
+ <table class="table">
1635
+ <thead>
1636
+ <tr>
1637
+ <th>Name</th>
1638
+ <th>Label</th>
1639
+ <th>Description</th>
1640
+ <th>Category</th>
1641
+ <th></th>
1642
+ </tr>
1643
+ </thead>
1644
+ <tbody>
1645
+ ${raw(availableRows)}
1646
+ </tbody>
1647
+ </table>
1648
+ </div>
1649
+ </div>
1650
+ </div>
1651
+ `);
1652
+ });
1653
+
1654
+ // ─── Usage ──────────────────────────────────────────────────────────────
1655
+
1656
+ function formatCost(cost: number): string {
1657
+ if (cost === 0) return "$0.00";
1658
+ if (cost < 0.01) return `$${cost.toFixed(4)}`;
1659
+ return `$${cost.toFixed(2)}`;
1660
+ }
1661
+
1662
+ app.get("/page/usage", (c) => {
1663
+ const totals = core.db.getUsageTotals();
1664
+ const perSpace = core.db.getUsageSummary();
1665
+
1666
+ const rowsHtml =
1667
+ perSpace.length > 0
1668
+ ? perSpace
1669
+ .map((s) => {
1670
+ return `
1671
+ <tr>
1672
+ <td class="mono">${escapeHtml(s.spaceName)}</td>
1673
+ <td>${formatTokenCount(s.totalInputTokens)}</td>
1674
+ <td>${formatTokenCount(s.totalOutputTokens)}</td>
1675
+ <td>${formatTokenCount(s.totalTokens)}</td>
1676
+ <td>${formatCost(s.totalCost)}</td>
1677
+ <td>${s.runCount}</td>
1678
+ <td class="muted">${formatRelativeTime(s.lastUsedAt)}</td>
1679
+ </tr>
1680
+ `;
1681
+ })
1682
+ .join("")
1683
+ : '<tr><td colspan="7" class="empty">No usage data yet. Token tracking starts after the next container run.</td></tr>';
1684
+
1685
+ c.header("Cache-Control", "no-store");
1686
+
1687
+ return c.html(html`
1688
+ <div class="page-header">
1689
+ <h2>Token Usage</h2>
1690
+ </div>
1691
+ <p class="muted" style="margin: -8px 0 16px; font-size: 13px; line-height: 1.5;">
1692
+ Figures come from the model runtime (pi JSON output). Token counts are aggregated per Mercury run; multi-step agent turns are summed. Cost is an estimate — use your provider for billing.
1693
+ </p>
1694
+ <div class=”panel”>
1695
+ <div class="panel-body stats">
1696
+ <div class="stat">
1697
+ <div class="stat-value">${formatTokenCount(totals.totalInputTokens)}</div>
1698
+ <div class="stat-label">Input Tokens</div>
1699
+ </div>
1700
+ <div class="stat">
1701
+ <div class="stat-value">${formatTokenCount(totals.totalOutputTokens)}</div>
1702
+ <div class="stat-label">Output Tokens</div>
1703
+ </div>
1704
+ <div class="stat">
1705
+ <div class="stat-value">${formatTokenCount(totals.totalTokens)}</div>
1706
+ <div class="stat-label">Total Tokens</div>
1707
+ </div>
1708
+ <div class="stat">
1709
+ <div class="stat-value">${formatCost(totals.totalCost)}</div>
1710
+ <div class="stat-label">Est. Cost</div>
1711
+ </div>
1712
+ <div class="stat">
1713
+ <div class="stat-value">${totals.runCount}</div>
1714
+ <div class="stat-label">Runs</div>
1715
+ </div>
1716
+ </div>
1717
+ </div>
1718
+
1719
+ <div class="panel">
1720
+ <div class="panel-header">Per Space</div>
1721
+ <div class="table-scroll">
1722
+ <table class="table">
1723
+ <thead>
1724
+ <tr>
1725
+ <th>Space</th>
1726
+ <th>Input</th>
1727
+ <th>Output</th>
1728
+ <th>Total</th>
1729
+ <th>Cost</th>
1730
+ <th>Runs</th>
1731
+ <th>Last Used</th>
1732
+ </tr>
1733
+ </thead>
1734
+ <tbody>${raw(rowsHtml)}</tbody>
1735
+ </table>
1736
+ </div>
1737
+ </div>
1738
+ `);
1739
+ });
1740
+
1741
+ app.get("/page/billing", (c) => {
1742
+ c.header("Cache-Control", "no-store");
1743
+ return c.html(html`
1744
+ <div class="page-header">
1745
+ <h2>Billing &amp; plan</h2>
1746
+ </div>
1747
+ <p class="muted" style="margin: -8px 0 16px; font-size: 13px; line-height: 1.5;">
1748
+ Mercury tracks <strong>estimated</strong> token usage on the Usage page. Hosted plans, add-on extensions, and invoices are managed by your
1749
+ your hosting operator when you use a managed deployment.
1750
+ </p>
1751
+ <div class="panel">
1752
+ <div class="panel-body">
1753
+ <p style="margin-bottom: 12px;">
1754
+ Self-hosted: you pay your model provider directly. Set keys in <span class="mono">.env</span> and review usage under <strong>Usage</strong>.
1755
+ </p>
1756
+ <p class="muted" style="font-size: 13px;">
1757
+ There is no in-dashboard payment method on this screen yet — it is a visibility panel for future console integration.
1758
+ </p>
1759
+ </div>
1760
+ </div>
1761
+ `);
1762
+ });
1763
+
1764
+ app.get("/page/keys", (c) => {
1765
+ c.header("Cache-Control", "no-store");
1766
+ const consoleUrl = core.config.consoleUrl;
1767
+
1768
+ // When managed by the Console, redirect users there for key management.
1769
+ // When self-hosted (no Console URL), keep the original key management UI.
1770
+ if (consoleUrl) {
1771
+ const keysUrl = `${consoleUrl.replace(/\/$/, "")}/dashboard/keys`;
1772
+ return c.html(html`
1773
+ <div class="page-header">
1774
+ <h2>API keys</h2>
1775
+ </div>
1776
+ <div class="panel" style="margin-top:8px">
1777
+ <div class="panel-body">
1778
+ <p style="font-size:13px;margin:0 0 12px;line-height:1.6">
1779
+ API keys for this agent are managed from the
1780
+ <strong>Mercury Console</strong>.<br />
1781
+ Changes made there are automatically applied to your agent.
1782
+ </p>
1783
+ <a href="${keysUrl}" target="_blank" rel="noopener noreferrer" class="btn btn-sm">
1784
+ Open Console → API Keys
1785
+ </a>
1786
+ </div>
1787
+ </div>
1788
+ `);
1789
+ }
1790
+
1791
+ const _envPath = path.join(projectRoot, ".env");
1792
+
1793
+ const providerRows = MODEL_PROVIDERS.map((p) => {
1794
+ const isSet = !!process.env[p.envVar];
1795
+ const badge = isSet
1796
+ ? `<span class="badge" style="background:var(--color-success);color:#000;font-size:11px">SET</span>`
1797
+ : `<span class="badge" style="background:var(--border);color:var(--muted);font-size:11px">NOT SET</span>`;
1798
+ const clearBtn = isSet
1799
+ ? `<button class="btn btn-sm btn-danger" type="submit" form="clear-${p.id}" style="white-space:nowrap">Clear</button>
1800
+ <form id="clear-${p.id}" hx-post="/dashboard/api/keys/clear" hx-target="#keys-feedback" hx-swap="innerHTML" style="display:none">
1801
+ <input type="hidden" name="provider" value="${p.id}" />
1802
+ </form>`
1803
+ : "";
1804
+ return `
1805
+ <div style="display:grid;grid-template-columns:140px 60px 1fr auto auto;gap:10px;align-items:center;padding:10px 0;border-bottom:1px solid var(--border)">
1806
+ <span style="font-weight:500">${p.label}</span>
1807
+ ${badge}
1808
+ <form hx-post="/dashboard/api/keys/set" hx-target="#keys-feedback" hx-swap="innerHTML"
1809
+ style="display:flex;gap:8px;align-items:center">
1810
+ <input type="hidden" name="provider" value="${p.id}" />
1811
+ <input type="password" name="apiKey" placeholder="${p.placeholder}" autocomplete="off"
1812
+ style="flex:1;min-width:0;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:var(--radius,4px);font-size:13px;font-family:monospace" />
1813
+ <button class="btn btn-sm" type="submit">Save</button>
1814
+ </form>
1815
+ <span>${clearBtn}</span>
1816
+ </div>`;
1817
+ }).join("");
1818
+
1819
+ return c.html(html`
1820
+ <div class="page-header">
1821
+ <h2>API keys</h2>
1822
+ </div>
1823
+ <p class="muted" style="margin: -8px 0 16px; font-size: 13px; line-height: 1.5;">
1824
+ Keys are written to <span class="mono">.env</span> and are <strong>never</strong> shown in plaintext.
1825
+ Mercury restarts automatically after saving.
1826
+ </p>
1827
+
1828
+ <div id="keys-feedback" style="min-height:4px"></div>
1829
+
1830
+ <div class="panel" style="margin-bottom:16px">
1831
+ <div class="panel-header" style="font-weight:600;font-size:13px">Model providers</div>
1832
+ <div class="panel-body" style="padding:0 16px">
1833
+ ${raw(providerRows)}
1834
+ </div>
1835
+ </div>
1836
+
1837
+ <div class="panel">
1838
+ <div class="panel-body">
1839
+ <p class="muted" style="font-size:13px;margin:0">
1840
+ <strong>OAuth tokens</strong> (e.g. Anthropic via <span class="mono">mercury auth login</span>)
1841
+ live under <span class="mono">.mercury/global/</span> and take precedence over API keys set here.
1842
+ Use the CLI to log in with OAuth.
1843
+ </p>
1844
+ </div>
1845
+ </div>
1846
+ `);
1847
+ });
1848
+
1849
+ app.post("/api/keys/set", async (c) => {
1850
+ // When managed by the Console, key editing is disabled in the dashboard.
1851
+ if (core.config.consoleUrl) {
1852
+ return c.html(
1853
+ renderFeaturesToast(
1854
+ "error",
1855
+ "Manage API keys from the Mercury Console.",
1856
+ ),
1857
+ );
1858
+ }
1859
+
1860
+ const form = await c.req.parseBody();
1861
+ const provider =
1862
+ typeof form.provider === "string" ? form.provider.trim() : "";
1863
+ const apiKey = typeof form.apiKey === "string" ? form.apiKey.trim() : "";
1864
+
1865
+ const meta = MODEL_PROVIDERS.find((p) => p.id === provider);
1866
+ if (!meta) {
1867
+ return c.html(renderFeaturesToast("error", "Unknown provider."));
1868
+ }
1869
+ if (!apiKey) {
1870
+ return c.html(renderFeaturesToast("error", "API key cannot be empty."));
1871
+ }
1872
+
1873
+ try {
1874
+ const envPath = path.join(projectRoot, ".env");
1875
+ updateDotEnv(envPath, { [meta.envVar]: apiKey });
1876
+ } catch (err) {
1877
+ const msg = err instanceof Error ? err.message : String(err);
1878
+ return c.html(
1879
+ renderFeaturesToast("error", `Failed to write .env: ${msg}`),
1880
+ );
1881
+ }
1882
+
1883
+ setTimeout(() => process.kill(process.pid, "SIGTERM"), 500);
1884
+ return c.html(
1885
+ renderFeaturesToast("success", `${meta.label} key saved — restarting…`),
1886
+ );
1887
+ });
1888
+
1889
+ app.post("/api/keys/clear", async (c) => {
1890
+ // When managed by the Console, key editing is disabled in the dashboard.
1891
+ if (core.config.consoleUrl) {
1892
+ return c.html(
1893
+ renderFeaturesToast(
1894
+ "error",
1895
+ "Manage API keys from the Mercury Console.",
1896
+ ),
1897
+ );
1898
+ }
1899
+
1900
+ const form = await c.req.parseBody();
1901
+ const provider =
1902
+ typeof form.provider === "string" ? form.provider.trim() : "";
1903
+
1904
+ const meta = MODEL_PROVIDERS.find((p) => p.id === provider);
1905
+ if (!meta) {
1906
+ return c.html(renderFeaturesToast("error", "Unknown provider."));
1907
+ }
1908
+
1909
+ try {
1910
+ const envPath = path.join(projectRoot, ".env");
1911
+ updateDotEnv(envPath, { [meta.envVar]: null });
1912
+ } catch (err) {
1913
+ const msg = err instanceof Error ? err.message : String(err);
1914
+ return c.html(
1915
+ renderFeaturesToast("error", `Failed to write .env: ${msg}`),
1916
+ );
1917
+ }
1918
+
1919
+ setTimeout(() => process.kill(process.pid, "SIGTERM"), 500);
1920
+ return c.html(
1921
+ renderFeaturesToast("success", `${meta.label} key cleared — restarting…`),
1922
+ );
1923
+ });
1924
+
1925
+ // ─── SSE Stream ─────────────────────────────────────────────────────────
1926
+
1927
+ app.get("/events", (c) => {
1928
+ return streamSSE(c, async (stream) => {
1929
+ const sendEvent = async (event: string, data: string) => {
1930
+ await stream.writeSSE({ event, data: data.replace(/\n/g, "") });
1931
+ };
1932
+
1933
+ const renderHealth = () => {
1934
+ const health = getSystemHealth();
1935
+ const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000);
1936
+ const icon =
1937
+ health.status === "healthy"
1938
+ ? "🟢"
1939
+ : health.status === "degraded"
1940
+ ? "🟡"
1941
+ : "🔴";
1942
+ const lastError = health.lastError
1943
+ ? `Last error: ${health.lastError}`
1944
+ : "";
1945
+
1946
+ return `
1947
+ <div class="health-status ${health.status}">
1948
+ <span class="health-icon">${icon}</span>
1949
+ <span class="health-message">${health.message}</span>
1950
+ </div>
1951
+ <div class="health-meta">
1952
+ <span class="uptime">up ${formatUptime(uptimeSeconds)}</span>
1953
+ ${lastError ? `<span class="last-error">${lastError}</span>` : ""}
1954
+ </div>
1955
+ `;
1956
+ };
1957
+
1958
+ const renderActiveCount = () => {
1959
+ const count = core.containerRunner.activeCount;
1960
+ return count > 0
1961
+ ? `<span class="badge pulse">${count} running</span>`
1962
+ : "";
1963
+ };
1964
+
1965
+ // Send initial state
1966
+ await sendEvent("health", renderHealth());
1967
+ await sendEvent("active-count", renderActiveCount());
1968
+
1969
+ // Update loop
1970
+ let running = true;
1971
+ let lastActiveCount = core.containerRunner.activeCount;
1972
+
1973
+ stream.onAbort(() => {
1974
+ running = false;
1975
+ });
1976
+
1977
+ while (running) {
1978
+ await stream.sleep(1000);
1979
+
1980
+ // Always update health (includes uptime)
1981
+ await sendEvent("health", renderHealth());
1982
+
1983
+ // Update active count only on change
1984
+ const currentActiveCount = core.containerRunner.activeCount;
1985
+ if (currentActiveCount !== lastActiveCount) {
1986
+ await sendEvent("active-count", renderActiveCount());
1987
+ lastActiveCount = currentActiveCount;
1988
+ }
1989
+ }
1990
+ });
1991
+ });
1992
+
1993
+ // ─── Dashboard Actions (no auth required, admin-only UI) ────────────────
1994
+
1995
+ app.post("/api/tasks/:id/run", async (c) => {
1996
+ const taskId = Number.parseInt(c.req.param("id"), 10);
1997
+ const task = core.db.listTasks().find((t) => t.id === taskId);
1998
+
1999
+ if (!task) {
2000
+ return c.json({ error: "Task not found" }, 404);
2001
+ }
2002
+
2003
+ const triggered = await core.scheduler.triggerTask(taskId);
2004
+ if (!triggered) {
2005
+ return c.json({ error: "Task not found or inactive" }, 400);
2006
+ }
2007
+
2008
+ return c.json({ ok: true });
2009
+ });
2010
+
2011
+ app.post("/api/tasks/:id/pause", (c) => {
2012
+ const taskId = Number.parseInt(c.req.param("id"), 10);
2013
+ const task = core.db.listTasks().find((t) => t.id === taskId);
2014
+
2015
+ if (!task) {
2016
+ return c.json({ error: "Task not found" }, 404);
2017
+ }
2018
+
2019
+ core.db.setTaskActive(taskId, false);
2020
+ return c.json({ ok: true });
2021
+ });
2022
+
2023
+ app.post("/api/tasks/:id/resume", (c) => {
2024
+ const taskId = Number.parseInt(c.req.param("id"), 10);
2025
+ const task = core.db.listTasks().find((t) => t.id === taskId);
2026
+
2027
+ if (!task) {
2028
+ return c.json({ error: "Task not found" }, 404);
2029
+ }
2030
+
2031
+ core.db.setTaskActive(taskId, true);
2032
+ return c.json({ ok: true });
2033
+ });
2034
+
2035
+ app.delete("/api/tasks/:id", (c) => {
2036
+ const taskId = Number.parseInt(c.req.param("id"), 10);
2037
+ const task = core.db.listTasks().find((t) => t.id === taskId);
2038
+
2039
+ if (!task) {
2040
+ return c.json({ error: "Task not found" }, 404);
2041
+ }
2042
+
2043
+ const deleted = core.db.deleteTask(taskId, task.spaceId);
2044
+ if (!deleted) {
2045
+ return c.json({ error: "Failed to delete task" }, 500);
2046
+ }
2047
+
2048
+ return c.json({ ok: true });
2049
+ });
2050
+
2051
+ app.post("/api/roles", async (c) => {
2052
+ const form = await c.req.parseBody();
2053
+ const spaceId = typeof form.spaceId === "string" ? form.spaceId.trim() : "";
2054
+ const platformUserId =
2055
+ typeof form.platformUserId === "string" ? form.platformUserId.trim() : "";
2056
+ const role = typeof form.role === "string" ? form.role.trim() : "";
2057
+
2058
+ if (!spaceId || !platformUserId || !role) {
2059
+ return c.json({ error: "Missing spaceId, platformUserId, or role" }, 400);
2060
+ }
2061
+
2062
+ core.db.setRole(spaceId, platformUserId, role, "dashboard");
2063
+ return c.json({ ok: true });
2064
+ });
2065
+
2066
+ app.delete("/api/roles", (c) => {
2067
+ const spaceId = c.req.query("spaceId");
2068
+ const platformUserId = c.req.query("platformUserId");
2069
+
2070
+ if (!spaceId || !platformUserId) {
2071
+ return c.json({ error: "Missing spaceId or platformUserId" }, 400);
2072
+ }
2073
+
2074
+ core.db.deleteRole(spaceId, platformUserId);
2075
+ return c.json({ ok: true });
2076
+ });
2077
+
2078
+ app.delete("/api/mutes", (c) => {
2079
+ const spaceId = c.req.query("spaceId");
2080
+ const platformUserId = c.req.query("platformUserId");
2081
+
2082
+ if (!spaceId || !platformUserId) {
2083
+ return c.json({ error: "Missing spaceId or platformUserId" }, 400);
2084
+ }
2085
+
2086
+ const removed = core.db.unmuteUser(spaceId, platformUserId);
2087
+ if (!removed) {
2088
+ return c.json({ error: "User is not muted in this space" }, 404);
2089
+ }
2090
+
2091
+ return c.json({ ok: true });
2092
+ });
2093
+
2094
+ app.post("/api/prefs", async (c) => {
2095
+ const form = await c.req.parseBody();
2096
+ const spaceId = typeof form.spaceId === "string" ? form.spaceId.trim() : "";
2097
+ const key = typeof form.key === "string" ? form.key.trim() : "";
2098
+ const value = typeof form.value === "string" ? form.value : "";
2099
+
2100
+ if (!spaceId || !key) {
2101
+ return c.json({ error: "Missing spaceId or key" }, 400);
2102
+ }
2103
+
2104
+ const keyErr = validatePrefKey(key);
2105
+ if (keyErr) return c.json({ error: keyErr }, 400);
2106
+
2107
+ const valErr = validatePrefValue(value);
2108
+ if (valErr) return c.json({ error: valErr }, 400);
2109
+
2110
+ if (!core.db.getSpace(spaceId)) {
2111
+ return c.json({ error: "Space not found" }, 404);
2112
+ }
2113
+
2114
+ try {
2115
+ core.db.setSpacePreference(spaceId, key, value, "dashboard");
2116
+ } catch (e) {
2117
+ const msg = e instanceof Error ? e.message : String(e);
2118
+ if (msg.includes("Maximum 50")) {
2119
+ return c.json({ error: msg }, 400);
2120
+ }
2121
+ throw e;
2122
+ }
2123
+
2124
+ return c.json({ ok: true });
2125
+ });
2126
+
2127
+ app.post("/api/space-config", async (c) => {
2128
+ const form = await c.req.parseBody();
2129
+ const spaceId = typeof form.spaceId === "string" ? form.spaceId.trim() : "";
2130
+ const key = typeof form.key === "string" ? form.key.trim() : "";
2131
+ const value = typeof form.value === "string" ? form.value : "";
2132
+
2133
+ if (!spaceId || !key) {
2134
+ return c.json({ error: "Missing spaceId or key" }, 400);
2135
+ }
2136
+
2137
+ const err = validateDashboardBuiltinConfig(key, value);
2138
+ if (err) return c.json({ error: err }, 400);
2139
+
2140
+ if (!core.db.getSpace(spaceId)) {
2141
+ return c.json({ error: "Space not found" }, 404);
2142
+ }
2143
+
2144
+ core.db.setSpaceConfig(spaceId, key, value, "dashboard");
2145
+ return c.json({ ok: true });
2146
+ });
2147
+
2148
+ app.delete("/api/space-config", (c) => {
2149
+ const spaceId = c.req.query("spaceId");
2150
+ const key = c.req.query("key");
2151
+
2152
+ if (!spaceId || !key) {
2153
+ return c.json({ error: "Missing spaceId or key" }, 400);
2154
+ }
2155
+
2156
+ if (!isBuiltinConfigKey(key)) {
2157
+ return c.json({ error: "Invalid config key" }, 400);
2158
+ }
2159
+
2160
+ const removed = core.db.deleteSpaceConfig(spaceId, key);
2161
+ if (!removed) {
2162
+ return c.json({ error: "Config key not set" }, 404);
2163
+ }
2164
+
2165
+ return c.json({ ok: true });
2166
+ });
2167
+
2168
+ app.delete("/api/prefs", (c) => {
2169
+ const spaceId = c.req.query("spaceId");
2170
+ const key = c.req.query("key");
2171
+
2172
+ if (!spaceId || !key) {
2173
+ return c.json({ error: "Missing spaceId or key" }, 400);
2174
+ }
2175
+
2176
+ const keyErr = validatePrefKey(key);
2177
+ if (keyErr) return c.json({ error: keyErr }, 400);
2178
+
2179
+ const removed = core.db.deleteSpacePreference(spaceId, key);
2180
+ if (!removed) {
2181
+ return c.json({ error: "Preference not found" }, 404);
2182
+ }
2183
+
2184
+ return c.json({ ok: true });
2185
+ });
2186
+
2187
+ app.post("/api/voice-transcribe", async (c) => {
2188
+ const reg = configRegistry;
2189
+ if (!reg?.isValidKey(VT_KEY.model)) {
2190
+ return c.json(
2191
+ { error: "Voice transcription extension is not loaded" },
2192
+ 400,
2193
+ );
2194
+ }
2195
+
2196
+ const form = await c.req.parseBody();
2197
+ const spaceId = typeof form.spaceId === "string" ? form.spaceId.trim() : "";
2198
+ const intentRaw = form.intent;
2199
+ const intent =
2200
+ typeof intentRaw === "string"
2201
+ ? intentRaw.trim()
2202
+ : Array.isArray(intentRaw)
2203
+ ? String(intentRaw[0] ?? "").trim()
2204
+ : "";
2205
+
2206
+ if (!spaceId) {
2207
+ return c.json({ error: "Missing spaceId" }, 400);
2208
+ }
2209
+
2210
+ if (!core.db.getSpace(spaceId)) {
2211
+ return c.json({ error: "Space not found" }, 404);
2212
+ }
2213
+
2214
+ if (intent === "reset") {
2215
+ core.db.deleteSpaceConfig(spaceId, VT_KEY.provider);
2216
+ core.db.deleteSpaceConfig(spaceId, VT_KEY.local_engine);
2217
+ core.db.deleteSpaceConfig(spaceId, VT_KEY.model);
2218
+ return c.json({ ok: true });
2219
+ }
2220
+
2221
+ if (intent !== "apply") {
2222
+ return c.json({ error: "Missing or invalid intent" }, 400);
2223
+ }
2224
+
2225
+ const presetRaw = form.preset;
2226
+ const preset =
2227
+ typeof presetRaw === "string"
2228
+ ? presetRaw.trim()
2229
+ : Array.isArray(presetRaw)
2230
+ ? String(presetRaw[0] ?? "").trim()
2231
+ : "";
2232
+
2233
+ let provider: string;
2234
+ let local_engine: string;
2235
+ let model: string;
2236
+
2237
+ if (preset === "custom") {
2238
+ const cp = form.custom_provider;
2239
+ const cl = form.custom_local_engine;
2240
+ const cm = form.custom_model;
2241
+ provider =
2242
+ typeof cp === "string"
2243
+ ? cp.trim()
2244
+ : Array.isArray(cp)
2245
+ ? String(cp[0] ?? "").trim()
2246
+ : "";
2247
+ local_engine =
2248
+ typeof cl === "string"
2249
+ ? cl.trim()
2250
+ : Array.isArray(cl)
2251
+ ? String(cl[0] ?? "").trim()
2252
+ : "";
2253
+ model =
2254
+ typeof cm === "string"
2255
+ ? cm.trim()
2256
+ : Array.isArray(cm)
2257
+ ? String(cm[0] ?? "").trim()
2258
+ : "";
2259
+
2260
+ if (!model) {
2261
+ return c.json({ error: "Custom model id is required" }, 400);
2262
+ }
2263
+ if (model.length > VOICE_CUSTOM_MODEL_MAX_LEN) {
2264
+ return c.json({ error: "Model id too long" }, 400);
2265
+ }
2266
+ } else {
2267
+ const pr = VOICE_TRANSCRIBE_PRESETS.find((p) => p.id === preset);
2268
+ if (!pr) {
2269
+ return c.json({ error: "Invalid preset" }, 400);
2270
+ }
2271
+ ({ provider, local_engine, model } = pr);
2272
+ }
2273
+
2274
+ const triplet: [string, string][] = [
2275
+ [VT_KEY.provider, provider],
2276
+ [VT_KEY.local_engine, local_engine],
2277
+ [VT_KEY.model, model],
2278
+ ];
2279
+ for (const [key, value] of triplet) {
2280
+ if (!reg.validate(key, value)) {
2281
+ return c.json({ error: `Invalid value for ${key}` }, 400);
2282
+ }
2283
+ }
2284
+
2285
+ for (const [key, value] of triplet) {
2286
+ core.db.setSpaceConfig(spaceId, key, value, "dashboard");
2287
+ }
2288
+
2289
+ return c.json({ ok: true });
2290
+ });
2291
+
2292
+ app.post("/api/voice-synth", async (c) => {
2293
+ const reg = configRegistry;
2294
+ if (!reg?.isValidKey(VS_KEY.mode)) {
2295
+ return c.json({ error: "Voice synthesis extension is not loaded" }, 400);
2296
+ }
2297
+
2298
+ const form = await c.req.parseBody();
2299
+ const spaceId = typeof form.spaceId === "string" ? form.spaceId.trim() : "";
2300
+ const intentRaw = form.intent;
2301
+ const intent =
2302
+ typeof intentRaw === "string"
2303
+ ? intentRaw.trim()
2304
+ : Array.isArray(intentRaw)
2305
+ ? String(intentRaw[0] ?? "").trim()
2306
+ : "";
2307
+
2308
+ if (!spaceId) {
2309
+ return c.json({ error: "Missing spaceId" }, 400);
2310
+ }
2311
+
2312
+ if (!core.db.getSpace(spaceId)) {
2313
+ return c.json({ error: "Space not found" }, 404);
2314
+ }
2315
+
2316
+ if (intent === "reset") {
2317
+ core.db.deleteSpaceConfig(spaceId, VS_KEY.mode);
2318
+ core.db.deleteSpaceConfig(spaceId, VS_KEY.auto);
2319
+ return c.json({ ok: true });
2320
+ }
2321
+
2322
+ if (intent !== "apply") {
2323
+ return c.json({ error: "Missing or invalid intent" }, 400);
2324
+ }
2325
+
2326
+ const modeRaw = form.mode;
2327
+ const mode =
2328
+ typeof modeRaw === "string"
2329
+ ? modeRaw.trim()
2330
+ : Array.isArray(modeRaw)
2331
+ ? String(modeRaw[0] ?? "").trim()
2332
+ : "";
2333
+
2334
+ if (!reg.validate(VS_KEY.mode, mode)) {
2335
+ return c.json({ error: "Invalid mode" }, 400);
2336
+ }
2337
+
2338
+ core.db.setSpaceConfig(spaceId, VS_KEY.mode, mode, "dashboard");
2339
+ core.db.deleteSpaceConfig(spaceId, VS_KEY.auto);
2340
+
2341
+ return c.json({ ok: true });
2342
+ });
2343
+
2344
+ app.post("/api/mutes", async (c) => {
2345
+ const form = await c.req.parseBody();
2346
+ const spaceId = typeof form.spaceId === "string" ? form.spaceId.trim() : "";
2347
+ const platformUserId =
2348
+ typeof form.platformUserId === "string" ? form.platformUserId.trim() : "";
2349
+ const duration =
2350
+ typeof form.duration === "string" ? form.duration.trim() : "";
2351
+ const reason =
2352
+ typeof form.reason === "string" && form.reason.trim()
2353
+ ? form.reason.trim()
2354
+ : undefined;
2355
+
2356
+ if (!spaceId || !platformUserId || !duration) {
2357
+ return c.json(
2358
+ { error: "Missing spaceId, platformUserId, or duration" },
2359
+ 400,
2360
+ );
2361
+ }
2362
+
2363
+ if (!core.db.getSpace(spaceId)) {
2364
+ return c.json({ error: "Space not found" }, 404);
2365
+ }
2366
+
2367
+ const durationMs = parseMuteDuration(duration);
2368
+ if (!durationMs) {
2369
+ return c.json(
2370
+ {
2371
+ error: `Invalid duration: "${duration}". Use e.g. 10m, 1h, 24h, 7d`,
2372
+ },
2373
+ 400,
2374
+ );
2375
+ }
2376
+
2377
+ const expiresAt = Date.now() + durationMs;
2378
+ core.db.muteUser(spaceId, platformUserId, expiresAt, "dashboard", reason);
2379
+
2380
+ return c.json({
2381
+ ok: true,
2382
+ platformUserId,
2383
+ expiresAt,
2384
+ duration,
2385
+ reason: reason ?? null,
2386
+ });
2387
+ });
2388
+
2389
+ app.post("/api/conversations/:id/link", async (c) => {
2390
+ const conversationId = Number.parseInt(c.req.param("id"), 10);
2391
+ if (!Number.isFinite(conversationId) || conversationId < 1) {
2392
+ return c.json({ error: "Invalid conversation ID" }, 400);
2393
+ }
2394
+
2395
+ const form = await c.req.parseBody();
2396
+ const spaceId = typeof form.spaceId === "string" ? form.spaceId : undefined;
2397
+ if (!spaceId) {
2398
+ return c.json({ error: "Missing spaceId" }, 400);
2399
+ }
2400
+
2401
+ const space = core.db.getSpace(spaceId);
2402
+ if (!space) {
2403
+ return c.json({ error: "Space not found" }, 404);
2404
+ }
2405
+
2406
+ const linked = core.db.linkConversation(conversationId, spaceId);
2407
+ if (!linked) {
2408
+ return c.json({ error: "Conversation not found" }, 404);
2409
+ }
2410
+
2411
+ return c.json({ ok: true });
2412
+ });
2413
+
2414
+ app.post("/api/conversations/:id/unlink", (c) => {
2415
+ const conversationId = Number.parseInt(c.req.param("id"), 10);
2416
+ if (!Number.isFinite(conversationId) || conversationId < 1) {
2417
+ return c.json({ error: "Invalid conversation ID" }, 400);
2418
+ }
2419
+
2420
+ const unlinked = core.db.unlinkConversation(conversationId);
2421
+ if (!unlinked) {
2422
+ return c.json({ error: "Conversation not found" }, 404);
2423
+ }
2424
+
2425
+ return c.json({ ok: true });
2426
+ });
2427
+
2428
+ app.post("/api/stop", (c) => {
2429
+ const spaceId = c.req.header("X-Mercury-Space");
2430
+
2431
+ if (!spaceId) {
2432
+ return c.json({ error: "Missing X-Mercury-Space header" }, 400);
2433
+ }
2434
+
2435
+ core.containerRunner.abort(spaceId);
2436
+ return c.json({ ok: true });
2437
+ });
2438
+
2439
+ app.post("/api/extensions/install", async (c) => {
2440
+ const form = await c.req.parseBody();
2441
+ const name = typeof form.name === "string" ? form.name.trim() : "";
2442
+ if (!name) {
2443
+ return c.html(renderFeaturesToast("error", "Missing extension name."));
2444
+ }
2445
+ const entry = getCatalogEntryByName(name);
2446
+ if (!entry) {
2447
+ return c.html(renderFeaturesToast("error", "Unknown catalog extension."));
2448
+ }
2449
+ const src = resolveExamplesExtensionDir(packageRoot, entry.sourceDir);
2450
+ if (!existsSync(src)) {
2451
+ return c.html(
2452
+ renderFeaturesToast(
2453
+ "error",
2454
+ "Bundled extension source not found. Use a mercury-agent install that includes examples/, or run: mercury add <path>",
2455
+ ),
2456
+ );
2457
+ }
2458
+ const result = await installExtensionFromDirectory({
2459
+ cwd: projectRoot,
2460
+ sourceDir: src,
2461
+ destName: entry.name,
2462
+ });
2463
+ if (!result.ok) {
2464
+ return c.html(renderFeaturesToast("error", result.error));
2465
+ }
2466
+ setTimeout(() => process.kill(process.pid, "SIGTERM"), 500);
2467
+ return c.html(
2468
+ renderFeaturesToast(
2469
+ "success",
2470
+ `Extension "${entry.name}" installed — restarting…`,
2471
+ ),
2472
+ );
2473
+ });
2474
+
2475
+ app.delete("/api/extensions/:name", (c) => {
2476
+ const name = c.req.param("name");
2477
+ const result = removeInstalledExtension({ cwd: projectRoot, name });
2478
+ if (!result.ok) {
2479
+ return c.html(renderFeaturesToast("error", result.error));
2480
+ }
2481
+ setTimeout(() => process.kill(process.pid, "SIGTERM"), 500);
2482
+ return c.html(
2483
+ renderFeaturesToast(
2484
+ "success",
2485
+ `Extension "${name}" removed — restarting…`,
2486
+ ),
2487
+ );
2488
+ });
2489
+
2490
+ return app;
2491
+ }