mercury-agent 0.19.0 → 0.21.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.
Files changed (137) hide show
  1. package/container/Dockerfile +25 -0
  2. package/container/build.sh +23 -3
  3. package/docs/behavior-layers.md +29 -16
  4. package/docs/configuration.md +154 -11
  5. package/docs/container-lifecycle.md +151 -1
  6. package/docs/context-architecture.md +6 -2
  7. package/docs/extensions.md +9 -1
  8. package/docs/goals/feed-watch-social-sources/blocked-sources-research.md +289 -0
  9. package/docs/goals/feed-watch-social-sources/decisions.md +112 -0
  10. package/docs/goals/feed-watch-social-sources/goal.md +217 -0
  11. package/docs/goals/feed-watch-social-sources/roadmap.md +203 -0
  12. package/docs/goals/football-match-day-mode/decisions.md +209 -0
  13. package/docs/goals/football-match-day-mode/goal.md +109 -0
  14. package/docs/goals/football-match-day-mode/roadmap.md +208 -0
  15. package/docs/goals/football-reporter-profile/decisions.md +79 -0
  16. package/docs/goals/football-reporter-profile/roadmap.md +4 -4
  17. package/docs/goals/rehearsal-bench/decisions.md +13 -0
  18. package/docs/goals/rehearsal-bench/goal.md +2 -2
  19. package/docs/goals/rehearsal-bench/roadmap.md +1 -1
  20. package/docs/goals/release-gate/decisions.md +53 -7
  21. package/docs/goals/release-gate/goal.md +2 -2
  22. package/docs/goals/release-gate/roadmap.md +23 -10
  23. package/docs/live-testing.md +55 -2
  24. package/docs/memory.md +1 -1
  25. package/docs/pending-verification.md +374 -0
  26. package/docs/permissions.md +1 -1
  27. package/docs/profile-guide.md +30 -10
  28. package/docs/subagents.md +60 -9
  29. package/examples/extensions/archive/backends/local.ts +6 -3
  30. package/examples/extensions/archive/queue.ts +143 -14
  31. package/examples/extensions/feed-watch/config.ts +42 -2
  32. package/examples/extensions/feed-watch/digest.ts +4 -7
  33. package/examples/extensions/feed-watch/feeds.ts +246 -13
  34. package/examples/extensions/feed-watch/index.ts +84 -15
  35. package/examples/extensions/feed-watch/items.ts +18 -7
  36. package/examples/extensions/feed-watch/match.ts +125 -9
  37. package/examples/extensions/feed-watch/skill/SKILL.md +25 -0
  38. package/examples/extensions/feed-watch/watch.ts +51 -6
  39. package/examples/extensions/gws/index.ts +30 -3
  40. package/examples/extensions/longview/hook.ts +230 -15
  41. package/examples/extensions/longview/index.ts +2 -0
  42. package/examples/extensions/morning/index.ts +13 -11
  43. package/examples/extensions/napkin/index.ts +12 -3
  44. package/examples/extensions/napkin/pi-spawn.ts +5 -1
  45. package/examples/extensions/overview/index.ts +20 -0
  46. package/examples/extensions/overview/skill/SKILL.md +9 -1
  47. package/examples/extensions/pinchtab/index.ts +36 -7
  48. package/examples/extensions/pinchtab/skill/SKILL.md +28 -1
  49. package/examples/profiles/_template/AGENTS.md +6 -1
  50. package/examples/profiles/football-reporter/AGENTS.md +21 -4
  51. package/examples/profiles/football-reporter/README.md +8 -2
  52. package/examples/profiles/football-reporter/config.yaml +42 -6
  53. package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
  54. package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +1 -1
  55. package/examples/profiles/football-reporter/seed/episodes/maccabi-tel-aviv-2026-27.md +24 -0
  56. package/examples/profiles/football-reporter/seed/napkin-distill.md +30 -29
  57. package/examples/profiles/football-reporter/standard.json +67 -8
  58. package/package.json +8 -6
  59. package/resources/agents/explore.md +0 -1
  60. package/resources/agents/worker.md +0 -1
  61. package/resources/pi-extensions/subagent/agents.ts +123 -99
  62. package/resources/pi-extensions/subagent/index.ts +1179 -885
  63. package/resources/pi-extensions/subagent/spawn.ts +229 -0
  64. package/resources/skills/tasks/SKILL.md +17 -1
  65. package/resources/templates/AGENTS.md +4 -3
  66. package/resources/templates/mercury.example.yaml +13 -4
  67. package/src/adapters/whatsapp-ingress.ts +15 -3
  68. package/src/agent/container-entry.ts +315 -31
  69. package/src/agent/container-env.ts +26 -8
  70. package/src/agent/container-error.ts +13 -1
  71. package/src/agent/container-runner.ts +720 -126
  72. package/src/agent/image-contract.ts +77 -0
  73. package/src/agent/image-manifest.ts +180 -0
  74. package/src/agent/image-refresh.ts +342 -0
  75. package/src/agent/instance-id.ts +185 -0
  76. package/src/cli/mercury.ts +1457 -240
  77. package/src/cli/mrctl-http.ts +5 -0
  78. package/src/cli/mrctl.ts +57 -5
  79. package/src/cli/service-unit.ts +109 -0
  80. package/src/config-file.ts +73 -2
  81. package/src/config.ts +106 -15
  82. package/src/core/api.ts +11 -2
  83. package/src/core/commands.ts +16 -5
  84. package/src/core/connection-health.ts +377 -0
  85. package/src/core/conversation.ts +8 -1
  86. package/src/core/direct-send.ts +235 -14
  87. package/src/core/exec.ts +10 -0
  88. package/src/core/handler.ts +28 -1
  89. package/src/core/history-window.ts +142 -0
  90. package/src/core/model-command.ts +130 -0
  91. package/src/core/operator-alerts.ts +349 -28
  92. package/src/core/permissions.ts +28 -0
  93. package/src/core/profiles.ts +32 -16
  94. package/src/core/reply-context.ts +31 -0
  95. package/src/core/router.ts +27 -0
  96. package/src/core/routes/chat.ts +1 -0
  97. package/src/core/routes/config-builtin.ts +11 -0
  98. package/src/core/routes/console.ts +98 -16
  99. package/src/core/routes/dashboard.ts +102 -13
  100. package/src/core/routes/model.ts +26 -3
  101. package/src/core/routes/send.ts +1 -1
  102. package/src/core/routes/tasks.ts +74 -0
  103. package/src/core/runtime.ts +199 -25
  104. package/src/core/system-messages.ts +33 -0
  105. package/src/core/task-scheduler.ts +155 -7
  106. package/src/env-dump/build-deps.ts +128 -0
  107. package/src/env-dump/build.ts +572 -0
  108. package/src/env-dump/deps.ts +84 -0
  109. package/src/env-dump/manifest.ts +430 -0
  110. package/src/extensions/image-builder.ts +67 -3
  111. package/src/extensions/installer.ts +72 -17
  112. package/src/extensions/load-project.ts +74 -0
  113. package/src/extensions/loader.ts +50 -9
  114. package/src/host-version.ts +32 -0
  115. package/src/logger.ts +13 -0
  116. package/src/main.ts +152 -44
  117. package/src/ops/shadow-service.ts +901 -0
  118. package/src/ops/shadow-snapshot.ts +34 -1
  119. package/src/preflight/build-deps.ts +112 -0
  120. package/src/preflight/checks/credential.ts +304 -0
  121. package/src/preflight/checks/docker.ts +149 -0
  122. package/src/preflight/checks/extensions.ts +90 -0
  123. package/src/preflight/checks/host-deps.ts +272 -0
  124. package/src/preflight/checks/image-contract.ts +228 -0
  125. package/src/preflight/checks/roundtrip.ts +424 -0
  126. package/src/preflight/checks/sandbox.ts +159 -0
  127. package/src/preflight/deps.ts +107 -0
  128. package/src/preflight/doctor-lines.ts +74 -0
  129. package/src/preflight/platform-path.ts +33 -0
  130. package/src/preflight/probe-container.ts +178 -0
  131. package/src/preflight/report.ts +290 -0
  132. package/src/preflight/run.ts +301 -0
  133. package/src/server.ts +81 -14
  134. package/src/storage/db.ts +41 -2
  135. package/src/storage/models-json.ts +110 -0
  136. package/src/text/reporter-lint.ts +231 -23
  137. package/src/types.ts +22 -0
@@ -125,6 +125,31 @@ RUN while IFS= read -r ext || [ -n "$ext" ]; do \
125
125
  RUN echo '#!/bin/sh\nbun run /app/src/cli/mrctl.ts "$@"' > /usr/local/bin/mrctl && \
126
126
  chmod +x /usr/local/bin/mrctl
127
127
 
128
+ # --- Self-description -------------------------------------------------------
129
+ # Last, deliberately: MERCURY_VERSION changes on every release and an ARG
130
+ # invalidates every layer after it, so anything above this line stays cached
131
+ # across versions.
132
+ #
133
+ # The manifest is generated from THIS Dockerfile, inside the build, rather than
134
+ # from a copy in the repo — a manifest written beside the Dockerfile can drift
135
+ # from it, and a drift check whose own input has drifted is worse than none.
136
+ # `container/Dockerfile` is copied in for that reason and kept, so a running
137
+ # container can be asked what it was built from.
138
+ ARG MERCURY_VERSION=unknown
139
+ COPY container/Dockerfile /app/container/Dockerfile
140
+ COPY src/agent/image-manifest.ts /app/src/agent/image-manifest.ts
141
+ RUN bun run /app/src/agent/image-manifest.ts \
142
+ /app/container/Dockerfile "$MERCURY_VERSION" > /app/image-manifest.json && \
143
+ grep -q 'src/agent/container-entry.ts' /app/image-manifest.json
144
+
145
+ # Read by the host's version check through the derived mercury-agent-ext-*
146
+ # image, which inherits labels from this base with no builder change.
147
+ LABEL org.opencontainers.image.version="${MERCURY_VERSION}" \
148
+ org.opencontainers.image.title="mercury-agent" \
149
+ org.opencontainers.image.source="https://github.com/Avishai-Tsabari/mercury" \
150
+ com.mercury.version="${MERCURY_VERSION}" \
151
+ com.mercury.image-manifest="/app/image-manifest.json"
152
+
128
153
  USER mercury
129
154
 
130
155
  ENTRYPOINT ["bun", "run", "/app/src/agent/container-entry.ts"]
@@ -16,8 +16,28 @@ if [ $# -gt 0 ] && [ "$1" != "latest" ]; then
16
16
  exit 1
17
17
  fi
18
18
 
19
- echo "Building ${IMAGE_NAME}:latest (full devcontainer)..."
20
- docker build -f container/Dockerfile -t "${IMAGE_NAME}:latest" .
21
- echo "✓ Built ${IMAGE_NAME}:latest"
19
+ # Taken from package.json, never typed here: a hand-written version is a second
20
+ # source of truth, and the whole point of stamping the image is that the host
21
+ # and the image cannot disagree about which release this is.
22
+ VERSION="$(bun -e 'console.log(require("./package.json").version)')"
23
+ if [ -z "$VERSION" ]; then
24
+ echo "Could not read the version from package.json — refusing to build an unlabelled image." >&2
25
+ exit 1
26
+ fi
27
+
28
+ echo "Building ${IMAGE_NAME}:${VERSION} (full devcontainer)..."
29
+ docker build -f container/Dockerfile \
30
+ --build-arg "MERCURY_VERSION=${VERSION}" \
31
+ -t "${IMAGE_NAME}:${VERSION}" \
32
+ -t "${IMAGE_NAME}:latest" .
33
+
34
+ # Both tags, on purpose. `:latest` is what src/config.ts still defaults to, so
35
+ # dropping it would break every existing host; `:<version>` is what a host can
36
+ # pin to once it stops trusting a floating tag (release-gate R0.4).
37
+ echo "✓ Built ${IMAGE_NAME}:${VERSION} and ${IMAGE_NAME}:latest"
38
+ echo ""
39
+ echo "Version label:"
40
+ docker image inspect "${IMAGE_NAME}:${VERSION}" \
41
+ -f ' com.mercury.version = {{index .Config.Labels "com.mercury.version"}}'
22
42
  echo ""
23
43
  echo "Build complete!"
@@ -16,9 +16,13 @@ the repo, after a pi upgrade.
16
16
  ## 1. The system prompt, top to bottom
17
17
 
18
18
  ```
19
- 1. pi preamble ............................ image container-entry.ts buildSystemPrompt
19
+ 1. Mercury standalone preamble ............ image container-entry.ts buildSystemPrompt
20
20
  "You are an expert AI assistant…", tool list, "Be concise"
21
- (override mode; OVERRIDE_PI_SYSTEM_PROMPT)
21
+ Replaces pi's own built-in prompt — this text is Mercury's, not pi's.
22
+ (override mode; MERCURY_OVERRIDE_PI_SYSTEM_PROMPT, default since 2026-09-03.
23
+ Set it to false and pi's "…operating inside pi, a coding agent harness"
24
+ prompt takes this slot instead — which an Anthropic OAuth credential
25
+ cannot use; see docs/configuration.md.)
22
26
  2. Mercury platform block .................. image container-entry.ts buildMercuryAdditions
23
27
  inbox/outbox · "You are <bot_username>; read /docs/mercury"
24
28
  ## Permissions & Security ("Permission denied → simply inform the user")
@@ -32,7 +36,9 @@ the repo, after a pi upgrade.
32
36
  ## Delivery ("[reply] … [/reply] — only the span inside is sent";
33
37
  pi-jsonl-parser.ts extractReplyEnvelope honours it,
34
38
  a reply with no markers is sent whole)
35
- ## Character (mrctl character flow + "standing instruction → set a preference"
39
+ ## Character (heading + "Always follow it" + "only the owner can change it"
40
+ — unconditional, it is the reader instruction for 3c;
41
+ the mrctl character/prefs procedure under it
36
42
  — only for a caller holding prefs.set)
37
43
  reply-anchor sentence (swipe-replies only)
38
44
  3. MERCURY_EXT_SYSTEM_PROMPT ............... host runtime.ts ~1700-1760
@@ -66,17 +72,22 @@ Two consequences worth reading twice:
66
72
  than rely on being more specific."* The only reliable override is an
67
73
  explicit sentence naming what it overrides, plus deleting the competing
68
74
  rule wherever you control it.
69
- - **Layer 2 is mostly unconditional, and two voices are removable.** Every
75
+ - **Layer 2 is mostly unconditional, and one voice is removable.** Every
70
76
  space receives the security text, the destructive-ops rule and the "simple
71
- lists, never raw output" paragraph. Two paragraphs are *capability* text
72
- and are emitted only when the caller can act on them: "## Moderation" for a
73
- caller holding `mutes.mute`, "## Character" (the preference / character
74
- flow) for a caller holding `prefs.set` — a member without `prefs.set` is no
75
- longer told that "a standing instruction → set a preference". And a space
76
- that owns its standard can set `persona.exclusive=true` (`space_config`,
77
- profile-owned): the host then leaves the owner's Bot Character (3c) out of
78
- its prompt and does not mount the global `AGENTS.md` (4), so the space file
79
- is the only persona left. Everything that still reaches such a space (the
77
+ lists, never raw output" paragraph. One whole paragraph is *capability* text
78
+ and is emitted only when the caller can act on it: "## Moderation", for a
79
+ caller holding `mutes.mute`. "## Character" is **split** since `7d242e2`:
80
+ the heading, "Always follow it", the fact that only the owner can change the
81
+ character and the one-line decline for a personality/tone/greeting request
82
+ ship to *every* caller (`container-entry.ts:426-432`, ungated — they state a
83
+ fact and a refusal and carry no procedure), while only the `mrctl character
84
+ get/set` + `mrctl prefs *` procedure under them is gated on `prefs.set`. So a
85
+ member is told the rule and gets a one-line decline; what they no longer see
86
+ is the procedure, not the section. And a space that owns its standard can set
87
+ `persona.exclusive=true` (`space_config`, profile-owned): the host then
88
+ leaves the owner's Bot Character (3c) out of its prompt and does not mount
89
+ the global `AGENTS.md` (4), so the space file is the only persona left.
90
+ Everything that still reaches such a space (the
80
91
  platform text, skills, a per-space `system_prompt`) is unchanged. A persona
81
92
  that contradicts what remains (a reporter who must not offer lists) still
82
93
  has to say so in its `AGENTS.md`, in words.
@@ -134,8 +145,10 @@ prompt", and it runs as the caller who created it.
134
145
  ## 4. The history window
135
146
 
136
147
  - `context.mode` is a per-space key. `main` and DM auto-spaces are seeded
137
- `context`; **any other space defaults to `clear`** (no history at all — only
138
- the reply chain when someone swipe-replies). Set it explicitly.
148
+ with the host default (`contextMode` / `MERCURY_CONTEXT_MODE`, `context`
149
+ unless set); **any space without a row runs on that same host default**.
150
+ `clear` means no history at all — only the reply chain when someone
151
+ swipe-replies. Set the key explicitly on a space that should differ.
139
152
  - `context.window_size` (default 10 — `config.ts contextWindowSize`) counts
140
153
  **user turns**: `getRecentTurns` takes the newest `turnCount*5` non-ambient
141
154
  rows after the compact boundary and cuts at the Nth user row. Assistant rows
@@ -158,7 +171,7 @@ it is still inside the window.
158
171
  | Surface | File | Injected | Condition |
159
172
  |---|---|---|---|
160
173
  | Episodic memory | `MEMORY.md` in the space dir | every run, verbatim, as `<episodic_memory>` | file exists and is non-empty. No size cap is enforced — the prompt asks for ~1500 tokens |
161
- | Active episodes | `knowledge/episodes/*.md` | per run, as `<active_episodes>`, ≤800 tokens | frontmatter `status: active|cooling`, `keywords: ["…"]` (JSON array); score = keyword overlap × (active 1.0 / cooling 0.5) × log(mentions+1); body = `summary:` + `## Current State` |
174
+ | Active episodes | `knowledge/episodes/*.md` | per run, as `<active_episodes>`, ≤800 tokens | frontmatter `status: active|cooling`, `keywords: ["…"]` (JSON array); score = keyword overlap × (active 1.0 / cooling 0.5) × log(mentions+1); a message word matches a keyword exactly, or after dropping up to three leading Hebrew prefix letters (ו ה ב ל מ ש כ) when at least four characters remain; body = `summary:` + `## Current State` |
162
175
  | Member notes | `knowledge/members/<id>.md` | per run, as `<member_notes>` | `<id>` = platform id with `/`→`_`; ≤6000 chars total |
163
176
  | Members | `space_roles` table | per run, as `<members>` | always; authoritative identity |
164
177
  | Preferences | `space_preferences` | per run, as `<preferences>` | always |
@@ -35,15 +35,80 @@ context:
35
35
  mode: context # clear | context (default: context)
36
36
  window_size: 10 # 1-50 (default: 10). Sliding-window turns when mode=context.
37
37
  reply_chain_depth: 10 # 1-50 (default: 10). Reply chain depth when mode=clear.
38
+ history_max_chars: 2000 # 0-100000 (default: 2000, 0 = off). Per-message cap on the window.
38
39
  ```
39
40
 
40
- Per-space overrides via `mrctl config set context.<key> <value>` always win over YAML defaults; YAML re-reads on restart do not overwrite an existing space row.
41
+ Per-space overrides via `mrctl config set context.<key> <value>` always win over YAML defaults; YAML re-reads on restart do not overwrite an existing space row. This holds for `history_max_chars` too — it is a builtin space key with a dashboard row, which is what makes it usable: the pathology is space-specific, since one space publishes articles and another only chats.
42
+
43
+ ### Why `history_max_chars` exists
44
+
45
+ `window_size` counts *turns*, and a turn has no size limit. A space that
46
+ publishes rather than chats therefore pays for its last ten whole articles on
47
+ every round of every run — `<history>` measured 65-81% of the football space's
48
+ prompt. Older messages in the window are clipped to `history_max_chars` and
49
+ carry an in-band `[truncated: N of M characters omitted...]` notice so the model
50
+ can tell a clipped message from one that ended where it looks like it ended.
51
+
52
+ Never clipped: the newest turn, the swipe-reply anchor, reply chains in
53
+ `clear` mode, ambient rows (they have their own budget), and the stored message
54
+ itself — only the copy sent to the container is shortened. Set `0` to disable.
55
+ Each time the cap bites, the host logs `History window: older messages clipped`
56
+ with the space, `clippedRows` and `savedChars`.
57
+
58
+ Per space: `mrctl config set context.history_max_chars 4000`, or the
59
+ `context.history_max_chars` row on the dashboard's Context panel.
60
+
61
+ ## Missed scheduled runs (`scheduling.catch_up_window_ms`, per-task `catch_up`)
62
+
63
+ Mercury does not fire a cron task on boot for the occurrences it slept through.
64
+ That default is deliberate — a restart during a *failing* task must not replay
65
+ it — but until it was reported, an outage longer than a task's interval lost the
66
+ run with no log naming it and nobody told.
67
+
68
+ Now every boot names what it skipped:
69
+
70
+ ```
71
+ WARN Missed 2 scheduled run(s) while the scheduler was down
72
+ taskId=24 name="daily article" oldest=2026-09-05T06:00:00Z
73
+ newest=2026-09-06T06:00:00Z policy=skip
74
+ ```
75
+
76
+ and reports it in chat — to the task's creator, or to the task's own space when
77
+ the creator has no conversation Mercury can reach. The report is gated by
78
+ `alerts.enabled` (below) and honours no cooldown: the same boot pass advances
79
+ `next_run_at` past the misses, so the same occurrences can never be reported
80
+ twice.
81
+
82
+ A task can opt into **one** make-up run:
83
+
84
+ ```bash
85
+ mrctl tasks catch-up 24 once # or: skip, the default
86
+ mrctl tasks create --cron "0 9 * * *" --prompt "..." --catch-up once
87
+ ```
88
+
89
+ `once` marks the task due immediately at boot — once, however many occurrences
90
+ were missed — provided the newest of them is recent enough:
91
+
92
+ ```yaml
93
+ scheduling:
94
+ catch_up_window_ms: 43200000 # 0 – 604800000 (default: 12h)
95
+ ```
96
+
97
+ Env equivalent: **`MERCURY_TASK_CATCH_UP_WINDOW_MS`**. Past the window the task
98
+ is treated as `skip`: a daily digest two days late is no longer the digest
99
+ anyone asked for. `catch_up` applies to cron tasks only — an `at` task has one
100
+ occurrence and is deleted after it, so there is nothing to make up.
101
+
102
+ Restarting *before* the make-up run lands drops it, the same way a restart drops
103
+ a pending retry: the boot pass leaves `next_run_at` off the cron grid, and the
104
+ next boot realigns normally.
41
105
 
42
106
  ## Operator alerts (`alerts:`)
43
107
 
44
108
  When a provider OAuth credential can no longer be refreshed, every space stops
45
109
  answering until someone re-authenticates on the host. Mercury DMs the configured
46
- operators so the outage does not wait for a human to read the logs.
110
+ operators so the outage does not wait for a human to read the logs. The same
111
+ switch gates the missed-run reports described above.
47
112
 
48
113
  ```yaml
49
114
  alerts:
@@ -79,6 +144,7 @@ Env equivalents: **`MERCURY_ALERTS_ENABLED`**, **`MERCURY_ALERTS_COOLDOWN_MS`**.
79
144
  In linked group chats, messages that don't trigger the bot are stored as **ambient context** (author-attributed) so it can answer questions about conversation it overheard. Every message in a linked group becomes a row, bounded on both ends:
80
145
 
81
146
  - **`MERCURY_AMBIENT_TTL_DAYS`** (default `14`) — days before ambient rows are aged out by the storage cleanup. Real user/assistant turns are never touched.
147
+ - **`MERCURY_CONTEXT_HISTORY_MAX_CHARS`** (default `2000`, `0` disables) — per-message character cap on the `context`-mode history window, for spaces with no `context.history_max_chars` row of their own. Clips older messages only; the newest turn, the reply anchor and the stored row are left whole. See the `context:` block above.
82
148
  - **`MERCURY_AMBIENT_CONTEXT_ROWS`** (default `30`) — max ambient rows injected into a single prompt. Ambient has its own budget, so overheard chatter can't crowd real turns out of the context window.
83
149
 
84
150
  Per-space: `mrctl config set ambient.enabled false` disables capture entirely (tag-only mode).
@@ -133,30 +199,105 @@ It compares path *segments*, never string prefixes, so `mercury-shadowy` is
133
199
  not inside `mercury-shadow`. A live process (`MERCURY_SHADOW` unset) is never
134
200
  checked and runs exactly the code it ran before.
135
201
 
202
+ **A shadow never fetches an image.** `ensureImage` does not pull a missing
203
+ base image and does not re-pull a floating tag whose host version moved (the
204
+ release-gate R0.4 refresh), and `ensureDerivedImage` does not build: a cache
205
+ miss is fatal at boot, naming the tag. A rehearsal must run the image the live
206
+ bot is running, and a shadow that fell back to the base image would be
207
+ rehearsing a bot with none of the owner's extension CLIs.
208
+
209
+ **Every container carries `mercury.instance=<id>`**, derived from the resolved
210
+ data dir and settable by nothing, and the orphan sweep filters on it — no id,
211
+ no sweep. Two Mercury processes on one Docker daemon is the rehearsal shape,
212
+ and without this each one's boot sweep would force-remove the other's running
213
+ container. `/health` reports the same id in its `instance` field, always, so an
214
+ external probe can tell which process answered. `MERCURY_AGENT_ID` still
215
+ narrows further; it never replaces this.
216
+
136
217
  Snapshot directories are built by `mercury shadow snapshot`, which generates
137
218
  the shadow's `.env` and `mercury.yaml` — nothing here needs to be set by
138
219
  hand. See [live-testing.md](live-testing.md) §2 and §7.
139
220
 
221
+ ## System prompt authorship (`agent.override_pi_system_prompt`)
222
+
223
+ Chooses whether Mercury replaces pi's own system prompt or appends to it:
224
+
225
+ ```yaml
226
+ agent:
227
+ override_pi_system_prompt: true # true (default) | false
228
+ ```
229
+
230
+ Env: `MERCURY_OVERRIDE_PI_SYSTEM_PROMPT`. Unlike every other boolean key, this one is parsed
231
+ strictly: only `false` or `0` (case-insensitive) opt out, an **empty** value is read as unset and
232
+ takes the default, and anything else — `no`, `off`, a typo — is a startup config error. Opting into
233
+ the mode that a Claude subscription cannot use has to be deliberate, so a bare `=` with nothing
234
+ after it must not do it by accident. Note that a blank env var still *shadows* `mercury.yaml` (the
235
+ env merge honours any present `MERCURY_*` key), so a `mercury.yaml` opt-out plus a blank env line
236
+ resolves to the default, not to the file's `false`.
237
+
238
+ - **`true`** — Mercury is the sole author. pi is invoked with `--system-prompt`, and
239
+ `buildSystemPrompt` supplies the whole thing: its own identity wrapper, the tool list, the
240
+ guidelines, and every Mercury platform block.
241
+ - **`false`** — pi's built-in coding-agent preamble comes first and Mercury's content is appended
242
+ (`--append-system-prompt`).
243
+
244
+ > ⚠️ **`false` does not work on a Claude subscription.** Anthropic's OAuth gate fingerprints pi's
245
+ > preamble as a third-party app and answers **every** request with
246
+ > `400 Third-party apps now draw from your extra usage, not your plan limits`. The message names
247
+ > billing, but it is a *classification* result — no plan balance and no top-up changes it. If you
248
+ > authenticated with `mercury auth login` (rather than setting `MERCURY_ANTHROPIC_API_KEY`), leave
249
+ > this key at its default. Mercury logs a warning once per process when it spawns a container in
250
+ > append mode while holding an Anthropic OAuth credential.
251
+ >
252
+ > API-key deployments are not gated and may use either mode.
253
+
254
+ **Override mode gives up nothing you want.** Diffed against pi 0.84.2's `core/system-prompt.js`
255
+ *inside the agent image* — the file ships in the image, not in this repo — append mode uniquely
256
+ contributes exactly two things:
257
+
258
+ - the phrase `operating inside pi, a coding agent harness`, which is the fingerprint itself; and
259
+ - a block of pointers to pi's own README, docs and examples, for answering questions about pi's
260
+ extensions, themes, skills and TUI.
261
+
262
+ The tool snippets and the three guidelines (`Use bash for file operations…`, `Be concise…`,
263
+ `Show file paths clearly…`) are already reproduced verbatim by Mercury's override, which also
264
+ substitutes its own identity line for the `You are Claude Code` preamble. Re-diff after a pi
265
+ upgrade: this is a claim about the image, so it can go stale.
266
+
267
+ > ⚠️ **Changing this setting needs no image rebuild, but the flag is read inside the container.**
268
+ > The host forwards it as `OVERRIDE_PI_SYSTEM_PROMPT`; `container-entry.ts` reads it per run. A
269
+ > restart is enough to pick up a new value.
270
+
140
271
  ## Container env passthrough (`agent.env_passthrough`)
141
272
 
142
273
  Controls which host `MERCURY_*` variables reach agent containers:
143
274
 
144
275
  ```yaml
145
276
  agent:
146
- env_passthrough: all # all (default) | claimed
277
+ env_passthrough: claimed # claimed (default) | all
147
278
  ```
148
279
 
280
+ - **`claimed`** (default) — only variables an extension declared via `mercury.env()` are passed, and only when the triggering caller holds that extension's permission. Undeclared variables stay on the host.
149
281
  - **`all`** — every `MERCURY_*` var except a fixed blocklist is passed into the container with the prefix stripped (`MERCURY_BILLING_API_KEY` → `BILLING_API_KEY`). Convenient, but blunt: a secret added to `.env` for one purpose reaches **every space's container**, regardless of who triggered the turn or whether that space has anything to do with it. Vars an extension declares `hostOnly` are excluded even here — `MERCURY_BRAVE_API_KEY` stays on the host once `web-search` is installed, which brokers Brave queries through `mrctl capability web-search`.
150
- - **`claimed`** — only variables an extension declared via `mercury.env()` are passed, and only when the triggering caller holds that extension's permission. Undeclared variables stay on the host.
151
282
 
152
283
  **Model-provider credentials are exempt** and pass in both modes (`MERCURY_ANTHROPIC_API_KEY`, `MERCURY_ANTHROPIC_OAUTH_TOKEN`, `MERCURY_GEMINI_API_KEY`, `MERCURY_GROQ_API_KEY`, and the rest of the provider list). pi reads them inside the container, and no extension declares them — without the exemption, `claimed` would leave the agent unable to reach any model. They remain subject to the blocklist.
153
284
 
285
+ **So is the sandbox escape hatch** `MERCURY_DISABLE_BUBBLEWRAP` — not a secret, and read inside the container under its stripped name, so `claimed` would otherwise withdraw it silently and the container would spawn bwrap again on a host where it cannot start.
286
+
154
287
  Env: `MERCURY_CONTAINER_ENV_PASSTHROUGH`.
155
288
 
156
- `claimed` is opt-in because it breaks setups that rely on blind passthrough for anything other than provider keys — API keys consumed by skills (search, TTS, scrapers) and any credential you added by hand. To migrate, declare those in an extension (see [extensions.md](extensions.md)) before switching. At startup with `all`, Mercury logs the vars it is passing that are neither declared nor provider credentials — names only, so a genuine outlier stands out:
289
+ `all` exists because `claimed` breaks setups that rely on blind passthrough for anything other than provider keys — API keys consumed by skills (search, TTS, scrapers) and any credential you added by hand. **This default changed:** it was `all` in every release up to and including v0.18.2 (changed 2026-09-06). A deployment that fed the agent an unclaimed `MERCURY_*` var now stops delivering it, and the container side of that is silent — the variable is simply unset. Declare those vars in an extension (see [extensions.md](extensions.md)), or keep the old behaviour with `agent.env_passthrough: all` / `MERCURY_CONTAINER_ENV_PASSTHROUGH=all`.
290
+
291
+ To make the change readable rather than silent, startup logs the unscoped vars in **both** modes — names only, so a genuine outlier stands out. Under the default they are the vars being withheld:
292
+
293
+ ```
294
+ Container env passthrough: claimed — only extension-declared vars and model-provider credentials reach containers. […] withheld=MERCURY_SCRAPER_API_KEY, MERCURY_BILLING_API_KEY
295
+ ```
296
+
297
+ and under an explicit override, the vars reaching every container:
157
298
 
158
299
  ```
159
- Container env passthrough: all — these vars reach every space's container and are scoped to nothing. […] vars=MERCURY_SCRAPER_API_KEY, MERCURY_BILLING_API_KEY
300
+ Container env passthrough: explicit `all` override — these vars reach every space's container and are scoped to nothing. […] vars=MERCURY_SCRAPER_API_KEY, MERCURY_BILLING_API_KEY
160
301
  ```
161
302
 
162
303
  For secrets that only host-side hooks and jobs need, prefer `mercury.env({ from: "…", hostOnly: true })`, which keeps them out of containers in either mode. For credentials the agent should never hold at all, use a host-side capability handler (`mercury.capability()`), which runs the privileged call on the host and returns only the result.
@@ -335,11 +476,13 @@ Which credential path applies depends on which shape you used:
335
476
  map, so no `*_API_KEY` variable is read for it. Its key must come from the
336
477
  provider entry — written as a **reference**, never a literal:
337
478
  `"apiKey": "$MYGATEWAY_API_KEY"` (`${VAR}` and `!some-command` also work).
338
- Pass the secret itself as `MERCURY_MYGATEWAY_API_KEY` and it reaches the
339
- container through the usual passthrough. This assumes the default
340
- `agent.env_passthrough: all`; under `claimed`, only the provider variables
341
- Mercury knows about are forwarded, and a custom provider's key is not among
342
- them, so pi fails to resolve it.
479
+ Pass the secret itself as `MERCURY_MYGATEWAY_API_KEY` — but under the default
480
+ `agent.env_passthrough: claimed`, only the provider variables Mercury knows
481
+ about are forwarded, and a custom provider's key is not among them, so pi
482
+ fails to resolve it. Either declare it in an extension or set
483
+ `agent.env_passthrough: all`. `mercury preflight` catches this case: the
484
+ `credential` check resolves the reference through the same passthrough the
485
+ spawn uses, and reports `blocked` naming the mode.
343
486
 
344
487
  > ⚠️ **Never store a credential literally.** Mercury refuses to mount a
345
488
  > `models.json` that has a literal value in `apiKey`, in a credential-shaped
@@ -197,6 +197,8 @@ The pi agent runs inside `bwrap`, which creates a minimal mount namespace with o
197
197
 
198
198
  **On a stock Linux host the default shape cannot run bwrap.** Docker's default seccomp profile blocks the user-namespace clone and Ubuntu's `docker-default` AppArmor profile independently blocks bwrap's mount step, so every turn fails with `bwrap: No permissions to create new namespace` (or `Creating new namespace failed: Operation not permitted`). On a stock modern host the kernel sysctls that message points at (`unprivileged_userns_clone`, `max_user_namespaces`) are not the blocker — both gates are Docker's; check them only if they are off their defaults. Mercury probes for this once at boot (`docker run --entrypoint bwrap …` with the real bwrap shape) and, if blocked, logs one `error` line naming the fix and refuses every spawn with the same diagnosis instead of a per-turn bwrap error. Escapes, narrowest first: `MERCURY_CONTAINER_RUNTIME=runsc` (gVisor — strongest sandbox, no relaxation needed), then `MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT=1` (`--privileged`), then `MERCURY_DISABLE_BUBBLEWRAP=1` (no inner sandbox at all).
199
199
 
200
+ `mercury preflight` runs the same probe on demand and reports it as `sandbox.bwrap` — one implementation, called by boot and by the CLI, so the two can never drift apart.
201
+
200
202
  Custom images must install `bubblewrap` for sandboxing to work.
201
203
 
202
204
  **runsc (gVisor) mode requires `--host-uds=open` on the runtime registration.** In runsc mode the agent container reaches the host API over a bind-mounted unix socket — the socket is mrctl's *only* transport there (`API_URL` is a deliberate dummy). gVisor refuses `connect()` to host unix sockets unless the daemon registers runsc with the flag:
@@ -214,6 +216,114 @@ Custom images must install `bubblewrap` for sandboxing to work.
214
216
 
215
217
  Without it, Mercury starts clean and containers run, but **every mrctl verb fails** (config, tasks, mutes, stop, capabilities — the whole host API surface) with a bare connect error inside the conversation. Mercury probes for this once at boot (a runsc container connect-tests the socket with the real spawn's mount shape) and, if blocked, logs one `error` line naming this fix. Applying it is zero-downtime: `runtimes` is a SIGHUP-reloadable daemon.json key, so edit the file and `kill -SIGHUP $(pidof dockerd)` — no daemon restart, no container kills.
216
218
 
219
+ `mercury preflight` runs the same probe on demand as `sandbox.runsc-host-uds`, and `container.mrctl-roundtrip` proves the whole path end to end by running `mrctl whoami` inside a container on the production launch shape.
220
+
221
+ ## `mercury preflight`
222
+
223
+ One command that answers *"can this box, with this image and this config,
224
+ actually run a Mercury turn?"* — and says **why not**, one diagnosis per
225
+ blocker, with the narrowest fix. Run it from the project directory the service
226
+ runs in, the way every other `mercury` command is run.
227
+
228
+ ```bash
229
+ mercury preflight # human report
230
+ mercury preflight --json # the report, for a caller to gate on
231
+ mercury preflight --space football # judge that space's pinned model leg
232
+ mercury preflight --report /tmp/pf.json
233
+ ```
234
+
235
+ | Exit | Meaning |
236
+ |------|---------|
237
+ | `0` | no blocker (warnings allowed) |
238
+ | `1` | at least one check is `blocked` — a turn cannot run here |
239
+ | `2` | usage or config error (bad `--space`, unwritable report, the shadow refusal) |
240
+
241
+ Callers gate on the exit code and nothing else. A `warn` box answers messages
242
+ while something is wrong; rolling an upgrade back over one would be wrong.
243
+
244
+ | Check | Verdict class | What it proves |
245
+ |-------|---------------|----------------|
246
+ | `docker.daemon` | blocked | `docker info` answers; "not installed" and "daemon down" are distinguished |
247
+ | `docker.image` | blocked | the configured image is present locally, and which derived tag a spawn would use |
248
+ | `sandbox.bwrap` | blocked | `probeBwrapSandbox` — the boot probe, on demand |
249
+ | `sandbox.runsc-host-uds` | blocked | `probeRunscHostUds` — the boot probe, on demand |
250
+ | `image.contract` | blocked | every `container/Dockerfile` `COPY` target is in the image, and the version the build stamped into `/app/image-manifest.json` equals the host's |
251
+ | `container.mrctl-roundtrip` | blocked | a container on the production launch shape runs `mrctl whoami` and comes back as the caller preflight asserted — by minted token where a caller-token key is configured, by identity header where none is (evidence `callerAuth`; see below). On runsc the host API socket is connect-tested first, so a stopped service blocks with the service remedy and no container is created |
252
+ | `credential.first-leg` | blocked | the chain's first leg (or `--space`'s pinned leg) resolves a credential the way a spawn resolves it |
253
+ | `host.pi` | warn | `pi` is on the host when napkin or longview is loaded |
254
+ | `host.timezone` | warn | no active cron task carries `timezone: null` — a zoneless cron is parsed as UTC on every host, so the host's own zone is evidence, not the condition |
255
+ | `host.swap` | warn | a host of ≤ 4 GiB has swap |
256
+ | `extensions.load-report` | warn | every extension on disk is loaded, or skipped **with a reason** |
257
+
258
+ **Absence denies.** A probe that could not run is `blocked`, never `pass` — a
259
+ gate that reports "nothing proved" as health is the 2026-09-01 `/health` that
260
+ stayed green while every container on the box was doomed. A check downstream of
261
+ a blocked prerequisite is `skipped` with a named `because`, so the report shows
262
+ one blocker rather than seven.
263
+
264
+ **It costs no tokens and changes nothing.** The round trip is an `mrctl` call,
265
+ not a model turn. Preflight never builds or pulls an image, never writes the
266
+ extensions dir, never writes the database, and never sends to a person; its
267
+ only writes are the report and the probe containers it removes. The database
268
+ half is not free: the round trip goes through the real API middleware, which
269
+ calls `resolveRole`, which upserts a member row on first contact. `preflight`
270
+ is therefore a reserved *ephemeral caller* (`EPHEMERAL_CALLERS` in
271
+ `src/core/permissions.ts`) that resolves as `member` without being written or
272
+ seeded — deliberately not a system caller, which would hand the probe token
273
+ system-level permissions. For the same caller the middleware also refuses to
274
+ create the space it names (`ensureSpace` would insert it, and bump `updated_at`
275
+ even when it exists) — so the round trip needs an existing space, and on a box
276
+ whose spaces are not named `main` you pass `--space <id>`; without it the
277
+ command exits 2 before minting anything. It loads
278
+ extensions through the same helper boot uses, but read-only: boot creates the
279
+ `node_modules/mercury-agent` package symlink, preflight only reads it. The one
280
+ consequence worth knowing is that on a box where the service has **never**
281
+ started, that link does not exist yet, so an extension importing
282
+ `mercury-agent` is reported as a load-error the running service will not have —
283
+ the check's own remedy says so. Probe containers carry `mercury.managed=true`,
284
+ so a crash mid-probe leaves something the next boot's orphan sweep reaps.
285
+
286
+ The report is written to `<dataDir>/preflight/<timestamp>.json` with a copy at
287
+ `latest.json` (or to `--report <path>`). It is the evidence a deploy attaches;
288
+ the terminal output is a convenience — `docs/live-testing.md` § 7 and rule 6
289
+ of § 8 say why a gate whose report was not persisted did not pass, and where
290
+ preflight stops ("can this box run a turn", never "is the scenario working"). Under `MERCURY_SHADOW=1` it runs against
291
+ the shadow's own config and data dir, and the M1.2 refusal applies unchanged.
292
+
293
+ **The round trip needs no `MERCURY_CALLER_TOKEN_KEY`, and says which identity
294
+ it used.** Preflight mints its probe token in its own process, so a *configured*
295
+ key is the only way the service can verify it: with none set, `caller-token.ts`
296
+ falls back to a random key generated once **per process** and the two ends can
297
+ never agree. The check therefore picks its auth mode from the config
298
+ (`roundtripCallerAuth`, `src/preflight/checks/roundtrip.ts`) and reports it as
299
+ `callerAuth` in the evidence:
300
+
301
+ | `callerAuth` | When | What the pass proves |
302
+ |---|---|---|
303
+ | `token` | `MERCURY_CALLER_TOKEN_KEY` (or `caller_token_key`) is set | the whole production shape — transport, `API_SECRET`, the minted caller token, and the identity the host hands back |
304
+ | `headers` | no key configured (the shipped default) | transport, `API_SECRET` and the identity — the token leg is **not** exercised, and the pass title says so |
305
+
306
+ In `headers` mode the probe simply omits `CALLER_TOKEN`, so `mrctl` sends no
307
+ `x-mercury-token` and the host resolves `x-mercury-caller` / `x-mercury-space`
308
+ through the fallback it has always had for token-less callers. Nothing is
309
+ relaxed: every request on either mode still has to carry
310
+ `Authorization: Bearer <API_SECRET>`, and `authorizeApiSecret`
311
+ (`src/core/auth.ts`) fails **closed** — 503 when the service has no secret
312
+ configured, 401 on a missing or mismatched one — before any identity header is
313
+ read. Setting the key is still worth doing, because it upgrades the check from
314
+ `headers` to `token` and is required by anything that mints in one process and
315
+ verifies in another; the real agent containers never needed it, since the
316
+ service mints and verifies their tokens in one process. Before 2026-09-06 the
317
+ absent key was a permanent blocker (`401 Invalid or expired caller token` on
318
+ every run, with a remedy that sent the operator to compare a `.env` with
319
+ itself) —
320
+ `docs/debug/moderate/2026-09-06-preflight-roundtrip-cannot-pass-without-configured-caller-token-key.md`.
321
+
322
+ **After changing the Dockerfile or bumping the version, `image.contract` blocks
323
+ until the image is rebuilt** (`container/build.sh`, then restart so the derived
324
+ image follows). That is the point: it is the "a restart does not redeploy the
325
+ image" trap made visible instead of silent.
326
+
217
327
  ## Agent Image Preset
218
328
 
219
329
  Mercury publishes an image preset to GitHub Container Registry:
@@ -222,7 +332,47 @@ Mercury publishes an image preset to GitHub Container Registry:
222
332
  |--------|------|----------|
223
333
  | `ghcr.io/avishai-tsabari/mercury-agent:latest` | ~2.8GB | Full devcontainer: Bun, Node.js, Python, Go, git, build tools |
224
334
 
225
- Images are published on each release. Version-specific tags are also available (e.g., `:0.2.0`).
335
+ Images are published on each release by `release.yml`'s `publish-image` job,
336
+ which builds from the published npm tarball and pushes `:<version>` alongside
337
+ `:latest`. A prerelease publishes its own tag only and leaves `:latest` alone.
338
+
339
+ > Before 0.19.0 this sentence was aspirational: nothing in CI built the image,
340
+ > the registry copy was hand-pushed and stale, and an install outside the
341
+ > tagula fleet had no image to pull. See `docs/goals/release-gate/decisions.md`
342
+ > G-007.
343
+
344
+ ### Staying Current
345
+
346
+ A floating tag used to mean "whatever this box pulled the first time":
347
+ `ensureImage()` returned as soon as `docker image inspect` succeeded, so a host
348
+ upgraded through npm kept running the old container against new host code with
349
+ `/health` at 200 and nothing in the log.
350
+
351
+ Startup now re-pulls the image whenever the host's Mercury version is not the
352
+ one that last pulled it, recording the pair in `<dataDir>/image-pull.json`.
353
+ Two references are never re-pulled: a **digest-pinned** one (`…@sha256:…`),
354
+ which cannot drift, and a **locally built** one, which exists in no registry —
355
+ so a pull would fail on every boot. "Locally built" means any name with no
356
+ registry host in its first path component, which is `mercury-agent:latest` and
357
+ `mercury-agent-ext:<hash>` — and, deliberately, a bare Docker Hub name such as
358
+ `someuser/mercury-agent:latest`: a false pull attempt on every local build is
359
+ a worse daily cost than a missed refresh on a shape nothing here uses. Pin such
360
+ an image by digest, or give it a registry host, to get refreshes. A refresh
361
+ that fails warns and the service continues on the image it already has.
362
+
363
+ Either way boot logs one `Agent image resolved` line carrying the reference,
364
+ image id, repo digest, version label and whether a refresh happened, so drift
365
+ is visible without being asked for:
366
+
367
+ ```
368
+ Agent image resolved image=ghcr.io/avishai-tsabari/mercury-agent:latest
369
+ imageId=sha256:… digest=…@sha256:… imageVersion=null hostVersion=0.18.2
370
+ refresh=refreshed reason=version-changed previousImageId=sha256:…
371
+ ```
372
+
373
+ `imageVersion` is the image's `com.mercury.version` label (release-gate R0.1
374
+ stamps it from the build arg); `mercury preflight`'s `image.contract` check
375
+ reads the same version from `/app/image-manifest.json` inside the image.
226
376
 
227
377
  ### Building Locally
228
378
 
@@ -48,8 +48,12 @@ The session boundary (`chat_state.min_message_id`) excludes messages older than
48
48
  ### System prompt layering (broadest → most specific)
49
49
 
50
50
  ```
51
- 1. pi built-in prompt (Claude Code identity, tool definitions)
52
- — or Mercury's override when OVERRIDE_PI_SYSTEM_PROMPT is set
51
+ 1. Mercury's own standalone prompt (identity, tool list, guidelines)
52
+ — the default; pi's built-in prompt is replaced via --system-prompt.
53
+ Set MERCURY_OVERRIDE_PI_SYSTEM_PROMPT=false to keep pi's built-in prompt
54
+ (Claude Code identity, tool definitions) and append instead — but see
55
+ docs/configuration.md: that mode is rejected under an Anthropic OAuth
56
+ credential.
53
57
 
54
58
  2. Mercury platform additions (buildMercuryAdditions)
55
59
  — identity, inbox/outbox, permissions, moderation, capabilities, memory guidance
@@ -328,7 +328,9 @@ mercury.job("appointment-reminders", {
328
328
  });
329
329
  ```
330
330
 
331
- `to` resolves callerId-first: an exact space id, a raw WhatsApp caller id (phone JID or opaque LID — same normalization that keys DM auto-spaces), a platform-qualified id (`whatsapp:123@lid`), or a phone with leading `+`. It never creates spaces — sending to someone without an existing conversation fails.
331
+ `to` resolves callerId-first: an exact space id; otherwise the space the recipient's existing DM conversation is linked to (with DM auto-spaces on, an admin's DM lands in `main`, and any DM can be re-linked by hand — the `conversations` table, not a naming convention, knows where a person is reachable); otherwise the derived DM auto-space (`dm-<id>`), which still delivers to the person's own conversation row when one exists unlinked. The conversation step matches WhatsApp and Telegram, whose DM rows are keyed by the person's id; on Discord and Slack a DM row is keyed by its channel id, so address the DM **channel** id — a user id resolves through neither the conversation lookup nor the derived space. Recipient ids are loose: a raw WhatsApp caller id (phone JID or opaque LID, with the learned LID↔phone alias tried too), a platform-qualified id (`whatsapp:123@lid`), or a phone with leading `+`. It never creates spaces — sending to someone with neither a linked conversation nor an auto-space fails.
332
+
333
+ **Delivery is the conversation, not the space.** When `to` names a person, the message goes to that person's one conversation and no other — even when its space holds several. The space is how the person is found; it is not the audience, and a space that also holds a group never sees a message addressed to a person. That holds on both person-resolving steps: a person whose auto-space exists but whose conversation row Mercury has never seen is `unknown_recipient` rather than a send to the auto-space, because the auto-space may hold something other than their DM. (Resolving an exact space id is the caller explicitly asking for the space, and still reaches everything linked to it.) The result stays `{ spaceId }`: it says who was reached, not which thread received it.
332
334
 
333
335
  Failures throw `DirectSendError` with a `reason` you can branch on: `sender_not_ready` (adapters not up yet, or the context has no delivery path — e.g. connection status probes), `unknown_recipient` (no existing space matches), `invalid_text` (empty or over 4096 chars).
334
336
 
@@ -469,6 +471,12 @@ The skill is installed into the management space's own workspace on
469
471
  dir mounted into *every* container, which would advertise the capability to
470
472
  spaces that cannot use it.
471
473
 
474
+ The description alone is not enough to make the model reach for the skill
475
+ unprompted, so `before_container` also returns a management-space-only
476
+ `systemPrompt` fragment (layer 3a) stating the fact that other spaces are
477
+ readable from here — the same scoping check `workspace_init` uses, so no other
478
+ space's agent ever sees it.
479
+
472
480
  ## Morning / Green Invoice invoicing (`morning`)
473
481
 
474
482
  `examples/extensions/morning/` issues tax documents through the