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,396 @@
1
+ # Open threads — Mercury WhatsApp Bot
2
+
3
+ **Goal**: [whatsapp-bot-hardening](goal.md)
4
+ **Opened**: 2026-08-10
5
+
6
+ Everything still outstanding, in one place. The eight hardening stories are
7
+ shipped (see [roadmap](roadmap.md)); this is what did *not* fit inside them,
8
+ plus what the prompt-layer investigation turned up.
9
+
10
+ Each item says what it is, why it matters, and what "done" looks like — so any
11
+ of them can be picked up cold.
12
+
13
+ ---
14
+
15
+ ## A. Prompt layers & voice
16
+
17
+ ### A-1. There are **five** prompt layers, not three
18
+
19
+ This was the root of the confusion about `system_prompt` vs preferences. Full
20
+ map, verified against the running code:
21
+
22
+ | # | Layer | Where it lands | Scope | Setter |
23
+ |---|-------|----------------|-------|--------|
24
+ | 1 | `.mercury/global/AGENTS.md` | pi auto-loads from the resource dir (`storage/memory.ts:16`) | global | edit the file |
25
+ | 2 | `project_config.character` | system prompt, as `## Bot Character` via `MERCURY_EXT_SYSTEM_PROMPT` (`runtime.ts:1601`) | global | dashboard, `mrctl character set` |
26
+ | 3 | `space_config.system_prompt` | system prompt (`runtime.ts:1614`) | per-space | **none — see A-2** |
27
+ | 4 | `.mercury/spaces/<id>/AGENTS.md` | pi auto-loads from the space workspace | per-space | edit the file |
28
+ | 5 | `space_preferences` | **user** prompt, as `<preferences>` XML (`preferences-prompt.ts`, `container-entry.ts:783`) | per-space | dashboard, `mrctl prefs set` |
29
+
30
+ Current occupancy: layer 1 is the stock template. Layer 3 is empty everywhere.
31
+ Layer 4 is written for `main` and `admin`, **empty for `19` and `greece2026`** —
32
+ which is why those two spaces run almost entirely on layers 2 and 5.
33
+
34
+ ### A-2. `space_config.system_prompt` has no setter
35
+
36
+ Its only writer is `conversation.ts:177` (`seedSpaceConfigIfAbsent`, from
37
+ `autoSpace.defaultSystemPrompt`). Nothing in chat, `mrctl`, or the dashboard
38
+ writes it. So it is a real prompt layer that no user can reach.
39
+
40
+ **Decide:** add a setter (`mrctl config set system_prompt`), or drop the layer.
41
+ Leaning: drop it. Layer 4 (`AGENTS.md`) already covers per-space long-form
42
+ instructions and is editable, so a setter would add a fifth way to say the same
43
+ thing. Either way, stop treating it as a live option.
44
+
45
+ ### A-3. Layer precedence is **not** deterministic — do not design around it
46
+
47
+ Measured on the throwaway `prompttest` space over `POST /chat`, using target
48
+ language as the probe (each layer told to answer in a different language):
49
+
50
+ - A direct `system_prompt` vs `preferences` conflict went 8/10 to
51
+ `system_prompt` — but **not 10/10**, and a swapped-language control produced
52
+ the opposite result. Repeating one fixed config gave different winners on
53
+ different runs.
54
+ - The global character contains its own conflict ("answer in the language they
55
+ wrote in" **and** "default Hebrew"); an English probe resolves it
56
+ inconsistently.
57
+
58
+ **Conclusion:** you cannot rely on a lower layer overriding a higher one. The
59
+ only reliable fix is to **not state the same rule in two layers**. Recorded as
60
+ D-014.
61
+
62
+ ### A-4. Vague quantities produce vague behaviour — the emoji data
63
+
64
+ Live emoji rate in assistant messages, by space:
65
+
66
+ | Space | Instruction | Emoji rate |
67
+ |-------|-------------|-----------|
68
+ | `19` (baduk) | pref: "בלי אימוג'ים בכלל" (none at all) | **0 / 40 = 0%** |
69
+ | `greece2026` | pref: "minimal emojis" | 33% |
70
+ | `main` | character only: "אימוג'י פה ושם כשזה מתאים" (here and there when it fits) | 73% |
71
+
72
+ Preferences demonstrably work — `19` is at a hard zero. What fails is the
73
+ *wording*: "minimal" and "here and there" are not countable, so the model picks
74
+ its own number. A countable rule is obeyed; an adjective is not.
75
+
76
+ ### A-5. Emoji rule and layer cleanup — **applied 2026-08-10**
77
+
78
+ Owner's decision: *at most one emoji per ~5 lines, only if it carries meaning,
79
+ default 0, in every space* — no exception for `19` even though its absolute ban
80
+ had it at a clean 0/40. Applied together with the layer cleanup below; DB backup
81
+ at `~/mercury-backup-prompts-20260810/`.
82
+
83
+ | Layer | Change |
84
+ |---|---|
85
+ | `character` | Rewritten **in English**. Five clauses: persona, language, length, emoji, honesty. Countable emoji rule; anti-fabrication **and** stale-facts lines promoted here (both existed only in `main`/`admin` AGENTS.md, i.e. in neither real group); **"default Hebrew" removed** — matching the message is the only global language rule. |
86
+ | `19` prefs | `tone` reworded (emoji ban dropped, now global; "one of the guys" → group-neutral); `language` = **always Hebrew**, everyone in the group speaks it |
87
+ | `greece2026` prefs | `language.style` **deleted** — it bundled a hard constraint, a tone hint, and a vague "minimal emojis" that would have *loosened* the stricter global rule. Now `language` (English by default, because members share no other common language, **with an explicit-request escape hatch**) and `tone` (family + partners + friends, not just friends). `response.length` **deleted** — length is a global rule now. |
88
+ | `main/AGENTS.md` | Duplicated language, brevity, warmth, anti-fabrication and stale-facts removed; keeps only what is space-specific |
89
+ | `admin/AGENTS.md` | Explicit "this space overrides the global character" banner; Hebrew default removed |
90
+
91
+ Both per-space language rules now **contradict** the global "match the message"
92
+ rule outright, so each states the override in words. `greece2026` is two
93
+ overrides deep — space rule beats global, explicit user request beats space
94
+ rule — and that holds (P3 below).
95
+
96
+ Preferences and character are read per turn (`runtime.ts:1601`, `:1799`), so no
97
+ restart was needed.
98
+
99
+ **Overrides are now stated in words, not implied by position.** `greece2026`'s
100
+ language pref literally says it overrides the global rule, and `admin`'s
101
+ `AGENTS.md` says it overrides the character. Per A-3 an implicit override is a
102
+ coin flip; an explicit one is just an instruction.
103
+
104
+ ### A-6. Verification — measured on fresh spaces, 2026-08-10
105
+
106
+ One fresh space per probe, no shared history.
107
+
108
+ | Probe | Result |
109
+ |---|---|
110
+ | Hebrew in, character only | Hebrew out **4/4** |
111
+ | English in, clean space | English out **3/3** (100% Latin) |
112
+ | Hebrew conversation → English message | switches to English |
113
+ | `19` prefs, **English** in | Hebrew out **3/3** — unconditional override beats the incoming message |
114
+ | `greece2026` prefs, Hebrew in | English out **4/4** |
115
+ | `greece2026` prefs + "answer me in Hebrew please" | Hebrew out **3/3** — the escape hatch beats the space rule |
116
+ | Long question (4-day itinerary, 8 people) | 25 and 10 lines, complete, not truncated |
117
+ | Short question (time zone) | **1 line** both times — the length rule did not become "always long" |
118
+
119
+ Dropping `19`'s absolute emoji ban did **not** cause a spike.
120
+
121
+ **Emoji: one leak found and mostly closed.** The first pass held on *count* but
122
+ leaked on *placement* — `19` opened 2 of 3 replies with `מה קורה 👋`. One emoji
123
+ in a 9-line reply is inside the "~1 per 5 lines" budget, so the number was never
124
+ the problem: "never to open or close a message" was too abstract to bind against
125
+ a tone pref pulling toward banter. Naming the exact case ("a wave, a smile or a
126
+ thumbs-up attached to a greeting or a sign-off is decoration, however casual the
127
+ conversation is") took it to **1 emoji over 12 lines, 1 of 4 replies** on
128
+ deliberately greeting-shaped prompts. Same lesson as "minimal" (33%) vs "none at
129
+ all" (0%): concrete beats abstract.
130
+
131
+ Residual, left as a taste call: a bare thank-you (`אחלה, תודה!`) still gets
132
+ `בכיף! 🙂`. One line, one emoji — over budget and carrying nothing. Closing it
133
+ needs one more sentence ("a message of one or two lines gets no emoji at all"),
134
+ at some risk of reading cold in a friends' group.
135
+
136
+ > **Method note, worth keeping.** The first run appeared to show a regression:
137
+ > an English prompt came back in Hebrew, 0/2. It was the *test* that was broken —
138
+ > the English probe ran in `prompttest` straight after four Hebrew turns in the
139
+ > same space, so the model was matching the conversation, not the message. Re-run
140
+ > in virgin spaces it passes 3/3. The detector was also too crude (it scored any
141
+ > reply containing a single Hebrew character as Hebrew, so English prose with a
142
+ > Hebrew greeting counted as a failure); it now scores by majority script.
143
+ > **One-space, sequential prompt probes are not trustworthy — use a fresh space
144
+ > per probe.**
145
+
146
+ ### A-6b. Left open after the cleanup
147
+
148
+ - **The `בכיף! 🙂` case** above — one sentence closes it, at some cost to warmth.
149
+ Owner's call.
150
+ - **Re-measure the live emoji rate** after a week of real traffic. Everything
151
+ above is API probes, not `main` and `19` in daily use. Count
152
+ `\p{Extended_Pictographic}` over assistant rows since 2026-08-10 and compare
153
+ against the pre-change baseline (`main` 73%, `greece2026` 33%, `19` 0%).
154
+ - **`admin` is now the only space whose voice is set by `AGENTS.md` rather than
155
+ preferences.** That is deliberate — it is long-form and structural — but it
156
+ means the one space with a genuinely different voice is also the one with no
157
+ dashboard-visible setting. Worth remembering before wondering why `admin`
158
+ reads differently.
159
+
160
+ ### A-7. The agent is never told preferences exist — **fixed 2026-08-10**
161
+
162
+ `container-entry.ts` never mentioned `<preferences>` or `mrctl prefs set`, so
163
+ the self-service flow the design implies was never explained to the model. It
164
+ also carried an actively wrong instruction at `container-entry.ts:326`:
165
+ per-space tone lives in `system_prompt` "set from the dashboard" — no such
166
+ setter exists (A-2), and tone belongs in preferences.
167
+
168
+ Both halves are now fixed in the `## Character` section: the `system_prompt`
169
+ sentence is replaced with the four real `mrctl prefs` verbs and when to reach
170
+ for them, plus a closing line carrying D-014 — do not state the same rule in
171
+ two layers, and make an intended override explicit in words rather than relying
172
+ on specificity. Now a PR item to *describe*, not to do.
173
+
174
+ **Live since 2026-08-10.** Prompt-side, so it needed the image rebuild, not just
175
+ a service restart: base rebuilt, derived tag moved `dd9a11d5 → 0597b302`,
176
+ verified by grepping the new image rather than the repo. The rebuild also
177
+ disproved the roadmap's claim that the derived image ignores a base change —
178
+ corrected there.
179
+
180
+ ---
181
+
182
+ ## B. API exposure — remaining hardening
183
+
184
+ Closed the acute part on 2026-08-10 (both secrets set; see roadmap). Left over:
185
+
186
+ - **B-1. Done 2026-08-10.** `POST /chat` now requires
187
+ `content-type: application/json` and returns 415 otherwise
188
+ (`routes/chat.ts`). The check runs **before** the API-key check, so it holds
189
+ in the configuration it actually protects — the one with no key set. Killing
190
+ the browser drive-by is the point: a `text/plain` body makes a cross-origin
191
+ POST a CORS "simple request" with no preflight, so any open web page could
192
+ start a real agent turn. Demanding JSON forces a preflight this server never
193
+ answers. Covered by `tests/chat-content-type.test.ts`; the only in-repo
194
+ caller (`mercury chat`, `cli/mercury.ts:2067`) already sent the header.
195
+ - **B-2.** Add a bind-host config option. `main.ts:128` calls `Bun.serve` with
196
+ no `hostname`, so it listens on `*:8787`. ⚠️ **Do not fix by hardcoding
197
+ `127.0.0.1`** — agent containers reach the host via
198
+ `host.docker.internal:host-gateway`, so loopback-only binding breaks every
199
+ turn. It needs to be configurable, defaulting to the docker-bridge address.
200
+ - **B-3.** Bare `GET /dashboard` is registered at `server.ts:135`, *before* the
201
+ `/dashboard/*` auth middleware at `server.ts:191`, and Hono's `/dashboard/*`
202
+ does not match the bare path. With auth on, it serves the SPA shell whose data
203
+ calls then 401 — an empty page with no error and no login prompt. It should
204
+ redirect to the login page. (Version disclosure is the lesser half.)
205
+ - **B-4.** `.env` is cwd-bound: hardcoded `join(CWD, ".env")`
206
+ (`cli/mercury.ts:112`), no override. CLI commands needing API keys must run
207
+ from `~/whatsapp-bot`. Not worth patching upstream on its own; note it if
208
+ another `.env` issue comes up.
209
+
210
+ ---
211
+
212
+ ## C. The PR
213
+
214
+ **Opened 2026-08-10.** Push access arrived; **18 commits** on
215
+ `fix/whatsapp-context-identity-and-docs`, rebased onto `origin/main`. Always
216
+ count with `git rev-list origin/main..HEAD` after a `git fetch` — a stale local
217
+ `main` gave a misleading 16 for most of this goal.
218
+
219
+ All ops docs live in exactly one commit (`docs(ops): add whatsapp-bot-hardening
220
+ goal, roadmap and story archive`) and **no code commit touches `docs/`**, so if
221
+ the maintainer would rather not carry planning docs upstream that single commit
222
+ can be dropped without disturbing anything else.
223
+
224
+ Body covers the six points in the roadmap's Completion section, **plus** these
225
+ accumulated since: B-2, B-3, and the ambient prompt-injection fix below. B-1 and
226
+ A-7 are **done** (2026-08-10), so they are changes the PR describes rather than
227
+ work it still owes.
228
+
229
+ **New behaviour users will feel** (add to the roadmap's list of six): tagging
230
+ the bot now always triggers it, whatever `trigger.patterns` says — see §G. New
231
+ space config key `trigger.mention_always` to turn that off.
232
+
233
+ **Also worth its own section:** ambient message content used to be interpolated
234
+ into the prompt raw (`container-entry.ts`, `${m.content}`); it is now run
235
+ through `escapeXmlText` like every other block, and reply-context attributes go
236
+ through `escapeAttr`. Any group member could previously write XML straight into
237
+ the prompt. That is a security fix and is not currently listed anywhere in the
238
+ PR plan.
239
+
240
+ ---
241
+
242
+ ## D. Operations
243
+
244
+ - **D-1. WSL does not auto-start.** Carried over from the setup plan. Lingering
245
+ (`loginctl enable-linger`) survives closing a terminal, but not
246
+ `wsl --shutdown` or a Windows reboot — the bot is down until something touches
247
+ the distro. Fix: a Task Scheduler task at logon running
248
+ `wsl.exe -d Ubuntu -u guysaha -e /bin/true`.
249
+ - **D-2. Tighten `allowedDomains` from `"*"`** (D-011). The injection guard is
250
+ verified still on, so this is hardening, not a live hole. Deferred out of the
251
+ PR by decision; revisit after it merges.
252
+ - **D-3. `prompttest` deleted 2026-08-10** (64 messages, 4 roles, 32 token-usage
253
+ rows — all from prompt experiments). Live spaces are now exactly `19`, `admin`,
254
+ `greece2026`, `main`.
255
+ - **D-4. Orphaned workspace directories — cleaned 2026-08-10.** The prompt tests
256
+ left 23 dirs in `.mercury/spaces/` (`prompttest`, `langtest-*`, `v2-*`,
257
+ `v3-*`); removed by hand, leaving exactly `19`, `admin`, `greece2026`, `main`.
258
+ **Not an upstream bug** — `db.deleteSpace()` only clears rows, and every real
259
+ delete path pairs it with `removeSpaceWorkspace` (`routes/spaces.ts:87`,
260
+ `routes/console.ts:555`, `cli/mercury.ts:2362,2380`). The test scripts called
261
+ the DB layer directly and skipped that half.
262
+ **Rule for any future throwaway-space harness: go through the API, or call
263
+ `removeSpaceWorkspace` alongside `deleteSpace`.**
264
+ - **D-5. `agent.env_passthrough` tightened to `claimed` — 2026-08-10.** Startup
265
+ was logging that the blind `MERCURY_*` passthrough carried `MERCURY_AGENT_IMAGE`
266
+ into every space's container, scoped to nothing. `claimed` narrows it: model
267
+ provider credentials stay exempt (otherwise the agent could not reach a model)
268
+ and extension-declared vars are injected separately behind their permission
269
+ gate, so the only var actually withdrawn is `AGENT_IMAGE` — host-side only,
270
+ and nothing in-container reads it (grepped across `src/`, `container/`,
271
+ `examples/`, `resources/`, `docker/`). Startup now logs the `claimed` line.
272
+ Backup: `mercury.yaml.bak-envpassthrough-20260810`.
273
+ **The value of the setting is the audit, not the one var** — it converts every
274
+ future `.env` addition from silently container-wide into a deliberate
275
+ declaration.
276
+ - **D-6. Machine audit against the setup guide — 2026-08-10, clean.** Ubuntu
277
+ 26.04, bun 1.3.14, node v24.19.0, docker 29.1.3 (enabled + active, user in
278
+ the `docker` group), git, curl. `mercury` 0.9.0 on PATH; `mercury doctor`
279
+ reports 8 passed / 1 warning / 0 errors, the warning being "port 8787 in use",
280
+ which is Mercury itself. Lingering is on. Derived image carries pandoc 3.1.3,
281
+ python `docx`/`openpyxl`/`odf`, and `mrctl`. `pandoc` is **absent from the
282
+ host** and that is correct — the docs extension needs it in the container.
283
+ The unit at `~/.config/systemd/user/mercury.service` matches what
284
+ `generateSystemdService()` emits, field for field, with one **deliberate**
285
+ documented deviation: `Restart=always` instead of the generated
286
+ `Restart=on-failure`, re-applied by hand because `service install` reverts it
287
+ (see `archive/run-from-source-switchover.md` step 5). Anyone re-running
288
+ `mercury service install` must re-apply it again.
289
+
290
+ ---
291
+
292
+ ## E. Phone-dependent manual checks — never run
293
+
294
+ Every one of these needs the owner's phone; none block the PR, but the
295
+ milestone checkpoints are not truly closed until they pass. Full detail lives in
296
+ the per-story specs in `docs/goals/whatsapp-bot-hardening/archive/`.
297
+
298
+ | # | Check | From |
299
+ |---|-------|------|
300
+ | E-1 | 3 untagged messages in a test group → exactly 3 new `role='ambient'` rows, with authors | M2 |
301
+ | E-2 | Two people talk, a third asks "who said X?" → correct attribution | M2 |
302
+ | E-3 | Restart the service, then quote-reply an old message → `<reply_to>` shows a **name**, not a raw LID | M2 |
303
+ | E-4 | In a **throwaway** space: `/spaces delete <other-id>` → **reply** "yes" → named space dies, current one survives (the baduk regression) | M3 |
304
+ | E-5 | Bare `/spaces list`, untagged, in a group | M3 |
305
+ | E-6 | Send a ~50 MB docx → bot reads it. Send ~200 MB → bot says *why* it can't | M4 |
306
+ | E-7 | "Make me a doc with X" → `.docx` arrives and opens in Word, triggered by a **member**, not the admin | M4 |
307
+ | E-8 | Unlink a throwaway group, then re-link it entirely from chat | M3 |
308
+
309
+ ⚠️ E-1 to E-3 want `log_level: debug` for the test window only — it writes
310
+ chat-content previews into journald, i.e. real family/friends chat into system
311
+ logs. **Set it back to `info` afterwards.**
312
+
313
+ ---
314
+
315
+ ## F. Scheduled tasks — the 2026-08-10 football failure
316
+
317
+ Filed in full at
318
+ [`docs/debug/major/2026-08-11-scheduled-task-timeout-silent.md`](../../debug/major/2026-08-11-scheduled-task-timeout-silent.md)
319
+ — fixed and archived. Deferred by decision on 2026-08-10; recorded here so it is
320
+ not lost. The runtime half it spun off is
321
+ [`scheduled-research-task-runtime-drift`](../../debug/moderate/2026-08-19-scheduled-research-task-runtime-drift.md).
322
+
323
+ The daily football update never reached the group. The scheduler fired on time;
324
+ the container ran the full 20-minute timeout and was killed. Two defects:
325
+
326
+ - **F-1 (generic, affects every scheduled task).** A task whose handler throws is
327
+ reported nowhere — the scheduler logs and swallows, and `runtime.ts` only
328
+ sends on success. Worse, `next_run_at` is advanced *before* running, so one
329
+ failure costs a whole cron period with no retry. Same "no silent drops"
330
+ principle M4.1 applied to media, not yet applied to tasks.
331
+
332
+ ⚠️ **Re-verified against `origin/main` 9e78a68** ("stop dashboard Run now from
333
+ firing a second agent run"), which rewrote ~156 lines of
334
+ `task-scheduler.ts` after this was first written. **Both halves survive**, at
335
+ new locations: the pre-advance is now `runTask`, `task-scheduler.ts:155-158`,
336
+ and its doc comment states it as deliberate ("Both happen regardless of
337
+ handler success"); the swallow is `task-scheduler.ts:168-174`. So F-1 is a
338
+ design decision to argue with, not an oversight to patch quietly — and any
339
+ fix now has to keep 9e78a68's in-flight/`applyBookkeeping` contract intact.
340
+ - **F-2 (this task).** The prompt asks for 6–8 improvised web searches daily, so
341
+ its runtime is unbounded: 5.9 min on Aug 9, 20.0 min (timeout) on Aug 10, with
342
+ a **byte-identical** prompt. Wants a faster, more deterministic pipeline —
343
+ possibly a skill or script — that keeps the freshness and the named-source
344
+ precision. Investigate before designing.
345
+
346
+ First step for either is capturing a pi trace of one run: the container is
347
+ `--rm`, so today's 20 minutes left no evidence of where the time went.
348
+
349
+ ---
350
+
351
+ ## G. Tagging the bot by number — **fixed 2026-08-10**
352
+
353
+ **The bug.** In `greece2026`, tagging the bot's number did nothing. The chain:
354
+ the adapter rewrites a tag into `@<botUsername>` (`@ויציבוט` here); the space
355
+ sets `trigger.patterns = "Wiz, @Wiz"`; and `trigger.ts` *replaces* the default
356
+ pattern list when a space defines its own, discarding the `@<botUsername>` that
357
+ `router.ts` appends to the defaults. So the rewritten name matched nothing and
358
+ a correct tag was filed as ambient.
359
+
360
+ **Caught in the live DB:** `messages#1181`, greece2026, 2026-08-10T07:47:44Z,
361
+ from Sharon — `@ויציבוט : I am very fond of cheeses, what can I expect and
362
+ explore on our holiday?` — stored `role='ambient'`, never answered.
363
+
364
+ **`main` was broken the same way** and nobody had noticed: its patterns are all
365
+ bare (`ויציבוט`, no `@` form), and the mention regex requires start-of-string or
366
+ whitespace before the pattern, so a bare pattern never matches `@`-prefixed
367
+ text. Only space `19` worked, and only because `@ויציבוט` had been added to its
368
+ list by hand.
369
+
370
+ **The fix** stops re-deriving "was I addressed" from text. The adapter already
371
+ knows from the mention's JID (`isBotJid`), so it now sets `isBotMentioned` and
372
+ that flag rides the IngressMessage to both trigger gates (`router.ts`,
373
+ `handler.ts` — they each have one). The text rewrite stays, but only as
374
+ cosmetics for stripping and readability; it is allowed to find nothing.
375
+
376
+ - **Off switch:** space config `trigger.mention_always` (default `true`), set
377
+ per space. `mrctl config set trigger.mention_always false` works as soon as
378
+ the key is in `BUILTIN_CONFIG_KEYS` — but the **dashboard row is not free**.
379
+ `routes/dashboard.ts` builds the "Triggers & ambient" rows from a hardcoded
380
+ list, so the control had to be added there by hand (it reads through
381
+ `mentionAlwaysEnabled()`, the same helper the gates use, so the two cannot
382
+ drift).
383
+ - **Also fixed in passing:** `mergeIngresses` dropped `droppedAttachments`, so
384
+ the M4.1 media-drop note vanished whenever two messages merged inside the
385
+ debounce window — the exact silence that fix existed to remove.
386
+ - Cover: `tests/mention-always-triggers.test.ts` (13 tests), including the live
387
+ failure and the case where the rewrite finds nothing.
388
+
389
+ ### G-1. Still open: Discord has the same latent bug
390
+
391
+ `discord-native.ts:430` already computes a structured `isMention` from
392
+ `msg.mentions.users`, then throws it away and relies on the same text rewrite.
393
+ Only the WhatsApp bridge sets `isBotMentioned`. A Discord space with custom
394
+ trigger patterns would fail exactly as `greece2026` did. Not wired up because
395
+ this branch is WhatsApp-scoped — the flag and both gates are already generic, so
396
+ it is a two-line bridge change whenever Discord matters.
@@ -0,0 +1,245 @@
1
+ # Roadmap: Mercury WhatsApp Bot — Context, Identity & Command Hardening
2
+
3
+ **Goal**: [whatsapp-bot-hardening](goal.md)
4
+ **Last updated**: 2026-08-10
5
+ **Still open**: [open-threads.md](open-threads.md)
6
+
7
+ **All four milestones are done.** What remains is the PR (parked on push access)
8
+ and the phone-dependent manual checks — both tracked in
9
+ [open-threads.md](open-threads.md), which is now the live to-do list. This file
10
+ is the record of what shipped.
11
+
12
+ Ordering follows the handoff's §7 (archived at
13
+ `docs/goals/whatsapp-bot-hardening/archive/handoff-2026-08-09.md`): context/identity
14
+ first (highest impact,
15
+ shared code path), then the three defects that destroy data or lock you out,
16
+ then the rest of the command surface, then capabilities, then member memory.
17
+ The switchover is Milestone 1 because nothing can be tested live before it.
18
+
19
+ ## Milestones
20
+
21
+ ### Milestone 1: Run from source
22
+ > The live bot serves from the git checkout; fixes become edit→restart testable.
23
+
24
+ | ID | Story | Slug | Depends on | Status |
25
+ |----|-------|------|------------|--------|
26
+ | M1.1 | Deployment switchover + commit the `.dockerignore` build fix | run-from-source-switchover | — | **done** |
27
+
28
+ **Checkpoint:** DB backed up (all three files: `state.db`, `-wal`, `-shm`);
29
+ `bun run check` green; service restarted and answering a WhatsApp DM;
30
+ `SELECT model, provider FROM token_usage ORDER BY id DESC LIMIT 1` confirms the
31
+ expected model; the 0.9.0 db.ts migration ran cleanly against the existing DB;
32
+ rollback path (`npm unlink` + old unit) verified to exist but not exercised.
33
+
34
+ ### Milestone 2: Context & identity (Bugs 1 + 2)
35
+ > The bot sees untagged group messages and knows who said what — with the
36
+ > context budget capped so ambient can't starve real turns.
37
+
38
+ | ID | Story | Slug | Depends on | Status |
39
+ |----|-------|------|------------|--------|
40
+ | M2.1 | Message author attribution (schema, rendering, pushNames persistence, LID resolution) | message-author-attribution | M1.1 | **done** (`a7279e8`) |
41
+ | M2.2 | Ambient group context (recordAmbient, bounded query, retention TTL) | ambient-group-context | M2.1 | **done** (`4bf5505`) |
42
+
43
+ M2.1 lands first so that when ambient starts flowing it is attributed from day
44
+ one — ambient without attribution produces a wall of anonymous text that makes
45
+ misidentification *worse* (HANDOFF §7.1).
46
+
47
+ **Checkpoint:** with `log_level: debug` for the test window only — send 3
48
+ untagged messages in a test group → exactly 3 new `role='ambient'` rows with
49
+ authors; tag the bot and ask what was just said; two people talk, a third asks
50
+ "who said X?" → correct attribution; restart the service, quote-reply an old
51
+ message → `<reply_to>` shows a name, not a raw LID. Then set logging back to
52
+ `info` and confirm ambient rows for `greece2026` keep growing:
53
+ `SELECT space_id, role, COUNT(*) FROM messages GROUP BY space_id, role`.
54
+
55
+ ### Milestone 3: Command surface safety
56
+ > No command can silently destroy data, lock a group out, or fail to stop a
57
+ > runaway agent; chat and CLI gates agree.
58
+
59
+ | ID | Story | Slug | Depends on | Status |
60
+ |----|-------|------|------------|--------|
61
+ | M3.1 | Destructive command guards — mrctl delete argument, confirmation matching, unlink lockout, /stop routing (Bugs 5.1, 6a, 6c) | destructive-command-guards | M1.1 | **done** (`c44720f`) |
62
+ | M3.2 | Command routing consistency — untagged slash commands in groups, permission-gate alignment, /resume + /help pause, redundancy cleanup (Bugs 5 rest, 6b, 6d, 6e) | command-routing-consistency | M3.1 | **done** (`4f05fbb`) |
63
+
64
+ **Checkpoint:** in a **throwaway** space/group only — `/spaces delete <other-id>`
65
+ then **reply** "yes" → the named space dies, current one survives (the baduk
66
+ regression test); `mrctl spaces delete foo` refuses with the explanatory error;
67
+ `/stop` during a running turn aborts it; unlink a throwaway group then re-link
68
+ it entirely from chat; bare `/spaces list` works untagged in a group.
69
+
70
+ ### Milestone 4: Capabilities & member memory
71
+ > Large files are readable, failures are explained, the bot can produce real
72
+ > documents, and it accumulates per-member knowledge.
73
+
74
+ | ID | Story | Slug | Depends on | Status |
75
+ |----|-------|------|------------|--------|
76
+ | M4.1 | Media size cap + no silent drops (Bug 3) | media-size-and-silent-drop | M1.1 | **done** (`5734c04`) |
77
+ | M4.2 | `docs` extension — real .docx creation (Bug 4) | docs-extension | M1.1 | **done** (`e1208b8`) |
78
+ | M4.3 | Member memory — hard identity + soft notes | member-memory | M2.1 | **done** (`44a7542`) |
79
+
80
+ **Checkpoint:** send a ~50 MB docx → bot reads it; send a ~200 MB file → bot
81
+ says *why* it can't; "make me a doc with X" → a `.docx` arrives in WhatsApp and
82
+ opens in Word, triggered by a member (not just admin); after some group chat,
83
+ ask the bot what it knows about a member → answer draws on
84
+ `knowledge/members/<id>.md` with correct identity.
85
+
86
+ ## Dependency Graph
87
+
88
+ ```
89
+ M1.1 run-from-source-switchover
90
+ ├─→ M2.1 message-author-attribution ─→ M2.2 ambient-group-context
91
+ │ └─→ M4.3 member-memory
92
+ ├─→ M3.1 destructive-command-guards ─→ M3.2 command-routing-consistency
93
+ ├─→ M4.1 media-size-and-silent-drop
94
+ └─→ M4.2 docs-extension
95
+ ```
96
+
97
+ M3.x is independent of M2.x and can be built in parallel, but M3.1 should not
98
+ wait past M2 — it contains the fixes for the defects that already destroyed a
99
+ space once.
100
+
101
+ ## Deploying prompt changes — read this before believing a test
102
+
103
+ **`src/agent/container-entry.ts` runs inside the agent container and is baked
104
+ into the image** (`container/Dockerfile:107`). Restarting the service does not
105
+ pick up edits to it. Everything host-side (ambient recording, author storage,
106
+ command guards, media notes) goes live on `systemctl --user restart mercury`;
107
+ everything prompt-side (author names in history, ambient rendering,
108
+ `<members>`, member notes) needs the image rebuilt.
109
+
110
+ The derived image's cache key is
111
+ `sha256(baseImage + "\n" + baseImageId + "\n" + installCommands)`
112
+ (`extensions/image-builder.ts:313-314`). It **does** include the base image ID,
113
+ so `bash ./container/build.sh` alone is enough to change the derived tag and
114
+ force a rebuild — measured 2026-08-10, tag moved `dd9a11d5 → 0597b302` with the
115
+ extension list untouched.
116
+
117
+ The caveat is `resolveBaseImageId` (`image-builder.ts:327`): it shells out to
118
+ `docker image inspect` and returns `null` on failure. A null base ID drops out
119
+ of the key and the stale-cache trap comes back, silently. So `docker rmi` is
120
+ belt-and-braces rather than mandatory — cheap enough to keep in the procedure.
121
+
122
+ Full procedure after touching `container-entry.ts`:
123
+
124
+ ```bash
125
+ cd ~/mercury-src && bash ./container/build.sh # rebuild mercury-agent:latest
126
+ docker rmi mercury-agent-ext:<current-hash> # optional; forces it regardless
127
+ systemctl --user restart mercury # ~4 min rebuild, then live
128
+ docker run --rm --entrypoint sh mercury-agent-ext:<new-hash> \
129
+ -c 'grep -c members /app/src/agent/container-entry.ts' # verify, do not assume
130
+ ```
131
+
132
+ Then confirm the service actually resolved to the new image: its
133
+ `Built derived agent image <tag>` / `Using cached agent image <tag>` log line
134
+ must show the **new** hash, not the one you deleted.
135
+
136
+ This was found by asking the bot to list its `<members>` block and getting an
137
+ honest "there is no members block" — the tests were green and the service had
138
+ been restarted. Verify prompt changes against the running bot, not the repo.
139
+
140
+ Note also that `sync-mercury.sh` only warns you to rebuild when `container/` or
141
+ `docker/` changed. `src/agent/container-entry.ts` is baked in too and is **not**
142
+ in that check, so a prompt-only change syncs and restarts with no warning at
143
+ all.
144
+
145
+ ## Completion — the PR (after Milestone 4)
146
+
147
+ Not a story; the goal's exit gate (archived handoff §5). One PR from
148
+ `fix/whatsapp-context-identity-and-docs` → `main`, one section per change,
149
+ explicitly covering:
150
+
151
+ 1. The four pinchtab commits as **upstreaming an already-live hotfix**, with
152
+ the `allowedDomains "*"` trade-off stated plainly (D-011).
153
+ 2. The `.dockerignore` fix (exists in no commit anywhere until M1.1).
154
+ 3. The `messages` schema migration and its NULL-backfill behaviour.
155
+ 4. Ambient volume/retention implications.
156
+ 5. The baduk incident as motivation for the regression tests.
157
+ 6. **Behaviour changes users will feel:** `/stop` now aborts instead of
158
+ prompting; slash commands work untagged in groups; `/spaces unlink` removed
159
+ from chat; `/model active` dropped.
160
+
161
+ ### Pre-PR housekeeping — **done** (2026-08-10)
162
+
163
+ | Item | Outcome |
164
+ |------|---------|
165
+ | D:-clone's `fix/pinchtab-browser-startup` | **Superseded, not pushed** (D-013). Combined patch byte-identical to `bfe5a33..b12d7c0` in `mercury-src`; clone's base is 3 commits behind `origin/main`. Branch left in place. |
166
+ | Stray `~/.mercury/state.db` | **Deleted.** Verified schema-only first: 0 messages, 0 spaces, 0 space_roles. Live DB is `~/whatsapp-bot/.mercury/state.db` (1020 messages, 4 spaces, 27 roles) and was untouched. |
167
+ | Debug logging | **Already `info`.** `mercury.yaml:45`; no `MERCURY_LOG_LEVEL` in `.env` (only the three API keys and `MERCURY_AGENT_IMAGE`), so no env override. Debug was never enabled during implementation. |
168
+
169
+ ### The cwd trap — root-caused and closed (2026-08-10)
170
+
171
+ The stray DB existed because `data_dir: .mercury` is **relative**, resolved
172
+ against `process.cwd()` by `resolveProjectPath` (`config.ts:412`). The service
173
+ was never at risk (`WorkingDirectory=/home/guysaha/whatsapp-bot`), but running
174
+ `mercury` by hand from `$HOME` created a fresh empty `~/.mercury/state.db` and
175
+ used *that* — which is why `mercury auth status` reported "not logged in".
176
+
177
+ Making `data_dir` absolute is **not sufficient on its own**: `mercury.yaml` is
178
+ itself located relative to cwd (`config-file.ts:315`), so from `$HOME` no config
179
+ is found and the built-in `.mercury` default applies regardless. Both halves
180
+ were required:
181
+
182
+ 1. `runtime.data_dir: /home/guysaha/whatsapp-bot/.mercury` in `mercury.yaml`
183
+ 2. `export MERCURY_CONFIG_FILE=/home/guysaha/whatsapp-bot/mercury.yaml` in
184
+ `~/.profile` — deliberately **not** `~/.bashrc`, which returns early for
185
+ non-interactive shells (`case $- in *i*) ;; *) return;;`) and so would never
186
+ apply to `wsl -e bash -lc`.
187
+
188
+ Verified by loading the real `loadConfig()` from both directories, with a
189
+ negative control: unsetting `MERCURY_CONFIG_FILE` makes `$HOME` fall back to
190
+ `/home/guysaha/.mercury`, proving the mechanism rather than assuming it. After
191
+ the restart, `mercury auth status` from `$HOME` reports "✓ logged in", no
192
+ `~/.mercury` is recreated, and the live DB is intact.
193
+
194
+ **Residual limit:** `.env` is still cwd-bound — hardcoded `join(CWD, ".env")`
195
+ (`cli/mercury.ts:112`), no override exists. CLI commands that need API keys must
196
+ still run from `~/whatsapp-bot`; `mercury auth status` elsewhere will say "No API
197
+ keys found in .env". Not worth patching upstream for this alone.
198
+
199
+ **`mrctl` is not a host CLI.** It is the in-container agent tool, reading
200
+ `API_URL`/`CALLER_ID`/`SPACE_ID` from the environment and exiting
201
+ "API_URL not set" on the host. Inspect live state by querying `state.db`.
202
+
203
+ Backups: `mercury.yaml.bak-datadir-20260810`, `~/.profile.bak-20260810`.
204
+
205
+ ### API exposure — closed 2026-08-10
206
+
207
+ Found while building a phone-free test harness. `Bun.serve` is called with no
208
+ `hostname` (`main.ts:128`), so the server listens on `*:8787`, and **both auth
209
+ secrets were unset**. Unauthenticated, that meant:
210
+
211
+ - `GET /dashboard/page/*` — every dashboard data page
212
+ - `GET /api/*` — caller identity is self-asserted via `x-mercury-caller` /
213
+ `x-mercury-space` headers, so any space's data was readable
214
+ - `POST /chat` — runs a real agent turn: spends tokens, invokes tools
215
+
216
+ Not LAN-reachable (WSL2 default NAT, no `.wslconfig`, no `netsh portproxy`), but
217
+ reachable from any process on the Windows host and — because `/chat` parses a
218
+ `text/plain` body — from **any web page open in a browser**, since that makes a
219
+ cross-origin POST a CORS "simple request" with no preflight. The response can't
220
+ be read, but the turn still executes.
221
+
222
+ Fixed by setting `MERCURY_API_SECRET` and `MERCURY_CHAT_API_KEY` in `.env`
223
+ (backup `.env.bak-secrets-20260810`), then restarting. Verified after: all three
224
+ surfaces return 401, and an authenticated `/chat` turn that runs `mrctl prefs
225
+ list` still succeeds — confirming `API_SECRET` reaches the container
226
+ (`container-runner.ts:950`).
227
+
228
+ `GET /dashboard` still returns 200 unauthenticated. That is the static SPA shell
229
+ only (`dashboard/index.html`, no space data); its `app.get` is registered at
230
+ `server.ts:135`, before the `/dashboard/*` auth middleware at `server.ts:191`,
231
+ and Hono's `/dashboard/*` does not match the bare path. Version disclosure only.
232
+
233
+ **Do not "fix" this by binding to `127.0.0.1`** — containers reach the API via
234
+ `host.docker.internal:host-gateway`, so loopback-only binding breaks every turn.
235
+
236
+ Remaining hardening moved to [open-threads.md](open-threads.md) §B.
237
+
238
+ ### Prompt layers — investigated 2026-08-10
239
+
240
+ Triggered by the question "do space preferences actually change behaviour?".
241
+ They do — `19`'s "no emojis at all" holds at 0/40 assistant messages. But the
242
+ investigation found **five** prompt layers rather than the three assumed, one of
243
+ which (`space_config.system_prompt`) has no setter at all, and it measured that
244
+ **precedence between layers is not deterministic** (D-014). Full map, the emoji
245
+ data, and the live conflicts are in [open-threads.md](open-threads.md) §A.