@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,650 @@
1
+ // src/corpus/topics.ts
2
+ import { Obj } from "@dxos/echo";
3
+
4
+ // src/types/Thread.ts
5
+ import * as Schema from "effect/Schema";
6
+ import { DXN, Type } from "@dxos/echo";
7
+ var ThreadState = Schema.Literal("awaiting-mine", "awaiting-theirs", "resolved", "stalled");
8
+ var Thread = class extends Type.makeObject(DXN.make("org.dxos.type.emailThread", "0.1.0"))(Schema.Struct({
9
+ threadId: Schema.String,
10
+ subject: Schema.String,
11
+ summary: Schema.String,
12
+ state: ThreadState,
13
+ participants: Schema.Array(Schema.String),
14
+ messageIds: Schema.Array(Schema.String),
15
+ openQuestions: Schema.Array(Schema.String),
16
+ actionItems: Schema.Array(Schema.String)
17
+ })) {
18
+ };
19
+
20
+ // src/types/Topic.ts
21
+ import * as Schema2 from "effect/Schema";
22
+ import { DXN as DXN2, Type as Type2 } from "@dxos/echo";
23
+ var Topic = class extends Type2.makeObject(DXN2.make("org.dxos.type.emailTopic", "0.1.0"))(Schema2.Struct({
24
+ label: Schema2.String,
25
+ summary: Schema2.String,
26
+ threadIds: Schema2.Array(Schema2.String),
27
+ participants: Schema2.Array(Schema2.String),
28
+ keywords: Schema2.Array(Schema2.String)
29
+ })) {
30
+ };
31
+
32
+ // src/corpus/prompts.ts
33
+ import { trim } from "@dxos/util";
34
+ var DEFAULT_EMAIL_PROMPTS = {
35
+ topicSummary: trim`
36
+ Summarize the following email conversation topic in 1-2 sentences.
37
+ Focus on what the threads have in common and any outcome or open question.
38
+ Respond with ONLY the summary text.
39
+ `,
40
+ digest: trim`
41
+ Write a short briefing (3-5 sentences) from the following inbox digest data.
42
+ Lead with what needs attention (stalled threads, due commitments), then notable topics.
43
+ Respond with ONLY the briefing text.
44
+ `
45
+ };
46
+ var mergePrompts = (overrides) => ({
47
+ ...DEFAULT_EMAIL_PROMPTS,
48
+ ...overrides
49
+ });
50
+
51
+ // src/corpus/topics.ts
52
+ var DEFAULT_STOPWORDS = [
53
+ "the",
54
+ "and",
55
+ "for",
56
+ "with",
57
+ "from",
58
+ "about",
59
+ "this",
60
+ "that",
61
+ "your",
62
+ "our",
63
+ "are",
64
+ "was",
65
+ "will",
66
+ "has",
67
+ "have",
68
+ "not",
69
+ "you",
70
+ "all",
71
+ "any",
72
+ "can",
73
+ "per",
74
+ "new"
75
+ ];
76
+ var DEFAULT_TOPIC_OPTIONS = {
77
+ similarityThreshold: 0.25,
78
+ minSharedParticipants: 2,
79
+ minTokenLength: 3,
80
+ stopwords: DEFAULT_STOPWORDS,
81
+ maxKeywords: 5
82
+ };
83
+ var tokenize = (subject, options) => new Set(subject.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length >= options.minTokenLength && !options.stopwords.includes(token)));
84
+ var jaccard = (left, right) => {
85
+ if (left.size === 0 || right.size === 0) {
86
+ return 0;
87
+ }
88
+ let shared = 0;
89
+ for (const token of left) {
90
+ if (right.has(token)) {
91
+ shared += 1;
92
+ }
93
+ }
94
+ return shared / (left.size + right.size - shared);
95
+ };
96
+ var sharedCount = (left, right) => {
97
+ let shared = 0;
98
+ for (const value of left) {
99
+ if (right.has(value)) {
100
+ shared += 1;
101
+ }
102
+ }
103
+ return shared;
104
+ };
105
+ var clusterThreads = (threads, options) => {
106
+ const opts = {
107
+ ...DEFAULT_TOPIC_OPTIONS,
108
+ ...options
109
+ };
110
+ const clusters = [];
111
+ for (const thread of threads) {
112
+ const signature = {
113
+ thread,
114
+ tokens: tokenize(thread.subject, opts),
115
+ participants: new Set(thread.participants)
116
+ };
117
+ const home = clusters.find((cluster) => jaccard(cluster.tokens, signature.tokens) >= opts.similarityThreshold || sharedCount(cluster.participants, signature.participants) >= opts.minSharedParticipants);
118
+ if (home) {
119
+ home.members.push(signature);
120
+ for (const token of signature.tokens) {
121
+ home.tokens.add(token);
122
+ }
123
+ for (const participant of signature.participants) {
124
+ home.participants.add(participant);
125
+ }
126
+ } else {
127
+ clusters.push({
128
+ members: [
129
+ signature
130
+ ],
131
+ tokens: new Set(signature.tokens),
132
+ participants: new Set(signature.participants)
133
+ });
134
+ }
135
+ }
136
+ return clusters.map((cluster) => {
137
+ const counts = /* @__PURE__ */ new Map();
138
+ for (const member of cluster.members) {
139
+ for (const token of member.tokens) {
140
+ counts.set(token, (counts.get(token) ?? 0) + 1);
141
+ }
142
+ }
143
+ const keywords = [
144
+ ...counts.entries()
145
+ ].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])).slice(0, opts.maxKeywords).map(([token]) => token);
146
+ const summaries = cluster.members.flatMap((member) => member.thread.summary.length > 0 ? [
147
+ member.thread.summary
148
+ ] : []);
149
+ const first = cluster.members[0].thread;
150
+ return {
151
+ label: keywords.length > 0 ? keywords.slice(0, 3).join(" ") : first.subject || first.threadId,
152
+ summary: summaries.join("\n"),
153
+ threadIds: cluster.members.map((member) => member.thread.threadId),
154
+ participants: [
155
+ ...cluster.participants
156
+ ].sort(),
157
+ keywords
158
+ };
159
+ });
160
+ };
161
+ var summarizeTopics = async (drafts, summarize, options) => {
162
+ const prompts = options?.prompts ? mergePrompts(options.prompts) : DEFAULT_EMAIL_PROMPTS;
163
+ return Promise.all(drafts.map(async (draft) => {
164
+ try {
165
+ const summary = await summarize([
166
+ prompts.topicSummary,
167
+ `Topic: ${draft.label}`,
168
+ `Threads:
169
+ ${draft.summary}`
170
+ ].join("\n\n"));
171
+ return summary.trim().length > 0 ? {
172
+ ...draft,
173
+ summary: summary.trim()
174
+ } : draft;
175
+ } catch {
176
+ return draft;
177
+ }
178
+ }));
179
+ };
180
+ var materializeTopics = (drafts) => drafts.map((draft) => Obj.make(Topic, {
181
+ label: draft.label,
182
+ summary: draft.summary,
183
+ threadIds: [
184
+ ...draft.threadIds
185
+ ],
186
+ participants: [
187
+ ...draft.participants
188
+ ],
189
+ keywords: [
190
+ ...draft.keywords
191
+ ]
192
+ }));
193
+
194
+ // src/corpus/ledger.ts
195
+ import * as Effect from "effect/Effect";
196
+ var DEFAULT_COMMITMENT_PREDICATES = [
197
+ "owes",
198
+ "will send",
199
+ "will provide",
200
+ "must",
201
+ "due"
202
+ ];
203
+ var termLabel = (term) => "entity" in term ? term.label ?? term.entity : term.literal;
204
+ var toCommitment = (fact) => ({
205
+ who: termLabel(fact.assertion.subject),
206
+ what: termLabel(fact.assertion.object),
207
+ ...fact.assertion.validTo ? {
208
+ dueBy: fact.assertion.validTo
209
+ } : {},
210
+ ...fact.factuality.confidence !== void 0 ? {
211
+ confidence: fact.factuality.confidence
212
+ } : {},
213
+ factId: fact.id,
214
+ source: fact.attribution.source
215
+ });
216
+ var commitmentLedger = (store, options) => Effect.forEach(options?.predicates ?? DEFAULT_COMMITMENT_PREDICATES, (predicate) => store.query({
217
+ predicate
218
+ })).pipe(Effect.map((results) => {
219
+ const seen = /* @__PURE__ */ new Set();
220
+ const commitments = [];
221
+ for (const fact of results.flat()) {
222
+ if (!seen.has(fact.id)) {
223
+ seen.add(fact.id);
224
+ commitments.push(toCommitment(fact));
225
+ }
226
+ }
227
+ return commitments.sort((a, b) => a.dueBy && b.dueBy ? a.dueBy.localeCompare(b.dueBy) : a.dueBy ? -1 : b.dueBy ? 1 : 0);
228
+ }));
229
+
230
+ // src/internal/threading.ts
231
+ var PREFIX = /^\s*(re|fwd?|fw)\s*:\s*/i;
232
+ var normalizeSubject = (subject) => {
233
+ let value = subject;
234
+ while (PREFIX.test(value)) {
235
+ value = value.replace(PREFIX, "");
236
+ }
237
+ return value.replace(/\s+/g, " ").trim().toLowerCase();
238
+ };
239
+ var deriveThreadId = (message) => {
240
+ const subject = message.properties?.subject;
241
+ const normalized = typeof subject === "string" ? normalizeSubject(subject) : "";
242
+ return normalized.length > 0 ? normalized : "no-subject";
243
+ };
244
+
245
+ // src/corpus/rollups.ts
246
+ var MS_PER_DAY = 24 * 60 * 60 * 1e3;
247
+ var buildRollups = (messages) => {
248
+ const bySender = /* @__PURE__ */ new Map();
249
+ for (const message of messages) {
250
+ const email = message.sender.email?.toLowerCase();
251
+ if (!email) {
252
+ continue;
253
+ }
254
+ let entry = bySender.get(email);
255
+ if (!entry) {
256
+ entry = {
257
+ created: [],
258
+ threadIds: []
259
+ };
260
+ bySender.set(email, entry);
261
+ }
262
+ entry.created.push(message.created);
263
+ const threadId = deriveThreadId(message);
264
+ if (!entry.threadIds.includes(threadId)) {
265
+ entry.threadIds.push(threadId);
266
+ }
267
+ }
268
+ const rollups = [];
269
+ for (const [email, entry] of bySender) {
270
+ const ordered = [
271
+ ...entry.created
272
+ ].sort();
273
+ const first = ordered[0];
274
+ const last = ordered[ordered.length - 1];
275
+ const spanMs = new Date(last).getTime() - new Date(first).getTime();
276
+ rollups.push({
277
+ email,
278
+ messageCount: ordered.length,
279
+ firstContact: first,
280
+ lastContact: last,
281
+ ...ordered.length >= 2 ? {
282
+ cadenceDays: spanMs / (ordered.length - 1) / MS_PER_DAY
283
+ } : {},
284
+ threadIds: entry.threadIds
285
+ });
286
+ }
287
+ return rollups.sort((a, b) => b.messageCount - a.messageCount || a.email.localeCompare(b.email));
288
+ };
289
+
290
+ // src/corpus/digest.ts
291
+ var buildDigest = (input, options) => ({
292
+ generatedAt: options.now,
293
+ threadCount: input.threads.length,
294
+ topicCount: input.topics.length,
295
+ stalledThreads: input.threads.filter((thread) => thread.state === "stalled").map((thread) => thread.threadId),
296
+ awaitingMine: input.threads.filter((thread) => thread.state === "awaiting-mine").map((thread) => thread.threadId),
297
+ dueCommitments: input.commitments.filter((commitment) => commitment.dueBy !== void 0).sort((a, b) => (a.dueBy ?? "").localeCompare(b.dueBy ?? "")),
298
+ topTopics: [
299
+ ...input.topics
300
+ ].sort((a, b) => b.threadIds.length - a.threadIds.length || a.label.localeCompare(b.label)).slice(0, options.maxTopTopics ?? 5).map((topic) => ({
301
+ label: topic.label,
302
+ threadCount: topic.threadIds.length
303
+ })),
304
+ narrative: ""
305
+ });
306
+ var renderDigest = (digest) => [
307
+ `Inbox digest (${digest.generatedAt})`,
308
+ `Threads: ${digest.threadCount} Topics: ${digest.topicCount}`,
309
+ `Awaiting my reply: ${digest.awaitingMine.join(", ") || "none"}`,
310
+ `Stalled: ${digest.stalledThreads.join(", ") || "none"}`,
311
+ `Due commitments: ${digest.dueCommitments.map((commitment) => `${commitment.who} \u2192 ${commitment.what} (${commitment.dueBy})`).join("; ") || "none"}`,
312
+ `Top topics: ${digest.topTopics.map((topic) => `${topic.label} (${topic.threadCount})`).join("; ") || "none"}`
313
+ ].join("\n");
314
+ var narrateDigest = async (digest, summarize, options) => {
315
+ const prompts = options?.prompts ? mergePrompts(options.prompts) : DEFAULT_EMAIL_PROMPTS;
316
+ try {
317
+ const narrative = await summarize([
318
+ prompts.digest,
319
+ renderDigest(digest)
320
+ ].join("\n\n"));
321
+ return {
322
+ ...digest,
323
+ narrative: narrative.trim()
324
+ };
325
+ } catch {
326
+ return digest;
327
+ }
328
+ };
329
+
330
+ // src/fact-pipeline.ts
331
+ import * as Effect7 from "effect/Effect";
332
+ import * as Stream from "effect/Stream";
333
+ import { Pipeline } from "@dxos/pipeline";
334
+
335
+ // src/stages/context.ts
336
+ import * as Context from "effect/Context";
337
+ var emptyStats = () => ({
338
+ from: /* @__PURE__ */ new Map(),
339
+ to: /* @__PURE__ */ new Map(),
340
+ total: 0,
341
+ spam: 0
342
+ });
343
+ var EmailPipelineCtx = class extends Context.Tag("@dxos/pipeline-email/EmailPipelineCtx")() {
344
+ };
345
+
346
+ // src/stages/extract-contacts.ts
347
+ import * as Effect2 from "effect/Effect";
348
+ import { extractContact } from "@dxos/extractor-lib";
349
+ import { Stage } from "@dxos/pipeline";
350
+ var extractContactsStage = Stage.map("extract-contacts", (message) => Effect2.gen(function* () {
351
+ const { db } = yield* EmailPipelineCtx;
352
+ const result = yield* extractContact({
353
+ db,
354
+ source: message
355
+ });
356
+ for (const object of result.created) {
357
+ db.add(object);
358
+ }
359
+ return message;
360
+ }));
361
+
362
+ // src/stages/extract-facts.ts
363
+ import * as Effect3 from "effect/Effect";
364
+ import { Stage as Stage2 } from "@dxos/pipeline";
365
+ var extractFactsStage = (indexFacts) => Stage2.map("extract-facts", (message) => Effect3.tryPromise(() => indexFacts(message)).pipe(Effect3.tapError((error) => Effect3.logWarning("extract-facts failed; degrading to no facts", error)), Effect3.orElse(() => Effect3.succeed([])), Effect3.as(message)));
366
+
367
+ // src/stages/extract-facts-commit.ts
368
+ import * as Effect4 from "effect/Effect";
369
+ import { Stage as Stage3 } from "@dxos/pipeline";
370
+
371
+ // src/stages/facts.ts
372
+ import { Message } from "@dxos/types";
373
+ var EMAIL_EXTRACTION_RULES = [
374
+ 'Treat a request or promise as a commitment: "please send X by Friday" => subject (the owner), predicate "owes"/"will send", object X, validTo the deadline.',
375
+ "A deadline or due date becomes validTo on the relevant assertion (ISO date when known).",
376
+ 'Attribute first-person statements ("I will\u2026", "we can\u2026") to the message sender as subject.'
377
+ ];
378
+ var EMAIL_EXTRACT_OPTIONS = {
379
+ rules: EMAIL_EXTRACTION_RULES
380
+ };
381
+ var messageSource = (message) => {
382
+ const messageId = message.properties?.messageId;
383
+ if (typeof messageId === "string" && messageId.length > 0) {
384
+ return messageId;
385
+ }
386
+ return `${message.sender.email ?? "unknown"}:${message.created}`;
387
+ };
388
+ var messageToDocument = (message) => ({
389
+ text: Message.extractText(message),
390
+ source: messageSource(message),
391
+ ...message.sender.email ? {
392
+ author: message.sender.email
393
+ } : {},
394
+ date: message.created
395
+ });
396
+
397
+ // src/stages/extract-facts-commit.ts
398
+ var extractFactsUnitStage = (extract) => Stage3.map("extract-facts-unit", (message) => Effect4.tryPromise(() => extract(message)).pipe(Effect4.tapError((error) => Effect4.logWarning("extract-facts-unit failed; degrading to no facts", error)), Effect4.orElse(() => Effect4.succeed([])), Effect4.map((facts) => ({
399
+ facts,
400
+ foreignId: messageSource(message),
401
+ key: keyOf(message)
402
+ }))));
403
+ var keyOf = (message) => Date.parse(message.created);
404
+
405
+ // src/stages/stats.ts
406
+ import * as Effect5 from "effect/Effect";
407
+ import { Stage as Stage4 } from "@dxos/pipeline";
408
+ var statsStage = Stage4.map("stats", (message) => Effect5.gen(function* () {
409
+ const { stats } = yield* EmailPipelineCtx;
410
+ stats.total += 1;
411
+ const sender = message.sender.email;
412
+ if (sender) {
413
+ stats.from.set(sender, (stats.from.get(sender) ?? 0) + 1);
414
+ }
415
+ const recipients = message.properties?.to;
416
+ if (Array.isArray(recipients)) {
417
+ for (const recipient of recipients) {
418
+ const address = String(recipient);
419
+ stats.to.set(address, (stats.to.get(address) ?? 0) + 1);
420
+ }
421
+ }
422
+ if (message.properties?.spam) {
423
+ stats.spam += 1;
424
+ }
425
+ return message;
426
+ }));
427
+
428
+ // src/stages/summarize.ts
429
+ import * as LanguageModel from "@effect/ai/LanguageModel";
430
+ import * as Effect6 from "effect/Effect";
431
+ import * as Layer from "effect/Layer";
432
+ import { AiService } from "@dxos/ai";
433
+ import { Stage as Stage5 } from "@dxos/pipeline";
434
+ import { Message as Message2 } from "@dxos/types";
435
+ import { trim as trim2 } from "@dxos/util";
436
+ var SUMMARIZE_MODEL = "com.anthropic.model.claude-haiku-4-5.default";
437
+ var SUMMARIZE_PROMPT = trim2`
438
+ Summarize the following email in one sentence, decide whether it is spam, and list up to five keywords.
439
+ Respond with ONLY a JSON object of the form {"summary": string, "isSpam": boolean, "keywords": string[]}.
440
+ `;
441
+ var parseSummary = (raw) => {
442
+ const match = raw.match(/\{[\s\S]*\}/);
443
+ if (!match) {
444
+ return {
445
+ summary: "",
446
+ isSpam: false,
447
+ keywords: []
448
+ };
449
+ }
450
+ try {
451
+ const parsed = JSON.parse(match[0]);
452
+ const keywords = parsed.keywords;
453
+ return {
454
+ summary: typeof parsed.summary === "string" ? parsed.summary : "",
455
+ isSpam: parsed.isSpam === true || parsed.spam === true || parsed.is_spam === true,
456
+ keywords: Array.isArray(keywords) ? keywords.map((keyword) => String(keyword)) : []
457
+ };
458
+ } catch {
459
+ return {
460
+ summary: "",
461
+ isSpam: false,
462
+ keywords: []
463
+ };
464
+ }
465
+ };
466
+ var summarizeStage = Stage5.map("summarize", (message) => Effect6.gen(function* () {
467
+ const ctx = yield* EmailPipelineCtx;
468
+ const text = Message2.extractText(message);
469
+ const raw = yield* LanguageModel.generateText({
470
+ prompt: `${SUMMARIZE_PROMPT}
471
+
472
+ ${text}`
473
+ }).pipe(Effect6.provide(AiService.model(SUMMARIZE_MODEL).pipe(Layer.orDie)), Effect6.timeout("30 seconds"), Effect6.map((response) => response.text), Effect6.orElse(() => Effect6.succeed("")));
474
+ const summary = parseSummary(raw);
475
+ const messageId = String(message.properties?.messageId ?? message.id);
476
+ ctx.summaries.push({
477
+ messageId,
478
+ summary
479
+ });
480
+ const summaryBlock = {
481
+ _tag: "text",
482
+ text: summary.summary
483
+ };
484
+ return Message2.make({
485
+ id: message.id,
486
+ created: message.created,
487
+ sender: message.sender,
488
+ blocks: [
489
+ ...message.blocks,
490
+ summaryBlock
491
+ ],
492
+ properties: {
493
+ ...message.properties,
494
+ spam: summary.isSpam,
495
+ keywords: summary.keywords
496
+ }
497
+ });
498
+ }));
499
+
500
+ // src/fact-pipeline.ts
501
+ var EmailFactPipeline = {
502
+ /**
503
+ * Facts-only assembly of the email stages over a batch of messages: stats → extract-facts. Omits
504
+ * summarize, extract-contacts, and {@link buildThreads} from {@link EmailPipeline}, so it needs no
505
+ * `AiService` and never writes to `db`. The cursored `EnrichMailbox` operation (in plugin-inbox)
506
+ * covers the same fact-extraction concern incrementally, via a sibling facts-returning stage
507
+ * (`extractFactsUnitStage`) that commits pages atomically with a cursor advance instead of indexing
508
+ * in-stage; this batch variant is for one-shot runs over an already-collected set of messages.
509
+ */
510
+ run: (messages, options) => Effect7.gen(function* () {
511
+ const stats = emptyStats();
512
+ const collected = [];
513
+ yield* Stream.fromIterable(messages).pipe(statsStage, extractFactsStage(options.indexFacts), Pipeline.run({
514
+ sink: (message) => Effect7.sync(() => collected.push(message))
515
+ }), Effect7.provideService(EmailPipelineCtx, {
516
+ db: options.db,
517
+ stats,
518
+ summaries: []
519
+ }));
520
+ return {
521
+ messages: collected,
522
+ stats
523
+ };
524
+ })
525
+ };
526
+
527
+ // src/pipeline.ts
528
+ import * as Effect8 from "effect/Effect";
529
+ import * as Stream2 from "effect/Stream";
530
+ import { Pipeline as Pipeline2 } from "@dxos/pipeline";
531
+
532
+ // src/internal/threads.ts
533
+ import { Obj as Obj2 } from "@dxos/echo";
534
+ var DEFAULT_STALE_PERIOD_MS = 14 * 24 * 60 * 60 * 1e3;
535
+ var computeState = (lastFromOwner, lastCreated, options) => {
536
+ const idleMs = new Date(options.now).getTime() - new Date(lastCreated).getTime();
537
+ const stale = idleMs > (options.stalePeriodMs ?? DEFAULT_STALE_PERIOD_MS);
538
+ if (stale) {
539
+ return "stalled";
540
+ }
541
+ return lastFromOwner ? "awaiting-theirs" : "awaiting-mine";
542
+ };
543
+ var buildThreads = (messages, options) => {
544
+ const buckets = /* @__PURE__ */ new Map();
545
+ for (const message of messages) {
546
+ const threadId = deriveThreadId(message);
547
+ let bucket = buckets.get(threadId);
548
+ if (!bucket) {
549
+ bucket = [];
550
+ buckets.set(threadId, bucket);
551
+ }
552
+ bucket.push(message);
553
+ }
554
+ const threads = [];
555
+ for (const [threadId, bucket] of buckets) {
556
+ const ordered = [
557
+ ...bucket
558
+ ].sort((a, b) => a.created.localeCompare(b.created));
559
+ const last = ordered[ordered.length - 1];
560
+ const owner = options.ownerEmail.toLowerCase();
561
+ const lastFromOwner = last.sender.email?.toLowerCase() === owner;
562
+ const participants = [
563
+ ...new Set(ordered.flatMap((m) => m.sender.email ? [
564
+ m.sender.email.toLowerCase()
565
+ ] : []))
566
+ ];
567
+ const summaries = ordered.flatMap((m) => typeof m.properties?.summary === "string" && m.properties.summary.length > 0 ? [
568
+ m.properties.summary
569
+ ] : []);
570
+ const subject = typeof ordered[0].properties?.subject === "string" ? ordered[0].properties.subject : threadId;
571
+ threads.push(Obj2.make(Thread, {
572
+ threadId,
573
+ subject,
574
+ summary: summaries.length > 0 ? summaries.join("\n") : subject,
575
+ state: computeState(lastFromOwner, last.created, options),
576
+ participants,
577
+ messageIds: ordered.map(messageSource),
578
+ openQuestions: [],
579
+ actionItems: []
580
+ }));
581
+ }
582
+ return threads;
583
+ };
584
+
585
+ // src/pipeline.ts
586
+ var EmailPipeline = {
587
+ /**
588
+ * Default assembly of the email stages over a batch of messages: summarize → extract-contacts →
589
+ * stats → extract-facts stream through with back pressure, then {@link buildThreads} groups the
590
+ * collected messages into canonical threads. Contacts and threads are persisted to `db`; the fact
591
+ * substrate is populated via `indexFacts`. Requires an `AiService` (summarize); the shared
592
+ * {@link EmailPipelineCtx} is constructed and provided internally.
593
+ */
594
+ run: (messages, options) => Effect8.gen(function* () {
595
+ const stats = emptyStats();
596
+ const summaries = [];
597
+ const collected = [];
598
+ yield* Stream2.fromIterable(messages).pipe(summarizeStage, extractContactsStage, statsStage, extractFactsStage(options.indexFacts), Pipeline2.run({
599
+ sink: (message) => Effect8.sync(() => collected.push(message))
600
+ }), Effect8.provideService(EmailPipelineCtx, {
601
+ db: options.db,
602
+ stats,
603
+ summaries
604
+ }));
605
+ const threads = buildThreads(collected, {
606
+ ownerEmail: options.ownerEmail,
607
+ now: options.now
608
+ });
609
+ for (const thread of threads) {
610
+ options.db.add(thread);
611
+ }
612
+ return {
613
+ messages: collected,
614
+ stats,
615
+ summaries,
616
+ threads
617
+ };
618
+ })
619
+ };
620
+ export {
621
+ DEFAULT_COMMITMENT_PREDICATES,
622
+ DEFAULT_EMAIL_PROMPTS,
623
+ DEFAULT_TOPIC_OPTIONS,
624
+ EMAIL_EXTRACTION_RULES,
625
+ EMAIL_EXTRACT_OPTIONS,
626
+ EmailFactPipeline,
627
+ EmailPipeline,
628
+ EmailPipelineCtx,
629
+ Thread,
630
+ ThreadState,
631
+ Topic,
632
+ buildDigest,
633
+ buildRollups,
634
+ clusterThreads,
635
+ commitmentLedger,
636
+ emptyStats,
637
+ extractContactsStage,
638
+ extractFactsStage,
639
+ extractFactsUnitStage,
640
+ materializeTopics,
641
+ mergePrompts,
642
+ messageSource,
643
+ messageToDocument,
644
+ narrateDigest,
645
+ renderDigest,
646
+ statsStage,
647
+ summarizeStage,
648
+ summarizeTopics
649
+ };
650
+ //# sourceMappingURL=index.mjs.map