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

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
@@ -44,9 +44,10 @@ export const HELP = `${NAME}: act on the targets you were given (machines, folde
44
44
  ${NAME} mail TARGET send --to "a@b,c@d" --subject "..." --body "..." [--cc "..."] [--attach FILE]
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"]
49
+ ${NAME} calendar TARGET update EVENT_ID [--title] [--start] [--end] [--where] [--notes] [--with] (Microsoft)
50
+ ${NAME} calendar TARGET attach EVENT_ID FILE [--share] join a file to an event (Microsoft); --share if a participant is external
50
51
  ${NAME} calendar TARGET detach EVENT_ID NAME|N remove an attachment from an event (Microsoft)
51
52
  ${NAME} calendar TARGET delete EVENT_ID delete an event
52
53
  ${NAME} calendar TARGET respond EVENT_ID accept|decline|tentative answer an invitation (Microsoft)
@@ -69,7 +70,7 @@ ${NAME} ${VERSION}`;
69
70
  const FILE_VERBS = ['list', 'read', 'search', 'write', 'move', 'delete'];
70
71
  const BROWSER_VERBS = ['tabs', 'open', 'shot', 'html', 'console', 'eval', 'click', 'type', 'key', 'scroll', 'drag'];
71
72
  const MAIL_VERBS = ['list', 'read', 'source', 'attachment', 'search', 'move', 'flag', 'delete', 'send'];
72
- const CAL_VERBS = ['list', 'read', 'create', 'update', 'attach', 'detach', 'delete', 'respond'];
73
+ const CAL_VERBS = ['list', 'read', 'freebusy', 'create', 'update', 'attach', 'detach', 'delete', 'respond'];
73
74
  const CONTACT_VERBS = ['list', 'search', 'read', 'create', 'update', 'delete', 'attach'];
74
75
  const baseName = (p) => String(p).split('/').pop();
75
76
  const readB64 = (p) => { try { return readFileSync(p).toString('base64'); } catch { return null; } };
@@ -82,6 +83,7 @@ export function read(argv) {
82
83
  const a = argv[i];
83
84
  if (a === '--enter') { flags.enter = true; continue; }
84
85
  if (a === '--readable') { flags.readable = true; continue; }
86
+ if (a === '--share') { flags.share = true; continue; }
85
87
  if (a.startsWith('--')) { if (argv[i + 1] === undefined) return { refused: `${a} needs a value` }; flags[a.slice(2)] = argv[++i]; continue; }
86
88
  words.push(a);
87
89
  }
@@ -155,16 +157,17 @@ export function read(argv) {
155
157
  switch (what) {
156
158
  case 'list': return ['calendar.list', { target, ...(flags.from !== undefined ? { from: flags.from } : {}), ...(flags.to !== undefined ? { to: flags.to } : {}) }];
157
159
  case 'read': return need(p[0], 'calendar TARGET read ID') ?? ['calendar.read', { target, id: p[0] }];
160
+ 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
161
  case 'attach': {
159
162
  if (p.length < 2) return { refused: 'calendar TARGET attach EVENT_ID FILE' };
160
163
  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 }];
164
+ return ['calendar.attach', { target, id: p[0], attach: b, attachName: flags['attach-name'] || baseName(p[1]), ...(flags.share !== undefined ? { share: true } : {}), ...why }];
162
165
  }
163
166
  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
167
  case 'delete': return need(p[0], 'calendar TARGET delete EVENT_ID') ?? ['calendar.delete', { target, id: p[0], ...why }];
165
168
  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 }];
169
+ case 'update': return need(p[0], 'calendar TARGET update EVENT_ID [--title] [--start] [--end] [--where] [--notes] [--with]') ?? ['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 } : {}), ...why }];
170
+ 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"]') ?? ['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 } : {}), ...why }];
168
171
  }
169
172
  }
170
173
  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
 
@@ -37,8 +40,14 @@ const API = {
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}` : ''}`,
44
+ body: ({ title, start, end, where, notes, attendees }) => ({
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
+ }),
42
51
  },
43
52
  };
44
53
 
@@ -63,7 +72,9 @@ export function calendar({ fetch: fetchImpl = globalThis.fetch } = {}) {
63
72
 
64
73
  async function call(target, ctx, method, url, json) {
65
74
  const token = await access(target, ctx);
66
- const headers = { Authorization: `Bearer ${token}` };
75
+ /* Ask Microsoft to answer in the person's timezone, not UTC, so a 4pm
76
+ meeting reads 16:00 and not 15:00. Harmless for the Google REST path. */
77
+ const headers = { Authorization: `Bearer ${token}`, Prefer: `outlook.timezone="${TZ}"` };
67
78
  if (json !== undefined) headers['Content-Type'] = 'application/json';
68
79
  let res;
69
80
  try { res = await fetchImpl(url, { method, headers, ...(json !== undefined ? { body: JSON.stringify(json) } : {}) }); }
@@ -162,12 +173,50 @@ export function calendar({ fetch: fetchImpl = globalThis.fetch } = {}) {
162
173
  const text = JSON.stringify(await res.json(), null, 1).slice(0, READ_MAX);
163
174
  return { text, size: Buffer.byteLength(text) };
164
175
  },
176
+ /* See when others are busy, to find a time together. A read: it returns
177
+ only busy/free blocks (and a title only where the person already shares
178
+ it), never the contents of their calendars. `who` is a comma-separated
179
+ list of addresses; `from`/`to` bound the window (default: the next 7
180
+ days). Microsoft over Graph getSchedule; a Google free/busy query comes
181
+ with parity (its REST path needs the account's own OAuth app). */
182
+ async 'calendar.freebusy'(target, a, ctx) {
183
+ const provider = providerOf(target);
184
+ if (provider !== 'microsoft') no(`${target.title}: reading others' availability is Microsoft-only for now`);
185
+ const who = String(a.who ?? '').split(',').map((x) => x.trim()).filter(Boolean);
186
+ if (!who.length) no('freebusy needs at least one address (comma-separated)');
187
+ if (who.length > 20) no('freebusy looks at up to 20 people at once');
188
+ /* A window in the person's own wall-clock (Europe/Paris), passed with
189
+ that timezone so "09:00" means 09:00 there. Default: now → +7 days. */
190
+ 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`; };
191
+ 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; };
192
+ const now = new Date();
193
+ const from = a.from ? norm(a.from) : localISO(now);
194
+ const to = a.to ? norm(a.to) : localISO(new Date(now.getTime() + 7 * 86_400_000));
195
+ const res = await call(target, ctx, 'POST', 'https://graph.microsoft.com/v1.0/me/calendar/getSchedule', {
196
+ schedules: who,
197
+ startTime: { dateTime: from, timeZone: TZ }, endTime: { dateTime: to, timeZone: TZ },
198
+ availabilityViewInterval: 60,
199
+ });
200
+ if (!res.ok) no(`${target.title}: availability could not be read (${res.status} ${await bodyError(res)})`.trim());
201
+ const value = (await res.json()).value ?? [];
202
+ const label = { tentative: 'tentative', busy: 'busy', oof: 'out of office', workingElsewhere: 'working elsewhere' };
203
+ const blocks = value.map((s) => {
204
+ const id = s.scheduleId || '(unknown)';
205
+ if (s.error) return `${id}\n could not be read (no access, or unknown address)`;
206
+ const items = (s.scheduleItems ?? []).filter((it) => it.status && it.status !== 'free');
207
+ if (!items.length) return `${id}\n free for the whole window`;
208
+ 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');
209
+ });
210
+ const text = value.length ? `Availability ${shortWhen(from)} → ${shortWhen(to)} (${TZ}):\n\n${blocks.join('\n\n')}` : 'No availability returned.';
211
+ return { text, size: Buffer.byteLength(text) };
212
+ },
165
213
  /* Create an event. An act: the start and end are ISO times (a bare date is all-day). */
166
214
  async 'calendar.create'(target, a, ctx) {
167
215
  const api = API[providerOf(target)];
168
216
  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 }));
217
+ if (!a.start || !a.end) no('an event needs a start and an end (local ISO times, e.g. 2026-11-10T16:00:00)');
218
+ const attendees = String(a.with ?? '').split(',').map((x) => x.trim()).filter(Boolean);
219
+ 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 }));
171
220
  if (!res.ok) no(`${target.title}: the event was not created (${res.status} ${await bodyError(res)})`.trim());
172
221
  const made = await res.json().catch(() => ({}));
173
222
  return { text: `Created: ${a.title} (${a.start}). Its id is ${made.id ?? '(unknown)'}.`, size: 0 };
@@ -180,10 +229,32 @@ export function calendar({ fetch: fetchImpl = globalThis.fetch } = {}) {
180
229
  if (provider !== 'microsoft') no(`${target.title}: joining a file to an event is Microsoft-only for now`);
181
230
  if (!a.id) no('attach needs the event id (from the list)');
182
231
  if (!a.attach) no('attach needs the file (its base64 bytes)');
232
+ const ev = encodeURIComponent(String(a.id));
233
+ /* Who would receive the file: an event attachment reaches every participant
234
+ on the next invitation update or resend. Split internal from external by
235
+ the organiser's domain, and refuse an external audience unless `share`. */
236
+ const internals = []; const externals = [];
237
+ const info = await call(target, ctx, 'GET', `https://graph.microsoft.com/v1.0/me/events/${ev}?$select=attendees,organizer`);
238
+ if (info.ok) {
239
+ const j = await info.json().catch(() => ({}));
240
+ const org = (j.organizer?.emailAddress?.address || target.config?.user || '').toLowerCase();
241
+ const dom = org.split('@')[1] || '';
242
+ for (const at of (j.attendees ?? [])) {
243
+ const addr = at.emailAddress?.address; if (!addr || addr.toLowerCase() === org) continue;
244
+ (dom && addr.toLowerCase().endsWith('@' + dom) ? internals : externals).push(addr);
245
+ }
246
+ }
247
+ if (externals.length && a.share !== true) {
248
+ 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.`);
249
+ }
183
250
  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);
251
+ const res = await call(target, ctx, 'POST', `https://graph.microsoft.com/v1.0/me/events/${ev}/attachments`, att);
185
252
  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 };
253
+ const all = internals.length + externals.length;
254
+ const who = all
255
+ ? ` It will reach ${all} participant(s) on the next invitation update/resend:${internals.length ? ` ${internals.join(', ')} (internal)` : ''}${externals.length ? `${internals.length ? ';' : ''} ${externals.join(', ')} (external)` : ''}.`
256
+ : ' Visible to you alone (the event has no participants).';
257
+ return { text: `Attached ${a.attachName || 'the file'} to the event.${who}`, size: 0 };
187
258
  },
188
259
  /* Remove a file from an event, by its name or 1-based number. An act.
189
260
  Microsoft only, like attaching (Graph deletes the attachment by id). */
@@ -247,7 +318,9 @@ export function calendar({ fetch: fetchImpl = globalThis.fetch } = {}) {
247
318
  if (a.start !== undefined) patch.start = { dateTime: String(a.start), timeZone: 'UTC' };
248
319
  if (a.end !== undefined) patch.end = { dateTime: String(a.end), timeZone: 'UTC' };
249
320
  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');
321
+ if (a.notes !== undefined) patch.body = { contentType: 'text', content: String(a.notes) };
322
+ if (a.with !== undefined) patch.attendees = String(a.with).split(',').map((x) => x.trim()).filter(Boolean).map((e) => ({ emailAddress: { address: e }, type: 'required' }));
323
+ if (!Object.keys(patch).length) no('update needs at least one of: title, start, end, where, notes, with');
251
324
  const res = await call(target, ctx, 'PATCH', API.microsoft.event(String(a.id)), patch);
252
325
  if (res.status === 404) no('no such event');
253
326
  if (!res.ok) no(`${target.title}: the event was not changed (${res.status} ${await bodyError(res)})`.trim());
@@ -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 ?? ''}`;
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.22",
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
@@ -115,12 +115,13 @@ export const TOOLS = [
115
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
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']),
117
117
  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']),
118
+ 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']),
119
+ 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)'), why: WHY }, ['target', 'title', 'start', 'end']),
120
+ 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
121
  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
122
  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
123
  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']),
124
+ 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'), why: WHY }, ['target', 'id']),
124
125
  tool('contacts-list', 'The contacts of an address-book target: name, emails, phones.', { target: TARGET }, ['target']),
125
126
  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
127
  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.22';
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
@@ -70,6 +70,7 @@ export const VERBS = Object.freeze({
70
70
  'mail.send': { reads: false, kinds: ['mail'], needs: ['to', 'subject', 'body'] },
71
71
  'calendar.list': { reads: true, kinds: ['calendar'], needs: [] },
72
72
  'calendar.read': { reads: true, kinds: ['calendar'], needs: ['id'] },
73
+ 'calendar.freebusy': { reads: true, kinds: ['calendar'], needs: ['who'] },
73
74
  'calendar.create': { reads: false, kinds: ['calendar'], needs: ['title', 'start', 'end'] },
74
75
  'calendar.attach': { reads: false, kinds: ['calendar'], needs: ['id', 'attach'] },
75
76
  'calendar.detach': { reads: false, kinds: ['calendar'], needs: ['id', 'which'] },
@@ -166,12 +167,13 @@ export function check(verb, a = {}) {
166
167
  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
168
  case 'calendar.list': str('from', 64); str('to', 64); break;
168
169
  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;
170
+ case 'calendar.freebusy': str('who', 2000); str('from', 64); str('to', 64); break;
171
+ case 'calendar.create': str('title', 400); str('start', 64); str('end', 64); str('where', 400); str('notes', TEXT_MAX, true); str('with', 1000); break;
172
+ 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
173
  case 'calendar.detach': str('id', 1024); str('which', 400); break;
172
174
  case 'calendar.delete': str('id', 1024); break;
173
175
  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;
176
+ 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); break;
175
177
  case 'contacts.attach': str('id', 1024); str('attach', TEXT_MAX, true); str('attachName', 200); break;
176
178
  case 'contacts.search': str('text', 200); break;
177
179
  case 'contacts.read': str('id', 1024); break;
@@ -211,22 +213,25 @@ export function detail(verb, a = {}) {
211
213
  case 'mail.list': return a.mailbox ?? '';
212
214
  case 'mail.read': return `#${a.uid}`;
213
215
  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}`;
216
+ /* Ids from Graph are long and opaque; the approval says the ACTION, and the
217
+ agent's "why" names the thing. So no raw id dumped into the one line. */
218
+ case 'mail.move': return `move a message → ${a.mailbox}`;
219
+ case 'mail.flag': return `mark a message ${a.flag}`;
220
+ case 'mail.delete': return 'delete a message';
217
221
  case 'mail.send': return `to ${a.to}: ${a.subject ?? ''}${a.attach ? ` + ${a.attachName || 'a file'}` : ''}`;
218
222
  case 'calendar.read': return a.id;
223
+ case 'calendar.freebusy': return a.who;
219
224
  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}` : ''}`;
225
+ case 'calendar.attach': return `${a.attachName || 'a file'} → an event`;
226
+ case 'calendar.detach': return `remove ${a.which || 'a file'} from an event`;
227
+ case 'calendar.delete': return 'delete an event';
228
+ case 'calendar.respond': return `${a.answer} an invitation`;
229
+ case 'calendar.update': return `change an event${a.title ? ` → ${a.title}` : ''}`;
225
230
  case 'contacts.read': return a.id;
226
231
  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}`;
232
+ case 'contacts.update': return 'change a contact';
233
+ case 'contacts.delete': return 'delete a contact';
234
+ case 'contacts.attach': return "set a contact's photo";
230
235
  default: return '';
231
236
  }
232
237
  }