@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
@@ -1,3 +1,4 @@
1
+ import type { QueryAst } from "@wirecat/cli-base/query";
1
2
  import type { SearchHit } from "./store.js";
2
3
  export interface MeetingSearchOptions {
3
4
  accountId: number;
@@ -37,7 +38,8 @@ export interface MeetingSearchPage {
37
38
  }
38
39
  /** Optional bounded read capability; paging reaches inside a single large transcript. */
39
40
  export interface MeetingSearchStore {
40
- searchHits(query: string, options: MeetingSearchOptions): Promise<MeetingSearchPage>;
41
+ /** A string is what a person typed, parsed as a query; text that must not become syntax comes as `meetingWordsQuery`. */
42
+ searchHits(query: string | QueryAst, options: MeetingSearchOptions): Promise<MeetingSearchPage>;
41
43
  }
42
44
  export interface MeetingSearchIndexInput {
43
45
  accountId: number;
@@ -82,6 +82,13 @@ export interface SearchHit {
82
82
  id: number;
83
83
  text: string;
84
84
  startMs: number | null;
85
+ /** `corrected` when a query word matched only as a stored word recognition may have garbled. */
86
+ match?: "words" | "corrected";
87
+ /** For a `corrected` hit, the stored words each such query word matched in this passage. */
88
+ corrections?: {
89
+ from: string;
90
+ to: string[];
91
+ }[];
85
92
  }
86
93
  export interface ContractCase {
87
94
  name: string;
@@ -0,0 +1,20 @@
1
+ import type { TranscriptLine } from "./model.js";
2
+ export declare class TranscriptTextError extends Error {
3
+ name: string;
4
+ }
5
+ /** What the header of a text transcript says, as written; the caller reads the date in its zone. */
6
+ export interface TranscriptTextHeader {
7
+ title: string | null;
8
+ date: string | null;
9
+ meetingId: string | null;
10
+ speakers: string[];
11
+ }
12
+ /**
13
+ * A transcript in the text form zoom-searcher wrote: a header (`Встреча:`, `Дата:`, `Zoom ID:`, `Говорят:`),
14
+ * a rule, then `[hh:mm:ss] Speaker: text` blocks apart by a blank line. A block may run over several lines.
15
+ * A line ends where the next begins: the file keeps start times only.
16
+ */
17
+ export declare const parseTranscriptText: (input: string) => {
18
+ header: TranscriptTextHeader;
19
+ lines: TranscriptLine[];
20
+ };
@@ -0,0 +1,59 @@
1
+ export class TranscriptTextError extends Error {
2
+ name = "TranscriptTextError";
3
+ }
4
+ const HEADER = {
5
+ "Встреча: ": "title",
6
+ "Дата: ": "date",
7
+ "Zoom ID: ": "meetingId",
8
+ "Говорят: ": "speakers",
9
+ };
10
+ const LINE = /^\[(\d{2,}):([0-5]\d):([0-5]\d)\]\s*(?:([^:\n]{1,60}):\s*)?([\s\S]*)$/;
11
+ // Early files had no blank line after the rule, so the first line of speech is glued to it.
12
+ const RULE = /^[—–-]{10,}[^\n]*(?:\n|$)/;
13
+ const EMPTY = new Set(["", "—", "-"]);
14
+ /**
15
+ * A transcript in the text form zoom-searcher wrote: a header (`Встреча:`, `Дата:`, `Zoom ID:`, `Говорят:`),
16
+ * a rule, then `[hh:mm:ss] Speaker: text` blocks apart by a blank line. A block may run over several lines.
17
+ * A line ends where the next begins: the file keeps start times only.
18
+ */
19
+ export const parseTranscriptText = (input) => {
20
+ const text = input.replace(/^/, "").replace(/\r\n?/g, "\n");
21
+ const header = { title: null, date: null, meetingId: null, speakers: [] };
22
+ let sawHeader = false;
23
+ for (const line of text.split("\n")) {
24
+ if (!line.trim())
25
+ break;
26
+ for (const [prefix, key] of Object.entries(HEADER)) {
27
+ if (!line.startsWith(prefix))
28
+ continue;
29
+ sawHeader = true;
30
+ const value = line.slice(prefix.length).trim();
31
+ if (key === "speakers")
32
+ header.speakers = value
33
+ .split(",")
34
+ .map((name) => name.trim())
35
+ .filter((name) => !EMPTY.has(name));
36
+ else
37
+ header[key] = EMPTY.has(value) ? null : value;
38
+ }
39
+ }
40
+ if (!sawHeader)
41
+ throw new TranscriptTextError('a text transcript starts with a header such as "Встреча: …"');
42
+ const found = [];
43
+ for (const block of text.split(/\n\s*\n/)) {
44
+ const match = LINE.exec(block.trim().replace(RULE, ""));
45
+ if (!match)
46
+ continue;
47
+ const [, hours = "0", minutes = "0", seconds = "0", speaker, said = ""] = match;
48
+ found.push({
49
+ startMs: ((Number(hours) * 60 + Number(minutes)) * 60 + Number(seconds)) * 1000,
50
+ speaker: speaker?.trim() || null,
51
+ text: said.trim(),
52
+ });
53
+ }
54
+ const lines = found.map((line, at) => ({
55
+ ...line,
56
+ endMs: Math.max(line.startMs, found[at + 1]?.startMs ?? line.startMs),
57
+ }));
58
+ return { header, lines };
59
+ };
@@ -8,11 +8,11 @@ import { singleLine, visibleControls } from "@wirecat/cli-base";
8
8
  * as often as in an editor, and a byte that rewrites the screen is nobody's text.
9
9
  */
10
10
  export const toMarkdown = (title, messages, timeZone) => {
11
- const zone = timeZone ? { timeZone } : {};
11
+ const zone = { timeZone: timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone };
12
12
  const day = new Intl.DateTimeFormat("sv-SE", { ...zone, year: "numeric", month: "2-digit", day: "2-digit" });
13
13
  const clock = new Intl.DateTimeFormat("en-GB", { ...zone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
14
14
  const byId = new Map(messages.map((message) => [message.id, message]));
15
- const lines = [`# ${literal(singleLine(title))}`];
15
+ const lines = [`# ${literal(singleLine(title))}`, "", `Times are in ${zone.timeZone}.`];
16
16
  let today = "";
17
17
  for (const message of messages) {
18
18
  const when = new Date(message.timestamp);
@@ -0,0 +1,29 @@
1
+ /** A query word as speech search widens it: its folded form and the stems the store's stemmers give it. */
2
+ export interface GarbledProbe {
3
+ literal: string;
4
+ stems: string[];
5
+ }
6
+ /** ztx's floor: a shorter word has too many neighbours to widen safely. */
7
+ export declare const SHORTEST_GARBLED = 4;
8
+ export declare const isNumberWord: (word: string) => boolean;
9
+ /** What the index already finds for the word: its beginning or one of its stems. */
10
+ export declare const plainMatch: (probe: GarbledProbe, word: string, stems: string[]) => boolean;
11
+ /**
12
+ * How far a stored word is from a query word, or `null` when it is not a candidate. `0` is another form the
13
+ * stemmer cut differently: a stem one side extends counts only when the other side's word spells the extension
14
+ * out — «циана» (stem `циа`) reaches «цианом» (`циан`), «устав» (`уста`) does not reach «установить» (`установ`).
15
+ * `1` is a spelling recognition may have garbled, compared by stem: «рилторов» is three edits from «риелтор»
16
+ * as a word but one as a stem.
17
+ */
18
+ export declare const garbledDistance: (probe: GarbledProbe, word: string, stems: string[]) => number | null;
19
+ /**
20
+ * The stored words each probe widens to, nearest first and then the most used, from one pass over the
21
+ * vocabulary, whose terms are folded as the index folds them. `stem` is the store's stemmer, so a stored
22
+ * word's stem is the one its index holds. A spelling that `known` finds in a dictionary is a different real
23
+ * word, not a garbled one — «договор» is one stem edit from «поговорить» — so only unknown spellings widen;
24
+ * on real transcripts that kept 517 of 10,271 widened passages.
25
+ */
26
+ export declare const widenWords: (probes: GarbledProbe[], vocabulary: Iterable<{
27
+ term: string;
28
+ docs: number;
29
+ }>, stem: (word: string) => string[], known: (word: string) => boolean) => string[][];
@@ -0,0 +1,125 @@
1
+ import { editDistance } from "./correct.js";
2
+ import { createStemmer } from "./stem.js";
3
+ /** ztx's floor: a shorter word has too many neighbours to widen safely. */
4
+ export const SHORTEST_GARBLED = 4;
5
+ const MOST_WIDENED = 64;
6
+ /** The index folds ё but keeps э, and recognition writes «риэлтор» as often as «риелтор». */
7
+ const plain = (word) => word.replaceAll("э", "е");
8
+ const russian = createStemmer({ cyrillic: "russian", latin: "none" }, { cacheLimit: 10_000 });
9
+ /**
10
+ * Recognition swaps one number for another («восемь» and «шесть» for one spoken word), and «десять» is one edit
11
+ * from «девять»; a widened number would be a wrong fact, so number words only ever match their own forms.
12
+ */
13
+ const NUMBER_STEMS = new Set(`ноль нуль один одна одно одного одной одному одним одном одну два две двух двум двумя три трех трем тремя
14
+ четыре четырех четырем четырьмя пять пяти пятью шесть шести шестью семь семи семью восемь восьми восемью
15
+ девять девяти девятью десять десяти десятью одиннадцать двенадцать тринадцать четырнадцать пятнадцать
16
+ шестнадцать семнадцать восемнадцать девятнадцать двадцать двадцати тридцать тридцати сорок сорока пятьдесят
17
+ пятидесяти шестьдесят шестидесяти семьдесят семидесяти восемьдесят восьмидесяти девяносто девяноста сто ста
18
+ двести двухсот триста трехсот четыреста четырехсот пятьсот пятисот шестьсот семьсот восемьсот девятьсот
19
+ тысяча тысячи тысяч миллион миллиона миллионов миллиард первый второй третий четвертый пятый шестой седьмой
20
+ восьмой девятый десятый сотый тысячный половина треть четверть`
21
+ .split(/\s+/)
22
+ .map((word) => russian.stemToken(word)));
23
+ export const isNumberWord = (word) => /\d/.test(word) || NUMBER_STEMS.has(russian.stemToken(word));
24
+ /**
25
+ * One edit between stems, tighter than typed-text correction (`maxEdits`), because a transcript always widens
26
+ * instead of only when nothing matched: two edits turn «проверка» into «провела», «работа» into «заботы».
27
+ * A stem shorter than this keeps its first letter; a longer one may gain one, as «дриелтора» does.
28
+ */
29
+ const LOOSE_START = 7;
30
+ /** What the index already finds for the word: its beginning or one of its stems. */
31
+ export const plainMatch = (probe, word, stems) => word.startsWith(probe.literal) || stems.some((stem) => probe.stems.includes(stem));
32
+ /**
33
+ * How far a stored word is from a query word, or `null` when it is not a candidate. `0` is another form the
34
+ * stemmer cut differently: a stem one side extends counts only when the other side's word spells the extension
35
+ * out — «циана» (stem `циа`) reaches «цианом» (`циан`), «устав» (`уста`) does not reach «установить» (`установ`).
36
+ * `1` is a spelling recognition may have garbled, compared by stem: «рилторов» is three edits from «риелтор»
37
+ * as a word but one as a stem.
38
+ */
39
+ export const garbledDistance = (probe, word, stems) => {
40
+ if (probe.literal.length < SHORTEST_GARBLED || plainMatch(probe, word, stems))
41
+ return null;
42
+ if (isNumberWord(probe.literal) || isNumberWord(word))
43
+ return null;
44
+ const literal = plain(probe.literal);
45
+ const stored = plain(word);
46
+ let best = null;
47
+ for (const queryStem of probe.stems.map(plain))
48
+ for (const wordStem of stems.map(plain)) {
49
+ if (Math.min(queryStem.length, wordStem.length) < 3)
50
+ continue;
51
+ if ((wordStem.startsWith(queryStem) && literal.startsWith(wordStem)) ||
52
+ (queryStem.startsWith(wordStem) && stored.startsWith(queryStem)))
53
+ return 0;
54
+ if (queryStem.length < SHORTEST_GARBLED)
55
+ continue;
56
+ if (queryStem.length < LOOSE_START && queryStem[0] !== wordStem[0])
57
+ continue;
58
+ const distance = editDistance(queryStem, wordStem, 1);
59
+ if (distance <= 1)
60
+ best = distance;
61
+ }
62
+ return best;
63
+ };
64
+ /** `editDistance` from `stem` to the nearest beginning of `word`, or `max + 1` once every beginning is further. */
65
+ const prefixDistance = (stem, word, max) => {
66
+ let before = new Int32Array(word.length + 1);
67
+ let previous = new Int32Array(word.length + 1);
68
+ let current = new Int32Array(word.length + 1);
69
+ for (let i = 1; i <= stem.length; i++) {
70
+ current[0] = i;
71
+ let lowest = i;
72
+ for (let j = 1; j <= word.length; j++) {
73
+ let value = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (stem[i - 1] === word[j - 1] ? 0 : 1));
74
+ if (i > 1 && j > 1 && stem[i - 1] === word[j - 2] && stem[i - 2] === word[j - 1])
75
+ value = Math.min(value, before[j - 2] + 1);
76
+ current[j] = value;
77
+ lowest = Math.min(lowest, value);
78
+ }
79
+ if (lowest > max)
80
+ return max + 1;
81
+ [before, previous, current] = [previous, current, before];
82
+ }
83
+ return previous.reduce((lowest, value) => Math.min(lowest, value), max + 1);
84
+ };
85
+ /**
86
+ * Whether a stored word can pass `garbledDistance`, checked without stemming it: a stem begins its word, so a
87
+ * near stem means a near beginning. Stemming every stored word in a fresh process costs half a second.
88
+ */
89
+ const worthStemming = (probe, word) => {
90
+ const stored = plain(word);
91
+ return probe.stems.some((raw) => {
92
+ const stem = plain(raw);
93
+ if (stem.length >= 3 && stored.startsWith(stem))
94
+ return true;
95
+ return stem.length >= SHORTEST_GARBLED && prefixDistance(stem, stored, 1) <= 1;
96
+ });
97
+ };
98
+ /**
99
+ * The stored words each probe widens to, nearest first and then the most used, from one pass over the
100
+ * vocabulary, whose terms are folded as the index folds them. `stem` is the store's stemmer, so a stored
101
+ * word's stem is the one its index holds. A spelling that `known` finds in a dictionary is a different real
102
+ * word, not a garbled one — «договор» is one stem edit from «поговорить» — so only unknown spellings widen;
103
+ * on real transcripts that kept 517 of 10,271 widened passages.
104
+ */
105
+ export const widenWords = (probes, vocabulary, stem, known) => {
106
+ const found = probes.map(() => []);
107
+ const live = probes.map((probe) => probe.literal.length >= SHORTEST_GARBLED);
108
+ if (!live.some(Boolean))
109
+ return probes.map(() => []);
110
+ for (const { term: word, docs } of vocabulary) {
111
+ let stems;
112
+ probes.forEach((probe, at) => {
113
+ if (!live[at] || !worthStemming(probe, word))
114
+ return;
115
+ stems ??= stem(word);
116
+ const distance = garbledDistance(probe, word, stems);
117
+ if (distance === 0 || (distance === 1 && !known(word)))
118
+ found[at]?.push({ term: word, docs, distance });
119
+ });
120
+ }
121
+ return found.map((words) => words
122
+ .sort((a, b) => a.distance - b.distance || b.docs - a.docs || a.term.localeCompare(b.term))
123
+ .slice(0, MOST_WIDENED)
124
+ .map((word) => word.term));
125
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Whether a word the index stores is a real Russian word. The index folds й into и and ё into е, so each is
3
+ * tried back one letter at a time.
4
+ */
5
+ export declare const knownRussianWord: (word: string) => boolean;
@@ -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();
@@ -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 {};