@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.
- package/CHANGELOG.md +33 -0
- package/dist/calendar/mcp/index.d.ts +12 -12
- package/dist/cli/findings-input.js +6 -0
- package/dist/domain/orders.d.ts +19 -0
- package/dist/domain/orders.js +12 -0
- package/dist/domain/ref-summaries.js +1 -0
- package/dist/domain/references.d.ts +1 -1
- package/dist/domain/references.js +1 -0
- package/dist/entities/index.d.ts +75 -0
- package/dist/entities/mail-findings.d.ts +75 -0
- package/dist/entities/mail-findings.js +62 -0
- package/dist/errors/reasons.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mail/cli/bulk-settings.d.ts +6 -0
- package/dist/mail/cli/bulk-settings.js +65 -0
- package/dist/mail/cli/index.d.ts +4 -0
- package/dist/mail/cli/index.js +1 -0
- package/dist/mail/cli/review.js +34 -13
- package/dist/mail/findings.d.ts +73 -1
- package/dist/mail/findings.js +164 -1
- package/dist/mail/index.d.ts +2 -1
- package/dist/mail/index.js +1 -0
- package/dist/mail/marks.d.ts +19 -7
- package/dist/mail/marks.js +72 -16
- package/dist/mail/newsletter.d.ts +52 -0
- package/dist/mail/newsletter.js +189 -0
- package/dist/mail/review.d.ts +2 -0
- package/dist/services/proposals/kinds/mail-findings.js +9 -0
- package/dist/services/proposals/kinds/orders.d.ts +4 -0
- package/dist/services/proposals/kinds/orders.js +32 -0
- package/dist/services/proposals/registry.js +2 -0
- package/dist/store/index.d.ts +1 -0
- package/dist/store/sqlite/entity-types.d.ts +1 -0
- package/dist/store/sqlite/entity-types.js +1 -0
- package/dist/store/sqlite/finance.d.ts +40 -2
- package/dist/store/sqlite/finance.js +48 -42
- package/dist/store/sqlite/knowledge.js +2 -1
- package/dist/store/sqlite/mail-findings.js +81 -1
- package/dist/store/sqlite/manifest.js +2 -0
- package/dist/store/sqlite/migrations.generated.js +20 -0
- package/dist/store/sqlite/orders.d.ts +84 -0
- package/dist/store/sqlite/orders.js +186 -0
- package/dist/store/sqlite/ref-summaries.js +10 -0
- package/dist/store/sqlite/schema.d.ts +440 -0
- package/dist/store/sqlite/schema.js +38 -0
- package/dist/store/sqlite/things.js +1 -0
- package/dist/store/store.d.ts +3 -0
- package/dist/store/store.js +5 -0
- package/package.json +1 -1
package/dist/mail/cli/review.js
CHANGED
|
@@ -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) =>
|
|
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
|
|
156
|
-
const
|
|
157
|
-
|
|
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 =
|
|
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
|
-
"
|
|
229
|
-
"
|
|
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.
|
package/dist/mail/findings.d.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/dist/mail/findings.js
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/dist/mail/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/mail/index.js
CHANGED
|
@@ -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";
|
package/dist/mail/marks.d.ts
CHANGED
|
@@ -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.
|
|
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 —
|
|
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.
|
package/dist/mail/marks.js
CHANGED
|
@@ -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 = () => ({
|
|
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
|
-
|
|
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`
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 —
|
|
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 {
|