@wirecat/cli-core 0.551.0 → 0.553.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 (94) hide show
  1. package/CHANGELOG.md +83 -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/compact.d.ts +4 -0
  8. package/dist/cli/compact.js +65 -7
  9. package/dist/cli/context.d.ts +2 -0
  10. package/dist/cli/meeting-preparation-answer.d.ts +3 -0
  11. package/dist/cli/meeting-preparation-projects.d.ts +3 -0
  12. package/dist/cli/meeting-preparation-read.d.ts +3 -0
  13. package/dist/cli/messenger/context.d.ts +3 -0
  14. package/dist/cli/messenger/projects-command.js +38 -11
  15. package/dist/cli/messenger/search-answers.js +8 -1
  16. package/dist/cli/messenger/serve-catch-up.d.ts +43 -0
  17. package/dist/cli/messenger/serve-catch-up.js +90 -0
  18. package/dist/cli/messenger/serve-command.js +54 -1
  19. package/dist/cli/messenger/serve-relay.d.ts +1 -0
  20. package/dist/cli/messenger/server-command.d.ts +4 -3
  21. package/dist/cli/messenger/server-command.js +30 -12
  22. package/dist/cli/program.js +9 -4
  23. package/dist/cli/project-evidence.d.ts +33 -4
  24. package/dist/cli/project-evidence.js +38 -4
  25. package/dist/cli/project-status-rows.js +11 -1
  26. package/dist/cli/project-status.d.ts +24 -0
  27. package/dist/cli/project-status.js +52 -9
  28. package/dist/cli/schedule/schedule-command.js +5 -1
  29. package/dist/entities/hit.d.ts +5 -0
  30. package/dist/entities/hit.js +5 -0
  31. package/dist/entities/index.d.ts +27 -0
  32. package/dist/entities/listener.d.ts +6 -0
  33. package/dist/entities/listener.js +1 -0
  34. package/dist/entities/meeting-preparation.d.ts +3 -0
  35. package/dist/errors/reasons.js +1 -0
  36. package/dist/mcp/meeting-preparation.d.ts +3 -0
  37. package/dist/meetings/answers.d.ts +5 -0
  38. package/dist/meetings/answers.js +8 -0
  39. package/dist/meetings/cli/index.js +14 -0
  40. package/dist/meetings/index.d.ts +2 -1
  41. package/dist/meetings/index.js +1 -0
  42. package/dist/meetings/ingest.js +2 -1
  43. package/dist/meetings/language.d.ts +5 -0
  44. package/dist/meetings/language.js +57 -0
  45. package/dist/meetings/mcp/search.d.ts +3 -0
  46. package/dist/meetings/project-evidence-answer.d.ts +3 -0
  47. package/dist/meetings/result-items.d.ts +9 -0
  48. package/dist/meetings/result-items.js +7 -0
  49. package/dist/meetings/store.d.ts +22 -0
  50. package/dist/meetings/testing/memory.js +3 -0
  51. package/dist/meetings/transcription.d.ts +2 -0
  52. package/dist/meetings/transcription.js +5 -1
  53. package/dist/search/lucene/types.d.ts +7 -0
  54. package/dist/search/lucene/types.js +11 -1
  55. package/dist/services/index.d.ts +4 -3
  56. package/dist/services/index.js +4 -3
  57. package/dist/services/link-scorers/index.d.ts +1 -1
  58. package/dist/services/link-scorers/index.js +1 -1
  59. package/dist/services/link-scorers/scan.d.ts +0 -1
  60. package/dist/services/link-scorers/scan.js +1 -1
  61. package/dist/services/messages-archive-search.js +3 -2
  62. package/dist/services/messages-combined.js +2 -2
  63. package/dist/services/messages-discovery.js +2 -2
  64. package/dist/services/notes-search.js +4 -5
  65. package/dist/services/project-purposes.d.ts +28 -0
  66. package/dist/services/project-purposes.js +41 -0
  67. package/dist/services/project-status/index.d.ts +22 -8
  68. package/dist/services/project-status/index.js +17 -9
  69. package/dist/services/search-all-meetings.d.ts +3 -0
  70. package/dist/services/search-all-meetings.js +6 -1
  71. package/dist/services/stored-project-gathering.d.ts +79 -4
  72. package/dist/services/stored-project-gathering.js +171 -22
  73. package/dist/speech/local.d.ts +2 -0
  74. package/dist/speech/local.js +8 -1
  75. package/dist/speech/models.d.ts +2 -0
  76. package/dist/speech/models.js +2 -0
  77. package/dist/store/driver.d.ts +11 -0
  78. package/dist/store/driver.js +13 -0
  79. package/dist/store/drivers/node-sqlite.d.ts +1 -1
  80. package/dist/store/drivers/node-sqlite.js +3 -0
  81. package/dist/store/messages.d.ts +2 -1
  82. package/dist/store/messages.js +2 -2
  83. package/dist/store/sqlite/lucene.d.ts +1 -1
  84. package/dist/store/sqlite/lucene.js +33 -7
  85. package/dist/store/sqlite/meeting-hit-search.js +10 -3
  86. package/dist/store/sqlite/meeting-languages.d.ts +4 -0
  87. package/dist/store/sqlite/meeting-languages.js +36 -0
  88. package/dist/store/sqlite/meetings.d.ts +2 -1
  89. package/dist/store/sqlite/meetings.js +9 -3
  90. package/dist/store/sqlite/note-search.js +2 -2
  91. package/dist/store/sqlite/reads.d.ts +1 -1
  92. package/dist/store/sqlite/reads.js +2 -2
  93. package/package.json +1 -1
  94. package/parity.json +10 -0
@@ -1,7 +1,8 @@
1
1
  import type { ContextSource } from "../context/evidence.js";
2
- import type { MessageStore } from "../store/store.js";
2
+ import type { AccountKey, MessageStore } from "../store/store.js";
3
3
  import { gatherProjectEvidence, type ProjectChatFetcher, type ProjectGatheringPorts } from "./project-gathering.js";
4
4
  import { type EvidenceMentions } from "./project-gathering-mentions.js";
5
+ import { type PurposeConfig } from "./project-purposes.js";
5
6
  export interface StoredProjectInput {
6
7
  project: string;
7
8
  /** The sources the host resolved from its `--source`, and those it could not read. */
@@ -31,19 +32,81 @@ export interface StoredProjectInput {
31
32
  maxFetchMs?: number;
32
33
  onFetchProgress?: ProjectGatheringPorts["onFetchProgress"];
33
34
  signal?: AbortSignal;
35
+ /** The linked chats' purposes to read; `["all"]` reads every one. Left out, the config's defaults. */
36
+ purposes?: readonly string[];
37
+ /** The host's purposes; core's built-in ones when left out. */
38
+ purposeConfig?: PurposeConfig;
39
+ /** Proposed `member-of` links count as linked chats and members too, after the confirmed ones, every item marked. */
40
+ includeProposed?: boolean;
41
+ /** How many messages a left-out chat holds in the window; the store's own count when left out. */
42
+ count?: ChatCounter;
34
43
  }
44
+ export interface ChatCount {
45
+ messages: number;
46
+ firstAt: string | null;
47
+ lastAt: string | null;
48
+ }
49
+ export type ChatCounter = (account: AccountKey, chatId: string, window: {
50
+ since: number;
51
+ until: number;
52
+ }) => Promise<ChatCount>;
53
+ /** The store's count of one chat's messages in a window, `until` exclusive, and the first and last of them. */
54
+ export declare const storedChatCount: (store: MessageStore) => ChatCounter;
35
55
  /** `project:<id>`, or the one project a key, name or alias names; several or none is refused, never guessed. */
36
56
  export declare const canonicalProjectRef: (store: MessageStore, reference: string) => Promise<string>;
57
+ export interface ProjectMember {
58
+ person: string;
59
+ state: "read" | "member-limit" | "no-identity";
60
+ /** The proposal that names this person a member; read only with `includeProposed`. */
61
+ proposed?: string;
62
+ score?: number;
63
+ }
37
64
  export interface LinkedChat {
38
65
  provider: string;
39
66
  account: string;
40
67
  chat: string;
41
68
  /** The link's free role (`source report`, `what worked`), if the owner set one. */
42
69
  role: string | null;
70
+ /** What the chat is for in the project; `null` means `work`. */
71
+ purpose: string | null;
72
+ /** The confirmed link's id; null for a proposed one. */
73
+ relationship: string | null;
74
+ /** The proposal that links the chat; only with `includeProposed`. */
75
+ proposed?: string;
76
+ score?: number;
77
+ }
78
+ /**
79
+ * Links the owner confirmed, then, when asked, the proposed ones: a suggestion would put a whole chat into the
80
+ * evidence on a guess, so it is read only on request and marked.
81
+ */
82
+ export declare const linkedChatsOf: (store: MessageStore, project: string | null, options?: {
83
+ includeProposed?: boolean;
84
+ }) => Promise<LinkedChat[]>;
85
+ /** A linked chat whose purpose this run did not read: counted, never listed. */
86
+ export interface LeftOutChat {
87
+ ref: string;
88
+ provider: string;
89
+ account: string;
90
+ chat: string;
91
+ title: string | null;
92
+ purpose: string;
93
+ relationship: string | null;
94
+ proposed?: string;
95
+ messages: number;
96
+ firstAt: string | null;
97
+ lastAt: string | null;
43
98
  }
44
- /** Only links the owner confirmed: a suggested one would put a whole chat into the evidence on a guess. */
45
- export declare const linkedChatsOf: (store: MessageStore, project: string | null) => Promise<LinkedChat[]>;
46
- export type StoredProjectGathering = Awaited<ReturnType<typeof gatherProjectEvidence>> & {
99
+ type Gathered = Awaited<ReturnType<typeof gatherProjectEvidence>>;
100
+ export type StoredProjectGathering = Omit<Gathered, "items" | "linkedChats"> & {
101
+ /** `proposed`: the proposal whose link alone brought the item in; only with `includeProposed`. */
102
+ items: (Gathered["items"][number] & {
103
+ proposed?: string;
104
+ })[];
105
+ linkedChats: (Gathered["linkedChats"][number] & {
106
+ purpose: string;
107
+ proposed?: string;
108
+ score?: number;
109
+ })[];
47
110
  projectRef: string | null;
48
111
  unreadSources: {
49
112
  source: string;
@@ -51,9 +114,21 @@ export type StoredProjectGathering = Awaited<ReturnType<typeof gatherProjectEvid
51
114
  reason?: "out-of-scope" | "linked-chat-limit" | "member-limit" | "no-identity" | "unknown-provider";
52
115
  }[];
53
116
  mentions: EvidenceMentions;
117
+ /** The purposes read: `all`, or the list. */
118
+ purposes: "all" | string[];
119
+ leftOut: LeftOutChat[];
120
+ members: ProjectMember[];
121
+ /** One linked chat with no purpose set gave most of this run's items; a first run only, so it is said once. */
122
+ purposeHint: {
123
+ chat: string;
124
+ relationship: string;
125
+ items: number;
126
+ of: number;
127
+ } | null;
54
128
  };
55
129
  /**
56
130
  * `projects evidence` over a whole store: the project's names, key and aliases, the given words and contacts, its
57
131
  * confirmed person members and its confirmed linked chats, in the sources the host resolved.
58
132
  */
59
133
  export declare const gatherStoredProject: (store: MessageStore, input: StoredProjectInput) => Promise<StoredProjectGathering>;
134
+ export {};
@@ -4,6 +4,21 @@ import { chatCoverage } from "./chat-coverage.js";
4
4
  import { gatherProjectEvidence } from "./project-gathering.js";
5
5
  import { storedNeighbours } from "./project-gathering-around.js";
6
6
  import { findEvidenceMentions } from "./project-gathering-mentions.js";
7
+ import { LINK_PURPOSE, purposeRead, purposesOf } from "./project-purposes.js";
8
+ /** The store's count of one chat's messages in a window, `until` exclusive, and the first and last of them. */
9
+ export const storedChatCount = (store) => async (account, chatId, { since, until }) => {
10
+ const window = { since: new Date(since).toISOString(), until: new Date(until - 1).toISOString() };
11
+ const [messages, newest, oldest] = await Promise.all([
12
+ store.messages.countMessages(account, chatId, window),
13
+ store.messages.messages(account, chatId, { limit: 1, since: window.since, until: until - 1 }),
14
+ store.messages.messages(account, chatId, { limit: 1, after: { time: since - 1 }, until: until - 1 }),
15
+ ]);
16
+ return {
17
+ messages,
18
+ firstAt: oldest.items[0]?.timestamp ?? null,
19
+ lastAt: newest.items.at(-1)?.timestamp ?? null,
20
+ };
21
+ };
7
22
  const PROJECT_ID = /^project:[1-9]\d*$/;
8
23
  /** `project:<id>`, or the one project a key, name or alias names; several or none is refused, never guessed. */
9
24
  export const canonicalProjectRef = async (store, reference) => {
@@ -85,59 +100,122 @@ const contactOf = async (store, contact) => {
85
100
  };
86
101
  const LINKED_CHATS = 16;
87
102
  const CORE_LIMIT = 16;
88
- /** Confirmed `person` members, in a stable order, so a cut at the lane limit always drops the same ones. */
89
- const memberRefsOf = async (store, project) => {
103
+ /** Pending `member-of` proposals to the project, oldest first, so a cut at a limit always drops the same ones. */
104
+ const proposedLinksOf = async (store, project) => {
105
+ const stored = await store.proposedActions.list({ kinds: ["relationship"], status: "proposed", project, limit: 500 });
106
+ const proposals = stored
107
+ .flatMap((action) => {
108
+ const payload = action.payload;
109
+ const { purpose, score } = action.payload;
110
+ if (payload?.kind !== "member-of" || payload.to !== project || typeof payload.from !== "string")
111
+ return [];
112
+ return [
113
+ {
114
+ from: payload.from,
115
+ id: action.id,
116
+ role: typeof payload.role === "string" ? payload.role : null,
117
+ purpose: typeof purpose === "string" ? purpose : null,
118
+ ...(typeof score === "number" ? { score } : {}),
119
+ },
120
+ ];
121
+ })
122
+ .sort((a, b) => Number(a.id) - Number(b.id));
123
+ const legacy = (await store.knowledge.relations(null, project))
124
+ .filter((one) => !one.confirmed && one.kind === "member-of" && one.to === project)
125
+ .map((one) => ({ from: one.from, id: `link:${one.id}`, role: one.role, purpose: one.purpose }));
126
+ return [...proposals, ...legacy];
127
+ };
128
+ /** Confirmed `person` members in a stable order, then proposed ones when asked, so a cut drops the same ones. */
129
+ const memberRefsOf = async (store, project, includeProposed) => {
90
130
  if (!project)
91
131
  return [];
92
- const refs = [];
132
+ const confirmed = new Set();
93
133
  for (const relation of await store.knowledge.relations(null, project))
94
134
  if (relation.confirmed &&
95
135
  relation.kind === "member-of" &&
96
136
  relation.to === project &&
97
137
  /^person:.+/.test(relation.from))
98
- refs.push(relation.from);
99
- return [...new Set(refs)].sort();
138
+ confirmed.add(relation.from);
139
+ const members = [...confirmed].sort().map((ref) => ({ ref }));
140
+ if (!includeProposed)
141
+ return members;
142
+ for (const one of await proposedLinksOf(store, project))
143
+ if (/^person:.+/.test(one.from) && !members.some((member) => member.ref === one.from))
144
+ members.push({ ref: one.from, proposed: one.id, ...(one.score === undefined ? {} : { score: one.score }) });
145
+ return members;
100
146
  };
101
147
  /**
102
148
  * Members are read by what their identities wrote, like `--contact person:<id>` but without the owner's aliases:
103
149
  * a short alias would add a text lane that matches mentions, and membership is about who wrote, not who was named.
104
150
  */
105
- const memberLanesOf = async (store, project, given, room) => {
151
+ const memberLanesOf = async (store, project, given, room, includeProposed) => {
106
152
  const lanes = [];
107
153
  const left = [];
108
- for (const reference of (await memberRefsOf(store, project)).filter((one) => !given.has(one))) {
154
+ const rows = [];
155
+ for (const member of (await memberRefsOf(store, project, includeProposed)).filter((one) => !given.has(one.ref))) {
156
+ const { ref: reference, ...marks } = member;
109
157
  const person = await store.people.personByUid(reference.slice("person:".length));
110
158
  const senders = person ? sendersOf(person) : [];
111
- if (!senders.length)
112
- left.push({ source: reference, kind: "member", reason: "no-identity" });
113
- else if (lanes.length >= room)
114
- left.push({ source: reference, kind: "member", reason: "member-limit" });
115
- else
159
+ const state = !senders.length ? "no-identity" : lanes.length >= room ? "member-limit" : "read";
160
+ if (state === "read")
116
161
  lanes.push({ reference, senders });
162
+ else
163
+ left.push({ source: reference, kind: "member", reason: state });
164
+ rows.push({ person: reference, state, ...marks });
117
165
  }
118
- return { lanes, left };
166
+ return { lanes, left, rows };
119
167
  };
120
- /** Only links the owner confirmed: a suggested one would put a whole chat into the evidence on a guess. */
121
- export const linkedChatsOf = async (store, project) => {
168
+ const chatOf = (from) => {
169
+ if (!from.startsWith("chat:"))
170
+ return undefined;
171
+ const reference = parseReference(from);
172
+ return reference.type === "chat" ? reference : undefined;
173
+ };
174
+ /**
175
+ * Links the owner confirmed, then, when asked, the proposed ones: a suggestion would put a whole chat into the
176
+ * evidence on a guess, so it is read only on request and marked.
177
+ */
178
+ export const linkedChatsOf = async (store, project, options = {}) => {
122
179
  if (!project)
123
180
  return [];
124
181
  const chats = [];
125
182
  for (const relation of await store.knowledge.relations(null, project)) {
126
183
  if (!relation.confirmed || relation.kind !== "member-of" || relation.to !== project)
127
184
  continue;
128
- if (!relation.from.startsWith("chat:"))
129
- continue;
130
- const reference = parseReference(relation.from);
131
- if (reference.type === "chat")
185
+ const reference = chatOf(relation.from);
186
+ if (reference)
132
187
  chats.push({
133
188
  provider: reference.provider,
134
189
  account: reference.account,
135
190
  chat: reference.chat,
136
191
  role: relation.role,
192
+ purpose: relation.purpose,
193
+ relationship: relation.id,
137
194
  });
138
195
  }
196
+ if (!options.includeProposed)
197
+ return chats;
198
+ for (const one of await proposedLinksOf(store, project)) {
199
+ const reference = chatOf(one.from);
200
+ if (!reference)
201
+ continue;
202
+ if (chats.some((chat) => chat.provider === reference.provider && chat.account === reference.account && chat.chat === reference.chat))
203
+ continue;
204
+ chats.push({
205
+ provider: reference.provider,
206
+ account: reference.account,
207
+ chat: reference.chat,
208
+ role: one.role,
209
+ purpose: one.purpose,
210
+ relationship: null,
211
+ proposed: one.id,
212
+ ...(one.score === undefined ? {} : { score: one.score }),
213
+ });
214
+ }
139
215
  return chats;
140
216
  };
217
+ /** Over half the items and at least this many, from one chat's membership alone: worth asking what the chat is for. */
218
+ const HINT_ITEMS = 20;
141
219
  /**
142
220
  * `projects evidence` over a whole store: the project's names, key and aliases, the given words and contacts, its
143
221
  * confirmed person members and its confirmed linked chats, in the sources the host resolved.
@@ -160,9 +238,12 @@ export const gatherStoredProject = async (store, input) => {
160
238
  (input.keywords?.length ?? 0) +
161
239
  contacts.filter((one) => one.name).length;
162
240
  const room = Math.min(CORE_LIMIT - contacts.length, CORE_LIMIT - searchLanes - contacts.filter((one) => one.senders).length);
163
- const members = await memberLanesOf(store, project.ref, new Set(contacts.flatMap((one) => one.reference ?? [])), room);
241
+ const includeProposed = input.includeProposed === true;
242
+ const purposes = purposesOf(input.purposes, input.purposeConfig, project);
243
+ const members = await memberLanesOf(store, project.ref, new Set(contacts.flatMap((one) => one.reference ?? [])), room, includeProposed);
164
244
  const people = [...contacts, ...members.lanes];
165
- const linked = await linkedChatsOf(store, project.ref);
245
+ const all = await linkedChatsOf(store, project.ref, { includeProposed });
246
+ const linked = all.filter((one) => purposeRead(purposes, one.purpose));
166
247
  const linkedChats = linked.slice(0, LINKED_CHATS).map(({ provider, account, chat }) => ({ provider, account, chat }));
167
248
  const budgets = {
168
249
  ...(input.maxReads === undefined ? {} : { maxReads: input.maxReads }),
@@ -208,5 +289,73 @@ export const gatherStoredProject = async (store, input) => {
208
289
  reason: "linked-chat-limit",
209
290
  })),
210
291
  ];
211
- return { projectRef: project.ref, unreadSources: [...unread, ...unreadChats, ...members.left], ...gathered, mentions };
292
+ const refOf = (one) => formatReference({ type: "chat", ...one });
293
+ const byRef = new Map(linked.map((one) => [refOf(one), one]));
294
+ const proposedLanes = new Map();
295
+ for (const row of gathered.linkedChats) {
296
+ const proposed = byRef.get(row.ref)?.proposed;
297
+ if (proposed && row.lane !== null)
298
+ proposedLanes.set(row.lane, proposed);
299
+ }
300
+ for (const lane of gathered.lanes) {
301
+ const proposed = members.rows.find((one) => one.proposed && lane.people.includes(one.person))?.proposed;
302
+ if (proposed && lane.kinds.includes("sender"))
303
+ proposedLanes.set(lane.id, proposed);
304
+ }
305
+ // Marked only when nothing but proposed links found it: an item a word or a confirmed link found is evidence anyway.
306
+ const items = gathered.items.map((item) => {
307
+ const proposed = item.lanes.every((lane) => proposedLanes.has(lane))
308
+ ? proposedLanes.get(item.lanes[0] ?? "")
309
+ : undefined;
310
+ return proposed ? { ...item, proposed } : item;
311
+ });
312
+ const linkedRows = gathered.linkedChats.map((row) => {
313
+ const link = byRef.get(row.ref);
314
+ return {
315
+ ...row,
316
+ purpose: link?.purpose ?? LINK_PURPOSE,
317
+ ...(link?.proposed ? { proposed: link.proposed } : {}),
318
+ ...(link?.score === undefined ? {} : { score: link.score }),
319
+ };
320
+ });
321
+ const count = input.count ?? storedChatCount(store);
322
+ const leftOut = [];
323
+ for (const one of all.filter((chat) => !purposeRead(purposes, chat.purpose))) {
324
+ const account = { provider: one.provider, account: one.account };
325
+ const [counted, [held]] = await Promise.all([
326
+ count(account, one.chat, { since: gathered.window.after, until: gathered.window.before }),
327
+ chatCoverage(store, account, [one.chat]),
328
+ ]);
329
+ leftOut.push({
330
+ ref: refOf(one),
331
+ provider: one.provider,
332
+ account: one.account,
333
+ chat: one.chat,
334
+ title: held?.title ?? null,
335
+ purpose: one.purpose ?? LINK_PURPOSE,
336
+ relationship: one.relationship,
337
+ ...(one.proposed ? { proposed: one.proposed } : {}),
338
+ ...counted,
339
+ });
340
+ }
341
+ return {
342
+ projectRef: project.ref,
343
+ unreadSources: [...unread, ...unreadChats, ...members.left],
344
+ ...gathered,
345
+ items,
346
+ linkedChats: linkedRows,
347
+ mentions,
348
+ purposes: purposes === "all" ? "all" : [...purposes],
349
+ leftOut,
350
+ members: members.rows,
351
+ purposeHint: gathered.resumed ? null : purposeHintOf(linked, linkedRows, items.length),
352
+ };
353
+ };
354
+ const purposeHintOf = (linked, rows, of) => {
355
+ const [top] = [...rows].sort((a, b) => (b.items ?? 0) - (a.items ?? 0));
356
+ const items = top?.items ?? 0;
357
+ const link = linked.find((one) => formatReference({ type: "chat", ...one }) === top?.ref);
358
+ if (!top || !link?.relationship || link.purpose !== null || items < HINT_ITEMS || items * 2 <= of)
359
+ return null;
360
+ return { chat: top.ref, relationship: link.relationship, items, of };
212
361
  };
@@ -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);
@@ -45,9 +45,10 @@ export interface AccountMessages {
45
45
  * ends the window; `reads.MessagePageWindow` says how.
46
46
  */
47
47
  messages(key: AccountKey, chatId: Id, window: reads.MessagePageWindow): Promise<Page<Message>>;
48
- /** How many stored messages the chat has, sent at `since` or later when it is given. */
48
+ /** How many stored messages the chat has, sent at `since` or later and at `until` or earlier when given. */
49
49
  countMessages(key: AccountKey, chatId: Id, options?: {
50
50
  since?: string;
51
+ until?: string;
51
52
  }): Promise<number>;
52
53
  /** Saved or edited after `at` (ISO, this machine's clock), oldest first, and the ids deleted after it. */
53
54
  changes(key: AccountKey, chatId: Id, at: string): Promise<{
@@ -32,9 +32,9 @@ export const accountMessagesOver = (context) => {
32
32
  const chatKey = chatKeyOf(key, chatId);
33
33
  return chatKey === undefined ? { items: [], hasMore: false } : reads.messagePage(context, chatKey, window);
34
34
  },
35
- countMessages: async (key, chatId, { since } = {}) => {
35
+ countMessages: async (key, chatId, { since, until } = {}) => {
36
36
  const chatKey = chatKeyOf(key, chatId);
37
- return chatKey === undefined ? 0 : reads.countMessages(context, chatKey, since);
37
+ return chatKey === undefined ? 0 : reads.countMessages(context, chatKey, since, until);
38
38
  },
39
39
  changes: async (key, chatId, at) => {
40
40
  const chatKey = chatKeyOf(key, chatId);
@@ -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) => {