mercury-agent 0.5.12 → 0.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -154,8 +154,26 @@ Each space is a user-defined memory boundary with its own workspace and pi sessi
154
154
  | **Permissions** | Role-based access control | [docs/permissions.md](docs/permissions.md) |
155
155
  | **Media** | Images, documents, voice notes | [docs/media/overview.md](docs/media/overview.md) |
156
156
  | **KB Distillation** | Extract lasting knowledge from chats | [docs/kb-distillation.md](docs/kb-distillation.md) |
157
+ | **Dashboard** | Web UI for managing spaces, tasks, permissions, extensions | [docs/dashboard.md](docs/dashboard.md) |
157
158
  | **Extensions** | TypeScript plugins for CLIs, skills, jobs, hooks | [docs/extensions.md](docs/extensions.md) |
158
- | **DM Auto-Space** | Auto-create isolated spaces per customer DM | [docs/rate-limiting.md](docs/rate-limiting.md) |
159
+ | **DM Auto-Space** | Auto-create isolated spaces per user DM | [docs/rate-limiting.md](docs/rate-limiting.md) |
160
+
161
+ ### Default capabilities vs. extensions
162
+
163
+ Out of the box, Mercury is a capable chat assistant with conversation context, scheduled tasks, role-based permissions, multi-space management, message recall, and sub-agent delegation. It does **not** include web search, web browsing, a knowledge vault, voice, or third-party service integrations — those are added via extensions.
164
+
165
+ Install extensions from the dashboard (**Features** page), the CLI, or during setup with a profile:
166
+
167
+ ```bash
168
+ # From the CLI
169
+ mercury add web-browser # Web browsing & search (uses Brave Search, no API key needed)
170
+ mercury add napkin # Obsidian-style knowledge vault
171
+
172
+ # Or pick a profile that bundles what you need
173
+ mercury setup --profile research
174
+ ```
175
+
176
+ Run `mercury extensions list` or visit the dashboard's Features page to see what's available.
159
177
 
160
178
  ---
161
179
 
@@ -404,7 +422,7 @@ Supported OAuth providers: Anthropic, GitHub Copilot, Google Gemini CLI, Antigra
404
422
 
405
423
  ### DM Auto-Space
406
424
 
407
- Auto-create isolated spaces for each customer who DMs your bot. Each customer gets their own space with persistent context, configurable permissions, and rate limits.
425
+ Auto-create isolated spaces for each user who DMs your bot. Each user gets their own space with persistent context, configurable permissions, and rate limits.
408
426
 
409
427
  ```yaml
410
428
  # mercury.yaml
@@ -413,7 +431,7 @@ dm_auto_space:
413
431
  admin_ids: # platform IDs — auto-link to "main" space
414
432
  - "24417056866472" # WhatsApp LID, Telegram user ID, etc.
415
433
  default_system_prompt: "You are a helpful assistant."
416
- default_member_permissions: "prompt,prefs.get" # restrict customers to chat only
434
+ default_member_permissions: "prompt,prefs.get" # restrict users to chat only
417
435
 
418
436
  runtime:
419
437
  rate_limit_daily_member: 20 # global daily cap for members (0 = unlimited)
@@ -449,6 +467,7 @@ mrctl config set trigger_patterns "@Bot,Bot"
449
467
  - [Container lifecycle](docs/container-lifecycle.md)
450
468
  - [Graceful shutdown](docs/graceful-shutdown.md)
451
469
  - [Rate limiting](docs/rate-limiting.md)
470
+ - [Dashboard](docs/dashboard.md)
452
471
  - [Extensions](docs/extensions.md)
453
472
 
454
473
  ---
@@ -13,8 +13,8 @@ only on this contract.
13
13
  ## 1. Profile manifest — `mercury-profile.yaml`
14
14
 
15
15
  ```yaml
16
- name: barber-appointments # lowercase-kebab; required
17
- description: Appointment booking for a barber shop
16
+ name: room-booking # lowercase-kebab; required
17
+ description: Meeting room booking assistant
18
18
  version: 0.1.0
19
19
 
20
20
  agents_md: ./AGENTS.md # optional; copied to the global AGENTS.md
@@ -26,8 +26,8 @@ capabilities:
26
26
 
27
27
  # Extensions this profile ships (copied into .mercury/extensions).
28
28
  extensions:
29
- - name: barber
30
- source: ./extensions/barber
29
+ - name: rooms
30
+ source: ./extensions/rooms
31
31
 
32
32
  # EXHAUSTIVE member permission set while active. When present it REPLACES the
33
33
  # default member permissions — nothing is merged in — so raw capabilities stay
@@ -35,18 +35,18 @@ extensions:
35
35
  member_permissions:
36
36
  - prompt
37
37
  - prefs.get
38
- - barber # the profile's own capability, NOT gws
38
+ - rooms # the profile's own capability, NOT gws
39
39
 
40
40
  # Optional env the profile needs (host-side credentials for capabilities).
41
41
  env:
42
- - key: MERCURY_BARBER_BUSINESS_HOURS
43
- description: "Business hours"
42
+ - key: MERCURY_ROOMS_OFFICE_HOURS
43
+ description: "Office hours"
44
44
  default: "09:00-18:00"
45
45
 
46
46
  # Project-wide agent persona, injected into every container's system prompt.
47
47
  system_prompt: |
48
- You are a barber shop assistant. Help each customer book, view, and cancel
49
- ONLY their own appointments. Never reveal other customers' schedules.
48
+ You are a meeting room booking assistant. Help each user book, view, and
49
+ cancel ONLY their own reservations. Never reveal other users' bookings.
50
50
 
51
51
  defaults:
52
52
  trigger_patterns: always
@@ -59,7 +59,7 @@ capabilities, copies extensions/AGENTS.md, and persists activation to
59
59
  ## 2. The capability broker (where the business logic runs)
60
60
 
61
61
  A profile's privileged work runs **on the host**, so credentials never enter the
62
- customer-controlled agent container. The profile's extension registers a
62
+ user-controlled agent container. The profile's extension registers a
63
63
  handler; the agent invokes it through a thin CLI.
64
64
 
65
65
  ### Register a handler (in the extension's `index.ts`)
@@ -72,18 +72,18 @@ const setup: ExtensionSetupFn = (mercury) => {
72
72
  mercury.permission({ defaultRoles: [] });
73
73
 
74
74
  // Host-side credentials for the wrapped capability (never sent to the container).
75
- mercury.env({ from: "MERCURY_BARBER_GOOGLE_CREDENTIALS" });
75
+ mercury.env({ from: "MERCURY_ROOMS_GOOGLE_CREDENTIALS" });
76
76
 
77
- mercury.capability("barber", async (req, ctx) => {
77
+ mercury.capability("rooms", async (req, ctx) => {
78
78
  // req.callerId is the TOKEN-DERIVED, unspoofable caller — safe for ownership.
79
79
  switch (req.action) {
80
80
  case "availability":
81
- return { data: await listOpenSlots(req.body, ctx) };
81
+ return { data: await listOpenRooms(req.body, ctx) };
82
82
  case "book":
83
- return { data: await book(req.callerId, req.body, ctx) };
83
+ return { data: await bookRoom(req.callerId, req.body, ctx) };
84
84
  case "cancel":
85
85
  return { data: await cancelOwn(req.callerId, req.body, ctx) };
86
- case "my-appointments":
86
+ case "my-bookings":
87
87
  return { data: await listOwn(req.callerId, ctx) };
88
88
  default:
89
89
  return { status: 400, data: { error: `unknown action: ${req.action}` } };
@@ -125,21 +125,21 @@ interface CapabilityResult {
125
125
  Inside the container the agent runs:
126
126
 
127
127
  ```
128
- mrctl capability barber book '{"slot":"2026-07-02T15:00"}'
128
+ mrctl capability rooms book '{"room":"A3","slot":"2026-07-02T15:00"}'
129
129
  ```
130
130
 
131
- → `POST /api/capability/barber/book`. Document these commands for the agent in
131
+ → `POST /api/capability/rooms/book`. Document these commands for the agent in
132
132
  the extension's `SKILL.md` or the profile's `AGENTS.md`.
133
133
 
134
134
  ## 3. Permission & security model (non-negotiable)
135
135
 
136
136
  - **`member_permissions` is exhaustive.** List every permission a member may
137
- hold, including the capability name (e.g. `barber`). Anything not listed —
137
+ hold, including the capability name (e.g. `rooms`). Anything not listed —
138
138
  including raw capabilities like `gws` — is unavailable to members.
139
139
  - **Authorization = permission named after the capability.** The broker route
140
140
  requires the caller to hold the `<name>` permission; the same grant gates both
141
- the `mrctl capability <name> …` CLI and the route. Keep capability name ==
142
- permission name == the entry in `member_permissions`.
141
+ the `mrctl capability <name> …` CLI and the route. Keep capability name =
142
+ permission name = the entry in `member_permissions`.
143
143
  - **Credentials stay on the host.** Put capability credentials in host env
144
144
  (`mercury.env`) / `mercury.store` and use them only inside the handler. Never
145
145
  expose a raw capability CLI (e.g. `gws`) to members — its secret would be
@@ -147,17 +147,17 @@ the extension's `SKILL.md` or the profile's `AGENTS.md`.
147
147
  - **Enforce ownership with `req.callerId`.** It is token-derived and cannot be
148
148
  spoofed by the container. Never trust an id passed in `req.body`.
149
149
  - **`callerId === "system"`** for scheduled/system runs — handle it explicitly
150
- (e.g. skip per-customer ownership).
150
+ (e.g. skip per-user ownership).
151
151
 
152
152
  ## 4. Suggested profile repo layout
153
153
 
154
154
  ```
155
155
  <profile-repo>/
156
- barber/ # one folder per profile
156
+ room-booking/ # one folder per profile
157
157
  mercury-profile.yaml
158
158
  AGENTS.md
159
159
  extensions/
160
- barber/
160
+ rooms/
161
161
  index.ts # registers permission + capability
162
162
  package.json
163
163
  ```
@@ -168,7 +168,7 @@ Depend on this repo (`mercury-agent`) for the types
168
168
 
169
169
  ## 5. Local test loop
170
170
 
171
- 1. `mercury setup --profile ./barber`
171
+ 1. `mercury setup --profile ./room-booking`
172
172
  2. `mercury service install` (builds the derived image with the extension CLI)
173
173
  3. DM the bot as a non-admin number; confirm you can `book`/`cancel` only your
174
- own appointments and cannot reach `gws`/Gmail.
174
+ own reservations and cannot reach `gws`/Gmail.
@@ -0,0 +1,81 @@
1
+ # Dashboard
2
+
3
+ Mercury ships with a web dashboard at `http://localhost:8787/dashboard` (or whichever port `MERCURY_PORT` is set to). It gives you a real-time view of your instance and lets you manage spaces, conversations, tasks, permissions, extensions, and API keys — all without touching the CLI.
4
+
5
+ ## Access & Authentication
6
+
7
+ When `MERCURY_API_SECRET` is set (auto-generated by `mercury setup`), the dashboard requires authentication. Open the login URL printed at startup or navigate to:
8
+
9
+ ```
10
+ http://localhost:8787/dashboard/login?token=<your-api-secret>
11
+ ```
12
+
13
+ This sets a session cookie so subsequent visits go straight through. If no secret is configured, the dashboard is open.
14
+
15
+ ## Pages
16
+
17
+ ### Overview
18
+
19
+ System-at-a-glance: adapter connection status (WhatsApp, Slack, Discord, Telegram), active container runs, recent activity across spaces, upcoming scheduled tasks, space count, queue depth, uptime, and the configured model chain. Extension-provided widgets also appear here.
20
+
21
+ ### Spaces
22
+
23
+ Lists all memory spaces with platform badges, conversation count, message count, and last-active time. Click a space to open its detail page.
24
+
25
+ **Space detail** is the most feature-rich page. From here you can:
26
+
27
+ | Section | What you can do |
28
+ |---------|-----------------|
29
+ | **Conversations** | View linked conversations, unlink them |
30
+ | **Roles** | Promote members to admin, demote admins |
31
+ | **Mutes** | Mute/unmute users with optional duration and reason |
32
+ | **Triggers & Ambient** | Set `trigger.match` (mention/prefix/always), patterns, case sensitivity, media-in-groups, and ambient mode |
33
+ | **Context** | Choose context mode (clear/context), window size, reply-chain depth |
34
+ | **Rate limits** | Set burst-per-minute and daily caps for members and admins |
35
+ | **Voice** | Configure per-space STT (transcription preset or custom model) and TTS (on-demand vs auto) |
36
+ | **Preferences** | Add/edit/delete arbitrary key-value preferences |
37
+ | **Recent messages** | View the last 50 messages with role coloring and token usage |
38
+
39
+ ### Conversations
40
+
41
+ All discovered platform conversations across every adapter. Shows platform, title, kind (group/DM), linked space, and last seen. You can link unlinked conversations to a space or unlink existing ones — the same operation as `mercury link` from the CLI.
42
+
43
+ ### Tasks
44
+
45
+ All scheduled tasks (cron and one-shot). For each task you can see the cron expression, timezone, prompt, next run time, and whether it runs silently or posts to chat. Actions: **run now**, **pause**, **resume**, **delete**. Filterable by space.
46
+
47
+ ### Permissions
48
+
49
+ Cross-space view of every role assignment. Shows platform, space, user (with display name), and role. You can promote or demote users directly.
50
+
51
+ ### Logs
52
+
53
+ Aggregated recent messages across all spaces, displayed as a log stream with timestamps, level, source platform, and message preview. Filterable by log level.
54
+
55
+ ### Usage
56
+
57
+ Token usage statistics: aggregate totals (input tokens, output tokens, estimated cost, run count) and a per-space breakdown table.
58
+
59
+ ### API Keys
60
+
61
+ Manage API keys for supported model providers (Anthropic, OpenAI, Google Gemini, Groq, Mistral, OpenRouter). Shows current status (SET / NOT SET). Saving or clearing a key writes to `.env` and triggers a restart.
62
+
63
+ ### Features
64
+
65
+ Extension management through the UI. Shows installed extensions with their capabilities (CLI / skill / widget) and a remove button. Lists available catalog extensions with a one-click install button. Installing or removing an extension triggers a container image rebuild and restart.
66
+
67
+ ## Real-time Updates
68
+
69
+ The dashboard uses Server-Sent Events (SSE) for live updates:
70
+
71
+ - **Health banner** — shows healthy / degraded / critical status with uptime
72
+ - **Active runs badge** — pulses when agent containers are running
73
+ - **Connection indicator** — shows live / reconnecting / disconnected state
74
+
75
+ ## Keyboard Shortcuts
76
+
77
+ | Key | Action |
78
+ |-----|--------|
79
+ | `1`–`8` | Switch between tabs |
80
+ | `/` | Focus the search input |
81
+ | `?` | Show shortcut help |
@@ -1,33 +1,34 @@
1
- # Web Search
1
+ # Web Search & Browsing
2
2
 
3
- Web search is **extension-based** in Mercury.
3
+ Web search is **extension-based** in Mercury. The base agent does not include a browser — install the `web-browser` extension to add search and browsing capabilities.
4
4
 
5
- Mercury core does not ship a built-in browser/search CLI. Instead, install a web automation extension (for example, `pinchtab`) and let the agent use that tool directly.
5
+ ## Quick Start
6
6
 
7
- ---
8
-
9
- ## Recommended Setup
7
+ ```bash
8
+ mercury add web-browser
9
+ ```
10
10
 
11
- Use the real example extension at:
11
+ Or install from the dashboard's **Features** page (one click).
12
12
 
13
- - `examples/extensions/pinchtab/`
13
+ No API key is required. The extension uses [pinchtab](https://www.npmjs.com/package/pinchtab) to control a local headless Chromium and navigates to the public [Brave Search](https://search.brave.com) website — the same way a human would.
14
14
 
15
- It demonstrates:
15
+ ## What It Provides
16
16
 
17
- - installing browser tooling in the derived image
18
- - `before_container` hook for runtime env injection
19
- - system-prompt guidance for consistent search behavior
17
+ The `web-browser` extension installs pinchtab and Playwright/Chromium into the agent container. The agent can:
20
18
 
21
- ---
19
+ - **Search the web** — navigate to Brave Search, read results
20
+ - **Browse any URL** — open pages, follow links
21
+ - **Extract text** — pull readable content from any page
22
+ - **Take snapshots** — capture the accessibility tree for structured interaction
23
+ - **Fill forms** — click elements, type into fields
24
+ - **Authenticated sessions** — log into sites using injected cookies/localStorage (configured via `MERCURY_BROWSER_SESSIONS`)
22
25
 
23
26
  ## Typical Flow
24
27
 
25
- 1. Start browser automation tool (`pinchtab`)
26
- 2. Navigate to search engine URL (e.g. Brave Search)
27
- 3. Extract text/snapshot content
28
- 4. Summarize and cite key findings
29
-
30
- Example command pattern used by agents (see `examples/extensions/pinchtab/` for `pinchtab_ensure` — wait until the bridge listens on `:9867` before calling the CLI; otherwise you get `connection refused`):
28
+ 1. Start the browser daemon (`pinchtab_ensure`)
29
+ 2. Navigate to a search URL or any page
30
+ 3. Extract text or snapshot content
31
+ 4. Summarize and cite findings
31
32
 
32
33
  ```bash
33
34
  pinchtab_ensure || exit 1
@@ -36,27 +37,19 @@ sleep 3
36
37
  pinchtab text
37
38
  ```
38
39
 
39
- ---
40
-
41
40
  ## Why Extension-Based
42
41
 
43
- - keeps Mercury core lean
44
- - lets each deployment pick its own browser/search stack
45
- - allows per-space RBAC for web tooling (`pinchtab` permission)
46
- - avoids locking users into one provider/tool
47
-
48
- ---
42
+ - Keeps Mercury core lean
43
+ - Lets each deployment pick its own browser/search stack
44
+ - Allows per-space RBAC for web tooling (`pinchtab` permission)
45
+ - Avoids locking users into one provider/tool
49
46
 
50
47
  ## Security & RBAC
51
48
 
52
- Extension CLIs are called directly in bash, with RBAC enforced by Mercury's in-container permission guard.
53
-
54
- If a caller lacks permission for a web extension CLI, execution is blocked.
55
-
56
- ---
49
+ Extension CLIs are called directly in bash, with RBAC enforced by Mercury's in-container permission guard. If a caller lacks the `pinchtab` permission, execution is blocked.
57
50
 
58
51
  ## Related Docs
59
52
 
60
- - [extensions.md](./extensions.md)
61
- - [pipeline.md](./pipeline.md)
62
- - [container-lifecycle.md](./container-lifecycle.md)
53
+ - [Extensions](./extensions.md)
54
+ - [Pipeline](./pipeline.md)
55
+ - [Container lifecycle](./container-lifecycle.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -80,8 +80,8 @@
80
80
  # enabled: false
81
81
  # admin_ids: # auto-link to "main" space
82
82
  # - "972501234567"
83
- # default_system_prompt: "" # seeded into auto-created customer spaces
84
- # default_member_permissions: "prompt,prefs.get" # restrict customers to chat only
83
+ # default_system_prompt: "" # seeded into auto-created user spaces
84
+ # default_member_permissions: "prompt,prefs.get" # restrict users to chat only
85
85
 
86
86
  # ─── Applicative profile ────────────────────────────────────────────────────
87
87
  # A profile wraps raw capabilities with deterministic business logic and scopes
@@ -92,7 +92,7 @@
92
92
  # credentials never enter the agent container.
93
93
  #
94
94
  # Env-only override of the active profile name (informational):
95
- # MERCURY_PROFILE=barber-appointments
95
+ # MERCURY_PROFILE=room-booking
96
96
  #
97
97
  # Host-only signing key for per-turn caller tokens (auto-generated if unset;
98
98
  # only set when the API and container-runner run in separate processes):
@@ -41,7 +41,7 @@ capability.post("/:name/:action", async (c) => {
41
41
  });
42
42
 
43
43
  // Authorization reuses the permission whose name equals the capability, so a
44
- // single grant (e.g. "barber" in member_permissions) gates both the CLI and
44
+ // single grant (e.g. "rooms" in member_permissions) gates both the CLI and
45
45
  // this route.
46
46
  const denied = checkPerm(c, name);
47
47
  if (denied) return denied;
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { getModels, type KnownProvider } from "@earendil-works/pi-ai";
3
4
  import { Hono } from "hono";
4
5
  import { html, raw } from "hono/html";
5
6
  import { streamSSE } from "hono/streaming";
@@ -115,42 +116,140 @@ export function createDashboardRoutes(ctx: DashboardContext) {
115
116
  label: string;
116
117
  envVar: string;
117
118
  placeholder: string;
119
+ defaultModel: string;
118
120
  }[] = [
119
121
  {
120
122
  id: "anthropic",
121
123
  label: "Anthropic",
122
124
  envVar: "MERCURY_ANTHROPIC_API_KEY",
123
125
  placeholder: "sk-ant-...",
126
+ defaultModel: "claude-sonnet-4-6",
124
127
  },
125
128
  {
126
129
  id: "openai",
127
130
  label: "OpenAI",
128
131
  envVar: "MERCURY_OPENAI_API_KEY",
129
132
  placeholder: "sk-...",
133
+ defaultModel: "gpt-4o",
130
134
  },
131
135
  {
132
136
  id: "google",
133
137
  label: "Google Gemini",
134
138
  envVar: "MERCURY_GEMINI_API_KEY",
135
139
  placeholder: "AIza...",
140
+ defaultModel: "gemini-2.5-flash",
141
+ },
142
+ {
143
+ id: "deepseek",
144
+ label: "DeepSeek",
145
+ envVar: "MERCURY_DEEPSEEK_API_KEY",
146
+ placeholder: "sk-...",
147
+ defaultModel: "deepseek-v4-flash",
136
148
  },
137
149
  {
138
150
  id: "groq",
139
151
  label: "Groq",
140
152
  envVar: "MERCURY_GROQ_API_KEY",
141
153
  placeholder: "gsk_...",
154
+ defaultModel: "llama-3.3-70b-versatile",
142
155
  },
143
156
  {
144
157
  id: "mistral",
145
158
  label: "Mistral",
146
159
  envVar: "MERCURY_MISTRAL_API_KEY",
147
160
  placeholder: "...",
161
+ defaultModel: "mistral-large-latest",
148
162
  },
149
163
  {
150
164
  id: "openrouter",
151
165
  label: "OpenRouter",
152
166
  envVar: "MERCURY_OPENROUTER_API_KEY",
153
167
  placeholder: "sk-or-...",
168
+ defaultModel: "meta-llama/llama-3.3-70b-instruct",
169
+ },
170
+ {
171
+ id: "xai",
172
+ label: "xAI (Grok)",
173
+ envVar: "MERCURY_XAI_API_KEY",
174
+ placeholder: "xai-...",
175
+ defaultModel: "grok-2-latest",
176
+ },
177
+ {
178
+ id: "cerebras",
179
+ label: "Cerebras",
180
+ envVar: "MERCURY_CEREBRAS_API_KEY",
181
+ placeholder: "csk-...",
182
+ defaultModel: "llama3.1-8b",
183
+ },
184
+ {
185
+ id: "google-vertex",
186
+ label: "Google Vertex AI",
187
+ envVar: "MERCURY_GOOGLE_CLOUD_API_KEY",
188
+ placeholder: "AIza...",
189
+ defaultModel: "gemini-2.0-flash",
190
+ },
191
+ {
192
+ id: "amazon-bedrock",
193
+ label: "Amazon Bedrock",
194
+ envVar: "MERCURY_AWS_BEARER_TOKEN_BEDROCK",
195
+ placeholder: "...",
196
+ defaultModel: "amazon.nova-lite-v1:0",
197
+ },
198
+ {
199
+ id: "huggingface",
200
+ label: "HuggingFace",
201
+ envVar: "MERCURY_HF_TOKEN",
202
+ placeholder: "hf_...",
203
+ defaultModel: "Qwen/Qwen3-235B-A22B-Thinking-2507",
204
+ },
205
+ {
206
+ id: "azure-openai-responses",
207
+ label: "Azure OpenAI",
208
+ envVar: "MERCURY_AZURE_OPENAI_API_KEY",
209
+ placeholder: "...",
210
+ defaultModel: "gpt-4o",
211
+ },
212
+ {
213
+ id: "vercel-ai-gateway",
214
+ label: "Vercel AI Gateway",
215
+ envVar: "MERCURY_AI_GATEWAY_API_KEY",
216
+ placeholder: "...",
217
+ defaultModel: "alibaba/qwen-3-235b",
218
+ },
219
+ {
220
+ id: "minimax",
221
+ label: "MiniMax",
222
+ envVar: "MERCURY_MINIMAX_API_KEY",
223
+ placeholder: "...",
224
+ defaultModel: "MiniMax-M2.7",
225
+ },
226
+ {
227
+ id: "minimax-cn",
228
+ label: "MiniMax (China)",
229
+ envVar: "MERCURY_MINIMAX_CN_API_KEY",
230
+ placeholder: "...",
231
+ defaultModel: "MiniMax-M2.7",
232
+ },
233
+ {
234
+ id: "zai",
235
+ label: "ZAI",
236
+ envVar: "MERCURY_ZAI_API_KEY",
237
+ placeholder: "...",
238
+ defaultModel: "glm-4.5",
239
+ },
240
+ {
241
+ id: "kimi-coding",
242
+ label: "Kimi (Moonshot)",
243
+ envVar: "MERCURY_KIMI_API_KEY",
244
+ placeholder: "...",
245
+ defaultModel: "k2p5",
246
+ },
247
+ {
248
+ id: "github-copilot",
249
+ label: "GitHub Copilot",
250
+ envVar: "MERCURY_GITHUB_COPILOT_OAUTH_TOKEN",
251
+ placeholder: "",
252
+ defaultModel: "claude-sonnet-4-6",
154
253
  },
155
254
  ];
156
255
 
@@ -1861,7 +1960,103 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1861
1960
  `);
1862
1961
  }
1863
1962
 
1864
- const _envPath = path.join(projectRoot, ".env");
1963
+ const cfg = core.config;
1964
+ const isMultiLeg = cfg.resolvedModelChain.length > 1;
1965
+ const currentProvider = cfg.resolvedModelChain[0]?.provider ?? "";
1966
+ const currentModel = cfg.resolvedModelChain[0]?.model ?? "";
1967
+ const currentProviderMeta = MODEL_PROVIDERS.find(
1968
+ (p) => p.id === currentProvider,
1969
+ );
1970
+
1971
+ const modelSelectorPanel = (() => {
1972
+ if (isMultiLeg) {
1973
+ const chainFromEnv = !!process.env.MERCURY_MODEL_CHAIN;
1974
+ const chainYaml = cfg.resolvedModelChain
1975
+ .map(
1976
+ (leg) =>
1977
+ ` - provider: ${leg.provider}\n model: ${leg.model}`,
1978
+ )
1979
+ .join("\n");
1980
+ const configSnippet = chainFromEnv
1981
+ ? `MERCURY_MODEL_CHAIN='${JSON.stringify(cfg.resolvedModelChain.map((l) => ({ provider: l.provider, model: l.model })))}'`
1982
+ : `model:\n chain:\n${chainYaml}`;
1983
+ const sourceLabel = chainFromEnv
1984
+ ? `<span class="mono">MERCURY_MODEL_CHAIN</span> env var in <span class="mono">.env</span>`
1985
+ : `<span class="mono">model.chain</span> in <span class="mono">mercury.yaml</span>`;
1986
+
1987
+ return `
1988
+ <div class="panel" style="margin-bottom:16px">
1989
+ <div class="panel-header" style="font-weight:600;font-size:13px">Active model</div>
1990
+ <div class="panel-body">
1991
+ ${renderModelBlock(cfg)}
1992
+ <p class="muted" style="font-size:12px;margin:8px 0 0">
1993
+ Configured via ${sourceLabel}. Edit that file to change.
1994
+ </p>
1995
+ <details style="margin-top:8px">
1996
+ <summary class="muted" style="font-size:12px;cursor:pointer">Copy config</summary>
1997
+ <pre style="margin:6px 0 0;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius,4px);font-size:12px;overflow-x:auto;cursor:pointer;position:relative"
1998
+ onclick="navigator.clipboard.writeText(this.textContent.trim());var s=this.querySelector('.copy-hint');if(s){s.textContent='Copied!';setTimeout(function(){s.textContent='Click to copy'},1500)}">${escapeHtml(configSnippet)}<span class="copy-hint muted" style="position:absolute;top:4px;right:8px;font-size:11px">Click to copy</span></pre>
1999
+ </details>
2000
+ </div>
2001
+ </div>`;
2002
+ }
2003
+
2004
+ const providerOptions = MODEL_PROVIDERS.map((p) => {
2005
+ const selected = p.id === currentProvider ? " selected" : "";
2006
+ const keySet = !!process.env[p.envVar];
2007
+ const suffix = keySet ? "" : " (no key)";
2008
+ return `<option value="${escapeHtml(p.id)}"${selected}>${escapeHtml(p.label)}${suffix}</option>`;
2009
+ }).join("");
2010
+
2011
+ const currentInRegistry = getModels(
2012
+ currentProvider as KnownProvider,
2013
+ ).some((m) => m.id === currentModel);
2014
+
2015
+ const modelOptions = getModels(currentProvider as KnownProvider);
2016
+ let modelOptionsHtml: string;
2017
+ if (modelOptions.length === 0) {
2018
+ modelOptionsHtml = `<input type="text" name="model" value="${escapeHtml(currentModel)}" required
2019
+ style="flex:1;min-width:200px;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"
2020
+ placeholder="Enter model ID" />`;
2021
+ } else {
2022
+ const opts = modelOptions
2023
+ .map(
2024
+ (m) =>
2025
+ `<option value="${escapeHtml(m.id)}"${m.id === currentModel ? " selected" : ""}>${escapeHtml(m.name)}</option>`,
2026
+ )
2027
+ .join("");
2028
+ const customOpt =
2029
+ !currentInRegistry && currentModel
2030
+ ? `<option value="${escapeHtml(currentModel)}" selected>${escapeHtml(currentModel)} (not in registry)</option>`
2031
+ : "";
2032
+ modelOptionsHtml = `<select name="model" class="select" style="flex:1;min-width:200px">${customOpt}${opts}</select>`;
2033
+ }
2034
+
2035
+ return `
2036
+ <div class="panel" style="margin-bottom:16px">
2037
+ <div class="panel-header" style="font-weight:600;font-size:13px">Active model</div>
2038
+ <div class="panel-body">
2039
+ <p class="muted" style="font-size:12px;margin:0 0 10px">
2040
+ Current: <span class="mono">${escapeHtml(currentProvider)}</span> / <span class="mono">${escapeHtml(currentModel)}</span>
2041
+ </p>
2042
+ <form hx-post="/dashboard/api/model/set" hx-target="#keys-feedback" hx-swap="innerHTML"
2043
+ style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
2044
+ <select name="provider" class="select" style="min-width:160px"
2045
+ hx-get="/dashboard/api/models" hx-trigger="change"
2046
+ hx-target="#model-select-container" hx-swap="innerHTML">
2047
+ ${providerOptions}
2048
+ </select>
2049
+ <span id="model-select-container" style="display:flex;flex:1;min-width:200px">
2050
+ ${modelOptionsHtml}
2051
+ </span>
2052
+ <button class="btn btn-sm" type="submit">Save</button>
2053
+ </form>
2054
+ <p id="model-key-warning" class="muted" style="font-size:12px;margin:8px 0 0;${currentProviderMeta && process.env[currentProviderMeta.envVar] ? "display:none" : ""}">
2055
+ No API key set for this provider. Set one below or via <span class="mono">mercury auth login</span>.
2056
+ </p>
2057
+ </div>
2058
+ </div>`;
2059
+ })();
1865
2060
 
1866
2061
  const providerRows = MODEL_PROVIDERS.map((p) => {
1867
2062
  const isSet = !!process.env[p.envVar];
@@ -1900,6 +2095,8 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1900
2095
 
1901
2096
  <div id="keys-feedback" style="min-height:4px"></div>
1902
2097
 
2098
+ ${raw(modelSelectorPanel)}
2099
+
1903
2100
  <div class="panel" style="margin-bottom:16px">
1904
2101
  <div class="panel-header" style="font-weight:600;font-size:13px">Model providers</div>
1905
2102
  <div class="panel-body" style="padding:0 16px">
@@ -1995,6 +2192,91 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1995
2192
  );
1996
2193
  });
1997
2194
 
2195
+ // ─── Model Selector ─────────────────────────────────────────────────────
2196
+
2197
+ app.get("/api/models", (c) => {
2198
+ const provider = c.req.query("provider") ?? "";
2199
+ const meta = MODEL_PROVIDERS.find((p) => p.id === provider);
2200
+ const hasKey = meta ? !!process.env[meta.envVar] : false;
2201
+ const keyWarning = `<p id="model-key-warning" hx-swap-oob="true" class="muted" style="font-size:12px;margin:8px 0 0;${hasKey ? "display:none" : ""}">
2202
+ No API key set for this provider. Set one below or via <span class="mono">mercury auth login</span>.
2203
+ </p>`;
2204
+
2205
+ const models = getModels(provider as KnownProvider);
2206
+ if (models.length === 0) {
2207
+ const placeholder = meta?.defaultModel ?? "model-id";
2208
+ return c.html(
2209
+ `<input type="text" name="model" required placeholder="${escapeHtml(placeholder)}"
2210
+ style="flex:1;min-width:200px;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" />${keyWarning}`,
2211
+ );
2212
+ }
2213
+ const options = models
2214
+ .map(
2215
+ (m) =>
2216
+ `<option value="${escapeHtml(m.id)}">${escapeHtml(m.name)}</option>`,
2217
+ )
2218
+ .join("");
2219
+ return c.html(
2220
+ `<select name="model" class="select" style="flex:1;min-width:200px">${options}</select>${keyWarning}`,
2221
+ );
2222
+ });
2223
+
2224
+ app.post("/api/model/set", async (c) => {
2225
+ if (core.config.consoleUrl) {
2226
+ return c.html(
2227
+ renderFeaturesToast(
2228
+ "error",
2229
+ "Model configuration is managed from the Mercury Console.",
2230
+ ),
2231
+ );
2232
+ }
2233
+
2234
+ if (core.config.resolvedModelChain.length > 1) {
2235
+ return c.html(
2236
+ renderFeaturesToast(
2237
+ "error",
2238
+ "Model chain is configured — edit mercury.yaml or MERCURY_MODEL_CHAIN directly.",
2239
+ ),
2240
+ );
2241
+ }
2242
+
2243
+ const form = await c.req.parseBody();
2244
+ const provider =
2245
+ typeof form.provider === "string" ? form.provider.trim() : "";
2246
+ const model = typeof form.model === "string" ? form.model.trim() : "";
2247
+
2248
+ if (!provider) {
2249
+ return c.html(renderFeaturesToast("error", "Provider is required."));
2250
+ }
2251
+ if (!model) {
2252
+ return c.html(renderFeaturesToast("error", "Model is required."));
2253
+ }
2254
+
2255
+ const meta = MODEL_PROVIDERS.find((p) => p.id === provider);
2256
+ const providerLabel = meta?.label ?? provider;
2257
+
2258
+ try {
2259
+ const envPath = path.join(projectRoot, ".env");
2260
+ updateDotEnv(envPath, {
2261
+ MERCURY_MODEL_PROVIDER: provider,
2262
+ MERCURY_MODEL: model,
2263
+ });
2264
+ } catch (err) {
2265
+ const msg = err instanceof Error ? err.message : String(err);
2266
+ return c.html(
2267
+ renderFeaturesToast("error", `Failed to write .env: ${msg}`),
2268
+ );
2269
+ }
2270
+
2271
+ setTimeout(() => process.kill(process.pid, "SIGTERM"), 500);
2272
+ return c.html(
2273
+ renderFeaturesToast(
2274
+ "success",
2275
+ `Model set to ${providerLabel} / ${model} — restarting…`,
2276
+ ),
2277
+ );
2278
+ });
2279
+
1998
2280
  // ─── SSE Stream ─────────────────────────────────────────────────────────
1999
2281
 
2000
2282
  app.get("/events", (c) => {
@@ -30,7 +30,6 @@ export const EXTENSION_CATALOG: ExtensionCatalogEntry[] = [
30
30
  "Web search (Brave) and interactive browser (pinchtab) for sites like Gmail, banks, and forms.",
31
31
  category: "automation",
32
32
  sourceDir: "pinchtab",
33
- requiredEnvVars: ["MERCURY_BRAVE_API_KEY"],
34
33
  requiresRestart: true,
35
34
  },
36
35
  {
@@ -356,8 +356,8 @@ export interface MercuryExtensionAPI {
356
356
  * handler stay on the host and never enter the agent container.
357
357
  *
358
358
  * @example
359
- * mercury.capability("barber", async (req, ctx) => {
360
- * if (req.action === "book") return { data: await book(req.callerId, req.body) };
359
+ * mercury.capability("rooms", async (req, ctx) => {
360
+ * if (req.action === "book") return { data: await bookRoom(req.callerId, req.body) };
361
361
  * return { status: 400, data: { error: "unknown action" } };
362
362
  * });
363
363
  */