clavarium-tether 0.1.0-next.21 → 0.1.0-next.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.mjs CHANGED
@@ -41,12 +41,15 @@ export const HELP = `${NAME}: act on the targets you were given (machines, folde
41
41
  ${NAME} mail TARGET move UID MAILBOX move a message to a folder
42
42
  ${NAME} mail TARGET flag UID read|unread|flagged|unflagged
43
43
  ${NAME} mail TARGET delete UID delete a message (to Trash / Deleted Items)
44
- ${NAME} mail TARGET send --to "a@b,c@d" --subject "..." --body "..." [--cc "..."] [--attach FILE]
44
+ ${NAME} mail TARGET send --to "a@b,c@d" --subject "..." --body "..." [--cc "..."] [--attach FILE ...] (--attach repeats, or --attach A,B)
45
45
  ${NAME} calendar TARGET list [--from DATE] [--to DATE] events in a window (default: from now on)
46
46
  ${NAME} calendar TARGET read ID
47
- ${NAME} calendar TARGET create --title "..." --start ISO --end ISO [--where "..."]
48
- ${NAME} calendar TARGET update EVENT_ID [--title "..."] [--start ISO] [--end ISO] [--where "..."] (Microsoft)
49
- ${NAME} calendar TARGET attach EVENT_ID FILE join a file to an event (Microsoft)
47
+ ${NAME} calendar TARGET freebusy "a@b,c@d" [--from DATE] [--to DATE] when others are busy (Microsoft)
48
+ ${NAME} calendar TARGET create --title "..." --start ISO --end ISO [--where "..."] [--notes "..."] [--with "a@b,c@d"] [--category "Internal"]
49
+ ${NAME} calendar TARGET update EVENT_ID [--title] [--start] [--end] [--where] [--notes] [--with] [--category NAME|none] (Microsoft)
50
+ ${NAME} calendar TARGET categories the mailbox's categories (name, colour; Microsoft)
51
+ ${NAME} calendar TARGET list --category "Travel" only the events of one category (Microsoft)
52
+ ${NAME} calendar TARGET attach EVENT_ID FILE [--share] join a file to an event (Microsoft); --share if a participant is external
50
53
  ${NAME} calendar TARGET detach EVENT_ID NAME|N remove an attachment from an event (Microsoft)
51
54
  ${NAME} calendar TARGET delete EVENT_ID delete an event
52
55
  ${NAME} calendar TARGET respond EVENT_ID accept|decline|tentative answer an invitation (Microsoft)
@@ -69,7 +72,7 @@ ${NAME} ${VERSION}`;
69
72
  const FILE_VERBS = ['list', 'read', 'search', 'write', 'move', 'delete'];
70
73
  const BROWSER_VERBS = ['tabs', 'open', 'shot', 'html', 'console', 'eval', 'click', 'type', 'key', 'scroll', 'drag'];
71
74
  const MAIL_VERBS = ['list', 'read', 'source', 'attachment', 'search', 'move', 'flag', 'delete', 'send'];
72
- const CAL_VERBS = ['list', 'read', 'create', 'update', 'attach', 'detach', 'delete', 'respond'];
75
+ const CAL_VERBS = ['list', 'read', 'freebusy', 'create', 'update', 'attach', 'detach', 'delete', 'respond', 'categories'];
73
76
  const CONTACT_VERBS = ['list', 'search', 'read', 'create', 'update', 'delete', 'attach'];
74
77
  const baseName = (p) => String(p).split('/').pop();
75
78
  const readB64 = (p) => { try { return readFileSync(p).toString('base64'); } catch { return null; } };
@@ -82,6 +85,10 @@ export function read(argv) {
82
85
  const a = argv[i];
83
86
  if (a === '--enter') { flags.enter = true; continue; }
84
87
  if (a === '--readable') { flags.readable = true; continue; }
88
+ if (a === '--share') { flags.share = true; continue; }
89
+ /* --attach may be given several times, and each value may list files with commas. */
90
+ if (a === '--attach') { if (argv[i + 1] === undefined) return { refused: '--attach needs a file' }; (flags.attach ??= []).push(...String(argv[++i]).split(',').map((x) => x.trim()).filter(Boolean)); continue; }
91
+ if (a === '--category') { if (argv[i + 1] === undefined) return { refused: '--category needs a name (or none)' }; (flags.category ??= []).push(...String(argv[++i]).split(',').map((x) => x.trim()).filter(Boolean)); continue; }
85
92
  if (a.startsWith('--')) { if (argv[i + 1] === undefined) return { refused: `${a} needs a value` }; flags[a.slice(2)] = argv[++i]; continue; }
86
93
  words.push(a);
87
94
  }
@@ -144,7 +151,11 @@ export function read(argv) {
144
151
  const bad = need(flags.to !== undefined && flags.subject !== undefined && flags.body !== undefined, 'mail TARGET send --to "a@b" --subject "..." --body "..." [--cc "..."] [--attach FILE]');
145
152
  if (bad) return bad;
146
153
  let att = {};
147
- if (flags.attach !== undefined) { const b = readB64(flags.attach); if (b === null) return { refused: `cannot read the file to attach: ${flags.attach}` }; att = { attach: b, attachName: flags['attach-name'] || baseName(flags.attach) }; }
154
+ if (flags.attach !== undefined) {
155
+ const files = [];
156
+ for (const path of flags.attach) { const b = readB64(path); if (b === null) return { refused: `cannot read the file to attach: ${path}` }; files.push({ name: baseName(path), bytes: b }); }
157
+ att = { attachments: files };
158
+ }
148
159
  return ['mail.send', { target, to: flags.to, subject: flags.subject, body: flags.body, ...(flags.cc !== undefined ? { cc: flags.cc } : {}), ...att, ...why }];
149
160
  }
150
161
  }
@@ -152,19 +163,23 @@ export function read(argv) {
152
163
  case 'calendar': {
153
164
  const [what, ...p] = rest;
154
165
  if (!target || !CAL_VERBS.includes(what)) return { refused: `${NAME} calendar TARGET ${CAL_VERBS.join('|')} ...` };
166
+ /* --category is repeatable (an event may wear several); "--category none" clears them. */
167
+ const catArr = flags.category ? (flags.category.length === 1 && flags.category[0].toLowerCase() === 'none' ? [] : flags.category) : undefined;
155
168
  switch (what) {
156
- case 'list': return ['calendar.list', { target, ...(flags.from !== undefined ? { from: flags.from } : {}), ...(flags.to !== undefined ? { to: flags.to } : {}) }];
169
+ case 'categories': return ['calendar.categories', { target }];
170
+ case 'list': return ['calendar.list', { target, ...(flags.from !== undefined ? { from: flags.from } : {}), ...(flags.to !== undefined ? { to: flags.to } : {}), ...(catArr && catArr.length ? { category: catArr[0] } : {}) }];
157
171
  case 'read': return need(p[0], 'calendar TARGET read ID') ?? ['calendar.read', { target, id: p[0] }];
172
+ case 'freebusy': return need(p[0], 'calendar TARGET freebusy "a@b,c@d" [--from DATE] [--to DATE]') ?? ['calendar.freebusy', { target, who: p.join(','), ...(flags.from !== undefined ? { from: flags.from } : {}), ...(flags.to !== undefined ? { to: flags.to } : {}) }];
158
173
  case 'attach': {
159
174
  if (p.length < 2) return { refused: 'calendar TARGET attach EVENT_ID FILE' };
160
175
  const b = readB64(p[1]); if (b === null) return { refused: `cannot read the file to attach: ${p[1]}` };
161
- return ['calendar.attach', { target, id: p[0], attach: b, attachName: flags['attach-name'] || baseName(p[1]), ...why }];
176
+ return ['calendar.attach', { target, id: p[0], attach: b, attachName: flags['attach-name'] || baseName(p[1]), ...(flags.share !== undefined ? { share: true } : {}), ...why }];
162
177
  }
163
178
  case 'detach': return need(p.length >= 2, 'calendar TARGET detach EVENT_ID NAME|N') ?? ['calendar.detach', { target, id: p[0], which: p.slice(1).join(' '), ...why }];
164
179
  case 'delete': return need(p[0], 'calendar TARGET delete EVENT_ID') ?? ['calendar.delete', { target, id: p[0], ...why }];
165
180
  case 'respond': return need(p.length >= 2, 'calendar TARGET respond EVENT_ID accept|decline|tentative [--comment "..."]') ?? ['calendar.respond', { target, id: p[0], answer: p[1], ...(flags.comment !== undefined ? { comment: flags.comment } : {}), ...why }];
166
- case 'update': return need(p[0], 'calendar TARGET update EVENT_ID [--title "..."] [--start ISO] [--end ISO] [--where "..."]') ?? ['calendar.update', { target, id: p[0], ...(flags.title !== undefined ? { title: flags.title } : {}), ...(flags.start !== undefined ? { start: flags.start } : {}), ...(flags.end !== undefined ? { end: flags.end } : {}), ...(flags.where !== undefined ? { where: flags.where } : {}), ...why }];
167
- default: return need(flags.title !== undefined && flags.start !== undefined && flags.end !== undefined, 'calendar TARGET create --title "..." --start ISO --end ISO [--where "..."]') ?? ['calendar.create', { target, title: flags.title, start: flags.start, end: flags.end, ...(flags.where !== undefined ? { where: flags.where } : {}), ...why }];
181
+ case 'update': return need(p[0], 'calendar TARGET update EVENT_ID [--title] [--start] [--end] [--where] [--notes] [--with] [--category NAME|none]') ?? ['calendar.update', { target, id: p[0], ...(flags.title !== undefined ? { title: flags.title } : {}), ...(flags.start !== undefined ? { start: flags.start } : {}), ...(flags.end !== undefined ? { end: flags.end } : {}), ...(flags.where !== undefined ? { where: flags.where } : {}), ...(flags.notes !== undefined ? { notes: flags.notes } : {}), ...(flags.with !== undefined ? { with: flags.with } : {}), ...(catArr !== undefined ? { categories: catArr } : {}), ...why }];
182
+ default: return need(flags.title !== undefined && flags.start !== undefined && flags.end !== undefined, 'calendar TARGET create --title "..." --start ISO --end ISO [--where "..."] [--notes "..."] [--with "a@b,c@d"] [--category NAME]') ?? ['calendar.create', { target, title: flags.title, start: flags.start, end: flags.end, ...(flags.where !== undefined ? { where: flags.where } : {}), ...(flags.notes !== undefined ? { notes: flags.notes } : {}), ...(flags.with !== undefined ? { with: flags.with } : {}), ...(catArr && catArr.length ? { categories: catArr } : {}), ...why }];
168
183
  }
169
184
  }
170
185
  case 'contacts': {
@@ -9,7 +9,7 @@
9
9
  * target.secrets.refresh the NAME of the refresh token in the host's secret store
10
10
  * target.secrets.secret optional: the NAME of an own client secret
11
11
  *
12
- * Reads (watch): calendar.list, calendar.read. Acts (above watch): calendar.create.
12
+ * Reads (watch): calendar.list, calendar.read, calendar.freebusy. Acts (above watch): calendar.create.
13
13
  * `fetch` is injected for tests. (Note: the Thunderbird client covers Google calendar
14
14
  * cleanly; a Microsoft calendar needs the right Graph scope on the connected account.)
15
15
  */
@@ -17,6 +17,9 @@ import { no, Refused } from '../../server/verbs.mjs';
17
17
  import { refreshAccess, thunderbird, PROVIDERS } from '../oauth.mjs';
18
18
 
19
19
  const LIST_MAX = 200; /* events a list returns at most, then it says so (a year holds many) */
20
+ const TZ = 'Europe/Paris'; /* the person's timezone: events are shown and created in it, not UTC */
21
+ /* A Graph/ISO datetime, shown short and local: "2026-11-10 16:00". */
22
+ const shortWhen = (dt) => { const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(String(dt || '')); return m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}` : (dt || '?'); };
20
23
  const READ_MAX = 256 * 1024;
21
24
  const dateField = (s) => (/^\d{4}-\d{2}-\d{2}$/.test(s) ? { date: s } : { dateTime: s });
22
25
 
@@ -33,15 +36,34 @@ const API = {
33
36
  /* calendarView, not /events: /events orders over the WHOLE calendar, so it
34
37
  returns the oldest events, not the next ones. calendarView takes a window
35
38
  (from now on) and expands recurrences, so "list" means what is coming. */
36
- list: (n, { from, to } = {}) => `https://graph.microsoft.com/v1.0/me/calendarView?startDateTime=${encodeURIComponent(from)}&endDateTime=${encodeURIComponent(to)}&$top=${n}&$orderby=start/dateTime&$select=id,subject,start,end,location`,
39
+ list: (n, { from, to } = {}) => `https://graph.microsoft.com/v1.0/me/calendarView?startDateTime=${encodeURIComponent(from)}&endDateTime=${encodeURIComponent(to)}&$top=${n}&$orderby=start/dateTime&$select=id,subject,start,end,location,categories`,
37
40
  event: (id) => `https://graph.microsoft.com/v1.0/me/events/${encodeURIComponent(id)}`,
38
41
  create: 'https://graph.microsoft.com/v1.0/me/events',
39
42
  items: (j) => j.value ?? [],
40
- brief: (e) => `${e.id}\n ${e.start?.dateTime ?? '?'} — ${e.subject ?? '(no title)'}${e.location?.displayName ? ` @ ${e.location.displayName}` : ''}`,
41
- body: ({ title, start, end, where }) => ({ subject: title, start: { dateTime: start, timeZone: 'UTC' }, end: { dateTime: end, timeZone: 'UTC' }, ...(where ? { location: { displayName: where } } : {}) }),
43
+ brief: (e) => `${e.id}\n ${shortWhen(e.start?.dateTime)} — ${e.subject ?? '(no title)'}${e.location?.displayName ? ` @ ${e.location.displayName}` : ''}${e.categories?.length ? ` [${e.categories.join(', ')}]` : ''}`,
44
+ body: ({ title, start, end, where, notes, attendees, categories }) => ({
45
+ subject: title,
46
+ start: { dateTime: start, timeZone: TZ }, end: { dateTime: end, timeZone: TZ },
47
+ ...(where ? { location: { displayName: where } } : {}),
48
+ ...(notes ? { body: { contentType: 'text', content: String(notes) } } : {}),
49
+ ...(attendees?.length ? { attendees: attendees.map((a) => ({ emailAddress: { address: a }, type: 'required' })) } : {}),
50
+ ...(categories?.length ? { categories } : {}),
51
+ }),
42
52
  },
43
53
  };
44
54
 
55
+ /* Outlook's own category colours (Graph preset ids), shown by name when the
56
+ * mailbox's categories are listed. Reference data, not a setting. */
57
+ const COLOR_NAME = {
58
+ none: 'no colour', preset0: 'red', preset1: 'orange', preset2: 'brown', preset3: 'yellow', preset4: 'green',
59
+ preset5: 'teal', preset6: 'olive', preset7: 'blue', preset8: 'purple', preset9: 'cranberry', preset10: 'steel',
60
+ preset11: 'dark steel', preset12: 'grey', preset13: 'dark grey', preset14: 'black', preset15: 'dark red',
61
+ preset16: 'dark orange', preset17: 'dark brown', preset18: 'dark yellow', preset19: 'dark green', preset20: 'dark teal',
62
+ preset21: 'dark olive', preset22: 'dark blue', preset23: 'dark purple', preset24: 'dark cranberry',
63
+ };
64
+ /* The categories asked for, as a clean list (empty array clears them). */
65
+ const catsOf = (a) => (Array.isArray(a.categories) ? a.categories.map((c) => String(c).trim()).filter(Boolean) : (a.categories ? [String(a.categories).trim()].filter(Boolean) : null));
66
+
45
67
  export function calendar({ fetch: fetchImpl = globalThis.fetch } = {}) {
46
68
  const tokens = new Map();
47
69
  const providerOf = (target) => { const p = String(target.config?.provider ?? ''); if (!API[p] || !PROVIDERS[p]) no(`${target.title}: set its provider to google or microsoft`); return p; };
@@ -63,7 +85,9 @@ export function calendar({ fetch: fetchImpl = globalThis.fetch } = {}) {
63
85
 
64
86
  async function call(target, ctx, method, url, json) {
65
87
  const token = await access(target, ctx);
66
- const headers = { Authorization: `Bearer ${token}` };
88
+ /* Ask Microsoft to answer in the person's timezone, not UTC, so a 4pm
89
+ meeting reads 16:00 and not 15:00. Harmless for the Google REST path. */
90
+ const headers = { Authorization: `Bearer ${token}`, Prefer: `outlook.timezone="${TZ}"` };
67
91
  if (json !== undefined) headers['Content-Type'] = 'application/json';
68
92
  let res;
69
93
  try { res = await fetchImpl(url, { method, headers, ...(json !== undefined ? { body: JSON.stringify(json) } : {}) }); }
@@ -144,12 +168,15 @@ export function calendar({ fetch: fetchImpl = globalThis.fetch } = {}) {
144
168
  return { text, size: Buffer.byteLength(text) };
145
169
  };
146
170
  /* Google over CalDAV (the REST API its client cannot use); Microsoft over Graph. */
147
- if (provider === 'google') return pack(await googleList(target, ctx, { from, to }));
171
+ if (provider === 'google') { if (a.category) no(`${target.title}: filtering by category is Microsoft-only`); return pack(await googleList(target, ctx, { from, to })); }
148
172
  const api = API[provider];
149
173
  const res = await call(target, ctx, 'GET', api.list(LIST_MAX, { from, to }));
150
174
  if (!res.ok) no(`${target.title}: ${res.status} ${await bodyError(res)}`.trim());
151
175
  const json = await res.json();
152
- return pack(api.items(json).map(api.brief), Boolean(json['@odata.nextLink']));
176
+ let items = api.items(json);
177
+ /* Only one category's events, matched by name without case: all this month's travel, say. */
178
+ if (a.category) { const want = String(a.category).toLowerCase(); items = items.filter((e) => (e.categories ?? []).some((c) => String(c).toLowerCase() === want)); }
179
+ return pack(items.map(api.brief), Boolean(json['@odata.nextLink']));
153
180
  },
154
181
  /* One event in full, by its id. */
155
182
  async 'calendar.read'(target, a, ctx) {
@@ -162,12 +189,53 @@ export function calendar({ fetch: fetchImpl = globalThis.fetch } = {}) {
162
189
  const text = JSON.stringify(await res.json(), null, 1).slice(0, READ_MAX);
163
190
  return { text, size: Buffer.byteLength(text) };
164
191
  },
192
+ /* See when others are busy, to find a time together. A read: it returns
193
+ only busy/free blocks (and a title only where the person already shares
194
+ it), never the contents of their calendars. `who` is a comma-separated
195
+ list of addresses; `from`/`to` bound the window (default: the next 7
196
+ days). Microsoft over Graph getSchedule; a Google free/busy query comes
197
+ with parity (its REST path needs the account's own OAuth app). */
198
+ async 'calendar.freebusy'(target, a, ctx) {
199
+ const provider = providerOf(target);
200
+ if (provider !== 'microsoft') no(`${target.title}: reading others' availability is Microsoft-only for now`);
201
+ const who = String(a.who ?? '').split(',').map((x) => x.trim()).filter(Boolean);
202
+ if (!who.length) no('freebusy needs at least one address (comma-separated)');
203
+ if (who.length > 20) no('freebusy looks at up to 20 people at once');
204
+ /* A window in the person's own wall-clock (Europe/Paris), passed with
205
+ that timezone so "09:00" means 09:00 there. Default: now → +7 days. */
206
+ const localISO = (d) => { const p = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(d).filter((x) => x.type !== 'literal').map((x) => [x.type, x.value])); return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:00`; };
207
+ const norm = (s) => { const t = String(s).trim(); if (/^\d{4}-\d{2}-\d{2}$/.test(t)) return `${t}T00:00:00`; if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(t)) return `${t}:00`; return t; };
208
+ const now = new Date();
209
+ const from = a.from ? norm(a.from) : localISO(now);
210
+ const to = a.to ? norm(a.to) : localISO(new Date(now.getTime() + 7 * 86_400_000));
211
+ const res = await call(target, ctx, 'POST', 'https://graph.microsoft.com/v1.0/me/calendar/getSchedule', {
212
+ schedules: who,
213
+ startTime: { dateTime: from, timeZone: TZ }, endTime: { dateTime: to, timeZone: TZ },
214
+ availabilityViewInterval: 60,
215
+ });
216
+ if (!res.ok) no(`${target.title}: availability could not be read (${res.status} ${await bodyError(res)})`.trim());
217
+ const value = (await res.json()).value ?? [];
218
+ const label = { tentative: 'tentative', busy: 'busy', oof: 'out of office', workingElsewhere: 'working elsewhere' };
219
+ const blocks = value.map((s) => {
220
+ const id = s.scheduleId || '(unknown)';
221
+ if (s.error) return `${id}\n could not be read (no access, or unknown address)`;
222
+ const items = (s.scheduleItems ?? []).filter((it) => it.status && it.status !== 'free');
223
+ if (!items.length) return `${id}\n free for the whole window`;
224
+ return `${id}\n` + items.map((it) => ` ${label[it.status] || it.status} ${shortWhen(it.start?.dateTime)} — ${shortWhen(it.end?.dateTime)}${it.subject ? ` (${it.subject})` : ''}`).join('\n');
225
+ });
226
+ const text = value.length ? `Availability ${shortWhen(from)} → ${shortWhen(to)} (${TZ}):\n\n${blocks.join('\n\n')}` : 'No availability returned.';
227
+ return { text, size: Buffer.byteLength(text) };
228
+ },
165
229
  /* Create an event. An act: the start and end are ISO times (a bare date is all-day). */
166
230
  async 'calendar.create'(target, a, ctx) {
167
- const api = API[providerOf(target)];
231
+ const provider = providerOf(target);
232
+ const api = API[provider];
168
233
  if (!a.title) no('an event needs a title');
169
- if (!a.start || !a.end) no('an event needs a start and an end (ISO times, e.g. 2030-01-02T09:00:00Z)');
170
- const res = await call(target, ctx, 'POST', api.create, api.body({ title: a.title, start: a.start, end: a.end, where: a.where }));
234
+ if (!a.start || !a.end) no('an event needs a start and an end (local ISO times, e.g. 2026-11-10T16:00:00)');
235
+ const categories = catsOf(a);
236
+ if (categories && provider !== 'microsoft') no(`${target.title}: event categories are Microsoft-only`);
237
+ const attendees = String(a.with ?? '').split(',').map((x) => x.trim()).filter(Boolean);
238
+ const res = await call(target, ctx, 'POST', api.create, api.body({ title: a.title, start: a.start, end: a.end, where: a.where, notes: a.notes, attendees, categories: categories ?? undefined }));
171
239
  if (!res.ok) no(`${target.title}: the event was not created (${res.status} ${await bodyError(res)})`.trim());
172
240
  const made = await res.json().catch(() => ({}));
173
241
  return { text: `Created: ${a.title} (${a.start}). Its id is ${made.id ?? '(unknown)'}.`, size: 0 };
@@ -180,10 +248,32 @@ export function calendar({ fetch: fetchImpl = globalThis.fetch } = {}) {
180
248
  if (provider !== 'microsoft') no(`${target.title}: joining a file to an event is Microsoft-only for now`);
181
249
  if (!a.id) no('attach needs the event id (from the list)');
182
250
  if (!a.attach) no('attach needs the file (its base64 bytes)');
251
+ const ev = encodeURIComponent(String(a.id));
252
+ /* Who would receive the file: an event attachment reaches every participant
253
+ on the next invitation update or resend. Split internal from external by
254
+ the organiser's domain, and refuse an external audience unless `share`. */
255
+ const internals = []; const externals = [];
256
+ const info = await call(target, ctx, 'GET', `https://graph.microsoft.com/v1.0/me/events/${ev}?$select=attendees,organizer`);
257
+ if (info.ok) {
258
+ const j = await info.json().catch(() => ({}));
259
+ const org = (j.organizer?.emailAddress?.address || target.config?.user || '').toLowerCase();
260
+ const dom = org.split('@')[1] || '';
261
+ for (const at of (j.attendees ?? [])) {
262
+ const addr = at.emailAddress?.address; if (!addr || addr.toLowerCase() === org) continue;
263
+ (dom && addr.toLowerCase().endsWith('@' + dom) ? internals : externals).push(addr);
264
+ }
265
+ }
266
+ if (externals.length && a.share !== true) {
267
+ no(`${externals.length} external participant(s): ${externals.join(', ')}. The file would reach them on the next invitation update/resend. Add --share (CLI) or share:true (MCP) to attach anyway.`);
268
+ }
183
269
  const att = { '@odata.type': '#microsoft.graph.fileAttachment', name: String(a.attachName || 'attachment'), contentBytes: String(a.attach) };
184
- const res = await call(target, ctx, 'POST', `https://graph.microsoft.com/v1.0/me/events/${encodeURIComponent(String(a.id))}/attachments`, att);
270
+ const res = await call(target, ctx, 'POST', `https://graph.microsoft.com/v1.0/me/events/${ev}/attachments`, att);
185
271
  if (!res.ok) no(`${target.title}: the file was not attached (${res.status} ${await bodyError(res)})`.trim());
186
- return { text: `Attached ${a.attachName || 'the file'} to the event.`, size: 0 };
272
+ const all = internals.length + externals.length;
273
+ const who = all
274
+ ? ` It will reach ${all} participant(s) on the next invitation update/resend:${internals.length ? ` ${internals.join(', ')} (internal)` : ''}${externals.length ? `${internals.length ? ';' : ''} ${externals.join(', ')} (external)` : ''}.`
275
+ : ' Visible to you alone (the event has no participants).';
276
+ return { text: `Attached ${a.attachName || 'the file'} to the event.${who}`, size: 0 };
187
277
  },
188
278
  /* Remove a file from an event, by its name or 1-based number. An act.
189
279
  Microsoft only, like attaching (Graph deletes the attachment by id). */
@@ -247,12 +337,28 @@ export function calendar({ fetch: fetchImpl = globalThis.fetch } = {}) {
247
337
  if (a.start !== undefined) patch.start = { dateTime: String(a.start), timeZone: 'UTC' };
248
338
  if (a.end !== undefined) patch.end = { dateTime: String(a.end), timeZone: 'UTC' };
249
339
  if (a.where !== undefined) patch.location = { displayName: String(a.where) };
250
- if (!Object.keys(patch).length) no('update needs at least one of: title, start, end, where');
340
+ if (a.notes !== undefined) patch.body = { contentType: 'text', content: String(a.notes) };
341
+ if (a.with !== undefined) patch.attendees = String(a.with).split(',').map((x) => x.trim()).filter(Boolean).map((e) => ({ emailAddress: { address: e }, type: 'required' }));
342
+ if (a.categories !== undefined) patch.categories = catsOf(a) ?? []; /* --category none clears them */
343
+ if (!Object.keys(patch).length) no('update needs at least one of: title, start, end, where, notes, with, category');
251
344
  const res = await call(target, ctx, 'PATCH', API.microsoft.event(String(a.id)), patch);
252
345
  if (res.status === 404) no('no such event');
253
346
  if (!res.ok) no(`${target.title}: the event was not changed (${res.status} ${await bodyError(res)})`.trim());
254
347
  return { text: 'Updated the event.', size: 0 };
255
348
  },
349
+ /* The mailbox's own categories (name and colour), so the agent knows which
350
+ ones exist before it tags an event. A read. Microsoft only; a category is
351
+ a personal label, invisible to the other participants. */
352
+ async 'calendar.categories'(target, a, ctx) {
353
+ const provider = providerOf(target);
354
+ if (provider !== 'microsoft') no(`${target.title}: calendar categories are Microsoft-only`);
355
+ const res = await call(target, ctx, 'GET', 'https://graph.microsoft.com/v1.0/me/outlook/masterCategories');
356
+ if (!res.ok) no(`${target.title}: the categories could not be read (${res.status} ${await bodyError(res)})`.trim());
357
+ const cats = (await res.json()).value ?? [];
358
+ if (!cats.length) return { text: 'No category set in this mailbox yet (add them in Outlook, then tag events with --category).', size: 0 };
359
+ const text = cats.map((c) => `${c.displayName} (${COLOR_NAME[c.color] ?? c.color ?? 'no colour'})`).join('\n');
360
+ return { text, size: Buffer.byteLength(text) };
361
+ },
256
362
  };
257
363
 
258
364
  return {
@@ -15,6 +15,7 @@
15
15
  import { no, asDate } from '../../server/verbs.mjs';
16
16
 
17
17
  const GRAPH = 'https://graph.microsoft.com/v1.0';
18
+ const TZ = 'Europe/Paris'; /* answer in the person's timezone, not UTC */
18
19
  const LIST_MAX = 30;
19
20
  const READ_MAX = 512 * 1024;
20
21
  const SOURCE_MAX = 4 * 1024 * 1024;
@@ -36,13 +37,15 @@ export function graphMail({ fetch: fetchImpl = globalThis.fetch, token }) {
36
37
  const access = await token(target, ctx);
37
38
  const r = await fetchImpl(`${GRAPH}${path}`, {
38
39
  method,
39
- headers: { authorization: `Bearer ${access}`, ...(body ? { 'content-type': 'application/json' } : {}) },
40
+ headers: { authorization: `Bearer ${access}`, prefer: `outlook.timezone="${TZ}"`, ...(body ? { 'content-type': 'application/json' } : {}) },
40
41
  ...(body ? { body: JSON.stringify(body) } : {}),
41
42
  });
42
43
  if (r.status === 401 || r.status === 403) no(`${target.title}: not authorized -- reconnect the account (the Microsoft mail scope may be missing)`);
43
44
  if (!r.ok) { let m = ''; try { m = (await r.json())?.error?.message ?? ''; } catch { /* no json */ } no(`${target.title}: ${m || `the mail request failed (${r.status})`}`); }
44
- if (r.status === 204) return null;
45
- return r.json();
45
+ /* Some acts answer with no body (sendMail is 202, a delete is 204): parse the
46
+ body only if there is one, so a successful send is not a JSON-parse error. */
47
+ const text = await r.text();
48
+ return text ? JSON.parse(text) : null;
46
49
  }
47
50
 
48
51
  const brief = (m) => `#${m.id}\n ${m.receivedDateTime ?? ''}\n From: ${addrsOf([m.from])}\n Subject: ${m.subject ?? ''}`;
@@ -159,18 +162,20 @@ export function graphMail({ fetch: fetchImpl = globalThis.fetch, token }) {
159
162
  const to = String(a.to ?? '').split(',').map((x) => x.trim()).filter(Boolean);
160
163
  if (!to.length) no('send needs a recipient (to)');
161
164
  const cc = String(a.cc ?? '').split(',').map((x) => x.trim()).filter(Boolean);
162
- /* A file joined to the message: its bytes, already base64 (what Graph
163
- wants), and a name. One attachment for now (the common need). */
164
- const attach = String(a.attach ?? '');
165
+ /* Files joined to the message: bytes already base64 (what Graph wants)
166
+ and a name. Several possible: an `attachments` array, or one
167
+ `attach`+`attachName` (back-compat). */
168
+ const files = (Array.isArray(a.attachments) ? a.attachments : (a.attach ? [{ name: a.attachName, bytes: a.attach }] : []))
169
+ .map((f) => ({ name: String(f?.name || 'attachment'), bytes: String(f?.bytes ?? '') })).filter((f) => f.bytes);
165
170
  const message = {
166
171
  subject: String(a.subject ?? ''),
167
172
  body: { contentType: 'text', content: String(a.body ?? '') },
168
173
  toRecipients: to.map((address) => ({ emailAddress: { address } })),
169
174
  ...(cc.length ? { ccRecipients: cc.map((address) => ({ emailAddress: { address } })) } : {}),
170
- ...(attach ? { attachments: [{ '@odata.type': '#microsoft.graph.fileAttachment', name: String(a.attachName || 'attachment'), contentBytes: attach }] } : {}),
175
+ ...(files.length ? { attachments: files.map((f) => ({ '@odata.type': '#microsoft.graph.fileAttachment', name: f.name, contentBytes: f.bytes })) } : {}),
171
176
  };
172
177
  await call(target, ctx, 'POST', '/me/sendMail', { message, saveToSentItems: true });
173
- return { text: `Sent to ${to.join(', ')}${attach ? ` with ${a.attachName || 'an attachment'}` : ''}.`, size: 0 };
178
+ return { text: `Sent to ${to.join(', ')}${files.length ? ` with ${files.map((f) => f.name).join(', ')}` : ''}.`, size: 0 };
174
179
  },
175
180
  };
176
181
  }
@@ -405,10 +405,11 @@ export function mail({ fetch: fetchImpl = globalThis.fetch, connect = tlsConnect
405
405
  if (Number(target.config?.smtpPort) > 0) port = Number(target.config.smtpPort);
406
406
  let sent;
407
407
  try {
408
- sent = await smtpSend({ host, port, auth, from: user, to, subject: a.subject, body: a.body, attach: a.attach, attachName: a.attachName,
408
+ sent = await smtpSend({ host, port, auth, from: user, to, subject: a.subject, body: a.body, attach: a.attach, attachName: a.attachName, attachments: a.attachments,
409
409
  ...(smtpConnect ? { connect: smtpConnect } : {}), ...(smtpSecure ? { secure: smtpSecure } : {}) });
410
410
  } catch (e) { if (e instanceof Refused) throw e; no(`${target.title}: ${e?.message ?? 'the message could not be sent'}`); }
411
- return { text: `Sent to ${sent.join(', ')}${a.attach ? ` with ${a.attachName || 'an attachment'}` : ''}.`, size: 0 };
411
+ const names = Array.isArray(a.attachments) ? a.attachments.map((f) => f.name) : (a.attach ? [a.attachName || 'an attachment'] : []);
412
+ return { text: `Sent to ${sent.join(', ')}${names.length ? ` with ${names.join(', ')}` : ''}.`, size: 0 };
412
413
  },
413
414
  };
414
415
 
package/host/smtp.mjs CHANGED
@@ -19,22 +19,30 @@ export function address(a) {
19
19
  return s;
20
20
  }
21
21
 
22
- /* An RFC-822 message from plain fields; headers come only from here. With an
23
- `attach` (base64 bytes) + `attachName`, a multipart/mixed message: the text and
24
- then the file, so a message can carry one joined file. */
25
- export function buildMessage({ from, to, subject, body, attach, attachName, date = new Date() }) {
22
+ /* Several files joined to a message, as {name, bytes(base64)}. Takes an
23
+ `attachments` array, or a single `attach` + `attachName` (back-compat). */
24
+ export function joinedFiles({ attach, attachName, attachments } = {}) {
25
+ const list = Array.isArray(attachments) ? attachments : (attach ? [{ name: attachName, bytes: attach }] : []);
26
+ return list.map((f) => ({ name: String(f?.name || 'attachment').replace(/[\r\n"\\]/g, ''), bytes: String(f?.bytes ?? '') })).filter((f) => f.bytes);
27
+ }
28
+
29
+ /* An RFC-822 message from plain fields; headers come only from here. With one or
30
+ more joined files (`attachments`, or a single `attach`+`attachName`), a
31
+ multipart/mixed message: the text, then each file. */
32
+ export function buildMessage({ from, to, subject, body, attach, attachName, attachments, date = new Date() }) {
26
33
  const header = (name, value) => `${name}: ${String(value).replace(/[\r\n]+/g, ' ').trim()}`;
27
34
  const top = [header('From', from), header('To', to.join(', ')), header('Subject', subject ?? ''), header('Date', date.toUTCString()), 'MIME-Version: 1.0'];
28
35
  const text = String(body ?? '').replace(/\r?\n/g, '\r\n');
29
- if (attach) {
30
- const name = String(attachName || 'attachment').replace(/[\r\n"\\]/g, '');
36
+ const files = joinedFiles({ attach, attachName, attachments });
37
+ if (files.length) {
31
38
  const b = `b_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
32
- const b64 = String(attach).replace(/\s+/g, '').replace(/(.{76})/g, '$1\r\n');
33
- return [...top,
34
- `Content-Type: multipart/mixed; boundary="${b}"`, '',
35
- `--${b}`, 'Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: 8bit', '', text,
36
- `--${b}`, `Content-Type: application/octet-stream; name="${name}"`, 'Content-Transfer-Encoding: base64', `Content-Disposition: attachment; filename="${name}"`, '', b64,
37
- `--${b}--`, ''].join('\r\n');
39
+ const parts = [`--${b}`, 'Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: 8bit', '', text];
40
+ for (const f of files) {
41
+ const b64 = f.bytes.replace(/\s+/g, '').replace(/(.{76})/g, '$1\r\n');
42
+ parts.push(`--${b}`, `Content-Type: application/octet-stream; name="${f.name}"`, 'Content-Transfer-Encoding: base64', `Content-Disposition: attachment; filename="${f.name}"`, '', b64);
43
+ }
44
+ parts.push(`--${b}--`, '');
45
+ return [...top, `Content-Type: multipart/mixed; boundary="${b}"`, '', ...parts].join('\r\n');
38
46
  }
39
47
  const lines = [...top, 'Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: 8bit', '', text];
40
48
  return lines.join('\r\n');
@@ -72,7 +80,7 @@ class Line {
72
80
  /* Send one message. `auth` is { xoauth2 } for a provider, or { user, password } for
73
81
  a classic box. `connect` opens the plain socket, `secure` upgrades it after
74
82
  STARTTLS (tls.connect wrapping the same socket). */
75
- export async function send({ host, port = 587, auth, from, to, subject, body, attach, attachName, connect = netConnect, secure = (opts) => tlsConnect(opts) }) {
83
+ export async function send({ host, port = 587, auth, from, to, subject, body, attach, attachName, attachments, connect = netConnect, secure = (opts) => tlsConnect(opts) }) {
76
84
  const sender = address(from);
77
85
  const rcpts = (Array.isArray(to) ? to : [to]).map(address);
78
86
  if (!rcpts.length) throw new Error('no recipient');
@@ -98,7 +106,7 @@ export async function send({ host, port = 587, auth, from, to, subject, body, at
98
106
  await step(`MAIL FROM:<${sender}>`, 250);
99
107
  for (const r of rcpts) await step(`RCPT TO:<${r}>`, 250);
100
108
  await step('DATA', 354);
101
- const message = buildMessage({ from: sender, to: rcpts, subject, body, attach, attachName })
109
+ const message = buildMessage({ from: sender, to: rcpts, subject, body, attach, attachName, attachments })
102
110
  .split('\r\n').map((l) => (l.startsWith('.') ? `.${l}` : l)).join('\r\n'); /* dot-stuffing */
103
111
  io.write(`${message}\r\n.\r\n`);
104
112
  const done = await io.reply();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium-tether",
3
- "version": "0.1.0-next.21",
3
+ "version": "0.1.0-next.23",
4
4
  "description": "Clavarium Tether: an AI agent acts outside its own machine on a tether: levels per target, a person's approval, a journal and a stop (a command and an MCP server for agents, a keeper and a screen for the application). One dependency: ssh2, for the SSH connector.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/clavarium/clavarium-tether#readme",
package/server/mcp.mjs CHANGED
@@ -76,6 +76,7 @@ export async function ask(dir, verb, args = {}, { wait = WAIT_MS, grace = GRACE_
76
76
 
77
77
  /* ---- MCP ---- */
78
78
  const S = (description) => ({ type: 'string', description });
79
+ const CATS = { type: 'array', description: 'personal Outlook categories (colour labels, invisible to the other participants), e.g. ["Internal"]; on update, [] clears them', items: { type: 'string' } };
79
80
  const TARGET = S('a target you were given (see targets)');
80
81
  const WHY = S('one short line for the person: why you do this');
81
82
  const TAB = { type: 'number', description: 'which granted tab (its id from browser-tabs); your active one by default' };
@@ -112,15 +113,17 @@ export const TOOLS = [
112
113
  tool('mail-move', 'Move a message to another folder. A person may approve: say why.', { target: TARGET, uid: S('the message id from a list'), mailbox: S('the destination folder'), why: WHY }, ['target', 'uid', 'mailbox']),
113
114
  tool('mail-flag', 'Mark a message read/unread or flagged/unflagged. A person may approve: say why.', { target: TARGET, uid: S('the message id from a list'), flag: S('read, unread, flagged or unflagged'), why: WHY }, ['target', 'uid', 'flag']),
114
115
  tool('mail-delete', 'Delete a message (to Trash / Deleted Items) by its number (uid). A person may approve: say why.', { target: TARGET, uid: S('the message id from a list'), mailbox: S('a folder; INBOX by default'), why: WHY }, ['target', 'uid']),
115
- tool('mail-send', 'Send a message from the mailbox, optionally with one file joined. A person may approve: say why.', { target: TARGET, to: S('recipients, comma-separated'), subject: { type: 'string' }, body: { type: 'string' }, cc: S('cc recipients, comma-separated, optional'), attach: S("a file to join, as base64 bytes (<=~750KB); read a file in your machine and base64 it"), attachName: S('the file name for the attachment'), why: WHY }, ['target', 'to', 'subject', 'body']),
116
- tool('calendar-list', 'Events of a calendar target, soonest first. By default from now on; give from and/or to (ISO date or date-time) to look at any past or future window, e.g. a given week or month.', { target: TARGET, from: S('start of the window, ISO date or date-time; now by default'), to: S('end of the window, ISO; a year after "from" by default') }, ['target']),
116
+ tool('mail-send', 'Send a message from the mailbox, optionally with one or more files joined (all of them go, or the send is refused). A person may approve: say why.', { target: TARGET, to: S('recipients, comma-separated'), subject: { type: 'string' }, body: { type: 'string' }, cc: S('cc recipients, comma-separated, optional'), attachments: { type: 'array', description: 'files to join, each { name, bytes } with bytes base64-encoded', items: { type: 'object', properties: { name: S('the file name'), bytes: S('the file as base64') }, required: ['bytes'] } }, attach: S("a single file to join, as base64 bytes (shorthand for one attachment)"), attachName: S('the file name, with attach'), why: WHY }, ['target', 'to', 'subject', 'body']),
117
+ tool('calendar-list', 'Events of a calendar target, soonest first. By default from now on; give from and/or to (ISO date or date-time) to look at any past or future window, e.g. a given week or month. Each event shows its categories. Give category to keep only one category\'s events (Microsoft).', { target: TARGET, from: S('start of the window, ISO date or date-time; now by default'), to: S('end of the window, ISO; a year after "from" by default'), category: S('only the events of this category (Microsoft)') }, ['target']),
118
+ tool('calendar-categories', "The calendar mailbox's own categories — their names and colours (Microsoft). A read. A category is a personal label, invisible to the other participants.", { target: TARGET }, ['target']),
117
119
  tool('calendar-read', 'One event in full by its id.', { target: TARGET, id: { type: 'string' } }, ['target', 'id']),
118
- tool('calendar-create', 'Create an event. A person may approve: say why.', { target: TARGET, title: { type: 'string' }, start: S('ISO date-time, or a date for all-day'), end: S('ISO date-time, or a date'), where: S('a place, optional'), why: WHY }, ['target', 'title', 'start', 'end']),
119
- tool('calendar-attach', 'Join a file to an event (Microsoft). A person may approve: say why.', { target: TARGET, id: S('the event id from a list'), attach: S('the file as base64 bytes'), attachName: S('the file name'), why: WHY }, ['target', 'id', 'attach']),
120
+ tool('calendar-freebusy', "See when other people are busy, to find a time together (Microsoft). Returns only busy/free blocks over the window, never their calendar contents. A read.", { target: TARGET, who: S('the people, comma-separated email addresses (up to 20)'), from: S('start of the window, local ISO date or date-time; now by default'), to: S('end of the window, local ISO; 7 days after "from" by default') }, ['target', 'who']),
121
+ tool('calendar-create', 'Create an event, with notes and invitees. Times are the person\'s local time. A person may approve: say why.', { target: TARGET, title: { type: 'string' }, start: S('local ISO date-time, or a date for all-day'), end: S('local ISO date-time, or a date'), where: S('a place, optional'), notes: S('the event body/notes, optional'), with: S('invitees, comma-separated emails, optional (they get an invitation)'), categories: CATS, why: WHY }, ['target', 'title', 'start', 'end']),
122
+ tool('calendar-attach', 'Join a file to an event (Microsoft). It reaches every participant on the next invitation update/resend; the result says who. Refused if a participant is external, unless share:true. A person may approve: say why.', { target: TARGET, id: S('the event id from a list'), attach: S('the file as base64 bytes'), attachName: S('the file name'), share: { type: 'boolean', description: 'attach even though a participant is external' }, why: WHY }, ['target', 'id', 'attach']),
120
123
  tool('calendar-detach', 'Remove an attachment from an event (Microsoft), by its file name or 1-based number. A person may approve: say why.', { target: TARGET, id: S('the event id from a list'), which: S('the attachment file name, or its number'), why: WHY }, ['target', 'id', 'which']),
121
124
  tool('calendar-delete', 'Delete an event. A person may approve: say why.', { target: TARGET, id: S('the event id from a list'), why: WHY }, ['target', 'id']),
122
125
  tool('calendar-respond', 'Answer a meeting invitation — accept, decline or tentative (Microsoft). A person may approve: say why.', { target: TARGET, id: S('the event id from a list'), answer: S('accept, decline or tentative'), comment: S('a short note to the organiser, optional'), why: WHY }, ['target', 'id', 'answer']),
123
- tool('calendar-update', 'Change an event — any of title, start, end, where (Microsoft). A person may approve: say why.', { target: TARGET, id: S('the event id from a list'), title: S('new title, optional'), start: S('new start, ISO, optional'), end: S('new end, ISO, optional'), where: S('new place, optional'), why: WHY }, ['target', 'id']),
126
+ tool('calendar-update', 'Change an event — any of title, start, end, where, notes, invitees (Microsoft). A person may approve: say why.', { target: TARGET, id: S('the event id from a list'), title: S('new title, optional'), start: S('new start, local ISO, optional'), end: S('new end, local ISO, optional'), where: S('new place, optional'), notes: S('new body/notes, optional'), with: S('invitees, comma-separated emails, optional'), categories: CATS, why: WHY }, ['target', 'id']),
124
127
  tool('contacts-list', 'The contacts of an address-book target: name, emails, phones.', { target: TARGET }, ['target']),
125
128
  tool('contacts-search', 'Contacts whose name or email matches a word, briefly.', { target: TARGET, text: S('a name or part of one to look for') }, ['target', 'text']),
126
129
  tool('contacts-read', 'One contact in full by its id.', { target: TARGET, id: { type: 'string' } }, ['target', 'id']),
package/server/name.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  export const NAME = 'tether';
7
7
  export const PRODUCT = 'Clavarium Tether';
8
8
  export const PACKAGE = `clavarium-${NAME}`;
9
- export const VERSION = '0.1.0-next.21';
9
+ export const VERSION = '0.1.0-next.23';
10
10
  /* The agent's folder: where its asks and the keeper's answers are written. */
11
11
  export const FOLDER = `.${NAME}`;
12
12
  /* $TETHER_DIR: the agent's folder; $TETHER_STORE: the keeper's store (serve). */
package/server/verbs.mjs CHANGED
@@ -17,6 +17,8 @@ const FOLDERS = ['computer', 'ssh', 'files'];
17
17
  /* Sizes an agent may send. What a target sends back is capped by its connector. */
18
18
  export const COMMAND_MAX = 4000;
19
19
  export const TEXT_MAX = 1_000_000;
20
+ /* The total base64 of a message's attachments (~22 MB of real files). Over it, the send is refused, not sent short. */
21
+ export const ATTACH_MAX = 30 * 1024 * 1024;
20
22
  export const PATH_MAX = 1000;
21
23
  export const WHY_MAX = 300;
22
24
 
@@ -70,12 +72,14 @@ export const VERBS = Object.freeze({
70
72
  'mail.send': { reads: false, kinds: ['mail'], needs: ['to', 'subject', 'body'] },
71
73
  'calendar.list': { reads: true, kinds: ['calendar'], needs: [] },
72
74
  'calendar.read': { reads: true, kinds: ['calendar'], needs: ['id'] },
75
+ 'calendar.freebusy': { reads: true, kinds: ['calendar'], needs: ['who'] },
73
76
  'calendar.create': { reads: false, kinds: ['calendar'], needs: ['title', 'start', 'end'] },
74
77
  'calendar.attach': { reads: false, kinds: ['calendar'], needs: ['id', 'attach'] },
75
78
  'calendar.detach': { reads: false, kinds: ['calendar'], needs: ['id', 'which'] },
76
79
  'calendar.delete': { reads: false, kinds: ['calendar'], needs: ['id'] },
77
80
  'calendar.respond': { reads: false, kinds: ['calendar'], needs: ['id', 'answer'] },
78
81
  'calendar.update': { reads: false, kinds: ['calendar'], needs: ['id'] },
82
+ 'calendar.categories': { reads: true, kinds: ['calendar'], needs: [] },
79
83
  'contacts.list': { reads: true, kinds: ['contacts'], needs: [] },
80
84
  'contacts.search': { reads: true, kinds: ['contacts'], needs: ['text'] },
81
85
  'contacts.read': { reads: true, kinds: ['contacts'], needs: ['id'] },
@@ -118,6 +122,9 @@ export function check(verb, a = {}) {
118
122
  };
119
123
  /* Which granted tab a browser verb acts on (optional: none means the agent's active one). */
120
124
  const tab = () => { if (a.tab !== undefined && a.tab !== null && a.tab !== '') out.tab = typeof a.tab === 'number' ? Math.trunc(a.tab) : oneLine(a.tab, 100); };
125
+ /* Event categories: a clean list, up to 25, each short. An empty list is kept
126
+ (it clears the categories on an update); undefined leaves them untouched. */
127
+ const cats = () => { if (a.categories === undefined || a.categories === null) return; out.categories = (Array.isArray(a.categories) ? a.categories : [a.categories]).map((c) => oneLine(c, 200)).filter(Boolean).slice(0, 25); };
121
128
  const num = (k) => { if (a[k] !== undefined && a[k] !== null && a[k] !== '') { const n = Number(a[k]); if (Number.isFinite(n)) out[k] = Math.trunc(n); } };
122
129
  switch (verb) {
123
130
  case 'terminal.read': if (a.lines !== undefined) out.lines = Math.max(1, Math.min(2000, Math.trunc(Number(a.lines)) || 0)); break;
@@ -163,15 +170,26 @@ export function check(verb, a = {}) {
163
170
  case 'mail.move': str('uid', 1024); str('mailbox', 200); break;
164
171
  case 'mail.flag': str('uid', 1024); str('flag', 40); break;
165
172
  case 'mail.delete': str('uid', 1024); str('mailbox', 200); break;
166
- case 'mail.send': str('to', 1000); str('subject', 400); str('body', TEXT_MAX, true); str('cc', 1000); str('attach', TEXT_MAX, true); str('attachName', 200); break;
167
- case 'calendar.list': str('from', 64); str('to', 64); break;
173
+ case 'mail.send': {
174
+ str('to', 1000); str('subject', 400); str('body', TEXT_MAX, true); str('cc', 1000); str('attach', TEXT_MAX, true); str('attachName', 200);
175
+ if (Array.isArray(a.attachments)) {
176
+ let total = 0;
177
+ const files = a.attachments.slice(0, 50).map((f) => ({ name: oneLine(f?.name, 200) || 'attachment', bytes: String(f?.bytes ?? '') })).filter((f) => f.bytes);
178
+ for (const f of files) total += f.bytes.length;
179
+ if (total > ATTACH_MAX) no(`the attachments are too large to send together (over ${Math.round(ATTACH_MAX / 1024 / 1024)} MB of files)`);
180
+ if (files.length) out.attachments = files;
181
+ }
182
+ break;
183
+ }
184
+ case 'calendar.list': str('from', 64); str('to', 64); str('category', 200); break;
168
185
  case 'calendar.read': str('id', 1024); break;
169
- case 'calendar.create': str('title', 400); str('start', 64); str('end', 64); str('where', 400); break;
170
- case 'calendar.attach': str('id', 1024); str('attach', TEXT_MAX, true); str('attachName', 200); break;
186
+ case 'calendar.freebusy': str('who', 2000); str('from', 64); str('to', 64); break;
187
+ case 'calendar.create': str('title', 400); str('start', 64); str('end', 64); str('where', 400); str('notes', TEXT_MAX, true); str('with', 1000); cats(); break;
188
+ case 'calendar.attach': str('id', 1024); str('attach', TEXT_MAX, true); str('attachName', 200); if (a.share === true || a.share === 'true') out.share = true; break;
171
189
  case 'calendar.detach': str('id', 1024); str('which', 400); break;
172
190
  case 'calendar.delete': str('id', 1024); break;
173
191
  case 'calendar.respond': str('id', 1024); str('answer', 40); str('comment', 400); break;
174
- case 'calendar.update': str('id', 1024); str('title', 400); str('start', 64); str('end', 64); str('where', 400); break;
192
+ case 'calendar.update': str('id', 1024); str('title', 400); str('start', 64); str('end', 64); str('where', 400); str('notes', TEXT_MAX, true); str('with', 1000); cats(); break;
175
193
  case 'contacts.attach': str('id', 1024); str('attach', TEXT_MAX, true); str('attachName', 200); break;
176
194
  case 'contacts.search': str('text', 200); break;
177
195
  case 'contacts.read': str('id', 1024); break;
@@ -211,22 +229,29 @@ export function detail(verb, a = {}) {
211
229
  case 'mail.list': return a.mailbox ?? '';
212
230
  case 'mail.read': return `#${a.uid}`;
213
231
  case 'mail.search': return a.text;
214
- case 'mail.move': return `#${a.uid} → ${a.mailbox}`;
215
- case 'mail.flag': return `#${a.uid} ${a.flag}`;
216
- case 'mail.delete': return `delete message #${a.uid}`;
217
- case 'mail.send': return `to ${a.to}: ${a.subject ?? ''}${a.attach ? ` + ${a.attachName || 'a file'}` : ''}`;
232
+ /* Ids from Graph are long and opaque; the approval says the ACTION, and the
233
+ agent's "why" names the thing. So no raw id dumped into the one line. */
234
+ case 'mail.move': return `move a message → ${a.mailbox}`;
235
+ case 'mail.flag': return `mark a message ${a.flag}`;
236
+ case 'mail.delete': return 'delete a message';
237
+ case 'mail.send': {
238
+ const kb = (b64) => Math.max(1, Math.round((String(b64).length * 3 / 4) / 1024));
239
+ const files = Array.isArray(a.attachments) ? a.attachments.map((f) => `${f.name} (${kb(f.bytes)} KB)`) : (a.attach ? [`${a.attachName || 'a file'} (${kb(a.attach)} KB)`] : []);
240
+ return `to ${a.to}: ${a.subject ?? ''}${files.length ? ` + ${files.join(', ')}` : ''}`;
241
+ }
218
242
  case 'calendar.read': return a.id;
243
+ case 'calendar.freebusy': return a.who;
219
244
  case 'calendar.create': return `${a.title} (${a.start})`;
220
- case 'calendar.attach': return `${a.attachName || 'a file'} → event ${a.id}`;
221
- case 'calendar.detach': return `remove ${a.which} from event ${a.id}`;
222
- case 'calendar.delete': return `delete event ${a.id}`;
223
- case 'calendar.respond': return `${a.answer} invitation ${a.id}`;
224
- case 'calendar.update': return `event ${a.id}${a.title ? `: ${a.title}` : ''}`;
245
+ case 'calendar.attach': return `${a.attachName || 'a file'} → an event`;
246
+ case 'calendar.detach': return `remove ${a.which || 'a file'} from an event`;
247
+ case 'calendar.delete': return 'delete an event';
248
+ case 'calendar.respond': return `${a.answer} an invitation`;
249
+ case 'calendar.update': return `change an event${a.title ? ` → ${a.title}` : ''}`;
225
250
  case 'contacts.read': return a.id;
226
251
  case 'contacts.create': return `add ${a.name || a.email || 'a contact'}`;
227
- case 'contacts.update': return `contact ${a.id}`;
228
- case 'contacts.delete': return `delete contact ${a.id}`;
229
- case 'contacts.attach': return `a photo → contact ${a.id}`;
252
+ case 'contacts.update': return 'change a contact';
253
+ case 'contacts.delete': return 'delete a contact';
254
+ case 'contacts.attach': return "set a contact's photo";
230
255
  default: return '';
231
256
  }
232
257
  }