mercury-agent 0.8.11 → 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/container-entry.ts +48 -70
- package/src/agent/model-capabilities-core.ts +16 -0
- package/src/agent/model-capabilities.ts +30 -7
- package/src/agent/user-error-messages.ts +9 -35
- package/src/config-file.ts +12 -0
- package/src/config.ts +10 -1
- 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
|
|
@@ -12,8 +12,10 @@ import {
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import type { MessageAttachment, StoredMessage } from "../types.js";
|
|
14
14
|
import {
|
|
15
|
+
type CapabilitySource,
|
|
15
16
|
DEFAULT_CAPABILITIES,
|
|
16
17
|
type ModelCapabilities,
|
|
18
|
+
type WireModelCapabilities,
|
|
17
19
|
} from "./model-capabilities-core.js";
|
|
18
20
|
import { classifyPiFailure } from "./pi-failure-class.js";
|
|
19
21
|
import {
|
|
@@ -59,48 +61,60 @@ function backoffMs(attemptIndex: number): number {
|
|
|
59
61
|
return Math.min(cap, base + jitter);
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
const CAPABILITY_SOURCES: readonly CapabilitySource[] = [
|
|
65
|
+
"env",
|
|
66
|
+
"yaml",
|
|
67
|
+
"builtin",
|
|
68
|
+
"default",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** Guessed capabilities, explicitly marked as such. */
|
|
72
|
+
function unverifiedCapabilities(): WireModelCapabilities {
|
|
73
|
+
return { ...DEFAULT_CAPABILITIES, source: "default" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parsePartialCapabilities(obj: unknown): WireModelCapabilities {
|
|
77
|
+
if (!obj || typeof obj !== "object") return unverifiedCapabilities();
|
|
64
78
|
const o = obj as Record<string, unknown>;
|
|
65
|
-
const out =
|
|
79
|
+
const out: WireModelCapabilities = unverifiedCapabilities();
|
|
66
80
|
if (typeof o.tools === "boolean") out.tools = o.tools;
|
|
67
81
|
if (typeof o.vision === "boolean") out.vision = o.vision;
|
|
68
82
|
if (typeof o.audio_input === "boolean") out.audio_input = o.audio_input;
|
|
69
83
|
if (typeof o.audio_output === "boolean") out.audio_output = o.audio_output;
|
|
70
84
|
if (typeof o.extended_thinking === "boolean")
|
|
71
85
|
out.extended_thinking = o.extended_thinking;
|
|
86
|
+
if (
|
|
87
|
+
typeof o.source === "string" &&
|
|
88
|
+
(CAPABILITY_SOURCES as readonly string[]).includes(o.source)
|
|
89
|
+
) {
|
|
90
|
+
out.source = o.source as CapabilitySource;
|
|
91
|
+
}
|
|
72
92
|
return out;
|
|
73
93
|
}
|
|
74
94
|
|
|
75
95
|
/**
|
|
76
96
|
* Per-leg capabilities from host (MODEL_CHAIN_CAPABILITIES JSON array).
|
|
77
|
-
* When missing or invalid,
|
|
97
|
+
* When missing or invalid, falls back to guessed capabilities marked
|
|
98
|
+
* `source: "default"` so no unverified limitation is asserted to the model.
|
|
78
99
|
*/
|
|
79
100
|
function parseModelChainCapabilitiesFromEnv(
|
|
80
101
|
legCount: number,
|
|
81
|
-
):
|
|
102
|
+
): WireModelCapabilities[] {
|
|
103
|
+
const fallback = (): WireModelCapabilities[] =>
|
|
104
|
+
Array.from({ length: legCount }, unverifiedCapabilities);
|
|
105
|
+
|
|
82
106
|
const raw = process.env.MODEL_CHAIN_CAPABILITIES?.trim();
|
|
83
|
-
if (!raw)
|
|
84
|
-
return Array.from({ length: legCount }, () => ({
|
|
85
|
-
...DEFAULT_CAPABILITIES,
|
|
86
|
-
}));
|
|
87
|
-
}
|
|
107
|
+
if (!raw) return fallback();
|
|
88
108
|
try {
|
|
89
109
|
const arr = JSON.parse(raw) as unknown;
|
|
90
|
-
if (!Array.isArray(arr))
|
|
91
|
-
|
|
92
|
-
...DEFAULT_CAPABILITIES,
|
|
93
|
-
}));
|
|
94
|
-
}
|
|
95
|
-
const out: ModelCapabilities[] = [];
|
|
110
|
+
if (!Array.isArray(arr)) return fallback();
|
|
111
|
+
const out: WireModelCapabilities[] = [];
|
|
96
112
|
for (let i = 0; i < legCount; i++) {
|
|
97
113
|
out.push(parsePartialCapabilities(arr[i]));
|
|
98
114
|
}
|
|
99
115
|
return out;
|
|
100
116
|
} catch {
|
|
101
|
-
return
|
|
102
|
-
...DEFAULT_CAPABILITIES,
|
|
103
|
-
}));
|
|
117
|
+
return fallback();
|
|
104
118
|
}
|
|
105
119
|
}
|
|
106
120
|
|
|
@@ -191,33 +205,20 @@ function formatContextTimestamp(ms: number): string {
|
|
|
191
205
|
});
|
|
192
206
|
}
|
|
193
207
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
):
|
|
208
|
-
if (!attachments?.length) return false;
|
|
209
|
-
return attachments.some(
|
|
210
|
-
(a) =>
|
|
211
|
-
a.type === "audio" ||
|
|
212
|
-
a.type === "voice" ||
|
|
213
|
-
(a.mimeType?.toLowerCase().startsWith("audio/") ?? false),
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function buildCapabilitySection(
|
|
218
|
-
caps: ModelCapabilities,
|
|
219
|
-
payload: Payload,
|
|
220
|
-
): string {
|
|
208
|
+
/**
|
|
209
|
+
* Narrates only `tools` — the one capability pi does not track and Mercury can
|
|
210
|
+
* state as fact, since a `false` can only come from operator config.
|
|
211
|
+
*
|
|
212
|
+
* Vision and audio are deliberately absent. pi's `read` tool already emits
|
|
213
|
+
* "[Current model does not support images...]" at the moment an image is read,
|
|
214
|
+
* keyed to the model it is actually calling, and stays silent for model ids it
|
|
215
|
+
* does not recognise. Mercury's up-front version had to guess for unknown ids,
|
|
216
|
+
* and `DEFAULT_CAPABILITIES` guesses `vision: false` — which told every model
|
|
217
|
+
* newer than the pinned pi registry that it was blind. The audio flags could
|
|
218
|
+
* never be anything but `false` from a lookup (pi has no audio models, and
|
|
219
|
+
* voice notes are transcribed host-side), so they only ever added noise.
|
|
220
|
+
*/
|
|
221
|
+
export function buildCapabilitySection(caps: WireModelCapabilities): string {
|
|
221
222
|
const parts: string[] = ["## Current model capabilities"];
|
|
222
223
|
parts.push(
|
|
223
224
|
`This turn uses a model with the following constraints (do not assume you can exceed them):`,
|
|
@@ -225,15 +226,6 @@ function buildCapabilitySection(
|
|
|
225
226
|
parts.push(
|
|
226
227
|
`- **tools (bash / read / write / edit):** ${caps.tools ? "available" : "NOT available — you cannot run shell commands, read/write workspace files via tools, or use mrctl"}`,
|
|
227
228
|
);
|
|
228
|
-
parts.push(
|
|
229
|
-
`- **vision (images):** ${caps.vision ? "available" : "NOT available"}`,
|
|
230
|
-
);
|
|
231
|
-
parts.push(
|
|
232
|
-
`- **audio input:** ${caps.audio_input ? "available" : "NOT available"}`,
|
|
233
|
-
);
|
|
234
|
-
parts.push(
|
|
235
|
-
`- **audio output:** ${caps.audio_output ? "available" : "NOT available"}`,
|
|
236
|
-
);
|
|
237
229
|
|
|
238
230
|
if (!caps.tools) {
|
|
239
231
|
parts.push("");
|
|
@@ -242,20 +234,6 @@ function buildCapabilitySection(
|
|
|
242
234
|
);
|
|
243
235
|
}
|
|
244
236
|
|
|
245
|
-
if (!caps.vision && hasImageAttachments(payload.attachments)) {
|
|
246
|
-
parts.push("");
|
|
247
|
-
parts.push(
|
|
248
|
-
`**Note:** This model cannot process image pixels. Image files are still listed in <attachments /> with paths — you may reference paths and filenames but cannot interpret visual content.`,
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (!caps.audio_input && hasAudioAttachments(payload.attachments)) {
|
|
253
|
-
parts.push("");
|
|
254
|
-
parts.push(
|
|
255
|
-
`**Note:** This model cannot process audio. Voice attachments are listed with paths only.`,
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
237
|
return parts.join("\n");
|
|
260
238
|
}
|
|
261
239
|
|
|
@@ -356,7 +334,7 @@ Your prompt may include \`<active_episodes>\` XML with time-bounded topics relev
|
|
|
356
334
|
parts.push(claudeCodePreamble);
|
|
357
335
|
}
|
|
358
336
|
parts.push(mercuryPlatform);
|
|
359
|
-
parts.push(buildCapabilitySection(caps
|
|
337
|
+
parts.push(buildCapabilitySection(caps));
|
|
360
338
|
parts.push(memory);
|
|
361
339
|
parts.push(destructiveOps);
|
|
362
340
|
parts.push(toolResultPresentation);
|
|
@@ -13,6 +13,22 @@ export type ModelCapabilities = {
|
|
|
13
13
|
|
|
14
14
|
export type ModelCapabilityKey = keyof ModelCapabilities;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Where a capability set came from. `"default"` means the model id matched
|
|
18
|
+
* nothing — the flags below are guesses, not facts, and callers must not
|
|
19
|
+
* present them to the model as constraints. See `buildCapabilitySection`.
|
|
20
|
+
*/
|
|
21
|
+
export type CapabilitySource = "env" | "yaml" | "builtin" | "default";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Capabilities as serialized to the container over `MODEL_CHAIN_CAPABILITIES`.
|
|
25
|
+
* `source` rides along so the container can tell a looked-up `false` from an
|
|
26
|
+
* assumed one.
|
|
27
|
+
*/
|
|
28
|
+
export type WireModelCapabilities = ModelCapabilities & {
|
|
29
|
+
source?: CapabilitySource;
|
|
30
|
+
};
|
|
31
|
+
|
|
16
32
|
/** Fallback when no builtin / YAML / env match. */
|
|
17
33
|
export const DEFAULT_CAPABILITIES: ModelCapabilities = {
|
|
18
34
|
tools: true,
|
|
@@ -9,18 +9,23 @@ import { parse as parseYaml } from "yaml";
|
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import type { ModelLeg } from "../config.js";
|
|
11
11
|
import {
|
|
12
|
+
type CapabilitySource,
|
|
12
13
|
DEFAULT_CAPABILITIES,
|
|
13
14
|
type ModelCapabilities,
|
|
14
15
|
type ModelCapabilityKey,
|
|
16
|
+
type WireModelCapabilities,
|
|
15
17
|
} from "./model-capabilities-core.js";
|
|
16
18
|
|
|
17
19
|
export type {
|
|
20
|
+
CapabilitySource,
|
|
18
21
|
ModelCapabilities,
|
|
19
22
|
ModelCapabilityKey,
|
|
23
|
+
WireModelCapabilities,
|
|
20
24
|
} from "./model-capabilities-core.js";
|
|
21
25
|
export { DEFAULT_CAPABILITIES } from "./model-capabilities-core.js";
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
/** @deprecated Use `CapabilitySource` from model-capabilities-core. */
|
|
28
|
+
export type CapabilityResolveSource = CapabilitySource;
|
|
24
29
|
|
|
25
30
|
export type ResolvedModelCapabilities = {
|
|
26
31
|
capabilities: ModelCapabilities;
|
|
@@ -164,22 +169,40 @@ export function resolveModelChainCapabilities(
|
|
|
164
169
|
dataDirAbsolute: string,
|
|
165
170
|
envCaps: ModelCapabilities | null,
|
|
166
171
|
): {
|
|
167
|
-
chainCaps:
|
|
172
|
+
chainCaps: WireModelCapabilities[];
|
|
168
173
|
userMap: UserModelCapabilitiesMap | null;
|
|
169
174
|
} {
|
|
170
175
|
const userMap = loadUserModelCapabilitiesMap(dataDirAbsolute);
|
|
171
|
-
const chainCaps = chain.map((leg) =>
|
|
172
|
-
|
|
173
|
-
|
|
176
|
+
const chainCaps = chain.map((leg) => {
|
|
177
|
+
const { capabilities, source } = resolveModelCapabilitiesWithSource(
|
|
178
|
+
leg.model,
|
|
179
|
+
leg.provider,
|
|
180
|
+
userMap,
|
|
181
|
+
envCaps,
|
|
182
|
+
);
|
|
183
|
+
return { ...capabilities, source };
|
|
184
|
+
});
|
|
174
185
|
return { chainCaps, userMap };
|
|
175
186
|
}
|
|
176
187
|
|
|
177
188
|
export function chainSupportsRequirements(
|
|
178
189
|
requires: ModelCapabilityKey[],
|
|
179
|
-
chainCaps:
|
|
190
|
+
chainCaps: WireModelCapabilities[],
|
|
180
191
|
): boolean {
|
|
181
192
|
if (requires.length === 0) return true;
|
|
182
|
-
|
|
193
|
+
// A `false` from an unresolved model id (`source: "default"`) is a guess, not
|
|
194
|
+
// a fact — `DEFAULT_CAPABILITIES` assumes no vision, so every model newer
|
|
195
|
+
// than the pinned pi registry looks incapable here. Dropping a
|
|
196
|
+
// capability-gated extension or skill on that guess removes function from a
|
|
197
|
+
// model that may well support it, and does so invisibly at startup. Treat
|
|
198
|
+
// unknown as permissive: let the extension install and fail visibly at use
|
|
199
|
+
// time instead. This is the last consumer of `source` — the system prompt no
|
|
200
|
+
// longer narrates vision or audio at all (see `buildCapabilitySection`).
|
|
201
|
+
const satisfies = (caps: WireModelCapabilities, key: ModelCapabilityKey) =>
|
|
202
|
+
caps[key] === true || caps.source === "default";
|
|
203
|
+
return chainCaps.some((caps) =>
|
|
204
|
+
requires.every((key) => satisfies(caps, key)),
|
|
205
|
+
);
|
|
183
206
|
}
|
|
184
207
|
|
|
185
208
|
/** Log warnings for models that fell back to defaults (once per distinct model id). */
|
|
@@ -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
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type ModelCapabilities,
|
|
5
5
|
parseModelCapabilitiesEnv,
|
|
6
6
|
resolveModelChainCapabilities,
|
|
7
|
+
type WireModelCapabilities,
|
|
7
8
|
} from "./agent/model-capabilities.js";
|
|
8
9
|
import { mergeRawMercuryConfig } from "./config-file.js";
|
|
9
10
|
import { parseModelLegsArray } from "./config-model-chain.js";
|
|
@@ -220,6 +221,14 @@ const schema = z.object({
|
|
|
220
221
|
// ─── Permissions ────────────────────────────────────────────────────
|
|
221
222
|
admins: z.string().default(""),
|
|
222
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
|
+
|
|
223
232
|
// ─── Applicative Profile ────────────────────────────────────────────
|
|
224
233
|
/**
|
|
225
234
|
* Name of the active applicative profile (informational at runtime; the
|
|
@@ -305,7 +314,7 @@ export type AppConfig = z.infer<typeof schema> & {
|
|
|
305
314
|
/** Parsed MERCURY_MODEL_CAPABILITIES override, if valid. */
|
|
306
315
|
parsedModelCapabilitiesEnv: ModelCapabilities | null;
|
|
307
316
|
/** Capabilities per chain leg (same order as resolvedModelChain). */
|
|
308
|
-
resolvedModelChainCapabilities:
|
|
317
|
+
resolvedModelChainCapabilities: WireModelCapabilities[];
|
|
309
318
|
/** Effective budget after clamping to container timeout. */
|
|
310
319
|
effectiveModelChainBudgetMs: number;
|
|
311
320
|
/** Parsed `extensionDefaults` JSON: flat "ext.key" → value map. */
|
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
|
+
}
|