@rubytech/create-maxy 1.0.805 → 1.0.807

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 (27) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/neo4j/migrations/004-project-admin-agent.ts +247 -0
  3. package/payload/platform/neo4j/migrations/004-prune-alien-accounts.ts +134 -0
  4. package/payload/platform/plugins/docs/references/cloudflare.md +1 -1
  5. package/payload/platform/plugins/docs/references/graph.md +42 -0
  6. package/payload/platform/plugins/docs/references/internals.md +11 -1
  7. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  8. package/payload/platform/plugins/whatsapp-import/PLUGIN.md +18 -5
  9. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import-enrich/SKILL.md +314 -0
  10. package/payload/platform/templates/agents/admin/IDENTITY.md +3 -1
  11. package/payload/platform/templates/specialists/agents/database-operator.md +5 -2
  12. package/payload/server/chunk-LSUMH6OF.js +9993 -0
  13. package/payload/server/chunk-LTIWPCUF.js +3477 -0
  14. package/payload/server/chunk-SC3ZSD7N.js +9993 -0
  15. package/payload/server/chunk-YULDSPAC.js +3484 -0
  16. package/payload/server/client-pool-CD7WHZIK.js +31 -0
  17. package/payload/server/client-pool-LXE7RIRT.js +31 -0
  18. package/payload/server/maxy-edge.js +2 -2
  19. package/payload/server/neo4j-migrations-HEECOAGK.js +128 -0
  20. package/payload/server/public/assets/admin-CTM9Vb-j.js +352 -0
  21. package/payload/server/public/assets/{graph-CBu0rtrP.js → graph-CDwy6Qw1.js} +1 -1
  22. package/payload/server/public/assets/page-DEyK-lSN.js +50 -0
  23. package/payload/server/public/graph.html +2 -2
  24. package/payload/server/public/index.html +2 -2
  25. package/payload/server/server.js +348 -202
  26. package/payload/server/public/assets/admin-BYsaXlDv.js +0 -352
  27. package/payload/server/public/assets/page-BNM63zsb.js +0 -50
@@ -0,0 +1,314 @@
1
+ ---
2
+ name: whatsapp-import-enrich
3
+ description: Operator-driven semantic enrichment pass over an already-loaded WhatsApp Conversation. Walks `:Person {participantStatus:'auto-created'}` and `:Observation {observationStatus:'auto-extracted'}` rows scoped to the chosen conversation, surfaces evidence per row, and writes operator-confirmed wiring (participant promotion/merge, `:MENTIONS` / `:RELATED_TO` edges, `:Task` and `:Preference` nodes). Triggers on operator phrases like "enrich the X chat", "promote the auto-created participants from Y", "wire the observations from yesterday's import". Runs against a Conversation already imported by `whatsapp-import` (Task 855); never re-runs parse.
4
+ ---
5
+
6
+ # WhatsApp Import — Enrich
7
+
8
+ Phase 2 of the two-phase WhatsApp ingest contract. Phase 1 (`whatsapp-import`) is the deterministic Bash entry that lands raw shape: Conversation + Messages + chronological NEXT chain + auto-created `:Person` participants + raw `:Observation` nodes. Phase 2 (this skill) is operator-driven semantic resolution: disambiguates participants against the live graph, wires observations to typed entities, and reattributes the operator's own messages from the auto-Person to their `:AdminUser`.
9
+
10
+ ## When this applies
11
+
12
+ The operator triggers this skill against a single, already-loaded `:Conversation:WhatsAppConversation`. Acceptable phrases include any reference to enriching, promoting participants from, or wiring observations against a conversation the operator can name (display name, recent timestamp, conversationId). When the conversation reference is ambiguous, list the recent WhatsApp conversations and require operator selection before any walk begins. Never run against a conversation whose `whatsapp-import` Phase 1 has not completed (`MATCH (c:WhatsAppConversation {conversationId:$cid}) WHERE c.lastImportedAt IS NULL` is a blocker — surface "Phase 1 has not completed for <cid>; run whatsapp-import first" and yield).
13
+
14
+ ## Bulk preview (mandatory, before any walk)
15
+
16
+ Before walking a single row, count the work and offer a yield. Two read-only Cyphers via `mcp__graph__maxy-graph-read_neo4j_cypher`:
17
+
18
+ ```cypher
19
+ MATCH (p:Person {accountId:$acct, source:'whatsapp', participantStatus:'auto-created'})
20
+ -[:PARTICIPANT_IN]->(:Conversation {conversationId:$cid})
21
+ RETURN count(p) AS autoParticipants
22
+ ```
23
+
24
+ ```cypher
25
+ MATCH (o:Observation {accountId:$acct, observationStatus:'auto-extracted', insightPass:true})
26
+ -[:OBSERVED_IN]->(:Conversation {conversationId:$cid})
27
+ RETURN count(o) AS autoObservations,
28
+ sum(CASE o.kind WHEN 'mention' THEN 1 ELSE 0 END) AS mentions,
29
+ sum(CASE o.kind WHEN 'task' THEN 1 ELSE 0 END) AS tasks,
30
+ sum(CASE o.kind WHEN 'preference' THEN 1 ELSE 0 END) AS preferences,
31
+ sum(CASE o.kind WHEN 'observed-relationship' THEN 1 ELSE 0 END) AS relationships
32
+ ```
33
+
34
+ Surface one chat message: `"<N> auto-participants and <M> auto-observations to review (<a> mentions, <b> tasks, <c> preferences, <d> relationships). Proceed?"`. Yield on no. On yes, persist `$preParticipantCount` and per-kind `$preObservationCount` for post-walk verification (Cypher silent-no-op detection — see "Status-update verification").
35
+
36
+ Emit one chat line: `[whatsapp-import-enrich] start conversationId=<cid> auto-participants=<N> auto-observations=<M>`.
37
+
38
+ ## Walk 1 — Auto-created participants
39
+
40
+ For each `:Person {participantStatus:'auto-created'}` `PARTICIPANT_IN` the conversation, surface evidence and ask the operator to choose an action.
41
+
42
+ Per-row evidence Cypher:
43
+
44
+ ```cypher
45
+ MATCH (p:Person)-[:PARTICIPANT_IN]->(c:Conversation {conversationId:$cid})
46
+ WHERE p.participantStatus = 'auto-created' AND p.accountId = $acct AND p.source = 'whatsapp'
47
+ WITH p, c
48
+ OPTIONAL MATCH (p)-[:SENT]->(m:Message {conversationId:$cid})
49
+ WITH p, count(m) AS messageCount,
50
+ min(m.dateSent) AS firstSeenAt, max(m.dateSent) AS lastSeenAt,
51
+ [m IN collect(m)[..3] | substring(m.body, 0, 80)] AS bodySamples
52
+ RETURN elementId(p) AS elemId, p.name AS displayName,
53
+ messageCount, firstSeenAt, lastSeenAt, bodySamples
54
+ ```
55
+
56
+ Operator choices per row:
57
+
58
+ | Action | Effect |
59
+ |--------|--------|
60
+ | **promote-to-existing** | Operator names an existing `:Person` or `:AdminUser` (resolved via `mcp__memory__memory-search` against `displayName`). Skill writes the merge below. |
61
+ | **mint-new-Person** | Operator names a new contact identity. Skill calls `mcp__contacts__contact-create` with `givenName` / `familyName` / at least one of `email` / `telephone`, then merges the auto-Person into the new contact's `:Person` node. |
62
+ | **merge-same-person** | Two auto-Persons that are the same person under different display names (e.g. phone-then-name). Operator names the survivor; skill merges the other into it. |
63
+ | **skip** | Leave `participantStatus='auto-created'`. Re-running the skill surfaces it again. |
64
+
65
+ ### Merge Cypher (load-bearing — read carefully)
66
+
67
+ Every promote-to-existing, mint-new, and merge-same-person uses `apoc.refactor.mergeNodes` with **non-default** property-merge mode:
68
+
69
+ ```cypher
70
+ MATCH (survivor) WHERE elementId(survivor) = $survivorId
71
+ MATCH (duplicate:Person) WHERE elementId(duplicate) = $autoPersonId
72
+ CALL apoc.refactor.mergeNodes([survivor, duplicate], {properties:'discard', mergeRels:true})
73
+ YIELD node
74
+ SET node.participantStatus = 'operator-confirmed',
75
+ node.mergedFromAutoPerson = $autoPersonId,
76
+ node.mergedAt = datetime(),
77
+ node.mergedFromAgent = 'whatsapp-import-enrich',
78
+ node.mergedFromSession = $sessionId
79
+ RETURN elementId(node) AS survivorId, count(node) AS affected
80
+ ```
81
+
82
+ `{properties:'discard'}` keeps the survivor's value when both nodes have the same property — the survivor (first array element) wins on conflict. The duplicate's properties that the survivor does NOT have are still copied onto the survivor; this is desirable here (auto-Person's `firstSeenAt`/`lastSeenAt` enrich the `:AdminUser` if absent). **Do not switch to `'overwrite'`** — that mode hands the conflict to the duplicate, silently replacing `:AdminUser.name` (e.g. "Joel Smalley") with the auto-Person's WhatsApp display name (e.g. "Joel S."), which is identity corruption with no error. `'combine'` would coerce conflicting scalars into arrays (`name=["Joel Smalley","Joel S."]`), which breaks downstream callers that expect string scalars. **`discard` is the only safe mode here.**
83
+
84
+ `mergeRels:true` reparents every `:SENT` / `:PARTICIPANT_IN` / `:MENTIONS` edge from the duplicate onto the survivor in the same transaction.
85
+
86
+ ### Owner reconciliation — first row of Walk 1
87
+
88
+ Phase 1 takes `--owner-element-id` as argv but does not stamp it on the Conversation node — the owner pointer is implicit (the `:SENT` edges from `:AdminUser` → `:Message` are the only structural link). The skill therefore re-asks the operator who owns this conversation, the same way Phase 1's anchor-confirmation flow does. List candidate `:AdminUser` rows:
89
+
90
+ ```cypher
91
+ MATCH (u:AdminUser)
92
+ OPTIONAL MATCH (u)-[:SENT]->(m:Message {conversationId:$cid})
93
+ WITH u, count(m) AS senderMessageCount
94
+ RETURN elementId(u) AS elementId, u.name AS name, u.userId AS userId,
95
+ senderMessageCount
96
+ ORDER BY senderMessageCount DESC, name
97
+ ```
98
+
99
+ Surface: `"Who exported this conversation? Pick from: <:AdminUser rows with senderMessageCount>"`. The `senderMessageCount` is a hint — an :AdminUser already SENT-edged to messages in this conversation is the most likely owner — but the operator confirms verbatim. Echo the chosen owner back (`:AdminUser <name> (<elementId>) — confirm yes/no`) before any write.
100
+
101
+ On confirm, find auto-Persons whose display name might match the owner. Surface ALL candidates — string equality alone is not safe (owner display names drift across re-exports):
102
+
103
+ ```cypher
104
+ MATCH (auto:Person {participantStatus:'auto-created', accountId:$acct, source:'whatsapp'})
105
+ -[:PARTICIPANT_IN]->(:Conversation {conversationId:$cid})
106
+ RETURN elementId(auto) AS elemId, auto.name AS displayName,
107
+ size([(auto)-[:SENT]->(:Message) | 1]) AS sentMessageCount
108
+ ORDER BY sentMessageCount DESC
109
+ ```
110
+
111
+ Operator picks zero or more auto-Persons to merge into the owner. For each picked auto-Person, run the merge Cypher above with `survivorId = ownerElementId` and `autoPersonId = picked-auto-elemId`. `mergeRels:true` reparents SENT and PARTICIPANT_IN onto the `:AdminUser`; the auto-Person is consumed.
112
+
113
+ ### After each row — emit one log line
114
+
115
+ `[whatsapp-import-enrich] participant action=<promoted-existing|minted-new|merged-with-id|reattributed-to-owner|skipped> name=<displayName> elementId=<survivorId-or-autoId>`.
116
+
117
+ ## Walk 2 — Auto-extracted observations
118
+
119
+ For each `:Observation {observationStatus:'auto-extracted', insightPass:true}` `OBSERVED_IN` the conversation, dispatch by `kind`. Field mapping is non-obvious (see ingest.mjs:459-505) — get this exactly right:
120
+
121
+ | kind | entity name | evidence | from | to |
122
+ |------|-------------|----------|------|-----|
123
+ | `mention` | `o.summary` | `o.snippet` (≤80 chars verbatim) | — | — |
124
+ | `task` | task body in `o.summary` | `o.snippet` | — | — |
125
+ | `preference` | `o.summary` (preference statement) | — | — | — |
126
+ | | `o.subject` (whose preference) | | | |
127
+ | `observed-relationship` | `o.summary` (verb) | — | `o.from` | `o.to` |
128
+
129
+ `o.summary` is the load-bearing field for mention disambiguation — `o.subject` is `null` on mentions (verified ingest.mjs:462).
130
+
131
+ ### kind = 'mention'
132
+
133
+ Run `mcp__memory__memory-search` against `o.summary` (the mention text — e.g. "Sarah", "Sarah Chen at Acme"). Three branches:
134
+
135
+ 1. **Single high-confidence match AND `o.summary` contains whitespace OR matches a unique disambiguator (email, phone, role context).** Wire the edge and mark wired.
136
+ 2. **Single match BUT `o.summary` is single-token (e.g. "Sarah") with no disambiguator.** Surface: `"Mention: 'Sarah' — found one :Person <fullName> (<elemId>). Confirm wire to this person?"` (mirrors the Gate 2 pattern in [whatsapp-export-insight-write.ts](../../../memory/mcp/src/tools/whatsapp-export-insight-write.ts) — operator IS the disambiguator). On yes wire; on no mark `observationStatus='rejected'`.
137
+ 3. **Multiple matches OR zero matches.** Surface candidates (or the absence) and let the operator pick or reject.
138
+
139
+ ### Wire Cypher — `:MENTIONS` edge with messageId recovery
140
+
141
+ The load phase does NOT stamp `messageId` on `:Observation` (Task 855's chunked Haiku has no per-message provenance). To respect the `(:Message)-[:MENTIONS]->(:Person)` semantics, recover `messageId` from `snippet`:
142
+
143
+ ```cypher
144
+ MATCH (m:Message {conversationId:$cid})
145
+ WHERE m.body CONTAINS $snippet
146
+ RETURN m.messageId AS messageId, m.dateSent AS sentAt
147
+ ORDER BY m.dateSent ASC
148
+ LIMIT 1
149
+ ```
150
+
151
+ Three outcomes:
152
+
153
+ - **Unique or first-by-chronology match** → write `(:Message)-[:MENTIONS]->(:Person|:AdminUser)`:
154
+
155
+ ```cypher
156
+ MATCH (m:Message {conversationId:$cid, messageId:$messageId})
157
+ MATCH (target) WHERE elementId(target) = $targetElementId AND (target:Person OR target:AdminUser)
158
+ MERGE (m)-[r:MENTIONS]->(target)
159
+ ON CREATE SET r.source='whatsapp', r.evidenceSnippet=$snippet,
160
+ r.createdByAgent='whatsapp-import-enrich', r.createdAt=datetime(),
161
+ r.createdBySession=$sessionId
162
+ WITH r
163
+ MATCH (o:Observation) WHERE elementId(o) = $observationElementId
164
+ SET o.observationStatus = 'wired', o.wiredEdgeKind = 'MENTIONS-from-Message',
165
+ o.wiredAt = datetime(), o.wiredBySession = $sessionId
166
+ RETURN elementId(r) AS edgeId, count(o) AS affected
167
+ ```
168
+
169
+ - **Zero match** (snippet was paraphrased / normalised by Haiku) → fall back to `:Conversation`-anchored mention:
170
+
171
+ ```cypher
172
+ MATCH (c:Conversation {conversationId:$cid})
173
+ MATCH (target) WHERE elementId(target) = $targetElementId AND (target:Person OR target:AdminUser)
174
+ MERGE (c)-[r:MENTIONS]->(target)
175
+ ON CREATE SET r.source='whatsapp', r.evidenceSnippet=$snippet,
176
+ r.createdByAgent='whatsapp-import-enrich', r.createdAt=datetime(),
177
+ r.createdBySession=$sessionId
178
+ WITH r
179
+ MATCH (o:Observation) WHERE elementId(o) = $observationElementId
180
+ SET o.observationStatus = 'wired', o.wiredEdgeKind = 'MENTIONS-from-Conversation-fallback',
181
+ o.wiredAt = datetime(), o.wiredBySession = $sessionId
182
+ RETURN elementId(r) AS edgeId, count(o) AS affected
183
+ ```
184
+
185
+ The `wiredEdgeKind` property is the audit anchor — operator can grep wired observations to see which path the skill took. Surface one chat line per wire: `[whatsapp-import-enrich] observation kind=mention action=wired-mention edge=<edgeKind> elementId=<observationElementId>`.
186
+
187
+ ### kind = 'task'
188
+
189
+ Surface a one-line proposal: `"Task: '<o.summary>' — evidence: '<o.snippet>'. Mint as :Task affecting this conversation?"`. On yes, call `mcp__tasks__task-create` with the task text and `affects=$conversationElementId` (the conversation elementId is the required adjacency — `:Task` requires ≥1 typed edge at creation per project-manager.md:44). Then mark wired:
190
+
191
+ ```cypher
192
+ MATCH (o:Observation) WHERE elementId(o) = $observationElementId
193
+ SET o.observationStatus = 'wired', o.wiredEdgeKind = 'task-created',
194
+ o.wiredTaskElementId = $taskElementId,
195
+ o.wiredAt = datetime(), o.wiredBySession = $sessionId
196
+ RETURN count(o) AS affected
197
+ ```
198
+
199
+ On no, mark `observationStatus='rejected'`. Log line: `action=task-created` or `action=rejected`.
200
+
201
+ ### kind = 'preference'
202
+
203
+ Write a `:Preference` node with `:OBSERVED_IN` edge to the conversation. The `mcp__memory__memory-write` tool is schema-aware and the wrapped writer enforces `≥1 typed edge`. Pass:
204
+
205
+ ```json
206
+ {
207
+ "type": "Preference",
208
+ "properties": {
209
+ "subject": "<o.subject>",
210
+ "preference": "<o.summary>",
211
+ "source": "whatsapp",
212
+ "scope": "<conversation scope>"
213
+ },
214
+ "relationships": [
215
+ { "type": "OBSERVED_IN", "targetElementId": "<conversationElementId>" }
216
+ ]
217
+ }
218
+ ```
219
+
220
+ Then mark the observation wired (`wiredEdgeKind='preference-written', wiredPreferenceElementId=<id>`). Log line: `action=preference-written`.
221
+
222
+ ### kind = 'observed-relationship'
223
+
224
+ Surface: `"Relationship: <o.from> --[<o.summary>]--> <o.to>. Confirm?"`. The endpoints (`o.from`, `o.to`) are participant display names from the chat — they may be auto-Persons (now possibly merged into existing `:Person` / `:AdminUser` after Walk 1). Resolve each endpoint via `mcp__memory__memory-search` against the display name AND scoped to participants of this conversation. Branches:
225
+
226
+ 1. **Both endpoints resolve uniquely.** Operator-confirms; write the edge:
227
+
228
+ ```cypher
229
+ MATCH (a) WHERE elementId(a) = $fromElementId
230
+ MATCH (b) WHERE elementId(b) = $toElementId
231
+ MERGE (a)-[r:RELATED_TO {relationship: $relationship}]->(b)
232
+ ON CREATE SET r.source='whatsapp', r.operatorConfirmed=true,
233
+ r.evidenceMessageIds=$evidenceMessageIds,
234
+ r.createdByAgent='whatsapp-import-enrich', r.createdAt=datetime(),
235
+ r.createdBySession=$sessionId
236
+ WITH r
237
+ MATCH (o:Observation) WHERE elementId(o) = $observationElementId
238
+ SET o.observationStatus = 'wired', o.wiredEdgeKind='RELATED_TO',
239
+ o.wiredAt = datetime(), o.wiredBySession = $sessionId
240
+ RETURN elementId(r) AS edgeId, count(o) AS affected
241
+ ```
242
+
243
+ `evidenceMessageIds` is best-effort — recover via `MATCH (m:Message {conversationId:$cid}) WHERE m.body CONTAINS $relationship OR m.body CONTAINS $fromName OR m.body CONTAINS $toName RETURN collect(m.messageId)[..5]` (cap at 5).
244
+
245
+ 2. **Endpoint does not resolve.** Surface the candidate options or the missing-Person; operator decides whether to mint via `contact-create` first or reject the observation.
246
+
247
+ 3. **Operator answers no.** Mark `observationStatus='rejected'`. Log line: `action=rejected`.
248
+
249
+ `operatorConfirmed=true` is mandatory for every `:RELATED_TO` write — the brief's anti-hallucination doctrine (mirrors [whatsapp-export-insight-write.ts](../../../memory/mcp/src/tools/whatsapp-export-insight-write.ts)). Never write `:RELATED_TO` without explicit operator yes.
250
+
251
+ ## Status-update verification (Cypher silent-no-op trap)
252
+
253
+ `MATCH (n) WHERE elementId(n) = $id SET n.foo = $val` against a missing node returns zero rows and zero mutations, but the query SUCCEEDS — Neo4j Community read-committed isolation does not protect co-transactional writes. Every status-update Cypher in this skill ends `RETURN count(<bound-var>) AS affected`. The skill code path:
254
+
255
+ - Captures pre-walk counts (`$preParticipantCount`, `$preObservationCount` per kind) from the bulk-preview Cyphers.
256
+ - After each transition (`participantStatus='operator-confirmed'`, `observationStatus IN {'wired','rejected'}`) records `affected` from the result.
257
+ - Post-walk re-runs the bulk-preview Cyphers. Asserts the new counts equal `pre - count(operations-that-claimed-affected=1)`. Mismatch is a hard blocker — surface `"Status-update silent-no-op detected: pre=<n> ops=<n> post=<n>; aborting before further writes"` and yield. Never claim done with a count mismatch.
258
+
259
+ ## Idempotency
260
+
261
+ The walk filters on `participantStatus='auto-created'` and `observationStatus='auto-extracted'`. Re-running surfaces only items still in those states. Already-wired observations (`'wired'` / `'rejected'`) and operator-confirmed participants are skipped naturally — no skill-side bookkeeping needed.
262
+
263
+ This means the skill is safe to re-run at any time. Operators can enrich incrementally (one ten-row session, then another) and re-imports of the same archive (which add only the message delta per Phase 1's idempotency contract) leave existing wired state untouched.
264
+
265
+ ## Done — emit one chat line
266
+
267
+ After both walks complete, emit `[whatsapp-import-enrich] done conversationId=<cid> wired=<n> skipped=<n> rejected=<n> ms=<n>` and return a structured summary to the admin agent in the database-operator output contract shape (see [database-operator.md](../../../../templates/specialists/agents/database-operator.md#output-contract)):
268
+
269
+ > WhatsApp enrichment complete for conversation `<displayName>` (`<cid>`):
270
+ > Participants: `<promoted>` promoted to existing, `<minted>` minted new, `<merged>` cross-displayname merged, `<reattributed>` reattributed to operator, `<skipped>` left as auto-created.
271
+ > Observations: `<wiredMentions>` mentions wired (`<msgEdge>` from :Message, `<convoEdge>` from :Conversation fallback), `<wiredTasks>` tasks created, `<wiredPrefs>` preferences written, `<wiredRels>` relationships confirmed, `<rejected>` rejected.
272
+ > Status-update verification: pre=`<preCount>` ops=`<opCount>` post=`<postCount>` (mismatch=`<0|N>`).
273
+
274
+ ## Verification (post-write — for operator audit)
275
+
276
+ Run via `mcp__graph__maxy-graph-read_neo4j_cypher`:
277
+
278
+ - `MATCH (o:Observation {accountId:$acct, observationStatus:'auto-extracted'})-[:OBSERVED_IN]->(c:Conversation {conversationId:$cid}) RETURN count(o)` — should be 0 after a complete enrich.
279
+ - `MATCH (p:Person {participantStatus:'auto-created', accountId:$acct, source:'whatsapp'})-[:PARTICIPANT_IN]->(c:Conversation {conversationId:$cid}) RETURN count(p)` — equals the count of skipped rows from the chat summary.
280
+ - `MATCH ()-[r:MENTIONS {createdByAgent:'whatsapp-import-enrich'}]->() WHERE r.evidenceSnippet IS NOT NULL RETURN count(r)` — equals `wiredMentions` from the chat summary.
281
+ - Re-run the skill against the same conversation immediately. Bulk preview should report `auto-participants=<skippedCount>` and `auto-observations=<rejectedAndUnwiredCount>` — never duplicate edges, never duplicate `:Task`/`:Preference`.
282
+
283
+ ## Observability — log lines
284
+
285
+ Every line emitted to chat is mirrored into the per-conversation agent-stream log (greppable via `ssh neo@<host> "grep -nE '\[whatsapp-import-enrich\]' ~/<install>/data/accounts/<accountId>/logs/<conversationId>-claude-agent-stream.log"`):
286
+
287
+ - `[whatsapp-import-enrich] start conversationId=<id> auto-participants=<n> auto-observations=<n>`
288
+ - `[whatsapp-import-enrich] participant action=<promoted-existing|minted-new|merged-with-id|reattributed-to-owner|skipped> name=<name> elementId=<id>`
289
+ - `[whatsapp-import-enrich] observation kind=<mention|task|preference|observed-relationship> action=<wired-mention|task-created|preference-written|relationship-confirmed|rejected> elementId=<id>` (mention rows append `edge=MENTIONS-from-Message` or `edge=MENTIONS-from-Conversation-fallback`)
290
+ - `[whatsapp-import-enrich] done conversationId=<id> wired=<n> skipped=<n> rejected=<n> ms=<n>`
291
+
292
+ **Confirms correct behaviour:** one `start … done` pair per enrich run; every `:Observation` row transitions out of `auto-extracted`; every `participant` log line cites a real `elementId`; the `done` line's wired/skipped/rejected sum equals the `start` line's `auto-observations` count.
293
+
294
+ **Indicates failure:** post-run grep `'observationStatus="auto-extracted"'` non-zero (silent SET no-op); duplicate `participant action=promoted-existing` for the same elementId across reruns (idempotency violation); `done` line missing from a run that emitted `start` (mid-walk crash, no rollback).
295
+
296
+ ## Tools this skill uses
297
+
298
+ Every prescribed tool resolves on database-operator's frontmatter `tools:` list. The pre-publish gate `platform/scripts/verify-skill-tool-surface.sh` asserts this statically:
299
+
300
+ - `mcp__graph__maxy-graph-read_neo4j_cypher` — bulk preview, evidence reads, messageId recovery, owner-reconciliation lookup.
301
+ - `mcp__graph__maxy-graph-write_neo4j_cypher` — `apoc.refactor.mergeNodes`, `:MENTIONS` and `:RELATED_TO` MERGEs, status-update SETs.
302
+ - `mcp__memory__memory-search` — entity disambiguation for mentions and observed-relationship endpoints.
303
+ - `mcp__memory__memory-write` — `:Preference` node creation with `:OBSERVED_IN` edge.
304
+ - `mcp__contacts__contact-create` — mint-new-Person path.
305
+ - `mcp__tasks__task-create` — `:Task` node creation with `affects=$conversationElementId`.
306
+
307
+ Raw Cypher and `cypher-shell` are forbidden in this skill (per [database-operator's LOUD-FAIL prerogative](../../../../templates/specialists/agents/database-operator.md#prerogatives)). Every write goes through the MCP tool surface above. If a wrapped writer cannot express a needed shape, file a task — never improvise via Bash.
308
+
309
+ ## What this is not
310
+
311
+ - **Not** Phase 1. Parse, archive-write, and the chunked Haiku insight pass live in `whatsapp-import` (the deterministic Bash entry). This skill never re-parses; it only transitions already-loaded state.
312
+ - **Not** automatic. Every transition out of `auto-created` / `auto-extracted` requires an operator action — no auto-promotion, no auto-mention-acceptance, no batch confirmation. Compression-on-write doctrine ([feedback_compress_at_ingest_for_bulk_archives.md](../../../../../.claude/projects/-Users-neo-getmaxy/memory/feedback_compress_at_ingest_for_bulk_archives.md)) requires per-row operator judgement.
313
+ - **Not** cross-conversation. The walk is scoped to one Conversation. Cross-conversation participant deduplication (the same person under two conversations) is operator-driven graph hygiene via [database-operator.md §Dedup merges](../../../../templates/specialists/agents/database-operator.md#dedup-merges), not this skill.
314
+ - **Not** a backfill tool. Pre-Task-855 `:Observation` nodes do not exist; this skill assumes the Phase 1 contract and refuses to walk a conversation without `c.lastImportedAt`.
@@ -108,12 +108,14 @@ Load `plugins/cloudflare/skills/setup-tunnel/SKILL.md` via `plugin-read` on the
108
108
 
109
109
  ## Questions
110
110
 
111
- Operationalises the CONCISE prerogative for clarification. Two rules:
111
+ Operationalises the CONCISE prerogative for clarification. Three rules:
112
112
 
113
113
  1. **One-sided questions only.** Frame every clarifying question so a single-word "yes" or "no" is unambiguous. Never pose two opposing framings joined by "or" — "Should I proceed, or stop?", "Want me to do X, or not?", "Shall I run this, or do you want to?". "Yes" to such a question is unusable — you cannot tell which side was affirmed, and guessing produces the wrong action. Pick one side, ask it plainly: "Proceed?" or "Stop?" — not both.
114
114
 
115
115
  2. **No choice-fork when the signal is deterministic.** When a tool returns a typed failure — an enum failure-mode, an UPPERCASE_ERROR_CODE, or a populated recovery instruction — the tool has already told you which action is correct. Relay that action to the owner and take it. Do not degrade the signal into a menu ("want me to run the recovery, or do something else?"). The menu invites the wrong branch. This extends the Tool Failure Discipline above — that section covers acknowledgement and no-silent-fallback; this rule covers no-menu-when-the-answer-is-given.
116
116
 
117
+ 3. **One question, last sentence.** Every response contains at most one question, and that question is the final sentence. Describe the situation, context, trade-offs, and any alternatives in declarative sentences first; close with the single question that asks for the owner's decision. Never open with the question and append caveats, never scatter mini-questions through the body, never end with two questions. The only exception is when the *intent* of the response is to offer enumerated choices — in that case, present the options as a numbered list, then close with one question ("Which would you like?"). A response that informs but does not require a decision ends without a question at all. *Failure symptoms:* a question in the first paragraph, a question followed by further explanation, two question marks in one response, "Would you like X, or shall I Y?" framings.
118
+
117
119
  ## Tool Routing
118
120
 
119
121
  Plugins provide domain-specific tools that query their own data stores directly. `memory-search` is a general-purpose semantic search across the entire knowledge graph — it finds nodes by vector similarity, which means results are ranked by semantic closeness to the query, not by domain relevance. A query containing the word "email" will surface product documentation *about* email features before it surfaces actual Email nodes whose content is unrelated to the query wording.
@@ -3,7 +3,7 @@ name: database-operator
3
3
  description: "Document and archive ingestion and ad-hoc graph operations — running the universal `document-ingest` skill for any unstructured document (PDF, text, transcript, web page, audio, video) and per-source archive-import skills (LinkedIn Basic Data Export today; CRM-type seed archives as each plugin ships), plus operator-driven graph hygiene (prune orphans, deduplicate entities, add edges, normalise labels). Delegate when the operator uploads any document, drops an archive directory into chat, or asks for any graph operation that is not a routine per-turn write."
4
4
  summary: "Ingests every unstructured document and external archive into your graph (LinkedIn today; other CRM sources in future) and handles ad-hoc graph tidy-ups on request. For example, when you upload a CV, a pricing guide, or a contract; when you drop a LinkedIn export folder into chat; or when you ask to prune orphan nodes, merge duplicate people, or add edges between entities."
5
5
  model: claude-sonnet-4-6
6
- tools: Read, Bash, Glob, Grep, mcp__graph__maxy-graph-read_neo4j_cypher, mcp__graph__maxy-graph-write_neo4j_cypher, mcp__graph__maxy-graph-get_neo4j_schema, mcp__memory__memory-write, mcp__memory__memory-update, mcp__memory__memory-delete, mcp__memory__memory-search, mcp__memory__memory-rank, mcp__memory__memory-reindex, mcp__memory__memory-find-candidates, mcp__memory__memory-ingest, mcp__memory__memory-ingest-extract, mcp__memory__memory-ingest-web, mcp__memory__memory-classify, mcp__memory__memory-archive-write, mcp__memory__graph-prune-denylist-list, mcp__memory__graph-prune-denylist-add, mcp__memory__graph-prune-denylist-remove, mcp__contacts__contact-create, mcp__contacts__contact-update, mcp__contacts__contact-lookup, mcp__contacts__contact-list, mcp__admin__file-attach, mcp__admin__plugin-read
6
+ tools: Read, Bash, Glob, Grep, mcp__graph__maxy-graph-read_neo4j_cypher, mcp__graph__maxy-graph-write_neo4j_cypher, mcp__graph__maxy-graph-get_neo4j_schema, mcp__memory__memory-write, mcp__memory__memory-update, mcp__memory__memory-delete, mcp__memory__memory-search, mcp__memory__memory-rank, mcp__memory__memory-reindex, mcp__memory__memory-find-candidates, mcp__memory__memory-ingest, mcp__memory__memory-ingest-extract, mcp__memory__memory-ingest-web, mcp__memory__memory-classify, mcp__memory__memory-archive-write, mcp__memory__graph-prune-denylist-list, mcp__memory__graph-prune-denylist-add, mcp__memory__graph-prune-denylist-remove, mcp__contacts__contact-create, mcp__contacts__contact-update, mcp__contacts__contact-lookup, mcp__contacts__contact-list, mcp__tasks__task-create, mcp__admin__file-attach, mcp__admin__plugin-read
7
7
  ---
8
8
 
9
9
  # Database Operator
@@ -119,7 +119,10 @@ The classifier maps document sections to typed ontology labels. It does not inve
119
119
  Per-source archive imports keep their own skill because their CSVs already encode entity types deterministically and need no LLM classifier. Currently shipped:
120
120
 
121
121
  - **linkedin-import** — LinkedIn Basic Data Export. Ships with references for `Profile.csv` and `Connections.csv`; additional CSVs land as new references inside the same plugin over time. Path: `platform/plugins/linkedin-import/skills/linkedin-import/SKILL.md`. Load via `plugin-read` before any ingestion.
122
- - **whatsapp-import** — WhatsApp `_chat.txt` export ingestion. Imports historical Conversation + Messages with chronological NEXT chain plus typed insights (mentions, tasks, preferences, observed relationships) via the single deterministic Bash entry at `platform/plugins/whatsapp-import/bin/whatsapp-ingest.sh` parse, archive-write, and Haiku insight all run in-process; no MCP envelope between steps (Task 855). The legacy `mcp__memory__whatsapp-export-parse` / `whatsapp-export-insight-write` / `memory-archive-write{archiveType:whatsapp-export}` MCP tools are blocked at the harness; the Bash script is the only supported path. Distinct from the live `whatsapp` plugin (Baileys QR pairing, in-memory store). SKILL: `platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md`. Load via `plugin-read` before any ingestion.
122
+ - **whatsapp-import** — WhatsApp `_chat.txt` export ingestion. **Two-phase contract** (Task 855 + Task 859):
123
+ - **Phase 1 — load** (`whatsapp-import` skill). The single deterministic Bash entry at `platform/plugins/whatsapp-import/bin/whatsapp-ingest.sh` parses the archive, writes Conversation + Messages with chronological NEXT chain, auto-creates one `:Person {participantStatus:'auto-created'}` per distinct senderName, and runs the chunked Haiku insight pass that lands `:Observation {observationStatus:'auto-extracted'}` rows connected `:OBSERVED_IN`→Conversation. Parse, archive-write, and insight all run in-process; no MCP envelope between steps. The legacy `mcp__memory__whatsapp-export-parse` / `whatsapp-export-insight-write` / `memory-archive-write{archiveType:whatsapp-export}` MCP tools are blocked at the harness; the Bash script is the only supported invocation. SKILL: `platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md`.
124
+ - **Phase 2 — enrich** (`whatsapp-import-enrich` skill). Operator-driven semantic resolution against an already-loaded Conversation: walks the `auto-created` participants and `auto-extracted` observations, surfaces evidence per row, and writes operator-confirmed wiring (`apoc.refactor.mergeNodes` for participant promotion/merge, `:MENTIONS` and `:RELATED_TO` edges with `evidenceSnippet`/`evidenceMessageIds`, `:Task` via `mcp__tasks__task-create`, `:Preference` via `memory-write`). Idempotent — re-running surfaces only items still in `auto-created`/`auto-extracted` state. SKILL: `platform/plugins/whatsapp-import/skills/whatsapp-import-enrich/SKILL.md`.
125
+ - Distinct from the live `whatsapp` plugin (Baileys QR pairing, in-memory store). Load both SKILLs via `plugin-read` before invocation; the trigger phrase decides which phase the operator is asking for ("import this chat" → Phase 1; "enrich the X chat" / "promote auto-created participants from Y" / "wire observations from yesterday's import" → Phase 2). Phase 2 refuses to run against a Conversation whose `c.lastImportedAt` is null (Phase 1 never completed).
123
126
 
124
127
  Future CRM-type seed plugins (HubSpot, Salesforce, Pipedrive, iCloud contacts, Gmail CSV, etc.) will ship under the same pattern — each as its own opt-in plugin, each with its own `SKILL.md` path under `platform/plugins/<name>/skills/`. When the admin adds a new archive-import skill, its PLUGIN.md will name itself here and in the admin's `<plugin-manifest>`. No prompt change required.
125
128