@wirecat/cli-core 0.379.0 → 0.380.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 (46) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/cli/meeting-preparation-answer.d.ts +5 -0
  3. package/dist/cli/meeting-preparation-projects.d.ts +5 -0
  4. package/dist/cli/meeting-preparation-read.d.ts +5 -0
  5. package/dist/cli/messenger/chats-stats-command.js +5 -1
  6. package/dist/cli/messenger/contacts-command.js +8 -4
  7. package/dist/cli/messenger/stats-answers.d.ts +2 -1
  8. package/dist/cli/messenger/stats-answers.js +1 -1
  9. package/dist/cli/messenger/stats-command.js +4 -9
  10. package/dist/entities/index.d.ts +15 -0
  11. package/dist/entities/meeting-preparation.d.ts +5 -0
  12. package/dist/mcp/meeting-preparation.d.ts +5 -0
  13. package/dist/mcp/tools/stats.js +9 -6
  14. package/dist/meetings/answers.d.ts +6 -0
  15. package/dist/meetings/answers.js +5 -0
  16. package/dist/meetings/cli/index.d.ts +2 -0
  17. package/dist/meetings/cli/index.js +5 -2
  18. package/dist/meetings/cli/search.js +1 -1
  19. package/dist/meetings/hit-query.d.ts +12 -0
  20. package/dist/meetings/hit-query.js +58 -0
  21. package/dist/meetings/import.d.ts +2 -0
  22. package/dist/meetings/import.js +8 -2
  23. package/dist/meetings/index.d.ts +2 -0
  24. package/dist/meetings/index.js +2 -0
  25. package/dist/meetings/mcp/search.d.ts +6 -0
  26. package/dist/meetings/mcp/search.js +5 -1
  27. package/dist/meetings/project-evidence-answer.d.ts +5 -0
  28. package/dist/meetings/pull.d.ts +2 -0
  29. package/dist/meetings/recording-schedule.js +6 -5
  30. package/dist/meetings/search-store.d.ts +3 -1
  31. package/dist/meetings/store.d.ts +7 -0
  32. package/dist/meetings/transcript-text.d.ts +20 -0
  33. package/dist/meetings/transcript-text.js +59 -0
  34. package/dist/render/markdown.js +2 -2
  35. package/dist/search/garbled.d.ts +29 -0
  36. package/dist/search/garbled.js +125 -0
  37. package/dist/search/russian-dictionary.d.ts +5 -0
  38. package/dist/search/russian-dictionary.js +66 -0
  39. package/dist/services/meeting-project-discovery.js +2 -2
  40. package/dist/services/moment.d.ts +21 -0
  41. package/dist/services/moment.js +19 -0
  42. package/dist/services/retention.js +6 -3
  43. package/dist/store/sqlite/meeting-hit-query.d.ts +60 -0
  44. package/dist/store/sqlite/meeting-hit-query.js +262 -0
  45. package/dist/store/sqlite/meeting-hit-search.js +28 -45
  46. package/package.json +4 -3
@@ -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
+ };
@@ -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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.379.0",
3
+ "version": "0.380.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": {