@wirecat/cli-core 0.505.0 → 0.507.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/cli/messenger/backfill-command.js +26 -3
  3. package/dist/mail/sync.js +56 -14
  4. package/dist/oauth/keeper.js +2 -2
  5. package/dist/oauth/storage.d.ts +3 -2
  6. package/dist/oauth/storage.js +3 -3
  7. package/dist/search/lucene/meaning-parts.d.ts +14 -0
  8. package/dist/search/lucene/meaning-parts.js +38 -0
  9. package/dist/services/archive.d.ts +6 -0
  10. package/dist/services/archive.js +4 -0
  11. package/dist/services/messages-discovery.d.ts +17 -2
  12. package/dist/services/messages-discovery.js +63 -17
  13. package/dist/services/messages-meaning.js +9 -6
  14. package/dist/services/messages-search.d.ts +2 -1
  15. package/dist/services/notes-search.d.ts +0 -7
  16. package/dist/services/notes-search.js +5 -39
  17. package/dist/services/search-all.js +1 -1
  18. package/dist/store/index.d.ts +1 -0
  19. package/dist/store/queries.d.ts +2 -0
  20. package/dist/store/queries.js +1 -0
  21. package/dist/store/sqlite/emails.d.ts +4 -1
  22. package/dist/store/sqlite/emails.js +11 -1
  23. package/dist/store/sqlite/facts.js +7 -4
  24. package/dist/store/sqlite/follow-ups.js +4 -4
  25. package/dist/store/sqlite/lucene.d.ts +5 -3
  26. package/dist/store/sqlite/lucene.js +9 -7
  27. package/dist/store/sqlite/memories.d.ts +8 -0
  28. package/dist/store/sqlite/memories.js +21 -3
  29. package/dist/store/sqlite/message-chunks.d.ts +6 -0
  30. package/dist/store/sqlite/message-chunks.js +10 -0
  31. package/dist/store/sqlite/project-transfer/records.js +3 -2
  32. package/dist/store/sqlite/sync.d.ts +7 -1
  33. package/dist/store/sqlite/transaction.d.ts +2 -1
  34. package/dist/store/sqlite/transaction.js +12 -3
  35. package/dist/store/vectors.d.ts +13 -0
  36. package/dist/store/vectors.js +4 -0
  37. package/dist/terminal/sign-in-steps.js +2 -4
  38. package/dist/terminal/step-lines.d.ts +7 -0
  39. package/dist/terminal/step-lines.js +8 -0
  40. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -7,6 +7,73 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.507.0 — 31.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`messages sync --json` prints its progress on stderr**, for a window to draw a bar instead of an endless one
15
+ (WireCat Desktop). One JSON line each, counts only — never a chat's id or title:
16
+ `{"event":"progress","step":"sync","chats":3,"chats_done":1,"fetched":250}`. With `--all`, one once the chat list
17
+ is read (`chats_done` 0), then one after each chat, those passed as already current included; for one chat, one per
18
+ page with `chats` and `chats_done` `null`. At most about two a second; the first and last of `--all` always come.
19
+ Only in machine output (`--json`, `--jsonl`, or stdout not a terminal) and not in a `--background` job's log;
20
+ stdout still carries the one answer. Archive services take an optional `onChat` for the same counts.
21
+
22
+ ### Changed — may break callers
23
+
24
+ - **Needs `@wirecat/cli-base` 0.68.0** (peer range `>=0.68.0 <0.69.0`). cli-base's `Credentials` in `auto` now reads
25
+ a sign-in the private file holds before the keyring and keeps its refreshes there (WC-SECR-4).
26
+
27
+ ### Fixed
28
+
29
+ - **A new OAuth sign-in moves a file sign-in back to the keyring.** `TokenStorage.write` takes `{ signIn: true }`, and
30
+ the keeper's `signIn` and `save` pass it: the keyring is tried again and, once it takes the sign-in, the file copy
31
+ is removed. A refresh stays where the sign-in is, so a locked keyring is not tried for it on every refresh.
32
+
33
+ ## 0.506.0 — 31.10.2026
34
+
35
+ ### Added
36
+
37
+ - **`QueriesStore.matchingPks`, `AccountVectors.scoreMessages` and `MeaningLane`'s fourth argument `within`**, which
38
+ lists a filter's messages once meaning can run: together they rank a filter's own messages. `fieldsOfQuery` and
39
+ `meaningWords` live in `src/search/lucene/meaning-parts.ts`, shared by notes and messages.
40
+
41
+ - **`MailStore.saveThreads(inputs, states?)`.** Saves several threads and sync state (`{ accountId, name, value }`,
42
+ as `setSyncState` saves it) in one write transaction: all of it commits or none of it does. Like
43
+ `EventStore.saveSlots`, it lets a sync commit a slice of work with its resume point.
44
+
45
+ ### Changed — may break callers
46
+
47
+ - **`search messages --discover` searches by meaning when the query uses `-`, `NOT`, `OR`, groups, phrases or
48
+ `exact:`.** Such a query used to run strict search alone, with no meaning hits. Now its words are still matched by
49
+ the whole query, as strict search does (`discovery.method: "lexical-strict"`), and the messages nearest in meaning
50
+ to its wanted words join them; every hit found by meaning, and every reply and follow-up discovery adds, matches
51
+ every field of the query and every excluded word, as notes do since 0.496.0. A field joined to the words by `OR`
52
+ narrows nothing. A query with no word to embed (`date:today`, `Helix*`) stays strict. On the owner's store, 6
53
+ such queries went from 0 meaning hits to 10–17 of 20, with no hit breaking the query.
54
+ ### Fixed
55
+
56
+ - **Search by meaning inside a filter ranks the filter's own messages.** With fields that hold at most 100,000
57
+ messages (`RANK_WITHIN_MESSAGES`), `--discover` scores every one of them that has a vector, instead of taking the
58
+ 100 nearest messages overall and dropping those outside the filter. On the owner's store, `chat:<id>` with a chat of
59
+ 1,000 messages went from 3 meaning candidates to 100 and from 9 hits to 20. A larger filter, or one the store
60
+ cannot list in one query (a preset, a pattern), keeps the old way.
61
+ - **`--discover` works on a read-only store.** Its follow-up step created a temporary table to count rare words,
62
+ which a read-only connection refuses; it now counts them with a bounded query.
63
+ - **`mail sync` no longer holds the store's one writer lock for each email in turn.** It saves fetched mail in slices of
64
+ 50 emails, each with the resume point in the same transaction, and pauses 100 ms between slices, so `memo sync` or
65
+ another process next to it can take the lock instead of failing with "database is locked". A run that fails resumes
66
+ after the last committed slice.
67
+ - **A long fill or rebuild in one process no longer makes another process's write fail with "database is locked".**
68
+ `writeBatch` pauses 2 ms after each batch it commits. Without the pause, the next batch took the lock again within
69
+ microseconds, and a write waiting in another process (a command run during `store rebuild` or a migration's fill)
70
+ could miss every gap until its 5 s wait ran out.
71
+ - **A forgotten memory's or fact's number is never given to a new one.** SQLite gave the next row the highest id + 1,
72
+ so after `memories.remove` or `facts.remove` of the newest record, the next one took its number and a stale
73
+ `memory:N` or `fact:N` named it. A remove now keeps the highest id handed out in `store_settings`
74
+ (`ids.memories.highest`, `ids.facts.highest`), and new memories and facts, restored project facts included, take
75
+ the next id above it. No schema change.
76
+
10
77
  ## 0.505.0 — 31.10.2026
11
78
 
12
79
  ### Added
@@ -12,6 +12,7 @@ import { JOBS_QUERY, jobItems } from "../../services/job-query.js";
12
12
  import { dateWindow } from "../../services/moment.js";
13
13
  import { validateCatchUpBounds } from "../../services/search-catchup.js";
14
14
  import { applyTaskRulesSinceLastPass } from "../../services/task-rules.js";
15
+ import { stepLines } from "../../terminal/step-lines.js";
15
16
  import { environmentOf } from "../context.js";
16
17
  import { destroysNothing, FETCH, LOCAL_READ, LOCAL_WRITE } from "../effects.js";
17
18
  import { itemOf, listOf, renderAnswer, snakeKeys } from "../envelope.js";
@@ -41,7 +42,8 @@ export const syncCommand = (messenger) => {
41
42
  cooperative: true,
42
43
  })
43
44
  .summary("fetch and save a chat's message history")
44
- .description("bring a chat's history in the local store up to date, newest first; run it again to continue; --all syncs every chat")
45
+ .description("bring a chat's history in the local store up to date, newest first; run it again to continue; --all syncs every chat; " +
46
+ 'with --json, stderr carries progress lines: {"event":"progress","step":"sync","chats","chats_done","fetched"}, counts only')
45
47
  .argument("[chat]", messenger.chatArgument)
46
48
  .option("--all", `every chat, most recently active first — what search needs; the last ${ALL_SINCE} unless --since-time or --last`)
47
49
  .option("--limit <n>", `at most this many messages in this run, per chat with --all; ${fetching.maxPages * fetching.page} if not given`, wholeNumber)
@@ -139,6 +141,8 @@ export const syncCommand = (messenger) => {
139
141
  const jobs = jobsDir(messenger.app, context.env);
140
142
  const jobId = context.env[envName(messenger.app, "BACKFILL_JOB")];
141
143
  const stop = stopOnSignal(this);
144
+ // A background job keeps its log as it was; its progress is the job file.
145
+ const progress = syncProgress(stepLines(context.streams.diagnostic, !jobId && context.format !== "pretty"));
142
146
  try {
143
147
  const options = {
144
148
  limit,
@@ -149,10 +153,13 @@ export const syncCommand = (messenger) => {
149
153
  ...(last === undefined ? {} : { last }),
150
154
  note: context.renderer.note,
151
155
  stop: stop.signal,
152
- onPage: (progress) => {
156
+ onPage: (page) => {
153
157
  if (jobId)
154
- updateJob(jobs, jobId, { progress: { ...progress, at: new Date().toISOString() } });
158
+ updateJob(jobs, jobId, { progress: { ...page, at: new Date().toISOString() } });
159
+ if (chat !== undefined)
160
+ progress({ chats: null, chats_done: null, fetched: page.fetched });
155
161
  },
162
+ onChat: ({ chats, done, fetched }) => progress({ chats, chats_done: done, fetched }, done === 0 || done === chats),
156
163
  };
157
164
  const result = await context.withServices((services) => chat === undefined ? services.archive.fetchAll(options) : services.archive.fetch(chat, options));
158
165
  if (jobId)
@@ -197,6 +204,22 @@ export const syncCommand = (messenger) => {
197
204
  }
198
205
  });
199
206
  };
207
+ /** At most this often a progress line, so a run of fast pages or of chats already current does not flood the window. */
208
+ const PROGRESS_EVERY_MS = 500;
209
+ /**
210
+ * `messages sync`'s progress for a window to draw a bar by: counts only, because the lines reach a desktop app and
211
+ * its logs — never a chat's id or title. `always` keeps the first and the last line of `--all` through the throttle.
212
+ */
213
+ const syncProgress = (emit) => {
214
+ let last = Number.NEGATIVE_INFINITY;
215
+ return (counts, always = false) => {
216
+ const now = Date.now();
217
+ if (!always && now - last < PROGRESS_EVERY_MS)
218
+ return;
219
+ last = now;
220
+ emit({ event: "progress", step: "sync", ...counts });
221
+ };
222
+ };
200
223
  /**
201
224
  * What the sync stored, through the task rules (owner, 2026-10-14). A failure is noted, never the sync's failure:
202
225
  * the messages are already kept, and the next sync's pass starts from the same stop point.
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 saveProgress = async () => {
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
- await store.sync.setSyncState(key, resumeKey(folder), JSON.stringify(point));
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 (!force && now() - savedAt < SAVE_EVERY_MS)
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
- await store.mail.saveThread({
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
- await store.mail.saveThread({
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
  }
@@ -137,13 +137,13 @@ export const tokenKeeper = (options) => {
137
137
  const extra = (await accept?.(received)) ?? undefined;
138
138
  await locked(async () => {
139
139
  forget();
140
- storage.write({ tokens: received, client, ...(extra ? { extra } : {}) });
140
+ storage.write({ tokens: received, client, ...(extra ? { extra } : {}) }, { signIn: true });
141
141
  });
142
142
  return { scopes: received.scopes };
143
143
  },
144
144
  save: (saved, extra) => locked(async () => {
145
145
  forget();
146
- storage.write({ tokens: saved, client: clientOf(storage.read()), ...(extra ? { extra } : {}) });
146
+ storage.write({ tokens: saved, client: clientOf(storage.read()), ...(extra ? { extra } : {}) }, { signIn: true });
147
147
  }),
148
148
  signOut: ({ revoke = false } = {}) => locked(async () => {
149
149
  forget();
@@ -1,4 +1,4 @@
1
- import { type CredentialSource, type CredentialsOptions } from "@wirecat/cli-base";
1
+ import { type CredentialSource, type CredentialsOptions, type WriteOptions } from "@wirecat/cli-base";
2
2
  import type { OAuthClient, TokenSet } from "./provider.js";
3
3
  export interface StoredTokens {
4
4
  tokens: TokenSet;
@@ -29,7 +29,8 @@ export interface TokenStorage {
29
29
  /** Throws when the entry cannot be saved, so a sign-in fails before the browser, not after consent. */
30
30
  probe(): void;
31
31
  read(): StoredTokens | undefined;
32
- write(input: Omit<EncodeInput, "previous">): void;
32
+ /** `signIn` moves an entry the file holds back to the keyring when it takes it (WC-SECR-4); a refresh leaves it. */
33
+ write(input: Omit<EncodeInput, "previous">, options?: WriteOptions): void;
33
34
  remove(): CredentialSource[];
34
35
  }
35
36
  export declare const oauthAccount: (providerId: string, profile: string) => string;
@@ -162,9 +162,9 @@ export const credentialStorage = (options) => {
162
162
  throw keyringFailure(error);
163
163
  }
164
164
  };
165
- const save = (value) => {
165
+ const save = (value, writeOptions) => {
166
166
  try {
167
- credentials.write(account, value);
167
+ credentials.write(account, value, writeOptions);
168
168
  }
169
169
  catch (error) {
170
170
  throw keyringFailure(error, value);
@@ -195,7 +195,7 @@ export const credentialStorage = (options) => {
195
195
  const value = current();
196
196
  return value === undefined ? undefined : codec.decode(value);
197
197
  },
198
- write: (input) => save(codec.encode({ ...input, previous: raw(account) })),
198
+ write: (input, writeOptions) => save(codec.encode({ ...input, previous: raw(account) }), writeOptions),
199
199
  remove: () => {
200
200
  try {
201
201
  return credentials.remove(account);
@@ -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
+ };
@@ -39,6 +39,12 @@ export interface FetchOptions {
39
39
  chatId: string;
40
40
  oldest: number;
41
41
  }) => void;
42
+ /** `fetchAll` only: once the chat list is read, then after each chat, current or not; counts across every chat. */
43
+ onChat?: (progress: {
44
+ chats: number;
45
+ done: number;
46
+ fetched: number;
47
+ }) => void;
42
48
  }
43
49
  /** A type, not an interface: a job keeps it as a plain record. */
44
50
  export type Fetched = {
@@ -148,6 +148,8 @@ export const archiveService = (deps) => {
148
148
  ...options,
149
149
  catchUp: catchingUp ? (options.catchUp ?? {}) : false,
150
150
  });
151
+ const finished = () => options.onChat?.({ chats: chats.length, done: answer.items.length, fetched: answer.fetched });
152
+ finished();
151
153
  for (const chat of chats) {
152
154
  if (options.stop.aborted || batch.stopped) {
153
155
  answer.stopped = true;
@@ -155,6 +157,7 @@ export const archiveService = (deps) => {
155
157
  }
156
158
  if (await upToDate(chat)) {
157
159
  answer.items.push({ chat: chat.id, title: chat.title, fetched: 0, complete: true, current: true });
160
+ finished();
158
161
  continue;
159
162
  }
160
163
  try {
@@ -183,6 +186,7 @@ export const archiveService = (deps) => {
183
186
  const issue = batch.fail(chat.id, "history", error);
184
187
  answer.items.push({ chat: chat.id, title: chat.title, fetched: 0, complete: false, error: issue.code, issue });
185
188
  }
189
+ finished();
186
190
  }
187
191
  if (batch.failed > 0)
188
192
  answer.batch = batch.result();
@@ -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">) => Promise<{
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: string) => Promise<(FoundMessage & {
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 { exhausted, QUERY_LIMITS } from "../search/lucene/types.js";
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 || implicitTextTerms(text).size === 0)
70
+ if (request.ast !== undefined || request.exact || request.newest)
50
71
  return searchLucene(store, account, request, messenger);
51
72
  const implicit = implicitTextTerms(text);
52
- if (!plan.changed &&
53
- walkQuery(original.execution.root).some((node) => (node.field === "text" || node.field === "exact") &&
54
- (node.operator !== "term" || node.field === "exact" || !implicit.has(node.span.start))))
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 = [...new Set(plan.terms.concat(plan.aliases.flatMap((alias) => words(alias.split(" -> ")[1] ?? ""))))];
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 lane = meaning ? await meaning(text, original.scopeAccounts) : undefined;
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: plan.scope || undefined,
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, plan.scope);
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, plan.scope);
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: scope || undefined, ast: undefined, limit: only.length, only, context: 0, thread: undefined }, messenger);
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: scope || undefined, ast: undefined, limit: only.length, only, context: 0, thread: undefined }, messenger);
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];