@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/dist/cli/messenger/context.js +1 -0
  3. package/dist/cli/messenger/conversation-answers.d.ts +1 -0
  4. package/dist/cli/messenger/conversations-command.js +2 -2
  5. package/dist/cli/messenger/message-answers.d.ts +1 -0
  6. package/dist/cli/messenger/message-writes.d.ts +2 -0
  7. package/dist/cli/messenger/messages-search-command.js +6 -10
  8. package/dist/cli/messenger/models-command.js +34 -7
  9. package/dist/cli/messenger/search-answers.d.ts +3 -0
  10. package/dist/cli/messenger/search-answers.js +40 -8
  11. package/dist/cli/messenger/search-command.js +3 -1
  12. package/dist/cli/messenger/store-maintenance-command.js +11 -5
  13. package/dist/cli/model-catalogue-command.js +1 -1
  14. package/dist/cli/settings.d.ts +4 -0
  15. package/dist/cli/settings.js +5 -0
  16. package/dist/cli/shared-command-exports.d.ts +4 -0
  17. package/dist/embeddings/child.js +15 -5
  18. package/dist/embeddings/embed.d.ts +16 -2
  19. package/dist/embeddings/embed.js +57 -28
  20. package/dist/embeddings/models.d.ts +18 -0
  21. package/dist/embeddings/models.js +69 -0
  22. package/dist/embeddings/process.d.ts +6 -1
  23. package/dist/embeddings/process.js +23 -7
  24. package/dist/embeddings/rerank.d.ts +10 -0
  25. package/dist/embeddings/rerank.js +31 -0
  26. package/dist/entities/conversation-results.d.ts +2 -0
  27. package/dist/entities/evidence.d.ts +8 -0
  28. package/dist/entities/index.d.ts +37 -4
  29. package/dist/entities/listener.d.ts +4 -0
  30. package/dist/entities/message-context.d.ts +3 -0
  31. package/dist/entities/message-write.d.ts +4 -0
  32. package/dist/entities/message.d.ts +2 -0
  33. package/dist/entities/message.js +2 -0
  34. package/dist/entities/model.d.ts +5 -2
  35. package/dist/entities/model.js +5 -2
  36. package/dist/entities/store-maintenance.d.ts +11 -3
  37. package/dist/entities/store-maintenance.js +18 -2
  38. package/dist/entities/store-records.d.ts +1 -0
  39. package/dist/errors/reasons.js +7 -0
  40. package/dist/mcp/tool.d.ts +1 -1
  41. package/dist/mcp/tool.js +1 -0
  42. package/dist/services/deps.d.ts +3 -0
  43. package/dist/services/embeddings-maintenance.d.ts +10 -2
  44. package/dist/services/index.d.ts +2 -0
  45. package/dist/services/index.js +1 -0
  46. package/dist/services/messages-archive-search.d.ts +2 -2
  47. package/dist/services/messages-archive-search.js +4 -4
  48. package/dist/services/messages-discovery.d.ts +33 -4
  49. package/dist/services/messages-discovery.js +88 -3
  50. package/dist/services/messages-meaning.d.ts +2 -1
  51. package/dist/services/messages-meaning.js +7 -3
  52. package/dist/services/messages-rerank.d.ts +7 -0
  53. package/dist/services/messages-rerank.js +42 -0
  54. package/dist/services/messages-search.d.ts +5 -0
  55. package/dist/services/messages.d.ts +6 -2
  56. package/dist/services/messages.js +4 -3
  57. package/dist/services/model-downloads.d.ts +3 -1
  58. package/dist/services/model-downloads.js +20 -1
  59. package/dist/services/notes-search.d.ts +7 -2
  60. package/dist/services/notes-search.js +7 -4
  61. package/dist/services/search-all-meetings.d.ts +3 -2
  62. package/dist/services/search-all-meetings.js +8 -8
  63. package/dist/services/search-all.d.ts +7 -1
  64. package/dist/services/search-all.js +109 -45
  65. package/dist/store/drivers/bun-sqlite.d.ts +6 -0
  66. package/dist/store/drivers/bun-sqlite.js +16 -1
  67. package/dist/store/drivers/node-sqlite.d.ts +5 -1
  68. package/dist/store/drivers/node-sqlite.js +15 -1
  69. package/dist/store/exports.d.ts +1 -0
  70. package/dist/store/exports.js +1 -0
  71. package/dist/store/sqlite/accounts.js +2 -2
  72. package/dist/store/sqlite/chats.d.ts +1 -1
  73. package/dist/store/sqlite/chats.js +11 -4
  74. package/dist/store/sqlite/document-pictures.js +5 -7
  75. package/dist/store/sqlite/email-index.d.ts +1 -3
  76. package/dist/store/sqlite/email-index.js +5 -21
  77. package/dist/store/sqlite/emails.js +4 -3
  78. package/dist/store/sqlite/image-chunks.d.ts +3 -3
  79. package/dist/store/sqlite/image-chunks.js +55 -25
  80. package/dist/store/sqlite/manifest.js +2 -0
  81. package/dist/store/sqlite/meeting-vectors.js +11 -9
  82. package/dist/store/sqlite/message-chunks.d.ts +5 -2
  83. package/dist/store/sqlite/message-chunks.js +44 -20
  84. package/dist/store/sqlite/migrations.generated.js +22 -0
  85. package/dist/store/sqlite/note-index.js +2 -7
  86. package/dist/store/sqlite/open-store.js +4 -0
  87. package/dist/store/sqlite/open.js +4 -6
  88. package/dist/store/sqlite/schema.d.ts +118 -1
  89. package/dist/store/sqlite/schema.js +18 -1
  90. package/dist/store/sqlite/vector-index.d.ts +76 -0
  91. package/dist/store/sqlite/vector-index.js +205 -0
  92. package/dist/store/sqlite/vectors.d.ts +8 -5
  93. package/dist/store/sqlite/vectors.js +82 -39
  94. package/dist/store/vector-extension.d.ts +10 -0
  95. package/dist/store/vector-extension.js +53 -0
  96. 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
- return found.items.map((hit) => {
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: ["words"],
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
- for (const resource of only) {
82
- try {
83
- if (resource === "notes") {
84
- const chosen = request.scope?.notes;
85
- const found = await searchNotes(store, request.text, {
86
- limit: window,
87
- command,
88
- ...(chosen === undefined ? {} : notesScopeOf(chosen)),
89
- ...(request.exact ? { exact: true } : {}),
90
- ...(request.timezone === undefined ? {} : { timezone: request.timezone }),
91
- ...(request.env === undefined ? {} : { env: request.env }),
92
- });
93
- hasMore ||= found.has_more;
94
- notes = {
95
- by: found.by,
96
- ...(found.meaning_skipped === undefined ? {} : { meaningSkipped: found.meaning_skipped }),
97
- };
98
- lists.push(found.hits.map((hit) => ({
99
- kind: hit.kind ?? "note",
100
- ref: hit.ref,
101
- provider: hit.provider ?? "notes",
102
- account: hit.account ?? hit.folder_id ?? INTERNAL_NOTES,
103
- ...(hit.url ? { url: hit.url } : {}),
104
- title: hit.title,
105
- timestamp: hit.updated_at,
106
- text: hit.excerpt,
107
- foundBy: hit.found_by,
108
- })));
109
- const read = chosen?.map(({ account }) => account) ?? [
110
- INTERNAL_NOTES,
111
- ...(await store.notes.folders()).map(({ id }) => id),
112
- ...(await store.notes.documentsAccounts()).map(({ id }) => id),
113
- ];
114
- accounts.push(...read.map((account) => ({ resource, provider: "notes", account })));
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(new (await bunDatabase())(path));
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(new DatabaseSync(path));
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),
@@ -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";
@@ -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
- orm.run(sql `DELETE FROM embeddings WHERE content_hash IN (SELECT value FROM json_each(${JSON.stringify(hashes)}))
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
- orm
274
+ const chatPks = sql `(SELECT value FROM json_each(${JSON.stringify(pks)}))`;
275
+ const released = orm
274
276
  .delete(chunks)
275
- .where(sql `${chunks.chunkableType}='conversation' AND ${chunks.chunkableId} IN (SELECT id FROM conversations WHERE chat_id IN (SELECT value FROM json_each(${JSON.stringify(pks)})))`)
276
- .run();
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
- purge(database, hashes);
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(vector.buffer, vector.byteOffset, vector.byteLength), at, at);
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 `purgeUnused` once new ones are in. */
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
- purgeUnused(database, before);
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 `purgeUnused` once new ones are in. */
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
- purgeUnused(database, single);
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
- purgeUnused(database, before);
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, purgeUnused, queueThreads, threadPieces, titleOf, } from "./email-index.js";
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
- purgeUnused(database, before);
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
- purgeUnused(database, pieces);
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: ({ orm }: StoreContext, accountPk: number, { chatKey, model, since, limit, query, position, }: {
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;