@wirecat/cli-core 0.434.0 → 0.436.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 +47 -0
- package/dist/cli/messenger/context.js +1 -0
- package/dist/cli/messenger/conversation-answers.d.ts +1 -0
- package/dist/cli/messenger/conversations-command.js +2 -2
- 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 +6 -10
- package/dist/cli/messenger/models-command.js +34 -7
- package/dist/cli/messenger/search-answers.d.ts +3 -0
- package/dist/cli/messenger/search-answers.js +40 -8
- package/dist/cli/messenger/search-command.js +3 -1
- package/dist/cli/messenger/store-maintenance-command.js +7 -4
- package/dist/cli/model-catalogue-command.js +1 -1
- package/dist/cli/profile.d.ts +4 -1
- package/dist/cli/profile.js +22 -7
- package/dist/cli/program.js +8 -4
- package/dist/cli/settings.d.ts +4 -0
- package/dist/cli/settings.js +5 -0
- package/dist/cli/shared-command-exports.d.ts +4 -0
- package/dist/embeddings/child.js +15 -5
- package/dist/embeddings/embed.d.ts +16 -2
- package/dist/embeddings/embed.js +57 -28
- package/dist/embeddings/models.d.ts +18 -0
- package/dist/embeddings/models.js +69 -0
- package/dist/embeddings/process.d.ts +6 -1
- package/dist/embeddings/process.js +23 -7
- package/dist/embeddings/rerank.d.ts +10 -0
- package/dist/embeddings/rerank.js +31 -0
- package/dist/entities/conversation-results.d.ts +2 -0
- package/dist/entities/evidence.d.ts +8 -0
- package/dist/entities/index.d.ts +27 -1
- package/dist/entities/listener.d.ts +4 -0
- package/dist/entities/message-context.d.ts +3 -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/model.d.ts +5 -2
- package/dist/entities/model.js +5 -2
- package/dist/entities/store-maintenance.d.ts +1 -0
- package/dist/entities/store-maintenance.js +8 -1
- package/dist/entities/store-records.d.ts +1 -0
- package/dist/mcp/tool.d.ts +1 -1
- package/dist/mcp/tool.js +1 -0
- package/dist/services/deps.d.ts +3 -0
- package/dist/services/embeddings-maintenance.d.ts +10 -2
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +1 -0
- package/dist/services/messages-archive-search.d.ts +2 -2
- package/dist/services/messages-archive-search.js +4 -4
- package/dist/services/messages-discovery.d.ts +33 -4
- package/dist/services/messages-discovery.js +88 -3
- package/dist/services/messages-meaning.d.ts +2 -1
- package/dist/services/messages-meaning.js +7 -3
- package/dist/services/messages-rerank.d.ts +7 -0
- package/dist/services/messages-rerank.js +42 -0
- package/dist/services/messages-search.d.ts +5 -0
- package/dist/services/messages.d.ts +6 -2
- package/dist/services/messages.js +4 -3
- package/dist/services/model-downloads.d.ts +3 -1
- package/dist/services/model-downloads.js +20 -1
- package/dist/services/notes-search.d.ts +7 -2
- package/dist/services/notes-search.js +7 -4
- package/dist/services/search-all-meetings.d.ts +3 -2
- package/dist/services/search-all-meetings.js +8 -8
- package/dist/services/search-all.d.ts +7 -1
- package/dist/services/search-all.js +109 -45
- package/dist/store/exports.d.ts +1 -1
- package/dist/store/exports.js +1 -1
- package/dist/store/sqlite/accounts.js +2 -2
- package/dist/store/sqlite/chats.d.ts +1 -1
- package/dist/store/sqlite/chats.js +11 -4
- package/dist/store/sqlite/document-pictures.js +2 -7
- package/dist/store/sqlite/email-index.d.ts +1 -3
- package/dist/store/sqlite/email-index.js +5 -21
- package/dist/store/sqlite/emails.js +4 -3
- package/dist/store/sqlite/image-chunks.d.ts +2 -2
- package/dist/store/sqlite/image-chunks.js +10 -13
- package/dist/store/sqlite/meeting-vectors.js +11 -9
- package/dist/store/sqlite/message-chunks.js +3 -8
- package/dist/store/sqlite/note-index.js +2 -7
- package/dist/store/sqlite/schema.d.ts +1 -1
- package/dist/store/sqlite/schema.js +1 -1
- package/dist/store/sqlite/vector-index.d.ts +9 -0
- package/dist/store/sqlite/vector-index.js +25 -0
- package/dist/store/sqlite/vectors.d.ts +2 -2
- package/dist/store/sqlite/vectors.js +11 -8
- package/package.json +1 -1
|
@@ -8,7 +8,17 @@ 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
9
|
export const MEANING_DEPTH = 100;
|
|
10
10
|
const RRF_K = 60;
|
|
11
|
-
|
|
11
|
+
/** How many of discovery's first hits the reranker reads, and its share of their order: the benchmark's winner. */
|
|
12
|
+
export const RERANK_DEPTH = 50;
|
|
13
|
+
const RERANK_WEIGHT = 0.7;
|
|
14
|
+
/** Pairs scored between two looks at the clock. */
|
|
15
|
+
const RERANK_CHUNK = 5;
|
|
16
|
+
/**
|
|
17
|
+
* Scoring has its own clock, not the word search's 2 s: 50 pairs are bounded work, and BGE on WebAssembly takes
|
|
18
|
+
* ~7–10 s of it (cli-testing performance/search/evidence-pipeline, "In Core").
|
|
19
|
+
*/
|
|
20
|
+
export const RERANK_MILLISECONDS = 15_000;
|
|
21
|
+
export const searchDiscovery = async (store, account, asked, messenger = {}, meaning, rerank) => {
|
|
12
22
|
// A question's own "?" would parse as a one-character wildcard and send it to strict search (BUG-395).
|
|
13
23
|
const request = asked.ast !== undefined || asked.exact || !asked.text
|
|
14
24
|
? asked
|
|
@@ -30,7 +40,7 @@ export const searchDiscovery = async (store, account, asked, messenger = {}, mea
|
|
|
30
40
|
exhausted("discovery terms");
|
|
31
41
|
// Before the clock starts: loading the model alone takes about a second of the word search's budget.
|
|
32
42
|
const lane = meaning ? await meaning(text, original.scopeAccounts) : undefined;
|
|
33
|
-
|
|
43
|
+
let started = performance.now();
|
|
34
44
|
const check = () => {
|
|
35
45
|
if (request.signal?.aborted)
|
|
36
46
|
throw new CliError("validation_error", "search was aborted", { reason: "query.aborted", complete: false });
|
|
@@ -152,6 +162,51 @@ export const searchDiscovery = async (store, account, asked, messenger = {}, mea
|
|
|
152
162
|
meaningCandidates = near.length;
|
|
153
163
|
candidates = fusedWithMeaning(candidates, near, described);
|
|
154
164
|
}
|
|
165
|
+
let reranked;
|
|
166
|
+
let rerankSkipped;
|
|
167
|
+
if (rerank && candidates.length > 1) {
|
|
168
|
+
const opening = performance.now();
|
|
169
|
+
const reranker = await rerank();
|
|
170
|
+
if ("skipped" in reranker)
|
|
171
|
+
rerankSkipped = reranker.skipped;
|
|
172
|
+
else {
|
|
173
|
+
const scoring = performance.now();
|
|
174
|
+
try {
|
|
175
|
+
const head = candidates.slice(0, RERANK_DEPTH);
|
|
176
|
+
// Telegram keeps only the id a reply answers: the parent is read from the store.
|
|
177
|
+
const parents = await Promise.all(head.map(async (m) => parentByLocator.get(m.locator) ??
|
|
178
|
+
m.replyTo ??
|
|
179
|
+
(m.replyToId === undefined
|
|
180
|
+
? undefined
|
|
181
|
+
: await store.messages.message(accountOfHit(m), m.replyToId, { chatId: m.chatId }))));
|
|
182
|
+
const passages = head.map((m, i) => pairInput(m, parents[i]));
|
|
183
|
+
let scores = [];
|
|
184
|
+
let chunk = 0;
|
|
185
|
+
while (scores.length < passages.length) {
|
|
186
|
+
if (request.signal?.aborted)
|
|
187
|
+
throw new CliError("validation_error", "search was aborted", { reason: "query.aborted", complete: false });
|
|
188
|
+
const now = performance.now();
|
|
189
|
+
if (now + chunk > scoring + RERANK_MILLISECONDS)
|
|
190
|
+
break;
|
|
191
|
+
scores = scores.concat(await reranker.score(text, passages.slice(scores.length, scores.length + RERANK_CHUNK)));
|
|
192
|
+
chunk = performance.now() - now;
|
|
193
|
+
}
|
|
194
|
+
if (scores.length < passages.length || performance.now() > scoring + RERANK_MILLISECONDS) {
|
|
195
|
+
// A part of the hits rescored cannot be mixed with the rest.
|
|
196
|
+
rerankSkipped = { reason: "scoring would take longer than it may", model: reranker.model };
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
candidates = mixedWithReranker(head, scores).concat(candidates.slice(RERANK_DEPTH));
|
|
200
|
+
reranked = { model: reranker.model, scored: head.length };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
await reranker.done();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Loading and scoring are not the word search's time, as loading is not for meaning.
|
|
208
|
+
started += performance.now() - opening;
|
|
209
|
+
}
|
|
155
210
|
check();
|
|
156
211
|
const items = await Promise.all(candidates.slice(0, request.limit).map(async ({ fused: _fused, ...m }) => {
|
|
157
212
|
if (!request.context)
|
|
@@ -184,14 +239,16 @@ export const searchDiscovery = async (store, account, asked, messenger = {}, mea
|
|
|
184
239
|
terms,
|
|
185
240
|
queries: [...(plan.changed ? [] : plan.queries), relaxed],
|
|
186
241
|
...(lane && "ranked" in lane ? { meaning: { model: lane.model, candidates: meaningCandidates } } : {}),
|
|
242
|
+
...(reranked ? { rerank: reranked } : {}),
|
|
187
243
|
},
|
|
188
244
|
},
|
|
245
|
+
...(rerankSkipped ? { rerankSkipped } : {}),
|
|
189
246
|
...(lane && "skipped" in lane ? { meaningSkipped: lane.skipped } : {}),
|
|
190
247
|
...(lane && "partly" in lane && lane.partly ? { meaningSkipped: lane.partly } : {}),
|
|
191
248
|
};
|
|
192
249
|
};
|
|
193
250
|
/** The nearest messages the question's filters allow, as hits, nearest first. */
|
|
194
|
-
const nearEligible = async (store, account, request, messenger, ranked, scope) => {
|
|
251
|
+
export const nearEligible = async (store, account, request, messenger, ranked, scope) => {
|
|
195
252
|
const keyOf = (one) => JSON.stringify([one.provider, one.account, one.chatId, one.id]);
|
|
196
253
|
const only = ranked
|
|
197
254
|
.slice(0, MEANING_DEPTH)
|
|
@@ -232,3 +289,31 @@ const fusedWithMeaning = (words, near, described) => {
|
|
|
232
289
|
});
|
|
233
290
|
return [...merged.values()].sort((a, b) => b.rrf - a.rrf).map(({ hit, rrf }) => ({ ...hit, score: rrf }));
|
|
234
291
|
};
|
|
292
|
+
const pairInput = (hit, parent) => {
|
|
293
|
+
const line = (m) => `[${hit.chatTitle ?? hit.chatId} · ${m.senderName ?? "unknown"} · ${m.timestamp?.slice(0, 10) ?? "undated"}] ${m.text}`;
|
|
294
|
+
return (parent ? [parent, hit] : [hit]).map(line).join("\n");
|
|
295
|
+
};
|
|
296
|
+
const accountOfHit = (hit) => {
|
|
297
|
+
const { provider, account } = parseLocator(hit.locator);
|
|
298
|
+
return { provider, account };
|
|
299
|
+
};
|
|
300
|
+
const scaled = (values) => {
|
|
301
|
+
const low = values.reduce((a, b) => Math.min(a, b), Number.POSITIVE_INFINITY);
|
|
302
|
+
const span = values.reduce((a, b) => Math.max(a, b), Number.NEGATIVE_INFINITY) - low;
|
|
303
|
+
return values.map((value) => (span ? (value - low) / span : 1));
|
|
304
|
+
};
|
|
305
|
+
/**
|
|
306
|
+
* The benchmark's winning mix: discovery's score and the reranker's, each scaled to 0–1 over the hits it read,
|
|
307
|
+
* weighed 0.3 and 0.7. `score` keeps discovery's own; the reranker's is beside it.
|
|
308
|
+
*/
|
|
309
|
+
const mixedWithReranker = (head, scores) => {
|
|
310
|
+
const fused = scaled(head.map((m) => m.score ?? 0));
|
|
311
|
+
const read = scaled(scores);
|
|
312
|
+
return head
|
|
313
|
+
.map((m, i) => ({
|
|
314
|
+
hit: m.discovery ? { ...m, discovery: { ...m.discovery, rerankerScore: scores[i] } } : m,
|
|
315
|
+
mixed: (1 - RERANK_WEIGHT) * fused[i] + RERANK_WEIGHT * read[i],
|
|
316
|
+
}))
|
|
317
|
+
.sort((a, b) => b.mixed - a.mixed)
|
|
318
|
+
.map(({ hit }) => hit);
|
|
319
|
+
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ServiceDeps } from "./deps.js";
|
|
2
|
+
import { type ModelDeps } from "./embeddings-maintenance.js";
|
|
2
3
|
import { type MeaningLane } from "./messages-discovery.js";
|
|
3
4
|
/**
|
|
4
5
|
* Discovery's meaning half: the default local model over each message's own vector. It runs only when the model is
|
|
5
6
|
* on disk and vectors exist — never a download, never a remote model — and any failure leaves the words alone.
|
|
6
7
|
*/
|
|
7
|
-
export declare const meaningLane: (deps: ServiceDeps) => MeaningLane;
|
|
8
|
+
export declare const meaningLane: (deps: ModelDeps & Pick<ServiceDeps, "store">) => MeaningLane;
|
|
@@ -6,7 +6,7 @@ import { plainWords } from "./pictures.js";
|
|
|
6
6
|
* Discovery's meaning half: the default local model over each message's own vector. It runs only when the model is
|
|
7
7
|
* on disk and vectors exist — never a download, never a remote model — and any failure leaves the words alone.
|
|
8
8
|
*/
|
|
9
|
-
export const meaningLane = (deps) => async (question, accounts) => {
|
|
9
|
+
export const meaningLane = (deps) => async (question, accounts, shared) => {
|
|
10
10
|
const target = embeddingTarget(deps, DEFAULT_TEXT_MODEL);
|
|
11
11
|
const skipped = (reason, step) => ({
|
|
12
12
|
skipped: { reason, model: target.id, ...(step ? { step } : {}) },
|
|
@@ -24,7 +24,7 @@ export const meaningLane = (deps) => async (question, accounts) => {
|
|
|
24
24
|
try {
|
|
25
25
|
const ranked = [];
|
|
26
26
|
if (words) {
|
|
27
|
-
|
|
27
|
+
const lookUp = async (embedder) => {
|
|
28
28
|
const [query] = await embedder.embed([words], "query");
|
|
29
29
|
for (const one of ready) {
|
|
30
30
|
const near = await store.vectors.nearestMessages(one, {
|
|
@@ -35,7 +35,11 @@ export const meaningLane = (deps) => async (question, accounts) => {
|
|
|
35
35
|
for (const { chatId, messageId, score } of near)
|
|
36
36
|
ranked.push({ ...one, chatId, id: messageId, score });
|
|
37
37
|
}
|
|
38
|
-
}
|
|
38
|
+
};
|
|
39
|
+
if (shared && !deps.embedders)
|
|
40
|
+
await lookUp(await shared.get(target.key, () => target.open()));
|
|
41
|
+
else
|
|
42
|
+
await withEmbedder(deps, target, DEFAULT_TEXT_MODEL, {}, lookUp);
|
|
39
43
|
}
|
|
40
44
|
return {
|
|
41
45
|
model: target.id,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ServiceDeps } from "./deps.js";
|
|
2
|
+
import type { RerankLane } from "./messages-discovery.js";
|
|
3
|
+
/**
|
|
4
|
+
* Discovery's last step: the reranker reads the question with each of the first hits. It runs only when the model
|
|
5
|
+
* is on disk — never a download, never a remote model — and any failure leaves discovery's order alone.
|
|
6
|
+
*/
|
|
7
|
+
export declare const rerankLane: (deps: ServiceDeps) => RerankLane | undefined;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { defaultThreads, isTextModelInstalled, textModelsDirectory } from "../embeddings/embed.js";
|
|
2
|
+
import { DEFAULT_RERANK_MODEL, rerankModel } from "../embeddings/models.js";
|
|
3
|
+
import { plainWords } from "./pictures.js";
|
|
4
|
+
/**
|
|
5
|
+
* Discovery's last step: the reranker reads the question with each of the first hits. It runs only when the model
|
|
6
|
+
* is on disk — never a download, never a remote model — and any failure leaves discovery's order alone.
|
|
7
|
+
*/
|
|
8
|
+
export const rerankLane = (deps) => {
|
|
9
|
+
const choice = deps.reranker ?? DEFAULT_RERANK_MODEL;
|
|
10
|
+
if (choice === "off")
|
|
11
|
+
return undefined;
|
|
12
|
+
return async () => {
|
|
13
|
+
const model = rerankModel(choice);
|
|
14
|
+
const directory = textModelsDirectory(deps.env);
|
|
15
|
+
if (!isTextModelInstalled(model, directory))
|
|
16
|
+
return { skipped: { reason: `${model.id} is not downloaded`, step: "models.download", model: model.id } };
|
|
17
|
+
try {
|
|
18
|
+
const warm = deps.embedders;
|
|
19
|
+
const reranker = warm
|
|
20
|
+
? await warm.reranker(`rerank:${model.id}`, async () => (await import("../embeddings/process.js")).openRerankerProcess(model, directory, {
|
|
21
|
+
threads: defaultThreads(),
|
|
22
|
+
}))
|
|
23
|
+
: await (await import("../embeddings/rerank.js")).openReranker(model, directory);
|
|
24
|
+
return {
|
|
25
|
+
model: model.id,
|
|
26
|
+
score: (question, passages) => reranker.score(plainWords(question), passages),
|
|
27
|
+
done: async () => {
|
|
28
|
+
if (!warm)
|
|
29
|
+
await reranker.close();
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return {
|
|
35
|
+
skipped: {
|
|
36
|
+
reason: `${model.id} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
37
|
+
model: model.id,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
};
|
|
@@ -20,6 +20,11 @@ export interface QueryMetadata {
|
|
|
20
20
|
model: string;
|
|
21
21
|
candidates: number;
|
|
22
22
|
};
|
|
23
|
+
/** Present when the reranker reordered the first hits: the model, and how many it read. */
|
|
24
|
+
rerank?: {
|
|
25
|
+
model: string;
|
|
26
|
+
scored: number;
|
|
27
|
+
};
|
|
23
28
|
};
|
|
24
29
|
version: 1;
|
|
25
30
|
fieldsVersion: number;
|
|
@@ -7,7 +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
|
+
import type { MeaningSkipped, RerankSkipped } from "./messages-discovery.js";
|
|
11
11
|
import type { MessageStats, QueryMetadata, SearchCoverage, StatsGrouping } from "./messages-search.js";
|
|
12
12
|
import type { PictureHit } from "./pictures.js";
|
|
13
13
|
import { type SearchAllFound, type SearchAllRequest } from "./search-all.js";
|
|
@@ -82,6 +82,8 @@ export type FoundMessage = StoredHit & {
|
|
|
82
82
|
parent?: string;
|
|
83
83
|
/** Only when meaning was searched too: which of the two lists found it. */
|
|
84
84
|
foundBy?: ("words" | "meaning")[];
|
|
85
|
+
/** Only when the reranker read it: the cross-encoder's own score, higher is better. */
|
|
86
|
+
rerankerScore?: number;
|
|
85
87
|
};
|
|
86
88
|
match?: "words" | "beginnings" | "corrected";
|
|
87
89
|
/** The garbled spellings a `corrected` hit's transcript holds, per query word. */
|
|
@@ -118,9 +120,11 @@ export interface SearchFound extends Page<FoundMessage> {
|
|
|
118
120
|
}[];
|
|
119
121
|
/** Per chat of the page: how much of its history the store holds. */
|
|
120
122
|
completeness: (ChatCompleteness & AccountKey)[];
|
|
121
|
-
/** `false` while the word index is being built. */
|
|
122
123
|
/** `--discover` searched by words alone: why meaning was not searched, and the step that would enable it. */
|
|
123
124
|
meaningSkipped?: MeaningSkipped;
|
|
125
|
+
/** `--discover` kept its own order: why the reranker did not read the hits, and the step that would let it. */
|
|
126
|
+
rerankSkipped?: RerankSkipped;
|
|
127
|
+
/** `false` while the word index is being built. */
|
|
124
128
|
wordsReady: boolean;
|
|
125
129
|
/** The same for the stems, in strict search: `false` until a stemmed search may run. */
|
|
126
130
|
stemsReady?: boolean;
|
|
@@ -13,6 +13,7 @@ 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
15
|
import { meaningLane } from "./messages-meaning.js";
|
|
16
|
+
import { rerankLane } from "./messages-rerank.js";
|
|
16
17
|
import { searchAll } from "./search-all.js";
|
|
17
18
|
import { searchAllWithMeetings, } from "./search-all-meetings.js";
|
|
18
19
|
import { searchRecordOf } from "./search-record.js";
|
|
@@ -32,7 +33,7 @@ export const messagesService = (deps) => {
|
|
|
32
33
|
return { ...account, chatId, id };
|
|
33
34
|
})
|
|
34
35
|
: undefined;
|
|
35
|
-
const found = await searchStore(store, account, only ? { ...query, only } : query, deps.messenger, query.discover && query.kind !== "mail" ? meaningLane(deps) : undefined);
|
|
36
|
+
const found = await searchStore(store, account, only ? { ...query, only } : query, deps.messenger, query.discover && query.kind !== "mail" ? meaningLane(deps) : undefined, query.discover && query.kind !== "mail" ? rerankLane(deps) : undefined);
|
|
36
37
|
return { found, server };
|
|
37
38
|
};
|
|
38
39
|
const unifiedMessages = (store, account) => async (query) => {
|
|
@@ -243,8 +244,8 @@ export const messagesService = (deps) => {
|
|
|
243
244
|
return withServer(withRefresh(found, refreshed), account, server);
|
|
244
245
|
});
|
|
245
246
|
},
|
|
246
|
-
searchAll: (request) => inStore((store, account) => searchAll(store, account, request, deps.messenger, unifiedMessages(store, account))),
|
|
247
|
-
searchAllWithMeetings: (request) => inStore((store, account) => searchAllWithMeetings(store, account, request, deps.messenger, unifiedMessages(store, account))),
|
|
247
|
+
searchAll: (request) => inStore((store, account) => searchAll(store, account, request, deps.messenger, unifiedMessages(store, account), meaningLane(deps))),
|
|
248
|
+
searchAllWithMeetings: (request) => inStore((store, account) => searchAllWithMeetings(store, account, request, deps.messenger, unifiedMessages(store, account), meaningLane(deps))),
|
|
248
249
|
stats: (query) => inStore(async (store, account) => {
|
|
249
250
|
const refreshed = await refreshSearch(deps, query);
|
|
250
251
|
const stats = await statsStore(store, account, query, deps.messenger);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type TextModel } from "../embeddings/models.js";
|
|
1
|
+
import { type RerankModel, type TextModel } from "../embeddings/models.js";
|
|
2
2
|
import { type ImageModel } from "../images/picture/models.js";
|
|
3
3
|
export interface ModelDownloadOptions {
|
|
4
4
|
env: NodeJS.ProcessEnv;
|
|
@@ -16,5 +16,7 @@ export interface DownloadedModel<M> {
|
|
|
16
16
|
export declare const downloadTextModel: (id: string, { env, progress, acceptTerms }: ModelDownloadOptions & {
|
|
17
17
|
acceptTerms?: boolean;
|
|
18
18
|
}) => Promise<DownloadedModel<TextModel>>;
|
|
19
|
+
/** A reranker, the same way as {@link downloadTextModel}, into the same folder. */
|
|
20
|
+
export declare const downloadRerankModel: (id: string, { env, progress }: ModelDownloadOptions) => Promise<DownloadedModel<RerankModel>>;
|
|
19
21
|
/** The picture model, the same way as {@link downloadTextModel}. */
|
|
20
22
|
export declare const downloadImageModel: (id: string, { env, progress }: ModelDownloadOptions) => Promise<DownloadedModel<ImageModel>>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { isTextModelInstalled, placedText, textModelsDirectory } from "../embeddings/embed.js";
|
|
3
|
-
import { textModel } from "../embeddings/models.js";
|
|
3
|
+
import { rerankModel, textModel } from "../embeddings/models.js";
|
|
4
4
|
import { imageModel, imageModelsDirectory, isImageModelInstalled, placedImage, } from "../images/picture/models.js";
|
|
5
5
|
import { installFiles, megabytes } from "../speech/install.js";
|
|
6
6
|
const bytesOf = (model) => model.files.reduce((sum, file) => sum + file.bytes, 0);
|
|
@@ -31,6 +31,25 @@ export const downloadTextModel = async (id, { env, progress = () => { }, acceptT
|
|
|
31
31
|
}
|
|
32
32
|
return { model, directory };
|
|
33
33
|
};
|
|
34
|
+
/** A reranker, the same way as {@link downloadTextModel}, into the same folder. */
|
|
35
|
+
export const downloadRerankModel = async (id, { env, progress = () => { } }) => {
|
|
36
|
+
const model = rerankModel(id);
|
|
37
|
+
const directory = textModelsDirectory(env);
|
|
38
|
+
if (!isTextModelInstalled(model, directory)) {
|
|
39
|
+
progress(`${model.id}: ${megabytes(bytesOf(model))} from Hugging Face`);
|
|
40
|
+
await installFiles(placedText(model, directory), { progress });
|
|
41
|
+
}
|
|
42
|
+
progress(`${model.id}: loading it once to check it works`);
|
|
43
|
+
const { openReranker } = await import("../embeddings/rerank.js");
|
|
44
|
+
const reranker = await openReranker(model, directory);
|
|
45
|
+
try {
|
|
46
|
+
await reranker.score("check", ["check"]);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await reranker.close();
|
|
50
|
+
}
|
|
51
|
+
return { model, directory };
|
|
52
|
+
};
|
|
34
53
|
/** The picture model, the same way as {@link downloadTextModel}. */
|
|
35
54
|
export const downloadImageModel = async (id, { env, progress = () => { } }) => {
|
|
36
55
|
const model = imageModel(id);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type WarmEmbedders } from "../embeddings/embed.js";
|
|
1
2
|
import type { PictureEmbedder } from "../images/picture/embedder.js";
|
|
2
3
|
import type { AppIdentity } from "../ports/app.js";
|
|
3
4
|
import { type SkippedSource } from "../search/account-list.js";
|
|
@@ -45,12 +46,14 @@ export declare const embedMail: (store: MessageStore, options?: NotesEmbedOption
|
|
|
45
46
|
command?: string;
|
|
46
47
|
}) => Promise<NotesEmbedded>;
|
|
47
48
|
/** Notes nearest in meaning to the query, best first; only notes already embedded can be found. */
|
|
48
|
-
export declare const nearestNotes: (store: MessageStore, query: string, { model: choice, limit, folderIds, withInternal, source, env, threads, command, }: Omit<NotesEmbedOptions, "maxChunks"> & {
|
|
49
|
+
export declare const nearestNotes: (store: MessageStore, query: string, { model: choice, limit, folderIds, withInternal, source, env, threads, command, embedders, }: Omit<NotesEmbedOptions, "maxChunks"> & {
|
|
49
50
|
limit: number;
|
|
50
51
|
folderIds?: string[];
|
|
51
52
|
withInternal?: boolean;
|
|
52
53
|
source?: Note["source"];
|
|
53
54
|
command?: string;
|
|
55
|
+
/** The search's own, when another part of it loads the same model. */
|
|
56
|
+
embedders?: Pick<WarmEmbedders, "get">;
|
|
54
57
|
}) => Promise<NearestNote[]>;
|
|
55
58
|
export interface FoundNote {
|
|
56
59
|
kind?: "document" | "note";
|
|
@@ -119,6 +122,8 @@ export interface NotesQuery {
|
|
|
119
122
|
env?: NodeJS.ProcessEnv;
|
|
120
123
|
/** The tool's command, named in the hint when the model is missing. */
|
|
121
124
|
command?: string;
|
|
125
|
+
/** The search's own models, when another part of it loads the same one. */
|
|
126
|
+
embedders?: Pick<WarmEmbedders, "get">;
|
|
122
127
|
/** Adds `pictures`, the documents whose picture the plain words describe, beside the hits. English words. */
|
|
123
128
|
pictures?: boolean;
|
|
124
129
|
pictureLimit?: number;
|
|
@@ -139,7 +144,7 @@ export declare const noteSourceOfQuery: (query: string) => {
|
|
|
139
144
|
* `search notes`: words and stems, and meaning when the local model is there, merged by reciprocal rank;
|
|
140
145
|
* each hit says which half found it, and the answer lists what the notes found link to.
|
|
141
146
|
*/
|
|
142
|
-
export declare const searchNotes: (store: MessageStore, query: string, { limit, offset, tag, filter: expression, folderIds, withInternal, source: given, exact, timezone, env, command, pictures: withPictures, pictureLimit, pictureEmbedder, }?: NotesQuery) => Promise<NotesFound>;
|
|
147
|
+
export declare const searchNotes: (store: MessageStore, query: string, { limit, offset, tag, filter: expression, folderIds, withInternal, source: given, exact, timezone, env, command, embedders, pictures: withPictures, pictureLimit, pictureEmbedder, }?: NotesQuery) => Promise<NotesFound>;
|
|
143
148
|
/** What `notes:internal` and `notes:<folder id>` ask of the store: one query, so a page of it is one page. */
|
|
144
149
|
export declare const notesScopeOf: (accounts: readonly AccountKey[]) => Pick<NotesQuery, "folderIds" | "withInternal" | "source">;
|
|
145
150
|
export interface NotesFoundOf extends NotesFound {
|
|
@@ -64,7 +64,9 @@ const localModel = (choice, env, command) => {
|
|
|
64
64
|
});
|
|
65
65
|
return { model, directory, key: vectorModelKey(model) };
|
|
66
66
|
};
|
|
67
|
-
const withLocal = async (model, directory, threads, work) => {
|
|
67
|
+
const withLocal = async (model, directory, threads, work, shared) => {
|
|
68
|
+
if (shared)
|
|
69
|
+
return work(await shared.get(vectorModelKey(model), () => openEmbedder(model, directory, { threads: threads ?? defaultThreads() })));
|
|
68
70
|
const embedder = await openEmbedder(model, directory, { threads: threads ?? defaultThreads() });
|
|
69
71
|
try {
|
|
70
72
|
return await work(embedder);
|
|
@@ -105,7 +107,7 @@ export const embedNotes = (store, options = {}) => embedChunks(store, store.note
|
|
|
105
107
|
/** Embeds mail's chunks as notes' are embedded: same model and key, so search by meaning reads both. */
|
|
106
108
|
export const embedMail = (store, options = {}) => embedChunks(store, store.mail, options);
|
|
107
109
|
/** Notes nearest in meaning to the query, best first; only notes already embedded can be found. */
|
|
108
|
-
export const nearestNotes = async (store, query, { model: choice, limit, folderIds, withInternal, source, env, threads, command, }) => {
|
|
110
|
+
export const nearestNotes = async (store, query, { model: choice, limit, folderIds, withInternal, source, env, threads, command, embedders, }) => {
|
|
109
111
|
if (!query.trim())
|
|
110
112
|
throw new CliError("validation_error", "say what to find in the notes");
|
|
111
113
|
const { model, directory, key } = localModel(choice, env, command);
|
|
@@ -116,7 +118,7 @@ export const nearestNotes = async (store, query, { model: choice, limit, folderI
|
|
|
116
118
|
};
|
|
117
119
|
if (!(await store.notes.embedded(key, scope)))
|
|
118
120
|
throw new CliError("not_found", `nothing searched here has vectors of ${model.id} yet, so only words were matched`);
|
|
119
|
-
const [vector] = await withLocal(model, directory, threads, (embedder) => embedder.embed([query], "query"));
|
|
121
|
+
const [vector] = await withLocal(model, directory, threads, (embedder) => embedder.embed([query], "query"), embedders);
|
|
120
122
|
const floor = meaningFloor(key);
|
|
121
123
|
const nearest = await store.notes.nearest(key, vector, {
|
|
122
124
|
limit,
|
|
@@ -214,7 +216,7 @@ export const noteSourceOfQuery = (query) => {
|
|
|
214
216
|
* `search notes`: words and stems, and meaning when the local model is there, merged by reciprocal rank;
|
|
215
217
|
* each hit says which half found it, and the answer lists what the notes found link to.
|
|
216
218
|
*/
|
|
217
|
-
export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, filter: expression, folderIds, withInternal, source: given, exact = false, timezone, env, command, pictures: withPictures = false, pictureLimit = 5, pictureEmbedder, } = {}) => {
|
|
219
|
+
export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, filter: expression, folderIds, withInternal, source: given, exact = false, timezone, env, command, embedders, pictures: withPictures = false, pictureLimit = 5, pictureEmbedder, } = {}) => {
|
|
218
220
|
const typed = noteSourceOfQuery(query);
|
|
219
221
|
const source = given ?? typed.source;
|
|
220
222
|
const words = typed.source !== undefined && typed.source === source ? typed.rest : query;
|
|
@@ -239,6 +241,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
|
|
|
239
241
|
limit: Math.max(window, CANDIDATES),
|
|
240
242
|
command,
|
|
241
243
|
...(env === undefined ? {} : { env }),
|
|
244
|
+
...(embedders === undefined ? {} : { embedders }),
|
|
242
245
|
...(folderIds === undefined ? {} : { folderIds }),
|
|
243
246
|
...(withInternal ? { withInternal: true } : {}),
|
|
244
247
|
...(source === undefined ? {} : { source }),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SearchHit } from "../meetings/index.js";
|
|
2
2
|
import type { AccountKey, MessageStore } from "../store/store.js";
|
|
3
3
|
import type { SearchFound, SearchQuery } from "./messages.js";
|
|
4
|
+
import type { MeaningLane } from "./messages-discovery.js";
|
|
4
5
|
import { type SearchAllFound, type SearchAllItem, type SearchAllMessenger, type SearchAllRequest, type Skipped } from "./search-all.js";
|
|
5
6
|
export declare const RESOURCES_SEARCHED_WITH_MEETINGS: readonly ["messages", "mail", "notes", "meetings"];
|
|
6
7
|
export type ResourceWithMeetings = (typeof RESOURCES_SEARCHED_WITH_MEETINGS)[number];
|
|
@@ -54,7 +55,7 @@ export interface SearchAllIncludingMeetingsFound extends Omit<SearchAllFound, "i
|
|
|
54
55
|
meetings?: MeetingSearchCoverage;
|
|
55
56
|
}
|
|
56
57
|
/** Opt-in fusion of the existing unified ranking and one explicitly scoped meeting archive. */
|
|
57
|
-
export declare const searchAllIncludingMeetings: (store: MessageStore, account: AccountKey, request: SearchAllIncludingMeetingsRequest, messenger?: SearchAllMessenger, searchMessages?: (query: SearchQuery) => Promise<SearchFound
|
|
58
|
+
export declare const searchAllIncludingMeetings: (store: MessageStore, account: AccountKey, request: SearchAllIncludingMeetingsRequest, messenger?: SearchAllMessenger, searchMessages?: (query: SearchQuery) => Promise<SearchFound>, meaning?: MeaningLane) => Promise<SearchAllIncludingMeetingsFound>;
|
|
58
59
|
export interface SearchAllWithMeetingsRequest extends Omit<SearchAllIncludingMeetingsRequest, "meetingAccount"> {
|
|
59
60
|
/**
|
|
60
61
|
* `--source`: each kind reads the accounts the list names of it and a kind it names none of is skipped.
|
|
@@ -65,4 +66,4 @@ export interface SearchAllWithMeetingsRequest extends Omit<SearchAllIncludingMee
|
|
|
65
66
|
/** Meetings have no index to search every account at once, so they are read only when named. */
|
|
66
67
|
export declare const MEETINGS_UNNAMED = "meetings are searched only when --source names a meeting account, zoom or meetings";
|
|
67
68
|
/** `search all`: every kind but meetings, unless `--source` names a meeting account. */
|
|
68
|
-
export declare const searchAllWithMeetings: (store: MessageStore, account: AccountKey, request: SearchAllWithMeetingsRequest, messenger?: SearchAllMessenger, searchMessages?: (query: SearchQuery) => Promise<SearchFound
|
|
69
|
+
export declare const searchAllWithMeetings: (store: MessageStore, account: AccountKey, request: SearchAllWithMeetingsRequest, messenger?: SearchAllMessenger, searchMessages?: (query: SearchQuery) => Promise<SearchFound>, meaning?: MeaningLane) => Promise<SearchAllIncludingMeetingsFound>;
|
|
@@ -124,7 +124,7 @@ const collectMeetings = async (store, account, request, query) => {
|
|
|
124
124
|
return collected;
|
|
125
125
|
};
|
|
126
126
|
/** Opt-in fusion of the existing unified ranking and one explicitly scoped meeting archive. */
|
|
127
|
-
export const searchAllIncludingMeetings = async (store, account, request, messenger = {}, searchMessages) => {
|
|
127
|
+
export const searchAllIncludingMeetings = async (store, account, request, messenger = {}, searchMessages, meaning) => {
|
|
128
128
|
whole(request.limit, 1, 1000, "limit");
|
|
129
129
|
if (request.maxMeetings !== "all")
|
|
130
130
|
whole(request.maxMeetings ?? 100, 1, Number.MAX_SAFE_INTEGER, "maxMeetings");
|
|
@@ -142,14 +142,14 @@ export const searchAllIncludingMeetings = async (store, account, request, messen
|
|
|
142
142
|
cancelled(request.signal);
|
|
143
143
|
const others = { ...request, only: only.filter((one) => one !== "meetings") };
|
|
144
144
|
if (!only.includes("meetings")) {
|
|
145
|
-
const found = await searchAll(store, account, others, messenger, searchMessages);
|
|
145
|
+
const found = await searchAll(store, account, others, messenger, searchMessages, meaning);
|
|
146
146
|
cancelled(request.signal);
|
|
147
147
|
return found;
|
|
148
148
|
}
|
|
149
149
|
// Meetings rank with the rest, so a later page is the same merge read deeper, then cut.
|
|
150
150
|
const offset = request.offset ?? 0;
|
|
151
151
|
const window = offset + request.limit;
|
|
152
|
-
const legacy = await searchAll(store, account, { ...others, offset: 0, limit: window }, messenger, searchMessages);
|
|
152
|
+
const legacy = await searchAll(store, account, { ...others, offset: 0, limit: window }, messenger, searchMessages, meaning);
|
|
153
153
|
cancelled(request.signal);
|
|
154
154
|
const compiled = meetingQuery(request.text, request.exact);
|
|
155
155
|
if ("reason" in compiled)
|
|
@@ -207,15 +207,15 @@ export const searchAllIncludingMeetings = async (store, account, request, messen
|
|
|
207
207
|
/** Meetings have no index to search every account at once, so they are read only when named. */
|
|
208
208
|
export const MEETINGS_UNNAMED = "meetings are searched only when --source names a meeting account, zoom or meetings";
|
|
209
209
|
/** `search all`: every kind but meetings, unless `--source` names a meeting account. */
|
|
210
|
-
export const searchAllWithMeetings = async (store, account, request, messenger = {}, searchMessages) => {
|
|
210
|
+
export const searchAllWithMeetings = async (store, account, request, messenger = {}, searchMessages, meaning) => {
|
|
211
211
|
if (request.source !== undefined)
|
|
212
|
-
return searchAllOfSource(store, account, request.source, request, messenger, searchMessages);
|
|
212
|
+
return searchAllOfSource(store, account, request.source, request, messenger, searchMessages, meaning);
|
|
213
213
|
const only = request.only ?? RESOURCES_SEARCHED_WITH_MEETINGS;
|
|
214
214
|
if (only.length === 1 && only[0] === "meetings")
|
|
215
215
|
throw new CliError("validation_error", `--only meetings: ${MEETINGS_UNNAMED}`);
|
|
216
216
|
if (request.maxMeetings !== undefined)
|
|
217
217
|
throw new CliError("validation_error", `--max-meetings: ${MEETINGS_UNNAMED}`);
|
|
218
|
-
const found = await searchAll(store, account, { ...request, only: only.filter((resource) => resource !== "meetings") }, messenger, searchMessages);
|
|
218
|
+
const found = await searchAll(store, account, { ...request, only: only.filter((resource) => resource !== "meetings") }, messenger, searchMessages, meaning);
|
|
219
219
|
return only.includes("meetings")
|
|
220
220
|
? {
|
|
221
221
|
...found,
|
|
@@ -235,7 +235,7 @@ const holdingMeetings = async (store, named) => {
|
|
|
235
235
|
}
|
|
236
236
|
return holding.length ? holding : named;
|
|
237
237
|
};
|
|
238
|
-
const searchAllOfSource = async (store, account, source, request, messenger, searchMessages) => {
|
|
238
|
+
const searchAllOfSource = async (store, account, source, request, messenger, searchMessages, meaning) => {
|
|
239
239
|
const only = request.only ?? RESOURCES_SEARCHED_WITH_MEETINGS;
|
|
240
240
|
const entries = entriesOfList(source, await listContextOf(store, account, messenger.app));
|
|
241
241
|
const { skipped } = fitting(entries, only, "search all", (kind) => `--only leaves ${kind} out`);
|
|
@@ -263,7 +263,7 @@ const searchAllOfSource = async (store, account, source, request, messenger, sea
|
|
|
263
263
|
only: read,
|
|
264
264
|
scope: { messages: named("messages"), mail: named("mail"), notes: named("notes") },
|
|
265
265
|
...(meetings[0] ? { meetingAccount: meetings[0] } : {}),
|
|
266
|
-
}, messenger, searchMessages);
|
|
266
|
+
}, messenger, searchMessages, meaning);
|
|
267
267
|
const unnamed = only
|
|
268
268
|
.filter((resource) => !read.includes(resource))
|
|
269
269
|
.map((resource) => ({ resource, code: "not_found", reason: `--source names no ${resource} account` }));
|
|
@@ -3,6 +3,7 @@ import type { MailFields } from "../domain/models.js";
|
|
|
3
3
|
import type { MessengerTraits } from "../ports/traits.js";
|
|
4
4
|
import type { AccountKey, MessageStore } from "../store/store.js";
|
|
5
5
|
import type { SearchFound, SearchQuery } from "./messages.js";
|
|
6
|
+
import { type MeaningLane, type MeaningSkipped } from "./messages-discovery.js";
|
|
6
7
|
import type { Backend, ServerOptions, ServerSearched } from "./server-search.js";
|
|
7
8
|
export declare const RESOURCES_SEARCHED: readonly ["messages", "mail", "notes"];
|
|
8
9
|
export type SearchedResource = (typeof RESOURCES_SEARCHED)[number];
|
|
@@ -62,6 +63,11 @@ export interface SearchAllFound {
|
|
|
62
63
|
by: "words" | "words and meaning";
|
|
63
64
|
meaningSkipped?: string;
|
|
64
65
|
};
|
|
66
|
+
/** How the messages were searched, when they were; never by meaning with `exact` or the server alone. */
|
|
67
|
+
messages?: {
|
|
68
|
+
by: "words" | "words and meaning";
|
|
69
|
+
meaningSkipped?: MeaningSkipped;
|
|
70
|
+
};
|
|
65
71
|
/** The messenger's server step for messages, as `search messages` reports it. */
|
|
66
72
|
server?: ServerSearched;
|
|
67
73
|
}
|
|
@@ -93,4 +99,4 @@ export type SearchAllMessenger = Partial<Pick<MessengerTraits, "savedChatId" | "
|
|
|
93
99
|
* server, as `search messages` asks it — each list best first, merged by reciprocal rank so no
|
|
94
100
|
* resource's own scores have to be compared with another's.
|
|
95
101
|
*/
|
|
96
|
-
export declare const searchAll: (store: MessageStore, account: AccountKey, request: SearchAllRequest, messenger?: SearchAllMessenger, searchMessages?: (query: SearchQuery) => Promise<SearchFound
|
|
102
|
+
export declare const searchAll: (store: MessageStore, account: AccountKey, request: SearchAllRequest, messenger?: SearchAllMessenger, searchMessages?: (query: SearchQuery) => Promise<SearchFound>, meaning?: MeaningLane) => Promise<SearchAllFound>;
|