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 +10 -7
- package/host/connectors/calendar.mjs +82 -9
- package/host/connectors/graphmail.mjs +6 -3
- package/package.json +1 -1
- package/server/mcp.mjs +4 -3
- package/server/name.mjs +1 -1
- package/server/verbs.mjs +19 -14
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
|
|
48
|
-
${NAME} calendar TARGET
|
|
49
|
-
${NAME} calendar TARGET
|
|
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
|
|
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
|
|
41
|
-
body: ({ title, start, end, 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
|
-
|
|
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.
|
|
170
|
-
const
|
|
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/${
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
45
|
-
|
|
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.
|
|
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-
|
|
119
|
-
tool('calendar-
|
|
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.
|
|
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.
|
|
170
|
-
case 'calendar.
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
case 'mail.
|
|
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
|
|
221
|
-
case 'calendar.detach': return `remove ${a.which} from event
|
|
222
|
-
case 'calendar.delete': return
|
|
223
|
-
case 'calendar.respond': return `${a.answer} invitation
|
|
224
|
-
case 'calendar.update': return `event
|
|
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
|
|
228
|
-
case 'contacts.delete': return
|
|
229
|
-
case 'contacts.attach': return
|
|
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
|
}
|