@wirecat/cli-core 0.379.0 → 0.381.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/cli/bot/watch.js +1 -1
  3. package/dist/cli/index.d.ts +1 -1
  4. package/dist/cli/meeting-preparation-answer.d.ts +5 -0
  5. package/dist/cli/meeting-preparation-projects.d.ts +5 -0
  6. package/dist/cli/meeting-preparation-read.d.ts +5 -0
  7. package/dist/cli/messenger/chats-stats-command.js +5 -1
  8. package/dist/cli/messenger/contacts-command.js +8 -4
  9. package/dist/cli/messenger/stats-answers.d.ts +2 -1
  10. package/dist/cli/messenger/stats-answers.js +1 -1
  11. package/dist/cli/messenger/stats-command.js +4 -9
  12. package/dist/cli/messenger/stored.js +12 -7
  13. package/dist/cli/messenger/watch-command.js +6 -0
  14. package/dist/domain/models.d.ts +23 -2
  15. package/dist/entities/index.d.ts +15 -0
  16. package/dist/entities/meeting-preparation.d.ts +5 -0
  17. package/dist/errors/reasons.js +1 -0
  18. package/dist/mcp/meeting-preparation.d.ts +5 -0
  19. package/dist/mcp/tools/stats.js +9 -6
  20. package/dist/meetings/answers.d.ts +6 -0
  21. package/dist/meetings/answers.js +5 -0
  22. package/dist/meetings/cli/index.d.ts +2 -0
  23. package/dist/meetings/cli/index.js +5 -2
  24. package/dist/meetings/cli/search.js +1 -1
  25. package/dist/meetings/hit-query.d.ts +12 -0
  26. package/dist/meetings/hit-query.js +58 -0
  27. package/dist/meetings/import.d.ts +2 -0
  28. package/dist/meetings/import.js +9 -2
  29. package/dist/meetings/index.d.ts +2 -0
  30. package/dist/meetings/index.js +2 -0
  31. package/dist/meetings/mcp/search.d.ts +6 -0
  32. package/dist/meetings/mcp/search.js +5 -1
  33. package/dist/meetings/project-evidence-answer.d.ts +5 -0
  34. package/dist/meetings/pull.d.ts +2 -0
  35. package/dist/meetings/recording-schedule.js +6 -5
  36. package/dist/meetings/search-store.d.ts +3 -1
  37. package/dist/meetings/store.d.ts +13 -0
  38. package/dist/meetings/transcript-text.d.ts +20 -0
  39. package/dist/meetings/transcript-text.js +59 -0
  40. package/dist/ports/messenger.d.ts +10 -3
  41. package/dist/render/markdown.js +2 -2
  42. package/dist/resolve.d.ts +6 -0
  43. package/dist/resolve.js +13 -4
  44. package/dist/search/garbled.d.ts +30 -0
  45. package/dist/search/garbled.js +130 -0
  46. package/dist/search/russian-dictionary.d.ts +5 -0
  47. package/dist/search/russian-dictionary.js +66 -0
  48. package/dist/services/meeting-project-discovery.js +2 -2
  49. package/dist/services/moment.d.ts +21 -0
  50. package/dist/services/moment.js +19 -0
  51. package/dist/services/retention.js +6 -3
  52. package/dist/store/messages.d.ts +16 -3
  53. package/dist/store/messages.js +7 -1
  54. package/dist/store/sqlite/accounts.js +2 -1
  55. package/dist/store/sqlite/chat-rekey.js +9 -1
  56. package/dist/store/sqlite/chats.js +2 -1
  57. package/dist/store/sqlite/manifest.js +1 -0
  58. package/dist/store/sqlite/meeting-hit-query.d.ts +60 -0
  59. package/dist/store/sqlite/meeting-hit-query.js +262 -0
  60. package/dist/store/sqlite/meeting-hit-search.js +28 -45
  61. package/dist/store/sqlite/meetings.js +56 -1
  62. package/dist/store/sqlite/messages.d.ts +11 -3
  63. package/dist/store/sqlite/messages.js +74 -3
  64. package/dist/store/sqlite/migrations.generated.js +7 -0
  65. package/dist/store/sqlite/open-store.js +16 -5
  66. package/dist/store/sqlite/schema.d.ts +155 -0
  67. package/dist/store/sqlite/schema.js +22 -0
  68. package/package.json +4 -3
@@ -0,0 +1,58 @@
1
+ import { defineQuery, parseLucene, queryError, validateQuery, } from "@wirecat/cli-base/query";
2
+ const context = {
3
+ command: "search meetings",
4
+ guide: "https://github.com/WireCatLabs/cli-core/blob/main/docs/search/query-language.md#встречи",
5
+ };
6
+ const words = ["term", "phrase"];
7
+ /** A transcript line holds a sentence or two; further than this is a different part of the meeting. */
8
+ export const MOST_LINES_APART = 20;
9
+ const near = (node) => {
10
+ if (node.slop !== undefined && node.slop > MOST_LINES_APART)
11
+ queryError("query.invalid-number", node.span, `words at most ${MOST_LINES_APART} lines apart: "a b"~${MOST_LINES_APART}`, context);
12
+ };
13
+ /** The messages query language over meeting text: the same fields, a narrower set of operators. */
14
+ export const MEETING_HIT_QUERY = defineQuery({
15
+ entity: "meeting_hit",
16
+ backend: "sql",
17
+ fields: [
18
+ {
19
+ name: "text",
20
+ type: "txt",
21
+ operators: words,
22
+ example: "text:budget",
23
+ relation: "a passage with the word in any form, or a spelling speech recognition garbled",
24
+ validate: near,
25
+ },
26
+ {
27
+ name: "exact",
28
+ type: "txt",
29
+ operators: words,
30
+ example: "exact:budget",
31
+ relation: "a passage with the word as typed: no other forms, no garbled spellings",
32
+ validate: near,
33
+ },
34
+ {
35
+ name: "from",
36
+ type: "kw",
37
+ operators: words,
38
+ example: 'from:"Alice Synthetic"',
39
+ relation: "a line said or a chat message written by this person; attendance is --identity-id",
40
+ },
41
+ ],
42
+ });
43
+ /** What a person typed: the CLI, MCP and batch queries. */
44
+ export const parseMeetingQuery = (query) => validateQuery(parseLucene(query, { defaultField: "text", proximity: true, ...context }), MEETING_HIT_QUERY, context);
45
+ /**
46
+ * Words from a name or a note, never parsed: a project called «C++ (beta)» must not become syntax. Each word is
47
+ * required, in any form.
48
+ */
49
+ export const meetingWordsQuery = (text) => {
50
+ const found = text.match(/[\p{L}\p{N}\p{M}]+/gu) ?? [];
51
+ const span = { start: 0, end: text.length };
52
+ const clauses = found.map((value) => ({
53
+ occur: "must",
54
+ node: { kind: "predicate", field: "text", operator: "term", value, span },
55
+ }));
56
+ const root = clauses.length === 1 && clauses[0] ? clauses[0].node : { kind: "boolean", clauses, span };
57
+ return { version: 1, language: "lucene-v1", root };
58
+ };
@@ -5,6 +5,8 @@ export interface ImportFile {
5
5
  content: string;
6
6
  participants?: ParticipantInput[];
7
7
  source?: string;
8
+ /** `vtt` (the default) or `text`, the form `parseTranscriptText` reads. */
9
+ format?: "vtt" | "text";
8
10
  }
9
11
  /** The caller reads files and supplies provider-neutral occurrence metadata. */
10
12
  export declare const importFiles: (files: ImportFile[], store: MeetingStore, now?: number) => Promise<RunReport>;
@@ -1,18 +1,21 @@
1
1
  import { linkEvent } from "./events.js";
2
2
  import { transcriptInput } from "./ingest.js";
3
+ import { parseTranscriptText } from "./transcript-text.js";
3
4
  import { parseVtt } from "./vtt.js";
4
5
  /** The caller reads files and supplies provider-neutral occurrence metadata. */
5
6
  export const importFiles = async (files, store, now = Date.now()) => {
6
7
  const report = { meetings: 0, participants: 0, transcriptRows: 0, summaries: 0, warnings: [] };
7
8
  for (const file of files) {
8
9
  try {
9
- const lines = parseVtt(file.content);
10
+ const format = file.format ?? "vtt";
11
+ const lines = format === "text" ? parseTranscriptText(file.content).lines : parseVtt(file.content);
10
12
  const participants = file.participants ?? [];
11
13
  const details = await store.saveMeeting({
12
14
  meeting: file.meeting,
13
15
  participants: file.participants,
14
- transcripts: [await transcriptInput(lines, participants, file.source ?? "file", file.content, "vtt")],
16
+ transcripts: [await transcriptInput(lines, participants, file.source ?? "file", file.content, format)],
15
17
  now,
18
+ fromFile: true,
16
19
  });
17
20
  await linkEvent(store, details, now);
18
21
  report.meetings++;
@@ -26,5 +29,9 @@ export const importFiles = async (files, store, now = Date.now()) => {
26
29
  });
27
30
  }
28
31
  }
32
+ const zones = new Set(files.map((file) => file.meeting.timezone).filter((zone) => zone !== null && zone !== undefined));
33
+ const [zone] = zones;
34
+ if (zones.size === 1 && zone)
35
+ report.timezone = zone;
29
36
  return report;
30
37
  };
@@ -6,6 +6,7 @@ export { linkEvent } from "./events.js";
6
6
  export { exportMeeting, type MeetingExport, type MeetingExportFormat, type MeetingExportOptions, serializeVtt, } from "./export.js";
7
7
  export { type FindingEvidence, type FindingOutcome, findingHash, MEETING_FINDINGS_KIND, type MeetingFactFinding, type MeetingFinding, type MeetingFindingsInput, type MeetingFindingsReceipt, type MeetingTaskFinding, meetingFindingFingerprint, validateMeetingFindings, } from "./findings.js";
8
8
  export { type Finding, type FindingsInput, type FindingsReceipt, type FindingsRules, findingFingerprint, validateFindings, } from "./findings-rules.js";
9
+ export { MEETING_HIT_QUERY, meetingWordsQuery, parseMeetingQuery } from "./hit-query.js";
9
10
  export { type ImportFile, importFiles } from "./import.js";
10
11
  export { MEETING_QUERY, type MeetingItem, type MeetingOwner, meeting, meetingItem } from "./item.js";
11
12
  export { createMeetingContext, MEETING_FACT_TYPES, type MeetingContextAction, type MeetingContextChange, type MeetingContextOperation, type MeetingContextPorts, type MeetingContextReceipt, validateMeetingContextChange, } from "./meeting-context.js";
@@ -41,6 +42,7 @@ export { validateMeetingSeriesInventoryCursor } from "./series-inventory-validat
41
42
  export type { MeetingSource, SourceParticipant } from "./source.js";
42
43
  export type { ChatInput, ContractCase, EventCandidateFilter, IdentityInput, MeetingDetails, MeetingFilter, MeetingHitSource, MeetingInput, MeetingSave, MeetingStore, NewRecord, ParticipantInput, SearchHit, TranscriptInput, TranscriptRowInput, } from "./store.js";
43
44
  export type { MeetingTranscriptReceiptStore, MeetingTranscriptStore, TranscriptAppend, TranscriptAppendReceipt, UnlinkedTranscriptInput, } from "./transcript-store.js";
45
+ export { parseTranscriptText, TranscriptTextError, type TranscriptTextHeader } from "./transcript-text.js";
44
46
  export { type MeetingMediaDescriptor, type MeetingRecognitionSegment, type MeetingTranscriptionContext, type MeetingTranscriptionOptions, type MeetingTranscriptionReceipt, type MeetingTranscriptionSource, transcribeMeeting, } from "./transcription.js";
45
47
  export { createUpcomingMeetingReader } from "./upcoming.js";
46
48
  export type { MeetingOccurrence, MeetingOccurrencePort, MeetingOccurrenceRead, MeetingOccurrenceSchedule, UpcomingMeetingRequest, UpcomingMeetingResult, } from "./upcoming-types.js";
@@ -4,6 +4,7 @@ export { linkEvent } from "./events.js";
4
4
  export { exportMeeting, serializeVtt, } from "./export.js";
5
5
  export { findingHash, MEETING_FINDINGS_KIND, meetingFindingFingerprint, validateMeetingFindings, } from "./findings.js";
6
6
  export { findingFingerprint, validateFindings, } from "./findings-rules.js";
7
+ export { MEETING_HIT_QUERY, meetingWordsQuery, parseMeetingQuery } from "./hit-query.js";
7
8
  export { importFiles } from "./import.js";
8
9
  export { MEETING_QUERY, meeting, meetingItem } from "./item.js";
9
10
  export { createMeetingContext, MEETING_FACT_TYPES, validateMeetingContextChange, } from "./meeting-context.js";
@@ -27,6 +28,7 @@ export { validateMeetingHitSearch } from "./search-validation.js";
27
28
  export { validateMeetingSeriesHistory, } from "./series-history.js";
28
29
  export { createMeetingSeriesInventoryReader } from "./series-inventory.js";
29
30
  export { validateMeetingSeriesInventoryCursor } from "./series-inventory-validation.js";
31
+ export { parseTranscriptText, TranscriptTextError } from "./transcript-text.js";
30
32
  export { transcribeMeeting, } from "./transcription.js";
31
33
  export { createUpcomingMeetingReader } from "./upcoming.js";
32
34
  export { parseVtt, VttError } from "./vtt.js";
@@ -15,6 +15,7 @@ export declare const meetingSearchTool: {
15
15
  query: {
16
16
  type: string;
17
17
  minLength: number;
18
+ description: string;
18
19
  };
19
20
  meeting_id: {
20
21
  type: string;
@@ -138,6 +139,11 @@ export declare const callMeetingSearchTool: (search: MeetingHitSearch, input: un
138
139
  id: string;
139
140
  excerpt: string;
140
141
  offset_ms: number | null;
142
+ match?: "corrected" | "words" | undefined;
143
+ corrections?: {
144
+ from: string;
145
+ to: string[];
146
+ }[] | undefined;
141
147
  source?: {
142
148
  ref: string;
143
149
  title: string | null;
@@ -11,7 +11,11 @@ export const meetingSearchTool = {
11
11
  inputSchema: {
12
12
  type: "object",
13
13
  properties: {
14
- query: { type: "string", minLength: 1 },
14
+ query: {
15
+ type: "string",
16
+ minLength: 1,
17
+ description: 'The messages query language: words (all required), OR, (groups), NOT or -word, "a phrase", "a b"~N for words within N transcript lines (at most 20), exact:word, from:"Name" for who said the line',
18
+ },
15
19
  meeting_id: integer,
16
20
  identity_id: integer,
17
21
  person_id: integer,
@@ -24,6 +24,11 @@ export declare const meetingProjectEvidence: v.StrictObjectSchema<{
24
24
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
25
25
  readonly excerpt: v.StringSchema<undefined>;
26
26
  readonly offset_ms: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
27
+ readonly match: v.OptionalSchema<v.PicklistSchema<["words", "corrected"], undefined>, undefined>;
28
+ readonly corrections: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
29
+ readonly from: v.StringSchema<undefined>;
30
+ readonly to: v.ArraySchema<v.StringSchema<undefined>, undefined>;
31
+ }, undefined>, undefined>, undefined>;
27
32
  readonly source: v.OptionalSchema<v.StrictObjectSchema<{
28
33
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
29
34
  readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -9,6 +9,8 @@ export interface RunReport {
9
9
  externalId: string;
10
10
  message: string;
11
11
  }[];
12
+ /** An import whose files all read their start time in one zone names it; files carry no zone of their own. */
13
+ timezone?: string;
12
14
  }
13
15
  export interface PullOptions {
14
16
  accountId: number;
@@ -12,6 +12,8 @@ const clock = (now) => {
12
12
  return new Date(now);
13
13
  };
14
14
  const day = (instant) => instant.toISOString().slice(0, 10);
15
+ /** The calendar day it is at `instant` in the schedule's own zone: the day the owner's daily run belongs to. */
16
+ const localDay = (instant, timeZone) => new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(instant);
15
17
  const identity = (schedule) => createHash("sha256").update(JSON.stringify(schedule)).digest("hex");
16
18
  export const planRecordingArchiveSchedule = (input, options) => {
17
19
  const now = clock(options.now);
@@ -29,8 +31,8 @@ export const planRecordingArchiveSchedule = (input, options) => {
29
31
  fail("archive schedule requires a valid IANA time zone");
30
32
  }
31
33
  recordingWindows(input.from, input.from);
32
- if (input.from <= day(now))
33
- recordingWindows(input.from, day(now));
34
+ if (input.from <= localDay(now, input.timeZone))
35
+ recordingWindows(input.from, localDay(now, input.timeZone));
34
36
  const lookbackDays = input.lookbackDays ?? 7;
35
37
  const maxBytes = input.maxBytes ?? 20 * 1024 ** 3;
36
38
  if (!Number.isSafeInteger(lookbackDays) || lookbackDays < 0 || lookbackDays > 31)
@@ -76,11 +78,10 @@ export const parseRecordingArchiveSchedule = (value) => {
76
78
  export const recordingArchiveScheduleWindow = (value, options) => {
77
79
  const schedule = parseRecordingArchiveSchedule(value);
78
80
  const now = clock(options.now);
79
- const through = day(now);
81
+ const through = localDay(now, schedule.timeZone);
80
82
  if (schedule.from > through)
81
83
  return null;
82
- const floor = new Date(now);
83
- floor.setUTCHours(0, 0, 0, 0);
84
+ const floor = new Date(`${through}T00:00:00Z`);
84
85
  // A running job ages beyond its original ten-year discovery interval; saved pending work survives separately.
85
86
  const month = floor.getUTCMonth();
86
87
  const date = floor.getUTCDate();
@@ -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;
@@ -36,6 +36,12 @@ export interface MeetingSave {
36
36
  summaries?: Omit<NewRecord<Summary>, "meetingId">[];
37
37
  attachments?: Omit<NewRecord<Attachment>, "attachableType" | "attachableId">[];
38
38
  now: number;
39
+ /**
40
+ * A copy of the occurrence read from a file, not from the provider. It joins the provider's record of the same
41
+ * occurrence — same series, start within 15 minutes — and only fills a missing transcript there; a provider
42
+ * record saved later takes over a record made from files. Transcripts from files have source `file`.
43
+ */
44
+ fromFile?: boolean;
39
45
  }
40
46
  export interface MeetingDetails {
41
47
  meeting: Meeting;
@@ -82,6 +88,13 @@ export interface SearchHit {
82
88
  id: number;
83
89
  text: string;
84
90
  startMs: number | null;
91
+ /** `corrected` when a query word matched only as a stored word recognition may have garbled. */
92
+ match?: "words" | "corrected";
93
+ /** For a `corrected` hit, the stored words each such query word matched in this passage. */
94
+ corrections?: {
95
+ from: string;
96
+ to: string[];
97
+ }[];
85
98
  }
86
99
  export interface ContractCase {
87
100
  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
+ };
@@ -3,7 +3,7 @@ import type { CounterField, CounterObservations } from "../domain/counters.js";
3
3
  import type { HtmlFormatting, MarkdownFormatting, TextSpan } from "../domain/formatting.js";
4
4
  import type { Markup } from "../domain/markdown.js";
5
5
  import type { MessagePermalink } from "../domain/message-link.js";
6
- import type { Account, AccountSession, AdminRight, Button, CallRecord, Chat, ChatCard, ChatEvent, ChatEvents, ChatUpdate, Discussion, Folder, FolderChange, FolderRules, GroupCard, GroupChange, GroupMember, Id, InviteLink, InviteLinkChange, JoinRequest, LinkTarget, MediaKind, Member, Message, MessageEvent, MessageHit, OfficialChatStats, Page, PersonCard, PhoneBookEntry, Poll, PollVote, PrivacySettings, ProfileFacts, ProviderMetadata, SenderIdentity, Sticker, StickerSet, Topic, TopicChange, WindowedMessage } from "../domain/models.js";
6
+ import type { Account, AccountSession, AdminRight, Button, CallRecord, Chat, ChatCard, ChatEvent, ChatEvents, ChatUpdate, Discussion, Folder, FolderChange, FolderRules, GroupCard, GroupChange, GroupMember, Id, InviteLink, InviteLinkChange, JoinRequest, LinkTarget, MediaKind, Member, Message, MessageEvent, MessageHit, OfficialChatStats, Page, PersonCard, PersonReaction, PhoneBookEntry, Poll, PollVote, PrivacySettings, ProfileFacts, ProviderMetadata, SenderIdentity, Sticker, StickerSet, Topic, TopicChange, WindowedMessage } from "../domain/models.js";
7
7
  /** One attachment's bytes, fetched over the adapter's connection when `bytes` is read — so read it before the command closes. */
8
8
  export interface RemoteFile {
9
9
  kind: string;
@@ -331,6 +331,12 @@ export interface HistoryVote {
331
331
  messageId: Id;
332
332
  vote: PollVote;
333
333
  }
334
+ /** One person's reaction to message `messageId` of `chatId`, as `PersonReactionEvent` has it. */
335
+ export interface HistoryReaction {
336
+ chatId: Id;
337
+ messageId: Id;
338
+ reaction: PersonReaction;
339
+ }
334
340
  /** One change to who is in chat `chatId`, or to its title, as `ChatEventNotice` has it. */
335
341
  export interface HistoryChatEvent {
336
342
  chatId: Id;
@@ -344,8 +350,8 @@ export interface HistoryStar {
344
350
  }
345
351
  /**
346
352
  * What a messenger pushed in one go: any of chats, the people in them, past messages of any chats, votes in
347
- * their polls, the owner's stars, and changes to who is in them. A vote or a star is saved with its chat's messages,
348
- * after them, so a message in the same batch is held before its votes and its star.
353
+ * their polls, each person's reactions, the owner's stars, and changes to who is in them. A vote, a reaction or a star
354
+ * is saved with its chat's messages, after them, so a message in the same batch is held before them.
349
355
  * `rekeys` are chats the messenger now names by another id; they apply first, so the batch's messages under the
350
356
  * new id join the chat already stored under the old one.
351
357
  */
@@ -358,6 +364,7 @@ export interface HistoryBatch {
358
364
  people?: Member[];
359
365
  messages?: Message[];
360
366
  votes?: HistoryVote[];
367
+ reactions?: HistoryReaction[];
361
368
  stars?: HistoryStar[];
362
369
  events?: HistoryChatEvent[];
363
370
  }
@@ -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);
package/dist/resolve.d.ts CHANGED
@@ -7,6 +7,12 @@ export interface PeopleLookup {
7
7
  }
8
8
  /** Shaped like a Telegram or MAX id. Another messenger's ids may be any string, so a miss here is not "not an id". */
9
9
  export declare const isId: (reference: string) => boolean;
10
+ /**
11
+ * A name as matching compares it. A copied emoji often carries a presentation mark (U+FE0F, U+FE0E) or a joiner
12
+ * (U+200D) the typed one lacks, and a letter may come composed or as letter plus accent; none of that is a different
13
+ * name.
14
+ */
15
+ export declare const foldName: (name: string) => string;
10
16
  /**
11
17
  * A name matched as a fragment — and **an ambiguous one is an error, not a guess**: sending to the
12
18
  * wrong conversation is not undoable, so the caller is shown the candidates and asked to be
package/dist/resolve.js CHANGED
@@ -1,6 +1,15 @@
1
1
  import { CliError, singleLine } from "@wirecat/cli-base";
2
2
  /** Shaped like a Telegram or MAX id. Another messenger's ids may be any string, so a miss here is not "not an id". */
3
3
  export const isId = (reference) => /^-?\d+$/.test(reference.trim());
4
+ /**
5
+ * A name as matching compares it. A copied emoji often carries a presentation mark (U+FE0F, U+FE0E) or a joiner
6
+ * (U+200D) the typed one lacks, and a letter may come composed or as letter plus accent; none of that is a different
7
+ * name.
8
+ */
9
+ export const foldName = (name) => name
10
+ .normalize("NFC")
11
+ .replace(/[\uFE0E\uFE0F\u200D]/g, "")
12
+ .toLowerCase();
4
13
  /**
5
14
  * A name matched as a fragment — and **an ambiguous one is an error, not a guess**: sending to the
6
15
  * wrong conversation is not undoable, so the caller is shown the candidates and asked to be
@@ -8,8 +17,8 @@ export const isId = (reference) => /^-?\d+$/.test(reference.trim());
8
17
  * "Мама" must not quietly take a send meant for "Мама Иванова".
9
18
  */
10
19
  export const pickChat = (reference, chats) => {
11
- const wanted = reference.trim().toLowerCase();
12
- const matches = chats.filter((chat) => chat.title?.toLowerCase().includes(wanted));
20
+ const wanted = foldName(reference.trim());
21
+ const matches = chats.filter((chat) => chat.title !== null && foldName(chat.title).includes(wanted));
13
22
  if (matches.length === 1 && matches[0])
14
23
  return matches[0];
15
24
  if (matches.length === 0)
@@ -27,9 +36,9 @@ export const pickPerson = (reference, people) => {
27
36
  return known;
28
37
  if (isId(trimmed))
29
38
  throw new CliError("not_found", `no person ${trimmed} in what this account has seen`);
30
- const wanted = trimmed.replace(/^@/, "").toLowerCase();
39
+ const wanted = foldName(trimmed.replace(/^@/, ""));
31
40
  const everyone = people.all().toSorted((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
32
- const fields = (person) => [person.alias, person.name, person.username].filter(isPresent).map((one) => one.toLowerCase());
41
+ const fields = (person) => [person.alias, person.name, person.username].filter(isPresent).map(foldName);
33
42
  const exact = everyone.filter((person) => fields(person).includes(wanted));
34
43
  const matches = exact.length > 0 ? exact : everyone.filter((person) => fields(person).some((one) => one.includes(wanted)));
35
44
  if (matches.length === 1 && matches[0])
@@ -0,0 +1,30 @@
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; or a word recognition cut off, of four letters or more, that begins the query word
17
+ * («може» for «может»), as ztx matched.
18
+ */
19
+ export declare const garbledDistance: (probe: GarbledProbe, word: string, stems: string[]) => number | null;
20
+ /**
21
+ * The stored words each probe widens to, nearest first and then the most used, from one pass over the
22
+ * vocabulary, whose terms are folded as the index folds them. `stem` is the store's stemmer, so a stored
23
+ * word's stem is the one its index holds. A spelling that `known` finds in a dictionary is a different real
24
+ * word, not a garbled one — «договор» is one stem edit from «поговорить» — so only unknown spellings widen;
25
+ * on real transcripts that kept 517 of 10,271 widened passages.
26
+ */
27
+ export declare const widenWords: (probes: GarbledProbe[], vocabulary: Iterable<{
28
+ term: string;
29
+ docs: number;
30
+ }>, stem: (word: string) => string[], known: (word: string) => boolean) => string[][];
@@ -0,0 +1,130 @@
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; or a word recognition cut off, of four letters or more, that begins the query word
38
+ * («може» for «может»), as ztx matched.
39
+ */
40
+ export const garbledDistance = (probe, word, stems) => {
41
+ if (probe.literal.length < SHORTEST_GARBLED || plainMatch(probe, word, stems))
42
+ return null;
43
+ if (isNumberWord(probe.literal) || isNumberWord(word))
44
+ return null;
45
+ const literal = plain(probe.literal);
46
+ const stored = plain(word);
47
+ let best = null;
48
+ for (const queryStem of probe.stems.map(plain))
49
+ for (const wordStem of stems.map(plain)) {
50
+ if (Math.min(queryStem.length, wordStem.length) < 3)
51
+ continue;
52
+ if ((wordStem.startsWith(queryStem) && literal.startsWith(wordStem)) ||
53
+ (queryStem.startsWith(wordStem) && stored.startsWith(queryStem)))
54
+ return 0;
55
+ if (queryStem.length < SHORTEST_GARBLED)
56
+ continue;
57
+ if (queryStem.length < LOOSE_START && queryStem[0] !== wordStem[0])
58
+ continue;
59
+ const distance = editDistance(queryStem, wordStem, 1);
60
+ if (distance <= 1)
61
+ best = distance;
62
+ }
63
+ if (best === null && stored.length >= SHORTEST_GARBLED && literal.startsWith(stored))
64
+ return 1;
65
+ return best;
66
+ };
67
+ /** `editDistance` from `stem` to the nearest beginning of `word`, or `max + 1` once every beginning is further. */
68
+ const prefixDistance = (stem, word, max) => {
69
+ let before = new Int32Array(word.length + 1);
70
+ let previous = new Int32Array(word.length + 1);
71
+ let current = new Int32Array(word.length + 1);
72
+ for (let i = 1; i <= stem.length; i++) {
73
+ current[0] = i;
74
+ let lowest = i;
75
+ for (let j = 1; j <= word.length; j++) {
76
+ let value = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (stem[i - 1] === word[j - 1] ? 0 : 1));
77
+ if (i > 1 && j > 1 && stem[i - 1] === word[j - 2] && stem[i - 2] === word[j - 1])
78
+ value = Math.min(value, before[j - 2] + 1);
79
+ current[j] = value;
80
+ lowest = Math.min(lowest, value);
81
+ }
82
+ if (lowest > max)
83
+ return max + 1;
84
+ [before, previous, current] = [previous, current, before];
85
+ }
86
+ return previous.reduce((lowest, value) => Math.min(lowest, value), max + 1);
87
+ };
88
+ /**
89
+ * Whether a stored word can pass `garbledDistance`, checked without stemming it: a stem begins its word, so a
90
+ * near stem means a near beginning. Stemming every stored word in a fresh process costs half a second.
91
+ */
92
+ const worthStemming = (probe, word) => {
93
+ const stored = plain(word);
94
+ if (stored.length >= SHORTEST_GARBLED && plain(probe.literal).startsWith(stored))
95
+ return true;
96
+ return probe.stems.some((raw) => {
97
+ const stem = plain(raw);
98
+ if (stem.length >= 3 && stored.startsWith(stem))
99
+ return true;
100
+ return stem.length >= SHORTEST_GARBLED && prefixDistance(stem, stored, 1) <= 1;
101
+ });
102
+ };
103
+ /**
104
+ * The stored words each probe widens to, nearest first and then the most used, from one pass over the
105
+ * vocabulary, whose terms are folded as the index folds them. `stem` is the store's stemmer, so a stored
106
+ * word's stem is the one its index holds. A spelling that `known` finds in a dictionary is a different real
107
+ * word, not a garbled one — «договор» is one stem edit from «поговорить» — so only unknown spellings widen;
108
+ * on real transcripts that kept 517 of 10,271 widened passages.
109
+ */
110
+ export const widenWords = (probes, vocabulary, stem, known) => {
111
+ const found = probes.map(() => []);
112
+ const live = probes.map((probe) => probe.literal.length >= SHORTEST_GARBLED);
113
+ if (!live.some(Boolean))
114
+ return probes.map(() => []);
115
+ for (const { term: word, docs } of vocabulary) {
116
+ let stems;
117
+ probes.forEach((probe, at) => {
118
+ if (!live[at] || !worthStemming(probe, word))
119
+ return;
120
+ stems ??= stem(word);
121
+ const distance = garbledDistance(probe, word, stems);
122
+ if (distance === 0 || (distance === 1 && !known(word)))
123
+ found[at]?.push({ term: word, docs, distance });
124
+ });
125
+ }
126
+ return found.map((words) => words
127
+ .sort((a, b) => a.distance - b.distance || b.docs - a.docs || a.term.localeCompare(b.term))
128
+ .slice(0, MOST_WIDENED)
129
+ .map((word) => word.term));
130
+ };
@@ -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;