mercury-agent 0.4.24 → 0.4.26

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
@@ -17,6 +17,7 @@ Mercury is a personal AI assistant that lives where you chat. It connects to Wha
17
17
 
18
18
  ## Prerequisites
19
19
 
20
+ - **[Node.js](https://nodejs.org/)** >= 18 — Required for `npm install -g mercury-agent`
20
21
  - **[Bun](https://bun.sh)** >= 1.0 — JavaScript runtime used by Mercury
21
22
  - **[Docker](https://docs.docker.com/get-docker/)** — Required for running agent containers
22
23
  - **Windows users:** Mercury runs best under [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install). Install WSL2 with `wsl --install`, then install Bun and Docker inside it.
@@ -357,6 +358,10 @@ Supported OAuth providers: Anthropic, GitHub Copilot, Google Gemini CLI, Antigra
357
358
  |----------|---------|-------------|
358
359
  | `MERCURY_AGENT_CONTAINER_IMAGE` | `ghcr.io/avishai-tsabari/mercury-agent:latest` | Container image |
359
360
  | `MERCURY_CONTAINER_TIMEOUT_MS` | `300000` | Container timeout (5 min) |
361
+ | `MERCURY_CONTAINER_RUNTIME` | `runc` | `runc` (default) or `runsc` ([gVisor](https://gvisor.dev)) |
362
+ | `MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT` | `false` | Set `true` on Linux Docker Engine (see note below) |
363
+
364
+ > **Linux Docker Engine:** Mercury uses [bubblewrap](https://github.com/containers/bubblewrap) for in-container sandboxing. On Linux Docker Engine (not Docker Desktop), bwrap cannot mount `/proc` without extra privileges. Either set `container_bwrap_docker_compat: true` in `mercury.yaml` (adds `--privileged` to `docker run`), or install [gVisor](https://gvisor.dev/docs/user_guide/install/) and set `MERCURY_CONTAINER_RUNTIME=runsc` to skip bwrap entirely.
360
365
 
361
366
  **KB Distillation:**
362
367
 
package/docs/ROADMAP.md CHANGED
@@ -25,6 +25,8 @@ Single source of truth for priority. Rule: ≤15 min/week to maintain.
25
25
  | # | Item | Doc | Depends on | Why now |
26
26
  |---|------|-----|-----------|---------|
27
27
  | 1 | **autonomous-planning-tier** | [doc](backlog/autonomous-planning-tier.md) | — | Let agents decompose ideas, keep human gate |
28
+ | 2 | **dm-auto-space** | [doc](backlog/dm-auto-space.md) | — | Auto-create space per customer DM |
29
+ | 3 | **applicative-profiles** | [doc](backlog/applicative-profiles.md) | — | Generic profile layer for business logic scoping |
28
30
 
29
31
  ## Later
30
32
 
@@ -0,0 +1,273 @@
1
+ # Auto-Create Space per DM
2
+
3
+ **Status**: Done
4
+ **Slug**: dm-auto-space
5
+ **Created**: 2026-06-30
6
+ **Last updated**: 2026-07-01
7
+
8
+ ---
9
+
10
+ ## Goal
11
+
12
+ When Mercury serves a business (e.g. a barber shop), each customer texts from a unique phone number. Today every new DM creates a conversation with `space_id = null`, so the bot has no persistent per-customer context — it can't remember preferences, appointment history, or prior conversations. This feature adds a config-driven option to auto-create a space per customer and link their conversation to it, giving each customer an isolated, persistent context.
13
+
14
+ ## User Stories
15
+
16
+ - As a business owner, I want each customer who messages my bot to automatically get their own space so the bot remembers their history across conversations.
17
+ - As a business owner, I want my own number and staff numbers to auto-link to the `main` space instead of creating new spaces.
18
+ - As a business owner, I want customers restricted to chat only — no tools, no scripts, no web search.
19
+ - As a Mercury operator, I want this behavior off by default so existing deployments are unaffected.
20
+ - As a Mercury operator, I want a global daily rate limit per role so I don't have to configure each space individually.
21
+
22
+ ## MVP Scope
23
+
24
+ **In scope:**
25
+ - New `dm_auto_space` config block in `mercury.yaml` with `enabled` (default `false`), `admin_numbers` (auto-link to `main`), `default_system_prompt`, and `default_member_permissions`
26
+ - On new DM from a non-admin number: find or create a space for that phone number, **and link the conversation to it** (without linking, messages are silently dropped by the handler)
27
+ - Admin numbers auto-link their DM conversation to `main` space
28
+ - Auto-created spaces seeded with: `trigger.match = "always"`, `context.mode = "context"`, member permissions restricted to config value
29
+ - Space `display_name` derived from the sender's push name (if available) or phone number
30
+ - Returning customers hitting the same conversation get their existing space — no duplicate creation
31
+ - New global daily rate limit defaults in `mercury.yaml` (`runtime.rate_limit_daily_member`, `runtime.rate_limit_daily_admin`) — applied as fallback when no per-space override exists
32
+ - Dashboard UI: add rate limit controls to the space settings panel
33
+
34
+ **Out of scope:**
35
+ - Space merging when a customer contacts from multiple numbers — complex identity resolution, not MVP
36
+ - Auto-space for group chats — DMs only for now
37
+ - Per-bridge config (WhatsApp vs Telegram) — single global toggle for now, can be extended later
38
+
39
+ ---
40
+
41
+ ## Context for Claude
42
+
43
+ > Key files to read before touching anything.
44
+
45
+ - `src/config.ts` — Zod `schema` object (line 53) and `AppConfig` type; add new config fields here
46
+ - `src/config-file.ts` — `mercuryFileSchema` (Zod for YAML parsing, line 25), `flattenMercuryFile()` (line 185), and `CAMEL_TO_ENV` (line 280); add the YAML blocks + env mappings here
47
+ - `src/core/conversation.ts` — `resolveConversation()` (line 9) returns `null` when `space_id` is null; this is the hook point for auto-create + link
48
+ - `src/core/handler.ts` — `createMessageHandler()` (line 17) calls `resolveConversation()` at line 50; needs to pass config + db context for auto-space logic
49
+ - `src/storage/db.ts` — `ensureSpace()` (line 334), `linkConversation()` (line 611), `ensureConversation()` (line 471), `setSpaceConfig()`, `getSpaceConfig()`; space IDs must match `/^[a-z0-9][a-z0-9-]*$/`
50
+ - `src/core/runtime.ts` — daily role-based rate limit check (line 397); burst rate limit fallback (line 424); needs fallback to new global daily rate limit config
51
+ - `src/core/routes/dashboard.ts` — space settings panels (trigger, context); add rate limit panel here
52
+ - `src/core/routes/config-builtin.ts` — `BUILTIN_CONFIG_KEYS` and `BUILTIN_CONFIG_DESCRIPTIONS`; add `rate_limit`, `rate_limit.member`, `rate_limit.admin` here so dashboard can manage them
53
+ - `src/storage/memory.ts` — `ensureSpaceWorkspace()` (line 27) creates per-space dirs with empty `AGENTS.md`; already called lazily at container execution time (runtime.ts line 1142), no changes needed
54
+
55
+ ---
56
+
57
+ ## Architecture & Data
58
+
59
+ ### Data Models
60
+
61
+ No schema changes. Uses existing tables:
62
+
63
+ - `spaces` — auto-created via `db.ensureSpace(spaceId)` with a derived slug
64
+ - `conversations` — linked via `db.linkConversation(conversationId, spaceId)`
65
+ - `space_config` — seeded with trigger, context, rate limit, permissions, and system prompt defaults on auto-created spaces
66
+
67
+ **Space ID derivation:** Phone numbers (e.g. `972501234567@s.whatsapp.net`) must be converted to valid space IDs matching `/^[a-z0-9][a-z0-9-]*$/`. Strategy: strip the `@s.whatsapp.net` suffix and prefix with `dm-` → `dm-972501234567`. For other platforms: `dm-{platform}-{sanitized-external-id}`.
68
+
69
+ **Space display name:** Use the sender's push name if available from the message, otherwise fall back to the phone number.
70
+
71
+ ### Config Shape
72
+
73
+ ```yaml
74
+ # mercury.yaml
75
+
76
+ # --- Part 1: Auto-space for DMs ---
77
+ dm_auto_space:
78
+ enabled: false # default off
79
+ admin_numbers: # auto-link to "main" instead of creating a new space
80
+ - "972501234567"
81
+ default_system_prompt: "You are a barber shop assistant..." # optional, seeded into space_config
82
+ default_member_permissions: "prompt,prefs.get" # restrict customers to chat only
83
+
84
+ # --- Part 2: Global daily rate limits (independent of dm_auto_space) ---
85
+ runtime:
86
+ rate_limit_daily_member: 20 # NEW — global default daily cap for members, 0 = unlimited
87
+ rate_limit_daily_admin: 0 # NEW — global default daily cap for admins, 0 = unlimited
88
+ ```
89
+
90
+ **Part 1 — dm_auto_space config keys:**
91
+ - `dmAutoSpaceEnabled` → `MERCURY_DM_AUTO_SPACE_ENABLED` (boolean, default `false`)
92
+ - `dmAutoSpaceAdminNumbers` → `MERCURY_DM_AUTO_SPACE_ADMIN_NUMBERS` (comma-separated string, default `""`)
93
+ - `dmAutoSpaceDefaultSystemPrompt` → `MERCURY_DM_AUTO_SPACE_DEFAULT_SYSTEM_PROMPT` (string, default `""`)
94
+ - `dmAutoSpaceDefaultMemberPermissions` → `MERCURY_DM_AUTO_SPACE_DEFAULT_MEMBER_PERMISSIONS` (string, default `"prompt,prefs.get"`)
95
+
96
+ **Part 2 — global daily rate limit config keys:**
97
+ - `rateLimitDailyMember` → `MERCURY_RATE_LIMIT_DAILY_MEMBER` (number, default `0` = unlimited)
98
+ - `rateLimitDailyAdmin` → `MERCURY_RATE_LIMIT_DAILY_ADMIN` (number, default `0` = unlimited)
99
+
100
+ ### Space seeding on auto-create
101
+
102
+ When a new customer space is created, the following `space_config` keys are seeded:
103
+
104
+ | Key | Value | Why |
105
+ |-----|-------|-----|
106
+ | `trigger.match` | `"always"` | Bot responds to every message in DMs |
107
+ | `context.mode` | `"context"` | Bot remembers conversation history |
108
+ | `role.member.permissions` | from `default_member_permissions` config | Restrict customers to chat only |
109
+ | `system_prompt` | from `default_system_prompt` config (if set) | Customer-facing persona |
110
+ | `rate_limit.member` | from `rate_limit_daily_member` config (if >0) | Daily cap seeded from global default |
111
+
112
+ ### Global daily rate limit fallback
113
+
114
+ In `runtime.ts` line 397, the daily role-based rate check currently reads `rate_limit.{role}` from `space_config` only. When the key is null (not set per-space), it currently skips the daily check entirely. Change: when the per-space key is null, fall back to the new global config value (`rateLimitDailyMember` / `rateLimitDailyAdmin`). A value of `0` means unlimited (skip check), which is the default — preserving backward compatibility.
115
+
116
+ ### API Contracts
117
+
118
+ No new API endpoints. Auto-created spaces are fully managed through the existing space API (`/api/spaces`, `/api/conversations/:id/link`). Rate limits are managed via the existing `POST /dashboard/api/space-config` endpoint.
119
+
120
+ ### Dashboard: Rate limit panel
121
+
122
+ Add a new "Rate Limits" section to the space settings page in `dashboard.ts`, following the same pattern as the Trigger and Context panels. Fields:
123
+
124
+ | Label | Config key | Input | Notes |
125
+ |-------|-----------|-------|-------|
126
+ | Burst rate limit | `rate_limit` | number input | Per-user per-minute, falls back to global `rate_limit_per_user` |
127
+ | Daily member limit | `rate_limit.member` | number input | Per-user per-day, falls back to global `rate_limit_daily_member` |
128
+ | Daily admin limit | `rate_limit.admin` | number input | Per-user per-day, falls back to global `rate_limit_daily_admin` |
129
+
130
+ Each row shows the effective value (per-space override or global default) and a "Reset" button to clear the override.
131
+
132
+ Requires adding `rate_limit`, `rate_limit.member`, `rate_limit.admin` to `BUILTIN_CONFIG_KEYS`, `BUILTIN_CONFIG_DESCRIPTIONS`, and validators in `config-builtin.ts`.
133
+
134
+ ### File & Folder Structure
135
+
136
+ | Path | New / Modified | Purpose |
137
+ |------|---------------|---------|
138
+ | `src/config-file.ts` | Modified | Add `dm_auto_space` and `runtime.rate_limit_daily_*` to YAML schema, flatten, env mappings |
139
+ | `src/config.ts` | Modified | Add all new config fields to Zod schema |
140
+ | `src/core/conversation.ts` | Modified | Add auto-space + admin-link logic in `resolveConversation()` |
141
+ | `src/core/handler.ts` | Modified | Thread auto-space config into `resolveConversation()` call |
142
+ | `src/core/runtime.ts` | Modified | Add global daily rate limit fallback in `handleRawInput()` |
143
+ | `src/core/routes/dashboard.ts` | Modified | Add rate limit settings panel to space page |
144
+ | `src/core/routes/config-builtin.ts` | Modified | Add rate limit keys to builtin config set |
145
+ | `resources/templates/mercury.example.yaml` | Modified | Add commented dm_auto_space section and rate_limit_daily_* |
146
+ | `tests/dm-auto-space.test.ts` | New | Unit tests for auto-space flow |
147
+ | `tests/global-daily-rate-limit.test.ts` | New | Unit tests for global daily rate limit fallback |
148
+
149
+ ### Implementation Sequence
150
+
151
+ 1. **Add config fields** — Add `dm_auto_space` block and `runtime.rate_limit_daily_*` to `mercuryFileSchema` in `config-file.ts`, flatten in `flattenMercuryFile()`, add env mappings to `CAMEL_TO_ENV`, and add all new fields to the Zod schema in `config.ts`.
152
+ - Read first: `src/config-file.ts`, `src/config.ts`
153
+ - Verify: `bun run typecheck` passes
154
+
155
+ 2. **Add global daily rate limit fallback** — In `runtime.ts` `handleRawInput()`, when `rate_limit.{role}` is not set per-space, fall back to `config.rateLimitDailyMember` / `config.rateLimitDailyAdmin` instead of skipping.
156
+ - Read first: `src/core/runtime.ts` (lines 395-430)
157
+ - Verify: `bun run typecheck` passes
158
+ - Blocker: Step 1 (config fields must exist)
159
+
160
+ 3. **Add rate limit to dashboard + config-builtin** — Add `rate_limit`, `rate_limit.member`, `rate_limit.admin` to `BUILTIN_CONFIG_KEYS` and `BUILTIN_CONFIG_DESCRIPTIONS` in `config-builtin.ts`. Add a rate limit panel to the space page in `dashboard.ts` following the trigger/context panel pattern.
161
+ - Read first: `src/core/routes/config-builtin.ts`, `src/core/routes/dashboard.ts` (trigger panel ~line 250, context panel ~line 345)
162
+ - Verify: `bun run typecheck` passes
163
+ - Blocker: Step 1 (need global defaults to show "project default" label)
164
+
165
+ 4. **Implement auto-space logic in `resolveConversation()`** — When `conversation.spaceId` is null AND kind is `"dm"` AND config has `dmAutoSpaceEnabled: true`:
166
+ - If sender is in `admin_numbers`: link conversation to `"main"`, return resolution
167
+ - Otherwise: derive space ID (`dm-{sanitized-id}`), call `db.ensureSpace()`, seed `space_config` keys (trigger, context, permissions, system prompt, rate limit), call `db.linkConversation()`, return resolution
168
+ - Read first: `src/core/conversation.ts`, `src/core/handler.ts`, `src/storage/db.ts`
169
+ - Verify: `bun run typecheck` passes
170
+ - Blocker: Steps 1-2 (config fields and rate limit fallback must exist)
171
+
172
+ 5. **Thread config and author name through the call chain** — `resolveConversation()` currently takes `(db, platform, externalId, kind, observedTitle?)`. Add an `autoSpaceConfig` parameter with `{ enabled, adminNumbers, defaultSystemPrompt, defaultMemberPermissions, rateLimitDailyMember }` and an `authorName` parameter. The handler passes config from `config` and author name from `message.author.userName` (available before `resolveConversation` is called; set by WhatsApp adapter from `pushName`). Author name is used for the space display name.
173
+ - Read first: `src/core/handler.ts` (line 50 where resolveConversation is called), `src/adapters/whatsapp.ts` (line 497, 576 — `userName`/`fullName` set from pushName)
174
+ - Verify: `bun run typecheck` passes
175
+
176
+ 6. **Write tests** — Test the auto-space flow: enabled + non-admin → space created + linked + config seeded; admin number → linked to main; disabled → returns null; returning customer → reuses existing space. Test global daily rate limit fallback: per-space set → uses per-space; per-space not set → uses global; global is 0 → unlimited.
177
+ - Read first: existing test files in `tests/` for patterns
178
+ - Verify: `bun test` passes
179
+
180
+ 7. **Update example config** — Add commented `dm_auto_space` section and `rate_limit_daily_*` to `resources/templates/mercury.example.yaml`.
181
+ - Read first: `resources/templates/mercury.example.yaml`
182
+ - Verify: file contains the new sections
183
+
184
+ ---
185
+
186
+ ## Non-Negotiable Rules
187
+
188
+ - **Default off** — `dm_auto_space.enabled` must default to `false`. Existing deployments must not change behavior.
189
+ - **Admin numbers link to main** — admin numbers are auto-linked to the `main` space, never dropped. They never create a new space.
190
+ - **Customer permissions are locked down** — auto-created spaces seed `role.member.permissions` from config (default `"prompt,prefs.get"`), stripping all extension tool access.
191
+ - **Space ID format** — derived IDs must always pass the `/^[a-z0-9][a-z0-9-]*$/` regex. Strip all characters that don't match.
192
+ - **No data loss** — auto-created spaces are regular spaces. Deleting them via the existing API unlinks conversations (FK ON DELETE SET NULL), which is the correct behavior.
193
+ - **Idempotent** — `ensureSpace()` already does INSERT OR IGNORE. Space config seeding must use a read-before-write pattern (`getSpaceConfig` check before `setSpaceConfig`) because `setSpaceConfig` uses `ON CONFLICT DO UPDATE` which would overwrite manual overrides.
194
+ - **Backward compatible rate limits** — global daily rate limit defaults to `0` (unlimited), preserving existing behavior for all deployments.
195
+
196
+ ---
197
+
198
+ ## Edge Cases & Risks
199
+
200
+ | Scenario | Handling |
201
+ |----------|---------|
202
+ | Same customer sends multiple messages before first one is processed | `ensureSpace()` is idempotent (INSERT OR IGNORE); `linkConversation()` is an UPDATE — safe for concurrent calls. Space config seeding uses read-before-write (`getSpaceConfig` → `setSpaceConfig` only if null), so first write wins and subsequent messages don't overwrite. |
203
+ | Customer number produces an invalid space ID (starts with dash, etc.) | Sanitize: strip `@...` suffix, prefix with `dm-`, remove non-alphanumeric chars except hyphens |
204
+ | Admin numbers in different formats (+972 vs 972 vs 972...@s.whatsapp.net) | Normalize: strip leading `+`, strip `@s.whatsapp.net` suffix before comparison |
205
+ | Admin links a DM conversation to a different space manually | `resolveConversation()` only auto-creates when `spaceId` is null — if already linked, uses the existing link |
206
+ | Feature is enabled then disabled | Existing auto-created spaces and links remain; new DMs from unknown numbers go back to being dropped |
207
+ | Admin overrides rate limit per space via dashboard | Per-space value takes precedence over global default; "Reset" button clears override to fall back to global |
208
+ | Very large number of unique customers | Each gets a space row + workspace dir + space_config entries. Acceptable for MVP; monitoring via `listSpaces()` count |
209
+ | `main` space doesn't exist when admin number messages | `ensureSpace("main")` is called at runtime boot (runtime.ts line 88); always exists |
210
+ | Race in space config seeding (read-before-write) | Two concurrent messages could both read null and both write. `setSpaceConfig` uses `ON CONFLICT DO UPDATE` so second write wins — acceptable since both write the same default values. The race only matters if an admin manually changed the config between the two messages, which is vanishingly unlikely during the first-message window. |
211
+ | Push name not available (e.g., privacy settings) | Fall back to phone number as space display name. `message.author.userName` may be just the JID prefix — acceptable for MVP. |
212
+
213
+ ---
214
+
215
+ ## Implementation Checklist
216
+
217
+ ### Phase 1 — Goal (fill before asking for approval)
218
+ - [x] Goal, User Stories, and MVP Scope written and reviewed
219
+
220
+ ### Phase 2 — Architecture (fill after Goal approved)
221
+ - [x] Architecture & Data section complete
222
+ - [x] Non-Negotiable Rules defined
223
+ - [x] Edge Cases covered
224
+ - [x] Context for Claude pointers filled in
225
+
226
+ ### Implementation (tick off as you go)
227
+ - [x] Step 1 — Add config fields to `config-file.ts` and `config.ts`
228
+ - [x] Step 2 — Add global daily rate limit fallback in `runtime.ts`
229
+ - [x] Step 3 — Add rate limit to dashboard + config-builtin
230
+ - [x] Step 4 — Implement auto-space logic in `resolveConversation()`
231
+ - [x] Step 5 — Thread config through handler → resolveConversation call chain
232
+ - [x] Step 6 — Write tests
233
+ - [x] Step 7 — Update example config
234
+ - [x] `bun run check` passes
235
+ - [x] No secrets or `.env` files committed
236
+ - [x] No unrelated files modified
237
+ - [x] All user stories verifiably met
238
+
239
+ ---
240
+
241
+ ## Open Questions
242
+
243
+ - [x] Config key naming — confirmed: `dm_auto_space: { enabled, admin_numbers, default_system_prompt, default_member_permissions }`
244
+ - [x] Should `exclude` match exact numbers or support patterns/wildcards? — exact phone numbers only
245
+ - [x] What happens to excluded/admin numbers? — auto-linked to `main`, not dropped
246
+ - [x] How to restrict customer tools? — seed `role.member.permissions` via `default_member_permissions` config
247
+ - [x] How to set trigger mode? — auto-seeded `trigger.match = "always"` on customer spaces
248
+ - [x] Global daily rate limits? — added to `runtime` config block, per-space dashboard override on top
249
+
250
+ ---
251
+
252
+ ## Retrospective
253
+
254
+ > Fill this section when archiving.
255
+
256
+ **Residual risks / follow-ups:**
257
+ - Per-space AGENTS.md for auto-created spaces is empty by default; the global AGENTS.md applies but a dedicated customer-facing template could improve quality — accepted
258
+ - Business logic (appointment booking, etc.) must be handled by dedicated business extensions with deterministic code, NOT via LLM prompting → ideas/business-extensions.md
259
+
260
+ **What changed from the plan:**
261
+ - Steps 4 and 5 (auto-space logic + config threading) implemented together as they are tightly coupled
262
+ - Added `.toLowerCase()` to `deriveSpaceId` for future non-numeric platform IDs (caught in code review)
263
+ - `memory.ts` removed from modified files list — `ensureSpaceWorkspace` is already called lazily at container execution time
264
+
265
+ **Key decisions made during implementation:**
266
+ - Used `seedSpaceConfigIfAbsent` helper (read-before-write) instead of raw `setSpaceConfig` to avoid overwriting manual admin overrides
267
+ - Author name sourced from `message.author.userName` (set from WhatsApp pushName) before `resolveConversation` — available before bridge normalization
268
+
269
+ **Architecture impact** (update `docs/ARCHITECTURE.md` if any of these apply):
270
+ - [ ] New package/module added
271
+ - [ ] New data model or schema change
272
+ - [ ] New inter-package interaction
273
+ - [ ] Deployment topology changed
@@ -0,0 +1,273 @@
1
+ # Applicative Profiles
2
+
3
+ **Status**: Backlog
4
+ **Slug**: applicative-profiles
5
+ **Created**: 2026-07-01
6
+ **Last updated**: 2026-07-01
7
+
8
+ ---
9
+
10
+ ## Goal
11
+
12
+ Mercury's existing profile system (`src/core/profiles.ts`) is a static provisioning mechanism — it bundles extensions, AGENTS.md, env vars, and config defaults into a deployable template. But it has no runtime behavior: it can't scope which capabilities members access, enforce data isolation (ownership), or declare capability dependencies. Business workflows (appointment booking, customer management) require deterministic logic that can't be reliably delegated to the LLM.
13
+
14
+ Applicative Profiles extend the existing profile system with a **runtime contract** — a profile declares what capabilities it wraps, what commands it exposes to members, and what isolation rules it enforces. Mercury provides the schema, loader, and runtime enforcement; profiles live in external repos. The barber-appointments profile is the first implementation to validate the design.
15
+
16
+ ## User Stories
17
+
18
+ - As a Mercury operator, I want to install a profile package from an external repo so my bot gains domain-specific business logic without modifying Mercury core.
19
+ - As a barber shop owner, I want my customers (DM members) to book and cancel only their own appointments through the bot, without seeing other customers' schedules.
20
+ - As a barber shop owner, I want my customers to have zero access to my Gmail, even though the profile uses my GWS credentials for calendar access internally.
21
+ - As a profile developer, I want a clear schema/contract for defining profiles so I know what to implement (commands, permissions, capability dependencies, data isolation rules).
22
+ - As a Mercury admin, I want raw capability extensions (GWS, web search) to remain admin-only while the profile's scoped commands are member-facing.
23
+
24
+ ## MVP Scope
25
+
26
+ **In scope:**
27
+ - Extend `mercury-profile.yaml` schema with runtime fields: `capabilities` (required extensions), `member_permissions` (exhaustive permission list for members), and `system_prompt` (profile-specific agent persona)
28
+ - Profile-aware permission resolution — when a profile is active, member permissions come from the profile, not from extension defaults
29
+ - Capability dependency validation — profile apply fails if required extensions aren't installed
30
+ - Profile activation per space — a space can have an active profile that governs its runtime behavior
31
+ - `CALLER_ID` injection — profile extensions receive the caller's identity so they can enforce ownership in their CLI code
32
+ - Barber-appointments profile (separate repo) as the first implementation to validate the design
33
+
34
+ **Out of scope (deferred):**
35
+ - Profile marketplace/registry — profiles are installed manually (npm/bun add or git clone) for now
36
+ - Profile versioning/migration — no schema version negotiation yet
37
+ - Multi-profile per space — one profile per space for MVP
38
+ - Profile UI in dashboard — managed via config/YAML only
39
+ - Declarative data isolation rules — ownership enforcement is in profile extension code, not a schema declaration (too domain-specific for a generic schema in v1)
40
+
41
+ ---
42
+
43
+ ## Context for Claude
44
+
45
+ > Key files to read before touching anything.
46
+
47
+ - `src/core/profiles.ts` — Existing profile system: `profileSchema`, `loadProfileFromDir()`, `applyProfile()`, `resolveProfileSource()`. Bundles extensions + AGENTS.md + env + defaults into `.mercury/`
48
+ - `src/extensions/types.ts` — Extension type system: `MercuryExtensionAPI`, `ExtensionMeta`, `PermissionDef`, `CliDef`, `EnvDef`. The API surface extensions use during setup
49
+ - `src/core/permissions.ts` — Permission resolution: `getRolePermissions()`, `registerPermission()`, `resolveRole()`. Role-based (admin/member) per space with extension-registered defaults
50
+ - `src/extensions/permission-guard.ts` — Container-side enforcement: reads `MERCURY_DENIED_CLIS` env var and blocks unauthorized CLI calls
51
+ - `src/extensions/loader.ts` — `ExtensionRegistry` class: scans `.mercury/extensions/`, validates, runs setup functions, collects `ExtensionMeta`
52
+
53
+ ---
54
+
55
+ ## Architecture & Data
56
+
57
+ ### Data Models
58
+
59
+ Extend the existing `profileSchema` in `src/core/profiles.ts`:
60
+
61
+ ```typescript
62
+ // New fields added to profileSchema
63
+ const profileSchema = z.object({
64
+ // ... existing fields ...
65
+ name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
66
+ description: z.string().optional(),
67
+ version: z.string().default("0.1.0"),
68
+ agents_md: z.string().optional(),
69
+ extensions: z.array(profileExtensionSchema).default([]),
70
+ env: z.array(profileEnvVarSchema).default([]),
71
+ defaults: profileDefaultsSchema.optional(),
72
+
73
+ // NEW — runtime applicative fields
74
+ capabilities: z.array(z.string()).default([]), // required raw extensions (e.g. ["gws"])
75
+ member_permissions: z.array(z.string()).optional(), // exhaustive member permission list when profile is active
76
+ system_prompt: z.string().optional(), // profile-specific agent persona
77
+ });
78
+ ```
79
+
80
+ New DB storage — per-space active profile tracking via existing `space_config`:
81
+ - Key: `profile.active` — value: profile name (e.g. `"barber-appointments"`)
82
+ - Key: `profile.member_permissions` — value: comma-separated permission list from the profile manifest
83
+
84
+ No new tables or migrations needed.
85
+
86
+ ### How it works at runtime
87
+
88
+ 1. **Profile apply** (`applyProfile()` extended): copies extensions + AGENTS.md (existing behavior), then validates that all `capabilities` are installed as extensions, and stores the profile's `member_permissions` in a resolvable location.
89
+
90
+ 2. **Permission resolution** (`getRolePermissions()` extended): when a space has `profile.active` set, member permissions come from `profile.member_permissions` instead of the default `prompt + prefs.get + extension defaults`. Admin permissions are unchanged (all). This means raw capabilities (e.g., `gws`) stay admin-only unless the profile explicitly lists them in `member_permissions`.
91
+
92
+ 3. **Capability scoping**: the profile's own extensions call `mercury.permission({ defaultRoles: ["member"] })` to make their commands member-accessible. Raw capability extensions (e.g., `gws`) are NOT in `member_permissions`, so their CLIs are denied by `permission-guard.ts`. The profile extension uses the raw capability internally (server-side hooks, or by passing credentials via env vars that only the extension code — not the container — can access).
93
+
94
+ 4. **Data isolation / ownership**: handled in the profile extension's CLI code. The container already receives `CALLER_ID` (the platform user ID). The profile's CLI checks the caller against the data before acting (e.g., "does this appointment belong to this caller?"). This is intentionally not a generic schema — it's too domain-specific.
95
+
96
+ 5. **Gmail protection**: the `gws` extension exposes both Calendar and Gmail CLIs. When the barber profile is active, `member_permissions` does NOT include `gws`, so the permission guard blocks all `gws` CLI calls for members. The barber extension accesses Calendar server-side (via hooks or its own env vars) — members never touch GWS directly.
97
+
98
+ ### Example: barber-appointments profile
99
+
100
+ ```yaml
101
+ # mercury-profile.yaml (in separate repo)
102
+ name: barber-appointments
103
+ description: Appointment booking for a barber shop
104
+ version: 0.1.0
105
+
106
+ agents_md: ./AGENTS.md
107
+
108
+ capabilities:
109
+ - gws # requires GWS extension installed (for Calendar)
110
+
111
+ extensions:
112
+ - name: barber
113
+ source: ./extensions/barber
114
+
115
+ member_permissions:
116
+ - prompt
117
+ - prefs.get
118
+ - barber # only the barber extension — NOT gws
119
+
120
+ env:
121
+ - key: MERCURY_BARBER_BUSINESS_HOURS
122
+ description: "Business hours (e.g. 09:00-18:00)"
123
+ default: "09:00-18:00"
124
+ - key: MERCURY_BARBER_SLOT_DURATION
125
+ description: "Appointment slot duration in minutes"
126
+ default: "30"
127
+
128
+ system_prompt: |
129
+ You are a barber shop assistant. Help customers book, view, and cancel
130
+ their appointments. You can only help each customer with their own
131
+ appointments — never reveal other customers' schedules.
132
+
133
+ defaults:
134
+ trigger_patterns: "always"
135
+ ```
136
+
137
+ The barber extension (`./extensions/barber/index.ts`):
138
+ - Calls `mercury.cli({ name: "barber", ... })` — installs a CLI with subcommands: `barber book`, `barber availability`, `barber cancel`, `barber my-appointments`
139
+ - Calls `mercury.permission({ defaultRoles: ["member"] })` — members can use the `barber` CLI
140
+ - The CLI internally uses GWS Calendar API (via env var credentials) but filters results by `CALLER_ID`
141
+ - Members NEVER see the `gws` CLI — it's blocked by the permission guard
142
+
143
+ ### API Contracts
144
+
145
+ No new HTTP API endpoints. Profile activation is done via:
146
+ 1. `mercury.yaml` — `profile: barber-appointments` (static config)
147
+ 2. `space_config` — `profile.active: barber-appointments` (per-space override)
148
+
149
+ ### File & Folder Structure
150
+
151
+ | Path | New / Modified | Purpose |
152
+ |------|---------------|---------|
153
+ | `src/core/profiles.ts` | Modified | Extend `profileSchema` with `capabilities`, `member_permissions`, `system_prompt`. Add validation in `applyProfile()` |
154
+ | `src/core/permissions.ts` | Modified | Profile-aware `getRolePermissions()` — check `profile.member_permissions` for the space |
155
+ | `src/config.ts` | Modified | Add `profile` config key for static profile activation |
156
+ | `src/config-file.ts` | Modified | Add `profile` to YAML schema and env mapping |
157
+ | `tests/applicative-profiles.test.ts` | New | Tests for profile permission scoping and capability validation |
158
+
159
+ ### Implementation Sequence
160
+
161
+ 1. **Extend profile schema** — Add `capabilities`, `member_permissions`, `system_prompt` to `profileSchema` in `profiles.ts`. These are optional fields so existing profiles remain valid.
162
+ - Read first: `src/core/profiles.ts`
163
+ - Verify: `bun run typecheck` passes
164
+
165
+ 2. **Add capability validation** — In `applyProfile()`, after copying extensions, check that every entry in `capabilities` has a matching directory in `.mercury/extensions/`. Throw a clear error if a required capability is missing.
166
+ - Read first: `src/core/profiles.ts`, `src/extensions/loader.ts`
167
+ - Verify: `bun run typecheck` passes
168
+ - Blocker: Step 1
169
+
170
+ 3. **Store profile activation in space_config** — When `applyProfile()` runs for a space, write `profile.active` and `profile.member_permissions` to `space_config`. Add `profile` config key to `config.ts` / `config-file.ts` for static activation.
171
+ - Read first: `src/config.ts`, `src/config-file.ts`, `src/storage/db.ts`
172
+ - Verify: `bun run typecheck` passes
173
+ - Blocker: Step 1
174
+
175
+ 4. **Profile-aware permission resolution** — In `getRolePermissions()`, when the space has `profile.member_permissions` set, use that as the member permission set instead of the default. Admin/system roles unchanged.
176
+ - Read first: `src/core/permissions.ts`
177
+ - Verify: `bun run typecheck` passes
178
+ - Blocker: Step 3
179
+
180
+ 5. **System prompt injection** — When a profile has `system_prompt`, inject it via the existing `before_container` hook mechanism or config seeding (same pattern as `dm-auto-space`'s `default_system_prompt`).
181
+ - Read first: `src/core/runtime.ts`, `src/extensions/hooks.ts`
182
+ - Verify: `bun run typecheck` passes
183
+ - Blocker: Step 3
184
+
185
+ 6. **Write tests** — Profile permission scoping: member gets only profile-declared permissions; admin gets all; raw capability blocked for members. Capability validation: apply fails when required extension missing. Profile activation: space_config correctly set.
186
+ - Read first: `tests/permissions.test.ts`
187
+ - Verify: `bun test` passes
188
+ - Blocker: Steps 1-5
189
+
190
+ 7. **Update example config** — Add commented `profile` section to `mercury.example.yaml`.
191
+ - Read first: `resources/templates/mercury.example.yaml`
192
+ - Verify: file contains the new section
193
+
194
+ ---
195
+
196
+ ## Non-Negotiable Rules
197
+
198
+ - **Profiles are external** — Mercury core provides the schema, loader, and runtime. No specific profile (barber, etc.) lives in this repo.
199
+ - **Member permissions are exhaustive** — When a profile declares `member_permissions`, that is the COMPLETE set. No extension defaults are merged in. This prevents capability leakage.
200
+ - **Admin permissions unchanged** — Admins always get all permissions regardless of profile. Profiles scope members only.
201
+ - **Capability validation is strict** — `applyProfile()` must fail loudly if a required capability extension is not installed. Silent degradation is not acceptable.
202
+ - **Data isolation is code, not config** — Ownership enforcement happens in the profile extension's CLI code, not in a declarative schema. The extension checks `CALLER_ID` before acting.
203
+ - **Backward compatible** — Existing profiles without the new fields continue to work exactly as before. All new fields are optional.
204
+ - **No Gmail exposure** — When a profile wraps GWS for Calendar access, the raw `gws` CLI (which includes Gmail) must be blocked for members via permission scoping.
205
+
206
+ ---
207
+
208
+ ## Edge Cases & Risks
209
+
210
+ | Scenario | Handling |
211
+ |----------|---------|
212
+ | Profile declares `member_permissions: ["nonexistent"]` | `isValidPermission()` filters invalid names. Log a warning but don't crash — the invalid permission is silently dropped. |
213
+ | Profile requires capability `gws` but it's not installed | `applyProfile()` throws with a clear error listing missing capabilities. |
214
+ | Admin manually overrides `role.member.permissions` per space | Per-space override in `space_config` takes precedence over profile defaults (existing behavior). Profile sets the baseline; admin can further restrict or expand. |
215
+ | Profile is removed/changed but space_config still references it | `profile.active` becomes stale. Permission resolution still works from the stored `profile.member_permissions`. On next `applyProfile()` the values are updated. |
216
+ | Two extensions declare the same CLI name | Existing loader behavior: second registration throws. No change needed. |
217
+ | Profile extension tries to call raw capability CLI in container | Allowed only if the extension is running as admin (hooks run on host, not in container). Container CLI calls go through permission guard. |
218
+ | Caller ID not available (e.g., system-triggered task) | `CALLER_ID` is already `"system"` for scheduled tasks. Profile extension should handle this gracefully (e.g., skip ownership check for system callers). |
219
+ | Existing deployment upgrades Mercury with profiles feature | No behavioral change. `member_permissions` is optional; when absent, existing permission resolution applies. |
220
+
221
+ ---
222
+
223
+ ## Implementation Checklist
224
+
225
+ ### Phase 1 — Goal (fill before asking for approval)
226
+ - [x] Goal, User Stories, and MVP Scope written and reviewed
227
+
228
+ ### Phase 2 — Architecture (fill after Goal approved)
229
+ - [x] Architecture & Data section complete
230
+ - [x] Non-Negotiable Rules defined
231
+ - [x] Edge Cases covered
232
+ - [x] Context for Claude pointers filled in
233
+
234
+ ### Implementation (tick off as you go)
235
+ - [ ] Step 1 — Extend profile schema
236
+ - [ ] Step 2 — Add capability validation
237
+ - [ ] Step 3 — Store profile activation in space_config
238
+ - [ ] Step 4 — Profile-aware permission resolution
239
+ - [ ] Step 5 — System prompt injection
240
+ - [ ] Step 6 — Write tests
241
+ - [ ] Step 7 — Update example config
242
+ - [ ] `bun run check` passes
243
+ - [ ] No secrets or `.env` files committed
244
+ - [ ] No unrelated files modified
245
+ - [ ] All user stories verifiably met
246
+
247
+ ---
248
+
249
+ ## Open Questions
250
+
251
+ - [ ] Should `dm_auto_space` auto-activate a profile for new customer spaces? (Likely yes — `dm_auto_space.profile: barber-appointments` config key) — owner: TBD
252
+ - [ ] How does `CALLER_ID` map to appointment ownership? Phone number? Display name? A registration step? — owner: profile developer (barber repo)
253
+
254
+ ---
255
+
256
+ ## Retrospective
257
+
258
+ > Fill this section when archiving.
259
+
260
+ **Residual risks / follow-ups:**
261
+ - none
262
+
263
+ **What changed from the plan:**
264
+ - ...
265
+
266
+ **Key decisions made during implementation:**
267
+ - ...
268
+
269
+ **Architecture impact** (update `docs/ARCHITECTURE.md` if any of these apply):
270
+ - [ ] New package/module added
271
+ - [ ] New data model or schema change
272
+ - [ ] New inter-package interaction
273
+ - [ ] Deployment topology changed
@@ -0,0 +1,15 @@
1
+ ---
2
+ slug: dm-auto-space
3
+ shipped: 2026-07-01
4
+ ---
5
+
6
+ ## ROADMAP changes
7
+
8
+ **Remove from:** Next (table row where Item = dm-auto-space)
9
+
10
+ **Also pull:** none
11
+
12
+ **Add to Shipped table** (insert at top, after header row):
13
+ | **dm-auto-space** | 2026-07-01 | Auto-create space per customer DM with rate limits and dashboard controls |
14
+
15
+ **Milestone Map:** none
@@ -6,3 +6,41 @@ Before publishing a new version to npm:
6
6
  2. **`mercury build`** — if you touched build path, Dockerfiles, or file-copy logic
7
7
  3. **`mercury init` + `mercury service install`** in a test project — if you touched CLI flow, config loading, or service management
8
8
  4. **Smoke test assistants** — send a message, verify they respond
9
+
10
+ ## Version bump & publish
11
+
12
+ ```bash
13
+ npm version patch --no-git-tag-version # or minor/major
14
+ git add package.json
15
+ git commit -m "chore: bump version to $(node -p 'require("./package.json").version')"
16
+ git tag "v$(node -p 'require("./package.json").version')"
17
+ git push && git push --tags
18
+ npm publish
19
+ ```
20
+
21
+ Always tag and publish together — `package.json` is the single source of truth for both npm and GitHub.
22
+
23
+ ## Beta testing on another machine
24
+
25
+ Use `--tag beta` to publish a test version without affecting the stable `latest` tag.
26
+
27
+ ```bash
28
+ # 1. Bump to a beta prerelease
29
+ npm version prerelease --preid=beta # e.g. 0.4.24 → 0.4.25-beta.0
30
+
31
+ # 2. Publish under the beta tag (won't affect `npm install mercury-agent`)
32
+ npm publish --tag beta
33
+
34
+ # 3. On the other machine, install the beta
35
+ npm install -g mercury-agent@beta
36
+
37
+ # 4. Test, iterate. Bump again if needed:
38
+ npm version prerelease --preid=beta # 0.4.25-beta.0 → 0.4.25-beta.1
39
+ npm publish --tag beta
40
+
41
+ # 5. When satisfied, promote to stable:
42
+ npm version patch # 0.4.25-beta.1 → 0.4.25
43
+ npm publish # publishes to "latest"
44
+ ```
45
+
46
+ `npm install -g mercury-agent` (without @beta) always gets the stable version. Only explicit `@beta` gets the test version.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.4.24",
3
+ "version": "0.4.26",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -40,6 +40,8 @@
40
40
  # log_format: text
41
41
  # rate_limit_per_user: 10
42
42
  # rate_limit_window_ms: 60000
43
+ # rate_limit_daily_member: 0 # 0 = unlimited; global daily message cap for members
44
+ # rate_limit_daily_admin: 0 # 0 = unlimited; global daily message cap for admins
43
45
 
44
46
  # trigger:
45
47
  # patterns: "@Mercury,Mercury"
@@ -73,3 +75,10 @@
73
75
 
74
76
  # permissions:
75
77
  # admins: ""
78
+
79
+ # dm_auto_space:
80
+ # enabled: false
81
+ # admin_numbers: # auto-link to "main" space
82
+ # - "972501234567"
83
+ # default_system_prompt: "" # seeded into auto-created customer spaces
84
+ # default_member_permissions: "prompt,prefs.get" # restrict customers to chat only
@@ -85,6 +85,8 @@ const mercuryFileSchema = z
85
85
  .min(1000)
86
86
  .max(60 * 60 * 1000)
87
87
  .optional(),
88
+ rate_limit_daily_member: z.number().int().min(0).max(10000).optional(),
89
+ rate_limit_daily_admin: z.number().int().min(0).max(10000).optional(),
88
90
  })
89
91
  .strict()
90
92
  .optional(),
@@ -161,6 +163,16 @@ const mercuryFileSchema = z
161
163
  })
162
164
  .strict()
163
165
  .optional(),
166
+
167
+ dm_auto_space: z
168
+ .object({
169
+ enabled: z.boolean().optional(),
170
+ admin_numbers: z.array(z.string()).optional(),
171
+ default_system_prompt: z.string().optional(),
172
+ default_member_permissions: z.string().optional(),
173
+ })
174
+ .strict()
175
+ .optional(),
164
176
  })
165
177
  .strict();
166
178
 
@@ -233,6 +245,12 @@ function flattenMercuryFile(f: MercuryFile): RawMercuryConfigInput {
233
245
  if (f.runtime?.rate_limit_window_ms != null) {
234
246
  o.rateLimitWindowMs = f.runtime.rate_limit_window_ms;
235
247
  }
248
+ if (f.runtime?.rate_limit_daily_member != null) {
249
+ o.rateLimitDailyMember = f.runtime.rate_limit_daily_member;
250
+ }
251
+ if (f.runtime?.rate_limit_daily_admin != null) {
252
+ o.rateLimitDailyAdmin = f.runtime.rate_limit_daily_admin;
253
+ }
236
254
 
237
255
  if (f.scheduling?.default_timezone != null) {
238
256
  o.defaultTimezone = f.scheduling.default_timezone;
@@ -273,6 +291,20 @@ function flattenMercuryFile(f: MercuryFile): RawMercuryConfigInput {
273
291
 
274
292
  if (f.permissions?.admins != null) o.admins = f.permissions.admins;
275
293
 
294
+ if (f.dm_auto_space?.enabled != null) {
295
+ o.dmAutoSpaceEnabled = f.dm_auto_space.enabled;
296
+ }
297
+ if (f.dm_auto_space?.admin_numbers != null) {
298
+ o.dmAutoSpaceAdminNumbers = f.dm_auto_space.admin_numbers.join(",");
299
+ }
300
+ if (f.dm_auto_space?.default_system_prompt != null) {
301
+ o.dmAutoSpaceDefaultSystemPrompt = f.dm_auto_space.default_system_prompt;
302
+ }
303
+ if (f.dm_auto_space?.default_member_permissions != null) {
304
+ o.dmAutoSpaceDefaultMemberPermissions =
305
+ f.dm_auto_space.default_member_permissions;
306
+ }
307
+
276
308
  return o;
277
309
  }
278
310
 
@@ -335,6 +367,13 @@ const CAMEL_TO_ENV: Record<string, string> = {
335
367
  googleApplicationCredentials: "MERCURY_GOOGLE_APPLICATION_CREDENTIALS",
336
368
  ttsMaxChars: "MERCURY_TTS_MAX_CHARS",
337
369
  defaultTimezone: "MERCURY_DEFAULT_TIMEZONE",
370
+ rateLimitDailyMember: "MERCURY_RATE_LIMIT_DAILY_MEMBER",
371
+ rateLimitDailyAdmin: "MERCURY_RATE_LIMIT_DAILY_ADMIN",
372
+ dmAutoSpaceEnabled: "MERCURY_DM_AUTO_SPACE_ENABLED",
373
+ dmAutoSpaceAdminNumbers: "MERCURY_DM_AUTO_SPACE_ADMIN_NUMBERS",
374
+ dmAutoSpaceDefaultSystemPrompt: "MERCURY_DM_AUTO_SPACE_DEFAULT_SYSTEM_PROMPT",
375
+ dmAutoSpaceDefaultMemberPermissions:
376
+ "MERCURY_DM_AUTO_SPACE_DEFAULT_MEMBER_PERMISSIONS",
338
377
  };
339
378
 
340
379
  function envValueForSchema(
package/src/config.ts CHANGED
@@ -169,6 +169,8 @@ const schema = z.object({
169
169
  .min(1000)
170
170
  .max(60 * 60 * 1000)
171
171
  .default(60 * 1000), // 1 minute
172
+ rateLimitDailyMember: z.coerce.number().int().min(0).max(10000).default(0),
173
+ rateLimitDailyAdmin: z.coerce.number().int().min(0).max(10000).default(0),
172
174
 
173
175
  // ─── Server ─────────────────────────────────────────────────────────
174
176
  port: z.coerce.number().int().min(1).max(65535).default(8787),
@@ -247,6 +249,12 @@ const schema = z.object({
247
249
  googleApplicationCredentials: z.string().optional(),
248
250
  /** Max input characters per /api/tts request (clamped 500–10000). */
249
251
  ttsMaxChars: z.coerce.number().int().min(500).max(10_000).default(5000),
252
+
253
+ // ─── DM Auto-Space ─────────────────────────────────────────────────
254
+ dmAutoSpaceEnabled: booleanFromEnv.default(false),
255
+ dmAutoSpaceAdminNumbers: z.string().default(""),
256
+ dmAutoSpaceDefaultSystemPrompt: z.string().default(""),
257
+ dmAutoSpaceDefaultMemberPermissions: z.string().default("prompt,prefs.get"),
250
258
  });
251
259
 
252
260
  export type AppConfig = z.infer<typeof schema> & {
@@ -1,3 +1,4 @@
1
+ import { logger } from "../logger.js";
1
2
  import type { Db } from "../storage/db.js";
2
3
  import type { Conversation } from "../types.js";
3
4
 
@@ -6,12 +7,45 @@ export interface ConversationResolution {
6
7
  spaceId: string;
7
8
  }
8
9
 
10
+ export interface AutoSpaceConfig {
11
+ enabled: boolean;
12
+ adminNumbers: string[];
13
+ defaultSystemPrompt: string;
14
+ defaultMemberPermissions: string;
15
+ rateLimitDailyMember: number;
16
+ }
17
+
18
+ function normalizePhone(raw: string): string {
19
+ return raw.replace(/^[+]+/, "").replace(/@.*$/, "");
20
+ }
21
+
22
+ function deriveSpaceId(platform: string, externalId: string): string {
23
+ const cleaned = normalizePhone(externalId)
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9-]/g, "");
26
+ if (platform === "whatsapp") return `dm-${cleaned}`;
27
+ return `dm-${platform}-${cleaned}`;
28
+ }
29
+
30
+ function seedSpaceConfigIfAbsent(
31
+ db: Db,
32
+ spaceId: string,
33
+ key: string,
34
+ value: string,
35
+ ): void {
36
+ if (db.getSpaceConfig(spaceId, key) === null) {
37
+ db.setSpaceConfig(spaceId, key, value, "dm-auto-space");
38
+ }
39
+ }
40
+
9
41
  export function resolveConversation(
10
42
  db: Db,
11
43
  platform: string,
12
44
  externalId: string,
13
45
  kind: string,
14
46
  observedTitle?: string,
47
+ autoSpace?: AutoSpaceConfig,
48
+ authorName?: string,
15
49
  ): ConversationResolution | null {
16
50
  const conversation = db.ensureConversation(
17
51
  platform,
@@ -20,9 +54,71 @@ export function resolveConversation(
20
54
  observedTitle,
21
55
  );
22
56
 
23
- if (!conversation.spaceId) return null;
57
+ if (conversation.spaceId) {
58
+ return { conversation, spaceId: conversation.spaceId };
59
+ }
60
+
61
+ if (!autoSpace?.enabled || kind !== "dm") return null;
24
62
 
25
- return { conversation, spaceId: conversation.spaceId };
63
+ const senderNormalized = normalizePhone(externalId);
64
+ const isAdmin = autoSpace.adminNumbers.some(
65
+ (n) => normalizePhone(n) === senderNormalized,
66
+ );
67
+
68
+ if (isAdmin) {
69
+ db.ensureSpace("main");
70
+ db.linkConversation(conversation.id, "main");
71
+ logger.info("dm-auto-space: admin number linked to main", {
72
+ platform,
73
+ externalId: senderNormalized,
74
+ });
75
+ return {
76
+ conversation: { ...conversation, spaceId: "main" },
77
+ spaceId: "main",
78
+ };
79
+ }
80
+
81
+ const spaceId = deriveSpaceId(platform, externalId);
82
+ db.ensureSpace(spaceId);
83
+
84
+ const displayName = authorName || senderNormalized;
85
+ db.updateSpaceName(spaceId, displayName);
86
+
87
+ seedSpaceConfigIfAbsent(db, spaceId, "trigger.match", "always");
88
+ seedSpaceConfigIfAbsent(db, spaceId, "context.mode", "context");
89
+ if (autoSpace.defaultMemberPermissions) {
90
+ seedSpaceConfigIfAbsent(
91
+ db,
92
+ spaceId,
93
+ "role.member.permissions",
94
+ autoSpace.defaultMemberPermissions,
95
+ );
96
+ }
97
+ if (autoSpace.defaultSystemPrompt) {
98
+ seedSpaceConfigIfAbsent(
99
+ db,
100
+ spaceId,
101
+ "system_prompt",
102
+ autoSpace.defaultSystemPrompt,
103
+ );
104
+ }
105
+ if (autoSpace.rateLimitDailyMember > 0) {
106
+ seedSpaceConfigIfAbsent(
107
+ db,
108
+ spaceId,
109
+ "rate_limit.member",
110
+ String(autoSpace.rateLimitDailyMember),
111
+ );
112
+ }
113
+
114
+ db.linkConversation(conversation.id, spaceId);
115
+ logger.info("dm-auto-space: created space and linked conversation", {
116
+ platform,
117
+ externalId: senderNormalized,
118
+ spaceId,
119
+ displayName,
120
+ });
121
+ return { conversation: { ...conversation, spaceId }, spaceId };
26
122
  }
27
123
 
28
124
  export function inferConversationKind(
@@ -3,7 +3,11 @@ import { telegramInboundLooksLikeMedia } from "../bridges/telegram.js";
3
3
  import type { AppConfig } from "../config.js";
4
4
  import { logger } from "../logger.js";
5
5
  import type { NormalizeContext, PlatformBridge } from "../types.js";
6
- import { inferConversationKind, resolveConversation } from "./conversation.js";
6
+ import {
7
+ type AutoSpaceConfig,
8
+ inferConversationKind,
9
+ resolveConversation,
10
+ } from "./conversation.js";
7
11
  import type { MercuryCoreRuntime } from "./runtime.js";
8
12
  import { loadTriggerConfig, matchTrigger } from "./trigger.js";
9
13
 
@@ -21,6 +25,19 @@ export function createMessageHandler(opts: MessageHandlerOptions) {
21
25
  .map((s: string) => s.trim())
22
26
  .filter(Boolean);
23
27
 
28
+ const autoSpaceConfig: AutoSpaceConfig | undefined = config.dmAutoSpaceEnabled
29
+ ? {
30
+ enabled: true,
31
+ adminNumbers: config.dmAutoSpaceAdminNumbers
32
+ .split(",")
33
+ .map((s) => s.trim())
34
+ .filter(Boolean),
35
+ defaultSystemPrompt: config.dmAutoSpaceDefaultSystemPrompt,
36
+ defaultMemberPermissions: config.dmAutoSpaceDefaultMemberPermissions,
37
+ rateLimitDailyMember: config.rateLimitDailyMember,
38
+ }
39
+ : undefined;
40
+
24
41
  return async (
25
42
  adapter: Adapter,
26
43
  threadId: string,
@@ -52,6 +69,9 @@ export function createMessageHandler(opts: MessageHandlerOptions) {
52
69
  bridge.platform,
53
70
  externalId,
54
71
  kind,
72
+ undefined,
73
+ autoSpaceConfig,
74
+ message.author.userName ?? message.author.fullName,
55
75
  );
56
76
 
57
77
  if (!resolution) {
@@ -84,6 +84,13 @@ export function routeInput(input: {
84
84
  .split(",")
85
85
  .map((s) => s.trim())
86
86
  .filter(Boolean);
87
+
88
+ // Auto-inject @botUsername so adapters that rewrite @-mentions (e.g. WhatsApp)
89
+ // always trigger the bot without manual config.
90
+ const atBot = `@${input.config.botUsername}`;
91
+ if (!defaultPatterns.some((p) => p.toLowerCase() === atBot.toLowerCase())) {
92
+ defaultPatterns.push(atBot);
93
+ }
87
94
  const triggerConfig = loadTriggerConfig(input.db, input.spaceId, {
88
95
  patterns: defaultPatterns,
89
96
  match: input.config.triggerMatch,
@@ -10,6 +10,9 @@ export const BUILTIN_CONFIG_KEYS = new Set([
10
10
  "context.window_size",
11
11
  "context.reply_chain_depth",
12
12
  "security.sensitive_connections_allowed",
13
+ "rate_limit",
14
+ "rate_limit.member",
15
+ "rate_limit.admin",
13
16
  ]);
14
17
 
15
18
  /**
@@ -37,6 +40,12 @@ export const BUILTIN_CONFIG_DESCRIPTIONS: Record<string, string> = {
37
40
  "Max number of turns walked back when following a reply chain. Integer 1-50.",
38
41
  "security.sensitive_connections_allowed":
39
42
  "When true, sensitive integrations (e.g. payments, identity) are usable in this group space. False blocks them by default.",
43
+ rate_limit:
44
+ "Per-user burst rate limit (messages per window). Overrides global rate_limit_per_user for this space. Integer ≥ 1, or 0 for unlimited.",
45
+ "rate_limit.member":
46
+ "Daily message cap for members in this space. Overrides global rate_limit_daily_member. Integer ≥ 1, or 0 for unlimited.",
47
+ "rate_limit.admin":
48
+ "Daily message cap for admins in this space. Overrides global rate_limit_daily_admin. Integer ≥ 1, or 0 for unlimited.",
40
49
  };
41
50
 
42
51
  const BUILTIN_VALIDATORS: Record<string, (v: string) => string | null> = {
@@ -76,6 +85,24 @@ const BUILTIN_VALIDATORS: Record<string, (v: string) => string | null> = {
76
85
  ["true", "false"].includes(v)
77
86
  ? null
78
87
  : "Invalid security.sensitive_connections_allowed value. Valid: true, false",
88
+ rate_limit: (v) => {
89
+ const n = Number.parseInt(v, 10);
90
+ return Number.isInteger(n) && n >= 0 && n <= 1000
91
+ ? null
92
+ : "Invalid rate_limit value. Must be an integer between 0 and 1000";
93
+ },
94
+ "rate_limit.member": (v) => {
95
+ const n = Number.parseInt(v, 10);
96
+ return Number.isInteger(n) && n >= 0 && n <= 10000
97
+ ? null
98
+ : "Invalid rate_limit.member value. Must be an integer between 0 and 10000";
99
+ },
100
+ "rate_limit.admin": (v) => {
101
+ const n = Number.parseInt(v, 10);
102
+ return Number.isInteger(n) && n >= 0 && n <= 10000
103
+ ? null
104
+ : "Invalid rate_limit.admin value. Must be an integer between 0 and 10000";
105
+ },
79
106
  };
80
107
 
81
108
  export function isBuiltinConfigKey(key: string): boolean {
@@ -423,6 +423,71 @@ export function createDashboardRoutes(ctx: DashboardContext) {
423
423
  `;
424
424
  }
425
425
 
426
+ function renderRateLimitPanel(
427
+ spaceId: string,
428
+ spacePageReload: string,
429
+ ): string {
430
+ const cfg = core.config;
431
+ const burstRaw = core.db.getSpaceConfig(spaceId, "rate_limit");
432
+ const memberRaw = core.db.getSpaceConfig(spaceId, "rate_limit.member");
433
+ const adminRaw = core.db.getSpaceConfig(spaceId, "rate_limit.admin");
434
+
435
+ const effectiveBurst = burstRaw ?? String(cfg.rateLimitPerUser);
436
+ const effectiveMember =
437
+ memberRaw ??
438
+ (cfg.rateLimitDailyMember > 0 ? String(cfg.rateLimitDailyMember) : "0");
439
+ const effectiveAdmin =
440
+ adminRaw ??
441
+ (cfg.rateLimitDailyAdmin > 0 ? String(cfg.rateLimitDailyAdmin) : "0");
442
+
443
+ const hasOverride = (key: string) =>
444
+ core.db.getSpaceConfig(spaceId, key) !== null;
445
+
446
+ const resetBtn = (key: string) =>
447
+ hasOverride(key)
448
+ ? `<button type="button" class="btn btn-sm" title="Use project default"
449
+ hx-delete="/dashboard/api/space-config?spaceId=${encodeURIComponent(spaceId)}&key=${encodeURIComponent(key)}"
450
+ hx-swap="none"
451
+ hx-on::after-request="${spacePageReload}">Reset</button>`
452
+ : "";
453
+
454
+ const sid = escapeHtml(spaceId);
455
+
456
+ const numRow = (label: string, key: string, value: string) => {
457
+ const desc = BUILTIN_CONFIG_DESCRIPTIONS[key];
458
+ const titleAttr = desc ? ` title="${escapeHtml(desc)}"` : "";
459
+ return `<div class="role-row" style="flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:10px">
460
+ <span style="min-width:160px;font-weight:500"${titleAttr}>${escapeHtml(label)}</span>
461
+ <form style="display:flex;gap:8px;align-items:center;flex:1;flex-wrap:wrap" class="trigger-cfg-form"
462
+ hx-post="/dashboard/api/space-config" hx-swap="none" hx-on::after-request="${spacePageReload}">
463
+ <input type="hidden" name="spaceId" value="${sid}" />
464
+ <input type="hidden" name="key" value="${escapeHtml(key)}" />
465
+ <input type="number" name="value" class="select" value="${escapeHtml(value)}" min="0" required style="width:80px" />
466
+ <button type="submit" class="btn btn-sm">Save</button>
467
+ </form>
468
+ ${resetBtn(key)}
469
+ </div>`;
470
+ };
471
+
472
+ return `
473
+ <p class="muted" style="margin-bottom:10px;line-height:1.5">
474
+ <strong>Project default</strong> (from env / mercury.yaml):
475
+ burst <span class="mono">${cfg.rateLimitPerUser}/min</span>,
476
+ daily member <span class="mono">${cfg.rateLimitDailyMember === 0 ? "unlimited" : String(cfg.rateLimitDailyMember)}</span>,
477
+ daily admin <span class="mono">${cfg.rateLimitDailyAdmin === 0 ? "unlimited" : String(cfg.rateLimitDailyAdmin)}</span>.
478
+ </p>
479
+ <p class="muted" style="margin-bottom:16px;line-height:1.5;font-size:0.92em">
480
+ <strong>Effective</strong> for this space:
481
+ burst <span class="mono">${escapeHtml(effectiveBurst)}/min</span>,
482
+ daily member <span class="mono">${effectiveMember === "0" ? "unlimited" : escapeHtml(effectiveMember)}/day</span>,
483
+ daily admin <span class="mono">${effectiveAdmin === "0" ? "unlimited" : escapeHtml(effectiveAdmin)}/day</span>
484
+ </p>
485
+ ${numRow("rate_limit", "rate_limit", effectiveBurst)}
486
+ ${numRow("rate_limit.member", "rate_limit.member", effectiveMember)}
487
+ ${numRow("rate_limit.admin", "rate_limit.admin", effectiveAdmin)}
488
+ `;
489
+ }
490
+
426
491
  function renderModelBlock(cfg: AppConfig): string {
427
492
  const legs = cfg.resolvedModelChain;
428
493
  if (legs.length === 0) {
@@ -1077,6 +1142,7 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1077
1142
  );
1078
1143
 
1079
1144
  const contextHtml = renderContextPanel(spaceId, spacePageReload);
1145
+ const rateLimitHtml = renderRateLimitPanel(spaceId, spacePageReload);
1080
1146
 
1081
1147
  const configHtml =
1082
1148
  configEntriesFiltered.length > 0
@@ -1157,6 +1223,11 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1157
1223
  <div class="panel-body">${raw(contextHtml)}</div>
1158
1224
  </div>
1159
1225
 
1226
+ <div class="panel">
1227
+ <div class="panel-header">Rate limits</div>
1228
+ <div class="panel-body">${raw(rateLimitHtml)}</div>
1229
+ </div>
1230
+
1160
1231
  <div class="grid-2">
1161
1232
  <div class="panel">
1162
1233
  <div class="panel-header">Tasks</div>
@@ -398,8 +398,17 @@ export class MercuryCoreRuntime {
398
398
  if (route.role !== "system") {
399
399
  const roleKey = `rate_limit.${route.role}`;
400
400
  const roleLimitRaw = this.db.getSpaceConfig(message.spaceId, roleKey);
401
- if (roleLimitRaw !== null) {
402
- const roleLimit = Number.parseInt(roleLimitRaw, 10);
401
+ const globalDailyLimit =
402
+ route.role === "member"
403
+ ? this.config.rateLimitDailyMember
404
+ : route.role === "admin"
405
+ ? this.config.rateLimitDailyAdmin
406
+ : 0;
407
+ const effectiveDailyRaw =
408
+ roleLimitRaw ??
409
+ (globalDailyLimit > 0 ? String(globalDailyLimit) : null);
410
+ if (effectiveDailyRaw !== null) {
411
+ const roleLimit = Number.parseInt(effectiveDailyRaw, 10);
403
412
  if (!Number.isNaN(roleLimit) && roleLimit > 0) {
404
413
  const daily = this.db.checkAndIncrementDailyUsage(
405
414
  message.spaceId,
@@ -416,7 +425,6 @@ export class MercuryCoreRuntime {
416
425
  };
417
426
  }
418
427
  }
419
- // roleLimit === 0 means unlimited — skip daily check
420
428
  }
421
429
  }
422
430