@wirecat/cli-core 0.431.0 → 0.432.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 (39) hide show
  1. package/CHANGELOG.md +6 -1
  2. package/dist/cli/messenger/conversation-answers.d.ts +1 -0
  3. package/dist/cli/messenger/conversations-command.js +11 -3
  4. package/dist/cli/messenger/message-answers.d.ts +1 -0
  5. package/dist/cli/messenger/message-writes.d.ts +2 -0
  6. package/dist/cli/messenger/messages-search-command.js +11 -1
  7. package/dist/cli/messenger/search-answers.js +13 -0
  8. package/dist/cli/steps.js +6 -0
  9. package/dist/entities/conversation-preparation.d.ts +10 -0
  10. package/dist/entities/conversation-preparation.js +2 -0
  11. package/dist/entities/conversation-results.d.ts +10 -0
  12. package/dist/entities/evidence.d.ts +8 -0
  13. package/dist/entities/index.d.ts +45 -0
  14. package/dist/entities/listener.d.ts +4 -0
  15. package/dist/entities/message-context.d.ts +3 -0
  16. package/dist/entities/message-transfer.d.ts +8 -0
  17. package/dist/entities/message-write.d.ts +4 -0
  18. package/dist/entities/message.d.ts +2 -0
  19. package/dist/entities/message.js +2 -0
  20. package/dist/entities/store-records.d.ts +5 -0
  21. package/dist/services/embeddings-maintenance.js +71 -2
  22. package/dist/services/embeddings-search.js +3 -1
  23. package/dist/services/embeddings.d.ts +7 -0
  24. package/dist/services/messages-archive-search.d.ts +2 -1
  25. package/dist/services/messages-archive-search.js +4 -4
  26. package/dist/services/messages-discovery.d.ts +24 -1
  27. package/dist/services/messages-discovery.js +61 -4
  28. package/dist/services/messages-meaning.d.ts +7 -0
  29. package/dist/services/messages-meaning.js +53 -0
  30. package/dist/services/messages-search.d.ts +5 -0
  31. package/dist/services/messages.d.ts +5 -0
  32. package/dist/services/messages.js +2 -1
  33. package/dist/services/search-catchup.js +1 -0
  34. package/dist/store/sqlite/message-chunks.d.ts +44 -0
  35. package/dist/store/sqlite/message-chunks.js +76 -0
  36. package/dist/store/sqlite/vectors.js +4 -1
  37. package/dist/store/vectors.d.ts +36 -0
  38. package/dist/store/vectors.js +54 -0
  39. package/package.json +1 -1
@@ -0,0 +1,76 @@
1
+ import { sql } from "./drizzle/core.js";
2
+ import { dot } from "./vectors.js";
3
+ /** Live messages with text of the account, by pk after `after`; the caller compares hashes. */
4
+ export const messagesToEmbed = ({ orm }, accountPk, { chatKey, model, after, limit }) => orm
5
+ .all(sql `SELECT m.id AS messagePk, m.text, m.sent_at AS sentAt, k.content_hash AS hash,
6
+ EXISTS (SELECT 1 FROM embeddings v WHERE v.model = ${model} AND v.content_hash = k.content_hash) AS embedded
7
+ FROM messages m
8
+ LEFT JOIN chunks k ON k.chunkable_type = 'message' AND k.chunkable_id = m.id AND k.position = 0
9
+ WHERE m.account_id = ${accountPk} AND m.deleted_at IS NULL AND trim(m.text) <> '' AND m.id > ${after}
10
+ ${chatKey === undefined ? sql `` : sql `AND m.chat_id = ${chatKey}`}
11
+ ORDER BY m.id LIMIT ${limit}`)
12
+ .map((row) => ({ ...row, embedded: row.embedded === 1 }));
13
+ const dropUnshared = ({ orm }, hashes) => {
14
+ for (const hash of hashes)
15
+ orm.run(sql `DELETE FROM embeddings WHERE content_hash = ${hash}
16
+ AND NOT EXISTS (SELECT 1 FROM chunks WHERE content_hash = ${hash})`);
17
+ };
18
+ /** Makes each hash its message's chunk; the vectors are saved apart, by `saveVectors`. */
19
+ export const saveMessageChunks = (context, accountPk, chunks) => {
20
+ const { orm, now } = context;
21
+ const at = now();
22
+ const scope = orm.get(sql `SELECT scope FROM accounts WHERE id = ${accountPk}`)?.scope ?? null;
23
+ const old = new Set();
24
+ for (const { messagePk, hash, length, occurredAt } of chunks) {
25
+ const held = orm.get(sql `SELECT content_hash AS hash FROM chunks
26
+ WHERE chunkable_type = 'message' AND chunkable_id = ${messagePk} AND position = 0`);
27
+ if (held?.hash === hash)
28
+ continue;
29
+ if (held)
30
+ old.add(held.hash);
31
+ orm.run(sql `DELETE FROM chunks WHERE chunkable_type = 'message' AND chunkable_id = ${messagePk} AND position = 0`);
32
+ orm.run(sql `INSERT INTO chunks (chunkable_type, chunkable_id, position, start_offset, end_offset, content_hash, scope,
33
+ account_id, occurred_at, created_at, updated_at)
34
+ VALUES ('message', ${messagePk}, 0, 0, ${length}, ${hash}, ${scope}, ${accountPk}, ${occurredAt}, ${at}, ${at})`);
35
+ }
36
+ dropUnshared(context, old);
37
+ };
38
+ /**
39
+ * Drops the chunks of the account's messages that are gone, deleted or emptied, with the vectors nothing else
40
+ * uses: no trigger does it for messages, and a deleted message keeps no trace of its text.
41
+ */
42
+ export const dropGoneMessageChunks = (context, accountPk) => {
43
+ const gone = sql `chunkable_type = 'message' AND account_id = ${accountPk} AND NOT EXISTS (SELECT 1 FROM messages m
44
+ WHERE m.id = chunkable_id AND m.deleted_at IS NULL AND trim(m.text) <> '')`;
45
+ const dropped = context.orm.all(sql `SELECT content_hash AS hash FROM chunks WHERE ${gone}`);
46
+ context.orm.run(sql `DELETE FROM chunks WHERE ${gone}`);
47
+ dropUnshared(context, dropped.map(({ hash }) => hash));
48
+ return dropped.length;
49
+ };
50
+ /** Whether any message of the account has a vector of `model`. */
51
+ export const hasMessageVectors = ({ orm }, accountPk, model) => orm.get(sql `SELECT 1 FROM chunks k JOIN embeddings v ON v.model = ${model} AND v.content_hash = k.content_hash
52
+ WHERE k.chunkable_type = 'message' AND k.account_id = ${accountPk} LIMIT 1`) !== undefined;
53
+ const SCAN_PAGE = 5_000;
54
+ /** The account's live messages nearest to `query` by their vector of `model`, best first: a scan of every vector. */
55
+ export const nearestMessages = ({ orm }, accountPk, { chatKey, model, since, before, limit, query, }) => {
56
+ const found = [];
57
+ let after = 0;
58
+ for (;;) {
59
+ const rows = orm.all(sql `SELECT k.id, ch.external_id AS chatId, m.external_id AS messageId, v.vector
60
+ FROM chunks k CROSS JOIN messages m ON m.id = k.chunkable_id AND m.deleted_at IS NULL
61
+ JOIN chats ch ON ch.id = m.chat_id
62
+ JOIN embeddings v ON v.model = ${model} AND v.content_hash = k.content_hash
63
+ WHERE k.chunkable_type = 'message' AND k.position = 0 AND k.account_id = ${accountPk} AND k.id > ${after}
64
+ ${chatKey === undefined ? sql `AND ch.searchable = 1` : sql `AND m.chat_id = ${chatKey}`}
65
+ ${since === undefined ? sql `` : sql `AND m.sent_at >= ${since}`}
66
+ ${before === undefined ? sql `` : sql `AND m.sent_at < ${before}`}
67
+ ORDER BY k.id LIMIT ${SCAN_PAGE}`);
68
+ for (const row of rows)
69
+ found.push({ chatId: row.chatId, messageId: row.messageId, score: dot(query, row.vector) });
70
+ const last = rows.at(-1);
71
+ if (!last || rows.length < SCAN_PAGE)
72
+ break;
73
+ after = last.id;
74
+ }
75
+ return found.sort((a, b) => b.score - a.score).slice(0, limit);
76
+ };
@@ -144,7 +144,10 @@ export const clearVectors = ({ orm }, chatKey, model) => orm.all(sql `DELETE FRO
144
144
  /** Rows read per step of a search, so a chat of any size is scanned in bounded memory. */
145
145
  const SCAN_PAGE = 5_000;
146
146
  export const dot = (query, blob) => {
147
- const vector = new Float32Array(blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength));
147
+ // A view, not a copy: copying every blob cost more than the scoring itself (cli-testing vector-index).
148
+ const vector = blob.byteOffset % 4 === 0
149
+ ? new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4)
150
+ : new Float32Array(blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength));
148
151
  let sum = 0;
149
152
  for (let index = 0; index < query.length; index++)
150
153
  sum += query[index] * vector[index];
@@ -1,6 +1,7 @@
1
1
  import type { Id } from "../domain/models.js";
2
2
  import { type MailStore } from "./sqlite/emails.js";
3
3
  import * as imageChunks from "./sqlite/image-chunks.js";
4
+ import * as messageChunks from "./sqlite/message-chunks.js";
4
5
  import type { StoreContext } from "./sqlite/open.js";
5
6
  import type { ChunkToEmbed } from "./sqlite/vectors.js";
6
7
  import type { AccountKey, ConversationHit, PictureInput, StoredReadiness } from "./types.js";
@@ -51,6 +52,41 @@ export interface AccountVectors {
51
52
  messageId: Id;
52
53
  score: number;
53
54
  }[]>;
55
+ /** Live messages with text after pk `after`, with the chunk and vector each has; `chatId` narrows it. */
56
+ messagesToEmbed(key: AccountKey, options: {
57
+ chatId?: Id;
58
+ model: string;
59
+ after: number;
60
+ limit: number;
61
+ }): Promise<messageChunks.MessageToEmbed[]>;
62
+ /** Keeps each message's vector of `model` and makes its hash the message's chunk. */
63
+ saveMessageVectors(key: AccountKey, model: string, dims: number, messages: {
64
+ messagePk: number;
65
+ hash: string;
66
+ length: number;
67
+ occurredAt: number;
68
+ vector: Float32Array;
69
+ }[]): Promise<void>;
70
+ /** Drops the chunks of messages gone, deleted or emptied, and the vectors only they used; how many. */
71
+ dropGoneMessages(key: AccountKey): Promise<number>;
72
+ /** Whether any message of the account has a vector of `model`. */
73
+ hasMessageVectors(key: AccountKey, model: string): Promise<boolean>;
74
+ /**
75
+ * The messages nearest in meaning to `query` by their own vector of `model`, best first. The one place
76
+ * message vectors are read, so their storage can change behind it.
77
+ */
78
+ nearestMessages(key: AccountKey, options: {
79
+ chatId?: Id;
80
+ model: string;
81
+ since?: string;
82
+ before?: string;
83
+ limit: number;
84
+ query: Float32Array;
85
+ }): Promise<{
86
+ chatId: Id;
87
+ messageId: Id;
88
+ score: number;
89
+ }[]>;
54
90
  /** The current build's distinct chunks, and how many have a vector of `model`. */
55
91
  vectorStatus(key: AccountKey, chatId: Id, model: string): Promise<{
56
92
  chunks: number;
@@ -2,6 +2,7 @@ import { keysOver } from "./keys.js";
2
2
  import * as conversationQueries from "./sqlite/conversations.js";
3
3
  import { threadConversation } from "./sqlite/emails.js";
4
4
  import * as imageChunks from "./sqlite/image-chunks.js";
5
+ import * as messageChunks from "./sqlite/message-chunks.js";
5
6
  import { write } from "./sqlite/transaction.js";
6
7
  import * as vectors from "./sqlite/vectors.js";
7
8
  export const accountVectorsOver = (context, mail) => {
@@ -93,6 +94,59 @@ export const accountVectorsOver = (context, mail) => {
93
94
  position: imageChunks.PICTURE_POSITION,
94
95
  });
95
96
  },
97
+ messagesToEmbed: async (key, { chatId, model, after, limit }) => {
98
+ const accountPk = findAccountPk(key);
99
+ if (accountPk === undefined)
100
+ return [];
101
+ const chatKey = chatId === undefined ? undefined : chatKeyOf(key, chatId);
102
+ if (chatId !== undefined && chatKey === undefined)
103
+ return [];
104
+ return messageChunks.messagesToEmbed(context, accountPk, {
105
+ ...(chatKey === undefined ? {} : { chatKey }),
106
+ model,
107
+ after,
108
+ limit,
109
+ });
110
+ },
111
+ saveMessageVectors: async (key, model, dims, messages) => {
112
+ const accountPk = findAccountPk(key);
113
+ if (accountPk === undefined)
114
+ return;
115
+ write(database, () => {
116
+ vectors.saveVectors(context, model, dims, messages.map(({ hash, vector }) => ({ hash, vector })));
117
+ messageChunks.saveMessageChunks(context, accountPk, messages);
118
+ });
119
+ },
120
+ dropGoneMessages: async (key) => {
121
+ const accountPk = findAccountPk(key);
122
+ if (accountPk === undefined)
123
+ return 0;
124
+ let dropped = 0;
125
+ write(database, () => {
126
+ dropped = messageChunks.dropGoneMessageChunks(context, accountPk);
127
+ });
128
+ return dropped;
129
+ },
130
+ hasMessageVectors: async (key, model) => {
131
+ const accountPk = findAccountPk(key);
132
+ return accountPk !== undefined && messageChunks.hasMessageVectors(context, accountPk, model);
133
+ },
134
+ nearestMessages: async (key, { chatId, model, since, before, limit, query }) => {
135
+ const accountPk = findAccountPk(key);
136
+ if (accountPk === undefined)
137
+ return [];
138
+ const chatKey = chatId === undefined ? undefined : chatKeyOf(key, chatId);
139
+ if (chatId !== undefined && chatKey === undefined)
140
+ return [];
141
+ return messageChunks.nearestMessages(context, accountPk, {
142
+ ...(chatKey === undefined ? {} : { chatKey }),
143
+ ...(since === undefined ? {} : { since: Date.parse(since) }),
144
+ ...(before === undefined ? {} : { before: Date.parse(before) }),
145
+ model,
146
+ limit,
147
+ query,
148
+ });
149
+ },
96
150
  vectorStatus: async (key, chatId, model) => {
97
151
  const chatKey = chatKeyOf(key, chatId);
98
152
  return chatKey === undefined ? { chunks: 0, embedded: 0 } : vectors.vectorStatus(context, chatKey, model);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.431.0",
3
+ "version": "0.432.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",