@wirecat/cli-core 0.567.0 → 0.568.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 CHANGED
@@ -5,6 +5,50 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.568.0 — 05.11.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **`holderHasMail` is gone: `mailWaiting` checks and `advancePastSkipped` moves.** `holderHasMail` read like a
15
+ check but moved the holder's mark on every schedule tick. `mailWaiting(store, holder)` only reads: how many emails
16
+ past the holder's marks would reach it and when the oldest was stored, or `unmarked`. `advancePastSkipped(store,
17
+ holder)` moves the mark over the mail at its head the holder would only skip, and leaves it for the next check when
18
+ a sync holds the store. `ScheduleHost.newMail` now returns `MailWaiting`. A host calls `advancePastSkipped`, then
19
+ `mailWaiting`.
20
+ - **A gated agent schedule waits for a batch of mail.** On its timer, `schedule agent --on-new-mail <holder>` starts
21
+ the agent once 10 emails wait for it or the oldest has waited 30 minutes; `--mail-wait <duration>` and
22
+ `--mail-batch <n>` change that per schedule, and a host's `ScheduleHost.limits` gives its config file's values
23
+ (`SCHEDULE_SETTINGS`, read with `scheduleLimitsOf`): flag, then config file, then default. `schedule run` by hand
24
+ still starts it for any mail. Its skipped answer names `why` instead of `holder`.
25
+ - **An agent schedule stops for the day at a cost cap.** A claude run is asked for its JSON answer, so its cost
26
+ (`total_cost_usd`) is kept in `last.json` (`costUsd`) and summed per local day in `spend.json`; the report kept is
27
+ the answer's text. Once a day's runs cost `--max-cost-per-day` (default 5 US dollars, or the config file's; 0 for
28
+ none), no run starts until the next day; `last.json` says why under `skipped`, and `schedule list` shows the day's
29
+ spend and the skip.
30
+ - **`messages acknowledge` moves only a holder the caller may move.** A run that `schedule agent --on-new-mail` started
31
+ gets `<PREFIX>_MAIL_HOLDER` and moves that holder's mark only; any other agent, or a run under the read-only lock,
32
+ is refused with `mail.holder-not-yours`; the owner moves any. A cursor past the newest stored email, or read in the
33
+ future, is refused with `mail.cursor-ahead`. A scheduled triage agent needs `--on-new-mail`.
34
+ - **`review --holder` leaves out suspicious senders, and now keeps them on the mark.** Their
35
+ emails stay on the holder's mark and come in a later review once the sender's score is cleared, instead of being
36
+ passed for good. This was listed under Added in the release that brought it; it changes what an existing caller
37
+ reads.
38
+ - **A mail findings citation of an email the store does not hold is `not_found`** with reason
39
+ `mail-findings.not-stored`, no longer `mail-findings.stale`, which told the agent to read the mailbox again. An email
40
+ deleted after the batch was staged still makes accepting it stale.
41
+ - **`parseMailFindingsJson`, `parseMeetingFindingsJson` and `parseEvidenceFindingsJson` live in
42
+ `src/cli/findings-input.ts`;** the exports from `@wirecat/cli-core/cli` are unchanged. `findingText` and
43
+ `findingFields` are the checks themselves, not wrappers.
44
+
45
+ ### Fixed
46
+
47
+ - **An accepted `new_contact` mail finding names the person.** Sync already names a sender's person from the From
48
+ display name, so accepting set nothing while the receipt said accepted; the owner's chosen name now replaces it.
49
+ - **`mail review` offers at most two next steps.** It gave one `conversations show` step per cut thread, up to 50;
50
+ it now gives one, and names the other cut threads under `missing`.
51
+
8
52
  ## 0.567.0 — 05.11.2026
9
53
 
10
54
  ### Added
@@ -19,7 +19,27 @@ export interface Schedule {
19
19
  createdAt: string;
20
20
  /** A holder whose mark gates the run: it starts only when mail is stored past that mark. */
21
21
  onNewMail?: string;
22
+ /** Gated on mail: start once the oldest waiting email has waited this many minutes, or `mailBatch` emails wait. */
23
+ mailWaitMinutes?: number;
24
+ mailBatch?: number;
25
+ /** An agent's: no run starts for the rest of the local day once its runs cost this much, in US dollars. */
26
+ maxCostPerDay?: number;
22
27
  }
28
+ /** What a gated agent waits for, and what a day of its runs may cost, when neither its flags nor the config say. */
29
+ export declare const SCHEDULE_DEFAULTS: {
30
+ readonly mailWaitMinutes: 30;
31
+ readonly mailBatch: 10;
32
+ readonly maxCostPerDay: 5;
33
+ };
34
+ /** One local day's cost of a schedule's runs, as the harness reported it (`spend.json` beside the schedule). */
35
+ export interface DaySpend {
36
+ /** The local date, YYYY-MM-DD. */
37
+ day: string;
38
+ usd: number;
39
+ }
40
+ export declare const localDay: (at: Date) => string;
41
+ /** What the schedule's runs cost today; nothing before its first priced run of the day. */
42
+ export declare const spentToday: (file: string, now?: Date) => number;
23
43
  /** Another CLI a scheduled agent may run by name, as the host declares it: `memo`'s agent runs `mail`. */
24
44
  export type AgentCli = Pick<AppIdentity, "command" | "appName" | "envPrefix">;
25
45
  export interface LastRun {
@@ -37,6 +57,13 @@ export interface LastRun {
37
57
  failingSince?: string;
38
58
  /** Failed runs in a row; absent once a run succeeds. */
39
59
  failures?: number;
60
+ /** What the run cost, as the harness reported it; absent when it reports none. */
61
+ costUsd?: number;
62
+ /** The last time the timer did not start it, and why; a run after it clears this. */
63
+ skipped?: {
64
+ at: string;
65
+ why: string;
66
+ };
40
67
  }
41
68
  /**
42
69
  * Why a failed run failed, from the error object the CLI printed last: its code and reason id. Never the
@@ -60,7 +87,10 @@ export declare const recordRun: (path: string, run: {
60
87
  traceId?: string;
61
88
  reason?: string;
62
89
  errorCode?: string;
90
+ costUsd?: number;
63
91
  }) => LastRun;
92
+ /** Notes on the last run that the timer left this one out, keeping that run's result. */
93
+ export declare const recordSkip: (file: string, why: string, at?: Date) => void;
64
94
  /**
65
95
  * One stderr line per failing background job, or nothing: `tg: scheduled digest failing since … (3 runs):
66
96
  * <reason> — tg schedule list`. A status the owner has to ask for is how a broken timer goes unnoticed.
@@ -1,10 +1,54 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { basename, join } from "node:path";
4
4
  import { CliError, errorCodes, writeSecurely } from "@wirecat/cli-base";
5
- import { readOnlyLockOf } from "../ports/app.js";
5
+ import { mailHolderVariable, readOnlyLockOf } from "../ports/app.js";
6
6
  import { recordLogFor } from "../runs/record-log.js";
7
7
  import { childTrace } from "../runs/scope.js";
8
+ /** What a gated agent waits for, and what a day of its runs may cost, when neither its flags nor the config say. */
9
+ export const SCHEDULE_DEFAULTS = { mailWaitMinutes: 30, mailBatch: 10, maxCostPerDay: 5 };
10
+ export const localDay = (at) => `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, "0")}-${String(at.getDate()).padStart(2, "0")}`;
11
+ const spendFile = (file) => join(file, "..", "spend.json");
12
+ /** What the schedule's runs cost today; nothing before its first priced run of the day. */
13
+ export const spentToday = (file, now = new Date()) => {
14
+ try {
15
+ const spend = JSON.parse(readFileSync(spendFile(file), "utf8"));
16
+ return spend.day === localDay(now) && typeof spend.usd === "number" ? spend.usd : 0;
17
+ }
18
+ catch {
19
+ return 0;
20
+ }
21
+ };
22
+ const addSpend = (folder, usd, at) => {
23
+ const file = join(folder, "schedule.json");
24
+ const spend = { day: localDay(at), usd: spentToday(file, at) + usd };
25
+ writeFileSync(spendFile(file), `${JSON.stringify(spend)}\n`, { mode: 0o600 });
26
+ };
27
+ /**
28
+ * Claude reports a run's cost only in its JSON answer, so a scheduled claude run asks for it; the report kept is the
29
+ * answer's text. Codex reports none.
30
+ */
31
+ const priced = (argv) => {
32
+ const [program = ""] = argv;
33
+ const claude = /^claude(\.exe|\.cmd)?$/i.test(basename(program));
34
+ return claude && argv.includes("-p") && !argv.includes("--output-format")
35
+ ? [...argv, "--output-format", "json"]
36
+ : argv;
37
+ };
38
+ const reportOf = (stdout) => {
39
+ try {
40
+ const answer = JSON.parse(stdout.toString("utf8"));
41
+ if (answer?.type !== "result")
42
+ return { text: stdout };
43
+ return {
44
+ text: typeof answer.result === "string" ? Buffer.from(answer.result) : stdout,
45
+ ...(typeof answer.total_cost_usd === "number" ? { costUsd: answer.total_cost_usd } : {}),
46
+ };
47
+ }
48
+ catch {
49
+ return { text: stdout };
50
+ }
51
+ };
8
52
  const REASON_ID = /^[a-z0-9][a-z0-9.-]{0,79}$/;
9
53
  /**
10
54
  * Why a failed run failed, from the error object the CLI printed last: its code and reason id. Never the
@@ -52,6 +96,14 @@ export const recordRun = (path, run) => {
52
96
  writeSecurely(path, `${JSON.stringify(last, null, 2)}\n`, 0o600);
53
97
  return last;
54
98
  };
99
+ /** Notes on the last run that the timer left this one out, keeping that run's result. */
100
+ export const recordSkip = (file, why, at = new Date()) => {
101
+ const path = join(file, "..", "last.json");
102
+ const before = readRun(path);
103
+ if (!before)
104
+ return;
105
+ writeSecurely(path, `${JSON.stringify({ ...before, skipped: { at: at.toISOString(), why } }, null, 2)}\n`, 0o600);
106
+ };
55
107
  const shortTime = (iso) => {
56
108
  const at = new Date(iso);
57
109
  const two = (n) => String(n).padStart(2, "0");
@@ -408,13 +460,14 @@ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal
408
460
  const agent = schedule.kind === "agent";
409
461
  const output = join(runs, `${stamp}.${agent ? "md" : "log"}`);
410
462
  const errors = agent ? join(runs, `${stamp}.log`) : output;
411
- const [program = "", ...args] = agent ? schedule.argv : [...system.entry, ...schedule.argv];
463
+ const [program = "", ...args] = agent ? priced(schedule.argv) : [...system.entry, ...schedule.argv];
412
464
  const trace = childTrace();
413
465
  const childEnv = {
414
466
  ...env,
415
467
  ...schedule.environment,
416
468
  TRACEPARENT: trace.traceparent,
417
469
  [`${app.envPrefix}_SCHEDULED`]: schedule.name,
470
+ ...(schedule.onNewMail ? { [mailHolderVariable(app)]: schedule.onNewMail } : {}),
418
471
  // A run started under the lock stays under it, whatever the schedule file says.
419
472
  ...(agent || readOnlyLockOf(app, env) ? { [`${app.envPrefix}_READ_ONLY`]: "1" } : {}),
420
473
  ...(agent
@@ -430,6 +483,7 @@ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal
430
483
  const facts = { schedule: schedule.name, kind: schedule.kind };
431
484
  const began = now().getTime();
432
485
  log.info({ event: "schedule.start", ...facts });
486
+ let costUsd;
433
487
  const exitCode = await new Promise((resolve) => {
434
488
  const child = spawn(program, args, { env: childEnv, cwd: folder, stdio: ["ignore", "pipe", "pipe"] });
435
489
  const out = [];
@@ -454,24 +508,30 @@ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal
454
508
  signal?.removeEventListener("abort", stop);
455
509
  if (kill)
456
510
  clearTimeout(kill);
457
- writeFileSync(output, Buffer.concat(agent ? out : [...out, ...err]), { mode: 0o600 });
511
+ const report = agent ? reportOf(Buffer.concat(out)) : { text: Buffer.concat([...out, ...err]) };
512
+ costUsd = report.costUsd;
513
+ writeFileSync(output, report.text, { mode: 0o600 });
458
514
  if (agent)
459
515
  writeFileSync(errors, Buffer.concat(err), { mode: 0o600 });
460
516
  resolve(code ?? (killedBy === "SIGKILL" ? 137 : killedBy === "SIGTERM" ? 143 : 1));
461
517
  });
462
518
  });
463
519
  const ended = now();
520
+ if (costUsd !== undefined)
521
+ addSpend(folder, costUsd, ended);
464
522
  log[exitCode === 0 ? "info" : "error"]({
465
523
  event: "schedule.exit",
466
524
  ...facts,
467
525
  exitCode,
468
526
  durationMs: ended.getTime() - began,
527
+ ...(costUsd === undefined ? {} : { costUsd }),
469
528
  });
470
529
  return recordRun(join(folder, "last.json"), {
471
530
  at: ended.toISOString(),
472
531
  exitCode,
473
532
  output,
474
533
  traceId: trace.traceId,
534
+ ...(costUsd === undefined ? {} : { costUsd }),
475
535
  ...(exitCode === 0 ? {} : failureOf(readFileSync(errors, "utf8"), exitCode)),
476
536
  });
477
537
  };
@@ -75,6 +75,7 @@ export { admittedUnderLock, changesAuthority, changesAuthorityAt, changesAuthori
75
75
  export { type CliEntry, type EntryContext, type EntryIo, openBrowser, startCli } from "./entry.js";
76
76
  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";
77
77
  export { withAbort } from "./execution.js";
78
+ export { parseEvidenceFindingsJson, parseMailFindingsJson, parseMeetingFindingsJson, } from "./findings-input.js";
78
79
  export { askSetupQuestions, asksAtStart, type FirstRun, type FirstRunIo, setupAtStart, systemLanguage, } from "./first-run.js";
79
80
  export { checkGuide, checkStep, type GuideOptions, type GuideReference, type GuideStep, loadScenarios, MEMO_SCENARIOS, type Scenario, shellProblems, } from "./guide.js";
80
81
  export { type CommandHelp, commandHelp, commandHelpMarkdown, commandHelpText, exampleReport, type NextChoice, nextUsually, } from "./help/command-help.js";
@@ -87,7 +88,6 @@ export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists,
87
88
  export { MCP_HTTP_DEFAULT_PORT, type McpServerOptions, type McpServerTarget, mcpServerCommand, } from "./mcp-server-command.js";
88
89
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
89
90
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
90
- export { parseEvidenceFindingsJson, parseMailFindingsJson, parseMeetingFindingsJson, } from "./meeting-findings-input.js";
91
91
  export { meetingPreparationAnswer } from "./meeting-preparation-answer.js";
92
92
  export { type MeetingPreparationReader, meetingPreparationInput, readMeetingPreparation, } from "./meeting-preparation-read.js";
93
93
  export { readMeetingProjectCandidates } from "./meeting-project-candidates-read.js";
@@ -120,7 +120,7 @@ export { type ProjectTextHints, projectAnswerParts, projectCutoffTime, projectEv
120
120
  export { type ProjectStatusHints, type ProjectStatusItem, projectStatusAnswer, projectStatusRow, projectStatusSchema, projectStatusText, } from "./project-status.js";
121
121
  export { STATUS_ROWS, STATUS_SIGNAL_ROWS } from "./project-status-rows.js";
122
122
  export { fieldsOf, projectFields } from "./result-fields.js";
123
- export type { ScheduleHost } from "./schedule/schedule-command.js";
123
+ export { SCHEDULE_SETTINGS, type ScheduleHost, type ScheduleLimits, scheduleLimitsOf, } from "./schedule/schedule-command.js";
124
124
  export { type DeclaredSetting, type SettingRow, settingRows, withSettingDefaults } from "./setting-defaults.js";
125
125
  export { type Config, type Configuration, count, first, flag, fromFile, type GlobalFlags, NONE_NAMED, namedProfile, type ProfileKind, plain, type ResolveOptions, type Settings, type SettingsExtension, type SettingsExtensionContext, type Source, settingsFor, } from "./settings.js";
126
126
  export { type PermissionAnswer, readSetupAnswers, type SetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
package/dist/cli/index.js CHANGED
@@ -66,6 +66,7 @@ export { admittedUnderLock, changesAuthority, changesAuthorityAt, changesAuthori
66
66
  export { openBrowser, startCli } from "./entry.js";
67
67
  export { answerSchema, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
68
68
  export { withAbort } from "./execution.js";
69
+ export { parseEvidenceFindingsJson, parseMailFindingsJson, parseMeetingFindingsJson, } from "./findings-input.js";
69
70
  export { askSetupQuestions, asksAtStart, setupAtStart, systemLanguage, } from "./first-run.js";
70
71
  export { checkGuide, checkStep, loadScenarios, MEMO_SCENARIOS, shellProblems, } from "./guide.js";
71
72
  export { commandHelp, commandHelpMarkdown, commandHelpText, exampleReport, nextUsually, } from "./help/command-help.js";
@@ -78,7 +79,6 @@ export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists,
78
79
  export { MCP_HTTP_DEFAULT_PORT, mcpServerCommand, } from "./mcp-server-command.js";
79
80
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
80
81
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
81
- export { parseEvidenceFindingsJson, parseMailFindingsJson, parseMeetingFindingsJson, } from "./meeting-findings-input.js";
82
82
  export { meetingPreparationAnswer } from "./meeting-preparation-answer.js";
83
83
  export { meetingPreparationInput, readMeetingPreparation, } from "./meeting-preparation-read.js";
84
84
  export { readMeetingProjectCandidates } from "./meeting-project-candidates-read.js";
@@ -109,6 +109,7 @@ export { projectAnswerParts, projectCutoffTime, projectEvidenceText, projectWind
109
109
  export { projectStatusAnswer, projectStatusRow, projectStatusSchema, projectStatusText, } from "./project-status.js";
110
110
  export { STATUS_ROWS, STATUS_SIGNAL_ROWS } from "./project-status-rows.js";
111
111
  export { fieldsOf, projectFields } from "./result-fields.js";
112
+ export { SCHEDULE_SETTINGS, scheduleLimitsOf, } from "./schedule/schedule-command.js";
112
113
  export { settingRows, withSettingDefaults } from "./setting-defaults.js";
113
114
  export { count, first, flag, fromFile, NONE_NAMED, namedProfile, plain, settingsFor, } from "./settings.js";
114
115
  export { readSetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
@@ -1,7 +1,9 @@
1
1
  import { Command } from "commander";
2
- import { type AgentCli } from "../../background/schedules.js";
2
+ import { type AgentCli, type Schedule } from "../../background/schedules.js";
3
+ import { type MailWaiting } from "../../mail/marks.js";
3
4
  import { type AppIdentity } from "../../ports/app.js";
4
5
  import { type AnswerOutput } from "../envelope.js";
6
+ import type { DeclaredSetting } from "../setting-defaults.js";
5
7
  export interface ScheduleHost {
6
8
  app: AppIdentity;
7
9
  context(command: Command, resolving?: {
@@ -11,19 +13,30 @@ export interface ScheduleHost {
11
13
  profile: string;
12
14
  };
13
15
  /**
14
- * Whether mail is stored past the holder's mark (`holderHasMail`). Given, `schedule agent` takes `--on-new-mail`:
15
- * the agent then runs only after a sync stored mail it has not processed.
16
+ * What mail waits past the holder's mark (`mailWaiting`). Given, `schedule agent` takes `--on-new-mail`: the agent
17
+ * then runs only once enough of it waits, or the oldest has waited long enough.
16
18
  */
17
- newMail?: (command: Command, holder: string) => Promise<boolean>;
19
+ newMail?: (command: Command, holder: string) => Promise<MailWaiting>;
20
+ /** The config file's limits for agent schedules; a schedule's own flags win, `SCHEDULE_DEFAULTS` fill the rest. */
21
+ limits?: (command: Command) => ScheduleLimits;
18
22
  /**
19
23
  * Other CLIs the scheduled agent may run besides this one, each under its own `<PREFIX>_READ_ONLY`: memo's mail
20
24
  * triage runs `mail`. Claude may run them; codex may write their state, config and cache folders.
21
25
  */
22
26
  agentRuns?: readonly AgentCli[];
23
27
  }
28
+ export type ScheduleLimits = Partial<Pick<Schedule, "mailWaitMinutes" | "mailBatch" | "maxCostPerDay">>;
29
+ /** The agent-schedule limits a host with `newMail` writes into its config file's top level (WC-CMD-30). */
30
+ export declare const SCHEDULE_SETTINGS: readonly DeclaredSetting[];
31
+ /** `SCHEDULE_SETTINGS` as the config file gives them; a missing or unusable value is left to the default. */
32
+ export declare const scheduleLimitsOf: (config: {
33
+ schedule_mail_wait?: unknown;
34
+ schedule_mail_batch?: unknown;
35
+ schedule_max_cost_per_day?: unknown;
36
+ }) => ScheduleLimits;
24
37
  /**
25
38
  * Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
26
39
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
27
40
  * the rest, so what runs and with which variables is the same on all three.
28
41
  */
29
- export declare const scheduleCommandFor: ({ app, context: contextOf, newMail, agentRuns }: ScheduleHost) => Command;
42
+ export declare const scheduleCommandFor: ({ app, context: contextOf, newMail, limits: configured, agentRuns, }: ScheduleHost) => Command;
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { CliError, resolvePaths, writeSecurely } from "@wirecat/cli-base";
4
4
  import { annotate } from "@wirecat/cli-base/commands";
5
5
  import { Argument, Command, Option } from "commander";
6
- import { describeWhen, findProgram, lastRun, listSchedules, readSchedule, runSchedule, scheduleFile, scheduleName, timerFor, } from "../../background/schedules.js";
6
+ import { describeWhen, findProgram, lastRun, listSchedules, readSchedule, recordSkip, runSchedule, SCHEDULE_DEFAULTS, scheduleFile, scheduleName, spentToday, timerFor, } from "../../background/schedules.js";
7
7
  import { thisMachine } from "../../background/system.js";
8
8
  import { locationVariables } from "../../background/units.js";
9
9
  import { parseDuration } from "../../domain/duration.js";
@@ -76,12 +76,43 @@ const withWhen = (command) => command
76
76
  const promptExample = (app) => app.messenger === false
77
77
  ? `summarize what came in during the last day: ${app.command} personal search messages 'date>1d' --newest --limit 20`
78
78
  : `run the daily follow-up review: ${app.command} skill show follow-ups`;
79
+ /** The agent-schedule limits a host with `newMail` writes into its config file's top level (WC-CMD-30). */
80
+ export const SCHEDULE_SETTINGS = [
81
+ {
82
+ key: "schedule_mail_wait",
83
+ default: `${SCHEDULE_DEFAULTS.mailWaitMinutes}m`,
84
+ description: "an agent gated on new mail starts once the oldest waiting email has waited this long",
85
+ },
86
+ {
87
+ key: "schedule_mail_batch",
88
+ default: SCHEDULE_DEFAULTS.mailBatch,
89
+ description: "or once this many emails wait for it",
90
+ },
91
+ {
92
+ key: "schedule_max_cost_per_day",
93
+ default: SCHEDULE_DEFAULTS.maxCostPerDay,
94
+ description: "US dollars an agent schedule's runs may cost in a day before it waits for the next; 0 for no cap",
95
+ },
96
+ ];
97
+ /** `SCHEDULE_SETTINGS` as the config file gives them; a missing or unusable value is left to the default. */
98
+ export const scheduleLimitsOf = (config) => {
99
+ const { schedule_mail_wait: wait, schedule_mail_batch: batch, schedule_max_cost_per_day: cap } = config;
100
+ return {
101
+ ...(typeof wait === "string"
102
+ ? { mailWaitMinutes: Math.ceil(parseDuration(wait, "schedule_mail_wait") / 60_000) }
103
+ : {}),
104
+ ...(typeof batch === "number" && Number.isSafeInteger(batch) && batch >= 1 ? { mailBatch: batch } : {}),
105
+ ...(typeof cap === "number" && Number.isFinite(cap) && cap >= 0 ? { maxCostPerDay: cap } : {}),
106
+ };
107
+ };
108
+ const given = (limits) => Object.fromEntries(Object.entries(limits).filter(([, value]) => value !== undefined));
109
+ const usd = (value) => `$${value.toFixed(2)}`;
79
110
  /**
80
111
  * Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
81
112
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
82
113
  * the rest, so what runs and with which variables is the same on all three.
83
114
  */
84
- export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns = [] }) => {
115
+ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: configured, agentRuns = [], }) => {
85
116
  const agentClis = [app, ...agentRuns];
86
117
  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`);
87
118
  const prepare = (self, resolving = {}) => {
@@ -118,12 +149,44 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
118
149
  };
119
150
  return { context, system, state, say, timer, environment, save };
120
151
  };
121
- /** A schedule gated on new mail waits while nothing is stored past its holder's mark. */
122
- const waits = async (self, file) => {
123
- const holder = existsSync(file) ? readSchedule(file).onNewMail : undefined;
124
- if (holder === undefined || !newMail || (await newMail(self, holder)))
152
+ /**
153
+ * Why a run does not start now, or nothing: an agent whose runs reached the day's cost cap waits for tomorrow, and
154
+ * one gated on mail waits for mail past its holder's mark — on its timer, until enough of it waits.
155
+ */
156
+ const waits = async (self, file, { timed }) => {
157
+ if (!existsSync(file))
158
+ return undefined;
159
+ const schedule = readSchedule(file);
160
+ const { mailWaitMinutes, mailBatch, maxCostPerDay } = {
161
+ ...SCHEDULE_DEFAULTS,
162
+ ...given(configured?.(self) ?? {}),
163
+ ...given({
164
+ mailWaitMinutes: schedule.mailWaitMinutes,
165
+ mailBatch: schedule.mailBatch,
166
+ maxCostPerDay: schedule.maxCostPerDay,
167
+ }),
168
+ };
169
+ if (schedule.kind === "agent" && maxCostPerDay > 0) {
170
+ const spent = spentToday(file);
171
+ if (spent >= maxCostPerDay) {
172
+ const why = `today's runs cost ${usd(spent)}, the ${usd(maxCostPerDay)} a day allowed; it runs again tomorrow`;
173
+ recordSkip(file, why);
174
+ return why;
175
+ }
176
+ }
177
+ const holder = schedule.onNewMail;
178
+ if (holder === undefined || !newMail)
179
+ return undefined;
180
+ const waiting = await newMail(self, holder);
181
+ if (waiting.unmarked)
182
+ return undefined;
183
+ if (waiting.emails === 0)
184
+ return `no mail stored past ${holder}'s mark`;
185
+ const waited = waiting.oldestStoredAt === null ? 0 : Date.now() - waiting.oldestStoredAt;
186
+ if (!timed || waiting.emails >= mailBatch || waited >= mailWaitMinutes * 60_000)
125
187
  return undefined;
126
- return holder;
188
+ return (`${waiting.emails} email(s) wait past ${holder}'s mark; it starts at ${mailBatch}, ` +
189
+ `or once the oldest has waited ${mailWaitMinutes}m`);
127
190
  };
128
191
  withWhen(grantsAuthority(annotate(command.command("command"), {
129
192
  literal_after_dashes: true,
@@ -159,11 +222,20 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
159
222
  .argument("<name>", "lowercase letters, digits and dashes")
160
223
  .addArgument(new Argument("<harness>", "the agent to start").choices(HARNESSES))
161
224
  .argument("<prompt>", `what to do, e.g. "${promptExample(app)}"`)
162
- .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) {
225
+ .addOption(new Option("--max-cost <usd>", "claude only: stop a run that costs more than this, in US dollars").argParser(Number))
226
+ .addOption(new Option("--max-cost-per-day <usd>", `claude only: start no run for the rest of the day once its runs cost this much, in US dollars; 0 for no cap (default ${SCHEDULE_DEFAULTS.maxCostPerDay}, or the config file's)`).argParser(Number))).action(async function (name, harness, prompt) {
163
227
  const { context, system, environment, save } = prepare(this);
164
228
  const options = this.opts();
165
229
  if (options.onNewMail !== undefined && !HOLDER.test(options.onNewMail))
166
230
  throw new CliError("validation_error", `--on-new-mail takes a holder: lower-case letters, digits and dashes`);
231
+ if ((options.mailWait !== undefined || options.mailBatch !== undefined) && options.onNewMail === undefined)
232
+ throw new CliError("validation_error", "--mail-wait and --mail-batch shape a run gated by --on-new-mail");
233
+ if (options.maxCostPerDay !== undefined && !(Number.isFinite(options.maxCostPerDay) && options.maxCostPerDay >= 0))
234
+ throw new CliError("validation_error", "--max-cost-per-day takes US dollars, 0 or more");
235
+ const mailBatch = options.mailBatch === undefined ? undefined : Number(options.mailBatch);
236
+ if (mailBatch !== undefined && !(Number.isSafeInteger(mailBatch) && mailBatch >= 1))
237
+ throw new CliError("validation_error", "--mail-batch takes a count of emails, 1 or more");
238
+ const mailWaitMinutes = options.mailWait === undefined ? undefined : Math.ceil(parseDuration(options.mailWait, "--mail-wait") / 60_000);
167
239
  const program = findProgram(harness, context.env, system.platform);
168
240
  if (!program)
169
241
  throw new CliError("not_found", `${harness} is not on PATH`);
@@ -205,12 +277,17 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
205
277
  environment: environment(),
206
278
  createdAt: new Date().toISOString(),
207
279
  ...(options.onNewMail ? { onNewMail: options.onNewMail } : {}),
280
+ ...(mailWaitMinutes === undefined ? {} : { mailWaitMinutes }),
281
+ ...(mailBatch === undefined ? {} : { mailBatch }),
282
+ ...(options.maxCostPerDay === undefined ? {} : { maxCostPerDay: options.maxCostPerDay }),
208
283
  });
209
284
  });
210
285
  if (newMail)
211
286
  command.commands
212
287
  .find((one) => one.name() === "agent")
213
- ?.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`);
288
+ ?.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`)
289
+ .option("--mail-wait <duration>", `with --on-new-mail: start once the oldest waiting email has waited this long (default ${SCHEDULE_DEFAULTS.mailWaitMinutes}m, or the config file's)`)
290
+ .option("--mail-batch <n>", `with --on-new-mail: or once this many emails wait (default ${SCHEDULE_DEFAULTS.mailBatch}, or the config file's)`);
214
291
  withQuery(annotate(command.command("list"), {
215
292
  effects: LOCAL_READ,
216
293
  examples: [`${app.command} personal schedule list`, `${app.command} personal schedule list kind:agent`],
@@ -224,6 +301,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
224
301
  kind: schedule.kind,
225
302
  when: describeWhen(schedule.when),
226
303
  last: lastRun(scheduleFile(state, schedule.name)) ?? null,
304
+ ...(schedule.kind === "agent" ? { spentTodayUsd: spentToday(scheduleFile(state, schedule.name)) } : {}),
227
305
  }));
228
306
  const answer = listOf("schedule list", {
229
307
  items: rows.map((row) => snakeKeys(row)),
@@ -236,7 +314,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
236
314
  return "";
237
315
  }
238
316
  return rows
239
- .map(({ name, kind, when, last }) => [
317
+ .map(({ name, kind, when, last, spentTodayUsd }) => [
240
318
  name,
241
319
  kind,
242
320
  when,
@@ -247,6 +325,10 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
247
325
  `${last.failures} in a row${last.reason ? `: ${last.reason}` : ""} — ${last.output}`
248
326
  : "")
249
327
  : "not run yet",
328
+ ...(spentTodayUsd ? [`today ${usd(spentTodayUsd)}`] : []),
329
+ ...(last?.skipped
330
+ ? [`skipped ${last.skipped.at.slice(0, 16).replace("T", " ")}: ${last.skipped.why}`]
331
+ : []),
250
332
  ].join(" "))
251
333
  .join("\n");
252
334
  });
@@ -259,9 +341,9 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
259
341
  .argument("<name>", "the schedule's name, as `schedule list` shows it")
260
342
  .action(async function (name) {
261
343
  const { context, system, state, say, timer } = prepare(this);
262
- const holder = await waits(this, scheduleFile(state, scheduleName(name)));
263
- if (holder !== undefined)
264
- return say({ name, skipped: true, holder }, [`${name}: skipped — no mail stored past ${holder}'s mark`]);
344
+ const why = await waits(this, scheduleFile(state, scheduleName(name)), { timed: false });
345
+ if (why !== undefined)
346
+ return say({ name, skipped: true, why }, [`${name}: skipped — ${why}`]);
265
347
  const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, scheduleFile(state, scheduleName(name)), {
266
348
  timer: timer(),
267
349
  agentRuns,
@@ -295,7 +377,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
295
377
  .action(async function (file) {
296
378
  // A timer starts it with no profile named; the schedule's own environment carries the profile.
297
379
  const { context, system, timer } = prepare(this, { needsProfile: false });
298
- if ((await waits(this, file)) !== undefined)
380
+ if ((await waits(this, file, { timed: true })) !== undefined)
299
381
  return;
300
382
  const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, file, {
301
383
  timer: timer(),
@@ -247,7 +247,7 @@ export const settingsFor = (app, extension = {}) => {
247
247
  const declaredSettings = [...SHARED_SETTINGS, ...(extension.settings ?? [])]
248
248
  .filter(({ key, default: value }) => allSettings.includes(key) && v.safeParse(readSchema, { defaults: { [key]: value } }).success)
249
249
  .map((setting) => ({ ...setting, key: `defaults.${setting.key}` }));
250
- // `defaults` needs no field of its own, so it is the one section created: every key then shows (RISK-2125).
250
+ // `defaults` needs no field of its own, so it is the one section created: every key then shows.
251
251
  const withDefaults = (raw, except = []) => withSettingDefaults(isScope(raw) && !Object.hasOwn(raw, "defaults") ? { ...raw, defaults: {} } : raw, declaredSettings.filter(({ key }) => !except.includes(key)));
252
252
  const PROFILE = envName(app, "PROFILE");
253
253
  const PROFILE_LOCK = envName(app, "PROFILE_LOCK");
@@ -58,6 +58,7 @@ const OTHERS = [
58
58
  validation("meeting-findings.dedicated-review", "review meeting findings through the dedicated findings command"),
59
59
  validation("evidence-findings.stale", "evidence findings changed since they were read; stage a new batch"),
60
60
  validation("mail-findings.stale", "mail findings cite mail that changed since it was read; stage a new batch"),
61
+ ["mail-findings.not-stored", "not_found", "a mail finding cites an email the store does not hold"],
61
62
  validation("mail-findings.partial", "a cited email is not stored whole yet; cite it once it is"),
62
63
  validation("evidence-findings.dedicated-review", "review evidence findings through the dedicated findings command"),
63
64
  validation("mail-findings.dedicated-review", "review mail findings through the dedicated findings command"),
@@ -66,6 +67,8 @@ const OTHERS = [
66
67
  ["extraction.invalid-proposals", "invalid_response", "the model's memory proposals could not be used"],
67
68
  validation("extraction.text-too-long", "the text is too long for memory extraction"),
68
69
  validation("file.locked", "another process holds the file's lock"),
70
+ validation("mail.cursor-ahead", "an acknowledge cursor is past the newest stored email"),
71
+ ["mail.holder-not-yours", "permission_error", "a run moves only the mark of the holder its schedule names"],
69
72
  ["mail.source-failed", "provider_error", "the mail server refused or did not complete a request"],
70
73
  ["mail.search-refused", "provider_error", "the mail server refused to search a folder"],
71
74
  [
@@ -22,6 +22,7 @@ export declare const markItem: (mark: MailMark) => v.InferOutput<typeof mailMark
22
22
  export declare const markText: (mark: MailMark) => string;
23
23
  /**
24
24
  * `messages acknowledge`: a holder says it is done with what `review --holder` gave it, and its mark moves there. It
25
- * only moves the holder's own bookkeeping forward, so a read-only scheduled run may do it.
25
+ * only moves the holder's own bookkeeping forward, so a read-only scheduled run may do it — for the holder its
26
+ * schedule names only: an email that talks its reader into acknowledging would otherwise silence another pipeline.
26
27
  */
27
28
  export declare const acknowledgeCommand: (mail: MailReads) => Command;
@@ -1,10 +1,13 @@
1
+ import { CliError } from "@wirecat/cli-base";
1
2
  import { annotate } from "@wirecat/cli-base/commands";
2
3
  import { Command } from "commander";
3
4
  import * as v from "valibot";
4
5
  import { answerEntity } from "../../cli/answer-entity.js";
5
- import { outputFor } from "../../cli/context.js";
6
+ import { actorOf } from "../../cli/author.js";
7
+ import { environmentOf, outputFor } from "../../cli/context.js";
6
8
  import { LOCAL_WRITE, proposesOnly } from "../../cli/effects.js";
7
9
  import { itemOf, renderAnswer } from "../../cli/envelope.js";
10
+ import { mailHolderVariable, readOnlyLockOf } from "../../ports/app.js";
8
11
  import { acknowledgeMark } from "../marks.js";
9
12
  const iso = (ms) => (ms === null ? null : new Date(ms).toISOString());
10
13
  /** A holder's mark as `--json`, `mail status` and MCP show it. */
@@ -38,7 +41,8 @@ export const markText = (mark) => `${mark.holder} through ${mark.throughId}${ma
38
41
  `updated ${iso(mark.updatedAt)}\n`;
39
42
  /**
40
43
  * `messages acknowledge`: a holder says it is done with what `review --holder` gave it, and its mark moves there. It
41
- * only moves the holder's own bookkeeping forward, so a read-only scheduled run may do it.
44
+ * only moves the holder's own bookkeeping forward, so a read-only scheduled run may do it — for the holder its
45
+ * schedule names only: an email that talks its reader into acknowledging would otherwise silence another pipeline.
42
46
  */
43
47
  export const acknowledgeCommand = (mail) => answerEntity(proposesOnly(annotate(new Command("acknowledge"), {
44
48
  effects: LOCAL_WRITE,
@@ -53,6 +57,13 @@ export const acknowledgeCommand = (mail) => answerEntity(proposesOnly(annotate(n
53
57
  .requiredOption("--cursor <cursor>", "the cursor `review --holder` gave")
54
58
  .action(async function () {
55
59
  const { holder, cursor } = this.opts();
60
+ const env = environmentOf(this).env ?? process.env;
61
+ const named = env[mailHolderVariable(mail.app)]?.trim();
62
+ const owner = actorOf(this) === "owner" && !readOnlyLockOf(mail.app, env);
63
+ if (named ? named !== holder : !owner)
64
+ throw new CliError("permission_error", named
65
+ ? `this run moves only ${named}'s mark, not ${holder}'s`
66
+ : `an agent moves only the mark of the holder its schedule names (schedule agent --on-new-mail ${holder}); the owner moves any`, { reason: "mail.holder-not-yours" });
56
67
  const mark = await mail.read(this, (store, mailbox) => acknowledgeMark(store, mailbox, holder, cursor));
57
68
  renderAnswer(this, outputFor(this), itemOf(markItem(mark)), () => markText(mark));
58
69
  });
@@ -90,14 +90,16 @@ const reviewAnswer = (found, mailbox, fresh, step) => {
90
90
  const cut = found.chats.filter((chat) => chat.more);
91
91
  const ref = (thread) => formatReference({ type: "chat", ...mailbox, chat: thread });
92
92
  const turns = found.unanswered !== undefined || found.waiting !== undefined;
93
- const wholeThreads = cut.flatMap((chat) => step("mail-conversations.show", { conversation: ref(chat.id) }));
93
+ const [first, ...rest] = cut.map((chat) => ref(chat.id));
94
+ const wholeThread = first === undefined ? [] : step("mail-conversations.show", { conversation: first });
94
95
  const missing = [
95
- ...(cut.length > 0
96
+ ...(first !== undefined
96
97
  ? [
97
98
  {
98
99
  field: "items",
99
- why: `only the newest emails of ${cut.length} thread(s)`,
100
- ...(wholeThreads.length ? { step: "mail-conversations.show" } : {}),
100
+ why: `only the newest emails of ${cut.length} thread(s)` +
101
+ (rest.length ? `; read the others whole the same way: ${rest.join(", ")}` : ""),
102
+ ...(wholeThread.length ? { step: "mail-conversations.show" } : {}),
101
103
  },
102
104
  ]
103
105
  : []),
@@ -117,7 +119,7 @@ const reviewAnswer = (found, mailbox, fresh, step) => {
117
119
  ...(!fresh && !turns
118
120
  ? step("mail-review.next", { query: `date>${quoted(found.complete ? found.until : found.since)}` })
119
121
  : []),
120
- ...wholeThreads,
122
+ ...wholeThread,
121
123
  ];
122
124
  return listOf("review", {
123
125
  items: read.map((message) => ({
@@ -9,7 +9,7 @@ export { dropMissing, type Gone, tooManyGone } from "./gone.js";
9
9
  export { type AuthVerdicts, HEADER_FIELDS, headersOf, type MailHeaders, mailHeadersOf } from "./headers.js";
10
10
  export { type MailIdsInput, mailIds } from "./ids.js";
11
11
  export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
12
- export { acknowledgeMark, cursorOf, HOLD_PARTIAL_MS, HOLDER, holderHasMail, MARK_BATCH, type MailMark, type MarkRead, markOf, marksOf, readPastMark, type SkipReason, } from "./marks.js";
12
+ export { acknowledgeMark, advancePastSkipped, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, type MailMark, type MailWaiting, type MarkRead, mailWaiting, markOf, marksOf, readPastMark, type SkipReason, } from "./marks.js";
13
13
  export { type MailTurn, mailTurn } from "./owed.js";
14
14
  export { type Address, htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, type ParsedMail, parseMail, } from "./parse.js";
15
15
  export { PARTIAL, type PartialOutcome } from "./partial.js";
@@ -9,7 +9,7 @@ export { dropMissing, tooManyGone } from "./gone.js";
9
9
  export { HEADER_FIELDS, headersOf, mailHeadersOf } from "./headers.js";
10
10
  export { mailIds } from "./ids.js";
11
11
  export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
12
- export { acknowledgeMark, cursorOf, HOLD_PARTIAL_MS, HOLDER, holderHasMail, MARK_BATCH, markOf, marksOf, readPastMark, } from "./marks.js";
12
+ export { acknowledgeMark, advancePastSkipped, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, mailWaiting, markOf, marksOf, readPastMark, } from "./marks.js";
13
13
  export { mailTurn } from "./owed.js";
14
14
  export { htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, parseMail, } from "./parse.js";
15
15
  export { PARTIAL } from "./partial.js";
@@ -17,6 +17,8 @@ export interface MailMark {
17
17
  /** ms: when the email at `throughId` was received; for display. */
18
18
  throughAt: number | null;
19
19
  held: number[];
20
+ /** Emails skipped while their sender looked suspicious; offered again once the sender's score is cleared. */
21
+ suspicious: number[];
20
22
  read: number;
21
23
  skipped: Record<SkipReason, number>;
22
24
  /** ms. */
@@ -48,13 +50,27 @@ export declare const readPastMark: (store: MessageStore, mailbox: AccountKey, ho
48
50
  now?: number;
49
51
  limit?: number;
50
52
  }) => Promise<MarkRead>;
53
+ /** Mail past a holder's marks that would reach it: how many emails, and when the oldest of them was stored. */
54
+ export interface MailWaiting {
55
+ /** No mark yet in some mailbox: the holder's first run sets it. */
56
+ unmarked: boolean;
57
+ emails: number;
58
+ /** ms. */
59
+ oldestStoredAt: number | null;
60
+ }
61
+ /**
62
+ * What waits for the holder in every stored mailbox, read only: what a schedule asks before it starts the holder's
63
+ * agent, so a run with nothing new costs no model call. Bulk, envelopes, drafts, suspicious senders and partial mail
64
+ * do not count.
65
+ */
66
+ export declare const mailWaiting: (store: MessageStore, holder: string, now?: number) => Promise<MailWaiting>;
51
67
  /**
52
- * Whether the holder has mail to read in any stored mailbox — what a schedule asks before it starts the holder's
53
- * agent, so a run with nothing new costs no model call. Mail that is only skipped (bulk, envelopes, drafts,
54
- * suspicious senders) moves the holder's mark here, as the agent would have acknowledged it, or it would wait
55
- * forever behind the mark. A holder with no mark yet has mail to read: its first run sets the mark.
68
+ * Moves the holder's mark, in every stored mailbox, over the mail at its head that it would only skip — bulk,
69
+ * envelopes, drafts, suspicious senders — as the agent would have acknowledged it, so a quiet holder's next check
70
+ * does not read it all again. Suspicious senders' emails stay on the mark to be offered again. It stops at the first
71
+ * email the holder reads. A store busy with a sync is left alone: the next check moves it.
56
72
  */
57
- export declare const holderHasMail: (store: MessageStore, holder: string, now?: number) => Promise<boolean>;
73
+ export declare const advancePastSkipped: (store: MessageStore, holder: string, now?: number) => Promise<void>;
58
74
  /**
59
75
  * Moves the holder's mark to the cursor its read gave, never back. An email whole when it was read counts read; one
60
76
  * still partial, or changed after the read (it may have become whole since), is held and comes with the next read.
@@ -1,12 +1,15 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { isBusy } from "../store/sqlite/transaction.js";
2
3
  /** A holder's name: what `--holder` takes and the mark's key ends with. */
3
4
  export const HOLDER = /^[a-z0-9][a-z0-9-]{0,39}$/;
4
5
  /** At most this many emails are read past the mark in one go; the rest come with the next read. */
5
6
  export const MARK_BATCH = 200;
6
7
  /** A partial email waits this long to be stored whole, then is skipped. */
7
8
  export const HOLD_PARTIAL_MS = 7 * 86_400_000;
8
- /** Held emails kept on one mark at most; past it the oldest are skipped as partial. */
9
+ /** Held, or suspicious, emails kept on one mark at most; past it the oldest are dropped. */
9
10
  const MOST_HELD = 500;
11
+ /** A cursor read at most this far in the future is a clock apart, not a forgery. */
12
+ const CLOCK_SLACK_MS = 60_000;
10
13
  const PREFIX = "consumer:";
11
14
  const noneSkipped = () => ({ bulk: 0, envelope: 0, partial: 0, draft: 0, suspicious: 0 });
12
15
  const keyOf = (holder) => {
@@ -24,6 +27,7 @@ const parsed = (holder, value, at) => {
24
27
  throughId: saved.throughId,
25
28
  throughAt: saved.throughAt ?? null,
26
29
  held: Array.isArray(saved.held) ? saved.held.filter((id) => typeof id === "number") : [],
30
+ suspicious: Array.isArray(saved.suspicious) ? saved.suspicious.filter((id) => typeof id === "number") : [],
27
31
  read: saved.read ?? 0,
28
32
  skipped: { ...noneSkipped(), ...saved.skipped },
29
33
  updatedAt: at,
@@ -79,34 +83,38 @@ const parseCursor = (cursor) => {
79
83
  * empty and gives the cursor of the newest stored email.
80
84
  */
81
85
  export const readPastMark = async (store, mailbox, holder, options = {}) => {
82
- const { wanted, ...read } = await examine(store, mailbox, holder, options);
86
+ const { wanted, oldestStoredAt: _, ...read } = await examine(store, mailbox, holder, options);
83
87
  return { emails: await store.mail.emailHits(wanted), ...read };
84
88
  };
85
89
  /** `readPastMark` without the emails' text: the store ids it would read, by their verdicts. */
86
- const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_BATCH }) => {
87
- const mark = await markOf(store, mailbox, holder);
90
+ const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_BATCH, after }) => {
91
+ const saved = await markOf(store, mailbox, holder);
88
92
  const account = await accountOf(store, mailbox);
89
- const empty = { wanted: [], more: false, held: 0, skipped: noneSkipped(), mark };
93
+ const empty = { wanted: [], oldestStoredAt: null, more: false, held: 0, skipped: noneSkipped(), mark: saved };
90
94
  if (!account)
91
95
  return { ...empty, cursor: cursorOf(0, now) };
92
- if (!mark)
96
+ if (!saved)
93
97
  return { ...empty, cursor: cursorOf(await store.mail.newestEmailId(account.id), now) };
94
- const found = await store.mail.emailsAfter(account.id, mark.throughId, limit + 1, mark.held);
98
+ const mark = after === undefined ? saved : { ...saved, throughId: after, held: [], suspicious: [] };
99
+ const found = await store.mail.emailsAfter(account.id, mark.throughId, limit + 1, [...mark.held, ...mark.suspicious]);
95
100
  // Mail a running sync stored is read once that sync ended: it files bulk mail only at its end.
96
101
  const settled = await store.sync.syncState(mailbox, "mail_coverage");
97
102
  const settledAt = settled ? Date.parse(settled.at) : Number.POSITIVE_INFINITY;
98
- const after = found.filter((email) => email.id > mark.throughId);
99
- const unsettled = after.findIndex((email) => email.storedAt > settledAt);
100
- const fresh = unsettled === -1 ? after : after.slice(0, unsettled);
103
+ const newer = found.filter((email) => email.id > mark.throughId);
104
+ const unsettled = newer.findIndex((email) => email.storedAt > settledAt);
105
+ const fresh = unsettled === -1 ? newer : newer.slice(0, unsettled);
101
106
  const batch = fresh.slice(0, limit);
102
107
  const examined = [...found.filter((email) => email.id <= mark.throughId), ...batch];
103
108
  const skipped = noneSkipped();
104
109
  const wanted = [];
110
+ let oldestStoredAt = null;
105
111
  let held = 0;
106
112
  for (const email of examined) {
107
113
  const said = verdict(email, now);
108
- if (said === "read")
114
+ if (said === "read") {
109
115
  wanted.push(email.id);
116
+ oldestStoredAt = Math.min(oldestStoredAt ?? email.storedAt, email.storedAt);
117
+ }
110
118
  else if (said === "held")
111
119
  held += 1;
112
120
  else if (email.id > mark.throughId)
@@ -114,35 +122,68 @@ const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_
114
122
  }
115
123
  return {
116
124
  wanted: wanted.sort((a, b) => a - b),
125
+ oldestStoredAt,
117
126
  cursor: cursorOf(batch.at(-1)?.id ?? mark.throughId, now),
118
- more: after.length > batch.length,
127
+ more: newer.length > batch.length,
119
128
  held,
120
129
  skipped,
121
- mark,
130
+ mark: saved,
122
131
  };
123
132
  };
124
133
  /**
125
- * Whether the holder has mail to read in any stored mailbox — what a schedule asks before it starts the holder's
126
- * agent, so a run with nothing new costs no model call. Mail that is only skipped (bulk, envelopes, drafts,
127
- * suspicious senders) moves the holder's mark here, as the agent would have acknowledged it, or it would wait
128
- * forever behind the mark. A holder with no mark yet has mail to read: its first run sets the mark.
134
+ * What waits for the holder in every stored mailbox, read only: what a schedule asks before it starts the holder's
135
+ * agent, so a run with nothing new costs no model call. Bulk, envelopes, drafts, suspicious senders and partial mail
136
+ * do not count.
137
+ */
138
+ export const mailWaiting = async (store, holder, now = Date.now()) => {
139
+ const waiting = { unmarked: false, emails: 0, oldestStoredAt: null };
140
+ for (const mailbox of await store.accounts.accounts()) {
141
+ if (mailbox.provider !== "email")
142
+ continue;
143
+ let after;
144
+ for (;;) {
145
+ const read = await examine(store, mailbox, holder, { now, ...(after === undefined ? {} : { after }) });
146
+ if (!read.mark) {
147
+ waiting.unmarked = true;
148
+ break;
149
+ }
150
+ waiting.emails += read.wanted.length;
151
+ if (read.oldestStoredAt !== null)
152
+ waiting.oldestStoredAt = Math.min(waiting.oldestStoredAt ?? read.oldestStoredAt, read.oldestStoredAt);
153
+ const through = Number(read.cursor.split(".")[0]);
154
+ if (!read.more || through === (after ?? read.mark.throughId))
155
+ break;
156
+ after = through;
157
+ }
158
+ }
159
+ return waiting;
160
+ };
161
+ /**
162
+ * Moves the holder's mark, in every stored mailbox, over the mail at its head that it would only skip — bulk,
163
+ * envelopes, drafts, suspicious senders — as the agent would have acknowledged it, so a quiet holder's next check
164
+ * does not read it all again. Suspicious senders' emails stay on the mark to be offered again. It stops at the first
165
+ * email the holder reads. A store busy with a sync is left alone: the next check moves it.
129
166
  */
130
- export const holderHasMail = async (store, holder, now = Date.now()) => {
167
+ export const advancePastSkipped = async (store, holder, now = Date.now()) => {
131
168
  for (const mailbox of await store.accounts.accounts()) {
132
169
  if (mailbox.provider !== "email")
133
170
  continue;
134
171
  for (;;) {
135
172
  const read = await examine(store, mailbox, holder, { now });
136
- if (!read.mark || read.wanted.length > 0)
137
- return true;
138
- if (read.cursor === cursorOf(read.mark.throughId, now))
173
+ if (!read.mark || read.wanted.length > 0 || read.cursor === cursorOf(read.mark.throughId, now))
139
174
  break;
140
- await acknowledgeMark(store, mailbox, holder, read.cursor, now);
175
+ try {
176
+ await acknowledgeMark(store, mailbox, holder, read.cursor, now);
177
+ }
178
+ catch (error) {
179
+ if (isBusy(error))
180
+ return;
181
+ throw error;
182
+ }
141
183
  if (!read.more)
142
184
  break;
143
185
  }
144
186
  }
145
- return false;
146
187
  };
147
188
  /**
148
189
  * Moves the holder's mark to the cursor its read gave, never back. An email whole when it was read counts read; one
@@ -155,6 +196,11 @@ export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date
155
196
  const account = await accountOf(store, mailbox);
156
197
  if (!account)
157
198
  throw new CliError("not_found", `no mail of ${mailbox.account} is stored`);
199
+ const newest = await store.mail.newestEmailId(account.id);
200
+ if (throughId > newest || readAt > now + CLOCK_SLACK_MS)
201
+ throw new CliError("validation_error", `--cursor ${cursor} is past the newest stored email; acknowledge a cursor review gave`, {
202
+ reason: "mail.cursor-ahead",
203
+ });
158
204
  const mark = await markOf(store, mailbox, holder);
159
205
  const from = mark?.throughId ?? throughId;
160
206
  if (throughId < from)
@@ -162,7 +208,8 @@ export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date
162
208
  const examined = [];
163
209
  let after = from;
164
210
  const held = new Set(mark?.held ?? []);
165
- const again = await store.mail.emailsAfter(account.id, Number.MAX_SAFE_INTEGER, 0, [...held]);
211
+ const doubted = new Set(mark?.suspicious ?? []);
212
+ const again = await store.mail.emailsAfter(account.id, Number.MAX_SAFE_INTEGER, 0, [...held, ...doubted]);
166
213
  examined.push(...again);
167
214
  while (after < throughId) {
168
215
  const page = (await store.mail.emailsAfter(account.id, after, MARK_BATCH)).filter((one) => one.id <= throughId);
@@ -174,6 +221,7 @@ export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date
174
221
  const skipped = { ...(mark?.skipped ?? noneSkipped()) };
175
222
  let read = mark?.read ?? 0;
176
223
  const keep = [];
224
+ const suspicious = [];
177
225
  const live = new Set(again.map((one) => one.id));
178
226
  for (const id of held)
179
227
  if (!live.has(id))
@@ -181,6 +229,7 @@ export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date
181
229
  for (const email of examined) {
182
230
  const said = verdict(email, readAt);
183
231
  const wasHeld = held.has(email.id);
232
+ const wasDoubted = doubted.has(email.id) && !wasHeld;
184
233
  if (said === "held" || (said === "read" && email.changedAt > readAt) || (wasHeld && email.changedAt > readAt)) {
185
234
  if (email.partial && now - email.storedAt >= HOLD_PARTIAL_MS)
186
235
  skipped.partial += 1;
@@ -188,8 +237,13 @@ export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date
188
237
  keep.push(email.id);
189
238
  }
190
239
  else if (said === "read")
191
- read += wasHeld || email.id > from ? 1 : 0;
192
- else if (!wasHeld || said === "partial")
240
+ read += wasHeld || wasDoubted || email.id > from ? 1 : 0;
241
+ else if (said === "suspicious") {
242
+ suspicious.push(email.id);
243
+ if (!wasDoubted && !wasHeld)
244
+ skipped.suspicious += 1;
245
+ }
246
+ else if (!wasDoubted && (!wasHeld || said === "partial"))
193
247
  skipped[said] += 1;
194
248
  }
195
249
  const kept = [...new Set(keep)].sort((a, b) => a - b);
@@ -199,6 +253,7 @@ export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date
199
253
  throughId,
200
254
  throughAt: through?.id === throughId ? through.at : (mark?.throughAt ?? null),
201
255
  held: kept.slice(-MOST_HELD),
256
+ suspicious: [...new Set(suspicious)].sort((a, b) => a - b).slice(-MOST_HELD),
202
257
  read,
203
258
  skipped,
204
259
  };
@@ -59,7 +59,9 @@ export interface FindingsReceipt {
59
59
  accepted: string[];
60
60
  items: FindingOutcome[];
61
61
  }
62
+ export declare const findingText: (value: unknown, limit: number) => string;
62
63
  export declare const findingHash: (value: unknown) => string;
64
+ export declare const findingFields: (value: object, allowed: string[], noun: string) => void;
63
65
  /** What differs between findings kinds: their wording and what one cited reference must look like. */
64
66
  export interface FindingsRules {
65
67
  /** "meeting findings" */
@@ -97,7 +99,5 @@ export declare const validateBatch: <F>(input: {
97
99
  findings: F[];
98
100
  };
99
101
  export declare const validateFindings: (input: FindingsInput, rules: FindingsRules) => FindingsInput;
100
- export declare const findingText: (value: unknown, limit: number) => string;
101
- export declare const findingFields: (value: object, allowed: string[], noun: string) => void;
102
102
  export declare const findingFingerprint: ({ id: _id, ...finding }: Finding) => string;
103
103
  export {};
@@ -6,13 +6,13 @@ const TASK_TYPES = ["bug", "feature", "chore"];
6
6
  const invalid = (message) => {
7
7
  throw new CliError("validation_error", message);
8
8
  };
9
- const text = (value, limit) => {
9
+ export const findingText = (value, limit) => {
10
10
  if (typeof value !== "string" || !value.trim() || value.includes("\0") || value.length > limit)
11
11
  return invalid(`finding text must contain 1–${limit} characters without NUL`);
12
12
  return value.trim();
13
13
  };
14
14
  export const findingHash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
15
- const fields = (value, allowed, noun) => {
15
+ export const findingFields = (value, allowed, noun) => {
16
16
  if (Object.keys(value).some((key) => !allowed.includes(key)))
17
17
  invalid(`unknown ${noun} field`);
18
18
  };
@@ -25,7 +25,7 @@ export const validEvidence = (raw, rules) => {
25
25
  .map((item) => {
26
26
  if (!item || typeof item !== "object")
27
27
  return invalid("finding evidence is a reference and fingerprint");
28
- fields(item, ["reference", "fingerprint"], rules.singular);
28
+ findingFields(item, ["reference", "fingerprint"], rules.singular);
29
29
  if (typeof item.reference !== "string" || typeof item.fingerprint !== "string")
30
30
  return invalid("evidence requires a reference and fingerprint");
31
31
  const reference = rules.reference(item.reference);
@@ -51,10 +51,10 @@ export const validateFinding = (finding, rules, ids) => {
51
51
  if (!finding || typeof finding !== "object")
52
52
  return invalid("a finding is an object");
53
53
  const id = findingId(finding.id, ids);
54
- const project = finding.project === undefined && rules.anySubject ? undefined : text(finding.project, 64);
54
+ const project = finding.project === undefined && rules.anySubject ? undefined : findingText(finding.project, 64);
55
55
  if (project !== undefined && !/^project:[1-9]\d*$/.test(project))
56
56
  return invalid("a finding names a project reference");
57
- if (finding.subject !== undefined && (!rules.anySubject || !SUBJECT.test(text(finding.subject, 80))))
57
+ if (finding.subject !== undefined && (!rules.anySubject || !SUBJECT.test(findingText(finding.subject, 80))))
58
58
  return invalid("a finding's subject is a person:, organization:, task: or project: reference");
59
59
  if (finding.kind === "task" && project === undefined && !rules.inboxTasks)
60
60
  return invalid("a task finding names the project the task goes in");
@@ -66,15 +66,15 @@ export const validateFinding = (finding, rules, ids) => {
66
66
  evidence,
67
67
  };
68
68
  if (finding.kind === "fact") {
69
- fields(finding, ["id", "project", "subject", "evidence", "kind", "type", "statement"], rules.singular);
69
+ findingFields(finding, ["id", "project", "subject", "evidence", "kind", "type", "statement"], rules.singular);
70
70
  if (finding.type === "payment")
71
71
  return invalid("a payment is a finding of kind payment; money is a payments row, not a fact");
72
72
  if (!FACT_TYPES.includes(finding.type))
73
73
  return invalid("unknown fact finding type");
74
- return { ...base, kind: "fact", type: finding.type, statement: text(finding.statement, 2000) };
74
+ return { ...base, kind: "fact", type: finding.type, statement: findingText(finding.statement, 2000) };
75
75
  }
76
76
  if (finding.kind === "payment" && rules.payments) {
77
- fields(finding, ["id", "project", "subject", "evidence", "kind", "statement", "payment", "supersedes"], rules.singular);
77
+ findingFields(finding, ["id", "project", "subject", "evidence", "kind", "statement", "payment", "supersedes"], rules.singular);
78
78
  if (finding.supersedes !== undefined && !/^invoice:[1-9]\d*$/.test(String(finding.supersedes)))
79
79
  return invalid("a payment finding supersedes an invoice: reference, the expected payment it settles or voids");
80
80
  const payment = validatePayment(finding.payment);
@@ -87,14 +87,14 @@ export const validateFinding = (finding, rules, ids) => {
87
87
  return {
88
88
  ...base,
89
89
  kind: "payment",
90
- ...(finding.statement === undefined ? {} : { statement: text(finding.statement, 2000) }),
90
+ ...(finding.statement === undefined ? {} : { statement: findingText(finding.statement, 2000) }),
91
91
  payment,
92
92
  ...(finding.supersedes === undefined ? {} : { supersedes: finding.supersedes }),
93
93
  };
94
94
  }
95
95
  if (finding.kind !== "task")
96
96
  return invalid(`a finding is a fact${rules.payments ? ", payment" : ""} or task`);
97
- fields(finding, [
97
+ findingFields(finding, [
98
98
  "id",
99
99
  "project",
100
100
  "evidence",
@@ -122,8 +122,8 @@ export const validateFinding = (finding, rules, ids) => {
122
122
  ...base,
123
123
  kind: "task",
124
124
  type: finding.type,
125
- title: text(finding.title, 300),
126
- reason: text(finding.reason, 2000),
125
+ title: findingText(finding.title, 300),
126
+ reason: findingText(finding.reason, 2000),
127
127
  commitment: finding.commitment,
128
128
  ...(finding.dueAt === undefined
129
129
  ? {}
@@ -149,20 +149,18 @@ export const support = (value, evidence) => {
149
149
  export const validateBatch = (input, rules, most, one) => {
150
150
  if (!input || typeof input !== "object" || Buffer.byteLength(JSON.stringify(input), "utf8") > 65536)
151
151
  return invalid(`${rules.plural} take at most 65536 UTF-8 bytes`);
152
- fields(input, ["schemaVersion", "key", "extractor", "findings"], rules.singular);
152
+ findingFields(input, ["schemaVersion", "key", "extractor", "findings"], rules.singular);
153
153
  if (input.schemaVersion !== 1 ||
154
154
  !Array.isArray(input.findings) ||
155
155
  input.findings.length < 1 ||
156
156
  input.findings.length > most)
157
157
  return invalid(`schemaVersion 1 takes 1–${most} ${rules.plural}`);
158
- const key = text(input.key, 64);
158
+ const key = findingText(input.key, 64);
159
159
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(key))
160
160
  return invalid("a findings key uses letters, digits, dot, colon, underscore or hyphen");
161
161
  const ids = new Set();
162
162
  const findings = input.findings.map((finding) => one(finding, ids));
163
- return { schemaVersion: 1, key, extractor: text(input.extractor, 200), findings };
163
+ return { schemaVersion: 1, key, extractor: findingText(input.extractor, 200), findings };
164
164
  };
165
165
  export const validateFindings = (input, rules) => validateBatch(input, rules, 16, (finding, ids) => validateFinding(finding, rules, ids));
166
- export const findingText = (value, limit) => text(value, limit);
167
- export const findingFields = (value, allowed, noun) => fields(value, allowed, noun);
168
166
  export const findingFingerprint = ({ id: _id, ...finding }) => findingHash(finding);
@@ -38,6 +38,8 @@ export interface AppIdentity {
38
38
  export declare const envName: (app: AppIdentity, name: string) => string;
39
39
  /** The variable that locks this process read-only, when it is set to anything but "0". */
40
40
  export declare const readOnlyLockOf: (app: AppIdentity, env: NodeJS.ProcessEnv) => string | undefined;
41
+ /** The holder a scheduled run was started for (`schedule agent --on-new-mail`): the only mark that run may move. */
42
+ export declare const mailHolderVariable: (app: AppIdentity) => string;
41
43
  export declare const MESSENGER_CHANGE_REFUSAL = "send, react, change chats or change the account";
42
44
  /** The one wording for a write `<PREFIX>_READ_ONLY` stops: `what` finishes "it cannot …". */
43
45
  export declare const readOnlyRefusal: (lock: string, what: string) => CliError;
package/dist/ports/app.js CHANGED
@@ -6,6 +6,8 @@ export const readOnlyLockOf = (app, env) => {
6
6
  const value = env[name];
7
7
  return value === undefined || value.trim() === "" || value === "0" ? undefined : name;
8
8
  };
9
+ /** The holder a scheduled run was started for (`schedule agent --on-new-mail`): the only mark that run may move. */
10
+ export const mailHolderVariable = (app) => envName(app, "MAIL_HOLDER");
9
11
  export const MESSENGER_CHANGE_REFUSAL = "send, react, change chats or change the account";
10
12
  /** The one wording for a write `<PREFIX>_READ_ONLY` stops: `what` finishes "it cannot …". */
11
13
  export const readOnlyRefusal = (lock, what) => new CliError("permission_error", `this process is read-only (${lock}) — it cannot ${what}`);
@@ -1,8 +1,9 @@
1
1
  import { type MessageStore } from "../../../store/index.js";
2
2
  import type { ProposalKind } from "../types.js";
3
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
4
+ * What an agent read in new mail, one batch per mailbox and run. Only the owner decides it — mail is text a stranger
5
+ * wrote, so an agent that read it must not also approve what it made of it: an agent's confirmation is refused
6
+ * whatever `agent_decisions` says. Confirming accepts the parts named, or every finding not
6
7
  * rejected before; the rest are rejected.
7
8
  */
8
9
  export declare const mailFindingsKind: (store: Pick<MessageStore, "mailFindings" | "proposedActions">) => ProposalKind;
@@ -44,8 +44,9 @@ const rowOf = (batch, createdAt) => {
44
44
  };
45
45
  };
46
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
47
+ * What an agent read in new mail, one batch per mailbox and run. Only the owner decides it — mail is text a stranger
48
+ * wrote, so an agent that read it must not also approve what it made of it: an agent's confirmation is refused
49
+ * whatever `agent_decisions` says. Confirming accepts the parts named, or every finding not
49
50
  * rejected before; the rest are rejected.
50
51
  */
51
52
  export const mailFindingsKind = (store) => {
@@ -82,9 +82,10 @@ export const mailFindingsStoreOver = (context) => {
82
82
  };
83
83
  /**
84
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.
85
+ * mail of the batch's own mailbox, and no sender other than the owner is behind more than five findings. An email
86
+ * gone by the time the owner decides changed since it was read; one never stored was never read.
86
87
  */
87
- const cite = (findings, account) => {
88
+ const cite = (findings, account, deciding = false) => {
88
89
  const read = new Map();
89
90
  const perSender = new Map();
90
91
  let bytes = 0;
@@ -96,8 +97,12 @@ export const mailFindingsStoreOver = (context) => {
96
97
  if (!row) {
97
98
  const at = parseLocator(reference);
98
99
  row = database.prepare(EMAIL).get(at.account, at.chat, at.message);
99
- if (!row)
100
+ if (!row && deciding)
100
101
  return stale();
102
+ if (!row)
103
+ throw new CliError("not_found", `no stored email ${reference}; cite only mail a review gave`, {
104
+ reason: "mail-findings.not-stored",
105
+ });
101
106
  bytes += Buffer.byteLength(String(row.body)) + Buffer.byteLength(String(row.subject ?? ""));
102
107
  if (bytes > TEXT_BUDGET)
103
108
  return invalid(`finding evidence exceeds the ${TEXT_BUDGET}-byte stored text budget`);
@@ -227,9 +232,7 @@ export const mailFindingsStoreOver = (context) => {
227
232
  }
228
233
  case "new_contact": {
229
234
  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);
235
+ database.prepare("UPDATE persons SET name = ?, updated_at = ? WHERE id = ?").run(finding.name, now(), person);
233
236
  return `person:${person}`;
234
237
  }
235
238
  }
@@ -303,7 +306,7 @@ export const mailFindingsStoreOver = (context) => {
303
306
  // accepted findings' own mail unchanged.
304
307
  const chosen = batch.input.findings.filter((finding) => accepted.includes(finding.id));
305
308
  if (chosen.length)
306
- cite(chosen, account);
309
+ cite(chosen, account, true);
307
310
  if (proposal.proposedBy.type !== "bot" || !proposal.proposedBy.name)
308
311
  return invalid("a findings author must be an agent");
309
312
  const agent = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.567.0",
3
+ "version": "0.568.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
package/parity.json CHANGED
@@ -565,6 +565,10 @@
565
565
  "value": "<usd>",
566
566
  "meaning": "stop an agent's run that costs more than this, in US dollars"
567
567
  },
568
+ "--max-cost-per-day": {
569
+ "value": "<usd>",
570
+ "meaning": "start no agent run for the rest of the day once its runs cost this much, in US dollars"
571
+ },
568
572
  "--max-gaps": {
569
573
  "value": "<n>",
570
574
  "meaning": "maximum interior coverage gaps repaired in this run"
@@ -3962,6 +3966,12 @@
3962
3966
  "planned": {
3963
3967
  "wa": "wa backlog: mount the shared schedule command"
3964
3968
  }
3969
+ },
3970
+ "--max-cost-per-day": {
3971
+ "in": ["max", "tg"],
3972
+ "planned": {
3973
+ "wa": "wa backlog: mount the shared schedule command"
3974
+ }
3965
3975
  }
3966
3976
  }
3967
3977
  },