@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
|
package/dist/services/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/services/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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",
|