@wirecat/cli-core 0.433.0 → 0.435.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 -1
- 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 +11 -5
- package/dist/cli/model-catalogue-command.js +1 -1
- 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 +37 -4
- 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 +11 -3
- package/dist/entities/store-maintenance.js +18 -2
- package/dist/entities/store-records.d.ts +1 -0
- package/dist/errors/reasons.js +7 -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/drivers/bun-sqlite.d.ts +6 -0
- package/dist/store/drivers/bun-sqlite.js +16 -1
- package/dist/store/drivers/node-sqlite.d.ts +5 -1
- package/dist/store/drivers/node-sqlite.js +15 -1
- package/dist/store/exports.d.ts +1 -0
- package/dist/store/exports.js +1 -0
- 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 +5 -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 +3 -3
- package/dist/store/sqlite/image-chunks.js +55 -25
- package/dist/store/sqlite/manifest.js +2 -0
- package/dist/store/sqlite/meeting-vectors.js +11 -9
- package/dist/store/sqlite/message-chunks.d.ts +5 -2
- package/dist/store/sqlite/message-chunks.js +44 -20
- package/dist/store/sqlite/migrations.generated.js +22 -0
- package/dist/store/sqlite/note-index.js +2 -7
- package/dist/store/sqlite/open-store.js +4 -0
- package/dist/store/sqlite/open.js +4 -6
- package/dist/store/sqlite/schema.d.ts +118 -1
- package/dist/store/sqlite/schema.js +18 -1
- package/dist/store/sqlite/vector-index.d.ts +76 -0
- package/dist/store/sqlite/vector-index.js +205 -0
- package/dist/store/sqlite/vectors.d.ts +8 -5
- package/dist/store/sqlite/vectors.js +82 -39
- package/dist/store/vector-extension.d.ts +10 -0
- package/dist/store/vector-extension.js +53 -0
- package/package.json +2 -2
|
@@ -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
|
};
|
|
@@ -9,6 +9,7 @@ interface BunStatement {
|
|
|
9
9
|
export interface BunDatabase {
|
|
10
10
|
exec(sql: string): void;
|
|
11
11
|
query(sql: string): BunStatement;
|
|
12
|
+
loadExtension(path: string): void;
|
|
12
13
|
close(): void;
|
|
13
14
|
}
|
|
14
15
|
export interface BunDatabaseClass {
|
|
@@ -25,5 +26,10 @@ export interface BunDatabaseClass {
|
|
|
25
26
|
*/
|
|
26
27
|
export declare const bunDatabase: () => Promise<BunDatabaseClass>;
|
|
27
28
|
export declare const openWithBunSqlite: (path: string) => Promise<CacheDatabase>;
|
|
29
|
+
/** A connection with the vector extension loaded; it closes again when the extension does not load. */
|
|
30
|
+
export declare const bunClient: (path: string, options?: {
|
|
31
|
+
readonly?: boolean;
|
|
32
|
+
create?: boolean;
|
|
33
|
+
}) => Promise<BunDatabase>;
|
|
28
34
|
export declare const cacheOverBunSqlite: (database: BunDatabase) => CacheDatabase;
|
|
29
35
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loadVectorExtension, vectorExtension } from "../vector-extension.js";
|
|
1
2
|
let customised = false;
|
|
2
3
|
/**
|
|
3
4
|
* Bun on macOS takes the system's SQLite, which can be older than the store needs, so it always
|
|
@@ -29,7 +30,21 @@ export const bunDatabase = async () => {
|
|
|
29
30
|
}
|
|
30
31
|
return Database;
|
|
31
32
|
};
|
|
32
|
-
export const openWithBunSqlite = async (path) => cacheOverBunSqlite(
|
|
33
|
+
export const openWithBunSqlite = async (path) => cacheOverBunSqlite(await bunClient(path));
|
|
34
|
+
/** A connection with the vector extension loaded; it closes again when the extension does not load. */
|
|
35
|
+
export const bunClient = async (path, options) => {
|
|
36
|
+
const Database = await bunDatabase();
|
|
37
|
+
const extension = await vectorExtension();
|
|
38
|
+
const client = options ? new Database(path, options) : new Database(path);
|
|
39
|
+
try {
|
|
40
|
+
loadVectorExtension(client, extension);
|
|
41
|
+
return client;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
client.close();
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
33
48
|
export const cacheOverBunSqlite = (database) => {
|
|
34
49
|
return {
|
|
35
50
|
exec: (sql) => database.exec(sql),
|
|
@@ -4,5 +4,9 @@ import type { CacheDatabase } from "../driver.js";
|
|
|
4
4
|
* Import this module only through a dynamic import that follows `quietSqliteWarning()`: a static import loads
|
|
5
5
|
* `node:sqlite`, and on Node 22 its ExperimentalWarning, before the filter can run.
|
|
6
6
|
*/
|
|
7
|
-
export declare const openWithNodeSqlite: (path: string) => CacheDatabase
|
|
7
|
+
export declare const openWithNodeSqlite: (path: string) => Promise<CacheDatabase>;
|
|
8
|
+
/** A connection with the vector extension loaded; it closes again when the extension does not load. */
|
|
9
|
+
export declare const nodeClient: (path: string, { readOnly }?: {
|
|
10
|
+
readOnly?: boolean;
|
|
11
|
+
}) => Promise<DatabaseSync>;
|
|
8
12
|
export declare const cacheOverNodeSqlite: (database: DatabaseSync) => CacheDatabase;
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
// biome-ignore-all lint/plugin/no-sqlite-driver: cli-core owns the store, so it opens the driver itself
|
|
2
2
|
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import { loadVectorExtension, vectorExtension } from "../vector-extension.js";
|
|
3
4
|
/**
|
|
4
5
|
* Import this module only through a dynamic import that follows `quietSqliteWarning()`: a static import loads
|
|
5
6
|
* `node:sqlite`, and on Node 22 its ExperimentalWarning, before the filter can run.
|
|
6
7
|
*/
|
|
7
|
-
export const openWithNodeSqlite = (path) => cacheOverNodeSqlite(
|
|
8
|
+
export const openWithNodeSqlite = async (path) => cacheOverNodeSqlite(await nodeClient(path));
|
|
9
|
+
/** A connection with the vector extension loaded; it closes again when the extension does not load. */
|
|
10
|
+
export const nodeClient = async (path, { readOnly = false } = {}) => {
|
|
11
|
+
const extension = await vectorExtension();
|
|
12
|
+
const client = new DatabaseSync(path, { readOnly, allowExtension: true });
|
|
13
|
+
try {
|
|
14
|
+
loadVectorExtension(client, extension);
|
|
15
|
+
return client;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
client.close();
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
8
22
|
export const cacheOverNodeSqlite = (database) => {
|
|
9
23
|
return {
|
|
10
24
|
exec: (sql) => database.exec(sql),
|
package/dist/store/exports.d.ts
CHANGED
|
@@ -54,6 +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 { dropAllUnusedVectors, vectorIndexCounts } from "./sqlite/vector-index.js";
|
|
57
58
|
export type { MatchPlace, ScoredHit } from "./sqlite/words.js";
|
|
58
59
|
export type { TaskDetails } from "./sqlite/work-tasks.js";
|
|
59
60
|
export type { SyncStore } from "./sync.js";
|
package/dist/store/exports.js
CHANGED
|
@@ -24,3 +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 { 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,5 +1,6 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { write } from "./transaction.js";
|
|
3
|
+
import { dropUnusedVectors, ensureVectorIndex, unitVector } from "./vector-index.js";
|
|
3
4
|
import { dot } from "./vectors.js";
|
|
4
5
|
/** A document's picture is one chunk of this type at position 0; its vector is the picture's, not text's. */
|
|
5
6
|
export const DOCUMENT_PICTURE = "document_picture";
|
|
@@ -10,11 +11,6 @@ const idOf = (ref) => {
|
|
|
10
11
|
throw new CliError("validation_error", `${ref} is not a document reference (document:<id>)`);
|
|
11
12
|
return Number(match[1]);
|
|
12
13
|
};
|
|
13
|
-
const purge = (database, hashes) => {
|
|
14
|
-
const drop = database.prepare("DELETE FROM embeddings WHERE content_hash = ? AND NOT EXISTS (SELECT 1 FROM chunks WHERE content_hash = ?)");
|
|
15
|
-
for (const hash of hashes)
|
|
16
|
-
drop.run(hash, hash);
|
|
17
|
-
};
|
|
18
14
|
/** Drops a document's picture chunk, and its vector when no other chunk shares the bytes. */
|
|
19
15
|
export const dropDocumentPicture = (database, documentId) => {
|
|
20
16
|
const hashes = database
|
|
@@ -22,7 +18,7 @@ export const dropDocumentPicture = (database, documentId) => {
|
|
|
22
18
|
.all(DOCUMENT_PICTURE, documentId)
|
|
23
19
|
.map((row) => String(row.hash));
|
|
24
20
|
database.prepare("DELETE FROM chunks WHERE chunkable_type = ? AND chunkable_id = ?").run(DOCUMENT_PICTURE, documentId);
|
|
25
|
-
|
|
21
|
+
dropUnusedVectors(database, hashes);
|
|
26
22
|
return hashes.length > 0;
|
|
27
23
|
};
|
|
28
24
|
export const documentPicturesOver = ({ database, now }) => ({
|
|
@@ -44,10 +40,12 @@ export const documentPicturesOver = ({ database, now }) => ({
|
|
|
44
40
|
throw new CliError("not_found", `no document ${ref} in the local store`);
|
|
45
41
|
const at = now();
|
|
46
42
|
dropDocumentPicture(database, id);
|
|
43
|
+
ensureVectorIndex(database, model, dims);
|
|
44
|
+
const unit = unitVector(vector);
|
|
47
45
|
database
|
|
48
46
|
.prepare(`INSERT INTO embeddings (model, content_hash, dims, vector, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
|
|
49
47
|
ON CONFLICT DO NOTHING`)
|
|
50
|
-
.run(model, hash, dims, Buffer.from(
|
|
48
|
+
.run(model, hash, dims, Buffer.from(unit.buffer, unit.byteOffset, unit.byteLength), at, at);
|
|
51
49
|
database
|
|
52
50
|
.prepare(`INSERT INTO chunks (chunkable_type, chunkable_id, position, start_offset, end_offset, content_hash, scope,
|
|
53
51
|
account_id, occurred_at, created_at, updated_at) VALUES (?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?)`)
|
|
@@ -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 {
|
|
@@ -46,7 +46,7 @@ export interface NearestImage {
|
|
|
46
46
|
* The images of the account nearest to `query` by the vector of `model`, best first. Position 0 of an image is
|
|
47
47
|
* its context text, position 1 its picture (`PICTURE_POSITION`): a model's vectors only ever sit at one.
|
|
48
48
|
*/
|
|
49
|
-
export declare const nearestImages: (
|
|
49
|
+
export declare const nearestImages: (context: StoreContext, accountPk: number, { chatKey, model, since, limit, query, position, }: {
|
|
50
50
|
chatKey?: number;
|
|
51
51
|
model: string;
|
|
52
52
|
since?: number;
|
|
@@ -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;
|