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.
- package/README.md +6 -0
- package/container/Dockerfile +6 -1
- package/container/Dockerfile.base +6 -1
- package/docs/README.md +50 -0
- package/docs/authoring-profiles.md +8 -0
- package/docs/behavior-layers.md +193 -0
- package/docs/extensions.md +2 -0
- package/docs/goals/football-reporter-profile/decisions.md +479 -0
- package/docs/goals/football-reporter-profile/goal.md +91 -0
- package/docs/goals/football-reporter-profile/roadmap.md +196 -0
- package/docs/goals/whatsapp-bot-hardening/README.md +43 -0
- package/docs/goals/whatsapp-bot-hardening/archive/ambient-group-context.md +211 -0
- package/docs/goals/whatsapp-bot-hardening/archive/command-routing-consistency.md +210 -0
- package/docs/goals/whatsapp-bot-hardening/archive/destructive-command-guards.md +218 -0
- package/docs/goals/whatsapp-bot-hardening/archive/docs-extension.md +182 -0
- package/docs/goals/whatsapp-bot-hardening/archive/handoff-2026-08-09.md +588 -0
- package/docs/goals/whatsapp-bot-hardening/archive/media-size-and-silent-drop.md +178 -0
- package/docs/goals/whatsapp-bot-hardening/archive/member-memory.md +199 -0
- package/docs/goals/whatsapp-bot-hardening/archive/message-author-attribution.md +211 -0
- package/docs/goals/whatsapp-bot-hardening/archive/run-from-source-switchover.md +212 -0
- package/docs/goals/whatsapp-bot-hardening/archive/setup-plan-2026-08-06.md +1117 -0
- package/docs/goals/whatsapp-bot-hardening/decisions.md +200 -0
- package/docs/goals/whatsapp-bot-hardening/goal.md +75 -0
- package/docs/goals/whatsapp-bot-hardening/open-threads.md +396 -0
- package/docs/goals/whatsapp-bot-hardening/roadmap.md +245 -0
- package/docs/memory.md +30 -0
- package/docs/profile-guide.md +483 -0
- package/docs/refactor/.gitkeep +0 -0
- package/docs/refactor/archive/.gitkeep +0 -0
- package/docs/refactor/audits/.gitkeep +0 -0
- package/docs/refactor/backlog/.gitkeep +0 -0
- package/docs/skills-guide.md +154 -0
- package/examples/extensions/README.md +1 -0
- package/examples/extensions/feed-watch/config.ts +354 -0
- package/examples/extensions/feed-watch/digest.ts +344 -0
- package/examples/extensions/feed-watch/feeds.ts +341 -0
- package/examples/extensions/feed-watch/index.ts +302 -0
- package/examples/extensions/feed-watch/items.ts +171 -0
- package/examples/extensions/feed-watch/match.ts +107 -0
- package/examples/extensions/feed-watch/prompts/verify.md +35 -0
- package/examples/extensions/feed-watch/skill/SKILL.md +128 -0
- package/examples/extensions/feed-watch/watch.ts +548 -0
- package/examples/extensions/longview/hook.ts +179 -16
- package/examples/extensions/longview/index.ts +13 -2
- package/examples/extensions/longview/prompts/summarize.md +3 -2
- package/examples/extensions/longview/render/telegraph-nodes.ts +21 -1
- package/examples/extensions/longview/summarize.ts +57 -14
- package/examples/extensions/napkin/index.ts +334 -86
- package/examples/extensions/napkin/pi-spawn.ts +196 -0
- package/examples/extensions/pinchtab/index.ts +37 -4
- package/examples/extensions/pinchtab/lib/session-injector.ts +31 -9
- package/examples/extensions/pinchtab/skill/SKILL.md +29 -0
- package/examples/profiles/_template/AGENTS.md +138 -0
- package/examples/profiles/_template/README.md +65 -0
- package/examples/profiles/_template/config.yaml +57 -0
- package/examples/profiles/_template/tasks/daily.md +32 -0
- package/examples/profiles/football-reporter/AGENTS.md +219 -0
- package/examples/profiles/football-reporter/README.md +138 -0
- package/examples/profiles/football-reporter/config.yaml +174 -0
- package/examples/profiles/football-reporter/seed/MEMORY.md +42 -0
- package/examples/profiles/football-reporter/seed/episodes/barcelona-2026-27.md +25 -0
- package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +25 -0
- package/examples/profiles/football-reporter/seed/episodes/maccabi-haifa-2026-27.md +24 -0
- package/examples/profiles/football-reporter/seed/episodes/man-united-2026-27.md +26 -0
- package/examples/profiles/football-reporter/seed/episodes/real-madrid-2026-27.md +25 -0
- package/examples/profiles/football-reporter/seed/napkin-distill.md +55 -0
- package/examples/profiles/football-reporter/tasks/daily-article.md +142 -0
- package/package.json +8 -5
- package/src/adapters/whatsapp.ts +75 -13
- package/src/bridges/whatsapp.ts +6 -4
- package/src/cli/mrctl.ts +9 -1
- package/src/core/handler.ts +51 -6
- package/src/core/routes/dashboard.ts +106 -5
- package/src/core/routes/tasks.ts +28 -0
- package/src/core/runtime.ts +63 -2
- package/src/core/task-scheduler.ts +103 -12
- package/src/extensions/catalog.ts +9 -0
- package/src/profile/space-profile.ts +780 -0
- package/src/storage/db.ts +215 -0
- 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`)
|