@wirecat/cli-core 0.567.0 → 0.569.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 +58 -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 +27 -5
- package/dist/cli/schedule/schedule-command.js +138 -15
- 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 +16 -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/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.569.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",
|