@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.
- package/CHANGELOG.md +19 -1
- package/dist/cli/config-issues.d.ts +30 -0
- package/dist/cli/config-issues.js +90 -12
- package/dist/cli/config-keys.js +6 -7
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/messenger/conversation-answers.d.ts +1 -0
- package/dist/cli/messenger/conversations-command.js +11 -3
- package/dist/cli/messenger/message-answers.d.ts +1 -0
- package/dist/cli/messenger/message-writes.d.ts +2 -0
- package/dist/cli/messenger/messages-search-command.js +11 -1
- package/dist/cli/messenger/private-files.js +8 -4
- package/dist/cli/messenger/search-answers.js +13 -0
- package/dist/cli/settings.js +12 -43
- package/dist/cli/steps.js +6 -0
- package/dist/entities/conversation-preparation.d.ts +10 -0
- package/dist/entities/conversation-preparation.js +2 -0
- package/dist/entities/conversation-results.d.ts +10 -0
- package/dist/entities/evidence.d.ts +8 -0
- package/dist/entities/index.d.ts +45 -0
- package/dist/entities/listener.d.ts +4 -0
- package/dist/entities/message-context.d.ts +3 -0
- package/dist/entities/message-transfer.d.ts +8 -0
- package/dist/entities/message-write.d.ts +4 -0
- package/dist/entities/message.d.ts +2 -0
- package/dist/entities/message.js +2 -0
- package/dist/entities/store-records.d.ts +5 -0
- package/dist/services/embeddings-maintenance.js +71 -2
- package/dist/services/embeddings-search.js +3 -1
- package/dist/services/embeddings.d.ts +7 -0
- package/dist/services/messages-archive-search.d.ts +2 -1
- package/dist/services/messages-archive-search.js +4 -4
- package/dist/services/messages-discovery.d.ts +24 -1
- package/dist/services/messages-discovery.js +61 -4
- package/dist/services/messages-meaning.d.ts +7 -0
- package/dist/services/messages-meaning.js +53 -0
- package/dist/services/messages-search.d.ts +5 -0
- package/dist/services/messages.d.ts +5 -0
- package/dist/services/messages.js +2 -1
- package/dist/services/search-catchup.js +1 -0
- package/dist/store/sqlite/message-chunks.d.ts +44 -0
- package/dist/store/sqlite/message-chunks.js +76 -0
- package/dist/store/sqlite/vectors.js +4 -1
- package/dist/store/vectors.d.ts +36 -0
- package/dist/store/vectors.js +54 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|
|
@@ -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
|
-
|
|
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];
|
package/dist/store/vectors.d.ts
CHANGED
|
@@ -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;
|
package/dist/store/vectors.js
CHANGED
|
@@ -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.
|
|
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",
|