@wirecat/cli-core 0.374.0 → 0.376.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,46 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.376.0 — 20.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`gatherProjectEvidence` reads a project's linked chats.** `linkedChats: [{ provider, account, chat }]` (at
15
+ most 16, the host's confirmed `member-of` links) become lanes of their own (`kinds: ["linked-chat"]`,
16
+ `query: null`, `chat: <reference>`), read whole through the selected source of the chat's own account. Items
17
+ found only that way have `context: "linked-chat"` and no `around`; a reference a word lane also finds keeps
18
+ `context: "project"`. The result's `linkedChats` gives each entry's `ref`, `source`, `lane` and `state`
19
+ (`in-scope`, `out-of-scope` when its account is not in `sources`, never read; `unavailable` when not stored).
20
+ Checkpoints stay version 1; a run without linked chats keeps its fingerprint, and a changed link set is refused
21
+ with "start again without --cursor". See [project gathering](docs/dev/PROJECT-GATHERING.md).
22
+ - **The context reader reads one chat.** `chat` (a chat or mail thread external id) in place of `query` returns
23
+ that chat's records newest first, including a chat hidden from search, with `coverage.matching: "chat"`.
24
+ Refused on document and meeting sources and together with `query`. Old cursors stay valid.
25
+ - **`knowledge.relate` links a chat to a project.** A `chat:<provider>/<account>/<chat>` reference may be the
26
+ `from` of a `member-of` relationship to a `project:`, and `confirmRelation` confirms it. The reference carries
27
+ its account; a scope key of another account is refused. A chat is related to nothing else.
28
+
29
+ ### Changed — may break callers
30
+
31
+ - **`ProjectLane.query` is `string | null`** (null for a linked chat's lane), and `ProjectEvidenceItem.context`
32
+ adds `"linked-chat"`. `ContextEvidenceRequest.query` is optional and `coverage.matching` adds `"chat"`.
33
+ - **`store.contextRecords.read` resolves a message of a chat hidden from search**, since its reference names
34
+ the chat, so evidence from a linked hidden chat can be staged and its fingerprint rechecked.
35
+
36
+ ### Fixed
37
+
38
+ - **A resumed project gathering read nothing when every open unit already had a page** (for example a lane
39
+ stopped after its first page): it now starts at the shallowest open unit's round.
40
+
41
+ ## 0.375.0 — 20.10.2026
42
+
43
+ ### Fixed
44
+
45
+ - **A WhatsApp chat id works unquoted in a query.** `chat:133…@lid` and `chat:120…@g.us` parse as one term; before,
46
+ `@lid` was refused with `query.unsupported-operator`. The fix is in cli-base 0.47.3, so cli-core now needs
47
+ `@wirecat/cli-base >=0.47.3`: a CLI pinned to 0.47.0–0.47.2 must raise its own cli-base pin with this release.
48
+ Emails in the `email` field help and the search docs are unquoted too.
49
+
10
50
  ## 0.374.0 — 20.10.2026
11
51
 
12
52
  ### Added
@@ -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, after, before, limit, maxReadBytes } = contextEvidenceRequest(request);
11
+ const { source, chat, 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 !== "literal-words" ||
26
+ c.matching !== (chat === undefined ? "literal-words" : "chat") ||
27
27
  c.completeness !== "unknown" ||
28
28
  c.consistency !== "snapshot-per-page")
29
29
  fail();
@@ -83,6 +83,7 @@ export const validateContextEvidencePage = (page, request) => {
83
83
  const locator = parseLocator(item.ref);
84
84
  if (locator.provider !== source.provider ||
85
85
  locator.account !== source.account ||
86
+ (chat !== undefined && locator.chat !== chat) ||
86
87
  formatLocator(locator) !== item.ref)
87
88
  fail();
88
89
  text.push(locator.chat, locator.message);
@@ -8,7 +8,10 @@ export interface ContextSource {
8
8
  }
9
9
  export interface ContextEvidenceRequest {
10
10
  source: ContextSource;
11
- query: string;
11
+ /** Required unless `chat` is given; a chat read takes no query. */
12
+ query?: string;
13
+ /** One stored chat or mail thread of the source's account, by its external id; refused for documents and meetings. */
14
+ chat?: string;
12
15
  after: number;
13
16
  before: number;
14
17
  limit?: number;
@@ -52,7 +55,7 @@ export interface ContextEvidencePage {
52
55
  hasMore: boolean;
53
56
  hasUndated: boolean;
54
57
  completeness: "unknown";
55
- matching: "literal-words";
58
+ matching: "literal-words" | "chat";
56
59
  consistency: "snapshot-per-page";
57
60
  };
58
61
  }
@@ -80,11 +83,12 @@ export declare const fittingRows: (sizes: readonly number[], limit: number, maxR
80
83
  export interface ContextRecordReader {
81
84
  read(ref: string): Promise<Pick<ContextEvidenceItem, "ref" | "title" | "text" | "revision"> | null>;
82
85
  }
83
- export declare const contextCursorScope: (request: Pick<ContextEvidenceRequest, "source" | "query" | "after" | "before">) => string;
86
+ export declare const contextCursorScope: (request: Pick<ContextEvidenceRequest, "source" | "query" | "chat" | "after" | "before">) => string;
84
87
  export declare const contextSource: (value: ContextSource) => ContextSource;
85
88
  export declare const contextEvidenceRequest: (input: ContextEvidenceRequest) => {
86
89
  source: ContextSource;
87
- query: string;
90
+ query: string | undefined;
91
+ chat: string | undefined;
88
92
  words: RegExpMatchArray | [];
89
93
  after: number;
90
94
  before: number;
@@ -24,9 +24,11 @@ export const contextCursorScope = (request) => createHash("sha256")
24
24
  request.source.accountId,
25
25
  request.source.provider,
26
26
  request.source.account,
27
- request.query,
27
+ request.query ?? null,
28
28
  request.after,
29
29
  request.before,
30
+ // Only a chat read hashes its chat, so cursors issued before chat reads existed stay valid.
31
+ ...(request.chat === undefined ? [] : [request.chat]),
30
32
  ]))
31
33
  .digest("hex");
32
34
  const fields = (value, allowed, prefix) => {
@@ -51,7 +53,7 @@ export const contextSource = (value) => {
51
53
  };
52
54
  export const contextEvidenceRequest = (input) => {
53
55
  const source = contextSource(input?.source);
54
- fields(input, ["source", "query", "after", "before", "limit", "maxReadBytes", "cursor", "signal"], "");
56
+ fields(input, ["source", "query", "chat", "after", "before", "limit", "maxReadBytes", "cursor", "signal"], "");
55
57
  const integer = (value, name, low, high) => {
56
58
  if (!Number.isSafeInteger(value) || value < low || value > high)
57
59
  throw new CliError("validation_error", `${name} must be a whole number from ${low} to ${high}`);
@@ -61,11 +63,20 @@ export const contextEvidenceRequest = (input) => {
61
63
  const before = integer(input.before, "before", after + 1, 8_640_000_000_000_000);
62
64
  if (before - after > 90 * 86400000)
63
65
  throw new CliError("validation_error", "context time window must not exceed 90 days");
66
+ const chat = input.chat;
67
+ if (chat !== undefined) {
68
+ if (typeof chat !== "string" || !chat.trim() || Buffer.byteLength(chat) > 1024)
69
+ throw new CliError("validation_error", "chat needs 1–1024 UTF-8 bytes");
70
+ if (source.kind !== "message" && source.kind !== "mail")
71
+ throw new CliError("validation_error", "only a message or mail source reads one chat");
72
+ if (input.query !== undefined)
73
+ throw new CliError("validation_error", "a chat read takes no query");
74
+ }
64
75
  const query = input.query;
65
- if (typeof query !== "string" || !query.trim() || Buffer.byteLength(query) > 2048)
76
+ if (chat === undefined && (typeof query !== "string" || !query.trim() || Buffer.byteLength(query) > 2048))
66
77
  throw new CliError("validation_error", "query needs 1–2048 UTF-8 bytes of literal words");
67
- const words = query.match(/[\p{L}\p{N}]+/gu) ?? [];
68
- if (!words.length || words.length > 16)
78
+ const words = query?.match(/[\p{L}\p{N}]+/gu) ?? [];
79
+ if (chat === undefined && (!words.length || words.length > 16))
69
80
  throw new CliError("validation_error", "query needs 1–16 literal words");
70
81
  const limit = integer(input.limit ?? 10, "limit", 1, 20);
71
82
  const maxReadBytes = integer(input.maxReadBytes ?? 65536, "maxReadBytes", 1, CONTEXT_MAX_READ_BYTES);
@@ -80,7 +91,7 @@ export const contextEvidenceRequest = (input) => {
80
91
  id: integer(input.cursor.id, "cursor.id", 1, Number.MAX_SAFE_INTEGER),
81
92
  scope: input.cursor.scope,
82
93
  };
83
- if (cursor && cursor.scope !== contextCursorScope({ source, query, after, before }))
84
- throw new CliError("validation_error", "cursor belongs to another source, query or time window");
85
- return { source, query, words, after, before, limit, maxReadBytes, cursor, signal: input.signal };
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 };
86
97
  };
@@ -355,7 +355,7 @@ export const QUERY_FIELDS = [
355
355
  'phone:"8 900 000-00-01"',
356
356
  "a phone found in the text, written any way; a number without + is read in phone_countries",
357
357
  ],
358
- ["email", 'email:"ana@example.com"', "an address found in the text, lower-cased; quoted, since @ is an operator"],
358
+ ["email", "email:ana@example.com", "an address found in the text, lower-cased"],
359
359
  ["url", 'url:"https://example.com/a"', "a link found in the text, without utm_*, fbclid and the fragment"],
360
360
  ["domain", "domain:google.com", "the site of a link or an address in the text; docs.google.com means google.com"],
361
361
  [
@@ -21,7 +21,8 @@ export const createContextEvidenceReader = (port, allowed, limits) => {
21
21
  const reservation = budget.reserve(validated.maxReadBytes);
22
22
  const request = Object.freeze({
23
23
  source: Object.freeze({ ...source }),
24
- query: validated.query,
24
+ ...(validated.query === undefined ? {} : { query: validated.query }),
25
+ ...(validated.chat === undefined ? {} : { chat: validated.chat }),
25
26
  after: validated.after,
26
27
  before: validated.before,
27
28
  limit: validated.limit,
@@ -3,13 +3,13 @@ export type { ContextEvidenceItem, ContextEvidencePage, ContextEvidenceReader, C
3
3
  export { contextEvidenceFingerprint, createContextEvidenceReader } from "./context-evidence.js";
4
4
  export { excerpt } from "./excerpt.js";
5
5
  export { type DownloadedModel, downloadImageModel, downloadTextModel, type ModelDownloadOptions, } from "./model-downloads.js";
6
- export type { ProjectEvidenceItem, ProjectGathering, ProjectGatheringPorts, ProjectGatheringStop, ProjectIndexCatchUp, ProjectSourceFetcher, } from "./project-gathering.js";
6
+ export type { ProjectEvidenceContext, ProjectEvidenceItem, ProjectGathering, ProjectGatheringPorts, ProjectGatheringStop, ProjectIndexCatchUp, ProjectSourceFetcher, } from "./project-gathering.js";
7
7
  export { gatherProjectEvidence } from "./project-gathering.js";
8
8
  export type { ProjectNeighbour, ProjectNeighbourReader } from "./project-gathering-around.js";
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, ProjectUnitState, } from "./project-gathering-request.js";
12
+ export type { ProjectFetchState, ProjectGatheringBudgets, ProjectGatheringRequest, ProjectLane, ProjectLaneKind, ProjectLinkedChat, 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";
@@ -14,7 +14,8 @@ export declare const captureCommunicationSelections: (port: MeetingCommunication
14
14
  id: any;
15
15
  request: {
16
16
  source: import("./context-evidence.js").ContextSource;
17
- query: string;
17
+ query: string | undefined;
18
+ chat: string | undefined;
18
19
  after: number;
19
20
  before: number;
20
21
  limit: number;
@@ -12,6 +12,8 @@ export interface ProjectGatheringRequest {
12
12
  reference?: string;
13
13
  }[];
14
14
  sources: ContextSource[];
15
+ /** Chats the host confirmed as the project's; each is read whole, through the source of its own account. */
16
+ linkedChats?: ProjectLinkedChat[];
15
17
  scope: "explicit" | "all-connected";
16
18
  lookbackDays?: number;
17
19
  after?: number;
@@ -28,6 +30,11 @@ export interface ProjectGatheringRequest {
28
30
  checkpoint?: string;
29
31
  signal?: AbortSignal;
30
32
  }
33
+ export interface ProjectLinkedChat {
34
+ provider: string;
35
+ account: string;
36
+ chat: string;
37
+ }
31
38
  export interface ProjectGatheringBudgets {
32
39
  maxReads?: number;
33
40
  maxReadBytes?: number;
@@ -35,12 +42,15 @@ export interface ProjectGatheringBudgets {
35
42
  maxElapsedMs?: number;
36
43
  maxItems?: number;
37
44
  }
38
- export type ProjectLaneKind = "project" | "key" | "alias" | "keyword" | "person";
45
+ export type ProjectLaneKind = "project" | "key" | "alias" | "keyword" | "person" | "linked-chat";
39
46
  export interface ProjectLane {
40
47
  id: string;
41
48
  kinds: ProjectLaneKind[];
42
- query: string;
49
+ /** Null for a linked chat's lane, which reads every message of the chat. */
50
+ query: string | null;
43
51
  people: string[];
52
+ /** A linked chat's lane only: the chat's reference. */
53
+ chat?: string;
44
54
  }
45
55
  export type ProjectUnitState = "pending" | "partial" | "done" | "unavailable" | "failed" | "read-limited";
46
56
  export interface ProjectUnitProgress {
@@ -67,6 +77,8 @@ export interface ProjectGatheringCheckpoint {
67
77
  code: string | null;
68
78
  }[];
69
79
  units: ProjectUnitProgress[];
80
+ /** Present only when the run reads linked chats: a hash of their references. */
81
+ linkedChats?: string;
70
82
  seen: string[];
71
83
  totals: {
72
84
  reads: number;
@@ -90,6 +102,15 @@ export declare const projectGatheringRequest: (input: ProjectGatheringRequest, n
90
102
  }[];
91
103
  lanes: ProjectLane[];
92
104
  sources: ContextSource[];
105
+ linkedChats: {
106
+ provider: string;
107
+ account: string;
108
+ chat: string;
109
+ ref: string;
110
+ source: number | null;
111
+ lane: string | null;
112
+ }[];
113
+ linkedKey: string | undefined;
93
114
  scope: "all-connected" | "explicit";
94
115
  lookbackDays: number | null;
95
116
  window: {
@@ -126,6 +147,7 @@ export declare const projectUnits: (plan: ProjectGatheringPlan, window: {
126
147
  after: number;
127
148
  before: number;
128
149
  };
150
+ chat: string | undefined;
129
151
  }[];
130
152
  export declare const seenKey: (ref: string) => string;
131
153
  export declare const encodeCheckpoint: (checkpoint: ProjectGatheringCheckpoint) => string;
@@ -2,10 +2,11 @@ import { createHash } from "node:crypto";
2
2
  import { CliError } from "@wirecat/cli-base";
3
3
  import { contextSource } from "../context/evidence.js";
4
4
  import { contextSourceKey } from "../context/evidence-response.js";
5
+ import { formatReference } from "../domain/references.js";
5
6
  const DAY = 86400000;
6
7
  const MAX_TIME = 8_640_000_000_000_000;
7
8
  export const CONTEXT_CHUNK_DAYS = 90;
8
- const LIMITS = { lanes: 16, sources: 32, units: 512, lookbackDays: 730, people: 16 };
9
+ const LIMITS = { lanes: 16, sources: 32, units: 512, lookbackDays: 730, people: 16, linkedChats: 16 };
9
10
  const BUDGETS = {
10
11
  maxReads: [48, 512],
11
12
  maxReadBytes: [2097152, 16777216],
@@ -49,6 +50,7 @@ export const projectGatheringRequest = (input, now) => {
49
50
  "keywords",
50
51
  "people",
51
52
  "sources",
53
+ "linkedChats",
52
54
  "scope",
53
55
  "lookbackDays",
54
56
  "after",
@@ -90,10 +92,33 @@ export const projectGatheringRequest = (input, now) => {
90
92
  const sources = sourceList.map((s) => contextSource(s));
91
93
  if (new Set(sources.map(contextSourceKey)).size !== sources.length)
92
94
  fail("sources lists one source twice", "sources");
95
+ const linkedChats = list(input.linkedChats, "linkedChats", LIMITS.linkedChats).map((value, i) => {
96
+ const name = `linkedChats.${i}`;
97
+ if (!value || typeof value !== "object")
98
+ return fail(`${name} needs provider, account and chat`, name);
99
+ known(value, ["provider", "account", "chat"], `${name}.`);
100
+ const given = value;
101
+ const provider = text(given.provider, `${name}.provider`, 128);
102
+ const account = text(given.account, `${name}.account`, 1024);
103
+ const chat = text(given.chat, `${name}.chat`, 1024);
104
+ const kind = provider === "email" ? "mail" : "message";
105
+ // The chat's own account must be a selected source; any other account would widen the owner's scope.
106
+ const source = sources.findIndex((s) => s.kind === kind && s.provider === provider && s.account === account);
107
+ return {
108
+ provider,
109
+ account,
110
+ chat,
111
+ ref: formatReference({ type: "chat", provider, account, chat }),
112
+ source: source < 0 ? null : source,
113
+ lane: null,
114
+ };
115
+ });
116
+ if (new Set(linkedChats.map((c) => c.ref)).size !== linkedChats.length)
117
+ fail("linkedChats lists one chat twice", "linkedChats");
93
118
  const lanes = [];
94
119
  const lane = (kind, query, person) => {
95
120
  const key = query.toLowerCase();
96
- const found = lanes.find((l) => l.query.toLowerCase() === key) ?? {
121
+ const found = lanes.find((l) => l.query?.toLowerCase() === key) ?? {
97
122
  id: `l${lanes.length + 1}`,
98
123
  kinds: [],
99
124
  query,
@@ -117,6 +142,12 @@ export const projectGatheringRequest = (input, now) => {
117
142
  lane("person", p.name, p.reference ?? p.name);
118
143
  if (lanes.length > LIMITS.lanes)
119
144
  fail(`at most ${LIMITS.lanes} distinct search lanes`, "keywords");
145
+ for (const linked of linkedChats) {
146
+ if (linked.source === null)
147
+ continue;
148
+ linked.lane = `l${lanes.length + 1}`;
149
+ lanes.push({ id: linked.lane, kinds: ["linked-chat"], query: null, people: [], chat: linked.ref });
150
+ }
120
151
  const fetch = input.fetch ?? "none";
121
152
  const index = input.index ?? "update";
122
153
  if (index !== "update" && index !== "as-is")
@@ -171,6 +202,11 @@ export const projectGatheringRequest = (input, now) => {
171
202
  fetch,
172
203
  ]))
173
204
  .digest("hex");
205
+ const linkedKey = linkedChats.length
206
+ ? createHash("sha256")
207
+ .update(JSON.stringify(linkedChats.map((c) => c.ref)))
208
+ .digest("hex")
209
+ : undefined;
174
210
  const runKey = input.runKey === undefined ? fingerprint : text(input.runKey, "runKey", 256);
175
211
  if (input.checkpoint !== undefined && typeof input.checkpoint !== "string")
176
212
  fail("checkpoint must be text", "checkpoint");
@@ -180,6 +216,8 @@ export const projectGatheringRequest = (input, now) => {
180
216
  people,
181
217
  lanes,
182
218
  sources,
219
+ linkedChats,
220
+ linkedKey,
183
221
  scope: input.scope,
184
222
  lookbackDays,
185
223
  window,
@@ -201,7 +239,25 @@ export const contextChunks = (after, before) => {
201
239
  return chunks;
202
240
  };
203
241
  export const projectUnits = (plan, window) => {
204
- const units = contextChunks(window.after, window.before).flatMap((chunk) => plan.lanes.flatMap((lane) => plan.sources.map((source, sourceIndex) => ({ lane, source, sourceIndex, chunk }))));
242
+ const chunks = contextChunks(window.after, window.before);
243
+ const literal = chunks.flatMap((chunk) => plan.lanes
244
+ .filter((lane) => lane.query !== null)
245
+ .flatMap((lane) => plan.sources.map((source, sourceIndex) => ({
246
+ lane,
247
+ source,
248
+ sourceIndex,
249
+ chunk,
250
+ chat: undefined,
251
+ }))));
252
+ // Appended after every literal unit: a checkpoint from a run without links keeps its unit order, and in each
253
+ // round a literal lane labels a shared hit before the linked chat does.
254
+ const linked = chunks.flatMap((chunk) => plan.linkedChats.flatMap(({ lane, source, chat }) => {
255
+ const found = plan.lanes.find((l) => l.id === lane);
256
+ return found && source !== null
257
+ ? [{ lane: found, source: plan.sources[source], sourceIndex: source, chunk, chat }]
258
+ : [];
259
+ }));
260
+ const units = [...literal, ...linked];
205
261
  if (units.length > LIMITS.units)
206
262
  fail(`${units.length} lane × source × 90-day reads exceed ${LIMITS.units}; narrow the lanes, sources or window`);
207
263
  return units;
@@ -227,6 +283,8 @@ export const decodeCheckpoint = (text, plan) => {
227
283
  value.after < 0 ||
228
284
  value.before <= value.after)
229
285
  fail("invalid project gathering checkpoint", "checkpoint");
286
+ if ((value.linkedChats ?? null) !== (plan.linkedKey ?? null))
287
+ fail("the project's linked chats changed; start again without --cursor", "checkpoint");
230
288
  if (value.runKey !== plan.runKey)
231
289
  fail("checkpoint belongs to another run key", "checkpoint");
232
290
  if (value.fingerprint !== plan.fingerprint)
@@ -32,11 +32,13 @@ export interface ProjectEvidenceItem extends Omit<ContextEvidenceItem, "text"> {
32
32
  text: string;
33
33
  textTruncated: boolean;
34
34
  lanes: string[];
35
- context: "project" | "person-only";
35
+ context: ProjectEvidenceContext;
36
36
  fingerprint: string;
37
37
  around?: ProjectNeighbour[] | null;
38
38
  aroundIssue?: string | null;
39
39
  }
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";
40
42
  export type ProjectGatheringStop = "reads" | "read-bytes" | "elapsed" | "items" | null;
41
43
  export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, request: ProjectGatheringRequest) => Promise<{
42
44
  schemaVersion: 1;
@@ -62,6 +64,15 @@ export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, reque
62
64
  sources: ContextSource[];
63
65
  };
64
66
  lanes: ProjectLane[];
67
+ linkedChats: {
68
+ provider: string;
69
+ account: string;
70
+ chat: string;
71
+ ref: string;
72
+ source: number | null;
73
+ lane: string | null;
74
+ state: "in-scope" | "out-of-scope" | "unavailable";
75
+ }[];
65
76
  fetch: {
66
77
  state: ProjectFetchState;
67
78
  history: "complete" | "partial" | "unknown" | null;
@@ -5,6 +5,12 @@ import { excerpt } from "./excerpt.js";
5
5
  import { attachNeighbours, documentPassage, } 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"];
9
+ const contextOf = (lane) => lane.kinds.includes("linked-chat")
10
+ ? "linked-chat"
11
+ : lane.kinds.every((k) => k === "person")
12
+ ? "person-only"
13
+ : "project";
8
14
  export const gatherProjectEvidence = async (ports, request) => {
9
15
  if (!ports?.evidence || typeof ports.evidence.read !== "function")
10
16
  throw new CliError("configuration_error", "stored context reads are unavailable in this store");
@@ -81,8 +87,9 @@ export const gatherProjectEvidence = async (ports, request) => {
81
87
  if (known) {
82
88
  if (!known.lanes.includes(lane.id))
83
89
  known.lanes.push(lane.id);
84
- if (!lane.kinds.every((k) => k === "person"))
85
- known.context = "project";
90
+ const context = contextOf(lane);
91
+ if (CONTEXTS.indexOf(context) > CONTEXTS.indexOf(known.context))
92
+ known.context = context;
86
93
  return true;
87
94
  }
88
95
  if (!seen.has(seenKey(ref)))
@@ -94,7 +101,7 @@ export const gatherProjectEvidence = async (ports, request) => {
94
101
  if (again(item.ref, lane))
95
102
  return;
96
103
  seen.add(seenKey(item.ref));
97
- const short = excerpt(item.text, lane.query, 300, { literal: true });
104
+ const short = excerpt(item.text, lane.query ?? "", 300, { literal: true });
98
105
  const fingerprint = contextEvidenceFingerprint(item);
99
106
  const entry = {
100
107
  ...item,
@@ -104,9 +111,9 @@ export const gatherProjectEvidence = async (ports, request) => {
104
111
  textTruncated: short !== item.text.replace(/\s+/g, " ").trim(),
105
112
  fingerprint,
106
113
  lanes: [lane.id],
107
- context: lane.kinds.every((k) => k === "person") ? "person-only" : "project",
114
+ context: contextOf(lane),
108
115
  };
109
- if (kind === "document" && plan.around.before + plan.around.after) {
116
+ if (kind === "document" && lane.query !== null && plan.around.before + plan.around.after) {
110
117
  entry.around = [];
111
118
  for (const part of documentPassage(item.text, lane.query, plan.around)) {
112
119
  const bytes = Buffer.byteLength(part.text);
@@ -122,7 +129,9 @@ export const gatherProjectEvidence = async (ports, request) => {
122
129
  items.push(entry);
123
130
  usage.items++;
124
131
  };
125
- for (let round = 0; !stopped; round++) {
132
+ // A resumed run starts at its shallowest open unit: below it no unit is due, and an empty round would end the run.
133
+ const shallowest = progress.reduce((low, u) => (open(u) ? Math.min(low, u.pages) : low), Number.POSITIVE_INFINITY);
134
+ for (let round = shallowest; !stopped; round++) {
126
135
  const due = progress.flatMap((u, index) => (open(u) && u.pages <= round ? [index] : []));
127
136
  if (!due.length)
128
137
  break;
@@ -152,7 +161,7 @@ export const gatherProjectEvidence = async (ports, request) => {
152
161
  try {
153
162
  const page = await reader.read({
154
163
  source: unit.source,
155
- query: unit.lane.query,
164
+ ...(unit.chat === undefined ? { query: unit.lane.query ?? undefined } : { chat: unit.chat }),
156
165
  after: unit.chunk.after,
157
166
  before: unit.chunk.before,
158
167
  limit,
@@ -214,7 +223,9 @@ export const gatherProjectEvidence = async (ports, request) => {
214
223
  }
215
224
  }
216
225
  }
217
- await attachNeighbours(items, ports.neighbours, plan.around, {
226
+ await attachNeighbours(
227
+ // A linked chat's neighbours are already its own lane's evidence.
228
+ items.filter((item) => item.context !== "linked-chat"), ports.neighbours, plan.around, {
218
229
  remaining: () => plan.budgets.maxReadBytes - usage.readBytes - usage.aroundBytes,
219
230
  charge: (bytes) => {
220
231
  usage.aroundBytes += bytes;
@@ -236,6 +247,7 @@ export const gatherProjectEvidence = async (ports, request) => {
236
247
  asOf: window.asOf,
237
248
  fetched,
238
249
  units: progress.map((u) => (u.state === "read-limited" ? { ...u, state: "partial" } : u)),
250
+ ...(plan.linkedKey === undefined ? {} : { linkedChats: plan.linkedKey }),
239
251
  seen: [...seen],
240
252
  totals,
241
253
  };
@@ -255,6 +267,19 @@ export const gatherProjectEvidence = async (ports, request) => {
255
267
  },
256
268
  scope: { origin: plan.scope, sources: plan.sources },
257
269
  lanes: plan.lanes,
270
+ linkedChats: plan.linkedChats.map(({ provider, account, chat, ref, source, lane }) => ({
271
+ provider,
272
+ account,
273
+ chat,
274
+ ref,
275
+ source,
276
+ lane,
277
+ state: source === null
278
+ ? "out-of-scope"
279
+ : units.every((unit, index) => unit.lane.id !== lane || progress[index]?.state === "unavailable")
280
+ ? "unavailable"
281
+ : "in-scope",
282
+ })),
258
283
  fetch: fetched.map((f, index) => ({ source: index, ...f })),
259
284
  index: indexed,
260
285
  units: units.map((unit, index) => ({
@@ -19,6 +19,9 @@ const corpora = {
19
19
  timeKind: "'sent'",
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
+ chats: "chats",
23
+ // A read that names the chat sees it even when it is hidden from search (searchable=0).
24
+ named: "c.external_id=?",
22
25
  },
23
26
  mail: {
24
27
  table: "emails",
@@ -32,6 +35,8 @@ const corpora = {
32
35
  timeKind: "'sent'",
33
36
  join: "JOIN email_threads c ON c.id=n.email_thread_id AND c.account_id=n.account_id",
34
37
  where: "a.provider='email'",
38
+ chats: "email_threads",
39
+ named: "a.provider='email' AND c.external_id=?",
35
40
  },
36
41
  document: {
37
42
  table: "documents",
@@ -45,17 +50,19 @@ const corpora = {
45
50
  timeKind: contextDocumentTimeKind,
46
51
  join: "",
47
52
  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
+ chats: null,
54
+ named: null,
48
55
  },
49
56
  };
50
57
  /** No index fills, model loads or record hydration outside the read transaction. */
51
58
  export const contextEvidenceOver = ({ database }) => ({
52
59
  async read(original) {
53
60
  const request = contextEvidenceRequest(original);
54
- const { source, words, after, before, limit, maxReadBytes, cursor, signal } = request;
61
+ const { source, words, chat, after, before, limit, maxReadBytes, cursor, signal } = request;
55
62
  if (source.kind === "meeting")
56
63
  return readMeetingContextEvidence(database, request);
57
64
  const corpus = corpora[source.kind];
58
- const scope = contextCursorScope({ source, query: request.query, after, before });
65
+ const scope = contextCursorScope({ source, query: request.query, chat, after, before });
59
66
  signal?.throwIfAborted();
60
67
  return read(database, () => {
61
68
  const coverage = {
@@ -64,25 +71,35 @@ export const contextEvidenceOver = ({ database }) => ({
64
71
  hasMore: false,
65
72
  hasUndated: false,
66
73
  completeness: "unknown",
67
- matching: "literal-words",
74
+ matching: chat === undefined ? "literal-words" : "chat",
68
75
  consistency: "snapshot-per-page",
69
76
  };
70
77
  const empty = () => ({ source, items: [], next: null, readBytes: 0, coverage });
71
- const scopeParams = [source.accountId, source.provider, source.account];
78
+ const accountParams = [source.accountId, source.provider, source.account];
72
79
  const account = database
73
80
  .prepare(`SELECT 1 FROM accounts WHERE id=? AND provider=? AND external_id=?${source.kind === "mail" ? " AND provider='email'" : ""}`)
74
- .get(...scopeParams);
81
+ .get(...accountParams);
75
82
  if (!account)
76
83
  return empty();
84
+ if (chat !== undefined &&
85
+ !database
86
+ .prepare(`SELECT 1 FROM ${corpus.chats} WHERE account_id=? AND external_id=?`)
87
+ .get(source.accountId, chat))
88
+ return empty();
77
89
  coverage.available = true;
78
- if (!database.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(corpus.index)) {
90
+ const scopeParams = chat === undefined ? accountParams : [...accountParams, chat];
91
+ const base = `FROM ${corpus.table} n JOIN accounts a ON a.id=n.account_id ${corpus.join}
92
+ 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)
96
+ coverage.index = "ready";
97
+ else if (!database.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(corpus.index)) {
79
98
  coverage.index = "missing";
80
99
  return empty();
81
100
  }
82
- const base = `FROM ${corpus.table} n JOIN accounts a ON a.id=n.account_id ${corpus.join}
83
- WHERE n.account_id=? AND a.provider=? AND a.external_id=? AND n.deleted_at IS NULL AND ${corpus.where}`;
84
101
  coverage.hasUndated = Boolean(database.prepare(`SELECT 1 ${base} AND ${corpus.at} IS NULL LIMIT 1`).get(...scopeParams));
85
- if (source.kind === "message") {
102
+ if (chat === undefined && source.kind === "message") {
86
103
  const state = database
87
104
  .prepare("SELECT watermark,filled_through FROM search_index_state WHERE name='message_words'")
88
105
  .get();
@@ -95,7 +112,7 @@ export const contextEvidenceOver = ({ database }) => ({
95
112
  ? "pending"
96
113
  : "ready";
97
114
  }
98
- else {
115
+ else if (chat === undefined) {
99
116
  coverage.index = database
100
117
  .prepare(`SELECT 1 ${base} AND ${corpus.at}>=? AND ${corpus.at}<?
101
118
  AND EXISTS(SELECT 1 FROM ${source.kind === "mail" ? "email" : "document"}_index_pending p
@@ -108,16 +125,13 @@ export const contextEvidenceOver = ({ database }) => ({
108
125
  ${corpus.title} AS title,${corpus.body} AS body,${corpus.url} AS url,${corpus.at} AS at,
109
126
  n.updated_at,${corpus.revision} AS revision,${corpus.timeKind} AS time_kind ${base}
110
127
  AND ${corpus.at}>=? AND ${corpus.at}<?
111
- AND n.id IN (SELECT rowid FROM ${corpus.index} WHERE ${corpus.index} MATCH ?)
112
- ${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"}')`}
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"}')`}
113
130
  ${cursor ? `AND (${corpus.at}<? OR (${corpus.at}=? AND n.id<?))` : ""}
114
131
  ORDER BY ${corpus.at} DESC,n.id DESC LIMIT ?`;
115
- const params = [
116
- ...scopeParams,
117
- after,
118
- before,
119
- words.map((w) => `normalized_text:"${normalize(w)}"`).join(" AND "),
120
- ];
132
+ const params = [...scopeParams, after, before];
133
+ if (chat === undefined)
134
+ params.push(words.map((w) => `normalized_text:"${normalize(w)}"`).join(" AND "));
121
135
  if (cursor)
122
136
  params.push(cursor.at, cursor.at, cursor.id);
123
137
  params.push(limit + 1);
@@ -217,9 +231,9 @@ export const contextRecordOf = (database, ref) => {
217
231
  const corpus = at.provider === "email" ? corpora.mail : corpora.message;
218
232
  const row = database
219
233
  .prepare(`SELECT ${corpus.title} AS title,${corpus.body} AS body FROM ${corpus.table} n JOIN accounts a ON a.id=n.account_id
220
- ${corpus.join} WHERE a.provider=? AND a.external_id=? AND c.external_id=? AND n.external_id=?
221
- AND n.deleted_at IS NULL AND ${corpus.where}`)
222
- .get(at.provider, at.account, at.chat, at.message);
234
+ ${corpus.join} WHERE a.provider=? AND a.external_id=? AND n.external_id=?
235
+ AND n.deleted_at IS NULL AND ${corpus.named}`)
236
+ .get(at.provider, at.account, at.message, at.chat);
223
237
  return row
224
238
  ? { ref, title: row.title == null ? null : String(row.title), text: String(row.body), revision: null }
225
239
  : null;
@@ -222,11 +222,18 @@ export const knowledgeStoreOver = (context) => {
222
222
  throw new CliError("not_found", "no relationship with that id");
223
223
  return row;
224
224
  };
225
- const related = (key, reference) => {
225
+ /** A chat may only be the member: its reference names its own account, so it is never another account's. */
226
+ const related = (key, reference, member = false) => {
226
227
  checked(key);
227
228
  const thing = thingOf(database, reference);
228
- if (!thing || !RELATED.includes(thing.type) || stateOfThing(database, thing) !== "available")
229
- throw new CliError("not_found", "reference needs a stored person:, organization:, project: or task:");
229
+ const chat = member && (thing?.type === "chat" || thing?.type === "email_thread");
230
+ if (!thing || !(RELATED.includes(thing.type) || chat) || stateOfThing(database, thing) !== "available")
231
+ throw new CliError("not_found", `reference needs a stored person:, organization:, project:${member ? ", task: or chat:" : " or task:"}`);
232
+ if (chat && key !== null) {
233
+ const parsed = parseReference(reference);
234
+ if (parsed.provider !== key.provider || parsed.account !== key.account)
235
+ throw new CliError("validation_error", "the chat belongs to another account");
236
+ }
230
237
  return thing;
231
238
  };
232
239
  const reminderOf = (row) => ({
@@ -473,10 +480,13 @@ export const knowledgeStoreOver = (context) => {
473
480
  .all()
474
481
  .map(projectOf),
475
482
  relate: async (key, input) => {
476
- const from = related(key, input.from);
483
+ const from = related(key, input.from, true);
477
484
  const to = related(key, input.to);
478
485
  if ((from.type === to.type && from.id === to.id) || !RELATION_KINDS.includes(input.kind))
479
486
  throw new CliError("validation_error", "a relationship needs distinct references and a supported kind");
487
+ if ((from.type === "chat" || from.type === "email_thread") &&
488
+ (input.kind !== "member-of" || to.type !== "project"))
489
+ throw new CliError("validation_error", "a chat is related only as member-of a project");
480
490
  const existing = database
481
491
  .prepare(`${RELATIONS} AND from_type=? AND from_id=? AND to_type=? AND to_id=? AND kind=?`)
482
492
  .get(from.type, from.id, to.type, to.id, input.kind);
@@ -514,7 +524,7 @@ export const knowledgeStoreOver = (context) => {
514
524
  checked(key);
515
525
  const row = relation(id);
516
526
  const link = linkOf(database, row);
517
- related(key, link.from);
527
+ related(key, link.from, true);
518
528
  related(key, String(link.to));
519
529
  database
520
530
  .prepare("UPDATE links SET confirmed=1, source='owner', author='owner', updated_at=? WHERE id=?")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.374.0",
3
+ "version": "0.376.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",
@@ -202,7 +202,7 @@
202
202
  "@types/node": "^22.20.4",
203
203
  "@types/pngjs": "6.0.5",
204
204
  "@vitest/coverage-v8": "^5.0.2",
205
- "@wirecat/cli-base": "0.47.0",
205
+ "@wirecat/cli-base": "0.47.3",
206
206
  "commander": "15.0.0",
207
207
  "cspell": "10.3.6",
208
208
  "drizzle-kit": "1.0.0-rc.4",
@@ -222,7 +222,7 @@
222
222
  },
223
223
  "peerDependencies": {
224
224
  "@napi-rs/canvas": ">=0.1.69 <2",
225
- "@wirecat/cli-base": ">=0.47.0 <0.48.0",
225
+ "@wirecat/cli-base": ">=0.47.3 <0.48.0",
226
226
  "commander": ">=15"
227
227
  },
228
228
  "bin": {