@wirecat/cli-core 0.545.0 → 0.547.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 (175) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/dist/calendar/busy.js +4 -1
  3. package/dist/calendar/cli/answers.d.ts +31 -0
  4. package/dist/calendar/cli/answers.js +8 -0
  5. package/dist/calendar/cli/index.d.ts +6 -0
  6. package/dist/calendar/cli/index.js +43 -4
  7. package/dist/calendar/cli/remind.js +2 -1
  8. package/dist/calendar/expand.d.ts +17 -0
  9. package/dist/calendar/expand.js +340 -0
  10. package/dist/calendar/feeds.d.ts +96 -0
  11. package/dist/calendar/feeds.js +324 -0
  12. package/dist/calendar/ics.d.ts +15 -4
  13. package/dist/calendar/ics.js +18 -7
  14. package/dist/calendar/index.d.ts +3 -1
  15. package/dist/calendar/index.js +2 -0
  16. package/dist/calendar/server-search.js +3 -1
  17. package/dist/calendar/writes.js +15 -4
  18. package/dist/cli/after-dashes.d.ts +16 -0
  19. package/dist/cli/after-dashes.js +41 -0
  20. package/dist/cli/bot/context.d.ts +1 -1
  21. package/dist/cli/bot/context.js +9 -1
  22. package/dist/cli/bot/messages.js +2 -0
  23. package/dist/cli/bot/setup.js +1 -0
  24. package/dist/cli/contract-templates.js +2 -2
  25. package/dist/cli/entry.d.ts +2 -0
  26. package/dist/cli/entry.js +3 -2
  27. package/dist/cli/index.d.ts +2 -1
  28. package/dist/cli/index.js +2 -1
  29. package/dist/cli/messenger/admin-commands.js +5 -1
  30. package/dist/cli/messenger/admin-contacts-command.js +5 -1
  31. package/dist/cli/messenger/admin-folders-command.js +5 -1
  32. package/dist/cli/messenger/complete-command.js +1 -0
  33. package/dist/cli/messenger/history-query.d.ts +10 -4
  34. package/dist/cli/messenger/history-query.js +87 -31
  35. package/dist/cli/messenger/messages-command.js +12 -23
  36. package/dist/cli/messenger/messages-comments-command.js +4 -2
  37. package/dist/cli/messenger/messages-edit-command.js +1 -0
  38. package/dist/cli/messenger/messages-press-command.js +1 -0
  39. package/dist/cli/messenger/messages-send-command.js +1 -0
  40. package/dist/cli/messenger/observed.js +2 -2
  41. package/dist/cli/messenger/polls-command.js +1 -0
  42. package/dist/cli/messenger/private-people-command.js +5 -1
  43. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  44. package/dist/cli/messenger/stored.js +56 -3
  45. package/dist/cli/messenger/topics-command.js +1 -1
  46. package/dist/cli/option-hints.d.ts +6 -0
  47. package/dist/cli/option-hints.js +15 -0
  48. package/dist/cli/program.js +17 -6
  49. package/dist/cli/schedule/schedule-command.js +1 -0
  50. package/dist/domain/message-link.d.ts +3 -0
  51. package/dist/domain/message-link.js +9 -14
  52. package/dist/domain/money-words.d.ts +11 -0
  53. package/dist/domain/money-words.js +38 -0
  54. package/dist/domain/providers.d.ts +2 -0
  55. package/dist/domain/providers.js +15 -3
  56. package/dist/domain/ref-arguments.d.ts +31 -0
  57. package/dist/domain/ref-arguments.js +79 -0
  58. package/dist/domain/references.d.ts +20 -2
  59. package/dist/domain/references.js +132 -47
  60. package/dist/errors/reasons.js +31 -1
  61. package/dist/index.d.ts +1 -1
  62. package/dist/index.js +1 -1
  63. package/dist/mail/cli/index.js +4 -4
  64. package/dist/mail/index.d.ts +2 -0
  65. package/dist/mail/index.js +2 -0
  66. package/dist/mail/partial.d.ts +34 -0
  67. package/dist/mail/partial.js +94 -0
  68. package/dist/mail/server-search.js +17 -9
  69. package/dist/mail/source.d.ts +13 -2
  70. package/dist/mail/source.js +7 -1
  71. package/dist/mail/sync-types.d.ts +112 -0
  72. package/dist/mail/sync-types.js +1 -0
  73. package/dist/mail/sync.d.ts +4 -106
  74. package/dist/mail/sync.js +49 -4
  75. package/dist/mail/testing/fake.d.ts +3 -1
  76. package/dist/mail/testing/fake.js +10 -2
  77. package/dist/meetings/mcp/index.d.ts +15 -15
  78. package/dist/oauth/client-file.d.ts +14 -0
  79. package/dist/oauth/client-file.js +28 -0
  80. package/dist/oauth/index.d.ts +1 -0
  81. package/dist/oauth/index.js +1 -0
  82. package/dist/ports/messenger.d.ts +3 -2
  83. package/dist/resolve.d.ts +3 -1
  84. package/dist/resolve.js +8 -0
  85. package/dist/search/task-signature.d.ts +5 -1
  86. package/dist/search/task-signature.js +27 -9
  87. package/dist/services/agenda.d.ts +5 -0
  88. package/dist/services/agenda.js +3 -1
  89. package/dist/services/deps.d.ts +0 -2
  90. package/dist/services/deps.js +0 -2
  91. package/dist/services/documents/ingestion.js +1 -1
  92. package/dist/services/documents/knowledge.d.ts +10 -6
  93. package/dist/services/documents/knowledge.js +25 -11
  94. package/dist/services/index.d.ts +6 -1
  95. package/dist/services/index.js +6 -1
  96. package/dist/services/message-resolution.d.ts +4 -1
  97. package/dist/services/message-resolution.js +19 -3
  98. package/dist/services/messages.d.ts +2 -0
  99. package/dist/services/messages.js +40 -36
  100. package/dist/services/money-words.d.ts +3 -0
  101. package/dist/services/money-words.js +5 -0
  102. package/dist/services/proposal-tasks.d.ts +3 -2
  103. package/dist/services/proposal-tasks.js +5 -2
  104. package/dist/services/proposals/ids.d.ts +5 -0
  105. package/dist/services/proposals/ids.js +43 -0
  106. package/dist/services/proposals/index.d.ts +3 -0
  107. package/dist/services/proposals/index.js +2 -0
  108. package/dist/services/proposals/kinds/action.d.ts +7 -0
  109. package/dist/services/proposals/kinds/action.js +45 -0
  110. package/dist/services/proposals/kinds/fact.d.ts +4 -0
  111. package/dist/services/proposals/kinds/fact.js +43 -0
  112. package/dist/services/proposals/kinds/findings.d.ts +7 -0
  113. package/dist/services/proposals/kinds/findings.js +75 -0
  114. package/dist/services/proposals/kinds/key-document.d.ts +9 -0
  115. package/dist/services/proposals/kinds/key-document.js +75 -0
  116. package/dist/services/proposals/kinds/memory.d.ts +4 -0
  117. package/dist/services/proposals/kinds/memory.js +42 -0
  118. package/dist/services/proposals/kinds/relationship.d.ts +10 -0
  119. package/dist/services/proposals/kinds/relationship.js +82 -0
  120. package/dist/services/proposals/kinds/shared.d.ts +25 -0
  121. package/dist/services/proposals/kinds/shared.js +49 -0
  122. package/dist/services/proposals/registry.d.ts +9 -0
  123. package/dist/services/proposals/registry.js +129 -0
  124. package/dist/services/proposals/types.d.ts +85 -0
  125. package/dist/services/proposals/types.js +1 -0
  126. package/dist/services/questions.d.ts +7 -2
  127. package/dist/services/questions.js +13 -4
  128. package/dist/services/server-search.d.ts +7 -4
  129. package/dist/services/server-search.js +5 -2
  130. package/dist/services/stored-project-gathering.d.ts +9 -0
  131. package/dist/services/stored-project-gathering.js +9 -4
  132. package/dist/services/task-categories.d.ts +2 -0
  133. package/dist/services/task-categories.js +19 -8
  134. package/dist/services/tasks.js +8 -4
  135. package/dist/store/decisions.d.ts +65 -0
  136. package/dist/store/decisions.js +55 -0
  137. package/dist/store/evidence-findings.d.ts +6 -6
  138. package/dist/store/index.d.ts +4 -3
  139. package/dist/store/index.js +2 -1
  140. package/dist/store/meeting-findings.d.ts +2 -0
  141. package/dist/store/messages.d.ts +4 -9
  142. package/dist/store/sqlite/actors.d.ts +13 -2
  143. package/dist/store/sqlite/actors.js +24 -1
  144. package/dist/store/sqlite/email-locations.d.ts +16 -0
  145. package/dist/store/sqlite/email-locations.js +26 -0
  146. package/dist/store/sqlite/emails.d.ts +6 -2
  147. package/dist/store/sqlite/emails.js +2 -1
  148. package/dist/store/sqlite/evidence-findings.js +13 -3
  149. package/dist/store/sqlite/evidence-sources.js +2 -2
  150. package/dist/store/sqlite/facts.d.ts +16 -8
  151. package/dist/store/sqlite/facts.js +48 -15
  152. package/dist/store/sqlite/findings-core.d.ts +2 -1
  153. package/dist/store/sqlite/findings-core.js +4 -4
  154. package/dist/store/sqlite/identities.js +1 -1
  155. package/dist/store/sqlite/knowledge.d.ts +43 -7
  156. package/dist/store/sqlite/knowledge.js +62 -17
  157. package/dist/store/sqlite/meeting-findings.js +6 -6
  158. package/dist/store/sqlite/memories.d.ts +12 -8
  159. package/dist/store/sqlite/memories.js +33 -10
  160. package/dist/store/sqlite/notes.d.ts +5 -1
  161. package/dist/store/sqlite/notes.js +22 -6
  162. package/dist/store/sqlite/open-store.d.ts +4 -1
  163. package/dist/store/sqlite/open-store.js +8 -2
  164. package/dist/store/sqlite/open.d.ts +3 -0
  165. package/dist/store/sqlite/proposed-actions.d.ts +21 -8
  166. package/dist/store/sqlite/proposed-actions.js +29 -9
  167. package/dist/store/sqlite/reads.d.ts +13 -2
  168. package/dist/store/sqlite/reads.js +8 -4
  169. package/dist/store/sqlite/relation-proposals.d.ts +34 -0
  170. package/dist/store/sqlite/relation-proposals.js +114 -0
  171. package/dist/store/sqlite/things.d.ts +0 -1
  172. package/dist/store/sqlite/things.js +25 -24
  173. package/dist/store/store.d.ts +3 -0
  174. package/dist/store/store.js +2 -0
  175. package/package.json +1 -1
@@ -0,0 +1,324 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { CliError, isCliError } from "@wirecat/cli-base";
3
+ import { zonedMoment } from "@wirecat/cli-base/query";
4
+ import { runBudget } from "../background/budget.js";
5
+ import { calendarText, joinUrlWithoutQuery } from "../meetings/index.js";
6
+ import { expandSeries, unexpandable } from "./expand.js";
7
+ import { readIcs } from "./ics.js";
8
+ import { syncCalendars } from "./sync.js";
9
+ /** A feed's calendar id: `ics:` and a random id, never the address or anything made from it. */
10
+ export const FEED_PREFIX = "ics:";
11
+ export const isFeedCalendar = (calendar) => calendar.externalId.startsWith(FEED_PREFIX);
12
+ export const FEED_LIMITS = { maxBytes: 10 * 1024 * 1024, timeoutMs: 30_000, redirects: 5 };
13
+ const invalidAddress = (why) => new CliError("validation_error", `the feed address ${why}`, { reason: "feed.address-invalid" });
14
+ /** `webcal://` is `https://`: calendar apps write it so a click opens them, and the feed itself is served over https. */
15
+ export const feedAddress = (text) => {
16
+ let url;
17
+ try {
18
+ url = new URL(text.trim().replace(/^webcals?:\/\//iu, "https://"));
19
+ }
20
+ catch {
21
+ throw invalidAddress("is not a URL; give the feed's https:// or webcal:// address");
22
+ }
23
+ if (url.protocol !== "https:" && url.protocol !== "http:")
24
+ throw invalidAddress("must start with https://, http:// or webcal://");
25
+ if (url.username || url.password)
26
+ throw invalidAddress("must not hold a user name or a password");
27
+ return url;
28
+ };
29
+ const sameAddress = (a, b) => feedAddress(a).href === feedAddress(b).href;
30
+ const readBody = async (response, name, maxBytes) => {
31
+ const reader = response.body?.getReader();
32
+ if (!reader)
33
+ return "";
34
+ const chunks = [];
35
+ let size = 0;
36
+ for (;;) {
37
+ const { done, value } = await reader.read();
38
+ if (done)
39
+ break;
40
+ size += value.byteLength;
41
+ if (size > maxBytes) {
42
+ await reader.cancel();
43
+ throw new CliError("validation_error", `${name} is larger than ${Math.round(maxBytes / 1024 / 1024)} MB`, {
44
+ reason: "feed.too-large",
45
+ });
46
+ }
47
+ chunks.push(value);
48
+ }
49
+ return new TextDecoder("utf-8").decode(Buffer.concat(chunks));
50
+ };
51
+ const STATUS_ERRORS = {
52
+ 401: ["authentication_error", "feed.refused"],
53
+ 403: ["permission_error", "feed.refused"],
54
+ 404: ["not_found", "feed.gone"],
55
+ 410: ["not_found", "feed.gone"],
56
+ };
57
+ /**
58
+ * The feed's text: http or https, each redirect followed by hand and only to https, the body cut off at its limit and
59
+ * the request at its time. Errors name the feed and the status, never the address, which may hold a token.
60
+ */
61
+ export const fetchFeed = async (address, options) => {
62
+ const { name, limits = FEED_LIMITS } = options;
63
+ const run = options.fetch ?? fetch;
64
+ const timeout = AbortSignal.timeout(limits.timeoutMs);
65
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
66
+ let url = feedAddress(address);
67
+ try {
68
+ for (let hop = 0;; hop++) {
69
+ const response = await run(url, {
70
+ redirect: "manual",
71
+ signal,
72
+ headers: { accept: "text/calendar, text/plain;q=0.5, */*;q=0.1" },
73
+ });
74
+ const location = response.headers.get("location");
75
+ if (response.status >= 300 && response.status < 400 && location) {
76
+ await response.body?.cancel();
77
+ if (hop >= limits.redirects)
78
+ throw new CliError("provider_error", `${name} sent the request on more than ${limits.redirects} times`, {
79
+ reason: "feed.refused",
80
+ });
81
+ const next = new URL(location, url);
82
+ if (next.protocol !== "https:")
83
+ throw new CliError("validation_error", `${name} sent the request on to an address that is not https`, {
84
+ reason: "feed.redirect-refused",
85
+ });
86
+ url = next;
87
+ continue;
88
+ }
89
+ if (!response.ok) {
90
+ await response.body?.cancel();
91
+ const [code, reason] = STATUS_ERRORS[response.status] ?? ["provider_error", "feed.refused"];
92
+ throw new CliError(code, `${name} answered HTTP ${response.status}`, {
93
+ reason,
94
+ status: response.status,
95
+ retryable: response.status >= 500 || response.status === 429,
96
+ });
97
+ }
98
+ return await readBody(response, name, limits.maxBytes);
99
+ }
100
+ }
101
+ catch (error) {
102
+ if (isCliError(error))
103
+ throw error;
104
+ if (options.signal?.aborted)
105
+ throw new CliError("cancelled", `reading ${name} was cancelled`);
106
+ if (timeout.aborted)
107
+ throw new CliError("timeout", `${name} did not answer within ${Math.round(limits.timeoutMs / 1000)} s`, {
108
+ reason: "feed.timeout",
109
+ retryable: true,
110
+ });
111
+ // The cause's code only: a fetch error's message may quote the address.
112
+ const cause = error.cause?.code;
113
+ throw new CliError("network_error", `${name} could not be reached${typeof cause === "string" ? ` (${cause})` : ""}`, {
114
+ reason: "feed.unreachable",
115
+ retryable: true,
116
+ });
117
+ }
118
+ };
119
+ const SPAN = { start: 0, end: 0 };
120
+ const firstLink = (text) => text?.match(/https?:\/\/[^\s<>"')\]]+/iu)?.[0];
121
+ /** One feed event in the neutral words, its text cleaned as an invitation's is: feed text is somebody else's. */
122
+ export const feedEntry = (event, zone) => {
123
+ const allDay = event.startDate !== undefined;
124
+ const timezone = allDay ? zone : (event.timezone ?? null);
125
+ const recurrence = event.recurrence?.length ? event.recurrence : null;
126
+ return {
127
+ externalId: event.uid,
128
+ kind: recurrence ? "series" : "single",
129
+ icalUid: event.uid,
130
+ status: event.status ?? "confirmed",
131
+ title: calendarText(event.title, { dropSecretLines: false }),
132
+ description: calendarText(event.description),
133
+ location: calendarText(event.location),
134
+ startsAt: allDay ? zonedMoment(event.startDate, zone, SPAN) : (event.startsAt ?? null),
135
+ endsAt: allDay ? zonedMoment(event.endDate, zone, SPAN) : (event.endsAt ?? null),
136
+ allDay,
137
+ startDate: event.startDate ?? null,
138
+ endDate: event.endDate ?? null,
139
+ timezone,
140
+ recurrence,
141
+ eventType: "default",
142
+ transparency: event.transparency ?? null,
143
+ visibility: null,
144
+ ownResponse: null,
145
+ organizer: event.organizer ? { email: event.organizer.email, name: event.organizer.name ?? null } : null,
146
+ participants: (event.attendees ?? []).map((one) => ({
147
+ email: one.email,
148
+ name: one.name ?? null,
149
+ response: one.response ?? null,
150
+ ...(one.optional ? { optional: true } : {}),
151
+ })),
152
+ joinUrl: joinUrlWithoutQuery(firstLink(event.location) ?? firstLink(event.description)),
153
+ conference: null,
154
+ url: event.url ?? null,
155
+ sequence: null,
156
+ // The engine keeps a series' slots as they are while its version stays the same.
157
+ etag: createHash("sha256").update(JSON.stringify(event)).digest("hex").slice(0, 32),
158
+ externalUpdatedAt: null,
159
+ metadata: null,
160
+ attachments: [],
161
+ };
162
+ };
163
+ const inWindow = (entry, window) => entry.startsAt != null &&
164
+ entry.startsAt < window.to &&
165
+ (entry.kind === "series" || Math.max(entry.endsAt ?? entry.startsAt, entry.startsAt + 1) > window.from);
166
+ const zoneOk = (zone) => {
167
+ if (!zone)
168
+ return false;
169
+ try {
170
+ new Intl.DateTimeFormat("en", { timeZone: zone });
171
+ return true;
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ };
177
+ /** `X-WR-TIMEZONE`: the zone a feed's floating times are written in, when it names one. */
178
+ const feedZone = (text) => /^X-WR-TIMEZONE:(.+)$/imu.exec(text)?.[1]?.trim();
179
+ /** A feed's events inside the window, its series with their overrides, and what it could not take. */
180
+ export const readFeed = (text, zone, window) => {
181
+ const named = feedZone(text);
182
+ const where = zoneOk(named) ? named : zone;
183
+ const read = readIcs(text, where, { overrides: true });
184
+ let skipped = read.skipped.length;
185
+ const entries = [];
186
+ for (const event of read.events) {
187
+ const entry = feedEntry(event, where);
188
+ if (entry.kind === "series" && unexpandable(entry.recurrence) !== null) {
189
+ skipped++;
190
+ continue;
191
+ }
192
+ if (!window || inWindow(entry, window))
193
+ entries.push(entry);
194
+ }
195
+ const series = new Set(entries.filter((one) => one.kind === "series").map((one) => one.externalId));
196
+ const inFeed = new Set(read.events.map((one) => one.uid));
197
+ const overrides = new Map();
198
+ for (const override of read.overrides) {
199
+ if (!series.has(override.uid)) {
200
+ if (!inFeed.has(override.uid))
201
+ skipped++;
202
+ continue;
203
+ }
204
+ const entry = { ...feedEntry(override, where), originalStartsAt: override.recurrenceId };
205
+ overrides.set(override.uid, [...(overrides.get(override.uid) ?? []), entry]);
206
+ }
207
+ return {
208
+ entries,
209
+ overrides,
210
+ name: calendarText(read.name, { dropSecretLines: false }),
211
+ timezone: zoneOk(named) ? named : null,
212
+ events: read.events.length + read.overrides.length,
213
+ skipped,
214
+ };
215
+ };
216
+ const calendarOf = (feed, timezone = null) => ({
217
+ externalId: `${FEED_PREFIX}${feed.id}`,
218
+ title: feed.title,
219
+ timezone,
220
+ accessRole: "reader",
221
+ primary: false,
222
+ syncByDefault: true,
223
+ });
224
+ /**
225
+ * The profile's feeds as one calendar source with `expands: "local"` and a full list on every sync: a feed has no
226
+ * cursor. The expander a sync is given reads the overrides of the feed `changes()` just read.
227
+ */
228
+ export const icsFeeds = (options) => {
229
+ const overridesOf = new WeakMap();
230
+ const stats = new Map();
231
+ const feedOf = async (externalId) => {
232
+ const feed = (await options.registry.read()).find((one) => `${FEED_PREFIX}${one.id}` === externalId);
233
+ if (!feed)
234
+ throw new CliError("configuration_error", `the address of feed ${externalId} is no longer kept on this computer`, {
235
+ reason: "feed.no-address",
236
+ });
237
+ return feed;
238
+ };
239
+ const read = (url, name) => fetchFeed(url, {
240
+ name,
241
+ ...(options.fetch ? { fetch: options.fetch } : {}),
242
+ ...(options.signal ? { signal: options.signal } : {}),
243
+ ...(options.limits ? { limits: options.limits } : {}),
244
+ });
245
+ const source = {
246
+ provider: "ics",
247
+ traits: { expands: "local", cursorHoldsWindow: false, fullListOnly: true },
248
+ async calendars() {
249
+ return (await options.registry.read()).map((feed) => calendarOf(feed));
250
+ },
251
+ async changes(calendar, { window }) {
252
+ const feed = await feedOf(calendar.externalId);
253
+ const text = await read(feed.url, `feed ${calendar.id}`);
254
+ const found = readFeed(text, calendar.timezone ?? options.zone, window);
255
+ for (const entry of found.entries) {
256
+ const changed = found.overrides.get(entry.externalId);
257
+ if (changed)
258
+ overridesOf.set(entry, changed);
259
+ }
260
+ stats.set(calendar.id, { calendarId: calendar.id, events: found.events, skipped: found.skipped });
261
+ return { entries: found.entries, removed: [], cursor: null };
262
+ },
263
+ };
264
+ return {
265
+ source,
266
+ expand: (series, window) => expandSeries(series, window, overridesOf.get(series) ?? []),
267
+ async add(url, { title } = {}) {
268
+ const address = feedAddress(url).href;
269
+ const feeds = await options.registry.read();
270
+ if (feeds.some((one) => sameAddress(one.url, address)))
271
+ throw new CliError("validation_error", "this profile follows a feed at this address already", {
272
+ reason: "feed.duplicate",
273
+ });
274
+ const found = readFeed(await read(address, "the feed"), options.zone);
275
+ const feed = {
276
+ id: randomBytes(6).toString("hex"),
277
+ url: address,
278
+ title: title?.trim() || found.name || new URL(address).hostname,
279
+ };
280
+ await options.registry.write([...feeds, feed]);
281
+ return { calendar: calendarOf(feed, found.timezone), events: found.events };
282
+ },
283
+ async remove(externalId) {
284
+ const feeds = await options.registry.read();
285
+ await options.registry.write(feeds.filter((one) => `${FEED_PREFIX}${one.id}` !== externalId));
286
+ },
287
+ stats: () => [...stats.values()],
288
+ };
289
+ };
290
+ /**
291
+ * The provider's calendars, then the profile's feeds, under one budget and in one report: the feeds are calendars of
292
+ * the same account, synced by their own source because they expand their series here.
293
+ */
294
+ export const syncWithFeeds = async (source, feeds, store, options) => {
295
+ const budget = options.budget ?? runBudget();
296
+ const first = await syncCalendars(source, store, { ...options, budget });
297
+ if (!feeds)
298
+ return first;
299
+ const second = await syncCalendars(feeds.source, store, { ...options, budget, expand: feeds.expand });
300
+ if (second.calendars === 0)
301
+ return first;
302
+ const complete = first.complete && second.complete;
303
+ const stopped = first.stopped ?? second.stopped;
304
+ const hint = first.hint ?? second.hint;
305
+ const sum = (field) => first.progress[field] + second.progress[field];
306
+ return {
307
+ ...first,
308
+ calendars: first.calendars + second.calendars,
309
+ synced: [...first.synced, ...second.synced],
310
+ notSynced: [...first.notSynced, ...second.notSynced],
311
+ complete,
312
+ state: first.state === "complete" && second.state === "complete" ? "complete" : "partial",
313
+ ...(stopped ? { stopped } : {}),
314
+ progress: {
315
+ calendars: sum("calendars"),
316
+ calendarsDone: sum("calendarsDone"),
317
+ requests: budget.used,
318
+ saved: sum("saved"),
319
+ steps: sum("steps"),
320
+ },
321
+ ...(hint ? { hint } : {}),
322
+ feeds: feeds.stats(),
323
+ };
324
+ };
@@ -45,11 +45,22 @@ export interface IcsSkip {
45
45
  uid: string | null;
46
46
  reason: string;
47
47
  }
48
+ /** One changed occurrence of a repeating event: `recurrenceId` is the slot it replaces, as `originalStartsAt`. */
49
+ export interface IcsOverride extends IcsEvent {
50
+ recurrenceId: number;
51
+ }
52
+ export interface IcsRead {
53
+ events: IcsEvent[];
54
+ skipped: IcsSkip[];
55
+ /** With `overrides`: the changed occurrences, which are otherwise skipped. */
56
+ overrides: IcsOverride[];
57
+ /** The calendar's own name (`X-WR-CALNAME`), as the text has it. */
58
+ name: string | null;
59
+ }
48
60
  /**
49
61
  * The VEVENTs of an iCalendar text; times with neither `Z` nor a TZID are read in `zone`. An event that cannot be
50
62
  * imported alone is in `skipped` with the reason; a text with no VCALENDAR is refused.
51
63
  */
52
- export declare const readIcs: (text: string, zone: string) => {
53
- events: IcsEvent[];
54
- skipped: IcsSkip[];
55
- };
64
+ export declare const readIcs: (text: string, zone: string, options?: {
65
+ overrides?: boolean;
66
+ }) => IcsRead;
@@ -182,12 +182,14 @@ const emailOf = (value) => {
182
182
  .toLowerCase();
183
183
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email) ? email : null;
184
184
  };
185
- const eventOf = (lines, zone) => {
185
+ const slotMoment = (moment) => ("date" in moment ? Date.parse(`${moment.date}T00:00:00Z`) : moment.at);
186
+ const eventOf = (lines, zone, overrides = false) => {
186
187
  const one = (name) => lines.find((line) => line.name === name);
187
188
  const uid = one("UID")?.value.trim();
188
189
  if (!uid)
189
190
  throw new Error("the event has no UID");
190
- if (one("RECURRENCE-ID"))
191
+ const recurrenceLine = one("RECURRENCE-ID");
192
+ if (recurrenceLine && !overrides)
191
193
  throw new Error("a changed occurrence of a repeating event (RECURRENCE-ID) is not imported on its own");
192
194
  const startLine = one("DTSTART");
193
195
  if (!startLine)
@@ -264,13 +266,13 @@ const eventOf = (lines, zone) => {
264
266
  const url = one("URL")?.value.trim();
265
267
  if (url)
266
268
  event.url = url;
267
- return event;
269
+ return recurrenceLine ? { ...event, recurrenceId: slotMoment(momentOf(recurrenceLine, zone)) } : event;
268
270
  };
269
271
  /**
270
272
  * The VEVENTs of an iCalendar text; times with neither `Z` nor a TZID are read in `zone`. An event that cannot be
271
273
  * imported alone is in `skipped` with the reason; a text with no VCALENDAR is refused.
272
274
  */
273
- export const readIcs = (text, zone) => {
275
+ export const readIcs = (text, zone, options = {}) => {
274
276
  const unfolded = text.replace(/^\uFEFF/u, "").replace(/\r?\n[ \t]/gu, "");
275
277
  const lines = unfolded.split(/\r?\n/u).filter((line) => line.trim() !== "");
276
278
  if (!/^BEGIN:VCALENDAR$/iu.test(lines[0]?.trim() ?? ""))
@@ -279,6 +281,8 @@ export const readIcs = (text, zone) => {
279
281
  });
280
282
  const events = [];
281
283
  const skipped = [];
284
+ const overrides = [];
285
+ let name = null;
282
286
  let held = null;
283
287
  // Components inside a VEVENT, such as VALARM, are passed over whole.
284
288
  let inner = 0;
@@ -291,15 +295,22 @@ export const readIcs = (text, zone) => {
291
295
  held = [];
292
296
  continue;
293
297
  }
294
- if (held === null)
298
+ if (held === null) {
299
+ if (line.name === "X-WR-CALNAME" && name === null)
300
+ name = unescapeText(line.value).trim() || null;
295
301
  continue;
302
+ }
296
303
  if (line.name === "BEGIN")
297
304
  inner++;
298
305
  else if (line.name === "END" && inner > 0)
299
306
  inner--;
300
307
  else if (line.name === "END" && value === "VEVENT") {
301
308
  try {
302
- events.push(eventOf(held, zone));
309
+ const event = eventOf(held, zone, options.overrides);
310
+ if ("recurrenceId" in event)
311
+ overrides.push(event);
312
+ else
313
+ events.push(event);
303
314
  }
304
315
  catch (error) {
305
316
  const uid = held.find(({ name }) => name === "UID")?.value.trim() || null;
@@ -310,5 +321,5 @@ export const readIcs = (text, zone) => {
310
321
  else if (inner === 0)
311
322
  held.push(line);
312
323
  }
313
- return { events, skipped };
324
+ return { events, skipped, overrides, name };
314
325
  };
@@ -1,5 +1,7 @@
1
1
  export type { CalendarAccessRole, CalendarInput, EntryAttachment, EntryAttachmentInput, EntryEventType, EntryInput, EntryKind, EntryParticipantInput, EntryRevision, EntryStatus, EntryTransparency, EntryVisibility, EventDetails, EventFilter, EventStore, LinkReview, ParticipantResponse, ReviewDecision, StoredCalendar, StoredEntry, StoredParticipant, } from "../meetings/index.js";
2
- export { type IcsEvent, type IcsSkip, readIcs, writeIcs } from "./ics.js";
2
+ export { expandSeries, localMoment, unexpandable } from "./expand.js";
3
+ export { type CalendarFeeds, FEED_LIMITS, FEED_PREFIX, type FeedLimits, type FeedRecord, type FeedRegistry, type FeedSyncStats, type FeedsSyncReport, feedAddress, fetchFeed, icsFeeds, isFeedCalendar, readFeed, syncWithFeeds, } from "./feeds.js";
4
+ export { type IcsEvent, type IcsOverride, type IcsRead, type IcsSkip, readIcs, writeIcs } from "./ics.js";
3
5
  export { repeatRule } from "./recurrence.js";
4
6
  export type { BusyTimes, CalendarExpander, CalendarSource, CalendarSourceTraits, ChangePage, ChangeResult, FreeBusyRequest, SearchPage, SyncWindow, } from "./source.js";
5
7
  export { type CalendarCursor, type CalendarSyncOptions, type CalendarSyncReport, type CalendarSyncResult, calendarStatus, calendarSyncWindow, type PendingRound, parseCalendarCursor, syncCalendars, } from "./sync.js";
@@ -1,3 +1,5 @@
1
+ export { expandSeries, localMoment, unexpandable } from "./expand.js";
2
+ export { FEED_LIMITS, FEED_PREFIX, feedAddress, fetchFeed, icsFeeds, isFeedCalendar, readFeed, syncWithFeeds, } from "./feeds.js";
1
3
  export { readIcs, writeIcs } from "./ics.js";
2
4
  export { repeatRule } from "./recurrence.js";
3
5
  export { calendarStatus, calendarSyncWindow, parseCalendarCursor, syncCalendars, } from "./sync.js";
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { isFeedCalendar } from "./feeds.js";
2
3
  const iso = (at) => (at == null ? null : new Date(at).toISOString());
3
4
  /**
4
5
  * The provider's own search over the calendars the account syncs, earliest first, stored nowhere. A calendar the owner
@@ -7,7 +8,8 @@ const iso = (at) => (at == null ? null : new Date(at).toISOString());
7
8
  export const serverSearchAnswer = async (source, store, { accountId, account, query, limit }) => {
8
9
  if (!source.search)
9
10
  throw new CliError("configuration_error", "this calendar provider has no search of its own");
10
- const calendars = (await store.calendars(accountId)).filter(({ synced, deletedAt }) => synced && deletedAt === null);
11
+ // A feed is read whole into the store, so the stored search already covers it; the provider has never seen it.
12
+ const calendars = (await store.calendars(accountId)).filter((calendar) => calendar.synced && calendar.deletedAt === null && !isFeedCalendar(calendar));
11
13
  if (calendars.length === 0)
12
14
  throw new CliError("validation_error", "no calendar syncs yet; run sync, or calendars sync <calendar>", {
13
15
  reason: "search.no-calendars",
@@ -5,6 +5,7 @@ import { guardFor } from "../sends/guard.js";
5
5
  import { guardedWrite } from "../sends/guarded.js";
6
6
  import { DEFAULT_PERMISSIONS, levelFor } from "../sends/permissions.js";
7
7
  import { newOperationId } from "../sends/send-id.js";
8
+ import { isFeedCalendar } from "./feeds.js";
8
9
  import { readIcs } from "./ics.js";
9
10
  import { calendarIn, entryItem, eventIdOf, eventOf, plusDays } from "./reads.js";
10
11
  import { kindBoundLines, repeatRule, ruleCount, ruleEnd, ruleEndingBefore, ruleLine, splitDates } from "./recurrence.js";
@@ -184,8 +185,17 @@ const savedAfterCreate = async (context, id, save) => {
184
185
  `sync to store it — do not create it again: a create repeated with ${context.names.id} ${id} finds it`, { ...(isCliError(error) ? error.details : {}), operation_id: id, id });
185
186
  }
186
187
  };
188
+ const READ_ONLY = new Set(["reader", "free-busy"]);
189
+ /** A calendar the account may only read refuses here, before any question is asked or anything is journaled. */
190
+ const writable = (calendar) => {
191
+ if (READ_ONLY.has(calendar.accessRole ?? ""))
192
+ throw new CliError("permission_error", isFeedCalendar(calendar)
193
+ ? `calendar ${calendar.id} is a feed, read again from its address on every sync; its events cannot be changed`
194
+ : `calendar ${calendar.id} is read-only for this account`, { reason: "calendar.read-only", sent: false });
195
+ return calendar;
196
+ };
187
197
  export const createEvent = async (context, calendarReference, fields, options = {}) => {
188
- const calendar = await calendarIn(context.store, context.accountId, calendarReference);
198
+ const calendar = writable(await calendarIn(context.store, context.accountId, calendarReference));
189
199
  const draft = draftOf(fields, context.zone, { whole: true });
190
200
  const id = idOf(context, options.id);
191
201
  const notify = notifyOf(context, options.notify, (draft.attendees?.length ?? 0) > 0);
@@ -209,7 +219,7 @@ export const createEventFromText = async (context, calendarReference, text, opti
209
219
  throw new CliError("validation_error", "the text is empty: say what, when and where");
210
220
  if (line.length > TEXT_MAX || /[\r\n]/u.test(line))
211
221
  throw new CliError("validation_error", `the text is one line of at most ${TEXT_MAX} characters`);
212
- const calendar = await calendarIn(context.store, context.accountId, calendarReference);
222
+ const calendar = writable(await calendarIn(context.store, context.accountId, calendarReference));
213
223
  const notify = options.notify ?? false;
214
224
  const guard = writeGuard(context, keysFor(context, "quick-add", notify));
215
225
  const id = newEventId();
@@ -251,7 +261,7 @@ export const importEvents = async (context, calendarReference, text) => {
251
261
  : "the file holds no event (VEVENT)", { reason: "ics.no-events", skipped: skipped.map(({ uid, reason }) => ({ uid, reason })) });
252
262
  if (events.length > IMPORT_MAX)
253
263
  throw new CliError("validation_error", `the file holds ${events.length} events; one import takes at most ${IMPORT_MAX}`, { reason: "ics.too-many" });
254
- const calendar = await calendarIn(context.store, context.accountId, calendarReference);
264
+ const calendar = writable(await calendarIn(context.store, context.accountId, calendarReference));
255
265
  const guard = writeGuard(context, keysFor(context, "import", false));
256
266
  const id = newEventId();
257
267
  const imported = [];
@@ -300,6 +310,7 @@ const targetOf = async (context, reference, { calendar, scope }) => {
300
310
  if (live.length > 1)
301
311
  throw new CliError("validation_error", `${reference} is on calendars ${live.map(({ calendarId }) => calendarId).join(", ")}; name one with ` +
302
312
  `${context.names.calendar}`, { param: context.names.calendar });
313
+ const owner = writable(calendars.find(({ id }) => id === entry.calendarId));
303
314
  let target = entry;
304
315
  if (scope !== "this") {
305
316
  const flag = scope === "series" ? context.names.series : context.names.following;
@@ -316,7 +327,7 @@ const targetOf = async (context, reference, { calendar, scope }) => {
316
327
  sent: false,
317
328
  });
318
329
  return {
319
- calendar: calendars.find(({ id }) => id === entry.calendarId),
330
+ calendar: owner,
320
331
  entry,
321
332
  target,
322
333
  participants: details.participants.filter(({ entryId }) => entryId === entry.id),
@@ -0,0 +1,16 @@
1
+ import type { Command } from "commander";
2
+ declare module "@wirecat/cli-base/commands" {
3
+ interface CommandMeta {
4
+ /**
5
+ * Every word after `--` stays a positional, even one spelled as this command's option: the command takes free
6
+ * text (a message, a title, another program's arguments), where `--json` may be the text itself.
7
+ */
8
+ literal_after_dashes?: boolean;
9
+ }
10
+ }
11
+ /**
12
+ * `messages sync -- -100 --json` as `messages sync --json -- -100`: agents copy `-- <id> --json` from old skill
13
+ * text, and a negative id needs no `--` anyway. Only a word that is exactly one of the command's options moves, with
14
+ * the value it takes; a command marked `literal_after_dashes` keeps every word after `--` as it is.
15
+ */
16
+ export declare const liftOptionsAfterDashes: (argv: readonly string[], target: (words: string[]) => Command | undefined) => string[];
@@ -0,0 +1,41 @@
1
+ import { metaOf } from "@wirecat/cli-base/commands";
2
+ const optionsOf = (command) => {
3
+ const found = new Map();
4
+ for (let at = command; at; at = at.parent)
5
+ for (const option of at.options)
6
+ if (option.long && !found.has(option.long))
7
+ found.set(option.long, option);
8
+ return found;
9
+ };
10
+ /**
11
+ * `messages sync -- -100 --json` as `messages sync --json -- -100`: agents copy `-- <id> --json` from old skill
12
+ * text, and a negative id needs no `--` anyway. Only a word that is exactly one of the command's options moves, with
13
+ * the value it takes; a command marked `literal_after_dashes` keeps every word after `--` as it is.
14
+ */
15
+ export const liftOptionsAfterDashes = (argv, target) => {
16
+ const end = argv.indexOf("--");
17
+ if (end < 0)
18
+ return [...argv];
19
+ const command = target(argv.slice(0, end));
20
+ if (!command || metaOf(command).literal_after_dashes)
21
+ return [...argv];
22
+ const options = optionsOf(command);
23
+ const moved = [];
24
+ const kept = [];
25
+ for (let index = end + 1; index < argv.length; index++) {
26
+ const word = argv[index];
27
+ const equals = word.indexOf("=");
28
+ const option = word.startsWith("--") ? options.get(equals < 0 ? word : word.slice(0, equals)) : undefined;
29
+ if (!option) {
30
+ kept.push(word);
31
+ continue;
32
+ }
33
+ moved.push(word);
34
+ // An optional value is left alone: after `--` the next word is as likely the positional as the value.
35
+ if (option.required && equals < 0 && index + 1 < argv.length)
36
+ moved.push(argv[++index]);
37
+ }
38
+ if (moved.length === 0)
39
+ return [...argv];
40
+ return [...argv.slice(0, end), ...moved, "--", ...kept];
41
+ };
@@ -44,7 +44,7 @@ export declare const botContext: (command: Command, bot: BotMessenger) => {
44
44
  * its section sets one.
45
45
  */
46
46
  guard: (permissionDefaults?: Readonly<Record<PermissionKey, Level>>) => import("../../sends/guard.js").SendGuard;
47
- /** A chat id, `user:<id>`, or the title of a chat this bot has seen — never a guess. */
47
+ /** A chat id, `user:<id>`, a `chat:` or `msg:` reference, or the title of a chat this bot has seen — never a guess. */
48
48
  chatRef: (typed: string) => BotChatRef;
49
49
  readSecret: (prompt: string) => Promise<string>;
50
50
  /** A client for this token, closed when the command ends. */
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { chatReferenceOf, wrongAccount } from "../../domain/ref-arguments.js";
2
3
  import { pickChat } from "../../resolve.js";
3
4
  import { sendGuard } from "../../sends/guard.js";
4
5
  import { SendJournal } from "../../sends/journal.js";
@@ -75,9 +76,16 @@ export const botContext = (command, bot) => {
75
76
  recipients: recipients(),
76
77
  warn: base.renderer.warn,
77
78
  }),
78
- /** A chat id, `user:<id>`, or the title of a chat this bot has seen — never a guess. */
79
+ /** A chat id, `user:<id>`, a `chat:` or `msg:` reference, or the title of a chat this bot has seen — never a guess. */
79
80
  chatRef: (typed) => {
80
81
  const trimmed = typed.trim();
82
+ const named = chatReferenceOf(trimmed, bot.provider);
83
+ if (named) {
84
+ const own = registry.botId();
85
+ if (own === undefined || own !== named.account)
86
+ throw wrongAccount(named, { account: own, profile });
87
+ return named.chat;
88
+ }
81
89
  if (/^-?\d+$/.test(trimmed) || /^user:\d+$/.test(trimmed))
82
90
  return trimmed;
83
91
  const known = registry.list();
@@ -63,6 +63,7 @@ const seen = (context, message) => {
63
63
  context.registry.observe([{ id: message.chatId }]);
64
64
  };
65
65
  const sendCommand = (bot) => annotate(new Command("send"), {
66
+ literal_after_dashes: true,
66
67
  effects: REMOTE_WRITE,
67
68
  examples: botExamples(bot, 'messages send Team "Doors open at 9"', 'messages send Team "Here it is" --file report.pdf'),
68
69
  })
@@ -199,6 +200,7 @@ const showCommand = (bot) => annotate(new Command("show"), { effects: REMOTE_REA
199
200
  });
200
201
  });
201
202
  const editCommand = (bot) => annotate(new Command("edit"), {
203
+ literal_after_dashes: true,
202
204
  effects: REMOTE_WRITE,
203
205
  examples: botExamples(bot, 'messages edit Team 101 "Doors open at 10"'),
204
206
  })
@@ -53,6 +53,7 @@ export const botMenuCommand = (bot) => {
53
53
  });
54
54
  };
55
55
  annotate(command.command("set"), {
56
+ literal_after_dashes: true,
56
57
  effects: REMOTE_WRITE,
57
58
  examples: botExamples(bot, 'commands set "start=Begin here" "help=What I can do"'),
58
59
  })
@@ -65,11 +65,11 @@ export const sharedCommandTemplate = (app, name) => {
65
65
  annotate(notes.command("show"), { effects: LOCAL_READ, output: { entity: "note" } })
66
66
  .description("show one note")
67
67
  .argument("<note>", "stored note reference");
68
- annotate(notes.command("add"), { effects: LOCAL_WRITE, output: { entity: "note" } })
68
+ annotate(notes.command("add"), { effects: LOCAL_WRITE, output: { entity: "note" }, literal_after_dashes: true })
69
69
  .description("write a note")
70
70
  .argument("<text>", "note text")
71
71
  .option("--subject <ref>", "stored resource the note describes");
72
- annotate(notes.command("edit"), { effects: LOCAL_WRITE, output: { entity: "note" } })
72
+ annotate(notes.command("edit"), { effects: LOCAL_WRITE, output: { entity: "note" }, literal_after_dashes: true })
73
73
  .description("change a note")
74
74
  .argument("<note>", "stored note reference")
75
75
  .argument("<text>", "replacement text");