@wirecat/cli-core 0.563.0 → 0.564.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/calendar/mcp/index.d.ts +12 -12
  3. package/dist/cli/messenger/archive-commands.js +17 -0
  4. package/dist/cli/messenger/catch-up-answers.d.ts +5 -0
  5. package/dist/cli/messenger/catch-up-answers.js +3 -0
  6. package/dist/cli/messenger/search-answers.d.ts +1 -0
  7. package/dist/cli/messenger/search-answers.js +1 -1
  8. package/dist/cli/messenger/stats-answers.d.ts +3 -3
  9. package/dist/cli/messenger/stats-answers.js +21 -9
  10. package/dist/cli/messenger/tasks-command.js +35 -14
  11. package/dist/conversations/mail-text.d.ts +6 -0
  12. package/dist/conversations/mail-text.js +73 -0
  13. package/dist/domain/calendar.d.ts +2 -0
  14. package/dist/domain/calendar.js +6 -0
  15. package/dist/entities/chat-stats.d.ts +16 -2
  16. package/dist/entities/chat-stats.js +18 -3
  17. package/dist/entities/index.d.ts +50 -802
  18. package/dist/entities/index.js +23 -16
  19. package/dist/entities/mail-counts.d.ts +15 -0
  20. package/dist/entities/mail-counts.js +16 -0
  21. package/dist/entities/meeting.d.ts +1 -1
  22. package/dist/entities/meeting.js +1 -1
  23. package/dist/entities/store-records.d.ts +8 -0
  24. package/dist/entities/store-records.js +8 -0
  25. package/dist/errors/reasons.js +5 -0
  26. package/dist/mail/cli/index.d.ts +5 -0
  27. package/dist/mail/cli/index.js +27 -3
  28. package/dist/mail/cli/review.js +11 -4
  29. package/dist/mail/index.d.ts +2 -0
  30. package/dist/mail/index.js +2 -0
  31. package/dist/mail/stats.d.ts +50 -0
  32. package/dist/mail/stats.js +60 -0
  33. package/dist/mail/whole.d.ts +4 -4
  34. package/dist/mail/whole.js +4 -3
  35. package/dist/meetings/index.d.ts +2 -2
  36. package/dist/meetings/index.js +2 -2
  37. package/dist/meetings/mcp/index.d.ts +15 -15
  38. package/dist/meetings/read-answers.d.ts +22 -1
  39. package/dist/meetings/read-answers.js +38 -0
  40. package/dist/meetings/stats.d.ts +44 -0
  41. package/dist/meetings/stats.js +77 -7
  42. package/dist/search/lucene/registry.js +2 -1
  43. package/dist/search/question-plan.d.ts +16 -0
  44. package/dist/search/question-plan.js +34 -0
  45. package/dist/services/events-search.d.ts +5 -1
  46. package/dist/services/events-search.js +10 -4
  47. package/dist/services/index.d.ts +1 -1
  48. package/dist/services/index.js +1 -1
  49. package/dist/services/meeting-stats.d.ts +12 -1
  50. package/dist/services/meeting-stats.js +12 -0
  51. package/dist/services/messages-discovery.d.ts +1 -5
  52. package/dist/services/messages-discovery.js +5 -18
  53. package/dist/services/messages-meaning.js +25 -3
  54. package/dist/services/messages.js +4 -3
  55. package/dist/services/notes-search.d.ts +5 -1
  56. package/dist/services/notes-search.js +16 -5
  57. package/dist/services/proposals/ids.d.ts +2 -2
  58. package/dist/services/proposals/ids.js +5 -3
  59. package/dist/services/proposals/kinds/findings.d.ts +1 -1
  60. package/dist/services/proposals/kinds/findings.js +1 -1
  61. package/dist/services/proposals/kinds/meeting-findings.d.ts +7 -0
  62. package/dist/services/proposals/kinds/meeting-findings.js +73 -0
  63. package/dist/services/proposals/kinds/task-close.d.ts +7 -0
  64. package/dist/services/proposals/kinds/task-close.js +76 -0
  65. package/dist/services/proposals/registry.js +4 -0
  66. package/dist/services/search-all.d.ts +5 -0
  67. package/dist/services/search-all.js +47 -14
  68. package/dist/services/task-stats.d.ts +41 -0
  69. package/dist/services/task-stats.js +126 -0
  70. package/dist/services/tasks.d.ts +17 -4
  71. package/dist/services/tasks.js +31 -14
  72. package/dist/store/chats.d.ts +3 -1
  73. package/dist/store/chats.js +6 -0
  74. package/dist/store/decisions.d.ts +4 -2
  75. package/dist/store/decisions.js +5 -3
  76. package/dist/store/exports.d.ts +1 -1
  77. package/dist/store/exports.js +1 -1
  78. package/dist/store/index.d.ts +2 -1
  79. package/dist/store/index.js +1 -1
  80. package/dist/store/sqlite/completeness.d.ts +17 -0
  81. package/dist/store/sqlite/completeness.js +49 -13
  82. package/dist/store/sqlite/lucene-fields.js +2 -0
  83. package/dist/store/sqlite/lucene-groups.d.ts +15 -0
  84. package/dist/store/sqlite/lucene-groups.js +21 -0
  85. package/dist/store/sqlite/lucene.d.ts +2 -9
  86. package/dist/store/sqlite/lucene.js +1 -12
  87. package/dist/store/sqlite/meeting-stats.d.ts +2 -0
  88. package/dist/store/sqlite/meeting-stats.js +27 -1
  89. package/dist/store/sqlite/retention.js +2 -7
  90. package/dist/store/sqlite/task-board.js +2 -0
  91. package/dist/store/sqlite/task-stats.d.ts +32 -0
  92. package/dist/store/sqlite/task-stats.js +45 -0
  93. package/dist/store/sqlite/tasks.d.ts +2 -1
  94. package/dist/store/sqlite/tasks.js +2 -0
  95. package/package.json +1 -1
  96. package/parity.json +13 -1
@@ -24,18 +24,54 @@ export const chatCompleteness = ({ database }, accountKey, chatIds) => {
24
24
  FROM chats c WHERE c.account_id = ? AND c.external_id IN (SELECT value FROM json_each(?))`)
25
25
  .all(accountKey, JSON.stringify(chatIds))
26
26
  .map((row) => {
27
- const stretches = Number(row.stretches);
28
- const reachesStart = Number(row.start) === 1;
29
- const upToDate = row.newest === null ? null : row.held !== null && Number(row.held) >= Number(row.newest);
30
- const gaps = stretches > 1;
31
- const fetched = stretches > 0 || reachesStart;
32
- return {
33
- chatId: String(row.chat),
34
- state: !fetched ? "unknown" : upToDate !== false && !gaps && reachesStart ? "complete" : "partial",
35
- upToDate,
36
- gaps,
37
- reachesStart,
38
- fetchedAt: toIso(row.fetched),
39
- };
27
+ const facts = factsOf(row);
28
+ return { chatId: String(row.chat), ...facts, fetchedAt: toIso(row.fetched) };
40
29
  });
41
30
  };
31
+ const factsOf = (row) => {
32
+ const stretches = Number(row.stretches);
33
+ const reachesStart = Number(row.start) === 1;
34
+ const upToDate = row.newest === null ? null : row.held !== null && Number(row.held) >= Number(row.newest);
35
+ const gaps = stretches > 1;
36
+ const fetched = stretches > 0 || reachesStart;
37
+ const state = !fetched
38
+ ? "unknown"
39
+ : upToDate !== false && !gaps && reachesStart
40
+ ? "complete"
41
+ : "partial";
42
+ return { state, upToDate, gaps, reachesStart };
43
+ };
44
+ export const COVERAGE_SQL = `SELECT c.last_message_at AS newest, c.message_count AS messages,
45
+ (SELECT m.sent_at FROM messages m WHERE m.chat_id = c.id AND m.deleted_at IS NULL
46
+ ORDER BY m.sent_at DESC LIMIT 1) AS held,
47
+ (SELECT m.sent_at FROM messages m WHERE m.chat_id = c.id AND m.deleted_at IS NULL
48
+ ORDER BY m.sent_at ASC LIMIT 1) AS oldest,
49
+ (SELECT count(*) FROM sync_ranges r WHERE r.chat_id = c.id) AS stretches,
50
+ EXISTS (SELECT 1 FROM sync_cursors s WHERE s.account_id = c.account_id AND s.key = 'history_start:' || c.external_id
51
+ AND NOT EXISTS (SELECT 1 FROM sync_ranges r WHERE r.chat_id = c.id AND r.from_key < CAST(s.value AS INTEGER)))
52
+ AS start,
53
+ EXISTS (SELECT 1 FROM sync_cursors s WHERE s.account_id = c.account_id
54
+ AND s.key IN ('history_start:' || c.external_id, 'fetched:' || c.external_id)) AS recorded
55
+ FROM chats c WHERE c.account_id = ? AND c.searchable = 1`;
56
+ /**
57
+ * `chatCompleteness` over every searchable chat of the account in one statement. A chat whose history was pushed
58
+ * into the store (WhatsApp) has messages and no fetch record, so it counts as partial, not never. The newest and
59
+ * oldest held are read with ORDER BY … LIMIT 1, so each stops at the first live row of `messages_by_time` rather than
60
+ * relying on the planner's min/max shortcut, which the deleted_at filter may defeat.
61
+ */
62
+ export const chatCoverage = ({ database }, accountKey) => {
63
+ const rows = database.prepare(COVERAGE_SQL).all(accountKey);
64
+ const coverage = { chats: rows.length, fetched: 0, partial: 0, never: 0, oldestMessageAt: null };
65
+ let oldest = null;
66
+ for (const row of rows) {
67
+ if (row.oldest !== null && (oldest === null || Number(row.oldest) < oldest))
68
+ oldest = Number(row.oldest);
69
+ if (factsOf(row).state === "complete")
70
+ coverage.fetched += 1;
71
+ else if (Number(row.stretches) === 0 && Number(row.recorded) === 0 && Number(row.messages) === 0)
72
+ coverage.never += 1;
73
+ else
74
+ coverage.partial += 1;
75
+ }
76
+ return { ...coverage, oldestMessageAt: toIso(oldest) };
77
+ };
@@ -349,6 +349,8 @@ export const PEER_KIND = "CASE WHEN json_extract(c.metadata,'$.peerKind') IN ('p
349
349
  const kind = messengerOnly(({ value }) => always(bound(`${PEER_KIND} = ?`, value.toLowerCase())));
350
350
  const has = ({ value }, { corpus, mail }) => {
351
351
  const wanted = value.toLowerCase();
352
+ if (wanted === "transcript")
353
+ return always(bound(mail ? "0" : "EXISTS (SELECT 1 FROM message_transcripts t WHERE t.message_id = m.id)"));
352
354
  return always(wanted === "text"
353
355
  ? bound("length(trim(coalesce(m.text,''))) > 0")
354
356
  : wanted === "link"
@@ -0,0 +1,15 @@
1
+ /** `_direction`: the same groups, each split by `outgoing`, for counts of received and sent mail. */
2
+ export type QueryGrouping = "chat" | "sender" | "time" | "sender_direction" | "time_direction";
3
+ export interface QueryGroup {
4
+ provider?: string;
5
+ account?: string;
6
+ id: string | null;
7
+ name: string | null;
8
+ outgoing?: boolean;
9
+ count: number;
10
+ }
11
+ /** How `countQuery` groups the matches: the GROUP BY key and the columns each group reads. */
12
+ export declare const GROUPS: Record<QueryGrouping, {
13
+ key: string;
14
+ select: string;
15
+ }>;
@@ -0,0 +1,21 @@
1
+ /** How `countQuery` groups the matches: the GROUP BY key and the columns each group reads. */
2
+ export const GROUPS = {
3
+ chat: {
4
+ key: "m.chat_id",
5
+ select: "ac.provider AS provider, ac.external_id AS account, c.external_id AS id, c.title AS name",
6
+ },
7
+ sender: {
8
+ key: "coalesce(m.sender_identity_id, -m.account_id)",
9
+ select: "ac.provider AS provider, ac.external_id AS account, i.external_id AS id, i.name AS name, max(m.outgoing) AS outgoing",
10
+ },
11
+ // Quarter hours, so a calendar day or hour in any zone (+05:30, +05:45) is a whole number of buckets.
12
+ time: { key: "m.sent_at / 900000", select: "CAST(min(m.sent_at / 900000) * 900000 AS TEXT) AS id, NULL AS name" },
13
+ sender_direction: {
14
+ key: "coalesce(m.sender_identity_id, -m.account_id), coalesce(m.outgoing, 0)",
15
+ select: "ac.provider AS provider, ac.external_id AS account, i.external_id AS id, i.name AS name, coalesce(m.outgoing, 0) AS outgoing",
16
+ },
17
+ time_direction: {
18
+ key: "m.sent_at / 900000, coalesce(m.outgoing, 0)",
19
+ select: "CAST(min(m.sent_at / 900000) * 900000 AS TEXT) AS id, NULL AS name, coalesce(m.outgoing, 0) AS outgoing",
20
+ },
21
+ };
@@ -1,18 +1,11 @@
1
1
  import type { Page } from "../../domain/models.js";
2
2
  import type { QueryExecution } from "../../search/lucene/resolved.js";
3
3
  import { type SqlValue } from "../driver.js";
4
+ import { type QueryGroup, type QueryGrouping } from "./lucene-groups.js";
4
5
  import type { StoreContext } from "./open.js";
5
6
  import type { ScoredHit } from "./words.js";
6
7
  export { type Corpus, prefixOf } from "./lucene-fields.js";
7
- export type QueryGrouping = "chat" | "sender" | "time";
8
- export interface QueryGroup {
9
- provider?: string;
10
- account?: string;
11
- id: string | null;
12
- name: string | null;
13
- outgoing?: boolean;
14
- count: number;
15
- }
8
+ export type { QueryGroup, QueryGrouping } from "./lucene-groups.js";
16
9
  export declare const matchQuery: (context: StoreContext, execution: QueryExecution) => Promise<Page<ScoredHit>>;
17
10
  /** Every match counted once, grouped; the work and candidate budgets of a search still apply. */
18
11
  export declare const countQuery: (context: StoreContext, execution: QueryExecution, by: QueryGrouping) => Promise<QueryGroup[]>;
@@ -6,6 +6,7 @@ import { TIME_LIMIT_FUNCTION, TimeLimitReached } from "../driver.js";
6
6
  import { emailHitsByPk } from "./emails.js";
7
7
  import { placeHits } from "./hit-places.js";
8
8
  import { combine, FIELD_COMPILERS, ftsMatch, SPEECH, } from "./lucene-fields.js";
9
+ import { GROUPS } from "./lucene-groups.js";
9
10
  import { hitsByPk } from "./search.js";
10
11
  import { read } from "./transaction.js";
11
12
  import { transcriptItems, transcriptTerms } from "./transcript-query.js";
@@ -32,18 +33,6 @@ const MAIL = {
32
33
  item: "email",
33
34
  thread: "email_thread",
34
35
  };
35
- const GROUPS = {
36
- chat: {
37
- key: "m.chat_id",
38
- select: "ac.provider AS provider, ac.external_id AS account, c.external_id AS id, c.title AS name",
39
- },
40
- sender: {
41
- key: "coalesce(m.sender_identity_id, -m.account_id)",
42
- select: "ac.provider AS provider, ac.external_id AS account, i.external_id AS id, i.name AS name, max(m.outgoing) AS outgoing",
43
- },
44
- // Quarter hours, so a calendar day or hour in any zone (+05:30, +05:45) is a whole number of buckets.
45
- time: { key: "m.sent_at / 900000", select: "CAST(min(m.sent_at / 900000) * 900000 AS TEXT) AS id, NULL AS name" },
46
- };
47
36
  export const matchQuery = (context, execution) => runQuery(context, execution);
48
37
  /** Every match counted once, grouped; the work and candidate budgets of a search still apply. */
49
38
  export const countQuery = (context, execution, by) => runQuery(context, execution, by);
@@ -1,5 +1,7 @@
1
1
  import { type MeetingStatsStore } from "../../meetings/index.js";
2
2
  import type { StoreContext } from "./open.js";
3
+ /** One row per selected meeting through `meetings_by_time`; a current transcript is neither deleted nor superseded. */
4
+ export declare const meetingCountsSql: (where: string) => string;
3
5
  /**
4
6
  * One transcript per meeting — its earliest current one — so a local transcript beside the provider's does not count
5
7
  * the talk twice. Words are counted from spaces in SQL, so no transcript text leaves the database.
@@ -1,10 +1,17 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { aggregateMeetingStats } from "../../meetings/index.js";
2
+ import { aggregateMeetingCounts, aggregateMeetingStats, } from "../../meetings/index.js";
3
3
  import { checkMeetingCancelled } from "./meeting-operation.js";
4
4
  import { positiveReadInteger } from "./meeting-reads.js";
5
5
  import { meetingSelectionSql } from "./meeting-selection.js";
6
6
  import { read } from "./transaction.js";
7
7
  const MOST_ROWS = 500_000;
8
+ /** One row per selected meeting through `meetings_by_time`; a current transcript is neither deleted nor superseded. */
9
+ export const meetingCountsSql = (where) => `SELECT m.started_at, coalesce(m.duration_ms, m.ended_at - m.started_at) AS duration_ms,
10
+ EXISTS (SELECT 1 FROM meeting_transcripts t WHERE t.meeting_id = m.id AND t.deleted_at IS NULL
11
+ AND t.superseded_at IS NULL) AS transcribed,
12
+ s.external_id AS series_key, CASE WHEN s.id IS NULL THEN NULL ELSE coalesce(s.title, m.title) END AS series_title
13
+ FROM meetings m LEFT JOIN meeting_series s ON s.id = m.meeting_series_id
14
+ WHERE ${where} ORDER BY m.started_at DESC`;
8
15
  /**
9
16
  * One transcript per meeting — its earliest current one — so a local transcript beside the provider's does not count
10
17
  * the talk twice. Words are counted from spaces in SQL, so no transcript text leaves the database.
@@ -52,4 +59,23 @@ export const meetingStatsOver = ({ database }) => ({
52
59
  })), input.timeZone);
53
60
  });
54
61
  },
62
+ async meetingCounts(input) {
63
+ checkMeetingCancelled(input.signal);
64
+ positiveReadInteger(input.accountId, "accountId");
65
+ return read(database, () => {
66
+ const { clauses, params } = meetingSelectionSql(input);
67
+ const meetings = database
68
+ .prepare(meetingCountsSql(clauses.join(" AND ")))
69
+ .all(...params)
70
+ .map((row) => ({
71
+ startedAt: row.started_at === null ? null : Number(row.started_at),
72
+ durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
73
+ transcribed: Number(row.transcribed) === 1,
74
+ seriesKey: row.series_key === null ? null : String(row.series_key),
75
+ seriesTitle: row.series_title === null ? null : String(row.series_title),
76
+ }));
77
+ checkMeetingCancelled(input.signal);
78
+ return aggregateMeetingCounts(meetings, input.by, input.timeZone, input.limit);
79
+ });
80
+ },
55
81
  });
@@ -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(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.563.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>",
@@ -4636,9 +4636,15 @@
4636
4636
  "stats tasks show": {
4637
4637
  "in": "all",
4638
4638
  "options": {
4639
+ "--by": {
4640
+ "in": "all"
4641
+ },
4639
4642
  "--chat": {
4640
4643
  "in": "all"
4641
4644
  },
4645
+ "--timezone": {
4646
+ "in": "all"
4647
+ },
4642
4648
  "--type": {
4643
4649
  "in": "all"
4644
4650
  }
@@ -4676,6 +4682,12 @@
4676
4682
  "--encrypt": "all"
4677
4683
  }
4678
4684
  },
4685
+ "store chats": {
4686
+ "in": "all"
4687
+ },
4688
+ "store chats status": {
4689
+ "in": "all"
4690
+ },
4679
4691
  "store check": {
4680
4692
  "in": "all"
4681
4693
  },