@wirecat/cli-core 0.488.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/dist/calendar/cli/index.js +48 -13
  3. package/dist/calendar/index.d.ts +1 -1
  4. package/dist/calendar/mcp/index.d.ts +390 -0
  5. package/dist/calendar/mcp/index.js +50 -12
  6. package/dist/calendar/reads.d.ts +9 -2
  7. package/dist/calendar/reads.js +60 -5
  8. package/dist/calendar/recurrence.d.ts +20 -0
  9. package/dist/calendar/recurrence.js +74 -0
  10. package/dist/calendar/sync.d.ts +6 -0
  11. package/dist/calendar/sync.js +123 -41
  12. package/dist/calendar/testing/memory.js +90 -7
  13. package/dist/calendar/testing/sync-contract.js +90 -0
  14. package/dist/calendar/testing/writer-contract.d.ts +2 -6
  15. package/dist/calendar/testing/writer-contract.js +157 -29
  16. package/dist/calendar/testing/writer.d.ts +3 -1
  17. package/dist/calendar/testing/writer.js +11 -0
  18. package/dist/calendar/writer.d.ts +20 -3
  19. package/dist/calendar/writer.js +8 -4
  20. package/dist/calendar/writes.d.ts +21 -12
  21. package/dist/calendar/writes.js +259 -20
  22. package/dist/errors/reasons.d.ts +1 -1
  23. package/dist/errors/reasons.js +5 -0
  24. package/dist/meetings/event-store.d.ts +80 -2
  25. package/dist/meetings/event-store.js +65 -1
  26. package/dist/meetings/index.d.ts +2 -1
  27. package/dist/meetings/index.js +1 -0
  28. package/dist/meetings/testing/event-store-contract.d.ts +1 -1
  29. package/dist/meetings/testing/event-store-contract.js +114 -0
  30. package/dist/store/drivers/node-sqlite.d.ts +9 -1
  31. package/dist/store/drivers/node-sqlite.js +30 -8
  32. package/dist/store/sqlite/calendar-events.d.ts +4 -0
  33. package/dist/store/sqlite/calendar-events.js +89 -0
  34. package/dist/store/sqlite/calendar.js +126 -94
  35. package/dist/store/sqlite/identities.d.ts +1 -1
  36. package/dist/store/sqlite/identities.js +4 -1
  37. package/dist/store/sqlite/knowledge.js +4 -2
  38. package/dist/store/sqlite/manifest.js +1 -0
  39. package/dist/store/sqlite/migrations.generated.js +10 -0
  40. package/dist/store/sqlite/note-index.d.ts +16 -1
  41. package/dist/store/sqlite/note-index.js +68 -3
  42. package/dist/store/sqlite/notes.js +7 -5
  43. package/package.json +1 -1
@@ -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.488.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",