@wirecat/cli-core 0.378.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 (95) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/cli/envelope.d.ts +10 -0
  3. package/dist/cli/list-query.js +2 -2
  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/meeting-preparation-read.js +5 -5
  8. package/dist/cli/meeting-series-inventory-read.js +3 -4
  9. package/dist/cli/messenger/chats-stats-command.js +5 -1
  10. package/dist/cli/messenger/contacts-command.js +8 -4
  11. package/dist/cli/messenger/stats-answers.d.ts +2 -1
  12. package/dist/cli/messenger/stats-answers.js +1 -1
  13. package/dist/cli/messenger/stats-command.js +4 -7
  14. package/dist/domain/moment.d.ts +8 -0
  15. package/dist/domain/moment.js +14 -0
  16. package/dist/domain/send-time.d.ts +2 -2
  17. package/dist/domain/send-time.js +4 -3
  18. package/dist/entities/account-session.d.ts +2 -2
  19. package/dist/entities/admin-report.d.ts +8 -8
  20. package/dist/entities/agenda.d.ts +12 -12
  21. package/dist/entities/chat-event.d.ts +1 -1
  22. package/dist/entities/chat-stats.d.ts +5 -5
  23. package/dist/entities/chat.d.ts +4 -4
  24. package/dist/entities/contact-note.d.ts +2 -2
  25. package/dist/entities/contact.d.ts +13 -13
  26. package/dist/entities/conversation.d.ts +3 -3
  27. package/dist/entities/counter-reading.d.ts +1 -1
  28. package/dist/entities/counters.d.ts +2 -2
  29. package/dist/entities/document.d.ts +2 -2
  30. package/dist/entities/evidence.d.ts +72 -72
  31. package/dist/entities/fact.d.ts +3 -3
  32. package/dist/entities/fields.d.ts +3 -1
  33. package/dist/entities/fields.js +2 -0
  34. package/dist/entities/hit.d.ts +1 -1
  35. package/dist/entities/index.d.ts +231 -215
  36. package/dist/entities/index.js +1 -0
  37. package/dist/entities/invite-link.d.ts +1 -1
  38. package/dist/entities/job.d.ts +2 -2
  39. package/dist/entities/join-request.d.ts +1 -1
  40. package/dist/entities/meeting-communications.d.ts +4 -4
  41. package/dist/entities/meeting-findings.d.ts +2 -2
  42. package/dist/entities/meeting-preparation-parts.d.ts +9 -9
  43. package/dist/entities/meeting-preparation.d.ts +28 -23
  44. package/dist/entities/meeting-series-inventory.d.ts +3 -3
  45. package/dist/entities/member.d.ts +3 -3
  46. package/dist/entities/memory.d.ts +3 -3
  47. package/dist/entities/message.d.ts +6 -6
  48. package/dist/entities/note.d.ts +3 -3
  49. package/dist/entities/official-stats.d.ts +2 -2
  50. package/dist/entities/poll-vote.d.ts +1 -1
  51. package/dist/entities/ranking.d.ts +2 -2
  52. package/dist/entities/relationship.d.ts +1 -1
  53. package/dist/entities/retention.d.ts +4 -4
  54. package/dist/entities/saved-search.d.ts +2 -2
  55. package/dist/entities/tagging.d.ts +1 -1
  56. package/dist/entities/task.d.ts +7 -7
  57. package/dist/entities/topic.d.ts +2 -2
  58. package/dist/mcp/meeting-preparation.d.ts +5 -0
  59. package/dist/mcp/tools/stats.js +9 -4
  60. package/dist/meetings/answers.d.ts +20 -14
  61. package/dist/meetings/answers.js +5 -0
  62. package/dist/meetings/cli/index.d.ts +2 -0
  63. package/dist/meetings/cli/index.js +12 -6
  64. package/dist/meetings/cli/search.js +12 -14
  65. package/dist/meetings/hit-query.d.ts +12 -0
  66. package/dist/meetings/hit-query.js +58 -0
  67. package/dist/meetings/import.d.ts +2 -0
  68. package/dist/meetings/import.js +8 -2
  69. package/dist/meetings/index.d.ts +2 -0
  70. package/dist/meetings/index.js +2 -0
  71. package/dist/meetings/item.d.ts +4 -4
  72. package/dist/meetings/mcp/search.d.ts +6 -0
  73. package/dist/meetings/mcp/search.js +5 -1
  74. package/dist/meetings/project-evidence-answer.d.ts +8 -3
  75. package/dist/meetings/pull.d.ts +2 -0
  76. package/dist/meetings/recording-schedule.js +6 -5
  77. package/dist/meetings/search-store.d.ts +3 -1
  78. package/dist/meetings/store.d.ts +7 -0
  79. package/dist/meetings/transcript-text.d.ts +20 -0
  80. package/dist/meetings/transcript-text.js +59 -0
  81. package/dist/render/markdown.js +2 -2
  82. package/dist/search/garbled.d.ts +29 -0
  83. package/dist/search/garbled.js +125 -0
  84. package/dist/search/russian-dictionary.d.ts +5 -0
  85. package/dist/search/russian-dictionary.js +66 -0
  86. package/dist/services/admin-statistics.js +2 -2
  87. package/dist/services/meeting-project-discovery.js +2 -2
  88. package/dist/services/moment.d.ts +25 -2
  89. package/dist/services/moment.js +32 -4
  90. package/dist/services/retention.js +6 -3
  91. package/dist/store/sqlite/facts.js +2 -1
  92. package/dist/store/sqlite/meeting-hit-query.d.ts +60 -0
  93. package/dist/store/sqlite/meeting-hit-query.js +262 -0
  94. package/dist/store/sqlite/meeting-hit-search.js +28 -45
  95. package/package.json +4 -3
@@ -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
+ };
@@ -213,10 +213,10 @@ export const adminStatisticsService = (deps) => ({
213
213
  : duration(query.olderThan ?? "24h", "--older-than"),
214
214
  within: query.within === undefined && pinned ? pinned.options.within : duration(query.within ?? "7d", "--within"),
215
215
  joinSince: query.sinceTime
216
- ? momentOf(query.sinceTime, "--since-time", cutoff)
216
+ ? momentOf(query.sinceTime, "--since-time", cutoff, { zone: prepared.timezone })
217
217
  : (pinned?.options.joinSince ?? cutoff - 30 * 86_400_000),
218
218
  joinUntil: query.untilTime
219
- ? momentOf(query.untilTime, "--until-time", cutoff)
219
+ ? momentOf(query.untilTime, "--until-time", cutoff, { zone: prepared.timezone, end: true })
220
220
  : (pinned?.options.joinUntil ?? cutoff),
221
221
  minViews: count(query.minViews ?? pinned?.options.minViews ?? 1, "--min-views"),
222
222
  maxReplies: count(query.maxReplies ?? pinned?.options.maxReplies ?? 0, "--max-replies"),
@@ -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
  });
@@ -1,12 +1,35 @@
1
+ import { type MomentZone } from "../domain/moment.js";
2
+ export type { MomentZone };
1
3
  /** Shaped like a moment `momentOf` reads, whether or not it is a valid one. */
2
4
  export declare const isMoment: (reference: string) => boolean;
3
5
  /**
4
6
  * `--since` as a moment: ISO 8601, or `30m`, `2h`, `1d` ago. Unlike max-cli, never a message id —
5
7
  * a MAX id carries its time, and a Telegram id is only a counter within one chat.
6
8
  */
7
- export declare const momentOf: (reference: string, flag?: string, now?: number) => number;
9
+ export declare const momentOf: (reference: string, flag?: string, now?: number, at?: MomentZone) => number;
8
10
  /**
9
11
  * A time ahead, for a due date or the next check: ISO 8601, or `30m`, `2h`, `3d` from now. `none`
10
12
  * is not a time; a caller that clears a date checks for it first.
11
13
  */
12
- export declare const aheadOf: (reference: string, flag: string, now?: number) => number;
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,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { timezoneOf } from "@wirecat/cli-base/query";
3
+ import { calendarMoment } from "../domain/moment.js";
2
4
  const AGO = /^(\d+)(m|h|d)$/;
3
5
  const AGO_MS = { m: 60_000, h: 3_600_000, d: 86_400_000 };
4
6
  const DATE = /^\d{4}-\d{2}-\d{2}/;
@@ -11,11 +13,15 @@ export const isMoment = (reference) => {
11
13
  * `--since` as a moment: ISO 8601, or `30m`, `2h`, `1d` ago. Unlike max-cli, never a message id —
12
14
  * a MAX id carries its time, and a Telegram id is only a counter within one chat.
13
15
  */
14
- export const momentOf = (reference, flag = "--since", now = Date.now()) => {
16
+ export const momentOf = (reference, flag = "--since", now = Date.now(), at = {}) => {
15
17
  const wanted = reference.trim();
16
18
  const [, amount, unit] = AGO.exec(wanted) ?? [];
17
19
  // By shape: `Date.parse("12345")` is the year 12345, so a message id would pass as a date.
18
- const time = amount && unit ? now - Number(amount) * (AGO_MS[unit] ?? 0) : DATE.test(wanted) ? Date.parse(wanted) : Number.NaN;
20
+ const time = amount && unit
21
+ ? now - Number(amount) * (AGO_MS[unit] ?? 0)
22
+ : DATE.test(wanted)
23
+ ? calendarMoment(wanted, flag, at)
24
+ : Number.NaN;
19
25
  if (Number.isNaN(time)) {
20
26
  throw new CliError("validation_error", `${flag} takes an ISO 8601 time or 30m, 2h, 1d ago — not "${wanted}"`);
21
27
  }
@@ -25,12 +31,34 @@ export const momentOf = (reference, flag = "--since", now = Date.now()) => {
25
31
  * A time ahead, for a due date or the next check: ISO 8601, or `30m`, `2h`, `3d` from now. `none`
26
32
  * is not a time; a caller that clears a date checks for it first.
27
33
  */
28
- export const aheadOf = (reference, flag, now = Date.now()) => {
34
+ export const aheadOf = (reference, flag, now = Date.now(), at = {}) => {
29
35
  const wanted = reference.trim();
30
36
  const [, amount, unit] = AGO.exec(wanted) ?? [];
31
- const time = amount && unit ? now + Number(amount) * (AGO_MS[unit] ?? 0) : DATE.test(wanted) ? Date.parse(wanted) : Number.NaN;
37
+ const time = amount && unit
38
+ ? now + Number(amount) * (AGO_MS[unit] ?? 0)
39
+ : DATE.test(wanted)
40
+ ? calendarMoment(wanted, flag, at)
41
+ : Number.NaN;
32
42
  if (Number.isNaN(time)) {
33
43
  throw new CliError("validation_error", `${flag} takes an ISO 8601 time or 30m, 2h, 3d from now — not "${wanted}"`);
34
44
  }
35
45
  return time;
36
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,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { FACT_TYPES, MEMORY_SCOPES } from "../../domain/knowledge.js";
3
+ import { calendarMoment } from "../../domain/moment.js";
3
4
  import { linkKind } from "./link-kinds.js";
4
5
  import { authorActor, authorOf, evidenceOf, linkEvidence } from "./memories.js";
5
6
  import { requiredThing } from "./notes.js";
@@ -64,7 +65,7 @@ export const factOperationsOver = (context) => {
64
65
  if (!statement || statement.length > 2000)
65
66
  throw new CliError("validation_error", "a fact's statement takes 1–2000 characters");
66
67
  const scope = input.scope === undefined ? null : oneOf(input.scope, MEMORY_SCOPES, "a fact's scope");
67
- const occurredAt = input.occurredAt === undefined ? null : Date.parse(input.occurredAt);
68
+ const occurredAt = input.occurredAt === undefined ? null : calendarMoment(input.occurredAt, "occurredAt");
68
69
  if (occurredAt !== null && !Number.isFinite(occurredAt))
69
70
  throw new CliError("validation_error", "occurredAt takes an ISO timestamp");
70
71
  if (input.supersedes !== undefined && type !== "decision")
@@ -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 {};