@wirecat/cli-core 0.356.0 → 0.358.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
@@ -5,7 +5,24 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
- ## Unreleased
8
+ ## 0.358.0 — 19.10.2026
9
+
10
+ ### Added
11
+
12
+ - **Meeting transcripts in bounded context reads:** `ContextSource.kind` accepts `meeting`; the native
13
+ reader returns current transcript cue references with `timeKind: "meeting-start"`. Account/time scope,
14
+ cursor validation, read-only snapshots and pre-hydration byte limits apply. Deleted/superseded and
15
+ queued cues are excluded; missing indexes and unknown archive coverage remain explicit. Hosts must
16
+ opt in to meeting sources; the reader does not fetch recordings, fill indexes or infer project links.
17
+
18
+ ## 0.357.0 — 19.10.2026
19
+
20
+ ### Added
21
+
22
+ - **An adapter can say "member again" without clearing other states.** `ChatUpdate.membershipState` (in `saveChats`
23
+ and a pushed `HistoryBatch.chats`) now also takes `"member"`: it takes off a "left" mark, as `rejoinChats` does,
24
+ and keeps any other stored state, such as "archived"; a new chat stores none. It is never stored or read back.
25
+ `null` still clears every state. Send `"member"` when the owner is added back to a group. Additive.
9
26
 
10
27
  ## 0.356.0 — 19.10.2026
11
28
 
@@ -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;
@@ -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",
@@ -49,10 +49,11 @@ export interface Chat {
49
49
  /**
50
50
  * A chat as an adapter saves it. A `title`, `unreadCount`, `lastMessageAt`, `participantsCount` or `membershipState`
51
51
  * left out keeps the stored value; `null` stores none. So a push that knows only some of them, such as a notice that
52
- * a group left its community, leaves the rest out.
52
+ * a group left its community, leaves the rest out. `membershipState: "member"` says the owner is in the chat again: it
53
+ * takes off a "left" mark and keeps any other state, such as "archived". It is never stored.
53
54
  */
54
55
  export type ChatUpdate = Omit<Chat, KeptFields | "membershipState"> & Partial<Pick<Chat, KeptFields>> & {
55
- membershipState?: MembershipState | null;
56
+ membershipState?: MembershipState | "member" | null;
56
57
  };
57
58
  type KeptFields = "title" | "unreadCount" | "lastMessageAt" | "participantsCount";
58
59
  /** A group's switches; `null` where the messenger does not say, or has no such switch. */
@@ -104,7 +104,7 @@ export const upsertChat = (context, accountKey, chat) => {
104
104
  lastMessageAt: toMs(chat.lastMessageAt),
105
105
  participantsCount: chat.participantsCount ?? null,
106
106
  metadata: mergedMetadata(stored?.metadata ?? null, chat.providerMetadata),
107
- membershipState: chat.membershipState ?? null,
107
+ membershipState: chat.membershipState === "member" ? null : (chat.membershipState ?? null),
108
108
  createdAt: now(),
109
109
  updatedAt: now(),
110
110
  })
@@ -119,12 +119,19 @@ export const upsertChat = (context, accountKey, chat) => {
119
119
  ...(chat.lastMessageAt === undefined ? {} : { lastMessageAt: sql `excluded.last_message_at` }),
120
120
  ...(chat.participantsCount === undefined ? {} : { participantsCount: sql `excluded.participants_count` }),
121
121
  metadata: sql `excluded.metadata`,
122
- ...(chat.membershipState === undefined ? {} : { membershipState: sql `excluded.membership_state` }),
122
+ ...membershipSet(chat.membershipState),
123
123
  updatedAt: sql `excluded.updated_at`,
124
124
  },
125
125
  })
126
126
  .run();
127
127
  };
128
+ const membershipSet = (state) => {
129
+ if (state === undefined)
130
+ return {};
131
+ if (state === "member")
132
+ return { membershipState: sql `nullif(${chats.membershipState}, 'left')` };
133
+ return { membershipState: sql `excluded.membership_state` };
134
+ };
128
135
  const mergedMetadata = (stored, incoming) => {
129
136
  if (incoming === undefined)
130
137
  return stored;
@@ -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.356.0",
3
+ "version": "0.358.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",