@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,4 +1,5 @@
1
1
  import { sql } from "./drizzle/core.js";
2
+ import { dropUnusedVectors, searchIndex } from "./vector-index.js";
2
3
  import { dot } from "./vectors.js";
3
4
  const IS_IMAGE = sql `(a.kind = 'photo' OR a.mime LIKE 'image/%')`;
4
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
@@ -26,7 +27,7 @@ export const imagesOfChat = (context, chatKey, window) => context.orm
26
27
  * Makes these the chunks of their images, and drops the chunks of images that are gone or whose message was
27
28
  * deleted, with the vectors nothing else uses any more.
28
29
  */
29
- export const saveImageChunks = ({ orm, now }, accountPk, chatKey, chunks, keep) => {
30
+ export const saveImageChunks = ({ orm, now, database }, accountPk, chatKey, chunks, keep) => {
30
31
  const at = now();
31
32
  const scope = orm.get(sql `SELECT scope FROM accounts WHERE id = ${accountPk}`)?.scope ?? null;
32
33
  const old = new Set();
@@ -50,9 +51,7 @@ export const saveImageChunks = ({ orm, now }, accountPk, chatKey, chunks, keep)
50
51
  account_id, occurred_at, created_at, updated_at)
51
52
  VALUES ('image', ${attachmentPk}, 0, 0, ${length}, ${hash}, ${scope}, ${accountPk}, ${occurredAt}, ${at}, ${at})`);
52
53
  }
53
- for (const hash of old)
54
- orm.run(sql `DELETE FROM embeddings WHERE content_hash = ${hash}
55
- AND NOT EXISTS (SELECT 1 FROM chunks WHERE content_hash = ${hash})`);
54
+ dropUnusedVectors(database, old);
56
55
  };
57
56
  /** The hashes among these that already have a vector of `model`. */
58
57
  export const embeddedHashes = ({ orm }, model, hashes) => new Set(hashes.length === 0
@@ -62,32 +61,64 @@ export const embeddedHashes = ({ orm }, model, hashes) => new Set(hashes.length
62
61
  WHERE model = ${model} AND content_hash IN (SELECT value FROM json_each(${JSON.stringify(hashes)}))`)
63
62
  .map(({ hash }) => hash));
64
63
  const SCAN_PAGE = 5_000;
64
+ /** The scan's rows: one page after `after`, or the `ids` the index picked. */
65
+ const imageRows = ({ orm }, accountPk, { chatKey, model, since, position }, rows) => orm.all(sql `SELECT k.id, k.content_hash AS hash, ch.external_id AS chatId, m.external_id AS messageId, v.vector
66
+ FROM chunks k CROSS JOIN attachments a ON a.id = k.chunkable_id AND a.attachable_type = 'message'
67
+ JOIN messages m ON m.id = a.attachable_id AND m.deleted_at IS NULL
68
+ JOIN chats ch ON ch.id = m.chat_id
69
+ JOIN embeddings v ON v.model = ${model} AND v.content_hash = k.content_hash
70
+ WHERE k.chunkable_type = 'image' AND k.position = ${position} AND ch.account_id = ${accountPk}
71
+ ${chatKey === undefined ? sql `AND ch.searchable = 1` : sql `AND m.chat_id = ${chatKey}`}
72
+ ${since === undefined ? sql `` : sql `AND k.occurred_at >= ${since}`}
73
+ ${"ids" in rows
74
+ ? sql `AND k.id IN (SELECT value FROM json_each(${JSON.stringify(rows.ids)})) ORDER BY k.id`
75
+ : sql `AND k.id > ${rows.after} ORDER BY k.id LIMIT ${SCAN_PAGE}`}`);
65
76
  /**
66
77
  * The images of the account nearest to `query` by the vector of `model`, best first. Position 0 of an image is
67
78
  * its context text, position 1 its picture (`PICTURE_POSITION`): a model's vectors only ever sit at one.
68
79
  */
69
- export const nearestImages = ({ orm }, accountPk, { chatKey, model, since, limit, query, position = 0, }) => {
80
+ export const nearestImages = (context, accountPk, { chatKey, model, since, limit, query, position = 0, }) => {
81
+ const filter = {
82
+ model,
83
+ position,
84
+ ...(chatKey === undefined ? {} : { chatKey }),
85
+ ...(since === undefined ? {} : { since }),
86
+ };
87
+ const score = (row) => ({
88
+ id: row.id,
89
+ chatId: row.chatId,
90
+ messageId: row.messageId,
91
+ score: dot(query, row.vector),
92
+ });
93
+ // Stable, so equal scores keep the scan's order, by chunk id.
94
+ const best = (found) => found.sort((a, b) => b.score - a.score).slice(0, limit);
95
+ const indexed = searchIndex(context.database, model, {
96
+ kind: "image",
97
+ account: accountPk,
98
+ ...(chatKey === undefined ? {} : { chat: chatKey }),
99
+ ...(since === undefined ? {} : { since }),
100
+ }, query, limit, (candidates) => {
101
+ const hashes = new Map(candidates.map(({ id, hash }) => [id, hash]));
102
+ const results = best(imageRows(context, accountPk, filter, { ids: [...hashes.keys()] })
103
+ .filter((row) => hashes.get(row.id) === row.hash)
104
+ .map(score));
105
+ return { results, best: results.map(({ id }) => id) };
106
+ });
107
+ const plain = (found) => found.map(({ chatId, messageId, score }) => ({ chatId, messageId, score }));
108
+ if (indexed)
109
+ return plain(indexed);
70
110
  const found = [];
71
111
  let after = 0;
72
112
  for (;;) {
73
- const rows = orm.all(sql `SELECT k.id, ch.external_id AS chatId, m.external_id AS messageId, v.vector
74
- FROM chunks k CROSS JOIN attachments a ON a.id = k.chunkable_id AND a.attachable_type = 'message'
75
- JOIN messages m ON m.id = a.attachable_id AND m.deleted_at IS NULL
76
- JOIN chats ch ON ch.id = m.chat_id
77
- JOIN embeddings v ON v.model = ${model} AND v.content_hash = k.content_hash
78
- WHERE k.chunkable_type = 'image' AND k.position = ${position} AND ch.account_id = ${accountPk}
79
- AND k.id > ${after}
80
- ${chatKey === undefined ? sql `AND ch.searchable = 1` : sql `AND m.chat_id = ${chatKey}`}
81
- ${since === undefined ? sql `` : sql `AND k.occurred_at >= ${since}`}
82
- ORDER BY k.id LIMIT ${SCAN_PAGE}`);
113
+ const rows = imageRows(context, accountPk, filter, { after });
83
114
  for (const row of rows)
84
- found.push({ chatId: row.chatId, messageId: row.messageId, score: dot(query, row.vector) });
115
+ found.push(score(row));
85
116
  const last = rows.at(-1);
86
117
  if (!last || rows.length < SCAN_PAGE)
87
118
  break;
88
119
  after = last.id;
89
120
  }
90
- return found.sort((a, b) => b.score - a.score).slice(0, limit);
121
+ return plain(best(found));
91
122
  };
92
123
  /** An image's own vector is its chunk at this position; position 0 is the text around it. */
93
124
  export const PICTURE_POSITION = 1;
@@ -102,24 +133,23 @@ export const picturesToEmbed = ({ orm }, accountPk, { chatKey, after, limit }) =
102
133
  AND k.position = ${PICTURE_POSITION})
103
134
  ORDER BY a.id LIMIT ${limit}`);
104
135
  /** Makes each hash the picture chunk of its image; the vectors are saved apart, by `saveVectors`. */
105
- export const savePictureChunks = ({ orm, now }, accountPk, pictures) => {
136
+ export const savePictureChunks = ({ orm, now, database }, accountPk, pictures) => {
106
137
  const at = now();
107
138
  const scope = orm.get(sql `SELECT scope FROM accounts WHERE id = ${accountPk}`)?.scope ?? null;
108
139
  // A deleted message keeps no trace of its picture, as it keeps none of its text.
109
140
  const gone = sql `chunkable_type = 'image' AND position = ${PICTURE_POSITION} AND account_id = ${accountPk}
110
141
  AND NOT EXISTS (SELECT 1 FROM attachments a JOIN messages m ON a.attachable_type = 'message'
111
142
  AND m.id = a.attachable_id WHERE a.id = chunkable_id AND m.deleted_at IS NULL)`;
112
- const dropped = orm.all(sql `SELECT content_hash AS hash FROM chunks WHERE ${gone}`);
113
- orm.run(sql `DELETE FROM chunks WHERE ${gone}`);
114
- for (const { hash } of dropped)
115
- orm.run(sql `DELETE FROM embeddings WHERE content_hash = ${hash}
116
- 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`);
117
144
  for (const { attachmentPk, hash, bytes, occurredAt } of pictures) {
118
- orm.run(sql `DELETE FROM chunks WHERE chunkable_type = 'image' AND chunkable_id = ${attachmentPk}
119
- 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);
120
149
  orm.run(sql `INSERT INTO chunks (chunkable_type, chunkable_id, position, start_offset, end_offset, content_hash, scope,
121
150
  account_id, occurred_at, created_at, updated_at)
122
151
  VALUES ('image', ${attachmentPk}, ${PICTURE_POSITION}, 0, ${bytes}, ${hash}, ${scope}, ${accountPk},
123
152
  ${occurredAt}, ${at}, ${at})`);
124
153
  }
154
+ dropUnusedVectors(database, released.map(({ hash }) => hash));
125
155
  };
@@ -43,6 +43,8 @@ export const MANIFEST = [
43
43
  { name: "20261025071850_version-32-files-data", version: 32, minCompatible: 32 },
44
44
  { name: "20261025072643_version-33-calendars", version: 33, minCompatible: 32 },
45
45
  { name: "20261025072644_version-33-calendars-triggers", version: 33, minCompatible: 32 },
46
+ { name: "20261025182500_version-34-vector-index", version: 34, minCompatible: 34 },
47
+ { name: "20261025182501_version-34-vector-index-triggers", version: 34, minCompatible: 34 },
46
48
  ];
47
49
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
48
50
  const migrations = [];
@@ -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 {
@@ -33,12 +33,15 @@ export interface NearestMessage {
33
33
  messageId: string;
34
34
  score: number;
35
35
  }
36
- /** The account's live messages nearest to `query` by their vector of `model`, best first: a scan of every vector. */
37
- export declare const nearestMessages: ({ orm }: StoreContext, accountPk: number, { chatKey, model, since, before, limit, query, }: {
36
+ type MessageFilter = {
38
37
  chatKey?: number;
39
38
  model: string;
40
39
  since?: number;
41
40
  before?: number;
41
+ };
42
+ /** The account's live messages nearest to `query` by their vector of `model`, best first. */
43
+ export declare const nearestMessages: (context: StoreContext, accountPk: number, { limit, query, ...filter }: MessageFilter & {
42
44
  limit: number;
43
45
  query: Float32Array;
44
46
  }) => NearestMessage[];
47
+ export {};
@@ -1,4 +1,5 @@
1
1
  import { sql } from "./drizzle/core.js";
2
+ import { dropUnusedVectors, searchIndex } from "./vector-index.js";
2
3
  import { dot } from "./vectors.js";
3
4
  /** Live messages with text of the account, by pk after `after`; the caller compares hashes. */
4
5
  export const messagesToEmbed = ({ orm }, accountPk, { chatKey, model, after, limit }) => orm
@@ -10,11 +11,6 @@ export const messagesToEmbed = ({ orm }, accountPk, { chatKey, model, after, lim
10
11
  ${chatKey === undefined ? sql `` : sql `AND m.chat_id = ${chatKey}`}
11
12
  ORDER BY m.id LIMIT ${limit}`)
12
13
  .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
14
  /** Makes each hash its message's chunk; the vectors are saved apart, by `saveVectors`. */
19
15
  export const saveMessageChunks = (context, accountPk, chunks) => {
20
16
  const { orm, now } = context;
@@ -33,7 +29,7 @@ export const saveMessageChunks = (context, accountPk, chunks) => {
33
29
  account_id, occurred_at, created_at, updated_at)
34
30
  VALUES ('message', ${messagePk}, 0, 0, ${length}, ${hash}, ${scope}, ${accountPk}, ${occurredAt}, ${at}, ${at})`);
35
31
  }
36
- dropUnshared(context, old);
32
+ dropUnusedVectors(context.database, old);
37
33
  };
38
34
  /**
39
35
  * Drops the chunks of the account's messages that are gone, deleted or emptied, with the vectors nothing else
@@ -44,33 +40,61 @@ export const dropGoneMessageChunks = (context, accountPk) => {
44
40
  WHERE m.id = chunkable_id AND m.deleted_at IS NULL AND trim(m.text) <> '')`;
45
41
  const dropped = context.orm.all(sql `SELECT content_hash AS hash FROM chunks WHERE ${gone}`);
46
42
  context.orm.run(sql `DELETE FROM chunks WHERE ${gone}`);
47
- dropUnshared(context, dropped.map(({ hash }) => hash));
43
+ dropUnusedVectors(context.database, dropped.map(({ hash }) => hash));
48
44
  return dropped.length;
49
45
  };
50
46
  /** Whether any message of the account has a vector of `model`. */
51
47
  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
48
  WHERE k.chunkable_type = 'message' AND k.account_id = ${accountPk} LIMIT 1`) !== undefined;
53
49
  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, }) => {
50
+ /** The scan's rows: one page after `after`, or the `ids` the index picked. */
51
+ const messageRows = ({ orm }, accountPk, { chatKey, model, since, before }, rows) => orm.all(sql `SELECT k.id, k.content_hash AS hash, ch.external_id AS chatId, m.external_id AS messageId, v.vector
52
+ FROM chunks k CROSS JOIN messages m ON m.id = k.chunkable_id AND m.deleted_at IS NULL
53
+ JOIN chats ch ON ch.id = m.chat_id
54
+ JOIN embeddings v ON v.model = ${model} AND v.content_hash = k.content_hash
55
+ WHERE k.chunkable_type = 'message' AND k.position = 0 AND k.account_id = ${accountPk}
56
+ ${chatKey === undefined ? sql `AND ch.searchable = 1` : sql `AND m.chat_id = ${chatKey}`}
57
+ ${since === undefined ? sql `` : sql `AND m.sent_at >= ${since}`}
58
+ ${before === undefined ? sql `` : sql `AND m.sent_at < ${before}`}
59
+ ${"ids" in rows
60
+ ? sql `AND k.id IN (SELECT value FROM json_each(${JSON.stringify(rows.ids)})) ORDER BY k.id`
61
+ : sql `AND k.id > ${rows.after} ORDER BY k.id LIMIT ${SCAN_PAGE}`}`);
62
+ /** The account's live messages nearest to `query` by their vector of `model`, best first. */
63
+ export const nearestMessages = (context, accountPk, { limit, query, ...filter }) => {
64
+ const score = (row) => ({
65
+ id: row.id,
66
+ chatId: row.chatId,
67
+ messageId: row.messageId,
68
+ score: dot(query, row.vector),
69
+ });
70
+ // Stable, so equal scores keep the scan's order, by chunk id.
71
+ const best = (found) => found.sort((a, b) => b.score - a.score).slice(0, limit);
72
+ const indexed = searchIndex(context.database, filter.model, {
73
+ kind: "message",
74
+ account: accountPk,
75
+ ...(filter.chatKey === undefined ? {} : { chat: filter.chatKey }),
76
+ ...(filter.since === undefined ? {} : { since: filter.since }),
77
+ ...(filter.before === undefined ? {} : { before: filter.before }),
78
+ }, query, limit, (candidates) => {
79
+ const hashes = new Map(candidates.map(({ id, hash }) => [id, hash]));
80
+ const results = best(messageRows(context, accountPk, filter, { ids: [...hashes.keys()] })
81
+ .filter((row) => hashes.get(row.id) === row.hash)
82
+ .map(score));
83
+ return { results, best: results.map(({ id }) => id) };
84
+ });
85
+ const plain = (found) => found.map(({ chatId, messageId, score }) => ({ chatId, messageId, score }));
86
+ if (indexed)
87
+ return plain(indexed);
56
88
  const found = [];
57
89
  let after = 0;
58
90
  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}`);
91
+ const rows = messageRows(context, accountPk, filter, { after });
68
92
  for (const row of rows)
69
- found.push({ chatId: row.chatId, messageId: row.messageId, score: dot(query, row.vector) });
93
+ found.push(score(row));
70
94
  const last = rows.at(-1);
71
95
  if (!last || rows.length < SCAN_PAGE)
72
96
  break;
73
97
  after = last.id;
74
98
  }
75
- return found.sort((a, b) => b.score - a.score).slice(0, limit);
99
+ return plain(best(found));
76
100
  };
@@ -739,5 +739,27 @@ export const GENERATED = [
739
739
  "INSERT OR IGNORE INTO search_index_state (name, watermark, filled_through, terms_through, normalizer_version, built_at, analyzer)\n VALUES ('event_index', 0, 0, 0, 1, NULL, NULL);",
740
740
  "INSERT OR IGNORE INTO event_index_pending (indexable_type, id) SELECT 'event', id FROM events;"
741
741
  ]
742
+ },
743
+ {
744
+ "name": "20261025182500_version-34-vector-index",
745
+ "statements": [
746
+ "CREATE TABLE `vector_index_redo` (\n\t`chunk_id` integer NOT NULL\n);",
747
+ "CREATE TABLE `vector_indexes` (\n\t`id` integer PRIMARY KEY,\n\t`model` text NOT NULL UNIQUE,\n\t`dims` integer NOT NULL,\n\t`ready` integer NOT NULL,\n\t`filled_through` text NOT NULL\n);"
748
+ ]
749
+ },
750
+ {
751
+ "name": "20261025182501_version-34-vector-index-triggers",
752
+ "statements": [
753
+ "-- Every change that can add, drop or move a chunk's vector passes the chunk's id through vector_index_redo, where\n-- each model's own trigger (made with its vec0 table, `vector-index.ts`) re-derives that row. Writes in TypeScript\n-- and the schema's own delete triggers (document_bd, note_bd, …) all reach the index this way.\n-- A chunk usually comes before its vector; the vector's own trigger indexes it then.\n-- CROSS JOIN: led by the vectors, SQLite read every one of them per chunk (7 ms each at 100k).\nCREATE TRIGGER vector_index_chunk_ai AFTER INSERT ON chunks WHEN EXISTS (SELECT 1 FROM vector_indexes i\n CROSS JOIN embeddings v ON v.model = i.model AND v.content_hash = new.content_hash) BEGIN\n INSERT INTO vector_index_redo (chunk_id) VALUES (new.id);\n DELETE FROM vector_index_redo;\nEND;",
754
+ "CREATE TRIGGER vector_index_chunk_ad AFTER DELETE ON chunks BEGIN\n INSERT INTO vector_index_redo (chunk_id) VALUES (old.id);\n DELETE FROM vector_index_redo;\nEND;",
755
+ "CREATE TRIGGER vector_index_chunk_au AFTER UPDATE OF content_hash, chunkable_type, chunkable_id, account_id, occurred_at\n ON chunks WHEN old.content_hash IS NOT new.content_hash OR old.chunkable_type IS NOT new.chunkable_type\n OR old.chunkable_id IS NOT new.chunkable_id OR old.account_id IS NOT new.account_id\n OR old.occurred_at IS NOT new.occurred_at BEGIN\n INSERT INTO vector_index_redo (chunk_id) VALUES (new.id);\n DELETE FROM vector_index_redo;\nEND;",
756
+ "CREATE TRIGGER vector_index_embedding_ai AFTER INSERT ON embeddings\n WHEN EXISTS (SELECT 1 FROM vector_indexes WHERE model = new.model) BEGIN\n INSERT INTO vector_index_redo (chunk_id) SELECT id FROM chunks WHERE content_hash = new.content_hash;\n DELETE FROM vector_index_redo;\nEND;",
757
+ "CREATE TRIGGER vector_index_embedding_ad AFTER DELETE ON embeddings\n WHEN EXISTS (SELECT 1 FROM vector_indexes WHERE model = old.model) BEGIN\n INSERT INTO vector_index_redo (chunk_id) SELECT id FROM chunks WHERE content_hash = old.content_hash;\n DELETE FROM vector_index_redo;\nEND;",
758
+ "CREATE TRIGGER vector_index_embedding_au AFTER UPDATE OF model, content_hash, vector ON embeddings BEGIN\n INSERT INTO vector_index_redo (chunk_id)\n SELECT id FROM chunks WHERE content_hash = old.content_hash OR content_hash = new.content_hash;\n DELETE FROM vector_index_redo;\nEND;",
759
+ "-- A message's chunk and its images' chunks are indexed under its chat and send time; a chat merge moves messages.\nCREATE TRIGGER vector_index_message_au AFTER UPDATE OF chat_id, sent_at ON messages\n WHEN old.chat_id IS NOT new.chat_id OR old.sent_at IS NOT new.sent_at BEGIN\n INSERT INTO vector_index_redo (chunk_id)\n SELECT id FROM chunks WHERE chunkable_type = 'message' AND chunkable_id = new.id\n UNION ALL SELECT k.id FROM attachments a JOIN chunks k ON k.chunkable_type = 'image' AND k.chunkable_id = a.id\n WHERE a.attachable_type = 'message' AND a.attachable_id = new.id;\n DELETE FROM vector_index_redo;\nEND;",
760
+ "-- An image's chat and account come from its message; a chat merge moves attachments to the kept copy.\nCREATE TRIGGER vector_index_attachment_au AFTER UPDATE OF attachable_type, attachable_id ON attachments\n WHEN old.attachable_type IS NOT new.attachable_type OR old.attachable_id IS NOT new.attachable_id BEGIN\n INSERT INTO vector_index_redo (chunk_id) SELECT id FROM chunks WHERE chunkable_type = 'image' AND chunkable_id = new.id;\n DELETE FROM vector_index_redo;\nEND;",
761
+ "CREATE TRIGGER vector_index_conversation_au AFTER UPDATE OF chat_id ON conversations\n WHEN old.chat_id IS NOT new.chat_id BEGIN\n INSERT INTO vector_index_redo (chunk_id)\n SELECT id FROM chunks WHERE chunkable_type = 'conversation' AND chunkable_id = new.id;\n DELETE FROM vector_index_redo;\nEND;",
762
+ "-- Images and conversations are indexed under their chat's account; messages carry their own.\nCREATE TRIGGER vector_index_chat_au AFTER UPDATE OF account_id ON chats WHEN old.account_id IS NOT new.account_id BEGIN\n INSERT INTO vector_index_redo (chunk_id)\n SELECT k.id FROM messages m JOIN attachments a ON a.attachable_type = 'message' AND a.attachable_id = m.id\n JOIN chunks k ON k.chunkable_type = 'image' AND k.chunkable_id = a.id WHERE m.chat_id = new.id\n UNION ALL SELECT k.id FROM conversations c JOIN chunks k ON k.chunkable_type = 'conversation' AND k.chunkable_id = c.id\n WHERE c.chat_id = new.id;\n DELETE FROM vector_index_redo;\nEND;"
763
+ ]
742
764
  }
743
765
  ];
@@ -8,6 +8,7 @@ import { indexRow } from "./index-state.js";
8
8
  import { speech } from "./lucene-fields.js";
9
9
  import { savedStemmers } from "./stems.js";
10
10
  import { write } from "./transaction.js";
11
+ import { dropUnusedVectors } from "./vector-index.js";
11
12
  export const CORPORA = {
12
13
  document: {
13
14
  type: "document",
@@ -114,8 +115,6 @@ const writer = (database, corpus, stemmer) => {
114
115
  stems: database.prepare(`INSERT INTO ${corpus.type}_stems (rowid, stems, scope) VALUES (?, ?, ?)`),
115
116
  chunk: database.prepare("INSERT INTO chunks (chunkable_type, chunkable_id, position, start_offset, end_offset, content_hash, scope, account_id, created_at, updated_at) " +
116
117
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
117
- purge: database.prepare(`DELETE FROM embeddings WHERE content_hash = ?
118
- AND NOT EXISTS (SELECT 1 FROM chunks WHERE content_hash = ?)`),
119
118
  dequeue: database.prepare(`DELETE FROM ${corpus.type}_index_pending WHERE indexable_type = ? AND id = ?`),
120
119
  };
121
120
  return (id) => {
@@ -134,7 +133,6 @@ const writer = (database, corpus, stemmer) => {
134
133
  const stems = terms ? terms.stems : text ? stemmer.indexText(text) : "";
135
134
  if (stems !== "")
136
135
  statements.stems.run(id, stems, scope);
137
- const after = new Set();
138
136
  if (text.trim() && corpus.pieces !== false) {
139
137
  const at = Date.now();
140
138
  const sections = row?.metadata == null ? [] : storedSections(String(row.metadata));
@@ -143,13 +141,10 @@ const writer = (database, corpus, stemmer) => {
143
141
  : splitText(text, CHUNK_CHARS);
144
142
  ranges.forEach(({ start, end }, position) => {
145
143
  const hash = chunkHash(text.slice(start, end));
146
- after.add(hash);
147
144
  statements.chunk.run(corpus.type, id, position, start, end, hash, scope, int(row?.account_id), at, at);
148
145
  });
149
146
  }
150
- for (const hash of before)
151
- if (!after.has(hash))
152
- statements.purge.run(hash, hash);
147
+ dropUnusedVectors(database, before);
153
148
  if (!live && corpus.type === "document")
154
149
  dropDocumentPicture(database, id);
155
150
  statements.dequeue.run(queuedAs(corpus), id);
@@ -15,6 +15,7 @@ import { loadProviders } from "./providers.js";
15
15
  import { assertReadCompatible } from "./read-compatibility.js";
16
16
  import { stemsState } from "./stems.js";
17
17
  import { fillStems, followDefaultStemmers } from "./stems-fill.js";
18
+ import { fillVectorIndexes } from "./vector-index.js";
18
19
  export const BACKFILL_ON_OPEN = 5_000;
19
20
  const NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS"]);
20
21
  const createWritable = (file) => {
@@ -62,6 +63,9 @@ export const openStoreContext = async ({ path, env, now = Date.now, command, ver
62
63
  stemming.watermark - stemming.filledThrough + stemming.pending <= BACKFILL_ON_OPEN)
63
64
  fillStems(database, { now });
64
65
  logPruner(database, now)();
66
+ fillVectorIndexes(database, {
67
+ notice: (model) => warn(`indexing the stored vectors of ${model} for search by meaning — once; a large archive takes minutes`),
68
+ });
65
69
  }
66
70
  loadProviders(database);
67
71
  return { ...opened, now, sharedConfig: sharedConfigPath(file, env ?? process.env) };
@@ -20,10 +20,9 @@ export const openSqlite = async (path, { readOnly = false } = {}) => {
20
20
  };
21
21
  const openUnderNode = async (path, readOnly) => {
22
22
  quietSqliteWarning();
23
- const { DatabaseSync } = await import("node:sqlite");
24
23
  const { drizzle } = await import("./drizzle/node.js");
25
- const { cacheOverNodeSqlite } = await import("../drivers/node-sqlite.js");
26
- const client = new DatabaseSync(path, { readOnly });
24
+ const { cacheOverNodeSqlite, nodeClient } = await import("../drivers/node-sqlite.js");
25
+ const client = await nodeClient(path, { readOnly });
27
26
  try {
28
27
  return { database: cacheOverNodeSqlite(client), orm: drizzle({ client }) };
29
28
  }
@@ -33,10 +32,9 @@ const openUnderNode = async (path, readOnly) => {
33
32
  }
34
33
  };
35
34
  const openUnderBun = async (path, readOnly) => {
36
- const { bunDatabase, cacheOverBunSqlite } = await import("../drivers/bun-sqlite.js");
37
- const Database = await bunDatabase();
35
+ const { bunClient, cacheOverBunSqlite } = await import("../drivers/bun-sqlite.js");
38
36
  const { drizzle } = (await import("./drizzle/bun.js"));
39
- const client = readOnly ? new Database(path, { readonly: true, create: false }) : new Database(path);
37
+ const client = await bunClient(path, readOnly ? { readonly: true, create: false } : undefined);
40
38
  try {
41
39
  return { database: cacheOverBunSqlite(client), orm: drizzle({ client }) };
42
40
  }
@@ -15535,7 +15535,7 @@ export declare const conversationState: import("drizzle-orm/sqlite-core").SQLite
15535
15535
  dialect: 'sqlite';
15536
15536
  isAlias: false;
15537
15537
  }>;
15538
- /** One model's vector for one chunk text; no owner, so vectors outlive the builds and chunks that point at them. */
15538
+ /** One model's vector for one chunk text; no owner: chunks of any kind share it, and it goes once none holds the text. */
15539
15539
  export declare const embeddings: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
15540
15540
  name: "embeddings";
15541
15541
  schema: undefined;
@@ -16637,6 +16637,123 @@ export declare const mentionPending: import("drizzle-orm/sqlite-core").SQLiteTab
16637
16637
  dialect: 'sqlite';
16638
16638
  isAlias: false;
16639
16639
  }>;
16640
+ /**
16641
+ * One sqlite-vec `vec0` table per embedding model, `vector_index_<id>`, created at run time with its trigger since
16642
+ * its width is the model's: a row per chunk with a vector of that model, rowid `chunks.id` (`vector-index.ts`).
16643
+ */
16644
+ export declare const vectorIndexes: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
16645
+ name: "vector_indexes";
16646
+ schema: undefined;
16647
+ columns: {
16648
+ id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
16649
+ name: string;
16650
+ tableName: "vector_indexes";
16651
+ dataType: "number int53";
16652
+ data: number;
16653
+ driverParam: number;
16654
+ notNull: true;
16655
+ hasDefault: true;
16656
+ isPrimaryKey: true;
16657
+ isAutoincrement: false;
16658
+ hasRuntimeDefault: false;
16659
+ enumValues: undefined;
16660
+ baseColumn: never;
16661
+ identity: undefined;
16662
+ generated: undefined;
16663
+ }, {}>;
16664
+ model: import("drizzle-orm/sqlite-core").SQLiteColumn<{
16665
+ name: string;
16666
+ tableName: "vector_indexes";
16667
+ dataType: "string";
16668
+ data: string;
16669
+ driverParam: string;
16670
+ notNull: true;
16671
+ hasDefault: false;
16672
+ isPrimaryKey: false;
16673
+ isAutoincrement: false;
16674
+ hasRuntimeDefault: false;
16675
+ enumValues: [string, ...string[]];
16676
+ baseColumn: never;
16677
+ identity: undefined;
16678
+ generated: undefined;
16679
+ }, {}>;
16680
+ dims: import("drizzle-orm/sqlite-core").SQLiteColumn<{
16681
+ name: string;
16682
+ tableName: "vector_indexes";
16683
+ dataType: "number int53";
16684
+ data: number;
16685
+ driverParam: number;
16686
+ notNull: true;
16687
+ hasDefault: false;
16688
+ isPrimaryKey: false;
16689
+ isAutoincrement: false;
16690
+ hasRuntimeDefault: false;
16691
+ enumValues: undefined;
16692
+ baseColumn: never;
16693
+ identity: undefined;
16694
+ generated: undefined;
16695
+ }, {}>;
16696
+ ready: import("drizzle-orm/sqlite-core").SQLiteColumn<{
16697
+ name: string;
16698
+ tableName: "vector_indexes";
16699
+ dataType: "number int53";
16700
+ data: number;
16701
+ driverParam: number;
16702
+ notNull: true;
16703
+ hasDefault: false;
16704
+ isPrimaryKey: false;
16705
+ isAutoincrement: false;
16706
+ hasRuntimeDefault: false;
16707
+ enumValues: undefined;
16708
+ baseColumn: never;
16709
+ identity: undefined;
16710
+ generated: undefined;
16711
+ }, {}>;
16712
+ filledThrough: import("drizzle-orm/sqlite-core").SQLiteColumn<{
16713
+ name: string;
16714
+ tableName: "vector_indexes";
16715
+ dataType: "string";
16716
+ data: string;
16717
+ driverParam: string;
16718
+ notNull: true;
16719
+ hasDefault: false;
16720
+ isPrimaryKey: false;
16721
+ isAutoincrement: false;
16722
+ hasRuntimeDefault: false;
16723
+ enumValues: [string, ...string[]];
16724
+ baseColumn: never;
16725
+ identity: undefined;
16726
+ generated: undefined;
16727
+ }, {}>;
16728
+ };
16729
+ dialect: 'sqlite';
16730
+ isAlias: false;
16731
+ }>;
16732
+ /** Always empty: each changed chunk's id passes through it so that every model's index re-derives its row. */
16733
+ export declare const vectorIndexRedo: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
16734
+ name: "vector_index_redo";
16735
+ schema: undefined;
16736
+ columns: {
16737
+ chunkId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
16738
+ name: string;
16739
+ tableName: "vector_index_redo";
16740
+ dataType: "number int53";
16741
+ data: number;
16742
+ driverParam: number;
16743
+ notNull: true;
16744
+ hasDefault: false;
16745
+ isPrimaryKey: false;
16746
+ isAutoincrement: false;
16747
+ hasRuntimeDefault: false;
16748
+ enumValues: undefined;
16749
+ baseColumn: never;
16750
+ identity: undefined;
16751
+ generated: undefined;
16752
+ }, {}>;
16753
+ };
16754
+ dialect: 'sqlite';
16755
+ isAlias: false;
16756
+ }>;
16640
16757
  /** Settings of the store file itself, shared by every profile, tg and MAX — unlike a profile's config file. */
16641
16758
  export declare const storeSettings: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
16642
16759
  name: "store_settings";
@@ -1556,7 +1556,7 @@ export const conversationState = sqliteTable("conversation_state", {
1556
1556
  /** The build readers see; a higher one is being written, or failed. */
1557
1557
  currentBuild: integer("current_build"),
1558
1558
  });
1559
- /** One model's vector for one chunk text; no owner, so vectors outlive the builds and chunks that point at them. */
1559
+ /** One model's vector for one chunk text; no owner: chunks of any kind share it, and it goes once none holds the text. */
1560
1560
  export const embeddings = sqliteTable("embeddings", {
1561
1561
  /** `<provider>:<model>:<dims>` — vectors of different models never mix. */
1562
1562
  model: text("model").notNull(),
@@ -1684,6 +1684,23 @@ export const mentionPending = sqliteTable("mention_pending", {
1684
1684
  sourceType: text("source_type").notNull(),
1685
1685
  sourceId: integer("source_id").notNull(),
1686
1686
  }, (table) => [primaryKey({ columns: [table.sourceType, table.sourceId] })]);
1687
+ /**
1688
+ * One sqlite-vec `vec0` table per embedding model, `vector_index_<id>`, created at run time with its trigger since
1689
+ * its width is the model's: a row per chunk with a vector of that model, rowid `chunks.id` (`vector-index.ts`).
1690
+ */
1691
+ export const vectorIndexes = sqliteTable("vector_indexes", {
1692
+ id: integer("id").primaryKey(),
1693
+ model: text("model").notNull().unique(),
1694
+ dims: integer("dims").notNull(),
1695
+ /** 1 once it holds every vector; until then searches scan. */
1696
+ ready: integer("ready").notNull(),
1697
+ /** The last `content_hash` the fill of older vectors reached. */
1698
+ filledThrough: text("filled_through").notNull(),
1699
+ });
1700
+ /** Always empty: each changed chunk's id passes through it so that every model's index re-derives its row. */
1701
+ export const vectorIndexRedo = sqliteTable("vector_index_redo", {
1702
+ chunkId: integer("chunk_id").notNull(),
1703
+ });
1687
1704
  /** Settings of the store file itself, shared by every profile, tg and MAX — unlike a profile's config file. */
1688
1705
  export const storeSettings = sqliteTable("store_settings", {
1689
1706
  key: text("key").primaryKey(),