mercury-agent 0.4.25 → 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/docs/ROADMAP.md +2 -0
- package/docs/archive/2026-07-01-dm-auto-space.md +273 -0
- package/docs/backlog/applicative-profiles.md +273 -0
- package/docs/pending-updates/dm-auto-space.md +15 -0
- package/package.json +1 -1
- package/resources/templates/mercury.example.yaml +9 -0
- package/src/config-file.ts +39 -0
- package/src/config.ts +8 -0
- package/src/core/conversation.ts +98 -2
- package/src/core/handler.ts +21 -1
- package/src/core/routes/config-builtin.ts +27 -0
- package/src/core/routes/dashboard.ts +71 -0
- package/src/core/runtime.ts +11 -3
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
|
package/package.json
CHANGED
|
@@ -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
|
package/src/config-file.ts
CHANGED
|
@@ -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> & {
|
package/src/core/conversation.ts
CHANGED
|
@@ -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 (
|
|
57
|
+
if (conversation.spaceId) {
|
|
58
|
+
return { conversation, spaceId: conversation.spaceId };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!autoSpace?.enabled || kind !== "dm") return null;
|
|
24
62
|
|
|
25
|
-
|
|
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(
|
package/src/core/handler.ts
CHANGED
|
@@ -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 {
|
|
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) {
|
|
@@ -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>
|
package/src/core/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
402
|
-
|
|
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
|
|