@wirecat/cli-core 0.451.0 → 0.453.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 (50) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/cli/messenger/search-answers.d.ts +1 -1
  3. package/dist/cli/messenger/search-answers.js +1 -1
  4. package/dist/cli/messenger/search-command.js +6 -3
  5. package/dist/entities/contact-context.d.ts +42 -0
  6. package/dist/entities/contact-context.js +11 -0
  7. package/dist/entities/hit.d.ts +3 -3
  8. package/dist/entities/hit.js +3 -3
  9. package/dist/entities/index.d.ts +61 -21
  10. package/dist/entities/task-results.d.ts +20 -20
  11. package/dist/entities/task-results.js +3 -15
  12. package/dist/mcp/instructions.d.ts +1 -1
  13. package/dist/mcp/instructions.js +5 -5
  14. package/dist/meetings/event-store.d.ts +23 -0
  15. package/dist/meetings/index.d.ts +1 -1
  16. package/dist/search/query.js +1 -3
  17. package/dist/services/events-search.d.ts +67 -0
  18. package/dist/services/events-search.js +131 -0
  19. package/dist/services/index.d.ts +3 -2
  20. package/dist/services/index.js +2 -1
  21. package/dist/services/notes-search.d.ts +25 -1
  22. package/dist/services/notes-search.js +24 -11
  23. package/dist/services/person-context.d.ts +20 -1
  24. package/dist/services/person-context.js +46 -1
  25. package/dist/services/search-all-meetings.d.ts +1 -1
  26. package/dist/services/search-all-meetings.js +7 -2
  27. package/dist/services/search-all.d.ts +12 -7
  28. package/dist/services/search-all.js +39 -4
  29. package/dist/store/index.d.ts +1 -0
  30. package/dist/store/index.js +1 -0
  31. package/dist/store/project-transfer/request.d.ts +1 -1
  32. package/dist/store/project-transfer/schema.d.ts +2 -2
  33. package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
  34. package/dist/store/sqlite/calendar.js +85 -11
  35. package/dist/store/sqlite/event-search.d.ts +66 -0
  36. package/dist/store/sqlite/event-search.js +163 -0
  37. package/dist/store/sqlite/files.d.ts +4 -0
  38. package/dist/store/sqlite/files.js +28 -0
  39. package/dist/store/sqlite/link-kinds.d.ts +5 -1
  40. package/dist/store/sqlite/link-kinds.js +5 -0
  41. package/dist/store/sqlite/note-index.d.ts +17 -1
  42. package/dist/store/sqlite/note-index.js +32 -8
  43. package/dist/store/sqlite/note-search.d.ts +43 -0
  44. package/dist/store/sqlite/note-search.js +24 -24
  45. package/dist/store/sqlite/notes.d.ts +4 -5
  46. package/dist/store/sqlite/notes.js +2 -2
  47. package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
  48. package/dist/store/store.d.ts +2 -1
  49. package/dist/store/store.js +2 -1
  50. package/package.json +1 -1
@@ -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. */
@@ -1,5 +1,6 @@
1
1
  import type { ResolvedNode } from "../../search/lucene/resolved.js";
2
2
  import type { Stemmer } from "../../search/stem.js";
3
+ import type { SqlValue } from "../driver.js";
3
4
  import { type DocumentPictureStore } from "./document-pictures.js";
4
5
  import { type NoteIndexState } from "./note-index.js";
5
6
  import { type Note } from "./notes.js";
@@ -28,11 +29,52 @@ export interface NoteHit {
28
29
  }
29
30
  /** The fields a note has. A message-only field (`from:`, `chat:`, …) is refused rather than matching nothing. */
30
31
  export declare const NOTE_FIELDS: readonly ["text", "exact", "body", "tag", "date", "in", "has", "title", "site", "lang", "author", "keyword", "source"];
32
+ interface Fragment {
33
+ sql: string;
34
+ params: SqlValue[];
35
+ /** False when the SQL only narrows the candidates and a JS test decides. */
36
+ exact: boolean;
37
+ fts?: string;
38
+ stems?: string;
39
+ }
40
+ /**
41
+ * Files in notes folders, the notes the owner wrote and calendar events: one query language, each in its own table
42
+ * and index.
43
+ */
44
+ export interface Corpus {
45
+ type: "document" | "note" | "event";
46
+ /** What a hit is called in an error and in `in:` (`in:notes`, `in:events`). */
47
+ noun: "note" | "event";
48
+ fields: readonly string[];
49
+ /** The rows as `n`, with the columns the query reads under one set of names; `at` is the time `date:` reads. */
50
+ view: string;
51
+ words: string;
52
+ stems: string;
53
+ /** The rows a search may return at all, besides `deleted_at`. */
54
+ live?: string;
55
+ /** The rows of these accounts, as a JSON list of ids. */
56
+ inAccounts?: (ids: string) => Fragment;
57
+ /** The tagged rows; a document is also labelled through its folder or a subfolder of it. */
58
+ tagged?: (tag: string) => Fragment;
59
+ }
60
+ export interface CorpusRow {
61
+ type: Corpus["type"];
62
+ id: number;
63
+ /** The time `date:` reads: a note's last change, an event's start. */
64
+ at: number | null;
65
+ relevance: number | null;
66
+ exact?: boolean;
67
+ }
68
+ /** What one corpus is searched by; `accountIds` narrows a corpus that has accounts. */
69
+ export type CorpusQuery = Pick<NoteQuery, "root" | "limit" | "offset" | "newest" | "stemmer" | "signal"> & {
70
+ accountIds?: string[];
71
+ };
31
72
  /** Notes matching a parsed query, by the same language as messages: files and written notes, merged. */
32
73
  export declare const searchNotes: (context: StoreContext, query: NoteQuery) => {
33
74
  items: NoteHit[];
34
75
  hasMore: boolean;
35
76
  };
77
+ export declare const searchCorpus: (context: StoreContext, query: CorpusQuery, corpus: Corpus) => CorpusRow[];
36
78
  export interface NoteChunkToEmbed {
37
79
  hash: string;
38
80
  text: string;
@@ -75,3 +117,4 @@ export interface NoteSearch extends DocumentPictureStore {
75
117
  }): Promise<NearestNote[]>;
76
118
  }
77
119
  export declare const noteSearchOver: (context: StoreContext) => NoteSearch;
120
+ export {};
@@ -40,9 +40,13 @@ const combine = (parts, operator) => ({
40
40
  const CORPORA = [
41
41
  {
42
42
  type: "document",
43
- view: "(SELECT id, account_id AS folder_id, external_id AS path, title, coalesce(body, '') AS text, metadata, updated_at, deleted_at FROM documents)",
43
+ noun: "note",
44
+ 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)",
44
46
  words: "document_words",
45
47
  stems: "document_stems",
48
+ live: "coalesce(json_extract(n.metadata, '$.catalog.active'), 1)=1",
49
+ inAccounts: (ids) => bound("n.folder_id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))", ids),
46
50
  tagged: (tag) => bound("(n.id IN (SELECT g.taggable_id FROM taggings g JOIN resolved_tags t ON t.id=g.tag_id WHERE t.name = ? AND g.taggable_type = 'document') " +
47
51
  "OR n.folder_id IN (SELECT g.taggable_id FROM taggings g JOIN resolved_tags t ON t.id=g.tag_id WHERE t.name = ? AND g.taggable_type = 'account') " +
48
52
  "OR EXISTS (SELECT 1 FROM links l JOIN resolved_tags t ON t.id=l.to_id WHERE l.from_type = 'account' AND l.from_id = n.folder_id " +
@@ -50,7 +54,9 @@ const CORPORA = [
50
54
  },
51
55
  {
52
56
  type: "note",
53
- view: "(SELECT id, NULL AS folder_id, NULL AS path, title, body AS text, NULL AS metadata, updated_at, deleted_at FROM notes)",
57
+ noun: "note",
58
+ fields: NOTE_FIELDS,
59
+ view: "(SELECT id, NULL AS folder_id, NULL AS path, title, body AS text, NULL AS metadata, updated_at AS at, deleted_at FROM notes)",
54
60
  words: "note_words",
55
61
  stems: "note_stems",
56
62
  tagged: (tag) => bound("n.id IN (SELECT g.taggable_id FROM taggings g JOIN resolved_tags t ON t.id=g.tag_id WHERE t.name = ? AND g.taggable_type = 'note')", tag),
@@ -61,14 +67,14 @@ export const searchNotes = (context, query) => {
61
67
  const offset = query.offset ?? 0;
62
68
  const chosen = CORPORA.filter(({ type }) => (query.source === undefined || (query.source === "file") === (type === "document")) &&
63
69
  (!query.folderIds?.length || query.withInternal === true || type === "document"));
64
- const rows = chosen.flatMap((corpus) => searchCorpus(context, query, corpus));
70
+ const rows = chosen.flatMap((corpus) => searchCorpus(context, { ...query, ...(query.folderIds ? { accountIds: query.folderIds } : {}) }, corpus));
65
71
  const exactFirst = rows.some((row) => row.exact !== undefined);
66
72
  rows.sort((a, b) => query.newest
67
- ? b.updatedAt - a.updatedAt || a.id - b.id
73
+ ? (b.at ?? 0) - (a.at ?? 0) || a.id - b.id
68
74
  : (exactFirst ? Number(b.exact === true) - Number(a.exact === true) : 0) ||
69
75
  Number(a.relevance === null) - Number(b.relevance === null) ||
70
76
  (a.relevance ?? 0) - (b.relevance ?? 0) ||
71
- b.updatedAt - a.updatedAt ||
77
+ (b.at ?? 0) - (a.at ?? 0) ||
72
78
  a.id - b.id);
73
79
  const read = {
74
80
  document: context.database.prepare("SELECT * FROM documents WHERE id = ?"),
@@ -76,7 +82,7 @@ export const searchNotes = (context, query) => {
76
82
  };
77
83
  return {
78
84
  items: rows.slice(offset, offset + query.limit).map((row) => {
79
- const found = read[row.type].get(row.id);
85
+ const found = read[row.type === "document" ? "document" : "note"].get(row.id);
80
86
  const note = row.type === "document" ? documentOf(found) : noteOf(found);
81
87
  return {
82
88
  ref: note.ref,
@@ -88,7 +94,7 @@ export const searchNotes = (context, query) => {
88
94
  hasMore: rows.length > offset + query.limit,
89
95
  };
90
96
  };
91
- const searchCorpus = (context, query, corpus) => {
97
+ export const searchCorpus = (context, query, corpus) => {
92
98
  const { database } = context;
93
99
  const wordMatch = (match) => ({
94
100
  sql: `n.id IN (SELECT rowid FROM ${corpus.words} WHERE ${corpus.words} MATCH ?)`,
@@ -116,8 +122,8 @@ const searchCorpus = (context, query, corpus) => {
116
122
  .map((row) => ({ id: Number(row.id), title: normalize(String(row.title)) })));
117
123
  const leaf = (node) => {
118
124
  const { field, operator, value } = node;
119
- if (!NOTE_FIELDS.includes(field))
120
- searchError("query.unsupported-field", node.span, `${field}: is not a note field — notes take ${NOTE_FIELDS.join(", ")}`);
125
+ if (!corpus.fields.includes(field))
126
+ searchError("query.unsupported-field", node.span, `${field}: is not ${corpus.noun === "event" ? "an" : "a"} ${corpus.noun} field — ${corpus.noun}s take ${corpus.fields.join(", ")}`);
121
127
  if ((field === "text" || field === "exact") && value === "")
122
128
  return bound("0");
123
129
  if (isStemmed(node)) {
@@ -168,7 +174,7 @@ const searchCorpus = (context, query, corpus) => {
168
174
  if (tag === undefined)
169
175
  searchError("query.invalid-tag", node.span);
170
176
  // A label on a folder or subfolder labels every note under it, at any depth.
171
- return corpus.tagged(canonicalTagNamed(database, tag)?.name ?? tag);
177
+ return corpus.tagged?.(canonicalTagNamed(database, tag)?.name ?? tag) ?? bound("0");
172
178
  }
173
179
  if (field === "source")
174
180
  return bound(value.toLowerCase() === (corpus.type === "note" ? "internal" : "file") ? "1" : "0");
@@ -199,15 +205,11 @@ const searchCorpus = (context, query, corpus) => {
199
205
  if (!range)
200
206
  searchError("query.invalid-ast", node.span);
201
207
  return combine([
202
- ...(range.lower === undefined
203
- ? []
204
- : [bound(`n.updated_at ${range.lower_inclusive ? ">=" : ">"} ?`, range.lower)]),
205
- ...(range.upper === undefined
206
- ? []
207
- : [bound(`n.updated_at ${range.upper_inclusive ? "<=" : "<"} ?`, range.upper)]),
208
+ ...(range.lower === undefined ? [] : [bound(`n.at ${range.lower_inclusive ? ">=" : ">"} ?`, range.lower)]),
209
+ ...(range.upper === undefined ? [] : [bound(`n.at ${range.upper_inclusive ? "<=" : "<"} ?`, range.upper)]),
208
210
  ], "AND");
209
211
  }
210
- return bound(["notes", "note"].includes(value.toLowerCase()) ? "1" : "0");
212
+ return bound([corpus.noun, `${corpus.noun}s`].includes(value.toLowerCase()) ? "1" : "0");
211
213
  };
212
214
  const compile = (node) => {
213
215
  if (node.kind === "predicate") {
@@ -243,10 +245,8 @@ const searchCorpus = (context, query, corpus) => {
243
245
  const stemmed = [...fragments.keys()].some(isStemmed);
244
246
  const scope = combine([
245
247
  bound("n.deleted_at IS NULL"),
246
- ...(corpus.type === "document" ? [bound("coalesce(json_extract(n.metadata, '$.catalog.active'), 1)=1")] : []),
247
- ...(query.folderIds?.length && corpus.type === "document"
248
- ? [bound("n.folder_id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))", JSON.stringify(query.folderIds))]
249
- : []),
248
+ ...(corpus.live ? [bound(corpus.live)] : []),
249
+ ...(query.accountIds?.length && corpus.inAccounts ? [corpus.inAccounts(JSON.stringify(query.accountIds))] : []),
250
250
  ], "AND");
251
251
  const where = combine([scope, expression], "AND");
252
252
  const ranking = query.newest ? undefined : required(query.root, stemmed ? "stems" : "fts");
@@ -263,8 +263,8 @@ const searchCorpus = (context, query, corpus) => {
263
263
  cteParams.push(`normalized_text : (${exactTier})`);
264
264
  }
265
265
  const order = query.newest
266
- ? "n.updated_at DESC, n.id"
267
- : `${exactTier ? "exact DESC, " : ""}${ranking ? "f.rank IS NULL, f.rank, " : ""}n.updated_at DESC, n.id`;
266
+ ? "n.at DESC, n.id"
267
+ : `${exactTier ? "exact DESC, " : ""}${ranking ? "f.rank IS NULL, f.rank, " : ""}n.at DESC, n.id`;
268
268
  const offset = query.offset ?? 0;
269
269
  // With a JS test the SQL only names candidates: every leaf's SQL answer comes along, so the test sees it.
270
270
  const leaves = [...fragments.entries()].filter(([node]) => !tests.has(node));
@@ -304,7 +304,7 @@ const searchCorpus = (context, query, corpus) => {
304
304
  return matched.map((row) => ({
305
305
  type: corpus.type,
306
306
  id: Number(row.id),
307
- updatedAt: Number(row.updated_at),
307
+ at: row.at == null ? null : Number(row.at),
308
308
  relevance: row.relevance === null || row.relevance === undefined ? null : Number(row.relevance),
309
309
  ...(exactTier ? { exact: Number(row.exact) === 1 } : {}),
310
310
  }));
@@ -2,13 +2,13 @@ import type { Section } from "../../readers/types.js";
2
2
  import type { CacheDatabase } from "../driver.js";
3
3
  import { type DocumentMetadata } from "./document-metadata.js";
4
4
  import { type ImportedDocuments } from "./imported-documents.js";
5
- import { LINK_KINDS } from "./link-kinds.js";
5
+ import { LINK_KINDS, LINK_ORIGINS, type LINK_SOURCES } from "./link-kinds.js";
6
6
  import type { NoteSearch } from "./note-search.js";
7
7
  import type { StoreContext } from "./open.js";
8
8
  import { type Label } from "./tags.js";
9
9
  import { type Thing } from "./things.js";
10
10
  export declare const NOTE_FORMATS: readonly ["obsidian", "markdown"];
11
- export declare const LINK_ORIGINS: readonly ["file", "owner", "suggested", "message"];
11
+ export { LINK_ORIGINS } from "./link-kinds.js";
12
12
  /**
13
13
  * A notes folder: an `accounts` row of provider `local` or `obsidian`. Where it is on disk is this computer's config. A
14
14
  * documents account (Drive's, one per Google account) is read and written the same way and names its `account`.
@@ -52,7 +52,7 @@ export interface Link {
52
52
  to: string | null;
53
53
  kind: (typeof LINK_KINDS)[number];
54
54
  anchor: string | null;
55
- origin: (typeof LINK_ORIGINS)[number];
55
+ origin: (typeof LINK_SOURCES)[number];
56
56
  targetText: string | null;
57
57
  role: string | null;
58
58
  evidence: string | null;
@@ -76,7 +76,7 @@ export interface LinkInput {
76
76
  targetText?: string;
77
77
  kind: Link["kind"];
78
78
  anchor?: string;
79
- origin?: Link["origin"];
79
+ origin?: (typeof LINK_ORIGINS)[number];
80
80
  role?: string;
81
81
  evidence?: string;
82
82
  provenance?: string;
@@ -193,4 +193,3 @@ export declare const resolvePersonLinks: (database: CacheDatabase, names: (strin
193
193
  /** Writes one link; an identical resolved link is answered instead of doubled. */
194
194
  export declare const insertLink: (context: StoreContext, input: LinkInput) => Link;
195
195
  export declare const notesStoreOver: (context: StoreContext) => Omit<NotesStore, keyof NoteSearch>;
196
- export {};
@@ -10,13 +10,13 @@ import { ownerPerson } from "./actors.js";
10
10
  import { encodeDocumentMetadata, languageOf } from "./document-metadata.js";
11
11
  import { PREFERRED_FILE, syncFile } from "./files.js";
12
12
  import { importedDocumentWhere } from "./imported-documents.js";
13
- import { LINK_KINDS, linkKind } from "./link-kinds.js";
13
+ import { LINK_KINDS, LINK_ORIGINS, linkKind } from "./link-kinds.js";
14
14
  import { reviewLink } from "./link-reviews.js";
15
15
  import { addTags, ensureTag, labelsOf } from "./tags.js";
16
16
  import { canonicalThingReference, referenceOfThing, storedThing, thingOf } from "./things.js";
17
17
  import { write } from "./transaction.js";
18
18
  export const NOTE_FORMATS = ["obsidian", "markdown"];
19
- export const LINK_ORIGINS = ["file", "owner", "suggested", "message"];
19
+ export { LINK_ORIGINS } from "./link-kinds.js";
20
20
  const iso = (value) => new Date(Number(value)).toISOString();
21
21
  const optionalIso = (value) => (value == null ? null : iso(value));
22
22
  const textOf = (text, what, max = 100_000, allowEmpty = false) => {
@@ -23,7 +23,7 @@ export declare const projectAnnotationSnapshot: (database: CacheDatabase, projec
23
23
  ref: string;
24
24
  from: string;
25
25
  to: string | null;
26
- kind: "about" | "answered-by" | "assigned-to" | "blocked-by" | "created-from" | "duplicate-of" | "evidence" | "follow-up-of" | "labelled" | "links-to" | "member-of" | "related-to";
26
+ kind: "about" | "answered-by" | "assigned-to" | "attached" | "blocked-by" | "created-from" | "duplicate-of" | "evidence" | "follow-up-of" | "labelled" | "links-to" | "member-of" | "related-to";
27
27
  origin: "file" | "message" | "owner" | "suggested";
28
28
  anchor: string | null;
29
29
  target_text: string | null;
@@ -20,6 +20,7 @@ import { type AgentActionsStore } from "./sqlite/agent-actions.js";
20
20
  import { type BotUpdateStore } from "./sqlite/bot-updates.js";
21
21
  import { type CatalogStore } from "./sqlite/catalog.js";
22
22
  import { type MailStore } from "./sqlite/emails.js";
23
+ import { type EventSearch } from "./sqlite/event-search.js";
23
24
  import { type FactsStore } from "./sqlite/facts.js";
24
25
  import { type FilesStore } from "./sqlite/files.js";
25
26
  import { type InvolvementStore } from "./sqlite/involvements.js";
@@ -84,7 +85,7 @@ export interface MessageStore {
84
85
  /** Where each document is kept: its files, their folders and paths. */
85
86
  readonly files: FilesStore;
86
87
  /** Calendars and their entries, the source records of events: the `EventStore` a calendar CLI writes through. */
87
- readonly calendar: EventStore;
88
+ readonly calendar: EventStore & EventSearch;
88
89
  /** Choices that hold until replaced, each with its evidence. */
89
90
  readonly meetingFindings: MeetingFindingsStore;
90
91
  /** Cited facts and follow-ups about one project from stored messages, mail and documents, for review. */
@@ -19,6 +19,7 @@ import { eventStoreOver } from "./sqlite/calendar.js";
19
19
  import { catalogStoreOver } from "./sqlite/catalog.js";
20
20
  import { contextEvidenceOver, contextIndexOver, contextRecordsOver } from "./sqlite/context-evidence.js";
21
21
  import { mailStoreOver } from "./sqlite/emails.js";
22
+ import { eventSearchOver } from "./sqlite/event-search.js";
22
23
  import { evidenceFindingsStoreOver } from "./sqlite/evidence-findings.js";
23
24
  import { factsStoreOver } from "./sqlite/facts.js";
24
25
  import { filesStoreOver } from "./sqlite/files.js";
@@ -100,7 +101,7 @@ const storeOver = (context) => {
100
101
  const notes = once(() => ({ ...notesStoreOver(context), ...noteSearchOver(context) }));
101
102
  const catalog = once(() => catalogStoreOver(context));
102
103
  const files = once(() => filesStoreOver(context));
103
- const calendar = once(() => eventStoreOver(context));
104
+ const calendar = once(() => ({ ...eventStoreOver(context), ...eventSearchOver(context) }));
104
105
  const meetingFindings = once(() => meetingFindingsStoreOver(context));
105
106
  const evidenceFindings = once(() => evidenceFindingsStoreOver(context));
106
107
  const facts = once(() => factsStoreOver(context));