@wirecat/cli-core 0.375.0 → 0.377.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,52 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.377.0 — 20.10.2026
11
+
12
+ ### Added
13
+
14
+ - **A document hit says how many other paragraphs match.** `ProjectEvidenceItem.moreMatches` (on a document item
15
+ with `around`) counts the paragraphs after the one `around` centres on that also name a lane word. See
16
+ [project gathering](docs/dev/PROJECT-GATHERING.md).
17
+
18
+ ### Fixed
19
+
20
+ - **A document hit's `around` followed the lane that found it first, not the document.** It centred on that lane's
21
+ first matching paragraph (in a measured 145 KB document, paragraph 60 found by the key, not paragraph 4 that
22
+ names the project). It now centres on the first paragraph that names a word of any lane, in document order, as
23
+ the guide says. No extra reads or charged bytes.
24
+
25
+ ## 0.376.0 — 20.10.2026
26
+
27
+ ### Added
28
+
29
+ - **`gatherProjectEvidence` reads a project's linked chats.** `linkedChats: [{ provider, account, chat }]` (at
30
+ most 16, the host's confirmed `member-of` links) become lanes of their own (`kinds: ["linked-chat"]`,
31
+ `query: null`, `chat: <reference>`), read whole through the selected source of the chat's own account. Items
32
+ found only that way have `context: "linked-chat"` and no `around`; a reference a word lane also finds keeps
33
+ `context: "project"`. The result's `linkedChats` gives each entry's `ref`, `source`, `lane` and `state`
34
+ (`in-scope`, `out-of-scope` when its account is not in `sources`, never read; `unavailable` when not stored).
35
+ Checkpoints stay version 1; a run without linked chats keeps its fingerprint, and a changed link set is refused
36
+ with "start again without --cursor". See [project gathering](docs/dev/PROJECT-GATHERING.md).
37
+ - **The context reader reads one chat.** `chat` (a chat or mail thread external id) in place of `query` returns
38
+ that chat's records newest first, including a chat hidden from search, with `coverage.matching: "chat"`.
39
+ Refused on document and meeting sources and together with `query`. Old cursors stay valid.
40
+ - **`knowledge.relate` links a chat to a project.** A `chat:<provider>/<account>/<chat>` reference may be the
41
+ `from` of a `member-of` relationship to a `project:`, and `confirmRelation` confirms it. The reference carries
42
+ its account; a scope key of another account is refused. A chat is related to nothing else.
43
+
44
+ ### Changed — may break callers
45
+
46
+ - **`ProjectLane.query` is `string | null`** (null for a linked chat's lane), and `ProjectEvidenceItem.context`
47
+ adds `"linked-chat"`. `ContextEvidenceRequest.query` is optional and `coverage.matching` adds `"chat"`.
48
+ - **`store.contextRecords.read` resolves a message of a chat hidden from search**, since its reference names
49
+ the chat, so evidence from a linked hidden chat can be staged and its fingerprint rechecked.
50
+
51
+ ### Fixed
52
+
53
+ - **A resumed project gathering read nothing when every open unit already had a page** (for example a lane
54
+ stopped after its first page): it now starts at the shallowest open unit's round.
55
+
10
56
  ## 0.375.0 — 20.10.2026
11
57
 
12
58
  ### Fixed
@@ -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
  };
@@ -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;
@@ -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.
@@ -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,15 @@ 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
+ /** A document hit with `around`: how many paragraphs after the one it centres on also name a lane word. */
40
+ moreMatches?: number;
39
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";
40
44
  export type ProjectGatheringStop = "reads" | "read-bytes" | "elapsed" | "items" | null;
41
45
  export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, request: ProjectGatheringRequest) => Promise<{
42
46
  schemaVersion: 1;
@@ -62,6 +66,15 @@ export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, reque
62
66
  sources: ContextSource[];
63
67
  };
64
68
  lanes: ProjectLane[];
69
+ linkedChats: {
70
+ provider: string;
71
+ account: string;
72
+ chat: string;
73
+ ref: string;
74
+ source: number | null;
75
+ lane: string | null;
76
+ state: "in-scope" | "out-of-scope" | "unavailable";
77
+ }[];
65
78
  fetch: {
66
79
  state: ProjectFetchState;
67
80
  history: "complete" | "partial" | "unknown" | null;
@@ -2,9 +2,15 @@ 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"];
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");
@@ -25,6 +31,7 @@ export const gatherProjectEvidence = async (ports, request) => {
25
31
  plan.sources.map(() => ({ state: "not-requested", history: null, code: null }));
26
32
  const seen = new Set(previous?.seen ?? []);
27
33
  const signal = plan.signal;
34
+ const queries = plan.lanes.flatMap((lane) => (lane.query === null ? [] : [lane.query]));
28
35
  if (plan.fetch === "before-read" && !ports.fetcher)
29
36
  throw new CliError("configuration_error", "this host cannot fetch source history; use fetch none", {
30
37
  param: "fetch",
@@ -81,8 +88,9 @@ export const gatherProjectEvidence = async (ports, request) => {
81
88
  if (known) {
82
89
  if (!known.lanes.includes(lane.id))
83
90
  known.lanes.push(lane.id);
84
- if (!lane.kinds.every((k) => k === "person"))
85
- known.context = "project";
91
+ const context = contextOf(lane);
92
+ if (CONTEXTS.indexOf(context) > CONTEXTS.indexOf(known.context))
93
+ known.context = context;
86
94
  return true;
87
95
  }
88
96
  if (!seen.has(seenKey(ref)))
@@ -94,7 +102,7 @@ export const gatherProjectEvidence = async (ports, request) => {
94
102
  if (again(item.ref, lane))
95
103
  return;
96
104
  seen.add(seenKey(item.ref));
97
- const short = excerpt(item.text, lane.query, 300, { literal: true });
105
+ const short = excerpt(item.text, lane.query ?? "", 300, { literal: true });
98
106
  const fingerprint = contextEvidenceFingerprint(item);
99
107
  const entry = {
100
108
  ...item,
@@ -104,11 +112,13 @@ export const gatherProjectEvidence = async (ports, request) => {
104
112
  textTruncated: short !== item.text.replace(/\s+/g, " ").trim(),
105
113
  fingerprint,
106
114
  lanes: [lane.id],
107
- context: lane.kinds.every((k) => k === "person") ? "person-only" : "project",
115
+ context: contextOf(lane),
108
116
  };
109
- if (kind === "document" && plan.around.before + plan.around.after) {
117
+ if (kind === "document" && lane.query !== null && plan.around.before + plan.around.after) {
118
+ const passage = documentMatches(item.text, queries, plan.around);
110
119
  entry.around = [];
111
- for (const part of documentPassage(item.text, lane.query, plan.around)) {
120
+ entry.moreMatches = passage.moreMatches;
121
+ for (const part of passage.around) {
112
122
  const bytes = Buffer.byteLength(part.text);
113
123
  if (bytes > plan.budgets.maxReadBytes - usage.readBytes - usage.aroundBytes) {
114
124
  entry.aroundIssue = "read-bytes";
@@ -122,7 +132,9 @@ export const gatherProjectEvidence = async (ports, request) => {
122
132
  items.push(entry);
123
133
  usage.items++;
124
134
  };
125
- for (let round = 0; !stopped; round++) {
135
+ // A resumed run starts at its shallowest open unit: below it no unit is due, and an empty round would end the run.
136
+ const shallowest = progress.reduce((low, u) => (open(u) ? Math.min(low, u.pages) : low), Number.POSITIVE_INFINITY);
137
+ for (let round = shallowest; !stopped; round++) {
126
138
  const due = progress.flatMap((u, index) => (open(u) && u.pages <= round ? [index] : []));
127
139
  if (!due.length)
128
140
  break;
@@ -152,7 +164,7 @@ export const gatherProjectEvidence = async (ports, request) => {
152
164
  try {
153
165
  const page = await reader.read({
154
166
  source: unit.source,
155
- query: unit.lane.query,
167
+ ...(unit.chat === undefined ? { query: unit.lane.query ?? undefined } : { chat: unit.chat }),
156
168
  after: unit.chunk.after,
157
169
  before: unit.chunk.before,
158
170
  limit,
@@ -214,7 +226,9 @@ export const gatherProjectEvidence = async (ports, request) => {
214
226
  }
215
227
  }
216
228
  }
217
- await attachNeighbours(items, ports.neighbours, plan.around, {
229
+ await attachNeighbours(
230
+ // A linked chat's neighbours are already its own lane's evidence.
231
+ items.filter((item) => item.context !== "linked-chat"), ports.neighbours, plan.around, {
218
232
  remaining: () => plan.budgets.maxReadBytes - usage.readBytes - usage.aroundBytes,
219
233
  charge: (bytes) => {
220
234
  usage.aroundBytes += bytes;
@@ -236,6 +250,7 @@ export const gatherProjectEvidence = async (ports, request) => {
236
250
  asOf: window.asOf,
237
251
  fetched,
238
252
  units: progress.map((u) => (u.state === "read-limited" ? { ...u, state: "partial" } : u)),
253
+ ...(plan.linkedKey === undefined ? {} : { linkedChats: plan.linkedKey }),
239
254
  seen: [...seen],
240
255
  totals,
241
256
  };
@@ -255,6 +270,19 @@ export const gatherProjectEvidence = async (ports, request) => {
255
270
  },
256
271
  scope: { origin: plan.scope, sources: plan.sources },
257
272
  lanes: plan.lanes,
273
+ linkedChats: plan.linkedChats.map(({ provider, account, chat, ref, source, lane }) => ({
274
+ provider,
275
+ account,
276
+ chat,
277
+ ref,
278
+ source,
279
+ lane,
280
+ state: source === null
281
+ ? "out-of-scope"
282
+ : units.every((unit, index) => unit.lane.id !== lane || progress[index]?.state === "unavailable")
283
+ ? "unavailable"
284
+ : "in-scope",
285
+ })),
258
286
  fetch: fetched.map((f, index) => ({ source: index, ...f })),
259
287
  index: indexed,
260
288
  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.375.0",
3
+ "version": "0.377.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",