@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 +44 -0
- package/dist/background/schedules.d.ts +30 -0
- package/dist/background/schedules.js +64 -4
- package/dist/cli/index.d.ts +2 -2
- package/dist/cli/index.js +2 -1
- package/dist/cli/schedule/schedule-command.d.ts +18 -5
- package/dist/cli/schedule/schedule-command.js +96 -14
- package/dist/cli/settings.js +1 -1
- package/dist/errors/reasons.js +3 -0
- package/dist/mail/cli/marks.d.ts +2 -1
- package/dist/mail/cli/marks.js +13 -2
- package/dist/mail/cli/review.js +7 -5
- package/dist/mail/index.d.ts +1 -1
- package/dist/mail/index.js +1 -1
- package/dist/mail/marks.d.ts +21 -5
- package/dist/mail/marks.js +81 -26
- package/dist/meetings/findings-rules.d.ts +2 -2
- package/dist/meetings/findings-rules.js +15 -17
- package/dist/ports/app.d.ts +2 -0
- package/dist/ports/app.js +2 -0
- package/dist/services/proposals/kinds/mail-findings.d.ts +3 -2
- package/dist/services/proposals/kinds/mail-findings.js +3 -2
- package/dist/store/sqlite/mail-findings.js +10 -7
- package/package.json +1 -1
- package/parity.json +10 -0
- /package/dist/cli/{meeting-findings-input.d.ts → findings-input.d.ts} +0 -0
- /package/dist/cli/{meeting-findings-input.js → findings-input.js} +0 -0
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
|
-
|
|
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
|
};
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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<
|
|
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
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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))
|
|
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
|
|
263
|
-
if (
|
|
264
|
-
return say({ name, skipped: true,
|
|
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(),
|
package/dist/cli/settings.js
CHANGED
|
@@ -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
|
|
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");
|
package/dist/errors/reasons.js
CHANGED
|
@@ -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
|
[
|
package/dist/mail/cli/marks.d.ts
CHANGED
|
@@ -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;
|
package/dist/mail/cli/marks.js
CHANGED
|
@@ -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 {
|
|
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
|
});
|
package/dist/mail/cli/review.js
CHANGED
|
@@ -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
|
|
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
|
-
...(
|
|
96
|
+
...(first !== undefined
|
|
96
97
|
? [
|
|
97
98
|
{
|
|
98
99
|
field: "items",
|
|
99
|
-
why: `only the newest emails of ${cut.length} thread(s)
|
|
100
|
-
|
|
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
|
-
...
|
|
122
|
+
...wholeThread,
|
|
121
123
|
];
|
|
122
124
|
return listOf("review", {
|
|
123
125
|
items: read.map((message) => ({
|
package/dist/mail/index.d.ts
CHANGED
|
@@ -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,
|
|
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";
|
package/dist/mail/index.js
CHANGED
|
@@ -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,
|
|
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";
|
package/dist/mail/marks.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
|
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.
|
package/dist/mail/marks.js
CHANGED
|
@@ -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
|
|
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
|
|
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 (!
|
|
96
|
+
if (!saved)
|
|
93
97
|
return { ...empty, cursor: cursorOf(await store.mail.newestEmailId(account.id), now) };
|
|
94
|
-
const
|
|
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
|
|
99
|
-
const unsettled =
|
|
100
|
-
const fresh = unsettled === -1 ?
|
|
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:
|
|
127
|
+
more: newer.length > batch.length,
|
|
119
128
|
held,
|
|
120
129
|
skipped,
|
|
121
|
-
mark,
|
|
130
|
+
mark: saved,
|
|
122
131
|
};
|
|
123
132
|
};
|
|
124
133
|
/**
|
|
125
|
-
*
|
|
126
|
-
* agent, so a run with nothing new costs no model call.
|
|
127
|
-
*
|
|
128
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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 :
|
|
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(
|
|
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
|
-
|
|
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:
|
|
74
|
+
return { ...base, kind: "fact", type: finding.type, statement: findingText(finding.statement, 2000) };
|
|
75
75
|
}
|
|
76
76
|
if (finding.kind === "payment" && rules.payments) {
|
|
77
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
126
|
-
reason:
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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);
|
package/dist/ports/app.d.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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
|
|
48
|
-
*
|
|
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.
|
|
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
|
},
|
|
File without changes
|
|
File without changes
|