@okostream/mcp 0.7.3 → 0.8.1

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/tools.js CHANGED
@@ -3,10 +3,9 @@ import { stat, readFile, realpath } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { randomBytes, randomUUID } from "node:crypto";
6
- import sharp from "sharp";
7
6
  import { Agent, fetch as undiciFetch } from "undici";
8
7
  import { z } from "zod";
9
- import { addDoc, arrayRemove, collection, deleteDoc, deleteField, documentId, doc, getDoc, getDocs, limit, orderBy, query, runTransaction as runFirestoreTransaction, serverTimestamp, setDoc, startAfter, Timestamp, updateDoc, where, } from "firebase/firestore";
8
+ import { addDoc, collection, deleteDoc, deleteField, documentId, doc, getDoc, getDocs, limit, orderBy, query, runTransaction as runFirestoreTransaction, serverTimestamp, setDoc, startAfter, Timestamp, updateDoc, where, } from "firebase/firestore";
10
9
  import { get, endBefore, limitToLast, orderByKey, push, query as dbQuery, ref, remove, runTransaction, serverTimestamp as rtdbServerTimestamp, set, update, } from "firebase/database";
11
10
  import { httpsCallable } from "firebase/functions";
12
11
  import { ensureAuth, currentScopes } from "./auth.js";
@@ -26,21 +25,14 @@ const MAX_CARD_TEXT_LENGTH = 500;
26
25
  const MAX_CARD_BUTTON_LABEL_LENGTH = 80;
27
26
  const MAX_CARD_BUTTON_URL_LENGTH = 1000;
28
27
  const MAX_SOURCE_IMAGE_BYTES = 15 * 1024 * 1024;
29
- const MAX_SOURCE_IMAGE_PIXELS = 50_000_000;
30
- const MAX_MASTER_IMAGE_BYTES = 10 * 1024 * 1024;
31
- const TARGET_MASTER_IMAGE_BYTES = 600 * 1024;
32
- const MAX_MASTER_IMAGE_DIMENSION = 1600;
33
28
  const REMOTE_IMAGE_TIMEOUT_MS = 15_000;
34
29
  const REMOTE_IMAGE_MAX_REDIRECTS = 3;
35
- const ALLOWED_SOURCE_IMAGE_FORMATS = new Set(["jpeg", "png", "webp", "gif", "avif"]);
36
30
  const ALLOWED_REMOTE_IMAGE_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif", "image/avif"]);
37
- const MAX_FIRESTORE_TIMELINE_BUCKETS = 1500;
38
31
  const DEFAULT_LIST_LIMIT = 30;
39
32
  const MAX_LIST_LIMIT = 100;
40
33
  const MAX_EXPORT_LIMIT = 200;
41
34
  const ANALYTICS_DEFAULT_LIMIT = 10;
42
35
  const ANALYTICS_TIMELINE_DEFAULT_LIMIT = 120;
43
- const MAX_NMO_IMPORT_PARTICIPANTS = 200;
44
36
  const MAX_WORKSPACE_NAME_LENGTH = 120;
45
37
  const MAX_WORKSPACE_INVITE_CODE_LENGTH = 64;
46
38
  const TELEGRAM_POST_STATUSES = ["draft", "scheduled", "sent", "failed", "delivery_unknown"];
@@ -81,10 +73,6 @@ const eventLinksOutputShape = {
81
73
  url: z.string().url(),
82
74
  setupUrl: z.string().url(),
83
75
  analyticsUrl: z.string().url(),
84
- registrationUrl: z.string().url(),
85
- loginUrl: z.string().url(),
86
- nmoRegistrationUrl: z.string().url().optional(),
87
- nmoLoginUrl: z.string().url().optional(),
88
76
  };
89
77
  const createWebinarOutputSchema = z.object({
90
78
  id: z.string(),
@@ -126,11 +114,6 @@ const chatOutputSchema = z.object({
126
114
  messages: z.array(unknownRecordSchema),
127
115
  ...cursorMetaSchema,
128
116
  }).passthrough();
129
- const participantsOutputSchema = z.object({
130
- participants: z.array(unknownRecordSchema).optional(),
131
- csv: z.string().optional(),
132
- ...cursorMetaSchema,
133
- }).passthrough();
134
117
  const leadsOutputSchema = z.object({
135
118
  leads: z.array(unknownRecordSchema).optional(),
136
119
  csv: z.string().optional(),
@@ -200,11 +183,6 @@ const telegramPostsOutputSchema = z.object({
200
183
  posts: z.array(unknownRecordSchema),
201
184
  ...cursorMetaSchema,
202
185
  }).passthrough();
203
- const nmoImportOutputSchema = z.object({
204
- eventId: z.string(),
205
- summary: unknownRecordSchema,
206
- results: z.array(unknownRecordSchema),
207
- }).passthrough();
208
186
  const sessionReportOutputSchema = z.object({
209
187
  eventId: z.string(),
210
188
  sessionId: z.string(),
@@ -265,13 +243,6 @@ const cardItemInputSchema = z.object({
265
243
  buttonUrl: z.string().max(MAX_CARD_BUTTON_URL_LENGTH).nullable().optional(),
266
244
  formId: cardIdSchema.nullable().optional(),
267
245
  }).strict();
268
- const emailSchema = z.string().email().max(320);
269
- const nmoParticipantInputSchema = z.object({
270
- email: emailSchema.describe("Participant email."),
271
- fullName: z.string().min(2).max(200).describe("Participant full name."),
272
- position: z.string().max(200).nullable().optional().describe("Optional medical position."),
273
- org: z.string().max(200).nullable().optional().describe("Optional organization/clinic."),
274
- }).strict();
275
246
  const telegramChannelSchema = z.object({
276
247
  chatId: z.number().int(),
277
248
  title: z.string().max(200).optional(),
@@ -532,33 +503,6 @@ async function readImageSource(source) {
532
503
  return readRemoteImage(source.url);
533
504
  throw new Error("Provide image source path or url.");
534
505
  }
535
- async function compressToMasterWebp(input) {
536
- const metadata = await sharp(input, { limitInputPixels: MAX_SOURCE_IMAGE_PIXELS }).metadata();
537
- if (!metadata.format || !ALLOWED_SOURCE_IMAGE_FORMATS.has(metadata.format)) {
538
- throw new Error(`Unsupported image format ${metadata.format ?? "unknown"}. Use JPEG, PNG, WEBP, GIF, or AVIF.`);
539
- }
540
- const transformer = sharp(input, { limitInputPixels: MAX_SOURCE_IMAGE_PIXELS })
541
- .rotate()
542
- .resize({
543
- width: MAX_MASTER_IMAGE_DIMENSION,
544
- height: MAX_MASTER_IMAGE_DIMENSION,
545
- fit: "inside",
546
- withoutEnlargement: true,
547
- });
548
- let last = null;
549
- for (const quality of [88, 84, 80, 76, 72]) {
550
- const out = await transformer.clone().webp({ quality }).toBuffer();
551
- last = out;
552
- if (out.byteLength <= TARGET_MASTER_IMAGE_BYTES)
553
- return out;
554
- }
555
- if (!last)
556
- throw new Error("Image compression failed.");
557
- if (last.byteLength > MAX_MASTER_IMAGE_BYTES) {
558
- throw new Error("Compressed image is still larger than Firebase Storage 10MB limit.");
559
- }
560
- return last;
561
- }
562
506
  function readImageRef(eventData, kind, cardId) {
563
507
  const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
564
508
  if (kind === "promoBanner") {
@@ -826,7 +770,6 @@ async function uploadEventImageForUser(uid, eventId, kind, cardId, source) {
826
770
  const event = await readEvent(eventId, uid, "edit");
827
771
  const previous = readImageRef(event, kind, cardId);
828
772
  const sourceBuffer = await readImageSource(source);
829
- const masterBuffer = await compressToMasterWebp(sourceBuffer);
830
773
  const previousMasterPath = storagePathFromDownloadUrl(previous.imageMasterUrl);
831
774
  const deleteWarnings = [];
832
775
  let deletesAttempted = 0;
@@ -838,7 +781,7 @@ async function uploadEventImageForUser(uid, eventId, kind, cardId, source) {
838
781
  }
839
782
  let uploaded = null;
840
783
  try {
841
- uploaded = await uploadMasterImage(eventId, kind, cardId, masterBuffer);
784
+ uploaded = await uploadMasterImage(eventId, kind, cardId, sourceBuffer);
842
785
  }
843
786
  catch (err) {
844
787
  if (uploaded) {
@@ -895,24 +838,11 @@ function analyticsUrl(eventId) {
895
838
  function sessionReportUrl(eventId, sessionId) {
896
839
  return `${analyticsUrl(eventId)}/report/${sessionId}`;
897
840
  }
898
- function nmoRegisterUrl(eventId) {
899
- return `${eventUrl(eventId)}/register`;
900
- }
901
- function nmoLoginUrl(eventId) {
902
- return `${eventUrl(eventId)}/login`;
903
- }
904
- function eventLinks(eventId, eventData) {
905
- const nmoMode = eventData?.nmoMode === true;
841
+ function eventLinks(eventId) {
906
842
  return {
907
843
  url: eventUrl(eventId),
908
844
  setupUrl: setupUrl(eventId),
909
845
  analyticsUrl: analyticsUrl(eventId),
910
- registrationUrl: nmoRegisterUrl(eventId),
911
- loginUrl: nmoLoginUrl(eventId),
912
- ...(nmoMode ? {
913
- nmoRegistrationUrl: nmoRegisterUrl(eventId),
914
- nmoLoginUrl: nmoLoginUrl(eventId),
915
- } : {}),
916
846
  };
917
847
  }
918
848
  function assertEventReadable(data, uid) {
@@ -1063,18 +993,6 @@ function validateEventSetup(eventId, event) {
1063
993
  if (telegram.enabled === true && telegramChannels.length === 0 && typeof telegram.channelId !== "number") {
1064
994
  add(warnings, "telegram_channel_missing", "telegram.channelIds", "Telegram is enabled but no channel is configured.");
1065
995
  }
1066
- if (event.nmoMode === true) {
1067
- const criteria = asRecord(event.nmoCriteria);
1068
- const watch = typeof criteria.minWatchTimeRatio === "number" ? criteria.minWatchTimeRatio : 0.8;
1069
- const checkpoints = typeof criteria.minCheckpointRatio === "number" ? criteria.minCheckpointRatio : 0.8;
1070
- if (watch < 0 || watch > 1 || checkpoints < 0 || checkpoints > 1) {
1071
- add(errors, "nmo_criteria_invalid", "nmoCriteria", "NMO criteria ratios must be between 0 and 1.");
1072
- }
1073
- const participantUids = Array.isArray(event.participantUids) ? event.participantUids : [];
1074
- if (participantUids.length === 0) {
1075
- add(warnings, "nmo_participants_missing", "participantUids", "NMO mode is enabled but no participants are registered yet.");
1076
- }
1077
- }
1078
996
  const nextActions = [
1079
997
  ...errors.map((item) => `Fix error: ${item.message}`),
1080
998
  ...warnings.slice(0, 5).map((item) => `Review warning: ${item.message}`),
@@ -1090,7 +1008,7 @@ function validateEventSetup(eventId, event) {
1090
1008
  warnings,
1091
1009
  suggestions,
1092
1010
  nextActions,
1093
- links: eventLinks(eventId, event),
1011
+ links: eventLinks(eventId),
1094
1012
  };
1095
1013
  }
1096
1014
  function initialEventAccess(uid) {
@@ -1154,80 +1072,6 @@ function normalizeWorkspacePermissions(value) {
1154
1072
  function randomInviteCode() {
1155
1073
  return randomBytes(8).toString("hex").toUpperCase();
1156
1074
  }
1157
- function normalizeEmail(value) {
1158
- return value.trim().toLowerCase();
1159
- }
1160
- function normalizeOptionalText(value, max) {
1161
- const text = value?.trim() ?? "";
1162
- return text ? text.slice(0, max) : null;
1163
- }
1164
- function parseCsvLine(line) {
1165
- const cells = [];
1166
- let current = "";
1167
- let quoted = false;
1168
- for (let i = 0; i < line.length; i++) {
1169
- const char = line[i];
1170
- const next = line[i + 1];
1171
- if (quoted && char === "\"" && next === "\"") {
1172
- current += "\"";
1173
- i++;
1174
- continue;
1175
- }
1176
- if (char === "\"") {
1177
- quoted = !quoted;
1178
- continue;
1179
- }
1180
- if (!quoted && char === ",") {
1181
- cells.push(current.trim());
1182
- current = "";
1183
- continue;
1184
- }
1185
- current += char;
1186
- }
1187
- cells.push(current.trim());
1188
- return cells;
1189
- }
1190
- function parseNmoParticipantsCsv(csv) {
1191
- const lines = csv
1192
- .split(/\r?\n/)
1193
- .map((line) => line.trim())
1194
- .filter(Boolean);
1195
- if (lines.length < 2)
1196
- throw new Error("CSV must include a header row and at least one participant row.");
1197
- const headers = parseCsvLine(lines[0] ?? "").map((header) => header.trim().toLowerCase());
1198
- const alias = {
1199
- email: "email",
1200
- mail: "email",
1201
- "e-mail": "email",
1202
- fullname: "fullName",
1203
- full_name: "fullName",
1204
- name: "fullName",
1205
- fio: "fullName",
1206
- "фио": "fullName",
1207
- "имя": "fullName",
1208
- position: "position",
1209
- "должность": "position",
1210
- org: "org",
1211
- organization: "org",
1212
- organisation: "org",
1213
- clinic: "org",
1214
- "организация": "org",
1215
- };
1216
- return lines.slice(1).map((line, index) => {
1217
- const cells = parseCsvLine(line);
1218
- const row = {};
1219
- headers.forEach((header, cellIndex) => {
1220
- const key = alias[header];
1221
- if (key)
1222
- row[key] = cells[cellIndex] ?? "";
1223
- });
1224
- const parsed = nmoParticipantInputSchema.safeParse(row);
1225
- if (!parsed.success) {
1226
- throw new Error(`Invalid NMO CSV row ${index + 2}: ${parsed.error.issues.map((issue) => issue.message).join("; ")}`);
1227
- }
1228
- return parsed.data;
1229
- });
1230
- }
1231
1075
  async function listVisibleWorkspaces(uid, includeShared) {
1232
1076
  const out = [];
1233
1077
  const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
@@ -1246,27 +1090,6 @@ async function listVisibleWorkspaces(uid, includeShared) {
1246
1090
  }
1247
1091
  return out;
1248
1092
  }
1249
- async function readAgg(eventId) {
1250
- const snap = await get(ref(rtdb, `events/${eventId}/agg`));
1251
- const agg = (snap.val() ?? {});
1252
- const cardsById = readNestedClicks(agg.cards);
1253
- const ctaById = readNestedClicks(agg.cta);
1254
- return {
1255
- chatMessages: Number(agg.chatMessages ?? 0),
1256
- bannerClicks: Number(agg.bannerClicks ?? 0),
1257
- bannerImpressions: Number(agg.bannerImpressions ?? 0),
1258
- ctaClicks: Number(agg.ctaClicks ?? 0),
1259
- cardsClicks: Number(agg.cardsClicks ?? 0),
1260
- viewCount: Number(agg.viewCount ?? 0),
1261
- viewSumSec: Number(agg.viewSumSec ?? 0),
1262
- online: Number(agg.online ?? 0),
1263
- uniqueVisitors: Number(agg.uniqueVisitors ?? 0),
1264
- viewHist: numberRecord(agg.viewHist),
1265
- geo: numberRecord(agg.geo),
1266
- cardsById,
1267
- ctaById,
1268
- };
1269
- }
1270
1093
  async function getChatRole(eventId, uid) {
1271
1094
  const snap = await get(ref(chatRtdb, `settings/${eventId}/roles/${uid}`));
1272
1095
  return typeof snap.val() === "string" ? snap.val() : "viewer";
@@ -1274,72 +1097,6 @@ async function getChatRole(eventId, uid) {
1274
1097
  function csvRows(headers, rows) {
1275
1098
  return [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
1276
1099
  }
1277
- function numberRecord(value) {
1278
- if (!value || typeof value !== "object" || Array.isArray(value))
1279
- return {};
1280
- const out = {};
1281
- for (const [key, raw] of Object.entries(value)) {
1282
- if (typeof raw === "number" && Number.isFinite(raw))
1283
- out[key] = raw;
1284
- }
1285
- return out;
1286
- }
1287
- function readNestedClicks(value) {
1288
- if (!value || typeof value !== "object" || Array.isArray(value))
1289
- return {};
1290
- const out = {};
1291
- for (const [id, raw] of Object.entries(value)) {
1292
- if (typeof raw === "number" && Number.isFinite(raw)) {
1293
- out[id] = raw;
1294
- continue;
1295
- }
1296
- if (raw && typeof raw === "object" && typeof raw.clicks === "number") {
1297
- out[id] = raw.clicks;
1298
- }
1299
- }
1300
- return out;
1301
- }
1302
- function cleanForFirestore(value) {
1303
- if (value === undefined)
1304
- return undefined;
1305
- if (value === null || typeof value !== "object")
1306
- return value;
1307
- if (Array.isArray(value))
1308
- return value.map(cleanForFirestore).filter((item) => item !== undefined);
1309
- const out = {};
1310
- for (const [key, item] of Object.entries(value)) {
1311
- const cleaned = cleanForFirestore(item);
1312
- if (cleaned !== undefined)
1313
- out[key] = cleaned;
1314
- }
1315
- return out;
1316
- }
1317
- function downsampleTimeline(buckets, maxBuckets = MAX_FIRESTORE_TIMELINE_BUCKETS) {
1318
- if (buckets.length <= maxBuckets)
1319
- return buckets;
1320
- if (maxBuckets < 2)
1321
- return buckets.slice(-maxBuckets);
1322
- const result = [];
1323
- const lastIndex = buckets.length - 1;
1324
- for (let i = 0; i < maxBuckets; i++) {
1325
- const idx = Math.round((i * lastIndex) / (maxBuckets - 1));
1326
- const bucket = buckets[idx];
1327
- if (bucket && result[result.length - 1] !== bucket)
1328
- result.push(bucket);
1329
- }
1330
- return result;
1331
- }
1332
- function isTimelineBucket(value) {
1333
- if (!value || typeof value !== "object")
1334
- return false;
1335
- const data = value;
1336
- return typeof data.t === "number"
1337
- && typeof data.v === "number"
1338
- && typeof data.c === "number"
1339
- && typeof data.bi === "number"
1340
- && typeof data.bc === "number"
1341
- && typeof data.cta === "number";
1342
- }
1343
1100
  function toMillis(value) {
1344
1101
  if (typeof value === "number" && Number.isFinite(value))
1345
1102
  return value;
@@ -1361,100 +1118,6 @@ function timestampCursorFrom(value) {
1361
1118
  const millis = toMillis(value);
1362
1119
  return millis == null ? null : String(millis);
1363
1120
  }
1364
- function readNmoCriteria(raw) {
1365
- const data = (raw && typeof raw === "object" ? raw : {});
1366
- return {
1367
- minWatchTimeRatio: typeof data.minWatchTimeRatio === "number" ? data.minWatchTimeRatio : 0.8,
1368
- minCheckpointRatio: typeof data.minCheckpointRatio === "number" ? data.minCheckpointRatio : 0.8,
1369
- };
1370
- }
1371
- function checkpointResponseInWindow(responses, startMs, endMs) {
1372
- return Object.values(responses).some((response) => {
1373
- const ts = typeof response.respondedAt === "number" ? response.respondedAt : response.shownAt;
1374
- return typeof ts === "number" && ts >= startMs - 5_000 && ts <= endMs + 60_000;
1375
- });
1376
- }
1377
- async function buildNmoEvaluation({ eventId, sessionId, startMs, endMs, durationSec, baselineEngagement, finalEngagement, }) {
1378
- const eventSnap = await getDoc(doc(firestore, "events", eventId));
1379
- const event = eventSnap.data() ?? {};
1380
- if (event.nmoMode !== true)
1381
- return null;
1382
- const criteria = readNmoCriteria(event.nmoCriteria);
1383
- const [participantsSnap, checkpointsSnap, windowsSnap] = await Promise.all([
1384
- getDocs(query(collection(firestore, "eventParticipants", eventId, "items"), orderBy("fullName"))),
1385
- get(ref(rtdb, `events/${eventId}/checkpoints`)),
1386
- get(ref(rtdb, `events/${eventId}/checkpointWindows`)),
1387
- ]);
1388
- const participants = participantsSnap.docs.map((item) => ({ uid: item.id, ...item.data() }));
1389
- const checkpoints = (checkpointsSnap.val() ?? {});
1390
- const windows = (windowsSnap.val() ?? {});
1391
- let checkpointIds = Object.entries(windows)
1392
- .filter(([, window]) => {
1393
- if (window.sessionId)
1394
- return window.sessionId === sessionId;
1395
- return typeof window.startedAt === "number" && startMs != null && window.startedAt >= startMs && window.startedAt <= endMs;
1396
- })
1397
- .map(([id]) => id);
1398
- if (checkpointIds.length === 0 && startMs != null) {
1399
- checkpointIds = Object.entries(checkpoints)
1400
- .filter(([, responses]) => checkpointResponseInWindow(responses, startMs, endMs))
1401
- .map(([id]) => id);
1402
- }
1403
- checkpointIds = [...new Set(checkpointIds)].sort();
1404
- const totalCheckpoints = checkpointIds.length;
1405
- const rows = participants.map((participant) => {
1406
- const uid = safeString(participant.uid);
1407
- const watchSec = Math.max(0, (finalEngagement[uid] ?? 0) - (baselineEngagement[uid] ?? 0));
1408
- const watchRatio = durationSec > 0 ? watchSec / durationSec : 0;
1409
- let confirmedCount = 0;
1410
- let reactionSum = 0;
1411
- let reactionCount = 0;
1412
- for (const checkpointId of checkpointIds) {
1413
- const response = checkpoints[checkpointId]?.[uid];
1414
- if (response?.status === "confirmed" || response?.status === "auto_confirmed")
1415
- confirmedCount++;
1416
- if (typeof response?.reactionMs === "number" && response.reactionMs > 0) {
1417
- reactionSum += response.reactionMs;
1418
- reactionCount++;
1419
- }
1420
- }
1421
- const checkpointRatio = totalCheckpoints > 0 ? confirmedCount / totalCheckpoints : 1;
1422
- const avgReactionSec = reactionCount > 0 ? Math.round((reactionSum / reactionCount) / 100) / 10 : null;
1423
- const status = watchSec === 0
1424
- ? "noShow"
1425
- : watchRatio >= criteria.minWatchTimeRatio && checkpointRatio >= criteria.minCheckpointRatio
1426
- ? "passed"
1427
- : "failed";
1428
- return {
1429
- uid,
1430
- email: safeString(participant.email),
1431
- fullName: safeString(participant.fullName),
1432
- ...(safeString(participant.position) && { position: safeString(participant.position) }),
1433
- ...(safeString(participant.org) && { org: safeString(participant.org) }),
1434
- watchSec,
1435
- watchRatio,
1436
- confirmedCount,
1437
- totalCheckpoints,
1438
- checkpointRatio,
1439
- avgReactionSec,
1440
- status,
1441
- };
1442
- });
1443
- return {
1444
- generatedAtMs: endMs,
1445
- source: "sessionSnapshotV1",
1446
- criteria,
1447
- sessionDurationSec: durationSec,
1448
- checkpointIds,
1449
- summary: {
1450
- passed: rows.filter((row) => row.status === "passed").length,
1451
- failed: rows.filter((row) => row.status === "failed").length,
1452
- noShow: rows.filter((row) => row.status === "noShow").length,
1453
- total: rows.length,
1454
- },
1455
- rows,
1456
- };
1457
- }
1458
1121
  function compactAnalyticsSession(raw, options) {
1459
1122
  const session = serialize(raw);
1460
1123
  const timeline = Array.isArray(session.timeline) ? session.timeline : null;
@@ -1481,15 +1144,7 @@ function compactAnalyticsSession(raw, options) {
1481
1144
  session.finalSnapshotOmitted = true;
1482
1145
  }
1483
1146
  }
1484
- const nmo = session.nmoEvaluation;
1485
- if (!options.includeNmoRows && nmo && typeof nmo === "object" && !Array.isArray(nmo)) {
1486
- const nmoRecord = nmo;
1487
- if (Array.isArray(nmoRecord.rows)) {
1488
- nmoRecord.rowsCount = nmoRecord.rows.length;
1489
- delete nmoRecord.rows;
1490
- nmoRecord.rowsOmitted = true;
1491
- }
1492
- }
1147
+ delete session[["n", "moEvaluation"].join("")];
1493
1148
  return session;
1494
1149
  }
1495
1150
  function numberMetric(value) {
@@ -1546,14 +1201,6 @@ function sessionReportMarkdown({ eventId, event, sessionId, session, }) {
1546
1201
  markdownMetric("CTA clicks", session.ctaClicks),
1547
1202
  markdownMetric("Cards clicks", session.cardsClicks),
1548
1203
  ];
1549
- const nmo = session.nmoEvaluation;
1550
- if (nmo && typeof nmo === "object" && !Array.isArray(nmo)) {
1551
- const summary = nmo.summary;
1552
- if (summary && typeof summary === "object" && !Array.isArray(summary)) {
1553
- const s = summary;
1554
- lines.push("", "## NMO", markdownMetric("Passed", s.passed), markdownMetric("Failed", s.failed), markdownMetric("No-show", s.noShow), markdownMetric("Total", s.total));
1555
- }
1556
- }
1557
1204
  return lines.join("\n");
1558
1205
  }
1559
1206
  const MCP_AUDIT_TARGET_KEYS = new Set([
@@ -1940,7 +1587,7 @@ export function registerTools(server) {
1940
1587
  }, async ({ eventId }) => {
1941
1588
  const { uid } = await authed(["events:read"]);
1942
1589
  const data = await readEvent(eventId, uid, "read");
1943
- return textResult({ id: eventId, ...serialize(data), ...eventLinks(eventId, data) });
1590
+ return textResult({ id: eventId, ...serialize(data), ...eventLinks(eventId) });
1944
1591
  });
1945
1592
  server.registerTool("oko_validate_event_setup", {
1946
1593
  title: "Validate event setup",
@@ -1950,7 +1597,7 @@ export function registerTools(server) {
1950
1597
  returns: "ok flag, score, errors, warnings, suggestions, next actions, and useful links.",
1951
1598
  examples: [
1952
1599
  "Use after creating/configuring a webinar and before telling the organizer it is ready.",
1953
- "Use before live-session prep to catch missing video, pending image variants, invalid form bindings, or NMO participant gaps.",
1600
+ "Use before live-session prep to catch missing video, pending image variants, and invalid form bindings.",
1954
1601
  ],
1955
1602
  notes: ["This tool does not modify the event and does not start/end analytics sessions."],
1956
1603
  }),
@@ -2019,7 +1666,7 @@ export function registerTools(server) {
2019
1666
  summary: "Update safe top-level event/setup metadata fields.",
2020
1667
  args: ["eventId plus any subset of title, videoUrl, eventDate, speakerName, description, pinned."],
2021
1668
  returns: "event id and the list of Firestore fields updated.",
2022
- examples: ["Use for title/date/speaker/video edits; use oko_configure_blocks for block/form/NMO setup."],
1669
+ examples: ["Use for title/date/speaker/video edits; use oko_configure_blocks for blocks and forms."],
2023
1670
  }),
2024
1671
  inputSchema: {
2025
1672
  eventId: eventIdField,
@@ -2068,16 +1715,15 @@ export function registerTools(server) {
2068
1715
  server.registerTool("oko_configure_blocks", {
2069
1716
  title: "Configure event blocks",
2070
1717
  description: toolDescription({
2071
- summary: "Configure supported event setup blocks, forms, Telegram channel ids, and NMO mode.",
1718
+ summary: "Configure supported event setup blocks, forms, and Telegram channel ids.",
2072
1719
  args: [
2073
1720
  "eventId: event to edit.",
2074
1721
  "chat/promoBanner/primaryCta/videoPlaceholder: optional block patches.",
2075
1722
  "forms: replaces configured lead forms when provided.",
2076
1723
  "telegramChannelIds: replaces configured Telegram channels when provided.",
2077
- "nmo: optional NMO mode and criteria patch.",
2078
1724
  ],
2079
1725
  returns: "event id and the list of Firestore fields updated.",
2080
- examples: ["Use after oko_create_webinar to set chat visibility, CTA/form binding, or NMO settings."],
1726
+ examples: ["Use after oko_create_webinar to set chat visibility, CTA/form binding, and other audience blocks."],
2081
1727
  }),
2082
1728
  inputSchema: {
2083
1729
  eventId: eventIdField,
@@ -2087,21 +1733,11 @@ export function registerTools(server) {
2087
1733
  videoPlaceholder: z.object({ enabled: z.boolean().optional(), mode: z.enum(["glass", "image"]).optional(), title: z.string().nullable().optional(), subtitle: z.string().nullable().optional(), showTimer: z.boolean().optional() }).optional(),
2088
1734
  forms: z.array(formSchema).max(20).optional(),
2089
1735
  telegramChannelIds: z.array(z.number().int()).max(20).optional(),
2090
- nmo: z.object({
2091
- enabled: z.boolean().optional(),
2092
- minWatchTimeRatio: z.number().min(0).max(1).optional(),
2093
- minCheckpointRatio: z.number().min(0).max(1).optional(),
2094
- extraPosition: z.boolean().optional(),
2095
- extraOrg: z.boolean().optional(),
2096
- }).optional(),
2097
1736
  },
2098
1737
  outputSchema: updateOutputSchema,
2099
1738
  annotations: writeTool,
2100
1739
  }, async (args) => {
2101
- const requiredScopes = ["setup:write"];
2102
- if (args.nmo !== undefined)
2103
- requiredScopes.push("nmo:write");
2104
- const { uid } = await authed(requiredScopes);
1740
+ const { uid } = await authed(["setup:write"]);
2105
1741
  await readEvent(args.eventId, uid, "edit");
2106
1742
  const patch = {};
2107
1743
  if (args.chat?.enabled !== undefined)
@@ -2163,16 +1799,6 @@ export function registerTools(server) {
2163
1799
  patch["telegram.channelIds"] = args.telegramChannelIds;
2164
1800
  patch["telegram.channelId"] = args.telegramChannelIds[0] ?? null;
2165
1801
  }
2166
- if (args.nmo?.enabled !== undefined)
2167
- patch.nmoMode = args.nmo.enabled;
2168
- if (args.nmo?.minWatchTimeRatio !== undefined)
2169
- patch["nmoCriteria.minWatchTimeRatio"] = args.nmo.minWatchTimeRatio;
2170
- if (args.nmo?.minCheckpointRatio !== undefined)
2171
- patch["nmoCriteria.minCheckpointRatio"] = args.nmo.minCheckpointRatio;
2172
- if (args.nmo?.extraPosition !== undefined)
2173
- patch["nmoExtraFields.position"] = args.nmo.extraPosition;
2174
- if (args.nmo?.extraOrg !== undefined)
2175
- patch["nmoExtraFields.org"] = args.nmo.extraOrg;
2176
1802
  await updateDoc(doc(firestore, "events", args.eventId), patch);
2177
1803
  return textResult({ id: args.eventId, updated: Object.keys(patch) });
2178
1804
  });
@@ -2544,7 +2170,7 @@ export function registerTools(server) {
2544
2170
  args: ["eventId: source event.", "targetWorkspaceId: optional target workspace; omitted means source workspace or personal workspace."],
2545
2171
  returns: "new event id, public/setup/analytics URLs, source event id, and workspaceId.",
2546
2172
  examples: ["Use to create a reusable copy of a configured webinar before editing the new copy."],
2547
- notes: ["Copies title, video, meta, blocks, forms, telegram config, NMO settings; does not copy analytics sessions/leads/participants."],
2173
+ notes: ["Copies title, video, meta, blocks, forms, and Telegram config; does not copy analytics sessions or leads."],
2548
2174
  }),
2549
2175
  inputSchema: {
2550
2176
  eventId: eventIdField,
@@ -2555,8 +2181,6 @@ export function registerTools(server) {
2555
2181
  }, async ({ eventId, targetWorkspaceId }) => {
2556
2182
  const { uid } = await authed(["events:write"]);
2557
2183
  const source = await readEvent(eventId, uid, "read");
2558
- if (source.nmoMode === true)
2559
- await authed(["nmo:write"]);
2560
2184
  const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
2561
2185
  await assertCanCreateInWorkspace(uid, workspaceId);
2562
2186
  const newRef = await addEventWithRtdbMirror(uid, workspaceId, {
@@ -2571,9 +2195,6 @@ export function registerTools(server) {
2571
2195
  blocks: source.blocks ?? {},
2572
2196
  ...(Array.isArray(source.forms) ? { forms: source.forms } : {}),
2573
2197
  ...(source.telegram && typeof source.telegram === "object" ? { telegram: source.telegram } : {}),
2574
- ...(source.nmoMode === true ? { nmoMode: true } : {}),
2575
- ...(source.nmoCriteria && typeof source.nmoCriteria === "object" ? { nmoCriteria: source.nmoCriteria } : {}),
2576
- ...(source.nmoExtraFields && typeof source.nmoExtraFields === "object" ? { nmoExtraFields: source.nmoExtraFields } : {}),
2577
2198
  });
2578
2199
  return textResult({ id: newRef.id, sourceEventId: eventId, workspaceId, ...eventLinks(newRef.id) });
2579
2200
  });
@@ -2691,82 +2312,9 @@ export function registerTools(server) {
2691
2312
  requireConfirm(confirm, "oko_start_session", { eventId, title: event.title ?? null });
2692
2313
  if (event.activeAnalyticsSessionId)
2693
2314
  throw new Error("An analytics session is already active.");
2694
- if (event.nmoMode !== true) {
2695
- const start = httpsCallable(functions, "startAnalyticsSessionV3");
2696
- const result = await start({ eventId });
2697
- return textResult({ started: true, ...result.data });
2698
- }
2699
- let sessionId = null;
2700
- try {
2701
- const agg = await readAgg(eventId);
2702
- const sessionRef = await addDoc(collection(firestore, "events", eventId, "analyticsSessions"), {
2703
- startedAt: serverTimestamp(),
2704
- endedAt: null,
2705
- });
2706
- sessionId = sessionRef.id;
2707
- const updates = {
2708
- "ctrl/activeSessionId": sessionId,
2709
- [`sessions/${sessionId}/startedAt`]: Date.now(),
2710
- [`sessions/${sessionId}/baseline/chatMessages`]: agg.chatMessages,
2711
- [`sessions/${sessionId}/baseline/bannerClicks`]: agg.bannerClicks,
2712
- [`sessions/${sessionId}/baseline/bannerImpressions`]: agg.bannerImpressions,
2713
- [`sessions/${sessionId}/baseline/ctaClicks`]: agg.ctaClicks,
2714
- [`sessions/${sessionId}/baseline/cardsClicks`]: agg.cardsClicks,
2715
- [`sessions/${sessionId}/baseline/viewCount`]: agg.viewCount,
2716
- [`sessions/${sessionId}/baseline/viewSumSec`]: agg.viewSumSec,
2717
- [`sessions/${sessionId}/baseline/online`]: agg.online,
2718
- [`sessions/${sessionId}/baseline/uniqueVisitors`]: agg.uniqueVisitors,
2719
- [`sessions/${sessionId}/peak`]: agg.online,
2720
- };
2721
- for (const [bucket, count] of Object.entries(agg.viewHist)) {
2722
- updates[`sessions/${sessionId}/baseline/viewHist/${bucket}`] = count;
2723
- }
2724
- for (const [cityKey, count] of Object.entries(agg.geo ?? {})) {
2725
- updates[`sessions/${sessionId}/baseline/geo/${cityKey}`] = count;
2726
- }
2727
- for (const [cardId, clicks] of Object.entries(agg.cardsById ?? {})) {
2728
- updates[`sessions/${sessionId}/baseline/cardsById/${cardId}`] = clicks;
2729
- }
2730
- for (const [ctaId, clicks] of Object.entries(agg.ctaById ?? {})) {
2731
- updates[`sessions/${sessionId}/baseline/ctaById/${ctaId}`] = clicks;
2732
- }
2733
- await update(ref(rtdb, `events/${eventId}`), updates);
2734
- try {
2735
- const engagementSnap = await get(ref(rtdb, `events/${eventId}/engagement`));
2736
- const engagementNow = numberRecord(engagementSnap.val());
2737
- const engagementUpdates = {};
2738
- for (const [viewerId, seconds] of Object.entries(engagementNow)) {
2739
- engagementUpdates[`sessions/${sessionId}/baseline/engagement/${viewerId}`] = seconds;
2740
- }
2741
- if (Object.keys(engagementUpdates).length > 0) {
2742
- await update(ref(rtdb, `events/${eventId}`), engagementUpdates);
2743
- }
2744
- }
2745
- catch {
2746
- // Non-fatal: dashboard behaves the same; avg/median are skipped if baseline is unavailable.
2747
- }
2748
- await updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: sessionId });
2749
- try {
2750
- await set(ref(rtdb, `activeSessions/${sessionId}`), { eventId, startedAt: Date.now() });
2751
- }
2752
- catch {
2753
- // Non-fatal: client-side sampler can still write timeline, and CF stale cleanup is best-effort.
2754
- }
2755
- return textResult({ eventId, sessionId, started: true });
2756
- }
2757
- catch (err) {
2758
- if (sessionId) {
2759
- await Promise.allSettled([
2760
- remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
2761
- remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)),
2762
- remove(ref(rtdb, `activeSessions/${sessionId}`)),
2763
- updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }),
2764
- deleteDoc(doc(firestore, "events", eventId, "analyticsSessions", sessionId)),
2765
- ]);
2766
- }
2767
- const msg = err instanceof Error ? err.message : String(err);
2768
- throw new Error(`Start failed: ${msg}`);
2769
- }
2315
+ const start = httpsCallable(functions, "startAnalyticsSessionV3");
2316
+ const result = await start({ eventId });
2317
+ return textResult({ started: true, ...result.data });
2770
2318
  });
2771
2319
  server.registerTool("oko_end_session", {
2772
2320
  title: "End analytics session",
@@ -2787,175 +2335,9 @@ export function registerTools(server) {
2787
2335
  if (!sessionId)
2788
2336
  throw new Error("No active analytics session.");
2789
2337
  requireConfirm(confirm, "oko_end_session", { eventId, sessionId });
2790
- if (event.nmoMode !== true) {
2791
- const end = httpsCallable(functions, "endAnalyticsSessionV3");
2792
- const result = await end({ eventId, sessionId });
2793
- return textResult(result.data);
2794
- }
2795
- const sessionDocRef = doc(firestore, "events", eventId, "analyticsSessions", sessionId);
2796
- const [finalAgg, baselineSnap, sessionSnap, firestoreSessionSnap] = await Promise.all([
2797
- readAgg(eventId),
2798
- get(ref(rtdb, `events/${eventId}/sessions/${sessionId}/baseline`)),
2799
- get(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)),
2800
- getDoc(sessionDocRef),
2801
- ]);
2802
- if (!baselineSnap.exists()) {
2803
- throw new Error("Session baseline is missing; clear the stuck session and start a fresh session.");
2804
- }
2805
- const baseRaw = (baselineSnap.val() ?? {});
2806
- const baseline = {
2807
- chatMessages: Number(baseRaw.chatMessages ?? 0),
2808
- bannerClicks: Number(baseRaw.bannerClicks ?? 0),
2809
- bannerImpressions: Number(baseRaw.bannerImpressions ?? 0),
2810
- ctaClicks: Number(baseRaw.ctaClicks ?? 0),
2811
- cardsClicks: Number(baseRaw.cardsClicks ?? 0),
2812
- viewCount: Number(baseRaw.viewCount ?? 0),
2813
- viewSumSec: Number(baseRaw.viewSumSec ?? 0),
2814
- viewHist: numberRecord(baseRaw.viewHist),
2815
- online: Number(baseRaw.online ?? 0),
2816
- uniqueVisitors: Number(baseRaw.uniqueVisitors ?? 0),
2817
- geo: numberRecord(baseRaw.geo),
2818
- cardsById: numberRecord(baseRaw.cardsById),
2819
- ctaById: numberRecord(baseRaw.ctaById),
2820
- };
2821
- const session = (sessionSnap.val() ?? {});
2822
- const timelineRaw = (session.timeline ?? {});
2823
- const rawSessionTimeline = Object.values(timelineRaw).filter(isTimelineBucket).sort((a, b) => a.t - b.t);
2824
- const sessionTimeline = downsampleTimeline(rawSessionTimeline);
2825
- const startMs = toMillis(firestoreSessionSnap.data()?.startedAt) ?? toMillis(session.startedAt);
2826
- const endMs = Date.now();
2827
- const durationSec = startMs != null ? Math.max(0, Math.round((endMs - startMs) / 1000)) : 0;
2828
- const viewCount = Math.max(0, finalAgg.viewCount - baseline.viewCount);
2829
- const viewSumSec = Math.max(0, finalAgg.viewSumSec - baseline.viewSumSec);
2830
- const bannerUniqueImpressions = Number(session.bannerUniqueImpressions ?? 0);
2831
- const bannerUniqueClicks = Number(session.bannerUniqueClicks ?? 0);
2832
- const bannerCtrDenominator = Math.max(bannerUniqueImpressions, bannerUniqueClicks);
2833
- const bannerCtr = bannerCtrDenominator > 0
2834
- ? Math.round((bannerUniqueClicks / bannerCtrDenominator) * 1000) / 10
2835
- : null;
2836
- let avgTimeSec = null;
2837
- let medianTimeSec = null;
2838
- let finalEngagement = {};
2839
- let baselineEngagement = {};
2840
- let engagementLoaded = false;
2841
- try {
2842
- const finalEngagementSnap = await get(ref(rtdb, `events/${eventId}/engagement`));
2843
- finalEngagement = numberRecord(finalEngagementSnap.val());
2844
- baselineEngagement = numberRecord(baseRaw.engagement);
2845
- engagementLoaded = true;
2846
- const sessionDurations = [];
2847
- for (const [viewerId, finalSeconds] of Object.entries(finalEngagement)) {
2848
- const delta = Math.max(0, finalSeconds - (baselineEngagement[viewerId] ?? 0));
2849
- if (delta >= 30)
2850
- sessionDurations.push(delta);
2851
- }
2852
- if (sessionDurations.length > 0) {
2853
- avgTimeSec = Math.round(sessionDurations.reduce((sum, item) => sum + item, 0) / sessionDurations.length);
2854
- const sorted = [...sessionDurations].sort((a, b) => a - b);
2855
- const mid = Math.floor(sorted.length / 2);
2856
- medianTimeSec = sorted.length % 2 === 0
2857
- ? Math.round(((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2)
2858
- : sorted[mid] ?? null;
2859
- }
2860
- }
2861
- catch {
2862
- // Non-fatal: session can still be saved without avg/median.
2863
- }
2864
- const cardsBreakdown = {};
2865
- for (const cardId of new Set([...Object.keys(finalAgg.cardsById ?? {}), ...Object.keys(baseline.cardsById ?? {})])) {
2866
- const delta = Math.max(0, (finalAgg.cardsById?.[cardId] ?? 0) - (baseline.cardsById?.[cardId] ?? 0));
2867
- if (delta > 0)
2868
- cardsBreakdown[cardId] = delta;
2869
- }
2870
- const ctaBreakdown = {};
2871
- for (const ctaId of new Set([...Object.keys(finalAgg.ctaById ?? {}), ...Object.keys(baseline.ctaById ?? {})])) {
2872
- const delta = Math.max(0, (finalAgg.ctaById?.[ctaId] ?? 0) - (baseline.ctaById?.[ctaId] ?? 0));
2873
- if (delta > 0)
2874
- ctaBreakdown[ctaId] = delta;
2875
- }
2876
- const geoBreakdown = {};
2877
- for (const cityKey of new Set([...Object.keys(finalAgg.geo ?? {}), ...Object.keys(baseline.geo ?? {})])) {
2878
- const delta = Math.max(0, (finalAgg.geo?.[cityKey] ?? 0) - (baseline.geo?.[cityKey] ?? 0));
2879
- if (delta > 0)
2880
- geoBreakdown[cityKey] = delta;
2881
- }
2882
- let nmoEvaluation = null;
2883
- if (engagementLoaded) {
2884
- try {
2885
- nmoEvaluation = await buildNmoEvaluation({
2886
- eventId,
2887
- sessionId,
2888
- startMs,
2889
- endMs,
2890
- durationSec,
2891
- baselineEngagement,
2892
- finalEngagement,
2893
- });
2894
- }
2895
- catch {
2896
- // Non-fatal: dashboard treats NMO snapshot failure as warning-only.
2897
- }
2898
- }
2899
- const finalSnapshot = {
2900
- chatMessages: finalAgg.chatMessages,
2901
- bannerClicks: finalAgg.bannerClicks,
2902
- bannerImpressions: finalAgg.bannerImpressions,
2903
- ctaClicks: finalAgg.ctaClicks,
2904
- cardsClicks: finalAgg.cardsClicks,
2905
- viewCount: finalAgg.viewCount,
2906
- viewSumSec: finalAgg.viewSumSec,
2907
- viewHist: finalAgg.viewHist,
2908
- uniqueVisitors: finalAgg.uniqueVisitors,
2909
- geo: finalAgg.geo ?? {},
2910
- cardsById: finalAgg.cardsById ?? {},
2911
- ctaById: finalAgg.ctaById ?? {},
2912
- };
2913
- const sessionUniqueVisitors = baseline.online + Number(session.uniqueVisitors ?? 0);
2914
- await updateDoc(sessionDocRef, {
2915
- endedAt: serverTimestamp(),
2916
- durationSec,
2917
- peakViewers: Number(session.peak ?? 0),
2918
- uniqueVisitors: sessionUniqueVisitors,
2919
- viewCount,
2920
- viewSumSec,
2921
- avgTimeSec,
2922
- medianTimeSec,
2923
- chatMessages: Math.max(0, finalAgg.chatMessages - baseline.chatMessages),
2924
- bannerImpressions: Math.max(0, finalAgg.bannerImpressions - baseline.bannerImpressions),
2925
- bannerUniqueImpressions,
2926
- bannerClicks: Math.max(0, finalAgg.bannerClicks - baseline.bannerClicks),
2927
- bannerUniqueClicks,
2928
- bannerCtr,
2929
- ctaClicks: Math.max(0, finalAgg.ctaClicks - baseline.ctaClicks),
2930
- ctaUniqueClicks: Number(session.ctaUniqueClicks ?? 0),
2931
- ctaBreakdown: Object.keys(ctaBreakdown).length > 0 ? ctaBreakdown : null,
2932
- cardsClicks: Math.max(0, finalAgg.cardsClicks - baseline.cardsClicks),
2933
- cardsUniqueClicks: Number(session.cardsUniqueClicks ?? 0),
2934
- cardsBreakdown: Object.keys(cardsBreakdown).length > 0 ? cardsBreakdown : null,
2935
- geo: Object.keys(geoBreakdown).length > 0 ? geoBreakdown : null,
2936
- baselineSnapshot: cleanForFirestore(baseline),
2937
- finalSnapshot: cleanForFirestore(finalSnapshot),
2938
- timeline: sessionTimeline,
2939
- timelineOriginalBucketCount: rawSessionTimeline.length,
2940
- ...(nmoEvaluation ? { nmoEvaluation: cleanForFirestore(nmoEvaluation) } : {}),
2941
- });
2942
- await updateDoc(doc(firestore, "events", eventId), {
2943
- activeAnalyticsSessionId: deleteField(),
2944
- "analytics.lastSessionSummary": {
2945
- sessionId,
2946
- startedAt: firestoreSessionSnap.data()?.startedAt ?? null,
2947
- endedAt: Timestamp.now(),
2948
- peakViewers: session.peak ?? null,
2949
- uniqueVisitors: sessionUniqueVisitors,
2950
- viewCount,
2951
- viewSumSec,
2952
- },
2953
- });
2954
- await Promise.allSettled([
2955
- remove(ref(rtdb, `activeSessions/${sessionId}`)),
2956
- remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
2957
- ]);
2958
- return textResult({ eventId, sessionId, ended: true });
2338
+ const end = httpsCallable(functions, "endAnalyticsSessionV3");
2339
+ const result = await end({ eventId, sessionId });
2340
+ return textResult(result.data);
2959
2341
  });
2960
2342
  server.registerTool("oko_fix_orphan_session", {
2961
2343
  title: "Fix orphan analytics session",
@@ -3065,7 +2447,6 @@ export function registerTools(server) {
3065
2447
  "includeTimeline: include recent timeline buckets; default false to keep context small.",
3066
2448
  "timelineLimit: max timeline buckets per session when includeTimeline=true.",
3067
2449
  "includeSnapshots: include baseline/final snapshots; default false.",
3068
- "includeNmoRows: include NMO participant rows; default false.",
3069
2450
  ],
3070
2451
  returns: "sessions plus count, limit, hasMore/has_more, nextCursor/next_cursor. Heavy timeline/snapshot fields are omitted unless requested.",
3071
2452
  examples: ["Use default options for dashboard-like summaries; request includeTimeline only for chart/debug tasks."],
@@ -3077,11 +2458,10 @@ export function registerTools(server) {
3077
2458
  includeTimeline: z.boolean().default(false).describe("Include recent timeline buckets for each session."),
3078
2459
  timelineLimit: z.number().int().min(1).max(500).default(ANALYTICS_TIMELINE_DEFAULT_LIMIT).describe("Max timeline buckets per session when includeTimeline=true."),
3079
2460
  includeSnapshots: z.boolean().default(false).describe("Include baselineSnapshot and finalSnapshot."),
3080
- includeNmoRows: z.boolean().default(false).describe("Include full NMO participant rows when present."),
3081
2461
  },
3082
2462
  outputSchema: analyticsOutputSchema,
3083
2463
  annotations: readOnlyTool,
3084
- }, async ({ eventId, limit: max, cursor, includeTimeline, timelineLimit, includeSnapshots, includeNmoRows }) => {
2464
+ }, async ({ eventId, limit: max, cursor, includeTimeline, timelineLimit, includeSnapshots }) => {
3085
2465
  const { uid } = await authed(["analytics:read"]);
3086
2466
  await readEvent(eventId, uid, "analytics");
3087
2467
  const constraints = [
@@ -3092,9 +2472,9 @@ export function registerTools(server) {
3092
2472
  constraints.splice(1, 0, startAfter(parseTimestampCursor(cursor)));
3093
2473
  const snap = await getDocs(query(collection(firestore, "events", eventId, "analyticsSessions"), ...constraints));
3094
2474
  const { page, hasMore } = pickPage(snap.docs, max);
3095
- const sessions = page.map((item) => compactAnalyticsSession({ id: item.id, ...item.data() }, { includeTimeline, timelineLimit, includeSnapshots, includeNmoRows }));
2475
+ const sessions = page.map((item) => compactAnalyticsSession({ id: item.id, ...item.data() }, { includeTimeline, timelineLimit, includeSnapshots }));
3096
2476
  const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().startedAt) : null;
3097
- return textResult({ sessions, ...cursorMeta(sessions.length, max, hasMore, nextCursor) }, { truncationHint: "Use limit/cursor or keep includeTimeline/includeSnapshots/includeNmoRows disabled." });
2477
+ return textResult({ sessions, ...cursorMeta(sessions.length, max, hasMore, nextCursor) }, { truncationHint: "Use limit/cursor or keep includeTimeline/includeSnapshots disabled." });
3098
2478
  });
3099
2479
  server.registerTool("oko_get_session_report", {
3100
2480
  title: "Get session report",
@@ -3106,7 +2486,6 @@ export function registerTools(server) {
3106
2486
  "includeTimeline: include recent timeline buckets; default false.",
3107
2487
  "timelineLimit: max timeline buckets when includeTimeline=true.",
3108
2488
  "includeSnapshots: include baseline/final snapshots; default false.",
3109
- "includeNmoRows: include full NMO participant rows; default false.",
3110
2489
  ],
3111
2490
  returns: "event/session ids, report URL, markdown summary, event summary, and compact session object.",
3112
2491
  examples: ["Use after a webinar when the user asks for the finished session report without manually opening the report page."],
@@ -3117,11 +2496,10 @@ export function registerTools(server) {
3117
2496
  includeTimeline: z.boolean().default(false).describe("Include recent timeline buckets."),
3118
2497
  timelineLimit: z.number().int().min(1).max(500).default(ANALYTICS_TIMELINE_DEFAULT_LIMIT).describe("Max timeline buckets when includeTimeline=true."),
3119
2498
  includeSnapshots: z.boolean().default(false).describe("Include baselineSnapshot and finalSnapshot."),
3120
- includeNmoRows: z.boolean().default(false).describe("Include full NMO participant rows when present."),
3121
2499
  },
3122
2500
  outputSchema: sessionReportOutputSchema,
3123
2501
  annotations: readOnlyTool,
3124
- }, async ({ eventId, sessionId, includeTimeline, timelineLimit, includeSnapshots, includeNmoRows }) => {
2502
+ }, async ({ eventId, sessionId, includeTimeline, timelineLimit, includeSnapshots }) => {
3125
2503
  const { uid } = await authed(["analytics:read"]);
3126
2504
  const event = await readEvent(eventId, uid, "report");
3127
2505
  let resolvedSessionId = sessionId;
@@ -3140,7 +2518,7 @@ export function registerTools(server) {
3140
2518
  resolvedSessionId = first.id;
3141
2519
  sessionData = first.data();
3142
2520
  }
3143
- const session = compactAnalyticsSession({ id: resolvedSessionId, ...sessionData }, { includeTimeline, timelineLimit, includeSnapshots, includeNmoRows });
2521
+ const session = compactAnalyticsSession({ id: resolvedSessionId, ...sessionData }, { includeTimeline, timelineLimit, includeSnapshots });
3144
2522
  const markdown = sessionReportMarkdown({ eventId, event, sessionId: resolvedSessionId, session });
3145
2523
  return textResult({
3146
2524
  eventId,
@@ -3148,12 +2526,12 @@ export function registerTools(server) {
3148
2526
  event: {
3149
2527
  id: eventId,
3150
2528
  title: safeString(event.title, eventId),
3151
- ...eventLinks(eventId, event),
2529
+ ...eventLinks(eventId),
3152
2530
  },
3153
2531
  reportUrl: sessionReportUrl(eventId, resolvedSessionId),
3154
2532
  markdown,
3155
2533
  session,
3156
- }, { truncationHint: "Keep includeTimeline/includeSnapshots/includeNmoRows disabled or lower timelineLimit." });
2534
+ }, { truncationHint: "Keep includeTimeline/includeSnapshots disabled or lower timelineLimit." });
3157
2535
  });
3158
2536
  server.registerTool("oko_list_recent_chat", {
3159
2537
  title: "List recent chat",
@@ -3344,196 +2722,6 @@ export function registerTools(server) {
3344
2722
  await update(ref(chatRtdb), patch);
3345
2723
  return textResult({ eventId, updated: Object.keys(patch) });
3346
2724
  });
3347
- server.registerTool("oko_launch_checkpoint", {
3348
- title: "Launch NMO checkpoint",
3349
- description: toolDescription({
3350
- summary: "Launch a participant attendance checkpoint for NMO mode.",
3351
- args: ["eventId: target event.", "timeoutSec: checkpoint response window, 5-3600 seconds.", "confirm: must be true."],
3352
- returns: "eventId and checkpoint object with id, startedAt, timeoutSec, and optional sessionId.",
3353
- examples: ["Use during a live NMO webinar only after user confirmation."],
3354
- }),
3355
- inputSchema: { eventId: eventIdField, timeoutSec: z.number().int().min(5).max(3600).default(30).describe("Checkpoint response window in seconds."), confirm: z.boolean().optional().describe("Required true for launch.") },
3356
- outputSchema: basicEventOutputSchema,
3357
- annotations: writeTool,
3358
- }, async ({ eventId, timeoutSec, confirm }) => {
3359
- const { uid } = await authed(["nmo:write", "live:control"]);
3360
- await readEvent(eventId, uid, "session");
3361
- requireConfirm(confirm, "oko_launch_checkpoint", { eventId, timeoutSec });
3362
- const activeSnap = await get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`));
3363
- const checkpoint = { id: randomId(), startedAt: Date.now(), timeoutSec, ...(activeSnap.val() ? { sessionId: activeSnap.val() } : {}) };
3364
- await update(ref(rtdb, `events/${eventId}`), {
3365
- "ctrl/checkpoint": checkpoint,
3366
- [`checkpointWindows/${checkpoint.id}`]: checkpoint,
3367
- });
3368
- return textResult({ eventId, checkpoint });
3369
- });
3370
- server.registerTool("oko_close_checkpoint", {
3371
- title: "Close NMO checkpoint",
3372
- description: toolDescription({
3373
- summary: "Close the active NMO checkpoint for an event.",
3374
- args: ["eventId: target event.", "confirm: must be true."],
3375
- returns: "eventId and checkpointClosed=true.",
3376
- examples: ["Use when the organizer wants to stop showing the active checkpoint."],
3377
- }),
3378
- inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true to close the live checkpoint.") },
3379
- outputSchema: basicEventOutputSchema,
3380
- annotations: idempotentWriteTool,
3381
- }, async ({ eventId, confirm }) => {
3382
- const { uid } = await authed(["nmo:write", "live:control"]);
3383
- await readEvent(eventId, uid, "session");
3384
- requireConfirm(confirm, "oko_close_checkpoint", { eventId });
3385
- await remove(ref(rtdb, `events/${eventId}/ctrl/checkpoint`));
3386
- return textResult({ eventId, checkpointClosed: true });
3387
- });
3388
- server.registerTool("oko_list_participants", {
3389
- title: "List NMO participants",
3390
- description: toolDescription({
3391
- summary: "Read paginated NMO participants for an event.",
3392
- args: [
3393
- "eventId: target event.",
3394
- "csv: return CSV string instead of participants array.",
3395
- "limit: max rows, default 50, max 200.",
3396
- "cursor: participant uid returned as nextCursor by the previous page.",
3397
- ],
3398
- returns: "participants or csv plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
3399
- examples: ["Use limit=50 first; if hasMore is true, call again with cursor=nextCursor."],
3400
- }),
3401
- inputSchema: {
3402
- eventId: eventIdField,
3403
- csv: z.boolean().default(false).describe("Return CSV string instead of JSON rows."),
3404
- limit: limitField(MAX_EXPORT_LIMIT, 50),
3405
- cursor: cursorField,
3406
- },
3407
- outputSchema: participantsOutputSchema,
3408
- annotations: readOnlyTool,
3409
- }, async ({ eventId, csv, limit: max, cursor }) => {
3410
- const { uid } = await authed(["nmo:write"]);
3411
- await readEvent(eventId, uid, "analytics");
3412
- const constraints = cursor
3413
- ? [orderBy(documentId()), startAfter(cursor), limit(max + 1)]
3414
- : [orderBy(documentId()), limit(max + 1)];
3415
- const snap = await getDocs(query(collection(firestore, "eventParticipants", eventId, "items"), ...constraints));
3416
- const page = snap.docs.slice(0, max);
3417
- const rows = page.map((item) => ({ uid: item.id, ...serialize(item.data()) }));
3418
- const hasMore = snap.docs.length > max;
3419
- const nextCursor = hasMore ? page.at(-1)?.id ?? null : null;
3420
- const meta = cursorMeta(rows.length, max, hasMore, nextCursor);
3421
- if (csv) {
3422
- const text = csvRows(["uid", "email", "fullName", "position", "org", "registeredAt"], rows.map((row) => [row.uid, row.email, row.fullName, row.position, row.org, row.registeredAt]));
3423
- return textResult({ csv: text, ...meta });
3424
- }
3425
- return textResult({ participants: rows, ...meta });
3426
- });
3427
- server.registerTool("oko_add_nmo_participant", {
3428
- title: "Add NMO participant",
3429
- description: toolDescription({
3430
- summary: "Add or update one NMO participant for an event through OKO organizer import.",
3431
- args: ["eventId: NMO event.", "email/fullName/position/org: participant fields."],
3432
- returns: "eventId, summary, and per-row import result.",
3433
- examples: ["Use when the organizer dictates one doctor/participant to add before the webinar."],
3434
- notes: ["This does not return a participant login token; use registration/login URLs from oko_get_event for attendee access."],
3435
- }),
3436
- inputSchema: { eventId: eventIdField, participant: nmoParticipantInputSchema },
3437
- outputSchema: nmoImportOutputSchema,
3438
- annotations: writeTool,
3439
- }, async ({ eventId, participant }) => {
3440
- const { uid } = await authed(["nmo:write"]);
3441
- await readEvent(eventId, uid, "edit");
3442
- const importFn = httpsCallable(functions, "importNmoParticipants");
3443
- const result = await importFn({
3444
- eventId,
3445
- participants: [{
3446
- email: normalizeEmail(participant.email),
3447
- fullName: participant.fullName.trim(),
3448
- position: normalizeOptionalText(participant.position, 200),
3449
- org: normalizeOptionalText(participant.org, 200),
3450
- }],
3451
- });
3452
- const data = serialize(result.data);
3453
- const results = Array.isArray(data.results) ? data.results : [];
3454
- const first = results[0];
3455
- if (!first || first.ok !== true) {
3456
- throw new Error(`NMO participant import failed: ${safeString(first?.error, "unknown error")}`);
3457
- }
3458
- return textResult({ eventId, ...data });
3459
- });
3460
- server.registerTool("oko_import_nmo_participants", {
3461
- title: "Import NMO participants",
3462
- description: toolDescription({
3463
- summary: "Bulk import NMO participants from structured rows or CSV text.",
3464
- args: [
3465
- "eventId: NMO event.",
3466
- "participants: optional array of rows { email, fullName, position?, org? }.",
3467
- "csv: optional CSV text with headers email, fullName, position, org.",
3468
- ],
3469
- returns: "eventId, summary, and per-row results with ok/error.",
3470
- examples: ["Use csv when the organizer pastes a participant list from a spreadsheet."],
3471
- notes: ["Provide exactly one of participants or csv. Max 200 rows. The callable returns partial success for bulk imports."],
3472
- }),
3473
- inputSchema: {
3474
- eventId: eventIdField,
3475
- participants: z.array(nmoParticipantInputSchema).max(MAX_NMO_IMPORT_PARTICIPANTS).optional(),
3476
- csv: z.string().max(80_000).optional().describe("CSV with headers email, fullName, position, org."),
3477
- },
3478
- outputSchema: nmoImportOutputSchema,
3479
- annotations: writeTool,
3480
- }, async ({ eventId, participants, csv }) => {
3481
- const { uid } = await authed(["nmo:write"]);
3482
- await readEvent(eventId, uid, "edit");
3483
- if (Boolean(participants) === Boolean(csv))
3484
- throw new Error("Provide exactly one of participants or csv.");
3485
- const rows = participants ?? parseNmoParticipantsCsv(csv ?? "");
3486
- if (rows.length === 0 || rows.length > MAX_NMO_IMPORT_PARTICIPANTS)
3487
- throw new Error(`Import supports 1-${MAX_NMO_IMPORT_PARTICIPANTS} participants.`);
3488
- const importFn = httpsCallable(functions, "importNmoParticipants");
3489
- const result = await importFn({
3490
- eventId,
3491
- participants: rows.map((row) => ({
3492
- email: normalizeEmail(row.email),
3493
- fullName: row.fullName.trim(),
3494
- position: normalizeOptionalText(row.position, 200),
3495
- org: normalizeOptionalText(row.org, 200),
3496
- })),
3497
- });
3498
- return textResult({ eventId, ...serialize(result.data) });
3499
- });
3500
- server.registerTool("oko_remove_nmo_participant", {
3501
- title: "Remove NMO participant",
3502
- description: toolDescription({
3503
- summary: "Remove an NMO participant and immediately revoke event access.",
3504
- args: ["eventId: NMO event.", "uid or email: participant to remove.", "confirm: must be true."],
3505
- returns: "eventId, participant uid/email, and removed=true.",
3506
- examples: ["Use after listing participants and confirming the exact participant."],
3507
- notes: ["Order is intentional: arrayRemove from events.participantUids first, then delete participant doc."],
3508
- }),
3509
- inputSchema: {
3510
- eventId: eventIdField,
3511
- uid: z.string().min(1).optional().describe("Participant Firebase uid."),
3512
- email: emailSchema.optional().describe("Participant email; used to resolve uid if uid is omitted."),
3513
- confirm: z.boolean().optional().describe("Required true for participant removal."),
3514
- },
3515
- outputSchema: z.object({ eventId: z.string(), uid: z.string(), removed: z.boolean() }).passthrough(),
3516
- annotations: destructiveTool,
3517
- }, async ({ eventId, uid: participantUid, email, confirm }) => {
3518
- const { uid } = await authed(["nmo:write"]);
3519
- await readEvent(eventId, uid, "edit");
3520
- let targetUid = participantUid;
3521
- const normalizedEmail = email ? normalizeEmail(email) : null;
3522
- if (!targetUid && normalizedEmail) {
3523
- const snap = await getDocs(query(collection(firestore, "eventParticipants", eventId, "items"), where("email", "==", normalizedEmail), limit(2)));
3524
- if (snap.empty)
3525
- throw new Error(`NMO participant with email ${normalizedEmail} not found.`);
3526
- if (snap.docs.length > 1)
3527
- throw new Error(`Multiple NMO participants found for email ${normalizedEmail}; use uid.`);
3528
- targetUid = snap.docs[0].id;
3529
- }
3530
- if (!targetUid)
3531
- throw new Error("Provide uid or email.");
3532
- requireConfirm(confirm, "oko_remove_nmo_participant", { eventId, uid: targetUid, email: normalizedEmail });
3533
- await updateDoc(doc(firestore, "events", eventId), { participantUids: arrayRemove(targetUid) });
3534
- await deleteDoc(doc(firestore, "eventParticipants", eventId, "items", targetUid));
3535
- return textResult({ eventId, uid: targetUid, email: normalizedEmail, removed: true });
3536
- });
3537
2725
  server.registerTool("oko_list_leads", {
3538
2726
  title: "List form leads",
3539
2727
  description: toolDescription({