@wirecat/cli-core 0.489.0 → 0.490.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.490.0 — 31.10.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **Notes, documents, knowledge annotations and contacts `--notes` are found by any part of a word, in any letter
15
+ case (store version 40).** `notes` with a search, `knowledge.annotations` with a search and `contacts` with
16
+ `notes` used `instr(lower(...))`, which folds only Latin letters and reads every row: a mixed-case Cyrillic word
17
+ was missed, and a search took 1.5–4 s on a store five times today's size. They now match `note_trigrams` and
18
+ `document_trigrams`, trigram indexes over the normalized title and text (`ё` as `е`, accents dropped) that the
19
+ notes drain keeps, and answer in milliseconds. A query of one or two characters is refused with "search needs at
20
+ least three characters", as message search is; an empty one still filters nothing. The title is searched too,
21
+ not only the text. The notes and documents stored before version 40 get their trigrams in batches from the
22
+ notes drain (`search notes`, `store migrate`); until then they are matched as before.
23
+
8
24
  ## 0.489.0 — 31.10.2026
9
25
 
10
26
  ### Added
@@ -28,7 +28,7 @@ export declare const people: ({ orm, database }: StoreContext, provider: Provide
28
28
  export interface ContactFilter {
29
29
  /** Three letters or more of the name, the @username or the local alias. */
30
30
  query?: string;
31
- /** Text in one of the owner's private notes about them; case folds for Latin letters only, as SQLite's lower(). */
31
+ /** Three characters or more of one of the owner's private notes about them, anywhere, in any letter case. */
32
32
  notes?: string;
33
33
  }
34
34
  export declare const contacts: ({ orm }: StoreContext, accountKey: number, key: AccountKey, { order, limit, offset, ...filter }: {
@@ -2,7 +2,9 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { fold } from "../normalize.js";
3
3
  import { and, asc, desc, eq, inArray, isNotNull, isNull, ne, sql } from "./drizzle/core.js";
4
4
  import { folded } from "./folded.js";
5
+ import { holdingText } from "./note-index.js";
5
6
  import { resolvePersonLinks } from "./person-resolution.js";
7
+ import { bound } from "./query-filter.js";
6
8
  import { accountIdentities, accounts, aliases, chatMembers, chats, identities, identityLinkEvents, identityLinks, identityRevisions, messages, persons, } from "./schema.js";
7
9
  import { toIso } from "./values.js";
8
10
  const flag = (value) => (value === undefined || value === null ? null : Number(value));
@@ -209,13 +211,14 @@ const contactsWhere = (accountKey, key, { query, notes }) => {
209
211
  if (query !== undefined && query.trim().length < 3) {
210
212
  throw new CliError("validation_error", `a contact search takes at least 3 characters, got "${query}"`);
211
213
  }
214
+ const noted = notes === undefined ? undefined : holdingText("note", "n.id", notes);
212
215
  return and(eq(accountIdentities.accountId, accountKey), ne(identities.externalId, key.account), sql `EXISTS (SELECT 1 FROM ${chatMembers} JOIN ${chats} ON ${chats.id} = ${chatMembers.chatId}
213
216
  WHERE ${chatMembers.identityId} = ${identities.id} AND ${chats.accountId} = ${accountIdentities.accountId}
214
217
  AND ${chats.kind} = 'dialog')`, query === undefined
215
218
  ? undefined
216
219
  : sql `(${identities.id} IN (SELECT rowid FROM identities_fts WHERE identities_fts MATCH ${`"${query.trim().replaceAll('"', '""')}"`}) OR EXISTS (SELECT 1 FROM aliases ca WHERE ca.aliasable_type='identity' AND ca.display=1 AND ca.account_id=${accountKey} AND ca.aliasable_id=${identities.id} AND instr(ca.name_folded, ${fold(query.trim())}) > 0))`, notes === undefined
217
220
  ? undefined
218
- : sql `EXISTS (SELECT 1 FROM notes n WHERE n.notable_type='identity' AND n.notable_id=${identities.id} AND n.deleted_at IS NULL AND instr(lower(n.body), lower(${notes})) > 0)`);
221
+ : sql `EXISTS (SELECT 1 FROM notes n WHERE n.notable_type='identity' AND n.notable_id=${identities.id} AND n.deleted_at IS NULL${noted ? sql ` AND ${bound(noted)}` : sql ``})`);
219
222
  };
220
223
  export const contacts = ({ orm }, accountKey, key, { order, limit, offset = 0, ...filter }) => {
221
224
  const byName = [
@@ -10,6 +10,7 @@ import { ulid } from "../ulid.js";
10
10
  import { ownerPerson } from "./actors.js";
11
11
  import { linkKind, RELATION_KINDS } from "./link-kinds.js";
12
12
  import { meetingProjectsStoreOver } from "./meeting-projects.js";
13
+ import { holdingText } from "./note-index.js";
13
14
  import { insertLink, linkOf } from "./notes.js";
14
15
  import { queryFilter } from "./query-filter.js";
15
16
  import { addTags as addThingTags, canonicalTagNamed, createTag, labelsOf, liveTagging, removeTags as removeThingTags, setMainTopic, } from "./tags.js";
@@ -314,10 +315,11 @@ export const knowledgeStoreOver = (context) => {
314
315
  const thing = options.target ? resolve(key, options.target).thing : undefined;
315
316
  if (options.target && !thing)
316
317
  return { items: [], hasMore: false };
318
+ const text = options.search === undefined ? undefined : holdingText("note", "id", options.search);
317
319
  const rows = database
318
320
  .prepare("SELECT * FROM notes WHERE deleted_at IS NULL AND (? IS NULL OR (notable_type = ? AND notable_id = ?)) " +
319
- "AND (? IS NULL OR instr(lower(body), lower(?)) > 0) ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?")
320
- .all(thing?.type ?? null, thing?.type ?? null, thing?.id ?? null, options.search ?? null, options.search ?? null, limit + 1, offset);
321
+ `${text ? `AND ${text.sql} ` : ""}ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`)
322
+ .all(thing?.type ?? null, thing?.type ?? null, thing?.id ?? null, ...(text?.params ?? []), limit + 1, offset);
321
323
  const items = rows.slice(0, limit).flatMap((row) => annotationOf(row) ?? []);
322
324
  return { items, hasMore: rows.length > limit };
323
325
  },
@@ -53,6 +53,7 @@ export const MANIFEST = [
53
53
  { name: "20261029224106_version-38-folded-names", version: 38, minCompatible: 34 },
54
54
  { name: "20261029224107_version-38-folded-triggers", version: 38, minCompatible: 34 },
55
55
  { name: "20261030114008_version-39-email-locations", version: 39, minCompatible: 34 },
56
+ { name: "20261030225520_version-40-note-trigrams", version: 40, minCompatible: 34 },
56
57
  ];
57
58
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
58
59
  const migrations = [];
@@ -837,5 +837,15 @@ export const GENERATED = [
837
837
  "CREATE TABLE `email_locations` (\n\t`mailbox_id` integer NOT NULL,\n\t`uidvalidity` text NOT NULL,\n\t`uid` integer NOT NULL,\n\t`email_id` integer NOT NULL,\n\tCONSTRAINT `email_locations_pk` PRIMARY KEY(`mailbox_id`, `uidvalidity`, `uid`),\n\tCONSTRAINT `fk_email_locations_mailbox_id_mailboxes_id_fk` FOREIGN KEY (`mailbox_id`) REFERENCES `mailboxes`(`id`),\n\tCONSTRAINT `fk_email_locations_email_id_emails_id_fk` FOREIGN KEY (`email_id`) REFERENCES `emails`(`id`)\n);",
838
838
  "CREATE INDEX `email_locations_by_email_id` ON `email_locations` (`email_id`);"
839
839
  ]
840
+ },
841
+ {
842
+ "name": "20261030225520_version-40-note-trigrams",
843
+ "statements": [
844
+ "CREATE VIRTUAL TABLE note_trigrams USING fts5(text, content = '', contentless_delete = 1, tokenize = 'trigram');",
845
+ "CREATE VIRTUAL TABLE document_trigrams USING fts5(text, content = '', contentless_delete = 1, tokenize = 'trigram');",
846
+ "CREATE TRIGGER note_trigrams_bd BEFORE DELETE ON notes BEGIN\n DELETE FROM note_trigrams WHERE rowid = old.id;\nEND;",
847
+ "CREATE TRIGGER document_trigrams_bd BEFORE DELETE ON documents BEGIN\n DELETE FROM document_trigrams WHERE rowid = old.id;\nEND;",
848
+ "-- The trigrams hold JS-normalized text, which SQL cannot produce: fillTrigrams fills the rows stored before this\n-- version, up to `watermark`, and a search reads the rest of them by their text meanwhile.\nINSERT INTO search_index_state (name, watermark, filled_through, terms_through, normalizer_version)\n SELECT 'note_trigrams', coalesce(max(id), 0), 0, 0, 1 FROM notes\n UNION ALL SELECT 'document_trigrams', coalesce(max(id), 0), 0, 0, 1 FROM documents;"
849
+ ]
840
850
  }
841
851
  ];
@@ -1,5 +1,6 @@
1
1
  import { type Stemmer, type Stemmers } from "../../search/stem.js";
2
2
  import type { CacheDatabase } from "../driver.js";
3
+ import type { Where } from "./query-filter.js";
3
4
  export interface NoteIndexState {
4
5
  /** Rows written since they were indexed. */
5
6
  pending: number;
@@ -102,6 +103,20 @@ export declare const CORPORA: {
102
103
  };
103
104
  /** What a row is indexed as: its title, then its text. Chunk offsets point into this. */
104
105
  export declare const noteIndexText: (title: string | null, text: string) => string;
106
+ /**
107
+ * `column` (a note's or document's id) is a row whose title or text holds `search` anywhere, in any letter case;
108
+ * no condition for an empty `search`. A row the trigrams do not hold yet — waiting in its queue, or stored before
109
+ * version 40 and not yet reached by `fillTrigrams` — is matched by its text as SQLite's `lower()` reads it.
110
+ */
111
+ export declare const holdingText: (type: "note" | "document", column: string, search: string) => Where | undefined;
112
+ /**
113
+ * Writes the trigrams of the notes and documents stored before version 40, in short write transactions, up to the
114
+ * highest id the migration found; later rows get theirs from the drain. Stopping between batches loses nothing.
115
+ */
116
+ export declare const fillTrigrams: (database: CacheDatabase, { batch, until }?: {
117
+ batch?: number;
118
+ until?: () => boolean;
119
+ }) => number;
105
120
  export declare const corpusIndexState: (database: CacheDatabase, corpus: Corpus) => NoteIndexState;
106
121
  /** Documents and notes together: what a notes search reads. */
107
122
  export declare const noteIndexState: (database: CacheDatabase) => NoteIndexState;
@@ -113,7 +128,7 @@ export declare const drainCorpus: (database: CacheDatabase, corpus: Corpus, stem
113
128
  batch?: number;
114
129
  until?: () => boolean;
115
130
  }) => number;
116
- /** Documents, then notes: what a notes search reads. */
131
+ /** Documents, then notes: what a notes search reads; then the trigrams still to fill. */
117
132
  export declare const drainNoteIndex: (database: CacheDatabase, stemmerFor: (stemmers: Stemmers) => Stemmer, options?: {
118
133
  batch?: number;
119
134
  until?: () => boolean;
@@ -1,3 +1,4 @@
1
+ import { CliError } from "@wirecat/cli-base";
1
2
  import { CHUNK_CHARS, chunkHash, splitSections, splitText } from "../../conversations/chunks.js";
2
3
  import { analyzerIdentity, DEFAULT_STEMMERS } from "../../search/stem.js";
3
4
  import { taskStemsOf, taskWordsOf } from "../../search/task-similarity.js";
@@ -103,6 +104,62 @@ export const CORPORA = {
103
104
  /** What a row is indexed as: its title, then its text. Chunk offsets point into this. */
104
105
  export const noteIndexText = (title, text) => (title ? `${title}\n\n${text}` : text);
105
106
  const queuedAs = (corpus) => corpus.queued ?? corpus.type;
107
+ const hasTrigrams = (corpus) => corpus.type === "note" || corpus.type === "document";
108
+ /**
109
+ * `column` (a note's or document's id) is a row whose title or text holds `search` anywhere, in any letter case;
110
+ * no condition for an empty `search`. A row the trigrams do not hold yet — waiting in its queue, or stored before
111
+ * version 40 and not yet reached by `fillTrigrams` — is matched by its text as SQLite's `lower()` reads it.
112
+ */
113
+ export const holdingText = (type, column, search) => {
114
+ const text = normalize(search);
115
+ if (text === "")
116
+ return undefined;
117
+ // A trigram index answers a shorter query with nothing, silently.
118
+ if ([...text].length < 3)
119
+ throw new CliError("validation_error", "search needs at least three characters");
120
+ const queued = `SELECT id FROM ${type}_index_pending WHERE indexable_type = '${type}'`;
121
+ const state = (field) => `(SELECT ${field} FROM search_index_state WHERE name = '${type}_trigrams')`;
122
+ return {
123
+ sql: `${column} IN (SELECT rowid FROM ${type}_trigrams WHERE ${type}_trigrams MATCH ? AND rowid NOT IN (${queued}) ` +
124
+ `UNION ALL SELECT r.id FROM ${type}s r WHERE (r.id IN (${queued}) OR (r.id > ${state("filled_through")} ` +
125
+ `AND r.id <= ${state("watermark")})) AND instr(lower(coalesce(r.body, '')), lower(?)) > 0)`,
126
+ params: [`"${text.replaceAll('"', '""')}"`, search],
127
+ };
128
+ };
129
+ /**
130
+ * Writes the trigrams of the notes and documents stored before version 40, in short write transactions, up to the
131
+ * highest id the migration found; later rows get theirs from the drain. Stopping between batches loses nothing.
132
+ */
133
+ export const fillTrigrams = (database, { batch = 200, until = () => false } = {}) => {
134
+ let filled = 0;
135
+ for (const type of ["document", "note"]) {
136
+ const name = `${type}_trigrams`;
137
+ const state = database.prepare("SELECT watermark, filled_through FROM search_index_state WHERE name = ?");
138
+ const rows = database.prepare(`SELECT id, title, coalesce(body, '') AS body FROM ${type}s WHERE id > ? AND id <= ? AND deleted_at IS NULL ` +
139
+ "ORDER BY id LIMIT ?");
140
+ const drop = database.prepare(`DELETE FROM ${name} WHERE rowid = ?`);
141
+ const add = database.prepare(`INSERT INTO ${name} (rowid, text) VALUES (?, ?)`);
142
+ const reached = database.prepare("UPDATE search_index_state SET filled_through = ? WHERE name = ?");
143
+ for (;;) {
144
+ const at = state.get(name);
145
+ const mark = Number(at?.watermark ?? 0);
146
+ if (Number(at?.filled_through ?? mark) >= mark || until())
147
+ break;
148
+ filled += write(database, () => {
149
+ const page = rows.all(Number(at?.filled_through), mark, batch);
150
+ for (const row of page) {
151
+ const text = normalize(noteIndexText(row.title == null ? null : String(row.title), String(row.body)));
152
+ drop.run(Number(row.id));
153
+ if (text !== "")
154
+ add.run(Number(row.id), text);
155
+ }
156
+ reached.run(page.length < batch ? mark : Number(page.at(-1)?.id), name);
157
+ return page.length;
158
+ });
159
+ }
160
+ }
161
+ return filled;
162
+ };
106
163
  export const corpusIndexState = (database, corpus) => {
107
164
  const row = indexRow(database, corpus.index);
108
165
  const pending = Number(database
@@ -139,9 +196,13 @@ const writer = (database, corpus, stemmer) => {
139
196
  const statements = {
140
197
  dropWords: database.prepare(`DELETE FROM ${corpus.type}_words WHERE rowid = ?`),
141
198
  dropStems: database.prepare(`DELETE FROM ${corpus.type}_stems WHERE rowid = ?`),
199
+ dropTrigrams: hasTrigrams(corpus) ? database.prepare(`DELETE FROM ${corpus.type}_trigrams WHERE rowid = ?`) : null,
142
200
  dropChunks: database.prepare("DELETE FROM chunks WHERE chunkable_type = ? AND chunkable_id = ?"),
143
201
  words: database.prepare(`INSERT INTO ${corpus.type}_words (rowid, normalized_text, scope) VALUES (?, ?, ?)`),
144
202
  stems: database.prepare(`INSERT INTO ${corpus.type}_stems (rowid, stems, scope) VALUES (?, ?, ?)`),
203
+ trigrams: hasTrigrams(corpus)
204
+ ? database.prepare(`INSERT INTO ${corpus.type}_trigrams (rowid, text) VALUES (?, ?)`)
205
+ : null,
145
206
  chunk: database.prepare("INSERT INTO chunks (chunkable_type, chunkable_id, position, start_offset, end_offset, content_hash, scope, account_id, created_at, updated_at) " +
146
207
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
147
208
  dequeue: database.prepare(`DELETE FROM ${corpus.type}_index_pending WHERE indexable_type = ? AND id = ?`),
@@ -151,14 +212,17 @@ const writer = (database, corpus, stemmer) => {
151
212
  const before = oldHashes.all(corpus.type, id).map((hash) => String(hash.hash));
152
213
  statements.dropWords.run(id);
153
214
  statements.dropStems.run(id);
215
+ statements.dropTrigrams?.run(id);
154
216
  statements.dropChunks.run(corpus.type, id);
155
217
  const live = row !== undefined && Number(row.live) === 1;
156
218
  const text = live ? noteIndexText(row.title == null ? null : String(row.title), String(row.body)) : "";
157
219
  const scope = live ? String(row.scope ?? "personal") : "";
158
220
  const terms = corpus.terms && text ? corpus.terms(text, stemmer) : undefined;
159
221
  const words = terms ? terms.words : normalize(text);
160
- if (words !== "")
222
+ if (words !== "") {
161
223
  statements.words.run(id, words, scope);
224
+ statements.trigrams?.run(id, words);
225
+ }
162
226
  const stems = terms ? terms.stems : text ? stemmer.indexText(text) : "";
163
227
  if (stems !== "")
164
228
  statements.stems.run(id, stems, scope);
@@ -228,9 +292,10 @@ export const drainCorpus = (database, corpus, stemmerFor, { batch = 200, until =
228
292
  }
229
293
  return done;
230
294
  };
231
- /** Documents, then notes: what a notes search reads. */
295
+ /** Documents, then notes: what a notes search reads; then the trigrams still to fill. */
232
296
  export const drainNoteIndex = (database, stemmerFor, options = {}) => drainCorpus(database, CORPORA.document, stemmerFor, options) +
233
- drainCorpus(database, CORPORA.note, stemmerFor, options);
297
+ drainCorpus(database, CORPORA.note, stemmerFor, options) +
298
+ fillTrigrams(database, options);
234
299
  /** Queues every document, note and memory again — for `store rebuild`. */
235
300
  export const resetNoteIndex = (database) => {
236
301
  write(database, () => {
@@ -12,6 +12,7 @@ import { PREFERRED_FILE, syncFile } from "./files.js";
12
12
  import { importedDocumentWhere } from "./imported-documents.js";
13
13
  import { LINK_KINDS, LINK_ORIGINS, linkKind } from "./link-kinds.js";
14
14
  import { reviewLink } from "./link-reviews.js";
15
+ import { holdingText } from "./note-index.js";
15
16
  import { addTags, ensureTag, labelsOf } from "./tags.js";
16
17
  import { canonicalThingReference, referenceOfThing, storedThing, thingOf } from "./things.js";
17
18
  import { write } from "./transaction.js";
@@ -525,7 +526,8 @@ export const notesStoreOver = (context) => {
525
526
  if (options.about !== undefined && !about)
526
527
  return { items: [], hasMore: false };
527
528
  const folder = options.folderId === undefined ? null : Number(folderRow(options.folderId).id);
528
- const search = options.search ?? null;
529
+ const documentText = options.search === undefined ? undefined : holdingText("document", "d.id", options.search);
530
+ const noteText = options.search === undefined ? undefined : holdingText("note", "n.id", options.search);
529
531
  const aboutType = about?.type ?? null;
530
532
  const aboutId = about?.id ?? null;
531
533
  const files = options.source === "internal"
@@ -541,16 +543,16 @@ export const notesStoreOver = (context) => {
541
543
  "WHERE f.deleted_at IS NULL AND d.deleted_at IS NULL AND f.account_id = ? AND ? IS NOT NULL ") +
542
544
  "AND coalesce(json_extract(d.metadata, '$.catalog.active'), 1)=1 " +
543
545
  `AND (? IS NULL OR EXISTS (SELECT 1 FROM links l WHERE l.from_type = 'document' AND l.from_id = d.id AND l.kind = ${linkKind("about")} AND l.to_type = ? AND l.to_id = ?)) ` +
544
- "AND (? IS NULL OR instr(lower(coalesce(d.body, '')), lower(?)) > 0) ORDER BY d.created_at DESC, d.id DESC LIMIT ?")
545
- .all(folder, folder, aboutType, aboutType, aboutId, search, search, limit + offset + 1)
546
+ `${documentText ? `AND ${documentText.sql} ` : ""}ORDER BY d.created_at DESC, d.id DESC LIMIT ?`)
547
+ .all(folder, folder, aboutType, aboutType, aboutId, ...(documentText?.params ?? []), limit + offset + 1)
546
548
  .map(documentOf);
547
549
  const written = options.source === "file" || folder !== null
548
550
  ? []
549
551
  : database
550
552
  .prepare("SELECT n.* FROM notes n WHERE n.deleted_at IS NULL " +
551
553
  `AND (? IS NULL OR (n.notable_type = ? AND n.notable_id = ?) OR EXISTS (SELECT 1 FROM links l WHERE l.from_type = 'note' AND l.from_id = n.id AND l.kind = ${linkKind("about")} AND l.to_type = ? AND l.to_id = ?)) ` +
552
- "AND (? IS NULL OR instr(lower(n.body), lower(?)) > 0) ORDER BY n.created_at DESC, n.id DESC LIMIT ?")
553
- .all(aboutType, aboutType, aboutId, aboutType, aboutId, search, search, limit + offset + 1)
554
+ `${noteText ? `AND ${noteText.sql} ` : ""}ORDER BY n.created_at DESC, n.id DESC LIMIT ?`)
555
+ .all(aboutType, aboutType, aboutId, aboutType, aboutId, ...(noteText?.params ?? []), limit + offset + 1)
554
556
  .map(noteOf);
555
557
  const all = [...files, ...written].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.ref.localeCompare(a.ref));
556
558
  return { items: all.slice(offset, offset + limit), hasMore: all.length > offset + limit };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.489.0",
3
+ "version": "0.490.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",