@wirecat/cli-core 0.495.0 → 0.497.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 (44) hide show
  1. package/CHANGELOG.md +89 -1
  2. package/dist/calendar/cli/answers.d.ts +277 -0
  3. package/dist/calendar/cli/answers.js +257 -0
  4. package/dist/calendar/cli/index.d.ts +10 -1
  5. package/dist/calendar/cli/index.js +78 -47
  6. package/dist/calendar/index.d.ts +1 -1
  7. package/dist/calendar/reads.d.ts +7 -0
  8. package/dist/calendar/server-search.d.ts +31 -0
  9. package/dist/calendar/server-search.js +41 -0
  10. package/dist/calendar/source.d.ts +14 -0
  11. package/dist/calendar/testing/answers.d.ts +6 -0
  12. package/dist/calendar/testing/answers.js +37 -0
  13. package/dist/calendar/testing/source.d.ts +2 -0
  14. package/dist/calendar/testing/source.js +9 -0
  15. package/dist/cli/commands-command.js +10 -2
  16. package/dist/cli/config-command.js +18 -8
  17. package/dist/cli/messenger/conversation-answers.d.ts +1 -1
  18. package/dist/cli/messenger/message-answers.d.ts +1 -1
  19. package/dist/cli/messenger/message-writes.d.ts +2 -2
  20. package/dist/cli/messenger/search-answers.js +2 -2
  21. package/dist/cli/messenger/search-command.js +4 -2
  22. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  23. package/dist/cli/program.js +2 -1
  24. package/dist/entities/conversation-results.d.ts +2 -2
  25. package/dist/entities/evidence.d.ts +8 -8
  26. package/dist/entities/index.d.ts +26 -19
  27. package/dist/entities/listener.d.ts +4 -4
  28. package/dist/entities/message-context.d.ts +3 -3
  29. package/dist/entities/message-write.d.ts +4 -4
  30. package/dist/entities/message.d.ts +2 -2
  31. package/dist/entities/message.js +2 -2
  32. package/dist/entities/store-records.d.ts +1 -1
  33. package/dist/errors/reasons.js +4 -0
  34. package/dist/meetings/read-answers.d.ts +10 -0
  35. package/dist/meetings/read-answers.js +7 -0
  36. package/dist/services/document-source.d.ts +2 -3
  37. package/dist/services/document-source.js +2 -7
  38. package/dist/services/messages-discovery.js +7 -2
  39. package/dist/services/notes-search.d.ts +13 -3
  40. package/dist/services/notes-search.js +93 -19
  41. package/dist/store/sqlite/note-search.d.ts +5 -0
  42. package/dist/store/sqlite/note-search.js +11 -2
  43. package/package.json +1 -1
  44. package/parity.json +1 -1
@@ -1,8 +1,8 @@
1
1
  import { isFolderProvider, kindIn } from "../domain/providers.js";
2
2
  /**
3
- * Where a hit came from. The document's preferred file says it: its account's provider, its link and its edit time.
3
+ * Where a hit came from. The document's preferred file says it: its account's provider and its link.
4
4
  * A Drive document under a notes folder (before version 31 moves it) is still known by its front matter.
5
- * Indexing time belongs to the local row.
5
+ * The hit's time is the store search's: the one `date:` reads.
6
6
  */
7
7
  export const documentSource = (note, accounts, profiles, file) => {
8
8
  const metadata = note.frontMatter;
@@ -16,15 +16,10 @@ export const documentSource = (note, accounts, profiles, file) => {
16
16
  (isFolderProvider(key.provider) && key.account === account.name))
17
17
  : undefined;
18
18
  const url = file?.url ?? (remote && typeof metadata?.sourceUrl === "string" ? metadata.sourceUrl : undefined);
19
- const modified = file?.externalUpdatedAt ??
20
- (remote && typeof metadata?.modifiedAt === "string" && Number.isFinite(Date.parse(metadata.modifiedAt))
21
- ? metadata.modifiedAt
22
- : undefined);
23
19
  return {
24
20
  kind: remote ? "document" : "note",
25
21
  provider: profile ? profile.command : "notes",
26
22
  account: profile?.name ?? note.folderId ?? "internal",
27
23
  ...(remote && url !== undefined ? { url } : {}),
28
- updated_at: remote && modified !== undefined ? modified : note.updatedAt,
29
24
  };
30
25
  };
@@ -31,10 +31,15 @@ const FOLLOW_UP_CANDIDATES = 20;
31
31
  export const searchDiscovery = async (store, account, asked, messenger = {}, meaning, rerank) => {
32
32
  // A question's own "?" would parse as a one-character wildcard and send it to strict search (BUG-395); the
33
33
  // colon of a time or ratio ("22:00", "1:1") would parse as a field and fail the whole search, and escaped it is
34
- // syntax again, so it splits the numbers (BUG-450).
34
+ // syntax again, so it splits the numbers (BUG-450). A quoted timestamp keeps its colons (BUG-458).
35
35
  const request = asked.ast !== undefined || asked.exact || !asked.text
36
36
  ? asked
37
- : { ...asked, text: asked.text.replace(/(?<=\p{L})\?(?=\s|$)/gu, "").replace(/(?<=\d):(?=\d)/gu, " ") };
37
+ : {
38
+ ...asked,
39
+ text: asked.text
40
+ .replace(/(?<=\p{L})\?(?=\s|$)/gu, "")
41
+ .replace(/"(?:[^"\\]|\\.)*"|(?<=\d):(?=\d)/gu, (found) => (found === ":" ? " " : found)),
42
+ };
38
43
  const original = await prepareLucene(store, account, request, messenger);
39
44
  const text = request.text ?? "";
40
45
  const plan = planQuestion(text, request.ast !== undefined || !!request.exact || !!request.newest);
@@ -1,3 +1,4 @@
1
+ import { type QueryNode } from "@wirecat/cli-base/query";
1
2
  import { type Embedder, type WarmEmbedders } from "../embeddings/embed.js";
2
3
  import { type TextModel } from "../embeddings/models.js";
3
4
  import type { PictureEmbedder } from "../images/picture/embedder.js";
@@ -90,7 +91,7 @@ export interface FoundNote {
90
91
  /** The note's path inside its folder; `null` for a note written in memo. */
91
92
  path: string | null;
92
93
  title: string | null;
93
- /** When the store's copy last changed. */
94
+ /** The time `date:` reads: a Drive document's edit time when the store has one, else when the store's copy changed. */
94
95
  updated_at: string;
95
96
  /** The first line holding a word of the query. */
96
97
  excerpt: string;
@@ -135,7 +136,7 @@ export interface NotesQuery {
135
136
  limit?: number;
136
137
  offset?: number;
137
138
  tag?: string;
138
- /** A query in the same language that every hit must also match; it does not change the meaning half. */
139
+ /** A query in the same language that every hit must also match, as the query's own fields are. */
139
140
  filter?: string;
140
141
  folderIds?: string[];
141
142
  /** With `folderIds`, the notes written in memo too: one ranked list, so `offset` pages it. */
@@ -164,9 +165,18 @@ export declare const noteSourceOfQuery: (query: string) => {
164
165
  source?: Note["source"];
165
166
  rest: string;
166
167
  };
168
+ /**
169
+ * What a note found by meaning must still match: the query with each word it asks for taken as matched, since the
170
+ * meaning half stands in for those. Words it excludes, and every other field, stay. `undefined` when nothing but
171
+ * wanted words is left, so any note found by meaning fits.
172
+ */
173
+ export declare const fieldsOfQuery: (root: QueryNode) => QueryNode | undefined;
174
+ /** The words and phrases the query asks for, for the meaning half to embed; none it excludes, no field. */
175
+ export declare const meaningWords: (root: QueryNode) => string;
167
176
  /**
168
177
  * `search notes`: words and stems, and meaning when the local model is there, merged by reciprocal rank;
169
- * each hit says which half found it, and the answer lists what the notes found link to.
178
+ * each hit says which half found it, and the answer lists what the notes found link to. Every field of the query and
179
+ * of `filter` holds for every hit, found by words or by meaning; a query of fields alone is not searched by meaning.
170
180
  */
171
181
  export declare const searchNotes: (store: MessageStore, query: string, { limit, offset, tag, filter: expression, folderIds, withInternal, source: given, exact, timezone, env, command, embedders, pictures: withPictures, pictureLimit, pictureEmbedder, }?: NotesQuery) => Promise<NotesFound>;
172
182
  /** What `notes:internal` and `notes:<folder id>` ask of the store: one query, so a page of it is one page. */
@@ -19,8 +19,10 @@ import { listContextOf } from "./search-kind.js";
19
19
  * Text in the query language messages use, parsed for a store search: `date:` read in `timezone`, and the store's
20
20
  * stemmer when a word is stemmed.
21
21
  */
22
- export const storeQuery = async (store, text, { exact, timezone } = {}) => {
23
- const ast = validateFields(parseLucene(text, { ...SEARCH_QUERY, defaultField: exact ? "exact" : "text" }));
22
+ export const storeQuery = async (store, text, { exact, timezone } = {}) => resolvedQuery(store, parsedQuery(text, exact), timezone);
23
+ const parsedQuery = (text, exact) => validateFields(parseLucene(text, { ...SEARCH_QUERY, defaultField: exact ? "exact" : "text" })).root;
24
+ const resolvedQuery = async (store, root, timezone) => {
25
+ const ast = { root };
24
26
  const zone = timezoneOf(timezone);
25
27
  const resolve = (node) => {
26
28
  if (node.kind === "boolean")
@@ -145,8 +147,6 @@ const MAX_LINE = 200;
145
147
  const MAX_NOTES_OFFSET = 1000;
146
148
  /** How deep each half is read before the two are merged. */
147
149
  const CANDIDATES = 50;
148
- /** What a filter may narrow the meaning half to; past this the filter is applied to words only. */
149
- const FILTERED = 500;
150
150
  /** Reciprocal rank fusion's usual constant, as conversations use. */
151
151
  export const RRF_K = 60;
152
152
  const firstLine = (text, query) => {
@@ -225,17 +225,88 @@ export const noteSourceOfQuery = (query) => {
225
225
  }
226
226
  return { source, rest };
227
227
  };
228
+ const WORD_FIELDS = new Set(["text", "exact"]);
229
+ /**
230
+ * What a note found by meaning must still match: the query with each word it asks for taken as matched, since the
231
+ * meaning half stands in for those. Words it excludes, and every other field, stay. `undefined` when nothing but
232
+ * wanted words is left, so any note found by meaning fits.
233
+ */
234
+ export const fieldsOfQuery = (root) => {
235
+ let narrows = false;
236
+ const kept = (node) => {
237
+ if (node.kind === "boolean")
238
+ return {
239
+ ...node,
240
+ clauses: node.clauses.map(({ occur, node }) => {
241
+ if (occur !== "must_not")
242
+ return { occur, node: kept(node) };
243
+ narrows = true;
244
+ return { occur, node };
245
+ }),
246
+ };
247
+ if (!WORD_FIELDS.has(node.field)) {
248
+ narrows = true;
249
+ return node;
250
+ }
251
+ // `in:notes` holds for every note and document; an empty clause list would match nothing.
252
+ return { kind: "predicate", field: "in", operator: "term", value: "notes", span: node.span };
253
+ };
254
+ const fields = kept(root);
255
+ return narrows ? fields : undefined;
256
+ };
257
+ /** The words and phrases the query asks for, for the meaning half to embed; none it excludes, no field. */
258
+ export const meaningWords = (root) => {
259
+ const wanted = (node) => node.kind === "boolean"
260
+ ? node.clauses.flatMap(({ occur, node }) => (occur === "must_not" ? [] : wanted(node)))
261
+ : WORD_FIELDS.has(node.field) && (node.operator === "term" || node.operator === "phrase")
262
+ ? [node.value]
263
+ : [];
264
+ return wanted(root).join(" ").trim();
265
+ };
266
+ const allOf = (nodes) => nodes.length <= 1
267
+ ? nodes[0]
268
+ : {
269
+ kind: "boolean",
270
+ clauses: nodes.map((node) => ({ occur: "must", node })),
271
+ span: { start: 0, end: 0 },
272
+ };
273
+ /**
274
+ * The notes of `refs` that match `root`, in the order of `refs`, each with the time `date:` read. One store query over
275
+ * just those notes, so a filter that matches thousands of notes still checks every candidate.
276
+ */
277
+ const matching = async (store, refs, root, scope) => {
278
+ if (refs.length === 0)
279
+ return new Map();
280
+ const resolved = await resolvedQuery(store, root ?? { kind: "predicate", field: "in", operator: "term", value: "notes", span: { start: 0, end: 0 } }, scope.timezone);
281
+ const found = await store.notes.search({
282
+ root: resolved.root,
283
+ refs,
284
+ limit: refs.length,
285
+ ...(scope.folderIds ? { folderIds: scope.folderIds } : {}),
286
+ ...(scope.withInternal ? { withInternal: true } : {}),
287
+ ...(scope.source ? { source: scope.source } : {}),
288
+ ...(resolved.stemmer ? { stemmer: resolved.stemmer } : {}),
289
+ });
290
+ return new Map(found.items.map(({ ref, note, at }) => [ref, { note, at }]));
291
+ };
228
292
  /**
229
293
  * `search notes`: words and stems, and meaning when the local model is there, merged by reciprocal rank;
230
- * each hit says which half found it, and the answer lists what the notes found link to.
294
+ * each hit says which half found it, and the answer lists what the notes found link to. Every field of the query and
295
+ * of `filter` holds for every hit, found by words or by meaning; a query of fields alone is not searched by meaning.
231
296
  */
232
297
  export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, filter: expression, folderIds, withInternal, source: given, exact = false, timezone, env, command, embedders, pictures: withPictures = false, pictureLimit = 5, pictureEmbedder, } = {}) => {
233
298
  const typed = noteSourceOfQuery(query);
234
299
  const source = given ?? typed.source;
235
- const words = typed.source !== undefined && typed.source === source ? typed.rest : query;
300
+ const rest = typed.source !== undefined && typed.source === source ? typed.rest : query;
236
301
  const label = tag === undefined ? undefined : normalizeTag(tag);
237
302
  const filter = [expression ? `(${expression})` : "", label === undefined ? "" : `tag:${label}`].filter(Boolean).join(" AND ") ||
238
303
  undefined;
304
+ const parsed = query.trim() ? parsedQuery(query, exact) : undefined;
305
+ const words = parsed === undefined ? "" : meaningWords(parsed);
306
+ const narrowing = allOf([
307
+ ...(parsed === undefined ? [] : [fieldsOfQuery(parsed)].filter((node) => node !== undefined)),
308
+ ...(filter === undefined ? [] : [parsedQuery(filter)]),
309
+ ]);
239
310
  const text = [query.trim() ? `(${query})` : "", filter ?? ""].filter(Boolean).join(" AND ");
240
311
  const window = offset + limit;
241
312
  const scope = {
@@ -248,7 +319,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
248
319
  let meaning;
249
320
  if (exact)
250
321
  meaningSkipped = "--exact searches words as written";
251
- else if (words.trim()) {
322
+ else if (words) {
252
323
  try {
253
324
  const nearest = await nearestNotes(store, words, {
254
325
  limit: Math.max(window, CANDIDATES),
@@ -259,13 +330,11 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
259
330
  ...(withInternal ? { withInternal: true } : {}),
260
331
  ...(source === undefined ? {} : { source }),
261
332
  });
262
- if (filter === undefined)
263
- meaning = nearest;
264
- else {
265
- const allowed = await searchNotesQuery(store, { text: filter, limit: FILTERED, ...scope });
266
- const refs = new Set(allowed.items.map(({ ref }) => ref));
267
- meaning = nearest.filter(({ ref }) => refs.has(ref));
268
- }
333
+ const allowed = await matching(store, nearest.map(({ ref }) => ref), narrowing, scope);
334
+ meaning = nearest.flatMap(({ ref }) => {
335
+ const found = allowed.get(ref);
336
+ return found ? [{ ref, ...found }] : [];
337
+ });
269
338
  }
270
339
  catch (error) {
271
340
  if (!(error instanceof CliError && error.code === "not_found"))
@@ -273,7 +342,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
273
342
  meaningSkipped = error.message;
274
343
  }
275
344
  }
276
- const titleWords = plainQueryWords(words);
345
+ const titleWords = plainQueryWords(rest);
277
346
  const titled = titleWords === undefined
278
347
  ? { items: [], hasMore: false }
279
348
  : await searchNotesQuery(store, {
@@ -292,10 +361,10 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
292
361
  ...scope,
293
362
  });
294
363
  const ranked = new Map();
295
- const add = (list, by) => list.forEach(({ ref, note }, rank) => {
364
+ const add = (list, by) => list.forEach(({ ref, note, at }, rank) => {
296
365
  if (tierRefs.has(ref))
297
366
  return;
298
- const held = ranked.get(ref) ?? { note, score: 0, foundBy: [] };
367
+ const held = ranked.get(ref) ?? { note, at, score: 0, foundBy: [] };
299
368
  held.score += 1 / (RRF_K + rank + 1);
300
369
  held.foundBy.push(by);
301
370
  ranked.set(ref, held);
@@ -304,7 +373,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
304
373
  if (meaning !== undefined)
305
374
  add(meaning, "meaning");
306
375
  const ordered = [
307
- ...tier.map(({ ref, note }) => [ref, { note, score: 0, foundBy: ["title"] }]),
376
+ ...tier.map(({ ref, note, at }) => [ref, { note, at, score: 0, foundBy: ["title"] }]),
308
377
  ...[...ranked.entries()].sort(([, a], [, b]) => b.score - a.score),
309
378
  ];
310
379
  const page = slice ? ordered : ordered.slice(offset, window);
@@ -313,7 +382,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
313
382
  const counts = new Map();
314
383
  const accounts = await store.accounts.storedAccounts();
315
384
  const profiles = await store.accounts.profiles();
316
- for (const [ref, { note, foundBy }] of page) {
385
+ for (const [ref, { note, at, foundBy }] of page) {
317
386
  const links = (await store.notes.links({ from: ref })).map(({ to, targetText, kind, anchor }) => ({
318
387
  to,
319
388
  target_text: targetText,
@@ -327,6 +396,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
327
396
  path: note.path,
328
397
  title: note.title,
329
398
  ...documentSource(note, accounts, profiles, note.source === "file" ? (await store.files.ofDocument(note.ref))[0] : undefined),
399
+ updated_at: at,
330
400
  excerpt: firstLine(note.text, words),
331
401
  links,
332
402
  found_by: foundBy,
@@ -360,6 +430,10 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
360
430
  embedder: pictureEmbedder,
361
431
  })
362
432
  : {};
433
+ if (lane.pictures && narrowing !== undefined) {
434
+ const allowed = await matching(store, lane.pictures.map(({ ref }) => ref), narrowing, scope);
435
+ lane.pictures = lane.pictures.filter(({ ref }) => allowed.has(ref));
436
+ }
363
437
  return {
364
438
  query,
365
439
  ...(label === undefined ? {} : { tag: label }),
@@ -13,6 +13,8 @@ export interface NoteQuery {
13
13
  /** With `folderIds`, the notes written in memo too, in the same ranked list. */
14
14
  withInternal?: boolean;
15
15
  source?: Note["source"];
16
+ /** Only these notes (`document:<id>`, `note:<id>`): checks a list found another way against the query. */
17
+ refs?: string[];
16
18
  /** Newest first instead of best first. */
17
19
  newest?: boolean;
18
20
  /** The store's stemmer, set when a leaf is stemmed. */
@@ -26,6 +28,8 @@ export interface NoteHit {
26
28
  relevance: number | null;
27
29
  /** Whether the note holds the exact forms, when the search was stemmed. */
28
30
  exact?: boolean;
31
+ /** The time `date:` read: a Drive document's edit time when the store has one, else when the store's copy changed. */
32
+ at: string;
29
33
  }
30
34
  /** The fields a note has. A message-only field (`from:`, `chat:`, …) is refused rather than matching nothing. */
31
35
  export declare const NOTE_FIELDS: readonly ["text", "exact", "body", "tag", "date", "in", "has", "title", "site", "lang", "author", "keyword", "source"];
@@ -68,6 +72,7 @@ export interface CorpusRow {
68
72
  /** What one corpus is searched by; `accountIds` narrows a corpus that has accounts. */
69
73
  export type CorpusQuery = Pick<NoteQuery, "root" | "limit" | "offset" | "newest" | "stemmer" | "signal"> & {
70
74
  accountIds?: string[];
75
+ ids?: number[];
71
76
  };
72
77
  /** Notes matching a parsed query, by the same language as messages: files and written notes, merged. */
73
78
  export declare const searchNotes: (context: StoreContext, query: NoteQuery) => {
@@ -4,6 +4,7 @@ import { compileAutomaton, foldRegex, wildcardPattern, } from "../../search/luce
4
4
  import { isStemmed } from "../../search/lucene/resolved.js";
5
5
  import { exhausted, QUERY_LIMITS, searchError } from "../../search/lucene/types.js";
6
6
  import { normalize } from "../normalize.js";
7
+ import { contextDocumentTime } from "./context-document-source.js";
7
8
  import { documentPicturesOver } from "./document-pictures.js";
8
9
  import { linkKind } from "./link-kinds.js";
9
10
  import { prefixOf } from "./lucene.js";
@@ -42,7 +43,7 @@ const CORPORA = [
42
43
  type: "document",
43
44
  noun: "note",
44
45
  fields: NOTE_FIELDS,
45
- view: "(SELECT id, account_id AS folder_id, external_id AS path, title, coalesce(body, '') AS text, metadata, updated_at AS at, deleted_at FROM documents)",
46
+ view: `(SELECT n.id, n.account_id AS folder_id, n.external_id AS path, n.title, coalesce(n.body, '') AS text, n.metadata, ${contextDocumentTime} AS at, n.deleted_at FROM documents n)`,
46
47
  words: "document_words",
47
48
  stems: "document_stems",
48
49
  live: "coalesce(json_extract(n.metadata, '$.catalog.active'), 1)=1",
@@ -67,7 +68,13 @@ export const searchNotes = (context, query) => {
67
68
  const offset = query.offset ?? 0;
68
69
  const chosen = CORPORA.filter(({ type }) => (query.source === undefined || (query.source === "file") === (type === "document")) &&
69
70
  (!query.folderIds?.length || query.withInternal === true || type === "document"));
70
- const rows = chosen.flatMap((corpus) => searchCorpus(context, { ...query, ...(query.folderIds ? { accountIds: query.folderIds } : {}) }, corpus));
71
+ const idsOf = (type) => query.refs?.flatMap((ref) => (ref.startsWith(`${type}:`) ? [Number(ref.slice(type.length + 1))] : []));
72
+ const rows = chosen.flatMap((corpus) => {
73
+ const ids = idsOf(corpus.type);
74
+ if (ids?.length === 0)
75
+ return [];
76
+ return searchCorpus(context, { ...query, ...(query.folderIds ? { accountIds: query.folderIds } : {}), ...(ids ? { ids } : {}) }, corpus);
77
+ });
71
78
  const exactFirst = rows.some((row) => row.exact !== undefined);
72
79
  rows.sort((a, b) => query.newest
73
80
  ? (b.at ?? 0) - (a.at ?? 0) || a.id - b.id
@@ -89,6 +96,7 @@ export const searchNotes = (context, query) => {
89
96
  note,
90
97
  relevance: row.relevance,
91
98
  ...(row.exact === undefined ? {} : { exact: row.exact }),
99
+ at: new Date(row.at ?? 0).toISOString(),
92
100
  };
93
101
  }),
94
102
  hasMore: rows.length > offset + query.limit,
@@ -247,6 +255,7 @@ export const searchCorpus = (context, query, corpus) => {
247
255
  bound("n.deleted_at IS NULL"),
248
256
  ...(corpus.live ? [bound(corpus.live)] : []),
249
257
  ...(query.accountIds?.length && corpus.inAccounts ? [corpus.inAccounts(JSON.stringify(query.accountIds))] : []),
258
+ ...(query.ids ? [bound("n.id IN (SELECT value FROM json_each(?))", JSON.stringify(query.ids))] : []),
250
259
  ], "AND");
251
260
  const where = combine([scope, expression], "AND");
252
261
  const ranking = query.newest ? undefined : required(query.root, stemmed ? "stems" : "fts");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.495.0",
3
+ "version": "0.497.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",
package/parity.json CHANGED
@@ -387,7 +387,7 @@
387
387
  "--filter": {
388
388
  "value": "query",
389
389
  "meaning": "Strict Lucene filter: any message in the conversation must match; the meaning query stays unchanged",
390
- "note": "search notes uses it for a query every note must also match, the meaning half unchanged"
390
+ "note": "search notes uses it for a query every note must also match, found by words or by meaning"
391
391
  },
392
392
  "--find-by-phone": {
393
393
  "value": "<who>",