@wirecat/cli-core 0.552.0 → 0.554.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/cli/compact.d.ts +4 -0
  3. package/dist/cli/compact.js +65 -7
  4. package/dist/cli/messenger/context.d.ts +3 -0
  5. package/dist/cli/messenger/projects-command.js +38 -11
  6. package/dist/cli/messenger/serve-relay.d.ts +1 -0
  7. package/dist/cli/project-evidence.d.ts +33 -4
  8. package/dist/cli/project-evidence.js +38 -4
  9. package/dist/cli/project-status-rows.js +11 -1
  10. package/dist/cli/project-status.d.ts +24 -0
  11. package/dist/cli/project-status.js +52 -9
  12. package/dist/cli/schedule/schedule-command.d.ts +0 -1
  13. package/dist/cli/schedule/schedule-command.js +6 -1
  14. package/dist/mail/email-input.d.ts +2 -0
  15. package/dist/mail/email-input.js +6 -1
  16. package/dist/mail/retry.js +2 -1
  17. package/dist/mail/sync.js +5 -1
  18. package/dist/mail/testing/fake.d.ts +2 -0
  19. package/dist/mail/testing/fake.js +1 -1
  20. package/dist/services/index.d.ts +4 -3
  21. package/dist/services/index.js +4 -3
  22. package/dist/services/link-scorers/index.d.ts +1 -1
  23. package/dist/services/link-scorers/index.js +1 -1
  24. package/dist/services/link-scorers/scan.d.ts +0 -1
  25. package/dist/services/link-scorers/scan.js +1 -1
  26. package/dist/services/project-purposes.d.ts +28 -0
  27. package/dist/services/project-purposes.js +41 -0
  28. package/dist/services/project-status/index.d.ts +22 -8
  29. package/dist/services/project-status/index.js +17 -9
  30. package/dist/services/stored-project-gathering.d.ts +79 -4
  31. package/dist/services/stored-project-gathering.js +171 -22
  32. package/dist/store/messages.d.ts +2 -1
  33. package/dist/store/messages.js +2 -2
  34. package/dist/store/sqlite/email-locations.d.ts +1 -0
  35. package/dist/store/sqlite/email-locations.js +4 -0
  36. package/dist/store/sqlite/emails.d.ts +2 -0
  37. package/dist/store/sqlite/emails.js +3 -6
  38. package/dist/store/sqlite/reads.d.ts +1 -1
  39. package/dist/store/sqlite/reads.js +2 -2
  40. package/package.json +1 -1
  41. package/parity.json +10 -0
@@ -1,7 +1,8 @@
1
1
  import type { ContextSource } from "../context/evidence.js";
2
- import type { MessageStore } from "../store/store.js";
2
+ import type { AccountKey, MessageStore } from "../store/store.js";
3
3
  import { gatherProjectEvidence, type ProjectChatFetcher, type ProjectGatheringPorts } from "./project-gathering.js";
4
4
  import { type EvidenceMentions } from "./project-gathering-mentions.js";
5
+ import { type PurposeConfig } from "./project-purposes.js";
5
6
  export interface StoredProjectInput {
6
7
  project: string;
7
8
  /** The sources the host resolved from its `--source`, and those it could not read. */
@@ -31,19 +32,81 @@ export interface StoredProjectInput {
31
32
  maxFetchMs?: number;
32
33
  onFetchProgress?: ProjectGatheringPorts["onFetchProgress"];
33
34
  signal?: AbortSignal;
35
+ /** The linked chats' purposes to read; `["all"]` reads every one. Left out, the config's defaults. */
36
+ purposes?: readonly string[];
37
+ /** The host's purposes; core's built-in ones when left out. */
38
+ purposeConfig?: PurposeConfig;
39
+ /** Proposed `member-of` links count as linked chats and members too, after the confirmed ones, every item marked. */
40
+ includeProposed?: boolean;
41
+ /** How many messages a left-out chat holds in the window; the store's own count when left out. */
42
+ count?: ChatCounter;
34
43
  }
44
+ export interface ChatCount {
45
+ messages: number;
46
+ firstAt: string | null;
47
+ lastAt: string | null;
48
+ }
49
+ export type ChatCounter = (account: AccountKey, chatId: string, window: {
50
+ since: number;
51
+ until: number;
52
+ }) => Promise<ChatCount>;
53
+ /** The store's count of one chat's messages in a window, `until` exclusive, and the first and last of them. */
54
+ export declare const storedChatCount: (store: MessageStore) => ChatCounter;
35
55
  /** `project:<id>`, or the one project a key, name or alias names; several or none is refused, never guessed. */
36
56
  export declare const canonicalProjectRef: (store: MessageStore, reference: string) => Promise<string>;
57
+ export interface ProjectMember {
58
+ person: string;
59
+ state: "read" | "member-limit" | "no-identity";
60
+ /** The proposal that names this person a member; read only with `includeProposed`. */
61
+ proposed?: string;
62
+ score?: number;
63
+ }
37
64
  export interface LinkedChat {
38
65
  provider: string;
39
66
  account: string;
40
67
  chat: string;
41
68
  /** The link's free role (`source report`, `what worked`), if the owner set one. */
42
69
  role: string | null;
70
+ /** What the chat is for in the project; `null` means `work`. */
71
+ purpose: string | null;
72
+ /** The confirmed link's id; null for a proposed one. */
73
+ relationship: string | null;
74
+ /** The proposal that links the chat; only with `includeProposed`. */
75
+ proposed?: string;
76
+ score?: number;
77
+ }
78
+ /**
79
+ * Links the owner confirmed, then, when asked, the proposed ones: a suggestion would put a whole chat into the
80
+ * evidence on a guess, so it is read only on request and marked.
81
+ */
82
+ export declare const linkedChatsOf: (store: MessageStore, project: string | null, options?: {
83
+ includeProposed?: boolean;
84
+ }) => Promise<LinkedChat[]>;
85
+ /** A linked chat whose purpose this run did not read: counted, never listed. */
86
+ export interface LeftOutChat {
87
+ ref: string;
88
+ provider: string;
89
+ account: string;
90
+ chat: string;
91
+ title: string | null;
92
+ purpose: string;
93
+ relationship: string | null;
94
+ proposed?: string;
95
+ messages: number;
96
+ firstAt: string | null;
97
+ lastAt: string | null;
43
98
  }
44
- /** Only links the owner confirmed: a suggested one would put a whole chat into the evidence on a guess. */
45
- export declare const linkedChatsOf: (store: MessageStore, project: string | null) => Promise<LinkedChat[]>;
46
- export type StoredProjectGathering = Awaited<ReturnType<typeof gatherProjectEvidence>> & {
99
+ type Gathered = Awaited<ReturnType<typeof gatherProjectEvidence>>;
100
+ export type StoredProjectGathering = Omit<Gathered, "items" | "linkedChats"> & {
101
+ /** `proposed`: the proposal whose link alone brought the item in; only with `includeProposed`. */
102
+ items: (Gathered["items"][number] & {
103
+ proposed?: string;
104
+ })[];
105
+ linkedChats: (Gathered["linkedChats"][number] & {
106
+ purpose: string;
107
+ proposed?: string;
108
+ score?: number;
109
+ })[];
47
110
  projectRef: string | null;
48
111
  unreadSources: {
49
112
  source: string;
@@ -51,9 +114,21 @@ export type StoredProjectGathering = Awaited<ReturnType<typeof gatherProjectEvid
51
114
  reason?: "out-of-scope" | "linked-chat-limit" | "member-limit" | "no-identity" | "unknown-provider";
52
115
  }[];
53
116
  mentions: EvidenceMentions;
117
+ /** The purposes read: `all`, or the list. */
118
+ purposes: "all" | string[];
119
+ leftOut: LeftOutChat[];
120
+ members: ProjectMember[];
121
+ /** One linked chat with no purpose set gave most of this run's items; a first run only, so it is said once. */
122
+ purposeHint: {
123
+ chat: string;
124
+ relationship: string;
125
+ items: number;
126
+ of: number;
127
+ } | null;
54
128
  };
55
129
  /**
56
130
  * `projects evidence` over a whole store: the project's names, key and aliases, the given words and contacts, its
57
131
  * confirmed person members and its confirmed linked chats, in the sources the host resolved.
58
132
  */
59
133
  export declare const gatherStoredProject: (store: MessageStore, input: StoredProjectInput) => Promise<StoredProjectGathering>;
134
+ export {};
@@ -4,6 +4,21 @@ import { chatCoverage } from "./chat-coverage.js";
4
4
  import { gatherProjectEvidence } from "./project-gathering.js";
5
5
  import { storedNeighbours } from "./project-gathering-around.js";
6
6
  import { findEvidenceMentions } from "./project-gathering-mentions.js";
7
+ import { LINK_PURPOSE, purposeRead, purposesOf } from "./project-purposes.js";
8
+ /** The store's count of one chat's messages in a window, `until` exclusive, and the first and last of them. */
9
+ export const storedChatCount = (store) => async (account, chatId, { since, until }) => {
10
+ const window = { since: new Date(since).toISOString(), until: new Date(until - 1).toISOString() };
11
+ const [messages, newest, oldest] = await Promise.all([
12
+ store.messages.countMessages(account, chatId, window),
13
+ store.messages.messages(account, chatId, { limit: 1, since: window.since, until: until - 1 }),
14
+ store.messages.messages(account, chatId, { limit: 1, after: { time: since - 1 }, until: until - 1 }),
15
+ ]);
16
+ return {
17
+ messages,
18
+ firstAt: oldest.items[0]?.timestamp ?? null,
19
+ lastAt: newest.items.at(-1)?.timestamp ?? null,
20
+ };
21
+ };
7
22
  const PROJECT_ID = /^project:[1-9]\d*$/;
8
23
  /** `project:<id>`, or the one project a key, name or alias names; several or none is refused, never guessed. */
9
24
  export const canonicalProjectRef = async (store, reference) => {
@@ -85,59 +100,122 @@ const contactOf = async (store, contact) => {
85
100
  };
86
101
  const LINKED_CHATS = 16;
87
102
  const CORE_LIMIT = 16;
88
- /** Confirmed `person` members, in a stable order, so a cut at the lane limit always drops the same ones. */
89
- const memberRefsOf = async (store, project) => {
103
+ /** Pending `member-of` proposals to the project, oldest first, so a cut at a limit always drops the same ones. */
104
+ const proposedLinksOf = async (store, project) => {
105
+ const stored = await store.proposedActions.list({ kinds: ["relationship"], status: "proposed", project, limit: 500 });
106
+ const proposals = stored
107
+ .flatMap((action) => {
108
+ const payload = action.payload;
109
+ const { purpose, score } = action.payload;
110
+ if (payload?.kind !== "member-of" || payload.to !== project || typeof payload.from !== "string")
111
+ return [];
112
+ return [
113
+ {
114
+ from: payload.from,
115
+ id: action.id,
116
+ role: typeof payload.role === "string" ? payload.role : null,
117
+ purpose: typeof purpose === "string" ? purpose : null,
118
+ ...(typeof score === "number" ? { score } : {}),
119
+ },
120
+ ];
121
+ })
122
+ .sort((a, b) => Number(a.id) - Number(b.id));
123
+ const legacy = (await store.knowledge.relations(null, project))
124
+ .filter((one) => !one.confirmed && one.kind === "member-of" && one.to === project)
125
+ .map((one) => ({ from: one.from, id: `link:${one.id}`, role: one.role, purpose: one.purpose }));
126
+ return [...proposals, ...legacy];
127
+ };
128
+ /** Confirmed `person` members in a stable order, then proposed ones when asked, so a cut drops the same ones. */
129
+ const memberRefsOf = async (store, project, includeProposed) => {
90
130
  if (!project)
91
131
  return [];
92
- const refs = [];
132
+ const confirmed = new Set();
93
133
  for (const relation of await store.knowledge.relations(null, project))
94
134
  if (relation.confirmed &&
95
135
  relation.kind === "member-of" &&
96
136
  relation.to === project &&
97
137
  /^person:.+/.test(relation.from))
98
- refs.push(relation.from);
99
- return [...new Set(refs)].sort();
138
+ confirmed.add(relation.from);
139
+ const members = [...confirmed].sort().map((ref) => ({ ref }));
140
+ if (!includeProposed)
141
+ return members;
142
+ for (const one of await proposedLinksOf(store, project))
143
+ if (/^person:.+/.test(one.from) && !members.some((member) => member.ref === one.from))
144
+ members.push({ ref: one.from, proposed: one.id, ...(one.score === undefined ? {} : { score: one.score }) });
145
+ return members;
100
146
  };
101
147
  /**
102
148
  * Members are read by what their identities wrote, like `--contact person:<id>` but without the owner's aliases:
103
149
  * a short alias would add a text lane that matches mentions, and membership is about who wrote, not who was named.
104
150
  */
105
- const memberLanesOf = async (store, project, given, room) => {
151
+ const memberLanesOf = async (store, project, given, room, includeProposed) => {
106
152
  const lanes = [];
107
153
  const left = [];
108
- for (const reference of (await memberRefsOf(store, project)).filter((one) => !given.has(one))) {
154
+ const rows = [];
155
+ for (const member of (await memberRefsOf(store, project, includeProposed)).filter((one) => !given.has(one.ref))) {
156
+ const { ref: reference, ...marks } = member;
109
157
  const person = await store.people.personByUid(reference.slice("person:".length));
110
158
  const senders = person ? sendersOf(person) : [];
111
- if (!senders.length)
112
- left.push({ source: reference, kind: "member", reason: "no-identity" });
113
- else if (lanes.length >= room)
114
- left.push({ source: reference, kind: "member", reason: "member-limit" });
115
- else
159
+ const state = !senders.length ? "no-identity" : lanes.length >= room ? "member-limit" : "read";
160
+ if (state === "read")
116
161
  lanes.push({ reference, senders });
162
+ else
163
+ left.push({ source: reference, kind: "member", reason: state });
164
+ rows.push({ person: reference, state, ...marks });
117
165
  }
118
- return { lanes, left };
166
+ return { lanes, left, rows };
119
167
  };
120
- /** Only links the owner confirmed: a suggested one would put a whole chat into the evidence on a guess. */
121
- export const linkedChatsOf = async (store, project) => {
168
+ const chatOf = (from) => {
169
+ if (!from.startsWith("chat:"))
170
+ return undefined;
171
+ const reference = parseReference(from);
172
+ return reference.type === "chat" ? reference : undefined;
173
+ };
174
+ /**
175
+ * Links the owner confirmed, then, when asked, the proposed ones: a suggestion would put a whole chat into the
176
+ * evidence on a guess, so it is read only on request and marked.
177
+ */
178
+ export const linkedChatsOf = async (store, project, options = {}) => {
122
179
  if (!project)
123
180
  return [];
124
181
  const chats = [];
125
182
  for (const relation of await store.knowledge.relations(null, project)) {
126
183
  if (!relation.confirmed || relation.kind !== "member-of" || relation.to !== project)
127
184
  continue;
128
- if (!relation.from.startsWith("chat:"))
129
- continue;
130
- const reference = parseReference(relation.from);
131
- if (reference.type === "chat")
185
+ const reference = chatOf(relation.from);
186
+ if (reference)
132
187
  chats.push({
133
188
  provider: reference.provider,
134
189
  account: reference.account,
135
190
  chat: reference.chat,
136
191
  role: relation.role,
192
+ purpose: relation.purpose,
193
+ relationship: relation.id,
137
194
  });
138
195
  }
196
+ if (!options.includeProposed)
197
+ return chats;
198
+ for (const one of await proposedLinksOf(store, project)) {
199
+ const reference = chatOf(one.from);
200
+ if (!reference)
201
+ continue;
202
+ if (chats.some((chat) => chat.provider === reference.provider && chat.account === reference.account && chat.chat === reference.chat))
203
+ continue;
204
+ chats.push({
205
+ provider: reference.provider,
206
+ account: reference.account,
207
+ chat: reference.chat,
208
+ role: one.role,
209
+ purpose: one.purpose,
210
+ relationship: null,
211
+ proposed: one.id,
212
+ ...(one.score === undefined ? {} : { score: one.score }),
213
+ });
214
+ }
139
215
  return chats;
140
216
  };
217
+ /** Over half the items and at least this many, from one chat's membership alone: worth asking what the chat is for. */
218
+ const HINT_ITEMS = 20;
141
219
  /**
142
220
  * `projects evidence` over a whole store: the project's names, key and aliases, the given words and contacts, its
143
221
  * confirmed person members and its confirmed linked chats, in the sources the host resolved.
@@ -160,9 +238,12 @@ export const gatherStoredProject = async (store, input) => {
160
238
  (input.keywords?.length ?? 0) +
161
239
  contacts.filter((one) => one.name).length;
162
240
  const room = Math.min(CORE_LIMIT - contacts.length, CORE_LIMIT - searchLanes - contacts.filter((one) => one.senders).length);
163
- const members = await memberLanesOf(store, project.ref, new Set(contacts.flatMap((one) => one.reference ?? [])), room);
241
+ const includeProposed = input.includeProposed === true;
242
+ const purposes = purposesOf(input.purposes, input.purposeConfig, project);
243
+ const members = await memberLanesOf(store, project.ref, new Set(contacts.flatMap((one) => one.reference ?? [])), room, includeProposed);
164
244
  const people = [...contacts, ...members.lanes];
165
- const linked = await linkedChatsOf(store, project.ref);
245
+ const all = await linkedChatsOf(store, project.ref, { includeProposed });
246
+ const linked = all.filter((one) => purposeRead(purposes, one.purpose));
166
247
  const linkedChats = linked.slice(0, LINKED_CHATS).map(({ provider, account, chat }) => ({ provider, account, chat }));
167
248
  const budgets = {
168
249
  ...(input.maxReads === undefined ? {} : { maxReads: input.maxReads }),
@@ -208,5 +289,73 @@ export const gatherStoredProject = async (store, input) => {
208
289
  reason: "linked-chat-limit",
209
290
  })),
210
291
  ];
211
- return { projectRef: project.ref, unreadSources: [...unread, ...unreadChats, ...members.left], ...gathered, mentions };
292
+ const refOf = (one) => formatReference({ type: "chat", ...one });
293
+ const byRef = new Map(linked.map((one) => [refOf(one), one]));
294
+ const proposedLanes = new Map();
295
+ for (const row of gathered.linkedChats) {
296
+ const proposed = byRef.get(row.ref)?.proposed;
297
+ if (proposed && row.lane !== null)
298
+ proposedLanes.set(row.lane, proposed);
299
+ }
300
+ for (const lane of gathered.lanes) {
301
+ const proposed = members.rows.find((one) => one.proposed && lane.people.includes(one.person))?.proposed;
302
+ if (proposed && lane.kinds.includes("sender"))
303
+ proposedLanes.set(lane.id, proposed);
304
+ }
305
+ // Marked only when nothing but proposed links found it: an item a word or a confirmed link found is evidence anyway.
306
+ const items = gathered.items.map((item) => {
307
+ const proposed = item.lanes.every((lane) => proposedLanes.has(lane))
308
+ ? proposedLanes.get(item.lanes[0] ?? "")
309
+ : undefined;
310
+ return proposed ? { ...item, proposed } : item;
311
+ });
312
+ const linkedRows = gathered.linkedChats.map((row) => {
313
+ const link = byRef.get(row.ref);
314
+ return {
315
+ ...row,
316
+ purpose: link?.purpose ?? LINK_PURPOSE,
317
+ ...(link?.proposed ? { proposed: link.proposed } : {}),
318
+ ...(link?.score === undefined ? {} : { score: link.score }),
319
+ };
320
+ });
321
+ const count = input.count ?? storedChatCount(store);
322
+ const leftOut = [];
323
+ for (const one of all.filter((chat) => !purposeRead(purposes, chat.purpose))) {
324
+ const account = { provider: one.provider, account: one.account };
325
+ const [counted, [held]] = await Promise.all([
326
+ count(account, one.chat, { since: gathered.window.after, until: gathered.window.before }),
327
+ chatCoverage(store, account, [one.chat]),
328
+ ]);
329
+ leftOut.push({
330
+ ref: refOf(one),
331
+ provider: one.provider,
332
+ account: one.account,
333
+ chat: one.chat,
334
+ title: held?.title ?? null,
335
+ purpose: one.purpose ?? LINK_PURPOSE,
336
+ relationship: one.relationship,
337
+ ...(one.proposed ? { proposed: one.proposed } : {}),
338
+ ...counted,
339
+ });
340
+ }
341
+ return {
342
+ projectRef: project.ref,
343
+ unreadSources: [...unread, ...unreadChats, ...members.left],
344
+ ...gathered,
345
+ items,
346
+ linkedChats: linkedRows,
347
+ mentions,
348
+ purposes: purposes === "all" ? "all" : [...purposes],
349
+ leftOut,
350
+ members: members.rows,
351
+ purposeHint: gathered.resumed ? null : purposeHintOf(linked, linkedRows, items.length),
352
+ };
353
+ };
354
+ const purposeHintOf = (linked, rows, of) => {
355
+ const [top] = [...rows].sort((a, b) => (b.items ?? 0) - (a.items ?? 0));
356
+ const items = top?.items ?? 0;
357
+ const link = linked.find((one) => formatReference({ type: "chat", ...one }) === top?.ref);
358
+ if (!top || !link?.relationship || link.purpose !== null || items < HINT_ITEMS || items * 2 <= of)
359
+ return null;
360
+ return { chat: top.ref, relationship: link.relationship, items, of };
212
361
  };
@@ -45,9 +45,10 @@ export interface AccountMessages {
45
45
  * ends the window; `reads.MessagePageWindow` says how.
46
46
  */
47
47
  messages(key: AccountKey, chatId: Id, window: reads.MessagePageWindow): Promise<Page<Message>>;
48
- /** How many stored messages the chat has, sent at `since` or later when it is given. */
48
+ /** How many stored messages the chat has, sent at `since` or later and at `until` or earlier when given. */
49
49
  countMessages(key: AccountKey, chatId: Id, options?: {
50
50
  since?: string;
51
+ until?: string;
51
52
  }): Promise<number>;
52
53
  /** Saved or edited after `at` (ISO, this machine's clock), oldest first, and the ids deleted after it. */
53
54
  changes(key: AccountKey, chatId: Id, at: string): Promise<{
@@ -32,9 +32,9 @@ export const accountMessagesOver = (context) => {
32
32
  const chatKey = chatKeyOf(key, chatId);
33
33
  return chatKey === undefined ? { items: [], hasMore: false } : reads.messagePage(context, chatKey, window);
34
34
  },
35
- countMessages: async (key, chatId, { since } = {}) => {
35
+ countMessages: async (key, chatId, { since, until } = {}) => {
36
36
  const chatKey = chatKeyOf(key, chatId);
37
- return chatKey === undefined ? 0 : reads.countMessages(context, chatKey, since);
37
+ return chatKey === undefined ? 0 : reads.countMessages(context, chatKey, since, until);
38
38
  },
39
39
  changes: async (key, chatId, at) => {
40
40
  const chatKey = chatKeyOf(key, chatId);
@@ -59,3 +59,4 @@ export declare const partialMail: (database: CacheDatabase) => PartialMailStore;
59
59
  export declare const storedWhole: (database: CacheDatabase, emailId: number) => boolean;
60
60
  export declare const setLocations: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string, found: ReadonlyMap<string, number[]>) => number;
61
61
  export declare const forgetLocations: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string, uids: number[]) => void;
62
+ export declare const backfillSentAt: (database: CacheDatabase, accountId: number, limit: number) => number;
@@ -107,3 +107,7 @@ export const forgetLocations = (database, accountId, folder, uidvalidity, uids)
107
107
  for (const emailId of new Set(emails))
108
108
  leave.run(emailId, Number(box.id), emailId, Number(box.id));
109
109
  };
110
+ export const backfillSentAt = (database, accountId, limit) => Number(database
111
+ .prepare(`UPDATE emails SET sent_at = received_at WHERE id IN (
112
+ SELECT id FROM emails WHERE account_id = ? AND sent_at IS NULL AND received_at IS NOT NULL LIMIT ?)`)
113
+ .run(accountId, limit).changes);
@@ -238,6 +238,8 @@ export interface MailStore extends PartialMailStore {
238
238
  read?: boolean;
239
239
  flagged?: boolean;
240
240
  }>, now: number): Promise<number>;
241
+ /** Gives up to `limit` emails stored without `sent_at` their internal date; answers how many. */
242
+ backfillSentAt(accountId: number, limit: number): Promise<number>;
241
243
  /** The live emails whose location in `folder` has this UIDVALIDITY, oldest UID first. */
242
244
  folderEmails(accountId: number, folder: string, uidvalidity: string): Promise<FolderEmail[]>;
243
245
  /**
@@ -2,7 +2,7 @@ import { formatLocator } from "../../domain/locator.js";
2
2
  import { formatReference } from "../../domain/references.js";
3
3
  import { fold, normalize } from "../normalize.js";
4
4
  import { chunkAttachments, drainEmailIndex, dropAttachmentChunks, MAIL_PIECES, queueThreads, threadPieces, titleOf, } from "./email-index.js";
5
- import { folderEmails, forgetLocations, locate, partialMail, setLocations, storedWhole, } from "./email-locations.js";
5
+ import { backfillSentAt, folderEmails, forgetLocations, locate, partialMail, setLocations, storedWhole, } from "./email-locations.js";
6
6
  import { markEmailsDeleted, markEmailsRestored, recountEmailThread as recount, setEmailFlags } from "./email-state.js";
7
7
  import { bool, flag, fromJson, int, page, str, toJson } from "./events.js";
8
8
  import { folded } from "./folded.js";
@@ -554,11 +554,8 @@ export const mailStoreOver = ({ database, orm, now }, stemmerFor = stemmerCache(
554
554
  return count;
555
555
  });
556
556
  },
557
- async setFlags(accountId, flags, now) {
558
- return write(database, () => {
559
- return setEmailFlags(database, accountId, flags, now);
560
- });
561
- },
557
+ setFlags: async (accountId, flags, now) => write(database, () => setEmailFlags(database, accountId, flags, now)),
558
+ backfillSentAt: async (accountId, limit) => write(database, () => backfillSentAt(database, accountId, limit)),
562
559
  folderEmails: async (accountId, folder, uidvalidity) => folderEmails(database, accountId, folder, uidvalidity),
563
560
  setLocations: async (accountId, folder, uidvalidity, found) => write(database, () => setLocations(database, accountId, folder, uidvalidity, found)),
564
561
  forgetLocations: async (accountId, folder, uidvalidity, uids) => write(database, () => forgetLocations(database, accountId, folder, uidvalidity, uids)),
@@ -882,7 +882,7 @@ export declare const selectMessages: ({ orm }: StoreContext) => Omit<import("dri
882
882
  export declare const newestFirst: SQL<unknown>[];
883
883
  export declare const before: (sentAt: number, pk: number) => SQL<unknown>;
884
884
  export declare const toMessages: ({ orm }: StoreContext, rows: MessageRow[]) => Message[];
885
- export declare const countMessages: ({ orm }: StoreContext, chatKey: number, since: string | undefined) => number;
885
+ export declare const countMessages: ({ orm }: StoreContext, chatKey: number, since: string | undefined, until?: string) => number;
886
886
  /**
887
887
  * What changed in a chat after `at`, a time on this machine's clock: messages first saved or edited
888
888
  * since, oldest first, and the ids deleted since. Send time cannot answer it — an edit made today to
@@ -51,10 +51,10 @@ export const toMessages = ({ orm }, rows) => {
51
51
  }
52
52
  return rows.map((row) => toMessage(row, byMessage.get(row.pk) ?? []));
53
53
  };
54
- export const countMessages = ({ orm }, chatKey, since) => Number(orm
54
+ export const countMessages = ({ orm }, chatKey, since, until) => Number(orm
55
55
  .select({ n: sql `count(*)` })
56
56
  .from(messages)
57
- .where(live(chatKey, since === undefined ? undefined : gte(messages.sentAt, toMs(since))))
57
+ .where(live(chatKey, since === undefined ? undefined : gte(messages.sentAt, toMs(since)), until === undefined ? undefined : lte(messages.sentAt, toMs(until))))
58
58
  .get()?.n);
59
59
  /**
60
60
  * What changed in a chat after `at`, a time on this machine's clock: messages first saved or edited
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.552.0",
3
+ "version": "0.554.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
package/parity.json CHANGED
@@ -478,6 +478,9 @@
478
478
  "meaning": "every chat of these kinds, comma-separated",
479
479
  "note": "a folder (`chats folders`) or an export folder (`store export`) takes these chats"
480
480
  },
481
+ "--include-proposed": {
482
+ "meaning": "also read chats and members proposed for the project and not yet confirmed, each item marked"
483
+ },
481
484
  "--into": {
482
485
  "value": "<id>",
483
486
  "meaning": "the task that stays: a duplicate is merged into it, or a message is linked to it instead of opening a task"
@@ -832,6 +835,10 @@
832
835
  "value": "<url>",
833
836
  "meaning": "the tunnel's https address: the browser apps reach mcp serve --http through it, a messenger delivers to bot watch"
834
837
  },
838
+ "--purpose": {
839
+ "value": "<purposes>",
840
+ "meaning": "the linked chats' purposes to read, comma-separated, or all; a chat left out is counted"
841
+ },
835
842
  "--question": {
836
843
  "meaning": "match only questions"
837
844
  },
@@ -3707,9 +3714,11 @@
3707
3714
  "--contact": "all",
3708
3715
  "--context-query": "all",
3709
3716
  "--cursor": "all",
3717
+ "--include-proposed": "all",
3710
3718
  "--limit": "all",
3711
3719
  "--max-pages": "all",
3712
3720
  "--no-catch-up": "all",
3721
+ "--purpose": "all",
3713
3722
  "--since-time": "all",
3714
3723
  "--sync-first": "all",
3715
3724
  "--sync-time": "all"
@@ -3723,6 +3732,7 @@
3723
3732
  "options": {
3724
3733
  "--before-time": "all",
3725
3734
  "--context-query": "all",
3735
+ "--purpose": "all",
3726
3736
  "--since-time": "all"
3727
3737
  }
3728
3738
  },