@wirecat/cli-core 0.430.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 (46) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/dist/cli/config-issues.d.ts +30 -0
  3. package/dist/cli/config-issues.js +90 -12
  4. package/dist/cli/config-keys.js +6 -7
  5. package/dist/cli/index.d.ts +1 -1
  6. package/dist/cli/index.js +1 -1
  7. package/dist/cli/messenger/conversation-answers.d.ts +1 -0
  8. package/dist/cli/messenger/conversations-command.js +11 -3
  9. package/dist/cli/messenger/message-answers.d.ts +1 -0
  10. package/dist/cli/messenger/message-writes.d.ts +2 -0
  11. package/dist/cli/messenger/messages-search-command.js +11 -1
  12. package/dist/cli/messenger/private-files.js +8 -4
  13. package/dist/cli/messenger/search-answers.js +13 -0
  14. package/dist/cli/settings.js +12 -43
  15. package/dist/cli/steps.js +6 -0
  16. package/dist/entities/conversation-preparation.d.ts +10 -0
  17. package/dist/entities/conversation-preparation.js +2 -0
  18. package/dist/entities/conversation-results.d.ts +10 -0
  19. package/dist/entities/evidence.d.ts +8 -0
  20. package/dist/entities/index.d.ts +45 -0
  21. package/dist/entities/listener.d.ts +4 -0
  22. package/dist/entities/message-context.d.ts +3 -0
  23. package/dist/entities/message-transfer.d.ts +8 -0
  24. package/dist/entities/message-write.d.ts +4 -0
  25. package/dist/entities/message.d.ts +2 -0
  26. package/dist/entities/message.js +2 -0
  27. package/dist/entities/store-records.d.ts +5 -0
  28. package/dist/services/embeddings-maintenance.js +71 -2
  29. package/dist/services/embeddings-search.js +3 -1
  30. package/dist/services/embeddings.d.ts +7 -0
  31. package/dist/services/messages-archive-search.d.ts +2 -1
  32. package/dist/services/messages-archive-search.js +4 -4
  33. package/dist/services/messages-discovery.d.ts +24 -1
  34. package/dist/services/messages-discovery.js +61 -4
  35. package/dist/services/messages-meaning.d.ts +7 -0
  36. package/dist/services/messages-meaning.js +53 -0
  37. package/dist/services/messages-search.d.ts +5 -0
  38. package/dist/services/messages.d.ts +5 -0
  39. package/dist/services/messages.js +2 -1
  40. package/dist/services/search-catchup.js +1 -0
  41. package/dist/store/sqlite/message-chunks.d.ts +44 -0
  42. package/dist/store/sqlite/message-chunks.js +76 -0
  43. package/dist/store/sqlite/vectors.js +4 -1
  44. package/dist/store/vectors.d.ts +36 -0
  45. package/dist/store/vectors.js +54 -0
  46. package/package.json +1 -1
@@ -105,6 +105,8 @@ export interface RefreshOptions {
105
105
  maxChunks?: number;
106
106
  threads?: number;
107
107
  progress?: (note: string) => void;
108
+ /** With the default local model, also each message's own vector, which `--discover` reads; `false` skips it. */
109
+ messages?: boolean;
108
110
  }
109
111
  export interface Refreshed {
110
112
  model: string;
@@ -117,6 +119,11 @@ export interface Refreshed {
117
119
  chat: Id;
118
120
  needs: "build" | "embed";
119
121
  }[];
122
+ /** Messages given their own vector by this run, and how many still lack one; only when they were looked at. */
123
+ messages?: {
124
+ embedded: number;
125
+ left: number;
126
+ };
120
127
  }
121
128
  export interface FoundConversations {
122
129
  accounts?: AccountKey[];
@@ -1,6 +1,7 @@
1
1
  import type { AccountKey, MessageStore, StoredHit } from "../store/store.js";
2
2
  import type { Saved } from "./message-resolution.js";
3
3
  import type { SearchFound, SearchQuery } from "./messages.js";
4
+ import { type MeaningLane } from "./messages-discovery.js";
4
5
  import { type MessageStats, type StatsGrouping } from "./messages-search.js";
5
6
  /** How long a search may spend building the word index, then the stems, first (phase 2 plan S3: about 200 ms). */
6
7
  export declare const SEARCH_FILL_MS = 200;
@@ -18,6 +19,6 @@ export declare const MAX_SEARCH_OFFSET = 1000;
18
19
  * Search the store, one page at a time: `offset` skips the hits a page before held, and `nextOffset` says where
19
20
  * the next page starts. A page is the hits up to `offset + limit` read again and cut, so deep pages cost more.
20
21
  */
21
- export declare const searchStore: (store: MessageStore, account: AccountKey, request: SearchQuery, messenger?: Saved) => Promise<SearchFound>;
22
+ export declare const searchStore: (store: MessageStore, account: AccountKey, request: SearchQuery, messenger?: Saved, meaning?: MeaningLane) => Promise<SearchFound>;
22
23
  export declare const accountOf: (hit: StoredHit) => AccountKey;
23
24
  export declare const keyOf: ({ provider, account }: AccountKey) => string;
@@ -43,18 +43,18 @@ export const MAX_SEARCH_OFFSET = 1000;
43
43
  * Search the store, one page at a time: `offset` skips the hits a page before held, and `nextOffset` says where
44
44
  * the next page starts. A page is the hits up to `offset + limit` read again and cut, so deep pages cost more.
45
45
  */
46
- export const searchStore = async (store, account, request, messenger = {}) => {
46
+ export const searchStore = async (store, account, request, messenger = {}, meaning) => {
47
47
  const offset = request.offset ?? 0;
48
48
  if (!Number.isInteger(offset) || offset < 0 || offset > MAX_SEARCH_OFFSET)
49
49
  throw new CliError("validation_error", `offset takes a whole number from 0 to ${MAX_SEARCH_OFFSET}`);
50
- const found = await searchPage(store, account, { ...request, limit: request.limit + offset }, messenger);
50
+ const found = await searchPage(store, account, { ...request, limit: request.limit + offset }, messenger, meaning);
51
51
  return {
52
52
  ...found,
53
53
  items: found.items.slice(offset),
54
54
  ...(found.hasMore && offset + request.limit <= MAX_SEARCH_OFFSET ? { nextOffset: offset + request.limit } : {}),
55
55
  };
56
56
  };
57
- const searchPage = async (store, account, request, messenger) => {
57
+ const searchPage = async (store, account, request, messenger, meaning) => {
58
58
  if (request.signal?.aborted)
59
59
  throw new CliError("validation_error", "search was aborted", { reason: "query.aborted", complete: false });
60
60
  if (request.thread)
@@ -65,7 +65,7 @@ const searchPage = async (store, account, request, messenger) => {
65
65
  throw new CliError("validation_error", "discovery searches the local archive; use backend archive");
66
66
  await topUp(store);
67
67
  const found = request.discover
68
- ? await searchDiscovery(store, account, { ...request, thread: undefined }, messenger)
68
+ ? await searchDiscovery(store, account, { ...request, thread: undefined }, messenger, meaning)
69
69
  : await searchLucene(store, account, request, messenger);
70
70
  return withThreads(store, found, request);
71
71
  };
@@ -1,4 +1,27 @@
1
+ import type { Id } from "../domain/models.js";
1
2
  import type { MessengerTraits } from "../ports/traits.js";
2
3
  import type { AccountKey, MessageStore } from "../store/store.js";
3
4
  import type { SearchFound, SearchQuery } from "./messages.js";
4
- export declare const searchDiscovery: (store: MessageStore, account: AccountKey, asked: SearchQuery, messenger?: Partial<Pick<MessengerTraits, "savedChatId" | "app" | "history" | "provider">>) => Promise<SearchFound>;
5
+ /** How many of the messages nearest in meaning join the fusion: the benchmark's pool (performance/search/evidence-pipeline). */
6
+ export declare const MEANING_DEPTH = 100;
7
+ export type MeaningRanked = (AccountKey & {
8
+ chatId: Id;
9
+ id: Id;
10
+ score: number;
11
+ })[];
12
+ /** Why meaning was not searched, and the step that would make it searchable. */
13
+ export interface MeaningSkipped {
14
+ reason: string;
15
+ /** Absent when the model itself failed: nothing the owner runs would fix it. */
16
+ step?: "models.download" | "conversations.rebuild";
17
+ model: string;
18
+ }
19
+ /** The messages of these accounts nearest in meaning to the question, best first; or why there are none. */
20
+ export type MeaningLane = (question: string, accounts: AccountKey[]) => Promise<{
21
+ model: string;
22
+ ranked: MeaningRanked;
23
+ partly?: MeaningSkipped;
24
+ } | {
25
+ skipped: MeaningSkipped;
26
+ }>;
27
+ export declare const searchDiscovery: (store: MessageStore, account: AccountKey, asked: SearchQuery, messenger?: Partial<Pick<MessengerTraits, "savedChatId" | "app" | "history" | "provider">>, meaning?: MeaningLane) => Promise<SearchFound>;
@@ -5,7 +5,10 @@ import { exhausted, QUERY_LIMITS } from "../search/lucene/types.js";
5
5
  import { planQuestion, words } from "../search/question-plan.js";
6
6
  import { searchCombined } from "./messages-combined.js";
7
7
  import { prepareLucene, searchLucene } from "./messages-search.js";
8
- export const searchDiscovery = async (store, account, asked, messenger = {}) => {
8
+ /** How many of the messages nearest in meaning join the fusion: the benchmark's pool (performance/search/evidence-pipeline). */
9
+ export const MEANING_DEPTH = 100;
10
+ const RRF_K = 60;
11
+ export const searchDiscovery = async (store, account, asked, messenger = {}, meaning) => {
9
12
  // A question's own "?" would parse as a one-character wildcard and send it to strict search (BUG-395).
10
13
  const request = asked.ast !== undefined || asked.exact || !asked.text
11
14
  ? asked
@@ -25,6 +28,8 @@ export const searchDiscovery = async (store, account, asked, messenger = {}) =>
25
28
  return searchLucene(store, account, request, messenger);
26
29
  if (terms.length > 64)
27
30
  exhausted("discovery terms");
31
+ // Before the clock starts: loading the model alone takes about a second of the word search's budget.
32
+ const lane = meaning ? await meaning(text, original.scopeAccounts) : undefined;
28
33
  const started = performance.now();
29
34
  const check = () => {
30
35
  if (request.signal?.aborted)
@@ -115,8 +120,7 @@ export const searchDiscovery = async (store, account, asked, messenger = {}) =>
115
120
  const stem = (w) => original.execution.stemmer?.stemToken(w) ?? w;
116
121
  const stems = terms.map(stem);
117
122
  let bytes = 0;
118
- candidates = candidates
119
- .map((m) => {
123
+ const described = (m) => {
120
124
  check();
121
125
  const parent = parentByLocator.get(m.locator);
122
126
  const body = `${parent ? `${parent.text}\n` : ""}${m.text}`;
@@ -137,9 +141,17 @@ export const searchDiscovery = async (store, account, asked, messenger = {}) =>
137
141
  ...(parent ? { parent: parent.locator } : {}),
138
142
  },
139
143
  };
140
- })
144
+ };
145
+ candidates = candidates
146
+ .map(described)
141
147
  .sort((a, b) => (b.score ?? 0) - (a.score ?? 0) ||
142
148
  Number(parentByLocator.has(b.locator)) - Number(parentByLocator.has(a.locator)));
149
+ let meaningCandidates = 0;
150
+ if (lane && "ranked" in lane) {
151
+ const near = await nearEligible(store, account, request, messenger, lane.ranked, plan.scope);
152
+ meaningCandidates = near.length;
153
+ candidates = fusedWithMeaning(candidates, near, described);
154
+ }
143
155
  check();
144
156
  const items = await Promise.all(candidates.slice(0, request.limit).map(async ({ fused: _fused, ...m }) => {
145
157
  if (!request.context)
@@ -171,7 +183,52 @@ export const searchDiscovery = async (store, account, asked, messenger = {}) =>
171
183
  truncated,
172
184
  terms,
173
185
  queries: [...(plan.changed ? [] : plan.queries), relaxed],
186
+ ...(lane && "ranked" in lane ? { meaning: { model: lane.model, candidates: meaningCandidates } } : {}),
174
187
  },
175
188
  },
189
+ ...(lane && "skipped" in lane ? { meaningSkipped: lane.skipped } : {}),
190
+ ...(lane && "partly" in lane && lane.partly ? { meaningSkipped: lane.partly } : {}),
176
191
  };
177
192
  };
193
+ /** The nearest messages the question's filters allow, as hits, nearest first. */
194
+ const nearEligible = async (store, account, request, messenger, ranked, scope) => {
195
+ const keyOf = (one) => JSON.stringify([one.provider, one.account, one.chatId, one.id]);
196
+ const only = ranked
197
+ .slice(0, MEANING_DEPTH)
198
+ .filter((m) => !request.only || request.only.some((key) => keyOf(key) === keyOf(m)))
199
+ .map(({ provider, account, chatId, id }) => ({ provider, account, chatId, id }));
200
+ if (!only.length)
201
+ return [];
202
+ const eligible = await searchLucene(store, account, { ...request, text: scope || undefined, ast: undefined, limit: only.length, only, context: 0, thread: undefined }, messenger);
203
+ const hits = new Map(eligible.items.map((hit) => {
204
+ const { provider, account } = parseLocator(hit.locator);
205
+ return [keyOf({ provider, account, chatId: hit.chatId, id: hit.id }), hit];
206
+ }));
207
+ return ranked.flatMap((m) => {
208
+ const hit = hits.get(keyOf(m));
209
+ return hit ? [{ ...hit, cosine: m.score }] : [];
210
+ });
211
+ };
212
+ /**
213
+ * Reciprocal rank fusion of discovery's order and the meaning order, k = 60, as the benchmark's A2 arm: a hit
214
+ * scores 1 / (k + rank) in each list it is in. Coverage is left out of the fused score, so a message that answers
215
+ * in other words can rise above one that only repeats them.
216
+ */
217
+ const fusedWithMeaning = (words, near, described) => {
218
+ const merged = new Map();
219
+ const by = (hit, foundBy) => hit.discovery ? { ...hit, discovery: { ...hit.discovery, foundBy } } : hit;
220
+ words.forEach((hit, rank) => {
221
+ merged.set(hit.locator, { hit: by(hit, ["words"]), rrf: 1 / (RRF_K + rank + 1) });
222
+ });
223
+ near.forEach(({ cosine: _, ...m }, rank) => {
224
+ const share = 1 / (RRF_K + rank + 1);
225
+ const held = merged.get(m.locator);
226
+ if (held) {
227
+ held.rrf += share;
228
+ held.hit = by(held.hit, ["words", "meaning"]);
229
+ }
230
+ else
231
+ merged.set(m.locator, { hit: by(described({ ...m, fused: 0 }), ["meaning"]), rrf: share });
232
+ });
233
+ return [...merged.values()].sort((a, b) => b.rrf - a.rrf).map(({ hit, rrf }) => ({ ...hit, score: rrf }));
234
+ };
@@ -0,0 +1,7 @@
1
+ import type { ServiceDeps } from "./deps.js";
2
+ import { type MeaningLane } from "./messages-discovery.js";
3
+ /**
4
+ * Discovery's meaning half: the default local model over each message's own vector. It runs only when the model is
5
+ * on disk and vectors exist — never a download, never a remote model — and any failure leaves the words alone.
6
+ */
7
+ export declare const meaningLane: (deps: ServiceDeps) => MeaningLane;
@@ -0,0 +1,53 @@
1
+ import { DEFAULT_TEXT_MODEL } from "../embeddings/models.js";
2
+ import { embeddingTarget, withEmbedder } from "./embeddings-maintenance.js";
3
+ import { MEANING_DEPTH } from "./messages-discovery.js";
4
+ import { plainWords } from "./pictures.js";
5
+ /**
6
+ * Discovery's meaning half: the default local model over each message's own vector. It runs only when the model is
7
+ * on disk and vectors exist — never a download, never a remote model — and any failure leaves the words alone.
8
+ */
9
+ export const meaningLane = (deps) => async (question, accounts) => {
10
+ const target = embeddingTarget(deps, DEFAULT_TEXT_MODEL);
11
+ const skipped = (reason, step) => ({
12
+ skipped: { reason, model: target.id, ...(step ? { step } : {}) },
13
+ });
14
+ if (!target.installed)
15
+ return skipped(`${target.id} is not downloaded`, "models.download");
16
+ const store = await deps.store();
17
+ const ready = [];
18
+ const without = [];
19
+ for (const one of accounts)
20
+ ((await store.vectors.hasMessageVectors(one, target.key)) ? ready : without).push(one);
21
+ if (ready.length === 0)
22
+ return skipped("no message has a vector yet", "conversations.rebuild");
23
+ const words = plainWords(question);
24
+ try {
25
+ const ranked = [];
26
+ if (words) {
27
+ await withEmbedder(deps, target, DEFAULT_TEXT_MODEL, {}, async (embedder) => {
28
+ const [query] = await embedder.embed([words], "query");
29
+ for (const one of ready) {
30
+ const near = await store.vectors.nearestMessages(one, {
31
+ model: target.key,
32
+ limit: MEANING_DEPTH,
33
+ query: query,
34
+ });
35
+ for (const { chatId, messageId, score } of near)
36
+ ranked.push({ ...one, chatId, id: messageId, score });
37
+ }
38
+ });
39
+ }
40
+ return {
41
+ model: target.id,
42
+ ranked: ranked.sort((a, b) => b.score - a.score).slice(0, MEANING_DEPTH),
43
+ ...(without.length
44
+ ? {
45
+ partly: skipped(`${without.map(({ provider, account }) => `${provider}:${account}`).join(", ")} has no message vectors yet`, "conversations.rebuild").skipped,
46
+ }
47
+ : {}),
48
+ };
49
+ }
50
+ catch (error) {
51
+ return skipped(`${target.id} failed: ${error instanceof Error ? error.message : String(error)}`);
52
+ }
53
+ };
@@ -15,6 +15,11 @@ export interface QueryMetadata {
15
15
  truncated: boolean;
16
16
  terms: string[];
17
17
  queries: string[];
18
+ /** Present when meaning was searched too: the model, and how many of its nearest messages the filters allowed. */
19
+ meaning?: {
20
+ model: string;
21
+ candidates: number;
22
+ };
18
23
  };
19
24
  version: 1;
20
25
  fieldsVersion: number;
@@ -7,6 +7,7 @@ import { type Operated } from "../sends/guarded.js";
7
7
  import type { PermissionKey } from "../sends/permissions.js";
8
8
  import type { AccountKey, ChatCompleteness, MatchPlace, StoredHit } from "../store/store.js";
9
9
  import { type ServiceDeps } from "./deps.js";
10
+ import type { MeaningSkipped } from "./messages-discovery.js";
10
11
  import type { MessageStats, QueryMetadata, SearchCoverage, StatsGrouping } from "./messages-search.js";
11
12
  import type { PictureHit } from "./pictures.js";
12
13
  import { type SearchAllFound, type SearchAllRequest } from "./search-all.js";
@@ -79,6 +80,8 @@ export type FoundMessage = StoredHit & {
79
80
  matchedTerms: string[];
80
81
  missingTerms: string[];
81
82
  parent?: string;
83
+ /** Only when meaning was searched too: which of the two lists found it. */
84
+ foundBy?: ("words" | "meaning")[];
82
85
  };
83
86
  match?: "words" | "beginnings" | "corrected";
84
87
  /** The garbled spellings a `corrected` hit's transcript holds, per query word. */
@@ -116,6 +119,8 @@ export interface SearchFound extends Page<FoundMessage> {
116
119
  /** Per chat of the page: how much of its history the store holds. */
117
120
  completeness: (ChatCompleteness & AccountKey)[];
118
121
  /** `false` while the word index is being built. */
122
+ /** `--discover` searched by words alone: why meaning was not searched, and the step that would enable it. */
123
+ meaningSkipped?: MeaningSkipped;
119
124
  wordsReady: boolean;
120
125
  /** The same for the stems, in strict search: `false` until a stemmed search may run. */
121
126
  stemsReady?: boolean;
@@ -12,6 +12,7 @@ import { uploadSize } from "../sends/upload.js";
12
12
  import { crossAccount, fromStore, PUSHED } from "./deps.js";
13
13
  import { readChatId, storedChatId } from "./message-resolution.js";
14
14
  import { accountOf, keyOf, searchStore, statsStore, topUp } from "./messages-archive-search.js";
15
+ import { meaningLane } from "./messages-meaning.js";
15
16
  import { searchAll } from "./search-all.js";
16
17
  import { searchAllWithMeetings, } from "./search-all-meetings.js";
17
18
  import { searchRecordOf } from "./search-record.js";
@@ -31,7 +32,7 @@ export const messagesService = (deps) => {
31
32
  return { ...account, chatId, id };
32
33
  })
33
34
  : undefined;
34
- const found = await searchStore(store, account, only ? { ...query, only } : query, deps.messenger);
35
+ const found = await searchStore(store, account, only ? { ...query, only } : query, deps.messenger, query.discover && query.kind !== "mail" ? meaningLane(deps) : undefined);
35
36
  return { found, server };
36
37
  };
37
38
  const unifiedMessages = (store, account) => async (query) => {
@@ -36,6 +36,7 @@ export const catchUpSearch = async (deps, chat, options, signal, clock = Date.no
36
36
  maxMessages,
37
37
  check,
38
38
  threads: 1,
39
+ messages: false,
39
40
  });
40
41
  check();
41
42
  return {
@@ -0,0 +1,44 @@
1
+ import type { StoreContext } from "./open.js";
2
+ /** A live message with text, and the chunk and vector it already has, if any. */
3
+ export interface MessageToEmbed {
4
+ messagePk: number;
5
+ text: string;
6
+ sentAt: number;
7
+ hash: string | null;
8
+ embedded: boolean;
9
+ }
10
+ /** Live messages with text of the account, by pk after `after`; the caller compares hashes. */
11
+ export declare const messagesToEmbed: ({ orm }: StoreContext, accountPk: number, { chatKey, model, after, limit }: {
12
+ chatKey?: number;
13
+ model: string;
14
+ after: number;
15
+ limit: number;
16
+ }) => MessageToEmbed[];
17
+ /** Makes each hash its message's chunk; the vectors are saved apart, by `saveVectors`. */
18
+ export declare const saveMessageChunks: (context: StoreContext, accountPk: number, chunks: {
19
+ messagePk: number;
20
+ hash: string;
21
+ length: number;
22
+ occurredAt: number;
23
+ }[]) => void;
24
+ /**
25
+ * Drops the chunks of the account's messages that are gone, deleted or emptied, with the vectors nothing else
26
+ * uses: no trigger does it for messages, and a deleted message keeps no trace of its text.
27
+ */
28
+ export declare const dropGoneMessageChunks: (context: StoreContext, accountPk: number) => number;
29
+ /** Whether any message of the account has a vector of `model`. */
30
+ export declare const hasMessageVectors: ({ orm }: StoreContext, accountPk: number, model: string) => boolean;
31
+ export interface NearestMessage {
32
+ chatId: string;
33
+ messageId: string;
34
+ score: number;
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, }: {
38
+ chatKey?: number;
39
+ model: string;
40
+ since?: number;
41
+ before?: number;
42
+ limit: number;
43
+ query: Float32Array;
44
+ }) => NearestMessage[];
@@ -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.430.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",