mercury-agent 0.16.2 → 0.17.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/README.md +12 -0
- package/container/Dockerfile +1 -0
- package/container/Dockerfile.base +1 -0
- package/docs/autopilot/config.yaml +43 -0
- package/docs/behavior-layers.md +26 -12
- package/docs/container-lifecycle.md +1 -0
- package/docs/extensions.md +43 -0
- package/docs/goals/football-reporter-profile/decisions.md +37 -1
- package/docs/goals/football-reporter-profile/roadmap.md +63 -5
- package/docs/permissions.md +15 -0
- package/docs/profile-guide.md +304 -18
- package/docs/skills-guide.md +24 -0
- package/examples/extensions/feed-watch/config.ts +36 -0
- package/examples/extensions/feed-watch/digest.ts +230 -32
- package/examples/extensions/feed-watch/index.ts +8 -0
- package/examples/extensions/feed-watch/items.ts +48 -4
- package/examples/extensions/overview/actions.ts +264 -0
- package/examples/extensions/overview/gate.ts +82 -0
- package/examples/extensions/overview/index.ts +123 -0
- package/examples/extensions/overview/skill/SKILL.md +80 -0
- package/examples/profiles/_template/config.yaml +34 -0
- package/examples/profiles/football-reporter/AGENTS.md +24 -6
- package/examples/profiles/football-reporter/README.md +9 -1
- package/examples/profiles/football-reporter/config.yaml +31 -2
- package/examples/profiles/football-reporter/tasks/daily-article.md +6 -4
- package/package.json +7 -3
- package/resources/templates/mercury.example.yaml +5 -0
- package/src/adapters/whatsapp-media.ts +80 -3
- package/src/agent/container-entry.ts +143 -21
- package/src/agent/container-env.ts +11 -0
- package/src/agent/container-error.ts +12 -1
- package/src/agent/container-runner.ts +91 -9
- package/src/agent/pi-jsonl-parser.ts +166 -5
- package/src/bridges/discord.ts +4 -0
- package/src/bridges/slack.ts +4 -0
- package/src/bridges/teams.ts +4 -0
- package/src/bridges/telegram.ts +33 -1
- package/src/cli/build-image-tags.ts +34 -0
- package/src/cli/mercury.ts +51 -4
- package/src/config-file.ts +6 -0
- package/src/config.ts +28 -3
- package/src/core/attachment-notes.ts +4 -0
- package/src/core/confirmation.ts +3 -1
- package/src/core/handler.ts +2 -1
- package/src/core/media.ts +104 -2
- package/src/core/outbox.ts +23 -3
- package/src/core/reply-context.ts +61 -0
- package/src/core/routes/chat.ts +70 -3
- package/src/core/routes/config-builtin.ts +7 -0
- package/src/core/routes/dashboard.ts +91 -5
- package/src/core/runtime.ts +170 -22
- package/src/core/task-output.ts +36 -18
- package/src/core/task-scheduler.ts +18 -1
- package/src/extensions/catalog.ts +1 -0
- package/src/extensions/hooks.ts +8 -0
- package/src/extensions/loader.ts +68 -7
- package/src/extensions/types.ts +35 -2
- package/src/main.ts +19 -0
- package/src/server.ts +15 -8
- package/src/storage/db.ts +231 -12
- package/src/text/sanitize-text.ts +118 -0
- package/src/types.ts +73 -1
|
@@ -122,8 +122,27 @@ extension_config:
|
|
|
122
122
|
# separately from reporting.
|
|
123
123
|
feed-watch.quiet_hours: "00:30-07:30"
|
|
124
124
|
feed-watch.timezone: "Asia/Jerusalem"
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
# Run rate. These two are the *cost* levers, and the cost law is
|
|
126
|
+
# `cost/day ≈ runs/day × prefix tokens × $2.50/M` — every run cache-writes its
|
|
127
|
+
# whole prefix, because runs are further apart than the 5-minute cache lives.
|
|
128
|
+
# At 3/hour and a 4-minute batch the space opened 46-48 runs/day, ~70 % of
|
|
129
|
+
# which answered NO_UPDATE. Lowered 2026-08-31 to 1/hour and a 15-minute
|
|
130
|
+
# batch: a wider batch also merges more outlets carrying one story into a
|
|
131
|
+
# single run, which is what the verify prompt asks the model to do anyway.
|
|
132
|
+
#
|
|
133
|
+
# These reduce the *number* of runs. They are not the digest fix — see
|
|
134
|
+
# `docs/debug/major/2026-08-31-feed-watch-digest-persisted-and-replayed.md` for the prefix size,
|
|
135
|
+
# which is the other factor in the same product.
|
|
136
|
+
feed-watch.max_per_hour: "1"
|
|
137
|
+
feed-watch.batch_minutes: "15"
|
|
138
|
+
|
|
139
|
+
# The other factor in the same product: how big each run's prompt is. The
|
|
140
|
+
# digest carries one window into the article, and on 2026-08-31 that was 346
|
|
141
|
+
# items / 130 KB. Capped here at 200 newest; the whole window, links included,
|
|
142
|
+
# is written to `knowledge/feed-digest-window.md` before the run, so nothing
|
|
143
|
+
# the cap holds back is out of reach. Declared explicitly rather than left to
|
|
144
|
+
# the extension default so `space-profile check` covers it.
|
|
145
|
+
feed-watch.digest_max_items: "200"
|
|
127
146
|
|
|
128
147
|
# ── member permissions (M2.3, audit R7) ─────────────────────────────────
|
|
129
148
|
#
|
|
@@ -139,6 +158,16 @@ extension_config:
|
|
|
139
158
|
# above for why that one stays out of this file.
|
|
140
159
|
role.member.permissions: "prompt,prefs.get,napkin,pdf,voice-transcribe,web-browser,tasks.create,tasks.list,tasks.pause,tasks.resume,tts.synthesize,media.receive,media.send"
|
|
141
160
|
|
|
161
|
+
# ── one voice (M2.4, audit R2, D-017) ──────────────────────────────────
|
|
162
|
+
#
|
|
163
|
+
# This space owns its standard: AGENTS.md here is the only persona the model
|
|
164
|
+
# hears. With this on, the host leaves the owner's global character ("Wiz, a
|
|
165
|
+
# personal assistant") out of the system prompt and does not mount the
|
|
166
|
+
# global AGENTS.md ("a helpful AI assistant… ask before acting") for runs in
|
|
167
|
+
# this space. Everything else — skills, models, the platform's security
|
|
168
|
+
# text — is unchanged. Profile-owned so a hand flip shows up as drift.
|
|
169
|
+
persona.exclusive: "true"
|
|
170
|
+
|
|
142
171
|
# Stray items in the space workspace, deleted by apply and logged (D-009).
|
|
143
172
|
# Paths are relative to the space directory and may not escape it.
|
|
144
173
|
workspace_remove:
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
|
|
38
38
|
### בלוק הסיכום
|
|
39
39
|
|
|
40
|
-
התשובה נפתחת בבלוק הזה, בלי שום תו
|
|
40
|
+
התשובה נפתחת בבלוק הזה, בלי שום תו לפניו (כשההודעה עטופה בסמני המסירה
|
|
41
|
+
`[reply]` … `[/reply]`, הבלוק הוא השורה הראשונה **בתוך** הסמנים):
|
|
41
42
|
|
|
42
43
|
```
|
|
43
44
|
[longview:summary]
|
|
@@ -79,7 +80,7 @@ DD.MM.YYYY · N מקורות
|
|
|
79
80
|
|
|
80
81
|
*מקורות*
|
|
81
82
|
|
|
82
|
-
-
|
|
83
|
+
- <אתר> — <כותרת הידיעה>, <תאריך>
|
|
83
84
|
```
|
|
84
85
|
|
|
85
86
|
- **כותרת** — עובדתית וספציפית, עד 70 תווים, בלי סימני שאלה. היא גם כתובת
|
|
@@ -90,8 +91,9 @@ DD.MM.YYYY · N מקורות
|
|
|
90
91
|
עם שם הדובר, ההקשר והתאריך. אם אין ציטוט כזה, אין את השורה הזאת.
|
|
91
92
|
- **בוודאות / בספק** — שתי שורות ציטוט, כל אחת בשורה נפרדת עם שורה ריקה
|
|
92
93
|
ביניהן. מה שלא רשמי נכנס ל"בספק" גם אם הוא סביר.
|
|
93
|
-
- **מקורות** — רשימה של כל מה שקראת בפועל,
|
|
94
|
-
מופיע
|
|
94
|
+
- **מקורות** — רשימה של כל מה שקראת בפועל, בשם האתר ובתאריך. בלי כתובות URL:
|
|
95
|
+
הקישור משמש אותך לאימות לפני הכתיבה, לא את הקורא. אתר שלא פתחת לא מופיע כאן,
|
|
96
|
+
וזה לא משתנה — הרשימה היא הצהרה על מה שנקרא, לא קישוט.
|
|
95
97
|
|
|
96
98
|
### הבלוקים
|
|
97
99
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mercury-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Avishai Tsabari",
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
},
|
|
21
21
|
"type": "module",
|
|
22
22
|
"exports": {
|
|
23
|
+
"./core/global-admin": {
|
|
24
|
+
"types": "./src/core/global-admin.ts",
|
|
25
|
+
"default": "./src/core/global-admin.ts"
|
|
26
|
+
},
|
|
23
27
|
"./extensions/types": {
|
|
24
28
|
"types": "./src/extensions/types.ts",
|
|
25
29
|
"default": "./src/extensions/types.ts"
|
|
@@ -72,8 +76,8 @@
|
|
|
72
76
|
"typecheck": "bunx tsc --noEmit",
|
|
73
77
|
"typecheck:scripts": "bunx tsc --noEmit -p tsconfig.scripts.json",
|
|
74
78
|
"typecheck:examples": "bunx tsc --noEmit -p tsconfig.examples.json",
|
|
75
|
-
"lint": "bunx biome check src/ tests/ scripts/space-profile.ts examples/extensions/feed-watch/ examples/extensions/napkin/ examples/extensions/pinchtab/",
|
|
76
|
-
"lint:fix": "bunx biome check --fix src/ tests/ scripts/space-profile.ts examples/extensions/feed-watch/ examples/extensions/napkin/ examples/extensions/pinchtab/",
|
|
79
|
+
"lint": "bunx biome check src/ tests/ scripts/space-profile.ts examples/extensions/feed-watch/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/",
|
|
80
|
+
"lint:fix": "bunx biome check --fix src/ tests/ scripts/space-profile.ts examples/extensions/feed-watch/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/",
|
|
77
81
|
"check:silent-catch": "bun run scripts/check-silent-catch.ts",
|
|
78
82
|
"check:sync-calls": "bun run scripts/check-sync-calls.ts",
|
|
79
83
|
"check:dep-floors": "bun run scripts/check-dep-floors.ts",
|
|
@@ -62,6 +62,11 @@
|
|
|
62
62
|
# image: ghcr.io/avishai-tsabari/mercury-agent:latest
|
|
63
63
|
# container_timeout_ms: 300000
|
|
64
64
|
# container_bwrap_docker_compat: false
|
|
65
|
+
# # Docker Desktop only. Mercury refuses the auto-detected sandbox relaxation
|
|
66
|
+
# # (seccomp=unconfined + SYS_ADMIN) unless you opt in here. Without it,
|
|
67
|
+
# # containers do not start on macOS/Windows. Ignored when
|
|
68
|
+
# # container_bwrap_docker_compat is set or the runtime is runsc.
|
|
69
|
+
# allow_unconfined: false
|
|
65
70
|
# # Which MERCURY_* vars reach containers. "all" (default) passes every
|
|
66
71
|
# # non-blocked var into every space's container; "claimed" passes only
|
|
67
72
|
# # extension-declared vars, gated by the caller's permission.
|
|
@@ -15,8 +15,13 @@ import {
|
|
|
15
15
|
type WAMessage,
|
|
16
16
|
type WASocket,
|
|
17
17
|
} from "@whiskeysockets/baileys";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
hasExecutableHeader,
|
|
20
|
+
isAllowedExtension,
|
|
21
|
+
mimeToExt,
|
|
22
|
+
} from "../core/media.js";
|
|
19
23
|
import { logger } from "../logger.js";
|
|
24
|
+
import { stripInvisibleUnicode } from "../text/sanitize-text.js";
|
|
20
25
|
import type {
|
|
21
26
|
DroppedAttachment,
|
|
22
27
|
MediaType,
|
|
@@ -170,6 +175,25 @@ export async function downloadWhatsAppMedia(
|
|
|
170
175
|
|
|
171
176
|
const messageId = msg.key.id || `${Date.now()}`;
|
|
172
177
|
|
|
178
|
+
// Part A — extension allowlist, WARN-ONLY: logs what a rejection would have
|
|
179
|
+
// dropped, accepts the file regardless. Before the download, like
|
|
180
|
+
// `downloadMediaFromUrl`, so the log reflects what a rejection would have
|
|
181
|
+
// skipped fetching — a file that is oversized or fails to download would
|
|
182
|
+
// otherwise never be counted. Only meaningful when WhatsApp supplied a
|
|
183
|
+
// filename; without one the stored name is derived from the MIME type and
|
|
184
|
+
// there is no extension to judge.
|
|
185
|
+
if (mediaInfo.filename && !isAllowedExtension(mediaInfo.filename)) {
|
|
186
|
+
logger.warn(
|
|
187
|
+
"Attachment extension not in EXT_TO_MIME (warn-only, accepted)",
|
|
188
|
+
{
|
|
189
|
+
messageId,
|
|
190
|
+
filename: mediaInfo.filename,
|
|
191
|
+
mimeType: mediaInfo.mimeType,
|
|
192
|
+
type: mediaInfo.type,
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
173
197
|
// Check file size before downloading
|
|
174
198
|
if (mediaInfo.fileLength && mediaInfo.fileLength > options.maxSizeBytes) {
|
|
175
199
|
logger.warn("Skipping large media file", {
|
|
@@ -222,6 +246,23 @@ export async function downloadWhatsAppMedia(
|
|
|
222
246
|
return null;
|
|
223
247
|
}
|
|
224
248
|
|
|
249
|
+
// Part B — binary-header denylist, enforced. Checked on the bytes already
|
|
250
|
+
// in memory, before anything reaches disk: never write-then-unlink.
|
|
251
|
+
if (hasExecutableHeader(buffer)) {
|
|
252
|
+
logger.warn("Dropping attachment with executable header", {
|
|
253
|
+
messageId,
|
|
254
|
+
filename: mediaInfo.filename,
|
|
255
|
+
mimeType: mediaInfo.mimeType,
|
|
256
|
+
type: mediaInfo.type,
|
|
257
|
+
sizeBytes: buffer.length,
|
|
258
|
+
});
|
|
259
|
+
options.onSkipped?.({
|
|
260
|
+
type: mediaInfo.type,
|
|
261
|
+
reason: "executable_content",
|
|
262
|
+
});
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
225
266
|
// Ensure inbox directory exists
|
|
226
267
|
const mediaDir = path.join(options.outputDir, "inbox");
|
|
227
268
|
fs.mkdirSync(mediaDir, { recursive: true });
|
|
@@ -252,7 +293,7 @@ export async function downloadWhatsAppMedia(
|
|
|
252
293
|
path: filePath,
|
|
253
294
|
type: mediaInfo.type,
|
|
254
295
|
mimeType: mediaInfo.mimeType,
|
|
255
|
-
filename: mediaInfo.filename,
|
|
296
|
+
filename: stripInvisibleUnicode(mediaInfo.filename),
|
|
256
297
|
sizeBytes: buffer.length,
|
|
257
298
|
};
|
|
258
299
|
} catch (error) {
|
|
@@ -304,6 +345,25 @@ export async function downloadQuotedMedia(
|
|
|
304
345
|
|
|
305
346
|
const messageId = syntheticMsg.key.id || `${Date.now()}`;
|
|
306
347
|
|
|
348
|
+
// Part A — extension allowlist, WARN-ONLY: logs what a rejection would have
|
|
349
|
+
// dropped, accepts the file regardless. Before the download, like
|
|
350
|
+
// `downloadMediaFromUrl`, so the log reflects what a rejection would have
|
|
351
|
+
// skipped fetching — a file that is oversized or fails to download would
|
|
352
|
+
// otherwise never be counted. Only meaningful when WhatsApp supplied a
|
|
353
|
+
// filename; without one the stored name is derived from the MIME type and
|
|
354
|
+
// there is no extension to judge.
|
|
355
|
+
if (mediaInfo.filename && !isAllowedExtension(mediaInfo.filename)) {
|
|
356
|
+
logger.warn(
|
|
357
|
+
"Attachment extension not in EXT_TO_MIME (warn-only, accepted)",
|
|
358
|
+
{
|
|
359
|
+
messageId,
|
|
360
|
+
filename: mediaInfo.filename,
|
|
361
|
+
mimeType: mediaInfo.mimeType,
|
|
362
|
+
type: mediaInfo.type,
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
307
367
|
if (mediaInfo.fileLength && mediaInfo.fileLength > options.maxSizeBytes) {
|
|
308
368
|
logger.warn("Skipping large quoted media file", {
|
|
309
369
|
messageId,
|
|
@@ -349,6 +409,23 @@ export async function downloadQuotedMedia(
|
|
|
349
409
|
return null;
|
|
350
410
|
}
|
|
351
411
|
|
|
412
|
+
// Same enforced gate as the primary download path. A quoted message is a
|
|
413
|
+
// second ingest leg into the same inbox, not a display-only reference.
|
|
414
|
+
if (hasExecutableHeader(buffer)) {
|
|
415
|
+
logger.warn("Dropping quoted attachment with executable header", {
|
|
416
|
+
messageId,
|
|
417
|
+
filename: mediaInfo.filename,
|
|
418
|
+
mimeType: mediaInfo.mimeType,
|
|
419
|
+
type: mediaInfo.type,
|
|
420
|
+
sizeBytes: buffer.length,
|
|
421
|
+
});
|
|
422
|
+
options.onSkipped?.({
|
|
423
|
+
type: mediaInfo.type,
|
|
424
|
+
reason: "executable_content",
|
|
425
|
+
});
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
352
429
|
const mediaDir = path.join(options.outputDir, "inbox");
|
|
353
430
|
fs.mkdirSync(mediaDir, { recursive: true });
|
|
354
431
|
|
|
@@ -375,7 +452,7 @@ export async function downloadQuotedMedia(
|
|
|
375
452
|
path: filePath,
|
|
376
453
|
type: mediaInfo.type,
|
|
377
454
|
mimeType: mediaInfo.mimeType,
|
|
378
|
-
filename: mediaInfo.filename,
|
|
455
|
+
filename: stripInvisibleUnicode(mediaInfo.filename),
|
|
379
456
|
sizeBytes: buffer.length,
|
|
380
457
|
};
|
|
381
458
|
} catch (error) {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
writeFileSync,
|
|
12
12
|
} from "node:fs";
|
|
13
13
|
import path from "node:path";
|
|
14
|
+
import { stripInvisibleUnicode } from "../text/sanitize-text.js";
|
|
14
15
|
import type { MessageAttachment, StoredMessage } from "../types.js";
|
|
15
16
|
import {
|
|
16
17
|
type CapabilitySource,
|
|
@@ -95,6 +96,17 @@ export type Payload = {
|
|
|
95
96
|
anchorMessages?: StoredMessage[];
|
|
96
97
|
prompt: string;
|
|
97
98
|
callerRole?: string;
|
|
99
|
+
/**
|
|
100
|
+
* The caller's effective permission set in this space (`permissions.ts`
|
|
101
|
+
* `getRolePermissions`). Platform-prompt sections that instruct the model to
|
|
102
|
+
* use a tool — "set a preference", "mute" — are emitted only when the caller
|
|
103
|
+
* holds the matching permission, so the model is never told to do a thing
|
|
104
|
+
* and then told to refuse it in the same turn.
|
|
105
|
+
*
|
|
106
|
+
* Optional for the same reason as the budget fields: an older host does not
|
|
107
|
+
* send it, and then every section is emitted exactly as before.
|
|
108
|
+
*/
|
|
109
|
+
callerPermissions?: string[];
|
|
98
110
|
authorName?: string;
|
|
99
111
|
attachments?: MessageAttachment[];
|
|
100
112
|
preferences?: Array<{ key: string; value: string }>;
|
|
@@ -327,7 +339,17 @@ Prioritize practical outputs and explicit assumptions.`;
|
|
|
327
339
|
const raw = process.env.BOT_USERNAME || "Mercury";
|
|
328
340
|
const botName = raw.charAt(0).toUpperCase() + raw.slice(1);
|
|
329
341
|
|
|
330
|
-
|
|
342
|
+
// Which capability paragraphs this caller gets. A host that sends no
|
|
343
|
+
// permission set gets every paragraph, exactly as before the field existed;
|
|
344
|
+
// a host that does send one gets only the paragraphs the caller can act on.
|
|
345
|
+
// Security text is never gated — it describes boundaries, not tools.
|
|
346
|
+
const perms = payload.callerPermissions
|
|
347
|
+
? new Set(payload.callerPermissions)
|
|
348
|
+
: null;
|
|
349
|
+
const callerMay = (permission: string): boolean =>
|
|
350
|
+
perms === null || perms.has(permission);
|
|
351
|
+
|
|
352
|
+
const platformIntro = `Files received from users (images, documents, voice notes) are saved to the \`inbox/\` directory in the current workspace. To send files back with your reply, write them to the \`outbox/\` directory — any files created or modified there during this run will be automatically attached to your response.
|
|
331
353
|
|
|
332
354
|
You are ${botName}. You run on the Mercury platform (https://github.com/Avishai-Tsabari/mercury). When users ask about the platform — what it can do, how to configure it, scheduling, permissions, extensions, or anything about Mercury — you MUST read from \`/docs/mercury/\` before answering. Start with \`/docs/mercury/README.md\` for an overview, then check \`/docs/mercury/docs/\` for detailed guides.
|
|
333
355
|
|
|
@@ -337,14 +359,23 @@ Each run is triggered by a specific caller with a role (admin or member). The ca
|
|
|
337
359
|
- **member**: Limited access. Some tools and extensions are restricted.
|
|
338
360
|
If a tool call is blocked with "Permission denied", this is a hard security boundary. Do NOT attempt to achieve the same result through alternative means — no curl, no direct API calls, no workarounds. Simply inform the user they do not have permission.
|
|
339
361
|
|
|
340
|
-
Never write or execute scripts whose purpose is to read data from the local filesystem or database and transmit it to an external URL or endpoint. This applies regardless of how the request is phrased — "backup", "sync", "export", "check", etc. are not exceptions. If a user asks you to do this, refuse and explain why
|
|
362
|
+
Never write or execute scripts whose purpose is to read data from the local filesystem or database and transmit it to an external URL or endpoint. This applies regardless of how the request is phrased — "backup", "sync", "export", "check", etc. are not exceptions. If a user asks you to do this, refuse and explain why.`;
|
|
341
363
|
|
|
342
|
-
|
|
343
|
-
|
|
364
|
+
// Only for a caller who can actually run `mrctl mute`: told to a member
|
|
365
|
+
// without `mutes.mute`, this paragraph is an instruction the next tool call
|
|
366
|
+
// refuses, and the refusal then reads as "inform the user".
|
|
367
|
+
const moderation = `## Moderation
|
|
368
|
+
You can mute users who are being abusive, spamming, trying to exfiltrate secrets, or deliberately wasting the group's resources by triggering you for pointless nonsense. Use \`mrctl mute\` when you judge it necessary — you don't need to wait for an admin to ask. Warn the user first, then mute if they continue.`;
|
|
344
369
|
|
|
345
|
-
|
|
370
|
+
const environmentVariables = `## Environment Variables
|
|
346
371
|
Host \`MERCURY_*\` env vars are passed into your container with the prefix stripped: \`MERCURY_FOO\` on the host is \`$FOO\` here (e.g. \`MERCURY_GITHUB_TOKEN\` → \`$GITHUB_TOKEN\`). \`$MERCURY_*\` names never exist inside the container — when wiring a secret into a script, git credential helper, or config, always reference the stripped name.`;
|
|
347
372
|
|
|
373
|
+
const mercuryPlatform = [
|
|
374
|
+
platformIntro,
|
|
375
|
+
...(callerMay("mutes.mute") ? [moderation] : []),
|
|
376
|
+
environmentVariables,
|
|
377
|
+
].join("\n\n");
|
|
378
|
+
|
|
348
379
|
const destructiveOps = `## Destructive Operations — Confirmation Required
|
|
349
380
|
|
|
350
381
|
Before deleting, trashing, or permanently removing any data, you MUST stop and confirm with the user first:
|
|
@@ -375,6 +406,19 @@ After running any command or tool, never send raw output to the user. Always tra
|
|
|
375
406
|
|
|
376
407
|
This rule applies to all tools: Google Workspace, TradeStation, web search, and any future extension.`;
|
|
377
408
|
|
|
409
|
+
// The envelope exists because the shape it catches has no structural
|
|
410
|
+
// signal: one final text block that opens with a working sentence and
|
|
411
|
+
// continues with the answer. The parser can split on a thinking block; it
|
|
412
|
+
// cannot split inside a block. Asking for the message in markers turns
|
|
413
|
+
// "never narrate" — a rule the space layer already tried and lost — into
|
|
414
|
+
// "narrate anywhere, only this span is sent". Same contract as longview's
|
|
415
|
+
// `[longview:summary]`, honoured by `extractReplyEnvelope` in the parser.
|
|
416
|
+
const delivery = `## Delivery
|
|
417
|
+
|
|
418
|
+
Wrap the message you want delivered between a \`[reply]\` line and a \`[/reply]\` line, each marker alone on its own line. Text outside the markers is not sent — that is where a note on what you checked, a remark that a file was written, or a sentence after a tool result belongs. Put the markers on your final message only, once; if you have already written the message and then run one more tool, write it again inside the markers when you are done.
|
|
419
|
+
|
|
420
|
+
Everything a space's standard says about the message — language, length, format, the exact word to send when there is nothing to say — applies to the text inside the markers. Files still go to \`outbox/\`; the markers cover text only.`;
|
|
421
|
+
|
|
378
422
|
const characterChangeFlow = `## Character
|
|
379
423
|
|
|
380
424
|
The system prompt may include a "Bot Character" section — the owner-defined voice for
|
|
@@ -450,7 +494,15 @@ Your prompt carries a \`<run_budget>\` block with the exact kill time, the total
|
|
|
450
494
|
}
|
|
451
495
|
parts.push(destructiveOps);
|
|
452
496
|
parts.push(toolResultPresentation);
|
|
453
|
-
parts.push(
|
|
497
|
+
parts.push(delivery);
|
|
498
|
+
// The character / preference flow is a set of `mrctl prefs set` and
|
|
499
|
+
// `mrctl character set` instructions. A caller without `prefs.set` cannot
|
|
500
|
+
// follow them, and the paragraph's "a standing instruction → set a
|
|
501
|
+
// preference" is exactly how a member's joke got escalated to "the admin
|
|
502
|
+
// must approve". Left out for that caller; the security text above stays.
|
|
503
|
+
if (callerMay("prefs.set")) {
|
|
504
|
+
parts.push(characterChangeFlow);
|
|
505
|
+
}
|
|
454
506
|
if (payload.anchorMessages && payload.anchorMessages.length > 0) {
|
|
455
507
|
parts.push(
|
|
456
508
|
`When a \`<reply_anchor>\` block appears in the user prompt, the user is swipe-replying to those specific messages. Address the anchor content directly.`,
|
|
@@ -513,27 +565,44 @@ Current working directory: ${cwd}`;
|
|
|
513
565
|
/**
|
|
514
566
|
* Format attachment information for the prompt as XML.
|
|
515
567
|
* Converts absolute paths to container-relative paths.
|
|
568
|
+
*
|
|
569
|
+
* This is the single point where an attachment's `filename` — a string the
|
|
570
|
+
* sender chose — becomes prompt text, so it is where the invisible-Unicode
|
|
571
|
+
* strip belongs. The seven ingest paths (five bridges, the web chat route and
|
|
572
|
+
* the shared URL downloader) all sanitize on the way in as well, but only this
|
|
573
|
+
* function is guaranteed to run for every attachment of every future bridge.
|
|
516
574
|
*/
|
|
517
|
-
function formatAttachments(
|
|
575
|
+
export function formatAttachments(
|
|
518
576
|
attachments: MessageAttachment[] | undefined,
|
|
519
577
|
): string | null {
|
|
520
578
|
if (!attachments || attachments.length === 0) return null;
|
|
521
579
|
|
|
580
|
+
// Every remote-supplied value goes through this, not just the obvious one.
|
|
581
|
+
// Stripped because invisible carriers tokenize as instructions, and escaped
|
|
582
|
+
// because the value lands inside a double-quoted XML attribute where a bare
|
|
583
|
+
// `"` would close it early and let the remainder parse as markup of its own.
|
|
584
|
+
const attr = (value: string) =>
|
|
585
|
+
stripInvisibleUnicode(value).replace(/"/g, """);
|
|
586
|
+
|
|
522
587
|
const entries = attachments.map((att) => {
|
|
523
588
|
// Convert host path to container path
|
|
524
589
|
const containerPath = att.path.replace(/^.*\/spaces\//, "/spaces/");
|
|
525
590
|
|
|
526
591
|
const attrs = [
|
|
592
|
+
// `type` is a closed set from `mimeToMediaType` and `path` is
|
|
593
|
+
// ASCII-allowlisted at every ingest site, so both are already safe.
|
|
594
|
+
// `mimeType` is NOT: bridges pass the platform's `content_type` through
|
|
595
|
+
// verbatim, so it is exactly as attacker-controlled as `filename`.
|
|
527
596
|
`type="${att.type}"`,
|
|
528
597
|
`path="${containerPath}"`,
|
|
529
|
-
`mime="${att.mimeType}"`,
|
|
598
|
+
`mime="${attr(att.mimeType)}"`,
|
|
530
599
|
];
|
|
531
600
|
|
|
532
601
|
if (att.sizeBytes) {
|
|
533
602
|
attrs.push(`size="${att.sizeBytes}"`);
|
|
534
603
|
}
|
|
535
604
|
if (att.filename) {
|
|
536
|
-
attrs.push(`filename="${att.filename}"`);
|
|
605
|
+
attrs.push(`filename="${attr(att.filename)}"`);
|
|
537
606
|
}
|
|
538
607
|
|
|
539
608
|
return ` <attachment ${attrs.join(" ")} />`;
|
|
@@ -592,7 +661,21 @@ function parseEpisodeFrontmatter(content: string): EpisodeMeta | null {
|
|
|
592
661
|
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
593
662
|
const title = titleMatch ? titleMatch[1].trim() : "";
|
|
594
663
|
|
|
595
|
-
|
|
664
|
+
// `[^\n]*` and not a bare `\n`: the distiller writes the heading as
|
|
665
|
+
// `## Current State (as of 2026-08-20)` whenever it feels like dating it, and
|
|
666
|
+
// an anchored `\n` then fails to match, yielding `currentState = ""`. The
|
|
667
|
+
// episode is still injected — as its one-line summary, with every fact gone
|
|
668
|
+
// and nothing logged. Ten of ten football notes were summary-only for a day
|
|
669
|
+
// before anyone noticed, because the block was present and looked alive.
|
|
670
|
+
//
|
|
671
|
+
// The live vault currently avoids this by carrying an HTML comment in each
|
|
672
|
+
// note telling the distiller to keep the heading bare. That is a workaround
|
|
673
|
+
// living in the data — one rewrite drops it and the failure returns silently.
|
|
674
|
+
// Accepting the suffix here is what actually closes it, and it cannot regress
|
|
675
|
+
// the bare form.
|
|
676
|
+
const csMatch = content.match(
|
|
677
|
+
/## Current State[^\n]*\n([\s\S]*?)(?=\n## |$)/,
|
|
678
|
+
);
|
|
596
679
|
const currentState = csMatch ? csMatch[1].trim() : "";
|
|
597
680
|
|
|
598
681
|
return {
|
|
@@ -761,8 +844,12 @@ function authorAttrs(m: StoredMessage): string {
|
|
|
761
844
|
const HISTORY_CHAR_BUDGET = 400_000;
|
|
762
845
|
|
|
763
846
|
export function buildHistoryXml(messages: StoredMessage[]): string | null {
|
|
764
|
-
// Pair up user+assistant turns; skip ambient (they have their own section)
|
|
765
|
-
|
|
847
|
+
// Pair up user+assistant turns; skip ambient (they have their own section).
|
|
848
|
+
// An assistant row with no user row before it is a turn of its own, not
|
|
849
|
+
// noise: since the window stopped carrying scheduler prompts, every
|
|
850
|
+
// scheduled post arrives exactly like that, and dropping it would hide the
|
|
851
|
+
// bot's own published output from the model.
|
|
852
|
+
const turns: Array<{ user?: StoredMessage; assistant?: StoredMessage }> = [];
|
|
766
853
|
let pendingUser: StoredMessage | null = null;
|
|
767
854
|
|
|
768
855
|
for (const m of messages) {
|
|
@@ -771,8 +858,10 @@ export function buildHistoryXml(messages: StoredMessage[]): string | null {
|
|
|
771
858
|
turns.push({ user: pendingUser });
|
|
772
859
|
}
|
|
773
860
|
pendingUser = m;
|
|
774
|
-
} else if (m.role === "assistant"
|
|
775
|
-
turns.push(
|
|
861
|
+
} else if (m.role === "assistant") {
|
|
862
|
+
turns.push(
|
|
863
|
+
pendingUser ? { user: pendingUser, assistant: m } : { assistant: m },
|
|
864
|
+
);
|
|
776
865
|
pendingUser = null;
|
|
777
866
|
}
|
|
778
867
|
}
|
|
@@ -788,10 +877,14 @@ export function buildHistoryXml(messages: StoredMessage[]): string | null {
|
|
|
788
877
|
const turn = turns[i];
|
|
789
878
|
if (!turn) break;
|
|
790
879
|
const { user, assistant } = turn;
|
|
791
|
-
const
|
|
792
|
-
|
|
880
|
+
const anchorRow = user ?? assistant;
|
|
881
|
+
if (!anchorRow) continue;
|
|
882
|
+
const ts = formatContextTimestamp(anchorRow.createdAt);
|
|
883
|
+
const userLine = user
|
|
884
|
+
? ` <user${authorAttrs(user)}>${escapeXmlText(user.content)}</user>`
|
|
885
|
+
: "";
|
|
793
886
|
const assistantLine = assistant
|
|
794
|
-
?
|
|
887
|
+
? `${user ? "\n" : ""} <assistant>${escapeXmlText(assistant.content)}</assistant>`
|
|
795
888
|
: "";
|
|
796
889
|
const entry = ` <turn timestamp="${ts}">\n${userLine}${assistantLine}\n </turn>`;
|
|
797
890
|
|
|
@@ -805,12 +898,17 @@ export function buildHistoryXml(messages: StoredMessage[]): string | null {
|
|
|
805
898
|
return `<history>\n${entries.join("\n")}\n</history>`;
|
|
806
899
|
}
|
|
807
900
|
|
|
808
|
-
function buildAnchorXml(messages: StoredMessage[]): string | null {
|
|
901
|
+
export function buildAnchorXml(messages: StoredMessage[]): string | null {
|
|
809
902
|
if (!messages || messages.length === 0) return null;
|
|
810
903
|
|
|
904
|
+
// Author attributes for the same reason `<history>` carries them: the
|
|
905
|
+
// anchor is now the *only* rendering of the quoted message (the adapter's
|
|
906
|
+
// `<reply_to name=…>` copy is dropped when the anchor resolves), so a quoted
|
|
907
|
+
// member's name has to travel with the row.
|
|
811
908
|
const entries = messages.map((m) => {
|
|
812
909
|
const ts = formatContextTimestamp(m.createdAt);
|
|
813
|
-
|
|
910
|
+
const author = m.role === "assistant" ? "" : authorAttrs(m);
|
|
911
|
+
return ` <message role="${m.role}"${author} timestamp="${ts}">${escapeXmlText(m.content)}</message>`;
|
|
814
912
|
});
|
|
815
913
|
|
|
816
914
|
return `<reply_anchor>\n${entries.join("\n")}\n</reply_anchor>`;
|
|
@@ -902,7 +1000,11 @@ export function buildPrompt(payload: Payload): string {
|
|
|
902
1000
|
const callerId = process.env.CALLER_ID ?? "unknown";
|
|
903
1001
|
const role = payload.callerRole ?? "member";
|
|
904
1002
|
const space = payload.spaceId ?? "unknown";
|
|
905
|
-
|
|
1003
|
+
// Display names are platform-supplied; a `"` in one would close the
|
|
1004
|
+
// attribute early, exactly as it would in `authorAttrs`.
|
|
1005
|
+
const nameAttr = payload.authorName
|
|
1006
|
+
? ` name="${escapeXmlAttr(payload.authorName)}"`
|
|
1007
|
+
: "";
|
|
906
1008
|
parts.push(
|
|
907
1009
|
`<caller id="${callerId}"${nameAttr} role="${role}" space="${space}" />`,
|
|
908
1010
|
);
|
|
@@ -992,7 +1094,19 @@ export function buildPrompt(payload: Payload): string {
|
|
|
992
1094
|
// 8. Current prompt
|
|
993
1095
|
parts.push(payload.prompt);
|
|
994
1096
|
|
|
995
|
-
|
|
1097
|
+
// Stripped once, over the whole assembled prompt, rather than per section.
|
|
1098
|
+
// Every remote-supplied string lands here — the live turn, history bodies,
|
|
1099
|
+
// author and member display names, ambient and anchor messages,
|
|
1100
|
+
// preferences, member notes — and `escapeXmlText` handles only XML
|
|
1101
|
+
// metacharacters, not the Tags Block or bidi controls. One pass at the join
|
|
1102
|
+
// cannot miss a section (the attachment fix strips per site, and its sibling
|
|
1103
|
+
// report is exactly a section it did not reach), and it covers sections
|
|
1104
|
+
// added later without each having to remember. Host-authored scaffolding
|
|
1105
|
+
// never carries these characters, so the pass is a no-op on it. Emoji built
|
|
1106
|
+
// on ZWJ or Tags Block sequences (family, subdivision flags) degrade to
|
|
1107
|
+
// their components for the model — the trade the helper documents,
|
|
1108
|
+
// accepted here for the same reason.
|
|
1109
|
+
return stripInvisibleUnicode(parts.join("\n"));
|
|
996
1110
|
}
|
|
997
1111
|
|
|
998
1112
|
/** Read-only bwrap binds for each resource entry the host mounted into pi's agent dir. */
|
|
@@ -1255,6 +1369,14 @@ function invokePiOnce(
|
|
|
1255
1369
|
blocks: parsed.droppedNarrationBlocks,
|
|
1256
1370
|
});
|
|
1257
1371
|
}
|
|
1372
|
+
if (parsed.narrationOutsideEnvelope) {
|
|
1373
|
+
// Same contract, the other shape: text the model wrote around its
|
|
1374
|
+
// `[reply]` envelope. A run that logs this is one that would have
|
|
1375
|
+
// leaked before the envelope existed.
|
|
1376
|
+
logTiming("container.pi.narration_outside_envelope", {
|
|
1377
|
+
chars: parsed.narrationOutsideEnvelope,
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1258
1380
|
if (parsed.piFailureMessage) {
|
|
1259
1381
|
reject(new Error(parsed.piFailureMessage));
|
|
1260
1382
|
return;
|
|
@@ -24,6 +24,9 @@ export const BLOCKED_ENV_VARS = new Set([
|
|
|
24
24
|
"MERCURY_ADMINS",
|
|
25
25
|
// Host-only: affects `docker run` flags, not the agent process inside the container
|
|
26
26
|
"MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT",
|
|
27
|
+
// Host-only for the same reason: it only decides whether the host applies the
|
|
28
|
+
// sandbox relaxation to `docker run`. Nothing inside reads it.
|
|
29
|
+
"MERCURY_CONTAINER_ALLOW_UNCONFINED",
|
|
27
30
|
// Host-only: selects the OCI runtime for `docker run --runtime`; not meaningful inside the container
|
|
28
31
|
"MERCURY_CONTAINER_RUNTIME",
|
|
29
32
|
// Host-only: resolved volume mountpoint on the host; inner containers don't need it
|
|
@@ -37,6 +40,14 @@ export const BLOCKED_ENV_VARS = new Set([
|
|
|
37
40
|
"MERCURY_TEAMS_APP_ID",
|
|
38
41
|
"MERCURY_TEAMS_APP_PASSWORD",
|
|
39
42
|
"MERCURY_WHATSAPP_AUTH_DIR",
|
|
43
|
+
// Belt to the overview extension's `mercury.env({ hostOnly: true })` brace.
|
|
44
|
+
// That declaration only protects the var while the extension is LOADED: an
|
|
45
|
+
// unclaimed MERCURY_* name falls through the blunt passthrough path, so an
|
|
46
|
+
// operator who sets this and then removes (or fails to load) the extension
|
|
47
|
+
// would ship the management space's id into every container as
|
|
48
|
+
// MANAGEMENT_SPACE_ID -- the one fact overview promises the agent never
|
|
49
|
+
// learns. Blocked here so the guarantee does not depend on load order.
|
|
50
|
+
"MERCURY_MANAGEMENT_SPACE_ID",
|
|
40
51
|
]);
|
|
41
52
|
|
|
42
53
|
/**
|
|
@@ -3,7 +3,8 @@ export type ContainerFailureReason =
|
|
|
3
3
|
| "oom"
|
|
4
4
|
| "aborted"
|
|
5
5
|
| "error"
|
|
6
|
-
| "no-credentials"
|
|
6
|
+
| "no-credentials"
|
|
7
|
+
| "sandbox-unconfined";
|
|
7
8
|
|
|
8
9
|
export class ContainerError extends Error {
|
|
9
10
|
readonly reason: ContainerFailureReason;
|
|
@@ -52,6 +53,16 @@ export class ContainerError extends Error {
|
|
|
52
53
|
);
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The host detected Docker Desktop and would have to strip seccomp/apparmor
|
|
58
|
+
* and grant SYS_ADMIN for bubblewrap to nest — a materially weaker sandbox
|
|
59
|
+
* than the same code gets on a plain Linux host. Refused unless the operator
|
|
60
|
+
* opted in, because a relaxation nobody chose is the one nobody knows about.
|
|
61
|
+
*/
|
|
62
|
+
static sandboxUnconfined(detail: string): ContainerError {
|
|
63
|
+
return new ContainerError("sandbox-unconfined", null, detail);
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
static error(exitCode: number, output: string): ContainerError {
|
|
56
67
|
return new ContainerError(
|
|
57
68
|
"error",
|