@dxos/pipeline-email 0.0.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/index.mjs +994 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/testing.mjs +49 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/corpus/digest.d.ts +0 -4
- package/dist/types/src/corpus/digest.d.ts.map +1 -1
- package/dist/types/src/corpus/topics.d.ts +12 -4
- package/dist/types/src/corpus/topics.d.ts.map +1 -1
- package/dist/types/src/fact-pipeline.d.ts +28 -3
- package/dist/types/src/fact-pipeline.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/internal/threads.d.ts +5 -1
- package/dist/types/src/internal/threads.d.ts.map +1 -1
- package/dist/types/src/model-policy.d.ts +17 -0
- package/dist/types/src/model-policy.d.ts.map +1 -0
- package/dist/types/src/model-policy.test.d.ts +2 -0
- package/dist/types/src/model-policy.test.d.ts.map +1 -0
- package/dist/types/src/stages/EmailStage.d.ts +149 -0
- package/dist/types/src/stages/EmailStage.d.ts.map +1 -0
- package/dist/types/src/stages/extract-facts-commit.d.ts +1 -1
- package/dist/types/src/stages/extract-facts-commit.d.ts.map +1 -1
- package/dist/types/src/stages/index.d.ts +2 -0
- package/dist/types/src/stages/index.d.ts.map +1 -1
- package/dist/types/src/stages/tag.d.ts +38 -0
- package/dist/types/src/stages/tag.d.ts.map +1 -0
- package/dist/types/src/stages/tag.test.d.ts +2 -0
- package/dist/types/src/stages/tag.test.d.ts.map +1 -0
- package/dist/types/src/topics-pipeline.d.ts +55 -0
- package/dist/types/src/topics-pipeline.d.ts.map +1 -0
- package/dist/types/src/topics-pipeline.test.d.ts +2 -0
- package/dist/types/src/topics-pipeline.test.d.ts.map +1 -0
- package/dist/types/src/types/index.d.ts +0 -1
- package/dist/types/src/types/index.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +22 -17
- package/src/corpus/digest.test.ts +1 -1
- package/src/corpus/digest.ts +6 -6
- package/src/corpus/topics.test.ts +72 -18
- package/src/corpus/topics.ts +61 -15
- package/src/fact-pipeline.ts +114 -4
- package/src/index.ts +4 -0
- package/src/internal/threads.test.ts +12 -0
- package/src/internal/threads.ts +11 -3
- package/src/model-policy.test.ts +21 -0
- package/src/model-policy.ts +45 -0
- package/src/stages/EmailStage.ts +398 -0
- package/src/stages/extract-facts-commit.ts +16 -7
- package/src/stages/index.ts +3 -0
- package/src/stages/tag.test.ts +98 -0
- package/src/stages/tag.ts +150 -0
- package/src/testing/email-pipeline.test.ts +9 -6
- package/src/topics-pipeline.test.ts +95 -0
- package/src/topics-pipeline.ts +106 -0
- package/src/types/index.ts +0 -1
- package/dist/lib/neutral/index.mjs +0 -650
- package/dist/lib/neutral/index.mjs.map +0 -7
- package/dist/lib/neutral/meta.json +0 -1
- package/dist/lib/neutral/testing/index.mjs +0 -48
- package/dist/lib/neutral/testing/index.mjs.map +0 -7
- package/dist/types/src/types/Topic.d.ts +0 -12
- package/dist/types/src/types/Topic.d.ts.map +0 -1
- package/dist/types/src/types/Topic.test.d.ts +0 -2
- package/dist/types/src/types/Topic.test.d.ts.map +0 -1
- package/src/types/Topic.test.ts +0 -25
- 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
|