@wirecat/cli-core 0.472.0 → 0.474.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 (40) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/dist/background/after-upgrade.js +6 -4
  3. package/dist/cli/execution.d.ts +5 -0
  4. package/dist/cli/execution.js +5 -0
  5. package/dist/cli/help/command-help.js +3 -0
  6. package/dist/cli/help/first-screen.js +5 -0
  7. package/dist/cli/messenger/conversation-answers.d.ts +1 -0
  8. package/dist/cli/messenger/hearing-command.js +1 -1
  9. package/dist/cli/messenger/message-answers.d.ts +1 -0
  10. package/dist/cli/messenger/message-items.js +2 -0
  11. package/dist/cli/messenger/message-writes.d.ts +2 -0
  12. package/dist/cli/program.js +5 -3
  13. package/dist/entities/conversation-results.d.ts +2 -0
  14. package/dist/entities/evidence.d.ts +8 -0
  15. package/dist/entities/index.d.ts +19 -0
  16. package/dist/entities/knowledge-lists.d.ts +1 -0
  17. package/dist/entities/knowledge-lists.js +1 -0
  18. package/dist/entities/listener.d.ts +4 -0
  19. package/dist/entities/message-context.d.ts +3 -0
  20. package/dist/entities/message-write.d.ts +4 -0
  21. package/dist/entities/message.d.ts +1 -0
  22. package/dist/entities/message.js +1 -0
  23. package/dist/entities/store-records.d.ts +1 -0
  24. package/dist/mail/source.d.ts +7 -0
  25. package/dist/mail/sync.d.ts +16 -1
  26. package/dist/mail/sync.js +187 -11
  27. package/dist/mail/testing/fake.d.ts +11 -0
  28. package/dist/mail/testing/fake.js +28 -8
  29. package/dist/runs/error-detail.d.ts +2 -0
  30. package/dist/runs/error-detail.js +4 -0
  31. package/dist/speech/hearing.d.ts +2 -0
  32. package/dist/speech/hearing.js +10 -0
  33. package/dist/store/knowledge-query.js +3 -1
  34. package/dist/store/people.d.ts +2 -0
  35. package/dist/store/people.js +12 -0
  36. package/dist/store/sqlite/knowledge.d.ts +3 -1
  37. package/dist/store/sqlite/knowledge.js +7 -2
  38. package/dist/store/sqlite/task-keys.d.ts +1 -0
  39. package/dist/store/sqlite/task-keys.js +1 -0
  40. package/package.json +1 -1
@@ -11,6 +11,7 @@ export declare const message: v.StrictObjectSchema<{
11
11
  readonly sender_name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
12
12
  readonly sender_username: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
13
13
  readonly sender_is_chat: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
14
+ readonly sender_is_me: v.BooleanSchema<undefined>;
14
15
  readonly mentions: v.OptionalSchema<v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>, undefined>;
15
16
  /** When it was sent. */
16
17
  readonly timestamp: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
@@ -23,6 +23,7 @@ export const message = v.strictObject({
23
23
  sender_name: v.nullable(text),
24
24
  sender_username: v.optional(text),
25
25
  sender_is_chat: v.optional(v.boolean()),
26
+ sender_is_me: v.boolean(),
26
27
  mentions: v.optional(v.array(id)),
27
28
  /** When it was sent. */
28
29
  timestamp: time,
@@ -35,6 +35,7 @@ export declare const storeExport: v.UnionSchema<[v.StrictObjectSchema<{
35
35
  readonly sender_name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
36
36
  readonly sender_username: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
37
37
  readonly sender_is_chat: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
38
+ readonly sender_is_me: v.BooleanSchema<undefined>;
38
39
  readonly mentions: v.OptionalSchema<v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>, undefined>;
39
40
  readonly timestamp: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
40
41
  readonly edited_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -23,6 +23,13 @@ export interface Searched {
23
23
  }
24
24
  /** Everything the import asks of a mail client, so a transport can be replaced without touching the sync. */
25
25
  export interface MailSource {
26
+ /**
27
+ * How many messages one `list` call reads in about one request's time, parallel connections included; a budgeted
28
+ * sync lists that many at a time, so a request of the budget is one call. Absent: 200.
29
+ */
30
+ listBatch?: number;
31
+ /** The same for `fetchRaw`. Absent: 50. */
32
+ fetchBatch?: number;
26
33
  /**
27
34
  * Every message in `folder` received since `since`; all or nothing, since deletions are proved from it.
28
35
  * `headers` also reads From, To, Cc and the list fields, for the probe and the owner's rules.
@@ -1,3 +1,4 @@
1
+ import type { BudgetStop, RunBudget } from "../background/budget.js";
1
2
  import type { LoadEngine } from "../readers/types.js";
2
3
  import type { AccountKey, MessageStore } from "../store/index.js";
3
4
  import { type TagOutcome } from "./rule-tags.js";
@@ -31,6 +32,14 @@ export interface MailSyncResult {
31
32
  deleted: number;
32
33
  /** False when `max` stopped the run before every new message was read; the next run continues. */
33
34
  complete: boolean;
35
+ /** `partial`: a budgeted run stopped before the whole window was read; the next run goes on. */
36
+ state?: "partial" | "complete";
37
+ /** Why a budgeted run stopped early: its request budget, its deadline, or a cancel. */
38
+ stopped?: BudgetStop;
39
+ /** Messages of the window this run did not get to read; a budgeted run only. */
40
+ remaining?: number;
41
+ /** Requests a budgeted run spent. */
42
+ requests?: number;
34
43
  /** Emails that could not be read or parsed; they are tried again next run. */
35
44
  unreadable?: {
36
45
  message: string;
@@ -85,7 +94,13 @@ export interface MailSyncOptions {
85
94
  index?: (store: MessageStore, key: AccountKey) => Promise<MailIndexed>;
86
95
  /** Stops between batches of emails, after saving the resume point. */
87
96
  signal?: AbortSignal;
97
+ /**
98
+ * Makes the run budgeted: it reads the window in rounds, one request of the budget per call to the source, saves
99
+ * after each, stops when the budget says so and answers `state: "partial"`. A message the source could not give
100
+ * is tried once more at the end of the run and, if it still fails, left for the next one. `max` defaults to no limit.
101
+ */
102
+ budget?: RunBudget;
88
103
  }
89
- export declare const syncMail: (source: MailSource, store: MessageStore, { account, since, max, maxBodyChars, maxAttachmentChars, now, ages, loadEngine, retryAttachments, rescan, resumeFrom, index, signal, }: MailSyncOptions) => Promise<MailSyncResult>;
104
+ export declare const syncMail: (source: MailSource, store: MessageStore, options: MailSyncOptions) => Promise<MailSyncResult>;
90
105
  /** Failures a newer install mends: the reader was missing, not the file at fault, so reading again can succeed. */
91
106
  export declare const RETRYABLE_ATTACHMENTS: string[];
package/dist/mail/sync.js CHANGED
@@ -53,7 +53,100 @@ const readAttachments = async (mail, messageId, { loadEngine, maxAttachmentChars
53
53
  }
54
54
  return files;
55
55
  };
56
- export const syncMail = async (source, store, { account, since, max = 200, maxBodyChars = MAX_BODY_CHARS, maxAttachmentChars = MAX_ATTACHMENT_CHARS, now = Date.now, ages, loadEngine = importEngine, retryAttachments = false, rescan = false, resumeFrom = RESUME_FROM, index, signal, }) => {
56
+ const LIST_SLICE = 200;
57
+ const FETCH_SLICE = 50;
58
+ const keyOf = (folder, uid) => `${folder}\0${uid}`;
59
+ export const syncMail = async (source, store, options) => {
60
+ const { budget, index } = options;
61
+ if (budget === undefined || options.retryAttachments || options.account.mode === "mbox")
62
+ return syncRound(source, store, options);
63
+ const plan = {
64
+ budget,
65
+ tried: new Set(),
66
+ failed: new Set(),
67
+ searches: new Map(),
68
+ modes: new Map(),
69
+ unreadable: new Map(),
70
+ unlisted: new Set(),
71
+ stopped: null,
72
+ remaining: 0,
73
+ asked: 0,
74
+ };
75
+ const rounds = [];
76
+ const goOn = async () => {
77
+ for (;;) {
78
+ plan.asked = 0;
79
+ plan.remaining = 0;
80
+ try {
81
+ rounds.push(await syncRound(source, store, { ...options, index: undefined, max: options.max ?? Number.POSITIVE_INFINITY }, plan));
82
+ }
83
+ catch (error) {
84
+ // A request cut at the deadline, or failed after the budget was spent, is the run's end, not its failure: what
85
+ // earlier rounds saved is kept and the next run goes on. While the budget has room the error is real.
86
+ const transient = error instanceof CliError && (error.code === "timeout" || error.code === "network_error");
87
+ const stop = transient && rounds.length > 0 ? budget.take() : null;
88
+ if (stop === null)
89
+ throw error;
90
+ plan.stopped = stop;
91
+ plan.remaining = Math.max(plan.remaining, 1);
92
+ return;
93
+ }
94
+ if (plan.stopped !== null || plan.remaining === 0)
95
+ return;
96
+ }
97
+ };
98
+ await goOn();
99
+ const remaining = plan.remaining;
100
+ // A message the server failed on once is often fine a minute later; only one more try, then the next run.
101
+ if (plan.stopped === null && plan.failed.size > 0) {
102
+ plan.only = new Set(plan.failed);
103
+ plan.failed.clear();
104
+ plan.tried.clear();
105
+ await goOn();
106
+ }
107
+ const last = rounds.at(-1);
108
+ const sum = (pick) => rounds.reduce((total, round) => total + pick(round), 0);
109
+ const unreadable = [
110
+ ...[...plan.unreadable].map(([message, reason]) => ({ message, reason })),
111
+ ...[...plan.unlisted].map((key) => ({
112
+ message: `uid:${key.replace("\0", ":")}`,
113
+ reason: "the server did not return it",
114
+ })),
115
+ ];
116
+ const complete = last.complete && plan.stopped === null && unreadable.length === 0 && remaining === 0;
117
+ const account = { provider: "email", account: options.account.address.toLowerCase() };
118
+ const indexed = plan.stopped === null ? await index?.(store, account) : undefined;
119
+ const deletionsSkipped = rounds.map((round) => round.deletionsSkipped).findLast((text) => text !== undefined);
120
+ const { unreadable: _unreadable, ...rest } = last;
121
+ return {
122
+ ...rest,
123
+ listed: sum((round) => round.listed),
124
+ saved: sum((round) => round.saved),
125
+ alreadyStored: sum((round) => round.alreadyStored),
126
+ skipped: sum((round) => round.skipped),
127
+ envelopes: sum((round) => round.envelopes),
128
+ deleted: sum((round) => round.deleted),
129
+ complete,
130
+ state: complete ? "complete" : "partial",
131
+ remaining,
132
+ requests: budget.used,
133
+ ...(plan.stopped === null ? {} : { stopped: plan.stopped }),
134
+ ...(indexed === undefined ? {} : { indexed }),
135
+ ...(unreadable.length ? { unreadable } : {}),
136
+ ...(deletionsSkipped === undefined ? {} : { deletionsSkipped }),
137
+ ...(rounds.some((round) => round.pass === "resumed") ? { pass: "resumed" } : {}),
138
+ ...(rounds.some((round) => round.attachments)
139
+ ? { attachments: rounds.flatMap((round) => round.attachments ?? []) }
140
+ : {}),
141
+ ...(rounds.some((round) => round.tagsReused)
142
+ ? { tagsReused: rounds.flatMap((round) => round.tagsReused ?? []) }
143
+ : {}),
144
+ ...(rounds.some((round) => round.tagsSkipped)
145
+ ? { tagsSkipped: rounds.flatMap((round) => round.tagsSkipped ?? []) }
146
+ : {}),
147
+ };
148
+ };
149
+ const syncRound = async (source, store, { account, since, max = 200, maxBodyChars = MAX_BODY_CHARS, maxAttachmentChars = MAX_ATTACHMENT_CHARS, now = Date.now, ages, loadEngine = importEngine, retryAttachments = false, rescan = false, resumeFrom = RESUME_FROM, index, signal, }, plan) => {
57
150
  if (retryAttachments)
58
151
  return retryUnread({
59
152
  store,
@@ -94,10 +187,24 @@ export const syncMail = async (source, store, { account, since, max = 200, maxBo
94
187
  if (point?.complete && point.size === mboxStamp.size && point.mtimeMs === mboxStamp.mtimeMs)
95
188
  mboxPoint = point;
96
189
  }
190
+ let capped = false;
97
191
  for (const folder of folders) {
98
192
  if (mboxPoint !== undefined)
99
193
  break;
100
- const searched = source.search === undefined || snapshot ? undefined : await source.search(since, folder);
194
+ let searched;
195
+ if (source.search !== undefined && !snapshot) {
196
+ searched = plan?.searches.get(folder);
197
+ if (searched === undefined) {
198
+ const stop = plan?.budget.take();
199
+ if (plan && stop) {
200
+ plan.stopped = stop;
201
+ capped = true;
202
+ continue;
203
+ }
204
+ searched = await source.search(since, folder);
205
+ plan?.searches.set(folder, searched);
206
+ }
207
+ }
101
208
  let wanted = searched;
102
209
  if (searched !== undefined) {
103
210
  searches.set(folder, searched);
@@ -107,18 +214,59 @@ export const syncMail = async (source, store, { account, since, max = 200, maxBo
107
214
  const point = fresh ? undefined : kept;
108
215
  if (point !== undefined && point.validity === searched.validity && point.rules === ruleSet) {
109
216
  earlier.set(folder, point);
110
- if (searched.uids.length >= resumeFrom &&
111
- Date.parse(point.full.since) <= since.getTime() &&
112
- startedAt - Date.parse(point.full.at) < FULL_PASS_MS) {
217
+ const mode = plan?.modes.get(folder);
218
+ const usable = Date.parse(point.full.since) <= since.getTime() && startedAt - Date.parse(point.full.at) < FULL_PASS_MS;
219
+ // A budgeted run goes on with an unfinished pass however small its window is: listing it whole again would
220
+ // ask for the same newest messages every run.
221
+ const unfinished = plan !== undefined && searched.uids.some((uid) => !within(point.done, Number(uid)));
222
+ if (mode === "resumed" ||
223
+ (mode === undefined && usable && (searched.uids.length >= resumeFrom || unfinished))) {
113
224
  resumed.add(folder);
114
225
  wanted = { ...searched, uids: searched.uids.filter((uid) => !within(point.done, Number(uid))) };
115
226
  }
116
227
  }
228
+ if (plan !== undefined) {
229
+ plan.modes.set(folder, resumed.has(folder) ? "resumed" : "full");
230
+ const open = wanted.uids.filter((uid) => plan.only
231
+ ? plan.only.has(keyOf(folder, uid)) && !plan.tried.has(keyOf(folder, uid))
232
+ : !plan.tried.has(keyOf(folder, uid)));
233
+ const slice = [...open].sort((a, b) => Number(b) - Number(a)).slice(0, source.listBatch ?? LIST_SLICE);
234
+ plan.remaining += open.length - slice.length;
235
+ wanted = { ...searched, uids: slice };
236
+ if (open.length > slice.length)
237
+ capped = true;
238
+ }
239
+ }
240
+ if (plan !== undefined && wanted !== undefined && wanted.uids.length === 0)
241
+ continue;
242
+ if (plan !== undefined) {
243
+ const stop = plan.budget.take();
244
+ if (stop) {
245
+ plan.stopped = stop;
246
+ capped = true;
247
+ plan.remaining += (wanted?.uids ?? []).length;
248
+ continue;
249
+ }
250
+ for (const uid of wanted?.uids ?? [])
251
+ plan.tried.add(keyOf(folder, uid));
252
+ plan.asked += (wanted?.uids ?? []).length;
117
253
  }
118
254
  const found = await source.list(since, folder, {
119
255
  headers: rules.length > 0,
120
256
  ...(wanted === undefined ? {} : { searched: wanted }),
121
257
  });
258
+ if (plan !== undefined && wanted !== undefined) {
259
+ const got = new Set(found.map(({ uid }) => uid));
260
+ for (const uid of wanted.uids) {
261
+ const key = keyOf(folder, uid);
262
+ if (got.has(uid))
263
+ plan.unlisted.delete(key);
264
+ else {
265
+ plan.failed.add(key);
266
+ plan.unlisted.add(key);
267
+ }
268
+ }
269
+ }
122
270
  // Spreading a whole mailbox into push() overflows the stack past ~120,000 emails.
123
271
  for (const item of found)
124
272
  collected.push(item);
@@ -151,6 +299,8 @@ export const syncMail = async (source, store, { account, since, max = 200, maxBo
151
299
  let skipped;
152
300
  if (snapshot)
153
301
  skipped = "an mbox file is a snapshot; mail missing from it is not proven deleted";
302
+ else if (plan !== undefined && (capped || plan.tried.size > plan.asked || plan.only !== undefined))
303
+ skipped = "this run read the window in parts; a run that lists all of it at once checks deletions";
154
304
  else if (resumed.size > 0)
155
305
  skipped =
156
306
  "this run listed only mail not read before; a full listing, at least daily or with --rescan, checks deletions";
@@ -355,14 +505,33 @@ export const syncMail = async (source, store, { account, since, max = 200, maxBo
355
505
  await tagStored(item);
356
506
  await saveNow();
357
507
  }
358
- for (let start = 0; start < pending.length; start += FETCH_CHUNK) {
508
+ const fetchChunk = plan === undefined ? FETCH_CHUNK : (source.fetchBatch ?? FETCH_SLICE);
509
+ for (let start = 0; start < pending.length; start += fetchChunk) {
359
510
  await stopIfAborted();
360
- const chunk = pending.slice(start, start + FETCH_CHUNK);
361
- const raws = await source.fetchRaw(chunk);
511
+ const chunk = pending.slice(start, start + fetchChunk);
512
+ if (plan !== undefined) {
513
+ const stop = plan.budget.take();
514
+ if (stop) {
515
+ plan.stopped = stop;
516
+ full = true;
517
+ break;
518
+ }
519
+ }
520
+ let raws;
521
+ try {
522
+ raws = await source.fetchRaw(chunk);
523
+ }
524
+ catch (error) {
525
+ // What this run saved stays saved: the next one goes on from it.
526
+ await saveNow(true);
527
+ throw error;
528
+ }
362
529
  await stopIfAborted();
363
530
  // Every email of a chunk failing is the connection or the login, not the mail: stop instead of trying the rest.
364
- if (chunk.length > 1 && raws.every((raw) => raw instanceof Error))
531
+ if (chunk.length > 1 && raws.every((raw) => raw instanceof Error)) {
532
+ await saveNow(true);
365
533
  throw raws[0];
534
+ }
366
535
  for (const [index, item] of chunk.entries()) {
367
536
  let mail;
368
537
  try {
@@ -372,9 +541,15 @@ export const syncMail = async (source, store, { account, since, max = 200, maxBo
372
541
  mail = await parseMail(raw, maxBodyChars);
373
542
  }
374
543
  catch (error) {
375
- unreadable.push({ message: item.messageId, reason: error instanceof Error ? error.message : String(error) });
544
+ const reason = error instanceof Error ? error.message : String(error);
545
+ unreadable.push({ message: item.messageId, reason });
546
+ if (plan !== undefined) {
547
+ plan.unreadable.set(item.messageId, reason);
548
+ plan.failed.add(keyOf(item.folder ?? folders[0], item.uid));
549
+ }
376
550
  continue;
377
551
  }
552
+ plan?.unreadable.delete(item.messageId);
378
553
  const header = mailHeadersOf(raws[index]);
379
554
  const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt }, attachments);
380
555
  await store.mail.saveThread({
@@ -426,7 +601,8 @@ export const syncMail = async (source, store, { account, since, max = 200, maxBo
426
601
  await store.mail.setFlags(accountId, new Map([...labelsOf].flatMap(([id, { seen, flagged }]) => seen === undefined && flagged === undefined
427
602
  ? []
428
603
  : [[id, { ...(seen === undefined ? {} : { read: seen }), ...(flagged === undefined ? {} : { flagged }) }]])), startedAt);
429
- const complete = !full && unreadable.length === 0;
604
+ const incomplete = plan !== undefined && (capped || plan.stopped !== null || plan.failed.size > 0);
605
+ const complete = !full && !incomplete && unreadable.length === 0 && (plan?.unreadable.size ?? 0) === 0;
430
606
  const bulk = new Map(listed.filter((item) => item.headers?.bulk).map((item) => [item.messageId, [bulkBox.externalId]]));
431
607
  if (bulk.size)
432
608
  await store.mail.setMailboxes(accountId, [bulkBox], bulk, startedAt);
@@ -41,6 +41,17 @@ export interface FakeMailbox {
41
41
  moveFails?: boolean;
42
42
  /** UIDs whose raw bytes cannot be read: `fetchRaw` answers an Error for each of them, and only for them. */
43
43
  unreadable?: string[];
44
+ /** UIDs that fail once, as a server's backend error does: `list` leaves each out, `fetchRaw` answers an Error. */
45
+ failOnce?: string[];
46
+ /** Calls the source answered, by kind, so a test counts a budget's requests. */
47
+ calls?: {
48
+ search: number;
49
+ list: number;
50
+ fetchRaw: number;
51
+ };
52
+ /** What the source tells a budgeted sync about its batch sizes. */
53
+ listBatch?: number;
54
+ fetchBatch?: number;
44
55
  /** What the source was asked: the UIDs of each listing, and `msgid:<id>` for each `find`. */
45
56
  asked?: string[];
46
57
  }
@@ -64,9 +64,26 @@ export const fakeMailSource = (mailbox) => {
64
64
  .map((m) => m.uid),
65
65
  };
66
66
  };
67
+ const failedOnce = new Set();
68
+ const failsNow = (uid) => {
69
+ if (!mailbox.failOnce?.includes(uid) || failedOnce.has(uid))
70
+ return false;
71
+ failedOnce.add(uid);
72
+ return true;
73
+ };
74
+ const count = (kind) => {
75
+ if (mailbox.calls)
76
+ mailbox.calls[kind] += 1;
77
+ };
67
78
  return {
68
- search,
79
+ ...(mailbox.listBatch === undefined ? {} : { listBatch: mailbox.listBatch }),
80
+ ...(mailbox.fetchBatch === undefined ? {} : { fetchBatch: mailbox.fetchBatch }),
81
+ search: async (since, folder) => {
82
+ count("search");
83
+ return search(since, folder);
84
+ },
69
85
  list: async (since, folder, options) => {
86
+ count("list");
70
87
  const { uids } = options?.searched ?? (await search(since, folder));
71
88
  if (uids.length > 0)
72
89
  mailbox.asked?.push(uids.join(","));
@@ -74,7 +91,7 @@ export const fakeMailSource = (mailbox) => {
74
91
  throw failure("the header fetch was refused");
75
92
  const wanted = new Set(uids);
76
93
  return inFolder(folder)
77
- .filter((m) => wanted.has(m.uid))
94
+ .filter((m) => wanted.has(m.uid) && !failsNow(m.uid))
78
95
  .map((m) => listingOf(m, options?.headers ?? false, folder));
79
96
  },
80
97
  find: async (messageIds, folder, options) => {
@@ -112,12 +129,15 @@ export const fakeMailSource = (mailbox) => {
112
129
  if (messageIds.includes(m.messageId))
113
130
  m.labels = [...new Set([...(m.labels ?? []), ...labels])];
114
131
  },
115
- fetchRaw: async (items) => items.map((item) => {
116
- const m = mailbox.messages.find((each) => each.uid === item.uid);
117
- if (m === undefined || mailbox.unreadable?.includes(item.uid))
118
- return failure(`email ${item.uid} was not read`);
119
- return rawOf(m);
120
- }),
132
+ fetchRaw: async (items) => {
133
+ count("fetchRaw");
134
+ return items.map((item) => {
135
+ const m = mailbox.messages.find((each) => each.uid === item.uid);
136
+ if (m === undefined || mailbox.unreadable?.includes(item.uid) || failsNow(item.uid))
137
+ return failure(`email ${item.uid} was not read`);
138
+ return rawOf(m);
139
+ });
140
+ },
121
141
  };
122
142
  };
123
143
  /**
@@ -7,6 +7,8 @@ export interface RunError {
7
7
  class: string;
8
8
  code?: string;
9
9
  reason?: string;
10
+ /** The transport's own code (`NoConnection`, `ETIMEDOUT`, `UNAVAILABLE`), kept so a failed run says what broke. */
11
+ cause_code?: string;
10
12
  message?: string;
11
13
  /** Ours, not the messenger's: `messages.send`. */
12
14
  operation?: string;
@@ -10,6 +10,9 @@ export const errorDetailOf = (error, response) => {
10
10
  const thrown = error instanceof Error ? error : undefined;
11
11
  const code = isCliFailure(error) ? error.code : codeOf(error);
12
12
  const reason = typeof details.reason === "string" ? details.reason : undefined;
13
+ const causeCode = typeof details.cause_code === "string" && /^[A-Za-z0-9_.-]{1,40}$/.test(details.cause_code)
14
+ ? details.cause_code
15
+ : undefined;
13
16
  const message = thrown ? redactText(thrown.message) : undefined;
14
17
  const operation = stringOf(details.operation) ?? response?.operation;
15
18
  const status = numberOf(details.status) ?? response?.status;
@@ -30,6 +33,7 @@ export const errorDetailOf = (error, response) => {
30
33
  class: classOf(error),
31
34
  ...(code === undefined ? {} : { code }),
32
35
  ...(reason === undefined ? {} : { reason }),
36
+ ...(causeCode === undefined ? {} : { cause_code: causeCode }),
33
37
  ...(message ? { message } : {}),
34
38
  ...(operation === undefined ? {} : { operation }),
35
39
  ...(http ? { http } : {}),
@@ -19,6 +19,8 @@ export interface Hearing {
19
19
  unheard: Voice[];
20
20
  /** Why some were not heard — the first reason; the messages are still worth showing. */
21
21
  problem?: string;
22
+ /** Set under `--offline`: the command (`<app> <profile>`) that hears a message online, which `--offline` never does. */
23
+ offline?: string;
22
24
  }
23
25
  export declare const keyOf: ({ chatId, messageId }: Voice) => string;
24
26
  /** Telegram says `voice`; MAX says `audio` with Ogg in it. */
@@ -119,6 +119,16 @@ export const hearingMissing = (hearing, transcribe) => {
119
119
  return [{ field: "transcript", why: `not transcribed: ${hearing.problem}`, step: "messages.transcribe" }];
120
120
  if (hearing.unheard.length === 0)
121
121
  return [];
122
+ if (hearing.offline !== undefined) {
123
+ const commands = hearing.unheard.map(({ chatId, messageId }) => `${hearing.offline} messages transcribe ${chatId} ${messageId}`);
124
+ return [
125
+ {
126
+ field: "transcript",
127
+ why: `--offline reads only transcripts already stored; ${hearing.unheard.length} voice message(s) have none: ${commands.join("; ")}`,
128
+ step: "messages.transcribe",
129
+ },
130
+ ];
131
+ }
122
132
  const ids = hearing.unheard.map((voice) => voice.messageId).join(", ");
123
133
  return [
124
134
  {
@@ -13,10 +13,12 @@ export const PROJECTS_QUERY = {
13
13
  entity: "project",
14
14
  backend: "sql",
15
15
  fields: queryFieldsOf(project, {
16
- native: ["id", "key", "type", "organization_id", "scope", "created_at"],
16
+ native: ["id", "key", "name", "kind", "type", "organization_id", "scope", "created_at"],
17
17
  examples: {
18
18
  id: "id:1",
19
19
  key: "key:SMP",
20
+ name: "name:*tart*",
21
+ kind: "kind:inbox",
20
22
  type: "type:work",
21
23
  organization_id: "organization_id:1",
22
24
  scope: "scope:work",
@@ -45,6 +45,8 @@ export interface PeopleStore extends AccountPeople {
45
45
  /** The person a `person:<uid>` reference names. */
46
46
  personSeenInAccounts(uid: string, accountIds: readonly number[]): Promise<boolean>;
47
47
  personByUid(uid: string): Promise<PersonRecord | undefined>;
48
+ /** The person marked as the owner, with every identity linked to them; `undefined` when none is. */
49
+ owner(): Promise<PersonRecord | undefined>;
48
50
  /** Accepted facts, the owner's notes and open tasks about them; tasks of an account only if `accounts` holds it. */
49
51
  preparation(uid: string, options: {
50
52
  accounts: readonly AccountKey[];
@@ -1,4 +1,6 @@
1
+ import { CliError } from "@wirecat/cli-base";
1
2
  import { keysOver } from "./keys.js";
3
+ import { ownerPerson } from "./sqlite/actors.js";
2
4
  import * as identities from "./sqlite/identities.js";
3
5
  import { linkToOwner } from "./sqlite/own-identities.js";
4
6
  import * as personLinks from "./sqlite/person-links.js";
@@ -59,6 +61,16 @@ export const peopleStoreOver = (context, keyed) => {
59
61
  personOf: async (identity) => personLinks.personOf(context, identity),
60
62
  personSeenInAccounts: async (uid, accountIds) => personSeenInAccounts(context, uid, accountIds),
61
63
  personByUid: async (uid) => personLinks.personByUid(context, uid),
64
+ owner: async () => {
65
+ try {
66
+ return personLinks.personByUid(context, String(ownerPerson(database).id));
67
+ }
68
+ catch (error) {
69
+ if (error instanceof CliError && error.code === "configuration_error")
70
+ return undefined;
71
+ throw error;
72
+ }
73
+ },
62
74
  preparation: async (uid, options) => personPreparation(context, uid, options),
63
75
  linkIdentities: async (person, other, options) => {
64
76
  return write(database, () => personLinks.linkIdentities(context, person, other, options));
@@ -1,4 +1,4 @@
1
- import type { QueryAst } from "@wirecat/cli-base/query";
1
+ import { type QueryAst } from "@wirecat/cli-base/query";
2
2
  import type { AccountKey } from "../types.js";
3
3
  import { RELATION_KINDS } from "./link-kinds.js";
4
4
  import { type MeetingProjectsStore } from "./meeting-projects.js";
@@ -78,6 +78,8 @@ export interface Project {
78
78
  ref: string;
79
79
  key: string;
80
80
  name: string;
81
+ /** `inbox`: the per-account project that holds an account's tasks; listed only when asked for. */
82
+ kind: "project" | "inbox";
81
83
  type: (typeof PROJECT_TYPES)[number];
82
84
  organizationId: string | null;
83
85
  scope: (typeof SCOPES)[number];
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { walkQuery } from "@wirecat/cli-base/query";
2
3
  import { parseLocator } from "../../domain/locator.js";
3
4
  import { formatReference, parseReference } from "../../domain/references.js";
4
5
  import { normalizeTag } from "../../domain/tags.js";
@@ -11,7 +12,7 @@ import { meetingProjectsStoreOver } from "./meeting-projects.js";
11
12
  import { insertLink, linkOf } from "./notes.js";
12
13
  import { queryFilter } from "./query-filter.js";
13
14
  import { addTags as addThingTags, canonicalTagNamed, createTag, labelsOf, liveTagging, removeTags as removeThingTags, setMainTopic, } from "./tags.js";
14
- import { TASK_KEY_PREFIXES } from "./task-keys.js";
15
+ import { INBOX_KEY_LENGTH, TASK_KEY_PREFIXES } from "./task-keys.js";
15
16
  import { inboxKey, taskAccountOf, taskIdOf, taskRowOf } from "./tasks.js";
16
17
  import { referenceOfThing, stateOfThing, storedThing, thingOf } from "./things.js";
17
18
  import { write } from "./transaction.js";
@@ -199,6 +200,7 @@ export const knowledgeStoreOver = (context) => {
199
200
  ref: `project:${row.id}`,
200
201
  key: String(row.key),
201
202
  name: String(row.name),
203
+ kind: String(row.key).length === INBOX_KEY_LENGTH ? "inbox" : "project",
202
204
  type: row.type,
203
205
  organizationId: row.organization_id == null ? null : String(row.organization_id),
204
206
  scope: row.scope,
@@ -489,17 +491,20 @@ export const knowledgeStoreOver = (context) => {
489
491
  return projectOf(row);
490
492
  }),
491
493
  projects: async (query) => {
494
+ const asksKind = query !== undefined && walkQuery(query.root).some((node) => node.field === "kind");
492
495
  const where = query &&
493
496
  queryFilter(query, {
494
497
  id: { type: "kw", sql: "CAST(id AS TEXT)" },
495
498
  key: { type: "kw", sql: "key" },
499
+ name: { type: "kw", sql: "name" },
500
+ kind: { type: "enum", sql: `CASE WHEN length(key) = ${INBOX_KEY_LENGTH} THEN 'inbox' ELSE 'project' END` },
496
501
  type: { type: "enum", sql: "type" },
497
502
  organization_id: { type: "kw", sql: "CAST(organization_id AS TEXT)" },
498
503
  scope: { type: "enum", sql: "scope" },
499
504
  created_at: { type: "time", sql: "created_at" },
500
505
  });
501
506
  return database
502
- .prepare(`SELECT * FROM projects WHERE deleted_at IS NULL${where ? ` AND (${where.sql})` : ""} ORDER BY name, id LIMIT 500`)
507
+ .prepare(`SELECT * FROM projects WHERE deleted_at IS NULL${asksKind ? "" : ` AND length(key) <> ${INBOX_KEY_LENGTH}`}${where ? ` AND (${where.sql})` : ""} ORDER BY name, id LIMIT 500`)
503
508
  .all(...(where?.params ?? []))
504
509
  .map(projectOf);
505
510
  },
@@ -8,6 +8,7 @@ export declare const TASK_KEY_PREFIXES: Readonly<Record<TaskKind, string>>;
8
8
  */
9
9
  export declare const typedKey: (database: CacheDatabase, kind: TaskKind) => string;
10
10
  /** One inbox project per task account (`telegram:100`): derived from the account text alone, no `-` in it. */
11
+ export declare const INBOX_KEY_LENGTH = 10;
11
12
  export declare const inboxKey: (account: string) => string;
12
13
  /** An inbox made before its messenger saved the account learns it here, not only on its next task. */
13
14
  export declare const linkInbox: (database: CacheDatabase, account: string, accountId: number) => void;
@@ -22,6 +22,7 @@ export const typedKey = (database, kind) => {
22
22
  }
23
23
  };
24
24
  /** One inbox project per task account (`telegram:100`): derived from the account text alone, no `-` in it. */
25
+ export const INBOX_KEY_LENGTH = 10;
25
26
  export const inboxKey = (account) => `IN${createHash("sha256").update(account).digest("hex").slice(0, 8).toUpperCase()}`;
26
27
  /** An inbox made before its messenger saved the account learns it here, not only on its next task. */
27
28
  export const linkInbox = (database, account, accountId) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.472.0",
3
+ "version": "0.474.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",