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,588 @@
1
+ > **ARCHIVED 2026-08-10 — fully executed. Do not plan from this file.**
2
+ >
3
+ > Every fix in §4 shipped (8 stories, 13 commits on
4
+ > `fix/whatsapp-context-identity-and-docs`); §3's switchover is live; §5 steps
5
+ > 1–5 are done; all of §6's open decisions were taken (see `decisions.md`
6
+ > D-001…D-013). Kept as the **investigation record** — the root causes here were
7
+ > verified against running code and live data, and the file:line references are
8
+ > still the best map of *why* each fix looks the way it does.
9
+ >
10
+ > Two things in it are now **wrong**, corrected elsewhere:
11
+ > - §1's tree table says the live bot is the npm-installed 0.8.12. It is not —
12
+ > the service has run from `~/mercury-src` since 2026-08-10 (D-001).
13
+ > - §1 says `mercury auth status` is cwd-sensitive. Fixed 2026-08-10; see the
14
+ > roadmap's "The cwd trap" section.
15
+ >
16
+ > Live successor documents:
17
+ > - `docs/goals/whatsapp-bot-hardening/roadmap.md` — status of everything here
18
+ > - `docs/goals/whatsapp-bot-hardening/open-threads.md` — what is still open
19
+ > - `docs/goals/whatsapp-bot-hardening/archive/*.md` — the 8 per-story specs,
20
+ > with retrospectives
21
+
22
+ # Mercury WhatsApp Bot — Bug Fix & Feature Handoff
23
+
24
+ **Written:** 2026-08-09 · **For:** a fresh Claude Code session picking up this work
25
+ **Branch:** `fix/whatsapp-context-identity-and-docs` in `~/mercury-src` (WSL)
26
+ **Status:** investigation complete, pinchtab commits merged, **no code fixes written yet**
27
+
28
+ Read this top to bottom before touching anything. Every root cause below was verified
29
+ against running code and live data — file:line references are to the **v0.9.0 tree in
30
+ `~/mercury-src`**, not the currently-running build.
31
+
32
+ ---
33
+
34
+ ## 1. Environment — read this first
35
+
36
+ There are **three different source trees** and they are not the same. Confusing them
37
+ will waste your time.
38
+
39
+ | Tree | Version | Role |
40
+ |---|---|---|
41
+ | `~/.nvm/versions/node/v24.19.0/lib/node_modules/mercury-agent` | 0.8.12 | **What is running right now** (systemd `mercury`) |
42
+ | `~/mercury-src` | v0.9.0 + our branch | **The working repo — do all work here** |
43
+ | `D:\Projects\whatsapp-bot\mercury` | 17c92c6 | Stale Windows clone. Will drift. Do not edit. |
44
+
45
+ The live bot runs in **WSL Ubuntu**, project dir `~/whatsapp-bot` (systemd user unit
46
+ `mercury`). This Claude session runs on **Windows**.
47
+
48
+ **Operating rules (learned the hard way):**
49
+
50
+ - Run commands via `wsl.exe -e bash -lc '<cmd>'` and pipe through `tr -d '\0'` —
51
+ output is otherwise NUL-interleaved and unreadable.
52
+ - **Fix PATH on every invocation.** Even `bash -lc` resolves `npm`/`bun` to the
53
+ *Windows* binaries via PATH interop. Prefix with:
54
+ `export PATH=$HOME/.nvm/versions/node/v24.19.0/bin:$HOME/.bun/bin:$PATH`
55
+ - **Edit files via the UNC path** `\\wsl.localhost\Ubuntu\home\guysaha\mercury-src\...`
56
+ with Read/Edit/Write. This works and beats heredocs.
57
+ - **Never Read `~/whatsapp-bot/.env`** (live API keys). Edit it with `awk`/`sed` inside WSL.
58
+ - `MERCURY_*` env vars in `.env` **silently override** `mercury.yaml` (`config.ts`).
59
+ Before debugging any config behaviour, dump the *effective* value, don't read the YAML.
60
+ Currently no media/log overrides are set, so YAML edits will take effect.
61
+ - `mercury auth status` is **cwd-sensitive** — it reports "not logged in" unless you
62
+ `cd ~/whatsapp-bot` first.
63
+ - Restart with `systemctl --user restart mercury`. **Never** run `mercury run` directly,
64
+ **never** kill by port (repo CLAUDE.md).
65
+ - Which model actually served is only knowable from the DB, never the reply:
66
+ `SELECT model, provider FROM token_usage ORDER BY id DESC LIMIT 1`. No `sqlite3`
67
+ installed — use `bun -e` with `bun:sqlite`.
68
+
69
+ ---
70
+
71
+ ## 2. Current repo state
72
+
73
+ Branch `fix/whatsapp-context-identity-and-docs`, based on `70a5ce3` (v0.9.0):
74
+
75
+ ```
76
+ b12d7c0 refactor(web-browser): probe the pinchtab bridge port with curl
77
+ 1fee658 fix(web-browser): start the pinchtab daemon with the "server" subcommand
78
+ 7ae56c7 fix(container): disable core dumps in agent containers
79
+ bfe5a33 fix(web-browser): bake the pinchtab domain allowlist into the agent image
80
+ 70a5ce3 (origin/main, v0.9.0)
81
+ ```
82
+
83
+ **Nothing has been pushed.** Git identity is set *repo-locally* to
84
+ `saharw01 <Saharw01@gmail.com>` to match existing commits.
85
+
86
+ **Uncommitted, and it must go in the PR:** `~/mercury-src/.dockerignore` has a local
87
+ edit adding `!examples/extensions` + `!examples/extensions/**`. This is a real build
88
+ fix — `.dockerignore:15` ignores `examples/`, but `container/Dockerfile:118` does
89
+ `COPY examples/extensions/ /tmp/examples-extensions/`, which copies nothing without the
90
+ negation. **This exists in no commit on any branch or remote.** Commit it.
91
+
92
+ ### Why the pinchtab commits were merged
93
+
94
+ All four were verified necessary (or harmless hardening) by testing the real binary,
95
+ **not** by trusting commit messages:
96
+
97
+ - `pinchtab server` — **real bug.** `pinchtab --help` documents `server` as "Starts the
98
+ full server"; bare `pinchtab` prints a banner and **exits 0**. The old
99
+ `nohup pinchtab &` never started a daemon.
100
+ - `allowedDomains "*"` — **real bug.** pinchtab's TRUST.md documents the navigation
101
+ default as a "Local-only allowlist"; `idpi_domain_blocked` exists in the binary;
102
+ `idpi.enabled/strictMode/scanContent/wrapContent` are all `true` by default. Every
103
+ public URL was refused. ⚠️ **Security note for the PR:** `"*"` removes the domain
104
+ allowlist entirely. The injection guard genuinely stays on (verified), but this is a
105
+ deliberate widening of attack surface and should be described as such, not as a plain bugfix.
106
+ - `--ulimit core=0` — **justified.** WSL2 `/proc/sys/kernel/core_pattern` is literally
107
+ `core` with no handler pipe, so segfaults dump into the CWD = bind-mounted workspace.
108
+ The "~300 MB per dump" figure is the author's observation, **not independently verified**.
109
+ - curl port probe — **hardening, not a live fix.** Correctly labeled `refactor:`. The
110
+ branch's comment is honest that pi runs bash today so `/dev/tcp` works.
111
+
112
+ **Critical:** all four were already hand-patched into the *running* installed package and
113
+ the deployed extension. Switching the service to `~/mercury-src` **without** them would
114
+ have silently regressed the browser extension.
115
+
116
+ ---
117
+
118
+ ## 3. Deployment switchover (do this before testing)
119
+
120
+ Decision already made: **run the bot from the git checkout**, so fixes are
121
+ version-controlled, testing is edit→restart, and `npm i -g` can't revert them.
122
+
123
+ ```bash
124
+ # 1. Backup — a restart checkpoints the ~4 MB WAL, so grab all three files
125
+ mkdir -p ~/mercury-backup-$(date +%s) && cp ~/whatsapp-bot/.mercury/state.db{,-wal,-shm} ~/mercury-backup-*/
126
+
127
+ # 2. Deps + gate
128
+ cd ~/mercury-src && bun install && bun run check
129
+
130
+ # 3. Repoint the CLI, reinstall the service the documented way
131
+ npm link # makes `mercury` resolve to ~/mercury-src
132
+ cd ~/whatsapp-bot && mercury service install --user
133
+
134
+ # 4. Re-apply Restart=always (generator writes on-failure), then restart
135
+ ```
136
+
137
+ **Gotchas:**
138
+ - `service install` bakes `Environment=PATH=` from the install shell. Set PATH explicitly
139
+ or the unit won't find bun/node.
140
+ - It also **reverts `Restart=always` → `Restart=on-failure`**. The live unit was
141
+ hand-changed to `always`; re-apply it after install.
142
+ - A **db.ts migration runs on first 0.9.0 start** against the existing `state.db`.
143
+ That is the reason for step 1, not superstition.
144
+ - The derived Docker image may rebuild (cached by content hash, version changed). It
145
+ builds in the background and doesn't block startup, but early replies may be slow.
146
+ - **Leave the npm-installed 0.8.12 in place** as rollback: `npm unlink` + restore the
147
+ old unit gets you back in under a minute.
148
+
149
+ ---
150
+
151
+ ## 4. The fixes
152
+
153
+ ### Bug 1 — Ambient mode is dead code (highest impact)
154
+
155
+ **Symptom:** the bot cannot see any group message it isn't tagged in.
156
+
157
+ **Root cause.** `src/core/handler.ts:237-240`:
158
+
159
+ ```ts
160
+ const shouldProcess = triggerResult.matched || (ingress.isReplyToBot && !isDM);
161
+ if (!shouldProcess && !isCommand) return; // ← returns here
162
+ ```
163
+
164
+ The ambient-write branch lives in `src/core/runtime.ts:591-612`, inside `handleRawInput`,
165
+ which is only reached via `processIngress` — **after** that early return. `ambient.enabled`
166
+ defaults to true and works as designed; it gates code nothing reaches.
167
+
168
+ **Evidence.** Across the whole DB there are exactly **2** ambient rows, both in space `19`,
169
+ both `סהר ויצמן: /spaces…`. That's the tell: `isCommand` is the sole bypass, so only stray
170
+ `/`-prefixed messages leak through. `greece2026` has **0**.
171
+ `handler.ts` is byte-identical on origin/main — **upstream has not fixed this.**
172
+
173
+ **Fix.**
174
+ 1. Extract `runtime.ts:591-612` into a public `recordAmbient(ingress: IngressMessage)` on
175
+ `MercuryCoreRuntime` (keep the group-only + `ambient.enabled` + non-DM guards).
176
+ 2. In `handler.ts:240`, replace the bare return:
177
+ ```ts
178
+ if (!shouldProcess && !isCommand) {
179
+ core.recordAmbient(ingress);
180
+ return;
181
+ }
182
+ ```
183
+ `ingress` is already built at line 221, so the data is in hand.
184
+ 3. **Cap the ambient context budget.** `db.ts:1047 getRecentTurns` fetches `turnCount * 5`
185
+ rows (`db.ts:1068`) and lets ambient share that window. Once ambient actually flows,
186
+ it will crowd out real turns. Give ambient its own bounded query — do **not** ship
187
+ step 2 without this.
188
+ 4. **Add an ambient retention TTL** to `storage-cleanup.ts`. Every group message is now a row.
189
+
190
+ **Known side effect (pre-existing, not caused by this fix):** `bridge.normalize` runs at
191
+ `handler.ts:221`, *before* the gate — so media from untriggered group messages is already
192
+ being downloaded and then discarded. Fixing bug 1 doesn't add that cost.
193
+
194
+ **Tests.**
195
+ - Unit: `recordAmbient` writes for a group + `ambient.enabled` unset/true; writes nothing
196
+ for a DM; writes nothing when `ambient.enabled=false`.
197
+ - Unit: handler calls `recordAmbient` exactly once for a non-triggered group message, and
198
+ **not** for a triggered one (no double-write).
199
+ - Unit: ambient context query returns at most N rows and does not starve user/assistant turns.
200
+ - Manual: send 3 untagged messages in a test group → assert 3 new `role='ambient'` rows;
201
+ then tag the bot and ask what was just said.
202
+
203
+ ---
204
+
205
+ ### Bug 2 — Sender misidentification + member memory
206
+
207
+ **Symptom:** the bot attributes past messages to the wrong person.
208
+
209
+ **Root cause A (primary).** Stored messages carry **no author**. `runtime.ts:1573` writes
210
+ `addMessage(spaceId, "user", finalPrompt, …)`, and the `messages` table has no author
211
+ column (`id, space_id, role, content, attachments, run_meta, reply_to_id, created_at,
212
+ updated_at`). At render time `agent/container-entry.ts:598` emits `<user>…</user>` with no
213
+ name. The **only** identity anywhere in the prompt is `<caller id=… name=… role=…/>` at
214
+ `container-entry.ts:634` — the *current* speaker.
215
+
216
+ So in a group the model sees a flat run of unattributed turns plus one name. Attributing
217
+ the whole history to that person is the only reading the prompt supports. This is not a
218
+ model glitch.
219
+
220
+ **Root cause B.** Quoted-reply senders resolve to raw LIDs. `buildReplyContext`
221
+ (`adapters/whatsapp.ts:82`) looks names up in `pushNames` (`whatsapp.ts:169`) — an
222
+ **in-memory** map, empty after every restart — and canonicalizes sync-only (no async LID
223
+ lookup). Real row currently in the greece2026 DB:
224
+ `<reply_to name="136043810578453" jid="136043810578453@lid" …>`
225
+
226
+ **Good news:** the identity data already exists and is clean. `space_roles.display_name`
227
+ is fully populated, correctly keyed by canonical phone JID (26 members), e.g.
228
+ `whatsapp:972500000000@s.whatsapp.net → "Member Name"`. Nothing uses it for context.
229
+
230
+ **Fix.**
231
+ 1. Migration: add `author_id` / `author_name` to `messages`; populate at `addMessage`.
232
+ 2. Render `<user name="…" id="…">` in `buildHistoryXml`; put the author on ambient entries
233
+ too (ambient already prefixes `Name: text` at `runtime.ts:604` — promote it to an attribute).
234
+ 3. Back `pushNames` with the DB: seed from `space_roles.display_name` at startup, write
235
+ through per message. Fixes `<reply_to>` across restarts.
236
+ 4. Use the **async** `canonicalizeJid` for quoted participants so unknown LIDs resolve.
237
+
238
+ **Member memory feature.** Build on the existing episodic plumbing rather than a new
239
+ subsystem: per-space `knowledge/members/<id>.md`, injected like `<active_episodes>`
240
+ (`container-entry.ts:513`), written by the agent with the `write` tool it already has.
241
+ Pair it with a `<members>` block generated from `space_roles` — identity stays **hard**
242
+ (id → name from the DB, not guessable), characteristics stay **soft** (accumulating notes).
243
+ No schema migration for the notes half; naturally per-space.
244
+
245
+ **Tests.**
246
+ - Migration: runs idempotently on the existing DB; existing rows get NULL authors and
247
+ still render.
248
+ - Unit: history XML for messages from two different users renders two distinct names.
249
+ - Unit: `buildReplyContext` resolves a known LID via the alias store + `display_name`,
250
+ and degrades gracefully to the bare jid when unknown.
251
+ - Unit: `pushNames` survives a simulated restart (seeded from DB).
252
+ - Manual: two people talk in a group, then a third asks "who said X?" — verify correct attribution.
253
+
254
+ ---
255
+
256
+ ### Bug 3 — 50 MB docx rejected, silently
257
+
258
+ **Symptom:** a large docx sent to the greece space was never readable; Google Drive worked.
259
+
260
+ **Root cause.** `mercury.yaml:74` sets `max_size_mb: 10`. `adapters/whatsapp-media.ts:165`
261
+ (and `:284` for the quoted-reply retry) skips anything larger **before** downloading.
262
+
263
+ **Evidence (host journal, 2026-08-09):**
264
+ ```
265
+ 15:43:57 [WARN] Skipping large media file type=document sizeBytes=51585024 maxBytes=10485760
266
+ 15:51:53 [WARN] Skipping large media file type=document sizeBytes=58117658 maxBytes=10485760
267
+ ```
268
+ Seven attempts, all rejected — including the reply path, which is why replying to the file
269
+ didn't help.
270
+
271
+ **The worse half is the silence.** `runtime.ts` only surfaces "could not use your
272
+ attachment" when the message has **no text at all**. The file had a caption, so the agent
273
+ was told nothing and invented a wrong explanation ("WhatsApp isn't handing me that .docx"),
274
+ sending the user chasing a non-existent bug.
275
+
276
+ **Fix.**
277
+ 1. Raise `media.max_size_mb` to **64**. (Verified `MERCURY_MEDIA_MAX_SIZE_MB` is unset, so
278
+ the YAML edit will take effect.)
279
+ 2. **Always** inject a system note when an attachment is dropped and why, regardless of
280
+ whether text was present. `src/core/media-gate.ts` already has exactly this pattern
281
+ (`promptNote`) — copy it.
282
+ 3. Optional: for oversize *documents*, download → extract text → discard the binary, so
283
+ huge files stay readable without being stored.
284
+
285
+ **Tests.**
286
+ - Unit: an over-limit attachment with a caption still produces a `promptNote`.
287
+ - Unit: note text distinguishes "too large" from "media disabled" from "download failed".
288
+ - Manual: send a ~50 MB docx → it downloads and is readable. Send a ~200 MB file → the bot
289
+ says *why* it can't read it.
290
+
291
+ ---
292
+
293
+ ### Bug 4 — Cannot create a document
294
+
295
+ **Symptom:** asked to make a doc, the bot attached HTML + markdown and told the user to
296
+ convert manually.
297
+
298
+ **Root cause.** No document-writing capability exists. Installed extensions are
299
+ `napkin, pdf, voice-transcribe, web-browser`. The container (`container/Dockerfile`) has
300
+ python3 + pip, node and bun, but **no `python-docx` and no `pandoc`**.
301
+
302
+ Egress already works — the HTML attachment proves it. The only missing piece is
303
+ *producing* a `.docx`.
304
+
305
+ **Fix.** A `docs` extension modeled directly on the `pdf` one (which is just `index.ts` +
306
+ a skill dir; its `install` string is compiled into the derived image by
307
+ `extensions/image-builder.ts`, which groups apt/pip/npm installs):
308
+
309
+ ```ts
310
+ mercury.cli({
311
+ name: "docs",
312
+ install: "apt-get update && apt-get install -y --no-install-recommends pandoc && "
313
+ + "python3 -m pip install --break-system-packages python-docx openpyxl odfpy && "
314
+ + "rm -rf /var/lib/apt/lists/*",
315
+ });
316
+ mercury.permission({ defaultRoles: ["admin", "member"] });
317
+ mercury.skill("./skill");
318
+ ```
319
+
320
+ Plus a `SKILL.md` teaching it to build the `.docx` and drop it in `outbox/`. Real Word
321
+ files, no Google account. *(Actual Google Docs **links** would be a separate Workspace
322
+ extension + OAuth — out of scope.)*
323
+
324
+ **Tests.**
325
+ - Image build succeeds and `python3 -c "import docx"` works in the derived image.
326
+ - Manual: "make me a doc with X" → a `.docx` arrives in WhatsApp and opens in Word/Docs.
327
+ - Manual: verify a member (not just admin) can trigger it.
328
+
329
+ ---
330
+
331
+ ### Bug 5 — Slash commands imply cross-space reach they don't have
332
+
333
+ **Symptom:** commands look like they can affect other spaces, e.g. `spaces delete <id>`.
334
+
335
+ **Root cause — the two surfaces disagree, and the dangerous one silently ignores its argument.**
336
+
337
+ | Surface | Behaviour |
338
+ |---|---|
339
+ | Chat `/spaces delete <id>` (`runtime.ts:911-933`) | **Honors the id.** Validates the target, stores `pending_delete_id`, deletes that space on confirm. Correct. |
340
+ | `mrctl spaces delete <id>` (`cli/mrctl.ts:384`) | **Silently ignores the argument** — always `DELETE /api/spaces/current`. Deletes the space you're in. |
341
+
342
+ `cli/mrctl.ts:384` is literally `print(await api("DELETE", "/api/spaces/current"))`; `args[2]`
343
+ is never read. It is the **only** mrctl subcommand that ignores an argument — every other
344
+ one reads `args[2]`. The usage line at `cli/mrctl.ts:85` (`mrctl spaces list|name|delete`)
345
+ shows no args at all, so an agent that has watched users type `/spaces delete <id>` will
346
+ naturally type the same thing at `mrctl` and destroy the current space.
347
+
348
+ The **API itself is correctly designed** — `core/routes/spaces.ts` deliberately exposes only
349
+ `/current` and has no `/:id` route, scoping every mutation to the caller's own space via
350
+ `getAuth(c).spaceId`. Do **not** "fix" this by adding a `/:id` route; that would remove a
351
+ real security boundary.
352
+
353
+ **Second, compounding bug — the confirmation can never match on a WhatsApp reply.**
354
+ `handlePendingSpaceDelete` (`runtime.ts:953`) tests `text === "yes"`, where text is
355
+ `message.text.trim().toLowerCase()`. But the WhatsApp adapter **appends a `<reply_to …>`
356
+ block** to the text (`adapters/whatsapp.ts:609`). The confirmation prompt literally says
357
+ "Reply *yes* to confirm" — and replying makes the match fail. Worse, `runtime.ts:981-982`
358
+ clears the pending state **before** evaluating the match, so the correct target is silently
359
+ discarded and the message falls through to the LLM. Typing bare `yes` (not as a reply) is
360
+ currently the only working confirmation.
361
+
362
+ *(This composition destroyed the `baduk` space on 2026-08-07: 559 messages, 5 tasks,
363
+ 14 roles. Recovered from the SQLite WAL.)*
364
+
365
+ **Fix.**
366
+ 1. `cli/mrctl.ts:384` — **reject the argument loudly** rather than ignoring it:
367
+ ```
368
+ Error: `mrctl spaces delete` only deletes the CURRENT space and takes no argument.
369
+ To delete a different space, use the chat command `/spaces delete <id>` as an admin.
370
+ ```
371
+ 2. `cli/mrctl.ts:85` — usage becomes `mrctl spaces list|name [<name>]|delete` with an
372
+ explicit "(current space only)" note.
373
+ 3. `resources/skills/spaces/SKILL.md` — state plainly that `mrctl` is current-space-only
374
+ and that cross-space deletion is chat-admin-only. *(The skill is already honest; make it emphatic.)*
375
+ 4. **Normalize confirmation text before matching.** Strip the `<reply_to …>…</reply_to>`
376
+ block (and any appended context) before the `yes`/`no` comparison. Best done as a shared
377
+ helper so every future confirmation flow benefits.
378
+ 5. **Reorder `handlePendingSpaceDelete`:** only clear pending state on an actual match,
379
+ an explicit `no`, or expiry — never on an unrecognized message. A stray message must
380
+ not silently cancel a pending delete.
381
+ 6. Consider requiring the confirmation to echo the target id (`yes cmp-opus`) for
382
+ cross-space deletes.
383
+
384
+ **Tests.**
385
+ - Unit: `mrctl spaces delete foo` exits non-zero with the explanatory error and issues **no**
386
+ HTTP request.
387
+ - Unit: `mrctl spaces delete` (no arg) still works.
388
+ - Unit: confirmation matches `yes` when a `<reply_to>` block is appended (**this is the
389
+ regression test for the baduk incident**).
390
+ - Unit: an unrelated message does **not** clear pending state; `no` and expiry do.
391
+ - Unit: deleting space A from space B deletes A, not B.
392
+ - Manual: in a throwaway space, `/spaces delete <other-id>` → **reply** "yes" → verify the
393
+ named space died and the current one survived.
394
+
395
+ ---
396
+
397
+ ### Bug 6 — Command-surface audit: five more defects of the same family
398
+
399
+ A full audit of every `/` command found that `spaces delete` was not an isolated slip.
400
+ Ordered by severity.
401
+
402
+ #### 6a. `/stop`, `/compact`, `/clear` silently do nothing (HIGH)
403
+
404
+ `SLASH_COMMANDS` (`core/commands.ts:15-67`) contains only `help, model, spaces, pause,
405
+ resume`. `stop/compact/clear` live in a **separate** `CHAT_COMMANDS` map
406
+ (`core/router.ts:39-43`) which is matched against the *whole prompt*:
407
+
408
+ ```ts
409
+ const cmdWord = prompt.toLowerCase().trim();
410
+ if (cmdWord in CHAT_COMMANDS) { … }
411
+ ```
412
+
413
+ `stop` matches. **`/stop` does not** — `cmdWord` is `"/stop"`, slash included. It fails the
414
+ slash-command check too (not in `SLASH_COMMANDS`), falls through both, and is sent to the
415
+ model as an ordinary prompt.
416
+
417
+ So typing `/stop` on a runaway agent **queues another model run instead of aborting one** —
418
+ the exact opposite of the intent, on the emergency-brake command. `/help` lists these under
419
+ *"Bare commands (no slash needed)"*, which reads as "slash optional", not "slash breaks it".
420
+
421
+ **Fix:** strip a leading `/` before the `CHAT_COMMANDS` lookup (or register them as leaf
422
+ entries in `SLASH_COMMANDS`), and reword the help line to say the slash form works.
423
+
424
+ #### 6b. Slash commands in groups silently require the trigger word (HIGH)
425
+
426
+ `routeInput` runs `matchTrigger` at `router.ts:105-106` and returns `ignore` **before** the
427
+ slash parse at `:115`. In a group, a bare `/spaces list` is therefore ignored outright — you
428
+ must write `Wiz /spaces list`.
429
+
430
+ Meanwhile `handler.ts:236-240` has an `isCommand = text.startsWith("/")` bypass that lets
431
+ `/`-prefixed messages skip the trigger gate — but `routeInput` re-applies it and drops them.
432
+ **The bypass accomplishes nothing** except routing the command into ambient storage.
433
+
434
+ **Confirmed by live data.** The only two ambient rows in the entire DB are
435
+ `סהר ויצמן: /spaces` and `סהר ויצמן: /spaces delete cmp-opus` — both typed untagged in the
436
+ baduk group, both silently ignored and filed as chat instead of executed.
437
+
438
+ > **This corrects the incident account in Bug 5.** Those chat commands never ran. The
439
+ > baduk deletion must have come from the agent invoking `mrctl spaces delete <id>`, which
440
+ > ignores its argument and destroys the current space. **That makes the `mrctl` fix (5.1)
441
+ > the load-bearing one**, not the confirmation fix — prioritize accordingly.
442
+
443
+ **Fix:** honor slash commands without the trigger in groups (matching the handler's evident
444
+ intent), gated so an unknown `/foo` still falls through to ambient rather than becoming a
445
+ command. Otherwise delete the dead bypass and document the requirement.
446
+
447
+ #### 6c. `/spaces unlink` is an unrecoverable self-lockout in groups (HIGH)
448
+
449
+ `resolveConversation` (`core/conversation.ts:127`) ends with:
450
+
451
+ ```ts
452
+ if (!autoSpace?.enabled || kind !== "dm") return null;
453
+ ```
454
+
455
+ For a **group**, once unlinked, resolution returns `null`, and `handler.ts:190-197` drops
456
+ every message — **including slash commands**, because that gate sits upstream of
457
+ `routeInput` entirely.
458
+
459
+ The command's own success text says *"link it to a space with `/spaces switch`"*. That is
460
+ **impossible**: the message will never be routed. Recovery requires the dashboard, the API,
461
+ or a direct DB edit. DMs self-heal via auto-space; groups do not.
462
+
463
+ **Same failure mode reachable without any command:** `onGroupRemoval` (`main.ts`) unlinks
464
+ the conversation when the bot is removed from a group. Re-adding the bot leaves it
465
+ **permanently deaf** — the conversation is still unlinked and every message is dropped.
466
+
467
+ **Fix:** keep an unlinked conversation eligible for *link-restoring commands only*
468
+ (`/spaces switch`, `/spaces list`) instead of dropping everything; correct the message text;
469
+ and re-link (or prompt to re-link) on group re-join.
470
+
471
+ #### 6d. Chat and CLI permission gates disagree (MEDIUM, security-relevant)
472
+
473
+ | Path | Gate |
474
+ |---|---|
475
+ | Chat `/spaces …` | `SEEDED_ADMIN_COMMANDS` (`router.ts:33`) → **only the seeded admin from config** |
476
+ | `mrctl spaces …` / API | `checkPerm(c, "spaces.delete")` → and `getDefaultPermissions` (`permissions.ts:208-210`) grants **every space `admin` all permissions** |
477
+
478
+ A promoted space admin is denied `/spaces delete` in chat, but the agent acting on their
479
+ behalf can call `mrctl spaces delete` and destroy the current space. The tighter chat gate
480
+ is bypassable via the CLI.
481
+
482
+ Members are unaffected — `DEFAULT_MEMBER_PERMISSIONS` (`permissions.ts:192-197`) is only
483
+ `prompt, prefs.get, media.receive, media.send`.
484
+
485
+ **Fix:** align the two deliberately — either loosen chat to `admin`, or put
486
+ `spaces.delete` behind a seeded-admin-only tier on the API side too. Decide which; don't
487
+ leave them divergent.
488
+
489
+ #### 6e. `/resume <arg>` ignores its argument; `/help pause` fails (LOW)
490
+
491
+ - `executeResumeCommand(spaceId, _callerId)` never receives `verb`, so `/resume now` and
492
+ `/resume 5m` silently behave as bare `/resume`. Same class as `spaces delete`, but with no
493
+ destructive effect. Reject unknown args for consistency.
494
+ - The `pause` entry (`commands.ts:59-62`) has no `verbs` array, so `formatCategoryHelp`
495
+ returns null and **`/help pause` answers "No help available for '/pause'."** — even though
496
+ `/pause` takes a duration. `parsePauseDuration` accepts only `^(\d+)(m|h)$` (no days, no
497
+ seconds), which is documented nowhere.
498
+
499
+ #### Redundancy assessment
500
+
501
+ | Command | Verdict |
502
+ |---|---|
503
+ | `/model active` | **Redundant.** `/model list` already marks the active leg with `← active`. Two commands, one fact. Drop it. |
504
+ | `/compact` vs `/clear` | **Near-identical and undifferentiated.** Both take `MAX(id)` for the space and write a boundary (`db.ts:913` vs `:936`); one sets `session_boundary`, the other `clear_boundary`. Help text ("reset the session context window" vs "clear context for this message") does not let a chat user choose correctly. Merge, or rewrite both descriptions to state the actual difference. |
505
+ | `/help <cat>` vs `/<cat>` | Duplicate paths into `formatCategoryHelp`. Harmless — keep. |
506
+ | `/spaces unlink` | **Recommend removing from chat entirely** (see 6c). It is a one-way door whose only advertised recovery path cannot work. |
507
+ | Everything else | Earns its place. |
508
+
509
+ **Tests for Bug 6.**
510
+ - Unit: `/stop`, `/compact`, `/clear` route to `type: "command"`; assert they do **not**
511
+ reach the assistant path (regression for 6a).
512
+ - Unit: a bare `/spaces list` in a group resolves as a command, not ambient (6b).
513
+ - Unit: an unknown `/foo` in a group still falls through to ambient, not a command (6b).
514
+ - Unit: after `unlinkConversation`, `/spaces switch <id>` is still routed and re-links;
515
+ an ordinary message is still dropped (6c).
516
+ - Unit: a space `admin` (not seeded) gets the same answer from the chat and CLI paths (6d).
517
+ - Unit: `/resume extra` errors rather than silently succeeding; `/help pause` returns real
518
+ help (6e).
519
+ - Manual: in a throwaway group, unlink then re-link entirely from chat.
520
+
521
+ ---
522
+
523
+ ## 5. When the fixes are done
524
+
525
+ 1. **Gate:** `cd ~/mercury-src && bun run check` (typecheck + lint + tests) must be green.
526
+ 2. **Deploy:** section 3 switchover, then `systemctl --user restart mercury`.
527
+ 3. **Verify with debug logging on.** Set `log_level: debug` in `mercury.yaml` for the test
528
+ window. The lines that matter only exist at debug level:
529
+ - `Incoming message` (`handler.ts:151`) — proves untriggered messages arrive
530
+ - `Message ignored: conversation not linked to a space` (`handler.ts:191`)
531
+ - `WhatsApp identity canonicalized` (`whatsapp.ts`) — bug 2
532
+ - `WhatsApp skipping backlog message` — rules out the reconnect filter
533
+
534
+ ⚠️ **Turn debug back off afterwards.** It logs message-content previews into journald —
535
+ real chat content from a family/friends group sitting in system logs.
536
+ 4. **Confirm with data, not vibes:**
537
+ ```sql
538
+ SELECT space_id, role, COUNT(*) FROM messages GROUP BY space_id, role;
539
+ ```
540
+ Ambient rows for `greece2026` must be > 0 and growing.
541
+ 5. **Housekeeping:** delete the stray `~/.mercury/state.db` (147 KB, created by running a
542
+ mercury command from `$HOME` instead of the project dir — the cwd-scoping trap that makes
543
+ `auth status` misreport). Verify it isn't the live DB first.
544
+ 6. **PR:** one PR from `fix/whatsapp-context-identity-and-docs` → `main`, with a section per
545
+ change. Must call out:
546
+ - the four pinchtab commits as **upstreaming an already-live hotfix**, with the
547
+ `allowedDomains "*"` security tradeoff stated plainly
548
+ - the `.dockerignore` build fix (exists in no commit anywhere)
549
+ - the `messages` schema migration and its backfill behaviour
550
+ - the ambient volume/retention implications
551
+ - the baduk incident as motivation for the bug-5 regression tests
552
+ - **behaviour changes in the command surface** (bug 6): `/stop` now aborts instead of
553
+ prompting, slash commands work untagged in groups, `/spaces unlink` removed or made
554
+ recoverable, `/model active` dropped. These change what existing users' muscle memory
555
+ does — list them explicitly rather than burying them in the diff.
556
+ 7. **Also decide before pushing:** the 4 pinchtab commits still live only on the D: clone's
557
+ `fix/pinchtab-browser-startup` branch. Either push that branch too or accept that the
558
+ cherry-picks supersede it.
559
+
560
+ ---
561
+
562
+ ## 6. Open decisions
563
+
564
+ | Decision | Recommendation |
565
+ |---|---|
566
+ | Media cap size | 64 MB |
567
+ | Member notes: workspace files vs DB table | Workspace files (`knowledge/members/<id>.md`) — reuses existing plumbing, no migration |
568
+ | Tighten `allowedDomains` from `"*"` | Worth doing eventually; out of scope for this PR, but say so in the PR |
569
+ | Require echoing target id on cross-space delete | Recommended |
570
+ | Slash commands without the trigger word in groups (6b) | **Allow** — matches the handler's evident intent; keep unknown `/foo` falling through to ambient |
571
+ | Align chat vs CLI permission gates (6d) | Pick one deliberately. Leaning: keep `/spaces` seeded-admin-only and lift `spaces.delete` out of the blanket `admin` grant |
572
+ | Drop `/model active` (redundant) | Yes |
573
+ | Merge `/compact` and `/clear` | Don't merge blind — first document the real difference, then decide |
574
+ | Remove `/spaces unlink` from chat | Recommended — one-way door with an impossible recovery path |
575
+
576
+ ## 7. Suggested order
577
+
578
+ 1. **Bugs 1 + 2 together** — same code path, and ambient without attribution produces a wall
579
+ of anonymous text that could make misidentification *worse*. Bundle the ambient fix, the
580
+ author columns, and the context cap.
581
+ 2. **Bug 5.1 (`mrctl spaces delete` argument) + 6a + 6c** — the three that can destroy data
582
+ or lock you out. 5.1 is the one that actually killed baduk (see 6b); 6a means the
583
+ emergency brake doesn't work; 6c is unrecoverable without DB access. All are small.
584
+ 3. **Rest of bug 5 + 6b, 6d** — confirmation matching, trigger-gating, permission alignment.
585
+ 4. **Bug 3** — config + prompt-note plumbing.
586
+ 5. **Bug 4** — new extension, no core changes.
587
+ 6. **6e + redundancy cleanup** — cosmetic; fold into the PR last.
588
+ 7. **Member memory** — mostly prompt + skill work once attribution lands.