@wirecat/cli-core 0.592.0 → 0.593.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 CHANGED
@@ -7,6 +7,33 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.593.0 — 06.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`questionLookup` is public** (`@wirecat/cli-core/services`): the rule `search all` uses to search a question
15
+ typed as a sentence with discovery, so a CLI's own message search can do the same and find a short reply by the
16
+ message it answers.
17
+ - **`schedule agent` and `schedule command` take `--to <route>`, repeatable.** After the run, when `--notify-on`
18
+ says so, the scheduler sends the result through the owner's own CLI: `telegram-bot:<profile>/<chat id or
19
+ user:id>` runs `tg <profile> bot messages send`, `email:<profile>/<address>` runs `mail <profile> messages send`.
20
+ A finished run sends its output (an agent's report, a command's log): to a bot as one message up to 4096
21
+ characters, else as a file with its first lines as the caption; by mail as the body, the title as the subject. A
22
+ failed run sends one line: exit code, reason id, the log's path. The run itself stays read-only; nothing it prints
23
+ can change where the result goes; a run under `<PREFIX>_READ_ONLY` sends nothing. Each route's outcome is in
24
+ `last.json` as `deliveries` (`to`, `ok`, a dotted `reason`, the sending CLI's error code); a failed send never fails
25
+ the run, and `schedule list` shows it. `ServerSystem.run` takes an optional `{ input, timeoutMs }`.
26
+ - **`store.mail.writtenTo(addresses)`.** Of these addresses, the ones the owner wrote to: To or Cc of a live outgoing
27
+ email in any account, in lower case, in one read.
28
+
29
+ ### Fixed
30
+
31
+ - **`search all` finds mail by meaning through the vector index.** The mail half of the meaning lane read every mail
32
+ vector of each mailbox (`store.mail.nearest`, #2471), most of a `search all` on a large store: 9.5 s warm and
33
+ 44–71 s with a cold disk on the owner's store. It now asks the model's vector index for the nearest
34
+ thread pieces and attachments, with the same results, and scans only when the index is not ready or a thread is
35
+ named (3.9 s warm on the same query).
36
+
10
37
  ## 0.592.0 — 06.11.2026
11
38
 
12
39
  ## 0.591.0 — 06.11.2026
@@ -30,17 +57,6 @@ shape of this file.
30
57
  in `@wirecat/cli-core`.
31
58
  - **`DealFilter.account` and `offset`, `pipelines({ account })`.** A list reads the owner's own (`"own"`) or one CRM
32
59
  account's copies, a page at a time.
33
- - **`schedule agent` and `schedule command` take `--to <route>`, repeatable.** After the run, when `--notify-on`
34
- says so, the scheduler sends the result through the owner's own CLI: `telegram-bot:<profile>/<chat id or
35
- user:id>` runs `tg <profile> bot messages send`, `email:<profile>/<address>` runs `mail <profile> messages send`.
36
- A finished run sends its output (an agent's report, a command's log): to a bot as one message up to 4096
37
- characters, else as a file with its first lines as the caption; by mail as the body, the title as the subject. A
38
- failed run sends one line: exit code, reason id, the log's path. The run itself stays read-only; nothing it prints
39
- can change where the result goes; a run under `<PREFIX>_READ_ONLY` sends nothing. Each route's outcome is in
40
- `last.json` as `deliveries` (`to`, `ok`, a dotted `reason`, the sending CLI's error code); a failed send never fails
41
- the run, and `schedule list` shows it. `ServerSystem.run` takes an optional `{ input, timeoutMs }`.
42
- - **`store.mail.writtenTo(addresses)`.** Of these addresses, the ones the owner wrote to: To or Cc of a live outgoing
43
- email in any account, in lower case, in one read.
44
60
 
45
61
  - **`syncCrm` and the `CrmSource` port, in `@wirecat/cli-core/crm`.** A CRM CLI writes an adapter (`pipelines(kind)`,
46
62
  `changes(kind, cursor, page)` answering a page or `{ reset: true }`, and `ids(kind, page)` when the CRM does not name
@@ -154,6 +154,7 @@ export declare const servicesFor: (deps: ServiceDeps) => Services;
154
154
  export { entriesOfList, type ListContext, type ListedEntry } from "../search/account-list.js";
155
155
  export { FIELD_VERSION, QUERY_FIELDS, QUERY_OPERATORS, validateAst } from "../search/lucene/registry.js";
156
156
  export { QUERY_LIMITS, QUERY_VERSION } from "../search/lucene/types.js";
157
+ export { questionLookup } from "../search/question-plan.js";
157
158
  export { type SearchTotal, TOTAL_CAP } from "../search/total.js";
158
159
  export { TASK_KINDS, TASK_ORIGINS, type TaskKind, type TaskOrigin } from "../tasks/index.js";
159
160
  export type { AdminFound, AdminQuery, AdminSelection, AdminStatisticsService } from "./admin-statistics.js";
@@ -119,6 +119,7 @@ export const servicesFor = (deps) => {
119
119
  export { entriesOfList } from "../search/account-list.js";
120
120
  export { FIELD_VERSION, QUERY_FIELDS, QUERY_OPERATORS, validateAst } from "../search/lucene/registry.js";
121
121
  export { QUERY_LIMITS, QUERY_VERSION } from "../search/lucene/types.js";
122
+ export { questionLookup } from "../search/question-plan.js";
122
123
  export { TOTAL_CAP } from "../search/total.js";
123
124
  export { TASK_KINDS, TASK_ORIGINS } from "../tasks/index.js";
124
125
  export { adminStatisticsService } from "./admin-statistics.js";
@@ -8,6 +8,7 @@ import { markEmailsDeleted, markEmailsRestored, recountEmailThread as recount, s
8
8
  import { bool, flag, fromJson, int, page, str, toJson } from "./events.js";
9
9
  import { folded } from "./folded.js";
10
10
  import { ensurePerson } from "./identities.js";
11
+ import { nearestMailPieces } from "./mail-nearest.js";
11
12
  import { mailRulesOver } from "./mail-rules.js";
12
13
  import { attachmentOf, saveAttachment, wordsQuery } from "./meetings.js";
13
14
  import { stemmerCache } from "./stems.js";
@@ -15,7 +16,6 @@ import { writeState } from "./sync.js";
15
16
  import { threadRows } from "./thread-columns.js";
16
17
  import { write } from "./transaction.js";
17
18
  import { dropUnusedVectors } from "./vector-index.js";
18
- import { dot } from "./vectors.js";
19
19
  /** A thread as a conversation: the summary `search conversations` and `related` answer for an email. */
20
20
  export const threadConversation = (place, builtAt) => ({
21
21
  id: `email:${place.emailId}`,
@@ -590,28 +590,10 @@ export const mailStoreOver = ({ database, orm, now }, stemmerFor = stemmerCache(
590
590
  },
591
591
  async nearest(model, query, { accountId, limit, threadExternalId, since }) {
592
592
  drainEmailIndex(database, stemmerFor);
593
- const page = database.prepare(`SELECT p.id, p.type, p.owner, p.position, p.email_id AS email, v.vector FROM ${MAIL_PIECES}
594
- JOIN embeddings v ON v.model = ? AND v.content_hash = p.content_hash
595
- WHERE p.id > ? AND p.account_id = ?
596
- AND (? IS NULL OR p.thread_id IN (SELECT id FROM email_threads WHERE account_id = p.account_id AND external_id = ?))
597
- AND (? IS NULL OR p.occurred_at >= ?)
598
- ORDER BY p.id LIMIT 500`);
599
- const best = new Map();
600
- for (let after = 0;;) {
601
- const rows = page.all(model, after, accountId, threadExternalId ?? null, threadExternalId ?? null, since ?? null, since ?? null);
602
- for (const row of rows) {
603
- const score = dot(query, row.vector);
604
- const key = row.type === "email_thread" ? `${row.owner}:${row.position}` : `email:${row.email}`;
605
- if (score > (best.get(key) ?? Number.NEGATIVE_INFINITY))
606
- best.set(key, score);
607
- }
608
- if (rows.length < 500)
609
- break;
610
- after = Number(rows.at(-1)?.id);
611
- }
593
+ const best = nearestMailPieces(database, model, query, { accountId, threadExternalId, since }, limit * 2);
612
594
  const threads = new Map();
613
595
  const emails = new Map();
614
- for (const [key, score] of [...best.entries()].sort(([, a], [, b]) => b - a)) {
596
+ for (const [key, score] of best) {
615
597
  if (emails.size >= limit)
616
598
  break;
617
599
  const [owner, position] = key.split(":");
@@ -0,0 +1,12 @@
1
+ import type { CacheDatabase } from "../driver.js";
2
+ interface MailPieceFilter {
3
+ accountId: number;
4
+ threadExternalId?: string | undefined;
5
+ since?: number | undefined;
6
+ }
7
+ /**
8
+ * Each mail piece of the account (a thread's piece or an email's attachment) by its best score against `query`, best
9
+ * first. Without a thread the index gives the best `depth` of each kind; a thread, or an index not ready, scans.
10
+ */
11
+ export declare const nearestMailPieces: (database: CacheDatabase, model: string, query: Float32Array, filter: MailPieceFilter, depth: number) => (readonly [string, number])[];
12
+ export {};
@@ -0,0 +1,73 @@
1
+ import { MAIL_PIECES } from "./email-index.js";
2
+ import { searchIndex } from "./vector-index.js";
3
+ import { dot } from "./vectors.js";
4
+ const MAIL_PIECE_ROWS = `SELECT p.id, p.content_hash AS hash, p.type, p.owner, p.position, p.email_id AS email, v.vector
5
+ FROM ${MAIL_PIECES} JOIN embeddings v ON v.model = ? AND v.content_hash = p.content_hash
6
+ WHERE p.account_id = ?
7
+ AND (? IS NULL OR p.thread_id IN (SELECT id FROM email_threads WHERE account_id = p.account_id AND external_id = ?))
8
+ AND (? IS NULL OR p.occurred_at >= ?)`;
9
+ const pieceKey = (row) => row.type === "email_thread" ? `${row.owner}:${row.position}` : `email:${row.email}`;
10
+ const filterParams = (model, { accountId, threadExternalId, since }) => [
11
+ model,
12
+ accountId,
13
+ threadExternalId ?? null,
14
+ threadExternalId ?? null,
15
+ since ?? null,
16
+ since ?? null,
17
+ ];
18
+ const byScore = (best) => [...best.entries()].map(([key, { score }]) => [key, score]).sort(([, a], [, b]) => b - a);
19
+ /** Every mail piece of the account scored against `query`: each thread piece or attachment keeps its best score. */
20
+ const scannedMailPieces = (database, model, query, filter) => {
21
+ const page = database.prepare(`${MAIL_PIECE_ROWS} AND p.id > ? ORDER BY p.id LIMIT 500`);
22
+ const best = new Map();
23
+ for (let after = 0;;) {
24
+ const rows = page.all(...filterParams(model, filter), after);
25
+ for (const row of rows) {
26
+ const score = dot(query, row.vector);
27
+ const key = pieceKey(row);
28
+ if (score > (best.get(key)?.score ?? Number.NEGATIVE_INFINITY))
29
+ best.set(key, { score });
30
+ }
31
+ if (rows.length < 500)
32
+ break;
33
+ after = Number(rows.at(-1)?.id);
34
+ }
35
+ return byScore(best);
36
+ };
37
+ /**
38
+ * The best `depth` pieces of each kind by the model's vector index, or `undefined` when the index cannot answer and
39
+ * the caller scans. Reading every mail vector of a large mailbox took most of a `search all`.
40
+ */
41
+ const indexedMailPieces = (database, model, query, filter, depth) => {
42
+ const candidates = database.prepare(`${MAIL_PIECE_ROWS} AND p.id IN (SELECT value FROM json_each(?)) ORDER BY p.id`);
43
+ const merged = new Map();
44
+ for (const kind of ["email_thread", "attachment"]) {
45
+ const found = searchIndex(database, model, { kind, account: filter.accountId, ...(filter.since === undefined ? {} : { since: filter.since }) }, query, depth, (near) => {
46
+ const hashes = new Map(near.map(({ id, hash }) => [id, hash]));
47
+ const best = new Map();
48
+ for (const row of candidates.all(...filterParams(model, filter), JSON.stringify([...hashes.keys()]))) {
49
+ if (hashes.get(Number(row.id)) !== row.hash)
50
+ continue;
51
+ const score = dot(query, row.vector);
52
+ const key = pieceKey(row);
53
+ if (score > (best.get(key)?.score ?? Number.NEGATIVE_INFINITY))
54
+ best.set(key, { score, chunk: Number(row.id) });
55
+ }
56
+ const results = byScore(best)
57
+ .slice(0, depth)
58
+ .map(([key]) => [key, best.get(key)]);
59
+ return { results, best: results.map(([, { chunk }]) => chunk) };
60
+ });
61
+ if (!found)
62
+ return undefined;
63
+ for (const [key, held] of found)
64
+ merged.set(key, held);
65
+ }
66
+ return byScore(merged);
67
+ };
68
+ /**
69
+ * Each mail piece of the account (a thread's piece or an email's attachment) by its best score against `query`, best
70
+ * first. Without a thread the index gives the best `depth` of each kind; a thread, or an index not ready, scans.
71
+ */
72
+ export const nearestMailPieces = (database, model, query, filter, depth) => (filter.threadExternalId === undefined ? indexedMailPieces(database, model, query, filter, depth) : undefined) ??
73
+ scannedMailPieces(database, model, query, filter);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.592.0",
3
+ "version": "0.593.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",