@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
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@dxos/pipeline-email",
3
+ "version": "0.0.0",
4
+ "description": "Worked email pipeline over the Enron dataset (parquet source → summarize/extract/stats stages).",
5
+ "homepage": "https://dxos.org",
6
+ "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
11
+ "license": "FSL-1.1-Apache-2.0",
12
+ "author": "info@dxos.org",
13
+ "sideEffects": false,
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "default": "./dist/lib/neutral/index.mjs",
18
+ "source": "./src/index.ts",
19
+ "types": "./dist/types/src/index.d.ts"
20
+ },
21
+ "./testing": {
22
+ "default": "./dist/lib/neutral/testing/index.mjs",
23
+ "source": "./src/testing/index.ts",
24
+ "types": "./dist/types/src/testing/index.d.ts"
25
+ }
26
+ },
27
+ "types": "dist/types/src/index.d.ts",
28
+ "files": [
29
+ "dist",
30
+ "src"
31
+ ],
32
+ "dependencies": {
33
+ "@effect/ai": "0.36.0",
34
+ "hyparquet": "^1.26.2",
35
+ "@dxos/ai": "0.10.0",
36
+ "@dxos/extractor-lib": "0.10.0",
37
+ "@dxos/echo": "0.10.0",
38
+ "@dxos/node-std": "0.10.0",
39
+ "@dxos/pipeline-rdf": "0.0.0",
40
+ "@dxos/util": "0.10.0",
41
+ "@dxos/pipeline": "0.9.0",
42
+ "@dxos/types": "0.10.0"
43
+ },
44
+ "devDependencies": {
45
+ "effect": "3.21.4",
46
+ "hyparquet-writer": "^0.16.1",
47
+ "vitest": "4.1.8",
48
+ "@dxos/echo-client": "0.10.0",
49
+ "@dxos/effect": "0.10.0",
50
+ "@dxos/log": "0.10.0"
51
+ },
52
+ "peerDependencies": {
53
+ "effect": "3.21.4"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }
@@ -0,0 +1,78 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { Message } from '@dxos/types';
8
+
9
+ import { buildThreads } from '../internal/threads';
10
+ import { buildDigest, narrateDigest, renderDigest } from './digest';
11
+ import { type Commitment } from './ledger';
12
+ import { buildRollups } from './rollups';
13
+ import { clusterThreads } from './topics';
14
+
15
+ const OWNER = 'me@enron.com';
16
+ const NOW = '2001-06-01T00:00:00.000Z';
17
+
18
+ const COMMITMENTS: Commitment[] = [
19
+ { who: 'alice@enron.com', what: 'Q2 report', dueBy: '2001-05-18', factId: 'f1', source: '<m1>' },
20
+ { who: 'bob@enron.com', what: 'budget', factId: 'f2', source: '<m2>' },
21
+ ];
22
+
23
+ describe('digest', () => {
24
+ test('builds a deterministic skeleton', ({ expect }) => {
25
+ const digest = buildDigest(input(), { now: NOW });
26
+ expect(digest.generatedAt).toBe(NOW);
27
+ expect(digest.threadCount).toBe(2);
28
+ expect(digest.topicCount).toBeGreaterThan(0);
29
+ expect(digest.stalledThreads).toContain('old deal terms');
30
+ expect(digest.awaitingMine).toContain('q2 report');
31
+ // Only the commitment with a deadline is due.
32
+ expect(digest.dueCommitments).toHaveLength(1);
33
+ expect(digest.dueCommitments[0].what).toBe('Q2 report');
34
+ expect(digest.topTopics.length).toBeGreaterThan(0);
35
+ expect(digest.narrative).toBe('');
36
+ });
37
+
38
+ test('renderDigest emits the skeleton as text', ({ expect }) => {
39
+ const text = renderDigest(buildDigest(input(), { now: NOW }));
40
+ expect(text).toContain('Threads: 2');
41
+ expect(text).toContain('old deal terms');
42
+ expect(text).toContain('Q2 report');
43
+ });
44
+
45
+ test('narrateDigest fills the narrative via the summarizer and degrades on failure', async ({ expect }) => {
46
+ const digest = buildDigest(input(), { now: NOW });
47
+ const narrated = await narrateDigest(digest, async () => 'All quiet except the Q2 report.');
48
+ expect(narrated.narrative).toBe('All quiet except the Q2 report.');
49
+
50
+ const degraded = await narrateDigest(digest, async () => {
51
+ throw new Error('model down');
52
+ });
53
+ expect(degraded.narrative).toBe('');
54
+ });
55
+ });
56
+
57
+ const msg = (subject: string, from: string, created: string) =>
58
+ Message.make({
59
+ created,
60
+ sender: { email: from },
61
+ blocks: [{ _tag: 'text', text: 'body' }],
62
+ properties: { subject, messageId: `<${from}:${created}>` },
63
+ });
64
+
65
+ const input = () => {
66
+ const messages = [
67
+ // Recent thread awaiting my reply; older thread long stalled.
68
+ msg('Q2 report', 'alice@enron.com', '2001-05-31T10:00:00.000Z'),
69
+ msg('Old deal terms', 'bob@enron.com', '2001-04-01T10:00:00.000Z'),
70
+ ];
71
+ const threads = buildThreads(messages, { ownerEmail: OWNER, now: NOW });
72
+ return {
73
+ threads,
74
+ topics: clusterThreads(threads),
75
+ commitments: COMMITMENTS,
76
+ rollups: buildRollups(messages),
77
+ };
78
+ };
@@ -0,0 +1,92 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Thread } from '../types';
6
+ import { type Commitment } from './ledger';
7
+ import { DEFAULT_EMAIL_PROMPTS, type EmailPrompts, type Summarizer, mergePrompts } from './prompts';
8
+ import { type RelationshipRollup } from './rollups';
9
+ import { type TopicDraft } from './topics';
10
+
11
+ /**
12
+ * Everything the corpus pass has computed; the digest is a Query-mode view over it (spec §3③
13
+ * briefings) — assembled on demand, never materialized as ECHO.
14
+ */
15
+ export type DigestInput = {
16
+ readonly threads: readonly Thread[];
17
+ readonly topics: readonly TopicDraft[];
18
+ readonly commitments: readonly Commitment[];
19
+ readonly rollups: readonly RelationshipRollup[];
20
+ };
21
+
22
+ export type DigestOptions = {
23
+ /** Timestamp the digest reports as its generation time (passed in for determinism). */
24
+ readonly now: string;
25
+ /** Topics listed in the skeleton (largest first). */
26
+ readonly maxTopTopics?: number;
27
+ };
28
+
29
+ /** Deterministic digest skeleton; `narrative` stays empty until {@link narrateDigest} fills it. */
30
+ export type Digest = {
31
+ readonly generatedAt: string;
32
+ readonly threadCount: number;
33
+ readonly topicCount: number;
34
+ /** Threads needing attention. */
35
+ readonly stalledThreads: readonly string[];
36
+ readonly awaitingMine: readonly string[];
37
+ /** Commitments carrying a deadline, soonest first. */
38
+ readonly dueCommitments: readonly Commitment[];
39
+ readonly topTopics: readonly { readonly label: string; readonly threadCount: number }[];
40
+ /** LLM briefing text; empty when narration is off or degraded. */
41
+ readonly narrative: string;
42
+ };
43
+
44
+ /** Assemble the deterministic digest skeleton from the corpus outputs (Compute mode; no LLM). */
45
+ export const buildDigest = (input: DigestInput, options: DigestOptions): Digest => ({
46
+ generatedAt: options.now,
47
+ threadCount: input.threads.length,
48
+ topicCount: input.topics.length,
49
+ stalledThreads: input.threads.filter((thread) => thread.state === 'stalled').map((thread) => thread.threadId),
50
+ awaitingMine: input.threads.filter((thread) => thread.state === 'awaiting-mine').map((thread) => thread.threadId),
51
+ dueCommitments: input.commitments
52
+ .filter((commitment) => commitment.dueBy !== undefined)
53
+ .sort((a, b) => (a.dueBy ?? '').localeCompare(b.dueBy ?? '')),
54
+ topTopics: [...input.topics]
55
+ .sort((a, b) => b.threadIds.length - a.threadIds.length || a.label.localeCompare(b.label))
56
+ .slice(0, options.maxTopTopics ?? 5)
57
+ .map((topic) => ({ label: topic.label, threadCount: topic.threadIds.length })),
58
+ narrative: '',
59
+ });
60
+
61
+ /** Render the skeleton as plain text — the prompt payload for narration, and a fallback briefing. */
62
+ export const renderDigest = (digest: Digest): string =>
63
+ [
64
+ `Inbox digest (${digest.generatedAt})`,
65
+ `Threads: ${digest.threadCount} Topics: ${digest.topicCount}`,
66
+ `Awaiting my reply: ${digest.awaitingMine.join(', ') || 'none'}`,
67
+ `Stalled: ${digest.stalledThreads.join(', ') || 'none'}`,
68
+ `Due commitments: ${
69
+ digest.dueCommitments
70
+ .map((commitment) => `${commitment.who} → ${commitment.what} (${commitment.dueBy})`)
71
+ .join('; ') || 'none'
72
+ }`,
73
+ `Top topics: ${digest.topTopics.map((topic) => `${topic.label} (${topic.threadCount})`).join('; ') || 'none'}`,
74
+ ].join('\n');
75
+
76
+ /**
77
+ * Optionally add an LLM briefing over the skeleton, using the editable `digest` prompt. Degrades to
78
+ * the un-narrated digest on failure — the skeleton is always available.
79
+ */
80
+ export const narrateDigest = async (
81
+ digest: Digest,
82
+ summarize: Summarizer,
83
+ options?: { readonly prompts?: Partial<EmailPrompts> },
84
+ ): Promise<Digest> => {
85
+ const prompts = options?.prompts ? mergePrompts(options.prompts) : DEFAULT_EMAIL_PROMPTS;
86
+ try {
87
+ const narrative = await summarize([prompts.digest, renderDigest(digest)].join('\n\n'));
88
+ return { ...digest, narrative: narrative.trim() };
89
+ } catch {
90
+ return digest;
91
+ }
92
+ };
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './topics';
6
+ export * from './ledger';
7
+ export * from './rollups';
8
+ export * from './digest';
9
+ export * from './prompts';
@@ -0,0 +1,85 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+ import * as ManagedRuntime from 'effect/ManagedRuntime';
7
+ import { afterAll, describe, test } from 'vitest';
8
+
9
+ import { FactStore, type RDF, normalizeEntityId } from '@dxos/pipeline-rdf';
10
+
11
+ import { commitmentLedger } from './ledger';
12
+
13
+ const runtime = ManagedRuntime.make(FactStore.layerMemory);
14
+
15
+ describe('commitmentLedger', () => {
16
+ afterAll(async () => {
17
+ await runtime.dispose();
18
+ });
19
+
20
+ test('collects commitment facts by predicate, ignoring plain statements', async ({ expect }) => {
21
+ await runtime.runPromise(
22
+ Effect.gen(function* () {
23
+ const store = yield* FactStore;
24
+ yield* store.putFacts(FACTS);
25
+ }),
26
+ );
27
+
28
+ const commitments = await runtime.runPromise(
29
+ Effect.gen(function* () {
30
+ const store = yield* FactStore;
31
+ return yield* commitmentLedger(store);
32
+ }),
33
+ );
34
+
35
+ expect(commitments).toHaveLength(2);
36
+ const send = commitments.find((commitment) => commitment.what === 'Q2 report');
37
+ expect(send?.who).toBe('alice@enron.com');
38
+ expect(send?.dueBy).toBe('2001-05-18');
39
+ expect(send?.source).toBe('<m1@enron.com>');
40
+ expect(commitments.some((commitment) => commitment.what === 'Enron')).toBe(false);
41
+ });
42
+
43
+ test('custom predicates narrow the ledger', async ({ expect }) => {
44
+ const commitments = await runtime.runPromise(
45
+ Effect.gen(function* () {
46
+ const store = yield* FactStore;
47
+ return yield* commitmentLedger(store, { predicates: ['owes'] });
48
+ }),
49
+ );
50
+ expect(commitments).toHaveLength(1);
51
+ expect(commitments[0].who).toBe('bob@enron.com');
52
+ });
53
+ });
54
+
55
+ // Hand-built facts: two commitments (one with a deadline) and one plain statement.
56
+ const fact = (
57
+ id: string,
58
+ subject: string,
59
+ predicate: string,
60
+ object: string,
61
+ extra?: { validTo?: string; confidence?: number },
62
+ ): RDF.Fact => ({
63
+ id,
64
+ assertion: {
65
+ subject: { entity: normalizeEntityId(subject), label: subject },
66
+ predicate,
67
+ object: { entity: normalizeEntityId(object), label: object },
68
+ ...(extra?.validTo ? { validTo: extra.validTo } : {}),
69
+ },
70
+ factuality: {
71
+ value: 'CT+',
72
+ polarity: '+',
73
+ ...(extra?.confidence !== undefined ? { confidence: extra.confidence } : {}),
74
+ },
75
+ attribution: { source: `<${id}@enron.com>`, generatedAtTime: '2001-05-01T10:00:00.000Z' },
76
+ recordedAt: '2001-05-01T10:00:00.000Z',
77
+ extractor: { id: 'test', model: 'test', version: '1' },
78
+ sourceHash: `h-${id}`,
79
+ });
80
+
81
+ const FACTS: RDF.Fact[] = [
82
+ fact('m1', 'alice@enron.com', 'will send', 'Q2 report', { validTo: '2001-05-18' }),
83
+ fact('m2', 'bob@enron.com', 'owes', 'budget confirmation'),
84
+ fact('m3', 'alice@enron.com', 'works at', 'Enron'),
85
+ ];
@@ -0,0 +1,73 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import { type FactStoreApi, type RDF, type SemanticIndexError } from '@dxos/pipeline-rdf';
8
+
9
+ /**
10
+ * Predicates treated as commitments (spec §3③ commitment ledger). Editable: extraction phrasing
11
+ * varies by corpus and extraction rules, so callers tune this list rather than the query logic.
12
+ */
13
+ export const DEFAULT_COMMITMENT_PREDICATES: readonly string[] = ['owes', 'will send', 'will provide', 'must', 'due'];
14
+
15
+ export type LedgerOptions = {
16
+ /** Predicates that mark a fact as a commitment. */
17
+ readonly predicates?: readonly string[];
18
+ };
19
+
20
+ /** One row of the commitment ledger, traceable to its supporting fact. */
21
+ export type Commitment = {
22
+ /** Who owes it (fact subject label). */
23
+ readonly who: string;
24
+ /** What is owed (fact object label or literal). */
25
+ readonly what: string;
26
+ /** Deadline (assertion validTo), when stated. */
27
+ readonly dueBy?: string;
28
+ /** Model confidence carried from the fact's factuality. */
29
+ readonly confidence?: number;
30
+ readonly factId: string;
31
+ /** Source message (fact attribution). */
32
+ readonly source: string;
33
+ };
34
+
35
+ const termLabel = (term: RDF.Assertion['subject']): string =>
36
+ 'entity' in term ? (term.label ?? term.entity) : term.literal;
37
+
38
+ const toCommitment = (fact: RDF.Fact): Commitment => ({
39
+ who: termLabel(fact.assertion.subject),
40
+ what: termLabel(fact.assertion.object),
41
+ ...(fact.assertion.validTo ? { dueBy: fact.assertion.validTo } : {}),
42
+ ...(fact.factuality.confidence !== undefined ? { confidence: fact.factuality.confidence } : {}),
43
+ factId: fact.id,
44
+ source: fact.attribution.source,
45
+ });
46
+
47
+ /**
48
+ * Query the fact store for outstanding commitments — the spec's cross-thread "owe / owed" ledger.
49
+ * Uses the structured query path (one `query({ predicate })` per commitment predicate, compiled to
50
+ * SPARQL on the sqlite backend), which works on both store backends; rows are advisory evidence
51
+ * (§4 canonicality) and each carries its fact id + source message for grounding.
52
+ */
53
+ export const commitmentLedger = (
54
+ store: FactStoreApi,
55
+ options?: LedgerOptions,
56
+ ): Effect.Effect<Commitment[], SemanticIndexError> =>
57
+ Effect.forEach(options?.predicates ?? DEFAULT_COMMITMENT_PREDICATES, (predicate) => store.query({ predicate })).pipe(
58
+ Effect.map((results) => {
59
+ // A fact can match several predicate probes; keep the first occurrence.
60
+ const seen = new Set<string>();
61
+ const commitments: Commitment[] = [];
62
+ for (const fact of results.flat()) {
63
+ if (!seen.has(fact.id)) {
64
+ seen.add(fact.id);
65
+ commitments.push(toCommitment(fact));
66
+ }
67
+ }
68
+ // Deadlines first (soonest first), then the rest in stable fact order.
69
+ return commitments.sort((a, b) =>
70
+ a.dueBy && b.dueBy ? a.dueBy.localeCompare(b.dueBy) : a.dueBy ? -1 : b.dueBy ? 1 : 0,
71
+ );
72
+ }),
73
+ );
@@ -0,0 +1,24 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { DEFAULT_EMAIL_PROMPTS, mergePrompts } from './prompts';
8
+
9
+ describe('prompts', () => {
10
+ test('defaults cover every prompt slot', ({ expect }) => {
11
+ expect(DEFAULT_EMAIL_PROMPTS.topicSummary.length).toBeGreaterThan(0);
12
+ expect(DEFAULT_EMAIL_PROMPTS.digest.length).toBeGreaterThan(0);
13
+ });
14
+
15
+ test('mergePrompts overrides only the given slots', ({ expect }) => {
16
+ const merged = mergePrompts({ digest: 'Custom digest prompt.' });
17
+ expect(merged.digest).toBe('Custom digest prompt.');
18
+ expect(merged.topicSummary).toBe(DEFAULT_EMAIL_PROMPTS.topicSummary);
19
+ });
20
+
21
+ test('mergePrompts with no overrides returns the defaults', ({ expect }) => {
22
+ expect(mergePrompts()).toEqual(DEFAULT_EMAIL_PROMPTS);
23
+ });
24
+ });
@@ -0,0 +1,43 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { trim } from '@dxos/util';
6
+
7
+ /**
8
+ * User-editable prompts for the LLM-assisted corpus algorithms. Every slot has a default; callers
9
+ * override any subset via {@link mergePrompts}, so the discovery algorithms stay configurable
10
+ * without forking the pipeline. (Extraction rules are configured separately, via pipeline-rdf
11
+ * `ExtractOptions.rules`.)
12
+ */
13
+ export type EmailPrompts = {
14
+ /** Summarize one topic (cluster of threads); receives the topic label + per-thread summaries. */
15
+ readonly topicSummary: string;
16
+ /** Narrate a corpus digest; receives the deterministic digest skeleton. */
17
+ readonly digest: string;
18
+ };
19
+
20
+ export const DEFAULT_EMAIL_PROMPTS: EmailPrompts = {
21
+ topicSummary: trim`
22
+ Summarize the following email conversation topic in 1-2 sentences.
23
+ Focus on what the threads have in common and any outcome or open question.
24
+ Respond with ONLY the summary text.
25
+ `,
26
+ digest: trim`
27
+ Write a short briefing (3-5 sentences) from the following inbox digest data.
28
+ Lead with what needs attention (stalled threads, due commitments), then notable topics.
29
+ Respond with ONLY the briefing text.
30
+ `,
31
+ };
32
+
33
+ /** Overlay caller overrides onto the defaults; absent slots keep their default. */
34
+ export const mergePrompts = (overrides?: Partial<EmailPrompts>): EmailPrompts => ({
35
+ ...DEFAULT_EMAIL_PROMPTS,
36
+ ...overrides,
37
+ });
38
+
39
+ /**
40
+ * LLM call boundary used by the corpus algorithms: the harness owns model/runtime plumbing and hands
41
+ * modules a plain async closure (same pattern as `FactIndexer`), so algorithm Effects stay R = never.
42
+ */
43
+ export type Summarizer = (prompt: string) => Promise<string>;
@@ -0,0 +1,50 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { Message } from '@dxos/types';
8
+
9
+ import { buildRollups } from './rollups';
10
+
11
+ describe('buildRollups', () => {
12
+ test('aggregates per sender: counts, contact span, cadence, threads', ({ expect }) => {
13
+ const rollups = buildRollups([
14
+ msg('Q2 report', 'Alice@Enron.com', '2001-05-01T10:00:00.000Z'),
15
+ msg('RE: Q2 report', 'alice@enron.com', '2001-05-03T10:00:00.000Z'),
16
+ msg('Budget', 'alice@enron.com', '2001-05-05T10:00:00.000Z'),
17
+ msg('Lunch', 'bob@enron.com', '2001-05-02T10:00:00.000Z'),
18
+ ]);
19
+
20
+ expect(rollups).toHaveLength(2);
21
+ // Sorted by message count, so alice first; mixed casing folds into one sender.
22
+ const [alice, bob] = rollups;
23
+ expect(alice.email).toBe('alice@enron.com');
24
+ expect(alice.messageCount).toBe(3);
25
+ expect(alice.firstContact).toBe('2001-05-01T10:00:00.000Z');
26
+ expect(alice.lastContact).toBe('2001-05-05T10:00:00.000Z');
27
+ expect(alice.cadenceDays).toBe(2);
28
+ expect(alice.threadIds).toEqual(['q2 report', 'budget']);
29
+
30
+ expect(bob.messageCount).toBe(1);
31
+ expect(bob.cadenceDays).toBeUndefined();
32
+ });
33
+
34
+ test('skips messages without a sender email', ({ expect }) => {
35
+ const anonymous = Message.make({
36
+ created: '2001-05-01T10:00:00.000Z',
37
+ sender: { name: 'Mystery' },
38
+ blocks: [{ _tag: 'text', text: 'body' }],
39
+ });
40
+ expect(buildRollups([anonymous])).toHaveLength(0);
41
+ });
42
+ });
43
+
44
+ const msg = (subject: string, from: string, created: string) =>
45
+ Message.make({
46
+ created,
47
+ sender: { email: from },
48
+ blocks: [{ _tag: 'text', text: 'body' }],
49
+ properties: { subject, messageId: `<${from}:${created}>` },
50
+ });
@@ -0,0 +1,65 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { Message } from '@dxos/types';
6
+
7
+ import { deriveThreadId } from '../internal/threading';
8
+
9
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
10
+
11
+ /**
12
+ * Per-sender relationship aggregate (spec §3③ relationship rollups). Computed values, not ECHO
13
+ * mutations: `Person` is a closed, canonical schema the pipeline must not write onto, so rollups are
14
+ * consumed by the digest / callers (join back to a Person via the lowercased email and the
15
+ * fact-index reconciliation table); materialization waits for a proper schema home.
16
+ */
17
+ export type RelationshipRollup = {
18
+ /** Lowercased sender address (email addresses are case-insensitive). */
19
+ readonly email: string;
20
+ readonly messageCount: number;
21
+ readonly firstContact: string;
22
+ readonly lastContact: string;
23
+ /** Mean days between messages; absent below two messages. */
24
+ readonly cadenceDays?: number;
25
+ /** Threads this sender participated in (send-side), in first-seen order. */
26
+ readonly threadIds: readonly string[];
27
+ };
28
+
29
+ /** Aggregate messages into per-sender rollups, most active sender first. Deterministic (Compute mode). */
30
+ export const buildRollups = (messages: readonly Message.Message[]): RelationshipRollup[] => {
31
+ const bySender = new Map<string, { created: string[]; threadIds: string[] }>();
32
+ for (const message of messages) {
33
+ const email = message.sender.email?.toLowerCase();
34
+ if (!email) {
35
+ continue;
36
+ }
37
+ let entry = bySender.get(email);
38
+ if (!entry) {
39
+ entry = { created: [], threadIds: [] };
40
+ bySender.set(email, entry);
41
+ }
42
+ entry.created.push(message.created);
43
+ const threadId = deriveThreadId(message);
44
+ if (!entry.threadIds.includes(threadId)) {
45
+ entry.threadIds.push(threadId);
46
+ }
47
+ }
48
+
49
+ const rollups: RelationshipRollup[] = [];
50
+ for (const [email, entry] of bySender) {
51
+ const ordered = [...entry.created].sort();
52
+ const first = ordered[0];
53
+ const last = ordered[ordered.length - 1];
54
+ const spanMs = new Date(last).getTime() - new Date(first).getTime();
55
+ rollups.push({
56
+ email,
57
+ messageCount: ordered.length,
58
+ firstContact: first,
59
+ lastContact: last,
60
+ ...(ordered.length >= 2 ? { cadenceDays: spanMs / (ordered.length - 1) / MS_PER_DAY } : {}),
61
+ threadIds: entry.threadIds,
62
+ });
63
+ }
64
+ return rollups.sort((a, b) => b.messageCount - a.messageCount || a.email.localeCompare(b.email));
65
+ };
@@ -0,0 +1,90 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { Obj } from '@dxos/echo';
8
+ import { Message } from '@dxos/types';
9
+
10
+ import { buildThreads } from '../internal/threads';
11
+ import { Topic } from '../types';
12
+ import { clusterThreads, materializeTopics, summarizeTopics } from './topics';
13
+
14
+ const OWNER = 'me@enron.com';
15
+ const NOW = '2001-05-02T00:00:00.000Z';
16
+
17
+ describe('topics', () => {
18
+ test('clusters related threads and separates unrelated ones', ({ expect }) => {
19
+ const drafts = clusterThreads(threads());
20
+ expect(drafts).toHaveLength(2);
21
+
22
+ const q2 = drafts.find((draft) => draft.threadIds.includes('q2 report draft'));
23
+ expect(q2?.threadIds).toContain('q2 report budget numbers');
24
+ expect(q2?.participants).toContain('alice@enron.com');
25
+ expect(q2?.keywords).toContain('report');
26
+
27
+ const lunch = drafts.find((draft) => draft.threadIds.includes('lunch on friday?'));
28
+ expect(lunch?.threadIds).toHaveLength(1);
29
+ });
30
+
31
+ test('every thread lands in exactly one topic', ({ expect }) => {
32
+ const input = threads();
33
+ const drafts = clusterThreads(input);
34
+ const assigned = drafts.flatMap((draft) => draft.threadIds);
35
+ expect(assigned.sort()).toEqual(input.map((thread) => thread.threadId).sort());
36
+ });
37
+
38
+ test('deterministic summary concatenates thread summaries; label from top keywords', ({ expect }) => {
39
+ const drafts = clusterThreads(threads());
40
+ const q2 = drafts.find((draft) => draft.threadIds.includes('q2 report draft'));
41
+ expect(q2?.summary).toContain('Draft circulated.');
42
+ expect(q2?.label.length).toBeGreaterThan(0);
43
+ });
44
+
45
+ test('blank-subject threads still get a non-empty topic label', ({ expect }) => {
46
+ const blank = buildThreads([msg('', 'a@x.com', '2001-05-01T10:00:00.000Z')], { ownerEmail: OWNER, now: NOW });
47
+ const [draft] = clusterThreads(blank);
48
+ expect(draft.label).toBe('no-subject');
49
+ });
50
+
51
+ test('summarizeTopics replaces summaries via the summarizer and degrades on failure', async ({ expect }) => {
52
+ const drafts = clusterThreads(threads());
53
+ const summarized = await summarizeTopics(drafts, async () => 'LLM topic summary.');
54
+ for (const draft of summarized) {
55
+ expect(draft.summary).toBe('LLM topic summary.');
56
+ }
57
+
58
+ // A failing summarizer keeps the deterministic summary.
59
+ const degraded = await summarizeTopics(drafts, async () => {
60
+ throw new Error('model down');
61
+ });
62
+ expect(degraded.map((draft) => draft.summary)).toEqual(drafts.map((draft) => draft.summary));
63
+ });
64
+
65
+ test('materializeTopics produces Topic ECHO objects', ({ expect }) => {
66
+ const [first] = materializeTopics(clusterThreads(threads()));
67
+ expect(Obj.instanceOf(Topic, first)).toBe(true);
68
+ expect(first.threadIds.length).toBeGreaterThan(0);
69
+ });
70
+ });
71
+
72
+ const msg = (subject: string, from: string, created: string, summary?: string) =>
73
+ Message.make({
74
+ created,
75
+ sender: { email: from },
76
+ blocks: [{ _tag: 'text', text: 'body' }],
77
+ properties: { subject, messageId: `<${from}:${created}>`, ...(summary ? { summary } : {}) },
78
+ });
79
+
80
+ // Two related threads (shared participants + overlapping subject tokens) and one unrelated thread.
81
+ const threads = () =>
82
+ buildThreads(
83
+ [
84
+ msg('Q2 report draft', 'alice@enron.com', '2001-05-01T10:00:00.000Z', 'Draft circulated.'),
85
+ msg('RE: Q2 report draft', OWNER, '2001-05-01T11:00:00.000Z', 'Comments sent.'),
86
+ msg('Q2 report budget numbers', 'alice@enron.com', '2001-05-01T12:00:00.000Z', 'Budget attached.'),
87
+ msg('Lunch on friday?', 'zed@other.org', '2001-05-01T13:00:00.000Z'),
88
+ ],
89
+ { ownerEmail: OWNER, now: NOW },
90
+ );