@wirecat/cli-core 0.558.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/background/schedules.d.ts +2 -0
  3. package/dist/cli/effects.d.ts +13 -2
  4. package/dist/cli/effects.js +23 -2
  5. package/dist/cli/index.d.ts +2 -2
  6. package/dist/cli/index.js +2 -2
  7. package/dist/cli/meeting-findings-input.d.ts +3 -0
  8. package/dist/cli/meeting-findings-input.js +4 -0
  9. package/dist/cli/messenger/conversation-answers.d.ts +3 -0
  10. package/dist/cli/messenger/message-answers.d.ts +3 -0
  11. package/dist/cli/messenger/message-writes.d.ts +6 -0
  12. package/dist/cli/schedule/schedule-command.d.ts +6 -1
  13. package/dist/cli/schedule/schedule-command.js +24 -2
  14. package/dist/domain/records.d.ts +161 -0
  15. package/dist/domain/records.js +234 -0
  16. package/dist/entities/contact-context.d.ts +6 -0
  17. package/dist/entities/conversation-results.d.ts +6 -0
  18. package/dist/entities/evidence.d.ts +24 -0
  19. package/dist/entities/index.d.ts +190 -0
  20. package/dist/entities/index.js +3 -0
  21. package/dist/entities/listener.d.ts +12 -0
  22. package/dist/entities/mail-findings.d.ts +116 -0
  23. package/dist/entities/mail-findings.js +68 -0
  24. package/dist/entities/message-context.d.ts +9 -0
  25. package/dist/entities/message-write.d.ts +12 -0
  26. package/dist/entities/message.d.ts +6 -0
  27. package/dist/entities/message.js +6 -0
  28. package/dist/entities/store-records.d.ts +3 -0
  29. package/dist/errors/reasons.js +11 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +1 -0
  32. package/dist/mail/cli/marks.d.ts +1 -0
  33. package/dist/mail/cli/marks.js +11 -4
  34. package/dist/mail/cli/review.js +10 -2
  35. package/dist/mail/findings.d.ts +55 -0
  36. package/dist/mail/findings.js +140 -0
  37. package/dist/mail/index.d.ts +2 -1
  38. package/dist/mail/index.js +2 -1
  39. package/dist/mail/marks.d.ts +10 -3
  40. package/dist/mail/marks.js +34 -5
  41. package/dist/meetings/findings-rules.d.ts +24 -0
  42. package/dist/meetings/findings-rules.js +136 -116
  43. package/dist/meetings/index.d.ts +2 -1
  44. package/dist/meetings/index.js +2 -1
  45. package/dist/meetings/mcp/index.d.ts +15 -15
  46. package/dist/runs/secrets.d.ts +2 -0
  47. package/dist/runs/secrets.js +2 -0
  48. package/dist/services/index.d.ts +1 -0
  49. package/dist/services/index.js +1 -0
  50. package/dist/services/ingest.d.ts +45 -0
  51. package/dist/services/ingest.js +80 -0
  52. package/dist/services/proposals/kinds/mail-findings.d.ts +8 -0
  53. package/dist/services/proposals/kinds/mail-findings.js +87 -0
  54. package/dist/services/proposals/registry.js +3 -1
  55. package/dist/services/proposals/types.d.ts +2 -0
  56. package/dist/store/decisions.d.ts +1 -1
  57. package/dist/store/decisions.js +1 -1
  58. package/dist/store/index.d.ts +2 -0
  59. package/dist/store/index.js +1 -0
  60. package/dist/store/mail-findings.d.ts +51 -0
  61. package/dist/store/mail-findings.js +1 -0
  62. package/dist/store/path.d.ts +2 -0
  63. package/dist/store/path.js +21 -1
  64. package/dist/store/sqlite/accounts.js +4 -0
  65. package/dist/store/sqlite/email-activity.d.ts +8 -0
  66. package/dist/store/sqlite/email-activity.js +31 -5
  67. package/dist/store/sqlite/findings-core.d.ts +1 -0
  68. package/dist/store/sqlite/findings-core.js +1 -1
  69. package/dist/store/sqlite/mail-findings.d.ts +3 -0
  70. package/dist/store/sqlite/mail-findings.js +333 -0
  71. package/dist/store/sqlite/manifest.js +2 -0
  72. package/dist/store/sqlite/memories.d.ts +4 -1
  73. package/dist/store/sqlite/memories.js +48 -43
  74. package/dist/store/sqlite/migrations.generated.js +16 -0
  75. package/dist/store/sqlite/open-store.d.ts +1 -1
  76. package/dist/store/sqlite/open-store.js +4 -1
  77. package/dist/store/sqlite/open.js +2 -1
  78. package/dist/store/sqlite/proposed-actions.js +2 -1
  79. package/dist/store/sqlite/records.d.ts +81 -0
  80. package/dist/store/sqlite/records.js +372 -0
  81. package/dist/store/sqlite/schema.d.ts +353 -0
  82. package/dist/store/sqlite/schema.js +37 -0
  83. package/dist/store/sqlite/tasks.d.ts +2 -0
  84. package/dist/store/sqlite/tasks.js +26 -24
  85. package/dist/store/sqlite/test-accounts.d.ts +8 -0
  86. package/dist/store/sqlite/test-accounts.js +31 -0
  87. package/dist/store/sqlite/work-tasks.d.ts +1 -0
  88. package/dist/store/sqlite/work-tasks.js +6 -0
  89. package/dist/store/store.d.ts +6 -0
  90. package/dist/store/store.js +10 -0
  91. package/package.json +4 -2
  92. package/schemas/records-envelope.v1.schema.json +108 -0
@@ -0,0 +1,45 @@
1
+ import { type IngestError } from "../domain/records.js";
2
+ import type { MessageStore } from "../store/store.js";
3
+ export interface IngestOptions {
4
+ /** The account the caller means; the envelope's `source.account` must be this one. */
5
+ account: string;
6
+ /** Checks and plans everything, then writes nothing. */
7
+ dryRun?: boolean;
8
+ /** Records per transaction, 1000 by default. */
9
+ chunk?: number;
10
+ }
11
+ export interface IngestReceipt {
12
+ dryRun: boolean;
13
+ source: {
14
+ provider: string;
15
+ account: string;
16
+ batchId: string;
17
+ } | null;
18
+ created: number;
19
+ updated: number;
20
+ unchanged: number;
21
+ deleted: number;
22
+ refused: number;
23
+ /** `invoice:` and `payment:` proposals this import made; the owner confirms them in review. */
24
+ proposed: string[];
25
+ /** Kept, with no entity yet: each named role points at a record not imported, or one with no entity. */
26
+ waiting: {
27
+ key: string;
28
+ pointer: string;
29
+ missing: string[];
30
+ }[];
31
+ /** Changed or deleted at the source after the entity was made from it: the entity was not touched. */
32
+ flagged: {
33
+ key: string;
34
+ pointer: string;
35
+ entity: string;
36
+ }[];
37
+ errors: IngestError[];
38
+ }
39
+ /**
40
+ * Takes a v1 records envelope (or `envelopeOfJsonl` of a JSONL file) into the store: checks it, then writes per
41
+ * chunk in one transaction. The same import twice writes nothing new. A record with a lower revision than the stored
42
+ * one is refused, a delete is soft, a reference to a record not yet imported waits for it, and nothing is linked by
43
+ * name. An invoice or a payment becomes a proposal: an import never writes confirmed money.
44
+ */
45
+ export declare const ingest: (store: Pick<MessageStore, "records">, envelope: unknown, { account, dryRun, chunk }: IngestOptions) => Promise<IngestReceipt>;
@@ -0,0 +1,80 @@
1
+ import { envelopeErrors, pointerOf, recordErrors, } from "../domain/records.js";
2
+ const emptyReceipt = (dryRun) => ({
3
+ dryRun,
4
+ source: null,
5
+ created: 0,
6
+ updated: 0,
7
+ unchanged: 0,
8
+ deleted: 0,
9
+ refused: 0,
10
+ proposed: [],
11
+ waiting: [],
12
+ flagged: [],
13
+ errors: [],
14
+ });
15
+ /**
16
+ * Takes a v1 records envelope (or `envelopeOfJsonl` of a JSONL file) into the store: checks it, then writes per
17
+ * chunk in one transaction. The same import twice writes nothing new. A record with a lower revision than the stored
18
+ * one is refused, a delete is soft, a reference to a record not yet imported waits for it, and nothing is linked by
19
+ * name. An invoice or a payment becomes a proposal: an import never writes confirmed money.
20
+ */
21
+ export const ingest = async (store, envelope, { account, dryRun = false, chunk = 1000 }) => {
22
+ const receipt = emptyReceipt(dryRun);
23
+ const broken = envelopeErrors(envelope, account);
24
+ if (broken.length > 0)
25
+ return { ...receipt, errors: broken };
26
+ const { source, records } = envelope;
27
+ receipt.source = { provider: source.provider, account: source.account, batchId: source.batch_id };
28
+ const taken = [];
29
+ const keys = new Set();
30
+ const identities = new Set();
31
+ for (const [index, record] of records.entries()) {
32
+ const errors = recordErrors(record, index);
33
+ const valid = record;
34
+ if (errors.length === 0 && keys.has(valid.key))
35
+ errors.push({
36
+ pointer: pointerOf("records", index, "key"),
37
+ code: "record.duplicate-key",
38
+ message: "another record of this envelope has this key",
39
+ key: valid.key,
40
+ });
41
+ const identity = errors.length === 0 ? `${valid.type}\0${valid.external_id}` : "";
42
+ if (errors.length === 0 && identities.has(identity))
43
+ errors.push({
44
+ pointer: pointerOf("records", index, "external_id"),
45
+ code: "record.duplicate",
46
+ message: `another record of this envelope is ${valid.type} ${valid.external_id}`,
47
+ key: valid.key,
48
+ });
49
+ if (errors.length > 0) {
50
+ receipt.refused += 1;
51
+ for (const error of errors)
52
+ receipt.errors.push(error);
53
+ continue;
54
+ }
55
+ keys.add(valid.key);
56
+ identities.add(identity);
57
+ taken.push({ index, record: valid });
58
+ }
59
+ const written = await store.records.write(source, taken, { dryRun, chunk });
60
+ for (const outcome of written.outcomes) {
61
+ receipt[outcome.status] += 1;
62
+ if (outcome.error)
63
+ receipt.errors.push(outcome.error);
64
+ }
65
+ for (const error of written.laterErrors)
66
+ receipt.errors.push(error);
67
+ receipt.errors.sort((a, b) => a.pointer.localeCompare(b.pointer, "en", { numeric: true }));
68
+ receipt.proposed = written.proposed;
69
+ receipt.waiting = written.waiting.map(({ index, key, missing }) => ({
70
+ key,
71
+ pointer: pointerOf("records", index, "relations"),
72
+ missing,
73
+ }));
74
+ receipt.flagged = written.flagged.map(({ index, key, entity }) => ({
75
+ key,
76
+ pointer: pointerOf("records", index),
77
+ entity,
78
+ }));
79
+ return receipt;
80
+ };
@@ -0,0 +1,8 @@
1
+ import { type MessageStore } from "../../../store/index.js";
2
+ import type { ProposalKind } from "../types.js";
3
+ /**
4
+ * What an agent read in new mail, one batch per mailbox and run. Only the owner decides it (NEED-2187): an agent's
5
+ * confirmation is refused whatever `agent_decisions` says. Confirming accepts the parts named, or every finding not
6
+ * rejected before; the rest are rejected.
7
+ */
8
+ export declare const mailFindingsKind: (store: Pick<MessageStore, "mailFindings" | "proposedActions">) => ProposalKind;
@@ -0,0 +1,87 @@
1
+ import { MAIL_FINDINGS_KIND } from "../../../store/index.js";
2
+ import { kept, proposerOfAuthor } from "./shared.js";
3
+ /** A finding's one line in the inbox; never the email's text, only what the agent wrote. */
4
+ const summaryOf = (finding) => {
5
+ switch (finding.kind) {
6
+ case "task":
7
+ return finding.title;
8
+ case "fact":
9
+ return finding.statement;
10
+ case "task_update":
11
+ return `${finding.change} ${finding.task}: ${finding.reason}`;
12
+ case "memory":
13
+ return finding.body.slice(0, 200);
14
+ case "link":
15
+ return `${finding.from} → ${finding.to}`;
16
+ case "new_contact":
17
+ return `${finding.name} <${finding.address}>`;
18
+ }
19
+ };
20
+ const rowOf = (batch, createdAt) => {
21
+ const findings = batch.input.findings;
22
+ const outcome = (id) => batch.receipt?.items.find((item) => item.id === id)?.status ?? null;
23
+ const project = findings.flatMap((finding) => ("project" in finding && finding.project ? [finding.project] : []))[0] ?? null;
24
+ return {
25
+ id: batch.id,
26
+ kind: "mail_findings",
27
+ status: batch.status === "proposed" ? "proposed" : batch.receipt?.accepted.length ? "confirmed" : "rejected",
28
+ project,
29
+ subject: `findings:${batch.id}`,
30
+ object: null,
31
+ summary: `${findings.length} mail finding${findings.length === 1 ? "" : "s"} for ${batch.account.account}`,
32
+ proposedBy: proposerOfAuthor(batch.author),
33
+ reason: null,
34
+ evidence: [...new Set(findings.flatMap((finding) => finding.evidence.map((one) => one.reference)))].slice(0, 5),
35
+ score: null,
36
+ signals: null,
37
+ parts: findings.map((finding) => ({
38
+ id: finding.id,
39
+ kind: finding.kind,
40
+ summary: summaryOf(finding),
41
+ outcome: outcome(finding.id),
42
+ })),
43
+ createdAt,
44
+ };
45
+ };
46
+ /**
47
+ * What an agent read in new mail, one batch per mailbox and run. Only the owner decides it (NEED-2187): an agent's
48
+ * confirmation is refused whatever `agent_decisions` says. Confirming accepts the parts named, or every finding not
49
+ * rejected before; the rest are rejected.
50
+ */
51
+ export const mailFindingsKind = (store) => {
52
+ const created = async (batch) => rowOf(batch, (await store.proposedActions.get(batch.id)).createdAt);
53
+ return {
54
+ kind: "mail_findings",
55
+ stored: [MAIL_FINDINGS_KIND],
56
+ parts: true,
57
+ ownerOnly: true,
58
+ list: async (filter) => {
59
+ const status = filter.status ?? "proposed";
60
+ const rows = [];
61
+ for (let offset = 0; rows.length < filter.limit; offset += 50) {
62
+ const page = await store.mailFindings.list({
63
+ ...(status === "proposed" ? { status: "proposed" } : {}),
64
+ ...(status === "confirmed" || status === "rejected" ? { status: "reviewed" } : {}),
65
+ limit: 50,
66
+ offset,
67
+ });
68
+ for (const row of kept(await Promise.all(page.items.map(created)), { ...filter, limit: 50 }))
69
+ rows.push(row);
70
+ if (!page.hasMore)
71
+ break;
72
+ }
73
+ return kept(rows, filter);
74
+ },
75
+ get: async (id) => created(await store.mailFindings.get(id)),
76
+ decide: async (id, { confirm, by, parts }) => {
77
+ const batch = await store.mailFindings.get(id);
78
+ const turnedDown = new Set(batch.previous.filter((one) => one.status === "rejected").map((one) => one.id));
79
+ const accept = !confirm
80
+ ? []
81
+ : (parts ?? batch.input.findings.map((finding) => finding.id).filter((one) => !turnedDown.has(one)));
82
+ const reviewed = await store.mailFindings.review(id, { fingerprint: batch.fingerprint, accept, by });
83
+ const made = (reviewed.receipt?.items ?? []).flatMap((item) => item.status === "accepted" && item.reference && !item.reused ? [item.reference] : []);
84
+ return { id, kind: "mail_findings", status: accept.length ? "confirmed" : "rejected", made };
85
+ },
86
+ };
87
+ };
@@ -5,6 +5,7 @@ import { factKind } from "./kinds/fact.js";
5
5
  import { invoiceKind, paymentKind } from "./kinds/finance.js";
6
6
  import { findingsKind } from "./kinds/findings.js";
7
7
  import { keyDocumentKind } from "./kinds/key-document.js";
8
+ import { mailFindingsKind } from "./kinds/mail-findings.js";
8
9
  import { memoryKind } from "./kinds/memory.js";
9
10
  import { relationshipKind } from "./kinds/relationship.js";
10
11
  import { relationshipUpdateKind } from "./kinds/relationship-update.js";
@@ -98,7 +99,7 @@ export const emptyProposalRegistry = (store) => {
98
99
  });
99
100
  continue;
100
101
  }
101
- const refusal = mayDecide(row, decision.by, policy);
102
+ const refusal = mayDecide(kind.ownerOnly ? { ...row, ownerOnly: true } : row, decision.by, policy);
102
103
  if (refusal) {
103
104
  refused.push(refusal);
104
105
  continue;
@@ -127,6 +128,7 @@ export const proposalRegistry = (store) => {
127
128
  paymentKind(store),
128
129
  memoryKind(store),
129
130
  findingsKind(store),
131
+ mailFindingsKind(store),
130
132
  ])
131
133
  registry.register(kind);
132
134
  registry.register(actionKind(store, registry.claimed));
@@ -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`: `false` keeps every decision with the owner. */
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;
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- export const DEFAULT_DECISION_POLICY = { agentDecisions: true, ruleProposalsOwnerOnly: true };
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.
@@ -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";
@@ -54,6 +55,7 @@ export { type NearestNote, NOTE_FIELDS, type NoteChunkToEmbed, type NoteHit, typ
54
55
  export type { FileNoteInput, Link, LinkInput, LinkListFilter, Note, NoteFolder, NotesStore, NoteTag, } from "./sqlite/notes.js";
55
56
  export type { PrivateContact, PrivateContactNote } from "./sqlite/private-people.js";
56
57
  export { PROPOSAL_STATUSES, type ProposalInput, type ProposedAction, type ProposedActionsStore, proposerOfAction, } from "./sqlite/proposed-actions.js";
58
+ export type { RecordKey, RecordOutcome, RecordStore, RecordsWritten, SourceRecord, } from "./sqlite/records.js";
57
59
  export type { LinkDecision, RelationUpdate } from "./sqlite/relation-proposals.js";
58
60
  export { fillSearchIndex, resetSearchIndex, type SearchIndexFill, type SearchIndexState, searchIndexState, } from "./sqlite/search-index.js";
59
61
  export type { SyncStateSave } from "./sqlite/sync.js";
@@ -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";
@@ -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;
@@ -1,4 +1,5 @@
1
- import { join } from "node:path";
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
+ };
@@ -114,6 +114,10 @@ export const purgeAccount = ({ orm, database }, accountKey) => {
114
114
  // The inbox project and its tasks are the owner's; only the tie to the account goes.
115
115
  orm.update(projects).set({ accountId: null }).where(eq(projects.accountId, accountKey)).run();
116
116
  orm.run(sql `DELETE FROM files WHERE account_id = ${accountKey}`);
117
+ // An entity made from a record is the owner's and stays; only what the source said goes.
118
+ const recordsOf = sql `(SELECT id FROM records WHERE account_id = ${accountKey})`;
119
+ orm.run(sql `DELETE FROM record_relations WHERE record_id IN ${recordsOf} OR target_id IN ${recordsOf}`);
120
+ orm.run(sql `DELETE FROM records WHERE account_id = ${accountKey}`);
117
121
  orm.delete(accounts).where(eq(accounts.id, accountKey)).run();
118
122
  dropOrphanPeople(database, unseenBefore);
119
123
  dropUnusedVectors(database, hashes);
@@ -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
- FROM emails e WHERE e.account_id = ? AND e.external_id = ? AND e.deleted_at IS NULL`;
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
- marks.set(id, { bulk: Boolean(row.bulk), listId: str(row.list_id), ruleId: int(row.mail_rule_id) });
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
  };
@@ -120,5 +120,5 @@ export const findingsCoreOver = (context, kind) => {
120
120
  reused: false,
121
121
  };
122
122
  });
123
- return { snapshot, snapshotParts, previous, outcomes };
123
+ return { snapshot, snapshotParts, previous, outcomes, create };
124
124
  };
@@ -0,0 +1,3 @@
1
+ import { type MailFindingsStore } from "../mail-findings.js";
2
+ import type { StoreContext } from "./open.js";
3
+ export declare const mailFindingsStoreOver: (context: StoreContext) => MailFindingsStore;