@rubytech/create-realagent 1.0.826 → 1.0.828

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/neo4j/schema.cypher +34 -2
  3. package/payload/platform/plugins/admin/hooks/archive-ingest-surface-gate.sh +19 -13
  4. package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +5 -5
  5. package/payload/platform/plugins/docs/references/cloudflare.md +1 -1
  6. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  7. package/payload/platform/plugins/docs/references/troubleshooting.md +1 -0
  8. package/payload/platform/plugins/memory/PLUGIN.md +1 -1
  9. package/payload/platform/plugins/memory/mcp/dist/index.js +6 -41
  10. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  11. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/llm-classifier.test.js +51 -0
  12. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/llm-classifier.test.js.map +1 -1
  13. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.d.ts +19 -4
  14. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.d.ts.map +1 -1
  15. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.js +139 -56
  16. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.js.map +1 -1
  17. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest.test.d.ts +2 -0
  18. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest.test.d.ts.map +1 -0
  19. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest.test.js +61 -0
  20. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest.test.js.map +1 -0
  21. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts +34 -0
  22. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
  23. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +241 -0
  24. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
  25. package/payload/platform/plugins/memory/references/schema-base.md +5 -2
  26. package/payload/platform/plugins/whatsapp-import/PLUGIN.md +17 -15
  27. package/payload/platform/plugins/whatsapp-import/bin/ingest.mjs +313 -366
  28. package/payload/platform/plugins/whatsapp-import/bin/whatsapp-ingest.sh +27 -60
  29. package/payload/platform/plugins/whatsapp-import/lib/dist/delta-cursor.d.ts +18 -0
  30. package/payload/platform/plugins/whatsapp-import/lib/dist/delta-cursor.d.ts.map +1 -0
  31. package/payload/platform/plugins/whatsapp-import/lib/dist/delta-cursor.js +31 -0
  32. package/payload/platform/plugins/whatsapp-import/lib/dist/delta-cursor.js.map +1 -0
  33. package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.d.ts +27 -12
  34. package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.d.ts.map +1 -1
  35. package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.js +40 -20
  36. package/payload/platform/plugins/whatsapp-import/lib/dist/derive-keys.js.map +1 -1
  37. package/payload/platform/plugins/whatsapp-import/lib/dist/index.d.ts +7 -4
  38. package/payload/platform/plugins/whatsapp-import/lib/dist/index.d.ts.map +1 -1
  39. package/payload/platform/plugins/whatsapp-import/lib/dist/index.js +9 -6
  40. package/payload/platform/plugins/whatsapp-import/lib/dist/index.js.map +1 -1
  41. package/payload/platform/plugins/whatsapp-import/lib/dist/sessionize.d.ts +25 -0
  42. package/payload/platform/plugins/whatsapp-import/lib/dist/sessionize.d.ts.map +1 -0
  43. package/payload/platform/plugins/whatsapp-import/lib/dist/sessionize.js +48 -0
  44. package/payload/platform/plugins/whatsapp-import/lib/dist/sessionize.js.map +1 -0
  45. package/payload/platform/plugins/whatsapp-import/lib/dist/to-classifier-input.d.ts +3 -0
  46. package/payload/platform/plugins/whatsapp-import/lib/dist/to-classifier-input.d.ts.map +1 -0
  47. package/payload/platform/plugins/whatsapp-import/lib/dist/to-classifier-input.js +47 -0
  48. package/payload/platform/plugins/whatsapp-import/lib/dist/to-classifier-input.js.map +1 -0
  49. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/delta-append.test.ts +163 -0
  50. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/sessionize.test.ts +91 -0
  51. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/to-classifier-input.test.ts +59 -0
  52. package/payload/platform/plugins/whatsapp-import/lib/src/delta-cursor.ts +54 -0
  53. package/payload/platform/plugins/whatsapp-import/lib/src/derive-keys.ts +55 -32
  54. package/payload/platform/plugins/whatsapp-import/lib/src/index.ts +9 -6
  55. package/payload/platform/plugins/whatsapp-import/lib/src/sessionize.ts +81 -0
  56. package/payload/platform/plugins/whatsapp-import/lib/src/to-classifier-input.ts +48 -0
  57. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md +66 -73
  58. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/conversation-archive-shape.md +143 -0
  59. package/payload/platform/templates/specialists/agents/database-operator.md +10 -11
  60. package/payload/server/chunk-T2OPNP3L.js +654 -0
  61. package/payload/server/cloudflare-task-tracker-CR6TL4VL.js +19 -0
  62. package/payload/server/public/assets/{admin-DOkUspG1.js → admin-BNwPsMhJ.js} +2 -2
  63. package/payload/server/public/assets/{graph-LLMJa4Ch.js → graph-N_Bw-8oT.js} +1 -1
  64. package/payload/server/public/assets/{page-DoaF3DB0.js → page-BKLGP-th.js} +1 -1
  65. package/payload/server/public/graph.html +2 -2
  66. package/payload/server/public/index.html +2 -2
  67. package/payload/server/server.js +277 -164
  68. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/filter-gate.test.ts +0 -172
  69. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/ingest-idempotence.test.ts +0 -141
  70. package/payload/platform/plugins/whatsapp-import/lib/src/filter.ts +0 -136
  71. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import-enrich/SKILL.md +0 -333
@@ -1,172 +0,0 @@
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
- "--subject-person-id",
153
- "stub-subject",
154
- "--scope",
155
- "admin",
156
- ],
157
- { encoding: "utf8" },
158
- );
159
-
160
- expect(res.status).not.toBe(0);
161
- const stderr = res.stderr ?? "";
162
- expect(stderr).toMatch(/\[whatsapp-ingest\] FAIL phase=argv/);
163
- expect(stderr).toMatch(/reason="--filter is required/);
164
- // Confirm the gate fired BEFORE any Neo4j or import work — no
165
- // [whatsapp-ingest] start line, no archive-write log line.
166
- expect(stderr).not.toMatch(/\[whatsapp-ingest\] start /);
167
- expect(stderr).not.toMatch(/\[memory-archive-write\]/);
168
- } finally {
169
- rmSync(work, { recursive: true, force: true });
170
- }
171
- });
172
- });
@@ -1,141 +0,0 @@
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
- });
@@ -1,136 +0,0 @@
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
- }