@rubytech/create-maxy 1.0.808 → 1.0.809
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/plugins/memory/mcp/dist/index.js +86 -0
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.d.ts +23 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.js +401 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.d.ts +28 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.js +34 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.js.map +1 -0
- package/payload/platform/plugins/memory/references/schema-base.md +12 -0
- package/payload/platform/plugins/whatsapp/PLUGIN.md +3 -1
- package/payload/platform/plugins/whatsapp-import/bin/ingest.mjs +225 -346
- package/payload/platform/plugins/whatsapp-import/bin/whatsapp-ingest.sh +28 -10
- package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.d.ts +21 -0
- package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.d.ts.map +1 -0
- package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.js +41 -0
- package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.js.map +1 -0
- package/payload/platform/plugins/whatsapp-import/lib/dist/filter.d.ts +29 -0
- package/payload/platform/plugins/whatsapp-import/lib/dist/filter.d.ts.map +1 -0
- package/payload/platform/plugins/whatsapp-import/lib/dist/filter.js +123 -0
- package/payload/platform/plugins/whatsapp-import/lib/dist/filter.js.map +1 -0
- package/payload/platform/plugins/whatsapp-import/lib/dist/index.d.ts +4 -0
- package/payload/platform/plugins/whatsapp-import/lib/dist/index.d.ts.map +1 -1
- package/payload/platform/plugins/whatsapp-import/lib/dist/index.js +9 -1
- package/payload/platform/plugins/whatsapp-import/lib/dist/index.js.map +1 -1
- package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/filter-gate.test.ts +170 -0
- package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/ingest-idempotence.test.ts +141 -0
- package/payload/platform/plugins/whatsapp-import/lib/src/derive-keys.ts +59 -0
- package/payload/platform/plugins/whatsapp-import/lib/src/filter.ts +136 -0
- package/payload/platform/plugins/whatsapp-import/lib/src/index.ts +12 -0
- package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md +80 -25
- package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import-enrich/SKILL.md +22 -3
- package/payload/platform/templates/specialists/agents/database-operator.md +9 -4
- package/payload/server/public/assets/admin-Bwrd2DBq.js +352 -0
- package/payload/server/public/index.html +1 -1
- package/payload/server/server.js +271 -188
- package/payload/server/public/assets/admin-MxaCgGHZ.js +0 -352
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
normaliseSenderName,
|
|
4
|
+
sha256Hex,
|
|
5
|
+
deriveMessageId,
|
|
6
|
+
observationContentHash,
|
|
7
|
+
} from "../derive-keys.js";
|
|
8
|
+
|
|
9
|
+
describe("normaliseSenderName", () => {
|
|
10
|
+
it("returns NFKC-trim-lower form", () => {
|
|
11
|
+
expect(normaliseSenderName(" Adam Mackay ")).toBe("adam mackay");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("collapses NFKC equivalent forms (composed vs decomposed accents)", () => {
|
|
15
|
+
const composed = "Adám";
|
|
16
|
+
const decomposed = "Adám";
|
|
17
|
+
expect(normaliseSenderName(composed)).toBe(normaliseSenderName(decomposed));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("collapses full-width characters to ASCII via NFKC", () => {
|
|
21
|
+
expect(normaliseSenderName("Adam")).toBe("adam");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns empty string for empty input without throwing", () => {
|
|
25
|
+
expect(normaliseSenderName("")).toBe("");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("sha256Hex", () => {
|
|
30
|
+
it("matches the canonical sha256 of an empty string", () => {
|
|
31
|
+
expect(sha256Hex("")).toBe(
|
|
32
|
+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("produces a deterministic hex digest", () => {
|
|
37
|
+
expect(sha256Hex("hello")).toBe(sha256Hex("hello"));
|
|
38
|
+
expect(sha256Hex("hello")).not.toBe(sha256Hex("world"));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("deriveMessageId", () => {
|
|
43
|
+
const baseInputs = {
|
|
44
|
+
conversationSha256: "abc123",
|
|
45
|
+
dateSent: "2026-03-14T10:15:23+00:00",
|
|
46
|
+
senderName: "Adam Mackay",
|
|
47
|
+
body: "Hello there",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
it("produces a stable id for identical inputs", () => {
|
|
51
|
+
const id1 = deriveMessageId(baseInputs);
|
|
52
|
+
const id2 = deriveMessageId({ ...baseInputs });
|
|
53
|
+
expect(id1).toBe(id2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("collapses identical (sender, dateSent, body) tuples to one id under NFKC-trim-lower (correct for export duplicates)", () => {
|
|
57
|
+
const id1 = deriveMessageId(baseInputs);
|
|
58
|
+
const id2 = deriveMessageId({ ...baseInputs, senderName: " ADAM Mackay " });
|
|
59
|
+
expect(id1).toBe(id2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("changes when the body differs", () => {
|
|
63
|
+
const id1 = deriveMessageId(baseInputs);
|
|
64
|
+
const id2 = deriveMessageId({ ...baseInputs, body: "Hello there!" });
|
|
65
|
+
expect(id1).not.toBe(id2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("changes when the sender differs (after normalisation)", () => {
|
|
69
|
+
const id1 = deriveMessageId(baseInputs);
|
|
70
|
+
const id2 = deriveMessageId({ ...baseInputs, senderName: "Joel" });
|
|
71
|
+
expect(id1).not.toBe(id2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("changes when the dateSent differs", () => {
|
|
75
|
+
const id1 = deriveMessageId(baseInputs);
|
|
76
|
+
const id2 = deriveMessageId({
|
|
77
|
+
...baseInputs,
|
|
78
|
+
dateSent: "2026-03-14T10:15:24+00:00",
|
|
79
|
+
});
|
|
80
|
+
expect(id1).not.toBe(id2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("changes when the conversation changes", () => {
|
|
84
|
+
const id1 = deriveMessageId(baseInputs);
|
|
85
|
+
const id2 = deriveMessageId({ ...baseInputs, conversationSha256: "def456" });
|
|
86
|
+
expect(id1).not.toBe(id2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("starts with the whatsapp-export:msg prefix and embeds normalised sender", () => {
|
|
90
|
+
const id = deriveMessageId(baseInputs);
|
|
91
|
+
expect(id.startsWith("whatsapp-export:msg:")).toBe(true);
|
|
92
|
+
expect(id).toContain(":adam mackay:");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("does not embed array-position or FNV32 collapse (Task 870 contract)", () => {
|
|
96
|
+
const id = deriveMessageId(baseInputs);
|
|
97
|
+
expect(id).toContain(":msg:");
|
|
98
|
+
expect(id).toMatch(/:[a-f0-9]{64}$/);
|
|
99
|
+
expect(id).not.toMatch(/:\d+:[a-f0-9]{8}$/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("produces a stable id for empty body", () => {
|
|
103
|
+
const id = deriveMessageId({ ...baseInputs, body: "" });
|
|
104
|
+
expect(id).toBe(deriveMessageId({ ...baseInputs, body: "" }));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("observationContentHash", () => {
|
|
109
|
+
it("is deterministic for identical inputs", () => {
|
|
110
|
+
const fields = { summary: "Adam said hi", from: "Adam", to: "Joel", subject: null };
|
|
111
|
+
expect(observationContentHash(fields)).toBe(observationContentHash(fields));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("normalises NFKC + trim + lowercase across all fields", () => {
|
|
115
|
+
const a = { summary: " Hello ", from: "ADAM", to: null, subject: null };
|
|
116
|
+
const b = { summary: "hello", from: "adam", to: null, subject: null };
|
|
117
|
+
expect(observationContentHash(a)).toBe(observationContentHash(b));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("treats null and empty string equivalently", () => {
|
|
121
|
+
const withNull = { summary: "x", from: null, to: null, subject: null };
|
|
122
|
+
const withEmpty = { summary: "x", from: "", to: "", subject: "" };
|
|
123
|
+
expect(observationContentHash(withNull)).toBe(observationContentHash(withEmpty));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("changes when any field changes", () => {
|
|
127
|
+
const base = { summary: "x", from: null, to: null, subject: null };
|
|
128
|
+
expect(observationContentHash(base)).not.toBe(
|
|
129
|
+
observationContentHash({ ...base, summary: "y" }),
|
|
130
|
+
);
|
|
131
|
+
expect(observationContentHash(base)).not.toBe(
|
|
132
|
+
observationContentHash({ ...base, from: "z" }),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("collapses NFKC equivalent forms in summary", () => {
|
|
137
|
+
const composed = { summary: "Adám", from: null, to: null, subject: null };
|
|
138
|
+
const decomposed = { summary: "Adám", from: null, to: null, subject: null };
|
|
139
|
+
expect(observationContentHash(composed)).toBe(observationContentHash(decomposed));
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// derive-keys — natural-key derivation for whatsapp-import (Task 870).
|
|
5
|
+
//
|
|
6
|
+
// Pure functions. No I/O. The whole point is that re-imports of the same
|
|
7
|
+
// archive collapse to the same Message identity regardless of release-level
|
|
8
|
+
// drift in array indices, hash widths, or arbitrary tiebreakers.
|
|
9
|
+
//
|
|
10
|
+
// Key shape (Task 870 brief):
|
|
11
|
+
//
|
|
12
|
+
// messageId = whatsapp-export:msg:<conversationSha256>:<dateSentISO>
|
|
13
|
+
// :<NFKC-trim-lower(senderName)>
|
|
14
|
+
// :<sha256-hex(body)>
|
|
15
|
+
//
|
|
16
|
+
// Operator constraint: the same archive must be re-imported with the same
|
|
17
|
+
// `--timezone` flag. Different timezones reinterpret wall-clock instants and
|
|
18
|
+
// will produce drifted messageIds — that is correct semantics, not a bug.
|
|
19
|
+
// Documented in .docs/whatsapp.md natural-key contract section.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export function normaliseSenderName(name: string): string {
|
|
23
|
+
return name.normalize("NFKC").trim().toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sha256Hex(input: string): string {
|
|
27
|
+
return createHash("sha256").update(input).digest("hex");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DeriveMessageIdInput {
|
|
31
|
+
/** SHA-256 of the source `_chat.txt` bytes — stable across re-imports. */
|
|
32
|
+
conversationSha256: string;
|
|
33
|
+
/** ISO 8601 with timezone offset, as emitted by parseExport. */
|
|
34
|
+
dateSent: string;
|
|
35
|
+
/** Raw senderName from the export line. Normalised internally. */
|
|
36
|
+
senderName: string;
|
|
37
|
+
/** Raw message body. Hashed internally. */
|
|
38
|
+
body: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function deriveMessageId(input: DeriveMessageIdInput): string {
|
|
42
|
+
const norm = normaliseSenderName(input.senderName);
|
|
43
|
+
const bodyHash = sha256Hex(input.body);
|
|
44
|
+
return `whatsapp-export:msg:${input.conversationSha256}:${input.dateSent}:${norm}:${bodyHash}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ObservationContentFields {
|
|
48
|
+
summary?: string | null;
|
|
49
|
+
from?: string | null;
|
|
50
|
+
to?: string | null;
|
|
51
|
+
subject?: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function observationContentHash(fields: ObservationContentFields): string {
|
|
55
|
+
const parts = [fields.summary, fields.from, fields.to, fields.subject].map(
|
|
56
|
+
(p) => (p ?? "").normalize("NFKC").trim().toLowerCase(),
|
|
57
|
+
);
|
|
58
|
+
return sha256Hex(parts.join("|"));
|
|
59
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// filter — operator-supplied gate over ParsedLine[] (Task 871).
|
|
3
|
+
//
|
|
4
|
+
// Phase 1 ingest is now mandatory-filter: the deterministic Bash entry refuses
|
|
5
|
+
// to write a bulk archive without `--filter`. Three forms cover the operator
|
|
6
|
+
// patterns named in the brief:
|
|
7
|
+
//
|
|
8
|
+
// --filter all → no row drop
|
|
9
|
+
// --filter senders=Alice,Bob Carter → keep rows whose
|
|
10
|
+
// senderName matches
|
|
11
|
+
// any csv entry exactly
|
|
12
|
+
// --filter date-range=2024-01-01..2024-06-30 → keep rows whose
|
|
13
|
+
// dateSent ISO falls
|
|
14
|
+
// inside the inclusive
|
|
15
|
+
// range (date or full
|
|
16
|
+
// ISO 8601)
|
|
17
|
+
//
|
|
18
|
+
// Doctrine alignment:
|
|
19
|
+
// - feedback_compress_at_ingest_for_bulk_archives.md — the gate is
|
|
20
|
+
// mandatory at write-time, not after.
|
|
21
|
+
// - feedback_deterministic_means_remove_llm.md — the filter parser is a
|
|
22
|
+
// pure function, no LLM in the per-row decision path.
|
|
23
|
+
// - feedback_loud_failures.md — malformed `--filter` raises a structured
|
|
24
|
+
// error with a named reason rather than silently coercing to `all`.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
import type { ParsedLine } from "./parse-export.js";
|
|
28
|
+
|
|
29
|
+
export type Filter =
|
|
30
|
+
| { kind: "all" }
|
|
31
|
+
| { kind: "senders"; senders: string[] }
|
|
32
|
+
| { kind: "date-range"; fromIso: string; toIso: string };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a CLI `--filter` argument into a structured Filter.
|
|
36
|
+
*
|
|
37
|
+
* Throws Error with message starting "filter: …" on malformed input. The
|
|
38
|
+
* caller (ingest.mjs / vitest) surfaces the reason verbatim — the brief
|
|
39
|
+
* mandates `[whatsapp-ingest] FAIL filter-required reason="…"` so the
|
|
40
|
+
* operator can grep one line.
|
|
41
|
+
*/
|
|
42
|
+
export function parseFilterArg(raw: string | undefined | null): Filter {
|
|
43
|
+
if (raw == null || raw.trim() === "") {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'filter: --filter is required (one of "all", "senders=<csv>", "date-range=<isoFrom>..<isoTo>")',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const value = raw.trim();
|
|
49
|
+
if (value === "all") return { kind: "all" };
|
|
50
|
+
if (value.startsWith("senders=")) {
|
|
51
|
+
const csv = value.slice("senders=".length);
|
|
52
|
+
const senders = csv
|
|
53
|
+
.split(",")
|
|
54
|
+
.map((s) => s.trim())
|
|
55
|
+
.filter((s) => s.length > 0);
|
|
56
|
+
if (senders.length === 0) {
|
|
57
|
+
throw new Error('filter: senders= requires at least one comma-separated name');
|
|
58
|
+
}
|
|
59
|
+
return { kind: "senders", senders };
|
|
60
|
+
}
|
|
61
|
+
if (value.startsWith("date-range=")) {
|
|
62
|
+
const range = value.slice("date-range=".length);
|
|
63
|
+
const parts = range.split("..");
|
|
64
|
+
if (parts.length !== 2) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`filter: date-range must be "<isoFrom>..<isoTo>" — got "${range}"`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const [fromIso, toIso] = parts.map((p) => p.trim());
|
|
70
|
+
if (!fromIso || !toIso) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`filter: date-range requires both endpoints — got "${range}"`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (Number.isNaN(Date.parse(fromIso))) {
|
|
76
|
+
throw new Error(`filter: date-range fromIso="${fromIso}" is not parseable as ISO 8601`);
|
|
77
|
+
}
|
|
78
|
+
if (Number.isNaN(Date.parse(toIso))) {
|
|
79
|
+
throw new Error(`filter: date-range toIso="${toIso}" is not parseable as ISO 8601`);
|
|
80
|
+
}
|
|
81
|
+
if (Date.parse(fromIso) > Date.parse(toIso)) {
|
|
82
|
+
throw new Error(`filter: date-range fromIso="${fromIso}" is later than toIso="${toIso}"`);
|
|
83
|
+
}
|
|
84
|
+
return { kind: "date-range", fromIso, toIso };
|
|
85
|
+
}
|
|
86
|
+
throw new Error(
|
|
87
|
+
`filter: unrecognised form "${value}" — must be "all", "senders=<csv>", or "date-range=<isoFrom>..<isoTo>"`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Apply a parsed Filter to ParsedLine[]. Returns a new array of kept lines
|
|
93
|
+
* with the parser's original `sequenceIndex` preserved (the filter never
|
|
94
|
+
* reorders). ingest.mjs re-stamps `sequenceIndex` to its post-filter position
|
|
95
|
+
* during row construction for archive-write — re-stamping here too would be
|
|
96
|
+
* redundant.
|
|
97
|
+
*/
|
|
98
|
+
export function applyFilter(
|
|
99
|
+
parsedLines: readonly ParsedLine[],
|
|
100
|
+
filter: Filter,
|
|
101
|
+
): ParsedLine[] {
|
|
102
|
+
const predicate = makePredicate(filter);
|
|
103
|
+
const kept: ParsedLine[] = [];
|
|
104
|
+
for (const line of parsedLines) {
|
|
105
|
+
if (!predicate(line)) continue;
|
|
106
|
+
kept.push(line);
|
|
107
|
+
}
|
|
108
|
+
return kept;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function makePredicate(filter: Filter): (line: ParsedLine) => boolean {
|
|
112
|
+
if (filter.kind === "all") return () => true;
|
|
113
|
+
if (filter.kind === "senders") {
|
|
114
|
+
const allow = new Set(filter.senders);
|
|
115
|
+
return (line) => allow.has(line.senderName);
|
|
116
|
+
}
|
|
117
|
+
// date-range: inclusive on both ends. Date-only endpoints widen to whole-
|
|
118
|
+
// day semantics: `from=YYYY-MM-DD` → `T00:00:00Z`, `to=YYYY-MM-DD` →
|
|
119
|
+
// `T23:59:59.999Z`. Full ISO 8601 endpoints with `T` are passed through.
|
|
120
|
+
// Without this widening, `--filter date-range=2024-01-01..2024-06-30`
|
|
121
|
+
// would silently drop every message later than 2024-06-30T00:00:00Z on the
|
|
122
|
+
// last day — a UX trap that contradicts the operator's reading.
|
|
123
|
+
const fromMs = parseRangeEndpoint(filter.fromIso, "start");
|
|
124
|
+
const toMs = parseRangeEndpoint(filter.toIso, "end");
|
|
125
|
+
return (line) => {
|
|
126
|
+
const ms = Date.parse(line.dateSent);
|
|
127
|
+
return ms >= fromMs && ms <= toMs;
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseRangeEndpoint(iso: string, edge: "start" | "end"): number {
|
|
132
|
+
if (/T/.test(iso)) return Date.parse(iso);
|
|
133
|
+
// Date-only — widen to whole-day inclusive on the requested edge.
|
|
134
|
+
const suffix = edge === "start" ? "T00:00:00.000Z" : "T23:59:59.999Z";
|
|
135
|
+
return Date.parse(iso + suffix);
|
|
136
|
+
}
|
|
@@ -5,3 +5,15 @@ export type {
|
|
|
5
5
|
ParseExportCounters,
|
|
6
6
|
ParsedLine,
|
|
7
7
|
} from "./parse-export.js";
|
|
8
|
+
export { parseFilterArg, applyFilter } from "./filter.js";
|
|
9
|
+
export type { Filter } from "./filter.js";
|
|
10
|
+
export {
|
|
11
|
+
normaliseSenderName,
|
|
12
|
+
sha256Hex,
|
|
13
|
+
deriveMessageId,
|
|
14
|
+
observationContentHash,
|
|
15
|
+
} from "./derive-keys.js";
|
|
16
|
+
export type {
|
|
17
|
+
DeriveMessageIdInput,
|
|
18
|
+
ObservationContentFields,
|
|
19
|
+
} from "./derive-keys.js";
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: whatsapp-import
|
|
3
|
-
description:
|
|
3
|
+
description: Phase 1 of the WhatsApp `_chat.txt` ingest contract — deterministic, LLM-free. Preview the archive (parsed counts, date range, sender histogram), ask the operator to choose a filter (`all`, `senders=<csv>`, `date-range=<isoFrom>..<isoTo>`), then write Conversation + Messages + NEXT chain + auto-Person participants via the single Bash entry `whatsapp-ingest.sh`. NO observations and NO LLM at this phase — semantic enrichment lives in the `whatsapp-import-enrich` skill (Phase 2). Triggers when the user asks to import a WhatsApp chat, ingest a `_chat.txt` file, or drops the contents of an "Export Chat" folder into chat. Distinct from the live `whatsapp` plugin (Baileys); this is import-from-export only.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# WhatsApp Import
|
|
6
|
+
# WhatsApp Import — Phase 1 (Load)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Phase 1 of the two-phase WhatsApp ingest contract. Deterministic only: parse → preview → operator-supplied filter → archive-write. NO LLM is invoked at this phase. The chunked Haiku insight pass moved to Phase 2 (`whatsapp-import-enrich` skill) so one ingest cannot blow the operator's context window with `:Observation` enumeration prose.
|
|
9
9
|
|
|
10
10
|
## Owner confirmation (mandatory first step)
|
|
11
11
|
|
|
12
|
-
A WhatsApp export belongs to exactly one operator (the person whose phone produced the export). The owner is metadata stamped on the `:Conversation` node — the row-level participants are auto-created by the script and promoted in
|
|
12
|
+
A WhatsApp export belongs to exactly one operator (the person whose phone produced the export). The owner is metadata stamped on the `:Conversation` node — the row-level participants are auto-created by the script and promoted in Phase 2.
|
|
13
13
|
|
|
14
14
|
1. List every `:AdminUser` in the graph via `mcp__graph__maxy-graph-read_neo4j_cypher`:
|
|
15
15
|
`MATCH (u:AdminUser) RETURN elementId(u) AS elementId, u.name AS name, u.userId AS userId, u.accountId AS accountId`
|
|
@@ -17,58 +17,113 @@ A WhatsApp export belongs to exactly one operator (the person whose phone produc
|
|
|
17
17
|
3. Echo the chosen owner back verbatim. Require explicit yes/no confirmation.
|
|
18
18
|
4. Persist the resolved owner's `elementId` for the script invocation as `--owner-element-id`.
|
|
19
19
|
|
|
20
|
-
##
|
|
20
|
+
## Step 1 — preview (mandatory before any write)
|
|
21
|
+
|
|
22
|
+
Call `mcp__memory__whatsapp-export-preview` with the operator-supplied path:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"filePath": "/abs/path/to/_chat.txt",
|
|
27
|
+
"timezone": "Europe/London"
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Returns: `{conversationSha256, archiveSourceFile, archiveBytes, parsed, mediaSkipped, systemSkipped, totalMessages, dateRange:{first,last}, senders:[{name,messageCount}, …]}`. No Cypher writes; the call is read-only and does NOT touch Neo4j.
|
|
32
|
+
|
|
33
|
+
Surface to the operator as one chat message — counters and the histogram, no prose:
|
|
34
|
+
|
|
35
|
+
> Preview of `<archive>`: `<parsed>` messages parsed, `<mediaSkipped>` media skipped, `<systemSkipped>` system skipped. Date range: `<first>` → `<last>`. Senders (top by count): `Joel (812), Adam (895)`. File hash: `<conversationSha256>` (`<archiveBytes>` bytes).
|
|
36
|
+
|
|
37
|
+
## Step 2 — operator chooses a filter
|
|
38
|
+
|
|
39
|
+
Ask exactly: "Filter to apply: `all`, `senders=<csv>`, or `date-range=<isoFrom>..<isoTo>`?" — no defaults, no menu of "or shall I just write everything". The operator picks one of the three forms verbatim:
|
|
40
|
+
|
|
41
|
+
| Filter | Effect |
|
|
42
|
+
|--------|--------|
|
|
43
|
+
| `all` | Write every parsed row. Operator's explicit "I want the full archive" choice. |
|
|
44
|
+
| `senders=Alice,Bob Carter` | Keep only rows whose senderName matches one of the comma-separated names exactly (whitespace trimmed). |
|
|
45
|
+
| `date-range=2024-01-01..2024-06-30` | Keep only rows whose `dateSent` falls inside the inclusive range (date-only or full ISO 8601 endpoints both accepted). |
|
|
46
|
+
|
|
47
|
+
Echo the chosen filter back; require explicit yes/no confirmation before the write.
|
|
48
|
+
|
|
49
|
+
## Step 3 — archive-write
|
|
21
50
|
|
|
22
51
|
Single Bash call:
|
|
23
52
|
|
|
24
53
|
```bash
|
|
25
54
|
bash platform/plugins/whatsapp-import/bin/whatsapp-ingest.sh <archive.zip|dir|_chat.txt> \
|
|
26
55
|
--owner-element-id <id> \
|
|
27
|
-
--scope <admin|public>
|
|
56
|
+
--scope <admin|public> \
|
|
57
|
+
--filter <all|senders=<csv>|date-range=<isoFrom>..<isoTo>>
|
|
28
58
|
```
|
|
29
59
|
|
|
30
60
|
Optional flags:
|
|
31
61
|
- `--account-id <id>` — explicit account id when more than one exists under `data/accounts/` (Phase 0 has one).
|
|
32
62
|
- `--timezone <iana>` — IANA zone for timestamps (default `Europe/London`).
|
|
33
63
|
- `--date-format <DD/MM/YY|MM/DD/YY|DD/MM/YYYY|MM/DD/YYYY>` — override auto-detect for ambiguous locales.
|
|
34
|
-
- `--no-insight` — skip the Haiku insight pass (parse + archive-write only).
|
|
35
64
|
|
|
36
65
|
The script:
|
|
37
66
|
- Unzips the archive if needed; locates `_chat.txt`.
|
|
38
67
|
- Parses the file deterministically (year shape, sender/body grammar, timezone offset).
|
|
39
|
-
-
|
|
68
|
+
- Applies the operator-supplied filter to `parseResult.parsedLines` BEFORE archive-write.
|
|
69
|
+
- Auto-creates one `:Person {participantStatus:'auto-created'}` per distinct senderName in the filtered set, scoped to the account, MERGEd on `(accountId, source, name)`.
|
|
40
70
|
- Writes the Conversation + Messages + edges + NEXT chronology via `memoryArchiveWrite` directly (no MCP envelope between steps).
|
|
41
|
-
- Runs the insight pass: chunked Haiku calls (1500 messages per chunk), in-process Cypher writes of `:Observation` nodes connected `:OBSERVED_IN`→`:Conversation`. Insight failures log and proceed; archive-write success is what determines exit code.
|
|
42
71
|
|
|
43
|
-
|
|
72
|
+
NO insight pass runs. The `--no-insight` flag of older releases is gone — Phase 1 always means parse + filter + archive-write, nothing else.
|
|
73
|
+
|
|
74
|
+
## Phase 1 agent-return — counters only
|
|
75
|
+
|
|
76
|
+
Stdout JSON shape (success — full diagnostic counters per Task 871 success criterion 5):
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"conversationElementId": "4:abcd…:42",
|
|
81
|
+
"conversationId": "whatsapp-export:<sha>:<accountId>",
|
|
82
|
+
"parsed": 1707,
|
|
83
|
+
"mediaSkipped": 0,
|
|
84
|
+
"systemSkipped": 0,
|
|
85
|
+
"filtered": 1707,
|
|
86
|
+
"written": 1707,
|
|
87
|
+
"messagesAlreadyExisted": 0,
|
|
88
|
+
"nextEdgesProcessed": 1706,
|
|
89
|
+
"nextEdgesCreated": 1706,
|
|
90
|
+
"participantsAlreadyExisted": 0,
|
|
91
|
+
"ms": 6800
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Surface to the admin agent as exactly one message (the agent-return shape per Task 871 success criterion 6 — counters first, one sentence pointing at the Phase 2 surface):
|
|
96
|
+
|
|
97
|
+
> Imported `<written>` messages from `<archive>` into conversation `<conversationElementId>` (`<conversationId>`); already existed: `<messagesAlreadyExisted>`; NEXT edges created: `<nextEdgesCreated>`. Use `mcp__memory__whatsapp-export-preview` for any future re-import preview; trigger semantic enrichment via the `whatsapp-import-enrich` skill ("enrich the `<chat-name>` chat") when ready.
|
|
98
|
+
|
|
99
|
+
NO inline enumeration of mention/task/preference/relationship counts. NO multi-paragraph "ask to enrich" prose. The above shape is load-bearing — the brief's `feedback_concision_over_completeness.md` and the Task-871 root incident (one ingest blew the operator's context with the count enumeration) require this discipline.
|
|
100
|
+
|
|
101
|
+
### Re-import signal
|
|
102
|
+
|
|
103
|
+
A second invocation against the same archive should report `messagesAlreadyExisted > 0 AND written > 0` (after Task 870's stable-messageId contract lands; pre-Task 870 the messageId is unstable and re-imports double). The subagent asserts both counters appear non-trivially before claiming a re-import landed cleanly.
|
|
104
|
+
|
|
105
|
+
## Failure path — single FAIL line
|
|
44
106
|
|
|
45
|
-
- **Exit
|
|
46
|
-
```json
|
|
47
|
-
{"conversationId": "whatsapp-export:<sha>:<accountId>",
|
|
48
|
-
"parsed": 1707, "mediaSkipped": 0, "systemSkipped": 0,
|
|
49
|
-
"createdMessages": 1707,
|
|
50
|
-
"insightCounters": {"chunks": 2, "mentions": 12, "tasks": 3, "preferences": 1, "observedRelationships": 0},
|
|
51
|
-
"ms": 67000}
|
|
52
|
-
```
|
|
53
|
-
Surface this to the operator as one chat message: `Imported 1707 messages from <archive> into conversation <conversationId>; insights: 12 mentions, 3 tasks, 1 preference, 0 relationships.`
|
|
107
|
+
- **Exit non-zero** + one stderr line: `[whatsapp-ingest] FAIL phase=<argv|filter|parse|archive-write|import|uncaught> reason="<sanitised first 80c>" ...`. Surface this verbatim to the operator and yield. **Do not retry. Do not edit parser source.** The archive-ingest-surface-gate denies parser-source edits, JS test runners, and the legacy `whatsapp-export-parse` / `whatsapp-export-insight-write` / `memory-archive-write{archiveType:whatsapp-export}` MCP tools — none of those are escape hatches in your surface.
|
|
54
108
|
|
|
55
|
-
|
|
109
|
+
Missing `--filter` emits the pinned line `[whatsapp-ingest] FAIL filter-required reason="bulk-archive-gate (Task 871) — operator must specify --filter (one of all, senders=<csv>, date-range=<isoFrom>..<isoTo>)"`. Re-invoke with the operator's chosen filter — never fabricate a default.
|
|
56
110
|
|
|
57
111
|
## Idempotency
|
|
58
112
|
|
|
59
|
-
Re-running
|
|
113
|
+
Re-running with the same `<archive>` + `--filter` is a no-op once Task 870's stable-messageId contract lands: `written: 0`, `nextEdgesCreated: 0`, conversation scalars refreshed via `lastImportedAt` / `lastImportedBySession`. Re-exports with appended messages add only the delta and extend the NEXT chain. Pre-Task-870 the messageId is unstable (`hashLine` is a 32-bit FNV collapse; `sequenceIndex` is array-position) so re-imports double the message set — Task 870 is the natural-key fix that makes the contract real.
|
|
60
114
|
|
|
61
115
|
## Verification (post-write)
|
|
62
116
|
|
|
63
117
|
Run via `mcp__graph__maxy-graph-read_neo4j_cypher`:
|
|
64
118
|
|
|
65
119
|
- `MATCH (c:Conversation:WhatsAppConversation {conversationId: $cid}) RETURN c.messageCount, c.participantCount, c.firstMessageAt, c.lastMessageAt` — agrees with the JSON summary.
|
|
66
|
-
- `MATCH (m:Message)-[:PART_OF]->(c {conversationId: $cid}) RETURN count(m)` — equals `
|
|
67
|
-
- `MATCH p=(:Message {conversationId: $cid})-[:NEXT*]->() WITH max(length(p)) AS chain RETURN chain` — equals `
|
|
68
|
-
- `MATCH (o:Observation
|
|
120
|
+
- `MATCH (m:Message)-[:PART_OF]->(c {conversationId: $cid}) RETURN count(m)` — equals `written + messagesAlreadyExisted` (post-filter).
|
|
121
|
+
- `MATCH p=(:Message {conversationId: $cid})-[:NEXT*]->() WITH max(length(p)) AS chain RETURN chain` — equals `messageCount - 1`.
|
|
122
|
+
- Phase 1 wrote ZERO observations: `MATCH (o:Observation)-[:OBSERVED_IN]->(:Conversation {conversationId: $cid}) RETURN count(o)` — should be 0 immediately after Phase 1. Observations land only when the operator triggers Phase 2.
|
|
69
123
|
|
|
70
124
|
## What this is not
|
|
71
125
|
|
|
72
126
|
- **Not** the live `whatsapp` plugin. That plugin (Baileys QR pairing) holds messages in an in-memory store cleared on restart. This plugin imports historical exports into Neo4j as persistent graph nodes.
|
|
73
127
|
- **Not** a media-transcription pipeline. Voice notes, photos, PDFs are skipped at parse with a counter logged.
|
|
74
|
-
- **Not** the operator-level semantic enrichment pass.
|
|
128
|
+
- **Not** the operator-level semantic enrichment pass. Auto-created participants and `:Observation` nodes are deliberately raw — Phase 2 (`whatsapp-import-enrich`) lays down the observations via `whatsapp-export-insight-pass` and walks them through operator-confirmed wiring.
|
|
129
|
+
- **Not** an LLM entry. Phase 1 has no Haiku call, no OAuth call, no model surface. The single sanctioned LLM entry for WhatsApp ingest is `mcp__memory__whatsapp-export-insight-pass`, invoked by the Phase 2 skill.
|
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: whatsapp-import-enrich
|
|
3
|
-
description: Operator-driven semantic enrichment pass over an already-loaded WhatsApp Conversation.
|
|
3
|
+
description: Operator-driven semantic enrichment pass over an already-loaded WhatsApp Conversation. Owns the LLM half of the WhatsApp ingest pipeline (Task 871) — 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` (Task 855 + Task 871 Phase 1); never re-runs parse.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# WhatsApp Import — Enrich
|
|
7
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
|
|
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 was the Task 871 collapse: Phase 1 used to run the insight pass inline (1500 msgs/chunk, no operator gate), which polluted the parent's tool_result with `:Observation` enumeration prose and blew operator context (`max-turns-retry-budget-exhausted`, log `0d5442b4`). Phase 1 is now mute on insights; this skill triggers them consciously with `mcp__memory__whatsapp-export-insight-pass` when the operator asks.
|
|
9
11
|
|
|
10
12
|
## When this applies
|
|
11
13
|
|
|
12
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).
|
|
13
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** (vs the Task 855 implementation's 1500 msgs/chunk that 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 (Task 870 contract). 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
|
+
|
|
14
32
|
## Bulk preview (mandatory, before any walk)
|
|
15
33
|
|
|
16
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`:
|
|
@@ -297,6 +315,7 @@ Every line emitted to chat is mirrored into the per-conversation agent-stream lo
|
|
|
297
315
|
|
|
298
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:
|
|
299
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.
|
|
300
319
|
- `mcp__graph__maxy-graph-read_neo4j_cypher` — bulk preview, evidence reads, messageId recovery, owner-reconciliation lookup.
|
|
301
320
|
- `mcp__graph__maxy-graph-write_neo4j_cypher` — `apoc.refactor.mergeNodes`, `:MENTIONS` and `:RELATED_TO` MERGEs, status-update SETs.
|
|
302
321
|
- `mcp__memory__memory-search` — entity disambiguation for mentions and observed-relationship endpoints.
|
|
@@ -308,7 +327,7 @@ Raw Cypher and `cypher-shell` are forbidden in this skill (per [database-operato
|
|
|
308
327
|
|
|
309
328
|
## What this is not
|
|
310
329
|
|
|
311
|
-
- **Not** Phase 1. Parse
|
|
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 moved here in Task 871 — 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.
|
|
312
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. 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
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.
|
|
314
333
|
- **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`.
|
|
@@ -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__tasks__task-create, 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-preview, mcp__memory__whatsapp-export-insight-pass, 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,9 +119,14 @@ 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. **Two-phase contract** (Task 855 + Task
|
|
123
|
-
- **Phase 1 —
|
|
124
|
-
|
|
122
|
+
- **whatsapp-import** — WhatsApp `_chat.txt` export ingestion. **Two-phase contract** (Task 855 + Task 871 — Phase 1 deterministic, Phase 2 operator-triggered):
|
|
123
|
+
- **Phase 1 — preview-then-filtered-write** (`whatsapp-import` skill). Phase 1 is LLM-FREE. Three steps:
|
|
124
|
+
1. **Preview** via `mcp__memory__whatsapp-export-preview` — read-only parse that returns `{conversationSha256, parsed, mediaSkipped, systemSkipped, dateRange:{first,last}, senders:[{name,messageCount}], totalMessages, archiveBytes}`. No Cypher writes.
|
|
125
|
+
2. **Operator chooses a filter.** Surface the preview to the operator and ask: "Filter to apply: `all`, `senders=<csv>`, or `date-range=<isoFrom>..<isoTo>`?". `--filter` is mandatory — the deterministic Bash entry refuses to write without it (`feedback_compress_at_ingest_for_bulk_archives.md`).
|
|
126
|
+
3. **Archive-write** via `bash platform/plugins/whatsapp-import/bin/whatsapp-ingest.sh <archive> --owner-element-id <id> --scope <admin|public> --filter <chosen>`. Parses, applies the filter, writes Conversation + Messages with chronological NEXT chain, auto-creates one `:Person {participantStatus:'auto-created'}` per distinct senderName. ZERO `:Observation` writes — the LLM insight pass moved to Phase 2.
|
|
127
|
+
|
|
128
|
+
Phase 1 agent-return is COUNTERS ONLY — no inline enumeration of mention/task/preference counts, no multi-paragraph "ask to enrich" prose. Surface as one chat message: the JSON shape `{conversationElementId, conversationId, parsed, written, alreadyExisted, nextEdgesCreated, ms}` plus one sentence: "Preview before any future re-import via `mcp__memory__whatsapp-export-preview`; enrich semantically when ready via the `whatsapp-import-enrich` skill." The legacy `mcp__memory__whatsapp-export-parse` / `whatsapp-export-insight-write` / `memory-archive-write{archiveType:whatsapp-export}` MCP tools remain blocked at the harness; the Bash script is the only supported archive-write invocation. SKILL: `platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md`.
|
|
129
|
+
- **Phase 2 — enrich** (`whatsapp-import-enrich` skill). Operator-triggered ("enrich the X chat"). First runs `mcp__memory__whatsapp-export-insight-pass` against the already-loaded Conversation (chunkSize=50, overlap=5, server-side `confidence>=0.8` gate) to lay down `:Observation {observationStatus:'auto-extracted'}` rows. Then walks the auto-created participants and auto-extracted observations, surfacing 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
130
|
- 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).
|
|
126
131
|
|
|
127
132
|
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.
|