@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/cli/messenger/context.js +1 -0
  3. package/dist/cli/messenger/conversation-answers.d.ts +1 -0
  4. package/dist/cli/messenger/conversations-command.js +2 -2
  5. package/dist/cli/messenger/message-answers.d.ts +1 -0
  6. package/dist/cli/messenger/message-writes.d.ts +2 -0
  7. package/dist/cli/messenger/messages-search-command.js +6 -10
  8. package/dist/cli/messenger/models-command.js +34 -7
  9. package/dist/cli/messenger/search-answers.d.ts +3 -0
  10. package/dist/cli/messenger/search-answers.js +40 -8
  11. package/dist/cli/messenger/search-command.js +3 -1
  12. package/dist/cli/messenger/store-maintenance-command.js +7 -4
  13. package/dist/cli/model-catalogue-command.js +1 -1
  14. package/dist/cli/profile.d.ts +4 -1
  15. package/dist/cli/profile.js +22 -7
  16. package/dist/cli/program.js +8 -4
  17. package/dist/cli/settings.d.ts +4 -0
  18. package/dist/cli/settings.js +5 -0
  19. package/dist/cli/shared-command-exports.d.ts +4 -0
  20. package/dist/embeddings/child.js +15 -5
  21. package/dist/embeddings/embed.d.ts +16 -2
  22. package/dist/embeddings/embed.js +57 -28
  23. package/dist/embeddings/models.d.ts +18 -0
  24. package/dist/embeddings/models.js +69 -0
  25. package/dist/embeddings/process.d.ts +6 -1
  26. package/dist/embeddings/process.js +23 -7
  27. package/dist/embeddings/rerank.d.ts +10 -0
  28. package/dist/embeddings/rerank.js +31 -0
  29. package/dist/entities/conversation-results.d.ts +2 -0
  30. package/dist/entities/evidence.d.ts +8 -0
  31. package/dist/entities/index.d.ts +27 -1
  32. package/dist/entities/listener.d.ts +4 -0
  33. package/dist/entities/message-context.d.ts +3 -0
  34. package/dist/entities/message-write.d.ts +4 -0
  35. package/dist/entities/message.d.ts +2 -0
  36. package/dist/entities/message.js +2 -0
  37. package/dist/entities/model.d.ts +5 -2
  38. package/dist/entities/model.js +5 -2
  39. package/dist/entities/store-maintenance.d.ts +1 -0
  40. package/dist/entities/store-maintenance.js +8 -1
  41. package/dist/entities/store-records.d.ts +1 -0
  42. package/dist/mcp/tool.d.ts +1 -1
  43. package/dist/mcp/tool.js +1 -0
  44. package/dist/services/deps.d.ts +3 -0
  45. package/dist/services/embeddings-maintenance.d.ts +10 -2
  46. package/dist/services/index.d.ts +2 -0
  47. package/dist/services/index.js +1 -0
  48. package/dist/services/messages-archive-search.d.ts +2 -2
  49. package/dist/services/messages-archive-search.js +4 -4
  50. package/dist/services/messages-discovery.d.ts +33 -4
  51. package/dist/services/messages-discovery.js +88 -3
  52. package/dist/services/messages-meaning.d.ts +2 -1
  53. package/dist/services/messages-meaning.js +7 -3
  54. package/dist/services/messages-rerank.d.ts +7 -0
  55. package/dist/services/messages-rerank.js +42 -0
  56. package/dist/services/messages-search.d.ts +5 -0
  57. package/dist/services/messages.d.ts +6 -2
  58. package/dist/services/messages.js +4 -3
  59. package/dist/services/model-downloads.d.ts +3 -1
  60. package/dist/services/model-downloads.js +20 -1
  61. package/dist/services/notes-search.d.ts +7 -2
  62. package/dist/services/notes-search.js +7 -4
  63. package/dist/services/search-all-meetings.d.ts +3 -2
  64. package/dist/services/search-all-meetings.js +8 -8
  65. package/dist/services/search-all.d.ts +7 -1
  66. package/dist/services/search-all.js +109 -45
  67. package/dist/store/exports.d.ts +1 -1
  68. package/dist/store/exports.js +1 -1
  69. package/dist/store/sqlite/accounts.js +2 -2
  70. package/dist/store/sqlite/chats.d.ts +1 -1
  71. package/dist/store/sqlite/chats.js +11 -4
  72. package/dist/store/sqlite/document-pictures.js +2 -7
  73. package/dist/store/sqlite/email-index.d.ts +1 -3
  74. package/dist/store/sqlite/email-index.js +5 -21
  75. package/dist/store/sqlite/emails.js +4 -3
  76. package/dist/store/sqlite/image-chunks.d.ts +2 -2
  77. package/dist/store/sqlite/image-chunks.js +10 -13
  78. package/dist/store/sqlite/meeting-vectors.js +11 -9
  79. package/dist/store/sqlite/message-chunks.js +3 -8
  80. package/dist/store/sqlite/note-index.js +2 -7
  81. package/dist/store/sqlite/schema.d.ts +1 -1
  82. package/dist/store/sqlite/schema.js +1 -1
  83. package/dist/store/sqlite/vector-index.d.ts +9 -0
  84. package/dist/store/sqlite/vector-index.js +25 -0
  85. package/dist/store/sqlite/vectors.d.ts +2 -2
  86. package/dist/store/sqlite/vectors.js +11 -8
  87. 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
- export const searchDiscovery = async (store, account, asked, messenger = {}, meaning) => {
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
- const started = performance.now();
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
- await withEmbedder(deps, target, DEFAULT_TEXT_MODEL, {}, async (embedder) => {
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>) => Promise<SearchAllIncludingMeetingsFound>;
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>) => Promise<SearchAllIncludingMeetingsFound>;
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>) => Promise<SearchAllFound>;
102
+ export declare const searchAll: (store: MessageStore, account: AccountKey, request: SearchAllRequest, messenger?: SearchAllMessenger, searchMessages?: (query: SearchQuery) => Promise<SearchFound>, meaning?: MeaningLane) => Promise<SearchAllFound>;