@wirecat/cli-core 0.452.0 → 0.454.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 (54) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/mcp-server-command.js +5 -1
  3. package/dist/cli/messenger/search-answers.d.ts +1 -1
  4. package/dist/cli/messenger/search-answers.js +1 -1
  5. package/dist/cli/messenger/search-command.js +6 -3
  6. package/dist/entities/contact-context.d.ts +42 -0
  7. package/dist/entities/contact-context.js +11 -0
  8. package/dist/entities/hit.d.ts +3 -3
  9. package/dist/entities/hit.js +3 -3
  10. package/dist/entities/index.d.ts +61 -21
  11. package/dist/entities/task-results.d.ts +20 -20
  12. package/dist/entities/task-results.js +3 -15
  13. package/dist/mcp/http/activity.d.ts +24 -0
  14. package/dist/mcp/http/activity.js +86 -0
  15. package/dist/mcp/http/serve.js +14 -0
  16. package/dist/mcp/http/state.d.ts +5 -0
  17. package/dist/mcp/http/state.js +14 -4
  18. package/dist/meetings/event-store.d.ts +23 -0
  19. package/dist/meetings/index.d.ts +1 -1
  20. package/dist/search/query.js +1 -3
  21. package/dist/services/events-search.d.ts +67 -0
  22. package/dist/services/events-search.js +131 -0
  23. package/dist/services/index.d.ts +3 -2
  24. package/dist/services/index.js +2 -1
  25. package/dist/services/notes-search.d.ts +25 -1
  26. package/dist/services/notes-search.js +24 -11
  27. package/dist/services/person-context.d.ts +20 -1
  28. package/dist/services/person-context.js +46 -1
  29. package/dist/services/search-all-meetings.d.ts +1 -1
  30. package/dist/services/search-all-meetings.js +7 -2
  31. package/dist/services/search-all.d.ts +12 -7
  32. package/dist/services/search-all.js +39 -4
  33. package/dist/store/index.d.ts +1 -0
  34. package/dist/store/index.js +1 -0
  35. package/dist/store/project-transfer/request.d.ts +1 -1
  36. package/dist/store/project-transfer/schema.d.ts +2 -2
  37. package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
  38. package/dist/store/sqlite/calendar.js +85 -11
  39. package/dist/store/sqlite/event-search.d.ts +66 -0
  40. package/dist/store/sqlite/event-search.js +163 -0
  41. package/dist/store/sqlite/files.d.ts +4 -0
  42. package/dist/store/sqlite/files.js +28 -0
  43. package/dist/store/sqlite/link-kinds.d.ts +5 -1
  44. package/dist/store/sqlite/link-kinds.js +5 -0
  45. package/dist/store/sqlite/note-index.d.ts +17 -1
  46. package/dist/store/sqlite/note-index.js +32 -8
  47. package/dist/store/sqlite/note-search.d.ts +43 -0
  48. package/dist/store/sqlite/note-search.js +24 -24
  49. package/dist/store/sqlite/notes.d.ts +4 -5
  50. package/dist/store/sqlite/notes.js +2 -2
  51. package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
  52. package/dist/store/store.d.ts +2 -1
  53. package/dist/store/store.js +2 -1
  54. package/package.json +1 -1
@@ -56,7 +56,7 @@ export declare const projectAnnotationsSchema: v.StrictObjectSchema<{
56
56
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
57
57
  readonly from: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
58
58
  readonly to: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
59
- readonly kind: v.PicklistSchema<readonly ["links-to", "blocked-by", "follow-up-of", "about", "member-of", "labelled", "answered-by", "evidence", "created-from", "duplicate-of", "related-to", "assigned-to"], undefined>;
59
+ readonly kind: v.PicklistSchema<readonly ["links-to", "blocked-by", "follow-up-of", "about", "member-of", "labelled", "answered-by", "evidence", "created-from", "duplicate-of", "related-to", "assigned-to", "attached"], undefined>;
60
60
  readonly origin: v.PicklistSchema<readonly ["file", "owner", "suggested", "message"], undefined>;
61
61
  readonly anchor: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
62
62
  readonly target_text: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
@@ -71,7 +71,7 @@ export declare const projectAnnotationsSchema: v.StrictObjectSchema<{
71
71
  ref: string;
72
72
  from: string;
73
73
  to: string | null;
74
- kind: "about" | "answered-by" | "assigned-to" | "blocked-by" | "created-from" | "duplicate-of" | "evidence" | "follow-up-of" | "labelled" | "links-to" | "member-of" | "related-to";
74
+ kind: "about" | "answered-by" | "assigned-to" | "attached" | "blocked-by" | "created-from" | "duplicate-of" | "evidence" | "follow-up-of" | "labelled" | "links-to" | "member-of" | "related-to";
75
75
  origin: "file" | "message" | "owner" | "suggested";
76
76
  anchor: string | null;
77
77
  target_text: string | null;
@@ -37,7 +37,7 @@ export declare const projectAnnotationSnapshotSchema: v.StrictObjectSchema<{
37
37
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
38
38
  readonly from: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
39
39
  readonly to: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
40
- readonly kind: v.PicklistSchema<readonly ["links-to", "blocked-by", "follow-up-of", "about", "member-of", "labelled", "answered-by", "evidence", "created-from", "duplicate-of", "related-to", "assigned-to"], undefined>;
40
+ readonly kind: v.PicklistSchema<readonly ["links-to", "blocked-by", "follow-up-of", "about", "member-of", "labelled", "answered-by", "evidence", "created-from", "duplicate-of", "related-to", "assigned-to", "attached"], undefined>;
41
41
  readonly origin: v.PicklistSchema<readonly ["file", "owner", "suggested", "message"], undefined>;
42
42
  readonly anchor: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
43
43
  readonly target_text: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
@@ -52,7 +52,7 @@ export declare const projectAnnotationSnapshotSchema: v.StrictObjectSchema<{
52
52
  ref: string;
53
53
  from: string;
54
54
  to: string | null;
55
- kind: "about" | "answered-by" | "assigned-to" | "blocked-by" | "created-from" | "duplicate-of" | "evidence" | "follow-up-of" | "labelled" | "links-to" | "member-of" | "related-to";
55
+ kind: "about" | "answered-by" | "assigned-to" | "attached" | "blocked-by" | "created-from" | "duplicate-of" | "evidence" | "follow-up-of" | "labelled" | "links-to" | "member-of" | "related-to";
56
56
  origin: "file" | "message" | "owner" | "suggested";
57
57
  anchor: string | null;
58
58
  target_text: string | null;
@@ -1,7 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { calendarText, entryMetadata, joinKeyOf, joinUrlWithoutQuery, } from "../../meetings/index.js";
3
3
  import { bool, eventOf, fromJson, int, str, toJson } from "./events.js";
4
- import { logFileLink } from "./files.js";
4
+ import { driveDocumentOf, driveFileTarget, logFileLink } from "./files.js";
5
5
  import { identityOf } from "./identities.js";
6
6
  import { reviewLink } from "./link-reviews.js";
7
7
  import { read, write } from "./transaction.js";
@@ -86,6 +86,12 @@ const reviewOf = (row) => ({
86
86
  decidedAt: int(row.decided_at),
87
87
  createdAt: Number(row.created_at),
88
88
  });
89
+ const attachmentOf = (row) => ({
90
+ entryId: Number(row.from_id),
91
+ fileId: String(row.target_text).slice(driveFileTarget("").length),
92
+ title: str(fromJson(row.metadata)?.title),
93
+ documentId: int(row.to_id),
94
+ });
89
95
  const TRACKED = ["title", "starts_at", "ends_at", "location", "status", "recurrence", "recurrence_ends_at"];
90
96
  const flag = (value) => (value ? 1 : 0);
91
97
  const addressOf = (email) => email.trim().toLowerCase();
@@ -204,17 +210,34 @@ export const eventStoreOver = (context) => {
204
210
  return;
205
211
  setEntity(entry, newEntity(entry, now), "new", "rules", now);
206
212
  };
207
- /** The event's fields from its preferred entry: the owner's copy when the owner organizes, else the latest. */
213
+ /**
214
+ * The event's fields from its preferred entry: a copy with text before one without (a busy-only slot), then the
215
+ * owner's copy when the owner organizes, then the latest. With no live copy left, a past event stays as history
216
+ * and a future one is cancelled.
217
+ */
208
218
  const refreshEvent = (eventId, now) => {
219
+ // Search reads the event's status and its copies' visibility, which no index trigger watches.
220
+ database.prepare("INSERT OR IGNORE INTO event_index_pending (indexable_type, id) VALUES ('event', ?)").run(eventId);
209
221
  const entries = database
210
222
  .prepare(`SELECT ce.*, EXISTS (SELECT 1 FROM calendar_entry_participants p WHERE p.entry_id = ce.id AND p.self = 1
211
223
  AND p.organizer = 1) AS own FROM calendar_entries ce WHERE ce.event_id = ? AND ce.deleted_at IS NULL
212
- ORDER BY own DESC, coalesce(ce.external_updated_at, ce.updated_at) DESC, ce.id DESC`)
224
+ ORDER BY trim(coalesce(ce.title, '') || coalesce(ce.description, '') || coalesce(ce.location, '')) <> '' DESC,
225
+ own DESC, coalesce(ce.external_updated_at, ce.updated_at) DESC, ce.id DESC`)
213
226
  .all(eventId);
214
227
  const preferred = entries[0];
215
- if (!preferred)
216
- return;
217
228
  const event = database.prepare("SELECT * FROM events WHERE id = ?").get(eventId);
229
+ if (!preferred) {
230
+ if (!event || !database.prepare("SELECT 1 FROM calendar_entries WHERE event_id = ?").get(eventId))
231
+ return;
232
+ const startsAt = int(event.starts_at);
233
+ if (startsAt !== null && startsAt < now)
234
+ database
235
+ .prepare("UPDATE events SET source_deleted_at = coalesce(source_deleted_at, ?), updated_at = ? WHERE id = ?")
236
+ .run(now, now, eventId);
237
+ else
238
+ database.prepare("UPDATE events SET status = 'cancelled', updated_at = ? WHERE id = ?").run(now, eventId);
239
+ return;
240
+ }
218
241
  const cancelled = entries.every((row) => row.status === "cancelled");
219
242
  const series = preferred.series_entry_id == null ? null : entryRow(Number(preferred.series_entry_id));
220
243
  const startsAt = int(preferred.starts_at) ?? int(event.starts_at);
@@ -259,6 +282,42 @@ export const eventStoreOver = (context) => {
259
282
  for (const email of stale)
260
283
  drop.run(entryId, email);
261
284
  };
285
+ /**
286
+ * The entry's Drive files as `attached` links: the list given replaces the one held. A link still waiting for its
287
+ * document tries again, since the file may have become one account's since.
288
+ */
289
+ const saveAttachments = (entryId, attachments, now) => {
290
+ const held = new Map(database
291
+ .prepare("SELECT id, target_text, to_id FROM links WHERE from_type = 'calendar_entry' AND from_id = ? AND kind = 'attached'")
292
+ .all(entryId)
293
+ .map((row) => [String(row.target_text), { id: Number(row.id), documentId: int(row.to_id) }]));
294
+ const insert = database.prepare(`INSERT INTO links (from_type, from_id, to_type, to_id, kind, source, target_text, metadata, confirmed, author,
295
+ created_at, updated_at) VALUES ('calendar_entry', ?, 'document', ?, 'attached', 'calendar', ?, ?, 1, 'rule', ?, ?)`);
296
+ const retitle = database.prepare("UPDATE links SET metadata = ?, updated_at = ? WHERE id = ? AND metadata IS NOT ?");
297
+ const resolve = database.prepare("UPDATE links SET to_id = ?, updated_at = ? WHERE id = ?");
298
+ const handled = new Set();
299
+ for (const { fileId, title } of attachments) {
300
+ const file = fileId.trim();
301
+ const target = driveFileTarget(file);
302
+ if (!file || handled.has(target))
303
+ continue;
304
+ handled.add(target);
305
+ const metadata = title ? JSON.stringify({ title }) : null;
306
+ const link = held.get(target);
307
+ if (link === undefined)
308
+ insert.run(entryId, driveDocumentOf(database, file), target, metadata, now, now);
309
+ else {
310
+ retitle.run(metadata, now, link.id, metadata);
311
+ const document = link.documentId === null ? driveDocumentOf(database, file) : null;
312
+ if (document !== null)
313
+ resolve.run(document, now, link.id);
314
+ }
315
+ held.delete(target);
316
+ }
317
+ const drop = database.prepare("DELETE FROM links WHERE id = ?");
318
+ for (const { id } of held.values())
319
+ drop.run(id);
320
+ };
262
321
  /** Rules 1 and 2: the row this entry already is, if any. */
263
322
  const existingFor = (calendarId, input, seriesEntryId) => {
264
323
  const same = database
@@ -351,6 +410,8 @@ export const eventStoreOver = (context) => {
351
410
  }
352
411
  if (input.participants)
353
412
  saveParticipants(id, input.participants, now);
413
+ if (input.attachments)
414
+ saveAttachments(id, input.attachments, now);
354
415
  if (input.kind === "series")
355
416
  database
356
417
  .prepare("UPDATE calendar_entries SET series_entry_id = ?, updated_at = ? WHERE calendar_id = ? AND kind = 'occurrence' " +
@@ -421,11 +482,18 @@ export const eventStoreOver = (context) => {
421
482
  }
422
483
  return entries.map((input) => entryOf(entryRow(ids.get(input))));
423
484
  }),
424
- markUnseen: async (calendarId, seenBefore, window, now) => write(database, () => database
425
- .prepare(`UPDATE calendar_entries SET deleted_at = ?, updated_at = ? WHERE calendar_id = ? AND deleted_at IS NULL
426
- AND (seen_at IS NULL OR seen_at < ?)
427
- AND (kind <> 'occurrence' OR exception = 1 OR (original_starts_at >= ? AND original_starts_at < ?))`)
428
- .run(now, now, calendarId, seenBefore, window.from, window.to).changes),
485
+ markUnseen: async (calendarId, seenBefore, window, now) => write(database, () => {
486
+ const marked = database
487
+ .prepare(`UPDATE calendar_entries SET deleted_at = ?, updated_at = ? WHERE calendar_id = ? AND deleted_at IS NULL
488
+ AND (seen_at IS NULL OR seen_at < ?)
489
+ AND (kind <> 'occurrence' OR exception = 1 OR (original_starts_at >= ? AND original_starts_at < ?))
490
+ RETURNING event_id`)
491
+ .all(now, now, calendarId, seenBefore, window.from, window.to);
492
+ for (const eventId of new Set(marked.map((row) => int(row.event_id))))
493
+ if (eventId !== null)
494
+ refreshEvent(eventId, now);
495
+ return marked.length;
496
+ }),
429
497
  events: async (filter = {}) => {
430
498
  const clauses = ["e.deleted_at IS NULL"];
431
499
  const params = [];
@@ -456,7 +524,7 @@ export const eventStoreOver = (context) => {
456
524
  if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0)
457
525
  throw new CliError("validation_error", "limit takes 1–1000 and offset 0 or more");
458
526
  return database
459
- .prepare(`SELECT e.* FROM events e WHERE ${clauses.join(" AND ")} ORDER BY e.starts_at, e.id LIMIT ? OFFSET ?`)
527
+ .prepare(`SELECT e.* FROM events e WHERE ${clauses.join(" AND ")} ORDER BY ${filter.newest ? "e.starts_at DESC, e.id DESC" : "e.starts_at, e.id"} LIMIT ? OFFSET ?`)
460
528
  .all(...params, limit, offset)
461
529
  .map(eventOf);
462
530
  },
@@ -480,6 +548,12 @@ export const eventStoreOver = (context) => {
480
548
  .prepare("SELECT * FROM calendar_entry_revisions WHERE entry_id IN (SELECT value FROM json_each(?)) ORDER BY created_at, id")
481
549
  .all(ids)
482
550
  .map(revisionOf),
551
+ attachments: database
552
+ .prepare("SELECT l.from_id, l.target_text, CASE WHEN d.deleted_at IS NULL THEN l.to_id END AS to_id, l.metadata " +
553
+ "FROM links l LEFT JOIN documents d ON d.id = l.to_id WHERE l.from_type = 'calendar_entry' " +
554
+ "AND l.kind = 'attached' AND l.from_id IN (SELECT value FROM json_each(?)) ORDER BY l.from_id, l.id")
555
+ .all(ids)
556
+ .map(attachmentOf),
483
557
  };
484
558
  }),
485
559
  linkEntry: async (entryId, eventId, now) => write(database, () => {
@@ -0,0 +1,66 @@
1
+ import type { Event } from "../../meetings/index.js";
2
+ import { type NoteIndexState } from "./note-index.js";
3
+ import { type CorpusQuery } from "./note-search.js";
4
+ import type { StoreContext } from "./open.js";
5
+ /** The fields an event has; a message or note field is refused rather than matching nothing. */
6
+ export declare const EVENT_FIELDS: readonly ["text", "exact", "body", "title", "date", "in"];
7
+ /**
8
+ * `accountIds` are stored account ids; `limit` and `offset` count hits, one per series. `candidates` is how many
9
+ * matching events are read before they are grouped (50,000 by default).
10
+ */
11
+ export type EventQuery = CorpusQuery & {
12
+ candidates?: number;
13
+ };
14
+ export interface EventHit {
15
+ /** `event:<id>`, of the occurrence shown. */
16
+ ref: string;
17
+ /** The occurrence shown: of a series' matches, the one nearest now that is not cancelled. */
18
+ event: Event;
19
+ /** What the words were matched in besides the title: description, place, attendees. */
20
+ text: string;
21
+ /** bm25 of the words or stems; lower is better. `null` when ranked by time. */
22
+ relevance: number | null;
23
+ exact?: boolean;
24
+ /** How many occurrences of its series matched; 1 for an event that does not repeat. */
25
+ occurrences: number;
26
+ /** The calendar account it is in, the first of several; `null` for an event no calendar holds (a Zoom one). */
27
+ account: {
28
+ provider: string;
29
+ account: string;
30
+ } | null;
31
+ /** The event's page at the calendar. */
32
+ url: string | null;
33
+ }
34
+ export interface EventChunkToEmbed {
35
+ hash: string;
36
+ text: string;
37
+ }
38
+ export interface NearestEvent extends Omit<EventHit, "relevance" | "exact"> {
39
+ /** Cosine of the best piece of its series; higher is nearer. */
40
+ score: number;
41
+ }
42
+ export interface EventSearch {
43
+ /** Indexes what was written since, then searches: the occurrences of one series are one hit. */
44
+ search(query: EventQuery): Promise<{
45
+ items: EventHit[];
46
+ hasMore: boolean;
47
+ /** More events matched than `candidates`: the ones past it were not read, so the last page may not be the end. */
48
+ truncated: boolean;
49
+ }>;
50
+ eventIndexState(): Promise<NoteIndexState>;
51
+ /** Live events' pieces with no vector of `model`, by hash from `after`, each with its text cut again. */
52
+ chunksToEmbed(model: string, options: {
53
+ after?: string;
54
+ limit: number;
55
+ }): Promise<EventChunkToEmbed[]>;
56
+ /** Whether any live event with a searchable copy in these accounts has a vector of `model`. */
57
+ embedded(model: string, options?: {
58
+ accountIds?: string[];
59
+ }): Promise<boolean>;
60
+ /** Events nearest in meaning to `query`, one hit per series, best first. Vectors are unit length. */
61
+ nearest(model: string, query: Float32Array, options: {
62
+ limit: number;
63
+ accountIds?: string[];
64
+ }): Promise<NearestEvent[]>;
65
+ }
66
+ export declare const eventSearchOver: (context: StoreContext) => EventSearch;
@@ -0,0 +1,163 @@
1
+ import { QUERY_LIMITS } from "../../search/lucene/types.js";
2
+ import { eventOf } from "./events.js";
3
+ import { CORPORA, corpusIndexState, drainCorpus, EVENT_LIVE, EVENT_TEXT, noteIndexText, searchableCopy, } from "./note-index.js";
4
+ import { searchCorpus } from "./note-search.js";
5
+ import { stemmerCache } from "./stems.js";
6
+ import { read } from "./transaction.js";
7
+ import { dot } from "./vectors.js";
8
+ /** The fields an event has; a message or note field is refused rather than matching nothing. */
9
+ export const EVENT_FIELDS = ["text", "exact", "body", "title", "date", "in"];
10
+ /**
11
+ * Whether the event `event` (an SQL expression of its id) has a searchable copy in the accounts of the one parameter,
12
+ * a JSON list of stored account ids. Words and meaning both filter by it, so an event held in several accounts is
13
+ * found through each of them.
14
+ */
15
+ const inEventAccounts = (event) => searchableCopy(event, "c.account_id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))");
16
+ const EVENT_CORPUS = {
17
+ type: "event",
18
+ noun: "event",
19
+ fields: EVENT_FIELDS,
20
+ view: `(SELECT e.id, NULL AS folder_id, NULL AS path, e.title, ${EVENT_TEXT} AS text, NULL AS metadata, ` +
21
+ `e.starts_at AS at, e.deleted_at, ${EVENT_LIVE} AS live FROM events e)`,
22
+ words: "event_words",
23
+ stems: "event_stems",
24
+ live: "n.live = 1",
25
+ inAccounts: (ids) => ({
26
+ sql: inEventAccounts("n.id"),
27
+ params: [ids],
28
+ exact: true,
29
+ }),
30
+ };
31
+ const SCAN_PAGE = 5_000;
32
+ export const eventSearchOver = (context) => {
33
+ const { database } = context;
34
+ const stemmerFor = stemmerCache();
35
+ const drain = () => drainCorpus(database, CORPORA.event, stemmerFor);
36
+ /** The live event pieces with a searchable copy in these accounts, as SQL over chunks `k`. */
37
+ const live = (accountIds) => {
38
+ const sql = ` AND k.chunkable_type = 'event' AND EXISTS (SELECT 1 FROM events e WHERE e.id = k.chunkable_id AND ${EVENT_LIVE})`;
39
+ return accountIds?.length
40
+ ? { sql: `${sql} AND ${inEventAccounts("k.chunkable_id")}`, params: [JSON.stringify(accountIds)] }
41
+ : { sql, params: [] };
42
+ };
43
+ /** Events in rank order, as hits: one per series, at its occurrence nearest now that is not cancelled. */
44
+ const grouped = (rows, accountIds) => {
45
+ const facts = new Map(database
46
+ .prepare("SELECT id, event_series_id AS series, starts_at, status FROM events WHERE id IN (SELECT value FROM json_each(?))")
47
+ .all(JSON.stringify(rows.map(({ id }) => id)))
48
+ .map((row) => [Number(row.id), row]));
49
+ const groups = new Map();
50
+ for (const row of rows) {
51
+ const fact = facts.get(row.id);
52
+ if (!fact)
53
+ continue;
54
+ const key = fact.series == null ? `e${row.id}` : `s${fact.series}`;
55
+ const group = groups.get(key) ?? { first: row, members: [] };
56
+ group.members.push(fact);
57
+ groups.set(key, group);
58
+ }
59
+ const now = context.now();
60
+ const nearest = (members) => members.toSorted((a, b) => Number(a.status === "cancelled") - Number(b.status === "cancelled") ||
61
+ Math.abs(Number(a.starts_at ?? 0) - now) - Math.abs(Number(b.starts_at ?? 0) - now) ||
62
+ Number(a.id) - Number(b.id))[0];
63
+ const event = database.prepare(`SELECT e.*, ${EVENT_TEXT} AS text FROM events e WHERE e.id = ?`);
64
+ const place = database.prepare("SELECT a.provider, a.external_id AS account, ce.url FROM calendar_entries ce " +
65
+ "JOIN calendars c ON c.id = ce.calendar_id JOIN accounts a ON a.id = c.account_id " +
66
+ "WHERE ce.event_id = ? " +
67
+ "ORDER BY c.account_id NOT IN (SELECT CAST(value AS INTEGER) FROM json_each(?)), ce.deleted_at IS NOT NULL, ce.id LIMIT 1");
68
+ const chosen = JSON.stringify(accountIds ?? []);
69
+ return [...groups.values()].map(({ first, members }) => {
70
+ const id = Number(nearest(members).id);
71
+ const row = event.get(id);
72
+ const at = place.get(id, chosen);
73
+ return {
74
+ first,
75
+ hit: {
76
+ ref: `event:${id}`,
77
+ event: eventOf(row),
78
+ text: String(row.text ?? ""),
79
+ occurrences: members.length,
80
+ account: at ? { provider: String(at.provider), account: String(at.account) } : null,
81
+ url: at?.url == null ? null : String(at.url),
82
+ },
83
+ };
84
+ });
85
+ };
86
+ return {
87
+ search: async (query) => {
88
+ drain();
89
+ const offset = query.offset ?? 0;
90
+ const candidates = query.candidates ?? QUERY_LIMITS.candidates;
91
+ const matched = searchCorpus(context, { ...query, limit: candidates, offset: 0 }, EVENT_CORPUS);
92
+ const truncated = matched.length > candidates;
93
+ const rows = truncated ? matched.slice(0, candidates) : matched;
94
+ return read(database, () => {
95
+ const groups = grouped(rows, query.accountIds);
96
+ return {
97
+ items: groups.slice(offset, offset + query.limit).map(({ first, hit }) => ({
98
+ ...hit,
99
+ relevance: first.relevance,
100
+ ...(first.exact === undefined ? {} : { exact: first.exact }),
101
+ })),
102
+ hasMore: groups.length > offset + query.limit,
103
+ truncated,
104
+ };
105
+ });
106
+ },
107
+ eventIndexState: async () => corpusIndexState(database, CORPORA.event),
108
+ chunksToEmbed: async (model, { after, limit }) => {
109
+ drain();
110
+ const scope = live(undefined);
111
+ const text = database.prepare(`SELECT e.title, ${EVENT_TEXT} AS body FROM events e WHERE e.id = ?`);
112
+ return database
113
+ .prepare(`SELECT k.content_hash AS hash, min(k.chunkable_id) AS owner, k.start_offset AS start, k.end_offset AS end
114
+ FROM chunks k
115
+ WHERE k.content_hash > ?${scope.sql}
116
+ AND NOT EXISTS (SELECT 1 FROM embeddings v WHERE v.model = ? AND v.content_hash = k.content_hash)
117
+ GROUP BY k.content_hash ORDER BY k.content_hash LIMIT ?`)
118
+ .all(after ?? "", ...scope.params, model, limit)
119
+ .flatMap((row) => {
120
+ const event = text.get(Number(row.owner));
121
+ if (!event)
122
+ return [];
123
+ const whole = noteIndexText(event.title == null ? null : String(event.title), String(event.body));
124
+ return [{ hash: String(row.hash), text: whole.slice(Number(row.start), Number(row.end)) }];
125
+ });
126
+ },
127
+ embedded: async (model, { accountIds } = {}) => {
128
+ drain();
129
+ const scope = live(accountIds);
130
+ return (database
131
+ .prepare(`SELECT 1 FROM chunks k JOIN embeddings v ON v.model = ? AND v.content_hash = k.content_hash
132
+ WHERE 1 = 1${scope.sql} LIMIT 1`)
133
+ .get(model, ...scope.params) !== undefined);
134
+ },
135
+ nearest: async (model, query, { limit, accountIds }) => {
136
+ drain();
137
+ const scope = live(accountIds);
138
+ const page = database.prepare(`SELECT k.id, k.chunkable_id AS owner, v.vector
139
+ FROM chunks k JOIN embeddings v ON v.model = ? AND v.content_hash = k.content_hash
140
+ WHERE k.id > ?${scope.sql}
141
+ ORDER BY k.id LIMIT ?`);
142
+ const best = new Map();
143
+ let after = 0;
144
+ for (;;) {
145
+ const rows = page.all(model, after, ...scope.params, SCAN_PAGE);
146
+ for (const row of rows) {
147
+ const score = dot(query, row.vector);
148
+ const owner = Number(row.owner);
149
+ if (score > (best.get(owner) ?? Number.NEGATIVE_INFINITY))
150
+ best.set(owner, score);
151
+ }
152
+ const last = rows.at(-1);
153
+ if (!last || rows.length < SCAN_PAGE)
154
+ break;
155
+ after = Number(last.id);
156
+ }
157
+ const ranked = [...best.entries()].map(([id, score]) => ({ id, score })).sort((a, b) => b.score - a.score);
158
+ return read(database, () => grouped(ranked, accountIds)
159
+ .slice(0, limit)
160
+ .map(({ first, hit }) => ({ ...hit, score: first.score })));
161
+ },
162
+ };
163
+ };
@@ -15,6 +15,10 @@ interface Placed {
15
15
  export declare const logFileLink: (database: CacheDatabase, now: number, fileId: number, from: unknown, to: number | null, method?: string, author?: string) => {
16
16
  changes: number;
17
17
  };
18
+ /** How an `attached` link names a Drive file it waits for: by its Drive id, since no row may hold it yet. */
19
+ export declare const driveFileTarget: (fileId: string) => string;
20
+ /** The document of the Drive file with this id; `null` when no Drive account holds it, or two do. */
21
+ export declare const driveDocumentOf: (database: CacheDatabase, fileId: string) => number | null;
18
22
  /**
19
23
  * Writes the `files` row of one document from the document as it is now: the document's source record. A Drive
20
24
  * document (catalog metadata or Drive front matter) is keyed by its Drive file id and placed under its first parent;
@@ -48,6 +48,30 @@ export const logFileLink = (database, now, fileId, from, to, method = "import",
48
48
  .prepare("INSERT INTO entity_link_events (linkable_type, linkable_id, entity_type, from_entity_id, to_entity_id, method, " +
49
49
  "confidence, author, created_at) VALUES ('file', ?, 'document', ?, ?, ?, 1, ?, ?)")
50
50
  .run(fileId, from == null ? null : Number(from), to, method, author, now);
51
+ /** How an `attached` link names a Drive file it waits for: by its Drive id, since no row may hold it yet. */
52
+ export const driveFileTarget = (fileId) => `google-drive:${fileId}`;
53
+ /** The document of the Drive file with this id; `null` when no Drive account holds it, or two do. */
54
+ export const driveDocumentOf = (database, fileId) => {
55
+ const rows = database
56
+ .prepare("SELECT DISTINCT f.document_id AS id FROM files f JOIN accounts a ON a.id = f.account_id WHERE a.provider = 'google-drive' " +
57
+ "AND f.external_id = ? AND f.deleted_at IS NULL AND f.document_id IS NOT NULL LIMIT 2")
58
+ .all(fileId);
59
+ return rows.length === 1 ? Number(rows[0]?.id) : null;
60
+ };
61
+ /** Calendar entries that named this Drive file before Drive stored it now link its document. */
62
+ const linkAttached = (database, now, fileId) => {
63
+ const target = driveFileTarget(fileId);
64
+ if (!database
65
+ .prepare("SELECT 1 FROM links WHERE to_type = 'document' AND to_id IS NULL AND target_text = ? AND kind = 'attached'")
66
+ .get(target))
67
+ return;
68
+ const document = driveDocumentOf(database, fileId);
69
+ if (document !== null)
70
+ database
71
+ .prepare("UPDATE links SET to_id = ?, updated_at = ? WHERE to_type = 'document' AND to_id IS NULL AND target_text = ? " +
72
+ "AND kind = 'attached'")
73
+ .run(document, now, target);
74
+ };
51
75
  /** Moves the rows under a folder whose path changed, in one statement over the old prefix. */
52
76
  const movePaths = (database, accountId, from, to) => {
53
77
  database
@@ -136,6 +160,8 @@ export const syncFile = (database, now, documentId, { seen = true, depth = 0, pl
136
160
  .get(...Object.values(values), Number(doc.created_at), now);
137
161
  const placed = { id: Number(row?.id), path };
138
162
  logFileLink(database, now, placed.id, null, documentId, method);
163
+ if (remote)
164
+ linkAttached(database, now, externalId);
139
165
  if (remote && kind === "folder")
140
166
  placeChildren(database, now, accountId, String(doc.external_id), depth);
141
167
  return placed;
@@ -152,6 +178,8 @@ export const syncFile = (database, now, documentId, { seen = true, depth = 0, pl
152
178
  .run(...Object.values(values), changed ? now : Number(existing.updated_at), id);
153
179
  if (kind === "folder" && existing.path !== path)
154
180
  movePaths(database, accountId, String(existing.path), path);
181
+ if (remote && changed)
182
+ linkAttached(database, now, externalId);
155
183
  }
156
184
  return { id, path };
157
185
  };
@@ -2,8 +2,12 @@
2
2
  * Every kind a `links` row may have. The schema doc's description of `links` names exactly these
3
3
  * (`schema.test.ts` checks it); a writer that needs a new kind adds it here first.
4
4
  */
5
- export declare const LINK_KINDS: readonly ["links-to", "blocked-by", "follow-up-of", "about", "member-of", "labelled", "answered-by", "evidence", "created-from", "duplicate-of", "related-to", "assigned-to"];
5
+ export declare const LINK_KINDS: readonly ["links-to", "blocked-by", "follow-up-of", "about", "member-of", "labelled", "answered-by", "evidence", "created-from", "duplicate-of", "related-to", "assigned-to", "attached"];
6
6
  export type LinkKind = (typeof LINK_KINDS)[number];
7
+ /** Where a caller may say a link came from. */
8
+ export declare const LINK_ORIGINS: readonly ["file", "owner", "suggested", "message"];
9
+ /** Where a stored link came from: an origin a caller gives, or `calendar`, which only the store writes. */
10
+ export declare const LINK_SOURCES: readonly ["file", "owner", "suggested", "message", "calendar"];
7
11
  /** The kinds `knowledge.relate` makes between people, organizations and projects. */
8
12
  export declare const RELATION_KINDS: readonly ["member-of", "related-to"];
9
13
  /** A kind as an SQL literal, so a kind written into a query is checked against the list. */
@@ -15,7 +15,12 @@ export const LINK_KINDS = [
15
15
  "duplicate-of",
16
16
  "related-to",
17
17
  "assigned-to",
18
+ "attached",
18
19
  ];
20
+ /** Where a caller may say a link came from. */
21
+ export const LINK_ORIGINS = ["file", "owner", "suggested", "message"];
22
+ /** Where a stored link came from: an origin a caller gives, or `calendar`, which only the store writes. */
23
+ export const LINK_SOURCES = [...LINK_ORIGINS, "calendar"];
19
24
  /** The kinds `knowledge.relate` makes between people, organizations and projects. */
20
25
  export const RELATION_KINDS = ["member-of", "related-to"];
21
26
  /** A kind as an SQL literal, so a kind written into a query is checked against the list. */
@@ -31,6 +31,23 @@ export interface Corpus {
31
31
  stems: string;
32
32
  };
33
33
  }
34
+ /** What an event `e` is searched by besides its title: the cleaned description, the place and the attendees' names. */
35
+ export declare const EVENT_TEXT: string;
36
+ /** Calendar entry types that are listed, never searched (requirements 3.4). */
37
+ export declare const UNSEARCHED_EVENT_TYPES: readonly ["workingLocation", "focusTime", "outOfOffice", "birthday"];
38
+ /** Visibilities whose entry, when it carries no text, is a busy-only slot: stored, never searched (requirements 3.4). */
39
+ export declare const BUSY_ONLY_VISIBILITIES: readonly ["private", "confidential"];
40
+ /**
41
+ * Whether the event `event` (an SQL expression of its id) has a searchable calendar copy, and with `account` one in
42
+ * those accounts (an SQL condition over `c.account_id`). Only live copies count, unless none is: an event its
43
+ * calendars deleted keeps its past copies as history.
44
+ */
45
+ export declare const searchableCopy: (event: string, account?: string) => string;
46
+ /**
47
+ * Whether an event `e` is searched at all: by words, by meaning and in `search all`. The one place that names what
48
+ * is left out. An event no calendar holds (a Zoom meeting's own) is searched; a cancelled one is not.
49
+ */
50
+ export declare const EVENT_LIVE: string;
34
51
  export declare const CORPORA: {
35
52
  readonly document: {
36
53
  readonly type: "document";
@@ -81,7 +98,6 @@ export declare const CORPORA: {
81
98
  readonly index: "event_index";
82
99
  readonly table: "events";
83
100
  readonly read: string;
84
- readonly pieces: false;
85
101
  };
86
102
  };
87
103
  /** What a row is indexed as: its title, then its text. Chunk offsets point into this. */
@@ -9,6 +9,34 @@ import { speech } from "./lucene-fields.js";
9
9
  import { savedStemmers } from "./stems.js";
10
10
  import { write } from "./transaction.js";
11
11
  import { dropUnusedVectors } from "./vector-index.js";
12
+ /** What an event `e` is searched by besides its title: the cleaned description, the place and the attendees' names. */
13
+ export const EVENT_TEXT = "trim(coalesce(e.description, '') || ' ' || coalesce(e.location, '') || ' ' || coalesce((" +
14
+ "SELECT group_concat(p.name, ' ') FROM calendar_entries ce JOIN calendar_entry_participants p ON p.entry_id = ce.id " +
15
+ "WHERE ce.event_id = e.id AND ce.deleted_at IS NULL AND p.resource = 0 AND p.name IS NOT NULL), ''))";
16
+ /** Calendar entry types that are listed, never searched (requirements 3.4). */
17
+ export const UNSEARCHED_EVENT_TYPES = ["workingLocation", "focusTime", "outOfOffice", "birthday"];
18
+ /** Visibilities whose entry, when it carries no text, is a busy-only slot: stored, never searched (requirements 3.4). */
19
+ export const BUSY_ONLY_VISIBILITIES = ["private", "confidential"];
20
+ const sqlList = (values) => values.map((value) => `'${value}'`).join(", ");
21
+ /** A calendar copy `ce` that may be searched: of a searchable type, and not a busy-only slot. */
22
+ const SEARCHABLE_COPY = `(ce.event_type IS NULL OR ce.event_type NOT IN (${sqlList(UNSEARCHED_EVENT_TYPES)})) ` +
23
+ `AND NOT (coalesce(ce.visibility, '') IN (${sqlList(BUSY_ONLY_VISIBILITIES)}) AND trim(coalesce(ce.title, '')) = '' ` +
24
+ "AND trim(coalesce(ce.description, '')) = '' AND trim(coalesce(ce.location, '')) = '')";
25
+ /**
26
+ * Whether the event `event` (an SQL expression of its id) has a searchable calendar copy, and with `account` one in
27
+ * those accounts (an SQL condition over `c.account_id`). Only live copies count, unless none is: an event its
28
+ * calendars deleted keeps its past copies as history.
29
+ */
30
+ export const searchableCopy = (event, account) => `EXISTS (SELECT 1 FROM calendar_entries ce${account ? " JOIN calendars c ON c.id = ce.calendar_id" : ""} ` +
31
+ `WHERE ce.event_id = ${event} AND ${SEARCHABLE_COPY}${account ? ` AND ${account}` : ""} ` +
32
+ `AND (ce.deleted_at IS NULL OR NOT EXISTS (SELECT 1 FROM calendar_entries l WHERE l.event_id = ${event} ` +
33
+ "AND l.deleted_at IS NULL)))";
34
+ /**
35
+ * Whether an event `e` is searched at all: by words, by meaning and in `search all`. The one place that names what
36
+ * is left out. An event no calendar holds (a Zoom meeting's own) is searched; a cancelled one is not.
37
+ */
38
+ export const EVENT_LIVE = "(e.deleted_at IS NULL AND coalesce(e.status, '') <> 'cancelled' AND " +
39
+ `(NOT EXISTS (SELECT 1 FROM calendar_entries ce WHERE ce.event_id = e.id) OR ${searchableCopy("e.id")}))`;
12
40
  export const CORPORA = {
13
41
  document: {
14
42
  type: "document",
@@ -60,15 +88,11 @@ export const CORPORA = {
60
88
  type: "event",
61
89
  index: "event_index",
62
90
  table: "events",
63
- // Working location, focus time, out of office and birthdays are listed, never searched (requirements 3.4).
64
- read: "SELECT e.title, trim(coalesce(e.description, '') || ' ' || coalesce(e.location, '') || ' ' || coalesce((" +
65
- "SELECT group_concat(p.name, ' ') FROM calendar_entries ce JOIN calendar_entry_participants p ON p.entry_id = ce.id " +
66
- "WHERE ce.event_id = e.id AND ce.deleted_at IS NULL AND p.resource = 0 AND p.name IS NOT NULL), '')) AS body, " +
67
- "e.deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM calendar_entries ce WHERE ce.event_id = e.id " +
68
- "AND ce.deleted_at IS NULL AND ce.event_type IN ('workingLocation', 'focusTime', 'outOfOffice', 'birthday')) AS live, " +
91
+ read: `SELECT e.title, ${EVENT_TEXT} AS body, ${EVENT_LIVE} AS live, ` +
69
92
  "coalesce((SELECT a.scope FROM calendar_entries ce JOIN calendars c ON c.id = ce.calendar_id " +
70
- "JOIN accounts a ON a.id = c.account_id WHERE ce.event_id = e.id LIMIT 1), 'personal') AS scope FROM events e WHERE e.id = ?",
71
- pieces: false,
93
+ "JOIN accounts a ON a.id = c.account_id WHERE ce.event_id = e.id LIMIT 1), 'personal') AS scope, " +
94
+ "(SELECT c.account_id FROM calendar_entries ce JOIN calendars c ON c.id = ce.calendar_id " +
95
+ "WHERE ce.event_id = e.id AND ce.deleted_at IS NULL ORDER BY ce.id LIMIT 1) AS account_id FROM events e WHERE e.id = ?",
72
96
  },
73
97
  };
74
98
  /** What a row is indexed as: its title, then its text. Chunk offsets point into this. */