@wirecat/cli-core 0.574.0 → 0.576.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/calendar/mcp/index.d.ts +12 -12
  3. package/dist/cli/findings-input.js +6 -0
  4. package/dist/domain/orders.d.ts +19 -0
  5. package/dist/domain/orders.js +12 -0
  6. package/dist/domain/ref-summaries.js +1 -0
  7. package/dist/domain/references.d.ts +1 -1
  8. package/dist/domain/references.js +1 -0
  9. package/dist/entities/index.d.ts +75 -0
  10. package/dist/entities/mail-findings.d.ts +75 -0
  11. package/dist/entities/mail-findings.js +62 -0
  12. package/dist/errors/reasons.js +5 -0
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +1 -0
  15. package/dist/mail/cli/bulk-settings.d.ts +6 -0
  16. package/dist/mail/cli/bulk-settings.js +65 -0
  17. package/dist/mail/cli/index.d.ts +4 -0
  18. package/dist/mail/cli/index.js +1 -0
  19. package/dist/mail/cli/review.js +34 -13
  20. package/dist/mail/findings.d.ts +73 -1
  21. package/dist/mail/findings.js +164 -1
  22. package/dist/mail/index.d.ts +2 -1
  23. package/dist/mail/index.js +1 -0
  24. package/dist/mail/marks.d.ts +19 -7
  25. package/dist/mail/marks.js +72 -16
  26. package/dist/mail/newsletter.d.ts +52 -0
  27. package/dist/mail/newsletter.js +189 -0
  28. package/dist/mail/review.d.ts +2 -0
  29. package/dist/services/proposals/kinds/mail-findings.js +9 -0
  30. package/dist/services/proposals/kinds/orders.d.ts +4 -0
  31. package/dist/services/proposals/kinds/orders.js +32 -0
  32. package/dist/services/proposals/registry.js +2 -0
  33. package/dist/store/index.d.ts +1 -0
  34. package/dist/store/sqlite/entity-types.d.ts +1 -0
  35. package/dist/store/sqlite/entity-types.js +1 -0
  36. package/dist/store/sqlite/finance.d.ts +40 -2
  37. package/dist/store/sqlite/finance.js +48 -42
  38. package/dist/store/sqlite/knowledge.js +2 -1
  39. package/dist/store/sqlite/mail-findings.js +81 -1
  40. package/dist/store/sqlite/manifest.js +2 -0
  41. package/dist/store/sqlite/migrations.generated.js +20 -0
  42. package/dist/store/sqlite/orders.d.ts +84 -0
  43. package/dist/store/sqlite/orders.js +186 -0
  44. package/dist/store/sqlite/ref-summaries.js +10 -0
  45. package/dist/store/sqlite/schema.d.ts +440 -0
  46. package/dist/store/sqlite/schema.js +38 -0
  47. package/dist/store/sqlite/things.js +1 -0
  48. package/dist/store/store.d.ts +3 -0
  49. package/dist/store/store.js +5 -0
  50. package/package.json +1 -1
@@ -19,6 +19,7 @@ import { formatReference } from "../../domain/references.js";
19
19
  import { AGENDA_DEFAULTS } from "../../services/agenda.js";
20
20
  import { REVIEW_DAYS, reviewStart } from "../../services/inbox.js";
21
21
  import { readPastMark } from "../marks.js";
22
+ import { BULK_TRIAGE_DEFAULTS } from "../newsletter.js";
22
23
  import { hitText } from "../read.js";
23
24
  import { OWED_DAYS, REVIEW_THREADS, reviewMail } from "../review.js";
24
25
  const DAY = 86_400_000;
@@ -74,9 +75,10 @@ const waitingHours = (value) => value === true ? AGENDA_DEFAULTS.waiting / 3_600
74
75
  const quoted = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
75
76
  /** A file name for the mailbox's points: they describe the mailbox, whichever profile reads it. */
76
77
  const pointsName = (mailbox) => encodeURIComponent(mailbox.account);
77
- const markText = (marks) => {
78
+ const markText = (marks, excerptOnly = false) => {
78
79
  const said = [
79
- marks?.bulk ? "bulk" : "",
80
+ marks?.bulk || excerptOnly ? "bulk" : "",
81
+ excerptOnly ? "excerpt only" : "",
80
82
  marks?.listId ? `list ${marks.listId}` : "",
81
83
  marks?.ruleId ? `rule ${marks.ruleId}` : "",
82
84
  ];
@@ -84,9 +86,11 @@ const markText = (marks) => {
84
86
  return shown ? `\n [${shown}]` : "";
85
87
  };
86
88
  const reviewAnswer = (found, mailbox, fresh, step) => {
89
+ // Excerpts come after the mail written to the owner, so an agent with a budget reads that first.
87
90
  const read = found.chats
88
91
  .flatMap((chat) => chat.messages.map((message) => ({ ...message, chatTitle: chat.title })))
89
- .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
92
+ .sort((a, b) => Number(a.excerptOnly ?? false) - Number(b.excerptOnly ?? false) ||
93
+ Date.parse(a.timestamp) - Date.parse(b.timestamp));
90
94
  const cut = found.chats.filter((chat) => chat.more);
91
95
  const ref = (thread) => formatReference({ type: "chat", ...mailbox, chat: thread });
92
96
  const turns = found.unanswered !== undefined || found.waiting !== undefined;
@@ -126,7 +130,8 @@ const reviewAnswer = (found, mailbox, fresh, step) => {
126
130
  ...summaryOf(message, mailbox),
127
131
  email: {
128
132
  ...messageItem(message, mailbox).email,
129
- bulk: message.marks?.bulk ?? false,
133
+ bulk: (message.marks?.bulk ?? false) || message.excerptOnly === true,
134
+ ...(message.excerptOnly ? { excerpt_only: true } : {}),
130
135
  list_id: message.marks?.listId ?? null,
131
136
  rule_id: message.marks?.ruleId ?? null,
132
137
  ...(message.marks
@@ -150,16 +155,22 @@ const holderReview = async (command, mail, holder, query) => {
150
155
  const { unanswered, waiting } = command.opts();
151
156
  if (typedQuery(query).trim() || unanswered !== undefined || waiting !== undefined)
152
157
  throw new CliError("validation_error", "--holder reads past its own mark — not with a query, --unanswered or --waiting");
158
+ const triage = mail.bulkTriage?.(command) ?? BULK_TRIAGE_DEFAULTS;
153
159
  const { read, mailbox, marks } = await mail.read(command, async (store, mailbox) => {
154
- const read = await readPastMark(store, mailbox, holder);
155
- const account = read.emails.length ? await store.accounts.storedAccount(mailbox) : undefined;
156
- const marks = account
157
- ? await store.mail.emailMarks(account.id, read.emails.map((one) => one.id))
158
- : new Map();
160
+ const read = await readPastMark(store, mailbox, holder, { triage });
161
+ const ids = [...read.emails, ...read.excerpts.map(({ email }) => email)].map((one) => one.id);
162
+ const account = ids.length ? await store.accounts.storedAccount(mailbox) : undefined;
163
+ const marks = account ? await store.mail.emailMarks(account.id, ids) : new Map();
159
164
  return { read, mailbox, marks };
160
165
  });
161
166
  // An agent reads this: earlier mail and signatures stay out of its text; the fingerprint is over the stored body.
162
- const emails = read.emails.map((email) => ({ ...email, text: mailOwnText(email.text) }));
167
+ const emails = [
168
+ ...read.emails.map((email) => ({ ...email, text: mailOwnText(email.text) })),
169
+ ...read.excerpts.map(({ email, excerpt }) => {
170
+ const subject = email.email?.subject;
171
+ return { ...email, text: subject ? `${subject}\n\n${excerpt}` : excerpt, excerptOnly: true };
172
+ }),
173
+ ];
163
174
  const chats = new Map();
164
175
  for (const email of emails) {
165
176
  const chat = chats.get(email.chatId) ?? {
@@ -193,13 +204,22 @@ const holderReview = async (command, mail, holder, query) => {
193
204
  ...(skipped.length
194
205
  ? [{ field: "items", why: `left out: ${skipped.map(([reason, count]) => `${count} ${reason}`).join(", ")}` }]
195
206
  : []),
207
+ ...(read.excerpts.length
208
+ ? [
209
+ {
210
+ field: "items",
211
+ why: `${read.excerpts.length} bulk emails (excerpt_only) as their subject and first lines: ` +
212
+ "read one whole with `messages show <ref>` when it is an order, an invoice or a request",
213
+ },
214
+ ]
215
+ : []),
196
216
  ];
197
217
  const output = outputFor(command);
198
218
  renderAnswer(command, output, answer, () => {
199
219
  output.renderer.note(read.mark
200
220
  ? `${holder}: past ${read.mark.throughId}; acknowledge with --cursor ${read.cursor}${read.more ? "; more after these" : ""}`
201
221
  : `${holder} has no mark yet: it starts from now — acknowledge with --cursor ${read.cursor}`);
202
- return emails.map((hit) => `${hitText(hit)}${markText(marks.get(hit.id))}\n`).join("");
222
+ return emails.map((hit) => `${hitText(hit)}${markText(marks.get(hit.id), hit.excerptOnly)}\n`).join("");
203
223
  });
204
224
  };
205
225
  /**
@@ -225,8 +245,9 @@ export const mailReviewCommand = (mail) => annotate(new Command("review"), {
225
245
  .option("--unanswered [duration]", "only threads whose newest email came to you, in To or Cc, with no answer for this long — 4h, 1d; 24h if not given")
226
246
  .option("--waiting [duration]", "only threads where you wrote last and nobody answered for this long — 3d; 7d if not given")
227
247
  .option("--holder <name>", "read for this holder — an agent or a pipeline — what is stored past its processing mark, by store order; " +
228
- "bulk, envelopes, drafts, suspicious senders and partial mail left out; each text without quoted earlier mail " +
229
- "or the signature; `messages acknowledge` moves the mark")
248
+ "newsletters, envelopes, drafts, suspicious senders and partial mail left out; other bulk mail after the rest, " +
249
+ "as its subject and first lines (excerpt_only); each text without quoted earlier mail or the signature; " +
250
+ "`messages acknowledge` moves the mark")
230
251
  .option("--new", "what changed since the last `review --new` of this mailbox, a point per thread — for scheduled runs")
231
252
  .addHelpText("after", `
232
253
  Without a date, --unanswered and --waiting look at threads active in the last ${OWED_DAYS} days.
@@ -1,4 +1,6 @@
1
+ import { INVOICE_DIRECTIONS, MONEY_DIRECTIONS } from "../domain/finance.js";
1
2
  import { MEMORY_KINDS, MEMORY_SCOPES } from "../domain/knowledge.js";
3
+ import { ORDER_PURPOSES, ORDER_STATUSES } from "../domain/orders.js";
2
4
  import { type FactFinding, type FindingEvidence, type TaskFinding } from "../meetings/index.js";
3
5
  export { MAIL_FINDINGS_KIND } from "../store/mail-findings.js";
4
6
  /** At most this many findings in one run's batch, and per sender in it. */
@@ -39,7 +41,77 @@ export interface MailNewContactFinding extends Cited {
39
41
  name: string;
40
42
  reason?: string;
41
43
  }
42
- export type MailFinding = MailTaskFinding | MailFactFinding | MailTaskUpdateFinding | MailMemoryFinding | MailLinkFinding | MailNewContactFinding;
44
+ /** An invoice the mail carries; accepting it records the invoice through `store.finance`, confirmed. */
45
+ export interface MailInvoiceFinding extends Cited {
46
+ kind: "invoice";
47
+ direction: (typeof INVOICE_DIRECTIONS)[number];
48
+ /** `person:` or `organization:`. */
49
+ counterparty: string;
50
+ currency: string;
51
+ lines: {
52
+ description: string;
53
+ quantity?: string;
54
+ unitPrice?: string;
55
+ amount?: string;
56
+ }[];
57
+ /** The total printed on it: the store refuses it unless it is the lines' sum. */
58
+ total?: string;
59
+ number?: string;
60
+ issueDate?: string;
61
+ dueDate?: string;
62
+ project?: string;
63
+ reason: string;
64
+ }
65
+ /** A payment the mail confirms, such as a receipt; accepting it records the payment through `store.finance`. */
66
+ export interface MailPaymentFinding extends Cited {
67
+ kind: "payment";
68
+ direction: (typeof MONEY_DIRECTIONS)[number];
69
+ payer?: string;
70
+ payee?: string;
71
+ amount: string;
72
+ currency: string;
73
+ /** `YYYY-MM-DD`. */
74
+ date: string;
75
+ /** The payment's own reference text, such as the receipt number. */
76
+ reference?: string;
77
+ /** Stored invoices it settles, in the payment's currency. */
78
+ allocations?: {
79
+ invoice: string;
80
+ amount: string;
81
+ }[];
82
+ project?: string;
83
+ reason: string;
84
+ }
85
+ /** An order the mail confirms; accepting it records the order through `store.orders`. */
86
+ export interface MailOrderFinding extends Cited {
87
+ kind: "order";
88
+ purpose: (typeof ORDER_PURPOSES)[number];
89
+ /** Whom it is for; the owner when left out. */
90
+ customer?: string;
91
+ supplier?: string;
92
+ supplierName: string;
93
+ number?: string;
94
+ total?: string;
95
+ currency?: string;
96
+ status?: (typeof ORDER_STATUSES)[number];
97
+ orderDate?: string;
98
+ fulfilledDate?: string;
99
+ invoice?: string;
100
+ payment?: string;
101
+ project?: string;
102
+ reason: string;
103
+ }
104
+ /** A later mail about a stored order, found by its shop and number: shipped, delivered, cancelled, returned. */
105
+ export interface MailOrderUpdateFinding extends Cited {
106
+ kind: "order_update";
107
+ supplierName: string;
108
+ number: string;
109
+ status: (typeof ORDER_STATUSES)[number];
110
+ /** `YYYY-MM-DD`, the delivery day of a delivered order. */
111
+ date?: string;
112
+ reason: string;
113
+ }
114
+ export type MailFinding = MailTaskFinding | MailFactFinding | MailTaskUpdateFinding | MailMemoryFinding | MailLinkFinding | MailNewContactFinding | MailInvoiceFinding | MailPaymentFinding | MailOrderFinding | MailOrderUpdateFinding;
43
115
  export interface MailFindingsInput {
44
116
  schemaVersion: 1;
45
117
  /** `<holder>:<through_id>`: one batch per run of a holder over the mail it was given. */
@@ -1,6 +1,8 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { INVOICE_DIRECTIONS, MONEY_DIRECTIONS } from "../domain/finance.js";
2
3
  import { MEMORY_KINDS, MEMORY_SCOPES } from "../domain/knowledge.js";
3
4
  import { formatLocator, parseLocator } from "../domain/locator.js";
5
+ import { ORDER_PURPOSES, ORDER_STATUSES } from "../domain/orders.js";
4
6
  import { parseReference } from "../domain/references.js";
5
7
  import { findingFields, findingHash, findingId, findingText, meetingTimestamp, validateBatch, validateFinding, validEvidence, } from "../meetings/index.js";
6
8
  export { MAIL_FINDINGS_KIND } from "../store/mail-findings.js";
@@ -42,6 +44,18 @@ const LINKS = {
42
44
  "chat:email": "project",
43
45
  };
44
46
  const ADDRESS = /^[^\s@<>]+@[^\s@<>]+$/;
47
+ const PARTY = /^(person|organization):[1-9]\d*$/;
48
+ const INVOICE = /^invoice:[1-9]\d*$/;
49
+ const PAYMENT = /^payment:[1-9]\d*$/;
50
+ const PROJECT = /^project:[1-9]\d*$/;
51
+ const oneOf = (value, allowed, what) => {
52
+ if (typeof value !== "string" || !allowed.includes(value))
53
+ return invalid(`${what} is ${allowed.join(", ")}`);
54
+ return value;
55
+ };
56
+ /** Money and dates stay text here: the store's finance and order services check them as they check the owner's. */
57
+ const word = (value, limit) => findingText(value, limit);
58
+ const optional = (key, value, read) => (value === undefined ? {} : { [key]: read(value) });
45
59
  const one = (raw, ids) => {
46
60
  if (!raw || typeof raw !== "object")
47
61
  return invalid("a finding is an object");
@@ -123,8 +137,157 @@ const one = (raw, ids) => {
123
137
  evidence,
124
138
  };
125
139
  }
140
+ case "invoice": {
141
+ findingFields(raw, [
142
+ "id",
143
+ "kind",
144
+ "direction",
145
+ "counterparty",
146
+ "currency",
147
+ "lines",
148
+ "total",
149
+ "number",
150
+ "issueDate",
151
+ "dueDate",
152
+ "project",
153
+ "reason",
154
+ "evidence",
155
+ ], RULES.singular);
156
+ if (!Array.isArray(raw.lines) || raw.lines.length < 1 || raw.lines.length > 50)
157
+ return invalid("an invoice finding has 1–50 lines");
158
+ const lines = raw.lines.map((line) => {
159
+ if (!line || typeof line !== "object")
160
+ return invalid("an invoice line is an object");
161
+ const fields = line;
162
+ findingFields(fields, ["description", "quantity", "unitPrice", "amount"], "invoice line");
163
+ return {
164
+ description: word(fields.description, 500),
165
+ ...optional("quantity", fields.quantity, (value) => word(value, 20)),
166
+ ...optional("unitPrice", fields.unitPrice, (value) => word(value, 30)),
167
+ ...optional("amount", fields.amount, (value) => word(value, 30)),
168
+ };
169
+ });
170
+ return {
171
+ id,
172
+ kind: "invoice",
173
+ direction: oneOf(raw.direction, INVOICE_DIRECTIONS, "an invoice's direction"),
174
+ counterparty: reference(raw.counterparty, PARTY, "an invoice's counterparty"),
175
+ currency: word(raw.currency, 3),
176
+ lines,
177
+ ...optional("total", raw.total, (value) => word(value, 30)),
178
+ ...optional("number", raw.number, (value) => word(value, 80)),
179
+ ...optional("issueDate", raw.issueDate, (value) => word(value, 10)),
180
+ ...optional("dueDate", raw.dueDate, (value) => word(value, 10)),
181
+ ...optional("project", raw.project, (value) => reference(value, PROJECT, "a project")),
182
+ reason: findingText(raw.reason, 2000),
183
+ evidence,
184
+ };
185
+ }
186
+ case "payment": {
187
+ findingFields(raw, [
188
+ "id",
189
+ "kind",
190
+ "direction",
191
+ "payer",
192
+ "payee",
193
+ "amount",
194
+ "currency",
195
+ "date",
196
+ "reference",
197
+ "allocations",
198
+ "project",
199
+ "reason",
200
+ "evidence",
201
+ ], RULES.singular);
202
+ let allocations;
203
+ if (raw.allocations !== undefined) {
204
+ if (!Array.isArray(raw.allocations) || raw.allocations.length < 1 || raw.allocations.length > 8)
205
+ return invalid("a payment finding's allocations are 1–8 invoices it settles");
206
+ allocations = raw.allocations.map((share) => {
207
+ if (!share || typeof share !== "object")
208
+ return invalid("an allocation is an invoice and an amount");
209
+ const fields = share;
210
+ findingFields(fields, ["invoice", "amount"], "allocation");
211
+ return {
212
+ invoice: reference(fields.invoice, INVOICE, "an allocation's invoice"),
213
+ amount: word(fields.amount, 30),
214
+ };
215
+ });
216
+ }
217
+ return {
218
+ id,
219
+ kind: "payment",
220
+ direction: oneOf(raw.direction, MONEY_DIRECTIONS, "a payment's direction"),
221
+ ...optional("payer", raw.payer, (value) => reference(value, PARTY, "a payment's payer")),
222
+ ...optional("payee", raw.payee, (value) => reference(value, PARTY, "a payment's payee")),
223
+ amount: word(raw.amount, 30),
224
+ currency: word(raw.currency, 3),
225
+ date: word(raw.date, 10),
226
+ ...optional("reference", raw.reference, (value) => word(value, 500)),
227
+ ...(allocations === undefined ? {} : { allocations }),
228
+ ...optional("project", raw.project, (value) => reference(value, PROJECT, "a project")),
229
+ reason: findingText(raw.reason, 2000),
230
+ evidence,
231
+ };
232
+ }
233
+ case "order": {
234
+ findingFields(raw, [
235
+ "id",
236
+ "kind",
237
+ "purpose",
238
+ "customer",
239
+ "supplier",
240
+ "supplierName",
241
+ "number",
242
+ "total",
243
+ "currency",
244
+ "status",
245
+ "orderDate",
246
+ "fulfilledDate",
247
+ "invoice",
248
+ "payment",
249
+ "project",
250
+ "reason",
251
+ "evidence",
252
+ ], RULES.singular);
253
+ return {
254
+ id,
255
+ kind: "order",
256
+ purpose: oneOf(raw.purpose, ORDER_PURPOSES, "an order's purpose"),
257
+ ...optional("customer", raw.customer, (value) => reference(value, PARTY, "an order's customer")),
258
+ ...optional("supplier", raw.supplier, (value) => reference(value, PARTY, "an order's supplier")),
259
+ supplierName: word(raw.supplierName, 200),
260
+ ...optional("number", raw.number, (value) => word(value, 80)),
261
+ ...optional("total", raw.total, (value) => word(value, 30)),
262
+ ...optional("currency", raw.currency, (value) => word(value, 3)),
263
+ ...optional("status", raw.status, (value) => oneOf(value, ORDER_STATUSES, "an order's status")),
264
+ ...optional("orderDate", raw.orderDate, (value) => word(value, 10)),
265
+ ...optional("fulfilledDate", raw.fulfilledDate, (value) => word(value, 10)),
266
+ ...optional("invoice", raw.invoice, (value) => reference(value, INVOICE, "an order's invoice")),
267
+ ...optional("payment", raw.payment, (value) => reference(value, PAYMENT, "an order's payment")),
268
+ ...optional("project", raw.project, (value) => reference(value, PROJECT, "a project")),
269
+ reason: findingText(raw.reason, 2000),
270
+ evidence,
271
+ };
272
+ }
273
+ case "order_update": {
274
+ findingFields(raw, ["id", "kind", "supplierName", "number", "status", "date", "reason", "evidence"], RULES.singular);
275
+ const status = oneOf(raw.status, ORDER_STATUSES, "an order update's status");
276
+ if (raw.date !== undefined && status !== "delivered")
277
+ return invalid("only a delivered order update gives date");
278
+ return {
279
+ id,
280
+ kind: "order_update",
281
+ supplierName: word(raw.supplierName, 200),
282
+ number: word(raw.number, 80),
283
+ status,
284
+ ...optional("date", raw.date, (value) => word(value, 10)),
285
+ reason: findingText(raw.reason, 2000),
286
+ evidence,
287
+ };
288
+ }
126
289
  default:
127
- return invalid("a mail finding is a task, task_update, fact, memory, link or new_contact");
290
+ return invalid("a mail finding is a task, task_update, fact, memory, link, new_contact, invoice, payment, order or order_update");
128
291
  }
129
292
  };
130
293
  /**
@@ -9,7 +9,8 @@ export { dropMissing, type Gone, tooManyGone } from "./gone.js";
9
9
  export { type AuthVerdicts, HEADER_FIELDS, headersOf, type MailHeaders, mailHeadersOf } from "./headers.js";
10
10
  export { type MailIdsInput, mailIds } from "./ids.js";
11
11
  export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
12
- export { acknowledgeMark, advancePastSkipped, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, type MailMark, type MailWaiting, type MarkRead, mailWaiting, markOf, marksOf, readPastMark, type SkipReason, } from "./marks.js";
12
+ export { acknowledgeMark, advancePastSkipped, type BulkExcerpt, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, type MailMark, type MailWaiting, type MarkRead, mailWaiting, markOf, marksOf, readPastMark, type SkipReason, } from "./marks.js";
13
+ export { BULK_TRIAGE_DEFAULTS, type BulkSorted, type BulkTriage, excerptOf, NEWSLETTER_DEFAULTS, NEWSLETTER_SENDERS, NEWSLETTER_WORDS, type NewsletterCues, newsletterSorter, ORDER_WORDS, } from "./newsletter.js";
13
14
  export { type MailTurn, mailTurn } from "./owed.js";
14
15
  export { type Address, htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, type ParsedMail, parseMail, } from "./parse.js";
15
16
  export { PARTIAL, type PartialOutcome } from "./partial.js";
@@ -10,6 +10,7 @@ export { HEADER_FIELDS, headersOf, mailHeadersOf } from "./headers.js";
10
10
  export { mailIds } from "./ids.js";
11
11
  export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
12
12
  export { acknowledgeMark, advancePastSkipped, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, mailWaiting, markOf, marksOf, readPastMark, } from "./marks.js";
13
+ export { BULK_TRIAGE_DEFAULTS, excerptOf, NEWSLETTER_DEFAULTS, NEWSLETTER_SENDERS, NEWSLETTER_WORDS, newsletterSorter, ORDER_WORDS, } from "./newsletter.js";
13
14
  export { mailTurn } from "./owed.js";
14
15
  export { htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, parseMail, } from "./parse.js";
15
16
  export { PARTIAL } from "./partial.js";
@@ -1,11 +1,12 @@
1
1
  import type { AccountKey, MessageStore, StoredHit } from "../store/store.js";
2
+ import { type BulkTriage } from "./newsletter.js";
2
3
  /** A holder's name: what `--holder` takes and the mark's key ends with. */
3
4
  export declare const HOLDER: RegExp;
4
5
  /** At most this many emails are read past the mark in one go; the rest come with the next read. */
5
6
  export declare const MARK_BATCH = 200;
6
7
  /** A partial email waits this long to be stored whole, then is skipped. */
7
8
  export declare const HOLD_PARTIAL_MS: number;
8
- export type SkipReason = "bulk" | "envelope" | "partial" | "draft" | "suspicious";
9
+ export type SkipReason = "bulk" | "newsletter" | "envelope" | "partial" | "draft" | "suspicious";
9
10
  /**
10
11
  * How far one holder — an agent, a pipeline — has processed an account's mail, in the shared store (`sync_cursors`,
11
12
  * key `consumer:<holder>`). It counts by the store's insert order (`emails.id`), so mail stored late, older than what
@@ -29,9 +30,19 @@ export declare const markOf: (store: MessageStore, mailbox: AccountKey, holder:
29
30
  export declare const marksOf: (store: MessageStore, mailbox: AccountKey) => Promise<MailMark[]>;
30
31
  /** The mark's state, as a cursor `acknowledge` takes back: the last id read, and when it was read. */
31
32
  export declare const cursorOf: (throughId: number, readAt: number) => string;
33
+ /** A bulk email that is not surely a newsletter: the holder gets its subject and first lines only. */
34
+ export interface BulkExcerpt {
35
+ email: StoredHit;
36
+ /** The first lines of its own text, without the subject. */
37
+ excerpt: string;
38
+ /** What kept it, or the newsletter signs too few to leave it out. */
39
+ cues: string[];
40
+ }
32
41
  export interface MarkRead {
33
42
  /** The emails for the holder, oldest first; bulk, envelopes, drafts, suspicious senders and partial mail left out. */
34
43
  emails: StoredHit[];
44
+ /** Bulk mail past the mark that is not surely a newsletter, oldest first; none when the triage gives no excerpts. */
45
+ excerpts: BulkExcerpt[];
35
46
  /** What `acknowledge --cursor` takes once the holder is done with these. */
36
47
  cursor: string;
37
48
  /** More is stored after this batch: read again after acknowledging. */
@@ -49,6 +60,7 @@ export interface MarkRead {
49
60
  export declare const readPastMark: (store: MessageStore, mailbox: AccountKey, holder: string, options?: {
50
61
  now?: number;
51
62
  limit?: number;
63
+ triage?: BulkTriage;
52
64
  }) => Promise<MarkRead>;
53
65
  /** Mail past a holder's marks that would reach it: how many emails, and when the oldest of them was stored. */
54
66
  export interface MailWaiting {
@@ -60,17 +72,17 @@ export interface MailWaiting {
60
72
  }
61
73
  /**
62
74
  * What waits for the holder in every stored mailbox, read only: what a schedule asks before it starts the holder's
63
- * agent, so a run with nothing new costs no model call. Bulk, envelopes, drafts, suspicious senders and partial mail
64
- * do not count.
75
+ * agent, so a run with nothing new costs no model call. Newsletters, envelopes, drafts, suspicious senders and partial
76
+ * mail do not count; other bulk mail counts as the excerpt the holder gets, unless the triage gives none.
65
77
  */
66
- export declare const mailWaiting: (store: MessageStore, holder: string, now?: number) => Promise<MailWaiting>;
78
+ export declare const mailWaiting: (store: MessageStore, holder: string, now?: number, triage?: BulkTriage) => Promise<MailWaiting>;
67
79
  /**
68
- * Moves the holder's mark, in every stored mailbox, over the mail at its head that it would only skip — bulk,
69
- * envelopes, drafts, suspicious senders — as the agent would have acknowledged it, so a quiet holder's next check
80
+ * Moves the holder's mark, in every stored mailbox, over the mail at its head that it would only skip — newsletters,
81
+ * bulk mail the triage gives no excerpt of, envelopes, drafts, suspicious senders — as the agent would have acknowledged it, so a quiet holder's next check
70
82
  * does not read it all again. Suspicious senders' emails stay on the mark to be offered again. It stops at the first
71
83
  * email the holder reads. A store busy with a sync is left alone: the next check moves it.
72
84
  */
73
- export declare const advancePastSkipped: (store: MessageStore, holder: string, now?: number) => Promise<void>;
85
+ export declare const advancePastSkipped: (store: MessageStore, holder: string, now?: number, triage?: BulkTriage) => Promise<void>;
74
86
  /**
75
87
  * Moves the holder's mark to the cursor its read gave, never back. An email whole when it was read counts read; one
76
88
  * still partial, or changed after the read (it may have become whole since), is held and comes with the next read.
@@ -1,5 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { mailOwnText } from "../conversations/mail-text.js";
2
3
  import { isBusy } from "../store/sqlite/transaction.js";
4
+ import { BULK_TRIAGE_DEFAULTS, newsletterSorter } from "./newsletter.js";
3
5
  /** A holder's name: what `--holder` takes and the mark's key ends with. */
4
6
  export const HOLDER = /^[a-z0-9][a-z0-9-]{0,39}$/;
5
7
  /** At most this many emails are read past the mark in one go; the rest come with the next read. */
@@ -11,7 +13,14 @@ const MOST_HELD = 500;
11
13
  /** A cursor read at most this far in the future is a clock apart, not a forgery. */
12
14
  const CLOCK_SLACK_MS = 60_000;
13
15
  const PREFIX = "consumer:";
14
- const noneSkipped = () => ({ bulk: 0, envelope: 0, partial: 0, draft: 0, suspicious: 0 });
16
+ const noneSkipped = () => ({
17
+ bulk: 0,
18
+ newsletter: 0,
19
+ envelope: 0,
20
+ partial: 0,
21
+ draft: 0,
22
+ suspicious: 0,
23
+ });
15
24
  const keyOf = (holder) => {
16
25
  if (!HOLDER.test(holder))
17
26
  throw new CliError("validation_error", `a holder is lower-case letters, digits and dashes, not "${holder}"`);
@@ -83,14 +92,54 @@ const parseCursor = (cursor) => {
83
92
  * empty and gives the cursor of the newest stored email.
84
93
  */
85
94
  export const readPastMark = async (store, mailbox, holder, options = {}) => {
86
- const { wanted, oldestStoredAt: _, ...read } = await examine(store, mailbox, holder, options);
87
- return { emails: await store.mail.emailHits(wanted), ...read };
95
+ const { wanted, sorted, oldestStoredAt: _, ...read } = await examine(store, mailbox, holder, options);
96
+ const excerpts = wanted.flatMap((id) => {
97
+ const one = sorted.get(id);
98
+ return one ? [{ email: one.email, excerpt: one.excerpt, cues: one.cues }] : [];
99
+ });
100
+ const emails = await store.mail.emailHits(wanted.filter((id) => !sorted.has(id)));
101
+ return { emails, excerpts, ...read };
102
+ };
103
+ /**
104
+ * The batch's bulk emails a holder might read, sorted by `newsletterSorter`, and where the batch ends so it holds at
105
+ * most `perRead` excerpts. Drafts, envelopes, suspicious senders and partial mail keep their own verdicts.
106
+ */
107
+ const sortBulk = async (store, batch, triage) => {
108
+ const sorted = new Map();
109
+ if (!triage.excerpts)
110
+ return { sorted, end: batch.length };
111
+ const sort = newsletterSorter(triage.cues);
112
+ let excerpts = 0;
113
+ for (const [index, marked] of batch.entries()) {
114
+ if (!marked.bulk || marked.draft || marked.envelope || marked.demoted || marked.partial)
115
+ continue;
116
+ const [email] = await store.mail.emailHits([marked.id]);
117
+ if (!email)
118
+ continue;
119
+ const subject = email.email?.subject ?? null;
120
+ const body = subject && email.text.startsWith(`${subject}\n\n`) ? email.text.slice(subject.length + 2) : email.text;
121
+ const one = sort({ subject, text: mailOwnText(body), from: email.email?.from?.address ?? null });
122
+ if (one.verdict === "excerpt" && excerpts === triage.perRead)
123
+ return { sorted, end: index };
124
+ if (one.verdict === "excerpt")
125
+ excerpts += 1;
126
+ sorted.set(marked.id, { ...one, email });
127
+ }
128
+ return { sorted, end: batch.length };
88
129
  };
89
- /** `readPastMark` without the emails' text: the store ids it would read, by their verdicts. */
90
- const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_BATCH, after }) => {
130
+ /** `readPastMark` before it reads the emails: the store ids it would read by their verdicts, and the bulk sorted. */
131
+ const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_BATCH, after, triage = BULK_TRIAGE_DEFAULTS, }) => {
91
132
  const saved = await markOf(store, mailbox, holder);
92
133
  const account = await accountOf(store, mailbox);
93
- const empty = { wanted: [], oldestStoredAt: null, more: false, held: 0, skipped: noneSkipped(), mark: saved };
134
+ const empty = {
135
+ wanted: [],
136
+ sorted: new Map(),
137
+ oldestStoredAt: null,
138
+ more: false,
139
+ held: 0,
140
+ skipped: noneSkipped(),
141
+ mark: saved,
142
+ };
94
143
  if (!account)
95
144
  return { ...empty, cursor: cursorOf(0, now) };
96
145
  if (!saved)
@@ -103,7 +152,8 @@ const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_
103
152
  const newer = found.filter((email) => email.id > mark.throughId);
104
153
  const unsettled = newer.findIndex((email) => email.storedAt > settledAt);
105
154
  const fresh = unsettled === -1 ? newer : newer.slice(0, unsettled);
106
- const batch = fresh.slice(0, limit);
155
+ const { sorted, end } = await sortBulk(store, fresh.slice(0, limit), triage);
156
+ const batch = fresh.slice(0, end);
107
157
  const examined = [...found.filter((email) => email.id <= mark.throughId), ...batch];
108
158
  const skipped = noneSkipped();
109
159
  const wanted = [];
@@ -111,7 +161,12 @@ const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_
111
161
  let held = 0;
112
162
  for (const email of examined) {
113
163
  const said = verdict(email, now);
114
- if (said === "read") {
164
+ const bulk = sorted.get(email.id);
165
+ if (bulk?.verdict === "newsletter") {
166
+ if (email.id > mark.throughId)
167
+ skipped.newsletter += 1;
168
+ }
169
+ else if (said === "read" || bulk) {
115
170
  wanted.push(email.id);
116
171
  oldestStoredAt = Math.min(oldestStoredAt ?? email.storedAt, email.storedAt);
117
172
  }
@@ -122,6 +177,7 @@ const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_
122
177
  }
123
178
  return {
124
179
  wanted: wanted.sort((a, b) => a - b),
180
+ sorted: new Map([...sorted].filter(([, one]) => one.verdict === "excerpt")),
125
181
  oldestStoredAt,
126
182
  cursor: cursorOf(batch.at(-1)?.id ?? mark.throughId, now),
127
183
  more: newer.length > batch.length,
@@ -132,17 +188,17 @@ const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_
132
188
  };
133
189
  /**
134
190
  * What waits for the holder in every stored mailbox, read only: what a schedule asks before it starts the holder's
135
- * agent, so a run with nothing new costs no model call. Bulk, envelopes, drafts, suspicious senders and partial mail
136
- * do not count.
191
+ * agent, so a run with nothing new costs no model call. Newsletters, envelopes, drafts, suspicious senders and partial
192
+ * mail do not count; other bulk mail counts as the excerpt the holder gets, unless the triage gives none.
137
193
  */
138
- export const mailWaiting = async (store, holder, now = Date.now()) => {
194
+ export const mailWaiting = async (store, holder, now = Date.now(), triage = BULK_TRIAGE_DEFAULTS) => {
139
195
  const waiting = { unmarked: false, emails: 0, oldestStoredAt: null };
140
196
  for (const mailbox of await store.accounts.accounts()) {
141
197
  if (mailbox.provider !== "email")
142
198
  continue;
143
199
  let after;
144
200
  for (;;) {
145
- const read = await examine(store, mailbox, holder, { now, ...(after === undefined ? {} : { after }) });
201
+ const read = await examine(store, mailbox, holder, { now, triage, ...(after === undefined ? {} : { after }) });
146
202
  if (!read.mark) {
147
203
  waiting.unmarked = true;
148
204
  break;
@@ -159,17 +215,17 @@ export const mailWaiting = async (store, holder, now = Date.now()) => {
159
215
  return waiting;
160
216
  };
161
217
  /**
162
- * Moves the holder's mark, in every stored mailbox, over the mail at its head that it would only skip — bulk,
163
- * envelopes, drafts, suspicious senders — as the agent would have acknowledged it, so a quiet holder's next check
218
+ * Moves the holder's mark, in every stored mailbox, over the mail at its head that it would only skip — newsletters,
219
+ * bulk mail the triage gives no excerpt of, envelopes, drafts, suspicious senders — as the agent would have acknowledged it, so a quiet holder's next check
164
220
  * does not read it all again. Suspicious senders' emails stay on the mark to be offered again. It stops at the first
165
221
  * email the holder reads. A store busy with a sync is left alone: the next check moves it.
166
222
  */
167
- export const advancePastSkipped = async (store, holder, now = Date.now()) => {
223
+ export const advancePastSkipped = async (store, holder, now = Date.now(), triage = BULK_TRIAGE_DEFAULTS) => {
168
224
  for (const mailbox of await store.accounts.accounts()) {
169
225
  if (mailbox.provider !== "email")
170
226
  continue;
171
227
  for (;;) {
172
- const read = await examine(store, mailbox, holder, { now });
228
+ const read = await examine(store, mailbox, holder, { now, triage });
173
229
  if (!read.mark || read.wanted.length > 0 || read.cursor === cursorOf(read.mark.throughId, now))
174
230
  break;
175
231
  try {