@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
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Mail sent to many is often an order, an invoice or a site's lead form that carries List-Unsubscribe. This tells a
3
+ * newsletter from it by words in the subject and the first lines, so a holder can leave out only what is surely a
4
+ * newsletter and give the rest to its agent as an excerpt. No model: it runs on every read past a mark.
5
+ */
6
+ /** Words that say "advertising"; each matches at the start of a word, any case. */
7
+ export declare const NEWSLETTER_WORDS: readonly ["скидк", "акци", "распродаж", "вебинар", "рассылк", "промокод", "спецпредложени", "дайджест", "новинк", "подборк", "кешбэк", "кэшбэк", "только сегодня", "успейте", "чёрная пятница", "черная пятница", "выгодн", "newsletter", "unsubscribe", "% off", "sale", "discount", "webinar", "digest", "weekly", "promo", "deal", "limited time", "free shipping", "new arrivals", "black friday", "cyber monday", "coupon", "save up to", "last chance", "don't miss", "descuento", "oferta", "rebaja", "boletín", "seminario web", "novedades", "promoción", "no te pierdas", "últimos días"];
8
+ /** Parts of a sender's address that say "a newsletter": `news@…`, `newsletter.example.com`, `….substack.com`. */
9
+ export declare const NEWSLETTER_SENDERS: readonly ["news", "newsletter", "newsletters", "digest", "weekly", "promo", "marketing", "offers", "deals", "sale", "substack.com"];
10
+ /** Words that say "this is about something of yours"; one of them keeps the email for the agent. */
11
+ export declare const ORDER_WORDS: readonly ["заказ", "счёт", "счет на", "счета", "оплат", "квитанци", "кассовый чек", "электронный чек", "заявк", "форма", "доставк", "трек", "договор", "подтвержд", "код подтверждения", "код для входа", "пароль", "order", "invoice", "receipt", "payment", "shipped", "delivery", "tracking", "booking", "reservation", "confirm", "your account", "verification", "password", "refund", "statement", "pedido", "factura", "recibo", "pago", "envío", "reserva", "confirmación", "contraseña"];
12
+ export interface NewsletterCues {
13
+ newsletterWords: readonly string[];
14
+ newsletterSenders: readonly string[];
15
+ orderWords: readonly string[];
16
+ /** The score from which an email is surely a newsletter and left out. */
17
+ threshold: number;
18
+ /** How much of the email's own text is read, and shown as the excerpt. */
19
+ excerptChars: number;
20
+ }
21
+ export declare const NEWSLETTER_DEFAULTS: NewsletterCues;
22
+ export interface BulkSorted {
23
+ /** `newsletter` is left out; `excerpt` goes to the agent as the subject and the first lines. */
24
+ verdict: "newsletter" | "excerpt";
25
+ score: number;
26
+ /** What decided it: the newsletter signs, or what kept the email. */
27
+ cues: string[];
28
+ excerpt: string;
29
+ }
30
+ /** The first lines of an email's own text, at most `chars`, cut at a word. */
31
+ export declare const excerptOf: (text: string, chars: number) => string;
32
+ /**
33
+ * Built once per read, so the word lists compile once. A reply, an order word in the subject or an order's number
34
+ * keeps the email. Otherwise a newsletter word counts 2 in the subject and 1 in the first lines, a newsletter's
35
+ * address 2, an emoji in the subject and a browser or unsubscribe link at the top 1 each, and each order word in
36
+ * the first lines takes 2 off. Measured 2026-11-05 on 30 days of the owner's bulk mail: 99 of 100 sampled
37
+ * newsletter verdicts were newsletters, and about half of the newsletters were found.
38
+ */
39
+ export declare const newsletterSorter: (cues?: NewsletterCues) => ({ subject, text, from }: {
40
+ subject: string | null;
41
+ text: string;
42
+ from: string | null;
43
+ }) => BulkSorted;
44
+ /** How a holder's read treats bulk mail. */
45
+ export interface BulkTriage {
46
+ /** `false` leaves every bulk email out, newsletter or not. */
47
+ excerpts: boolean;
48
+ /** At most this many excerpts in one read; the read ends before the next one, which comes with the next read. */
49
+ perRead: number;
50
+ cues: NewsletterCues;
51
+ }
52
+ export declare const BULK_TRIAGE_DEFAULTS: BulkTriage;
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Mail sent to many is often an order, an invoice or a site's lead form that carries List-Unsubscribe. This tells a
3
+ * newsletter from it by words in the subject and the first lines, so a holder can leave out only what is surely a
4
+ * newsletter and give the rest to its agent as an excerpt. No model: it runs on every read past a mark.
5
+ */
6
+ /** Words that say "advertising"; each matches at the start of a word, any case. */
7
+ export const NEWSLETTER_WORDS = [
8
+ "скидк",
9
+ "акци",
10
+ "распродаж",
11
+ "вебинар",
12
+ "рассылк",
13
+ "промокод",
14
+ "спецпредложени",
15
+ "дайджест",
16
+ "новинк",
17
+ "подборк",
18
+ "кешбэк",
19
+ "кэшбэк",
20
+ "только сегодня",
21
+ "успейте",
22
+ "чёрная пятница",
23
+ "черная пятница",
24
+ "выгодн",
25
+ "newsletter",
26
+ "unsubscribe",
27
+ "% off",
28
+ "sale",
29
+ "discount",
30
+ "webinar",
31
+ "digest",
32
+ "weekly",
33
+ "promo",
34
+ "deal",
35
+ "limited time",
36
+ "free shipping",
37
+ "new arrivals",
38
+ "black friday",
39
+ "cyber monday",
40
+ "coupon",
41
+ "save up to",
42
+ "last chance",
43
+ "don't miss",
44
+ "descuento",
45
+ "oferta",
46
+ "rebaja",
47
+ "boletín",
48
+ "seminario web",
49
+ "novedades",
50
+ "promoción",
51
+ "no te pierdas",
52
+ "últimos días",
53
+ ];
54
+ /** Parts of a sender's address that say "a newsletter": `news@…`, `newsletter.example.com`, `….substack.com`. */
55
+ export const NEWSLETTER_SENDERS = [
56
+ "news",
57
+ "newsletter",
58
+ "newsletters",
59
+ "digest",
60
+ "weekly",
61
+ "promo",
62
+ "marketing",
63
+ "offers",
64
+ "deals",
65
+ "sale",
66
+ "substack.com",
67
+ ];
68
+ /** Words that say "this is about something of yours"; one of them keeps the email for the agent. */
69
+ export const ORDER_WORDS = [
70
+ "заказ",
71
+ "счёт",
72
+ "счет на",
73
+ "счета",
74
+ "оплат",
75
+ "квитанци",
76
+ "кассовый чек",
77
+ "электронный чек",
78
+ "заявк",
79
+ "форма",
80
+ "доставк",
81
+ "трек",
82
+ "договор",
83
+ "подтвержд",
84
+ "код подтверждения",
85
+ "код для входа",
86
+ "пароль",
87
+ "order",
88
+ "invoice",
89
+ "receipt",
90
+ "payment",
91
+ "shipped",
92
+ "delivery",
93
+ "tracking",
94
+ "booking",
95
+ "reservation",
96
+ "confirm",
97
+ "your account",
98
+ "verification",
99
+ "password",
100
+ "refund",
101
+ "statement",
102
+ "pedido",
103
+ "factura",
104
+ "recibo",
105
+ "pago",
106
+ "envío",
107
+ "reserva",
108
+ "confirmación",
109
+ "contraseña",
110
+ ];
111
+ /** A number an order, an invoice or a request is known by, after the word that names it. */
112
+ const REFERENCE_NUMBER = /(?<![\p{L}])(?:заказ|заявк|сч[её]т|квитанци|order|invoice|ticket|pedido|factura)\p{L}*\s*(?:№|#|n[o°]\.?)?\s?\d{3,}/iu;
113
+ /** The top of a newsletter: a link to read it in a browser, or to leave the list. */
114
+ const NEWSLETTER_TOP = /в браузере|веб-верси|view (?:it |this email )?(?:in|on) (?:your |a )?browser|unsubscribe|отписаться|darse de baja|ver en (?:el )?navegador/iu;
115
+ const PICTOGRAPH = /\p{Extended_Pictographic}/u;
116
+ /** A reply or a forward is a conversation, whatever it says. */
117
+ const REPLY = /^\s*(?:re|fw|fwd|aw|wg|ответ|отв|rv|res)\s*(?:\[\d+\])?\s*:/iu;
118
+ export const NEWSLETTER_DEFAULTS = {
119
+ newsletterWords: NEWSLETTER_WORDS,
120
+ newsletterSenders: NEWSLETTER_SENDERS,
121
+ orderWords: ORDER_WORDS,
122
+ threshold: 2,
123
+ excerptChars: 400,
124
+ };
125
+ const escaped = (word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
126
+ const matcherOf = (words) => {
127
+ const list = words.map((word) => word.trim().toLowerCase()).filter(Boolean);
128
+ if (list.length === 0)
129
+ return () => [];
130
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}])(?:${list.map(escaped).join("|")})`, "giu");
131
+ return (text) => [...new Set([...text.toLowerCase().matchAll(pattern)].map(([found]) => found))];
132
+ };
133
+ /** The first lines of an email's own text, at most `chars`, cut at a word. */
134
+ export const excerptOf = (text, chars) => {
135
+ const flat = text
136
+ .split("\n")
137
+ .map((line) => line.trim())
138
+ .filter(Boolean)
139
+ .join("\n");
140
+ if (flat.length <= chars)
141
+ return flat;
142
+ const cut = flat.slice(0, chars);
143
+ const space = cut.search(/\s\S*$/);
144
+ return `${space > chars / 2 ? cut.slice(0, space) : cut}…`;
145
+ };
146
+ /**
147
+ * Built once per read, so the word lists compile once. A reply, an order word in the subject or an order's number
148
+ * keeps the email. Otherwise a newsletter word counts 2 in the subject and 1 in the first lines, a newsletter's
149
+ * address 2, an emoji in the subject and a browser or unsubscribe link at the top 1 each, and each order word in
150
+ * the first lines takes 2 off. Measured 2026-11-05 on 30 days of the owner's bulk mail: 99 of 100 sampled
151
+ * newsletter verdicts were newsletters, and about half of the newsletters were found.
152
+ */
153
+ export const newsletterSorter = (cues = NEWSLETTER_DEFAULTS) => {
154
+ const advertising = matcherOf(cues.newsletterWords);
155
+ const ordered = matcherOf(cues.orderWords);
156
+ const senders = cues.newsletterSenders.map((part) => part.trim().toLowerCase()).filter(Boolean);
157
+ const fromNewsletter = (address) => {
158
+ const domain = address.split("@")[1] ?? "";
159
+ const parts = new Set(address.split(/[.@_+-]/));
160
+ return senders.some((one) => (one.includes(".") ? domain === one || domain.endsWith(`.${one}`) : parts.has(one)));
161
+ };
162
+ return ({ subject, text, from }) => {
163
+ const excerpt = excerptOf(text, cues.excerptChars);
164
+ const title = subject ?? "";
165
+ const number = REFERENCE_NUMBER.exec(`${title}\n${excerpt}`);
166
+ const kept = [...(REPLY.test(title) ? ["a reply"] : []), ...ordered(title), ...(number ? [number[0]] : [])];
167
+ if (kept.length > 0)
168
+ return { verdict: "excerpt", score: 0, cues: kept, excerpt };
169
+ const inTitle = advertising(title);
170
+ const signs = [
171
+ ...inTitle.map((word) => [word, 2]),
172
+ ...advertising(excerpt)
173
+ .filter((word) => !inTitle.includes(word))
174
+ .map((word) => [word, 1]),
175
+ ...(PICTOGRAPH.test(title) ? [["an emoji in the subject", 1]] : []),
176
+ ...(NEWSLETTER_TOP.test(excerpt) ? [["a browser or unsubscribe link", 1]] : []),
177
+ ...(fromNewsletter((from ?? "").toLowerCase()) ? [["a newsletter's address", 2]] : []),
178
+ ...ordered(excerpt).map((word) => [`-${word}`, -2]),
179
+ ];
180
+ const score = signs.reduce((sum, [, weight]) => sum + weight, 0);
181
+ return {
182
+ verdict: score >= cues.threshold ? "newsletter" : "excerpt",
183
+ score,
184
+ cues: signs.map(([cue]) => cue),
185
+ excerpt,
186
+ };
187
+ };
188
+ };
189
+ export const BULK_TRIAGE_DEFAULTS = { excerpts: true, perRead: 30, cues: NEWSLETTER_DEFAULTS };
@@ -18,8 +18,10 @@ export interface MailReviewOptions {
18
18
  now?: number;
19
19
  }
20
20
  /** A review of mail: each thread a chat, each email a stored hit with its locator. */
21
+ /** `excerptOnly`: a bulk email a holder gets as its subject and first lines, its `text` cut to them. */
21
22
  export type MailHit = StoredHit & {
22
23
  marks?: EmailMarks;
24
+ excerptOnly?: true;
23
25
  };
24
26
  export interface MailReview extends Review {
25
27
  chats: (Omit<ReviewChat, "messages"> & {
@@ -1,5 +1,6 @@
1
1
  import { MAIL_FINDINGS_KIND } from "../../../store/index.js";
2
2
  import { kept, proposerOfAuthor } from "./shared.js";
3
+ const words = (...parts) => parts.filter(Boolean).join(" ");
3
4
  /** A finding's one line in the inbox; never the email's text, only what the agent wrote. */
4
5
  const summaryOf = (finding) => {
5
6
  switch (finding.kind) {
@@ -15,6 +16,14 @@ const summaryOf = (finding) => {
15
16
  return `${finding.from} → ${finding.to}`;
16
17
  case "new_contact":
17
18
  return `${finding.name} <${finding.address}>`;
19
+ case "invoice":
20
+ return words("invoice", finding.number, finding.total, finding.currency, `· ${finding.reason}`);
21
+ case "payment":
22
+ return words("payment", finding.amount, finding.currency, finding.date, `· ${finding.reason}`);
23
+ case "order":
24
+ return words("order", finding.number, finding.supplierName, finding.status ?? "placed", `· ${finding.reason}`);
25
+ case "order_update":
26
+ return words("order", finding.number, finding.supplierName, finding.status, `· ${finding.reason}`);
18
27
  }
19
28
  };
20
29
  const rowOf = (batch, createdAt) => {
@@ -0,0 +1,4 @@
1
+ import type { MessageStore } from "../../../store/index.js";
2
+ import type { ProposalKind } from "../types.js";
3
+ /** An agent's order, by its own ref (`order:12`) as invoices are; only the owner decides it. */
4
+ export declare const orderKind: (store: Pick<MessageStore, "orders">) => ProposalKind;
@@ -0,0 +1,32 @@
1
+ import { kept, proposerOfAuthor, pushed } from "./shared.js";
2
+ const rowOf = (order) => ({
3
+ id: order.ref,
4
+ kind: "order",
5
+ status: order.review,
6
+ project: order.project,
7
+ subject: order.ref,
8
+ object: order.supplier?.ref ?? null,
9
+ summary: order.title,
10
+ proposedBy: proposerOfAuthor(order.author),
11
+ reason: order.reason,
12
+ evidence: [],
13
+ score: null,
14
+ signals: null,
15
+ parts: null,
16
+ createdAt: order.createdAt,
17
+ });
18
+ /** An agent's order, by its own ref (`order:12`) as invoices are; only the owner decides it. */
19
+ export const orderKind = (store) => ({
20
+ kind: "order",
21
+ prefix: "order",
22
+ list: async (filter) => {
23
+ const status = filter.status ?? "proposed";
24
+ const orders = await store.orders.list({ ...pushed(filter), ...(status === "all" ? {} : { review: status }) });
25
+ return kept(orders.map(rowOf), filter);
26
+ },
27
+ get: async (id) => rowOf(await store.orders.get(id)),
28
+ decide: async (id, { confirm, by, reason }) => {
29
+ const order = confirm ? await store.orders.confirm(id, by, reason) : await store.orders.reject(id, by, reason);
30
+ return { id, kind: "order", status: confirm ? "confirmed" : "rejected", made: confirm ? [order.ref] : [] };
31
+ },
32
+ });
@@ -8,6 +8,7 @@ import { keyDocumentKind } from "./kinds/key-document.js";
8
8
  import { mailFindingsKind } from "./kinds/mail-findings.js";
9
9
  import { meetingFindingsKind } from "./kinds/meeting-findings.js";
10
10
  import { memoryKind } from "./kinds/memory.js";
11
+ import { orderKind } from "./kinds/orders.js";
11
12
  import { relationshipKind } from "./kinds/relationship.js";
12
13
  import { relationshipUpdateKind } from "./kinds/relationship-update.js";
13
14
  import { taskCloseKind } from "./kinds/task-close.js";
@@ -132,6 +133,7 @@ export const proposalRegistry = (store) => {
132
133
  factKind(store),
133
134
  invoiceKind(store),
134
135
  paymentKind(store),
136
+ orderKind(store),
135
137
  memoryKind(store),
136
138
  findingsKind(store),
137
139
  mailFindingsKind(store),
@@ -53,6 +53,7 @@ export type { Author, MemoriesStore, Memory, MemoryInput } from "./sqlite/memori
53
53
  export type { NoteIndexState } from "./sqlite/note-index.js";
54
54
  export { type NearestNote, NOTE_FIELDS, type NoteChunkToEmbed, type NoteHit, type NoteQuery, type NoteSearch, } from "./sqlite/note-search.js";
55
55
  export type { FileNoteInput, Link, LinkInput, LinkListFilter, Note, NoteFolder, NotesStore, NoteTag, } from "./sqlite/notes.js";
56
+ export type { Order, OrderFilter, OrderInput, OrdersStore } from "./sqlite/orders.js";
56
57
  export type { PrivateContact, PrivateContactNote } from "./sqlite/private-people.js";
57
58
  export { assertGenericAction, PROPOSAL_STATUSES, type ProposalInput, type ProposedAction, type ProposedActionsStore, proposerOfAction, } from "./sqlite/proposed-actions.js";
58
59
  export type { RecordKey, RecordOutcome, RecordStore, RecordsWritten, SourceRecord, } from "./sqlite/records.js";
@@ -27,6 +27,7 @@ export declare const ENTITY_TABLES: {
27
27
  readonly memory: "memories";
28
28
  readonly message: "messages";
29
29
  readonly note: "notes";
30
+ readonly order: "orders";
30
31
  readonly organization: "organizations";
31
32
  readonly payment: "payments";
32
33
  readonly person: "persons";
@@ -26,6 +26,7 @@ export const ENTITY_TABLES = {
26
26
  memory: "memories",
27
27
  message: "messages",
28
28
  note: "notes",
29
+ order: "orders",
29
30
  organization: "organizations",
30
31
  payment: "payments",
31
32
  person: "persons",
@@ -11,7 +11,7 @@ export interface Party {
11
11
  /** The owner's own person row. */
12
12
  owner: boolean;
13
13
  }
14
- interface Reviewed {
14
+ export interface Reviewed {
15
15
  review: FinanceReview;
16
16
  /** `name` is a bot's handle and `kind` whether it is an agent or a rule; both `null` for a person. */
17
17
  author: {
@@ -91,7 +91,7 @@ export interface Payment extends Reviewed {
91
91
  export interface PaymentDetail extends Payment {
92
92
  allocations: Allocation[];
93
93
  }
94
- interface Written {
94
+ export interface Written {
95
95
  /** The owner's entry is confirmed at once; a bot's waits as proposed for the owner. */
96
96
  by: Author;
97
97
  project?: string;
@@ -204,6 +204,44 @@ export interface FinanceStore {
204
204
  payable: OpenTotal[];
205
205
  }>;
206
206
  }
207
+ type Row = Record<string, unknown>;
208
+ export declare const refuse: (message: string, reason: string, code?: "validation_error" | "permission_error") => never;
209
+ export declare const oneOf: <T extends string>(value: unknown, allowed: readonly T[], what: string) => T;
210
+ export declare const text: (value: unknown, what: string, limit: number) => string | null;
211
+ export declare const isoDate: (value: unknown, what: string) => string | null;
212
+ export declare const iso: (value: unknown) => string;
213
+ /** What invoices, payments and orders share: their parties, who wrote them, and how the owner decides them. */
214
+ export declare const reviewedRowsOver: (context: StoreContext) => {
215
+ partyOf: (type: unknown, id: unknown) => Party | null;
216
+ partyThing: (reference: string | undefined, what: string) => import("./things.js").Thing | undefined;
217
+ reviewedOf: (row: Row, type: "invoice" | "payment" | "order") => Reviewed;
218
+ written: (input: Written) => {
219
+ owner: boolean;
220
+ actor: Actor;
221
+ model: string | null;
222
+ source: string;
223
+ review: string;
224
+ confirmedType: "bot" | "person" | null;
225
+ confirmedId: number | null;
226
+ metadata: string | null;
227
+ project: number | null;
228
+ evidence: string[];
229
+ };
230
+ keepEvidence: (type: "invoice" | "payment" | "order", id: number, by: ReturnType<(input: Written) => {
231
+ owner: boolean;
232
+ actor: Actor;
233
+ model: string | null;
234
+ source: string;
235
+ review: string;
236
+ confirmedType: "bot" | "person" | null;
237
+ confirmedId: number | null;
238
+ metadata: string | null;
239
+ project: number | null;
240
+ evidence: string[];
241
+ }>) => void;
242
+ ownerOnly: (by: Author, what: string) => void;
243
+ keepDecision: (table: "invoices" | "payments" | "orders", id: number, by: Decider, reason?: string) => void;
244
+ };
207
245
  type FinanceOperations = {
208
246
  [K in keyof FinanceStore]: FinanceStore[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => R : never;
209
247
  };
@@ -6,22 +6,22 @@ import { proposedBySql, proposerOf } from "./actors.js";
6
6
  import { authorActor, authorOf, evidenceOf, linkEvidence, projectId, projectOf, subjectOf, } from "./memories.js";
7
7
  import { requiredThing } from "./notes.js";
8
8
  import { write } from "./transaction.js";
9
- const refuse = (message, reason, code = "validation_error") => {
9
+ export const refuse = (message, reason, code = "validation_error") => {
10
10
  throw new CliError(code, message, { reason });
11
11
  };
12
- const oneOf = (value, allowed, what) => {
12
+ export const oneOf = (value, allowed, what) => {
13
13
  if (typeof value !== "string" || !allowed.includes(value))
14
14
  return refuse(`${what} is one of ${allowed.join(", ")}`, "finance.field");
15
15
  return value;
16
16
  };
17
- const text = (value, what, limit) => {
17
+ export const text = (value, what, limit) => {
18
18
  if (value === undefined || value === null)
19
19
  return null;
20
20
  if (typeof value !== "string" || !value.trim() || value.length > limit || value.includes("\0"))
21
21
  return refuse(`${what} takes 1–${limit} characters`, "finance.field");
22
22
  return value.trim();
23
23
  };
24
- const isoDate = (value, what) => {
24
+ export const isoDate = (value, what) => {
25
25
  if (value === undefined || value === null)
26
26
  return null;
27
27
  const date = typeof value === "string" ? value.trim() : "";
@@ -29,7 +29,7 @@ const isoDate = (value, what) => {
29
29
  return refuse(`${what} is a date such as 2026-11-15`, "finance.field");
30
30
  return date;
31
31
  };
32
- const iso = (value) => new Date(Number(value)).toISOString();
32
+ export const iso = (value) => new Date(Number(value)).toISOString();
33
33
  const evidenceList = (value) => {
34
34
  if (value === undefined)
35
35
  return [];
@@ -56,8 +56,9 @@ const INVOICES = `SELECT i.*, ${PAID} AS paid, b.name AS author_name, b.kind AS
56
56
  "LEFT JOIN bots b ON i.author_type = 'bot' AND b.id = i.author_id";
57
57
  const PAYMENTS = "SELECT p.*, (SELECT coalesce(sum(amount), 0) FROM payment_allocations WHERE payment_id = p.id) AS allocated, " +
58
58
  "b.name AS author_name, b.kind AS author_kind FROM payments p LEFT JOIN bots b ON p.author_type = 'bot' AND b.id = p.author_id";
59
- export const financeOperationsOver = (context) => {
60
- const { database, now } = context;
59
+ /** What invoices, payments and orders share: their parties, who wrote them, and how the owner decides them. */
60
+ export const reviewedRowsOver = (context) => {
61
+ const { database } = context;
61
62
  const partyOf = (type, id) => {
62
63
  if (type == null)
63
64
  return null;
@@ -86,6 +87,46 @@ export const financeOperationsOver = (context) => {
86
87
  createdAt: iso(row.created_at),
87
88
  updatedAt: iso(row.updated_at),
88
89
  });
90
+ const written = (input) => {
91
+ const owner = input.by === "owner";
92
+ const { actor, model } = authorActor(context, input.by);
93
+ const source = owner ? "owner" : (text(input.source, "a source", 80) ?? "agent");
94
+ const reason = owner ? null : text(input.reason, "a reason", 2000);
95
+ return {
96
+ owner,
97
+ actor,
98
+ model,
99
+ source,
100
+ review: owner ? "confirmed" : "proposed",
101
+ confirmedType: owner ? actor.type : null,
102
+ confirmedId: owner ? actor.id : null,
103
+ metadata: reason === null ? null : JSON.stringify({ reason }),
104
+ project: input.project === undefined ? null : projectId(database, input.project),
105
+ evidence: evidenceList(input.evidence),
106
+ };
107
+ };
108
+ const keepEvidence = (type, id, by) => {
109
+ if (by.evidence.length)
110
+ linkEvidence(context, { type, id }, by.evidence, by.owner ? "owner" : "agent");
111
+ };
112
+ const ownerOnly = (by, what) => {
113
+ if (by !== "owner")
114
+ refuse(`only the owner ${what}`, "finance.owner-only", "permission_error");
115
+ };
116
+ const keepDecision = (table, id, by, reason) => {
117
+ const why = [by === "owner" ? undefined : by.reason.trim(), reason?.trim() || undefined].filter(Boolean).join(": ");
118
+ if (by === "owner" && !why)
119
+ return;
120
+ const decision = by === "owner" ? { by: "owner", reason: why } : { by: "agent", bot: by.bot, reason: why };
121
+ database
122
+ .prepare(`UPDATE ${table} SET metadata = json_set(coalesce(metadata, '{}'), '$.decision', json(?)) WHERE id = ?`)
123
+ .run(JSON.stringify(decision), id);
124
+ };
125
+ return { partyOf, partyThing, reviewedOf, written, keepEvidence, ownerOnly, keepDecision };
126
+ };
127
+ export const financeOperationsOver = (context) => {
128
+ const { database, now } = context;
129
+ const { partyOf, partyThing, reviewedOf, written, keepEvidence, ownerOnly, keepDecision } = reviewedRowsOver(context);
89
130
  const invoiceOf = (row) => {
90
131
  const currency = String(row.currency);
91
132
  const scale = scaleOf(currency);
@@ -229,41 +270,6 @@ export const financeOperationsOver = (context) => {
229
270
  .run(Number(payment.id), Number(invoice.id), units, actor.type, actor.id, at, at);
230
271
  return Number(invoice.id);
231
272
  };
232
- const written = (input) => {
233
- const owner = input.by === "owner";
234
- const { actor, model } = authorActor(context, input.by);
235
- const source = owner ? "owner" : (text(input.source, "a source", 80) ?? "agent");
236
- const reason = owner ? null : text(input.reason, "a reason", 2000);
237
- return {
238
- owner,
239
- actor,
240
- model,
241
- source,
242
- review: owner ? "confirmed" : "proposed",
243
- confirmedType: owner ? actor.type : null,
244
- confirmedId: owner ? actor.id : null,
245
- metadata: reason === null ? null : JSON.stringify({ reason }),
246
- project: input.project === undefined ? null : projectId(database, input.project),
247
- evidence: evidenceList(input.evidence),
248
- };
249
- };
250
- const keepEvidence = (type, id, by) => {
251
- if (by.evidence.length)
252
- linkEvidence(context, { type, id }, by.evidence, by.owner ? "owner" : "agent");
253
- };
254
- const ownerOnly = (by, what) => {
255
- if (by !== "owner")
256
- refuse(`only the owner ${what}`, "finance.owner-only", "permission_error");
257
- };
258
- const keepDecision = (table, id, by, reason) => {
259
- const why = [by === "owner" ? undefined : by.reason.trim(), reason?.trim() || undefined].filter(Boolean).join(": ");
260
- if (by === "owner" && !why)
261
- return;
262
- const decision = by === "owner" ? { by: "owner", reason: why } : { by: "agent", bot: by.bot, reason: why };
263
- database
264
- .prepare(`UPDATE ${table} SET metadata = json_set(coalesce(metadata, '{}'), '$.decision', json(?)) WHERE id = ?`)
265
- .run(JSON.stringify(decision), id);
266
- };
267
273
  /** A proposed payment's allocations count once confirmed; by then other confirmed payments may cover the invoice. */
268
274
  const coveredElsewhere = (paymentId) => {
269
275
  const over = database
@@ -150,7 +150,8 @@ export const knowledgeStoreOver = (context) => {
150
150
  parsed.type === "event" ||
151
151
  parsed.type === "meeting" ||
152
152
  parsed.type === "invoice" ||
153
- parsed.type === "payment")
153
+ parsed.type === "payment" ||
154
+ parsed.type === "order")
154
155
  return undefined;
155
156
  return { type: parsed.type, id: parsed.id };
156
157
  };