@wirecat/cli-core 0.434.0 → 0.436.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 +47 -0
- package/dist/cli/messenger/context.js +1 -0
- package/dist/cli/messenger/conversation-answers.d.ts +1 -0
- package/dist/cli/messenger/conversations-command.js +2 -2
- package/dist/cli/messenger/message-answers.d.ts +1 -0
- package/dist/cli/messenger/message-writes.d.ts +2 -0
- package/dist/cli/messenger/messages-search-command.js +6 -10
- package/dist/cli/messenger/models-command.js +34 -7
- package/dist/cli/messenger/search-answers.d.ts +3 -0
- package/dist/cli/messenger/search-answers.js +40 -8
- package/dist/cli/messenger/search-command.js +3 -1
- package/dist/cli/messenger/store-maintenance-command.js +7 -4
- package/dist/cli/model-catalogue-command.js +1 -1
- package/dist/cli/profile.d.ts +4 -1
- package/dist/cli/profile.js +22 -7
- package/dist/cli/program.js +8 -4
- package/dist/cli/settings.d.ts +4 -0
- package/dist/cli/settings.js +5 -0
- package/dist/cli/shared-command-exports.d.ts +4 -0
- package/dist/embeddings/child.js +15 -5
- package/dist/embeddings/embed.d.ts +16 -2
- package/dist/embeddings/embed.js +57 -28
- package/dist/embeddings/models.d.ts +18 -0
- package/dist/embeddings/models.js +69 -0
- package/dist/embeddings/process.d.ts +6 -1
- package/dist/embeddings/process.js +23 -7
- package/dist/embeddings/rerank.d.ts +10 -0
- package/dist/embeddings/rerank.js +31 -0
- package/dist/entities/conversation-results.d.ts +2 -0
- package/dist/entities/evidence.d.ts +8 -0
- package/dist/entities/index.d.ts +27 -1
- package/dist/entities/listener.d.ts +4 -0
- package/dist/entities/message-context.d.ts +3 -0
- package/dist/entities/message-write.d.ts +4 -0
- package/dist/entities/message.d.ts +2 -0
- package/dist/entities/message.js +2 -0
- package/dist/entities/model.d.ts +5 -2
- package/dist/entities/model.js +5 -2
- package/dist/entities/store-maintenance.d.ts +1 -0
- package/dist/entities/store-maintenance.js +8 -1
- package/dist/entities/store-records.d.ts +1 -0
- package/dist/mcp/tool.d.ts +1 -1
- package/dist/mcp/tool.js +1 -0
- package/dist/services/deps.d.ts +3 -0
- package/dist/services/embeddings-maintenance.d.ts +10 -2
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +1 -0
- package/dist/services/messages-archive-search.d.ts +2 -2
- package/dist/services/messages-archive-search.js +4 -4
- package/dist/services/messages-discovery.d.ts +33 -4
- package/dist/services/messages-discovery.js +88 -3
- package/dist/services/messages-meaning.d.ts +2 -1
- package/dist/services/messages-meaning.js +7 -3
- package/dist/services/messages-rerank.d.ts +7 -0
- package/dist/services/messages-rerank.js +42 -0
- package/dist/services/messages-search.d.ts +5 -0
- package/dist/services/messages.d.ts +6 -2
- package/dist/services/messages.js +4 -3
- package/dist/services/model-downloads.d.ts +3 -1
- package/dist/services/model-downloads.js +20 -1
- package/dist/services/notes-search.d.ts +7 -2
- package/dist/services/notes-search.js +7 -4
- package/dist/services/search-all-meetings.d.ts +3 -2
- package/dist/services/search-all-meetings.js +8 -8
- package/dist/services/search-all.d.ts +7 -1
- package/dist/services/search-all.js +109 -45
- package/dist/store/exports.d.ts +1 -1
- package/dist/store/exports.js +1 -1
- package/dist/store/sqlite/accounts.js +2 -2
- package/dist/store/sqlite/chats.d.ts +1 -1
- package/dist/store/sqlite/chats.js +11 -4
- package/dist/store/sqlite/document-pictures.js +2 -7
- package/dist/store/sqlite/email-index.d.ts +1 -3
- package/dist/store/sqlite/email-index.js +5 -21
- package/dist/store/sqlite/emails.js +4 -3
- package/dist/store/sqlite/image-chunks.d.ts +2 -2
- package/dist/store/sqlite/image-chunks.js +10 -13
- package/dist/store/sqlite/meeting-vectors.js +11 -9
- package/dist/store/sqlite/message-chunks.js +3 -8
- package/dist/store/sqlite/note-index.js +2 -7
- package/dist/store/sqlite/schema.d.ts +1 -1
- package/dist/store/sqlite/schema.js +1 -1
- package/dist/store/sqlite/vector-index.d.ts +9 -0
- package/dist/store/sqlite/vector-index.js +25 -0
- package/dist/store/sqlite/vectors.d.ts +2 -2
- package/dist/store/sqlite/vectors.js +11 -8
- package/package.json +1 -1
|
@@ -1,18 +1,38 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { parseLucene } from "@wirecat/cli-base/query";
|
|
2
3
|
import { parseLocator } from "../domain/locator.js";
|
|
3
4
|
import { INTERNAL_NOTES, kindIn } from "../domain/providers.js";
|
|
5
|
+
import { runEmbedders } from "../embeddings/embed.js";
|
|
6
|
+
import { DEFAULT_TEXT_MODEL } from "../embeddings/models.js";
|
|
7
|
+
import { MESSAGE_PARSING } from "../search/lucene/types.js";
|
|
8
|
+
import { planQuestion } from "../search/question-plan.js";
|
|
4
9
|
import { excerpt } from "./excerpt.js";
|
|
5
10
|
import { MAX_SEARCH_OFFSET, searchStore } from "./messages-archive-search.js";
|
|
11
|
+
import { nearEligible } from "./messages-discovery.js";
|
|
6
12
|
import { notesScopeOf, RRF_K, searchNotes } from "./notes-search.js";
|
|
7
13
|
export const RESOURCES_SEARCHED = ["messages", "mail", "notes"];
|
|
8
14
|
/** A query a resource cannot answer is skipped with its reason, never a failure of the whole search. */
|
|
9
15
|
const skippable = (error) => error instanceof CliError && (error.code === "validation_error" || error.code === "not_found");
|
|
16
|
+
/**
|
|
17
|
+
* The filters the meaning hits must pass, or `undefined` when they cannot be followed: a NOT anywhere (meaning would
|
|
18
|
+
* bring the excluded back), or a filter under OR or in a group, which the plain list of filters would turn into AND.
|
|
19
|
+
*/
|
|
20
|
+
const filtersBeside = (text) => {
|
|
21
|
+
const { root } = parseLucene(text, MESSAGE_PARSING);
|
|
22
|
+
const followable = (node, required) => node.kind === "boolean"
|
|
23
|
+
? node.clauses.every(({ node: inner, occur }) => occur !== "must_not" && followable(inner, false))
|
|
24
|
+
: node.kind === "predicate" && (required || node.field === "text" || node.field === "exact");
|
|
25
|
+
const top = root.kind === "boolean" ? root.clauses : [{ node: root, occur: "must" }];
|
|
26
|
+
return top.every(({ node, occur }) => occur !== "must_not" && followable(node, occur === "must"))
|
|
27
|
+
? planQuestion(text).scope
|
|
28
|
+
: undefined;
|
|
29
|
+
};
|
|
10
30
|
/**
|
|
11
31
|
* `search all`: messages, mail and notes from the local store — messages also from the messenger's
|
|
12
32
|
* server, as `search messages` asks it — each list best first, merged by reciprocal rank so no
|
|
13
33
|
* resource's own scores have to be compared with another's.
|
|
14
34
|
*/
|
|
15
|
-
export const searchAll = async (store, account, request, messenger = {}, searchMessages = (query) => searchStore(store, account, query, messenger)) => {
|
|
35
|
+
export const searchAll = async (store, account, request, messenger = {}, searchMessages = (query) => searchStore(store, account, query, messenger), meaning) => {
|
|
16
36
|
if (!request.text.trim())
|
|
17
37
|
throw new CliError("validation_error", "say what to find");
|
|
18
38
|
const offset = request.offset ?? 0;
|
|
@@ -28,6 +48,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
|
|
|
28
48
|
const skipped = [];
|
|
29
49
|
let hasMore = false;
|
|
30
50
|
let notes;
|
|
51
|
+
let messagesBy;
|
|
31
52
|
let server;
|
|
32
53
|
const messagesOf = async (kind) => {
|
|
33
54
|
const given = request.scope?.[kind];
|
|
@@ -60,7 +81,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
|
|
|
60
81
|
if (!skipped.some(({ source }) => source === one.source))
|
|
61
82
|
skipped.push(one);
|
|
62
83
|
accounts.push(...read.map(({ provider, account }) => ({ resource: kind, provider, account })));
|
|
63
|
-
|
|
84
|
+
const itemOf = (hit, foundBy) => {
|
|
64
85
|
const at = parseLocator(hit.locator);
|
|
65
86
|
return {
|
|
66
87
|
kind: kind === "mail" ? "mail" : "message",
|
|
@@ -74,55 +95,97 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
|
|
|
74
95
|
? { name: hit.email.from.name ?? hit.email.from.address, ref: hit.email.from.ref }
|
|
75
96
|
: { name: hit.senderName, ref: null },
|
|
76
97
|
...(hit.email ? { email: hit.email } : {}),
|
|
77
|
-
foundBy
|
|
98
|
+
foundBy,
|
|
78
99
|
};
|
|
100
|
+
};
|
|
101
|
+
const byWords = found.items.map((hit) => itemOf(hit, ["words"]));
|
|
102
|
+
// Server hits are only those the server returned; the local vectors would add others.
|
|
103
|
+
if (kind === "mail" || !meaning || request.exact || request.backend === "server")
|
|
104
|
+
return byWords;
|
|
105
|
+
const filters = filtersBeside(request.text);
|
|
106
|
+
const lane = filters === undefined
|
|
107
|
+
? {
|
|
108
|
+
skipped: {
|
|
109
|
+
reason: "the query uses NOT, or a filter under OR or in a group, which meaning cannot follow",
|
|
110
|
+
model: DEFAULT_TEXT_MODEL,
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
: await meaning(request.text, read, embedders);
|
|
114
|
+
if ("skipped" in lane) {
|
|
115
|
+
messagesBy = { by: "words", meaningSkipped: lane.skipped };
|
|
116
|
+
return byWords;
|
|
117
|
+
}
|
|
118
|
+
messagesBy = { by: "words and meaning", ...(lane.partly ? { meaningSkipped: lane.partly } : {}) };
|
|
119
|
+
const near = await nearEligible(store, account, query, messenger, lane.ranked, filters ?? "");
|
|
120
|
+
// One list per resource, as notes give theirs, so messages get no second vote against mail.
|
|
121
|
+
const fused = new Map();
|
|
122
|
+
byWords.forEach((item, rank) => {
|
|
123
|
+
fused.set(item.ref, { item, score: 1 / (RRF_K + rank + 1) });
|
|
79
124
|
});
|
|
125
|
+
near.forEach((hit, rank) => {
|
|
126
|
+
const held = fused.get(hit.locator);
|
|
127
|
+
if (held)
|
|
128
|
+
held.item.foundBy = ["words", "meaning"];
|
|
129
|
+
fused.set(hit.locator, {
|
|
130
|
+
item: held?.item ?? itemOf(hit, ["meaning"]),
|
|
131
|
+
score: (held?.score ?? 0) + 1 / (RRF_K + rank + 1),
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
return [...fused.values()].sort((a, b) => b.score - a.score).map(({ item }) => item);
|
|
80
135
|
};
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
136
|
+
// Messages and notes both look up by meaning with the same model: loading it costs most of a second.
|
|
137
|
+
const embedders = runEmbedders();
|
|
138
|
+
try {
|
|
139
|
+
for (const resource of only) {
|
|
140
|
+
try {
|
|
141
|
+
if (resource === "notes") {
|
|
142
|
+
const chosen = request.scope?.notes;
|
|
143
|
+
const found = await searchNotes(store, request.text, {
|
|
144
|
+
limit: window,
|
|
145
|
+
command,
|
|
146
|
+
embedders,
|
|
147
|
+
...(chosen === undefined ? {} : notesScopeOf(chosen)),
|
|
148
|
+
...(request.exact ? { exact: true } : {}),
|
|
149
|
+
...(request.timezone === undefined ? {} : { timezone: request.timezone }),
|
|
150
|
+
...(request.env === undefined ? {} : { env: request.env }),
|
|
151
|
+
});
|
|
152
|
+
hasMore ||= found.has_more;
|
|
153
|
+
notes = {
|
|
154
|
+
by: found.by,
|
|
155
|
+
...(found.meaning_skipped === undefined ? {} : { meaningSkipped: found.meaning_skipped }),
|
|
156
|
+
};
|
|
157
|
+
lists.push(found.hits.map((hit) => ({
|
|
158
|
+
kind: hit.kind ?? "note",
|
|
159
|
+
ref: hit.ref,
|
|
160
|
+
provider: hit.provider ?? "notes",
|
|
161
|
+
account: hit.account ?? hit.folder_id ?? INTERNAL_NOTES,
|
|
162
|
+
...(hit.url ? { url: hit.url } : {}),
|
|
163
|
+
title: hit.title,
|
|
164
|
+
timestamp: hit.updated_at,
|
|
165
|
+
text: hit.excerpt,
|
|
166
|
+
foundBy: hit.found_by,
|
|
167
|
+
})));
|
|
168
|
+
const read = chosen?.map(({ account }) => account) ?? [
|
|
169
|
+
INTERNAL_NOTES,
|
|
170
|
+
...(await store.notes.folders()).map(({ id }) => id),
|
|
171
|
+
...(await store.notes.documentsAccounts()).map(({ id }) => id),
|
|
172
|
+
];
|
|
173
|
+
accounts.push(...read.map((account) => ({ resource, provider: "notes", account })));
|
|
174
|
+
}
|
|
175
|
+
else
|
|
176
|
+
lists.push(await messagesOf(resource));
|
|
177
|
+
searched.push(resource);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (!skippable(error))
|
|
181
|
+
throw error;
|
|
182
|
+
skipped.push({ resource, code: error.code, reason: error.message });
|
|
115
183
|
}
|
|
116
|
-
else
|
|
117
|
-
lists.push(await messagesOf(resource));
|
|
118
|
-
searched.push(resource);
|
|
119
|
-
}
|
|
120
|
-
catch (error) {
|
|
121
|
-
if (!skippable(error))
|
|
122
|
-
throw error;
|
|
123
|
-
skipped.push({ resource, code: error.code, reason: error.message });
|
|
124
184
|
}
|
|
125
185
|
}
|
|
186
|
+
finally {
|
|
187
|
+
await embedders.close();
|
|
188
|
+
}
|
|
126
189
|
const ranked = new Map();
|
|
127
190
|
for (const list of lists)
|
|
128
191
|
list.forEach((item, rank) => {
|
|
@@ -141,6 +204,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
|
|
|
141
204
|
accounts,
|
|
142
205
|
skipped,
|
|
143
206
|
...(notes === undefined ? {} : { notes }),
|
|
207
|
+
...(messagesBy === undefined ? {} : { messages: messagesBy }),
|
|
144
208
|
...(server === undefined ? {} : { server }),
|
|
145
209
|
};
|
|
146
210
|
};
|
package/dist/store/exports.d.ts
CHANGED
|
@@ -54,7 +54,7 @@ export { savedTaskCategories } from "./sqlite/task-category-settings.js";
|
|
|
54
54
|
export { DEFAULT_AGENT_CLOSE, savedAgentClose } from "./sqlite/task-close.js";
|
|
55
55
|
export { DEFAULT_FIRST_PASS_PER_CHAT, firstPassPerChatOf, savedFirstPassPerChat } from "./sqlite/task-first-pass.js";
|
|
56
56
|
export { inboxKey } from "./sqlite/tasks.js";
|
|
57
|
-
export { vectorIndexCounts } from "./sqlite/vector-index.js";
|
|
57
|
+
export { dropAllUnusedVectors, vectorIndexCounts } from "./sqlite/vector-index.js";
|
|
58
58
|
export type { MatchPlace, ScoredHit } from "./sqlite/words.js";
|
|
59
59
|
export type { TaskDetails } from "./sqlite/work-tasks.js";
|
|
60
60
|
export type { SyncStore } from "./sync.js";
|
package/dist/store/exports.js
CHANGED
|
@@ -24,4 +24,4 @@ export { savedTaskCategories } from "./sqlite/task-category-settings.js";
|
|
|
24
24
|
export { DEFAULT_AGENT_CLOSE, savedAgentClose } from "./sqlite/task-close.js";
|
|
25
25
|
export { DEFAULT_FIRST_PASS_PER_CHAT, firstPassPerChatOf, savedFirstPassPerChat } from "./sqlite/task-first-pass.js";
|
|
26
26
|
export { inboxKey } from "./sqlite/tasks.js";
|
|
27
|
-
export { vectorIndexCounts } from "./sqlite/vector-index.js";
|
|
27
|
+
export { dropAllUnusedVectors, vectorIndexCounts } from "./sqlite/vector-index.js";
|
|
@@ -2,6 +2,7 @@ import { CliError } from "@wirecat/cli-base";
|
|
|
2
2
|
import { and, asc, eq, inArray, sql } from "./drizzle/core.js";
|
|
3
3
|
import { pointerPairs } from "./entity-types.js";
|
|
4
4
|
import { accountIdentities, accounts, aliases, botUpdates, chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageReactions, messageRevisions, messages, messageTranscripts, pollVotes, projects, syncCursors, syncRanges, syncs, } from "./schema.js";
|
|
5
|
+
import { dropUnusedVectors } from "./vector-index.js";
|
|
5
6
|
/** An account's row, made on first sight; a name only replaces what was known when there is one. */
|
|
6
7
|
export const accountPk = ({ orm, now }, { provider, account, scope }, name = null) => Number(orm
|
|
7
8
|
.insert(accounts)
|
|
@@ -114,8 +115,7 @@ export const purgeAccount = ({ orm, database }, accountKey) => {
|
|
|
114
115
|
orm.run(sql `DELETE FROM files WHERE account_id = ${accountKey}`);
|
|
115
116
|
orm.delete(accounts).where(eq(accounts.id, accountKey)).run();
|
|
116
117
|
dropOrphanPeople(database, unseenBefore);
|
|
117
|
-
|
|
118
|
-
AND NOT EXISTS (SELECT 1 FROM chunks k WHERE k.content_hash = embeddings.content_hash)`);
|
|
118
|
+
dropUnusedVectors(database, hashes);
|
|
119
119
|
};
|
|
120
120
|
/** Identity and person rows a person's own record keeps, not evidence that anyone saw them. */
|
|
121
121
|
const OWN = new Set(["identity_links", "identity_link_events", "identity_revisions"]);
|
|
@@ -60,7 +60,7 @@ export declare const leftChats: ({ orm }: StoreContext, accountKey: number) => {
|
|
|
60
60
|
messages: number;
|
|
61
61
|
};
|
|
62
62
|
/** These chats and everything under them, children before parents: the foreign keys are enforced. */
|
|
63
|
-
export declare const purgeChats: ({ orm }: StoreContext, pks: number[]) => void;
|
|
63
|
+
export declare const purgeChats: ({ orm, database }: StoreContext, pks: number[]) => void;
|
|
64
64
|
export declare const chatsWith: ({ orm, now }: StoreContext, accountKey: number, key: AccountKey, memberId: Id) => Chat[];
|
|
65
65
|
/**
|
|
66
66
|
* How the store answers a chat list's query. `title:` goes through the trigram index, so it matches part of a
|
|
@@ -5,6 +5,7 @@ import { bound } from "./query-filter.js";
|
|
|
5
5
|
import { chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, identities, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageReactions, messageRevisions, messages, messageTranscripts, pollVotes, syncRanges, } from "./schema.js";
|
|
6
6
|
import { ensureTag } from "./tags.js";
|
|
7
7
|
import { parsed, present, toIso, toMs } from "./values.js";
|
|
8
|
+
import { dropUnusedVectors } from "./vector-index.js";
|
|
8
9
|
/** The chat's own id, when the store key is a chat of this account. */
|
|
9
10
|
export const chatOf = ({ orm }, accountKey, chatKey) => orm
|
|
10
11
|
.select({ id: chats.externalId })
|
|
@@ -266,14 +267,19 @@ export const leftChats = ({ orm }, accountKey) => {
|
|
|
266
267
|
return { pks, chats: left, messages: left.reduce((sum, chat) => sum + chat.messages, 0) };
|
|
267
268
|
};
|
|
268
269
|
/** These chats and everything under them, children before parents: the foreign keys are enforced. */
|
|
269
|
-
export const purgeChats = ({ orm }, pks) => {
|
|
270
|
+
export const purgeChats = ({ orm, database }, pks) => {
|
|
270
271
|
if (pks.length === 0)
|
|
271
272
|
return;
|
|
272
273
|
const messagesOf = orm.select({ pk: messages.id }).from(messages).where(inArray(messages.chatId, pks));
|
|
273
|
-
|
|
274
|
+
const chatPks = sql `(SELECT value FROM json_each(${JSON.stringify(pks)}))`;
|
|
275
|
+
const released = orm
|
|
274
276
|
.delete(chunks)
|
|
275
|
-
.where(sql
|
|
276
|
-
.
|
|
277
|
+
.where(sql `(${chunks.chunkableType}='conversation' AND ${chunks.chunkableId} IN (SELECT id FROM conversations WHERE chat_id IN ${chatPks}))
|
|
278
|
+
OR (${chunks.chunkableType}='message' AND ${chunks.chunkableId} IN (SELECT id FROM messages WHERE chat_id IN ${chatPks}))
|
|
279
|
+
OR (${chunks.chunkableType}='image' AND ${chunks.chunkableId} IN (SELECT a.id FROM attachments a
|
|
280
|
+
JOIN messages m ON a.attachable_type = 'message' AND m.id = a.attachable_id WHERE m.chat_id IN ${chatPks}))`)
|
|
281
|
+
.returning({ hash: chunks.contentHash })
|
|
282
|
+
.all();
|
|
277
283
|
orm
|
|
278
284
|
.delete(involvements)
|
|
279
285
|
.where(sql `(${involvements.subjectType}='chat' AND ${involvements.subjectId} IN (SELECT value FROM json_each(${JSON.stringify(pks)}))) OR (${involvements.subjectType}='message' AND ${involvements.subjectId} IN (${messagesOf}))`)
|
|
@@ -303,6 +309,7 @@ export const purgeChats = ({ orm }, pks) => {
|
|
|
303
309
|
orm.delete(messageRevisions).where(inArray(messageRevisions.messageId, messagesOf)).run();
|
|
304
310
|
orm.delete(messages).where(inArray(messages.chatId, pks)).run();
|
|
305
311
|
orm.delete(chats).where(inArray(chats.id, pks)).run();
|
|
312
|
+
dropUnusedVectors(database, released.map(({ hash }) => hash));
|
|
306
313
|
};
|
|
307
314
|
export const chatsWith = ({ orm, now }, accountKey, key, memberId) => {
|
|
308
315
|
const rows = orm
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { write } from "./transaction.js";
|
|
3
|
-
import { ensureVectorIndex, unitVector } from "./vector-index.js";
|
|
3
|
+
import { dropUnusedVectors, ensureVectorIndex, unitVector } from "./vector-index.js";
|
|
4
4
|
import { dot } from "./vectors.js";
|
|
5
5
|
/** A document's picture is one chunk of this type at position 0; its vector is the picture's, not text's. */
|
|
6
6
|
export const DOCUMENT_PICTURE = "document_picture";
|
|
@@ -11,11 +11,6 @@ const idOf = (ref) => {
|
|
|
11
11
|
throw new CliError("validation_error", `${ref} is not a document reference (document:<id>)`);
|
|
12
12
|
return Number(match[1]);
|
|
13
13
|
};
|
|
14
|
-
const purge = (database, hashes) => {
|
|
15
|
-
const drop = database.prepare("DELETE FROM embeddings WHERE content_hash = ? AND NOT EXISTS (SELECT 1 FROM chunks WHERE content_hash = ?)");
|
|
16
|
-
for (const hash of hashes)
|
|
17
|
-
drop.run(hash, hash);
|
|
18
|
-
};
|
|
19
14
|
/** Drops a document's picture chunk, and its vector when no other chunk shares the bytes. */
|
|
20
15
|
export const dropDocumentPicture = (database, documentId) => {
|
|
21
16
|
const hashes = database
|
|
@@ -23,7 +18,7 @@ export const dropDocumentPicture = (database, documentId) => {
|
|
|
23
18
|
.all(DOCUMENT_PICTURE, documentId)
|
|
24
19
|
.map((row) => String(row.hash));
|
|
25
20
|
database.prepare("DELETE FROM chunks WHERE chunkable_type = ? AND chunkable_id = ?").run(DOCUMENT_PICTURE, documentId);
|
|
26
|
-
|
|
21
|
+
dropUnusedVectors(database, hashes);
|
|
27
22
|
return hashes.length > 0;
|
|
28
23
|
};
|
|
29
24
|
export const documentPicturesOver = ({ database, now }) => ({
|
|
@@ -13,10 +13,8 @@ export declare const MAIL_PIECES = "(SELECT k.id, k.content_hash, k.chunkable_ty
|
|
|
13
13
|
*/
|
|
14
14
|
export declare const threadPieces: (database: CacheDatabase, threadId: number) => Chunk[];
|
|
15
15
|
export declare const queueThreads: (database: CacheDatabase, ids: Iterable<number>) => void;
|
|
16
|
-
/** Drops the pieces of an email's attachment texts; answers their hashes, for `
|
|
16
|
+
/** Drops the pieces of an email's attachment texts; answers their hashes, for `dropUnusedVectors` once new ones are in. */
|
|
17
17
|
export declare const dropAttachmentChunks: (database: CacheDatabase, emailId: number) => string[];
|
|
18
|
-
/** Vectors are keyed by text alone, so one goes only when no piece of anything holds that text any more. */
|
|
19
|
-
export declare const purgeUnused: (database: CacheDatabase, hashes: string[]) => void;
|
|
20
18
|
/** Each attachment's extracted text, in pieces for search by meaning, as an email's own text is. */
|
|
21
19
|
export declare const chunkAttachments: (database: CacheDatabase, accountId: number, emailId: number, now: number) => void;
|
|
22
20
|
export declare const drainEmailIndex: (database: CacheDatabase, stemmerFor: (stemmers: Stemmers) => Stemmer) => number;
|
|
@@ -3,6 +3,7 @@ import { mailCore } from "../../conversations/mail-text.js";
|
|
|
3
3
|
import { int, str } from "./events.js";
|
|
4
4
|
import { CORPORA, drainCorpus } from "./note-index.js";
|
|
5
5
|
import { write } from "./transaction.js";
|
|
6
|
+
import { dropUnusedVectors } from "./vector-index.js";
|
|
6
7
|
export const titleOf = (subject) => subject?.replace(/^\s*((re|fwd?|aw|wg)\s*:\s*)+/i, "").trim() || null;
|
|
7
8
|
/**
|
|
8
9
|
* Every piece of mail: a stretch of a thread, or of one email's attachment. A thread piece holds no email of its
|
|
@@ -58,7 +59,7 @@ const chunkThread = (database, threadId, now) => {
|
|
|
58
59
|
for (const [position, piece] of cutThread(lines).entries())
|
|
59
60
|
insert.run(threadId, position, piece.text.length, piece.hash, str(thread.scope), Number(thread.account_id), at.get(piece.lastId) ?? null, now, now);
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
+
dropUnusedVectors(database, before);
|
|
62
63
|
};
|
|
63
64
|
export const queueThreads = (database, ids) => {
|
|
64
65
|
const queue = database.prepare("INSERT OR IGNORE INTO email_index_pending (indexable_type, id) VALUES ('email_thread', ?)");
|
|
@@ -66,7 +67,7 @@ export const queueThreads = (database, ids) => {
|
|
|
66
67
|
queue.run(id);
|
|
67
68
|
};
|
|
68
69
|
const ATTACHMENTS_OF_EMAIL = "SELECT id FROM attachments WHERE attachable_type = 'email' AND attachable_id = ?";
|
|
69
|
-
/** Drops the pieces of an email's attachment texts; answers their hashes, for `
|
|
70
|
+
/** Drops the pieces of an email's attachment texts; answers their hashes, for `dropUnusedVectors` once new ones are in. */
|
|
70
71
|
export const dropAttachmentChunks = (database, emailId) => {
|
|
71
72
|
const hashes = database
|
|
72
73
|
.prepare(`SELECT content_hash AS hash FROM chunks WHERE chunkable_type = 'attachment' AND chunkable_id IN (${ATTACHMENTS_OF_EMAIL})`)
|
|
@@ -77,23 +78,6 @@ export const dropAttachmentChunks = (database, emailId) => {
|
|
|
77
78
|
.run(emailId);
|
|
78
79
|
return hashes;
|
|
79
80
|
};
|
|
80
|
-
/** Vectors are keyed by text alone, so one goes only when no piece of anything holds that text any more. */
|
|
81
|
-
export const purgeUnused = (database, hashes) => {
|
|
82
|
-
const unique = new Set(hashes);
|
|
83
|
-
if (unique.size === 0)
|
|
84
|
-
return;
|
|
85
|
-
// The key is (model, content_hash): naming the model reaches the row by the key; the hash alone scans every vector.
|
|
86
|
-
const models = database
|
|
87
|
-
.prepare(`WITH RECURSIVE m(model) AS (SELECT min(model) FROM embeddings
|
|
88
|
-
UNION ALL SELECT (SELECT min(model) FROM embeddings WHERE model > m.model) FROM m WHERE m.model IS NOT NULL)
|
|
89
|
-
SELECT model FROM m WHERE model IS NOT NULL`)
|
|
90
|
-
.all()
|
|
91
|
-
.map(({ model }) => String(model));
|
|
92
|
-
const purge = database.prepare("DELETE FROM embeddings WHERE model = ? AND content_hash = ? AND NOT EXISTS (SELECT 1 FROM chunks WHERE content_hash = ?)");
|
|
93
|
-
for (const hash of unique)
|
|
94
|
-
for (const model of models)
|
|
95
|
-
purge.run(model, hash, hash);
|
|
96
|
-
};
|
|
97
81
|
/** Each attachment's extracted text, in pieces for search by meaning, as an email's own text is. */
|
|
98
82
|
export const chunkAttachments = (database, accountId, emailId, now) => {
|
|
99
83
|
const email = database
|
|
@@ -125,7 +109,7 @@ export const drainEmailIndex = (database, stemmerFor) => {
|
|
|
125
109
|
.prepare("SELECT id FROM email_threads")
|
|
126
110
|
.all()
|
|
127
111
|
.map(({ id }) => Number(id)));
|
|
128
|
-
|
|
112
|
+
dropUnusedVectors(database, single);
|
|
129
113
|
database
|
|
130
114
|
.prepare("INSERT OR REPLACE INTO store_settings (key, value, at) VALUES (?, ?, ?)")
|
|
131
115
|
.run(MAIL_CUT, CUT, Date.now());
|
|
@@ -146,7 +130,7 @@ export const drainEmailIndex = (database, stemmerFor) => {
|
|
|
146
130
|
for (const row of unchunked) {
|
|
147
131
|
const before = dropAttachmentChunks(database, Number(row.id));
|
|
148
132
|
chunkAttachments(database, Number(row.account_id), Number(row.id), Date.now());
|
|
149
|
-
|
|
133
|
+
dropUnusedVectors(database, before);
|
|
150
134
|
}
|
|
151
135
|
});
|
|
152
136
|
const emails = drainCorpus(database, CORPORA.email, stemmerFor);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { formatLocator } from "../../domain/locator.js";
|
|
2
2
|
import { formatReference } from "../../domain/references.js";
|
|
3
3
|
import { normalize } from "../normalize.js";
|
|
4
|
-
import { chunkAttachments, drainEmailIndex, dropAttachmentChunks, MAIL_PIECES,
|
|
4
|
+
import { chunkAttachments, drainEmailIndex, dropAttachmentChunks, MAIL_PIECES, queueThreads, threadPieces, titleOf, } from "./email-index.js";
|
|
5
5
|
import { recountEmailThread as recount, setEmailFlags } from "./email-state.js";
|
|
6
6
|
import { bool, flag, fromJson, int, page, str, toJson } from "./events.js";
|
|
7
7
|
import { ensurePerson } from "./identities.js";
|
|
@@ -9,6 +9,7 @@ import { mailRulesOver } from "./mail-rules.js";
|
|
|
9
9
|
import { attachmentOf, saveAttachment, wordsQuery } from "./meetings.js";
|
|
10
10
|
import { stemmerCache } from "./stems.js";
|
|
11
11
|
import { write } from "./transaction.js";
|
|
12
|
+
import { dropUnusedVectors } from "./vector-index.js";
|
|
12
13
|
import { dot } from "./vectors.js";
|
|
13
14
|
/** A thread as a conversation: the summary `search conversations` and `related` answer for an email. */
|
|
14
15
|
export const threadConversation = (place, builtAt) => ({
|
|
@@ -234,7 +235,7 @@ const saveEmail = (database, orm, accountId, threadId, mail, now) => {
|
|
|
234
235
|
for (const a of mail.attachments)
|
|
235
236
|
saveAttachment(database, "email", emailId, { ...a, normalizedText: a.text === null ? null : normalize(a.text) }, now);
|
|
236
237
|
chunkAttachments(database, accountId, emailId, now);
|
|
237
|
-
|
|
238
|
+
dropUnusedVectors(database, before);
|
|
238
239
|
}
|
|
239
240
|
return found ? Number(found.email_thread_id) : null;
|
|
240
241
|
};
|
|
@@ -547,7 +548,7 @@ export const mailStoreOver = ({ database, orm }, stemmerFor = stemmerCache()) =>
|
|
|
547
548
|
const threads = externalIds.flatMap((id) => mark.all(now, now, accountId, id).map((r) => {
|
|
548
549
|
const pieces = dropAttachmentChunks(database, Number(r.id));
|
|
549
550
|
forget.run(Number(r.id));
|
|
550
|
-
|
|
551
|
+
dropUnusedVectors(database, pieces);
|
|
551
552
|
return Number(r.email_thread_id);
|
|
552
553
|
}));
|
|
553
554
|
for (const id of new Set(threads))
|
|
@@ -34,7 +34,7 @@ export interface ImageChunk {
|
|
|
34
34
|
* Makes these the chunks of their images, and drops the chunks of images that are gone or whose message was
|
|
35
35
|
* deleted, with the vectors nothing else uses any more.
|
|
36
36
|
*/
|
|
37
|
-
export declare const saveImageChunks: ({ orm, now }: StoreContext, accountPk: number, chatKey: number, chunks: ImageChunk[], keep: number[]) => void;
|
|
37
|
+
export declare const saveImageChunks: ({ orm, now, database }: StoreContext, accountPk: number, chatKey: number, chunks: ImageChunk[], keep: number[]) => void;
|
|
38
38
|
/** The hashes among these that already have a vector of `model`. */
|
|
39
39
|
export declare const embeddedHashes: ({ orm }: StoreContext, model: string, hashes: string[]) => Set<string>;
|
|
40
40
|
export interface NearestImage {
|
|
@@ -75,7 +75,7 @@ export declare const picturesToEmbed: ({ orm }: StoreContext, accountPk: number,
|
|
|
75
75
|
limit: number;
|
|
76
76
|
}) => PictureInput[];
|
|
77
77
|
/** Makes each hash the picture chunk of its image; the vectors are saved apart, by `saveVectors`. */
|
|
78
|
-
export declare const savePictureChunks: ({ orm, now }: StoreContext, accountPk: number, pictures: {
|
|
78
|
+
export declare const savePictureChunks: ({ orm, now, database }: StoreContext, accountPk: number, pictures: {
|
|
79
79
|
attachmentPk: number;
|
|
80
80
|
hash: string;
|
|
81
81
|
bytes: number;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sql } from "./drizzle/core.js";
|
|
2
|
-
import { searchIndex } from "./vector-index.js";
|
|
2
|
+
import { dropUnusedVectors, searchIndex } from "./vector-index.js";
|
|
3
3
|
import { dot } from "./vectors.js";
|
|
4
4
|
const IS_IMAGE = sql `(a.kind = 'photo' OR a.mime LIKE 'image/%')`;
|
|
5
5
|
const neighbours = ({ orm }, chatKey, messagePk, sentAt, { messages, withinMs }, side) => orm.all(sql `SELECT sender_name AS sender, text, sent_at AS sentAt FROM messages
|
|
@@ -27,7 +27,7 @@ export const imagesOfChat = (context, chatKey, window) => context.orm
|
|
|
27
27
|
* Makes these the chunks of their images, and drops the chunks of images that are gone or whose message was
|
|
28
28
|
* deleted, with the vectors nothing else uses any more.
|
|
29
29
|
*/
|
|
30
|
-
export const saveImageChunks = ({ orm, now }, accountPk, chatKey, chunks, keep) => {
|
|
30
|
+
export const saveImageChunks = ({ orm, now, database }, accountPk, chatKey, chunks, keep) => {
|
|
31
31
|
const at = now();
|
|
32
32
|
const scope = orm.get(sql `SELECT scope FROM accounts WHERE id = ${accountPk}`)?.scope ?? null;
|
|
33
33
|
const old = new Set();
|
|
@@ -51,9 +51,7 @@ export const saveImageChunks = ({ orm, now }, accountPk, chatKey, chunks, keep)
|
|
|
51
51
|
account_id, occurred_at, created_at, updated_at)
|
|
52
52
|
VALUES ('image', ${attachmentPk}, 0, 0, ${length}, ${hash}, ${scope}, ${accountPk}, ${occurredAt}, ${at}, ${at})`);
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
orm.run(sql `DELETE FROM embeddings WHERE content_hash = ${hash}
|
|
56
|
-
AND NOT EXISTS (SELECT 1 FROM chunks WHERE content_hash = ${hash})`);
|
|
54
|
+
dropUnusedVectors(database, old);
|
|
57
55
|
};
|
|
58
56
|
/** The hashes among these that already have a vector of `model`. */
|
|
59
57
|
export const embeddedHashes = ({ orm }, model, hashes) => new Set(hashes.length === 0
|
|
@@ -135,24 +133,23 @@ export const picturesToEmbed = ({ orm }, accountPk, { chatKey, after, limit }) =
|
|
|
135
133
|
AND k.position = ${PICTURE_POSITION})
|
|
136
134
|
ORDER BY a.id LIMIT ${limit}`);
|
|
137
135
|
/** Makes each hash the picture chunk of its image; the vectors are saved apart, by `saveVectors`. */
|
|
138
|
-
export const savePictureChunks = ({ orm, now }, accountPk, pictures) => {
|
|
136
|
+
export const savePictureChunks = ({ orm, now, database }, accountPk, pictures) => {
|
|
139
137
|
const at = now();
|
|
140
138
|
const scope = orm.get(sql `SELECT scope FROM accounts WHERE id = ${accountPk}`)?.scope ?? null;
|
|
141
139
|
// A deleted message keeps no trace of its picture, as it keeps none of its text.
|
|
142
140
|
const gone = sql `chunkable_type = 'image' AND position = ${PICTURE_POSITION} AND account_id = ${accountPk}
|
|
143
141
|
AND NOT EXISTS (SELECT 1 FROM attachments a JOIN messages m ON a.attachable_type = 'message'
|
|
144
142
|
AND m.id = a.attachable_id WHERE a.id = chunkable_id AND m.deleted_at IS NULL)`;
|
|
145
|
-
const
|
|
146
|
-
orm.run(sql `DELETE FROM chunks WHERE ${gone}`);
|
|
147
|
-
for (const { hash } of dropped)
|
|
148
|
-
orm.run(sql `DELETE FROM embeddings WHERE content_hash = ${hash}
|
|
149
|
-
AND NOT EXISTS (SELECT 1 FROM chunks WHERE content_hash = ${hash})`);
|
|
143
|
+
const released = orm.all(sql `DELETE FROM chunks WHERE ${gone} RETURNING content_hash AS hash`);
|
|
150
144
|
for (const { attachmentPk, hash, bytes, occurredAt } of pictures) {
|
|
151
|
-
orm.
|
|
152
|
-
AND position = ${PICTURE_POSITION}`);
|
|
145
|
+
const replaced = orm.get(sql `DELETE FROM chunks WHERE chunkable_type = 'image' AND chunkable_id = ${attachmentPk}
|
|
146
|
+
AND position = ${PICTURE_POSITION} RETURNING content_hash AS hash`);
|
|
147
|
+
if (replaced)
|
|
148
|
+
released.push(replaced);
|
|
153
149
|
orm.run(sql `INSERT INTO chunks (chunkable_type, chunkable_id, position, start_offset, end_offset, content_hash, scope,
|
|
154
150
|
account_id, occurred_at, created_at, updated_at)
|
|
155
151
|
VALUES ('image', ${attachmentPk}, ${PICTURE_POSITION}, 0, ${bytes}, ${hash}, ${scope}, ${accountPk},
|
|
156
152
|
${occurredAt}, ${at}, ${at})`);
|
|
157
153
|
}
|
|
154
|
+
dropUnusedVectors(database, released.map(({ hash }) => hash));
|
|
158
155
|
};
|
|
@@ -3,6 +3,7 @@ import { chunkHash, cutChunks } from "../../conversations/chunks.js";
|
|
|
3
3
|
import { positiveReadInteger } from "./meeting-reads.js";
|
|
4
4
|
import { meetingSelectionSql } from "./meeting-selection.js";
|
|
5
5
|
import { read, write } from "./transaction.js";
|
|
6
|
+
import { dropUnusedVectors } from "./vector-index.js";
|
|
6
7
|
import { dot, saveVectors } from "./vectors.js";
|
|
7
8
|
/** Vectors read per query while ranking, so a store of any size stays within a few megabytes at a time. */
|
|
8
9
|
const RANK_BATCH = 512;
|
|
@@ -129,11 +130,13 @@ export const meetingVectorsOver = (context) => ({
|
|
|
129
130
|
text: load(Number(row.id)),
|
|
130
131
|
}));
|
|
131
132
|
let count = 0;
|
|
133
|
+
const released = [];
|
|
132
134
|
for (const { row, text } of prepared) {
|
|
133
135
|
input.signal?.throwIfAborted();
|
|
134
|
-
context.database
|
|
135
|
-
.prepare("DELETE FROM chunks WHERE chunkable_type='meeting_transcript' AND chunkable_id=?")
|
|
136
|
-
.
|
|
136
|
+
for (const { hash } of context.database
|
|
137
|
+
.prepare("DELETE FROM chunks WHERE chunkable_type='meeting_transcript' AND chunkable_id=? RETURNING content_hash AS hash")
|
|
138
|
+
.all(Number(row.id)))
|
|
139
|
+
released.push(String(hash));
|
|
137
140
|
const chunks = cutChunks([{ id: String(row.id), sender: null, text: text.text }], undefined, () => input.signal?.throwIfAborted());
|
|
138
141
|
for (let index = 0; index < chunks.length; index++) {
|
|
139
142
|
const chunk = chunks[index];
|
|
@@ -145,12 +148,11 @@ export const meetingVectorsOver = (context) => ({
|
|
|
145
148
|
count++;
|
|
146
149
|
}
|
|
147
150
|
}
|
|
148
|
-
context.database
|
|
149
|
-
.prepare("DELETE FROM
|
|
150
|
-
.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
.run(input.accountId, input.accountId);
|
|
151
|
+
for (const { hash } of context.database
|
|
152
|
+
.prepare("DELETE FROM chunks WHERE chunkable_type='meeting_transcript' AND account_id=? AND NOT EXISTS (SELECT 1 FROM meeting_transcripts t JOIN meetings m ON m.id=t.meeting_id WHERE t.id=chunks.chunkable_id AND t.deleted_at IS NULL AND t.superseded_at IS NULL AND m.deleted_at IS NULL AND m.account_id=?) RETURNING content_hash AS hash")
|
|
153
|
+
.all(input.accountId, input.accountId))
|
|
154
|
+
released.push(String(hash));
|
|
155
|
+
dropUnusedVectors(context.database, released);
|
|
154
156
|
input.signal?.throwIfAborted();
|
|
155
157
|
const hasMore = found.length > limit;
|
|
156
158
|
return {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sql } from "./drizzle/core.js";
|
|
2
|
-
import { searchIndex } from "./vector-index.js";
|
|
2
|
+
import { dropUnusedVectors, searchIndex } from "./vector-index.js";
|
|
3
3
|
import { dot } from "./vectors.js";
|
|
4
4
|
/** Live messages with text of the account, by pk after `after`; the caller compares hashes. */
|
|
5
5
|
export const messagesToEmbed = ({ orm }, accountPk, { chatKey, model, after, limit }) => orm
|
|
@@ -11,11 +11,6 @@ export const messagesToEmbed = ({ orm }, accountPk, { chatKey, model, after, lim
|
|
|
11
11
|
${chatKey === undefined ? sql `` : sql `AND m.chat_id = ${chatKey}`}
|
|
12
12
|
ORDER BY m.id LIMIT ${limit}`)
|
|
13
13
|
.map((row) => ({ ...row, embedded: row.embedded === 1 }));
|
|
14
|
-
const dropUnshared = ({ orm }, hashes) => {
|
|
15
|
-
for (const hash of hashes)
|
|
16
|
-
orm.run(sql `DELETE FROM embeddings WHERE content_hash = ${hash}
|
|
17
|
-
AND NOT EXISTS (SELECT 1 FROM chunks WHERE content_hash = ${hash})`);
|
|
18
|
-
};
|
|
19
14
|
/** Makes each hash its message's chunk; the vectors are saved apart, by `saveVectors`. */
|
|
20
15
|
export const saveMessageChunks = (context, accountPk, chunks) => {
|
|
21
16
|
const { orm, now } = context;
|
|
@@ -34,7 +29,7 @@ export const saveMessageChunks = (context, accountPk, chunks) => {
|
|
|
34
29
|
account_id, occurred_at, created_at, updated_at)
|
|
35
30
|
VALUES ('message', ${messagePk}, 0, 0, ${length}, ${hash}, ${scope}, ${accountPk}, ${occurredAt}, ${at}, ${at})`);
|
|
36
31
|
}
|
|
37
|
-
|
|
32
|
+
dropUnusedVectors(context.database, old);
|
|
38
33
|
};
|
|
39
34
|
/**
|
|
40
35
|
* Drops the chunks of the account's messages that are gone, deleted or emptied, with the vectors nothing else
|
|
@@ -45,7 +40,7 @@ export const dropGoneMessageChunks = (context, accountPk) => {
|
|
|
45
40
|
WHERE m.id = chunkable_id AND m.deleted_at IS NULL AND trim(m.text) <> '')`;
|
|
46
41
|
const dropped = context.orm.all(sql `SELECT content_hash AS hash FROM chunks WHERE ${gone}`);
|
|
47
42
|
context.orm.run(sql `DELETE FROM chunks WHERE ${gone}`);
|
|
48
|
-
|
|
43
|
+
dropUnusedVectors(context.database, dropped.map(({ hash }) => hash));
|
|
49
44
|
return dropped.length;
|
|
50
45
|
};
|
|
51
46
|
/** Whether any message of the account has a vector of `model`. */
|