@wirecat/cli-core 0.561.0 → 0.563.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 (74) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/cli/app-doctor-command.js +3 -1
  3. package/dist/cli/config-command.js +18 -1
  4. package/dist/cli/envelope.js +13 -1
  5. package/dist/cli/execution.js +1 -0
  6. package/dist/cli/index.d.ts +2 -1
  7. package/dist/cli/index.js +2 -1
  8. package/dist/cli/messenger/reply-stats-command.d.ts +4 -0
  9. package/dist/cli/messenger/reply-stats-command.js +57 -0
  10. package/dist/cli/messenger/search-command.js +8 -0
  11. package/dist/cli/messenger/stats-answers.d.ts +3 -0
  12. package/dist/cli/messenger/stats-answers.js +18 -0
  13. package/dist/cli/messenger/stats-command.js +5 -0
  14. package/dist/cli/output-budget.d.ts +26 -0
  15. package/dist/cli/output-budget.js +73 -0
  16. package/dist/cli/program.js +14 -1
  17. package/dist/cli/setting-defaults.d.ts +27 -0
  18. package/dist/cli/setting-defaults.js +36 -0
  19. package/dist/cli/settings.d.ts +5 -0
  20. package/dist/cli/settings.js +88 -5
  21. package/dist/cli/shared-command-exports.d.ts +8 -0
  22. package/dist/domain/admin-statistics.d.ts +5 -0
  23. package/dist/domain/admin-statistics.js +4 -3
  24. package/dist/entities/index.d.ts +31 -1
  25. package/dist/entities/index.js +4 -1
  26. package/dist/entities/reply-stats.d.ts +33 -0
  27. package/dist/entities/reply-stats.js +19 -0
  28. package/dist/errors/reasons.d.ts +1 -1
  29. package/dist/errors/reasons.js +2 -3
  30. package/dist/mail/attachments.d.ts +15 -2
  31. package/dist/mail/attachments.js +19 -3
  32. package/dist/mail/cli/index.d.ts +17 -1
  33. package/dist/mail/cli/index.js +1 -1
  34. package/dist/mail/cli/review.d.ts +5 -2
  35. package/dist/mail/cli/review.js +47 -39
  36. package/dist/mail/index.d.ts +2 -0
  37. package/dist/mail/index.js +2 -0
  38. package/dist/mail/partial.d.ts +3 -1
  39. package/dist/mail/partial.js +2 -2
  40. package/dist/mail/retry.d.ts +3 -1
  41. package/dist/mail/retry.js +3 -3
  42. package/dist/mail/sync-types.d.ts +3 -0
  43. package/dist/mail/sync.js +5 -3
  44. package/dist/mail/whole.d.ts +28 -0
  45. package/dist/mail/whole.js +69 -0
  46. package/dist/services/index.d.ts +2 -0
  47. package/dist/services/index.js +2 -0
  48. package/dist/services/proposal-tasks.js +3 -12
  49. package/dist/services/proposals/kinds/action.js +3 -2
  50. package/dist/services/proposals/registry.js +8 -4
  51. package/dist/services/proposals/types.d.ts +2 -0
  52. package/dist/services/reply-stats.d.ts +48 -0
  53. package/dist/services/reply-stats.js +137 -0
  54. package/dist/store/decisions.d.ts +14 -11
  55. package/dist/store/decisions.js +9 -2
  56. package/dist/store/index.d.ts +3 -3
  57. package/dist/store/index.js +2 -2
  58. package/dist/store/queries.d.ts +4 -0
  59. package/dist/store/queries.js +3 -0
  60. package/dist/store/sqlite/email-locations.d.ts +4 -0
  61. package/dist/store/sqlite/email-locations.js +26 -11
  62. package/dist/store/sqlite/lucene-fields.d.ts +2 -0
  63. package/dist/store/sqlite/lucene-fields.js +3 -1
  64. package/dist/store/sqlite/mail-findings.js +1 -1
  65. package/dist/store/sqlite/meeting-findings.js +1 -1
  66. package/dist/store/sqlite/proposed-actions.d.ts +2 -0
  67. package/dist/store/sqlite/proposed-actions.js +24 -17
  68. package/dist/store/sqlite/reply-times.d.ts +57 -0
  69. package/dist/store/sqlite/reply-times.js +113 -0
  70. package/dist/store/sqlite/work-tasks.d.ts +12 -2
  71. package/dist/store/sqlite/work-tasks.js +35 -26
  72. package/dist/store/store.d.ts +1 -0
  73. package/package.json +1 -1
  74. package/parity.json +24 -0
@@ -29,31 +29,44 @@ export const MAIL_REVIEW_QUERY = defineQuery({
29
29
  nativeField({ name: "bulk", type: "kw", operators: ["term"], example: "bulk:false" }),
30
30
  ],
31
31
  });
32
- /** The mail review's steps; a host lists them beside its own so its step checks know them. */
33
- export const MAIL_REVIEW_STEPS = defineSteps([
34
- {
35
- id: "mail-messages.acknowledge",
36
- command: "messages acknowledge",
37
- args: [
38
- { name: "holder", required: true },
39
- { name: "cursor", required: true },
40
- ],
41
- why: "once these are processed, this moves the holder's mark past them",
42
- },
43
- {
44
- id: "mail-review.next",
45
- command: "review",
46
- args: [{ name: "query", required: true }],
47
- why: "the next review starts where this one ended",
48
- },
49
- {
50
- id: "mail-conversations.show",
51
- command: "conversations show",
52
- args: [{ name: "conversation", required: true }],
53
- why: "only the newest emails of the thread were read here; this reads it whole",
54
- },
55
- ]);
56
- const step = (id) => MAIL_REVIEW_STEPS.find((one) => one.id === id);
32
+ /** The mail review's steps where a host mounted them; a host lists them beside its own so its step checks know them. */
33
+ export const mailReviewSteps = (mount = {}) => {
34
+ const mailbox = mount.mailbox ? [{ name: mount.mailbox.option, required: true }] : [];
35
+ return defineSteps([
36
+ {
37
+ id: "mail-messages.acknowledge",
38
+ command: mount.acknowledge ?? "messages acknowledge",
39
+ args: [{ name: "holder", required: true }, { name: "cursor", required: true }, ...mailbox],
40
+ why: "once these are processed, this moves the holder's mark past them",
41
+ },
42
+ {
43
+ id: "mail-review.next",
44
+ command: mount.review ?? "review",
45
+ args: [{ name: "query", required: true }, ...mailbox],
46
+ why: "the next review starts where this one ended",
47
+ },
48
+ ...(mount.conversation === null
49
+ ? []
50
+ : [
51
+ {
52
+ id: "mail-conversations.show",
53
+ command: mount.conversation ?? "conversations show",
54
+ args: [{ name: "conversation", required: true }, ...mailbox],
55
+ why: "only the newest emails of the thread were read here; this reads it whole",
56
+ },
57
+ ]),
58
+ ]);
59
+ };
60
+ /** The steps of a mail CLI, whose profile names the mailbox. */
61
+ export const MAIL_REVIEW_STEPS = mailReviewSteps();
62
+ const stepsFor = (mount, mailbox) => {
63
+ const definitions = mailReviewSteps(mount);
64
+ const where = mount?.mailbox ? { [mount.mailbox.option]: mount.mailbox.value(mailbox) } : {};
65
+ return (id, args) => {
66
+ const definition = definitions.find((one) => one.id === id);
67
+ return definition ? [stepOf(definition, { args: { ...args, ...where } })] : [];
68
+ };
69
+ };
57
70
  const waitingHours = (value) => value === true ? AGENDA_DEFAULTS.waiting / 3_600_000 : parseDuration(String(value), "--waiting") / 3_600_000;
58
71
  const quoted = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
59
72
  /** A file name for the mailbox's points: they describe the mailbox, whichever profile reads it. */
@@ -67,20 +80,21 @@ const markText = (marks) => {
67
80
  const shown = said.filter(Boolean).join(" · ");
68
81
  return shown ? `\n [${shown}]` : "";
69
82
  };
70
- const reviewAnswer = (found, mailbox, fresh) => {
83
+ const reviewAnswer = (found, mailbox, fresh, step) => {
71
84
  const read = found.chats
72
85
  .flatMap((chat) => chat.messages.map((message) => ({ ...message, chatTitle: chat.title })))
73
86
  .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
74
87
  const cut = found.chats.filter((chat) => chat.more);
75
88
  const ref = (thread) => formatReference({ type: "chat", ...mailbox, chat: thread });
76
89
  const turns = found.unanswered !== undefined || found.waiting !== undefined;
90
+ const wholeThreads = cut.flatMap((chat) => step("mail-conversations.show", { conversation: ref(chat.id) }));
77
91
  const missing = [
78
92
  ...(cut.length > 0
79
93
  ? [
80
94
  {
81
95
  field: "items",
82
96
  why: `only the newest emails of ${cut.length} thread(s)`,
83
- step: "mail-conversations.show",
97
+ ...(wholeThreads.length ? { step: "mail-conversations.show" } : {}),
84
98
  },
85
99
  ]
86
100
  : []),
@@ -98,13 +112,9 @@ const reviewAnswer = (found, mailbox, fresh) => {
98
112
  ];
99
113
  const next = [
100
114
  ...(!fresh && !turns
101
- ? [
102
- stepOf(step("mail-review.next"), {
103
- args: { query: `date>${quoted(found.complete ? found.until : found.since)}` },
104
- }),
105
- ]
115
+ ? step("mail-review.next", { query: `date>${quoted(found.complete ? found.until : found.since)}` })
106
116
  : []),
107
- ...cut.map((chat) => stepOf(step("mail-conversations.show"), { args: { conversation: ref(chat.id) } })),
117
+ ...wholeThreads,
108
118
  ];
109
119
  return listOf("review", {
110
120
  items: read.map((message) => ({
@@ -165,12 +175,10 @@ const holderReview = async (command, mail, holder, query) => {
165
175
  partial: false,
166
176
  quiet: 0,
167
177
  };
168
- const answer = reviewAnswer(found, mailbox, true);
178
+ const step = stepsFor(mail.mount, mailbox);
179
+ const answer = reviewAnswer(found, mailbox, true, step);
169
180
  const skipped = Object.entries(read.skipped).filter(([, count]) => count > 0);
170
- answer.next = [
171
- stepOf(step("mail-messages.acknowledge"), { args: { holder, cursor: read.cursor } }),
172
- ...(answer.next ?? []),
173
- ];
181
+ answer.next = [...step("mail-messages.acknowledge", { holder, cursor: read.cursor }), ...(answer.next ?? [])];
174
182
  answer.missing = [
175
183
  ...(answer.missing ?? []),
176
184
  ...(read.held > 0 ? [{ field: "items", why: `${read.held} partial emails held until they are whole` }] : []),
@@ -254,7 +262,7 @@ an agent over MCP, and each scheduled run.`)
254
262
  return { found, mailbox, saved };
255
263
  });
256
264
  const output = outputFor(this);
257
- const answer = reviewAnswer(found, mailbox, saved !== undefined);
265
+ const answer = reviewAnswer(found, mailbox, saved !== undefined, stepsFor(mail.mount, mailbox));
258
266
  if (meta)
259
267
  answer.meta = { ...meta, ...answer.meta };
260
268
  renderAnswer(this, output, answer, () => {
@@ -1,4 +1,5 @@
1
1
  export { mailAccountKey } from "./account-key.js";
2
+ export { DEFAULT_ATTACHMENT_POLICY, type MailAttachmentPolicy, POLICY_SKIPS } from "./attachments.js";
2
3
  export { BULK, bulkBox } from "./bulk.js";
3
4
  export { factsOf } from "./facts.js";
4
5
  export { MAIL_FINDINGS_KIND, MAIL_FINDINGS_PER_RUN, MAIL_FINDINGS_PER_SENDER, type MailFinding, type MailFindingsInput, mailFindingFingerprint, validateMailFindings, } from "./findings.js";
@@ -29,3 +30,4 @@ export type { Listed, MailSource, Quick, Searched } from "./source.js";
29
30
  export { SEARCH_REFUSED, searchRefused } from "./source.js";
30
31
  export { listedOf, type StoredMail } from "./stored.js";
31
32
  export { MAX_ATTACHMENT_CHARS, type MailAccount, type MailIndexed, type MailSyncOptions, type MailSyncResult, RETRYABLE_ATTACHMENTS, syncMail, } from "./sync.js";
33
+ export { readEmailWhole, type WholeAttachment } from "./whole.js";
@@ -1,4 +1,5 @@
1
1
  export { mailAccountKey } from "./account-key.js";
2
+ export { DEFAULT_ATTACHMENT_POLICY, POLICY_SKIPS } from "./attachments.js";
2
3
  export { BULK, bulkBox } from "./bulk.js";
3
4
  export { factsOf } from "./facts.js";
4
5
  export { MAIL_FINDINGS_KIND, MAIL_FINDINGS_PER_RUN, MAIL_FINDINGS_PER_SENDER, mailFindingFingerprint, validateMailFindings, } from "./findings.js";
@@ -28,3 +29,4 @@ export { searchMailServer } from "./server-search.js";
28
29
  export { SEARCH_REFUSED, searchRefused } from "./source.js";
29
30
  export { listedOf } from "./stored.js";
30
31
  export { MAX_ATTACHMENT_CHARS, RETRYABLE_ATTACHMENTS, syncMail, } from "./sync.js";
32
+ export { readEmailWhole } from "./whole.js";
@@ -1,6 +1,7 @@
1
1
  import type { BudgetStop } from "../background/budget.js";
2
2
  import type { LoadEngine } from "../readers/types.js";
3
3
  import type { MessageStore } from "../store/index.js";
4
+ import { type MailAttachmentPolicy } from "./attachments.js";
4
5
  import type { MailSource } from "./source.js";
5
6
  /** The `emails.metadata` key of an email stored from its first bytes: `{ bytes }` read of its `size`. */
6
7
  export declare const PARTIAL = "partial";
@@ -23,7 +24,7 @@ export interface PartialOutcome {
23
24
  * `identify` gives the stored id for the bytes read there. Each failed read is counted, and after `PARTIAL_TRIES`, or
24
25
  * at once when the place holds another message, the email is left partial for good.
25
26
  */
26
- export declare const completePartial: ({ store, source, accountId, address, folders, maxBodyChars, maxAttachmentChars, loadEngine, now, take, signal, }: {
27
+ export declare const completePartial: ({ store, source, accountId, address, folders, maxBodyChars, maxAttachmentChars, policy, loadEngine, now, take, signal, }: {
27
28
  store: MessageStore;
28
29
  source: MailSource;
29
30
  accountId: number;
@@ -32,6 +33,7 @@ export declare const completePartial: ({ store, source, accountId, address, fold
32
33
  folders: () => Promise<readonly string[]>;
33
34
  maxBodyChars: number;
34
35
  maxAttachmentChars: number;
36
+ policy?: MailAttachmentPolicy;
35
37
  loadEngine: LoadEngine;
36
38
  now: number;
37
39
  /** A budgeted run's next request, or why there is none. */
@@ -15,7 +15,7 @@ export const PARTIAL_TRIES = 3;
15
15
  * `identify` gives the stored id for the bytes read there. Each failed read is counted, and after `PARTIAL_TRIES`, or
16
16
  * at once when the place holds another message, the email is left partial for good.
17
17
  */
18
- export const completePartial = async ({ store, source, accountId, address, folders, maxBodyChars, maxAttachmentChars, loadEngine, now, take, signal, }) => {
18
+ export const completePartial = async ({ store, source, accountId, address, folders, maxBodyChars, maxAttachmentChars, policy, loadEngine, now, take, signal, }) => {
19
19
  const { emails, total, stuck } = await store.mail.partialEmails(accountId, PARTIAL_PER_RUN);
20
20
  if (emails.length === 0)
21
21
  return { completed: 0, left: 0, stuck };
@@ -89,7 +89,7 @@ export const completePartial = async ({ store, source, accountId, address, folde
89
89
  }
90
90
  try {
91
91
  const mail = await parseMail(raw, maxBodyChars);
92
- const files = await readAttachments(mail, email.externalId, { loadEngine, maxAttachmentChars, at: now, ...(signal ? { signal } : {}) }, attachments);
92
+ const files = await readAttachments(mail, email.externalId, { loadEngine, maxAttachmentChars, at: now, ...(policy ? { policy } : {}), ...(signal ? { signal } : {}) }, attachments);
93
93
  const { [PARTIAL]: _partial, ...metadata } = email.metadata ?? {};
94
94
  const whole = { ...item, messageId: email.externalId, threadId: email.threadExternalId };
95
95
  await store.mail.saveThread({
@@ -1,5 +1,6 @@
1
1
  import type { LoadEngine } from "../readers/types.js";
2
2
  import type { MessageStore } from "../store/index.js";
3
+ import { type MailAttachmentPolicy } from "./attachments.js";
3
4
  import type { MailSource } from "./source.js";
4
5
  import type { MailAccount, MailSyncOptions, MailSyncResult } from "./sync.js";
5
6
  /** Failures a newer install mends: the reader was missing, not the file at fault, so reading again can succeed. */
@@ -9,7 +10,7 @@ export declare const RETRYABLE_ATTACHMENTS: string[];
9
10
  * attachment file, so the email comes from the source again. Nothing is listed, so no deletion is proved and no
10
11
  * resume point moves; the email keeps its stored mailboxes, flags and rule.
11
12
  */
12
- export declare const retryUnread: ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, now, loadEngine, index, }: {
13
+ export declare const retryUnread: ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, policy, now, loadEngine, index, }: {
13
14
  store: MessageStore;
14
15
  source: MailSource;
15
16
  account: MailAccount;
@@ -17,6 +18,7 @@ export declare const retryUnread: ({ store, source, account, since, max, maxBody
17
18
  max: number;
18
19
  maxBodyChars: number;
19
20
  maxAttachmentChars: number;
21
+ policy?: MailAttachmentPolicy;
20
22
  now: () => number;
21
23
  loadEngine: LoadEngine;
22
24
  index: MailSyncOptions["index"];
@@ -1,6 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { mailAccountKey } from "./account-key.js";
3
- import { addressOf, FETCH_CHUNK, readAttachments } from "./attachments.js";
3
+ import { addressOf, FETCH_CHUNK, readAttachments, } from "./attachments.js";
4
4
  import { sentAtOf } from "./email-input.js";
5
5
  import { mailFolders } from "./folders.js";
6
6
  import { mailHeadersOf } from "./headers.js";
@@ -12,7 +12,7 @@ export const RETRYABLE_ATTACHMENTS = ["engine-missing"];
12
12
  * attachment file, so the email comes from the source again. Nothing is listed, so no deletion is proved and no
13
13
  * resume point moves; the email keeps its stored mailboxes, flags and rule.
14
14
  */
15
- export const retryUnread = async ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, now, loadEngine, index, }) => {
15
+ export const retryUnread = async ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, policy, now, loadEngine, index, }) => {
16
16
  const startedAt = now();
17
17
  const key = mailAccountKey(account.address);
18
18
  const find = source.find;
@@ -55,7 +55,7 @@ export const retryUnread = async ({ store, source, account, since, max, maxBodyC
55
55
  continue;
56
56
  }
57
57
  const before = new Map(stored.attachments.map(({ position, extractionError }) => [position, extractionError]));
58
- const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt }, attachments);
58
+ const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt, ...(policy ? { policy } : {}) }, attachments);
59
59
  for (const file of files)
60
60
  if (RETRYABLE_ATTACHMENTS.includes(before.get(file.position) ?? ""))
61
61
  if (file.extraction === "text")
@@ -1,6 +1,7 @@
1
1
  import type { BudgetStop, RunBudget } from "../background/budget.js";
2
2
  import type { LoadEngine } from "../readers/types.js";
3
3
  import type { AccountKey, MessageStore } from "../store/index.js";
4
+ import type { MailAttachmentPolicy } from "./attachments.js";
4
5
  import type { PartialOutcome } from "./partial.js";
5
6
  import type { TagOutcome } from "./rule-tags.js";
6
7
  import type { MailRule } from "./rules.js";
@@ -84,6 +85,8 @@ export interface MailSyncOptions {
84
85
  max?: number;
85
86
  maxBodyChars?: number;
86
87
  maxAttachmentChars?: number;
88
+ /** Whether attachments are read into the index, and up to what size; `DEFAULT_ATTACHMENT_POLICY` when left out. */
89
+ attachments?: MailAttachmentPolicy;
87
90
  now?: () => number;
88
91
  /** Domain registration dates already known, for the sender score a rule asks for. */
89
92
  ages?: ReadonlyMap<string, DomainAge>;
package/dist/mail/sync.js CHANGED
@@ -3,7 +3,7 @@ import { CliError } from "@wirecat/cli-base";
3
3
  import { importEngine } from "../attachments/extract.js";
4
4
  import { isBusy } from "../store/sqlite/transaction.js";
5
5
  import { mailAccountKey } from "./account-key.js";
6
- import { FETCH_CHUNK, MAX_ATTACHMENT_CHARS, readAttachments } from "./attachments.js";
6
+ import { DEFAULT_ATTACHMENT_POLICY, FETCH_CHUNK, MAX_ATTACHMENT_CHARS, readAttachments } from "./attachments.js";
7
7
  import { BULK, bulkBox } from "./bulk.js";
8
8
  import { parsedEmail } from "./email-input.js";
9
9
  import { factsOf } from "./facts.js";
@@ -51,6 +51,7 @@ export const syncMail = async (source, store, options) => {
51
51
  folders: () => mailFolders(options.account, source),
52
52
  maxBodyChars: options.maxBodyChars ?? MAX_BODY_CHARS,
53
53
  maxAttachmentChars: options.maxAttachmentChars ?? MAX_ATTACHMENT_CHARS,
54
+ policy: options.attachments ?? DEFAULT_ATTACHMENT_POLICY,
54
55
  loadEngine: options.loadEngine ?? importEngine,
55
56
  now: (options.now ?? Date.now)(),
56
57
  ...(options.budget ? { take: () => options.budget?.take() ?? null } : {}),
@@ -169,7 +170,7 @@ const syncRounds = async (source, store, options) => {
169
170
  : {}),
170
171
  };
171
172
  };
172
- 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, saveCoverage = true, resumeFrom = RESUME_FROM, index, signal, }, plan) => {
173
+ const syncRound = async (source, store, { account, since, max = 200, maxBodyChars = MAX_BODY_CHARS, maxAttachmentChars = MAX_ATTACHMENT_CHARS, attachments: policy = DEFAULT_ATTACHMENT_POLICY, now = Date.now, ages, loadEngine = importEngine, retryAttachments = false, rescan = false, saveCoverage = true, resumeFrom = RESUME_FROM, index, signal, }, plan) => {
173
174
  if (retryAttachments)
174
175
  return retryUnread({
175
176
  store,
@@ -179,6 +180,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
179
180
  max,
180
181
  maxBodyChars,
181
182
  maxAttachmentChars,
183
+ policy,
182
184
  now,
183
185
  loadEngine,
184
186
  index,
@@ -759,7 +761,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
759
761
  plan?.unreadable.delete(item.messageId);
760
762
  const header = mailHeadersOf(raws[index]);
761
763
  // A stop between attachments ends the chunk; what the earlier slices saved stays.
762
- const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt, ...(signal ? { signal } : {}) }, attachments).catch(async (error) => {
764
+ const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt, policy, ...(signal ? { signal } : {}) }, attachments).catch(async (error) => {
763
765
  await stopIfAborted();
764
766
  throw error;
765
767
  });
@@ -0,0 +1,28 @@
1
+ import type { LoadEngine } from "../readers/types.js";
2
+ import type { MessageStore } from "../store/index.js";
3
+ import type { MailSource } from "./source.js";
4
+ export interface WholeAttachment {
5
+ position: number;
6
+ name: string;
7
+ mime: string | null;
8
+ /** `null` when the email held no content for it. */
9
+ bytes: Uint8Array | null;
10
+ status: string;
11
+ }
12
+ /**
13
+ * Reads one stored email from its source again and every attachment of it, whatever its size: what a person asks for
14
+ * by name is read even when a sync's policy left it. The new text is saved as a sync saves it, so the search index
15
+ * takes it; the files are only answered, never written. Gmail's email is found by its id in `folders`; elsewhere it is
16
+ * read at its newest stored place, trusted only when `identify` gives the stored id for the bytes read there.
17
+ */
18
+ export declare const readEmailWhole: ({ store, source, address, externalId, folders, maxBodyChars, maxAttachmentChars, loadEngine, now, }: {
19
+ store: MessageStore;
20
+ source: MailSource;
21
+ address: string;
22
+ externalId: string;
23
+ folders: () => Promise<readonly string[]>;
24
+ maxBodyChars: number;
25
+ maxAttachmentChars: number;
26
+ loadEngine: LoadEngine;
27
+ now: number;
28
+ }) => Promise<WholeAttachment[]>;
@@ -0,0 +1,69 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { mailAccountKey } from "./account-key.js";
3
+ import { readAttachments } from "./attachments.js";
4
+ import { parsedEmail } from "./email-input.js";
5
+ import { mailHeadersOf } from "./headers.js";
6
+ import { parseMail } from "./parse.js";
7
+ import { PARTIAL } from "./partial.js";
8
+ /**
9
+ * Reads one stored email from its source again and every attachment of it, whatever its size: what a person asks for
10
+ * by name is read even when a sync's policy left it. The new text is saved as a sync saves it, so the search index
11
+ * takes it; the files are only answered, never written. Gmail's email is found by its id in `folders`; elsewhere it is
12
+ * read at its newest stored place, trusted only when `identify` gives the stored id for the bytes read there.
13
+ */
14
+ export const readEmailWhole = async ({ store, source, address, externalId, folders, maxBodyChars, maxAttachmentChars, loadEngine, now, }) => {
15
+ const { id: accountId } = await store.accounts.storedAccount(mailAccountKey(address));
16
+ const stored = await store.mail.emailPlace(accountId, externalId);
17
+ if (stored === null)
18
+ throw new CliError("not_found", `${externalId} is not stored for ${address}`);
19
+ let item;
20
+ if (source.find)
21
+ for (const folder of await folders()) {
22
+ item = (await source.find([externalId], folder)).find(({ messageId }) => messageId === externalId);
23
+ if (item)
24
+ break;
25
+ }
26
+ else {
27
+ const place = stored.locations[0];
28
+ if (place)
29
+ item = {
30
+ uid: String(place.uid),
31
+ folder: place.folder,
32
+ validity: place.uidvalidity,
33
+ receivedAt: new Date(stored.receivedAt ?? 0).toISOString(),
34
+ };
35
+ }
36
+ if (item === undefined)
37
+ throw new CliError("not_found", `the mail server no longer gives ${externalId}`);
38
+ const [raw] = await source.fetchRaw([item]);
39
+ if (!(raw instanceof Uint8Array))
40
+ throw new CliError("provider_unavailable", `${externalId} could not be read from the mail server`);
41
+ if (!source.find && source.identify?.(raw, item).messageId !== externalId)
42
+ throw new CliError("not_found", `the mail server holds another email where ${externalId} was`);
43
+ const mail = await parseMail(raw, maxBodyChars);
44
+ const log = [];
45
+ const files = await readAttachments(mail, externalId, { loadEngine, maxAttachmentChars, at: now }, log);
46
+ const { [PARTIAL]: _partial, ...metadata } = stored.metadata ?? {};
47
+ const whole = { ...item, messageId: externalId, threadId: stored.threadExternalId };
48
+ await store.mail.saveThread({
49
+ accountId,
50
+ externalId: stored.threadExternalId,
51
+ now,
52
+ emails: [
53
+ {
54
+ ...parsedEmail(mail, whole, mailHeadersOf(raw), address),
55
+ receivedAt: stored.receivedAt,
56
+ size: raw.byteLength,
57
+ metadata,
58
+ attachments: files,
59
+ },
60
+ ],
61
+ });
62
+ return mail.attachments.map((part, position) => ({
63
+ position,
64
+ name: part.name,
65
+ mime: part.mime,
66
+ bytes: part.bytes,
67
+ status: log[position]?.status ?? "unavailable",
68
+ }));
69
+ };
@@ -49,6 +49,7 @@ import { type PeopleService } from "./people.js";
49
49
  import { type PicturesService } from "./pictures.js";
50
50
  import { privatePeopleService } from "./private-people.js";
51
51
  import { type RankingsService } from "./rankings.js";
52
+ import { type ReplyStatsService } from "./reply-stats.js";
52
53
  import { type RetentionService } from "./retention.js";
53
54
  import { type SearchesService } from "./searches.js";
54
55
  import { type TagsService } from "./tags.js";
@@ -119,6 +120,7 @@ export interface Services {
119
120
  counters: CountersService;
120
121
  retention: RetentionService;
121
122
  rankings: RankingsService;
123
+ replyStats: ReplyStatsService;
122
124
  topics: TopicsService;
123
125
  messages: MessagesService;
124
126
  chats: ChatsService;
@@ -40,6 +40,7 @@ import { peopleService } from "./people.js";
40
40
  import { picturesService, withPictures } from "./pictures.js";
41
41
  import { privatePeopleService } from "./private-people.js";
42
42
  import { rankingsService } from "./rankings.js";
43
+ import { replyStatsService } from "./reply-stats.js";
43
44
  import { retentionService } from "./retention.js";
44
45
  import { searchesService } from "./searches.js";
45
46
  import { tagsService } from "./tags.js";
@@ -89,6 +90,7 @@ export const servicesFor = (deps) => {
89
90
  counters: countersService(deps),
90
91
  retention: retentionService(deps),
91
92
  rankings: rankingsService(deps),
93
+ replyStats: replyStatsService(deps),
92
94
  topics: topicsService(deps),
93
95
  messages: withPictures(messagesService(deps), pictures),
94
96
  chats: chatsService(deps),
@@ -1,8 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { MEETING_FINDINGS_KIND } from "../meetings/index.js";
3
2
  import { assertMayDecide } from "../store/decisions.js";
4
- import { EVIDENCE_FINDINGS_KIND } from "../store/evidence-findings.js";
5
- import { proposerOfAction } from "../store/index.js";
3
+ import { assertGenericAction, proposerOfAction } from "../store/index.js";
6
4
  import { createTaskService } from "../tasks/index.js";
7
5
  import { taskAccount } from "./task-rules.js";
8
6
  /** The source a proposal's task keeps: found again on a retry, so one proposal never makes two tasks. */
@@ -13,19 +11,12 @@ export const proposalSource = (id) => `proposed-action:${id}`;
13
11
  */
14
12
  export const approveAsTask = async (store, id, by) => {
15
13
  const proposal = await store.proposedActions.get(id);
16
- if (proposal.kind === MEETING_FINDINGS_KIND)
17
- throw new CliError("validation_error", "review meeting findings through the dedicated findings command", {
18
- reason: "meeting-findings.dedicated-review",
19
- });
20
- if (proposal.kind === EVIDENCE_FINDINGS_KIND)
21
- throw new CliError("validation_error", "review evidence findings through the dedicated findings command", {
22
- reason: "evidence-findings.dedicated-review",
23
- });
14
+ assertGenericAction(proposal.kind);
24
15
  if (proposal.status !== "proposed")
25
16
  throw new CliError("validation_error", `a ${proposal.status} action cannot be approved`, {
26
17
  reason: "proposal.wrong-state",
27
18
  });
28
- assertMayDecide({ id: proposal.id, proposedBy: proposerOfAction(proposal) }, by, store.decisionPolicy);
19
+ assertMayDecide({ id: proposal.id, kind: proposal.kind, proposedBy: proposerOfAction(proposal) }, by, store.decisionPolicy);
29
20
  if (proposal.account === null)
30
21
  throw new CliError("validation_error", "a proposal names the account that would act; this one names none");
31
22
  const { task } = await createTaskService({ store: store.tasks }).add({
@@ -1,5 +1,5 @@
1
1
  import { MEETING_FINDINGS_KIND } from "../../../meetings/index.js";
2
- import { proposerOfAction } from "../../../store/index.js";
2
+ import { EVIDENCE_FINDINGS_KIND, isOwnerOnlyKind, MAIL_FINDINGS_KIND, proposerOfAction, } from "../../../store/index.js";
3
3
  import { approveAsTask } from "../../proposal-tasks.js";
4
4
  import { actionStatus, kept, projectOf, pushed, storedStatus } from "./shared.js";
5
5
  const rowOf = (action) => ({
@@ -17,6 +17,7 @@ const rowOf = (action) => ({
17
17
  signals: null,
18
18
  parts: null,
19
19
  createdAt: action.createdAt,
20
+ ...(isOwnerOnlyKind(action.kind) ? { ownerOnly: true } : {}),
20
21
  });
21
22
  /**
22
23
  * Every stored proposal no other kind claims: a reply, a ban, a task to create. Confirming makes it the owner's task
@@ -27,7 +28,7 @@ export const actionKind = (store, claimed) => ({
27
28
  list: async (filter) => {
28
29
  const status = storedStatus(filter);
29
30
  const actions = await store.proposedActions.list({
30
- except: [...claimed(), MEETING_FINDINGS_KIND],
31
+ except: [...claimed(), MEETING_FINDINGS_KIND, EVIDENCE_FINDINGS_KIND, MAIL_FINDINGS_KIND],
31
32
  ...(status === undefined ? {} : { status }),
32
33
  ...pushed(filter),
33
34
  });
@@ -10,6 +10,7 @@ import { memoryKind } from "./kinds/memory.js";
10
10
  import { relationshipKind } from "./kinds/relationship.js";
11
11
  import { relationshipUpdateKind } from "./kinds/relationship-update.js";
12
12
  const PREFIX = /^([a-z][a-z_]*):/;
13
+ const marked = (kind, row) => kind.ownerOnly ? { ...row, ownerOnly: true } : row;
13
14
  const refusalOf = (id, error) => {
14
15
  if (!(error instanceof CliError))
15
16
  throw error;
@@ -51,12 +52,15 @@ export const emptyProposalRegistry = (store) => {
51
52
  throw new CliError("validation_error", "a proposal list takes a nonnegative offset");
52
53
  const asked = filter.kinds === undefined ? kinds : kinds.filter((kind) => filter.kinds?.includes(kind.kind));
53
54
  const wide = { ...filter, offset: 0, limit: filter.offset + filter.limit + 1 };
54
- const rows = (await Promise.all(asked.map((kind) => kind.list(wide)))).flat();
55
+ const rows = (await Promise.all(asked.map(async (kind) => (await kind.list(wide)).map((row) => marked(kind, row))))).flat();
55
56
  rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
56
57
  const page = rows.slice(filter.offset, filter.offset + filter.limit + 1);
57
58
  return { items: page.slice(0, filter.limit), hasMore: page.length > filter.limit };
58
59
  },
59
- get: async (id) => (await kindOf(id)).get(id),
60
+ get: async (id) => {
61
+ const kind = await kindOf(id);
62
+ return marked(kind, await kind.get(id));
63
+ },
60
64
  decide: async (ids, decision) => {
61
65
  const parts = new Map();
62
66
  const whole = new Set();
@@ -89,7 +93,7 @@ export const emptyProposalRegistry = (store) => {
89
93
  refuseIds(id, `a ${kind.kind} proposal has no parts; name ${id} alone`);
90
94
  continue;
91
95
  }
92
- const row = await kind.get(id);
96
+ const row = marked(kind, await kind.get(id));
93
97
  if (row.status !== "proposed") {
94
98
  refused.push({
95
99
  id,
@@ -99,7 +103,7 @@ export const emptyProposalRegistry = (store) => {
99
103
  });
100
104
  continue;
101
105
  }
102
- const refusal = mayDecide(kind.ownerOnly ? { ...row, ownerOnly: true } : row, decision.by, policy);
106
+ const refusal = mayDecide(row, decision.by, policy);
103
107
  if (refusal) {
104
108
  refused.push(refusal);
105
109
  continue;
@@ -28,6 +28,8 @@ export interface ProposalRow {
28
28
  outcome: "accepted" | "rejected" | null;
29
29
  }[] | null;
30
30
  createdAt: string;
31
+ /** Only the owner decides it: its kind says so, whichever path decides it. */
32
+ ownerOnly?: boolean;
31
33
  }
32
34
  export interface ProposalFilter {
33
35
  project?: string;
@@ -0,0 +1,48 @@
1
+ import type { NewestMessage } from "../store/store.js";
2
+ import type { ServiceDeps } from "./deps.js";
3
+ export type ReplyWho = "me" | "others";
4
+ export interface ReplyFigures {
5
+ replies: number;
6
+ medianMs: number | null;
7
+ p90Ms: number | null;
8
+ meanMs: number | null;
9
+ }
10
+ export interface ReplyTimes extends ReplyFigures {
11
+ who: ReplyWho;
12
+ by: "day" | "total";
13
+ buckets: {
14
+ under5m: number;
15
+ under1h: number;
16
+ under1d: number;
17
+ over1d: number;
18
+ };
19
+ rows: (ReplyFigures & {
20
+ key: string;
21
+ })[];
22
+ }
23
+ export interface Waiting {
24
+ chats: number;
25
+ messages: number;
26
+ oldestAt: string | null;
27
+ byKind: {
28
+ private: number;
29
+ group: number;
30
+ };
31
+ }
32
+ export interface ReplyQuery {
33
+ text?: string;
34
+ source?: string;
35
+ timezone?: string;
36
+ }
37
+ export interface ReplyStatsService {
38
+ responses(query: ReplyQuery & {
39
+ who: ReplyWho;
40
+ by: "day" | "total";
41
+ }): Promise<ReplyTimes>;
42
+ waiting(query: ReplyQuery): Promise<Waiting>;
43
+ }
44
+ /** Per waiting chat, how far back its unanswered messages are counted. */
45
+ export declare const WAITING_TAIL = 100;
46
+ /** Asks the owner something: a question in a one-to-one chat; in a group, a mention by id or `@handle`, or a reply. */
47
+ export declare const waitsOnOwner: (newest: NewestMessage) => boolean;
48
+ export declare const replyStatsService: (deps: ServiceDeps) => ReplyStatsService;