@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.
- package/dist/browser-tool-KJMHSNK3.js +3997 -0
- package/dist/chunk-AQH4DFYV.js +142 -0
- package/dist/chunk-CETB63LZ.js +15813 -0
- package/dist/chunk-FN73A3AQ.js +15813 -0
- package/dist/chunk-LOA5LNXR.js +898 -0
- package/dist/chunk-PHZS6OIO.js +2191 -0
- package/dist/chunk-QISZRTVR.js +2154 -0
- package/dist/chunk-QJIY4RQJ.js +2191 -0
- package/dist/chunk-TGOSFAYW.js +194 -0
- package/dist/chunk-U72GUDSD.js +898 -0
- package/dist/cli.js +1 -1
- package/dist/dashboard/pages/agent-detail.js +60 -4
- package/dist/environment-L6BN6KGK.js +11 -0
- package/dist/index.js +5 -3
- package/dist/pw-ai-IRORDXFW.js +2212 -0
- package/dist/routes-5BXENG5D.js +6871 -0
- package/dist/routes-VIK7WDVW.js +6859 -0
- package/dist/runtime-NEO2ZB6O.js +49 -0
- package/dist/runtime-XVQZ3DRZ.js +49 -0
- package/dist/server-5NCVUWGY.js +12 -0
- package/dist/server-ZIB4HUUC.js +12 -0
- package/dist/setup-3KIT2V6U.js +20 -0
- package/dist/setup-YD3LJ6DT.js +20 -0
- package/package.json +1 -1
- package/scripts/vm-setup.sh +309 -0
- package/src/agent-tools/index.ts +69 -2
- package/src/agent-tools/tools/meeting-lifecycle.ts +437 -0
- package/src/dashboard/pages/agent-detail.js +60 -4
- package/src/engine/agent-routes.ts +28 -0
- package/src/runtime/agent-loop.ts +1 -1
- package/src/runtime/environment.ts +290 -0
- package/src/runtime/index.ts +3 -3
|
@@ -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',
|
|
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 } },
|
|
4076
|
-
|
|
4077
|
-
|
|
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 =
|
|
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 ──────────────────────────────────────────
|