@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
@@ -0,0 +1,131 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { kindIn } from "../domain/providers.js";
3
+ import { meaningFloor } from "../embeddings/models.js";
4
+ import { excerpt } from "./excerpt.js";
5
+ import { localModel, RRF_K, storeQuery, withLocal } from "./notes-search.js";
6
+ import { plainQueryWords } from "./notes-title-tier.js";
7
+ const MAX_EVENTS_OFFSET = 1000;
8
+ const iso = (at) => (at === null ? null : new Date(at).toISOString());
9
+ /** The stored ids of the calendar accounts named; a name the store does not hold reads nothing. */
10
+ const accountIdsOf = async (store, accounts) => {
11
+ const held = await store.accounts.storedAccounts();
12
+ return held
13
+ .filter((one) => accounts.some(({ provider, account }) => one.provider === provider && one.account === account))
14
+ .map(({ id }) => String(id));
15
+ };
16
+ /** The calendar accounts the store holds: every account whose provider is of the `events` kind. */
17
+ export const calendarAccounts = async (store) => (await store.accounts.storedAccounts())
18
+ .filter(({ provider }) => kindIn(provider) === "events")
19
+ .map(({ provider, account }) => ({ provider, account }));
20
+ export const foundEvent = (hit, query, foundBy) => ({
21
+ ref: hit.ref,
22
+ provider: hit.account?.provider ?? null,
23
+ account: hit.account?.account ?? null,
24
+ title: hit.event.title,
25
+ starts_at: iso(hit.event.startsAt),
26
+ ends_at: iso(hit.event.endsAt),
27
+ all_day: hit.event.allDay === true,
28
+ status: hit.event.status ?? null,
29
+ location: hit.event.location,
30
+ ...(hit.url ? { url: hit.url } : {}),
31
+ excerpt: excerpt(hit.text || (hit.event.title ?? ""), query),
32
+ occurrences: hit.occurrences,
33
+ updated_at: new Date(hit.event.updatedAt).toISOString(),
34
+ found_by: foundBy,
35
+ });
36
+ /** How deep each half is read before the two are merged. */
37
+ const CANDIDATES = 50;
38
+ /** Events nearest in meaning to plain words, one per series, best first; only events already embedded are found. */
39
+ const nearestEvents = async (store, words, { limit, accountIds, env, command, embedders, }) => {
40
+ const { model, directory, key } = localModel(undefined, env, command);
41
+ if (!(await store.calendar.embedded(key, accountIds ? { accountIds } : {})))
42
+ throw new CliError("not_found", `no event has vectors of ${model.id} yet, so only words were matched`);
43
+ const [vector] = await withLocal(model, directory, undefined, (embedder) => embedder.embed([words], "query"), embedders);
44
+ const floor = meaningFloor(key);
45
+ return (await store.calendar.nearest(key, vector, { limit, ...(accountIds ? { accountIds } : {}) })).filter(({ score }) => score > floor);
46
+ };
47
+ /** One hit per series, whichever half found it. */
48
+ const keyOf = ({ event }) => event.eventSeriesId === null ? `e${event.id}` : `s${event.eventSeriesId}`;
49
+ /**
50
+ * Calendar events by words and, when the local text model is there and the query is plain words, by meaning, merged
51
+ * by reciprocal rank: title, cleaned description, place and attendees' names. The occurrences of one series are one
52
+ * hit. Working location, focus time, out of office, birthdays and busy-only slots are never found.
53
+ */
54
+ export const searchEvents = async (store, query, { limit = 20, offset = 0, accounts, exact, newest, timezone, signal, candidates, env, command, embedders, } = {}) => {
55
+ if (!query.trim())
56
+ throw new CliError("validation_error", "say what to find in the events");
57
+ const { root, stemmer } = await storeQuery(store, query, {
58
+ ...(exact ? { exact: true } : {}),
59
+ ...(timezone === undefined ? {} : { timezone }),
60
+ });
61
+ const accountIds = accounts === undefined ? undefined : await accountIdsOf(store, accounts);
62
+ if (accountIds?.length === 0)
63
+ return { query, by: "words", hits: [], has_more: false };
64
+ const words = plainQueryWords(query);
65
+ const window = offset + limit;
66
+ let meaningSkipped;
67
+ let meaning;
68
+ if (exact)
69
+ meaningSkipped = "--exact searches words as written";
70
+ else if (newest)
71
+ meaningSkipped = "newest first orders by time";
72
+ else if (words === undefined)
73
+ meaningSkipped = "the query has fields or operators, which meaning cannot follow";
74
+ else
75
+ try {
76
+ meaning = await nearestEvents(store, words, {
77
+ limit: Math.max(window, CANDIDATES),
78
+ ...(accountIds ? { accountIds } : {}),
79
+ ...(env === undefined ? {} : { env }),
80
+ ...(command === undefined ? {} : { command }),
81
+ ...(embedders === undefined ? {} : { embedders }),
82
+ });
83
+ }
84
+ catch (error) {
85
+ if (!(error instanceof CliError && error.code === "not_found"))
86
+ throw error;
87
+ meaningSkipped = error.message;
88
+ }
89
+ const found = await store.calendar.search({
90
+ root,
91
+ limit: meaning === undefined ? limit : Math.max(window, CANDIDATES),
92
+ offset: meaning === undefined ? offset : 0,
93
+ ...(accountIds === undefined ? {} : { accountIds }),
94
+ ...(newest ? { newest: true } : {}),
95
+ ...(stemmer ? { stemmer } : {}),
96
+ ...(signal ? { signal } : {}),
97
+ ...(candidates === undefined ? {} : { candidates }),
98
+ });
99
+ const truncated = found.truncated ? { truncated: true } : {};
100
+ const by = meaning === undefined ? "words" : "words and meaning";
101
+ const skipped = meaningSkipped === undefined ? {} : { meaning_skipped: meaningSkipped };
102
+ if (meaning === undefined)
103
+ return {
104
+ query,
105
+ by,
106
+ ...skipped,
107
+ hits: found.items.map((hit) => foundEvent(hit, query, ["words"])),
108
+ has_more: found.hasMore,
109
+ ...(found.hasMore && window <= MAX_EVENTS_OFFSET ? { next_offset: window } : {}),
110
+ ...truncated,
111
+ };
112
+ const ranked = new Map();
113
+ const add = (list, half) => list.forEach((hit, rank) => {
114
+ const held = ranked.get(keyOf(hit)) ?? { hit, score: 0, by: [] };
115
+ held.score += 1 / (RRF_K + rank + 1);
116
+ held.by.push(half);
117
+ ranked.set(keyOf(hit), held);
118
+ });
119
+ add(found.items, "words");
120
+ add(meaning, "meaning");
121
+ const ordered = [...ranked.values()].sort((a, b) => b.score - a.score);
122
+ const hasMore = ordered.length > window || found.hasMore;
123
+ return {
124
+ query,
125
+ by,
126
+ hits: ordered.slice(offset, window).map(({ hit, by: halves }) => foundEvent(hit, query, halves)),
127
+ has_more: hasMore,
128
+ ...(hasMore && window <= MAX_EVENTS_OFFSET ? { next_offset: window } : {}),
129
+ ...truncated,
130
+ };
131
+ };
@@ -84,7 +84,7 @@ export type { ModerateOptions, ModerationService, ShownRules } from "./moderatio
84
84
  export { moderationService } from "./moderation.js";
85
85
  export type { ContactSync, PeopleService } from "./people.js";
86
86
  export { peopleService, phoneOf } from "./people.js";
87
- export type { ContextMessage, ContextOptions, NotReadReason, PersonContext, SharedChat, } from "./person-context.js";
87
+ export type { ContextEvent, ContextMessage, ContextOptions, NotReadReason, PersonContext, SharedChat, } from "./person-context.js";
88
88
  export { CONTEXT_BYTES, CONTEXT_MESSAGES, identityIn, personContext } from "./person-context.js";
89
89
  export type { PersonPreparation, WaitingDialog } from "./person-preparation.js";
90
90
  export { PREPARE_BYTES, personPreparation } from "./person-preparation.js";
@@ -149,6 +149,7 @@ export type { CounterQuery, CountersService } from "./counters.js";
149
149
  export { countersService } from "./counters.js";
150
150
  export { type DocumentTarget, openPictureEmbedderFor, type PictureOptions, removePicture, savePicture, } from "./document-pictures.js";
151
151
  export type { ModelChoice } from "./embeddings.js";
152
+ export { calendarAccounts, type EventsFound, type EventsQuery, type FoundEvent, foundEvent, searchEvents, } from "./events-search.js";
152
153
  export { createMeetingBatchReader } from "./meeting-batch.js";
153
154
  export { type MeetingCombinedHit, type MeetingCombinedRequest, searchMeetingsCombined, } from "./meeting-combined.js";
154
155
  export type { MeetingCommunicationCoverage, MeetingCommunicationExcerpt, MeetingCommunications, MeetingCommunicationsPort, MeetingCommunicationsRequest, } from "./meeting-communications-types.js";
@@ -168,7 +169,7 @@ export type { MeaningLane, MeaningSkipped } from "./messages-discovery.js";
168
169
  export { meaningLane } from "./messages-meaning.js";
169
170
  export type { QueryMetadata, SearchCoverage } from "./messages-search.js";
170
171
  export { metadataService } from "./metadata.js";
171
- export { embedMail, embedNotes, type FoundNote, type LinkedRecord, type NotesEmbedded, type NotesEmbedOptions, type NotesFound, type NotesFoundOf, type NotesQuery, type NotesSearchRequest, nearestNotes, searchNotes, searchNotesOf, searchNotesQuery, } from "./notes-search.js";
172
+ export { embedEvents, embedMail, embedNotes, type FoundNote, type LinkedRecord, type NotesEmbedded, type NotesEmbedOptions, type NotesFound, type NotesFoundOf, type NotesQuery, type NotesSearchRequest, nearestNotes, searchNotes, searchNotesOf, searchNotesQuery, storeQuery, } from "./notes-search.js";
172
173
  export { type PackageUpgradeOutcome, type PackageUpgradePorts, type PackageUpgradeResult, type ServerRestarts, upgradePackage, } from "./package-upgrade.js";
173
174
  export { type PersonMeetingContext, type PersonMeetingContextItem, type PersonMeetingContextOptions, type PersonMeetingReadStore, personMeetingContext, } from "./person-meeting-context.js";
174
175
  export { privatePeopleService } from "./private-people.js";
@@ -111,6 +111,7 @@ export { adminStatisticsService } from "./admin-statistics.js";
111
111
  export { GAP_BOUNDS, gapsService } from "./archive-gaps.js";
112
112
  export { countersService } from "./counters.js";
113
113
  export { openPictureEmbedderFor, removePicture, savePicture, } from "./document-pictures.js";
114
+ export { calendarAccounts, foundEvent, searchEvents, } from "./events-search.js";
114
115
  export { createMeetingBatchReader } from "./meeting-batch.js";
115
116
  export { searchMeetingsCombined, } from "./meeting-combined.js";
116
117
  export { createMeetingContextService, } from "./meeting-context.js";
@@ -126,7 +127,7 @@ export { proposeMeetingTask } from "./meeting-task-proposal.js";
126
127
  export { createTaskFromMeeting, readMeetingTask, } from "./meeting-tasks.js";
127
128
  export { meaningLane } from "./messages-meaning.js";
128
129
  export { metadataService } from "./metadata.js";
129
- export { embedMail, embedNotes, nearestNotes, searchNotes, searchNotesOf, searchNotesQuery, } from "./notes-search.js";
130
+ export { embedEvents, embedMail, embedNotes, nearestNotes, searchNotes, searchNotesOf, searchNotesQuery, storeQuery, } from "./notes-search.js";
130
131
  export { upgradePackage, } from "./package-upgrade.js";
131
132
  export { personMeetingContext, } from "./person-meeting-context.js";
132
133
  export { privatePeopleService } from "./private-people.js";
@@ -1,7 +1,10 @@
1
- import { type WarmEmbedders } from "../embeddings/embed.js";
1
+ import { type Embedder, type WarmEmbedders } from "../embeddings/embed.js";
2
+ import { type TextModel } from "../embeddings/models.js";
2
3
  import type { PictureEmbedder } from "../images/picture/embedder.js";
3
4
  import type { AppIdentity } from "../ports/app.js";
4
5
  import { type SkippedSource } from "../search/account-list.js";
6
+ import { type ResolvedNode } from "../search/lucene/resolved.js";
7
+ import { type Stemmer } from "../search/stem.js";
5
8
  import type { Link, NearestNote, Note, NoteHit } from "../store/index.js";
6
9
  import type { AccountKey, MessageStore } from "../store/store.js";
7
10
  export interface NotesSearchRequest {
@@ -19,6 +22,17 @@ export interface NotesSearchRequest {
19
22
  timezone?: string;
20
23
  signal?: AbortSignal;
21
24
  }
25
+ /**
26
+ * Text in the query language messages use, parsed for a store search: `date:` read in `timezone`, and the store's
27
+ * stemmer when a word is stemmed.
28
+ */
29
+ export declare const storeQuery: (store: MessageStore, text: string, { exact, timezone }?: {
30
+ exact?: boolean;
31
+ timezone?: string;
32
+ }) => Promise<{
33
+ root: ResolvedNode;
34
+ stemmer?: Stemmer;
35
+ }>;
22
36
  /** A notes search in the query language messages use, for `search notes` and `search all` to call. */
23
37
  export declare const searchNotesQuery: (store: MessageStore, request: NotesSearchRequest) => Promise<{
24
38
  items: NoteHit[];
@@ -38,9 +52,19 @@ export interface NotesEmbedded {
38
52
  /** Chunks still without a vector, when `maxChunks` stopped the run. */
39
53
  left: boolean;
40
54
  }
55
+ export declare const localModel: (choice: string | undefined, env: NodeJS.ProcessEnv | undefined, command: string | undefined) => {
56
+ model: TextModel;
57
+ directory: string;
58
+ key: string;
59
+ };
60
+ export declare const withLocal: <T>(model: TextModel, directory: string, threads: number | undefined, work: (embedder: Embedder) => Promise<T>, shared?: Pick<WarmEmbedders, "get">) => Promise<T>;
41
61
  export declare const embedNotes: (store: MessageStore, options?: NotesEmbedOptions & {
42
62
  command?: string;
43
63
  }) => Promise<NotesEmbedded>;
64
+ /** Embeds calendar events' pieces as notes' are embedded; the occurrences of a series share one vector. */
65
+ export declare const embedEvents: (store: MessageStore, options?: NotesEmbedOptions & {
66
+ command?: string;
67
+ }) => Promise<NotesEmbedded>;
44
68
  /** Embeds mail's chunks as notes' are embedded: same model and key, so search by meaning reads both. */
45
69
  export declare const embedMail: (store: MessageStore, options?: NotesEmbedOptions & {
46
70
  command?: string;
@@ -15,12 +15,13 @@ import { documentSource } from "./document-source.js";
15
15
  import { plainQueryWords, titleQuery, titleTiers } from "./notes-title-tier.js";
16
16
  import { PICTURE_FLOOR, plainWords } from "./pictures.js";
17
17
  import { listContextOf } from "./search-kind.js";
18
- /** A notes search in the query language messages use, for `search notes` and `search all` to call. */
19
- export const searchNotesQuery = async (store, request) => {
20
- if (!request.text.trim())
21
- throw new CliError("validation_error", "say what to find in the notes");
22
- const ast = validateFields(parseLucene(request.text, { ...SEARCH_QUERY, defaultField: request.exact ? "exact" : "text" }));
23
- const timezone = timezoneOf(request.timezone);
18
+ /**
19
+ * Text in the query language messages use, parsed for a store search: `date:` read in `timezone`, and the store's
20
+ * stemmer when a word is stemmed.
21
+ */
22
+ export const storeQuery = async (store, text, { exact, timezone } = {}) => {
23
+ const ast = validateFields(parseLucene(text, { ...SEARCH_QUERY, defaultField: exact ? "exact" : "text" }));
24
+ const zone = timezoneOf(timezone);
24
25
  const resolve = (node) => {
25
26
  if (node.kind === "boolean")
26
27
  return { ...node, clauses: node.clauses.map(({ occur, node }) => ({ occur, node: resolve(node) })) };
@@ -29,15 +30,25 @@ export const searchNotesQuery = async (store, request) => {
29
30
  return {
30
31
  ...node,
31
32
  resolution: {
32
- date: dateRange(node.value, node.operator === "range" ? (node.upper ?? "*") : node.value, node.operator === "range" ? node.lower_inclusive === true : true, node.operator === "range" ? node.upper_inclusive === true : true, timezone, node.span),
33
+ date: dateRange(node.value, node.operator === "range" ? (node.upper ?? "*") : node.value, node.operator === "range" ? node.lower_inclusive === true : true, node.operator === "range" ? node.upper_inclusive === true : true, zone, node.span),
33
34
  },
34
35
  };
35
36
  };
36
37
  const stemmers = hasStems(ast.root) ? await store.searchIndex.stemmers() : undefined;
37
38
  if (stemmers === null)
38
39
  throw new CliError("validation_error", "the store asks for stemmers this tool does not know — upgrade this tool, or search exact forms with --exact", { reason: "stemmer.unknown" });
39
- return store.notes.search({
40
+ return {
40
41
  root: resolve(ast.root),
42
+ ...(hasStems(ast.root) ? { stemmer: createStemmer(stemmers ?? DEFAULT_STEMMERS) } : {}),
43
+ };
44
+ };
45
+ /** A notes search in the query language messages use, for `search notes` and `search all` to call. */
46
+ export const searchNotesQuery = async (store, request) => {
47
+ if (!request.text.trim())
48
+ throw new CliError("validation_error", "say what to find in the notes");
49
+ const { root, stemmer } = await storeQuery(store, request.text, request);
50
+ return store.notes.search({
51
+ root,
41
52
  limit: request.limit,
42
53
  ...(request.offset === undefined ? {} : { offset: request.offset }),
43
54
  ...(request.newest ? { newest: true } : {}),
@@ -45,7 +56,7 @@ export const searchNotesQuery = async (store, request) => {
45
56
  ...(request.withInternal ? { withInternal: true } : {}),
46
57
  ...(request.source ? { source: request.source } : {}),
47
58
  ...(request.signal ? { signal: request.signal } : {}),
48
- ...(hasStems(ast.root) ? { stemmer: createStemmer(stemmers ?? DEFAULT_STEMMERS) } : {}),
59
+ ...(stemmer ? { stemmer } : {}),
49
60
  });
50
61
  };
51
62
  /** Chunks one batch embeds: one short transaction writes each, as for conversations. */
@@ -54,7 +65,7 @@ const NOTE_BATCH = 8;
54
65
  const downloadHint = (model, command) => command === undefined
55
66
  ? `install it with a WireCat CLI that has \`models download\` (tg, max or wa); they share the model folder`
56
67
  : `\`${command} models download ${model}\``;
57
- const localModel = (choice, env, command) => {
68
+ export const localModel = (choice, env, command) => {
58
69
  const model = textModel(choice ?? DEFAULT_TEXT_MODEL);
59
70
  const directory = textModelsDirectory(env);
60
71
  if (!isTextModelInstalled(model, directory))
@@ -64,7 +75,7 @@ const localModel = (choice, env, command) => {
64
75
  });
65
76
  return { model, directory, key: vectorModelKey(model) };
66
77
  };
67
- const withLocal = async (model, directory, threads, work, shared) => {
78
+ export const withLocal = async (model, directory, threads, work, shared) => {
68
79
  if (shared)
69
80
  return work(await shared.get(vectorModelKey(model), () => openEmbedder(model, directory, { threads: threads ?? defaultThreads() })));
70
81
  const embedder = await openEmbedder(model, directory, { threads: threads ?? defaultThreads() });
@@ -104,6 +115,8 @@ const embedChunks = async (store, source, { model: choice, maxChunks = Number.PO
104
115
  });
105
116
  };
106
117
  export const embedNotes = (store, options = {}) => embedChunks(store, store.notes, options);
118
+ /** Embeds calendar events' pieces as notes' are embedded; the occurrences of a series share one vector. */
119
+ export const embedEvents = (store, options = {}) => embedChunks(store, store.calendar, options);
107
120
  /** Embeds mail's chunks as notes' are embedded: same model and key, so search by meaning reads both. */
108
121
  export const embedMail = (store, options = {}) => embedChunks(store, store.mail, options);
109
122
  /** Notes nearest in meaning to the query, best first; only notes already embedded can be found. */
@@ -23,6 +23,18 @@ export interface SharedChat {
23
23
  kind: ChatKind;
24
24
  lastMessageAt: string | null;
25
25
  }
26
+ /** A calendar event the person is invited to, through any contact linked to them. */
27
+ export interface ContextEvent {
28
+ /** `event:<id>`: of a series, the one occurrence listed. */
29
+ ref: string;
30
+ title: string | null;
31
+ startsAt: string | null;
32
+ endsAt: string | null;
33
+ allDay: boolean;
34
+ location: string | null;
35
+ /** confirmed or tentative; a cancelled event is not listed. */
36
+ status: string | null;
37
+ }
26
38
  /** `not_fetched`: nothing of its history was ever read into the store; `partial`: only some of it was. */
27
39
  export type NotReadReason = "not_fetched" | "partial";
28
40
  export interface PersonContext {
@@ -42,6 +54,11 @@ export interface PersonContext {
42
54
  groups: ContextMessage[];
43
55
  };
44
56
  mentions: ContextMessage[];
57
+ /** Calendar events they are invited to, one occurrence per series: the next ones, then the latest past ones. */
58
+ events: {
59
+ upcoming: ContextEvent[];
60
+ recent: ContextEvent[];
61
+ };
45
62
  limits: {
46
63
  messages: number;
47
64
  bytes: number;
@@ -59,6 +76,8 @@ export interface ContextOptions {
59
76
  bytes?: number;
60
77
  /** ms: nothing older. */
61
78
  since?: number;
79
+ /** ms: what splits upcoming events from past ones; the clock when absent. */
80
+ now?: number;
62
81
  }
63
82
  export declare const toContext: (key: AccountKey, message: Message, chatTitle: string | null) => ContextMessage;
64
83
  /**
@@ -74,7 +93,7 @@ export declare const readsOf: (record: PersonRecord, asked: AccountKey) => {
74
93
  * What the store knows about one person, across every identity linked to them. Reads the store
75
94
  * only: nothing is fetched, nothing marked read. Identity is by id, never by name.
76
95
  */
77
- export declare const personContext: (store: MessageStore, asked: AccountKey, reference: string, { messages, bytes, since }?: ContextOptions) => Promise<PersonContext>;
96
+ export declare const personContext: (store: MessageStore, asked: AccountKey, reference: string, { messages, bytes, since, now }?: ContextOptions) => Promise<PersonContext>;
78
97
  /** `max:Ana` names a person in another messenger of this store; a reference with no prefix, this one. */
79
98
  export declare const identityIn: (store: MessageStore, asked: AccountKey, reference: string) => Promise<IdentityRef>;
80
99
  export declare const CHAT_MESSAGES = 20;
@@ -44,6 +44,42 @@ const mailWith = async (store, key, address, limit, since) => {
44
44
  return { items, hasMore: emails.length > limit };
45
45
  };
46
46
  const newestFirst = (a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp);
47
+ const iso = (at) => (at === null ? null : new Date(at).toISOString());
48
+ const contextEvent = (event) => ({
49
+ ref: `event:${event.id}`,
50
+ title: event.title,
51
+ startsAt: iso(event.startsAt),
52
+ endsAt: iso(event.endsAt),
53
+ allDay: event.allDay === true,
54
+ location: event.location,
55
+ status: event.status ?? null,
56
+ });
57
+ const EVENT_PAGE = 100;
58
+ /** A weekly meeting held for years is hundreds of occurrences; past this many rows the list stops, cut. */
59
+ const EVENT_ROWS = 1000;
60
+ /** Up to `limit` events of `filter`, in its order, the first occurrence of each series only and none cancelled. */
61
+ const eventsOnce = async (store, filter, limit) => {
62
+ const items = [];
63
+ const series = new Set();
64
+ for (let offset = 0; offset < EVENT_ROWS; offset += EVENT_PAGE) {
65
+ const page = await store.calendar.events({ ...filter, limit: EVENT_PAGE, offset });
66
+ for (const event of page) {
67
+ if (event.status === "cancelled")
68
+ continue;
69
+ if (event.eventSeriesId !== null) {
70
+ if (series.has(event.eventSeriesId))
71
+ continue;
72
+ series.add(event.eventSeriesId);
73
+ }
74
+ if (items.length === limit)
75
+ return { items, cut: true };
76
+ items.push(event);
77
+ }
78
+ if (page.length < EVENT_PAGE)
79
+ return { items, cut: false };
80
+ }
81
+ return { items, cut: true };
82
+ };
47
83
  /**
48
84
  * The accounts each identity is read in: for the messenger asked, only the account asked about — a
49
85
  * second account of the same messenger is somebody else's view; for a linked messenger, every account
@@ -56,7 +92,7 @@ export const readsOf = (record, asked) => record.identities.flatMap((identity) =
56
92
  * What the store knows about one person, across every identity linked to them. Reads the store
57
93
  * only: nothing is fetched, nothing marked read. Identity is by id, never by name.
58
94
  */
59
- export const personContext = async (store, asked, reference, { messages = CONTEXT_MESSAGES, bytes = CONTEXT_BYTES, since } = {}) => {
95
+ export const personContext = async (store, asked, reference, { messages = CONTEXT_MESSAGES, bytes = CONTEXT_BYTES, since, now = Date.now() } = {}) => {
60
96
  const found = pickPerson(reference, await store.people.people(asked.provider, { account: asked.account }));
61
97
  const record = await store.people.personOf({ provider: asked.provider, id: found.id });
62
98
  if (!record)
@@ -121,6 +157,11 @@ export const personContext = async (store, asked, reference, { messages = CONTEX
121
157
  .filter(recent));
122
158
  cut ||= named.hasMore;
123
159
  }
160
+ const personId = Number(record.uid);
161
+ const upcoming = await eventsOnce(store, { personId, from: now }, messages);
162
+ const upcomingIds = new Set(upcoming.items.map(({ id }) => id));
163
+ const past = await eventsOnce(store, { personId, to: now, newest: true, ...(since === undefined ? {} : { from: since }) }, messages);
164
+ cut ||= upcoming.cut || past.cut;
124
165
  direct.sort(newestFirst);
125
166
  const each = [...direct, ...beyond].sort(newestFirst);
126
167
  theirs.sort(newestFirst);
@@ -145,6 +186,10 @@ export const personContext = async (store, asked, reference, { messages = CONTEX
145
186
  },
146
187
  recent: { direct: kept.direct, groups: kept.groups },
147
188
  mentions: kept.mentions,
189
+ events: {
190
+ upcoming: upcoming.items.map(contextEvent),
191
+ recent: past.items.filter(({ id }) => !upcomingIds.has(id)).map(contextEvent),
192
+ },
148
193
  limits: { messages, bytes },
149
194
  hasMore: cut || kept.cut,
150
195
  complete: notRead.length === 0,
@@ -3,7 +3,7 @@ import type { AccountKey, MessageStore } from "../store/store.js";
3
3
  import type { SearchFound, SearchQuery } from "./messages.js";
4
4
  import type { MeaningLane } from "./messages-discovery.js";
5
5
  import { type SearchAllFound, type SearchAllItem, type SearchAllMessenger, type SearchAllRequest, type Skipped } from "./search-all.js";
6
- export declare const RESOURCES_SEARCHED_WITH_MEETINGS: readonly ["messages", "mail", "notes", "meetings"];
6
+ export declare const RESOURCES_SEARCHED_WITH_MEETINGS: readonly ["messages", "mail", "notes", "events", "meetings"];
7
7
  export type ResourceWithMeetings = (typeof RESOURCES_SEARCHED_WITH_MEETINGS)[number];
8
8
  export interface MeetingSearchItem {
9
9
  kind: "meeting";
@@ -131,7 +131,7 @@ export const searchAllIncludingMeetings = async (store, account, request, messen
131
131
  const only = request.only ?? RESOURCES_SEARCHED_WITH_MEETINGS;
132
132
  if (!only.length ||
133
133
  only.some((resource) => !RESOURCES_SEARCHED_WITH_MEETINGS.includes(resource)))
134
- throw new CliError("validation_error", "only takes messages, mail, notes and meetings");
134
+ throw new CliError("validation_error", "only takes messages, mail, notes, events and meetings");
135
135
  if (request.meetingCursor) {
136
136
  if (only.length !== 1 || only[0] !== "meetings")
137
137
  throw new CliError("validation_error", "meeting cursor requires only: [meetings]");
@@ -261,7 +261,12 @@ const searchAllOfSource = async (store, account, source, request, messenger, sea
261
261
  const found = await searchAllIncludingMeetings(store, account, {
262
262
  ...request,
263
263
  only: read,
264
- scope: { messages: named("messages"), mail: named("mail"), notes: named("notes") },
264
+ scope: {
265
+ messages: named("messages"),
266
+ mail: named("mail"),
267
+ notes: named("notes"),
268
+ events: named("events"),
269
+ },
265
270
  ...(meetings[0] ? { meetingAccount: meetings[0] } : {}),
266
271
  }, messenger, searchMessages, meaning);
267
272
  const unnamed = only
@@ -5,16 +5,16 @@ import type { AccountKey, MessageStore } from "../store/store.js";
5
5
  import type { SearchFound, SearchQuery } from "./messages.js";
6
6
  import { type MeaningLane, type MeaningSkipped } from "./messages-discovery.js";
7
7
  import type { Backend, ServerOptions, ServerSearched } from "./server-search.js";
8
- export declare const RESOURCES_SEARCHED: readonly ["messages", "mail", "notes"];
8
+ export declare const RESOURCES_SEARCHED: readonly ["messages", "mail", "notes", "events"];
9
9
  export type SearchedResource = (typeof RESOURCES_SEARCHED)[number];
10
10
  export interface SearchAllItem {
11
- kind: "message" | "mail" | "note" | "document";
12
- /** `msg:…` for a message or a mail, `note:…` for a note. */
11
+ kind: "message" | "mail" | "note" | "document" | "event";
12
+ /** `msg:…` for a message or a mail, `note:…` for a note, `event:…` for a calendar event. */
13
13
  ref: string;
14
14
  provider: string;
15
15
  /** The account it was found in; a note's folder id, or `internal` for a note written in memo. */
16
16
  account: string;
17
- /** The chat, the mail thread's subject or the note's title. */
17
+ /** The chat, the mail thread's subject, the note's or the event's title. */
18
18
  url?: string;
19
19
  title: string | null;
20
20
  timestamp: string;
@@ -63,6 +63,11 @@ export interface SearchAllFound {
63
63
  by: "words" | "words and meaning";
64
64
  meaningSkipped?: string;
65
65
  };
66
+ /** How calendar events were searched, when they were. */
67
+ events?: {
68
+ by: "words" | "words and meaning";
69
+ meaningSkipped?: string;
70
+ };
66
71
  /** How the messages were searched, when they were; never by meaning with `exact` or the server alone. */
67
72
  messages?: {
68
73
  by: "words" | "words and meaning";
@@ -79,7 +84,7 @@ export interface SearchAllRequest {
79
84
  only?: readonly SearchedResource[];
80
85
  exact?: boolean;
81
86
  timezone?: string;
82
- /** For messages only; mail and notes are always the local store's. */
87
+ /** For messages only; mail, notes and events are always the local store's. */
83
88
  backend?: Backend;
84
89
  server?: ServerOptions;
85
90
  env?: NodeJS.ProcessEnv;
@@ -95,8 +100,8 @@ export type SearchAllMessenger = Partial<Pick<MessengerTraits, "savedChatId" | "
95
100
  hintCommand?: string | null;
96
101
  };
97
102
  /**
98
- * `search all`: messages, mail and notes from the local store — messages also from the messenger's
103
+ * `search all`: messages, mail, notes and calendar events from the local store — messages also from the messenger's
99
104
  * server, as `search messages` asks it — each list best first, merged by reciprocal rank so no
100
- * resource's own scores have to be compared with another's.
105
+ * resource's own scores have to be compared with another's. Events are read only when the store holds a calendar.
101
106
  */
102
107
  export declare const searchAll: (store: MessageStore, account: AccountKey, request: SearchAllRequest, messenger?: SearchAllMessenger, searchMessages?: (query: SearchQuery) => Promise<SearchFound>, meaning?: MeaningLane) => Promise<SearchAllFound>;
@@ -6,11 +6,12 @@ import { runEmbedders } from "../embeddings/embed.js";
6
6
  import { DEFAULT_TEXT_MODEL } from "../embeddings/models.js";
7
7
  import { MESSAGE_PARSING } from "../search/lucene/types.js";
8
8
  import { planQuestion } from "../search/question-plan.js";
9
+ import { calendarAccounts, searchEvents } from "./events-search.js";
9
10
  import { excerpt } from "./excerpt.js";
10
11
  import { MAX_SEARCH_OFFSET, searchStore } from "./messages-archive-search.js";
11
12
  import { nearEligible } from "./messages-discovery.js";
12
13
  import { notesScopeOf, RRF_K, searchNotes } from "./notes-search.js";
13
- export const RESOURCES_SEARCHED = ["messages", "mail", "notes"];
14
+ export const RESOURCES_SEARCHED = ["messages", "mail", "notes", "events"];
14
15
  /** A query a resource cannot answer is skipped with its reason, never a failure of the whole search. */
15
16
  const skippable = (error) => error instanceof CliError && (error.code === "validation_error" || error.code === "not_found");
16
17
  /**
@@ -28,9 +29,9 @@ const filtersBeside = (text) => {
28
29
  : undefined;
29
30
  };
30
31
  /**
31
- * `search all`: messages, mail and notes from the local store — messages also from the messenger's
32
+ * `search all`: messages, mail, notes and calendar events from the local store — messages also from the messenger's
32
33
  * server, as `search messages` asks it — each list best first, merged by reciprocal rank so no
33
- * resource's own scores have to be compared with another's.
34
+ * resource's own scores have to be compared with another's. Events are read only when the store holds a calendar.
34
35
  */
35
36
  export const searchAll = async (store, account, request, messenger = {}, searchMessages = (query) => searchStore(store, account, query, messenger), meaning) => {
36
37
  if (!request.text.trim())
@@ -48,6 +49,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
48
49
  const skipped = [];
49
50
  let hasMore = false;
50
51
  let notes;
52
+ let events;
51
53
  let messagesBy;
52
54
  let server;
53
55
  const messagesOf = async (kind) => {
@@ -133,7 +135,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
133
135
  });
134
136
  return [...fused.values()].sort((a, b) => b.score - a.score).map(({ item }) => item);
135
137
  };
136
- // Messages and notes both look up by meaning with the same model: loading it costs most of a second.
138
+ // Messages, notes and events all look up by meaning with the same model: loading it costs most of a second.
137
139
  const embedders = runEmbedders();
138
140
  try {
139
141
  for (const resource of only) {
@@ -172,6 +174,38 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
172
174
  ];
173
175
  accounts.push(...read.map((account) => ({ resource, provider: "notes", account })));
174
176
  }
177
+ else if (resource === "events") {
178
+ const read = request.scope?.events ?? (await calendarAccounts(store));
179
+ if (!read.length)
180
+ continue;
181
+ const found = await searchEvents(store, request.text, {
182
+ limit: window,
183
+ accounts: read,
184
+ embedders,
185
+ ...(command === undefined ? {} : { command }),
186
+ ...(request.env === undefined ? {} : { env: request.env }),
187
+ ...(request.exact ? { exact: true } : {}),
188
+ ...(request.timezone === undefined ? {} : { timezone: request.timezone }),
189
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
190
+ });
191
+ hasMore ||= found.has_more;
192
+ events = {
193
+ by: found.by,
194
+ ...(found.meaning_skipped === undefined ? {} : { meaningSkipped: found.meaning_skipped }),
195
+ };
196
+ lists.push(found.hits.map((hit) => ({
197
+ kind: "event",
198
+ ref: hit.ref,
199
+ provider: hit.provider ?? "calendar",
200
+ account: hit.account ?? "",
201
+ ...(hit.url ? { url: hit.url } : {}),
202
+ title: hit.title,
203
+ timestamp: hit.starts_at ?? hit.updated_at,
204
+ text: hit.excerpt,
205
+ foundBy: hit.found_by,
206
+ })));
207
+ accounts.push(...read.map(({ provider, account }) => ({ resource, provider, account })));
208
+ }
175
209
  else
176
210
  lists.push(await messagesOf(resource));
177
211
  searched.push(resource);
@@ -204,6 +238,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
204
238
  accounts,
205
239
  skipped,
206
240
  ...(notes === undefined ? {} : { notes }),
241
+ ...(events === undefined ? {} : { events }),
207
242
  ...(messagesBy === undefined ? {} : { messages: messagesBy }),
208
243
  ...(server === undefined ? {} : { server }),
209
244
  };
@@ -30,6 +30,7 @@ export type { CatalogActor, CatalogEntry, CatalogInput, CatalogQuery, CatalogSto
30
30
  export { CATALOG_QUERY } from "./sqlite/catalog.js";
31
31
  export type { ChatMetadata } from "./sqlite/chats.js";
32
32
  export type { Email, EmailAddress, EmailAttachment, EmailFacts, EmailFilter, EmailInput, EmailInThread, EmailRecipient, EmailRef, EmailThread, EmailVectorHit, Mailbox, MailboxInput, MailFilter, MailStore, SenderScore, ThreadDetails, ThreadSave, } from "./sqlite/emails.js";
33
+ export { EVENT_FIELDS, type EventChunkToEmbed, type EventHit, type EventQuery, type EventSearch, type NearestEvent, } from "./sqlite/event-search.js";
33
34
  export type { Fact, FactInput, FactsStore } from "./sqlite/facts.js";
34
35
  export type { FileListQuery, FilesStore, StoredFile } from "./sqlite/files.js";
35
36
  export { IMPORTED_DOCUMENT_QUERY, type ImportedDocuments } from "./sqlite/imported-documents.js";
@@ -16,6 +16,7 @@ export { planProjectAnnotationSnapshot } from "./project-transfer/snapshot-plan.
16
16
  export { resetAttachmentWords } from "./sqlite/attachment-texts.js";
17
17
  export { backfillNormalized, pendingNormalization } from "./sqlite/backfill.js";
18
18
  export { CATALOG_QUERY } from "./sqlite/catalog.js";
19
+ export { EVENT_FIELDS, } from "./sqlite/event-search.js";
19
20
  export { IMPORTED_DOCUMENT_QUERY } from "./sqlite/imported-documents.js";
20
21
  export { ORGANIZATION_KINDS, PROJECT_TYPES, SCOPES } from "./sqlite/knowledge.js";
21
22
  export { NOTE_FIELDS, } from "./sqlite/note-search.js";
@@ -22,7 +22,7 @@ export declare const projectAnnotationRequest: (request: ProjectAnnotationTarget
22
22
  ref: string;
23
23
  from: string;
24
24
  to: string | null;
25
- kind: "about" | "answered-by" | "assigned-to" | "blocked-by" | "created-from" | "duplicate-of" | "evidence" | "follow-up-of" | "labelled" | "links-to" | "member-of" | "related-to";
25
+ kind: "about" | "answered-by" | "assigned-to" | "attached" | "blocked-by" | "created-from" | "duplicate-of" | "evidence" | "follow-up-of" | "labelled" | "links-to" | "member-of" | "related-to";
26
26
  origin: "file" | "message" | "owner" | "suggested";
27
27
  anchor: string | null;
28
28
  target_text: string | null;