@wirecat/cli-core 0.549.0 → 0.551.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.
Files changed (137) hide show
  1. package/CHANGELOG.md +223 -22
  2. package/THIRD_PARTY_NOTICES +41 -0
  3. package/dist/calendar/ics.d.ts +1 -1
  4. package/dist/calendar/ics.js +5 -2
  5. package/dist/calendar/windows-zones.generated.d.ts +1 -0
  6. package/dist/calendar/windows-zones.generated.js +146 -0
  7. package/dist/cli/compact.js +2 -0
  8. package/dist/cli/index.d.ts +3 -0
  9. package/dist/cli/index.js +3 -0
  10. package/dist/cli/meeting-findings-input.js +4 -3
  11. package/dist/cli/messenger/backfill-command.js +28 -1
  12. package/dist/cli/messenger/context.js +1 -0
  13. package/dist/cli/messenger/history-gaps.js +4 -4
  14. package/dist/cli/messenger/mcp-command.js +1 -0
  15. package/dist/cli/messenger/messages-delete-command.js +7 -8
  16. package/dist/cli/messenger/projects-command.js +47 -2
  17. package/dist/cli/payment-query.d.ts +8 -0
  18. package/dist/cli/payment-query.js +27 -0
  19. package/dist/cli/project-status-rows.d.ts +8 -0
  20. package/dist/cli/project-status-rows.js +102 -0
  21. package/dist/cli/project-status.d.ts +132 -0
  22. package/dist/cli/project-status.js +248 -0
  23. package/dist/cli/steps.js +4 -1
  24. package/dist/domain/knowledge.d.ts +1 -1
  25. package/dist/domain/knowledge.js +1 -1
  26. package/dist/domain/status-words.d.ts +3 -0
  27. package/dist/domain/status-words.js +44 -0
  28. package/dist/entities/fact.d.ts +12 -1
  29. package/dist/entities/fact.js +13 -0
  30. package/dist/entities/index.d.ts +18 -2
  31. package/dist/entities/meeting-findings.d.ts +1 -1
  32. package/dist/entities/message-transfer.d.ts +16 -0
  33. package/dist/entities/message-transfer.js +6 -0
  34. package/dist/errors/reasons.js +1 -0
  35. package/dist/mail/partial.d.ts +9 -3
  36. package/dist/mail/partial.js +35 -10
  37. package/dist/mail/source.d.ts +7 -1
  38. package/dist/mail/sync.js +1 -1
  39. package/dist/mcp/app-server.js +1 -0
  40. package/dist/mcp/http/oauth.d.ts +2 -1
  41. package/dist/mcp/http/oauth.js +2 -2
  42. package/dist/mcp/http/serve.d.ts +2 -0
  43. package/dist/mcp/http/serve.js +2 -1
  44. package/dist/mcp/server.js +1 -0
  45. package/dist/mcp/surface.js +1 -1
  46. package/dist/meetings/findings-rules.d.ts +14 -3
  47. package/dist/meetings/findings-rules.js +16 -2
  48. package/dist/meetings/index.d.ts +3 -2
  49. package/dist/meetings/index.js +2 -1
  50. package/dist/meetings/meeting-context.d.ts +2 -3
  51. package/dist/meetings/meeting-context.js +3 -4
  52. package/dist/meetings/payment.d.ts +27 -0
  53. package/dist/meetings/payment.js +81 -0
  54. package/dist/services/archive.d.ts +13 -0
  55. package/dist/services/archive.js +56 -5
  56. package/dist/services/deps.d.ts +3 -0
  57. package/dist/services/history-limits.d.ts +2 -0
  58. package/dist/services/index.d.ts +4 -1
  59. package/dist/services/index.js +4 -1
  60. package/dist/services/job-query.d.ts +5 -2
  61. package/dist/services/job-query.js +5 -2
  62. package/dist/services/link-scorers/alias-hits.d.ts +7 -0
  63. package/dist/services/link-scorers/alias-hits.js +54 -0
  64. package/dist/services/link-scorers/chat-senders.d.ts +6 -0
  65. package/dist/services/link-scorers/chat-senders.js +56 -0
  66. package/dist/services/link-scorers/chat-title.d.ts +6 -0
  67. package/dist/services/link-scorers/chat-title.js +44 -0
  68. package/dist/services/link-scorers/email-domain.d.ts +6 -0
  69. package/dist/services/link-scorers/email-domain.js +64 -0
  70. package/dist/services/link-scorers/index.d.ts +9 -0
  71. package/dist/services/link-scorers/index.js +8 -0
  72. package/dist/services/link-scorers/mail-thread.d.ts +3 -0
  73. package/dist/services/link-scorers/mail-thread.js +74 -0
  74. package/dist/services/link-scorers/member-chats.d.ts +3 -0
  75. package/dist/services/link-scorers/member-chats.js +40 -0
  76. package/dist/services/link-scorers/member-dialog.d.ts +3 -0
  77. package/dist/services/link-scorers/member-dialog.js +34 -0
  78. package/dist/services/link-scorers/scan.d.ts +41 -0
  79. package/dist/services/link-scorers/scan.js +244 -0
  80. package/dist/services/link-scorers/shared.d.ts +26 -0
  81. package/dist/services/link-scorers/shared.js +48 -0
  82. package/dist/services/link-scorers/types.d.ts +77 -0
  83. package/dist/services/link-scorers/types.js +1 -0
  84. package/dist/services/project-status/index.d.ts +71 -0
  85. package/dist/services/project-status/index.js +196 -0
  86. package/dist/services/project-status/items.d.ts +10 -0
  87. package/dist/services/project-status/items.js +23 -0
  88. package/dist/services/project-status/signals/amounts.d.ts +20 -0
  89. package/dist/services/project-status/signals/amounts.js +91 -0
  90. package/dist/services/project-status/signals/children.d.ts +13 -0
  91. package/dist/services/project-status/signals/children.js +28 -0
  92. package/dist/services/project-status/signals/coverage.d.ts +13 -0
  93. package/dist/services/project-status/signals/coverage.js +30 -0
  94. package/dist/services/project-status/signals/index.d.ts +3 -0
  95. package/dist/services/project-status/signals/index.js +21 -0
  96. package/dist/services/project-status/signals/last-message.d.ts +4 -0
  97. package/dist/services/project-status/signals/last-message.js +22 -0
  98. package/dist/services/project-status/signals/payments.d.ts +9 -0
  99. package/dist/services/project-status/signals/payments.js +24 -0
  100. package/dist/services/project-status/signals/promises.d.ts +17 -0
  101. package/dist/services/project-status/signals/promises.js +109 -0
  102. package/dist/services/project-status/signals/questions.d.ts +8 -0
  103. package/dist/services/project-status/signals/questions.js +38 -0
  104. package/dist/services/project-status/signals/reply-next.d.ts +19 -0
  105. package/dist/services/project-status/signals/reply-next.js +75 -0
  106. package/dist/services/project-status/signals/topics.d.ts +10 -0
  107. package/dist/services/project-status/signals/topics.js +50 -0
  108. package/dist/services/project-status/types.d.ts +84 -0
  109. package/dist/services/project-status/types.js +1 -0
  110. package/dist/services/proposals/index.d.ts +1 -0
  111. package/dist/services/proposals/index.js +1 -0
  112. package/dist/services/proposals/kinds/findings.d.ts +3 -0
  113. package/dist/services/proposals/kinds/findings.js +8 -1
  114. package/dist/store/decisions.d.ts +5 -1
  115. package/dist/store/decisions.js +5 -1
  116. package/dist/store/evidence-findings.js +1 -0
  117. package/dist/store/index.d.ts +2 -1
  118. package/dist/store/index.js +1 -0
  119. package/dist/store/project-transfer/request.d.ts +1 -1
  120. package/dist/store/project-transfer/schema.d.ts +2 -2
  121. package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
  122. package/dist/store/sqlite/email-locations.d.ts +22 -1
  123. package/dist/store/sqlite/email-locations.js +29 -2
  124. package/dist/store/sqlite/emails.d.ts +3 -7
  125. package/dist/store/sqlite/emails.js +4 -2
  126. package/dist/store/sqlite/evidence-findings.js +5 -1
  127. package/dist/store/sqlite/facts.d.ts +13 -0
  128. package/dist/store/sqlite/facts.js +64 -6
  129. package/dist/store/sqlite/findings-core.js +14 -1
  130. package/dist/store/sqlite/knowledge.d.ts +16 -2
  131. package/dist/store/sqlite/knowledge.js +1 -0
  132. package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
  133. package/dist/store/sqlite/relation-proposals.d.ts +29 -2
  134. package/dist/store/sqlite/relation-proposals.js +75 -4
  135. package/dist/store/store.d.ts +1 -0
  136. package/package.json +3 -3
  137. package/parity.json +11 -0
@@ -0,0 +1,244 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { parseReference } from "../../domain/references.js";
3
+ import { canonicalProjectRef } from "../stored-project-gathering.js";
4
+ import { aliasHits } from "./alias-hits.js";
5
+ import { chatSenders } from "./chat-senders.js";
6
+ import { chatTitle } from "./chat-title.js";
7
+ import { emailDomain } from "./email-domain.js";
8
+ import { mailThread } from "./mail-thread.js";
9
+ import { memberChats } from "./member-chats.js";
10
+ import { memberDialog } from "./member-dialog.js";
11
+ import { sameAccount } from "./shared.js";
12
+ /** A new scorer is one file and one line here. */
13
+ export const SCORERS = [chatTitle, aliasHits, memberChats, memberDialog, chatSenders, mailThread];
14
+ /** What a scan with no project runs: person ↔ person rules. */
15
+ export const PERSON_SCORERS = [emailDomain];
16
+ /** The rule bot every scan proposal is written by: only the owner decides its proposals. */
17
+ export const SCAN_BOT = { bot: "memo-scan", kind: "rule" };
18
+ export const BUILT_IN_PURPOSES = ["work", "sales", "support", "other"];
19
+ export const DEFAULT_THRESHOLD = 0.5;
20
+ /** A person ↔ person scan keeps the old rule's reach: one weak signal is enough to ask the owner. */
21
+ export const PERSON_THRESHOLD = 0.3;
22
+ export const DEFAULT_LIMIT = 20;
23
+ const projectOf = async (store, reference) => {
24
+ const ref = await canonicalProjectRef(store, reference);
25
+ const entry = (await store.knowledge.vocabulary({ kind: "project" })).entries.find((one) => one.id === ref.slice(8));
26
+ if (!entry)
27
+ throw new CliError("not_found", `no project ${reference}`);
28
+ return { ref, name: entry.name, key: entry.key, aliases: entry.aliases.map((alias) => alias.name) };
29
+ };
30
+ const confirmedOf = async (store, project) => {
31
+ if (!project)
32
+ return { chats: [], people: [] };
33
+ const members = (await store.knowledge.relations(null, project.ref)).filter((one) => one.confirmed && one.kind === "member-of" && one.to === project.ref);
34
+ return {
35
+ chats: [...new Set(members.map((one) => one.from).filter((ref) => ref.startsWith("chat:")))].sort(),
36
+ people: [...new Set(members.map((one) => one.from).filter((ref) => ref.startsWith("person:")))].sort(),
37
+ };
38
+ };
39
+ const checkedSources = async (store, sources) => {
40
+ const stored = await store.accounts.accounts();
41
+ const choices = stored.map((one) => `${one.provider}:${one.account}`);
42
+ if (!Array.isArray(sources) || sources.length === 0)
43
+ throw new CliError("validation_error", "a scan names the accounts it reads; none is picked for you", {
44
+ param: "source",
45
+ choices,
46
+ });
47
+ for (const source of sources)
48
+ if (!stored.some((one) => one.provider === source.provider && String(one.account) === String(source.account)))
49
+ throw new CliError("not_found", `the store holds no account ${source.provider}:${source.account}`, {
50
+ param: "source",
51
+ choices,
52
+ });
53
+ return sources;
54
+ };
55
+ const pairKey = ({ from, to, kind }) => `${from}\u0000${to}\u0000${kind}`;
56
+ /** 1 − Π(1 − wᵢ), over each scorer's strongest signal for the pair. */
57
+ export const combinedScore = (weights) => Math.round((1 - weights.reduce((left, weight) => left * (1 - weight), 1)) * 1000) / 1000;
58
+ /**
59
+ * Runs the scorers for one project over the sources named, combines their signals per pair, and proposes what scores
60
+ * `threshold` or more, highest first, at most `limit` new ones. A pair a link joins, or that was rejected once, is
61
+ * never proposed; a pending one is updated in place. People are matched by identity only. With `write: false`
62
+ * nothing is written.
63
+ */
64
+ export const scanProjectLinks = async (store, input) => {
65
+ const limit = input.limit ?? DEFAULT_LIMIT;
66
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50)
67
+ throw new CliError("validation_error", "a scan proposes 1–50 links a run", { param: "limit" });
68
+ const threshold = input.threshold ?? (input.project === null ? PERSON_THRESHOLD : DEFAULT_THRESHOLD);
69
+ if (typeof threshold !== "number" || !(threshold > 0 && threshold <= 1))
70
+ throw new CliError("validation_error", "a scan's threshold is more than 0 and at most 1", { param: "threshold" });
71
+ const since = Date.parse(input.since);
72
+ if (Number.isNaN(since))
73
+ throw new CliError("validation_error", "a scan's window starts at an ISO 8601 time", { param: "since" });
74
+ const sources = await checkedSources(store, input.sources);
75
+ const project = input.project === null ? null : await projectOf(store, input.project);
76
+ const owner = await store.people.owner();
77
+ const scorerInput = {
78
+ store,
79
+ project,
80
+ confirmed: await confirmedOf(store, project),
81
+ sources,
82
+ since: new Date(since).toISOString(),
83
+ purposes: input.purposes ?? BUILT_IN_PURPOSES,
84
+ owner: owner ? `person:${owner.uid}` : null,
85
+ ...(input.signal ? { signal: input.signal } : {}),
86
+ };
87
+ const scorers = input.scorers ?? (project ? SCORERS : PERSON_SCORERS);
88
+ const skipped = {
89
+ confirmed: 0,
90
+ rejected: 0,
91
+ belowThreshold: 0,
92
+ overLimit: 0,
93
+ nameOnly: 0,
94
+ unresolved: 0,
95
+ };
96
+ const pairs = new Map();
97
+ const nameOnly = new Set();
98
+ for (const scorer of scorers) {
99
+ input.signal?.throwIfAborted();
100
+ for (const candidate of await scorer.candidates(scorerInput)) {
101
+ const key = pairKey(candidate);
102
+ const verdict = await admissible(store, scorerInput, candidate);
103
+ if (verdict === "owner")
104
+ continue;
105
+ if (verdict === "name-only") {
106
+ if (!nameOnly.has(key))
107
+ skipped.nameOnly++;
108
+ nameOnly.add(key);
109
+ continue;
110
+ }
111
+ if (verdict !== "ok") {
112
+ skipped.unresolved++;
113
+ continue;
114
+ }
115
+ const weight = Math.min(1, Math.max(0, candidate.weight));
116
+ const held = pairs.get(key) ?? { candidate, signals: new Map() };
117
+ const scorerId = `${scorer.id}@${scorer.version}`;
118
+ const before = held.signals.get(scorerId);
119
+ if (!before || before.weight < weight)
120
+ held.signals.set(scorerId, {
121
+ scorer: scorerId,
122
+ weight,
123
+ why: candidate.why.slice(0, 500),
124
+ ...(candidate.purpose && scorerInput.purposes.includes(candidate.purpose)
125
+ ? { purpose: candidate.purpose }
126
+ : {}),
127
+ });
128
+ held.candidate = {
129
+ ...held.candidate,
130
+ evidence: [...new Set([...held.candidate.evidence, ...candidate.evidence])],
131
+ };
132
+ pairs.set(key, held);
133
+ }
134
+ }
135
+ const scored = [...pairs.values()]
136
+ .map(({ candidate, signals }) => {
137
+ const list = [...signals.values()].sort((a, b) => b.weight - a.weight || a.scorer.localeCompare(b.scorer));
138
+ const purpose = list.find((one) => one.purpose)?.purpose;
139
+ return {
140
+ candidate,
141
+ purpose,
142
+ signals: list.map(({ scorer, weight, why }) => ({ scorer, weight, why })),
143
+ score: combinedScore(list.map((one) => one.weight)),
144
+ };
145
+ })
146
+ .sort((a, b) => b.score - a.score || a.candidate.from.localeCompare(b.candidate.from));
147
+ const proposals = [];
148
+ let fresh = 0;
149
+ for (const { candidate, purpose, signals, score } of scored) {
150
+ const state = await store.knowledge.relationState(null, candidate);
151
+ if (!state) {
152
+ skipped.unresolved++;
153
+ continue;
154
+ }
155
+ if (state.linked) {
156
+ skipped.confirmed++;
157
+ continue;
158
+ }
159
+ if (state.rejected || (await rejectedReversed(store, candidate))) {
160
+ skipped.rejected++;
161
+ continue;
162
+ }
163
+ if (score < threshold) {
164
+ skipped.belowThreshold++;
165
+ continue;
166
+ }
167
+ if (!state.pending && fresh >= limit) {
168
+ skipped.overLimit++;
169
+ continue;
170
+ }
171
+ if (!state.pending)
172
+ fresh++;
173
+ const { identity: _identity, purpose: _purpose, weight: _weight, why: _why, ...pair } = candidate;
174
+ const evidence = candidate.evidence.slice(0, 5);
175
+ const proposal = {
176
+ ...pair,
177
+ weight: score,
178
+ why: signals.map((one) => one.why).join("; "),
179
+ evidence,
180
+ ...(purpose ? { purpose } : {}),
181
+ score,
182
+ signals,
183
+ };
184
+ if (!input.write) {
185
+ proposals.push({ ...proposal, id: state.pending?.id ?? null, state: "dry-run" });
186
+ continue;
187
+ }
188
+ const written = await store.knowledge.proposeRelation(null, {
189
+ from: pair.from,
190
+ to: pair.to,
191
+ kind: pair.kind,
192
+ evidence,
193
+ reason: proposal.why.slice(0, 2000),
194
+ score,
195
+ signals,
196
+ ...(purpose ? { purpose } : {}),
197
+ }, SCAN_BOT);
198
+ proposals.push({
199
+ ...proposal,
200
+ id: written.id,
201
+ state: !state.pending
202
+ ? "new"
203
+ : JSON.stringify(written.payload) === JSON.stringify(state.pending.payload)
204
+ ? "unchanged"
205
+ : "updated",
206
+ });
207
+ }
208
+ return { project: project?.ref ?? null, proposals, skipped };
209
+ };
210
+ /** A person candidate stands only on an identity that is that person's; a chat only in a source the scan reads. */
211
+ const admissible = async (store, input, candidate) => {
212
+ if (candidate.from === candidate.to || !Number.isFinite(candidate.weight))
213
+ return "unresolved";
214
+ if (input.project && candidate.to !== input.project.ref && !candidate.to.startsWith("person:"))
215
+ return "unresolved";
216
+ if (candidate.from === input.owner || candidate.to === input.owner)
217
+ return "owner";
218
+ if (candidate.from.startsWith("person:")) {
219
+ const identity = candidate.identity === undefined ? undefined : referenceOf(candidate.identity);
220
+ if (identity?.type !== "contact")
221
+ return "name-only";
222
+ const person = await store.people.personOf({ provider: identity.provider, id: identity.id });
223
+ return person !== undefined && `person:${person.uid}` === candidate.from ? "ok" : "name-only";
224
+ }
225
+ if (candidate.from.startsWith("chat:")) {
226
+ const chat = referenceOf(candidate.from);
227
+ return chat?.type === "chat" && input.sources.some((source) => sameAccount(source, chat)) ? "ok" : "unresolved";
228
+ }
229
+ return "unresolved";
230
+ };
231
+ const referenceOf = (text) => {
232
+ try {
233
+ return parseReference(text);
234
+ }
235
+ catch {
236
+ return undefined;
237
+ }
238
+ };
239
+ /** A person ↔ person relation reads both ways: rejecting B → A also rejects A → B. */
240
+ const rejectedReversed = async (store, candidate) => candidate.kind === "related-to" &&
241
+ candidate.to.startsWith("person:") &&
242
+ ((await store.knowledge.relationState(null, { from: candidate.to, to: candidate.from, kind: candidate.kind }))
243
+ ?.rejected ??
244
+ false);
@@ -0,0 +1,26 @@
1
+ import type { Id } from "../../domain/models.js";
2
+ import type { MessageStore } from "../../store/store.js";
3
+ import type { AccountKey } from "../../store/types.js";
4
+ import type { ScannedProject } from "./types.js";
5
+ /** A whole-word test, the rule the evidence lanes and meeting candidates use: «Harbour» is not in «Harbourside». */
6
+ export declare const wholeWords: (term: string) => RegExp;
7
+ /** The project's name, key and aliases, each once. */
8
+ export declare const projectTerms: (project: ScannedProject) => string[];
9
+ /** The first of the project's terms a text names as whole words. */
10
+ export declare const namesProject: (project: ScannedProject) => ((text: string | null | undefined) => string | undefined);
11
+ export declare const chatRef: (key: AccountKey, chat: Id) => string;
12
+ export declare const contactRef: (provider: string, id: Id) => string;
13
+ export declare const sameAccount: (a: AccountKey, b: {
14
+ provider: string;
15
+ account: string;
16
+ }) => boolean;
17
+ /** The person an identity is, as `person:<uid>`; `undefined` when the store has no person for it. */
18
+ export declare const personRefOf: (store: MessageStore, provider: string, id: Id) => Promise<string | undefined>;
19
+ /** How many projects a person is a confirmed member of. */
20
+ export declare const projectsOf: (store: MessageStore, person: string) => Promise<number>;
21
+ /** Each confirmed member's identities in one messenger, by member. */
22
+ export declare const identitiesIn: (store: MessageStore, people: readonly string[], provider: string) => Promise<{
23
+ person: string;
24
+ id: string;
25
+ }[]>;
26
+ export declare const aborted: (signal: AbortSignal | undefined) => void;
@@ -0,0 +1,48 @@
1
+ import { formatReference } from "../../domain/references.js";
2
+ import { normalizeVocabularyName } from "../../domain/vocabulary.js";
3
+ const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4
+ const folded = (text) => text.normalize("NFKC").toLowerCase().replace(/\s+/gu, " ");
5
+ /** A whole-word test, the rule the evidence lanes and meeting candidates use: «Harbour» is not in «Harbourside». */
6
+ export const wholeWords = (term) => new RegExp(`(?<![\\p{L}\\p{N}\\p{M}_])${escaped(normalizeVocabularyName(term))}(?![\\p{L}\\p{N}\\p{M}_])`, "u");
7
+ /** The project's name, key and aliases, each once. */
8
+ export const projectTerms = (project) => {
9
+ const terms = new Map();
10
+ for (const term of [project.name, project.key, ...project.aliases])
11
+ if (term?.trim())
12
+ terms.set(normalizeVocabularyName(term), term);
13
+ return [...terms.values()];
14
+ };
15
+ /** The first of the project's terms a text names as whole words. */
16
+ export const namesProject = (project) => {
17
+ const patterns = projectTerms(project).map((term) => ({ term, pattern: wholeWords(term) }));
18
+ return (text) => {
19
+ if (!text)
20
+ return undefined;
21
+ const value = folded(text);
22
+ return patterns.find(({ pattern }) => pattern.test(value))?.term;
23
+ };
24
+ };
25
+ export const chatRef = (key, chat) => formatReference({ type: "chat", provider: key.provider, account: String(key.account), chat: String(chat) });
26
+ export const contactRef = (provider, id) => formatReference({ type: "contact", provider, id: String(id) });
27
+ export const sameAccount = (a, b) => a.provider === b.provider && String(a.account) === b.account;
28
+ /** The person an identity is, as `person:<uid>`; `undefined` when the store has no person for it. */
29
+ export const personRefOf = async (store, provider, id) => {
30
+ const person = await store.people.personOf({ provider, id });
31
+ return person ? `person:${person.uid}` : undefined;
32
+ };
33
+ /** How many projects a person is a confirmed member of. */
34
+ export const projectsOf = async (store, person) => new Set((await store.knowledge.relations(null, person))
35
+ .filter((one) => one.confirmed && one.kind === "member-of" && one.from === person && one.to.startsWith("project:"))
36
+ .map((one) => one.to)).size;
37
+ /** Each confirmed member's identities in one messenger, by member. */
38
+ export const identitiesIn = async (store, people, provider) => {
39
+ const found = [];
40
+ for (const person of people) {
41
+ const record = await store.people.personByUid(person.slice("person:".length));
42
+ for (const identity of record?.identities ?? [])
43
+ if (identity.provider === provider)
44
+ found.push({ person, id: String(identity.id) });
45
+ }
46
+ return found;
47
+ };
48
+ export const aborted = (signal) => signal?.throwIfAborted();
@@ -0,0 +1,77 @@
1
+ import type { MessageStore } from "../../store/store.js";
2
+ import type { AccountKey } from "../../store/types.js";
3
+ import type { ProposalRow } from "../proposals/types.js";
4
+ /** One scorer's reason to link a chat, a mail thread or a person to a project, or a person to a person. */
5
+ export interface LinkCandidate {
6
+ /** `chat:…` (a mail thread is `chat:email/…`) or `person:…`. */
7
+ from: string;
8
+ /** `project:…`, or `person:…` for a person ↔ person rule. */
9
+ to: string;
10
+ kind: "member-of" | "related-to";
11
+ /** 0..1. */
12
+ weight: number;
13
+ /** Ids and counts only, never message text. */
14
+ why: string;
15
+ evidence: string[];
16
+ /**
17
+ * The `contact:<provider>/<id>` a person candidate was found by. A person candidate without one, or with one that
18
+ * is not that person's, is refused: people are matched by identity, never by name.
19
+ */
20
+ identity?: string;
21
+ /** What the chat is for in the project, one of the configured purposes; absent means the default, `work`. */
22
+ purpose?: string;
23
+ }
24
+ export interface ScannedProject {
25
+ ref: string;
26
+ name: string;
27
+ key: string | null;
28
+ aliases: string[];
29
+ }
30
+ export interface ScorerInput {
31
+ store: MessageStore;
32
+ /** `null` for a scan with no project, which only person ↔ person rules answer. */
33
+ project: ScannedProject | null;
34
+ confirmed: {
35
+ chats: string[];
36
+ people: string[];
37
+ };
38
+ /** Explicit; never a default account. */
39
+ sources: AccountKey[];
40
+ /** ISO 8601: the start of the window message signals count in. */
41
+ since: string;
42
+ /** The purposes the host's config lists; a candidate's purpose outside them is dropped. */
43
+ purposes: readonly string[];
44
+ /** The owner's `person:` reference, never a candidate. */
45
+ owner: string | null;
46
+ signal?: AbortSignal;
47
+ }
48
+ export interface LinkScorer {
49
+ readonly id: string;
50
+ readonly version: number;
51
+ candidates(input: ScorerInput): Promise<LinkCandidate[]>;
52
+ }
53
+ export type Signal = NonNullable<ProposalRow["signals"]>[number];
54
+ export interface ScannedProposal extends LinkCandidate {
55
+ score: number;
56
+ signals: Signal[];
57
+ /** The stored proposal; `null` in a dry run for a pair nothing holds yet. */
58
+ id: string | null;
59
+ state: "new" | "updated" | "unchanged" | "dry-run";
60
+ }
61
+ export interface ScanResult {
62
+ project: string | null;
63
+ proposals: ScannedProposal[];
64
+ skipped: {
65
+ /** A link already joins the pair. */
66
+ confirmed: number;
67
+ /** The owner (or an agent for the owner) rejected the pair once; it is never proposed again. */
68
+ rejected: number;
69
+ belowThreshold: number;
70
+ /** Over the limit of new proposals for one run, lowest scores first. */
71
+ overLimit: number;
72
+ /** A person candidate with no identity behind it. */
73
+ nameOnly: number;
74
+ /** An end the store does not hold, or a chat outside the sources. */
75
+ unresolved: number;
76
+ };
77
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import type { AccountKey, MessageStore } from "../../store/store.js";
2
+ import { STATUS_SIGNALS } from "./signals/index.js";
3
+ import type { StatusAttention, StatusChat, StatusItem, StatusProject, StatusSignal } from "./types.js";
4
+ export type * from "./types.js";
5
+ export { STATUS_SIGNALS };
6
+ /** Messages read per chat; the newest of the window. */
7
+ export declare const STATUS_WINDOW = 300;
8
+ /** Linked chats read at most, as `projects evidence` reads. */
9
+ export declare const STATUS_CHATS = 16;
10
+ /** What `projects status` reads when no purpose is asked for (built in; memo's config may change it). */
11
+ export declare const DEFAULT_PURPOSES: readonly string[];
12
+ export interface ProjectStatusInput {
13
+ /** An id, key, name or alias, as `canonicalProjectRef` takes it. */
14
+ project: string;
15
+ /** tg and max: the profile's own; left out, every account the links name. */
16
+ accounts?: AccountKey[];
17
+ days?: number;
18
+ after?: number;
19
+ asOf?: number;
20
+ contextQueries?: string[];
21
+ /** The chat purposes read; `all` reads every one. Default `DEFAULT_PURPOSES`. */
22
+ purposes?: readonly string[];
23
+ /** Default `STATUS_SIGNALS`; tests pass their own. */
24
+ signals?: readonly StatusSignal[];
25
+ }
26
+ export type StatusChatSummary = Omit<StatusChat, "messages" | "account">;
27
+ export interface MoneyTotal {
28
+ direction: string;
29
+ currency: string | null;
30
+ amount: string;
31
+ rows: number;
32
+ }
33
+ export interface ProjectStatus {
34
+ project: StatusProject;
35
+ children: (StatusProject & {
36
+ chats: number;
37
+ })[];
38
+ window: {
39
+ since: string;
40
+ until: string;
41
+ };
42
+ /** Every account a read chat is in, `provider/account`. */
43
+ accounts: string[];
44
+ chats: StatusChatSummary[];
45
+ /** Every signal's items, in signal order, then chat order. */
46
+ items: StatusItem[];
47
+ /** Open amounts and expected payment facts, summed per direction and currency. */
48
+ money: {
49
+ open: MoneyTotal[];
50
+ };
51
+ outOfScope: {
52
+ chat: string;
53
+ reason: "other-account" | "linked-chat-limit" | "purpose";
54
+ purpose?: string;
55
+ }[];
56
+ failed: {
57
+ signal: string;
58
+ why: string;
59
+ }[];
60
+ /** Chats worth reading next, highest first. */
61
+ attention: StatusAttention[];
62
+ signals: {
63
+ id: string;
64
+ version: number;
65
+ }[];
66
+ }
67
+ /**
68
+ * Where a project stands, from the store alone: its linked chats and its children's, each read once, then every
69
+ * signal on its own. A signal that fails is named in `failed` and the others still answer. No model call.
70
+ */
71
+ export declare const projectStatus: (store: MessageStore, input: ProjectStatusInput) => Promise<ProjectStatus>;
@@ -0,0 +1,196 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { formatReference } from "../../domain/references.js";
3
+ import { chatCoverage } from "../chat-coverage.js";
4
+ import { canonicalProjectRef, linkedChatsOf } from "../stored-project-gathering.js";
5
+ import { centsText } from "./signals/amounts.js";
6
+ import { STATUS_SIGNALS } from "./signals/index.js";
7
+ export { STATUS_SIGNALS };
8
+ const DAY = 86_400_000;
9
+ /** Messages read per chat; the newest of the window. */
10
+ export const STATUS_WINDOW = 300;
11
+ /** Linked chats read at most, as `projects evidence` reads. */
12
+ export const STATUS_CHATS = 16;
13
+ const CHILD_DEPTH = 2;
14
+ const CHILDREN = 8;
15
+ /** What `projects status` reads when no purpose is asked for (built in; memo's config may change it). */
16
+ export const DEFAULT_PURPOSES = ["work"];
17
+ const projectsCatalog = async (store) => new Map((await store.knowledge.vocabulary({ kind: "project" })).entries.map((entry) => [`project:${entry.id}`, entry]));
18
+ /** Confirmed `member-of` links from another project; depth two, eight at most, a project seen twice skipped. */
19
+ const childrenOf = async (store, root, catalog) => {
20
+ const children = [];
21
+ const cut = [];
22
+ const seen = new Set([root.ref]);
23
+ let level = [root.ref];
24
+ for (let depth = 1; level.length > 0; depth++) {
25
+ const below = [];
26
+ for (const parent of level)
27
+ for (const relation of await store.knowledge.relations(null, parent)) {
28
+ if (!relation.confirmed || relation.kind !== "member-of" || relation.to !== parent)
29
+ continue;
30
+ if (!relation.from.startsWith("project:"))
31
+ continue;
32
+ const ref = relation.from;
33
+ const entry = catalog.get(ref);
34
+ if (!entry)
35
+ continue;
36
+ if (seen.has(ref)) {
37
+ if (!cut.some((one) => one.ref === ref) && !children.some((one) => one.ref === ref))
38
+ cut.push({ ref, reason: "cycle" });
39
+ continue;
40
+ }
41
+ seen.add(ref);
42
+ if (depth > CHILD_DEPTH)
43
+ cut.push({ ref, reason: "depth" });
44
+ else if (children.length >= CHILDREN)
45
+ cut.push({ ref, reason: "limit" });
46
+ else {
47
+ children.push({ ref, key: entry.key, name: entry.name, parent, depth });
48
+ below.push(ref);
49
+ }
50
+ }
51
+ level = below;
52
+ }
53
+ return { children, cut };
54
+ };
55
+ const inAccounts = (accounts, provider, account) => accounts === undefined || accounts.some((one) => one.provider === provider && one.account === account);
56
+ const failure = (error) => (error instanceof Error ? error.message : String(error));
57
+ /** An open ask, or an expected payment fact; an ask that a payment fact cites is counted once, as the fact. */
58
+ const counted = (items) => {
59
+ const recorded = new Set(items.filter((item) => item.signal === "payments").flatMap((item) => item.evidence.slice(1).map((one) => one.ref)));
60
+ return (item, state) => item.signal === "payments"
61
+ ? state === "expected"
62
+ : item.signal === "amounts" && state === "open" && !item.evidence.some((one) => recorded.has(one.ref));
63
+ };
64
+ const totalsOf = (items) => {
65
+ const totals = new Map();
66
+ const open = counted(items);
67
+ for (const item of items) {
68
+ const data = item.data;
69
+ if (!open(item, data.state) || data.amount === undefined)
70
+ continue;
71
+ const key = `${data.direction}|${data.currency ?? ""}`;
72
+ const total = totals.get(key) ?? {
73
+ direction: data.direction ?? "unknown",
74
+ currency: data.currency ?? null,
75
+ amount: "0.00",
76
+ rows: 0,
77
+ cents: 0,
78
+ };
79
+ total.cents += Math.round(Number(data.amount) * 100);
80
+ total.rows += 1;
81
+ totals.set(key, total);
82
+ }
83
+ return [...totals.values()].map(({ cents, ...total }) => ({ ...total, amount: centsText(cents) }));
84
+ };
85
+ /**
86
+ * Where a project stands, from the store alone: its linked chats and its children's, each read once, then every
87
+ * signal on its own. A signal that fails is named in `failed` and the others still answer. No model call.
88
+ */
89
+ export const projectStatus = async (store, input) => {
90
+ const now = input.asOf ?? Date.now();
91
+ const since = input.after ?? now - (input.days ?? 60) * DAY;
92
+ if (!(since < now))
93
+ throw new CliError("validation_error", "the window must start before it ends");
94
+ const signals = input.signals ?? STATUS_SIGNALS;
95
+ const purposes = new Set(input.purposes ?? DEFAULT_PURPOSES);
96
+ const catalog = await projectsCatalog(store);
97
+ const ref = await canonicalProjectRef(store, input.project);
98
+ const entry = catalog.get(ref);
99
+ if (!entry)
100
+ throw new CliError("not_found", "the owner project is unavailable");
101
+ const root = { ref, key: entry.key, name: entry.name };
102
+ const { children, cut } = await childrenOf(store, root, catalog);
103
+ const outOfScope = [];
104
+ const linked = new Map();
105
+ for (const project of [root, ...children])
106
+ for (const link of await linkedChatsOf(store, project.ref)) {
107
+ const chat = formatReference({ type: "chat", provider: link.provider, account: link.account, chat: link.chat });
108
+ if (!linked.has(chat))
109
+ linked.set(chat, { ...link, project: project.ref });
110
+ }
111
+ const chosen = [];
112
+ for (const [chat, link] of linked) {
113
+ // Links carry no purpose yet; every one is `work`, as a link without one will be.
114
+ const purpose = "work";
115
+ if (!purposes.has("all") && !purposes.has(purpose))
116
+ outOfScope.push({ chat, reason: "purpose", purpose });
117
+ else if (!inAccounts(input.accounts, link.provider, link.account))
118
+ outOfScope.push({ chat, reason: "other-account" });
119
+ else if (chosen.length >= STATUS_CHATS)
120
+ outOfScope.push({ chat, reason: "linked-chat-limit" });
121
+ else
122
+ chosen.push([chat, link]);
123
+ }
124
+ const sinceIso = new Date(since).toISOString();
125
+ const chats = [];
126
+ for (const [chat, link] of chosen) {
127
+ const account = { provider: link.provider, account: link.account };
128
+ const [coverage] = await chatCoverage(store, account, [link.chat]);
129
+ const page = await store.messages.messages(account, link.chat, { limit: STATUS_WINDOW, since: sinceIso });
130
+ chats.push({
131
+ ref: chat,
132
+ account,
133
+ chatId: link.chat,
134
+ title: coverage?.title ?? null,
135
+ kind: (await store.chats.chatKind(account, link.chat)) ?? "unknown",
136
+ role: link.role,
137
+ purpose: "work",
138
+ project: link.project,
139
+ coverage: coverage,
140
+ messages: page.items.filter((message) => Date.parse(message.timestamp) <= now),
141
+ });
142
+ }
143
+ const context = {
144
+ now,
145
+ since,
146
+ chats,
147
+ project: { ...root, children, cut },
148
+ store,
149
+ contextQueries: input.contextQueries ?? [],
150
+ };
151
+ const items = [];
152
+ const failed = [];
153
+ const weights = new Map();
154
+ for (const signal of signals) {
155
+ try {
156
+ const found = signal.scope === "chat"
157
+ ? (await Promise.all(chats.map((chat) => signal.read(context, chat)))).flat()
158
+ : await signal.read(context);
159
+ for (const one of signal.attention?.(found, context) ?? []) {
160
+ const held = weights.get(one.chat) ?? { weight: 0, why: [] };
161
+ held.weight += Math.max(0, Math.min(3, one.weight));
162
+ if (!held.why.includes(one.why))
163
+ held.why.push(one.why);
164
+ weights.set(one.chat, held);
165
+ }
166
+ for (const one of found)
167
+ items.push(one);
168
+ }
169
+ catch (error) {
170
+ failed.push({ signal: signal.id, why: failure(error) });
171
+ }
172
+ }
173
+ const order = new Map(chats.map((chat, index) => [chat.ref, index]));
174
+ const attention = [...weights]
175
+ .filter(([, { weight }]) => weight > 0)
176
+ .map(([chat, { weight, why }]) => ({ chat, weight, why: why.join(", ") }))
177
+ .sort((a, b) => b.weight - a.weight || (order.get(a.chat) ?? 0) - (order.get(b.chat) ?? 0));
178
+ return {
179
+ project: root,
180
+ children: children.map(({ ref, key, name }) => ({
181
+ ref,
182
+ key,
183
+ name,
184
+ chats: chats.filter((chat) => chat.project === ref).length,
185
+ })),
186
+ window: { since: sinceIso, until: new Date(now).toISOString() },
187
+ accounts: [...new Set(chats.map((chat) => `${chat.account.provider}/${chat.account.account}`))],
188
+ chats: chats.map(({ messages: _messages, account: _account, ...chat }) => chat),
189
+ items,
190
+ money: { open: totalsOf(items) },
191
+ outOfScope,
192
+ failed,
193
+ attention,
194
+ signals: signals.map(({ id, version }) => ({ id, version })),
195
+ };
196
+ };