@wirecat/cli-core 0.386.0 → 0.388.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.388.0 — 22.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`searchMeetingsCombined`: meeting search by words and meaning in one ranking.** Word hits and passage vectors of
15
+ one model are merged by reciprocal rank fusion over one selection of meetings: the date, meeting, series, event,
16
+ participant, person, project and tag filters apply to both. A word hit inside a passage the meaning search found
17
+ joins it (`by: ["words", "meaning"]`) and is cited by its own line. A question («что решили по цене?», "what did we
18
+ decide about pricing?") searches its content words; other text is the meeting query language, and text it refuses
19
+ is asked by meaning alone (`words.refused`). On a tie a line with the words ranks above a passage that only
20
+ resembles the query. The ranking covers
21
+ every vector of the selection up to `maxChunks` (default 10000), read in batches with text loaded only for the
22
+ passages kept; pages continue one ranking (`nextCursor`), bound to the query, filters and model.
23
+ - **`meetingVectors.ranked`** — the nearest passages over the whole selection, with meeting title and start.
24
+ `questionTerms` (`search/question-plan`) — a question's content words, or null.
25
+
26
+ ## 0.387.0 — 22.10.2026
27
+
28
+ ### Added
29
+
30
+ - `dateWindow` and `DateWindow` from `./cli`: `--since-time` and `--until-time` read together, with the
31
+ `meta.timezone` and `meta.window` an answer reports them by (WC-TIME-3, WC-TIME-5).
32
+
10
33
  ## 0.386.0 — 22.10.2026
11
34
 
12
35
  ### Added
@@ -27,7 +27,7 @@ export { guardedApp, guardedPress, guardedStart, type Pressed } from "../sends/b
27
27
  export { guardedClose, guardedCreatePoll, guardedVote } from "../sends/polls.js";
28
28
  export type { InboxReader } from "../services/inbox.js";
29
29
  export { INBOX_CHATS, newIn, REVIEW_CHATS, reviewIn, unreadIn } from "../services/inbox.js";
30
- export { momentOf } from "../services/moment.js";
30
+ export { type DateWindow, dateWindow, momentOf } from "../services/moment.js";
31
31
  export { maskedAccount } from "../services/people.js";
32
32
  export { unanswered } from "../services/questions.js";
33
33
  export { openRequestTask } from "../services/task-rules.js";
package/dist/cli/index.js CHANGED
@@ -22,7 +22,7 @@ export { currentRun, inRun, useAsProcessRun } from "../runs/scope.js";
22
22
  export { guardedApp, guardedPress, guardedStart } from "../sends/buttons.js";
23
23
  export { guardedClose, guardedCreatePoll, guardedVote } from "../sends/polls.js";
24
24
  export { INBOX_CHATS, newIn, REVIEW_CHATS, reviewIn, unreadIn } from "../services/inbox.js";
25
- export { momentOf } from "../services/moment.js";
25
+ export { dateWindow, momentOf } from "../services/moment.js";
26
26
  export { maskedAccount } from "../services/people.js";
27
27
  export { unanswered } from "../services/questions.js";
28
28
  export { openRequestTask } from "../services/task-rules.js";
@@ -38,6 +38,7 @@ export declare const meetingTools: ({
38
38
  max_read_bytes?: undefined;
39
39
  cursor?: undefined;
40
40
  } | {
41
+ limit?: undefined;
41
42
  event_id?: undefined;
42
43
  series_id?: undefined;
43
44
  page?: undefined;
@@ -53,11 +54,11 @@ export declare const meetingTools: ({
53
54
  format?: undefined;
54
55
  transcript_id?: undefined;
55
56
  query?: undefined;
56
- limit?: undefined;
57
57
  scan_limit?: undefined;
58
58
  max_read_bytes?: undefined;
59
59
  cursor?: undefined;
60
60
  } | {
61
+ limit?: undefined;
61
62
  event_id?: undefined;
62
63
  series_id?: undefined;
63
64
  page?: undefined;
@@ -75,11 +76,11 @@ export declare const meetingTools: ({
75
76
  format?: undefined;
76
77
  transcript_id?: undefined;
77
78
  query?: undefined;
78
- limit?: undefined;
79
79
  scan_limit?: undefined;
80
80
  max_read_bytes?: undefined;
81
81
  cursor?: undefined;
82
82
  } | {
83
+ limit?: undefined;
83
84
  event_id?: undefined;
84
85
  series_id?: undefined;
85
86
  page?: undefined;
@@ -101,7 +102,6 @@ export declare const meetingTools: ({
101
102
  minimum: number;
102
103
  };
103
104
  query?: undefined;
104
- limit?: undefined;
105
105
  scan_limit?: undefined;
106
106
  max_read_bytes?: undefined;
107
107
  cursor?: undefined;
@@ -188,6 +188,7 @@ export declare const meetingTools: ({
188
188
  max_read_bytes?: undefined;
189
189
  cursor?: undefined;
190
190
  } | {
191
+ limit?: undefined;
191
192
  event_id?: undefined;
192
193
  series_id?: undefined;
193
194
  page?: undefined;
@@ -203,11 +204,11 @@ export declare const meetingTools: ({
203
204
  format?: undefined;
204
205
  transcript_id?: undefined;
205
206
  query?: undefined;
206
- limit?: undefined;
207
207
  scan_limit?: undefined;
208
208
  max_read_bytes?: undefined;
209
209
  cursor?: undefined;
210
210
  } | {
211
+ limit?: undefined;
211
212
  event_id?: undefined;
212
213
  series_id?: undefined;
213
214
  page?: undefined;
@@ -225,11 +226,11 @@ export declare const meetingTools: ({
225
226
  format?: undefined;
226
227
  transcript_id?: undefined;
227
228
  query?: undefined;
228
- limit?: undefined;
229
229
  scan_limit?: undefined;
230
230
  max_read_bytes?: undefined;
231
231
  cursor?: undefined;
232
232
  } | {
233
+ limit?: undefined;
233
234
  event_id?: undefined;
234
235
  series_id?: undefined;
235
236
  page?: undefined;
@@ -251,7 +252,6 @@ export declare const meetingTools: ({
251
252
  minimum: number;
252
253
  };
253
254
  query?: undefined;
254
- limit?: undefined;
255
255
  scan_limit?: undefined;
256
256
  max_read_bytes?: undefined;
257
257
  cursor?: undefined;
@@ -338,6 +338,7 @@ export declare const meetingTools: ({
338
338
  max_read_bytes?: undefined;
339
339
  cursor?: undefined;
340
340
  } | {
341
+ limit?: undefined;
341
342
  event_id?: undefined;
342
343
  series_id?: undefined;
343
344
  page?: undefined;
@@ -353,11 +354,11 @@ export declare const meetingTools: ({
353
354
  format?: undefined;
354
355
  transcript_id?: undefined;
355
356
  query?: undefined;
356
- limit?: undefined;
357
357
  scan_limit?: undefined;
358
358
  max_read_bytes?: undefined;
359
359
  cursor?: undefined;
360
360
  } | {
361
+ limit?: undefined;
361
362
  event_id?: undefined;
362
363
  series_id?: undefined;
363
364
  page?: undefined;
@@ -375,11 +376,11 @@ export declare const meetingTools: ({
375
376
  format?: undefined;
376
377
  transcript_id?: undefined;
377
378
  query?: undefined;
378
- limit?: undefined;
379
379
  scan_limit?: undefined;
380
380
  max_read_bytes?: undefined;
381
381
  cursor?: undefined;
382
382
  } | {
383
+ limit?: undefined;
383
384
  event_id?: undefined;
384
385
  series_id?: undefined;
385
386
  page?: undefined;
@@ -401,7 +402,6 @@ export declare const meetingTools: ({
401
402
  minimum: number;
402
403
  };
403
404
  query?: undefined;
404
- limit?: undefined;
405
405
  scan_limit?: undefined;
406
406
  max_read_bytes?: undefined;
407
407
  cursor?: undefined;
@@ -488,6 +488,7 @@ export declare const meetingTools: ({
488
488
  max_read_bytes?: undefined;
489
489
  cursor?: undefined;
490
490
  } | {
491
+ limit?: undefined;
491
492
  event_id?: undefined;
492
493
  series_id?: undefined;
493
494
  page?: undefined;
@@ -503,11 +504,11 @@ export declare const meetingTools: ({
503
504
  format?: undefined;
504
505
  transcript_id?: undefined;
505
506
  query?: undefined;
506
- limit?: undefined;
507
507
  scan_limit?: undefined;
508
508
  max_read_bytes?: undefined;
509
509
  cursor?: undefined;
510
510
  } | {
511
+ limit?: undefined;
511
512
  event_id?: undefined;
512
513
  series_id?: undefined;
513
514
  page?: undefined;
@@ -525,11 +526,11 @@ export declare const meetingTools: ({
525
526
  format?: undefined;
526
527
  transcript_id?: undefined;
527
528
  query?: undefined;
528
- limit?: undefined;
529
529
  scan_limit?: undefined;
530
530
  max_read_bytes?: undefined;
531
531
  cursor?: undefined;
532
532
  } | {
533
+ limit?: undefined;
533
534
  event_id?: undefined;
534
535
  series_id?: undefined;
535
536
  page?: undefined;
@@ -551,7 +552,6 @@ export declare const meetingTools: ({
551
552
  minimum: number;
552
553
  };
553
554
  query?: undefined;
554
- limit?: undefined;
555
555
  scan_limit?: undefined;
556
556
  max_read_bytes?: undefined;
557
557
  cursor?: undefined;
@@ -638,6 +638,7 @@ export declare const meetingTools: ({
638
638
  max_read_bytes?: undefined;
639
639
  cursor?: undefined;
640
640
  } | {
641
+ limit?: undefined;
641
642
  event_id?: undefined;
642
643
  series_id?: undefined;
643
644
  page?: undefined;
@@ -653,11 +654,11 @@ export declare const meetingTools: ({
653
654
  format?: undefined;
654
655
  transcript_id?: undefined;
655
656
  query?: undefined;
656
- limit?: undefined;
657
657
  scan_limit?: undefined;
658
658
  max_read_bytes?: undefined;
659
659
  cursor?: undefined;
660
660
  } | {
661
+ limit?: undefined;
661
662
  event_id?: undefined;
662
663
  series_id?: undefined;
663
664
  page?: undefined;
@@ -675,11 +676,11 @@ export declare const meetingTools: ({
675
676
  format?: undefined;
676
677
  transcript_id?: undefined;
677
678
  query?: undefined;
678
- limit?: undefined;
679
679
  scan_limit?: undefined;
680
680
  max_read_bytes?: undefined;
681
681
  cursor?: undefined;
682
682
  } | {
683
+ limit?: undefined;
683
684
  event_id?: undefined;
684
685
  series_id?: undefined;
685
686
  page?: undefined;
@@ -701,7 +702,6 @@ export declare const meetingTools: ({
701
702
  minimum: number;
702
703
  };
703
704
  query?: undefined;
704
- limit?: undefined;
705
705
  scan_limit?: undefined;
706
706
  max_read_bytes?: undefined;
707
707
  cursor?: undefined;
@@ -1,4 +1,9 @@
1
1
  export declare const words: (text: string) => RegExpMatchArray | [];
2
+ /**
3
+ * A question's content words — «что решили по цене?» → решили, цене — or null when the text is not a question: it
4
+ * ends with «?» or starts with a question word.
5
+ */
6
+ export declare const questionTerms: (text: string) => string[] | null;
2
7
  export declare function planQuestion(text: string, strict?: boolean): {
3
8
  changed: boolean;
4
9
  natural: boolean;
@@ -4,6 +4,17 @@ import { SEARCH_QUERY } from "./lucene/types.js";
4
4
  export const words = (text) => normalize(text).match(/[\p{L}\p{N}]+/gu) ?? [];
5
5
  const scaffolding = new Set(words("what which how many much who where when is are was were do does did can could should we the a an for of to under from in on at has have сколько какой какая какие какое кто кому кем где когда как что во по на в к с у через ли должен должна нужно идёт идет можно может могут"));
6
6
  const questionWord = /\b(?:what|which|how|who|where|when|can|could|should)\b|(?:^|\s)(?:сколько|какой|какая|какие|какое|кто|кому|кем|где|когда|как|что|можно|может|могут|ли)(?:\s|$)/iu;
7
+ const QUESTION_STARTS = new Set(words("what which how who whom whose where when why can could should would did do does is are was were will " +
8
+ "что кто кого кому кем где когда как какой какая какое какие какую каким какого каких почему зачем откуда " +
9
+ "куда сколько ли можно разве чей чья чьё чьи"));
10
+ const QUESTION_FILLER = new Set(words("about any did does why whom whose would will это мы вы я они он она о об про ли"));
11
+ /**
12
+ * A question's content words — «что решили по цене?» → решили, цене — or null when the text is not a question: it
13
+ * ends with «?» or starts with a question word.
14
+ */
15
+ export const questionTerms = (text) => /\?\s*$/u.test(text) || QUESTION_STARTS.has(words(text)[0] ?? "")
16
+ ? words(text).filter((word) => !scaffolding.has(word) && !QUESTION_STARTS.has(word) && !QUESTION_FILLER.has(word))
17
+ : null;
7
18
  export function planQuestion(text, strict = false) {
8
19
  if (strict)
9
20
  return {
@@ -139,6 +139,7 @@ export { countersService } from "./counters.js";
139
139
  export { type DocumentTarget, openPictureEmbedderFor, type PictureOptions, removePicture, savePicture, } from "./document-pictures.js";
140
140
  export type { ModelChoice } from "./embeddings.js";
141
141
  export { createMeetingBatchReader } from "./meeting-batch.js";
142
+ export { type MeetingCombinedHit, type MeetingCombinedRequest, searchMeetingsCombined, } from "./meeting-combined.js";
142
143
  export type { MeetingCommunicationCoverage, MeetingCommunicationExcerpt, MeetingCommunications, MeetingCommunicationsPort, MeetingCommunicationsRequest, } from "./meeting-communications-types.js";
143
144
  export { createMeetingContextService, type MeetingContextService, type MeetingContextServiceStore, } from "./meeting-context.js";
144
145
  export { type MeetingEvidenceCue, type MeetingEvidenceOptions, type MeetingEvidencePacket, readMeetingEvidence, } from "./meeting-evidence.js";
@@ -105,6 +105,7 @@ export { GAP_BOUNDS, gapsService } from "./archive-gaps.js";
105
105
  export { countersService } from "./counters.js";
106
106
  export { openPictureEmbedderFor, removePicture, savePicture, } from "./document-pictures.js";
107
107
  export { createMeetingBatchReader } from "./meeting-batch.js";
108
+ export { searchMeetingsCombined, } from "./meeting-combined.js";
108
109
  export { createMeetingContextService, } from "./meeting-context.js";
109
110
  export { readMeetingEvidence, } from "./meeting-evidence.js";
110
111
  export { meetingHitContextLimits, readMeetingHitContext, } from "./meeting-hit-context.js";
@@ -0,0 +1,95 @@
1
+ import { type MeetingHitSearchRequest, type SearchHit } from "../meetings/index.js";
2
+ import type { MessageStore } from "../store/store.js";
3
+ import type { MeetingEmbedder } from "./meeting-model.js";
4
+ export interface MeetingCombinedRequest extends MeetingHitSearchRequest {
5
+ /** Most stored passage vectors to compare, 1 through 100000 (default 10000). */
6
+ maxChunks?: number;
7
+ maxRows?: number;
8
+ maxTextBytes?: number;
9
+ }
10
+ export interface MeetingCombinedHit {
11
+ /** A transcript line to cite: the best word hit in the passage, else the passage's first line. */
12
+ ref: string;
13
+ meetingId: number;
14
+ scope: SearchHit["scope"];
15
+ text: string;
16
+ startMs: number | null;
17
+ source: {
18
+ title: string | null;
19
+ startedAt: number | null;
20
+ url: string | null;
21
+ };
22
+ /** Which searches found it: `words`, `meaning` or both. */
23
+ by: ("words" | "meaning")[];
24
+ /** Similarity in meaning, when the meaning search found it. */
25
+ score: number | null;
26
+ /** The passage the meaning search matched, as its first and last line. */
27
+ passage?: {
28
+ first: string;
29
+ last: string;
30
+ };
31
+ match?: SearchHit["match"];
32
+ corrections?: SearchHit["corrections"];
33
+ }
34
+ /**
35
+ * Words and meaning, ranked together by reciprocal rank fusion over one selection of meetings. A question
36
+ * («что решили по цене?») searches its content words; other text is the meeting query language. A word hit inside
37
+ * a passage the meaning search found joins it, so one discussion found both ways ranks first.
38
+ */
39
+ export declare const searchMeetingsCombined: (store: Pick<MessageStore, "meetings" | "meetingVectors"> & {
40
+ knowledge?: MessageStore["knowledge"];
41
+ }, embedder: MeetingEmbedder, query: string, request: MeetingCombinedRequest) => Promise<{
42
+ items: {
43
+ /** A transcript line to cite: the best word hit in the passage, else the passage's first line. */
44
+ ref: string;
45
+ meetingId: number;
46
+ scope: SearchHit["scope"];
47
+ text: string;
48
+ startMs: number | null;
49
+ source: {
50
+ title: string | null;
51
+ startedAt: number | null;
52
+ url: string | null;
53
+ };
54
+ /** Which searches found it: `words`, `meaning` or both. */
55
+ by: ("words" | "meaning")[];
56
+ /** Similarity in meaning, when the meaning search found it. */
57
+ score: number | null;
58
+ /** The passage the meaning search matched, as its first and last line. */
59
+ passage?: {
60
+ first: string;
61
+ last: string;
62
+ };
63
+ match?: SearchHit["match"];
64
+ corrections?: SearchHit["corrections"];
65
+ }[];
66
+ hasMore: boolean;
67
+ nextCursor: string | null;
68
+ selection: {
69
+ kind: import("../store/index.js").VocabularyKind;
70
+ id: number;
71
+ name: string;
72
+ by: "explicit-link";
73
+ }[];
74
+ words: {
75
+ query: string;
76
+ from?: "question" | undefined;
77
+ refused?: string | undefined;
78
+ };
79
+ model: {
80
+ key: string;
81
+ model: string;
82
+ kind: "local" | "remote";
83
+ dims: number;
84
+ };
85
+ coverage: {
86
+ archiveCompleteness: "unknown";
87
+ indexCompleteness: "unknown";
88
+ indexing: "pending" | "ready";
89
+ /** Every vector of the selection was compared; false when `maxChunks` stopped the scan. */
90
+ meaningScanComplete: boolean;
91
+ scanned: number;
92
+ /** Each side ranks this many at most; pages past them hold nothing more. */
93
+ depth: number;
94
+ };
95
+ }>;
@@ -0,0 +1,163 @@
1
+ import { createHash } from "node:crypto";
2
+ import { CliError } from "@wirecat/cli-base";
3
+ import { meaningFloor } from "../embeddings/models.js";
4
+ import { formatMeetingReference, parseMeetingReference, } from "../meetings/index.js";
5
+ import { questionTerms } from "../search/question-plan.js";
6
+ import { refreshMeetingIndex, resolveMeetingSelection } from "./meeting-hit-search.js";
7
+ import { validateVector } from "./meeting-semantic.js";
8
+ import { RRF_K } from "./notes-search.js";
9
+ /** How many hits each side ranks before they are merged; a page past it has nothing more to show. */
10
+ const DEPTH = 200;
11
+ const cursorOf = (value, key) => {
12
+ if (value === undefined)
13
+ return 0;
14
+ let cursor;
15
+ try {
16
+ cursor = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
17
+ }
18
+ catch {
19
+ throw new CliError("validation_error", "the cursor is not from a combined meeting search; search again");
20
+ }
21
+ if (cursor?.version !== 1 || cursor.key !== key || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0)
22
+ throw new CliError("validation_error", "the cursor belongs to another query, filter or model; search again");
23
+ return cursor.offset;
24
+ };
25
+ /**
26
+ * Words and meaning, ranked together by reciprocal rank fusion over one selection of meetings. A question
27
+ * («что решили по цене?») searches its content words; other text is the meeting query language. A word hit inside
28
+ * a passage the meaning search found joins it, so one discussion found both ways ranks first.
29
+ */
30
+ export const searchMeetingsCombined = async (store, embedder, query, request) => {
31
+ const { maxChunks, maxRows, maxTextBytes, ...words } = request;
32
+ const { input, selection } = await resolveMeetingSelection(store, words);
33
+ const { after, limit = 20, maxReadBytes, ...filters } = input;
34
+ const terms = questionTerms(query);
35
+ const wordQuery = terms === null ? query : terms.join(" ");
36
+ const key = createHash("sha256")
37
+ .update(JSON.stringify([query, embedder.key, Object.entries(filters).filter(([name]) => name !== "signal")]))
38
+ .digest("base64url");
39
+ const offset = cursorOf(after, key);
40
+ let byWords = [];
41
+ let indexing = "ready";
42
+ let refused;
43
+ if (wordQuery.trim()) {
44
+ await refreshMeetingIndex(store.meetings, input.accountId, input.signal);
45
+ try {
46
+ const page = await store.meetings.searchHits(wordQuery, { ...filters, limit: DEPTH, maxReadBytes });
47
+ byWords = page.items;
48
+ indexing = page.coverage.indexing;
49
+ }
50
+ catch (error) {
51
+ // Text the query language refuses is still a question to ask by meaning.
52
+ if (!(error instanceof CliError) || error.code !== "validation_error")
53
+ throw error;
54
+ refused = error.message;
55
+ }
56
+ }
57
+ input.signal?.throwIfAborted();
58
+ const vectors = await embedder.embed([query], "query");
59
+ if (vectors.length !== 1)
60
+ throw new CliError("invalid_response", "Meeting query model returned another vector count");
61
+ const vector = vectors[0];
62
+ const ranked = await store.meetingVectors.ranked(embedder.key, validateVector(vector, embedder.dims), {
63
+ ...filters,
64
+ limit: DEPTH,
65
+ ...(maxChunks === undefined ? {} : { maxChunks }),
66
+ ...(maxRows === undefined ? {} : { maxRows }),
67
+ ...(maxTextBytes === undefined ? {} : { maxTextBytes }),
68
+ });
69
+ const floor = meaningFloor(embedder.key);
70
+ const byMeaning = ranked.items.filter(({ score }) => score > floor);
71
+ const merged = new Map();
72
+ byMeaning.forEach((hit, rank) => {
73
+ const reference = { type: "meeting", accountId: input.accountId, meetingId: hit.meetingId };
74
+ const first = formatMeetingReference({
75
+ ...reference,
76
+ transcriptId: hit.transcriptId,
77
+ cuePosition: hit.firstPosition,
78
+ });
79
+ merged.set(`${hit.transcriptId}:${hit.firstPosition}`, {
80
+ ref: first,
81
+ meetingId: hit.meetingId,
82
+ scope: "transcript",
83
+ text: hit.text,
84
+ startMs: null,
85
+ source: { title: hit.title, startedAt: hit.startedAt, url: null },
86
+ by: ["meaning"],
87
+ score: hit.score,
88
+ passage: {
89
+ first,
90
+ last: formatMeetingReference({ ...reference, transcriptId: hit.transcriptId, cuePosition: hit.lastPosition }),
91
+ },
92
+ fused: 1 / (RRF_K + rank + 1),
93
+ });
94
+ });
95
+ byWords.forEach((hit, rank) => {
96
+ const share = 1 / (RRF_K + rank + 1);
97
+ const cue = hit.scope === "transcript" ? parseMeetingReference(hit.ref) : undefined;
98
+ const within = cue?.cuePosition === undefined
99
+ ? undefined
100
+ : byMeaning.find((passage) => passage.transcriptId === cue.transcriptId &&
101
+ passage.firstPosition <= cue.cuePosition &&
102
+ cue.cuePosition <= passage.lastPosition);
103
+ const held = within && merged.get(`${within.transcriptId}:${within.firstPosition}`);
104
+ const found = {
105
+ ref: hit.ref,
106
+ text: hit.text,
107
+ startMs: hit.startMs,
108
+ ...(hit.match === undefined ? {} : { match: hit.match }),
109
+ ...(hit.corrections === undefined ? {} : { corrections: hit.corrections }),
110
+ };
111
+ if (held && !held.by.includes("words")) {
112
+ Object.assign(held, found, { by: ["words", "meaning"], fused: held.fused + share });
113
+ if (hit.source?.url)
114
+ held.source.url = hit.source.url;
115
+ }
116
+ else if (held)
117
+ held.fused += share;
118
+ else
119
+ merged.set(`w:${hit.ref}:${hit.scope}:${hit.id}`, {
120
+ ...found,
121
+ meetingId: hit.meetingId,
122
+ scope: hit.scope,
123
+ source: {
124
+ title: hit.source?.title ?? null,
125
+ startedAt: hit.source?.startedAt ?? null,
126
+ url: hit.source?.url ?? null,
127
+ },
128
+ by: ["words"],
129
+ score: null,
130
+ fused: share,
131
+ });
132
+ });
133
+ // On a tie a line that has the words outranks a passage that only resembles the query.
134
+ const ordered = [...merged.values()].sort((a, b) => b.fused - a.fused ||
135
+ Number(b.by.includes("words")) - Number(a.by.includes("words")) ||
136
+ (b.score ?? -2) - (a.score ?? -2));
137
+ const items = ordered.slice(offset, offset + limit).map(({ fused: _, ...hit }) => hit);
138
+ const hasMore = ordered.length > offset + limit;
139
+ return {
140
+ items,
141
+ hasMore,
142
+ nextCursor: hasMore
143
+ ? Buffer.from(JSON.stringify({ version: 1, key, offset: offset + limit })).toString("base64url")
144
+ : null,
145
+ selection,
146
+ words: {
147
+ query: wordQuery,
148
+ ...(terms === null ? {} : { from: "question" }),
149
+ ...(refused === undefined ? {} : { refused }),
150
+ },
151
+ model: { key: embedder.key, model: embedder.model, kind: embedder.kind, dims: embedder.dims },
152
+ coverage: {
153
+ archiveCompleteness: "unknown",
154
+ indexCompleteness: "unknown",
155
+ indexing,
156
+ /** Every vector of the selection was compared; false when `maxChunks` stopped the scan. */
157
+ meaningScanComplete: ranked.complete,
158
+ scanned: ranked.scanned,
159
+ /** Each side ranks this many at most; pages past them hold nothing more. */
160
+ depth: DEPTH,
161
+ },
162
+ };
163
+ };
@@ -5,6 +5,32 @@ export type { MeetingHitSearchRequest } from "../meetings/index.js";
5
5
  /** Each batch commits on its own, so a search cut short by its deadline keeps the progress it made. */
6
6
  export declare const refreshMeetingIndex: (meetings: Pick<MessageStore["meetings"], "indexSearch">, accountId: number, signal?: AbortSignal) => Promise<void>;
7
7
  /** Name resolution changes selection only; it never assigns projects or labels from transcript words. */
8
+ export declare const resolveMeetingSelection: (store: {
9
+ knowledge?: MessageStore["knowledge"];
10
+ }, request: MeetingHitSearchRequest) => Promise<{
11
+ input: {
12
+ accountId: number;
13
+ meetingId?: number;
14
+ participantIdentityId?: number;
15
+ personId?: number;
16
+ projectId?: number;
17
+ tagId?: number;
18
+ eventId?: number;
19
+ meetingSeriesId?: number;
20
+ since?: number;
21
+ until?: number;
22
+ limit?: number;
23
+ after?: string;
24
+ maxReadBytes?: number;
25
+ signal?: AbortSignal;
26
+ };
27
+ selection: {
28
+ kind: VocabularyKind;
29
+ id: number;
30
+ name: string;
31
+ by: "explicit-link";
32
+ }[];
33
+ }>;
8
34
  export declare const searchMeetingHits: (store: Pick<MessageStore, "meetings"> & {
9
35
  knowledge?: MessageStore["knowledge"];
10
36
  }, query: string, request: MeetingHitSearchRequest, options?: {
@@ -16,7 +16,7 @@ export const refreshMeetingIndex = async (meetings, accountId, signal) => {
16
16
  }
17
17
  };
18
18
  /** Name resolution changes selection only; it never assigns projects or labels from transcript words. */
19
- export const searchMeetingHits = async (store, query, request, options = {}) => {
19
+ export const resolveMeetingSelection = async (store, request) => {
20
20
  const { project, tag, topic, ...input } = request;
21
21
  input.signal?.throwIfAborted();
22
22
  if ((project !== undefined && input.projectId !== undefined) ||
@@ -51,6 +51,10 @@ export const searchMeetingHits = async (store, query, request, options = {}) =>
51
51
  input.tagId = id;
52
52
  selection.push({ kind, id, name: entry.name, by: "explicit-link" });
53
53
  }
54
+ return { input, selection };
55
+ };
56
+ export const searchMeetingHits = async (store, query, request, options = {}) => {
57
+ const { input, selection } = await resolveMeetingSelection(store, request);
54
58
  if (options.refreshIndex !== false)
55
59
  await refreshMeetingIndex(store.meetings, input.accountId, input.signal);
56
60
  const result = await store.meetings.searchHits(query, input);
@@ -16,6 +16,7 @@ export interface MeetingEmbeddingApplyOptions {
16
16
  maxVectorBytes?: number;
17
17
  signal?: AbortSignal;
18
18
  }
19
+ export declare const validateVector: (vector: Float32Array | undefined, dims: number) => Float32Array;
19
20
  /** Embeds only this explicit query; never rebuilds chunks or generates stored vectors. */
20
21
  export declare const readMeetingSemantic: (store: Pick<MeetingVectors, "nearest">, accountId: number, query: string, embedder: MeetingEmbedder, options?: MeetingSemanticOptions) => Promise<{
21
22
  ranking: "scanned-candidates";
@@ -25,7 +25,7 @@ const textBudget = (options) => ({
25
25
  maxRows: positive(options.maxRows ?? 10000, "transcript row budget", 100000),
26
26
  maxTextBytes: positive(options.maxTextBytes ?? 4 * 1024 * 1024, "transcript text byte budget", 64 * 1024 * 1024),
27
27
  });
28
- const validateVector = (vector, dims) => {
28
+ export const validateVector = (vector, dims) => {
29
29
  if (!(vector instanceof Float32Array) ||
30
30
  vector.length !== dims ||
31
31
  !vector.some((value) => value !== 0) ||
@@ -151,7 +151,7 @@ export interface EmailFilter extends MailFilter {
151
151
  mailbox?: string;
152
152
  /** On the received time, or the sent time when that is unknown: IMAP's SINCE counts by the received day. */
153
153
  since?: number;
154
- /** Exclusive. */
154
+ /** Exclusive. `momentOf` and `dateWindow` with `end` give the last millisecond of a day, an inclusive end: add 1. */
155
155
  until?: number;
156
156
  /** Only emails with an attachment whose text extraction failed with one of these errors. */
157
157
  attachmentErrors?: string[];
@@ -5,6 +5,7 @@ import { matchMeetingText, meetingQuerySql, resolveMeetingQuery } from "./meetin
5
5
  import { indexedText } from "./meeting-index-values.js";
6
6
  import { checkMeetingCancelled } from "./meeting-operation.js";
7
7
  import { positiveReadInteger } from "./meeting-reads.js";
8
+ import { meetingSelectionSql } from "./meeting-selection.js";
8
9
  import { meetingStemState } from "./meeting-stem-state.js";
9
10
  import { read } from "./transaction.js";
10
11
  const fail = (message) => {
@@ -112,45 +113,8 @@ export function meetingHitSearchOver({ database }) {
112
113
  indexing: pending || !state.stemmer || state.through < state.watermark ? "pending" : "ready",
113
114
  indexCompleteness: "unknown",
114
115
  };
115
- const clauses = ["m.account_id=?", "m.deleted_at IS NULL"];
116
116
  const { sql: matching, params: queryParams } = meetingQuerySql(tree, state);
117
- const params = [input.accountId];
118
- if (input.meetingId !== undefined) {
119
- clauses.push("m.id=?");
120
- params.push(input.meetingId);
121
- }
122
- if (input.eventId !== undefined) {
123
- clauses.push("m.event_id=?");
124
- params.push(input.eventId);
125
- }
126
- if (input.meetingSeriesId !== undefined) {
127
- clauses.push("m.meeting_series_id=?");
128
- params.push(input.meetingSeriesId);
129
- }
130
- if (input.since !== undefined) {
131
- clauses.push("m.started_at>=?");
132
- params.push(input.since);
133
- }
134
- if (input.until !== undefined) {
135
- clauses.push("m.started_at<=?");
136
- params.push(input.until);
137
- }
138
- if (input.participantIdentityId !== undefined) {
139
- clauses.push("EXISTS (SELECT 1 FROM meeting_participants p WHERE p.meeting_id=m.id AND p.identity_id=?)");
140
- params.push(input.participantIdentityId);
141
- }
142
- if (input.personId !== undefined) {
143
- clauses.push("EXISTS (SELECT 1 FROM meeting_participants p JOIN identity_links i ON i.identity_id=p.identity_id JOIN persons person ON person.id=i.person_id WHERE p.meeting_id=m.id AND person.id=?)");
144
- params.push(input.personId);
145
- }
146
- if (input.projectId !== undefined) {
147
- clauses.push("EXISTS (SELECT 1 FROM links l JOIN projects p ON p.id=l.to_id WHERE l.from_type='meeting' AND l.from_id=m.id AND l.to_type='project' AND l.kind='member-of' AND l.anchor IS NULL AND l.confirmed=1 AND p.id=? AND p.account_id IS NULL AND p.deleted_at IS NULL)");
148
- params.push(input.projectId);
149
- }
150
- if (input.tagId !== undefined) {
151
- clauses.push("EXISTS (SELECT 1 FROM taggings g JOIN tags t ON t.id=g.tag_id WHERE g.taggable_type='meeting' AND g.taggable_id=m.id AND t.id=?)");
152
- params.push(input.tagId);
153
- }
117
+ const { clauses, params } = meetingSelectionSql(input);
154
118
  const seek = after
155
119
  ? "WHERE h.tier>? OR (h.tier=? AND (h.started<? OR (h.started=? AND (h.meeting_id>? OR (h.meeting_id=? AND (h.kind>? OR (h.kind=? AND h.id>?)))))))"
156
120
  : "";
@@ -0,0 +1,8 @@
1
+ import type { MeetingSearchOptions } from "../../meetings/index.js";
2
+ import type { SqlValue } from "../driver.js";
3
+ export type MeetingSelection = Pick<MeetingSearchOptions, "accountId" | "meetingId" | "eventId" | "meetingSeriesId" | "since" | "until" | "participantIdentityId" | "personId" | "projectId" | "tagId">;
4
+ /** The meetings a search may read, over `meetings m`: the same filters for words and for meaning. */
5
+ export declare const meetingSelectionSql: (input: MeetingSelection) => {
6
+ clauses: string[];
7
+ params: SqlValue[];
8
+ };
@@ -0,0 +1,42 @@
1
+ /** The meetings a search may read, over `meetings m`: the same filters for words and for meaning. */
2
+ export const meetingSelectionSql = (input) => {
3
+ const clauses = ["m.account_id=?", "m.deleted_at IS NULL"];
4
+ const params = [input.accountId];
5
+ if (input.meetingId !== undefined) {
6
+ clauses.push("m.id=?");
7
+ params.push(input.meetingId);
8
+ }
9
+ if (input.eventId !== undefined) {
10
+ clauses.push("m.event_id=?");
11
+ params.push(input.eventId);
12
+ }
13
+ if (input.meetingSeriesId !== undefined) {
14
+ clauses.push("m.meeting_series_id=?");
15
+ params.push(input.meetingSeriesId);
16
+ }
17
+ if (input.since !== undefined) {
18
+ clauses.push("m.started_at>=?");
19
+ params.push(input.since);
20
+ }
21
+ if (input.until !== undefined) {
22
+ clauses.push("m.started_at<=?");
23
+ params.push(input.until);
24
+ }
25
+ if (input.participantIdentityId !== undefined) {
26
+ clauses.push("EXISTS (SELECT 1 FROM meeting_participants p WHERE p.meeting_id=m.id AND p.identity_id=?)");
27
+ params.push(input.participantIdentityId);
28
+ }
29
+ if (input.personId !== undefined) {
30
+ clauses.push("EXISTS (SELECT 1 FROM meeting_participants p JOIN identity_links i ON i.identity_id=p.identity_id JOIN persons person ON person.id=i.person_id WHERE p.meeting_id=m.id AND person.id=?)");
31
+ params.push(input.personId);
32
+ }
33
+ if (input.projectId !== undefined) {
34
+ clauses.push("EXISTS (SELECT 1 FROM links l JOIN projects p ON p.id=l.to_id WHERE l.from_type='meeting' AND l.from_id=m.id AND l.to_type='project' AND l.kind='member-of' AND l.anchor IS NULL AND l.confirmed=1 AND p.id=? AND p.account_id IS NULL AND p.deleted_at IS NULL)");
35
+ params.push(input.projectId);
36
+ }
37
+ if (input.tagId !== undefined) {
38
+ clauses.push("EXISTS (SELECT 1 FROM taggings g JOIN tags t ON t.id=g.tag_id WHERE g.taggable_type='meeting' AND g.taggable_id=m.id AND t.id=?)");
39
+ params.push(input.tagId);
40
+ }
41
+ return { clauses, params };
42
+ };
@@ -1,3 +1,4 @@
1
+ import { type MeetingSelection } from "./meeting-selection.js";
1
2
  import type { StoreContext } from "./open.js";
2
3
  interface Scope {
3
4
  accountId: number;
@@ -61,6 +62,22 @@ export interface MeetingVectors {
61
62
  chunks: number;
62
63
  embedded: number;
63
64
  }>;
65
+ /**
66
+ * The `limit` passages nearest to `query` among every vector of `model` in the selected meetings, up to
67
+ * `maxChunks` of them: one ranking over the whole selection, with text read only for the passages kept.
68
+ */
69
+ ranked(model: string, query: Float32Array, input: MeetingSelection & Scope & TextBudget & {
70
+ limit: number;
71
+ maxChunks?: number;
72
+ }): Promise<{
73
+ items: (MeetingVectorHit & {
74
+ text: string;
75
+ title: string | null;
76
+ startedAt: number | null;
77
+ })[];
78
+ scanned: number;
79
+ complete: boolean;
80
+ }>;
64
81
  }
65
82
  export declare const meetingVectorsOver: (context: StoreContext) => MeetingVectors;
66
83
  export {};
@@ -1,8 +1,11 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { chunkHash, cutChunks } from "../../conversations/chunks.js";
3
3
  import { positiveReadInteger } from "./meeting-reads.js";
4
+ import { meetingSelectionSql } from "./meeting-selection.js";
4
5
  import { read, write } from "./transaction.js";
5
6
  import { dot, saveVectors } from "./vectors.js";
7
+ /** Vectors read per query while ranking, so a store of any size stays within a few megabytes at a time. */
8
+ const RANK_BATCH = 512;
6
9
  const LIVE = "FROM chunks k JOIN meeting_transcripts t ON k.chunkable_type = 'meeting_transcript' AND t.id = k.chunkable_id JOIN meetings m ON m.id = t.meeting_id WHERE m.account_id = ? AND m.deleted_at IS NULL AND t.deleted_at IS NULL AND t.superseded_at IS NULL";
7
10
  const limitOf = (value, name, max = 1000) => {
8
11
  positiveReadInteger(value, name);
@@ -233,6 +236,69 @@ export const meetingVectorsOver = (context) => ({
233
236
  };
234
237
  });
235
238
  },
239
+ async ranked(model, query, input) {
240
+ scope(input);
241
+ modelOf(model);
242
+ const limit = limitOf(input.limit, "limit"), maxChunks = limitOf(input.maxChunks ?? 10000, "maxChunks", 100000);
243
+ if (!query.length || query.length > 65536 || Array.from(query).some((value) => !Number.isFinite(value)))
244
+ throw new CliError("validation_error", "Query vector must have finite dimensions");
245
+ return read(context.database, () => {
246
+ const { clauses, params } = meetingSelectionSql(input);
247
+ const batch = context.database.prepare(`SELECT k.id,k.chunkable_id,k.content_hash,k.start_offset,k.end_offset,e.dims,e.vector FROM chunks k JOIN meeting_transcripts t ON k.chunkable_type='meeting_transcript' AND t.id=k.chunkable_id JOIN meetings m ON m.id=t.meeting_id JOIN embeddings e ON e.content_hash=k.content_hash AND e.model=? WHERE ${clauses.join(" AND ")} AND t.deleted_at IS NULL AND t.superseded_at IS NULL AND k.id>? ORDER BY k.id LIMIT ?`);
248
+ const best = [];
249
+ let scanned = 0;
250
+ let after = 0;
251
+ let complete = true;
252
+ for (;;) {
253
+ input.signal?.throwIfAborted();
254
+ const take = Math.min(RANK_BATCH, maxChunks - scanned);
255
+ if (take <= 0) {
256
+ complete = !batch.get(model, ...params, after, 1);
257
+ break;
258
+ }
259
+ const rows = batch.all(model, ...params, after, take);
260
+ for (const row of rows) {
261
+ if (Number(row.dims) !== query.length ||
262
+ !(row.vector instanceof Uint8Array) ||
263
+ row.vector.byteLength !== query.length * 4)
264
+ throw new CliError("validation_error", "Stored vector dimensions do not match query");
265
+ const score = dot(query, row.vector);
266
+ if (!Number.isFinite(score))
267
+ throw new CliError("invalid_response", "Stored vector contains non-finite values");
268
+ if (best.length < limit || score > (best.at(-1)?.score ?? Number.NEGATIVE_INFINITY)) {
269
+ const { vector: _, ...kept } = row;
270
+ const at = best.findIndex((held) => held.score < score);
271
+ best.splice(at < 0 ? best.length : at, 0, { row: kept, score });
272
+ if (best.length > limit)
273
+ best.pop();
274
+ }
275
+ }
276
+ scanned += rows.length;
277
+ after = Number(rows.at(-1)?.id ?? after);
278
+ if (rows.length < take)
279
+ break;
280
+ }
281
+ const load = textLoader(context, input);
282
+ const meeting = context.database.prepare("SELECT title,started_at FROM meetings WHERE id=?");
283
+ const items = best.map(({ row, score }) => {
284
+ input.signal?.throwIfAborted();
285
+ const text = chunkText(context, input.accountId, row, input, input.signal, load(Number(row.chunkable_id)));
286
+ const about = meeting.get(text.meetingId);
287
+ return {
288
+ title: about?.title == null ? null : String(about.title),
289
+ startedAt: about?.started_at == null ? null : Number(about.started_at),
290
+ meetingId: text.meetingId,
291
+ transcriptId: Number(row.chunkable_id),
292
+ firstPosition: text.firstPosition,
293
+ lastPosition: text.lastPosition,
294
+ hash: String(row.content_hash),
295
+ score,
296
+ text: text.text,
297
+ };
298
+ });
299
+ return { items, scanned, complete };
300
+ });
301
+ },
236
302
  async status(input) {
237
303
  scope(input);
238
304
  modelOf(input.model);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.386.0",
3
+ "version": "0.388.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",