@wirecat/cli-core 0.562.0 → 0.564.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/dist/calendar/mcp/index.d.ts +12 -12
  3. package/dist/cli/config-command.js +18 -1
  4. package/dist/cli/envelope.js +13 -1
  5. package/dist/cli/execution.js +1 -0
  6. package/dist/cli/messenger/archive-commands.js +17 -0
  7. package/dist/cli/messenger/catch-up-answers.d.ts +5 -0
  8. package/dist/cli/messenger/catch-up-answers.js +3 -0
  9. package/dist/cli/messenger/reply-stats-command.d.ts +4 -0
  10. package/dist/cli/messenger/reply-stats-command.js +57 -0
  11. package/dist/cli/messenger/search-answers.d.ts +1 -0
  12. package/dist/cli/messenger/search-answers.js +1 -1
  13. package/dist/cli/messenger/search-command.js +8 -0
  14. package/dist/cli/messenger/stats-answers.d.ts +6 -3
  15. package/dist/cli/messenger/stats-answers.js +39 -9
  16. package/dist/cli/messenger/stats-command.js +5 -0
  17. package/dist/cli/messenger/tasks-command.js +35 -14
  18. package/dist/cli/output-budget.d.ts +26 -0
  19. package/dist/cli/output-budget.js +73 -0
  20. package/dist/cli/program.js +14 -1
  21. package/dist/cli/settings.d.ts +5 -0
  22. package/dist/cli/settings.js +88 -5
  23. package/dist/cli/shared-command-exports.d.ts +8 -0
  24. package/dist/conversations/mail-text.d.ts +6 -0
  25. package/dist/conversations/mail-text.js +73 -0
  26. package/dist/domain/admin-statistics.d.ts +5 -0
  27. package/dist/domain/admin-statistics.js +4 -3
  28. package/dist/domain/calendar.d.ts +2 -0
  29. package/dist/domain/calendar.js +6 -0
  30. package/dist/entities/chat-stats.d.ts +16 -2
  31. package/dist/entities/chat-stats.js +18 -3
  32. package/dist/entities/index.d.ts +80 -802
  33. package/dist/entities/index.js +26 -16
  34. package/dist/entities/mail-counts.d.ts +15 -0
  35. package/dist/entities/mail-counts.js +16 -0
  36. package/dist/entities/meeting.d.ts +1 -1
  37. package/dist/entities/meeting.js +1 -1
  38. package/dist/entities/reply-stats.d.ts +33 -0
  39. package/dist/entities/reply-stats.js +19 -0
  40. package/dist/entities/store-records.d.ts +8 -0
  41. package/dist/entities/store-records.js +8 -0
  42. package/dist/errors/reasons.d.ts +1 -1
  43. package/dist/errors/reasons.js +7 -3
  44. package/dist/mail/attachments.d.ts +15 -2
  45. package/dist/mail/attachments.js +19 -3
  46. package/dist/mail/cli/index.d.ts +22 -1
  47. package/dist/mail/cli/index.js +28 -4
  48. package/dist/mail/cli/review.d.ts +5 -2
  49. package/dist/mail/cli/review.js +58 -43
  50. package/dist/mail/index.d.ts +4 -0
  51. package/dist/mail/index.js +4 -0
  52. package/dist/mail/partial.d.ts +3 -1
  53. package/dist/mail/partial.js +2 -2
  54. package/dist/mail/retry.d.ts +3 -1
  55. package/dist/mail/retry.js +3 -3
  56. package/dist/mail/stats.d.ts +50 -0
  57. package/dist/mail/stats.js +60 -0
  58. package/dist/mail/sync-types.d.ts +3 -0
  59. package/dist/mail/sync.js +5 -3
  60. package/dist/mail/whole.d.ts +28 -0
  61. package/dist/mail/whole.js +70 -0
  62. package/dist/meetings/index.d.ts +2 -2
  63. package/dist/meetings/index.js +2 -2
  64. package/dist/meetings/mcp/index.d.ts +15 -15
  65. package/dist/meetings/read-answers.d.ts +22 -1
  66. package/dist/meetings/read-answers.js +38 -0
  67. package/dist/meetings/stats.d.ts +44 -0
  68. package/dist/meetings/stats.js +77 -7
  69. package/dist/search/lucene/registry.js +2 -1
  70. package/dist/search/question-plan.d.ts +16 -0
  71. package/dist/search/question-plan.js +34 -0
  72. package/dist/services/events-search.d.ts +5 -1
  73. package/dist/services/events-search.js +10 -4
  74. package/dist/services/index.d.ts +3 -1
  75. package/dist/services/index.js +3 -1
  76. package/dist/services/meeting-stats.d.ts +12 -1
  77. package/dist/services/meeting-stats.js +12 -0
  78. package/dist/services/messages-discovery.d.ts +1 -5
  79. package/dist/services/messages-discovery.js +5 -18
  80. package/dist/services/messages-meaning.js +25 -3
  81. package/dist/services/messages.js +4 -3
  82. package/dist/services/notes-search.d.ts +5 -1
  83. package/dist/services/notes-search.js +16 -5
  84. package/dist/services/proposal-tasks.js +3 -12
  85. package/dist/services/proposals/ids.d.ts +2 -2
  86. package/dist/services/proposals/ids.js +5 -3
  87. package/dist/services/proposals/kinds/action.js +3 -2
  88. package/dist/services/proposals/kinds/findings.d.ts +1 -1
  89. package/dist/services/proposals/kinds/findings.js +1 -1
  90. package/dist/services/proposals/kinds/meeting-findings.d.ts +7 -0
  91. package/dist/services/proposals/kinds/meeting-findings.js +73 -0
  92. package/dist/services/proposals/kinds/task-close.d.ts +7 -0
  93. package/dist/services/proposals/kinds/task-close.js +76 -0
  94. package/dist/services/proposals/registry.js +12 -4
  95. package/dist/services/proposals/types.d.ts +2 -0
  96. package/dist/services/reply-stats.d.ts +48 -0
  97. package/dist/services/reply-stats.js +137 -0
  98. package/dist/services/search-all.d.ts +5 -0
  99. package/dist/services/search-all.js +47 -14
  100. package/dist/services/task-stats.d.ts +41 -0
  101. package/dist/services/task-stats.js +126 -0
  102. package/dist/services/tasks.d.ts +17 -4
  103. package/dist/services/tasks.js +31 -14
  104. package/dist/store/chats.d.ts +3 -1
  105. package/dist/store/chats.js +6 -0
  106. package/dist/store/decisions.d.ts +16 -11
  107. package/dist/store/decisions.js +11 -2
  108. package/dist/store/exports.d.ts +1 -1
  109. package/dist/store/exports.js +1 -1
  110. package/dist/store/index.d.ts +5 -4
  111. package/dist/store/index.js +3 -3
  112. package/dist/store/queries.d.ts +4 -0
  113. package/dist/store/queries.js +3 -0
  114. package/dist/store/sqlite/completeness.d.ts +17 -0
  115. package/dist/store/sqlite/completeness.js +49 -13
  116. package/dist/store/sqlite/email-locations.d.ts +4 -0
  117. package/dist/store/sqlite/email-locations.js +26 -11
  118. package/dist/store/sqlite/lucene-fields.d.ts +2 -0
  119. package/dist/store/sqlite/lucene-fields.js +5 -1
  120. package/dist/store/sqlite/lucene-groups.d.ts +15 -0
  121. package/dist/store/sqlite/lucene-groups.js +21 -0
  122. package/dist/store/sqlite/lucene.d.ts +2 -9
  123. package/dist/store/sqlite/lucene.js +1 -12
  124. package/dist/store/sqlite/mail-findings.js +1 -1
  125. package/dist/store/sqlite/meeting-findings.js +1 -1
  126. package/dist/store/sqlite/meeting-stats.d.ts +2 -0
  127. package/dist/store/sqlite/meeting-stats.js +27 -1
  128. package/dist/store/sqlite/proposed-actions.d.ts +2 -0
  129. package/dist/store/sqlite/proposed-actions.js +24 -17
  130. package/dist/store/sqlite/reply-times.d.ts +57 -0
  131. package/dist/store/sqlite/reply-times.js +113 -0
  132. package/dist/store/sqlite/retention.js +2 -7
  133. package/dist/store/sqlite/task-board.js +2 -0
  134. package/dist/store/sqlite/task-stats.d.ts +32 -0
  135. package/dist/store/sqlite/task-stats.js +45 -0
  136. package/dist/store/sqlite/tasks.d.ts +2 -1
  137. package/dist/store/sqlite/tasks.js +2 -0
  138. package/dist/store/sqlite/work-tasks.d.ts +12 -2
  139. package/dist/store/sqlite/work-tasks.js +35 -26
  140. package/dist/store/store.d.ts +1 -0
  141. package/package.json +1 -1
  142. package/parity.json +37 -1
@@ -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[];
@@ -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
+ };
@@ -1,14 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { CliError } from "@wirecat/cli-base";
3
- import { calendarKey } from "../../domain/calendar.js";
3
+ import { calendarKey, weekKey } from "../../domain/calendar.js";
4
4
  import { RETENTION_TOLERANCE, retentionCohorts, validateRetentionOptions, } from "../../domain/retention.js";
5
5
  import { chatCompleteness } from "./completeness.js";
6
6
  const iso = (at) => new Date(at).toISOString();
7
- const week = (day) => {
8
- const date = new Date(`${day}T00:00:00Z`);
9
- date.setUTCDate(date.getUTCDate() - ((date.getUTCDay() + 6) % 7));
10
- return date.toISOString().slice(0, 10);
11
- };
12
7
  export const retentionQuery = (context, accountPk, chatPk, chatId, options, evidence) => {
13
8
  validateRetentionOptions(options);
14
9
  if (options.cutoff > context.now())
@@ -33,7 +28,7 @@ export const retentionQuery = (context, accountPk, chatPk, chatId, options, evid
33
28
  const activityStatement = database.prepare(`SELECT min(sent_at) AS at FROM messages WHERE chat_id=? AND sender_identity_id=? AND deleted_at IS NULL AND sent_at >= ? AND sent_at < ?`);
34
29
  const stays = raw.map((row) => {
35
30
  const joinedAt = Number(row.joined_at), pk = Number(row.pk), end = joinedAt + options.within;
36
- const day = dayOf(joinedAt), cohort = options.by === "week" ? week(day) : day;
31
+ const day = dayOf(joinedAt), cohort = options.by === "week" ? weekKey(day) : day;
37
32
  const absence = departureStatement.get(chatPk, Math.max(joinedAt, Number(row.first_seen_at)), options.cutoff, pk);
38
33
  const upper = absence ? Number(absence.observed_at) : null;
39
34
  const positive = lastPositiveStatement.get(pk, upper ?? options.cutoff + 1);
@@ -37,6 +37,7 @@ export const PROJECT_TASK_FIELDS = [
37
37
  relation: "the due date as a board groups it: overdue (open work due before today), today, upcoming (the next 7 days " +
38
38
  "after today), later, or none",
39
39
  }),
40
+ field("created", "time", "created:>=30d", { relation: "when it was opened" }),
40
41
  field("closed", "time", "closed:>=14d", { relation: "when it was done or dismissed" }),
41
42
  field("ready", "bool", "ready:true", { relation: "open work whose blocked-by tasks are all done" }),
42
43
  field("text", "txt", "text:invoice", { relation: "words of the title and description; a bare word means this" }),
@@ -183,6 +184,7 @@ export const projectTaskColumns = (now, zone) => ({
183
184
  agent: { where: agentWhere },
184
185
  parent: { where: parentWhere },
185
186
  due: { type: "time", sql: "t.due_at" },
187
+ created: { type: "time", sql: "t.created_at" },
186
188
  closed: { type: "time", sql: "t.closed_at" },
187
189
  overdue: { where: (node) => truth(node, dueGroups(now, zone).overdue) },
188
190
  when: {
@@ -0,0 +1,32 @@
1
+ import type { StoreContext } from "./open.js";
2
+ import type { Where } from "./query-filter.js";
3
+ /** One account's tasks (its inbox), or the owner's project tasks across every project. */
4
+ export type TaskStatScope = {
5
+ account: string;
6
+ } | {
7
+ board: true;
8
+ };
9
+ export interface TaskStatRow {
10
+ group: string | null;
11
+ sourceKind: string | null;
12
+ projectId: number;
13
+ state: string;
14
+ createdAt: number;
15
+ closedAt: number | null;
16
+ }
17
+ export type TaskStatNames = "chat" | "meeting" | "project";
18
+ export interface TaskStats {
19
+ /** One narrow row per live task in the scope that `where` keeps, oldest first. */
20
+ statRows(scope: TaskStatScope, where: Where | undefined): Promise<TaskStatRow[]>;
21
+ /** Titles by id, read once: chats by external id within `account`, meetings and projects by their store id. */
22
+ statNames(kind: TaskStatNames, ids: readonly string[], account?: string): Promise<Map<string, string>>;
23
+ }
24
+ /** Every statement `stats tasks show` runs, for the query-plan test. */
25
+ export declare const TASK_STATS_SQL: {
26
+ readonly rows: (board: boolean, filter: string) => string;
27
+ readonly chat: string;
28
+ readonly meeting: "SELECT CAST(id AS TEXT) AS id, title AS name FROM meetings WHERE id IN (SELECT value FROM json_each(?))";
29
+ readonly project: "SELECT CAST(id AS TEXT) AS id, name FROM projects WHERE id IN (SELECT value FROM json_each(?))";
30
+ };
31
+ /** `inbox` finds an account's inbox project without making one. */
32
+ export declare const taskStatsOver: ({ database }: StoreContext, inbox: (account: string) => number | undefined) => TaskStats;
@@ -0,0 +1,45 @@
1
+ import { BOARD_SCOPE } from "./task-board.js";
2
+ const ACCOUNT_SCOPE = "t.package_id IS NOT NULL AND t.deleted_at IS NULL AND t.project_id = ?";
3
+ /** Every statement `stats tasks show` runs, for the query-plan test. */
4
+ export const TASK_STATS_SQL = {
5
+ rows: (board, filter) => "SELECT t.source_group, t.source_kind, t.project_id, t.status, t.created_at, t.closed_at FROM tasks t " +
6
+ `WHERE ${board ? BOARD_SCOPE : ACCOUNT_SCOPE}${filter} ORDER BY t.created_at, t.id`,
7
+ chat: "SELECT c.external_id AS id, c.title AS name FROM chats c JOIN accounts a ON a.id = c.account_id " +
8
+ "WHERE a.provider = ? AND a.external_id = ? AND c.external_id IN (SELECT value FROM json_each(?))",
9
+ meeting: "SELECT CAST(id AS TEXT) AS id, title AS name FROM meetings WHERE id IN (SELECT value FROM json_each(?))",
10
+ project: "SELECT CAST(id AS TEXT) AS id, name FROM projects WHERE id IN (SELECT value FROM json_each(?))",
11
+ };
12
+ /** `inbox` finds an account's inbox project without making one. */
13
+ export const taskStatsOver = ({ database }, inbox) => ({
14
+ statRows: async (scope, where) => {
15
+ const filter = where === undefined ? "" : ` AND (${where.sql})`;
16
+ const board = !("account" in scope);
17
+ const project = board ? undefined : inbox(scope.account);
18
+ if (!board && project === undefined)
19
+ return [];
20
+ return database
21
+ .prepare(TASK_STATS_SQL.rows(board, filter))
22
+ .all(...(project === undefined ? [] : [project]), ...(where?.params ?? []))
23
+ .map((row) => ({
24
+ group: row.source_group == null ? null : String(row.source_group),
25
+ sourceKind: row.source_kind == null ? null : String(row.source_kind),
26
+ projectId: Number(row.project_id),
27
+ state: String(row.status),
28
+ createdAt: Number(row.created_at),
29
+ closedAt: row.closed_at == null ? null : Number(row.closed_at),
30
+ }));
31
+ },
32
+ statNames: async (kind, ids, account) => {
33
+ if (ids.length === 0)
34
+ return new Map();
35
+ let params = [JSON.stringify(ids.map(Number))];
36
+ if (kind === "chat") {
37
+ if (account === undefined)
38
+ return new Map();
39
+ const at = account.indexOf(":");
40
+ params = [account.slice(0, at), account.slice(at + 1), JSON.stringify(ids)];
41
+ }
42
+ const rows = database.prepare(TASK_STATS_SQL[kind]).all(...params);
43
+ return new Map(rows.filter((row) => row.name != null).map((row) => [String(row.id), String(row.name)]));
44
+ },
45
+ });
@@ -6,11 +6,12 @@ import { type TaskSimilarity } from "./task-candidates.js";
6
6
  import { type CloseSuggestion } from "./task-close.js";
7
7
  import { type TaskCloseStore } from "./task-close-store.js";
8
8
  import { type TaskPairs } from "./task-pairs.js";
9
+ import { type TaskStats } from "./task-stats.js";
9
10
  import { type WorkTasks } from "./work-tasks.js";
10
11
  export { inboxKey, linkInbox, taskAccountOf } from "./task-keys.js";
11
12
  export { taskIdOf, taskRowOf } from "./things.js";
12
13
  /** `TaskStore` for `src/tasks/`, plus a way from its task ids to the store's rows. */
13
- export interface StoreTaskStore extends Omit<Required<TaskStore>, "reviewDuplicates">, WorkTasks, TaskSimilarity, TaskPairs, TaskCloseStore {
14
+ export interface StoreTaskStore extends Omit<Required<TaskStore>, "reviewDuplicates">, WorkTasks, TaskSimilarity, TaskPairs, TaskStats, TaskCloseStore {
14
15
  /** The default list (or one an older build saved here) with the shared config file's `task_categories` edits. */
15
16
  categories(): Promise<readonly TaskCategory[]>;
16
17
  /** The shared config file whose `task_categories` edits the list. */
@@ -11,6 +11,7 @@ import { CLEAR_SUGGESTION, closeSuggestionOf, SUGGESTION_COLUMNS } from "./task-
11
11
  import { taskCloseOver } from "./task-close-store.js";
12
12
  import { inboxKey, typedKey } from "./task-keys.js";
13
13
  import { taskPairsOver } from "./task-pairs.js";
14
+ import { taskStatsOver } from "./task-stats.js";
14
15
  import { taskRowOf, thingOf } from "./things.js";
15
16
  import { read, write } from "./transaction.js";
16
17
  import { recordTaskEvent, workTasksOver } from "./work-tasks.js";
@@ -91,6 +92,7 @@ export const taskStoreOver = (context) => {
91
92
  ...workTasksOver(context),
92
93
  ...similarity,
93
94
  ...taskPairsOver(context, similarity),
95
+ ...taskStatsOver(context, (account) => inbox(account, false)),
94
96
  ...taskCloseOver(context),
95
97
  rowOf: (id) => taskRowOf(database, id),
96
98
  categories: async () => listOf(),
@@ -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 the same edit.
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<TaskDetails>;
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 children = database
186
- .prepare("SELECT key FROM tasks WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id")
187
- .all(task)
188
- .map((row) => String(row.key));
189
- if (children.length)
190
- throw new CliError("validation_error", `${String(before.key)} has subtasks (${children.join(", ")}); move or detach them first`, { reason: "task.has-subtasks", subtasks: children });
191
- const counted = database
192
- .prepare("UPDATE projects SET tasks_count = tasks_count + 1, updated_at = ? WHERE id = ? RETURNING key, tasks_count")
193
- .get(at, target);
194
- const number = Number(counted.tasks_count);
195
- const key = `${String(counted.key)}-${number}`;
196
- database
197
- .prepare("UPDATE tasks SET project_id = ?, number = ?, key = ?, parent_id = NULL, updated_at = ? WHERE id = ?")
198
- .run(target, number, key, at, task);
199
- recordTaskEvent(database, task, actor, "project", {
200
- project: [`project:${before.project_id}`, `project:${target}`],
201
- key: [String(before.key), key],
202
- ...(before.parent_id == null ? {} : { parent: [`task:${taskIdOf(database, Number(before.parent_id))}`, null] }),
203
- }, at);
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
- if (fields.project !== undefined)
287
- moveToProject(task, before, projectOf(fields.project), actor, at, fields.parent === null);
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) => {
@@ -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.562.0",
3
+ "version": "0.564.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
@@ -160,7 +160,7 @@
160
160
  "--by": {
161
161
  "value": "<grouping>",
162
162
  "meaning": "what to count by",
163
- "note": "each command names its own groupings — stats messages show chat, sender, day or hour, and searches create the same for stats messages show --saved; stats chats show day or week, as a series beside its totals — so it differs on purpose (Help text rule 4)"
163
+ "note": "each command names its own groupings — stats messages show chat, sender, day or hour, and searches create the same for stats messages show --saved; stats chats show day or week, as a series beside its totals; stats tasks show chat, meeting or project — so it differs on purpose (Help text rule 4)"
164
164
  },
165
165
  "--calls": {
166
166
  "value": "<who>",
@@ -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,15 +4623,28 @@
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
  },
4612
4636
  "stats tasks show": {
4613
4637
  "in": "all",
4614
4638
  "options": {
4639
+ "--by": {
4640
+ "in": "all"
4641
+ },
4615
4642
  "--chat": {
4616
4643
  "in": "all"
4617
4644
  },
4645
+ "--timezone": {
4646
+ "in": "all"
4647
+ },
4618
4648
  "--type": {
4619
4649
  "in": "all"
4620
4650
  }
@@ -4652,6 +4682,12 @@
4652
4682
  "--encrypt": "all"
4653
4683
  }
4654
4684
  },
4685
+ "store chats": {
4686
+ "in": "all"
4687
+ },
4688
+ "store chats status": {
4689
+ "in": "all"
4690
+ },
4655
4691
  "store check": {
4656
4692
  "in": "all"
4657
4693
  },