@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.
- package/CHANGELOG.md +44 -1
- package/dist/background/after-upgrade.js +6 -4
- package/dist/cli/execution.d.ts +5 -0
- package/dist/cli/execution.js +5 -0
- package/dist/cli/help/command-help.js +3 -0
- package/dist/cli/help/first-screen.js +5 -0
- package/dist/cli/messenger/conversation-answers.d.ts +1 -0
- package/dist/cli/messenger/hearing-command.js +1 -1
- package/dist/cli/messenger/message-answers.d.ts +1 -0
- package/dist/cli/messenger/message-items.js +2 -0
- package/dist/cli/messenger/message-writes.d.ts +2 -0
- package/dist/cli/program.js +5 -3
- package/dist/entities/conversation-results.d.ts +2 -0
- package/dist/entities/evidence.d.ts +8 -0
- package/dist/entities/index.d.ts +19 -0
- package/dist/entities/knowledge-lists.d.ts +1 -0
- package/dist/entities/knowledge-lists.js +1 -0
- package/dist/entities/listener.d.ts +4 -0
- package/dist/entities/message-context.d.ts +3 -0
- package/dist/entities/message-write.d.ts +4 -0
- package/dist/entities/message.d.ts +1 -0
- package/dist/entities/message.js +1 -0
- package/dist/entities/store-records.d.ts +1 -0
- package/dist/mail/source.d.ts +7 -0
- package/dist/mail/sync.d.ts +16 -1
- package/dist/mail/sync.js +187 -11
- package/dist/mail/testing/fake.d.ts +11 -0
- package/dist/mail/testing/fake.js +28 -8
- package/dist/runs/error-detail.d.ts +2 -0
- package/dist/runs/error-detail.js +4 -0
- package/dist/speech/hearing.d.ts +2 -0
- package/dist/speech/hearing.js +10 -0
- package/dist/store/knowledge-query.js +3 -1
- package/dist/store/people.d.ts +2 -0
- package/dist/store/people.js +12 -0
- package/dist/store/sqlite/knowledge.d.ts +3 -1
- package/dist/store/sqlite/knowledge.js +7 -2
- package/dist/store/sqlite/task-keys.d.ts +1 -0
- package/dist/store/sqlite/task-keys.js +1 -0
- 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`">]>;
|
package/dist/entities/message.js
CHANGED
|
@@ -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>;
|
package/dist/mail/source.d.ts
CHANGED
|
@@ -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.
|
package/dist/mail/sync.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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 +
|
|
361
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 } : {}),
|
package/dist/speech/hearing.d.ts
CHANGED
|
@@ -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. */
|
package/dist/speech/hearing.js
CHANGED
|
@@ -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",
|
package/dist/store/people.d.ts
CHANGED
|
@@ -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[];
|
package/dist/store/people.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|