@wirecat/cli-core 0.566.0 → 0.568.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 (41) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/background/schedules.d.ts +35 -1
  3. package/dist/background/schedules.js +74 -8
  4. package/dist/cli/bot/command.js +5 -2
  5. package/dist/cli/bot/context.d.ts +2 -2
  6. package/dist/cli/bot/context.js +2 -2
  7. package/dist/cli/effects.d.ts +10 -0
  8. package/dist/cli/effects.js +19 -0
  9. package/dist/cli/index.d.ts +4 -3
  10. package/dist/cli/index.js +3 -2
  11. package/dist/cli/messenger/account-command.js +8 -2
  12. package/dist/cli/messenger/mcp-command.js +4 -1
  13. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  14. package/dist/cli/program.js +2 -2
  15. package/dist/cli/schedule/schedule-command.d.ts +23 -4
  16. package/dist/cli/schedule/schedule-command.js +107 -22
  17. package/dist/cli/settings.d.ts +9 -1
  18. package/dist/cli/settings.js +31 -5
  19. package/dist/entities/account-profile.d.ts +2 -0
  20. package/dist/entities/account-profile.js +2 -0
  21. package/dist/entities/index.d.ts +1 -0
  22. package/dist/errors/reasons.js +4 -1
  23. package/dist/mail/cli/marks.d.ts +2 -1
  24. package/dist/mail/cli/marks.js +13 -2
  25. package/dist/mail/cli/review.js +7 -5
  26. package/dist/mail/index.d.ts +1 -1
  27. package/dist/mail/index.js +1 -1
  28. package/dist/mail/marks.d.ts +21 -5
  29. package/dist/mail/marks.js +81 -26
  30. package/dist/meetings/findings-rules.d.ts +2 -2
  31. package/dist/meetings/findings-rules.js +15 -17
  32. package/dist/ports/app.d.ts +2 -0
  33. package/dist/ports/app.js +2 -0
  34. package/dist/services/proposals/kinds/mail-findings.d.ts +3 -2
  35. package/dist/services/proposals/kinds/mail-findings.js +3 -2
  36. package/dist/store/sqlite/mail-findings.js +10 -7
  37. package/dist/store/sqlite/work-tasks.js +1 -1
  38. package/package.json +1 -1
  39. package/parity.json +10 -0
  40. /package/dist/cli/{meeting-findings-input.d.ts → findings-input.d.ts} +0 -0
  41. /package/dist/cli/{meeting-findings-input.js → findings-input.js} +0 -0
@@ -1,12 +1,15 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { isBusy } from "../store/sqlite/transaction.js";
2
3
  /** A holder's name: what `--holder` takes and the mark's key ends with. */
3
4
  export const HOLDER = /^[a-z0-9][a-z0-9-]{0,39}$/;
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 const MARK_BATCH = 200;
6
7
  /** A partial email waits this long to be stored whole, then is skipped. */
7
8
  export const HOLD_PARTIAL_MS = 7 * 86_400_000;
8
- /** Held emails kept on one mark at most; past it the oldest are skipped as partial. */
9
+ /** Held, or suspicious, emails kept on one mark at most; past it the oldest are dropped. */
9
10
  const MOST_HELD = 500;
11
+ /** A cursor read at most this far in the future is a clock apart, not a forgery. */
12
+ const CLOCK_SLACK_MS = 60_000;
10
13
  const PREFIX = "consumer:";
11
14
  const noneSkipped = () => ({ bulk: 0, envelope: 0, partial: 0, draft: 0, suspicious: 0 });
12
15
  const keyOf = (holder) => {
@@ -24,6 +27,7 @@ const parsed = (holder, value, at) => {
24
27
  throughId: saved.throughId,
25
28
  throughAt: saved.throughAt ?? null,
26
29
  held: Array.isArray(saved.held) ? saved.held.filter((id) => typeof id === "number") : [],
30
+ suspicious: Array.isArray(saved.suspicious) ? saved.suspicious.filter((id) => typeof id === "number") : [],
27
31
  read: saved.read ?? 0,
28
32
  skipped: { ...noneSkipped(), ...saved.skipped },
29
33
  updatedAt: at,
@@ -79,34 +83,38 @@ const parseCursor = (cursor) => {
79
83
  * empty and gives the cursor of the newest stored email.
80
84
  */
81
85
  export const readPastMark = async (store, mailbox, holder, options = {}) => {
82
- const { wanted, ...read } = await examine(store, mailbox, holder, options);
86
+ const { wanted, oldestStoredAt: _, ...read } = await examine(store, mailbox, holder, options);
83
87
  return { emails: await store.mail.emailHits(wanted), ...read };
84
88
  };
85
89
  /** `readPastMark` without the emails' text: the store ids it would read, by their verdicts. */
86
- const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_BATCH }) => {
87
- const mark = await markOf(store, mailbox, holder);
90
+ const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_BATCH, after }) => {
91
+ const saved = await markOf(store, mailbox, holder);
88
92
  const account = await accountOf(store, mailbox);
89
- const empty = { wanted: [], more: false, held: 0, skipped: noneSkipped(), mark };
93
+ const empty = { wanted: [], oldestStoredAt: null, more: false, held: 0, skipped: noneSkipped(), mark: saved };
90
94
  if (!account)
91
95
  return { ...empty, cursor: cursorOf(0, now) };
92
- if (!mark)
96
+ if (!saved)
93
97
  return { ...empty, cursor: cursorOf(await store.mail.newestEmailId(account.id), now) };
94
- const found = await store.mail.emailsAfter(account.id, mark.throughId, limit + 1, mark.held);
98
+ const mark = after === undefined ? saved : { ...saved, throughId: after, held: [], suspicious: [] };
99
+ const found = await store.mail.emailsAfter(account.id, mark.throughId, limit + 1, [...mark.held, ...mark.suspicious]);
95
100
  // Mail a running sync stored is read once that sync ended: it files bulk mail only at its end.
96
101
  const settled = await store.sync.syncState(mailbox, "mail_coverage");
97
102
  const settledAt = settled ? Date.parse(settled.at) : Number.POSITIVE_INFINITY;
98
- const after = found.filter((email) => email.id > mark.throughId);
99
- const unsettled = after.findIndex((email) => email.storedAt > settledAt);
100
- const fresh = unsettled === -1 ? after : after.slice(0, unsettled);
103
+ const newer = found.filter((email) => email.id > mark.throughId);
104
+ const unsettled = newer.findIndex((email) => email.storedAt > settledAt);
105
+ const fresh = unsettled === -1 ? newer : newer.slice(0, unsettled);
101
106
  const batch = fresh.slice(0, limit);
102
107
  const examined = [...found.filter((email) => email.id <= mark.throughId), ...batch];
103
108
  const skipped = noneSkipped();
104
109
  const wanted = [];
110
+ let oldestStoredAt = null;
105
111
  let held = 0;
106
112
  for (const email of examined) {
107
113
  const said = verdict(email, now);
108
- if (said === "read")
114
+ if (said === "read") {
109
115
  wanted.push(email.id);
116
+ oldestStoredAt = Math.min(oldestStoredAt ?? email.storedAt, email.storedAt);
117
+ }
110
118
  else if (said === "held")
111
119
  held += 1;
112
120
  else if (email.id > mark.throughId)
@@ -114,35 +122,68 @@ const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_
114
122
  }
115
123
  return {
116
124
  wanted: wanted.sort((a, b) => a - b),
125
+ oldestStoredAt,
117
126
  cursor: cursorOf(batch.at(-1)?.id ?? mark.throughId, now),
118
- more: after.length > batch.length,
127
+ more: newer.length > batch.length,
119
128
  held,
120
129
  skipped,
121
- mark,
130
+ mark: saved,
122
131
  };
123
132
  };
124
133
  /**
125
- * Whether the holder has mail to read in any stored mailbox — what a schedule asks before it starts the holder's
126
- * agent, so a run with nothing new costs no model call. Mail that is only skipped (bulk, envelopes, drafts,
127
- * suspicious senders) moves the holder's mark here, as the agent would have acknowledged it, or it would wait
128
- * forever behind the mark. A holder with no mark yet has mail to read: its first run sets the mark.
134
+ * 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.
137
+ */
138
+ export const mailWaiting = async (store, holder, now = Date.now()) => {
139
+ const waiting = { unmarked: false, emails: 0, oldestStoredAt: null };
140
+ for (const mailbox of await store.accounts.accounts()) {
141
+ if (mailbox.provider !== "email")
142
+ continue;
143
+ let after;
144
+ for (;;) {
145
+ const read = await examine(store, mailbox, holder, { now, ...(after === undefined ? {} : { after }) });
146
+ if (!read.mark) {
147
+ waiting.unmarked = true;
148
+ break;
149
+ }
150
+ waiting.emails += read.wanted.length;
151
+ if (read.oldestStoredAt !== null)
152
+ waiting.oldestStoredAt = Math.min(waiting.oldestStoredAt ?? read.oldestStoredAt, read.oldestStoredAt);
153
+ const through = Number(read.cursor.split(".")[0]);
154
+ if (!read.more || through === (after ?? read.mark.throughId))
155
+ break;
156
+ after = through;
157
+ }
158
+ }
159
+ return waiting;
160
+ };
161
+ /**
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
164
+ * does not read it all again. Suspicious senders' emails stay on the mark to be offered again. It stops at the first
165
+ * email the holder reads. A store busy with a sync is left alone: the next check moves it.
129
166
  */
130
- export const holderHasMail = async (store, holder, now = Date.now()) => {
167
+ export const advancePastSkipped = async (store, holder, now = Date.now()) => {
131
168
  for (const mailbox of await store.accounts.accounts()) {
132
169
  if (mailbox.provider !== "email")
133
170
  continue;
134
171
  for (;;) {
135
172
  const read = await examine(store, mailbox, holder, { now });
136
- if (!read.mark || read.wanted.length > 0)
137
- return true;
138
- if (read.cursor === cursorOf(read.mark.throughId, now))
173
+ if (!read.mark || read.wanted.length > 0 || read.cursor === cursorOf(read.mark.throughId, now))
139
174
  break;
140
- await acknowledgeMark(store, mailbox, holder, read.cursor, now);
175
+ try {
176
+ await acknowledgeMark(store, mailbox, holder, read.cursor, now);
177
+ }
178
+ catch (error) {
179
+ if (isBusy(error))
180
+ return;
181
+ throw error;
182
+ }
141
183
  if (!read.more)
142
184
  break;
143
185
  }
144
186
  }
145
- return false;
146
187
  };
147
188
  /**
148
189
  * Moves the holder's mark to the cursor its read gave, never back. An email whole when it was read counts read; one
@@ -155,6 +196,11 @@ export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date
155
196
  const account = await accountOf(store, mailbox);
156
197
  if (!account)
157
198
  throw new CliError("not_found", `no mail of ${mailbox.account} is stored`);
199
+ const newest = await store.mail.newestEmailId(account.id);
200
+ if (throughId > newest || readAt > now + CLOCK_SLACK_MS)
201
+ throw new CliError("validation_error", `--cursor ${cursor} is past the newest stored email; acknowledge a cursor review gave`, {
202
+ reason: "mail.cursor-ahead",
203
+ });
158
204
  const mark = await markOf(store, mailbox, holder);
159
205
  const from = mark?.throughId ?? throughId;
160
206
  if (throughId < from)
@@ -162,7 +208,8 @@ export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date
162
208
  const examined = [];
163
209
  let after = from;
164
210
  const held = new Set(mark?.held ?? []);
165
- const again = await store.mail.emailsAfter(account.id, Number.MAX_SAFE_INTEGER, 0, [...held]);
211
+ const doubted = new Set(mark?.suspicious ?? []);
212
+ const again = await store.mail.emailsAfter(account.id, Number.MAX_SAFE_INTEGER, 0, [...held, ...doubted]);
166
213
  examined.push(...again);
167
214
  while (after < throughId) {
168
215
  const page = (await store.mail.emailsAfter(account.id, after, MARK_BATCH)).filter((one) => one.id <= throughId);
@@ -174,6 +221,7 @@ export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date
174
221
  const skipped = { ...(mark?.skipped ?? noneSkipped()) };
175
222
  let read = mark?.read ?? 0;
176
223
  const keep = [];
224
+ const suspicious = [];
177
225
  const live = new Set(again.map((one) => one.id));
178
226
  for (const id of held)
179
227
  if (!live.has(id))
@@ -181,6 +229,7 @@ export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date
181
229
  for (const email of examined) {
182
230
  const said = verdict(email, readAt);
183
231
  const wasHeld = held.has(email.id);
232
+ const wasDoubted = doubted.has(email.id) && !wasHeld;
184
233
  if (said === "held" || (said === "read" && email.changedAt > readAt) || (wasHeld && email.changedAt > readAt)) {
185
234
  if (email.partial && now - email.storedAt >= HOLD_PARTIAL_MS)
186
235
  skipped.partial += 1;
@@ -188,8 +237,13 @@ export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date
188
237
  keep.push(email.id);
189
238
  }
190
239
  else if (said === "read")
191
- read += wasHeld || email.id > from ? 1 : 0;
192
- else if (!wasHeld || said === "partial")
240
+ read += wasHeld || wasDoubted || email.id > from ? 1 : 0;
241
+ else if (said === "suspicious") {
242
+ suspicious.push(email.id);
243
+ if (!wasDoubted && !wasHeld)
244
+ skipped.suspicious += 1;
245
+ }
246
+ else if (!wasDoubted && (!wasHeld || said === "partial"))
193
247
  skipped[said] += 1;
194
248
  }
195
249
  const kept = [...new Set(keep)].sort((a, b) => a - b);
@@ -199,6 +253,7 @@ export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date
199
253
  throughId,
200
254
  throughAt: through?.id === throughId ? through.at : (mark?.throughAt ?? null),
201
255
  held: kept.slice(-MOST_HELD),
256
+ suspicious: [...new Set(suspicious)].sort((a, b) => a - b).slice(-MOST_HELD),
202
257
  read,
203
258
  skipped,
204
259
  };
@@ -59,7 +59,9 @@ export interface FindingsReceipt {
59
59
  accepted: string[];
60
60
  items: FindingOutcome[];
61
61
  }
62
+ export declare const findingText: (value: unknown, limit: number) => string;
62
63
  export declare const findingHash: (value: unknown) => string;
64
+ export declare const findingFields: (value: object, allowed: string[], noun: string) => void;
63
65
  /** What differs between findings kinds: their wording and what one cited reference must look like. */
64
66
  export interface FindingsRules {
65
67
  /** "meeting findings" */
@@ -97,7 +99,5 @@ export declare const validateBatch: <F>(input: {
97
99
  findings: F[];
98
100
  };
99
101
  export declare const validateFindings: (input: FindingsInput, rules: FindingsRules) => FindingsInput;
100
- export declare const findingText: (value: unknown, limit: number) => string;
101
- export declare const findingFields: (value: object, allowed: string[], noun: string) => void;
102
102
  export declare const findingFingerprint: ({ id: _id, ...finding }: Finding) => string;
103
103
  export {};
@@ -6,13 +6,13 @@ const TASK_TYPES = ["bug", "feature", "chore"];
6
6
  const invalid = (message) => {
7
7
  throw new CliError("validation_error", message);
8
8
  };
9
- const text = (value, limit) => {
9
+ export const findingText = (value, limit) => {
10
10
  if (typeof value !== "string" || !value.trim() || value.includes("\0") || value.length > limit)
11
11
  return invalid(`finding text must contain 1–${limit} characters without NUL`);
12
12
  return value.trim();
13
13
  };
14
14
  export const findingHash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
15
- const fields = (value, allowed, noun) => {
15
+ export const findingFields = (value, allowed, noun) => {
16
16
  if (Object.keys(value).some((key) => !allowed.includes(key)))
17
17
  invalid(`unknown ${noun} field`);
18
18
  };
@@ -25,7 +25,7 @@ export const validEvidence = (raw, rules) => {
25
25
  .map((item) => {
26
26
  if (!item || typeof item !== "object")
27
27
  return invalid("finding evidence is a reference and fingerprint");
28
- fields(item, ["reference", "fingerprint"], rules.singular);
28
+ findingFields(item, ["reference", "fingerprint"], rules.singular);
29
29
  if (typeof item.reference !== "string" || typeof item.fingerprint !== "string")
30
30
  return invalid("evidence requires a reference and fingerprint");
31
31
  const reference = rules.reference(item.reference);
@@ -51,10 +51,10 @@ export const validateFinding = (finding, rules, ids) => {
51
51
  if (!finding || typeof finding !== "object")
52
52
  return invalid("a finding is an object");
53
53
  const id = findingId(finding.id, ids);
54
- const project = finding.project === undefined && rules.anySubject ? undefined : text(finding.project, 64);
54
+ const project = finding.project === undefined && rules.anySubject ? undefined : findingText(finding.project, 64);
55
55
  if (project !== undefined && !/^project:[1-9]\d*$/.test(project))
56
56
  return invalid("a finding names a project reference");
57
- if (finding.subject !== undefined && (!rules.anySubject || !SUBJECT.test(text(finding.subject, 80))))
57
+ if (finding.subject !== undefined && (!rules.anySubject || !SUBJECT.test(findingText(finding.subject, 80))))
58
58
  return invalid("a finding's subject is a person:, organization:, task: or project: reference");
59
59
  if (finding.kind === "task" && project === undefined && !rules.inboxTasks)
60
60
  return invalid("a task finding names the project the task goes in");
@@ -66,15 +66,15 @@ export const validateFinding = (finding, rules, ids) => {
66
66
  evidence,
67
67
  };
68
68
  if (finding.kind === "fact") {
69
- fields(finding, ["id", "project", "subject", "evidence", "kind", "type", "statement"], rules.singular);
69
+ findingFields(finding, ["id", "project", "subject", "evidence", "kind", "type", "statement"], rules.singular);
70
70
  if (finding.type === "payment")
71
71
  return invalid("a payment is a finding of kind payment; money is a payments row, not a fact");
72
72
  if (!FACT_TYPES.includes(finding.type))
73
73
  return invalid("unknown fact finding type");
74
- return { ...base, kind: "fact", type: finding.type, statement: text(finding.statement, 2000) };
74
+ return { ...base, kind: "fact", type: finding.type, statement: findingText(finding.statement, 2000) };
75
75
  }
76
76
  if (finding.kind === "payment" && rules.payments) {
77
- fields(finding, ["id", "project", "subject", "evidence", "kind", "statement", "payment", "supersedes"], rules.singular);
77
+ findingFields(finding, ["id", "project", "subject", "evidence", "kind", "statement", "payment", "supersedes"], rules.singular);
78
78
  if (finding.supersedes !== undefined && !/^invoice:[1-9]\d*$/.test(String(finding.supersedes)))
79
79
  return invalid("a payment finding supersedes an invoice: reference, the expected payment it settles or voids");
80
80
  const payment = validatePayment(finding.payment);
@@ -87,14 +87,14 @@ export const validateFinding = (finding, rules, ids) => {
87
87
  return {
88
88
  ...base,
89
89
  kind: "payment",
90
- ...(finding.statement === undefined ? {} : { statement: text(finding.statement, 2000) }),
90
+ ...(finding.statement === undefined ? {} : { statement: findingText(finding.statement, 2000) }),
91
91
  payment,
92
92
  ...(finding.supersedes === undefined ? {} : { supersedes: finding.supersedes }),
93
93
  };
94
94
  }
95
95
  if (finding.kind !== "task")
96
96
  return invalid(`a finding is a fact${rules.payments ? ", payment" : ""} or task`);
97
- fields(finding, [
97
+ findingFields(finding, [
98
98
  "id",
99
99
  "project",
100
100
  "evidence",
@@ -122,8 +122,8 @@ export const validateFinding = (finding, rules, ids) => {
122
122
  ...base,
123
123
  kind: "task",
124
124
  type: finding.type,
125
- title: text(finding.title, 300),
126
- reason: text(finding.reason, 2000),
125
+ title: findingText(finding.title, 300),
126
+ reason: findingText(finding.reason, 2000),
127
127
  commitment: finding.commitment,
128
128
  ...(finding.dueAt === undefined
129
129
  ? {}
@@ -149,20 +149,18 @@ export const support = (value, evidence) => {
149
149
  export const validateBatch = (input, rules, most, one) => {
150
150
  if (!input || typeof input !== "object" || Buffer.byteLength(JSON.stringify(input), "utf8") > 65536)
151
151
  return invalid(`${rules.plural} take at most 65536 UTF-8 bytes`);
152
- fields(input, ["schemaVersion", "key", "extractor", "findings"], rules.singular);
152
+ findingFields(input, ["schemaVersion", "key", "extractor", "findings"], rules.singular);
153
153
  if (input.schemaVersion !== 1 ||
154
154
  !Array.isArray(input.findings) ||
155
155
  input.findings.length < 1 ||
156
156
  input.findings.length > most)
157
157
  return invalid(`schemaVersion 1 takes 1–${most} ${rules.plural}`);
158
- const key = text(input.key, 64);
158
+ const key = findingText(input.key, 64);
159
159
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(key))
160
160
  return invalid("a findings key uses letters, digits, dot, colon, underscore or hyphen");
161
161
  const ids = new Set();
162
162
  const findings = input.findings.map((finding) => one(finding, ids));
163
- return { schemaVersion: 1, key, extractor: text(input.extractor, 200), findings };
163
+ return { schemaVersion: 1, key, extractor: findingText(input.extractor, 200), findings };
164
164
  };
165
165
  export const validateFindings = (input, rules) => validateBatch(input, rules, 16, (finding, ids) => validateFinding(finding, rules, ids));
166
- export const findingText = (value, limit) => text(value, limit);
167
- export const findingFields = (value, allowed, noun) => fields(value, allowed, noun);
168
166
  export const findingFingerprint = ({ id: _id, ...finding }) => findingHash(finding);
@@ -38,6 +38,8 @@ export interface AppIdentity {
38
38
  export declare const envName: (app: AppIdentity, name: string) => string;
39
39
  /** The variable that locks this process read-only, when it is set to anything but "0". */
40
40
  export declare const readOnlyLockOf: (app: AppIdentity, env: NodeJS.ProcessEnv) => string | undefined;
41
+ /** The holder a scheduled run was started for (`schedule agent --on-new-mail`): the only mark that run may move. */
42
+ export declare const mailHolderVariable: (app: AppIdentity) => string;
41
43
  export declare const MESSENGER_CHANGE_REFUSAL = "send, react, change chats or change the account";
42
44
  /** The one wording for a write `<PREFIX>_READ_ONLY` stops: `what` finishes "it cannot …". */
43
45
  export declare const readOnlyRefusal: (lock: string, what: string) => CliError;
package/dist/ports/app.js CHANGED
@@ -6,6 +6,8 @@ export const readOnlyLockOf = (app, env) => {
6
6
  const value = env[name];
7
7
  return value === undefined || value.trim() === "" || value === "0" ? undefined : name;
8
8
  };
9
+ /** The holder a scheduled run was started for (`schedule agent --on-new-mail`): the only mark that run may move. */
10
+ export const mailHolderVariable = (app) => envName(app, "MAIL_HOLDER");
9
11
  export const MESSENGER_CHANGE_REFUSAL = "send, react, change chats or change the account";
10
12
  /** The one wording for a write `<PREFIX>_READ_ONLY` stops: `what` finishes "it cannot …". */
11
13
  export const readOnlyRefusal = (lock, what) => new CliError("permission_error", `this process is read-only (${lock}) — it cannot ${what}`);
@@ -1,8 +1,9 @@
1
1
  import { type MessageStore } from "../../../store/index.js";
2
2
  import type { ProposalKind } from "../types.js";
3
3
  /**
4
- * What an agent read in new mail, one batch per mailbox and run. Only the owner decides it (NEED-2187): an agent's
5
- * confirmation is refused whatever `agent_decisions` says. Confirming accepts the parts named, or every finding not
4
+ * What an agent read in new mail, one batch per mailbox and run. Only the owner decides it — mail is text a stranger
5
+ * wrote, so an agent that read it must not also approve what it made of it: an agent's confirmation is refused
6
+ * whatever `agent_decisions` says. Confirming accepts the parts named, or every finding not
6
7
  * rejected before; the rest are rejected.
7
8
  */
8
9
  export declare const mailFindingsKind: (store: Pick<MessageStore, "mailFindings" | "proposedActions">) => ProposalKind;
@@ -44,8 +44,9 @@ const rowOf = (batch, createdAt) => {
44
44
  };
45
45
  };
46
46
  /**
47
- * What an agent read in new mail, one batch per mailbox and run. Only the owner decides it (NEED-2187): an agent's
48
- * confirmation is refused whatever `agent_decisions` says. Confirming accepts the parts named, or every finding not
47
+ * What an agent read in new mail, one batch per mailbox and run. Only the owner decides it — mail is text a stranger
48
+ * wrote, so an agent that read it must not also approve what it made of it: an agent's confirmation is refused
49
+ * whatever `agent_decisions` says. Confirming accepts the parts named, or every finding not
49
50
  * rejected before; the rest are rejected.
50
51
  */
51
52
  export const mailFindingsKind = (store) => {
@@ -82,9 +82,10 @@ export const mailFindingsStoreOver = (context) => {
82
82
  };
83
83
  /**
84
84
  * Reads each cited email once: it must be stored, whole, and hash to the fingerprint given. Every finding cites
85
- * mail of the batch's own mailbox, and no sender other than the owner is behind more than five findings.
85
+ * mail of the batch's own mailbox, and no sender other than the owner is behind more than five findings. An email
86
+ * gone by the time the owner decides changed since it was read; one never stored was never read.
86
87
  */
87
- const cite = (findings, account) => {
88
+ const cite = (findings, account, deciding = false) => {
88
89
  const read = new Map();
89
90
  const perSender = new Map();
90
91
  let bytes = 0;
@@ -96,8 +97,12 @@ export const mailFindingsStoreOver = (context) => {
96
97
  if (!row) {
97
98
  const at = parseLocator(reference);
98
99
  row = database.prepare(EMAIL).get(at.account, at.chat, at.message);
99
- if (!row)
100
+ if (!row && deciding)
100
101
  return stale();
102
+ if (!row)
103
+ throw new CliError("not_found", `no stored email ${reference}; cite only mail a review gave`, {
104
+ reason: "mail-findings.not-stored",
105
+ });
101
106
  bytes += Buffer.byteLength(String(row.body)) + Buffer.byteLength(String(row.subject ?? ""));
102
107
  if (bytes > TEXT_BUDGET)
103
108
  return invalid(`finding evidence exceeds the ${TEXT_BUDGET}-byte stored text budget`);
@@ -227,9 +232,7 @@ export const mailFindingsStoreOver = (context) => {
227
232
  }
228
233
  case "new_contact": {
229
234
  const person = Number(identityOfAddress(finding.address)?.person_id);
230
- database
231
- .prepare("UPDATE persons SET name = ?, updated_at = ? WHERE id = ? AND name IS NULL")
232
- .run(finding.name, now(), person);
235
+ database.prepare("UPDATE persons SET name = ?, updated_at = ? WHERE id = ?").run(finding.name, now(), person);
233
236
  return `person:${person}`;
234
237
  }
235
238
  }
@@ -303,7 +306,7 @@ export const mailFindingsStoreOver = (context) => {
303
306
  // accepted findings' own mail unchanged.
304
307
  const chosen = batch.input.findings.filter((finding) => accepted.includes(finding.id));
305
308
  if (chosen.length)
306
- cite(chosen, account);
309
+ cite(chosen, account, true);
307
310
  if (proposal.proposedBy.type !== "bot" || !proposal.proposedBy.name)
308
311
  return invalid("a findings author must be an agent");
309
312
  const agent = {
@@ -68,7 +68,7 @@ const timesAsIso = (changes) => Object.fromEntries(Object.entries(changes).map((
68
68
  export const checkRevision = (row, expected) => {
69
69
  if (expected === undefined || Number(row.revision) === expected)
70
70
  return;
71
- throw new CliError("configuration_error", `task ${String(row.key)} changed since revision ${expected}; it is at ${String(row.revision)}: read it again`, { reason: "task.stale", revision: Number(row.revision) });
71
+ throw new CliError("conflict", `task ${String(row.key)} changed since revision ${expected}; it is at ${String(row.revision)}: read it again`, { reason: "task.stale", revision: Number(row.revision) });
72
72
  };
73
73
  export const workTaskOperationsOver = (context) => {
74
74
  const { database, now } = context;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.566.0",
3
+ "version": "0.568.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
package/parity.json CHANGED
@@ -565,6 +565,10 @@
565
565
  "value": "<usd>",
566
566
  "meaning": "stop an agent's run that costs more than this, in US dollars"
567
567
  },
568
+ "--max-cost-per-day": {
569
+ "value": "<usd>",
570
+ "meaning": "start no agent run for the rest of the day once its runs cost this much, in US dollars"
571
+ },
568
572
  "--max-gaps": {
569
573
  "value": "<n>",
570
574
  "meaning": "maximum interior coverage gaps repaired in this run"
@@ -3962,6 +3966,12 @@
3962
3966
  "planned": {
3963
3967
  "wa": "wa backlog: mount the shared schedule command"
3964
3968
  }
3969
+ },
3970
+ "--max-cost-per-day": {
3971
+ "in": ["max", "tg"],
3972
+ "planned": {
3973
+ "wa": "wa backlog: mount the shared schedule command"
3974
+ }
3965
3975
  }
3966
3976
  }
3967
3977
  },