@dxos/pipeline-email 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/corpus/prompts.ts","../../src/corpus/topics.ts","../../src/corpus/ledger.ts","../../src/internal/threading.ts","../../src/corpus/rollups.ts","../../src/corpus/digest.ts","../../src/stages/context.ts","../../src/stages/extract-contacts.ts","../../src/stages/extract-facts.ts","../../src/stages/facts.ts","../../src/stages/extract-facts-commit.ts","../../src/stages/stats.ts","../../src/stages/summarize.ts","../../src/model-policy.ts","../../src/stages/tag.ts","../../src/stages/EmailStage.ts","../../src/fact-pipeline.ts","../../src/types/Thread.ts","../../src/internal/threads.ts","../../src/pipeline.ts","../../src/topics-pipeline.ts"],"sourcesContent":["//\n// Copyright 2026 DXOS.org\n//\n\nimport { trim } from '@dxos/util';\n\n/**\n * User-editable prompts for the LLM-assisted corpus algorithms. Every slot has a default; callers\n * override any subset via {@link mergePrompts}, so the discovery algorithms stay configurable\n * without forking the pipeline. (Extraction rules are configured separately, via pipeline-rdf\n * `ExtractOptions.rules`.)\n */\nexport type EmailPrompts = {\n /** Summarize one topic (cluster of threads); receives the topic label + per-thread summaries. */\n readonly topicSummary: string;\n /** Narrate a corpus digest; receives the deterministic digest skeleton. */\n readonly digest: string;\n};\n\nexport const DEFAULT_EMAIL_PROMPTS: EmailPrompts = {\n topicSummary: trim`\n Summarize the following email conversation topic in 1-2 sentences.\n Focus on what the threads have in common and any outcome or open question.\n Respond with ONLY the summary text.\n `,\n digest: trim`\n Write a short briefing (3-5 sentences) from the following inbox digest data.\n Lead with what needs attention (stalled threads, due commitments), then notable topics.\n Respond with ONLY the briefing text.\n `,\n};\n\n/** Overlay caller overrides onto the defaults; absent slots keep their default. */\nexport const mergePrompts = (overrides?: Partial<EmailPrompts>): EmailPrompts => ({\n ...DEFAULT_EMAIL_PROMPTS,\n ...overrides,\n});\n\n/**\n * LLM call boundary used by the corpus algorithms: the harness owns model/runtime plumbing and hands\n * modules a plain async closure (same pattern as `FactIndexer`), so algorithm Effects stay R = never.\n */\nexport type Summarizer = (prompt: string) => Promise<string>;\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Project from '@dxos/compute/Project';\n\nimport { type Thread } from '../types/index.ts';\nimport { DEFAULT_EMAIL_PROMPTS, type EmailPrompts, type Summarizer, mergePrompts } from './prompts.ts';\n\n// TODO(burdon): Move to LLM.\n// Subject tokens that carry no topical signal; excluded from signatures and keywords.\nconst DEFAULT_STOPWORDS: readonly string[] = [\n 'the',\n 'and',\n 'for',\n 'with',\n 'from',\n 'about',\n 'this',\n 'that',\n 'your',\n 'our',\n 'are',\n 'was',\n 'will',\n 'has',\n 'have',\n 'not',\n 'you',\n 'all',\n 'any',\n 'can',\n 'per',\n 'new',\n];\n\n/**\n * Tunable knobs for the deterministic shared-signal clustering (spec §10.3: agglomeration over\n * participant/keyword overlap; embedding/LLM grouping deferred). All values have defaults so\n * callers can adjust the algorithm without forking it.\n */\nexport type TopicOptions = {\n /** Minimum Jaccard similarity between subject-token signatures that joins a topic. */\n readonly similarityThreshold?: number;\n /** Shared-participant count that joins a topic regardless of token overlap. */\n readonly minSharedParticipants?: number;\n /** Subject tokens shorter than this are ignored. */\n readonly minTokenLength?: number;\n /** Tokens excluded from signatures and keywords. */\n readonly stopwords?: readonly string[];\n /** Keywords retained per topic (most frequent first). */\n readonly maxKeywords?: number;\n /**\n * Drop identifier tokens (invoice hashes, order numbers, tracking codes) from subject signatures.\n * These are unique per message, so they fragment otherwise-identical automated mail into singletons.\n */\n readonly dropIdTokens?: boolean;\n};\n\nexport const DEFAULT_TOPIC_OPTIONS: Required<TopicOptions> = {\n similarityThreshold: 0.25,\n minSharedParticipants: 2,\n minTokenLength: 3,\n stopwords: DEFAULT_STOPWORDS,\n maxKeywords: 5,\n dropIdTokens: true,\n};\n\n/** A topic before ECHO materialization: plain values so LLM enrichment can rewrite them freely. */\nexport type TopicDraft = {\n readonly name: string;\n readonly summary: string;\n readonly threadIds: readonly string[];\n readonly participants: readonly string[];\n readonly keywords: readonly string[];\n readonly questions: readonly string[];\n readonly tasks: readonly string[];\n};\n\ntype Signature = {\n readonly thread: Thread;\n readonly tokens: Set<string>;\n readonly participants: Set<string>;\n};\n\n// Identifiers (invoice hashes, order numbers, tracking codes, years) carry no topical signal and are\n// unique per message, so they must be dropped or near-identical automated mail never collapses. A\n// token is an id if it is a pure number, a hex-looking string, or a digit-heavy alphanumeric code;\n// short version-like tokens (\"q4\", \"v2\") are kept.\nconst isIdToken = (token: string): boolean => {\n if (/^\\d+$/.test(token)) {\n return true;\n }\n const digits = (token.match(/\\d/g) ?? []).length;\n if (digits === 0) {\n return false;\n }\n if (/^[0-9a-f]+$/.test(token) && token.length >= 6) {\n return true;\n }\n return token.length >= 5 && digits / token.length >= 0.3;\n};\n\nconst tokenize = (subject: string, options: Required<TopicOptions>): Set<string> =>\n new Set(\n subject\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(\n (token) =>\n token.length >= options.minTokenLength &&\n !options.stopwords.includes(token) &&\n !(options.dropIdTokens && isIdToken(token)),\n ),\n );\n\nconst jaccard = (left: Set<string>, right: Set<string>): number => {\n if (left.size === 0 || right.size === 0) {\n return 0;\n }\n let shared = 0;\n for (const token of left) {\n if (right.has(token)) {\n shared += 1;\n }\n }\n return shared / (left.size + right.size - shared);\n};\n\nconst sharedCount = (left: Set<string>, right: Set<string>): number => {\n let shared = 0;\n for (const value of left) {\n if (right.has(value)) {\n shared += 1;\n }\n }\n return shared;\n};\n\n/**\n * Greedy agglomerative clustering of threads into topic drafts. Deterministic (Compute mode): a\n * thread joins the first cluster whose aggregate signature overlaps enough — by subject-token\n * Jaccard similarity or by shared participants — else starts its own. Bounded by O(threads ×\n * clusters); no LLM involved, so results are stable and unit-testable.\n */\nexport const clusterThreads = (threads: readonly Thread[], options?: TopicOptions): TopicDraft[] => {\n const opts = { ...DEFAULT_TOPIC_OPTIONS, ...options };\n const clusters: { members: Signature[]; tokens: Set<string>; participants: Set<string> }[] = [];\n\n for (const thread of threads) {\n const signature: Signature = {\n thread,\n tokens: tokenize(thread.subject, opts),\n participants: new Set(thread.participants),\n };\n const home = clusters.find(\n (cluster) =>\n jaccard(cluster.tokens, signature.tokens) >= opts.similarityThreshold ||\n sharedCount(cluster.participants, signature.participants) >= opts.minSharedParticipants,\n );\n if (home) {\n home.members.push(signature);\n for (const token of signature.tokens) {\n home.tokens.add(token);\n }\n for (const participant of signature.participants) {\n home.participants.add(participant);\n }\n } else {\n clusters.push({\n members: [signature],\n tokens: new Set(signature.tokens),\n participants: new Set(signature.participants),\n });\n }\n }\n\n return clusters.map((cluster) => {\n // Rank keywords by how many member threads mention them, then alphabetically for determinism.\n const counts = new Map<string, number>();\n for (const member of cluster.members) {\n for (const token of member.tokens) {\n counts.set(token, (counts.get(token) ?? 0) + 1);\n }\n }\n const keywords = [...counts.entries()]\n .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))\n .slice(0, opts.maxKeywords)\n .map(([token]) => token);\n\n const summaries = cluster.members.flatMap((member) =>\n member.thread.summary.length > 0 ? [member.thread.summary] : [],\n );\n\n // Roll up open questions / action items from the member threads (deduped, order-preserving).\n const questions = [...new Set(cluster.members.flatMap((member) => member.thread.openQuestions ?? []))];\n const tasks = [...new Set(cluster.members.flatMap((member) => member.thread.actionItems ?? []))];\n\n // Label from top keywords; a keyword-less cluster (e.g. blank subjects) falls back through the\n // first thread's subject to its threadId so the label is never empty.\n const first = cluster.members[0].thread;\n return {\n name: keywords.length > 0 ? keywords.slice(0, 3).join(' ') : first.subject || first.threadId,\n summary: summaries.join('\\n'),\n threadIds: cluster.members.map((member) => member.thread.threadId),\n participants: [...cluster.participants].sort(),\n keywords,\n questions,\n tasks,\n } satisfies TopicDraft;\n });\n};\n\n/**\n * Optionally replace each draft's deterministic summary with an LLM one, using the editable\n * `topicSummary` prompt. Degrades per-topic: a failed call keeps that draft's deterministic summary\n * (advisory enrichment must not fail the corpus pass).\n */\nexport const summarizeTopics = async (\n drafts: readonly TopicDraft[],\n summarize: Summarizer,\n options?: { readonly prompts?: Partial<EmailPrompts> },\n): Promise<TopicDraft[]> => {\n const prompts = options?.prompts ? mergePrompts(options.prompts) : DEFAULT_EMAIL_PROMPTS;\n return Promise.all(\n drafts.map(async (draft) => {\n try {\n const summary = await summarize(\n [\n //\n prompts.topicSummary,\n `Topic: ${draft.name}`,\n // `Threads:\\n${draft.summary}`,\n ].join('\\n\\n'),\n );\n\n return summary.trim().length > 0 ? { ...draft, summary: summary.trim() } : draft;\n } catch {\n return draft;\n }\n }),\n );\n};\n\n/** Materialize drafts as canonical Project ECHO objects (done last; drafts stay freely mutable). */\nexport const materializeTopics = (drafts: readonly TopicDraft[]): Project.Project[] =>\n drafts.map((draft) =>\n Project.make({\n name: draft.name,\n // summary: draft.summary,\n // threadIds: [...draft.threadIds],\n // participants: [...draft.participants],\n // keywords: [...draft.keywords],\n // questions: [...draft.questions],\n // tasks: [...draft.tasks],\n }),\n );\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\n\nimport { type FactStoreApi, type RDF, type SemanticIndexError } from '@dxos/pipeline-rdf';\n\n/**\n * Predicates treated as commitments (spec §3③ commitment ledger). Editable: extraction phrasing\n * varies by corpus and extraction rules, so callers tune this list rather than the query logic.\n */\nexport const DEFAULT_COMMITMENT_PREDICATES: readonly string[] = ['owes', 'will send', 'will provide', 'must', 'due'];\n\nexport type LedgerOptions = {\n /** Predicates that mark a fact as a commitment. */\n readonly predicates?: readonly string[];\n};\n\n/** One row of the commitment ledger, traceable to its supporting fact. */\nexport type Commitment = {\n /** Who owes it (fact subject label). */\n readonly who: string;\n /** What is owed (fact object label or literal). */\n readonly what: string;\n /** Deadline (assertion validTo), when stated. */\n readonly dueBy?: string;\n /** Model confidence carried from the fact's factuality. */\n readonly confidence?: number;\n readonly factId: string;\n /** Source message (fact attribution). */\n readonly source: string;\n};\n\nconst termLabel = (term: RDF.Assertion['subject']): string =>\n term.kind === 'entity' ? (term.label ?? term.entity) : term.literal;\n\nconst toCommitment = (fact: RDF.Fact): Commitment => ({\n who: termLabel(fact.assertion.subject),\n what: termLabel(fact.assertion.object),\n ...(fact.assertion.validTo ? { dueBy: fact.assertion.validTo } : {}),\n ...(fact.factuality.confidence !== undefined ? { confidence: fact.factuality.confidence } : {}),\n factId: fact.id,\n source: fact.attribution.source,\n});\n\n/**\n * Query the fact store for outstanding commitments — the spec's cross-thread \"owe / owed\" ledger.\n * Uses the structured query path (one `query({ predicate })` per commitment predicate, compiled to\n * SPARQL on the sqlite backend), which works on both store backends; rows are advisory evidence\n * (§4 canonicality) and each carries its fact id + source message for grounding.\n */\nexport const commitmentLedger = (\n store: FactStoreApi,\n options?: LedgerOptions,\n): Effect.Effect<Commitment[], SemanticIndexError> =>\n Effect.forEach(options?.predicates ?? DEFAULT_COMMITMENT_PREDICATES, (predicate) => store.query({ predicate })).pipe(\n Effect.map((results) => {\n // A fact can match several predicate probes; keep the first occurrence.\n const seen = new Set<string>();\n const commitments: Commitment[] = [];\n for (const fact of results.flat()) {\n if (!seen.has(fact.id)) {\n seen.add(fact.id);\n commitments.push(toCommitment(fact));\n }\n }\n // Deadlines first (soonest first), then the rest in stable fact order.\n return commitments.sort((a, b) =>\n a.dueBy && b.dueBy ? a.dueBy.localeCompare(b.dueBy) : a.dueBy ? -1 : b.dueBy ? 1 : 0,\n );\n }),\n );\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport { Message } from '@dxos/types';\n\n// Leading reply/forward markers, stripped repeatedly (\"Fw: Re:\" → \"\").\nconst PREFIX = /^\\s*(re|fwd?|fw)\\s*:\\s*/i;\n\n// Canonical subject for threading: drop reply/forward prefixes, collapse internal whitespace,\n// lowercase. Two messages with the same normalized subject belong to the same thread.\nexport const normalizeSubject = (subject: string): string => {\n let value = subject;\n while (PREFIX.test(value)) {\n value = value.replace(PREFIX, '');\n }\n return value.replace(/\\s+/g, ' ').trim().toLowerCase();\n};\n\n// Derive a thread id from the subject. The Enron dataset carries no References/In-Reply-To headers,\n// so the normalized subject is the available grouping signal; blank subjects share a `no-subject`\n// bucket rather than each forming a singleton thread.\nexport const deriveThreadId = (message: Message.Message): string => {\n const subject = message.properties?.subject;\n const normalized = typeof subject === 'string' ? normalizeSubject(subject) : '';\n return normalized.length > 0 ? normalized : 'no-subject';\n};\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport { Message } from '@dxos/types';\n\nimport { deriveThreadId } from '../internal/threading.ts';\n\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/**\n * Per-sender relationship aggregate (spec §3③ relationship rollups). Computed values, not ECHO\n * mutations: `Person` is a closed, canonical schema the pipeline must not write onto, so rollups are\n * consumed by the digest / callers (join back to a Person via the lowercased email and the\n * fact-index reconciliation table); materialization waits for a proper schema home.\n */\nexport type RelationshipRollup = {\n /** Lowercased sender address (email addresses are case-insensitive). */\n readonly email: string;\n readonly messageCount: number;\n readonly firstContact: string;\n readonly lastContact: string;\n /** Mean days between messages; absent below two messages. */\n readonly cadenceDays?: number;\n /** Threads this sender participated in (send-side), in first-seen order. */\n readonly threadIds: readonly string[];\n};\n\n/** Aggregate messages into per-sender rollups, most active sender first. Deterministic (Compute mode). */\nexport const buildRollups = (messages: readonly Message.Message[]): RelationshipRollup[] => {\n const bySender = new Map<string, { created: string[]; threadIds: string[] }>();\n for (const message of messages) {\n const email = message.sender.email?.toLowerCase();\n if (!email) {\n continue;\n }\n let entry = bySender.get(email);\n if (!entry) {\n entry = { created: [], threadIds: [] };\n bySender.set(email, entry);\n }\n entry.created.push(message.created);\n const threadId = deriveThreadId(message);\n if (!entry.threadIds.includes(threadId)) {\n entry.threadIds.push(threadId);\n }\n }\n\n const rollups: RelationshipRollup[] = [];\n for (const [email, entry] of bySender) {\n const ordered = [...entry.created].sort();\n const first = ordered[0];\n const last = ordered[ordered.length - 1];\n const spanMs = new Date(last).getTime() - new Date(first).getTime();\n rollups.push({\n email,\n messageCount: ordered.length,\n firstContact: first,\n lastContact: last,\n ...(ordered.length >= 2 ? { cadenceDays: spanMs / (ordered.length - 1) / MS_PER_DAY } : {}),\n threadIds: entry.threadIds,\n });\n }\n return rollups.sort((a, b) => b.messageCount - a.messageCount || a.email.localeCompare(b.email));\n};\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport { type Thread } from '../types/index.ts';\nimport { type Commitment } from './ledger.ts';\nimport { DEFAULT_EMAIL_PROMPTS, type EmailPrompts, type Summarizer, mergePrompts } from './prompts.ts';\nimport { type RelationshipRollup } from './rollups.ts';\nimport { type TopicDraft } from './topics.ts';\n\n/**\n * Everything the corpus pass has computed; the digest is a Query-mode view over it (spec §3③\n * briefings) — assembled on demand, never materialized as ECHO.\n */\nexport type DigestInput = {\n readonly threads: readonly Thread[];\n readonly topics: readonly TopicDraft[];\n readonly commitments: readonly Commitment[];\n readonly rollups: readonly RelationshipRollup[];\n};\n\nexport type DigestOptions = {\n /** Timestamp the digest reports as its generation time (passed in for determinism). */\n readonly now: string;\n /** Topics listed in the skeleton (largest first). */\n readonly maxTopTopics?: number;\n};\n\n/** Deterministic digest skeleton; `narrative` stays empty until {@link narrateDigest} fills it. */\nexport type Digest = {\n readonly generatedAt: string;\n readonly threadCount: number;\n readonly topicCount: number;\n /** Threads needing attention. */\n readonly stalledThreads: readonly string[];\n readonly awaitingMine: readonly string[];\n /** Commitments carrying a deadline, soonest first. */\n readonly dueCommitments: readonly Commitment[];\n // readonly topTopics: readonly { readonly label: string; readonly threadCount: number }[];\n /** LLM briefing text; empty when narration is off or degraded. */\n readonly narrative: string;\n};\n\n/** Assemble the deterministic digest skeleton from the corpus outputs (Compute mode; no LLM). */\nexport const buildDigest = (input: DigestInput, options: DigestOptions): Digest => ({\n generatedAt: options.now,\n threadCount: input.threads.length,\n topicCount: input.topics.length,\n stalledThreads: input.threads.filter((thread) => thread.state === 'stalled').map((thread) => thread.threadId),\n awaitingMine: input.threads.filter((thread) => thread.state === 'awaiting-mine').map((thread) => thread.threadId),\n dueCommitments: input.commitments\n .filter((commitment) => commitment.dueBy !== undefined)\n .sort((a, b) => (a.dueBy ?? '').localeCompare(b.dueBy ?? '')),\n // topTopics: [...input.topics]\n // .sort((a, b) => b.threadIds.length - a.threadIds.length || a.label.localeCompare(b.label))\n // .slice(0, options.maxTopTopics ?? 5)\n // .map((topic) => ({ label: topic.label, threadCount: topic.threadIds.length })),\n narrative: '',\n});\n\n/** Render the skeleton as plain text — the prompt payload for narration, and a fallback briefing. */\nexport const renderDigest = (digest: Digest): string =>\n [\n `Inbox digest (${digest.generatedAt})`,\n `Threads: ${digest.threadCount} Topics: ${digest.topicCount}`,\n `Awaiting my reply: ${digest.awaitingMine.join(', ') || 'none'}`,\n `Stalled: ${digest.stalledThreads.join(', ') || 'none'}`,\n `Due commitments: ${\n digest.dueCommitments\n .map((commitment) => `${commitment.who} → ${commitment.what} (${commitment.dueBy})`)\n .join('; ') || 'none'\n }`,\n // `Top topics: ${digest.topTopics.map((topic) => `${topic.label} (${topic.threadCount})`).join('; ') || 'none'}`,\n ].join('\\n');\n\n/**\n * Optionally add an LLM briefing over the skeleton, using the editable `digest` prompt. Degrades to\n * the un-narrated digest on failure — the skeleton is always available.\n */\nexport const narrateDigest = async (\n digest: Digest,\n summarize: Summarizer,\n options?: { readonly prompts?: Partial<EmailPrompts> },\n): Promise<Digest> => {\n const prompts = options?.prompts ? mergePrompts(options.prompts) : DEFAULT_EMAIL_PROMPTS;\n try {\n const narrative = await summarize([prompts.digest, renderDigest(digest)].join('\\n\\n'));\n return { ...digest, narrative: narrative.trim() };\n } catch {\n return digest;\n }\n};\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Context from 'effect/Context';\n\nimport { type Database } from '@dxos/echo';\n\n/** Summary produced by the summarize stage. */\nexport type Summary = { readonly summary: string; readonly isSpam: boolean; readonly keywords: readonly string[] };\n\n/** Running tallies produced by the stats stage (mutable accumulator). */\nexport type Stats = { from: Map<string, number>; to: Map<string, number>; total: number; spam: number };\n\n/** Per-message summary result keyed by messageId (for a summary view). */\nexport type SummaryResult = { readonly summaries: ReadonlyArray<{ messageId: string; summary: Summary }> };\n\nexport const emptyStats = (): Stats => ({ from: new Map(), to: new Map(), total: 0, spam: 0 });\n\n/**\n * Shared context threaded through the email stages via Effect's Requirements channel. `db` is the\n * ECHO space database (browser-safe); `stats` and `summaries` are mutable accumulators read after\n * the run. Callers provide it once at the pipeline edge (`Effect.provide`).\n */\nexport class EmailPipelineCtx extends Context.Service<\n EmailPipelineCtx,\n {\n readonly db: Database.Database;\n readonly stats: Stats;\n readonly summaries: Array<{ messageId: string; summary: Summary }>;\n }\n>()('@dxos/pipeline-email/EmailPipelineCtx') {}\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\n\nimport { extractContact } from '@dxos/extractor-lib';\nimport { Stage } from '@dxos/pipeline';\nimport { Message } from '@dxos/types';\n\nimport { EmailPipelineCtx } from './context.ts';\n\n/** Extract a Person (+ Organization) from the sender and persist to the ECHO space; pass message through. */\nexport const extractContactsStage: Stage.Stage<Message.Message, Message.Message, never, EmailPipelineCtx> = Stage.map(\n 'extract-contacts',\n (message) =>\n Effect.gen(function* () {\n const { db } = yield* EmailPipelineCtx;\n const result = yield* extractContact({ db, source: message });\n for (const object of result.created) {\n db.add(object);\n }\n return message;\n }),\n);\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\n\nimport { Stage } from '@dxos/pipeline';\nimport { type RDF } from '@dxos/pipeline-rdf';\nimport { Message } from '@dxos/types';\n\n// Extract + persist one message's facts, returning them. The store/AI-bound work is a Promise closure\n// so the stage's Effect stays R = never (a failed extraction must not fail the pipeline run).\nexport type FactIndexer = (message: Message.Message) => Promise<RDF.Fact[]>;\n\n// Message-layer stage: index each message into the fact substrate, passing the Message through\n// unchanged. Extraction degrades to no facts on failure (advisory layer) but logs first, so the\n// degradation stays observable. A factory over `indexFacts` (like the pipeline's `logStage(label)`)\n// keeps the module decoupled from any test-level Context.\nexport const extractFactsStage = (indexFacts: FactIndexer): Stage.Stage<Message.Message, Message.Message> =>\n Stage.map('extract-facts', (message) =>\n Effect.tryPromise(() => indexFacts(message)).pipe(\n Effect.tapError((error) => Effect.logWarning('extract-facts failed; degrading to no facts', error)),\n Effect.catch(() => Effect.succeed<RDF.Fact[]>([])),\n Effect.as(message),\n ),\n );\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport { type RDF } from '@dxos/pipeline-rdf';\nimport { Message } from '@dxos/types';\n\n// Email-specific extraction guidance appended after the pipeline-rdf DEFAULT_EXTRACTION_RULES.\n// Kept atomic so the two rule sets compose without forking the base prompt.\nexport const EMAIL_EXTRACTION_RULES: readonly string[] = [\n '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.',\n 'A deadline or due date becomes validTo on the relevant assertion (ISO date when known).',\n 'Attribute first-person statements (\"I will…\", \"we can…\") to the message sender as subject.',\n];\n\nexport const EMAIL_EXTRACT_OPTIONS: RDF.ExtractOptions = {\n rules: EMAIL_EXTRACTION_RULES,\n};\n\n// Stable per-message identifier used as the fact `source` and the incremental cursor key. Prefer the\n// RFC message-id header (present in the Enron dataset); fall back to sender+timestamp so every message\n// has a deterministic source even when the header is absent.\nexport const messageSource = (message: Message.Message): string => {\n const messageId = message.properties?.messageId;\n if (typeof messageId === 'string' && messageId.length > 0) {\n return messageId;\n }\n\n return `${message.sender.email ?? 'unknown'}:${message.created}`;\n};\n\nexport const messageToDocument = (message: Message.Message): RDF.ExtractDocument => ({\n text: Message.extractText(message),\n source: messageSource(message),\n ...(message.sender.email ? { author: message.sender.email } : {}),\n date: message.created,\n});\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\n\nimport { Stage } from '@dxos/pipeline';\nimport { type RDF } from '@dxos/pipeline-rdf';\nimport { Message } from '@dxos/types';\n\nimport { messageSource } from './facts.ts';\n\n// Extract one message's facts WITHOUT persisting them. Distinct from `FactIndexer`, whose closure\n// also writes to the store: here persistence is the sink's responsibility, so a page of facts can\n// commit atomically with the cursor advance. Same signature, different contract.\nexport type FactExtractor = (message: Message.Message) => Promise<RDF.Fact[]>;\n\n// Terminal unit for the cursored fact pipeline: extracted facts plus the keys the sink needs to\n// dedup and advance the cursor.\nexport type FactUnit = {\n readonly facts: RDF.Fact[];\n /** Stable per-message id (`messageSource`); the dedup foreign id. */\n readonly foreignId: string;\n /** Monotonic cursor key. */\n readonly key: number;\n};\n\n// Facts-returning variant of `extractFactsStage`: returns the extracted facts as a `FactUnit`\n// instead of persisting them in-stage, so the sink can write a page atomically with the cursor\n// advance. Extraction degrades to no facts on failure (advisory layer) but logs first, matching\n// `extractFactsStage`'s degradation, so the loss stays observable.\n// `concurrency` bounds in-flight extractions; >1 runs the (independent, network-bound) per-message\n// LLM calls in parallel. Facts are order-independent — the sink commits by max cursor key — so the\n// reordering `mapEffect` may introduce is harmless here.\nexport const extractFactsUnitStage = (\n extract: FactExtractor,\n concurrency = 1,\n): Stage.Stage<Message.Message, FactUnit> =>\n Stage.map(\n 'extract-facts-unit',\n (message) =>\n Effect.tryPromise(() => extract(message)).pipe(\n Effect.tapError((error) => Effect.logWarning('extract-facts-unit failed; degrading to no facts', error)),\n Effect.catch(() => Effect.succeed<RDF.Fact[]>([])),\n Effect.map((facts): FactUnit => ({ facts, foreignId: messageSource(message), key: keyOf(message) })),\n ),\n { concurrency },\n );\n\n// NOTE(workaround): `message.created` is the incremental cursor key because ECHO's native feed\n// cursor is unimplemented (`Feed.cursor` is stubbed). Replace with the native queue sequence when\n// available.\nconst keyOf = (message: Message.Message): number => Date.parse(message.created);\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\n\nimport { Stage } from '@dxos/pipeline';\nimport { Message } from '@dxos/types';\n\nimport { EmailPipelineCtx } from './context.ts';\n\n/** Pure-JS running tallies (senders, recipients, spam); pass message through. */\nexport const statsStage: Stage.Stage<Message.Message, Message.Message, never, EmailPipelineCtx> = Stage.map(\n 'stats',\n (message) =>\n Effect.gen(function* () {\n const { stats } = yield* EmailPipelineCtx;\n stats.total += 1;\n const sender = message.sender.email;\n if (sender) {\n stats.from.set(sender, (stats.from.get(sender) ?? 0) + 1);\n }\n const recipients = message.properties?.to;\n if (Array.isArray(recipients)) {\n for (const recipient of recipients) {\n const address = String(recipient);\n stats.to.set(address, (stats.to.get(address) ?? 0) + 1);\n }\n }\n if (message.properties?.spam) {\n stats.spam += 1;\n }\n return message;\n }),\n);\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as LanguageModel from 'effect/ai/LanguageModel';\nimport * as Effect from 'effect/Effect';\nimport * as Layer from 'effect/Layer';\n\nimport { AiService } from '@dxos/ai';\nimport { Stage } from '@dxos/pipeline';\nimport { ContentBlock, Message } from '@dxos/types';\nimport { trim } from '@dxos/util';\n\nimport { EmailPipelineCtx, type Summary } from './context.ts';\n\nconst SUMMARIZE_MODEL = 'com.anthropic.model.claude-haiku-4-5.default';\n\nconst SUMMARIZE_PROMPT = trim`\n Summarize the following email in one sentence, decide whether it is spam, and list up to five keywords.\n Respond with ONLY a JSON object of the form {\"summary\": string, \"isSpam\": boolean, \"keywords\": string[]}.\n`;\n\n// Local models often wrap JSON in prose; extract the first object and coerce leniently.\nconst parseSummary = (raw: string): Summary => {\n const match = raw.match(/\\{[\\s\\S]*\\}/);\n if (!match) {\n return { summary: '', isSpam: false, keywords: [] };\n }\n try {\n const parsed = JSON.parse(match[0]);\n const keywords = parsed.keywords;\n return {\n summary: typeof parsed.summary === 'string' ? parsed.summary : '',\n isSpam: parsed.isSpam === true || parsed.spam === true || parsed.is_spam === true,\n keywords: Array.isArray(keywords) ? keywords.map((keyword: unknown) => String(keyword)) : [],\n };\n } catch {\n return { summary: '', isSpam: false, keywords: [] };\n }\n};\n\n/** Summarize via AiService/LanguageModel; append a summary block and record spam/keywords + result. */\nexport const summarizeStage: Stage.Stage<\n Message.Message,\n Message.Message,\n never,\n EmailPipelineCtx | AiService.AiService\n> = Stage.map('summarize', (message) =>\n Effect.gen(function* () {\n const ctx = yield* EmailPipelineCtx;\n const text = Message.extractText(message);\n // Resolve the LanguageModel layer locally from AiService so `LanguageModel` is discharged and the\n // stage's requirement correctly reduces to `EmailPipelineCtx | AiService.AiService`.\n // Bound the LLM call: `orElse` only recovers failures, so without a timeout a hung/slow provider\n // would block the stage indefinitely. On timeout the effect fails, then `orElse` degrades to ''.\n const raw = yield* LanguageModel.generateText({ prompt: `${SUMMARIZE_PROMPT}\\n\\n${text}` }).pipe(\n Effect.provide(AiService.languageModel(SUMMARIZE_MODEL).pipe(Layer.orDie)),\n Effect.timeout('30 seconds'),\n Effect.map((response) => response.text),\n Effect.catch(() => Effect.succeed('')),\n );\n const summary = parseSummary(raw);\n const messageId = String(message.properties?.messageId ?? message.id);\n ctx.summaries.push({ messageId, summary });\n const summaryBlock: ContentBlock.Text = { _tag: 'text', text: summary.summary };\n // Preserve the original id: `Message.make` mints a fresh one when omitted, which would break the\n // `message.id` fallback used for `messageId` downstream.\n return Message.make({\n id: message.id,\n created: message.created,\n sender: message.sender,\n blocks: [...message.blocks, summaryBlock],\n properties: { ...message.properties, spam: summary.isSpam, keywords: summary.keywords },\n });\n }),\n);\n","//\n// Copyright 2026 DXOS.org\n//\n\n// Declarative model routing for the email pipeline stages (productized from the stories-brain research\n// harness): one place that maps each LLM-bearing stage to the model it runs on, instead of hard-coding\n// a model DXN at every call site. Product tiers only (Anthropic); the research ladder's open-weight\n// (ollama) variants stay in the harness. Each layer overrides the previous: default ← per-run policy.\n\n/** Anthropic model DXNs available to product pipelines. */\nexport const MODEL_HAIKU = 'com.anthropic.model.claude-haiku-4-5.default';\nexport const MODEL_SONNET = 'com.anthropic.model.claude-sonnet-5.default';\nexport const MODEL_OPUS = 'com.anthropic.model.claude-opus-5.default';\n\n/** The LLM-bearing email pipeline stages. Deterministic stages (threading, dedup, stats) never appear. */\nexport type StageId =\n | 'classify-sender'\n | 'tag'\n | 'summarize-message'\n | 'summarize-thread'\n | 'summarize-topic'\n | 'extract-facts'\n | 'draft';\n\n/** A partial policy: stage → a model DXN. */\nexport type ModelPolicy = Partial<Record<StageId, string>>;\n\n/**\n * Default stage → model routing, seeded from the model-ladder findings (REPORT §4/§5): labeling and\n * summaries want a premier model, drafting tolerates a cheaper one. Product defaults to haiku across\n * the board (the cheapest premier tier); callers override per run via a `ModelPolicy`.\n */\nexport const DEFAULT_MODEL_POLICY: Record<StageId, string> = {\n 'classify-sender': MODEL_HAIKU,\n 'tag': MODEL_HAIKU,\n 'summarize-message': MODEL_HAIKU,\n 'summarize-thread': MODEL_HAIKU,\n 'summarize-topic': MODEL_HAIKU,\n 'extract-facts': MODEL_HAIKU,\n 'draft': MODEL_HAIKU,\n};\n\n/** Resolves the model DXN for a stage: per-run `policy` wins over `DEFAULT_MODEL_POLICY`. */\nexport const resolveModel = (stage: StageId, policy?: ModelPolicy): string =>\n policy?.[stage] ?? DEFAULT_MODEL_POLICY[stage];\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as LanguageModel from 'effect/ai/LanguageModel';\nimport * as Effect from 'effect/Effect';\nimport * as Layer from 'effect/Layer';\n\nimport { AiService } from '@dxos/ai';\nimport { Message } from '@dxos/types';\nimport { trim } from '@dxos/util';\n\nimport { type ModelPolicy, resolveModel } from '../model-policy.ts';\n\n// Per-message topic tagging (productized from the stories-brain research tagger): free-form lowercase\n// topic tags plus a spam flag, generated by the AI service. Deterministic stages don't tag; this is\n// the one LLM-bearing per-message classification the topics pipeline applies via `Mailbox.applyTag`.\n\nexport type TagResult = {\n readonly tags: string[];\n readonly spam: boolean;\n /** No-action automated mail (receipts, logins, confirmations) — distinct from spam and from action-required mail. */\n readonly bulk: boolean;\n};\n\nconst TAG_PROMPT = trim`\n Classify the following email. Assign between 1 and 5 short, lowercase, single-word topic tags\n (e.g. \"invoice\", \"newsletter\", \"security\", \"personal\"). Decide whether the email is spam or a\n cold marketing blast; if so include the tag \"spam\".\n Decide whether the email is \"bulk\" — automated, no-action mail such as receipts, login or\n security notices, order/shipping confirmations, or newsletters. If so include the tag \"bulk\".\n Do NOT tag invoices, payment requests, or anything that needs a reply as \"bulk\".\n Respond with ONLY a JSON object of the form {\"tags\": string[], \"spam\": boolean}.\n`;\n\n// No-action transactional/automated mail — receipts, login/security notices, order & shipping\n// confirmations, newsletters. These get a `bulk` tag (nothing for the user to do), distinct from\n// spam (unsolicited) and from invoices / payment requests (which DO require action).\nconst BULK_SUBJECT_RE =\n /\\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;\n\n// Action-required financial mail — must NOT be classified bulk even when it looks automated.\nconst ACTION_SUBJECT_RE =\n /\\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;\n\n// Role / no-reply local parts that send automated no-action mail (used only when the subject gives no signal).\nconst BULK_LOCALPART_RE =\n /^(no-?reply|do-?not-?reply|receipts?|notifications?|notify|alerts?|updates?|news(letter)?|digest|mailer|postmaster)([._+-]|$)/i;\n\n// A body-level unsubscribe affordance — the strongest deterministic bulk give-away (a person doesn't\n// put an unsubscribe link on a real message). Matched when the `List-Unsubscribe` header is absent.\nconst UNSUBSCRIBE_BODY_RE = /\\bunsubscribe\\b/i;\n\n/**\n * Deterministic bulk / no-action signal. Returns `'bulk'` for mailing-list / automated mail —\n * **any unsubscribe affordance** (a `List-Unsubscribe` header or an unsubscribe link in the body) is\n * the top-priority give-away (RFC 2369), followed by receipts / logins / order confirmations /\n * newsletters and no-reply role senders. Returns `'action'` when the mail requires a response\n * (invoice / payment request) and has no unsubscribe affordance; `'unknown'` otherwise. Pure.\n */\nexport const classifyBulk = (input: {\n readonly subject?: string;\n readonly senderEmail?: string;\n /** `List-Unsubscribe` header value, when present (real synced mail). */\n readonly listUnsubscribe?: string;\n /** Message body text, scanned for an unsubscribe link when the header is absent (e.g. fixtures). */\n readonly bodyText?: string;\n}): 'bulk' | 'action' | 'unknown' => {\n // An unsubscribe affordance deterministically marks bulk — it outranks even action-looking subjects,\n // since transactional invoices/payment requests don't carry one.\n if ((input.listUnsubscribe ?? '').length > 0 || UNSUBSCRIBE_BODY_RE.test(input.bodyText ?? '')) {\n return 'bulk';\n }\n const subject = input.subject ?? '';\n if (ACTION_SUBJECT_RE.test(subject)) {\n return 'action';\n }\n if (BULK_SUBJECT_RE.test(subject)) {\n return 'bulk';\n }\n const localPart = (input.senderEmail ?? '').split('@')[0] ?? '';\n return BULK_LOCALPART_RE.test(localPart) ? 'bulk' : 'unknown';\n};\n\n/** Folds the deterministic bulk classification into a tag set: adds `bulk` for no-action mail, strips it for action-required mail. Pure. */\nexport const applyBulkTag = (tags: readonly string[], classification: 'bulk' | 'action' | 'unknown'): string[] => {\n if (classification === 'action') {\n return tags.filter((tag) => tag !== 'bulk');\n }\n if (classification === 'bulk' && !tags.includes('bulk')) {\n return [...tags, 'bulk'];\n }\n return [...tags];\n};\n\n/** Parses the tag response leniently (models wrap JSON in prose); infers/dedups the spam tag. Pure. */\nexport const parseTagResult = (raw: string): TagResult => {\n const match = raw.match(/\\{[\\s\\S]*\\}/);\n if (!match) {\n return { tags: [], spam: false, bulk: false };\n }\n try {\n const parsed = JSON.parse(match[0]) as { tags?: unknown; spam?: unknown };\n const tags = Array.isArray(parsed.tags)\n ? parsed.tags.map((tag) => String(tag).toLowerCase()).filter((tag) => tag.length > 0)\n : [];\n const spam = parsed.spam === true || tags.includes('spam');\n return {\n tags: spam && !tags.includes('spam') ? [...tags, 'spam'] : tags,\n spam,\n bulk: tags.includes('bulk'),\n };\n } catch {\n return { tags: [], spam: false, bulk: false };\n }\n};\n\n/**\n * Tags a single message (topic tags + spam) via the AI service. The model comes from the `tag` stage\n * of the model policy (overridable per run). Degrades to no tags on timeout/error so one bad message\n * never fails the pipeline.\n */\nexport const tagMessage = (\n message: Message.Message,\n options: { readonly policy?: ModelPolicy } = {},\n): Effect.Effect<TagResult, never, AiService.AiService> =>\n Effect.gen(function* () {\n const subject = String(message.properties?.subject ?? '');\n const body = Message.extractText(message);\n const raw = yield* LanguageModel.generateText({\n prompt: `${TAG_PROMPT}\\n\\nSubject: ${subject}\\n\\n${body}`,\n }).pipe(\n Effect.provide(AiService.languageModel(resolveModel('tag', options.policy)).pipe(Layer.orDie)),\n Effect.timeout('30 seconds'),\n Effect.map((response) => response.text),\n Effect.catch(() => Effect.succeed('')),\n );\n const parsed = parseTagResult(raw);\n // Deterministic bulk gate overrides the model: an unsubscribe affordance (header or body link)\n // forces `bulk`, adds it for no-action mail the model missed, and strips it from invoices.\n const listUnsubscribe = message.properties?.listUnsubscribe;\n const classification = classifyBulk({\n subject,\n senderEmail: message.sender?.email,\n listUnsubscribe: typeof listUnsubscribe === 'string' ? listUnsubscribe : undefined,\n bodyText: body,\n });\n const tags = applyBulkTag(parsed.tags, classification);\n return { tags, spam: parsed.spam, bulk: tags.includes('bulk') };\n });\n","//\n// Copyright 2026 DXOS.org\n//\n\n// @import-as-namespace\n\nimport * as Effect from 'effect/Effect';\nimport * as Stream from 'effect/Stream';\n\nimport { Blob, Database, Feed, Filter, Obj, Ref } from '@dxos/echo';\nimport { type IdentityIndex, overlayIdentityIndex } from '@dxos/extractor';\nimport { buildContactFromActor, getIdentityIndex, identitySpecs, senderSignals } from '@dxos/extractor-lib';\nimport { Cursor } from '@dxos/link';\nimport { log } from '@dxos/log';\nimport { normalizeText } from '@dxos/markdown';\nimport { Stage } from '@dxos/pipeline';\nimport { Tagging, TagIndex } from '@dxos/schema';\nimport { DraftMessage, Message, Person } from '@dxos/types';\n\n// TODO(burdon): Factor out.\n\n/**\n * Reusable, generic email-processing pipeline stages: {@link Change} → {@link Change}, composing in any\n * order, each simply recording what it found (`message.attachments`, `contact`, …) rather than writing\n * directly. {@link toCommitUnit} is the terminal stage that turns those into a generic\n * `Cursor.CommitUnit`, and must run last. Provider-specific decode/map stages construct the initial\n * `Change` (an `Insert`, tagged from the start) and stay in each sync op.\n */\n\n/** An item carrying a body string that may contain HTML. */\nexport type Bodied = { readonly body: string };\n\n/**\n * Normalizes an item's `body` from (possibly) HTML into markdown via `normalizeText` (turndown when\n * HTML, passthrough for plaintext). Requires nothing from the Requirements channel. Written as a\n * generic pipeable so the item type infers from the upstream stream (no type argument needed).\n */\nexport const htmlToMarkdown = <In extends Bodied, E, R>(self: Stream.Stream<In, E, R>): Stream.Stream<In, E, R> =>\n Stage.map('html-to-markdown', (item: In) => Effect.sync(() => ({ ...item, body: normalizeText(item.body) })))(self);\n\n/**\n * A new message to append, tagged `insert` from the moment a provider decodes it. The upstream dedup\n * stage (`Cursor.dedupStage`) guarantees a message reaching this variant was never previously committed,\n * so it's a one-time insert, never a resync/overwrite. The generic email stages act only on this variant\n * of {@link Change}.\n */\nexport type Insert = {\n readonly _tag: 'insert';\n readonly message: Message.Message;\n readonly foreignId: string;\n readonly key: number;\n /** Provider label/folder tag URIs to apply on commit; omit for providers/stages that don't tag. */\n readonly tagUris?: readonly string[];\n /**\n * The subset of {@link tagUris} mirroring the provider's own labels, as opposed to tags local logic\n * added at insert time (the known-sender `important` rule, an on-arrival extractor).\n *\n * Bidirectional tag sync seeds a newly-inserted message's reconciliation base from this, so provider\n * labels land in the base (and never push back at the provider they came from) while locally-added\n * tags stay outside it and push. Both kinds land before the run captures its heads, so nothing else\n * can tell them apart. Absent means all of `tagUris` is provider-derived.\n */\n readonly remoteTagUris?: readonly string[];\n /** Attachments fetched by a provider-specific stage upstream of {@link processAttachments}. */\n readonly attachments?: readonly Attachment[];\n /** Contact resolved by {@link extractContacts}, added to the database by {@link toCommitUnit}. */\n readonly contact?: Person.Person;\n /** Local drafts this synced message supersedes ({@link reconcileDrafts}), removed by {@link toCommitUnit}. */\n readonly supersededDrafts?: readonly Message.Message[];\n};\n\n/**\n * A label change on an already-committed message, resolved to its feed message's `entityId`.\n * {@link toCommitUnit} turns it into an objectless commit unit that applies the tag adds/removes — no\n * feed append, so the body stages ({@link processAttachments}, {@link extractContacts},\n * {@link reconcileDrafts}) pass it through untouched.\n */\nexport type Retag = {\n readonly _tag: 'retag';\n readonly foreignId: string;\n readonly entityId: Obj.ID;\n readonly addTagIds: readonly string[];\n readonly removeTagIds: readonly string[];\n};\n\n/**\n * A remote deletion of an already-committed message. Wired but STUBBED — {@link toCommitUnit} emits an\n * objectless unit with no effect (see the TODO there) pending safe in-sync feed object deletion.\n */\nexport type Delete = {\n readonly _tag: 'delete';\n readonly foreignId: string;\n readonly entityId: Obj.ID;\n};\n\n/**\n * The discriminated item flowing through the shared email tail. `insert` is a new message; `retag`/\n * `delete` act on existing feed messages by `entityId`. All converge on the single {@link toCommitUnit}\n * → `Cursor.commit`.\n */\nexport type Change = Insert | Retag | Delete;\n\n/** A decoded email attachment, ready for {@link processAttachments} to turn into a Blob. */\nexport type Attachment = {\n readonly name?: string;\n readonly mimeType?: string;\n readonly size: number;\n readonly bytes: Uint8Array;\n /** The attachment's `Content-ID`, if any — matches a `cid:` reference in the message's HTML body. */\n readonly contentId?: string;\n};\n\n/**\n * Builds a Person (+ Organization link by domain) from the message sender and records it on the\n * item as {@link Insert.contact} — the stage writes nothing itself; {@link toCommitUnit} defers\n * the actual `db.add` to commit.\n *\n * Dedup is the space's shared identity index (see `getIdentityIndex`), refreshed once at the start\n * of the run: it covers contacts committed before the run, contacts built earlier in it, and — since\n * the index is shared per database — contacts a concurrently-syncing mailbox is building right now.\n *\n * Not every sender earns a record: {@link senderSignals} feeds the allow-list in\n * `shouldExtractContact`, so bulk and automated mail no longer fills the space with contacts nobody\n * will look at. NOTE: no provider records an outbound signal yet, so in practice the allow-list is\n * \"the domain matches a known Organization\" — stricter than the policy intends. Extracting the\n * recipients of sent mail is what closes that gap.\n */\nexport const extractContacts = (): Stage.Stage<Change, Change, never, Database.Service> => {\n // Run-scoped overlay over the space's shared index: reads see everything committed (including by a\n // concurrent sync), writes stay local, so a run that dies mid-way leaves nothing claiming a contact\n // it never committed.\n let index: IdentityIndex | undefined;\n return Stage.map('extract-contacts', (change: Change) =>\n Effect.gen(function* () {\n // Only new messages carry a body/sender; retag/delete pass through untouched.\n if (change._tag !== 'insert') {\n return change;\n }\n const { db } = yield* Database.Service;\n if (!index) {\n // Re-seed once per run so the shared index picks up whatever was committed since it was built.\n index = overlayIdentityIndex(identitySpecs, yield* getIdentityIndex(db, { refresh: true }));\n }\n const sender = change.message.sender;\n const contact = sender\n ? yield* buildContactFromActor(sender, db, { signals: senderSignals(change.message), index })\n : undefined;\n return contact ? { ...change, contact } : change;\n }),\n );\n};\n\n/**\n * Turns each of the item's `attachments` into a Blob object (via the database's configured storage\n * backend — edge in Composer) and adds a {@link Message.Attachment} pointing at it to the message.\n * {@link toCommitUnit} finds these blobs again via each attachment's ref (inlined, since the\n * blob isn't attached to a database yet) to defer their feed append to commit. A no-op when the item\n * has no attachments.\n *\n * One bad or oversized attachment must not fail the whole message: each `Blob.fromBytes` is caught\n * and logged individually, so a rejected attachment is simply dropped from the message.\n */\nexport const processAttachments = (): Stage.Stage<Change, Change, never, Database.Service> =>\n Stage.map('process-attachments', (change: Change) =>\n Effect.gen(function* () {\n // Only new messages carry attachments; retag/delete pass through untouched.\n if (change._tag !== 'insert') {\n return change;\n }\n const mapped = change;\n if (!mapped.attachments?.length) {\n return mapped;\n }\n\n const attachments: Message.Attachment[] = [];\n for (const attachment of mapped.attachments) {\n const blob: Blob.Blob | undefined = yield* Blob.fromBytes(attachment.bytes, {\n type: attachment.mimeType,\n }).pipe(\n Effect.catch((error) => {\n log.catch(error, { foreignId: mapped.foreignId, name: attachment.name, size: attachment.size });\n return Effect.succeed(undefined);\n }),\n );\n if (!blob) {\n continue;\n }\n attachments.push({ name: attachment.name, ref: Ref.make(blob), contentId: attachment.contentId });\n }\n if (attachments.length === 0) {\n return mapped;\n }\n\n // The message is not yet appended to the feed, so mutating it here is safe — mirrors how the\n // mappers assemble `blocks` before the message is ever persisted.\n Obj.update(mapped.message, (message) => {\n message.attachments = [...(message.attachments ?? []), ...attachments];\n });\n\n return mapped;\n }),\n );\n\n/**\n * Queries the parent container's already-sent drafts once per sync run and pools them by the provider\n * message id captured at send time (`properties.sentMessageId`) — the key {@link reconcileDrafts}\n * matches each incoming message's `foreignId` against. Doing this once (rather than per item) keeps\n * reconciliation off the database as messages stream through. Unsent drafts (no `sentMessageId`) are\n * never pooled and thus never eligible for removal.\n *\n * `parent` is the local root object drafts are scoped to (e.g. a Mailbox) — only its URI is read, so\n * any object works.\n */\nexport const queryDraftPool = Effect.fn('queryDraftPool')(function* (parent: Obj.Unknown) {\n const parentUri = Obj.getURI(parent);\n const drafts = (yield* Database.query(Filter.type(Message.Message, { properties: { mailbox: parentUri } }))\n .run).filter((candidate) => DraftMessage.belongsTo(candidate, parentUri) && candidate.properties?.sentMessageId);\n\n const pool = new Map<string, Message.Message[]>();\n for (const draft of drafts) {\n const sentMessageId = draft.properties?.sentMessageId;\n if (!sentMessageId) {\n continue;\n }\n const existing = pool.get(sentMessageId);\n if (existing) {\n existing.push(draft);\n } else {\n pool.set(sentMessageId, [draft]);\n }\n }\n return pool;\n});\n\n/**\n * Records the local drafts each incoming message supersedes — matched by the provider message id the\n * draft captured at send time (`properties.sentMessageId`), which equals the synced message's\n * `foreignId` — from a pool built once per run and keyed by that id. Writes nothing itself;\n * {@link toCommitUnit} defers the `db.remove` to commit, so the canonical message is appended\n * to the feed before its now-redundant drafts are deleted. A stage factory: pass the run's draft\n * pool once.\n *\n * The pool is queried a single time by the caller (per sync operation), so this stage never touches\n * the database or feed per item.\n */\nexport const reconcileDrafts = (\n draftPool: ReadonlyMap<string, readonly Message.Message[]>,\n): Stage.Stage<Change, Change, never, never> =>\n Stage.map('reconcile-drafts', (change: Change) =>\n Effect.sync(() => {\n // Only new messages can supersede a draft; retag/delete pass through untouched.\n if (change._tag !== 'insert') {\n return change;\n }\n const drafts = draftPool.get(change.foreignId);\n return drafts?.length ? { ...change, supersededDrafts: drafts } : change;\n }),\n );\n\n/**\n * Terminal stage converting a run's {@link Change} into the generic `Cursor.CommitUnit` the commit sink\n * consumes. For an `insert`: `message.attachments` (populated by {@link processAttachments}) becomes a\n * feed-append commit effect for the referenced blobs, `mapped.contact` (from {@link extractContacts})\n * becomes a `db.add` commit effect, and `mapped.supersededDrafts` (from {@link reconcileDrafts})\n * becomes a `db.remove` commit effect. Every other email stage is Change → Change and composes in\n * any order; this is the one stage that must run last.\n *\n * Tagging is email-specific (`Cursor.CommitUnit` carries no tag data), so it's applied here rather\n * than by the generic commit sink: `tagIndex` is optional (omit for providers/tests that don't tag),\n * and every tagged unit attaches the *same* `applyTags` closure (keyed by `mapped.tagUris` recorded in\n * a run-scoped map, read back by object identity) so the commit's identity-based batching applies\n * every tag pair for the page in one `Tagging.setBatch` call rather than one per message.\n */\nexport const toCommitUnit = (\n options: { readonly tagIndex?: TagIndex.TagIndex } = {},\n): Stage.Stage<Change, Cursor.CommitUnit, never, Cursor.Service> => {\n const { tagIndex } = options;\n const tagUrisByObject = new WeakMap<Obj.Any, readonly string[]>();\n const applyTags: Cursor.CommitEffect = Effect.fn('email.commit.tags')(function* (\n units: readonly Cursor.CommitUnit[],\n ) {\n if (!tagIndex) {\n return;\n }\n const tagEntries: { object: Obj.Any; tagId: string }[] = [];\n for (const unit of units) {\n if (!unit.object) {\n continue;\n }\n for (const uri of tagUrisByObject.get(unit.object) ?? []) {\n tagEntries.push({ object: unit.object, tagId: uri });\n }\n }\n if (tagEntries.length > 0) {\n Tagging.setBatch(tagEntries, { index: tagIndex });\n }\n });\n\n // Retag adds/removes recorded per foreign id, applied in one shared effect (identity-batched by the\n // commit) so a page of label changes resolves to a single pass over the tag index.\n const retagByForeignId = new Map<string, Pick<Retag, 'entityId' | 'addTagIds' | 'removeTagIds'>>();\n const applyRetags: Cursor.CommitEffect = Effect.fn('email.commit.retags')(function* (\n units: readonly Cursor.CommitUnit[],\n ) {\n if (!tagIndex) {\n return;\n }\n const accessor = TagIndex.bind(tagIndex);\n for (const unit of units) {\n const retag = retagByForeignId.get(unit.foreignId);\n if (!retag) {\n continue;\n }\n for (const tagId of retag.addTagIds) {\n accessor.setTag(tagId, retag.entityId);\n }\n for (const tagId of retag.removeTagIds) {\n accessor.unsetTag(tagId, retag.entityId);\n }\n }\n });\n\n return Stage.map('to-commit-unit', (change: Change) =>\n Effect.gen(function* () {\n // Label change on an existing message: an objectless unit (no feed append, key 0) whose effect\n // retags by entityId.\n if (change._tag === 'retag') {\n retagByForeignId.set(change.foreignId, {\n entityId: change.entityId,\n addTagIds: change.addTagIds,\n removeTagIds: change.removeTagIds,\n });\n return {\n foreignId: change.foreignId,\n key: 0,\n commitEffects: tagIndex ? [applyRetags] : undefined,\n } satisfies Cursor.CommitUnit;\n }\n\n // Remote deletion: wired but STUBBED — the branch resolves an objectless unit that does nothing.\n // TODO(wittjosiah): Remove the message from the feed once in-sync feed object deletion is safe\n // (cursor/dedup-set interaction across the non-transactional queue↔space-db seam).\n if (change._tag === 'delete') {\n return { foreignId: change.foreignId, key: 0 } satisfies Cursor.CommitUnit;\n }\n\n const mapped = change;\n const { feed } = yield* Cursor.Service;\n const commitEffects: Cursor.CommitEffect[] = [];\n\n // Each attachment's ref is inlined (its blob isn't attached to a database yet), so `.target`\n // resolves it synchronously without an Insert.attachmentBlobs field to carry it separately.\n const blobs = (mapped.message.attachments ?? [])\n .map((attachment) => attachment.ref.target)\n .filter((target): target is Blob.Blob => Obj.instanceOf(Blob.Blob, target));\n if (feed && blobs.length > 0) {\n commitEffects.push(() => Feed.append(feed, blobs));\n }\n\n if (mapped.contact) {\n const contact = mapped.contact;\n commitEffects.push(\n Effect.fn('email.commit.addContact')(function* () {\n yield* Database.add(contact);\n }),\n );\n }\n\n if (mapped.supersededDrafts?.length) {\n const drafts = mapped.supersededDrafts;\n commitEffects.push(\n Effect.fn('email.commit.removeDrafts')(function* () {\n const { db } = yield* Database.Service;\n for (const draft of drafts) {\n db.remove(draft);\n }\n }),\n );\n }\n\n if (tagIndex && mapped.tagUris?.length) {\n tagUrisByObject.set(mapped.message, mapped.tagUris);\n commitEffects.push(applyTags);\n }\n\n return {\n object: mapped.message,\n foreignId: mapped.foreignId,\n key: mapped.key,\n commitEffects: commitEffects.length > 0 ? commitEffects : undefined,\n };\n }),\n );\n};\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\nimport * as Stream from 'effect/Stream';\n\nimport { Database, Feed, Filter } from '@dxos/echo';\nimport { Cursor } from '@dxos/link';\nimport { log } from '@dxos/log';\nimport { Pipeline, Stage } from '@dxos/pipeline';\nimport { FactStore } from '@dxos/pipeline-rdf';\nimport { Message } from '@dxos/types';\n\nimport {\n EmailPipelineCtx,\n type FactExtractor,\n type FactIndexer,\n type FactUnit,\n type Stats,\n emptyStats,\n extractFactsStage,\n extractFactsUnitStage,\n messageSource,\n statsStage,\n} from './stages/index.ts';\n\nexport type EmailFactPipelineOptions = {\n /** ECHO space database, required by `EmailPipelineCtx` even though this assembly never writes to it. */\n readonly db: Database.Database;\n /** Persists one message's facts to the fact substrate (a closure over a `FactStore`). */\n readonly indexFacts: FactIndexer;\n};\n\nexport type EmailFactPipelineResult = {\n readonly messages: Message.Message[];\n readonly stats: Stats;\n};\n\nexport const EmailFactPipeline = {\n /**\n * Facts-only assembly of the email stages over a batch of messages: stats → extract-facts. Omits\n * summarize, extract-contacts, and {@link buildThreads} from {@link EmailPipeline}, so it needs no\n * `AiService` and never writes to `db`. The cursored `AnalyzeMailbox` operation (in plugin-inbox)\n * covers the same fact-extraction concern incrementally, via a sibling facts-returning stage\n * (`extractFactsUnitStage`) that commits pages atomically with a cursor advance instead of indexing\n * in-stage; this batch variant is for one-shot runs over an already-collected set of messages.\n */\n run: (\n messages: readonly Message.Message[],\n options: EmailFactPipelineOptions,\n ): Effect.Effect<EmailFactPipelineResult> =>\n Effect.gen(function* () {\n const stats = emptyStats();\n const collected: Message.Message[] = [];\n yield* Stream.fromIterable(messages).pipe(\n statsStage,\n extractFactsStage(options.indexFacts),\n Pipeline.run({ sink: (message) => Effect.sync(() => collected.push(message)) }),\n Effect.provideService(EmailPipelineCtx, { db: options.db, stats, summaries: [] }),\n );\n\n return { messages: collected, stats };\n }),\n};\n\n/**\n * Runs the cursored fact pipeline over a feed: dedup → extract facts → commit each page to the\n * {@link FactStore} while advancing `cursor`. Extraction is injected (`extract`) so the pipeline is\n * unit-testable with a deterministic stub — no `AiService` required. Feed-generic (the mailbox\n * `AnalyzeMailbox` operation is a thin wrapper that resolves a Mailbox to its feed and its persisted\n * `Cursor`).\n *\n * Dedup is by `cursor`'s high-water key (a coarse `key < cursorKey` skip) plus the set of message\n * sources already in the store (the precise idempotency backstop) — the store doesn't share `cursor`'s\n * persistence lifetime (it's still in-memory per space), so the source-dedup backstop still matters\n * even though the cursor itself is now durable.\n */\nexport const runFactPipeline = (options: {\n readonly feed: Feed.Feed;\n readonly cursor: Cursor.Cursor;\n readonly pageSize: number;\n /** In-flight parallelism for the per-message extraction stage (defaults to 1 = serial). */\n readonly concurrency?: number;\n readonly extract: FactExtractor;\n /**\n * Called once at run start (`processed: 0`) and after each committed page — the live-progress seam\n * (e.g. a UI meter). `total` is the exact pending count: both dedup predicates (cursor prefilter,\n * indexed-source set) are evaluated up-front over the already-materialized message list.\n */\n readonly onProgress?: (progress: { processed: number; facts: number; total: number }) => void;\n}): Effect.Effect<{ processed: number; facts: number }, never, FactStore | Database.Service> =>\n Effect.gen(function* () {\n const { feed, cursor, extract, pageSize, concurrency = 1, onProgress } = options;\n const store = yield* FactStore;\n\n // Sources (== `messageSource`) already indexed — the precise skip; the cursor is only a coarse\n // prefilter (the newest message keys equal to the advanced cursor, so `< cursorKey` alone would\n // re-extract it).\n const priorFacts = yield* store.query({}).pipe(Effect.orElseSucceed(() => []));\n const indexedSources = new Set(priorFacts.map((fact) => fact.attribution.source));\n\n // NOTE(workaround): the cursor key is `message.created` (epoch-ms) because ECHO's native feed\n // cursor is unimplemented (`Feed.cursor` is stubbed). Replace with the native queue sequence when\n // available.\n let cursorKey = Cursor.parseKey(cursor.max);\n\n let processed = 0;\n let facts = 0;\n // Ascending key order is load-bearing: the sink advances `cursorKey` per committed page and the\n // dedup stage reads it live, so on an unordered feed (e.g. an archive imported newest-first) the\n // first-committed newest message would advance the cursor past every older one. A malformed\n // `created` (NaN key) is dropped up front — it cannot be ordered against the cursor, and its NaN\n // would poison the page's `Math.max` cursor advance.\n const messages = (yield* Feed.query(feed, Filter.type(Message.Message)).run)\n .filter((message) => Number.isFinite(Date.parse(message.created)))\n .sort((left, right) => Date.parse(left.created) - Date.parse(right.created));\n // Exact pending count for a determinate meter: ascending order means the live mid-run cursor\n // advance can never drop a message this initial predicate kept (equal keys pass `< cursorKey`).\n const total = messages.filter(\n (message) => !(Date.parse(message.created) < cursorKey) && !indexedSources.has(messageSource(message)),\n ).length;\n log.info('analyze: pipeline start', {\n messages: messages.length,\n total,\n cursorKey,\n indexed: indexedSources.size,\n pageSize,\n });\n onProgress?.({ processed: 0, facts: 0, total });\n yield* Stream.fromIterable(messages).pipe(\n Stage.map('facts-dedup', (message: Message.Message) =>\n Effect.sync(() =>\n Date.parse(message.created) < cursorKey || indexedSources.has(messageSource(message)) ? undefined : message,\n ),\n ),\n extractFactsUnitStage(extract, concurrency),\n // Observability stage: confirms units flow through and how many facts each carries.\n Stage.map('facts-log', (unit: FactUnit) =>\n Effect.sync(() => {\n log.info('analyze: extracted unit', { foreignId: unit.foreignId, key: unit.key, facts: unit.facts.length });\n return unit;\n }),\n ),\n Stream.grouped(pageSize),\n Pipeline.run({\n sink: (page) =>\n Effect.gen(function* () {\n const units = page;\n if (units.length === 0) {\n return;\n }\n\n const pageFacts = units.flatMap((unit) => unit.facts);\n if (pageFacts.length > 0) {\n // A fact-store write failure is fatal to the run (not a recoverable per-page error).\n yield* store.putFacts(pageFacts).pipe(Effect.orDie);\n }\n\n processed += units.length;\n facts += pageFacts.length;\n cursorKey = Math.max(cursorKey, ...units.map((unit) => unit.key));\n Cursor.advance(cursor, Cursor.formatKey(cursorKey));\n\n // Terminal progress log (one line per committed page): the only live signal between\n // pipeline start and done, so a long run's advance is observable rather than silent.\n log.info('analyze: committed page', {\n page: units.length,\n pageFacts: pageFacts.length,\n processed,\n facts,\n cursorKey,\n });\n onProgress?.({ processed, facts, total });\n }),\n }),\n );\n\n log.info('analyze: pipeline done', { processed, facts });\n return { processed, facts };\n });\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Schema from 'effect/Schema';\n\nimport { DXN, Type } from '@dxos/echo';\n\nexport const ThreadState = Schema.Literals(['awaiting-mine', 'awaiting-theirs', 'resolved', 'stalled']);\nexport type ThreadState = Schema.Schema.Type<typeof ThreadState>;\n\n// A canonical (spec §4) conversation aggregate: messages sharing a derived threadId, with a rolling\n// summary, a coarse state, and consolidated open questions / action items. Authoritative in ECHO;\n// facts are advisory evidence, not the source of this object's state.\nexport class Thread extends Type.makeObject<Thread>(DXN.make('org.dxos.type.emailThread', '0.1.0'))(\n Schema.Struct({\n threadId: Schema.String,\n subject: Schema.String,\n summary: Schema.String,\n state: ThreadState,\n participants: Schema.Array(Schema.String),\n messageIds: Schema.Array(Schema.String),\n openQuestions: Schema.Array(Schema.String),\n actionItems: Schema.Array(Schema.String),\n }),\n) {}\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport { Obj } from '@dxos/echo';\nimport { Message } from '@dxos/types';\n\nimport { messageSource } from '../stages/facts.ts';\nimport { Thread, type ThreadState } from '../types/index.ts';\nimport { deriveThreadId } from './threading.ts';\n\nconst DEFAULT_STALE_PERIOD_MS = 14 * 24 * 60 * 60 * 1000;\n\nexport type BuildThreadsOptions = {\n /**\n * The mailbox owner's email(s). Accepts multiple so a person's aliases (e.g. work + personal) all\n * count as \"from the owner\" when inferring thread state; a single string is still accepted.\n */\n readonly ownerEmail: string | readonly string[];\n readonly now: string;\n readonly stalePeriodMs?: number;\n};\n\n/** Lowercased set of the owner's addresses (handles the single-string and multi-alias forms). */\nconst ownerAddressSet = (ownerEmail: string | readonly string[]): ReadonlySet<string> =>\n new Set((Array.isArray(ownerEmail) ? ownerEmail : [ownerEmail]).map((email) => email.toLowerCase()).filter(Boolean));\n\n// Coarse thread state from who spoke last and how long ago. `resolved` needs a signal we do not yet\n// extract, so it is not inferred in this slice.\nconst computeState = (lastFromOwner: boolean, lastCreated: string, options: BuildThreadsOptions): ThreadState => {\n const idleMs = new Date(options.now).getTime() - new Date(lastCreated).getTime();\n const stale = idleMs > (options.stalePeriodMs ?? DEFAULT_STALE_PERIOD_MS);\n if (stale) {\n return 'stalled';\n }\n return lastFromOwner ? 'awaiting-theirs' : 'awaiting-mine';\n};\n\n// Group messages into canonical Thread objects. Messages are bucketed by derived threadId; within a\n// bucket they are ordered by `created` to find the last speaker.\nexport const buildThreads = (messages: readonly Message.Message[], options: BuildThreadsOptions): Thread[] => {\n const owners = ownerAddressSet(options.ownerEmail);\n const buckets = new Map<string, Message.Message[]>();\n for (const message of messages) {\n const threadId = deriveThreadId(message);\n let bucket = buckets.get(threadId);\n if (!bucket) {\n bucket = [];\n buckets.set(threadId, bucket);\n }\n bucket.push(message);\n }\n\n const threads: Thread[] = [];\n for (const [threadId, bucket] of buckets) {\n const ordered = [...bucket].sort((a, b) => a.created.localeCompare(b.created));\n const last = ordered[ordered.length - 1];\n // Email addresses are case-insensitive, so compare and dedup on a lowercased form; otherwise\n // mixed-case senders split into distinct participants and can misclassify awaiting-mine/theirs.\n const lastFromOwner = owners.has(last.sender.email?.toLowerCase() ?? '');\n const participants = [...new Set(ordered.flatMap((m) => (m.sender.email ? [m.sender.email.toLowerCase()] : [])))];\n const summaries = ordered.flatMap((m) =>\n typeof m.properties?.summary === 'string' && m.properties.summary.length > 0 ? [m.properties.summary] : [],\n );\n const subject = typeof ordered[0].properties?.subject === 'string' ? ordered[0].properties.subject : threadId;\n\n threads.push(\n Obj.make(Thread, {\n threadId,\n subject,\n summary: summaries.length > 0 ? summaries.join('\\n') : subject,\n state: computeState(lastFromOwner, last.created, options),\n participants,\n messageIds: ordered.map(messageSource),\n openQuestions: [],\n actionItems: [],\n }),\n );\n }\n return threads;\n};\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\nimport * as Stream from 'effect/Stream';\n\nimport { type AiService } from '@dxos/ai';\nimport { type Database } from '@dxos/echo';\nimport { Pipeline } from '@dxos/pipeline';\nimport { Message } from '@dxos/types';\n\nimport { buildThreads } from './internal/threads.ts';\nimport {\n EmailPipelineCtx,\n type FactIndexer,\n type Stats,\n type Summary,\n emptyStats,\n extractContactsStage,\n extractFactsStage,\n statsStage,\n summarizeStage,\n} from './stages/index.ts';\nimport { type Thread } from './types/index.ts';\n\nexport type EmailPipelineOptions = {\n /** ECHO space database: extract-contacts persists Person/Organization, threads are added here. */\n readonly db: Database.Database;\n /** Persists one message's facts to the fact substrate (a closure over a `FactStore`). */\n readonly indexFacts: FactIndexer;\n /** The space owner's email, used to orient thread state (awaiting-mine / awaiting-theirs). */\n readonly ownerEmail: string;\n /** Wall clock used to derive thread staleness; injected so the assembly stays deterministic. */\n readonly now: string;\n};\n\nexport type EmailPipelineResult = {\n readonly messages: Message.Message[];\n readonly stats: Stats;\n readonly summaries: ReadonlyArray<{ messageId: string; summary: Summary }>;\n readonly threads: Thread[];\n};\n\nexport const EmailPipeline = {\n /**\n * Default assembly of the email stages over a batch of messages: summarize → extract-contacts →\n * stats → extract-facts stream through with back pressure, then {@link buildThreads} groups the\n * collected messages into canonical threads. Contacts and threads are persisted to `db`; the fact\n * substrate is populated via `indexFacts`. Requires an `AiService` (summarize); the shared\n * {@link EmailPipelineCtx} is constructed and provided internally.\n */\n run: (\n messages: readonly Message.Message[],\n options: EmailPipelineOptions,\n ): Effect.Effect<EmailPipelineResult, never, AiService.AiService> =>\n Effect.gen(function* () {\n const stats = emptyStats();\n const summaries: Array<{ messageId: string; summary: Summary }> = [];\n const collected: Message.Message[] = [];\n yield* Stream.fromIterable(messages).pipe(\n summarizeStage,\n extractContactsStage,\n statsStage,\n extractFactsStage(options.indexFacts),\n Pipeline.run({ sink: (message) => Effect.sync(() => collected.push(message)) }),\n Effect.provideService(EmailPipelineCtx, { db: options.db, stats, summaries }),\n );\n\n const threads = buildThreads(collected, { ownerEmail: options.ownerEmail, now: options.now });\n for (const thread of threads) {\n options.db.add(thread);\n }\n return { messages: collected, stats, summaries, threads };\n }),\n};\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport { type Message } from '@dxos/types';\n\nimport { type Summarizer, type TopicDraft, type TopicOptions, clusterThreads } from './corpus/index.ts';\nimport { buildThreads } from './internal/threads.ts';\nimport { type TagResult } from './stages/tag.ts';\n\n// The topics pipeline (productized from the research harness): tag each message, then cluster its\n// threads into `Topic` objects with LLM summaries. Pure orchestration — the LLM-bearing steps (`tag`,\n// `summarize`) are INJECTED so this is unit-testable with stubs; the plugin-inbox operation wires them\n// to the AI service and applies the results (tags via `Mailbox.applyTag`, topics + `AnchoredTo`\n// relations into the space). One-shot + resumable-lite: `limit` bounds a run, `skipMessage` /\n// `skipTopic` let a re-run resume past what a previous invocation already tagged / materialized.\n\n/** Per-message tag output — applied to the mailbox tag index by the caller. */\nexport type MessageTags = {\n readonly messageId: string;\n readonly tags: readonly string[];\n readonly spam: boolean;\n};\n\nexport type TopicsPipelineInput = {\n readonly messages: readonly Message.Message[];\n /** Mailbox owner email — steers thread-state inference in `buildThreads`. */\n readonly ownerEmail: string;\n /** Wall-clock ISO timestamp for `buildThreads` idle/state inference (injected — no `Date.now()` here). */\n readonly now: string;\n /** Cap messages tagged this run (resumable-lite); undefined → all. */\n readonly limit?: number;\n /** Skip a message already tagged by a previous run (resume). */\n readonly skipMessage?: (messageId: string) => boolean;\n /** Skip a topic already materialized by a previous run (dedupe on re-run). */\n readonly skipTopic?: (label: string) => boolean;\n /**\n * Keep a clustered topic only when this returns true (applied before summarization). Used to create\n * topics only for senders the user has a relationship with (an existing `Person` record); undefined\n * keeps every topic.\n */\n readonly keepTopic?: (draft: TopicDraft) => boolean;\n readonly topicOptions?: TopicOptions;\n /** Progress hook: phase `'tag'` (per message) or `'topic'` (per summarized topic). */\n readonly onProgress?: (phase: 'tag' | 'topic', current: number, total: number) => void;\n /** Cooperative cancellation — checked between messages; aborting stops after the current item. */\n readonly signal?: AbortSignal;\n};\n\n/** Effectful dependencies, injected so the orchestration stays pure and testable. */\nexport type TopicsPipelineDeps = {\n readonly tag: (message: Message.Message) => Promise<TagResult>;\n readonly summarize: Summarizer;\n};\n\nexport type TopicsPipelineResult = {\n readonly messageTags: readonly MessageTags[];\n /**\n * Summarized topic drafts (plain values, not ECHO objects — excludes any skipped by `skipTopic` /\n * dropped by `keepTopic`). The caller decides what to do with them: materialize `Topic` objects, or\n * write them to `Mailbox.topicSuggestions` for the user to curate.\n */\n readonly topicDrafts: readonly TopicDraft[];\n};\n\nconst messageIdOf = (message: Message.Message): string => String(message.properties?.messageId ?? message.id);\n\n/**\n * Runs the topics pipeline: tag (bounded/resumable) → `buildThreads` → `clusterThreads` →\n * `summarizeTopics`. Returns per-message tags and the summarized topic drafts for the caller to\n * materialize or suggest. Tagging failures degrade per message (the injected `tag` should not\n * reject); clustering is deterministic.\n */\nexport const runTopicsPipeline = async (\n input: TopicsPipelineInput,\n deps: TopicsPipelineDeps,\n): Promise<TopicsPipelineResult> => {\n const { messages, ownerEmail, now, limit, skipMessage, skipTopic, keepTopic, topicOptions, onProgress, signal } =\n input;\n\n // Phase 1 — tag each not-yet-tagged message, bounded by `limit`. Stop early if cancelled.\n const pending = messages.filter((message) => !skipMessage?.(messageIdOf(message)));\n const toTag = limit !== undefined ? pending.slice(0, limit) : pending;\n const messageTags: MessageTags[] = [];\n for (let index = 0; index < toTag.length && !signal?.aborted; index++) {\n const message = toTag[index];\n const result = await deps.tag(message);\n messageTags.push({ messageId: messageIdOf(message), tags: result.tags, spam: result.spam });\n onProgress?.('tag', index + 1, toTag.length);\n }\n\n // Cancelled during tagging — skip the (LLM) topic phase and return what was tagged.\n if (signal?.aborted) {\n return { messageTags, topicDrafts: [] };\n }\n\n // Phase 2 — cluster threads into topic drafts (deterministic), summarize the new ones.\n const threads = buildThreads(messages, { ownerEmail, now });\n const drafts = clusterThreads(threads, topicOptions);\n // const fresh = drafts.filter((draft: TopicDraft) => !skipTopic?.(draft.label) && (keepTopic?.(draft) ?? true));\n // const summarized = await summarizeTopics(fresh, deps.summarize);\n // summarized.forEach((_, index) => onProgress?.('topic', index + 1, summarized.length));\n\n // return { messageTags, topicDrafts: summarized };\n return { messageTags, topicDrafts: drafts };\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["__dxlog_file"],"sources":["../../src/corpus/prompts.ts","../../src/corpus/topics.ts","../../src/corpus/ledger.ts","../../src/internal/threading.ts","../../src/corpus/rollups.ts","../../src/corpus/digest.ts","../../src/stages/context.ts","../../src/stages/extract-contacts.ts","../../src/stages/extract-facts.ts","../../src/stages/facts.ts","../../src/stages/extract-facts-commit.ts","../../src/stages/stats.ts","../../src/stages/summarize.ts","../../src/model-policy.ts","../../src/stages/tag.ts","../../src/stages/EmailStage.ts","../../src/fact-pipeline.ts","../../src/types/Thread.ts","../../src/internal/threads.ts","../../src/pipeline.ts","../../src/topics-pipeline.ts"],"sourcesContent":["//\n// Copyright 2026 DXOS.org\n//\n\nimport { trim } from '@dxos/util';\n\n/**\n * User-editable prompts for the LLM-assisted corpus algorithms. Every slot has a default; callers\n * override any subset via {@link mergePrompts}, so the discovery algorithms stay configurable\n * without forking the pipeline. (Extraction rules are configured separately, via pipeline-rdf\n * `ExtractOptions.rules`.)\n */\nexport type EmailPrompts = {\n /** Summarize one topic (cluster of threads); receives the topic label + per-thread summaries. */\n readonly topicSummary: string;\n /** Narrate a corpus digest; receives the deterministic digest skeleton. */\n readonly digest: string;\n};\n\nexport const DEFAULT_EMAIL_PROMPTS: EmailPrompts = {\n topicSummary: trim`\n Summarize the following email conversation topic in 1-2 sentences.\n Focus on what the threads have in common and any outcome or open question.\n Respond with ONLY the summary text.\n `,\n digest: trim`\n Write a short briefing (3-5 sentences) from the following inbox digest data.\n Lead with what needs attention (stalled threads, due commitments), then notable topics.\n Respond with ONLY the briefing text.\n `,\n};\n\n/** Overlay caller overrides onto the defaults; absent slots keep their default. */\nexport const mergePrompts = (overrides?: Partial<EmailPrompts>): EmailPrompts => ({\n ...DEFAULT_EMAIL_PROMPTS,\n ...overrides,\n});\n\n/**\n * LLM call boundary used by the corpus algorithms: the harness owns model/runtime plumbing and hands\n * modules a plain async closure (same pattern as `FactIndexer`), so algorithm Effects stay R = never.\n */\nexport type Summarizer = (prompt: string) => Promise<string>;\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Project from '@dxos/compute/Project';\n\nimport { type Thread } from '../types/index.ts';\nimport { DEFAULT_EMAIL_PROMPTS, type EmailPrompts, type Summarizer, mergePrompts } from './prompts.ts';\n\n// TODO(burdon): Move to LLM.\n// Subject tokens that carry no topical signal; excluded from signatures and keywords.\nconst DEFAULT_STOPWORDS: readonly string[] = [\n 'the',\n 'and',\n 'for',\n 'with',\n 'from',\n 'about',\n 'this',\n 'that',\n 'your',\n 'our',\n 'are',\n 'was',\n 'will',\n 'has',\n 'have',\n 'not',\n 'you',\n 'all',\n 'any',\n 'can',\n 'per',\n 'new',\n];\n\n/**\n * Tunable knobs for the deterministic shared-signal clustering (spec §10.3: agglomeration over\n * participant/keyword overlap; embedding/LLM grouping deferred). All values have defaults so\n * callers can adjust the algorithm without forking it.\n */\nexport type TopicOptions = {\n /** Minimum Jaccard similarity between subject-token signatures that joins a topic. */\n readonly similarityThreshold?: number;\n /** Shared-participant count that joins a topic regardless of token overlap. */\n readonly minSharedParticipants?: number;\n /** Subject tokens shorter than this are ignored. */\n readonly minTokenLength?: number;\n /** Tokens excluded from signatures and keywords. */\n readonly stopwords?: readonly string[];\n /** Keywords retained per topic (most frequent first). */\n readonly maxKeywords?: number;\n /**\n * Drop identifier tokens (invoice hashes, order numbers, tracking codes) from subject signatures.\n * These are unique per message, so they fragment otherwise-identical automated mail into singletons.\n */\n readonly dropIdTokens?: boolean;\n};\n\nexport const DEFAULT_TOPIC_OPTIONS: Required<TopicOptions> = {\n similarityThreshold: 0.25,\n minSharedParticipants: 2,\n minTokenLength: 3,\n stopwords: DEFAULT_STOPWORDS,\n maxKeywords: 5,\n dropIdTokens: true,\n};\n\n/** A topic before ECHO materialization: plain values so LLM enrichment can rewrite them freely. */\nexport type TopicDraft = {\n readonly name: string;\n readonly summary: string;\n readonly threadIds: readonly string[];\n readonly participants: readonly string[];\n readonly keywords: readonly string[];\n readonly questions: readonly string[];\n readonly tasks: readonly string[];\n};\n\ntype Signature = {\n readonly thread: Thread;\n readonly tokens: Set<string>;\n readonly participants: Set<string>;\n};\n\n// Identifiers (invoice hashes, order numbers, tracking codes, years) carry no topical signal and are\n// unique per message, so they must be dropped or near-identical automated mail never collapses. A\n// token is an id if it is a pure number, a hex-looking string, or a digit-heavy alphanumeric code;\n// short version-like tokens (\"q4\", \"v2\") are kept.\nconst isIdToken = (token: string): boolean => {\n if (/^\\d+$/.test(token)) {\n return true;\n }\n const digits = (token.match(/\\d/g) ?? []).length;\n if (digits === 0) {\n return false;\n }\n if (/^[0-9a-f]+$/.test(token) && token.length >= 6) {\n return true;\n }\n return token.length >= 5 && digits / token.length >= 0.3;\n};\n\nconst tokenize = (subject: string, options: Required<TopicOptions>): Set<string> =>\n new Set(\n subject\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(\n (token) =>\n token.length >= options.minTokenLength &&\n !options.stopwords.includes(token) &&\n !(options.dropIdTokens && isIdToken(token)),\n ),\n );\n\nconst jaccard = (left: Set<string>, right: Set<string>): number => {\n if (left.size === 0 || right.size === 0) {\n return 0;\n }\n let shared = 0;\n for (const token of left) {\n if (right.has(token)) {\n shared += 1;\n }\n }\n return shared / (left.size + right.size - shared);\n};\n\nconst sharedCount = (left: Set<string>, right: Set<string>): number => {\n let shared = 0;\n for (const value of left) {\n if (right.has(value)) {\n shared += 1;\n }\n }\n return shared;\n};\n\n/**\n * Greedy agglomerative clustering of threads into topic drafts. Deterministic (Compute mode): a\n * thread joins the first cluster whose aggregate signature overlaps enough — by subject-token\n * Jaccard similarity or by shared participants — else starts its own. Bounded by O(threads ×\n * clusters); no LLM involved, so results are stable and unit-testable.\n */\nexport const clusterThreads = (threads: readonly Thread[], options?: TopicOptions): TopicDraft[] => {\n const opts = { ...DEFAULT_TOPIC_OPTIONS, ...options };\n const clusters: { members: Signature[]; tokens: Set<string>; participants: Set<string> }[] = [];\n\n for (const thread of threads) {\n const signature: Signature = {\n thread,\n tokens: tokenize(thread.subject, opts),\n participants: new Set(thread.participants),\n };\n const home = clusters.find(\n (cluster) =>\n jaccard(cluster.tokens, signature.tokens) >= opts.similarityThreshold ||\n sharedCount(cluster.participants, signature.participants) >= opts.minSharedParticipants,\n );\n if (home) {\n home.members.push(signature);\n for (const token of signature.tokens) {\n home.tokens.add(token);\n }\n for (const participant of signature.participants) {\n home.participants.add(participant);\n }\n } else {\n clusters.push({\n members: [signature],\n tokens: new Set(signature.tokens),\n participants: new Set(signature.participants),\n });\n }\n }\n\n return clusters.map((cluster) => {\n // Rank keywords by how many member threads mention them, then alphabetically for determinism.\n const counts = new Map<string, number>();\n for (const member of cluster.members) {\n for (const token of member.tokens) {\n counts.set(token, (counts.get(token) ?? 0) + 1);\n }\n }\n const keywords = [...counts.entries()]\n .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))\n .slice(0, opts.maxKeywords)\n .map(([token]) => token);\n\n const summaries = cluster.members.flatMap((member) =>\n member.thread.summary.length > 0 ? [member.thread.summary] : [],\n );\n\n // Roll up open questions / action items from the member threads (deduped, order-preserving).\n const questions = [...new Set(cluster.members.flatMap((member) => member.thread.openQuestions ?? []))];\n const tasks = [...new Set(cluster.members.flatMap((member) => member.thread.actionItems ?? []))];\n\n // Label from top keywords; a keyword-less cluster (e.g. blank subjects) falls back through the\n // first thread's subject to its threadId so the label is never empty.\n const first = cluster.members[0].thread;\n return {\n name: keywords.length > 0 ? keywords.slice(0, 3).join(' ') : first.subject || first.threadId,\n summary: summaries.join('\\n'),\n threadIds: cluster.members.map((member) => member.thread.threadId),\n participants: [...cluster.participants].sort(),\n keywords,\n questions,\n tasks,\n } satisfies TopicDraft;\n });\n};\n\n/**\n * Optionally replace each draft's deterministic summary with an LLM one, using the editable\n * `topicSummary` prompt. Degrades per-topic: a failed call keeps that draft's deterministic summary\n * (advisory enrichment must not fail the corpus pass).\n */\nexport const summarizeTopics = async (\n drafts: readonly TopicDraft[],\n summarize: Summarizer,\n options?: { readonly prompts?: Partial<EmailPrompts> },\n): Promise<TopicDraft[]> => {\n const prompts = options?.prompts ? mergePrompts(options.prompts) : DEFAULT_EMAIL_PROMPTS;\n return Promise.all(\n drafts.map(async (draft) => {\n try {\n const summary = await summarize(\n [\n //\n prompts.topicSummary,\n `Topic: ${draft.name}`,\n // `Threads:\\n${draft.summary}`,\n ].join('\\n\\n'),\n );\n\n return summary.trim().length > 0 ? { ...draft, summary: summary.trim() } : draft;\n } catch {\n return draft;\n }\n }),\n );\n};\n\n/** Materialize drafts as canonical Project ECHO objects (done last; drafts stay freely mutable). */\nexport const materializeTopics = (drafts: readonly TopicDraft[]): Project.Project[] =>\n drafts.map((draft) =>\n Project.make({\n name: draft.name,\n // summary: draft.summary,\n // threadIds: [...draft.threadIds],\n // participants: [...draft.participants],\n // keywords: [...draft.keywords],\n // questions: [...draft.questions],\n // tasks: [...draft.tasks],\n }),\n );\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\n\nimport { type FactStoreApi, type RDF, type SemanticIndexError } from '@dxos/pipeline-rdf';\n\n/**\n * Predicates treated as commitments (spec §3③ commitment ledger). Editable: extraction phrasing\n * varies by corpus and extraction rules, so callers tune this list rather than the query logic.\n */\nexport const DEFAULT_COMMITMENT_PREDICATES: readonly string[] = ['owes', 'will send', 'will provide', 'must', 'due'];\n\nexport type LedgerOptions = {\n /** Predicates that mark a fact as a commitment. */\n readonly predicates?: readonly string[];\n};\n\n/** One row of the commitment ledger, traceable to its supporting fact. */\nexport type Commitment = {\n /** Who owes it (fact subject label). */\n readonly who: string;\n /** What is owed (fact object label or literal). */\n readonly what: string;\n /** Deadline (assertion validTo), when stated. */\n readonly dueBy?: string;\n /** Model confidence carried from the fact's factuality. */\n readonly confidence?: number;\n readonly factId: string;\n /** Source message (fact attribution). */\n readonly source: string;\n};\n\nconst termLabel = (term: RDF.Assertion['subject']): string =>\n term.kind === 'entity' ? (term.label ?? term.entity) : term.literal;\n\nconst toCommitment = (fact: RDF.Fact): Commitment => ({\n who: termLabel(fact.assertion.subject),\n what: termLabel(fact.assertion.object),\n ...(fact.assertion.validTo ? { dueBy: fact.assertion.validTo } : {}),\n ...(fact.factuality.confidence !== undefined ? { confidence: fact.factuality.confidence } : {}),\n factId: fact.id,\n source: fact.attribution.source,\n});\n\n/**\n * Query the fact store for outstanding commitments — the spec's cross-thread \"owe / owed\" ledger.\n * Uses the structured query path (one `query({ predicate })` per commitment predicate, compiled to\n * SPARQL on the sqlite backend), which works on both store backends; rows are advisory evidence\n * (§4 canonicality) and each carries its fact id + source message for grounding.\n */\nexport const commitmentLedger = (\n store: FactStoreApi,\n options?: LedgerOptions,\n): Effect.Effect<Commitment[], SemanticIndexError> =>\n Effect.forEach(options?.predicates ?? DEFAULT_COMMITMENT_PREDICATES, (predicate) => store.query({ predicate })).pipe(\n Effect.map((results) => {\n // A fact can match several predicate probes; keep the first occurrence.\n const seen = new Set<string>();\n const commitments: Commitment[] = [];\n for (const fact of results.flat()) {\n if (!seen.has(fact.id)) {\n seen.add(fact.id);\n commitments.push(toCommitment(fact));\n }\n }\n // Deadlines first (soonest first), then the rest in stable fact order.\n return commitments.sort((a, b) =>\n a.dueBy && b.dueBy ? a.dueBy.localeCompare(b.dueBy) : a.dueBy ? -1 : b.dueBy ? 1 : 0,\n );\n }),\n );\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport { Message } from '@dxos/types';\n\n// Leading reply/forward markers, stripped repeatedly (\"Fw: Re:\" → \"\").\nconst PREFIX = /^\\s*(re|fwd?|fw)\\s*:\\s*/i;\n\n// Canonical subject for threading: drop reply/forward prefixes, collapse internal whitespace,\n// lowercase. Two messages with the same normalized subject belong to the same thread.\nexport const normalizeSubject = (subject: string): string => {\n let value = subject;\n while (PREFIX.test(value)) {\n value = value.replace(PREFIX, '');\n }\n return value.replace(/\\s+/g, ' ').trim().toLowerCase();\n};\n\n// Derive a thread id from the subject. The Enron dataset carries no References/In-Reply-To headers,\n// so the normalized subject is the available grouping signal; blank subjects share a `no-subject`\n// bucket rather than each forming a singleton thread.\nexport const deriveThreadId = (message: Message.Message): string => {\n const subject = message.properties?.subject;\n const normalized = typeof subject === 'string' ? normalizeSubject(subject) : '';\n return normalized.length > 0 ? normalized : 'no-subject';\n};\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport { Message } from '@dxos/types';\n\nimport { deriveThreadId } from '../internal/threading.ts';\n\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/**\n * Per-sender relationship aggregate (spec §3③ relationship rollups). Computed values, not ECHO\n * mutations: `Person` is a closed, canonical schema the pipeline must not write onto, so rollups are\n * consumed by the digest / callers (join back to a Person via the lowercased email and the\n * fact-index reconciliation table); materialization waits for a proper schema home.\n */\nexport type RelationshipRollup = {\n /** Lowercased sender address (email addresses are case-insensitive). */\n readonly email: string;\n readonly messageCount: number;\n readonly firstContact: string;\n readonly lastContact: string;\n /** Mean days between messages; absent below two messages. */\n readonly cadenceDays?: number;\n /** Threads this sender participated in (send-side), in first-seen order. */\n readonly threadIds: readonly string[];\n};\n\n/** Aggregate messages into per-sender rollups, most active sender first. Deterministic (Compute mode). */\nexport const buildRollups = (messages: readonly Message.Message[]): RelationshipRollup[] => {\n const bySender = new Map<string, { created: string[]; threadIds: string[] }>();\n for (const message of messages) {\n const email = message.sender.email?.toLowerCase();\n if (!email) {\n continue;\n }\n let entry = bySender.get(email);\n if (!entry) {\n entry = { created: [], threadIds: [] };\n bySender.set(email, entry);\n }\n entry.created.push(message.created);\n const threadId = deriveThreadId(message);\n if (!entry.threadIds.includes(threadId)) {\n entry.threadIds.push(threadId);\n }\n }\n\n const rollups: RelationshipRollup[] = [];\n for (const [email, entry] of bySender) {\n const ordered = [...entry.created].sort();\n const first = ordered[0];\n const last = ordered[ordered.length - 1];\n const spanMs = new Date(last).getTime() - new Date(first).getTime();\n rollups.push({\n email,\n messageCount: ordered.length,\n firstContact: first,\n lastContact: last,\n ...(ordered.length >= 2 ? { cadenceDays: spanMs / (ordered.length - 1) / MS_PER_DAY } : {}),\n threadIds: entry.threadIds,\n });\n }\n return rollups.sort((a, b) => b.messageCount - a.messageCount || a.email.localeCompare(b.email));\n};\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport { type Thread } from '../types/index.ts';\nimport { type Commitment } from './ledger.ts';\nimport { DEFAULT_EMAIL_PROMPTS, type EmailPrompts, type Summarizer, mergePrompts } from './prompts.ts';\nimport { type RelationshipRollup } from './rollups.ts';\nimport { type TopicDraft } from './topics.ts';\n\n/**\n * Everything the corpus pass has computed; the digest is a Query-mode view over it (spec §3③\n * briefings) — assembled on demand, never materialized as ECHO.\n */\nexport type DigestInput = {\n readonly threads: readonly Thread[];\n readonly topics: readonly TopicDraft[];\n readonly commitments: readonly Commitment[];\n readonly rollups: readonly RelationshipRollup[];\n};\n\nexport type DigestOptions = {\n /** Timestamp the digest reports as its generation time (passed in for determinism). */\n readonly now: string;\n /** Topics listed in the skeleton (largest first). */\n readonly maxTopTopics?: number;\n};\n\n/** Deterministic digest skeleton; `narrative` stays empty until {@link narrateDigest} fills it. */\nexport type Digest = {\n readonly generatedAt: string;\n readonly threadCount: number;\n readonly topicCount: number;\n /** Threads needing attention. */\n readonly stalledThreads: readonly string[];\n readonly awaitingMine: readonly string[];\n /** Commitments carrying a deadline, soonest first. */\n readonly dueCommitments: readonly Commitment[];\n // readonly topTopics: readonly { readonly label: string; readonly threadCount: number }[];\n /** LLM briefing text; empty when narration is off or degraded. */\n readonly narrative: string;\n};\n\n/** Assemble the deterministic digest skeleton from the corpus outputs (Compute mode; no LLM). */\nexport const buildDigest = (input: DigestInput, options: DigestOptions): Digest => ({\n generatedAt: options.now,\n threadCount: input.threads.length,\n topicCount: input.topics.length,\n stalledThreads: input.threads.filter((thread) => thread.state === 'stalled').map((thread) => thread.threadId),\n awaitingMine: input.threads.filter((thread) => thread.state === 'awaiting-mine').map((thread) => thread.threadId),\n dueCommitments: input.commitments\n .filter((commitment) => commitment.dueBy !== undefined)\n .sort((a, b) => (a.dueBy ?? '').localeCompare(b.dueBy ?? '')),\n // topTopics: [...input.topics]\n // .sort((a, b) => b.threadIds.length - a.threadIds.length || a.label.localeCompare(b.label))\n // .slice(0, options.maxTopTopics ?? 5)\n // .map((topic) => ({ label: topic.label, threadCount: topic.threadIds.length })),\n narrative: '',\n});\n\n/** Render the skeleton as plain text — the prompt payload for narration, and a fallback briefing. */\nexport const renderDigest = (digest: Digest): string =>\n [\n `Inbox digest (${digest.generatedAt})`,\n `Threads: ${digest.threadCount} Topics: ${digest.topicCount}`,\n `Awaiting my reply: ${digest.awaitingMine.join(', ') || 'none'}`,\n `Stalled: ${digest.stalledThreads.join(', ') || 'none'}`,\n `Due commitments: ${\n digest.dueCommitments\n .map((commitment) => `${commitment.who} → ${commitment.what} (${commitment.dueBy})`)\n .join('; ') || 'none'\n }`,\n // `Top topics: ${digest.topTopics.map((topic) => `${topic.label} (${topic.threadCount})`).join('; ') || 'none'}`,\n ].join('\\n');\n\n/**\n * Optionally add an LLM briefing over the skeleton, using the editable `digest` prompt. Degrades to\n * the un-narrated digest on failure — the skeleton is always available.\n */\nexport const narrateDigest = async (\n digest: Digest,\n summarize: Summarizer,\n options?: { readonly prompts?: Partial<EmailPrompts> },\n): Promise<Digest> => {\n const prompts = options?.prompts ? mergePrompts(options.prompts) : DEFAULT_EMAIL_PROMPTS;\n try {\n const narrative = await summarize([prompts.digest, renderDigest(digest)].join('\\n\\n'));\n return { ...digest, narrative: narrative.trim() };\n } catch {\n return digest;\n }\n};\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Context from 'effect/Context';\n\nimport { type Database } from '@dxos/echo';\n\n/** Summary produced by the summarize stage. */\nexport type Summary = { readonly summary: string; readonly isSpam: boolean; readonly keywords: readonly string[] };\n\n/** Running tallies produced by the stats stage (mutable accumulator). */\nexport type Stats = { from: Map<string, number>; to: Map<string, number>; total: number; spam: number };\n\n/** Per-message summary result keyed by messageId (for a summary view). */\nexport type SummaryResult = { readonly summaries: ReadonlyArray<{ messageId: string; summary: Summary }> };\n\nexport const emptyStats = (): Stats => ({ from: new Map(), to: new Map(), total: 0, spam: 0 });\n\n/**\n * Shared context threaded through the email stages via Effect's Requirements channel. `db` is the\n * ECHO space database (browser-safe); `stats` and `summaries` are mutable accumulators read after\n * the run. Callers provide it once at the pipeline edge (`Effect.provide`).\n */\nexport class EmailPipelineCtx extends Context.Service<\n EmailPipelineCtx,\n {\n readonly db: Database.Database;\n readonly stats: Stats;\n readonly summaries: Array<{ messageId: string; summary: Summary }>;\n }\n>()('@dxos/pipeline-email/EmailPipelineCtx') {}\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\n\nimport { extractContact } from '@dxos/extractor-lib';\nimport { Stage } from '@dxos/pipeline';\nimport { Message } from '@dxos/types';\n\nimport { EmailPipelineCtx } from './context.ts';\n\n/** Extract a Person (+ Organization) from the sender and persist to the ECHO space; pass message through. */\nexport const extractContactsStage: Stage.Stage<Message.Message, Message.Message, never, EmailPipelineCtx> = Stage.map(\n 'extract-contacts',\n (message) =>\n Effect.gen(function* () {\n const { db } = yield* EmailPipelineCtx;\n const result = yield* extractContact({ db, source: message });\n for (const object of result.created) {\n db.add(object);\n }\n return message;\n }),\n);\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\n\nimport { Stage } from '@dxos/pipeline';\nimport { type RDF } from '@dxos/pipeline-rdf';\nimport { Message } from '@dxos/types';\n\n// Extract + persist one message's facts, returning them. The store/AI-bound work is a Promise closure\n// so the stage's Effect stays R = never (a failed extraction must not fail the pipeline run).\nexport type FactIndexer = (message: Message.Message) => Promise<RDF.Fact[]>;\n\n// Message-layer stage: index each message into the fact substrate, passing the Message through\n// unchanged. Extraction degrades to no facts on failure (advisory layer) but logs first, so the\n// degradation stays observable. A factory over `indexFacts` (like the pipeline's `logStage(label)`)\n// keeps the module decoupled from any test-level Context.\nexport const extractFactsStage = (indexFacts: FactIndexer): Stage.Stage<Message.Message, Message.Message> =>\n Stage.map('extract-facts', (message) =>\n Effect.tryPromise(() => indexFacts(message)).pipe(\n Effect.tapError((error) => Effect.logWarning('extract-facts failed; degrading to no facts', error)),\n Effect.catch(() => Effect.succeed<RDF.Fact[]>([])),\n Effect.as(message),\n ),\n );\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport { type RDF } from '@dxos/pipeline-rdf';\nimport { Message } from '@dxos/types';\n\n// Email-specific extraction guidance appended after the pipeline-rdf DEFAULT_EXTRACTION_RULES.\n// Kept atomic so the two rule sets compose without forking the base prompt.\nexport const EMAIL_EXTRACTION_RULES: readonly string[] = [\n '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.',\n 'A deadline or due date becomes validTo on the relevant assertion (ISO date when known).',\n 'Attribute first-person statements (\"I will…\", \"we can…\") to the message sender as subject.',\n];\n\nexport const EMAIL_EXTRACT_OPTIONS: RDF.ExtractOptions = {\n rules: EMAIL_EXTRACTION_RULES,\n};\n\n// Stable per-message identifier used as the fact `source` and the incremental cursor key. Prefer the\n// RFC message-id header (present in the Enron dataset); fall back to sender+timestamp so every message\n// has a deterministic source even when the header is absent.\nexport const messageSource = (message: Message.Message): string => {\n const messageId = message.properties?.messageId;\n if (typeof messageId === 'string' && messageId.length > 0) {\n return messageId;\n }\n\n return `${message.sender.email ?? 'unknown'}:${message.created}`;\n};\n\nexport const messageToDocument = (message: Message.Message): RDF.ExtractDocument => ({\n text: Message.extractText(message),\n source: messageSource(message),\n ...(message.sender.email ? { author: message.sender.email } : {}),\n date: message.created,\n});\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\n\nimport { Stage } from '@dxos/pipeline';\nimport { type RDF } from '@dxos/pipeline-rdf';\nimport { Message } from '@dxos/types';\n\nimport { messageSource } from './facts.ts';\n\n// Extract one message's facts WITHOUT persisting them. Distinct from `FactIndexer`, whose closure\n// also writes to the store: here persistence is the sink's responsibility, so a page of facts can\n// commit atomically with the cursor advance. Same signature, different contract.\nexport type FactExtractor = (message: Message.Message) => Promise<RDF.Fact[]>;\n\n// Terminal unit for the cursored fact pipeline: extracted facts plus the keys the sink needs to\n// dedup and advance the cursor.\nexport type FactUnit = {\n readonly facts: RDF.Fact[];\n /** Stable per-message id (`messageSource`); the dedup foreign id. */\n readonly foreignId: string;\n /** Monotonic cursor key. */\n readonly key: number;\n};\n\n// Facts-returning variant of `extractFactsStage`: returns the extracted facts as a `FactUnit`\n// instead of persisting them in-stage, so the sink can write a page atomically with the cursor\n// advance. Extraction degrades to no facts on failure (advisory layer) but logs first, matching\n// `extractFactsStage`'s degradation, so the loss stays observable.\n// `concurrency` bounds in-flight extractions; >1 runs the (independent, network-bound) per-message\n// LLM calls in parallel. Facts are order-independent — the sink commits by max cursor key — so the\n// reordering `mapEffect` may introduce is harmless here.\nexport const extractFactsUnitStage = (\n extract: FactExtractor,\n concurrency = 1,\n): Stage.Stage<Message.Message, FactUnit> =>\n Stage.map(\n 'extract-facts-unit',\n (message) =>\n Effect.tryPromise(() => extract(message)).pipe(\n Effect.tapError((error) => Effect.logWarning('extract-facts-unit failed; degrading to no facts', error)),\n Effect.catch(() => Effect.succeed<RDF.Fact[]>([])),\n Effect.map((facts): FactUnit => ({ facts, foreignId: messageSource(message), key: keyOf(message) })),\n ),\n { concurrency },\n );\n\n// NOTE(workaround): `message.created` is the incremental cursor key because ECHO's native feed\n// cursor is unimplemented (`Feed.cursor` is stubbed). Replace with the native queue sequence when\n// available.\nconst keyOf = (message: Message.Message): number => Date.parse(message.created);\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\n\nimport { Stage } from '@dxos/pipeline';\nimport { Message } from '@dxos/types';\n\nimport { EmailPipelineCtx } from './context.ts';\n\n/** Pure-JS running tallies (senders, recipients, spam); pass message through. */\nexport const statsStage: Stage.Stage<Message.Message, Message.Message, never, EmailPipelineCtx> = Stage.map(\n 'stats',\n (message) =>\n Effect.gen(function* () {\n const { stats } = yield* EmailPipelineCtx;\n stats.total += 1;\n const sender = message.sender.email;\n if (sender) {\n stats.from.set(sender, (stats.from.get(sender) ?? 0) + 1);\n }\n const recipients = message.properties?.to;\n if (Array.isArray(recipients)) {\n for (const recipient of recipients) {\n const address = String(recipient);\n stats.to.set(address, (stats.to.get(address) ?? 0) + 1);\n }\n }\n if (message.properties?.spam) {\n stats.spam += 1;\n }\n return message;\n }),\n);\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as LanguageModel from 'effect/ai/LanguageModel';\nimport * as Effect from 'effect/Effect';\nimport * as Layer from 'effect/Layer';\n\nimport { AiService } from '@dxos/ai';\nimport { Stage } from '@dxos/pipeline';\nimport { ContentBlock, Message } from '@dxos/types';\nimport { trim } from '@dxos/util';\n\nimport { EmailPipelineCtx, type Summary } from './context.ts';\n\nconst SUMMARIZE_MODEL = 'com.anthropic.model.claude-haiku-4-5.default';\n\nconst SUMMARIZE_PROMPT = trim`\n Summarize the following email in one sentence, decide whether it is spam, and list up to five keywords.\n Respond with ONLY a JSON object of the form {\"summary\": string, \"isSpam\": boolean, \"keywords\": string[]}.\n`;\n\n// Local models often wrap JSON in prose; extract the first object and coerce leniently.\nconst parseSummary = (raw: string): Summary => {\n const match = raw.match(/\\{[\\s\\S]*\\}/);\n if (!match) {\n return { summary: '', isSpam: false, keywords: [] };\n }\n try {\n const parsed = JSON.parse(match[0]);\n const keywords = parsed.keywords;\n return {\n summary: typeof parsed.summary === 'string' ? parsed.summary : '',\n isSpam: parsed.isSpam === true || parsed.spam === true || parsed.is_spam === true,\n keywords: Array.isArray(keywords) ? keywords.map((keyword: unknown) => String(keyword)) : [],\n };\n } catch {\n return { summary: '', isSpam: false, keywords: [] };\n }\n};\n\n/** Summarize via AiService/LanguageModel; append a summary block and record spam/keywords + result. */\nexport const summarizeStage: Stage.Stage<\n Message.Message,\n Message.Message,\n never,\n EmailPipelineCtx | AiService.AiService\n> = Stage.map('summarize', (message) =>\n Effect.gen(function* () {\n const ctx = yield* EmailPipelineCtx;\n const text = Message.extractText(message);\n // Resolve the LanguageModel layer locally from AiService so `LanguageModel` is discharged and the\n // stage's requirement correctly reduces to `EmailPipelineCtx | AiService.AiService`.\n // Bound the LLM call: `orElse` only recovers failures, so without a timeout a hung/slow provider\n // would block the stage indefinitely. On timeout the effect fails, then `orElse` degrades to ''.\n const raw = yield* LanguageModel.generateText({ prompt: `${SUMMARIZE_PROMPT}\\n\\n${text}` }).pipe(\n Effect.provide(AiService.languageModel(SUMMARIZE_MODEL).pipe(Layer.orDie)),\n Effect.timeout('30 seconds'),\n Effect.map((response) => response.text),\n Effect.catch(() => Effect.succeed('')),\n );\n const summary = parseSummary(raw);\n const messageId = String(message.properties?.messageId ?? message.id);\n ctx.summaries.push({ messageId, summary });\n const summaryBlock: ContentBlock.Text = { _tag: 'text', text: summary.summary };\n // Preserve the original id: `Message.make` mints a fresh one when omitted, which would break the\n // `message.id` fallback used for `messageId` downstream.\n return Message.make({\n id: message.id,\n created: message.created,\n sender: message.sender,\n blocks: [...message.blocks, summaryBlock],\n properties: { ...message.properties, spam: summary.isSpam, keywords: summary.keywords },\n });\n }),\n);\n","//\n// Copyright 2026 DXOS.org\n//\n\n// Declarative model routing for the email pipeline stages (productized from the stories-brain research\n// harness): one place that maps each LLM-bearing stage to the model it runs on, instead of hard-coding\n// a model DXN at every call site. Product tiers only (Anthropic); the research ladder's open-weight\n// (ollama) variants stay in the harness. Each layer overrides the previous: default ← per-run policy.\n\n/** Anthropic model DXNs available to product pipelines. */\nexport const MODEL_HAIKU = 'com.anthropic.model.claude-haiku-4-5.default';\nexport const MODEL_SONNET = 'com.anthropic.model.claude-sonnet-5.default';\nexport const MODEL_OPUS = 'com.anthropic.model.claude-opus-5.default';\n\n/** The LLM-bearing email pipeline stages. Deterministic stages (threading, dedup, stats) never appear. */\nexport type StageId =\n | 'classify-sender'\n | 'tag'\n | 'summarize-message'\n | 'summarize-thread'\n | 'summarize-topic'\n | 'extract-facts'\n | 'draft';\n\n/** A partial policy: stage → a model DXN. */\nexport type ModelPolicy = Partial<Record<StageId, string>>;\n\n/**\n * Default stage → model routing, seeded from the model-ladder findings (REPORT §4/§5): labeling and\n * summaries want a premier model, drafting tolerates a cheaper one. Product defaults to haiku across\n * the board (the cheapest premier tier); callers override per run via a `ModelPolicy`.\n */\nexport const DEFAULT_MODEL_POLICY: Record<StageId, string> = {\n 'classify-sender': MODEL_HAIKU,\n 'tag': MODEL_HAIKU,\n 'summarize-message': MODEL_HAIKU,\n 'summarize-thread': MODEL_HAIKU,\n 'summarize-topic': MODEL_HAIKU,\n 'extract-facts': MODEL_HAIKU,\n 'draft': MODEL_HAIKU,\n};\n\n/** Resolves the model DXN for a stage: per-run `policy` wins over `DEFAULT_MODEL_POLICY`. */\nexport const resolveModel = (stage: StageId, policy?: ModelPolicy): string =>\n policy?.[stage] ?? DEFAULT_MODEL_POLICY[stage];\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as LanguageModel from 'effect/ai/LanguageModel';\nimport * as Effect from 'effect/Effect';\nimport * as Layer from 'effect/Layer';\n\nimport { AiService } from '@dxos/ai';\nimport { Message } from '@dxos/types';\nimport { trim } from '@dxos/util';\n\nimport { type ModelPolicy, resolveModel } from '../model-policy.ts';\n\n// Per-message topic tagging (productized from the stories-brain research tagger): free-form lowercase\n// topic tags plus a spam flag, generated by the AI service. Deterministic stages don't tag; this is\n// the one LLM-bearing per-message classification the topics pipeline applies via `Mailbox.applyTag`.\n\nexport type TagResult = {\n readonly tags: string[];\n readonly spam: boolean;\n /** No-action automated mail (receipts, logins, confirmations) — distinct from spam and from action-required mail. */\n readonly bulk: boolean;\n};\n\nconst TAG_PROMPT = trim`\n Classify the following email. Assign between 1 and 5 short, lowercase, single-word topic tags\n (e.g. \"invoice\", \"newsletter\", \"security\", \"personal\"). Decide whether the email is spam or a\n cold marketing blast; if so include the tag \"spam\".\n Decide whether the email is \"bulk\" — automated, no-action mail such as receipts, login or\n security notices, order/shipping confirmations, or newsletters. If so include the tag \"bulk\".\n Do NOT tag invoices, payment requests, or anything that needs a reply as \"bulk\".\n Respond with ONLY a JSON object of the form {\"tags\": string[], \"spam\": boolean}.\n`;\n\n// No-action transactional/automated mail — receipts, login/security notices, order & shipping\n// confirmations, newsletters. These get a `bulk` tag (nothing for the user to do), distinct from\n// spam (unsolicited) and from invoices / payment requests (which DO require action).\nconst BULK_SUBJECT_RE =\n /\\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;\n\n// Action-required financial mail — must NOT be classified bulk even when it looks automated.\nconst ACTION_SUBJECT_RE =\n /\\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;\n\n// Role / no-reply local parts that send automated no-action mail (used only when the subject gives no signal).\nconst BULK_LOCALPART_RE =\n /^(no-?reply|do-?not-?reply|receipts?|notifications?|notify|alerts?|updates?|news(letter)?|digest|mailer|postmaster)([._+-]|$)/i;\n\n// A body-level unsubscribe affordance — the strongest deterministic bulk give-away (a person doesn't\n// put an unsubscribe link on a real message). Matched when the `List-Unsubscribe` header is absent.\nconst UNSUBSCRIBE_BODY_RE = /\\bunsubscribe\\b/i;\n\n/**\n * Deterministic bulk / no-action signal. Returns `'bulk'` for mailing-list / automated mail —\n * **any unsubscribe affordance** (a `List-Unsubscribe` header or an unsubscribe link in the body) is\n * the top-priority give-away (RFC 2369), followed by receipts / logins / order confirmations /\n * newsletters and no-reply role senders. Returns `'action'` when the mail requires a response\n * (invoice / payment request) and has no unsubscribe affordance; `'unknown'` otherwise. Pure.\n */\nexport const classifyBulk = (input: {\n readonly subject?: string;\n readonly senderEmail?: string;\n /** `List-Unsubscribe` header value, when present (real synced mail). */\n readonly listUnsubscribe?: string;\n /** Message body text, scanned for an unsubscribe link when the header is absent (e.g. fixtures). */\n readonly bodyText?: string;\n}): 'bulk' | 'action' | 'unknown' => {\n // An unsubscribe affordance deterministically marks bulk — it outranks even action-looking subjects,\n // since transactional invoices/payment requests don't carry one.\n if ((input.listUnsubscribe ?? '').length > 0 || UNSUBSCRIBE_BODY_RE.test(input.bodyText ?? '')) {\n return 'bulk';\n }\n const subject = input.subject ?? '';\n if (ACTION_SUBJECT_RE.test(subject)) {\n return 'action';\n }\n if (BULK_SUBJECT_RE.test(subject)) {\n return 'bulk';\n }\n const localPart = (input.senderEmail ?? '').split('@')[0] ?? '';\n return BULK_LOCALPART_RE.test(localPart) ? 'bulk' : 'unknown';\n};\n\n/** Folds the deterministic bulk classification into a tag set: adds `bulk` for no-action mail, strips it for action-required mail. Pure. */\nexport const applyBulkTag = (tags: readonly string[], classification: 'bulk' | 'action' | 'unknown'): string[] => {\n if (classification === 'action') {\n return tags.filter((tag) => tag !== 'bulk');\n }\n if (classification === 'bulk' && !tags.includes('bulk')) {\n return [...tags, 'bulk'];\n }\n return [...tags];\n};\n\n/** Parses the tag response leniently (models wrap JSON in prose); infers/dedups the spam tag. Pure. */\nexport const parseTagResult = (raw: string): TagResult => {\n const match = raw.match(/\\{[\\s\\S]*\\}/);\n if (!match) {\n return { tags: [], spam: false, bulk: false };\n }\n try {\n const parsed = JSON.parse(match[0]) as { tags?: unknown; spam?: unknown };\n const tags = Array.isArray(parsed.tags)\n ? parsed.tags.map((tag) => String(tag).toLowerCase()).filter((tag) => tag.length > 0)\n : [];\n const spam = parsed.spam === true || tags.includes('spam');\n return {\n tags: spam && !tags.includes('spam') ? [...tags, 'spam'] : tags,\n spam,\n bulk: tags.includes('bulk'),\n };\n } catch {\n return { tags: [], spam: false, bulk: false };\n }\n};\n\n/**\n * Tags a single message (topic tags + spam) via the AI service. The model comes from the `tag` stage\n * of the model policy (overridable per run). Degrades to no tags on timeout/error so one bad message\n * never fails the pipeline.\n */\nexport const tagMessage = (\n message: Message.Message,\n options: { readonly policy?: ModelPolicy } = {},\n): Effect.Effect<TagResult, never, AiService.AiService> =>\n Effect.gen(function* () {\n const subject = String(message.properties?.subject ?? '');\n const body = Message.extractText(message);\n const raw = yield* LanguageModel.generateText({\n prompt: `${TAG_PROMPT}\\n\\nSubject: ${subject}\\n\\n${body}`,\n }).pipe(\n Effect.provide(AiService.languageModel(resolveModel('tag', options.policy)).pipe(Layer.orDie)),\n Effect.timeout('30 seconds'),\n Effect.map((response) => response.text),\n Effect.catch(() => Effect.succeed('')),\n );\n const parsed = parseTagResult(raw);\n // Deterministic bulk gate overrides the model: an unsubscribe affordance (header or body link)\n // forces `bulk`, adds it for no-action mail the model missed, and strips it from invoices.\n const listUnsubscribe = message.properties?.listUnsubscribe;\n const classification = classifyBulk({\n subject,\n senderEmail: message.sender?.email,\n listUnsubscribe: typeof listUnsubscribe === 'string' ? listUnsubscribe : undefined,\n bodyText: body,\n });\n const tags = applyBulkTag(parsed.tags, classification);\n return { tags, spam: parsed.spam, bulk: tags.includes('bulk') };\n });\n","//\n// Copyright 2026 DXOS.org\n//\n\n// @import-as-namespace\n\nimport * as Effect from 'effect/Effect';\nimport * as Stream from 'effect/Stream';\n\nimport { Blob, Database, Feed, Filter, Obj, Ref } from '@dxos/echo';\nimport { type IdentityIndex, overlayIdentityIndex } from '@dxos/extractor';\nimport { buildContactFromActor, getIdentityIndex, identitySpecs, senderSignals } from '@dxos/extractor-lib';\nimport { Cursor } from '@dxos/link';\nimport { log } from '@dxos/log';\nimport { normalizeText } from '@dxos/markdown';\nimport { Stage } from '@dxos/pipeline';\nimport { Tagging, TagIndex } from '@dxos/schema';\nimport { DraftMessage, Message, Person } from '@dxos/types';\n\n// TODO(burdon): Factor out.\n\n/**\n * Reusable, generic email-processing pipeline stages: {@link Change} → {@link Change}, composing in any\n * order, each simply recording what it found (`message.attachments`, `contact`, …) rather than writing\n * directly. {@link toCommitUnit} is the terminal stage that turns those into a generic\n * `Cursor.CommitUnit`, and must run last. Provider-specific decode/map stages construct the initial\n * `Change` (an `Insert`, tagged from the start) and stay in each sync op.\n */\n\n/** An item carrying a body string that may contain HTML. */\nexport type Bodied = { readonly body: string };\n\n/**\n * Normalizes an item's `body` from (possibly) HTML into markdown via `normalizeText` (turndown when\n * HTML, passthrough for plaintext). Requires nothing from the Requirements channel. Written as a\n * generic pipeable so the item type infers from the upstream stream (no type argument needed).\n */\nexport const htmlToMarkdown = <In extends Bodied, E, R>(self: Stream.Stream<In, E, R>): Stream.Stream<In, E, R> =>\n Stage.map('html-to-markdown', (item: In) => Effect.sync(() => ({ ...item, body: normalizeText(item.body) })))(self);\n\n/**\n * A new message to append, tagged `insert` from the moment a provider decodes it. The upstream dedup\n * stage (`Cursor.dedupStage`) guarantees a message reaching this variant was never previously committed,\n * so it's a one-time insert, never a resync/overwrite. The generic email stages act only on this variant\n * of {@link Change}.\n */\nexport type Insert = {\n readonly _tag: 'insert';\n readonly message: Message.Message;\n readonly foreignId: string;\n readonly key: number;\n /** Provider label/folder tag URIs to apply on commit; omit for providers/stages that don't tag. */\n readonly tagUris?: readonly string[];\n /**\n * The subset of {@link tagUris} mirroring the provider's own labels, as opposed to tags local logic\n * added at insert time (the known-sender `important` rule, an on-arrival extractor).\n *\n * Bidirectional tag sync seeds a newly-inserted message's reconciliation base from this, so provider\n * labels land in the base (and never push back at the provider they came from) while locally-added\n * tags stay outside it and push. Both kinds land before the run captures its heads, so nothing else\n * can tell them apart. Absent means all of `tagUris` is provider-derived.\n */\n readonly remoteTagUris?: readonly string[];\n /** Attachments fetched by a provider-specific stage upstream of {@link processAttachments}. */\n readonly attachments?: readonly Attachment[];\n /** Contact resolved by {@link extractContacts}, added to the database by {@link toCommitUnit}. */\n readonly contact?: Person.Person;\n /** Local drafts this synced message supersedes ({@link reconcileDrafts}), removed by {@link toCommitUnit}. */\n readonly supersededDrafts?: readonly Message.Message[];\n};\n\n/**\n * A label change on an already-committed message, resolved to its feed message's `entityId`.\n * {@link toCommitUnit} turns it into an objectless commit unit that applies the tag adds/removes — no\n * feed append, so the body stages ({@link processAttachments}, {@link extractContacts},\n * {@link reconcileDrafts}) pass it through untouched.\n */\nexport type Retag = {\n readonly _tag: 'retag';\n readonly foreignId: string;\n readonly entityId: Obj.ID;\n readonly addTagIds: readonly string[];\n readonly removeTagIds: readonly string[];\n};\n\n/**\n * A remote deletion of an already-committed message. Wired but STUBBED — {@link toCommitUnit} emits an\n * objectless unit with no effect (see the TODO there) pending safe in-sync feed object deletion.\n */\nexport type Delete = {\n readonly _tag: 'delete';\n readonly foreignId: string;\n readonly entityId: Obj.ID;\n};\n\n/**\n * The discriminated item flowing through the shared email tail. `insert` is a new message; `retag`/\n * `delete` act on existing feed messages by `entityId`. All converge on the single {@link toCommitUnit}\n * → `Cursor.commit`.\n */\nexport type Change = Insert | Retag | Delete;\n\n/** A decoded email attachment, ready for {@link processAttachments} to turn into a Blob. */\nexport type Attachment = {\n readonly name?: string;\n readonly mimeType?: string;\n readonly size: number;\n readonly bytes: Uint8Array;\n /** The attachment's `Content-ID`, if any — matches a `cid:` reference in the message's HTML body. */\n readonly contentId?: string;\n};\n\n/**\n * Builds a Person (+ Organization link by domain) from the message sender and records it on the\n * item as {@link Insert.contact} — the stage writes nothing itself; {@link toCommitUnit} defers\n * the actual `db.add` to commit.\n *\n * Dedup is the space's shared identity index (see `getIdentityIndex`), refreshed once at the start\n * of the run: it covers contacts committed before the run, contacts built earlier in it, and — since\n * the index is shared per database — contacts a concurrently-syncing mailbox is building right now.\n *\n * Not every sender earns a record: {@link senderSignals} feeds the allow-list in\n * `shouldExtractContact`, so bulk and automated mail no longer fills the space with contacts nobody\n * will look at. NOTE: no provider records an outbound signal yet, so in practice the allow-list is\n * \"the domain matches a known Organization\" — stricter than the policy intends. Extracting the\n * recipients of sent mail is what closes that gap.\n */\nexport const extractContacts = (): Stage.Stage<Change, Change, never, Database.Service> => {\n // Run-scoped overlay over the space's shared index: reads see everything committed (including by a\n // concurrent sync), writes stay local, so a run that dies mid-way leaves nothing claiming a contact\n // it never committed.\n let index: IdentityIndex | undefined;\n return Stage.map('extract-contacts', (change: Change) =>\n Effect.gen(function* () {\n // Only new messages carry a body/sender; retag/delete pass through untouched.\n if (change._tag !== 'insert') {\n return change;\n }\n const { db } = yield* Database.Service;\n if (!index) {\n // Re-seed once per run so the shared index picks up whatever was committed since it was built.\n index = overlayIdentityIndex(identitySpecs, yield* getIdentityIndex(db, { refresh: true }));\n }\n const sender = change.message.sender;\n const contact = sender\n ? yield* buildContactFromActor(sender, db, { signals: senderSignals(change.message), index })\n : undefined;\n return contact ? { ...change, contact } : change;\n }),\n );\n};\n\n/**\n * Turns each of the item's `attachments` into a Blob object (via the database's configured storage\n * backend — edge in Composer) and adds a {@link Message.Attachment} pointing at it to the message.\n * {@link toCommitUnit} finds these blobs again via each attachment's ref (inlined, since the\n * blob isn't attached to a database yet) to defer their feed append to commit. A no-op when the item\n * has no attachments.\n *\n * One bad or oversized attachment must not fail the whole message: each `Blob.fromBytes` is caught\n * and logged individually, so a rejected attachment is simply dropped from the message.\n */\nexport const processAttachments = (): Stage.Stage<Change, Change, never, Database.Service> =>\n Stage.map('process-attachments', (change: Change) =>\n Effect.gen(function* () {\n // Only new messages carry attachments; retag/delete pass through untouched.\n if (change._tag !== 'insert') {\n return change;\n }\n const mapped = change;\n if (!mapped.attachments?.length) {\n return mapped;\n }\n\n const attachments: Message.Attachment[] = [];\n for (const attachment of mapped.attachments) {\n const blob: Blob.Blob | undefined = yield* Blob.fromBytes(attachment.bytes, {\n type: attachment.mimeType,\n }).pipe(\n Effect.catch((error) => {\n log.catch(error, { foreignId: mapped.foreignId, name: attachment.name, size: attachment.size });\n return Effect.succeed(undefined);\n }),\n );\n if (!blob) {\n continue;\n }\n attachments.push({ name: attachment.name, ref: Ref.make(blob), contentId: attachment.contentId });\n }\n if (attachments.length === 0) {\n return mapped;\n }\n\n // The message is not yet appended to the feed, so mutating it here is safe — mirrors how the\n // mappers assemble `blocks` before the message is ever persisted.\n Obj.update(mapped.message, (message) => {\n message.attachments = [...(message.attachments ?? []), ...attachments];\n });\n\n return mapped;\n }),\n );\n\n/**\n * Queries the parent container's already-sent drafts once per sync run and pools them by the provider\n * message id captured at send time (`properties.sentMessageId`) — the key {@link reconcileDrafts}\n * matches each incoming message's `foreignId` against. Doing this once (rather than per item) keeps\n * reconciliation off the database as messages stream through. Unsent drafts (no `sentMessageId`) are\n * never pooled and thus never eligible for removal.\n *\n * `parent` is the local root object drafts are scoped to (e.g. a Mailbox) — only its URI is read, so\n * any object works.\n */\nexport const queryDraftPool = Effect.fn('queryDraftPool')(function* (parent: Obj.Unknown) {\n const parentUri = Obj.getURI(parent);\n const drafts = (yield* Database.query(Filter.type(Message.Message, { properties: { mailbox: parentUri } }))\n .run).filter((candidate) => DraftMessage.belongsTo(candidate, parentUri) && candidate.properties?.sentMessageId);\n\n const pool = new Map<string, Message.Message[]>();\n for (const draft of drafts) {\n const sentMessageId = draft.properties?.sentMessageId;\n if (!sentMessageId) {\n continue;\n }\n const existing = pool.get(sentMessageId);\n if (existing) {\n existing.push(draft);\n } else {\n pool.set(sentMessageId, [draft]);\n }\n }\n return pool;\n});\n\n/**\n * Records the local drafts each incoming message supersedes — matched by the provider message id the\n * draft captured at send time (`properties.sentMessageId`), which equals the synced message's\n * `foreignId` — from a pool built once per run and keyed by that id. Writes nothing itself;\n * {@link toCommitUnit} defers the `db.remove` to commit, so the canonical message is appended\n * to the feed before its now-redundant drafts are deleted. A stage factory: pass the run's draft\n * pool once.\n *\n * The pool is queried a single time by the caller (per sync operation), so this stage never touches\n * the database or feed per item.\n */\nexport const reconcileDrafts = (\n draftPool: ReadonlyMap<string, readonly Message.Message[]>,\n): Stage.Stage<Change, Change, never, never> =>\n Stage.map('reconcile-drafts', (change: Change) =>\n Effect.sync(() => {\n // Only new messages can supersede a draft; retag/delete pass through untouched.\n if (change._tag !== 'insert') {\n return change;\n }\n const drafts = draftPool.get(change.foreignId);\n return drafts?.length ? { ...change, supersededDrafts: drafts } : change;\n }),\n );\n\n/**\n * Terminal stage converting a run's {@link Change} into the generic `Cursor.CommitUnit` the commit sink\n * consumes. For an `insert`: `message.attachments` (populated by {@link processAttachments}) becomes a\n * feed-append commit effect for the referenced blobs, `mapped.contact` (from {@link extractContacts})\n * becomes a `db.add` commit effect, and `mapped.supersededDrafts` (from {@link reconcileDrafts})\n * becomes a `db.remove` commit effect. Every other email stage is Change → Change and composes in\n * any order; this is the one stage that must run last.\n *\n * Tagging is email-specific (`Cursor.CommitUnit` carries no tag data), so it's applied here rather\n * than by the generic commit sink: `tagIndex` is optional (omit for providers/tests that don't tag),\n * and every tagged unit attaches the *same* `applyTags` closure (keyed by `mapped.tagUris` recorded in\n * a run-scoped map, read back by object identity) so the commit's identity-based batching applies\n * every tag pair for the page in one `Tagging.setBatch` call rather than one per message.\n */\nexport const toCommitUnit = (\n options: { readonly tagIndex?: TagIndex.TagIndex } = {},\n): Stage.Stage<Change, Cursor.CommitUnit, never, Cursor.Service> => {\n const { tagIndex } = options;\n const tagUrisByObject = new WeakMap<Obj.Any, readonly string[]>();\n const applyTags: Cursor.CommitEffect = Effect.fn('email.commit.tags')(function* (\n units: readonly Cursor.CommitUnit[],\n ) {\n if (!tagIndex) {\n return;\n }\n const tagEntries: { object: Obj.Any; tagId: string }[] = [];\n for (const unit of units) {\n if (!unit.object) {\n continue;\n }\n for (const uri of tagUrisByObject.get(unit.object) ?? []) {\n tagEntries.push({ object: unit.object, tagId: uri });\n }\n }\n if (tagEntries.length > 0) {\n Tagging.setBatch(tagEntries, { index: tagIndex });\n }\n });\n\n // Retag adds/removes recorded per foreign id, applied in one shared effect (identity-batched by the\n // commit) so a page of label changes resolves to a single pass over the tag index.\n const retagByForeignId = new Map<string, Pick<Retag, 'entityId' | 'addTagIds' | 'removeTagIds'>>();\n const applyRetags: Cursor.CommitEffect = Effect.fn('email.commit.retags')(function* (\n units: readonly Cursor.CommitUnit[],\n ) {\n if (!tagIndex) {\n return;\n }\n const accessor = TagIndex.bind(tagIndex);\n for (const unit of units) {\n const retag = retagByForeignId.get(unit.foreignId);\n if (!retag) {\n continue;\n }\n for (const tagId of retag.addTagIds) {\n accessor.setTag(tagId, retag.entityId);\n }\n for (const tagId of retag.removeTagIds) {\n accessor.unsetTag(tagId, retag.entityId);\n }\n }\n });\n\n return Stage.map('to-commit-unit', (change: Change) =>\n Effect.gen(function* () {\n // Label change on an existing message: an objectless unit (no feed append, key 0) whose effect\n // retags by entityId.\n if (change._tag === 'retag') {\n retagByForeignId.set(change.foreignId, {\n entityId: change.entityId,\n addTagIds: change.addTagIds,\n removeTagIds: change.removeTagIds,\n });\n return {\n foreignId: change.foreignId,\n key: 0,\n commitEffects: tagIndex ? [applyRetags] : undefined,\n } satisfies Cursor.CommitUnit;\n }\n\n // Remote deletion: wired but STUBBED — the branch resolves an objectless unit that does nothing.\n // TODO(wittjosiah): Remove the message from the feed once in-sync feed object deletion is safe\n // (cursor/dedup-set interaction across the non-transactional queue↔space-db seam).\n if (change._tag === 'delete') {\n return { foreignId: change.foreignId, key: 0 } satisfies Cursor.CommitUnit;\n }\n\n const mapped = change;\n const { feed } = yield* Cursor.Service;\n const commitEffects: Cursor.CommitEffect[] = [];\n\n // Each attachment's ref is inlined (its blob isn't attached to a database yet), so `.target`\n // resolves it synchronously without an Insert.attachmentBlobs field to carry it separately.\n const blobs = (mapped.message.attachments ?? [])\n .map((attachment) => attachment.ref.target)\n .filter((target): target is Blob.Blob => Obj.instanceOf(Blob.Blob, target));\n if (feed && blobs.length > 0) {\n commitEffects.push(() => Feed.append(feed, blobs));\n }\n\n if (mapped.contact) {\n const contact = mapped.contact;\n commitEffects.push(\n Effect.fn('email.commit.addContact')(function* () {\n yield* Database.add(contact);\n }),\n );\n }\n\n if (mapped.supersededDrafts?.length) {\n const drafts = mapped.supersededDrafts;\n commitEffects.push(\n Effect.fn('email.commit.removeDrafts')(function* () {\n const { db } = yield* Database.Service;\n for (const draft of drafts) {\n db.remove(draft);\n }\n }),\n );\n }\n\n if (tagIndex && mapped.tagUris?.length) {\n tagUrisByObject.set(mapped.message, mapped.tagUris);\n commitEffects.push(applyTags);\n }\n\n return {\n object: mapped.message,\n foreignId: mapped.foreignId,\n key: mapped.key,\n commitEffects: commitEffects.length > 0 ? commitEffects : undefined,\n };\n }),\n );\n};\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\nimport * as Stream from 'effect/Stream';\n\nimport { Database, Feed, Filter } from '@dxos/echo';\nimport { Cursor } from '@dxos/link';\nimport { log } from '@dxos/log';\nimport { Pipeline, Stage } from '@dxos/pipeline';\nimport { FactStore } from '@dxos/pipeline-rdf';\nimport { Message } from '@dxos/types';\n\nimport {\n EmailPipelineCtx,\n type FactExtractor,\n type FactIndexer,\n type FactUnit,\n type Stats,\n emptyStats,\n extractFactsStage,\n extractFactsUnitStage,\n messageSource,\n statsStage,\n} from './stages/index.ts';\n\nexport type EmailFactPipelineOptions = {\n /** ECHO space database, required by `EmailPipelineCtx` even though this assembly never writes to it. */\n readonly db: Database.Database;\n /** Persists one message's facts to the fact substrate (a closure over a `FactStore`). */\n readonly indexFacts: FactIndexer;\n};\n\nexport type EmailFactPipelineResult = {\n readonly messages: Message.Message[];\n readonly stats: Stats;\n};\n\nexport const EmailFactPipeline = {\n /**\n * Facts-only assembly of the email stages over a batch of messages: stats → extract-facts. Omits\n * summarize, extract-contacts, and {@link buildThreads} from {@link EmailPipeline}, so it needs no\n * `AiService` and never writes to `db`. The cursored `AnalyzeMailbox` operation (in plugin-inbox)\n * covers the same fact-extraction concern incrementally, via a sibling facts-returning stage\n * (`extractFactsUnitStage`) that commits pages atomically with a cursor advance instead of indexing\n * in-stage; this batch variant is for one-shot runs over an already-collected set of messages.\n */\n run: (\n messages: readonly Message.Message[],\n options: EmailFactPipelineOptions,\n ): Effect.Effect<EmailFactPipelineResult> =>\n Effect.gen(function* () {\n const stats = emptyStats();\n const collected: Message.Message[] = [];\n yield* Stream.fromIterable(messages).pipe(\n statsStage,\n extractFactsStage(options.indexFacts),\n Pipeline.run({ sink: (message) => Effect.sync(() => collected.push(message)) }),\n Effect.provideService(EmailPipelineCtx, { db: options.db, stats, summaries: [] }),\n );\n\n return { messages: collected, stats };\n }),\n};\n\n/**\n * Runs the cursored fact pipeline over a feed: dedup → extract facts → commit each page to the\n * {@link FactStore} while advancing `cursor`. Extraction is injected (`extract`) so the pipeline is\n * unit-testable with a deterministic stub — no `AiService` required. Feed-generic (the mailbox\n * `AnalyzeMailbox` operation is a thin wrapper that resolves a Mailbox to its feed and its persisted\n * `Cursor`).\n *\n * Dedup is by `cursor`'s high-water key (a coarse `key < cursorKey` skip) plus the set of message\n * sources already in the store (the precise idempotency backstop) — the store doesn't share `cursor`'s\n * persistence lifetime (it's still in-memory per space), so the source-dedup backstop still matters\n * even though the cursor itself is now durable.\n */\nexport const runFactPipeline = (options: {\n readonly feed: Feed.Feed;\n readonly cursor: Cursor.Cursor;\n readonly pageSize: number;\n /** In-flight parallelism for the per-message extraction stage (defaults to 1 = serial). */\n readonly concurrency?: number;\n readonly extract: FactExtractor;\n /**\n * Called once at run start (`processed: 0`) and after each committed page — the live-progress seam\n * (e.g. a UI meter). `total` is the exact pending count: both dedup predicates (cursor prefilter,\n * indexed-source set) are evaluated up-front over the already-materialized message list.\n */\n readonly onProgress?: (progress: { processed: number; facts: number; total: number }) => void;\n}): Effect.Effect<{ processed: number; facts: number }, never, FactStore | Database.Service> =>\n Effect.gen(function* () {\n const { feed, cursor, extract, pageSize, concurrency = 1, onProgress } = options;\n const store = yield* FactStore;\n\n // Sources (== `messageSource`) already indexed — the precise skip; the cursor is only a coarse\n // prefilter (the newest message keys equal to the advanced cursor, so `< cursorKey` alone would\n // re-extract it).\n const priorFacts = yield* store.query({}).pipe(Effect.orElseSucceed(() => []));\n const indexedSources = new Set(priorFacts.map((fact) => fact.attribution.source));\n\n // NOTE(workaround): the cursor key is `message.created` (epoch-ms) because ECHO's native feed\n // cursor is unimplemented (`Feed.cursor` is stubbed). Replace with the native queue sequence when\n // available.\n let cursorKey = Cursor.parseKey(cursor.max);\n\n let processed = 0;\n let facts = 0;\n // Ascending key order is load-bearing: the sink advances `cursorKey` per committed page and the\n // dedup stage reads it live, so on an unordered feed (e.g. an archive imported newest-first) the\n // first-committed newest message would advance the cursor past every older one. A malformed\n // `created` (NaN key) is dropped up front — it cannot be ordered against the cursor, and its NaN\n // would poison the page's `Math.max` cursor advance.\n const messages = (yield* Feed.query(feed, Filter.type(Message.Message)).run)\n .filter((message) => Number.isFinite(Date.parse(message.created)))\n .sort((left, right) => Date.parse(left.created) - Date.parse(right.created));\n // Exact pending count for a determinate meter: ascending order means the live mid-run cursor\n // advance can never drop a message this initial predicate kept (equal keys pass `< cursorKey`).\n const total = messages.filter(\n (message) => !(Date.parse(message.created) < cursorKey) && !indexedSources.has(messageSource(message)),\n ).length;\n log.info('analyze: pipeline start', {\n messages: messages.length,\n total,\n cursorKey,\n indexed: indexedSources.size,\n pageSize,\n });\n onProgress?.({ processed: 0, facts: 0, total });\n yield* Stream.fromIterable(messages).pipe(\n Stage.map('facts-dedup', (message: Message.Message) =>\n Effect.sync(() =>\n Date.parse(message.created) < cursorKey || indexedSources.has(messageSource(message)) ? undefined : message,\n ),\n ),\n extractFactsUnitStage(extract, concurrency),\n // Observability stage: confirms units flow through and how many facts each carries.\n Stage.map('facts-log', (unit: FactUnit) =>\n Effect.sync(() => {\n log.info('analyze: extracted unit', { foreignId: unit.foreignId, key: unit.key, facts: unit.facts.length });\n return unit;\n }),\n ),\n Stream.grouped(pageSize),\n Pipeline.run({\n sink: (page) =>\n Effect.gen(function* () {\n const units = page;\n if (units.length === 0) {\n return;\n }\n\n const pageFacts = units.flatMap((unit) => unit.facts);\n if (pageFacts.length > 0) {\n // A fact-store write failure is fatal to the run (not a recoverable per-page error).\n yield* store.putFacts(pageFacts).pipe(Effect.orDie);\n }\n\n processed += units.length;\n facts += pageFacts.length;\n cursorKey = Math.max(cursorKey, ...units.map((unit) => unit.key));\n Cursor.advance(cursor, Cursor.formatKey(cursorKey));\n\n // Terminal progress log (one line per committed page): the only live signal between\n // pipeline start and done, so a long run's advance is observable rather than silent.\n log.info('analyze: committed page', {\n page: units.length,\n pageFacts: pageFacts.length,\n processed,\n facts,\n cursorKey,\n });\n onProgress?.({ processed, facts, total });\n }),\n }),\n );\n\n log.info('analyze: pipeline done', { processed, facts });\n return { processed, facts };\n });\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Schema from 'effect/Schema';\n\nimport { DXN, Type } from '@dxos/echo';\n\nexport const ThreadState = Schema.Literals(['awaiting-mine', 'awaiting-theirs', 'resolved', 'stalled']);\nexport type ThreadState = Schema.Schema.Type<typeof ThreadState>;\n\n// A canonical (spec §4) conversation aggregate: messages sharing a derived threadId, with a rolling\n// summary, a coarse state, and consolidated open questions / action items. Authoritative in ECHO;\n// facts are advisory evidence, not the source of this object's state.\nexport class Thread extends Type.makeObject<Thread>(DXN.make('org.dxos.type.emailThread', '0.1.0'))(\n Schema.Struct({\n threadId: Schema.String,\n subject: Schema.String,\n summary: Schema.String,\n state: ThreadState,\n participants: Schema.Array(Schema.String),\n messageIds: Schema.Array(Schema.String),\n openQuestions: Schema.Array(Schema.String),\n actionItems: Schema.Array(Schema.String),\n }),\n) {}\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport { Obj } from '@dxos/echo';\nimport { Message } from '@dxos/types';\n\nimport { messageSource } from '../stages/facts.ts';\nimport { Thread, type ThreadState } from '../types/index.ts';\nimport { deriveThreadId } from './threading.ts';\n\nconst DEFAULT_STALE_PERIOD_MS = 14 * 24 * 60 * 60 * 1000;\n\nexport type BuildThreadsOptions = {\n /**\n * The mailbox owner's email(s). Accepts multiple so a person's aliases (e.g. work + personal) all\n * count as \"from the owner\" when inferring thread state; a single string is still accepted.\n */\n readonly ownerEmail: string | readonly string[];\n readonly now: string;\n readonly stalePeriodMs?: number;\n};\n\n/** Lowercased set of the owner's addresses (handles the single-string and multi-alias forms). */\nconst ownerAddressSet = (ownerEmail: string | readonly string[]): ReadonlySet<string> =>\n new Set((Array.isArray(ownerEmail) ? ownerEmail : [ownerEmail]).map((email) => email.toLowerCase()).filter(Boolean));\n\n// Coarse thread state from who spoke last and how long ago. `resolved` needs a signal we do not yet\n// extract, so it is not inferred in this slice.\nconst computeState = (lastFromOwner: boolean, lastCreated: string, options: BuildThreadsOptions): ThreadState => {\n const idleMs = new Date(options.now).getTime() - new Date(lastCreated).getTime();\n const stale = idleMs > (options.stalePeriodMs ?? DEFAULT_STALE_PERIOD_MS);\n if (stale) {\n return 'stalled';\n }\n return lastFromOwner ? 'awaiting-theirs' : 'awaiting-mine';\n};\n\n// Group messages into canonical Thread objects. Messages are bucketed by derived threadId; within a\n// bucket they are ordered by `created` to find the last speaker.\nexport const buildThreads = (messages: readonly Message.Message[], options: BuildThreadsOptions): Thread[] => {\n const owners = ownerAddressSet(options.ownerEmail);\n const buckets = new Map<string, Message.Message[]>();\n for (const message of messages) {\n const threadId = deriveThreadId(message);\n let bucket = buckets.get(threadId);\n if (!bucket) {\n bucket = [];\n buckets.set(threadId, bucket);\n }\n bucket.push(message);\n }\n\n const threads: Thread[] = [];\n for (const [threadId, bucket] of buckets) {\n const ordered = [...bucket].sort((a, b) => a.created.localeCompare(b.created));\n const last = ordered[ordered.length - 1];\n // Email addresses are case-insensitive, so compare and dedup on a lowercased form; otherwise\n // mixed-case senders split into distinct participants and can misclassify awaiting-mine/theirs.\n const lastFromOwner = owners.has(last.sender.email?.toLowerCase() ?? '');\n const participants = [...new Set(ordered.flatMap((m) => (m.sender.email ? [m.sender.email.toLowerCase()] : [])))];\n const summaries = ordered.flatMap((m) =>\n typeof m.properties?.summary === 'string' && m.properties.summary.length > 0 ? [m.properties.summary] : [],\n );\n const subject = typeof ordered[0].properties?.subject === 'string' ? ordered[0].properties.subject : threadId;\n\n threads.push(\n Obj.make(Thread, {\n threadId,\n subject,\n summary: summaries.length > 0 ? summaries.join('\\n') : subject,\n state: computeState(lastFromOwner, last.created, options),\n participants,\n messageIds: ordered.map(messageSource),\n openQuestions: [],\n actionItems: [],\n }),\n );\n }\n return threads;\n};\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\nimport * as Stream from 'effect/Stream';\n\nimport { type AiService } from '@dxos/ai';\nimport { type Database } from '@dxos/echo';\nimport { Pipeline } from '@dxos/pipeline';\nimport { Message } from '@dxos/types';\n\nimport { buildThreads } from './internal/threads.ts';\nimport {\n EmailPipelineCtx,\n type FactIndexer,\n type Stats,\n type Summary,\n emptyStats,\n extractContactsStage,\n extractFactsStage,\n statsStage,\n summarizeStage,\n} from './stages/index.ts';\nimport { type Thread } from './types/index.ts';\n\nexport type EmailPipelineOptions = {\n /** ECHO space database: extract-contacts persists Person/Organization, threads are added here. */\n readonly db: Database.Database;\n /** Persists one message's facts to the fact substrate (a closure over a `FactStore`). */\n readonly indexFacts: FactIndexer;\n /** The space owner's email, used to orient thread state (awaiting-mine / awaiting-theirs). */\n readonly ownerEmail: string;\n /** Wall clock used to derive thread staleness; injected so the assembly stays deterministic. */\n readonly now: string;\n};\n\nexport type EmailPipelineResult = {\n readonly messages: Message.Message[];\n readonly stats: Stats;\n readonly summaries: ReadonlyArray<{ messageId: string; summary: Summary }>;\n readonly threads: Thread[];\n};\n\nexport const EmailPipeline = {\n /**\n * Default assembly of the email stages over a batch of messages: summarize → extract-contacts →\n * stats → extract-facts stream through with back pressure, then {@link buildThreads} groups the\n * collected messages into canonical threads. Contacts and threads are persisted to `db`; the fact\n * substrate is populated via `indexFacts`. Requires an `AiService` (summarize); the shared\n * {@link EmailPipelineCtx} is constructed and provided internally.\n */\n run: (\n messages: readonly Message.Message[],\n options: EmailPipelineOptions,\n ): Effect.Effect<EmailPipelineResult, never, AiService.AiService> =>\n Effect.gen(function* () {\n const stats = emptyStats();\n const summaries: Array<{ messageId: string; summary: Summary }> = [];\n const collected: Message.Message[] = [];\n yield* Stream.fromIterable(messages).pipe(\n summarizeStage,\n extractContactsStage,\n statsStage,\n extractFactsStage(options.indexFacts),\n Pipeline.run({ sink: (message) => Effect.sync(() => collected.push(message)) }),\n Effect.provideService(EmailPipelineCtx, { db: options.db, stats, summaries }),\n );\n\n const threads = buildThreads(collected, { ownerEmail: options.ownerEmail, now: options.now });\n for (const thread of threads) {\n options.db.add(thread);\n }\n return { messages: collected, stats, summaries, threads };\n }),\n};\n","//\n// Copyright 2026 DXOS.org\n//\n\nimport { type Message } from '@dxos/types';\n\nimport { type Summarizer, type TopicDraft, type TopicOptions, clusterThreads } from './corpus/index.ts';\nimport { buildThreads } from './internal/threads.ts';\nimport { type TagResult } from './stages/tag.ts';\n\n// The topics pipeline (productized from the research harness): tag each message, then cluster its\n// threads into `Topic` objects with LLM summaries. Pure orchestration — the LLM-bearing steps (`tag`,\n// `summarize`) are INJECTED so this is unit-testable with stubs; the plugin-inbox operation wires them\n// to the AI service and applies the results (tags via `Mailbox.applyTag`, topics + `AnchoredTo`\n// relations into the space). One-shot + resumable-lite: `limit` bounds a run, `skipMessage` /\n// `skipTopic` let a re-run resume past what a previous invocation already tagged / materialized.\n\n/** Per-message tag output — applied to the mailbox tag index by the caller. */\nexport type MessageTags = {\n readonly messageId: string;\n readonly tags: readonly string[];\n readonly spam: boolean;\n};\n\nexport type TopicsPipelineInput = {\n readonly messages: readonly Message.Message[];\n /** Mailbox owner email — steers thread-state inference in `buildThreads`. */\n readonly ownerEmail: string;\n /** Wall-clock ISO timestamp for `buildThreads` idle/state inference (injected — no `Date.now()` here). */\n readonly now: string;\n /** Cap messages tagged this run (resumable-lite); undefined → all. */\n readonly limit?: number;\n /** Skip a message already tagged by a previous run (resume). */\n readonly skipMessage?: (messageId: string) => boolean;\n /** Skip a topic already materialized by a previous run (dedupe on re-run). */\n readonly skipTopic?: (label: string) => boolean;\n /**\n * Keep a clustered topic only when this returns true (applied before summarization). Used to create\n * topics only for senders the user has a relationship with (an existing `Person` record); undefined\n * keeps every topic.\n */\n readonly keepTopic?: (draft: TopicDraft) => boolean;\n readonly topicOptions?: TopicOptions;\n /** Progress hook: phase `'tag'` (per message) or `'topic'` (per summarized topic). */\n readonly onProgress?: (phase: 'tag' | 'topic', current: number, total: number) => void;\n /** Cooperative cancellation — checked between messages; aborting stops after the current item. */\n readonly signal?: AbortSignal;\n};\n\n/** Effectful dependencies, injected so the orchestration stays pure and testable. */\nexport type TopicsPipelineDeps = {\n readonly tag: (message: Message.Message) => Promise<TagResult>;\n readonly summarize: Summarizer;\n};\n\nexport type TopicsPipelineResult = {\n readonly messageTags: readonly MessageTags[];\n /**\n * Summarized topic drafts (plain values, not ECHO objects — excludes any skipped by `skipTopic` /\n * dropped by `keepTopic`). The caller decides what to do with them: materialize `Topic` objects, or\n * write them to `Mailbox.topicSuggestions` for the user to curate.\n */\n readonly topicDrafts: readonly TopicDraft[];\n};\n\nconst messageIdOf = (message: Message.Message): string => String(message.properties?.messageId ?? message.id);\n\n/**\n * Runs the topics pipeline: tag (bounded/resumable) → `buildThreads` → `clusterThreads` →\n * `summarizeTopics`. Returns per-message tags and the summarized topic drafts for the caller to\n * materialize or suggest. Tagging failures degrade per message (the injected `tag` should not\n * reject); clustering is deterministic.\n */\nexport const runTopicsPipeline = async (\n input: TopicsPipelineInput,\n deps: TopicsPipelineDeps,\n): Promise<TopicsPipelineResult> => {\n const { messages, ownerEmail, now, limit, skipMessage, skipTopic, keepTopic, topicOptions, onProgress, signal } =\n input;\n\n // Phase 1 — tag each not-yet-tagged message, bounded by `limit`. Stop early if cancelled.\n const pending = messages.filter((message) => !skipMessage?.(messageIdOf(message)));\n const toTag = limit !== undefined ? pending.slice(0, limit) : pending;\n const messageTags: MessageTags[] = [];\n for (let index = 0; index < toTag.length && !signal?.aborted; index++) {\n const message = toTag[index];\n const result = await deps.tag(message);\n messageTags.push({ messageId: messageIdOf(message), tags: result.tags, spam: result.spam });\n onProgress?.('tag', index + 1, toTag.length);\n }\n\n // Cancelled during tagging — skip the (LLM) topic phase and return what was tagged.\n if (signal?.aborted) {\n return { messageTags, topicDrafts: [] };\n }\n\n // Phase 2 — cluster threads into topic drafts (deterministic), summarize the new ones.\n const threads = buildThreads(messages, { ownerEmail, now });\n const drafts = clusterThreads(threads, topicOptions);\n // const fresh = drafts.filter((draft: TopicDraft) => !skipTopic?.(draft.label) && (keepTopic?.(draft) ?? true));\n // const summarized = await summarizeTopics(fresh, deps.summarize);\n // summarized.forEach((_, index) => onProgress?.('topic', index + 1, summarized.length));\n\n // return { messageTags, topicDrafts: summarized };\n return { messageTags, topicDrafts: drafts };\n};\n"],"mappings":"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"}
|