@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
@@ -1,11 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { CliError } from "@wirecat/cli-base";
3
- import { formatMeetingReference } from "../../meetings/index.js";
4
- import { normalize } from "../normalize.js";
3
+ import { formatMeetingReference, parseMeetingQuery } from "../../meetings/index.js";
4
+ import { matchMeetingText, meetingQuerySql, resolveMeetingQuery } from "./meeting-hit-query.js";
5
5
  import { indexedText } from "./meeting-index-values.js";
6
6
  import { checkMeetingCancelled } from "./meeting-operation.js";
7
7
  import { positiveReadInteger } from "./meeting-reads.js";
8
- import { meetingStemmableText, meetingStemState } from "./meeting-stem-state.js";
8
+ import { meetingStemState } from "./meeting-stem-state.js";
9
9
  import { read } from "./transaction.js";
10
10
  const fail = (message) => {
11
11
  throw new CliError("validation_error", message);
@@ -42,8 +42,9 @@ const cursorOf = (value, key) => {
42
42
  catch {
43
43
  return fail("Invalid meeting search cursor");
44
44
  }
45
- if (parsed?.version !== 1 ||
45
+ if (parsed?.version !== 2 ||
46
46
  parsed.key !== key ||
47
+ ![0, 1, 2].includes(parsed.tier) ||
47
48
  !Number.isSafeInteger(parsed.started) ||
48
49
  Math.abs(parsed.started) > 8640000000000000 ||
49
50
  !Number.isSafeInteger(parsed.meeting) ||
@@ -86,21 +87,14 @@ export function meetingHitSearchOver({ database }) {
86
87
  fail("Meeting search limit must be from 1 through 1000");
87
88
  if (!Number.isSafeInteger(maxReadBytes) || maxReadBytes < 1 || maxReadBytes > 64 * 1024 ** 2)
88
89
  fail("Meeting search byte limit must be from 1 through 67108864");
89
- if (typeof rawQuery !== "string" || Buffer.byteLength(rawQuery) > 65536)
90
+ if (typeof rawQuery === "string" && Buffer.byteLength(rawQuery) > 65536)
90
91
  fail("Meeting search query must be a bounded string");
91
- const rawWords = rawQuery.match(/[\p{L}\p{N}\p{M}]+/gu)?.filter((word) => normalize(word)) ?? [];
92
- if (rawWords.length > 128)
93
- fail("Meeting search supports at most 128 query words");
92
+ const ast = typeof rawQuery === "string" ? parseMeetingQuery(rawQuery) : rawQuery;
94
93
  return read(database, () => {
95
94
  checkMeetingCancelled(input.signal);
96
95
  const state = meetingStemState(database, input.accountId);
97
- const terms = rawWords.map((raw) => ({
98
- literal: normalize(raw),
99
- stems: state.stemmer && raw.length <= 1024
100
- ? [...new Set(state.stemmer.explain(raw).map((word) => word.stem))]
101
- : [],
102
- }));
103
- const key = fingerprint(JSON.stringify(terms), input, state.identity);
96
+ const tree = resolveMeetingQuery(database, input.accountId, ast, state);
97
+ const key = fingerprint(JSON.stringify(tree), input, state.identity);
104
98
  const after = cursorOf(input.after, key);
105
99
  const pending = database
106
100
  .prepare(`SELECT 1 FROM meeting_index_pending p
@@ -118,20 +112,8 @@ export function meetingHitSearchOver({ database }) {
118
112
  indexing: pending || !state.stemmer || state.through < state.watermark ? "pending" : "ready",
119
113
  indexCompleteness: "unknown",
120
114
  };
121
- if (terms.length === 0)
122
- return { items: [], hasMore: false, nextCursor: null, coverage };
123
115
  const clauses = ["m.account_id=?", "m.deleted_at IS NULL"];
124
- const queryParams = [];
125
- const termTables = terms.map((term, i) => {
126
- queryParams.push(`normalized_text : ("${term.literal}"*)`);
127
- let select = "SELECT rowid AS rid FROM meeting_words WHERE meeting_words MATCH ?";
128
- if (state.stemmer && state.through > 0 && term.stems.length) {
129
- queryParams.push(`stems : (${term.stems.map((stem) => `"${stem.replaceAll('"', '""')}"`).join(" OR ")})`, state.through);
130
- select += " UNION SELECT rowid AS rid FROM meeting_stems WHERE meeting_stems MATCH ? AND rowid<=?";
131
- }
132
- return `term_${i} AS (${select})`;
133
- });
134
- const matching = terms.map((_, i) => `SELECT rid FROM term_${i}`).join(" INTERSECT ");
116
+ const { sql: matching, params: queryParams } = meetingQuerySql(tree, state);
135
117
  const params = [input.accountId];
136
118
  if (input.meetingId !== undefined) {
137
119
  clauses.push("m.id=?");
@@ -170,17 +152,17 @@ export function meetingHitSearchOver({ database }) {
170
152
  params.push(input.tagId);
171
153
  }
172
154
  const seek = after
173
- ? "WHERE h.started<? OR (h.started=? AND (h.meeting_id>? OR (h.meeting_id=? AND (h.kind>? OR (h.kind=? AND h.id>?)))))"
155
+ ? "WHERE h.tier>? OR (h.tier=? AND (h.started<? OR (h.started=? AND (h.meeting_id>? OR (h.meeting_id=? AND (h.kind>? OR (h.kind=? AND h.id>?)))))))"
174
156
  : "";
175
157
  if (after)
176
- params.push(after.started, after.started, after.meeting, after.meeting, after.kind, after.kind, after.id);
177
- const sql = `WITH ${termTables.join(",")}, matches AS (${matching}), matching AS (SELECT rid FROM matches h WHERE NOT EXISTS(SELECT 1 FROM meeting_index_pending p WHERE p.id=h.rid/4 AND p.indexable_type=CASE h.rid%4 WHEN 1 THEN 'meeting_transcript_row' WHEN 2 THEN 'meeting_chat_message' ELSE 'meeting_summary' END)), selected AS (SELECT m.id,coalesce(m.started_at,0) AS started FROM meetings m WHERE ${clauses.join(" AND ")}), hits AS (
178
- SELECT m.started,m.id AS meeting_id,0 AS kind,r.id,r.start_ms,${bytes("r", ["text"])} AS bytes,t.id AS transcript_id,r.position FROM matching h JOIN meeting_transcript_rows r ON h.rid%4=1 AND r.id=h.rid/4 JOIN meeting_transcripts t ON t.id=r.meeting_transcript_id AND t.superseded_at IS NULL AND t.deleted_at IS NULL JOIN selected m ON m.id=t.meeting_id
179
- UNION ALL SELECT m.started,m.id,1,c.id,NULL,${bytes("c", ["text"])},NULL,NULL FROM matching h JOIN meeting_chat_messages c ON h.rid%4=2 AND c.id=h.rid/4 JOIN selected m ON m.id=c.meeting_id
180
- UNION ALL SELECT m.started,m.id,2,s.id,NULL,${bytes("s", fields)},NULL,NULL FROM matching h JOIN meeting_summaries s ON h.rid%4=3 AND s.id=h.rid/4 JOIN selected m ON m.id=s.meeting_id)
158
+ params.push(after.tier, after.tier, after.started, after.started, after.meeting, after.meeting, after.kind, after.kind, after.id);
159
+ const sql = `WITH matches AS (${matching}), matching AS (SELECT rid,tier FROM matches h WHERE NOT EXISTS(SELECT 1 FROM meeting_index_pending p WHERE p.id=h.rid/4 AND p.indexable_type=CASE h.rid%4 WHEN 1 THEN 'meeting_transcript_row' WHEN 2 THEN 'meeting_chat_message' ELSE 'meeting_summary' END)), selected AS (SELECT m.id,coalesce(m.started_at,0) AS started FROM meetings m WHERE ${clauses.join(" AND ")}), hits AS (
160
+ SELECT h.tier,m.started,m.id AS meeting_id,0 AS kind,r.id,r.start_ms,${bytes("r", ["text"])} AS bytes,t.id AS transcript_id,r.position FROM matching h JOIN meeting_transcript_rows r ON h.rid%4=1 AND r.id=h.rid/4 JOIN meeting_transcripts t ON t.id=r.meeting_transcript_id AND t.superseded_at IS NULL AND t.deleted_at IS NULL JOIN selected m ON m.id=t.meeting_id
161
+ UNION ALL SELECT h.tier,m.started,m.id,1,c.id,NULL,${bytes("c", ["text"])},NULL,NULL FROM matching h JOIN meeting_chat_messages c ON h.rid%4=2 AND c.id=h.rid/4 JOIN selected m ON m.id=c.meeting_id
162
+ UNION ALL SELECT h.tier,m.started,m.id,2,s.id,NULL,${bytes("s", fields)},NULL,NULL FROM matching h JOIN meeting_summaries s ON h.rid%4=3 AND s.id=h.rid/4 JOIN selected m ON m.id=s.meeting_id)
181
163
  SELECT h.*,${bytes("m", ["title"])}+${bytes("s", ["doc_url"])} AS context_bytes FROM hits h
182
164
  JOIN meetings m ON m.id=h.meeting_id LEFT JOIN meeting_summaries s ON h.kind=2 AND s.id=h.id
183
- ${seek} ORDER BY h.started DESC,h.meeting_id,h.kind,h.id LIMIT ?`;
165
+ ${seek} ORDER BY h.tier,h.started DESC,h.meeting_id,h.kind,h.id LIMIT ?`;
184
166
  const rows = database.prepare(sql).all(...queryParams, ...params, limit + 1);
185
167
  let total = 0;
186
168
  const meetingMetadata = database.prepare("SELECT title,started_at FROM meetings WHERE id=? AND account_id=? AND deleted_at IS NULL");
@@ -232,15 +214,15 @@ export function meetingHitSearchOver({ database }) {
232
214
  startMs: row.start_ms === null ? null : Number(row.start_ms),
233
215
  };
234
216
  });
235
- const fresh = items.filter((item) => {
236
- const literalTokens = normalize(item.text).match(/[\p{L}\p{N}]+/gu) ?? [];
237
- const missing = terms.filter((term) => !literalTokens.some((token) => token.startsWith(term.literal)));
238
- if (missing.length === 0)
239
- return true;
240
- const stemTokens = state.stemmer
241
- ? new Set(state.stemmer.explain(meetingStemmableText(item.text)).map((word) => word.stem))
242
- : new Set();
243
- return missing.every((term) => term.stems.some((stem) => stemTokens.has(stem)));
217
+ const fresh = items.flatMap((item) => {
218
+ const corrections = matchMeetingText(tree, item.text, state.stemmer);
219
+ if (!corrections)
220
+ return [];
221
+ return [
222
+ corrections.length
223
+ ? { ...item, match: "corrected", corrections }
224
+ : { ...item, match: "words" },
225
+ ];
244
226
  });
245
227
  if (fresh.length !== items.length)
246
228
  coverage.indexing = "pending";
@@ -248,8 +230,9 @@ export function meetingHitSearchOver({ database }) {
248
230
  const hasMore = rows.length > limit;
249
231
  const nextCursor = hasMore && last
250
232
  ? Buffer.from(JSON.stringify({
251
- version: 1,
233
+ version: 2,
252
234
  key,
235
+ tier: Number(last.tier),
253
236
  started: Number(last.started),
254
237
  meeting: Number(last.meeting_id),
255
238
  kind: Number(last.kind),
@@ -166,14 +166,65 @@ export const saveAttachment = (database, type, parentId, a, now) => upsert(datab
166
166
  content_sha256: a.contentSha256,
167
167
  extracted_at: a.extractedAt,
168
168
  }, now);
169
+ const FILE_SOURCE = "file";
170
+ const SAME_OCCURRENCE_MS = 15 * 60_000;
171
+ /** The account's record of this occurrence under another key: same series, the nearest start within 15 minutes. */
172
+ const sameOccurrence = (database, meeting, seriesKey) => {
173
+ if (!seriesKey || meeting.startedAt === null)
174
+ return undefined;
175
+ const row = database
176
+ .prepare(`SELECT m.id,m.external_id,m.meeting_series_id,
177
+ EXISTS (SELECT 1 FROM meeting_transcripts t WHERE t.meeting_id=m.id AND t.source=?) AND
178
+ NOT EXISTS (SELECT 1 FROM meeting_transcripts t WHERE t.meeting_id=m.id AND t.source!=?) AS from_file
179
+ FROM meetings m JOIN meeting_series s ON s.id=m.meeting_series_id
180
+ WHERE m.account_id=? AND s.account_id=? AND s.external_id=? AND m.deleted_at IS NULL
181
+ AND m.started_at BETWEEN ? AND ? ORDER BY abs(m.started_at-?),m.id LIMIT 1`)
182
+ .get(FILE_SOURCE, FILE_SOURCE, meeting.accountId, meeting.accountId, seriesKey, meeting.startedAt - SAME_OCCURRENCE_MS, meeting.startedAt + SAME_OCCURRENCE_MS, meeting.startedAt);
183
+ return row
184
+ ? {
185
+ id: row.id,
186
+ externalId: row.external_id,
187
+ seriesId: row.meeting_series_id,
188
+ fromFile: Number(row.from_file) === 1,
189
+ }
190
+ : undefined;
191
+ };
192
+ /** A file's copy of an occurrence the provider already gave: its transcript only, and only where there is none. */
193
+ const fillGap = (context, input, meetingId) => {
194
+ const { database } = context;
195
+ const held = database
196
+ .prepare("SELECT 1 FROM meeting_transcripts WHERE meeting_id = ? AND superseded_at IS NULL AND deleted_at IS NULL")
197
+ .get(meetingId);
198
+ if (held || !input.transcripts?.length)
199
+ return meetingId;
200
+ const participantIds = (input.participants ?? []).map(({ identity, ...p }) => upsert(database, "meeting_participants", {
201
+ meeting_id: meetingId,
202
+ identity_id: meetingIdentityPk({ ...context, now: () => input.now }, input.meeting.accountId, identity),
203
+ }, { display_name: p.displayName, email: p.email, role: p.role, external_id: p.externalId }, input.now));
204
+ saveTranscripts(database, meetingId, input.transcripts, (position) => (position === null ? null : (participantIds[position] ?? null)), input.now);
205
+ return meetingId;
206
+ };
169
207
  const save = (context, input) => {
170
208
  const { database } = context;
171
209
  const { meeting: { series: seriesInput, ...meeting }, now, } = input;
172
210
  if (!Number.isSafeInteger(meeting.accountId) || meeting.accountId <= 0 || !meeting.externalId)
173
211
  throw new Error("Invalid meeting key");
174
- const existing = database
212
+ let existing = database
175
213
  .prepare("SELECT id, meeting_series_id FROM meetings WHERE account_id = ? AND external_id = ?")
176
214
  .get(meeting.accountId, meeting.externalId);
215
+ const same = existing ? undefined : sameOccurrence(database, meeting, seriesInput?.externalId);
216
+ if (same?.fromFile === false && input.fromFile)
217
+ return fillGap(context, input, Number(same.id));
218
+ if (same && (same.fromFile || input.fromFile)) {
219
+ // A provider record takes over a record made from files; two files of one occurrence share the first one's key.
220
+ if (input.fromFile)
221
+ meeting.externalId = String(same.externalId);
222
+ else
223
+ database
224
+ .prepare("UPDATE meetings SET external_id = ?, updated_at = ? WHERE id = ?")
225
+ .run(meeting.externalId, now, Number(same.id));
226
+ existing = { id: same.id, meeting_series_id: same.seriesId };
227
+ }
177
228
  let seriesId = existing ? int(existing.meeting_series_id) : null;
178
229
  if (seriesInput) {
179
230
  if (!seriesInput.externalId)
@@ -228,6 +279,10 @@ const save = (context, input) => {
228
279
  return id;
229
280
  };
230
281
  saveTranscripts(database, meetingId, input.transcripts ?? [], participantId, now);
282
+ if ((input.transcripts ?? []).some((transcript) => transcript.source !== FILE_SOURCE))
283
+ database
284
+ .prepare("UPDATE meeting_transcripts SET superseded_at = ?, updated_at = ? WHERE meeting_id = ? AND source = ? AND superseded_at IS NULL")
285
+ .run(now, now, meetingId, FILE_SOURCE);
231
286
  for (const { senderParticipantPosition, ...line } of input.chat ?? []) {
232
287
  const values = {
233
288
  sent_at: line.sentAt,
@@ -1,4 +1,4 @@
1
- import type { Id, Message, PollVote, Provider, Reactions } from "../../domain/models.js";
1
+ import type { Id, Message, PersonReaction, PollVote, Provider, Reactions } from "../../domain/models.js";
2
2
  import type { AccountKey, DeletionScope } from "../types.js";
3
3
  import type { StoreContext } from "./open.js";
4
4
  export declare const upsertMessage: (context: StoreContext, key: AccountKey, accountKey: number, chatKey: number, message: Message, via: string, seenAt?: number) => void;
@@ -7,11 +7,19 @@ export declare const upsertMessage: (context: StoreContext, key: AccountKey, acc
7
7
  * back, and leaves no text — not in the row, the search copy, the edit history, a transcript or a vector.
8
8
  */
9
9
  export declare const tombstone: (context: StoreContext, pk: number) => number;
10
- /** Whether the message is held at all. */
11
- export declare const saveReactions: ({ orm }: StoreContext, chatKey: number, messageId: Id, reactions: Reactions) => boolean;
10
+ /** Whether the message is held at all. Where people's own reactions are held, the totals come from them instead. */
11
+ export declare const saveReactions: (context: StoreContext, chatKey: number, messageId: Id, reactions: Reactions) => boolean;
12
12
  /** Answers whether the message is held; a deleted one keeps no star. */
13
13
  export declare const saveStar: ({ orm }: StoreContext, chatKey: number, messageId: Id, starred: boolean) => boolean;
14
14
  export declare const markDeleted: (context: StoreContext, accountKey: number, messageIds: Id[], chatId: Id | undefined, among?: DeletionScope) => number;
15
15
  /** A vote older than the one held for that voter is dropped, and so is any vote in a deleted poll. */
16
16
  export declare const saveVote: (context: StoreContext, accountKey: number, provider: Provider, chatKey: number, messageId: Id, { person, answers, votedAt }: PollVote) => void;
17
17
  export declare const pollVotes: ({ database }: StoreContext, chatKey: number, messageId: Id) => PollVote[];
18
+ /**
19
+ * The totals of the people's reactions the store holds for a message, or `undefined` when it holds none: then the
20
+ * messenger's own totals stand. A taken-back reaction still counts as held, so all of them taken back is zero.
21
+ */
22
+ export declare const derivedReactions: ({ database }: StoreContext, chatKey: number, messageId: Id) => Reactions | undefined;
23
+ /** A reaction older than the one held for that person is dropped, and so is any reaction to a deleted message. */
24
+ export declare const saveReaction: (context: StoreContext, accountKey: number, provider: Provider, chatKey: number, messageId: Id, { person, own, reaction, reactedAt }: PersonReaction) => void;
25
+ export declare const writeDerivedReactions: (context: StoreContext, chatKey: number, messageId: Id) => void;
@@ -174,6 +174,13 @@ export const upsertMessage = (context, key, accountKey, chatKey, message, via, s
174
174
  if (revived)
175
175
  orm.update(messages).set({ deletedAt: null, text: message.text }).where(eq(messages.id, pk)).run();
176
176
  }
177
+ const derived = derivedReactions(context, chatKey, message.id);
178
+ if (derived)
179
+ orm
180
+ .update(messages)
181
+ .set({ reactions: JSON.stringify(derived) })
182
+ .where(eq(messages.id, pk))
183
+ .run();
177
184
  if (message.counterObservations)
178
185
  applyCounterObservations(context, pk, message.counterObservations);
179
186
  if (message.starred !== undefined)
@@ -237,6 +244,9 @@ export const tombstone = (context, pk) => {
237
244
  context.database
238
245
  .prepare("DELETE FROM poll_votes WHERE chat_id=? AND message_external_id=?")
239
246
  .run(row.chatId, row.externalId);
247
+ context.database
248
+ .prepare("DELETE FROM message_reactions WHERE chat_id=? AND message_external_id=?")
249
+ .run(row.chatId, row.externalId);
240
250
  purgeVectorsOf(context, pk);
241
251
  orm
242
252
  .delete(messageTranscripts)
@@ -244,10 +254,10 @@ export const tombstone = (context, pk) => {
244
254
  .run();
245
255
  return 1;
246
256
  };
247
- /** Whether the message is held at all. */
248
- export const saveReactions = ({ orm }, chatKey, messageId, reactions) => orm
257
+ /** Whether the message is held at all. Where people's own reactions are held, the totals come from them instead. */
258
+ export const saveReactions = (context, chatKey, messageId, reactions) => context.orm
249
259
  .update(messages)
250
- .set({ reactions: JSON.stringify(reactions) })
260
+ .set({ reactions: JSON.stringify(derivedReactions(context, chatKey, messageId) ?? reactions) })
251
261
  .where(and(eq(messages.chatId, chatKey), eq(messages.externalId, messageId), isNull(messages.deletedAt)))
252
262
  .returning({ pk: messages.id })
253
263
  .all().length > 0;
@@ -326,3 +336,64 @@ export const pollVotes = ({ database }, chatKey, messageId) => database
326
336
  answers: JSON.parse(answers),
327
337
  votedAt: new Date(votedAt).toISOString(),
328
338
  }));
339
+ const reactionStatements = new WeakMap();
340
+ const reactionStatementsOf = (database) => {
341
+ const held = reactionStatements.get(database);
342
+ if (held)
343
+ return held;
344
+ const statements = {
345
+ gone: database.prepare("SELECT 1 FROM messages WHERE chat_id=? AND external_id=? AND deleted_at IS NOT NULL"),
346
+ upsert: database.prepare(`INSERT INTO message_reactions
347
+ (chat_id, message_external_id, identity_id, own, reaction, reacted_at, created_at, updated_at)
348
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
349
+ ON CONFLICT (chat_id, message_external_id, identity_id) DO UPDATE SET
350
+ own = excluded.own, reaction = excluded.reaction, reacted_at = excluded.reacted_at,
351
+ updated_at = excluded.updated_at
352
+ WHERE excluded.reacted_at >= message_reactions.reacted_at`),
353
+ held: database.prepare("SELECT reaction, own, reacted_at AS reactedAt FROM message_reactions WHERE chat_id=? AND message_external_id=?"),
354
+ total: database.prepare("UPDATE messages SET reactions=? WHERE chat_id=? AND external_id=? AND deleted_at IS NULL"),
355
+ };
356
+ reactionStatements.set(database, statements);
357
+ return statements;
358
+ };
359
+ /**
360
+ * The totals of the people's reactions the store holds for a message, or `undefined` when it holds none: then the
361
+ * messenger's own totals stand. A taken-back reaction still counts as held, so all of them taken back is zero.
362
+ */
363
+ export const derivedReactions = ({ database }, chatKey, messageId) => {
364
+ const rows = reactionStatementsOf(database).held.all(chatKey, messageId);
365
+ if (rows.length === 0)
366
+ return undefined;
367
+ const counts = new Map();
368
+ let mine = null;
369
+ for (const { reaction, own, reactedAt } of rows) {
370
+ if (reaction === null)
371
+ continue;
372
+ const held = counts.get(reaction);
373
+ counts.set(reaction, { count: (held?.count ?? 0) + 1, first: Math.min(held?.first ?? reactedAt, reactedAt) });
374
+ if (own === 1 && (mine === null || reactedAt > mine.at))
375
+ mine = { reaction, at: reactedAt };
376
+ }
377
+ const sorted = [...counts].sort(([, a], [, b]) => b.count - a.count || a.first - b.first);
378
+ return {
379
+ counts: sorted.map(([reaction, { count }]) => ({ reaction, count })),
380
+ mine: mine?.reaction ?? null,
381
+ total: sorted.reduce((sum, [, { count }]) => sum + count, 0),
382
+ };
383
+ };
384
+ /** A reaction older than the one held for that person is dropped, and so is any reaction to a deleted message. */
385
+ export const saveReaction = (context, accountKey, provider, chatKey, messageId, { person, own, reaction, reactedAt }) => {
386
+ const { database, now } = context;
387
+ const statements = reactionStatementsOf(database);
388
+ if (statements.gone.get(chatKey, messageId))
389
+ return;
390
+ const reactor = identityPk(context, accountKey, provider, person.id, person.name, person.username ? { username: person.username } : {});
391
+ const at = toMs(reactedAt) ?? now();
392
+ statements.upsert.run(chatKey, messageId, reactor, own ? 1 : 0, reaction, at, now(), now());
393
+ writeDerivedReactions(context, chatKey, messageId);
394
+ };
395
+ export const writeDerivedReactions = (context, chatKey, messageId) => {
396
+ const derived = derivedReactions(context, chatKey, messageId);
397
+ if (derived)
398
+ reactionStatementsOf(context.database).total.run(JSON.stringify(derived), chatKey, messageId);
399
+ };
@@ -554,5 +554,12 @@ export const GENERATED = [
554
554
  "CREATE TRIGGER chat_revisions_au AFTER UPDATE OF external_id, title, membership_state, scope, searchable, parent_chat_id ON chats BEGIN\n INSERT INTO chat_revisions (chat_id, account_id, field, old_value, new_value, source, created_at)\n SELECT new.id, new.account_id, f.field, f.old_value, f.new_value, s.source,\n coalesce(s.at, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))\n FROM (\n SELECT 'external_id' AS field, old.external_id AS old_value, new.external_id AS new_value\n UNION ALL SELECT 'title', old.title, new.title\n UNION ALL SELECT 'membership_state', old.membership_state, new.membership_state\n UNION ALL SELECT 'scope', old.scope, new.scope\n UNION ALL SELECT 'searchable', old.searchable, new.searchable\n UNION ALL SELECT 'parent_chat_id', old.parent_chat_id, new.parent_chat_id\n ) f\n LEFT JOIN chat_revision_source s ON s.id = 1\n WHERE f.old_value IS NOT f.new_value;\nEND;",
555
555
  "CREATE TRIGGER chat_revisions_ad AFTER DELETE ON chats BEGIN\n DELETE FROM chat_revisions WHERE chat_id = old.id;\nEND;"
556
556
  ]
557
+ },
558
+ {
559
+ "name": "20261021214318_version-26-message-reactions",
560
+ "statements": [
561
+ "CREATE TABLE `message_reactions` (\n\t`id` integer PRIMARY KEY,\n\t`chat_id` integer NOT NULL,\n\t`message_external_id` text NOT NULL,\n\t`identity_id` integer NOT NULL,\n\t`own` integer NOT NULL,\n\t`reaction` text,\n\t`reacted_at` integer NOT NULL,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\tCONSTRAINT `fk_message_reactions_chat_id_chats_id_fk` FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`),\n\tCONSTRAINT `fk_message_reactions_identity_id_identities_id_fk` FOREIGN KEY (`identity_id`) REFERENCES `identities`(`id`),\n\tCONSTRAINT `message_reactions_chat_id_message_external_id_identity_id_unique` UNIQUE(`chat_id`,`message_external_id`,`identity_id`)\n);",
562
+ "CREATE INDEX `message_reactions_by_identity_id` ON `message_reactions` (`identity_id`);"
563
+ ]
557
564
  }
558
565
  ];
@@ -13,6 +13,20 @@ import { assertReadCompatible } from "./read-compatibility.js";
13
13
  import { stemsState } from "./stems.js";
14
14
  import { fillStems, followDefaultStemmers } from "./stems-fill.js";
15
15
  export const BACKFILL_ON_OPEN = 5_000;
16
+ const NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS"]);
17
+ const createWritable = (file) => {
18
+ try {
19
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
20
+ // SQLite gives its sidecar files the database's permissions.
21
+ writeFileSync(file, "", { flag: "a", mode: 0o600 });
22
+ }
23
+ catch (error) {
24
+ if (!NOT_WRITABLE.has(error.code ?? ""))
25
+ throw error;
26
+ // The errno's own sentence names the path, which holds the owner's home folder.
27
+ throw new CliError("permission_error", "the message store cannot be written from here; run where it is writable, or set MESSAGING_STORE", { reason: "store.not-writable" });
28
+ }
29
+ };
16
30
  export const openStoreContext = async ({ path, env, now = Date.now, command, readOnly = false, } = {}) => {
17
31
  if (typeof readOnly !== "boolean")
18
32
  throw new CliError("validation_error", "readOnly must be a boolean");
@@ -22,11 +36,8 @@ export const openStoreContext = async ({ path, env, now = Date.now, command, rea
22
36
  if (!statSync(file, { throwIfNoEntry: false })?.isFile())
23
37
  throw new CliError("not_found", "The store does not exist as a file; read-only opening does not create it");
24
38
  }
25
- else {
26
- mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
27
- // SQLite gives its sidecar files the database's permissions.
28
- writeFileSync(file, "", { flag: "a", mode: 0o600 });
29
- }
39
+ else
40
+ createWritable(file);
30
41
  const opened = await openSqlite(file, { readOnly });
31
42
  const database = opened.database;
32
43
  try {
@@ -4478,6 +4478,161 @@ export declare const chatEventPeople: import("drizzle-orm/sqlite-core").SQLiteTa
4478
4478
  };
4479
4479
  dialect: 'sqlite';
4480
4480
  }>;
4481
+ /**
4482
+ * Each person's newest reaction to a message, for a messenger that sends one person's change at a time; the store
4483
+ * derives `messages.reactions` from them. Keyed by chat and external id: a reaction can arrive before its message.
4484
+ */
4485
+ export declare const messageReactions: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
4486
+ name: "message_reactions";
4487
+ schema: undefined;
4488
+ columns: {
4489
+ id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4490
+ name: string;
4491
+ tableName: "message_reactions";
4492
+ dataType: "number int53";
4493
+ data: number;
4494
+ driverParam: number;
4495
+ notNull: true;
4496
+ hasDefault: true;
4497
+ isPrimaryKey: true;
4498
+ isAutoincrement: false;
4499
+ hasRuntimeDefault: false;
4500
+ enumValues: undefined;
4501
+ baseColumn: never;
4502
+ identity: undefined;
4503
+ generated: undefined;
4504
+ }, {}>;
4505
+ chatId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4506
+ name: string;
4507
+ tableName: "message_reactions";
4508
+ dataType: "number int53";
4509
+ data: number;
4510
+ driverParam: number;
4511
+ notNull: true;
4512
+ hasDefault: false;
4513
+ isPrimaryKey: false;
4514
+ isAutoincrement: false;
4515
+ hasRuntimeDefault: false;
4516
+ enumValues: undefined;
4517
+ baseColumn: never;
4518
+ identity: undefined;
4519
+ generated: undefined;
4520
+ }, {}>;
4521
+ messageExternalId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4522
+ name: string;
4523
+ tableName: "message_reactions";
4524
+ dataType: "string";
4525
+ data: string;
4526
+ driverParam: string;
4527
+ notNull: true;
4528
+ hasDefault: false;
4529
+ isPrimaryKey: false;
4530
+ isAutoincrement: false;
4531
+ hasRuntimeDefault: false;
4532
+ enumValues: [string, ...string[]];
4533
+ baseColumn: never;
4534
+ identity: undefined;
4535
+ generated: undefined;
4536
+ }, {}>;
4537
+ identityId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4538
+ name: string;
4539
+ tableName: "message_reactions";
4540
+ dataType: "number int53";
4541
+ data: number;
4542
+ driverParam: number;
4543
+ notNull: true;
4544
+ hasDefault: false;
4545
+ isPrimaryKey: false;
4546
+ isAutoincrement: false;
4547
+ hasRuntimeDefault: false;
4548
+ enumValues: undefined;
4549
+ baseColumn: never;
4550
+ identity: undefined;
4551
+ generated: undefined;
4552
+ }, {}>;
4553
+ own: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4554
+ name: string;
4555
+ tableName: "message_reactions";
4556
+ dataType: "number int53";
4557
+ data: number;
4558
+ driverParam: number;
4559
+ notNull: true;
4560
+ hasDefault: false;
4561
+ isPrimaryKey: false;
4562
+ isAutoincrement: false;
4563
+ hasRuntimeDefault: false;
4564
+ enumValues: undefined;
4565
+ baseColumn: never;
4566
+ identity: undefined;
4567
+ generated: undefined;
4568
+ }, {}>;
4569
+ reaction: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4570
+ name: string;
4571
+ tableName: "message_reactions";
4572
+ dataType: "string";
4573
+ data: string;
4574
+ driverParam: string;
4575
+ notNull: false;
4576
+ hasDefault: false;
4577
+ isPrimaryKey: false;
4578
+ isAutoincrement: false;
4579
+ hasRuntimeDefault: false;
4580
+ enumValues: [string, ...string[]];
4581
+ baseColumn: never;
4582
+ identity: undefined;
4583
+ generated: undefined;
4584
+ }, {}>;
4585
+ reactedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4586
+ name: string;
4587
+ tableName: "message_reactions";
4588
+ dataType: "number int53";
4589
+ data: number;
4590
+ driverParam: number;
4591
+ notNull: true;
4592
+ hasDefault: false;
4593
+ isPrimaryKey: false;
4594
+ isAutoincrement: false;
4595
+ hasRuntimeDefault: false;
4596
+ enumValues: undefined;
4597
+ baseColumn: never;
4598
+ identity: undefined;
4599
+ generated: undefined;
4600
+ }, {}>;
4601
+ createdAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4602
+ name: string;
4603
+ tableName: "message_reactions";
4604
+ dataType: "number int53";
4605
+ data: number;
4606
+ driverParam: number;
4607
+ notNull: true;
4608
+ hasDefault: false;
4609
+ isPrimaryKey: false;
4610
+ isAutoincrement: false;
4611
+ hasRuntimeDefault: false;
4612
+ enumValues: undefined;
4613
+ baseColumn: never;
4614
+ identity: undefined;
4615
+ generated: undefined;
4616
+ }, {}>;
4617
+ updatedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4618
+ name: string;
4619
+ tableName: "message_reactions";
4620
+ dataType: "number int53";
4621
+ data: number;
4622
+ driverParam: number;
4623
+ notNull: true;
4624
+ hasDefault: false;
4625
+ isPrimaryKey: false;
4626
+ isAutoincrement: false;
4627
+ hasRuntimeDefault: false;
4628
+ enumValues: undefined;
4629
+ baseColumn: never;
4630
+ identity: undefined;
4631
+ generated: undefined;
4632
+ }, {}>;
4633
+ };
4634
+ dialect: 'sqlite';
4635
+ }>;
4481
4636
  /** One read of a chat's member list. Retention reads presence at a checkpoint from these, not from stays. */
4482
4637
  export declare const memberObservations: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
4483
4638
  name: "member_observations";
@@ -511,6 +511,28 @@ export const chatEventPeople = sqliteTable("chat_event_people", {
511
511
  primaryKey({ columns: [table.chatEventId, table.identityId] }),
512
512
  index("chat_event_people_by_identity_id").on(table.identityId),
513
513
  ]);
514
+ /**
515
+ * Each person's newest reaction to a message, for a messenger that sends one person's change at a time; the store
516
+ * derives `messages.reactions` from them. Keyed by chat and external id: a reaction can arrive before its message.
517
+ */
518
+ export const messageReactions = sqliteTable("message_reactions", {
519
+ id: integer("id").primaryKey(),
520
+ chatId: integer("chat_id")
521
+ .notNull()
522
+ .references(() => chats.id),
523
+ messageExternalId: text("message_external_id").notNull(),
524
+ identityId: integer("identity_id")
525
+ .notNull()
526
+ .references(() => identities.id),
527
+ own: integer("own").notNull(),
528
+ reaction: text("reaction"),
529
+ reactedAt: integer("reacted_at").notNull(),
530
+ createdAt: integer("created_at").notNull(),
531
+ updatedAt: integer("updated_at").notNull(),
532
+ }, (table) => [
533
+ unique().on(table.chatId, table.messageExternalId, table.identityId),
534
+ index("message_reactions_by_identity_id").on(table.identityId),
535
+ ]);
514
536
  /** One read of a chat's member list. Retention reads presence at a checkpoint from these, not from stays. */
515
537
  export const memberObservations = sqliteTable("member_observations", {
516
538
  id: integer("id").primaryKey(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.379.0",
3
+ "version": "0.381.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
@@ -173,6 +173,7 @@
173
173
  "@xmldom/xmldom": "0.9.12",
174
174
  "chardet": "2.2.0",
175
175
  "defuddle": "0.19.5",
176
+ "dictionary-ru": "3.0.0",
176
177
  "echarts": "6.1.0",
177
178
  "fflate": "0.8.3",
178
179
  "iconv-lite": "0.7.3",
@@ -202,7 +203,7 @@
202
203
  "@types/node": "^22.20.4",
203
204
  "@types/pngjs": "6.0.5",
204
205
  "@vitest/coverage-v8": "^5.0.2",
205
- "@wirecat/cli-base": "0.48.0",
206
+ "@wirecat/cli-base": "0.49.0",
206
207
  "commander": "15.0.0",
207
208
  "cspell": "10.3.6",
208
209
  "drizzle-kit": "1.0.0-rc.4",
@@ -222,7 +223,7 @@
222
223
  },
223
224
  "peerDependencies": {
224
225
  "@napi-rs/canvas": ">=0.1.69 <2",
225
- "@wirecat/cli-base": ">=0.48.0 <0.49.0",
226
+ "@wirecat/cli-base": ">=0.49.0 <0.50.0",
226
227
  "commander": ">=15"
227
228
  },
228
229
  "bin": {