@wirecat/cli-core 0.376.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,38 @@ 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.
25
+
26
+ ## 0.377.0 — 20.10.2026
27
+
28
+ ### Added
29
+
30
+ - **A document hit says how many other paragraphs match.** `ProjectEvidenceItem.moreMatches` (on a document item
31
+ with `around`) counts the paragraphs after the one `around` centres on that also name a lane word. See
32
+ [project gathering](docs/dev/PROJECT-GATHERING.md).
33
+
34
+ ### Fixed
35
+
36
+ - **A document hit's `around` followed the lane that found it first, not the document.** It centred on that lane's
37
+ first matching paragraph (in a measured 145 KB document, paragraph 60 found by the key, not paragraph 4 that
38
+ names the project). It now centres on the first paragraph that names a word of any lane, in document order, as
39
+ the guide says. No extra reads or charged bytes.
9
40
 
10
41
  ## 0.376.0 — 20.10.2026
11
42
 
@@ -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
  };
@@ -20,6 +20,20 @@ export interface ProjectNeighbourReader {
20
20
  }[]>;
21
21
  }
22
22
  export declare const storedMessageNeighbours: (messages: Pick<MessagesStore, "around">) => ProjectNeighbourReader;
23
+ /**
24
+ * The paragraphs around the first one that names a word of any query, in document order, with the hit's own marked
25
+ * found, and how many later paragraphs name one too.
26
+ */
27
+ export declare const documentMatches: (text: string, queries: string[], window: {
28
+ before: number;
29
+ after: number;
30
+ }) => {
31
+ around: {
32
+ text: string;
33
+ found: boolean;
34
+ }[];
35
+ moreMatches: number;
36
+ };
23
37
  /** The paragraphs around the first one that names the query, in document order; the hit's own is marked found. */
24
38
  export declare const documentPassage: (text: string, query: string, window: {
25
39
  before: number;
@@ -19,22 +19,30 @@ export const storedMessageNeighbours = (messages) => ({
19
19
  },
20
20
  });
21
21
  const wordsOf = (text) => (text.match(/[\p{L}\p{N}\p{M}]+/gu) ?? []).map(fold);
22
- /** The paragraphs around the first one that names the query, in document order; the hit's own is marked found. */
23
- export const documentPassage = (text, query, window) => {
22
+ /**
23
+ * The paragraphs around the first one that names a word of any query, in document order, with the hit's own marked
24
+ * found, and how many later paragraphs name one too.
25
+ */
26
+ export const documentMatches = (text, queries, window) => {
24
27
  const paragraphs = text
25
28
  .split(/\n\s*\n/)
26
29
  .map((part) => part.replace(/\s+/g, " ").trim())
27
30
  .filter(Boolean);
31
+ const query = queries.join(" ");
28
32
  const words = new Set(wordsOf(query));
29
- const hit = paragraphs.findIndex((part) => wordsOf(part).some((word) => words.has(word)));
30
- if (hit < 0 || paragraphs.length < 2)
31
- return [];
33
+ const matching = paragraphs.flatMap((part, index) => (wordsOf(part).some((word) => words.has(word)) ? [index] : []));
34
+ const hit = matching[0];
35
+ if (hit === undefined || paragraphs.length < 2)
36
+ return { around: [], moreMatches: 0 };
32
37
  const from = Math.max(0, hit - window.before);
33
- return paragraphs.slice(from, hit + window.after + 1).map((part, index) => ({
38
+ const around = paragraphs.slice(from, hit + window.after + 1).map((part, index) => ({
34
39
  text: excerpt(part, query, 200, { literal: true }),
35
40
  found: from + index === hit,
36
41
  }));
42
+ return { around, moreMatches: matching.length - 1 };
37
43
  };
44
+ /** The paragraphs around the first one that names the query, in document order; the hit's own is marked found. */
45
+ export const documentPassage = (text, query, window) => documentMatches(text, [query], window).around;
38
46
  /**
39
47
  * Neighbours are context for reading a hit, not matches: they never join `items`, and their text counts against
40
48
  * the run's read bytes.
@@ -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;
@@ -36,9 +36,14 @@ export interface ProjectEvidenceItem extends Omit<ContextEvidenceItem, "text"> {
36
36
  fingerprint: string;
37
37
  around?: ProjectNeighbour[] | null;
38
38
  aroundIssue?: string | null;
39
+ /** A document hit with `around`: how many paragraphs after the one it centres on also name a lane word. */
40
+ moreMatches?: number;
39
41
  }
40
- /** Strongest first: a hit that names the project, then one only from a linked chat, then one naming only a person. */
41
- 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";
42
47
  export type ProjectGatheringStop = "reads" | "read-bytes" | "elapsed" | "items" | null;
43
48
  export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, request: ProjectGatheringRequest) => Promise<{
44
49
  schemaVersion: 1;
@@ -2,15 +2,17 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { CONTEXT_MAX_READ_BYTES, contextEvidenceFingerprint } from "../context/evidence.js";
3
3
  import { createContextEvidenceReader } from "./context-evidence.js";
4
4
  import { excerpt } from "./excerpt.js";
5
- import { attachNeighbours, documentPassage, } from "./project-gathering-around.js";
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");
@@ -31,6 +33,7 @@ export const gatherProjectEvidence = async (ports, request) => {
31
33
  plan.sources.map(() => ({ state: "not-requested", history: null, code: null }));
32
34
  const seen = new Set(previous?.seen ?? []);
33
35
  const signal = plan.signal;
36
+ const queries = plan.lanes.flatMap((lane) => (lane.query === null ? [] : [lane.query]));
34
37
  if (plan.fetch === "before-read" && !ports.fetcher)
35
38
  throw new CliError("configuration_error", "this host cannot fetch source history; use fetch none", {
36
39
  param: "fetch",
@@ -114,8 +117,10 @@ export const gatherProjectEvidence = async (ports, request) => {
114
117
  context: contextOf(lane),
115
118
  };
116
119
  if (kind === "document" && lane.query !== null && plan.around.before + plan.around.after) {
120
+ const passage = documentMatches(item.text, queries, plan.around);
117
121
  entry.around = [];
118
- for (const part of documentPassage(item.text, lane.query, plan.around)) {
122
+ entry.moreMatches = passage.moreMatches;
123
+ for (const part of passage.around) {
119
124
  const bytes = Buffer.byteLength(part.text);
120
125
  if (bytes > plan.budgets.maxReadBytes - usage.readBytes - usage.aroundBytes) {
121
126
  entry.aroundIssue = "read-bytes";
@@ -161,7 +166,11 @@ export const gatherProjectEvidence = async (ports, request) => {
161
166
  try {
162
167
  const page = await reader.read({
163
168
  source: unit.source,
164
- ...(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 }),
165
174
  after: unit.chunk.after,
166
175
  before: unit.chunk.before,
167
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.376.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",