@wirecat/cli-core 0.357.0 → 0.359.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.359.0 — 19.10.2026
11
+
12
+ ### Fixed
13
+
14
+ - **The first `serve` after an upgrade no longer locks other commands out of the store while it re-keys chats.**
15
+ A pushed batch's `rekeys` were saved one transaction each, back to back, so another command waited for the write
16
+ lock as long as the re-keys ran: up to 3.4 s for 5,000 re-keys on a synthetic 10,000-chat store. They now go
17
+ through the new `store.chats.rekeyChats` in passes of one transaction, each holding the lock for about 50 ms with
18
+ a pause after it. Each re-key keeps its own savepoint, privacy merge and account scope, and a refused one still
19
+ warns and leaves the others applied. Once the store is about to close (`Saving.closing`, which `connected`
20
+ aborts) or refuses a whole pass (closed, busy past its timeout), every re-key still queued, in any batch, is given
21
+ up with one warning: "N chat re-keys not applied yet; the next serve applies them", and one
22
+ `rekeys_not_applied` event whose new `counts` field (`WarningEvent.counts`) holds N. Before, a messenger that sends
23
+ each re-key as its own batch (wa) got one `store_not_written` per chat left. `ChatsStore` gains `rekeyChats`, so a
24
+ hand-written `ChatsStore` must add it.
25
+
26
+ ## 0.358.0 — 19.10.2026
27
+
28
+ ### Added
29
+
30
+ - **Meeting transcripts in bounded context reads:** `ContextSource.kind` accepts `meeting`; the native
31
+ reader returns current transcript cue references with `timeKind: "meeting-start"`. Account/time scope,
32
+ cursor validation, read-only snapshots and pre-hydration byte limits apply. Deleted/superseded and
33
+ queued cues are excluded; missing indexes and unknown archive coverage remain explicit. Hosts must
34
+ opt in to meeting sources; the reader does not fetch recordings, fill indexes or infer project links.
35
+
10
36
  ## 0.357.0 — 19.10.2026
11
37
 
12
38
  ### Added
@@ -37,7 +37,7 @@ export declare const mapPreparationCommunications: ({ items, ...section }: Meeti
37
37
  title: string | null;
38
38
  url: string | null;
39
39
  text: string;
40
- timeKind: "sent" | "source-modified" | "stored-update";
40
+ timeKind: "sent" | "source-modified" | "stored-update" | "meeting-start";
41
41
  revision: number | null;
42
42
  selectionId: string;
43
43
  textTruncated: boolean;
@@ -194,6 +194,7 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
194
194
  const adapter = observed(flooded(connection, memory, { name: name ?? app.command, warn: renderer.warn, pacer }), events);
195
195
  const pending = new Set();
196
196
  const announcing = new Set();
197
+ const closing = new AbortController();
197
198
  const saving = self === null
198
199
  ? adapter
199
200
  : stored(adapter, {
@@ -204,6 +205,7 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
204
205
  events,
205
206
  pending,
206
207
  announcing,
208
+ closing: closing.signal,
207
209
  ...(deletedWithoutChat ? { deletedWithoutChat } : {}),
208
210
  rekeyed: (from, to) => rekeyRecipients(app, settings.profile, env, renderer.warn, from, to),
209
211
  recipientIds: () => (new RecipientList(recipientsPathFor(app, settings.profile, env), app.command).read() ?? []).flatMap((recipient) => [recipient.id, ...(recipient.partnerId ? [recipient.partnerId] : [])]),
@@ -230,6 +232,7 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
230
232
  renderer.warn(`not saved to the local store: the messenger was still handing on ${what} at exit`);
231
233
  });
232
234
  await settled(new Set([...pending, ...announcing]), SAVES_WAIT_MS);
235
+ closing.abort();
233
236
  if (pending.size > 0) {
234
237
  events({ event: "warning", code: "store_not_written", operation: "close" });
235
238
  renderer.warn("not saved to the local store: the last messages were still being written at exit");
@@ -21,6 +21,13 @@ export interface Saving {
21
21
  };
22
22
  /** After a re-key is saved: what outside the store spells the chat id follows it. */
23
23
  rekeyed?: (from: Id, to: Id) => void | Promise<void>;
24
+ /** How long one pass of re-keys may hold the write lock, and the pause after it. */
25
+ rekeyPacing?: {
26
+ lockMs: number;
27
+ pauseMs: number;
28
+ };
29
+ /** Aborted just before the store closes: the re-keys still queued are given up, with one warning for them all. */
30
+ closing?: AbortSignal;
24
31
  /**
25
32
  * Chat and partner ids this account's recipient list names, told to `storedChats` apart from the stored ones: an
26
33
  * entry saved before any chat was stored under its id would otherwise never be re-keyed, and the send guard would
@@ -41,4 +48,4 @@ export interface Saving {
41
48
  * would erase what `chats list` stored. Nor are `chat` and `contact`: a card's member list is not
42
49
  * known to be whole, and `saveMembers` replaces the list.
43
50
  */
44
- export declare const stored: (messenger: MessengerAdapter, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, namesFromStore, recipientIds, }: Saving) => MessengerAdapter;
51
+ export declare const stored: (messenger: MessengerAdapter, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, rekeyPacing, closing, namesFromStore, recipientIds, }: Saving) => MessengerAdapter;
@@ -3,6 +3,7 @@ import { capability, throughWrapper } from "../../ports/messenger.js";
3
3
  import { isId, pickPerson } from "../../resolve.js";
4
4
  import { codeOf } from "../../sends/guarded.js";
5
5
  import { chatIdIn } from "../../services/message-resolution.js";
6
+ const REKEY_PACING = { lockMs: 50, pauseMs: 25 };
6
7
  /**
7
8
  * The adapter, with what each read answers written to the store. **A failed write never fails the
8
9
  * read** — the answer is already here, and it is what was asked for.
@@ -11,9 +12,15 @@ import { chatIdIn } from "../../services/message-resolution.js";
11
12
  * would erase what `chats list` stored. Nor are `chat` and `contact`: a card's member list is not
12
13
  * known to be whole, and `saveMembers` replaces the list.
13
14
  */
14
- export const stored = (messenger, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, namesFromStore, recipientIds, }) => {
15
+ export const stored = (messenger, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, rekeyPacing = REKEY_PACING, closing, namesFromStore, recipientIds, }) => {
15
16
  let warned = false;
16
17
  let recorded = profile === undefined;
18
+ const notWritten = (operation, error) => {
19
+ events({ event: "warning", code: "store_not_written", operation });
20
+ if (!warned)
21
+ warn(`not saved to the local store: ${error instanceof Error ? error.message : String(error)}`);
22
+ warned = true;
23
+ };
17
24
  const attempt = async (operation, write) => {
18
25
  try {
19
26
  const opened = await store();
@@ -28,10 +35,7 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
28
35
  }
29
36
  }
30
37
  catch (error) {
31
- events({ event: "warning", code: "store_not_written", operation });
32
- if (!warned)
33
- warn(`not saved to the local store: ${error instanceof Error ? error.message : String(error)}`);
34
- warned = true;
38
+ notWritten(operation, error);
35
39
  }
36
40
  };
37
41
  const save = (operation, writing) => {
@@ -72,6 +76,54 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
72
76
  return undefined;
73
77
  }
74
78
  };
79
+ /**
80
+ * The first serve after an upgrade re-keys every phone-form chat at once. Back-to-back transactions keep the write
81
+ * lock so busy that another command waits past `busy_timeout`, so the re-keys go in passes with a pause between.
82
+ */
83
+ let rekeysQueued = 0;
84
+ let rekeyRound = 0;
85
+ const rekeysOnly = new Set();
86
+ // wa sends each re-key as its own batch, so a closed store would otherwise refuse, and warn for, every chat left.
87
+ const giveUpRekeys = () => {
88
+ rekeyRound += 1;
89
+ const count = rekeysQueued;
90
+ rekeysQueued = 0;
91
+ for (const saving of rekeysOnly)
92
+ pending?.delete(saving);
93
+ if (count === 0)
94
+ return;
95
+ events({ event: "warning", code: "rekeys_not_applied", operation: "history.rekey", counts: { rekeys: count } });
96
+ warn(`${count} chat re-key${count === 1 ? "" : "s"} not applied yet; the next serve applies them`);
97
+ };
98
+ closing?.addEventListener("abort", giveUpRekeys, { once: true });
99
+ const saveRekeys = async (pairs, round) => {
100
+ let rest = pairs;
101
+ while (rest.length > 0 && round === rekeyRound) {
102
+ let pass;
103
+ try {
104
+ pass = await (await store())?.chats.rekeyChats(account, rest, rekeyPacing.lockMs);
105
+ }
106
+ catch {
107
+ // Closed at exit or busy past its timeout, the store would refuse every pass after this one too.
108
+ if (round === rekeyRound)
109
+ giveUpRekeys();
110
+ return;
111
+ }
112
+ if (round !== rekeyRound)
113
+ return;
114
+ const taken = pass?.taken ?? rest.length;
115
+ rekeysQueued -= taken;
116
+ for (const { from, to } of pass?.applied ?? [])
117
+ await Promise.resolve()
118
+ .then(() => rekeyed?.(from, to))
119
+ .catch((error) => notWritten("history.rekey", error));
120
+ for (const { error } of pass?.refused ?? [])
121
+ notWritten("history.rekey", error);
122
+ rest = rest.slice(taken);
123
+ if (rest.length > 0)
124
+ await new Promise((resolve) => setTimeout(resolve, rekeyPacing.pauseMs));
125
+ }
126
+ };
75
127
  const saveBatches = (operation, onBatch) => {
76
128
  // In order: a batch's messages may belong to chats only an earlier batch named.
77
129
  let previous = Promise.resolve();
@@ -87,18 +139,22 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
87
139
  if (chat.title)
88
140
  titles.set(chat.id, chat.title);
89
141
  }
90
- // Each re-key apart: one refused leaves the chat split, never the batch's history unsaved.
142
+ const rekeys = batch.rekeys ?? [];
143
+ const round = rekeyRound;
144
+ rekeysQueued += rekeys.length;
145
+ // A refused re-key leaves its chat split, never the others or the batch's history unsaved.
91
146
  const saving = previous.then(async () => {
92
- for (const { from, to } of batch.rekeys ?? [])
93
- await attempt("history.rekey", async (opened) => {
94
- await opened.chats.rekeyChat(account, from, to);
95
- await rekeyed?.(from, to);
96
- });
147
+ await saveRekeys(rekeys, round);
97
148
  await attempt(operation, (opened) => fill(opened, account, batch));
98
149
  });
99
150
  previous = saving;
100
151
  pending?.add(saving);
101
- void saving.finally(() => pending?.delete(saving));
152
+ if (onlyRekeys(batch))
153
+ rekeysOnly.add(saving);
154
+ void saving.finally(() => {
155
+ pending?.delete(saving);
156
+ rekeysOnly.delete(saving);
157
+ });
102
158
  onBatch(batch);
103
159
  };
104
160
  };
@@ -436,6 +492,7 @@ const titled = (event, titles) => {
436
492
  const title = titles.get(event.chatId);
437
493
  return title ? { ...event, chatTitle: title } : event;
438
494
  };
495
+ const onlyRekeys = (batch) => Object.entries(batch).every(([part, items]) => (part === "rekeys") === (items?.length ?? 0) > 0);
439
496
  /**
440
497
  * A pushed batch as the store keeps it. No `markChatsLeft`: a push names some chats, never all of them.
441
498
  * No `seenAt`: a pushed snapshot may be older than a deletion this store already holds. A chat's votes and stars go
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { formatLocator, parseLocator } from "../domain/locator.js";
3
+ import { formatMeetingReference, parseMeetingReference } from "../meetings/index.js";
3
4
  import { contextEvidenceRequest, contextSource, } from "./evidence.js";
4
5
  export const contextSourceKey = (source) => JSON.stringify([source.kind, source.accountId, source.provider, source.account]);
5
6
  export const validateContextEvidencePage = (page, request) => {
@@ -49,9 +50,9 @@ export const validateContextEvidencePage = (page, request) => {
49
50
  fail();
50
51
  if (item.revision !== null && (!Number.isSafeInteger(item.revision) || item.revision < 1))
51
52
  fail();
52
- if (!["source-modified", "stored-update", "sent"].includes(item.timeKind) ||
53
- (source.kind !== "document" && item.timeKind !== "sent") ||
54
- (source.kind === "document" && item.timeKind === "sent"))
53
+ if (source.kind === "document"
54
+ ? !["source-modified", "stored-update"].includes(item.timeKind)
55
+ : item.timeKind !== (source.kind === "meeting" ? "meeting-start" : "sent"))
55
56
  fail();
56
57
  const text = [item.text, item.title, item.url, item.provider, item.account, item.timeKind];
57
58
  if (text.some((value) => value !== null && typeof value !== "string"))
@@ -61,6 +62,15 @@ export const validateContextEvidencePage = (page, request) => {
61
62
  if (!Number.isSafeInteger(id) || id < 1 || item.ref !== `document:${id}`)
62
63
  fail();
63
64
  }
65
+ else if (source.kind === "meeting") {
66
+ const reference = parseMeetingReference(item.ref);
67
+ if (reference.accountId !== source.accountId ||
68
+ reference.transcriptId === undefined ||
69
+ reference.cuePosition === undefined ||
70
+ formatMeetingReference(reference) !== item.ref)
71
+ fail();
72
+ text.push(item.ref);
73
+ }
64
74
  else {
65
75
  const locator = parseLocator(item.ref);
66
76
  if (locator.provider !== source.provider ||
@@ -1,4 +1,4 @@
1
- export declare const CONTEXT_SOURCE_KINDS: readonly ["message", "mail", "document"];
1
+ export declare const CONTEXT_SOURCE_KINDS: readonly ["message", "mail", "document", "meeting"];
2
2
  export interface ContextSource {
3
3
  kind: (typeof CONTEXT_SOURCE_KINDS)[number];
4
4
  accountId: number;
@@ -27,7 +27,7 @@ export interface ContextEvidenceItem {
27
27
  url: string | null;
28
28
  text: string;
29
29
  at: number;
30
- timeKind: "sent" | "source-modified" | "stored-update";
30
+ timeKind: "sent" | "source-modified" | "stored-update" | "meeting-start";
31
31
  updatedAt: number;
32
32
  revision: number | null;
33
33
  }
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { CliError } from "@wirecat/cli-base";
3
- export const CONTEXT_SOURCE_KINDS = ["message", "mail", "document"];
3
+ export const CONTEXT_SOURCE_KINDS = ["message", "mail", "document", "meeting"];
4
4
  export const contextCursorScope = (request) => createHash("sha256")
5
5
  .update(JSON.stringify([
6
6
  "context-evidence-v1",
@@ -34,6 +34,8 @@ export interface WarningEvent {
34
34
  operation?: string;
35
35
  /** Field paths and types for an answer of an unexpected shape — built without the values. */
36
36
  detail?: string;
37
+ /** How many things the warning is about, per field: `{ rekeys: 199 }`. */
38
+ counts?: Record<string, number>;
37
39
  }
38
40
  /** A read answered locally, without asking the messenger, and why. */
39
41
  export interface CacheEvent {
@@ -10,7 +10,8 @@ export const providerErrorKey = (value) => typeof value === "string" && /^[A-Za-
10
10
  */
11
11
  export const renderEvent = (event) => {
12
12
  if (event.event === "warning") {
13
- return [`${MARK.warning} ${(event.operation ?? "").padEnd(16)} ${event.code}`, event.detail]
13
+ const counts = Object.entries(event.counts ?? {}).map(([field, count]) => `${count} ${field}`);
14
+ return [`${MARK.warning} ${(event.operation ?? "").padEnd(16)} ${event.code}`, ...counts, event.detail]
14
15
  .filter(Boolean)
15
16
  .join(" ");
16
17
  }
@@ -1,6 +1,6 @@
1
1
  import type { ChannelTagMatch } from "../domain/channel-tags.js";
2
2
  import type { Chat, ChatKind, ChatUpdate, Id, Page } from "../domain/models.js";
3
- import { type ChatRekey } from "./sqlite/chat-rekey.js";
3
+ import { type ChatRekey, type ChatRekeys } from "./sqlite/chat-rekey.js";
4
4
  import type { ChatMetadata, LeftChat } from "./sqlite/chats.js";
5
5
  import type { ChatCompleteness } from "./sqlite/completeness.js";
6
6
  import type { StoreContext } from "./sqlite/open.js";
@@ -39,6 +39,14 @@ export interface ChatsStore {
39
39
  * `to`. Replaying it changes nothing.
40
40
  */
41
41
  rekeyChat(key: AccountKey, from: Id, to: Id): Promise<ChatRekey>;
42
+ /**
43
+ * `rekeyChat` for a run of pairs, in order, in one transaction that ends once it has held the write lock for
44
+ * `lockMs`: `taken` says how many it got to. A pair refused is in `refused`, and the others still apply.
45
+ */
46
+ rekeyChats(key: AccountKey, pairs: readonly {
47
+ from: Id;
48
+ to: Id;
49
+ }[], lockMs: number): Promise<ChatRekeys>;
42
50
  /**
43
51
  * The chats marked left and the messages they hold, each named in `left`; with `clear`, deletes them and all under
44
52
  * them — `left` is then what was deleted.
@@ -1,6 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { keysOver } from "./keys.js";
3
- import { rekeyChat } from "./sqlite/chat-rekey.js";
3
+ import { rekeyChat, rekeyChats } from "./sqlite/chat-rekey.js";
4
4
  import * as chatQueries from "./sqlite/chats.js";
5
5
  import * as completeness from "./sqlite/completeness.js";
6
6
  import * as reads from "./sqlite/reads.js";
@@ -49,6 +49,14 @@ export const chatsStoreOver = (context) => {
49
49
  return { outcome: "unchanged", messages: 0, duplicates: 0 };
50
50
  return write(database, () => rekeyChat(context, key, accountKey, from, to));
51
51
  },
52
+ rekeyChats: async (key, pairs, lockMs) => {
53
+ const accountKey = findAccountPk(key);
54
+ if (accountKey === undefined) {
55
+ const rekey = { outcome: "unchanged", messages: 0, duplicates: 0 };
56
+ return { taken: pairs.length, applied: pairs.map((pair) => ({ ...pair, rekey })), refused: [] };
57
+ }
58
+ return write(database, () => rekeyChats(context, key, accountKey, pairs, lockMs));
59
+ },
52
60
  leftChats: async (key, { clear = false } = {}) => {
53
61
  const accountKey = findAccountPk(key);
54
62
  if (accountKey === undefined)
@@ -16,7 +16,7 @@ export { accountGroupsOf } from "./sqlite/account-groups.js";
16
16
  export type { AdminStoreRequest, AdminStoreResult } from "./sqlite/admin-statistics.js";
17
17
  export { type AttachmentTextEntry, type AttachmentView, attachmentColumns, type FileAttachment, resetAttachmentWords, type TextOrigin, } from "./sqlite/attachment-texts.js";
18
18
  export { pendingNormalization } from "./sqlite/backfill.js";
19
- export type { ChatRekey } from "./sqlite/chat-rekey.js";
19
+ export type { ChatRekey, ChatRekeys } from "./sqlite/chat-rekey.js";
20
20
  export { CHAT_COLUMNS, titlePart } from "./sqlite/chats.js";
21
21
  export { CHAT_LIST_KEY, type ChatCompleteness, fetchedKey, historyStartKey } from "./sqlite/completeness.js";
22
22
  export { CONVERSATION_COLUMNS } from "./sqlite/conversations.js";
@@ -8,8 +8,30 @@ export interface ChatRekey {
8
8
  /** Messages both chats held under one id; the kept chat's copy stays. */
9
9
  duplicates: number;
10
10
  }
11
+ export interface ChatRekeys {
12
+ /** How many of the pairs this pass got to, from the first: the rest wait for another pass. */
13
+ taken: number;
14
+ applied: {
15
+ from: Id;
16
+ to: Id;
17
+ rekey: ChatRekey;
18
+ }[];
19
+ refused: {
20
+ from: Id;
21
+ to: Id;
22
+ error: unknown;
23
+ }[];
24
+ }
11
25
  /**
12
26
  * A chat the messenger now names by another id: renamed in place, or merged into the row already stored under the
13
27
  * new id's twin. Replaying it, or naming a chat the store does not hold, changes nothing. Runs inside `write`.
14
28
  */
15
29
  export declare const rekeyChat: (context: StoreContext, key: AccountKey, accountKey: number, from: Id, to: Id) => ChatRekey;
30
+ /**
31
+ * Re-keys in order, each in its own savepoint so a refused one leaves the others applied, and stops once `lockMs` has
32
+ * passed: the caller lets the other processes at the write lock before the next pass. Runs inside `write`.
33
+ */
34
+ export declare const rekeyChats: (context: StoreContext, key: AccountKey, accountKey: number, pairs: readonly {
35
+ from: Id;
36
+ to: Id;
37
+ }[], lockMs: number) => ChatRekeys;
@@ -4,6 +4,7 @@ import { purgeChats, stricterPrivacy } from "./chats.js";
4
4
  import { pointerPairs } from "./entity-types.js";
5
5
  import { tombstone } from "./messages.js";
6
6
  import { targetKey } from "./notes.js";
7
+ import { write } from "./transaction.js";
7
8
  const chatRow = (context, accountKey, chatId) => context.database.prepare("SELECT * FROM chats WHERE account_id = ? AND external_id = ?").get(accountKey, chatId);
8
9
  /**
9
10
  * Re-points every `<x>_type = type` pointer from one row to another. A pointer the kept row already has (a unique
@@ -185,3 +186,27 @@ export const rekeyChat = (context, key, accountKey, from, to) => {
185
186
  renameText(context, key, accountKey, from, to);
186
187
  return { outcome: twin ? "merged" : "renamed", ...moved };
187
188
  };
189
+ /**
190
+ * Re-keys in order, each in its own savepoint so a refused one leaves the others applied, and stops once `lockMs` has
191
+ * passed: the caller lets the other processes at the write lock before the next pass. Runs inside `write`.
192
+ */
193
+ export const rekeyChats = (context, key, accountKey, pairs, lockMs) => {
194
+ const done = { taken: 0, applied: [], refused: [] };
195
+ const started = performance.now();
196
+ for (const { from, to } of pairs) {
197
+ if (done.taken > 0 && performance.now() - started >= lockMs)
198
+ break;
199
+ done.taken += 1;
200
+ try {
201
+ done.applied.push({
202
+ from,
203
+ to,
204
+ rekey: write(context.database, () => rekeyChat(context, key, accountKey, from, to)),
205
+ });
206
+ }
207
+ catch (error) {
208
+ done.refused.push({ from, to, error });
209
+ }
210
+ }
211
+ return done;
212
+ };
@@ -3,6 +3,7 @@ import { contextCursorScope, contextEvidenceRequest, } from "../../context/evide
3
3
  import { formatLocator } from "../../domain/locator.js";
4
4
  import { NORMALIZER_VERSION, normalize } from "../normalize.js";
5
5
  import { contextDocumentTime, contextDocumentTimeKind, contextDocumentUrl } from "./context-document-source.js";
6
+ import { readMeetingContextEvidence } from "./context-meeting-source.js";
6
7
  import { drainEmailIndex } from "./email-index.js";
7
8
  import { drainNoteIndex } from "./note-index.js";
8
9
  import { read } from "./transaction.js";
@@ -52,10 +53,12 @@ export const contextEvidenceOver = ({ database }) => ({
52
53
  async read(original) {
53
54
  const request = contextEvidenceRequest(original);
54
55
  const { source, words, after, before, limit, maxReadBytes, cursor, signal } = request;
56
+ if (source.kind === "meeting")
57
+ return readMeetingContextEvidence(database, request);
58
+ const corpus = corpora[source.kind];
55
59
  const scope = contextCursorScope({ source, query: request.query, after, before });
56
60
  signal?.throwIfAborted();
57
61
  return read(database, () => {
58
- const corpus = corpora[source.kind];
59
62
  const coverage = {
60
63
  available: false,
61
64
  index: "unknown",
@@ -0,0 +1,6 @@
1
+ import { type ContextEvidencePage, type contextEvidenceRequest } from "../../context/evidence.js";
2
+ import type { CacheDatabase } from "../driver.js";
3
+ type Request = ReturnType<typeof contextEvidenceRequest>;
4
+ /** Transcript cues keep their retained revision reference; a meeting title alone is not a transcript match. */
5
+ export declare const readMeetingContextEvidence: (database: CacheDatabase, request: Request) => ContextEvidencePage;
6
+ export {};
@@ -0,0 +1,97 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { contextCursorScope } from "../../context/evidence.js";
3
+ import { normalize } from "../normalize.js";
4
+ import { read } from "./transaction.js";
5
+ /** Transcript cues keep their retained revision reference; a meeting title alone is not a transcript match. */
6
+ export const readMeetingContextEvidence = (database, request) => {
7
+ const { source, words, after, before, limit, maxReadBytes, cursor, signal } = request;
8
+ signal?.throwIfAborted();
9
+ return read(database, () => {
10
+ const coverage = {
11
+ available: false,
12
+ index: "unknown",
13
+ hasMore: false,
14
+ hasUndated: false,
15
+ completeness: "unknown",
16
+ matching: "literal-words",
17
+ consistency: "snapshot-per-page",
18
+ };
19
+ const empty = () => ({ source, items: [], next: null, readBytes: 0, coverage });
20
+ const scope = [source.accountId, source.provider, source.account];
21
+ if (!database.prepare("SELECT 1 FROM accounts WHERE id=? AND provider=? AND external_id=?").get(...scope))
22
+ return empty();
23
+ coverage.available = true;
24
+ const base = `FROM meeting_transcript_rows r
25
+ JOIN meeting_transcripts t ON t.id=r.meeting_transcript_id
26
+ JOIN meetings m ON m.id=t.meeting_id
27
+ JOIN accounts a ON a.id=m.account_id
28
+ WHERE m.account_id=? AND a.provider=? AND a.external_id=?
29
+ AND m.deleted_at IS NULL AND t.deleted_at IS NULL AND t.superseded_at IS NULL`;
30
+ coverage.hasUndated = Boolean(database.prepare(`SELECT 1 ${base} AND m.started_at IS NULL LIMIT 1`).get(...scope));
31
+ if (!database.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='meeting_words'").get()) {
32
+ coverage.index = "missing";
33
+ return empty();
34
+ }
35
+ coverage.index = database
36
+ .prepare(`SELECT 1 ${base} AND m.started_at>=? AND m.started_at<?
37
+ AND EXISTS(SELECT 1 FROM meeting_index_pending p
38
+ WHERE p.indexable_type='meeting_transcript_row' AND p.id=r.id) LIMIT 1`)
39
+ .get(...scope, after, before)
40
+ ? "pending"
41
+ : "ready";
42
+ const reference = "'meeting:'||m.account_id||'/'||m.id||'/'||t.id||'/'||r.position";
43
+ const selection = `SELECT r.id,m.title,r.text,a.provider,a.external_id AS account,
44
+ m.started_at AS at,max(m.updated_at,t.updated_at,r.created_at) AS updated_at,
45
+ ${reference} AS ref ${base} AND m.started_at>=? AND m.started_at<?
46
+ AND (r.id*4+1) IN (SELECT rowid FROM meeting_words WHERE meeting_words MATCH ?)
47
+ AND NOT EXISTS(SELECT 1 FROM meeting_index_pending p
48
+ WHERE p.indexable_type='meeting_transcript_row' AND p.id=r.id)
49
+ ${cursor ? "AND (m.started_at<? OR (m.started_at=? AND r.id<?))" : ""}
50
+ ORDER BY m.started_at DESC,r.id DESC LIMIT ?`;
51
+ const params = [
52
+ ...scope,
53
+ after,
54
+ before,
55
+ words.map((word) => `normalized_text:"${normalize(word)}"`).join(" AND "),
56
+ ];
57
+ if (cursor)
58
+ params.push(cursor.at, cursor.at, cursor.id);
59
+ params.push(limit + 1);
60
+ const probe = database
61
+ .prepare(`SELECT coalesce(sum(length(CAST(coalesce(title,'') AS BLOB))+
62
+ length(CAST(text AS BLOB))+length(CAST(provider AS BLOB))+
63
+ length(CAST(account AS BLOB))+length(CAST(ref AS BLOB))+length('meeting-start')),0) AS bytes FROM (${selection})`)
64
+ .get(...params);
65
+ const readBytes = Number(probe?.bytes);
66
+ if (!Number.isSafeInteger(readBytes) || readBytes < 0 || readBytes > maxReadBytes)
67
+ throw new CliError("validation_error", "meeting context evidence exceeds maxReadBytes before hydration", {
68
+ reason: "context.read-budget",
69
+ });
70
+ signal?.throwIfAborted();
71
+ const rows = database.prepare(selection).all(...params);
72
+ signal?.throwIfAborted();
73
+ coverage.hasMore = rows.length > limit;
74
+ const selected = rows.slice(0, limit);
75
+ const last = selected.at(-1);
76
+ return {
77
+ source,
78
+ readBytes,
79
+ coverage,
80
+ next: coverage.hasMore && last
81
+ ? { at: Number(last.at), id: Number(last.id), scope: contextCursorScope(request) }
82
+ : null,
83
+ items: selected.map((row) => ({
84
+ ref: String(row.ref),
85
+ provider: String(row.provider),
86
+ account: String(row.account),
87
+ title: row.title === null ? null : String(row.title),
88
+ url: null,
89
+ text: String(row.text),
90
+ at: Number(row.at),
91
+ timeKind: "meeting-start",
92
+ updatedAt: Number(row.updated_at),
93
+ revision: null,
94
+ })),
95
+ };
96
+ });
97
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.357.0",
3
+ "version": "0.359.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",