@agenticmail/enterprise 0.5.84 → 0.5.86

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
+ }
@@ -4003,6 +4003,14 @@ function MeetingCapabilitiesSection(props) {
4003
4003
  var _d = useApp(); var toast = _d.toast;
4004
4004
  var _launching = useState(false); var launching = _launching[0]; var setLaunching = _launching[1];
4005
4005
  var _browserStatus = useState(null); var browserStatus = _browserStatus[0]; var setBrowserStatus = _browserStatus[1];
4006
+ var _sysCaps = useState(null); var sysCaps = _sysCaps[0]; var setSysCaps = _sysCaps[1];
4007
+
4008
+ // Fetch system capabilities on mount
4009
+ useEffect(function() {
4010
+ engineCall('/bridge/system/capabilities')
4011
+ .then(function(d) { setSysCaps(d); })
4012
+ .catch(function() { setSysCaps(null); });
4013
+ }, []);
4006
4014
 
4007
4015
  function checkMeetingBrowser() {
4008
4016
  engineCall('/bridge/agents/' + agentId + '/browser-config/test', { method: 'POST' })
@@ -4027,9 +4035,41 @@ function MeetingCapabilitiesSection(props) {
4027
4035
 
4028
4036
  var meetingsOn = cfg.meetingsEnabled === true;
4029
4037
 
4038
+ var isContainer = sysCaps && sysCaps.raw && (sysCaps.raw.deployment === 'container');
4039
+ var canJoinMeetings = sysCaps && sysCaps.raw && sysCaps.raw.canJoinMeetings;
4040
+
4030
4041
  return h('div', { style: sectionStyle },
4031
4042
  sectionTitle('\uD83C\uDFA5', 'Meetings & Video Calls'),
4032
4043
 
4044
+ // Deployment capability warning
4045
+ sysCaps && !canJoinMeetings && h('div', { style: {
4046
+ background: 'rgba(255,152,0,0.08)', border: '1px solid rgba(255,152,0,0.3)',
4047
+ borderRadius: 8, padding: '12px 16px', marginBottom: 16,
4048
+ } },
4049
+ h('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 10 } },
4050
+ h('span', { style: { fontSize: 18 } }, '\u26A0\uFE0F'),
4051
+ h('div', null,
4052
+ h('div', { style: { fontWeight: 600, fontSize: 13, marginBottom: 4 } },
4053
+ 'Limited on this deployment' + (isContainer ? ' (container)' : '')
4054
+ ),
4055
+ h('div', { style: { fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 } },
4056
+ 'Video meeting joining requires a display server, audio subsystem, and browser — which are not available on container deployments (Fly.io, Railway, etc.).'
4057
+ ),
4058
+ h('div', { style: { fontSize: 12, marginTop: 8, lineHeight: 1.5 } },
4059
+ h('strong', null, 'What works here: '), 'Calendar management, meeting prep, Drive organization, notes, email scanning for invites, RSVP.'
4060
+ ),
4061
+ h('div', { style: { fontSize: 12, marginTop: 4, lineHeight: 1.5 } },
4062
+ h('strong', null, 'For meeting joining: '), 'Deploy on a VM (Hetzner, DigitalOcean, GCP) with our ',
4063
+ h('code', { style: { fontSize: 11, background: 'var(--bg-secondary)', padding: '1px 4px', borderRadius: 3 } }, 'vm-setup.sh'),
4064
+ ' script, or use a Remote Browser (CDP) provider below.'
4065
+ ),
4066
+ sysCaps.unavailable && sysCaps.unavailable.length > 0 && h('div', { style: { fontSize: 11, color: 'var(--text-muted)', marginTop: 8 } },
4067
+ 'Missing: ' + sysCaps.unavailable.join(' \u2022 ')
4068
+ )
4069
+ )
4070
+ )
4071
+ ),
4072
+
4033
4073
  // Main toggle
4034
4074
  h('div', { style: { display: 'flex', alignItems: 'center', gap: 16, marginBottom: meetingsOn ? 16 : 0 } },
4035
4075
  h('div', {
@@ -4070,11 +4110,27 @@ function MeetingCapabilitiesSection(props) {
4070
4110
  browserStatus?.ok && h('span', { className: 'badge', style: { fontSize: 10, padding: '1px 6px', background: 'var(--success-soft)', color: 'var(--success)' } }, 'Running')
4071
4111
  ),
4072
4112
  browserStatus?.ok
4073
- ? h('div', { style: { fontSize: 12, color: 'var(--text-muted)' } }, browserStatus.browserVersion || 'Chromium ready')
4113
+ ? h('div', null,
4114
+ h('div', { style: { fontSize: 12, color: 'var(--text-muted)' } }, browserStatus.browserVersion || 'Chromium ready'),
4115
+ isContainer && !canJoinMeetings && h('div', { style: { fontSize: 11, color: 'var(--warning)', marginTop: 4 } },
4116
+ '\u26A0 Browser is headless-only on this container. It cannot join video calls (no display/audio).'
4117
+ )
4118
+ )
4074
4119
  : h('div', null,
4075
- h('div', { style: { fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 } }, 'A dedicated browser instance will be launched for video calls with virtual display and audio.'),
4076
- h('button', { className: 'btn btn-sm', disabled: launching, onClick: launchMeetingBrowser },
4077
- launching ? 'Launching...' : '\u25B6\uFE0F Launch Meeting Browser'
4120
+ h('div', { style: { fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 } },
4121
+ isContainer && !canJoinMeetings
4122
+ ? 'Meeting browser cannot join video calls on container deployments. Use a VM or Remote Browser (CDP) instead.'
4123
+ : 'A dedicated browser instance will be launched for video calls with virtual display and audio.'
4124
+ ),
4125
+ h('button', {
4126
+ className: 'btn btn-sm',
4127
+ disabled: launching || (isContainer && !canJoinMeetings),
4128
+ onClick: launchMeetingBrowser,
4129
+ title: isContainer && !canJoinMeetings ? 'Not available on container deployments' : '',
4130
+ },
4131
+ isContainer && !canJoinMeetings
4132
+ ? '\u274C Not available on containers'
4133
+ : launching ? 'Launching...' : '\u25B6\uFE0F Launch Meeting Browser'
4078
4134
  )
4079
4135
  )
4080
4136
  )
@@ -922,6 +922,20 @@ export function createAgentRoutes(opts: {
922
922
  ];
923
923
 
924
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
+
925
939
  // BROWSER CONFIGURATION
926
940
  // ═══════════════════════════════════════════════════════════
927
941
 
@@ -943,6 +957,20 @@ export function createAgentRoutes(opts: {
943
957
  if (!managed) return c.json({ error: 'Agent not found' }, 404);
944
958
 
945
959
  try {
960
+ // Check system capabilities first
961
+ const { detectCapabilities, getCapabilitySummary } = await import('../runtime/environment.js');
962
+ const caps = detectCapabilities();
963
+ if (!caps.canJoinMeetings) {
964
+ const summary = getCapabilitySummary(caps);
965
+ return c.json({
966
+ error: 'Meeting browser cannot run on this ' + summary.deployment + ' deployment',
967
+ deployment: summary.deployment,
968
+ missing: summary.unavailable,
969
+ recommendations: summary.recommendations,
970
+ hint: 'Deploy on a VM with display + audio, or configure a Remote Browser (CDP) provider.',
971
+ }, 400);
972
+ }
973
+
946
974
  const { execSync, spawn } = await import('node:child_process');
947
975
  const chromePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || '/usr/bin/chromium';
948
976
 
@@ -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 ──────────────────────────────────────────