@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.
- package/package.json +1 -1
- package/payload/platform/neo4j/migrations/004-project-admin-agent.ts +247 -0
- package/payload/platform/neo4j/migrations/004-prune-alien-accounts.ts +134 -0
- package/payload/platform/plugins/docs/references/cloudflare.md +1 -1
- package/payload/platform/plugins/docs/references/graph.md +42 -0
- package/payload/platform/plugins/docs/references/internals.md +11 -1
- package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
- package/payload/platform/plugins/whatsapp-import/PLUGIN.md +18 -5
- package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import-enrich/SKILL.md +314 -0
- package/payload/platform/templates/agents/admin/IDENTITY.md +3 -1
- package/payload/platform/templates/specialists/agents/database-operator.md +5 -2
- package/payload/server/chunk-LSUMH6OF.js +9993 -0
- package/payload/server/chunk-LTIWPCUF.js +3477 -0
- package/payload/server/chunk-SC3ZSD7N.js +9993 -0
- package/payload/server/chunk-YULDSPAC.js +3484 -0
- package/payload/server/client-pool-CD7WHZIK.js +31 -0
- package/payload/server/client-pool-LXE7RIRT.js +31 -0
- package/payload/server/maxy-edge.js +2 -2
- package/payload/server/neo4j-migrations-HEECOAGK.js +128 -0
- package/payload/server/public/assets/admin-CTM9Vb-j.js +352 -0
- package/payload/server/public/assets/{graph-CBu0rtrP.js → graph-CDwy6Qw1.js} +1 -1
- package/payload/server/public/assets/page-DEyK-lSN.js +50 -0
- package/payload/server/public/graph.html +2 -2
- package/payload/server/public/index.html +2 -2
- package/payload/server/server.js +348 -202
- package/payload/server/public/assets/admin-BYsaXlDv.js +0 -352
- 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.
|
|
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.
|
|
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
|
|