mercury-agent 0.16.0 → 0.16.2

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 (80) hide show
  1. package/README.md +6 -0
  2. package/container/Dockerfile +6 -1
  3. package/container/Dockerfile.base +6 -1
  4. package/docs/README.md +50 -0
  5. package/docs/authoring-profiles.md +8 -0
  6. package/docs/behavior-layers.md +193 -0
  7. package/docs/extensions.md +2 -0
  8. package/docs/goals/football-reporter-profile/decisions.md +479 -0
  9. package/docs/goals/football-reporter-profile/goal.md +91 -0
  10. package/docs/goals/football-reporter-profile/roadmap.md +196 -0
  11. package/docs/goals/whatsapp-bot-hardening/README.md +43 -0
  12. package/docs/goals/whatsapp-bot-hardening/archive/ambient-group-context.md +211 -0
  13. package/docs/goals/whatsapp-bot-hardening/archive/command-routing-consistency.md +210 -0
  14. package/docs/goals/whatsapp-bot-hardening/archive/destructive-command-guards.md +218 -0
  15. package/docs/goals/whatsapp-bot-hardening/archive/docs-extension.md +182 -0
  16. package/docs/goals/whatsapp-bot-hardening/archive/handoff-2026-08-09.md +588 -0
  17. package/docs/goals/whatsapp-bot-hardening/archive/media-size-and-silent-drop.md +178 -0
  18. package/docs/goals/whatsapp-bot-hardening/archive/member-memory.md +199 -0
  19. package/docs/goals/whatsapp-bot-hardening/archive/message-author-attribution.md +211 -0
  20. package/docs/goals/whatsapp-bot-hardening/archive/run-from-source-switchover.md +212 -0
  21. package/docs/goals/whatsapp-bot-hardening/archive/setup-plan-2026-08-06.md +1117 -0
  22. package/docs/goals/whatsapp-bot-hardening/decisions.md +200 -0
  23. package/docs/goals/whatsapp-bot-hardening/goal.md +75 -0
  24. package/docs/goals/whatsapp-bot-hardening/open-threads.md +396 -0
  25. package/docs/goals/whatsapp-bot-hardening/roadmap.md +245 -0
  26. package/docs/memory.md +30 -0
  27. package/docs/profile-guide.md +483 -0
  28. package/docs/refactor/.gitkeep +0 -0
  29. package/docs/refactor/archive/.gitkeep +0 -0
  30. package/docs/refactor/audits/.gitkeep +0 -0
  31. package/docs/refactor/backlog/.gitkeep +0 -0
  32. package/docs/skills-guide.md +154 -0
  33. package/examples/extensions/README.md +1 -0
  34. package/examples/extensions/feed-watch/config.ts +354 -0
  35. package/examples/extensions/feed-watch/digest.ts +344 -0
  36. package/examples/extensions/feed-watch/feeds.ts +341 -0
  37. package/examples/extensions/feed-watch/index.ts +302 -0
  38. package/examples/extensions/feed-watch/items.ts +171 -0
  39. package/examples/extensions/feed-watch/match.ts +107 -0
  40. package/examples/extensions/feed-watch/prompts/verify.md +35 -0
  41. package/examples/extensions/feed-watch/skill/SKILL.md +128 -0
  42. package/examples/extensions/feed-watch/watch.ts +548 -0
  43. package/examples/extensions/longview/hook.ts +179 -16
  44. package/examples/extensions/longview/index.ts +13 -2
  45. package/examples/extensions/longview/prompts/summarize.md +3 -2
  46. package/examples/extensions/longview/render/telegraph-nodes.ts +21 -1
  47. package/examples/extensions/longview/summarize.ts +57 -14
  48. package/examples/extensions/napkin/index.ts +334 -86
  49. package/examples/extensions/napkin/pi-spawn.ts +196 -0
  50. package/examples/extensions/pinchtab/index.ts +37 -4
  51. package/examples/extensions/pinchtab/lib/session-injector.ts +31 -9
  52. package/examples/extensions/pinchtab/skill/SKILL.md +29 -0
  53. package/examples/profiles/_template/AGENTS.md +138 -0
  54. package/examples/profiles/_template/README.md +65 -0
  55. package/examples/profiles/_template/config.yaml +57 -0
  56. package/examples/profiles/_template/tasks/daily.md +32 -0
  57. package/examples/profiles/football-reporter/AGENTS.md +219 -0
  58. package/examples/profiles/football-reporter/README.md +138 -0
  59. package/examples/profiles/football-reporter/config.yaml +174 -0
  60. package/examples/profiles/football-reporter/seed/MEMORY.md +42 -0
  61. package/examples/profiles/football-reporter/seed/episodes/barcelona-2026-27.md +25 -0
  62. package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +25 -0
  63. package/examples/profiles/football-reporter/seed/episodes/maccabi-haifa-2026-27.md +24 -0
  64. package/examples/profiles/football-reporter/seed/episodes/man-united-2026-27.md +26 -0
  65. package/examples/profiles/football-reporter/seed/episodes/real-madrid-2026-27.md +25 -0
  66. package/examples/profiles/football-reporter/seed/napkin-distill.md +55 -0
  67. package/examples/profiles/football-reporter/tasks/daily-article.md +142 -0
  68. package/package.json +8 -5
  69. package/src/adapters/whatsapp.ts +75 -13
  70. package/src/bridges/whatsapp.ts +6 -4
  71. package/src/cli/mrctl.ts +9 -1
  72. package/src/core/handler.ts +51 -6
  73. package/src/core/routes/dashboard.ts +106 -5
  74. package/src/core/routes/tasks.ts +28 -0
  75. package/src/core/runtime.ts +63 -2
  76. package/src/core/task-scheduler.ts +103 -12
  77. package/src/extensions/catalog.ts +9 -0
  78. package/src/profile/space-profile.ts +780 -0
  79. package/src/storage/db.ts +215 -0
  80. package/src/types.ts +56 -0
@@ -0,0 +1,1117 @@
1
+ > **ARCHIVED 2026-08-10 — Phases 0–5 complete, the bot is live.**
2
+ >
3
+ > Kept for the traps and the config reference, which are still accurate and
4
+ > still the only record of several live settings. **Parts of it are now stale:**
5
+ >
6
+ > | Section | Status |
7
+ > |---|---|
8
+ > | Decision C/D (Gemini API key, `gemini-*` models) | **Superseded** — the bot runs `claude-opus-5` over Claude-subscription OAuth. The whole "Free-tier rate limits" section is history, not current config. |
9
+ > | Decision E (install via global npm) | **Superseded** by D-001 — the service runs from the `~/mercury-src` git checkout. `npm update -g` is no longer part of upgrades, so the "erases both patches" warning no longer applies. |
10
+ > | "`mercury auth status` is cwd-sensitive" | **Fixed 2026-08-10** — see the roadmap's "The cwd trap" section. |
11
+ > | `media.max_size_mb: 10` implications | **Superseded** — raised to 64 (D-004). |
12
+ > | Phase 5 "Personality / tone → `AGENTS.md` … or `mrctl character set`" | **Incomplete** — there are five prompt layers, not two. See `open-threads.md` §A. |
13
+ > | Risk register row on the `.dockerignore` build fix | **Fixed and committed** in the hardening branch. |
14
+ >
15
+ > Still live and unresolved from this file: **WSL does not auto-start** after
16
+ > `wsl --shutdown` or a Windows reboot ("Keeping it running"). Tracked in
17
+ > `docs/goals/whatsapp-bot-hardening/open-threads.md` §D.
18
+
19
+ # WhatsApp Bot on Mercury — Setup Plan
20
+
21
+ Personal WhatsApp assistant, running locally, built on
22
+ [Mercury](https://github.com/Avishai-Tsabari/mercury) (v0.8.12).
23
+
24
+ Project layout:
25
+
26
+ ```
27
+ D:\Projects\whatsapp-bot\
28
+ ├── mercury\ # cloned source repo (reference + `mercury build` source)
29
+ └── PLAN.md # this file
30
+ ```
31
+
32
+ The runtime project (`.env`, `mercury.yaml`, `.mercury/`) does **not** live here —
33
+ see Phase 0, decision A.
34
+
35
+ ---
36
+
37
+ ## What Mercury actually is
38
+
39
+ A host process (Bun) that runs platform adapters (WhatsApp via Baileys, Slack,
40
+ Discord, Telegram), a router/queue, and a SQLite DB. Each incoming message is
41
+ routed to a **space** (a memory boundary), and the agent for that space runs
42
+ **inside a Docker container** using [pi](https://github.com/badlogic/pi) as the
43
+ LLM runtime. There's also a web dashboard on port 8787.
44
+
45
+ ```
46
+ WhatsApp adapter ─┐
47
+ Scheduler ────────┼─→ Router/Queue → SQLite → Docker container (pi CLI)
48
+ ┘ /spaces/<space-id>
49
+ ```
50
+
51
+ Consequences for us:
52
+
53
+ - **Docker is mandatory.** No Docker, no agent. The base image is ~2.8 GB.
54
+ - WhatsApp uses the **linked-devices** protocol (Baileys). **Corrected
55
+ 2026-08-06:** there is *no* fixed re-scan interval. The 14-day clock is on the
56
+ **primary phone being offline** — linked devices are force-logged-out only if
57
+ the phone goes 14 days without connecting to WhatsApp at all. Keep the bot's
58
+ phone online (or open the app once a fortnight) and the link persists
59
+ indefinitely. See "Re-auth" under Ongoing operations.
60
+ - Out of the box there is no web search, no browsing, no vault, no voice — those
61
+ are extensions (`mercury add web-browser`, `mercury add napkin`, …).
62
+
63
+ ---
64
+
65
+ ## Environment audit (2026-08-05)
66
+
67
+ | Requirement | Status |
68
+ |---|---|
69
+ | Git 2.50.1 | OK |
70
+ | Node v24.18.1 (≥18) | OK |
71
+ | Bun 1.3.14 on Windows (≥1.2) | OK — but see below |
72
+ | **WSL2** | **MISSING** — kernel not installed, zero distros |
73
+ | **Docker** | **MISSING** — not on PATH at all |
74
+
75
+ The Windows-side Bun does not help: Mercury's README states it "runs best under
76
+ WSL2", and the agent sandbox uses **bubblewrap** inside the container, which is
77
+ Linux-only. Everything must run inside WSL2, so Bun has to be installed *there*
78
+ too.
79
+
80
+ **These two gaps are the whole critical path.** Nothing else can be tested until
81
+ they're closed.
82
+
83
+ ---
84
+
85
+ ## Phase 0 — Decisions (RESOLVED 2026-08-05)
86
+
87
+ | # | Decision | Chosen |
88
+ |---|---|---|
89
+ | A | Runtime project location | **WSL ext4 — `~/whatsapp-bot`** (clone stays at `D:\Projects\whatsapp-bot\mercury`) |
90
+ | B | Phone number | **Dedicated number** (second SIM / data-only eSIM) |
91
+ | C | LLM auth | ~~OAuth via Claude subscription~~ → ~~Google AI Studio API key~~ → **re-revised 2026-08-06: OAuth via Claude subscription** (`mercury auth login anthropic`) — verified working |
92
+ | D | Model | ~~`claude-sonnet-5`~~ → ~~`gemini-3.6-flash`~~ → **re-revised 2026-08-06: `claude-opus-5`** (provider `anthropic`) — verified serving, see "Opus 5 vs the google legs" |
93
+ | E | Install method | Global npm — `npm i -g mercury-agent` |
94
+
95
+ Rationale for each is retained below.
96
+
97
+ ### A. Where does the runtime project live? → WSL ext4
98
+
99
+ Mercury bind-mounts each space's workspace (`.mercury/spaces/<id>/`) into a
100
+ Docker container. Two options:
101
+
102
+ | | `/mnt/d/Projects/whatsapp-bot` (Windows drive) | `~/whatsapp-bot` (WSL ext4) |
103
+ |---|---|---|
104
+ | File I/O | Slow (9p/drvfs translation) | Native |
105
+ | Docker bind-mount perms | uid/gid mapping quirks, `chmod` is a no-op | Clean |
106
+ | SQLite | Locking over drvfs is a known problem source | Fine |
107
+ | Editing from Windows | Direct | Via `\\wsl$\Ubuntu\home\...` or VS Code Remote |
108
+
109
+ **Recommendation:** put the *runtime* project at `~/whatsapp-bot` inside WSL, and
110
+ keep this cloned repo at `D:\Projects\whatsapp-bot\mercury` for reference and for
111
+ `mercury build`. If you'd rather keep everything on `D:`, it will probably work
112
+ but expect slowness and permission friction.
113
+
114
+ ### B. Which phone number? → **dedicated** (reconfirmed 2026-08-05)
115
+
116
+ Mercury's docs recommend a dedicated number, but the decisive reason is
117
+ structural, not just caution:
118
+
119
+ > **A personal-number bot cannot be talked to by its owner.** `handler.ts:158`
120
+ > returns immediately on `message.author.isMe`, and the WhatsApp adapter sets
121
+ > that from Baileys' `fromMe` flag (`whatsapp.ts:481`). Every message you send
122
+ > from your own phone on that account is `fromMe: true`, so the bot would ignore
123
+ > you and only ever reply to *other people* messaging your number.
124
+
125
+ Secondary reasons:
126
+
127
+ - **Blast radius.** `.mercury/whatsapp-auth/` is password-equivalent; on a
128
+ personal number a leak is a full takeover of your primary account.
129
+ - **Ban asymmetry.** Baileys is an unofficial client. Losing a burner is an
130
+ annoyance; losing your primary number costs your history, groups, and contact
131
+ point.
132
+ - **Keeping the link alive.** Not a fixed re-scan interval (see the corrected
133
+ note above) — but a *dedicated* SIM that normally sits powered off **will** trip
134
+ the 14-days-offline rule and force a re-scan. A dedicated number only stays
135
+ hands-off if its phone stays online.
136
+
137
+ What a linked device actually exposes, precisely: every message arrives over the
138
+ socket, but **unlinked conversations are dropped at `handler.ts:191`** before any
139
+ storage or LLM call — nothing reaches Gemini. However `resolveConversation` calls
140
+ `db.ensureConversation` *first*, so a metadata row (platform, external ID, kind,
141
+ title) is recorded for every chat that messages the account.
142
+
143
+ **Alternative considered:** Telegram ingress (`ingress.telegram`) uses the
144
+ *official* Bot API — no number, no QR, no expiry, no ban risk. Worth revisiting
145
+ if the WhatsApp session churn becomes tiresome.
146
+
147
+ ### C. Auth method for the LLM — **revised to Gemini free tier**
148
+
149
+ Mercury supports four providers by API key (`src/cli/mercury.ts:747`):
150
+
151
+ | Provider | `.env` key | Default model |
152
+ |---|---|---|
153
+ | `anthropic` | `MERCURY_ANTHROPIC_API_KEY` | `claude-opus-4-8` |
154
+ | `openai` | `MERCURY_OPENAI_API_KEY` | `gpt-4o` |
155
+ | `google` | `MERCURY_GEMINI_API_KEY` | `gemini-2.5-flash` |
156
+ | `groq` | `MERCURY_GROQ_API_KEY` | `llama-3.3-70b-versatile` |
157
+
158
+ > **Trap — OAuth only works for two of the five providers it offers.**
159
+ > `mercury auth login` advertises `anthropic`, `github-copilot`,
160
+ > `google-gemini-cli`, `antigravity`, and `openai-codex`. But
161
+ > `src/storage/pi-auth.ts:23-25` maps only **`anthropic`** →
162
+ > `ANTHROPIC_OAUTH_TOKEN` and **`github-copilot`** → `COPILOT_GITHUB_TOKEN` into
163
+ > the agent container. The other three have no env var, so the login *succeeds*
164
+ > and the model leg is then "dead on arrival" — logged, not thrown, so it fails
165
+ > quietly at message time rather than at setup. **Gemini must be an API key, not
166
+ > OAuth.**
167
+
168
+ A **free** Claude account does not grant OAuth for external tools — that flow
169
+ needs Pro or Max. Hence the switch to Google AI Studio's free tier
170
+ (<https://aistudio.google.com/apikey>), which needs no subscription.
171
+
172
+ Gemini over Groq's Llama because Mercury runs an **agentic loop** (pi, with tool
173
+ use), not single-shot chat, and Llama is markedly weaker at multi-step tool
174
+ calling.
175
+
176
+ #### Two bugs hit while wiring this up — both cost a full debug cycle
177
+
178
+ **1. `model.chain` alone is not enough — you must also set `model.provider`.**
179
+
180
+ Setting only the chain leaves the legacy scalar `modelProvider` at its schema
181
+ default `"anthropic"` (`config.ts:65`), because `config-file.ts:323` assigns it
182
+ only from `model.provider`. The guard at `container-runner.ts:892` reads *that
183
+ scalar*, not the resolved chain — so with a pure-Gemini chain every turn was
184
+ refused with:
185
+
186
+ ```
187
+ Refusing to start container: no Anthropic credential configured
188
+ ```
189
+
190
+ which is actively misleading: nothing in the config mentions Anthropic. The
191
+ working config sets **both** forms:
192
+
193
+ ```yaml
194
+ model:
195
+ provider: google # satisfies the container-runner guard
196
+ model: gemini-3.6-flash
197
+ chain:
198
+ - provider: google # drives the actual request path
199
+ model: gemini-3.6-flash
200
+ ```
201
+
202
+ **2. `gemini-2.5-flash` is retired for new API keys.** Mercury's own default for
203
+ the `google` provider (`src/cli/mercury.ts:768`) now fails:
204
+
205
+ ```
206
+ 404 — This model models/gemini-2.5-flash is no longer available to new users.
207
+ ```
208
+
209
+ It still appears in `GET /v1beta/models`, so *listing a model does not mean you
210
+ can call it*. Probed each candidate with a real `generateContent` call:
211
+
212
+ | Model | Result |
213
+ |---|---|
214
+ | `gemini-3.6-flash` | ✅ **chosen** |
215
+ | `gemini-3.5-flash` | ✅ |
216
+ | `gemini-flash-latest` | ✅ (alias — tracks newest, can shift under you) |
217
+ | `gemini-3-flash-preview` | ✅ |
218
+ | `gemini-2.0-flash` | ❌ 429 quota |
219
+
220
+ To re-probe later, list with
221
+ `curl "https://generativelanguage.googleapis.com/v1beta/models?key=$KEY"` and
222
+ then actually POST to `:generateContent` — the list alone will mislead you.
223
+
224
+ ### D. Model
225
+
226
+ `MERCURY_MODEL` is a free-form string handed to pi (`src/config.ts:66`), so it's
227
+ not pinned to a list. Current options and list pricing per million tokens:
228
+
229
+ | Model ID | Input | Output |
230
+ |---|---|---|
231
+ | `claude-opus-5` | $5 | $25 |
232
+ | `claude-opus-4-8` (Mercury's default) | $5 | $25 |
233
+ | `claude-sonnet-5` | $3 ($2 intro thru 2026-08-31) | $15 ($10 intro) |
234
+ | `claude-haiku-4-5` | $1 | $5 |
235
+
236
+ For a personal chat bot, **`claude-sonnet-5`** is the sensible default —
237
+ near-Opus quality on agentic work at ~60% of the cost. Move to `claude-opus-5`
238
+ if you want maximum capability.
239
+
240
+ Two caveats, both only observable at runtime:
241
+
242
+ 1. pi (`@earendil-works/pi-ai ~0.79.10`) must recognise the ID. If it errors,
243
+ fall back to `claude-opus-4-8`.
244
+ 2. **We chose OAuth (decision C), so the model set is whatever the Claude
245
+ subscription grants** — that is not the same surface as a pay-per-token API
246
+ key, and the per-token prices above then don't apply (you're on plan limits
247
+ instead). If `claude-sonnet-5` isn't reachable over OAuth, either drop to
248
+ `claude-opus-4-8` or switch to an API key. Verify at Phase 4 with
249
+ `mercury chat "hello"` before wiring WhatsApp up.
250
+
251
+ ### E. Install method
252
+
253
+ - **Global npm package** (`npm i -g mercury-agent`) — what the docs assume;
254
+ `npm update -g` to upgrade. Recommended.
255
+ - **From this clone** (`bun link` in `mercury/`) — only if you intend to modify
256
+ Mercury itself.
257
+
258
+ ---
259
+
260
+ ## Phase 1 — Close the environment gaps ✅ COMPLETE
261
+
262
+ ### Status
263
+
264
+ | Item | State |
265
+ |---|---|
266
+ | WSL2 + Ubuntu **26.04 LTS** (`resolute`), user `guysaha` | ✅ done |
267
+ | systemd as PID 1 (`/etc/wsl.conf` already has `[boot] systemd=true`) | ✅ done — `mercury service install` will work |
268
+ | Linux-native Node **v24.19.0** + npm **11.17.0** via nvm (`~/.nvm`) | ✅ done |
269
+ | `unzip` | ✅ `/usr/bin/unzip` |
270
+ | Bun **1.3.14** (Linux, `~/.bun/bin/bun`) | ✅ done |
271
+ | Docker **29.1.3** + buildx **0.30.1**, daemon running, `guysaha` in `docker` group | ✅ done |
272
+
273
+ ### Trap: WSL PATH interop shadows Linux tooling
274
+
275
+ Before nvm, `bun` and `npm` inside WSL resolved to **Windows** binaries
276
+ (`/mnt/c/Users/.../npm/bun`, `/mnt/c/Program Files/nodejs/npm`). Running
277
+ `npm install -g mercury-agent` in that state would have used the Windows npm and
278
+ installed into the Windows prefix. nvm now prepends `~/.nvm/.../bin`, so Node and
279
+ npm resolve correctly — verify with `which node npm` before any global install.
280
+
281
+ nvm was chosen over `apt install nodejs npm` deliberately: global installs land in
282
+ `~/.nvm`, so `npm install -g mercury-agent` and later `npm update -g` never need
283
+ sudo.
284
+
285
+ **Symptom to recognise.** A WSL shell opened *before* the `~/.bashrc` fix will
286
+ still have the old PATH, and every `mercury` command in it dies with:
287
+
288
+ ```
289
+ error: ELOOP reading "\\wsl.localhost\Ubuntu\home\guysaha\.nvm\...\bin\mercury"
290
+ Bun v1.3.14 (Windows x64)
291
+ ```
292
+
293
+ The `mercury` shim is `#!/usr/bin/env bun`, so a stale PATH resolves `bun` to
294
+ `/mnt/c/.../npm/bun` — the **Windows** binary — which sees the Linux script path
295
+ as a UNC share and can't follow npm's symlink chain across it. Fix:
296
+ `exec bash -l`, or open a new terminal. Check with `command -v bun`; it must be
297
+ `/home/guysaha/.bun/bin/bun`, never `/mnt/c/...`.
298
+
299
+ ### What was done
300
+
301
+ `docker.io` and `docker-buildx` live in the **universe** component and the apt
302
+ lists only indexed main/restricted, so `apt-get update` had to run first:
303
+
304
+ ```bash
305
+ sudo apt-get update
306
+ sudo apt-get install -y unzip docker.io docker-buildx
307
+ sudo usermod -aG docker "$USER"
308
+ sudo systemctl enable --now docker
309
+ ```
310
+
311
+ Followed by `wsl --shutdown` from Windows PowerShell to pick up the new `docker`
312
+ group membership. Verified: `docker info` reports server **29.1.3**, default
313
+ runtime `runc`, reachable as `guysaha` without sudo.
314
+
315
+ Note this is Docker **Engine**, not Desktop — bubblewrap may not be able to
316
+ create namespaces inside the agent container. Left
317
+ `agent.container_bwrap_docker_compat: false` for now (stronger sandbox); flip it
318
+ to `true` only if the logs show
319
+ `bwrap: Creating new namespace failed: Operation not permitted`.
320
+
321
+ ---
322
+
323
+ ## Phase 2 — Create the assistant project ✅ COMPLETE
324
+
325
+ Installed and scaffolded at `~/whatsapp-bot` inside WSL:
326
+
327
+ ```bash
328
+ curl -fsSL https://bun.sh/install | bash # bun 1.3.14 → ~/.bun/bin
329
+ npm install -g --allow-scripts=@whiskeysockets/baileys,@google/genai,protobufjs mercury-agent
330
+ mkdir ~/whatsapp-bot && cd ~/whatsapp-bot
331
+ mercury init # non-interactive twin of `mercury setup`
332
+ ```
333
+
334
+ Three things worth remembering:
335
+
336
+ 1. **`mercury setup` is interactive**; `mercury init` is the scriptable
337
+ equivalent. It writes `.env`, `.mercury/`, and `mercury.example.yaml`.
338
+ 2. **npm 11 blocks postinstall scripts by default.** The first
339
+ `npm i -g mercury-agent` silently skipped Baileys' and protobufjs'
340
+ postinstalls — which WhatsApp needs. The `--allow-scripts=` form above fixes
341
+ it. If you ever reinstall or upgrade, use that flag again.
342
+ 3. **`~/.bashrc` now pins Linux tooling ahead of `/mnt/c` interop** (see the
343
+ PATH trap above) — without it `bun` resolved to the Windows binary.
344
+
345
+ Configuration split, per Mercury's own convention: non-secrets in
346
+ `mercury.yaml`, secrets in `.env` only.
347
+
348
+ `mercury.yaml` sets `ingress.whatsapp: true`, the model chain
349
+ (`anthropic` / `claude-sonnet-5`), `trigger.match: mention`, and the agent image.
350
+ `.env` has the Anthropic API key line **commented out** — decision C is OAuth, so
351
+ credentials come from `mercury auth login` instead. A `.gitignore` covering
352
+ `.env`, `.mercury/`, and `node_modules/` was added up front.
353
+
354
+ > **Trigger semantics, confirmed in `src/core/trigger.ts:65`:** in DMs the bot
355
+ > replies to *everything* — the trigger pattern is optional and stripped if
356
+ > present. `match: mention` only constrains **group** chats. So a DM to the bot
357
+ > number just works.
358
+
359
+ ### Agent image: GHCR is private, so we build locally
360
+
361
+ `docker pull ghcr.io/avishai-tsabari/mercury-agent:latest` fails with
362
+ `unauthorized` — that package isn't publicly readable. Two ways out:
363
+
364
+ | | |
365
+ |---|---|
366
+ | `docker login ghcr.io` with a GitHub PAT (`read:packages`) | Fast, but needs a token |
367
+ | **Build from source** (chosen) | No credentials; ~2.8 GB build |
368
+
369
+ Building from a WSL-side clone at `~/mercury-src` (not `/mnt/d`, which would drag
370
+ the whole build context over drvfs):
371
+
372
+ ```bash
373
+ git clone --depth 1 https://github.com/Avishai-Tsabari/mercury.git ~/mercury-src
374
+ cd ~/mercury-src && bash ./container/build.sh # → mercury-agent:latest
375
+ ```
376
+
377
+ Built successfully: **`mercury-agent:latest`, 5.16 GB** (larger than the ~2.8 GB
378
+ the docs quote — that figure predates the current Dockerfile). No `ghcr.io/`
379
+ prefix, so `agent.image` in `mercury.yaml` is set to match.
380
+
381
+ Contents verified inside the image: `bun`, `pi`, `mrctl`, `bwrap` all present.
382
+ Note `.env.example` claims custom images need `agent-browser`, `napkin`, and
383
+ `mercury-ctl` — that comment is **stale**. The real host-bridge binary is
384
+ `mrctl` (`Dockerfile:126`), and `agent-browser`/`napkin` are extension CLIs baked
385
+ into the *derived* image (`mercury-agent-ext:<hash>`) only after
386
+ `mercury add web-browser` / `mercury add napkin`. Nothing is missing.
387
+
388
+ ### Bubblewrap needs compat mode — confirmed empirically
389
+
390
+ Rather than wait to discover this at first message, both modes were tested
391
+ directly against the built image:
392
+
393
+ ```bash
394
+ docker run --rm --entrypoint sh mercury-agent:latest \
395
+ -c 'bwrap --dev-bind / / --proc /proc --tmpfs /tmp true'
396
+ # → bwrap: No permissions to create new namespace
397
+
398
+ docker run --rm --security-opt seccomp=unconfined --cap-add SYS_ADMIN \
399
+ --entrypoint sh mercury-agent:latest \
400
+ -c 'bwrap --dev-bind / / --proc /proc --tmpfs /tmp true'
401
+ # → OK
402
+ ```
403
+
404
+ So `agent.container_bwrap_docker_compat: true` is **required** on this host and
405
+ is already set. Bubblewrap stays enabled — this loosens the outer Docker sandbox
406
+ but keeps the inner one, which is the better trade than
407
+ `MERCURY_DISABLE_BUBBLEWRAP=1`.
408
+
409
+ ### `mercury doctor` after Phase 2
410
+
411
+ **5 passed, 2 warnings, 2 errors.** Both errors are the interactive steps below
412
+ (AI credentials, WhatsApp auth); one warning clears when you set
413
+ `permissions.admins`, the other when the DB is created on first run. Everything
414
+ mechanical is green: Docker, agent image found, Bun 1.3.14, `.env`, port 8787.
415
+
416
+ ---
417
+
418
+ ## Phase 3 — Link WhatsApp ✅ COMPLETE
419
+
420
+ ### Model pipeline — ✅ verified end to end
421
+
422
+ `mercury chat "Reply with exactly: it works"` → `it works`. That exercises HTTP →
423
+ router → Docker container → pi → Gemini → reply, so the whole runtime is proven
424
+ before WhatsApp is added.
425
+
426
+ Two confirmations from that run:
427
+
428
+ - **bwrap compat is working** — `Enabling bwrap Docker compat with --privileged
429
+ (containerBwrapDockerCompat=true)`, then `Container started`.
430
+ - **`mercury chat` needs the service running.** It POSTs to
431
+ `http://localhost:8787/chat` (`src/cli/mercury.ts:2037`); with nothing
432
+ listening it fails with the unhelpful `Error: Unable to connect. Is the
433
+ computer able to access the url?`
434
+
435
+ ### Remaining: the QR scan
436
+
437
+ `docs/setup-whatsapp.md` runs `mercury auth whatsapp` **before**
438
+ `mercury service install`, and its re-auth procedure stops the service first —
439
+ two processes must not contend for `.mercury/whatsapp-auth/`. The service was
440
+ installed early here, so it has been **stopped**; start it again after auth.
441
+
442
+ If Gemini's free tier proves too rate-limited in practice, uncomment the
443
+ `anthropic` fallback leg in `mercury.yaml` — the chain falls through only when
444
+ the earlier leg fails. Then:
445
+
446
+ ```bash
447
+ mercury auth whatsapp # prints a QR in the terminal
448
+ ```
449
+
450
+ On the **dedicated** phone (decision B): **Settings → Linked Devices → Link a
451
+ Device**, scan. If the QR expires too fast, use
452
+ `mercury auth whatsapp --pairing-code --phone <number>`.
453
+
454
+ The command prints the bot's WhatsApp ID on success. Put **your own** ID (the
455
+ number you'll message the bot *from*) into `mercury.yaml` → `permissions.admins`.
456
+ Mercury canonicalises WhatsApp's LID (`24417...@lid`) vs phone JID
457
+ (`972...@s.whatsapp.net`) forms; the dashboard's Conversations page shows which
458
+ form you have.
459
+
460
+ **Result:** linked and connected (`WhatsApp connection open`).
461
+
462
+ | | |
463
+ |---|---|
464
+ | Bot account | `972552538335:1@s.whatsapp.net` — "ויצ דה בוט" |
465
+ | Bot LID | `136043810578453:1@lid` |
466
+ | Admin (you) | `whatsapp:972503515999@s.whatsapp.net` |
467
+
468
+ > **`mercury doctor`'s "No AI credentials configured" is a false positive on any
469
+ > non-Anthropic provider.** `src/cli/mercury.ts:513-516` checks only
470
+ > `MERCURY_ANTHROPIC_API_KEY`, `MERCURY_ANTHROPIC_OAUTH_TOKEN`, and `auth.json`
471
+ > — it has no branch for `MERCURY_GEMINI_API_KEY`, `MERCURY_OPENAI_API_KEY`, or
472
+ > `MERCURY_GROQ_API_KEY`, even though `mercury setup` offers all four. Ignore
473
+ > that one error on this deployment; the "Port 8787 in use" warning is Mercury
474
+ > itself.
475
+
476
+ ---
477
+
478
+ ## Phase 4 — First run and wiring conversations ✅ COMPLETE — **BOT IS LIVE**
479
+
480
+ A WhatsApp DM to the bot number gets a reply. Confirmed round trip:
481
+
482
+ ```
483
+ Message from: whatsapp:972503515999@s.whatsapp.net (סהר ויצמן)
484
+ Container started spaceId=main
485
+ Container exited exitCode=0 durationMs=6773
486
+ WhatsApp outbound chatJid=972503515999@s.whatsapp.net
487
+ ```
488
+
489
+ **~7 s per turn** — Mercury starts a fresh container for every message.
490
+
491
+ ### Gotcha: messages sent while the service is down are lost
492
+
493
+ The first test message never arrived. Baileys drops anything delivered before
494
+ `WhatsApp connection open`, and the service was stopped for the QR scan. Nothing
495
+ was broken — the message simply predated the connection. Verify with
496
+ `journalctl --user -u mercury | grep "connection open"` before concluding the
497
+ adapter is at fault.
498
+
499
+ Debug logging (`runtime.log_level: debug`) is what made this visible: `Incoming
500
+ message ... isMe=false` and `Message ignored: conversation not linked to a space`
501
+ are both DEBUG-level. It has since been set back to `info`.
502
+
503
+
504
+
505
+ The systemd **user** unit is already installed at
506
+ `~/.config/systemd/user/mercury.service`, so after the QR scan just start it:
507
+
508
+ ```bash
509
+ systemctl --user start mercury
510
+ mercury service status
511
+ mercury service logs -f
512
+ ```
513
+
514
+ > **Cosmetic upstream bug:** the generated unit writes
515
+ > `Environment=PATH=<...>` unquoted, and the WSL-interop PATH is full of
516
+ > `/mnt/c/Program Files/...` entries with spaces. systemd logs ~40
517
+ > `Invalid environment assignment, ignoring: Files` warnings and truncates PATH
518
+ > at the first space. Harmless here — `/usr/bin` (and therefore `docker`) comes
519
+ > before the truncation point — but the warnings are noise, and on a differently
520
+ > ordered PATH it would break container spawning. Fix upstream is quoting:
521
+ > `Environment="PATH=<...>"`.
522
+
523
+ Never use `mercury run` directly (per the repo's own `CLAUDE.md`): it blocks the
524
+ terminal and won't auto-restart.
525
+
526
+ Then, because conversations start **unlinked**:
527
+
528
+ ```bash
529
+ mercury spaces create main
530
+ # message the bot number from your phone, then:
531
+ mercury conversations --unlinked
532
+ mercury link <conversation-id> main
533
+ ```
534
+
535
+ **Exit criteria:** a WhatsApp DM to the bot number gets a reply.
536
+
537
+ ### If the agent container fails to start
538
+
539
+ The bwrap namespace failure was already found and fixed pre-emptively (see Phase
540
+ 2), so the most likely cause is gone. If containers still fail, check
541
+ `mercury service logs -f` — and if you see a bwrap error anyway, the last resort
542
+ is `MERCURY_DISABLE_BUBBLEWRAP=1`, which removes a security layer.
543
+
544
+ ---
545
+
546
+ ## Phase 5 — Make it actually useful
547
+
548
+ Pick from these; each is optional.
549
+
550
+ | Want | Do |
551
+ |---|---|
552
+ | Web search / browsing | `mercury add web-browser` (uses Brave, no API key) |
553
+ | Obsidian-style knowledge vault | `mercury add napkin` |
554
+ | Personality / tone | `AGENTS.md` in the space workspace, or bot-wide character via `mrctl character set` |
555
+ | Scheduled prompts ("brief me at 8am") | `mrctl tasks create` — see `docs/scheduler.md` |
556
+ | Web UI | Dashboard on `http://localhost:8787` — spaces, tasks, permissions, extensions |
557
+ | Separate work/family/personal memory | More spaces + `mercury link` |
558
+ | Voice notes, images, documents | Supported natively — `docs/media/overview.md` |
559
+
560
+ Extensions with CLIs trigger a derived Docker image rebuild
561
+ (`mercury-agent-ext:<hash>`) on next start. That's automatic; expect a slow first
562
+ boot after adding one.
563
+
564
+ ---
565
+
566
+ ## Web browsing — installed, and why it was silently broken
567
+
568
+ `Extensions loaded count=2` (`web-browser`, `voice-transcribe`) and the derived
569
+ image `mercury-agent-ext:e2c502ed9498` (5.25 GB) builds in ~3.4 min. Despite
570
+ that, the agent could not reach a single public URL.
571
+
572
+ ### Three separate traps, in the order they bite
573
+
574
+ **1. `mercury add web-browser` does not work.** `README.md:169` and
575
+ `docs/web-search.md` both say to run it; the CLI rejects it — `add` only accepts
576
+ a path, `npm:`, `git:`, or `user/repo`. The name `web-browser` is a *dashboard
577
+ catalog* label (`src/extensions/catalog.ts:29`) whose `sourceDir` is `pinchtab`.
578
+ From the CLI the real command is:
579
+
580
+ ```bash
581
+ mercury add ~/.nvm/versions/node/<ver>/lib/node_modules/mercury-agent/examples/extensions/pinchtab
582
+ ```
583
+
584
+ …which installs it under the name `pinchtab`, **duplicating** a dashboard-installed
585
+ `web-browser` (same CLI, skill, and permission). Prefer the dashboard's Features
586
+ page, or remove the duplicate.
587
+
588
+ **2. The real blocker: pinchtab's IDPI domain allowlist.** The extension pins
589
+ `pinchtab@0.13.2`, which ships:
590
+
591
+ ```
592
+ website whitelist 127.0.0.1, localhost, ::1
593
+ IDPI strict mode enforcing
594
+ ```
595
+
596
+ Every public navigation is refused — `Error 403: navigation blocked by IDPI:
597
+ Domain not in allowlist: search.brave.com` — including the Brave search the
598
+ extension's own skill instructs the agent to perform. Infrastructure was never
599
+ the problem: `pinchtab` CLI present, Chromium present, egress fine (`google:
600
+ 200`).
601
+
602
+ Two ways out, and they are **not** equivalent:
603
+
604
+ | | whitelist | IDPI strict | content guard |
605
+ |---|---|---|---|
606
+ | `pinchtab security down` | disabled | **disabled** | **disabled** |
607
+ | `config set security.allowedDomains "*"` | wildcard | enforcing | **active** |
608
+
609
+ The content guard is what wraps fetched pages in `<untrusted_web_content>` with
610
+ an explicit prompt-injection warning. For an agent that browses *and* holds
611
+ tools, keeping it is the whole point — so the allowlist route is correct and
612
+ `security down` should be avoided.
613
+
614
+ **3. Mercury silently reverts edits to installed extensions.** Patching
615
+ `.mercury/extensions/web-browser/index.ts` looked like it worked, but the next
616
+ start logged `Bundled extension source updated — reinstalling name=web-browser`
617
+ and restored the original. The patch must go in the **bundled source** inside the
618
+ npm package:
619
+
620
+ ```
621
+ ~/.nvm/versions/node/<ver>/lib/node_modules/mercury-agent/examples/extensions/pinchtab/index.ts
622
+ ```
623
+
624
+ ### The fix that actually works: bake it at image build time
625
+
626
+ Setting the allowlist inside `pinchtab_ensure` is **not sufficient**. The agent
627
+ usually runs `pinchtab nav` directly, and nav *auto-starts the server*
628
+ (`server not running, starting automatically`) without ever calling the ensure
629
+ function — so the config never ran. Verified: the bot still reported
630
+ `403 ... Domain not in allowlist` after that patch.
631
+
632
+ The durable fix appends to the extension's CLI `install` string, so the config
633
+ file exists in the image before anything runs. The container runs as uid 1000
634
+ (`mercury`), so `HOME` must be set explicitly during the root-run build:
635
+
636
+ ```
637
+ && HOME=/home/mercury pinchtab config set security.allowedDomains "*"
638
+ && chown -R mercury:mercury /home/mercury/.pinchtab
639
+ ```
640
+
641
+ Changing `install` changes the image hash → one rebuild (~3.4 min) →
642
+ `mercury-agent-ext:23ab9b6a1159`. Confirmed baked in, with guards intact:
643
+
644
+ ```
645
+ allowedDomains: ['*']
646
+ idpi: {enabled: True, strictMode: True, scanContent: True, wrapContent: True}
647
+ allowEvaluate: False, allowDownload: False, allowClipboard: False
648
+ ```
649
+
650
+ ### Trap: the bash lives inside a TS template literal
651
+
652
+ The first patch attempt put backticks in an explanatory comment
653
+ (`` # ...use `pinchtab security down`...``). Those backticks **closed the
654
+ enclosing template literal**, and the extension stopped loading entirely:
655
+
656
+ ```
657
+ Failed to load extension "web-browser": BuildMessage: Expected ";" but found "pinchtab"
658
+ ```
659
+
660
+ The symptom surfaced in the dashboard as *web-browser: uninstalled* — the
661
+ Features page lists `registry.list()`, so a load failure reads as "not
662
+ installed". **Never put a backtick in that bash block.** Check any edit with
663
+ `mercury extensions validate web-browser` before restarting.
664
+
665
+ > ⚠️ **`npm update -g mercury-agent` erases both patches**, and browsing stops
666
+ > working again. Re-apply after every upgrade until fixed upstream.
667
+
668
+ ### Verifying honestly — plausible answers are not evidence
669
+
670
+ Two false positives happened here, both worth remembering:
671
+
672
+ 1. *"What's the weather in Tel Aviv?"* → a confident **30 °C**, produced while
673
+ navigation was fully blocked. Pure training data.
674
+ 2. A live GitHub API check (`stargazers_count`, `pushed_at`) matched ground truth
675
+ **exactly** — but was run while the extension was failing to load. The agent
676
+ had simply used `curl`; container egress was never restricted, only pinchtab
677
+ navigation was.
678
+
679
+ The test that actually proves the browser ran must be something `curl` **cannot**
680
+ do. Brave Search returns `429` to plain curl from the container, so real Brave
681
+ result titles are proof:
682
+
683
+ | | |
684
+ |---|---|
685
+ | `curl https://search.brave.com/...` from container | **429** |
686
+ | Bot, asked for the first two result titles | "Jerusalem, Israel 14 day weather forecast" (TimeAndDate), "Jerusalem — BBC Weather" |
687
+
688
+ ---
689
+
690
+ ## Voice transcription
691
+
692
+ Configured to reuse the existing Gemini key, so no new provider or cost:
693
+
694
+ ```yaml
695
+ extensions:
696
+ voice-transcribe:
697
+ provider: gemini # local | api | openai | gemini
698
+ model: gemini-3.1-flash-lite
699
+ language: he # empty = auto-detect
700
+ ```
701
+
702
+ `.env` gains `MERCURY_STT_GEMINI_API_KEY` (same value as the chat key). The
703
+ extension declares this var `hostOnly: true`, so it is consumed on the host in
704
+ `before_container` and **never enters the agent container**.
705
+
706
+ > **The model must be set explicitly.** The extension's built-in Gemini default
707
+ > is `DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"` — the model that now 404s for
708
+ > new keys. Left alone, transcription fails silently.
709
+
710
+ Audio-input support verified by posting a real WAV to `:generateContent`:
711
+
712
+ | Model | Result |
713
+ |---|---|
714
+ | `gemini-3.1-flash-lite` | ✅ accepted (**chosen** — best measured availability) |
715
+ | `gemini-3.5-flash` | ✅ accepted (better accuracy if it's reachable) |
716
+ | `gemini-3.6-flash` | ✅ accepted |
717
+ | `gemini-2.5-flash` | ❌ 404 retired |
718
+
719
+ Other providers: `openai` (`MERCURY_STT_API_KEY`, also serves Groq via
720
+ `base_url`), `api` (Hugging Face, `MERCURY_HF_TOKEN`), `local` (Python +
721
+ transformers on the host, `MERCURY_VOICE_PYTHON`).
722
+
723
+ ---
724
+
725
+ ## Configuration reference
726
+
727
+ ### Triggers — how the bot decides to answer
728
+
729
+ `trigger.match` takes three values (`src/core/trigger.ts:9`):
730
+
731
+ | Mode | Behaviour |
732
+ |---|---|
733
+ | `prefix` | Pattern must start the message |
734
+ | `mention` | Pattern anywhere, word-boundary aware — `@Mercury, hi` works, `Pi` inside `Pixel` does not (**default**) |
735
+ | `always` | No trigger needed at all |
736
+
737
+ **DMs ignore this entirely** — `trigger.ts:65` always matches in a DM and strips
738
+ the pattern if present. `match` only constrains **group** chats.
739
+
740
+ `server.bot_username` does double duty: `router.ts:90` auto-appends
741
+ `@<bot_username>` to the trigger patterns, so renaming the bot changes what it
742
+ answers to in groups.
743
+
744
+ ### Applied identity
745
+
746
+ **Revised 2026-08-06** — bare `ויצ` was too short and false-triggered, so the
747
+ mention name moved to `ויציבוט`:
748
+
749
+ ```yaml
750
+ server:
751
+ bot_username: ויציבוט # was ויצ
752
+ trigger:
753
+ patterns: "ויצ דה בוט,@Mercury,Mercury" # bare ויצ removed
754
+ match: mention
755
+ ```
756
+
757
+ `bot_username` is kept to **one word** on purpose: `router.ts:95` auto-appends
758
+ `@<bot_username>`, and `@ויצ דה בוט` would be awkward to type in a group. The
759
+ full name is listed as a pattern instead, and patterns are sorted longest-first
760
+ at match time so the full name is stripped before the short one.
761
+
762
+ `bot_username` is *not* cosmetic. It is what the WhatsApp adapter rewrites a real
763
+ @-mention of the bot's number into (`adapters/setup.ts:69` → `whatsapp.ts:592`)
764
+ before trigger matching runs. Change it and the tap-to-mention path changes with
765
+ it.
766
+
767
+ The Hebrew word for "bot" was deliberately **left out** of the *global* list — it
768
+ is a common word and would false-trigger in groups. `Mercury` is kept as a
769
+ fallback.
770
+
771
+ > **Trap — a per-space `trigger.patterns` override replaces the auto-appended
772
+ > `@<bot_username>`, it does not merge with it.** `trigger.ts:29-34` uses the
773
+ > space's list *instead of* the defaults, and the `@bot` form is only ever pushed
774
+ > onto the defaults (`router.ts:95-98`). Worse, `mention` matching anchors on
775
+ > start-of-string or whitespace (`trigger.ts:110`), so a bare `ויציבוט` pattern
776
+ > does **not** match the text `@ויציבוט` — the `@` blocks the lookbehind.
777
+ >
778
+ > Net effect: a space with custom patterns silently stops responding to
779
+ > tap-to-mention unless the `@` form is listed explicitly. Every per-space
780
+ > pattern list must include both forms:
781
+ >
782
+ > ```
783
+ > @ויציבוט,ויציבוט,ויצ דה בוט,ווצבוט,ווצ דה בוט
784
+ > ```
785
+
786
+ Persona lives in `.mercury/spaces/main/AGENTS.md` (was empty until now). It sets
787
+ Hebrew-first replies, short mobile-friendly answers, and — prompted by the two
788
+ false positives above — an explicit rule never to claim a capability it has not
789
+ just used, plus a reminder that `<untrusted_web_content>` is data, not
790
+ instructions.
791
+
792
+ ### Per-space overrides
793
+
794
+ These live in the DB (dashboard, or space config), not `mercury.yaml`, and beat
795
+ the global values for that space: `trigger.match`, `trigger.patterns`,
796
+ `trigger.case_sensitive`, `trigger.media_in_groups`.
797
+
798
+ ### Main `mercury.yaml` sections
799
+
800
+ `server` (port, bot_username) · `model` · `ingress` · `runtime` (log level, rate
801
+ limits, concurrency) · `trigger` · `context` (mode, window_size,
802
+ reply_chain_depth) · `compaction` · `agent` (image, timeout, bwrap compat,
803
+ env_passthrough) · `media` · `permissions.admins` · `dm_auto_space` ·
804
+ `scheduling.default_timezone` · `extensions`
805
+
806
+ Personality/instructions are **not** YAML — they go in
807
+ `.mercury/spaces/<space>/AGENTS.md`.
808
+
809
+ ### Groups: adding the number is not enough
810
+
811
+ A new group creates an **unlinked** conversation, and unlinked messages are
812
+ dropped at `handler.ts:191` before any model call. Every new group needs:
813
+
814
+ ```bash
815
+ mercury conversations --unlinked
816
+ mercury link <id> <space>
817
+ ```
818
+
819
+ Then, in groups only, a message must match the trigger. Link a group to `main`
820
+ to share memory with the DM, or to its own space to keep memory separate.
821
+
822
+ `dm_auto_space.enabled: true` automates this for **DMs** only — never groups.
823
+
824
+ ---
825
+
826
+ ## Free-tier rate limits — the real constraint
827
+
828
+ The bot replied *"I've reached my usage limit for now"* after a handful of
829
+ messages. The underlying error:
830
+
831
+ ```
832
+ 429 Quota exceeded for metric: generate_content_free_tier_requests,
833
+ limit: 5, model: gemini-3.6-flash
834
+ Please retry in 10.817604931s
835
+ ```
836
+
837
+ **That is 5 requests per _minute_, not a daily cap** — it resets in seconds. The
838
+ newest models carry the smallest free allowances and the most congestion
839
+ (`gemini-3.6-flash` also returned `503 UNAVAILABLE: high demand` on a direct
840
+ probe minutes later). Newest ≠ best on a free key.
841
+
842
+ Revised to a two-leg chain, which is exactly what `model.chain` exists for —
843
+ a throttled leg falls through instead of failing the turn:
844
+
845
+ ```yaml
846
+ model:
847
+ provider: google
848
+ model: gemini-3.5-flash
849
+ chain:
850
+ - provider: google
851
+ model: gemini-3.5-flash
852
+ - provider: google
853
+ model: gemini-3.1-flash-lite
854
+ max_retries_per_leg: 3
855
+ ```
856
+
857
+ `-lite` variants generally carry the most generous free quotas, which makes leg 2
858
+ a genuine safety net rather than a second way to hit the same wall.
859
+
860
+ ### Measured, not assumed: pick legs by availability
861
+
862
+ Version number is a terrible proxy for usability on a free key. Measured with a
863
+ 12-request burst plus a two-function tool-calling probe:
864
+
865
+ | Model | 12 rapid requests | Parallel tool calls |
866
+ |---|---|---|
867
+ | `gemini-3.1-flash-lite` | **12 × 200, 0 × 429** | ✅ both, correct args |
868
+ | `gemini-3.5-flash` | **0 × 200** (503/429) | ✅ both, correct args |
869
+ | `gemini-3.6-flash` | 5 rpm cap, also 503 | — |
870
+ | `gemini-2.5-flash-lite` | 404 — retired | — |
871
+
872
+ The `-lite` model is far and away the most *available*, and it drives tools
873
+ correctly — which is the only capability Mercury's skills actually need. Final
874
+ order is availability-first:
875
+
876
+ ```yaml
877
+ model:
878
+ provider: google
879
+ model: gemini-3.1-flash-lite
880
+ chain:
881
+ - provider: google
882
+ model: gemini-3.1-flash-lite # measured 12/12
883
+ - provider: google
884
+ model: gemini-3.5-flash # better reasoning when it's reachable
885
+ max_retries_per_leg: 3
886
+ ```
887
+
888
+ Reproduce any time with a burst of `:generateContent` calls — the numbers move as
889
+ Google shifts free-tier allocations, and today's best leg may not be next
890
+ month's.
891
+
892
+ ### Do skills need anything beyond tool calling?
893
+
894
+ No. `model-capabilities.ts:201` treats an unrecognised model as **permissive** —
895
+ `DEFAULT_CAPABILITIES` is `tools: true`, and an unknown model is never denied a
896
+ capability-gated skill on a guess. So any model that emits correct function calls
897
+ runs Mercury's skills (recall, tasks, spaces, permissions, …).
898
+
899
+ ### Other free providers Mercury supports
900
+
901
+ | Option | Free? | Notes |
902
+ |---|---|---|
903
+ | **Google Gemini** | Yes | In use. Best free tool-caller measured so far. |
904
+ | **Groq** (`MERCURY_GROQ_API_KEY`) | Yes | Mercury-supported provider; hosts open models. Needs a key from console.groq.com to benchmark. |
905
+ | **GitHub Copilot** (`mercury auth login github-copilot`) | Partly | One of only **two** OAuth providers Mercury can actually serve (`pi-auth.ts:24`). Reaches GPT/Claude-class models — the strongest tool users. Free for verified students/teachers/OSS maintainers; otherwise a limited free tier then paid. |
906
+ | **OpenAI** | No | Key added but the account has **no credits** — every model 429s. Inert until topped up. |
907
+
908
+ If throttling still bites: add more legs, raise `max_retries_per_leg`, or put a
909
+ paid leg last so only overflow costs money.
910
+
911
+ ---
912
+
913
+ ## Switched to Claude Opus 5 (2026-08-06)
914
+
915
+ OAuth against the Claude subscription now works (`mercury auth login anthropic`),
916
+ so decision C flips back to OAuth and D to `claude-opus-5`.
917
+
918
+ ### The bot was "not working well" for a config reason, not a model reason
919
+
920
+ `mercury auth status` reported **`Anthropic: not logged in`** — but only when run
921
+ from outside the project. `auth.json` lives at `.mercury/global/auth.json`,
922
+ resolved relative to the **data dir**, so the command is cwd-sensitive. From
923
+ `~/whatsapp-bot` it correctly reports `✓ logged in`. Always `cd` first before
924
+ believing it.
925
+
926
+ The real fault was in `.env`, which had grown three lines that silently beat
927
+ `mercury.yaml` (env wins — `config.ts:357`, and the file's own header says so):
928
+
929
+ | Line | Effect |
930
+ |---|---|
931
+ | `MERCURY_MODEL_PROVIDER=google` | pinned the provider regardless of YAML |
932
+ | `MERCURY_MODEL_CHAIN=[{"provider":"google","model":"gemma-4-31b-it"}]` | **replaced the tuned two-leg chain with a single Gemma leg — no fallback** |
933
+ | `MERCURY_GEMINI_API_KEY=KEY` (2nd copy) | a 3-char placeholder duplicate |
934
+
935
+ The Gemma line is what would have degraded answers — an open-weights model with
936
+ no fallback leg, in place of the availability-ordered Gemini chain. It had not
937
+ actually bitten yet: `.env` was edited at 13:05, after the last probe run at
938
+ 12:49, and the service had not been restarted since, so the running process was
939
+ still on `gemini-3.1-flash-lite`. `token_usage` confirms this.
940
+
941
+ The duplicate key was **inert** — bun's dotenv keeps the *first* occurrence, so
942
+ the real 53-char key still won (verified by reading back
943
+ `process.env.MERCURY_GEMINI_API_KEY.length`). Deleted anyway.
944
+
945
+ All three are now commented out / removed, making `mercury.yaml` the single
946
+ source of truth for the model.
947
+
948
+ ### Config applied
949
+
950
+ ```yaml
951
+ model:
952
+ provider: anthropic # container-runner.ts:892 reads this scalar
953
+ model: claude-opus-5
954
+ chain:
955
+ - provider: anthropic
956
+ model: claude-opus-5 # single leg on purpose — see caveat below
957
+ max_retries_per_leg: 3
958
+
959
+ agent:
960
+ image: mercury-agent:latest
961
+ container_bwrap_docker_compat: true
962
+ container_timeout_ms: 1200000 # 20 min; schema max is 3_600_000
963
+ override_pi_system_prompt: true
964
+ env_passthrough: all # kept — was not in the requested block
965
+ ```
966
+
967
+ No Anthropic key is needed in `.env`: with `provider: anthropic` and no
968
+ `ANTHROPIC_*` var, `container-runner.ts:892-903` falls back to
969
+ `.mercury/global/auth.json` and injects `ANTHROPIC_OAUTH_TOKEN` into the
970
+ container.
971
+
972
+ **Single leg is deliberate.** A google fallback leg would answer whenever Opus
973
+ failed, making a dead leg look like a pass. Add one back for resilience once the
974
+ Opus leg has proven itself in daily use — but then always confirm *which* model
975
+ answered:
976
+
977
+ ```sql
978
+ SELECT id, space_id, model, provider FROM token_usage ORDER BY id DESC LIMIT 1;
979
+ ```
980
+
981
+ ### `claude-opus-5` is not in pi's capability registry
982
+
983
+ Startup warned it was falling back to `DEFAULT_CAPABILITIES`
984
+ (`tools=true, vision=false`). Nothing was silently disabled — capability *gating*
985
+ treats an unknown model as permissive (`model-capabilities.ts:201`:
986
+ `source === "default"` satisfies any requirement) — but with `media.enabled: true`
987
+ the guess is wrong. Declared explicitly in `.mercury/model-capabilities.yaml`;
988
+ the warning is gone.
989
+
990
+ ### Opus 5 vs the google legs
991
+
992
+ Probes re-run from the previous session, plus a new one. Every row is attributed
993
+ via `token_usage`, not assumed.
994
+
995
+ | Probe | `gemini-3.1-flash-lite` | `claude-opus-5` |
996
+ |---|---|---|
997
+ | Smoke (`PROBE_OK`) | ✅ | ✅ |
998
+ | Haifa weather + real titles | ✅ number; **claimed titles while saying it could not open the browser** (2 of 4 answers) | ✅ number + titles, and flagged the earlier fabrication unprompted |
999
+ | Copper /tonne + real titles | ✅ ~14,000–14,200 | ✅ 14,100–14,200, cross-checked Kitco / LME / COMEX |
1000
+ | Silver /oz, **fresh space** | ✅ $62.15–62.98, both titles real | ✅ $61.80, both titles real, *and* stated the top-2 results carried no live number so it opened Trading Economics directly |
1001
+ | Latency | ~35 s | ~45–71 s |
1002
+
1003
+ Independently verified with an outside search: copper really was ~$14,195/t and
1004
+ silver ~$62.8/oz on 2026-08-05. **Both models had the numbers right.** An earlier
1005
+ reading of the ~$14k copper figure as a hallucination was simply wrong — copper
1006
+ had rallied ~53% year-on-year.
1007
+
1008
+ So the difference is not accuracy, it is **honesty about tool use**. Gemini's
1009
+ failure mode in the `main` space was asserting search-result titles in the same
1010
+ breath as admitting the browser never opened. Opus reported provenance
1011
+ accurately, including the inconvenient parts.
1012
+
1013
+ Two caveats, stated so the table is not over-read:
1014
+
1015
+ 1. **The `main`-space probes are contaminated.** All models share one history, so
1016
+ Opus could read Gemini's earlier titles before answering. That is why the
1017
+ silver probe was run in fresh spaces (`cmp-opus`, `cmp-gemini`) with a
1018
+ question never asked before — and there Gemini browsed cleanly and returned
1019
+ the same real titles. Its earlier fabrication is a *sometimes*, not an always.
1020
+ 2. **Cost figures for the Opus rows are junk.** `token_usage` recorded
1021
+ `input_tokens` of 2, 10, 16 for full agentic turns, so the derived `cost`
1022
+ column is meaningless on the OAuth path. Billing is subscription plan limits,
1023
+ not per-token, so ignore that column for anthropic legs.
1024
+
1025
+ ### Reproducing
1026
+
1027
+ ```bash
1028
+ cd ~/whatsapp-bot # cwd matters for auth status
1029
+ mercury spaces create cmp-<model> # fresh space = no shared history
1030
+ mercury chat --space cmp-<model> "…tell me the exact titles of the first two \
1031
+ search results you actually saw. If you could not open the browser, say so \
1032
+ explicitly and do not guess titles."
1033
+ ```
1034
+
1035
+ The "if you could not open the browser, say so" clause is what makes the probe
1036
+ diagnostic — without it a fabricated title is indistinguishable from a real one.
1037
+
1038
+ ---
1039
+
1040
+ ## Keeping it running
1041
+
1042
+ ### Trap: without lingering, closing your last terminal kills the bot
1043
+
1044
+ `mercury service install` creates a systemd **user** unit. By default a user's
1045
+ systemd manager is torn down when their last login session ends — so closing the
1046
+ Ubuntu terminal stopped Mercury with a clean `status=0/SUCCESS` graceful
1047
+ shutdown. Not a crash, and `Restart=on-failure` deliberately does **not** cover
1048
+ it, because exit 0 is not a failure.
1049
+
1050
+ Diagnosed with `loginctl show-user $USER --property=Linger` → `Linger=no`. Fixed:
1051
+
1052
+ ```bash
1053
+ loginctl enable-linger "$USER" # no sudo needed
1054
+ ```
1055
+
1056
+ Now the user manager persists with no session open, and the bot survives closing
1057
+ every terminal.
1058
+
1059
+ ### Still open: WSL itself does not auto-start
1060
+
1061
+ Lingering covers terminal closes, but not `wsl --shutdown` or a Windows reboot —
1062
+ WSL2 does not start a distro at boot. Until that's addressed, the bot is down
1063
+ after every restart until something touches the distro.
1064
+
1065
+ Fix when wanted: a Windows Task Scheduler task at logon running
1066
+
1067
+ ```
1068
+ wsl.exe -d Ubuntu -u guysaha -e /bin/true
1069
+ ```
1070
+
1071
+ Booting the distro starts the user manager, which (with lingering on, and the
1072
+ unit already `enabled`) starts Mercury automatically.
1073
+
1074
+ ---
1075
+
1076
+ ## Ongoing operations
1077
+
1078
+ - **Re-auth WhatsApp — on failure, not on a schedule.** There is no fixed expiry
1079
+ (corrected 2026-08-06). The link drops only if the bot's phone goes **14 days
1080
+ without connecting to WhatsApp**, or on an unrelated Baileys/server-side
1081
+ logout. Keep the phone online and no routine action is needed; no calendar
1082
+ reminder required. When it *does* drop: `mercury service uninstall` →
1083
+ `rm -rf .mercury/whatsapp-auth/` → `mercury auth whatsapp` →
1084
+ `mercury service install`.
1085
+ - **Upgrades:** stop the service → `npm update -g mercury-agent` →
1086
+ `mercury build` → start. The derived image rebuilds itself.
1087
+ - **Backups:** `.mercury/` holds the SQLite DB, space workspaces, and WhatsApp
1088
+ credentials. Back it up; never commit it.
1089
+ - **Secrets:** `.env` and `.mercury/whatsapp-auth/` are credentials. If you make
1090
+ the runtime project a git repo, confirm both are gitignored before the first
1091
+ commit.
1092
+
1093
+ ---
1094
+
1095
+ ## Risk register
1096
+
1097
+ | Risk | Impact | Mitigation |
1098
+ |---|---|---|
1099
+ | WSL2 + Docker install fails or is blocked by policy | Total blocker | Try Docker Desktop first; it handles the WSL plumbing |
1100
+ | Personal WhatsApp number linked to the bot | Bot has access to all your chats; ban risk | Dedicated number |
1101
+ | WhatsApp bans/limits automated accounts | Loss of the number | Baileys is an unofficial client — this is inherent. Low volume, personal use only |
1102
+ | Session expiry surprises you | Bot silently stops | Calendar reminder + `mercury service logs -f` |
1103
+ | `claude-sonnet-5` unrecognised by pi, or not granted by the Claude subscription | Startup/runtime error | Fall back to `claude-opus-4-8`; verify with `mercury chat "hello"` before WhatsApp |
1104
+ | GHCR agent image is private (`unauthorized`) | Can't pull the preset | Build locally — but `.dockerignore` excludes `examples/` while `Dockerfile:118` copies it, so the build needs a one-line re-include |
1105
+ | npm 11 skips postinstall scripts | Baileys/protobufjs half-installed, WhatsApp breaks subtly | Always install with `--allow-scripts=@whiskeysockets/baileys,@google/genai,protobufjs` |
1106
+ | bubblewrap can't nest under Docker Engine | Agent container won't start | **Confirmed on this host and already fixed** — `agent.container_bwrap_docker_compat: true` |
1107
+ | Docker image churn | Disk usage | Base image ~2.8 GB + derived images; set `MERCURY_AGENT_ID` |
1108
+ | Running from `/mnt/d` | Slow, permission bugs | Decision A — use WSL ext4 |
1109
+
1110
+ ---
1111
+
1112
+ ## Reference
1113
+
1114
+ Docs in the clone at `mercury/docs/`:
1115
+ `setup-whatsapp.md`, `configuration.md`, `pipeline.md`, `memory.md`,
1116
+ `scheduler.md`, `permissions.md`, `media/overview.md`, `dashboard.md`,
1117
+ `extensions.md`, `container-lifecycle.md`, `rate-limiting.md`.