mercury-agent 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/container/Dockerfile +25 -0
- package/container/build.sh +23 -3
- package/docs/behavior-layers.md +25 -14
- package/docs/configuration.md +139 -11
- package/docs/container-lifecycle.md +151 -1
- package/docs/context-architecture.md +6 -2
- package/docs/extensions.md +9 -1
- package/docs/goals/football-reporter-profile/decisions.md +79 -0
- package/docs/goals/release-gate/decisions.md +53 -7
- package/docs/goals/release-gate/roadmap.md +8 -8
- package/docs/live-testing.md +22 -0
- package/docs/pending-verification.md +178 -0
- package/docs/permissions.md +1 -1
- package/docs/profile-guide.md +28 -8
- package/examples/extensions/archive/backends/local.ts +6 -3
- package/examples/extensions/archive/queue.ts +51 -10
- package/examples/extensions/feed-watch/config.ts +42 -2
- package/examples/extensions/feed-watch/digest.ts +4 -7
- package/examples/extensions/feed-watch/items.ts +18 -7
- package/examples/extensions/feed-watch/match.ts +125 -9
- package/examples/extensions/feed-watch/skill/SKILL.md +25 -0
- package/examples/extensions/feed-watch/watch.ts +39 -5
- package/examples/extensions/gws/index.ts +30 -3
- package/examples/extensions/longview/hook.ts +72 -7
- package/examples/extensions/longview/index.ts +2 -0
- package/examples/extensions/morning/index.ts +13 -11
- package/examples/extensions/napkin/index.ts +12 -3
- package/examples/extensions/napkin/pi-spawn.ts +5 -1
- package/examples/extensions/overview/index.ts +20 -0
- package/examples/extensions/overview/skill/SKILL.md +9 -1
- package/examples/extensions/pinchtab/index.ts +36 -7
- package/examples/extensions/pinchtab/skill/SKILL.md +28 -1
- package/examples/profiles/_template/AGENTS.md +6 -1
- package/examples/profiles/football-reporter/AGENTS.md +16 -4
- package/examples/profiles/football-reporter/README.md +1 -1
- package/examples/profiles/football-reporter/config.yaml +42 -6
- package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
- package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +1 -1
- package/examples/profiles/football-reporter/seed/episodes/maccabi-tel-aviv-2026-27.md +24 -0
- package/examples/profiles/football-reporter/seed/napkin-distill.md +30 -29
- package/examples/profiles/football-reporter/standard.json +45 -8
- package/package.json +7 -6
- package/resources/skills/tasks/SKILL.md +17 -1
- package/resources/templates/AGENTS.md +4 -3
- package/resources/templates/mercury.example.yaml +13 -4
- package/src/adapters/whatsapp-ingress.ts +15 -3
- package/src/agent/container-entry.ts +299 -29
- package/src/agent/container-env.ts +26 -8
- package/src/agent/container-runner.ts +497 -76
- package/src/agent/image-contract.ts +77 -0
- package/src/agent/image-manifest.ts +180 -0
- package/src/agent/image-refresh.ts +318 -0
- package/src/cli/mercury.ts +225 -28
- package/src/cli/mrctl-http.ts +5 -0
- package/src/cli/mrctl.ts +57 -5
- package/src/cli/service-unit.ts +109 -0
- package/src/config-file.ts +22 -1
- package/src/config.ts +105 -14
- package/src/core/api.ts +11 -2
- package/src/core/commands.ts +11 -4
- package/src/core/connection-health.ts +377 -0
- package/src/core/direct-send.ts +235 -14
- package/src/core/exec.ts +10 -0
- package/src/core/history-window.ts +142 -0
- package/src/core/model-command.ts +130 -0
- package/src/core/operator-alerts.ts +326 -23
- package/src/core/permissions.ts +28 -0
- package/src/core/profiles.ts +32 -16
- package/src/core/reply-context.ts +31 -0
- package/src/core/routes/config-builtin.ts +11 -0
- package/src/core/routes/console.ts +98 -16
- package/src/core/routes/dashboard.ts +93 -10
- package/src/core/routes/model.ts +26 -3
- package/src/core/routes/send.ts +1 -1
- package/src/core/routes/tasks.ts +74 -0
- package/src/core/runtime.ts +138 -19
- package/src/core/system-messages.ts +33 -0
- package/src/core/task-scheduler.ts +155 -7
- package/src/extensions/image-builder.ts +1 -1
- package/src/extensions/installer.ts +72 -17
- package/src/extensions/load-project.ts +74 -0
- package/src/extensions/loader.ts +50 -9
- package/src/host-version.ts +32 -0
- package/src/main.ts +42 -21
- package/src/preflight/checks/credential.ts +300 -0
- package/src/preflight/checks/docker.ts +149 -0
- package/src/preflight/checks/extensions.ts +90 -0
- package/src/preflight/checks/host-deps.ts +247 -0
- package/src/preflight/checks/image-contract.ts +228 -0
- package/src/preflight/checks/roundtrip.ts +424 -0
- package/src/preflight/checks/sandbox.ts +159 -0
- package/src/preflight/deps.ts +107 -0
- package/src/preflight/probe-container.ts +156 -0
- package/src/preflight/report.ts +177 -0
- package/src/preflight/run.ts +223 -0
- package/src/server.ts +55 -14
- package/src/storage/db.ts +41 -2
- package/src/storage/models-json.ts +110 -0
- package/src/text/reporter-lint.ts +167 -23
- package/src/types.ts +22 -0
package/container/Dockerfile
CHANGED
|
@@ -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"]
|
package/container/build.sh
CHANGED
|
@@ -16,8 +16,28 @@ if [ $# -gt 0 ] && [ "$1" != "latest" ]; then
|
|
|
16
16
|
exit 1
|
|
17
17
|
fi
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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!"
|
package/docs/behavior-layers.md
CHANGED
|
@@ -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.
|
|
19
|
+
1. Mercury standalone preamble ............ image container-entry.ts buildSystemPrompt
|
|
20
20
|
"You are an expert AI assistant…", tool list, "Be concise"
|
|
21
|
-
|
|
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 (
|
|
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
|
|
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.
|
|
72
|
-
and
|
|
73
|
-
caller holding `mutes.mute
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
is 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.
|
|
@@ -158,7 +169,7 @@ it is still inside the window.
|
|
|
158
169
|
| Surface | File | Injected | Condition |
|
|
159
170
|
|---|---|---|---|
|
|
160
171
|
| 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` |
|
|
172
|
+
| 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
173
|
| Member notes | `knowledge/members/<id>.md` | per run, as `<member_notes>` | `<id>` = platform id with `/`→`_`; ≤6000 chars total |
|
|
163
174
|
| Members | `space_roles` table | per run, as `<members>` | always; authoritative identity |
|
|
164
175
|
| Preferences | `space_preferences` | per run, as `<preferences>` | always |
|
package/docs/configuration.md
CHANGED
|
@@ -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).
|
|
@@ -137,26 +203,86 @@ Snapshot directories are built by `mercury shadow snapshot`, which generates
|
|
|
137
203
|
the shadow's `.env` and `mercury.yaml` — nothing here needs to be set by
|
|
138
204
|
hand. See [live-testing.md](live-testing.md) §2 and §7.
|
|
139
205
|
|
|
206
|
+
## System prompt authorship (`agent.override_pi_system_prompt`)
|
|
207
|
+
|
|
208
|
+
Chooses whether Mercury replaces pi's own system prompt or appends to it:
|
|
209
|
+
|
|
210
|
+
```yaml
|
|
211
|
+
agent:
|
|
212
|
+
override_pi_system_prompt: true # true (default) | false
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Env: `MERCURY_OVERRIDE_PI_SYSTEM_PROMPT`. Unlike every other boolean key, this one is parsed
|
|
216
|
+
strictly: only `false` or `0` (case-insensitive) opt out, an **empty** value is read as unset and
|
|
217
|
+
takes the default, and anything else — `no`, `off`, a typo — is a startup config error. Opting into
|
|
218
|
+
the mode that a Claude subscription cannot use has to be deliberate, so a bare `=` with nothing
|
|
219
|
+
after it must not do it by accident. Note that a blank env var still *shadows* `mercury.yaml` (the
|
|
220
|
+
env merge honours any present `MERCURY_*` key), so a `mercury.yaml` opt-out plus a blank env line
|
|
221
|
+
resolves to the default, not to the file's `false`.
|
|
222
|
+
|
|
223
|
+
- **`true`** — Mercury is the sole author. pi is invoked with `--system-prompt`, and
|
|
224
|
+
`buildSystemPrompt` supplies the whole thing: its own identity wrapper, the tool list, the
|
|
225
|
+
guidelines, and every Mercury platform block.
|
|
226
|
+
- **`false`** — pi's built-in coding-agent preamble comes first and Mercury's content is appended
|
|
227
|
+
(`--append-system-prompt`).
|
|
228
|
+
|
|
229
|
+
> ⚠️ **`false` does not work on a Claude subscription.** Anthropic's OAuth gate fingerprints pi's
|
|
230
|
+
> preamble as a third-party app and answers **every** request with
|
|
231
|
+
> `400 Third-party apps now draw from your extra usage, not your plan limits`. The message names
|
|
232
|
+
> billing, but it is a *classification* result — no plan balance and no top-up changes it. If you
|
|
233
|
+
> authenticated with `mercury auth login` (rather than setting `MERCURY_ANTHROPIC_API_KEY`), leave
|
|
234
|
+
> this key at its default. Mercury logs a warning once per process when it spawns a container in
|
|
235
|
+
> append mode while holding an Anthropic OAuth credential.
|
|
236
|
+
>
|
|
237
|
+
> API-key deployments are not gated and may use either mode.
|
|
238
|
+
|
|
239
|
+
**Override mode gives up nothing you want.** Diffed against pi 0.84.2's `core/system-prompt.js`
|
|
240
|
+
*inside the agent image* — the file ships in the image, not in this repo — append mode uniquely
|
|
241
|
+
contributes exactly two things:
|
|
242
|
+
|
|
243
|
+
- the phrase `operating inside pi, a coding agent harness`, which is the fingerprint itself; and
|
|
244
|
+
- a block of pointers to pi's own README, docs and examples, for answering questions about pi's
|
|
245
|
+
extensions, themes, skills and TUI.
|
|
246
|
+
|
|
247
|
+
The tool snippets and the three guidelines (`Use bash for file operations…`, `Be concise…`,
|
|
248
|
+
`Show file paths clearly…`) are already reproduced verbatim by Mercury's override, which also
|
|
249
|
+
substitutes its own identity line for the `You are Claude Code` preamble. Re-diff after a pi
|
|
250
|
+
upgrade: this is a claim about the image, so it can go stale.
|
|
251
|
+
|
|
252
|
+
> ⚠️ **Changing this setting needs no image rebuild, but the flag is read inside the container.**
|
|
253
|
+
> The host forwards it as `OVERRIDE_PI_SYSTEM_PROMPT`; `container-entry.ts` reads it per run. A
|
|
254
|
+
> restart is enough to pick up a new value.
|
|
255
|
+
|
|
140
256
|
## Container env passthrough (`agent.env_passthrough`)
|
|
141
257
|
|
|
142
258
|
Controls which host `MERCURY_*` variables reach agent containers:
|
|
143
259
|
|
|
144
260
|
```yaml
|
|
145
261
|
agent:
|
|
146
|
-
env_passthrough:
|
|
262
|
+
env_passthrough: claimed # claimed (default) | all
|
|
147
263
|
```
|
|
148
264
|
|
|
265
|
+
- **`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
266
|
- **`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
267
|
|
|
152
268
|
**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
269
|
|
|
270
|
+
**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.
|
|
271
|
+
|
|
154
272
|
Env: `MERCURY_CONTAINER_ENV_PASSTHROUGH`.
|
|
155
273
|
|
|
156
|
-
`
|
|
274
|
+
`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`.
|
|
275
|
+
|
|
276
|
+
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:
|
|
277
|
+
|
|
278
|
+
```
|
|
279
|
+
Container env passthrough: claimed — only extension-declared vars and model-provider credentials reach containers. […] withheld=MERCURY_SCRAPER_API_KEY, MERCURY_BILLING_API_KEY
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
and under an explicit override, the vars reaching every container:
|
|
157
283
|
|
|
158
284
|
```
|
|
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
|
|
285
|
+
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
286
|
```
|
|
161
287
|
|
|
162
288
|
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 +461,13 @@ Which credential path applies depends on which shape you used:
|
|
|
335
461
|
map, so no `*_API_KEY` variable is read for it. Its key must come from the
|
|
336
462
|
provider entry — written as a **reference**, never a literal:
|
|
337
463
|
`"apiKey": "$MYGATEWAY_API_KEY"` (`${VAR}` and `!some-command` also work).
|
|
338
|
-
Pass the secret itself as `MERCURY_MYGATEWAY_API_KEY`
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
464
|
+
Pass the secret itself as `MERCURY_MYGATEWAY_API_KEY` — but under the default
|
|
465
|
+
`agent.env_passthrough: claimed`, only the provider variables Mercury knows
|
|
466
|
+
about are forwarded, and a custom provider's key is not among them, so pi
|
|
467
|
+
fails to resolve it. Either declare it in an extension or set
|
|
468
|
+
`agent.env_passthrough: all`. `mercury preflight` catches this case: the
|
|
469
|
+
`credential` check resolves the reference through the same passthrough the
|
|
470
|
+
spawn uses, and reports `blocked` naming the mode.
|
|
343
471
|
|
|
344
472
|
> ⚠️ **Never store a credential literally.** Mercury refuses to mount a
|
|
345
473
|
> `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
|
|
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.
|
|
52
|
-
—
|
|
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
|
package/docs/extensions.md
CHANGED
|
@@ -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
|
|
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
|