@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
package/CHANGELOG.md CHANGED
@@ -7,6 +7,66 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.560.0 — 02.11.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **Agents no longer decide for the owner by default; the owner turns it on.**
15
+ `DEFAULT_DECISION_POLICY.agentDecisions` is now `false`, so a store opened without `decisions` refuses every agent
16
+ confirm or reject with `proposals.agent-decisions-off`; before, an agent with a reason could decide facts, memories,
17
+ links and project findings. A host turns it back on only from the owner's explicit config, `"agent_decisions": true`,
18
+ passed as `openStore({ decisions: { ...DEFAULT_DECISION_POLICY, agentDecisions: true } })`. Owner-only proposals
19
+ (mail and meeting findings, task close suggestions) stay refused either way.
20
+
21
+ ### Added
22
+
23
+ - **A `propose` tier under `<PREFIX>_READ_ONLY`.** `proposesOnly(command)` marks a command that only stages
24
+ something for the owner or moves its caller's own mark; the lock lets it run only when its path also ends with one
25
+ of `PROPOSE_COMMANDS` — `messages acknowledge` and `mail findings add` — a closed list. `isStoreChange` applies it,
26
+ so every host's lock does. `messages acknowledge` moved from `destroysNothing` to this tier.
27
+ - **Mail findings for the owner** (`mail_findings` in `proposed_actions`, no schema change): `store.mailFindings`
28
+ stages one batch per mailbox and run (`key` `<holder>:<through_id>`), of up to 30 findings — `task`,
29
+ `task_update` (close, due, detail), `fact`, `memory`, `link` (address → person, person → organization, thread →
30
+ project) and `new_contact` — each citing 1–8 `msg:email/…` emails by fingerprint, at most five per sender. Changed
31
+ or partial mail refuses the batch. Only the owner decides one, whatever `agent_decisions` says; the registry kind
32
+ `mail_findings` is owner-only too (`ProposalKind.ownerOnly`). `parseMailFindingsJson` reads the file;
33
+ the `mail_findings` entity describes it.
34
+ - **Mail `review` items carry what a pipeline cites and checks:** each email's evidence `fingerprint`, `new_sender`
35
+ (no earlier stored email from the address) and `sender_identities`. `review --holder` also leaves out senders whose
36
+ score is demoted, counted as `suspicious` in the mark.
37
+ - **`schedule agent --on-new-mail <holder>`,** where the host passes `newMail` (`holderHasMail`): the agent starts
38
+ only when mail is stored past the holder's mark, and may check every 5 minutes; a run with nothing new costs no
39
+ model call and records nothing. Mail that is only skipped moves the holder's mark without the agent.
40
+
41
+ ### Fixed
42
+
43
+ - **A test can no longer open the owner's real store.** Under vitest, opening a file in the owner's WireCat folder
44
+ (found from the account's home, not `HOME`) throws, read-only or not. This holds in every CLI's tests that open the
45
+ store through cli-core. cli-core's own suite now refuses vitest's threads pool. There a test env without `HOME`
46
+ reached the real home folder: on 2026-10-18 a `--pool=threads` run wrote test rows into the real store.
47
+ - **The test rows that leaked into a real store are removed (store version 48).** A store that holds an account of
48
+ the test messenger `chat` gets a mark from the migration. The next writable open then removes every such account
49
+ with all it holds, as `cache clear` removes one, so the search and vector indexes stay in step. A store without
50
+ one is unchanged. If the removal fails, the store still opens: a warning names the error and the next open tries
51
+ again.
52
+
53
+ ## 0.559.0 — 02.11.2026
54
+
55
+ ### Added
56
+
57
+ - **Imports through one envelope (store version 47).** `ingest(store, envelope, { account, dryRun })` in
58
+ `./services` takes the v1 records envelope (`schema_version`, `source { provider, account, batch_id }`, `records[]`
59
+ with `key`, `type`, `external_id`, `operation` `upsert` or `delete`, `fields`, `revision`, `relations[]`), or a JSONL
60
+ file through `envelopeOfJsonl`. Its JSON Schema (Draft 2020-12) is `RECORDS_ENVELOPE_SCHEMA` and
61
+ `@wirecat/cli-core/schemas/records-envelope.v1.schema.json`. Each record is kept in the new `records` table, unique
62
+ per account, type and external id: the same import twice writes nothing, a lower revision is refused, a delete is
63
+ soft, and a field that looks like a secret is refused. The receipt counts created, updated, unchanged, deleted and
64
+ refused records, and each error carries a JSON pointer and a stable code.
65
+ - **An import never writes confirmed money.** An `invoice` or `payment` record becomes a proposal by the bot `import`,
66
+ decided in the proposal inbox. A reference to a record not yet imported waits for it, and a person or organization
67
+ record is linked only by the owner (`store.records.map`), never by name. `store.records` lists and reads records,
68
+ waiting and flagged ones too.
69
+
10
70
  ## 0.558.0 — 02.11.2026
11
71
 
12
72
  ### Changed — may break callers
@@ -17,6 +17,8 @@ export interface Schedule {
17
17
  /** The variables the run gets on top of the timer's: the profile, where the files are, PATH. Never a credential. */
18
18
  environment: Record<string, string>;
19
19
  createdAt: string;
20
+ /** A holder whose mark gates the run: it starts only when mail is stored past that mark. */
21
+ onNewMail?: string;
20
22
  }
21
23
  export interface LastRun {
22
24
  at: string;
@@ -39,6 +39,16 @@ export declare const isLogin: (command: Command) => boolean;
39
39
  * (merge/unmerge, claim/release) or keeps the old value in the task's history; under the lock a close only suggests.
40
40
  */
41
41
  export declare const destroysNothing: <T extends Command>(command: T) => T;
42
+ /**
43
+ * The `propose` tier, a closed list: the only commands `<PREFIX>_READ_ONLY` lets stage something for the owner. Each
44
+ * appends a proposal the owner decides or moves its caller's own processing mark, and nothing else. A command must
45
+ * be marked `proposesOnly` **and** end with one of these paths; adding a path widens what a read-only agent can
46
+ * write, so it needs its own review.
47
+ */
48
+ export declare const PROPOSE_COMMANDS: readonly ["messages acknowledge", "mail findings add"];
49
+ /** Marks a command of the `propose` tier; the lock lets it through only when its path is in `PROPOSE_COMMANDS`. */
50
+ export declare const proposesOnly: <T extends Command>(command: T) => T;
51
+ export declare const isProposal: (command: Command) => boolean;
42
52
  /** A local edit remains an edit even when it first fetches metadata from the messenger. */
43
53
  export declare const changesStore: <T extends Command>(command: T) => T;
44
54
  /**
@@ -66,8 +76,9 @@ export declare const readsOverMcp: (command: Command) => boolean;
66
76
  export declare const readsOverMcpAt: (root: Command) => (path: readonly string[]) => boolean;
67
77
  /**
68
78
  * Whether `<PREFIX>_READ_ONLY` refuses the command as a change to the store. Every write that stays on this machine
69
- * is refused unless `destroysNothing` marks it, so a new one fails closed (WC-PERM-12). A fetch (`remote: "read"`)
70
- * keeps receiving, a remote write is `changesMessenger`'s, and an authority change has its own refusal.
79
+ * is refused unless `destroysNothing` marks it or it is of the `propose` tier, so a new one fails closed
80
+ * (WC-PERM-12). A fetch (`remote: "read"`) keeps receiving, a remote write is `changesMessenger`'s, and an authority
81
+ * change has its own refusal.
71
82
  */
72
83
  export declare const isStoreChange: (command: Command) => boolean;
73
84
  export declare const STORE_CHANGE_REFUSAL = "change the message store";
@@ -60,6 +60,25 @@ export const destroysNothing = (command) => {
60
60
  KEPT_UNDER_LOCK.add(command);
61
61
  return command;
62
62
  };
63
+ /**
64
+ * The `propose` tier, a closed list: the only commands `<PREFIX>_READ_ONLY` lets stage something for the owner. Each
65
+ * appends a proposal the owner decides or moves its caller's own processing mark, and nothing else. A command must
66
+ * be marked `proposesOnly` **and** end with one of these paths; adding a path widens what a read-only agent can
67
+ * write, so it needs its own review.
68
+ */
69
+ export const PROPOSE_COMMANDS = ["messages acknowledge", "mail findings add"];
70
+ const PROPOSES = new WeakSet();
71
+ /** Marks a command of the `propose` tier; the lock lets it through only when its path is in `PROPOSE_COMMANDS`. */
72
+ export const proposesOnly = (command) => {
73
+ PROPOSES.add(command);
74
+ return command;
75
+ };
76
+ export const isProposal = (command) => {
77
+ if (!PROPOSES.has(command))
78
+ return false;
79
+ const path = pathOf(command).join(" ");
80
+ return PROPOSE_COMMANDS.some((listed) => path === listed || path.endsWith(` ${listed}`));
81
+ };
63
82
  const STORE_WRITES = new WeakSet();
64
83
  /** A local edit remains an edit even when it first fetches metadata from the messenger. */
65
84
  export const changesStore = (command) => {
@@ -125,14 +144,16 @@ export const readsOverMcpAt = (root) => (path) => {
125
144
  };
126
145
  /**
127
146
  * Whether `<PREFIX>_READ_ONLY` refuses the command as a change to the store. Every write that stays on this machine
128
- * is refused unless `destroysNothing` marks it, so a new one fails closed (WC-PERM-12). A fetch (`remote: "read"`)
129
- * keeps receiving, a remote write is `changesMessenger`'s, and an authority change has its own refusal.
147
+ * is refused unless `destroysNothing` marks it or it is of the `propose` tier, so a new one fails closed
148
+ * (WC-PERM-12). A fetch (`remote: "read"`) keeps receiving, a remote write is `changesMessenger`'s, and an authority
149
+ * change has its own refusal.
130
150
  */
131
151
  export const isStoreChange = (command) => {
132
152
  const effects = metaOf(command).effects;
133
153
  return ((effects?.remote === "none" || STORE_WRITES.has(command)) &&
134
154
  effects?.local === "write" &&
135
155
  !KEPT_UNDER_LOCK.has(command) &&
156
+ !isProposal(command) &&
136
157
  !changesAuthority(command) &&
137
158
  writesFileNow(command));
138
159
  };
@@ -69,7 +69,7 @@ export { sharedCommandTemplate } from "./contract-templates.js";
69
69
  export { type Closeable, withDeadline } from "./deadline.js";
70
70
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
71
71
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
72
- export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, previewFlag, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
72
+ export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isProposal, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, PROPOSE_COMMANDS, previewFlag, proposesOnly, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
73
73
  export { type CliEntry, type EntryContext, type EntryIo, openBrowser, startCli } from "./entry.js";
74
74
  export { type AnswerOutput, answerSchema, type Body, type ItemBody, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, type ListBody, type ListInput, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
75
75
  export { withAbort } from "./execution.js";
@@ -85,7 +85,7 @@ export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists,
85
85
  export { MCP_HTTP_DEFAULT_PORT, type McpServerOptions, type McpServerTarget, mcpServerCommand, } from "./mcp-server-command.js";
86
86
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
87
87
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
88
- export { parseEvidenceFindingsJson, parseMeetingFindingsJson } from "./meeting-findings-input.js";
88
+ export { parseEvidenceFindingsJson, parseMailFindingsJson, parseMeetingFindingsJson, } from "./meeting-findings-input.js";
89
89
  export { meetingPreparationAnswer } from "./meeting-preparation-answer.js";
90
90
  export { type MeetingPreparationReader, meetingPreparationInput, readMeetingPreparation, } from "./meeting-preparation-read.js";
91
91
  export { readMeetingProjectCandidates } from "./meeting-project-candidates-read.js";
package/dist/cli/index.js CHANGED
@@ -61,7 +61,7 @@ export { sharedCommandTemplate } from "./contract-templates.js";
61
61
  export { withDeadline } from "./deadline.js";
62
62
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
63
63
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
64
- export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, previewFlag, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
64
+ export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isProposal, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, PROPOSE_COMMANDS, previewFlag, proposesOnly, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
65
65
  export { openBrowser, startCli } from "./entry.js";
66
66
  export { answerSchema, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
67
67
  export { withAbort } from "./execution.js";
@@ -77,7 +77,7 @@ export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists,
77
77
  export { MCP_HTTP_DEFAULT_PORT, mcpServerCommand, } from "./mcp-server-command.js";
78
78
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
79
79
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
80
- export { parseEvidenceFindingsJson, parseMeetingFindingsJson } from "./meeting-findings-input.js";
80
+ export { parseEvidenceFindingsJson, parseMailFindingsJson, parseMeetingFindingsJson, } from "./meeting-findings-input.js";
81
81
  export { meetingPreparationAnswer } from "./meeting-preparation-answer.js";
82
82
  export { meetingPreparationInput, readMeetingPreparation, } from "./meeting-preparation-read.js";
83
83
  export { readMeetingProjectCandidates } from "./meeting-project-candidates-read.js";
@@ -1,3 +1,6 @@
1
+ import { type MailFindingsInput } from "../mail/findings.js";
1
2
  import { type FindingsInput, type MeetingFindingsInput } from "../meetings/index.js";
2
3
  export declare const parseMeetingFindingsJson: (text: string) => MeetingFindingsInput;
3
4
  export declare const parseEvidenceFindingsJson: (text: string) => FindingsInput;
5
+ /** A mail findings batch as `mail findings add` reads it: snake_case JSON, checked for shape before the store. */
6
+ export declare const parseMailFindingsJson: (text: string) => MailFindingsInput;
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { validateMailFindings } from "../mail/findings.js";
2
3
  import { validateMeetingFindings } from "../meetings/index.js";
3
4
  import { validateEvidenceFindings } from "../store/evidence-findings.js";
4
5
  const keys = {
@@ -6,6 +7,7 @@ const keys = {
6
7
  due_at: "dueAt",
7
8
  due_evidence: "dueEvidence",
8
9
  assignee_evidence: "assigneeEvidence",
10
+ memory_kind: "memoryKind",
9
11
  };
10
12
  const parseFindingsJson = (text, label, validate) => {
11
13
  if (Buffer.byteLength(text, "utf8") > 65536)
@@ -40,3 +42,5 @@ const parseFindingsJson = (text, label, validate) => {
40
42
  };
41
43
  export const parseMeetingFindingsJson = (text) => parseFindingsJson(text, "meeting findings", validateMeetingFindings);
42
44
  export const parseEvidenceFindingsJson = (text) => parseFindingsJson(text, "evidence findings", validateEvidenceFindings);
45
+ /** A mail findings batch as `mail findings add` reads it: snake_case JSON, checked for shape before the store. */
46
+ export const parseMailFindingsJson = (text) => parseFindingsJson(text, "mail findings", validateMailFindings);
@@ -251,6 +251,9 @@ export declare const conversationShown: (account: AccountKey, { summary, message
251
251
  bulk?: boolean | undefined;
252
252
  list_id?: string | null | undefined;
253
253
  rule_id?: number | null | undefined;
254
+ fingerprint?: string | undefined;
255
+ new_sender?: boolean | undefined;
256
+ sender_identities?: number | undefined;
254
257
  } | undefined;
255
258
  provider_metadata?: {
256
259
  [x: string]: unknown;
@@ -228,6 +228,9 @@ export declare const threadAnswer: ({ items, ...thread }: ThreadContext, reader:
228
228
  bulk?: boolean | undefined;
229
229
  list_id?: string | null | undefined;
230
230
  rule_id?: number | null | undefined;
231
+ fingerprint?: string | undefined;
232
+ new_sender?: boolean | undefined;
233
+ sender_identities?: number | undefined;
231
234
  } | undefined;
232
235
  provider_metadata?: {
233
236
  [x: string]: unknown;
@@ -232,6 +232,9 @@ export declare const writtenAnswer: ({ operationId, sendId, message, messages, s
232
232
  bulk?: boolean | undefined;
233
233
  list_id?: string | null | undefined;
234
234
  rule_id?: number | null | undefined;
235
+ fingerprint?: string | undefined;
236
+ new_sender?: boolean | undefined;
237
+ sender_identities?: number | undefined;
235
238
  } | undefined;
236
239
  provider_metadata?: {
237
240
  [x: string]: unknown;
@@ -454,6 +457,9 @@ export declare const writtenAnswer: ({ operationId, sendId, message, messages, s
454
457
  bulk?: boolean | undefined;
455
458
  list_id?: string | null | undefined;
456
459
  rule_id?: number | null | undefined;
460
+ fingerprint?: string | undefined;
461
+ new_sender?: boolean | undefined;
462
+ sender_identities?: number | undefined;
457
463
  } | undefined;
458
464
  provider_metadata?: {
459
465
  [x: string]: unknown;
@@ -9,10 +9,15 @@ export interface ScheduleHost {
9
9
  env: NodeJS.ProcessEnv;
10
10
  profile: string;
11
11
  };
12
+ /**
13
+ * Whether mail is stored past the holder's mark (`holderHasMail`). Given, `schedule agent` takes `--on-new-mail`:
14
+ * the agent then runs only after a sync stored mail it has not processed.
15
+ */
16
+ newMail?: (command: Command, holder: string) => Promise<boolean>;
12
17
  }
13
18
  /**
14
19
  * Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
15
20
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
16
21
  * the rest, so what runs and with which variables is the same on all three.
17
22
  */
18
- export declare const scheduleCommandFor: ({ app, context: contextOf }: ScheduleHost) => Command;
23
+ export declare const scheduleCommandFor: ({ app, context: contextOf, newMail }: ScheduleHost) => Command;
@@ -7,6 +7,7 @@ import { describeWhen, findProgram, lastRun, listSchedules, readSchedule, runSch
7
7
  import { thisMachine } from "../../background/system.js";
8
8
  import { locationVariables } from "../../background/units.js";
9
9
  import { parseDuration } from "../../domain/duration.js";
10
+ import { HOLDER } from "../../mail/marks.js";
10
11
  import { readOnlyLockOf } from "../../ports/app.js";
11
12
  import { aheadOf } from "../../services/moment.js";
12
13
  import { storePath } from "../../store/path.js";
@@ -18,6 +19,8 @@ import { SCHEDULES_QUERY, scheduleFilter } from "../messenger/list-result-querie
18
19
  import { lineOf } from "../steps.js";
19
20
  /** Each run of an agent is a paid model call that reads chats; keeping the store current is `serve`'s job. */
20
21
  const AGENT_MINIMUM_MINUTES = 60;
22
+ /** A run gated on new mail costs a store read when nothing came, so it may check as often as mail syncs. */
23
+ const GATED_AGENT_MINIMUM_MINUTES = 5;
21
24
  const COMMAND_MINIMUM_MINUTES = 1;
22
25
  const HARNESSES = ["claude", "codex"];
23
26
  const whenOf = ({ every, at, atTime }, minimum) => {
@@ -78,7 +81,7 @@ const promptExample = (app) => app.messenger === false
78
81
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
79
82
  * the rest, so what runs and with which variables is the same on all three.
80
83
  */
81
- export const scheduleCommandFor = ({ app, context: contextOf }) => {
84
+ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
82
85
  const command = new Command("schedule").description(`run a ${app.command} command or an agent later or on a timer (systemd, launchd, Task Scheduler); an agent cannot send`);
83
86
  const prepare = (self, resolving = {}) => {
84
87
  const context = contextOf(self, resolving);
@@ -114,6 +117,13 @@ export const scheduleCommandFor = ({ app, context: contextOf }) => {
114
117
  };
115
118
  return { context, system, state, say, timer, environment, save };
116
119
  };
120
+ /** A schedule gated on new mail waits while nothing is stored past its holder's mark. */
121
+ const waits = async (self, file) => {
122
+ const holder = existsSync(file) ? readSchedule(file).onNewMail : undefined;
123
+ if (holder === undefined || !newMail || (await newMail(self, holder)))
124
+ return undefined;
125
+ return holder;
126
+ };
117
127
  withWhen(grantsAuthority(annotate(command.command("command"), {
118
128
  literal_after_dashes: true,
119
129
  effects: LOCAL_WRITE,
@@ -151,6 +161,8 @@ export const scheduleCommandFor = ({ app, context: contextOf }) => {
151
161
  .addOption(new Option("--max-cost <usd>", "claude only: stop a run that costs more than this, in US dollars").argParser(Number))).action(async function (name, harness, prompt) {
152
162
  const { context, system, environment, save } = prepare(this);
153
163
  const options = this.opts();
164
+ if (options.onNewMail !== undefined && !HOLDER.test(options.onNewMail))
165
+ throw new CliError("validation_error", `--on-new-mail takes a holder: lower-case letters, digits and dashes`);
154
166
  const program = findProgram(harness, context.env, system.platform);
155
167
  if (!program)
156
168
  throw new CliError("not_found", `${harness} is not on PATH`);
@@ -188,11 +200,16 @@ export const scheduleCommandFor = ({ app, context: contextOf }) => {
188
200
  name: scheduleName(name),
189
201
  kind: "agent",
190
202
  argv,
191
- when: whenOf(options, AGENT_MINIMUM_MINUTES),
203
+ when: whenOf(options, options.onNewMail ? GATED_AGENT_MINIMUM_MINUTES : AGENT_MINIMUM_MINUTES),
192
204
  environment: environment(),
193
205
  createdAt: new Date().toISOString(),
206
+ ...(options.onNewMail ? { onNewMail: options.onNewMail } : {}),
194
207
  });
195
208
  });
209
+ if (newMail)
210
+ command.commands
211
+ .find((one) => one.name() === "agent")
212
+ ?.option("--on-new-mail <holder>", `run only when mail is stored past this holder's mark — after a sync brought new mail; may check every ${GATED_AGENT_MINIMUM_MINUTES}m`);
196
213
  withQuery(annotate(command.command("list"), {
197
214
  effects: LOCAL_READ,
198
215
  examples: [`${app.command} personal schedule list`, `${app.command} personal schedule list kind:agent`],
@@ -241,6 +258,9 @@ export const scheduleCommandFor = ({ app, context: contextOf }) => {
241
258
  .argument("<name>", "the schedule's name, as `schedule list` shows it")
242
259
  .action(async function (name) {
243
260
  const { context, system, state, say, timer } = prepare(this);
261
+ const holder = await waits(this, scheduleFile(state, scheduleName(name)));
262
+ if (holder !== undefined)
263
+ return say({ name, skipped: true, holder }, [`${name}: skipped — no mail stored past ${holder}'s mark`]);
244
264
  const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, scheduleFile(state, scheduleName(name)), {
245
265
  timer: timer(),
246
266
  ...(signal ? { signal } : {}),
@@ -273,6 +293,8 @@ export const scheduleCommandFor = ({ app, context: contextOf }) => {
273
293
  .action(async function (file) {
274
294
  // A timer starts it with no profile named; the schedule's own environment carries the profile.
275
295
  const { context, system, timer } = prepare(this, { needsProfile: false });
296
+ if ((await waits(this, file)) !== undefined)
297
+ return;
276
298
  const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, file, {
277
299
  timer: timer(),
278
300
  untilUnlocked: true,
@@ -0,0 +1,161 @@
1
+ export declare const RECORDS_SCHEMA_VERSION = 1;
2
+ export declare const RECORD_OPERATIONS: readonly ["upsert", "delete"];
3
+ /** Types with a table of their own; anything else a source sends is `custom:<name>` and stays a record only. */
4
+ export declare const RECORD_ENTITY_TYPES: readonly ["invoice", "payment", "person", "organization", "project"];
5
+ export type RecordOperation = (typeof RECORD_OPERATIONS)[number];
6
+ export type RecordEntityType = (typeof RECORD_ENTITY_TYPES)[number];
7
+ export interface RecordRelation {
8
+ /** What the target is to this record: `counterparty`, `payer`, `settles`, or a source's own word. */
9
+ role: string;
10
+ type: string;
11
+ external_id: string;
12
+ }
13
+ export interface EnvelopeRecord {
14
+ /** The caller's name for this line, unique in the envelope; the receipt cites it. */
15
+ key: string;
16
+ type: string;
17
+ external_id: string;
18
+ operation: RecordOperation;
19
+ fields?: Record<string, unknown>;
20
+ /** Goes up with each change at the source; a lower one than the store holds is refused. */
21
+ revision?: number;
22
+ relations?: RecordRelation[];
23
+ }
24
+ export interface EnvelopeSource {
25
+ provider: string;
26
+ account: string;
27
+ batch_id: string;
28
+ }
29
+ export interface RecordsEnvelope {
30
+ schema_version: 1;
31
+ source: EnvelopeSource;
32
+ records: EnvelopeRecord[];
33
+ }
34
+ /** A refusal: `pointer` is an RFC 6901 JSON pointer into the envelope, `code` a stable word callers may branch on. */
35
+ export interface IngestError {
36
+ pointer: string;
37
+ code: string;
38
+ message: string;
39
+ key?: string;
40
+ }
41
+ /** The v1 envelope as JSON Schema (Draft 2020-12); `schemas/records-envelope.v1.schema.json` is this, written out. */
42
+ export declare const RECORDS_ENVELOPE_SCHEMA: {
43
+ readonly $schema: "https://json-schema.org/draft/2020-12/schema";
44
+ readonly title: "WireCat records envelope, version 1";
45
+ readonly description: string;
46
+ readonly type: "object";
47
+ readonly required: readonly ["schema_version", "source", "records"];
48
+ readonly additionalProperties: false;
49
+ readonly properties: {
50
+ readonly schema_version: {
51
+ readonly const: 1;
52
+ };
53
+ readonly source: {
54
+ readonly $ref: "#/$defs/source";
55
+ };
56
+ readonly records: {
57
+ readonly type: "array";
58
+ readonly items: {
59
+ readonly $ref: "#/$defs/record";
60
+ };
61
+ };
62
+ };
63
+ readonly $defs: {
64
+ readonly source: {
65
+ readonly type: "object";
66
+ readonly required: readonly ["provider", "account", "batch_id"];
67
+ readonly additionalProperties: false;
68
+ readonly properties: {
69
+ readonly provider: {
70
+ readonly type: "string";
71
+ readonly pattern: "^[a-z0-9][a-z0-9_-]{0,62}$";
72
+ readonly description: "The system the records come from";
73
+ };
74
+ readonly account: {
75
+ readonly type: "string";
76
+ readonly minLength: 1;
77
+ readonly maxLength: 512;
78
+ readonly description: "The account in that system";
79
+ };
80
+ readonly batch_id: {
81
+ readonly type: "string";
82
+ readonly minLength: 1;
83
+ readonly maxLength: 512;
84
+ readonly description: "This import, for the receipt";
85
+ };
86
+ };
87
+ };
88
+ readonly record: {
89
+ readonly type: "object";
90
+ readonly required: readonly ["key", "type", "external_id", "operation"];
91
+ readonly additionalProperties: false;
92
+ readonly properties: {
93
+ readonly key: {
94
+ readonly type: "string";
95
+ readonly minLength: 1;
96
+ readonly maxLength: 512;
97
+ };
98
+ readonly type: {
99
+ readonly type: "string";
100
+ readonly pattern: "^(invoice|payment|person|organization|project|custom:[a-z0-9][a-z0-9_-]{0,62})$";
101
+ };
102
+ readonly external_id: {
103
+ readonly type: "string";
104
+ readonly minLength: 1;
105
+ readonly maxLength: 512;
106
+ };
107
+ readonly operation: {
108
+ readonly enum: readonly ["upsert", "delete"];
109
+ };
110
+ readonly fields: {
111
+ readonly type: "object";
112
+ readonly description: "What the source says, as data: never secrets, never instructions";
113
+ };
114
+ readonly revision: {
115
+ readonly type: "integer";
116
+ readonly minimum: 0;
117
+ readonly maximum: number;
118
+ };
119
+ readonly relations: {
120
+ readonly type: "array";
121
+ readonly maxItems: 100;
122
+ readonly items: {
123
+ readonly $ref: "#/$defs/relation";
124
+ };
125
+ };
126
+ };
127
+ };
128
+ readonly relation: {
129
+ readonly type: "object";
130
+ readonly required: readonly ["role", "type", "external_id"];
131
+ readonly additionalProperties: false;
132
+ readonly properties: {
133
+ readonly role: {
134
+ readonly type: "string";
135
+ readonly pattern: "^[a-z0-9][a-z0-9_-]{0,62}$";
136
+ };
137
+ readonly type: {
138
+ readonly type: "string";
139
+ readonly pattern: "^(invoice|payment|person|organization|project|custom:[a-z0-9][a-z0-9_-]{0,62})$";
140
+ };
141
+ readonly external_id: {
142
+ readonly type: "string";
143
+ readonly minLength: 1;
144
+ readonly maxLength: 512;
145
+ };
146
+ };
147
+ };
148
+ };
149
+ };
150
+ export declare const pointerOf: (...segments: (string | number)[]) => string;
151
+ /** Why one record cannot be taken, or nothing; `index` is its place in `records`. */
152
+ export declare const recordErrors: (record: unknown, index: number) => IngestError[];
153
+ /** The envelope's own errors (not its records'): when there is one, no record is taken. */
154
+ export declare const envelopeErrors: (envelope: unknown, account: string) => IngestError[];
155
+ /** The same fields and relations in any key or relation order give the same fingerprint. */
156
+ export declare const recordFingerprint: (fields: Record<string, unknown>, relations: RecordRelation[]) => string;
157
+ /**
158
+ * A JSONL import as the envelope it stands for: the first line holds `schema_version` and `source`, each further
159
+ * non-empty line one record. A line that is not JSON comes back as a record the envelope check refuses, at its index.
160
+ */
161
+ export declare const envelopeOfJsonl: (text: string) => unknown;