@wirecat/cli-core 0.554.0 → 0.556.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/calendar/mcp/index.d.ts +12 -12
- package/dist/cli/config-command.js +64 -2
- package/dist/cli/config-issues.d.ts +2 -0
- package/dist/cli/config-issues.js +11 -3
- package/dist/cli/config-repair.d.ts +26 -0
- package/dist/cli/config-repair.js +252 -0
- package/dist/cli/messenger/catch-up-answers.d.ts +50 -1
- package/dist/cli/messenger/catch-up-answers.js +2 -1
- package/dist/cli/messenger/catch-up.d.ts +6 -2
- package/dist/cli/messenger/catch-up.js +11 -2
- package/dist/cli/messenger/conversation-answers.d.ts +3 -0
- package/dist/cli/messenger/inbox.js +1 -1
- package/dist/cli/messenger/message-answers.d.ts +3 -0
- package/dist/cli/messenger/message-writes.d.ts +6 -0
- package/dist/cli/messenger/points.d.ts +9 -4
- package/dist/cli/messenger/points.js +10 -4
- package/dist/cli/messenger/review.js +1 -1
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/cli/messenger/tasks-analysis-command.d.ts +8 -0
- package/dist/cli/messenger/tasks-analysis-command.js +184 -0
- package/dist/cli/messenger/tasks-command.js +11 -2
- package/dist/cli/steps.js +10 -0
- package/dist/domain/models.d.ts +4 -0
- package/dist/entities/contact-context.d.ts +6 -0
- package/dist/entities/conversation-results.d.ts +6 -0
- package/dist/entities/evidence.d.ts +24 -0
- package/dist/entities/index.d.ts +327 -5
- package/dist/entities/index.js +6 -2
- package/dist/entities/listener.d.ts +12 -0
- package/dist/entities/message-context.d.ts +9 -0
- package/dist/entities/message-write.d.ts +12 -0
- package/dist/entities/message.d.ts +6 -0
- package/dist/entities/message.js +6 -0
- package/dist/entities/store-records.d.ts +3 -0
- package/dist/entities/task-results.d.ts +72 -0
- package/dist/entities/task-results.js +40 -1
- package/dist/errors/reasons.d.ts +1 -1
- package/dist/errors/reasons.js +9 -0
- package/dist/mail/cli/index.d.ts +3 -1
- package/dist/mail/cli/index.js +7 -1
- package/dist/mail/cli/marks.d.ts +26 -0
- package/dist/mail/cli/marks.js +51 -0
- package/dist/mail/cli/review.d.ts +24 -0
- package/dist/mail/cli/review.js +274 -0
- package/dist/mail/index.d.ts +3 -0
- package/dist/mail/index.js +3 -0
- package/dist/mail/marks.d.ts +56 -0
- package/dist/mail/marks.js +178 -0
- package/dist/mail/owed.d.ts +11 -0
- package/dist/mail/owed.js +14 -0
- package/dist/mail/review.d.ts +37 -0
- package/dist/mail/review.js +118 -0
- package/dist/sends/permissions.js +1 -0
- package/dist/services/agenda-mail.d.ts +15 -0
- package/dist/services/agenda-mail.js +66 -0
- package/dist/services/agenda.d.ts +4 -0
- package/dist/services/agenda.js +17 -7
- package/dist/services/task-analysis.d.ts +58 -0
- package/dist/services/task-analysis.js +125 -0
- package/dist/store/exports.d.ts +2 -1
- package/dist/store/exports.js +1 -0
- package/dist/store/index.d.ts +1 -1
- package/dist/store/sqlite/email-activity.d.ts +93 -0
- package/dist/store/sqlite/email-activity.js +109 -0
- package/dist/store/sqlite/emails.d.ts +9 -3
- package/dist/store/sqlite/emails.js +5 -8
- package/dist/store/sqlite/task-analysis-marks.d.ts +7 -0
- package/dist/store/sqlite/task-analysis-marks.js +27 -0
- package/dist/store/sync.d.ts +5 -1
- package/dist/store/sync.js +4 -0
- package/dist/store/types.d.ts +25 -0
- package/package.json +1 -1
- package/parity.json +29 -0
- package/skills/follow-ups/SKILL.md +22 -2
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { senderIsMe } from "../domain/models.js";
|
|
3
|
+
import { mailTurn } from "./owed.js";
|
|
4
|
+
/** Every thread is a local read; this many keep one answer readable, and the rest wait in `skipped`. */
|
|
5
|
+
export const REVIEW_THREADS = 50;
|
|
6
|
+
const REVIEW_PER_THREAD = 50;
|
|
7
|
+
/** Without a date, `--unanswered` and `--waiting` look as far back as `tasks agenda` does. */
|
|
8
|
+
export const OWED_DAYS = 30;
|
|
9
|
+
// A thread is picked by its own time, which is the Date header where sync stored one; mail delivered up to a day
|
|
10
|
+
// after its date is still found.
|
|
11
|
+
const DATED_SLACK = 86_400_000;
|
|
12
|
+
const HOUR = 3_600_000;
|
|
13
|
+
/** Mail sent to many, a list's, or recorded by an envelope rule without its text. */
|
|
14
|
+
export const isBulk = (email) => email.bulk || email.list || email.envelope;
|
|
15
|
+
const absent = (error) => {
|
|
16
|
+
if (error instanceof CliError && error.code === "not_found")
|
|
17
|
+
return undefined;
|
|
18
|
+
throw error;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* How far sync proved the mailbox: everything that arrived before `until` is stored. Review cuts there, so mail a
|
|
22
|
+
* running sync has not stored yet cannot slip behind a saved point.
|
|
23
|
+
*/
|
|
24
|
+
const syncedThrough = async (store, mailbox) => {
|
|
25
|
+
const saved = await store.sync.syncState(mailbox, "mail_coverage");
|
|
26
|
+
if (!saved)
|
|
27
|
+
return { until: undefined, complete: false };
|
|
28
|
+
try {
|
|
29
|
+
const { until, state } = JSON.parse(saved.value);
|
|
30
|
+
const at = typeof until === "string" ? Date.parse(until) : Number.NaN;
|
|
31
|
+
return { until: Number.isNaN(at) ? undefined : at, complete: state === "window" && !Number.isNaN(at) };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { until: undefined, complete: false };
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const wanted = (thread, { noBulk, unansweredAfterHours, waitingAfterHours }, now) => {
|
|
38
|
+
const newest = thread.newest;
|
|
39
|
+
if (unansweredAfterHours !== undefined || waitingAfterHours !== undefined) {
|
|
40
|
+
const turn = mailTurn(newest);
|
|
41
|
+
const age = newest === null ? 0 : now - newest.at;
|
|
42
|
+
const owed = turn === "owed" && unansweredAfterHours !== undefined && age >= unansweredAfterHours * HOUR;
|
|
43
|
+
const waiting = turn === "waiting" && waitingAfterHours !== undefined && age >= waitingAfterHours * HOUR;
|
|
44
|
+
if (!owed && !waiting)
|
|
45
|
+
return "no";
|
|
46
|
+
}
|
|
47
|
+
return noBulk && newest !== null && !newest.outgoing && isBulk(newest) ? "quiet" : "read";
|
|
48
|
+
};
|
|
49
|
+
/** The emails after the other side last wrote: what is owed, or what waits for an answer. */
|
|
50
|
+
const turnTail = (messages, owner, ownerWrote) => {
|
|
51
|
+
const start = messages.findLastIndex((one) => senderIsMe(one, owner) !== ownerWrote);
|
|
52
|
+
return messages.slice(start + 1);
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* **Every email, both sides, in each thread that changed since a point** — `review` over the store's mail, in
|
|
56
|
+
* messenger review's shape: a thread is a chat, an email a message. Reads the store alone, so it costs the mail
|
|
57
|
+
* server nothing; it opens no task (the owner reviews mail tasks first). Cut where sync proved the mailbox
|
|
58
|
+
* (`syncedThrough`), so the next review starting at `until` misses nothing sync had stored by then.
|
|
59
|
+
*/
|
|
60
|
+
export const reviewMail = async (store, mailbox, options) => {
|
|
61
|
+
const { since, points } = options;
|
|
62
|
+
const now = options.now ?? Date.now();
|
|
63
|
+
const account = await store.accounts.storedAccount(mailbox).catch(absent);
|
|
64
|
+
const synced = account ? await syncedThrough(store, mailbox) : { until: undefined, complete: false };
|
|
65
|
+
const cut = Math.min(now, synced.until ?? now);
|
|
66
|
+
const startOf = (thread) => points?.get(thread) ?? since;
|
|
67
|
+
const activity = account ? await store.mail.threadActivity(account.id, since - DATED_SLACK) : [];
|
|
68
|
+
const changed = activity.filter((thread) => Math.max(thread.lastEmailAt, thread.newest?.at ?? 0) > startOf(thread.threadId));
|
|
69
|
+
const picked = changed.map((thread) => ({ thread, as: wanted(thread, options, now) }));
|
|
70
|
+
const shown = picked.filter(({ as }) => as === "read").map(({ thread }) => thread);
|
|
71
|
+
const read = shown.slice(0, REVIEW_THREADS);
|
|
72
|
+
const skipped = shown.slice(REVIEW_THREADS).map(({ threadId, subject, lastEmailAt }) => ({
|
|
73
|
+
id: threadId,
|
|
74
|
+
title: subject,
|
|
75
|
+
lastMessageAt: new Date(lastEmailAt).toISOString(),
|
|
76
|
+
}));
|
|
77
|
+
const turns = options.unansweredAfterHours !== undefined || options.waitingAfterHours !== undefined;
|
|
78
|
+
const chats = [];
|
|
79
|
+
const checked = {};
|
|
80
|
+
for (const thread of read) {
|
|
81
|
+
const hits = await store.mail.threadHits(account?.id ?? 0, thread.threadId, {
|
|
82
|
+
after: startOf(thread.threadId),
|
|
83
|
+
until: cut,
|
|
84
|
+
});
|
|
85
|
+
const tail = turns ? turnTail(hits, mailbox.account, thread.newest?.outgoing === true) : hits;
|
|
86
|
+
const marks = await store.mail.emailMarks(account?.id ?? 0, tail.map((hit) => hit.id));
|
|
87
|
+
const messages = tail.map((hit) => {
|
|
88
|
+
const mark = marks.get(hit.id);
|
|
89
|
+
return mark ? { ...hit, marks: mark } : hit;
|
|
90
|
+
});
|
|
91
|
+
const more = messages.length > REVIEW_PER_THREAD;
|
|
92
|
+
if (messages.length > 0)
|
|
93
|
+
chats.push({
|
|
94
|
+
id: thread.threadId,
|
|
95
|
+
title: thread.subject,
|
|
96
|
+
kind: "dialog",
|
|
97
|
+
messages: more ? messages.slice(-REVIEW_PER_THREAD) : messages,
|
|
98
|
+
more,
|
|
99
|
+
});
|
|
100
|
+
if (!more)
|
|
101
|
+
checked[thread.threadId] = new Date(cut).toISOString();
|
|
102
|
+
}
|
|
103
|
+
const earliest = changed.reduce((low, thread) => Math.min(low, startOf(thread.threadId)), Number.POSITIVE_INFINITY);
|
|
104
|
+
return {
|
|
105
|
+
since: new Date(points === undefined || changed.length === 0 ? since : earliest).toISOString(),
|
|
106
|
+
until: new Date(cut).toISOString(),
|
|
107
|
+
complete: synced.complete && skipped.length === 0 && chats.every((one) => !one.more),
|
|
108
|
+
chats,
|
|
109
|
+
skipped,
|
|
110
|
+
partial: false,
|
|
111
|
+
quiet: picked.filter(({ as }) => as === "quiet").length,
|
|
112
|
+
...(options.unansweredAfterHours === undefined
|
|
113
|
+
? {}
|
|
114
|
+
: { unanswered: { olderThanHours: options.unansweredAfterHours } }),
|
|
115
|
+
...(options.waitingAfterHours === undefined ? {} : { waiting: { olderThanHours: options.waitingAfterHours } }),
|
|
116
|
+
...(points === undefined ? {} : { checked }),
|
|
117
|
+
};
|
|
118
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type MailTurn } from "../mail/owed.js";
|
|
2
|
+
import { type AccountKey, type ChatActivity, type MessageStore, queryFilter } from "../store/store.js";
|
|
3
|
+
import type { ChatCoverage } from "./chat-coverage.js";
|
|
4
|
+
export interface TurnActivity extends ChatActivity {
|
|
5
|
+
turn: MailTurn;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* `tasks agenda` over an email account: each thread with mail in the window, its newest email deciding whose turn it
|
|
9
|
+
* is (`mailTurn`). `storedAt` is when sync last proved the mailbox, so a quiet day with no mail is not `stale`.
|
|
10
|
+
*/
|
|
11
|
+
export declare const mailActivity: (store: MessageStore, account: AccountKey, since: number, chats: Parameters<typeof queryFilter>[0] | undefined) => Promise<{
|
|
12
|
+
chats: TurnActivity[];
|
|
13
|
+
storedAt: string | null;
|
|
14
|
+
coverage: (ids: string[]) => ChatCoverage[];
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { mailTurn } from "../mail/owed.js";
|
|
3
|
+
import { MAIL_AGENDA_COLUMNS, queryFilter, } from "../store/store.js";
|
|
4
|
+
const absent = (error) => {
|
|
5
|
+
if (error instanceof CliError && error.code === "not_found")
|
|
6
|
+
return undefined;
|
|
7
|
+
throw error;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* `tasks agenda` over an email account: each thread with mail in the window, its newest email deciding whose turn it
|
|
11
|
+
* is (`mailTurn`). `storedAt` is when sync last proved the mailbox, so a quiet day with no mail is not `stale`.
|
|
12
|
+
*/
|
|
13
|
+
export const mailActivity = async (store, account, since, chats) => {
|
|
14
|
+
const stored = await store.accounts.storedAccount(account).catch(absent);
|
|
15
|
+
if (!stored)
|
|
16
|
+
return { chats: [], storedAt: null, coverage: () => [] };
|
|
17
|
+
const where = chats
|
|
18
|
+
? queryFilter(chats, MAIL_AGENDA_COLUMNS, { command: "tasks agenda" })
|
|
19
|
+
: undefined;
|
|
20
|
+
const threads = await store.mail.threadActivity(stored.id, since, where);
|
|
21
|
+
const synced = await store.sync.syncState(account, "mail_coverage");
|
|
22
|
+
const window = (() => {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(synced?.value ?? "{}");
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
})();
|
|
30
|
+
const byId = new Map(threads.map((thread) => [thread.threadId, thread]));
|
|
31
|
+
return {
|
|
32
|
+
chats: threads.map((thread) => ({
|
|
33
|
+
chatId: thread.threadId,
|
|
34
|
+
title: thread.subject,
|
|
35
|
+
kind: "dialog",
|
|
36
|
+
lastMessageAt: new Date(thread.newest?.at ?? thread.lastEmailAt).toISOString(),
|
|
37
|
+
lastMessageId: thread.newest?.externalId ?? null,
|
|
38
|
+
lastFromOwner: thread.newest ? thread.newest.outgoing : null,
|
|
39
|
+
turn: mailTurn(thread.newest),
|
|
40
|
+
})),
|
|
41
|
+
storedAt: synced?.at ?? null,
|
|
42
|
+
coverage: (ids) => ids.map((chatId) => {
|
|
43
|
+
const thread = byId.get(chatId);
|
|
44
|
+
const newest = thread?.newest?.at ?? thread?.lastEmailAt;
|
|
45
|
+
const until = window.until === undefined ? Number.NaN : Date.parse(window.until);
|
|
46
|
+
return {
|
|
47
|
+
chatId,
|
|
48
|
+
title: thread?.subject ?? null,
|
|
49
|
+
completeness: {
|
|
50
|
+
chatId,
|
|
51
|
+
// Sync holds a window of the mailbox, never a thread's whole history.
|
|
52
|
+
state: synced ? "partial" : "unknown",
|
|
53
|
+
upToDate: window.state === "window" && newest !== undefined && !Number.isNaN(until) ? newest <= until : null,
|
|
54
|
+
gaps: false,
|
|
55
|
+
reachesStart: false,
|
|
56
|
+
fetchedAt: synced?.at ?? null,
|
|
57
|
+
},
|
|
58
|
+
held: {
|
|
59
|
+
messages: thread?.emails ?? 0,
|
|
60
|
+
oldestAt: null,
|
|
61
|
+
newestAt: newest === undefined ? null : new Date(newest).toISOString(),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
};
|
|
@@ -79,6 +79,10 @@ export interface Agenda {
|
|
|
79
79
|
export interface AgendaService {
|
|
80
80
|
show(options?: AgendaOptions): Promise<Agenda>;
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* An email account's chats are its threads, and whose turn one is follows `mailTurn` (`../mail/owed.ts`), not merely
|
|
84
|
+
* who wrote last: mail sent to many is never owed.
|
|
85
|
+
*/
|
|
82
86
|
export declare const agendaService: (deps: ServiceDeps, now?: () => number) => AgendaService;
|
|
83
87
|
export type AgendaItem = v.InferOutput<typeof agenda>;
|
|
84
88
|
/** The agenda in entity keys, as `--json` and MCP answer it; chats become `chat:` references. */
|
package/dist/services/agenda.js
CHANGED
|
@@ -3,6 +3,7 @@ import { formatLocator, isLocator, parseLocator } from "../domain/locator.js";
|
|
|
3
3
|
import { formatReference } from "../domain/references.js";
|
|
4
4
|
import { agenda } from "../entities/agenda.js";
|
|
5
5
|
import { AGENDA_COLUMNS, inboxKey, queryFilter, } from "../store/store.js";
|
|
6
|
+
import { mailActivity } from "./agenda-mail.js";
|
|
6
7
|
import { chatCoverage } from "./chat-coverage.js";
|
|
7
8
|
import { CHAT_KINDS } from "./chats.js";
|
|
8
9
|
import { taskAccount } from "./task-rules.js";
|
|
@@ -36,6 +37,10 @@ export const AGENDA_QUERY = {
|
|
|
36
37
|
}),
|
|
37
38
|
defaults: ["chat_kind:(dialog OR group)", "claimed:(none OR mine)"],
|
|
38
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* An email account's chats are its threads, and whose turn one is follows `mailTurn` (`../mail/owed.ts`), not merely
|
|
42
|
+
* who wrote last: mail sent to many is never owed.
|
|
43
|
+
*/
|
|
39
44
|
export const agendaService = (deps, now = Date.now) => ({
|
|
40
45
|
show: async ({ dueWithin = DAY, waiting = AGENDA_DEFAULTS.waiting, unanswered = AGENDA_DEFAULTS.unanswered, within = 30 * DAY, query, holder, } = {}) => {
|
|
41
46
|
const [store, account] = [await deps.store(), await deps.account()];
|
|
@@ -91,9 +96,12 @@ export const agendaService = (deps, now = Date.now) => ({
|
|
|
91
96
|
createdAt: task.createdAt,
|
|
92
97
|
assignees,
|
|
93
98
|
}));
|
|
94
|
-
const
|
|
95
|
-
const activity =
|
|
99
|
+
const mail = account.provider === "email" ? await mailActivity(store, account, at - within, parts.chats) : undefined;
|
|
100
|
+
const activity = mail ??
|
|
101
|
+
(await store.chats.chatActivity(account, at - within, parts.chats ? queryFilter(parts.chats, AGENDA_COLUMNS, { command: "tasks agenda" }) : undefined));
|
|
102
|
+
const turnOf = (chat) => "turn" in chat ? chat.turn : chat.lastFromOwner === null ? null : chat.lastFromOwner ? "waiting" : "owed";
|
|
96
103
|
const chats = activity.chats.map(({ lastMessageId, ...chat }) => ({
|
|
104
|
+
turn: turnOf({ lastMessageId, ...chat }),
|
|
97
105
|
...chat,
|
|
98
106
|
lastMessage: lastMessageId === null
|
|
99
107
|
? null
|
|
@@ -107,12 +115,14 @@ export const agendaService = (deps, now = Date.now) => ({
|
|
|
107
115
|
}));
|
|
108
116
|
const silentFor = (chat) => at - Date.parse(chat.lastMessageAt);
|
|
109
117
|
const shown = {
|
|
110
|
-
owed: chats.filter((chat) => chat.
|
|
111
|
-
waiting: chats.filter((chat) => chat.
|
|
112
|
-
|
|
118
|
+
owed: chats.filter((chat) => chat.turn === "owed" && silentFor(chat) >= unanswered),
|
|
119
|
+
waiting: chats.filter((chat) => chat.turn === "waiting" && silentFor(chat) >= waiting),
|
|
120
|
+
// A thread with no stored email but drafts is not behind: mail sync stores every email it lists.
|
|
121
|
+
unsynced: mail ? [] : chats.filter((chat) => chat.lastMessage === null),
|
|
113
122
|
};
|
|
114
|
-
const
|
|
115
|
-
const
|
|
123
|
+
const ids = Object.values(shown).flatMap((list) => list.map(({ chatId }) => chatId));
|
|
124
|
+
const covered = new Map((mail ? mail.coverage(ids) : await chatCoverage(store, account, ids)).map((one) => [one.chatId, one]));
|
|
125
|
+
const withCoverage = (list) => list.map(({ turn: _, ...chat }) => ({ ...chat, coverage: covered.get(chat.chatId) }));
|
|
116
126
|
const inList = new Set((await store.tasks.categories()).map(({ name }) => name));
|
|
117
127
|
const unlisted = new Map();
|
|
118
128
|
for (const { category } of open)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ChatKind, Id, Message } from "../domain/models.js";
|
|
2
|
+
import type { AccountKey, MessageStore, TaskAnalysisMark } from "../store/index.js";
|
|
3
|
+
/** How many messages one read gives an agent: a page it can judge in one go. */
|
|
4
|
+
export declare const ANALYSIS_PAGE = 100;
|
|
5
|
+
export declare const ANALYSIS_PAGE_MAX = 1000;
|
|
6
|
+
/** Where a chat never read for tasks starts, unless the caller names a time: older history is not new work. */
|
|
7
|
+
export declare const defaultStart: (now: Date) => string;
|
|
8
|
+
export interface ChatAnalysis {
|
|
9
|
+
chatId: Id;
|
|
10
|
+
title: string | null;
|
|
11
|
+
kind: ChatKind;
|
|
12
|
+
newestAt: string | null;
|
|
13
|
+
mark: TaskAnalysisMark | null;
|
|
14
|
+
/** Stored messages after the mark, or since the default start when the chat was never read for tasks. */
|
|
15
|
+
waiting: number;
|
|
16
|
+
/** Stored inside the read stretch after it was read: `--since <from>` reads the stretch again. */
|
|
17
|
+
late: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Every chat whose messages can hold the owner's tasks — not a channel or Saved Messages (owner, 2026-10-17) — with
|
|
21
|
+
* how far it was read for tasks and how much waits, newest chat first.
|
|
22
|
+
*/
|
|
23
|
+
export declare const taskAnalysisList: (store: MessageStore, account: AccountKey, now?: Date) => Promise<ChatAnalysis[]>;
|
|
24
|
+
export interface UnanalysedPage {
|
|
25
|
+
mark: TaskAnalysisMark | null;
|
|
26
|
+
/** Where this read started: the mark, `since`, or the default start. */
|
|
27
|
+
from: string;
|
|
28
|
+
messages: Message[];
|
|
29
|
+
more: boolean;
|
|
30
|
+
late: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The stored messages of a chat not yet read for tasks, oldest first, from its mark; `since` reads from that time
|
|
34
|
+
* instead, to read a stretch again. Reading moves nothing: `acknowledgeTaskAnalysis` does, once the reader is done.
|
|
35
|
+
*/
|
|
36
|
+
export declare const unanalysedMessages: (store: MessageStore, account: AccountKey, chatId: Id, { limit, since, now }?: {
|
|
37
|
+
limit?: number;
|
|
38
|
+
since?: string;
|
|
39
|
+
now?: Date;
|
|
40
|
+
}) => Promise<UnanalysedPage>;
|
|
41
|
+
/**
|
|
42
|
+
* Records that the chat was read for tasks through `messageId`, from `since` when that read started there. The mark
|
|
43
|
+
* never moves back. A read that covered the whole stretch again also clears what arrived late inside it.
|
|
44
|
+
*/
|
|
45
|
+
export declare const acknowledgeTaskAnalysis: (store: MessageStore, account: AccountKey, chatId: Id, messageId: Id, { since, by, now }: {
|
|
46
|
+
since?: string;
|
|
47
|
+
by: string;
|
|
48
|
+
now?: Date;
|
|
49
|
+
}) => Promise<{
|
|
50
|
+
mark: TaskAnalysisMark;
|
|
51
|
+
moved: boolean;
|
|
52
|
+
}>;
|
|
53
|
+
/**
|
|
54
|
+
* A read again of history the chat's task point already passed only updates and closes tasks (owner, 2026-11-03):
|
|
55
|
+
* a new task from such a message needs `--open-new`. A message that already has a task, or one after the point, is
|
|
56
|
+
* let through.
|
|
57
|
+
*/
|
|
58
|
+
export declare const refuseAlreadyRead: (store: MessageStore, account: AccountKey, source: string) => Promise<void>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { isLocator, parseLocator } from "../domain/locator.js";
|
|
3
|
+
import { FIRST_PASS_DAYS, RULES_IN, taskAccount } from "./task-rules.js";
|
|
4
|
+
/** How many messages one read gives an agent: a page it can judge in one go. */
|
|
5
|
+
export const ANALYSIS_PAGE = 100;
|
|
6
|
+
export const ANALYSIS_PAGE_MAX = 1000;
|
|
7
|
+
/** Where a chat never read for tasks starts, unless the caller names a time: older history is not new work. */
|
|
8
|
+
export const defaultStart = (now) => new Date(now.getTime() - FIRST_PASS_DAYS * 86_400_000).toISOString();
|
|
9
|
+
const after = (iso) => new Date(Date.parse(iso) + 1).toISOString();
|
|
10
|
+
/** Messages saved or edited after the mark was last checked, sent inside the stretch it had read. */
|
|
11
|
+
const lateOf = async (store, account, mark) => {
|
|
12
|
+
const { messages } = await store.messages.changes(account, mark.chatId, mark.checkedAt);
|
|
13
|
+
return messages.filter((one) => one.timestamp >= mark.from && one.timestamp <= mark.checkedThrough);
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Every chat whose messages can hold the owner's tasks — not a channel or Saved Messages (owner, 2026-10-17) — with
|
|
17
|
+
* how far it was read for tasks and how much waits, newest chat first.
|
|
18
|
+
*/
|
|
19
|
+
export const taskAnalysisList = async (store, account, now = new Date()) => {
|
|
20
|
+
const marks = new Map((await store.sync.taskAnalysisMarks(account)).map((mark) => [mark.chatId, mark]));
|
|
21
|
+
const start = defaultStart(now);
|
|
22
|
+
const chats = [];
|
|
23
|
+
for (const chat of await store.chats.chatStats(account)) {
|
|
24
|
+
if (RULES_IN[chat.kind] === "none")
|
|
25
|
+
continue;
|
|
26
|
+
const mark = marks.get(chat.chatId) ?? null;
|
|
27
|
+
chats.push({
|
|
28
|
+
chatId: chat.chatId,
|
|
29
|
+
title: chat.title,
|
|
30
|
+
kind: chat.kind,
|
|
31
|
+
newestAt: chat.newestAt,
|
|
32
|
+
mark,
|
|
33
|
+
waiting: await store.messages.countMessages(account, chat.chatId, { since: mark ? after(mark.through) : start }),
|
|
34
|
+
late: mark ? (await lateOf(store, account, mark)).length : 0,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return chats.sort((a, b) => (b.newestAt ?? "").localeCompare(a.newestAt ?? ""));
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* The stored messages of a chat not yet read for tasks, oldest first, from its mark; `since` reads from that time
|
|
41
|
+
* instead, to read a stretch again. Reading moves nothing: `acknowledgeTaskAnalysis` does, once the reader is done.
|
|
42
|
+
*/
|
|
43
|
+
export const unanalysedMessages = async (store, account, chatId, { limit = ANALYSIS_PAGE, since, now = new Date() } = {}) => {
|
|
44
|
+
const mark = (await store.sync.taskAnalysisMark(account, chatId)) ?? null;
|
|
45
|
+
const from = since ?? mark?.through ?? defaultStart(now);
|
|
46
|
+
const byTime = { time: Date.parse(from) - (since === undefined && mark ? 0 : 1) };
|
|
47
|
+
const read = (start) => store.messages.messages(account, chatId, { limit, after: start });
|
|
48
|
+
let page;
|
|
49
|
+
if (since === undefined && mark) {
|
|
50
|
+
try {
|
|
51
|
+
page = await read({ id: mark.throughId });
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (!(error instanceof CliError && error.code === "not_found"))
|
|
55
|
+
throw error;
|
|
56
|
+
page = await read(byTime);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else
|
|
60
|
+
page = await read(byTime);
|
|
61
|
+
return {
|
|
62
|
+
mark,
|
|
63
|
+
from,
|
|
64
|
+
messages: page.items,
|
|
65
|
+
more: page.hasMore,
|
|
66
|
+
late: mark ? (await lateOf(store, account, mark)).length : 0,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Records that the chat was read for tasks through `messageId`, from `since` when that read started there. The mark
|
|
71
|
+
* never moves back. A read that covered the whole stretch again also clears what arrived late inside it.
|
|
72
|
+
*/
|
|
73
|
+
export const acknowledgeTaskAnalysis = async (store, account, chatId, messageId, { since, by, now = new Date() }) => {
|
|
74
|
+
const message = await store.messages.message(account, messageId, { chatId });
|
|
75
|
+
if (!message)
|
|
76
|
+
throw new CliError("not_found", `message ${messageId} is not in the local copy of chat ${chatId}`);
|
|
77
|
+
const mark = await store.sync.taskAnalysisMark(account, chatId);
|
|
78
|
+
const start = since ?? mark?.from ?? defaultStart(now);
|
|
79
|
+
if (message.timestamp < start)
|
|
80
|
+
throw new CliError("validation_error", `message ${messageId} was sent before the read started (${start})`);
|
|
81
|
+
if (mark && message.timestamp < mark.from)
|
|
82
|
+
throw new CliError("validation_error", `message ${messageId} ends before the stretch already read (${mark.from}): read on to it, then acknowledge`);
|
|
83
|
+
const ahead = !mark || message.timestamp > mark.through || (since !== undefined && message.timestamp >= mark.through);
|
|
84
|
+
if (mark && !ahead && start >= mark.from)
|
|
85
|
+
return { mark, moved: false };
|
|
86
|
+
const reread = mark !== undefined && since !== undefined && since <= mark.from && ahead;
|
|
87
|
+
const checked = !mark || reread || (since === undefined && (await lateOf(store, account, mark)).length === 0);
|
|
88
|
+
const through = mark && !ahead ? mark : { through: message.timestamp, throughId: message.id };
|
|
89
|
+
const next = {
|
|
90
|
+
chatId,
|
|
91
|
+
from: mark && mark.from < start ? mark.from : start,
|
|
92
|
+
through: through.through,
|
|
93
|
+
throughId: through.throughId,
|
|
94
|
+
by,
|
|
95
|
+
checked,
|
|
96
|
+
checkedThrough: checked || !mark ? through.through : mark.checkedThrough,
|
|
97
|
+
};
|
|
98
|
+
await store.sync.setTaskAnalysisMark(account, next);
|
|
99
|
+
const saved = await store.sync.taskAnalysisMark(account, chatId);
|
|
100
|
+
if (!saved)
|
|
101
|
+
throw new Error(`the task analysis mark of chat ${chatId} was not saved`);
|
|
102
|
+
return { mark: saved, moved: true };
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* A read again of history the chat's task point already passed only updates and closes tasks (owner, 2026-11-03):
|
|
106
|
+
* a new task from such a message needs `--open-new`. A message that already has a task, or one after the point, is
|
|
107
|
+
* let through.
|
|
108
|
+
*/
|
|
109
|
+
export const refuseAlreadyRead = async (store, account, source) => {
|
|
110
|
+
if (!isLocator(source))
|
|
111
|
+
return;
|
|
112
|
+
const { provider, account: owner, chat, message } = parseLocator(source);
|
|
113
|
+
if (provider !== account.provider || owner !== account.account)
|
|
114
|
+
return;
|
|
115
|
+
const mark = await store.sync.taskAnalysisMark(account, chat);
|
|
116
|
+
if (!mark)
|
|
117
|
+
return;
|
|
118
|
+
const stored = await store.messages.message(account, message, { chatId: chat });
|
|
119
|
+
if (!stored || stored.timestamp > mark.through)
|
|
120
|
+
return;
|
|
121
|
+
if ((await store.tasks.findBySource(taskAccount(account), source)).length > 0)
|
|
122
|
+
return;
|
|
123
|
+
throw new CliError("validation_error", `${source} was already read for tasks (the point is at ${mark.through}): a read again only updates and closes ` +
|
|
124
|
+
"tasks; add --open-new to open a task from it anyway", { reason: "task.already-read" });
|
|
125
|
+
};
|
package/dist/store/exports.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export { storeClients } from "./sqlite/clients.js";
|
|
|
23
23
|
export { CHAT_LIST_KEY, type ChatCompleteness, fetchedKey, historyStartKey } from "./sqlite/completeness.js";
|
|
24
24
|
export { CONVERSATION_COLUMNS } from "./sqlite/conversations.js";
|
|
25
25
|
export type { CounterTarget } from "./sqlite/counters.js";
|
|
26
|
+
export { type EmailMarks, MAIL_AGENDA_COLUMNS, type MarkedEmail, type NewestEmail, type ThreadActivity, } from "./sqlite/email-activity.js";
|
|
26
27
|
export { messageOfEmail, threadConversation } from "./sqlite/emails.js";
|
|
27
28
|
export { orphanPointers } from "./sqlite/entity-types.js";
|
|
28
29
|
export { involvementStoreOver } from "./sqlite/involvements.js";
|
|
@@ -60,5 +61,5 @@ export type { MatchPlace, ScoredHit } from "./sqlite/words.js";
|
|
|
60
61
|
export type { TaskDetails } from "./sqlite/work-tasks.js";
|
|
61
62
|
export type { SyncStore } from "./sync.js";
|
|
62
63
|
export type { TagsStore } from "./tags.js";
|
|
63
|
-
export type { AccountKey, AccountMessageFilter, AgentAnswer, BatchMessage, ChatActivity, ChatStats, ConversationBuild, ConversationHit, ConversationSummary, DeletionScope, Delta, IdentityRef, IngestedVia, LinkBatch, LinkedIdentity, LinkOptions, MessageFilter, PersonFacts, PersonRecord, PictureInput, Range, SenderChatStats, StoredAccount, StoredChatFilter, StoredHit, StoredLink, StoredReadiness, } from "./types.js";
|
|
64
|
+
export type { AccountKey, AccountMessageFilter, AgentAnswer, BatchMessage, ChatActivity, ChatStats, ConversationBuild, ConversationHit, ConversationSummary, DeletionScope, Delta, IdentityRef, IngestedVia, LinkBatch, LinkedIdentity, LinkOptions, MessageFilter, PersonFacts, PersonRecord, PictureInput, Range, SenderChatStats, StoredAccount, StoredChatFilter, StoredHit, StoredLink, StoredReadiness, TaskAnalysisMark, TaskAnalysisMarkWrite, } from "./types.js";
|
|
64
65
|
export type { AccountVectors, VectorsStore } from "./vectors.js";
|
package/dist/store/exports.js
CHANGED
|
@@ -6,6 +6,7 @@ export { CHAT_COLUMNS, TRACKED_CHAT_COLUMNS, titlePart } from "./sqlite/chats.js
|
|
|
6
6
|
export { storeClients } from "./sqlite/clients.js";
|
|
7
7
|
export { CHAT_LIST_KEY, fetchedKey, historyStartKey } from "./sqlite/completeness.js";
|
|
8
8
|
export { CONVERSATION_COLUMNS } from "./sqlite/conversations.js";
|
|
9
|
+
export { MAIL_AGENDA_COLUMNS, } from "./sqlite/email-activity.js";
|
|
9
10
|
export { messageOfEmail, threadConversation } from "./sqlite/emails.js";
|
|
10
11
|
export { orphanPointers } from "./sqlite/entity-types.js";
|
|
11
12
|
export { involvementStoreOver } from "./sqlite/involvements.js";
|
package/dist/store/index.d.ts
CHANGED
|
@@ -71,4 +71,4 @@ export type { StoreTaskStore } from "./sqlite/tasks.js";
|
|
|
71
71
|
export type { TagPreflight, VocabularyAlias, VocabularyAliasReceipt, VocabularyAudit, VocabularyAuditOptions, VocabularyCatalog, VocabularyEntry, VocabularyKind, VocabularyOptions, VocabularyResolution, VocabularyStore, VocabularyTarget, } from "./sqlite/vocabulary.js";
|
|
72
72
|
export type { BoardCounts, BoardOptions, NewWorkTask, RevisionCheck, TaskAssignment, TaskDetails, TaskEvent, TaskFields, TaskMove, TaskMoved, TaskRole, TaskType, WorkTaskType, } from "./sqlite/work-tasks.js";
|
|
73
73
|
export { TASK_EVENT_KINDS, TASK_ROLES } from "./sqlite/work-tasks.js";
|
|
74
|
-
export { type AccountAccounts, type AccountAttachments, type AccountCounters, type AccountKey, type AccountMentions, type AccountMessageFilter, type AccountMessages, type AccountPeople, type AccountsStore, type AccountVectors, type AttachmentsStore, type AttachmentTextEntry, type AttachmentView, type ChatStats, type ChatsStore, type ConversationsStore, type CountersStore, type DeletionScope, type Delta, type FileAttachment, type IdentityRef, type IngestedVia, type LinkedIdentity, type LinkOptions, MENTION_QUERY, MENTION_STATUSES, type MentionsStore, type MessageFilter, type MessageStore, type MessagesStore, OTHER_SOURCES, openStore, type PeopleStore, type PersonFacts, type PersonRecord, type QueriesStore, type Range, type RankedEvidence, type RankedStoreFound, type RankedStoreRow, type RankingEvidenceItem, type RankingEvidenceRequest, type RankingRequest, type RosterStore, type SearchCommand, type SearchesStore, type SearchIndexStore, type SearchRecord, type StoredAccount, type StoredChatFilter, type StoredHit, type StoredSearch, type StoredTag, type StoreOptions, type SyncStore, type TagFilter, type TagsStore, type TagTarget, type TaskTagTarget, type TextOrigin, type VectorsStore, } from "./store.js";
|
|
74
|
+
export { type AccountAccounts, type AccountAttachments, type AccountCounters, type AccountKey, type AccountMentions, type AccountMessageFilter, type AccountMessages, type AccountPeople, type AccountsStore, type AccountVectors, type AttachmentsStore, type AttachmentTextEntry, type AttachmentView, type ChatStats, type ChatsStore, type ConversationsStore, type CountersStore, type DeletionScope, type Delta, type FileAttachment, type IdentityRef, type IngestedVia, type LinkedIdentity, type LinkOptions, MENTION_QUERY, MENTION_STATUSES, type MentionsStore, type MessageFilter, type MessageStore, type MessagesStore, OTHER_SOURCES, openStore, type PeopleStore, type PersonFacts, type PersonRecord, type QueriesStore, type Range, type RankedEvidence, type RankedStoreFound, type RankedStoreRow, type RankingEvidenceItem, type RankingEvidenceRequest, type RankingRequest, type RosterStore, type SearchCommand, type SearchesStore, type SearchIndexStore, type SearchRecord, type StoredAccount, type StoredChatFilter, type StoredHit, type StoredSearch, type StoredTag, type StoreOptions, type SyncStore, type TagFilter, type TagsStore, type TagTarget, type TaskAnalysisMark, type TaskAnalysisMarkWrite, type TaskTagTarget, type TextOrigin, type VectorsStore, } from "./store.js";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { CacheDatabase } from "../driver.js";
|
|
2
|
+
import type { Where } from "./query-filter.js";
|
|
3
|
+
/** What decides whose turn a thread is, read from its newest email that is not a draft; never its text. */
|
|
4
|
+
export interface NewestEmail {
|
|
5
|
+
externalId: string;
|
|
6
|
+
/** ms: when it was received, or sent when that is unknown. */
|
|
7
|
+
at: number;
|
|
8
|
+
outgoing: boolean;
|
|
9
|
+
/** The mailbox's own address is in To or Cc. */
|
|
10
|
+
addressed: boolean;
|
|
11
|
+
/** Filed in the mailbox of mail sent to many. */
|
|
12
|
+
bulk: boolean;
|
|
13
|
+
/** Carries a List-Id. */
|
|
14
|
+
list: boolean;
|
|
15
|
+
/** Recorded by an envelope rule, without its text. */
|
|
16
|
+
envelope: boolean;
|
|
17
|
+
/** Its sender's mail looks like spam, a scam or a robot (the sender score). */
|
|
18
|
+
demoted: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** One thread with mail after a time: `tasks agenda` and `review` read this, never the emails' text. */
|
|
21
|
+
export interface ThreadActivity {
|
|
22
|
+
/** The thread's external id: what a `chat:email/…` reference names. */
|
|
23
|
+
threadId: string;
|
|
24
|
+
subject: string | null;
|
|
25
|
+
/** ms. */
|
|
26
|
+
lastEmailAt: number;
|
|
27
|
+
emails: number;
|
|
28
|
+
/** `null` when every email of the thread is a draft. */
|
|
29
|
+
newest: NewestEmail | null;
|
|
30
|
+
}
|
|
31
|
+
/** What a reader sorts mail by, per email: never its text. */
|
|
32
|
+
export interface EmailMarks {
|
|
33
|
+
/** Filed in the mailbox of mail sent to many. */
|
|
34
|
+
bulk: boolean;
|
|
35
|
+
listId: string | null;
|
|
36
|
+
/** The mail rule that decided how it is stored, by its id in `rules list`. */
|
|
37
|
+
ruleId: number | null;
|
|
38
|
+
}
|
|
39
|
+
/** One email as a processing mark sees it, by its store id; never its text. */
|
|
40
|
+
export interface MarkedEmail {
|
|
41
|
+
id: number;
|
|
42
|
+
externalId: string;
|
|
43
|
+
thread: string;
|
|
44
|
+
/** ms: received, or sent when that is unknown. */
|
|
45
|
+
at: number | null;
|
|
46
|
+
/** ms: when the store saved it first, and last changed it. */
|
|
47
|
+
storedAt: number;
|
|
48
|
+
changedAt: number;
|
|
49
|
+
/** Sent to many: the bulk mailbox or a List-Id. */
|
|
50
|
+
bulk: boolean;
|
|
51
|
+
envelope: boolean;
|
|
52
|
+
/** Stored from its first bytes, not whole yet. */
|
|
53
|
+
partial: boolean;
|
|
54
|
+
draft: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface MailActivityStore {
|
|
57
|
+
/**
|
|
58
|
+
* The account's live emails stored after `afterId`, oldest first, at most `limit`, and those of `ids`: what a
|
|
59
|
+
* processing mark reads next. The id is the store's insert order, so mail stored late is never behind the mark.
|
|
60
|
+
*/
|
|
61
|
+
emailsAfter(accountId: number, afterId: number, limit: number, ids?: readonly number[]): Promise<MarkedEmail[]>;
|
|
62
|
+
/** The account's newest store id; 0 with no mail. */
|
|
63
|
+
newestEmailId(accountId: number): Promise<number>;
|
|
64
|
+
/** Every `sync_cursors` value of the account whose key starts with `prefix`. */
|
|
65
|
+
statesLike(accountId: number, prefix: string): Promise<{
|
|
66
|
+
key: string;
|
|
67
|
+
value: string;
|
|
68
|
+
at: number;
|
|
69
|
+
}[]>;
|
|
70
|
+
/** Each live email's marks, by its Message-ID; one the store does not hold is left out. */
|
|
71
|
+
emailMarks(accountId: number, externalIds: readonly string[]): Promise<Map<string, EmailMarks>>;
|
|
72
|
+
/**
|
|
73
|
+
* Each live thread of the account whose newest email is after `since` (ms), newest first, with the facts of that
|
|
74
|
+
* email. `where` is a `tasks agenda` query compiled over `MAIL_AGENDA_COLUMNS`.
|
|
75
|
+
*/
|
|
76
|
+
threadActivity(accountId: number, since: number, where?: Where): Promise<ThreadActivity[]>;
|
|
77
|
+
}
|
|
78
|
+
/** `tasks agenda` fields over `threadActivity`'s threads: an email thread counts as a dialog. */
|
|
79
|
+
export declare const MAIL_AGENDA_COLUMNS: {
|
|
80
|
+
readonly chat_kind: {
|
|
81
|
+
readonly type: "enum";
|
|
82
|
+
readonly sql: "'dialog'";
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
export declare const threadActivity: (database: CacheDatabase, accountId: number, since: number, where?: Where) => ThreadActivity[];
|
|
86
|
+
/** A thread's live emails, oldest first; `window` keeps those received in (after, until], in ms. */
|
|
87
|
+
export declare const threadEmailIds: (database: CacheDatabase, accountId: number, thread: string, { after, until }?: {
|
|
88
|
+
after?: number;
|
|
89
|
+
until?: number;
|
|
90
|
+
}) => number[];
|
|
91
|
+
export declare const emailMarks: (database: CacheDatabase, accountId: number, externalIds: readonly string[]) => Map<string, EmailMarks>;
|
|
92
|
+
export declare const emailsAfter: (database: CacheDatabase, accountId: number, afterId: number, limit: number, ids?: readonly number[]) => MarkedEmail[];
|
|
93
|
+
export declare const mailActivity: (database: CacheDatabase) => MailActivityStore;
|