@wirecat/cli-core 0.562.0 → 0.564.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 (142) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/dist/calendar/mcp/index.d.ts +12 -12
  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/messenger/archive-commands.js +17 -0
  7. package/dist/cli/messenger/catch-up-answers.d.ts +5 -0
  8. package/dist/cli/messenger/catch-up-answers.js +3 -0
  9. package/dist/cli/messenger/reply-stats-command.d.ts +4 -0
  10. package/dist/cli/messenger/reply-stats-command.js +57 -0
  11. package/dist/cli/messenger/search-answers.d.ts +1 -0
  12. package/dist/cli/messenger/search-answers.js +1 -1
  13. package/dist/cli/messenger/search-command.js +8 -0
  14. package/dist/cli/messenger/stats-answers.d.ts +6 -3
  15. package/dist/cli/messenger/stats-answers.js +39 -9
  16. package/dist/cli/messenger/stats-command.js +5 -0
  17. package/dist/cli/messenger/tasks-command.js +35 -14
  18. package/dist/cli/output-budget.d.ts +26 -0
  19. package/dist/cli/output-budget.js +73 -0
  20. package/dist/cli/program.js +14 -1
  21. package/dist/cli/settings.d.ts +5 -0
  22. package/dist/cli/settings.js +88 -5
  23. package/dist/cli/shared-command-exports.d.ts +8 -0
  24. package/dist/conversations/mail-text.d.ts +6 -0
  25. package/dist/conversations/mail-text.js +73 -0
  26. package/dist/domain/admin-statistics.d.ts +5 -0
  27. package/dist/domain/admin-statistics.js +4 -3
  28. package/dist/domain/calendar.d.ts +2 -0
  29. package/dist/domain/calendar.js +6 -0
  30. package/dist/entities/chat-stats.d.ts +16 -2
  31. package/dist/entities/chat-stats.js +18 -3
  32. package/dist/entities/index.d.ts +80 -802
  33. package/dist/entities/index.js +26 -16
  34. package/dist/entities/mail-counts.d.ts +15 -0
  35. package/dist/entities/mail-counts.js +16 -0
  36. package/dist/entities/meeting.d.ts +1 -1
  37. package/dist/entities/meeting.js +1 -1
  38. package/dist/entities/reply-stats.d.ts +33 -0
  39. package/dist/entities/reply-stats.js +19 -0
  40. package/dist/entities/store-records.d.ts +8 -0
  41. package/dist/entities/store-records.js +8 -0
  42. package/dist/errors/reasons.d.ts +1 -1
  43. package/dist/errors/reasons.js +7 -3
  44. package/dist/mail/attachments.d.ts +15 -2
  45. package/dist/mail/attachments.js +19 -3
  46. package/dist/mail/cli/index.d.ts +22 -1
  47. package/dist/mail/cli/index.js +28 -4
  48. package/dist/mail/cli/review.d.ts +5 -2
  49. package/dist/mail/cli/review.js +58 -43
  50. package/dist/mail/index.d.ts +4 -0
  51. package/dist/mail/index.js +4 -0
  52. package/dist/mail/partial.d.ts +3 -1
  53. package/dist/mail/partial.js +2 -2
  54. package/dist/mail/retry.d.ts +3 -1
  55. package/dist/mail/retry.js +3 -3
  56. package/dist/mail/stats.d.ts +50 -0
  57. package/dist/mail/stats.js +60 -0
  58. package/dist/mail/sync-types.d.ts +3 -0
  59. package/dist/mail/sync.js +5 -3
  60. package/dist/mail/whole.d.ts +28 -0
  61. package/dist/mail/whole.js +70 -0
  62. package/dist/meetings/index.d.ts +2 -2
  63. package/dist/meetings/index.js +2 -2
  64. package/dist/meetings/mcp/index.d.ts +15 -15
  65. package/dist/meetings/read-answers.d.ts +22 -1
  66. package/dist/meetings/read-answers.js +38 -0
  67. package/dist/meetings/stats.d.ts +44 -0
  68. package/dist/meetings/stats.js +77 -7
  69. package/dist/search/lucene/registry.js +2 -1
  70. package/dist/search/question-plan.d.ts +16 -0
  71. package/dist/search/question-plan.js +34 -0
  72. package/dist/services/events-search.d.ts +5 -1
  73. package/dist/services/events-search.js +10 -4
  74. package/dist/services/index.d.ts +3 -1
  75. package/dist/services/index.js +3 -1
  76. package/dist/services/meeting-stats.d.ts +12 -1
  77. package/dist/services/meeting-stats.js +12 -0
  78. package/dist/services/messages-discovery.d.ts +1 -5
  79. package/dist/services/messages-discovery.js +5 -18
  80. package/dist/services/messages-meaning.js +25 -3
  81. package/dist/services/messages.js +4 -3
  82. package/dist/services/notes-search.d.ts +5 -1
  83. package/dist/services/notes-search.js +16 -5
  84. package/dist/services/proposal-tasks.js +3 -12
  85. package/dist/services/proposals/ids.d.ts +2 -2
  86. package/dist/services/proposals/ids.js +5 -3
  87. package/dist/services/proposals/kinds/action.js +3 -2
  88. package/dist/services/proposals/kinds/findings.d.ts +1 -1
  89. package/dist/services/proposals/kinds/findings.js +1 -1
  90. package/dist/services/proposals/kinds/meeting-findings.d.ts +7 -0
  91. package/dist/services/proposals/kinds/meeting-findings.js +73 -0
  92. package/dist/services/proposals/kinds/task-close.d.ts +7 -0
  93. package/dist/services/proposals/kinds/task-close.js +76 -0
  94. package/dist/services/proposals/registry.js +12 -4
  95. package/dist/services/proposals/types.d.ts +2 -0
  96. package/dist/services/reply-stats.d.ts +48 -0
  97. package/dist/services/reply-stats.js +137 -0
  98. package/dist/services/search-all.d.ts +5 -0
  99. package/dist/services/search-all.js +47 -14
  100. package/dist/services/task-stats.d.ts +41 -0
  101. package/dist/services/task-stats.js +126 -0
  102. package/dist/services/tasks.d.ts +17 -4
  103. package/dist/services/tasks.js +31 -14
  104. package/dist/store/chats.d.ts +3 -1
  105. package/dist/store/chats.js +6 -0
  106. package/dist/store/decisions.d.ts +16 -11
  107. package/dist/store/decisions.js +11 -2
  108. package/dist/store/exports.d.ts +1 -1
  109. package/dist/store/exports.js +1 -1
  110. package/dist/store/index.d.ts +5 -4
  111. package/dist/store/index.js +3 -3
  112. package/dist/store/queries.d.ts +4 -0
  113. package/dist/store/queries.js +3 -0
  114. package/dist/store/sqlite/completeness.d.ts +17 -0
  115. package/dist/store/sqlite/completeness.js +49 -13
  116. package/dist/store/sqlite/email-locations.d.ts +4 -0
  117. package/dist/store/sqlite/email-locations.js +26 -11
  118. package/dist/store/sqlite/lucene-fields.d.ts +2 -0
  119. package/dist/store/sqlite/lucene-fields.js +5 -1
  120. package/dist/store/sqlite/lucene-groups.d.ts +15 -0
  121. package/dist/store/sqlite/lucene-groups.js +21 -0
  122. package/dist/store/sqlite/lucene.d.ts +2 -9
  123. package/dist/store/sqlite/lucene.js +1 -12
  124. package/dist/store/sqlite/mail-findings.js +1 -1
  125. package/dist/store/sqlite/meeting-findings.js +1 -1
  126. package/dist/store/sqlite/meeting-stats.d.ts +2 -0
  127. package/dist/store/sqlite/meeting-stats.js +27 -1
  128. package/dist/store/sqlite/proposed-actions.d.ts +2 -0
  129. package/dist/store/sqlite/proposed-actions.js +24 -17
  130. package/dist/store/sqlite/reply-times.d.ts +57 -0
  131. package/dist/store/sqlite/reply-times.js +113 -0
  132. package/dist/store/sqlite/retention.js +2 -7
  133. package/dist/store/sqlite/task-board.js +2 -0
  134. package/dist/store/sqlite/task-stats.d.ts +32 -0
  135. package/dist/store/sqlite/task-stats.js +45 -0
  136. package/dist/store/sqlite/tasks.d.ts +2 -1
  137. package/dist/store/sqlite/tasks.js +2 -0
  138. package/dist/store/sqlite/work-tasks.d.ts +12 -2
  139. package/dist/store/sqlite/work-tasks.js +35 -26
  140. package/dist/store/store.d.ts +1 -0
  141. package/package.json +1 -1
  142. package/parity.json +37 -1
@@ -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;
@@ -0,0 +1,137 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { parseLucene, timezoneOf } from "@wirecat/cli-base/query";
3
+ import { delayStats } from "../domain/admin-statistics.js";
4
+ import { calendarKey } from "../domain/calendar.js";
5
+ import { rankingQuestion } from "../domain/rankings-options.js";
6
+ import { MESSAGE_PARSING } from "../search/lucene/types.js";
7
+ import { prepareLucene } from "./messages-search.js";
8
+ const FIELDS = ["date", "chat", "kind"];
9
+ /** Per waiting chat, how far back its unanswered messages are counted. */
10
+ export const WAITING_TAIL = 100;
11
+ /** Only AND-ed `date:`, `chat:` and `kind:` terms: each becomes an index bound, so no query reads every message. */
12
+ const plainOnly = (text, command) => {
13
+ if (text === undefined)
14
+ return;
15
+ const refuse = () => {
16
+ throw new CliError("validation_error", `${command} reads date:, chat: and kind: terms, AND-ed — not words, other fields, OR or NOT`, { reason: "query.unsupported-field" });
17
+ };
18
+ const visit = (node) => {
19
+ if (node.kind === "boolean")
20
+ for (const clause of node.clauses) {
21
+ if (clause.occur !== "must")
22
+ refuse();
23
+ visit(clause.node);
24
+ }
25
+ else if (!FIELDS.includes(node.field) || !["term", "phrase", "range"].includes(node.operator))
26
+ refuse();
27
+ };
28
+ visit(parseLucene(text, { ...MESSAGE_PARSING, defaultField: "text" }).root);
29
+ };
30
+ const scopeOf = (root, accounts) => {
31
+ const scope = { accounts, chats: [], kinds: [] };
32
+ const visit = (node) => {
33
+ if (node.kind === "boolean") {
34
+ for (const clause of node.clauses)
35
+ visit(clause.node);
36
+ return;
37
+ }
38
+ const { chat, date } = node.resolution ?? {};
39
+ if (chat)
40
+ scope.chats.push(chat);
41
+ else if (node.field === "kind")
42
+ scope.kinds.push(node.value.toLowerCase());
43
+ else if (date) {
44
+ if (date.lower !== undefined) {
45
+ const lower = date.lower + (date.lower_inclusive ? 0 : 1);
46
+ scope.since = Math.max(scope.since ?? lower, lower);
47
+ }
48
+ if (date.upper !== undefined) {
49
+ const upper = date.upper - (date.upper_inclusive ? 0 : 1);
50
+ scope.until = Math.min(scope.until ?? upper, upper);
51
+ }
52
+ }
53
+ };
54
+ visit(root);
55
+ return scope;
56
+ };
57
+ const figuresOf = (delays) => {
58
+ const { medianMilliseconds, p90Milliseconds } = delayStats(delays);
59
+ return {
60
+ replies: delays.length,
61
+ medianMs: medianMilliseconds,
62
+ p90Ms: p90Milliseconds,
63
+ meanMs: delays.length ? Math.round(delays.reduce((sum, one) => sum + one, 0) / delays.length) : null,
64
+ };
65
+ };
66
+ const MINUTE = 60_000;
67
+ /** Asks the owner something: a question in a one-to-one chat; in a group, a mention by id or `@handle`, or a reply. */
68
+ export const waitsOnOwner = (newest) => {
69
+ if (newest.mine)
70
+ return false;
71
+ if (newest.kind === "private")
72
+ return !newest.quiet && rankingQuestion(newest.text);
73
+ return (newest.addressed ||
74
+ (newest.ownerUsername !== null &&
75
+ new RegExp(`(^|[^\\w@])@${newest.ownerUsername.replace(/[^\w]/g, "\\$&")}(?!\\w)`, "i").test(newest.text)));
76
+ };
77
+ export const replyStatsService = (deps) => {
78
+ const prepared = async (query, command) => {
79
+ plainOnly(query.text, command);
80
+ const store = await deps.store();
81
+ const found = await prepareLucene(store, await deps.account(), {
82
+ ...(query.text === undefined ? {} : { text: query.text }),
83
+ ...(query.source === undefined ? {} : { source: query.source }),
84
+ timezone: timezoneOf(query.timezone),
85
+ limit: 1,
86
+ }, deps.messenger);
87
+ return { store, timezone: found.timezone, scope: scopeOf(found.execution.root, found.scopeAccounts) };
88
+ };
89
+ return {
90
+ responses: async ({ who, by, ...query }) => {
91
+ const { store, timezone, scope } = await prepared(query, "stats messages responses");
92
+ const delays = scope.accounts.length ? await store.queries.replyDelays(scope, who) : [];
93
+ const buckets = { under5m: 0, under1h: 0, under1d: 0, over1d: 0 };
94
+ for (const { delayMs } of delays) {
95
+ if (delayMs < 5 * MINUTE)
96
+ buckets.under5m++;
97
+ else if (delayMs < 60 * MINUTE)
98
+ buckets.under1h++;
99
+ else if (delayMs < 24 * 60 * MINUTE)
100
+ buckets.under1d++;
101
+ else
102
+ buckets.over1d++;
103
+ }
104
+ const days = new Map();
105
+ if (by === "day") {
106
+ const keyOf = calendarKey(timezone, "day");
107
+ for (const { repliedAt, delayMs } of delays) {
108
+ const key = keyOf(repliedAt);
109
+ const list = days.get(key) ?? [];
110
+ list.push(delayMs);
111
+ days.set(key, list);
112
+ }
113
+ }
114
+ return {
115
+ who,
116
+ by,
117
+ ...figuresOf(delays.map(({ delayMs }) => delayMs)),
118
+ buckets,
119
+ rows: [...days].sort(([a], [b]) => a.localeCompare(b)).map(([key, list]) => ({ key, ...figuresOf(list) })),
120
+ };
121
+ },
122
+ waiting: async (query) => {
123
+ const { store, scope } = await prepared(query, "stats messages waiting");
124
+ const chats = scope.accounts.length ? await store.queries.waitingChats(scope, waitsOnOwner, WAITING_TAIL) : [];
125
+ const firsts = chats.flatMap(({ unanswered }) => unanswered.at(-1) ?? []);
126
+ return {
127
+ chats: chats.length,
128
+ messages: chats.reduce((sum, { unanswered }) => sum + unanswered.length, 0),
129
+ oldestAt: firsts.length ? new Date(firsts.reduce((low, one) => Math.min(low, one))).toISOString() : null,
130
+ byKind: {
131
+ private: chats.filter(({ kind }) => kind === "private").length,
132
+ group: chats.filter(({ kind }) => kind === "group").length,
133
+ },
134
+ };
135
+ },
136
+ };
137
+ };
@@ -79,6 +79,11 @@ export interface SearchAllFound {
79
79
  by: "words" | "words and meaning";
80
80
  meaningSkipped?: MeaningSkipped;
81
81
  };
82
+ /** How the mail was searched, when it was; never by meaning with `exact`. */
83
+ mail?: {
84
+ by: "words" | "words and meaning";
85
+ meaningSkipped?: MeaningSkipped;
86
+ };
82
87
  /** The messenger's server step for messages, as `search messages` reports it. */
83
88
  server?: ServerSearched;
84
89
  /** What the store held of the chats the messages were searched in; absent when messages were not searched. */
@@ -7,7 +7,7 @@ import { INTERNAL_NOTES, kindIn } from "../domain/providers.js";
7
7
  import { runEmbedders } from "../embeddings/embed.js";
8
8
  import { DEFAULT_TEXT_MODEL } from "../embeddings/models.js";
9
9
  import { MESSAGE_PARSING } from "../search/lucene/types.js";
10
- import { planQuestion } from "../search/question-plan.js";
10
+ import { planQuestion, questionLookup } from "../search/question-plan.js";
11
11
  import { calendarAccounts, searchEvents } from "./events-search.js";
12
12
  import { excerpt } from "./excerpt.js";
13
13
  import { MAX_SEARCH_OFFSET, searchStore } from "./messages-archive-search.js";
@@ -52,11 +52,17 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
52
52
  let hasMore = false;
53
53
  let notes;
54
54
  let events;
55
- let messagesBy;
55
+ const by = {};
56
56
  let server;
57
+ // Every word of a sentence is rarely in one record: a question finds what holds some of them, as `--discover` does.
58
+ const discover = !request.exact && questionLookup(request.text) !== undefined;
59
+ /** Each hit found by meaning, by its cosine: one model for every source, so these compare across them. */
60
+ const cosines = new Map();
57
61
  let coverage;
58
62
  const messagesOf = async (kind) => {
59
63
  const given = request.scope?.[kind];
64
+ // The server still gets the question as typed: a query that is not a sentence still finds what it holds.
65
+ const discovers = discover && (kind === "mail" || request.backend !== "server");
60
66
  const query = {
61
67
  text: request.text,
62
68
  kind,
@@ -71,6 +77,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
71
77
  : {}),
72
78
  limit: window,
73
79
  ...(request.exact ? { exact: true } : {}),
80
+ ...(discovers ? { discover: true } : {}),
74
81
  ...(request.timezone === undefined ? {} : { timezone: request.timezone }),
75
82
  ...(request.signal === undefined ? {} : { signal: request.signal }),
76
83
  };
@@ -109,7 +116,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
109
116
  };
110
117
  const byWords = found.items.map((hit) => itemOf(hit, ["words"]));
111
118
  // Server hits are only those the server returned; the local vectors would add others.
112
- if (kind === "mail" || !meaning || request.exact || request.backend === "server")
119
+ if (!meaning || request.exact || (kind === "messages" && request.backend === "server"))
113
120
  return byWords;
114
121
  const filters = filtersBeside(request.text);
115
122
  const lane = filters === undefined
@@ -121,10 +128,10 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
121
128
  }
122
129
  : await meaning(request.text, read, embedders);
123
130
  if ("skipped" in lane) {
124
- messagesBy = { by: "words", meaningSkipped: lane.skipped };
131
+ by[kind] = { by: "words", meaningSkipped: lane.skipped };
125
132
  return byWords;
126
133
  }
127
- messagesBy = { by: "words and meaning", ...(lane.partly ? { meaningSkipped: lane.partly } : {}) };
134
+ by[kind] = { by: "words and meaning", ...(lane.partly ? { meaningSkipped: lane.partly } : {}) };
128
135
  const near = await nearEligible(store, account, query, messenger, lane.ranked, filters ? { text: filters } : {});
129
136
  // One list per resource, as notes give theirs, so messages get no second vote against mail.
130
137
  const fused = new Map();
@@ -132,9 +139,13 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
132
139
  fused.set(item.ref, { item, score: 1 / (RRF_K + rank + 1) });
133
140
  });
134
141
  near.forEach((hit, rank) => {
142
+ cosines.set(hit.locator, hit.cosine);
135
143
  const held = fused.get(hit.locator);
136
144
  if (held)
137
145
  held.item.foundBy = ["words", "meaning"];
146
+ // As in notes: a question's any-word list already holds what meaning finds, and a second vote buries the rest.
147
+ if (held && discovers)
148
+ return;
138
149
  fused.set(hit.locator, {
139
150
  item: held?.item ?? itemOf(hit, ["meaning"]),
140
151
  score: (held?.score ?? 0) + 1 / (RRF_K + rank + 1),
@@ -155,6 +166,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
155
166
  embedders,
156
167
  ...(chosen === undefined ? {} : notesScopeOf(chosen)),
157
168
  ...(request.exact ? { exact: true } : {}),
169
+ ...(discover ? { discover: true } : {}),
158
170
  ...(request.timezone === undefined ? {} : { timezone: request.timezone }),
159
171
  ...(request.env === undefined ? {} : { env: request.env }),
160
172
  });
@@ -163,6 +175,9 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
163
175
  by: found.by,
164
176
  ...(found.meaning_skipped === undefined ? {} : { meaningSkipped: found.meaning_skipped }),
165
177
  };
178
+ for (const hit of found.hits)
179
+ if (hit.meaning_score !== undefined)
180
+ cosines.set(hit.ref, hit.meaning_score);
166
181
  lists.push(found.hits.map((hit) => ({
167
182
  kind: hit.kind ?? "note",
168
183
  ref: hit.ref,
@@ -192,6 +207,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
192
207
  ...(command === undefined ? {} : { command }),
193
208
  ...(request.env === undefined ? {} : { env: request.env }),
194
209
  ...(request.exact ? { exact: true } : {}),
210
+ ...(discover ? { discover: true } : {}),
195
211
  ...(request.timezone === undefined ? {} : { timezone: request.timezone }),
196
212
  ...(request.signal === undefined ? {} : { signal: request.signal }),
197
213
  });
@@ -200,6 +216,9 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
200
216
  by: found.by,
201
217
  ...(found.meaning_skipped === undefined ? {} : { meaningSkipped: found.meaning_skipped }),
202
218
  };
219
+ for (const hit of found.hits)
220
+ if (hit.meaning_score !== undefined)
221
+ cosines.set(hit.ref, hit.meaning_score);
203
222
  lists.push(found.hits.map((hit) => ({
204
223
  kind: "event",
205
224
  ref: hit.ref,
@@ -227,14 +246,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
227
246
  finally {
228
247
  await embedders.close();
229
248
  }
230
- const ranked = new Map();
231
- for (const list of lists)
232
- list.forEach((item, rank) => {
233
- const held = ranked.get(item.ref) ?? { item, score: 0 };
234
- held.score += 1 / (RRF_K + rank + 1);
235
- ranked.set(item.ref, held);
236
- });
237
- const ordered = [...ranked.values()].sort((a, b) => b.score - a.score).map(({ item }) => item);
249
+ const ordered = oneList(lists, cosines);
238
250
  const more = hasMore || ordered.length > window;
239
251
  return {
240
252
  query: request.text,
@@ -246,8 +258,29 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
246
258
  skipped,
247
259
  ...(notes === undefined ? {} : { notes }),
248
260
  ...(events === undefined ? {} : { events }),
249
- ...(messagesBy === undefined ? {} : { messages: messagesBy }),
261
+ ...(by.messages === undefined ? {} : { messages: by.messages }),
262
+ ...(by.mail === undefined ? {} : { mail: by.mail }),
250
263
  ...(server === undefined ? {} : { server }),
251
264
  ...(coverage === undefined ? {} : { coverage }),
252
265
  };
253
266
  };
267
+ /**
268
+ * Every source's hits as one list: by the rank each has in its own source, then by cosine. Rank alone gave every
269
+ * source's hit at one rank the same score, so the sources took turns and the order among them was the order they were
270
+ * read in (MEMO-37). One model scores every source, so cosine compares across them; a hit meaning did not find comes
271
+ * after those it did at the same rank. Each source's own order is kept, so recall at any depth past the number of
272
+ * sources does not change.
273
+ */
274
+ const oneList = (lists, cosines) => {
275
+ const place = new Map();
276
+ for (const list of lists)
277
+ list.forEach((item, rank) => {
278
+ const held = place.get(item.ref);
279
+ if (!held || rank < held.rank)
280
+ place.set(item.ref, { item, rank });
281
+ });
282
+ const cosine = (ref) => cosines.get(ref) ?? Number.NEGATIVE_INFINITY;
283
+ return [...place.values()]
284
+ .sort((a, b) => a.rank - b.rank || cosine(b.item.ref) - cosine(a.item.ref))
285
+ .map(({ item }) => item);
286
+ };
@@ -0,0 +1,41 @@
1
+ import { type QueryAst } from "@wirecat/cli-base/query";
2
+ import type { AccountKey, MessageStore } from "../store/store.js";
3
+ import { type ColumnMap } from "../store/store.js";
4
+ import { type TaskKind } from "../tasks/index.js";
5
+ export declare const TASK_STATS_BY: readonly ["chat", "meeting", "project"];
6
+ export type TaskStatsBy = (typeof TASK_STATS_BY)[number];
7
+ /** The query field that sets the period; `closed` counts read the same range on the close time. */
8
+ export declare const PERIOD_FIELD = "created";
9
+ export interface TaskStatsRequest {
10
+ by?: TaskStatsBy;
11
+ query?: QueryAst;
12
+ timezone?: string;
13
+ /** A stored chat id: only that chat's tasks. */
14
+ group?: string;
15
+ kind?: TaskKind;
16
+ }
17
+ export interface TaskStatsGroup {
18
+ /** The stored chat id, `meeting:<account>/<meeting>`, or the project's store id. */
19
+ group: string;
20
+ name: string | null;
21
+ open: number;
22
+ created: number;
23
+ closed: number;
24
+ oldestOpenAt?: Date;
25
+ medianCloseMs?: number;
26
+ meanOpenAgeMs?: number;
27
+ }
28
+ export interface TaskStats {
29
+ by: TaskStatsBy;
30
+ open: number;
31
+ created: number;
32
+ closed: number;
33
+ medianCloseMs?: number;
34
+ meanOpenAgeMs?: number;
35
+ rows: TaskStatsGroup[];
36
+ }
37
+ /**
38
+ * `stats tasks show`: `open` is open now, `created` the tasks opened in the period, `closed` those closed in it; the
39
+ * other terms of the query narrow all three. `columns` reads the task query's fields on the row `t`.
40
+ */
41
+ export declare const taskStatsOf: (store: MessageStore, account: AccountKey, { by, query, timezone, group, kind }: TaskStatsRequest, columns: ColumnMap, now?: number) => Promise<TaskStats>;
@@ -0,0 +1,126 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { walkQuery } from "@wirecat/cli-base/query";
3
+ import { parseMeetingReference } from "../meetings/index.js";
4
+ import { withoutField } from "../store/index.js";
5
+ import { queryFilter } from "../store/store.js";
6
+ import { isClosedState } from "../tasks/index.js";
7
+ import { taskAccount } from "./task-rules.js";
8
+ export const TASK_STATS_BY = ["chat", "meeting", "project"];
9
+ /** The query field that sets the period; `closed` counts read the same range on the close time. */
10
+ export const PERIOD_FIELD = "created";
11
+ /** Only as a required term at the top: inside OR or NOT it would no longer say which tasks the period covers. */
12
+ const refuseNestedPeriod = (node, top = true) => {
13
+ if (node.kind === "predicate")
14
+ return;
15
+ const optional = node.clauses.some(({ occur }) => occur === "should");
16
+ for (const { occur, node: inner } of node.clauses) {
17
+ if (!walkQuery(inner).some(({ field }) => field === PERIOD_FIELD))
18
+ continue;
19
+ if (!top || optional || occur !== "must")
20
+ throw new CliError("validation_error", `${PERIOD_FIELD}: sets the period of stats tasks show: give it as a required term, not inside OR or NOT`);
21
+ refuseNestedPeriod(inner, top);
22
+ }
23
+ };
24
+ const renamed = (node, from, to) => node.kind === "predicate"
25
+ ? node.field === from
26
+ ? { ...node, field: to }
27
+ : node
28
+ : { ...node, clauses: node.clauses.map((clause) => ({ ...clause, node: renamed(clause.node, from, to) })) };
29
+ const median = (values) => {
30
+ const sorted = [...values].sort((a, b) => a - b);
31
+ const middle = Math.floor(sorted.length / 2);
32
+ const upper = sorted[middle] ?? 0;
33
+ return sorted.length % 2 === 1 ? upper : ((sorted[middle - 1] ?? 0) + upper) / 2;
34
+ };
35
+ const mean = (values) => Math.round(values.reduce((sum, one) => sum + one, 0) / values.length);
36
+ const spread = (open, closeMs, now) => ({
37
+ ...(closeMs.length > 0 ? { medianCloseMs: median(closeMs) } : {}),
38
+ ...(open.length > 0 ? { meanOpenAgeMs: mean(open.map(({ createdAt }) => now - createdAt)) } : {}),
39
+ });
40
+ const and = (parts) => parts.length === 0
41
+ ? undefined
42
+ : { sql: parts.map(({ sql }) => `(${sql})`).join(" AND "), params: parts.flatMap(({ params }) => params) };
43
+ /**
44
+ * `stats tasks show`: `open` is open now, `created` the tasks opened in the period, `closed` those closed in it; the
45
+ * other terms of the query narrow all three. `columns` reads the task query's fields on the row `t`.
46
+ */
47
+ export const taskStatsOf = async (store, account, { by = "chat", query, timezone, group, kind }, columns, now = Date.now()) => {
48
+ if (query)
49
+ refuseNestedPeriod(query.root);
50
+ const time = (sql) => ({ type: "time", sql });
51
+ const read = { ...columns, [PERIOD_FIELD]: time("t.created_at"), closed: time("t.closed_at") };
52
+ const fixed = [
53
+ ...(group === undefined ? [] : [{ sql: "t.source_group = ?", params: [group] }]),
54
+ ...(kind === undefined ? [] : [{ sql: "t.type = ?", params: [kind] }]),
55
+ ...(by === "meeting" ? [{ sql: "t.source_kind = 'meeting'", params: [] }] : []),
56
+ ];
57
+ const options = { command: "stats tasks show", now, ...(timezone === undefined ? {} : { zone: timezone }) };
58
+ const where = (ast, more = []) => and([...fixed, ...more, ...(ast ? [queryFilter(ast, read, options)] : [])]);
59
+ const scope = by === "project" ? { board: true } : { account: taskAccount(account) };
60
+ const all = await store.tasks.statRows(scope, where(withoutField(query, PERIOD_FIELD)));
61
+ const created = await store.tasks.statRows(scope, where(query));
62
+ const closed = await store.tasks.statRows(scope, where(query && { ...query, root: renamed(query.root, PERIOD_FIELD, "closed") }, [
63
+ { sql: "t.closed_at IS NOT NULL", params: [] },
64
+ ]));
65
+ const keyOf = (row) => (by === "project" ? String(row.projectId) : (row.group ?? ""));
66
+ const groups = new Map();
67
+ const slot = (row) => {
68
+ const key = keyOf(row);
69
+ const found = groups.get(key) ?? { open: [], created: 0, closeMs: [] };
70
+ groups.set(key, found);
71
+ return found;
72
+ };
73
+ for (const row of all)
74
+ if (!isClosedState(row.state))
75
+ slot(row).open.push(row);
76
+ for (const row of created)
77
+ slot(row).created++;
78
+ for (const row of closed)
79
+ slot(row).closeMs.push((row.closedAt ?? row.createdAt) - row.createdAt);
80
+ const kept = [...groups].filter(([, one]) => one.open.length + one.created + one.closeMs.length > 0);
81
+ const ids = kept.map(([key]) => key);
82
+ const names = by === "chat"
83
+ ? await store.tasks.statNames("chat", ids, taskAccount(account))
84
+ : by === "project"
85
+ ? await store.tasks.statNames("project", ids)
86
+ : await meetingNames(store, ids);
87
+ const rows = kept.map(([key, one]) => ({
88
+ group: key,
89
+ name: names.get(key) ?? null,
90
+ open: one.open.length,
91
+ created: one.created,
92
+ closed: one.closeMs.length,
93
+ ...(one.open[0] ? { oldestOpenAt: new Date(one.open[0].createdAt) } : {}),
94
+ ...spread(one.open, one.closeMs, now),
95
+ }));
96
+ const open = kept.flatMap(([, one]) => one.open);
97
+ const closeMs = kept.flatMap(([, one]) => one.closeMs);
98
+ return {
99
+ by,
100
+ open: open.length,
101
+ created: created.length,
102
+ closed: closeMs.length,
103
+ ...spread(open, closeMs, now),
104
+ rows,
105
+ };
106
+ };
107
+ const meetingIdOf = (group) => {
108
+ try {
109
+ return String(parseMeetingReference(group).meetingId);
110
+ }
111
+ catch {
112
+ return undefined;
113
+ }
114
+ };
115
+ /** A meeting task's group is `meeting:<account>/<meeting>`; its title is the meeting's, by the meeting's store id. */
116
+ const meetingNames = async (store, groups) => {
117
+ const meetingOf = new Map(groups.flatMap((group) => {
118
+ const id = meetingIdOf(group);
119
+ return id === undefined ? [] : [[group, id]];
120
+ }));
121
+ const titles = await store.tasks.statNames("meeting", [...new Set(meetingOf.values())]);
122
+ return new Map([...meetingOf].flatMap(([group, id]) => {
123
+ const title = titles.get(id);
124
+ return title === undefined ? [] : [[group, title]];
125
+ }));
126
+ };
@@ -3,9 +3,10 @@ import type * as v from "valibot";
3
3
  import { task as taskEntity } from "../entities/task.js";
4
4
  import { type CloseSuggestion, type SimilarTask, type TaskDraft, type TaskPair, type TaskPairSide, type TaskRelation } from "../store/index.js";
5
5
  import type { AccountKey, Author, MessageStore, TaskDetails, ThingLink } from "../store/store.js";
6
- import { type ClosedState, type GroupStats, TASK_KINDS, TASK_STATES, type Task, type TaskKind, type TaskOrigin } from "../tasks/index.js";
6
+ import { type ClosedState, TASK_KINDS, TASK_STATES, type Task, type TaskKind, type TaskOrigin } from "../tasks/index.js";
7
7
  import type { ServiceDeps } from "./deps.js";
8
8
  import { type TaskCategory, type UnlistedCategory } from "./task-categories.js";
9
+ import { type TaskStats, type TaskStatsRequest } from "./task-stats.js";
9
10
  export { TASK_KINDS, TASK_STATES };
10
11
  /** Source previews are read on demand; task records never retain source text. */
11
12
  export interface TaskView extends Task {
@@ -26,6 +27,18 @@ export declare const TASK_QUERY: {
26
27
  readonly backend: "sql";
27
28
  readonly fields: import("@wirecat/cli-base/query").FieldSpec[];
28
29
  };
30
+ /** `stats tasks show`: the task query's fields, with the period as `created` in place of the two time fields. */
31
+ export declare const TASK_STATS_QUERY: {
32
+ readonly entity: "task";
33
+ readonly backend: "sql";
34
+ readonly fields: readonly [...import("@wirecat/cli-base/query").FieldSpec[], {
35
+ readonly name: "created";
36
+ readonly type: "time";
37
+ readonly operators: readonly import("@wirecat/cli-base/query").Operator[];
38
+ readonly example: "created>=30d";
39
+ readonly relation: "the period: created counts the tasks opened in it, closed the tasks done or dismissed in it; open is open now";
40
+ }];
41
+ };
29
42
  /** Which pairs `tasks duplicates list` lists: those waiting for the reviewing agent, or those it left to the owner. */
30
43
  export declare const PAIR_DECISIONS: readonly ["review", "unsure"];
31
44
  export type PairDecision = (typeof PAIR_DECISIONS)[number];
@@ -163,10 +176,10 @@ export interface TasksService {
163
176
  close(id: string, options: CloseRequest): Promise<ClosedTask>;
164
177
  /** The owner refuses an agent's close: the task stays open and the suggestion goes. */
165
178
  reject(id: string, by: TaskOrigin): Promise<TaskCard>;
166
- stats(filter?: {
179
+ /** `chat` narrows to one chat, by name or id; see `taskStatsOf` for what each count means. */
180
+ stats(request?: Omit<TaskStatsRequest, "group"> & {
167
181
  chat?: string;
168
- type?: TaskKind;
169
- }): Promise<GroupStats[]>;
182
+ }): Promise<TaskStats>;
170
183
  /** Title, description, due date and next check; `null` clears one. */
171
184
  plan(id: string, plan: TaskPlan, by: TaskOrigin): Promise<TaskCard>;
172
185
  /**
@@ -1,5 +1,5 @@
1
1
  import { CliError, isCliError, singleLine } from "@wirecat/cli-base";
2
- import { queryFieldsOf, walkQuery } from "@wirecat/cli-base/query";
2
+ import { OPERATORS_BY_TYPE, queryFieldsOf, walkQuery } from "@wirecat/cli-base/query";
3
3
  import { isLocator, parseLocator } from "../domain/locator.js";
4
4
  import { formatReference } from "../domain/references.js";
5
5
  import { task as taskEntity } from "../entities/task.js";
@@ -9,6 +9,7 @@ import { createTaskService, isClosedState, TASK_KINDS, TASK_STATES, } from "../t
9
9
  import { storedChatId } from "./message-resolution.js";
10
10
  import { categorize, taskCategoryOf } from "./task-categories.js";
11
11
  import { taskAccount } from "./task-rules.js";
12
+ import { PERIOD_FIELD, taskStatsOf } from "./task-stats.js";
12
13
  import { taskTagFilters } from "./task-tag-filters.js";
13
14
  export { TASK_KINDS, TASK_STATES };
14
15
  export const TASK_QUERY = {
@@ -41,6 +42,21 @@ export const TASK_QUERY = {
41
42
  },
42
43
  }),
43
44
  };
45
+ /** `stats tasks show`: the task query's fields, with the period as `created` in place of the two time fields. */
46
+ export const TASK_STATS_QUERY = {
47
+ entity: "task",
48
+ backend: "sql",
49
+ fields: [
50
+ ...TASK_QUERY.fields.filter(({ name }) => name !== "created_at" && name !== "closed_at"),
51
+ {
52
+ name: PERIOD_FIELD,
53
+ type: "time",
54
+ operators: OPERATORS_BY_TYPE.time,
55
+ example: `${PERIOD_FIELD}>=30d`,
56
+ relation: "the period: created counts the tasks opened in it, closed the tasks done or dismissed in it; open is open now",
57
+ },
58
+ ],
59
+ };
44
60
  /** Which pairs `tasks duplicates list` lists: those waiting for the reviewing agent, or those it left to the owner. */
45
61
  export const PAIR_DECISIONS = ["review", "unsure"];
46
62
  export const TASK_PAIR_QUERY = {
@@ -143,6 +159,14 @@ export const tasksService = (deps, categories) => {
143
159
  const inStore = async (work) => work(await deps.store(), await deps.account());
144
160
  const service = (store) => createTaskService({ store: store.tasks });
145
161
  const groupOf = async (store, account, chat) => chat === undefined ? undefined : storedChatId(deps.messenger, chat, store, account, { includeLeft: true });
162
+ /** The task query's columns, with each `chat:` name in the query resolved to its stored chat id once. */
163
+ const queryColumns = async (store, account, query) => {
164
+ const groups = new Map();
165
+ for (const node of query ? walkQuery(query.root) : [])
166
+ if (node.field === "chat" && node.operator !== "wildcard" && !groups.has(node.value))
167
+ groups.set(node.value, await storedChatId(deps.messenger, node.value, store, account, { includeLeft: true }));
168
+ return taskColumns(groups, await taskTagFilters(store, query));
169
+ };
146
170
  const refuseBare = (target) => {
147
171
  throw new CliError("validation_error", `no task ${singleLine(target)} — write task:${singleLine(target)} for a task`);
148
172
  };
@@ -204,12 +228,9 @@ export const tasksService = (deps, categories) => {
204
228
  : 0;
205
229
  return {
206
230
  list: ({ query, limit, offset, previews }) => inStore(async (store, account) => {
207
- const groups = new Map();
208
- for (const node of query ? walkQuery(query.root) : [])
209
- if (node.field === "chat" && node.operator !== "wildcard" && !groups.has(node.value))
210
- groups.set(node.value, await storedChatId(deps.messenger, node.value, store, account, { includeLeft: true }));
211
- const tags = await taskTagFilters(store, query);
212
- const where = query ? queryFilter(query, taskColumns(groups, tags), { command: "tasks list" }) : undefined;
231
+ const where = query
232
+ ? queryFilter(query, await queryColumns(store, account, query), { command: "tasks list" })
233
+ : undefined;
213
234
  const rows = await store.tasks.query(taskAccount(account), where, { limit: limit + 1, offset });
214
235
  const shown = rows.slice(0, limit);
215
236
  return {
@@ -484,13 +505,9 @@ export const tasksService = (deps, categories) => {
484
505
  await owned(store, account, subject);
485
506
  return store.tasks.similar({ task: subject }, options);
486
507
  }),
487
- stats: (filter = {}) => inStore(async (store, account) => {
488
- const group = await groupOf(store, account, filter.chat);
489
- const rows = await service(store).stats({
490
- account: taskAccount(account),
491
- ...(filter.type === undefined ? {} : { kind: filter.type }),
492
- });
493
- return group === undefined ? rows : rows.filter((one) => one.group === group);
508
+ stats: ({ chat, ...request } = {}) => inStore(async (store, account) => {
509
+ const group = await groupOf(store, account, chat);
510
+ return taskStatsOf(store, account, { ...request, ...(group === undefined ? {} : { group }) }, await queryColumns(store, account, request.query));
494
511
  }),
495
512
  };
496
513
  };