@agenticmail/enterprise 0.5.83 → 0.5.85

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.
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Meeting Lifecycle Tools
3
+ *
4
+ * Manages the full meeting lifecycle:
5
+ * - Pre-meeting: Create Drive folder, prep notes, check calendar
6
+ * - During: Join (if capable), take notes, record
7
+ * - Post-meeting: Upload recording, transcribe, extract action items, organize in Drive
8
+ *
9
+ * Works on ALL deployments:
10
+ * - Container (Fly.io): API-only — prep, notes, Drive organization, NO joining
11
+ * - VM: Full lifecycle including joining, recording, transcription
12
+ * - Local: Full lifecycle
13
+ */
14
+
15
+ import type { AnyAgentTool, ToolCreationOptions } from '../types.js';
16
+ import { jsonResult, errorResult } from '../common.js';
17
+ import type { TokenProvider } from './oauth-token-provider.js';
18
+ import { detectCapabilities, getCapabilitySummary, type SystemCapabilities } from '../../runtime/environment.js';
19
+
20
+ const CALENDAR_BASE = 'https://www.googleapis.com/calendar/v3';
21
+ const DRIVE_BASE = 'https://www.googleapis.com/drive/v3';
22
+ const DRIVE_UPLOAD = 'https://www.googleapis.com/upload/drive/v3';
23
+
24
+ async function api(token: string, base: string, path: string, opts?: { method?: string; body?: any; query?: Record<string, string> }): Promise<any> {
25
+ const url = new URL(base + path);
26
+ if (opts?.query) for (const [k, v] of Object.entries(opts.query)) { if (v) url.searchParams.set(k, v); }
27
+ const res = await fetch(url.toString(), {
28
+ method: opts?.method || 'GET',
29
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
30
+ body: opts?.body ? JSON.stringify(opts.body) : undefined,
31
+ });
32
+ if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
33
+ if (res.status === 204) return {};
34
+ return res.json();
35
+ }
36
+
37
+ async function driveUploadText(token: string, name: string, content: string, parentId: string, mimeType = 'text/plain'): Promise<any> {
38
+ const boundary = '===agenticmail===';
39
+ const metadata = JSON.stringify({ name, parents: [parentId], mimeType });
40
+ const body = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n--${boundary}\r\nContent-Type: ${mimeType}; charset=UTF-8\r\n\r\n${content}\r\n--${boundary}--`;
41
+ const res = await fetch(`${DRIVE_UPLOAD}/files?uploadType=multipart&fields=id,name,webViewLink`, {
42
+ method: 'POST',
43
+ headers: {
44
+ Authorization: `Bearer ${token}`,
45
+ 'Content-Type': `multipart/related; boundary=${boundary}`,
46
+ },
47
+ body,
48
+ });
49
+ if (!res.ok) throw new Error(`Drive upload failed: ${res.status}`);
50
+ return res.json();
51
+ }
52
+
53
+ async function ensureDriveFolder(token: string, name: string, parentId?: string): Promise<{ id: string; name: string }> {
54
+ // Check if folder exists
55
+ const parts = [`name = '${name.replace(/'/g, "\\'")}'`, "mimeType = 'application/vnd.google-apps.folder'", 'trashed = false'];
56
+ if (parentId) parts.push(`'${parentId}' in parents`);
57
+ const existing = await api(token, DRIVE_BASE, '/files', { query: { q: parts.join(' and '), fields: 'files(id,name)', pageSize: '1' } });
58
+ if (existing.files?.length) return existing.files[0];
59
+
60
+ // Create
61
+ const body: any = { name, mimeType: 'application/vnd.google-apps.folder' };
62
+ if (parentId) body.parents = [parentId];
63
+ return api(token, DRIVE_BASE, '/files', { method: 'POST', body, query: { fields: 'id,name' } });
64
+ }
65
+
66
+ export interface MeetingLifecycleConfig {
67
+ tokenProvider: TokenProvider;
68
+ }
69
+
70
+ export function createMeetingLifecycleTools(config: MeetingLifecycleConfig, _options?: ToolCreationOptions): AnyAgentTool[] {
71
+ const tp = config.tokenProvider;
72
+ let caps: SystemCapabilities | null = null;
73
+ function getCaps() { if (!caps) caps = detectCapabilities(); return caps; }
74
+
75
+ return [
76
+ // ─── System Capabilities Check ─────────────────────
77
+ {
78
+ name: 'system_capabilities',
79
+ description: 'Check what this deployment can do. Shows browser, display, audio, video meeting, and recording capabilities. Use this first to understand what tools are available on this system.',
80
+ category: 'utility' as const,
81
+ parameters: { type: 'object' as const, properties: {}, required: [] },
82
+ async execute() {
83
+ const c = getCaps();
84
+ const summary = getCapabilitySummary(c);
85
+ return jsonResult({
86
+ ...summary,
87
+ raw: {
88
+ deployment: c.deployment,
89
+ hasBrowser: c.hasBrowser,
90
+ browserPath: c.browserPath,
91
+ hasDisplay: c.hasDisplay,
92
+ hasAudio: c.hasAudio,
93
+ hasVirtualCamera: c.hasVirtualCamera,
94
+ canRunHeadedBrowser: c.canRunHeadedBrowser,
95
+ canJoinMeetings: c.canJoinMeetings,
96
+ canRecordMeetings: c.canRecordMeetings,
97
+ hasFfmpeg: c.hasFfmpeg,
98
+ hasPersistentDisk: c.hasPersistentDisk,
99
+ platform: c.platform,
100
+ },
101
+ });
102
+ },
103
+ },
104
+
105
+ // ─── Prepare Meeting (works everywhere) ────────────
106
+ {
107
+ name: 'meeting_prepare',
108
+ description: 'Prepare for a meeting: create a Google Drive folder structure, generate meeting notes template with attendees and agenda, and return everything needed. Works on ALL deployments (container + VM).',
109
+ category: 'utility' as const,
110
+ parameters: {
111
+ type: 'object' as const,
112
+ properties: {
113
+ eventId: { type: 'string', description: 'Google Calendar event ID (will auto-fetch details)' },
114
+ title: { type: 'string', description: 'Meeting title (if no eventId)' },
115
+ date: { type: 'string', description: 'Meeting date ISO string (if no eventId)' },
116
+ attendees: { type: 'string', description: 'Comma-separated attendee emails (if no eventId)' },
117
+ agenda: { type: 'string', description: 'Meeting agenda text' },
118
+ driveRootFolderId: { type: 'string', description: 'Root "Meetings" folder ID in Drive (will create if not provided)' },
119
+ },
120
+ required: [],
121
+ },
122
+ async execute(_id: string, params: any) {
123
+ try {
124
+ const token = await tp.getAccessToken();
125
+
126
+ // Fetch event details from calendar if eventId provided
127
+ let title = params.title || 'Meeting';
128
+ let date = params.date || new Date().toISOString();
129
+ let attendees: Array<{ email: string; name?: string; status?: string }> = [];
130
+ let organizer = '';
131
+ let meetingLink: string | null = null;
132
+ let platform = '';
133
+ let description = '';
134
+
135
+ if (params.eventId) {
136
+ const event = await api(token, CALENDAR_BASE, `/calendars/primary/events/${params.eventId}`);
137
+ title = event.summary || title;
138
+ date = event.start?.dateTime || event.start?.date || date;
139
+ description = event.description || '';
140
+ organizer = event.organizer?.email || '';
141
+ attendees = (event.attendees || []).map((a: any) => ({
142
+ email: a.email, name: a.displayName, status: a.responseStatus,
143
+ }));
144
+
145
+ // Extract meeting link
146
+ if (event.conferenceData?.entryPoints) {
147
+ for (const ep of event.conferenceData.entryPoints) {
148
+ if (ep.entryPointType === 'video' && ep.uri) {
149
+ meetingLink = ep.uri;
150
+ platform = 'google_meet';
151
+ break;
152
+ }
153
+ }
154
+ }
155
+ if (!meetingLink && event.hangoutLink) {
156
+ meetingLink = event.hangoutLink;
157
+ platform = 'google_meet';
158
+ }
159
+ }
160
+
161
+ if (params.attendees && !attendees.length) {
162
+ attendees = params.attendees.split(',').map((e: string) => ({ email: e.trim() }));
163
+ }
164
+
165
+ // Build folder structure: Meetings / YYYY / MM-Month / YYYY-MM-DD - Title - Attendees
166
+ const d = new Date(date);
167
+ const year = String(d.getFullYear());
168
+ const monthNum = String(d.getMonth() + 1).padStart(2, '0');
169
+ const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
170
+ const monthName = `${monthNum}-${months[d.getMonth()]}`;
171
+ const dateStr = `${year}-${monthNum}-${String(d.getDate()).padStart(2, '0')}`;
172
+ const attendeeNames = attendees.slice(0, 3).map(a => a.name || a.email.split('@')[0]).join(', ');
173
+ const folderName = `${dateStr} - ${title}${attendeeNames ? ` - ${attendeeNames}` : ''}`;
174
+
175
+ // Create folder hierarchy
176
+ const rootFolder = params.driveRootFolderId
177
+ ? { id: params.driveRootFolderId, name: 'Meetings' }
178
+ : await ensureDriveFolder(token, 'Meetings');
179
+ const yearFolder = await ensureDriveFolder(token, year, rootFolder.id);
180
+ const monthFolder = await ensureDriveFolder(token, monthName, yearFolder.id);
181
+ const meetingFolder = await ensureDriveFolder(token, folderName, monthFolder.id);
182
+
183
+ // Generate meeting notes template
184
+ const notesContent = [
185
+ `# ${title}`,
186
+ '',
187
+ `**Date:** ${d.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} at ${d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}`,
188
+ organizer ? `**Organizer:** ${organizer}` : '',
189
+ meetingLink ? `**Meeting Link:** ${meetingLink} (${platform})` : '',
190
+ '',
191
+ '## Attendees',
192
+ ...attendees.map(a => `- ${a.name || a.email} (${a.email})${a.status ? ` — ${a.status}` : ''}`),
193
+ '',
194
+ '## Agenda',
195
+ params.agenda || description || '_No agenda provided_',
196
+ '',
197
+ '## Notes',
198
+ '_Meeting notes will be added here..._',
199
+ '',
200
+ '## Action Items',
201
+ '- [ ] _Action items from the meeting..._',
202
+ '',
203
+ '## Decisions Made',
204
+ '- _Key decisions..._',
205
+ '',
206
+ '---',
207
+ `_Prepared by AI Agent on ${new Date().toISOString()}_`,
208
+ ].filter(Boolean).join('\n');
209
+
210
+ // Upload meeting notes template
211
+ const notesFile = await driveUploadText(token, 'meeting-notes.md', notesContent, meetingFolder.id);
212
+
213
+ // Check system capabilities for meeting joining
214
+ const c = getCaps();
215
+ const canJoin = c.canJoinMeetings;
216
+
217
+ return jsonResult({
218
+ prepared: true,
219
+ folder: {
220
+ id: meetingFolder.id,
221
+ path: `Meetings/${year}/${monthName}/${folderName}`,
222
+ hierarchy: {
223
+ root: rootFolder.id,
224
+ year: yearFolder.id,
225
+ month: monthFolder.id,
226
+ meeting: meetingFolder.id,
227
+ },
228
+ },
229
+ notesFile: { id: notesFile.id, name: notesFile.name, webViewLink: notesFile.webViewLink },
230
+ meeting: {
231
+ title, date, organizer, attendees,
232
+ meetingLink, platform,
233
+ },
234
+ capabilities: {
235
+ canJoinMeeting: canJoin,
236
+ canRecord: c.canRecordMeetings,
237
+ deployment: c.deployment,
238
+ ...(!canJoin ? {
239
+ limitation: 'This deployment cannot join video meetings. Meeting prep, notes, and post-meeting organization are fully available. To enable meeting joining, deploy on a VM with display + audio.',
240
+ } : {}),
241
+ },
242
+ });
243
+ } catch (e: any) { return errorResult(e.message); }
244
+ },
245
+ },
246
+
247
+ // ─── Post-Meeting: Save & Organize ─────────────────
248
+ {
249
+ name: 'meeting_save',
250
+ description: 'Save meeting artifacts to the meeting\'s Google Drive folder. Upload notes, transcript, recording, action items, and any shared files. Organizes everything neatly. Works on ALL deployments.',
251
+ category: 'utility' as const,
252
+ parameters: {
253
+ type: 'object' as const,
254
+ properties: {
255
+ folderId: { type: 'string', description: 'Meeting folder ID in Google Drive (from meeting_prepare)' },
256
+ notes: { type: 'string', description: 'Meeting notes content (markdown)' },
257
+ transcript: { type: 'string', description: 'Meeting transcript text' },
258
+ actionItems: { type: 'string', description: 'Action items as markdown list' },
259
+ summary: { type: 'string', description: 'Meeting summary text' },
260
+ decisions: { type: 'string', description: 'Key decisions made' },
261
+ followUps: { type: 'string', description: 'Follow-up tasks/meetings needed' },
262
+ recordingPath: { type: 'string', description: 'Local file path to recording (VM/local only)' },
263
+ },
264
+ required: ['folderId'],
265
+ },
266
+ async execute(_id: string, params: any) {
267
+ try {
268
+ const token = await tp.getAccessToken();
269
+ const folderId = params.folderId;
270
+ const uploaded: any[] = [];
271
+
272
+ if (params.notes) {
273
+ const f = await driveUploadText(token, 'meeting-notes.md', params.notes, folderId);
274
+ uploaded.push({ type: 'notes', id: f.id, name: f.name, webViewLink: f.webViewLink });
275
+ }
276
+
277
+ if (params.transcript) {
278
+ const f = await driveUploadText(token, 'transcript.txt', params.transcript, folderId);
279
+ uploaded.push({ type: 'transcript', id: f.id, name: f.name, webViewLink: f.webViewLink });
280
+ }
281
+
282
+ if (params.actionItems) {
283
+ const content = `# Action Items\n\n${params.actionItems}\n\n---\n_Extracted by AI Agent on ${new Date().toISOString()}_`;
284
+ const f = await driveUploadText(token, 'action-items.md', content, folderId);
285
+ uploaded.push({ type: 'action_items', id: f.id, name: f.name, webViewLink: f.webViewLink });
286
+ }
287
+
288
+ if (params.summary) {
289
+ const content = `# Meeting Summary\n\n${params.summary}${params.decisions ? `\n\n## Decisions\n${params.decisions}` : ''}${params.followUps ? `\n\n## Follow-ups\n${params.followUps}` : ''}\n\n---\n_Generated by AI Agent on ${new Date().toISOString()}_`;
290
+ const f = await driveUploadText(token, 'summary.md', content, folderId);
291
+ uploaded.push({ type: 'summary', id: f.id, name: f.name, webViewLink: f.webViewLink });
292
+ }
293
+
294
+ if (params.recordingPath) {
295
+ const c = getCaps();
296
+ if (!c.hasPersistentDisk) {
297
+ uploaded.push({ type: 'recording', error: 'Recording upload skipped — ephemeral filesystem. Recording may have been lost.' });
298
+ } else {
299
+ // For large file upload, we'd use resumable upload. For now, note the path.
300
+ uploaded.push({
301
+ type: 'recording', status: 'pending_upload',
302
+ localPath: params.recordingPath,
303
+ hint: 'Use google_drive_upload_file tool for large file upload, or the agent can use the browser to upload via drive.google.com',
304
+ });
305
+ }
306
+ }
307
+
308
+ return jsonResult({ saved: true, folderId, files: uploaded, count: uploaded.length });
309
+ } catch (e: any) { return errorResult(e.message); }
310
+ },
311
+ },
312
+
313
+ // ─── Meeting Record (VM only) ──────────────────────
314
+ {
315
+ name: 'meeting_record',
316
+ description: 'Start or stop recording the current meeting. Captures screen + audio using ffmpeg. REQUIRES: VM deployment with display + audio + ffmpeg. Not available on container deployments.',
317
+ category: 'utility' as const,
318
+ parameters: {
319
+ type: 'object' as const,
320
+ properties: {
321
+ action: { type: 'string', description: '"start" or "stop"' },
322
+ outputPath: { type: 'string', description: 'Output file path (default: /tmp/meeting-recording-{timestamp}.mp4)' },
323
+ display: { type: 'string', description: 'X11 display (default: :99)' },
324
+ audioSource: { type: 'string', description: 'PulseAudio source (default: auto-detect virtual monitor)' },
325
+ },
326
+ required: ['action'],
327
+ },
328
+ async execute(_id: string, params: any) {
329
+ const c = getCaps();
330
+ if (!c.canRecordMeetings) {
331
+ const summary = getCapabilitySummary(c);
332
+ return errorResult(
333
+ `Meeting recording is not available on this ${summary.deployment} deployment.\n` +
334
+ `Missing: ${summary.unavailable.join(', ')}\n\n` +
335
+ `${summary.recommendations.join('\n')}`
336
+ );
337
+ }
338
+
339
+ if (params.action === 'start') {
340
+ const output = params.outputPath || `/tmp/meeting-recording-${Date.now()}.mp4`;
341
+ const display = params.display || process.env.DISPLAY || ':99';
342
+ const audioSource = params.audioSource || 'default';
343
+
344
+ // Return ffmpeg command for the agent to execute via bash tool
345
+ const ffmpegCmd = [
346
+ 'ffmpeg', '-y',
347
+ '-f', 'x11grab', '-video_size', '1920x1080', '-framerate', '15', '-i', display,
348
+ '-f', 'pulse', '-i', audioSource,
349
+ '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '28',
350
+ '-c:a', 'aac', '-b:a', '128k',
351
+ '-movflags', '+faststart',
352
+ output,
353
+ ].join(' ');
354
+
355
+ return jsonResult({
356
+ action: 'start',
357
+ command: ffmpegCmd,
358
+ outputPath: output,
359
+ hint: 'Run this command in the background using the bash tool: `nohup ' + ffmpegCmd + ' &`\nTo stop recording later, use meeting_record with action="stop".',
360
+ });
361
+ }
362
+
363
+ if (params.action === 'stop') {
364
+ return jsonResult({
365
+ action: 'stop',
366
+ command: "pkill -INT -f 'ffmpeg.*meeting-recording'",
367
+ hint: 'Run this command via bash tool to gracefully stop ffmpeg recording. Then use meeting_save to upload the recording to Drive.',
368
+ });
369
+ }
370
+
371
+ return errorResult('action must be "start" or "stop"');
372
+ },
373
+ },
374
+
375
+ // ─── Check Meeting Joinability ─────────────────────
376
+ {
377
+ name: 'meeting_can_join',
378
+ description: 'Check if this agent can join a video meeting on the current deployment. Returns capabilities and specific instructions based on what is available.',
379
+ category: 'utility' as const,
380
+ parameters: {
381
+ type: 'object' as const,
382
+ properties: {
383
+ platform: { type: 'string', description: 'Meeting platform: google_meet, zoom, teams' },
384
+ },
385
+ required: [],
386
+ },
387
+ async execute(_id: string, params: any) {
388
+ const c = getCaps();
389
+ const summary = getCapabilitySummary(c);
390
+
391
+ if (c.canJoinMeetings) {
392
+ return jsonResult({
393
+ canJoin: true,
394
+ deployment: summary.deployment,
395
+ capabilities: summary.available,
396
+ instructions: [
397
+ 'Use the browser tool to navigate to the meeting URL.',
398
+ 'Take a snapshot to identify the pre-join screen.',
399
+ 'Use act to click mute/camera toggles and the join button.',
400
+ 'For Google Meet: use keyboard shortcuts Ctrl+D (mute) and Ctrl+E (camera).',
401
+ params.platform === 'zoom' ? 'For Zoom: click "Join from Your Browser" to avoid the Zoom client.' : null,
402
+ params.platform === 'teams' ? 'For Teams: click "Continue on this browser" to join in-browser.' : null,
403
+ ].filter(Boolean),
404
+ tips: [
405
+ 'Join with mic muted and camera off by default.',
406
+ 'Use meeting_record to capture the meeting.',
407
+ 'Use meeting_prepare first to create Drive folder + notes template.',
408
+ ],
409
+ });
410
+ }
411
+
412
+ // Can't join — give helpful alternatives
413
+ return jsonResult({
414
+ canJoin: false,
415
+ deployment: summary.deployment,
416
+ missing: summary.unavailable,
417
+ recommendations: summary.recommendations,
418
+ alternatives: [
419
+ 'Use meeting_prepare to create Drive folder with notes template.',
420
+ 'Use meetings_upcoming to monitor the calendar for meeting details.',
421
+ 'Use meetings_scan_inbox to find meeting links from email invites.',
422
+ 'Use meeting_rsvp to accept/decline meetings via Calendar API.',
423
+ 'After the meeting, use meeting_save to organize notes/recordings in Drive.',
424
+ 'For the agent to actually join meetings, deploy on a VM with: Xvfb + PulseAudio + Chromium + ffmpeg.',
425
+ ],
426
+ whatWorksHere: [
427
+ 'Calendar management (create, update, RSVP)',
428
+ 'Meeting prep (Drive folder + notes template)',
429
+ 'Post-meeting organization (notes, transcript, action items → Drive)',
430
+ 'Email/inbox scanning for meeting invites',
431
+ 'Scheduling and rescheduling meetings',
432
+ ],
433
+ });
434
+ },
435
+ },
436
+ ];
437
+ }
@@ -832,6 +832,11 @@ export function createAgentRoutes(opts: {
832
832
  tools: ['google_contacts_list', 'google_contacts_search', 'google_contacts_search_directory',
833
833
  'google_contacts_create', 'google_contacts_update'],
834
834
  },
835
+ {
836
+ id: 'meetings', name: 'Meetings', description: 'Join Google Meet, Zoom, Teams calls. Scan calendar and inbox for meeting invites. RSVP, take notes, send summaries.',
837
+ icon: '🎥', requiresOAuth: 'google',
838
+ tools: ['meetings_upcoming', 'meeting_join', 'meeting_action', 'meetings_scan_inbox', 'meeting_rsvp'],
839
+ },
835
840
  {
836
841
  id: 'enterprise_database', name: 'Database', description: 'SQL queries, schema inspection, data sampling',
837
842
  icon: '🗄️',
@@ -917,6 +922,20 @@ export function createAgentRoutes(opts: {
917
922
  ];
918
923
 
919
924
  // ═══════════════════════════════════════════════════════════
925
+ // SYSTEM CAPABILITIES
926
+ // ═══════════════════════════════════════════════════════════
927
+
928
+ router.get('/bridge/system/capabilities', async (c) => {
929
+ try {
930
+ const { detectCapabilities, getCapabilitySummary } = await import('../runtime/environment.js');
931
+ const caps = detectCapabilities();
932
+ const summary = getCapabilitySummary(caps);
933
+ return c.json({ ...summary, raw: caps });
934
+ } catch (e: any) {
935
+ return c.json({ error: e.message }, 500);
936
+ }
937
+ });
938
+
920
939
  // BROWSER CONFIGURATION
921
940
  // ═══════════════════════════════════════════════════════════
922
941
 
@@ -20,7 +20,7 @@ import { ToolRegistry, executeTool } from './tool-executor.js';
20
20
  const DEFAULT_MAX_TURNS = 0; // 0 = unlimited
21
21
  const DEFAULT_MAX_TOKENS = 8192;
22
22
  const DEFAULT_TEMPERATURE = 0.7;
23
- const DEFAULT_CONTEXT_WINDOW = 1_000_000; // 1M — most frontier models support this (Feb 2026)
23
+ const DEFAULT_CONTEXT_WINDOW = 2_000_000; // 1M — most frontier models support this (Feb 2026)
24
24
  const COMPACTION_THRESHOLD = 0.8; // compact when 80% of context used
25
25
 
26
26
  // ─── Agent Loop ──────────────────────────────────────────