@rubytech/create-realagent 1.0.826 → 1.0.828

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 (71) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/neo4j/schema.cypher +34 -2
  3. package/payload/platform/plugins/admin/hooks/archive-ingest-surface-gate.sh +19 -13
  4. package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +5 -5
  5. package/payload/platform/plugins/docs/references/cloudflare.md +1 -1
  6. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  7. package/payload/platform/plugins/docs/references/troubleshooting.md +1 -0
  8. package/payload/platform/plugins/memory/PLUGIN.md +1 -1
  9. package/payload/platform/plugins/memory/mcp/dist/index.js +6 -41
  10. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  11. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/llm-classifier.test.js +51 -0
  12. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/llm-classifier.test.js.map +1 -1
  13. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.d.ts +19 -4
  14. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.d.ts.map +1 -1
  15. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.js +139 -56
  16. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.js.map +1 -1
  17. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest.test.d.ts +2 -0
  18. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest.test.d.ts.map +1 -0
  19. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest.test.js +61 -0
  20. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest.test.js.map +1 -0
  21. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts +34 -0
  22. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
  23. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +241 -0
  24. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
  25. package/payload/platform/plugins/memory/references/schema-base.md +5 -2
  26. package/payload/platform/plugins/whatsapp-import/PLUGIN.md +17 -15
  27. package/payload/platform/plugins/whatsapp-import/bin/ingest.mjs +313 -366
  28. package/payload/platform/plugins/whatsapp-import/bin/whatsapp-ingest.sh +27 -60
  29. package/payload/platform/plugins/whatsapp-import/lib/dist/delta-cursor.d.ts +18 -0
  30. package/payload/platform/plugins/whatsapp-import/lib/dist/delta-cursor.d.ts.map +1 -0
  31. package/payload/platform/plugins/whatsapp-import/lib/dist/delta-cursor.js +31 -0
  32. package/payload/platform/plugins/whatsapp-import/lib/dist/delta-cursor.js.map +1 -0
  33. package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.d.ts +27 -12
  34. package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.d.ts.map +1 -1
  35. package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.js +40 -20
  36. package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.js.map +1 -1
  37. package/payload/platform/plugins/whatsapp-import/lib/dist/index.d.ts +7 -4
  38. package/payload/platform/plugins/whatsapp-import/lib/dist/index.d.ts.map +1 -1
  39. package/payload/platform/plugins/whatsapp-import/lib/dist/index.js +9 -6
  40. package/payload/platform/plugins/whatsapp-import/lib/dist/index.js.map +1 -1
  41. package/payload/platform/plugins/whatsapp-import/lib/dist/sessionize.d.ts +25 -0
  42. package/payload/platform/plugins/whatsapp-import/lib/dist/sessionize.d.ts.map +1 -0
  43. package/payload/platform/plugins/whatsapp-import/lib/dist/sessionize.js +48 -0
  44. package/payload/platform/plugins/whatsapp-import/lib/dist/sessionize.js.map +1 -0
  45. package/payload/platform/plugins/whatsapp-import/lib/dist/to-classifier-input.d.ts +3 -0
  46. package/payload/platform/plugins/whatsapp-import/lib/dist/to-classifier-input.d.ts.map +1 -0
  47. package/payload/platform/plugins/whatsapp-import/lib/dist/to-classifier-input.js +47 -0
  48. package/payload/platform/plugins/whatsapp-import/lib/dist/to-classifier-input.js.map +1 -0
  49. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/delta-append.test.ts +163 -0
  50. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/sessionize.test.ts +91 -0
  51. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/to-classifier-input.test.ts +59 -0
  52. package/payload/platform/plugins/whatsapp-import/lib/src/delta-cursor.ts +54 -0
  53. package/payload/platform/plugins/whatsapp-import/lib/src/derive-keys.ts +55 -32
  54. package/payload/platform/plugins/whatsapp-import/lib/src/index.ts +9 -6
  55. package/payload/platform/plugins/whatsapp-import/lib/src/sessionize.ts +81 -0
  56. package/payload/platform/plugins/whatsapp-import/lib/src/to-classifier-input.ts +48 -0
  57. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md +66 -73
  58. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/conversation-archive-shape.md +143 -0
  59. package/payload/platform/templates/specialists/agents/database-operator.md +10 -11
  60. package/payload/server/chunk-T2OPNP3L.js +654 -0
  61. package/payload/server/cloudflare-task-tracker-CR6TL4VL.js +19 -0
  62. package/payload/server/public/assets/{admin-DOkUspG1.js → admin-BNwPsMhJ.js} +2 -2
  63. package/payload/server/public/assets/{graph-LLMJa4Ch.js → graph-N_Bw-8oT.js} +1 -1
  64. package/payload/server/public/assets/{page-DoaF3DB0.js → page-BKLGP-th.js} +1 -1
  65. package/payload/server/public/graph.html +2 -2
  66. package/payload/server/public/index.html +2 -2
  67. package/payload/server/server.js +277 -164
  68. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/filter-gate.test.ts +0 -172
  69. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/ingest-idempotence.test.ts +0 -141
  70. package/payload/platform/plugins/whatsapp-import/lib/src/filter.ts +0 -136
  71. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import-enrich/SKILL.md +0 -333
@@ -1,333 +0,0 @@
1
- ---
2
- name: whatsapp-import-enrich
3
- description: Operator-driven semantic enrichment pass over an already-loaded WhatsApp Conversation. Owns the LLM half of the WhatsApp ingest pipeline — first runs `mcp__memory__whatsapp-export-insight-pass` (chunkSize=50, overlap=5, server-side confidence>=0.8 gate) to lay down `:Observation {observationStatus:'auto-extracted'}` rows, then walks `:Person {participantStatus:'auto-created'}` and the auto-extracted observations, 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` Phase 1; 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, LLM-FREE Bash entry that lands raw shape: Conversation + Messages + chronological NEXT chain + auto-created `:Person` participants. Phase 2 (this skill) owns the LLM half: it runs the chunked Haiku insight pass on demand to lay down `:Observation` nodes, then operator-driven semantic resolution disambiguates participants, wires observations to typed entities, and reattributes the operator's own messages from the auto-Person to their `:AdminUser`.
9
-
10
- The split exists because the inline insight pass on Phase 1 (1500 msgs/chunk, no operator gate) polluted the parent's tool_result with `:Observation` enumeration prose and blew operator context. Phase 1 is now mute on insights; this skill triggers them consciously with `mcp__memory__whatsapp-export-insight-pass` when the operator asks.
11
-
12
- ## When this applies
13
-
14
- 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).
15
-
16
- ## Step 0 — run the chunked Haiku insight pass (Phase 2a)
17
-
18
- Phase 1 writes ZERO `:Observation` rows. Before any walk, lay them down via `mcp__memory__whatsapp-export-insight-pass`:
19
-
20
- ```json
21
- { "conversationId": "whatsapp-export:<sha>:<accountId>" }
22
- ```
23
-
24
- The tool walks the Messages of the conversation in chronological order, chunks them at **chunkSize=50** with **overlap=5** (the prior 1500 msgs/chunk implementation lost per-message attention), runs Haiku per chunk, applies a server-side `confidence>=0.8` gate, and MERGE-keys `:Observation` rows. Returns `{conversationId, chunks, chunkSize, overlap, confidenceThreshold, totals:{mentions, tasks, preferences, observedRelationships, rejectedLowConfidence, written}, ms}`.
25
-
26
- Surface to the operator as one chat message — counters only, no enumeration:
27
-
28
- > Insight pass complete on `<conversationId>`: `<chunks>` chunks at chunkSize=50 / overlap=5 / confidenceThreshold=0.8. Wrote `<written>` observations (`<mentions>` mentions, `<tasks>` tasks, `<preferences>` preferences, `<observedRelationships>` relationships); rejected `<rejectedLowConfidence>` low-confidence items.
29
-
30
- Idempotent — re-running collapses identical `(conversationId, sourceMessageRef, kind, contentHash)` tuples into one row. Re-runs are safe; the operator can tune the conversation by re-importing extra rows in Phase 1, then re-running the pass here.
31
-
32
- ## Bulk preview (mandatory, before any walk)
33
-
34
- Before walking a single row, count the work and offer a yield. Two read-only Cyphers via `mcp__graph__maxy-graph-read_neo4j_cypher`:
35
-
36
- ```cypher
37
- MATCH (p:Person {accountId:$acct, source:'whatsapp', participantStatus:'auto-created'})
38
- -[:PARTICIPANT_IN]->(:Conversation {conversationId:$cid})
39
- RETURN count(p) AS autoParticipants
40
- ```
41
-
42
- ```cypher
43
- MATCH (o:Observation {accountId:$acct, observationStatus:'auto-extracted', insightPass:true})
44
- -[:OBSERVED_IN]->(:Conversation {conversationId:$cid})
45
- RETURN count(o) AS autoObservations,
46
- sum(CASE o.kind WHEN 'mention' THEN 1 ELSE 0 END) AS mentions,
47
- sum(CASE o.kind WHEN 'task' THEN 1 ELSE 0 END) AS tasks,
48
- sum(CASE o.kind WHEN 'preference' THEN 1 ELSE 0 END) AS preferences,
49
- sum(CASE o.kind WHEN 'observed-relationship' THEN 1 ELSE 0 END) AS relationships
50
- ```
51
-
52
- 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").
53
-
54
- Emit one chat line: `[whatsapp-import-enrich] start conversationId=<cid> auto-participants=<N> auto-observations=<M>`.
55
-
56
- ## Walk 1 — Auto-created participants
57
-
58
- For each `:Person {participantStatus:'auto-created'}` `PARTICIPANT_IN` the conversation, surface evidence and ask the operator to choose an action.
59
-
60
- Per-row evidence Cypher:
61
-
62
- ```cypher
63
- MATCH (p:Person)-[:PARTICIPANT_IN]->(c:Conversation {conversationId:$cid})
64
- WHERE p.participantStatus = 'auto-created' AND p.accountId = $acct AND p.source = 'whatsapp'
65
- WITH p, c
66
- OPTIONAL MATCH (p)-[:SENT]->(m:Message {conversationId:$cid})
67
- WITH p, count(m) AS messageCount,
68
- min(m.dateSent) AS firstSeenAt, max(m.dateSent) AS lastSeenAt,
69
- [m IN collect(m)[..3] | substring(m.body, 0, 80)] AS bodySamples
70
- RETURN elementId(p) AS elemId, p.name AS displayName,
71
- messageCount, firstSeenAt, lastSeenAt, bodySamples
72
- ```
73
-
74
- Operator choices per row:
75
-
76
- | Action | Effect |
77
- |--------|--------|
78
- | **promote-to-existing** | Operator names an existing `:Person` or `:AdminUser` (resolved via `mcp__memory__memory-search` against `displayName`). Skill writes the merge below. |
79
- | **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. |
80
- | **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. |
81
- | **skip** | Leave `participantStatus='auto-created'`. Re-running the skill surfaces it again. |
82
-
83
- ### Merge Cypher (load-bearing — read carefully)
84
-
85
- Every promote-to-existing, mint-new, and merge-same-person uses `apoc.refactor.mergeNodes` with **non-default** property-merge mode:
86
-
87
- ```cypher
88
- MATCH (survivor) WHERE elementId(survivor) = $survivorId
89
- MATCH (duplicate:Person) WHERE elementId(duplicate) = $autoPersonId
90
- CALL apoc.refactor.mergeNodes([survivor, duplicate], {properties:'discard', mergeRels:true})
91
- YIELD node
92
- SET node.participantStatus = 'operator-confirmed',
93
- node.mergedFromAutoPerson = $autoPersonId,
94
- node.mergedAt = datetime(),
95
- node.mergedFromAgent = 'whatsapp-import-enrich',
96
- node.mergedFromSession = $sessionId
97
- RETURN elementId(node) AS survivorId, count(node) AS affected
98
- ```
99
-
100
- `{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.**
101
-
102
- `mergeRels:true` reparents every `:SENT` / `:PARTICIPANT_IN` / `:MENTIONS` edge from the duplicate onto the survivor in the same transaction.
103
-
104
- ### Owner reconciliation — first row of Walk 1
105
-
106
- 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:
107
-
108
- ```cypher
109
- MATCH (u:AdminUser)
110
- OPTIONAL MATCH (u)-[:SENT]->(m:Message {conversationId:$cid})
111
- WITH u, count(m) AS senderMessageCount
112
- RETURN elementId(u) AS elementId, u.name AS name, u.userId AS userId,
113
- senderMessageCount
114
- ORDER BY senderMessageCount DESC, name
115
- ```
116
-
117
- 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.
118
-
119
- 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):
120
-
121
- ```cypher
122
- MATCH (auto:Person {participantStatus:'auto-created', accountId:$acct, source:'whatsapp'})
123
- -[:PARTICIPANT_IN]->(:Conversation {conversationId:$cid})
124
- RETURN elementId(auto) AS elemId, auto.name AS displayName,
125
- size([(auto)-[:SENT]->(:Message) | 1]) AS sentMessageCount
126
- ORDER BY sentMessageCount DESC
127
- ```
128
-
129
- 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.
130
-
131
- ### After each row — emit one log line
132
-
133
- `[whatsapp-import-enrich] participant action=<promoted-existing|minted-new|merged-with-id|reattributed-to-owner|skipped> name=<displayName> elementId=<survivorId-or-autoId>`.
134
-
135
- ## Walk 2 — Auto-extracted observations
136
-
137
- 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:
138
-
139
- | kind | entity name | evidence | from | to |
140
- |------|-------------|----------|------|-----|
141
- | `mention` | `o.summary` | `o.snippet` (≤80 chars verbatim) | — | — |
142
- | `task` | task body in `o.summary` | `o.snippet` | — | — |
143
- | `preference` | `o.summary` (preference statement) | — | — | — |
144
- | | `o.subject` (whose preference) | | | |
145
- | `observed-relationship` | `o.summary` (verb) | — | `o.from` | `o.to` |
146
-
147
- `o.summary` is the load-bearing field for mention disambiguation — `o.subject` is `null` on mentions (verified ingest.mjs:462).
148
-
149
- ### kind = 'mention'
150
-
151
- Run `mcp__memory__memory-search` against `o.summary` (the mention text — e.g. "Sarah", "Sarah Chen at Acme"). Three branches:
152
-
153
- 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.
154
- 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'`.
155
- 3. **Multiple matches OR zero matches.** Surface candidates (or the absence) and let the operator pick or reject.
156
-
157
- ### Wire Cypher — `:MENTIONS` edge with messageId recovery
158
-
159
- The load phase does NOT stamp `messageId` on `:Observation` (the chunked Haiku has no per-message provenance). To respect the `(:Message)-[:MENTIONS]->(:Person)` semantics, recover `messageId` from `snippet`:
160
-
161
- ```cypher
162
- MATCH (m:Message {conversationId:$cid})
163
- WHERE m.body CONTAINS $snippet
164
- RETURN m.messageId AS messageId, m.dateSent AS sentAt
165
- ORDER BY m.dateSent ASC
166
- LIMIT 1
167
- ```
168
-
169
- Three outcomes:
170
-
171
- - **Unique or first-by-chronology match** → write `(:Message)-[:MENTIONS]->(:Person|:AdminUser)`:
172
-
173
- ```cypher
174
- MATCH (m:Message {conversationId:$cid, messageId:$messageId})
175
- MATCH (target) WHERE elementId(target) = $targetElementId AND (target:Person OR target:AdminUser)
176
- MERGE (m)-[r:MENTIONS]->(target)
177
- ON CREATE SET r.source='whatsapp', r.evidenceSnippet=$snippet,
178
- r.createdByAgent='whatsapp-import-enrich', r.createdAt=datetime(),
179
- r.createdBySession=$sessionId
180
- WITH r
181
- MATCH (o:Observation) WHERE elementId(o) = $observationElementId
182
- SET o.observationStatus = 'wired', o.wiredEdgeKind = 'MENTIONS-from-Message',
183
- o.wiredAt = datetime(), o.wiredBySession = $sessionId
184
- RETURN elementId(r) AS edgeId, count(o) AS affected
185
- ```
186
-
187
- - **Zero match** (snippet was paraphrased / normalised by Haiku) → fall back to `:Conversation`-anchored mention:
188
-
189
- ```cypher
190
- MATCH (c:Conversation {conversationId:$cid})
191
- MATCH (target) WHERE elementId(target) = $targetElementId AND (target:Person OR target:AdminUser)
192
- MERGE (c)-[r:MENTIONS]->(target)
193
- ON CREATE SET r.source='whatsapp', r.evidenceSnippet=$snippet,
194
- r.createdByAgent='whatsapp-import-enrich', r.createdAt=datetime(),
195
- r.createdBySession=$sessionId
196
- WITH r
197
- MATCH (o:Observation) WHERE elementId(o) = $observationElementId
198
- SET o.observationStatus = 'wired', o.wiredEdgeKind = 'MENTIONS-from-Conversation-fallback',
199
- o.wiredAt = datetime(), o.wiredBySession = $sessionId
200
- RETURN elementId(r) AS edgeId, count(o) AS affected
201
- ```
202
-
203
- 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>`.
204
-
205
- ### kind = 'task'
206
-
207
- 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:
208
-
209
- ```cypher
210
- MATCH (o:Observation) WHERE elementId(o) = $observationElementId
211
- SET o.observationStatus = 'wired', o.wiredEdgeKind = 'task-created',
212
- o.wiredTaskElementId = $taskElementId,
213
- o.wiredAt = datetime(), o.wiredBySession = $sessionId
214
- RETURN count(o) AS affected
215
- ```
216
-
217
- On no, mark `observationStatus='rejected'`. Log line: `action=task-created` or `action=rejected`.
218
-
219
- ### kind = 'preference'
220
-
221
- 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:
222
-
223
- ```json
224
- {
225
- "type": "Preference",
226
- "properties": {
227
- "subject": "<o.subject>",
228
- "preference": "<o.summary>",
229
- "source": "whatsapp",
230
- "scope": "<conversation scope>"
231
- },
232
- "relationships": [
233
- { "type": "OBSERVED_IN", "targetElementId": "<conversationElementId>" }
234
- ]
235
- }
236
- ```
237
-
238
- Then mark the observation wired (`wiredEdgeKind='preference-written', wiredPreferenceElementId=<id>`). Log line: `action=preference-written`.
239
-
240
- ### kind = 'observed-relationship'
241
-
242
- 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:
243
-
244
- 1. **Both endpoints resolve uniquely.** Operator-confirms; write the edge:
245
-
246
- ```cypher
247
- MATCH (a) WHERE elementId(a) = $fromElementId
248
- MATCH (b) WHERE elementId(b) = $toElementId
249
- MERGE (a)-[r:RELATED_TO {relationship: $relationship}]->(b)
250
- ON CREATE SET r.source='whatsapp', r.operatorConfirmed=true,
251
- r.evidenceMessageIds=$evidenceMessageIds,
252
- r.createdByAgent='whatsapp-import-enrich', r.createdAt=datetime(),
253
- r.createdBySession=$sessionId
254
- WITH r
255
- MATCH (o:Observation) WHERE elementId(o) = $observationElementId
256
- SET o.observationStatus = 'wired', o.wiredEdgeKind='RELATED_TO',
257
- o.wiredAt = datetime(), o.wiredBySession = $sessionId
258
- RETURN elementId(r) AS edgeId, count(o) AS affected
259
- ```
260
-
261
- `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).
262
-
263
- 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.
264
-
265
- 3. **Operator answers no.** Mark `observationStatus='rejected'`. Log line: `action=rejected`.
266
-
267
- `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.
268
-
269
- ## Status-update verification (Cypher silent-no-op trap)
270
-
271
- `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:
272
-
273
- - Captures pre-walk counts (`$preParticipantCount`, `$preObservationCount` per kind) from the bulk-preview Cyphers.
274
- - After each transition (`participantStatus='operator-confirmed'`, `observationStatus IN {'wired','rejected'}`) records `affected` from the result.
275
- - 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.
276
-
277
- ## Idempotency
278
-
279
- 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.
280
-
281
- 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.
282
-
283
- ## Done — emit one chat line
284
-
285
- 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)):
286
-
287
- > WhatsApp enrichment complete for conversation `<displayName>` (`<cid>`):
288
- > Participants: `<promoted>` promoted to existing, `<minted>` minted new, `<merged>` cross-displayname merged, `<reattributed>` reattributed to operator, `<skipped>` left as auto-created.
289
- > Observations: `<wiredMentions>` mentions wired (`<msgEdge>` from :Message, `<convoEdge>` from :Conversation fallback), `<wiredTasks>` tasks created, `<wiredPrefs>` preferences written, `<wiredRels>` relationships confirmed, `<rejected>` rejected.
290
- > Status-update verification: pre=`<preCount>` ops=`<opCount>` post=`<postCount>` (mismatch=`<0|N>`).
291
-
292
- ## Verification (post-write — for operator audit)
293
-
294
- Run via `mcp__graph__maxy-graph-read_neo4j_cypher`:
295
-
296
- - `MATCH (o:Observation {accountId:$acct, observationStatus:'auto-extracted'})-[:OBSERVED_IN]->(c:Conversation {conversationId:$cid}) RETURN count(o)` — should be 0 after a complete enrich.
297
- - `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.
298
- - `MATCH ()-[r:MENTIONS {createdByAgent:'whatsapp-import-enrich'}]->() WHERE r.evidenceSnippet IS NOT NULL RETURN count(r)` — equals `wiredMentions` from the chat summary.
299
- - 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`.
300
-
301
- ## Observability — log lines
302
-
303
- 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"`):
304
-
305
- - `[whatsapp-import-enrich] start conversationId=<id> auto-participants=<n> auto-observations=<n>`
306
- - `[whatsapp-import-enrich] participant action=<promoted-existing|minted-new|merged-with-id|reattributed-to-owner|skipped> name=<name> elementId=<id>`
307
- - `[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`)
308
- - `[whatsapp-import-enrich] done conversationId=<id> wired=<n> skipped=<n> rejected=<n> ms=<n>`
309
-
310
- **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.
311
-
312
- **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).
313
-
314
- ## Tools this skill uses
315
-
316
- 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:
317
-
318
- - `mcp__memory__whatsapp-export-insight-pass` — Phase 2a chunked-Haiku insight extraction (chunkSize=50, overlap=5, confidence>=0.8). Lays down `:Observation` rows the rest of this skill walks. Owns the LLM half of WhatsApp ingest — Phase 1 has none.
319
- - `mcp__graph__maxy-graph-read_neo4j_cypher` — bulk preview, evidence reads, messageId recovery, owner-reconciliation lookup.
320
- - `mcp__graph__maxy-graph-write_neo4j_cypher` — `apoc.refactor.mergeNodes`, `:MENTIONS` and `:RELATED_TO` MERGEs, status-update SETs.
321
- - `mcp__memory__memory-search` — entity disambiguation for mentions and observed-relationship endpoints.
322
- - `mcp__memory__memory-write` — `:Preference` node creation with `:OBSERVED_IN` edge.
323
- - `mcp__contacts__contact-create` — mint-new-Person path.
324
- - `mcp__tasks__task-create` — `:Task` node creation with `affects=$conversationElementId`.
325
-
326
- 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.
327
-
328
- ## What this is not
329
-
330
- - **Not** Phase 1. Parse and archive-write live in `whatsapp-import` (the deterministic Bash entry, LLM-FREE). This skill never re-parses. The Haiku insight pass lives here — Step 0 above is the one sanctioned LLM entry for WhatsApp ingest, and it is invoked consciously by the operator, not silently on archive-write.
331
- - **Not** automatic. Every transition out of `auto-created` / `auto-extracted` requires an operator action — no auto-promotion, no auto-mention-acceptance, no batch confirmation. Compress-at-ingest doctrine requires per-row operator judgement.
332
- - **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.
333
- - **Not** a backfill tool. This skill assumes the Phase 1 contract and refuses to walk a conversation without `c.lastImportedAt`.