@wirecat/cli-core 0.499.0 → 0.501.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 (59) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/calendar/mcp/index.d.ts +12 -12
  3. package/dist/cli/envelope.d.ts +11 -1
  4. package/dist/cli/envelope.js +15 -3
  5. package/dist/cli/index.d.ts +1 -1
  6. package/dist/cli/index.js +1 -1
  7. package/dist/cli/meeting-preparation-answer.d.ts +8 -0
  8. package/dist/cli/meeting-preparation-projects.d.ts +8 -0
  9. package/dist/cli/meeting-preparation-read.d.ts +8 -0
  10. package/dist/cli/meeting-series-inventory-read.d.ts +8 -0
  11. package/dist/cli/messenger/messages-search-command.js +4 -1
  12. package/dist/cli/messenger/search-answers.d.ts +3 -0
  13. package/dist/cli/messenger/search-answers.js +6 -0
  14. package/dist/cli/messenger/search-command.js +4 -1
  15. package/dist/domain/knowledge.d.ts +1 -1
  16. package/dist/domain/knowledge.js +1 -1
  17. package/dist/entities/fact.d.ts +15 -1
  18. package/dist/entities/fact.js +6 -1
  19. package/dist/entities/fields.d.ts +11 -0
  20. package/dist/entities/fields.js +11 -0
  21. package/dist/entities/index.d.ts +49 -1
  22. package/dist/entities/meeting-preparation.d.ts +8 -0
  23. package/dist/entities/meeting-series-inventory.d.ts +8 -0
  24. package/dist/entities/memory.d.ts +14 -0
  25. package/dist/entities/memory.js +6 -1
  26. package/dist/mcp/meeting-preparation.d.ts +8 -0
  27. package/dist/mcp/meeting-series-inventory.d.ts +8 -0
  28. package/dist/meetings/mcp/index.d.ts +15 -15
  29. package/dist/meetings/project-evidence-answer.d.ts +8 -0
  30. package/dist/search/total.d.ts +13 -0
  31. package/dist/search/total.js +14 -0
  32. package/dist/services/index.d.ts +2 -1
  33. package/dist/services/index.js +2 -1
  34. package/dist/services/messages-archive-search.js +13 -3
  35. package/dist/services/messages-discovery.d.ts +6 -0
  36. package/dist/services/messages-discovery.js +14 -11
  37. package/dist/services/messages-search.d.ts +5 -0
  38. package/dist/services/messages-search.js +22 -0
  39. package/dist/services/messages.d.ts +3 -0
  40. package/dist/services/notes-search.d.ts +14 -2
  41. package/dist/services/notes-search.js +101 -19
  42. package/dist/store/index.d.ts +1 -0
  43. package/dist/store/project-transfer/request.d.ts +1 -1
  44. package/dist/store/project-transfer/schema.d.ts +2 -2
  45. package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
  46. package/dist/store/queries.d.ts +2 -0
  47. package/dist/store/queries.js +1 -0
  48. package/dist/store/sqlite/evidence-sources.d.ts +23 -0
  49. package/dist/store/sqlite/evidence-sources.js +59 -0
  50. package/dist/store/sqlite/facts.d.ts +16 -0
  51. package/dist/store/sqlite/facts.js +39 -5
  52. package/dist/store/sqlite/lucene.d.ts +5 -0
  53. package/dist/store/sqlite/lucene.js +17 -0
  54. package/dist/store/sqlite/memories.d.ts +28 -0
  55. package/dist/store/sqlite/memories.js +62 -3
  56. package/dist/store/sqlite/note-search.d.ts +18 -0
  57. package/dist/store/sqlite/note-search.js +40 -4
  58. package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
  59. package/package.json +3 -3
@@ -118,6 +118,11 @@ export declare const coverageOf: (store: MessageStore, { scopeAccounts, selected
118
118
  coverage: SearchCoverage;
119
119
  }>;
120
120
  export declare const searchLucene: (store: MessageStore, account: AccountKey, request: SearchQuery, messenger?: Partial<Pick<MessengerTraits, "savedChatId" | "app" | "history" | "provider">>) => Promise<SearchFound>;
121
+ /**
122
+ * How many messages the query matches, mail included, each counted no further than `cap + 1`; `undefined` when only
123
+ * the search itself could tell.
124
+ */
125
+ export declare const countLucene: (store: MessageStore, account: AccountKey, request: SearchQuery, messenger: Partial<Pick<MessengerTraits, "savedChatId" | "app">>, cap: number) => Promise<number | undefined>;
121
126
  export type StatsGrouping = "chat" | "sender" | "day" | "hour";
122
127
  export interface StatsRow {
123
128
  key: string;
@@ -366,6 +366,28 @@ export const searchLucene = async (store, account, request, messenger = {}) => {
366
366
  : {}),
367
367
  };
368
368
  };
369
+ /**
370
+ * How many messages the query matches, mail included, each counted no further than `cap + 1`; `undefined` when only
371
+ * the search itself could tell.
372
+ */
373
+ export const countLucene = async (store, account, request, messenger, cap) => {
374
+ const prepared = await prepareLucene(store, account, request, messenger);
375
+ if (!prepared.scopeAccounts.length)
376
+ return 0;
377
+ const count = (execution) => store.queries.countMatches(execution, cap);
378
+ const mailAccounts = prepared.scopeAccounts.filter(({ provider }) => provider === MAIL);
379
+ const messages = await count(prepared.execution);
380
+ let mail = 0;
381
+ if (mailAccounts.length)
382
+ try {
383
+ mail = await count({ ...prepared.execution, accounts: mailAccounts, corpus: "mail" });
384
+ }
385
+ catch (error) {
386
+ if (!(request.kind !== "mail" && error instanceof CliError && error.code === "validation_error"))
387
+ throw error;
388
+ }
389
+ return messages === undefined || mail === undefined ? undefined : messages + mail;
390
+ };
369
391
  /** The garbled spellings voice-note hits were found through, one entry per query word. */
370
392
  const correctionsOf = (items) => {
371
393
  const found = new Map();
@@ -3,6 +3,7 @@ import { type MessageLink } from "../domain/message-link.js";
3
3
  import type { Deletion, Discussion, Id, Message, Page, Provider, WindowedMessage } from "../domain/models.js";
4
4
  import { type After, type Download, type Sent } from "../ports/messenger.js";
5
5
  import type { SkippedSource } from "../search/account-list.js";
6
+ import type { SearchTotal } from "../search/total.js";
6
7
  import { type Operated } from "../sends/guarded.js";
7
8
  import type { PermissionKey } from "../sends/permissions.js";
8
9
  import type { AccountKey, ChatCompleteness, MatchPlace, StoredHit } from "../store/store.js";
@@ -104,6 +105,8 @@ export type FoundMessage = StoredHit & {
104
105
  export interface SearchFound extends Page<FoundMessage> {
105
106
  /** Where the next page starts, as `offset`; absent on the last page. */
106
107
  nextOffset?: number;
108
+ /** How many messages match, every page together, as Elasticsearch's `hits.total`; set by the archive search. */
109
+ total?: SearchTotal;
107
110
  refreshed?: SearchRefreshed;
108
111
  server?: ServerSearched;
109
112
  query?: QueryMetadata;
@@ -6,6 +6,7 @@ import type { AppIdentity } from "../ports/app.js";
6
6
  import { type SkippedSource } from "../search/account-list.js";
7
7
  import { type ResolvedNode } from "../search/lucene/resolved.js";
8
8
  import { type Stemmer } from "../search/stem.js";
9
+ import { type SearchTotal } from "../search/total.js";
9
10
  import type { Link, NearestNote, Note, NoteHit } from "../store/index.js";
10
11
  import type { AccountKey, MessageStore } from "../store/store.js";
11
12
  export interface NotesSearchRequest {
@@ -71,7 +72,8 @@ export declare const embedMail: (store: MessageStore, options?: NotesEmbedOption
71
72
  command?: string;
72
73
  }) => Promise<NotesEmbedded>;
73
74
  /** Notes nearest in meaning to the query, best first; only notes already embedded can be found. */
74
- export declare const nearestNotes: (store: MessageStore, query: string, { model: choice, limit, folderIds, withInternal, source, env, threads, command, embedders, }: Omit<NotesEmbedOptions, "maxChunks"> & {
75
+ export declare const nearestNotes: (store: MessageStore, query: string, options: NearestOptions) => Promise<NearestNote[]>;
76
+ type NearestOptions = Omit<NotesEmbedOptions, "maxChunks"> & {
75
77
  limit: number;
76
78
  folderIds?: string[];
77
79
  withInternal?: boolean;
@@ -79,7 +81,7 @@ export declare const nearestNotes: (store: MessageStore, query: string, { model:
79
81
  command?: string;
80
82
  /** The search's own, when another part of it loads the same model. */
81
83
  embedders?: Pick<WarmEmbedders, "get">;
82
- }) => Promise<NearestNote[]>;
84
+ };
83
85
  export interface FoundNote {
84
86
  kind?: "document" | "note";
85
87
  provider?: string;
@@ -120,6 +122,8 @@ export interface NotesFound {
120
122
  /** Why meaning was not searched, when it was not. */
121
123
  meaning_skipped?: string;
122
124
  hits: FoundNote[];
125
+ /** How many notes match the query, every page together, as Elasticsearch's `hits.total`. */
126
+ total?: SearchTotal;
123
127
  has_more: boolean;
124
128
  next_offset?: number;
125
129
  /** What the notes found link to, most linked first. */
@@ -173,6 +177,13 @@ export declare const noteSourceOfQuery: (query: string) => {
173
177
  export declare const fieldsOfQuery: (root: QueryNode) => QueryNode | undefined;
174
178
  /** The words and phrases the query asks for, for the meaning half to embed; none it excludes, no field. */
175
179
  export declare const meaningWords: (root: QueryNode) => string;
180
+ /**
181
+ * The most notes a filter may hold for meaning to rank all of them, each by its best chunk. Above it, meaning ranks
182
+ * the nearest notes overall and keeps those inside the filter, so a narrow filter over a large store loses some. On
183
+ * the owner's store (1,454 documents, 23,308 chunks) scoring all of them took ~0.5 s against ~0.17 s for the
184
+ * nearest 50 overall, and the cost grows with chunks, so 2,000 notes keeps it under a second at that density.
185
+ */
186
+ export declare const RANK_WITHIN = 2000;
176
187
  /**
177
188
  * `search notes`: words and stems, and meaning when the local model is there, merged by reciprocal rank;
178
189
  * each hit says which half found it, and the answer lists what the notes found link to. Every field of the query and
@@ -199,3 +210,4 @@ export declare const searchNotesOf: (store: MessageStore, account: AccountKey, t
199
210
  accounts?: string;
200
211
  app?: AppIdentity;
201
212
  }) => Promise<NotesFoundOf>;
213
+ export {};
@@ -1,4 +1,4 @@
1
- import { CliError } from "@wirecat/cli-base";
1
+ import { CliError, isCliError } from "@wirecat/cli-base";
2
2
  import { dateRange, parseLucene, timezoneOf } from "@wirecat/cli-base/query";
3
3
  import { INTERNAL_NOTES } from "../domain/providers.js";
4
4
  import { normalizeTag } from "../domain/tags.js";
@@ -10,6 +10,7 @@ import { validateFields } from "../search/lucene/registry.js";
10
10
  import { hasStems } from "../search/lucene/resolved.js";
11
11
  import { SEARCH_QUERY } from "../search/lucene/types.js";
12
12
  import { createStemmer, DEFAULT_STEMMERS } from "../search/stem.js";
13
+ import { TOTAL_CAP, totalOf } from "../search/total.js";
13
14
  import { openPictureEmbedderFor } from "./document-pictures.js";
14
15
  import { documentSource } from "./document-source.js";
15
16
  import { plainQueryWords, titleQuery, titleTiers } from "./notes-title-tier.js";
@@ -122,7 +123,17 @@ export const embedEvents = (store, options = {}) => embedChunks(store, store.cal
122
123
  /** Embeds mail's chunks as notes' are embedded: same model and key, so search by meaning reads both. */
123
124
  export const embedMail = (store, options = {}) => embedChunks(store, store.mail, options);
124
125
  /** Notes nearest in meaning to the query, best first; only notes already embedded can be found. */
125
- export const nearestNotes = async (store, query, { model: choice, limit, folderIds, withInternal, source, env, threads, command, embedders, }) => {
126
+ export const nearestNotes = async (store, query, options) => {
127
+ const { key, vector, floor } = await queryVector(store, query, options);
128
+ const nearest = await store.notes.nearest(key, vector, {
129
+ limit: options.limit,
130
+ ...(options.folderIds ? { folderIds: options.folderIds } : {}),
131
+ ...(options.withInternal ? { withInternal: true } : {}),
132
+ ...(options.source ? { source: options.source } : {}),
133
+ });
134
+ return nearest.filter(({ score }) => score > floor);
135
+ };
136
+ const queryVector = async (store, query, { model: choice, folderIds, withInternal, source, env, threads, command, embedders }) => {
126
137
  if (!query.trim())
127
138
  throw new CliError("validation_error", "say what to find in the notes");
128
139
  const { model, directory, key } = localModel(choice, env, command);
@@ -134,14 +145,12 @@ export const nearestNotes = async (store, query, { model: choice, limit, folderI
134
145
  if (!(await store.notes.embedded(key, scope)))
135
146
  throw new CliError("not_found", `nothing searched here has vectors of ${model.id} yet, so only words were matched`);
136
147
  const [vector] = await withLocal(model, directory, threads, (embedder) => embedder.embed([query], "query"), embedders);
137
- const floor = meaningFloor(key);
138
- const nearest = await store.notes.nearest(key, vector, {
139
- limit,
140
- ...(folderIds ? { folderIds } : {}),
141
- ...(withInternal ? { withInternal: true } : {}),
142
- ...(source ? { source } : {}),
143
- });
144
- return nearest.filter(({ score }) => score > floor);
148
+ return { key, vector: vector, floor: meaningFloor(key) };
149
+ };
150
+ /** Every note of `refs` nearer in meaning than the model's floor, best first. */
151
+ const nearestAmong = async (store, query, refs, options) => {
152
+ const { key, vector, floor } = await queryVector(store, query, options);
153
+ return (await store.notes.scoreNotes(key, vector, refs)).filter(({ score }) => score > floor);
145
154
  };
146
155
  const MAX_LINE = 200;
147
156
  const MAX_NOTES_OFFSET = 1000;
@@ -289,6 +298,62 @@ const matching = async (store, refs, root, scope) => {
289
298
  });
290
299
  return new Map(found.items.map(({ ref, note, at }) => [ref, { note, at }]));
291
300
  };
301
+ /**
302
+ * The most notes a filter may hold for meaning to rank all of them, each by its best chunk. Above it, meaning ranks
303
+ * the nearest notes overall and keeps those inside the filter, so a narrow filter over a large store loses some. On
304
+ * the owner's store (1,454 documents, 23,308 chunks) scoring all of them took ~0.5 s against ~0.17 s for the
305
+ * nearest 50 overall, and the cost grows with chunks, so 2,000 notes keeps it under a second at that density.
306
+ */
307
+ export const RANK_WITHIN = 2000;
308
+ /** The notes of a filter, newest first, when there are at most `RANK_WITHIN`; `undefined` when more, or too costly. */
309
+ const filterRefs = async (store, narrowing, scope) => {
310
+ const resolved = await resolvedQuery(store, narrowing, scope.timezone);
311
+ try {
312
+ const found = await store.notes.matchingRefs({
313
+ root: resolved.root,
314
+ limit: RANK_WITHIN,
315
+ ...(scope.folderIds ? { folderIds: scope.folderIds } : {}),
316
+ ...(scope.withInternal ? { withInternal: true } : {}),
317
+ ...(scope.source ? { source: scope.source } : {}),
318
+ ...(resolved.stemmer ? { stemmer: resolved.stemmer } : {}),
319
+ });
320
+ return found.hasMore ? undefined : found.refs;
321
+ }
322
+ catch (error) {
323
+ if (tooLarge(error))
324
+ return undefined;
325
+ throw error;
326
+ }
327
+ };
328
+ const tooLarge = (error) => isCliError(error) && error.details.reason === "query.too-large";
329
+ /**
330
+ * The notes the words find, with those found by meaning: every note the search would list, paged to the end. Never
331
+ * below what the page already showed; a count the store refuses as too costly is that lower bound.
332
+ */
333
+ const notesTotal = async (store, text, { exact, scope, shown, hasMore, }, others, othersComplete) => {
334
+ let words;
335
+ try {
336
+ const { root, stemmer } = await storeQuery(store, text, {
337
+ exact,
338
+ ...(scope.timezone ? { timezone: scope.timezone } : {}),
339
+ });
340
+ words = await store.notes.matchingRefs({
341
+ root,
342
+ limit: TOTAL_CAP,
343
+ ...(scope.folderIds ? { folderIds: scope.folderIds } : {}),
344
+ ...(scope.withInternal ? { withInternal: true } : {}),
345
+ ...(scope.source ? { source: scope.source } : {}),
346
+ ...(stemmer ? { stemmer } : {}),
347
+ });
348
+ }
349
+ catch (error) {
350
+ if (tooLarge(error))
351
+ return totalOf(undefined, shown, hasMore);
352
+ throw error;
353
+ }
354
+ const count = new Set([...words.refs, ...others]).size + (words.hasMore ? 1 : 0);
355
+ return totalOf(count, shown, hasMore, othersComplete);
356
+ };
292
357
  /**
293
358
  * `search notes`: words and stems, and meaning when the local model is there, merged by reciprocal rank;
294
359
  * each hit says which half found it, and the answer lists what the notes found link to. Every field of the query and
@@ -317,24 +382,39 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
317
382
  };
318
383
  let meaningSkipped;
319
384
  let meaning;
385
+ /** Every note found by meaning; `meaningComplete` is false when the nearest overall were cut at the depth. */
386
+ let meaningRefs;
387
+ let meaningComplete = true;
320
388
  if (exact)
321
389
  meaningSkipped = "--exact searches words as written";
322
390
  else if (words) {
391
+ const lane = {
392
+ command,
393
+ ...(env === undefined ? {} : { env }),
394
+ ...(embedders === undefined ? {} : { embedders }),
395
+ ...(folderIds === undefined ? {} : { folderIds }),
396
+ ...(withInternal ? { withInternal: true } : {}),
397
+ ...(source === undefined ? {} : { source }),
398
+ };
399
+ const depth = Math.max(window, CANDIDATES);
323
400
  try {
324
- const nearest = await nearestNotes(store, words, {
325
- limit: Math.max(window, CANDIDATES),
326
- command,
327
- ...(env === undefined ? {} : { env }),
328
- ...(embedders === undefined ? {} : { embedders }),
329
- ...(folderIds === undefined ? {} : { folderIds }),
330
- ...(withInternal ? { withInternal: true } : {}),
331
- ...(source === undefined ? {} : { source }),
332
- });
401
+ const within = narrowing === undefined ? undefined : await filterRefs(store, narrowing, scope);
402
+ let nearest;
403
+ if (within === undefined) {
404
+ nearest = await nearestNotes(store, words, { ...lane, limit: depth });
405
+ meaningComplete = nearest.length < depth;
406
+ }
407
+ else {
408
+ const ranked = within.length ? await nearestAmong(store, words, within, lane) : [];
409
+ meaningRefs = new Set(ranked.map(({ ref }) => ref));
410
+ nearest = ranked.slice(0, depth);
411
+ }
333
412
  const allowed = await matching(store, nearest.map(({ ref }) => ref), narrowing, scope);
334
413
  meaning = nearest.flatMap(({ ref }) => {
335
414
  const found = allowed.get(ref);
336
415
  return found ? [{ ref, ...found }] : [];
337
416
  });
417
+ meaningRefs ??= new Set(meaning.map(({ ref }) => ref));
338
418
  }
339
419
  catch (error) {
340
420
  if (!(error instanceof CliError && error.code === "not_found"))
@@ -378,6 +458,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
378
458
  ];
379
459
  const page = slice ? ordered : ordered.slice(offset, window);
380
460
  const hasMore = slice ? matched.hasMore : ordered.length > window || matched.hasMore || titled.hasMore;
461
+ const total = await notesTotal(store, text, { exact, scope, shown: offset + page.length, hasMore }, [...tier.map(({ ref }) => ref), ...(meaningRefs ?? [])], meaningComplete);
381
462
  const hits = [];
382
463
  const counts = new Map();
383
464
  const accounts = await store.accounts.storedAccounts();
@@ -441,6 +522,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
441
522
  by: meaning === undefined ? "words" : "words and meaning",
442
523
  ...(meaningSkipped === undefined ? {} : { meaning_skipped: meaningSkipped }),
443
524
  hits,
525
+ total,
444
526
  has_more: hasMore,
445
527
  ...(hasMore && window <= MAX_NOTES_OFFSET ? { next_offset: window } : {}),
446
528
  linked: [...counts.values()].sort((a, b) => b.notes - a.notes || (a.name ?? a.ref ?? "").localeCompare(b.name ?? b.ref ?? "")),
@@ -31,6 +31,7 @@ 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, EmailLocationInput, EmailRecipient, EmailRef, EmailThread, EmailVectorHit, FolderEmail, Mailbox, MailboxInput, MailFilter, MailStore, SenderScore, ThreadDetails, ThreadSave, } from "./sqlite/emails.js";
33
33
  export { EVENT_FIELDS, type EventChunkToEmbed, type EventHit, type EventQuery, type EventSearch, type NearestEvent, } from "./sqlite/event-search.js";
34
+ export type { EvidenceSource } from "./sqlite/evidence-sources.js";
34
35
  export type { Fact, FactInput, FactsStore } from "./sqlite/facts.js";
35
36
  export type { FileListQuery, FilesStore, StoredFile } from "./sqlite/files.js";
36
37
  export { IMPORTED_DOCUMENT_QUERY, type ImportedDocuments } from "./sqlite/imported-documents.js";
@@ -6,7 +6,7 @@ export declare const projectAnnotationRequest: (request: ProjectAnnotationTarget
6
6
  ref: string;
7
7
  type: "decision" | "fact" | "promise" | "question" | "statement";
8
8
  statement: string;
9
- status: "accepted" | "proposed" | "reversed" | "superseded";
9
+ status: "accepted" | "proposed" | "rejected" | "reversed" | "superseded";
10
10
  subject: string | null;
11
11
  occurred_at: number | null;
12
12
  supersedes: string | null;
@@ -25,7 +25,7 @@ export declare const projectAnnotationsSchema: v.StrictObjectSchema<{
25
25
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
26
26
  readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"], undefined>;
27
27
  readonly statement: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
28
- readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed"], undefined>;
28
+ readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed", "rejected"], undefined>;
29
29
  readonly subject: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
30
30
  readonly occurred_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.SafeIntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
31
31
  readonly supersedes: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
@@ -40,7 +40,7 @@ export declare const projectAnnotationsSchema: v.StrictObjectSchema<{
40
40
  ref: string;
41
41
  type: "decision" | "fact" | "promise" | "question" | "statement";
42
42
  statement: string;
43
- status: "accepted" | "proposed" | "reversed" | "superseded";
43
+ status: "accepted" | "proposed" | "rejected" | "reversed" | "superseded";
44
44
  subject: string | null;
45
45
  occurred_at: number | null;
46
46
  supersedes: string | null;
@@ -6,7 +6,7 @@ export declare const projectAnnotationSnapshotSchema: v.StrictObjectSchema<{
6
6
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
7
7
  readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"], undefined>;
8
8
  readonly statement: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
9
- readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed"], undefined>;
9
+ readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed", "rejected"], undefined>;
10
10
  readonly subject: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
11
11
  readonly occurred_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.SafeIntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
12
12
  readonly supersedes: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
@@ -21,7 +21,7 @@ export declare const projectAnnotationSnapshotSchema: v.StrictObjectSchema<{
21
21
  ref: string;
22
22
  type: "decision" | "fact" | "promise" | "question" | "statement";
23
23
  statement: string;
24
- status: "accepted" | "proposed" | "reversed" | "superseded";
24
+ status: "accepted" | "proposed" | "rejected" | "reversed" | "superseded";
25
25
  subject: string | null;
26
26
  occurred_at: number | null;
27
27
  supersedes: string | null;
@@ -13,6 +13,8 @@ export interface QueriesStore {
13
13
  matchQuery(execution: QueryExecution): Promise<Page<ScoredHit>>;
14
14
  /** The same matches as `matchQuery`, each counted once, grouped by chat, sender or quarter hour. */
15
15
  countQuery(execution: QueryExecution, by: QueryGrouping): Promise<QueryGroup[]>;
16
+ /** How many messages match, counted no further than `cap + 1`; `undefined` when only a full search could tell. */
17
+ countMatches(execution: QueryExecution, cap: number): Promise<number | undefined>;
16
18
  conversationEligibility(execution: QueryExecution): Promise<ConversationEligibility>;
17
19
  rankQuery(execution: QueryExecution, request: RankingRequest): Promise<RankedStoreFound>;
18
20
  rankingEvidence(execution: QueryExecution, request: RankingEvidenceRequest): Promise<RankedEvidence>;
@@ -13,6 +13,7 @@ export const queriesStoreOver = (context, stemmerFor) => {
13
13
  return {
14
14
  matchQuery: async (execution) => lucene.matchQuery(context, mailIndexed(execution)),
15
15
  countQuery: async (execution, by) => lucene.countQuery(context, mailIndexed(execution), by),
16
+ countMatches: async (execution, cap) => lucene.countMatches(context, mailIndexed(execution), cap),
16
17
  conversationEligibility: async (execution) => conversationEligibility(context, execution),
17
18
  rankQuery: async (execution, request) => rankQuery(context, execution, request),
18
19
  rankingEvidence: async (execution, request) => rankingEvidence(context, execution, request),
@@ -0,0 +1,23 @@
1
+ import type { CacheDatabase } from "../driver.js";
2
+ /** One reference a memory or fact rests on, read back whole enough to judge it without opening it. */
3
+ export interface EvidenceSource {
4
+ /** `null` when the row is gone from the store and its reference with it. */
5
+ ref: string | null;
6
+ kind: string;
7
+ title: string | null;
8
+ sender: string | null;
9
+ at: string | null;
10
+ excerpt: string | null;
11
+ /** The provider the source came through: `telegram`, `email`, `notes`, `zoom`… */
12
+ tool: string | null;
13
+ /** Deleted at its source since it was cited, or no longer in the store. */
14
+ missing: boolean;
15
+ }
16
+ /**
17
+ * Every `evidence` link from a memory or fact, in the order they were written, including one whose target was
18
+ * deleted since: `evidenceOf` drops those, so a reader would never learn the record lost a source.
19
+ */
20
+ export declare const evidenceSourcesOf: (database: CacheDatabase, from: {
21
+ type: string;
22
+ id: number;
23
+ }) => EvidenceSource[];
@@ -0,0 +1,59 @@
1
+ import { linkKind } from "./link-kinds.js";
2
+ import { referenceOfThing, stateOfThing, storedThing } from "./things.js";
3
+ const EXCERPT_CHARS = 280;
4
+ const excerptOf = (text) => {
5
+ if (text == null)
6
+ return null;
7
+ const flat = String(text).replace(/\s+/g, " ").trim();
8
+ if (!flat)
9
+ return null;
10
+ return flat.length > EXCERPT_CHARS ? `${flat.slice(0, EXCERPT_CHARS - 1)}…` : flat;
11
+ };
12
+ const iso = (value) => (value == null ? null : new Date(Number(value)).toISOString());
13
+ const textOf = (value) => (value == null || String(value).trim() === "" ? null : String(value));
14
+ const READS = {
15
+ message: "SELECT c.title, coalesce(m.sender_name, i.name) AS sender, m.sent_at AS at, m.text AS body, a.provider AS tool " +
16
+ "FROM messages m JOIN chats c ON c.id = m.chat_id JOIN accounts a ON a.id = c.account_id " +
17
+ "LEFT JOIN identities i ON i.id = m.sender_identity_id WHERE m.id = ?",
18
+ email: "SELECT e.subject AS title, coalesce(e.from_name, e.from_address) AS sender, e.sent_at AS at, " +
19
+ "coalesce(e.body_text, e.snippet) AS body, 'email' AS tool FROM emails e WHERE e.id = ?",
20
+ note: "SELECT title, NULL AS sender, updated_at AS at, body, 'notes' AS tool FROM notes WHERE id = ?",
21
+ document: "SELECT d.title, NULL AS sender, coalesce(d.external_updated_at, d.updated_at) AS at, d.body, a.provider AS tool " +
22
+ "FROM documents d JOIN accounts a ON a.id = d.account_id WHERE d.id = ?",
23
+ meeting: "SELECT m.title, NULL AS sender, m.started_at AS at, m.description AS body, a.provider AS tool " +
24
+ "FROM meetings m JOIN accounts a ON a.id = m.account_id WHERE m.id = ?",
25
+ meeting_transcript: "SELECT m.title, NULL AS sender, m.started_at AS at, NULL AS body, a.provider AS tool FROM meeting_transcripts t " +
26
+ "JOIN meetings m ON m.id = t.meeting_id JOIN accounts a ON a.id = m.account_id WHERE t.id = ?",
27
+ meeting_transcript_row: "SELECT m.title, r.speaker_name AS sender, m.started_at + coalesce(r.start_ms, 0) AS at, r.text AS body, " +
28
+ "a.provider AS tool FROM meeting_transcript_rows r JOIN meeting_transcripts t ON t.id = r.meeting_transcript_id " +
29
+ "JOIN meetings m ON m.id = t.meeting_id JOIN accounts a ON a.id = m.account_id WHERE r.id = ?",
30
+ };
31
+ const KIND = {
32
+ meeting_transcript: "meeting",
33
+ meeting_transcript_row: "meeting",
34
+ };
35
+ /**
36
+ * Every `evidence` link from a memory or fact, in the order they were written, including one whose target was
37
+ * deleted since: `evidenceOf` drops those, so a reader would never learn the record lost a source.
38
+ */
39
+ export const evidenceSourcesOf = (database, from) => database
40
+ .prepare(`SELECT to_type, to_id FROM links WHERE from_type = ? AND from_id = ? AND kind = ${linkKind("evidence")} ` +
41
+ "AND to_id IS NOT NULL ORDER BY id")
42
+ .all(from.type, from.id)
43
+ .map((link) => {
44
+ const thing = storedThing(link.to_type, link.to_id);
45
+ const state = stateOfThing(database, thing);
46
+ const read = READS[thing.type];
47
+ const row = read === undefined || state === "unavailable" ? undefined : database.prepare(read).get(thing.id);
48
+ const gone = state !== "available";
49
+ return {
50
+ ref: referenceOfThing(database, thing) ?? null,
51
+ kind: KIND[thing.type] ?? thing.type,
52
+ title: textOf(row?.title),
53
+ sender: textOf(row?.sender),
54
+ at: iso(row?.at),
55
+ excerpt: gone ? null : excerptOf(row?.body),
56
+ tool: textOf(row?.tool),
57
+ missing: gone,
58
+ };
59
+ });
@@ -1,5 +1,6 @@
1
1
  import { type FACT_STATUSES, FACT_TYPES, MEMORY_SCOPES } from "../../domain/knowledge.js";
2
2
  import type { Actor } from "./actors.js";
3
+ import { type EvidenceSource } from "./evidence-sources.js";
3
4
  import { type Author } from "./memories.js";
4
5
  import type { StoreContext } from "./open.js";
5
6
  /** What happened, as its evidence shows; its evidence is `links` of kind `evidence`. */
@@ -12,6 +13,8 @@ export interface Fact {
12
13
  status: (typeof FACT_STATUSES)[number];
13
14
  project: string | null;
14
15
  subject: string | null;
16
+ subjectName: string | null;
17
+ subjectIsOwner: boolean;
15
18
  occurredAt: string | null;
16
19
  supersedes: string | null;
17
20
  /** `name` is a bot's handle; `null` for a person. */
@@ -61,6 +64,19 @@ export interface FactsStore {
61
64
  accept(reference: string): Promise<Fact>;
62
65
  /** An accepted decision no longer holds and nothing replaced it. */
63
66
  reverse(reference: string): Promise<Fact>;
67
+ /** The owner turns down a proposed fact; it stays, marked `rejected`. */
68
+ reject(reference: string): Promise<Fact>;
69
+ /**
70
+ * Erases the fact: its row, its links, its tags, and the memory it was migrated from, which still held its text.
71
+ * A decision that replaced it no longer names it. A backup made before keeps it.
72
+ */
73
+ remove(reference: string): Promise<{
74
+ ref: string;
75
+ removed: true;
76
+ links: number;
77
+ }>;
78
+ /** Each reference it rests on, read back; one deleted since is `missing`. */
79
+ sources(reference: string): Promise<EvidenceSource[]>;
64
80
  }
65
81
  type FactOperations = {
66
82
  [K in keyof FactsStore]: FactsStore[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => R : never;
@@ -1,10 +1,10 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { FACT_TYPES, MEMORY_SCOPES } from "../../domain/knowledge.js";
3
3
  import { calendarMoment } from "../../domain/moment.js";
4
+ import { evidenceSourcesOf } from "./evidence-sources.js";
4
5
  import { linkKind } from "./link-kinds.js";
5
- import { authorActor, authorOf, evidenceOf, linkEvidence } from "./memories.js";
6
+ import { authorActor, authorOf, dropLinksOf, erase, evidenceOf, linkEvidence, subjectOf, } from "./memories.js";
6
7
  import { requiredThing } from "./notes.js";
7
- import { referenceOfThing, storedThing } from "./things.js";
8
8
  import { write } from "./transaction.js";
9
9
  const NAMED = "SELECT f.*, b.name AS author_name FROM facts f LEFT JOIN bots b ON f.author_type = 'bot' AND b.id = f.author_id";
10
10
  const iso = (value) => new Date(Number(value)).toISOString();
@@ -22,9 +22,7 @@ export const factOperationsOver = (context) => {
22
22
  statement: String(row.statement),
23
23
  status: row.status,
24
24
  project: row.project_id == null ? null : `project:${row.project_id}`,
25
- subject: row.subject_type == null
26
- ? null
27
- : (referenceOfThing(database, storedThing(row.subject_type, row.subject_id)) ?? null),
25
+ ...subjectOf(database, row),
28
26
  occurredAt: row.occurred_at == null ? null : iso(row.occurred_at),
29
27
  supersedes: row.supersedes_id == null ? null : `fact:${row.supersedes_id}`,
30
28
  author: authorOf(row),
@@ -125,8 +123,41 @@ export const factOperationsOver = (context) => {
125
123
  database.prepare("UPDATE facts SET status = 'reversed', updated_at = ? WHERE id = ?").run(now(), Number(row.id));
126
124
  return factOf(rowOf(reference));
127
125
  },
126
+ reject: (reference) => {
127
+ const row = rowOf(reference);
128
+ if (row.status !== "proposed")
129
+ throw new CliError("validation_error", `only a proposed fact can be rejected; this one is ${String(row.status)}`);
130
+ database.prepare("UPDATE facts SET status = 'rejected', updated_at = ? WHERE id = ?").run(now(), Number(row.id));
131
+ return factOf(rowOf(reference));
132
+ },
133
+ remove: (reference) => {
134
+ const row = rowOf(reference);
135
+ const id = Number(row.id);
136
+ const twin = row.source === "memory" && row.metadata != null ? migratedFrom(String(row.metadata)) : undefined;
137
+ const links = erase(database, () => {
138
+ database.prepare("UPDATE facts SET supersedes_id = NULL WHERE supersedes_id = ?").run(id);
139
+ const count = dropLinksOf(database, { type: "fact", id });
140
+ database.prepare("DELETE FROM links WHERE from_type = 'fact' AND from_id = ?").run(id);
141
+ database.prepare("DELETE FROM taggings WHERE taggable_type = 'fact' AND taggable_id = ?").run(id);
142
+ database.prepare("DELETE FROM facts WHERE id = ?").run(id);
143
+ if (twin !== undefined)
144
+ database.prepare("DELETE FROM memories WHERE id = ? AND deleted_at IS NOT NULL").run(twin);
145
+ return count;
146
+ });
147
+ return { ref: `fact:${id}`, removed: true, links };
148
+ },
149
+ sources: (reference) => evidenceSourcesOf(database, { type: "fact", id: Number(rowOf(reference).id) }),
128
150
  };
129
151
  };
152
+ const migratedFrom = (metadata) => {
153
+ try {
154
+ const { memory } = JSON.parse(metadata);
155
+ return Number.isInteger(memory) ? Number(memory) : undefined;
156
+ }
157
+ catch {
158
+ return undefined;
159
+ }
160
+ };
130
161
  export const factsStoreOver = (context) => {
131
162
  const operations = factOperationsOver(context);
132
163
  return {
@@ -135,5 +166,8 @@ export const factsStoreOver = (context) => {
135
166
  list: async (options) => operations.list(options),
136
167
  accept: async (reference) => operations.accept(reference),
137
168
  reverse: async (reference) => operations.reverse(reference),
169
+ reject: async (reference) => operations.reject(reference),
170
+ remove: async (reference) => operations.remove(reference),
171
+ sources: async (reference) => operations.sources(reference),
138
172
  };
139
173
  };
@@ -18,6 +18,11 @@ export declare const matchQuery: (context: StoreContext, execution: QueryExecuti
18
18
  export declare const countQuery: (context: StoreContext, execution: QueryExecution, by: QueryGrouping) => Promise<QueryGroup[]>;
19
19
  /** All matching message keys, without materializing message bodies or imposing a hit-page limit. */
20
20
  export declare const queryMessagePks: (context: StoreContext, execution: QueryExecution) => Promise<number[]>;
21
+ /**
22
+ * How many messages match, counting no further than `cap + 1`: one SQL count, unranked. `undefined` when a leaf is
23
+ * checked row by row (a preset, a pattern), where a count would cost as much as the search itself.
24
+ */
25
+ export declare const countMatches: (context: StoreContext, execution: QueryExecution, cap: number) => number | undefined;
21
26
  interface QuerySelection {
22
27
  sql: string;
23
28
  params: SqlValue[];
@@ -48,6 +48,23 @@ export const matchQuery = (context, execution) => runQuery(context, execution);
48
48
  export const countQuery = (context, execution, by) => runQuery(context, execution, by);
49
49
  /** All matching message keys, without materializing message bodies or imposing a hit-page limit. */
50
50
  export const queryMessagePks = (context, execution) => runQuery(context, execution, "pks");
51
+ /**
52
+ * How many messages match, counting no further than `cap + 1`: one SQL count, unranked. `undefined` when a leaf is
53
+ * checked row by row (a preset, a pattern), where a count would cost as much as the search itself.
54
+ */
55
+ export const countMatches = (context, execution, cap) => {
56
+ const { database, check, where, from, withRanking, ranked, leaves } = compileQuery(context, {
57
+ ...execution,
58
+ newest: true,
59
+ });
60
+ if (leaves.some(({ test }) => test))
61
+ return undefined;
62
+ const row = database
63
+ .prepare(`SELECT count(*) AS n FROM (${withRanking}SELECT m.id ${from} WHERE ${where.sql} LIMIT ?)`)
64
+ .get(...ranked.params, ...where.params, cap + 1);
65
+ check();
66
+ return Number(row?.n ?? 0);
67
+ };
51
68
  const compileQuery = (context, execution, boundedAttachments = false) => {
52
69
  const { database } = context;
53
70
  const corpus = execution.corpus === "mail" ? MAIL : MESSAGES;