@wirecat/cli-core 0.352.0 → 0.354.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 +43 -0
- package/dist/cli/messenger/archive-commands.js +28 -5
- package/dist/services/archive.d.ts +2 -4
- package/dist/services/embeddings-maintenance.d.ts +1 -1
- package/dist/services/embeddings-maintenance.js +2 -2
- package/dist/services/embeddings-search.js +2 -2
- package/dist/store/chats.d.ts +13 -6
- package/dist/store/chats.js +3 -3
- package/dist/store/exports.d.ts +1 -1
- package/dist/store/sqlite/chat-rekey.js +21 -2
- package/dist/store/sqlite/chats.d.ts +8 -1
- package/dist/store/sqlite/chats.js +18 -9
- package/dist/store/sqlite/context-evidence.js +1 -1
- package/dist/store/sqlite/image-chunks.js +1 -1
- package/dist/store/sqlite/person-links.js +2 -2
- package/dist/store/sqlite/search.js +1 -1
- package/dist/store/sqlite/vectors.d.ts +1 -1
- package/dist/store/sqlite/vectors.js +4 -4
- package/dist/store/vectors.d.ts +3 -1
- package/dist/store/vectors.js +2 -2
- package/package.json +1 -1
- package/parity.json +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,49 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.354.0 — 19.10.2026
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Meeting preparation and project gathering leave out a chat hidden from search** (security). The stored-context
|
|
15
|
+
reader (`store.contextEvidence`) searched the words of every chat of the account, so a chat with `searchable = 0`
|
|
16
|
+
still reached cli-memo's meeting preparation and project gathering. It now leaves such chats out; the reader never
|
|
17
|
+
names a chat. No released code sets `searchable = 0`, so only a store edited by hand was affected.
|
|
18
|
+
|
|
19
|
+
## 0.353.0 — 19.10.2026
|
|
20
|
+
|
|
21
|
+
### Changed — may break callers
|
|
22
|
+
|
|
23
|
+
- **`ChatsStore.leftChats` also names each chat** in a new `left` list (`id`, `title`, `messages`), the ones holding
|
|
24
|
+
most messages first; with `clear` it is what was deleted. A test that compares its whole answer, such as max-cli's
|
|
25
|
+
`src/record.test.ts`, adds `left`.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **`store clear --left` shows which chats it deletes**: the refusal without `--allow-dangerous` lists the first 20
|
|
30
|
+
by id, title and message count, and says how many more; the new `--dry-run` answers every one in `left` and
|
|
31
|
+
deletes nothing, also under `<PREFIX>_READ_ONLY`; the answer after deleting lists what was deleted. The titles go
|
|
32
|
+
only to the terminal or the JSON answer — the refusal's message, which a scheduled run's `last.json` keeps, holds
|
|
33
|
+
only the counts.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **A re-keyed chat keeps the stricter of its two halves' privacy settings** (security). When `rekeyChat` merges a
|
|
38
|
+
phone-form chat into its LID twin, the merged chat is hidden from search if either half was, and is personal if the
|
|
39
|
+
halves sit on different sides (`personal` and `work`, after the account's default). A scope either half pinned
|
|
40
|
+
stays pinned on the merged chat, so a later change to the account's default cannot loosen it. Before, the old row's
|
|
41
|
+
`searchable` and `scope` always won, so a chat the owner had hidden became searchable again when a stray
|
|
42
|
+
phone-form row merged into it; wa reaches this at every `wa serve` start. No released code sets either column, so
|
|
43
|
+
only a store edited by hand could have been affected.
|
|
44
|
+
- **A chat hidden from search stays out of every search that does not name it** (security). A chat with
|
|
45
|
+
`searchable = 0` was already left out of `search messages`, `search all`, rankings and statistics, but still came
|
|
46
|
+
back from `store.messages.search` and `find` (the plain text and sender search, also behind `bot people`, person
|
|
47
|
+
context and botcheck), `mentioning`, and the meaning half of `conversations search`, `conversations related` and
|
|
48
|
+
the picture search; the search answers also listed it in their readiness and "embedded only with another model"
|
|
49
|
+
lists. Each now leaves it out unless the call names the chat, as the Lucene search does. A sender-only `find`
|
|
50
|
+
therefore no longer counts the hidden chat's messages. No released code sets `searchable = 0`, so only a store
|
|
51
|
+
edited by hand was affected.
|
|
52
|
+
|
|
10
53
|
## 0.352.0 — 19.10.2026
|
|
11
54
|
|
|
12
55
|
### Changed — may break callers
|
|
@@ -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 (
|
|
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");
|
|
@@ -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
|
};
|
package/dist/store/chats.d.ts
CHANGED
|
@@ -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. */
|
|
@@ -32,13 +39,13 @@ export interface ChatsStore {
|
|
|
32
39
|
* `to`. Replaying it changes nothing.
|
|
33
40
|
*/
|
|
34
41
|
rekeyChat(key: AccountKey, from: Id, to: Id): Promise<ChatRekey>;
|
|
35
|
-
/**
|
|
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
|
+
*/
|
|
36
46
|
leftChats(key: AccountKey, options?: {
|
|
37
47
|
clear?: boolean;
|
|
38
|
-
}): Promise<
|
|
39
|
-
chats: number;
|
|
40
|
-
messages: number;
|
|
41
|
-
}>;
|
|
48
|
+
}): Promise<LeftChats>;
|
|
42
49
|
/** Per chat: whether its history is held up to date, without gaps, back to its start. */
|
|
43
50
|
chatCompleteness(key: AccountKey, chatIds: Id[]): Promise<ChatCompleteness[]>;
|
|
44
51
|
/** Per chat that has messages: how many, the oldest and newest, and when the last one was stored. */
|
package/dist/store/chats.js
CHANGED
|
@@ -52,14 +52,14 @@ export const chatsStoreOver = (context) => {
|
|
|
52
52
|
leftChats: async (key, { clear = false } = {}) => {
|
|
53
53
|
const accountKey = findAccountPk(key);
|
|
54
54
|
if (accountKey === undefined)
|
|
55
|
-
return { chats: 0, messages: 0 };
|
|
56
|
-
let found = { pks: [], messages: 0 };
|
|
55
|
+
return { chats: 0, messages: 0, left: [] };
|
|
56
|
+
let found = { pks: [], chats: [], messages: 0 };
|
|
57
57
|
write(database, () => {
|
|
58
58
|
found = chatQueries.leftChats(context, accountKey);
|
|
59
59
|
if (clear)
|
|
60
60
|
chatQueries.purgeChats(context, found.pks);
|
|
61
61
|
});
|
|
62
|
-
return { chats: found.pks.length, messages: found.messages };
|
|
62
|
+
return { chats: found.pks.length, messages: found.messages, left: found.chats };
|
|
63
63
|
},
|
|
64
64
|
chatCompleteness: async (key, chatIds) => {
|
|
65
65
|
const accountKey = findAccountPk(key);
|
package/dist/store/exports.d.ts
CHANGED
|
@@ -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);
|
|
@@ -18,9 +18,16 @@ export declare const rejoin: ({ orm }: StoreContext, accountKey: number, present
|
|
|
18
18
|
* list. Answers how many it marked.
|
|
19
19
|
*/
|
|
20
20
|
export declare const markLeft: (context: StoreContext, accountKey: number, present: Id[]) => number;
|
|
21
|
-
/**
|
|
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. */
|
|
22
28
|
export declare const leftChats: ({ orm }: StoreContext, accountKey: number) => {
|
|
23
29
|
pks: number[];
|
|
30
|
+
chats: LeftChat[];
|
|
24
31
|
messages: number;
|
|
25
32
|
};
|
|
26
33
|
/** These chats and everything under them, children before parents: the foreign keys are enforced. */
|
|
@@ -132,18 +132,27 @@ export const markLeft = (context, accountKey, present) => {
|
|
|
132
132
|
.returning({ pk: chats.id })
|
|
133
133
|
.all().length;
|
|
134
134
|
};
|
|
135
|
-
/** The chats marked left, and how many messages they hold. */
|
|
135
|
+
/** The chats marked left, the ones holding the most messages first, and how many messages they hold. */
|
|
136
136
|
export const leftChats = ({ orm }, accountKey) => {
|
|
137
|
-
const
|
|
138
|
-
.select({ pk: chats.id })
|
|
137
|
+
const found = orm
|
|
138
|
+
.select({ pk: chats.id, id: chats.externalId, title: chats.title })
|
|
139
139
|
.from(chats)
|
|
140
140
|
.where(and(eq(chats.accountId, accountKey), eq(chats.membershipState, "left")))
|
|
141
|
-
.all()
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
?
|
|
145
|
-
:
|
|
146
|
-
|
|
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) };
|
|
147
156
|
};
|
|
148
157
|
/** These chats and everything under them, children before parents: the foreign keys are enforced. */
|
|
149
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
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
package/dist/store/vectors.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/store/vectors.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.354.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",
|