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,178 @@
1
+ # Media Size Cap + No Silent Drops
2
+
3
+ **Status**: Done
4
+ **Slug**: media-size-and-silent-drop
5
+ **Goal**: whatsapp-bot-hardening
6
+ **Milestone**: 4
7
+ **Created**: 2026-08-09
8
+ **Last updated**: 2026-08-09
9
+
10
+ ---
11
+
12
+ ## Goal
13
+
14
+ > Part of goal [whatsapp-bot-hardening](../goals/whatsapp-bot-hardening/goal.md).
15
+ > HANDOFF Bug 3.
16
+
17
+ A 50 MB docx sent to the greece space was rejected seven times by the 10 MB cap
18
+ (`mercury.yaml:74`, enforced pre-download at `whatsapp-media.ts:165` and `:284`
19
+ for the quoted-reply retry) — and the worse half was the **silence**: the
20
+ "could not use your attachment" note only fires when the message has no text,
21
+ so a captioned file left the agent uninformed and it invented a wrong
22
+ explanation, sending the user chasing a non-existent bug. Raise the cap to
23
+ 64 MB (D-004 — `MERCURY_MEDIA_MAX_SIZE_MB` verified unset, so the YAML edit
24
+ takes effect) and make every dropped attachment produce an explanatory note.
25
+
26
+ ## User Stories
27
+
28
+ - As a group member, I want to send a ~50 MB document and have the bot read it.
29
+ - As any user, I want the bot to *tell me why* it can't use an attachment
30
+ (too large / media disabled / download failed) instead of guessing.
31
+
32
+ ## MVP Scope
33
+
34
+ **In scope:** `media.max_size_mb: 64` in the live `mercury.yaml`; an
35
+ unconditional `promptNote`-style system note on any dropped attachment,
36
+ distinguishing the three failure reasons, copied from the existing
37
+ `media-gate.ts` pattern.
38
+
39
+ **Out of scope:** download→extract-text→discard for oversize documents —
40
+ queued as a follow-up idea (HANDOFF fix 3, optional item).
41
+
42
+ ---
43
+
44
+ ## Context for Claude
45
+
46
+ - `~/whatsapp-bot/mercury.yaml:74` (`max_size_mb`) — live config, edit inside
47
+ WSL; confirm the effective value, not just the YAML (env overrides win).
48
+ - `src/adapters/whatsapp-media.ts:165` and `:284` — the pre-download size gates
49
+ (initial + quoted-reply retry paths).
50
+ - `src/core/runtime.ts` — where the existing text-only-when-no-caption
51
+ attachment note lives.
52
+ - `src/core/media-gate.ts` — the `promptNote` pattern to copy.
53
+ - `HANDOFF.md` Bug 3 — evidence incl. the journal WARN lines.
54
+
55
+ ---
56
+
57
+ ## Architecture & Data
58
+
59
+ ### Data Models
60
+
61
+ None. Config change:
62
+
63
+ ```yaml
64
+ media:
65
+ max_size_mb: 64
66
+ ```
67
+
68
+ ### API Contracts
69
+
70
+ None.
71
+
72
+ ### File & Folder Structure
73
+
74
+ | Path | New / Modified | Purpose |
75
+ |------|---------------|---------|
76
+ | `~/whatsapp-bot/mercury.yaml` | Modified | Cap 10 → 64 (live config, not in the repo) |
77
+ | `src/adapters/whatsapp-media.ts` | Modified | Surface the drop reason to the ingress path (both gates) |
78
+ | `src/core/runtime.ts` | Modified | Always inject the note when an attachment is dropped, caption or not |
79
+ | test files alongside | New/Modified | See tests below |
80
+
81
+ ### Implementation Sequence
82
+
83
+ 1. Thread the drop reason from both size gates in `whatsapp-media.ts` so the
84
+ runtime knows *that* and *why* an attachment was skipped.
85
+ - Read first: `src/adapters/whatsapp-media.ts:150-300`
86
+ - Verify: unit — an over-limit attachment yields a structured drop reason on
87
+ both the initial and quoted-reply paths.
88
+ 2. Unconditional note in `runtime.ts` using the `media-gate.ts` `promptNote`
89
+ pattern — fires regardless of caption text; wording distinguishes
90
+ "too large (limit N MB)" / "media disabled" / "download failed".
91
+ - Read first: `src/core/runtime.ts` (existing attachment-note branch),
92
+ `src/core/media-gate.ts`
93
+ - Verify: unit — over-limit attachment **with a caption** still produces a
94
+ note; note text differs across the three reasons.
95
+ 3. Raise the live cap: edit `mercury.yaml` in WSL, confirm the *effective*
96
+ value (env overrides beat YAML), restart.
97
+ - Read first: memory note "Env overrides beat mercury.yaml"
98
+ - Verify: journal shows the new `maxBytes` on the next large-file attempt.
99
+
100
+ ---
101
+
102
+ ## Non-Negotiable Rules
103
+
104
+ - Confirm `MERCURY_MEDIA_MAX_SIZE_MB` is still unset before trusting the YAML
105
+ edit (env silently wins). Do not Read `.env` — check the effective config.
106
+ - The note must never leak file contents — filename, type, size, reason only.
107
+
108
+ ---
109
+
110
+ ## Edge Cases & Risks
111
+
112
+ | Scenario | Handling |
113
+ |----------|---------|
114
+ | ~200 MB file | Still dropped; the bot now *says why* (manual test case) |
115
+ | Disk pressure from 64 MB files | Accepted (D-004); revisit if it bites |
116
+ | Quoted-reply retry of a large file | Same note — the `:284` gate is covered too |
117
+ | Env override added later | Memory note + rule above; check effective value when debugging |
118
+
119
+ ---
120
+
121
+ ## Implementation Checklist
122
+
123
+ ### Phase 1 — Goal
124
+ - [x] Goal, User Stories, and MVP Scope written and reviewed
125
+
126
+ ### Phase 2 — Architecture
127
+ - [x] Architecture & Data section complete
128
+ - [x] Non-Negotiable Rules defined
129
+ - [x] Edge Cases covered
130
+ - [x] Context for Claude pointers filled in
131
+
132
+ ### Implementation (commit `5734c04`)
133
+ - [x] Drop reason threaded (step 1) — all four drop paths, not just the two size gates
134
+ - [x] Unconditional note (step 2)
135
+ - [x] Live cap raised + **effective** value confirmed (`mediaMaxSizeMb: 64`)
136
+ - [x] 7 unit tests green; `bun run check` passes (1411 pass / 0 fail)
137
+ - [ ] **Manual (needs a phone):** ~50 MB docx downloads and is readable
138
+ - [ ] **Manual (needs a phone):** ~200 MB file → bot explains why it can't
139
+ - [x] No secrets or `.env` files committed
140
+ - [x] No unrelated files modified
141
+
142
+ ---
143
+
144
+ ## Open Questions
145
+
146
+ - [ ] none
147
+
148
+ ---
149
+
150
+ ## Retrospective
151
+
152
+ **Residual risks / follow-ups:**
153
+ - Oversize-document text extraction → ideas/oversize-doc-extraction.md (create if wanted)
154
+
155
+ **What changed from the plan:**
156
+ - The spec assumed the note only needed unblocking. Two upstream holes had to
157
+ be closed first, both found by reading the path rather than trusting it:
158
+ 1. **The WhatsApp bridge never set `hadIncomingAttachments`.** The existing
159
+ no-text branch that was supposed to speak up could therefore never fire
160
+ on WhatsApp — the one platform where the bug was reported.
161
+ 2. **`normalize()` returned null when there was no text and no attachment**,
162
+ so an oversize file sent *without* a caption vanished before the runtime
163
+ saw it at all. A recorded drop is now reason enough to build a message.
164
+ - Reasons are reported from four paths, not the two the spec named: the
165
+ pre-download size check, the post-download size check, an empty buffer, and
166
+ a download exception. The last two are how "download failed" becomes
167
+ distinguishable from "too large".
168
+
169
+ **Key decisions made during implementation:**
170
+ - The reason travels as structured data (`DroppedAttachment`) rather than a
171
+ pre-formatted string, so the note wording lives in one place and the note
172
+ can state both numbers for an over-limit file.
173
+ - The note explicitly instructs the agent not to invent an alternative
174
+ explanation. The original failure was not silence alone — it was the
175
+ confident wrong story the agent told to fill it.
176
+
177
+ **Architecture impact** (update `docs/ARCHITECTURE.md` if any of these apply):
178
+ - [ ] none expected
@@ -0,0 +1,199 @@
1
+ # Member Memory
2
+
3
+ **Status**: Done
4
+ **Slug**: member-memory
5
+ **Goal**: whatsapp-bot-hardening
6
+ **Milestone**: 4
7
+ **Created**: 2026-08-09
8
+ **Last updated**: 2026-08-09
9
+
10
+ ---
11
+
12
+ ## Goal
13
+
14
+ > Part of goal [whatsapp-bot-hardening](../goals/whatsapp-bot-hardening/goal.md).
15
+ > HANDOFF Bug 2, "Member memory feature" — mostly prompt + skill work once
16
+ > attribution (M2.1) lands.
17
+
18
+ Give the bot durable per-member knowledge in each space: **hard identity** (a
19
+ `<members>` block generated from `space_roles` — id → name from the DB, never
20
+ guessable by the model) plus **soft characteristics** (per-space
21
+ `knowledge/members/<id>.md` notes the agent accumulates itself with its
22
+ existing `write` tool), injected into the prompt the same way
23
+ `<active_episodes>` already is (`container-entry.ts:513`). Built on existing
24
+ episodic plumbing — no new subsystem, no schema migration for the notes half
25
+ (D-010).
26
+
27
+ ## User Stories
28
+
29
+ - As a group member, I want the bot to know who the 26 people in the space are
30
+ by name and id, reliably, from the DB.
31
+ - As a user, I want the bot to remember soft facts it learns ("guy prefers
32
+ short answers", "Sahar admins the space") across sessions, per space.
33
+
34
+ ## MVP Scope
35
+
36
+ **In scope:** `<members>` block from `space_roles` injected per turn;
37
+ `knowledge/members/<id>.md` convention + injection alongside episodes; skill
38
+ guidance telling the agent when/how to write member notes; per-space isolation.
39
+
40
+ **Out of scope:** cross-space member profiles (naturally per-space by design);
41
+ automatic summarization/consolidation of notes (napkin-style — future);
42
+ any DB table for notes (D-010 rejected it).
43
+
44
+ ---
45
+
46
+ ## Context for Claude
47
+
48
+ - `src/agent/container-entry.ts:513` — `<active_episodes>` injection; the
49
+ pattern to mirror for both `<members>` and member notes.
50
+ - `space_roles` table — id → `display_name`, the hard-identity source (verified
51
+ clean, 26 members in greece2026).
52
+ - `.mercury/spaces/<id>/` workspace layout — where `knowledge/members/` lives;
53
+ the agent's `write` tool already reaches it.
54
+ - `message-author-attribution` spec (M2.1) — supplies the ids that make notes
55
+ addressable; must be Done first.
56
+ - `HANDOFF.md` Bug 2, member-memory paragraph.
57
+
58
+ ---
59
+
60
+ ## Architecture & Data
61
+
62
+ ### Data Models
63
+
64
+ None (workspace files + prompt rendering only).
65
+
66
+ ```
67
+ .mercury/spaces/<space>/knowledge/members/<sanitized-member-id>.md
68
+ ```
69
+
70
+ File naming: sanitized canonical id (e.g. `972500000000.md`) so the agent can
71
+ address a note file from the `<members>` block without guessing.
72
+
73
+ ### API Contracts
74
+
75
+ Prompt contract (rendered per turn):
76
+
77
+ ```xml
78
+ <members>
79
+ <member id="whatsapp:972500000000@s.whatsapp.net" name="Member Name" role="member"/>
80
+ …
81
+ </members>
82
+ <member_notes>
83
+ <note member="whatsapp:9725...">…file contents…</note> <!-- only files that exist -->
84
+ </member_notes>
85
+ ```
86
+
87
+ Exact tag names to match surrounding prompt idiom in `container-entry.ts`.
88
+
89
+ ### File & Folder Structure
90
+
91
+ | Path | New / Modified | Purpose |
92
+ |------|---------------|---------|
93
+ | `src/agent/container-entry.ts` | Modified | Render `<members>` from space_roles + inject existing member note files |
94
+ | space workspace `knowledge/members/` | New (runtime) | Agent-written notes |
95
+ | skill / AGENTS.md guidance | Modified | When to write a member note, what belongs there, what doesn't |
96
+ | test files alongside | New/Modified | See tests below |
97
+
98
+ ### Implementation Sequence
99
+
100
+ 1. Render the `<members>` block from `space_roles` in the container entry
101
+ (group spaces only; cap or elide gracefully for very large member lists).
102
+ - Read first: `src/agent/container-entry.ts:490-540` and the query helpers
103
+ it already uses
104
+ - Verify: unit — prompt for a group space contains each member id + name
105
+ from the DB; DM spaces unaffected.
106
+ 2. Inject `knowledge/members/*.md` files that exist, following the episodes
107
+ injection pattern (size-bounded).
108
+ - Read first: the episodes injection code path
109
+ - Verify: unit — a note file present in the workspace appears in the
110
+ prompt; absent files add nothing; oversized notes are truncated bounded.
111
+ 3. Skill/persona guidance: teach the agent to append observations to
112
+ `knowledge/members/<id>.md` via its `write` tool — identity facts stay
113
+ hard (from `<members>`), notes are observations only.
114
+ - Read first: existing space `AGENTS.md` + any relevant skill
115
+ - Verify: text review; manual test below.
116
+
117
+ ---
118
+
119
+ ## Non-Negotiable Rules
120
+
121
+ - Identity (id ↔ name) always comes from `space_roles` — the model must never
122
+ be positioned to invent or override it; notes must not restate identity.
123
+ - Notes are per-space; no cross-space leakage.
124
+ - Prompt injection is size-bounded (member list and notes both) so a big group
125
+ can't blow the context budget — same discipline as D-003.
126
+
127
+ ---
128
+
129
+ ## Edge Cases & Risks
130
+
131
+ | Scenario | Handling |
132
+ |----------|---------|
133
+ | Member with no display_name yet | Render id-only entry; name appears once known |
134
+ | Very large group | Bounded rendering (cap + count note) |
135
+ | Agent writes junk/sensitive notes | Skill guidance constrains content; notes are plain files — reviewable and deletable by the operator |
136
+ | Member leaves the space | `space_roles` drives the block; stale note file stays until deleted (accepted) |
137
+ | Notes grow unbounded | Size-bounded injection now; consolidation deferred (out of scope) |
138
+
139
+ ---
140
+
141
+ ## Implementation Checklist
142
+
143
+ ### Phase 1 — Goal
144
+ - [x] Goal, User Stories, and MVP Scope written and reviewed
145
+
146
+ ### Phase 2 — Architecture
147
+ - [x] Architecture & Data section complete
148
+ - [x] Non-Negotiable Rules defined
149
+ - [x] Edge Cases covered
150
+ - [x] Context for Claude pointers filled in
151
+
152
+ ### Implementation (commit `44a7542`)
153
+ - [x] `<members>` rendering (step 1)
154
+ - [x] Note injection (step 2)
155
+ - [x] Skill guidance (step 3)
156
+ - [x] 12 unit tests green; `bun run check` passes (1423 pass / 0 fail)
157
+ - [x] **Verified live:** asked the bot to list the `<members>` block without
158
+ guessing — it returned the real ids, names and roles from `space_roles`
159
+ (and correctly reported the block's *absence* before the image rebuild,
160
+ which is how the stale-image problem below was caught)
161
+ - [ ] **Manual (needs a phone):** after real group chat, "what do you know
162
+ about <member>?" → correct identity + accumulated notes
163
+ - [x] No secrets or `.env` files committed
164
+ - [x] No unrelated files modified
165
+
166
+ ---
167
+
168
+ ## Open Questions
169
+
170
+ - [ ] Should the agent be told to ask before recording personal observations
171
+ about members (privacy etiquette in a family group)? — owner: user
172
+
173
+ ---
174
+
175
+ ## Retrospective
176
+
177
+ **Residual risks / follow-ups:**
178
+ - Note consolidation (napkin-style distillation) — ideas/member-notes-consolidation.md (create if wanted)
179
+
180
+ **What changed from the plan:**
181
+ - The `<members>` data could not be read in the container the way episodes
182
+ are — `space_roles` lives in the host database — so it rides on the
183
+ container payload instead. `payload = { ...input }` meant adding it to the
184
+ runner's input type was enough for it to flow through.
185
+ - Members are ordered named-first so a truncated list keeps the rows that
186
+ carry usable information; an id with no name tells the model nothing.
187
+
188
+ **Key decisions made during implementation:**
189
+ - Note bodies are XML-escaped even though the agent writes them. A note is
190
+ assembled from things people said in a group, so an unescaped body is a path
191
+ from group chat to forged prompt markup — the same hole closed in M2.1.
192
+ - The guidance draws the hard/soft line explicitly (notes must not restate
193
+ id-to-name mappings), so the two sources of identity can never disagree.
194
+ - Privacy is handled by instruction plus the storage choice: notes are plain
195
+ files the operator can read or delete, and the agent is told not to record
196
+ what someone would be uncomfortable reading, and to delete on request.
197
+
198
+ **Architecture impact** (update `docs/ARCHITECTURE.md` if any of these apply):
199
+ - [ ] New inter-package interaction (container-entry ← space_roles/members rendering)
@@ -0,0 +1,211 @@
1
+ # Message Author Attribution
2
+
3
+ **Status**: Done
4
+ **Slug**: message-author-attribution
5
+ **Goal**: whatsapp-bot-hardening
6
+ **Milestone**: 2
7
+ **Created**: 2026-08-09
8
+ **Last updated**: 2026-08-09
9
+
10
+ ---
11
+
12
+ ## Goal
13
+
14
+ > Part of goal [whatsapp-bot-hardening](../goals/whatsapp-bot-hardening/goal.md).
15
+ > HANDOFF Bug 2.
16
+
17
+ The bot attributes past group messages to the wrong person because stored
18
+ messages carry **no author** — `messages` has no author column, history renders
19
+ as bare `<user>…</user>`, and the only identity in the prompt is the *current*
20
+ caller. Attributing all history to that one name is the only reading the prompt
21
+ supports; this is a prompt bug, not a model glitch. Additionally, quoted-reply
22
+ senders resolve to raw LIDs after every restart because `pushNames` is
23
+ in-memory only. The clean identity data already exists in
24
+ `space_roles.display_name` (26 members, keyed by canonical phone JID) — nothing
25
+ uses it. This story lands **before** ambient (M2.2) so ambient text is
26
+ attributed from day one.
27
+
28
+ ## User Stories
29
+
30
+ - As a group member, I want the bot to know who said what so that "who said X?"
31
+ gets the right answer.
32
+ - As a group member, I want quoted replies to show real names even after the
33
+ service restarts, not `136043810578453@lid`.
34
+
35
+ ## MVP Scope
36
+
37
+ **In scope:** `author_id`/`author_name` columns + migration, populated at
38
+ `addMessage`; `<user name id>` rendering in history XML (incl. ambient
39
+ entries); DB-backed `pushNames` (seed at startup, write-through); async
40
+ `canonicalizeJid` for quoted participants.
41
+
42
+ **Out of scope:** backfilling authors for existing rows — they stay NULL and
43
+ must still render; member *characteristics* memory — that's `member-memory`
44
+ (M4.3).
45
+
46
+ ---
47
+
48
+ ## Context for Claude
49
+
50
+ - `src/core/runtime.ts:1573` — `addMessage(spaceId, "user", finalPrompt, …)`;
51
+ the write site that must start passing author identity.
52
+ - `src/agent/container-entry.ts:598` (`<user>` rendering in `buildHistoryXml`)
53
+ and `:634` (`<caller id name role/>` — the only identity today).
54
+ - `src/adapters/whatsapp.ts:82` (`buildReplyContext`) and `:169` (`pushNames`
55
+ in-memory map) — the LID/restart bug.
56
+ - `space_roles` table — `display_name` fully populated, canonical phone JIDs;
57
+ the seed source.
58
+ - `HANDOFF.md` Bug 2 — full root-cause analysis.
59
+
60
+ ---
61
+
62
+ ## Architecture & Data
63
+
64
+ ### Data Models
65
+
66
+ ```sql
67
+ -- migration: add author columns; existing rows backfill NULL
68
+ ALTER TABLE messages ADD COLUMN author_id TEXT; -- canonical JID, e.g. whatsapp:972...@s.whatsapp.net
69
+ ALTER TABLE messages ADD COLUMN author_name TEXT; -- display name at write time
70
+ ```
71
+
72
+ Migration must be idempotent against the live DB (follow the existing `db.ts`
73
+ migration pattern). NULL-author rows render as today's bare `<user>`.
74
+
75
+ ### API Contracts
76
+
77
+ None (internal schema + prompt rendering only).
78
+
79
+ ### File & Folder Structure
80
+
81
+ | Path | New / Modified | Purpose |
82
+ |------|---------------|---------|
83
+ | `src/storage/db.ts` | Modified | Migration + `addMessage` signature carries author |
84
+ | `src/core/runtime.ts` | Modified | Pass author identity at every `addMessage` call site (incl. ambient at `:604` — promote the `Name: text` prefix to real fields) |
85
+ | `src/agent/container-entry.ts` | Modified | `buildHistoryXml` renders `<user name="…" id="…">` |
86
+ | `src/adapters/whatsapp.ts` | Modified | `pushNames` seeded from `space_roles.display_name` at startup + write-through; `buildReplyContext` uses async `canonicalizeJid` |
87
+ | test files alongside each | New/Modified | See tests below |
88
+
89
+ ### Implementation Sequence
90
+
91
+ 1. Schema migration + `addMessage` author params.
92
+ - Read first: `src/storage/db.ts` (existing migration pattern, `addMessage`)
93
+ - Verify: migration runs idempotently on a **copy** of the live DB; existing
94
+ rows have NULL authors; `bun run check` green.
95
+ 2. Thread author identity through `runtime.ts` write sites — the ingress
96
+ message already carries the sender; promote the ambient `Name:` prefix
97
+ (`runtime.ts:604`) to the new columns too.
98
+ - Read first: `src/core/runtime.ts:1560-1620` and `:591-612`
99
+ - Verify: unit test — a stored group message row has `author_id` +
100
+ `author_name` matching the sender.
101
+ 3. Render authors in history: `<user name="…" id="…">` in `buildHistoryXml`;
102
+ NULL-author rows render without the attributes.
103
+ - Read first: `src/agent/container-entry.ts:580-640`
104
+ - Verify: unit test — history XML for messages from two different users
105
+ renders two distinct names; NULL-author rows still render.
106
+ 4. Persist `pushNames`: seed from `space_roles.display_name` at adapter
107
+ startup, write through on every message carrying a push name.
108
+ - Read first: `src/adapters/whatsapp.ts:150-200`
109
+ - Verify: unit test — simulated restart (fresh map, seeded from DB) still
110
+ resolves a known member's name.
111
+ 5. Async LID resolution in `buildReplyContext` via `canonicalizeJid`, with
112
+ graceful degradation to the bare jid when unknown.
113
+ - Read first: `src/adapters/whatsapp.ts:60-120` + the canonicalize helpers
114
+ - Verify: unit test — a known LID resolves to alias-store + `display_name`;
115
+ an unknown LID degrades to the bare jid without throwing.
116
+
117
+ Step 1 must precede all others (they write/read the columns).
118
+
119
+ ---
120
+
121
+ ## Non-Negotiable Rules
122
+
123
+ - Migration is idempotent and non-destructive; run it against a **copy** of the
124
+ live DB before deploying (the baduk incident is the cautionary tale).
125
+ - Identity must come from the DB (`space_roles`), never inferred by the model.
126
+ - No behaviour change for DMs beyond the added attributes.
127
+
128
+ ---
129
+
130
+ ## Edge Cases & Risks
131
+
132
+ | Scenario | Handling |
133
+ |----------|---------|
134
+ | Pre-migration rows (NULL author) | Render as today — no name attribute, no crash |
135
+ | Sender not in `space_roles` (new member, no push name yet) | Store canonical id with NULL name; render id only; write-through fills the name on their next named message |
136
+ | LID that can't be canonicalized | `buildReplyContext` degrades to bare jid (current behaviour), never blocks the message |
137
+ | Author name changes over time | `author_name` is name-at-write-time by design; live lookups use `space_roles` |
138
+ | Migration meets the 0.9.0 migration on first start | Ordered after it in `db.ts`; tested together on the DB copy |
139
+
140
+ ---
141
+
142
+ ## Implementation Checklist
143
+
144
+ ### Phase 1 — Goal
145
+ - [x] Goal, User Stories, and MVP Scope written and reviewed
146
+
147
+ ### Phase 2 — Architecture
148
+ - [x] Architecture & Data section complete
149
+ - [x] Non-Negotiable Rules defined
150
+ - [x] Edge Cases covered
151
+ - [x] Context for Claude pointers filled in
152
+
153
+ ### Implementation (commit `a7279e8`)
154
+ - [x] Migration + `addMessage` (step 1)
155
+ - [x] Runtime write sites (step 2)
156
+ - [x] History rendering (step 3)
157
+ - [x] `pushNames` persistence (step 4)
158
+ - [x] Async LID resolution (step 5)
159
+ - [x] 17 unit tests green; `bun run check` passes (1350 pass / 0 fail)
160
+ - [x] Migration verified against a **copy of the live DB** — existing rows load
161
+ with NULL authors, new rows attribute, reopen is idempotent
162
+ - [x] Deployed; live write path confirmed (`author_id` set on a new user row)
163
+ - [ ] **Manual (needs a phone):** two people talk in a group, a third asks
164
+ "who said X?" → correct attribution
165
+ - [ ] **Manual (needs a phone):** restart, quote-reply an old message →
166
+ `<reply_to name=…>` shows a name, not a raw LID
167
+ - [x] No secrets or `.env` files committed
168
+ - [x] No unrelated files modified
169
+
170
+ ---
171
+
172
+ ## Open Questions
173
+
174
+ - [x] `addMessage` signature — settled as an optional trailing `author?:
175
+ MessageAuthor` param. Only 5 production call sites; matches the existing
176
+ optional-trailing-param style; every test call stays valid unchanged.
177
+
178
+ ---
179
+
180
+ ## Retrospective
181
+
182
+ **Residual risks / follow-ups:**
183
+ - Assistant rows deliberately carry no author. If the bot ever gains multiple
184
+ personas, that becomes wrong — accepted.
185
+ - `findDisplayName` takes the most recently updated name across spaces. One
186
+ person with different nicknames in different groups gets the newest, not the
187
+ per-space one — accepted; identity (the id) is still exact.
188
+
189
+ **What changed from the plan:**
190
+ - The plan said to seed `pushNames` from the DB at startup. A lazy lookup is
191
+ better and is what shipped: `upsertMember` already persists names on every
192
+ message, so the DB is always fresher than a boot-time snapshot, and there is
193
+ no scan to run or cache to invalidate.
194
+ - The async LID fix needed **no signature changes**. `canonicalizeJid` learns
195
+ the pair into the alias store as a side effect, so awaiting it before the
196
+ existing synchronous reply-context builder is enough — the sync path then
197
+ finds the mapping. This avoided making `buildReplyContext` async and
198
+ rippling through the sync `parseMessage` interface.
199
+
200
+ **Key decisions made during implementation:**
201
+ - Closed two prompt-injection holes found while editing these lines: ambient
202
+ content was interpolated into `<message>` unescaped, and `<reply_to>`
203
+ attributes (push names are user-typed) were unescaped, so a double quote
204
+ could close the attribute and inject another. Both are in scope by the
205
+ spec's own "identity stays hard" rule — a sender must not be able to forge
206
+ identity markup.
207
+ - `MESSAGE_COLUMNS` extracted as a shared constant; the five message SELECTs
208
+ had drifted into four different hand-maintained column lists.
209
+
210
+ **Architecture impact** (update `docs/ARCHITECTURE.md` if any of these apply):
211
+ - [ ] New data model or schema change (`messages.author_id`, `author_name`)