@okostream/mcp 0.16.0 → 0.17.0

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.
Files changed (2) hide show
  1. package/dist/server.js +397 -49
  2. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -116311,7 +116311,7 @@ function sanitizeScopes(scopes) {
116311
116311
  }
116312
116312
 
116313
116313
  // build/version.js
116314
- var OKO_MCP_VERSION = "0.16.0";
116314
+ var OKO_MCP_VERSION = "0.17.0";
116315
116315
 
116316
116316
  // build/auth.js
116317
116317
  var state = null;
@@ -119025,6 +119025,10 @@ function buildCreateWebinarDocument(input) {
119025
119025
  createdAt: input.createdAt,
119026
119026
  title: input.title.trim(),
119027
119027
  pinned: false,
119028
+ // The auto start is ON in every new page (release "sessions", `docs/planning/sessions-and-show-billing.md` §9.5):
119029
+ // the session starts by itself at the start of its plan — the event time until Setup sets another. The flag is a
119030
+ // page setting (organizer-writable), not server state; the canary writes this exact shape through the rules.
119031
+ analyticsSessionMode: "schedule",
119028
119032
  video: input.video,
119029
119033
  meta: {
119030
119034
  eventDate: input.eventDate,
@@ -119045,6 +119049,12 @@ function buildCreateWebinarDocument(input) {
119045
119049
  }
119046
119050
  };
119047
119051
  }
119052
+ function withoutSessionPlan(meta2) {
119053
+ if (!meta2 || typeof meta2 !== "object" || Array.isArray(meta2))
119054
+ return void 0;
119055
+ const { sessionStartAt: _start, sessionEndAt: _end, ...rest } = meta2;
119056
+ return rest;
119057
+ }
119048
119058
  var DUPLICATE_TITLE_SUFFIX = " (copy)";
119049
119059
  var MAX_EVENT_TITLE_LENGTH = 300;
119050
119060
  function duplicateEventTitle(sourceTitle) {
@@ -119060,8 +119070,13 @@ function buildDuplicateEventDocument(input) {
119060
119070
  title: duplicateEventTitle(safeString(source.title, "Untitled event")),
119061
119071
  duplicatedFrom: input.sourceEventId,
119062
119072
  pinned: false,
119073
+ // A copy is a NEW page: the auto start is ON like everywhere else (§9.5), whatever the source's mode was.
119074
+ analyticsSessionMode: "schedule",
119063
119075
  video: source.video ?? { originalUrl: null, embedUrl: null },
119064
- meta: source.meta ?? { eventDate: null, speakerName: null, description: null },
119076
+ // 🔴 The session plan (`meta.sessionStartAt`/`sessionEndAt`) is NOT copied (R6): a copy is a second show with another
119077
+ // date, and a plan set by hand for the old date would read as «the session time does not match the show» on a page
119078
+ // that never had one. The copy's plan is its event time until Setup sets another.
119079
+ meta: withoutSessionPlan(source.meta) ?? { eventDate: null, speakerName: null, description: null },
119065
119080
  blocks: source.blocks ?? {},
119066
119081
  ...Array.isArray(source.forms) ? { forms: source.forms } : {},
119067
119082
  // 🔴 The copy does NOT inherit server-owned Telegram markers.
@@ -119105,6 +119120,124 @@ function eventRuntimeHint(runtime) {
119105
119120
  ];
119106
119121
  }
119107
119122
 
119123
+ // build/tools/scheduleWindow.js
119124
+ var SCHEDULED_SESSION_DEFAULT_MINUTES = 120;
119125
+ var SCHEDULED_SESSION_MIN_MINUTES = 15;
119126
+ var SCHEDULED_SESSION_MAX_MINUTES = 1440;
119127
+ function scheduledDurationMs(raw) {
119128
+ const minutes = typeof raw === "number" && Number.isFinite(raw) ? Math.min(SCHEDULED_SESSION_MAX_MINUTES, Math.max(SCHEDULED_SESSION_MIN_MINUTES, Math.round(raw))) : SCHEDULED_SESSION_DEFAULT_MINUTES;
119129
+ return minutes * 60 * 1e3;
119130
+ }
119131
+ function scheduleWindowPassed(sessionMode, eventDateMs, rawDurationMinutes, nowMs) {
119132
+ if (sessionMode !== "schedule")
119133
+ return false;
119134
+ if (eventDateMs === null)
119135
+ return false;
119136
+ return nowMs >= eventDateMs + scheduledDurationMs(rawDurationMinutes);
119137
+ }
119138
+
119139
+ // build/tools/sessionPlan.js
119140
+ function isRecord(value) {
119141
+ return typeof value === "object" && value !== null && !Array.isArray(value);
119142
+ }
119143
+ function timestampMillis(value) {
119144
+ if (value instanceof Date)
119145
+ return value.getTime();
119146
+ if (value && typeof value === "object" && typeof value.toMillis === "function") {
119147
+ return value.toMillis();
119148
+ }
119149
+ return null;
119150
+ }
119151
+ function readSessionPlan(event, durationMsOf) {
119152
+ const meta2 = isRecord(event.meta) ? event.meta : {};
119153
+ const startMs = timestampMillis(meta2.sessionStartAt);
119154
+ const endMs = timestampMillis(meta2.sessionEndAt);
119155
+ if (startMs !== null && endMs !== null && endMs > startMs)
119156
+ return { startMs, endMs, source: "meta" };
119157
+ const eventDateMs = timestampMillis(meta2.eventDate);
119158
+ if (eventDateMs === null)
119159
+ return null;
119160
+ return { startMs: eventDateMs, endMs: eventDateMs + durationMsOf(meta2.durationMinutes), source: "event" };
119161
+ }
119162
+ function sessionPlanOf(event) {
119163
+ return readSessionPlan(event, scheduledDurationMs);
119164
+ }
119165
+ var iso = (ms) => new Date(ms).toISOString();
119166
+ function sessionPlanWarnings(event) {
119167
+ const plan = sessionPlanOf(event);
119168
+ if (!plan || plan.source !== "meta")
119169
+ return [];
119170
+ const meta2 = isRecord(event.meta) ? event.meta : {};
119171
+ const eventDateMs = timestampMillis(meta2.eventDate);
119172
+ if (eventDateMs === null)
119173
+ return [];
119174
+ const eventEndMs = eventDateMs + scheduledDurationMs(meta2.durationMinutes);
119175
+ if (plan.startMs === eventDateMs && plan.endMs === eventEndMs)
119176
+ return [];
119177
+ return [{
119178
+ code: "session_plan_differs_from_event",
119179
+ message: `The session plan (${iso(plan.startMs)} to ${iso(plan.endMs)}) is not the event time (${iso(eventDateMs)} to ${iso(eventEndMs)}). The automatic start and the automatic stop follow the PLAN. If the event moved and the session should follow it, clear the plan with oko_update_event sessionStartAt: null and sessionEndAt: null (the plan then equals the event time).`
119180
+ }];
119181
+ }
119182
+ function sessionOutlookWarnings(outlook) {
119183
+ if (outlook.moneyMode === "off")
119184
+ return [];
119185
+ const warnings = [];
119186
+ if (!outlook.entitlement.ok) {
119187
+ warnings.push({
119188
+ code: "session_tier_has_no_sessions",
119189
+ message: outlook.entitlement.reason === "tier" ? `The plan that pays for this page (${outlook.entitlement.currentTier ?? "unknown"}) has no analytics sessions: they come with ${outlook.entitlement.requiredTier ?? "pro"} and above. Start is refused, and the automatic start is skipped silently.` : "The subscription that pays for this page is not active, so Start is refused and the automatic start will not happen. The subscription owner can renew it in the OKO cabinet."
119190
+ });
119191
+ return warnings;
119192
+ }
119193
+ if (outlook.lumens.enoughForPlan === false && outlook.lumens.neededForPlan !== null) {
119194
+ warnings.push({
119195
+ code: "session_lumens_not_enough_for_plan",
119196
+ message: `A session that runs to the end of its plan costs about ${outlook.lumens.neededForPlan} lumens (1 per minute, taken when it ends). The wallet that pays for this workspace holds less: the session would be stopped when the lumens run out. Its owner can top it up. Lumens are checked at Start (a wallet at zero refuses it) and every minute while it runs.`
119197
+ });
119198
+ }
119199
+ return warnings;
119200
+ }
119201
+ function sessionExplanation(args) {
119202
+ const lines = [];
119203
+ if (args.billingMode === "metered") {
119204
+ lines.push("A session costs 1 lumen per minute, taken from the wallet that pays when the session ends (minutes are rounded; under 30 seconds is free). There is no debt: if the wallet runs dry the session is stopped and the rest is forgiven.");
119205
+ } else if (args.billingMode === "free") {
119206
+ lines.push("Session minutes are not being charged for this page right now.");
119207
+ }
119208
+ if (typeof args.plannedEndAt === "number") {
119209
+ lines.push(`The session ends by itself at ${iso(args.plannedEndAt)} (its plan). Move the end with oko_extend_session, or end it earlier with oko_end_session.`);
119210
+ }
119211
+ if (args.clamped) {
119212
+ lines.push("The requested end was moved back to the end of the page's show window: a session cannot run past it.");
119213
+ }
119214
+ return lines;
119215
+ }
119216
+
119217
+ // build/tools/sessionOutlook.js
119218
+ async function fetchSessionOutlook(eventId) {
119219
+ try {
119220
+ const preview = httpsCallable(functions, "previewAnalyticsSession");
119221
+ const result = await preview({ eventId });
119222
+ return { outlook: result.data };
119223
+ } catch (error3) {
119224
+ return { error: error3 instanceof Error ? error3.message : String(error3) };
119225
+ }
119226
+ }
119227
+ async function collectSessionWarnings(eventId, event) {
119228
+ const warnings = sessionPlanWarnings(event);
119229
+ const fetched = await fetchSessionOutlook(eventId);
119230
+ if ("outlook" in fetched) {
119231
+ warnings.push(...sessionOutlookWarnings(fetched.outlook));
119232
+ } else {
119233
+ warnings.push({
119234
+ code: "session_outlook_unavailable",
119235
+ message: `Could not check the payer's plan and lumens for this page's session (${fetched.error}). The write itself went through.`
119236
+ });
119237
+ }
119238
+ return warnings;
119239
+ }
119240
+
119108
119241
  // build/tools/liveLayer.js
119109
119242
  var LIVE_FIELDS_HINT = "blocks.promoBanner.visibility, blocks.primaryCta.visibility, blocks.cards.visibility and blocks.video.placeholder.enabled are SETUP values. What viewers see right now is `live.<block>.visibleNow` \u2014 `decidedBy` says whether a live override (oko_set_block_visibility) or the setup default decided it. For the chat, `live.chat.accepting` is whether viewers can send a message right now; `blocks.chat.intake` is only the starting state. The live badge and the active analytics session are not resolved here: read oko_get_live_state (ctrl.liveBadge, ctrl.activeSessionId).";
119110
119243
  var liveUnreadableHint = (reason) => `blocks.*.visibility and .enabled above are SETUP values, not what viewers see now \u2014 and the live overrides could not be read (${reason}; a key without analytics:read or live:control is always refused here). Do not report a block as shown or hidden from these fields; read oko_get_live_state with a key that holds the scope.`;
@@ -119166,23 +119299,15 @@ async function readLiveLayer(eventId, data, nowMs) {
119166
119299
  return { live, hints };
119167
119300
  }
119168
119301
 
119169
- // build/tools/scheduleWindow.js
119170
- var SCHEDULED_SESSION_DEFAULT_MINUTES = 120;
119171
- var SCHEDULED_SESSION_MIN_MINUTES = 15;
119172
- var SCHEDULED_SESSION_MAX_MINUTES = 1440;
119173
- function scheduledDurationMs(raw) {
119174
- const minutes = typeof raw === "number" && Number.isFinite(raw) ? Math.min(SCHEDULED_SESSION_MAX_MINUTES, Math.max(SCHEDULED_SESSION_MIN_MINUTES, Math.round(raw))) : SCHEDULED_SESSION_DEFAULT_MINUTES;
119175
- return minutes * 60 * 1e3;
119176
- }
119177
- function scheduleWindowPassed(sessionMode, eventDateMs, rawDurationMinutes, nowMs) {
119178
- if (sessionMode !== "schedule")
119179
- return false;
119180
- if (eventDateMs === null)
119302
+ // build/tools/setupValidation.js
119303
+ function scheduleWindowPassedByPlan(event, eventDateMs, rawDurationMinutes, nowMs) {
119304
+ if (event.analyticsSessionMode !== "schedule")
119181
119305
  return false;
119182
- return nowMs >= eventDateMs + scheduledDurationMs(rawDurationMinutes);
119306
+ const plan = sessionPlanOf(event);
119307
+ if (plan && plan.source === "meta")
119308
+ return nowMs >= plan.endMs;
119309
+ return scheduleWindowPassed(event.analyticsSessionMode, eventDateMs, rawDurationMinutes, nowMs);
119183
119310
  }
119184
-
119185
- // build/tools/setupValidation.js
119186
119311
  function validateEventSetup(eventId, event, nowMs = Date.now()) {
119187
119312
  const errors = [];
119188
119313
  const warnings = [];
@@ -119204,13 +119329,16 @@ function validateEventSetup(eventId, event, nowMs = Date.now()) {
119204
119329
  const eventDateMs = toMillis(meta2.eventDate);
119205
119330
  if (eventDateMs == null) {
119206
119331
  add(suggestions, "date_missing", "meta.eventDate", "Add the webinar date/time if it is known.");
119207
- } else if (scheduleWindowPassed(event.analyticsSessionMode, eventDateMs, meta2.durationMinutes, nowMs)) {
119208
- add(errors, "schedule_window_passed", "meta.eventDate", "Session mode is 'schedule' and the scheduled time (eventDate + durationMinutes) has already passed, so OKO will not start the analytics session automatically. If the webinar has not taken place yet, set a future eventDate with oko_update_event, or start the session by hand with oko_start_session. If it has already taken place, this is expected.");
119332
+ } else if (scheduleWindowPassedByPlan(event, eventDateMs, meta2.durationMinutes, nowMs)) {
119333
+ add(errors, "schedule_window_passed", "meta.eventDate", "The auto start is on ('schedule') and the session plan (the session time set in Setup, else eventDate + durationMinutes) has already passed, so OKO will not start the analytics session automatically. If the webinar has not taken place yet, set a future eventDate (or session time) with oko_update_event, or start the session by hand with oko_start_session. If it has already taken place, this is expected.");
119209
119334
  } else if (event.analyticsSessionMode !== "schedule" && eventDateMs < nowMs - 60 * 60 * 1e3) {
119210
119335
  add(warnings, "date_in_past", "meta.eventDate", "Configured webinar date appears to be in the past.");
119211
119336
  }
119212
119337
  if (scheduleMissesDate(event.analyticsSessionMode, meta2.eventDate)) {
119213
- add(errors, "schedule_missing_date", "analyticsSessionMode", "Session mode is 'schedule' but meta.eventDate is empty, so OKO can never start or end the session automatically and the reminder is never sent. Set eventDate, or switch sessionMode to 'manual' \u2014 oko_update_event accepts both fields in one call.");
119338
+ add(warnings, "schedule_missing_date", "meta.eventDate", "Add the event date: without a date the session does not start by itself (the auto start is on, 'schedule', and OKO has no session time to start it at), and a page cannot be published without a date. A page is born this way until the date is set \u2014 this is a normal draft, not an error. Set meta.eventDate, or switch sessionMode to 'manual' \u2014 oko_update_event accepts both fields in one call.");
119339
+ }
119340
+ for (const warning of sessionPlanWarnings(event)) {
119341
+ add(warnings, warning.code, "meta.sessionStartAt", warning.message);
119214
119342
  }
119215
119343
  const duplicateFormIds = /* @__PURE__ */ new Set();
119216
119344
  const seenFormIds = /* @__PURE__ */ new Set();
@@ -119349,25 +119477,25 @@ function stateWithin(now, window2) {
119349
119477
  return "open";
119350
119478
  return "after";
119351
119479
  }
119352
- function isRecord(value) {
119480
+ function isRecord2(value) {
119353
119481
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
119354
119482
  }
119355
119483
  function toMillis2(value) {
119356
119484
  if (typeof value === "number" && Number.isFinite(value))
119357
119485
  return value;
119358
- if (isRecord(value) && typeof value.toMillis === "function") {
119486
+ if (isRecord2(value) && typeof value.toMillis === "function") {
119359
119487
  const ms = value.toMillis();
119360
119488
  return typeof ms === "number" && Number.isFinite(ms) ? ms : null;
119361
119489
  }
119362
119490
  return null;
119363
119491
  }
119364
119492
  function windowFromEvent(event, nowMs) {
119365
- const billing = isRecord(event.billing) ? event.billing : {};
119493
+ const billing = isRecord2(event.billing) ? event.billing : {};
119366
119494
  const storedStart = toMillis2(billing.windowStart);
119367
119495
  const storedEnd = toMillis2(billing.windowEnd);
119368
119496
  let window2 = storedStart !== null && storedEnd !== null ? { windowStart: new Date(storedStart), windowEnd: new Date(storedEnd) } : null;
119369
119497
  if (!window2) {
119370
- const meta2 = isRecord(event.meta) ? event.meta : {};
119498
+ const meta2 = isRecord2(event.meta) ? event.meta : {};
119371
119499
  const eventDateMs = toMillis2(meta2.eventDate);
119372
119500
  if (eventDateMs !== null)
119373
119501
  window2 = showWindow(new Date(eventDateMs));
@@ -119385,11 +119513,28 @@ function telegramBlocksPatch(args) {
119385
119513
  };
119386
119514
  }
119387
119515
  function assertScheduleHasDate(fresh, patch) {
119516
+ const touchesModeOrDate = "analyticsSessionMode" in patch || "meta.eventDate" in patch || "meta" in patch;
119517
+ if (!touchesModeOrDate)
119518
+ return;
119388
119519
  const missing = scheduleMissesDate(valueAfterPatch(fresh, patch, "analyticsSessionMode"), valueAfterPatch(fresh, patch, "meta.eventDate"));
119389
119520
  if (!missing)
119390
119521
  return;
119391
119522
  throw new Error("sessionMode 'schedule' requires meta.eventDate: without a date OKO can never start the session automatically. Set eventDate in the same call, or switch sessionMode to 'manual' in the same call to clear the date.");
119392
119523
  }
119524
+ function assertSessionPlanValid(fresh, patch) {
119525
+ if (!("meta.sessionStartAt" in patch) && !("meta.sessionEndAt" in patch) && !("meta" in patch))
119526
+ return;
119527
+ const start = toMillis(valueAfterPatch(fresh, patch, "meta.sessionStartAt"));
119528
+ const end = toMillis(valueAfterPatch(fresh, patch, "meta.sessionEndAt"));
119529
+ if (start === null && end === null)
119530
+ return;
119531
+ if (start === null || end === null) {
119532
+ throw new Error("The session plan needs BOTH sessionStartAt and sessionEndAt: pass the pair in one call, or pass null for both to follow the event time.");
119533
+ }
119534
+ if (end <= start) {
119535
+ throw new Error("sessionEndAt must be later than sessionStartAt.");
119536
+ }
119537
+ }
119393
119538
  var DATE_LOCKED_BILLING_STATES = /* @__PURE__ */ new Set(["published", "held"]);
119394
119539
  function eventDateLockedRefusal(eventId, windowOpened) {
119395
119540
  return {
@@ -119583,7 +119728,10 @@ function registerEventTools(server2) {
119583
119728
  "Use after creating/configuring a webinar and before telling the organizer it is ready.",
119584
119729
  "Use before live-session prep to catch missing video, pending image variants, and invalid form bindings."
119585
119730
  ],
119586
- notes: ["This tool does not modify the event and does not start/end analytics sessions."]
119731
+ notes: [
119732
+ "This tool does not modify the event and does not start/end analytics sessions.",
119733
+ "Session warnings (from the page plan and, when the server answers, the payer's plan and lumens): the session plan differs from the event time \xB7 the lumens will not last the plan \xB7 the plan that pays has no sessions. They are warnings, not errors: the page can be published and shown either way."
119734
+ ]
119587
119735
  }),
119588
119736
  inputSchema: { eventId: eventIdField },
119589
119737
  outputSchema: setupValidationOutputSchema,
@@ -119591,7 +119739,16 @@ function registerEventTools(server2) {
119591
119739
  }, async ({ eventId }) => {
119592
119740
  const { uid } = await authed(["events:read"]);
119593
119741
  const data = await readEvent(eventId, uid, "read");
119594
- return textResult(validateEventSetup(eventId, data, await serverNowMs()));
119742
+ const report = validateEventSetup(eventId, data, await serverNowMs());
119743
+ const fetched = await fetchSessionOutlook(eventId);
119744
+ const outlookWarnings = "outlook" in fetched ? sessionOutlookWarnings(fetched.outlook) : [{ code: "session_outlook_unavailable", message: `Could not check the payer's plan and lumens for this page's session (${fetched.error}).` }];
119745
+ return textResult({
119746
+ ...report,
119747
+ warnings: [
119748
+ ...report.warnings,
119749
+ ...outlookWarnings.map((warning) => ({ code: warning.code, path: "analyticsSessionMode", message: warning.message }))
119750
+ ]
119751
+ });
119595
119752
  });
119596
119753
  server2.registerTool("oko_create_webinar", {
119597
119754
  title: "Create webinar",
@@ -119642,12 +119799,15 @@ function registerEventTools(server2) {
119642
119799
  createdAt: serverTimestamp2()
119643
119800
  }));
119644
119801
  const docRef = created.ref;
119802
+ const sessionWarnings = await collectSessionWarnings(docRef.id, { meta: { eventDate: args.eventDate ? Timestamp.fromDate(new Date(args.eventDate)) : null } });
119645
119803
  return textResult({
119646
119804
  id: docRef.id,
119647
119805
  ...eventLinks(docRef.id),
119648
119806
  workspaceId,
119649
119807
  runtime: created.runtime,
119808
+ warnings: sessionWarnings,
119650
119809
  hints: [
119810
+ "Analytics sessions: the page starts and ends its session by itself at the event time (sessionMode schedule; a session costs 1 lumen per minute, taken when it ends). Change the time of the session or turn the auto start off with oko_update_event (sessionMode, sessionStartAt, sessionEndAt).",
119651
119811
  ...eventRuntimeHint(created.runtime),
119652
119812
  `The page is a draft: viewers of publicUrl see "not published" (a legacy-tier payer's page is published at creation). Do not hand publicUrl out as live until oko_publish_event has published it \u2014 quote first, then confirm with the organizer.`,
119653
119813
  "Next: oko_configure_blocks for chat/CTA/placeholder, oko_configure_cards for cards, image uploads, oko_validate_event_setup, then oko_publish_event.",
@@ -119660,12 +119820,14 @@ function registerEventTools(server2) {
119660
119820
  description: toolDescription({
119661
119821
  summary: "Update safe top-level event/setup metadata fields.",
119662
119822
  exampleCall: { "eventId": "Kx9pQ2mR7tL4vB8nW3cY", "title": "Autumn product webinar (rescheduled)", "eventDate": "2026-09-12T07:00:00.000Z", "speakerName": "Jane Doe", "description": "", "durationMinutes": 60 },
119663
- args: ["eventId plus any subset of title, videoUrl, eventDate, speakerName, description, pinned, sessionMode, durationMinutes."],
119664
- returns: "event id and the list of Firestore fields updated.",
119823
+ args: ["eventId plus any subset of title, videoUrl, eventDate, speakerName, description, pinned, sessionMode, durationMinutes, sessionStartAt, sessionEndAt."],
119824
+ returns: "event id, the list of Firestore fields updated, and session warnings (plan versus event time, lumens versus plan, a plan with no sessions).",
119665
119825
  examples: ["Use for title/date/speaker/video edits; use oko_configure_blocks for blocks and forms."],
119666
119826
  notes: [
119667
- "sessionMode 'schedule' makes OKO start and end the analytics session automatically at eventDate for durationMinutes; 'manual' keeps the Start/End buttons and a Telegram reminder 10 minutes before eventDate. This is still an analytics session, not an event status.",
119668
- "sessionMode 'schedule' and eventDate travel together: setting the mode without a date, or clearing the date while the mode stays, is refused. Change both in one call.",
119827
+ "An ANALYTICS SESSION measures the show and costs 1 lumen per minute, taken when it ends (no debt; it stops by itself when the lumens run out). It always has a start and an end: the page's session plan \u2014 sessionStartAt and sessionEndAt, both or neither \u2014 and without them the plan is the event time (eventDate to eventDate + durationMinutes).",
119828
+ "sessionMode now means ONLY the automatic start: 'schedule' (the default of every new page) starts the session by itself at the plan's start; 'manual' turns that off \u2014 the organizer starts it with oko_start_session and a Telegram reminder comes 10 minutes before eventDate. Either way the session ends at the end of its plan, and oko_extend_session moves that end while it runs. This is still an analytics session, not an event status.",
119829
+ "sessionStartAt/sessionEndAt: pass BOTH ISO datetimes to set a session time different from the show's, or pass null for both to follow the event time again. A session plan that differs from the event time is reported as a warning (moving eventDate does not move an explicit plan).",
119830
+ "Setting sessionMode 'schedule' on a page with no date, or clearing the date of a page in 'schedule', in one call is refused (nothing could start from it); a page born with the auto start on and no date yet is normal and can be edited freely.",
119669
119831
  'eventDate is LOCKED once the page is published and paid (not on the transition plan): changing it is refused with errorCode EVENT_DATE_LOCKED \u2014 every other field in the same call still goes through if eventDate is dropped from it. Before the show window opens: oko_unpublish_event (refunds), change the date, oko_publish_event (new charge). After the window opens, or once the page shows "held"/\u041F\u0440\u043E\u0432\u0435\u0434\u0435\u043D\u043E: the date cannot move at all \u2014 duplicate the page with oko_duplicate_event for the new date.'
119670
119832
  ]
119671
119833
  }),
@@ -119677,8 +119839,10 @@ function registerEventTools(server2) {
119677
119839
  speakerName: external_exports.string().max(160).nullable().optional().describe('New speaker display name; pass "" or null to clear (the literal text "null" is rejected).'),
119678
119840
  description: external_exports.string().max(3e3).nullable().optional().describe('New description; pass "" or null to clear (the literal text "null" is rejected).'),
119679
119841
  pinned: external_exports.boolean().optional().describe("Whether this event is pinned in the dashboard."),
119680
- sessionMode: external_exports.enum(["manual", "schedule"]).optional().describe("Analytics session mode: manual Start/End buttons, or automatic start/end on schedule at eventDate."),
119681
- durationMinutes: external_exports.number().int().min(15).max(1440).optional().describe("Event duration in minutes (15-1440) used by schedule mode to end the session.")
119842
+ sessionMode: external_exports.enum(["manual", "schedule"]).optional().describe("Automatic start of the analytics session: 'schedule' starts it by itself at the start of the session plan (the default of a new page), 'manual' leaves the start to oko_start_session. The session always ends at the end of its plan."),
119843
+ durationMinutes: external_exports.number().int().min(15).max(1440).optional().describe("Event duration in minutes (15-1440); with eventDate it is the default session plan."),
119844
+ sessionStartAt: external_exports.string().datetime().nullable().optional().describe("Start of the session plan (ISO datetime), or null to follow the event time. Pass together with sessionEndAt."),
119845
+ sessionEndAt: external_exports.string().datetime().nullable().optional().describe("End of the session plan (ISO datetime), or null to follow the event time. Pass together with sessionStartAt.")
119682
119846
  },
119683
119847
  outputSchema: updateOutputSchema,
119684
119848
  annotations: writeTool
@@ -119686,7 +119850,7 @@ function registerEventTools(server2) {
119686
119850
  const requiredScopes = /* @__PURE__ */ new Set();
119687
119851
  if (args.title !== void 0 || args.pinned !== void 0)
119688
119852
  requiredScopes.add("events:write");
119689
- if (args.videoUrl !== void 0 || args.eventDate !== void 0 || args.speakerName !== void 0 || args.description !== void 0 || args.sessionMode !== void 0 || args.durationMinutes !== void 0) {
119853
+ if (args.videoUrl !== void 0 || args.eventDate !== void 0 || args.speakerName !== void 0 || args.description !== void 0 || args.sessionMode !== void 0 || args.durationMinutes !== void 0 || args.sessionStartAt !== void 0 || args.sessionEndAt !== void 0) {
119690
119854
  requiredScopes.add("setup:write");
119691
119855
  }
119692
119856
  if (requiredScopes.size === 0)
@@ -119717,14 +119881,21 @@ function registerEventTools(server2) {
119717
119881
  patch.analyticsSessionMode = args.sessionMode;
119718
119882
  if (args.durationMinutes !== void 0)
119719
119883
  patch["meta.durationMinutes"] = args.durationMinutes;
119884
+ if (args.sessionStartAt !== void 0)
119885
+ patch["meta.sessionStartAt"] = args.sessionStartAt ? Timestamp.fromDate(new Date(args.sessionStartAt)) : null;
119886
+ if (args.sessionEndAt !== void 0)
119887
+ patch["meta.sessionEndAt"] = args.sessionEndAt ? Timestamp.fromDate(new Date(args.sessionEndAt)) : null;
119720
119888
  const nowMs = args.eventDate !== void 0 ? await serverNowMs() : null;
119721
119889
  const validate = (fresh, freshPatch) => {
119722
119890
  assertScheduleHasDate(fresh, freshPatch);
119891
+ assertSessionPlanValid(fresh, freshPatch);
119723
119892
  if (nowMs !== null)
119724
119893
  assertDateNotLocked(args.eventId, fresh, freshPatch, nowMs);
119725
119894
  };
119726
119895
  await writeEventPatchWithCards(args.eventId, patch, null, validate);
119727
- return textResult({ id: args.eventId, updated: Object.keys(patch) });
119896
+ const touchesSession = ["meta.eventDate", "meta.durationMinutes", "meta.sessionStartAt", "meta.sessionEndAt", "analyticsSessionMode"].some((key) => key in patch);
119897
+ const warnings = touchesSession ? await collectSessionWarnings(args.eventId, await readEvent(args.eventId, uid, "read")) : [];
119898
+ return textResult({ id: args.eventId, updated: Object.keys(patch), warnings });
119728
119899
  });
119729
119900
  server2.registerTool("oko_configure_blocks", {
119730
119901
  title: "Configure event blocks",
@@ -121105,6 +121276,16 @@ async function releaseSessionPointers(eventId, sessionId) {
121105
121276
  attempts.push(await attemptCleanup("events.activeAnalyticsSessionId", () => updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() })));
121106
121277
  return attempts;
121107
121278
  }
121279
+ function settlementLine(result) {
121280
+ if (typeof result.minutes !== "number")
121281
+ return null;
121282
+ if (result.billingMode === "free") {
121283
+ return `The session ran ${result.minutes} minute(s); minutes are not being charged for this page right now (0 lumens taken).`;
121284
+ }
121285
+ const charged = typeof result.charged === "number" ? result.charged : 0;
121286
+ const forgiven = typeof result.forgiven === "number" ? result.forgiven : 0;
121287
+ return `Charged ${charged} lumens for ${result.minutes} minute(s)` + (forgiven > 0 ? `; ${forgiven} more were forgiven because the wallet did not hold enough (no debt).` : ".");
121288
+ }
121108
121289
  function startSessionRefusal(eventId, body) {
121109
121290
  return { ...textResult({ tool: "oko_start_session", eventId, ...body }), isError: true };
121110
121291
  }
@@ -121126,6 +121307,54 @@ function startSessionRefusalResult(eventId, error3) {
121126
121307
  ]
121127
121308
  });
121128
121309
  }
121310
+ if (info.errorCode === "ENTITLEMENT_REQUIRED") {
121311
+ const inactive = info.reason === "plan_inactive";
121312
+ return startSessionRefusal(eventId, {
121313
+ errorCode: "ENTITLEMENT_REQUIRED",
121314
+ reason: inactive ? "plan_inactive" : "tier",
121315
+ requiredTier: typeof info.requiredTier === "string" ? info.requiredTier : "pro",
121316
+ currentTier: typeof info.currentTier === "string" ? info.currentTier : null,
121317
+ message: text || (inactive ? "OKO refused to start the session: the subscription that pays for this page is not active. Nothing was started or charged." : "OKO refused to start the session: the plan that pays for this page has no analytics sessions. Nothing was started or charged."),
121318
+ whatToDo: inactive ? [
121319
+ "Explain that the subscription owner can renew or buy a plan in the OKO cabinet.",
121320
+ "Do not retry as is: the answer will not change until the subscription does."
121321
+ ] : [
121322
+ "Explain that analytics sessions come with the Pro plan and above; the subscription owner can upgrade in the OKO cabinet.",
121323
+ "Do not retry as is: the answer will not change until the plan does."
121324
+ ]
121325
+ });
121326
+ }
121327
+ if (info.errorCode === "INSUFFICIENT_TOKENS") {
121328
+ const callerIsPayer = info.callerIsPayer === true;
121329
+ return startSessionRefusal(eventId, {
121330
+ errorCode: "INSUFFICIENT_TOKENS",
121331
+ callerIsPayer,
121332
+ message: text || "OKO refused to start the session: the wallet that pays for this page has 0 lumens. Nothing was started or charged.",
121333
+ whatToDo: [
121334
+ callerIsPayer ? "Explain that a session costs 1 lumen per minute and the wallet is at zero: lumens can be topped up in the OKO cabinet." : "Explain that this workspace is paid for by its owner, whose lumens have run out: ask the owner to top up.",
121335
+ "Do not retry as is: the balance will not change by itself."
121336
+ ]
121337
+ });
121338
+ }
121339
+ if (info.reason === "payer_unresolved") {
121340
+ return startSessionRefusal(eventId, {
121341
+ errorCode: "PAYER_UNRESOLVED",
121342
+ message: text || "OKO cannot tell which workspace wallet pays for this event, so a session cannot be charged. Nothing was started.",
121343
+ whatToDo: [
121344
+ "Check the event's workspace with oko_get_event; if the workspace was removed, move the event with oko_move_event.",
121345
+ "Do not retry as is: the answer will not change until the workspace does."
121346
+ ]
121347
+ });
121348
+ }
121349
+ if (info.reason === "planned_end_invalid") {
121350
+ return startSessionRefusal(eventId, {
121351
+ errorCode: "PLANNED_END_INVALID",
121352
+ message: text || "The end of the session must be a moment in the future. Nothing was started.",
121353
+ whatToDo: [
121354
+ "Pass endsAt as an ISO datetime later than now, or omit it to end with the page's plan."
121355
+ ]
121356
+ });
121357
+ }
121129
121358
  if (info.reason === "outside_show_window") {
121130
121359
  const state2 = info.window === "before" || info.window === "after" ? info.window : null;
121131
121360
  return startSessionRefusal(eventId, {
@@ -121145,32 +121374,92 @@ function startSessionRefusalResult(eventId, error3) {
121145
121374
  }
121146
121375
  return null;
121147
121376
  }
121377
+ function buildExtendRequest(input) {
121378
+ const { eventId, sessionId, endsAt, addMinutes } = input;
121379
+ if (endsAt !== void 0)
121380
+ return { eventId, sessionId, plannedEndAt: Date.parse(endsAt) };
121381
+ return { eventId, sessionId, addMinutes };
121382
+ }
121383
+ function extendSessionRefusalResult(eventId, error3) {
121384
+ if (!error3 || typeof error3 !== "object")
121385
+ return null;
121386
+ const { code, message, details } = error3;
121387
+ if (typeof code !== "string" || !code.startsWith("functions/"))
121388
+ return null;
121389
+ const info = details && typeof details === "object" ? details : {};
121390
+ const text = safeString(message);
121391
+ const refusal4 = (body) => ({ ...textResult({ tool: "oko_extend_session", eventId, ...body }), isError: true });
121392
+ if (info.reason === "session_not_running") {
121393
+ return refusal4({
121394
+ errorCode: "SESSION_NOT_RUNNING",
121395
+ message: text || "No analytics session is running on this page, so there is nothing to extend.",
121396
+ whatToDo: [
121397
+ "Start a session with oko_start_session (it takes an endsAt), or, if it already ended, extend nothing: a finished session is final."
121398
+ ]
121399
+ });
121400
+ }
121401
+ if (info.reason === "planned_end_invalid") {
121402
+ return refusal4({
121403
+ errorCode: "PLANNED_END_INVALID",
121404
+ message: text || "The new end must be a moment in the future.",
121405
+ whatToDo: ["Pass endsAt later than now, or addMinutes to move the current end forward."]
121406
+ });
121407
+ }
121408
+ if (info.reason === "outside_show_window") {
121409
+ return refusal4({
121410
+ errorCode: "OUTSIDE_SHOW_WINDOW",
121411
+ message: text || "The show window of this page is over: a session can no longer be extended.",
121412
+ whatToDo: ["End the session with oko_end_session; to run the show on another date, duplicate the page with oko_duplicate_event."]
121413
+ });
121414
+ }
121415
+ return null;
121416
+ }
121148
121417
  function registerSessionTools(server2) {
121149
121418
  server2.registerTool("oko_start_session", {
121150
121419
  title: "Start analytics session",
121151
121420
  description: toolDescription({
121152
- summary: "Start an analytics session using the same Firestore/RTDB pointers as the dashboard.",
121153
- args: ["eventId: event whose analytics session should start.", "confirm: must be true."],
121154
- returns: "eventId, new sessionId, and started=true.",
121421
+ summary: "Start an analytics session using the same Firestore/RTDB pointers as the dashboard. A session measures the show and costs 1 lumen per minute, taken when it ends.",
121422
+ args: [
121423
+ "eventId: event whose analytics session should start.",
121424
+ "endsAt: optional ISO datetime when the session should end. Omit it to end with the page's plan (the session time set in Setup, else the event time); it is never later than the end of the page's show window (the answer says clamped:true when it was moved back).",
121425
+ "confirm: must be true."
121426
+ ],
121427
+ returns: "eventId, new sessionId, started=true, plannedEndAt (ms) and endsAt (ISO), billingMode (metered or free), clamped, and an explanation.",
121155
121428
  examples: ["Use when the organizer wants analytics tracking to begin; it does not start the video stream."],
121156
121429
  notes: [
121157
121430
  "OKO Start/End controls analytics sessions only, not stream publishing.",
121158
- "A new session only starts inside the page's show window (24h before the event date to 48h after). Refusals carry errorCode and whatToDo: PAGE_NOT_PUBLISHED (publish the page first with oko_publish_event) and OUTSIDE_SHOW_WINDOW (windowState before/after \u2014 wait for the window, or duplicate the page for another date with oko_duplicate_event)."
121431
+ "A session ALWAYS has an end: the plan's end (or endsAt); when it comes OKO ends the session by itself, and the closer a show runs to it the more useful oko_extend_session is. If the show starts before the plan, the session is longer than the plan; if it starts after the plan already ended, it gets the plan's length from now.",
121432
+ "Minutes are counted when the session ends (rounded; under 30 seconds is free) and taken from the wallet that pays, at most as much as it holds \u2014 there is no debt. If the lumens run out while it runs, OKO stops the session by itself. Pages can also start their session automatically at the plan's start (sessionMode schedule); Start here is the manual way.",
121433
+ "A new session only starts inside the page's show window (24h before the event date to 48h after). Refusals carry errorCode and whatToDo: PAGE_NOT_PUBLISHED (publish the page first with oko_publish_event), OUTSIDE_SHOW_WINDOW (windowState before/after \u2014 wait for the window, or duplicate the page for another date with oko_duplicate_event), ENTITLEMENT_REQUIRED (reason tier: the plan has no sessions, they come with Pro and above; reason plan_inactive: the subscription is not active), INSUFFICIENT_TOKENS (the paying wallet is at zero; callerIsPayer tells who should top it up), PAYER_UNRESOLVED and PLANNED_END_INVALID (endsAt must be in the future)."
121159
121434
  ]
121160
121435
  }),
121161
- inputSchema: { eventId: eventIdField, confirm: external_exports.boolean().optional().describe("Required true to start analytics collection session.") },
121436
+ inputSchema: {
121437
+ eventId: eventIdField,
121438
+ endsAt: external_exports.string().datetime().optional().describe("Optional ISO datetime for the end of this session; omit to end with the page's plan."),
121439
+ confirm: external_exports.boolean().optional().describe("Required true to start analytics collection session.")
121440
+ },
121162
121441
  outputSchema: basicEventOutputSchema,
121163
121442
  annotations: writeTool
121164
- }, async ({ eventId, confirm }) => {
121443
+ }, async ({ eventId, endsAt, confirm }) => {
121165
121444
  const { uid } = await authed(["analytics:write", "live:control"]);
121166
121445
  const event = await readEvent(eventId, uid, "session");
121167
- requireConfirm(confirm, "oko_start_session", { eventId, title: event.title ?? null });
121446
+ requireConfirm(confirm, "oko_start_session", { eventId, title: event.title ?? null, endsAt: endsAt ?? null });
121168
121447
  if (event.activeAnalyticsSessionId)
121169
121448
  throw new Error("An analytics session is already active.");
121170
121449
  const start = httpsCallable(functions, "startAnalyticsSessionV3");
121171
121450
  try {
121172
- const result = await start({ eventId });
121173
- return textResult({ eventId, started: true, ...result.data });
121451
+ const result = await start({ eventId, ...endsAt ? { plannedEndAt: Date.parse(endsAt) } : {} });
121452
+ return textResult({
121453
+ eventId,
121454
+ started: true,
121455
+ ...result.data,
121456
+ endsAt: typeof result.data.plannedEndAt === "number" ? new Date(result.data.plannedEndAt).toISOString() : null,
121457
+ explanation: sessionExplanation({
121458
+ billingMode: result.data.billingMode,
121459
+ plannedEndAt: result.data.plannedEndAt,
121460
+ clamped: result.data.clamped
121461
+ })
121462
+ });
121174
121463
  } catch (error3) {
121175
121464
  const named = startSessionRefusalResult(eventId, error3);
121176
121465
  if (named)
@@ -121181,11 +121470,15 @@ function registerSessionTools(server2) {
121181
121470
  server2.registerTool("oko_end_session", {
121182
121471
  title: "End analytics session",
121183
121472
  description: toolDescription({
121184
- summary: "End the active analytics session and persist the session snapshot.",
121473
+ summary: "End the active analytics session, persist the session snapshot and settle its minutes.",
121185
121474
  args: ["eventId: event with an active analytics session.", "confirm: must be true."],
121186
- returns: "eventId, sessionId, and ended=true.",
121475
+ returns: "eventId, sessionId, ended=true, minutes, charged, forgiven, billingMode, and a plain-language line about the charge.",
121187
121476
  examples: ["Use after the webinar analytics window is over and the user confirms the exact event."],
121188
- notes: ["Destructive for the active session pointer, but it does not stop the video stream."]
121477
+ notes: [
121478
+ "Destructive for the active session pointer, but it does not stop the video stream.",
121479
+ "The session's minutes (rounded from its duration; a session under 30 seconds is 0) are 1 lumen each, taken now from the wallet that pays. charged is what was taken; forgiven is what the wallet could not cover \u2014 there is no debt. billingMode free means minutes were not charged (money counting is off for this page). Ending twice is safe: the second call returns the same figures and charges nothing.",
121480
+ "A session also ends by itself at its planned end and when the lumens run out (the report says which). Use oko_extend_session to move the planned end while it runs."
121481
+ ]
121189
121482
  }),
121190
121483
  inputSchema: {
121191
121484
  eventId: eventIdField,
@@ -121203,7 +121496,62 @@ function registerSessionTools(server2) {
121203
121496
  requireConfirm(confirm, "oko_end_session", { eventId, sessionId, reportLocale: reportLocale ?? null });
121204
121497
  const end = httpsCallable(functions, "endAnalyticsSessionV3");
121205
121498
  const result = await end({ eventId, sessionId, ...reportLocale ? { locale: reportLocale } : {} });
121206
- return textResult({ eventId, ...result.data });
121499
+ return textResult({ eventId, ...result.data, billingLine: settlementLine(result.data) });
121500
+ });
121501
+ server2.registerTool("oko_extend_session", {
121502
+ title: "Extend analytics session",
121503
+ description: toolDescription({
121504
+ summary: "Move the planned end of the RUNNING analytics session \u2014 later (the show runs long) or earlier. Analytics never edits page settings, so this is a server call like Start and End. Extending is free; only the minutes the session actually runs are paid.",
121505
+ args: [
121506
+ "eventId: event with an active analytics session.",
121507
+ "endsAt: the new end as an ISO datetime \u2014 OR \u2014",
121508
+ "addMinutes: move the CURRENT planned end by this many minutes (1-1440). Give exactly one of the two.",
121509
+ "confirm: must be true."
121510
+ ],
121511
+ returns: "eventId, sessionId, extended=true, plannedEndAt (ms), endsAt (ISO), clamped, windowEndAt.",
121512
+ examples: ["The show is running long: oko_extend_session with addMinutes 30, then confirm."],
121513
+ notes: [
121514
+ "The new end must be in the future and is never later than the end of the page's show window: past it the end is moved back to the window's end and the answer says clamped:true (windowEndAt tells where) \u2014 that is not an error.",
121515
+ "The session ends by itself at its planned end; extending is how a long show keeps its measurement. It also stops by itself when the lumens run out.",
121516
+ "Refusals carry errorCode and whatToDo: SESSION_NOT_RUNNING (no session is running on this page \u2014 start one with oko_start_session), PLANNED_END_INVALID (the end is not in the future), OUTSIDE_SHOW_WINDOW."
121517
+ ]
121518
+ }),
121519
+ inputSchema: {
121520
+ eventId: eventIdField,
121521
+ endsAt: external_exports.string().datetime().optional().describe("New end of the running session, ISO datetime."),
121522
+ addMinutes: external_exports.number().int().min(1).max(1440).optional().describe("Move the current planned end by this many minutes."),
121523
+ confirm: external_exports.boolean().optional().describe("Required true to move the end of the running session.")
121524
+ },
121525
+ outputSchema: basicEventOutputSchema,
121526
+ annotations: writeTool
121527
+ }, async ({ eventId, endsAt, addMinutes, confirm }) => {
121528
+ const { uid } = await authed(["analytics:write", "live:control"]);
121529
+ if (endsAt === void 0 === (addMinutes === void 0)) {
121530
+ throw new Error("Give exactly one of endsAt (an ISO datetime) or addMinutes (minutes to add to the current end).");
121531
+ }
121532
+ const event = await readEvent(eventId, uid, "session");
121533
+ const sessionId = safeString(event.activeAnalyticsSessionId);
121534
+ if (!sessionId) {
121535
+ throw new ToolRefusal(extendSessionRefusalResult(eventId, { code: "functions/failed-precondition", details: { reason: "session_not_running" } }));
121536
+ }
121537
+ requireConfirm(confirm, "oko_extend_session", { eventId, sessionId, endsAt: endsAt ?? null, addMinutes: addMinutes ?? null });
121538
+ const extend2 = httpsCallable(functions, "extendAnalyticsSession");
121539
+ try {
121540
+ const result = await extend2(buildExtendRequest({ eventId, sessionId, endsAt, addMinutes }));
121541
+ return textResult({
121542
+ eventId,
121543
+ sessionId,
121544
+ extended: true,
121545
+ ...result.data,
121546
+ endsAt: new Date(result.data.plannedEndAt).toISOString(),
121547
+ explanation: sessionExplanation({ billingMode: void 0, plannedEndAt: result.data.plannedEndAt, clamped: result.data.clamped })
121548
+ });
121549
+ } catch (error3) {
121550
+ const named = extendSessionRefusalResult(eventId, error3);
121551
+ if (named)
121552
+ throw new ToolRefusal(named);
121553
+ throw error3;
121554
+ }
121207
121555
  });
121208
121556
  server2.registerTool("oko_fix_orphan_session", {
121209
121557
  title: "Fix orphan analytics session",
@@ -121729,7 +122077,7 @@ function registerTelegramTools(server2) {
121729
122077
  // build/tools/unpublication.js
121730
122078
  var TOOL2 = "oko_unpublish_event";
121731
122079
  var NOTHING_WILL_CHANGE = "The page and its lumens stay exactly as they are now.";
121732
- function isRecord2(value) {
122080
+ function isRecord3(value) {
121733
122081
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
121734
122082
  }
121735
122083
  function refusal3(eventId, body) {
@@ -121779,7 +122127,7 @@ function windowOpenRefusal(eventId, state2, windowStart) {
121779
122127
  });
121780
122128
  }
121781
122129
  function previewUnpublish(eventId, event, nowMs) {
121782
- const billing = isRecord2(event.billing) ? event.billing : {};
122130
+ const billing = isRecord3(event.billing) ? event.billing : {};
121783
122131
  const state2 = billing.state === "published" || billing.state === "held" ? billing.state : "unpublished";
121784
122132
  if (state2 === "unpublished")
121785
122133
  return { kind: "already" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Control OKO Stream webinars from Claude, Codex, and other MCP clients.",
5
5
  "license": "UNLICENSED",
6
6
  "author": {