@wirecat/cli-core 0.549.0 → 0.551.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 (137) hide show
  1. package/CHANGELOG.md +223 -22
  2. package/THIRD_PARTY_NOTICES +41 -0
  3. package/dist/calendar/ics.d.ts +1 -1
  4. package/dist/calendar/ics.js +5 -2
  5. package/dist/calendar/windows-zones.generated.d.ts +1 -0
  6. package/dist/calendar/windows-zones.generated.js +146 -0
  7. package/dist/cli/compact.js +2 -0
  8. package/dist/cli/index.d.ts +3 -0
  9. package/dist/cli/index.js +3 -0
  10. package/dist/cli/meeting-findings-input.js +4 -3
  11. package/dist/cli/messenger/backfill-command.js +28 -1
  12. package/dist/cli/messenger/context.js +1 -0
  13. package/dist/cli/messenger/history-gaps.js +4 -4
  14. package/dist/cli/messenger/mcp-command.js +1 -0
  15. package/dist/cli/messenger/messages-delete-command.js +7 -8
  16. package/dist/cli/messenger/projects-command.js +47 -2
  17. package/dist/cli/payment-query.d.ts +8 -0
  18. package/dist/cli/payment-query.js +27 -0
  19. package/dist/cli/project-status-rows.d.ts +8 -0
  20. package/dist/cli/project-status-rows.js +102 -0
  21. package/dist/cli/project-status.d.ts +132 -0
  22. package/dist/cli/project-status.js +248 -0
  23. package/dist/cli/steps.js +4 -1
  24. package/dist/domain/knowledge.d.ts +1 -1
  25. package/dist/domain/knowledge.js +1 -1
  26. package/dist/domain/status-words.d.ts +3 -0
  27. package/dist/domain/status-words.js +44 -0
  28. package/dist/entities/fact.d.ts +12 -1
  29. package/dist/entities/fact.js +13 -0
  30. package/dist/entities/index.d.ts +18 -2
  31. package/dist/entities/meeting-findings.d.ts +1 -1
  32. package/dist/entities/message-transfer.d.ts +16 -0
  33. package/dist/entities/message-transfer.js +6 -0
  34. package/dist/errors/reasons.js +1 -0
  35. package/dist/mail/partial.d.ts +9 -3
  36. package/dist/mail/partial.js +35 -10
  37. package/dist/mail/source.d.ts +7 -1
  38. package/dist/mail/sync.js +1 -1
  39. package/dist/mcp/app-server.js +1 -0
  40. package/dist/mcp/http/oauth.d.ts +2 -1
  41. package/dist/mcp/http/oauth.js +2 -2
  42. package/dist/mcp/http/serve.d.ts +2 -0
  43. package/dist/mcp/http/serve.js +2 -1
  44. package/dist/mcp/server.js +1 -0
  45. package/dist/mcp/surface.js +1 -1
  46. package/dist/meetings/findings-rules.d.ts +14 -3
  47. package/dist/meetings/findings-rules.js +16 -2
  48. package/dist/meetings/index.d.ts +3 -2
  49. package/dist/meetings/index.js +2 -1
  50. package/dist/meetings/meeting-context.d.ts +2 -3
  51. package/dist/meetings/meeting-context.js +3 -4
  52. package/dist/meetings/payment.d.ts +27 -0
  53. package/dist/meetings/payment.js +81 -0
  54. package/dist/services/archive.d.ts +13 -0
  55. package/dist/services/archive.js +56 -5
  56. package/dist/services/deps.d.ts +3 -0
  57. package/dist/services/history-limits.d.ts +2 -0
  58. package/dist/services/index.d.ts +4 -1
  59. package/dist/services/index.js +4 -1
  60. package/dist/services/job-query.d.ts +5 -2
  61. package/dist/services/job-query.js +5 -2
  62. package/dist/services/link-scorers/alias-hits.d.ts +7 -0
  63. package/dist/services/link-scorers/alias-hits.js +54 -0
  64. package/dist/services/link-scorers/chat-senders.d.ts +6 -0
  65. package/dist/services/link-scorers/chat-senders.js +56 -0
  66. package/dist/services/link-scorers/chat-title.d.ts +6 -0
  67. package/dist/services/link-scorers/chat-title.js +44 -0
  68. package/dist/services/link-scorers/email-domain.d.ts +6 -0
  69. package/dist/services/link-scorers/email-domain.js +64 -0
  70. package/dist/services/link-scorers/index.d.ts +9 -0
  71. package/dist/services/link-scorers/index.js +8 -0
  72. package/dist/services/link-scorers/mail-thread.d.ts +3 -0
  73. package/dist/services/link-scorers/mail-thread.js +74 -0
  74. package/dist/services/link-scorers/member-chats.d.ts +3 -0
  75. package/dist/services/link-scorers/member-chats.js +40 -0
  76. package/dist/services/link-scorers/member-dialog.d.ts +3 -0
  77. package/dist/services/link-scorers/member-dialog.js +34 -0
  78. package/dist/services/link-scorers/scan.d.ts +41 -0
  79. package/dist/services/link-scorers/scan.js +244 -0
  80. package/dist/services/link-scorers/shared.d.ts +26 -0
  81. package/dist/services/link-scorers/shared.js +48 -0
  82. package/dist/services/link-scorers/types.d.ts +77 -0
  83. package/dist/services/link-scorers/types.js +1 -0
  84. package/dist/services/project-status/index.d.ts +71 -0
  85. package/dist/services/project-status/index.js +196 -0
  86. package/dist/services/project-status/items.d.ts +10 -0
  87. package/dist/services/project-status/items.js +23 -0
  88. package/dist/services/project-status/signals/amounts.d.ts +20 -0
  89. package/dist/services/project-status/signals/amounts.js +91 -0
  90. package/dist/services/project-status/signals/children.d.ts +13 -0
  91. package/dist/services/project-status/signals/children.js +28 -0
  92. package/dist/services/project-status/signals/coverage.d.ts +13 -0
  93. package/dist/services/project-status/signals/coverage.js +30 -0
  94. package/dist/services/project-status/signals/index.d.ts +3 -0
  95. package/dist/services/project-status/signals/index.js +21 -0
  96. package/dist/services/project-status/signals/last-message.d.ts +4 -0
  97. package/dist/services/project-status/signals/last-message.js +22 -0
  98. package/dist/services/project-status/signals/payments.d.ts +9 -0
  99. package/dist/services/project-status/signals/payments.js +24 -0
  100. package/dist/services/project-status/signals/promises.d.ts +17 -0
  101. package/dist/services/project-status/signals/promises.js +109 -0
  102. package/dist/services/project-status/signals/questions.d.ts +8 -0
  103. package/dist/services/project-status/signals/questions.js +38 -0
  104. package/dist/services/project-status/signals/reply-next.d.ts +19 -0
  105. package/dist/services/project-status/signals/reply-next.js +75 -0
  106. package/dist/services/project-status/signals/topics.d.ts +10 -0
  107. package/dist/services/project-status/signals/topics.js +50 -0
  108. package/dist/services/project-status/types.d.ts +84 -0
  109. package/dist/services/project-status/types.js +1 -0
  110. package/dist/services/proposals/index.d.ts +1 -0
  111. package/dist/services/proposals/index.js +1 -0
  112. package/dist/services/proposals/kinds/findings.d.ts +3 -0
  113. package/dist/services/proposals/kinds/findings.js +8 -1
  114. package/dist/store/decisions.d.ts +5 -1
  115. package/dist/store/decisions.js +5 -1
  116. package/dist/store/evidence-findings.js +1 -0
  117. package/dist/store/index.d.ts +2 -1
  118. package/dist/store/index.js +1 -0
  119. package/dist/store/project-transfer/request.d.ts +1 -1
  120. package/dist/store/project-transfer/schema.d.ts +2 -2
  121. package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
  122. package/dist/store/sqlite/email-locations.d.ts +22 -1
  123. package/dist/store/sqlite/email-locations.js +29 -2
  124. package/dist/store/sqlite/emails.d.ts +3 -7
  125. package/dist/store/sqlite/emails.js +4 -2
  126. package/dist/store/sqlite/evidence-findings.js +5 -1
  127. package/dist/store/sqlite/facts.d.ts +13 -0
  128. package/dist/store/sqlite/facts.js +64 -6
  129. package/dist/store/sqlite/findings-core.js +14 -1
  130. package/dist/store/sqlite/knowledge.d.ts +16 -2
  131. package/dist/store/sqlite/knowledge.js +1 -0
  132. package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
  133. package/dist/store/sqlite/relation-proposals.d.ts +29 -2
  134. package/dist/store/sqlite/relation-proposals.js +75 -4
  135. package/dist/store/store.d.ts +1 -0
  136. package/package.json +3 -3
  137. package/parity.json +11 -0
@@ -6,15 +6,29 @@ import { parseMail } from "./parse.js";
6
6
  export const PARTIAL = "partial";
7
7
  /** How many partial emails one sync reads whole at most; the rest wait for the next run. */
8
8
  export const PARTIAL_PER_RUN = 100;
9
+ /** Failed reads after which a partial email is given up on (`partial.stuck`) and stays as it is. */
10
+ export const PARTIAL_TRIES = 3;
9
11
  /**
10
12
  * Reads whole the emails a server search stored from their first bytes, so the archive holds their attachments too.
11
- * Sync never reads stored mail again, so without this step a partial email would stay partial. Gmail's are found by
12
- * their id; elsewhere at a stored location, trusted only when the bytes read there carry the stored id.
13
+ * Sync never reads stored mail again, so without this step a partial email would stay partial. A source with `find`
14
+ * finds them by id; elsewhere they are read at their newest stored location under its UIDVALIDITY, trusted only when
15
+ * `identify` gives the stored id for the bytes read there. Each failed read is counted, and after `PARTIAL_TRIES`, or
16
+ * at once when the place holds another message, the email is left partial for good.
13
17
  */
14
18
  export const completePartial = async ({ store, source, accountId, address, folders, maxBodyChars, maxAttachmentChars, loadEngine, now, take, signal, }) => {
15
- const { emails, total } = await store.mail.partialEmails(accountId, PARTIAL_PER_RUN);
19
+ const { emails, total, stuck } = await store.mail.partialEmails(accountId, PARTIAL_PER_RUN);
16
20
  if (emails.length === 0)
17
- return { completed: 0, left: 0 };
21
+ return { completed: 0, left: 0, stuck };
22
+ let given = 0;
23
+ // A failed read is counted, so an email the server no longer gives is not read whole on every sync forever.
24
+ const failed = async (email, forGood = false) => {
25
+ const mark = (email.metadata?.[PARTIAL] ?? {});
26
+ const tries = Number(mark.tries ?? 0) + 1;
27
+ const gaveUp = forGood || tries >= PARTIAL_TRIES;
28
+ await store.mail.markPartial(accountId, email.externalId, { ...mark, tries, ...(gaveUp ? { stuck: true } : {}) });
29
+ if (gaveUp)
30
+ given++;
31
+ };
18
32
  const byId = new Map(emails.map((email) => [email.externalId, email]));
19
33
  const items = new Map();
20
34
  if (source.find) {
@@ -23,21 +37,27 @@ export const completePartial = async ({ store, source, accountId, address, folde
23
37
  if (left.length === 0)
24
38
  break;
25
39
  if (take?.())
26
- return { completed: 0, left: total };
40
+ return { completed: 0, left: total, stuck };
27
41
  for (const found of await source.find(left, folder)) {
28
42
  const email = byId.get(found.messageId);
29
43
  if (email && !items.has(email))
30
44
  items.set(email, found);
31
45
  }
32
46
  }
47
+ for (const email of emails)
48
+ if (!items.has(email))
49
+ await failed(email);
33
50
  }
34
51
  else if (source.identify)
35
52
  for (const email of emails) {
36
53
  const place = email.locations[0];
37
- if (place)
54
+ if (!place)
55
+ await failed(email, true);
56
+ else
38
57
  items.set(email, {
39
58
  uid: String(place.uid),
40
59
  folder: place.folder,
60
+ validity: place.uidvalidity,
41
61
  receivedAt: new Date(email.receivedAt ?? 0).toISOString(),
42
62
  });
43
63
  }
@@ -58,11 +78,15 @@ export const completePartial = async ({ store, source, accountId, address, folde
58
78
  const attachments = [];
59
79
  for (const [index, [email, item]] of chunk.entries()) {
60
80
  const raw = raws[index];
61
- if (!(raw instanceof Uint8Array))
81
+ if (!(raw instanceof Uint8Array)) {
82
+ await failed(email);
62
83
  continue;
63
- // A UID means another message after a UIDVALIDITY change; only the stored id proves it is this one.
64
- if (!source.find && source.identify?.(raw, item).messageId !== email.externalId)
84
+ }
85
+ // Only the stored id proves the bytes at a stored UID are this email; another one there will not change back.
86
+ if (!source.find && source.identify?.(raw, item).messageId !== email.externalId) {
87
+ await failed(email, true);
65
88
  continue;
89
+ }
66
90
  try {
67
91
  const mail = await parseMail(raw, maxBodyChars);
68
92
  const files = await readAttachments(mail, email.externalId, { loadEngine, maxAttachmentChars, at: now, ...(signal ? { signal } : {}) }, attachments);
@@ -87,8 +111,9 @@ export const completePartial = async ({ store, source, accountId, address, folde
87
111
  catch {
88
112
  if (signal?.aborted)
89
113
  break;
114
+ await failed(email);
90
115
  }
91
116
  }
92
117
  }
93
- return { completed, left: total - completed };
118
+ return { completed, left: total - completed - given, stuck: stuck + given };
94
119
  };
@@ -24,7 +24,13 @@ export interface Listed {
24
24
  headers?: MailHeaders;
25
25
  }
26
26
  /** A message as `quick` lists it: everything but the ids, which a source without a cheap one reads from the raw bytes. */
27
- export type Quick = Omit<Listed, "messageId" | "threadId">;
27
+ export type Quick = Omit<Listed, "messageId" | "threadId"> & {
28
+ /**
29
+ * The folder's UIDVALIDITY the UID was stored under. `fetchRaw` reads nothing for it when the folder's differs, and
30
+ * `identify` works the ids out under it.
31
+ */
32
+ validity?: string;
33
+ };
28
34
  export interface Searched {
29
35
  /** Absent when the server does not say; then no UID is trusted across runs. */
30
36
  validity?: string;
package/dist/mail/sync.js CHANGED
@@ -54,7 +54,7 @@ export const syncMail = async (source, store, options) => {
54
54
  ...(options.budget ? { take: () => options.budget?.take() ?? null } : {}),
55
55
  ...(options.signal ? { signal: options.signal } : {}),
56
56
  });
57
- return partial.completed || partial.left ? { ...result, partial } : result;
57
+ return partial.completed || partial.left || partial.stuck ? { ...result, partial } : result;
58
58
  };
59
59
  const syncRounds = async (source, store, options) => {
60
60
  const { budget, index } = options;
@@ -144,6 +144,7 @@ export const serveAppOverHttp = async (command, context, options, http) => {
144
144
  const listening = await serveOverHttp(build, {
145
145
  ...http,
146
146
  appName: app.command,
147
+ reaches: `your ${options.name}`,
147
148
  onCode: (code, expires) => context.renderer.note(context.env[mcpHttpMarker(app.envPrefix)] === undefined
148
149
  ? `login code for a new browser app: ${code} (until ${expires.toTimeString().slice(0, 5)}; a new one after each login)`
149
150
  : `a new login code for a browser app: \`${app.command} ${context.settings.profile} mcp server status\``),
@@ -14,11 +14,12 @@ export interface OwnerLogin {
14
14
  * the browser apps need dynamic registration — but nobody gets a token without the one-time code
15
15
  * printed in the owner's terminal. Five wrong codes delay further attempts for thirty seconds.
16
16
  */
17
- export declare const ownerLogin: ({ issuer, resource, file, appName, onCode, now, }: {
17
+ export declare const ownerLogin: ({ issuer, resource, file, appName, reaches, onCode, now, }: {
18
18
  issuer: URL;
19
19
  resource: URL;
20
20
  file: string;
21
21
  appName: string;
22
+ reaches?: string;
22
23
  /** The code for the next login, for the owner's eyes only: their terminal, or the server's status file. */
23
24
  onCode: (code: string, expires: Date) => void;
24
25
  now?: () => number;
@@ -51,7 +51,7 @@ const redirectAllowed = (uri) => {
51
51
  * the browser apps need dynamic registration — but nobody gets a token without the one-time code
52
52
  * printed in the owner's terminal. Five wrong codes delay further attempts for thirty seconds.
53
53
  */
54
- export const ownerLogin = ({ issuer, resource, file, appName, onCode, now = () => Date.now(), }) => {
54
+ export const ownerLogin = ({ issuer, resource, file, appName, reaches = `your ${appName} data`, onCode, now = () => Date.now(), }) => {
55
55
  const store = tokenFile(file);
56
56
  const codes = new Map();
57
57
  let login = { value: "", expires: 0 };
@@ -153,7 +153,7 @@ export const ownerLogin = ({ issuer, resource, file, appName, onCode, now = () =
153
153
  .join("");
154
154
  const who = escapeHtml(client.name ?? "an application");
155
155
  const target = new URL(params.get("redirect_uri") ?? "");
156
- const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(appName)} login</title><style>body{font:16px system-ui,sans-serif;max-width:28rem;margin:3rem auto;padding:0 1rem;color:#1d2430}input[type=text]{font:20px ui-monospace,monospace;letter-spacing:.1em;width:100%;padding:.5rem;margin:.5rem 0 1rem;box-sizing:border-box}button{font:inherit;padding:.5rem 1rem}.error{color:#b42318}</style></head><body><h1>${escapeHtml(appName)}</h1><p>An app that calls itself <strong>${who}</strong> asks to reach your messages through <code>${escapeHtml(appName)} mcp --http</code>.</p><p>After you allow it, the login goes to <strong>${escapeHtml(target.host)}</strong>. Allow only if that is the app you are connecting, such as chatgpt.com or claude.ai.</p><p>Type the code shown in WireCat Desktop, or in the terminal where the server runs. If you did not start this login, close this page.</p>${message ? `<p class="error">${escapeHtml(message)}</p>` : ""}<form method="post" action="${escapeHtml(`${base}/authorize`)}">${hidden}<label>Login code<input type="text" name="login_code" autocomplete="off" autofocus required></label><button type="submit">Allow</button></form></body></html>`;
156
+ const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(appName)} login</title><style>body{font:16px system-ui,sans-serif;max-width:28rem;margin:3rem auto;padding:0 1rem;color:#1d2430}input[type=text]{font:20px ui-monospace,monospace;letter-spacing:.1em;width:100%;padding:.5rem;margin:.5rem 0 1rem;box-sizing:border-box}button{font:inherit;padding:.5rem 1rem}.error{color:#b42318}</style></head><body><h1>${escapeHtml(appName)}</h1><p>An app that calls itself <strong>${who}</strong> asks to reach ${escapeHtml(reaches)} through <code>${escapeHtml(appName)} mcp serve --http</code>.</p><p>After you allow it, the login goes to <strong>${escapeHtml(target.host)}</strong>. Allow only if that is the app you are connecting, such as chatgpt.com or claude.ai.</p><p>Type the code shown in WireCat Desktop, or in the terminal where the server runs. If you did not start this login, close this page.</p>${message ? `<p class="error">${escapeHtml(message)}</p>` : ""}<form method="post" action="${escapeHtml(`${base}/authorize`)}">${hidden}<label>Login code<input type="text" name="login_code" autocomplete="off" autofocus required></label><button type="submit">Allow</button></form></body></html>`;
157
157
  return new Response(html, {
158
158
  status: message ? 400 : 200,
159
159
  headers: {
@@ -13,6 +13,8 @@ export interface HttpOptions {
13
13
  /** Where the hashes of issued tokens and registered clients are kept. */
14
14
  tokenFile: string;
15
15
  appName: string;
16
+ /** What the browser login page says an app asks to reach: `your messages`, `your Google Calendar`. */
17
+ reaches?: string;
16
18
  onCode: (code: string, expires: Date) => void;
17
19
  onError?: (error: Error) => void;
18
20
  now?: () => number;
@@ -91,7 +91,7 @@ const toolsIn = async (request) => {
91
91
  * the owner's browser cannot reach the port through a rebound DNS name.
92
92
  */
93
93
  export const serveOverHttp = async (build, options) => {
94
- const { publicUrl, port, tokenFile, appName, onCode, onError, now, lockFile, version } = options;
94
+ const { publicUrl, port, tokenFile, appName, reaches, onCode, onError, now, lockFile, version } = options;
95
95
  const lock = lockFile
96
96
  ? httpLock(lockFile, {
97
97
  pid: process.pid,
@@ -114,6 +114,7 @@ export const serveOverHttp = async (build, options) => {
114
114
  resource,
115
115
  file: tokenFile,
116
116
  appName,
117
+ ...(reaches ? { reaches } : {}),
117
118
  onCode: (code, expires) => {
118
119
  lock?.update({ loginCode: code, loginCodeExpiresAt: expires.toISOString() });
119
120
  onCode(code, expires);
@@ -417,6 +417,7 @@ export const serveOverHttpUntilStopped = async (command, context, messenger, opt
417
417
  const listening = await serveOverHttp(build, {
418
418
  ...http,
419
419
  appName,
420
+ reaches: "your messages",
420
421
  // In the background its log is a file; the code goes only to the lock that `mcp server status` reads.
421
422
  onCode: (code, expires) => context.renderer.note(context.env[mcpHttpMarker(appEnvPrefix)] === undefined
422
423
  ? `login code for a new browser app: ${code} (until ${expires.toTimeString().slice(0, 5)}; a new one after each login)`
@@ -46,7 +46,7 @@ const runnableOnly = (result, offered) => {
46
46
  const body = result.structuredContent;
47
47
  if (result.isError || !Array.isArray(body?.next))
48
48
  return result;
49
- const next = body.next.filter((step) => offered(step.command));
49
+ const next = body.next.filter((step) => step.command === undefined || offered(step.command));
50
50
  const { next: _, ...rest } = body;
51
51
  const kept = next.length > 0 ? { ...rest, next } : rest;
52
52
  return {
@@ -1,4 +1,4 @@
1
- import { MEETING_FACT_TYPES as FACT_TYPES } from "./meeting-context.js";
1
+ import { FACT_TYPES, type Payment } from "./payment.js";
2
2
  declare const TASK_TYPES: readonly ["bug", "feature", "chore"];
3
3
  export interface FindingEvidence {
4
4
  reference: string;
@@ -14,9 +14,18 @@ interface FindingBase {
14
14
  }
15
15
  export interface FactFinding extends FindingBase {
16
16
  kind: "fact";
17
- type: (typeof FACT_TYPES)[number];
17
+ type: Exclude<(typeof FACT_TYPES)[number], "payment">;
18
18
  statement: string;
19
19
  }
20
+ /** A payment an agent read; confirming it records a `payment` fact. A paid one supersedes the expected one. */
21
+ export interface PaymentFinding extends FindingBase {
22
+ kind: "payment";
23
+ /** The fact's full sentence; without one, the payment's title. */
24
+ statement?: string;
25
+ payment: Payment;
26
+ /** The `fact:` of the expected payment this one replaces, once it is paid or cancelled. */
27
+ supersedes?: string;
28
+ }
20
29
  export interface TaskFinding extends FindingBase {
21
30
  kind: "task";
22
31
  type: (typeof TASK_TYPES)[number];
@@ -28,7 +37,7 @@ export interface TaskFinding extends FindingBase {
28
37
  assignee?: string;
29
38
  assigneeEvidence?: string[];
30
39
  }
31
- export type Finding = FactFinding | TaskFinding;
40
+ export type Finding = FactFinding | TaskFinding | PaymentFinding;
32
41
  export interface FindingsInput {
33
42
  schemaVersion: 1;
34
43
  key: string;
@@ -59,6 +68,8 @@ export interface FindingsRules {
59
68
  reference: (raw: string) => string;
60
69
  /** A fact finding may name no project, and any finding may name a subject. */
61
70
  anySubject?: boolean;
71
+ /** Payment findings are staged on a project's evidence, not from a meeting. */
72
+ payments?: boolean;
62
73
  }
63
74
  export declare const validateFindings: (input: FindingsInput, rules: FindingsRules) => FindingsInput;
64
75
  export declare const findingFingerprint: ({ id: _id, ...finding }: Finding) => string;
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { CliError } from "@wirecat/cli-base";
3
- import { MEETING_FACT_TYPES as FACT_TYPES } from "./meeting-context.js";
3
+ import { FACT_TYPES, validatePayment } from "./payment.js";
4
4
  import { meetingTimestamp } from "./timestamp.js";
5
5
  const TASK_TYPES = ["bug", "feature", "chore"];
6
6
  const invalid = (message) => {
@@ -69,12 +69,26 @@ export const validateFindings = (input, rules) => {
69
69
  };
70
70
  if (finding.kind === "fact") {
71
71
  fields(finding, ["id", "project", "subject", "evidence", "kind", "type", "statement"], rules.singular);
72
+ if (finding.type === "payment")
73
+ return invalid("a payment is a finding of kind payment");
72
74
  if (!FACT_TYPES.includes(finding.type))
73
75
  return invalid("unknown fact finding type");
74
76
  return { ...base, kind: "fact", type: finding.type, statement: text(finding.statement, 2000) };
75
77
  }
78
+ if (finding.kind === "payment" && rules.payments) {
79
+ fields(finding, ["id", "project", "subject", "evidence", "kind", "statement", "payment", "supersedes"], rules.singular);
80
+ if (finding.supersedes !== undefined && !/^fact:[1-9]\d*$/.test(String(finding.supersedes)))
81
+ return invalid("a payment finding supersedes a fact: reference");
82
+ return {
83
+ ...base,
84
+ kind: "payment",
85
+ ...(finding.statement === undefined ? {} : { statement: text(finding.statement, 2000) }),
86
+ payment: validatePayment(finding.payment),
87
+ ...(finding.supersedes === undefined ? {} : { supersedes: finding.supersedes }),
88
+ };
89
+ }
76
90
  if (finding.kind !== "task")
77
- return invalid("a finding is a fact or task");
91
+ return invalid(`a finding is a fact${rules.payments ? ", payment" : ""} or task`);
78
92
  fields(finding, [
79
93
  "id",
80
94
  "project",
@@ -9,17 +9,18 @@ export { entryInWindow, eventChange } from "./event-store.js";
9
9
  export { linkEvent } from "./events.js";
10
10
  export { exportMeeting, type MeetingExport, type MeetingExportFormat, type MeetingExportOptions, serializeVtt, } from "./export.js";
11
11
  export { type FindingEvidence, type FindingOutcome, findingHash, MEETING_FINDINGS_KIND, type MeetingFactFinding, type MeetingFinding, type MeetingFindingsInput, type MeetingFindingsReceipt, type MeetingTaskFinding, meetingFindingFingerprint, validateMeetingFindings, } from "./findings.js";
12
- export { type Finding, type FindingsInput, type FindingsReceipt, type FindingsRules, findingFingerprint, validateFindings, } from "./findings-rules.js";
12
+ export { type Finding, type FindingsInput, type FindingsReceipt, type FindingsRules, findingFingerprint, type PaymentFinding, validateFindings, } from "./findings-rules.js";
13
13
  export { liftMeetingFields, MEETING_HIT_QUERY, meetingWordsQuery, parseMeetingQuery } from "./hit-query.js";
14
14
  export { type ImportFile, importFiles } from "./import.js";
15
15
  export { MEETING_ISSUES, MEETING_QUERY, type MeetingIssue, type MeetingItem, type MeetingOwner, meeting, meetingIssuesOf, meetingItem, } from "./item.js";
16
16
  export { joinKeyOf, joinUrlWithoutQuery } from "./join-key.js";
17
17
  export { CHAT_MESSAGES_READ, type MeetingChatLine, type MeetingChatLinesInput, type MeetingChatLinesStore, type MeetingLink, } from "./links.js";
18
- export { createMeetingContext, MEETING_FACT_TYPES, type MeetingContextAction, type MeetingContextChange, type MeetingContextOperation, type MeetingContextPorts, type MeetingContextReceipt, validateMeetingContextChange, } from "./meeting-context.js";
18
+ export { createMeetingContext, type MeetingContextAction, type MeetingContextChange, type MeetingContextOperation, type MeetingContextPorts, type MeetingContextReceipt, validateMeetingContextChange, } from "./meeting-context.js";
19
19
  export { createMeetingManagement, dispatchMeetingMutation, type ManagedMeeting, type ManagedMeetingChange, type MeetingManagement, type MeetingManagementPorts, type MeetingMutationOptions, type MeetingMutationProposal, type MeetingMutationReceipt, type MeetingOperationOptions, type MeetingProviderContext, validateMeetingChange, } from "./meeting-management.js";
20
20
  export type { Attachment, ChatLine, Event, EventSeries, JsonValue, Meeting, MeetingSeries, Metadata, Occurrence, Participant, RecordTimes, SourceChatLine, SourceMeeting, Summary, SummaryContent, Transcript, TranscriptLine, TranscriptRow, } from "./model.js";
21
21
  export { type MeetingParticipantCandidate, type MeetingParticipantPage, type MeetingParticipantReader, type MeetingParticipantSearchOptions, type MeetingParticipantSearchStore, normalizeMeetingParticipantQuery, validateMeetingParticipantSearch, } from "./participant-search.js";
22
22
  export { type MeetingContextCue, type MeetingHitContextOptions, type MeetingHitContextPacket, type MeetingPassageOutputFormat, type MeetingPassageReader, type MeetingPassageRequest, type MeetingPassageResult, meetingHitContextLimits, validateMeetingPassage, } from "./passage.js";
23
+ export { FACT_TYPES, PAYMENT_DIRECTIONS, PAYMENT_STATES, type Payment, paymentTitle, validatePayment, } from "./payment.js";
23
24
  export { type MeetingPreparationContext, type MeetingPreparationContextRequest, type MeetingPreparationContextStore, type PreparationPage, validateMeetingPreparationContext, } from "./preparation-context.js";
24
25
  export type { MeetingPreparationFact, MeetingPreparationKnowledgeRequest, MeetingPreparationKnowledgeStore, } from "./preparation-knowledge.js";
25
26
  export type { MeetingPreparationNote, MeetingPreparationNotesSelection, MeetingPreparationNotesStore, } from "./preparation-notes.js";
@@ -12,10 +12,11 @@ export { importFiles } from "./import.js";
12
12
  export { MEETING_ISSUES, MEETING_QUERY, meeting, meetingIssuesOf, meetingItem, } from "./item.js";
13
13
  export { joinKeyOf, joinUrlWithoutQuery } from "./join-key.js";
14
14
  export { CHAT_MESSAGES_READ, } from "./links.js";
15
- export { createMeetingContext, MEETING_FACT_TYPES, validateMeetingContextChange, } from "./meeting-context.js";
15
+ export { createMeetingContext, validateMeetingContextChange, } from "./meeting-context.js";
16
16
  export { createMeetingManagement, dispatchMeetingMutation, validateMeetingChange, } from "./meeting-management.js";
17
17
  export { normalizeMeetingParticipantQuery, validateMeetingParticipantSearch, } from "./participant-search.js";
18
18
  export { meetingHitContextLimits, validateMeetingPassage, } from "./passage.js";
19
+ export { FACT_TYPES, PAYMENT_DIRECTIONS, PAYMENT_STATES, paymentTitle, validatePayment, } from "./payment.js";
19
20
  export { validateMeetingPreparationContext, } from "./preparation-context.js";
20
21
  export { readMeetingProjectEvidence } from "./project-evidence.js";
21
22
  export { meetingProjectEvidence, meetingProjectEvidenceAnswer, } from "./project-evidence-answer.js";
@@ -1,7 +1,6 @@
1
1
  import { type MeetingMutationOptions } from "./meeting-management.js";
2
+ import { FACT_TYPES } from "./payment.js";
2
3
  import { type MeetingProviderScope } from "./provider.js";
3
- /** Mirrors the store's fact types; the store checks the type again when it writes. */
4
- export declare const MEETING_FACT_TYPES: readonly ["fact", "statement", "question", "decision", "promise"];
5
4
  export type MeetingContextChange = {
6
5
  action: "note.add";
7
6
  text: string;
@@ -19,7 +18,7 @@ export type MeetingContextChange = {
19
18
  } | {
20
19
  action: "fact.add";
21
20
  statement: string;
22
- type: (typeof MEETING_FACT_TYPES)[number];
21
+ type: (typeof FACT_TYPES)[number];
23
22
  scope: "personal" | "work";
24
23
  evidence: readonly string[];
25
24
  subject?: string;
@@ -1,8 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { dispatchMeetingMutation } from "./meeting-management.js";
3
+ import { FACT_TYPES } from "./payment.js";
3
4
  import { snapshotMeetingScope } from "./provider.js";
4
- /** Mirrors the store's fact types; the store checks the type again when it writes. */
5
- export const MEETING_FACT_TYPES = ["fact", "statement", "question", "decision", "promise"];
6
5
  const id = (value) => {
7
6
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1)
8
7
  throw new CliError("validation_error", "meeting context identifiers must be positive integers");
@@ -63,8 +62,8 @@ export const validateMeetingContextChange = (change) => {
63
62
  });
64
63
  }
65
64
  case "fact.add": {
66
- if (!MEETING_FACT_TYPES.includes(change.type) || !["personal", "work"].includes(change.scope))
67
- throw new CliError("validation_error", `fact type (${MEETING_FACT_TYPES.join(", ")}) and explicit personal/work scope are required`);
65
+ if (!FACT_TYPES.includes(change.type) || !["personal", "work"].includes(change.scope))
66
+ throw new CliError("validation_error", `fact type (${FACT_TYPES.join(", ")}) and explicit personal/work scope are required`);
68
67
  return Object.freeze({
69
68
  action: change.action,
70
69
  statement: text(change.statement, 20_000),
@@ -0,0 +1,27 @@
1
+ /** Every fact type the store takes; findings and meeting changes check against the same list. */
2
+ export declare const FACT_TYPES: readonly ["fact", "statement", "question", "decision", "promise", "payment"];
3
+ export declare const PAYMENT_DIRECTIONS: readonly ["to_us", "from_us"];
4
+ export declare const PAYMENT_STATES: readonly ["expected", "paid", "cancelled"];
5
+ /** A payment fact's fields, kept in `facts.metadata.payment`. */
6
+ export interface Payment {
7
+ /** A decimal as text with two places, "50000.00": money is never a float. */
8
+ amount: string;
9
+ /** ISO 4217, "RUB". */
10
+ currency: string;
11
+ direction: (typeof PAYMENT_DIRECTIONS)[number];
12
+ /** `person:`, `organization:` or `project:`; `null` when only a name is known. */
13
+ counterparty: string | null;
14
+ counterparty_name: string | null;
15
+ /** An ISO date. */
16
+ due_at: string | null;
17
+ /** A change of state is a new fact that supersedes this one, so the history and its evidence stay. */
18
+ state: (typeof PAYMENT_STATES)[number];
19
+ /** What it is for, short: "support, September". The fact's statement is the full sentence. */
20
+ item: string | null;
21
+ }
22
+ /** A payment as stored: the amount with two decimals, the currency upper case; refuses what is not one. */
23
+ export declare const validatePayment: (input: unknown) => Payment;
24
+ /** One line for a payment, as a list or an inbox shows it: "1 200 EUR to us from Ana, due 31.10 (expected)". */
25
+ export declare const paymentTitle: (fact: {
26
+ payment: Payment | null;
27
+ }) => string;
@@ -0,0 +1,81 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ /** Every fact type the store takes; findings and meeting changes check against the same list. */
3
+ export const FACT_TYPES = ["fact", "statement", "question", "decision", "promise", "payment"];
4
+ export const PAYMENT_DIRECTIONS = ["to_us", "from_us"];
5
+ export const PAYMENT_STATES = ["expected", "paid", "cancelled"];
6
+ const invalid = (message) => {
7
+ throw new CliError("validation_error", message);
8
+ };
9
+ const CURRENCIES = new Set(Intl.supportedValuesOf("currency"));
10
+ const AMOUNT = /^(\d{1,15})(?:\.(\d{1,2}))?$/;
11
+ const COUNTERPARTY = /^(person|organization|project):[1-9]\d*$/;
12
+ const amountOf = (value) => {
13
+ const match = AMOUNT.exec(typeof value === "number" ? String(value) : typeof value === "string" ? value.trim() : "");
14
+ if (!match)
15
+ return invalid("a payment's amount is a positive number with at most 2 decimals, such as 1200.50");
16
+ const amount = `${BigInt(match[1] ?? "0")}.${(match[2] ?? "").padEnd(2, "0")}`;
17
+ if (amount === "0.00")
18
+ return invalid("a payment's amount is more than 0");
19
+ return amount;
20
+ };
21
+ const dateOf = (value) => {
22
+ const date = typeof value === "string" ? value.trim() : "";
23
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date)
24
+ return invalid("a payment's due date is an ISO date, such as 2026-10-31");
25
+ return date;
26
+ };
27
+ const optionalText = (value, what, limit) => {
28
+ if (value === undefined || value === null)
29
+ return null;
30
+ if (typeof value !== "string" || !value.trim() || value.length > limit || value.includes("\0"))
31
+ return invalid(`a payment's ${what} takes 1–${limit} characters`);
32
+ return value.trim();
33
+ };
34
+ const oneOf = (value, allowed, what) => {
35
+ if (typeof value !== "string" || !allowed.includes(value))
36
+ return invalid(`a payment's ${what} is one of ${allowed.join(", ")}`);
37
+ return value;
38
+ };
39
+ const FIELDS = ["amount", "currency", "direction", "counterparty", "counterparty_name", "due_at", "state", "item"];
40
+ /** A payment as stored: the amount with two decimals, the currency upper case; refuses what is not one. */
41
+ export const validatePayment = (input) => {
42
+ if (!input || typeof input !== "object" || Array.isArray(input))
43
+ return invalid("a payment is an object");
44
+ const raw = input;
45
+ const unknown = Object.keys(raw).filter((key) => !FIELDS.includes(key));
46
+ if (unknown.length)
47
+ return invalid(`a payment has no field ${unknown.join(", ")}; its fields are ${FIELDS.join(", ")}`);
48
+ const currency = typeof raw.currency === "string" ? raw.currency.trim().toUpperCase() : "";
49
+ if (!/^[A-Z]{3}$/.test(currency) || !CURRENCIES.has(currency))
50
+ return invalid("a payment's currency is an ISO 4217 code, such as EUR or RUB");
51
+ const counterparty = optionalText(raw.counterparty, "counterparty", 80);
52
+ if (counterparty !== null && !COUNTERPARTY.test(counterparty))
53
+ return invalid("a payment's counterparty is a person:, organization: or project: reference");
54
+ return {
55
+ amount: amountOf(raw.amount),
56
+ currency,
57
+ direction: oneOf(raw.direction, PAYMENT_DIRECTIONS, "direction"),
58
+ counterparty,
59
+ counterparty_name: optionalText(raw.counterparty_name, "counterparty name", 200),
60
+ due_at: raw.due_at === undefined || raw.due_at === null ? null : dateOf(raw.due_at),
61
+ state: oneOf(raw.state, PAYMENT_STATES, "state"),
62
+ item: optionalText(raw.item, "item", 200),
63
+ };
64
+ };
65
+ const grouped = (amount) => {
66
+ const [whole = "0", cents = "00"] = amount.split(".");
67
+ const digits = whole.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
68
+ return cents === "00" ? digits : `${digits}.${cents}`;
69
+ };
70
+ /** One line for a payment, as a list or an inbox shows it: "1 200 EUR to us from Ana, due 31.10 (expected)". */
71
+ export const paymentTitle = (fact) => {
72
+ const payment = fact.payment;
73
+ if (!payment)
74
+ return "";
75
+ const who = payment.counterparty_name ?? payment.counterparty;
76
+ const way = payment.direction === "to_us" ? "to us" : "from us";
77
+ const party = who === null ? "" : payment.direction === "to_us" ? ` from ${who}` : ` to ${who}`;
78
+ const due = payment.due_at === null ? "" : `, due ${payment.due_at.slice(8, 10)}.${payment.due_at.slice(5, 7)}`;
79
+ const item = payment.item === null ? "" : ` for ${payment.item}`;
80
+ return `${grouped(payment.amount)} ${payment.currency} ${way}${party}${item}${due} (${payment.state})`;
81
+ };
@@ -6,6 +6,7 @@ import { type AccountKey, type ChatStats, type MessageStore, type Range } from "
6
6
  import { type Estimate } from "./backfill-estimate.js";
7
7
  import { batchProgress } from "./batch.js";
8
8
  import type { ServiceDeps } from "./deps.js";
9
+ import { type ChatShape } from "./history-limits.js";
9
10
  import { type CatchUpOptions, type CatchUpResult } from "./search-catchup.js";
10
11
  /** The most messages a provider hands out per history request — Telegram's cap. */
11
12
  export declare const PAGE = 100;
@@ -45,6 +46,10 @@ export interface FetchOptions {
45
46
  done: number;
46
47
  fetched: number;
47
48
  }) => void;
49
+ /** Past the history limits for this chat (`--full-history`). */
50
+ fullHistory?: boolean;
51
+ /** The chat's kind and size as the messenger lists it, for a chat the store does not hold yet. */
52
+ shape?: ChatShape;
48
53
  }
49
54
  /** A type, not an interface: a job keeps it as a plain record. */
50
55
  export type Fetched = {
@@ -62,6 +67,8 @@ export type Fetched = {
62
67
  reachedSince?: true;
63
68
  reachedLast?: true;
64
69
  stopped?: true;
70
+ /** The history limits stopped this read: a group too large to read, or the window's start reached. */
71
+ limited?: "group_too_large" | "older_than_window";
65
72
  };
66
73
  export type FetchedAll = {
67
74
  batch?: ReturnType<ReturnType<typeof batchProgress>["result"]>;
@@ -80,7 +87,13 @@ export type FetchedAll = {
80
87
  stopped?: true;
81
88
  error?: string;
82
89
  issue?: ActionableError;
90
+ limited?: "group_too_large" | "older_than_window";
83
91
  }[];
92
+ /** How many chats the history limits left unread (big groups) or read only to the window's start. */
93
+ limited?: {
94
+ groupTooLarge: number;
95
+ olderThanWindow: number;
96
+ };
84
97
  };
85
98
  /** The local store of messages: what it holds, filling it from the messenger, and reading it out. */
86
99
  export interface ArchiveService {
@@ -5,6 +5,7 @@ import { typedCommand } from "../runs/scope.js";
5
5
  import { fetchedKey, historyStartKey, } from "../store/store.js";
6
6
  import { estimateBackfill } from "./backfill-estimate.js";
7
7
  import { actionable, batchProgress } from "./batch.js";
8
+ import { historyWindow } from "./history-limits.js";
8
9
  import { storedChatId } from "./message-resolution.js";
9
10
  import { patiently } from "./patience.js";
10
11
  import { catchUpSearch, validateCatchUp } from "./search-catchup.js";
@@ -114,13 +115,28 @@ export const archiveService = (deps) => {
114
115
  throw new CliError("authentication_error", "the connection belongs to another account");
115
116
  if (options.stop.aborted)
116
117
  return { chat: null, fetched: 0, complete: false, ranges: [], stopped: true };
117
- const result = await fetchInto({
118
+ const store = await deps.store();
119
+ const account = { provider: deps.messenger.provider, account: self };
120
+ const limit = await limitOf(deps, store, account, chat, options);
121
+ if (limit?.skip) {
122
+ await recordSkip(store, account, limit.chatId, { reason: "group_too_large", members: limit.skip.members });
123
+ return { chat: limit.chatId ?? null, fetched: 0, complete: false, ranges: [], limited: "group_too_large" };
124
+ }
125
+ const windowed = limit?.keepFrom !== undefined && !(limit.keepFrom <= (options.sinceMs ?? Number.NEGATIVE_INFINITY));
126
+ const fetched = await fetchInto({
118
127
  history: (window) => history(chat, { ...window, reactions: false }),
119
- store: await deps.store(),
120
- account: { provider: deps.messenger.provider, account: self },
128
+ store,
129
+ account,
121
130
  fetching: deps.messenger.fetching ?? FETCHING,
122
131
  ...options,
132
+ ...(windowed ? { sinceMs: limit.keepFrom } : {}),
123
133
  });
134
+ const result = windowed && fetched.reachedSince ? { ...fetched, limited: "older_than_window" } : fetched;
135
+ if (result.limited && limit?.keepFrom !== undefined)
136
+ await recordSkip(store, account, result.chat ?? limit.chatId, {
137
+ reason: "older_than_window",
138
+ keptFrom: new Date(limit.keepFrom).toISOString(),
139
+ });
124
140
  return requested && result.chat
125
141
  ? { ...result, prepared: await catchUpSearch(deps, result.chat, requested, options.stop) }
126
142
  : result;
@@ -161,7 +177,17 @@ export const archiveService = (deps) => {
161
177
  continue;
162
178
  }
163
179
  try {
164
- const one = await own.fetch(chat.id, options);
180
+ const one = await own.fetch(chat.id, {
181
+ ...options,
182
+ shape: { kind: chat.kind, participantsCount: chat.participantsCount ?? null },
183
+ });
184
+ if (one.limited) {
185
+ answer.limited ??= { groupTooLarge: 0, olderThanWindow: 0 };
186
+ if (one.limited === "group_too_large")
187
+ answer.limited.groupTooLarge += 1;
188
+ else
189
+ answer.limited.olderThanWindow += 1;
190
+ }
165
191
  if (one.issue)
166
192
  batch.fail(chat.id, "history", one.issue);
167
193
  else
@@ -172,7 +198,8 @@ export const archiveService = (deps) => {
172
198
  title: chat.title,
173
199
  fetched: one.fetched,
174
200
  // Reaching the window is what --all asked for; older history was not.
175
- complete: one.complete || one.reachedSince === true || one.reachedLast === true,
201
+ complete: one.complete || one.reachedSince === true || one.reachedLast === true || one.limited !== undefined,
202
+ ...(one.limited ? { limited: one.limited } : {}),
176
203
  ...(one.issue ? { error: one.issue.code, issue: one.issue } : {}),
177
204
  ...(one.stopped ? { stopped: true } : {}),
178
205
  });
@@ -195,6 +222,30 @@ export const archiveService = (deps) => {
195
222
  },
196
223
  };
197
224
  };
225
+ /**
226
+ * Where the history limits stop this chat's read: not at all for a group above the cap, else at the window's start.
227
+ * None for a lifted chat or without limits. A chat the store does not hold yet is judged by the shape the list gave.
228
+ */
229
+ const limitOf = async (deps, store, account, chat, options) => {
230
+ if (!deps.historyLimits || options.fullHistory)
231
+ return undefined;
232
+ const chatId = await storedChatId(deps.messenger, chat, store, account, { includeLeft: true }).catch(() => undefined);
233
+ const stored = chatId === undefined ? undefined : (await store.chats.historyShapes(account, [chatId])).get(chatId);
234
+ if (stored?.included)
235
+ return undefined;
236
+ const window = historyWindow(stored ?? options.shape, deps.historyLimits, Date.now());
237
+ return {
238
+ ...(chatId === undefined ? {} : { chatId }),
239
+ ...("skip" in window ? { skip: { members: window.members } } : { keepFrom: window.keepFrom }),
240
+ };
241
+ };
242
+ /** A read the limits stopped, for `store status` and the answers that say not all is there; the count is unknown. */
243
+ const recordSkip = async (store, account, chatId, skip) => {
244
+ if (!chatId)
245
+ return;
246
+ const at = new Date().toISOString();
247
+ await store.chats.recordHistorySkips(account, new Map([[chatId, { ...skip, messages: 0, oldestAt: at, newestAt: at, unread: true }]]));
248
+ };
198
249
  /** The fetch loop, over any history and store: the personal account's service and a bot's command share it. */
199
250
  export const abortable = async (read, signal) => {
200
251
  signal.throwIfAborted();