@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.
- package/LICENSE +105 -0
- package/README.md +15 -0
- package/dist/lib/neutral/index.mjs +650 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/neutral/testing/index.mjs +48 -0
- package/dist/lib/neutral/testing/index.mjs.map +7 -0
- package/dist/types/src/corpus/digest.d.ts +50 -0
- package/dist/types/src/corpus/digest.d.ts.map +1 -0
- package/dist/types/src/corpus/digest.test.d.ts +2 -0
- package/dist/types/src/corpus/digest.test.d.ts.map +1 -0
- package/dist/types/src/corpus/index.d.ts +6 -0
- package/dist/types/src/corpus/index.d.ts.map +1 -0
- package/dist/types/src/corpus/ledger.d.ts +33 -0
- package/dist/types/src/corpus/ledger.d.ts.map +1 -0
- package/dist/types/src/corpus/ledger.test.d.ts +2 -0
- package/dist/types/src/corpus/ledger.test.d.ts.map +1 -0
- package/dist/types/src/corpus/prompts.d.ts +21 -0
- package/dist/types/src/corpus/prompts.d.ts.map +1 -0
- package/dist/types/src/corpus/prompts.test.d.ts +2 -0
- package/dist/types/src/corpus/prompts.test.d.ts.map +1 -0
- package/dist/types/src/corpus/rollups.d.ts +21 -0
- package/dist/types/src/corpus/rollups.d.ts.map +1 -0
- package/dist/types/src/corpus/rollups.test.d.ts +2 -0
- package/dist/types/src/corpus/rollups.test.d.ts.map +1 -0
- package/dist/types/src/corpus/topics.d.ts +46 -0
- package/dist/types/src/corpus/topics.d.ts.map +1 -0
- package/dist/types/src/corpus/topics.test.d.ts +2 -0
- package/dist/types/src/corpus/topics.test.d.ts.map +1 -0
- package/dist/types/src/fact-pipeline.d.ts +26 -0
- package/dist/types/src/fact-pipeline.d.ts.map +1 -0
- package/dist/types/src/fact-pipeline.test.d.ts +2 -0
- package/dist/types/src/fact-pipeline.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +6 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/internal/fact-index.d.ts +8 -0
- package/dist/types/src/internal/fact-index.d.ts.map +1 -0
- package/dist/types/src/internal/fact-index.test.d.ts +2 -0
- package/dist/types/src/internal/fact-index.test.d.ts.map +1 -0
- package/dist/types/src/internal/threading.d.ts +4 -0
- package/dist/types/src/internal/threading.d.ts.map +1 -0
- package/dist/types/src/internal/threading.test.d.ts +2 -0
- package/dist/types/src/internal/threading.test.d.ts.map +1 -0
- package/dist/types/src/internal/threads.d.ts +9 -0
- package/dist/types/src/internal/threads.d.ts.map +1 -0
- package/dist/types/src/internal/threads.test.d.ts +2 -0
- package/dist/types/src/internal/threads.test.d.ts.map +1 -0
- package/dist/types/src/pipeline.d.ts +36 -0
- package/dist/types/src/pipeline.d.ts.map +1 -0
- package/dist/types/src/pipeline.test.d.ts +2 -0
- package/dist/types/src/pipeline.test.d.ts.map +1 -0
- package/dist/types/src/stages/context.d.ts +40 -0
- package/dist/types/src/stages/context.d.ts.map +1 -0
- package/dist/types/src/stages/extract-contacts.d.ts +6 -0
- package/dist/types/src/stages/extract-contacts.d.ts.map +1 -0
- package/dist/types/src/stages/extract-facts-commit.d.ts +13 -0
- package/dist/types/src/stages/extract-facts-commit.d.ts.map +1 -0
- package/dist/types/src/stages/extract-facts-commit.test.d.ts +2 -0
- package/dist/types/src/stages/extract-facts-commit.test.d.ts.map +1 -0
- package/dist/types/src/stages/extract-facts.d.ts +6 -0
- package/dist/types/src/stages/extract-facts.d.ts.map +1 -0
- package/dist/types/src/stages/extract-facts.test.d.ts +2 -0
- package/dist/types/src/stages/extract-facts.test.d.ts.map +1 -0
- package/dist/types/src/stages/facts.d.ts +7 -0
- package/dist/types/src/stages/facts.d.ts.map +1 -0
- package/dist/types/src/stages/facts.test.d.ts +2 -0
- package/dist/types/src/stages/facts.test.d.ts.map +1 -0
- package/dist/types/src/stages/index.d.ts +8 -0
- package/dist/types/src/stages/index.d.ts.map +1 -0
- package/dist/types/src/stages/stats.d.ts +6 -0
- package/dist/types/src/stages/stats.d.ts.map +1 -0
- package/dist/types/src/stages/stats.test.d.ts +2 -0
- package/dist/types/src/stages/stats.test.d.ts.map +1 -0
- package/dist/types/src/stages/summarize.d.ts +7 -0
- package/dist/types/src/stages/summarize.d.ts.map +1 -0
- package/dist/types/src/testing/email-extraction.bench.test.d.ts +2 -0
- package/dist/types/src/testing/email-extraction.bench.test.d.ts.map +1 -0
- package/dist/types/src/testing/email-fixtures.d.ts +25 -0
- package/dist/types/src/testing/email-fixtures.d.ts.map +1 -0
- package/dist/types/src/testing/email-pipeline.test.d.ts +2 -0
- package/dist/types/src/testing/email-pipeline.test.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +2 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/parquet-email.test.d.ts +2 -0
- package/dist/types/src/testing/parquet-email.test.d.ts.map +1 -0
- package/dist/types/src/testing/parquet.d.ts +20 -0
- package/dist/types/src/testing/parquet.d.ts.map +1 -0
- package/dist/types/src/testing/parquet.test.d.ts +2 -0
- package/dist/types/src/testing/parquet.test.d.ts.map +1 -0
- package/dist/types/src/types/Thread.d.ts +18 -0
- package/dist/types/src/types/Thread.d.ts.map +1 -0
- package/dist/types/src/types/Thread.test.d.ts +2 -0
- package/dist/types/src/types/Thread.test.d.ts.map +1 -0
- package/dist/types/src/types/Topic.d.ts +12 -0
- package/dist/types/src/types/Topic.d.ts.map +1 -0
- package/dist/types/src/types/Topic.test.d.ts +2 -0
- package/dist/types/src/types/Topic.test.d.ts.map +1 -0
- package/dist/types/src/types/index.d.ts +3 -0
- package/dist/types/src/types/index.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +58 -0
- package/src/corpus/digest.test.ts +78 -0
- package/src/corpus/digest.ts +92 -0
- package/src/corpus/index.ts +9 -0
- package/src/corpus/ledger.test.ts +85 -0
- package/src/corpus/ledger.ts +73 -0
- package/src/corpus/prompts.test.ts +24 -0
- package/src/corpus/prompts.ts +43 -0
- package/src/corpus/rollups.test.ts +50 -0
- package/src/corpus/rollups.ts +65 -0
- package/src/corpus/topics.test.ts +90 -0
- package/src/corpus/topics.ts +211 -0
- package/src/fact-pipeline.test.ts +63 -0
- package/src/fact-pipeline.ts +51 -0
- package/src/index.ts +9 -0
- package/src/internal/fact-index.test.ts +54 -0
- package/src/internal/fact-index.ts +43 -0
- package/src/internal/threading.test.ts +28 -0
- package/src/internal/threading.ts +27 -0
- package/src/internal/threads.test.ts +76 -0
- package/src/internal/threads.ts +73 -0
- package/src/pipeline.test.ts +74 -0
- package/src/pipeline.ts +76 -0
- package/src/stages/context.ts +32 -0
- package/src/stages/extract-contacts.ts +25 -0
- package/src/stages/extract-facts-commit.test.ts +75 -0
- package/src/stages/extract-facts-commit.ts +44 -0
- package/src/stages/extract-facts.test.ts +74 -0
- package/src/stages/extract-facts.ts +26 -0
- package/src/stages/facts.test.ts +40 -0
- package/src/stages/facts.ts +37 -0
- package/src/stages/index.ts +11 -0
- package/src/stages/stats.test.ts +38 -0
- package/src/stages/stats.ts +35 -0
- package/src/stages/summarize.ts +76 -0
- package/src/testing/email-extraction.bench.test.ts +117 -0
- package/src/testing/email-fixtures.ts +62 -0
- package/src/testing/email-pipeline.test.ts +483 -0
- package/src/testing/index.ts +8 -0
- package/src/testing/parquet-email.test.ts +114 -0
- package/src/testing/parquet.test.ts +73 -0
- package/src/testing/parquet.ts +82 -0
- package/src/types/Thread.test.ts +29 -0
- package/src/types/Thread.ts +26 -0
- package/src/types/Topic.test.ts +25 -0
- package/src/types/Topic.ts +20 -0
- 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
|
+
) {}
|