@wirecat/cli-core 0.561.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/cli/app-doctor-command.js +3 -1
  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/index.d.ts +2 -1
  7. package/dist/cli/index.js +2 -1
  8. package/dist/cli/messenger/reply-stats-command.d.ts +4 -0
  9. package/dist/cli/messenger/reply-stats-command.js +57 -0
  10. package/dist/cli/messenger/search-command.js +8 -0
  11. package/dist/cli/messenger/stats-answers.d.ts +3 -0
  12. package/dist/cli/messenger/stats-answers.js +18 -0
  13. package/dist/cli/messenger/stats-command.js +5 -0
  14. package/dist/cli/output-budget.d.ts +26 -0
  15. package/dist/cli/output-budget.js +73 -0
  16. package/dist/cli/program.js +14 -1
  17. package/dist/cli/setting-defaults.d.ts +27 -0
  18. package/dist/cli/setting-defaults.js +36 -0
  19. package/dist/cli/settings.d.ts +5 -0
  20. package/dist/cli/settings.js +88 -5
  21. package/dist/cli/shared-command-exports.d.ts +8 -0
  22. package/dist/domain/admin-statistics.d.ts +5 -0
  23. package/dist/domain/admin-statistics.js +4 -3
  24. package/dist/entities/index.d.ts +31 -1
  25. package/dist/entities/index.js +4 -1
  26. package/dist/entities/reply-stats.d.ts +33 -0
  27. package/dist/entities/reply-stats.js +19 -0
  28. package/dist/errors/reasons.d.ts +1 -1
  29. package/dist/errors/reasons.js +2 -3
  30. package/dist/mail/attachments.d.ts +15 -2
  31. package/dist/mail/attachments.js +19 -3
  32. package/dist/mail/cli/index.d.ts +17 -1
  33. package/dist/mail/cli/index.js +1 -1
  34. package/dist/mail/cli/review.d.ts +5 -2
  35. package/dist/mail/cli/review.js +47 -39
  36. package/dist/mail/index.d.ts +2 -0
  37. package/dist/mail/index.js +2 -0
  38. package/dist/mail/partial.d.ts +3 -1
  39. package/dist/mail/partial.js +2 -2
  40. package/dist/mail/retry.d.ts +3 -1
  41. package/dist/mail/retry.js +3 -3
  42. package/dist/mail/sync-types.d.ts +3 -0
  43. package/dist/mail/sync.js +5 -3
  44. package/dist/mail/whole.d.ts +28 -0
  45. package/dist/mail/whole.js +69 -0
  46. package/dist/services/index.d.ts +2 -0
  47. package/dist/services/index.js +2 -0
  48. package/dist/services/proposal-tasks.js +3 -12
  49. package/dist/services/proposals/kinds/action.js +3 -2
  50. package/dist/services/proposals/registry.js +8 -4
  51. package/dist/services/proposals/types.d.ts +2 -0
  52. package/dist/services/reply-stats.d.ts +48 -0
  53. package/dist/services/reply-stats.js +137 -0
  54. package/dist/store/decisions.d.ts +14 -11
  55. package/dist/store/decisions.js +9 -2
  56. package/dist/store/index.d.ts +3 -3
  57. package/dist/store/index.js +2 -2
  58. package/dist/store/queries.d.ts +4 -0
  59. package/dist/store/queries.js +3 -0
  60. package/dist/store/sqlite/email-locations.d.ts +4 -0
  61. package/dist/store/sqlite/email-locations.js +26 -11
  62. package/dist/store/sqlite/lucene-fields.d.ts +2 -0
  63. package/dist/store/sqlite/lucene-fields.js +3 -1
  64. package/dist/store/sqlite/mail-findings.js +1 -1
  65. package/dist/store/sqlite/meeting-findings.js +1 -1
  66. package/dist/store/sqlite/proposed-actions.d.ts +2 -0
  67. package/dist/store/sqlite/proposed-actions.js +24 -17
  68. package/dist/store/sqlite/reply-times.d.ts +57 -0
  69. package/dist/store/sqlite/reply-times.js +113 -0
  70. package/dist/store/sqlite/work-tasks.d.ts +12 -2
  71. package/dist/store/sqlite/work-tasks.js +35 -26
  72. package/dist/store/store.d.ts +1 -0
  73. package/package.json +1 -1
  74. package/parity.json +24 -0
@@ -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
+ };
@@ -21,6 +21,12 @@ export type Decider = "owner" | {
21
21
  model?: string;
22
22
  reason: string;
23
23
  };
24
+ export interface DecidedRow {
25
+ id: string;
26
+ proposedBy: Proposer;
27
+ ownerOnly?: boolean;
28
+ kind?: string;
29
+ }
24
30
  export interface DecisionPolicy {
25
31
  /** The host's `agent_decisions`: off unless the owner sets it to `true`; off keeps every decision with the owner. */
26
32
  agentDecisions: boolean;
@@ -34,17 +40,18 @@ export interface Refusal {
34
40
  reason: string;
35
41
  message: string;
36
42
  }
43
+ /**
44
+ * Stored proposal kinds only the owner decides, whatever `agent_decisions` says. Read from the row's kind, so a path
45
+ * that sees the row as something else — a generic action, a host's own kind — still refuses an agent.
46
+ */
47
+ export declare const isOwnerOnlyKind: (kind: string | undefined) => boolean;
37
48
  /**
38
49
  * The one check every decide path runs before it confirms or rejects a proposal. `null` means allowed. Every Claude
39
50
  * Code session is the bot `claude-code`, so comparing bot names keeps one session from deciding another's proposal.
40
51
  */
41
52
  export declare const mayDecide: (
42
- /** `ownerOnly`: what only the owner may say, such as that a payment was made. */
43
- row: {
44
- id: string;
45
- proposedBy: Proposer;
46
- ownerOnly?: boolean;
47
- }, by: Decider, policy?: DecisionPolicy) => Refusal | null;
53
+ /** `ownerOnly`: what only the owner may say, such as that a payment was made; `kind`: the stored kind. */
54
+ row: DecidedRow, by: Decider, policy?: DecisionPolicy) => Refusal | null;
48
55
  /** One bot whatever its letter case or spacing: `Claude-Code` is `claude-code`. */
49
56
  export declare const botKey: (name: string) => string;
50
57
  /**
@@ -59,11 +66,7 @@ export declare const assertMayWithdraw: (row: {
59
66
  bot: string;
60
67
  }) => void;
61
68
  /** `mayDecide` for a store method: throws the refusal instead of returning it. */
62
- export declare const assertMayDecide: (row: {
63
- id: string;
64
- proposedBy: Proposer;
65
- ownerOnly?: boolean;
66
- }, by: Decider, policy?: DecisionPolicy) => void;
69
+ export declare const assertMayDecide: (row: DecidedRow, by: Decider, policy?: DecisionPolicy) => void;
67
70
  /** The author a decision is recorded under. */
68
71
  export declare const deciderAuthor: (by: Decider) => "owner" | {
69
72
  bot: string;
@@ -1,11 +1,18 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { MEETING_FINDINGS_KIND } from "../meetings/index.js";
3
+ import { MAIL_FINDINGS_KIND } from "./mail-findings.js";
2
4
  export const DEFAULT_DECISION_POLICY = { agentDecisions: false, ruleProposalsOwnerOnly: true };
5
+ /**
6
+ * Stored proposal kinds only the owner decides, whatever `agent_decisions` says. Read from the row's kind, so a path
7
+ * that sees the row as something else — a generic action, a host's own kind — still refuses an agent.
8
+ */
9
+ export const isOwnerOnlyKind = (kind) => kind === MAIL_FINDINGS_KIND || kind === MEETING_FINDINGS_KIND;
3
10
  /**
4
11
  * The one check every decide path runs before it confirms or rejects a proposal. `null` means allowed. Every Claude
5
12
  * Code session is the bot `claude-code`, so comparing bot names keeps one session from deciding another's proposal.
6
13
  */
7
14
  export const mayDecide = (
8
- /** `ownerOnly`: what only the owner may say, such as that a payment was made. */
15
+ /** `ownerOnly`: what only the owner may say, such as that a payment was made; `kind`: the stored kind. */
9
16
  row, by, policy = DEFAULT_DECISION_POLICY) => {
10
17
  if (by === "owner")
11
18
  return null;
@@ -15,7 +22,7 @@ row, by, policy = DEFAULT_DECISION_POLICY) => {
15
22
  reason,
16
23
  message,
17
24
  });
18
- if (row.ownerOnly)
25
+ if (row.ownerOnly || isOwnerOnlyKind(row.kind))
19
26
  return refuse("permission_error", "proposal.owner-only", `only the owner decides ${row.id}`);
20
27
  if (!policy.agentDecisions)
21
28
  return refuse("permission_error", "proposals.agent-decisions-off", "agent decisions are turned off; the owner decides");
@@ -9,7 +9,7 @@ export type { FindingEvidence, FindingOutcome, MeetingFactFinding, MeetingFindin
9
9
  export { PAYMENT_DIRECTIONS, PAYMENT_STATES, type Payment, paymentTitle, validatePayment } from "../meetings/index.js";
10
10
  export { CLOSED_STATES, isClosedState, PRIORITY_LABELS, type PriorityLabel, priorityLabel, priorityOf, priorityOption, TASK_STATES, type TaskState, } from "../tasks/index.js";
11
11
  export type { AccountMail, AccountMeetings, AccountStore } from "./account-store.js";
12
- export { assertMayDecide, DEFAULT_DECISION_POLICY, type Decider, type DecisionPolicy, mayDecide, type Proposer, type Refusal, } from "./decisions.js";
12
+ export { assertMayDecide, DEFAULT_DECISION_POLICY, type Decider, type DecisionPolicy, isOwnerOnlyKind, mayDecide, type Proposer, type Refusal, } from "./decisions.js";
13
13
  export type { DocumentTransferOptions, DocumentTransferPreview, DocumentTransferReceipt, } from "./document-transfer/index.js";
14
14
  export { applyDocumentTransfer, previewDocumentTransfer } from "./document-transfer/index.js";
15
15
  export type { CacheDatabase, CacheStatement, OpenDatabase, SqlValue } from "./driver.js";
@@ -54,7 +54,7 @@ export type { NoteIndexState } from "./sqlite/note-index.js";
54
54
  export { type NearestNote, NOTE_FIELDS, type NoteChunkToEmbed, type NoteHit, type NoteQuery, type NoteSearch, } from "./sqlite/note-search.js";
55
55
  export type { FileNoteInput, Link, LinkInput, LinkListFilter, Note, NoteFolder, NotesStore, NoteTag, } from "./sqlite/notes.js";
56
56
  export type { PrivateContact, PrivateContactNote } from "./sqlite/private-people.js";
57
- export { PROPOSAL_STATUSES, type ProposalInput, type ProposedAction, type ProposedActionsStore, proposerOfAction, } from "./sqlite/proposed-actions.js";
57
+ export { assertGenericAction, PROPOSAL_STATUSES, type ProposalInput, type ProposedAction, type ProposedActionsStore, proposerOfAction, } from "./sqlite/proposed-actions.js";
58
58
  export type { RecordKey, RecordOutcome, RecordStore, RecordsWritten, SourceRecord, } from "./sqlite/records.js";
59
59
  export type { LinkDecision, RelationUpdate } from "./sqlite/relation-proposals.js";
60
60
  export { fillSearchIndex, resetSearchIndex, type SearchIndexFill, type SearchIndexState, searchIndexState, } from "./sqlite/search-index.js";
@@ -72,6 +72,6 @@ export type { TaskRelation, TaskRelationKind, TaskRelationsStore } from "./sqlit
72
72
  export { TASK_RELATION_KINDS } from "./sqlite/task-relations.js";
73
73
  export type { StoreTaskStore } from "./sqlite/tasks.js";
74
74
  export type { TagPreflight, VocabularyAlias, VocabularyAliasReceipt, VocabularyAudit, VocabularyAuditOptions, VocabularyCatalog, VocabularyEntry, VocabularyKind, VocabularyOptions, VocabularyResolution, VocabularyStore, VocabularyTarget, } from "./sqlite/vocabulary.js";
75
- export type { BoardCounts, BoardOptions, NewWorkTask, RevisionCheck, TaskAssignment, TaskDetails, TaskEvent, TaskFields, TaskMove, TaskMoved, TaskRole, TaskType, WorkTaskType, } from "./sqlite/work-tasks.js";
75
+ export type { BoardCounts, BoardOptions, EditedTask, MovedSubtask, NewWorkTask, RevisionCheck, TaskAssignment, TaskDetails, TaskEvent, TaskFields, TaskMove, TaskMoved, TaskRole, TaskType, WorkTaskType, } from "./sqlite/work-tasks.js";
76
76
  export { TASK_EVENT_KINDS, TASK_ROLES } from "./sqlite/work-tasks.js";
77
77
  export { type AccountAccounts, type AccountAttachments, type AccountCounters, type AccountKey, type AccountMentions, type AccountMessageFilter, type AccountMessages, type AccountPeople, type AccountsStore, type AccountVectors, type AttachmentsStore, type AttachmentTextEntry, type AttachmentView, type ChatStats, type ChatsStore, type ConversationsStore, type CountersStore, type DeletionScope, type Delta, type FileAttachment, type IdentityRef, type IngestedVia, type LinkedIdentity, type LinkOptions, MENTION_QUERY, MENTION_STATUSES, type MentionsStore, type MessageFilter, type MessageStore, type MessagesStore, OTHER_SOURCES, openStore, type PeopleStore, type PersonFacts, type PersonRecord, type QueriesStore, type Range, type RankedEvidence, type RankedStoreFound, type RankedStoreRow, type RankingEvidenceItem, type RankingEvidenceRequest, type RankingRequest, type RosterStore, type SearchCommand, type SearchesStore, type SearchIndexStore, type SearchRecord, type StoredAccount, type StoredChatFilter, type StoredHit, type StoredSearch, type StoredTag, type StoreOptions, type SyncStore, type TagFilter, type TagsStore, type TagTarget, type TaskAnalysisMark, type TaskAnalysisMarkWrite, type TaskTagTarget, type TextOrigin, type VectorsStore, } from "./store.js";
@@ -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";
@@ -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;
@@ -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((row) => ({
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
- const kind = messengerOnly(({ value }) => always(bound("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 = ?", value.toLowerCase())));
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), ownerOnly: true }, decision.by, context.decisions);
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).kind;
171
- if (kind === MEETING_FINDINGS_KIND)
172
- throw new CliError("validation_error", "review meeting findings through the dedicated findings command", {
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
- if (input.kind === MEETING_FINDINGS_KIND)
183
- throw new CliError("validation_error", "stage meeting findings through the dedicated findings command");
184
- if (input.kind === EVIDENCE_FINDINGS_KIND)
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[];