@wirecat/cli-core 0.550.0 → 0.552.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 +235 -0
- package/README.md +1 -1
- package/THIRD_PARTY_NOTICES +41 -0
- package/dist/background/schedules.d.ts +12 -1
- package/dist/background/schedules.js +65 -24
- package/dist/background/units.d.ts +16 -9
- package/dist/background/units.js +26 -11
- package/dist/calendar/ics.d.ts +1 -1
- package/dist/calendar/ics.js +5 -2
- package/dist/calendar/windows-zones.generated.d.ts +1 -0
- package/dist/calendar/windows-zones.generated.js +146 -0
- package/dist/cli/compact.js +2 -0
- package/dist/cli/context.d.ts +2 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/meeting-findings-input.js +4 -3
- package/dist/cli/meeting-preparation-answer.d.ts +3 -0
- package/dist/cli/meeting-preparation-projects.d.ts +3 -0
- package/dist/cli/meeting-preparation-read.d.ts +3 -0
- package/dist/cli/messenger/backfill-command.js +28 -1
- package/dist/cli/messenger/context.js +1 -0
- package/dist/cli/messenger/history-gaps.js +4 -4
- package/dist/cli/messenger/mcp-command.js +1 -0
- package/dist/cli/messenger/messages-delete-command.js +7 -8
- package/dist/cli/messenger/projects-command.js +47 -2
- package/dist/cli/messenger/search-answers.js +8 -1
- package/dist/cli/messenger/serve-catch-up.d.ts +43 -0
- package/dist/cli/messenger/serve-catch-up.js +90 -0
- package/dist/cli/messenger/serve-command.js +54 -1
- package/dist/cli/messenger/server-command.d.ts +4 -3
- package/dist/cli/messenger/server-command.js +30 -12
- package/dist/cli/payment-query.d.ts +8 -0
- package/dist/cli/payment-query.js +27 -0
- package/dist/cli/program.js +9 -4
- package/dist/cli/project-status-rows.d.ts +8 -0
- package/dist/cli/project-status-rows.js +102 -0
- package/dist/cli/project-status.d.ts +132 -0
- package/dist/cli/project-status.js +248 -0
- package/dist/cli/schedule/schedule-command.js +5 -1
- package/dist/cli/steps.js +4 -1
- package/dist/domain/knowledge.d.ts +1 -1
- package/dist/domain/knowledge.js +1 -1
- package/dist/domain/status-words.d.ts +3 -0
- package/dist/domain/status-words.js +44 -0
- package/dist/entities/fact.d.ts +12 -1
- package/dist/entities/fact.js +13 -0
- package/dist/entities/hit.d.ts +5 -0
- package/dist/entities/hit.js +5 -0
- package/dist/entities/index.d.ts +45 -2
- package/dist/entities/listener.d.ts +6 -0
- package/dist/entities/listener.js +1 -0
- package/dist/entities/meeting-findings.d.ts +1 -1
- package/dist/entities/meeting-preparation.d.ts +3 -0
- package/dist/entities/message-transfer.d.ts +16 -0
- package/dist/entities/message-transfer.js +6 -0
- package/dist/errors/reasons.js +1 -0
- package/dist/mcp/app-server.js +1 -0
- package/dist/mcp/http/oauth.d.ts +2 -1
- package/dist/mcp/http/oauth.js +2 -2
- package/dist/mcp/http/serve.d.ts +2 -0
- package/dist/mcp/http/serve.js +2 -1
- package/dist/mcp/meeting-preparation.d.ts +3 -0
- package/dist/mcp/server.js +1 -0
- package/dist/mcp/surface.js +1 -1
- package/dist/meetings/answers.d.ts +5 -0
- package/dist/meetings/answers.js +8 -0
- package/dist/meetings/cli/index.js +14 -0
- package/dist/meetings/findings-rules.d.ts +14 -3
- package/dist/meetings/findings-rules.js +16 -2
- package/dist/meetings/index.d.ts +5 -3
- package/dist/meetings/index.js +3 -1
- package/dist/meetings/ingest.js +2 -1
- package/dist/meetings/language.d.ts +5 -0
- package/dist/meetings/language.js +57 -0
- package/dist/meetings/mcp/search.d.ts +3 -0
- package/dist/meetings/meeting-context.d.ts +2 -3
- package/dist/meetings/meeting-context.js +3 -4
- package/dist/meetings/payment.d.ts +27 -0
- package/dist/meetings/payment.js +81 -0
- package/dist/meetings/project-evidence-answer.d.ts +3 -0
- package/dist/meetings/result-items.d.ts +9 -0
- package/dist/meetings/result-items.js +7 -0
- package/dist/meetings/store.d.ts +22 -0
- package/dist/meetings/testing/memory.js +3 -0
- package/dist/meetings/transcription.d.ts +2 -0
- package/dist/meetings/transcription.js +5 -1
- package/dist/search/lucene/types.d.ts +7 -0
- package/dist/search/lucene/types.js +11 -1
- package/dist/services/archive.d.ts +13 -0
- package/dist/services/archive.js +56 -5
- package/dist/services/deps.d.ts +3 -0
- package/dist/services/history-limits.d.ts +2 -0
- package/dist/services/index.d.ts +4 -1
- package/dist/services/index.js +4 -1
- package/dist/services/job-query.d.ts +5 -2
- package/dist/services/job-query.js +5 -2
- package/dist/services/link-scorers/alias-hits.d.ts +7 -0
- package/dist/services/link-scorers/alias-hits.js +54 -0
- package/dist/services/link-scorers/chat-senders.d.ts +6 -0
- package/dist/services/link-scorers/chat-senders.js +56 -0
- package/dist/services/link-scorers/chat-title.d.ts +6 -0
- package/dist/services/link-scorers/chat-title.js +44 -0
- package/dist/services/link-scorers/email-domain.d.ts +6 -0
- package/dist/services/link-scorers/email-domain.js +64 -0
- package/dist/services/link-scorers/index.d.ts +9 -0
- package/dist/services/link-scorers/index.js +8 -0
- package/dist/services/link-scorers/mail-thread.d.ts +3 -0
- package/dist/services/link-scorers/mail-thread.js +74 -0
- package/dist/services/link-scorers/member-chats.d.ts +3 -0
- package/dist/services/link-scorers/member-chats.js +40 -0
- package/dist/services/link-scorers/member-dialog.d.ts +3 -0
- package/dist/services/link-scorers/member-dialog.js +34 -0
- package/dist/services/link-scorers/scan.d.ts +41 -0
- package/dist/services/link-scorers/scan.js +244 -0
- package/dist/services/link-scorers/shared.d.ts +26 -0
- package/dist/services/link-scorers/shared.js +48 -0
- package/dist/services/link-scorers/types.d.ts +77 -0
- package/dist/services/link-scorers/types.js +1 -0
- package/dist/services/messages-archive-search.js +3 -2
- package/dist/services/messages-combined.js +2 -2
- package/dist/services/messages-discovery.js +2 -2
- package/dist/services/notes-search.js +4 -5
- package/dist/services/project-status/index.d.ts +71 -0
- package/dist/services/project-status/index.js +196 -0
- package/dist/services/project-status/items.d.ts +10 -0
- package/dist/services/project-status/items.js +23 -0
- package/dist/services/project-status/signals/amounts.d.ts +20 -0
- package/dist/services/project-status/signals/amounts.js +91 -0
- package/dist/services/project-status/signals/children.d.ts +13 -0
- package/dist/services/project-status/signals/children.js +28 -0
- package/dist/services/project-status/signals/coverage.d.ts +13 -0
- package/dist/services/project-status/signals/coverage.js +30 -0
- package/dist/services/project-status/signals/index.d.ts +3 -0
- package/dist/services/project-status/signals/index.js +21 -0
- package/dist/services/project-status/signals/last-message.d.ts +4 -0
- package/dist/services/project-status/signals/last-message.js +22 -0
- package/dist/services/project-status/signals/payments.d.ts +9 -0
- package/dist/services/project-status/signals/payments.js +24 -0
- package/dist/services/project-status/signals/promises.d.ts +17 -0
- package/dist/services/project-status/signals/promises.js +109 -0
- package/dist/services/project-status/signals/questions.d.ts +8 -0
- package/dist/services/project-status/signals/questions.js +38 -0
- package/dist/services/project-status/signals/reply-next.d.ts +19 -0
- package/dist/services/project-status/signals/reply-next.js +75 -0
- package/dist/services/project-status/signals/topics.d.ts +10 -0
- package/dist/services/project-status/signals/topics.js +50 -0
- package/dist/services/project-status/types.d.ts +84 -0
- package/dist/services/project-status/types.js +1 -0
- package/dist/services/proposals/index.d.ts +1 -0
- package/dist/services/proposals/index.js +1 -0
- package/dist/services/proposals/kinds/findings.d.ts +3 -0
- package/dist/services/proposals/kinds/findings.js +8 -1
- package/dist/services/search-all-meetings.d.ts +3 -0
- package/dist/services/search-all-meetings.js +6 -1
- package/dist/speech/local.d.ts +2 -0
- package/dist/speech/local.js +8 -1
- package/dist/speech/models.d.ts +2 -0
- package/dist/speech/models.js +2 -0
- package/dist/store/decisions.d.ts +5 -1
- package/dist/store/decisions.js +5 -1
- package/dist/store/driver.d.ts +11 -0
- package/dist/store/driver.js +13 -0
- package/dist/store/drivers/node-sqlite.d.ts +1 -1
- package/dist/store/drivers/node-sqlite.js +3 -0
- package/dist/store/evidence-findings.js +1 -0
- package/dist/store/index.d.ts +2 -1
- package/dist/store/index.js +1 -0
- package/dist/store/project-transfer/request.d.ts +1 -1
- package/dist/store/project-transfer/schema.d.ts +2 -2
- package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
- package/dist/store/sqlite/evidence-findings.js +5 -1
- package/dist/store/sqlite/facts.d.ts +13 -0
- package/dist/store/sqlite/facts.js +64 -6
- package/dist/store/sqlite/findings-core.js +14 -1
- package/dist/store/sqlite/knowledge.d.ts +16 -2
- package/dist/store/sqlite/knowledge.js +1 -0
- package/dist/store/sqlite/lucene.d.ts +1 -1
- package/dist/store/sqlite/lucene.js +33 -7
- package/dist/store/sqlite/meeting-hit-search.js +10 -3
- package/dist/store/sqlite/meeting-languages.d.ts +4 -0
- package/dist/store/sqlite/meeting-languages.js +36 -0
- package/dist/store/sqlite/meetings.d.ts +2 -1
- package/dist/store/sqlite/meetings.js +9 -3
- package/dist/store/sqlite/note-search.js +2 -2
- package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
- package/dist/store/sqlite/relation-proposals.d.ts +29 -2
- package/dist/store/sqlite/relation-proposals.js +75 -4
- package/dist/store/store.d.ts +1 -0
- package/package.json +3 -3
- package/parity.json +11 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { contactRef } from "./shared.js";
|
|
2
|
+
const WEIGHT = 0.3;
|
|
3
|
+
const CONTACTS = 500;
|
|
4
|
+
const MOST_PAIRS = 200;
|
|
5
|
+
const FREE = new Set([
|
|
6
|
+
"gmail.com",
|
|
7
|
+
"outlook.com",
|
|
8
|
+
"hotmail.com",
|
|
9
|
+
"yahoo.com",
|
|
10
|
+
"icloud.com",
|
|
11
|
+
"proton.me",
|
|
12
|
+
"protonmail.com",
|
|
13
|
+
]);
|
|
14
|
+
/**
|
|
15
|
+
* People whose mail addresses share a company domain, related to each other, weakly: a shared domain does not prove
|
|
16
|
+
* they work together. Needs no project; the rule `relationships scan` ran before scorers existed.
|
|
17
|
+
*/
|
|
18
|
+
export const emailDomain = {
|
|
19
|
+
id: "email-domain",
|
|
20
|
+
version: 1,
|
|
21
|
+
candidates: async ({ store, sources, owner, signal }) => {
|
|
22
|
+
const domains = new Map();
|
|
23
|
+
for (const key of sources) {
|
|
24
|
+
if (key.provider !== "email")
|
|
25
|
+
continue;
|
|
26
|
+
signal?.throwIfAborted();
|
|
27
|
+
const contacts = await store.people.contacts(key, { order: "name", limit: CONTACTS, offset: 0 });
|
|
28
|
+
for (const contact of contacts.items) {
|
|
29
|
+
const address = String(contact.id).toLowerCase();
|
|
30
|
+
const domain = address.split("@")[1];
|
|
31
|
+
if (!domain || FREE.has(domain))
|
|
32
|
+
continue;
|
|
33
|
+
const person = await store.people.personOf({ provider: key.provider, id: contact.id });
|
|
34
|
+
if (!person || `person:${person.uid}` === owner)
|
|
35
|
+
continue;
|
|
36
|
+
const people = domains.get(domain) ?? new Map();
|
|
37
|
+
if (!people.has(`person:${person.uid}`))
|
|
38
|
+
people.set(`person:${person.uid}`, contactRef(key.provider, contact.id));
|
|
39
|
+
domains.set(domain, people);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const found = [];
|
|
43
|
+
for (const [domain, people] of domains) {
|
|
44
|
+
const sorted = [...people].sort(([a], [b]) => a.localeCompare(b));
|
|
45
|
+
for (let left = 0; left < sorted.length; left++)
|
|
46
|
+
for (let right = left + 1; right < sorted.length; right++) {
|
|
47
|
+
if (found.length >= MOST_PAIRS)
|
|
48
|
+
return found;
|
|
49
|
+
const [from, identity] = sorted[left];
|
|
50
|
+
const [to] = sorted[right];
|
|
51
|
+
found.push({
|
|
52
|
+
from,
|
|
53
|
+
to,
|
|
54
|
+
kind: "related-to",
|
|
55
|
+
weight: WEIGHT,
|
|
56
|
+
why: `addresses share the domain ${domain}; this does not prove they work together`,
|
|
57
|
+
evidence: [],
|
|
58
|
+
identity,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return found;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { aliasHits } from "./alias-hits.js";
|
|
2
|
+
export { chatSenders } from "./chat-senders.js";
|
|
3
|
+
export { chatTitle } from "./chat-title.js";
|
|
4
|
+
export { emailDomain } from "./email-domain.js";
|
|
5
|
+
export { mailThread } from "./mail-thread.js";
|
|
6
|
+
export { memberChats } from "./member-chats.js";
|
|
7
|
+
export { memberDialog } from "./member-dialog.js";
|
|
8
|
+
export { BUILT_IN_PURPOSES, combinedScore, DEFAULT_LIMIT, DEFAULT_THRESHOLD, PERSON_SCORERS, PERSON_THRESHOLD, SCAN_BOT, SCORERS, type ScanInput, scanProjectLinks, } from "./scan.js";
|
|
9
|
+
export type { LinkCandidate, LinkScorer, ScannedProject, ScannedProposal, ScanResult, ScorerInput, Signal, } from "./types.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { aliasHits } from "./alias-hits.js";
|
|
2
|
+
export { chatSenders } from "./chat-senders.js";
|
|
3
|
+
export { chatTitle } from "./chat-title.js";
|
|
4
|
+
export { emailDomain } from "./email-domain.js";
|
|
5
|
+
export { mailThread } from "./mail-thread.js";
|
|
6
|
+
export { memberChats } from "./member-chats.js";
|
|
7
|
+
export { memberDialog } from "./member-dialog.js";
|
|
8
|
+
export { BUILT_IN_PURPOSES, combinedScore, DEFAULT_LIMIT, DEFAULT_THRESHOLD, PERSON_SCORERS, PERSON_THRESHOLD, SCAN_BOT, SCORERS, scanProjectLinks, } from "./scan.js";
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { formatReference } from "../../domain/references.js";
|
|
2
|
+
import { identitiesIn, namesProject } from "./shared.js";
|
|
3
|
+
const NAMED = 0.7;
|
|
4
|
+
const MEMBER_ONLY = 0.3;
|
|
5
|
+
const READ_LIMIT = 500;
|
|
6
|
+
/** A mail thread a confirmed member writes in or is written to: strong when its subject names the project. */
|
|
7
|
+
export const mailThread = {
|
|
8
|
+
id: "mail-thread",
|
|
9
|
+
version: 1,
|
|
10
|
+
candidates: async ({ store, project, confirmed, sources, since, owner, signal }) => {
|
|
11
|
+
if (!project)
|
|
12
|
+
return [];
|
|
13
|
+
const names = namesProject(project);
|
|
14
|
+
const people = confirmed.people.filter((person) => person !== owner);
|
|
15
|
+
const found = [];
|
|
16
|
+
for (const key of sources) {
|
|
17
|
+
if (key.provider !== "email")
|
|
18
|
+
continue;
|
|
19
|
+
const account = await store.accounts.storedAccount(key).catch(() => undefined);
|
|
20
|
+
if (!account)
|
|
21
|
+
continue;
|
|
22
|
+
const threads = new Map();
|
|
23
|
+
for (const { person, id } of await identitiesIn(store, people, "email")) {
|
|
24
|
+
signal?.throwIfAborted();
|
|
25
|
+
const emails = await store.mail.emails({
|
|
26
|
+
accountId: account.id,
|
|
27
|
+
participant: id,
|
|
28
|
+
since: Date.parse(since),
|
|
29
|
+
limit: READ_LIMIT,
|
|
30
|
+
});
|
|
31
|
+
for (const email of emails) {
|
|
32
|
+
const held = threads.get(email.emailThreadId) ?? {
|
|
33
|
+
members: new Set(),
|
|
34
|
+
subject: email.subject,
|
|
35
|
+
email: email.externalId,
|
|
36
|
+
};
|
|
37
|
+
held.members.add(person);
|
|
38
|
+
threads.set(email.emailThreadId, held);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const [id, { members, subject, email }] of threads) {
|
|
42
|
+
const thread = (await store.mail.thread(id))?.thread;
|
|
43
|
+
if (!thread)
|
|
44
|
+
continue;
|
|
45
|
+
const named = names(thread.subject ?? subject) !== undefined;
|
|
46
|
+
const chat = formatReference({
|
|
47
|
+
type: "chat",
|
|
48
|
+
provider: "email",
|
|
49
|
+
account: String(key.account),
|
|
50
|
+
chat: thread.externalId,
|
|
51
|
+
});
|
|
52
|
+
const who = [...members].sort();
|
|
53
|
+
found.push({
|
|
54
|
+
from: chat,
|
|
55
|
+
to: project.ref,
|
|
56
|
+
kind: "member-of",
|
|
57
|
+
weight: named ? NAMED : MEMBER_ONLY,
|
|
58
|
+
why: `a thread with ${who.length > 1 ? `${who.length} members` : `a member (${who[0]})`}${named ? "; its subject names the project" : ""}`,
|
|
59
|
+
evidence: [
|
|
60
|
+
formatReference({
|
|
61
|
+
type: "message",
|
|
62
|
+
provider: "email",
|
|
63
|
+
account: String(key.account),
|
|
64
|
+
chat: thread.externalId,
|
|
65
|
+
message: email,
|
|
66
|
+
}),
|
|
67
|
+
...who,
|
|
68
|
+
].slice(0, 5),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return found;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { chatRef, identitiesIn } from "./shared.js";
|
|
2
|
+
const WEIGHT = 0.5;
|
|
3
|
+
/** A group or channel where two or more of the project's confirmed person members are members. */
|
|
4
|
+
export const memberChats = {
|
|
5
|
+
id: "member-chats",
|
|
6
|
+
version: 1,
|
|
7
|
+
candidates: async ({ store, project, confirmed, sources, owner, signal }) => {
|
|
8
|
+
if (!project)
|
|
9
|
+
return [];
|
|
10
|
+
const people = confirmed.people.filter((person) => person !== owner);
|
|
11
|
+
const found = [];
|
|
12
|
+
for (const key of sources) {
|
|
13
|
+
const members = new Map();
|
|
14
|
+
for (const { person, id } of await identitiesIn(store, people, key.provider)) {
|
|
15
|
+
signal?.throwIfAborted();
|
|
16
|
+
for (const chat of await store.chats.chatsWith(key, id)) {
|
|
17
|
+
if (chat.kind !== "group" && chat.kind !== "channel")
|
|
18
|
+
continue;
|
|
19
|
+
const held = members.get(String(chat.id)) ?? new Set();
|
|
20
|
+
held.add(person);
|
|
21
|
+
members.set(String(chat.id), held);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const [chat, held] of members) {
|
|
25
|
+
if (held.size < 2)
|
|
26
|
+
continue;
|
|
27
|
+
const named = [...held].sort();
|
|
28
|
+
found.push({
|
|
29
|
+
from: chatRef(key, chat),
|
|
30
|
+
to: project.ref,
|
|
31
|
+
kind: "member-of",
|
|
32
|
+
weight: WEIGHT,
|
|
33
|
+
why: `a group with ${held.size} confirmed members (${named.slice(0, 3).join(", ")}${held.size > 3 ? ", …" : ""})`,
|
|
34
|
+
evidence: named.slice(0, 5),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return found;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { chatRef, identitiesIn, projectsOf } from "./shared.js";
|
|
2
|
+
const WEIGHT = 0.6;
|
|
3
|
+
/** A dialog with a confirmed person member, divided by how many projects that person is a member of. */
|
|
4
|
+
export const memberDialog = {
|
|
5
|
+
id: "member-dialog",
|
|
6
|
+
version: 1,
|
|
7
|
+
candidates: async ({ store, project, confirmed, sources, owner, signal }) => {
|
|
8
|
+
if (!project)
|
|
9
|
+
return [];
|
|
10
|
+
const people = confirmed.people.filter((person) => person !== owner);
|
|
11
|
+
const projects = new Map();
|
|
12
|
+
const found = [];
|
|
13
|
+
for (const key of sources)
|
|
14
|
+
for (const { person, id } of await identitiesIn(store, people, key.provider)) {
|
|
15
|
+
signal?.throwIfAborted();
|
|
16
|
+
for (const chat of await store.chats.chatsWith(key, id)) {
|
|
17
|
+
if (chat.kind !== "dialog")
|
|
18
|
+
continue;
|
|
19
|
+
if (!projects.has(person))
|
|
20
|
+
projects.set(person, Math.max(1, await projectsOf(store, person)));
|
|
21
|
+
const shared = projects.get(person) ?? 1;
|
|
22
|
+
found.push({
|
|
23
|
+
from: chatRef(key, chat.id),
|
|
24
|
+
to: project.ref,
|
|
25
|
+
kind: "member-of",
|
|
26
|
+
weight: WEIGHT / shared,
|
|
27
|
+
why: `a dialog with a member (${person})${shared > 1 ? `, who is in ${shared} projects` : ""}`,
|
|
28
|
+
evidence: [person],
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return found;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { MessageStore } from "../../store/store.js";
|
|
2
|
+
import type { AccountKey } from "../../store/types.js";
|
|
3
|
+
import type { LinkScorer, ScanResult } from "./types.js";
|
|
4
|
+
/** A new scorer is one file and one line here. */
|
|
5
|
+
export declare const SCORERS: readonly LinkScorer[];
|
|
6
|
+
/** What a scan with no project runs: person ↔ person rules. */
|
|
7
|
+
export declare const PERSON_SCORERS: readonly LinkScorer[];
|
|
8
|
+
/** The rule bot every scan proposal is written by: only the owner decides its proposals. */
|
|
9
|
+
export declare const SCAN_BOT: {
|
|
10
|
+
readonly bot: "memo-scan";
|
|
11
|
+
readonly kind: "rule";
|
|
12
|
+
};
|
|
13
|
+
export declare const BUILT_IN_PURPOSES: readonly ["work", "sales", "support", "other"];
|
|
14
|
+
export declare const DEFAULT_THRESHOLD = 0.5;
|
|
15
|
+
/** A person ↔ person scan keeps the old rule's reach: one weak signal is enough to ask the owner. */
|
|
16
|
+
export declare const PERSON_THRESHOLD = 0.3;
|
|
17
|
+
export declare const DEFAULT_LIMIT = 20;
|
|
18
|
+
export interface ScanInput {
|
|
19
|
+
/** A project's key, name, alias or `project:<id>`; `null` runs the person ↔ person rules. */
|
|
20
|
+
project: string | null;
|
|
21
|
+
sources: AccountKey[];
|
|
22
|
+
since: string;
|
|
23
|
+
/** `false` reads only: nothing is written, and each proposal says `dry-run`. */
|
|
24
|
+
write: boolean;
|
|
25
|
+
scorers?: readonly LinkScorer[];
|
|
26
|
+
threshold?: number;
|
|
27
|
+
/** Most new proposals in one run, 1–50. */
|
|
28
|
+
limit?: number;
|
|
29
|
+
/** The host's configured purposes; built-in when left out. */
|
|
30
|
+
purposes?: readonly string[];
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
}
|
|
33
|
+
/** 1 − Π(1 − wᵢ), over each scorer's strongest signal for the pair. */
|
|
34
|
+
export declare const combinedScore: (weights: readonly number[]) => number;
|
|
35
|
+
/**
|
|
36
|
+
* Runs the scorers for one project over the sources named, combines their signals per pair, and proposes what scores
|
|
37
|
+
* `threshold` or more, highest first, at most `limit` new ones. A pair a link joins, or that was rejected once, is
|
|
38
|
+
* never proposed; a pending one is updated in place. People are matched by identity only. With `write: false`
|
|
39
|
+
* nothing is written.
|
|
40
|
+
*/
|
|
41
|
+
export declare const scanProjectLinks: (store: MessageStore, input: ScanInput) => Promise<ScanResult>;
|
|
@@ -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();
|