@wirecat/cli-core 0.492.0 → 0.493.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 CHANGED
@@ -7,6 +7,39 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.493.0 — 31.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`chatCoverage(store, account, chatIds)` says what the store holds of each chat.** Per chat, in the order asked:
15
+ `completeness` (`complete`, `partial` or `unknown`, up to date, gaps, start reached, last fetch) and `held` (messages,
16
+ oldest, newest), never text. It reads only the chats named, so a store of thousands of chats costs what the ids cost.
17
+ `coverageGaps` also takes a list of these rows and answers `missing` (`field: "history"`) and `next`: for the first
18
+ incomplete chat, `messages sync <chat> --since-time <start of the window>` when the answer has a window, then
19
+ `messages sync <chat>`. The step `messages.sync` takes `since_time`.
20
+ - **`messages list --offline` says when the store holds only part of the chat.** A chat that is `partial` or was never
21
+ fetched gives `meta.complete: false`, exit code 0, `missing` and `next`, as an offline read of 262 of 777 messages
22
+ must not pass for the whole chat. Online reads are unchanged.
23
+ - **`tasks agenda` says how much of each listed chat the store holds.** Each row of `owed`, `waiting` and `unsynced` has
24
+ `completeness` and `held`; a chat held only in part is marked `[partial]` or `[never fetched]` in the text, with one
25
+ line above the lists. The answer then has `meta.complete: false`, `missing` and `next` (bounded sync over the
26
+ `--within` window, then the whole chat).
27
+ - **`search all` names the chat to sync** when the store holds the chats its messages were searched in only in part:
28
+ `missing` (`field: "coverage"`) and `next`, from the same coverage `search messages` reports. `SearchAllFound` gains
29
+ `coverage`.
30
+
31
+ ### Changed — may break callers
32
+
33
+ - **`tasks agenda --json` rows gain `completeness` and `held`**, and the answer may carry `meta.complete: false` with
34
+ `missing` and `next`; a caller that stops on anything but `meta.complete: true` now sees a store that holds chats in
35
+ part as incomplete. `tasks agenda` is declared `partial`, so the exit code stays 0. `AgendaChat` gains `coverage`
36
+ and `Agenda` gains `since`.
37
+ - **`search messages`, `search mail` and `search all` answer `meta.complete: false` when a chat searched is partial or
38
+ never fetched**, where they kept `true` and put the gap only in `missing` and `next`. Exit code stays 0 (the commands
39
+ are declared `partial`). A caller that read `meta.complete` as "no source failed" must read `missing` for the reason.
40
+ - **`messages list --offline --json` may carry `meta.complete: false`, `missing` and `next`** for a chat held in part.
41
+ `messages list` is declared `partial`: the exit code stays 0.
42
+
10
43
  ## 0.492.0 — 31.10.2026
11
44
 
12
45
  ### Added
@@ -5,7 +5,9 @@ import { isLocator, parseLocator } from "../../domain/locator.js";
5
5
  import { SAVE_HELP } from "../../meetings/index.js";
6
6
  import { renderMessages } from "../../render/messages.js";
7
7
  import { renderThreadLinks } from "../../render/thread-context.js";
8
+ import { chatCoverage } from "../../services/chat-coverage.js";
8
9
  import { readMail } from "../../services/mail-search.js";
10
+ import { storedChatId } from "../../services/message-resolution.js";
9
11
  import { modelWith, spoken } from "../../speech/hearing.js";
10
12
  import { REMOTE_READ } from "../effects.js";
11
13
  import { itemOf, listOf, renderAnswer } from "../envelope.js";
@@ -30,6 +32,7 @@ import { pinCommand, starCommands, unpinCommand } from "./messages-pin-command.j
30
32
  import { pressCommand } from "./messages-press-command.js";
31
33
  import { scheduledCommand } from "./messages-scheduled-command.js";
32
34
  import { sendCommand } from "./messages-send-command.js";
35
+ import { coverageGaps } from "./search-answers.js";
33
36
  import { threadOptions, threadRequest } from "./thread-options.js";
34
37
  import { transcribeSubcommand } from "./transcribe-command.js";
35
38
  /** `messages`: reading, and sending through the guard. A CLI may add its own subcommands. */
@@ -40,6 +43,7 @@ export const messagesCommand = (messenger) => {
40
43
  const list = annotate(messages.command("list"), {
41
44
  examples: [`${messenger.app.command} personal messages list "Book club" --limit 20`],
42
45
  effects: REMOTE_READ,
46
+ partial: true,
43
47
  })
44
48
  .description("a chat's messages, oldest to newest")
45
49
  .addHelpText("after", SAVE_HELP)
@@ -68,6 +72,12 @@ export const messagesCommand = (messenger) => {
68
72
  const next = page.hasMore
69
73
  ? cursorOf(window, { first: page.items[0]?.id, last: page.items.at(-1)?.id })
70
74
  : undefined;
75
+ // Offline, the store is the whole answer: say how much of the chat it holds. Online the messenger is read.
76
+ const gaps = context.settings.offline
77
+ ? coverageGaps(await context.withStore(async (store, account) => chatCoverage(store, account, [
78
+ await storedChatId(messenger, chat, store, account, { includeLeft: true }),
79
+ ])))
80
+ : { next: [], missing: [] };
71
81
  renderAnswer(this, context, listOf("messages list", {
72
82
  items: messageItems(heardItems(page.items, hearing), reader),
73
83
  limit,
@@ -75,7 +85,9 @@ export const messagesCommand = (messenger) => {
75
85
  paging: "cursor",
76
86
  ...(next === undefined ? {} : { cursor: next }),
77
87
  args: { chat, ...(query.length > 0 ? { query: query.join(" ") } : {}) },
78
- missing: hearingMissing(hearing, transcribe === true),
88
+ next: gaps.next,
89
+ missing: [...hearingMissing(hearing, transcribe === true), ...gaps.missing],
90
+ ...(gaps.missing.length > 0 ? { meta: { complete: false } } : {}),
79
91
  }), () => renderMessages(spokenItems(page.items, hearing), viewOf(context, messenger)));
80
92
  });
81
93
  annotate(list, { output: { entity: "message" }, query: history });
@@ -1,6 +1,7 @@
1
1
  import type { Missing, NextStep } from "@wirecat/cli-base/standards";
2
2
  import type { InferOutput } from "valibot";
3
3
  import type { ENTITIES } from "../../entities/index.js";
4
+ import type { ChatCoverage } from "../../services/chat-coverage.js";
4
5
  import type { FoundMessage, SearchFound } from "../../services/messages.js";
5
6
  import type { MeaningSkipped } from "../../services/messages-discovery.js";
6
7
  import type { FoundNote, NotesFoundOf } from "../../services/notes-search.js";
@@ -16,8 +17,11 @@ export type HitItem = InferOutput<(typeof ENTITIES)["hit"]>;
16
17
  /** The page a `--page` asks for, as the offset the search services take. */
17
18
  export declare const offsetOf: (page: number | undefined, limit: number) => number | undefined;
18
19
  export declare const messageHitItem: (hit: FoundMessage, query?: string) => MessageHitItem;
19
- /** What the archive lacked for this answer, and the step that would fill it. */
20
- export declare const coverageGaps: (found: Pick<SearchFound, "coverage" | "refreshed">, shown: number) => {
20
+ /**
21
+ * What the archive lacked for this answer, and the step that would fill it. From a search's coverage, or from the
22
+ * chats an answer is about (`since` is the start of the window it was asked for).
23
+ */
24
+ export declare const coverageGaps: (found: Pick<SearchFound, "coverage" | "refreshed"> | ChatCoverage[], shown?: number, since?: string) => {
21
25
  next: NextStep[];
22
26
  missing: Missing[];
23
27
  };
@@ -88,15 +88,49 @@ export const messageHitItem = (hit, query = "") => {
88
88
  };
89
89
  const accountName = ({ provider, account }) => `${provider}:${account}`;
90
90
  /** A source left out is an issue by its code; the sentence saying why goes to a person as a note, never here. */
91
- const metaOf = (accounts, skipped = []) => ({
91
+ const metaOf = (accounts, skipped = [], heldInPart = false) => ({
92
92
  accounts: accounts.map(accountName),
93
- complete: skipped.length === 0,
93
+ complete: skipped.length === 0 && !heldInPart,
94
94
  ...(skipped.length
95
95
  ? { issues: skipped.map(({ source, resource, code }) => ({ code, source: source ?? resource ?? "" })) }
96
96
  : {}),
97
97
  });
98
- /** What the archive lacked for this answer, and the step that would fill it. */
99
- export const coverageGaps = (found, shown) => {
98
+ const day = (at) => at?.slice(0, 10) ?? "no date";
99
+ /**
100
+ * The steps for chats the store holds only in part. Bounded first, when the answer's window is known, then the whole
101
+ * chat; both name the first chat only, because a download is the owner's call. Counts and dates only, never text.
102
+ */
103
+ const chatGaps = (rows, since) => {
104
+ const open = rows.filter(({ completeness }) => completeness.state !== "complete");
105
+ const [first] = open;
106
+ if (!first)
107
+ return { next: [], missing: [] };
108
+ const held = first.completeness.state === "unknown" ? "never fetched" : `held from ${day(first.held.oldestAt)} only`;
109
+ const steps = (args, why) => ({
110
+ ...nextStep("messages.sync", { args: { chat: first.chatId, ...args } }),
111
+ why,
112
+ });
113
+ return {
114
+ next: [
115
+ ...(since === undefined ? [] : [steps({ since_time: since }, `${held}; this reads back to ${day(since)}`)]),
116
+ steps({}, "reads the chat back to its first message; a big group can take minutes"),
117
+ ],
118
+ missing: [
119
+ {
120
+ field: "history",
121
+ why: `${open.length} of ${rows.length} chats hold only part of their history; the first is ${held}`,
122
+ step: "messages.sync",
123
+ },
124
+ ],
125
+ };
126
+ };
127
+ /**
128
+ * What the archive lacked for this answer, and the step that would fill it. From a search's coverage, or from the
129
+ * chats an answer is about (`since` is the start of the window it was asked for).
130
+ */
131
+ export const coverageGaps = (found, shown = 0, since) => {
132
+ if (Array.isArray(found))
133
+ return chatGaps(found, since);
100
134
  const coverage = found.coverage;
101
135
  const stale = found.refreshed?.complete === false;
102
136
  if (!coverage?.next && !stale)
@@ -203,7 +237,7 @@ export const messagesAnswer = (command, found, { limit, page, again }, query = "
203
237
  page: page ?? 1,
204
238
  ...(again ? { args: again } : {}),
205
239
  ...gapsOf(found),
206
- meta: metaOf(accounts, found.skipped),
240
+ meta: metaOf(accounts, found.skipped, coverageGaps(found).missing.length > 0),
207
241
  });
208
242
  };
209
243
  /**
@@ -272,6 +306,7 @@ export const allAnswer = (command, found, { limit, page, again }, exact = false)
272
306
  const scan = found.meetings?.complete === false ? found.meetings : undefined;
273
307
  const notes = meaningGap(found.notes?.meaningSkipped, exact);
274
308
  const messages = messageMeaningGap(found.messages?.meaningSkipped, "found_by");
309
+ const archive = coverageGaps({ coverage: found.coverage }, found.items.length);
275
310
  return listOf(command, {
276
311
  items: found.items.map(allHitItem),
277
312
  limit,
@@ -290,6 +325,7 @@ export const allAnswer = (command, found, { limit, page, again }, exact = false)
290
325
  : []),
291
326
  ...notes.next,
292
327
  ...messages.next.filter((step) => !notes.next.some((asked) => JSON.stringify(asked) === JSON.stringify(step))),
328
+ ...archive.next,
293
329
  ],
294
330
  missing: [
295
331
  ...(scan
@@ -303,7 +339,8 @@ export const allAnswer = (command, found, { limit, page, again }, exact = false)
303
339
  : []),
304
340
  ...notes.missing,
305
341
  ...messages.missing,
342
+ ...archive.missing,
306
343
  ],
307
- meta: metaOf(found.accounts, found.skipped),
344
+ meta: metaOf(found.accounts, found.skipped, archive.missing.length > 0),
308
345
  });
309
346
  };
@@ -15,6 +15,7 @@ import { itemOf, listOf, renderAnswer, snakeKeys } from "../envelope.js";
15
15
  import { listQuery, pagedList, pageOf, queryTerms, typedQuery, withQuery } from "../list-query.js";
16
16
  import { positiveCount } from "../paging.js";
17
17
  import { messengerContext, refuseLocalWrite } from "./context.js";
18
+ import { coverageGaps } from "./search-answers.js";
18
19
  import { refuseJsonl, taskStatsAnswer } from "./stats-answers.js";
19
20
  const line = ({ id, key, state, kind, group, createdAt, message, note }) => [
20
21
  key ?? id.slice(0, 8),
@@ -50,10 +51,16 @@ const applyText = (pass, yes, tool) => yes
50
51
  ...pass.tasks.map((task) => ` ${task.kind.padEnd(8)} ${(task.category ?? "—").padEnd(13)} ${task.source}`),
51
52
  ...(pass.tasks.length > 0 ? [` ${tool} tasks scan ${pass.chat} --yes opens them`] : []),
52
53
  ].join("\n");
54
+ const agendaChats = (agenda) => [...agenda.unsynced, ...agenda.owed, ...agenda.waiting];
55
+ const agendaGaps = (agenda) => coverageGaps(agendaChats(agenda).map(({ coverage }) => coverage), 0, agenda.since);
56
+ const STATE_MARK = { complete: "", partial: " [partial]", unknown: " [never fetched]" };
53
57
  const agendaText = (agenda) => {
54
58
  const chat = (one) => ` ${when(one.lastMessageAt)} ${one.title ?? one.chatId} ${one.lastMessage ?? one.chatId}` +
55
- (one.openTasks.length > 0 ? ` (${one.openTasks.length} open)` : "");
59
+ (one.openTasks.length > 0 ? ` (${one.openTasks.length} open)` : "") +
60
+ STATE_MARK[one.coverage.completeness.state];
61
+ const [history] = agendaGaps(agenda).missing;
56
62
  return [
63
+ ...(history ? [`${history.why}: sync before trusting what is listed`] : []),
57
64
  ...(agenda.stale
58
65
  ? [`the store saved nothing since ${when(agenda.storedAt)}: sync before trusting quiet chats`]
59
66
  : []),
@@ -356,7 +363,11 @@ export const tasksCommand = (messenger) => {
356
363
  const unmerged = await context.withServices((services) => services.tasks.unmerge(dup, writerOf(this)));
357
364
  renderAnswer(this, context, itemOf(snakeKeys(unmerged)), () => `${card(unmerged.task)}\n`);
358
365
  });
359
- withQuery(annotate(tasks.command("agenda"), { effects: LOCAL_READ, examples: [`${tool} personal tasks agenda`] })
366
+ withQuery(annotate(tasks.command("agenda"), {
367
+ effects: LOCAL_READ,
368
+ partial: true,
369
+ examples: [`${tool} personal tasks agenda`],
370
+ })
360
371
  .summary("what needs a look today: tasks due, chats you owe or wait on, what you are owed")
361
372
  .description("what needs a look today, from the local store and without message text: tasks overdue, due or to check, " +
362
373
  "chats where you owe an answer, chats where you wait for one, open tasks someone else does"), AGENDA_QUERY)
@@ -377,7 +388,8 @@ export const tasksCommand = (messenger) => {
377
388
  ...(query ? { query } : {}),
378
389
  holder: holderOf(this),
379
390
  }));
380
- renderAnswer(this, context, itemOf(agendaItem(agenda)), () => `${agendaText(agenda)}\n`);
391
+ const { next, missing } = agendaGaps(agenda);
392
+ renderAnswer(this, context, itemOf(agendaItem(agenda), { next, missing, ...(missing.length > 0 ? { meta: { complete: false } } : {}) }), () => `${agendaText(agenda)}\n`);
381
393
  });
382
394
  annotate(tasks.command("categories"), {
383
395
  output: { entity: "task_categories" },
package/dist/cli/steps.js CHANGED
@@ -14,6 +14,7 @@ export const STEPS = defineSteps([
14
14
  args: [
15
15
  { name: "chat", required: false },
16
16
  { name: "all", required: false },
17
+ { name: "since_time", required: false },
17
18
  { name: "background", required: false },
18
19
  ],
19
20
  why: "the local copy is missing messages; syncing brings them in",
@@ -42,6 +42,20 @@ export declare const agenda: v.StrictObjectSchema<{
42
42
  readonly last_from_owner: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
43
43
  /** Open tasks whose source is in this chat. */
44
44
  readonly open_tasks: v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
45
+ /** `unknown` means nothing ever fetched the chat's history; `partial` that the store holds only part of it. */
46
+ readonly completeness: v.StrictObjectSchema<{
47
+ readonly state: v.PicklistSchema<["complete", "partial", "unknown"], undefined>;
48
+ readonly up_to_date: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
49
+ readonly gaps: v.BooleanSchema<undefined>;
50
+ readonly reaches_start: v.BooleanSchema<undefined>;
51
+ readonly fetched_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>;
52
+ }, undefined>;
53
+ /** What the store holds of the chat: the count and the span, never the text. */
54
+ readonly held: v.StrictObjectSchema<{
55
+ readonly messages: v.NumberSchema<undefined>;
56
+ readonly oldest_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>;
57
+ readonly newest_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>;
58
+ }, undefined>;
45
59
  }, undefined>, undefined>;
46
60
  /** The owner wrote last, and nobody answered. */
47
61
  readonly waiting: v.ArraySchema<v.StrictObjectSchema<{
@@ -55,6 +69,20 @@ export declare const agenda: v.StrictObjectSchema<{
55
69
  readonly last_from_owner: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
56
70
  /** Open tasks whose source is in this chat. */
57
71
  readonly open_tasks: v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
72
+ /** `unknown` means nothing ever fetched the chat's history; `partial` that the store holds only part of it. */
73
+ readonly completeness: v.StrictObjectSchema<{
74
+ readonly state: v.PicklistSchema<["complete", "partial", "unknown"], undefined>;
75
+ readonly up_to_date: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
76
+ readonly gaps: v.BooleanSchema<undefined>;
77
+ readonly reaches_start: v.BooleanSchema<undefined>;
78
+ readonly fetched_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>;
79
+ }, undefined>;
80
+ /** What the store holds of the chat: the count and the span, never the text. */
81
+ readonly held: v.StrictObjectSchema<{
82
+ readonly messages: v.NumberSchema<undefined>;
83
+ readonly oldest_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>;
84
+ readonly newest_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>;
85
+ }, undefined>;
58
86
  }, undefined>, undefined>;
59
87
  /** The chat list says these moved and their newest message is not stored: read them first. */
60
88
  readonly unsynced: v.ArraySchema<v.StrictObjectSchema<{
@@ -68,6 +96,20 @@ export declare const agenda: v.StrictObjectSchema<{
68
96
  readonly last_from_owner: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
69
97
  /** Open tasks whose source is in this chat. */
70
98
  readonly open_tasks: v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
99
+ /** `unknown` means nothing ever fetched the chat's history; `partial` that the store holds only part of it. */
100
+ readonly completeness: v.StrictObjectSchema<{
101
+ readonly state: v.PicklistSchema<["complete", "partial", "unknown"], undefined>;
102
+ readonly up_to_date: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
103
+ readonly gaps: v.BooleanSchema<undefined>;
104
+ readonly reaches_start: v.BooleanSchema<undefined>;
105
+ readonly fetched_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>;
106
+ }, undefined>;
107
+ /** What the store holds of the chat: the count and the span, never the text. */
108
+ readonly held: v.StrictObjectSchema<{
109
+ readonly messages: v.NumberSchema<undefined>;
110
+ readonly oldest_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>;
111
+ readonly newest_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>;
112
+ }, undefined>;
71
113
  }, undefined>, undefined>;
72
114
  /** Open tasks someone else does: what they owe the owner, oldest first. */
73
115
  readonly assigned: v.ArraySchema<v.StrictObjectSchema<{
@@ -13,6 +13,16 @@ const agendaChat = v.strictObject({
13
13
  last_from_owner: v.nullable(v.boolean()),
14
14
  /** Open tasks whose source is in this chat. */
15
15
  open_tasks: v.array(id),
16
+ /** `unknown` means nothing ever fetched the chat's history; `partial` that the store holds only part of it. */
17
+ completeness: v.strictObject({
18
+ state: v.picklist(["complete", "partial", "unknown"]),
19
+ up_to_date: v.nullable(v.boolean()),
20
+ gaps: v.boolean(),
21
+ reaches_start: v.boolean(),
22
+ fetched_at: v.nullable(time),
23
+ }),
24
+ /** What the store holds of the chat: the count and the span, never the text. */
25
+ held: v.strictObject({ messages: v.number(), oldest_at: v.nullable(time), newest_at: v.nullable(time) }),
16
26
  });
17
27
  /** What needs a look today, from the store alone and without one message's text. */
18
28
  export const agenda = v.strictObject({
@@ -9614,6 +9614,18 @@ export declare const ENTITIES: {
9614
9614
  readonly last_message_at: 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`">]>;
9615
9615
  readonly last_from_owner: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
9616
9616
  readonly open_tasks: v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
9617
+ readonly completeness: v.StrictObjectSchema<{
9618
+ readonly state: v.PicklistSchema<["complete", "partial", "unknown"], undefined>;
9619
+ readonly up_to_date: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
9620
+ readonly gaps: v.BooleanSchema<undefined>;
9621
+ readonly reaches_start: v.BooleanSchema<undefined>;
9622
+ readonly fetched_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>;
9623
+ }, undefined>;
9624
+ readonly held: v.StrictObjectSchema<{
9625
+ readonly messages: v.NumberSchema<undefined>;
9626
+ readonly oldest_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>;
9627
+ readonly newest_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>;
9628
+ }, undefined>;
9617
9629
  }, undefined>, undefined>;
9618
9630
  readonly waiting: v.ArraySchema<v.StrictObjectSchema<{
9619
9631
  readonly chat: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
@@ -9623,6 +9635,18 @@ export declare const ENTITIES: {
9623
9635
  readonly last_message_at: 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`">]>;
9624
9636
  readonly last_from_owner: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
9625
9637
  readonly open_tasks: v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
9638
+ readonly completeness: v.StrictObjectSchema<{
9639
+ readonly state: v.PicklistSchema<["complete", "partial", "unknown"], undefined>;
9640
+ readonly up_to_date: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
9641
+ readonly gaps: v.BooleanSchema<undefined>;
9642
+ readonly reaches_start: v.BooleanSchema<undefined>;
9643
+ readonly fetched_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>;
9644
+ }, undefined>;
9645
+ readonly held: v.StrictObjectSchema<{
9646
+ readonly messages: v.NumberSchema<undefined>;
9647
+ readonly oldest_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>;
9648
+ readonly newest_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>;
9649
+ }, undefined>;
9626
9650
  }, undefined>, undefined>;
9627
9651
  readonly unsynced: v.ArraySchema<v.StrictObjectSchema<{
9628
9652
  readonly chat: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
@@ -9632,6 +9656,18 @@ export declare const ENTITIES: {
9632
9656
  readonly last_message_at: 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`">]>;
9633
9657
  readonly last_from_owner: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
9634
9658
  readonly open_tasks: v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
9659
+ readonly completeness: v.StrictObjectSchema<{
9660
+ readonly state: v.PicklistSchema<["complete", "partial", "unknown"], undefined>;
9661
+ readonly up_to_date: v.NullableSchema<v.BooleanSchema<undefined>, undefined>;
9662
+ readonly gaps: v.BooleanSchema<undefined>;
9663
+ readonly reaches_start: v.BooleanSchema<undefined>;
9664
+ readonly fetched_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>;
9665
+ }, undefined>;
9666
+ readonly held: v.StrictObjectSchema<{
9667
+ readonly messages: v.NumberSchema<undefined>;
9668
+ readonly oldest_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>;
9669
+ readonly newest_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>;
9670
+ }, undefined>;
9635
9671
  }, undefined>, undefined>;
9636
9672
  readonly assigned: v.ArraySchema<v.StrictObjectSchema<{
9637
9673
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
@@ -2,6 +2,7 @@ import { type QueryAst } from "@wirecat/cli-base/query";
2
2
  import type * as v from "valibot";
3
3
  import { agenda } from "../entities/agenda.js";
4
4
  import { type ChatActivity, type TaskDetails } from "../store/store.js";
5
+ import { type ChatCoverage } from "./chat-coverage.js";
5
6
  import type { ServiceDeps } from "./deps.js";
6
7
  export interface AgendaOptions {
7
8
  /** ms: a task due within this is listed. */
@@ -33,6 +34,8 @@ export interface AgendaAssigned extends Pick<TaskDetails, "id" | "key" | "title"
33
34
  assignees: string[];
34
35
  }
35
36
  export interface AgendaChat extends Omit<ChatActivity, "lastMessageId"> {
37
+ /** How much of the chat's history the store holds: a chat held from last week alone is not a quiet chat. */
38
+ coverage: ChatCoverage;
36
39
  /** The newest message, as a locator to read around; never its text. `null` while it is not stored. */
37
40
  lastMessage: string | null;
38
41
  /** Open tasks whose source is in this chat. */
@@ -45,6 +48,8 @@ export interface AgendaChat extends Omit<ChatActivity, "lastMessageId"> {
45
48
  */
46
49
  export interface Agenda {
47
50
  at: string;
51
+ /** The start of the window the chats were read for (`within`). */
52
+ since: string;
48
53
  account: string;
49
54
  storedAt: string | null;
50
55
  stale: boolean;
@@ -3,6 +3,7 @@ import { formatLocator, isLocator, parseLocator } from "../domain/locator.js";
3
3
  import { formatReference } from "../domain/references.js";
4
4
  import { agenda } from "../entities/agenda.js";
5
5
  import { AGENDA_COLUMNS, inboxKey, queryFilter, } from "../store/store.js";
6
+ import { chatCoverage } from "./chat-coverage.js";
6
7
  import { CHAT_KINDS } from "./chats.js";
7
8
  import { taskAccount } from "./task-rules.js";
8
9
  import { taskTagFilters } from "./task-tag-filters.js";
@@ -102,21 +103,29 @@ export const agendaService = (deps, now = Date.now) => ({
102
103
  }),
103
104
  openTasks: open.filter((task) => chatOf(task.source) === chat.chatId).map((task) => task.id),
104
105
  }));
106
+ const silentFor = (chat) => at - Date.parse(chat.lastMessageAt);
107
+ const shown = {
108
+ owed: chats.filter((chat) => chat.lastFromOwner === false && silentFor(chat) >= unanswered),
109
+ waiting: chats.filter((chat) => chat.lastFromOwner === true && silentFor(chat) >= waiting),
110
+ unsynced: chats.filter((chat) => chat.lastMessage === null),
111
+ };
112
+ const covered = new Map((await chatCoverage(store, account, Object.values(shown).flatMap((list) => list.map(({ chatId }) => chatId)))).map((one) => [one.chatId, one]));
113
+ const withCoverage = (list) => list.map((chat) => ({ ...chat, coverage: covered.get(chat.chatId) }));
105
114
  const inList = new Set((await store.tasks.categories()).map(({ name }) => name));
106
115
  const unlisted = new Map();
107
116
  for (const { category } of open)
108
117
  if (category && !inList.has(category))
109
118
  unlisted.set(category, (unlisted.get(category) ?? 0) + 1);
110
- const silentFor = (chat) => at - Date.parse(chat.lastMessageAt);
111
119
  return {
112
120
  at: new Date(at).toISOString(),
121
+ since: new Date(at - within).toISOString(),
113
122
  account: taskAccount(account),
114
123
  storedAt: activity.storedAt,
115
124
  stale: activity.storedAt === null || at - Date.parse(activity.storedAt) > DAY,
116
125
  tasks,
117
- owed: chats.filter((chat) => chat.lastFromOwner === false && silentFor(chat) >= unanswered),
118
- waiting: chats.filter((chat) => chat.lastFromOwner === true && silentFor(chat) >= waiting),
119
- unsynced: chats.filter((chat) => chat.lastMessage === null),
126
+ owed: withCoverage(shown.owed),
127
+ waiting: withCoverage(shown.waiting),
128
+ unsynced: withCoverage(shown.unsynced),
120
129
  assigned,
121
130
  unlistedCategories: [...unlisted].sort().map(([name, openTasks]) => ({ name, openTasks })),
122
131
  };
@@ -208,6 +217,18 @@ export const agendaItem = (shown) => {
208
217
  last_message_at: one.lastMessageAt,
209
218
  last_from_owner: one.lastFromOwner,
210
219
  open_tasks: one.openTasks,
220
+ completeness: {
221
+ state: one.coverage.completeness.state,
222
+ up_to_date: one.coverage.completeness.upToDate,
223
+ gaps: one.coverage.completeness.gaps,
224
+ reaches_start: one.coverage.completeness.reachesStart,
225
+ fetched_at: one.coverage.completeness.fetchedAt,
226
+ },
227
+ held: {
228
+ messages: one.coverage.held.messages,
229
+ oldest_at: one.coverage.held.oldestAt,
230
+ newest_at: one.coverage.held.newestAt,
231
+ },
211
232
  });
212
233
  return {
213
234
  at: shown.at,
@@ -0,0 +1,19 @@
1
+ import type { Id } from "../domain/models.js";
2
+ import type { AccountKey, ChatCompleteness, MessageStore } from "../store/store.js";
3
+ /** What the store holds of one chat, and how far that goes: never message text. */
4
+ export interface ChatCoverage {
5
+ chatId: Id;
6
+ /** `null` while the store holds no message of the chat: titles come with the stats. */
7
+ title: string | null;
8
+ completeness: ChatCompleteness;
9
+ held: {
10
+ messages: number;
11
+ oldestAt: string | null;
12
+ newestAt: string | null;
13
+ };
14
+ }
15
+ /**
16
+ * Per chat, in the order asked: `chatCompleteness` with the span `chatStats` holds. Stats are read one chat at a time,
17
+ * because the account-wide read groups every message of every chat. A chat the store has no row for is `unknown`.
18
+ */
19
+ export declare const chatCoverage: (store: Pick<MessageStore, "chats">, account: AccountKey, chatIds: Id[]) => Promise<ChatCoverage[]>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Per chat, in the order asked: `chatCompleteness` with the span `chatStats` holds. Stats are read one chat at a time,
3
+ * because the account-wide read groups every message of every chat. A chat the store has no row for is `unknown`.
4
+ */
5
+ export const chatCoverage = async (store, account, chatIds) => {
6
+ const ids = [...new Set(chatIds)];
7
+ if (ids.length === 0)
8
+ return [];
9
+ const found = new Map((await store.chats.chatCompleteness(account, ids)).map((one) => [one.chatId, one]));
10
+ return Promise.all(ids.map(async (chatId) => {
11
+ const [stats] = await store.chats.chatStats(account, chatId);
12
+ const completeness = found.get(chatId) ?? {
13
+ chatId,
14
+ state: "unknown",
15
+ upToDate: null,
16
+ gaps: false,
17
+ reachesStart: false,
18
+ fetchedAt: null,
19
+ };
20
+ return {
21
+ chatId,
22
+ title: stats?.title ?? null,
23
+ completeness,
24
+ held: { messages: stats?.messages ?? 0, oldestAt: stats?.oldestAt ?? null, newestAt: stats?.newestAt ?? null },
25
+ };
26
+ }));
27
+ };
@@ -4,6 +4,7 @@ import type { MessengerTraits } from "../ports/traits.js";
4
4
  import type { AccountKey, MessageStore } from "../store/store.js";
5
5
  import type { SearchFound, SearchQuery } from "./messages.js";
6
6
  import { type MeaningLane, type MeaningSkipped } from "./messages-discovery.js";
7
+ import type { SearchCoverage } from "./messages-search.js";
7
8
  import type { Backend, ServerOptions, ServerSearched } from "./server-search.js";
8
9
  export declare const RESOURCES_SEARCHED: readonly ["messages", "mail", "notes", "events"];
9
10
  export type SearchedResource = (typeof RESOURCES_SEARCHED)[number];
@@ -77,6 +78,8 @@ export interface SearchAllFound {
77
78
  };
78
79
  /** The messenger's server step for messages, as `search messages` reports it. */
79
80
  server?: ServerSearched;
81
+ /** What the store held of the chats the messages were searched in; absent when messages were not searched. */
82
+ coverage?: SearchCoverage;
80
83
  }
81
84
  export interface SearchAllRequest {
82
85
  text: string;
@@ -53,6 +53,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
53
53
  let events;
54
54
  let messagesBy;
55
55
  let server;
56
+ let coverage;
56
57
  const messagesOf = async (kind) => {
57
58
  const given = request.scope?.[kind];
58
59
  const query = {
@@ -76,6 +77,8 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
76
77
  const found = kind === "messages" ? await searchMessages(query) : await searchStore(store, account, query, messenger);
77
78
  if (found.server)
78
79
  server = found.server;
80
+ if (kind === "messages" && found.coverage)
81
+ coverage = found.coverage;
79
82
  hasMore ||= found.hasMore;
80
83
  const read = found.coverage?.accounts ??
81
84
  given ??
@@ -243,5 +246,6 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
243
246
  ...(events === undefined ? {} : { events }),
244
247
  ...(messagesBy === undefined ? {} : { messages: messagesBy }),
245
248
  ...(server === undefined ? {} : { server }),
249
+ ...(coverage === undefined ? {} : { coverage }),
246
250
  };
247
251
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.492.0",
3
+ "version": "0.493.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",