@wirecat/cli-core 0.433.0 → 0.435.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/dist/cli/messenger/context.js +1 -0
  3. package/dist/cli/messenger/conversation-answers.d.ts +1 -0
  4. package/dist/cli/messenger/conversations-command.js +2 -2
  5. package/dist/cli/messenger/message-answers.d.ts +1 -0
  6. package/dist/cli/messenger/message-writes.d.ts +2 -0
  7. package/dist/cli/messenger/messages-search-command.js +6 -10
  8. package/dist/cli/messenger/models-command.js +34 -7
  9. package/dist/cli/messenger/search-answers.d.ts +3 -0
  10. package/dist/cli/messenger/search-answers.js +40 -8
  11. package/dist/cli/messenger/search-command.js +3 -1
  12. package/dist/cli/messenger/store-maintenance-command.js +11 -5
  13. package/dist/cli/model-catalogue-command.js +1 -1
  14. package/dist/cli/settings.d.ts +4 -0
  15. package/dist/cli/settings.js +5 -0
  16. package/dist/cli/shared-command-exports.d.ts +4 -0
  17. package/dist/embeddings/child.js +15 -5
  18. package/dist/embeddings/embed.d.ts +16 -2
  19. package/dist/embeddings/embed.js +57 -28
  20. package/dist/embeddings/models.d.ts +18 -0
  21. package/dist/embeddings/models.js +69 -0
  22. package/dist/embeddings/process.d.ts +6 -1
  23. package/dist/embeddings/process.js +23 -7
  24. package/dist/embeddings/rerank.d.ts +10 -0
  25. package/dist/embeddings/rerank.js +31 -0
  26. package/dist/entities/conversation-results.d.ts +2 -0
  27. package/dist/entities/evidence.d.ts +8 -0
  28. package/dist/entities/index.d.ts +37 -4
  29. package/dist/entities/listener.d.ts +4 -0
  30. package/dist/entities/message-context.d.ts +3 -0
  31. package/dist/entities/message-write.d.ts +4 -0
  32. package/dist/entities/message.d.ts +2 -0
  33. package/dist/entities/message.js +2 -0
  34. package/dist/entities/model.d.ts +5 -2
  35. package/dist/entities/model.js +5 -2
  36. package/dist/entities/store-maintenance.d.ts +11 -3
  37. package/dist/entities/store-maintenance.js +18 -2
  38. package/dist/entities/store-records.d.ts +1 -0
  39. package/dist/errors/reasons.js +7 -0
  40. package/dist/mcp/tool.d.ts +1 -1
  41. package/dist/mcp/tool.js +1 -0
  42. package/dist/services/deps.d.ts +3 -0
  43. package/dist/services/embeddings-maintenance.d.ts +10 -2
  44. package/dist/services/index.d.ts +2 -0
  45. package/dist/services/index.js +1 -0
  46. package/dist/services/messages-archive-search.d.ts +2 -2
  47. package/dist/services/messages-archive-search.js +4 -4
  48. package/dist/services/messages-discovery.d.ts +33 -4
  49. package/dist/services/messages-discovery.js +88 -3
  50. package/dist/services/messages-meaning.d.ts +2 -1
  51. package/dist/services/messages-meaning.js +7 -3
  52. package/dist/services/messages-rerank.d.ts +7 -0
  53. package/dist/services/messages-rerank.js +42 -0
  54. package/dist/services/messages-search.d.ts +5 -0
  55. package/dist/services/messages.d.ts +6 -2
  56. package/dist/services/messages.js +4 -3
  57. package/dist/services/model-downloads.d.ts +3 -1
  58. package/dist/services/model-downloads.js +20 -1
  59. package/dist/services/notes-search.d.ts +7 -2
  60. package/dist/services/notes-search.js +7 -4
  61. package/dist/services/search-all-meetings.d.ts +3 -2
  62. package/dist/services/search-all-meetings.js +8 -8
  63. package/dist/services/search-all.d.ts +7 -1
  64. package/dist/services/search-all.js +109 -45
  65. package/dist/store/drivers/bun-sqlite.d.ts +6 -0
  66. package/dist/store/drivers/bun-sqlite.js +16 -1
  67. package/dist/store/drivers/node-sqlite.d.ts +5 -1
  68. package/dist/store/drivers/node-sqlite.js +15 -1
  69. package/dist/store/exports.d.ts +1 -0
  70. package/dist/store/exports.js +1 -0
  71. package/dist/store/sqlite/accounts.js +2 -2
  72. package/dist/store/sqlite/chats.d.ts +1 -1
  73. package/dist/store/sqlite/chats.js +11 -4
  74. package/dist/store/sqlite/document-pictures.js +5 -7
  75. package/dist/store/sqlite/email-index.d.ts +1 -3
  76. package/dist/store/sqlite/email-index.js +5 -21
  77. package/dist/store/sqlite/emails.js +4 -3
  78. package/dist/store/sqlite/image-chunks.d.ts +3 -3
  79. package/dist/store/sqlite/image-chunks.js +55 -25
  80. package/dist/store/sqlite/manifest.js +2 -0
  81. package/dist/store/sqlite/meeting-vectors.js +11 -9
  82. package/dist/store/sqlite/message-chunks.d.ts +5 -2
  83. package/dist/store/sqlite/message-chunks.js +44 -20
  84. package/dist/store/sqlite/migrations.generated.js +22 -0
  85. package/dist/store/sqlite/note-index.js +2 -7
  86. package/dist/store/sqlite/open-store.js +4 -0
  87. package/dist/store/sqlite/open.js +4 -6
  88. package/dist/store/sqlite/schema.d.ts +118 -1
  89. package/dist/store/sqlite/schema.js +18 -1
  90. package/dist/store/sqlite/vector-index.d.ts +76 -0
  91. package/dist/store/sqlite/vector-index.js +205 -0
  92. package/dist/store/sqlite/vectors.d.ts +8 -5
  93. package/dist/store/sqlite/vectors.js +82 -39
  94. package/dist/store/vector-extension.d.ts +10 -0
  95. package/dist/store/vector-extension.js +53 -0
  96. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -5,7 +5,49 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
- ## Unreleased
8
+ ## 0.435.0 — 25.10.2026
9
+
10
+ ### Added
11
+
12
+ - **`search messages --discover` reranks its first 50 hits, on its own, once the reranker is downloaded.** A cross-encoder reads the question with each hit, headed by its chat, author and date and preceded by the message it replies to, and its score is mixed with discovery's own, not put in its place: both scaled to 0–1 over those 50, then 0.3 · discovery + 0.7 · reranker; the hits after keep their order. This is the winning variant of the [evidence-pipeline benchmark](https://github.com/WireCatLabs/cli-testing/tree/main/performance/search/evidence-pipeline#reranker-variants) (holdout Answer@3 67% → 81% with mMARCO). Each hit it read has `discovery.reranker_score`; `score` stays discovery's. The default is `mmarco` (136 MB, `models download mmarco`); `bge-reranker-v2-m3` (588 MB) or `off` is the profile setting `search_reranker`. Loading and scoring do not count against the word search's 2 s: scoring has 15 s of its own, and a reranking that would not finish in them is dropped, never the search. Measured on a Ryzen AI 9 HX 470 through Core's WebAssembly runtime, 50 hits take mmarco about 0.9–1.7 s and BGE about 7–10 s. Without the model, discovery keeps its order and says so: a `not reranked: …` note in the terminal, `missing`/`next` (`models.download`) in `--json` and MCP. A search never downloads a model and never sends text anywhere; `--exact`, `--newest` and strict queries are untouched. `models list` shows the rerankers as kind `reranker`.
13
+ - **`search all` finds messages by meaning too, on its own, once it can.** It runs the same meaning lookup as
14
+ `search messages --discover` (e5-small over each message's own vector, the 100 nearest, every account the search
15
+ reads) and fuses it with the word hits by reciprocal rank fusion (k = 60) into one messages list, before the merge
16
+ with mail and notes. The query's filters apply to both; a query with NOT, or with a filter under OR or in a group,
17
+ stays words only, since the meaning hits cannot follow it (`cat OR fish` still uses meaning). Each item's `foundBy` says which search found it, and the answer's
18
+ `messages` says how they were searched. Without the model or the vectors, `search all` reads words as before and
19
+ says so: a `messages: meaning search not used: …` note, and `missing`/`next` (`models.download` or
20
+ `conversations.rebuild`) in `--json` and MCP. `--exact` and `--backend server` stay words only. A search never
21
+ downloads a model, embeds anything or sends text anywhere. `searchAll`, `searchAllWithMeetings` and
22
+ `searchAllIncludingMeetings` take the meaning lane as an optional last argument; a caller that passes none
23
+ (memo's `searchEverywhere` today) searches messages by words alone. `meaningLane` is exported and needs only
24
+ `{ env, store, messenger: { app: { command } } }`, so memo can build one without the messenger's services.
25
+ Messages and notes share one loaded model within a `search all`, so searching both by meaning loads e5-small once:
26
+ on a 10,000-message store with 200 notes the median went from 1.7 s to 0.9 s.
27
+
28
+ ### Fixed
29
+
30
+ - **A vector goes once no text in the store uses it, by one rule for every kind.** Writing messages, images, notes,
31
+ documents and their pictures, mail and meeting transcripts, and purging an account or a chat, each had their own
32
+ query; they now all share one, which drops every model's vector of a released hash that no chunk
33
+ holds any more, and keeps one a chunk of any kind still holds. Deleting a note, document, memory or email still
34
+ goes through its delete trigger, which applies the same rule. Three writes left vectors behind and no longer do: an image whose
35
+ picture changed, a meeting transcript whose text changed, and a purged chat (`store clear --left` and a chat
36
+ merged into another), which also left its messages' and images' chunks. The rule reaches each vector by its key
37
+ (model, hash), where most of the old queries scanned every vector once per hash.
38
+ - **`conversations embeddings clear --chat` drops the chat's message vectors too**, not only its conversations'; a
39
+ vector a chunk outside the chat shares stays, and `conversations embed --chat` makes them again.
40
+ - **`store rebuild` drops the vectors no stored text uses**, which older builds left behind, and says how many in
41
+ `unused_vectors_dropped`. `store check` counts them (`vectors.unused`) and, when there are any, notes that
42
+ `store rebuild` drops them.
43
+
44
+ ## 0.434.0 — 25.10.2026
45
+
46
+ ### Changed — may break callers
47
+
48
+ - **Search by meaning ranks by a sqlite-vec index in the store (store version 34), and every store connection loads the extension.** Each embedding model gets a `vec0` table, `vector_index_<id>`, listed in the new `vector_indexes`; triggers on `chunks`, `embeddings` and `messages` keep it in step inside each write's own transaction. `nearestMessages`, `nearestImages` and conversation search (without a person, project, scope or conversation list) take candidates from it, check them with the scan's own SQL and score them as before, so they answer exactly what the scan answers; the scan stays for the filters the index cannot express. A store that held vectors is indexed on its first open by this version, in short batches. **Version 34 raises `minCompatible` to 34**: a build without the extension cannot write chunks to a store with these triggers, so older CLIs refuse the file and ask to be upgraded. The extension ships in `@wirecat/cli-core-sqlite` 1.1.0 (now a required runtime piece on every platform, Windows included); when it is missing or does not load, opening the store fails with `store.vector-extension-missing` or `store.vector-extension-failed` and says to reinstall. Under Node the store is opened with `allowExtension`, and SQL's `load_extension()` is switched off again right after.
49
+ - **`store check` reports `vector_indexes`** — per model, the rows its index holds and the rows it should, and how many differ either way (`missing`, `extra`), compared whole — and fails `checks.vector_indexes` when a ready index is off.
50
+ - **Vectors are stored at length one.** `saveVectors` and a document picture's vector are scaled to unit length before they are kept, so search by dot product and by the index rank alike; a vector with a non-finite value is refused (`store.vector-invalid`). Vectors stored before keep their length. The `not_applicable.extensions` field ("SQLite needs none") is gone.
9
51
 
10
52
  ## 0.433.0 — 25.10.2026
11
53
 
@@ -414,6 +414,7 @@ export const messengerContext = (command, messenger, resolving = {}) => {
414
414
  history: base.settings.keepFailedRuns,
415
415
  searchCatchUp: base.settings.searchCatchUp,
416
416
  pictures: base.settings.pictureSearch,
417
+ reranker: base.settings.searchReranker,
417
418
  guard,
418
419
  ...(embedders ? { embedders } : {}),
419
420
  connection: async () => {
@@ -66,6 +66,7 @@ export declare const conversationShown: (account: AccountKey, { summary, message
66
66
  missing_terms: string[];
67
67
  parent?: string | undefined;
68
68
  found_by?: ("meaning" | "words")[] | undefined;
69
+ reranker_score?: number | undefined;
69
70
  } | undefined;
70
71
  context?: {
71
72
  ref: string;
@@ -125,8 +125,8 @@ const embeddingStatusCommand = (messenger) => withModelOptions(annotate(new Comm
125
125
  "\n");
126
126
  });
127
127
  const embeddingClearCommand = (messenger) => withModelOptions(destroysNothing(annotate(new Command("clear"), { output: { entity: "conversation_change" }, effects: LOCAL_WRITE }))
128
- .summary("remove stored conversation vectors")
129
- .description("drop a chat's vectors, or only one model's; messages and conversations are never touched")
128
+ .summary("remove a chat's stored conversation and message vectors")
129
+ .description("drop a chat's conversation and message vectors, or only one model's; messages and conversations are never touched")
130
130
  .option("--chat <chat>", messenger.chatArgument)).action(async function () {
131
131
  const options = this.optsWithGlobals();
132
132
  const chat = chatOf(options);
@@ -43,6 +43,7 @@ export declare const threadAnswer: ({ items, ...thread }: ThreadContext, reader:
43
43
  missing_terms: string[];
44
44
  parent?: string | undefined;
45
45
  found_by?: ("meaning" | "words")[] | undefined;
46
+ reranker_score?: number | undefined;
46
47
  } | undefined;
47
48
  context?: {
48
49
  ref: string;
@@ -47,6 +47,7 @@ export declare const writtenAnswer: ({ operationId, sendId, message, messages, s
47
47
  missing_terms: string[];
48
48
  parent?: string | undefined;
49
49
  found_by?: ("meaning" | "words")[] | undefined;
50
+ reranker_score?: number | undefined;
50
51
  } | undefined;
51
52
  context?: {
52
53
  ref: string;
@@ -236,6 +237,7 @@ export declare const writtenAnswer: ({ operationId, sendId, message, messages, s
236
237
  missing_terms: string[];
237
238
  parent?: string | undefined;
238
239
  found_by?: ("meaning" | "words")[] | undefined;
240
+ reranker_score?: number | undefined;
239
241
  } | undefined;
240
242
  context?: {
241
243
  ref: string;
@@ -12,7 +12,7 @@ import { renderAnswer } from "../envelope.js";
12
12
  import { positiveCount } from "../paging.js";
13
13
  import { archiveSummary } from "./archive-summary.js";
14
14
  import { messengerContext } from "./context.js";
15
- import { messagesAnswer, offsetOf } from "./search-answers.js";
15
+ import { meaningNote, messagesAnswer, offsetOf } from "./search-answers.js";
16
16
  import { backendOptions, backendRequest, noteServer } from "./search-backend-options.js";
17
17
  import { syncOptions, syncRequest, syncsFirst } from "./search-sync-options.js";
18
18
  import { threadOptions, threadRequest } from "./thread-options.js";
@@ -130,15 +130,11 @@ const searchAction = (messenger) => async function (given) {
130
130
  context.renderer.note(`${from} → ${to.join(", ")}`);
131
131
  for (const { source, reason } of found.skipped ?? [])
132
132
  context.renderer.note(`${source} not searched: ${reason}`);
133
- const meaning = found.meaningSkipped;
134
- if (meaning) {
135
- const step = meaning.step === "models.download"
136
- ? ` — \`${command} models download ${meaning.model}\`, then \`${command} conversations rebuild\` gives messages their vectors`
137
- : meaning.step === "conversations.rebuild"
138
- ? ` — \`${command} conversations rebuild\` gives messages their vectors, a batch a run`
139
- : "";
140
- context.renderer.note(`meaning search not used: ${meaning.reason}${step}`);
141
- }
133
+ if (found.meaningSkipped)
134
+ context.renderer.note(meaningNote(found.meaningSkipped, command));
135
+ const rerank = found.rerankSkipped;
136
+ if (rerank)
137
+ context.renderer.note(`not reranked: ${rerank.reason}${rerank.step === "models.download" ? ` — \`${command} models download ${rerank.model}\`` : ""}`);
142
138
  if (found.picturesSkipped && found.picturesModel === undefined)
143
139
  context.renderer.note(`pictures not searched: ${found.picturesSkipped}`);
144
140
  const stemming = found.query?.stemming;
@@ -4,10 +4,10 @@ import { annotate } from "@wirecat/cli-base/commands";
4
4
  import { Command } from "commander";
5
5
  import { isTextModelInstalled, textModelsDirectory } from "../../embeddings/embed.js";
6
6
  import { embeddingKeys } from "../../embeddings/keys.js";
7
- import { DEFAULT_TEXT_MODEL, TEXT_MODELS } from "../../embeddings/models.js";
7
+ import { DEFAULT_RERANK_MODEL, DEFAULT_TEXT_MODEL, RERANK_MODELS, TEXT_MODELS, } from "../../embeddings/models.js";
8
8
  import { model as modelEntity } from "../../entities/model.js";
9
9
  import { DEFAULT_IMAGE_MODEL, IMAGE_MODELS, imageModelsDirectory, isImageModelInstalled, } from "../../images/picture/models.js";
10
- import { downloadImageModel, downloadTextModel } from "../../services/model-downloads.js";
10
+ import { downloadImageModel, downloadRerankModel, downloadTextModel } from "../../services/model-downloads.js";
11
11
  import { install, installedBytes, isInstalled, megabytes, modelPath, modelsDirectory, vadPath, } from "../../speech/install.js";
12
12
  import { orderedModels, speechModel, VAD } from "../../speech/models.js";
13
13
  import { choose } from "../../speech/transcribe.js";
@@ -44,6 +44,17 @@ const textItem = (model, directory) => ({
44
44
  default: model.id === DEFAULT_TEXT_MODEL,
45
45
  path: join(directory, model.id),
46
46
  });
47
+ const rerankItem = (model, directory, chosen) => ({
48
+ id: model.id,
49
+ kind: "reranker",
50
+ title: model.title,
51
+ languages: model.languages,
52
+ licence: model.licence,
53
+ size_bytes: model.files.reduce((sum, file) => sum + file.bytes, 0),
54
+ downloaded: isTextModelInstalled(model, directory),
55
+ default: model.id === chosen,
56
+ path: join(directory, model.id),
57
+ });
47
58
  const imageItem = (model, directory) => ({
48
59
  id: model.id,
49
60
  kind: "image",
@@ -93,6 +104,16 @@ const downloadEmbeddingModel = async (command, messenger, id, options) => {
93
104
  renderAnswer(command, context, itemOf(textItem(model, directory)), downloaded);
94
105
  }, { unbounded: true });
95
106
  };
107
+ const downloadReranker = async (command, messenger, id) => {
108
+ const context = baseContext(command, messenger.resolveSettings);
109
+ await context.run(async () => {
110
+ const { model, directory } = await downloadRerankModel(id, {
111
+ env: context.env,
112
+ progress: (line) => context.renderer.note(line),
113
+ });
114
+ renderAnswer(command, context, itemOf(rerankItem(model, directory, context.settings.searchReranker ?? DEFAULT_RERANK_MODEL)), downloaded);
115
+ }, { unbounded: true });
116
+ };
96
117
  const modelKeysCommand = (messenger, tool) => {
97
118
  const key = new Command("keys").description("API keys for embedding and analysis providers");
98
119
  grantsAuthority(annotate(key.command("set"), {
@@ -136,7 +157,7 @@ export const modelsCommand = (messenger) => {
136
157
  effects: LOCAL_READ,
137
158
  examples: [`${tool} models list`, `${tool} models list kind:audio`],
138
159
  }), ROOT_MODELS_QUERY)
139
- .description("speech and embedding models, their download status and the default of each kind")
160
+ .description("speech, embedding and reranker models: downloaded or not, each kind's default")
140
161
  .action(async function (words) {
141
162
  const terms = nativeTerms(listQuery(typedQuery(words), ROOT_MODELS_QUERY, "models list"), "models list");
142
163
  const context = baseContext(this, messenger.resolveSettings);
@@ -150,25 +171,29 @@ export const modelsCommand = (messenger) => {
150
171
  const textModels = kinds === undefined || kinds === "text"
151
172
  ? TEXT_MODELS.filter((model) => id === undefined || model.id === id)
152
173
  : [];
174
+ const rerankers = kinds === undefined || kinds === "reranker"
175
+ ? RERANK_MODELS.filter((model) => id === undefined || model.id === id)
176
+ : [];
153
177
  const chosen = audioModels.length > 0 ? choose(messenger, context.settings, {}, context.env).model.id : "";
154
178
  const catalogue = [
155
179
  ...audioModels.map((model) => audioItem(model, modelsDirectory(context.env), chosen)),
156
180
  ...textModels.map((model) => textItem(model, textModelsDirectory(context.env))),
181
+ ...rerankers.map((model) => rerankItem(model, textModelsDirectory(context.env), context.settings.searchReranker ?? DEFAULT_RERANK_MODEL)),
157
182
  ];
158
183
  const items = catalogue.filter((item) => kept === undefined || item.downloaded === (kept === "true"));
159
184
  const next = items
160
185
  .filter((item) => item.default && !item.downloaded)
161
186
  .map((item) => nextStep("models.download", { args: { model: item.id } }));
162
- renderAnswer(this, context, everyModel("models list", items, next), () => items.map((item) => `${item.kind.padEnd(5)} ${modelLine(item, 18)} ${item.languages}\n`).join(""));
187
+ renderAnswer(this, context, everyModel("models list", items, next), () => items.map((item) => `${item.kind.padEnd(8)} ${modelLine(item, 18)} ${item.languages}\n`).join(""));
163
188
  });
164
189
  });
165
190
  reachesOtherService(annotate(models.command("download"), {
166
191
  effects: FETCH,
167
192
  output: { entity: "model" },
168
- examples: [`${tool} models download ${DEFAULT_TEXT_MODEL}`],
193
+ examples: [`${tool} models download ${DEFAULT_TEXT_MODEL}`, `${tool} models download ${DEFAULT_RERANK_MODEL}`],
169
194
  }))
170
- .summary("download and verify one speech or embedding model")
171
- .description("download and verify one speech or embedding model: tens to hundreds of MB, each size in models list")
195
+ .summary("download and verify one speech, embedding or reranker model")
196
+ .description("download and verify one speech, embedding or reranker model: tens to hundreds of MB, each size in models list")
172
197
  .argument("<model>", "a model id from models list")
173
198
  .option("--accept-terms", "accept the model's licence terms, for a model that has its own")
174
199
  .action(async function (id, options) {
@@ -176,6 +201,8 @@ export const modelsCommand = (messenger) => {
176
201
  return downloadAudioModel(this, messenger, id);
177
202
  if (TEXT_MODELS.some((model) => model.id === id))
178
203
  return downloadEmbeddingModel(this, messenger, id, options);
204
+ if (RERANK_MODELS.some((model) => model.id === id))
205
+ return downloadReranker(this, messenger, id);
179
206
  throw new CliError("validation_error", "unknown model; choose an id from models list");
180
207
  });
181
208
  return models;
@@ -2,6 +2,7 @@ import type { Missing, NextStep } from "@wirecat/cli-base/standards";
2
2
  import type { InferOutput } from "valibot";
3
3
  import type { ENTITIES } from "../../entities/index.js";
4
4
  import type { FoundMessage, SearchFound } from "../../services/messages.js";
5
+ import type { MeaningSkipped } from "../../services/messages-discovery.js";
5
6
  import type { FoundNote, NotesFoundOf } from "../../services/notes-search.js";
6
7
  import type { SearchAllItem } from "../../services/search-all.js";
7
8
  import type { MeetingSearchItem, SearchAllIncludingMeetingsFound } from "../../services/search-all-meetings.js";
@@ -20,6 +21,8 @@ export declare const coverageGaps: (found: Pick<SearchFound, "coverage" | "refre
20
21
  next: NextStep[];
21
22
  missing: Missing[];
22
23
  };
24
+ /** The pretty note for messages searched by words only, with the command that would add meaning. */
25
+ export declare const meaningNote: (meaning: MeaningSkipped, command: string) => string;
23
26
  export interface Paging {
24
27
  limit: number;
25
28
  page?: number;
@@ -31,6 +31,7 @@ export const messageHitItem = (hit, query = "") => {
31
31
  missing_terms: hit.discovery.missingTerms,
32
32
  parent: hit.discovery.parent,
33
33
  found_by: hit.discovery.foundBy,
34
+ reranker_score: hit.discovery.rerankerScore,
34
35
  })
35
36
  : undefined,
36
37
  email: hit.email
@@ -88,6 +89,31 @@ export const coverageGaps = (found, shown) => {
88
89
  ],
89
90
  };
90
91
  };
92
+ /** Messages searched by words only, and the step that would add meaning: `--discover` and `search all` alike. */
93
+ const messageMeaningGap = (meaning, field) => {
94
+ const next = [];
95
+ if (!meaning)
96
+ return { next, missing: [] };
97
+ if (meaning.step === "models.download")
98
+ next.push(nextStep("models.download", { args: { model: meaning.model } }));
99
+ if (meaning.step === "conversations.rebuild")
100
+ next.push(nextStep("conversations.rebuild", { args: {} }));
101
+ return {
102
+ next,
103
+ missing: [
104
+ { field, why: `searched by words only: ${meaning.reason}`, ...(meaning.step ? { step: meaning.step } : {}) },
105
+ ],
106
+ };
107
+ };
108
+ /** The pretty note for messages searched by words only, with the command that would add meaning. */
109
+ export const meaningNote = (meaning, command) => {
110
+ const step = meaning.step === "models.download"
111
+ ? ` — \`${command} models download ${meaning.model}\`, then \`${command} conversations rebuild\` gives messages their vectors`
112
+ : meaning.step === "conversations.rebuild"
113
+ ? ` — \`${command} conversations rebuild\` gives messages their vectors, a batch a run`
114
+ : "";
115
+ return `meaning search not used: ${meaning.reason}${step}`;
116
+ };
91
117
  const gapsOf = (found) => {
92
118
  const { next, missing } = coverageGaps(found, found.items.length);
93
119
  const building = !found.wordsReady || (found.query?.stemming?.applied === false && found.query.stemming.reason === "building");
@@ -101,16 +127,19 @@ const gapsOf = (found) => {
101
127
  step: "store.migrate",
102
128
  });
103
129
  }
104
- const meaning = found.meaningSkipped;
105
- if (meaning) {
106
- if (meaning.step === "models.download")
107
- next.push(nextStep("models.download", { args: { model: meaning.model } }));
108
- if (meaning.step === "conversations.rebuild")
109
- next.push(nextStep("conversations.rebuild", { args: {} }));
130
+ const meaning = messageMeaningGap(found.meaningSkipped, "discovery");
131
+ for (const step of meaning.next)
132
+ next.push(step);
133
+ for (const gap of meaning.missing)
134
+ missing.push(gap);
135
+ const rerank = found.rerankSkipped;
136
+ if (rerank) {
137
+ if (rerank.step === "models.download")
138
+ next.push(nextStep("models.download", { args: { model: rerank.model } }));
110
139
  missing.push({
111
140
  field: "discovery",
112
- why: `searched by words only: ${meaning.reason}`,
113
- ...(meaning.step ? { step: meaning.step } : {}),
141
+ why: `not reranked: ${rerank.reason}`,
142
+ ...(rerank.step ? { step: rerank.step } : {}),
114
143
  });
115
144
  }
116
145
  if (found.picturesModel !== undefined) {
@@ -197,6 +226,7 @@ export const notesAnswer = (command, found, { limit, page, again }, exact = fals
197
226
  export const allAnswer = (command, found, { limit, page, again }, exact = false) => {
198
227
  const scan = found.meetings?.complete === false ? found.meetings : undefined;
199
228
  const notes = meaningGap(found.notes?.meaningSkipped, exact);
229
+ const messages = messageMeaningGap(found.messages?.meaningSkipped, "found_by");
200
230
  return listOf(command, {
201
231
  items: found.items.map(allHitItem),
202
232
  limit,
@@ -214,6 +244,7 @@ export const allAnswer = (command, found, { limit, page, again }, exact = false)
214
244
  ]
215
245
  : []),
216
246
  ...notes.next,
247
+ ...messages.next.filter((step) => !notes.next.some((asked) => JSON.stringify(asked) === JSON.stringify(step))),
217
248
  ],
218
249
  missing: [
219
250
  ...(scan
@@ -226,6 +257,7 @@ export const allAnswer = (command, found, { limit, page, again }, exact = false)
226
257
  ]
227
258
  : []),
228
259
  ...notes.missing,
260
+ ...messages.missing,
229
261
  ],
230
262
  meta: metaOf(found.accounts, found.skipped),
231
263
  });
@@ -11,7 +11,7 @@ import { commandAt } from "../steps.js";
11
11
  import { messengerContext } from "./context.js";
12
12
  import { conversationsSearchCommand } from "./conversations-command.js";
13
13
  import { messagesSearchCommand } from "./messages-search-command.js";
14
- import { allAnswer, notesAnswer, offsetOf, SOURCE_SEARCHED } from "./search-answers.js";
14
+ import { allAnswer, meaningNote, notesAnswer, offsetOf, SOURCE_SEARCHED, } from "./search-answers.js";
15
15
  import { backendOptions, backendRequest, noteServer } from "./search-backend-options.js";
16
16
  import { topicsSearchCommand } from "./topics-command.js";
17
17
  const onlyOf = (value) => {
@@ -79,6 +79,8 @@ const allCommand = (messenger) => {
79
79
  context.renderer.note(`${resource ?? source} not searched: ${reason}`);
80
80
  if (found.notes?.meaningSkipped)
81
81
  context.renderer.note(`notes by words only: ${found.notes.meaningSkipped}`);
82
+ if (found.messages?.meaningSkipped)
83
+ context.renderer.note(`messages: ${meaningNote(found.messages.meaningSkipped, messenger.app.command)}`);
82
84
  const again = {
83
85
  query: words.join(" "),
84
86
  ...(only === undefined ? {} : { only: only.join(",") }),
@@ -12,7 +12,7 @@ import { migrate } from "../../store/migrations.js";
12
12
  import { openCache } from "../../store/open.js";
13
13
  import { storePath } from "../../store/path.js";
14
14
  import { deleteCopy, repairStore } from "../../store/repair.js";
15
- import { drainNoteIndex, fillSearchIndex, fillStems, involvementStoreOver, noteIndexState, openSqlite, openStore, orphanPointers, pendingNormalization, resetAttachmentWords, resetNoteIndex, resetSearchIndex, resetStems, searchIndexState, stemmerCache, stemsState, } from "../../store/store.js";
15
+ import { drainNoteIndex, dropAllUnusedVectors, fillSearchIndex, fillStems, involvementStoreOver, noteIndexState, openSqlite, openStore, orphanPointers, pendingNormalization, resetAttachmentWords, resetNoteIndex, resetSearchIndex, resetStems, searchIndexState, stemmerCache, stemsState, vectorIndexCounts, } from "../../store/store.js";
16
16
  import { environmentOf, outputFor } from "../context.js";
17
17
  import { destroysNothing, LOCAL_READ, LOCAL_WRITE } from "../effects.js";
18
18
  import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
@@ -217,6 +217,8 @@ const checkCommand = (messenger) => annotate(new Command("check"), { output: { e
217
217
  const orphans = answer.orphanPointers.reduce((sum, { count }) => sum + count, 0);
218
218
  if (orphans > 0)
219
219
  renderer.note(`${orphans} pointers name a row that is gone or a type the store does not know — see orphan_pointers`);
220
+ if (answer.vectors && answer.vectors.unused > 0)
221
+ renderer.note(`${answer.vectors.unused} vectors belong to no text the store still holds — \`${command} store rebuild\` drops them`);
220
222
  const older = answer.conversations.filter((chat) => chat.provider === messenger.provider && !chat.current).length;
221
223
  if (older > 0) {
222
224
  renderer.note(`${older} chats' conversations were built by older rules — \`${command} conversations links rebuild --chat <chat>\``);
@@ -241,11 +243,14 @@ const inspect = (path) => reading(path, (database) => {
241
243
  const { bavail, bsize } = statfsSync(dirname(path));
242
244
  const free = Number(bavail) * Number(bsize);
243
245
  const behind = schema.version > 0 ? chatsBehind(database) : [];
246
+ const vectorIndexes = schema.version >= 34 ? vectorIndexCounts(database) : [];
244
247
  const checks = {
245
248
  schema: schema.version >= SPEAKS && schema.writable,
246
249
  integrity: integrity.length === 1 && integrity[0] === "ok",
247
250
  foreignKeys: foreignKeys === 0,
248
251
  searchIndexes: Object.values(searchIndexes).every((state) => state === "ok"),
252
+ // One that is still filling is short by design; a ready one must hold exactly the rows its sources give.
253
+ vectorIndexes: vectorIndexes.every(({ ready, missing, extra }) => !ready || missing + extra === 0),
249
254
  // A backup or a VACUUM needs about as much again.
250
255
  disk: free >= size,
251
256
  };
@@ -268,7 +273,7 @@ const inspect = (path) => reading(path, (database) => {
268
273
  orphanPointers: schema.version > 0 ? orphanPointers(database) : [],
269
274
  conversations: conversationsBuilt(database),
270
275
  vectors: vectorsHeld(database),
271
- notApplicable: { extensions: "SQLite needs none" },
276
+ vectorIndexes,
272
277
  };
273
278
  });
274
279
  /** `rank = 1` compares the index with its table; without it a stale external-content index passes. */
@@ -282,8 +287,8 @@ const indexIntegrity = (database, index, rank = 1) => {
282
287
  }
283
288
  };
284
289
  /**
285
- * Vectors per model, and how many no chunk of any build points at any more — a rebuild that changed a
286
- * conversation's text leaves its old vector behind until `conversations embed clear` (phase 5).
290
+ * Vectors per model, and how many no chunk points at any more. Each write that drops chunks drops those
291
+ * (`dropUnusedVectors`), so a count above zero was most likely left by an older build; `store rebuild` drops them.
287
292
  */
288
293
  const vectorsHeld = (database) => {
289
294
  const exists = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'embeddings'").get();
@@ -415,7 +420,7 @@ const buildNoteIndex = (database, note, { reset = false } = {}) => {
415
420
  };
416
421
  const reindexCommand = (messenger) => destroysNothing(annotate(new Command("rebuild"), { output: { entity: "store_reindex" }, effects: LOCAL_WRITE }))
417
422
  .summary("rebuild the local archive's search indexes")
418
- .description("rebuild the word index, its typo vocabulary, the stems, the files' word index, the notes' indexes and who took part in what, from what is stored; loses nothing")
423
+ .description("rebuild the word index, its typo vocabulary, the stems, the files' word index, the notes' indexes and who took part in what, from what is stored, and drop the vectors no stored text uses; loses nothing")
419
424
  .action(async function () {
420
425
  const { renderer } = outputFor(this);
421
426
  const path = storePath(environmentOf(this).env ?? process.env);
@@ -443,6 +448,7 @@ const reindexCommand = (messenger) => destroysNothing(annotate(new Command("rebu
443
448
  ...buildStems(database, (note) => renderer.note(note), { force: true }),
444
449
  ...buildNoteIndex(database, (note) => renderer.note(note), { reset: true }),
445
450
  involvements: involvementStoreOver({ database, now: Date.now }).rebuild(),
451
+ unusedVectorsDropped: dropAllUnusedVectors(database),
446
452
  };
447
453
  });
448
454
  result(this, answer);
@@ -15,7 +15,7 @@ export const MODEL_CATALOGUE_QUERY = defineQuery({
15
15
  entity: "model",
16
16
  backend: "native",
17
17
  fields: [
18
- termField("kind", "enum", "kind:audio", { values: ["audio", "text"] }),
18
+ termField("kind", "enum", "kind:audio", { values: ["audio", "text", "reranker"] }),
19
19
  termField("id", "kw", "id:e5-small"),
20
20
  termField("downloaded", "bool", "downloaded:true"),
21
21
  ],
@@ -82,6 +82,8 @@ export interface Settings extends AISettings {
82
82
  searchCatchUp?: boolean;
83
83
  /** Search and `images embed` read the pictures themselves, with a model on this machine. Off until set. */
84
84
  pictureSearch?: boolean;
85
+ /** The reranker that orders `search messages --discover`'s first hits once it is downloaded, or `off`. */
86
+ searchReranker?: string;
85
87
  limit: number;
86
88
  /** Which page, 1-based. Per invocation only — a page number in a file is a setting nobody wants twice. */
87
89
  page: number;
@@ -271,6 +273,7 @@ export declare const settingsFor: <T extends object = Record<never, never>>(app:
271
273
  speech_model: v.OptionalSchema<v.StringSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
272
274
  search_catch_up: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
273
275
  picture_search: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
276
+ search_reranker: v.OptionalSchema<v.PicklistSchema<[...string[], "off"], (issue: v.BaseIssue<unknown>) => string>, undefined>;
274
277
  update_check: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
275
278
  skill_hint: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
276
279
  }, (issue: v.StrictObjectIssue) => string>, undefined>;
@@ -343,6 +346,7 @@ export declare const settingsFor: <T extends object = Record<never, never>>(app:
343
346
  speech_model: v.OptionalSchema<v.StringSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
344
347
  search_catch_up: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
345
348
  picture_search: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
349
+ search_reranker: v.OptionalSchema<v.PicklistSchema<[...string[], "off"], (issue: v.BaseIssue<unknown>) => string>, undefined>;
346
350
  }, (issue: v.StrictObjectIssue) => string>, "has to be an object of profiles, by name">, {}>;
347
351
  personal: v.OptionalSchema<v.StrictObjectSchema<{
348
352
  readonly defaults: v.OptionalSchema<v.StrictObjectSchema<v.ObjectEntries, (issue: v.StrictObjectIssue) => string>, undefined>;
@@ -3,6 +3,7 @@ import { CliError, configFilePath, resolvePaths, saveConfigFile } from "@wirecat
3
3
  import * as v from "valibot";
4
4
  import { AI_ENTRIES, resolveAISettings } from "../analysis/settings.js";
5
5
  import { parseDuration } from "../domain/duration.js";
6
+ import { DEFAULT_RERANK_MODEL, RERANK_MODELS } from "../embeddings/models.js";
6
7
  import { envName, readOnlyLockOf } from "../ports/app.js";
7
8
  import { assertOverridesTighten, CEILING_KEY, LEVELS, layerPermissions, permissionOverrides, RESOURCES, } from "../sends/permissions.js";
8
9
  import { storePath } from "../store/path.js";
@@ -63,6 +64,7 @@ const SHARED_PROFILE_ENTRIES = {
63
64
  speech_model: v.optional(v.string(plain("has to be a model id from `models list`, in quotes"))),
64
65
  search_catch_up: v.optional(flag),
65
66
  picture_search: v.optional(flag),
67
+ search_reranker: v.optional(v.picklist([...RERANK_MODELS.map((model) => model.id), "off"], plain(`has to be off or a reranker id: ${RERANK_MODELS.map((model) => model.id).join(", ")}`))),
66
68
  ...AI_ENTRIES,
67
69
  };
68
70
  /**
@@ -228,6 +230,7 @@ export const settingsFor = (app, extension = {}) => {
228
230
  const senderColors = fromLayers("sender_colors", false);
229
231
  const searchCatchUp = fromLayers("search_catch_up", false);
230
232
  const pictureSearch = fromLayers("picture_search", false);
233
+ const searchReranker = fromLayers("search_reranker", DEFAULT_RERANK_MODEL);
231
234
  const keepRunsForDays = fromLayers("keep_runs_for_days", DEFAULT_KEEP_RUNS_FOR_DAYS);
232
235
  // Set where an agent runs unattended; the config file and `--permission` cannot undo it.
233
236
  const readOnlyLock = readOnlyLockOf(app, env);
@@ -275,6 +278,7 @@ export const settingsFor = (app, extension = {}) => {
275
278
  senderColors: senderColors.value,
276
279
  searchCatchUp: searchCatchUp.value,
277
280
  pictureSearch: pictureSearch.value,
281
+ searchReranker: searchReranker.value,
278
282
  limit: limit.value,
279
283
  page: flags.page ?? 1,
280
284
  all: flags.all === true,
@@ -308,6 +312,7 @@ export const settingsFor = (app, extension = {}) => {
308
312
  senderColors: senderColors.from,
309
313
  searchCatchUp: searchCatchUp.from,
310
314
  pictureSearch: pictureSearch.from,
315
+ searchReranker: searchReranker.from,
311
316
  record: record.from,
312
317
  keepRunsForDays: keepRunsForDays.from,
313
318
  sendsPerHour: sendsPerHour.from,
@@ -87,6 +87,7 @@ export declare const configCommand: (app: import("./entry.js").AppIdentity, conf
87
87
  speech_model: import("valibot").OptionalSchema<import("valibot").StringSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
88
88
  search_catch_up: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
89
89
  picture_search: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
90
+ search_reranker: import("valibot").OptionalSchema<import("valibot").PicklistSchema<[...string[], "off"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
90
91
  update_check: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
91
92
  skill_hint: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
92
93
  }, (issue: import("valibot").StrictObjectIssue) => string>, undefined>;
@@ -159,6 +160,7 @@ export declare const configCommand: (app: import("./entry.js").AppIdentity, conf
159
160
  speech_model: import("valibot").OptionalSchema<import("valibot").StringSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
160
161
  search_catch_up: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
161
162
  picture_search: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
163
+ search_reranker: import("valibot").OptionalSchema<import("valibot").PicklistSchema<[...string[], "off"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
162
164
  }, (issue: import("valibot").StrictObjectIssue) => string>, "has to be an object of profiles, by name">, {}>;
163
165
  personal: import("valibot").OptionalSchema<import("valibot").StrictObjectSchema<{
164
166
  readonly defaults: import("valibot").OptionalSchema<import("valibot").StrictObjectSchema<import("valibot").ObjectEntries, (issue: import("valibot").StrictObjectIssue) => string>, undefined>;
@@ -286,6 +288,7 @@ export declare const completeCommand: (messenger: import("./index.js").Messenger
286
288
  speech_model: import("valibot").OptionalSchema<import("valibot").StringSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
287
289
  search_catch_up: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
288
290
  picture_search: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
291
+ search_reranker: import("valibot").OptionalSchema<import("valibot").PicklistSchema<[...string[], "off"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
289
292
  update_check: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
290
293
  skill_hint: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
291
294
  }, (issue: import("valibot").StrictObjectIssue) => string>, undefined>;
@@ -358,6 +361,7 @@ export declare const completeCommand: (messenger: import("./index.js").Messenger
358
361
  speech_model: import("valibot").OptionalSchema<import("valibot").StringSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
359
362
  search_catch_up: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
360
363
  picture_search: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
364
+ search_reranker: import("valibot").OptionalSchema<import("valibot").PicklistSchema<[...string[], "off"], (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
361
365
  }, (issue: import("valibot").StrictObjectIssue) => string>, "has to be an object of profiles, by name">, {}>;
362
366
  personal: import("valibot").OptionalSchema<import("valibot").StrictObjectSchema<{
363
367
  readonly defaults: import("valibot").OptionalSchema<import("valibot").StrictObjectSchema<import("valibot").ObjectEntries, (issue: import("valibot").StrictObjectIssue) => string>, undefined>;
@@ -1,18 +1,28 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { openEmbedder } from "./embed.js";
3
- const { model, directory, threads } = JSON.parse(process.argv[2] ?? "{}");
4
- const embedder = await openEmbedder(model, directory, { threads });
3
+ import { openReranker } from "./rerank.js";
4
+ const { model, directory, threads, rerank } = JSON.parse(process.argv[2] ?? "{}");
5
+ const session = rerank
6
+ ? { reranker: await openReranker(model, directory, { threads }) }
7
+ : { embedder: await openEmbedder(model, directory, { threads }) };
5
8
  // stdout carries the answers alone, one JSON line each: anything else there breaks the parent's reading.
6
9
  const answer = (line) => process.stdout.write(`${JSON.stringify(line)}\n`);
7
10
  answer({ ready: true });
8
11
  for await (const line of createInterface({ input: process.stdin })) {
9
- const { id, texts, kind } = JSON.parse(line);
12
+ const request = JSON.parse(line);
13
+ const { id } = request;
10
14
  try {
11
- answer({ id, vectors: (await embedder.embed(texts, kind)).map((vector) => Array.from(vector)) });
15
+ if (session.reranker)
16
+ answer({ id, scores: await session.reranker.score(request.question, request.texts) });
17
+ else if (session.embedder)
18
+ answer({
19
+ id,
20
+ vectors: (await session.embedder.embed(request.texts, request.kind)).map((vector) => Array.from(vector)),
21
+ });
12
22
  }
13
23
  catch (error) {
14
24
  answer({ id, error: error instanceof Error ? error.message : String(error) });
15
25
  }
16
26
  }
17
- await embedder.close();
27
+ await (session.reranker ?? session.embedder)?.close();
18
28
  process.exit(0);