@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
@@ -0,0 +1,66 @@
1
+ import { readFileSync } from "node:fs";
2
+ let loaded;
3
+ /**
4
+ * Hunspell's own check, an ending at a time, instead of nspell's: nspell expands the 146,269 entries into
5
+ * 1.44 million forms (1.4 s, 172 MB) where this loads in about 0.15 s and agrees on every word it knows.
6
+ */
7
+ const dictionary = () => {
8
+ if (loaded)
9
+ return loaded;
10
+ const file = (name) => readFileSync(new URL(name, import.meta.resolve("dictionary-ru")), "utf8");
11
+ const stems = new Map();
12
+ for (const line of file("index.dic").split("\n").slice(1)) {
13
+ const [entry, flags = ""] = line.trim().split("/");
14
+ if (!entry)
15
+ continue;
16
+ // Names and abbreviations are capitalised in the dictionary; a transcript's word index is lower case.
17
+ const word = entry.toLowerCase();
18
+ stems.set(word, (stems.get(word) ?? "") + flags);
19
+ }
20
+ const suffixes = new Map();
21
+ let longest = 0;
22
+ for (const line of file("index.aff").split("\n")) {
23
+ const [kind, flag, strip, append, condition] = line.trim().split(/\s+/);
24
+ if (kind !== "SFX" || flag === undefined || strip === undefined || append === undefined || !condition)
25
+ continue;
26
+ const ending = append === "0" ? "" : append;
27
+ const rules = suffixes.get(ending) ?? [];
28
+ rules.push({
29
+ flag,
30
+ strip: strip === "0" ? "" : strip,
31
+ condition: new RegExp(`${condition === "." ? "" : condition}$`),
32
+ });
33
+ suffixes.set(ending, rules);
34
+ longest = Math.max(longest, ending.length);
35
+ }
36
+ loaded = { stems, suffixes, longest };
37
+ return loaded;
38
+ };
39
+ const spelled = (word) => {
40
+ const { stems, suffixes, longest } = dictionary();
41
+ if (stems.has(word))
42
+ return true;
43
+ for (let cut = 0; cut <= Math.min(word.length - 1, longest); cut++)
44
+ for (const rule of suffixes.get(word.slice(word.length - cut)) ?? []) {
45
+ const stem = word.slice(0, word.length - cut) + rule.strip;
46
+ if (stems.get(stem)?.includes(rule.flag) && rule.condition.test(stem))
47
+ return true;
48
+ }
49
+ return false;
50
+ };
51
+ /**
52
+ * Whether a word the index stores is a real Russian word. The index folds й into и and ё into е, so each is
53
+ * tried back one letter at a time.
54
+ */
55
+ export const knownRussianWord = (word) => {
56
+ if (spelled(word))
57
+ return true;
58
+ for (const [folded, original] of [
59
+ ["и", "й"],
60
+ ["е", "ё"],
61
+ ])
62
+ for (let at = word.indexOf(folded); at >= 0; at = word.indexOf(folded, at + 1))
63
+ if (spelled(word.slice(0, at) + original + word.slice(at + 1)))
64
+ return true;
65
+ return false;
66
+ };
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { CliError } from "@wirecat/cli-base";
3
3
  import { normalizeVocabularyName } from "../domain/vocabulary.js";
4
- import { canonicalMeetingReference, parseMeetingReference, validateMeetingHitSearch, } from "../meetings/index.js";
4
+ import { canonicalMeetingReference, meetingWordsQuery, parseMeetingReference, validateMeetingHitSearch, } from "../meetings/index.js";
5
5
  const scopeKeys = [
6
6
  "accountId",
7
7
  "meetingId",
@@ -134,7 +134,7 @@ export const readMeetingProjectDiscovery = async (ports, projectName, original)
134
134
  const previous = cursorOf(cursor, key, terms.length);
135
135
  const termIndex = previous?.term ?? 0;
136
136
  const term = terms[termIndex];
137
- const result = await ports.search.searchHits(term.name, {
137
+ const result = await ports.search.searchHits(meetingWordsQuery(term.name), {
138
138
  ...input,
139
139
  ...(previous?.after ? { after: previous.after } : {}),
140
140
  });
@@ -12,3 +12,24 @@ export declare const momentOf: (reference: string, flag?: string, now?: number,
12
12
  * is not a time; a caller that clears a date checks for it first.
13
13
  */
14
14
  export declare const aheadOf: (reference: string, flag: string, now?: number, at?: MomentZone) => number;
15
+ export interface DateWindow {
16
+ since?: number;
17
+ until?: number;
18
+ /** What the answer says about it: the zone a value without one was read in, and the bounds in UTC. */
19
+ meta: {
20
+ timezone?: string;
21
+ window?: {
22
+ since?: string;
23
+ until?: string;
24
+ };
25
+ };
26
+ }
27
+ /** `--since-time` and `--until-time` together, as the filter and the `meta` an answer reports it by. */
28
+ export declare const dateWindow: ({ sinceTime, untilTime, zone, now, names, }: {
29
+ sinceTime?: string;
30
+ untilTime?: string;
31
+ zone?: string;
32
+ now?: number;
33
+ /** How the caller spells the two, for the error: `since_time` over MCP. */
34
+ names?: [string, string];
35
+ }) => DateWindow;
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { timezoneOf } from "@wirecat/cli-base/query";
2
3
  import { calendarMoment } from "../domain/moment.js";
3
4
  const AGO = /^(\d+)(m|h|d)$/;
4
5
  const AGO_MS = { m: 60_000, h: 3_600_000, d: 86_400_000 };
@@ -43,3 +44,21 @@ export const aheadOf = (reference, flag, now = Date.now(), at = {}) => {
43
44
  }
44
45
  return time;
45
46
  };
47
+ /** `--since-time` and `--until-time` together, as the filter and the `meta` an answer reports it by. */
48
+ export const dateWindow = ({ sinceTime, untilTime, zone, now = Date.now(), names = ["--since-time", "--until-time"], }) => {
49
+ const since = sinceTime === undefined ? undefined : momentOf(sinceTime, names[0], now, { zone });
50
+ const until = untilTime === undefined ? undefined : momentOf(untilTime, names[1], now, { zone, end: true });
51
+ const readInZone = [sinceTime, untilTime].some((value) => value !== undefined && DATE.test(value.trim()) && !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value.trim()));
52
+ const window = {
53
+ ...(since === undefined ? {} : { since: new Date(since).toISOString() }),
54
+ ...(until === undefined ? {} : { until: new Date(until).toISOString() }),
55
+ };
56
+ return {
57
+ ...(since === undefined ? {} : { since }),
58
+ ...(until === undefined ? {} : { until }),
59
+ meta: {
60
+ ...(readInZone ? { timezone: timezoneOf(zone) } : {}),
61
+ ...(since === undefined && until === undefined ? {} : { window }),
62
+ },
63
+ };
64
+ };
@@ -13,16 +13,19 @@ export const observationDuration = (text, flag) => {
13
13
  export const retentionService = (deps) => ({
14
14
  report: async (chat, query = {}) => {
15
15
  const cutoff = Date.now();
16
+ const zone = timezoneOf(query.timezone);
16
17
  const options = {
17
- since: query.sinceTime === undefined ? cutoff - 90 * 86_400_000 : momentOf(query.sinceTime, "--since-time"),
18
- until: query.untilTime === undefined ? cutoff : momentOf(query.untilTime, "--until-time"),
18
+ since: query.sinceTime === undefined
19
+ ? cutoff - 90 * 86_400_000
20
+ : momentOf(query.sinceTime, "--since-time", cutoff, { zone }),
21
+ until: query.untilTime === undefined ? cutoff : momentOf(query.untilTime, "--until-time", cutoff, { zone, end: true }),
19
22
  cutoff,
20
23
  checkpoints: (query.checkpoints ?? "1d,7d,30d")
21
24
  .split(",")
22
25
  .map((one) => observationDuration(one.trim(), "--checkpoints")),
23
26
  within: observationDuration(query.within ?? "7d", "--within"),
24
27
  by: query.by ?? "week",
25
- timezone: timezoneOf(query.timezone),
28
+ timezone: zone,
26
29
  limit: query.limit ?? 20,
27
30
  };
28
31
  const store = await deps.store(), account = await deps.account();
@@ -1,5 +1,5 @@
1
1
  import type { CounterObservations } from "../domain/counters.js";
2
- import type { Id, Message, Page, PollVote, Reactions, WindowedMessage } from "../domain/models.js";
2
+ import type { Id, Message, Page, PersonReaction, PollVote, Reactions, WindowedMessage } from "../domain/models.js";
3
3
  import { type DocumentLinkMessage } from "./sqlite/document-links.js";
4
4
  import type { StoreContext } from "./sqlite/open.js";
5
5
  import type { AccountKey, DeletionScope, IngestedVia, MessageFilter, SenderChatStats, StoredHit } from "./types.js";
@@ -10,7 +10,8 @@ export interface AccountMessages {
10
10
  * `seenAt` is when the messenger was asked: a message it still returned is not deleted, so a
11
11
  * tombstone older than that is lifted. A tombstone set after it stays — the deletion is newer.
12
12
  *
13
- * `votes` and `stars` are saved after the messages in the same transaction, as `saveVote` and `saveStar` save each.
13
+ * `votes`, `reactions` and `stars` are saved after the messages in the same transaction, as `saveVote`,
14
+ * `saveReaction` and `saveStar` save each.
14
15
  *
15
16
  * `typedChatId` is the id the owner wrote to when the messenger answered `chatId`: a chat row made for `chatId`
16
17
  * takes the typed row's title, kind and privacy, and is never more visible than it.
@@ -22,6 +23,10 @@ export interface AccountMessages {
22
23
  messageId: Id;
23
24
  vote: PollVote;
24
25
  }[];
26
+ reactions?: {
27
+ messageId: Id;
28
+ reaction: PersonReaction;
29
+ }[];
25
30
  stars?: {
26
31
  messageId: Id;
27
32
  starred: boolean;
@@ -89,10 +94,18 @@ export interface AccountMessages {
89
94
  chatId?: Id;
90
95
  among?: DeletionScope;
91
96
  }): Promise<number>;
92
- /** A message's reactions as they are now; answers whether the message is held at all. */
97
+ /**
98
+ * A message's reactions as they are now; answers whether the message is held at all. Ignored where the store holds
99
+ * people's own reactions to it (`saveReaction`): the totals come from those.
100
+ */
93
101
  saveReactions(key: AccountKey, chatId: Id, messageId: Id, reactions: Reactions, options?: {
94
102
  observation?: CounterObservations["reactions"];
95
103
  }): Promise<boolean>;
104
+ /**
105
+ * One person's reaction to a message, unless the store holds a newer one of theirs; a `null` reaction is a take-back.
106
+ * The message's `reactions` become the totals of every person's newest, whenever the message is held.
107
+ */
108
+ saveReaction(key: AccountKey, chatId: Id, messageId: Id, reaction: PersonReaction): Promise<void>;
96
109
  /** The owner's star on a message, or its removal; answers whether the message is held at all. */
97
110
  saveStar(key: AccountKey, chatId: Id, messageId: Id, starred: boolean): Promise<boolean>;
98
111
  /** One voter's vote in the poll a message carries, unless the store holds a newer one of theirs; no answers is a take-back. */
@@ -11,7 +11,7 @@ export const accountMessagesOver = (context) => {
11
11
  const { database } = context;
12
12
  const { accountPk, findAccountPk, findChatPk, chatKeyOf, chatPkFor, answeredChatPk, upsertMessage } = keysOver(context);
13
13
  return {
14
- saveMessages: async (key, chatId, messages, { via, seenAt, votes = [], stars = [], typedChatId }) => write(database, () => {
14
+ saveMessages: async (key, chatId, messages, { via, seenAt, votes = [], reactions = [], stars = [], typedChatId }) => write(database, () => {
15
15
  const accountKey = accountPk(key);
16
16
  const chatKey = typedChatId === undefined ? chatPkFor(accountKey, chatId) : answeredChatPk(accountKey, chatId, typedChatId);
17
17
  for (const message of messages) {
@@ -20,6 +20,8 @@ export const accountMessagesOver = (context) => {
20
20
  }
21
21
  for (const { messageId, vote } of votes)
22
22
  messageWrites.saveVote(context, accountKey, key.provider, chatKey, messageId, vote);
23
+ for (const { messageId, reaction } of reactions)
24
+ messageWrites.saveReaction(context, accountKey, key.provider, chatKey, messageId, reaction);
23
25
  for (const { messageId, starred } of stars)
24
26
  messageWrites.saveStar(context, chatKey, messageId, starred);
25
27
  }),
@@ -83,6 +85,10 @@ export const accountMessagesOver = (context) => {
83
85
  });
84
86
  return saved;
85
87
  },
88
+ saveReaction: async (key, chatId, messageId, reaction) => write(database, () => {
89
+ const accountKey = accountPk(key);
90
+ messageWrites.saveReaction(context, accountKey, key.provider, chatPkFor(accountKey, chatId), messageId, reaction);
91
+ }),
86
92
  saveVote: async (key, chatId, messageId, vote) => write(database, () => {
87
93
  const accountKey = accountPk(key);
88
94
  messageWrites.saveVote(context, accountKey, key.provider, chatPkFor(accountKey, chatId), messageId, vote);
@@ -1,7 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { and, asc, eq, inArray, sql } from "./drizzle/core.js";
3
3
  import { pointerPairs } from "./entity-types.js";
4
- import { accountIdentities, accounts, aliases, botUpdates, chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageRevisions, messages, messageTranscripts, pollVotes, projects, syncCursors, syncRanges, syncs, } from "./schema.js";
4
+ import { accountIdentities, accounts, aliases, botUpdates, chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageReactions, messageRevisions, messages, messageTranscripts, pollVotes, projects, syncCursors, syncRanges, syncs, } from "./schema.js";
5
5
  /** An account's row, made on first sight; a name only replaces what was known when there is one. */
6
6
  export const accountPk = ({ orm, now }, { provider, account, scope }, name = null) => Number(orm
7
7
  .insert(accounts)
@@ -67,6 +67,7 @@ export const purgeAccount = ({ orm, database }, accountKey) => {
67
67
  orm.delete(syncs).where(eq(syncs.accountId, accountKey)).run();
68
68
  orm.delete(messageTranscripts).where(inArray(messageTranscripts.chatId, chatsOf)).run();
69
69
  orm.delete(pollVotes).where(inArray(pollVotes.chatId, chatsOf)).run();
70
+ orm.delete(messageReactions).where(inArray(messageReactions.chatId, chatsOf)).run();
70
71
  orm
71
72
  .delete(chatEventPeople)
72
73
  .where(inArray(chatEventPeople.chatEventId, orm.select({ id: chatEvents.id }).from(chatEvents).where(inArray(chatEvents.chatId, chatsOf))))
@@ -2,7 +2,7 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { formatLocator } from "../../domain/locator.js";
3
3
  import { purgeChats, revisedBy, stricterPrivacy } from "./chats.js";
4
4
  import { pointerPairs } from "./entity-types.js";
5
- import { tombstone } from "./messages.js";
5
+ import { tombstone, writeDerivedReactions } from "./messages.js";
6
6
  import { targetKey } from "./notes.js";
7
7
  import { write } from "./transaction.js";
8
8
  const chatRow = (context, accountKey, chatId) => context.database.prepare("SELECT * FROM chats WHERE account_id = ? AND external_id = ?").get(accountKey, chatId);
@@ -106,6 +106,9 @@ const absorb = (context, kept, gone) => {
106
106
  database
107
107
  .prepare("DELETE FROM poll_votes WHERE chat_id = ? AND EXISTS (SELECT 1 FROM poll_votes g WHERE g.chat_id = ? AND g.message_external_id = poll_votes.message_external_id AND g.identity_id = poll_votes.identity_id AND g.voted_at > poll_votes.voted_at)")
108
108
  .run(kept, gone);
109
+ database
110
+ .prepare("DELETE FROM message_reactions WHERE chat_id = ? AND EXISTS (SELECT 1 FROM message_reactions g WHERE g.chat_id = ? AND g.message_external_id = message_reactions.message_external_id AND g.identity_id = message_reactions.identity_id AND g.reacted_at > message_reactions.reacted_at)")
111
+ .run(kept, gone);
109
112
  // The absorbed copy of an event both chats hold is purged, so the people only it named move first.
110
113
  database
111
114
  .prepare("INSERT OR IGNORE INTO chat_event_people (chat_event_id, identity_id) SELECT k.id, p.identity_id FROM chat_events g JOIN chat_events k ON k.chat_id = ? AND k.source_id = g.source_id JOIN chat_event_people p ON p.chat_event_id = g.id WHERE g.chat_id = ?")
@@ -113,6 +116,7 @@ const absorb = (context, kept, gone) => {
113
116
  foldOpenStays(context, kept, gone);
114
117
  for (const table of [
115
118
  "poll_votes",
119
+ "message_reactions",
116
120
  "chat_revisions",
117
121
  "chat_events",
118
122
  "message_transcripts",
@@ -131,6 +135,10 @@ const absorb = (context, kept, gone) => {
131
135
  database
132
136
  .prepare("DELETE FROM message_transcripts WHERE chat_id = ? AND message_id IN (SELECT id FROM messages WHERE chat_id = ? AND deleted_at IS NOT NULL)")
133
137
  .run(kept, kept);
138
+ for (const { id } of database
139
+ .prepare("SELECT DISTINCT message_external_id AS id FROM message_reactions WHERE chat_id = ?")
140
+ .all(kept))
141
+ writeDerivedReactions(context, kept, id);
134
142
  database.prepare("UPDATE chats SET parent_chat_id = ? WHERE parent_chat_id = ?").run(kept, gone);
135
143
  pointerMover(context, "chat")(gone, kept);
136
144
  purgeChats(context, [gone]);
@@ -2,7 +2,7 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { and, eq, inArray, or, sql } from "./drizzle/core.js";
3
3
  import { identityPk } from "./identities.js";
4
4
  import { bound } from "./query-filter.js";
5
- import { chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, identities, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageRevisions, messages, messageTranscripts, pollVotes, syncRanges, } from "./schema.js";
5
+ import { chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, identities, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageReactions, messageRevisions, messages, messageTranscripts, pollVotes, syncRanges, } from "./schema.js";
6
6
  import { ensureTag } from "./tags.js";
7
7
  import { parsed, present, toIso, toMs } from "./values.js";
8
8
  /** The chat's own id, when the store key is a chat of this account. */
@@ -262,6 +262,7 @@ export const purgeChats = ({ orm }, pks) => {
262
262
  .run();
263
263
  orm.delete(messageTranscripts).where(inArray(messageTranscripts.chatId, pks)).run();
264
264
  orm.delete(pollVotes).where(inArray(pollVotes.chatId, pks)).run();
265
+ orm.delete(messageReactions).where(inArray(messageReactions.chatId, pks)).run();
265
266
  orm
266
267
  .delete(chatEventPeople)
267
268
  .where(inArray(chatEventPeople.chatEventId, orm.select({ id: chatEvents.id }).from(chatEvents).where(inArray(chatEvents.chatId, pks))))
@@ -31,6 +31,7 @@ export const MANIFEST = [
31
31
  { name: "20261018063952_version-24-message-stars-index", version: 24, minCompatible: 1 },
32
32
  { name: "20261019115025_version-25-chat-revisions", version: 25, minCompatible: 1 },
33
33
  { name: "20261019115027_version-25-chat-revisions-triggers", version: 25, minCompatible: 1 },
34
+ { name: "20261021214318_version-26-message-reactions", version: 26, minCompatible: 1 },
34
35
  ];
35
36
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
36
37
  const migrations = [];
@@ -0,0 +1,60 @@
1
+ import type { QueryAst } from "@wirecat/cli-base/query";
2
+ import type { Stemmer } from "../../search/stem.js";
3
+ import type { CacheDatabase, SqlValue } from "../driver.js";
4
+ interface Word {
5
+ raw: string;
6
+ literal: string;
7
+ /** Matched as a whole word, not as a beginning: `exact:` and number words. */
8
+ whole: boolean;
9
+ stems: string[];
10
+ widened: string[];
11
+ }
12
+ type Leaf = {
13
+ kind: "words";
14
+ exact: boolean;
15
+ words: Word[];
16
+ } | {
17
+ kind: "phrase";
18
+ exact: boolean;
19
+ words: Word[];
20
+ }
21
+ /** `"a b"~N`: every word within `lines` transcript lines of a line that holds one of them. */
22
+ | {
23
+ kind: "near";
24
+ exact: boolean;
25
+ words: Word[];
26
+ lines: number;
27
+ } | {
28
+ kind: "speaker";
29
+ participants: number[];
30
+ names: string[];
31
+ };
32
+ export type Resolved = Leaf | {
33
+ kind: "boolean";
34
+ must: Resolved[];
35
+ should: Resolved[];
36
+ not: Resolved[];
37
+ };
38
+ interface State {
39
+ stemmer: Stemmer | null;
40
+ through: number;
41
+ }
42
+ /** The parsed query, its names resolved and its words stemmed and widened, before any text is read. */
43
+ export declare const resolveMeetingQuery: (database: CacheDatabase, accountId: number, ast: QueryAst, state: State) => Resolved;
44
+ /**
45
+ * A `(rid, tier)` select: tier 0 the typed form, 1 another form the stems found, 2 a garbled spelling. A passage
46
+ * all of a query's required parts reach takes the worst tier among them; one any optional part reaches, the best.
47
+ */
48
+ export declare const meetingQuerySql: (tree: Resolved, state: State) => {
49
+ sql: string;
50
+ params: SqlValue[];
51
+ };
52
+ /**
53
+ * The tree checked again against a passage's current text — an index lags an edit — and the garbled spellings
54
+ * that let it match. A speaker cannot be read from the text, so it passes here.
55
+ */
56
+ export declare const matchMeetingText: (tree: Resolved, text: string, stemmer: Stemmer | null) => {
57
+ from: string;
58
+ to: string[];
59
+ }[] | null;
60
+ export {};
@@ -0,0 +1,262 @@
1
+ import { CliError, singleLine } from "@wirecat/cli-base";
2
+ import { isNumberWord, widenWords } from "../../search/garbled.js";
3
+ import { knownRussianWord } from "../../search/russian-dictionary.js";
4
+ import { fold, normalize } from "../normalize.js";
5
+ import { meetingStemmableText } from "./meeting-stem-state.js";
6
+ const WORD = /[\p{L}\p{N}\p{M}]+/gu;
7
+ const MOST_WORDS = 128;
8
+ const quote = (word) => `"${word.replaceAll('"', '""')}"`;
9
+ /** The candidates `from:` picks among: a linked person with all their identities, else one identity or name. */
10
+ const speakers = (database, accountId) => {
11
+ const found = new Map();
12
+ const add = (key, label, names, participant, raw) => {
13
+ const speaker = found.get(key) ?? { label, names: new Set(), participants: new Set(), raw: new Set() };
14
+ for (const name of names)
15
+ if (name?.trim())
16
+ speaker.names.add(fold(name.trim()));
17
+ if (participant !== null)
18
+ speaker.participants.add(participant);
19
+ if (raw)
20
+ speaker.raw.add(raw);
21
+ found.set(key, speaker);
22
+ };
23
+ const participants = database
24
+ .prepare(`SELECT p.id,p.identity_id,p.display_name,i.name AS identity_name,l.person_id,person.name AS person_name
25
+ FROM meeting_participants p JOIN meetings m ON m.id=p.meeting_id
26
+ LEFT JOIN identities i ON i.id=p.identity_id LEFT JOIN identity_links l ON l.identity_id=p.identity_id
27
+ LEFT JOIN persons person ON person.id=l.person_id WHERE m.account_id=? AND m.deleted_at IS NULL`)
28
+ .all(accountId);
29
+ const personNames = database.prepare("SELECT i.name FROM identity_links l JOIN identities i ON i.id=l.identity_id WHERE l.person_id=?");
30
+ for (const row of participants) {
31
+ const names = [row.display_name, row.identity_name, row.person_name].map((name) => name === null || name === undefined ? null : String(name));
32
+ const label = names.find((name) => name?.trim()) ?? "unnamed";
33
+ if (row.person_id !== null && row.person_id !== undefined) {
34
+ const others = personNames.all(Number(row.person_id)).map((one) => (one.name === null ? null : String(one.name)));
35
+ add(`person:${row.person_id}`, String(row.person_name ?? label), [...names, ...others], Number(row.id));
36
+ }
37
+ else if (row.identity_id !== null && row.identity_id !== undefined)
38
+ add(`identity:${row.identity_id}`, label, names, Number(row.id));
39
+ else
40
+ add(`name:${fold(label)}`, label, names, Number(row.id));
41
+ }
42
+ const unlinked = database
43
+ .prepare(`SELECT DISTINCT r.speaker_name AS name FROM meeting_transcript_rows r
44
+ JOIN meeting_transcripts t ON t.id=r.meeting_transcript_id JOIN meetings m ON m.id=t.meeting_id
45
+ WHERE m.account_id=? AND m.deleted_at IS NULL AND r.speaker_participant_id IS NULL AND r.speaker_name IS NOT NULL
46
+ UNION SELECT DISTINCT c.sender_name FROM meeting_chat_messages c JOIN meetings m ON m.id=c.meeting_id
47
+ WHERE m.account_id=? AND m.deleted_at IS NULL AND c.sender_participant_id IS NULL AND c.sender_name IS NOT NULL`)
48
+ .all(accountId, accountId);
49
+ for (const row of unlinked) {
50
+ const name = String(row.name);
51
+ if (!name.trim())
52
+ continue;
53
+ const same = [...found.values()].find((speaker) => speaker.names.has(fold(name.trim())));
54
+ if (same)
55
+ same.raw.add(name);
56
+ else
57
+ add(`name:${fold(name.trim())}`, name, [name], null, name);
58
+ }
59
+ return [...found.values()];
60
+ };
61
+ /**
62
+ * As `pickPerson` for messages: a name matched as a fragment, the exact name first, and an ambiguous one is an
63
+ * error with the candidates rather than a guess.
64
+ */
65
+ const pickSpeaker = (database, accountId, reference) => {
66
+ const wanted = fold(reference.trim());
67
+ const everyone = speakers(database, accountId).sort((a, b) => a.label.localeCompare(b.label));
68
+ const exact = everyone.filter((speaker) => speaker.names.has(wanted));
69
+ const matches = exact.length
70
+ ? exact
71
+ : everyone.filter((speaker) => [...speaker.names].some((n) => n.includes(wanted)));
72
+ const [one] = matches;
73
+ if (matches.length === 1 && one)
74
+ return { kind: "speaker", participants: [...one.participants].sort((a, b) => a - b), names: [...one.raw].sort() };
75
+ if (matches.length === 0)
76
+ throw new CliError("not_found", `from:${reference} — nobody spoke as "${reference}"`);
77
+ throw new CliError("validation_error", `from:${reference} matches ${matches.length} speakers — write more of the name:\n${matches
78
+ .map((speaker) => ` ${singleLine(speaker.label)}`)
79
+ .join("\n")}`, { candidates: matches.map((speaker) => ({ name: speaker.label })) });
80
+ };
81
+ /** The parsed query, its names resolved and its words stemmed and widened, before any text is read. */
82
+ export const resolveMeetingQuery = (database, accountId, ast, state) => {
83
+ const all = [];
84
+ const wordsOf = (value, exact) => {
85
+ const words = (value.match(WORD) ?? [])
86
+ .filter((raw) => normalize(raw))
87
+ .map((raw) => {
88
+ const literal = normalize(raw);
89
+ return {
90
+ raw,
91
+ literal,
92
+ whole: exact || isNumberWord(literal),
93
+ stems: state.stemmer && raw.length <= 1024 && !exact
94
+ ? [...new Set(state.stemmer.explain(raw).map((word) => word.stem))]
95
+ : [],
96
+ widened: [],
97
+ };
98
+ });
99
+ for (const term of words)
100
+ all.push(term);
101
+ if (all.length > MOST_WORDS)
102
+ throw new CliError("validation_error", "Meeting search supports at most 128 query words");
103
+ return words;
104
+ };
105
+ const resolve = (node) => {
106
+ if (node.kind === "boolean")
107
+ return {
108
+ kind: "boolean",
109
+ must: node.clauses.filter((c) => c.occur === "must").map((c) => resolve(c.node)),
110
+ should: node.clauses.filter((c) => c.occur === "should").map((c) => resolve(c.node)),
111
+ not: node.clauses.filter((c) => c.occur === "must_not").map((c) => resolve(c.node)),
112
+ };
113
+ if (node.field === "from")
114
+ return pickSpeaker(database, accountId, node.value);
115
+ const exact = node.field === "exact";
116
+ const words = wordsOf(node.value, exact);
117
+ if (node.operator === "phrase" && node.slop !== undefined && words.length > 1)
118
+ return { kind: "near", exact, words, lines: node.slop };
119
+ return { kind: node.operator === "phrase" && words.length > 1 ? "phrase" : "words", exact, words };
120
+ };
121
+ const tree = resolve(ast.root);
122
+ const stemmer = state.stemmer;
123
+ const widening = [];
124
+ const collect = (node) => {
125
+ if (node.kind === "boolean")
126
+ [...node.must, ...node.should, ...node.not].forEach(collect);
127
+ else if ((node.kind === "words" || node.kind === "near") && !node.exact)
128
+ for (const term of node.words)
129
+ widening.push(term);
130
+ };
131
+ collect(tree);
132
+ if (stemmer && state.through > 0 && widening.length) {
133
+ const widened = widenWords(widening, database
134
+ .prepare("SELECT term,doc FROM meeting_words_vocab WHERE col='normalized_text'")
135
+ .all()
136
+ .map((row) => ({ term: String(row.term), docs: Number(row.doc) })), (word) => [...new Set(stemmer.explain(word).map((part) => part.stem))], knownRussianWord);
137
+ widening.forEach((word, at) => {
138
+ word.widened = widened[at] ?? [];
139
+ });
140
+ }
141
+ return tree;
142
+ };
143
+ /**
144
+ * A `(rid, tier)` select: tier 0 the typed form, 1 another form the stems found, 2 a garbled spelling. A passage
145
+ * all of a query's required parts reach takes the worst tier among them; one any optional part reaches, the best.
146
+ */
147
+ export const meetingQuerySql = (tree, state) => {
148
+ const params = [];
149
+ const stems = state.stemmer !== null && state.through > 0;
150
+ const word = (term) => {
151
+ params.push(`normalized_text : (${quote(term.literal)}${term.whole ? "" : "*"})`);
152
+ let select = "SELECT rowid AS rid,0 AS tier FROM meeting_words WHERE meeting_words MATCH ?";
153
+ if (stems && term.stems.length) {
154
+ params.push(`stems : (${term.stems.map(quote).join(" OR ")})`, state.through);
155
+ select += " UNION SELECT rowid,1 FROM meeting_stems WHERE meeting_stems MATCH ? AND rowid<=?";
156
+ }
157
+ if (term.widened.length) {
158
+ params.push(`normalized_text : (${term.widened.map(quote).join(" OR ")})`);
159
+ select += " UNION SELECT rowid,2 FROM meeting_words WHERE meeting_words MATCH ?";
160
+ }
161
+ return `SELECT rid,min(tier) AS tier FROM (${select}) GROUP BY rid`;
162
+ };
163
+ const all = (parts) => parts.length === 1
164
+ ? parts[0]
165
+ : `SELECT rid,max(tier) AS tier FROM (${parts.join(" UNION ALL ")}) GROUP BY rid HAVING count(*)=${parts.length}`;
166
+ const any = (parts) => parts.length === 1
167
+ ? parts[0]
168
+ : `SELECT rid,min(tier) AS tier FROM (${parts.join(" UNION ALL ")}) GROUP BY rid`;
169
+ const nothing = "SELECT NULL AS rid,0 AS tier WHERE 0";
170
+ const compile = (node) => {
171
+ if (node.kind === "words")
172
+ return node.words.length ? all(node.words.map(word)) : nothing;
173
+ if (node.kind === "near") {
174
+ const lines = node.words.map((term, i) => `w${i} AS MATERIALIZED (SELECT r.meeting_transcript_id AS t,r.position AS p,r.id,h.tier FROM (${word(term)}) h JOIN meeting_transcript_rows r ON h.rid%4=1 AND r.id=h.rid/4)`);
175
+ const nearest = node.words.map((_, i) => `(SELECT min(x.tier) FROM w${i} x WHERE x.t=c.t AND x.p BETWEEN c.p-${node.lines} AND c.p+${node.lines})`);
176
+ return `SELECT rid,tier FROM (WITH ${lines.join(",")}, c AS (${node.words
177
+ .map((_, i) => `SELECT t,p,id FROM w${i}`)
178
+ .join(" UNION ")}) SELECT c.id*4+1 AS rid,max(${nearest.join(",")}) AS tier FROM c) WHERE tier IS NOT NULL`;
179
+ }
180
+ if (node.kind === "phrase") {
181
+ params.push(`normalized_text : ${quote(node.words.map((term) => term.literal).join(" "))}`);
182
+ let select = "SELECT rowid AS rid,0 AS tier FROM meeting_words WHERE meeting_words MATCH ?";
183
+ const phrases = node.exact ? undefined : state.stemmer?.phrases(node.words.map((term) => term.raw).join(" "));
184
+ if (stems && phrases) {
185
+ params.push(`stems : (${phrases})`, state.through);
186
+ select += " UNION SELECT rowid,1 FROM meeting_stems WHERE meeting_stems MATCH ? AND rowid<=?";
187
+ }
188
+ return `SELECT rid,min(tier) AS tier FROM (${select}) GROUP BY rid`;
189
+ }
190
+ if (node.kind === "speaker") {
191
+ params.push(JSON.stringify(node.participants), JSON.stringify(node.names));
192
+ params.push(JSON.stringify(node.participants), JSON.stringify(node.names));
193
+ return `SELECT r.id*4+1 AS rid,0 AS tier FROM meeting_transcript_rows r
194
+ WHERE r.speaker_participant_id IN (SELECT value FROM json_each(?)) OR (r.speaker_participant_id IS NULL AND r.speaker_name IN (SELECT value FROM json_each(?)))
195
+ UNION ALL SELECT c.id*4+2,0 FROM meeting_chat_messages c
196
+ WHERE c.sender_participant_id IN (SELECT value FROM json_each(?)) OR (c.sender_participant_id IS NULL AND c.sender_name IN (SELECT value FROM json_each(?)))`;
197
+ }
198
+ // Lucene: optional clauses only count when nothing is required; a part that is only exclusions matches nothing.
199
+ const positive = node.must.length
200
+ ? all(node.must.map(compile))
201
+ : node.should.length
202
+ ? any(node.should.map(compile))
203
+ : nothing;
204
+ if (!node.not.length)
205
+ return positive;
206
+ return `SELECT rid,tier FROM (${positive}) WHERE rid NOT IN (${node.not.map((part) => `SELECT rid FROM (${compile(part)})`).join(" UNION ")})`;
207
+ };
208
+ return { sql: compile(tree), params };
209
+ };
210
+ /**
211
+ * The tree checked again against a passage's current text — an index lags an edit — and the garbled spellings
212
+ * that let it match. A speaker cannot be read from the text, so it passes here.
213
+ */
214
+ export const matchMeetingText = (tree, text, stemmer) => {
215
+ const tokens = normalize(text).match(/[\p{L}\p{N}]+/gu) ?? [];
216
+ let stemTokens;
217
+ const stemsOf = () => (stemTokens ??= stemmer
218
+ ? new Set(stemmer.explain(meetingStemmableText(text)).map((word) => word.stem))
219
+ : new Set());
220
+ const word = (term) => {
221
+ if (term.whole ? tokens.includes(term.literal) : tokens.some((token) => token.startsWith(term.literal)))
222
+ return [];
223
+ if (term.stems.some((stem) => stemsOf().has(stem)))
224
+ return [];
225
+ const to = term.widened.filter((widened) => tokens.includes(widened));
226
+ return to.length ? [{ from: term.raw, to }] : null;
227
+ };
228
+ const check = (node) => {
229
+ if (node.kind === "speaker")
230
+ return [];
231
+ // The other words are on neighbouring lines, so only this line's garbled spellings are named.
232
+ if (node.kind === "near")
233
+ return node.words.flatMap((term) => word(term) ?? []);
234
+ if (node.kind === "words" || node.kind === "phrase") {
235
+ const found = [];
236
+ for (const term of node.words) {
237
+ const one = word(term);
238
+ if (!one)
239
+ return null;
240
+ for (const fix of one)
241
+ found.push(fix);
242
+ }
243
+ return found;
244
+ }
245
+ if (node.not.some((part) => check(part) !== null))
246
+ return null;
247
+ if (node.must.length) {
248
+ const found = [];
249
+ for (const part of node.must) {
250
+ const one = check(part);
251
+ if (!one)
252
+ return null;
253
+ for (const fix of one)
254
+ found.push(fix);
255
+ }
256
+ return found;
257
+ }
258
+ const matched = node.should.map(check).filter((one) => one !== null);
259
+ return matched.length ? matched.flat() : null;
260
+ };
261
+ return check(tree);
262
+ };