@wirecat/cli-core 0.351.0 → 0.353.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,61 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.353.0 — 19.10.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **`ChatsStore.leftChats` also names each chat** in a new `left` list (`id`, `title`, `messages`), the ones holding
15
+ most messages first; with `clear` it is what was deleted. A test that compares its whole answer, such as max-cli's
16
+ `src/record.test.ts`, adds `left`.
17
+
18
+ ### Added
19
+
20
+ - **`store clear --left` shows which chats it deletes**: the refusal without `--allow-dangerous` lists the first 20
21
+ by id, title and message count, and says how many more; the new `--dry-run` answers every one in `left` and
22
+ deletes nothing, also under `<PREFIX>_READ_ONLY`; the answer after deleting lists what was deleted. The titles go
23
+ only to the terminal or the JSON answer — the refusal's message, which a scheduled run's `last.json` keeps, holds
24
+ only the counts.
25
+
26
+ ### Fixed
27
+
28
+ - **A re-keyed chat keeps the stricter of its two halves' privacy settings** (security). When `rekeyChat` merges a
29
+ phone-form chat into its LID twin, the merged chat is hidden from search if either half was, and is personal if the
30
+ halves sit on different sides (`personal` and `work`, after the account's default). A scope either half pinned
31
+ stays pinned on the merged chat, so a later change to the account's default cannot loosen it. Before, the old row's
32
+ `searchable` and `scope` always won, so a chat the owner had hidden became searchable again when a stray
33
+ phone-form row merged into it; wa reaches this at every `wa serve` start. No released code sets either column, so
34
+ only a store edited by hand could have been affected.
35
+ - **A chat hidden from search stays out of every search that does not name it** (security). A chat with
36
+ `searchable = 0` was already left out of `search messages`, `search all`, rankings and statistics, but still came
37
+ back from `store.messages.search` and `find` (the plain text and sender search, also behind `bot people`, person
38
+ context and botcheck), `mentioning`, and the meaning half of `conversations search`, `conversations related` and
39
+ the picture search; the search answers also listed it in their readiness and "embedded only with another model"
40
+ lists. Each now leaves it out unless the call names the chat, as the Lucene search does. A sender-only `find`
41
+ therefore no longer counts the hidden chat's messages. No released code sets `searchable = 0`, so only a store
42
+ edited by hand was affected.
43
+
44
+ ## 0.352.0 — 19.10.2026
45
+
46
+ ### Changed — may break callers
47
+
48
+ - **A saved chat keeps its membership when the update leaves it out**: `saveChats`, `Delta.chats` and
49
+ `HistoryBatch.chats` no longer take a "left" mark off a chat that comes without `membershipState`. So a late
50
+ community notice for a group the owner left no longer marks it rejoined. A value sets the membership; `null`
51
+ (newly allowed on `ChatUpdate`) clears it. A page of the adapter's `chats`, complete or not, still rejoins the
52
+ chats it names, through `markChatsLeft` (now also takes the mark off the chats its complete list names) or the new
53
+ `rejoinChats`; other states such as "archived" are kept. A client that saves its own chat list must call
54
+ `rejoinChats(key, ids)` for a list that is not complete, or a chat the owner rejoined stays hidden from stored
55
+ reads until the next complete one. **max-cli must do this in the bump that takes this version**: in
56
+ `rememberChats` (`src/record.ts`), `else await db.chats.rejoinChats(key, ids)`; without it a chat rejoined while
57
+ the session list is cut stays "left", is hidden from `max chats list`, and `archive clear --left` deletes it.
58
+ A caller that learns of a rejoin any other way sends `membershipState: null`.
59
+
60
+ ### Added
61
+
62
+ - **`ChatsStore.rejoinChats(key, ids)`** takes the "left" mark off the chats a messenger's chat list names, from any
63
+ page of it, and says how many.
64
+
10
65
  ## 0.351.0 — 19.10.2026
11
66
 
12
67
  ### Added
@@ -1,7 +1,7 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { Readable } from "node:stream";
4
- import { CliError } from "@wirecat/cli-base";
4
+ import { CliError, singleLine } from "@wirecat/cli-base";
5
5
  import { annotate } from "@wirecat/cli-base/commands";
6
6
  import { Command } from "commander";
7
7
  import { toMarkdown } from "../../render/markdown.js";
@@ -46,29 +46,52 @@ const statusCommand = (messenger) => annotate(new Command("status"), { effects:
46
46
  if (rows.length === 0 && context.format === "pretty")
47
47
  context.renderer.note("the store holds no messages for this profile yet");
48
48
  });
49
+ const LISTED = 20;
50
+ /** Titles are the owner's: these lines go to a person's terminal, never into an error message. */
51
+ const leftLines = (left) => {
52
+ const shown = left.slice(0, LISTED);
53
+ const width = shown.reduce((widest, { id }) => Math.max(widest, id.length), 0);
54
+ return [
55
+ ...shown.map(({ id, title, messages }) => ` ${id.padEnd(width)} ${title === null ? "(no title)" : singleLine(title)} ${messages}`),
56
+ ...(left.length > shown.length
57
+ ? [` and ${left.length - shown.length} more — --dry-run --json lists every one`]
58
+ : []),
59
+ ];
60
+ };
49
61
  /**
50
62
  * Deletes from the store, which is the archive: only what `--left` names, and only with
51
63
  * `--allow-dangerous`, since a chat the account left cannot be fetched again. Emptying the whole
52
- * store is not offered.
64
+ * store is not offered. The refusal names the chats to a person but keeps them out of its message,
65
+ * which a scheduled run's `last.json` keeps.
53
66
  */
54
67
  const clearCommand = (messenger) => annotate(new Command("clear"), { effects: LOCAL_WRITE, mcp: false })
55
68
  .description("delete from the store the chats this account has left, with their messages")
56
69
  .option("--left", "the chats this account has left — the only thing this clears")
57
70
  .option("--allow-dangerous", "yes, delete — it cannot be undone, and a chat you left cannot be fetched again")
71
+ .option("--dry-run", "list the chats it would delete, and delete nothing")
58
72
  .action(async function () {
59
- const { left, allowDangerous } = this.opts();
73
+ const { left, allowDangerous, dryRun } = this.opts();
60
74
  if (!left) {
61
75
  throw new CliError("validation_error", "say what to clear: --left, the chats this account has left — emptying the whole store is not offered");
62
76
  }
63
77
  const context = messengerContext(this, messenger);
64
78
  const found = await context.withServices((services) => services.archive.left());
79
+ const pretty = context.format === "pretty";
80
+ if (dryRun) {
81
+ renderAnswer(this, context, itemOf(snakeKeys({ cleared: false, dryRun: true, ...found })));
82
+ return;
83
+ }
65
84
  if (found.chats > 0 && !allowDangerous) {
85
+ // Not a note: --quiet drops notes, and this list is what the owner confirms.
86
+ if (pretty)
87
+ for (const line of ["it would delete these chats — id, title, messages:", ...leftLines(found.left)])
88
+ context.streams.diagnostic(line);
66
89
  throw new CliError("confirmation_required", `this deletes ${found.chats} chat(s) this account has left and their ${found.messages} message(s) ` +
67
- "from the store, and it cannot be undone — add --allow-dangerous to go ahead");
90
+ "from the store, and it cannot be undone — --dry-run lists them; add --allow-dangerous to go ahead");
68
91
  }
69
92
  const cleared = found.chats > 0 ? await context.withServices((services) => services.archive.left({ clear: true })) : found;
70
93
  renderAnswer(this, context, itemOf(snakeKeys({ cleared: cleared.chats > 0, ...cleared })));
71
- if (context.format !== "pretty")
94
+ if (!pretty)
72
95
  return;
73
96
  if (cleared.chats === 0)
74
97
  context.renderer.note("the store holds no chats this account has left");
@@ -186,8 +186,11 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
186
186
  const complete = window.offset === 0 && !page.hasMore && page.items.length > 0;
187
187
  await save("chats.list", async (opened) => {
188
188
  await opened.chats.saveChats(account, page.items);
189
+ const ids = page.items.map((chat) => chat.id);
189
190
  if (complete)
190
- await opened.chats.markChatsLeft(account, page.items.map((chat) => chat.id));
191
+ await opened.chats.markChatsLeft(account, ids);
192
+ else
193
+ await opened.chats.rejoinChats(account, ids);
191
194
  });
192
195
  return page;
193
196
  },
@@ -47,11 +47,13 @@ export interface Chat {
47
47
  providerMetadata?: ProviderMetadata;
48
48
  }
49
49
  /**
50
- * A chat as an adapter saves it. A `title`, `unreadCount`, `lastMessageAt` or `participantsCount` left out keeps the
51
- * stored value; `null` stores none. So a push that knows only some of them, such as a notice that a group left its
52
- * community, leaves the rest out.
50
+ * A chat as an adapter saves it. A `title`, `unreadCount`, `lastMessageAt`, `participantsCount` or `membershipState`
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.
53
53
  */
54
- export type ChatUpdate = Omit<Chat, KeptFields> & Partial<Pick<Chat, KeptFields>>;
54
+ export type ChatUpdate = Omit<Chat, KeptFields | "membershipState"> & Partial<Pick<Chat, KeptFields>> & {
55
+ membershipState?: MembershipState | null;
56
+ };
55
57
  type KeptFields = "title" | "unreadCount" | "lastMessageAt" | "participantsCount";
56
58
  /** A group's switches; `null` where the messenger does not say, or has no such switch. */
57
59
  export interface GroupSettings {
@@ -1,6 +1,7 @@
1
1
  import type { ChatKind, Id, Message } from "../domain/models.js";
2
2
  import type { ActionableError } from "../domain/recovery.js";
3
3
  import type { Fetching } from "../ports/traits.js";
4
+ import type { LeftChats } from "../store/chats.js";
4
5
  import { type AccountKey, type ChatStats, type MessageStore, type Range } from "../store/store.js";
5
6
  import { type Estimate } from "./backfill-estimate.js";
6
7
  import { batchProgress } from "./batch.js";
@@ -135,10 +136,7 @@ export interface ArchiveService {
135
136
  /** The chats this account has left, with their messages; `clear` deletes them. From the store alone. */
136
137
  left(options?: {
137
138
  clear?: boolean;
138
- }): Promise<{
139
- chats: number;
140
- messages: number;
141
- }>;
139
+ }): Promise<LeftChats>;
142
140
  }
143
141
  export declare const archiveService: (deps: ServiceDeps) => ArchiveService;
144
142
  /** One page of a chat's history, newest first, older than `before` when it is given. */
@@ -36,7 +36,7 @@ export declare const embeddingTarget: (deps: ServiceDeps, choice?: ModelChoice)
36
36
  apart?: boolean;
37
37
  }) => Promise<Embedder>;
38
38
  };
39
- export declare const readinessOf: (deps: ServiceDeps, chatId: Id | undefined, key: string) => Promise<ChatReadiness[]>;
39
+ export declare const readinessOf: (deps: ServiceDeps, chatId: Id | undefined, key: string, searchableOnly?: boolean) => Promise<ChatReadiness[]>;
40
40
  type EmbeddingTarget = ReturnType<typeof embeddingTarget>;
41
41
  /** The server's warm model when there is one, kept open after; otherwise opened here and closed. */
42
42
  export declare const withEmbedder: <T>(deps: ServiceDeps, target: EmbeddingTarget, choice: ModelChoice | undefined, { threads }: {
@@ -57,10 +57,10 @@ export const embeddingTarget = (deps, choice = DEFAULT_TEXT_MODEL) => {
57
57
  },
58
58
  };
59
59
  };
60
- export const readinessOf = async (deps, chatId, key) => {
60
+ export const readinessOf = async (deps, chatId, key, searchableOnly = false) => {
61
61
  const store = await deps.store();
62
62
  const account = await deps.account();
63
- return (await store.vectors.readiness(account, { ...(chatId === undefined ? {} : { chatId }), model: key })).map(shaped);
63
+ return (await store.vectors.readiness(account, { ...(chatId === undefined ? {} : { chatId }), model: key, searchableOnly })).map(shaped);
64
64
  };
65
65
  /** The server's warm model when there is one, kept open after; otherwise opened here and closed. */
66
66
  export const withEmbedder = async (deps, target, choice, { threads }, work) => {
@@ -191,7 +191,7 @@ export const embeddingsSearch = (deps) => {
191
191
  .filter(({ account }) => account.provider === one.provider && account.account === one.account)
192
192
  .map(({ chat }) => chat);
193
193
  const rows = eligibleChats === undefined
194
- ? await store.vectors.readiness(one, { ...scope, model: target.key })
194
+ ? await store.vectors.readiness(one, { ...scope, model: target.key, searchableOnly: true })
195
195
  : (await Promise.all(eligibleChats.map((chatId) => store.vectors.readiness(one, { chatId, model: target.key })))).flat();
196
196
  const readiness = searchReadiness(rows.map(shaped), target.installed, words.outside.get(JSON.stringify(one))?.chats ?? new Set());
197
197
  scoped.push({ source: one, readiness });
@@ -261,7 +261,7 @@ export const embeddingsSearch = (deps) => {
261
261
  hits: hits
262
262
  .slice(0, limit)
263
263
  .map(({ summary, chunk, score, stale }) => ({ summary, chunk, score, by: ["meaning"], stale })),
264
- readiness: searchReadiness(await readinessOf(deps, undefined, target.key), true, new Set()),
264
+ readiness: searchReadiness(await readinessOf(deps, undefined, target.key, true), true, new Set()),
265
265
  };
266
266
  },
267
267
  };
@@ -1,11 +1,18 @@
1
1
  import type { ChannelTagMatch } from "../domain/channel-tags.js";
2
2
  import type { Chat, ChatKind, ChatUpdate, Id, Page } from "../domain/models.js";
3
3
  import { type ChatRekey } from "./sqlite/chat-rekey.js";
4
- import type { ChatMetadata } from "./sqlite/chats.js";
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";
7
7
  import type { Where } from "./sqlite/query-filter.js";
8
8
  import type { AccountKey, ChatActivity, ChatStats, StoredChatFilter } from "./types.js";
9
+ export type { LeftChat };
10
+ /** The chats marked left: how many, the messages they hold, and each one. */
11
+ export interface LeftChats {
12
+ chats: number;
13
+ messages: number;
14
+ left: LeftChat[];
15
+ }
9
16
  export interface ChatsStore {
10
17
  saveChats(key: AccountKey, chats: ChatUpdate[]): Promise<void>;
11
18
  /** Newest first. `query` matches three letters or more of a title; `unread` keeps chats with unread messages. */
@@ -17,23 +24,28 @@ export interface ChatsStore {
17
24
  /** The chats a person is in, newest first — as far as the saved member lists go. */
18
25
  chatsWith(key: AccountKey, memberId: Id): Promise<Chat[]>;
19
26
  /**
20
- * Given every chat the account is in, marks the rest `left` and says how many it marked. Only
21
- * ever from a complete list: a page or a cut list says nothing about the chats it leaves out.
27
+ * Given every chat the account is in, marks the rest `left`, takes the mark off those it names, and says how many
28
+ * it marked. Only ever from a complete list: a page or a cut list says nothing about the chats it leaves out.
22
29
  * Remembers when, so a search can say the store knows the account's whole chat list.
23
30
  */
24
31
  markChatsLeft(key: AccountKey, present: Id[]): Promise<number>;
32
+ /**
33
+ * Takes the `left` mark off the chats a messenger's own chat list names, from any page of it: the list holds only
34
+ * chats the account is in. Other states are kept. Says how many it took the mark off.
35
+ */
36
+ rejoinChats(key: AccountKey, present: Id[]): Promise<number>;
25
37
  /**
26
38
  * The chat the messenger now names `to` instead of `from`: renamed, or merged into the chat already stored as
27
39
  * `to`. Replaying it changes nothing.
28
40
  */
29
41
  rekeyChat(key: AccountKey, from: Id, to: Id): Promise<ChatRekey>;
30
- /** The chats marked left and the messages they hold; with `clear`, deletes them and all under them. */
42
+ /**
43
+ * The chats marked left and the messages they hold, each named in `left`; with `clear`, deletes them and all under
44
+ * them — `left` is then what was deleted.
45
+ */
31
46
  leftChats(key: AccountKey, options?: {
32
47
  clear?: boolean;
33
- }): Promise<{
34
- chats: number;
35
- messages: number;
36
- }>;
48
+ }): Promise<LeftChats>;
37
49
  /** Per chat: whether its history is held up to date, without gaps, back to its start. */
38
50
  chatCompleteness(key: AccountKey, chatIds: Id[]): Promise<ChatCompleteness[]>;
39
51
  /** Per chat that has messages: how many, the oldest and newest, and when the last one was stored. */
@@ -39,6 +39,10 @@ export const chatsStoreOver = (context) => {
39
39
  });
40
40
  return marked;
41
41
  },
42
+ rejoinChats: async (key, present) => {
43
+ const accountKey = findAccountPk(key);
44
+ return accountKey === undefined ? 0 : write(database, () => chatQueries.rejoin(context, accountKey, present));
45
+ },
42
46
  rekeyChat: async (key, from, to) => {
43
47
  const accountKey = findAccountPk(key);
44
48
  if (accountKey === undefined)
@@ -48,14 +52,14 @@ export const chatsStoreOver = (context) => {
48
52
  leftChats: async (key, { clear = false } = {}) => {
49
53
  const accountKey = findAccountPk(key);
50
54
  if (accountKey === undefined)
51
- return { chats: 0, messages: 0 };
52
- let found = { pks: [], messages: 0 };
55
+ return { chats: 0, messages: 0, left: [] };
56
+ let found = { pks: [], chats: [], messages: 0 };
53
57
  write(database, () => {
54
58
  found = chatQueries.leftChats(context, accountKey);
55
59
  if (clear)
56
60
  chatQueries.purgeChats(context, found.pks);
57
61
  });
58
- return { chats: found.pks.length, messages: found.messages };
62
+ return { chats: found.pks.length, messages: found.messages, left: found.chats };
59
63
  },
60
64
  chatCompleteness: async (key, chatIds) => {
61
65
  const accountKey = findAccountPk(key);
@@ -2,7 +2,7 @@ export type { CounterField, CounterObservations, CounterState } from "../domain/
2
2
  export type { RetentionOptions } from "../domain/retention.js";
3
3
  export type { AccountAccounts, AccountsStore } from "./accounts.js";
4
4
  export type { AccountAttachments, AttachmentsStore } from "./attachments.js";
5
- export type { ChatsStore } from "./chats.js";
5
+ export type { ChatsStore, LeftChat, LeftChats } from "./chats.js";
6
6
  export type { ConversationsStore } from "./conversations.js";
7
7
  export type { AccountCounters, CountersStore } from "./counters.js";
8
8
  export type { AccountMentions, MentionsStore } from "./mentions.js";
@@ -143,6 +143,24 @@ const latest = (a, b) => {
143
143
  const [one, other] = [time(a), time(b)];
144
144
  return one === null ? other : other === null ? one : Math.max(one, other);
145
145
  };
146
+ /**
147
+ * The owner's word on a chat must not loosen because a stray twin merged in: hidden wins, and when the two chats sit
148
+ * on different sides of the owner's life, the personal side wins, since a work chat reaches work views and meeting
149
+ * preparation a personal one does not. A scope either half pinned stays pinned, so a later change to the account's
150
+ * default cannot loosen the merged chat.
151
+ */
152
+ const strictest = (context, accountKey, old, twin) => {
153
+ const account = context.database.prepare("SELECT scope FROM accounts WHERE id = ?").get(accountKey);
154
+ const effective = (row) => scalar(row.scope) ?? scalar(account?.scope);
155
+ return {
156
+ searchable: Math.min(Number(old.searchable), Number(twin.searchable)),
157
+ scope: scalar(old.scope) === null && scalar(twin.scope) === null
158
+ ? null
159
+ : effective(old) === effective(twin)
160
+ ? effective(old)
161
+ : "personal",
162
+ };
163
+ };
146
164
  const MESSENGER_FIELDS = [
147
165
  "title",
148
166
  "unread_count",
@@ -166,6 +184,7 @@ export const rekeyChat = (context, key, accountKey, from, to) => {
166
184
  if (twin) {
167
185
  if (old.kind !== twin.kind && old.kind !== "unknown" && twin.kind !== "unknown")
168
186
  throw new CliError("validation_error", `a ${String(old.kind)} and a ${String(twin.kind)} are not one chat`);
187
+ const strict = strictest(context, accountKey, old, twin);
169
188
  moved = absorb(context, kept, Number(twin.id));
170
189
  const fields = MESSENGER_FIELDS.map((field) => `${field} = coalesce(?, ${field})`).join(", ");
171
190
  context.database
@@ -173,8 +192,8 @@ export const rekeyChat = (context, key, accountKey, from, to) => {
173
192
  // Left only when both were: a complete chat list that named the new id before the re-key marked the old one.
174
193
  `UPDATE chats SET ${fields}, kind = CASE WHEN kind = 'unknown' THEN ? ELSE kind END,
175
194
  membership_state = CASE WHEN ? IS NULL THEN nullif(membership_state, 'left') ELSE coalesce(nullif(?, 'left'), membership_state) END,
176
- last_message_at = ?, updated_at = ? WHERE id = ?`)
177
- .run(...MESSENGER_FIELDS.map((field) => scalar(twin[field])), scalar(twin.kind), scalar(twin.membership_state), scalar(twin.membership_state), latest(old.last_message_at, twin.last_message_at), context.now(), kept);
195
+ searchable = ?, scope = ?, last_message_at = ?, updated_at = ? WHERE id = ?`)
196
+ .run(...MESSENGER_FIELDS.map((field) => scalar(twin[field])), scalar(twin.kind), scalar(twin.membership_state), scalar(twin.membership_state), strict.searchable, strict.scope, latest(old.last_message_at, twin.last_message_at), context.now(), kept);
178
197
  }
179
198
  context.database.prepare("UPDATE chats SET external_id = ?, updated_at = ? WHERE id = ?").run(to, context.now(), kept);
180
199
  renameText(context, key, accountKey, from, to);
@@ -11,11 +11,23 @@ export declare const upsertChat: (context: StoreContext, accountKey: number, cha
11
11
  /** Replaces who is in the chat, whole. */
12
12
  export declare const writeMembers: (context: StoreContext, key: AccountKey, accountKey: number, chatId: Id, memberIds: Id[]) => void;
13
13
  export declare const members: ({ orm }: StoreContext, chatKey: number) => Member[];
14
- /** Marks this account's chats that `present` does not name; only ever given a complete list. */
15
- export declare const markLeft: ({ orm }: StoreContext, accountKey: number, present: Id[]) => number;
16
- /** The chats marked left, and how many messages they hold. */
14
+ /** Takes the left mark off the chats `present` names; any other state is kept. Answers how many. */
15
+ export declare const rejoin: ({ orm }: StoreContext, accountKey: number, present: Id[]) => number;
16
+ /**
17
+ * Marks this account's chats that `present` does not name, and rejoins those it names. Only ever given a complete
18
+ * list. Answers how many it marked.
19
+ */
20
+ export declare const markLeft: (context: StoreContext, accountKey: number, present: Id[]) => number;
21
+ /** A chat marked left: its id, its title, and how many messages it holds. */
22
+ export interface LeftChat {
23
+ id: Id;
24
+ title: string | null;
25
+ messages: number;
26
+ }
27
+ /** The chats marked left, the ones holding the most messages first, and how many messages they hold. */
17
28
  export declare const leftChats: ({ orm }: StoreContext, accountKey: number) => {
18
29
  pks: number[];
30
+ chats: LeftChat[];
19
31
  messages: number;
20
32
  };
21
33
  /** These chats and everything under them, children before parents: the foreign keys are enforced. */
@@ -65,9 +65,7 @@ export const upsertChat = (context, accountKey, chat) => {
65
65
  ...(chat.lastMessageAt === undefined ? {} : { lastMessageAt: sql `excluded.last_message_at` }),
66
66
  ...(chat.participantsCount === undefined ? {} : { participantsCount: sql `excluded.participants_count` }),
67
67
  metadata: sql `excluded.metadata`,
68
- // A chat the list names again has been rejoined; any other state it had is kept.
69
- membershipState: sql `CASE WHEN excluded.membership_state IS NOT NULL THEN excluded.membership_state
70
- WHEN ${chats.membershipState} = 'left' THEN NULL ELSE ${chats.membershipState} END`,
68
+ ...(chat.membershipState === undefined ? {} : { membershipState: sql `excluded.membership_state` }),
71
69
  updatedAt: sql `excluded.updated_at`,
72
70
  },
73
71
  })
@@ -113,25 +111,48 @@ export const members = ({ orm }, chatKey) => orm
113
111
  .all();
114
112
  const byRecency = [sql `${chats.lastMessageAt} DESC NULLS LAST`, chats.id];
115
113
  const notLeft = sql `${chats.membershipState} IS NOT 'left'`;
116
- /** Marks this account's chats that `present` does not name; only ever given a complete list. */
117
- export const markLeft = ({ orm }, accountKey, present) => orm
114
+ const named = (present) => sql `${chats.externalId} IN (SELECT value FROM json_each(${JSON.stringify(present)}))`;
115
+ /** Takes the left mark off the chats `present` names; any other state is kept. Answers how many. */
116
+ export const rejoin = ({ orm }, accountKey, present) => orm
118
117
  .update(chats)
119
- .set({ membershipState: "left" })
120
- .where(and(eq(chats.accountId, accountKey), notLeft, sql `${chats.externalId} NOT IN (SELECT value FROM json_each(${JSON.stringify(present)}))`))
118
+ .set({ membershipState: null })
119
+ .where(and(eq(chats.accountId, accountKey), eq(chats.membershipState, "left"), named(present)))
121
120
  .returning({ pk: chats.id })
122
121
  .all().length;
123
- /** The chats marked left, and how many messages they hold. */
122
+ /**
123
+ * Marks this account's chats that `present` does not name, and rejoins those it names. Only ever given a complete
124
+ * list. Answers how many it marked.
125
+ */
126
+ export const markLeft = (context, accountKey, present) => {
127
+ rejoin(context, accountKey, present);
128
+ return context.orm
129
+ .update(chats)
130
+ .set({ membershipState: "left" })
131
+ .where(and(eq(chats.accountId, accountKey), notLeft, sql `NOT (${named(present)})`))
132
+ .returning({ pk: chats.id })
133
+ .all().length;
134
+ };
135
+ /** The chats marked left, the ones holding the most messages first, and how many messages they hold. */
124
136
  export const leftChats = ({ orm }, accountKey) => {
125
- const pks = orm
126
- .select({ pk: chats.id })
137
+ const found = orm
138
+ .select({ pk: chats.id, id: chats.externalId, title: chats.title })
127
139
  .from(chats)
128
140
  .where(and(eq(chats.accountId, accountKey), eq(chats.membershipState, "left")))
129
- .all()
130
- .map(({ pk }) => pk);
131
- const count = pks.length === 0
132
- ? 0
133
- : Number(orm.select({ n: sql `count(*)` }).from(messages).where(inArray(messages.chatId, pks)).get()?.n);
134
- return { pks, messages: count };
141
+ .all();
142
+ const pks = found.map(({ pk }) => pk);
143
+ const counts = new Map(pks.length === 0
144
+ ? []
145
+ : orm
146
+ .select({ pk: messages.chatId, n: sql `count(*)` })
147
+ .from(messages)
148
+ .where(inArray(messages.chatId, pks))
149
+ .groupBy(messages.chatId)
150
+ .all()
151
+ .map(({ pk, n }) => [pk, Number(n)]));
152
+ const left = found
153
+ .map(({ pk, id, title }) => ({ id, title, messages: counts.get(pk) ?? 0 }))
154
+ .sort((a, b) => b.messages - a.messages || a.id.localeCompare(b.id));
155
+ return { pks, chats: left, messages: left.reduce((sum, chat) => sum + chat.messages, 0) };
135
156
  };
136
157
  /** These chats and everything under them, children before parents: the foreign keys are enforced. */
137
158
  export const purgeChats = ({ orm }, pks) => {
@@ -77,7 +77,7 @@ export const nearestImages = ({ orm }, accountPk, { chatKey, model, since, limit
77
77
  JOIN embeddings v ON v.model = ${model} AND v.content_hash = k.content_hash
78
78
  WHERE k.chunkable_type = 'image' AND k.position = ${position} AND ch.account_id = ${accountPk}
79
79
  AND k.id > ${after}
80
- ${chatKey === undefined ? sql `` : sql `AND m.chat_id = ${chatKey}`}
80
+ ${chatKey === undefined ? sql `AND ch.searchable = 1` : sql `AND m.chat_id = ${chatKey}`}
81
81
  ${since === undefined ? sql `` : sql `AND k.occurred_at >= ${since}`}
82
82
  ORDER BY k.id LIMIT ${SCAN_PAGE}`);
83
83
  for (const row of rows)
@@ -1,7 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { and, eq, inArray, isNull, sql } from "./drizzle/core.js";
3
3
  import { resolvePersonLinks } from "./person-resolution.js";
4
- import { accountIdentities, accounts, identities, identityLinkEvents, identityLinks, messages, persons, } from "./schema.js";
4
+ import { accountIdentities, accounts, chats, identities, identityLinkEvents, identityLinks, messages, persons, } from "./schema.js";
5
5
  import { hitsWhere } from "./search.js";
6
6
  const identityRow = ({ orm }, { provider, id }) => orm
7
7
  .select({ pk: identities.id, personPk: identityLinks.personId })
@@ -159,7 +159,7 @@ export const unlinkIdentity = (context, ref, options) => {
159
159
  export const mentioning = (context, accountKey, { id, username }, limit) => {
160
160
  const byId = sql `EXISTS (SELECT 1 FROM json_each(${messages.mentions}) WHERE json_each.value = ${id})`;
161
161
  const byHandle = username === null ? undefined : sql `instr(lower(${messages.text}), ${`@${username.toLowerCase()}`}) > 0`;
162
- const where = and(eq(messages.accountId, accountKey), isNull(messages.deletedAt), byHandle ? sql `(${byId} OR ${byHandle})` : byId);
162
+ const where = and(eq(messages.accountId, accountKey), eq(chats.searchable, 1), isNull(messages.deletedAt), byHandle ? sql `(${byId} OR ${byHandle})` : byId);
163
163
  // instr finds "@ana" inside "@anastasia" too; the word boundary is checked here.
164
164
  const handle = username === null ? undefined : new RegExp(`@${escaped(username)}(?![\\p{L}\\p{N}_])`, "iu");
165
165
  const page = hitsWhere(context, where, limit);
@@ -35,7 +35,7 @@ export const matching = (context, { provider, account, accounts: within, senders
35
35
  const ids = senders?.length ? [...new Set(senders)] : undefined;
36
36
  return and(isNull(messages.deletedAt), trimmed
37
37
  ? sql `${messages.id} IN (SELECT rowid FROM messages_fts WHERE messages_fts MATCH ${wordsOf(trimmed)})`
38
- : undefined, scopeProvider === undefined ? undefined : eq(accounts.provider, scopeProvider), account ? eq(accounts.externalId, account.account) : undefined, within ? inArray(accounts.externalId, within) : undefined, chatId === undefined ? undefined : eq(chats.externalId, chatId), ids ? and(eq(identities.provider, accounts.provider), inArray(identities.externalId, ids)) : undefined, ids && together ? inArray(messages.chatId, everyoneWrote(context, ids, scopeProvider)) : undefined);
38
+ : undefined, scopeProvider === undefined ? undefined : eq(accounts.provider, scopeProvider), account ? eq(accounts.externalId, account.account) : undefined, within ? inArray(accounts.externalId, within) : undefined, chatId === undefined ? eq(chats.searchable, 1) : eq(chats.externalId, chatId), ids ? and(eq(identities.provider, accounts.provider), inArray(identities.externalId, ids)) : undefined, ids && together ? inArray(messages.chatId, everyoneWrote(context, ids, scopeProvider)) : undefined);
39
39
  };
40
40
  /** The chats where every one of `ids` has a message — the outer row's provider when no scope names one. */
41
41
  const everyoneWrote = ({ orm }, ids, scopeProvider) => {
@@ -86,7 +86,7 @@ export declare const embeddedOnlyElsewhere: ({ orm }: StoreContext, accountPk: n
86
86
  /** The messenger's ids of these messages, by pk. */
87
87
  export declare const messageIds: ({ orm }: StoreContext, pks: number[]) => Map<number, string>;
88
88
  /** Chats of the account whose conversations were ever built, or started to be. */
89
- export declare const builtChats: ({ orm }: StoreContext, accountPk: number) => {
89
+ export declare const builtChats: ({ orm }: StoreContext, accountPk: number, searchableOnly?: boolean) => {
90
90
  chatKey: number;
91
91
  id: string;
92
92
  }[];
@@ -173,7 +173,7 @@ export const nearestChunks = ({ orm }, accountPk, { chatKey, model, since, limit
173
173
  ${projectId === undefined ? sql `` : sql `AND k.project_id=${projectId}`}
174
174
  ${before === undefined ? sql `` : sql `AND k.occurred_at < ${before}`}
175
175
  ${personId === undefined ? sql `` : sql `AND EXISTS (SELECT 1 FROM conversation_messages pcm JOIN messages pm ON pm.id=pcm.message_id JOIN identity_links pil ON pil.identity_id=pm.sender_identity_id WHERE pcm.conversation_id=c.id AND pil.person_id=${personId})`}
176
- ${chatKey === undefined ? sql `` : sql `AND c.chat_id = ${chatKey}`}
176
+ ${chatKey === undefined ? sql `AND ch.searchable = 1` : sql `AND c.chat_id = ${chatKey}`}
177
177
  ${since === undefined ? sql `` : sql `AND k.occurred_at >= ${since}`}
178
178
  ${exclude === undefined ? sql `` : sql `AND c.id <> ${exclude}`}
179
179
  ${conversations === undefined ? sql `` : sql `AND c.id IN (SELECT value FROM json_each(${JSON.stringify(conversations)}))`}
@@ -215,7 +215,7 @@ export const embeddedOnlyElsewhere = ({ orm }, accountPk, { chatKey, model }) =>
215
215
  return [];
216
216
  return orm
217
217
  .all(sql `SELECT ch.external_id AS id FROM chats ch
218
- WHERE ch.account_id = ${accountPk} ${chatKey === undefined ? sql `` : sql `AND ch.id = ${chatKey}`}
218
+ WHERE ch.account_id = ${accountPk} ${chatKey === undefined ? sql `AND ch.searchable = 1` : sql `AND ch.id = ${chatKey}`}
219
219
  AND ${hasVectorOf(sql `IN (${sql.join(others, sql `, `)})`)}
220
220
  AND NOT ${hasVectorOf(sql `= ${model}`)}
221
221
  ORDER BY ch.external_id`)
@@ -228,8 +228,8 @@ export const messageIds = ({ orm }, pks) => new Map(pks.length === 0
228
228
  .all(sql `SELECT id AS pk, external_id AS id FROM messages WHERE id IN (${sql.join(pks.map((pk) => sql `${pk}`), sql `, `)})`)
229
229
  .map(({ pk, id }) => [pk, id]));
230
230
  /** Chats of the account whose conversations were ever built, or started to be. */
231
- export const builtChats = ({ orm }, accountPk) => orm.all(sql `SELECT ch.id AS chatKey, ch.external_id AS id FROM conversation_state s JOIN chats ch ON ch.id = s.chat_id
232
- WHERE ch.account_id = ${accountPk} ORDER BY ch.external_id`);
231
+ export const builtChats = ({ orm }, accountPk, searchableOnly = false) => orm.all(sql `SELECT ch.id AS chatKey, ch.external_id AS id FROM conversation_state s JOIN chats ch ON ch.id = s.chat_id
232
+ WHERE ch.account_id = ${accountPk} ${searchableOnly ? sql `AND ch.searchable = 1` : sql ``} ORDER BY ch.external_id`);
233
233
  /** Group chats of the account with stored messages and no build ever started, the newest message first. */
234
234
  export const unbuiltGroups = ({ orm }, accountPk) => orm
235
235
  .all(sql `SELECT ch.external_id AS id FROM chats ch
@@ -82,11 +82,13 @@ export interface AccountVectors {
82
82
  }>;
83
83
  /**
84
84
  * How fresh each built chat's conversations and `model`'s vectors are — one chat, or every chat whose
85
- * conversations were ever built. A chat never built answers `builtAt: null`.
85
+ * conversations were ever built. A chat never built answers `builtAt: null`. `searchableOnly` leaves out the chats
86
+ * the owner hid from searches that do not name them.
86
87
  */
87
88
  readiness(key: AccountKey, options: {
88
89
  chatId?: Id;
89
90
  model: string;
91
+ searchableOnly?: boolean;
90
92
  }): Promise<StoredReadiness[]>;
91
93
  /** Group chats with stored messages whose conversations were never built, the newest message first. */
92
94
  unbuiltGroups(key: AccountKey): Promise<Id[]>;
@@ -176,12 +176,12 @@ export const accountVectorsOver = (context, mail) => {
176
176
  }
177
177
  return vectors.conversationVectors(context, pk, model);
178
178
  },
179
- readiness: async (key, { chatId, model }) => {
179
+ readiness: async (key, { chatId, model, searchableOnly = false }) => {
180
180
  const accountPk = findAccountPk(key);
181
181
  if (accountPk === undefined)
182
182
  return [];
183
183
  const chats = chatId === undefined
184
- ? vectors.builtChats(context, accountPk)
184
+ ? vectors.builtChats(context, accountPk, searchableOnly)
185
185
  : [{ chatKey: chatKeyOf(key, chatId), id: chatId }].flatMap(({ chatKey, id }) => chatKey === undefined ? [] : [{ chatKey, id }]);
186
186
  return chats.map(({ chatKey, id }) => {
187
187
  const found = vectors.readiness(context, chatKey, model);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.351.0",
3
+ "version": "0.353.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",
package/parity.json CHANGED
@@ -4401,6 +4401,7 @@
4401
4401
  "in": "all",
4402
4402
  "options": {
4403
4403
  "--allow-dangerous": "all",
4404
+ "--dry-run": "all",
4404
4405
  "--left": "all"
4405
4406
  }
4406
4407
  },