mercury-agent 0.8.12 → 0.9.0
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/authoring-profiles.md +8 -0
- package/docs/media/overview.md +9 -0
- package/docs/permissions.md +14 -1
- package/package.json +2 -2
- package/resources/templates/mercury.example.yaml +7 -0
- package/src/agent/user-error-messages.ts +9 -35
- package/src/config-file.ts +12 -0
- package/src/config.ts +8 -0
- package/src/core/conversation.ts +8 -1
- package/src/core/media-gate.ts +121 -0
- package/src/core/permissions.ts +28 -2
- package/src/core/profiles.ts +8 -1
- package/src/core/router.ts +24 -6
- package/src/core/routes/config-builtin.ts +7 -0
- package/src/core/runtime.ts +128 -20
- package/src/core/system-messages.ts +226 -0
- package/src/storage/db.ts +78 -0
|
@@ -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 =
|
package/docs/media/overview.md
CHANGED
|
@@ -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/)
|
package/docs/permissions.md
CHANGED
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.9.0",
|
|
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.
|
|
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 =
|
|
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\
|
|
46
|
+
message += `\n\n${formatSystemMessage(locale, "err_upgrade_suffix", { url: base })}`;
|
|
73
47
|
} else if (category === "auth") {
|
|
74
|
-
return
|
|
48
|
+
return formatSystemMessage(locale, "err_session_expired", { url: base });
|
|
75
49
|
}
|
|
76
50
|
}
|
|
77
51
|
return message;
|
package/src/config-file.ts
CHANGED
|
@@ -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
|
package/src/core/conversation.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/core/permissions.ts
CHANGED
|
@@ -29,6 +29,10 @@ const BUILT_IN_PERMISSIONS = new Set([
|
|
|
29
29
|
"spaces.delete",
|
|
30
30
|
/** Purge inbox/outbox media files. */
|
|
31
31
|
"media.purge",
|
|
32
|
+
/** Incoming attachments are saved to inbox/ and shown to the agent. */
|
|
33
|
+
"media.receive",
|
|
34
|
+
/** Outbox files produced on this caller's turn are delivered back. */
|
|
35
|
+
"media.send",
|
|
32
36
|
/** Host Text-to-Speech (/api/tts); admin-only by default. */
|
|
33
37
|
"tts.synthesize",
|
|
34
38
|
/** Mute/unmute users and list mutes; admin-only by default. */
|
|
@@ -103,6 +107,23 @@ export function setActiveProfileMemberPermissions(
|
|
|
103
107
|
activeProfileMemberPermissions = permissions;
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Back-compat for permission lists authored before `media.receive` /
|
|
112
|
+
* `media.send` existed: media exchange used to be ungated, so a list that
|
|
113
|
+
* doesn't mention either name carries no revocation intent — append both to
|
|
114
|
+
* preserve behavior. A list mentioning either one has decided explicitly and
|
|
115
|
+
* is returned verbatim. `media.purge` predates this feature and is NOT an
|
|
116
|
+
* opt-out signal.
|
|
117
|
+
*/
|
|
118
|
+
export function withMediaBackCompat(permissions: string[]): string[] {
|
|
119
|
+
const mentionsMediaTransfer = permissions.some((p) => {
|
|
120
|
+
const t = p.trim();
|
|
121
|
+
return t === "media.receive" || t === "media.send";
|
|
122
|
+
});
|
|
123
|
+
if (mentionsMediaTransfer) return permissions;
|
|
124
|
+
return [...permissions, "media.receive", "media.send"];
|
|
125
|
+
}
|
|
126
|
+
|
|
106
127
|
/** Parse a permission list into a validated set (drops unknown names). */
|
|
107
128
|
function toPermissionSet(list: string[]): Set<string> {
|
|
108
129
|
return new Set(list.map((s) => s.trim()).filter((s) => isValidPermission(s)));
|
|
@@ -137,14 +158,19 @@ export function isSystemCaller(callerId: string): boolean {
|
|
|
137
158
|
// ---------------------------------------------------------------------------
|
|
138
159
|
|
|
139
160
|
/** Built-in defaults for the member role */
|
|
140
|
-
const DEFAULT_MEMBER_PERMISSIONS = new Set([
|
|
161
|
+
const DEFAULT_MEMBER_PERMISSIONS = new Set([
|
|
162
|
+
"prompt",
|
|
163
|
+
"prefs.get",
|
|
164
|
+
"media.receive",
|
|
165
|
+
"media.send",
|
|
166
|
+
]);
|
|
141
167
|
|
|
142
168
|
/**
|
|
143
169
|
* Compute the default permission set for a role, merging built-in defaults
|
|
144
170
|
* with extension-registered defaults.
|
|
145
171
|
*
|
|
146
172
|
* - `admin` and `system` get all permissions (built-in + extension)
|
|
147
|
-
* - `member` gets `prompt`, `prefs.get`, plus any extension permissions that list "member" in defaultRoles
|
|
173
|
+
* - `member` gets `prompt`, `prefs.get`, `media.receive`, `media.send`, plus any extension permissions that list "member" in defaultRoles
|
|
148
174
|
* - Other roles get extension permissions that list them in defaultRoles
|
|
149
175
|
*/
|
|
150
176
|
function getDefaultPermissions(role: string): Set<string> {
|
package/src/core/profiles.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/core/router.ts
CHANGED
|
@@ -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(
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
224
|
+
reason: formatSystemMessage(locale, "no_permission_command", { command }),
|
|
207
225
|
};
|
|
208
226
|
}
|
|
209
227
|
|
|
@@ -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 {
|
package/src/core/runtime.ts
CHANGED
|
@@ -30,11 +30,17 @@ import {
|
|
|
30
30
|
DirectSendError,
|
|
31
31
|
resolveRecipientSpaceId,
|
|
32
32
|
} from "./direct-send.js";
|
|
33
|
+
import { gateIncomingMedia, gateOutgoingMedia } from "./media-gate.js";
|
|
33
34
|
import { hasPermission, resolveRole } from "./permissions.js";
|
|
34
35
|
import { getActiveProfilePrompt } from "./profiles.js";
|
|
35
36
|
import { RateLimiter } from "./rate-limiter.js";
|
|
36
37
|
import { type RouteResult, routeInput } from "./router.js";
|
|
37
38
|
import { SpaceQueue } from "./space-queue.js";
|
|
39
|
+
import {
|
|
40
|
+
confirmWords,
|
|
41
|
+
formatSystemMessage,
|
|
42
|
+
resolveLocale,
|
|
43
|
+
} from "./system-messages.js";
|
|
38
44
|
import { TaskScheduler } from "./task-scheduler.js";
|
|
39
45
|
|
|
40
46
|
export type InputSource = "cli" | "scheduler" | "chat-sdk";
|
|
@@ -204,6 +210,8 @@ export class MercuryCoreRuntime {
|
|
|
204
210
|
const sensitiveName = this.getActiveSensitiveConnectionName();
|
|
205
211
|
if (!sensitiveName) return { action: "proceed" };
|
|
206
212
|
|
|
213
|
+
const locale = resolveLocale(this.db, this.config, spaceId);
|
|
214
|
+
|
|
207
215
|
const allowed = this.db.getSpaceConfig(
|
|
208
216
|
spaceId,
|
|
209
217
|
"security.sensitive_connections_allowed",
|
|
@@ -211,7 +219,9 @@ export class MercuryCoreRuntime {
|
|
|
211
219
|
if (allowed !== "true") {
|
|
212
220
|
return {
|
|
213
221
|
action: "block",
|
|
214
|
-
reason:
|
|
222
|
+
reason: formatSystemMessage(locale, "sensitive_disabled", {
|
|
223
|
+
name: sensitiveName,
|
|
224
|
+
}),
|
|
215
225
|
};
|
|
216
226
|
}
|
|
217
227
|
|
|
@@ -223,16 +233,20 @@ export class MercuryCoreRuntime {
|
|
|
223
233
|
const ageMs = Date.now() - new Date(pendingAt).getTime();
|
|
224
234
|
const expired = Number.isNaN(ageMs) || ageMs > 5 * 60 * 1000;
|
|
225
235
|
const text = prompt.trim().toLowerCase();
|
|
236
|
+
const words = confirmWords(locale);
|
|
226
237
|
|
|
227
|
-
if (expired || text
|
|
238
|
+
if (expired || words.no.includes(text)) {
|
|
228
239
|
this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_prompt");
|
|
229
240
|
this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_at");
|
|
230
241
|
if (expired) {
|
|
231
242
|
// Treat next message as fresh — fall through to new warning below
|
|
232
243
|
} else {
|
|
233
|
-
return {
|
|
244
|
+
return {
|
|
245
|
+
action: "block",
|
|
246
|
+
reason: formatSystemMessage(locale, "sensitive_cancelled"),
|
|
247
|
+
};
|
|
234
248
|
}
|
|
235
|
-
} else if (text
|
|
249
|
+
} else if (words.yes.includes(text)) {
|
|
236
250
|
const storedPrompt = this.db.getSpaceConfig(
|
|
237
251
|
spaceId,
|
|
238
252
|
"security.pending_sensitive_prompt",
|
|
@@ -261,7 +275,9 @@ export class MercuryCoreRuntime {
|
|
|
261
275
|
);
|
|
262
276
|
return {
|
|
263
277
|
action: "block",
|
|
264
|
-
reason:
|
|
278
|
+
reason: formatSystemMessage(locale, "sensitive_confirm", {
|
|
279
|
+
name: sensitiveName,
|
|
280
|
+
}),
|
|
265
281
|
};
|
|
266
282
|
}
|
|
267
283
|
|
|
@@ -541,7 +557,11 @@ export class MercuryCoreRuntime {
|
|
|
541
557
|
const hoursLeft = Math.ceil(msUntilReset / 3_600_000);
|
|
542
558
|
return {
|
|
543
559
|
type: "denied",
|
|
544
|
-
reason:
|
|
560
|
+
reason: formatSystemMessage(
|
|
561
|
+
resolveLocale(this.db, this.config, message.spaceId),
|
|
562
|
+
"rate_limit_daily",
|
|
563
|
+
{ count: daily.count, limit: roleLimit, hours: hoursLeft },
|
|
564
|
+
),
|
|
545
565
|
};
|
|
546
566
|
}
|
|
547
567
|
}
|
|
@@ -560,7 +580,10 @@ export class MercuryCoreRuntime {
|
|
|
560
580
|
) {
|
|
561
581
|
return {
|
|
562
582
|
type: "denied",
|
|
563
|
-
reason:
|
|
583
|
+
reason: formatSystemMessage(
|
|
584
|
+
resolveLocale(this.db, this.config, message.spaceId),
|
|
585
|
+
"rate_limit_burst",
|
|
586
|
+
),
|
|
564
587
|
};
|
|
565
588
|
}
|
|
566
589
|
}
|
|
@@ -598,8 +621,10 @@ export class MercuryCoreRuntime {
|
|
|
598
621
|
) {
|
|
599
622
|
return {
|
|
600
623
|
type: "denied",
|
|
601
|
-
reason:
|
|
602
|
-
|
|
624
|
+
reason: formatSystemMessage(
|
|
625
|
+
resolveLocale(this.db, this.config, message.spaceId),
|
|
626
|
+
"attachment_failed",
|
|
627
|
+
),
|
|
603
628
|
};
|
|
604
629
|
}
|
|
605
630
|
|
|
@@ -626,20 +651,31 @@ export class MercuryCoreRuntime {
|
|
|
626
651
|
replyToPlatformMessageId: message.replyToPlatformMessageId,
|
|
627
652
|
platformMessageId: message.platformMessageId,
|
|
628
653
|
},
|
|
629
|
-
{
|
|
654
|
+
{
|
|
655
|
+
isReplyToBot: route.isReplyToBot,
|
|
656
|
+
isDM: route.isDM,
|
|
657
|
+
hadIncomingAttachments: message.hadIncomingAttachments ?? false,
|
|
658
|
+
},
|
|
630
659
|
);
|
|
631
660
|
return { ...route, result };
|
|
632
661
|
} catch (error) {
|
|
633
662
|
if (error instanceof ContainerError) {
|
|
663
|
+
const locale = resolveLocale(this.db, this.config, message.spaceId);
|
|
634
664
|
switch (error.reason) {
|
|
635
665
|
case "aborted":
|
|
636
|
-
return {
|
|
666
|
+
return {
|
|
667
|
+
type: "denied",
|
|
668
|
+
reason: formatSystemMessage(locale, "run_stopped"),
|
|
669
|
+
};
|
|
637
670
|
case "timeout":
|
|
638
|
-
return {
|
|
671
|
+
return {
|
|
672
|
+
type: "denied",
|
|
673
|
+
reason: formatSystemMessage(locale, "container_timeout"),
|
|
674
|
+
};
|
|
639
675
|
case "oom":
|
|
640
676
|
return {
|
|
641
677
|
type: "denied",
|
|
642
|
-
reason:
|
|
678
|
+
reason: formatSystemMessage(locale, "container_oom"),
|
|
643
679
|
};
|
|
644
680
|
case "no-credentials": {
|
|
645
681
|
// Host refused to start the container (no model credential).
|
|
@@ -651,6 +687,7 @@ export class MercuryCoreRuntime {
|
|
|
651
687
|
"auth",
|
|
652
688
|
this.config.apiKeyMode,
|
|
653
689
|
this.config.consoleUrl,
|
|
690
|
+
locale,
|
|
654
691
|
);
|
|
655
692
|
return { type: "denied", reason };
|
|
656
693
|
}
|
|
@@ -664,6 +701,7 @@ export class MercuryCoreRuntime {
|
|
|
664
701
|
category,
|
|
665
702
|
this.config.apiKeyMode,
|
|
666
703
|
this.config.consoleUrl,
|
|
704
|
+
locale,
|
|
667
705
|
);
|
|
668
706
|
return { type: "denied", reason };
|
|
669
707
|
}
|
|
@@ -1211,7 +1249,7 @@ export class MercuryCoreRuntime {
|
|
|
1211
1249
|
private async executePrompt(
|
|
1212
1250
|
spaceId: string,
|
|
1213
1251
|
prompt: string,
|
|
1214
|
-
|
|
1252
|
+
source: InputSource,
|
|
1215
1253
|
callerId: string,
|
|
1216
1254
|
attachments?: MessageAttachment[],
|
|
1217
1255
|
authorName?: string,
|
|
@@ -1221,7 +1259,11 @@ export class MercuryCoreRuntime {
|
|
|
1221
1259
|
replyToPlatformMessageId?: string;
|
|
1222
1260
|
platformMessageId?: string;
|
|
1223
1261
|
},
|
|
1224
|
-
replyFlags?: {
|
|
1262
|
+
replyFlags?: {
|
|
1263
|
+
isReplyToBot: boolean;
|
|
1264
|
+
isDM: boolean;
|
|
1265
|
+
hadIncomingAttachments?: boolean;
|
|
1266
|
+
},
|
|
1225
1267
|
): Promise<ContainerResult> {
|
|
1226
1268
|
this.db.ensureSpace(spaceId);
|
|
1227
1269
|
|
|
@@ -1259,8 +1301,10 @@ export class MercuryCoreRuntime {
|
|
|
1259
1301
|
};
|
|
1260
1302
|
if (!quotaData.allowed) {
|
|
1261
1303
|
return {
|
|
1262
|
-
reply:
|
|
1263
|
-
|
|
1304
|
+
reply: formatSystemMessage(
|
|
1305
|
+
resolveLocale(this.db, this.config, spaceId),
|
|
1306
|
+
"platform_quota_exceeded",
|
|
1307
|
+
),
|
|
1264
1308
|
files: [],
|
|
1265
1309
|
};
|
|
1266
1310
|
}
|
|
@@ -1342,6 +1386,50 @@ export class MercuryCoreRuntime {
|
|
|
1342
1386
|
}
|
|
1343
1387
|
// ────────────────────────────────────────────────────────────────────
|
|
1344
1388
|
|
|
1389
|
+
// ── Media permission gates (media.receive / media.send) ─────────────
|
|
1390
|
+
// Role resolved once here for both gates. Scheduled tasks are exempt by
|
|
1391
|
+
// source (task delivery must keep working regardless of member
|
|
1392
|
+
// settings); admin and system callers are exempt by rule.
|
|
1393
|
+
const mediaRole =
|
|
1394
|
+
source === "scheduler"
|
|
1395
|
+
? "system"
|
|
1396
|
+
: resolveRole(
|
|
1397
|
+
this.db,
|
|
1398
|
+
spaceId,
|
|
1399
|
+
callerId,
|
|
1400
|
+
this.config.admins
|
|
1401
|
+
? this.config.admins
|
|
1402
|
+
.split(",")
|
|
1403
|
+
.map((s) => s.trim())
|
|
1404
|
+
.filter(Boolean)
|
|
1405
|
+
: [],
|
|
1406
|
+
);
|
|
1407
|
+
const mediaExempt = mediaRole === "admin" || mediaRole === "system";
|
|
1408
|
+
|
|
1409
|
+
// Receive gate — enforced before hooks, the user-message save, and the
|
|
1410
|
+
// container start, so denied files are deleted from disk before any
|
|
1411
|
+
// agent code can reach them (the container mounts the workspace).
|
|
1412
|
+
let effectiveAttachments = attachments;
|
|
1413
|
+
if (
|
|
1414
|
+
!mediaExempt &&
|
|
1415
|
+
!hasPermission(this.db, spaceId, mediaRole, "media.receive")
|
|
1416
|
+
) {
|
|
1417
|
+
const gated = gateIncomingMedia({
|
|
1418
|
+
workspacePath: workspace,
|
|
1419
|
+
spaceId,
|
|
1420
|
+
callerRole: mediaRole,
|
|
1421
|
+
attachments,
|
|
1422
|
+
hadIncomingAttachments: replyFlags?.hadIncomingAttachments ?? false,
|
|
1423
|
+
});
|
|
1424
|
+
effectiveAttachments = gated.attachments;
|
|
1425
|
+
if (gated.promptNote) {
|
|
1426
|
+
finalPrompt = [finalPrompt, gated.promptNote]
|
|
1427
|
+
.filter(Boolean)
|
|
1428
|
+
.join("\n\n");
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1432
|
+
|
|
1345
1433
|
// Emit workspace_init hook (extensions should be idempotent)
|
|
1346
1434
|
if (this.hooks && this.extensionCtx) {
|
|
1347
1435
|
await this.hooks.emit(
|
|
@@ -1361,7 +1449,7 @@ export class MercuryCoreRuntime {
|
|
|
1361
1449
|
callerId,
|
|
1362
1450
|
workspace,
|
|
1363
1451
|
containerWorkspace,
|
|
1364
|
-
attachments,
|
|
1452
|
+
attachments: effectiveAttachments,
|
|
1365
1453
|
},
|
|
1366
1454
|
this.extensionCtx,
|
|
1367
1455
|
);
|
|
@@ -1486,7 +1574,7 @@ export class MercuryCoreRuntime {
|
|
|
1486
1574
|
spaceId,
|
|
1487
1575
|
"user",
|
|
1488
1576
|
finalPrompt,
|
|
1489
|
-
|
|
1577
|
+
effectiveAttachments,
|
|
1490
1578
|
userReplyToId,
|
|
1491
1579
|
);
|
|
1492
1580
|
|
|
@@ -1602,7 +1690,7 @@ export class MercuryCoreRuntime {
|
|
|
1602
1690
|
callerId,
|
|
1603
1691
|
callerRole,
|
|
1604
1692
|
authorName,
|
|
1605
|
-
attachments,
|
|
1693
|
+
attachments: effectiveAttachments,
|
|
1606
1694
|
preferences,
|
|
1607
1695
|
extraEnv,
|
|
1608
1696
|
claimedEnvSources: this.extensionRegistry?.getClaimedEnvSources(),
|
|
@@ -1645,6 +1733,26 @@ export class MercuryCoreRuntime {
|
|
|
1645
1733
|
}
|
|
1646
1734
|
}
|
|
1647
1735
|
|
|
1736
|
+
// ── Media send gate ──────────────────────────────────────────────────
|
|
1737
|
+
// Applied before the assistant message is stored so the withhold notice
|
|
1738
|
+
// is part of the recorded reply. Withheld files stay in outbox/ for
|
|
1739
|
+
// admin retrieval; TTL cleanup removes them later.
|
|
1740
|
+
if (
|
|
1741
|
+
!mediaExempt &&
|
|
1742
|
+
containerResult.files.length > 0 &&
|
|
1743
|
+
!hasPermission(this.db, spaceId, mediaRole, "media.send")
|
|
1744
|
+
) {
|
|
1745
|
+
const gated = gateOutgoingMedia({
|
|
1746
|
+
spaceId,
|
|
1747
|
+
callerRole: mediaRole,
|
|
1748
|
+
files: containerResult.files,
|
|
1749
|
+
reply: containerResult.reply,
|
|
1750
|
+
});
|
|
1751
|
+
containerResult.files = gated.files;
|
|
1752
|
+
containerResult.reply = gated.reply;
|
|
1753
|
+
}
|
|
1754
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1755
|
+
|
|
1648
1756
|
const assistantMessageId = this.db.addMessage(
|
|
1649
1757
|
spaceId,
|
|
1650
1758
|
"assistant",
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale-keyed string table for host-generated system messages.
|
|
3
|
+
*
|
|
4
|
+
* These messages (rate-limit denials, container failures, permission denials,
|
|
5
|
+
* the sensitive-connection guard, provider-error messages) are produced
|
|
6
|
+
* deterministically on the host before any LLM runs, so system-prompt language
|
|
7
|
+
* instructions never touch them. This table lets a deployment or a single
|
|
8
|
+
* space switch them to another locale via the `messages.locale` config key.
|
|
9
|
+
*
|
|
10
|
+
* Resolution chain: per-space → `@global` → `config.messagesLocale` → `"en"`.
|
|
11
|
+
* The `en` strings are byte-identical to the original literals — tests and
|
|
12
|
+
* downstream matching depend on them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AppConfig } from "../config.js";
|
|
16
|
+
import { GLOBAL_CONFIG_SPACE_ID } from "../extensions/config-registry.js";
|
|
17
|
+
import type { Db } from "../storage/db.js";
|
|
18
|
+
|
|
19
|
+
export type MessageLocale = "en" | "he";
|
|
20
|
+
|
|
21
|
+
export const MESSAGE_LOCALES: readonly MessageLocale[] = ["en", "he"];
|
|
22
|
+
|
|
23
|
+
export type SystemMessageKey =
|
|
24
|
+
| "rate_limit_burst"
|
|
25
|
+
| "rate_limit_daily"
|
|
26
|
+
| "attachment_failed"
|
|
27
|
+
| "run_stopped"
|
|
28
|
+
| "container_timeout"
|
|
29
|
+
| "container_oom"
|
|
30
|
+
| "no_permission_prompt"
|
|
31
|
+
| "no_permission_slash"
|
|
32
|
+
| "slash_admin_only"
|
|
33
|
+
| "no_permission_command"
|
|
34
|
+
| "sensitive_disabled"
|
|
35
|
+
| "sensitive_confirm"
|
|
36
|
+
| "sensitive_cancelled"
|
|
37
|
+
| "platform_quota_exceeded"
|
|
38
|
+
// Provider-error messages (user-error-messages.ts), keyed category × mode
|
|
39
|
+
| "err_key-limit_platform"
|
|
40
|
+
| "err_key-limit_byok"
|
|
41
|
+
| "err_rate-limit_platform"
|
|
42
|
+
| "err_rate-limit_byok"
|
|
43
|
+
| "err_auth_platform"
|
|
44
|
+
| "err_auth_byok"
|
|
45
|
+
| "err_credits_platform"
|
|
46
|
+
| "err_credits_byok"
|
|
47
|
+
| "err_server-error_platform"
|
|
48
|
+
| "err_server-error_byok"
|
|
49
|
+
| "err_generic_platform"
|
|
50
|
+
| "err_generic_byok"
|
|
51
|
+
| "err_upgrade_suffix"
|
|
52
|
+
| "err_session_expired";
|
|
53
|
+
|
|
54
|
+
export const SYSTEM_MESSAGES: Record<
|
|
55
|
+
MessageLocale,
|
|
56
|
+
Record<SystemMessageKey, string>
|
|
57
|
+
> = {
|
|
58
|
+
en: {
|
|
59
|
+
rate_limit_burst: "Rate limit exceeded. Try again shortly.",
|
|
60
|
+
rate_limit_daily:
|
|
61
|
+
"You've used {count}/{limit} messages today. Resets in {hours}h.",
|
|
62
|
+
attachment_failed:
|
|
63
|
+
"Could not use your attachment (media disabled, over the size limit, or download failed). Check MERCURY_MEDIA_ENABLED and logs.",
|
|
64
|
+
run_stopped: "Stopped current run.",
|
|
65
|
+
container_timeout: "Container timed out.",
|
|
66
|
+
container_oom: "Container was killed (possibly out of memory).",
|
|
67
|
+
no_permission_prompt:
|
|
68
|
+
"You don't have permission to use the agent in this group.",
|
|
69
|
+
no_permission_slash: "You don't have permission to use '/{command}'.",
|
|
70
|
+
slash_admin_only: "Slash commands are only available to admins in groups.",
|
|
71
|
+
no_permission_command: "You don't have permission to use '{command}'.",
|
|
72
|
+
sensitive_disabled:
|
|
73
|
+
"⛔ Sensitive integrations ({name}) are disabled for this group space. A space admin must enable them first with: mrctl config set security.sensitive_connections_allowed true",
|
|
74
|
+
sensitive_confirm:
|
|
75
|
+
"⚠️ This response may contain data from {name} and will be visible to all members of this group. Reply *yes* to proceed or *no* to cancel.",
|
|
76
|
+
sensitive_cancelled: "Cancelled.",
|
|
77
|
+
platform_quota_exceeded:
|
|
78
|
+
"You've reached your daily message limit. Upgrade your plan at the Mercury Console to continue chatting.",
|
|
79
|
+
"err_key-limit_platform":
|
|
80
|
+
"I've reached my usage limit for now. Please try again later.",
|
|
81
|
+
"err_key-limit_byok":
|
|
82
|
+
"Your API key has hit its spending limit. Check your provider's key settings to increase it.",
|
|
83
|
+
"err_rate-limit_platform":
|
|
84
|
+
"I'm handling too many requests right now — please try again in a moment.",
|
|
85
|
+
"err_rate-limit_byok":
|
|
86
|
+
"Your API key is being rate-limited. Try again in a moment.",
|
|
87
|
+
err_auth_platform:
|
|
88
|
+
"Something went wrong on my end. This has been logged and the admin will be notified.",
|
|
89
|
+
err_auth_byok:
|
|
90
|
+
"Your API key appears to be invalid or expired. Please update it.",
|
|
91
|
+
err_credits_platform:
|
|
92
|
+
"I've reached my usage limit for now. Please try again later.",
|
|
93
|
+
err_credits_byok:
|
|
94
|
+
"Your API provider account has insufficient credits. Add credits to continue.",
|
|
95
|
+
"err_server-error_platform":
|
|
96
|
+
"The AI service is temporarily unavailable. Please try again in a few minutes.",
|
|
97
|
+
"err_server-error_byok":
|
|
98
|
+
"The AI service is temporarily unavailable. Please try again in a few minutes.",
|
|
99
|
+
err_generic_platform:
|
|
100
|
+
"Something went wrong processing your request. Please try again.",
|
|
101
|
+
err_generic_byok:
|
|
102
|
+
"Something went wrong processing your request. Please try again, or check your API key and provider status.",
|
|
103
|
+
err_upgrade_suffix: "Upgrade your plan: {url}/dashboard/billing",
|
|
104
|
+
err_session_expired:
|
|
105
|
+
"Your Anthropic session has expired. Please reconnect: {url}/dashboard/model",
|
|
106
|
+
},
|
|
107
|
+
he: {
|
|
108
|
+
rate_limit_burst: "חריגה ממגבלת הקצב. נסו שוב בעוד רגע.",
|
|
109
|
+
rate_limit_daily:
|
|
110
|
+
"נוצלו {count}/{limit} הודעות היום. המכסה מתאפסת בעוד {hours} שעות.",
|
|
111
|
+
attachment_failed:
|
|
112
|
+
"לא ניתן היה להשתמש בקובץ המצורף (מדיה מושבתת, חריגה ממגבלת הגודל, או שההורדה נכשלה). בדקו את MERCURY_MEDIA_ENABLED ואת הלוגים.",
|
|
113
|
+
run_stopped: "הריצה הנוכחית הופסקה.",
|
|
114
|
+
container_timeout: "זמן הריצה של הקונטיינר פג.",
|
|
115
|
+
container_oom: "הקונטיינר הופסק (ייתכן שבשל מחסור בזיכרון).",
|
|
116
|
+
no_permission_prompt: "אין לכם הרשאה להשתמש בסוכן בקבוצה זו.",
|
|
117
|
+
no_permission_slash: "אין לכם הרשאה להשתמש ב-'/{command}'.",
|
|
118
|
+
slash_admin_only: "פקודות סלאש זמינות רק למנהלים בקבוצות.",
|
|
119
|
+
no_permission_command: "אין לכם הרשאה להשתמש ב-'{command}'.",
|
|
120
|
+
sensitive_disabled:
|
|
121
|
+
"⛔ אינטגרציות רגישות ({name}) מושבתות במרחב הקבוצתי הזה. מנהל המרחב צריך להפעיל אותן תחילה באמצעות: mrctl config set security.sensitive_connections_allowed true",
|
|
122
|
+
sensitive_confirm:
|
|
123
|
+
"⚠️ התשובה עשויה להכיל מידע מ-{name} ותהיה גלויה לכל חברי הקבוצה. השיבו *כן* כדי להמשיך או *לא* כדי לבטל.",
|
|
124
|
+
sensitive_cancelled: "בוטל.",
|
|
125
|
+
platform_quota_exceeded:
|
|
126
|
+
"הגעתם למגבלת ההודעות היומית. שדרגו את התוכנית ב-Mercury Console כדי להמשיך בשיחה.",
|
|
127
|
+
"err_key-limit_platform":
|
|
128
|
+
"הגעתי למגבלת השימוש שלי לעת עתה. נסו שוב מאוחר יותר.",
|
|
129
|
+
"err_key-limit_byok":
|
|
130
|
+
"מפתח ה-API שלכם הגיע למגבלת ההוצאה שלו. בדקו את הגדרות המפתח אצל הספק כדי להגדיל אותה.",
|
|
131
|
+
"err_rate-limit_platform":
|
|
132
|
+
"אני מטפל בבקשות רבות מדי כרגע — נסו שוב בעוד רגע.",
|
|
133
|
+
"err_rate-limit_byok": "מפתח ה-API שלכם מוגבל בקצב. נסו שוב בעוד רגע.",
|
|
134
|
+
err_auth_platform: "משהו השתבש אצלי. האירוע נרשם והמנהל יקבל התראה.",
|
|
135
|
+
err_auth_byok: "נראה שמפתח ה-API שלכם אינו תקין או שפג תוקפו. עדכנו אותו.",
|
|
136
|
+
err_credits_platform:
|
|
137
|
+
"הגעתי למגבלת השימוש שלי לעת עתה. נסו שוב מאוחר יותר.",
|
|
138
|
+
err_credits_byok:
|
|
139
|
+
"בחשבון ספק ה-API שלכם אין מספיק קרדיטים. הוסיפו קרדיטים כדי להמשיך.",
|
|
140
|
+
"err_server-error_platform":
|
|
141
|
+
"שירות ה-AI אינו זמין זמנית. נסו שוב בעוד כמה דקות.",
|
|
142
|
+
"err_server-error_byok":
|
|
143
|
+
"שירות ה-AI אינו זמין זמנית. נסו שוב בעוד כמה דקות.",
|
|
144
|
+
err_generic_platform: "משהו השתבש בעיבוד הבקשה. נסו שוב.",
|
|
145
|
+
err_generic_byok:
|
|
146
|
+
"משהו השתבש בעיבוד הבקשה. נסו שוב, או בדקו את מפתח ה-API ואת סטטוס הספק.",
|
|
147
|
+
err_upgrade_suffix: "שדרגו את התוכנית: {url}/dashboard/billing",
|
|
148
|
+
err_session_expired:
|
|
149
|
+
"החיבור ל-Anthropic פג תוקף. התחברו מחדש: {url}/dashboard/model",
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Words accepted as confirmation replies by the sensitive-connection guard,
|
|
155
|
+
* per locale. English `yes`/`no` are ALWAYS accepted, in every locale —
|
|
156
|
+
* switching a space's locale must never break an in-flight or muscle-memory
|
|
157
|
+
* English confirmation. Matching is done against a lowercased, trimmed reply.
|
|
158
|
+
* Record-typed so adding a locale without confirm words is a compile error.
|
|
159
|
+
*/
|
|
160
|
+
const CONFIRM_WORDS: Record<MessageLocale, { yes: string[]; no: string[] }> = {
|
|
161
|
+
en: { yes: ["yes"], no: ["no"] },
|
|
162
|
+
he: { yes: ["yes", "כן"], no: ["no", "לא"] },
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export function confirmWords(locale: MessageLocale): {
|
|
166
|
+
yes: string[];
|
|
167
|
+
no: string[];
|
|
168
|
+
} {
|
|
169
|
+
return CONFIRM_WORDS[isMessageLocale(locale) ? locale : "en"];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isMessageLocale(v: unknown): v is MessageLocale {
|
|
173
|
+
return v === "en" || v === "he";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Format a system message for a locale, substituting `{name}` placeholders.
|
|
178
|
+
* Unknown locale falls back to `en`; unknown key falls back to the `en` table,
|
|
179
|
+
* then to the key itself (never throws).
|
|
180
|
+
*/
|
|
181
|
+
export function formatSystemMessage(
|
|
182
|
+
locale: MessageLocale,
|
|
183
|
+
key: SystemMessageKey,
|
|
184
|
+
params?: Record<string, string | number>,
|
|
185
|
+
): string {
|
|
186
|
+
const table = SYSTEM_MESSAGES[isMessageLocale(locale) ? locale : "en"];
|
|
187
|
+
const template =
|
|
188
|
+
(table as Record<string, string>)[key] ??
|
|
189
|
+
(SYSTEM_MESSAGES.en as Record<string, string>)[key] ??
|
|
190
|
+
key;
|
|
191
|
+
if (!params) return template;
|
|
192
|
+
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
|
193
|
+
name in params ? String(params[name]) : match,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Resolve the effective message locale for a space:
|
|
199
|
+
* per-space → `@global` → `config.messagesLocale` → `"en"`.
|
|
200
|
+
*
|
|
201
|
+
* Never throws and never returns an unknown locale — any invalid stored value
|
|
202
|
+
* (e.g. DB edited out-of-band) is treated as unset and degrades to the next
|
|
203
|
+
* link in the chain. Pass `spaceId: null` when no space context exists (very
|
|
204
|
+
* early ingress failures); the per-space link is skipped.
|
|
205
|
+
*/
|
|
206
|
+
export function resolveLocale(
|
|
207
|
+
db: Db,
|
|
208
|
+
config: Pick<AppConfig, "messagesLocale">,
|
|
209
|
+
spaceId: string | null,
|
|
210
|
+
): MessageLocale {
|
|
211
|
+
try {
|
|
212
|
+
if (spaceId) {
|
|
213
|
+
const spaceValue = db.getSpaceConfig(spaceId, "messages.locale");
|
|
214
|
+
if (isMessageLocale(spaceValue)) return spaceValue;
|
|
215
|
+
}
|
|
216
|
+
const globalValue = db.getSpaceConfig(
|
|
217
|
+
GLOBAL_CONFIG_SPACE_ID,
|
|
218
|
+
"messages.locale",
|
|
219
|
+
);
|
|
220
|
+
if (isMessageLocale(globalValue)) return globalValue;
|
|
221
|
+
} catch {
|
|
222
|
+
// DB unavailable — fall through to config/default
|
|
223
|
+
}
|
|
224
|
+
if (isMessageLocale(config.messagesLocale)) return config.messagesLocale;
|
|
225
|
+
return "en";
|
|
226
|
+
}
|
package/src/storage/db.ts
CHANGED
|
@@ -14,6 +14,43 @@ import type {
|
|
|
14
14
|
TokenUsage,
|
|
15
15
|
} from "../types.js";
|
|
16
16
|
|
|
17
|
+
// `bun:sqlite` caches prepared statements per Database, capped at
|
|
18
|
+
// Database.MAX_QUERY_CACHE_SIZE (default 20). When a 21st distinct statement is
|
|
19
|
+
// prepared the least-recently-used one is evicted WITHOUT being finalized, so
|
|
20
|
+
// sqlite3_close() returns SQLITE_BUSY. Bun's close() swallows that, leaving the
|
|
21
|
+
// OS handles for state.db / -wal / -shm open. On Windows the next rmdir fails
|
|
22
|
+
// with EBUSY; on POSIX the unlink succeeds anyway, which is why this is
|
|
23
|
+
// invisible on Linux CI.
|
|
24
|
+
//
|
|
25
|
+
// This file issues ~114 distinct statements, so eviction is constant. Raising
|
|
26
|
+
// the cap above that count means nothing is ever evicted and every statement is
|
|
27
|
+
// finalized by close(). The set is bounded — statements are static SQL except
|
|
28
|
+
// listConversations' filter combinations, which are themselves bounded — so
|
|
29
|
+
// this does not grow without limit. Roughly a few KB per statement.
|
|
30
|
+
//
|
|
31
|
+
// Bun reads the cap on each cache insert rather than snapshotting it when a
|
|
32
|
+
// Database is constructed (verified), so import order does not matter. It is
|
|
33
|
+
// nonetheless a process-global: this changes the cap for every bun:sqlite
|
|
34
|
+
// consumer in the process, not just Db. Today db.ts is the only importer.
|
|
35
|
+
//
|
|
36
|
+
// If db.ts ever grows past this many distinct statements the failure returns
|
|
37
|
+
// silently, so keep generous headroom — tests/db.test.ts gates that.
|
|
38
|
+
// Found because deleteSpace's ~12 statements in one call pushed a fresh
|
|
39
|
+
// per-test Db past the cap, and the temp dir then refused to delete.
|
|
40
|
+
//
|
|
41
|
+
// The cast is required because the static exists at runtime but is missing from
|
|
42
|
+
// bun-types. Read the property BEFORE assigning: writing through the cast would
|
|
43
|
+
// happily create the property on an object that never had it, so a post-hoc
|
|
44
|
+
// read cannot distinguish "Bun has this knob and we set it" from "Bun dropped
|
|
45
|
+
// the knob and we invented a dead property." Only this pre-read can, and a test
|
|
46
|
+
// asserts it — otherwise a future Bun rename silently restores the leak, and
|
|
47
|
+
// the EBUSY symptom is Windows-only so Linux CI would stay green.
|
|
48
|
+
const sqliteStatic = Database as unknown as { MAX_QUERY_CACHE_SIZE: number };
|
|
49
|
+
export const SQLITE_QUERY_CACHE_STATIC_EXISTS =
|
|
50
|
+
typeof sqliteStatic.MAX_QUERY_CACHE_SIZE === "number";
|
|
51
|
+
export const SQLITE_QUERY_CACHE_SIZE = 500;
|
|
52
|
+
sqliteStatic.MAX_QUERY_CACHE_SIZE = SQLITE_QUERY_CACHE_SIZE;
|
|
53
|
+
|
|
17
54
|
type SpaceRow = {
|
|
18
55
|
id: string;
|
|
19
56
|
name: string;
|
|
@@ -232,6 +269,47 @@ export class Db {
|
|
|
232
269
|
this.ensureSpaceRolesDisplayNameColumn();
|
|
233
270
|
this.ensureTasksTimezoneColumn();
|
|
234
271
|
this.ensureTasksNameColumn();
|
|
272
|
+
this.migrateMediaPermissionRows();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* One-time migration for the introduction of `media.receive` / `media.send`.
|
|
277
|
+
*
|
|
278
|
+
* Media exchange was ungated before these permissions existed, so a stored
|
|
279
|
+
* `role.<name>.permissions` row written pre-upgrade carries no revocation
|
|
280
|
+
* intent for them — append both to preserve behavior. Rows mentioning either
|
|
281
|
+
* name are left verbatim (`media.purge` predates the pair and is not an
|
|
282
|
+
* opt-out signal). `updated_by` is intentionally preserved so dm-auto-space
|
|
283
|
+
* seeded rows keep yielding to an active profile. Version-gated via a
|
|
284
|
+
* project_config guard key: after this runs once, stored lists are literal
|
|
285
|
+
* and revocation via `permissions set` sticks.
|
|
286
|
+
*/
|
|
287
|
+
private migrateMediaPermissionRows(): void {
|
|
288
|
+
const guardKey = "migration.media_role_permissions";
|
|
289
|
+
if (this.getProjectConfig(guardKey) !== null) return;
|
|
290
|
+
|
|
291
|
+
const rows = this.db
|
|
292
|
+
.query(
|
|
293
|
+
"SELECT space_id, key, value FROM space_config WHERE key LIKE 'role.%.permissions'",
|
|
294
|
+
)
|
|
295
|
+
.all() as { space_id: string; key: string; value: string }[];
|
|
296
|
+
|
|
297
|
+
const update = this.db.query(
|
|
298
|
+
"UPDATE space_config SET value = ?, updated_at = ? WHERE space_id = ? AND key = ?",
|
|
299
|
+
);
|
|
300
|
+
for (const row of rows) {
|
|
301
|
+
const entries = row.value
|
|
302
|
+
.split(",")
|
|
303
|
+
.map((s) => s.trim())
|
|
304
|
+
.filter(Boolean);
|
|
305
|
+
if (entries.some((e) => e === "media.receive" || e === "media.send")) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const next = [...entries, "media.receive", "media.send"].join(",");
|
|
309
|
+
update.run(next, Date.now(), row.space_id, row.key);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.setProjectConfig(guardKey, "1", "migration");
|
|
235
313
|
}
|
|
236
314
|
|
|
237
315
|
private ensureMessagesRunMetaColumn(): void {
|