@wirecat/cli-core 0.434.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 (84) hide show
  1. package/CHANGELOG.md +35 -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 +7 -4
  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 +27 -1
  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 +1 -0
  37. package/dist/entities/store-maintenance.js +8 -1
  38. package/dist/entities/store-records.d.ts +1 -0
  39. package/dist/mcp/tool.d.ts +1 -1
  40. package/dist/mcp/tool.js +1 -0
  41. package/dist/services/deps.d.ts +3 -0
  42. package/dist/services/embeddings-maintenance.d.ts +10 -2
  43. package/dist/services/index.d.ts +2 -0
  44. package/dist/services/index.js +1 -0
  45. package/dist/services/messages-archive-search.d.ts +2 -2
  46. package/dist/services/messages-archive-search.js +4 -4
  47. package/dist/services/messages-discovery.d.ts +33 -4
  48. package/dist/services/messages-discovery.js +88 -3
  49. package/dist/services/messages-meaning.d.ts +2 -1
  50. package/dist/services/messages-meaning.js +7 -3
  51. package/dist/services/messages-rerank.d.ts +7 -0
  52. package/dist/services/messages-rerank.js +42 -0
  53. package/dist/services/messages-search.d.ts +5 -0
  54. package/dist/services/messages.d.ts +6 -2
  55. package/dist/services/messages.js +4 -3
  56. package/dist/services/model-downloads.d.ts +3 -1
  57. package/dist/services/model-downloads.js +20 -1
  58. package/dist/services/notes-search.d.ts +7 -2
  59. package/dist/services/notes-search.js +7 -4
  60. package/dist/services/search-all-meetings.d.ts +3 -2
  61. package/dist/services/search-all-meetings.js +8 -8
  62. package/dist/services/search-all.d.ts +7 -1
  63. package/dist/services/search-all.js +109 -45
  64. package/dist/store/exports.d.ts +1 -1
  65. package/dist/store/exports.js +1 -1
  66. package/dist/store/sqlite/accounts.js +2 -2
  67. package/dist/store/sqlite/chats.d.ts +1 -1
  68. package/dist/store/sqlite/chats.js +11 -4
  69. package/dist/store/sqlite/document-pictures.js +2 -7
  70. package/dist/store/sqlite/email-index.d.ts +1 -3
  71. package/dist/store/sqlite/email-index.js +5 -21
  72. package/dist/store/sqlite/emails.js +4 -3
  73. package/dist/store/sqlite/image-chunks.d.ts +2 -2
  74. package/dist/store/sqlite/image-chunks.js +10 -13
  75. package/dist/store/sqlite/meeting-vectors.js +11 -9
  76. package/dist/store/sqlite/message-chunks.js +3 -8
  77. package/dist/store/sqlite/note-index.js +2 -7
  78. package/dist/store/sqlite/schema.d.ts +1 -1
  79. package/dist/store/sqlite/schema.js +1 -1
  80. package/dist/store/sqlite/vector-index.d.ts +9 -0
  81. package/dist/store/sqlite/vector-index.js +25 -0
  82. package/dist/store/sqlite/vectors.d.ts +2 -2
  83. package/dist/store/sqlite/vectors.js +11 -8
  84. 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
- 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
  };
@@ -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";
@@ -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
- 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,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
- purge(database, hashes);
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 `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 {
@@ -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
- for (const hash of old)
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 dropped = orm.all(sql `SELECT content_hash AS hash FROM chunks WHERE ${gone}`);
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.run(sql `DELETE FROM chunks WHERE chunkable_type = 'image' AND chunkable_id = ${attachmentPk}
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
- .run(Number(row.id));
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 embeddings WHERE EXISTS (SELECT 1 FROM chunks k WHERE k.content_hash=embeddings.content_hash AND k.chunkable_type='meeting_transcript' AND k.account_id=? AND NOT EXISTS (SELECT 1 FROM meeting_transcripts t JOIN meetings m ON m.id=t.meeting_id WHERE t.id=k.chunkable_id AND t.deleted_at IS NULL AND t.superseded_at IS NULL AND m.deleted_at IS NULL AND m.account_id=?)) AND NOT EXISTS (SELECT 1 FROM chunks k WHERE k.content_hash=embeddings.content_hash AND (k.chunkable_type <> 'meeting_transcript' OR k.account_id IS NOT ? OR EXISTS (SELECT 1 FROM meeting_transcripts t JOIN meetings m ON m.id=t.meeting_id WHERE t.id=k.chunkable_id AND t.deleted_at IS NULL AND t.superseded_at IS NULL AND m.deleted_at IS NULL AND m.account_id=?)))")
150
- .run(input.accountId, input.accountId, input.accountId, input.accountId);
151
- 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=?)")
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
- dropUnshared(context, old);
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
- dropUnshared(context, dropped.map(({ hash }) => hash));
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`. */