@wirecat/cli-core 0.504.0 → 0.506.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 +57 -0
- package/dist/cli/app-doctor-command.d.ts +14 -1
- package/dist/cli/app-doctor-command.js +49 -7
- package/dist/cli/doctor-text.d.ts +2 -0
- package/dist/cli/doctor-text.js +2 -0
- package/dist/cli/messenger/doctor-command.js +3 -2
- package/dist/mail/sync.js +56 -14
- package/dist/search/lucene/meaning-parts.d.ts +14 -0
- package/dist/search/lucene/meaning-parts.js +38 -0
- package/dist/services/messages-discovery.d.ts +17 -2
- package/dist/services/messages-discovery.js +63 -17
- package/dist/services/messages-meaning.js +9 -6
- package/dist/services/messages-search.d.ts +2 -1
- package/dist/services/notes-search.d.ts +0 -7
- package/dist/services/notes-search.js +5 -39
- package/dist/services/search-all.js +1 -1
- package/dist/store/index.d.ts +1 -0
- package/dist/store/queries.d.ts +2 -0
- package/dist/store/queries.js +1 -0
- package/dist/store/sqlite/emails.d.ts +4 -1
- package/dist/store/sqlite/emails.js +11 -1
- package/dist/store/sqlite/facts.js +7 -4
- package/dist/store/sqlite/follow-ups.js +4 -4
- package/dist/store/sqlite/lucene.d.ts +5 -3
- package/dist/store/sqlite/lucene.js +9 -7
- package/dist/store/sqlite/memories.d.ts +8 -0
- package/dist/store/sqlite/memories.js +21 -3
- package/dist/store/sqlite/message-chunks.d.ts +6 -0
- package/dist/store/sqlite/message-chunks.js +10 -0
- package/dist/store/sqlite/project-transfer/records.js +3 -2
- package/dist/store/sqlite/sync.d.ts +7 -1
- package/dist/store/sqlite/transaction.d.ts +2 -1
- package/dist/store/sqlite/transaction.js +12 -3
- package/dist/store/vectors.d.ts +13 -0
- package/dist/store/vectors.js +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,63 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.506.0 — 31.10.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`QueriesStore.matchingPks`, `AccountVectors.scoreMessages` and `MeaningLane`'s fourth argument `within`**, which
|
|
15
|
+
lists a filter's messages once meaning can run: together they rank a filter's own messages. `fieldsOfQuery` and
|
|
16
|
+
`meaningWords` live in `src/search/lucene/meaning-parts.ts`, shared by notes and messages.
|
|
17
|
+
|
|
18
|
+
- **`MailStore.saveThreads(inputs, states?)`.** Saves several threads and sync state (`{ accountId, name, value }`,
|
|
19
|
+
as `setSyncState` saves it) in one write transaction: all of it commits or none of it does. Like
|
|
20
|
+
`EventStore.saveSlots`, it lets a sync commit a slice of work with its resume point.
|
|
21
|
+
|
|
22
|
+
### Changed — may break callers
|
|
23
|
+
|
|
24
|
+
- **`search messages --discover` searches by meaning when the query uses `-`, `NOT`, `OR`, groups, phrases or
|
|
25
|
+
`exact:`.** Such a query used to run strict search alone, with no meaning hits. Now its words are still matched by
|
|
26
|
+
the whole query, as strict search does (`discovery.method: "lexical-strict"`), and the messages nearest in meaning
|
|
27
|
+
to its wanted words join them; every hit found by meaning, and every reply and follow-up discovery adds, matches
|
|
28
|
+
every field of the query and every excluded word, as notes do since 0.496.0. A field joined to the words by `OR`
|
|
29
|
+
narrows nothing. A query with no word to embed (`date:today`, `Helix*`) stays strict. On the owner's store, 6
|
|
30
|
+
such queries went from 0 meaning hits to 10–17 of 20, with no hit breaking the query.
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **Search by meaning inside a filter ranks the filter's own messages.** With fields that hold at most 100,000
|
|
34
|
+
messages (`RANK_WITHIN_MESSAGES`), `--discover` scores every one of them that has a vector, instead of taking the
|
|
35
|
+
100 nearest messages overall and dropping those outside the filter. On the owner's store, `chat:<id>` with a chat of
|
|
36
|
+
1,000 messages went from 3 meaning candidates to 100 and from 9 hits to 20. A larger filter, or one the store
|
|
37
|
+
cannot list in one query (a preset, a pattern), keeps the old way.
|
|
38
|
+
- **`--discover` works on a read-only store.** Its follow-up step created a temporary table to count rare words,
|
|
39
|
+
which a read-only connection refuses; it now counts them with a bounded query.
|
|
40
|
+
- **`mail sync` no longer holds the store's one writer lock for each email in turn.** It saves fetched mail in slices of
|
|
41
|
+
50 emails, each with the resume point in the same transaction, and pauses 100 ms between slices, so `memo sync` or
|
|
42
|
+
another process next to it can take the lock instead of failing with "database is locked". A run that fails resumes
|
|
43
|
+
after the last committed slice.
|
|
44
|
+
- **A long fill or rebuild in one process no longer makes another process's write fail with "database is locked".**
|
|
45
|
+
`writeBatch` pauses 2 ms after each batch it commits. Without the pause, the next batch took the lock again within
|
|
46
|
+
microseconds, and a write waiting in another process (a command run during `store rebuild` or a migration's fill)
|
|
47
|
+
could miss every gap until its 5 s wait ran out.
|
|
48
|
+
- **A forgotten memory's or fact's number is never given to a new one.** SQLite gave the next row the highest id + 1,
|
|
49
|
+
so after `memories.remove` or `facts.remove` of the newest record, the next one took its number and a stale
|
|
50
|
+
`memory:N` or `fact:N` named it. A remove now keeps the highest id handed out in `store_settings`
|
|
51
|
+
(`ids.memories.highest`, `ids.facts.highest`), and new memories and facts, restored project facts included, take
|
|
52
|
+
the next id above it. No schema change.
|
|
53
|
+
|
|
54
|
+
## 0.505.0 — 31.10.2026
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- **`appDoctorCommand` takes `--online`, and a messenger's and an app's `doctor` now say one thing.** Both use the
|
|
59
|
+
summary "the state this installation is in, without connecting unless --online" and the option text "also connect
|
|
60
|
+
once and read the account; sends nothing" (one constant, `doctor-text.ts`). Without the flag the answer is as before
|
|
61
|
+
and nothing is called. With it, `online` joins the answer: `{ ok, duration_ms, version: { current, latest, newer } }`,
|
|
62
|
+
plus the fields of the new `AppDoctorOptions.online(env)` hook, where a CLI checks its own service (a login, a folder
|
|
63
|
+
read); a throw there is `ok: false` with `error_code` and `error`, never a failed command. `version` comes from npm
|
|
64
|
+
(the `latestVersion` that `upgrade` uses) when the CLI passes the new `AppDoctorOptions.packageName`; without it that
|
|
65
|
+
check is left out. The command's effects are now `REMOTE_READ`, as the messenger's.
|
|
66
|
+
|
|
10
67
|
## 0.504.0 — 31.10.2026
|
|
11
68
|
|
|
12
69
|
### Added
|
|
@@ -9,6 +9,15 @@ export interface AppDoctorOptions {
|
|
|
9
9
|
oauthClient?: (env: NodeJS.ProcessEnv) => {
|
|
10
10
|
client: "wirecat" | "own";
|
|
11
11
|
};
|
|
12
|
+
/** The npm package, so `--online` can say whether a newer version is published; without it that check is left out. */
|
|
13
|
+
packageName?: string;
|
|
14
|
+
/**
|
|
15
|
+
* What `--online` checks of the service: a login, a folder read. Its fields join the `online` answer, and `ok: false`
|
|
16
|
+
* (or a throw, reported as `error_code` and `error`) makes the whole check fail without failing the command.
|
|
17
|
+
*/
|
|
18
|
+
online?: (env: NodeJS.ProcessEnv) => Promise<{
|
|
19
|
+
ok: boolean;
|
|
20
|
+
} & Record<string, unknown>>;
|
|
12
21
|
}
|
|
13
22
|
/**
|
|
14
23
|
* Run when everything else is broken, so a problem in the config file or the OAuth setting is a field, never a throw.
|
|
@@ -103,5 +112,9 @@ export declare const appDoctorReport: (app: AppIdentity, configuration: Configur
|
|
|
103
112
|
};
|
|
104
113
|
fixes: string[] | never[];
|
|
105
114
|
};
|
|
106
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* `doctor` for a CLI that is not a messenger: gcal, drive, zoom. A messenger's is `doctorCommand(messenger)`, and both
|
|
117
|
+
* say the same thing: the state from disk, and only with `--online` a call out. Here that call is `options.online`
|
|
118
|
+
* and, with `options.packageName`, whether npm has a newer version.
|
|
119
|
+
*/
|
|
107
120
|
export declare const appDoctorCommand: (app: AppIdentity, configuration: Configuration, options?: AppDoctorOptions) => Command;
|
|
@@ -2,15 +2,48 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { CliError, configFilePath, resolvePaths } from "@wirecat/cli-base";
|
|
4
4
|
import { annotate } from "@wirecat/cli-base/commands";
|
|
5
|
+
import { isNewer, latestVersion } from "@wirecat/cli-base/update";
|
|
5
6
|
import { Command } from "commander";
|
|
6
7
|
import { envName, readOnlyLockOf } from "../ports/app.js";
|
|
7
8
|
import { storePath } from "../store/path.js";
|
|
8
9
|
import { environmentOf, outputFor } from "./context.js";
|
|
9
|
-
import {
|
|
10
|
+
import { DOCTOR_ONLINE_OPTION, DOCTOR_SUMMARY } from "./doctor-text.js";
|
|
11
|
+
import { REMOTE_READ } from "./effects.js";
|
|
10
12
|
import { itemOf, renderAnswer } from "./envelope.js";
|
|
11
13
|
import { privateFiles } from "./messenger/private-files.js";
|
|
12
14
|
import { unknownPermissionIssues } from "./permission-keys.js";
|
|
13
15
|
import { profileFree } from "./profile.js";
|
|
16
|
+
const onlineCheck = async (app, env, fetcher, options) => {
|
|
17
|
+
const started = Date.now();
|
|
18
|
+
let service = { ok: true };
|
|
19
|
+
if (options.online)
|
|
20
|
+
try {
|
|
21
|
+
service = await options.online(env);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
service = {
|
|
25
|
+
ok: false,
|
|
26
|
+
error_code: error instanceof CliError ? error.code : "generic_failure",
|
|
27
|
+
error: error instanceof Error ? error.message : String(error),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const latest = options.packageName
|
|
31
|
+
? await latestVersion(options.packageName, fetcher, { timeoutMs: 3000 })
|
|
32
|
+
: undefined;
|
|
33
|
+
return {
|
|
34
|
+
...service,
|
|
35
|
+
duration_ms: Date.now() - started,
|
|
36
|
+
...(options.packageName
|
|
37
|
+
? {
|
|
38
|
+
version: {
|
|
39
|
+
current: app.version,
|
|
40
|
+
latest: latest ?? null,
|
|
41
|
+
newer: latest !== undefined && isNewer(latest, app.version),
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
: {}),
|
|
45
|
+
};
|
|
46
|
+
};
|
|
14
47
|
const failure = (error) => {
|
|
15
48
|
if (error instanceof CliError)
|
|
16
49
|
return error.message;
|
|
@@ -85,17 +118,26 @@ export const appDoctorReport = (app, configuration, env, command, options = {})
|
|
|
85
118
|
};
|
|
86
119
|
}
|
|
87
120
|
};
|
|
88
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* `doctor` for a CLI that is not a messenger: gcal, drive, zoom. A messenger's is `doctorCommand(messenger)`, and both
|
|
123
|
+
* say the same thing: the state from disk, and only with `--online` a call out. Here that call is `options.online`
|
|
124
|
+
* and, with `options.packageName`, whether npm has a newer version.
|
|
125
|
+
*/
|
|
89
126
|
export const appDoctorCommand = (app, configuration, options = {}) => profileFree(annotate(new Command("doctor"), {
|
|
90
|
-
effects:
|
|
127
|
+
effects: REMOTE_READ,
|
|
91
128
|
examples: [`${app.command} doctor`, `${app.command} doctor --json`],
|
|
92
129
|
}))
|
|
93
|
-
.description(
|
|
94
|
-
.
|
|
95
|
-
|
|
130
|
+
.description(DOCTOR_SUMMARY)
|
|
131
|
+
.option("--online", DOCTOR_ONLINE_OPTION)
|
|
132
|
+
.action(async function () {
|
|
133
|
+
const environment = environmentOf(this);
|
|
134
|
+
const env = environment.env ?? process.env;
|
|
96
135
|
const { report, fixes } = appDoctorReport(app, configuration, env, this, options);
|
|
136
|
+
const online = this.opts().online
|
|
137
|
+
? await onlineCheck(app, env, environment.fetch ?? fetch, options)
|
|
138
|
+
: undefined;
|
|
97
139
|
const output = outputFor(this);
|
|
98
|
-
renderAnswer(this, output, itemOf(report));
|
|
140
|
+
renderAnswer(this, output, itemOf(online ? { ...report, online } : report));
|
|
99
141
|
for (const fix of fixes)
|
|
100
142
|
output.renderer.note(`to make it private: ${fix}`);
|
|
101
143
|
});
|
|
@@ -10,6 +10,7 @@ import { FloodMemory, floodPathFor } from "../../sends/flood.js";
|
|
|
10
10
|
import { SendJournal, sendsPathFor } from "../../sends/journal.js";
|
|
11
11
|
import { storePath } from "../../store/path.js";
|
|
12
12
|
import { baseContext, environmentOf, outputFor } from "../context.js";
|
|
13
|
+
import { DOCTOR_ONLINE_OPTION, DOCTOR_SUMMARY } from "../doctor-text.js";
|
|
13
14
|
import { destroysNothing, LOCAL_WRITE, NO_EFFECTS, REMOTE_READ } from "../effects.js";
|
|
14
15
|
import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
|
|
15
16
|
import { unknownPermissionIssues } from "../permission-keys.js";
|
|
@@ -28,8 +29,8 @@ import { storeSummary } from "./store-maintenance-command.js";
|
|
|
28
29
|
*/
|
|
29
30
|
export const doctorCommand = (messenger) => {
|
|
30
31
|
const command = annotate(new Command("doctor"), { effects: REMOTE_READ })
|
|
31
|
-
.description(
|
|
32
|
-
.option("--online",
|
|
32
|
+
.description(DOCTOR_SUMMARY)
|
|
33
|
+
.option("--online", DOCTOR_ONLINE_OPTION)
|
|
33
34
|
.action(async function () {
|
|
34
35
|
const { online } = this.opts();
|
|
35
36
|
const { report, renderer, fixes = [] } = await diagnose(this, messenger);
|
package/dist/mail/sync.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { setTimeout } from "node:timers/promises";
|
|
1
2
|
import { CliError } from "@wirecat/cli-base";
|
|
2
3
|
import { importEngine } from "../attachments/extract.js";
|
|
3
4
|
import { addressOf, FETCH_CHUNK, MAX_ATTACHMENT_CHARS, readAttachments } from "./attachments.js";
|
|
@@ -18,6 +19,8 @@ const DAY = 86_400_000;
|
|
|
18
19
|
const EDGE = 2 * DAY;
|
|
19
20
|
/** How often a long run saves where it is, so a kill costs at most this much listing again. */
|
|
20
21
|
const SAVE_EVERY_MS = 30_000;
|
|
22
|
+
const SLICE_SIZE = 50;
|
|
23
|
+
const SLICE_PAUSE_MS = 100;
|
|
21
24
|
/** About a minute of listing; the 30-day window of the timer stays below it. */
|
|
22
25
|
const RESUME_FROM = 5_000;
|
|
23
26
|
export { MAX_ATTACHMENT_CHARS } from "./attachments.js";
|
|
@@ -488,7 +491,8 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
488
491
|
inFolder.set(item.messageId, [...(inFolder.get(item.messageId) ?? []), Number(item.uid)]);
|
|
489
492
|
uidsOf.set(folder, inFolder);
|
|
490
493
|
}
|
|
491
|
-
const
|
|
494
|
+
const progressStates = () => {
|
|
495
|
+
const states = [];
|
|
492
496
|
for (const [folder, searched] of searches) {
|
|
493
497
|
if (searched.validity === undefined)
|
|
494
498
|
continue;
|
|
@@ -534,8 +538,13 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
534
538
|
last: { ...thisRun, done: searched.uids.filter((uid) => within(merged, Number(uid))).length },
|
|
535
539
|
updatedAt: new Date(now()).toISOString(),
|
|
536
540
|
};
|
|
537
|
-
|
|
541
|
+
states.push({ accountId, name: resumeKey(folder), value: JSON.stringify(point) });
|
|
538
542
|
}
|
|
543
|
+
return states;
|
|
544
|
+
};
|
|
545
|
+
const saveProgress = async () => {
|
|
546
|
+
for (const { name, value } of progressStates())
|
|
547
|
+
await store.sync.setSyncState(key, name, value);
|
|
539
548
|
};
|
|
540
549
|
// Mail already stored is located before its UID is marked done, so a done UID always has its place.
|
|
541
550
|
for (const folder of locatable) {
|
|
@@ -543,8 +552,39 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
543
552
|
await store.mail.setLocations(accountId, folder, searched.validity, uidsOf.get(folder) ?? new Map());
|
|
544
553
|
}
|
|
545
554
|
let savedAt = now();
|
|
555
|
+
// Fetched mail is saved in slices: one transaction holds the lock for a slice and carries the resume point, and the
|
|
556
|
+
// pause between slices lets another process take the lock (`await` over synchronous SQLite never yields).
|
|
557
|
+
const slice = [];
|
|
558
|
+
const flush = async (pause = false) => {
|
|
559
|
+
savedAt = now();
|
|
560
|
+
const batch = slice.splice(0);
|
|
561
|
+
if (batch.length === 0)
|
|
562
|
+
return saveProgress();
|
|
563
|
+
const ids = batch.flatMap(({ input }) => input.emails.map(({ externalId }) => externalId));
|
|
564
|
+
for (const id of ids)
|
|
565
|
+
settled.add(id);
|
|
566
|
+
try {
|
|
567
|
+
await store.mail.saveThreads(batch.map(({ input }) => input), progressStates());
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
for (const id of ids)
|
|
571
|
+
settled.delete(id);
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
for (const { done } of batch)
|
|
575
|
+
await done();
|
|
576
|
+
if (pause)
|
|
577
|
+
await setTimeout(SLICE_PAUSE_MS);
|
|
578
|
+
};
|
|
579
|
+
const enqueue = async (input, done) => {
|
|
580
|
+
slice.push({ input, done });
|
|
581
|
+
if (slice.length >= SLICE_SIZE)
|
|
582
|
+
await flush(true);
|
|
583
|
+
};
|
|
546
584
|
const saveNow = async (force = false) => {
|
|
547
|
-
if (
|
|
585
|
+
if (force)
|
|
586
|
+
return flush();
|
|
587
|
+
if (now() - savedAt < SAVE_EVERY_MS)
|
|
548
588
|
return;
|
|
549
589
|
savedAt = now();
|
|
550
590
|
await saveProgress();
|
|
@@ -560,7 +600,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
560
600
|
for (const { item, rule } of toRecord) {
|
|
561
601
|
await stopIfAborted();
|
|
562
602
|
const headers = item.headers;
|
|
563
|
-
|
|
603
|
+
const input = {
|
|
564
604
|
accountId,
|
|
565
605
|
externalId: item.threadId,
|
|
566
606
|
now: startedAt,
|
|
@@ -588,10 +628,11 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
588
628
|
},
|
|
589
629
|
},
|
|
590
630
|
],
|
|
631
|
+
};
|
|
632
|
+
await enqueue(input, async () => {
|
|
633
|
+
envelopes++;
|
|
634
|
+
await tagStored(item);
|
|
591
635
|
});
|
|
592
|
-
envelopes++;
|
|
593
|
-
settled.add(item.messageId);
|
|
594
|
-
await tagStored(item);
|
|
595
636
|
await saveNow();
|
|
596
637
|
}
|
|
597
638
|
const fetchChunk = plan === undefined ? FETCH_CHUNK : (source.fetchBatch ?? FETCH_SLICE);
|
|
@@ -644,7 +685,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
644
685
|
plan?.unreadable.delete(item.messageId);
|
|
645
686
|
const header = mailHeadersOf(raws[index]);
|
|
646
687
|
const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt }, attachments);
|
|
647
|
-
|
|
688
|
+
const input = {
|
|
648
689
|
accountId,
|
|
649
690
|
externalId: item.threadId,
|
|
650
691
|
now: startedAt,
|
|
@@ -670,13 +711,14 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
670
711
|
attachments: files,
|
|
671
712
|
},
|
|
672
713
|
],
|
|
714
|
+
};
|
|
715
|
+
await enqueue(input, async () => {
|
|
716
|
+
saved++;
|
|
717
|
+
await tagStored(item);
|
|
718
|
+
const boxes = upgraded.get(item.messageId);
|
|
719
|
+
if (boxes !== undefined)
|
|
720
|
+
await store.mail.setMailboxes(accountId, boxes.map(envelopeBoxOf), new Map([[item.messageId, []]]), startedAt);
|
|
673
721
|
});
|
|
674
|
-
saved++;
|
|
675
|
-
settled.add(item.messageId);
|
|
676
|
-
await tagStored(item);
|
|
677
|
-
const boxes = upgraded.get(item.messageId);
|
|
678
|
-
if (boxes !== undefined)
|
|
679
|
-
await store.mail.setMailboxes(accountId, boxes.map(envelopeBoxOf), new Map([[item.messageId, []]]), startedAt);
|
|
680
722
|
}
|
|
681
723
|
await saveNow();
|
|
682
724
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { QueryNode } from "@wirecat/cli-base/query";
|
|
2
|
+
type Predicate = Extract<QueryNode, {
|
|
3
|
+
kind: "predicate";
|
|
4
|
+
}>;
|
|
5
|
+
/**
|
|
6
|
+
* What a hit found by meaning must still match: the query with each word it asks for taken as matched, since the
|
|
7
|
+
* meaning half stands in for those. Words it excludes, and every other field, stay. `everything` is a predicate every
|
|
8
|
+
* record of the corpus matches, as a wanted word's stand-in: an empty clause list would match nothing. `undefined`
|
|
9
|
+
* when nothing but wanted words is left, so any hit found by meaning fits.
|
|
10
|
+
*/
|
|
11
|
+
export declare const fieldsOfQuery: (root: QueryNode, everything: Omit<Predicate, "span">) => QueryNode | undefined;
|
|
12
|
+
/** The words and phrases the query asks for, for the meaning half to embed; none it excludes, no field. */
|
|
13
|
+
export declare const meaningWords: (root: QueryNode) => string;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const WORD_FIELDS = new Set(["text", "exact"]);
|
|
2
|
+
/**
|
|
3
|
+
* What a hit found by meaning must still match: the query with each word it asks for taken as matched, since the
|
|
4
|
+
* meaning half stands in for those. Words it excludes, and every other field, stay. `everything` is a predicate every
|
|
5
|
+
* record of the corpus matches, as a wanted word's stand-in: an empty clause list would match nothing. `undefined`
|
|
6
|
+
* when nothing but wanted words is left, so any hit found by meaning fits.
|
|
7
|
+
*/
|
|
8
|
+
export const fieldsOfQuery = (root, everything) => {
|
|
9
|
+
let narrows = false;
|
|
10
|
+
const kept = (node) => {
|
|
11
|
+
if (node.kind === "boolean")
|
|
12
|
+
return {
|
|
13
|
+
...node,
|
|
14
|
+
clauses: node.clauses.map(({ occur, node }) => {
|
|
15
|
+
if (occur !== "must_not")
|
|
16
|
+
return { occur, node: kept(node) };
|
|
17
|
+
narrows = true;
|
|
18
|
+
return { occur, node };
|
|
19
|
+
}),
|
|
20
|
+
};
|
|
21
|
+
if (!WORD_FIELDS.has(node.field)) {
|
|
22
|
+
narrows = true;
|
|
23
|
+
return node;
|
|
24
|
+
}
|
|
25
|
+
return { ...everything, span: node.span };
|
|
26
|
+
};
|
|
27
|
+
const fields = kept(root);
|
|
28
|
+
return narrows ? fields : undefined;
|
|
29
|
+
};
|
|
30
|
+
/** The words and phrases the query asks for, for the meaning half to embed; none it excludes, no field. */
|
|
31
|
+
export const meaningWords = (root) => {
|
|
32
|
+
const wanted = (node) => node.kind === "boolean"
|
|
33
|
+
? node.clauses.flatMap(({ occur, node }) => (occur === "must_not" ? [] : wanted(node)))
|
|
34
|
+
: WORD_FIELDS.has(node.field) && (node.operator === "term" || node.operator === "phrase")
|
|
35
|
+
? [node.value]
|
|
36
|
+
: [];
|
|
37
|
+
return wanted(root).join(" ").trim();
|
|
38
|
+
};
|
|
@@ -48,13 +48,28 @@ export interface MeaningSkipped {
|
|
|
48
48
|
* The messages of these accounts nearest in meaning to the question, best first; or why there are none. `embedders`
|
|
49
49
|
* is the search's own, when another part of it loads the same model.
|
|
50
50
|
*/
|
|
51
|
-
export type MeaningLane = (question: string, accounts: AccountKey[], embedders?: Pick<WarmEmbedders, "get"
|
|
51
|
+
export type MeaningLane = (question: string, accounts: AccountKey[], embedders?: Pick<WarmEmbedders, "get">,
|
|
52
|
+
/**
|
|
53
|
+
* The store keys of a filter's own messages, to rank all of them instead of the nearest overall; `undefined` when
|
|
54
|
+
* the filter is too large. Asked only once meaning can run, so a search without the model never lists the filter.
|
|
55
|
+
*/
|
|
56
|
+
within?: () => Promise<number[] | undefined>) => Promise<{
|
|
52
57
|
model: string;
|
|
53
58
|
ranked: MeaningRanked;
|
|
54
59
|
partly?: MeaningSkipped;
|
|
55
60
|
} | {
|
|
56
61
|
skipped: MeaningSkipped;
|
|
57
62
|
}>;
|
|
63
|
+
/**
|
|
64
|
+
* The most messages a filter may hold for meaning to rank every one of them that has a vector; above it, meaning
|
|
65
|
+
* takes the nearest overall and keeps those inside the filter, so a narrow filter loses some. Listing the filter costs
|
|
66
|
+
* ~2 µs a message and scoring ~22 µs a vector: on the owner's store (205k messages, 4% with vectors) a 30-day filter
|
|
67
|
+
* of 91k messages took ~0.2 s in all (cli-testing performance/search/messages-meaning-filters). Most messages have no
|
|
68
|
+
* vector, so the limit counts messages, not vectors; a store where all 100k have one would spend ~2 s.
|
|
69
|
+
*/
|
|
70
|
+
export declare const RANK_WITHIN_MESSAGES = 100000;
|
|
71
|
+
/** What a hit not found by the query's words must match: as a search request's text or AST. */
|
|
72
|
+
type Scope = Pick<SearchQuery, "text" | "ast">;
|
|
58
73
|
/**
|
|
59
74
|
* The query discovery searches. A question's own "?" would parse as a one-character wildcard and send it to strict
|
|
60
75
|
* search (BUG-395); the colon of a time or ratio ("22:00", "1:1") would parse as a field and fail the whole search,
|
|
@@ -63,7 +78,7 @@ export type MeaningLane = (question: string, accounts: AccountKey[], embedders?:
|
|
|
63
78
|
export declare const discoveryRequest: (asked: SearchQuery) => SearchQuery;
|
|
64
79
|
export declare const searchDiscovery: (store: MessageStore, account: AccountKey, asked: SearchQuery, messenger?: Partial<Pick<MessengerTraits, "savedChatId" | "app" | "history" | "provider">>, meaning?: MeaningLane, rerank?: RerankLane) => Promise<SearchFound>;
|
|
65
80
|
/** The nearest messages the question's filters allow, as hits, nearest first. */
|
|
66
|
-
export declare const nearEligible: (store: MessageStore, account: AccountKey, request: SearchQuery, messenger: Partial<Pick<MessengerTraits, "savedChatId" | "app" | "history" | "provider">>, ranked: MeaningRanked, scope:
|
|
81
|
+
export declare const nearEligible: (store: MessageStore, account: AccountKey, request: SearchQuery, messenger: Partial<Pick<MessengerTraits, "savedChatId" | "app" | "history" | "provider">>, ranked: MeaningRanked, scope: Scope) => Promise<(FoundMessage & {
|
|
67
82
|
cosine: number;
|
|
68
83
|
})[]>;
|
|
69
84
|
/** What the reranker reads of a hit, as the benchmark gave it: its reply parent first, each line headed by where, who and when. */
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
-
import { implicitTextTerms, walkQuery } from "@wirecat/cli-base/query";
|
|
2
|
+
import { implicitTextTerms, parseLucene, walkQuery } from "@wirecat/cli-base/query";
|
|
3
3
|
import { parseLocator } from "../domain/locator.js";
|
|
4
|
-
import {
|
|
4
|
+
import { fieldsOfQuery, meaningWords } from "../search/lucene/meaning-parts.js";
|
|
5
|
+
import { validateFields } from "../search/lucene/registry.js";
|
|
6
|
+
import { exhausted, MESSAGE_PARSING, QUERY_LIMITS } from "../search/lucene/types.js";
|
|
5
7
|
import { planQuestion, words } from "../search/question-plan.js";
|
|
6
8
|
import { searchCombined } from "./messages-combined.js";
|
|
7
9
|
import { prepareLucene, searchLucene } from "./messages-search.js";
|
|
@@ -28,6 +30,25 @@ const FOLLOW_UPS_PER_HIT = 2;
|
|
|
28
30
|
const RARE_AT = 3;
|
|
29
31
|
/** Later messages read per hit before the search's filters; the latest that pass are kept. */
|
|
30
32
|
const FOLLOW_UP_CANDIDATES = 20;
|
|
33
|
+
/**
|
|
34
|
+
* The most messages a filter may hold for meaning to rank every one of them that has a vector; above it, meaning
|
|
35
|
+
* takes the nearest overall and keeps those inside the filter, so a narrow filter loses some. Listing the filter costs
|
|
36
|
+
* ~2 µs a message and scoring ~22 µs a vector: on the owner's store (205k messages, 4% with vectors) a 30-day filter
|
|
37
|
+
* of 91k messages took ~0.2 s in all (cli-testing performance/search/messages-meaning-filters). Most messages have no
|
|
38
|
+
* vector, so the limit counts messages, not vectors; a store where all 100k have one would spend ~2 s.
|
|
39
|
+
*/
|
|
40
|
+
export const RANK_WITHIN_MESSAGES = 100_000;
|
|
41
|
+
/** Every message matches it: a wanted word's stand-in, and what a search with no text runs. */
|
|
42
|
+
const EVERY_MESSAGE = {
|
|
43
|
+
kind: "predicate",
|
|
44
|
+
field: "date",
|
|
45
|
+
operator: "range",
|
|
46
|
+
value: "*",
|
|
47
|
+
upper: "*",
|
|
48
|
+
lower_inclusive: true,
|
|
49
|
+
upper_inclusive: true,
|
|
50
|
+
};
|
|
51
|
+
const scopeOf = (root) => root === undefined ? {} : { ast: { version: 1, language: "lucene-v1", root } };
|
|
31
52
|
/**
|
|
32
53
|
* The query discovery searches. A question's own "?" would parse as a one-character wildcard and send it to strict
|
|
33
54
|
* search (BUG-395); the colon of a time or ratio ("22:00", "1:1") would parse as a field and fail the whole search,
|
|
@@ -46,20 +67,35 @@ export const searchDiscovery = async (store, account, asked, messenger = {}, mea
|
|
|
46
67
|
const original = await prepareLucene(store, account, request, messenger);
|
|
47
68
|
const text = request.text ?? "";
|
|
48
69
|
const plan = planQuestion(text, request.ast !== undefined || !!request.exact || !!request.newest);
|
|
49
|
-
if (request.ast !== undefined || request.exact || request.newest
|
|
70
|
+
if (request.ast !== undefined || request.exact || request.newest)
|
|
50
71
|
return searchLucene(store, account, request, messenger);
|
|
51
72
|
const implicit = implicitTextTerms(text);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
73
|
+
// Explicit syntax (-, OR, NOT, groups, phrases, exact:, wildcards) keeps the whole query for the words; meaning
|
|
74
|
+
// reads only its wanted words, and its hits must still match every field and every excluded word.
|
|
75
|
+
const explicit = implicit.size === 0 ||
|
|
76
|
+
(!plan.changed &&
|
|
77
|
+
walkQuery(original.execution.root).some((node) => (node.field === "text" || node.field === "exact") &&
|
|
78
|
+
(node.operator !== "term" || node.field === "exact" || !implicit.has(node.span.start))));
|
|
79
|
+
const root = explicit
|
|
80
|
+
? validateFields(parseLucene(text, { ...MESSAGE_PARSING, defaultField: "text" })).root
|
|
81
|
+
: undefined;
|
|
82
|
+
const question = root === undefined ? text : meaningWords(root);
|
|
83
|
+
if (!question)
|
|
55
84
|
return searchLucene(store, account, request, messenger);
|
|
56
|
-
const terms =
|
|
85
|
+
const terms = root === undefined
|
|
86
|
+
? [...new Set(plan.terms.concat(plan.aliases.flatMap((alias) => words(alias.split(" -> ")[1] ?? ""))))]
|
|
87
|
+
: [...new Set(words(question))];
|
|
57
88
|
if (!terms.length)
|
|
58
89
|
return searchLucene(store, account, request, messenger);
|
|
59
90
|
if (terms.length > 64)
|
|
60
91
|
exhausted("discovery terms");
|
|
92
|
+
const narrowing = root === undefined ? undefined : fieldsOfQuery(root, EVERY_MESSAGE);
|
|
93
|
+
const scope = root === undefined ? (plan.scope ? { text: plan.scope } : {}) : scopeOf(narrowing);
|
|
61
94
|
// Before the clock starts: loading the model alone takes about a second of the word search's budget.
|
|
62
|
-
const
|
|
95
|
+
const within = scope.text !== undefined || scope.ast !== undefined
|
|
96
|
+
? () => filterPks(store, account, { ...request, text: undefined, ast: undefined, ...scope }, messenger)
|
|
97
|
+
: undefined;
|
|
98
|
+
const lane = meaning ? await meaning(question, original.scopeAccounts, undefined, within) : undefined;
|
|
63
99
|
let started = performance.now();
|
|
64
100
|
const check = () => {
|
|
65
101
|
if (request.signal?.aborted)
|
|
@@ -75,7 +111,7 @@ export const searchDiscovery = async (store, account, asked, messenger = {}, mea
|
|
|
75
111
|
union.set(m.locator, { ...previous, ...m, fused: (previous?.fused ?? 0) + 1 / (60 + rank + 1) });
|
|
76
112
|
});
|
|
77
113
|
// Natural questions use one ranked partial lookup; repeated all-word lookups cannot rescue their wording gaps.
|
|
78
|
-
for (const query of plan.changed ? [] : plan.queries) {
|
|
114
|
+
for (const query of plan.changed || root !== undefined ? [] : plan.queries) {
|
|
79
115
|
check();
|
|
80
116
|
const found = await searchCombined(store, account, { ...request, text: query, limit: 300 }, messenger, {
|
|
81
117
|
candidateDepth: 300,
|
|
@@ -89,7 +125,7 @@ export const searchDiscovery = async (store, account, asked, messenger = {}, mea
|
|
|
89
125
|
truncated ||= found.hasMore || found.query.combined.truncated;
|
|
90
126
|
}
|
|
91
127
|
check();
|
|
92
|
-
const relaxed = `(${terms.join(" OR ")})${plan.scope ? ` ${plan.scope}` : ""}
|
|
128
|
+
const relaxed = root === undefined ? `(${terms.join(" OR ")})${plan.scope ? ` ${plan.scope}` : ""}` : text;
|
|
93
129
|
const partialRequest = { ...request, text: relaxed, ast: undefined, limit: 300, context: 0, thread: undefined };
|
|
94
130
|
const partial = await searchLucene(store, account, partialRequest, messenger);
|
|
95
131
|
add(partial.items);
|
|
@@ -116,8 +152,9 @@ export const searchDiscovery = async (store, account, asked, messenger = {}, mea
|
|
|
116
152
|
request.only.some((key) => key.provider === m.provider && key.account === m.account && key.chatId === m.chatId && key.id === m.id));
|
|
117
153
|
const eligible = await searchLucene(store, account, {
|
|
118
154
|
...request,
|
|
119
|
-
text:
|
|
155
|
+
text: undefined,
|
|
120
156
|
ast: undefined,
|
|
157
|
+
...scope,
|
|
121
158
|
limit: 500,
|
|
122
159
|
only,
|
|
123
160
|
context: 0,
|
|
@@ -178,7 +215,7 @@ export const searchDiscovery = async (store, account, asked, messenger = {}, mea
|
|
|
178
215
|
Number(parentByLocator.has(b.locator)) - Number(parentByLocator.has(a.locator)));
|
|
179
216
|
let meaningCandidates = 0;
|
|
180
217
|
if (lane && "ranked" in lane) {
|
|
181
|
-
const near = await nearEligible(store, account, request, messenger, lane.ranked,
|
|
218
|
+
const near = await nearEligible(store, account, request, messenger, lane.ranked, scope);
|
|
182
219
|
meaningCandidates = near.length;
|
|
183
220
|
candidates = fusedWithMeaning(candidates, near, described);
|
|
184
221
|
}
|
|
@@ -228,7 +265,7 @@ export const searchDiscovery = async (store, account, asked, messenger = {}, mea
|
|
|
228
265
|
started += performance.now() - opening;
|
|
229
266
|
}
|
|
230
267
|
check();
|
|
231
|
-
const followUps = await followUpsOf(store, account, request, messenger, candidates,
|
|
268
|
+
const followUps = await followUpsOf(store, account, request, messenger, candidates, scope);
|
|
232
269
|
const items = await Promise.all(candidates.slice(0, request.limit).map(async ({ fused: _fused, ...hit }) => {
|
|
233
270
|
const later = followUps.get(hit.locator);
|
|
234
271
|
const m = later?.length ? { ...hit, followUps: later } : hit;
|
|
@@ -254,14 +291,14 @@ export const searchDiscovery = async (store, account, asked, messenger = {}, mea
|
|
|
254
291
|
query: {
|
|
255
292
|
...partial.query,
|
|
256
293
|
discovery: {
|
|
257
|
-
method: "lexical-partial",
|
|
294
|
+
method: root === undefined ? "lexical-partial" : "lexical-strict",
|
|
258
295
|
candidateDepth: 300,
|
|
259
296
|
candidates: candidates.length,
|
|
260
297
|
repliesAdded: replyCount,
|
|
261
298
|
followUps: [...followUps.values()].reduce((sum, later) => sum + later.length, 0),
|
|
262
299
|
truncated,
|
|
263
300
|
terms,
|
|
264
|
-
queries: [...(plan.changed ? [] : plan.queries), relaxed],
|
|
301
|
+
queries: [...(plan.changed || root !== undefined ? [] : plan.queries), relaxed],
|
|
265
302
|
...(lane && "ranked" in lane ? { meaning: { model: lane.model, candidates: meaningCandidates } } : {}),
|
|
266
303
|
...(reranked ? { rerank: reranked } : {}),
|
|
267
304
|
},
|
|
@@ -286,7 +323,7 @@ const followUpsOf = async (store, account, request, messenger, candidates, scope
|
|
|
286
323
|
const out = new Map();
|
|
287
324
|
if (!only.length)
|
|
288
325
|
return out;
|
|
289
|
-
const eligible = await searchLucene(store, account, { ...request, text:
|
|
326
|
+
const eligible = await searchLucene(store, account, { ...request, text: undefined, ast: undefined, ...scope, limit: only.length, only, context: 0, thread: undefined }, messenger);
|
|
290
327
|
const allowed = new Map(eligible.items.map((m) => [m.locator, m]));
|
|
291
328
|
const shown = new Set();
|
|
292
329
|
head.forEach((hit, i) => {
|
|
@@ -302,6 +339,15 @@ const followUpsOf = async (store, account, request, messenger, candidates, scope
|
|
|
302
339
|
});
|
|
303
340
|
return out;
|
|
304
341
|
};
|
|
342
|
+
/**
|
|
343
|
+
* The store keys of the messages a filter holds, when there are at most `RANK_WITHIN_MESSAGES`; `undefined` when more,
|
|
344
|
+
* or when only a full search could list them.
|
|
345
|
+
*/
|
|
346
|
+
const filterPks = async (store, account, request, messenger) => {
|
|
347
|
+
const { execution } = await prepareLucene(store, account, request, messenger);
|
|
348
|
+
const pks = await store.queries.matchingPks(execution, RANK_WITHIN_MESSAGES);
|
|
349
|
+
return pks === undefined || pks.length > RANK_WITHIN_MESSAGES ? undefined : pks;
|
|
350
|
+
};
|
|
305
351
|
/** The nearest messages the question's filters allow, as hits, nearest first. */
|
|
306
352
|
export const nearEligible = async (store, account, request, messenger, ranked, scope) => {
|
|
307
353
|
const keyOf = (one) => JSON.stringify([one.provider, one.account, one.chatId, one.id]);
|
|
@@ -311,7 +357,7 @@ export const nearEligible = async (store, account, request, messenger, ranked, s
|
|
|
311
357
|
.map(({ provider, account, chatId, id }) => ({ provider, account, chatId, id }));
|
|
312
358
|
if (!only.length)
|
|
313
359
|
return [];
|
|
314
|
-
const eligible = await searchLucene(store, account, { ...request, text:
|
|
360
|
+
const eligible = await searchLucene(store, account, { ...request, text: undefined, ast: undefined, ...scope, limit: only.length, only, context: 0, thread: undefined }, messenger);
|
|
315
361
|
const hits = new Map(eligible.items.map((hit) => {
|
|
316
362
|
const { provider, account } = parseLocator(hit.locator);
|
|
317
363
|
return [keyOf({ provider, account, chatId: hit.chatId, id: hit.id }), hit];
|
|
@@ -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, shared) => {
|
|
9
|
+
export const meaningLane = (deps) => async (question, accounts, shared, within) => {
|
|
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,14 +24,17 @@ export const meaningLane = (deps) => async (question, accounts, shared) => {
|
|
|
24
24
|
try {
|
|
25
25
|
const ranked = [];
|
|
26
26
|
if (words) {
|
|
27
|
+
const pks = await within?.();
|
|
27
28
|
const lookUp = async (embedder) => {
|
|
28
29
|
const [query] = await embedder.embed([words], "query");
|
|
29
30
|
for (const one of ready) {
|
|
30
|
-
const near =
|
|
31
|
-
model: target.key,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
const near = pks
|
|
32
|
+
? await store.vectors.scoreMessages(one, { model: target.key, query: query, pks })
|
|
33
|
+
: await store.vectors.nearestMessages(one, {
|
|
34
|
+
model: target.key,
|
|
35
|
+
limit: MEANING_DEPTH,
|
|
36
|
+
query: query,
|
|
37
|
+
});
|
|
35
38
|
for (const { chatId, messageId, score } of near)
|
|
36
39
|
ranked.push({ ...one, chatId, id: messageId, score });
|
|
37
40
|
}
|
|
@@ -8,7 +8,8 @@ import type { SearchRefreshed } from "./search-refresh.js";
|
|
|
8
8
|
export interface QueryMetadata {
|
|
9
9
|
language: "lucene-v1";
|
|
10
10
|
discovery?: {
|
|
11
|
-
|
|
11
|
+
/** `lexical-strict` when the query's own syntax (-, OR, NOT, groups…) chose the word hits, not a relaxed OR. */
|
|
12
|
+
method: "lexical-partial" | "lexical-strict";
|
|
12
13
|
candidateDepth: number;
|
|
13
14
|
candidates: number;
|
|
14
15
|
repliesAdded: number;
|
|
@@ -169,14 +169,7 @@ export declare const noteSourceOfQuery: (query: string) => {
|
|
|
169
169
|
source?: Note["source"];
|
|
170
170
|
rest: string;
|
|
171
171
|
};
|
|
172
|
-
/**
|
|
173
|
-
* What a note found by meaning must still match: the query with each word it asks for taken as matched, since the
|
|
174
|
-
* meaning half stands in for those. Words it excludes, and every other field, stay. `undefined` when nothing but
|
|
175
|
-
* wanted words is left, so any note found by meaning fits.
|
|
176
|
-
*/
|
|
177
172
|
export declare const fieldsOfQuery: (root: QueryNode) => QueryNode | undefined;
|
|
178
|
-
/** The words and phrases the query asks for, for the meaning half to embed; none it excludes, no field. */
|
|
179
|
-
export declare const meaningWords: (root: QueryNode) => string;
|
|
180
173
|
/**
|
|
181
174
|
* The most notes a filter may hold for meaning to rank all of them, each by its best chunk. Above it, meaning ranks
|
|
182
175
|
* the nearest notes overall and keeps those inside the filter, so a narrow filter over a large store loses some. On
|
|
@@ -6,6 +6,7 @@ import { defaultThreads, isTextModelInstalled, openEmbedder, textModelsDirectory
|
|
|
6
6
|
import { DEFAULT_TEXT_MODEL, meaningFloor, textModel, vectorModelKey } from "../embeddings/models.js";
|
|
7
7
|
import { DEFAULT_IMAGE_MODEL, imageModel } from "../images/picture/models.js";
|
|
8
8
|
import { entriesOfList, fitting } from "../search/account-list.js";
|
|
9
|
+
import { fieldsOfQuery as fieldsOf, meaningWords } from "../search/lucene/meaning-parts.js";
|
|
9
10
|
import { validateFields } from "../search/lucene/registry.js";
|
|
10
11
|
import { hasStems } from "../search/lucene/resolved.js";
|
|
11
12
|
import { SEARCH_QUERY } from "../search/lucene/types.js";
|
|
@@ -234,44 +235,9 @@ export const noteSourceOfQuery = (query) => {
|
|
|
234
235
|
}
|
|
235
236
|
return { source, rest };
|
|
236
237
|
};
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
* meaning half stands in for those. Words it excludes, and every other field, stay. `undefined` when nothing but
|
|
241
|
-
* wanted words is left, so any note found by meaning fits.
|
|
242
|
-
*/
|
|
243
|
-
export const fieldsOfQuery = (root) => {
|
|
244
|
-
let narrows = false;
|
|
245
|
-
const kept = (node) => {
|
|
246
|
-
if (node.kind === "boolean")
|
|
247
|
-
return {
|
|
248
|
-
...node,
|
|
249
|
-
clauses: node.clauses.map(({ occur, node }) => {
|
|
250
|
-
if (occur !== "must_not")
|
|
251
|
-
return { occur, node: kept(node) };
|
|
252
|
-
narrows = true;
|
|
253
|
-
return { occur, node };
|
|
254
|
-
}),
|
|
255
|
-
};
|
|
256
|
-
if (!WORD_FIELDS.has(node.field)) {
|
|
257
|
-
narrows = true;
|
|
258
|
-
return node;
|
|
259
|
-
}
|
|
260
|
-
// `in:notes` holds for every note and document; an empty clause list would match nothing.
|
|
261
|
-
return { kind: "predicate", field: "in", operator: "term", value: "notes", span: node.span };
|
|
262
|
-
};
|
|
263
|
-
const fields = kept(root);
|
|
264
|
-
return narrows ? fields : undefined;
|
|
265
|
-
};
|
|
266
|
-
/** The words and phrases the query asks for, for the meaning half to embed; none it excludes, no field. */
|
|
267
|
-
export const meaningWords = (root) => {
|
|
268
|
-
const wanted = (node) => node.kind === "boolean"
|
|
269
|
-
? node.clauses.flatMap(({ occur, node }) => (occur === "must_not" ? [] : wanted(node)))
|
|
270
|
-
: WORD_FIELDS.has(node.field) && (node.operator === "term" || node.operator === "phrase")
|
|
271
|
-
? [node.value]
|
|
272
|
-
: [];
|
|
273
|
-
return wanted(root).join(" ").trim();
|
|
274
|
-
};
|
|
238
|
+
/** `in:notes` holds for every note and document. */
|
|
239
|
+
const EVERY_NOTE = { kind: "predicate", field: "in", operator: "term", value: "notes" };
|
|
240
|
+
export const fieldsOfQuery = (root) => fieldsOf(root, EVERY_NOTE);
|
|
275
241
|
const allOf = (nodes) => nodes.length <= 1
|
|
276
242
|
? nodes[0]
|
|
277
243
|
: {
|
|
@@ -286,7 +252,7 @@ const allOf = (nodes) => nodes.length <= 1
|
|
|
286
252
|
const matching = async (store, refs, root, scope) => {
|
|
287
253
|
if (refs.length === 0)
|
|
288
254
|
return new Map();
|
|
289
|
-
const resolved = await resolvedQuery(store, root ?? {
|
|
255
|
+
const resolved = await resolvedQuery(store, root ?? { ...EVERY_NOTE, span: { start: 0, end: 0 } }, scope.timezone);
|
|
290
256
|
const found = await store.notes.search({
|
|
291
257
|
root: resolved.root,
|
|
292
258
|
refs,
|
|
@@ -123,7 +123,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
|
|
|
123
123
|
return byWords;
|
|
124
124
|
}
|
|
125
125
|
messagesBy = { by: "words and meaning", ...(lane.partly ? { meaningSkipped: lane.partly } : {}) };
|
|
126
|
-
const near = await nearEligible(store, account, query, messenger, lane.ranked, filters
|
|
126
|
+
const near = await nearEligible(store, account, query, messenger, lane.ranked, filters ? { text: filters } : {});
|
|
127
127
|
// One list per resource, as notes give theirs, so messages get no second vote against mail.
|
|
128
128
|
const fused = new Map();
|
|
129
129
|
byWords.forEach((item, rank) => {
|
package/dist/store/index.d.ts
CHANGED
|
@@ -51,6 +51,7 @@ export type { PrivateContact, PrivateContactNote } from "./sqlite/private-people
|
|
|
51
51
|
export type { ProposalInput, ProposedAction, ProposedActionsStore } from "./sqlite/proposed-actions.js";
|
|
52
52
|
export { PROPOSAL_STATUSES } from "./sqlite/proposed-actions.js";
|
|
53
53
|
export { fillSearchIndex, resetSearchIndex, type SearchIndexFill, type SearchIndexState, searchIndexState, } from "./sqlite/search-index.js";
|
|
54
|
+
export type { SyncStateSave } from "./sqlite/sync.js";
|
|
54
55
|
export type { TagCreationOptions, TagCreationReview } from "./sqlite/tag-creation.js";
|
|
55
56
|
export type { TagKind, TagRow } from "./sqlite/tags.js";
|
|
56
57
|
export type { SimilarSubject, SimilarTask, TaskDraft } from "./sqlite/task-candidates.js";
|
package/dist/store/queries.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface QueriesStore {
|
|
|
15
15
|
countQuery(execution: QueryExecution, by: QueryGrouping): Promise<QueryGroup[]>;
|
|
16
16
|
/** How many messages match, counted no further than `cap + 1`; `undefined` when only a full search could tell. */
|
|
17
17
|
countMatches(execution: QueryExecution, cap: number): Promise<number | undefined>;
|
|
18
|
+
/** The keys of the messages that match, no more than `cap + 1`; `undefined` when only a full search could tell. */
|
|
19
|
+
matchingPks(execution: QueryExecution, cap: number): Promise<number[] | undefined>;
|
|
18
20
|
conversationEligibility(execution: QueryExecution): Promise<ConversationEligibility>;
|
|
19
21
|
rankQuery(execution: QueryExecution, request: RankingRequest): Promise<RankedStoreFound>;
|
|
20
22
|
rankingEvidence(execution: QueryExecution, request: RankingEvidenceRequest): Promise<RankedEvidence>;
|
package/dist/store/queries.js
CHANGED
|
@@ -14,6 +14,7 @@ export const queriesStoreOver = (context, stemmerFor) => {
|
|
|
14
14
|
matchQuery: async (execution) => lucene.matchQuery(context, mailIndexed(execution)),
|
|
15
15
|
countQuery: async (execution, by) => lucene.countQuery(context, mailIndexed(execution), by),
|
|
16
16
|
countMatches: async (execution, cap) => lucene.countMatches(context, mailIndexed(execution), cap),
|
|
17
|
+
matchingPks: async (execution, cap) => lucene.matchingPks(context, mailIndexed(execution), cap),
|
|
17
18
|
conversationEligibility: async (execution) => conversationEligibility(context, execution),
|
|
18
19
|
rankQuery: async (execution, request) => rankQuery(context, execution, request),
|
|
19
20
|
rankingEvidence: async (execution, request) => rankingEvidence(context, execution, request),
|
|
@@ -7,6 +7,7 @@ import { type EmailLocationInput, type FolderEmail } from "./email-locations.js"
|
|
|
7
7
|
import { type MailRulesStore } from "./mail-rules.js";
|
|
8
8
|
import { type AttachmentInput } from "./meetings.js";
|
|
9
9
|
import type { StoreContext } from "./open.js";
|
|
10
|
+
import { type SyncStateSave } from "./sync.js";
|
|
10
11
|
export type { EmailLocationInput, FolderEmail };
|
|
11
12
|
export interface EmailAddress {
|
|
12
13
|
address: string;
|
|
@@ -203,6 +204,8 @@ export interface SenderScore {
|
|
|
203
204
|
/** Mail in tables of its own: threads, emails, recipients and mailboxes, keyed by account and Message-ID. */
|
|
204
205
|
export interface MailStore {
|
|
205
206
|
saveThread(input: ThreadSave): Promise<ThreadDetails>;
|
|
207
|
+
/** Several threads and sync state in one transaction: a slice of mail commits with its resume point, or not at all. */
|
|
208
|
+
saveThreads(inputs: ThreadSave[], states?: SyncStateSave[]): Promise<ThreadDetails[]>;
|
|
206
209
|
/** Newest activity first. */
|
|
207
210
|
threads(filter?: MailFilter): Promise<EmailThread[]>;
|
|
208
211
|
thread(id: number): Promise<ThreadDetails | null>;
|
|
@@ -280,4 +283,4 @@ export declare const emailHitsByPk: (database: CacheDatabase, pks: number[]) =>
|
|
|
280
283
|
* Indexes the queued emails as the notes are indexed, words and stems of the subject, then the body; then cuts
|
|
281
284
|
* the threads they belong to into pieces for search by meaning.
|
|
282
285
|
*/
|
|
283
|
-
export declare const mailStoreOver: ({ database, orm }: Pick<StoreContext, "database" | "orm">, stemmerFor?: (stemmers: Stemmers) => Stemmer) => MailStore;
|
|
286
|
+
export declare const mailStoreOver: ({ database, orm, now }: Pick<StoreContext, "database" | "orm" | "now">, stemmerFor?: (stemmers: Stemmers) => Stemmer) => MailStore;
|
|
@@ -10,6 +10,7 @@ import { ensurePerson } from "./identities.js";
|
|
|
10
10
|
import { mailRulesOver } from "./mail-rules.js";
|
|
11
11
|
import { attachmentOf, saveAttachment, wordsQuery } from "./meetings.js";
|
|
12
12
|
import { stemmerCache } from "./stems.js";
|
|
13
|
+
import { writeState } from "./sync.js";
|
|
13
14
|
import { write } from "./transaction.js";
|
|
14
15
|
import { dropUnusedVectors } from "./vector-index.js";
|
|
15
16
|
import { dot } from "./vectors.js";
|
|
@@ -415,12 +416,21 @@ export const emailHitsByPk = (database, pks) => {
|
|
|
415
416
|
* Indexes the queued emails as the notes are indexed, words and stems of the subject, then the body; then cuts
|
|
416
417
|
* the threads they belong to into pieces for search by meaning.
|
|
417
418
|
*/
|
|
418
|
-
export const mailStoreOver = ({ database, orm }, stemmerFor = stemmerCache()) => ({
|
|
419
|
+
export const mailStoreOver = ({ database, orm, now }, stemmerFor = stemmerCache()) => ({
|
|
419
420
|
rules: mailRulesOver(database),
|
|
420
421
|
async saveThread(input) {
|
|
421
422
|
const id = write(database, () => saveThread(database, orm, input));
|
|
422
423
|
return threadDetails(database, id);
|
|
423
424
|
},
|
|
425
|
+
async saveThreads(inputs, states = []) {
|
|
426
|
+
const ids = write(database, () => {
|
|
427
|
+
const saved = inputs.map((input) => saveThread(database, orm, input));
|
|
428
|
+
for (const { accountId, name, value } of states)
|
|
429
|
+
writeState({ orm, now }, accountId, name, value);
|
|
430
|
+
return saved;
|
|
431
|
+
});
|
|
432
|
+
return ids.map((id) => threadDetails(database, id));
|
|
433
|
+
},
|
|
424
434
|
async threads(filter = {}) {
|
|
425
435
|
const [limit, offset] = page(filter);
|
|
426
436
|
return database
|
|
@@ -3,7 +3,7 @@ import { FACT_TYPES, MEMORY_SCOPES } from "../../domain/knowledge.js";
|
|
|
3
3
|
import { calendarMoment } from "../../domain/moment.js";
|
|
4
4
|
import { evidenceSourcesOf } from "./evidence-sources.js";
|
|
5
5
|
import { linkKind } from "./link-kinds.js";
|
|
6
|
-
import { authorActor, authorOf, dropLinksOf, erase, evidenceOf, linkEvidence, subjectOf, } from "./memories.js";
|
|
6
|
+
import { authorActor, authorOf, dropLinksOf, erase, evidenceOf, keepHighestId, linkEvidence, nextId, subjectOf, } from "./memories.js";
|
|
7
7
|
import { requiredThing } from "./notes.js";
|
|
8
8
|
import { write } from "./transaction.js";
|
|
9
9
|
const NAMED = "SELECT f.*, b.name AS author_name FROM facts f LEFT JOIN bots b ON f.author_type = 'bot' AND b.id = f.author_id";
|
|
@@ -80,10 +80,10 @@ export const factOperationsOver = (context) => {
|
|
|
80
80
|
const { actor, model } = authorActor(context, input.by);
|
|
81
81
|
const at = now();
|
|
82
82
|
const made = Number(database
|
|
83
|
-
.prepare("INSERT INTO facts (type, statement, status, project_id, subject_type, subject_id, occurred_at, supersedes_id, " +
|
|
83
|
+
.prepare("INSERT INTO facts (id, type, statement, status, project_id, subject_type, subject_id, occurred_at, supersedes_id, " +
|
|
84
84
|
"author_type, author_id, model, scope, source, created_at, updated_at) " +
|
|
85
|
-
"VALUES (?, ?, 'proposed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
|
|
86
|
-
.get(type, statement, project?.id ?? null, subject?.type ?? null, subject?.id ?? null, occurredAt, replaced === undefined ? null : Number(replaced.id), actor.type, actor.id, model, scope, owner ? "owner" : "agent", at, at)?.id);
|
|
85
|
+
"VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
|
|
86
|
+
.get(nextId(database, "facts"), type, statement, project?.id ?? null, subject?.type ?? null, subject?.id ?? null, occurredAt, replaced === undefined ? null : Number(replaced.id), actor.type, actor.id, model, scope, owner ? "owner" : "agent", at, at)?.id);
|
|
87
87
|
linkEvidence(context, { type: "fact", id: made }, input.evidence ?? [], owner ? "owner" : "agent");
|
|
88
88
|
if (input.createdFrom !== undefined) {
|
|
89
89
|
const from = requiredThing(database, input.createdFrom, "the source");
|
|
@@ -135,6 +135,9 @@ export const factOperationsOver = (context) => {
|
|
|
135
135
|
const id = Number(row.id);
|
|
136
136
|
const twin = row.source === "memory" && row.metadata != null ? migratedFrom(String(row.metadata)) : undefined;
|
|
137
137
|
const links = erase(database, () => {
|
|
138
|
+
keepHighestId(database, "facts", now());
|
|
139
|
+
if (twin !== undefined)
|
|
140
|
+
keepHighestId(database, "memories", now());
|
|
138
141
|
database.prepare("UPDATE facts SET supersedes_id = NULL WHERE supersedes_id = ?").run(id);
|
|
139
142
|
const count = dropLinksOf(database, { type: "fact", id });
|
|
140
143
|
database.prepare("DELETE FROM links WHERE from_type = 'fact' AND from_id = ?").run(id);
|
|
@@ -17,14 +17,14 @@ export const laterSharingRare = (context, wanted, { rareAt, limit }) => {
|
|
|
17
17
|
if (!wanted.length || !stemmers || !stemsState(database)?.ready)
|
|
18
18
|
return wanted.map(() => []);
|
|
19
19
|
const stemmer = stemmerFor(stemmers);
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
const count = database.prepare("SELECT doc FROM
|
|
20
|
+
// Counted only up to one past `rareAt`, so a common word costs no more than a rare one. Not an fts5vocab table:
|
|
21
|
+
// creating one is a write, which a read-only connection refuses.
|
|
22
|
+
const count = database.prepare("SELECT count(*) AS doc FROM (SELECT rowid FROM message_stems WHERE message_stems MATCH ? LIMIT ?)");
|
|
23
23
|
const counts = new Map();
|
|
24
24
|
const countOf = (stem) => {
|
|
25
25
|
let found = counts.get(stem);
|
|
26
26
|
if (found === undefined) {
|
|
27
|
-
found = Number(count.get(stem)?.doc ?? 0);
|
|
27
|
+
found = Number(count.get(`stems : "${stem.replaceAll('"', '""')}"`, rareAt + 1)?.doc ?? 0);
|
|
28
28
|
counts.set(stem, found);
|
|
29
29
|
}
|
|
30
30
|
return found;
|
|
@@ -18,11 +18,13 @@ export declare const matchQuery: (context: StoreContext, execution: QueryExecuti
|
|
|
18
18
|
export declare const countQuery: (context: StoreContext, execution: QueryExecution, by: QueryGrouping) => Promise<QueryGroup[]>;
|
|
19
19
|
/** All matching message keys, without materializing message bodies or imposing a hit-page limit. */
|
|
20
20
|
export declare const queryMessagePks: (context: StoreContext, execution: QueryExecution) => Promise<number[]>;
|
|
21
|
+
/** How many messages match, counting no further than `cap + 1`; `undefined` as `matchingPks` says. */
|
|
22
|
+
export declare const countMatches: (context: StoreContext, execution: QueryExecution, cap: number) => number | undefined;
|
|
21
23
|
/**
|
|
22
|
-
*
|
|
23
|
-
* checked row by row (a preset, a pattern), where
|
|
24
|
+
* The keys of the messages that match, no more than `cap + 1`: one SQL query, unranked. `undefined` when a leaf is
|
|
25
|
+
* checked row by row (a preset, a pattern), where it would cost as much as the search itself.
|
|
24
26
|
*/
|
|
25
|
-
export declare const
|
|
27
|
+
export declare const matchingPks: (context: StoreContext, execution: QueryExecution, cap: number) => number[] | undefined;
|
|
26
28
|
interface QuerySelection {
|
|
27
29
|
sql: string;
|
|
28
30
|
params: SqlValue[];
|
|
@@ -48,22 +48,24 @@ export const matchQuery = (context, execution) => runQuery(context, execution);
|
|
|
48
48
|
export const countQuery = (context, execution, by) => runQuery(context, execution, by);
|
|
49
49
|
/** All matching message keys, without materializing message bodies or imposing a hit-page limit. */
|
|
50
50
|
export const queryMessagePks = (context, execution) => runQuery(context, execution, "pks");
|
|
51
|
+
/** How many messages match, counting no further than `cap + 1`; `undefined` as `matchingPks` says. */
|
|
52
|
+
export const countMatches = (context, execution, cap) => matchingPks(context, execution, cap)?.length;
|
|
51
53
|
/**
|
|
52
|
-
*
|
|
53
|
-
* checked row by row (a preset, a pattern), where
|
|
54
|
+
* The keys of the messages that match, no more than `cap + 1`: one SQL query, unranked. `undefined` when a leaf is
|
|
55
|
+
* checked row by row (a preset, a pattern), where it would cost as much as the search itself.
|
|
54
56
|
*/
|
|
55
|
-
export const
|
|
57
|
+
export const matchingPks = (context, execution, cap) => {
|
|
56
58
|
const { database, check, where, from, withRanking, ranked, leaves } = compileQuery(context, {
|
|
57
59
|
...execution,
|
|
58
60
|
newest: true,
|
|
59
61
|
});
|
|
60
62
|
if (leaves.some(({ test }) => test))
|
|
61
63
|
return undefined;
|
|
62
|
-
const
|
|
63
|
-
.prepare(
|
|
64
|
-
.
|
|
64
|
+
const rows = database
|
|
65
|
+
.prepare(`${withRanking}SELECT m.id ${from} WHERE ${where.sql} LIMIT ?`)
|
|
66
|
+
.all(...ranked.params, ...where.params, cap + 1);
|
|
65
67
|
check();
|
|
66
|
-
return
|
|
68
|
+
return rows.map(({ id }) => Number(id));
|
|
67
69
|
};
|
|
68
70
|
const compileQuery = (context, execution, boundedAttachments = false) => {
|
|
69
71
|
const { database } = context;
|
|
@@ -100,6 +100,14 @@ export declare const evidenceOf: (context: StoreContext, from: {
|
|
|
100
100
|
}) => string[];
|
|
101
101
|
/** What a memory's or fact's subject is called, for a reader who should not have to look the reference up. */
|
|
102
102
|
export declare const subjectOf: (database: StoreContext["database"], row: Row) => Pick<Memory, "subject" | "subjectName" | "subjectIsOwner">;
|
|
103
|
+
type Numbered = "memories" | "facts";
|
|
104
|
+
/**
|
|
105
|
+
* The id the next row takes: above every row there is and every one forgotten. SQLite hands out the highest id + 1,
|
|
106
|
+
* so without this a forgotten record's number would name the next one, and a stale `memory:N` act on it.
|
|
107
|
+
*/
|
|
108
|
+
export declare const nextId: (database: StoreContext["database"], table: Numbered) => number;
|
|
109
|
+
/** Before a delete: remembers the highest id handed out, which the delete may be about to free. */
|
|
110
|
+
export declare const keepHighestId: (database: StoreContext["database"], table: Numbered, at: number) => void;
|
|
103
111
|
/** Counts every link from or to a row and drops the ones its delete trigger does not: those pointing at it. */
|
|
104
112
|
export declare const dropLinksOf: (database: StoreContext["database"], thing: {
|
|
105
113
|
type: string;
|
|
@@ -64,6 +64,23 @@ export const subjectOf = (database, row) => {
|
|
|
64
64
|
subjectIsOwner: Number(named?.owner) === 1,
|
|
65
65
|
};
|
|
66
66
|
};
|
|
67
|
+
const highestKey = (table) => `ids.${table}.highest`;
|
|
68
|
+
/**
|
|
69
|
+
* The id the next row takes: above every row there is and every one forgotten. SQLite hands out the highest id + 1,
|
|
70
|
+
* so without this a forgotten record's number would name the next one, and a stale `memory:N` act on it.
|
|
71
|
+
*/
|
|
72
|
+
export const nextId = (database, table) => {
|
|
73
|
+
const kept = database.prepare("SELECT value FROM store_settings WHERE key = ?").get(highestKey(table));
|
|
74
|
+
const highest = database.prepare(`SELECT max(id) AS id FROM ${table}`).get();
|
|
75
|
+
return Math.max(Number(kept?.value ?? 0), Number(highest?.id ?? 0)) + 1;
|
|
76
|
+
};
|
|
77
|
+
/** Before a delete: remembers the highest id handed out, which the delete may be about to free. */
|
|
78
|
+
export const keepHighestId = (database, table, at) => {
|
|
79
|
+
const highest = nextId(database, table) - 1;
|
|
80
|
+
database
|
|
81
|
+
.prepare("INSERT OR REPLACE INTO store_settings (key, value, at) VALUES (?, ?, ?)")
|
|
82
|
+
.run(highestKey(table), String(highest), at);
|
|
83
|
+
};
|
|
67
84
|
/** Counts every link from or to a row and drops the ones its delete trigger does not: those pointing at it. */
|
|
68
85
|
export const dropLinksOf = (database, thing) => {
|
|
69
86
|
const count = Number(database
|
|
@@ -153,9 +170,9 @@ export const memoriesStoreOver = (context) => {
|
|
|
153
170
|
const replaced = input.supersedes === undefined ? undefined : Number(rowOf(input.supersedes).id);
|
|
154
171
|
const at = now();
|
|
155
172
|
const made = Number(database
|
|
156
|
-
.prepare("INSERT INTO memories (kind, body, subject_type, subject_id, author_type, author_id, model, confidence, status, " +
|
|
157
|
-
"last_verified_at, supersedes_id, scope, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
|
|
158
|
-
.get(kind, body, subject?.type ?? null, subject?.id ?? null, actor.type, actor.id, model, input.confidence ?? null, status, status === "confirmed" ? at : null, replaced ?? null, scope, at, at)?.id);
|
|
173
|
+
.prepare("INSERT INTO memories (id, kind, body, subject_type, subject_id, author_type, author_id, model, confidence, status, " +
|
|
174
|
+
"last_verified_at, supersedes_id, scope, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
|
|
175
|
+
.get(nextId(database, "memories"), kind, body, subject?.type ?? null, subject?.id ?? null, actor.type, actor.id, model, input.confidence ?? null, status, status === "confirmed" ? at : null, replaced ?? null, scope, at, at)?.id);
|
|
159
176
|
linkEvidence(context, { type: "memory", id: made }, input.evidence, input.author === "owner" ? "owner" : "agent");
|
|
160
177
|
if (replaced !== undefined)
|
|
161
178
|
database.prepare("UPDATE memories SET status = 'superseded', updated_at = ? WHERE id = ?").run(at, replaced);
|
|
@@ -178,6 +195,7 @@ export const memoriesStoreOver = (context) => {
|
|
|
178
195
|
remove: async (reference) => {
|
|
179
196
|
const id = Number(rowOf(reference).id);
|
|
180
197
|
const links = erase(database, () => {
|
|
198
|
+
keepHighestId(database, "memories", now());
|
|
181
199
|
database.prepare("UPDATE memories SET supersedes_id = NULL WHERE supersedes_id = ?").run(id);
|
|
182
200
|
const count = dropLinksOf(database, { type: "memory", id });
|
|
183
201
|
database.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
@@ -39,6 +39,12 @@ type MessageFilter = {
|
|
|
39
39
|
since?: number;
|
|
40
40
|
before?: number;
|
|
41
41
|
};
|
|
42
|
+
/** Each of the account's messages among `pks` that has a vector of `model`, scored against `query`, best first. */
|
|
43
|
+
export declare const scoreMessages: ({ orm }: StoreContext, accountPk: number, { model, query, pks }: {
|
|
44
|
+
model: string;
|
|
45
|
+
query: Float32Array;
|
|
46
|
+
pks: number[];
|
|
47
|
+
}) => NearestMessage[];
|
|
42
48
|
/** The account's live messages nearest to `query` by their vector of `model`, best first. */
|
|
43
49
|
export declare const nearestMessages: (context: StoreContext, accountPk: number, { limit, query, ...filter }: MessageFilter & {
|
|
44
50
|
limit: number;
|
|
@@ -59,6 +59,16 @@ const messageRows = ({ orm }, accountPk, { chatKey, model, since, before }, rows
|
|
|
59
59
|
${"ids" in rows
|
|
60
60
|
? sql `AND k.id IN (SELECT value FROM json_each(${JSON.stringify(rows.ids)})) ORDER BY k.id`
|
|
61
61
|
: sql `AND k.id > ${rows.after} ORDER BY k.id LIMIT ${SCAN_PAGE}`}`);
|
|
62
|
+
/** Each of the account's messages among `pks` that has a vector of `model`, scored against `query`, best first. */
|
|
63
|
+
export const scoreMessages = ({ orm }, accountPk, { model, query, pks }) => orm
|
|
64
|
+
.all(sql `SELECT ch.external_id AS chatId, m.external_id AS messageId, v.vector
|
|
65
|
+
FROM chunks k CROSS JOIN messages m ON m.id = k.chunkable_id AND m.deleted_at IS NULL
|
|
66
|
+
JOIN chats ch ON ch.id = m.chat_id AND ch.searchable = 1
|
|
67
|
+
JOIN embeddings v ON v.model = ${model} AND v.content_hash = k.content_hash
|
|
68
|
+
WHERE k.chunkable_type = 'message' AND k.position = 0 AND k.account_id = ${accountPk}
|
|
69
|
+
AND k.chunkable_id IN (SELECT value FROM json_each(${JSON.stringify(pks)}))`)
|
|
70
|
+
.map(({ chatId, messageId, vector }) => ({ chatId, messageId, score: dot(query, vector) }))
|
|
71
|
+
.sort((a, b) => b.score - a.score);
|
|
62
72
|
/** The account's live messages nearest to `query` by their vector of `model`, best first. */
|
|
63
73
|
export const nearestMessages = (context, accountPk, { limit, query, ...filter }) => {
|
|
64
74
|
const score = (row) => ({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { fold } from "../../normalize.js";
|
|
3
|
+
import { nextId } from "../memories.js";
|
|
3
4
|
import { thingOf } from "../things.js";
|
|
4
5
|
export const restoreAnnotationRecords = (database, projectId, snapshot, bindings) => {
|
|
5
6
|
const mappings = { ...bindings };
|
|
@@ -24,8 +25,8 @@ export const restoreAnnotationRecords = (database, projectId, snapshot, bindings
|
|
|
24
25
|
if (!author)
|
|
25
26
|
throw new CliError("validation_error", "project annotation author is required");
|
|
26
27
|
const row = database
|
|
27
|
-
.prepare("INSERT INTO facts(type,statement,status,project_id,subject_type,subject_id,occurred_at,supersedes_id,author_type,author_id,model,confirmed_by_type,confirmed_by_id,scope,source,created_at,updated_at) VALUES(
|
|
28
|
-
.get(fact.type, fact.statement, fact.status, projectId, fact.occurred_at, author.type, author.id, fact.model, confirmed?.type ?? null, confirmed?.id ?? null, fact.scope, fact.source, fact.created_at, fact.updated_at);
|
|
28
|
+
.prepare("INSERT INTO facts(id,type,statement,status,project_id,subject_type,subject_id,occurred_at,supersedes_id,author_type,author_id,model,confirmed_by_type,confirmed_by_id,scope,source,created_at,updated_at) VALUES(?,?,?,?,?,NULL,NULL,?,NULL,?,?,?,?,?,?,?,?,?) RETURNING id")
|
|
29
|
+
.get(nextId(database, "facts"), fact.type, fact.statement, fact.status, projectId, fact.occurred_at, author.type, author.id, fact.model, confirmed?.type ?? null, confirmed?.id ?? null, fact.scope, fact.source, fact.created_at, fact.updated_at);
|
|
29
30
|
if (!row)
|
|
30
31
|
throw new CliError("configuration_error", "project annotation fact insertion failed");
|
|
31
32
|
mappings[fact.ref] = `fact:${row.id}`;
|
|
@@ -3,7 +3,13 @@ export declare const syncStateOf: ({ orm }: StoreContext, accountKey: number, na
|
|
|
3
3
|
value: string;
|
|
4
4
|
at: string;
|
|
5
5
|
} | undefined;
|
|
6
|
-
|
|
6
|
+
/** A sync state value saved with `MailStore.saveThreads`, by the account's row id. */
|
|
7
|
+
export interface SyncStateSave {
|
|
8
|
+
accountId: number;
|
|
9
|
+
name: string;
|
|
10
|
+
value: string;
|
|
11
|
+
}
|
|
12
|
+
export declare const writeState: ({ orm, now }: Pick<StoreContext, "orm" | "now">, accountKey: number, name: string, value: string) => void;
|
|
7
13
|
export declare const clearState: ({ orm }: StoreContext, accountKey: number, name: string) => void;
|
|
8
14
|
/** Taken when free, expired, or already this holder's; another holder's running lease stays. */
|
|
9
15
|
export declare const claim: ({ orm, now }: StoreContext, chatKey: number, anchor: string, holder: string, forMs: number) => boolean;
|
|
@@ -11,7 +11,8 @@ export declare const write: <T>(database: CacheDatabase, body: () => T, { before
|
|
|
11
11
|
/**
|
|
12
12
|
* A write to tables that queue no derived work, in a loop that budgets its lock time or reports what it did: a fill,
|
|
13
13
|
* a backfill, a rebuild. It drains nothing, so it neither does nor hides another write's work; the queues wait for
|
|
14
|
-
* the next `write`.
|
|
14
|
+
* the next `write`. The outermost pauses 2 ms after it commits: a loop that begins again at once leaves another
|
|
15
|
+
* process's `write` a gap of microseconds, and it misses that gap until its 5 s wait runs out.
|
|
15
16
|
*/
|
|
16
17
|
export declare const writeBatch: <T>(database: CacheDatabase, body: () => T) => T;
|
|
17
18
|
/** Several queries that must see one state of the file. Never drains: that is the outer write's work, or nobody's. */
|
|
@@ -11,7 +11,8 @@ const isBusy = (error) => {
|
|
|
11
11
|
/**
|
|
12
12
|
* Takes the write lock, waiting as long as the connection's `busy_timeout`. SQLite's own wait sleeps 1 ms, then up to
|
|
13
13
|
* 100 ms between tries, so a process that commits and begins again at once is never seen free and the waiter fails
|
|
14
|
-
* with "database is locked" after the full timeout. Trying every millisecond sees
|
|
14
|
+
* with "database is locked" after the full timeout. Trying every millisecond sees a gap of a fraction of a
|
|
15
|
+
* millisecond between two writes; a writer that leaves none still starves it, hence `writeBatch`'s pause.
|
|
15
16
|
*/
|
|
16
17
|
const beginImmediate = (database) => {
|
|
17
18
|
const timeout = Number(database.prepare("PRAGMA busy_timeout").get()?.timeout ?? 0);
|
|
@@ -53,9 +54,17 @@ export const write = (database, body, { beforeCommit } = {}) => transact(databas
|
|
|
53
54
|
/**
|
|
54
55
|
* A write to tables that queue no derived work, in a loop that budgets its lock time or reports what it did: a fill,
|
|
55
56
|
* a backfill, a rebuild. It drains nothing, so it neither does nor hides another write's work; the queues wait for
|
|
56
|
-
* the next `write`.
|
|
57
|
+
* the next `write`. The outermost pauses 2 ms after it commits: a loop that begins again at once leaves another
|
|
58
|
+
* process's `write` a gap of microseconds, and it misses that gap until its 5 s wait runs out.
|
|
57
59
|
*/
|
|
58
|
-
export const writeBatch = (database, body) =>
|
|
60
|
+
export const writeBatch = (database, body) => {
|
|
61
|
+
const outer = connectionOf(database).depth === 0;
|
|
62
|
+
const result = transact(database, body, false);
|
|
63
|
+
if (outer)
|
|
64
|
+
Atomics.wait(pause, 0, 0, BATCH_PAUSE_MS);
|
|
65
|
+
return result;
|
|
66
|
+
};
|
|
67
|
+
const BATCH_PAUSE_MS = 2;
|
|
59
68
|
const transact = (database, body, drain, beforeCommit) => {
|
|
60
69
|
const connection = connectionOf(database);
|
|
61
70
|
if (connection.reading)
|
package/dist/store/vectors.d.ts
CHANGED
|
@@ -87,6 +87,19 @@ export interface AccountVectors {
|
|
|
87
87
|
messageId: Id;
|
|
88
88
|
score: number;
|
|
89
89
|
}[]>;
|
|
90
|
+
/**
|
|
91
|
+
* The account's messages among `pks` (store keys, as a query's matches give them) scored against `query` by their
|
|
92
|
+
* own vector of `model`, best first: a filter's own messages ranked, not the nearest overall.
|
|
93
|
+
*/
|
|
94
|
+
scoreMessages(key: AccountKey, options: {
|
|
95
|
+
model: string;
|
|
96
|
+
query: Float32Array;
|
|
97
|
+
pks: number[];
|
|
98
|
+
}): Promise<{
|
|
99
|
+
chatId: Id;
|
|
100
|
+
messageId: Id;
|
|
101
|
+
score: number;
|
|
102
|
+
}[]>;
|
|
90
103
|
/** The current build's distinct chunks, and how many have a vector of `model`. */
|
|
91
104
|
vectorStatus(key: AccountKey, chatId: Id, model: string): Promise<{
|
|
92
105
|
chunks: number;
|
package/dist/store/vectors.js
CHANGED
|
@@ -147,6 +147,10 @@ export const accountVectorsOver = (context, mail) => {
|
|
|
147
147
|
query,
|
|
148
148
|
});
|
|
149
149
|
},
|
|
150
|
+
scoreMessages: async (key, options) => {
|
|
151
|
+
const accountPk = findAccountPk(key);
|
|
152
|
+
return accountPk === undefined ? [] : messageChunks.scoreMessages(context, accountPk, options);
|
|
153
|
+
},
|
|
150
154
|
vectorStatus: async (key, chatId, model) => {
|
|
151
155
|
const chatKey = chatKeyOf(key, chatId);
|
|
152
156
|
return chatKey === undefined ? { chunks: 0, embedded: 0 } : vectors.vectorStatus(context, chatKey, model);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.506.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",
|