@wirecat/cli-core 0.563.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.
- package/CHANGELOG.md +77 -0
- package/dist/calendar/mcp/index.d.ts +12 -12
- package/dist/cli/messenger/archive-commands.js +17 -0
- package/dist/cli/messenger/catch-up-answers.d.ts +5 -0
- package/dist/cli/messenger/catch-up-answers.js +3 -0
- package/dist/cli/messenger/search-answers.d.ts +1 -0
- package/dist/cli/messenger/search-answers.js +1 -1
- package/dist/cli/messenger/stats-answers.d.ts +3 -3
- package/dist/cli/messenger/stats-answers.js +21 -9
- package/dist/cli/messenger/tasks-command.js +35 -14
- package/dist/conversations/mail-text.d.ts +6 -0
- package/dist/conversations/mail-text.js +73 -0
- package/dist/domain/calendar.d.ts +2 -0
- package/dist/domain/calendar.js +6 -0
- package/dist/entities/chat-stats.d.ts +16 -2
- package/dist/entities/chat-stats.js +18 -3
- package/dist/entities/index.d.ts +50 -802
- package/dist/entities/index.js +23 -16
- package/dist/entities/mail-counts.d.ts +15 -0
- package/dist/entities/mail-counts.js +16 -0
- package/dist/entities/meeting.d.ts +1 -1
- package/dist/entities/meeting.js +1 -1
- package/dist/entities/store-records.d.ts +8 -0
- package/dist/entities/store-records.js +8 -0
- package/dist/errors/reasons.js +5 -0
- package/dist/mail/cli/index.d.ts +5 -0
- package/dist/mail/cli/index.js +27 -3
- package/dist/mail/cli/review.js +11 -4
- package/dist/mail/index.d.ts +2 -0
- package/dist/mail/index.js +2 -0
- package/dist/mail/stats.d.ts +50 -0
- package/dist/mail/stats.js +60 -0
- package/dist/mail/whole.d.ts +4 -4
- package/dist/mail/whole.js +4 -3
- package/dist/meetings/index.d.ts +2 -2
- package/dist/meetings/index.js +2 -2
- package/dist/meetings/mcp/index.d.ts +15 -15
- package/dist/meetings/read-answers.d.ts +22 -1
- package/dist/meetings/read-answers.js +38 -0
- package/dist/meetings/stats.d.ts +44 -0
- package/dist/meetings/stats.js +77 -7
- package/dist/search/lucene/registry.js +2 -1
- package/dist/search/question-plan.d.ts +16 -0
- package/dist/search/question-plan.js +34 -0
- package/dist/services/events-search.d.ts +5 -1
- package/dist/services/events-search.js +10 -4
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/meeting-stats.d.ts +12 -1
- package/dist/services/meeting-stats.js +12 -0
- package/dist/services/messages-discovery.d.ts +1 -5
- package/dist/services/messages-discovery.js +5 -18
- package/dist/services/messages-meaning.js +25 -3
- package/dist/services/messages.js +4 -3
- package/dist/services/notes-search.d.ts +5 -1
- package/dist/services/notes-search.js +16 -5
- package/dist/services/proposals/ids.d.ts +2 -2
- package/dist/services/proposals/ids.js +5 -3
- package/dist/services/proposals/kinds/findings.d.ts +1 -1
- package/dist/services/proposals/kinds/findings.js +1 -1
- package/dist/services/proposals/kinds/meeting-findings.d.ts +7 -0
- package/dist/services/proposals/kinds/meeting-findings.js +73 -0
- package/dist/services/proposals/kinds/task-close.d.ts +7 -0
- package/dist/services/proposals/kinds/task-close.js +76 -0
- package/dist/services/proposals/registry.js +4 -0
- package/dist/services/search-all.d.ts +5 -0
- package/dist/services/search-all.js +47 -14
- package/dist/services/task-stats.d.ts +41 -0
- package/dist/services/task-stats.js +126 -0
- package/dist/services/tasks.d.ts +17 -4
- package/dist/services/tasks.js +31 -14
- package/dist/store/chats.d.ts +3 -1
- package/dist/store/chats.js +6 -0
- package/dist/store/decisions.d.ts +4 -2
- package/dist/store/decisions.js +5 -3
- package/dist/store/exports.d.ts +1 -1
- package/dist/store/exports.js +1 -1
- package/dist/store/index.d.ts +2 -1
- package/dist/store/index.js +1 -1
- package/dist/store/sqlite/completeness.d.ts +17 -0
- package/dist/store/sqlite/completeness.js +49 -13
- package/dist/store/sqlite/lucene-fields.js +2 -0
- package/dist/store/sqlite/lucene-groups.d.ts +15 -0
- package/dist/store/sqlite/lucene-groups.js +21 -0
- package/dist/store/sqlite/lucene.d.ts +2 -9
- package/dist/store/sqlite/lucene.js +1 -12
- package/dist/store/sqlite/meeting-stats.d.ts +2 -0
- package/dist/store/sqlite/meeting-stats.js +27 -1
- package/dist/store/sqlite/retention.js +2 -7
- package/dist/store/sqlite/task-board.js +2 -0
- package/dist/store/sqlite/task-stats.d.ts +32 -0
- package/dist/store/sqlite/task-stats.js +45 -0
- package/dist/store/sqlite/tasks.d.ts +2 -1
- package/dist/store/sqlite/tasks.js +2 -0
- package/package.json +1 -1
- package/parity.json +13 -1
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
131
|
+
by[kind] = { by: "words", meaningSkipped: lane.skipped };
|
|
125
132
|
return byWords;
|
|
126
133
|
}
|
|
127
|
-
|
|
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
|
|
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
|
-
...(
|
|
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
|
+
};
|
package/dist/services/tasks.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/dist/services/tasks.js
CHANGED
|
@@ -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
|
|
208
|
-
|
|
209
|
-
|
|
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: (
|
|
488
|
-
const group = await groupOf(store, account,
|
|
489
|
-
|
|
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
|
};
|
package/dist/store/chats.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Chat, ChatKind, ChatUpdate, Id, Page } from "../domain/models.js";
|
|
|
3
3
|
import type { HistorySkip } from "../services/history-limits.js";
|
|
4
4
|
import { type ChatRekey, type ChatRekeys } from "./sqlite/chat-rekey.js";
|
|
5
5
|
import type { ChatMetadata, LeftChat } from "./sqlite/chats.js";
|
|
6
|
-
import type { ChatCompleteness } from "./sqlite/completeness.js";
|
|
6
|
+
import type { ChatCompleteness, ChatCoverage } from "./sqlite/completeness.js";
|
|
7
7
|
import type { ChatHistoryShape, StoredHistorySkip } from "./sqlite/history-skips.js";
|
|
8
8
|
import type { StoreContext } from "./sqlite/open.js";
|
|
9
9
|
import type { Where } from "./sqlite/query-filter.js";
|
|
@@ -59,6 +59,8 @@ export interface ChatsStore {
|
|
|
59
59
|
}): Promise<LeftChats>;
|
|
60
60
|
/** Per chat: whether its history is held up to date, without gaps, back to its start. */
|
|
61
61
|
chatCompleteness(key: AccountKey, chatIds: Id[]): Promise<ChatCompleteness[]>;
|
|
62
|
+
/** Over every searchable chat: how many are held in full, in part, or never fetched, and the oldest message held. */
|
|
63
|
+
chatCoverage(key: AccountKey): Promise<ChatCoverage>;
|
|
62
64
|
/** Per chat that has messages: how many, the oldest and newest, and when the last one was stored. */
|
|
63
65
|
chatStats(key: AccountKey, chatId?: Id): Promise<ChatStats[]>;
|
|
64
66
|
/**
|
package/dist/store/chats.js
CHANGED
|
@@ -78,6 +78,12 @@ export const chatsStoreOver = (context) => {
|
|
|
78
78
|
const accountKey = findAccountPk(key);
|
|
79
79
|
return accountKey === undefined ? [] : completeness.chatCompleteness(context, accountKey, chatIds);
|
|
80
80
|
},
|
|
81
|
+
chatCoverage: async (key) => {
|
|
82
|
+
const accountKey = findAccountPk(key);
|
|
83
|
+
return accountKey === undefined
|
|
84
|
+
? { chats: 0, fetched: 0, partial: 0, never: 0, oldestMessageAt: null }
|
|
85
|
+
: completeness.chatCoverage(context, accountKey);
|
|
86
|
+
},
|
|
81
87
|
chatStats: async (key, chatId) => {
|
|
82
88
|
const accountKey = findAccountPk(key);
|
|
83
89
|
return accountKey === undefined ? [] : reads.chatStats(context, accountKey, chatId);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** An agent's suggested close of a task: kept on the task, not in `proposed_actions`. */
|
|
2
|
+
export declare const TASK_CLOSE_KIND = "task_close";
|
|
1
3
|
/**
|
|
2
4
|
* Who proposed something waiting for the owner. `unknown` is a suggestion that names no author, such as an
|
|
3
5
|
* unconfirmed link written without provenance: only the owner decides it. `verified: false` is a bot named only by
|
|
@@ -41,8 +43,8 @@ export interface Refusal {
|
|
|
41
43
|
message: string;
|
|
42
44
|
}
|
|
43
45
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
+
* Proposal kinds only the owner decides, whatever `agent_decisions` says. Read from the row's kind, so a path that
|
|
47
|
+
* sees the row as something else — a generic action, a host's own kind — still refuses an agent.
|
|
46
48
|
*/
|
|
47
49
|
export declare const isOwnerOnlyKind: (kind: string | undefined) => boolean;
|
|
48
50
|
/**
|
package/dist/store/decisions.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { MEETING_FINDINGS_KIND } from "../meetings/index.js";
|
|
3
3
|
import { MAIL_FINDINGS_KIND } from "./mail-findings.js";
|
|
4
|
+
/** An agent's suggested close of a task: kept on the task, not in `proposed_actions`. */
|
|
5
|
+
export const TASK_CLOSE_KIND = "task_close";
|
|
4
6
|
export const DEFAULT_DECISION_POLICY = { agentDecisions: false, ruleProposalsOwnerOnly: true };
|
|
5
7
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
+
* Proposal kinds only the owner decides, whatever `agent_decisions` says. Read from the row's kind, so a path that
|
|
9
|
+
* sees the row as something else — a generic action, a host's own kind — still refuses an agent.
|
|
8
10
|
*/
|
|
9
|
-
export const isOwnerOnlyKind = (kind) => kind === MAIL_FINDINGS_KIND || kind === MEETING_FINDINGS_KIND;
|
|
11
|
+
export const isOwnerOnlyKind = (kind) => kind === MAIL_FINDINGS_KIND || kind === MEETING_FINDINGS_KIND || kind === TASK_CLOSE_KIND;
|
|
10
12
|
/**
|
|
11
13
|
* The one check every decide path runs before it confirms or rejects a proposal. `null` means allowed. Every Claude
|
|
12
14
|
* Code session is the bot `claude-code`, so comparing bot names keeps one session from deciding another's proposal.
|
package/dist/store/exports.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export { pendingNormalization } from "./sqlite/backfill.js";
|
|
|
20
20
|
export type { ChatRekey, ChatRekeys } from "./sqlite/chat-rekey.js";
|
|
21
21
|
export { CHAT_COLUMNS, TRACKED_CHAT_COLUMNS, titlePart } from "./sqlite/chats.js";
|
|
22
22
|
export { storeClients } from "./sqlite/clients.js";
|
|
23
|
-
export { CHAT_LIST_KEY, type ChatCompleteness, fetchedKey, historyStartKey } from "./sqlite/completeness.js";
|
|
23
|
+
export { CHAT_LIST_KEY, type ChatCompleteness, type ChatCoverage, fetchedKey, historyStartKey, } from "./sqlite/completeness.js";
|
|
24
24
|
export { CONVERSATION_COLUMNS } from "./sqlite/conversations.js";
|
|
25
25
|
export type { CounterTarget } from "./sqlite/counters.js";
|
|
26
26
|
export { type EmailMarks, MAIL_AGENDA_COLUMNS, type MarkedEmail, type NewestEmail, type ThreadActivity, } from "./sqlite/email-activity.js";
|
package/dist/store/exports.js
CHANGED
|
@@ -4,7 +4,7 @@ export { attachmentColumns, resetAttachmentWords, } from "./sqlite/attachment-te
|
|
|
4
4
|
export { pendingNormalization } from "./sqlite/backfill.js";
|
|
5
5
|
export { CHAT_COLUMNS, TRACKED_CHAT_COLUMNS, titlePart } from "./sqlite/chats.js";
|
|
6
6
|
export { storeClients } from "./sqlite/clients.js";
|
|
7
|
-
export { CHAT_LIST_KEY, fetchedKey, historyStartKey } from "./sqlite/completeness.js";
|
|
7
|
+
export { CHAT_LIST_KEY, fetchedKey, historyStartKey, } from "./sqlite/completeness.js";
|
|
8
8
|
export { CONVERSATION_COLUMNS } from "./sqlite/conversations.js";
|
|
9
9
|
export { MAIL_AGENDA_COLUMNS, } from "./sqlite/email-activity.js";
|
|
10
10
|
export { messageOfEmail, threadConversation } from "./sqlite/emails.js";
|
package/dist/store/index.d.ts
CHANGED
|
@@ -62,7 +62,7 @@ export type { SyncStateSave } from "./sqlite/sync.js";
|
|
|
62
62
|
export type { TagCreationOptions, TagCreationReview } from "./sqlite/tag-creation.js";
|
|
63
63
|
export type { TagKind, TagRow } from "./sqlite/tags.js";
|
|
64
64
|
export type { TaskSort } from "./sqlite/task-board.js";
|
|
65
|
-
export { PROJECT_TASK_FIELDS, TASK_SORTS } from "./sqlite/task-board.js";
|
|
65
|
+
export { PROJECT_TASK_FIELDS, TASK_SORTS, withoutField } from "./sqlite/task-board.js";
|
|
66
66
|
export type { SimilarSubject, SimilarTask, TaskDraft } from "./sqlite/task-candidates.js";
|
|
67
67
|
export { type AgentCloseSettings, type CloseSuggestion, certaintyOf } from "./sqlite/task-close.js";
|
|
68
68
|
export type { NamedAuthor, TaskComment } from "./sqlite/task-comments.js";
|
|
@@ -70,6 +70,7 @@ export type { TaskPair, TaskPairDecision, TaskPairSide } from "./sqlite/task-pai
|
|
|
70
70
|
export { TASK_PAIR_DECISIONS } from "./sqlite/task-pairs.js";
|
|
71
71
|
export type { TaskRelation, TaskRelationKind, TaskRelationsStore } from "./sqlite/task-relations.js";
|
|
72
72
|
export { TASK_RELATION_KINDS } from "./sqlite/task-relations.js";
|
|
73
|
+
export type { TaskStatRow, TaskStatScope } from "./sqlite/task-stats.js";
|
|
73
74
|
export type { StoreTaskStore } from "./sqlite/tasks.js";
|
|
74
75
|
export type { TagPreflight, VocabularyAlias, VocabularyAliasReceipt, VocabularyAudit, VocabularyAuditOptions, VocabularyCatalog, VocabularyEntry, VocabularyKind, VocabularyOptions, VocabularyResolution, VocabularyStore, VocabularyTarget, } from "./sqlite/vocabulary.js";
|
|
75
76
|
export type { BoardCounts, BoardOptions, EditedTask, MovedSubtask, NewWorkTask, RevisionCheck, TaskAssignment, TaskDetails, TaskEvent, TaskFields, TaskMove, TaskMoved, TaskRole, TaskType, WorkTaskType, } from "./sqlite/work-tasks.js";
|
package/dist/store/index.js
CHANGED
|
@@ -26,7 +26,7 @@ export { ORGANIZATION_KINDS, PROJECT_TYPES, SCOPES } from "./sqlite/knowledge.js
|
|
|
26
26
|
export { NOTE_FIELDS, } from "./sqlite/note-search.js";
|
|
27
27
|
export { assertGenericAction, PROPOSAL_STATUSES, proposerOfAction, } from "./sqlite/proposed-actions.js";
|
|
28
28
|
export { fillSearchIndex, resetSearchIndex, searchIndexState, } from "./sqlite/search-index.js";
|
|
29
|
-
export { PROJECT_TASK_FIELDS, TASK_SORTS } from "./sqlite/task-board.js";
|
|
29
|
+
export { PROJECT_TASK_FIELDS, TASK_SORTS, withoutField } from "./sqlite/task-board.js";
|
|
30
30
|
export { certaintyOf } from "./sqlite/task-close.js";
|
|
31
31
|
export { TASK_PAIR_DECISIONS } from "./sqlite/task-pairs.js";
|
|
32
32
|
export { TASK_RELATION_KINDS } from "./sqlite/task-relations.js";
|
|
@@ -23,3 +23,20 @@ export interface ChatCompleteness {
|
|
|
23
23
|
* A start mark that a later fetch reached past was wrong, so it stops counting.
|
|
24
24
|
*/
|
|
25
25
|
export declare const chatCompleteness: ({ database }: StoreContext, accountKey: number, chatIds: Id[]) => ChatCompleteness[];
|
|
26
|
+
export interface ChatCoverage {
|
|
27
|
+
chats: number;
|
|
28
|
+
/** Held back to the first message, up to date, without gaps: `complete` above. */
|
|
29
|
+
fetched: number;
|
|
30
|
+
partial: number;
|
|
31
|
+
/** No fetch ever recorded and no message held. */
|
|
32
|
+
never: number;
|
|
33
|
+
oldestMessageAt: string | null;
|
|
34
|
+
}
|
|
35
|
+
export declare const COVERAGE_SQL = "SELECT c.last_message_at AS newest, c.message_count AS messages,\n (SELECT m.sent_at FROM messages m WHERE m.chat_id = c.id AND m.deleted_at IS NULL\n ORDER BY m.sent_at DESC LIMIT 1) AS held,\n (SELECT m.sent_at FROM messages m WHERE m.chat_id = c.id AND m.deleted_at IS NULL\n ORDER BY m.sent_at ASC LIMIT 1) AS oldest,\n (SELECT count(*) FROM sync_ranges r WHERE r.chat_id = c.id) AS stretches,\n EXISTS (SELECT 1 FROM sync_cursors s WHERE s.account_id = c.account_id AND s.key = 'history_start:' || c.external_id\n AND NOT EXISTS (SELECT 1 FROM sync_ranges r WHERE r.chat_id = c.id AND r.from_key < CAST(s.value AS INTEGER)))\n AS start,\n EXISTS (SELECT 1 FROM sync_cursors s WHERE s.account_id = c.account_id\n AND s.key IN ('history_start:' || c.external_id, 'fetched:' || c.external_id)) AS recorded\n FROM chats c WHERE c.account_id = ? AND c.searchable = 1";
|
|
36
|
+
/**
|
|
37
|
+
* `chatCompleteness` over every searchable chat of the account in one statement. A chat whose history was pushed
|
|
38
|
+
* into the store (WhatsApp) has messages and no fetch record, so it counts as partial, not never. The newest and
|
|
39
|
+
* oldest held are read with ORDER BY … LIMIT 1, so each stops at the first live row of `messages_by_time` rather than
|
|
40
|
+
* relying on the planner's min/max shortcut, which the deleted_at filter may defeat.
|
|
41
|
+
*/
|
|
42
|
+
export declare const chatCoverage: ({ database }: StoreContext, accountKey: number) => ChatCoverage;
|