@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
package/CHANGELOG.md CHANGED
@@ -7,6 +7,83 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.564.0 — 05.11.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **`search all` ranks its sources as one list** (MEMO-37). Each source's hit at one rank used to get the same score,
15
+ so the sources took turns in the order they were read (messages, mail, notes, events). Now hits at the same rank
16
+ are ordered by cosine, since one model scores every source; a hit meaning did not find comes after those it did.
17
+ Each source's own order is kept. Without the model nothing changes. `search notes` and `search events` hits found
18
+ by meaning carry `meaning_score`, the cosine.
19
+ - **tg, max and wa `search all` change with the bump, with no code change in them.** Mail is searched by meaning when
20
+ the store holds mail, a question's message found both by words and by meaning gets one vote, and once vectors
21
+ exist the hits at one rank are ordered by meaning score.
22
+ - **`search all` searches mail by meaning too** (MEMO-38). Mail was only ever searched by words there, though its
23
+ threads had vectors. The meaning lane now reads a mail account's thread vectors through the mail store, and
24
+ `search all` fuses them with the word hits as it does for messages; the answer says how in `mail`. For a question,
25
+ a mail or message found by words gets no second vote from meaning, as in notes.
26
+ - **`search all` finds records for a question typed as a sentence.** When the text is a question in natural language
27
+ and not `exact`, messages and mail go through discovery (as `search messages --discover`), and notes and calendar
28
+ events search any of the question's content words, ranked by the store. Before, every word had to be in one record,
29
+ so a question found nothing (MEMO-36). tg, max and wa still ask the messenger's server with the question as typed.
30
+ For a question, a note found by both words and meaning gets one vote, not two, so notes found by words alone are not
31
+ buried. Plain words, query syntax and `exact` are searched strictly, as before. `searchNotes` and `searchEvents`
32
+ take `discover`.
33
+
34
+ ### Added
35
+
36
+ - **The proposal registry reviews meeting findings and task close suggestions.** `proposalRegistry` registers
37
+ `meeting_findings` (a meeting's staged batch, decided part by part as in `meetings actions confirm`) and
38
+ `task_close` (an agent's suggested close, id `close:<task key>`; confirming closes the task as suggested, rejecting
39
+ clears the suggestion). Both are owner only: `isOwnerOnlyKind` now names `task_close` too, so `mayDecide` refuses an
40
+ agent by the row's kind. `parseProposalIds` takes a task key after a prefix (`close:Q-15`). Moved from cli-memo,
41
+ which registered both itself; a host that registers a kind of the same name now gets `configuration_error`.
42
+
43
+ - **A mail CLI's `messages show` can read an email again when a sync's limit left its attachment unread.**
44
+ `mailReadCommands` takes `whole(command, store, mailbox, emailId)`; with it the command is a fetch, reads such an
45
+ email from the server before it answers (`readEmailWhole`), and shows the new text. When that read fails it shows
46
+ the stored email with `meta.complete: false` and an issue `mail.attachments-unread`. Without `whole` nothing changes.
47
+ `readEmailWhole`'s body and text limits, reader and clock now default as sync's do.
48
+ - **`has:transcript` in the messages query language** finds a voice message that has a transcription, so
49
+ `stats messages show --by day -- has:transcript date>=30d` counts transcribed voice messages per day. In mail it
50
+ finds nothing.
51
+ - **`store chats status`** counts the account's searchable chats of every kind, channels included, by how much of each
52
+ the archive holds: `{ chats, fetched, partial, never, oldest_message_at }`. `fetched` is held back to the first
53
+ message, up to date and without gaps; `never` has no fetch recorded and no message held; the rest is `partial`,
54
+ including WhatsApp chats whose history was pushed. One statement over the store, no list of chats. tg, max and wa
55
+ get it with the bump.
56
+ - **Meeting counts per day, week or series.** `readMeetingCounts` (services) and `store.meetings.meetingCounts`
57
+ answer meetings, minutes, mean minutes and meetings with a transcript, in total and per row, over the same
58
+ selection as `readMeetingStats`, reading `meetings` alone through `meetings_by_time`. `meetingCountsItem` and the
59
+ `meeting_counts` entity are the answer, for zoom-cli's `stats meetings history`. A meeting with no end counts in
60
+ `meetings`, not in `minutes`. `MeetingStatsStore` gains `meetingCounts`; only cli-core's own store implements it.
61
+ - **`stats tasks show` counts by chat, meeting or project, over a period.** `--by meeting` keys a row by the meeting a
62
+ task came from (`meeting:<account>/<meeting>`); `--by project` reads the owner's project tasks, every project
63
+ (`project:<id>`). A query after `--` takes the task query's fields and `created` for the period: `created` counts
64
+ the tasks opened in it, `closed` those closed in it, `open` is always open now (`-- created>=30d`). The item gains
65
+ `created`, `closed`, `median_close_ms` and `mean_open_age_ms`; each row gains `key`, `name` (chat title, meeting
66
+ title, project name), `created`, `closed` and `mean_open_age_ms`. Rows of `--by chat` keep `chat`; the others have
67
+ none. `--timezone` places calendar days such as `created>=today`.
68
+ - **`created:` in the project task query** (`PROJECT_TASK_FIELDS`), beside `closed:`: `created>=30d`.
69
+ - **`mailStats` from `./mail`**: stored mail received and sent per calendar day, week (by its Monday) or sender, for
70
+ the `search mail` query, counts only and no search recorded; `mailStatsItem` turns it into the new `mail_counts`
71
+ entity, `{ by, received, sent, rows: [{ key, name, received, sent }] }`. By sender it counts received mail only.
72
+ - **`mailOwnText(body)` (`@wirecat/cli-core/mail`): what an email says itself, without quoted earlier mail and the
73
+ signature.** It drops `>` lines, cuts at a reply header ("On … wrote:", "Le … a écrit :", "Am … schrieb …:",
74
+ "… написал(а):", Gmail's Russian "<address>:") and at an Outlook From/Sent block in English, German, French, Spanish
75
+ and Russian, and drops the `-- ` signature, sign-offs and mobile footers. A forwarded message stays whole; an email
76
+ that is only a quote keeps its body.
77
+
78
+ ### Security
79
+
80
+ - **`review --holder` gives each email's own text** (`mailOwnText`), so an agent's model does not read earlier mail
81
+ or signatures. The fingerprint a finding cites is still taken over the stored body; other reads show the whole text.
82
+ - **Review and inbox items say which fields others wrote** (WC-SECR-21): `summaryOf` items, and so every catch-up
83
+ answer and each mail `review` item, carry `untrusted` (`SUMMARY_UNTRUSTED`: chat title, sender name, text,
84
+ transcript, attachment names); a mail review item adds the email's subject and the names in its headers, as search
85
+ hits do. The `message.summary` entity takes the field.
86
+
10
87
  ## 0.563.0 — 03.11.2026
11
88
 
12
89
  ### Changed — may break callers
@@ -10,13 +10,13 @@ export declare const calendarTools: ({
10
10
  inputSchema: {
11
11
  type: string;
12
12
  properties: {
13
+ limit?: undefined;
13
14
  account_id: {
14
15
  type: string;
15
16
  minimum: number;
16
17
  };
17
18
  query?: undefined;
18
19
  timezone?: undefined;
19
- limit?: undefined;
20
20
  page?: undefined;
21
21
  event?: undefined;
22
22
  } | {
@@ -45,9 +45,9 @@ export declare const calendarTools: ({
45
45
  };
46
46
  event?: undefined;
47
47
  } | {
48
+ limit?: undefined;
48
49
  query?: undefined;
49
50
  timezone?: undefined;
50
- limit?: undefined;
51
51
  page?: undefined;
52
52
  event: {
53
53
  type: string;
@@ -58,9 +58,9 @@ export declare const calendarTools: ({
58
58
  minimum: number;
59
59
  };
60
60
  } | {
61
+ limit?: undefined;
61
62
  query?: undefined;
62
63
  timezone?: undefined;
63
- limit?: undefined;
64
64
  page?: undefined;
65
65
  event?: undefined;
66
66
  account_id?: undefined;
@@ -80,13 +80,13 @@ export declare const calendarTools: ({
80
80
  inputSchema: {
81
81
  type: string;
82
82
  properties: {
83
+ limit?: undefined;
83
84
  account_id: {
84
85
  type: string;
85
86
  minimum: number;
86
87
  };
87
88
  query?: undefined;
88
89
  timezone?: undefined;
89
- limit?: undefined;
90
90
  page?: undefined;
91
91
  event?: undefined;
92
92
  } | {
@@ -115,9 +115,9 @@ export declare const calendarTools: ({
115
115
  };
116
116
  event?: undefined;
117
117
  } | {
118
+ limit?: undefined;
118
119
  query?: undefined;
119
120
  timezone?: undefined;
120
- limit?: undefined;
121
121
  page?: undefined;
122
122
  event: {
123
123
  type: string;
@@ -128,9 +128,9 @@ export declare const calendarTools: ({
128
128
  minimum: number;
129
129
  };
130
130
  } | {
131
+ limit?: undefined;
131
132
  query?: undefined;
132
133
  timezone?: undefined;
133
- limit?: undefined;
134
134
  page?: undefined;
135
135
  event?: undefined;
136
136
  account_id?: undefined;
@@ -150,13 +150,13 @@ export declare const calendarTools: ({
150
150
  inputSchema: {
151
151
  type: string;
152
152
  properties: {
153
+ limit?: undefined;
153
154
  account_id: {
154
155
  type: string;
155
156
  minimum: number;
156
157
  };
157
158
  query?: undefined;
158
159
  timezone?: undefined;
159
- limit?: undefined;
160
160
  page?: undefined;
161
161
  event?: undefined;
162
162
  } | {
@@ -185,9 +185,9 @@ export declare const calendarTools: ({
185
185
  };
186
186
  event?: undefined;
187
187
  } | {
188
+ limit?: undefined;
188
189
  query?: undefined;
189
190
  timezone?: undefined;
190
- limit?: undefined;
191
191
  page?: undefined;
192
192
  event: {
193
193
  type: string;
@@ -198,9 +198,9 @@ export declare const calendarTools: ({
198
198
  minimum: number;
199
199
  };
200
200
  } | {
201
+ limit?: undefined;
201
202
  query?: undefined;
202
203
  timezone?: undefined;
203
- limit?: undefined;
204
204
  page?: undefined;
205
205
  event?: undefined;
206
206
  account_id?: undefined;
@@ -220,13 +220,13 @@ export declare const calendarTools: ({
220
220
  inputSchema: {
221
221
  type: string;
222
222
  properties: {
223
+ limit?: undefined;
223
224
  account_id: {
224
225
  type: string;
225
226
  minimum: number;
226
227
  };
227
228
  query?: undefined;
228
229
  timezone?: undefined;
229
- limit?: undefined;
230
230
  page?: undefined;
231
231
  event?: undefined;
232
232
  } | {
@@ -255,9 +255,9 @@ export declare const calendarTools: ({
255
255
  };
256
256
  event?: undefined;
257
257
  } | {
258
+ limit?: undefined;
258
259
  query?: undefined;
259
260
  timezone?: undefined;
260
- limit?: undefined;
261
261
  page?: undefined;
262
262
  event: {
263
263
  type: string;
@@ -268,9 +268,9 @@ export declare const calendarTools: ({
268
268
  minimum: number;
269
269
  };
270
270
  } | {
271
+ limit?: undefined;
271
272
  query?: undefined;
272
273
  timezone?: undefined;
273
- limit?: undefined;
274
274
  page?: undefined;
275
275
  event?: undefined;
276
276
  account_id?: undefined;
@@ -26,6 +26,7 @@ export const storeCommand = (messenger) => {
26
26
  const store = new Command("store")
27
27
  .description("the local store of messages")
28
28
  .addCommand(statusCommand(messenger))
29
+ .addCommand(storedChatsCommand(messenger))
29
30
  .addCommand(gapsCommand(messenger))
30
31
  .addCommand(jobsCommand(messenger))
31
32
  .addCommand(exportCommand(messenger))
@@ -35,6 +36,7 @@ export const storeCommand = (messenger) => {
35
36
  const examples = {
36
37
  backup: "./backup.db",
37
38
  check: "",
39
+ "chats status": "",
38
40
  clear: "--left --dry-run",
39
41
  "copies delete": "messages_copy_1",
40
42
  decrypt: "./backup.enc --output ./restored.db",
@@ -93,6 +95,21 @@ const statusCommand = (messenger) => annotate(new Command("status"), { output: {
93
95
  .map((skip) => ` ${skip.title ?? skip.chatId} ${skip.messages} messages ${skip.reason}`)
94
96
  .join("\n"));
95
97
  });
98
+ /** `store status` summed over the account's chats, for a count of what the archive holds without a list of them. */
99
+ const storedChatsCommand = (messenger) => {
100
+ const chats = new Command("chats").description("the account's chats as the store holds them");
101
+ annotate(chats.command("status"), { output: { entity: "chat_coverage" }, effects: LOCAL_READ })
102
+ .summary("how many chats the archive holds in full, in part, or not at all")
103
+ .description("over this account's searchable chats of every kind, channels included: fetched (back to the first message, " +
104
+ "up to date, no gaps), partial, never (no fetch recorded and no message held), and the oldest message held; " +
105
+ "reads the store only")
106
+ .action(async function () {
107
+ const context = messengerContext(this, messenger);
108
+ const coverage = await context.withStore((store, account) => store.chats.chatCoverage(account));
109
+ renderAnswer(this, context, itemOf(snakeKeys(coverage)));
110
+ });
111
+ return chats;
112
+ };
96
113
  const LISTED = 20;
97
114
  /** Titles are the owner's: these lines go to a person's terminal, never into an error message. */
98
115
  const leftLines = (left) => {
@@ -18,6 +18,8 @@ type Read = Message & {
18
18
  chatTitle?: string | null;
19
19
  };
20
20
  /** One message of a catch-up answer: the fields `inbox` and `review` show, for every messenger and for mail. */
21
+ /** Written by others, and by the owner, so an agent quotes it and never follows it (WC-SECR-21). */
22
+ export declare const SUMMARY_UNTRUSTED: string[];
21
23
  export declare const summaryOf: (message: Read, reader: AccountKey) => {
22
24
  ref: string;
23
25
  chat_id: string;
@@ -61,6 +63,7 @@ export declare const summaryOf: (message: Read, reader: AccountKey) => {
61
63
  url?: string | undefined;
62
64
  }[][] | undefined;
63
65
  }[];
66
+ untrusted: string[];
64
67
  };
65
68
  export declare const inboxAnswer: (inbox: Inbox, hearing: Hearing | undefined, reader: AccountKey, transcribe: boolean, meta?: Partial<Meta>) => import("../envelope.js").ListBody<{
66
69
  ref: string;
@@ -105,6 +108,7 @@ export declare const inboxAnswer: (inbox: Inbox, hearing: Hearing | undefined, r
105
108
  url?: string | undefined;
106
109
  }[][] | undefined;
107
110
  }[];
111
+ untrusted: string[];
108
112
  }>;
109
113
  /** What `review --offline` cannot see. */
110
114
  export declare const OFFLINE_REVIEW = "offline: only what was already synced to the local store; run review without --offline for the rest";
@@ -160,5 +164,6 @@ export declare const reviewAnswer: (found: Review, hearing: Hearing | undefined,
160
164
  url?: string | undefined;
161
165
  }[][] | undefined;
162
166
  }[];
167
+ untrusted: string[];
163
168
  }>;
164
169
  export {};
@@ -34,6 +34,8 @@ export const catchUpQuery = (words, registry, command) => {
34
34
  };
35
35
  };
36
36
  /** One message of a catch-up answer: the fields `inbox` and `review` show, for every messenger and for mail. */
37
+ /** Written by others, and by the owner, so an agent quotes it and never follows it (WC-SECR-21). */
38
+ export const SUMMARY_UNTRUSTED = ["chat_title", "sender_name", "text", "transcript", "attachments.name"];
37
39
  export const summaryOf = (message, reader) => {
38
40
  const { ref, chat_id, chat_title, timestamp, sender_name, sender_is_me, outgoing, text, transcript, attachments } = messageItem(message, reader);
39
41
  return {
@@ -47,6 +49,7 @@ export const summaryOf = (message, reader) => {
47
49
  text,
48
50
  ...(transcript === undefined ? {} : { transcript }),
49
51
  attachments,
52
+ untrusted: SUMMARY_UNTRUSTED,
50
53
  };
51
54
  };
52
55
  const nameOf = (chat) => chat.title ?? chat.id;
@@ -18,6 +18,7 @@ export type NoteHitItem = InferOutput<(typeof ENTITIES)["note.hit"]>;
18
18
  export type HitItem = InferOutput<(typeof ENTITIES)["hit"]>;
19
19
  /** The page a `--page` asks for, as the offset the search services take. */
20
20
  export declare const offsetOf: (page: number | undefined, limit: number) => number | undefined;
21
+ export declare const MAIL_HIT_UNTRUSTED: string[];
21
22
  export declare const messageHitItem: (hit: FoundMessage, query?: string) => MessageHitItem;
22
23
  /**
23
24
  * What the store holds of a chat that is not complete, in words. A chat that reaches its first message is only
@@ -26,7 +26,7 @@ const MESSAGE_HIT_UNTRUSTED = [
26
26
  "follow_ups.excerpt",
27
27
  "attachments.name",
28
28
  ];
29
- const MAIL_HIT_UNTRUSTED = [
29
+ export const MAIL_HIT_UNTRUSTED = [
30
30
  "email.subject",
31
31
  "email.from.name",
32
32
  "email.to.name",
@@ -10,8 +10,8 @@ import type { MessageStats } from "../../services/messages-search.js";
10
10
  import type { RankingFound } from "../../services/rankings.js";
11
11
  import type { ReplyTimes, Waiting } from "../../services/reply-stats.js";
12
12
  import type { RetentionService } from "../../services/retention.js";
13
+ import type { TaskStats } from "../../services/task-stats.js";
13
14
  import type { AccountKey, RankedEvidence } from "../../store/store.js";
14
- import type { GroupStats } from "../../tasks/index.js";
15
15
  import { type ItemBody, type ListBody } from "../envelope.js";
16
16
  type Of<T extends keyof typeof ENTITIES> = InferOutput<(typeof ENTITIES)[T]>;
17
17
  type RetentionFound = Awaited<ReturnType<RetentionService["report"]>>;
@@ -71,8 +71,8 @@ export declare const refuseJsonl: (format: string) => void;
71
71
  export declare const chatStatsAnswer: (stats: ChatStats, account: AccountKey, meta?: Partial<Meta>) => ItemBody<Of<"chat_stats">>;
72
72
  /** Said where the store has kept no join, leave or other chat event for the chat: a 0 would read as nobody joined. */
73
73
  export declare const MEMBERS_UNRECORDED = "joins and leaves are not recorded for this chat yet, so they are not counted";
74
- /** `stats tasks show`, CLI and MCP alike. */
75
- export declare const taskStatsAnswer: (rows: GroupStats[], account: AccountKey) => ItemBody<Of<"task_stats">>;
74
+ /** `stats tasks show`, CLI and MCP alike. A row of `--by chat` also names its chat as `chat`, as it always has. */
75
+ export declare const taskStatsAnswer: (stats: TaskStats, account: AccountKey) => ItemBody<Of<"task_stats">>;
76
76
  export declare const responsesAnswer: (found: ReplyTimes) => ItemBody<Of<"response_times">>;
77
77
  export declare const waitingAnswer: (found: Waiting) => ItemBody<Of<"waiting_chats">>;
78
78
  export {};
@@ -410,15 +410,27 @@ export const chatStatsAnswer = (stats, account, meta) => {
410
410
  };
411
411
  /** Said where the store has kept no join, leave or other chat event for the chat: a 0 would read as nobody joined. */
412
412
  export const MEMBERS_UNRECORDED = "joins and leaves are not recorded for this chat yet, so they are not counted";
413
- /** `stats tasks show`, CLI and MCP alike. */
414
- export const taskStatsAnswer = (rows, account) => itemOf({
415
- open: rows.reduce((sum, row) => sum + row.open, 0),
416
- rows: rows.map((row) => ({
417
- chat: chatRef(account, row.group),
418
- open: row.open,
419
- oldest_open_at: row.oldestOpenAt?.toISOString() ?? null,
420
- median_close_ms: row.medianCloseMs ?? null,
421
- })),
413
+ /** `stats tasks show`, CLI and MCP alike. A row of `--by chat` also names its chat as `chat`, as it always has. */
414
+ export const taskStatsAnswer = (stats, account) => itemOf({
415
+ open: stats.open,
416
+ created: stats.created,
417
+ closed: stats.closed,
418
+ median_close_ms: stats.medianCloseMs ?? null,
419
+ mean_open_age_ms: stats.meanOpenAgeMs ?? null,
420
+ rows: stats.rows.map((row) => {
421
+ const chat = stats.by === "chat" ? chatRef(account, row.group) : undefined;
422
+ return {
423
+ ...(chat === undefined ? {} : { chat }),
424
+ key: chat ?? (stats.by === "project" ? `project:${row.group}` : row.group),
425
+ name: row.name,
426
+ open: row.open,
427
+ created: row.created,
428
+ closed: row.closed,
429
+ oldest_open_at: row.oldestOpenAt?.toISOString() ?? null,
430
+ median_close_ms: row.medianCloseMs ?? null,
431
+ mean_open_age_ms: row.meanOpenAgeMs ?? null,
432
+ };
433
+ }),
422
434
  });
423
435
  const replyFigures = (one) => ({
424
436
  replies: one.replies,
@@ -8,7 +8,8 @@ import { aheadOf, momentOf } from "../../services/moment.js";
8
8
  import { refuseAlreadyRead } from "../../services/task-analysis.js";
9
9
  import { unlistedNote } from "../../services/task-categories.js";
10
10
  import { ARRIVAL_WINDOW, applyTaskRulesToChats } from "../../services/task-rules.js";
11
- import { CLAIM_LEASE_MS, closedStateOf, TASK_PAIR_QUERY, TASK_QUERY, taskLinkKindOf, taskTypeOf, } from "../../services/tasks.js";
11
+ import { TASK_STATS_BY } from "../../services/task-stats.js";
12
+ import { CLAIM_LEASE_MS, closedStateOf, TASK_PAIR_QUERY, TASK_QUERY, TASK_STATS_QUERY, taskLinkKindOf, taskTypeOf, } from "../../services/tasks.js";
12
13
  import { PRIORITY_LABELS, priorityOption } from "../../tasks/index.js";
13
14
  import { actorOf, holderOf as holderIn } from "../author.js";
14
15
  import { environmentOf } from "../context.js";
@@ -539,32 +540,52 @@ export const tasksCommand = (messenger) => {
539
540
  });
540
541
  return tasks;
541
542
  };
543
+ const statsByOf = (value) => {
544
+ if (!TASK_STATS_BY.includes(value))
545
+ throw new CliError("validation_error", `--by takes ${TASK_STATS_BY.join(", ")}`);
546
+ return value;
547
+ };
548
+ const minutes = (ms) => (ms === undefined ? "" : `${Math.round(ms / 60_000)} min`);
542
549
  export const tasksStatsCommand = (messenger) => {
543
- return annotate(new Command("show"), { effects: LOCAL_READ, output: { entity: "task_stats" } })
544
- .description("per chat: how many tasks are open, the oldest open one, the median time to close")
550
+ return withQuery(annotate(new Command("show"), {
551
+ effects: LOCAL_READ,
552
+ output: { entity: "task_stats" },
553
+ examples: [`${messenger.app.command} stats tasks show --by meeting -- created>=30d`],
554
+ }), TASK_STATS_QUERY)
555
+ .summary("count tasks by chat, meeting or project, open and closed")
556
+ .description("tasks per chat, meeting or project: open now, and opened and closed in the period (created: in the query), " +
557
+ "with the median time to close and the mean age of the open ones")
558
+ .option("--by <group>", "chat (default) or meeting: this account's tasks; project: the owner's project tasks, every project", statsByOf)
545
559
  .option("--chat <chat>", `only this chat; ${messenger.chatArgument}`)
546
560
  .option("--type <type>", "only this type: question, request, mention or promise", taskTypeOf)
547
- .action(async function () {
561
+ .option("--timezone <zone>", "the IANA timezone for calendar days in the period, such as created>=today")
562
+ .action(async function (words) {
548
563
  const context = messengerContext(this, messenger);
549
- const { chat, type } = this.opts();
564
+ const { by, chat, type, timezone } = this.opts();
550
565
  refuseJsonl(context.format);
551
- const { rows, account } = await context.withServices(async (services) => ({
552
- rows: await services.tasks.stats({
566
+ const query = listQuery(typedQuery(words), TASK_STATS_QUERY, "stats tasks show");
567
+ const { stats, account } = await context.withServices(async (services) => ({
568
+ stats: await services.tasks.stats({
569
+ ...(by === undefined ? {} : { by }),
553
570
  ...(chat === undefined ? {} : { chat }),
554
- ...(type === undefined ? {} : { type }),
571
+ ...(type === undefined ? {} : { kind: type }),
572
+ ...(query === undefined ? {} : { query }),
573
+ ...(timezone === undefined ? {} : { timezone }),
555
574
  }),
556
575
  account: await services.messages.reader(),
557
576
  }));
558
- if (rows.length === 0)
577
+ if (stats.rows.length === 0)
559
578
  context.renderer.note("no tasks yet");
560
- renderAnswer(this, context, taskStatsAnswer(rows, account), () => rows.length === 0
579
+ renderAnswer(this, context, taskStatsAnswer(stats, account), () => stats.rows.length === 0
561
580
  ? ""
562
- : `${rows
563
- .map(({ group, open, oldestOpenAt, medianCloseMs }) => [
564
- group,
581
+ : `${stats.rows
582
+ .map(({ group, name, open, created, closed, oldestOpenAt, medianCloseMs }) => [
583
+ name ? `${name} (${group})` : group,
565
584
  `${open} open`,
585
+ `${created} created`,
586
+ `${closed} closed`,
566
587
  oldestOpenAt ? `oldest ${oldestOpenAt.toISOString().slice(0, 16).replace("T", " ")}` : "",
567
- medianCloseMs === undefined ? "" : `median ${Math.round(medianCloseMs / 60_000)} min to close`,
588
+ medianCloseMs === undefined ? "" : `median ${minutes(medianCloseMs)} to close`,
568
589
  ]
569
590
  .filter(Boolean)
570
591
  .join(" "))
@@ -4,3 +4,9 @@
4
4
  * not hold stays, so a forward or a reply to an unsaved email is still found.
5
5
  */
6
6
  export declare const mailCore: (body: string, earlier: string[]) => string;
7
+ /**
8
+ * What an email says itself, for a model that should not read earlier mail or the sender's signature: no quoted
9
+ * lines, nothing after a reply or Outlook header, no signature or mobile footer. A forwarded message is kept whole,
10
+ * since it is what was sent. When nothing would be left — an email that is only a quote — the body stays as it is.
11
+ */
12
+ export declare const mailOwnText: (body: string) => string;
@@ -86,3 +86,76 @@ export const mailCore = (body, earlier) => {
86
86
  const kept = trimEdges(lines.filter((_, i) => !drop.has(i)));
87
87
  return withoutSignOff(withoutGreeting(kept)).join("\n").trim();
88
88
  };
89
+ const QUOTED = /^\s*>/;
90
+ const ATTRIBUTION_END = /(wrote|написал(\(а\)|а|и)?|escribió|a écrit|schrieb\s.+|<[^<>\s]+@[^<>\s]+>)\s*:\s*$/iu;
91
+ /** The first line of an attribution a client wrapped in two: "On Mon, 3 Nov 2026 at 10:00, Alice Example <". */
92
+ const ATTRIBUTION_START = /^(on|le|am|el|в)\s/iu;
93
+ const DATED = /^(on|le|am|el|в|\d)/iu;
94
+ const ORIGINAL = /^-{2,}\s*(original message|ursprüngliche nachricht|message d'origine|mensaje original|исходное сообщение)\s*-{2,}$/iu;
95
+ const FROM_LINE = /^(from|von|de|от)\s?:\s*\S/iu;
96
+ const DATE_LINE = /^(sent|date|gesendet|datum|envoyé|enviado|fecha|отправлено|дата)\s?:/iu;
97
+ const FORWARD = /^(-{2,}\s*(forwarded message|weitergeleitete nachricht|message transféré|mensaje reenviado|пересылаемое сообщение)\s*-{2,}|begin forwarded message:)$/iu;
98
+ const RULE = /^_{5,}$/;
99
+ const MOBILE = /^(sent from my |von meinem .+ gesendet$|envoyé de mon |отправлено с |enviado desde mi )/iu;
100
+ /** Where an attribution ending at `i` begins, or -1 when the line is not one. */
101
+ const attributionAt = (lines, i) => {
102
+ const line = (lines[i] ?? "").trim();
103
+ if (!ATTRIBUTION_END.test(line))
104
+ return -1;
105
+ const prior = (lines[i - 1] ?? "").trim();
106
+ return prior && ATTRIBUTION_START.test(prior) && !ATTRIBUTION_START.test(line) ? i - 1 : i;
107
+ };
108
+ /** An Outlook header — From, then Sent or Date within the next lines — or its "Original Message" divider. */
109
+ const outlookAt = (lines, i) => {
110
+ const line = (lines[i] ?? "").trim();
111
+ if (ORIGINAL.test(line))
112
+ return true;
113
+ return FROM_LINE.test(line) && lines.slice(i + 1, i + 4).some((next) => DATE_LINE.test(next.trim()));
114
+ };
115
+ const nextText = (lines, from) => lines.slice(from).find((line) => line.trim()) ?? "";
116
+ /** The lines an email adds, in reading order: quotes dropped, and everything after a reply header cut. */
117
+ const ownLines = (lines) => {
118
+ const kept = [];
119
+ for (let i = 0; i < lines.length; i++) {
120
+ const line = lines[i] ?? "";
121
+ if (QUOTED.test(line))
122
+ continue;
123
+ if (outlookAt(lines, i)) {
124
+ const last = kept.findLastIndex((one) => one.trim());
125
+ return last !== -1 && RULE.test((kept[last] ?? "").trim()) ? kept.slice(0, last) : kept;
126
+ }
127
+ const start = attributionAt(lines, i);
128
+ if (start === -1) {
129
+ kept.push(line);
130
+ continue;
131
+ }
132
+ if (start < i)
133
+ kept.pop();
134
+ if (QUOTED.test(nextText(lines, i + 1)))
135
+ continue;
136
+ // Without quote marks only a dated header is one: "Here is what Bob wrote:" is not.
137
+ const header = lines
138
+ .slice(start, i + 1)
139
+ .join(" ")
140
+ .trim();
141
+ if (/\d/.test(header) && (header.includes("@") || DATED.test(header)) && header.length <= 300)
142
+ return kept;
143
+ if (start < i)
144
+ kept.push(lines[start] ?? "");
145
+ kept.push(line);
146
+ }
147
+ return kept;
148
+ };
149
+ /**
150
+ * What an email says itself, for a model that should not read earlier mail or the sender's signature: no quoted
151
+ * lines, nothing after a reply or Outlook header, no signature or mobile footer. A forwarded message is kept whole,
152
+ * since it is what was sent. When nothing would be left — an email that is only a quote — the body stays as it is.
153
+ */
154
+ export const mailOwnText = (body) => {
155
+ const lines = body.replace(/\r\n?/g, "\n").split("\n");
156
+ const forward = lines.findIndex((line) => FORWARD.test(line.trim()));
157
+ const head = forward === -1 ? lines : lines.slice(0, forward);
158
+ const own = withoutSignOff(trimEdges(ownLines(head).filter((line) => !MOBILE.test(line.trim()))));
159
+ const text = [...own, ...(forward === -1 ? [] : ["", ...lines.slice(forward)])].join("\n").trim();
160
+ return text || body.trim();
161
+ };
@@ -1 +1,3 @@
1
1
  export declare const calendarKey: (zone: string, by: "day" | "hour") => (time: number) => string;
2
+ /** The Monday that starts the week of a `YYYY-MM-DD` day. */
3
+ export declare const weekKey: (day: string) => string;
@@ -11,3 +11,9 @@ export const calendarKey = (zone, by) => {
11
11
  return `${part.year}-${part.month}-${part.day}${by === "hour" ? `T${part.hour}` : ""}`;
12
12
  };
13
13
  };
14
+ /** The Monday that starts the week of a `YYYY-MM-DD` day. */
15
+ export const weekKey = (day) => {
16
+ const date = new Date(`${day}T00:00:00Z`);
17
+ date.setUTCDate(date.getUTCDate() - ((date.getUTCDay() + 6) % 7));
18
+ return date.toISOString().slice(0, 10);
19
+ };
@@ -62,14 +62,28 @@ export declare const chatStats: v.StrictObjectSchema<{
62
62
  readonly left: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
63
63
  }, undefined>, undefined>, undefined>;
64
64
  }, undefined>;
65
- /** Open and closed tasks, one row per chat. */
65
+ /**
66
+ * Open and closed tasks, one row per chat, meeting or project. `open` is open now; `created` and `closed` count the
67
+ * tasks opened and closed in the query's period, every task without one.
68
+ */
66
69
  export declare const taskStats: v.StrictObjectSchema<{
67
70
  /** Open tasks across every row. */
68
71
  readonly open: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
72
+ readonly created: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
73
+ readonly closed: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
74
+ readonly median_close_ms: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
75
+ readonly mean_open_age_ms: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
69
76
  readonly rows: v.ArraySchema<v.StrictObjectSchema<{
70
- readonly chat: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
77
+ /** Rows of `--by chat` only. */
78
+ readonly chat: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
79
+ /** The chat reference, `meeting:<account>/<meeting>` or `project:<id>`. */
80
+ readonly key: v.StringSchema<undefined>;
81
+ readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
71
82
  readonly open: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
83
+ readonly created: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
84
+ readonly closed: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
72
85
  readonly oldest_open_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
73
86
  readonly median_close_ms: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
87
+ readonly mean_open_age_ms: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
74
88
  }, undefined>, undefined>;
75
89
  }, undefined>;
@@ -54,14 +54,29 @@ export const chatStats = v.strictObject({
54
54
  })),
55
55
  series: v.optional(v.array(v.strictObject({ key: text, messages: count, senders: count, joined: optionalCount, left: optionalCount }))),
56
56
  });
57
- /** Open and closed tasks, one row per chat. */
57
+ const ms = v.nullable(v.pipe(v.number(), v.minValue(0)));
58
+ /**
59
+ * Open and closed tasks, one row per chat, meeting or project. `open` is open now; `created` and `closed` count the
60
+ * tasks opened and closed in the query's period, every task without one.
61
+ */
58
62
  export const taskStats = v.strictObject({
59
63
  /** Open tasks across every row. */
60
64
  open: count,
65
+ created: count,
66
+ closed: count,
67
+ median_close_ms: ms,
68
+ mean_open_age_ms: ms,
61
69
  rows: v.array(v.strictObject({
62
- chat: reference,
70
+ /** Rows of `--by chat` only. */
71
+ chat: v.optional(reference),
72
+ /** The chat reference, `meeting:<account>/<meeting>` or `project:<id>`. */
73
+ key: text,
74
+ name: v.nullable(text),
63
75
  open: count,
76
+ created: count,
77
+ closed: count,
64
78
  oldest_open_at: v.nullable(time),
65
- median_close_ms: v.nullable(v.pipe(v.number(), v.minValue(0))),
79
+ median_close_ms: ms,
80
+ mean_open_age_ms: ms,
66
81
  })),
67
82
  });