mercury-agent 0.16.2 → 0.16.3
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/behavior-layers.md +23 -12
- package/docs/goals/football-reporter-profile/roadmap.md +2 -2
- package/examples/profiles/football-reporter/README.md +1 -1
- package/examples/profiles/football-reporter/config.yaml +10 -0
- package/package.json +1 -1
- package/src/agent/container-entry.ts +43 -6
- package/src/agent/container-runner.ts +32 -2
- package/src/cli/build-image-tags.ts +34 -0
- package/src/cli/mercury.ts +50 -4
- package/src/config.ts +5 -3
- package/src/core/routes/config-builtin.ts +7 -0
- package/src/core/routes/dashboard.ts +11 -0
- package/src/core/runtime.ts +31 -11
package/docs/behavior-layers.md
CHANGED
|
@@ -22,23 +22,26 @@ the repo, after a pi upgrade.
|
|
|
22
22
|
2. Mercury platform block .................. image container-entry.ts buildMercuryAdditions
|
|
23
23
|
inbox/outbox · "You are <bot_username>; read /docs/mercury"
|
|
24
24
|
## Permissions & Security ("Permission denied → simply inform the user")
|
|
25
|
-
## Moderation ("warn first, then mute" —
|
|
25
|
+
## Moderation ("warn first, then mute" — only for a caller holding mutes.mute)
|
|
26
26
|
## Environment Variables
|
|
27
27
|
## Current model capabilities
|
|
28
28
|
## Memory (MEMORY.md, <active_episodes>, <members>, member notes)
|
|
29
29
|
## Run budget (tools-capable models only)
|
|
30
30
|
## Destructive Operations — Confirmation Required
|
|
31
31
|
## Presenting tool results ("simple lists", "never show JSON/commands")
|
|
32
|
-
## Character (mrctl character flow + "standing instruction → set a preference"
|
|
32
|
+
## Character (mrctl character flow + "standing instruction → set a preference"
|
|
33
|
+
— only for a caller holding prefs.set)
|
|
33
34
|
reply-anchor sentence (swipe-replies only)
|
|
34
35
|
3. MERCURY_EXT_SYSTEM_PROMPT ............... host runtime.ts ~1700-1760
|
|
35
36
|
a. extension before_container `systemPrompt` fragments
|
|
36
37
|
b. applicative profile `profile_prompt` (.mercury/active-profile.json)
|
|
37
38
|
c. "## Bot Character (set by the owner — applies to all conversations)"
|
|
38
|
-
(project_config.character
|
|
39
|
+
(project_config.character;
|
|
40
|
+
skipped when persona.exclusive=true)
|
|
39
41
|
d. space `system_prompt` (space_config, dashboard-set)
|
|
40
42
|
4. <project_context> ....................... pi resource-loader.js loadProjectContextFiles
|
|
41
43
|
<project_instructions path="/home/mercury/.pi/agent/AGENTS.md"> ← .mercury/global/AGENTS.md
|
|
44
|
+
(not mounted when persona.exclusive=true)
|
|
42
45
|
<project_instructions path="/spaces/<space>/AGENTS.md"> ← the space AGENTS.md
|
|
43
46
|
(pi walks from cwd to /; only the space dir is mounted, so only that
|
|
44
47
|
file and the global one exist. AGENTS.override.md / CLAUDE.md are
|
|
@@ -60,12 +63,20 @@ Two consequences worth reading twice:
|
|
|
60
63
|
than rely on being more specific."* The only reliable override is an
|
|
61
64
|
explicit sentence naming what it overrides, plus deleting the competing
|
|
62
65
|
rule wherever you control it.
|
|
63
|
-
- **Layer 2 is unconditional
|
|
64
|
-
the
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
- **Layer 2 is mostly unconditional, and two voices are removable.** Every
|
|
67
|
+
space receives the security text, the destructive-ops rule and the "simple
|
|
68
|
+
lists, never raw output" paragraph. Two paragraphs are *capability* text
|
|
69
|
+
and are emitted only when the caller can act on them: "## Moderation" for a
|
|
70
|
+
caller holding `mutes.mute`, "## Character" (the preference / character
|
|
71
|
+
flow) for a caller holding `prefs.set` — a member without `prefs.set` is no
|
|
72
|
+
longer told that "a standing instruction → set a preference". And a space
|
|
73
|
+
that owns its standard can set `persona.exclusive=true` (`space_config`,
|
|
74
|
+
profile-owned): the host then leaves the owner's Bot Character (3c) out of
|
|
75
|
+
its prompt and does not mount the global `AGENTS.md` (4), so the space file
|
|
76
|
+
is the only persona left. Everything that still reaches such a space (the
|
|
77
|
+
platform text, skills, a per-space `system_prompt`) is unchanged. A persona
|
|
78
|
+
that contradicts what remains (a reporter who must not offer lists) still
|
|
79
|
+
has to say so in its `AGENTS.md`, in words.
|
|
69
80
|
|
|
70
81
|
## 2. The user prompt, top to bottom
|
|
71
82
|
|
|
@@ -96,12 +107,12 @@ prompt", and it runs as the caller who created it.
|
|
|
96
107
|
|---|---|---|---|
|
|
97
108
|
| pi preamble, Mercury platform block | mercury (code) | `src/agent/container-entry.ts` | **agent image rebuild** (`container/build.sh`), not a restart |
|
|
98
109
|
| `profile_prompt`, `member_permissions` | deploy (applicative profile) | `mercury profiles apply <dir>` → `.mercury/active-profile.json` | **restart** (read at startup) |
|
|
99
|
-
| Bot Character | bot owner (global admin) | `mrctl character set --file`, `PUT /api/character`, dashboard | next run |
|
|
110
|
+
| Bot Character | bot owner (global admin) | `mrctl character set --file`, `PUT /api/character`, dashboard | next run; left out of a `persona.exclusive` space |
|
|
100
111
|
| space `system_prompt` | space admin | dashboard Spaces settings (`space_config.system_prompt`) | next run |
|
|
101
|
-
| global `AGENTS.md` | deploy | `.mercury/global/AGENTS.md` (written by `mercury init` from `resources/templates/AGENTS.md`, overwritten by an applicative profile's `agents_md`) | next run (mounted `:ro`) |
|
|
112
|
+
| global `AGENTS.md` | deploy | `.mercury/global/AGENTS.md` (written by `mercury init` from `resources/templates/AGENTS.md`, overwritten by an applicative profile's `agents_md`) | next run (mounted `:ro`); not mounted for a `persona.exclusive` space |
|
|
102
113
|
| space `AGENTS.md` | space profile / operator | `.mercury/spaces/<space>/AGENTS.md`; `scripts/space-profile.ts apply` | next run (mounted per run) |
|
|
103
114
|
| space preferences | space admin (`prefs.set`) | `mrctl prefs set/delete`; ≤500 chars each, ≤50 per space | next run |
|
|
104
|
-
| space config (`trigger.*`, `context.*`, `ambient.enabled`, `role.<r>.permissions`, `<ext>.<key>`, `model.active`, `system_prompt`) | space admin / dashboard / profile | `mrctl config set`, dashboard, `space-profile apply` (`extension_config`) | next run (per-run read) |
|
|
115
|
+
| space config (`trigger.*`, `context.*`, `ambient.enabled`, `persona.exclusive`, `role.<r>.permissions`, `<ext>.<key>`, `model.active`, `system_prompt`) | space admin / dashboard / profile | `mrctl config set`, dashboard, `space-profile apply` (`extension_config`) | next run (per-run read) |
|
|
105
116
|
| task prompts | whoever owns the `tasks` row | `mrctl tasks create`, dashboard, `space-profile apply` (`tasks:`) | next scheduled run |
|
|
106
117
|
| `MEMORY.md`, `knowledge/**` | the agent (and napkin's jobs) | `write` tool in-container; host edits | next run |
|
|
107
118
|
| extension skills | extension | `mercury.skill("./skill")` → copied to `.mercury/global/skills/<name>/` at startup | **restart** |
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Roadmap: Football Reporter Profile
|
|
2
2
|
|
|
3
3
|
**Goal**: [football-reporter-profile](goal.md)
|
|
4
|
-
**Last updated**: 2026-08-
|
|
4
|
+
**Last updated**: 2026-08-25 (M2.4 shipped — `persona.exclusive` and permission-gated platform sections; deploy pending an image rebuild)
|
|
5
5
|
|
|
6
6
|
> Sequence chosen by the owner on 2026-08-20: (1) profile + one editorial
|
|
7
7
|
> layer → (2) deterministic feed → (3) proof of quality, with a parallel bug
|
|
@@ -58,7 +58,7 @@ the group's reaction to the first two articles is recorded in the goal's notes.
|
|
|
58
58
|
| M2.1 | Feed Watch — host-side poller, digest into the article, verify one-shots (existing spec, absorbed) | feed-watch | M1.1 | done (merged `e07b239` 2026-08-21; **deploy not yet run** — runbook in `pending-verification.md`) |
|
|
59
59
|
| M2.2 | Scheduled task model-leg policy: primary-or-skip for research runs (F2) | scheduled-task-model-leg-policy | — | deferred until after M3.1 (D-015) |
|
|
60
60
|
| M2.3 | Reporter notebook, re-cut 2026-08-21 (D-019): topic notes are napkin-shaped **episodes** the profile seeds once and napkin maintains nightly, written in-turn before posting; seeded `MEMORY.md` for standing facts; priorities tie-break in `AGENTS.md`; `workspace_seed` in `space-profile`; per-space distillation addendum in napkin; member perms and stale-note hygiene (audit R1/R6/R7, D-016, D-019) | reporter-notebook | M1.1 | done (merged 2026-08-22; **deploy not yet run** — runbook in `pending-verification.md`) |
|
|
61
|
-
| M2.4 | One voice per space: `persona.exclusive` drops the global character and global `AGENTS.md` for a space that owns its standard; platform-prompt capability paragraphs gated on the caller's permissions (audit R2, D-017) | one-voice-per-space | — |
|
|
61
|
+
| M2.4 | One voice per space: `persona.exclusive` drops the global character and global `AGENTS.md` for a space that owns its standard; platform-prompt capability paragraphs gated on the caller's permissions (audit R2, D-017) | one-voice-per-space | — | done (merged 2026-08-25; **deploy not yet run** — needs an agent image rebuild, runbook in `pending-verification.md`) |
|
|
62
62
|
| M2.5 | Chat window holds conversation, not procedure (scheduled prompts out of the turn count, no halving on swipe-reply); silence is a legal chat reply (audit R3/R4, D-018) | chat-window-and-silence | — | backlog (host) |
|
|
63
63
|
|
|
64
64
|
> **Revisit 2026-08-21 — napkin works now** (`docs/notes/napkin-revisit-2026-08-21.md`). **Approved by the owner the same day ([D-019](decisions.md)):** M2.3 re-cut so the topic notes are napkin-maintained episodes under `knowledge/episodes/` (the only injected dir) that the profile seeds once, plus a per-space distillation addendum. Nothing removed; M2.4/M2.5/M3.x unchanged. Idea parked: `docs/ideas/napkin-member-notes.md`.
|
|
@@ -17,7 +17,7 @@ One rule lives in exactly one layer. That is the whole point; a rule repeated
|
|
|
17
17
|
| Standing facts | `seed/MEMORY.md` → space `MEMORY.md` | What the group decided and when, open commitments, verified spellings. **Seeded once, then the bot's** | Injected verbatim every turn |
|
|
18
18
|
| Topic notes | `seed/episodes/*.md` → `knowledge/episodes/` | One note per club, competition and saga: `## Current State` of dated, sourced facts. **Seeded once, then the bot's and napkin's** | Injected when the message matches the note's keywords |
|
|
19
19
|
| Distiller guidance | `seed/napkin-distill.md` → `knowledge/.napkin/distill.md` | The entity schema and keyword rules napkin uses for *this* space. **Seeded once** | Appended to napkin's distillation prompt on each nightly run |
|
|
20
|
-
| Extension config | `config.yaml` → `extension_config` | `longview.*`, `feed-watch.*` except `enabled`, `role.member.permissions` | Per run |
|
|
20
|
+
| Extension config | `config.yaml` → `extension_config` | `longview.*`, `feed-watch.*` except `enabled`, `role.member.permissions`, `persona.exclusive` (this `AGENTS.md` is the only persona: the global character and the global `AGENTS.md` stay out of this space's prompt) | Per run |
|
|
21
21
|
| Behaviour preferences | *not in this folder* | `language`, `timezone` | Per run |
|
|
22
22
|
|
|
23
23
|
The two behaviour preferences are deliberately out of scope: they are not
|
|
@@ -139,6 +139,16 @@ extension_config:
|
|
|
139
139
|
# above for why that one stays out of this file.
|
|
140
140
|
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
141
|
|
|
142
|
+
# ── one voice (M2.4, audit R2, D-017) ──────────────────────────────────
|
|
143
|
+
#
|
|
144
|
+
# This space owns its standard: AGENTS.md here is the only persona the model
|
|
145
|
+
# hears. With this on, the host leaves the owner's global character ("Wiz, a
|
|
146
|
+
# personal assistant") out of the system prompt and does not mount the
|
|
147
|
+
# global AGENTS.md ("a helpful AI assistant… ask before acting") for runs in
|
|
148
|
+
# this space. Everything else — skills, models, the platform's security
|
|
149
|
+
# text — is unchanged. Profile-owned so a hand flip shows up as drift.
|
|
150
|
+
persona.exclusive: "true"
|
|
151
|
+
|
|
142
152
|
# Stray items in the space workspace, deleted by apply and logged (D-009).
|
|
143
153
|
# Paths are relative to the space directory and may not escape it.
|
|
144
154
|
workspace_remove:
|
package/package.json
CHANGED
|
@@ -95,6 +95,17 @@ export type Payload = {
|
|
|
95
95
|
anchorMessages?: StoredMessage[];
|
|
96
96
|
prompt: string;
|
|
97
97
|
callerRole?: string;
|
|
98
|
+
/**
|
|
99
|
+
* The caller's effective permission set in this space (`permissions.ts`
|
|
100
|
+
* `getRolePermissions`). Platform-prompt sections that instruct the model to
|
|
101
|
+
* use a tool — "set a preference", "mute" — are emitted only when the caller
|
|
102
|
+
* holds the matching permission, so the model is never told to do a thing
|
|
103
|
+
* and then told to refuse it in the same turn.
|
|
104
|
+
*
|
|
105
|
+
* Optional for the same reason as the budget fields: an older host does not
|
|
106
|
+
* send it, and then every section is emitted exactly as before.
|
|
107
|
+
*/
|
|
108
|
+
callerPermissions?: string[];
|
|
98
109
|
authorName?: string;
|
|
99
110
|
attachments?: MessageAttachment[];
|
|
100
111
|
preferences?: Array<{ key: string; value: string }>;
|
|
@@ -327,7 +338,17 @@ Prioritize practical outputs and explicit assumptions.`;
|
|
|
327
338
|
const raw = process.env.BOT_USERNAME || "Mercury";
|
|
328
339
|
const botName = raw.charAt(0).toUpperCase() + raw.slice(1);
|
|
329
340
|
|
|
330
|
-
|
|
341
|
+
// Which capability paragraphs this caller gets. A host that sends no
|
|
342
|
+
// permission set gets every paragraph, exactly as before the field existed;
|
|
343
|
+
// a host that does send one gets only the paragraphs the caller can act on.
|
|
344
|
+
// Security text is never gated — it describes boundaries, not tools.
|
|
345
|
+
const perms = payload.callerPermissions
|
|
346
|
+
? new Set(payload.callerPermissions)
|
|
347
|
+
: null;
|
|
348
|
+
const callerMay = (permission: string): boolean =>
|
|
349
|
+
perms === null || perms.has(permission);
|
|
350
|
+
|
|
351
|
+
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
352
|
|
|
332
353
|
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
354
|
|
|
@@ -337,14 +358,23 @@ Each run is triggered by a specific caller with a role (admin or member). The ca
|
|
|
337
358
|
- **member**: Limited access. Some tools and extensions are restricted.
|
|
338
359
|
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
360
|
|
|
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
|
|
361
|
+
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
362
|
|
|
342
|
-
|
|
343
|
-
|
|
363
|
+
// Only for a caller who can actually run `mrctl mute`: told to a member
|
|
364
|
+
// without `mutes.mute`, this paragraph is an instruction the next tool call
|
|
365
|
+
// refuses, and the refusal then reads as "inform the user".
|
|
366
|
+
const moderation = `## Moderation
|
|
367
|
+
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
368
|
|
|
345
|
-
|
|
369
|
+
const environmentVariables = `## Environment Variables
|
|
346
370
|
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
371
|
|
|
372
|
+
const mercuryPlatform = [
|
|
373
|
+
platformIntro,
|
|
374
|
+
...(callerMay("mutes.mute") ? [moderation] : []),
|
|
375
|
+
environmentVariables,
|
|
376
|
+
].join("\n\n");
|
|
377
|
+
|
|
348
378
|
const destructiveOps = `## Destructive Operations — Confirmation Required
|
|
349
379
|
|
|
350
380
|
Before deleting, trashing, or permanently removing any data, you MUST stop and confirm with the user first:
|
|
@@ -450,7 +480,14 @@ Your prompt carries a \`<run_budget>\` block with the exact kill time, the total
|
|
|
450
480
|
}
|
|
451
481
|
parts.push(destructiveOps);
|
|
452
482
|
parts.push(toolResultPresentation);
|
|
453
|
-
|
|
483
|
+
// The character / preference flow is a set of `mrctl prefs set` and
|
|
484
|
+
// `mrctl character set` instructions. A caller without `prefs.set` cannot
|
|
485
|
+
// follow them, and the paragraph's "a standing instruction → set a
|
|
486
|
+
// preference" is exactly how a member's joke got escalated to "the admin
|
|
487
|
+
// must approve". Left out for that caller; the security text above stays.
|
|
488
|
+
if (callerMay("prefs.set")) {
|
|
489
|
+
parts.push(characterChangeFlow);
|
|
490
|
+
}
|
|
454
491
|
if (payload.anchorMessages && payload.anchorMessages.length > 0) {
|
|
455
492
|
parts.push(
|
|
456
493
|
`When a \`<reply_anchor>\` block appears in the user prompt, the user is swipe-replying to those specific messages. Address the anchor content directly.`,
|
|
@@ -399,9 +399,19 @@ function modelsJsonIsCredentialFree(file: string): boolean {
|
|
|
399
399
|
export function buildPiAgentMountArgs(
|
|
400
400
|
hostGlobalDir: string,
|
|
401
401
|
innerGlobalDir: string,
|
|
402
|
+
opts: {
|
|
403
|
+
/**
|
|
404
|
+
* False leaves the global `AGENTS.md` entry out of this run's mounts — a
|
|
405
|
+
* persona-exclusive space hears only its own file. Every other entry
|
|
406
|
+
* (skills, `.pi`, models.json, …) mounts regardless. Default true.
|
|
407
|
+
*/
|
|
408
|
+
includeAgentsMd?: boolean;
|
|
409
|
+
} = {},
|
|
402
410
|
): string[] {
|
|
411
|
+
const { includeAgentsMd = true } = opts;
|
|
403
412
|
const args: string[] = [];
|
|
404
413
|
for (const entry of PI_AGENT_RESOURCE_ENTRIES) {
|
|
414
|
+
if (entry === "AGENTS.md" && !includeAgentsMd) continue;
|
|
405
415
|
const hostPath = path.join(hostGlobalDir, entry);
|
|
406
416
|
if (!fs.existsSync(hostPath)) continue;
|
|
407
417
|
if (entry === "models.json" && !modelsJsonIsCredentialFree(hostPath)) {
|
|
@@ -1096,6 +1106,12 @@ export class AgentContainerRunner {
|
|
|
1096
1106
|
prompt: string;
|
|
1097
1107
|
callerId: string;
|
|
1098
1108
|
callerRole?: string;
|
|
1109
|
+
/**
|
|
1110
|
+
* The caller's effective permission set in this space. The container uses
|
|
1111
|
+
* it to leave platform-prompt sections about tools this caller cannot use
|
|
1112
|
+
* out of the system prompt. Absent → every section, as before.
|
|
1113
|
+
*/
|
|
1114
|
+
callerPermissions?: string[];
|
|
1099
1115
|
authorName?: string;
|
|
1100
1116
|
attachments?: MessageAttachment[];
|
|
1101
1117
|
preferences?: Array<{ key: string; value: string }>;
|
|
@@ -1103,6 +1119,12 @@ export class AgentContainerRunner {
|
|
|
1103
1119
|
members?: Array<{ id: string; name?: string; role: string }>;
|
|
1104
1120
|
extraEnv?: Record<string, string>;
|
|
1105
1121
|
claimedEnvSources?: Set<string>;
|
|
1122
|
+
/**
|
|
1123
|
+
* False for a persona-exclusive space (`persona.exclusive`): the global
|
|
1124
|
+
* `AGENTS.md` resource entry is not mounted for this run, so pi's project
|
|
1125
|
+
* context is the space's own file alone. Default true.
|
|
1126
|
+
*/
|
|
1127
|
+
mountGlobalAgentsMd?: boolean;
|
|
1106
1128
|
}): Promise<ContainerResult> {
|
|
1107
1129
|
const globalDir = path.resolve(this.config.globalDir);
|
|
1108
1130
|
const spacesRoot = path.resolve(this.config.spacesDir);
|
|
@@ -1447,7 +1469,11 @@ export class AgentContainerRunner {
|
|
|
1447
1469
|
args.push("-e", "TRACE_RUNS=1");
|
|
1448
1470
|
}
|
|
1449
1471
|
|
|
1450
|
-
args.push(
|
|
1472
|
+
args.push(
|
|
1473
|
+
...buildPiAgentMountArgs(globalDir, innerGlobalDir, {
|
|
1474
|
+
includeAgentsMd: input.mountGlobalAgentsMd !== false,
|
|
1475
|
+
}),
|
|
1476
|
+
);
|
|
1451
1477
|
|
|
1452
1478
|
if (this.config.containerRuntime === "runsc") {
|
|
1453
1479
|
// Mount the per-agent run dir so the inner container can reach the outer's
|
|
@@ -1530,8 +1556,12 @@ export class AgentContainerRunner {
|
|
|
1530
1556
|
const startTime = Date.now();
|
|
1531
1557
|
const deadline = startTime + this.config.containerTimeoutMs;
|
|
1532
1558
|
|
|
1559
|
+
// `mountGlobalAgentsMd` is a host-side mount decision, not something the
|
|
1560
|
+
// agent needs to know — it stays out of the payload.
|
|
1561
|
+
const { mountGlobalAgentsMd: _mountGlobalAgentsMd, ...payloadInput } =
|
|
1562
|
+
input;
|
|
1533
1563
|
const payload = {
|
|
1534
|
-
...
|
|
1564
|
+
...payloadInput,
|
|
1535
1565
|
messages: input.messages,
|
|
1536
1566
|
anchorMessages: input.anchorMessages,
|
|
1537
1567
|
spaceWorkspace: input.spaceWorkspace
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which image name(s) `mercury build` tags.
|
|
3
|
+
*
|
|
4
|
+
* `mercury build` used to hardcode `mercury-agent:latest` while the runtime ran
|
|
5
|
+
* whatever `agentContainerImage` pointed at — by default a ghcr reference. A
|
|
6
|
+
* local build therefore produced an image Mercury never looked at, and the
|
|
7
|
+
* documented "build it yourself" path (the only one that works when ghcr is
|
|
8
|
+
* unreachable) silently did nothing. Tagging the configured name as well is
|
|
9
|
+
* what makes a local build a real substitute for a pull: Docker only reaches
|
|
10
|
+
* for a registry when the reference is absent locally.
|
|
11
|
+
*
|
|
12
|
+
* Kept in its own module because `mercury.ts` calls `program.parse()` at import
|
|
13
|
+
* time and so cannot be imported from a test.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The historical local tag. Still applied, so existing scripts keep working. */
|
|
17
|
+
export const LOCAL_BUILD_IMAGE_TAG = "mercury-agent:latest";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Tags to pass to `docker build`, most specific first, without duplicates.
|
|
21
|
+
*
|
|
22
|
+
* `configuredImage` is `agentContainerImage`. Blank or unresolvable config
|
|
23
|
+
* collapses to just the local tag rather than failing the build — `mercury
|
|
24
|
+
* build` is documented to work with no project directory at all.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveBuildImageTags(
|
|
27
|
+
configuredImage: string | null | undefined,
|
|
28
|
+
): string[] {
|
|
29
|
+
const configured = configuredImage?.trim();
|
|
30
|
+
if (!configured || configured === LOCAL_BUILD_IMAGE_TAG) {
|
|
31
|
+
return [LOCAL_BUILD_IMAGE_TAG];
|
|
32
|
+
}
|
|
33
|
+
return [configured, LOCAL_BUILD_IMAGE_TAG];
|
|
34
|
+
}
|
package/src/cli/mercury.ts
CHANGED
|
@@ -17,7 +17,11 @@ import { homedir, tmpdir } from "node:os";
|
|
|
17
17
|
import { basename, dirname, join, resolve } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { Command } from "commander";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_AGENT_CONTAINER_IMAGE,
|
|
22
|
+
loadConfig,
|
|
23
|
+
resolveProjectPath,
|
|
24
|
+
} from "../config.js";
|
|
21
25
|
import { mergeRawMercuryConfig } from "../config-file.js";
|
|
22
26
|
import { reapProcessTree } from "../core/process-tree.js";
|
|
23
27
|
import { getCatalogEntryByName } from "../extensions/catalog.js";
|
|
@@ -31,6 +35,10 @@ import {
|
|
|
31
35
|
import { RESERVED_EXTENSION_NAMES } from "../extensions/reserved.js";
|
|
32
36
|
import { Db } from "../storage/db.js";
|
|
33
37
|
import { removeSpaceWorkspace } from "../storage/memory.js";
|
|
38
|
+
import {
|
|
39
|
+
LOCAL_BUILD_IMAGE_TAG,
|
|
40
|
+
resolveBuildImageTags,
|
|
41
|
+
} from "./build-image-tags.js";
|
|
34
42
|
import {
|
|
35
43
|
buildGlobalInstallArgs,
|
|
36
44
|
formatLockedDllMessage,
|
|
@@ -360,6 +368,29 @@ async function runAction(): Promise<void> {
|
|
|
360
368
|
|
|
361
369
|
function buildAction(): void {
|
|
362
370
|
// Build from package sources using a temp context — no files needed in user project
|
|
371
|
+
//
|
|
372
|
+
// The image is tagged with whatever `agentContainerImage` resolves to, not a
|
|
373
|
+
// fixed name: the runtime only ever looks for the configured reference, so a
|
|
374
|
+
// build under any other name is invisible to it. Config load is best-effort
|
|
375
|
+
// because this command is documented to run outside a project directory --
|
|
376
|
+
// there may be no .env and no mercury.yaml, and that is not an error.
|
|
377
|
+
let configuredImage: string = DEFAULT_AGENT_CONTAINER_IMAGE;
|
|
378
|
+
try {
|
|
379
|
+
// Inside the guard on purpose: loadEnvFile is a bare readFileSync, and an
|
|
380
|
+
// unreadable .env must not abort the one command that has to keep working
|
|
381
|
+
// in a degraded environment.
|
|
382
|
+
const buildEnvPath = join(CWD, ".env");
|
|
383
|
+
if (existsSync(buildEnvPath)) {
|
|
384
|
+
Object.assign(process.env, loadEnvFile(buildEnvPath));
|
|
385
|
+
}
|
|
386
|
+
configuredImage = loadConfig().agentContainerImage;
|
|
387
|
+
} catch (err) {
|
|
388
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
389
|
+
console.warn(`⚠️ Could not read configuration (${msg}).`);
|
|
390
|
+
console.warn(" Tagging the default image name instead.");
|
|
391
|
+
}
|
|
392
|
+
const imageTags = resolveBuildImageTags(configuredImage);
|
|
393
|
+
|
|
363
394
|
const tmpDir = join(tmpdir(), "mercury-build-context");
|
|
364
395
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
365
396
|
mkdirSync(tmpDir, { recursive: true });
|
|
@@ -414,13 +445,13 @@ function buildAction(): void {
|
|
|
414
445
|
|
|
415
446
|
console.log(`📦 Building container image...`);
|
|
416
447
|
console.log(` Package root: ${PACKAGE_ROOT}`);
|
|
417
|
-
console.log(` Build context: ${tmpDir}
|
|
448
|
+
console.log(` Build context: ${tmpDir}`);
|
|
449
|
+
console.log(` Tags: ${imageTags.join(", ")}\n`);
|
|
418
450
|
const result = spawnSync(
|
|
419
451
|
"docker",
|
|
420
452
|
[
|
|
421
453
|
"build",
|
|
422
|
-
"-t",
|
|
423
|
-
"mercury-agent:latest",
|
|
454
|
+
...imageTags.flatMap((tag) => ["-t", tag]),
|
|
424
455
|
"-f",
|
|
425
456
|
join(tmpDir, "container/Dockerfile"),
|
|
426
457
|
tmpDir,
|
|
@@ -431,6 +462,21 @@ function buildAction(): void {
|
|
|
431
462
|
if (result.status !== 0) {
|
|
432
463
|
process.exit(result.status ?? 1);
|
|
433
464
|
}
|
|
465
|
+
|
|
466
|
+
console.log(`
|
|
467
|
+
✓ Built ${imageTags.join(" and ")}`);
|
|
468
|
+
console.log(
|
|
469
|
+
` Mercury runs ${imageTags[0]} — no registry pull needed for it.`,
|
|
470
|
+
);
|
|
471
|
+
if (imageTags[0] !== LOCAL_BUILD_IMAGE_TAG) {
|
|
472
|
+
console.log(
|
|
473
|
+
` Note: this shadows the registry copy. Docker only pulls a reference`,
|
|
474
|
+
);
|
|
475
|
+
console.log(
|
|
476
|
+
` that is absent locally, so re-run 'mercury build' after upgrading,`,
|
|
477
|
+
);
|
|
478
|
+
console.log(` or 'docker rmi' it to go back to pulling.`);
|
|
479
|
+
}
|
|
434
480
|
} finally {
|
|
435
481
|
// Clean up temp context
|
|
436
482
|
rmSync(tmpDir, { recursive: true, force: true });
|
package/src/config.ts
CHANGED
|
@@ -51,6 +51,10 @@ const booleanFromEnv = z.union([z.boolean(), z.string()]).transform((val) => {
|
|
|
51
51
|
return lower === "true" || lower === "1";
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
/** Base agent image used when nothing overrides `agent.image`. */
|
|
55
|
+
export const DEFAULT_AGENT_CONTAINER_IMAGE =
|
|
56
|
+
"ghcr.io/avishai-tsabari/mercury-agent:latest";
|
|
57
|
+
|
|
54
58
|
const schema = z.object({
|
|
55
59
|
// ─── API Key Mode ───────────────────────────────────────────────────
|
|
56
60
|
apiKeyMode: z.enum(["platform", "byok"]).default("platform"),
|
|
@@ -136,9 +140,7 @@ const schema = z.object({
|
|
|
136
140
|
whatsappAuthDir: z.string().optional(),
|
|
137
141
|
|
|
138
142
|
// ─── Container / Agent ──────────────────────────────────────────────
|
|
139
|
-
agentContainerImage: z
|
|
140
|
-
.string()
|
|
141
|
-
.default("ghcr.io/avishai-tsabari/mercury-agent:latest"),
|
|
143
|
+
agentContainerImage: z.string().default(DEFAULT_AGENT_CONTAINER_IMAGE),
|
|
142
144
|
containerTimeoutMs: z.coerce
|
|
143
145
|
.number()
|
|
144
146
|
.int()
|
|
@@ -16,6 +16,7 @@ export const BUILTIN_CONFIG_KEYS = new Set([
|
|
|
16
16
|
"rate_limit.admin",
|
|
17
17
|
"debounce.idle_timeout_ms",
|
|
18
18
|
"messages.locale",
|
|
19
|
+
"persona.exclusive",
|
|
19
20
|
]);
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -55,6 +56,8 @@ export const BUILTIN_CONFIG_DESCRIPTIONS: Record<string, string> = {
|
|
|
55
56
|
"Milliseconds to wait for additional messages before processing a batch. 0 disables debounce. Platform default: 2000 for WhatsApp/Telegram, 0 for others.",
|
|
56
57
|
"messages.locale":
|
|
57
58
|
"Language for Mercury-generated system messages (rate limits, errors, denials) in this space: 'en' or 'he'. Unset falls back to the deployment default.",
|
|
59
|
+
"persona.exclusive":
|
|
60
|
+
"When true, this space's AGENTS.md is the only persona the model hears: the owner's global character and the global AGENTS.md are left out of its prompt. Unset/false keeps both (the default).",
|
|
58
61
|
};
|
|
59
62
|
|
|
60
63
|
const BUILTIN_VALIDATORS: Record<string, (v: string) => string | null> = {
|
|
@@ -126,6 +129,10 @@ const BUILTIN_VALIDATORS: Record<string, (v: string) => string | null> = {
|
|
|
126
129
|
["en", "he"].includes(v)
|
|
127
130
|
? null
|
|
128
131
|
: "Invalid messages.locale value. Valid: en, he",
|
|
132
|
+
"persona.exclusive": (v) =>
|
|
133
|
+
["true", "false"].includes(v)
|
|
134
|
+
? null
|
|
135
|
+
: "Invalid persona.exclusive value. Valid: true, false",
|
|
129
136
|
};
|
|
130
137
|
|
|
131
138
|
export function isBuiltinConfigKey(key: string): boolean {
|
|
@@ -379,6 +379,9 @@ export function createDashboardRoutes(ctx: DashboardContext) {
|
|
|
379
379
|
});
|
|
380
380
|
const ambientOn =
|
|
381
381
|
core.db.getSpaceConfig(spaceId, "ambient.enabled") !== "false";
|
|
382
|
+
// Absent is false: a space opts *in* to owning its persona.
|
|
383
|
+
const personaExclusive =
|
|
384
|
+
core.db.getSpaceConfig(spaceId, "persona.exclusive") === "true";
|
|
382
385
|
// Read through the same helper the router uses, so the default cannot
|
|
383
386
|
// drift between what this page shows and what actually gates a message.
|
|
384
387
|
const mentionAlwaysOn = mentionAlwaysEnabled(core.db, spaceId);
|
|
@@ -469,6 +472,14 @@ export function createDashboardRoutes(ctx: DashboardContext) {
|
|
|
469
472
|
${row("trigger.media_in_groups", "trigger.media_in_groups", boolSelect("trigger.media_in_groups", tc.mediaInGroups))}
|
|
470
473
|
${row("trigger.mention_always", "trigger.mention_always", boolSelect("trigger.mention_always", mentionAlwaysOn))}
|
|
471
474
|
${row("ambient.enabled", "ambient.enabled", boolSelect("ambient.enabled", ambientOn))}
|
|
475
|
+
<p class="muted" style="margin:14px 0 10px;line-height:1.5">
|
|
476
|
+
<span class="mono">persona.exclusive</span> — when true, this space's
|
|
477
|
+
<span class="mono">AGENTS.md</span> is the only persona the model hears:
|
|
478
|
+
the owner's global character and the global
|
|
479
|
+
<span class="mono">AGENTS.md</span> are left out of its prompt. Off by
|
|
480
|
+
default; meant for a space whose standard is repo-owned.
|
|
481
|
+
</p>
|
|
482
|
+
${row("persona.exclusive", "persona.exclusive", boolSelect("persona.exclusive", personaExclusive))}
|
|
472
483
|
`;
|
|
473
484
|
}
|
|
474
485
|
|
package/src/core/runtime.ts
CHANGED
|
@@ -39,7 +39,11 @@ import {
|
|
|
39
39
|
createOperatorAlerter,
|
|
40
40
|
registerCredentialAlertSink,
|
|
41
41
|
} from "./operator-alerts.js";
|
|
42
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
getRolePermissions,
|
|
44
|
+
hasPermission,
|
|
45
|
+
resolveRole,
|
|
46
|
+
} from "./permissions.js";
|
|
43
47
|
import { reapProcessTree } from "./process-tree.js";
|
|
44
48
|
import { getActiveProfilePrompt } from "./profiles.js";
|
|
45
49
|
import { RateLimiter } from "./rate-limiter.js";
|
|
@@ -1731,8 +1735,17 @@ export class MercuryCoreRuntime {
|
|
|
1731
1735
|
};
|
|
1732
1736
|
}
|
|
1733
1737
|
|
|
1738
|
+
// A space that owns its editorial standard can declare its AGENTS.md the
|
|
1739
|
+
// only persona: the owner's global character is left out of this run's
|
|
1740
|
+
// system prompt and the global AGENTS.md is not mounted (see the runner
|
|
1741
|
+
// input below). Absent is false — every other space is byte-identical.
|
|
1742
|
+
const personaExclusive =
|
|
1743
|
+
this.db.getSpaceConfig(spaceId, "persona.exclusive") === "true";
|
|
1744
|
+
|
|
1734
1745
|
// Inject global character (owner-set voice, deploy-safe, global-admin gated).
|
|
1735
|
-
const characterPrompt =
|
|
1746
|
+
const characterPrompt = personaExclusive
|
|
1747
|
+
? null
|
|
1748
|
+
: this.db.getProjectConfig("character");
|
|
1736
1749
|
if (characterPrompt) {
|
|
1737
1750
|
const existing = extraEnv?.MERCURY_EXT_SYSTEM_PROMPT;
|
|
1738
1751
|
const block = `## Bot Character (set by the owner — applies to all conversations)\n${characterPrompt}`;
|
|
@@ -1849,16 +1862,21 @@ export class MercuryCoreRuntime {
|
|
|
1849
1862
|
}
|
|
1850
1863
|
|
|
1851
1864
|
// Compute caller role, denied CLIs, and permitted env vars
|
|
1852
|
-
|
|
1865
|
+
const seededAdmins = this.config.admins
|
|
1866
|
+
? this.config.admins
|
|
1867
|
+
.split(",")
|
|
1868
|
+
.map((s) => s.trim())
|
|
1869
|
+
.filter(Boolean)
|
|
1870
|
+
: [];
|
|
1871
|
+
const callerRole = resolveRole(this.db, spaceId, callerId, seededAdmins);
|
|
1872
|
+
// The caller's effective permission set, handed to the container so the
|
|
1873
|
+
// platform prompt can leave out instructions about tools this caller
|
|
1874
|
+
// cannot use ("set a preference", "mute") instead of telling the model to
|
|
1875
|
+
// do a thing and then to refuse it in the same turn.
|
|
1876
|
+
const callerPermissions = [
|
|
1877
|
+
...getRolePermissions(this.db, spaceId, callerRole),
|
|
1878
|
+
];
|
|
1853
1879
|
if (this.extensionRegistry) {
|
|
1854
|
-
const seededAdmins = this.config.admins
|
|
1855
|
-
? this.config.admins
|
|
1856
|
-
.split(",")
|
|
1857
|
-
.map((s) => s.trim())
|
|
1858
|
-
.filter(Boolean)
|
|
1859
|
-
: [];
|
|
1860
|
-
callerRole = resolveRole(this.db, spaceId, callerId, seededAdmins);
|
|
1861
|
-
|
|
1862
1880
|
const cliExtensions = this.extensionRegistry.getCliExtensions();
|
|
1863
1881
|
if (cliExtensions.length > 0) {
|
|
1864
1882
|
const denied = cliExtensions
|
|
@@ -1950,12 +1968,14 @@ export class MercuryCoreRuntime {
|
|
|
1950
1968
|
prompt: finalPrompt,
|
|
1951
1969
|
callerId,
|
|
1952
1970
|
callerRole,
|
|
1971
|
+
callerPermissions,
|
|
1953
1972
|
authorName,
|
|
1954
1973
|
attachments: effectiveAttachments,
|
|
1955
1974
|
preferences,
|
|
1956
1975
|
members,
|
|
1957
1976
|
extraEnv,
|
|
1958
1977
|
claimedEnvSources: this.extensionRegistry?.getClaimedEnvSources(),
|
|
1978
|
+
mountGlobalAgentsMd: !personaExclusive,
|
|
1959
1979
|
});
|
|
1960
1980
|
} catch (err) {
|
|
1961
1981
|
this.db.updateMessageRunMeta(userMessageId, userTurnRunMeta(undefined));
|