@wirecat/cli-core 0.562.0 → 0.563.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/dist/cli/config-command.js +18 -1
- package/dist/cli/envelope.js +13 -1
- package/dist/cli/execution.js +1 -0
- package/dist/cli/messenger/reply-stats-command.d.ts +4 -0
- package/dist/cli/messenger/reply-stats-command.js +57 -0
- package/dist/cli/messenger/search-command.js +8 -0
- package/dist/cli/messenger/stats-answers.d.ts +3 -0
- package/dist/cli/messenger/stats-answers.js +18 -0
- package/dist/cli/messenger/stats-command.js +5 -0
- package/dist/cli/output-budget.d.ts +26 -0
- package/dist/cli/output-budget.js +73 -0
- package/dist/cli/program.js +14 -1
- package/dist/cli/settings.d.ts +5 -0
- package/dist/cli/settings.js +88 -5
- package/dist/cli/shared-command-exports.d.ts +8 -0
- package/dist/domain/admin-statistics.d.ts +5 -0
- package/dist/domain/admin-statistics.js +4 -3
- package/dist/entities/index.d.ts +31 -1
- package/dist/entities/index.js +4 -1
- package/dist/entities/reply-stats.d.ts +33 -0
- package/dist/entities/reply-stats.js +19 -0
- package/dist/errors/reasons.d.ts +1 -1
- package/dist/errors/reasons.js +2 -3
- package/dist/mail/attachments.d.ts +15 -2
- package/dist/mail/attachments.js +19 -3
- package/dist/mail/cli/index.d.ts +17 -1
- package/dist/mail/cli/index.js +1 -1
- package/dist/mail/cli/review.d.ts +5 -2
- package/dist/mail/cli/review.js +47 -39
- package/dist/mail/index.d.ts +2 -0
- package/dist/mail/index.js +2 -0
- package/dist/mail/partial.d.ts +3 -1
- package/dist/mail/partial.js +2 -2
- package/dist/mail/retry.d.ts +3 -1
- package/dist/mail/retry.js +3 -3
- package/dist/mail/sync-types.d.ts +3 -0
- package/dist/mail/sync.js +5 -3
- package/dist/mail/whole.d.ts +28 -0
- package/dist/mail/whole.js +69 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +2 -0
- package/dist/services/proposal-tasks.js +3 -12
- package/dist/services/proposals/kinds/action.js +3 -2
- package/dist/services/proposals/registry.js +8 -4
- package/dist/services/proposals/types.d.ts +2 -0
- package/dist/services/reply-stats.d.ts +48 -0
- package/dist/services/reply-stats.js +137 -0
- package/dist/store/decisions.d.ts +14 -11
- package/dist/store/decisions.js +9 -2
- package/dist/store/index.d.ts +3 -3
- package/dist/store/index.js +2 -2
- package/dist/store/queries.d.ts +4 -0
- package/dist/store/queries.js +3 -0
- package/dist/store/sqlite/email-locations.d.ts +4 -0
- package/dist/store/sqlite/email-locations.js +26 -11
- package/dist/store/sqlite/lucene-fields.d.ts +2 -0
- package/dist/store/sqlite/lucene-fields.js +3 -1
- package/dist/store/sqlite/mail-findings.js +1 -1
- package/dist/store/sqlite/meeting-findings.js +1 -1
- package/dist/store/sqlite/proposed-actions.d.ts +2 -0
- package/dist/store/sqlite/proposed-actions.js +24 -17
- package/dist/store/sqlite/reply-times.d.ts +57 -0
- package/dist/store/sqlite/reply-times.js +113 -0
- package/dist/store/sqlite/work-tasks.d.ts +12 -2
- package/dist/store/sqlite/work-tasks.js +35 -26
- package/dist/store/store.d.ts +1 -0
- package/package.json +1 -1
- package/parity.json +24 -0
package/dist/store/index.js
CHANGED
|
@@ -3,7 +3,7 @@ export { createTagRedirectIndex } from "../domain/tag-redirects.js";
|
|
|
3
3
|
export { note } from "../entities/note.js";
|
|
4
4
|
export { PAYMENT_DIRECTIONS, PAYMENT_STATES, paymentTitle, validatePayment } from "../meetings/index.js";
|
|
5
5
|
export { CLOSED_STATES, isClosedState, PRIORITY_LABELS, priorityLabel, priorityOf, priorityOption, TASK_STATES, } from "../tasks/index.js";
|
|
6
|
-
export { assertMayDecide, DEFAULT_DECISION_POLICY, mayDecide, } from "./decisions.js";
|
|
6
|
+
export { assertMayDecide, DEFAULT_DECISION_POLICY, isOwnerOnlyKind, mayDecide, } from "./decisions.js";
|
|
7
7
|
export { applyDocumentTransfer, previewDocumentTransfer } from "./document-transfer/index.js";
|
|
8
8
|
export { PRAGMAS } from "./driver.js";
|
|
9
9
|
export { EVIDENCE_FINDINGS_KIND, validateEvidenceFindings, } from "./evidence-findings.js";
|
|
@@ -24,7 +24,7 @@ export { EVENT_FIELDS, } from "./sqlite/event-search.js";
|
|
|
24
24
|
export { IMPORTED_DOCUMENT_QUERY } from "./sqlite/imported-documents.js";
|
|
25
25
|
export { ORGANIZATION_KINDS, PROJECT_TYPES, SCOPES } from "./sqlite/knowledge.js";
|
|
26
26
|
export { NOTE_FIELDS, } from "./sqlite/note-search.js";
|
|
27
|
-
export { PROPOSAL_STATUSES, proposerOfAction, } from "./sqlite/proposed-actions.js";
|
|
27
|
+
export { assertGenericAction, PROPOSAL_STATUSES, proposerOfAction, } from "./sqlite/proposed-actions.js";
|
|
28
28
|
export { fillSearchIndex, resetSearchIndex, searchIndexState, } from "./sqlite/search-index.js";
|
|
29
29
|
export { PROJECT_TASK_FIELDS, TASK_SORTS } from "./sqlite/task-board.js";
|
|
30
30
|
export { certaintyOf } from "./sqlite/task-close.js";
|
package/dist/store/queries.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { QueryGroup, QueryGrouping } from "./sqlite/lucene.js";
|
|
|
7
7
|
import type { StoreContext } from "./sqlite/open.js";
|
|
8
8
|
import { type RankedEvidence, type RankingEvidenceRequest } from "./sqlite/ranking-evidence.js";
|
|
9
9
|
import { type RankedStoreFound, type RankingRequest } from "./sqlite/rankings.js";
|
|
10
|
+
import { type ChatScope, type NewestMessage, type ReplyDelay, type WaitingChat } from "./sqlite/reply-times.js";
|
|
10
11
|
import type { ScoredHit } from "./sqlite/words.js";
|
|
11
12
|
/** A resolved Lucene query run over the store: hits, counts, rankings and the statistics behind them. */
|
|
12
13
|
export interface QueriesStore {
|
|
@@ -21,5 +22,8 @@ export interface QueriesStore {
|
|
|
21
22
|
rankQuery(execution: QueryExecution, request: RankingRequest): Promise<RankedStoreFound>;
|
|
22
23
|
rankingEvidence(execution: QueryExecution, request: RankingEvidenceRequest): Promise<RankedEvidence>;
|
|
23
24
|
adminStatisticsQuery(execution: QueryExecution, request: AdminStoreRequest): Promise<AdminStoreResult>;
|
|
25
|
+
/** Every reply in one-to-one chats whose reply falls in the scope's period, with how long it took. */
|
|
26
|
+
replyDelays(scope: ChatScope, who: "me" | "others"): Promise<ReplyDelay[]>;
|
|
27
|
+
waitingChats(scope: ChatScope, waits: (newest: NewestMessage) => boolean, cap: number): Promise<WaitingChat[]>;
|
|
24
28
|
}
|
|
25
29
|
export declare const queriesStoreOver: (context: StoreContext, stemmerFor: (stemmers: Stemmers) => Stemmer) => QueriesStore;
|
package/dist/store/queries.js
CHANGED
|
@@ -4,6 +4,7 @@ import { drainEmailIndex } from "./sqlite/email-index.js";
|
|
|
4
4
|
import * as lucene from "./sqlite/lucene.js";
|
|
5
5
|
import { rankingEvidence } from "./sqlite/ranking-evidence.js";
|
|
6
6
|
import { rankQuery } from "./sqlite/rankings.js";
|
|
7
|
+
import { replyDelays, waitingChats, } from "./sqlite/reply-times.js";
|
|
7
8
|
export const queriesStoreOver = (context, stemmerFor) => {
|
|
8
9
|
const mailIndexed = (execution) => {
|
|
9
10
|
if (execution.corpus === "mail")
|
|
@@ -19,5 +20,7 @@ export const queriesStoreOver = (context, stemmerFor) => {
|
|
|
19
20
|
rankQuery: async (execution, request) => rankQuery(context, execution, request),
|
|
20
21
|
rankingEvidence: async (execution, request) => rankingEvidence(context, execution, request),
|
|
21
22
|
adminStatisticsQuery: async (execution, request) => adminStatisticsQuery(context, execution, request),
|
|
23
|
+
replyDelays: async (scope, who) => replyDelays(context, scope, who),
|
|
24
|
+
waitingChats: async (scope, waits, cap) => waitingChats(context, scope, waits, cap),
|
|
22
25
|
};
|
|
23
26
|
};
|
|
@@ -38,6 +38,8 @@ export declare const partialEmails: (database: CacheDatabase, accountId: number,
|
|
|
38
38
|
total: number;
|
|
39
39
|
stuck: number;
|
|
40
40
|
};
|
|
41
|
+
/** One live email's thread and stored places, newest first, in the shape `partialEmails` gives; `null` if not stored. */
|
|
42
|
+
export declare const emailPlace: (database: CacheDatabase, accountId: number, externalId: string) => PartialEmail | null;
|
|
41
43
|
/** Replaces a partial email's `metadata.partial`, as a failed read records itself; a whole email is left alone. */
|
|
42
44
|
export declare const markPartial: (database: CacheDatabase, accountId: number, externalId: string, mark: Metadata) => {
|
|
43
45
|
changes: number;
|
|
@@ -48,6 +50,8 @@ export interface PartialMailStore {
|
|
|
48
50
|
total: number;
|
|
49
51
|
stuck: number;
|
|
50
52
|
}>;
|
|
53
|
+
/** One live email's thread and stored places, newest first; `null` when it is not stored. */
|
|
54
|
+
emailPlace(accountId: number, externalId: string): Promise<PartialEmail | null>;
|
|
51
55
|
/** Records a failed read of a partial email in its `metadata.partial`; a whole email is left alone. */
|
|
52
56
|
markPartial(accountId: number, externalId: string, mark: Metadata): Promise<void>;
|
|
53
57
|
}
|
|
@@ -23,6 +23,17 @@ export const folderEmails = (database, accountId, folder, uidvalidity) => databa
|
|
|
23
23
|
locations: Number(row.locations),
|
|
24
24
|
folders: Number(row.folders),
|
|
25
25
|
}));
|
|
26
|
+
const placed = (row) => ({
|
|
27
|
+
externalId: String(row.external_id),
|
|
28
|
+
threadExternalId: String(row.thread),
|
|
29
|
+
receivedAt: row.at === null ? null : Number(row.at),
|
|
30
|
+
metadata: fromJson(row.metadata),
|
|
31
|
+
locations: JSON.parse(String(row.places)).map((place) => ({
|
|
32
|
+
folder: String(place.folder),
|
|
33
|
+
uidvalidity: String(place.uidvalidity),
|
|
34
|
+
uid: Number(place.uid),
|
|
35
|
+
})),
|
|
36
|
+
});
|
|
26
37
|
export const partialEmails = (database, accountId, limit) => {
|
|
27
38
|
const rows = database
|
|
28
39
|
.prepare(`SELECT e.external_id, t.external_id AS thread, coalesce(e.received_at, e.sent_at) AS at, e.metadata,
|
|
@@ -43,19 +54,22 @@ export const partialEmails = (database, accountId, limit) => {
|
|
|
43
54
|
return {
|
|
44
55
|
total: rows.length ? Number(rows[0]?.total) : 0,
|
|
45
56
|
stuck: Number(stuck?.n ?? 0),
|
|
46
|
-
emails: rows.map(
|
|
47
|
-
externalId: String(row.external_id),
|
|
48
|
-
threadExternalId: String(row.thread),
|
|
49
|
-
receivedAt: row.at === null ? null : Number(row.at),
|
|
50
|
-
metadata: fromJson(row.metadata),
|
|
51
|
-
locations: JSON.parse(String(row.places)).map((place) => ({
|
|
52
|
-
folder: String(place.folder),
|
|
53
|
-
uidvalidity: String(place.uidvalidity),
|
|
54
|
-
uid: Number(place.uid),
|
|
55
|
-
})),
|
|
56
|
-
})),
|
|
57
|
+
emails: rows.map(placed),
|
|
57
58
|
};
|
|
58
59
|
};
|
|
60
|
+
/** One live email's thread and stored places, newest first, in the shape `partialEmails` gives; `null` if not stored. */
|
|
61
|
+
export const emailPlace = (database, accountId, externalId) => {
|
|
62
|
+
const row = database
|
|
63
|
+
.prepare(`SELECT e.external_id, t.external_id AS thread, coalesce(e.received_at, e.sent_at) AS at, e.metadata,
|
|
64
|
+
(SELECT json_group_array(json_object('folder', folder, 'uidvalidity', uidvalidity, 'uid', uid))
|
|
65
|
+
FROM (SELECT b.external_id AS folder, l.uidvalidity, l.uid
|
|
66
|
+
FROM email_locations l JOIN mailboxes b ON b.id = l.mailbox_id
|
|
67
|
+
WHERE l.email_id = e.id ORDER BY l.rowid DESC)) AS places
|
|
68
|
+
FROM emails e JOIN email_threads t ON t.id = e.email_thread_id
|
|
69
|
+
WHERE e.account_id = ? AND e.external_id = ? AND e.deleted_at IS NULL`)
|
|
70
|
+
.get(accountId, externalId);
|
|
71
|
+
return row ? placed(row) : null;
|
|
72
|
+
};
|
|
59
73
|
/** Replaces a partial email's `metadata.partial`, as a failed read records itself; a whole email is left alone. */
|
|
60
74
|
export const markPartial = (database, accountId, externalId, mark) => database
|
|
61
75
|
.prepare(`UPDATE emails SET metadata = json_set(metadata, '$.partial', json(?)) WHERE account_id = ? AND external_id = ?
|
|
@@ -63,6 +77,7 @@ export const markPartial = (database, accountId, externalId, mark) => database
|
|
|
63
77
|
.run(JSON.stringify(mark), accountId, externalId);
|
|
64
78
|
export const partialMail = (database) => ({
|
|
65
79
|
partialEmails: async (accountId, limit) => partialEmails(database, accountId, limit),
|
|
80
|
+
emailPlace: async (accountId, externalId) => emailPlace(database, accountId, externalId),
|
|
66
81
|
markPartial: async (accountId, externalId, mark) => {
|
|
67
82
|
write(database, () => markPartial(database, accountId, externalId, mark));
|
|
68
83
|
},
|
|
@@ -82,6 +82,8 @@ type Compiled = {
|
|
|
82
82
|
type FieldCompiler = (node: ResolvedPredicate, scope: FieldScope) => Compiled;
|
|
83
83
|
/** A leaf as an FTS5 match over a stem index: phrases of stems, or with `~N` their `NEAR`. */
|
|
84
84
|
export declare const stemMatch: (stemmer: Stemmer, node: ResolvedPredicate) => string | undefined;
|
|
85
|
+
/** What `kind:` matches for a chat aliased `c`: private, saved, bot, service, group, channel or unknown. */
|
|
86
|
+
export declare const PEER_KIND = "CASE WHEN json_extract(c.metadata,'$.peerKind') IN ('private','saved','bot','service','group','channel','unknown') THEN json_extract(c.metadata,'$.peerKind') WHEN c.kind='dialog' AND json_extract(c.metadata,'$.isBot')=1 THEN 'bot' WHEN c.kind='dialog' THEN 'private' ELSE c.kind END";
|
|
85
87
|
/** One compiler per field of `QUERY_FIELDS`, by its name: a leaf compiles by a lookup, never a branch. */
|
|
86
88
|
export declare const FIELD_COMPILERS: Readonly<Record<string, FieldCompiler>>;
|
|
87
89
|
export {};
|
|
@@ -344,7 +344,9 @@ const messengerOnly = (compile) => (node, scope) => {
|
|
|
344
344
|
return compile(node, scope);
|
|
345
345
|
};
|
|
346
346
|
const topic = messengerOnly(({ value }) => always(bound("m.thread_external_id = ?", value)));
|
|
347
|
-
|
|
347
|
+
/** What `kind:` matches for a chat aliased `c`: private, saved, bot, service, group, channel or unknown. */
|
|
348
|
+
export const PEER_KIND = "CASE WHEN json_extract(c.metadata,'$.peerKind') IN ('private','saved','bot','service','group','channel','unknown') THEN json_extract(c.metadata,'$.peerKind') WHEN c.kind='dialog' AND json_extract(c.metadata,'$.isBot')=1 THEN 'bot' WHEN c.kind='dialog' THEN 'private' ELSE c.kind END";
|
|
349
|
+
const kind = messengerOnly(({ value }) => always(bound(`${PEER_KIND} = ?`, value.toLowerCase())));
|
|
348
350
|
const has = ({ value }, { corpus, mail }) => {
|
|
349
351
|
const wanted = value.toLowerCase();
|
|
350
352
|
return always(wanted === "text"
|
|
@@ -290,7 +290,7 @@ export const mailFindingsStoreOver = (context) => {
|
|
|
290
290
|
return invalid("accept only unique IDs shown in this batch");
|
|
291
291
|
if (decision.by === undefined)
|
|
292
292
|
return invalid("a findings review names who decides: the owner");
|
|
293
|
-
assertMayDecide({ id, proposedBy: proposals.proposer(id)
|
|
293
|
+
assertMayDecide({ id, kind: MAIL_FINDINGS_KIND, proposedBy: proposals.proposer(id) }, decision.by, context.decisions);
|
|
294
294
|
const accepted = [...decision.accept].sort();
|
|
295
295
|
if (batch.receipt) {
|
|
296
296
|
if (JSON.stringify(batch.receipt.accepted) !== JSON.stringify(accepted))
|
|
@@ -179,7 +179,7 @@ export const meetingFindingsStoreOver = (context) => {
|
|
|
179
179
|
const accepted = [...decision.accept].sort();
|
|
180
180
|
if (decision.by === undefined)
|
|
181
181
|
return invalid("a findings review names who decides: the owner or an agent");
|
|
182
|
-
assertMayDecide({ id, proposedBy: proposals.proposer(id) }, decision.by, context.decisions);
|
|
182
|
+
assertMayDecide({ id, kind: MEETING_FINDINGS_KIND, proposedBy: proposals.proposer(id) }, decision.by, context.decisions);
|
|
183
183
|
if (batch.receipt) {
|
|
184
184
|
if (JSON.stringify(batch.receipt.accepted) !== JSON.stringify(accepted))
|
|
185
185
|
return invalid("this findings batch was already reviewed with a different decision");
|
|
@@ -83,5 +83,7 @@ type ProposalOperations = Omit<{
|
|
|
83
83
|
/** Who proposed an action, for `mayDecide`. */
|
|
84
84
|
export declare const proposerOfAction: ({ proposedBy, payload }: ProposedAction) => Proposer;
|
|
85
85
|
export declare const proposalOperationsOver: (context: StoreContext) => ProposalOperations;
|
|
86
|
+
/** Refuses a findings batch on a generic action path: approving one as an action would skip its own checks. */
|
|
87
|
+
export declare const assertGenericAction: (kind: string) => void;
|
|
86
88
|
export declare const proposedActionsStoreOver: (context: StoreContext) => ProposedActionsStore;
|
|
87
89
|
export {};
|
|
@@ -164,38 +164,45 @@ export const proposalOperationsOver = (context) => {
|
|
|
164
164
|
},
|
|
165
165
|
};
|
|
166
166
|
};
|
|
167
|
+
/** Stored kinds a generic action path never decides: each has its own review, which checks what the batch cites. */
|
|
168
|
+
const DEDICATED_REVIEW = {
|
|
169
|
+
[MEETING_FINDINGS_KIND]: { what: "meeting findings", reason: "meeting-findings.dedicated-review" },
|
|
170
|
+
[EVIDENCE_FINDINGS_KIND]: { what: "evidence findings", reason: "evidence-findings.dedicated-review" },
|
|
171
|
+
[MAIL_FINDINGS_KIND]: { what: "mail findings", reason: "mail-findings.dedicated-review" },
|
|
172
|
+
};
|
|
173
|
+
/** Refuses a findings batch on a generic action path: approving one as an action would skip its own checks. */
|
|
174
|
+
export const assertGenericAction = (kind) => {
|
|
175
|
+
const dedicated = DEDICATED_REVIEW[kind];
|
|
176
|
+
if (dedicated)
|
|
177
|
+
throw new CliError("validation_error", `review ${dedicated.what} through the dedicated findings command`, {
|
|
178
|
+
reason: dedicated.reason,
|
|
179
|
+
});
|
|
180
|
+
};
|
|
167
181
|
export const proposedActionsStoreOver = (context) => {
|
|
168
182
|
const operations = proposalOperationsOver(context);
|
|
169
183
|
const generic = (id) => {
|
|
170
|
-
const kind = operations.get(id)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
reason: "meeting-findings.dedicated-review",
|
|
174
|
-
});
|
|
175
|
-
if (kind === EVIDENCE_FINDINGS_KIND)
|
|
176
|
-
throw new CliError("validation_error", "review evidence findings through the dedicated findings command", {
|
|
177
|
-
reason: "evidence-findings.dedicated-review",
|
|
178
|
-
});
|
|
184
|
+
const { kind } = operations.get(id);
|
|
185
|
+
assertGenericAction(kind);
|
|
186
|
+
return kind;
|
|
179
187
|
};
|
|
180
188
|
return {
|
|
181
189
|
propose: async (input) => {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
throw new CliError("validation_error", "stage evidence findings through the dedicated findings command");
|
|
190
|
+
const dedicated = DEDICATED_REVIEW[input.kind];
|
|
191
|
+
if (dedicated)
|
|
192
|
+
throw new CliError("validation_error", `stage ${dedicated.what} through the dedicated findings command`);
|
|
186
193
|
return operations.propose(input);
|
|
187
194
|
},
|
|
188
195
|
get: async (id) => operations.get(id),
|
|
189
196
|
pending: async (options) => operations.pending(options),
|
|
190
197
|
list: async (options) => operations.list(options),
|
|
191
198
|
approve: async (id, by, result) => {
|
|
192
|
-
generic(id);
|
|
193
|
-
assertMayDecide({ id, proposedBy: operations.proposer(id) }, by, context.decisions);
|
|
199
|
+
const kind = generic(id);
|
|
200
|
+
assertMayDecide({ id, kind, proposedBy: operations.proposer(id) }, by, context.decisions);
|
|
194
201
|
return operations.approve(id, result, deciderAuthor(by));
|
|
195
202
|
},
|
|
196
203
|
reject: async (id, by, reason) => {
|
|
197
|
-
generic(id);
|
|
198
|
-
assertMayDecide({ id, proposedBy: operations.proposer(id) }, by, context.decisions);
|
|
204
|
+
const kind = generic(id);
|
|
205
|
+
assertMayDecide({ id, kind, proposedBy: operations.proposer(id) }, by, context.decisions);
|
|
199
206
|
return operations.reject(id, deciderAuthor(by), reason?.trim() ? { reason: reason.trim() } : undefined);
|
|
200
207
|
},
|
|
201
208
|
executed: async (id, result) => {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { SqlValue } from "../driver.js";
|
|
2
|
+
import type { AccountKey } from "../types.js";
|
|
3
|
+
import type { StoreContext } from "./open.js";
|
|
4
|
+
/** The chats a reply-time or waiting answer reads; every chat and kind listed must hold. */
|
|
5
|
+
export interface ChatScope {
|
|
6
|
+
accounts: AccountKey[];
|
|
7
|
+
chats: {
|
|
8
|
+
account: AccountKey;
|
|
9
|
+
chatId: string;
|
|
10
|
+
}[];
|
|
11
|
+
kinds: string[];
|
|
12
|
+
/** Inclusive bounds in ms; absent is open. */
|
|
13
|
+
since?: number;
|
|
14
|
+
until?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface ReplyDelay {
|
|
17
|
+
repliedAt: number;
|
|
18
|
+
delayMs: number;
|
|
19
|
+
}
|
|
20
|
+
export interface NewestMessage {
|
|
21
|
+
chatId: number;
|
|
22
|
+
kind: string;
|
|
23
|
+
sentAt: number;
|
|
24
|
+
mine: boolean;
|
|
25
|
+
text: string;
|
|
26
|
+
/** The message names the owner by id, or replies to one of the owner's messages. */
|
|
27
|
+
addressed: boolean;
|
|
28
|
+
ownerUsername: string | null;
|
|
29
|
+
/** Muted or archived, and not marked unread: what `inbox` leaves out. */
|
|
30
|
+
quiet: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* One row per reply in one-to-one chats. Each chat is read from the start of the run before the first message in the
|
|
34
|
+
* period, so a reply early in the period is timed from the first message it answers, however old.
|
|
35
|
+
*/
|
|
36
|
+
export declare const replyDelaysQuery: (scope: ChatScope, who: "me" | "others") => {
|
|
37
|
+
sql: string;
|
|
38
|
+
params: SqlValue[];
|
|
39
|
+
};
|
|
40
|
+
export declare const replyDelays: (context: StoreContext, scope: ChatScope, who: "me" | "others") => ReplyDelay[];
|
|
41
|
+
/** Each one-to-one chat's and group's newest message, found by index per chat, its time within the scope's bounds. */
|
|
42
|
+
export declare const newestMessagesQuery: (scope: ChatScope, now: number) => {
|
|
43
|
+
sql: string;
|
|
44
|
+
params: SqlValue[];
|
|
45
|
+
};
|
|
46
|
+
/** The newest `cap` messages of a chat, newest first: enough to find where the owner last spoke. */
|
|
47
|
+
export declare const TAIL_SQL = "SELECT sent_at, coalesce(outgoing,0) AS mine FROM messages WHERE chat_id=? AND deleted_at IS NULL ORDER BY sent_at DESC LIMIT ?";
|
|
48
|
+
export interface WaitingChat {
|
|
49
|
+
kind: string;
|
|
50
|
+
/** When each of the other side's messages after the owner's last one was sent, newest first. */
|
|
51
|
+
unanswered: number[];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The chats whose newest message `waits` accepts, with the other side's messages since the owner last spoke, read
|
|
55
|
+
* back no further than `cap` messages per chat.
|
|
56
|
+
*/
|
|
57
|
+
export declare const waitingChats: (context: StoreContext, scope: ChatScope, waits: (newest: NewestMessage) => boolean, cap: number) => WaitingChat[];
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { PEER_KIND } from "./lucene-fields.js";
|
|
2
|
+
import { read } from "./transaction.js";
|
|
3
|
+
const FAR = Number.MAX_SAFE_INTEGER;
|
|
4
|
+
const chatsIn = (scope, kinds) => {
|
|
5
|
+
const params = [...scope.accounts.flatMap(({ provider, account }) => [provider, account]), ...kinds];
|
|
6
|
+
const parts = [
|
|
7
|
+
`(${scope.accounts.map(() => "(ac.provider=? AND ac.external_id=?)").join(" OR ") || "0"})`,
|
|
8
|
+
`${PEER_KIND} IN (${kinds.map(() => "?").join(",")})`,
|
|
9
|
+
];
|
|
10
|
+
for (const { account, chatId } of scope.chats) {
|
|
11
|
+
parts.push("ac.provider=? AND ac.external_id=? AND c.external_id=?");
|
|
12
|
+
params.push(account.provider, account.account, chatId);
|
|
13
|
+
}
|
|
14
|
+
for (const kind of scope.kinds) {
|
|
15
|
+
parts.push(`${PEER_KIND}=?`);
|
|
16
|
+
params.push(kind);
|
|
17
|
+
}
|
|
18
|
+
// As in search: a chat named is read even when it is not searchable.
|
|
19
|
+
if (scope.chats.length === 0)
|
|
20
|
+
parts.push("c.searchable=1");
|
|
21
|
+
return { sql: parts.join(" AND "), params };
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* One row per reply in one-to-one chats. Each chat is read from the start of the run before the first message in the
|
|
25
|
+
* period, so a reply early in the period is timed from the first message it answers, however old.
|
|
26
|
+
*/
|
|
27
|
+
export const replyDelaysQuery = (scope, who) => {
|
|
28
|
+
const since = scope.since ?? -FAR;
|
|
29
|
+
const until = scope.until ?? FAR;
|
|
30
|
+
const chats = chatsIn(scope, ["private"]);
|
|
31
|
+
return {
|
|
32
|
+
sql: `WITH scoped AS MATERIALIZED (
|
|
33
|
+
SELECT c.id AS chat_id, coalesce((
|
|
34
|
+
SELECT p.sent_at FROM messages p WHERE p.chat_id=c.id AND p.sent_at<? AND p.deleted_at IS NULL
|
|
35
|
+
AND coalesce(p.outgoing,0)<>(SELECT coalesce(l.outgoing,0) FROM messages l
|
|
36
|
+
WHERE l.chat_id=c.id AND l.sent_at<? AND l.deleted_at IS NULL ORDER BY l.sent_at DESC LIMIT 1)
|
|
37
|
+
ORDER BY p.sent_at DESC LIMIT 1), ?) AS since
|
|
38
|
+
FROM chats c JOIN accounts ac ON ac.id=c.account_id WHERE ${chats.sql}
|
|
39
|
+
), sides AS (
|
|
40
|
+
SELECT m.chat_id, m.id, m.sent_at, coalesce(m.outgoing,0) AS mine,
|
|
41
|
+
lag(coalesce(m.outgoing,0)) OVER (PARTITION BY m.chat_id ORDER BY m.sent_at, m.id) AS before
|
|
42
|
+
FROM scoped s JOIN messages m ON m.chat_id=s.chat_id AND m.sent_at>=s.since AND m.sent_at<=?
|
|
43
|
+
WHERE m.deleted_at IS NULL
|
|
44
|
+
), runs AS (
|
|
45
|
+
SELECT sent_at, mine, lag(sent_at) OVER (PARTITION BY chat_id ORDER BY sent_at, id) AS asked_at
|
|
46
|
+
FROM sides WHERE before IS NULL OR before<>mine
|
|
47
|
+
)
|
|
48
|
+
SELECT sent_at AS replied_at, sent_at-asked_at AS delay_ms FROM runs
|
|
49
|
+
WHERE asked_at IS NOT NULL AND mine=? AND sent_at>=? AND sent_at<=?`,
|
|
50
|
+
params: [since, since, -FAR, ...chats.params, until, who === "me" ? 1 : 0, since, until],
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
export const replyDelays = (context, scope, who) => {
|
|
54
|
+
const { sql, params } = replyDelaysQuery(scope, who);
|
|
55
|
+
return read(context.database, () => context.database
|
|
56
|
+
.prepare(sql)
|
|
57
|
+
.all(...params)
|
|
58
|
+
.map((row) => ({ repliedAt: Number(row.replied_at), delayMs: Number(row.delay_ms) })));
|
|
59
|
+
};
|
|
60
|
+
/** Each one-to-one chat's and group's newest message, found by index per chat, its time within the scope's bounds. */
|
|
61
|
+
export const newestMessagesQuery = (scope, now) => {
|
|
62
|
+
const chats = chatsIn(scope, ["private", "group"]);
|
|
63
|
+
return {
|
|
64
|
+
sql: `SELECT c.id AS chat_id, ${PEER_KIND} AS kind, m.sent_at, coalesce(m.outgoing,0) AS mine, m.text,
|
|
65
|
+
(EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(m.mentions) THEN m.mentions ELSE '[]' END) WHERE value=ac.external_id)
|
|
66
|
+
OR coalesce(json_extract(CASE WHEN json_valid(m.reply_to) THEN m.reply_to ELSE '{}' END,'$.outgoing'),0)=1
|
|
67
|
+
OR EXISTS (SELECT 1 FROM messages q WHERE q.chat_id=c.id AND q.external_id=m.reply_to_external_id AND q.outgoing=1)
|
|
68
|
+
) AS addressed,
|
|
69
|
+
(SELECT i.username FROM identities i WHERE i.provider=ac.provider AND i.external_id=ac.external_id) AS owner_username,
|
|
70
|
+
((coalesce(c.muted,0)=1 AND (c.muted_until IS NULL OR c.muted_until>?)) OR coalesce(c.archived,0)=1)
|
|
71
|
+
AND coalesce(c.marked_unread,0)=0 AS quiet
|
|
72
|
+
FROM chats c JOIN accounts ac ON ac.id=c.account_id
|
|
73
|
+
JOIN messages m ON m.id=(SELECT n.id FROM messages n WHERE n.chat_id=c.id AND n.deleted_at IS NULL ORDER BY n.sent_at DESC LIMIT 1)
|
|
74
|
+
WHERE ${chats.sql} AND m.sent_at>=? AND m.sent_at<=?`,
|
|
75
|
+
params: [now, ...chats.params, scope.since ?? -FAR, scope.until ?? FAR],
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
/** The newest `cap` messages of a chat, newest first: enough to find where the owner last spoke. */
|
|
79
|
+
export const TAIL_SQL = "SELECT sent_at, coalesce(outgoing,0) AS mine FROM messages WHERE chat_id=? AND deleted_at IS NULL ORDER BY sent_at DESC LIMIT ?";
|
|
80
|
+
/**
|
|
81
|
+
* The chats whose newest message `waits` accepts, with the other side's messages since the owner last spoke, read
|
|
82
|
+
* back no further than `cap` messages per chat.
|
|
83
|
+
*/
|
|
84
|
+
export const waitingChats = (context, scope, waits, cap) => {
|
|
85
|
+
const { sql, params } = newestMessagesQuery(scope, context.now());
|
|
86
|
+
const { database } = context;
|
|
87
|
+
return read(database, () => {
|
|
88
|
+
const tail = database.prepare(TAIL_SQL);
|
|
89
|
+
return database
|
|
90
|
+
.prepare(sql)
|
|
91
|
+
.all(...params)
|
|
92
|
+
.map((row) => ({
|
|
93
|
+
chatId: Number(row.chat_id),
|
|
94
|
+
kind: String(row.kind),
|
|
95
|
+
sentAt: Number(row.sent_at),
|
|
96
|
+
mine: Number(row.mine) === 1,
|
|
97
|
+
text: String(row.text),
|
|
98
|
+
addressed: Number(row.addressed) === 1,
|
|
99
|
+
ownerUsername: row.owner_username === null ? null : String(row.owner_username),
|
|
100
|
+
quiet: Number(row.quiet) === 1,
|
|
101
|
+
}))
|
|
102
|
+
.filter(waits)
|
|
103
|
+
.map(({ chatId, kind }) => {
|
|
104
|
+
const unanswered = [];
|
|
105
|
+
for (const row of tail.all(chatId, cap)) {
|
|
106
|
+
if (Number(row.mine) === 1)
|
|
107
|
+
break;
|
|
108
|
+
unanswered.push(Number(row.sent_at));
|
|
109
|
+
}
|
|
110
|
+
return { kind, unanswered };
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
};
|
|
@@ -33,7 +33,8 @@ export interface TaskFields {
|
|
|
33
33
|
category?: string;
|
|
34
34
|
/**
|
|
35
35
|
* Another owner project, by key or `project:<id>`: the task takes the next key there; its old key still finds it.
|
|
36
|
-
* A subtask moves only with `parent: null` in
|
|
36
|
+
* Its subtasks, all the way down, move with it and keep their parents. A subtask moves only with `parent: null` in
|
|
37
|
+
* the same edit.
|
|
37
38
|
*/
|
|
38
39
|
project?: string;
|
|
39
40
|
/** A project task's kind, bug, feature or chore. */
|
|
@@ -91,6 +92,15 @@ export interface TaskDetails {
|
|
|
91
92
|
createdAt: string;
|
|
92
93
|
updatedAt: string;
|
|
93
94
|
}
|
|
95
|
+
/** A subtask that moved to another project with its parent: its key before and after. */
|
|
96
|
+
export interface MovedSubtask {
|
|
97
|
+
from: string;
|
|
98
|
+
to: string;
|
|
99
|
+
}
|
|
100
|
+
/** What `edit` answers: the task, and the subtasks a move to another project took with it. */
|
|
101
|
+
export interface EditedTask extends TaskDetails {
|
|
102
|
+
movedSubtasks?: MovedSubtask[];
|
|
103
|
+
}
|
|
94
104
|
export interface RevisionCheck {
|
|
95
105
|
/** The revision the caller read; a write on any other is refused (`task.stale`). */
|
|
96
106
|
revision?: number;
|
|
@@ -167,7 +177,7 @@ export interface TaskEvent {
|
|
|
167
177
|
/** Tasks beyond the package's four message kinds, and the fields the package does not know, on any task. */
|
|
168
178
|
export interface WorkTasks extends TaskRelationsStore {
|
|
169
179
|
create(input: NewWorkTask, by: Author): Promise<TaskDetails>;
|
|
170
|
-
edit(id: string, fields: TaskFields, by: Author, check?: RevisionCheck): Promise<
|
|
180
|
+
edit(id: string, fields: TaskFields, by: Author, check?: RevisionCheck): Promise<EditedTask>;
|
|
171
181
|
assign(id: string, assignment: TaskAssignment, by: Author, check?: RevisionCheck): Promise<TaskDetails>;
|
|
172
182
|
unassign(id: string, assignment: TaskAssignment, by: Author, check?: RevisionCheck): Promise<TaskDetails>;
|
|
173
183
|
/**
|
|
@@ -172,7 +172,7 @@ export const workTaskOperationsOver = (context) => {
|
|
|
172
172
|
/** The task takes the target's next number and key; the source's counter never goes back, so its old key stays unique. */
|
|
173
173
|
const moveToProject = (task, before, target, actor, at, detach) => {
|
|
174
174
|
if (target === Number(before.project_id))
|
|
175
|
-
return;
|
|
175
|
+
return [];
|
|
176
176
|
if (!onBoard(task))
|
|
177
177
|
throw new CliError("validation_error", "only a project task moves to another project");
|
|
178
178
|
const targetRow = database.prepare("SELECT key FROM projects WHERE id = ?").get(target);
|
|
@@ -182,25 +182,32 @@ export const workTaskOperationsOver = (context) => {
|
|
|
182
182
|
const parent = taskIdOf(database, Number(before.parent_id));
|
|
183
183
|
throw new CliError("validation_error", `${String(before.key)} is a subtask of ${parent}; detach it in the same write (parent none) to move it alone`, { reason: "task.is-subtask", parent });
|
|
184
184
|
}
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
185
|
+
const renumber = (row, parent) => {
|
|
186
|
+
const counted = database
|
|
187
|
+
.prepare("UPDATE projects SET tasks_count = tasks_count + 1, updated_at = ? WHERE id = ? RETURNING key, tasks_count")
|
|
188
|
+
.get(at, target);
|
|
189
|
+
const number = Number(counted.tasks_count);
|
|
190
|
+
const key = `${String(counted.key)}-${number}`;
|
|
191
|
+
database
|
|
192
|
+
.prepare("UPDATE tasks SET project_id = ?, number = ?, key = ?, parent_id = ?, updated_at = ? WHERE id = ?")
|
|
193
|
+
.run(target, number, key, parent, at, Number(row.id));
|
|
194
|
+
recordTaskEvent(database, Number(row.id), actor, "project", {
|
|
195
|
+
project: [`project:${row.project_id}`, `project:${target}`],
|
|
196
|
+
key: [String(row.key), key],
|
|
197
|
+
...(row.parent_id === parent ? {} : { parent: [`task:${taskIdOf(database, Number(row.parent_id))}`, null] }),
|
|
198
|
+
}, at);
|
|
199
|
+
return key;
|
|
200
|
+
};
|
|
201
|
+
renumber(before, null);
|
|
202
|
+
const moved = [];
|
|
203
|
+
const childrenOf = database.prepare("SELECT * FROM tasks WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id");
|
|
204
|
+
for (const queue = [task]; queue.length > 0;) {
|
|
205
|
+
for (const child of childrenOf.all(queue.shift())) {
|
|
206
|
+
moved.push({ from: String(child.key), to: renumber(child, child.parent_id) });
|
|
207
|
+
queue.push(Number(child.id));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return moved;
|
|
204
211
|
};
|
|
205
212
|
const edit = (task, fields, actor, created = false) => {
|
|
206
213
|
const before = rowById(task);
|
|
@@ -283,16 +290,18 @@ export const workTaskOperationsOver = (context) => {
|
|
|
283
290
|
change("type", fields.type, "type");
|
|
284
291
|
bump = true;
|
|
285
292
|
}
|
|
286
|
-
|
|
287
|
-
|
|
293
|
+
const moved = fields.project === undefined
|
|
294
|
+
? []
|
|
295
|
+
: moveToProject(task, before, projectOf(fields.project), actor, at, fields.parent === null);
|
|
288
296
|
if (set.length === 0) {
|
|
289
297
|
if (bump)
|
|
290
298
|
database.prepare("UPDATE tasks SET revision = revision + 1 WHERE id = ?").run(task);
|
|
291
|
-
return;
|
|
299
|
+
return moved;
|
|
292
300
|
}
|
|
293
301
|
if (bump)
|
|
294
302
|
set.push("revision = revision + 1");
|
|
295
303
|
database.prepare(`UPDATE tasks SET ${set.join(", ")}, updated_at = ? WHERE id = ?`).run(...values, at, task);
|
|
304
|
+
return moved;
|
|
296
305
|
};
|
|
297
306
|
/** The column's tasks in board order, the moving one left out; no priority reads as 0, the bottom of low. */
|
|
298
307
|
const columnOf = (task, status) => database
|
|
@@ -390,11 +399,11 @@ export const workTaskOperationsOver = (context) => {
|
|
|
390
399
|
},
|
|
391
400
|
edit: async (id, fields, by, check = {}) => {
|
|
392
401
|
const task = required(id);
|
|
393
|
-
write(database, () => {
|
|
402
|
+
const moved = write(database, () => {
|
|
394
403
|
checkRevision(rowById(task), check.revision);
|
|
395
|
-
edit(task, fields, authorActor(context, by).actor);
|
|
404
|
+
return edit(task, fields, authorActor(context, by).actor);
|
|
396
405
|
});
|
|
397
|
-
return details(task);
|
|
406
|
+
return moved.length > 0 ? { ...details(task), movedSubtasks: moved } : details(task);
|
|
398
407
|
},
|
|
399
408
|
/** `edit` inside the caller's write, so a decision that edits a task rolls back with it. */
|
|
400
409
|
editNow: (id, fields, by) => {
|
package/dist/store/store.d.ts
CHANGED
|
@@ -123,6 +123,7 @@ export interface MessageStore {
|
|
|
123
123
|
}
|
|
124
124
|
export type { ChatHistoryShape, StoredHistorySkip } from "./sqlite/history-skips.js";
|
|
125
125
|
export { BACKFILL_ON_OPEN, type StoreOptions } from "./sqlite/open-store.js";
|
|
126
|
+
export type { ChatScope, NewestMessage, ReplyDelay, WaitingChat } from "./sqlite/reply-times.js";
|
|
126
127
|
export { fileFacts, readStoreTables, type TableKind } from "./sqlite/store-tables.js";
|
|
127
128
|
export declare const openStore: (options?: StoreOptions) => Promise<MessageStore>;
|
|
128
129
|
export * from "./exports.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.563.0",
|
|
4
4
|
"description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|
package/parity.json
CHANGED
|
@@ -1127,6 +1127,11 @@
|
|
|
1127
1127
|
"value": "<json>",
|
|
1128
1128
|
"meaning": "the complete ranking component weights; replaces preset weights when --score is given"
|
|
1129
1129
|
},
|
|
1130
|
+
"--who": {
|
|
1131
|
+
"value": "<side>",
|
|
1132
|
+
"default": "me",
|
|
1133
|
+
"meaning": "whose replies are timed: the owner's (me) or the other side's (others)"
|
|
1134
|
+
},
|
|
1130
1135
|
"--with-notes": {
|
|
1131
1136
|
"meaning": "include your private notes, subject to contacts.notes.list permission"
|
|
1132
1137
|
},
|
|
@@ -2766,6 +2771,9 @@
|
|
|
2766
2771
|
"config": {
|
|
2767
2772
|
"in": "all"
|
|
2768
2773
|
},
|
|
2774
|
+
"config migrate": {
|
|
2775
|
+
"in": "all"
|
|
2776
|
+
},
|
|
2769
2777
|
"config repair": {
|
|
2770
2778
|
"in": "all",
|
|
2771
2779
|
"options": {
|
|
@@ -4528,6 +4536,15 @@
|
|
|
4528
4536
|
"--selection": "all"
|
|
4529
4537
|
}
|
|
4530
4538
|
},
|
|
4539
|
+
"stats messages responses": {
|
|
4540
|
+
"in": "all",
|
|
4541
|
+
"options": {
|
|
4542
|
+
"--by": "all",
|
|
4543
|
+
"--source": "all",
|
|
4544
|
+
"--timezone": "all",
|
|
4545
|
+
"--who": "all"
|
|
4546
|
+
}
|
|
4547
|
+
},
|
|
4531
4548
|
"stats messages show": {
|
|
4532
4549
|
"in": "all",
|
|
4533
4550
|
"options": {
|
|
@@ -4606,6 +4623,13 @@
|
|
|
4606
4623
|
"--timezone": "all"
|
|
4607
4624
|
}
|
|
4608
4625
|
},
|
|
4626
|
+
"stats messages waiting": {
|
|
4627
|
+
"in": "all",
|
|
4628
|
+
"options": {
|
|
4629
|
+
"--source": "all",
|
|
4630
|
+
"--timezone": "all"
|
|
4631
|
+
}
|
|
4632
|
+
},
|
|
4609
4633
|
"stats tasks": {
|
|
4610
4634
|
"in": "all"
|
|
4611
4635
|
},
|