@ai-matrx/meet 0.7.6 → 0.7.8

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/index.js CHANGED
@@ -192,6 +192,12 @@ var MEET_ROUTES = {
192
192
  meetingAnnounce: "/api/v1/meet/meetings/{meeting_id}/announce",
193
193
  /** Put a one-off meeting on the caller's own Google Calendar (gated by the server). */
194
194
  meetingGoogleCalendar: "/api/v1/meet/meetings/{meeting_id}/google-calendar",
195
+ /**
196
+ * Meet wave 3: the recap email after the meeting. `GET` = the draft (subject,
197
+ * body from the wrap-up, who it can go to); `POST` = send what the host
198
+ * reviewed (`dry_run` renders and queues nothing). Never sent automatically.
199
+ */
200
+ meetingFollowUp: "/api/v1/meet/meetings/{meeting_id}/follow-up",
195
201
  /**
196
202
  * Meet wave 2: EVERY in-meeting host power (mute, stop video, mute all, ask to
197
203
  * unmute, spotlight, lower hands, co-host, hand over, rename, remove, report).
@@ -321,10 +327,11 @@ function createTokenClient(options) {
321
327
  throw error;
322
328
  }
323
329
  if (response.status === 401 || response.status === 403) {
330
+ const said = await serverSentence(response);
324
331
  throw new MeetError(
325
332
  "forbidden",
326
- `meet.token: the server refused this join (${response.status})`,
327
- "Authorization is the server's (R5). The joiner is not a member of this meeting's organization, or the meeting is locked \u2014 never mint around it on the client."
333
+ said?.message ?? "You can't join this meeting.",
334
+ said?.remedy ?? "Ask the person who shared the link to let you in, then open the link again."
328
335
  );
329
336
  }
330
337
  if (response.status === 404) {
@@ -335,12 +342,7 @@ function createTokenClient(options) {
335
342
  );
336
343
  }
337
344
  if (response.status === 410) {
338
- let reason = "This meeting was cancelled.";
339
- try {
340
- const body = await response.json();
341
- if (typeof body?.detail?.message === "string") reason = body.detail.message;
342
- } catch {
343
- }
345
+ const reason = (await serverSentence(response))?.message ?? "This meeting was cancelled.";
344
346
  throw new MeetError(
345
347
  "cancelled",
346
348
  reason,
@@ -348,10 +350,11 @@ function createTokenClient(options) {
348
350
  );
349
351
  }
350
352
  if (response.status === 423) {
353
+ const said = await serverSentence(response);
351
354
  throw new MeetError(
352
355
  "room-locked",
353
- "meet.token: the meeting is locked",
354
- "The host locked the room. Ask them to unlock or admit you; this is a normal state, not a failure."
356
+ said?.message ?? "The meeting is locked.",
357
+ said?.remedy ?? "The host locked the room. Ask them to unlock it, then join again."
355
358
  );
356
359
  }
357
360
  if (response.status === 503) {
@@ -422,6 +425,19 @@ function placeholder(roomName) {
422
425
  lobby: false
423
426
  };
424
427
  }
428
+ async function serverSentence(response) {
429
+ try {
430
+ const body = await response.json();
431
+ const source = body !== null && typeof body.detail === "object" && body.detail !== null ? body.detail : body;
432
+ if (source === null) return null;
433
+ const message = typeof source.user_message === "string" && source.user_message.trim() !== "" ? source.user_message : typeof source.message === "string" && source.message.trim() !== "" ? source.message : null;
434
+ if (message === null) return null;
435
+ const remedy = typeof source.remedy === "string" && source.remedy.trim() !== "" ? source.remedy : void 0;
436
+ return { message, remedy };
437
+ } catch {
438
+ return null;
439
+ }
440
+ }
425
441
 
426
442
  // src/core/ai.ts
427
443
  var MEET_ANSWER_EVENTS = {
@@ -466,6 +482,29 @@ function readText(data) {
466
482
  }
467
483
  return "";
468
484
  }
485
+ var SERVER_FAILED_SENTENCE = "The server hit an unexpected problem and nothing was changed.";
486
+ function isRawServerText(value) {
487
+ const text3 = value.trim();
488
+ return /^internal server error\b/i.test(text3) || /^[A-Z][A-Za-z0-9]*(Error|Exception)$/.test(text3) || /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/.test(text3);
489
+ }
490
+ async function refusalFromResponse(response, operation, fallbackRemedy) {
491
+ let body = null;
492
+ try {
493
+ body = await response.json();
494
+ } catch {
495
+ body = null;
496
+ }
497
+ const said = readServerRefusal(body);
498
+ const code = response.status === 403 || response.status === 401 ? "forbidden" : response.status === 404 ? "not-found" : response.status === 409 ? "conflict" : "transport";
499
+ const error = new MeetError(
500
+ code,
501
+ said?.reason ?? SERVER_FAILED_SENTENCE,
502
+ said !== null && said.remedy.length > 0 ? said.remedy : fallbackRemedy
503
+ );
504
+ error.operation = operation;
505
+ error.status = response.status;
506
+ return error;
507
+ }
469
508
  function readServerRefusal(body) {
470
509
  if (typeof body === "string") {
471
510
  return body.trim().length === 0 ? null : { reason: body.trim(), remedy: "" };
@@ -484,7 +523,8 @@ function readServerRefusal(body) {
484
523
  return messages.length === 0 ? null : { reason: messages.join("; "), remedy: "" };
485
524
  }
486
525
  if (typeof detail === "object" && detail !== null) return readServerRefusal(detail);
487
- const reason = typeof row["reason"] === "string" ? row["reason"] : typeof row["message"] === "string" ? row["message"] : typeof detail === "string" ? detail : typeof row["error"] === "string" ? row["error"] : null;
526
+ const humanOrNull = (value) => typeof value === "string" && value.trim().length > 0 && !isRawServerText(value) ? value.trim() : null;
527
+ const reason = humanOrNull(row["reason"]) ?? humanOrNull(row["message"]) ?? humanOrNull(row["user_message"]) ?? (typeof detail === "string" ? humanOrNull(detail) : null) ?? (typeof row["message"] === "string" || typeof row["error"] === "string" || typeof detail === "string" ? SERVER_FAILED_SENTENCE : null);
488
528
  if (reason === null) return null;
489
529
  const remedy = typeof row["remedy"] === "string" ? row["remedy"] : "";
490
530
  if (remedy.length > 0) return { reason, remedy };
@@ -890,7 +930,7 @@ var DEFAULT_RING_TIMEOUT_MS = 45e3;
890
930
  function createCallCenter(options) {
891
931
  const { manager, repository, identity } = options;
892
932
  const timers = options.timers ?? {
893
- setTimeout: (run, ms) => setTimeout(run, ms),
933
+ setTimeout: (run, ms2) => setTimeout(run, ms2),
894
934
  clearTimeout: (handle) => clearTimeout(handle),
895
935
  now: () => Date.now()
896
936
  };
@@ -1174,7 +1214,16 @@ var RPCS = {
1174
1214
  /** One meeting's occurrences between two instants (INVOKER — the reader's RLS). */
1175
1215
  meetingOccurrences: "meet_meeting_occurrences",
1176
1216
  /** Every occurrence the reader can see between two instants — the Upcoming list. */
1177
- occurrencesBetween: "meet_occurrences_between"
1217
+ occurrencesBetween: "meet_occurrences_between",
1218
+ /*
1219
+ * ── MEET WAVE 3 — after the meeting. Both INVOKER (the reader's RLS) and both
1220
+ * scoped to the reader's OWN meetings (host, invitee, attended) — RLS is the
1221
+ * ceiling, never the view.
1222
+ */
1223
+ /** The recordings library. */
1224
+ myRecordings: "meet_my_recordings",
1225
+ /** Titles, agendas, current notes and final transcript lines; snippets mark hits with ⟦ ⟧. */
1226
+ search: "meet_search"
1178
1227
  };
1179
1228
  var VOID_RPCS = [RPCS.admit, RPCS.recordConsent];
1180
1229
  var MEETING_COLUMNS = "id,organization_id,room_name,slug,title,kind,host_user_id,scheduled_for,scheduled_duration_minutes,started_at,ended_at,locked,lobby_enabled,recording_policy,ai_enabled,metadata,time_zone,agenda,recurrence_rule,join_before_host,cancelled_at,cancellation_reason,deleted_at,calendar_sequence,version";
@@ -1879,12 +1928,117 @@ function createMeetRepository(options) {
1879
1928
  );
1880
1929
  },
1881
1930
  async recordings(meetingId) {
1882
- const response = await db.from(TABLES.recordings).select(RECORDING_COLUMNS).eq("meeting_id", meetingId).order("started_at", { ascending: false });
1931
+ const response = await db.from(TABLES.recordings).select(RECORDING_COLUMNS).eq("meeting_id", meetingId).is("deleted_at", null).order("started_at", { ascending: false });
1883
1932
  if (response.error !== null) throw normalizeMeetError(response.error, "meet.recordings");
1884
1933
  return (response.data ?? []).map(projectRecording);
1934
+ },
1935
+ async myRecordings({ includeArchived = false, limit = 200 } = {}) {
1936
+ const rows = await guard(
1937
+ "meet.myRecordings",
1938
+ () => db.rpc(RPCS.myRecordings, { p_include_archived: includeArchived, p_limit: limit })
1939
+ );
1940
+ return rows.map(projectLibraryRecording);
1941
+ },
1942
+ async searchMeetings(query, limit = 60) {
1943
+ if (query.trim().length === 0) return [];
1944
+ const rows = await guard(
1945
+ "meet.searchMeetings",
1946
+ () => db.rpc(RPCS.search, { p_query: query, p_limit: limit })
1947
+ );
1948
+ return rows.map(projectSearchHit);
1949
+ },
1950
+ async renameRecording(recordingId, title) {
1951
+ const clean = title === null ? null : title.trim().slice(0, 200) || null;
1952
+ const response = await db.from(TABLES.recordings).update({ title: clean }).eq("id", recordingId).select("id");
1953
+ if (response.error !== null) throw normalizeMeetError(response.error, "meet.renameRecording");
1954
+ if ((response.data ?? []).length === 0) {
1955
+ throw normalizeMeetError(
1956
+ { message: "permission denied: only the meeting's host or an editor can rename it", code: "42501" },
1957
+ "meet.renameRecording"
1958
+ );
1959
+ }
1960
+ },
1961
+ async setRecordingArchived(recordingId, archived) {
1962
+ const response = await db.from(TABLES.recordings).update({ deleted_at: archived ? (/* @__PURE__ */ new Date()).toISOString() : null }).eq("id", recordingId).select("id");
1963
+ if (response.error !== null) throw normalizeMeetError(response.error, "meet.setRecordingArchived");
1964
+ if ((response.data ?? []).length === 0) {
1965
+ throw normalizeMeetError(
1966
+ { message: "permission denied: only the meeting's host or an editor can archive it", code: "42501" },
1967
+ "meet.setRecordingArchived"
1968
+ );
1969
+ }
1885
1970
  }
1886
1971
  };
1887
1972
  }
1973
+ var LIBRARY_STATES = [
1974
+ "idle",
1975
+ "starting",
1976
+ "recording",
1977
+ "stopping",
1978
+ "processing",
1979
+ "available",
1980
+ "expired",
1981
+ "failed"
1982
+ ];
1983
+ function projectLibraryRecording(row) {
1984
+ const operation = "meet.projectLibraryRecording";
1985
+ const state = text(row, "state");
1986
+ const role = text(row, "my_role");
1987
+ return {
1988
+ id: requireText(row, "recording_id", operation),
1989
+ meetingId: asMeetingId(requireText(row, "meeting_id", operation)),
1990
+ meetingTitle: text(row, "meeting_title") ?? "Meeting",
1991
+ meetingSlug: requireText(row, "meeting_slug", operation),
1992
+ role: role === "host" || role === "cohost" || role === "invitee" ? role : "attendee",
1993
+ state: LIBRARY_STATES.includes(state) ? state : "failed",
1994
+ title: text(row, "recording_title"),
1995
+ fileId: text(row, "file_id"),
1996
+ startedAt: text(row, "started_at"),
1997
+ endedAt: text(row, "ended_at"),
1998
+ durationMs: num(row, "duration_ms"),
1999
+ sizeBytes: num(row, "size_bytes"),
2000
+ failureReason: text(row, "failure_reason"),
2001
+ archivedAt: text(row, "deleted_at"),
2002
+ organizationId: asOrganizationId(requireText(row, "organization_id", operation))
2003
+ };
2004
+ }
2005
+ var SEARCH_SOURCES = [
2006
+ "title",
2007
+ "summary",
2008
+ "decision",
2009
+ "action_item",
2010
+ "note",
2011
+ "transcript"
2012
+ ];
2013
+ function projectSearchHit(row) {
2014
+ const operation = "meet.projectSearchHit";
2015
+ const source = text(row, "source");
2016
+ return {
2017
+ meetingId: asMeetingId(requireText(row, "meeting_id", operation)),
2018
+ meetingTitle: text(row, "meeting_title") ?? "Meeting",
2019
+ meetingSlug: requireText(row, "meeting_slug", operation),
2020
+ meetingAt: text(row, "meeting_at"),
2021
+ source: SEARCH_SOURCES.includes(source) ? source : "note",
2022
+ refId: requireText(row, "ref_id", operation),
2023
+ speaker: text(row, "speaker"),
2024
+ snippet: text(row, "snippet") ?? "",
2025
+ at: text(row, "said_at"),
2026
+ rank: num(row, "rank") ?? 0
2027
+ };
2028
+ }
2029
+ function snippetRuns(snippet) {
2030
+ const runs = [];
2031
+ const pattern = /⟦([^⟧]*)⟧/g;
2032
+ let last = 0;
2033
+ for (const match of snippet.matchAll(pattern)) {
2034
+ const start = match.index ?? 0;
2035
+ if (start > last) runs.push({ text: snippet.slice(last, start), hit: false });
2036
+ runs.push({ text: match[1] ?? "", hit: true });
2037
+ last = start + match[0].length;
2038
+ }
2039
+ if (last < snippet.length) runs.push({ text: snippet.slice(last), hit: false });
2040
+ return runs;
2041
+ }
1888
2042
 
1889
2043
  // src/core/room-events.ts
1890
2044
  var RECORDING_STATES = [
@@ -3053,6 +3207,7 @@ var EMPTY = {
3053
3207
  spotlight: null,
3054
3208
  moderationNotice: null,
3055
3209
  actionNotice: null,
3210
+ privateChatRequest: null,
3056
3211
  effects: DEFAULT_EFFECTS,
3057
3212
  transcript: [],
3058
3213
  notes: [],
@@ -3318,7 +3473,9 @@ function createMeetingSession(options) {
3318
3473
  return identity.userId;
3319
3474
  }
3320
3475
  function knockIdentity() {
3321
- return asParticipantIdentity(identity.userId ?? `guest:${identity.deviceId}`);
3476
+ return asParticipantIdentity(
3477
+ identity.userId !== null ? `user:${identity.userId}` : `guest:${identity.deviceId}`
3478
+ );
3322
3479
  }
3323
3480
  function requireMeeting(operation) {
3324
3481
  const meeting = store.snapshot().meeting;
@@ -3353,8 +3510,9 @@ function createMeetingSession(options) {
3353
3510
  verdict.admitted ? "admitted" : "denied",
3354
3511
  verdict.by === null ? null : verdict.by
3355
3512
  );
3356
- if (verdict.admitted && decided === knockIdentity()) {
3357
- options.onAdmitted?.();
3513
+ if (decided === knockIdentity()) {
3514
+ if (verdict.admitted) options.onAdmitted?.();
3515
+ else options.onDenied?.();
3358
3516
  }
3359
3517
  }
3360
3518
  },
@@ -3726,19 +3884,413 @@ function fileDownloadPath(fileId) {
3726
3884
  return `/files/${encodeURIComponent(fileId)}/download?inline=1`;
3727
3885
  }
3728
3886
 
3729
- // src/core/management.ts
3730
- async function refusal(response, operation) {
3731
- let body = null;
3887
+ // src/core/after.ts
3888
+ import { formatDurationMs } from "@ai-matrx/kit/format";
3889
+ function kindOf(person) {
3890
+ if (person.isAgent) return "assistant";
3891
+ if (person.role === "host") return "host";
3892
+ if (person.role === "cohost") return "cohost";
3893
+ if (person.role === "guest") return person.userId !== null ? "signed-in guest" : "guest";
3894
+ return "member";
3895
+ }
3896
+ function ms(iso) {
3897
+ if (iso === null) return null;
3898
+ const at = new Date(iso).getTime();
3899
+ return Number.isNaN(at) ? null : at;
3900
+ }
3901
+ function attendeeKey(person, names = {}) {
3902
+ if (person.userId !== null) return `user:${person.userId}`;
3903
+ if (person.isAgent) return `agent:${person.identity}`;
3904
+ const name = displayNameFor(names, person.identity, person.displayName).trim().toLowerCase();
3905
+ return name.length > 0 ? `guest-name:${name}` : `identity:${person.identity}`;
3906
+ }
3907
+ function uniqueAttendees(attendees, names = {}) {
3908
+ const byKey = /* @__PURE__ */ new Map();
3909
+ for (const person of attendees) {
3910
+ const key = attendeeKey(person, names);
3911
+ const kept = byKey.get(key);
3912
+ if (kept === void 0) {
3913
+ byKey.set(key, person);
3914
+ continue;
3915
+ }
3916
+ const keptJoin = ms(kept.joinedAt);
3917
+ const join = ms(person.joinedAt);
3918
+ if (join !== null && (keptJoin === null || join < keptJoin)) byKey.set(key, person);
3919
+ }
3920
+ return [...byKey.values()];
3921
+ }
3922
+ var KIND_RANK = {
3923
+ host: 0,
3924
+ cohost: 1,
3925
+ member: 2,
3926
+ "signed-in guest": 3,
3927
+ guest: 4,
3928
+ assistant: 5
3929
+ };
3930
+ function coveredMs(intervals) {
3931
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
3932
+ let total = 0;
3933
+ let from = -Infinity;
3934
+ let to = -Infinity;
3935
+ for (const [start, end] of sorted) {
3936
+ if (start > to) {
3937
+ if (to > from) total += to - from;
3938
+ from = start;
3939
+ to = end;
3940
+ } else if (end > to) {
3941
+ to = end;
3942
+ }
3943
+ }
3944
+ if (to > from) total += to - from;
3945
+ return total;
3946
+ }
3947
+ function attendanceReport(meeting, attendees, names = {}) {
3948
+ const end = ms(meeting.endedAt);
3949
+ const perJoin = attendees.map((person) => {
3950
+ const joined = ms(person.joinedAt);
3951
+ const left = ms(person.leftAt);
3952
+ const until = left ?? end;
3953
+ const duration = joined !== null && until !== null && until >= joined ? until - joined : null;
3954
+ return {
3955
+ id: person.id,
3956
+ identity: person.identity,
3957
+ name: displayNameFor(names, person.identity, person.displayName),
3958
+ userId: person.userId,
3959
+ kind: kindOf(person),
3960
+ joinedAt: person.joinedAt,
3961
+ leftAt: person.leftAt,
3962
+ durationMs: duration,
3963
+ durationToEnd: duration !== null && left === null,
3964
+ admission: person.admissionState,
3965
+ joins: 1
3966
+ };
3967
+ });
3968
+ const groups = /* @__PURE__ */ new Map();
3969
+ attendees.forEach((person, index) => {
3970
+ const key = attendeeKey(person, names);
3971
+ const group = groups.get(key) ?? { rows: [], people: [] };
3972
+ group.rows.push(perJoin[index]);
3973
+ group.people.push(person);
3974
+ groups.set(key, group);
3975
+ });
3976
+ const rows = [...groups.values()].map(({ rows: joins }) => {
3977
+ if (joins.length === 1) return joins[0];
3978
+ const came = joins.filter((row) => row.joinedAt !== null);
3979
+ const first = [...came].sort((a, b) => (ms(a.joinedAt) ?? 0) - (ms(b.joinedAt) ?? 0))[0] ?? joins[0];
3980
+ const kind = joins.reduce(
3981
+ (best, row) => KIND_RANK[row.kind] < KIND_RANK[best] ? row.kind : best,
3982
+ first.kind
3983
+ );
3984
+ const open = came.some((row) => row.leftAt === null);
3985
+ const lastLeave = came.reduce((latest, row) => {
3986
+ const left = ms(row.leftAt);
3987
+ return left !== null && (latest === null || left > latest) ? left : latest;
3988
+ }, null);
3989
+ const intervals = came.flatMap((row) => {
3990
+ const joined = ms(row.joinedAt);
3991
+ return joined !== null && row.durationMs !== null ? [[joined, joined + row.durationMs]] : [];
3992
+ });
3993
+ return {
3994
+ ...first,
3995
+ kind,
3996
+ leftAt: open ? null : lastLeave === null ? null : new Date(lastLeave).toISOString(),
3997
+ durationMs: intervals.length === 0 ? null : coveredMs(intervals),
3998
+ durationToEnd: came.some((row) => row.durationToEnd),
3999
+ joins: came.length === 0 ? joins.length : came.length
4000
+ };
4001
+ });
4002
+ const rank = KIND_RANK;
4003
+ return rows.sort((a, b) => {
4004
+ if (rank[a.kind] !== rank[b.kind]) return rank[a.kind] - rank[b.kind];
4005
+ return (ms(a.joinedAt) ?? Number.MAX_SAFE_INTEGER) - (ms(b.joinedAt) ?? Number.MAX_SAFE_INTEGER);
4006
+ });
4007
+ }
4008
+ function attendanceTotals(rows) {
4009
+ const humans = rows.filter((row) => row.kind !== "assistant");
4010
+ const attended = humans.filter((row) => row.joinedAt !== null);
4011
+ const timed = attended.filter((row) => row.durationMs !== null);
4012
+ return {
4013
+ people: attended.length,
4014
+ guests: attended.filter((row) => row.kind === "guest" || row.kind === "signed-in guest").length,
4015
+ neverAdmitted: humans.filter((row) => row.joinedAt === null).length,
4016
+ averageMs: timed.length === 0 ? null : Math.round(timed.reduce((sum, row) => sum + (row.durationMs ?? 0), 0) / timed.length)
4017
+ };
4018
+ }
4019
+ function csvCell(value) {
4020
+ if (value === null) return "";
4021
+ const text3 = String(value);
4022
+ return /[",\n\r]/.test(text3) ? `"${text3.replace(/"/g, '""')}"` : text3;
4023
+ }
4024
+ var ATTENDANCE_CSV_HEADER = [
4025
+ "Meeting",
4026
+ "Meeting started",
4027
+ "Name",
4028
+ "Attended as",
4029
+ "Joined",
4030
+ "Left",
4031
+ "Minutes in the meeting",
4032
+ "Admission"
4033
+ ];
4034
+ function attendanceCsv(reports) {
4035
+ const lines = [ATTENDANCE_CSV_HEADER.join(",")];
4036
+ for (const { meeting, rows } of reports) {
4037
+ for (const row of rows) {
4038
+ lines.push(
4039
+ [
4040
+ meeting.title,
4041
+ meeting.startedAt ?? meeting.scheduledFor,
4042
+ row.name,
4043
+ row.kind,
4044
+ row.joinedAt,
4045
+ row.leftAt,
4046
+ row.durationMs === null ? null : Math.round(row.durationMs / 6e3) / 10,
4047
+ row.admission
4048
+ ].map(csvCell).join(",")
4049
+ );
4050
+ }
4051
+ }
4052
+ return `${lines.join("\r\n")}\r
4053
+ `;
4054
+ }
4055
+ function recordingOffsetMs(at, recording) {
4056
+ if (recording === null || recording.state !== "available") return null;
4057
+ const start = ms(recording.startedAt);
4058
+ const moment = ms(at);
4059
+ if (start === null || moment === null || moment < start) return null;
4060
+ const end = ms(recording.endedAt);
4061
+ if (end !== null && moment > end) return null;
4062
+ return moment - start;
4063
+ }
4064
+ function clockPosition(offsetMs) {
4065
+ const total = Math.max(0, Math.floor(offsetMs / 1e3));
4066
+ const h = Math.floor(total / 3600);
4067
+ const m = Math.floor(total % 3600 / 60);
4068
+ const s = total % 60;
4069
+ const ss = String(s).padStart(2, "0");
4070
+ return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
4071
+ }
4072
+ function searchTranscript(segments, query, names = {}) {
4073
+ const needle = fold(query.trim());
4074
+ if (needle.length === 0) return [];
4075
+ const out = [];
4076
+ for (const segment of segments) {
4077
+ const hay = fold(segment.text);
4078
+ const ranges = [];
4079
+ let from = 0;
4080
+ for (; ; ) {
4081
+ const at = hay.indexOf(needle, from);
4082
+ if (at < 0) break;
4083
+ ranges.push([at, at + needle.length]);
4084
+ from = at + needle.length;
4085
+ }
4086
+ const speaker = fold(displayNameFor(names, segment.identity, segment.speaker));
4087
+ if (ranges.length > 0 || speaker.includes(needle)) {
4088
+ out.push({ segmentId: segment.id, ranges });
4089
+ }
4090
+ }
4091
+ return out;
4092
+ }
4093
+ function fold(value) {
4094
+ return value.normalize("NFD").replace(/[̀-ͯ]/g, (mark) => mark.length === 1 ? "" : mark).toLowerCase();
4095
+ }
4096
+ function clockOfDay(iso, options) {
4097
+ const at = new Date(iso);
4098
+ if (Number.isNaN(at.getTime())) return "";
4099
+ return at.toLocaleTimeString(options.locale, {
4100
+ hour: "numeric",
4101
+ minute: "2-digit",
4102
+ second: "2-digit",
4103
+ ...options.timeZone !== void 0 ? { timeZone: options.timeZone } : {}
4104
+ });
4105
+ }
4106
+ function vttStamp(offsetMs) {
4107
+ const total = Math.max(0, Math.round(offsetMs));
4108
+ const h = Math.floor(total / 36e5);
4109
+ const m = Math.floor(total % 36e5 / 6e4);
4110
+ const s = Math.floor(total % 6e4 / 1e3);
4111
+ const milli = total % 1e3;
4112
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(milli).padStart(3, "0")}`;
4113
+ }
4114
+ function transcriptDocument(bundle, format, options = {}) {
4115
+ const names = options.names ?? bundle.names;
4116
+ const lines = bundle.transcript.filter((line) => line.text.trim().length > 0);
4117
+ const speaker = (line) => displayNameFor(names, line.identity, line.speaker);
4118
+ if (format === "vtt") {
4119
+ const zeroIso = bundle.recording?.state === "available" && bundle.recording.startedAt !== null ? bundle.recording.startedAt : bundle.meeting.startedAt ?? lines[0]?.startedAt ?? null;
4120
+ const zero = ms(zeroIso) ?? 0;
4121
+ const cues = ["WEBVTT", ""];
4122
+ lines.forEach((line, index) => {
4123
+ const start = (ms(line.startedAt) ?? zero) - zero;
4124
+ if (start < 0) return;
4125
+ const next = lines[index + 1];
4126
+ const nextStart = next !== void 0 ? (ms(next.startedAt) ?? zero) - zero : start + 4e3;
4127
+ const end = Math.max(start + 500, Math.min(nextStart, start + 1e4));
4128
+ cues.push(`${vttStamp(start)} --> ${vttStamp(end)}`);
4129
+ cues.push(`<v ${speaker(line).replace(/[<>]/g, "")}>${line.text.replace(/-->/g, "->")}`);
4130
+ cues.push("");
4131
+ });
4132
+ return cues.join("\n");
4133
+ }
4134
+ const out = [];
4135
+ const title = bundle.meeting.title;
4136
+ out.push(format === "md" ? `# ${title} \u2014 transcript` : `${title} \u2014 transcript`);
4137
+ out.push("");
4138
+ let last = null;
4139
+ for (const line of lines) {
4140
+ if (line.identity !== last) {
4141
+ if (last !== null) out.push("");
4142
+ const who = speaker(line);
4143
+ const clock = clockOfDay(line.startedAt, options);
4144
+ out.push(format === "md" ? `**${who}** \xB7 ${clock}` : `${who} (${clock})`);
4145
+ last = line.identity;
4146
+ }
4147
+ out.push(line.text.trim());
4148
+ }
4149
+ if (lines.length === 0) out.push("No transcript was captured for this meeting.");
4150
+ return `${out.join("\n")}
4151
+ `;
4152
+ }
4153
+ function bullet(note) {
4154
+ const owner = note.assigneeDisplayName !== null ? ` \u2014 ${note.assigneeDisplayName}` : "";
4155
+ return `- ${note.text.trim()}${owner}`;
4156
+ }
4157
+ function notesDocument(bundle, options = {}) {
4158
+ const out = [`# ${bundle.meeting.title}`, ""];
4159
+ if (options.when) out.push(`_${options.when}_`, "");
4160
+ const people = uniqueAttendees(
4161
+ bundle.attendees.filter((person) => !person.isAgent && person.joinedAt !== null),
4162
+ bundle.names
4163
+ ).map((person) => displayNameFor(bundle.names, person.identity, person.displayName));
4164
+ if (people.length > 0) out.push(`**Attended:** ${people.join(", ")}`, "");
4165
+ out.push("## Summary", "", bundle.summary?.text.trim() || "No summary was written.", "");
4166
+ out.push("## Decisions", "");
4167
+ out.push(...bundle.decisions.length > 0 ? bundle.decisions.map(bullet) : ["None recorded."], "");
4168
+ out.push("## Action items", "");
4169
+ out.push(
4170
+ ...bundle.actionItems.length > 0 ? bundle.actionItems.map(bullet) : ["None recorded."],
4171
+ ""
4172
+ );
4173
+ if (options.link) out.push(`[Open the meeting record](${options.link})`, "");
4174
+ return out.join("\n");
4175
+ }
4176
+ function meetingLength(meeting) {
4177
+ const start = ms(meeting.startedAt);
4178
+ const end = ms(meeting.endedAt);
4179
+ if (start === null || end === null || end < start) return null;
4180
+ return formatDurationMs(end - start, { style: "long" });
4181
+ }
4182
+ async function loadMeetingChat(api, meetingId, roomToken = null) {
4183
+ const operation = "meet.loadMeetingChat";
4184
+ let response;
3732
4185
  try {
3733
- body = await response.json();
3734
- } catch {
3735
- body = null;
4186
+ response = await api.request({
4187
+ path: `${MEET_ROUTES.chat}?meeting_id=${encodeURIComponent(meetingId)}`,
4188
+ method: "GET",
4189
+ operation,
4190
+ requireAuth: roomToken === null,
4191
+ ...roomToken !== null ? { headers: { [ROOM_TOKEN_HEADER]: roomToken } } : {}
4192
+ });
4193
+ } catch (error) {
4194
+ return {
4195
+ messages: [],
4196
+ refusal: {
4197
+ message: `The chat could not be read (${error.message}).`,
4198
+ remedy: error.remedy ?? "Reload the page."
4199
+ }
4200
+ };
3736
4201
  }
3737
- const said = readServerRefusal(body);
3738
- return new MeetError(
3739
- response.status === 403 || response.status === 401 ? "forbidden" : response.status === 404 ? "not-found" : response.status === 409 ? "conflict" : "transport",
3740
- `${operation}: ${said?.reason ?? `the server answered ${response.status}`}`,
3741
- said?.remedy || "The meeting itself is saved \u2014 only the telling failed. Try again; the call is idempotent."
4202
+ if (!response.ok) {
4203
+ let body = null;
4204
+ try {
4205
+ body = await response.json();
4206
+ } catch {
4207
+ body = null;
4208
+ }
4209
+ const said = readServerRefusal(body);
4210
+ return {
4211
+ messages: [],
4212
+ refusal: {
4213
+ message: said?.reason ?? `The chat could not be read (the server answered ${response.status}).`,
4214
+ remedy: said?.remedy || "Reload the page."
4215
+ }
4216
+ };
4217
+ }
4218
+ const payload = await response.json();
4219
+ const rows = Array.isArray(payload.messages) ? payload.messages : [];
4220
+ return {
4221
+ messages: rows.map(
4222
+ (row) => typeof row === "object" && row !== null ? chatFromRow(row) : null
4223
+ ).filter((message) => message !== null),
4224
+ refusal: null
4225
+ };
4226
+ }
4227
+ function refusalOf(response, operation) {
4228
+ return refusalFromResponse(response, operation, "Nothing was sent. Try again.");
4229
+ }
4230
+ function asRecipient(row) {
4231
+ const sources = Array.isArray(row["sources"]) ? row["sources"] : [];
4232
+ return {
4233
+ key: String(row["key"] ?? ""),
4234
+ name: String(row["name"] ?? ""),
4235
+ email: typeof row["email"] === "string" ? row["email"] : null,
4236
+ userId: typeof row["user_id"] === "string" ? row["user_id"] : null,
4237
+ sources: sources.filter((s) => s === "invitee" || s === "attendee"),
4238
+ reachable: row["reachable"] !== false
4239
+ };
4240
+ }
4241
+ async function loadFollowUpDraft(api, meetingId) {
4242
+ const operation = "meet.loadFollowUpDraft";
4243
+ const response = await api.request({
4244
+ path: meetingPath(MEET_ROUTES.meetingFollowUp, meetingId),
4245
+ method: "GET",
4246
+ requireAuth: true,
4247
+ operation
4248
+ });
4249
+ if (!response.ok) throw await refusalOf(response, operation);
4250
+ const body = await response.json();
4251
+ const last = body["last_sent"];
4252
+ return {
4253
+ subject: String(body["subject"] ?? ""),
4254
+ body: String(body["body"] ?? ""),
4255
+ recipients: (Array.isArray(body["recipients"]) ? body["recipients"] : []).map(
4256
+ (row) => asRecipient(row)
4257
+ ),
4258
+ wrapUpWritten: body["wrap_up_written"] === true,
4259
+ lastSent: last && typeof last["at"] === "string" ? { at: last["at"], count: typeof last["count"] === "number" ? last["count"] : 0 } : null
4260
+ };
4261
+ }
4262
+ async function sendFollowUp(api, meetingId, input) {
4263
+ const operation = "meet.sendFollowUp";
4264
+ const response = await api.request({
4265
+ path: meetingPath(MEET_ROUTES.meetingFollowUp, meetingId),
4266
+ method: "POST",
4267
+ requireAuth: true,
4268
+ operation,
4269
+ body: {
4270
+ subject: input.subject,
4271
+ body: input.body,
4272
+ recipient_keys: [...input.recipientKeys],
4273
+ dry_run: input.dryRun === true
4274
+ }
4275
+ });
4276
+ if (!response.ok) throw await refusalOf(response, operation);
4277
+ const body = await response.json();
4278
+ return {
4279
+ dryRun: body["dry_run"] === true,
4280
+ queued: Array.isArray(body["queued"]) ? body["queued"] : [],
4281
+ skipped: Array.isArray(body["skipped"]) ? body["skipped"].map((row) => ({
4282
+ key: String(row["key"] ?? ""),
4283
+ reason: String(row["reason"] ?? "")
4284
+ })) : []
4285
+ };
4286
+ }
4287
+
4288
+ // src/core/management.ts
4289
+ function refusal(response, operation) {
4290
+ return refusalFromResponse(
4291
+ response,
4292
+ operation,
4293
+ "The meeting itself is saved \u2014 only the telling failed. Try again; the call is idempotent."
3742
4294
  );
3743
4295
  }
3744
4296
  function count(body, key) {
@@ -4064,6 +4616,10 @@ function createRoomEngine(options) {
4064
4616
  }
4065
4617
  },
4066
4618
  disconnected(reason) {
4619
+ if (current !== null) {
4620
+ tokens.invalidate(current.roomName);
4621
+ current = null;
4622
+ }
4067
4623
  store.patch({ phase: "left" });
4068
4624
  options.onLeft?.(reason);
4069
4625
  }
@@ -4366,19 +4922,8 @@ function createInRoomClient(options) {
4366
4922
  const token = options.resolveRoomToken();
4367
4923
  return token === null ? void 0 : { [ROOM_TOKEN_HEADER]: token };
4368
4924
  }
4369
- async function refusal2(response, operation, remedy) {
4370
- let body = null;
4371
- try {
4372
- body = await response.json();
4373
- } catch {
4374
- body = null;
4375
- }
4376
- const said = readServerRefusal(body);
4377
- return new MeetError(
4378
- response.status === 403 || response.status === 401 ? "forbidden" : response.status === 404 ? "not-found" : response.status === 409 ? "conflict" : "transport",
4379
- said?.reason ?? `${operation}: the server answered ${response.status}`,
4380
- said?.remedy !== void 0 && said.remedy.length > 0 ? said.remedy : remedy
4381
- );
4925
+ function refusal2(response, operation, remedy) {
4926
+ return refusalFromResponse(response, operation, remedy);
4382
4927
  }
4383
4928
  return {
4384
4929
  async moderate(request) {
@@ -4482,7 +5027,7 @@ function createInRoomClient(options) {
4482
5027
  }
4483
5028
 
4484
5029
  // src/core/record.ts
4485
- import { formatDurationMs } from "@ai-matrx/kit/format";
5030
+ import { formatDurationMs as formatDurationMs2 } from "@ai-matrx/kit/format";
4486
5031
  function activeWrapUpSummary(notes) {
4487
5032
  return notes.find((note) => note.role === "summary" && note.supersededBy === null) ?? null;
4488
5033
  }
@@ -4565,6 +5110,36 @@ function wasRefused(bundle, part) {
4565
5110
  function absenceSentence(bundle, part, absence) {
4566
5111
  return wasRefused(bundle, part) ? REFUSED_ABSENCE : absence;
4567
5112
  }
5113
+ function noteTakerMetadata(meeting) {
5114
+ const raw = meeting.metadata["note_taker"];
5115
+ return typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {};
5116
+ }
5117
+ var WRAP_UP_IN_PROGRESS = "The wrap-up is being written\u2026 The summary, the decisions and the action items are produced from the whole transcript once the meeting ends, which takes a moment longer than the meeting does.";
5118
+ var WRAP_UP_ABSENT = "No wrap-up was written for this meeting. A summary is produced from the transcript when the meeting ends and the note-taker was in the room.";
5119
+ function wrapUpStatus(bundle) {
5120
+ if (bundle.summary !== null) return { state: "written" };
5121
+ if (wasRefused(bundle, "notes")) return { state: "refused", message: REFUSED_ABSENCE };
5122
+ const noteTaker = noteTakerMetadata(bundle.meeting);
5123
+ const intelligenceRaw = noteTaker["intelligence"];
5124
+ const intelligence = typeof intelligenceRaw === "object" && intelligenceRaw !== null && !Array.isArray(intelligenceRaw) ? intelligenceRaw : {};
5125
+ const wrapUpRaw = intelligence["wrap_up"];
5126
+ const wrapUp = typeof wrapUpRaw === "object" && wrapUpRaw !== null && !Array.isArray(wrapUpRaw) ? wrapUpRaw : {};
5127
+ if (wrapUp["state"] === "failed") {
5128
+ const reason = wrapUp["reason"];
5129
+ return {
5130
+ state: "failed",
5131
+ // THE SERVER'S OWN SENTENCE, verbatim. It already names the remedy, and a
5132
+ // house sentence over it would hide the one thing worth reading.
5133
+ message: typeof reason === "string" && reason.trim().length > 0 ? reason.trim() : "The wrap-up pass failed and no summary was written. It is not retried automatically; a host can end the meeting again to re-queue it."
5134
+ };
5135
+ }
5136
+ const queued = typeof wrapUp["run_id"] === "string" || typeof intelligence["wrap_up_run_id"] === "string" || wrapUp["state"] === "queued" || wrapUp["state"] === "running";
5137
+ const attended = noteTaker["state"] === "ended" || noteTaker["state"] === "transcribing" || noteTaker["state"] === "handoff_pending";
5138
+ if (hasEnded(bundle.meeting) && (queued || attended)) {
5139
+ return { state: "in-progress", message: WRAP_UP_IN_PROGRESS, canRefresh: true };
5140
+ }
5141
+ return { state: "absent", message: WRAP_UP_ABSENT };
5142
+ }
4568
5143
  function groupTranscript(segments, names = {}) {
4569
5144
  const blocks = [];
4570
5145
  for (const segment of segments) {
@@ -4633,7 +5208,7 @@ function meetingWhen(meeting, locale) {
4633
5208
  const endedAt = new Date(meeting.endedAt);
4634
5209
  if (Number.isNaN(endedAt.getTime())) return when2;
4635
5210
  const elapsedMs = endedAt.getTime() - startedAt.getTime();
4636
- return `${when2} \xB7 ${formatDurationMs(elapsedMs, { style: "long" })}`;
5211
+ return `${when2} \xB7 ${formatDurationMs2(elapsedMs, { style: "long" })}`;
4637
5212
  }
4638
5213
  var GUEST_RECORD_ACCESS_LEVELS = [
4639
5214
  "none",
@@ -4682,6 +5257,37 @@ var NOT_SHARED = {
4682
5257
  recording: "The recording of this meeting is not shared with guests."
4683
5258
  };
4684
5259
  var NOT_SHARED_REMEDY = "The organization that held this meeting decided how much of its record guests can read. If you need more, ask whoever invited you \u2014 they can send it to you, or change the setting for this meeting.";
5260
+ var RESTRICTION_PART_NAME = {
5261
+ attendance: "attendance",
5262
+ notes: "notes and decisions",
5263
+ transcript: "word-for-word transcript",
5264
+ recording: "recording"
5265
+ };
5266
+ function joinPartNames(parts) {
5267
+ const names = parts.map((part) => RESTRICTION_PART_NAME[part]);
5268
+ if (names.length < 2) return names[0] ?? "this part of the record";
5269
+ if (names.length === 2) return `${names[0]} and ${names[1]}`;
5270
+ return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
5271
+ }
5272
+ function summarizeMeetingRecordRestrictions(restrictions) {
5273
+ const grouped = /* @__PURE__ */ new Map();
5274
+ for (const restriction of restrictions) {
5275
+ const rows = grouped.get(restriction.remedy) ?? [];
5276
+ rows.push(restriction);
5277
+ grouped.set(restriction.remedy, rows);
5278
+ }
5279
+ return [...grouped.values()].map((rows) => {
5280
+ if (rows.length === 1) {
5281
+ const row = rows[0];
5282
+ return { parts: [row.part], message: row.message, remedy: row.remedy };
5283
+ }
5284
+ return {
5285
+ parts: rows.map((row) => row.part),
5286
+ message: `Some parts of this meeting record are not available to this reader: ${joinPartNames(rows.map((row) => row.part))}.`,
5287
+ remedy: rows[0].remedy
5288
+ };
5289
+ });
5290
+ }
4685
5291
  async function loadGuestMeetingRecord(args) {
4686
5292
  const { api, repository, meetingId, meeting, roomToken } = args;
4687
5293
  if (roomToken === null) {
@@ -4813,6 +5419,7 @@ async function setGuestRecordAccess(args) {
4813
5419
  }
4814
5420
  var GUEST_RECORD_NOT_SHARED = NOT_SHARED;
4815
5421
  export {
5422
+ ATTENDANCE_CSV_HEADER,
4816
5423
  CALL_INVITE_KIND,
4817
5424
  CALL_INVITE_VERSION,
4818
5425
  CONSENT_NOTICE_ATTRIBUTE,
@@ -4854,6 +5461,7 @@ export {
4854
5461
  TRANSCRIPTION_ATTRIBUTES,
4855
5462
  TRANSCRIPTION_TOPIC,
4856
5463
  absenceSentence,
5464
+ activeWrapUpSummary,
4857
5465
  announceMeeting,
4858
5466
  applyRoomEvent,
4859
5467
  asCallId,
@@ -4862,6 +5470,10 @@ export {
4862
5470
  asParticipantIdentity,
4863
5471
  asRoomName,
4864
5472
  asUserId,
5473
+ attendanceCsv,
5474
+ attendanceReport,
5475
+ attendanceTotals,
5476
+ attendeeKey,
4865
5477
  callInviteAction,
4866
5478
  callInviteText,
4867
5479
  callStateLabel,
@@ -4870,6 +5482,7 @@ export {
4870
5482
  canTransition,
4871
5483
  captionSegment,
4872
5484
  changesPatch,
5485
+ clockPosition,
4873
5486
  computeStage,
4874
5487
  countLabel,
4875
5488
  createCallCenter,
@@ -4910,11 +5523,14 @@ export {
4910
5523
  learnName,
4911
5524
  learnNames,
4912
5525
  listenOnlyNotice,
5526
+ loadFollowUpDraft,
4913
5527
  loadGuestMeetingRecord,
5528
+ loadMeetingChat,
4914
5529
  loadMeetingRecord,
4915
5530
  mapConnectionState,
4916
5531
  mapQuality,
4917
5532
  meetingInviteAction,
5533
+ meetingLength,
4918
5534
  meetingLink,
4919
5535
  meetingPath,
4920
5536
  meetingSlug,
@@ -4929,16 +5545,19 @@ export {
4929
5545
  noteTakerLabel,
4930
5546
  noteTakerParticipant,
4931
5547
  noteTakerStatus,
5548
+ notesDocument,
4932
5549
  orderParticipants,
4933
5550
  parseRoomToken,
4934
5551
  participantSummary,
4935
5552
  playableRecordingFileId,
4936
5553
  projectInvitee,
5554
+ projectLibraryRecording,
4937
5555
  projectNote,
4938
5556
  projectOccurrence,
4939
5557
  projectOverride,
4940
5558
  projectParticipant,
4941
5559
  projectRsvpInvitation,
5560
+ projectSearchHit,
4942
5561
  projectSegment,
4943
5562
  projectUpcoming,
4944
5563
  pushMeetingToGoogleCalendar,
@@ -4946,13 +5565,21 @@ export {
4946
5565
  readableTranscript,
4947
5566
  recordingLabel,
4948
5567
  recordingNotice,
5568
+ recordingOffsetMs,
4949
5569
  roleOf,
4950
5570
  roomTopic,
5571
+ searchTranscript,
5572
+ sendFollowUp,
4951
5573
  setGuestRecordAccess,
4952
5574
  settingsPatch,
4953
5575
  snapshotParticipant,
5576
+ snippetRuns,
5577
+ summarizeMeetingRecordRestrictions,
5578
+ transcriptDocument,
4954
5579
  unavailableFrom,
5580
+ uniqueAttendees,
4955
5581
  userTopic,
4956
- wasRefused
5582
+ wasRefused,
5583
+ wrapUpStatus
4957
5584
  };
4958
5585
  //# sourceMappingURL=index.js.map