@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 +55 -0
- package/dist/cli/messenger/archive-commands.js +28 -5
- package/dist/cli/messenger/stored.js +4 -1
- package/dist/domain/models.d.ts +6 -4
- 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 +20 -8
- package/dist/store/chats.js +7 -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 +15 -3
- package/dist/store/sqlite/chats.js +37 -16
- 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,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 (
|
|
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,
|
|
191
|
+
await opened.chats.markChatsLeft(account, ids);
|
|
192
|
+
else
|
|
193
|
+
await opened.chats.rejoinChats(account, ids);
|
|
191
194
|
});
|
|
192
195
|
return page;
|
|
193
196
|
},
|
package/dist/domain/models.d.ts
CHANGED
|
@@ -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 `
|
|
51
|
-
* stored value; `null` stores none. So a push that knows only some of them, such as a notice that
|
|
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
|
};
|
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. */
|
|
@@ -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
|
|
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
|
-
/**
|
|
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. */
|
package/dist/store/chats.js
CHANGED
|
@@ -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);
|
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);
|
|
@@ -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
|
-
/**
|
|
15
|
-
export declare const
|
|
16
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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:
|
|
120
|
-
.where(and(eq(chats.accountId, accountKey),
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
131
|
-
const
|
|
132
|
-
?
|
|
133
|
-
:
|
|
134
|
-
|
|
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
|
|
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.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",
|