@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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/memory/mcp/dist/index.js +86 -0
  3. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  4. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.d.ts +23 -0
  5. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.d.ts.map +1 -0
  6. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.js +401 -0
  7. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.js.map +1 -0
  8. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.d.ts +28 -0
  9. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.d.ts.map +1 -0
  10. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.js +34 -0
  11. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.js.map +1 -0
  12. package/payload/platform/plugins/memory/references/schema-base.md +12 -0
  13. package/payload/platform/plugins/whatsapp/PLUGIN.md +3 -1
  14. package/payload/platform/plugins/whatsapp-import/bin/ingest.mjs +225 -346
  15. package/payload/platform/plugins/whatsapp-import/bin/whatsapp-ingest.sh +28 -10
  16. package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.d.ts +21 -0
  17. package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.d.ts.map +1 -0
  18. package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.js +41 -0
  19. package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.js.map +1 -0
  20. package/payload/platform/plugins/whatsapp-import/lib/dist/filter.d.ts +29 -0
  21. package/payload/platform/plugins/whatsapp-import/lib/dist/filter.d.ts.map +1 -0
  22. package/payload/platform/plugins/whatsapp-import/lib/dist/filter.js +123 -0
  23. package/payload/platform/plugins/whatsapp-import/lib/dist/filter.js.map +1 -0
  24. package/payload/platform/plugins/whatsapp-import/lib/dist/index.d.ts +4 -0
  25. package/payload/platform/plugins/whatsapp-import/lib/dist/index.d.ts.map +1 -1
  26. package/payload/platform/plugins/whatsapp-import/lib/dist/index.js +9 -1
  27. package/payload/platform/plugins/whatsapp-import/lib/dist/index.js.map +1 -1
  28. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/filter-gate.test.ts +170 -0
  29. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/ingest-idempotence.test.ts +141 -0
  30. package/payload/platform/plugins/whatsapp-import/lib/src/derive-keys.ts +59 -0
  31. package/payload/platform/plugins/whatsapp-import/lib/src/filter.ts +136 -0
  32. package/payload/platform/plugins/whatsapp-import/lib/src/index.ts +12 -0
  33. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md +80 -25
  34. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import-enrich/SKILL.md +22 -3
  35. package/payload/platform/templates/specialists/agents/database-operator.md +9 -4
  36. package/payload/server/public/assets/admin-Bwrd2DBq.js +352 -0
  37. package/payload/server/public/index.html +1 -1
  38. package/payload/server/server.js +271 -188
  39. package/payload/server/public/assets/admin-MxaCgGHZ.js +0 -352
@@ -1,18 +1,28 @@
1
1
  #!/usr/bin/env bash
2
2
  # =============================================================================
3
3
  # whatsapp-ingest.sh — single deterministic Bash entry for WhatsApp archive
4
- # ingestion (Task 855). Thin wrapper: arg-validate, resolve env, invoke
5
- # ingest.mjs in-process. The script is the database-operator subagent's only
6
- # operator-facing handle on the parse → archive-write → insight pipeline.
4
+ # ingestion (Task 855 / Task 871). Thin wrapper: arg-validate, resolve env,
5
+ # invoke ingest.mjs in-process. The script is the database-operator
6
+ # subagent's only operator-facing handle on the parse → filter → archive-write
7
+ # pipeline. Phase 1 has NO LLM. The Haiku insight pass is Phase 2 — invoked
8
+ # consciously via `mcp__memory__whatsapp-export-insight-pass`.
7
9
  #
8
10
  # Usage:
9
11
  # bash whatsapp-ingest.sh <archive.zip|dir|_chat.txt>
10
12
  # --owner-element-id <id>
11
13
  # --scope <admin|public>
14
+ # --filter <all|senders=<csv>|date-range=<isoFrom>..<isoTo>>
12
15
  # [--account-id <accountId>]
13
16
  # [--timezone <iana-zone>]
14
17
  # [--date-format <DD/MM/YY|MM/DD/YY|DD/MM/YYYY|MM/DD/YYYY>]
15
- # [--no-insight]
18
+ #
19
+ # `--filter` is mandatory (Task 871). Forms:
20
+ # all — write every parsed row
21
+ # senders=Alice,Bob Carter — keep rows whose senderName ∈ csv
22
+ # date-range=2024-01-01..2024-06-30
23
+ # — keep rows whose dateSent falls inside
24
+ # the inclusive range (date-only or full
25
+ # ISO 8601 endpoints both accepted)
16
26
  #
17
27
  # Exit 0 + JSON summary on stdout on success.
18
28
  # Exit !0 + one [whatsapp-ingest] FAIL line on stderr on failure.
@@ -45,18 +55,20 @@ fi
45
55
  ARCHIVE=""
46
56
  OWNER_VAL=""
47
57
  SCOPE_VAL=""
58
+ FILTER_VAL=""
48
59
  HAS_OWNER=0
49
60
  HAS_SCOPE=0
61
+ HAS_FILTER=0
50
62
 
51
63
  ARGS=("$@")
52
64
  i=0
53
65
  while [ $i -lt ${#ARGS[@]} ]; do
54
66
  a="${ARGS[$i]}"
55
67
  case "$a" in
56
- --owner-element-id) HAS_OWNER=1; OWNER_VAL="${ARGS[$((i + 1))]:-}"; i=$((i + 2)); continue ;;
57
- --scope) HAS_SCOPE=1; SCOPE_VAL="${ARGS[$((i + 1))]:-}"; i=$((i + 2)); continue ;;
68
+ --owner-element-id) HAS_OWNER=1; OWNER_VAL="${ARGS[$((i + 1))]:-}"; i=$((i + 2)); continue ;;
69
+ --scope) HAS_SCOPE=1; SCOPE_VAL="${ARGS[$((i + 1))]:-}"; i=$((i + 2)); continue ;;
70
+ --filter) HAS_FILTER=1; FILTER_VAL="${ARGS[$((i + 1))]:-}"; i=$((i + 2)); continue ;;
58
71
  --account-id|--timezone|--date-format) i=$((i + 2)); continue ;;
59
- --no-insight) i=$((i + 1)); continue ;;
60
72
  --*) i=$((i + 2)); continue ;;
61
73
  *)
62
74
  if [ -z "$ARCHIVE" ]; then ARCHIVE="$a"; fi
@@ -66,13 +78,19 @@ while [ $i -lt ${#ARGS[@]} ]; do
66
78
  esac
67
79
  done
68
80
 
69
- [ -n "$ARCHIVE" ] || arg_fail "missing positional <archive>"
70
- [ "$HAS_OWNER" -eq 1 ] && [ -n "$OWNER_VAL" ] || arg_fail "missing --owner-element-id (or empty value)"
71
- [ "$HAS_SCOPE" -eq 1 ] && [ -n "$SCOPE_VAL" ] || arg_fail "missing --scope (or empty value)"
81
+ [ -n "$ARCHIVE" ] || arg_fail "missing positional <archive>"
82
+ [ "$HAS_OWNER" -eq 1 ] && [ -n "$OWNER_VAL" ] || arg_fail "missing --owner-element-id (or empty value)"
83
+ [ "$HAS_SCOPE" -eq 1 ] && [ -n "$SCOPE_VAL" ] || arg_fail "missing --scope (or empty value)"
72
84
  case "$SCOPE_VAL" in
73
85
  admin|public) : ;;
74
86
  *) arg_fail "invalid --scope \"$SCOPE_VAL\" (admin|public)" ;;
75
87
  esac
88
+ if [ "$HAS_FILTER" -ne 1 ] || [ -z "$FILTER_VAL" ]; then
89
+ # Mirror ingest.mjs's pinned LOUD-FAIL line so a single grep covers both
90
+ # layers — the operator's runbook recipe is `grep '\[whatsapp-ingest\] FAIL filter-required'`.
91
+ echo "[whatsapp-ingest] FAIL filter-required reason=\"bulk-archive-gate (Task 871) — operator must specify --filter (one of all, senders=<csv>, date-range=<isoFrom>..<isoTo>)\"" >&2
92
+ arg_fail "missing --filter (one of all, senders=<csv>, date-range=<isoFrom>..<isoTo>)"
93
+ fi
76
94
 
77
95
  # Lift NEO4J_PASSWORD from the install's config file when env doesn't carry it
78
96
  # (e.g. operator running directly from a shell rather than via the platform
@@ -0,0 +1,21 @@
1
+ export declare function normaliseSenderName(name: string): string;
2
+ export declare function sha256Hex(input: string): string;
3
+ export interface DeriveMessageIdInput {
4
+ /** SHA-256 of the source `_chat.txt` bytes — stable across re-imports. */
5
+ conversationSha256: string;
6
+ /** ISO 8601 with timezone offset, as emitted by parseExport. */
7
+ dateSent: string;
8
+ /** Raw senderName from the export line. Normalised internally. */
9
+ senderName: string;
10
+ /** Raw message body. Hashed internally. */
11
+ body: string;
12
+ }
13
+ export declare function deriveMessageId(input: DeriveMessageIdInput): string;
14
+ export interface ObservationContentFields {
15
+ summary?: string | null;
16
+ from?: string | null;
17
+ to?: string | null;
18
+ subject?: string | null;
19
+ }
20
+ export declare function observationContentHash(fields: ObservationContentFields): string;
21
+ //# sourceMappingURL=derive-keys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"derive-keys.d.ts","sourceRoot":"","sources":["../src/derive-keys.ts"],"names":[],"mappings":"AAqBA,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED,MAAM,WAAW,oBAAoB;IACnC,0EAA0E;IAC1E,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAC;IACjB,kEAAkE;IAClE,UAAU,EAAE,MAAM,CAAC;IACnB,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,oBAAoB,GAAG,MAAM,CAInE;AAED,MAAM,WAAW,wBAAwB;IACvC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,wBAAwB,GAAG,MAAM,CAK/E"}
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normaliseSenderName = normaliseSenderName;
4
+ exports.sha256Hex = sha256Hex;
5
+ exports.deriveMessageId = deriveMessageId;
6
+ exports.observationContentHash = observationContentHash;
7
+ const node_crypto_1 = require("node:crypto");
8
+ // ---------------------------------------------------------------------------
9
+ // derive-keys — natural-key derivation for whatsapp-import (Task 870).
10
+ //
11
+ // Pure functions. No I/O. The whole point is that re-imports of the same
12
+ // archive collapse to the same Message identity regardless of release-level
13
+ // drift in array indices, hash widths, or arbitrary tiebreakers.
14
+ //
15
+ // Key shape (Task 870 brief):
16
+ //
17
+ // messageId = whatsapp-export:msg:<conversationSha256>:<dateSentISO>
18
+ // :<NFKC-trim-lower(senderName)>
19
+ // :<sha256-hex(body)>
20
+ //
21
+ // Operator constraint: the same archive must be re-imported with the same
22
+ // `--timezone` flag. Different timezones reinterpret wall-clock instants and
23
+ // will produce drifted messageIds — that is correct semantics, not a bug.
24
+ // Documented in .docs/whatsapp.md natural-key contract section.
25
+ // ---------------------------------------------------------------------------
26
+ function normaliseSenderName(name) {
27
+ return name.normalize("NFKC").trim().toLowerCase();
28
+ }
29
+ function sha256Hex(input) {
30
+ return (0, node_crypto_1.createHash)("sha256").update(input).digest("hex");
31
+ }
32
+ function deriveMessageId(input) {
33
+ const norm = normaliseSenderName(input.senderName);
34
+ const bodyHash = sha256Hex(input.body);
35
+ return `whatsapp-export:msg:${input.conversationSha256}:${input.dateSent}:${norm}:${bodyHash}`;
36
+ }
37
+ function observationContentHash(fields) {
38
+ const parts = [fields.summary, fields.from, fields.to, fields.subject].map((p) => (p ?? "").normalize("NFKC").trim().toLowerCase());
39
+ return sha256Hex(parts.join("|"));
40
+ }
41
+ //# sourceMappingURL=derive-keys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"derive-keys.js","sourceRoot":"","sources":["../src/derive-keys.ts"],"names":[],"mappings":";;AAqBA,kDAEC;AAED,8BAEC;AAaD,0CAIC;AASD,wDAKC;AA1DD,6CAAyC;AAEzC,8EAA8E;AAC9E,uEAAuE;AACvE,EAAE;AACF,yEAAyE;AACzE,4EAA4E;AAC5E,iEAAiE;AACjE,EAAE;AACF,8BAA8B;AAC9B,EAAE;AACF,uEAAuE;AACvE,kEAAkE;AAClE,uDAAuD;AACvD,EAAE;AACF,0EAA0E;AAC1E,6EAA6E;AAC7E,0EAA0E;AAC1E,gEAAgE;AAChE,8EAA8E;AAE9E,SAAgB,mBAAmB,CAAC,IAAY;IAC9C,OAAO,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;AACrD,CAAC;AAED,SAAgB,SAAS,CAAC,KAAa;IACrC,OAAO,IAAA,wBAAU,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC1D,CAAC;AAaD,SAAgB,eAAe,CAAC,KAA2B;IACzD,MAAM,IAAI,GAAG,mBAAmB,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IACnD,MAAM,QAAQ,GAAG,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACvC,OAAO,uBAAuB,KAAK,CAAC,kBAAkB,IAAI,KAAK,CAAC,QAAQ,IAAI,IAAI,IAAI,QAAQ,EAAE,CAAC;AACjG,CAAC;AASD,SAAgB,sBAAsB,CAAC,MAAgC;IACrE,MAAM,KAAK,GAAG,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC,GAAG,CACxE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CACxD,CAAC;IACF,OAAO,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AACpC,CAAC"}
@@ -0,0 +1,29 @@
1
+ import type { ParsedLine } from "./parse-export.js";
2
+ export type Filter = {
3
+ kind: "all";
4
+ } | {
5
+ kind: "senders";
6
+ senders: string[];
7
+ } | {
8
+ kind: "date-range";
9
+ fromIso: string;
10
+ toIso: string;
11
+ };
12
+ /**
13
+ * Parse a CLI `--filter` argument into a structured Filter.
14
+ *
15
+ * Throws Error with message starting "filter: …" on malformed input. The
16
+ * caller (ingest.mjs / vitest) surfaces the reason verbatim — the brief
17
+ * mandates `[whatsapp-ingest] FAIL filter-required reason="…"` so the
18
+ * operator can grep one line.
19
+ */
20
+ export declare function parseFilterArg(raw: string | undefined | null): Filter;
21
+ /**
22
+ * Apply a parsed Filter to ParsedLine[]. Returns a new array of kept lines
23
+ * with the parser's original `sequenceIndex` preserved (the filter never
24
+ * reorders). ingest.mjs re-stamps `sequenceIndex` to its post-filter position
25
+ * during row construction for archive-write — re-stamping here too would be
26
+ * redundant.
27
+ */
28
+ export declare function applyFilter(parsedLines: readonly ParsedLine[], filter: Filter): ParsedLine[];
29
+ //# sourceMappingURL=filter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filter.d.ts","sourceRoot":"","sources":["../src/filter.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAEpD,MAAM,MAAM,MAAM,GACd;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,GACf;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,GACtC;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3D;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,CA+CrE;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CACzB,WAAW,EAAE,SAAS,UAAU,EAAE,EAClC,MAAM,EAAE,MAAM,GACb,UAAU,EAAE,CAQd"}
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ // ---------------------------------------------------------------------------
3
+ // filter — operator-supplied gate over ParsedLine[] (Task 871).
4
+ //
5
+ // Phase 1 ingest is now mandatory-filter: the deterministic Bash entry refuses
6
+ // to write a bulk archive without `--filter`. Three forms cover the operator
7
+ // patterns named in the brief:
8
+ //
9
+ // --filter all → no row drop
10
+ // --filter senders=Alice,Bob Carter → keep rows whose
11
+ // senderName matches
12
+ // any csv entry exactly
13
+ // --filter date-range=2024-01-01..2024-06-30 → keep rows whose
14
+ // dateSent ISO falls
15
+ // inside the inclusive
16
+ // range (date or full
17
+ // ISO 8601)
18
+ //
19
+ // Doctrine alignment:
20
+ // - feedback_compress_at_ingest_for_bulk_archives.md — the gate is
21
+ // mandatory at write-time, not after.
22
+ // - feedback_deterministic_means_remove_llm.md — the filter parser is a
23
+ // pure function, no LLM in the per-row decision path.
24
+ // - feedback_loud_failures.md — malformed `--filter` raises a structured
25
+ // error with a named reason rather than silently coercing to `all`.
26
+ // ---------------------------------------------------------------------------
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.parseFilterArg = parseFilterArg;
29
+ exports.applyFilter = applyFilter;
30
+ /**
31
+ * Parse a CLI `--filter` argument into a structured Filter.
32
+ *
33
+ * Throws Error with message starting "filter: …" on malformed input. The
34
+ * caller (ingest.mjs / vitest) surfaces the reason verbatim — the brief
35
+ * mandates `[whatsapp-ingest] FAIL filter-required reason="…"` so the
36
+ * operator can grep one line.
37
+ */
38
+ function parseFilterArg(raw) {
39
+ if (raw == null || raw.trim() === "") {
40
+ throw new Error('filter: --filter is required (one of "all", "senders=<csv>", "date-range=<isoFrom>..<isoTo>")');
41
+ }
42
+ const value = raw.trim();
43
+ if (value === "all")
44
+ return { kind: "all" };
45
+ if (value.startsWith("senders=")) {
46
+ const csv = value.slice("senders=".length);
47
+ const senders = csv
48
+ .split(",")
49
+ .map((s) => s.trim())
50
+ .filter((s) => s.length > 0);
51
+ if (senders.length === 0) {
52
+ throw new Error('filter: senders= requires at least one comma-separated name');
53
+ }
54
+ return { kind: "senders", senders };
55
+ }
56
+ if (value.startsWith("date-range=")) {
57
+ const range = value.slice("date-range=".length);
58
+ const parts = range.split("..");
59
+ if (parts.length !== 2) {
60
+ throw new Error(`filter: date-range must be "<isoFrom>..<isoTo>" — got "${range}"`);
61
+ }
62
+ const [fromIso, toIso] = parts.map((p) => p.trim());
63
+ if (!fromIso || !toIso) {
64
+ throw new Error(`filter: date-range requires both endpoints — got "${range}"`);
65
+ }
66
+ if (Number.isNaN(Date.parse(fromIso))) {
67
+ throw new Error(`filter: date-range fromIso="${fromIso}" is not parseable as ISO 8601`);
68
+ }
69
+ if (Number.isNaN(Date.parse(toIso))) {
70
+ throw new Error(`filter: date-range toIso="${toIso}" is not parseable as ISO 8601`);
71
+ }
72
+ if (Date.parse(fromIso) > Date.parse(toIso)) {
73
+ throw new Error(`filter: date-range fromIso="${fromIso}" is later than toIso="${toIso}"`);
74
+ }
75
+ return { kind: "date-range", fromIso, toIso };
76
+ }
77
+ throw new Error(`filter: unrecognised form "${value}" — must be "all", "senders=<csv>", or "date-range=<isoFrom>..<isoTo>"`);
78
+ }
79
+ /**
80
+ * Apply a parsed Filter to ParsedLine[]. Returns a new array of kept lines
81
+ * with the parser's original `sequenceIndex` preserved (the filter never
82
+ * reorders). ingest.mjs re-stamps `sequenceIndex` to its post-filter position
83
+ * during row construction for archive-write — re-stamping here too would be
84
+ * redundant.
85
+ */
86
+ function applyFilter(parsedLines, filter) {
87
+ const predicate = makePredicate(filter);
88
+ const kept = [];
89
+ for (const line of parsedLines) {
90
+ if (!predicate(line))
91
+ continue;
92
+ kept.push(line);
93
+ }
94
+ return kept;
95
+ }
96
+ function makePredicate(filter) {
97
+ if (filter.kind === "all")
98
+ return () => true;
99
+ if (filter.kind === "senders") {
100
+ const allow = new Set(filter.senders);
101
+ return (line) => allow.has(line.senderName);
102
+ }
103
+ // date-range: inclusive on both ends. Date-only endpoints widen to whole-
104
+ // day semantics: `from=YYYY-MM-DD` → `T00:00:00Z`, `to=YYYY-MM-DD` →
105
+ // `T23:59:59.999Z`. Full ISO 8601 endpoints with `T` are passed through.
106
+ // Without this widening, `--filter date-range=2024-01-01..2024-06-30`
107
+ // would silently drop every message later than 2024-06-30T00:00:00Z on the
108
+ // last day — a UX trap that contradicts the operator's reading.
109
+ const fromMs = parseRangeEndpoint(filter.fromIso, "start");
110
+ const toMs = parseRangeEndpoint(filter.toIso, "end");
111
+ return (line) => {
112
+ const ms = Date.parse(line.dateSent);
113
+ return ms >= fromMs && ms <= toMs;
114
+ };
115
+ }
116
+ function parseRangeEndpoint(iso, edge) {
117
+ if (/T/.test(iso))
118
+ return Date.parse(iso);
119
+ // Date-only — widen to whole-day inclusive on the requested edge.
120
+ const suffix = edge === "start" ? "T00:00:00.000Z" : "T23:59:59.999Z";
121
+ return Date.parse(iso + suffix);
122
+ }
123
+ //# sourceMappingURL=filter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filter.js","sourceRoot":"","sources":["../src/filter.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,7 @@
1
1
  export { parseExport } from "./parse-export.js";
2
2
  export type { ParseExportInput, ParseExportResult, ParseExportCounters, ParsedLine, } from "./parse-export.js";
3
+ export { parseFilterArg, applyFilter } from "./filter.js";
4
+ export type { Filter } from "./filter.js";
5
+ export { normaliseSenderName, sha256Hex, deriveMessageId, observationContentHash, } from "./derive-keys.js";
6
+ export type { DeriveMessageIdInput, ObservationContentFields, } from "./derive-keys.js";
3
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EACV,gBAAgB,EAChB,iBAAiB,EACjB,mBAAmB,EACnB,UAAU,GACX,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EACV,gBAAgB,EAChB,iBAAiB,EACjB,mBAAmB,EACnB,UAAU,GACX,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1D,YAAY,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EACL,mBAAmB,EACnB,SAAS,EACT,eAAe,EACf,sBAAsB,GACvB,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACV,oBAAoB,EACpB,wBAAwB,GACzB,MAAM,kBAAkB,CAAC"}
@@ -1,6 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.parseExport = void 0;
3
+ exports.observationContentHash = exports.deriveMessageId = exports.sha256Hex = exports.normaliseSenderName = exports.applyFilter = exports.parseFilterArg = exports.parseExport = void 0;
4
4
  var parse_export_js_1 = require("./parse-export.js");
5
5
  Object.defineProperty(exports, "parseExport", { enumerable: true, get: function () { return parse_export_js_1.parseExport; } });
6
+ var filter_js_1 = require("./filter.js");
7
+ Object.defineProperty(exports, "parseFilterArg", { enumerable: true, get: function () { return filter_js_1.parseFilterArg; } });
8
+ Object.defineProperty(exports, "applyFilter", { enumerable: true, get: function () { return filter_js_1.applyFilter; } });
9
+ var derive_keys_js_1 = require("./derive-keys.js");
10
+ Object.defineProperty(exports, "normaliseSenderName", { enumerable: true, get: function () { return derive_keys_js_1.normaliseSenderName; } });
11
+ Object.defineProperty(exports, "sha256Hex", { enumerable: true, get: function () { return derive_keys_js_1.sha256Hex; } });
12
+ Object.defineProperty(exports, "deriveMessageId", { enumerable: true, get: function () { return derive_keys_js_1.deriveMessageId; } });
13
+ Object.defineProperty(exports, "observationContentHash", { enumerable: true, get: function () { return derive_keys_js_1.observationContentHash; } });
6
14
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,qDAAgD;AAAvC,8GAAA,WAAW,OAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,qDAAgD;AAAvC,8GAAA,WAAW,OAAA;AAOpB,yCAA0D;AAAjD,2GAAA,cAAc,OAAA;AAAE,wGAAA,WAAW,OAAA;AAEpC,mDAK0B;AAJxB,qHAAA,mBAAmB,OAAA;AACnB,2GAAA,SAAS,OAAA;AACT,iHAAA,eAAe,OAAA;AACf,wHAAA,sBAAsB,OAAA"}
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { parseFilterArg, applyFilter } from "../filter.js";
9
+ import type { ParsedLine } from "../parse-export.js";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // filter-gate — Task 871 contract: --filter is mandatory, three forms.
13
+ //
14
+ // The unit tests cover the helper module's pure-function surface (parse +
15
+ // apply). The integration test spawns ingest.mjs as a subprocess and
16
+ // asserts the no-flag invocation exits non-zero with the LOUD-FAIL line
17
+ // (`[whatsapp-ingest] FAIL phase=argv reason="--filter is required …"`)
18
+ // before any Neo4j connection — argv parsing fires before imports/sessions.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ // src/__tests__/ → ../../bin/ingest.mjs (up to src/, up to lib/, up to plugin
23
+ // root, then bin/).
24
+ const INGEST_MJS = resolve(__dirname, "..", "..", "..", "bin", "ingest.mjs");
25
+
26
+ function lines(parsed: ParsedLine[]): string[] {
27
+ return parsed.map((p) => `${p.senderName}@${p.dateSent}:${p.body}`);
28
+ }
29
+
30
+ const SAMPLE: ParsedLine[] = [
31
+ { senderName: "Alice", dateSent: "2024-01-15T09:00:00+00:00", body: "morning", sequenceIndex: 0 },
32
+ { senderName: "Bob", dateSent: "2024-01-15T09:05:00+00:00", body: "hi", sequenceIndex: 1 },
33
+ { senderName: "Alice", dateSent: "2024-03-20T11:00:00+00:00", body: "lunch?", sequenceIndex: 2 },
34
+ { senderName: "Carol", dateSent: "2024-06-30T23:59:59+00:00: ", body: "bye", sequenceIndex: 3 },
35
+ { senderName: "Bob", dateSent: "2024-07-01T00:00:01+00:00", body: "next", sequenceIndex: 4 },
36
+ ];
37
+
38
+ // Repair the malformed dateSent fixture (extra ':' suffix would Date.parse to
39
+ // NaN). Keep this in code, not in the const above, so the fixture reads
40
+ // natural while we still hold a strict tuple.
41
+ SAMPLE[3] = { ...SAMPLE[3], dateSent: "2024-06-30T23:59:59+00:00" };
42
+
43
+ describe("parseFilterArg — happy path", () => {
44
+ it('parses --filter all', () => {
45
+ expect(parseFilterArg("all")).toEqual({ kind: "all" });
46
+ });
47
+
48
+ it('parses senders=<csv> with whitespace tolerance', () => {
49
+ expect(parseFilterArg("senders=Alice, Bob Carter , Carol")).toEqual({
50
+ kind: "senders",
51
+ senders: ["Alice", "Bob Carter", "Carol"],
52
+ });
53
+ });
54
+
55
+ it('parses date-range=<from>..<to>', () => {
56
+ expect(parseFilterArg("date-range=2024-01-01..2024-06-30")).toEqual({
57
+ kind: "date-range",
58
+ fromIso: "2024-01-01",
59
+ toIso: "2024-06-30",
60
+ });
61
+ });
62
+ });
63
+
64
+ describe("parseFilterArg — LOUD-FAIL on malformed input", () => {
65
+ it("rejects undefined / empty", () => {
66
+ expect(() => parseFilterArg(undefined)).toThrow(/--filter is required/);
67
+ expect(() => parseFilterArg("")).toThrow(/--filter is required/);
68
+ expect(() => parseFilterArg(" ")).toThrow(/--filter is required/);
69
+ });
70
+
71
+ it("rejects unknown form", () => {
72
+ expect(() => parseFilterArg("everyone")).toThrow(/unrecognised form/);
73
+ expect(() => parseFilterArg("nope=yes")).toThrow(/unrecognised form/);
74
+ });
75
+
76
+ it("rejects senders= with no names", () => {
77
+ expect(() => parseFilterArg("senders=")).toThrow(/at least one/);
78
+ expect(() => parseFilterArg("senders=,,,")).toThrow(/at least one/);
79
+ });
80
+
81
+ it("rejects date-range with malformed shape or unparseable ISO", () => {
82
+ expect(() => parseFilterArg("date-range=2024-01-01")).toThrow(/<isoFrom>\.\.<isoTo>/);
83
+ expect(() => parseFilterArg("date-range=..2024-06-30")).toThrow(/both endpoints/);
84
+ expect(() => parseFilterArg("date-range=2024-01-01..")).toThrow(/both endpoints/);
85
+ expect(() => parseFilterArg("date-range=not-a-date..2024-06-30")).toThrow(/not parseable/);
86
+ expect(() => parseFilterArg("date-range=2024-06-30..2024-01-01")).toThrow(/later than/);
87
+ });
88
+ });
89
+
90
+ describe("applyFilter — semantics", () => {
91
+ it("--filter all keeps every row, preserves the parser's original sequenceIndex", () => {
92
+ const out = applyFilter(SAMPLE, parseFilterArg("all"));
93
+ expect(out.length).toBe(SAMPLE.length);
94
+ expect(out.map((l) => l.sequenceIndex)).toEqual([0, 1, 2, 3, 4]);
95
+ });
96
+
97
+ it("--filter senders=Alice,Bob keeps only those senders, sequenceIndex stays as the parser-stamped value (Carol's index 3 drops out)", () => {
98
+ const out = applyFilter(SAMPLE, parseFilterArg("senders=Alice,Bob"));
99
+ expect(lines(out)).toEqual([
100
+ "Alice@2024-01-15T09:00:00+00:00:morning",
101
+ "Bob@2024-01-15T09:05:00+00:00:hi",
102
+ "Alice@2024-03-20T11:00:00+00:00:lunch?",
103
+ "Bob@2024-07-01T00:00:01+00:00:next",
104
+ ]);
105
+ expect(out.map((l) => l.sequenceIndex)).toEqual([0, 1, 2, 4]);
106
+ });
107
+
108
+ it("--filter date-range clips inclusively on both ends", () => {
109
+ const out = applyFilter(SAMPLE, parseFilterArg("date-range=2024-01-01..2024-06-30"));
110
+ // 2024-06-30T23:59:59 IS inclusive; 2024-07-01 is NOT.
111
+ expect(out.map((l) => `${l.senderName}/${l.dateSent}`)).toEqual([
112
+ "Alice/2024-01-15T09:00:00+00:00",
113
+ "Bob/2024-01-15T09:05:00+00:00",
114
+ "Alice/2024-03-20T11:00:00+00:00",
115
+ "Carol/2024-06-30T23:59:59+00:00",
116
+ ]);
117
+ });
118
+
119
+ it("--filter date-range with full ISO 8601 endpoints", () => {
120
+ const out = applyFilter(
121
+ SAMPLE,
122
+ parseFilterArg("date-range=2024-03-01T00:00:00Z..2024-06-15T00:00:00Z"),
123
+ );
124
+ expect(out).toHaveLength(1);
125
+ expect(out[0].senderName).toBe("Alice");
126
+ expect(out[0].body).toBe("lunch?");
127
+ });
128
+
129
+ it("an unmatched senders filter returns []", () => {
130
+ const out = applyFilter(SAMPLE, parseFilterArg("senders=Nobody"));
131
+ expect(out).toEqual([]);
132
+ });
133
+ });
134
+
135
+ describe("ingest.mjs — missing --filter exits non-zero with LOUD-FAIL line", () => {
136
+ it("emits [whatsapp-ingest] FAIL phase=argv reason=\"--filter required ...\" before touching Neo4j", () => {
137
+ // Write a stub _chat.txt; argv parse fires first and rejects the call
138
+ // before resolveChatTxt/Neo4j connection. We do NOT need a real archive
139
+ // to test the gate.
140
+ const work = mkdtempSync(join(tmpdir(), "filter-gate-"));
141
+ try {
142
+ const stub = join(work, "_chat.txt");
143
+ writeFileSync(stub, "[14/03/26, 10:15:23] Joel: stub\n");
144
+
145
+ const res = spawnSync(
146
+ "node",
147
+ [
148
+ INGEST_MJS,
149
+ stub,
150
+ "--owner-element-id",
151
+ "stub-owner",
152
+ "--scope",
153
+ "admin",
154
+ ],
155
+ { encoding: "utf8" },
156
+ );
157
+
158
+ expect(res.status).not.toBe(0);
159
+ const stderr = res.stderr ?? "";
160
+ expect(stderr).toMatch(/\[whatsapp-ingest\] FAIL phase=argv/);
161
+ expect(stderr).toMatch(/reason="--filter is required/);
162
+ // Confirm the gate fired BEFORE any Neo4j or import work — no
163
+ // [whatsapp-ingest] start line, no archive-write log line.
164
+ expect(stderr).not.toMatch(/\[whatsapp-ingest\] start /);
165
+ expect(stderr).not.toMatch(/\[memory-archive-write\]/);
166
+ } finally {
167
+ rmSync(work, { recursive: true, force: true });
168
+ }
169
+ });
170
+ });