@wirecat/cli-core 0.559.0 → 0.560.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 +43 -0
- package/dist/background/schedules.d.ts +2 -0
- package/dist/cli/effects.d.ts +13 -2
- package/dist/cli/effects.js +23 -2
- package/dist/cli/index.d.ts +2 -2
- package/dist/cli/index.js +2 -2
- package/dist/cli/meeting-findings-input.d.ts +3 -0
- package/dist/cli/meeting-findings-input.js +4 -0
- package/dist/cli/messenger/conversation-answers.d.ts +3 -0
- package/dist/cli/messenger/message-answers.d.ts +3 -0
- package/dist/cli/messenger/message-writes.d.ts +6 -0
- package/dist/cli/schedule/schedule-command.d.ts +6 -1
- package/dist/cli/schedule/schedule-command.js +24 -2
- package/dist/entities/contact-context.d.ts +6 -0
- package/dist/entities/conversation-results.d.ts +6 -0
- package/dist/entities/evidence.d.ts +24 -0
- package/dist/entities/index.d.ts +190 -0
- package/dist/entities/index.js +3 -0
- package/dist/entities/listener.d.ts +12 -0
- package/dist/entities/mail-findings.d.ts +116 -0
- package/dist/entities/mail-findings.js +68 -0
- package/dist/entities/message-context.d.ts +9 -0
- package/dist/entities/message-write.d.ts +12 -0
- package/dist/entities/message.d.ts +6 -0
- package/dist/entities/message.js +6 -0
- package/dist/entities/store-records.d.ts +3 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/mail/cli/marks.d.ts +1 -0
- package/dist/mail/cli/marks.js +11 -4
- package/dist/mail/cli/review.js +10 -2
- package/dist/mail/findings.d.ts +55 -0
- package/dist/mail/findings.js +140 -0
- package/dist/mail/index.d.ts +2 -1
- package/dist/mail/index.js +2 -1
- package/dist/mail/marks.d.ts +10 -3
- package/dist/mail/marks.js +34 -5
- package/dist/meetings/findings-rules.d.ts +24 -0
- package/dist/meetings/findings-rules.js +136 -116
- package/dist/meetings/index.d.ts +2 -1
- package/dist/meetings/index.js +2 -1
- package/dist/meetings/mcp/index.d.ts +15 -15
- package/dist/services/proposals/kinds/mail-findings.d.ts +8 -0
- package/dist/services/proposals/kinds/mail-findings.js +87 -0
- package/dist/services/proposals/registry.js +3 -1
- package/dist/services/proposals/types.d.ts +2 -0
- package/dist/store/decisions.d.ts +1 -1
- package/dist/store/decisions.js +1 -1
- package/dist/store/index.d.ts +1 -0
- package/dist/store/index.js +1 -0
- package/dist/store/mail-findings.d.ts +51 -0
- package/dist/store/mail-findings.js +1 -0
- package/dist/store/path.d.ts +2 -0
- package/dist/store/path.js +21 -1
- package/dist/store/sqlite/email-activity.d.ts +8 -0
- package/dist/store/sqlite/email-activity.js +31 -5
- package/dist/store/sqlite/findings-core.d.ts +1 -0
- package/dist/store/sqlite/findings-core.js +1 -1
- package/dist/store/sqlite/mail-findings.d.ts +3 -0
- package/dist/store/sqlite/mail-findings.js +333 -0
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/memories.d.ts +4 -1
- package/dist/store/sqlite/memories.js +48 -43
- package/dist/store/sqlite/migrations.generated.js +6 -0
- package/dist/store/sqlite/open-store.d.ts +1 -1
- package/dist/store/sqlite/open-store.js +4 -1
- package/dist/store/sqlite/open.js +2 -1
- package/dist/store/sqlite/proposed-actions.js +2 -1
- package/dist/store/sqlite/tasks.d.ts +2 -0
- package/dist/store/sqlite/tasks.js +26 -24
- package/dist/store/sqlite/test-accounts.d.ts +8 -0
- package/dist/store/sqlite/test-accounts.js +31 -0
- package/dist/store/sqlite/work-tasks.d.ts +1 -0
- package/dist/store/sqlite/work-tasks.js +6 -0
- package/dist/store/store.d.ts +3 -0
- package/dist/store/store.js +5 -0
- package/package.json +1 -1
|
@@ -64,6 +64,8 @@ export interface ProposalKind {
|
|
|
64
64
|
readonly stored?: readonly string[];
|
|
65
65
|
/** Whether its proposals have parts decided on their own, as a findings batch has findings. */
|
|
66
66
|
readonly parts?: boolean;
|
|
67
|
+
/** Only the owner decides it, whatever the host's `agent_decisions` says. */
|
|
68
|
+
readonly ownerOnly?: boolean;
|
|
67
69
|
/** Bounded: at most `offset + limit` rows, newest first. */
|
|
68
70
|
list(filter: ProposalFilter): Promise<ProposalRow[]>;
|
|
69
71
|
get(id: string): Promise<ProposalRow>;
|
|
@@ -22,7 +22,7 @@ export type Decider = "owner" | {
|
|
|
22
22
|
reason: string;
|
|
23
23
|
};
|
|
24
24
|
export interface DecisionPolicy {
|
|
25
|
-
/** The host's `agent_decisions`: `
|
|
25
|
+
/** The host's `agent_decisions`: off unless the owner sets it to `true`; off keeps every decision with the owner. */
|
|
26
26
|
agentDecisions: boolean;
|
|
27
27
|
/** A rule scorer's proposal is the owner's alone to decide. */
|
|
28
28
|
ruleProposalsOwnerOnly: boolean;
|
package/dist/store/decisions.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
-
export const DEFAULT_DECISION_POLICY = { agentDecisions:
|
|
2
|
+
export const DEFAULT_DECISION_POLICY = { agentDecisions: false, ruleProposalsOwnerOnly: true };
|
|
3
3
|
/**
|
|
4
4
|
* The one check every decide path runs before it confirms or rejects a proposal. `null` means allowed. Every Claude
|
|
5
5
|
* Code session is the bot `claude-code`, so comparing bot names keeps one session from deciding another's proposal.
|
package/dist/store/index.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export type { CacheDatabase, CacheStatement, OpenDatabase, SqlValue } from "./dr
|
|
|
16
16
|
export { PRAGMAS } from "./driver.js";
|
|
17
17
|
export { EVIDENCE_FINDINGS_KIND, type EvidenceFindingsBatch, type EvidenceFindingsDecider, type EvidenceFindingsReceipt, type EvidenceFindingsStore, validateEvidenceFindings, } from "./evidence-findings.js";
|
|
18
18
|
export { ORGANIZATIONS_QUERY, organization, PROJECTS_QUERY, project, REMINDERS_QUERY, reminder, } from "./knowledge-query.js";
|
|
19
|
+
export { MAIL_FINDINGS_KIND, type MailFindingsBatch, type MailFindingsStore } from "./mail-findings.js";
|
|
19
20
|
export type { MeetingFindingsBatch, MeetingFindingsStore } from "./meeting-findings.js";
|
|
20
21
|
export { MIGRATIONS, type Migration, migrate } from "./migrations.js";
|
|
21
22
|
export { openCache } from "./open.js";
|
package/dist/store/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { applyDocumentTransfer, previewDocumentTransfer } from "./document-trans
|
|
|
8
8
|
export { PRAGMAS } from "./driver.js";
|
|
9
9
|
export { EVIDENCE_FINDINGS_KIND, validateEvidenceFindings, } from "./evidence-findings.js";
|
|
10
10
|
export { ORGANIZATIONS_QUERY, organization, PROJECTS_QUERY, project, REMINDERS_QUERY, reminder, } from "./knowledge-query.js";
|
|
11
|
+
export { MAIL_FINDINGS_KIND } from "./mail-findings.js";
|
|
11
12
|
export { MIGRATIONS, migrate } from "./migrations.js";
|
|
12
13
|
export { openCache } from "./open.js";
|
|
13
14
|
export { storePath } from "./path.js";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { MailFindingsInput } from "../mail/findings.js";
|
|
2
|
+
import type { FindingOutcome, FindingsReceipt } from "../meetings/index.js";
|
|
3
|
+
import type { Decider } from "./decisions.js";
|
|
4
|
+
import type { AccountKey } from "./types.js";
|
|
5
|
+
export declare const MAIL_FINDINGS_KIND = "mail_findings";
|
|
6
|
+
export interface MailFindingsBatch {
|
|
7
|
+
id: string;
|
|
8
|
+
account: AccountKey;
|
|
9
|
+
input: MailFindingsInput;
|
|
10
|
+
fingerprint: string;
|
|
11
|
+
status: "proposed" | "reviewed";
|
|
12
|
+
author: {
|
|
13
|
+
type: "person" | "bot";
|
|
14
|
+
id: string;
|
|
15
|
+
name: string | null;
|
|
16
|
+
kind: "agent" | "rule" | null;
|
|
17
|
+
};
|
|
18
|
+
decidedBy: {
|
|
19
|
+
type: "person" | "bot";
|
|
20
|
+
id: string;
|
|
21
|
+
} | null;
|
|
22
|
+
decidedAt: string | null;
|
|
23
|
+
previous: FindingOutcome[];
|
|
24
|
+
receipt: FindingsReceipt | null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* What an agent read in new mail — tasks, task updates, facts, memories, links, new contacts — cited and staged for
|
|
28
|
+
* the owner, one batch per mailbox and run. Only the owner decides one: an agent's mail proposal never becomes the
|
|
29
|
+
* owner's word without him.
|
|
30
|
+
*/
|
|
31
|
+
export interface MailFindingsStore {
|
|
32
|
+
stage(account: AccountKey, input: MailFindingsInput, agent: {
|
|
33
|
+
bot: string;
|
|
34
|
+
model?: string;
|
|
35
|
+
}): Promise<MailFindingsBatch>;
|
|
36
|
+
get(id: string): Promise<MailFindingsBatch>;
|
|
37
|
+
list(options?: {
|
|
38
|
+
account?: AccountKey;
|
|
39
|
+
status?: "proposed" | "reviewed";
|
|
40
|
+
limit?: number;
|
|
41
|
+
offset?: number;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
items: MailFindingsBatch[];
|
|
44
|
+
hasMore: boolean;
|
|
45
|
+
}>;
|
|
46
|
+
review(id: string, input: {
|
|
47
|
+
fingerprint: string;
|
|
48
|
+
accept: string[];
|
|
49
|
+
by: Decider;
|
|
50
|
+
}): Promise<MailFindingsBatch>;
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const MAIL_FINDINGS_KIND = "mail_findings";
|
package/dist/store/path.d.ts
CHANGED
|
@@ -6,3 +6,5 @@ export declare const wirecatDirectory: (env?: NodeJS.ProcessEnv, platform?: Node
|
|
|
6
6
|
* before this one: a build still installed would open that name and run its own migrations over these tables.
|
|
7
7
|
*/
|
|
8
8
|
export declare const storePath: (env?: NodeJS.ProcessEnv) => string;
|
|
9
|
+
/** Throws when a test is about to open a file in the owner's real WireCat folder. */
|
|
10
|
+
export declare const refuseOwnerStoreInTests: (file: string, env?: NodeJS.ProcessEnv, home?: string | undefined) => void;
|
package/dist/store/path.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { userInfo } from "node:os";
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
2
3
|
import { resolvePaths } from "@wirecat/cli-base";
|
|
3
4
|
/** The folder the CLIs share: the store, its backups and the models. `WIRECAT_STATE_DIR` moves all of it. */
|
|
4
5
|
export const wirecatDirectory = (env = process.env, platform) => resolvePaths({ appName: "WireCat", prefix: "WIRECAT", env, ...(platform ? { platform } : {}) }).state;
|
|
@@ -8,3 +9,22 @@ export const wirecatDirectory = (env = process.env, platform) => resolvePaths({
|
|
|
8
9
|
* before this one: a build still installed would open that name and run its own migrations over these tables.
|
|
9
10
|
*/
|
|
10
11
|
export const storePath = (env = process.env) => env.MESSAGING_STORE ?? join(wirecatDirectory(env), "wirecat.db");
|
|
12
|
+
// The account's home from the user database, not HOME: the test sandbox moves HOME, and a worker thread's
|
|
13
|
+
// `os.homedir()` still answers the real one (the 2026-10-18 leak).
|
|
14
|
+
const accountHome = () => {
|
|
15
|
+
try {
|
|
16
|
+
return userInfo().homedir;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
/** Throws when a test is about to open a file in the owner's real WireCat folder. */
|
|
23
|
+
export const refuseOwnerStoreInTests = (file, env = process.env, home = accountHome()) => {
|
|
24
|
+
if (!env.VITEST || !home || file === ":memory:")
|
|
25
|
+
return;
|
|
26
|
+
const inside = relative(wirecatDirectory({ HOME: home, USERPROFILE: home }), resolve(file));
|
|
27
|
+
if (inside.startsWith("..") || isAbsolute(inside))
|
|
28
|
+
return;
|
|
29
|
+
throw new Error(`a test tried to open ${file}, in the owner's real WireCat folder; give it a MESSAGING_STORE or HOME of its own`);
|
|
30
|
+
};
|
|
@@ -35,6 +35,12 @@ export interface EmailMarks {
|
|
|
35
35
|
listId: string | null;
|
|
36
36
|
/** The mail rule that decided how it is stored, by its id in `rules list`. */
|
|
37
37
|
ruleId: number | null;
|
|
38
|
+
/** What a finding citing this email gives as its evidence fingerprint: a hash of its stored subject and text. */
|
|
39
|
+
fingerprint: string;
|
|
40
|
+
/** No earlier stored email, in any account, came from this address. */
|
|
41
|
+
newSender: boolean;
|
|
42
|
+
/** How many identities the sender's person has; 1 is this address alone. 0 when the sender is unknown. */
|
|
43
|
+
senderIdentities: number;
|
|
38
44
|
}
|
|
39
45
|
/** One email as a processing mark sees it, by its store id; never its text. */
|
|
40
46
|
export interface MarkedEmail {
|
|
@@ -52,6 +58,8 @@ export interface MarkedEmail {
|
|
|
52
58
|
/** Stored from its first bytes, not whole yet. */
|
|
53
59
|
partial: boolean;
|
|
54
60
|
draft: boolean;
|
|
61
|
+
/** Its sender's mail looks like spam, a scam or a robot (the sender score). */
|
|
62
|
+
demoted: boolean;
|
|
55
63
|
}
|
|
56
64
|
export interface MailActivityStore {
|
|
57
65
|
/**
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { contextEvidenceFingerprint } from "../../context/evidence.js";
|
|
2
|
+
import { formatLocator } from "../../domain/locator.js";
|
|
1
3
|
import { int, str } from "./events.js";
|
|
2
4
|
/** `tasks agenda` fields over `threadActivity`'s threads: an email thread counts as a dialog. */
|
|
3
5
|
export const MAIL_AGENDA_COLUMNS = { chat_kind: { type: "enum", sql: "'dialog'" } };
|
|
@@ -50,18 +52,39 @@ export const threadEmailIds = (database, accountId, thread, { after, until } = {
|
|
|
50
52
|
ORDER BY coalesce(e.sent_at, e.received_at), e.id`)
|
|
51
53
|
.all(accountId, thread, after ?? null, until ?? null)
|
|
52
54
|
.map(({ id }) => Number(id));
|
|
55
|
+
// The subject and text are read only to hash them as `contextRecordOf` does, so a cited fingerprint matches.
|
|
53
56
|
const MARKS = `
|
|
54
57
|
SELECT e.mail_rule_id, coalesce(json_extract(e.headers, '$."list-id"'), json_extract(e.metadata, '$.listId')) AS list_id,
|
|
55
58
|
EXISTS (SELECT 1 FROM email_mailboxes m JOIN mailboxes b ON b.id = m.mailbox_id
|
|
56
|
-
WHERE m.email_id = e.id AND b.kind = 'bulk') AS bulk
|
|
57
|
-
|
|
59
|
+
WHERE m.email_id = e.id AND b.kind = 'bulk') AS bulk,
|
|
60
|
+
a.provider, a.external_id AS account, t.external_id AS thread, e.subject, coalesce(e.body_text, '') AS body,
|
|
61
|
+
e.from_identity_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM emails f
|
|
62
|
+
WHERE f.from_identity_id = e.from_identity_id AND f.id < e.id AND f.deleted_at IS NULL) AS new_sender,
|
|
63
|
+
(SELECT count(*) FROM identity_links o WHERE o.person_id =
|
|
64
|
+
(SELECT l.person_id FROM identity_links l WHERE l.identity_id = e.from_identity_id)) AS identities
|
|
65
|
+
FROM emails e JOIN accounts a ON a.id = e.account_id JOIN email_threads t ON t.id = e.email_thread_id
|
|
66
|
+
WHERE e.account_id = ? AND e.external_id = ? AND e.deleted_at IS NULL`;
|
|
58
67
|
export const emailMarks = (database, accountId, externalIds) => {
|
|
59
68
|
const one = database.prepare(MARKS);
|
|
60
69
|
const marks = new Map();
|
|
61
70
|
for (const id of externalIds) {
|
|
62
71
|
const row = one.get(accountId, id);
|
|
63
|
-
if (row)
|
|
64
|
-
|
|
72
|
+
if (!row)
|
|
73
|
+
continue;
|
|
74
|
+
const ref = formatLocator({
|
|
75
|
+
provider: String(row.provider),
|
|
76
|
+
account: String(row.account),
|
|
77
|
+
chat: String(row.thread),
|
|
78
|
+
message: id,
|
|
79
|
+
});
|
|
80
|
+
marks.set(id, {
|
|
81
|
+
bulk: Boolean(row.bulk),
|
|
82
|
+
listId: str(row.list_id),
|
|
83
|
+
ruleId: int(row.mail_rule_id),
|
|
84
|
+
fingerprint: contextEvidenceFingerprint({ ref, title: str(row.subject), text: String(row.body), revision: null }),
|
|
85
|
+
newSender: Boolean(row.new_sender),
|
|
86
|
+
senderIdentities: Number(row.identities ?? 0),
|
|
87
|
+
});
|
|
65
88
|
}
|
|
66
89
|
return marks;
|
|
67
90
|
};
|
|
@@ -72,7 +95,9 @@ const MARKED = `
|
|
|
72
95
|
OR EXISTS (SELECT 1 FROM email_mailboxes m JOIN mailboxes b ON b.id = m.mailbox_id
|
|
73
96
|
WHERE m.email_id = e.id AND b.kind = 'bulk') AS bulk,
|
|
74
97
|
EXISTS (SELECT 1 FROM email_mailboxes m JOIN mailboxes b ON b.id = m.mailbox_id
|
|
75
|
-
WHERE m.email_id = e.id AND b.kind = 'envelope') AS envelope
|
|
98
|
+
WHERE m.email_id = e.id AND b.kind = 'envelope') AS envelope,
|
|
99
|
+
EXISTS (SELECT 1 FROM account_identities ai WHERE ai.account_id = e.account_id
|
|
100
|
+
AND ai.identity_id = e.from_identity_id AND ai.mail_demoted = 1) AS demoted
|
|
76
101
|
FROM emails e JOIN email_threads t ON t.id = e.email_thread_id`;
|
|
77
102
|
const markedOf = (row) => ({
|
|
78
103
|
id: Number(row.id),
|
|
@@ -85,6 +110,7 @@ const markedOf = (row) => ({
|
|
|
85
110
|
envelope: Boolean(row.envelope),
|
|
86
111
|
partial: Boolean(row.partial),
|
|
87
112
|
draft: Boolean(row.draft),
|
|
113
|
+
demoted: Boolean(row.demoted),
|
|
88
114
|
});
|
|
89
115
|
export const emailsAfter = (database, accountId, afterId, limit, ids = []) => {
|
|
90
116
|
const next = database
|
|
@@ -16,4 +16,5 @@ export declare const findingsCoreOver: (context: StoreContext, kind: string) =>
|
|
|
16
16
|
snapshotParts: (input: FindingsInput, cite: (evidence: FindingEvidence, finding: Finding, projectId: string | null, seen: Map<string, unknown>) => void) => Record<string, string>;
|
|
17
17
|
previous: (account: number | null, fingerprint: string) => FindingOutcome | undefined;
|
|
18
18
|
outcomes: (account: number | null, input: FindingsInput, accepted: string[], agent: FindingsAgent, decidedBy: Decider) => FindingOutcome[];
|
|
19
|
+
create: (finding: Finding, agent: FindingsAgent, decidedBy: Decider) => string;
|
|
19
20
|
};
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { contextEvidenceFingerprint } from "../../context/evidence.js";
|
|
3
|
+
import { parseLocator } from "../../domain/locator.js";
|
|
4
|
+
import { MAIL_FINDINGS_PER_SENDER, mailFindingFingerprint, validateMailFindings, } from "../../mail/findings.js";
|
|
5
|
+
import { findingHash } from "../../meetings/index.js";
|
|
6
|
+
import { assertMayDecide } from "../decisions.js";
|
|
7
|
+
import { MAIL_FINDINGS_KIND } from "../mail-findings.js";
|
|
8
|
+
import { findingsAgent, findingsCoreOver } from "./findings-core.js";
|
|
9
|
+
import { authorActor, memoriesStoreOver } from "./memories.js";
|
|
10
|
+
import { insertLink, requiredThing } from "./notes.js";
|
|
11
|
+
import { linkIdentityToPerson } from "./person-links.js";
|
|
12
|
+
import { proposalOperationsOver } from "./proposed-actions.js";
|
|
13
|
+
import { addComment } from "./task-comments.js";
|
|
14
|
+
import { inboxProject } from "./tasks.js";
|
|
15
|
+
import { taskRowOf } from "./things.js";
|
|
16
|
+
import { write } from "./transaction.js";
|
|
17
|
+
import { workTaskOperationsOver } from "./work-tasks.js";
|
|
18
|
+
const stale = () => {
|
|
19
|
+
throw new CliError("validation_error", "mail findings cite mail that changed since it was read; stage a new batch", {
|
|
20
|
+
reason: "mail-findings.stale",
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
const invalid = (message, reason) => {
|
|
24
|
+
throw new CliError("validation_error", message, reason ? { reason } : undefined);
|
|
25
|
+
};
|
|
26
|
+
const TEXT_BUDGET = 262144;
|
|
27
|
+
const EMAIL = `SELECT e.id, e.account_id, e.subject, coalesce(e.body_text, '') AS body,
|
|
28
|
+
json_extract(e.metadata, '$.partial') IS NOT NULL AS partial, lower(e.from_address) AS sender,
|
|
29
|
+
coalesce(e.outgoing, lower(e.from_address) = lower(a.external_id)) AS outgoing
|
|
30
|
+
FROM emails e JOIN email_threads t ON t.id = e.email_thread_id JOIN accounts a ON a.id = e.account_id
|
|
31
|
+
WHERE a.provider = 'email' AND a.external_id = ? AND t.external_id = ? AND e.external_id = ? AND e.deleted_at IS NULL`;
|
|
32
|
+
export const mailFindingsStoreOver = (context) => {
|
|
33
|
+
const { database, now } = context;
|
|
34
|
+
const proposals = proposalOperationsOver(context);
|
|
35
|
+
const core = findingsCoreOver(context, MAIL_FINDINGS_KIND);
|
|
36
|
+
const memories = memoriesStoreOver(context);
|
|
37
|
+
const tasks = workTaskOperationsOver(context);
|
|
38
|
+
const mailbox = (key) => {
|
|
39
|
+
if (key?.provider !== "email" || typeof key.account !== "string" || !key.account.trim())
|
|
40
|
+
return invalid("mail findings belong to one email account");
|
|
41
|
+
const row = database
|
|
42
|
+
.prepare("SELECT id FROM accounts WHERE provider = 'email' AND external_id = ?")
|
|
43
|
+
.get(key.account);
|
|
44
|
+
if (!row)
|
|
45
|
+
throw new CliError("not_found", `no mail of ${key.account} is stored`);
|
|
46
|
+
return Number(row.id);
|
|
47
|
+
};
|
|
48
|
+
const load = (id) => {
|
|
49
|
+
if (typeof id !== "string" || !/^[1-9]\d*$/.test(id) || !Number.isSafeInteger(Number(id)))
|
|
50
|
+
return invalid("a findings batch needs a stored ID");
|
|
51
|
+
const row = database
|
|
52
|
+
.prepare("SELECT length(CAST(payload AS BLOB)) AS bytes, length(CAST(result AS BLOB)) AS result_bytes FROM proposed_actions WHERE id = ? AND kind = ?")
|
|
53
|
+
.get(Number(id), MAIL_FINDINGS_KIND);
|
|
54
|
+
if (!row)
|
|
55
|
+
throw new CliError("not_found", "the mail findings batch is unavailable");
|
|
56
|
+
if (Number(row.bytes) > 100000 || Number(row.result_bytes) > 65536)
|
|
57
|
+
return invalid("stored findings exceed the read budget");
|
|
58
|
+
return proposals.get(id);
|
|
59
|
+
};
|
|
60
|
+
const accountOf = (proposal) => mailbox(proposal.account);
|
|
61
|
+
const view = (proposal) => {
|
|
62
|
+
const input = proposal.payload;
|
|
63
|
+
if (!input || !proposal.account || !["proposed", "approved", "rejected"].includes(proposal.status))
|
|
64
|
+
return invalid("invalid stored findings batch");
|
|
65
|
+
return {
|
|
66
|
+
id: proposal.id,
|
|
67
|
+
account: proposal.account,
|
|
68
|
+
input: { schemaVersion: 1, key: input.key, extractor: input.extractor, findings: input.findings },
|
|
69
|
+
fingerprint: findingHash({ input, author: proposal.proposedBy.id }),
|
|
70
|
+
status: proposal.status === "proposed" ? "proposed" : "reviewed",
|
|
71
|
+
author: proposal.proposedBy,
|
|
72
|
+
decidedBy: proposal.decidedBy,
|
|
73
|
+
decidedAt: proposal.decidedAt,
|
|
74
|
+
previous: proposal.result
|
|
75
|
+
? proposal.result.items
|
|
76
|
+
: input.findings.flatMap((finding) => {
|
|
77
|
+
const held = core.previous(accountOf(proposal), mailFindingFingerprint(finding));
|
|
78
|
+
return held ? [{ ...held, id: finding.id, reused: true }] : [];
|
|
79
|
+
}),
|
|
80
|
+
receipt: proposal.result,
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Reads each cited email once: it must be stored, whole, and hash to the fingerprint given. Every finding cites
|
|
85
|
+
* mail of the batch's own mailbox, and no sender other than the owner is behind more than five findings.
|
|
86
|
+
*/
|
|
87
|
+
const cite = (findings, account) => {
|
|
88
|
+
const read = new Map();
|
|
89
|
+
const perSender = new Map();
|
|
90
|
+
let bytes = 0;
|
|
91
|
+
for (const finding of findings) {
|
|
92
|
+
const senders = new Set();
|
|
93
|
+
let own = false;
|
|
94
|
+
for (const { reference, fingerprint } of finding.evidence) {
|
|
95
|
+
let row = read.get(reference);
|
|
96
|
+
if (!row) {
|
|
97
|
+
const at = parseLocator(reference);
|
|
98
|
+
row = database.prepare(EMAIL).get(at.account, at.chat, at.message);
|
|
99
|
+
if (!row)
|
|
100
|
+
return stale();
|
|
101
|
+
bytes += Buffer.byteLength(String(row.body)) + Buffer.byteLength(String(row.subject ?? ""));
|
|
102
|
+
if (bytes > TEXT_BUDGET)
|
|
103
|
+
return invalid(`finding evidence exceeds the ${TEXT_BUDGET}-byte stored text budget`);
|
|
104
|
+
if (row.partial)
|
|
105
|
+
return invalid(`${reference} is not stored whole yet; cite it once it is`, "mail-findings.partial");
|
|
106
|
+
const title = row.subject == null ? null : String(row.subject);
|
|
107
|
+
row.fingerprint = contextEvidenceFingerprint({
|
|
108
|
+
ref: reference,
|
|
109
|
+
title,
|
|
110
|
+
text: String(row.body),
|
|
111
|
+
revision: null,
|
|
112
|
+
});
|
|
113
|
+
read.set(reference, row);
|
|
114
|
+
}
|
|
115
|
+
if (row.fingerprint !== fingerprint)
|
|
116
|
+
return stale();
|
|
117
|
+
if (Number(row.account_id) === account)
|
|
118
|
+
own = true;
|
|
119
|
+
if (!row.outgoing && row.sender != null)
|
|
120
|
+
senders.add(String(row.sender));
|
|
121
|
+
}
|
|
122
|
+
if (!own)
|
|
123
|
+
return invalid(`finding ${finding.id} cites no email of this mailbox`);
|
|
124
|
+
for (const sender of senders) {
|
|
125
|
+
const count = (perSender.get(sender) ?? 0) + 1;
|
|
126
|
+
if (count > MAIL_FINDINGS_PER_SENDER)
|
|
127
|
+
return invalid(`more than ${MAIL_FINDINGS_PER_SENDER} findings from ${sender} in one batch`);
|
|
128
|
+
perSender.set(sender, count);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return read;
|
|
132
|
+
};
|
|
133
|
+
const identityOfAddress = (address) => database
|
|
134
|
+
.prepare("SELECT i.id, l.person_id FROM identities i LEFT JOIN identity_links l ON l.identity_id = i.id " +
|
|
135
|
+
"WHERE i.provider = 'email' AND lower(i.external_id) = ?")
|
|
136
|
+
.get(address.toLowerCase());
|
|
137
|
+
/** Whatever a finding names must be stored: a task to update, a subject, a link's ends, a contact's address. */
|
|
138
|
+
const targets = (finding, read) => {
|
|
139
|
+
const thing = (reference, what) => {
|
|
140
|
+
if (reference !== undefined)
|
|
141
|
+
requiredThing(database, reference, what);
|
|
142
|
+
};
|
|
143
|
+
switch (finding.kind) {
|
|
144
|
+
case "task":
|
|
145
|
+
thing(finding.project, "the project");
|
|
146
|
+
thing(finding.assignee, "the assignee");
|
|
147
|
+
return;
|
|
148
|
+
case "fact":
|
|
149
|
+
case "memory":
|
|
150
|
+
thing(finding.subject, "the subject");
|
|
151
|
+
thing(finding.project, "the project");
|
|
152
|
+
return;
|
|
153
|
+
case "task_update":
|
|
154
|
+
if (taskRowOf(database, finding.task.slice("task:".length)) === undefined)
|
|
155
|
+
throw new CliError("not_found", `no task ${finding.task}`);
|
|
156
|
+
return;
|
|
157
|
+
case "link":
|
|
158
|
+
thing(finding.from, "the link's source");
|
|
159
|
+
thing(finding.to, "the link's target");
|
|
160
|
+
return;
|
|
161
|
+
case "new_contact": {
|
|
162
|
+
const ids = finding.evidence.map(({ reference }) => Number(read.get(reference)?.id));
|
|
163
|
+
const marks = ids.map(() => "?").join(",");
|
|
164
|
+
const named = database
|
|
165
|
+
.prepare(`SELECT 1 FROM emails WHERE id IN (${marks}) AND lower(from_address) = ? UNION ALL ` +
|
|
166
|
+
`SELECT 1 FROM email_recipients WHERE email_id IN (${marks}) AND lower(address) = ? LIMIT 1`)
|
|
167
|
+
.get(...ids, finding.address, ...ids, finding.address);
|
|
168
|
+
if (!named)
|
|
169
|
+
return invalid(`${finding.address} neither sent nor received the mail finding ${finding.id} cites`);
|
|
170
|
+
if (!identityOfAddress(finding.address))
|
|
171
|
+
throw new CliError("not_found", `no stored contact ${finding.address}`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
/** What accepting one finding makes, inside the decision's write; returns the reference of what it made. */
|
|
177
|
+
const create = (finding, account, agent) => {
|
|
178
|
+
const evidence = finding.evidence.map((one) => one.reference);
|
|
179
|
+
switch (finding.kind) {
|
|
180
|
+
case "fact":
|
|
181
|
+
return core.create(finding, agent, "owner");
|
|
182
|
+
case "task": {
|
|
183
|
+
const project = finding.project ?? `project:${inboxProject(context, `email:${account.account}`, true)}`;
|
|
184
|
+
return core.create({ ...finding, project }, agent, "owner");
|
|
185
|
+
}
|
|
186
|
+
case "task_update": {
|
|
187
|
+
if (finding.change === "close")
|
|
188
|
+
tasks.editNow(finding.task, { status: "done", reason: finding.reason }, "owner");
|
|
189
|
+
else if (finding.change === "due")
|
|
190
|
+
tasks.editNow(finding.task, { dueAt: new Date(finding.dueAt) }, "owner");
|
|
191
|
+
else {
|
|
192
|
+
const row = taskRowOf(database, finding.task.slice("task:".length));
|
|
193
|
+
addComment(context, row, `${finding.detail}\n\n${evidence.join("\n")}`, agent);
|
|
194
|
+
}
|
|
195
|
+
return finding.task;
|
|
196
|
+
}
|
|
197
|
+
case "memory": {
|
|
198
|
+
const made = memories.addNow({
|
|
199
|
+
kind: finding.memoryKind,
|
|
200
|
+
body: finding.body,
|
|
201
|
+
scope: finding.scope,
|
|
202
|
+
evidence,
|
|
203
|
+
author: agent,
|
|
204
|
+
...(finding.subject === undefined ? {} : { subject: finding.subject }),
|
|
205
|
+
...(finding.project === undefined ? {} : { project: finding.project }),
|
|
206
|
+
});
|
|
207
|
+
return memories.confirmNow(made.ref, "owner").ref;
|
|
208
|
+
}
|
|
209
|
+
case "link": {
|
|
210
|
+
if (finding.from.startsWith("contact:")) {
|
|
211
|
+
const identity = requiredThing(database, finding.from, "the contact");
|
|
212
|
+
const person = requiredThing(database, finding.to, "the person");
|
|
213
|
+
linkIdentityToPerson(context, identity.id, person.id, { method: "mail-finding", by: "owner" });
|
|
214
|
+
return finding.to;
|
|
215
|
+
}
|
|
216
|
+
const link = insertLink(context, {
|
|
217
|
+
from: finding.from,
|
|
218
|
+
to: finding.to,
|
|
219
|
+
kind: "member-of",
|
|
220
|
+
origin: "owner",
|
|
221
|
+
evidence: evidence[0],
|
|
222
|
+
provenance: `${agent.bot}: ${finding.reason}`,
|
|
223
|
+
});
|
|
224
|
+
if (!link.confirmed)
|
|
225
|
+
database.prepare("UPDATE links SET confirmed = 1, updated_at = ? WHERE id = ?").run(now(), Number(link.id));
|
|
226
|
+
return `link:${link.id}`;
|
|
227
|
+
}
|
|
228
|
+
case "new_contact": {
|
|
229
|
+
const person = Number(identityOfAddress(finding.address)?.person_id);
|
|
230
|
+
database
|
|
231
|
+
.prepare("UPDATE persons SET name = ?, updated_at = ? WHERE id = ? AND name IS NULL")
|
|
232
|
+
.run(finding.name, now(), person);
|
|
233
|
+
return `person:${person}`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
return {
|
|
238
|
+
stage: async (key, raw, rawAgent) => {
|
|
239
|
+
const input = validateMailFindings(raw);
|
|
240
|
+
const by = findingsAgent(rawAgent);
|
|
241
|
+
return write(database, () => {
|
|
242
|
+
const account = mailbox(key);
|
|
243
|
+
const { actor } = authorActor(context, by);
|
|
244
|
+
const inputHash = findingHash({ input, model: by.model ?? null });
|
|
245
|
+
const held = database
|
|
246
|
+
.prepare("SELECT id FROM proposed_actions WHERE account_id = ? AND kind = ? AND proposed_by_type = ? AND proposed_by_id = ? AND json_extract(payload, '$.key') = ? ORDER BY id LIMIT 1")
|
|
247
|
+
.get(account, MAIL_FINDINGS_KIND, actor.type, actor.id, input.key);
|
|
248
|
+
if (held) {
|
|
249
|
+
const proposal = load(String(held.id));
|
|
250
|
+
if (proposal.payload.inputHash !== inputHash)
|
|
251
|
+
return invalid("the findings batch key was already used for different input");
|
|
252
|
+
return view(proposal);
|
|
253
|
+
}
|
|
254
|
+
const read = cite(input.findings, account);
|
|
255
|
+
for (const finding of input.findings)
|
|
256
|
+
targets(finding, read);
|
|
257
|
+
const fingerprints = input.findings.map(mailFindingFingerprint);
|
|
258
|
+
if (new Set(fingerprints).size !== fingerprints.length)
|
|
259
|
+
return invalid("the batch contains duplicate findings");
|
|
260
|
+
return view(proposals.propose({
|
|
261
|
+
kind: MAIL_FINDINGS_KIND,
|
|
262
|
+
by,
|
|
263
|
+
account: { provider: "email", account: key.account },
|
|
264
|
+
payload: { ...input, inputHash, agentModel: by.model ?? null },
|
|
265
|
+
}));
|
|
266
|
+
});
|
|
267
|
+
},
|
|
268
|
+
get: async (id) => view(load(id)),
|
|
269
|
+
list: async ({ account, status, limit = 20, offset = 0 } = {}) => {
|
|
270
|
+
if (status !== undefined && status !== "proposed" && status !== "reviewed")
|
|
271
|
+
return invalid("finding status is proposed or reviewed");
|
|
272
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50 || !Number.isSafeInteger(offset) || offset < 0)
|
|
273
|
+
return invalid("findings lists take 1–50 items and a nonnegative offset");
|
|
274
|
+
const id = account === undefined ? null : mailbox(account);
|
|
275
|
+
const rows = database
|
|
276
|
+
.prepare("SELECT id FROM proposed_actions WHERE kind = ? AND (? IS NULL OR account_id = ?) AND (? IS NULL OR status = ?) " +
|
|
277
|
+
"ORDER BY id DESC LIMIT ? OFFSET ?")
|
|
278
|
+
.all(MAIL_FINDINGS_KIND, id, id, status ?? null, status === "reviewed" ? "approved" : (status ?? null), limit + 1, offset);
|
|
279
|
+
return { items: rows.slice(0, limit).map((row) => view(load(String(row.id)))), hasMore: rows.length > limit };
|
|
280
|
+
},
|
|
281
|
+
review: async (id, decision) => write(database, () => {
|
|
282
|
+
const proposal = load(id);
|
|
283
|
+
const batch = view(proposal);
|
|
284
|
+
if (!decision || typeof decision.fingerprint !== "string" || decision.fingerprint !== batch.fingerprint)
|
|
285
|
+
return stale();
|
|
286
|
+
if (!Array.isArray(decision.accept) ||
|
|
287
|
+
decision.accept.length > batch.input.findings.length ||
|
|
288
|
+
new Set(decision.accept).size !== decision.accept.length ||
|
|
289
|
+
decision.accept.some((candidate) => !batch.input.findings.some((finding) => finding.id === candidate)))
|
|
290
|
+
return invalid("accept only unique IDs shown in this batch");
|
|
291
|
+
if (decision.by === undefined)
|
|
292
|
+
return invalid("a findings review names who decides: the owner");
|
|
293
|
+
assertMayDecide({ id, proposedBy: proposals.proposer(id), ownerOnly: true }, decision.by, context.decisions);
|
|
294
|
+
const accepted = [...decision.accept].sort();
|
|
295
|
+
if (batch.receipt) {
|
|
296
|
+
if (JSON.stringify(batch.receipt.accepted) !== JSON.stringify(accepted))
|
|
297
|
+
return invalid("this findings batch was already reviewed with a different decision");
|
|
298
|
+
return batch;
|
|
299
|
+
}
|
|
300
|
+
const input = proposal.payload;
|
|
301
|
+
const account = accountOf(proposal);
|
|
302
|
+
// Rejecting creates nothing, so a batch whose mail changed can still be closed; accepting needs the
|
|
303
|
+
// accepted findings' own mail unchanged.
|
|
304
|
+
const chosen = batch.input.findings.filter((finding) => accepted.includes(finding.id));
|
|
305
|
+
if (chosen.length)
|
|
306
|
+
cite(chosen, account);
|
|
307
|
+
if (proposal.proposedBy.type !== "bot" || !proposal.proposedBy.name)
|
|
308
|
+
return invalid("a findings author must be an agent");
|
|
309
|
+
const agent = {
|
|
310
|
+
bot: proposal.proposedBy.name,
|
|
311
|
+
...(input.agentModel === null ? {} : { model: input.agentModel }),
|
|
312
|
+
};
|
|
313
|
+
const items = batch.input.findings.map((finding) => {
|
|
314
|
+
const fingerprint = mailFindingFingerprint(finding);
|
|
315
|
+
const prior = core.previous(account, fingerprint);
|
|
316
|
+
if (prior) {
|
|
317
|
+
if (accepted.includes(finding.id) && prior.status === "rejected")
|
|
318
|
+
return invalid("this unchanged finding was already rejected; changed evidence needs a new candidate");
|
|
319
|
+
return { ...prior, id: finding.id, reused: true };
|
|
320
|
+
}
|
|
321
|
+
const status = accepted.includes(finding.id) ? "accepted" : "rejected";
|
|
322
|
+
return {
|
|
323
|
+
id: finding.id,
|
|
324
|
+
fingerprint,
|
|
325
|
+
status,
|
|
326
|
+
reference: status === "accepted" ? create(finding, batch.account, agent) : null,
|
|
327
|
+
reused: false,
|
|
328
|
+
};
|
|
329
|
+
});
|
|
330
|
+
return view(proposals.approve(id, { previewFingerprint: batch.fingerprint, accepted, items }, "owner"));
|
|
331
|
+
}),
|
|
332
|
+
};
|
|
333
|
+
};
|
|
@@ -63,6 +63,7 @@ export const MANIFEST = [
|
|
|
63
63
|
{ name: "20261102173113_version-45-finance-rules", version: 45, minCompatible: 34 },
|
|
64
64
|
{ name: "20261102185954_version-46-payment-facts", version: 46, minCompatible: 34 },
|
|
65
65
|
{ name: "20261102192959_version-47-records", version: 47, minCompatible: 34 },
|
|
66
|
+
{ name: "20261102222340_version-48-test-accounts", version: 48, minCompatible: 34 },
|
|
66
67
|
];
|
|
67
68
|
export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
|
|
68
69
|
const migrations = [];
|
|
@@ -141,5 +141,8 @@ export declare const dropLinksOf: (database: StoreContext["database"], thing: {
|
|
|
141
141
|
* segment still holds the erased words, and the write-ahead log emptied when no reader holds it.
|
|
142
142
|
*/
|
|
143
143
|
export declare const erase: <T>(database: StoreContext["database"], work: () => T) => T;
|
|
144
|
-
export declare const memoriesStoreOver: (context: StoreContext) => MemoriesStore
|
|
144
|
+
export declare const memoriesStoreOver: (context: StoreContext) => MemoriesStore & {
|
|
145
|
+
addNow(input: MemoryInput): Memory;
|
|
146
|
+
confirmNow(reference: string, by: Decider): Memory;
|
|
147
|
+
};
|
|
145
148
|
export {};
|