@dxos/pipeline-email 0.0.0 → 0.11.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 (66) hide show
  1. package/dist/lib/index.mjs +994 -0
  2. package/dist/lib/index.mjs.map +1 -0
  3. package/dist/lib/testing.mjs +49 -0
  4. package/dist/lib/testing.mjs.map +1 -0
  5. package/dist/types/src/corpus/digest.d.ts +0 -4
  6. package/dist/types/src/corpus/digest.d.ts.map +1 -1
  7. package/dist/types/src/corpus/topics.d.ts +12 -4
  8. package/dist/types/src/corpus/topics.d.ts.map +1 -1
  9. package/dist/types/src/fact-pipeline.d.ts +28 -3
  10. package/dist/types/src/fact-pipeline.d.ts.map +1 -1
  11. package/dist/types/src/index.d.ts +4 -0
  12. package/dist/types/src/index.d.ts.map +1 -1
  13. package/dist/types/src/internal/threads.d.ts +5 -1
  14. package/dist/types/src/internal/threads.d.ts.map +1 -1
  15. package/dist/types/src/model-policy.d.ts +17 -0
  16. package/dist/types/src/model-policy.d.ts.map +1 -0
  17. package/dist/types/src/model-policy.test.d.ts +2 -0
  18. package/dist/types/src/model-policy.test.d.ts.map +1 -0
  19. package/dist/types/src/stages/EmailStage.d.ts +149 -0
  20. package/dist/types/src/stages/EmailStage.d.ts.map +1 -0
  21. package/dist/types/src/stages/extract-facts-commit.d.ts +1 -1
  22. package/dist/types/src/stages/extract-facts-commit.d.ts.map +1 -1
  23. package/dist/types/src/stages/index.d.ts +2 -0
  24. package/dist/types/src/stages/index.d.ts.map +1 -1
  25. package/dist/types/src/stages/tag.d.ts +38 -0
  26. package/dist/types/src/stages/tag.d.ts.map +1 -0
  27. package/dist/types/src/stages/tag.test.d.ts +2 -0
  28. package/dist/types/src/stages/tag.test.d.ts.map +1 -0
  29. package/dist/types/src/topics-pipeline.d.ts +55 -0
  30. package/dist/types/src/topics-pipeline.d.ts.map +1 -0
  31. package/dist/types/src/topics-pipeline.test.d.ts +2 -0
  32. package/dist/types/src/topics-pipeline.test.d.ts.map +1 -0
  33. package/dist/types/src/types/index.d.ts +0 -1
  34. package/dist/types/src/types/index.d.ts.map +1 -1
  35. package/dist/types/tsconfig.tsbuildinfo +1 -1
  36. package/package.json +22 -17
  37. package/src/corpus/digest.test.ts +1 -1
  38. package/src/corpus/digest.ts +6 -6
  39. package/src/corpus/topics.test.ts +72 -18
  40. package/src/corpus/topics.ts +61 -15
  41. package/src/fact-pipeline.ts +114 -4
  42. package/src/index.ts +4 -0
  43. package/src/internal/threads.test.ts +12 -0
  44. package/src/internal/threads.ts +11 -3
  45. package/src/model-policy.test.ts +21 -0
  46. package/src/model-policy.ts +45 -0
  47. package/src/stages/EmailStage.ts +398 -0
  48. package/src/stages/extract-facts-commit.ts +16 -7
  49. package/src/stages/index.ts +3 -0
  50. package/src/stages/tag.test.ts +98 -0
  51. package/src/stages/tag.ts +150 -0
  52. package/src/testing/email-pipeline.test.ts +9 -6
  53. package/src/topics-pipeline.test.ts +95 -0
  54. package/src/topics-pipeline.ts +106 -0
  55. package/src/types/index.ts +0 -1
  56. package/dist/lib/neutral/index.mjs +0 -650
  57. package/dist/lib/neutral/index.mjs.map +0 -7
  58. package/dist/lib/neutral/meta.json +0 -1
  59. package/dist/lib/neutral/testing/index.mjs +0 -48
  60. package/dist/lib/neutral/testing/index.mjs.map +0 -7
  61. package/dist/types/src/types/Topic.d.ts +0 -12
  62. package/dist/types/src/types/Topic.d.ts.map +0 -1
  63. package/dist/types/src/types/Topic.test.d.ts +0 -2
  64. package/dist/types/src/types/Topic.test.d.ts.map +0 -1
  65. package/src/types/Topic.test.ts +0 -25
  66. package/src/types/Topic.ts +0 -20
@@ -0,0 +1,994 @@
1
+ import { Project } from "@dxos/compute";
2
+ import { trim } from "@dxos/util";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Chunk from "effect/Chunk";
5
+ import * as Stream from "effect/Stream";
6
+ import { Blob, DXN, Database, Feed, Filter, Obj, Ref, Type } from "@dxos/echo";
7
+ import { Cursor } from "@dxos/link";
8
+ import { log } from "@dxos/log";
9
+ import { Pipeline, Stage } from "@dxos/pipeline";
10
+ import { FactStore } from "@dxos/pipeline-rdf";
11
+ import { DraftMessage, Message } from "@dxos/types";
12
+ import * as Context from "effect/Context";
13
+ import { buildContactFromActor, extractContact, getIdentityIndex, identitySpecs } from "@dxos/extractor-lib";
14
+ import * as LanguageModel from "@effect/ai/LanguageModel";
15
+ import * as Layer from "effect/Layer";
16
+ import { AiService } from "@dxos/ai";
17
+ import { overlayIdentityIndex } from "@dxos/extractor";
18
+ import { normalizeText } from "@dxos/markdown";
19
+ import { TagIndex, Tagging } from "@dxos/schema";
20
+ import * as Schema from "effect/Schema";
21
+ //#region \0rolldown/runtime.js
22
+ var __defProp = Object.defineProperty;
23
+ var __exportAll = (all, no_symbols) => {
24
+ let target = {};
25
+ for (var name in all) __defProp(target, name, {
26
+ get: all[name],
27
+ enumerable: true
28
+ });
29
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
30
+ return target;
31
+ };
32
+ //#endregion
33
+ //#region src/corpus/prompts.ts
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
+ /** Overlay caller overrides onto the defaults; absent slots keep their default. */
47
+ var mergePrompts = (overrides) => ({
48
+ ...DEFAULT_EMAIL_PROMPTS,
49
+ ...overrides
50
+ });
51
+ var DEFAULT_TOPIC_OPTIONS = {
52
+ similarityThreshold: .25,
53
+ minSharedParticipants: 2,
54
+ minTokenLength: 3,
55
+ stopwords: [
56
+ "the",
57
+ "and",
58
+ "for",
59
+ "with",
60
+ "from",
61
+ "about",
62
+ "this",
63
+ "that",
64
+ "your",
65
+ "our",
66
+ "are",
67
+ "was",
68
+ "will",
69
+ "has",
70
+ "have",
71
+ "not",
72
+ "you",
73
+ "all",
74
+ "any",
75
+ "can",
76
+ "per",
77
+ "new"
78
+ ],
79
+ maxKeywords: 5,
80
+ dropIdTokens: true
81
+ };
82
+ var isIdToken = (token) => {
83
+ if (/^\d+$/.test(token)) return true;
84
+ const digits = (token.match(/\d/g) ?? []).length;
85
+ if (digits === 0) return false;
86
+ if (/^[0-9a-f]+$/.test(token) && token.length >= 6) return true;
87
+ return token.length >= 5 && digits / token.length >= .3;
88
+ };
89
+ var tokenize = (subject, options) => new Set(subject.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length >= options.minTokenLength && !options.stopwords.includes(token) && !(options.dropIdTokens && isIdToken(token))));
90
+ var jaccard = (left, right) => {
91
+ if (left.size === 0 || right.size === 0) return 0;
92
+ let shared = 0;
93
+ for (const token of left) if (right.has(token)) shared += 1;
94
+ return shared / (left.size + right.size - shared);
95
+ };
96
+ var sharedCount = (left, right) => {
97
+ let shared = 0;
98
+ for (const value of left) if (right.has(value)) shared += 1;
99
+ return shared;
100
+ };
101
+ /**
102
+ * Greedy agglomerative clustering of threads into topic drafts. Deterministic (Compute mode): a
103
+ * thread joins the first cluster whose aggregate signature overlaps enough — by subject-token
104
+ * Jaccard similarity or by shared participants — else starts its own. Bounded by O(threads ×
105
+ * clusters); no LLM involved, so results are stable and unit-testable.
106
+ */
107
+ var clusterThreads = (threads, options) => {
108
+ const opts = {
109
+ ...DEFAULT_TOPIC_OPTIONS,
110
+ ...options
111
+ };
112
+ const clusters = [];
113
+ for (const thread of threads) {
114
+ const signature = {
115
+ thread,
116
+ tokens: tokenize(thread.subject, opts),
117
+ participants: new Set(thread.participants)
118
+ };
119
+ const home = clusters.find((cluster) => jaccard(cluster.tokens, signature.tokens) >= opts.similarityThreshold || sharedCount(cluster.participants, signature.participants) >= opts.minSharedParticipants);
120
+ if (home) {
121
+ home.members.push(signature);
122
+ for (const token of signature.tokens) home.tokens.add(token);
123
+ for (const participant of signature.participants) home.participants.add(participant);
124
+ } else clusters.push({
125
+ members: [signature],
126
+ tokens: new Set(signature.tokens),
127
+ participants: new Set(signature.participants)
128
+ });
129
+ }
130
+ return clusters.map((cluster) => {
131
+ const counts = /* @__PURE__ */ new Map();
132
+ for (const member of cluster.members) for (const token of member.tokens) counts.set(token, (counts.get(token) ?? 0) + 1);
133
+ const keywords = [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])).slice(0, opts.maxKeywords).map(([token]) => token);
134
+ const summaries = cluster.members.flatMap((member) => member.thread.summary.length > 0 ? [member.thread.summary] : []);
135
+ const questions = [...new Set(cluster.members.flatMap((member) => member.thread.openQuestions ?? []))];
136
+ const tasks = [...new Set(cluster.members.flatMap((member) => member.thread.actionItems ?? []))];
137
+ const first = cluster.members[0].thread;
138
+ return {
139
+ name: keywords.length > 0 ? keywords.slice(0, 3).join(" ") : first.subject || first.threadId,
140
+ summary: summaries.join("\n"),
141
+ threadIds: cluster.members.map((member) => member.thread.threadId),
142
+ participants: [...cluster.participants].sort(),
143
+ keywords,
144
+ questions,
145
+ tasks
146
+ };
147
+ });
148
+ };
149
+ /**
150
+ * Optionally replace each draft's deterministic summary with an LLM one, using the editable
151
+ * `topicSummary` prompt. Degrades per-topic: a failed call keeps that draft's deterministic summary
152
+ * (advisory enrichment must not fail the corpus pass).
153
+ */
154
+ var summarizeTopics = async (drafts, summarize, options) => {
155
+ const prompts = options?.prompts ? mergePrompts(options.prompts) : DEFAULT_EMAIL_PROMPTS;
156
+ return Promise.all(drafts.map(async (draft) => {
157
+ try {
158
+ const summary = await summarize([prompts.topicSummary, `Topic: ${draft.name}`].join("\n\n"));
159
+ return summary.trim().length > 0 ? {
160
+ ...draft,
161
+ summary: summary.trim()
162
+ } : draft;
163
+ } catch {
164
+ return draft;
165
+ }
166
+ }));
167
+ };
168
+ /** Materialize drafts as canonical Project ECHO objects (done last; drafts stay freely mutable). */
169
+ var materializeTopics = (drafts) => drafts.map((draft) => Project.make({ name: draft.name }));
170
+ //#endregion
171
+ //#region src/corpus/ledger.ts
172
+ /**
173
+ * Predicates treated as commitments (spec §3③ commitment ledger). Editable: extraction phrasing
174
+ * varies by corpus and extraction rules, so callers tune this list rather than the query logic.
175
+ */
176
+ var DEFAULT_COMMITMENT_PREDICATES = [
177
+ "owes",
178
+ "will send",
179
+ "will provide",
180
+ "must",
181
+ "due"
182
+ ];
183
+ var termLabel = (term) => "entity" in term ? term.label ?? term.entity : term.literal;
184
+ var toCommitment = (fact) => ({
185
+ who: termLabel(fact.assertion.subject),
186
+ what: termLabel(fact.assertion.object),
187
+ ...fact.assertion.validTo ? { dueBy: fact.assertion.validTo } : {},
188
+ ...fact.factuality.confidence !== void 0 ? { confidence: fact.factuality.confidence } : {},
189
+ factId: fact.id,
190
+ source: fact.attribution.source
191
+ });
192
+ /**
193
+ * Query the fact store for outstanding commitments — the spec's cross-thread "owe / owed" ledger.
194
+ * Uses the structured query path (one `query({ predicate })` per commitment predicate, compiled to
195
+ * SPARQL on the sqlite backend), which works on both store backends; rows are advisory evidence
196
+ * (§4 canonicality) and each carries its fact id + source message for grounding.
197
+ */
198
+ var commitmentLedger = (store, options) => Effect.forEach(options?.predicates ?? DEFAULT_COMMITMENT_PREDICATES, (predicate) => store.query({ predicate })).pipe(Effect.map((results) => {
199
+ const seen = /* @__PURE__ */ new Set();
200
+ const commitments = [];
201
+ for (const fact of results.flat()) if (!seen.has(fact.id)) {
202
+ seen.add(fact.id);
203
+ commitments.push(toCommitment(fact));
204
+ }
205
+ return commitments.sort((a, b) => a.dueBy && b.dueBy ? a.dueBy.localeCompare(b.dueBy) : a.dueBy ? -1 : b.dueBy ? 1 : 0);
206
+ }));
207
+ //#endregion
208
+ //#region src/internal/threading.ts
209
+ var PREFIX = /^\s*(re|fwd?|fw)\s*:\s*/i;
210
+ var normalizeSubject = (subject) => {
211
+ let value = subject;
212
+ while (PREFIX.test(value)) value = value.replace(PREFIX, "");
213
+ return value.replace(/\s+/g, " ").trim().toLowerCase();
214
+ };
215
+ var deriveThreadId = (message) => {
216
+ const subject = message.properties?.subject;
217
+ const normalized = typeof subject === "string" ? normalizeSubject(subject) : "";
218
+ return normalized.length > 0 ? normalized : "no-subject";
219
+ };
220
+ //#endregion
221
+ //#region src/corpus/rollups.ts
222
+ var MS_PER_DAY = 1440 * 60 * 1e3;
223
+ /** Aggregate messages into per-sender rollups, most active sender first. Deterministic (Compute mode). */
224
+ var buildRollups = (messages) => {
225
+ const bySender = /* @__PURE__ */ new Map();
226
+ for (const message of messages) {
227
+ const email = message.sender.email?.toLowerCase();
228
+ if (!email) continue;
229
+ let entry = bySender.get(email);
230
+ if (!entry) {
231
+ entry = {
232
+ created: [],
233
+ threadIds: []
234
+ };
235
+ bySender.set(email, entry);
236
+ }
237
+ entry.created.push(message.created);
238
+ const threadId = deriveThreadId(message);
239
+ if (!entry.threadIds.includes(threadId)) entry.threadIds.push(threadId);
240
+ }
241
+ const rollups = [];
242
+ for (const [email, entry] of bySender) {
243
+ const ordered = [...entry.created].sort();
244
+ const first = ordered[0];
245
+ const last = ordered[ordered.length - 1];
246
+ const spanMs = new Date(last).getTime() - new Date(first).getTime();
247
+ rollups.push({
248
+ email,
249
+ messageCount: ordered.length,
250
+ firstContact: first,
251
+ lastContact: last,
252
+ ...ordered.length >= 2 ? { cadenceDays: spanMs / (ordered.length - 1) / MS_PER_DAY } : {},
253
+ threadIds: entry.threadIds
254
+ });
255
+ }
256
+ return rollups.sort((a, b) => b.messageCount - a.messageCount || a.email.localeCompare(b.email));
257
+ };
258
+ //#endregion
259
+ //#region src/corpus/digest.ts
260
+ /** Assemble the deterministic digest skeleton from the corpus outputs (Compute mode; no LLM). */
261
+ var buildDigest = (input, options) => ({
262
+ generatedAt: options.now,
263
+ threadCount: input.threads.length,
264
+ topicCount: input.topics.length,
265
+ stalledThreads: input.threads.filter((thread) => thread.state === "stalled").map((thread) => thread.threadId),
266
+ awaitingMine: input.threads.filter((thread) => thread.state === "awaiting-mine").map((thread) => thread.threadId),
267
+ dueCommitments: input.commitments.filter((commitment) => commitment.dueBy !== void 0).sort((a, b) => (a.dueBy ?? "").localeCompare(b.dueBy ?? "")),
268
+ narrative: ""
269
+ });
270
+ /** Render the skeleton as plain text — the prompt payload for narration, and a fallback briefing. */
271
+ var renderDigest = (digest) => [
272
+ `Inbox digest (${digest.generatedAt})`,
273
+ `Threads: ${digest.threadCount} Topics: ${digest.topicCount}`,
274
+ `Awaiting my reply: ${digest.awaitingMine.join(", ") || "none"}`,
275
+ `Stalled: ${digest.stalledThreads.join(", ") || "none"}`,
276
+ `Due commitments: ${digest.dueCommitments.map((commitment) => `${commitment.who} → ${commitment.what} (${commitment.dueBy})`).join("; ") || "none"}`
277
+ ].join("\n");
278
+ /**
279
+ * Optionally add an LLM briefing over the skeleton, using the editable `digest` prompt. Degrades to
280
+ * the un-narrated digest on failure — the skeleton is always available.
281
+ */
282
+ var narrateDigest = async (digest, summarize, options) => {
283
+ const prompts = options?.prompts ? mergePrompts(options.prompts) : DEFAULT_EMAIL_PROMPTS;
284
+ try {
285
+ const narrative = await summarize([prompts.digest, renderDigest(digest)].join("\n\n"));
286
+ return {
287
+ ...digest,
288
+ narrative: narrative.trim()
289
+ };
290
+ } catch {
291
+ return digest;
292
+ }
293
+ };
294
+ //#endregion
295
+ //#region src/stages/context.ts
296
+ var emptyStats = () => ({
297
+ from: /* @__PURE__ */ new Map(),
298
+ to: /* @__PURE__ */ new Map(),
299
+ total: 0,
300
+ spam: 0
301
+ });
302
+ /**
303
+ * Shared context threaded through the email stages via Effect's Requirements channel. `db` is the
304
+ * ECHO space database (browser-safe); `stats` and `summaries` are mutable accumulators read after
305
+ * the run. Callers provide it once at the pipeline edge (`Effect.provide`).
306
+ */
307
+ var EmailPipelineCtx = class extends Context.Tag("@dxos/pipeline-email/EmailPipelineCtx")() {};
308
+ //#endregion
309
+ //#region src/stages/extract-contacts.ts
310
+ /** Extract a Person (+ Organization) from the sender and persist to the ECHO space; pass message through. */
311
+ var extractContactsStage = Stage.map("extract-contacts", (message) => Effect.gen(function* () {
312
+ const { db } = yield* EmailPipelineCtx;
313
+ const result = yield* extractContact({
314
+ db,
315
+ source: message
316
+ });
317
+ for (const object of result.created) db.add(object);
318
+ return message;
319
+ }));
320
+ //#endregion
321
+ //#region src/stages/extract-facts.ts
322
+ var extractFactsStage = (indexFacts) => Stage.map("extract-facts", (message) => Effect.tryPromise(() => indexFacts(message)).pipe(Effect.tapError((error) => Effect.logWarning("extract-facts failed; degrading to no facts", error)), Effect.orElse(() => Effect.succeed([])), Effect.as(message)));
323
+ //#endregion
324
+ //#region src/stages/facts.ts
325
+ var EMAIL_EXTRACTION_RULES = [
326
+ "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.",
327
+ "A deadline or due date becomes validTo on the relevant assertion (ISO date when known).",
328
+ "Attribute first-person statements (\"I will…\", \"we can…\") to the message sender as subject."
329
+ ];
330
+ var EMAIL_EXTRACT_OPTIONS = { rules: EMAIL_EXTRACTION_RULES };
331
+ var messageSource = (message) => {
332
+ const messageId = message.properties?.messageId;
333
+ if (typeof messageId === "string" && messageId.length > 0) return messageId;
334
+ return `${message.sender.email ?? "unknown"}:${message.created}`;
335
+ };
336
+ var messageToDocument = (message) => ({
337
+ text: Message.extractText(message),
338
+ source: messageSource(message),
339
+ ...message.sender.email ? { author: message.sender.email } : {},
340
+ date: message.created
341
+ });
342
+ //#endregion
343
+ //#region src/stages/extract-facts-commit.ts
344
+ var extractFactsUnitStage = (extract, concurrency = 1) => Stage.map("extract-facts-unit", (message) => Effect.tryPromise(() => extract(message)).pipe(Effect.tapError((error) => Effect.logWarning("extract-facts-unit failed; degrading to no facts", error)), Effect.orElse(() => Effect.succeed([])), Effect.map((facts) => ({
345
+ facts,
346
+ foreignId: messageSource(message),
347
+ key: keyOf(message)
348
+ }))), { concurrency });
349
+ var keyOf = (message) => Date.parse(message.created);
350
+ //#endregion
351
+ //#region src/stages/stats.ts
352
+ /** Pure-JS running tallies (senders, recipients, spam); pass message through. */
353
+ var statsStage = Stage.map("stats", (message) => Effect.gen(function* () {
354
+ const { stats } = yield* EmailPipelineCtx;
355
+ stats.total += 1;
356
+ const sender = message.sender.email;
357
+ if (sender) stats.from.set(sender, (stats.from.get(sender) ?? 0) + 1);
358
+ const recipients = message.properties?.to;
359
+ if (Array.isArray(recipients)) for (const recipient of recipients) {
360
+ const address = String(recipient);
361
+ stats.to.set(address, (stats.to.get(address) ?? 0) + 1);
362
+ }
363
+ if (message.properties?.spam) stats.spam += 1;
364
+ return message;
365
+ }));
366
+ //#endregion
367
+ //#region src/stages/summarize.ts
368
+ var SUMMARIZE_MODEL = "com.anthropic.model.claude-haiku-4-5.default";
369
+ var SUMMARIZE_PROMPT = trim`
370
+ Summarize the following email in one sentence, decide whether it is spam, and list up to five keywords.
371
+ Respond with ONLY a JSON object of the form {"summary": string, "isSpam": boolean, "keywords": string[]}.
372
+ `;
373
+ var parseSummary = (raw) => {
374
+ const match = raw.match(/\{[\s\S]*\}/);
375
+ if (!match) return {
376
+ summary: "",
377
+ isSpam: false,
378
+ keywords: []
379
+ };
380
+ try {
381
+ const parsed = JSON.parse(match[0]);
382
+ const keywords = parsed.keywords;
383
+ return {
384
+ summary: typeof parsed.summary === "string" ? parsed.summary : "",
385
+ isSpam: parsed.isSpam === true || parsed.spam === true || parsed.is_spam === true,
386
+ keywords: Array.isArray(keywords) ? keywords.map((keyword) => String(keyword)) : []
387
+ };
388
+ } catch {
389
+ return {
390
+ summary: "",
391
+ isSpam: false,
392
+ keywords: []
393
+ };
394
+ }
395
+ };
396
+ /** Summarize via AiService/LanguageModel; append a summary block and record spam/keywords + result. */
397
+ var summarizeStage = Stage.map("summarize", (message) => Effect.gen(function* () {
398
+ const ctx = yield* EmailPipelineCtx;
399
+ const text = Message.extractText(message);
400
+ const summary = parseSummary(yield* LanguageModel.generateText({ prompt: `${SUMMARIZE_PROMPT}\n\n${text}` }).pipe(Effect.provide(AiService.model(SUMMARIZE_MODEL).pipe(Layer.orDie)), Effect.timeout("30 seconds"), Effect.map((response) => response.text), Effect.orElse(() => Effect.succeed(""))));
401
+ const messageId = String(message.properties?.messageId ?? message.id);
402
+ ctx.summaries.push({
403
+ messageId,
404
+ summary
405
+ });
406
+ const summaryBlock = {
407
+ _tag: "text",
408
+ text: summary.summary
409
+ };
410
+ return Message.make({
411
+ id: message.id,
412
+ created: message.created,
413
+ sender: message.sender,
414
+ blocks: [...message.blocks, summaryBlock],
415
+ properties: {
416
+ ...message.properties,
417
+ spam: summary.isSpam,
418
+ keywords: summary.keywords
419
+ }
420
+ });
421
+ }));
422
+ //#endregion
423
+ //#region src/model-policy.ts
424
+ /** Anthropic model DXNs available to product pipelines. */
425
+ var MODEL_HAIKU = "com.anthropic.model.claude-haiku-4-5.default";
426
+ var MODEL_SONNET = "com.anthropic.model.claude-sonnet-4-6.default";
427
+ var MODEL_OPUS = "com.anthropic.model.claude-opus-4-8.default";
428
+ /**
429
+ * Default stage → model routing, seeded from the model-ladder findings (REPORT §4/§5): labeling and
430
+ * summaries want a premier model, drafting tolerates a cheaper one. Product defaults to haiku across
431
+ * the board (the cheapest premier tier); callers override per run via a `ModelPolicy`.
432
+ */
433
+ var DEFAULT_MODEL_POLICY = {
434
+ "classify-sender": MODEL_HAIKU,
435
+ "tag": MODEL_HAIKU,
436
+ "summarize-message": MODEL_HAIKU,
437
+ "summarize-thread": MODEL_HAIKU,
438
+ "summarize-topic": MODEL_HAIKU,
439
+ "extract-facts": MODEL_HAIKU,
440
+ "draft": MODEL_HAIKU
441
+ };
442
+ /** Resolves the model DXN for a stage: per-run `policy` wins over `DEFAULT_MODEL_POLICY`. */
443
+ var resolveModel = (stage, policy) => policy?.[stage] ?? DEFAULT_MODEL_POLICY[stage];
444
+ //#endregion
445
+ //#region src/stages/tag.ts
446
+ var TAG_PROMPT = trim`
447
+ Classify the following email. Assign between 1 and 5 short, lowercase, single-word topic tags
448
+ (e.g. "invoice", "newsletter", "security", "personal"). Decide whether the email is spam or a
449
+ cold marketing blast; if so include the tag "spam".
450
+ Decide whether the email is "bulk" — automated, no-action mail such as receipts, login or
451
+ security notices, order/shipping confirmations, or newsletters. If so include the tag "bulk".
452
+ Do NOT tag invoices, payment requests, or anything that needs a reply as "bulk".
453
+ Respond with ONLY a JSON object of the form {"tags": string[], "spam": boolean}.
454
+ `;
455
+ var BULK_SUBJECT_RE = /\b(receipts?|your (order|purchase|receipt)|order (confirmation|placed|shipped|#)|(has )?shipped|out for delivery|tracking (number|info)|sign[-\s]?in|log[-\s]?in|new login|verify( your)?|verification code|confirm your (email|address)|password reset|welcome to|newsletter|digest|weekly (update|digest)|unsubscribe|subscription (confirmed|renewed))\b/i;
456
+ var ACTION_SUBJECT_RE = /\b(invoices?|payment (due|request|reminder|required)|amount due|balance due|past due|overdue|outstanding balance|please pay|remittance|bill(ing)? (due|statement)|pay now)\b/i;
457
+ var BULK_LOCALPART_RE = /^(no-?reply|do-?not-?reply|receipts?|notifications?|notify|alerts?|updates?|news(letter)?|digest|mailer|postmaster)([._+-]|$)/i;
458
+ var UNSUBSCRIBE_BODY_RE = /\bunsubscribe\b/i;
459
+ /**
460
+ * Deterministic bulk / no-action signal. Returns `'bulk'` for mailing-list / automated mail —
461
+ * **any unsubscribe affordance** (a `List-Unsubscribe` header or an unsubscribe link in the body) is
462
+ * the top-priority give-away (RFC 2369), followed by receipts / logins / order confirmations /
463
+ * newsletters and no-reply role senders. Returns `'action'` when the mail requires a response
464
+ * (invoice / payment request) and has no unsubscribe affordance; `'unknown'` otherwise. Pure.
465
+ */
466
+ var classifyBulk = (input) => {
467
+ if ((input.listUnsubscribe ?? "").length > 0 || UNSUBSCRIBE_BODY_RE.test(input.bodyText ?? "")) return "bulk";
468
+ const subject = input.subject ?? "";
469
+ if (ACTION_SUBJECT_RE.test(subject)) return "action";
470
+ if (BULK_SUBJECT_RE.test(subject)) return "bulk";
471
+ const localPart = (input.senderEmail ?? "").split("@")[0] ?? "";
472
+ return BULK_LOCALPART_RE.test(localPart) ? "bulk" : "unknown";
473
+ };
474
+ /** Folds the deterministic bulk classification into a tag set: adds `bulk` for no-action mail, strips it for action-required mail. Pure. */
475
+ var applyBulkTag = (tags, classification) => {
476
+ if (classification === "action") return tags.filter((tag) => tag !== "bulk");
477
+ if (classification === "bulk" && !tags.includes("bulk")) return [...tags, "bulk"];
478
+ return [...tags];
479
+ };
480
+ /** Parses the tag response leniently (models wrap JSON in prose); infers/dedups the spam tag. Pure. */
481
+ var parseTagResult = (raw) => {
482
+ const match = raw.match(/\{[\s\S]*\}/);
483
+ if (!match) return {
484
+ tags: [],
485
+ spam: false,
486
+ bulk: false
487
+ };
488
+ try {
489
+ const parsed = JSON.parse(match[0]);
490
+ const tags = Array.isArray(parsed.tags) ? parsed.tags.map((tag) => String(tag).toLowerCase()).filter((tag) => tag.length > 0) : [];
491
+ const spam = parsed.spam === true || tags.includes("spam");
492
+ return {
493
+ tags: spam && !tags.includes("spam") ? [...tags, "spam"] : tags,
494
+ spam,
495
+ bulk: tags.includes("bulk")
496
+ };
497
+ } catch {
498
+ return {
499
+ tags: [],
500
+ spam: false,
501
+ bulk: false
502
+ };
503
+ }
504
+ };
505
+ /**
506
+ * Tags a single message (topic tags + spam) via the AI service. The model comes from the `tag` stage
507
+ * of the model policy (overridable per run). Degrades to no tags on timeout/error so one bad message
508
+ * never fails the pipeline.
509
+ */
510
+ var tagMessage = (message, options = {}) => Effect.gen(function* () {
511
+ const subject = String(message.properties?.subject ?? "");
512
+ const body = Message.extractText(message);
513
+ const parsed = parseTagResult(yield* LanguageModel.generateText({ prompt: `${TAG_PROMPT}\n\nSubject: ${subject}\n\n${body}` }).pipe(Effect.provide(AiService.model(resolveModel("tag", options.policy)).pipe(Layer.orDie)), Effect.timeout("30 seconds"), Effect.map((response) => response.text), Effect.orElse(() => Effect.succeed(""))));
514
+ const listUnsubscribe = message.properties?.listUnsubscribe;
515
+ const classification = classifyBulk({
516
+ subject,
517
+ senderEmail: message.sender?.email,
518
+ listUnsubscribe: typeof listUnsubscribe === "string" ? listUnsubscribe : void 0,
519
+ bodyText: body
520
+ });
521
+ const tags = applyBulkTag(parsed.tags, classification);
522
+ return {
523
+ tags,
524
+ spam: parsed.spam,
525
+ bulk: tags.includes("bulk")
526
+ };
527
+ });
528
+ //#endregion
529
+ //#region src/stages/EmailStage.ts
530
+ var EmailStage_exports = /* @__PURE__ */ __exportAll({
531
+ extractContacts: () => extractContacts,
532
+ htmlToMarkdown: () => htmlToMarkdown,
533
+ processAttachments: () => processAttachments,
534
+ queryDraftPool: () => queryDraftPool,
535
+ reconcileDrafts: () => reconcileDrafts,
536
+ toCommitUnit: () => toCommitUnit
537
+ });
538
+ var __dxlog_file$1 = "/__w/dxos/dxos/packages/core/compute/pipeline-email/src/stages/EmailStage.ts";
539
+ /**
540
+ * Normalizes an item's `body` from (possibly) HTML into markdown via `normalizeText` (turndown when
541
+ * HTML, passthrough for plaintext). Requires nothing from the Requirements channel. Written as a
542
+ * generic pipeable so the item type infers from the upstream stream (no type argument needed).
543
+ */
544
+ var htmlToMarkdown = (self) => Stage.map("html-to-markdown", (item) => Effect.sync(() => ({
545
+ ...item,
546
+ body: normalizeText(item.body)
547
+ })))(self);
548
+ /**
549
+ * Builds a Person (+ Organization link by domain) from the message sender and records it on the
550
+ * item as {@link Insert.contact} — the stage writes nothing itself; {@link toCommitUnit} defers
551
+ * the actual `db.add` to commit.
552
+ *
553
+ * Dedup is the space's shared identity index (see `getIdentityIndex`), refreshed once at the start
554
+ * of the run: it covers contacts committed before the run, contacts built earlier in it, and — since
555
+ * the index is shared per database — contacts a concurrently-syncing mailbox is building right now.
556
+ *
557
+ * Not every sender earns a record: {@link senderSignals} feeds the allow-list in
558
+ * `shouldExtractContact`, so bulk and automated mail no longer fills the space with contacts nobody
559
+ * will look at. NOTE: no provider records an outbound signal yet, so in practice the allow-list is
560
+ * "the domain matches a known Organization" — stricter than the policy intends. Extracting the
561
+ * recipients of sent mail is what closes that gap.
562
+ */
563
+ var extractContacts = () => {
564
+ let index;
565
+ return Stage.map("extract-contacts", (change) => Effect.gen(function* () {
566
+ if (change._tag !== "insert") return change;
567
+ const { db } = yield* Database.Service;
568
+ if (!index) index = overlayIdentityIndex(identitySpecs, yield* getIdentityIndex(db, { refresh: true }));
569
+ const sender = change.message.sender;
570
+ const contact = sender ? yield* buildContactFromActor(sender, db, {
571
+ signals: senderSignals(change.message),
572
+ index
573
+ }) : void 0;
574
+ return contact ? {
575
+ ...change,
576
+ contact
577
+ } : change;
578
+ }));
579
+ };
580
+ /**
581
+ * The sender signals the shared extraction gate reads, taken from the fields provider mappers
582
+ * already record on `properties`. `Precedence`/`Auto-Submitted` are folded into `bulk` by the mapper
583
+ * where available.
584
+ */
585
+ var senderSignals = (message) => {
586
+ const properties = message.properties ?? {};
587
+ return {
588
+ noReply: properties.noReply === true,
589
+ listUnsubscribe: typeof properties.listUnsubscribe === "string" ? properties.listUnsubscribe : void 0,
590
+ bulk: properties.bulk === true
591
+ };
592
+ };
593
+ /**
594
+ * Turns each of the item's `attachments` into a Blob object (via the database's configured storage
595
+ * backend — edge in Composer) and adds a {@link Message.Attachment} pointing at it to the message.
596
+ * {@link toCommitUnit} finds these blobs again via each attachment's ref (inlined, since the
597
+ * blob isn't attached to a database yet) to defer their feed append to commit. A no-op when the item
598
+ * has no attachments.
599
+ *
600
+ * One bad or oversized attachment must not fail the whole message: each `Blob.fromBytes` is caught
601
+ * and logged individually, so a rejected attachment is simply dropped from the message.
602
+ */
603
+ var processAttachments = () => Stage.map("process-attachments", (change) => Effect.gen(function* () {
604
+ if (change._tag !== "insert") return change;
605
+ const mapped = change;
606
+ if (!mapped.attachments?.length) return mapped;
607
+ const attachments = [];
608
+ for (const attachment of mapped.attachments) {
609
+ const blob = yield* Blob.fromBytes(attachment.bytes, { type: attachment.mimeType }).pipe(Effect.catchAll((error) => {
610
+ log.catch(error, {
611
+ foreignId: mapped.foreignId,
612
+ name: attachment.name,
613
+ size: attachment.size
614
+ }, {
615
+ "~LogMeta": "~LogMeta",
616
+ F: __dxlog_file$1,
617
+ L: 185,
618
+ S: this
619
+ });
620
+ return Effect.succeed(void 0);
621
+ }));
622
+ if (!blob) continue;
623
+ attachments.push({
624
+ name: attachment.name,
625
+ ref: Ref.make(blob),
626
+ contentId: attachment.contentId
627
+ });
628
+ }
629
+ if (attachments.length === 0) return mapped;
630
+ Obj.update(mapped.message, (message) => {
631
+ message.attachments = [...message.attachments ?? [], ...attachments];
632
+ });
633
+ return mapped;
634
+ }));
635
+ /**
636
+ * Queries the parent container's already-sent drafts once per sync run and pools them by the provider
637
+ * message id captured at send time (`properties.sentMessageId`) — the key {@link reconcileDrafts}
638
+ * matches each incoming message's `foreignId` against. Doing this once (rather than per item) keeps
639
+ * reconciliation off the database as messages stream through. Unsent drafts (no `sentMessageId`) are
640
+ * never pooled and thus never eligible for removal.
641
+ *
642
+ * `parent` is the local root object drafts are scoped to (e.g. a Mailbox) — only its URI is read, so
643
+ * any object works.
644
+ */
645
+ var queryDraftPool = Effect.fn("queryDraftPool")(function* (parent) {
646
+ const parentUri = Obj.getURI(parent);
647
+ const drafts = (yield* Database.query(Filter.type(Message.Message, { properties: { mailbox: parentUri } })).run).filter((candidate) => DraftMessage.belongsTo(candidate, parentUri) && candidate.properties?.sentMessageId);
648
+ const pool = /* @__PURE__ */ new Map();
649
+ for (const draft of drafts) {
650
+ const sentMessageId = draft.properties?.sentMessageId;
651
+ if (!sentMessageId) continue;
652
+ const existing = pool.get(sentMessageId);
653
+ if (existing) existing.push(draft);
654
+ else pool.set(sentMessageId, [draft]);
655
+ }
656
+ return pool;
657
+ });
658
+ /**
659
+ * Records the local drafts each incoming message supersedes — matched by the provider message id the
660
+ * draft captured at send time (`properties.sentMessageId`), which equals the synced message's
661
+ * `foreignId` — from a pool built once per run and keyed by that id. Writes nothing itself;
662
+ * {@link toCommitUnit} defers the `db.remove` to commit, so the canonical message is appended
663
+ * to the feed before its now-redundant drafts are deleted. A stage factory: pass the run's draft
664
+ * pool once.
665
+ *
666
+ * The pool is queried a single time by the caller (per sync operation), so this stage never touches
667
+ * the database or feed per item.
668
+ */
669
+ var reconcileDrafts = (draftPool) => Stage.map("reconcile-drafts", (change) => Effect.sync(() => {
670
+ if (change._tag !== "insert") return change;
671
+ const drafts = draftPool.get(change.foreignId);
672
+ return drafts?.length ? {
673
+ ...change,
674
+ supersededDrafts: drafts
675
+ } : change;
676
+ }));
677
+ /**
678
+ * Terminal stage converting a run's {@link Change} into the generic `Cursor.CommitUnit` the commit sink
679
+ * consumes. For an `insert`: `message.attachments` (populated by {@link processAttachments}) becomes a
680
+ * feed-append commit effect for the referenced blobs, `mapped.contact` (from {@link extractContacts})
681
+ * becomes a `db.add` commit effect, and `mapped.supersededDrafts` (from {@link reconcileDrafts})
682
+ * becomes a `db.remove` commit effect. Every other email stage is Change → Change and composes in
683
+ * any order; this is the one stage that must run last.
684
+ *
685
+ * Tagging is email-specific (`Cursor.CommitUnit` carries no tag data), so it's applied here rather
686
+ * than by the generic commit sink: `tagIndex` is optional (omit for providers/tests that don't tag),
687
+ * and every tagged unit attaches the *same* `applyTags` closure (keyed by `mapped.tagUris` recorded in
688
+ * a run-scoped map, read back by object identity) so the commit's identity-based batching applies
689
+ * every tag pair for the page in one `Tagging.setBatch` call rather than one per message.
690
+ */
691
+ var toCommitUnit = (options = {}) => {
692
+ const { tagIndex } = options;
693
+ const tagUrisByObject = /* @__PURE__ */ new WeakMap();
694
+ const applyTags = Effect.fn("email.commit.tags")(function* (units) {
695
+ if (!tagIndex) return;
696
+ const tagEntries = [];
697
+ for (const unit of units) {
698
+ if (!unit.object) continue;
699
+ for (const uri of tagUrisByObject.get(unit.object) ?? []) tagEntries.push({
700
+ object: unit.object,
701
+ tagId: uri
702
+ });
703
+ }
704
+ if (tagEntries.length > 0) Tagging.setBatch(tagEntries, { index: tagIndex });
705
+ });
706
+ const retagByForeignId = /* @__PURE__ */ new Map();
707
+ const applyRetags = Effect.fn("email.commit.retags")(function* (units) {
708
+ if (!tagIndex) return;
709
+ const accessor = TagIndex.bind(tagIndex);
710
+ for (const unit of units) {
711
+ const retag = retagByForeignId.get(unit.foreignId);
712
+ if (!retag) continue;
713
+ for (const tagId of retag.addTagIds) accessor.setTag(tagId, retag.entityId);
714
+ for (const tagId of retag.removeTagIds) accessor.unsetTag(tagId, retag.entityId);
715
+ }
716
+ });
717
+ return Stage.map("to-commit-unit", (change) => Effect.gen(function* () {
718
+ if (change._tag === "retag") {
719
+ retagByForeignId.set(change.foreignId, {
720
+ entityId: change.entityId,
721
+ addTagIds: change.addTagIds,
722
+ removeTagIds: change.removeTagIds
723
+ });
724
+ return {
725
+ foreignId: change.foreignId,
726
+ key: 0,
727
+ commitEffects: tagIndex ? [applyRetags] : void 0
728
+ };
729
+ }
730
+ if (change._tag === "delete") return {
731
+ foreignId: change.foreignId,
732
+ key: 0
733
+ };
734
+ const mapped = change;
735
+ const { feed } = yield* Cursor.Service;
736
+ const commitEffects = [];
737
+ const blobs = (mapped.message.attachments ?? []).map((attachment) => attachment.ref.target).filter((target) => Obj.instanceOf(Blob.Blob, target));
738
+ if (feed && blobs.length > 0) commitEffects.push(() => Feed.append(feed, blobs));
739
+ if (mapped.contact) {
740
+ const contact = mapped.contact;
741
+ commitEffects.push(Effect.fn("email.commit.addContact")(function* () {
742
+ yield* Database.add(contact);
743
+ }));
744
+ }
745
+ if (mapped.supersededDrafts?.length) {
746
+ const drafts = mapped.supersededDrafts;
747
+ commitEffects.push(Effect.fn("email.commit.removeDrafts")(function* () {
748
+ const { db } = yield* Database.Service;
749
+ for (const draft of drafts) db.remove(draft);
750
+ }));
751
+ }
752
+ if (tagIndex && mapped.tagUris?.length) {
753
+ tagUrisByObject.set(mapped.message, mapped.tagUris);
754
+ commitEffects.push(applyTags);
755
+ }
756
+ return {
757
+ object: mapped.message,
758
+ foreignId: mapped.foreignId,
759
+ key: mapped.key,
760
+ commitEffects: commitEffects.length > 0 ? commitEffects : void 0
761
+ };
762
+ }));
763
+ };
764
+ //#endregion
765
+ //#region src/fact-pipeline.ts
766
+ var __dxlog_file = "/__w/dxos/dxos/packages/core/compute/pipeline-email/src/fact-pipeline.ts";
767
+ var EmailFactPipeline = {
768
+ /**
769
+ * Facts-only assembly of the email stages over a batch of messages: stats → extract-facts. Omits
770
+ * summarize, extract-contacts, and {@link buildThreads} from {@link EmailPipeline}, so it needs no
771
+ * `AiService` and never writes to `db`. The cursored `AnalyzeMailbox` operation (in plugin-inbox)
772
+ * covers the same fact-extraction concern incrementally, via a sibling facts-returning stage
773
+ * (`extractFactsUnitStage`) that commits pages atomically with a cursor advance instead of indexing
774
+ * in-stage; this batch variant is for one-shot runs over an already-collected set of messages.
775
+ */
776
+ run: (messages, options) => Effect.gen(function* () {
777
+ const stats = emptyStats();
778
+ const collected = [];
779
+ yield* Stream.fromIterable(messages).pipe(statsStage, extractFactsStage(options.indexFacts), Pipeline.run({ sink: (message) => Effect.sync(() => collected.push(message)) }), Effect.provideService(EmailPipelineCtx, {
780
+ db: options.db,
781
+ stats,
782
+ summaries: []
783
+ }));
784
+ return {
785
+ messages: collected,
786
+ stats
787
+ };
788
+ }) };
789
+ /**
790
+ * Runs the cursored fact pipeline over a feed: dedup → extract facts → commit each page to the
791
+ * {@link FactStore} while advancing `cursor`. Extraction is injected (`extract`) so the pipeline is
792
+ * unit-testable with a deterministic stub — no `AiService` required. Feed-generic (the mailbox
793
+ * `AnalyzeMailbox` operation is a thin wrapper that resolves a Mailbox to its feed and its persisted
794
+ * `Cursor`).
795
+ *
796
+ * Dedup is by `cursor`'s high-water key (a coarse `key < cursorKey` skip) plus the set of message
797
+ * sources already in the store (the precise idempotency backstop) — the store doesn't share `cursor`'s
798
+ * persistence lifetime (it's still in-memory per space), so the source-dedup backstop still matters
799
+ * even though the cursor itself is now durable.
800
+ */
801
+ var runFactPipeline = (options) => Effect.gen(function* () {
802
+ const { feed, cursor, extract, pageSize, concurrency = 1 } = options;
803
+ const store = yield* FactStore;
804
+ const priorFacts = yield* store.query({}).pipe(Effect.orElseSucceed(() => []));
805
+ const indexedSources = new Set(priorFacts.map((fact) => fact.attribution.source));
806
+ let cursorKey = Cursor.parseKey(cursor.max);
807
+ let processed = 0;
808
+ let facts = 0;
809
+ const messages = yield* Feed.query(feed, Filter.type(Message.Message)).run;
810
+ log.info("analyze: pipeline start", {
811
+ messages: messages.length,
812
+ cursorKey,
813
+ indexed: indexedSources.size,
814
+ pageSize
815
+ }, {
816
+ "~LogMeta": "~LogMeta",
817
+ F: __dxlog_file,
818
+ L: 106,
819
+ S: this
820
+ });
821
+ yield* Stream.fromIterable(messages).pipe(Stage.map("facts-dedup", (message) => Effect.sync(() => Date.parse(message.created) < cursorKey || indexedSources.has(messageSource(message)) ? void 0 : message)), extractFactsUnitStage(extract, concurrency), Stage.map("facts-log", (unit) => Effect.sync(() => {
822
+ log.info("analyze: extracted unit", {
823
+ foreignId: unit.foreignId,
824
+ key: unit.key,
825
+ facts: unit.facts.length
826
+ }, {
827
+ "~LogMeta": "~LogMeta",
828
+ F: __dxlog_file,
829
+ L: 122,
830
+ S: this
831
+ });
832
+ return unit;
833
+ })), Stream.grouped(pageSize), Pipeline.run({ sink: (page) => Effect.gen(function* () {
834
+ const units = Chunk.toReadonlyArray(page);
835
+ if (units.length === 0) return;
836
+ const pageFacts = units.flatMap((unit) => unit.facts);
837
+ if (pageFacts.length > 0) yield* store.putFacts(pageFacts).pipe(Effect.orDie);
838
+ processed += units.length;
839
+ facts += pageFacts.length;
840
+ cursorKey = Math.max(cursorKey, ...units.map((unit) => unit.key));
841
+ Cursor.advance(cursor, Cursor.formatKey(cursorKey));
842
+ log.info("analyze: committed page", {
843
+ page: units.length,
844
+ pageFacts: pageFacts.length,
845
+ processed,
846
+ facts,
847
+ cursorKey
848
+ }, {
849
+ "~LogMeta": "~LogMeta",
850
+ F: __dxlog_file,
851
+ L: 148,
852
+ S: this
853
+ });
854
+ }) }));
855
+ log.info("analyze: pipeline done", {
856
+ processed,
857
+ facts
858
+ }, {
859
+ "~LogMeta": "~LogMeta",
860
+ F: __dxlog_file,
861
+ L: 159,
862
+ S: this
863
+ });
864
+ return {
865
+ processed,
866
+ facts
867
+ };
868
+ });
869
+ //#endregion
870
+ //#region src/types/Thread.ts
871
+ var ThreadState = Schema.Literal("awaiting-mine", "awaiting-theirs", "resolved", "stalled");
872
+ var Thread = class extends Type.makeObject(DXN.make("org.dxos.type.emailThread", "0.1.0"))(Schema.Struct({
873
+ threadId: Schema.String,
874
+ subject: Schema.String,
875
+ summary: Schema.String,
876
+ state: ThreadState,
877
+ participants: Schema.Array(Schema.String),
878
+ messageIds: Schema.Array(Schema.String),
879
+ openQuestions: Schema.Array(Schema.String),
880
+ actionItems: Schema.Array(Schema.String)
881
+ })) {};
882
+ //#endregion
883
+ //#region src/internal/threads.ts
884
+ var DEFAULT_STALE_PERIOD_MS = 336 * 60 * 60 * 1e3;
885
+ /** Lowercased set of the owner's addresses (handles the single-string and multi-alias forms). */
886
+ var ownerAddressSet = (ownerEmail) => new Set((Array.isArray(ownerEmail) ? ownerEmail : [ownerEmail]).map((email) => email.toLowerCase()).filter(Boolean));
887
+ var computeState = (lastFromOwner, lastCreated, options) => {
888
+ if (new Date(options.now).getTime() - new Date(lastCreated).getTime() > (options.stalePeriodMs ?? DEFAULT_STALE_PERIOD_MS)) return "stalled";
889
+ return lastFromOwner ? "awaiting-theirs" : "awaiting-mine";
890
+ };
891
+ var buildThreads = (messages, options) => {
892
+ const owners = ownerAddressSet(options.ownerEmail);
893
+ const buckets = /* @__PURE__ */ new Map();
894
+ for (const message of messages) {
895
+ const threadId = deriveThreadId(message);
896
+ let bucket = buckets.get(threadId);
897
+ if (!bucket) {
898
+ bucket = [];
899
+ buckets.set(threadId, bucket);
900
+ }
901
+ bucket.push(message);
902
+ }
903
+ const threads = [];
904
+ for (const [threadId, bucket] of buckets) {
905
+ const ordered = [...bucket].sort((a, b) => a.created.localeCompare(b.created));
906
+ const last = ordered[ordered.length - 1];
907
+ const lastFromOwner = owners.has(last.sender.email?.toLowerCase() ?? "");
908
+ const participants = [...new Set(ordered.flatMap((m) => m.sender.email ? [m.sender.email.toLowerCase()] : []))];
909
+ const summaries = ordered.flatMap((m) => typeof m.properties?.summary === "string" && m.properties.summary.length > 0 ? [m.properties.summary] : []);
910
+ const subject = typeof ordered[0].properties?.subject === "string" ? ordered[0].properties.subject : threadId;
911
+ threads.push(Obj.make(Thread, {
912
+ threadId,
913
+ subject,
914
+ summary: summaries.length > 0 ? summaries.join("\n") : subject,
915
+ state: computeState(lastFromOwner, last.created, options),
916
+ participants,
917
+ messageIds: ordered.map(messageSource),
918
+ openQuestions: [],
919
+ actionItems: []
920
+ }));
921
+ }
922
+ return threads;
923
+ };
924
+ //#endregion
925
+ //#region src/pipeline.ts
926
+ var EmailPipeline = {
927
+ /**
928
+ * Default assembly of the email stages over a batch of messages: summarize → extract-contacts →
929
+ * stats → extract-facts stream through with back pressure, then {@link buildThreads} groups the
930
+ * collected messages into canonical threads. Contacts and threads are persisted to `db`; the fact
931
+ * substrate is populated via `indexFacts`. Requires an `AiService` (summarize); the shared
932
+ * {@link EmailPipelineCtx} is constructed and provided internally.
933
+ */
934
+ run: (messages, options) => Effect.gen(function* () {
935
+ const stats = emptyStats();
936
+ const summaries = [];
937
+ const collected = [];
938
+ yield* Stream.fromIterable(messages).pipe(summarizeStage, extractContactsStage, statsStage, extractFactsStage(options.indexFacts), Pipeline.run({ sink: (message) => Effect.sync(() => collected.push(message)) }), Effect.provideService(EmailPipelineCtx, {
939
+ db: options.db,
940
+ stats,
941
+ summaries
942
+ }));
943
+ const threads = buildThreads(collected, {
944
+ ownerEmail: options.ownerEmail,
945
+ now: options.now
946
+ });
947
+ for (const thread of threads) options.db.add(thread);
948
+ return {
949
+ messages: collected,
950
+ stats,
951
+ summaries,
952
+ threads
953
+ };
954
+ }) };
955
+ //#endregion
956
+ //#region src/topics-pipeline.ts
957
+ var messageIdOf = (message) => String(message.properties?.messageId ?? message.id);
958
+ /**
959
+ * Runs the topics pipeline: tag (bounded/resumable) → `buildThreads` → `clusterThreads` →
960
+ * `summarizeTopics`. Returns per-message tags and the summarized topic drafts for the caller to
961
+ * materialize or suggest. Tagging failures degrade per message (the injected `tag` should not
962
+ * reject); clustering is deterministic.
963
+ */
964
+ var runTopicsPipeline = async (input, deps) => {
965
+ const { messages, ownerEmail, now, limit, skipMessage, skipTopic, keepTopic, topicOptions, onProgress, signal } = input;
966
+ const pending = messages.filter((message) => !skipMessage?.(messageIdOf(message)));
967
+ const toTag = limit !== void 0 ? pending.slice(0, limit) : pending;
968
+ const messageTags = [];
969
+ for (let index = 0; index < toTag.length && !signal?.aborted; index++) {
970
+ const message = toTag[index];
971
+ const result = await deps.tag(message);
972
+ messageTags.push({
973
+ messageId: messageIdOf(message),
974
+ tags: result.tags,
975
+ spam: result.spam
976
+ });
977
+ onProgress?.("tag", index + 1, toTag.length);
978
+ }
979
+ if (signal?.aborted) return {
980
+ messageTags,
981
+ topicDrafts: []
982
+ };
983
+ return {
984
+ messageTags,
985
+ topicDrafts: clusterThreads(buildThreads(messages, {
986
+ ownerEmail,
987
+ now
988
+ }), topicOptions)
989
+ };
990
+ };
991
+ //#endregion
992
+ export { DEFAULT_COMMITMENT_PREDICATES, DEFAULT_EMAIL_PROMPTS, DEFAULT_MODEL_POLICY, DEFAULT_TOPIC_OPTIONS, EMAIL_EXTRACTION_RULES, EMAIL_EXTRACT_OPTIONS, EmailFactPipeline, EmailPipeline, EmailPipelineCtx, EmailStage_exports as EmailStage, MODEL_HAIKU, MODEL_OPUS, MODEL_SONNET, Thread, ThreadState, applyBulkTag, buildDigest, buildRollups, buildThreads, classifyBulk, clusterThreads, commitmentLedger, deriveThreadId, emptyStats, extractContactsStage, extractFactsStage, extractFactsUnitStage, materializeTopics, mergePrompts, messageSource, messageToDocument, narrateDigest, normalizeSubject, parseTagResult, renderDigest, resolveModel, runFactPipeline, runTopicsPipeline, statsStage, summarizeStage, summarizeTopics, tagMessage };
993
+
994
+ //# sourceMappingURL=index.mjs.map