@wirecat/cli-core 0.379.0 → 0.381.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 (68) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/cli/bot/watch.js +1 -1
  3. package/dist/cli/index.d.ts +1 -1
  4. package/dist/cli/meeting-preparation-answer.d.ts +5 -0
  5. package/dist/cli/meeting-preparation-projects.d.ts +5 -0
  6. package/dist/cli/meeting-preparation-read.d.ts +5 -0
  7. package/dist/cli/messenger/chats-stats-command.js +5 -1
  8. package/dist/cli/messenger/contacts-command.js +8 -4
  9. package/dist/cli/messenger/stats-answers.d.ts +2 -1
  10. package/dist/cli/messenger/stats-answers.js +1 -1
  11. package/dist/cli/messenger/stats-command.js +4 -9
  12. package/dist/cli/messenger/stored.js +12 -7
  13. package/dist/cli/messenger/watch-command.js +6 -0
  14. package/dist/domain/models.d.ts +23 -2
  15. package/dist/entities/index.d.ts +15 -0
  16. package/dist/entities/meeting-preparation.d.ts +5 -0
  17. package/dist/errors/reasons.js +1 -0
  18. package/dist/mcp/meeting-preparation.d.ts +5 -0
  19. package/dist/mcp/tools/stats.js +9 -6
  20. package/dist/meetings/answers.d.ts +6 -0
  21. package/dist/meetings/answers.js +5 -0
  22. package/dist/meetings/cli/index.d.ts +2 -0
  23. package/dist/meetings/cli/index.js +5 -2
  24. package/dist/meetings/cli/search.js +1 -1
  25. package/dist/meetings/hit-query.d.ts +12 -0
  26. package/dist/meetings/hit-query.js +58 -0
  27. package/dist/meetings/import.d.ts +2 -0
  28. package/dist/meetings/import.js +9 -2
  29. package/dist/meetings/index.d.ts +2 -0
  30. package/dist/meetings/index.js +2 -0
  31. package/dist/meetings/mcp/search.d.ts +6 -0
  32. package/dist/meetings/mcp/search.js +5 -1
  33. package/dist/meetings/project-evidence-answer.d.ts +5 -0
  34. package/dist/meetings/pull.d.ts +2 -0
  35. package/dist/meetings/recording-schedule.js +6 -5
  36. package/dist/meetings/search-store.d.ts +3 -1
  37. package/dist/meetings/store.d.ts +13 -0
  38. package/dist/meetings/transcript-text.d.ts +20 -0
  39. package/dist/meetings/transcript-text.js +59 -0
  40. package/dist/ports/messenger.d.ts +10 -3
  41. package/dist/render/markdown.js +2 -2
  42. package/dist/resolve.d.ts +6 -0
  43. package/dist/resolve.js +13 -4
  44. package/dist/search/garbled.d.ts +30 -0
  45. package/dist/search/garbled.js +130 -0
  46. package/dist/search/russian-dictionary.d.ts +5 -0
  47. package/dist/search/russian-dictionary.js +66 -0
  48. package/dist/services/meeting-project-discovery.js +2 -2
  49. package/dist/services/moment.d.ts +21 -0
  50. package/dist/services/moment.js +19 -0
  51. package/dist/services/retention.js +6 -3
  52. package/dist/store/messages.d.ts +16 -3
  53. package/dist/store/messages.js +7 -1
  54. package/dist/store/sqlite/accounts.js +2 -1
  55. package/dist/store/sqlite/chat-rekey.js +9 -1
  56. package/dist/store/sqlite/chats.js +2 -1
  57. package/dist/store/sqlite/manifest.js +1 -0
  58. package/dist/store/sqlite/meeting-hit-query.d.ts +60 -0
  59. package/dist/store/sqlite/meeting-hit-query.js +262 -0
  60. package/dist/store/sqlite/meeting-hit-search.js +28 -45
  61. package/dist/store/sqlite/meetings.js +56 -1
  62. package/dist/store/sqlite/messages.d.ts +11 -3
  63. package/dist/store/sqlite/messages.js +74 -3
  64. package/dist/store/sqlite/migrations.generated.js +7 -0
  65. package/dist/store/sqlite/open-store.js +16 -5
  66. package/dist/store/sqlite/schema.d.ts +155 -0
  67. package/dist/store/sqlite/schema.js +22 -0
  68. package/package.json +4 -3
package/CHANGELOG.md CHANGED
@@ -7,6 +7,91 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.381.0 — 22.10.2026
11
+
12
+ ### Added
13
+
14
+ - **Meeting search finds words speech recognition cut off.** A stored word of four letters or more that begins a
15
+ query word, and that the Russian dictionary does not know, widens it: «може» for «может», «напри» for «например».
16
+ Real shorter words («после» for «последующих») still do not. This is ztx's rule, behind the dictionary.
17
+
18
+ - **People's reactions in the store (store version 26).** For a messenger that sends one person's reaction change at a
19
+ time (WhatsApp), a `watch` event `{ event: "person_reaction", chatId, chatTitle, messageId, reaction }`
20
+ (`PersonReactionEvent`) and a pushed batch's `reactions` (`HistoryReaction`) carry `PersonReaction`
21
+ (`{ person, own, reaction, reactedAt }`, `reaction: null` a take-back). `store.messages.saveReaction` and
22
+ `saveMessages({ reactions })` keep each person's newest in the new `message_reactions` table, so an older history
23
+ push never undoes a newer live change, and write the message's `reactions` totals from them: readers, output and
24
+ MCP see the same shape. tg and max keep sending totals; a message with people's reactions keeps the derived ones.
25
+ See [adapters](docs/dev/ADAPTERS.md).
26
+
27
+ ### Fixed
28
+
29
+ - **A chat or person is found by a name copied with its emoji.** A copied emoji often carries an invisible mark
30
+ (U+FE0F, U+FE0E, or the U+200D joiner) that a typed one lacks, so "Семья❤️" did not find the chat "Семья❤". Names
31
+ now compare without those marks and with accented letters in one form, still ignoring case, wherever a chat or a
32
+ person is named (`pickChat`, `pickPerson`, a stored alias).
33
+ - **A store the process may not write is a permission error, not a crash.** Opening the store for writing where the
34
+ file or its folder is read-only (`EROFS`, `EACCES`, `EPERM`) was reported as `internal_error` "this is a bug".
35
+ It is now `permission_error` (exit 5) with reason `store.not-writable`, and the message names no path.
36
+
37
+ ## 0.380.0 — 21.10.2026
38
+
39
+ ### Added
40
+
41
+ - **A file's copy and the provider's copy of one meeting are one record.** `MeetingSave.fromFile` marks a save read
42
+ from a file; it joins the provider's record of the same occurrence (same series key, start within 15 minutes)
43
+ and only fills a missing transcript there. A provider record saved after a file takes over that record and its
44
+ key, and a provider transcript supersedes transcripts with source `file`. Two provider records never merge.
45
+ See [meetings](docs/dev/MEETINGS.md).
46
+ - **Meetings import text transcripts.** `parseTranscriptText` reads the text form zoom-searcher wrote: the header
47
+ (`Встреча:` title, `Дата:` start as written, `Zoom ID:`, `Говорят:` speakers) and `[hh:mm:ss] Speaker: text`
48
+ blocks, a block running over several lines. `ImportFile.format: "text"` makes `importFiles` use it; `vtt` stays
49
+ the default. The file keeps start times only, so a line ends where the next begins.
50
+ - **An import says the zone it read file times in, and a host can explain its file names.** `RunReport.timezone` is
51
+ set when every imported file's meeting carries one zone; `MeetingCommandDeps.importHelp` adds the host's note
52
+ (file name patterns, how their time is read) under `meetings import --help`.
53
+ ||||||| Stash base
54
+ - **Meeting search finds words spread over neighbouring lines.** `"циан риелтор"~3` in a meeting query finds a
55
+ transcript line with one of the words when the others are at most three lines away in the same transcript; `~0`
56
+ is one line, any order; at most 20. The words widen to garbled spellings as single words do. It needs
57
+ `@wirecat/cli-base` 0.49.0 (`parseLucene({ proximity: true })`), now the pinned version and the peer range.
58
+ See [query language](docs/search/query-language.md#встречи).
59
+
60
+ - **Meeting search finds words speech recognition garbled.** `meetings.searchHits` widens each query word of four
61
+ letters or more to stored words one stem edit away that a Russian dictionary does not know, and to stems one side
62
+ extends, so «риелтор» finds «реелтора», «рилторов» and «дриелтора», «циана» finds «цианом», and «договор» does not
63
+ find «поговорить». Hits come typed form first, then other forms, then garbled spellings; a widened hit carries `match: "corrected"` and `corrections`, and `meeting_hit` items show both. Number words never
64
+ widen, and match only their own forms («семь» finds «семи», not «семьдесят»). See [meetings](docs/dev/MEETINGS.md).
65
+ - **Answers say which zone a typed date was read in, and the UTC window it gave** (cli-base `WC-TIME-3`, `WC-TIME-5`).
66
+ `stats charts`, the MCP `stats` tool and `contacts timeline` put `meta.timezone` (only when a value had no zone of
67
+ its own) and `meta.window: { since?, until? }` in UTC in their answers. `dateWindow` in `./cli`'s moment helpers
68
+ builds both from `--since-time`/`--until-time`. Other date filters follow.
69
+
70
+ ### Changed — may break callers
71
+
72
+ - **Meeting search reads the messages query language.** `meetings.searchHits`, `search meetings` and MCP
73
+ `search_meetings` take `OR`, `(groups)`, `NOT`/`-word`, `"phrases"` (neighbouring words, any form), `exact:` and
74
+ `from:"Name"` — who said the line or wrote the chat message, a person through all their linked identities; an
75
+ ambiguous name is an error with the candidates. `:`, `(`, `"`, `-` and `AND`/`OR`/`NOT` in a meeting query are
76
+ now syntax, and a query of punctuation alone is a `validation_error`. `searchHits` also takes a `QueryAst`;
77
+ `meetingWordsQuery(text)` builds one from text that must stay plain words, as project discovery now does.
78
+ `MEETING_HIT_QUERY` and `parseMeetingQuery` are exported. See [query language](docs/search/query-language.md#встречи).
79
+
80
+ - **Meeting search reads `exact:` and has new cursors.** `exact:word` and `exact:"two words"` in a
81
+ `meetings.searchHits` query keep those words to their typed form, where `exact` was a search word before. Exact
82
+ hits now come before widened ones, so cursors from earlier versions are refused; start the search again.
83
+
84
+ ### Fixed
85
+
86
+ - **A recording archive schedule counts "today" in its own zone.** The window's end, its ten-year floor and its
87
+ look-back started from the UTC date, so a run between local midnight and UTC midnight worked on yesterday. The
88
+ schedule's `timeZone` was stored and never used for this.
89
+ - **A Markdown chat archive says which zone its times are in.** The file printed local wall times with no zone; it
90
+ now has `Times are in <zone>.` under the title.
91
+ - **`chats stats show` and `stats chats retention` read `--since-time`/`--until-time` in their `--timezone`.** They
92
+ used the computer's zone even when `--timezone` was given, and retention's `--until-time` dropped the day it named;
93
+ it now covers that whole local day.
94
+
10
95
  ## 0.379.0 — 21.10.2026
11
96
 
12
97
  ### Changed — may break callers
@@ -131,7 +131,7 @@ export const botWatchCommand = (bot) => annotate(new Command("watch"), { effects
131
131
  else if (event.event === "delete") {
132
132
  context.streams.data(`deleted in ${title(event.chatId)}: message ${event.messageId}\n`);
133
133
  }
134
- else if (event.event === "reaction") {
134
+ else if (event.event === "reaction" || event.event === "person_reaction") {
135
135
  context.streams.data(`reactions in ${title(event.chatId)} on message ${event.messageId}\n`);
136
136
  }
137
137
  else if (event.event === "vote") {
@@ -11,7 +11,7 @@ export { callMeetingSeriesInventoryTool, meetingSeriesInventoryTool } from "../m
11
11
  export { registerLinkConversationsPrompt } from "../mcp/prompts.js";
12
12
  export { agentBody, agentJson, agentResult, UNTRUSTED, UNTRUSTED_INSTRUCTION } from "../mcp/text.js";
13
13
  export { type AppIdentity, envName, readOnlyLockOf, readOnlyRefusal } from "../ports/app.js";
14
- export type { AccountEditing, AccountHealth, AccountRecords, AccountSettings, AccountStanding, AccountTools, After, BotChats, ChannelComments, ChatDeletion, ChatFolders, ChatReading, ContactBook, Download, ForumControl, ForumState, GroupAdmin, GroupModeration, HistoryBatch, HistoryChatEvent, HistoryStar, HistoryVote, InviteLinks, JoinRequests, LiveUpdates, MessageButtons, MessageEditing, MessageMedia, MessagePermalinks, MessagePins, MessagePolls, MessageReactions, MessageSearch, MessageStars, MessengerAdapter, MessengerCore, NewPoll, OfficialStats, PassiveUpdates, PersonProfiles, ProfileChange, ProfilePhotos, PushedHistory, ReadState, RemoteFile, ScheduledMessages, SenderIdentities, SenderSearch, SendOptions, Sent, ServerQuery, ServerReads, ThreadAddressing, TopicEditing, TopicHistory, TopicReadState, Transcript, } from "../ports/messenger.js";
14
+ export type { AccountEditing, AccountHealth, AccountRecords, AccountSettings, AccountStanding, AccountTools, After, BotChats, ChannelComments, ChatDeletion, ChatFolders, ChatReading, ContactBook, Download, ForumControl, ForumState, GroupAdmin, GroupModeration, HistoryBatch, HistoryChatEvent, HistoryReaction, HistoryStar, HistoryVote, InviteLinks, JoinRequests, LiveUpdates, MessageButtons, MessageEditing, MessageMedia, MessagePermalinks, MessagePins, MessagePolls, MessageReactions, MessageSearch, MessageStars, MessengerAdapter, MessengerCore, NewPoll, OfficialStats, PassiveUpdates, PersonProfiles, ProfileChange, ProfilePhotos, PushedHistory, ReadState, RemoteFile, ScheduledMessages, SenderIdentities, SenderSearch, SendOptions, Sent, ServerQuery, ServerReads, ThreadAddressing, TopicEditing, TopicHistory, TopicReadState, Transcript, } from "../ports/messenger.js";
15
15
  export type { Fetching, MediaOption, MessengerTraits } from "../ports/traits.js";
16
16
  export { senderFacts } from "../replies/dry-run.js";
17
17
  export { type ReplyRender, replyRenderer } from "../replies/rendering.js";
@@ -309,6 +309,11 @@ export declare const meetingPreparationAnswer: (packet: MeetingPreparation) => i
309
309
  id: string;
310
310
  excerpt: string;
311
311
  offset_ms: number | null;
312
+ match?: "corrected" | "words" | undefined;
313
+ corrections?: {
314
+ from: string;
315
+ to: string[];
316
+ }[] | undefined;
312
317
  source?: {
313
318
  ref: string;
314
319
  title: string | null;
@@ -29,6 +29,11 @@ export declare const mapPreparationProjectEvidence: (section: NonNullable<Meetin
29
29
  id: string;
30
30
  excerpt: string;
31
31
  offset_ms: number | null;
32
+ match?: "corrected" | "words" | undefined;
33
+ corrections?: {
34
+ from: string;
35
+ to: string[];
36
+ }[] | undefined;
32
37
  source?: {
33
38
  ref: string;
34
39
  title: string | null;
@@ -338,6 +338,11 @@ export declare const readMeetingPreparation: (read: MeetingPreparationReader, in
338
338
  id: string;
339
339
  excerpt: string;
340
340
  offset_ms: number | null;
341
+ match?: "corrected" | "words" | undefined;
342
+ corrections?: {
343
+ from: string;
344
+ to: string[];
345
+ }[] | undefined;
341
346
  source?: {
342
347
  ref: string;
343
348
  title: string | null;
@@ -21,7 +21,11 @@ export const statsCommand = (messenger) => annotate(new Command("show"), { effec
21
21
  refuseJsonl(context.format);
22
22
  const { stats, account } = await context.withServices(async (services) => ({
23
23
  stats: await services.chats.stats(chat, {
24
- ...(sinceTime === undefined ? {} : { since: momentOf(sinceTime, "--since-time") }),
24
+ ...(sinceTime === undefined
25
+ ? {}
26
+ : {
27
+ since: momentOf(sinceTime, "--since-time", Date.now(), timezone === undefined ? {} : { zone: timezone }),
28
+ }),
25
29
  ...(by ? { by } : {}),
26
30
  ...(timezone === undefined ? {} : { timezone }),
27
31
  }),
@@ -4,7 +4,7 @@ import { Command } from "commander";
4
4
  import { REGISTRIES, registriesCover } from "../../botcheck/registries.js";
5
5
  import { levelFor } from "../../sends/permissions.js";
6
6
  import { phoneOf } from "../../services/index.js";
7
- import { momentOf } from "../../services/moment.js";
7
+ import { dateWindow, momentOf } from "../../services/moment.js";
8
8
  import { maskedAccount } from "../../services/people.js";
9
9
  import { CHAT_MESSAGES, CONTEXT_BYTES, CONTEXT_MESSAGES } from "../../services/person-context.js";
10
10
  import { SCOPES, TIMELINE_ITEMS } from "../../services/person-timeline.js";
@@ -150,10 +150,14 @@ export const contactsCommand = (messenger) => {
150
150
  if (scope !== undefined && !SCOPES.includes(scope))
151
151
  throw new CliError("validation_error", `--scope is personal or work, not "${scope}"`);
152
152
  const context = messengerContext(this, messenger);
153
+ const window = dateWindow({
154
+ ...(sinceTime === undefined ? {} : { sinceTime }),
155
+ ...(untilTime === undefined ? {} : { untilTime }),
156
+ });
153
157
  const found = await context.withServices((services) => services.people.timeline(person, {
154
158
  ...(scope === undefined ? {} : { scope: scope }),
155
- ...(sinceTime === undefined ? {} : { since: momentOf(sinceTime, "--since-time") }),
156
- ...(untilTime === undefined ? {} : { until: momentOf(untilTime, "--until-time", Date.now(), { end: true }) }),
159
+ ...(window.since === undefined ? {} : { since: window.since }),
160
+ ...(window.until === undefined ? {} : { until: window.until }),
157
161
  ...(limit === undefined ? {} : { limit }),
158
162
  ...(cursor === undefined ? {} : { cursor }),
159
163
  }));
@@ -170,7 +174,7 @@ export const contactsCommand = (messenger) => {
170
174
  ...(sinceTime === undefined ? {} : { since_time: sinceTime }),
171
175
  ...(untilTime === undefined ? {} : { until_time: untilTime }),
172
176
  },
173
- ...(pending > 0 ? { meta: { truncated: true } } : {}),
177
+ meta: { ...window.meta, ...(pending > 0 ? { truncated: true } : {}) },
174
178
  }));
175
179
  });
176
180
  annotate(contacts.command("check"), { effects: REMOTE_READ })
@@ -1,3 +1,4 @@
1
+ import type { Meta } from "@wirecat/cli-base/standards";
1
2
  import type { InferOutput } from "valibot";
2
3
  import type { ChartData } from "../../charts/model.js";
3
4
  import type { CounterState } from "../../domain/counters.js";
@@ -62,7 +63,7 @@ export declare const chartAnswer: (chart: ChartData, file?: {
62
63
  format: "svg" | "png";
63
64
  width: number;
64
65
  height: number;
65
- }) => ItemBody<Of<"chart">>;
66
+ }, meta?: Partial<Meta>) => ItemBody<Of<"chart">>;
66
67
  /** A report or a chart is one object: `--jsonl` would print one line pretending to be a stream. */
67
68
  export declare const refuseJsonl: (format: string) => void;
68
69
  /** `stats chats show`, CLI and MCP alike; a chat the store does not hold whole names the sync that fills it. */
@@ -326,7 +326,7 @@ export const officialAnswer = (stats, provider) => {
326
326
  graphs: stats.graphs,
327
327
  }));
328
328
  };
329
- export const chartAnswer = (chart, file) => itemOf(defined({ ...chart, file }));
329
+ export const chartAnswer = (chart, file, meta) => itemOf(defined({ ...chart, file }), meta ? { meta } : {});
330
330
  /** A report or a chart is one object: `--jsonl` would print one line pretending to be a stream. */
331
331
  export const refuseJsonl = (format) => {
332
332
  if (format === "jsonl")
@@ -9,7 +9,7 @@ import { CHART_SIZE } from "../../charts/model.js";
9
9
  import { chartPng } from "../../charts/png.js";
10
10
  import { chartRenderer } from "../../charts/render.js";
11
11
  import { EVENTS_DAYS } from "../../services/chats.js";
12
- import { momentOf } from "../../services/moment.js";
12
+ import { dateWindow } from "../../services/moment.js";
13
13
  import { destroysNothing, LOCAL_WRITE } from "../effects.js";
14
14
  import { renderAnswer } from "../envelope.js";
15
15
  import { adminStatisticsCommand } from "./admin-statistics-command.js";
@@ -61,13 +61,8 @@ export const statsCommand = (messenger, loadRenderer = chartRenderer) => {
61
61
  throw new CliError("validation_error", "--output takes a .svg or .png file; image output to stdout is unavailable");
62
62
  }
63
63
  const timezone = timezoneOf(given);
64
- const found = await context.withServices((services) => services.chats.stats(chat, {
65
- by,
66
- timezone,
67
- ...(sinceTime === undefined
68
- ? {}
69
- : { since: momentOf(sinceTime, "--since-time", Date.now(), { zone: timezone }) }),
70
- }));
64
+ const window = dateWindow({ ...(sinceTime === undefined ? {} : { sinceTime }), zone: timezone });
65
+ const found = await context.withServices((services) => services.chats.stats(chat, { by, timezone, ...(window.since === undefined ? {} : { since: window.since }) }));
71
66
  const chart = chatChart(found, { kind: chartKind, by, timezone });
72
67
  let chartFile;
73
68
  if (output !== undefined) {
@@ -87,7 +82,7 @@ export const statsCommand = (messenger, loadRenderer = chartRenderer) => {
87
82
  }
88
83
  if (chart.partial)
89
84
  context.renderer.note("partial data — the chart shows lower bounds");
90
- renderAnswer(this, context, chartAnswer(chart, chartFile));
85
+ renderAnswer(this, context, chartAnswer(chart, chartFile, window.meta));
91
86
  });
92
87
  return stats;
93
88
  };
@@ -1,6 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { capability, throughWrapper } from "../../ports/messenger.js";
3
- import { isId, pickPerson } from "../../resolve.js";
3
+ import { foldName, isId, pickPerson } from "../../resolve.js";
4
4
  import { codeOf } from "../../sends/guarded.js";
5
5
  import { chatIdIn } from "../../services/message-resolution.js";
6
6
  const REKEY_PACING = { lockMs: 50, pauseMs: 25 };
@@ -212,8 +212,8 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
212
212
  const { id, held } = namesFromStore ? await storedChat(opened, scoped, namesFromStore, reference) : {};
213
213
  if (!isId(reference)) {
214
214
  const people = await opened.people.people(scoped.provider, { account: scoped.account });
215
- const wanted = reference.trim().toLowerCase();
216
- if (people.all().some((person) => person.alias?.toLowerCase().includes(wanted))) {
215
+ const wanted = foldName(reference.trim());
216
+ if (people.all().some((person) => person.alias && foldName(person.alias).includes(wanted))) {
217
217
  const person = pickPerson(reference, people);
218
218
  const chats = (await opened.chats.chatsWith(scoped, person.id)).filter((chat) => chat.kind === "dialog");
219
219
  if (chats.length !== 1 || !chats[0])
@@ -517,7 +517,7 @@ const fill = async (store, account, batch) => {
517
517
  await store.people.savePeople(account, batch.people);
518
518
  const byChat = new Map();
519
519
  const of = (chatId) => {
520
- const held = byChat.get(chatId) ?? { messages: [], votes: [], stars: [] };
520
+ const held = byChat.get(chatId) ?? { messages: [], votes: [], reactions: [], stars: [] };
521
521
  byChat.set(chatId, held);
522
522
  return held;
523
523
  };
@@ -525,10 +525,12 @@ const fill = async (store, account, batch) => {
525
525
  of(message.chatId).messages.push(message);
526
526
  for (const { chatId, messageId, vote } of batch.votes ?? [])
527
527
  of(chatId).votes.push({ messageId, vote });
528
+ for (const { chatId, messageId, reaction } of batch.reactions ?? [])
529
+ of(chatId).reactions.push({ messageId, reaction });
528
530
  for (const { chatId, messageId, starred } of batch.stars ?? [])
529
531
  of(chatId).stars.push({ messageId, starred });
530
- for (const [chatId, { messages, votes, stars }] of byChat)
531
- await store.messages.saveMessages(account, chatId, messages, { via: "history", votes, stars });
532
+ for (const [chatId, { messages, votes, reactions, stars }] of byChat)
533
+ await store.messages.saveMessages(account, chatId, messages, { via: "history", votes, reactions, stars });
532
534
  const changes = new Map();
533
535
  for (const { chatId, change } of batch.events ?? [])
534
536
  changes.set(chatId, [...(changes.get(chatId) ?? []), change]);
@@ -537,7 +539,7 @@ const fill = async (store, account, batch) => {
537
539
  };
538
540
  /**
539
541
  * Each change as the store keeps it: a message or an edit upserted, a deletion a tombstone, reactions replaced, a
540
- * voter's vote replacing their older one, a star set or taken off, a chat event kept once.
542
+ * voter's vote or a person's reaction replacing their older one, a star set or taken off, a chat event kept once.
541
543
  */
542
544
  const keep = async (store, account, event, seenAt, among) => {
543
545
  switch (event.event) {
@@ -553,6 +555,9 @@ const keep = async (store, account, event, seenAt, among) => {
553
555
  case "vote":
554
556
  await store.messages.saveVote(account, event.chatId, event.messageId, event.vote);
555
557
  return;
558
+ case "person_reaction":
559
+ await store.messages.saveReaction(account, event.chatId, event.messageId, event.reaction);
560
+ return;
556
561
  case "star":
557
562
  await store.messages.saveStar(account, event.chatId, event.messageId, event.starred);
558
563
  return;
@@ -110,6 +110,12 @@ const describe = (event, render) => {
110
110
  const choice = answers.length > 0 ? answers.join(", ") : "took it back";
111
111
  return `vote in ${event.chatTitle ?? event.chatId} on poll ${event.messageId}: ${person.name ?? person.id} — ${choice}\n`;
112
112
  }
113
+ case "person_reaction": {
114
+ const { person, reaction } = event.reaction;
115
+ const who = person.name ?? person.id;
116
+ const what = reaction === null ? "took their reaction back" : `reacted ${reaction}`;
117
+ return `${who} ${what} in ${event.chatTitle ?? event.chatId} on message ${event.messageId}\n`;
118
+ }
113
119
  case "star":
114
120
  return `${event.starred ? "starred" : "unstarred"} in ${event.chatTitle ?? event.chatId}: message ${event.messageId}\n`;
115
121
  case "chat_event": {
@@ -263,6 +263,19 @@ export interface Reactions {
263
263
  mine: string | null;
264
264
  total: number;
265
265
  }
266
+ /**
267
+ * One person's reaction to a message, for a messenger that sends a reaction change at a time (WhatsApp) instead of
268
+ * totals. `reaction` is `null` when they took it back. The store keeps each person's newest by `reactedAt` and derives
269
+ * the message's `Reactions` from them.
270
+ */
271
+ export interface PersonReaction {
272
+ person: Member;
273
+ /** The owner's own reaction, which becomes `Reactions.mine`. */
274
+ own: boolean;
275
+ reaction: string | null;
276
+ /** ISO 8601 — when the person reacted, as the messenger says, not when it arrived. */
277
+ reactedAt: string;
278
+ }
266
279
  /** The message a reply answers or a forward carries. */
267
280
  export interface QuotedMessage {
268
281
  id: Id;
@@ -384,6 +397,14 @@ export interface PollVoteEvent {
384
397
  messageId: Id;
385
398
  vote: PollVote;
386
399
  }
400
+ /** One person reacted to `messageId`, changed their reaction, or took it back; saved, an older one never wins. */
401
+ export interface PersonReactionEvent {
402
+ event: "person_reaction";
403
+ chatId: Id;
404
+ chatTitle: string | null;
405
+ messageId: Id;
406
+ reaction: PersonReaction;
407
+ }
387
408
  /** The owner starred a message or took the star off, here or on another device; saved, the same twice is one. */
388
409
  export interface MessageStarEvent {
389
410
  event: "star";
@@ -399,11 +420,11 @@ export interface ChatEventNotice {
399
420
  chatTitle: string | null;
400
421
  change: ChatEvent;
401
422
  }
402
- /** What a listening connection reports: a new message, a change to one, a vote, a star, or a change to a chat. */
423
+ /** What a listening connection reports: a new message, a change to one, a vote, a reaction, a star, or a change to a chat. */
403
424
  export type MessageEvent = {
404
425
  event: "message";
405
426
  message: MessageHit;
406
- } | MessageChange | PollVoteEvent | MessageStarEvent | ChatEventNotice;
427
+ } | MessageChange | PollVoteEvent | PersonReactionEvent | MessageStarEvent | ChatEventNotice;
407
428
  /** One chat's share of `inbox`: other people's messages since the last check, oldest first. */
408
429
  export interface InboxChat extends Pick<Chat, "id" | "title" | "kind" | "unreadCount"> {
409
430
  messages: Message[];
@@ -2106,6 +2106,11 @@ export declare const ENTITIES: {
2106
2106
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
2107
2107
  readonly excerpt: v.StringSchema<undefined>;
2108
2108
  readonly offset_ms: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
2109
+ readonly match: v.OptionalSchema<v.PicklistSchema<["words", "corrected"], undefined>, undefined>;
2110
+ readonly corrections: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
2111
+ readonly from: v.StringSchema<undefined>;
2112
+ readonly to: v.ArraySchema<v.StringSchema<undefined>, undefined>;
2113
+ }, undefined>, undefined>, undefined>;
2109
2114
  readonly source: v.OptionalSchema<v.StrictObjectSchema<{
2110
2115
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
2111
2116
  readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -2257,6 +2262,11 @@ export declare const ENTITIES: {
2257
2262
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
2258
2263
  readonly excerpt: v.StringSchema<undefined>;
2259
2264
  readonly offset_ms: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
2265
+ readonly match: v.OptionalSchema<v.PicklistSchema<["words", "corrected"], undefined>, undefined>;
2266
+ readonly corrections: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
2267
+ readonly from: v.StringSchema<undefined>;
2268
+ readonly to: v.ArraySchema<v.StringSchema<undefined>, undefined>;
2269
+ }, undefined>, undefined>, undefined>;
2260
2270
  readonly source: v.OptionalSchema<v.StrictObjectSchema<{
2261
2271
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
2262
2272
  readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -2614,6 +2624,11 @@ export declare const ENTITIES: {
2614
2624
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
2615
2625
  readonly excerpt: v.StringSchema<undefined>;
2616
2626
  readonly offset_ms: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
2627
+ readonly match: v.OptionalSchema<v.PicklistSchema<["words", "corrected"], undefined>, undefined>;
2628
+ readonly corrections: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
2629
+ readonly from: v.StringSchema<undefined>;
2630
+ readonly to: v.ArraySchema<v.StringSchema<undefined>, undefined>;
2631
+ }, undefined>, undefined>, undefined>;
2617
2632
  readonly source: v.OptionalSchema<v.StrictObjectSchema<{
2618
2633
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
2619
2634
  readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -309,6 +309,11 @@ export declare const meetingPreparation: v.StrictObjectSchema<{
309
309
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
310
310
  readonly excerpt: v.StringSchema<undefined>;
311
311
  readonly offset_ms: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
312
+ readonly match: v.OptionalSchema<v.PicklistSchema<["words", "corrected"], undefined>, undefined>;
313
+ readonly corrections: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
314
+ readonly from: v.StringSchema<undefined>;
315
+ readonly to: v.ArraySchema<v.StringSchema<undefined>, undefined>;
316
+ }, undefined>, undefined>, undefined>;
312
317
  readonly source: v.OptionalSchema<v.StrictObjectSchema<{
313
318
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
314
319
  readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -27,6 +27,7 @@ const OTHERS = [
27
27
  validation("file.locked", "another process holds the file's lock"),
28
28
  ["profile.wrong-account", "authentication_error", "the signed-in source account does not match this profile"],
29
29
  validation("store.refused", "the local store refused the document text"),
30
+ ["store.not-writable", "permission_error", "the message store cannot be written from this process"],
30
31
  validation("catalog.scope-limit", "the selected catalog exceeds its entry limit"),
31
32
  validation("evidence.row-too-large", "one evidence row is over its size limit"),
32
33
  validation("index.not-ready", "the search index is still being built"),
@@ -376,6 +376,11 @@ export declare const callMeetingPreparationTool: (read: MeetingPreparationReader
376
376
  id: string;
377
377
  excerpt: string;
378
378
  offset_ms: number | null;
379
+ match?: "corrected" | "words" | undefined;
380
+ corrections?: {
381
+ from: string;
382
+ to: string[];
383
+ }[] | undefined;
379
384
  source?: {
380
385
  ref: string;
381
386
  title: string | null;
@@ -7,7 +7,7 @@ import { chartRenderer } from "../../charts/render.js";
7
7
  import { sealed } from "../../cli/envelope.js";
8
8
  import { chartAnswer } from "../../cli/messenger/stats-answers.js";
9
9
  import { servicesFor, storedDeps } from "../../services/index.js";
10
- import { momentOf } from "../../services/moment.js";
10
+ import { dateWindow } from "../../services/moment.js";
11
11
  import { chatOf, Picture, READ, tool } from "../tool.js";
12
12
  export const statsTools = (messenger) => ({
13
13
  stats_charts: tool({
@@ -29,18 +29,21 @@ export const statsTools = (messenger) => ({
29
29
  stored: async (store, account, args, defaults) => {
30
30
  const timezone = timezoneOf(args.timezone);
31
31
  const by = args.by ?? "day";
32
+ const window = dateWindow({
33
+ ...(args.since_time === undefined ? {} : { sinceTime: args.since_time }),
34
+ zone: timezone,
35
+ names: ["since_time", "until_time"],
36
+ });
32
37
  const stats = await servicesFor(storedDeps(messenger, store, account, defaults.guard)).chats.stats(args.chat, {
33
38
  by,
34
39
  timezone,
35
- ...(args.since_time === undefined
36
- ? {}
37
- : { since: momentOf(args.since_time, "since_time", Date.now(), { zone: timezone }) }),
40
+ ...(window.since === undefined ? {} : { since: window.since }),
38
41
  });
39
42
  const chart = chatChart(stats, { kind: args.chart_kind ?? "messages", by, timezone });
40
43
  if (args.format !== "png")
41
- return chartAnswer(chart);
44
+ return chartAnswer(chart, undefined, window.meta);
42
45
  const image = await chartPng(await (await chartRenderer()).render(chart, CHART_SIZE));
43
- return new Picture(image.bytes, image.mimeType, sealed(chartAnswer(chart)));
46
+ return new Picture(image.bytes, image.mimeType, sealed(chartAnswer(chart, undefined, window.meta)));
44
47
  },
45
48
  }),
46
49
  });
@@ -15,6 +15,12 @@ export declare const meetingHit: v.StrictObjectSchema<{
15
15
  readonly excerpt: v.StringSchema<undefined>;
16
16
  /** How far into the meeting the passage is. */
17
17
  readonly offset_ms: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
18
+ /** `corrected`: a query word matched only a garbled form of itself, listed in `corrections`. */
19
+ readonly match: v.OptionalSchema<v.PicklistSchema<["words", "corrected"], undefined>, undefined>;
20
+ readonly corrections: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
21
+ readonly from: v.StringSchema<undefined>;
22
+ readonly to: v.ArraySchema<v.StringSchema<undefined>, undefined>;
23
+ }, undefined>, undefined>, undefined>;
18
24
  readonly source: v.OptionalSchema<v.StrictObjectSchema<{
19
25
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
20
26
  readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -16,6 +16,9 @@ export const meetingHit = v.strictObject({
16
16
  excerpt: text,
17
17
  /** How far into the meeting the passage is. */
18
18
  offset_ms: v.nullable(count),
19
+ /** `corrected`: a query word matched only a garbled form of itself, listed in `corrections`. */
20
+ match: v.optional(v.picklist(["words", "corrected"])),
21
+ corrections: v.optional(v.array(v.strictObject({ from: text, to: v.array(text) }))),
19
22
  source: v.optional(v.strictObject({
20
23
  ref: reference,
21
24
  title: v.nullable(text),
@@ -185,6 +188,8 @@ export const meetingHitItem = (hit) => ({
185
188
  id: String(hit.id),
186
189
  excerpt: hit.text,
187
190
  offset_ms: hit.startMs,
191
+ ...(hit.match === undefined ? {} : { match: hit.match }),
192
+ ...(hit.corrections === undefined ? {} : { corrections: hit.corrections }),
188
193
  ...(hit.source === undefined
189
194
  ? {}
190
195
  : {
@@ -7,6 +7,8 @@ export interface MeetingCommandDeps {
7
7
  accountId: number;
8
8
  source?: MeetingSource;
9
9
  readFiles?: (folder: string) => Promise<ImportFile[]>;
10
+ /** The host's file names and how it reads their time, shown in `meetings import --help`. */
11
+ importHelp?: string;
10
12
  writeFile?: (path: string, content: string) => Promise<void>;
11
13
  write: (value: unknown, format: "json" | "jsonl" | "text") => void;
12
14
  now?: () => number;
@@ -122,9 +122,12 @@ export const addMeetingCommands = (program, deps) => {
122
122
  lookbackMs: opts.lookbackDays * 86400000,
123
123
  }), command);
124
124
  });
125
- output(annotate(group.command("import"), { effects: LOCAL_WRITE })
125
+ const importCommand = annotate(group.command("import"), { effects: LOCAL_WRITE })
126
126
  .description("import downloaded meeting transcripts")
127
- .argument("<folder>", "folder of transcripts downloaded from the provider")).action(async (folder, _opts, command) => {
127
+ .argument("<folder>", "folder of transcripts downloaded from the provider");
128
+ if (deps.importHelp)
129
+ importCommand.addHelpText("after", `\n${deps.importHelp}`);
130
+ output(importCommand).action(async (folder, _opts, command) => {
128
131
  if (!deps.readFiles)
129
132
  throw new MeetingError("configuration_error", "file reader is unavailable");
130
133
  const files = await deps.readFiles(folder);
@@ -28,7 +28,7 @@ export const createMeetingSearchCommand = (program) => {
28
28
  const command = search
29
29
  .command("meetings")
30
30
  .description("find words in stored meetings, indexing new ones first")
31
- .argument("<query>", "words to find; each also matches its other word forms")
31
+ .argument("<query>", 'words, OR, (groups), -word, "a phrase", "a b"~N (within N lines), exact:word, from:"Name" (who said the line); a word also matches its other forms and spellings speech recognition garbled')
32
32
  .option("--meeting-id <id>", "only this stored meeting", integer)
33
33
  .option("--identity-id <id>", "meetings with this participant identity; does not say who spoke a hit", integer)
34
34
  .option("--person-id <id>", "meetings with a participant linked to this person", integer)
@@ -0,0 +1,12 @@
1
+ import { type QueryAst } from "@wirecat/cli-base/query";
2
+ /** A transcript line holds a sentence or two; further than this is a different part of the meeting. */
3
+ export declare const MOST_LINES_APART = 20;
4
+ /** The messages query language over meeting text: the same fields, a narrower set of operators. */
5
+ export declare const MEETING_HIT_QUERY: import("@wirecat/cli-base/query").QueryRegistry;
6
+ /** What a person typed: the CLI, MCP and batch queries. */
7
+ export declare const parseMeetingQuery: (query: string) => QueryAst;
8
+ /**
9
+ * Words from a name or a note, never parsed: a project called «C++ (beta)» must not become syntax. Each word is
10
+ * required, in any form.
11
+ */
12
+ export declare const meetingWordsQuery: (text: string) => QueryAst;