bosun 0.36.2 → 0.36.3

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,631 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { getSessionTracker } from "./session-tracker.mjs";
3
+ import {
4
+ execPrimaryPrompt as execPrimaryPromptDefault,
5
+ getPrimaryAgentName as getPrimaryAgentNameDefault,
6
+ getAgentMode as getAgentModeDefault,
7
+ } from "./primary-agent.mjs";
8
+ import {
9
+ analyzeVisionFrame as analyzeVisionFrameDefault,
10
+ isVoiceAvailable as isVoiceAvailableDefault,
11
+ getVoiceConfig as getVoiceConfigDefault,
12
+ getRealtimeConnectionInfo as getRealtimeConnectionInfoDefault,
13
+ } from "./voice-relay.mjs";
14
+
15
+ const MAX_TRANSCRIPT_PAGE_SIZE = 500;
16
+ const DEFAULT_TRANSCRIPT_PAGE_SIZE = 100;
17
+ const MAX_VISION_FRAME_BYTES = Math.max(
18
+ 128_000,
19
+ Number.parseInt(process.env.VISION_FRAME_MAX_BYTES || "", 10) || 2_000_000,
20
+ );
21
+ const DEFAULT_VISION_ANALYSIS_INTERVAL_MS = Math.min(
22
+ 30_000,
23
+ Math.max(
24
+ 500,
25
+ Number.parseInt(process.env.VISION_ANALYSIS_INTERVAL_MS || "", 10) || 1500,
26
+ ),
27
+ );
28
+ const INACTIVE_MEETING_STATUSES = new Set([
29
+ "paused",
30
+ "archived",
31
+ "completed",
32
+ "failed",
33
+ "cancelled",
34
+ ]);
35
+ const ALLOWED_STOP_STATUSES = new Set([
36
+ "active",
37
+ "paused",
38
+ "completed",
39
+ "archived",
40
+ "failed",
41
+ "cancelled",
42
+ ]);
43
+
44
+ const meetingVisionStateCache = new Map();
45
+
46
+ export class MeetingWorkflowServiceError extends Error {
47
+ constructor(code, message, details = undefined) {
48
+ super(message);
49
+ this.name = "MeetingWorkflowServiceError";
50
+ this.code = String(code || "MEETING_WORKFLOW_ERROR");
51
+ if (details !== undefined) this.details = details;
52
+ }
53
+ }
54
+
55
+ function normalizeObject(value) {
56
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
57
+ }
58
+
59
+ function normalizeNonEmptyString(value) {
60
+ const text = String(value ?? "").trim();
61
+ return text || null;
62
+ }
63
+
64
+ function requireNonEmptyString(value, fieldName) {
65
+ const normalized = normalizeNonEmptyString(value);
66
+ if (!normalized) {
67
+ throw new MeetingWorkflowServiceError(
68
+ "MEETING_VALIDATION_ERROR",
69
+ `${fieldName} is required`,
70
+ { field: fieldName },
71
+ );
72
+ }
73
+ return normalized;
74
+ }
75
+
76
+ function normalizePositiveInt(value, fallback, min, max) {
77
+ const parsed = Number.parseInt(String(value ?? ""), 10);
78
+ if (!Number.isFinite(parsed)) return fallback;
79
+ return Math.min(max, Math.max(min, parsed));
80
+ }
81
+
82
+ function normalizeSource(value) {
83
+ const normalized = String(value || "").trim().toLowerCase();
84
+ if (normalized === "camera") return "camera";
85
+ if (normalized === "screen" || normalized === "display") return "screen";
86
+ return "screen";
87
+ }
88
+
89
+ function parseVisionFrameDataUrl(dataUrl) {
90
+ const raw = String(dataUrl || "").trim();
91
+ const match = raw.match(/^data:(image\/(?:jpeg|jpg|png|webp));base64,([A-Za-z0-9+/=]+)$/i);
92
+ if (!match) {
93
+ throw new MeetingWorkflowServiceError(
94
+ "MEETING_FRAME_INVALID",
95
+ "frameDataUrl must be a base64 image data URL (jpeg/png/webp)",
96
+ );
97
+ }
98
+
99
+ const base64Data = String(match[2] || "");
100
+ const approxBytes = Math.floor((base64Data.length * 3) / 4);
101
+ if (approxBytes <= 0) {
102
+ throw new MeetingWorkflowServiceError(
103
+ "MEETING_FRAME_INVALID",
104
+ "frameDataUrl was empty",
105
+ );
106
+ }
107
+ if (approxBytes > MAX_VISION_FRAME_BYTES) {
108
+ throw new MeetingWorkflowServiceError(
109
+ "MEETING_FRAME_TOO_LARGE",
110
+ `frameDataUrl too large (${approxBytes} bytes > ${MAX_VISION_FRAME_BYTES} bytes limit)`,
111
+ { approxBytes, maxBytes: MAX_VISION_FRAME_BYTES },
112
+ );
113
+ }
114
+
115
+ return { raw, base64Data };
116
+ }
117
+
118
+ function getVisionState(sessionId, stateCache) {
119
+ if (!stateCache.has(sessionId)) {
120
+ stateCache.set(sessionId, {
121
+ lastFrameHash: null,
122
+ lastReceiptAt: 0,
123
+ lastAnalyzedHash: null,
124
+ lastAnalyzedAt: 0,
125
+ lastSummary: "",
126
+ inFlight: null,
127
+ });
128
+ }
129
+ return stateCache.get(sessionId);
130
+ }
131
+
132
+ function summarizeSession(session) {
133
+ if (!session) return null;
134
+ return {
135
+ id: session.id || session.taskId || null,
136
+ taskId: session.taskId || session.id || null,
137
+ type: session.type || "primary",
138
+ status: session.status || "active",
139
+ createdAt: session.createdAt || null,
140
+ lastActiveAt: session.lastActiveAt || null,
141
+ metadata: session.metadata || {},
142
+ messageCount: Array.isArray(session.messages) ? session.messages.length : 0,
143
+ };
144
+ }
145
+
146
+ function extractResultText(result) {
147
+ if (typeof result === "string") return result;
148
+ if (!result || typeof result !== "object") return "";
149
+ return String(
150
+ result.finalResponse
151
+ || result.text
152
+ || result.message
153
+ || "",
154
+ );
155
+ }
156
+
157
+ function buildVoiceSummary({
158
+ isVoiceAvailable,
159
+ getVoiceConfig,
160
+ getRealtimeConnectionInfo,
161
+ }) {
162
+ try {
163
+ const availability = isVoiceAvailable();
164
+ const config = getVoiceConfig();
165
+ const connectionInfo = availability?.tier === 1
166
+ ? getRealtimeConnectionInfo()
167
+ : null;
168
+
169
+ return {
170
+ available: Boolean(availability?.available),
171
+ tier: availability?.tier ?? null,
172
+ provider: availability?.provider || config?.provider || null,
173
+ reason: availability?.reason || null,
174
+ config: {
175
+ provider: config?.provider || null,
176
+ model: config?.model || null,
177
+ visionModel: config?.visionModel || null,
178
+ voiceId: config?.voiceId || null,
179
+ turnDetection: config?.turnDetection || null,
180
+ fallbackMode: config?.fallbackMode || null,
181
+ delegateExecutor: config?.delegateExecutor || null,
182
+ enabled: config?.enabled !== false,
183
+ },
184
+ connectionInfo,
185
+ };
186
+ } catch (err) {
187
+ return {
188
+ available: false,
189
+ tier: null,
190
+ provider: null,
191
+ reason: `voice_config_unavailable: ${err?.message || err}`,
192
+ config: null,
193
+ connectionInfo: null,
194
+ };
195
+ }
196
+ }
197
+
198
+ function ensureSessionTrackerShape(sessionTracker) {
199
+ const requiredMethods = [
200
+ "getSessionById",
201
+ "createSession",
202
+ "recordEvent",
203
+ "getSessionMessages",
204
+ "updateSessionStatus",
205
+ ];
206
+ for (const method of requiredMethods) {
207
+ if (typeof sessionTracker?.[method] !== "function") {
208
+ throw new MeetingWorkflowServiceError(
209
+ "MEETING_DEPENDENCY_ERROR",
210
+ `sessionTracker missing required method: ${method}`,
211
+ );
212
+ }
213
+ }
214
+ }
215
+
216
+ function buildMeetingMetadata(opts, getPrimaryAgentName, getAgentMode) {
217
+ const metadata = {
218
+ source: "workflow-meeting",
219
+ agent: normalizeNonEmptyString(opts.agent) || normalizeNonEmptyString(getPrimaryAgentName()),
220
+ mode: normalizeNonEmptyString(opts.mode) || normalizeNonEmptyString(getAgentMode()),
221
+ model: normalizeNonEmptyString(opts.model),
222
+ };
223
+
224
+ if (opts.metadata && typeof opts.metadata === "object" && !Array.isArray(opts.metadata)) {
225
+ return { ...opts.metadata, ...metadata };
226
+ }
227
+ return metadata;
228
+ }
229
+
230
+ export function createMeetingWorkflowService(dependencies = {}) {
231
+ const deps = normalizeObject(dependencies);
232
+ const sessionTracker = deps.sessionTracker || getSessionTracker();
233
+ ensureSessionTrackerShape(sessionTracker);
234
+
235
+ const execPrimaryPrompt = deps.execPrimaryPrompt || execPrimaryPromptDefault;
236
+ const analyzeVisionFrame = deps.analyzeVisionFrame || analyzeVisionFrameDefault;
237
+ const isVoiceAvailable = deps.isVoiceAvailable || isVoiceAvailableDefault;
238
+ const getVoiceConfig = deps.getVoiceConfig || getVoiceConfigDefault;
239
+ const getRealtimeConnectionInfo =
240
+ deps.getRealtimeConnectionInfo || getRealtimeConnectionInfoDefault;
241
+ const getPrimaryAgentName = deps.getPrimaryAgentName || getPrimaryAgentNameDefault;
242
+ const getAgentMode = deps.getAgentMode || getAgentModeDefault;
243
+ const now = typeof deps.now === "function" ? deps.now : Date.now;
244
+ const createMessageId = typeof deps.createMessageId === "function"
245
+ ? deps.createMessageId
246
+ : () => `msg-${now()}-${randomUUID().slice(0, 8)}`;
247
+ const stateCache = deps.visionStateCache instanceof Map
248
+ ? deps.visionStateCache
249
+ : meetingVisionStateCache;
250
+
251
+ if (typeof execPrimaryPrompt !== "function") {
252
+ throw new MeetingWorkflowServiceError(
253
+ "MEETING_DEPENDENCY_ERROR",
254
+ "execPrimaryPrompt dependency must be a function",
255
+ );
256
+ }
257
+ if (typeof analyzeVisionFrame !== "function") {
258
+ throw new MeetingWorkflowServiceError(
259
+ "MEETING_DEPENDENCY_ERROR",
260
+ "analyzeVisionFrame dependency must be a function",
261
+ );
262
+ }
263
+
264
+ function ensureMeetingSession(sessionId, opts = {}) {
265
+ const meetingId = requireNonEmptyString(sessionId, "sessionId");
266
+ const existing = sessionTracker.getSessionById(meetingId);
267
+ if (existing) return { session: existing, created: false };
268
+
269
+ const created = sessionTracker.createSession({
270
+ id: meetingId,
271
+ type: normalizeNonEmptyString(opts.type) || "primary",
272
+ metadata: buildMeetingMetadata(opts, getPrimaryAgentName, getAgentMode),
273
+ maxMessages: opts.maxMessages,
274
+ });
275
+ return { session: created, created: true };
276
+ }
277
+
278
+ async function startMeeting(opts = {}) {
279
+ const options = normalizeObject(opts);
280
+ const providedSessionId = normalizeNonEmptyString(options.sessionId || options.id);
281
+ const sessionId = providedSessionId || `meeting-${now()}-${randomUUID().slice(0, 8)}`;
282
+ const ensured = ensureMeetingSession(sessionId, options);
283
+
284
+ if (options.activate === true) {
285
+ sessionTracker.updateSessionStatus(sessionId, "active");
286
+ }
287
+
288
+ const session = sessionTracker.getSessionById(sessionId) || ensured.session;
289
+ return {
290
+ sessionId,
291
+ created: ensured.created,
292
+ session: summarizeSession(session),
293
+ voice: buildVoiceSummary({
294
+ isVoiceAvailable,
295
+ getVoiceConfig,
296
+ getRealtimeConnectionInfo,
297
+ }),
298
+ };
299
+ }
300
+
301
+ async function sendMeetingMessage(sessionId, content, opts = {}) {
302
+ const options = normalizeObject(opts);
303
+ const meetingId = requireNonEmptyString(sessionId, "sessionId");
304
+ const message = requireNonEmptyString(content, "content");
305
+
306
+ const ensureIfMissing = options.createIfMissing !== false;
307
+ let session = sessionTracker.getSessionById(meetingId);
308
+ if (!session && ensureIfMissing) {
309
+ session = ensureMeetingSession(meetingId, options).session;
310
+ }
311
+ if (!session) {
312
+ throw new MeetingWorkflowServiceError(
313
+ "MEETING_SESSION_NOT_FOUND",
314
+ `Session not found: ${meetingId}`,
315
+ );
316
+ }
317
+
318
+ const status = String(session.status || "active").trim().toLowerCase();
319
+ if (INACTIVE_MEETING_STATUSES.has(status) && options.allowInactive !== true) {
320
+ throw new MeetingWorkflowServiceError(
321
+ "MEETING_SESSION_INACTIVE",
322
+ `Session is ${status}`,
323
+ { sessionId: meetingId, status },
324
+ );
325
+ }
326
+
327
+ const messageId = String(createMessageId());
328
+ const observedEvents = [];
329
+ const upstreamOnEvent = typeof options.onEvent === "function" ? options.onEvent : null;
330
+
331
+ const onEvent = (err, event) => {
332
+ const payload = event || err;
333
+ if (!payload) return;
334
+ observedEvents.push(payload);
335
+
336
+ try {
337
+ if (typeof payload === "string") {
338
+ sessionTracker.recordEvent(meetingId, {
339
+ role: "system",
340
+ type: "system",
341
+ content: payload,
342
+ timestamp: new Date(now()).toISOString(),
343
+ });
344
+ } else {
345
+ sessionTracker.recordEvent(meetingId, payload);
346
+ }
347
+ } catch {
348
+ // best effort only; dispatch should continue
349
+ }
350
+
351
+ if (upstreamOnEvent) {
352
+ try {
353
+ upstreamOnEvent(err, event);
354
+ } catch {
355
+ // avoid user callback errors bubbling into active dispatch
356
+ }
357
+ }
358
+ };
359
+
360
+ try {
361
+ const result = await execPrimaryPrompt(message, {
362
+ sessionId: meetingId,
363
+ sessionType: String(session.type || "primary"),
364
+ mode: normalizeNonEmptyString(options.mode) || undefined,
365
+ model: normalizeNonEmptyString(options.model) || undefined,
366
+ persistent: options.persistent !== false,
367
+ sendRawEvents: options.sendRawEvents !== false,
368
+ attachments: Array.isArray(options.attachments) ? options.attachments : undefined,
369
+ attachmentsAppended: options.attachmentsAppended === true,
370
+ timeoutMs: normalizePositiveInt(
371
+ options.timeoutMs,
372
+ undefined,
373
+ 1000,
374
+ 4 * 60 * 60 * 1000,
375
+ ),
376
+ cwd: normalizeNonEmptyString(options.cwd) || undefined,
377
+ abortController: options.abortController,
378
+ onEvent,
379
+ });
380
+
381
+ return {
382
+ ok: true,
383
+ sessionId: meetingId,
384
+ messageId,
385
+ status: "sent",
386
+ responseText: extractResultText(result),
387
+ adapter: result?.adapter || null,
388
+ threadId: result?.threadId || result?.sessionId || meetingId,
389
+ usage: result?.usage || null,
390
+ observedEventCount: observedEvents.length,
391
+ resultMetadata: {
392
+ hasItems: Array.isArray(result?.items) && result.items.length > 0,
393
+ hasRawResult: Boolean(result),
394
+ },
395
+ };
396
+ } catch (err) {
397
+ const messageText = err?.message || String(err);
398
+ try {
399
+ sessionTracker.recordEvent(meetingId, {
400
+ role: "system",
401
+ type: "error",
402
+ content: `Agent error: ${messageText}`,
403
+ timestamp: new Date(now()).toISOString(),
404
+ });
405
+ } catch {
406
+ // best effort only
407
+ }
408
+ throw new MeetingWorkflowServiceError(
409
+ "MEETING_MESSAGE_DISPATCH_FAILED",
410
+ `Failed to send meeting message: ${messageText}`,
411
+ { sessionId: meetingId },
412
+ );
413
+ }
414
+ }
415
+
416
+ async function fetchMeetingTranscript(sessionId, opts = {}) {
417
+ const options = normalizeObject(opts);
418
+ const meetingId = requireNonEmptyString(sessionId, "sessionId");
419
+ const session = sessionTracker.getSessionMessages(meetingId);
420
+ if (!session) {
421
+ throw new MeetingWorkflowServiceError(
422
+ "MEETING_SESSION_NOT_FOUND",
423
+ `Session not found: ${meetingId}`,
424
+ );
425
+ }
426
+
427
+ const pageSize = normalizePositiveInt(
428
+ options.limit ?? options.pageSize,
429
+ DEFAULT_TRANSCRIPT_PAGE_SIZE,
430
+ 1,
431
+ MAX_TRANSCRIPT_PAGE_SIZE,
432
+ );
433
+ const requestedPage = normalizePositiveInt(options.page, 1, 1, Number.MAX_SAFE_INTEGER);
434
+ const messages = Array.isArray(session.messages) ? session.messages : [];
435
+ const totalMessages = messages.length;
436
+ const totalPages = totalMessages === 0 ? 0 : Math.ceil(totalMessages / pageSize);
437
+ const page = totalPages === 0 ? 1 : Math.min(requestedPage, totalPages);
438
+ const start = totalPages === 0 ? 0 : (page - 1) * pageSize;
439
+ const end = Math.min(totalMessages, start + pageSize);
440
+
441
+ return {
442
+ sessionId: meetingId,
443
+ status: String(session.status || "unknown"),
444
+ sessionType: String(session.type || "primary"),
445
+ metadata: session.metadata || {},
446
+ createdAt: session.createdAt || null,
447
+ lastActiveAt: session.lastActiveAt || null,
448
+ totalMessages,
449
+ page,
450
+ pageSize,
451
+ totalPages,
452
+ hasNextPage: totalPages > 0 && page < totalPages,
453
+ hasPreviousPage: totalPages > 0 && page > 1,
454
+ messages: messages.slice(start, end),
455
+ };
456
+ }
457
+
458
+ async function analyzeMeetingFrame(sessionId, frameDataUrl, opts = {}) {
459
+ const options = normalizeObject(opts);
460
+ const meetingId = requireNonEmptyString(sessionId, "sessionId");
461
+ const parsedFrame = parseVisionFrameDataUrl(frameDataUrl);
462
+ const source = normalizeSource(options.source);
463
+ const forceAnalyze = options.forceAnalyze === true;
464
+ const minIntervalMs = normalizePositiveInt(
465
+ options.minIntervalMs,
466
+ DEFAULT_VISION_ANALYSIS_INTERVAL_MS,
467
+ 300,
468
+ 30_000,
469
+ );
470
+ const width = Number.isFinite(Number(options.width)) ? Number(options.width) : null;
471
+ const height = Number.isFinite(Number(options.height)) ? Number(options.height) : null;
472
+
473
+ const state = getVisionState(meetingId, stateCache);
474
+ const frameHash = createHash("sha1").update(parsedFrame.base64Data).digest("hex");
475
+ const currentNow = now();
476
+
477
+ state.lastFrameHash = frameHash;
478
+ state.lastReceiptAt = currentNow;
479
+
480
+ if (!forceAnalyze && state.inFlight) {
481
+ return {
482
+ ok: true,
483
+ sessionId: meetingId,
484
+ analyzed: false,
485
+ skipped: true,
486
+ reason: "analysis_in_progress",
487
+ summary: state.lastSummary || undefined,
488
+ };
489
+ }
490
+
491
+ if (!forceAnalyze && frameHash === state.lastAnalyzedHash) {
492
+ return {
493
+ ok: true,
494
+ sessionId: meetingId,
495
+ analyzed: false,
496
+ skipped: true,
497
+ reason: "duplicate_frame",
498
+ summary: state.lastSummary || undefined,
499
+ };
500
+ }
501
+
502
+ if (!forceAnalyze && currentNow - state.lastAnalyzedAt < minIntervalMs) {
503
+ return {
504
+ ok: true,
505
+ sessionId: meetingId,
506
+ analyzed: false,
507
+ skipped: true,
508
+ reason: "throttled",
509
+ summary: state.lastSummary || undefined,
510
+ };
511
+ }
512
+
513
+ const pending = analyzeVisionFrame(parsedFrame.raw, {
514
+ source,
515
+ context: {
516
+ sessionId: meetingId,
517
+ executor: normalizeNonEmptyString(options.executor) || undefined,
518
+ mode: normalizeNonEmptyString(options.mode) || undefined,
519
+ model: normalizeNonEmptyString(options.model) || undefined,
520
+ },
521
+ prompt: normalizeNonEmptyString(options.prompt) || undefined,
522
+ model: normalizeNonEmptyString(options.visionModel) || undefined,
523
+ });
524
+ state.inFlight = pending;
525
+
526
+ let analysis;
527
+ try {
528
+ analysis = await pending;
529
+ } catch (err) {
530
+ throw new MeetingWorkflowServiceError(
531
+ "MEETING_VISION_ANALYSIS_FAILED",
532
+ `Vision analysis failed: ${err?.message || err}`,
533
+ { sessionId: meetingId },
534
+ );
535
+ } finally {
536
+ if (state.inFlight === pending) state.inFlight = null;
537
+ }
538
+
539
+ const summary = normalizeNonEmptyString(analysis?.summary);
540
+ if (!summary) {
541
+ throw new MeetingWorkflowServiceError(
542
+ "MEETING_VISION_ANALYSIS_FAILED",
543
+ "Vision analysis returned an empty summary",
544
+ { sessionId: meetingId },
545
+ );
546
+ }
547
+
548
+ const session = sessionTracker.getSessionById(meetingId)
549
+ || ensureMeetingSession(meetingId, options).session;
550
+ const dimension = width && height ? ` (${width}x${height})` : "";
551
+ sessionTracker.recordEvent(session.id || meetingId, {
552
+ role: "system",
553
+ type: "vision_summary",
554
+ content: `[Vision ${source}${dimension}] ${summary}`,
555
+ timestamp: new Date(now()).toISOString(),
556
+ meta: {
557
+ source,
558
+ provider: normalizeNonEmptyString(analysis?.provider) || undefined,
559
+ model: normalizeNonEmptyString(analysis?.model) || undefined,
560
+ },
561
+ });
562
+
563
+ state.lastAnalyzedHash = frameHash;
564
+ state.lastAnalyzedAt = now();
565
+ state.lastSummary = summary;
566
+
567
+ return {
568
+ ok: true,
569
+ sessionId: meetingId,
570
+ analyzed: true,
571
+ skipped: false,
572
+ summary,
573
+ provider: normalizeNonEmptyString(analysis?.provider),
574
+ model: normalizeNonEmptyString(analysis?.model),
575
+ frameHash,
576
+ };
577
+ }
578
+
579
+ async function stopMeeting(sessionId, opts = {}) {
580
+ const options = normalizeObject(opts);
581
+ const meetingId = requireNonEmptyString(sessionId, "sessionId");
582
+ const session = sessionTracker.getSessionById(meetingId);
583
+ if (!session) {
584
+ throw new MeetingWorkflowServiceError(
585
+ "MEETING_SESSION_NOT_FOUND",
586
+ `Session not found: ${meetingId}`,
587
+ );
588
+ }
589
+
590
+ const status = normalizeNonEmptyString(options.status || "completed")?.toLowerCase();
591
+ if (!status || !ALLOWED_STOP_STATUSES.has(status)) {
592
+ throw new MeetingWorkflowServiceError(
593
+ "MEETING_VALIDATION_ERROR",
594
+ `status must be one of: ${Array.from(ALLOWED_STOP_STATUSES).join(", ")}`,
595
+ );
596
+ }
597
+
598
+ sessionTracker.updateSessionStatus(meetingId, status);
599
+
600
+ const note = normalizeNonEmptyString(options.note);
601
+ if (note) {
602
+ sessionTracker.recordEvent(meetingId, {
603
+ role: "system",
604
+ type: "system",
605
+ content: note,
606
+ timestamp: new Date(now()).toISOString(),
607
+ });
608
+ }
609
+
610
+ if (status !== "active" && status !== "paused") {
611
+ stateCache.delete(meetingId);
612
+ }
613
+
614
+ const updatedSession = sessionTracker.getSessionById(meetingId);
615
+ return {
616
+ ok: true,
617
+ sessionId: meetingId,
618
+ status,
619
+ session: summarizeSession(updatedSession),
620
+ };
621
+ }
622
+
623
+ return {
624
+ startMeeting,
625
+ sendMeetingMessage,
626
+ fetchMeetingTranscript,
627
+ analyzeMeetingFrame,
628
+ stopMeeting,
629
+ };
630
+ }
631
+