@wirecat/cli-core 0.552.0 → 0.554.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/cli/compact.d.ts +4 -0
  3. package/dist/cli/compact.js +65 -7
  4. package/dist/cli/messenger/context.d.ts +3 -0
  5. package/dist/cli/messenger/projects-command.js +38 -11
  6. package/dist/cli/messenger/serve-relay.d.ts +1 -0
  7. package/dist/cli/project-evidence.d.ts +33 -4
  8. package/dist/cli/project-evidence.js +38 -4
  9. package/dist/cli/project-status-rows.js +11 -1
  10. package/dist/cli/project-status.d.ts +24 -0
  11. package/dist/cli/project-status.js +52 -9
  12. package/dist/cli/schedule/schedule-command.d.ts +0 -1
  13. package/dist/cli/schedule/schedule-command.js +6 -1
  14. package/dist/mail/email-input.d.ts +2 -0
  15. package/dist/mail/email-input.js +6 -1
  16. package/dist/mail/retry.js +2 -1
  17. package/dist/mail/sync.js +5 -1
  18. package/dist/mail/testing/fake.d.ts +2 -0
  19. package/dist/mail/testing/fake.js +1 -1
  20. package/dist/services/index.d.ts +4 -3
  21. package/dist/services/index.js +4 -3
  22. package/dist/services/link-scorers/index.d.ts +1 -1
  23. package/dist/services/link-scorers/index.js +1 -1
  24. package/dist/services/link-scorers/scan.d.ts +0 -1
  25. package/dist/services/link-scorers/scan.js +1 -1
  26. package/dist/services/project-purposes.d.ts +28 -0
  27. package/dist/services/project-purposes.js +41 -0
  28. package/dist/services/project-status/index.d.ts +22 -8
  29. package/dist/services/project-status/index.js +17 -9
  30. package/dist/services/stored-project-gathering.d.ts +79 -4
  31. package/dist/services/stored-project-gathering.js +171 -22
  32. package/dist/store/messages.d.ts +2 -1
  33. package/dist/store/messages.js +2 -2
  34. package/dist/store/sqlite/email-locations.d.ts +1 -0
  35. package/dist/store/sqlite/email-locations.js +4 -0
  36. package/dist/store/sqlite/emails.d.ts +2 -0
  37. package/dist/store/sqlite/emails.js +3 -6
  38. package/dist/store/sqlite/reads.d.ts +1 -1
  39. package/dist/store/sqlite/reads.js +2 -2
  40. package/package.json +1 -1
  41. package/parity.json +10 -0
@@ -49,7 +49,16 @@ export const projectStatusSchema = v.looseObject({
49
49
  attention: v.number(),
50
50
  why: v.nullable(v.string()),
51
51
  })),
52
- out_of_scope: v.optional(v.array(v.strictObject({ chat: v.string(), reason: v.string(), purpose: v.optional(v.string()) }))),
52
+ purposes: v.union([v.literal("all"), v.array(v.string())]),
53
+ out_of_scope: v.optional(v.array(v.strictObject({
54
+ chat: v.string(),
55
+ reason: v.string(),
56
+ purpose: v.optional(v.string()),
57
+ messages: v.optional(v.number()),
58
+ first_at: v.optional(v.nullable(v.string())),
59
+ last_at: v.optional(v.nullable(v.string())),
60
+ read_command: v.optional(v.nullable(v.string())),
61
+ }))),
53
62
  failed: v.optional(v.array(v.strictObject({ signal: v.string(), why: v.string() }))),
54
63
  ...Object.fromEntries(Object.keys(STATUS_SIGNAL_ROWS).map((name) => [listKey(name.slice(7)), v.optional(v.array(statusItem))])),
55
64
  });
@@ -78,9 +87,21 @@ const nextOf = (found, hints) => {
78
87
  const reads = top.flatMap(({ chat, why }) => partial(found, chat) ? [] : because(hints.read(chat, readFrom(found, chat.ref)), why));
79
88
  return [...syncs, ...reads].slice(0, 2);
80
89
  };
81
- const missingOf = (found) => {
90
+ const leftOutOf = (found) => found.outOfScope.filter((one) => one.reason === "purpose");
91
+ const readLine = (found, one, hints) => {
92
+ const step = hints.readChat?.({ ref: one.chat, account: one.account, chatId: one.chatId }, day(found.window.since));
93
+ return step ? hints.line(step) : null;
94
+ };
95
+ /** `12 messages 2026-10-03…2026-11-01`, or that the window holds none. */
96
+ export const heldInWindow = (one) => one.messages && one.firstAt && one.lastAt
97
+ ? `${one.messages} message${one.messages === 1 ? "" : "s"} ${day(one.firstAt)}…${day(one.lastAt)}`
98
+ : "no messages in the window";
99
+ const leftOutWhy = (one, read) => `${one.chat}, purpose ${one.purpose}, not read: ${heldInWindow(one)}` +
100
+ (read ? `; read it: ${read}` : "; add its purpose to --purpose to read it");
101
+ const missingOf = (found, hints) => {
82
102
  const held = found.chats.filter((chat) => partial(found, chat));
83
103
  const [first] = held;
104
+ const others = found.outOfScope.filter((one) => one.reason !== "purpose");
84
105
  return [
85
106
  ...(first
86
107
  ? [
@@ -92,16 +113,20 @@ const missingOf = (found) => {
92
113
  },
93
114
  ]
94
115
  : []),
95
- ...(found.outOfScope.length
116
+ ...(others.length
96
117
  ? [
97
118
  {
98
119
  field: "chats",
99
- why: `${found.outOfScope.length} linked chats were not read here (${[
100
- ...new Set(found.outOfScope.map((one) => one.reason)),
120
+ why: `${others.length} linked chats were not read here (${[
121
+ ...new Set(others.map((one) => one.reason)),
101
122
  ].join(", ")}); memo projects status reads every account`,
102
123
  },
103
124
  ]
104
125
  : []),
126
+ ...leftOutOf(found).map((one) => {
127
+ const read = readLine(found, one, hints);
128
+ return { field: "chats", why: leftOutWhy(one, read), ...(read ? { step: "messages.list" } : {}) };
129
+ }),
105
130
  ...found.failed.map((one) => ({ field: `signal:${one.signal}`, why: one.why })),
106
131
  ];
107
132
  };
@@ -145,11 +170,26 @@ export const projectStatusAnswer = (found, hints) => {
145
170
  attention: weights.get(chat.ref)?.weight ?? 0,
146
171
  why: weights.get(chat.ref)?.why ?? null,
147
172
  })),
148
- ...(found.outOfScope.length ? { outOfScope: found.outOfScope } : {}),
173
+ purposes: found.purposes,
174
+ ...(found.outOfScope.length
175
+ ? {
176
+ outOfScope: found.outOfScope.map((one) => one.reason === "purpose"
177
+ ? {
178
+ chat: one.chat,
179
+ reason: one.reason,
180
+ purpose: one.purpose,
181
+ messages: one.messages,
182
+ firstAt: one.firstAt,
183
+ lastAt: one.lastAt,
184
+ readCommand: readLine(found, one, hints),
185
+ }
186
+ : one),
187
+ }
188
+ : {}),
149
189
  ...(found.failed.length ? { failed: found.failed } : {}),
150
190
  ...lists,
151
191
  });
152
- const missing = missingOf(found);
192
+ const missing = missingOf(found, hints);
153
193
  const complete = found.failed.length === 0 && !found.chats.some((chat) => partial(found, chat));
154
194
  return {
155
195
  item,
@@ -235,8 +275,11 @@ export const projectStatusText = (found, hints) => {
235
275
  const cut = one.data.cut;
236
276
  lines.push(` ${cut ? `${data.child} not read (${cut})` : `${data.key ?? data.child} · ${data.name} · ${data.chats} chats`}`);
237
277
  }
238
- if (found.outOfScope.length)
239
- lines.push(`Not read here: ${found.outOfScope.map((one) => `${one.chat} (${one.reason})`).join(", ")}`);
278
+ const others = found.outOfScope.filter((one) => one.reason !== "purpose");
279
+ if (others.length)
280
+ lines.push(`Not read here: ${others.map((one) => `${one.chat} (${one.reason})`).join(", ")}`);
281
+ for (const one of leftOutOf(found))
282
+ lines.push(`Left out: ${leftOutWhy(one, readLine(found, one, hints))}`);
240
283
  for (const one of found.failed)
241
284
  lines.push(`Signal ${one.signal} failed: ${one.why}`);
242
285
  if (next.length)
@@ -1,7 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { type AppIdentity } from "../../ports/app.js";
3
3
  import { type AnswerOutput } from "../envelope.js";
4
- /** What the schedule command needs of a CLI: who it is and, per command, where it prints and which profile runs. */
5
4
  export interface ScheduleHost {
6
5
  app: AppIdentity;
7
6
  context(command: Command, resolving?: {
@@ -68,6 +68,11 @@ const withWhen = (command) => command
68
68
  .option("--every <duration>", "repeat: 30m, 2h, 1d")
69
69
  .option("--at <HH:MM>", "every day at this local time")
70
70
  .option("--at-time <time>", "one run: 30m, 2d from now, or an ISO time; the schedule removes itself after it");
71
+ /** What the schedule command needs of a CLI: who it is and, per command, where it prints and which profile runs. */
72
+ /** `skill show follow-ups` exists only for messenger CLIs; any other CLI gets a read-only summary it can run. */
73
+ const promptExample = (app) => app.messenger === false
74
+ ? `summarize what came in during the last day: ${app.command} personal search messages 'date>1d' --newest --limit 20`
75
+ : `run the daily follow-up review: ${app.command} skill show follow-ups`;
71
76
  /**
72
77
  * Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
73
78
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
@@ -142,7 +147,7 @@ export const scheduleCommandFor = ({ app, context: contextOf }) => {
142
147
  .description(`start an agent (claude or codex) with a prompt on a timer; it runs with ${app.envPrefix}_READ_ONLY=1, so it reads and drafts and cannot send — its answer is kept and you are notified`)
143
148
  .argument("<name>", "lowercase letters, digits and dashes")
144
149
  .addArgument(new Argument("<harness>", "the agent to start").choices(HARNESSES))
145
- .argument("<prompt>", `what to do, e.g. "run the daily follow-up review: ${app.command} skill show follow-ups"`)
150
+ .argument("<prompt>", `what to do, e.g. "${promptExample(app)}"`)
146
151
  .addOption(new Option("--max-cost <usd>", "claude only: stop a run that costs more than this, in US dollars").argParser(Number))).action(async function (name, harness, prompt) {
147
152
  const { context, system, environment, save } = prepare(this);
148
153
  const options = this.opts();
@@ -1,5 +1,7 @@
1
1
  import type { EmailInput } from "../store/index.js";
2
2
  import type { ParsedMail } from "./parse.js";
3
3
  import type { Listed } from "./source.js";
4
+ /** The Date header, or the server's internal date when the header is missing or unreadable. */
5
+ export declare const sentAtOf: (date: string | null, receivedAt: string) => number;
4
6
  /** What one read email stores of itself, whichever run read it; where it is filed is the caller's. */
5
7
  export declare const parsedEmail: (mail: ParsedMail, item: Listed, header: Record<string, string | boolean>, address: string) => Omit<EmailInput, "mailboxes">;
@@ -1,4 +1,9 @@
1
1
  import { addressOf } from "./attachments.js";
2
+ /** The Date header, or the server's internal date when the header is missing or unreadable. */
3
+ export const sentAtOf = (date, receivedAt) => {
4
+ const sent = date === null ? Number.NaN : Date.parse(date);
5
+ return Number.isNaN(sent) ? Date.parse(receivedAt) : sent;
6
+ };
2
7
  /** What one read email stores of itself, whichever run read it; where it is filed is the caller's. */
3
8
  export const parsedEmail = (mail, item, header, address) => ({
4
9
  externalId: item.messageId,
@@ -7,7 +12,7 @@ export const parsedEmail = (mail, item, header, address) => ({
7
12
  to: mail.to.map(addressOf),
8
13
  cc: mail.cc.map(addressOf),
9
14
  bcc: mail.bcc.map(addressOf),
10
- sentAt: null,
15
+ sentAt: sentAtOf(mail.date, item.receivedAt),
11
16
  receivedAt: Date.parse(item.receivedAt),
12
17
  bodyText: mail.text || null,
13
18
  size: item.size ?? null,
@@ -1,6 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { mailAccountKey } from "./account-key.js";
3
3
  import { addressOf, FETCH_CHUNK, readAttachments } from "./attachments.js";
4
+ import { sentAtOf } from "./email-input.js";
4
5
  import { mailFolders } from "./folders.js";
5
6
  import { mailHeadersOf } from "./headers.js";
6
7
  import { parseMail } from "./parse.js";
@@ -74,7 +75,7 @@ export const retryUnread = async ({ store, source, account, since, max, maxBodyC
74
75
  to: mail.to.map(addressOf),
75
76
  cc: mail.cc.map(addressOf),
76
77
  bcc: mail.bcc.map(addressOf),
77
- sentAt: null,
78
+ sentAt: sentAtOf(mail.date, item.receivedAt),
78
79
  receivedAt: Date.parse(item.receivedAt),
79
80
  bodyText: mail.text || null,
80
81
  size: item.size ?? stored.size,
package/dist/mail/sync.js CHANGED
@@ -24,6 +24,8 @@ const DAY = 86_400_000;
24
24
  const EDGE = 2 * DAY;
25
25
  /** How often a long run saves where it is, so a kill costs at most this much listing again. */
26
26
  const SAVE_EVERY_MS = 30_000;
27
+ /** About 55 runs clear a 276,000-email account; each is a short write that does not hold the sync up. */
28
+ const SENT_AT_BACKFILL_PER_RUN = 5000;
27
29
  const SLICE_SIZE = 50;
28
30
  const SLICE_PAUSE_MS = 100;
29
31
  /** About a minute of listing; the 30-day window of the timer stays below it. */
@@ -682,7 +684,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
682
684
  // Subject and text stay out of the columns search reads, so recorded mail is never a search hit.
683
685
  subject: null,
684
686
  from: headers?.from === undefined ? null : { address: headers.from, name: headers.fromName ?? null },
685
- sentAt: null,
687
+ sentAt: Date.parse(item.receivedAt),
686
688
  receivedAt: Date.parse(item.receivedAt),
687
689
  bodyText: null,
688
690
  size: item.size ?? null,
@@ -816,6 +818,8 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
816
818
  await store.mail.setMailboxes(accountId, [bulkBox], bulk, startedAt);
817
819
  if (!snapshot)
818
820
  await store.sync.setSyncState(key, "mail_folder_scope", scope);
821
+ if (!snapshot)
822
+ await store.mail.backfillSentAt(accountId, SENT_AT_BACKFILL_PER_RUN);
819
823
  await stopIfAborted();
820
824
  const indexed = await index?.(store, key);
821
825
  const coverage = {
@@ -12,6 +12,8 @@ export interface FakeMail {
12
12
  from: FakePerson;
13
13
  to: FakePerson[];
14
14
  cc?: FakePerson[];
15
+ /** The Date header, when it differs from the server's internal date `receivedAt`. */
16
+ date?: string;
15
17
  text?: string;
16
18
  html?: string;
17
19
  listId?: string;
@@ -31,7 +31,7 @@ export const rawOf = (m) => {
31
31
  ...(m.listId ? [`List-Id: News <${m.listId}>`, `List-Unsubscribe: <mailto:leave@${m.listId}>`] : []),
32
32
  `Subject: ${m.subject}`,
33
33
  `Message-ID: <${m.messageId}@example.test>`,
34
- `Date: ${new Date(m.receivedAt).toUTCString()}`,
34
+ `Date: ${m.date ?? new Date(m.receivedAt).toUTCString()}`,
35
35
  "MIME-Version: 1.0",
36
36
  ...(m.extraHeaders ?? []),
37
37
  ];
@@ -6,7 +6,7 @@ export { type ChatCoverage, chatCoverage } from "./chat-coverage.js";
6
6
  export type { ContextEvidenceItem, ContextEvidencePage, ContextEvidenceReader, ContextEvidenceRequest, ContextEvidenceSender, ContextIndex, ContextReadLimits, ContextReadUsage, ContextRecordReader, ContextSource, ScopedContextEvidenceReader, } from "./context-evidence.js";
7
7
  export { contextEvidenceFingerprint, createContextEvidenceReader } from "./context-evidence.js";
8
8
  export { excerpt } from "./excerpt.js";
9
- export { aliasHits, BUILT_IN_PURPOSES, chatSenders, chatTitle, combinedScore, DEFAULT_LIMIT as DEFAULT_LINK_SCAN_LIMIT, DEFAULT_THRESHOLD as DEFAULT_LINK_SCAN_THRESHOLD, emailDomain, type LinkCandidate, type LinkScorer, mailThread, memberChats, memberDialog, PERSON_SCORERS, PERSON_THRESHOLD as PERSON_LINK_SCAN_THRESHOLD, SCAN_BOT, SCORERS, type ScanInput as LinkScanInput, type ScannedProject, type ScannedProposal, type ScanResult as LinkScanResult, type ScorerInput, type Signal as LinkSignal, scanProjectLinks, } from "./link-scorers/index.js";
9
+ export { aliasHits, chatSenders, chatTitle, combinedScore, DEFAULT_LIMIT as DEFAULT_LINK_SCAN_LIMIT, DEFAULT_THRESHOLD as DEFAULT_LINK_SCAN_THRESHOLD, emailDomain, type LinkCandidate, type LinkScorer, mailThread, memberChats, memberDialog, PERSON_SCORERS, PERSON_THRESHOLD as PERSON_LINK_SCAN_THRESHOLD, SCAN_BOT, SCORERS, type ScanInput as LinkScanInput, type ScannedProject, type ScannedProposal, type ScanResult as LinkScanResult, type ScorerInput, type Signal as LinkSignal, scanProjectLinks, } from "./link-scorers/index.js";
10
10
  export type { MediaArchiveOptions, MediaArchivePorts } from "./media-archive.js";
11
11
  export { archiveMedia, checkMediaArchive } from "./media-archive.js";
12
12
  export type { MediaArchiveEntry, MediaIdentity } from "./media-archive-manifest.js";
@@ -21,9 +21,10 @@ export { documentPassage, storedMessageNeighbours, storedNeighbours } from "./pr
21
21
  export type { EvidenceMention, EvidenceMentions } from "./project-gathering-mentions.js";
22
22
  export { findEvidenceMentions } from "./project-gathering-mentions.js";
23
23
  export type { ProjectChatFetched, ProjectGatheringBudgets, ProjectGatheringRequest, ProjectLane, ProjectLaneKind, ProjectLinkedChat, ProjectPerson, ProjectSender, ProjectUnitState, } from "./project-gathering-request.js";
24
- export { DEFAULT_PURPOSES, type MoneyTotal, type ProjectStatus, type ProjectStatusInput, projectStatus, STATUS_CHATS, STATUS_SIGNALS, STATUS_WINDOW, type StatusAttention, type StatusChat, type StatusChatSummary, type StatusContext, type StatusItem, type StatusSignal, } from "./project-status/index.js";
24
+ export { BUILT_IN_PURPOSE_CONFIG, BUILT_IN_PURPOSES, DEFAULT_PURPOSES, LINK_PURPOSE, type PurposeChoice, type PurposeConfig, purposeList, purposeRead, purposesOf, } from "./project-purposes.js";
25
+ export { type MoneyTotal, type OutOfScope, type ProjectStatus, type ProjectStatusInput, projectStatus, STATUS_CHATS, STATUS_SIGNALS, STATUS_WINDOW, type StatusAttention, type StatusChat, type StatusChatSummary, type StatusContext, type StatusItem, type StatusSignal, } from "./project-status/index.js";
25
26
  export { questions } from "./questions.js";
26
- export { canonicalProjectRef, gatherStoredProject, type LinkedChat, linkedChatsOf, type StoredProjectGathering, type StoredProjectInput, } from "./stored-project-gathering.js";
27
+ export { type ChatCount, type ChatCounter, canonicalProjectRef, gatherStoredProject, type LeftOutChat, type LinkedChat, linkedChatsOf, type ProjectMember, type StoredProjectGathering, type StoredProjectInput, storedChatCount, } from "./stored-project-gathering.js";
27
28
  import { type AccountService } from "./account.js";
28
29
  import { type AdminService } from "./admin.js";
29
30
  import { type AdminStatisticsService } from "./admin-statistics.js";
@@ -5,7 +5,7 @@ export { AGENDA_DEFAULTS } from "./agenda.js";
5
5
  export { chatCoverage } from "./chat-coverage.js";
6
6
  export { contextEvidenceFingerprint, createContextEvidenceReader } from "./context-evidence.js";
7
7
  export { excerpt } from "./excerpt.js";
8
- export { aliasHits, BUILT_IN_PURPOSES, chatSenders, chatTitle, combinedScore, DEFAULT_LIMIT as DEFAULT_LINK_SCAN_LIMIT, DEFAULT_THRESHOLD as DEFAULT_LINK_SCAN_THRESHOLD, emailDomain, mailThread, memberChats, memberDialog, PERSON_SCORERS, PERSON_THRESHOLD as PERSON_LINK_SCAN_THRESHOLD, SCAN_BOT, SCORERS, scanProjectLinks, } from "./link-scorers/index.js";
8
+ export { aliasHits, chatSenders, chatTitle, combinedScore, DEFAULT_LIMIT as DEFAULT_LINK_SCAN_LIMIT, DEFAULT_THRESHOLD as DEFAULT_LINK_SCAN_THRESHOLD, emailDomain, mailThread, memberChats, memberDialog, PERSON_SCORERS, PERSON_THRESHOLD as PERSON_LINK_SCAN_THRESHOLD, SCAN_BOT, SCORERS, scanProjectLinks, } from "./link-scorers/index.js";
9
9
  export { archiveMedia, checkMediaArchive } from "./media-archive.js";
10
10
  export { moveMediaArchive } from "./media-archive-move.js";
11
11
  export { downloadImageModel, downloadTextModel, } from "./model-downloads.js";
@@ -13,9 +13,10 @@ export { MONEY_DONE, MONEY_REQUEST, MONEY_WORDS, moneyClassesOf, PROMISE } from
13
13
  export { gatherProjectEvidence } from "./project-gathering.js";
14
14
  export { documentPassage, storedMessageNeighbours, storedNeighbours } from "./project-gathering-around.js";
15
15
  export { findEvidenceMentions } from "./project-gathering-mentions.js";
16
- export { DEFAULT_PURPOSES, projectStatus, STATUS_CHATS, STATUS_SIGNALS, STATUS_WINDOW, } from "./project-status/index.js";
16
+ export { BUILT_IN_PURPOSE_CONFIG, BUILT_IN_PURPOSES, DEFAULT_PURPOSES, LINK_PURPOSE, purposeList, purposeRead, purposesOf, } from "./project-purposes.js";
17
+ export { projectStatus, STATUS_CHATS, STATUS_SIGNALS, STATUS_WINDOW, } from "./project-status/index.js";
17
18
  export { questions } from "./questions.js";
18
- export { canonicalProjectRef, gatherStoredProject, linkedChatsOf, } from "./stored-project-gathering.js";
19
+ export { canonicalProjectRef, gatherStoredProject, linkedChatsOf, storedChatCount, } from "./stored-project-gathering.js";
19
20
  import { accountService } from "./account.js";
20
21
  import { adminService } from "./admin.js";
21
22
  import { adminStatisticsService } from "./admin-statistics.js";
@@ -5,5 +5,5 @@ export { emailDomain } from "./email-domain.js";
5
5
  export { mailThread } from "./mail-thread.js";
6
6
  export { memberChats } from "./member-chats.js";
7
7
  export { memberDialog } from "./member-dialog.js";
8
- export { BUILT_IN_PURPOSES, combinedScore, DEFAULT_LIMIT, DEFAULT_THRESHOLD, PERSON_SCORERS, PERSON_THRESHOLD, SCAN_BOT, SCORERS, type ScanInput, scanProjectLinks, } from "./scan.js";
8
+ export { combinedScore, DEFAULT_LIMIT, DEFAULT_THRESHOLD, PERSON_SCORERS, PERSON_THRESHOLD, SCAN_BOT, SCORERS, type ScanInput, scanProjectLinks, } from "./scan.js";
9
9
  export type { LinkCandidate, LinkScorer, ScannedProject, ScannedProposal, ScanResult, ScorerInput, Signal, } from "./types.js";
@@ -5,4 +5,4 @@ export { emailDomain } from "./email-domain.js";
5
5
  export { mailThread } from "./mail-thread.js";
6
6
  export { memberChats } from "./member-chats.js";
7
7
  export { memberDialog } from "./member-dialog.js";
8
- export { BUILT_IN_PURPOSES, combinedScore, DEFAULT_LIMIT, DEFAULT_THRESHOLD, PERSON_SCORERS, PERSON_THRESHOLD, SCAN_BOT, SCORERS, scanProjectLinks, } from "./scan.js";
8
+ export { combinedScore, DEFAULT_LIMIT, DEFAULT_THRESHOLD, PERSON_SCORERS, PERSON_THRESHOLD, SCAN_BOT, SCORERS, scanProjectLinks, } from "./scan.js";
@@ -10,7 +10,6 @@ export declare const SCAN_BOT: {
10
10
  readonly bot: "memo-scan";
11
11
  readonly kind: "rule";
12
12
  };
13
- export declare const BUILT_IN_PURPOSES: readonly ["work", "sales", "support", "other"];
14
13
  export declare const DEFAULT_THRESHOLD = 0.5;
15
14
  /** A person ↔ person scan keeps the old rule's reach: one weak signal is enough to ask the owner. */
16
15
  export declare const PERSON_THRESHOLD = 0.3;
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { parseReference } from "../../domain/references.js";
3
+ import { BUILT_IN_PURPOSES } from "../project-purposes.js";
3
4
  import { canonicalProjectRef } from "../stored-project-gathering.js";
4
5
  import { aliasHits } from "./alias-hits.js";
5
6
  import { chatSenders } from "./chat-senders.js";
@@ -15,7 +16,6 @@ export const SCORERS = [chatTitle, aliasHits, memberChats, memberDialog, chatSen
15
16
  export const PERSON_SCORERS = [emailDomain];
16
17
  /** The rule bot every scan proposal is written by: only the owner decides its proposals. */
17
18
  export const SCAN_BOT = { bot: "memo-scan", kind: "rule" };
18
- export const BUILT_IN_PURPOSES = ["work", "sales", "support", "other"];
19
19
  export const DEFAULT_THRESHOLD = 0.5;
20
20
  /** A person ↔ person scan keeps the old rule's reach: one weak signal is enough to ask the owner. */
21
21
  export const PERSON_THRESHOLD = 0.3;
@@ -0,0 +1,28 @@
1
+ /** What a chat can be for in a project, when the host's config names none. */
2
+ export declare const BUILT_IN_PURPOSES: readonly string[];
3
+ /** What `projects evidence` and `projects status` read when no purpose is asked for (built in; memo's config may change it). */
4
+ export declare const DEFAULT_PURPOSES: readonly string[];
5
+ /** A link with no purpose is a work chat. */
6
+ export declare const LINK_PURPOSE = "work";
7
+ /** The purposes a host's config lists; core knows only the built-in ones and takes the rest as given. */
8
+ export interface PurposeConfig {
9
+ purposes: readonly string[];
10
+ /** Read when no `--purpose` is given. */
11
+ defaults: readonly string[];
12
+ /** A project's own defaults, by its key or `project:<id>`. */
13
+ projects?: Readonly<Record<string, readonly string[]>>;
14
+ }
15
+ export declare const BUILT_IN_PURPOSE_CONFIG: PurposeConfig;
16
+ /** The purposes one run reads: `all`, or a set. */
17
+ export type PurposeChoice = "all" | ReadonlySet<string>;
18
+ /**
19
+ * The purposes a run reads: the asked ones, each checked against the config's list, else the project's own defaults,
20
+ * else the config's. `all` reads every purpose, a link's unlisted one too.
21
+ */
22
+ export declare const purposesOf: (asked: readonly string[] | undefined, config?: PurposeConfig, project?: {
23
+ ref: string | null;
24
+ key: string | null;
25
+ }) => PurposeChoice;
26
+ export declare const purposeRead: (choice: PurposeChoice, purpose: string | null) => boolean;
27
+ /** `a,b` and repeated flags as one list. */
28
+ export declare const purposeList: (value: string, previous?: string[]) => string[];
@@ -0,0 +1,41 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ /** What a chat can be for in a project, when the host's config names none. */
3
+ export const BUILT_IN_PURPOSES = ["work", "sales", "support", "other"];
4
+ /** What `projects evidence` and `projects status` read when no purpose is asked for (built in; memo's config may change it). */
5
+ export const DEFAULT_PURPOSES = ["work"];
6
+ /** A link with no purpose is a work chat. */
7
+ export const LINK_PURPOSE = "work";
8
+ export const BUILT_IN_PURPOSE_CONFIG = { purposes: BUILT_IN_PURPOSES, defaults: DEFAULT_PURPOSES };
9
+ const unknown = (asked, config) => {
10
+ const wrong = asked.filter((one) => !config.purposes.includes(one));
11
+ if (wrong.length)
12
+ throw new CliError("validation_error", `unknown purpose ${wrong.join(", ")}; the configured ones are ${config.purposes.join(", ")}, or all`, { param: "purpose", purposes: [...config.purposes] });
13
+ };
14
+ /**
15
+ * The purposes a run reads: the asked ones, each checked against the config's list, else the project's own defaults,
16
+ * else the config's. `all` reads every purpose, a link's unlisted one too.
17
+ */
18
+ export const purposesOf = (asked, config = BUILT_IN_PURPOSE_CONFIG, project) => {
19
+ const words = (asked ?? []).map((one) => one.trim()).filter(Boolean);
20
+ if (words.includes("all")) {
21
+ if (words.length > 1)
22
+ throw new CliError("validation_error", "--purpose all reads every purpose; give it alone");
23
+ return "all";
24
+ }
25
+ if (words.length) {
26
+ unknown(words, config);
27
+ return new Set(words);
28
+ }
29
+ const own = (project?.key ? config.projects?.[project.key] : undefined) ??
30
+ (project?.ref ? config.projects?.[project.ref] : undefined);
31
+ return new Set(own ?? config.defaults);
32
+ };
33
+ export const purposeRead = (choice, purpose) => choice === "all" || choice.has(purpose ?? LINK_PURPOSE);
34
+ /** `a,b` and repeated flags as one list. */
35
+ export const purposeList = (value, previous = []) => [
36
+ ...previous,
37
+ ...value
38
+ .split(",")
39
+ .map((one) => one.trim())
40
+ .filter(Boolean),
41
+ ];
@@ -1,4 +1,7 @@
1
+ import type { Id } from "../../domain/models.js";
1
2
  import type { AccountKey, MessageStore } from "../../store/store.js";
3
+ import { type PurposeConfig } from "../project-purposes.js";
4
+ import { type ChatCount, type ChatCounter } from "../stored-project-gathering.js";
2
5
  import { STATUS_SIGNALS } from "./signals/index.js";
3
6
  import type { StatusAttention, StatusChat, StatusItem, StatusProject, StatusSignal } from "./types.js";
4
7
  export type * from "./types.js";
@@ -7,8 +10,6 @@ export { STATUS_SIGNALS };
7
10
  export declare const STATUS_WINDOW = 300;
8
11
  /** Linked chats read at most, as `projects evidence` reads. */
9
12
  export declare const STATUS_CHATS = 16;
10
- /** What `projects status` reads when no purpose is asked for (built in; memo's config may change it). */
11
- export declare const DEFAULT_PURPOSES: readonly string[];
12
13
  export interface ProjectStatusInput {
13
14
  /** An id, key, name or alias, as `canonicalProjectRef` takes it. */
14
15
  project: string;
@@ -18,12 +19,27 @@ export interface ProjectStatusInput {
18
19
  after?: number;
19
20
  asOf?: number;
20
21
  contextQueries?: string[];
21
- /** The chat purposes read; `all` reads every one. Default `DEFAULT_PURPOSES`. */
22
+ /** The chat purposes read; `["all"]` reads every one. Left out, the config's defaults. */
22
23
  purposes?: readonly string[];
24
+ /** The host's purposes; core's built-in ones when left out. */
25
+ purposeConfig?: PurposeConfig;
26
+ /** How many messages a chat left out by purpose holds in the window; the store's own count when left out. */
27
+ count?: ChatCounter;
23
28
  /** Default `STATUS_SIGNALS`; tests pass their own. */
24
29
  signals?: readonly StatusSignal[];
25
30
  }
26
31
  export type StatusChatSummary = Omit<StatusChat, "messages" | "account">;
32
+ /** A linked chat not read; one left out by its purpose says how much it holds in the window. */
33
+ export type OutOfScope = {
34
+ chat: string;
35
+ reason: "other-account" | "linked-chat-limit";
36
+ } | ({
37
+ chat: string;
38
+ reason: "purpose";
39
+ purpose: string;
40
+ account: AccountKey;
41
+ chatId: Id;
42
+ } & ChatCount);
27
43
  export interface MoneyTotal {
28
44
  direction: string;
29
45
  currency: string | null;
@@ -48,11 +64,9 @@ export interface ProjectStatus {
48
64
  money: {
49
65
  open: MoneyTotal[];
50
66
  };
51
- outOfScope: {
52
- chat: string;
53
- reason: "other-account" | "linked-chat-limit" | "purpose";
54
- purpose?: string;
55
- }[];
67
+ outOfScope: OutOfScope[];
68
+ /** The purposes read: `all`, or the list. */
69
+ purposes: "all" | string[];
56
70
  failed: {
57
71
  signal: string;
58
72
  why: string;
@@ -1,7 +1,8 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { formatReference } from "../../domain/references.js";
3
3
  import { chatCoverage } from "../chat-coverage.js";
4
- import { canonicalProjectRef, linkedChatsOf } from "../stored-project-gathering.js";
4
+ import { LINK_PURPOSE, purposeRead, purposesOf } from "../project-purposes.js";
5
+ import { canonicalProjectRef, linkedChatsOf, storedChatCount, } from "../stored-project-gathering.js";
5
6
  import { centsText } from "./signals/amounts.js";
6
7
  import { STATUS_SIGNALS } from "./signals/index.js";
7
8
  export { STATUS_SIGNALS };
@@ -12,8 +13,6 @@ export const STATUS_WINDOW = 300;
12
13
  export const STATUS_CHATS = 16;
13
14
  const CHILD_DEPTH = 2;
14
15
  const CHILDREN = 8;
15
- /** What `projects status` reads when no purpose is asked for (built in; memo's config may change it). */
16
- export const DEFAULT_PURPOSES = ["work"];
17
16
  const projectsCatalog = async (store) => new Map((await store.knowledge.vocabulary({ kind: "project" })).entries.map((entry) => [`project:${entry.id}`, entry]));
18
17
  /** Confirmed `member-of` links from another project; depth two, eight at most, a project seen twice skipped. */
19
18
  const childrenOf = async (store, root, catalog) => {
@@ -92,13 +91,14 @@ export const projectStatus = async (store, input) => {
92
91
  if (!(since < now))
93
92
  throw new CliError("validation_error", "the window must start before it ends");
94
93
  const signals = input.signals ?? STATUS_SIGNALS;
95
- const purposes = new Set(input.purposes ?? DEFAULT_PURPOSES);
96
94
  const catalog = await projectsCatalog(store);
97
95
  const ref = await canonicalProjectRef(store, input.project);
98
96
  const entry = catalog.get(ref);
99
97
  if (!entry)
100
98
  throw new CliError("not_found", "the owner project is unavailable");
101
99
  const root = { ref, key: entry.key, name: entry.name };
100
+ const purposes = purposesOf(input.purposes, input.purposeConfig, root);
101
+ const count = input.count ?? storedChatCount(store);
102
102
  const { children, cut } = await childrenOf(store, root, catalog);
103
103
  const outOfScope = [];
104
104
  const linked = new Map();
@@ -110,10 +110,17 @@ export const projectStatus = async (store, input) => {
110
110
  }
111
111
  const chosen = [];
112
112
  for (const [chat, link] of linked) {
113
- // Links carry no purpose yet; every one is `work`, as a link without one will be.
114
- const purpose = "work";
115
- if (!purposes.has("all") && !purposes.has(purpose))
116
- outOfScope.push({ chat, reason: "purpose", purpose });
113
+ if (!purposeRead(purposes, link.purpose)) {
114
+ const account = { provider: link.provider, account: link.account };
115
+ outOfScope.push({
116
+ chat,
117
+ reason: "purpose",
118
+ purpose: link.purpose ?? LINK_PURPOSE,
119
+ account,
120
+ chatId: link.chat,
121
+ ...(await count(account, link.chat, { since, until: now })),
122
+ });
123
+ }
117
124
  else if (!inAccounts(input.accounts, link.provider, link.account))
118
125
  outOfScope.push({ chat, reason: "other-account" });
119
126
  else if (chosen.length >= STATUS_CHATS)
@@ -134,7 +141,7 @@ export const projectStatus = async (store, input) => {
134
141
  title: coverage?.title ?? null,
135
142
  kind: (await store.chats.chatKind(account, link.chat)) ?? "unknown",
136
143
  role: link.role,
137
- purpose: "work",
144
+ purpose: link.purpose ?? LINK_PURPOSE,
138
145
  project: link.project,
139
146
  coverage: coverage,
140
147
  messages: page.items.filter((message) => Date.parse(message.timestamp) <= now),
@@ -189,6 +196,7 @@ export const projectStatus = async (store, input) => {
189
196
  items,
190
197
  money: { open: totalsOf(items) },
191
198
  outOfScope,
199
+ purposes: purposes === "all" ? "all" : [...purposes],
192
200
  failed,
193
201
  attention,
194
202
  signals: signals.map(({ id, version }) => ({ id, version })),