@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 +46 -0
- package/dist/context/evidence-response.js +3 -2
- package/dist/context/evidence.d.ts +8 -4
- package/dist/context/evidence.js +19 -8
- package/dist/services/context-evidence.js +2 -1
- package/dist/services/index.d.ts +2 -2
- package/dist/services/meeting-communications-selection.d.ts +2 -1
- package/dist/services/project-gathering-around.d.ts +14 -0
- package/dist/services/project-gathering-around.js +14 -6
- package/dist/services/project-gathering-request.d.ts +24 -2
- package/dist/services/project-gathering-request.js +61 -3
- package/dist/services/project-gathering.d.ts +14 -1
- package/dist/services/project-gathering.js +38 -10
- package/dist/store/sqlite/context-evidence.js +35 -21
- package/dist/store/sqlite/knowledge.js +15 -5
- package/package.json +1 -1
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
|
|
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;
|
package/dist/context/evidence.js
CHANGED
|
@@ -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
|
|
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,
|
package/dist/services/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
23
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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,
|
|
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
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(...
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
221
|
-
AND n.deleted_at IS NULL AND ${corpus.
|
|
222
|
-
.get(at.provider, at.account, at.
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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.
|
|
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",
|