mercury-agent 0.8.12 → 0.9.1

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.
@@ -136,6 +136,14 @@ the extension's `SKILL.md` or the profile's `AGENTS.md`.
136
136
  - **`member_permissions` is exhaustive.** List every permission a member may
137
137
  hold, including the capability name (e.g. `rooms`). Anything not listed —
138
138
  including raw capabilities like `gws` — is unavailable to members.
139
+ One back-compat exception: a list that mentions neither `media.receive` nor
140
+ `media.send` gets both appended at load time (media exchange predates these
141
+ permissions, so their absence carries no revocation intent). To restrict
142
+ media for members, list at least one of them explicitly — a list mentioning
143
+ either is taken verbatim. `media.purge` does not count as an opt-out.
144
+ Known limitation: denying *both* media permissions cannot be expressed in a
145
+ profile list (mentioning one grants it; mentioning neither appends both) —
146
+ use a per-space override (`mrctl permissions set member …`) for a full deny.
139
147
  - **Authorization = permission named after the capability.** The broker route
140
148
  requires the caller to hold the `<name>` permission; the same grant gates both
141
149
  the `mrctl capability <name> …` CLI and the route. Keep capability name =
@@ -48,6 +48,15 @@ interface MessageAttachment {
48
48
  | `MERCURY_MEDIA_ENABLED` | `true` | Enable/disable media downloads |
49
49
  | `MERCURY_MEDIA_MAX_SIZE_MB` | `10` | Max file size to download (MB) |
50
50
 
51
+ ## Permission Gating
52
+
53
+ The pipeline is gated per caller role, per space by two built-in permissions (both granted to `member` by default — see [permissions.md](../permissions.md)):
54
+
55
+ - **`media.receive`** — when the caller's role lacks it, incoming files are deleted from `inbox/` before the container runs and dropped from the stored message. The message text still goes through, and the agent receives a system note that files arrived but were blocked. Covers all media types including voice notes (revoking it disables voice transcription for that role).
56
+ - **`media.send`** — when the caller's role lacks it, files produced during that caller's turn are not delivered; the reply carries a one-line notice. The files stay in `outbox/` (TTL cleanup applies).
57
+
58
+ `admin` and `system` callers (scheduled tasks) are exempt. Gating is per-caller-turn: in a shared group, a blocked member's turns are gated while an admin's turns deliver files normally. Bridges still download media before routing — the write is transient; the gate deletes it before the message is saved or any agent code runs.
59
+
51
60
  ## Storage
52
61
 
53
62
  ### Ingress (inbox/)
@@ -28,7 +28,7 @@ Message arrives
28
28
  |------|---------------------|-------------|
29
29
  | `system` | All | Internal system caller (scheduler, etc.) — not assignable |
30
30
  | `admin` | All | Full control over the space |
31
- | `member` | `prompt`, `prefs.get` | Can chat and read space preferences (default for new users) |
31
+ | `member` | `prompt`, `prefs.get`, `media.receive`, `media.send` | Can chat, read space preferences, and exchange files (default for new users) |
32
32
 
33
33
  Custom roles can be created by assigning permissions to any role name.
34
34
 
@@ -56,6 +56,19 @@ Custom roles can be created by assigning permissions to any role name.
56
56
  | `spaces.list` | View all spaces |
57
57
  | `spaces.rename` | Rename a space and link/unlink conversations |
58
58
  | `spaces.delete` | Delete current space and all related DB data |
59
+ | `media.receive` | Incoming attachments are saved to `inbox/` and shown to the agent |
60
+ | `media.send` | Outbox files produced on this caller's turn are delivered back to the chat |
61
+
62
+ ### Media permissions
63
+
64
+ `media.receive` and `media.send` gate the media pipeline per caller role, per space. Both are granted to `member` by default, so behavior is unchanged unless an operator revokes them (e.g. `mrctl permissions set member prompt,prefs.get` — omitting the media names revokes them in that space).
65
+
66
+ Denials are never silent:
67
+
68
+ - **Blocked receive** — the caller's inbox files are deleted from disk before the container runs and dropped from the stored message; the message text still goes through, and the agent is told via a system note that files arrived but were blocked. `media.receive` covers all media types, including voice notes — revoking it also disables voice-message transcription for that role.
69
+ - **Blocked send** — files produced during that caller's turn are not delivered; the reply carries a one-line notice. The files remain in `outbox/` for admin retrieval until TTL cleanup removes them.
70
+
71
+ `admin` and `system` callers (scheduled tasks) are always exempt — the gates never fire for them. Lists that predate these permissions (profile manifests, stored per-space overrides, operator `defaultMemberPermissions` configs) get both names appended automatically for backwards compatibility; a list that mentions either name is taken verbatim (`media.purge` predates the pair and does not count as an opt-out).
59
72
 
60
73
  ## Mutes
61
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.8.12",
3
+ "version": "0.9.1",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -93,7 +93,7 @@
93
93
  "commander": "^14.0.3",
94
94
  "cron-parser": "^5.5.0",
95
95
  "discord.js": "^14.26.3",
96
- "hono": "^4.12.25",
96
+ "hono": "^4.12.34",
97
97
  "qrcode-terminal": "^0.12.0",
98
98
  "yaml": "^2.8.3",
99
99
  "zod": "^4.3.6"
@@ -87,6 +87,13 @@
87
87
  # default_system_prompt: "" # seeded into auto-created user spaces
88
88
  # default_member_permissions: "prompt,prefs.get" # restrict users to chat only
89
89
 
90
+ # ─── System messages ────────────────────────────────────────────────────────
91
+ # Deployment-wide default language for Mercury-generated system messages
92
+ # (rate-limit denials, error messages, permission denials). Overridable
93
+ # per-space from the dashboard or chat: mrctl config set messages.locale he
94
+ # messages:
95
+ # locale: en # en | he
96
+
90
97
  # ─── Extension config defaults ──────────────────────────────────────────────
91
98
  # Deployment-wide defaults for extension config keys, applied to every space
92
99
  # (incl. auto-created DM spaces) unless overridden per-space or in the
@@ -1,3 +1,8 @@
1
+ import {
2
+ formatSystemMessage,
3
+ type MessageLocale,
4
+ } from "../core/system-messages.js";
5
+
1
6
  export type UserErrorCategory =
2
7
  | "auth"
3
8
  | "key-limit"
@@ -28,50 +33,19 @@ export function classifyUserError(errorText: string): UserErrorCategory {
28
33
  return "generic";
29
34
  }
30
35
 
31
- const MESSAGES: Record<UserErrorCategory, { platform: string; byok: string }> =
32
- {
33
- "key-limit": {
34
- platform: "I've reached my usage limit for now. Please try again later.",
35
- byok: "Your API key has hit its spending limit. Check your provider's key settings to increase it.",
36
- },
37
- "rate-limit": {
38
- platform:
39
- "I'm handling too many requests right now — please try again in a moment.",
40
- byok: "Your API key is being rate-limited. Try again in a moment.",
41
- },
42
- auth: {
43
- platform:
44
- "Something went wrong on my end. This has been logged and the admin will be notified.",
45
- byok: "Your API key appears to be invalid or expired. Please update it.",
46
- },
47
- credits: {
48
- platform: "I've reached my usage limit for now. Please try again later.",
49
- byok: "Your API provider account has insufficient credits. Add credits to continue.",
50
- },
51
- "server-error": {
52
- platform:
53
- "The AI service is temporarily unavailable. Please try again in a few minutes.",
54
- byok: "The AI service is temporarily unavailable. Please try again in a few minutes.",
55
- },
56
- generic: {
57
- platform:
58
- "Something went wrong processing your request. Please try again.",
59
- byok: "Something went wrong processing your request. Please try again, or check your API key and provider status.",
60
- },
61
- };
62
-
63
36
  export function friendlyErrorMessage(
64
37
  category: UserErrorCategory,
65
38
  mode: "platform" | "byok",
66
39
  consoleUrl?: string,
40
+ locale: MessageLocale = "en",
67
41
  ): string {
68
- let message = MESSAGES[category][mode];
42
+ let message = formatSystemMessage(locale, `err_${category}_${mode}`);
69
43
  const base = consoleUrl?.replace(/\/+$/, "");
70
44
  if (base && mode === "platform") {
71
45
  if (category === "key-limit" || category === "credits") {
72
- message += `\n\nUpgrade your plan: ${base}/dashboard/billing`;
46
+ message += `\n\n${formatSystemMessage(locale, "err_upgrade_suffix", { url: base })}`;
73
47
  } else if (category === "auth") {
74
- return `Your Anthropic session has expired. Please reconnect: ${base}/dashboard/model`;
48
+ return formatSystemMessage(locale, "err_session_expired", { url: base });
75
49
  }
76
50
  }
77
51
  return message;
@@ -166,6 +166,13 @@ const mercuryFileSchema = z
166
166
  .strip()
167
167
  .optional(),
168
168
 
169
+ messages: z
170
+ .object({
171
+ locale: z.enum(["en", "he"]).optional(),
172
+ })
173
+ .strip()
174
+ .optional(),
175
+
169
176
  dm_auto_space: z
170
177
  .object({
171
178
  enabled: z.boolean().optional(),
@@ -211,6 +218,7 @@ const KNOWN_TOP_KEYS = new Set([
211
218
  "telegram",
212
219
  "media",
213
220
  "permissions",
221
+ "messages",
214
222
  "dm_auto_space",
215
223
  "extensions",
216
224
  ]);
@@ -254,6 +262,7 @@ const KNOWN_SECTION_KEYS: Record<string, Set<string>> = {
254
262
  telegram: new Set(["format_enabled"]),
255
263
  media: new Set(["enabled", "max_size_mb"]),
256
264
  permissions: new Set(["admins"]),
265
+ messages: new Set(["locale"]),
257
266
  dm_auto_space: new Set([
258
267
  "enabled",
259
268
  "admin_ids",
@@ -407,6 +416,8 @@ function flattenMercuryFile(f: MercuryFile): RawMercuryConfigInput {
407
416
 
408
417
  if (f.permissions?.admins != null) o.admins = f.permissions.admins;
409
418
 
419
+ if (f.messages?.locale != null) o.messagesLocale = f.messages.locale;
420
+
410
421
  if (f.dm_auto_space?.enabled != null) {
411
422
  o.dmAutoSpaceEnabled = f.dm_auto_space.enabled;
412
423
  }
@@ -484,6 +495,7 @@ const CAMEL_TO_ENV: Record<string, string> = {
484
495
  mediaEnabled: "MERCURY_MEDIA_ENABLED",
485
496
  mediaMaxSizeMb: "MERCURY_MEDIA_MAX_SIZE_MB",
486
497
  admins: "MERCURY_ADMINS",
498
+ messagesLocale: "MERCURY_MESSAGES_LOCALE",
487
499
  profile: "MERCURY_PROFILE",
488
500
  apiSecret: "MERCURY_API_SECRET",
489
501
  callerTokenKey: "MERCURY_CALLER_TOKEN_KEY",
package/src/config.ts CHANGED
@@ -221,6 +221,14 @@ const schema = z.object({
221
221
  // ─── Permissions ────────────────────────────────────────────────────
222
222
  admins: z.string().default(""),
223
223
 
224
+ // ─── System Messages ────────────────────────────────────────────────
225
+ /**
226
+ * Deployment-wide default locale for host-generated system messages
227
+ * (rate-limit denials, error messages, permission denials). Overridable
228
+ * per-space via the `messages.locale` space config key. Env: MERCURY_MESSAGES_LOCALE.
229
+ */
230
+ messagesLocale: z.enum(["en", "he"]).default("en"),
231
+
224
232
  // ─── Applicative Profile ────────────────────────────────────────────
225
233
  /**
226
234
  * Name of the active applicative profile (informational at runtime; the
@@ -1,6 +1,7 @@
1
1
  import { logger } from "../logger.js";
2
2
  import type { Db } from "../storage/db.js";
3
3
  import type { Conversation } from "../types.js";
4
+ import { withMediaBackCompat } from "./permissions.js";
4
5
 
5
6
  export interface ConversationResolution {
6
7
  conversation: Conversation;
@@ -159,11 +160,17 @@ export function resolveConversation(
159
160
  seedSpaceConfigIfAbsent(db, spaceId, "context.mode", "context");
160
161
  seedSpaceConfigIfAbsent(db, spaceId, "debounce.idle_timeout_ms", "2000");
161
162
  if (autoSpace.defaultMemberPermissions) {
163
+ // Seed-time back-compat: an operator config authored before the media
164
+ // permissions existed must not deny media in newly auto-created spaces
165
+ // while migrated older spaces allow it. A list mentioning either media
166
+ // transfer name is an explicit choice and is seeded verbatim.
162
167
  seedSpaceConfigIfAbsent(
163
168
  db,
164
169
  spaceId,
165
170
  "role.member.permissions",
166
- autoSpace.defaultMemberPermissions,
171
+ withMediaBackCompat(
172
+ autoSpace.defaultMemberPermissions.split(",").map((s) => s.trim()),
173
+ ).join(","),
167
174
  );
168
175
  }
169
176
  if (autoSpace.defaultSystemPrompt) {
@@ -0,0 +1,121 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { logger } from "../logger.js";
4
+ import type { EgressFile, MessageAttachment } from "../types.js";
5
+
6
+ /**
7
+ * Enforcement helpers for the `media.receive` / `media.send` permissions.
8
+ *
9
+ * Pure with respect to permission resolution: callers decide whether the
10
+ * caller's role holds the permission and only invoke these on denial. Denials
11
+ * are never silent — the receive gate tells the agent via a prompt note, the
12
+ * send gate tells the user via a reply notice, and both log at WARN.
13
+ */
14
+
15
+ export interface IncomingMediaGateResult {
16
+ /** Attachments to persist/prompt with — undefined when blocked. */
17
+ attachments: MessageAttachment[] | undefined;
18
+ /** System note to append to the agent prompt, or null when nothing was blocked. */
19
+ promptNote: string | null;
20
+ }
21
+
22
+ /**
23
+ * Apply a `media.receive` denial: delete the caller's inbox files from disk
24
+ * (before the container mounts the workspace) and drop the attachments from
25
+ * the message.
26
+ *
27
+ * Deletion is workspace-scoped: only paths resolving inside
28
+ * `<workspacePath>/inbox/` are deleted; anything else is logged and skipped.
29
+ * FS errors are logged and the attachment is still dropped — the inbox TTL
30
+ * cleanup is the backstop.
31
+ */
32
+ export function gateIncomingMedia(opts: {
33
+ workspacePath: string;
34
+ spaceId: string;
35
+ callerRole: string;
36
+ attachments: MessageAttachment[] | undefined;
37
+ hadIncomingAttachments: boolean;
38
+ }): IncomingMediaGateResult {
39
+ const { workspacePath, spaceId, callerRole, attachments } = opts;
40
+ const count = attachments?.length ?? 0;
41
+
42
+ if (count === 0 && !opts.hadIncomingAttachments) {
43
+ return { attachments, promptNote: null };
44
+ }
45
+
46
+ const inboxRoot = path.resolve(workspacePath, "inbox");
47
+ for (const att of attachments ?? []) {
48
+ const resolved = path.resolve(workspacePath, att.path);
49
+ // Strictly inside inbox/ — the inbox root itself is never a deletion target.
50
+ if (!resolved.startsWith(inboxRoot + path.sep)) {
51
+ logger.warn(
52
+ "media.receive gate: attachment path outside workspace inbox, not deleting",
53
+ { spaceId, path: att.path },
54
+ );
55
+ continue;
56
+ }
57
+ try {
58
+ fs.rmSync(resolved, { force: true });
59
+ } catch (error) {
60
+ logger.warn("media.receive gate: failed to delete inbox file", {
61
+ spaceId,
62
+ path: att.path,
63
+ error: error instanceof Error ? error.message : String(error),
64
+ });
65
+ }
66
+ }
67
+
68
+ logger.warn("Blocked incoming media (role lacks media.receive)", {
69
+ spaceId,
70
+ callerRole,
71
+ count,
72
+ });
73
+
74
+ // count === 0 means the platform reported attachments but nothing was
75
+ // persisted upstream (media disabled, size limit, download failure) — don't
76
+ // attribute that solely to the permission.
77
+ const promptNote =
78
+ count > 0
79
+ ? `[system] The user sent ${count} ${count === 1 ? "file" : "files"} with this message, but file receiving is disabled ` +
80
+ `for their role, so the ${count === 1 ? "file was" : "files were"} not kept and cannot be read. ` +
81
+ `If relevant, let the user know that sending files is not available to them.`
82
+ : `[system] The user attempted to send one or more files with this message, but they were not received ` +
83
+ `(file receiving is disabled for their role, or the files could not be downloaded). ` +
84
+ `If relevant, let the user know that sending files is not available to them.`;
85
+ return { attachments: undefined, promptNote };
86
+ }
87
+
88
+ export interface OutgoingMediaGateResult {
89
+ /** Files to deliver — empty when blocked. */
90
+ files: EgressFile[];
91
+ /** Reply text, with a withhold notice appended when files were blocked. */
92
+ reply: string;
93
+ }
94
+
95
+ /**
96
+ * Apply a `media.send` denial: withhold this turn's outbox files and append a
97
+ * one-line notice to the reply. Files stay in `outbox/` for admin retrieval;
98
+ * the outbox TTL cleanup removes them later.
99
+ */
100
+ export function gateOutgoingMedia(opts: {
101
+ spaceId: string;
102
+ callerRole: string;
103
+ files: EgressFile[];
104
+ reply: string;
105
+ }): OutgoingMediaGateResult {
106
+ const { spaceId, callerRole, files, reply } = opts;
107
+ if (files.length === 0) return { files, reply };
108
+
109
+ logger.warn("Withheld outgoing media (role lacks media.send)", {
110
+ spaceId,
111
+ callerRole,
112
+ count: files.length,
113
+ });
114
+
115
+ const noun = files.length === 1 ? "file was" : "files were";
116
+ const notice = `(${files.length} generated ${noun} not delivered because file delivery is disabled for your role.)`;
117
+ return {
118
+ files: [],
119
+ reply: reply ? `${reply}\n\n${notice}` : notice,
120
+ };
121
+ }
@@ -1,3 +1,4 @@
1
+ import { logger } from "../logger.js";
1
2
  import type { Db } from "../storage/db.js";
2
3
  import { matchesConfiguredId } from "./global-admin.js";
3
4
 
@@ -29,6 +30,10 @@ const BUILT_IN_PERMISSIONS = new Set([
29
30
  "spaces.delete",
30
31
  /** Purge inbox/outbox media files. */
31
32
  "media.purge",
33
+ /** Incoming attachments are saved to inbox/ and shown to the agent. */
34
+ "media.receive",
35
+ /** Outbox files produced on this caller's turn are delivered back. */
36
+ "media.send",
32
37
  /** Host Text-to-Speech (/api/tts); admin-only by default. */
33
38
  "tts.synthesize",
34
39
  /** Mute/unmute users and list mutes; admin-only by default. */
@@ -103,6 +108,23 @@ export function setActiveProfileMemberPermissions(
103
108
  activeProfileMemberPermissions = permissions;
104
109
  }
105
110
 
111
+ /**
112
+ * Back-compat for permission lists authored before `media.receive` /
113
+ * `media.send` existed: media exchange used to be ungated, so a list that
114
+ * doesn't mention either name carries no revocation intent — append both to
115
+ * preserve behavior. A list mentioning either one has decided explicitly and
116
+ * is returned verbatim. `media.purge` predates this feature and is NOT an
117
+ * opt-out signal.
118
+ */
119
+ export function withMediaBackCompat(permissions: string[]): string[] {
120
+ const mentionsMediaTransfer = permissions.some((p) => {
121
+ const t = p.trim();
122
+ return t === "media.receive" || t === "media.send";
123
+ });
124
+ if (mentionsMediaTransfer) return permissions;
125
+ return [...permissions, "media.receive", "media.send"];
126
+ }
127
+
106
128
  /** Parse a permission list into a validated set (drops unknown names). */
107
129
  function toPermissionSet(list: string[]): Set<string> {
108
130
  return new Set(list.map((s) => s.trim()).filter((s) => isValidPermission(s)));
@@ -118,6 +140,36 @@ function toPermissionSet(list: string[]): Set<string> {
118
140
  */
119
141
  export const seededSpaces = new Set<string>();
120
142
 
143
+ /**
144
+ * (space, caller) pairs whose config-admin re-promotion was already logged —
145
+ * once per process, not per message (resolveRole runs on every message).
146
+ * Exported for test isolation (tests should clear this in beforeEach).
147
+ */
148
+ export const warnedRepromotions = new Set<string>();
149
+
150
+ /**
151
+ * A stored non-admin role was overridden back to admin because the caller is
152
+ * listed in config.admins. The override is deliberate (config admins are
153
+ * always admins), but it must be visible: an operator's explicit demotion
154
+ * otherwise silently doesn't hold. Deduped once per (space, caller) per
155
+ * process.
156
+ */
157
+ function warnConfigAdminRepromotion(
158
+ spaceId: string,
159
+ callerId: string,
160
+ source: "re-seed" | "self-heal",
161
+ ): void {
162
+ // NUL separator: caller ids contain ":" (e.g. "whatsapp:..."), so a ":"
163
+ // join could collide distinct (space, caller) pairs.
164
+ const key = `${spaceId}\u0000${callerId}`;
165
+ if (warnedRepromotions.has(key)) return;
166
+ warnedRepromotions.add(key);
167
+ logger.warn(
168
+ "Config admin re-promoted: stored role overridden — remove from config.admins to demote",
169
+ { spaceId, callerId, source },
170
+ );
171
+ }
172
+
121
173
  // ---------------------------------------------------------------------------
122
174
  // System callers
123
175
  // ---------------------------------------------------------------------------
@@ -137,14 +189,19 @@ export function isSystemCaller(callerId: string): boolean {
137
189
  // ---------------------------------------------------------------------------
138
190
 
139
191
  /** Built-in defaults for the member role */
140
- const DEFAULT_MEMBER_PERMISSIONS = new Set(["prompt", "prefs.get"]);
192
+ const DEFAULT_MEMBER_PERMISSIONS = new Set([
193
+ "prompt",
194
+ "prefs.get",
195
+ "media.receive",
196
+ "media.send",
197
+ ]);
141
198
 
142
199
  /**
143
200
  * Compute the default permission set for a role, merging built-in defaults
144
201
  * with extension-registered defaults.
145
202
  *
146
203
  * - `admin` and `system` get all permissions (built-in + extension)
147
- * - `member` gets `prompt`, `prefs.get`, plus any extension permissions that list "member" in defaultRoles
204
+ * - `member` gets `prompt`, `prefs.get`, `media.receive`, `media.send`, plus any extension permissions that list "member" in defaultRoles
148
205
  * - Other roles get extension permissions that list them in defaultRoles
149
206
  */
150
207
  function getDefaultPermissions(role: string): Set<string> {
@@ -226,13 +283,19 @@ export function resolveRole(
226
283
  if (isSystemCaller(platformUserId)) return "system";
227
284
 
228
285
  if (seededAdmins.length > 0 && !seededSpaces.has(spaceId)) {
229
- db.seedAdmins(spaceId, seededAdmins);
286
+ for (const id of db.seedAdmins(spaceId, seededAdmins)) {
287
+ warnConfigAdminRepromotion(spaceId, id, "re-seed");
288
+ }
230
289
  seededSpaces.add(spaceId);
231
290
  }
232
291
 
292
+ // Read before upsertMember, which inserts a fresh "member" row on first
293
+ // contact — a null here distinguishes "never seen" from "operator demoted".
294
+ const storedRole = db.getRole(spaceId, platformUserId);
295
+
233
296
  db.upsertMember(spaceId, platformUserId, displayName);
234
297
 
235
- const role = db.getRole(spaceId, platformUserId) ?? "member";
298
+ const role = storedRole ?? "member";
236
299
 
237
300
  // Seeded rows are keyed by the raw config string, which may differ from the
238
301
  // canonical caller id (format looseness, WhatsApp LID vs phone JID). When the
@@ -243,6 +306,11 @@ export function resolveRole(
243
306
  role === "member" &&
244
307
  matchesConfiguredId(platformUserId, seededAdmins, db)
245
308
  ) {
309
+ // Warn only when a member row pre-existed (a stored demotion being
310
+ // overridden) — a first-contact self-heal has nothing to override.
311
+ if (storedRole === "member") {
312
+ warnConfigAdminRepromotion(spaceId, platformUserId, "self-heal");
313
+ }
246
314
  db.setRole(spaceId, platformUserId, "admin", "seed");
247
315
  return "admin";
248
316
  }
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
12
12
  import { dirname, join, resolve } from "node:path";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { z } from "zod";
15
+ import { withMediaBackCompat } from "./permissions.js";
15
16
 
16
17
  // ─── Profile Schema ───────────────────────────────────────────────────────
17
18
 
@@ -208,7 +209,13 @@ export function loadActiveProfile(dataDir: string): ActiveProfile | null {
208
209
  const raw = JSON.parse(readFileSync(file, "utf-8"));
209
210
  return {
210
211
  name: raw.name,
211
- memberPermissions: raw.memberPermissions ?? null,
212
+ // Load-time back-compat: profile manifests authored before the media
213
+ // permissions existed must keep member media working. Applied here (not
214
+ // at persist time) so already-deployed active-profile.json files are
215
+ // covered on every startup.
216
+ memberPermissions: Array.isArray(raw.memberPermissions)
217
+ ? withMediaBackCompat(raw.memberPermissions)
218
+ : null,
212
219
  profilePrompt: raw.profilePrompt ?? raw.systemPrompt ?? null,
213
220
  };
214
221
  } catch {
@@ -3,6 +3,11 @@ import type { Db } from "../storage/db.js";
3
3
  import type { MessageAttachment } from "../types.js";
4
4
  import { SLASH_COMMANDS } from "./commands.js";
5
5
  import { hasPermission, resolveRole } from "./permissions.js";
6
+ import {
7
+ formatSystemMessage,
8
+ type MessageLocale,
9
+ resolveLocale,
10
+ } from "./system-messages.js";
6
11
  import { loadTriggerConfig, matchTrigger } from "./trigger.js";
7
12
 
8
13
  export type RouteResult =
@@ -125,6 +130,7 @@ export function routeInput(input: {
125
130
  input.callerId,
126
131
  input.isDM,
127
132
  seededAdmins,
133
+ resolveLocale(input.db, input.config, input.spaceId),
128
134
  verb,
129
135
  arg,
130
136
  );
@@ -134,14 +140,24 @@ export function routeInput(input: {
134
140
  // Check for commands after trigger (e.g. "@Pi stop", "Pi compact")
135
141
  const cmdWord = prompt.toLowerCase().trim();
136
142
  if (cmdWord in CHAT_COMMANDS) {
137
- return gateCommand(input.db, input.spaceId, cmdWord, role, input.callerId);
143
+ return gateCommand(
144
+ input.db,
145
+ input.spaceId,
146
+ cmdWord,
147
+ role,
148
+ input.callerId,
149
+ resolveLocale(input.db, input.config, input.spaceId),
150
+ );
138
151
  }
139
152
 
140
153
  // Check prompt permission
141
154
  if (!hasPermission(input.db, input.spaceId, role, "prompt")) {
142
155
  return {
143
156
  type: "denied",
144
- reason: "You don't have permission to use the agent in this group.",
157
+ reason: formatSystemMessage(
158
+ resolveLocale(input.db, input.config, input.spaceId),
159
+ "no_permission_prompt",
160
+ ),
145
161
  };
146
162
  }
147
163
 
@@ -163,6 +179,7 @@ function gateSlashCommand(
163
179
  callerId: string,
164
180
  isDM: boolean,
165
181
  seededAdmins: string[],
182
+ locale: MessageLocale,
166
183
  verb?: string,
167
184
  arg?: string,
168
185
  ): RouteResult {
@@ -170,7 +187,7 @@ function gateSlashCommand(
170
187
  if (!seededAdmins.includes(callerId)) {
171
188
  return {
172
189
  type: "denied",
173
- reason: `You don't have permission to use '/${command}'.`,
190
+ reason: formatSystemMessage(locale, "no_permission_slash", { command }),
174
191
  };
175
192
  }
176
193
  return { type: "command", command, verb, arg, callerId, role };
@@ -178,13 +195,13 @@ function gateSlashCommand(
178
195
  if (!isDM && role !== "admin" && role !== "system") {
179
196
  return {
180
197
  type: "denied",
181
- reason: "Slash commands are only available to admins in groups.",
198
+ reason: formatSystemMessage(locale, "slash_admin_only"),
182
199
  };
183
200
  }
184
201
  if (!hasPermission(db, spaceId, role, "prompt")) {
185
202
  return {
186
203
  type: "denied",
187
- reason: `You don't have permission to use '/${command}'.`,
204
+ reason: formatSystemMessage(locale, "no_permission_slash", { command }),
188
205
  };
189
206
  }
190
207
  return { type: "command", command, verb, arg, callerId, role };
@@ -196,6 +213,7 @@ function gateCommand(
196
213
  command: string,
197
214
  role: string,
198
215
  callerId: string,
216
+ locale: MessageLocale,
199
217
  ): RouteResult {
200
218
  const permission = CHAT_COMMANDS[command];
201
219
  if (!permission) return { type: "ignore" };
@@ -203,7 +221,7 @@ function gateCommand(
203
221
  if (!hasPermission(db, spaceId, role, permission)) {
204
222
  return {
205
223
  type: "denied",
206
- reason: `You don't have permission to use '${command}'.`,
224
+ reason: formatSystemMessage(locale, "no_permission_command", { command }),
207
225
  };
208
226
  }
209
227
 
@@ -147,7 +147,14 @@ export function createChatRoute(core: MercuryCoreRuntime): Hono {
147
147
  }
148
148
 
149
149
  if (authenticated) {
150
- core.db.seedAdmins(spaceId, [callerId]);
150
+ // Fires at most once per actual override: after the re-promotion the
151
+ // row is admin again, so later requests return an empty list.
152
+ for (const id of core.db.seedAdmins(spaceId, [callerId])) {
153
+ logger.warn(
154
+ "Authenticated chat caller re-promoted: stored role overridden",
155
+ { spaceId, callerId: id },
156
+ );
157
+ }
151
158
  }
152
159
 
153
160
  const ingress: IngressMessage = {
@@ -14,6 +14,7 @@ export const BUILTIN_CONFIG_KEYS = new Set([
14
14
  "rate_limit.member",
15
15
  "rate_limit.admin",
16
16
  "debounce.idle_timeout_ms",
17
+ "messages.locale",
17
18
  ]);
18
19
 
19
20
  /**
@@ -49,6 +50,8 @@ export const BUILTIN_CONFIG_DESCRIPTIONS: Record<string, string> = {
49
50
  "Daily message cap for admins in this space. Overrides global rate_limit_daily_admin. Integer ≥ 1, or 0 for unlimited.",
50
51
  "debounce.idle_timeout_ms":
51
52
  "Milliseconds to wait for additional messages before processing a batch. 0 disables debounce. Platform default: 2000 for WhatsApp/Telegram, 0 for others.",
53
+ "messages.locale":
54
+ "Language for Mercury-generated system messages (rate limits, errors, denials) in this space: 'en' or 'he'. Unset falls back to the deployment default.",
52
55
  };
53
56
 
54
57
  const BUILTIN_VALIDATORS: Record<string, (v: string) => string | null> = {
@@ -112,6 +115,10 @@ const BUILTIN_VALIDATORS: Record<string, (v: string) => string | null> = {
112
115
  ? null
113
116
  : "Invalid debounce.idle_timeout_ms value. Must be an integer between 0 and 10000";
114
117
  },
118
+ "messages.locale": (v) =>
119
+ ["en", "he"].includes(v)
120
+ ? null
121
+ : "Invalid messages.locale value. Valid: en, he",
115
122
  };
116
123
 
117
124
  export function isBuiltinConfigKey(key: string): boolean {