@dxos/pipeline-email 0.0.0

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 (147) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +15 -0
  3. package/dist/lib/neutral/index.mjs +650 -0
  4. package/dist/lib/neutral/index.mjs.map +7 -0
  5. package/dist/lib/neutral/meta.json +1 -0
  6. package/dist/lib/neutral/testing/index.mjs +48 -0
  7. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  8. package/dist/types/src/corpus/digest.d.ts +50 -0
  9. package/dist/types/src/corpus/digest.d.ts.map +1 -0
  10. package/dist/types/src/corpus/digest.test.d.ts +2 -0
  11. package/dist/types/src/corpus/digest.test.d.ts.map +1 -0
  12. package/dist/types/src/corpus/index.d.ts +6 -0
  13. package/dist/types/src/corpus/index.d.ts.map +1 -0
  14. package/dist/types/src/corpus/ledger.d.ts +33 -0
  15. package/dist/types/src/corpus/ledger.d.ts.map +1 -0
  16. package/dist/types/src/corpus/ledger.test.d.ts +2 -0
  17. package/dist/types/src/corpus/ledger.test.d.ts.map +1 -0
  18. package/dist/types/src/corpus/prompts.d.ts +21 -0
  19. package/dist/types/src/corpus/prompts.d.ts.map +1 -0
  20. package/dist/types/src/corpus/prompts.test.d.ts +2 -0
  21. package/dist/types/src/corpus/prompts.test.d.ts.map +1 -0
  22. package/dist/types/src/corpus/rollups.d.ts +21 -0
  23. package/dist/types/src/corpus/rollups.d.ts.map +1 -0
  24. package/dist/types/src/corpus/rollups.test.d.ts +2 -0
  25. package/dist/types/src/corpus/rollups.test.d.ts.map +1 -0
  26. package/dist/types/src/corpus/topics.d.ts +46 -0
  27. package/dist/types/src/corpus/topics.d.ts.map +1 -0
  28. package/dist/types/src/corpus/topics.test.d.ts +2 -0
  29. package/dist/types/src/corpus/topics.test.d.ts.map +1 -0
  30. package/dist/types/src/fact-pipeline.d.ts +26 -0
  31. package/dist/types/src/fact-pipeline.d.ts.map +1 -0
  32. package/dist/types/src/fact-pipeline.test.d.ts +2 -0
  33. package/dist/types/src/fact-pipeline.test.d.ts.map +1 -0
  34. package/dist/types/src/index.d.ts +6 -0
  35. package/dist/types/src/index.d.ts.map +1 -0
  36. package/dist/types/src/internal/fact-index.d.ts +8 -0
  37. package/dist/types/src/internal/fact-index.d.ts.map +1 -0
  38. package/dist/types/src/internal/fact-index.test.d.ts +2 -0
  39. package/dist/types/src/internal/fact-index.test.d.ts.map +1 -0
  40. package/dist/types/src/internal/threading.d.ts +4 -0
  41. package/dist/types/src/internal/threading.d.ts.map +1 -0
  42. package/dist/types/src/internal/threading.test.d.ts +2 -0
  43. package/dist/types/src/internal/threading.test.d.ts.map +1 -0
  44. package/dist/types/src/internal/threads.d.ts +9 -0
  45. package/dist/types/src/internal/threads.d.ts.map +1 -0
  46. package/dist/types/src/internal/threads.test.d.ts +2 -0
  47. package/dist/types/src/internal/threads.test.d.ts.map +1 -0
  48. package/dist/types/src/pipeline.d.ts +36 -0
  49. package/dist/types/src/pipeline.d.ts.map +1 -0
  50. package/dist/types/src/pipeline.test.d.ts +2 -0
  51. package/dist/types/src/pipeline.test.d.ts.map +1 -0
  52. package/dist/types/src/stages/context.d.ts +40 -0
  53. package/dist/types/src/stages/context.d.ts.map +1 -0
  54. package/dist/types/src/stages/extract-contacts.d.ts +6 -0
  55. package/dist/types/src/stages/extract-contacts.d.ts.map +1 -0
  56. package/dist/types/src/stages/extract-facts-commit.d.ts +13 -0
  57. package/dist/types/src/stages/extract-facts-commit.d.ts.map +1 -0
  58. package/dist/types/src/stages/extract-facts-commit.test.d.ts +2 -0
  59. package/dist/types/src/stages/extract-facts-commit.test.d.ts.map +1 -0
  60. package/dist/types/src/stages/extract-facts.d.ts +6 -0
  61. package/dist/types/src/stages/extract-facts.d.ts.map +1 -0
  62. package/dist/types/src/stages/extract-facts.test.d.ts +2 -0
  63. package/dist/types/src/stages/extract-facts.test.d.ts.map +1 -0
  64. package/dist/types/src/stages/facts.d.ts +7 -0
  65. package/dist/types/src/stages/facts.d.ts.map +1 -0
  66. package/dist/types/src/stages/facts.test.d.ts +2 -0
  67. package/dist/types/src/stages/facts.test.d.ts.map +1 -0
  68. package/dist/types/src/stages/index.d.ts +8 -0
  69. package/dist/types/src/stages/index.d.ts.map +1 -0
  70. package/dist/types/src/stages/stats.d.ts +6 -0
  71. package/dist/types/src/stages/stats.d.ts.map +1 -0
  72. package/dist/types/src/stages/stats.test.d.ts +2 -0
  73. package/dist/types/src/stages/stats.test.d.ts.map +1 -0
  74. package/dist/types/src/stages/summarize.d.ts +7 -0
  75. package/dist/types/src/stages/summarize.d.ts.map +1 -0
  76. package/dist/types/src/testing/email-extraction.bench.test.d.ts +2 -0
  77. package/dist/types/src/testing/email-extraction.bench.test.d.ts.map +1 -0
  78. package/dist/types/src/testing/email-fixtures.d.ts +25 -0
  79. package/dist/types/src/testing/email-fixtures.d.ts.map +1 -0
  80. package/dist/types/src/testing/email-pipeline.test.d.ts +2 -0
  81. package/dist/types/src/testing/email-pipeline.test.d.ts.map +1 -0
  82. package/dist/types/src/testing/index.d.ts +2 -0
  83. package/dist/types/src/testing/index.d.ts.map +1 -0
  84. package/dist/types/src/testing/parquet-email.test.d.ts +2 -0
  85. package/dist/types/src/testing/parquet-email.test.d.ts.map +1 -0
  86. package/dist/types/src/testing/parquet.d.ts +20 -0
  87. package/dist/types/src/testing/parquet.d.ts.map +1 -0
  88. package/dist/types/src/testing/parquet.test.d.ts +2 -0
  89. package/dist/types/src/testing/parquet.test.d.ts.map +1 -0
  90. package/dist/types/src/types/Thread.d.ts +18 -0
  91. package/dist/types/src/types/Thread.d.ts.map +1 -0
  92. package/dist/types/src/types/Thread.test.d.ts +2 -0
  93. package/dist/types/src/types/Thread.test.d.ts.map +1 -0
  94. package/dist/types/src/types/Topic.d.ts +12 -0
  95. package/dist/types/src/types/Topic.d.ts.map +1 -0
  96. package/dist/types/src/types/Topic.test.d.ts +2 -0
  97. package/dist/types/src/types/Topic.test.d.ts.map +1 -0
  98. package/dist/types/src/types/index.d.ts +3 -0
  99. package/dist/types/src/types/index.d.ts.map +1 -0
  100. package/dist/types/tsconfig.tsbuildinfo +1 -0
  101. package/package.json +58 -0
  102. package/src/corpus/digest.test.ts +78 -0
  103. package/src/corpus/digest.ts +92 -0
  104. package/src/corpus/index.ts +9 -0
  105. package/src/corpus/ledger.test.ts +85 -0
  106. package/src/corpus/ledger.ts +73 -0
  107. package/src/corpus/prompts.test.ts +24 -0
  108. package/src/corpus/prompts.ts +43 -0
  109. package/src/corpus/rollups.test.ts +50 -0
  110. package/src/corpus/rollups.ts +65 -0
  111. package/src/corpus/topics.test.ts +90 -0
  112. package/src/corpus/topics.ts +211 -0
  113. package/src/fact-pipeline.test.ts +63 -0
  114. package/src/fact-pipeline.ts +51 -0
  115. package/src/index.ts +9 -0
  116. package/src/internal/fact-index.test.ts +54 -0
  117. package/src/internal/fact-index.ts +43 -0
  118. package/src/internal/threading.test.ts +28 -0
  119. package/src/internal/threading.ts +27 -0
  120. package/src/internal/threads.test.ts +76 -0
  121. package/src/internal/threads.ts +73 -0
  122. package/src/pipeline.test.ts +74 -0
  123. package/src/pipeline.ts +76 -0
  124. package/src/stages/context.ts +32 -0
  125. package/src/stages/extract-contacts.ts +25 -0
  126. package/src/stages/extract-facts-commit.test.ts +75 -0
  127. package/src/stages/extract-facts-commit.ts +44 -0
  128. package/src/stages/extract-facts.test.ts +74 -0
  129. package/src/stages/extract-facts.ts +26 -0
  130. package/src/stages/facts.test.ts +40 -0
  131. package/src/stages/facts.ts +37 -0
  132. package/src/stages/index.ts +11 -0
  133. package/src/stages/stats.test.ts +38 -0
  134. package/src/stages/stats.ts +35 -0
  135. package/src/stages/summarize.ts +76 -0
  136. package/src/testing/email-extraction.bench.test.ts +117 -0
  137. package/src/testing/email-fixtures.ts +62 -0
  138. package/src/testing/email-pipeline.test.ts +483 -0
  139. package/src/testing/index.ts +8 -0
  140. package/src/testing/parquet-email.test.ts +114 -0
  141. package/src/testing/parquet.test.ts +73 -0
  142. package/src/testing/parquet.ts +82 -0
  143. package/src/types/Thread.test.ts +29 -0
  144. package/src/types/Thread.ts +26 -0
  145. package/src/types/Topic.test.ts +25 -0
  146. package/src/types/Topic.ts +20 -0
  147. package/src/types/index.ts +6 -0
@@ -0,0 +1,114 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+ import * as Stream from 'effect/Stream';
7
+ import { readdir } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import { describe, test } from 'vitest';
10
+
11
+ import { EffectEx } from '@dxos/effect';
12
+ import { Pipeline, Stage } from '@dxos/pipeline';
13
+ import { captureSink } from '@dxos/pipeline/testing';
14
+ import { Message } from '@dxos/types';
15
+
16
+ import { emailToMessage } from './email-fixtures';
17
+ import { type ParquetRow, parquetSource } from './parquet';
18
+
19
+ // The email dataset (https://huggingface.co/datasets/corbt/enron-emails) is exposed via ROOT_DIR;
20
+ // its layout is `${ROOT_DIR}/data/train-*.parquet`.
21
+ const ROOT_DIR = process.env.ROOT_DIR;
22
+
23
+ type SenderCount = { readonly sender: string; readonly count: number };
24
+
25
+ describe('email parquet → Message', () => {
26
+ test('maps an email row to a Message with a text body block', ({ expect }) => {
27
+ const row: ParquetRow = {
28
+ message_id: '<abc@example.com>',
29
+ subject: 'Hello',
30
+ from: 'alice@example.com',
31
+ to: ['bob@example.com'],
32
+ cc: [],
33
+ bcc: [],
34
+ date: new Date('2020-01-02T03:04:05.000Z'),
35
+ body: 'Body text.',
36
+ file_name: 'inbox/1.',
37
+ };
38
+
39
+ const message = emailToMessage(row);
40
+ expect(message.sender.email).toBe('alice@example.com');
41
+ expect(message.created).toBe('2020-01-02T03:04:05.000Z');
42
+ expect(Message.extractText(message)).toBe('Body text.');
43
+ expect(message.properties?.subject).toBe('Hello');
44
+ expect(message.properties?.to).toEqual(['bob@example.com']);
45
+ expect(message.properties?.messageId).toBe('<abc@example.com>');
46
+ });
47
+
48
+ test('a pipeline stage counts emails per sender', async ({ expect }) => {
49
+ const rows: ParquetRow[] = [
50
+ { from: 'alice@example.com', body: 'one' },
51
+ { from: 'bob@example.com', body: 'two' },
52
+ { from: 'alice@example.com', body: 'three' },
53
+ { from: 'carol@example.com', body: 'four' },
54
+ ];
55
+
56
+ const { sink, items } = captureSink<SenderCount>();
57
+ await EffectEx.runPromise(Stream.fromIterable(rows).pipe(countBySenderStage<never>(), Pipeline.run({ sink })));
58
+
59
+ // The stage emits a running count per email; the final tally is the last count seen per sender.
60
+ const totals = new Map(items.map(({ sender, count }) => [sender, count]));
61
+ expect(totals.get('alice@example.com')).toBe(2);
62
+ expect(totals.get('bob@example.com')).toBe(1);
63
+ expect(totals.get('carol@example.com')).toBe(1);
64
+ });
65
+
66
+ // Runs only when ROOT_DIR points at the local dataset; skipped in CI so the suite stays green.
67
+ describe.skipIf(!ROOT_DIR)('local dataset', () => {
68
+ test('reads shards and converts the first rows to Messages', async ({ expect }) => {
69
+ // Unreachable when unset (guarded by describe.skipIf); narrows to string without a cast.
70
+ const rootDir = ROOT_DIR;
71
+ if (!rootDir) {
72
+ return;
73
+ }
74
+
75
+ const dataDir = join(rootDir, 'data');
76
+ const files = (await readdir(dataDir))
77
+ .filter((name) => /^train-.*\.parquet$/.test(name))
78
+ .sort()
79
+ .map((name) => join(dataDir, name));
80
+ expect(files.length).toBeGreaterThan(0);
81
+
82
+ const messages = await EffectEx.runPromise(
83
+ parquetSource(files).pipe(
84
+ Stream.take(5),
85
+ Stream.map(emailToMessage),
86
+ Stream.runCollect,
87
+ Effect.map((chunk) => [...chunk]),
88
+ ),
89
+ );
90
+
91
+ expect(messages).toHaveLength(5);
92
+ for (const message of messages) {
93
+ expect(typeof message.sender.email).toBe('string');
94
+ expect(message.blocks.some((block) => block._tag === 'text')).toBe(true);
95
+ expect(message.properties).toBeDefined();
96
+ }
97
+ });
98
+ });
99
+ });
100
+
101
+ // A pipeline stage that maintains a running count of emails per sender, emitting the updated count
102
+ // for each email's sender as it flows through (a stateful scan; the counter map is bounded by the
103
+ // number of distinct senders, not the stream length).
104
+ const countBySenderStage =
105
+ <E = never>(): Stage.Stage<ParquetRow, SenderCount, E> =>
106
+ (input) =>
107
+ input.pipe(
108
+ Stream.mapAccum(new Map<string, number>(), (counts, row) => {
109
+ const sender = String(row.from ?? '');
110
+ const count = (counts.get(sender) ?? 0) + 1;
111
+ counts.set(sender, count);
112
+ return [counts, { sender, count }];
113
+ }),
114
+ );
@@ -0,0 +1,73 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+ import * as Stream from 'effect/Stream';
7
+ import { parquetWriteFile } from 'hyparquet-writer';
8
+ import { mkdtemp, rm } from 'node:fs/promises';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { afterAll, beforeAll, describe, test } from 'vitest';
12
+
13
+ import { EffectEx } from '@dxos/effect';
14
+
15
+ import { ParquetReadError, type ParquetRow, parquetSource } from './parquet';
16
+
17
+ // git clone https://huggingface.co/datasets/corbt/enron-emails
18
+
19
+ describe('parquetSource', () => {
20
+ let dir: string;
21
+ let fileA: string;
22
+ let fileB: string;
23
+
24
+ beforeAll(async () => {
25
+ dir = await mkdtemp(join(tmpdir(), 'dxos-pipeline-parquet-'));
26
+ fileA = join(dir, 'a.parquet');
27
+ fileB = join(dir, 'b.parquet');
28
+ // rowGroupSize forces multiple row groups in fileA so the lazy per-group path is exercised.
29
+ parquetWriteFile({
30
+ filename: fileA,
31
+ columnData: [
32
+ { name: 'id', data: [1, 2, 3, 4], type: 'INT32' },
33
+ { name: 'name', data: ['a', 'b', 'c', 'd'], type: 'STRING' },
34
+ ],
35
+ rowGroupSize: 2,
36
+ });
37
+ parquetWriteFile({
38
+ filename: fileB,
39
+ columnData: [
40
+ { name: 'id', data: [5], type: 'INT32' },
41
+ { name: 'name', data: ['e'], type: 'STRING' },
42
+ ],
43
+ });
44
+ });
45
+
46
+ afterAll(async () => {
47
+ await rm(dir, { recursive: true, force: true });
48
+ });
49
+
50
+ test('streams rows across files in order, row group by row group', async ({ expect }) => {
51
+ const rows = await collect([fileA, fileB]);
52
+ expect(rows.map((row) => row.id)).toEqual([1, 2, 3, 4, 5]);
53
+ expect(rows.map((row) => row.name)).toEqual(['a', 'b', 'c', 'd', 'e']);
54
+ });
55
+
56
+ test('fails with ParquetReadError for a missing file', async ({ expect }) => {
57
+ const result = await EffectEx.runPromise(
58
+ parquetSource([join(dir, 'missing.parquet')]).pipe(Stream.runCollect, Effect.either),
59
+ );
60
+ expect(result._tag).toBe('Left');
61
+ if (result._tag === 'Left') {
62
+ expect(result.left).toBeInstanceOf(ParquetReadError);
63
+ }
64
+ });
65
+ });
66
+
67
+ const collect = (files: readonly string[]): Promise<readonly ParquetRow[]> =>
68
+ EffectEx.runPromise(
69
+ parquetSource(files).pipe(
70
+ Stream.runCollect,
71
+ Effect.map((chunk) => [...chunk]),
72
+ ),
73
+ );
@@ -0,0 +1,82 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Data from 'effect/Data';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Stream from 'effect/Stream';
8
+ import { type AsyncBuffer, parquetMetadataAsync, parquetReadObjects } from 'hyparquet';
9
+ import { open } from 'node:fs/promises';
10
+
11
+ /** A decoded parquet row, keyed by column name. */
12
+ export type ParquetRow = Record<string, unknown>;
13
+
14
+ /** Failure opening, parsing, or decoding a parquet file; carries the offending path. */
15
+ export class ParquetReadError extends Data.TaggedError('ParquetReadError')<{
16
+ readonly file: string;
17
+ readonly cause: unknown;
18
+ }> {}
19
+
20
+ type RowGroupRange = { readonly rowStart: number; readonly rowEnd: number };
21
+
22
+ /**
23
+ * A pipeline source over a set of parquet files. Emits decoded rows as a back-pressured stream, one
24
+ * row group at a time, so decoded-row memory stays bounded regardless of file size. Files are read
25
+ * in the given order; within a file, rows follow row-group order.
26
+ */
27
+ export const parquetSource = (files: readonly string[]): Stream.Stream<ParquetRow, ParquetReadError> =>
28
+ Stream.fromIterable(files).pipe(Stream.flatMap(fileRows, { concurrency: 1 }));
29
+
30
+ // Absolute [rowStart, rowEnd) ranges, one per row group; `num_rows` is a bigint in the metadata.
31
+ const rowGroupRanges = (metadata: Awaited<ReturnType<typeof parquetMetadataAsync>>): RowGroupRange[] => {
32
+ const ranges: RowGroupRange[] = [];
33
+ let rowStart = 0;
34
+ for (const group of metadata.row_groups) {
35
+ const rowEnd = rowStart + Number(group.num_rows);
36
+ ranges.push({ rowStart, rowEnd });
37
+ rowStart = rowEnd;
38
+ }
39
+ return ranges;
40
+ };
41
+
42
+ // One file's rows, decoded a row group at a time. The fs handle is scoped to the stream (opened on
43
+ // start, closed on completion/interruption via acquireRelease + unwrapScoped); the AsyncBuffer
44
+ // slices byte ranges on demand so only the bytes a row group needs are read.
45
+ const fileRows = (file: string): Stream.Stream<ParquetRow, ParquetReadError> =>
46
+ Stream.unwrapScoped(
47
+ Effect.gen(function* () {
48
+ const handle = yield* Effect.acquireRelease(
49
+ Effect.tryPromise({ try: () => open(file, 'r'), catch: (cause) => new ParquetReadError({ file, cause }) }),
50
+ // `tryPromise` + `ignore` so a `close()` rejection is a swallowed failure, not a scope-killing defect.
51
+ (handle) => Effect.tryPromise(() => handle.close()).pipe(Effect.ignore),
52
+ );
53
+ const { size } = yield* Effect.tryPromise({
54
+ try: () => handle.stat(),
55
+ catch: (cause) => new ParquetReadError({ file, cause }),
56
+ });
57
+ const asyncBuffer: AsyncBuffer = {
58
+ byteLength: size,
59
+ slice: async (start, end = size) => {
60
+ // Ranges hyparquet requests are always within [0, byteLength) (derived from the metadata),
61
+ // so the read fills the whole view; no short-read handling is needed.
62
+ const view = new Uint8Array(end - start);
63
+ await handle.read(view, 0, view.byteLength, start);
64
+ return view.buffer;
65
+ },
66
+ };
67
+ const metadata = yield* Effect.tryPromise({
68
+ try: () => parquetMetadataAsync(asyncBuffer),
69
+ catch: (cause) => new ParquetReadError({ file, cause }),
70
+ });
71
+ return Stream.fromIterable(rowGroupRanges(metadata)).pipe(
72
+ Stream.mapEffect((range) =>
73
+ Effect.tryPromise({
74
+ try: () =>
75
+ parquetReadObjects({ file: asyncBuffer, metadata, rowStart: range.rowStart, rowEnd: range.rowEnd }),
76
+ catch: (cause) => new ParquetReadError({ file, cause }),
77
+ }),
78
+ ),
79
+ Stream.flattenIterables,
80
+ );
81
+ }),
82
+ );
@@ -0,0 +1,29 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { Obj } from '@dxos/echo';
8
+
9
+ import { Thread } from './Thread';
10
+
11
+ describe('Thread', () => {
12
+ test('constructs a Thread object with expected fields', ({ expect }) => {
13
+ const thread = Obj.make(Thread, {
14
+ threadId: 'deal terms',
15
+ subject: 'Deal terms',
16
+ summary: 'Negotiation over Q2 deal terms.',
17
+ state: 'awaiting-mine',
18
+ participants: ['a@x.com', 'b@x.com'],
19
+ messageIds: ['<m-1@x.com>', '<m-2@x.com>'],
20
+ openQuestions: ['What is the close date?'],
21
+ actionItems: ['Send revised terms'],
22
+ });
23
+
24
+ expect(Obj.instanceOf(Thread, thread)).toBe(true);
25
+ expect(thread.threadId).toBe('deal terms');
26
+ expect(thread.state).toBe('awaiting-mine');
27
+ expect(thread.participants).toHaveLength(2);
28
+ });
29
+ });
@@ -0,0 +1,26 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Schema from 'effect/Schema';
6
+
7
+ import { DXN, Type } from '@dxos/echo';
8
+
9
+ export const ThreadState = Schema.Literal('awaiting-mine', 'awaiting-theirs', 'resolved', 'stalled');
10
+ export type ThreadState = Schema.Schema.Type<typeof ThreadState>;
11
+
12
+ // A canonical (spec §4) conversation aggregate: messages sharing a derived threadId, with a rolling
13
+ // summary, a coarse state, and consolidated open questions / action items. Authoritative in ECHO;
14
+ // facts are advisory evidence, not the source of this object's state.
15
+ export class Thread extends Type.makeObject<Thread>(DXN.make('org.dxos.type.emailThread', '0.1.0'))(
16
+ Schema.Struct({
17
+ threadId: Schema.String,
18
+ subject: Schema.String,
19
+ summary: Schema.String,
20
+ state: ThreadState,
21
+ participants: Schema.Array(Schema.String),
22
+ messageIds: Schema.Array(Schema.String),
23
+ openQuestions: Schema.Array(Schema.String),
24
+ actionItems: Schema.Array(Schema.String),
25
+ }),
26
+ ) {}
@@ -0,0 +1,25 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { Obj } from '@dxos/echo';
8
+
9
+ import { Topic } from './Topic';
10
+
11
+ describe('Topic', () => {
12
+ test('constructs a Topic object with expected fields', ({ expect }) => {
13
+ const topic = Obj.make(Topic, {
14
+ label: 'Q2 reporting',
15
+ summary: 'Threads about the Q2 report and budget confirmation.',
16
+ threadIds: ['q2 report', 'budget numbers'],
17
+ participants: ['alice@enron.com', 'bob@enron.com'],
18
+ keywords: ['q2', 'report', 'budget'],
19
+ });
20
+
21
+ expect(Obj.instanceOf(Topic, topic)).toBe(true);
22
+ expect(topic.threadIds).toHaveLength(2);
23
+ expect(topic.label).toBe('Q2 reporting');
24
+ });
25
+ });
@@ -0,0 +1,20 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Schema from 'effect/Schema';
6
+
7
+ import { DXN, Type } from '@dxos/echo';
8
+
9
+ // A canonical (spec §4) meta-thread: a cluster of related conversations discovered at the corpus
10
+ // layer, with an optional LLM label/summary. Authoritative in ECHO; string-keyed thread references
11
+ // (like Thread.messageIds) until the entity layer firms up to DXN refs.
12
+ export class Topic extends Type.makeObject<Topic>(DXN.make('org.dxos.type.emailTopic', '0.1.0'))(
13
+ Schema.Struct({
14
+ label: Schema.String,
15
+ summary: Schema.String,
16
+ threadIds: Schema.Array(Schema.String),
17
+ participants: Schema.Array(Schema.String),
18
+ keywords: Schema.Array(Schema.String),
19
+ }),
20
+ ) {}
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './Thread';
6
+ export * from './Topic';