@wirecat/cli-core 0.377.0 → 0.378.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
@@ -5,7 +5,23 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
- ## Unreleased
8
+ ## 0.378.0 — 21.10.2026
9
+
10
+ ### Added
11
+
12
+ - **`gatherProjectEvidence` reads what a person wrote.** A person given with `senders: [{ provider, id }]` (their
13
+ stored identities, at most 16; an email identity's id is its address) and a `reference` gets a lane of its own
14
+ (`kinds: ["sender"]`, `query: null`, `senders`): every message or mail one of those identities wrote in the window,
15
+ read only through a selected source of that identity's provider. `name` becomes optional for such a person. An
16
+ item found only that way has `context: "sender"`, ranked between `linked-chat` and `person-only`. The context reader
17
+ takes the same filter as `senders` (`coverage.matching: "sender"`). Requests without senders keep their
18
+ fingerprint, so their checkpoints still resume. See [project gathering](docs/dev/PROJECT-GATHERING.md).
19
+
20
+ ### Fixed
21
+
22
+ - **Discovery search accepts thread context.** `search messages --discover --thread` expands the ranked hits
23
+ through the shared bounded thread reader instead of refusing inside candidate search. Reply provenance,
24
+ time context and thread limits are preserved.
9
25
 
10
26
  ## 0.377.0 — 20.10.2026
11
27
 
@@ -8,7 +8,7 @@ export const validateContextEvidencePage = (page, request) => {
8
8
  throw new CliError("invalid_response", "context reader returned invalid or out-of-scope evidence");
9
9
  };
10
10
  try {
11
- const { source, chat, after, before, limit, maxReadBytes } = contextEvidenceRequest(request);
11
+ const { source, chat, senders, after, before, limit, maxReadBytes } = contextEvidenceRequest(request);
12
12
  if (!page || contextSourceKey(contextSource(page.source)) !== contextSourceKey(source))
13
13
  fail();
14
14
  if (!Array.isArray(page.items) ||
@@ -23,7 +23,7 @@ export const validateContextEvidencePage = (page, request) => {
23
23
  typeof c.hasMore !== "boolean" ||
24
24
  typeof c.hasUndated !== "boolean" ||
25
25
  !["ready", "pending", "missing", "unknown"].includes(c.index) ||
26
- c.matching !== (chat === undefined ? "literal-words" : "chat") ||
26
+ c.matching !== (chat !== undefined ? "chat" : senders !== undefined ? "sender" : "literal-words") ||
27
27
  c.completeness !== "unknown" ||
28
28
  c.consistency !== "snapshot-per-page")
29
29
  fail();
@@ -12,6 +12,8 @@ export interface ContextEvidenceRequest {
12
12
  query?: string;
13
13
  /** One stored chat or mail thread of the source's account, by its external id; refused for documents and meetings. */
14
14
  chat?: string;
15
+ /** Messages or mail written by these identities of the source's provider (mail: addresses); no query or chat. */
16
+ senders?: string[];
15
17
  after: number;
16
18
  before: number;
17
19
  limit?: number;
@@ -55,7 +57,7 @@ export interface ContextEvidencePage {
55
57
  hasMore: boolean;
56
58
  hasUndated: boolean;
57
59
  completeness: "unknown";
58
- matching: "literal-words" | "chat";
60
+ matching: "literal-words" | "chat" | "sender";
59
61
  consistency: "snapshot-per-page";
60
62
  };
61
63
  }
@@ -83,12 +85,13 @@ export declare const fittingRows: (sizes: readonly number[], limit: number, maxR
83
85
  export interface ContextRecordReader {
84
86
  read(ref: string): Promise<Pick<ContextEvidenceItem, "ref" | "title" | "text" | "revision"> | null>;
85
87
  }
86
- export declare const contextCursorScope: (request: Pick<ContextEvidenceRequest, "source" | "query" | "chat" | "after" | "before">) => string;
88
+ export declare const contextCursorScope: (request: Pick<ContextEvidenceRequest, "source" | "query" | "chat" | "senders" | "after" | "before">) => string;
87
89
  export declare const contextSource: (value: ContextSource) => ContextSource;
88
90
  export declare const contextEvidenceRequest: (input: ContextEvidenceRequest) => {
89
91
  source: ContextSource;
90
92
  query: string | undefined;
91
93
  chat: string | undefined;
94
+ senders: string[] | undefined;
92
95
  words: RegExpMatchArray | [];
93
96
  after: number;
94
97
  before: number;
@@ -29,6 +29,7 @@ export const contextCursorScope = (request) => createHash("sha256")
29
29
  request.before,
30
30
  // Only a chat read hashes its chat, so cursors issued before chat reads existed stay valid.
31
31
  ...(request.chat === undefined ? [] : [request.chat]),
32
+ ...(request.senders === undefined ? [] : [["senders", ...request.senders]]),
32
33
  ]))
33
34
  .digest("hex");
34
35
  const fields = (value, allowed, prefix) => {
@@ -53,7 +54,7 @@ export const contextSource = (value) => {
53
54
  };
54
55
  export const contextEvidenceRequest = (input) => {
55
56
  const source = contextSource(input?.source);
56
- fields(input, ["source", "query", "chat", "after", "before", "limit", "maxReadBytes", "cursor", "signal"], "");
57
+ fields(input, ["source", "query", "chat", "senders", "after", "before", "limit", "maxReadBytes", "cursor", "signal"], "");
57
58
  const integer = (value, name, low, high) => {
58
59
  if (!Number.isSafeInteger(value) || value < low || value > high)
59
60
  throw new CliError("validation_error", `${name} must be a whole number from ${low} to ${high}`);
@@ -72,11 +73,25 @@ export const contextEvidenceRequest = (input) => {
72
73
  if (input.query !== undefined)
73
74
  throw new CliError("validation_error", "a chat read takes no query");
74
75
  }
76
+ const senders = input.senders;
77
+ if (senders !== undefined) {
78
+ if (!Array.isArray(senders) ||
79
+ !senders.length ||
80
+ senders.length > 16 ||
81
+ senders.some((id) => typeof id !== "string" || !id.trim() || Buffer.byteLength(id) > 1024) ||
82
+ new Set(senders).size !== senders.length)
83
+ throw new CliError("validation_error", "senders needs 1–16 distinct identity ids");
84
+ if (source.kind !== "message" && source.kind !== "mail")
85
+ throw new CliError("validation_error", "only a message or mail source reads by sender");
86
+ if (input.query !== undefined || chat !== undefined)
87
+ throw new CliError("validation_error", "a sender read takes no query or chat");
88
+ }
89
+ const bare = chat === undefined && senders === undefined;
75
90
  const query = input.query;
76
- if (chat === undefined && (typeof query !== "string" || !query.trim() || Buffer.byteLength(query) > 2048))
91
+ if (bare && (typeof query !== "string" || !query.trim() || Buffer.byteLength(query) > 2048))
77
92
  throw new CliError("validation_error", "query needs 1–2048 UTF-8 bytes of literal words");
78
93
  const words = query?.match(/[\p{L}\p{N}]+/gu) ?? [];
79
- if (chat === undefined && (!words.length || words.length > 16))
94
+ if (bare && (!words.length || words.length > 16))
80
95
  throw new CliError("validation_error", "query needs 1–16 literal words");
81
96
  const limit = integer(input.limit ?? 10, "limit", 1, 20);
82
97
  const maxReadBytes = integer(input.maxReadBytes ?? 65536, "maxReadBytes", 1, CONTEXT_MAX_READ_BYTES);
@@ -91,7 +106,7 @@ export const contextEvidenceRequest = (input) => {
91
106
  id: integer(input.cursor.id, "cursor.id", 1, Number.MAX_SAFE_INTEGER),
92
107
  scope: input.cursor.scope,
93
108
  };
94
- if (cursor && cursor.scope !== contextCursorScope({ source, query, chat, after, before }))
95
- throw new CliError("validation_error", "cursor belongs to another source, query, chat or time window");
96
- return { source, query, chat, words, after, before, limit, maxReadBytes, cursor, signal: input.signal };
109
+ if (cursor && cursor.scope !== contextCursorScope({ source, query, chat, senders, after, before }))
110
+ throw new CliError("validation_error", "cursor belongs to another source, query, chat, senders or time window");
111
+ return { source, query, chat, senders, words, after, before, limit, maxReadBytes, cursor, signal: input.signal };
97
112
  };
@@ -23,6 +23,7 @@ export const createContextEvidenceReader = (port, allowed, limits) => {
23
23
  source: Object.freeze({ ...source }),
24
24
  ...(validated.query === undefined ? {} : { query: validated.query }),
25
25
  ...(validated.chat === undefined ? {} : { chat: validated.chat }),
26
+ ...(validated.senders === undefined ? {} : { senders: Object.freeze([...validated.senders]) }),
26
27
  after: validated.after,
27
28
  before: validated.before,
28
29
  limit: validated.limit,
@@ -9,7 +9,7 @@ export type { ProjectNeighbour, ProjectNeighbourReader } from "./project-gatheri
9
9
  export { documentPassage, storedMessageNeighbours, storedNeighbours } from "./project-gathering-around.js";
10
10
  export type { EvidenceMention, EvidenceMentions } from "./project-gathering-mentions.js";
11
11
  export { findEvidenceMentions } from "./project-gathering-mentions.js";
12
- export type { ProjectFetchState, ProjectGatheringBudgets, ProjectGatheringRequest, ProjectLane, ProjectLaneKind, ProjectLinkedChat, ProjectUnitState, } from "./project-gathering-request.js";
12
+ export type { ProjectFetchState, ProjectGatheringBudgets, ProjectGatheringRequest, ProjectLane, ProjectLaneKind, ProjectLinkedChat, ProjectPerson, ProjectSender, ProjectUnitState, } from "./project-gathering-request.js";
13
13
  import { type AccountService } from "./account.js";
14
14
  import { type AdminService } from "./admin.js";
15
15
  import { type AdminStatisticsService } from "./admin-statistics.js";
@@ -16,6 +16,7 @@ export declare const captureCommunicationSelections: (port: MeetingCommunication
16
16
  source: import("./context-evidence.js").ContextSource;
17
17
  query: string | undefined;
18
18
  chat: string | undefined;
19
+ senders: string[] | undefined;
19
20
  after: number;
20
21
  before: number;
21
22
  limit: number;
@@ -65,7 +65,7 @@ const searchPage = async (store, account, request, messenger) => {
65
65
  throw new CliError("validation_error", "discovery searches the local archive; use backend archive");
66
66
  await topUp(store);
67
67
  const found = request.discover
68
- ? await searchDiscovery(store, account, request, messenger)
68
+ ? await searchDiscovery(store, account, { ...request, thread: undefined }, messenger)
69
69
  : await searchLucene(store, account, request, messenger);
70
70
  return withThreads(store, found, request);
71
71
  };
@@ -7,10 +7,7 @@ export interface ProjectGatheringRequest {
7
7
  aliases?: string[];
8
8
  };
9
9
  keywords?: string[];
10
- people?: {
11
- name: string;
12
- reference?: string;
13
- }[];
10
+ people?: ProjectPerson[];
14
11
  sources: ContextSource[];
15
12
  /** Chats the host confirmed as the project's; each is read whole, through the source of its own account. */
16
13
  linkedChats?: ProjectLinkedChat[];
@@ -30,6 +27,20 @@ export interface ProjectGatheringRequest {
30
27
  checkpoint?: string;
31
28
  signal?: AbortSignal;
32
29
  }
30
+ /**
31
+ * `name` is searched as words; `senders` are the person's stored identities, and every message or mail one of them
32
+ * wrote in the window is read. A sender lane needs the person's `reference`.
33
+ */
34
+ export interface ProjectPerson {
35
+ name?: string;
36
+ reference?: string;
37
+ senders?: ProjectSender[];
38
+ }
39
+ /** One stored identity by its provider and the provider's own id; an email identity's id is its address. */
40
+ export interface ProjectSender {
41
+ provider: string;
42
+ id: string;
43
+ }
33
44
  export interface ProjectLinkedChat {
34
45
  provider: string;
35
46
  account: string;
@@ -42,13 +53,15 @@ export interface ProjectGatheringBudgets {
42
53
  maxElapsedMs?: number;
43
54
  maxItems?: number;
44
55
  }
45
- export type ProjectLaneKind = "project" | "key" | "alias" | "keyword" | "person" | "linked-chat";
56
+ export type ProjectLaneKind = "project" | "key" | "alias" | "keyword" | "person" | "sender" | "linked-chat";
46
57
  export interface ProjectLane {
47
58
  id: string;
48
59
  kinds: ProjectLaneKind[];
49
- /** Null for a linked chat's lane, which reads every message of the chat. */
60
+ /** Null for a linked chat's or a sender's lane, which read every message of the chat or of the sender. */
50
61
  query: string | null;
51
62
  people: string[];
63
+ /** A sender lane only: the identities whose messages and mail it reads. */
64
+ senders?: ProjectSender[];
52
65
  /** A linked chat's lane only: the chat's reference. */
53
66
  chat?: string;
54
67
  }
@@ -96,10 +109,18 @@ export declare const projectGatheringRequest: (input: ProjectGatheringRequest, n
96
109
  aliases: string[];
97
110
  };
98
111
  keywords: string[];
99
- people: {
112
+ people: ({
100
113
  name: string;
101
114
  reference: string | null;
102
- }[];
115
+ senders?: undefined;
116
+ } | {
117
+ name: string | null;
118
+ reference: string | null;
119
+ senders: {
120
+ provider: string;
121
+ id: string;
122
+ }[];
123
+ })[];
103
124
  lanes: ProjectLane[];
104
125
  sources: ContextSource[];
105
126
  linkedChats: {
@@ -148,6 +169,7 @@ export declare const projectUnits: (plan: ProjectGatheringPlan, window: {
148
169
  before: number;
149
170
  };
150
171
  chat: string | undefined;
172
+ senders: string[] | undefined;
151
173
  }[];
152
174
  export declare const seenKey: (ref: string) => string;
153
175
  export declare const encodeCheckpoint: (checkpoint: ProjectGatheringCheckpoint) => string;
@@ -6,7 +6,15 @@ import { formatReference } from "../domain/references.js";
6
6
  const DAY = 86400000;
7
7
  const MAX_TIME = 8_640_000_000_000_000;
8
8
  export const CONTEXT_CHUNK_DAYS = 90;
9
- const LIMITS = { lanes: 16, sources: 32, units: 512, lookbackDays: 730, people: 16, linkedChats: 16 };
9
+ const LIMITS = {
10
+ lanes: 16,
11
+ sources: 32,
12
+ units: 512,
13
+ lookbackDays: 730,
14
+ people: 16,
15
+ linkedChats: 16,
16
+ senders: 16,
17
+ };
10
18
  const BUDGETS = {
11
19
  maxReads: [48, 512],
12
20
  maxReadBytes: [2097152, 16777216],
@@ -77,11 +85,29 @@ export const projectGatheringRequest = (input, now) => {
77
85
  const people = list(input.people, "people", LIMITS.people).map((person, i) => {
78
86
  if (!person || typeof person !== "object")
79
87
  return fail(`people.${i} needs a name`, `people.${i}`);
80
- known(person, ["name", "reference"], `people.${i}.`);
88
+ known(person, ["name", "reference", "senders"], `people.${i}.`);
81
89
  const p = person;
90
+ const reference = p.reference === undefined ? null : text(p.reference, `people.${i}.reference`, 1024);
91
+ if (p.senders === undefined)
92
+ return { name: laneQuery(p.name, `people.${i}.name`), reference };
93
+ const senders = list(p.senders, `people.${i}.senders`, LIMITS.senders).map((value, j) => {
94
+ const name = `people.${i}.senders.${j}`;
95
+ if (!value || typeof value !== "object")
96
+ return fail(`${name} needs provider and id`, name);
97
+ known(value, ["provider", "id"], `${name}.`);
98
+ const given = value;
99
+ return { provider: text(given.provider, `${name}.provider`, 128), id: text(given.id, `${name}.id`, 1024) };
100
+ });
101
+ if (!senders.length)
102
+ fail(`people.${i}.senders needs at least one identity`, `people.${i}.senders`);
103
+ if (new Set(senders.map((one) => JSON.stringify([one.provider, one.id]))).size !== senders.length)
104
+ fail(`people.${i}.senders lists one identity twice`, `people.${i}.senders`);
105
+ if (reference === null)
106
+ fail(`people.${i} needs a reference to read by sender`, `people.${i}.reference`);
82
107
  return {
83
- name: laneQuery(p.name, `people.${i}.name`),
84
- reference: p.reference === undefined ? null : text(p.reference, `people.${i}.reference`, 1024),
108
+ name: p.name === undefined ? null : laneQuery(p.name, `people.${i}.name`),
109
+ reference,
110
+ senders,
85
111
  };
86
112
  });
87
113
  if (input.scope !== "explicit" && input.scope !== "all-connected")
@@ -139,7 +165,17 @@ export const projectGatheringRequest = (input, now) => {
139
165
  for (const k of keywords)
140
166
  lane("keyword", k);
141
167
  for (const p of people)
142
- lane("person", p.name, p.reference ?? p.name);
168
+ if (p.name !== null)
169
+ lane("person", p.name, p.reference ?? p.name);
170
+ for (const p of people)
171
+ if (p.senders && p.reference !== null)
172
+ lanes.push({
173
+ id: `l${lanes.length + 1}`,
174
+ kinds: ["sender"],
175
+ query: null,
176
+ people: [p.reference],
177
+ senders: p.senders,
178
+ });
143
179
  if (lanes.length > LIMITS.lanes)
144
180
  fail(`at most ${LIMITS.lanes} distinct search lanes`, "keywords");
145
181
  for (const linked of linkedChats) {
@@ -248,16 +284,33 @@ export const projectUnits = (plan, window) => {
248
284
  sourceIndex,
249
285
  chunk,
250
286
  chat: undefined,
287
+ senders: undefined,
251
288
  }))));
252
- // Appended after every literal unit: a checkpoint from a run without links keeps its unit order, and in each
289
+ // Only the sources of a sender's own provider: an identity of one messenger never wrote in another.
290
+ const bySender = chunks.flatMap((chunk) => plan.lanes.flatMap((lane) => plan.sources.flatMap((source, sourceIndex) => {
291
+ if (!lane.senders || (source.kind !== "message" && source.kind !== "mail"))
292
+ return [];
293
+ const ids = lane.senders.filter((one) => one.provider === source.provider).map((one) => one.id);
294
+ return ids.length ? [{ lane, source, sourceIndex, chunk, chat: undefined, senders: ids }] : [];
295
+ })));
296
+ // Appended after every literal and sender unit: a checkpoint from a run without links keeps its unit order, and in each
253
297
  // round a literal lane labels a shared hit before the linked chat does.
254
298
  const linked = chunks.flatMap((chunk) => plan.linkedChats.flatMap(({ lane, source, chat }) => {
255
299
  const found = plan.lanes.find((l) => l.id === lane);
256
300
  return found && source !== null
257
- ? [{ lane: found, source: plan.sources[source], sourceIndex: source, chunk, chat }]
301
+ ? [
302
+ {
303
+ lane: found,
304
+ source: plan.sources[source],
305
+ sourceIndex: source,
306
+ chunk,
307
+ chat,
308
+ senders: undefined,
309
+ },
310
+ ]
258
311
  : [];
259
312
  }));
260
- const units = [...literal, ...linked];
313
+ const units = [...literal, ...bySender, ...linked];
261
314
  if (units.length > LIMITS.units)
262
315
  fail(`${units.length} lane × source × 90-day reads exceed ${LIMITS.units}; narrow the lanes, sources or window`);
263
316
  return units;
@@ -39,8 +39,11 @@ export interface ProjectEvidenceItem extends Omit<ContextEvidenceItem, "text"> {
39
39
  /** A document hit with `around`: how many paragraphs after the one it centres on also name a lane word. */
40
40
  moreMatches?: number;
41
41
  }
42
- /** Strongest first: a hit that names the project, then one only from a linked chat, then one naming only a person. */
43
- export type ProjectEvidenceContext = "project" | "linked-chat" | "person-only";
42
+ /**
43
+ * Strongest first: a hit that names the project, then one only from a linked chat, then one a person wrote, then one
44
+ * naming only a person.
45
+ */
46
+ export type ProjectEvidenceContext = "project" | "linked-chat" | "sender" | "person-only";
44
47
  export type ProjectGatheringStop = "reads" | "read-bytes" | "elapsed" | "items" | null;
45
48
  export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, request: ProjectGatheringRequest) => Promise<{
46
49
  schemaVersion: 1;
@@ -5,12 +5,14 @@ import { excerpt } from "./excerpt.js";
5
5
  import { attachNeighbours, documentMatches, } from "./project-gathering-around.js";
6
6
  import { decodeCheckpoint, encodeCheckpoint, projectGatheringRequest, projectUnits, seenKey, } from "./project-gathering-request.js";
7
7
  const terminal = new Set(["done", "unavailable", "failed"]);
8
- const CONTEXTS = ["person-only", "linked-chat", "project"];
8
+ const CONTEXTS = ["person-only", "sender", "linked-chat", "project"];
9
9
  const contextOf = (lane) => lane.kinds.includes("linked-chat")
10
10
  ? "linked-chat"
11
- : lane.kinds.every((k) => k === "person")
12
- ? "person-only"
13
- : "project";
11
+ : lane.kinds.includes("sender")
12
+ ? "sender"
13
+ : lane.kinds.every((k) => k === "person")
14
+ ? "person-only"
15
+ : "project";
14
16
  export const gatherProjectEvidence = async (ports, request) => {
15
17
  if (!ports?.evidence || typeof ports.evidence.read !== "function")
16
18
  throw new CliError("configuration_error", "stored context reads are unavailable in this store");
@@ -164,7 +166,11 @@ export const gatherProjectEvidence = async (ports, request) => {
164
166
  try {
165
167
  const page = await reader.read({
166
168
  source: unit.source,
167
- ...(unit.chat === undefined ? { query: unit.lane.query ?? undefined } : { chat: unit.chat }),
169
+ ...(unit.chat !== undefined
170
+ ? { chat: unit.chat }
171
+ : unit.senders !== undefined
172
+ ? { senders: unit.senders }
173
+ : { query: unit.lane.query ?? undefined }),
168
174
  after: unit.chunk.after,
169
175
  before: unit.chunk.before,
170
176
  limit,
@@ -20,6 +20,7 @@ const corpora = {
20
20
  join: "JOIN chats c ON c.id=n.chat_id AND c.account_id=n.account_id",
21
21
  where: "c.searchable=1",
22
22
  chats: "chats",
23
+ sender: "n.sender_identity_id",
23
24
  // A read that names the chat sees it even when it is hidden from search (searchable=0).
24
25
  named: "c.external_id=?",
25
26
  },
@@ -36,6 +37,7 @@ const corpora = {
36
37
  join: "JOIN email_threads c ON c.id=n.email_thread_id AND c.account_id=n.account_id",
37
38
  where: "a.provider='email'",
38
39
  chats: "email_threads",
40
+ sender: "n.from_identity_id",
39
41
  named: "a.provider='email' AND c.external_id=?",
40
42
  },
41
43
  document: {
@@ -51,6 +53,7 @@ const corpora = {
51
53
  join: "",
52
54
  where: "n.body IS NOT NULL AND CASE WHEN json_valid(n.metadata) THEN coalesce(json_extract(n.metadata, '$.catalog.active'),1) ELSE 1 END=1",
53
55
  chats: null,
56
+ sender: null,
54
57
  named: null,
55
58
  },
56
59
  };
@@ -58,11 +61,12 @@ const corpora = {
58
61
  export const contextEvidenceOver = ({ database }) => ({
59
62
  async read(original) {
60
63
  const request = contextEvidenceRequest(original);
61
- const { source, words, chat, after, before, limit, maxReadBytes, cursor, signal } = request;
64
+ const { source, words, chat, senders, after, before, limit, maxReadBytes, cursor, signal } = request;
62
65
  if (source.kind === "meeting")
63
66
  return readMeetingContextEvidence(database, request);
64
67
  const corpus = corpora[source.kind];
65
- const scope = contextCursorScope({ source, query: request.query, chat, after, before });
68
+ const scope = contextCursorScope({ source, query: request.query, chat, senders, after, before });
69
+ const searched = chat === undefined && senders === undefined;
66
70
  signal?.throwIfAborted();
67
71
  return read(database, () => {
68
72
  const coverage = {
@@ -71,7 +75,7 @@ export const contextEvidenceOver = ({ database }) => ({
71
75
  hasMore: false,
72
76
  hasUndated: false,
73
77
  completeness: "unknown",
74
- matching: chat === undefined ? "literal-words" : "chat",
78
+ matching: chat !== undefined ? "chat" : senders !== undefined ? "sender" : "literal-words",
75
79
  consistency: "snapshot-per-page",
76
80
  };
77
81
  const empty = () => ({ source, items: [], next: null, readBytes: 0, coverage });
@@ -87,19 +91,27 @@ export const contextEvidenceOver = ({ database }) => ({
87
91
  .get(source.accountId, chat))
88
92
  return empty();
89
93
  coverage.available = true;
90
- const scopeParams = chat === undefined ? accountParams : [...accountParams, chat];
94
+ const scopeParams = chat !== undefined
95
+ ? [...accountParams, chat]
96
+ : senders !== undefined
97
+ ? [...accountParams, ...senders]
98
+ : accountParams;
99
+ const byChat = chat === undefined ? corpus.where : corpus.named;
100
+ const bySender = senders === undefined
101
+ ? ""
102
+ : ` AND ${corpus.sender} IN (SELECT id FROM identities WHERE provider=a.provider AND external_id IN (${senders.map(() => "?").join(",")}))`;
91
103
  const base = `FROM ${corpus.table} n JOIN accounts a ON a.id=n.account_id ${corpus.join}
92
104
  WHERE n.account_id=? AND a.provider=? AND a.external_id=? AND n.deleted_at IS NULL
93
- AND ${chat === undefined ? corpus.where : corpus.named}`;
94
- // A chat read uses no word index, so words still waiting for it hide nothing.
95
- if (chat !== undefined)
105
+ AND ${byChat}${bySender}`;
106
+ // A chat or sender read uses no word index, so words still waiting for it hide nothing.
107
+ if (!searched)
96
108
  coverage.index = "ready";
97
109
  else if (!database.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(corpus.index)) {
98
110
  coverage.index = "missing";
99
111
  return empty();
100
112
  }
101
113
  coverage.hasUndated = Boolean(database.prepare(`SELECT 1 ${base} AND ${corpus.at} IS NULL LIMIT 1`).get(...scopeParams));
102
- if (chat === undefined && source.kind === "message") {
114
+ if (searched && source.kind === "message") {
103
115
  const state = database
104
116
  .prepare("SELECT watermark,filled_through FROM search_index_state WHERE name='message_words'")
105
117
  .get();
@@ -112,7 +124,7 @@ export const contextEvidenceOver = ({ database }) => ({
112
124
  ? "pending"
113
125
  : "ready";
114
126
  }
115
- else if (chat === undefined) {
127
+ else if (searched) {
116
128
  coverage.index = database
117
129
  .prepare(`SELECT 1 ${base} AND ${corpus.at}>=? AND ${corpus.at}<?
118
130
  AND EXISTS(SELECT 1 FROM ${source.kind === "mail" ? "email" : "document"}_index_pending p
@@ -125,12 +137,12 @@ export const contextEvidenceOver = ({ database }) => ({
125
137
  ${corpus.title} AS title,${corpus.body} AS body,${corpus.url} AS url,${corpus.at} AS at,
126
138
  n.updated_at,${corpus.revision} AS revision,${corpus.timeKind} AS time_kind ${base}
127
139
  AND ${corpus.at}>=? AND ${corpus.at}<?
128
- ${chat !== undefined ? "" : `AND n.id IN (SELECT rowid FROM ${corpus.index} WHERE ${corpus.index} MATCH ?)`}
129
- ${chat !== undefined ? "" : source.kind === "message" ? `AND n.normalized_text IS NOT NULL AND n.normalizer_version=${NORMALIZER_VERSION}` : `AND NOT EXISTS(SELECT 1 FROM ${source.kind === "mail" ? "email" : "document"}_index_pending p WHERE p.id=n.id AND p.indexable_type='${source.kind === "mail" ? "email" : "document"}')`}
140
+ ${!searched ? "" : `AND n.id IN (SELECT rowid FROM ${corpus.index} WHERE ${corpus.index} MATCH ?)`}
141
+ ${!searched ? "" : source.kind === "message" ? `AND n.normalized_text IS NOT NULL AND n.normalizer_version=${NORMALIZER_VERSION}` : `AND NOT EXISTS(SELECT 1 FROM ${source.kind === "mail" ? "email" : "document"}_index_pending p WHERE p.id=n.id AND p.indexable_type='${source.kind === "mail" ? "email" : "document"}')`}
130
142
  ${cursor ? `AND (${corpus.at}<? OR (${corpus.at}=? AND n.id<?))` : ""}
131
143
  ORDER BY ${corpus.at} DESC,n.id DESC LIMIT ?`;
132
144
  const params = [...scopeParams, after, before];
133
- if (chat === undefined)
145
+ if (searched)
134
146
  params.push(words.map((w) => `normalized_text:"${normalize(w)}"`).join(" AND "));
135
147
  if (cursor)
136
148
  params.push(cursor.at, cursor.at, cursor.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.377.0",
3
+ "version": "0.378.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",