@rubytech/create-realagent 1.0.759 → 1.0.761

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 (46) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/package.json +2 -2
  3. package/payload/platform/plugins/docs/references/platform.md +1 -1
  4. package/payload/platform/plugins/memory/PLUGIN.md +2 -0
  5. package/payload/platform/plugins/memory/mcp/dist/index.js +133 -1
  6. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  7. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-archive-write.test.js +42 -2
  8. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-archive-write.test.js.map +1 -1
  9. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts +15 -0
  10. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts.map +1 -1
  11. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js +34 -1
  12. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js.map +1 -1
  13. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-write.d.ts +27 -0
  14. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-write.d.ts.map +1 -0
  15. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-write.js +160 -0
  16. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-write.js.map +1 -0
  17. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.d.ts +9 -0
  18. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.d.ts.map +1 -0
  19. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.js +29 -0
  20. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.js.map +1 -0
  21. package/payload/platform/plugins/whatsapp-import/lib/dist/index.d.ts +3 -0
  22. package/payload/platform/plugins/whatsapp-import/lib/dist/index.d.ts.map +1 -0
  23. package/payload/platform/plugins/whatsapp-import/lib/dist/index.js +6 -0
  24. package/payload/platform/plugins/whatsapp-import/lib/dist/index.js.map +1 -0
  25. package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.d.ts +33 -0
  26. package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.d.ts.map +1 -0
  27. package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.js +253 -0
  28. package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.js.map +1 -0
  29. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/parse-export.test.ts +503 -0
  30. package/payload/platform/plugins/whatsapp-import/lib/src/index.ts +7 -0
  31. package/payload/platform/plugins/whatsapp-import/lib/src/parse-export.ts +385 -0
  32. package/payload/platform/plugins/whatsapp-import/lib/tsconfig.json +9 -0
  33. package/payload/platform/plugins/whatsapp-import/lib/vitest.config.ts +9 -0
  34. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md +9 -11
  35. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/export-parse.md +10 -5
  36. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/insight-extraction.md +18 -15
  37. package/payload/platform/templates/specialists/agents/database-operator.md +1 -1
  38. package/payload/server/chunk-43JK6WNK.js +3057 -0
  39. package/payload/server/chunk-QXAUMZXQ.js +9512 -0
  40. package/payload/server/chunk-WW464F23.js +9512 -0
  41. package/payload/server/client-pool-SGPHSYLK.js +28 -0
  42. package/payload/server/maxy-edge.js +2 -2
  43. package/payload/server/public/assets/admin-GIIfvDj1.js +352 -0
  44. package/payload/server/public/index.html +1 -1
  45. package/payload/server/server.js +222 -94
  46. package/payload/server/public/assets/admin-DHg5a2u2.js +0 -352
@@ -0,0 +1,385 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // parse-export — deterministic WhatsApp `_chat.txt` parser (Task 805).
6
+ //
7
+ // Pure function. No LLM in the per-line decision path. Replaces the prose
8
+ // grammar that lived in references/export-parse.md when the database-operator
9
+ // specialist's Sonnet was the line tokeniser. Every grammar branch here is
10
+ // exercised by the vitest grid in `__tests__/parse-export.test.ts`; that
11
+ // grid IS the contract — extending the grammar means a new test first.
12
+ //
13
+ // Doctrine alignment:
14
+ // - feedback_deterministic_means_remove_llm.md — the LLM is no longer in
15
+ // the per-line decision path.
16
+ // - feedback_deterministic_is_a_shell_script.md — TypeScript is the right
17
+ // deliverable shape here (UTF-8 decode + multi-line body assembly + sha256
18
+ // would be cumbersome in shell); the LITERAL-MAPPING rule yields to
19
+ // "Node module" because the per-line decision path is the deliverable, not
20
+ // a one-shot orchestrator.
21
+ // - feedback_loud_failures.md — encoding errors, empty files, and lines
22
+ // that match a timestamp prefix but cannot be tokenised throw with named
23
+ // reasons rather than degrading silently.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export interface ParseExportInput {
27
+ /** Absolute path to the `_chat.txt` file. */
28
+ filePath: string;
29
+ /** Account scope used to compose `conversationId`. */
30
+ accountId: string;
31
+ /** IANA timezone the operator confirmed (e.g. `Europe/London`). */
32
+ timezone: string;
33
+ /** Defaults to `DD/MM/YY`; operator confirms when locale is ambiguous. */
34
+ dateFormat?: "DD/MM/YY" | "MM/DD/YY";
35
+ }
36
+
37
+ export interface ParsedLine {
38
+ senderName: string;
39
+ /** ISO 8601 with timezone offset for the supplied IANA zone. */
40
+ dateSent: string;
41
+ body: string;
42
+ /** Position within emitted (post-skip) messages, 0-based. */
43
+ sequenceIndex: number;
44
+ }
45
+
46
+ export interface ParseExportCounters {
47
+ parsed: number;
48
+ systemSkipped: number;
49
+ mediaSkipped: number;
50
+ parseErrors: number;
51
+ }
52
+
53
+ export interface ParseExportResult {
54
+ conversationId: string;
55
+ /** `whatsapp-export:<sha256-hex>` of the raw file bytes. */
56
+ archiveSourceFile: string;
57
+ parsedLines: ParsedLine[];
58
+ counters: ParseExportCounters;
59
+ }
60
+
61
+ const TIMESTAMP_PREFIX_DDMMYY =
62
+ /^\[(\d{2})\/(\d{2})\/(\d{2}),\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\]\s*(.*)$/;
63
+
64
+ const TIMESTAMP_PREFIX_MMDDYY = TIMESTAMP_PREFIX_DDMMYY; // shape is identical; ordering differs in interpretation only
65
+
66
+ // System-message patterns that appear WITHOUT a `: ` sender/body separator.
67
+ // WhatsApp emits group-event and security-code lines as `<Sender> <verb> ...`
68
+ // (no colon). Lines that match the timestamp prefix but lack `: ` and do not
69
+ // match one of these patterns are LOUD-FAIL parse errors — never silently
70
+ // dropped.
71
+ const LINE_LEVEL_SYSTEM_PATTERNS: RegExp[] = [
72
+ /^Messages and calls are end-to-end encrypted/i,
73
+ /'s security code changed\.?$/i,
74
+ / created group ["“”]/,
75
+ / added /,
76
+ / removed /,
77
+ / left$/,
78
+ / changed the subject from /,
79
+ / changed this group's icon/,
80
+ / joined using this group's invite link/,
81
+ /^You're now an admin$/i,
82
+ /^You created group/i,
83
+ ];
84
+
85
+ // Body-level patterns evaluated after `Sender: body` split. These are real
86
+ // messages syntactically but carry no graph value (deletions, media-only).
87
+ const BODY_LEVEL_SYSTEM_PATTERNS: RegExp[] = [
88
+ /^You deleted this message\.?$/,
89
+ /^This message was deleted\.?$/,
90
+ ];
91
+
92
+ const MEDIA_ONLY_PATTERNS: RegExp[] = [
93
+ /^<Media omitted>$/,
94
+ /^IMG-\d+-\w+\.(jpg|jpeg|png|heic|gif)\s*\(file attached\)$/i,
95
+ /^VID-\d+-\w+\.mp4\s*\(file attached\)$/i,
96
+ /^PTT-\d+-\w+\.opus\s*\(file attached\)$/i,
97
+ /^AUD-\d+-\w+\.opus\s*\(file attached\)$/i,
98
+ /^STK-\d+-\w+\.webp\s*\(file attached\)$/i,
99
+ /^.+\.(pdf|docx|doc|xlsx|xls|pptx|ppt|zip|csv|txt)\s*\(file attached\)$/i,
100
+ /^‎.+attached:\s*.+$/, // alternative LRM-prefixed format on some platforms
101
+ ];
102
+
103
+ export function parseExport(input: ParseExportInput): ParseExportResult {
104
+ const { filePath, accountId, timezone, dateFormat = "DD/MM/YY" } = input;
105
+
106
+ if (!accountId || !accountId.trim()) {
107
+ throw new Error("parse-export: accountId is required.");
108
+ }
109
+ if (!timezone || !timezone.trim()) {
110
+ throw new Error("parse-export: timezone is required (e.g. 'Europe/London').");
111
+ }
112
+
113
+ const rawBytes = readFileSync(filePath);
114
+ const sha256Hex = createHash("sha256").update(rawBytes).digest("hex");
115
+ const archiveSourceFile = `whatsapp-export:${sha256Hex}`;
116
+ const conversationId = `whatsapp-export:${sha256Hex}:${accountId}`;
117
+
118
+ const text = decodeAndNormalise(rawBytes);
119
+ if (text.length === 0) {
120
+ throw new Error(
121
+ `parse-export: file is empty — not a _chat.txt. file=${filePath}`,
122
+ );
123
+ }
124
+
125
+ const lines = text.split("\n");
126
+ const counters: ParseExportCounters = {
127
+ parsed: 0,
128
+ systemSkipped: 0,
129
+ mediaSkipped: 0,
130
+ parseErrors: 0,
131
+ };
132
+
133
+ // Stage 1 — tokenise into raw messages (timestamp + remainder), accumulating
134
+ // continuation lines into the previous remainder. Stage 2 then categorises
135
+ // each tokenised message (system / media / real) so the counter increments
136
+ // happen exactly once per source line.
137
+ interface RawMessage {
138
+ rawLineIndex: number; // 1-based file line number for LOUD-FAIL diagnostics
139
+ year: number;
140
+ month: number;
141
+ day: number;
142
+ hour: number;
143
+ minute: number;
144
+ second: number;
145
+ remainder: string; // everything after `]` on the prefix line, plus continuation lines
146
+ }
147
+ const raw: RawMessage[] = [];
148
+
149
+ for (let i = 0; i < lines.length; i++) {
150
+ const line = lines[i];
151
+ if (line.length === 0 && i === lines.length - 1) continue; // trailing newline
152
+ const prefixMatch = matchTimestampPrefix(line, dateFormat);
153
+ if (prefixMatch) {
154
+ raw.push({
155
+ rawLineIndex: i + 1,
156
+ ...prefixMatch.dateParts,
157
+ remainder: prefixMatch.remainder,
158
+ });
159
+ } else {
160
+ // Continuation of the previous message body. If there is no previous
161
+ // message, this line is leading garbage — ignore it (matches the
162
+ // export-parse.md edge case where a leading BOM or blank line precedes
163
+ // the first timestamp).
164
+ const last = raw[raw.length - 1];
165
+ if (last) {
166
+ last.remainder += "\n" + line;
167
+ }
168
+ }
169
+ }
170
+
171
+ // Stage 2 — categorise each raw message. Do NOT trim trailing whitespace
172
+ // from the remainder before splitting — `Joel: ` (sender + colon + trailing
173
+ // space + newline) collapses to `Joel:` after a `\s+$` trim and the `: `
174
+ // separator disappears, turning an empty-body system skip into a LOUD-FAIL.
175
+ const parsedLines: ParsedLine[] = [];
176
+ for (const r of raw) {
177
+ const remainder = r.remainder;
178
+ const colonIdx = findFirstColonSeparator(remainder);
179
+
180
+ if (colonIdx === -1) {
181
+ // No `: ` separator. Must match a known system pattern or LOUD-FAIL.
182
+ const trimmed = remainder.replace(/\s+$/, "");
183
+ if (matchesAny(trimmed, LINE_LEVEL_SYSTEM_PATTERNS)) {
184
+ counters.systemSkipped++;
185
+ continue;
186
+ }
187
+ counters.parseErrors++;
188
+ throw new Error(
189
+ `parse-export: parse-error file=${filePath} line=${r.rawLineIndex} reason=no-sender-body-separator content="${trimmed.slice(0, 80)}"`,
190
+ );
191
+ }
192
+
193
+ const senderName = remainder.slice(0, colonIdx).trim();
194
+ const body = remainder.slice(colonIdx + 2).replace(/\s+$/, "");
195
+
196
+ if (body.length === 0) {
197
+ counters.systemSkipped++;
198
+ continue;
199
+ }
200
+ if (matchesAny(body, BODY_LEVEL_SYSTEM_PATTERNS)) {
201
+ counters.systemSkipped++;
202
+ continue;
203
+ }
204
+ if (matchesAny(body, MEDIA_ONLY_PATTERNS)) {
205
+ counters.mediaSkipped++;
206
+ continue;
207
+ }
208
+
209
+ const dateSent = isoWithOffset(
210
+ r.year,
211
+ r.month,
212
+ r.day,
213
+ r.hour,
214
+ r.minute,
215
+ r.second,
216
+ timezone,
217
+ );
218
+
219
+ parsedLines.push({
220
+ senderName,
221
+ dateSent,
222
+ body,
223
+ sequenceIndex: parsedLines.length,
224
+ });
225
+ counters.parsed++;
226
+ }
227
+
228
+ if (parsedLines.length === 0 && counters.systemSkipped === 0 && counters.mediaSkipped === 0) {
229
+ throw new Error(
230
+ `parse-export: zero parsed lines after walking ${filePath} — not a _chat.txt or all lines failed grammar.`,
231
+ );
232
+ }
233
+
234
+ return {
235
+ conversationId,
236
+ archiveSourceFile,
237
+ parsedLines,
238
+ counters,
239
+ };
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Internals
244
+ // ---------------------------------------------------------------------------
245
+
246
+ function decodeAndNormalise(bytes: Buffer): string {
247
+ // Strict UTF-8 decode. Node's TextDecoder with `fatal: true` throws on
248
+ // invalid bytes — that's the LOUD-FAIL the brief mandates for encoding
249
+ // errors. The default `Buffer.toString('utf8')` silently substitutes
250
+ // U+FFFD, which would let bad bytes propagate into the graph.
251
+ let text: string;
252
+ try {
253
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
254
+ } catch (err) {
255
+ throw new Error(
256
+ `parse-export: UTF-8 decode failed — ${err instanceof Error ? err.message : String(err)}. The file is not valid UTF-8; re-export from WhatsApp.`,
257
+ );
258
+ }
259
+
260
+ // Strip leading BOM (U+FEFF).
261
+ if (text.charCodeAt(0) === 0xfeff) {
262
+ text = text.slice(1);
263
+ }
264
+
265
+ // Normalise mixed line endings to LF.
266
+ text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
267
+
268
+ return text;
269
+ }
270
+
271
+ interface TimestampMatch {
272
+ dateParts: {
273
+ year: number;
274
+ month: number;
275
+ day: number;
276
+ hour: number;
277
+ minute: number;
278
+ second: number;
279
+ };
280
+ remainder: string;
281
+ }
282
+
283
+ function matchTimestampPrefix(
284
+ line: string,
285
+ dateFormat: "DD/MM/YY" | "MM/DD/YY",
286
+ ): TimestampMatch | null {
287
+ const re = dateFormat === "MM/DD/YY" ? TIMESTAMP_PREFIX_MMDDYY : TIMESTAMP_PREFIX_DDMMYY;
288
+ const m = line.match(re);
289
+ if (!m) return null;
290
+ const a = parseInt(m[1], 10); // dd or mm depending on dateFormat
291
+ const b = parseInt(m[2], 10); // mm or dd
292
+ const yy = parseInt(m[3], 10);
293
+ const hour = parseInt(m[4], 10);
294
+ const minute = parseInt(m[5], 10);
295
+ const second = m[6] !== undefined ? parseInt(m[6], 10) : 0;
296
+ const remainder = m[7] ?? "";
297
+ const day = dateFormat === "MM/DD/YY" ? b : a;
298
+ const month = dateFormat === "MM/DD/YY" ? a : b;
299
+ // Range-check before passing to Date.UTC — that function silently rolls
300
+ // over invalid components (Date.UTC(2026, 13, 1) → 2027-02-01), which
301
+ // would corrupt timestamps when the operator passes the wrong dateFormat
302
+ // for a US-locale export. Reject as not-a-prefix; the caller retries the
303
+ // file with the correct format or LOUD-FAILs when the file isn't a chat.
304
+ if (month < 1 || month > 12 || day < 1 || day > 31) return null;
305
+ if (hour > 23 || minute > 59 || second > 59) return null;
306
+ // WhatsApp's two-digit year is unambiguous in the 21st century; explicit
307
+ // shift here documents the assumption rather than relying on locale.
308
+ const year = 2000 + yy;
309
+ return {
310
+ dateParts: { year, month, day, hour, minute, second },
311
+ remainder,
312
+ };
313
+ }
314
+
315
+ function findFirstColonSeparator(remainder: string): number {
316
+ // Split on the FIRST `: ` (colon-space). A sender display name may itself
317
+ // contain a `:` (e.g. "Joel: Work"), so we anchor on the first colon
318
+ // followed by a space — that's the WhatsApp export's stable separator.
319
+ const idx = remainder.indexOf(": ");
320
+ return idx;
321
+ }
322
+
323
+ function matchesAny(text: string, patterns: RegExp[]): boolean {
324
+ for (const p of patterns) {
325
+ if (p.test(text)) return true;
326
+ }
327
+ return false;
328
+ }
329
+
330
+ function isoWithOffset(
331
+ year: number,
332
+ month: number,
333
+ day: number,
334
+ hour: number,
335
+ minute: number,
336
+ second: number,
337
+ timezone: string,
338
+ ): string {
339
+ // Produce ISO 8601 with the offset that the supplied IANA zone holds for
340
+ // this wall-clock instant. Two-step refinement is needed to handle DST:
341
+ // the wall-clock components describe a local time, and we need the offset
342
+ // for the corresponding UTC instant in `timezone`.
343
+ const guessUtcMs = Date.UTC(year, month - 1, day, hour, minute, second);
344
+ let offMin = offsetMinutesAt(new Date(guessUtcMs), timezone);
345
+ const refinedUtcMs = guessUtcMs - offMin * 60_000;
346
+ offMin = offsetMinutesAt(new Date(refinedUtcMs), timezone);
347
+
348
+ const sign = offMin >= 0 ? "+" : "-";
349
+ const absOff = Math.abs(offMin);
350
+ const offHH = String(Math.floor(absOff / 60)).padStart(2, "0");
351
+ const offMM = String(absOff % 60).padStart(2, "0");
352
+ const Y = String(year).padStart(4, "0");
353
+ const M = String(month).padStart(2, "0");
354
+ const D = String(day).padStart(2, "0");
355
+ const H = String(hour).padStart(2, "0");
356
+ const Mi = String(minute).padStart(2, "0");
357
+ const S = String(second).padStart(2, "0");
358
+ return `${Y}-${M}-${D}T${H}:${Mi}:${S}${sign}${offHH}:${offMM}`;
359
+ }
360
+
361
+ function offsetMinutesAt(date: Date, timezone: string): number {
362
+ // Use Intl.DateTimeFormat with longOffset to read the IANA-zone offset for
363
+ // the given UTC instant. Output format: "GMT+01:00", "GMT-05:00", or "GMT".
364
+ const formatter = new Intl.DateTimeFormat("en-US", {
365
+ timeZone: timezone,
366
+ timeZoneName: "longOffset",
367
+ });
368
+ const parts = formatter.formatToParts(date);
369
+ const tzPart = parts.find((p) => p.type === "timeZoneName");
370
+ if (!tzPart) {
371
+ throw new Error(`parse-export: unable to read offset for timezone "${timezone}".`);
372
+ }
373
+ const value = tzPart.value;
374
+ if (value === "GMT" || value === "UTC") return 0;
375
+ const m = value.match(/^(?:GMT|UTC)([+-])(\d{1,2}):?(\d{2})?$/);
376
+ if (!m) {
377
+ throw new Error(
378
+ `parse-export: cannot parse timezone offset "${value}" for IANA zone "${timezone}".`,
379
+ );
380
+ }
381
+ const sign = m[1] === "+" ? 1 : -1;
382
+ const hh = parseInt(m[2], 10);
383
+ const mm = m[3] ? parseInt(m[3], 10) : 0;
384
+ return sign * (hh * 60 + mm);
385
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["src/__tests__"]
9
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ globals: false,
7
+ include: ["src/__tests__/**/*.test.ts"],
8
+ },
9
+ });
@@ -27,7 +27,9 @@ The owner is metadata: who exported this chat. Stamped on the `:Conversation` no
27
27
 
28
28
  ### Step 2 — Participants
29
29
 
30
- Parse the `_chat.txt` per [export-parse.md](references/export-parse.md). For each distinct sender name, capture: `{senderName, firstSeen, lastSeen, messageCount}`. Display the list in chat with these counts; the operator sees who they're about to ingest before any write.
30
+ Parse the `_chat.txt` by invoking `mcp__memory__whatsapp-export-parse(filePath: <path>, timezone: <iana-zone>)` (Task 805). The tool returns `{conversationId, archiveSourceFile, parsedLines[], counters}` the deterministic Node parser in [platform/plugins/whatsapp-import/lib/](../../../lib/) walks the line grammar; the agent does not tokenise lines itself. See [export-parse.md](references/export-parse.md) for the parser's behaviour reference.
31
+
32
+ For each distinct sender name in `parsedLines[]`, capture: `{senderName, firstSeen, lastSeen, messageCount}`. Display the list in chat with these counts; the operator sees who they're about to ingest before any write.
31
33
 
32
34
  For each distinct sender, ask the operator to choose:
33
35
 
@@ -80,17 +82,17 @@ Convert each parsed timestamp to ISO 8601 with the supplied offset before passin
80
82
 
81
83
  ## Execution model
82
84
 
83
- 1. **Parse** — Read `_chat.txt` per [export-parse.md](references/export-parse.md). Build the parsed-line structure: `{senderName, dateSent, body, sequenceIndex}`. Skip system messages and media-only lines, increment counters.
85
+ 1. **Parse** — Invoke `mcp__memory__whatsapp-export-parse(filePath, timezone, dateFormat?)`. The deterministic parser walks the line grammar, returns `{conversationId, archiveSourceFile, parsedLines[], counters}`. LOUD-FAIL on encoding error / empty file / malformed timestamp surfaces as the tool's `isError` content; the skill aborts the import without further work. The `archiveSourceFile` is `whatsapp-export:<sha256-of-file-bytes>` keep this exact value; `memory-archive-write` will recompute and assert it matches in Step 6.
84
86
  2. **Owner+participant confirmation** — Steps 1–3 above. Persist `$ownerNodeId` + `$participantNodeIds`.
85
87
  3. **Selective-ingest gate** — If `parsedLines.length > 100`, pause for filter selection. Apply filter.
86
88
  4. **Build rows[]** — Map each parsed line to `{messageId, conversationId, senderNodeId, senderName, dateSent (ISO 8601), body, sequenceIndex}`. Compute `messageId` per line.
87
- 5. **Build conversation block** — `{conversationId, archiveSourceFile, firstMessageAt, lastMessageAt, participantCount, messageCount}` from the rows[].
88
- 6. **Dispatch** `mcp__memory__memory-archive-write` once with `archiveType='whatsapp-export'`, `ownerNodeId`, `conversation`, `participantNodeIds` (the distinct elementIds from the map), `rows`, `sessionId`. The tool MERGEs the Conversation, MERGEs Messages, links PART_OF + SENT + PARTICIPANT_IN edges per row, and runs the `finalize` hook to MERGE the NEXT chronology by dateSent ordering.
89
+ 5. **Build conversation block** — `{conversationId, archiveSourceFile, firstMessageAt, lastMessageAt, participantCount, messageCount}` from the rows[]. `conversationId` and `archiveSourceFile` come straight from the parser's return value.
90
+ 6. **Dispatch** `mcp__memory__memory-archive-write` once with `archiveType='whatsapp-export'`, `ownerNodeId`, `conversation`, `participantNodeIds` (the distinct elementIds from the map), `rows`, `sessionId`, **and `archiveFilePath: <same path you passed to whatsapp-export-parse>`**. The server re-computes `sha256(file)` and asserts it matches `conversation.archiveSourceFile` before any write — mismatch is a hard reject (Task 805 silent-substitution gate). The tool MERGEs the Conversation, MERGEs Messages, links PART_OF + SENT + PARTICIPANT_IN edges per row, and runs the `finalize` hook to MERGE the NEXT chronology by dateSent ordering.
89
91
  7. **Emit per-export log line:**
90
92
  ```
91
93
  [whatsapp-import] file=<chat.txt> conversationId=<cid> participants=<n> messages-parsed=<n> media-skipped=<n> system-skipped=<n> ms=<elapsed>
92
94
  ```
93
- 8. **Insight pass** — Run pass 2 per [insight-extraction.md](references/insight-extraction.md). Read the just-written messages via `memory-search`, classify within the specialist's own LLM turn, and write typed observations through `memory-write` / `memory-update`. Emit:
95
+ 8. **Insight pass** — Run pass 2 per [insight-extraction.md](references/insight-extraction.md). Read the just-written messages via `memory-search`, classify within the specialist's own LLM turn, and write typed observations through `memory-write` / `memory-update`. **`:MENTIONS` and `:RELATED_TO` edges route through `mcp__memory__whatsapp-export-insight-write` (Task 805) — that tool re-runs `memory-search` server-side and asserts the agent's claimed candidate elementIds appear in the live result; rejects single-first-name names without `disambiguatorOk=true`; refuses `:RELATED_TO` writes without `operatorConfirmed=true`. The agent never authors `:MENTIONS` / `:RELATED_TO` Cypher directly.** Emit:
94
96
  ```
95
97
  [whatsapp-import] insight-pass model=sonnet chunks=<n> mentions=<n> preferences=<n> tasks=<n> observed-relationships=<n> novel-insights=<n> ms=<elapsed>
96
98
  ```
@@ -101,13 +103,9 @@ All writes route through `mcp__memory__memory-archive-write` (bulk Conversation+
101
103
 
102
104
  ## LOUD-FAIL on parse errors
103
105
 
104
- If [export-parse.md](references/export-parse.md)'s grammar doesn't match a line (genuine parser failure, not a documented skip case), emit:
105
-
106
- ```
107
- [whatsapp-import] parse-error file=<chat.txt> line=<n> reason=<r>
108
- ```
106
+ `mcp__memory__whatsapp-export-parse` is the LOUD-FAIL surface (Task 805). When the grammar can't classify a line, the tool throws with `parse-error file=<...> line=<n> reason=<r>` and the MCP layer returns `isError: true` with that message. The skill MUST abort the import on a parse-error response — do not retry, do not "best effort" the rest of the file. The operator gets a named error and re-exports if necessary.
109
107
 
110
- …and abort the import. Do NOT silently truncate or guess. The operator gets a named error; we keep no half-truths in the graph.
108
+ The deterministic parser also LOUD-FAILs on UTF-8 decode failure (`reason=encoding-error`), zero parsed lines (`reason=not-a-_chat.txt`), and missing required arguments (`reason=accountId|timezone`). All of these surface through the same tool error path; the agent does not need to detect them itself.
111
109
 
112
110
  ## Idempotency contract
113
111
 
@@ -1,6 +1,8 @@
1
- # Reference: `_chat.txt` parsing
1
+ # Reference: `_chat.txt` parsing — implementation reference
2
2
 
3
- WhatsApp's "Export Chat" produces a UTF-8 text file with a deterministic line grammar. This reference is the contract for converting that file into the parsed-line structure the [SKILL.md](../SKILL.md) builds rows from.
3
+ > **Task 805 this is no longer operator instruction.** The agent does NOT walk this grammar in its own LLM turn. Parsing runs deterministically in [`platform/plugins/whatsapp-import/lib/src/parse-export.ts`](../../../lib/src/parse-export.ts), invoked via `mcp__memory__whatsapp-export-parse`. The vitest grid in [`lib/src/__tests__/parse-export.test.ts`](../../../lib/src/__tests__/parse-export.test.ts) is the executable contract; this prose is the human-readable companion. Extend the grammar by adding a failing test first.
4
+
5
+ WhatsApp's "Export Chat" produces a UTF-8 text file with a deterministic line grammar. This reference describes what the parser library does when it converts that file into the `{senderName, dateSent, body, sequenceIndex}[]` structure the SKILL.md consumes.
4
6
 
5
7
  ## File-open invariants
6
8
 
@@ -95,8 +97,11 @@ The skill consumes this directly. The `messageId` is computed by the skill (not
95
97
 
96
98
  ## When to LOUD-FAIL
97
99
 
98
- - Encoding error at file open (UTF-8 decode fails partway).
99
- - Zero parsed lines after walking the file (the file isn't a `_chat.txt`).
100
- - A timestamp prefix matches but the body parse fails (no `: ` separator after the closing `]`) emit `[whatsapp-import] parse-error file=<...> line=<n> reason=<r>` and abort.
100
+ The parser throws (and `whatsapp-export-parse` returns `isError: true`) on:
101
+
102
+ - Encoding error at file open (UTF-8 decode fails the parser uses `TextDecoder` with `fatal: true`, so any invalid byte sequence aborts loudly rather than silently substituting U+FFFD).
103
+ - Empty file or zero parsed lines after walking the file (the file isn't a `_chat.txt`).
104
+ - A timestamp prefix matches but the body parse fails (no `: ` separator after the closing `]` AND no system-pattern match) — emits `parse-error file=<...> line=<n> reason=no-sender-body-separator content="<...>"`.
105
+ - Missing required input (`accountId`, `timezone`).
101
106
 
102
107
  Never silently drop data the parser couldn't classify. The operator chooses to skip; the parser does not choose for them.
@@ -15,28 +15,27 @@ This pass runs INLINE in the database-operator specialist's own LLM turn — Son
15
15
  | Inter-person relationship | (matched `:Person` nodes) | `:Person` | `:Person` | `:RELATED_TO` (with `kind`, `evidenceMessageIds[]`) | Operator-confirmation gate before write — see below. |
16
16
  | Genuinely novel finding | `:Insight` (new label, last resort only) | `:Insight` | `:Message` | `:DERIVED_FROM` | Only when reuse-over-invent fails for every existing label. Self-rated `confidence` 0–1. |
17
17
 
18
- ## Anti-hallucination gates
18
+ ## Anti-hallucination gates — server-enforced (Task 805)
19
19
 
20
- The biggest risk in this pass is Sonnet writing edges to wrong-Person nodes. Two gates protect the graph:
20
+ The biggest risk in this pass is Sonnet writing edges to wrong-Person nodes. **Three gates protect the graph and they live in code, not prose.** `mcp__memory__whatsapp-export-insight-write` enforces all of them server-side; the agent cannot bypass them by skipping this section. This file describes how the gates work and what the agent must supply to pass them; the [tool source](../../../../memory/mcp/src/tools/whatsapp-export-insight-write.ts) is the canonical contract.
21
21
 
22
- ### Gate 1: `memory-search` BEFORE every `:MENTIONS` edge
22
+ `:MENTIONS` and `:RELATED_TO` writes ROUTE THROUGH this tool. Other observation kinds (`:Preference`, `:Task`, `:DefinedTerm`, `:Insight`) keep using `memory-write` because their adjacency is not subject to wrong-Person ambiguity.
23
23
 
24
- For every `:MENTIONS` edge candidate, the skill turn MUST run `memory-search(query=<mentioned-name>, kind='person')` first. The result determines what happens:
24
+ ### Gate 1: candidate-overlap (re-run `memory-search` before every `:MENTIONS` write)
25
25
 
26
- - **0 hits** the mentioned name doesn't exist in the graph. Skip silently. Do not auto-mint a `:Person` from a chat-mention alone.
27
- - **1 hit** — proceed if the match is unambiguous; surface to operator if ambiguous (see Gate 2).
28
- - **2+ hits** — ambiguous. Surface to operator before any edge writes.
26
+ For every `:MENTIONS` edge candidate the agent runs `memory-search(query=<mentioned-name>, labels=['Person','AdminUser'])` first, then calls `whatsapp-export-insight-write` with the resulting candidate `nodeId`s in `candidateElementIds`. **The server re-runs the same search and asserts at least one of those IDs appears in the live result.** Mismatch → `gate-rejected reason=candidate-mismatch`.
29
27
 
30
- The agent never writes a `:MENTIONS` edge without the prior `memory-search`. This is a discipline gate: the inline classification is just `memory-search memory-write`, never `memory-write` directly from raw classification.
28
+ - **0 hits** the mentioned name doesn't exist in the graph. Don't write the server would reject with `candidate-mismatch` because there is nothing to overlap. Do not auto-mint a `:Person` from a chat-mention alone.
29
+ - **1+ hits** — supply the `nodeId`(s) the agent expects to be the referent. Server confirms by re-running the search.
31
30
 
32
- ### Gate 2: First-name-only matches surface to operator regardless of hit count
31
+ ### Gate 2: first-name-only rejection (independent of hit count)
33
32
 
34
- Single-first-name references in chat ("ask Sarah about Q3") have ambiguous referents even when `memory-search` returns one match — that one match might be the wrong Sarah. The rule:
33
+ Single-token names ("Sarah") without an explicit disambiguator are rejected at the tool boundary regardless of memory-search hit count — that one match might be the wrong Sarah. The rule lives in code: if `name` lacks whitespace AND lacks digits AND `disambiguatorOk` is not `true`, the tool returns `gate-rejected reason=first-name-only` and writes nothing.
35
34
 
36
- - The mention has a **disambiguator** (full name "Sarah Chen", phone, email, role context "Sarah at Acme") → `memory-search` 1-hit write the edge.
37
- - The mention is a **first-name only** without disambiguator → ALWAYS surface to operator confirmation, regardless of `memory-search` result count.
35
+ - The mention has a **disambiguator** (full name "Sarah Chen", phone, email, role context "Sarah at Acme") → set `disambiguatorOk: true` in the tool call, gate passes if Gate 1 also passes.
36
+ - The mention is a **first-name only** without disambiguator → omit `disambiguatorOk` (or set `false`); the tool refuses the write. The agent surfaces this to the operator as ambiguous and asks for confirmation before retrying with `disambiguatorOk: true`.
38
37
 
39
- Surface format:
38
+ Surface format when the operator must disambiguate:
40
39
 
41
40
  ```
42
41
  [whatsapp-import] mention-ambiguous name="Sarah" reason=first-name-only candidates=1 awaiting-operator-resolution
@@ -44,12 +43,16 @@ Surface format:
44
43
 
45
44
  Followed by a chat prompt: `"Sarah" mentioned in message <messageId>. Found 1 :Person candidate: Sarah Chen (sarah@acme.com). Confirm? Yes / No / Pick another.`
46
45
 
47
- ### Gate 3: `:RELATED_TO` between two existing distinct Persons
46
+ ### Gate 3: `:RELATED_TO` requires `operatorConfirmed: true`
48
47
 
49
- When the second pass infers a relationship between two `:Person` nodes who both already exist in the graph (e.g., chat says "Joel and Sarah are working on Q3 together" → `(joel)-[:RELATED_TO {kind:'collaborator'}]->(sarah)`), surface to operator confirmation before write. The operator sees the inferred edge with both endpoints' names + the supporting message excerpts; on yes, the edge writes with `evidenceMessageIds: [...]`.
48
+ When the second pass infers a relationship between two `:Person` nodes who both already exist in the graph (e.g., chat says "Joel and Sarah are working on Q3 together" → `(joel)-[:RELATED_TO {kind:'collaborator'}]->(sarah)`), the agent surfaces the inferred edge with both endpoints' names + supporting message excerpts. On operator yes, the agent calls `whatsapp-export-insight-write(kind='RELATED_TO', operatorConfirmed: true, evidenceMessageIds: [...])`. **Without `operatorConfirmed=true` the tool returns `gate-rejected reason=relationship-needs-confirm` and writes nothing.**
50
49
 
51
50
  The default for this gate is conservative — when in doubt, surface. False-positive RELATED_TO edges are graph noise; false-negative skips can be re-run.
52
51
 
52
+ ### Endpoint label + accountId checks (free with the tool)
53
+
54
+ `whatsapp-export-insight-write` also rejects writes whose endpoints are missing, cross-account, or wrong-labelled (a MENTIONS source must be a :Message; the target must be :Person/:AdminUser; RELATED_TO requires both endpoints to be :Person/:AdminUser). These are tool-level invariants — the agent does not need to re-check them in skill code.
55
+
53
56
  ## Chunking strategy
54
57
 
55
58
  For conversations with 100+ messages, chunk the input to the inline LLM turn at ~50 messages per chunk. The classifier processes each chunk independently; the skill aggregates observations across chunks before writing. Aggregation deduplicates (the same `:MENTIONS` edge would otherwise be proposed once per chunk that referenced the same person).
@@ -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__whatsapp-export-parse, mcp__memory__whatsapp-export-insight-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
7
7
  ---
8
8
 
9
9
  # Database Operator