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.
@@ -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" — unconditional)
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.** Every space, whatever its profile, receives
64
- the moderation paragraph, the "set a preference" paragraph and the
65
- "simple lists, never raw output" paragraph. A persona that contradicts them
66
- (a group where banter is the norm, a reporter who must not offer lists) has
67
- to say so in its `AGENTS.md`. Removing the paragraphs per space is planned
68
- (`docs/backlog/one-voice-per-space.md`); until then, override in words.
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-22 (M2.3 shipped; audit extension M2.3–M2.5, D-016–D-018; napkin revisit M2.3 re-cut, D-019)
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 | — | in-progress (worktree `worktree-feature-one-voice-per-space`; host; image rebuild) |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.16.2",
3
+ "version": "0.16.3",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -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
- const mercuryPlatform = `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.
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
- ## Moderation
343
- 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.
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
- ## Environment Variables
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
- parts.push(characterChangeFlow);
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(...buildPiAgentMountArgs(globalDir, innerGlobalDir));
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
- ...input,
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
+ }
@@ -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 { loadConfig, resolveProjectPath } from "../config.js";
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}\n`);
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
 
@@ -39,7 +39,11 @@ import {
39
39
  createOperatorAlerter,
40
40
  registerCredentialAlertSink,
41
41
  } from "./operator-alerts.js";
42
- import { hasPermission, resolveRole } from "./permissions.js";
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 = this.db.getProjectConfig("character");
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
- let callerRole = "member";
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));