@wirecat/cli-core 0.551.0 → 0.552.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 (67) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +1 -1
  3. package/dist/background/schedules.d.ts +12 -1
  4. package/dist/background/schedules.js +65 -24
  5. package/dist/background/units.d.ts +16 -9
  6. package/dist/background/units.js +26 -11
  7. package/dist/cli/context.d.ts +2 -0
  8. package/dist/cli/meeting-preparation-answer.d.ts +3 -0
  9. package/dist/cli/meeting-preparation-projects.d.ts +3 -0
  10. package/dist/cli/meeting-preparation-read.d.ts +3 -0
  11. package/dist/cli/messenger/search-answers.js +8 -1
  12. package/dist/cli/messenger/serve-catch-up.d.ts +43 -0
  13. package/dist/cli/messenger/serve-catch-up.js +90 -0
  14. package/dist/cli/messenger/serve-command.js +54 -1
  15. package/dist/cli/messenger/server-command.d.ts +4 -3
  16. package/dist/cli/messenger/server-command.js +30 -12
  17. package/dist/cli/program.js +9 -4
  18. package/dist/cli/schedule/schedule-command.js +5 -1
  19. package/dist/entities/hit.d.ts +5 -0
  20. package/dist/entities/hit.js +5 -0
  21. package/dist/entities/index.d.ts +27 -0
  22. package/dist/entities/listener.d.ts +6 -0
  23. package/dist/entities/listener.js +1 -0
  24. package/dist/entities/meeting-preparation.d.ts +3 -0
  25. package/dist/errors/reasons.js +1 -0
  26. package/dist/mcp/meeting-preparation.d.ts +3 -0
  27. package/dist/meetings/answers.d.ts +5 -0
  28. package/dist/meetings/answers.js +8 -0
  29. package/dist/meetings/cli/index.js +14 -0
  30. package/dist/meetings/index.d.ts +2 -1
  31. package/dist/meetings/index.js +1 -0
  32. package/dist/meetings/ingest.js +2 -1
  33. package/dist/meetings/language.d.ts +5 -0
  34. package/dist/meetings/language.js +57 -0
  35. package/dist/meetings/mcp/search.d.ts +3 -0
  36. package/dist/meetings/project-evidence-answer.d.ts +3 -0
  37. package/dist/meetings/result-items.d.ts +9 -0
  38. package/dist/meetings/result-items.js +7 -0
  39. package/dist/meetings/store.d.ts +22 -0
  40. package/dist/meetings/testing/memory.js +3 -0
  41. package/dist/meetings/transcription.d.ts +2 -0
  42. package/dist/meetings/transcription.js +5 -1
  43. package/dist/search/lucene/types.d.ts +7 -0
  44. package/dist/search/lucene/types.js +11 -1
  45. package/dist/services/messages-archive-search.js +3 -2
  46. package/dist/services/messages-combined.js +2 -2
  47. package/dist/services/messages-discovery.js +2 -2
  48. package/dist/services/notes-search.js +4 -5
  49. package/dist/services/search-all-meetings.d.ts +3 -0
  50. package/dist/services/search-all-meetings.js +6 -1
  51. package/dist/speech/local.d.ts +2 -0
  52. package/dist/speech/local.js +8 -1
  53. package/dist/speech/models.d.ts +2 -0
  54. package/dist/speech/models.js +2 -0
  55. package/dist/store/driver.d.ts +11 -0
  56. package/dist/store/driver.js +13 -0
  57. package/dist/store/drivers/node-sqlite.d.ts +1 -1
  58. package/dist/store/drivers/node-sqlite.js +3 -0
  59. package/dist/store/sqlite/lucene.d.ts +1 -1
  60. package/dist/store/sqlite/lucene.js +33 -7
  61. package/dist/store/sqlite/meeting-hit-search.js +10 -3
  62. package/dist/store/sqlite/meeting-languages.d.ts +4 -0
  63. package/dist/store/sqlite/meeting-languages.js +36 -0
  64. package/dist/store/sqlite/meetings.d.ts +2 -1
  65. package/dist/store/sqlite/meetings.js +9 -3
  66. package/dist/store/sqlite/note-search.js +2 -2
  67. package/package.json +1 -1
@@ -116,10 +116,17 @@ const embedding = v.union([
116
116
  }),
117
117
  }),
118
118
  ]);
119
+ const repair = v.strictObject({
120
+ account_id: identifier,
121
+ applied: flag,
122
+ transcripts: count,
123
+ languages: v.array(v.strictObject({ language: nullableText, transcripts: count })),
124
+ });
119
125
  export const MEETING_RESULT_ENTITIES = {
120
126
  meeting_ingestion: run,
121
127
  meeting_export: exported,
122
128
  meeting_association: meetingChange,
123
129
  meeting_transcription: transcription,
124
130
  meeting_embedding: embedding,
131
+ meeting_repair: repair,
125
132
  };
@@ -90,6 +90,11 @@ export interface SearchHit {
90
90
  startMs: number | null;
91
91
  /** Who spoke a transcript hit's cue, as the transcript names them; absent for chat and summary hits. */
92
92
  speakerName?: string | null;
93
+ /** A transcript hit's transcript language (`ru`, `en`…), `null` when unknown; absent for chat and summary hits. */
94
+ language?: string | null;
95
+ /** A transcript hit's line, from 1, of `linesTotal` lines in its transcript: where in the meeting it was said. */
96
+ line?: number;
97
+ linesTotal?: number;
93
98
  /** `corrected` when a query word matched only as a stored word recognition may have garbled. */
94
99
  match?: "words" | "corrected";
95
100
  /** For a `corrected` hit, the stored words each such query word matched in this passage. */
@@ -126,4 +131,21 @@ export interface MeetingStore {
126
131
  reviewMeetingLink?(meetingId: number, eventIds: number[], now: number): Promise<void>;
127
132
  cursor(accountId: number): Promise<string | null>;
128
133
  setCursor(accountId: number, value: string, now: number): Promise<void>;
134
+ /** Detects the language of the account's transcripts that have none; stores it only with `apply`. */
135
+ fillTranscriptLanguages?(input: {
136
+ accountId: number;
137
+ apply: boolean;
138
+ now: number;
139
+ }): Promise<TranscriptLanguageFill>;
140
+ }
141
+ export interface TranscriptLanguageFill {
142
+ accountId: number;
143
+ applied: boolean;
144
+ /** Transcripts that had no language. */
145
+ transcripts: number;
146
+ /** How many of them each language was found in; `null` where the text did not show one. */
147
+ languages: {
148
+ language: string | null;
149
+ transcripts: number;
150
+ }[];
129
151
  }
@@ -121,6 +121,9 @@ export const memoryMeetingStore = () => {
121
121
  text: r.text,
122
122
  startMs: r.startMs,
123
123
  speakerName: r.speakerName,
124
+ language: t.transcript.language,
125
+ line: r.position + 1,
126
+ linesTotal: t.rows.length,
124
127
  })));
125
128
  rows.push(...d.chat.map((r) => ({
126
129
  meetingId: d.meeting.id,
@@ -13,6 +13,8 @@ export interface MeetingMediaDescriptor {
13
13
  model: string;
14
14
  version?: string;
15
15
  timing?: "fixed-windows" | "recognition-segments";
16
+ /** The one language the model writes (`ru`); without it the language is read from the recognized text. */
17
+ language?: string;
16
18
  };
17
19
  }
18
20
  export interface MeetingRecognitionSegment {
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { contentHash } from "./ingest.js";
3
+ import { transcriptLanguage } from "./language.js";
3
4
  import { dispatchMeetingMutation } from "./meeting-management.js";
4
5
  const bounded = (value, max, name) => {
5
6
  if (!Number.isSafeInteger(value) || value < 1 || value > max)
@@ -46,6 +47,9 @@ export const transcribeMeeting = async (source, store, target, options = {}) =>
46
47
  const timing = recognition.timing ?? "recognition-segments";
47
48
  if (timing !== "fixed-windows" && timing !== "recognition-segments")
48
49
  throw new CliError("invalid_response", "unsupported recognition timing");
50
+ const modelLanguage = recognition.language;
51
+ if (modelLanguage !== undefined && (typeof modelLanguage !== "string" || !/^[a-z]{2,3}$/.test(modelLanguage)))
52
+ throw new CliError("invalid_response", "recognition language must be a two- or three-letter code");
49
53
  const media = Object.freeze({
50
54
  id: identifier(raw.id),
51
55
  contentHash: raw.contentHash,
@@ -137,7 +141,7 @@ export const transcribeMeeting = async (source, store, target, options = {}) =>
137
141
  source: `local/${encodeURIComponent(media.recognition.engine)}/${encodeURIComponent(media.recognition.model)}`,
138
142
  format: "local-transcription",
139
143
  }),
140
- language: null,
144
+ language: modelLanguage ?? transcriptLanguage(rows.map((row) => row.text)),
141
145
  contentHash: hash,
142
146
  externalCreatedAt: null,
143
147
  metadata: provenance,
@@ -22,4 +22,11 @@ export declare const MESSAGE_PARSING: {
22
22
  /** Further apart than this, two words are rarely about one thing, even in a long message. */
23
23
  export declare const MOST_WORDS_APART = 50;
24
24
  export declare function searchError(reason: string, span: Span, alternative?: string): never;
25
+ /**
26
+ * The search ran out of time, not the query out of bounds: a valid query over a large store. `timeout`, so a caller
27
+ * does not tell the person to fix what they typed.
28
+ */
29
+ export declare function outOfTime(limitMs?: number): never;
30
+ /** A search limit that leaves the answer without a part, such as its total, rather than failing it. */
31
+ export declare const isQueryLimit: (error: unknown) => boolean;
25
32
  export declare function exhausted(budget: string): never;
@@ -1,4 +1,4 @@
1
- import { CliError } from "@wirecat/cli-base";
1
+ import { CliError, isCliError } from "@wirecat/cli-base";
2
2
  import { QUERY_LIMITS as PARSER_LIMITS, queryError } from "@wirecat/cli-base/query";
3
3
  export const QUERY_VERSION = 1;
4
4
  export const QUERY_LIMITS = {
@@ -22,6 +22,16 @@ export const MOST_WORDS_APART = 50;
22
22
  export function searchError(reason, span, alternative) {
23
23
  queryError(reason, span, alternative, SEARCH_QUERY);
24
24
  }
25
+ /**
26
+ * The search ran out of time, not the query out of bounds: a valid query over a large store. `timeout`, so a caller
27
+ * does not tell the person to fix what they typed.
28
+ */
29
+ export function outOfTime(limitMs = QUERY_LIMITS.milliseconds) {
30
+ throw new CliError("timeout", `search stopped at its time limit of ${limitMs / 1000}s — the query is valid; ` +
31
+ "add chat: or date: to search less of the store", { reason: "query.time-limit", budget: "time", limit_ms: limitMs, complete: false });
32
+ }
33
+ /** A search limit that leaves the answer without a part, such as its total, rather than failing it. */
34
+ export const isQueryLimit = (error) => isCliError(error) && (error.details.reason === "query.too-large" || error.details.reason === "query.time-limit");
25
35
  export function exhausted(budget) {
26
36
  throw new CliError("validation_error", `search exceeded its ${budget} budget — narrow the chat or date range`, {
27
37
  reason: "query.too-large",
@@ -1,5 +1,6 @@
1
- import { CliError, isCliError } from "@wirecat/cli-base";
1
+ import { CliError } from "@wirecat/cli-base";
2
2
  import { parseLocator } from "../domain/locator.js";
3
+ import { isQueryLimit } from "../search/lucene/types.js";
3
4
  import { TOTAL_CAP, totalOf } from "../search/total.js";
4
5
  import { discoveryRequest, searchDiscovery } from "./messages-discovery.js";
5
6
  import { countLucene, searchLucene, statsLucene } from "./messages-search.js";
@@ -53,7 +54,7 @@ export const searchStore = async (store, account, request, messenger = {}, meani
53
54
  const total = discovery
54
55
  ? totalOf(discovery.candidates, found.items.length, found.hasMore, !discovery.truncated)
55
56
  : totalOf(await countLucene(store, account, { ...(request.discover ? discoveryRequest(request) : request), limit: request.limit + offset }, messenger, TOTAL_CAP).catch((error) => {
56
- if (isCliError(error) && error.details.reason === "query.too-large")
57
+ if (isQueryLimit(error))
57
58
  return undefined;
58
59
  throw error;
59
60
  }), found.items.length, found.hasMore);
@@ -4,7 +4,7 @@ import { parseLocator } from "../domain/locator.js";
4
4
  import { correctWords } from "../search/correct.js";
5
5
  import { PRESET_VERSION } from "../search/lucene/presets.js";
6
6
  import { FIELD_VERSION } from "../search/lucene/registry.js";
7
- import { exhausted, QUERY_LIMITS } from "../search/lucene/types.js";
7
+ import { exhausted, outOfTime, QUERY_LIMITS } from "../search/lucene/types.js";
8
8
  import { normalize } from "../store/normalize.js";
9
9
  import { withExcerpt } from "./excerpt.js";
10
10
  import { coverageOf, prepareLucene } from "./messages-search.js";
@@ -31,7 +31,7 @@ export const searchCombined = async (store, account, request, messenger = {}, op
31
31
  if (request.signal?.aborted)
32
32
  throw new CliError("validation_error", "search was aborted", { reason: "query.aborted", complete: false });
33
33
  if (performance.now() - started > QUERY_LIMITS.milliseconds)
34
- exhausted("milliseconds");
34
+ outOfTime();
35
35
  };
36
36
  check();
37
37
  const spans = request.ast === undefined && !request.exact && request.text !== undefined
@@ -3,7 +3,7 @@ import { implicitTextTerms, parseLucene, walkQuery } from "@wirecat/cli-base/que
3
3
  import { parseLocator } from "../domain/locator.js";
4
4
  import { fieldsOfQuery, meaningWords } from "../search/lucene/meaning-parts.js";
5
5
  import { validateFields } from "../search/lucene/registry.js";
6
- import { exhausted, MESSAGE_PARSING, QUERY_LIMITS } from "../search/lucene/types.js";
6
+ import { exhausted, MESSAGE_PARSING, outOfTime, QUERY_LIMITS } from "../search/lucene/types.js";
7
7
  import { planQuestion, words } from "../search/question-plan.js";
8
8
  import { searchCombined } from "./messages-combined.js";
9
9
  import { prepareLucene, searchLucene } from "./messages-search.js";
@@ -101,7 +101,7 @@ export const searchDiscovery = async (store, account, asked, messenger = {}, mea
101
101
  if (request.signal?.aborted)
102
102
  throw new CliError("validation_error", "search was aborted", { reason: "query.aborted", complete: false });
103
103
  if (performance.now() - started > QUERY_LIMITS.milliseconds)
104
- exhausted("milliseconds");
104
+ outOfTime();
105
105
  };
106
106
  const union = new Map();
107
107
  let truncated = false;
@@ -1,4 +1,4 @@
1
- import { CliError, isCliError } from "@wirecat/cli-base";
1
+ import { CliError } from "@wirecat/cli-base";
2
2
  import { dateRange, parseLucene, timezoneOf } from "@wirecat/cli-base/query";
3
3
  import { INTERNAL_NOTES } from "../domain/providers.js";
4
4
  import { normalizeTag } from "../domain/tags.js";
@@ -9,7 +9,7 @@ import { entriesOfList, fitting } from "../search/account-list.js";
9
9
  import { fieldsOfQuery as fieldsOf, meaningWords } from "../search/lucene/meaning-parts.js";
10
10
  import { validateFields } from "../search/lucene/registry.js";
11
11
  import { hasStems } from "../search/lucene/resolved.js";
12
- import { SEARCH_QUERY } from "../search/lucene/types.js";
12
+ import { isQueryLimit, SEARCH_QUERY } from "../search/lucene/types.js";
13
13
  import { createStemmer, DEFAULT_STEMMERS } from "../search/stem.js";
14
14
  import { TOTAL_CAP, totalOf } from "../search/total.js";
15
15
  import { openPictureEmbedderFor } from "./document-pictures.js";
@@ -286,12 +286,11 @@ const filterRefs = async (store, narrowing, scope) => {
286
286
  return found.hasMore ? undefined : found.refs;
287
287
  }
288
288
  catch (error) {
289
- if (tooLarge(error))
289
+ if (isQueryLimit(error))
290
290
  return undefined;
291
291
  throw error;
292
292
  }
293
293
  };
294
- const tooLarge = (error) => isCliError(error) && error.details.reason === "query.too-large";
295
294
  /**
296
295
  * The notes the words find, with those found by meaning: every note the search would list, paged to the end. Never
297
296
  * below what the page already showed; a count the store refuses as too costly is that lower bound.
@@ -313,7 +312,7 @@ const notesTotal = async (store, text, { exact, scope, shown, hasMore, }, others
313
312
  });
314
313
  }
315
314
  catch (error) {
316
- if (tooLarge(error))
315
+ if (isQueryLimit(error))
317
316
  return totalOf(undefined, shown, hasMore);
318
317
  throw error;
319
318
  }
@@ -22,6 +22,9 @@ export interface MeetingSearchItem {
22
22
  /** A transcript hit's cue, `meeting:<account>/<meeting>/<transcript>/<cue>`, which `meetings passage` opens. */
23
23
  cueRef?: string;
24
24
  speakerName?: string | null;
25
+ language?: string | null;
26
+ line?: number;
27
+ linesTotal?: number;
25
28
  }
26
29
  export interface MeetingSearchCursor {
27
30
  accountId: number;
@@ -105,7 +105,12 @@ const collectMeetings = async (store, account, request, query) => {
105
105
  id: hit.id,
106
106
  startMs: hit.startMs,
107
107
  ...(hit.scope === "transcript" && hit.ref !== undefined
108
- ? { cueRef: hit.ref, speakerName: hit.speakerName ?? null }
108
+ ? {
109
+ cueRef: hit.ref,
110
+ speakerName: hit.speakerName ?? null,
111
+ language: hit.language ?? null,
112
+ ...(hit.line === undefined ? {} : { line: hit.line, linesTotal: hit.linesTotal }),
113
+ }
109
114
  : {}),
110
115
  },
111
116
  });
@@ -5,6 +5,8 @@ export interface LocalSpeechRecognitionModel {
5
5
  engine: "sherpa-onnx";
6
6
  model: string;
7
7
  version: string;
8
+ /** The one language the model writes; absent for a multilingual model. */
9
+ language?: string;
8
10
  sampleRate: 16000;
9
11
  maxChunkSamples: 480000;
10
12
  }
@@ -23,7 +23,14 @@ export const localSpeechRecognitionModel = (id) => {
23
23
  featureDim: model.featureDim,
24
24
  }))
25
25
  .digest("hex");
26
- return Object.freeze({ engine: "sherpa-onnx", model: model.id, version, sampleRate: 16000, maxChunkSamples: 480000 });
26
+ return Object.freeze({
27
+ engine: "sherpa-onnx",
28
+ model: model.id,
29
+ version,
30
+ ...(model.language === undefined ? {} : { language: model.language }),
31
+ sampleRate: 16000,
32
+ maxChunkSamples: 480000,
33
+ });
27
34
  };
28
35
  const notInstalled = (id) => new CliError("not_found", `speech model ${id} is not installed; download it explicitly with a WireCat messenger's models download command`, { reason: "model.not-downloaded", model: id });
29
36
  /**
@@ -18,6 +18,8 @@ export interface SpeechModel {
18
18
  id: string;
19
19
  title: string;
20
20
  languages: string;
21
+ /** The one language the model writes, so its transcripts need no guessing; absent for multilingual models. */
22
+ language?: string;
21
23
  files: ModelFile[];
22
24
  /** The part of sherpa-onnx's `modelConfig` that names the files, given where they are. */
23
25
  config: (path: (name: string) => string) => Record<string, unknown>;
@@ -8,6 +8,7 @@ export const MODELS = [
8
8
  id: "gigaam-v3",
9
9
  title: "GigaAM v3, with punctuation",
10
10
  languages: "Russian — the best of the three for Russian",
11
+ language: "ru",
11
12
  featureDim: 64,
12
13
  files: [
13
14
  {
@@ -45,6 +46,7 @@ export const MODELS = [
45
46
  id: "gigaam-v3-ctc",
46
47
  title: "GigaAM v3 CTC, with punctuation — a little faster",
47
48
  languages: "Russian — a little faster, rougher with capital letters",
49
+ language: "ru",
48
50
  featureDim: 64,
49
51
  files: [
50
52
  {
@@ -23,7 +23,18 @@ export interface CacheDatabase {
23
23
  exec(sql: string): void;
24
24
  prepare(sql: string): CacheStatement;
25
25
  close(): void;
26
+ /**
27
+ * Set when the connection has `TIME_LIMIT_FUNCTION`. A statement runs to its end before any timer can fire, so a
28
+ * long one is stopped from inside: `WHERE … AND wc_time_limit(<epoch ms>)` throws `TimeLimitReached` once past it.
29
+ */
30
+ timeLimit?: true;
26
31
  }
32
+ export declare const TIME_LIMIT_FUNCTION = "wc_time_limit";
33
+ export declare class TimeLimitReached extends Error {
34
+ constructor();
35
+ }
36
+ /** The SQL function's body: 1 until `until` (epoch milliseconds) has passed. */
37
+ export declare const beforeTimeLimit: (until: unknown) => number;
27
38
  export type OpenDatabase = (path: string) => CacheDatabase;
28
39
  /**
29
40
  * **Write-ahead mode, and a timeout rather than an instant failure.**
@@ -9,6 +9,19 @@
9
9
  * The surface is deliberately tiny. Everything above it is ordinary SQL, so replacing the driver —
10
10
  * or adding a third — is writing thirty lines, not learning an abstraction.
11
11
  */
12
+ export const TIME_LIMIT_FUNCTION = "wc_time_limit";
13
+ export class TimeLimitReached extends Error {
14
+ constructor() {
15
+ super("the statement ran past its time limit");
16
+ this.name = "TimeLimitReached";
17
+ }
18
+ }
19
+ /** The SQL function's body: 1 until `until` (epoch milliseconds) has passed. */
20
+ export const beforeTimeLimit = (until) => {
21
+ if (Date.now() > Number(until))
22
+ throw new TimeLimitReached();
23
+ return 1;
24
+ };
12
25
  /**
13
26
  * **Write-ahead mode, and a timeout rather than an instant failure.**
14
27
  *
@@ -1,5 +1,5 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
- import type { CacheDatabase } from "../driver.js";
2
+ import { type CacheDatabase } from "../driver.js";
3
3
  /**
4
4
  * Import this module only through a dynamic import that follows `quietSqliteWarning()`: a static import loads
5
5
  * `node:sqlite`, and on Node 22 its ExperimentalWarning, before the filter can run.
@@ -1,5 +1,6 @@
1
1
  // biome-ignore-all lint/plugin/no-sqlite-driver: cli-core owns the store, so it opens the driver itself
2
2
  import { DatabaseSync } from "node:sqlite";
3
+ import { beforeTimeLimit, TIME_LIMIT_FUNCTION, } from "../driver.js";
3
4
  import { loadVectorExtension, vectorExtension } from "../vector-extension.js";
4
5
  /**
5
6
  * Import this module only through a dynamic import that follows `quietSqliteWarning()`: a static import loads
@@ -29,6 +30,7 @@ export const nodeClient = async (path, { readOnly = false } = {}) => {
29
30
  export const STATEMENT_CACHE_SIZE = 1000;
30
31
  export const cacheOverNodeSqlite = (database, cacheSize = STATEMENT_CACHE_SIZE) => {
31
32
  const statements = new Map();
33
+ database.function(TIME_LIMIT_FUNCTION, { deterministic: false, directOnly: true }, beforeTimeLimit);
32
34
  const prepare = (sql) => {
33
35
  const statement = database.prepare(sql);
34
36
  return {
@@ -38,6 +40,7 @@ export const cacheOverNodeSqlite = (database, cacheSize = STATEMENT_CACHE_SIZE)
38
40
  };
39
41
  };
40
42
  return {
43
+ timeLimit: true,
41
44
  exec: (sql) => database.exec(sql),
42
45
  prepare: (sql) => {
43
46
  const cached = statements.get(sql);
@@ -1,6 +1,6 @@
1
1
  import type { Page } from "../../domain/models.js";
2
2
  import type { QueryExecution } from "../../search/lucene/resolved.js";
3
- import type { SqlValue } from "../driver.js";
3
+ import { type SqlValue } from "../driver.js";
4
4
  import type { StoreContext } from "./open.js";
5
5
  import type { ScoredHit } from "./words.js";
6
6
  export { type Corpus, prefixOf } from "./lucene-fields.js";
@@ -1,7 +1,8 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { compileAutomaton } from "../../search/lucene/automaton.js";
3
3
  import { isStemmed } from "../../search/lucene/resolved.js";
4
- import { exhausted, QUERY_LIMITS, searchError } from "../../search/lucene/types.js";
4
+ import { exhausted, outOfTime, QUERY_LIMITS, searchError } from "../../search/lucene/types.js";
5
+ import { TIME_LIMIT_FUNCTION, TimeLimitReached } from "../driver.js";
5
6
  import { emailHitsByPk } from "./emails.js";
6
7
  import { placeHits } from "./hit-places.js";
7
8
  import { combine, FIELD_COMPILERS, ftsMatch, SPEECH, } from "./lucene-fields.js";
@@ -54,7 +55,7 @@ export const countMatches = (context, execution, cap) => matchingPks(context, ex
54
55
  * The keys of the messages that match, no more than `cap + 1`: one SQL query, unranked. `undefined` when a leaf is
55
56
  * checked row by row (a preset, a pattern), where it would cost as much as the search itself.
56
57
  */
57
- export const matchingPks = (context, execution, cap) => {
58
+ export const matchingPks = (context, execution, cap) => inTime(() => {
58
59
  const { database, check, where, from, withRanking, ranked, leaves } = compileQuery(context, {
59
60
  ...execution,
60
61
  newest: true,
@@ -66,6 +67,19 @@ export const matchingPks = (context, execution, cap) => {
66
67
  .all(...ranked.params, ...where.params, cap + 1);
67
68
  check();
68
69
  return rows.map(({ id }) => Number(id));
70
+ });
71
+ const timeLimited = (error) => {
72
+ if (error instanceof TimeLimitReached)
73
+ outOfTime();
74
+ throw error;
75
+ };
76
+ const inTime = (work) => {
77
+ try {
78
+ return work();
79
+ }
80
+ catch (error) {
81
+ return timeLimited(error);
82
+ }
69
83
  };
70
84
  const compileQuery = (context, execution, boundedAttachments = false) => {
71
85
  const { database } = context;
@@ -74,6 +88,7 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
74
88
  if (mail && (execution.conversationIds || execution.conversationSince !== undefined))
75
89
  throw new CliError("validation_error", "mail has no conversations to search within");
76
90
  const started = context.now();
91
+ const until = Date.now() + QUERY_LIMITS.milliseconds;
77
92
  const budget = { work: 0 };
78
93
  let states = 0, expansions = 0;
79
94
  const patterns = new Map();
@@ -82,7 +97,7 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
82
97
  if (execution.signal?.aborted)
83
98
  throw new CliError("validation_error", "search was aborted", { reason: "query.aborted", complete: false });
84
99
  if (context.now() - started > QUERY_LIMITS.milliseconds)
85
- exhausted("time");
100
+ outOfTime();
86
101
  };
87
102
  const patternOf = (pattern, node) => {
88
103
  check();
@@ -197,7 +212,10 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
197
212
  exact: true,
198
213
  })), "OR"),
199
214
  ], "AND");
200
- let where = combine([{ sql: "m.deleted_at IS NULL", params: [], exact: true }, scope, expression], "AND");
215
+ const timeLimit = database.timeLimit
216
+ ? [{ sql: `${TIME_LIMIT_FUNCTION}(?)`, params: [until], exact: true }]
217
+ : [];
218
+ let where = combine([{ sql: "m.deleted_at IS NULL", params: [], exact: true }, scope, expression, ...timeLimit], "AND");
201
219
  if (execution.conversationIds !== undefined || execution.conversationSince !== undefined) {
202
220
  const ids = execution.conversationIds;
203
221
  where = combine([
@@ -404,7 +422,7 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
404
422
  /** The matcher and its consumer run synchronously in one read snapshot; exact keys stay in SQL. */
405
423
  export const withQuerySelection = (context, execution, consume) => {
406
424
  const { database } = context;
407
- return read(database, () => {
425
+ return inTime(() => read(database, () => {
408
426
  const query = compileQuery(context, execution, true);
409
427
  const { check, leaves, where, from, withRanking, ranked } = query;
410
428
  check();
@@ -448,9 +466,17 @@ export const withQuerySelection = (context, execution, consume) => {
448
466
  throw new Error("query selection consumers must run synchronously");
449
467
  check();
450
468
  return result;
451
- });
469
+ }));
470
+ };
471
+ const runQuery = (context, execution, by) => {
472
+ try {
473
+ return runCompiled(context, execution, by).catch(timeLimited);
474
+ }
475
+ catch (error) {
476
+ return timeLimited(error);
477
+ }
452
478
  };
453
- const runQuery = async (context, execution, by) => {
479
+ const runCompiled = async (context, execution, by) => {
454
480
  const { corpus, rowsFrom, hits, database, check, grouped, where, from, withRanking, ranked, leaves, stemmed, exactTier, driver, baseFrom, filtered, ranking, relevance, rankedFrom, exactColumn, order, placeTier, projection, projectionParams, joinedFrom, evaluate, } = compileQuery(context, execution);
455
481
  if (by === "pks" && leaves.every(({ test }) => !test)) {
456
482
  const rows = database
@@ -121,9 +121,9 @@ export function meetingHitSearchOver({ database }) {
121
121
  if (after)
122
122
  params.push(after.tier, after.tier, after.started, after.started, after.meeting, after.meeting, after.kind, after.kind, after.id);
123
123
  const sql = `WITH matches AS (${matching}), matching AS (SELECT rid,tier FROM matches h WHERE NOT EXISTS(SELECT 1 FROM meeting_index_pending p WHERE p.id=h.rid/4 AND p.indexable_type=CASE h.rid%4 WHEN 1 THEN 'meeting_transcript_row' WHEN 2 THEN 'meeting_chat_message' ELSE 'meeting_summary' END)), selected AS (SELECT m.id,coalesce(m.started_at,0) AS started FROM meetings m WHERE ${clauses.join(" AND ")}), hits AS (
124
- SELECT h.tier,m.started,m.id AS meeting_id,0 AS kind,r.id,r.start_ms,${bytes("r", ["text"])} AS bytes,t.id AS transcript_id,r.position FROM matching h JOIN meeting_transcript_rows r ON h.rid%4=1 AND r.id=h.rid/4 JOIN meeting_transcripts t ON t.id=r.meeting_transcript_id AND t.superseded_at IS NULL AND t.deleted_at IS NULL JOIN selected m ON m.id=t.meeting_id
125
- UNION ALL SELECT h.tier,m.started,m.id,1,c.id,NULL,${bytes("c", ["text"])},NULL,NULL FROM matching h JOIN meeting_chat_messages c ON h.rid%4=2 AND c.id=h.rid/4 JOIN selected m ON m.id=c.meeting_id
126
- UNION ALL SELECT h.tier,m.started,m.id,2,s.id,NULL,${bytes("s", fields)},NULL,NULL FROM matching h JOIN meeting_summaries s ON h.rid%4=3 AND s.id=h.rid/4 JOIN selected m ON m.id=s.meeting_id)
124
+ SELECT h.tier,m.started,m.id AS meeting_id,0 AS kind,r.id,r.start_ms,${bytes("r", ["text"])} AS bytes,t.id AS transcript_id,r.position,t.language,(SELECT count(*) FROM meeting_transcript_rows n WHERE n.meeting_transcript_id=t.id) AS lines_total FROM matching h JOIN meeting_transcript_rows r ON h.rid%4=1 AND r.id=h.rid/4 JOIN meeting_transcripts t ON t.id=r.meeting_transcript_id AND t.superseded_at IS NULL AND t.deleted_at IS NULL JOIN selected m ON m.id=t.meeting_id
125
+ UNION ALL SELECT h.tier,m.started,m.id,1,c.id,NULL,${bytes("c", ["text"])},NULL,NULL,NULL,NULL FROM matching h JOIN meeting_chat_messages c ON h.rid%4=2 AND c.id=h.rid/4 JOIN selected m ON m.id=c.meeting_id
126
+ UNION ALL SELECT h.tier,m.started,m.id,2,s.id,NULL,${bytes("s", fields)},NULL,NULL,NULL,NULL FROM matching h JOIN meeting_summaries s ON h.rid%4=3 AND s.id=h.rid/4 JOIN selected m ON m.id=s.meeting_id)
127
127
  SELECT h.*,${bytes("m", ["title"])}+${bytes("s", ["doc_url"])} AS context_bytes FROM hits h
128
128
  JOIN meetings m ON m.id=h.meeting_id LEFT JOIN meeting_summaries s ON h.kind=2 AND s.id=h.id
129
129
  ${seek} ORDER BY h.tier,h.started DESC,h.meeting_id,h.kind,h.id LIMIT ?`;
@@ -176,6 +176,13 @@ export function meetingHitSearchOver({ database }) {
176
176
  id: Number(row.id),
177
177
  text,
178
178
  startMs: row.start_ms === null ? null : Number(row.start_ms),
179
+ ...(kind === 0
180
+ ? {
181
+ language: row.language === null ? null : String(row.language),
182
+ line: Number(row.position) + 1,
183
+ linesTotal: Number(row.lines_total),
184
+ }
185
+ : {}),
179
186
  };
180
187
  });
181
188
  const fresh = items.flatMap((item) => {
@@ -0,0 +1,4 @@
1
+ import { type MeetingStore } from "../../meetings/index.js";
2
+ import type { StoreContext } from "./open.js";
3
+ export type MeetingLanguageStore = Required<Pick<MeetingStore, "fillTranscriptLanguages">>;
4
+ export declare const meetingLanguagesOver: ({ database }: StoreContext) => MeetingLanguageStore;
@@ -0,0 +1,36 @@
1
+ import { transcriptLanguage } from "../../meetings/index.js";
2
+ import { positiveReadInteger } from "./meeting-reads.js";
3
+ import { read, writeBatch } from "./transaction.js";
4
+ export const meetingLanguagesOver = ({ database }) => ({
5
+ async fillTranscriptLanguages({ accountId, apply, now }) {
6
+ positiveReadInteger(accountId, "accountId");
7
+ const found = read(database, () => {
8
+ const rows = database.prepare("SELECT text FROM meeting_transcript_rows WHERE meeting_transcript_id = ?");
9
+ return database
10
+ .prepare(`SELECT t.id FROM meeting_transcripts t JOIN meetings m ON m.id = t.meeting_id
11
+ WHERE m.account_id = ? AND t.language IS NULL AND t.deleted_at IS NULL ORDER BY t.id`)
12
+ .all(accountId)
13
+ .map(({ id }) => ({
14
+ id: Number(id),
15
+ language: transcriptLanguage(rows.all(Number(id)).map((row) => String(row.text))),
16
+ }));
17
+ });
18
+ if (apply) {
19
+ const update = database.prepare("UPDATE meeting_transcripts SET language = ?, updated_at = ? WHERE id = ? AND language IS NULL");
20
+ writeBatch(database, () => {
21
+ for (const { id, language } of found)
22
+ if (language !== null)
23
+ update.run(language, now, id);
24
+ });
25
+ }
26
+ const counts = new Map();
27
+ for (const { language } of found)
28
+ counts.set(language, (counts.get(language) ?? 0) + 1);
29
+ return {
30
+ accountId,
31
+ applied: apply,
32
+ transcripts: found.length,
33
+ languages: [...counts].map(([language, transcripts]) => ({ language, transcripts })),
34
+ };
35
+ },
36
+ });
@@ -1,5 +1,6 @@
1
1
  import type { Attachment, MeetingChatLinesStore, MeetingContextStore, MeetingParticipantSearchStore, MeetingPreparationContextStore, MeetingPreparationKnowledgeStore, MeetingPreparationNotesStore, MeetingRelatedHistoryStore, MeetingReviewStore, MeetingSearchIndexStore, MeetingSearchStore, MeetingSeriesHistoryStore, MeetingStatsStore, MeetingStore, MeetingTranscriptReceiptStore, MeetingTranscriptStore, NewRecord } from "../../meetings/index.js";
2
2
  import type { CacheDatabase } from "../driver.js";
3
+ import { type MeetingLanguageStore } from "./meeting-languages.js";
3
4
  import { type MeetingContextReadCapabilities, type MeetingReadCapabilities } from "./meeting-reads.js";
4
5
  import type { StoreContext } from "./open.js";
5
6
  type Row = Record<string, unknown>;
@@ -11,7 +12,7 @@ export declare const saveAttachment: (database: CacheDatabase, type: string, par
11
12
  export declare const drainMeetingIndex: (database: CacheDatabase, batch?: number) => number;
12
13
  /** Each word of the query, as a prefix, all required. */
13
14
  export declare const wordsQuery: (query: string) => string | null;
14
- export type StoredMeetings = MeetingStore & MeetingPreparationContextStore & MeetingPreparationKnowledgeStore & MeetingPreparationNotesStore & MeetingRelatedHistoryStore & MeetingSeriesHistoryStore & MeetingTranscriptStore & MeetingReadCapabilities & MeetingContextReadCapabilities & MeetingParticipantSearchStore & MeetingSearchStore & MeetingTranscriptReceiptStore & MeetingContextStore & MeetingSearchIndexStore & MeetingStatsStore & MeetingReviewStore & MeetingChatLinesStore;
15
+ export type StoredMeetings = MeetingStore & MeetingPreparationContextStore & MeetingPreparationKnowledgeStore & MeetingPreparationNotesStore & MeetingRelatedHistoryStore & MeetingSeriesHistoryStore & MeetingTranscriptStore & MeetingReadCapabilities & MeetingContextReadCapabilities & MeetingParticipantSearchStore & MeetingSearchStore & MeetingTranscriptReceiptStore & MeetingContextStore & MeetingSearchIndexStore & MeetingStatsStore & MeetingReviewStore & MeetingChatLinesStore & MeetingLanguageStore;
15
16
  /** The `MeetingStore` of `src/meetings/` over the shared store. */
16
17
  export declare const meetingStoreOver: (context: StoreContext) => StoredMeetings;
17
18
  export {};
@@ -11,6 +11,7 @@ import { meetingContextLinksOver } from "./meeting-context-links.js";
11
11
  import { meetingHitIndexOver } from "./meeting-hit-index.js";
12
12
  import { meetingHitSearchOver } from "./meeting-hit-search.js";
13
13
  import { INDEX_CODES, indexedText, meetingRowid, summaryOf } from "./meeting-index-values.js";
14
+ import { meetingLanguagesOver } from "./meeting-languages.js";
14
15
  import { meetingParticipantSearchOver } from "./meeting-participant-search.js";
15
16
  import { meetingPreparationContextOver } from "./meeting-preparation-context.js";
16
17
  import { meetingPreparationKnowledgeOver } from "./meeting-preparation-knowledge.js";
@@ -516,17 +517,18 @@ const search = (database, query, filter) => {
516
517
  .prepare(`WITH hit AS (SELECT rowid AS r FROM meeting_words WHERE meeting_words MATCH ?),
517
518
  picked AS (SELECT id, row_number() OVER (ORDER BY coalesce(started_at, 0) DESC, id) AS n FROM (${picked.sql}))
518
519
  SELECT p.n, 0 AS kind, t.id AS a, r.position AS b, r.id, t.meeting_id, r.start_ms, coalesce(length(CAST(r.text AS BLOB)),0) AS bytes,
519
- r.speaker_name AS speaker, m.account_id
520
+ r.speaker_name AS speaker, m.account_id, t.language,
521
+ (SELECT count(*) FROM meeting_transcript_rows n WHERE n.meeting_transcript_id = t.id) AS lines_total
520
522
  FROM hit h JOIN meeting_transcript_rows r ON h.r % 4 = 1 AND r.id = h.r / 4
521
523
  JOIN meeting_transcripts t ON t.id = r.meeting_transcript_id
522
524
  AND t.superseded_at IS NULL AND t.deleted_at IS NULL
523
525
  JOIN picked p ON p.id = t.meeting_id JOIN meetings m ON m.id = t.meeting_id
524
526
  UNION ALL
525
- SELECT p.n, 1, c.id, 0, c.id, c.meeting_id, NULL, coalesce(length(CAST(c.text AS BLOB)),0), NULL, NULL
527
+ SELECT p.n, 1, c.id, 0, c.id, c.meeting_id, NULL, coalesce(length(CAST(c.text AS BLOB)),0), NULL, NULL, NULL, NULL
526
528
  FROM hit h JOIN meeting_chat_messages c ON h.r % 4 = 2 AND c.id = h.r / 4
527
529
  JOIN picked p ON p.id = c.meeting_id
528
530
  UNION ALL
529
- SELECT p.n, 2, s.id, 0, s.id, s.meeting_id, NULL, coalesce(length(CAST(s.title AS BLOB)),0)+coalesce(length(CAST(s.overview AS BLOB)),0)+coalesce(length(CAST(s.sections AS BLOB)),0)+coalesce(length(CAST(s.next_steps AS BLOB)),0)+coalesce(length(CAST(s.content AS BLOB)),0), NULL, NULL
531
+ SELECT p.n, 2, s.id, 0, s.id, s.meeting_id, NULL, coalesce(length(CAST(s.title AS BLOB)),0)+coalesce(length(CAST(s.overview AS BLOB)),0)+coalesce(length(CAST(s.sections AS BLOB)),0)+coalesce(length(CAST(s.next_steps AS BLOB)),0)+coalesce(length(CAST(s.content AS BLOB)),0), NULL, NULL, NULL, NULL
530
532
  FROM hit h JOIN meeting_summaries s ON h.r % 4 = 3 AND s.id = h.r / 4
531
533
  JOIN picked p ON p.id = s.meeting_id
532
534
  ORDER BY 1, 2, 3, 4 LIMIT 10001`)
@@ -551,6 +553,9 @@ const search = (database, query, filter) => {
551
553
  cuePosition: Number(row.b),
552
554
  }),
553
555
  speakerName: row.speaker === null ? null : String(row.speaker),
556
+ language: row.language === null ? null : String(row.language),
557
+ line: Number(row.b) + 1,
558
+ linesTotal: Number(row.lines_total),
554
559
  }
555
560
  : {};
556
561
  return {
@@ -596,6 +601,7 @@ export const meetingStoreOver = (context) => {
596
601
  ...meetingParticipantSearchOver(context),
597
602
  ...meetingHitIndexOver(context),
598
603
  ...meetingContextLinksOver(context),
604
+ ...meetingLanguagesOver(context),
599
605
  async appendTranscriptsWithReceipt(input) {
600
606
  try {
601
607
  return append(context, input);